A virtual jailed shell environment for Go apps backed by an io/fs#FS.
1
fork

Configure Feed

Select the types of activity you want to include in your feed.

feat: add basic implementation of ls based on just-bash

Assisted-by: Claude Opus 4.7 via Claude Code
Signed-off-by: Xe Iaso <me@xeiaso.net>

Xe Iaso 4399ab67 cf09d9b0

+813
+515
command/internal/ls/ls.go
··· 1 + package ls 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "io/fs" 9 + "math" 10 + "path" 11 + "sort" 12 + "strconv" 13 + "strings" 14 + "time" 15 + 16 + "github.com/spf13/pflag" 17 + "mvdan.cc/sh/v3/interp" 18 + "tangled.org/xeiaso.net/kefka/command" 19 + ) 20 + 21 + type Impl struct{} 22 + 23 + func (Impl) Exec(ctx context.Context, ec *command.ExecContext, args []string) error { 24 + if ec == nil { 25 + return errors.New("ls: nil ExecContext") 26 + } 27 + if ec.FS == nil { 28 + return errors.New("ls: ExecContext has no filesystem") 29 + } 30 + 31 + var ( 32 + showAll bool 33 + showAlmostAll bool 34 + longFormat bool 35 + humanReadable bool 36 + recursive bool 37 + reverse bool 38 + sortBySize bool 39 + classifyFiles bool 40 + directoryOnly bool 41 + sortByTime bool 42 + onePerLine bool 43 + ) 44 + 45 + flagSet := pflag.NewFlagSet("ls", pflag.ContinueOnError) 46 + flagSet.SetOutput(ec.Stderr) 47 + flagSet.BoolVarP(&showAll, "all", "a", false, "do not ignore entries starting with .") 48 + flagSet.BoolVarP(&showAlmostAll, "almost-all", "A", false, "do not list . and ..") 49 + flagSet.BoolVarP(&longFormat, "long", "l", false, "use a long listing format") 50 + flagSet.BoolVarP(&humanReadable, "human-readable", "h", false, "with -l, print sizes like 1K 234M 2G etc.") 51 + flagSet.BoolVarP(&recursive, "recursive", "R", false, "list subdirectories recursively") 52 + flagSet.BoolVarP(&reverse, "reverse", "r", false, "reverse order while sorting") 53 + flagSet.BoolVarP(&sortBySize, "sort-size", "S", false, "sort by file size, largest first") 54 + flagSet.BoolVarP(&classifyFiles, "classify", "F", false, "append indicator (one of */=>@) to entries") 55 + flagSet.BoolVarP(&directoryOnly, "directory", "d", false, "list directories themselves, not their contents") 56 + flagSet.BoolVarP(&sortByTime, "sort-time", "t", false, "sort by time, newest first") 57 + flagSet.BoolVarP(&onePerLine, "one-per-line", "1", false, "list one file per line") 58 + 59 + if err := flagSet.Parse(args); err != nil { 60 + return errors.Join(err, interp.ExitStatus(2)) 61 + } 62 + _ = sortByTime 63 + _ = onePerLine 64 + 65 + paths := flagSet.Args() 66 + if len(paths) == 0 { 67 + paths = []string{"."} 68 + } 69 + 70 + showHeader := len(paths) > 1 71 + exitCode := 0 72 + var stdoutBuf, stderrBuf strings.Builder 73 + 74 + for i, p := range paths { 75 + if i > 0 && stdoutBuf.Len() > 0 && !strings.HasSuffix(stdoutBuf.String(), "\n\n") { 76 + stdoutBuf.WriteString("\n") 77 + } 78 + 79 + var ( 80 + out string 81 + errS string 82 + code int 83 + ) 84 + 85 + switch { 86 + case directoryOnly: 87 + out, errS, code = listDirectoryEntry(ec, p, longFormat, humanReadable, classifyFiles) 88 + case strings.ContainsAny(p, "*?["): 89 + out, errS, code = listGlob(ec, p, showAll, showAlmostAll, longFormat, reverse, humanReadable, sortBySize, classifyFiles) 90 + default: 91 + out, errS, code = listPath(ctx, ec, p, showAll, showAlmostAll, longFormat, recursive, showHeader, reverse, humanReadable, sortBySize, classifyFiles) 92 + } 93 + 94 + stdoutBuf.WriteString(out) 95 + stderrBuf.WriteString(errS) 96 + if code != 0 { 97 + exitCode = code 98 + } 99 + } 100 + 101 + if ec.Stdout != nil { 102 + io.WriteString(ec.Stdout, stdoutBuf.String()) 103 + } 104 + if ec.Stderr != nil { 105 + io.WriteString(ec.Stderr, stderrBuf.String()) 106 + } 107 + 108 + if exitCode != 0 { 109 + return interp.ExitStatus(uint8(exitCode)) 110 + } 111 + return nil 112 + } 113 + 114 + func resolvePath(ec *command.ExecContext, p string) string { 115 + dir := ec.Dir 116 + if dir == "" { 117 + dir = "." 118 + } 119 + if path.IsAbs(p) { 120 + p = strings.TrimPrefix(p, "/") 121 + if p == "" { 122 + return "." 123 + } 124 + return path.Clean(p) 125 + } 126 + joined := path.Join(dir, p) 127 + if joined == "" { 128 + return "." 129 + } 130 + return joined 131 + } 132 + 133 + func formatHumanSize(bytes int64) string { 134 + if bytes < 1024 { 135 + return strconv.FormatInt(bytes, 10) 136 + } 137 + if bytes < 1024*1024 { 138 + k := float64(bytes) / 1024 139 + if k < 10 { 140 + return fmt.Sprintf("%.1fK", k) 141 + } 142 + return fmt.Sprintf("%dK", int64(math.Round(k))) 143 + } 144 + if bytes < 1024*1024*1024 { 145 + m := float64(bytes) / (1024 * 1024) 146 + if m < 10 { 147 + return fmt.Sprintf("%.1fM", m) 148 + } 149 + return fmt.Sprintf("%dM", int64(math.Round(m))) 150 + } 151 + g := float64(bytes) / (1024 * 1024 * 1024) 152 + if g < 10 { 153 + return fmt.Sprintf("%.1fG", g) 154 + } 155 + return fmt.Sprintf("%dG", int64(math.Round(g))) 156 + } 157 + 158 + func formatDate(t time.Time) string { 159 + month := t.Month().String()[:3] 160 + day := fmt.Sprintf("%2d", t.Day()) 161 + sixMonthsAgo := time.Now().Add(-180 * 24 * time.Hour) 162 + if t.After(sixMonthsAgo) { 163 + return fmt.Sprintf("%s %s %02d:%02d", month, day, t.Hour(), t.Minute()) 164 + } 165 + return fmt.Sprintf("%s %s %d", month, day, t.Year()) 166 + } 167 + 168 + func classifySuffix(info fs.FileInfo) string { 169 + if info.IsDir() { 170 + return "/" 171 + } 172 + if info.Mode()&fs.ModeSymlink != 0 { 173 + return "@" 174 + } 175 + if info.Mode()&0o111 != 0 { 176 + return "*" 177 + } 178 + return "" 179 + } 180 + 181 + func lstatFS(fsys fs.FS, name string) (fs.FileInfo, error) { 182 + if r, ok := fsys.(fs.ReadLinkFS); ok { 183 + return r.Lstat(name) 184 + } 185 + return fs.Stat(fsys, name) 186 + } 187 + 188 + func padLeft(s string, n int) string { 189 + if len(s) >= n { 190 + return s 191 + } 192 + return strings.Repeat(" ", n-len(s)) + s 193 + } 194 + 195 + func longFormatLine(info fs.FileInfo, name, suffix string, humanReadable bool) string { 196 + mode := "-rw-r--r--" 197 + if info.IsDir() { 198 + mode = "drwxr-xr-x" 199 + } 200 + var sizeStr string 201 + if humanReadable { 202 + sizeStr = formatHumanSize(info.Size()) 203 + } else { 204 + sizeStr = strconv.FormatInt(info.Size(), 10) 205 + } 206 + sizeStr = padLeft(sizeStr, 5) 207 + return fmt.Sprintf("%s 1 user user %s %s %s%s", mode, sizeStr, formatDate(info.ModTime()), name, suffix) 208 + } 209 + 210 + func reverseStrings(s []string) { 211 + for i, j := 0, len(s)-1; i < j; i, j = i+1, j-1 { 212 + s[i], s[j] = s[j], s[i] 213 + } 214 + } 215 + 216 + func listDirectoryEntry(ec *command.ExecContext, p string, longFormat, humanReadable, classifyFiles bool) (string, string, int) { 217 + full := resolvePath(ec, p) 218 + info, err := fs.Stat(ec.FS, full) 219 + if err != nil { 220 + return "", fmt.Sprintf("ls: cannot access '%s': No such file or directory\n", p), 2 221 + } 222 + if longFormat { 223 + suffix := "" 224 + if classifyFiles { 225 + if li, lerr := lstatFS(ec.FS, full); lerr == nil { 226 + suffix = classifySuffix(li) 227 + } 228 + } else if info.IsDir() { 229 + suffix = "/" 230 + } 231 + return longFormatLine(info, p, suffix, humanReadable) + "\n", "", 0 232 + } 233 + suffix := "" 234 + if classifyFiles { 235 + if li, lerr := lstatFS(ec.FS, full); lerr == nil { 236 + suffix = classifySuffix(li) 237 + } 238 + } 239 + return p + suffix + "\n", "", 0 240 + } 241 + 242 + func listGlob(ec *command.ExecContext, pattern string, showAll, showAlmostAll, longFormat, reverse, humanReadable, sortBySize, classifyFiles bool) (string, string, int) { 243 + showHidden := showAll || showAlmostAll 244 + dir := ec.Dir 245 + if dir == "" { 246 + dir = "." 247 + } 248 + 249 + var fsPattern string 250 + switch { 251 + case path.IsAbs(pattern): 252 + fsPattern = strings.TrimPrefix(pattern, "/") 253 + if fsPattern == "" { 254 + fsPattern = "." 255 + } 256 + case dir == ".": 257 + fsPattern = pattern 258 + default: 259 + fsPattern = path.Join(dir, pattern) 260 + } 261 + 262 + matched, err := fs.Glob(ec.FS, fsPattern) 263 + if err != nil || len(matched) == 0 { 264 + return "", fmt.Sprintf("ls: %s: No such file or directory\n", pattern), 2 265 + } 266 + 267 + displayPaths := make([]string, 0, len(matched)) 268 + for _, m := range matched { 269 + display := m 270 + if dir != "." && strings.HasPrefix(m, dir+"/") { 271 + display = strings.TrimPrefix(m, dir+"/") 272 + } 273 + basename := path.Base(display) 274 + if !showHidden && strings.HasPrefix(basename, ".") { 275 + continue 276 + } 277 + displayPaths = append(displayPaths, display) 278 + } 279 + 280 + if len(displayPaths) == 0 { 281 + return "", fmt.Sprintf("ls: %s: No such file or directory\n", pattern), 2 282 + } 283 + 284 + if sortBySize { 285 + sizes := make(map[string]int64, len(displayPaths)) 286 + for _, m := range displayPaths { 287 + if info, e := fs.Stat(ec.FS, resolvePath(ec, m)); e == nil { 288 + sizes[m] = info.Size() 289 + } 290 + } 291 + sort.SliceStable(displayPaths, func(i, j int) bool { 292 + return sizes[displayPaths[i]] > sizes[displayPaths[j]] 293 + }) 294 + } else { 295 + sort.Strings(displayPaths) 296 + } 297 + if reverse { 298 + reverseStrings(displayPaths) 299 + } 300 + 301 + var out, errOut strings.Builder 302 + exitCode := 0 303 + 304 + if longFormat { 305 + for _, m := range displayPaths { 306 + full := resolvePath(ec, m) 307 + info, statErr := fs.Stat(ec.FS, full) 308 + if statErr != nil { 309 + fmt.Fprintf(&errOut, "ls: cannot access '%s': %v\n", m, statErr) 310 + exitCode = 2 311 + continue 312 + } 313 + suffix := "" 314 + if classifyFiles { 315 + if li, lerr := lstatFS(ec.FS, full); lerr == nil { 316 + suffix = classifySuffix(li) 317 + } 318 + } else if info.IsDir() { 319 + suffix = "/" 320 + } 321 + out.WriteString(longFormatLine(info, m, suffix, humanReadable)) 322 + out.WriteString("\n") 323 + } 324 + return out.String(), errOut.String(), exitCode 325 + } 326 + 327 + if classifyFiles { 328 + classified := make([]string, len(displayPaths)) 329 + for i, m := range displayPaths { 330 + full := resolvePath(ec, m) 331 + suffix := "" 332 + if li, lerr := lstatFS(ec.FS, full); lerr == nil { 333 + suffix = classifySuffix(li) 334 + } 335 + classified[i] = m + suffix 336 + } 337 + return strings.Join(classified, "\n") + "\n", "", 0 338 + } 339 + 340 + return strings.Join(displayPaths, "\n") + "\n", "", 0 341 + } 342 + 343 + func listPath(ctx context.Context, ec *command.ExecContext, p string, showAll, showAlmostAll, longFormat, recursive, showHeader, reverse, humanReadable, sortBySize, classifyFiles bool) (string, string, int) { 344 + showHidden := showAll || showAlmostAll 345 + full := resolvePath(ec, p) 346 + 347 + info, err := fs.Stat(ec.FS, full) 348 + if err != nil { 349 + return "", fmt.Sprintf("ls: %s: No such file or directory\n", p), 2 350 + } 351 + 352 + if !info.IsDir() { 353 + suffix := "" 354 + if classifyFiles { 355 + if li, lerr := lstatFS(ec.FS, full); lerr == nil { 356 + suffix = classifySuffix(li) 357 + } 358 + } 359 + if longFormat { 360 + return longFormatLine(info, p, suffix, humanReadable) + "\n", "", 0 361 + } 362 + return p + suffix + "\n", "", 0 363 + } 364 + 365 + dirEntries, err := fs.ReadDir(ec.FS, full) 366 + if err != nil { 367 + return "", fmt.Sprintf("ls: %s: %v\n", p, err), 2 368 + } 369 + 370 + names := make([]string, 0, len(dirEntries)) 371 + entryByName := make(map[string]fs.DirEntry, len(dirEntries)) 372 + for _, e := range dirEntries { 373 + name := e.Name() 374 + if !showHidden && strings.HasPrefix(name, ".") { 375 + continue 376 + } 377 + names = append(names, name) 378 + entryByName[name] = e 379 + } 380 + 381 + if sortBySize { 382 + sizes := make(map[string]int64, len(names)) 383 + for _, name := range names { 384 + if einfo, e := fs.Stat(ec.FS, path.Join(full, name)); e == nil { 385 + sizes[name] = einfo.Size() 386 + } 387 + } 388 + sort.SliceStable(names, func(i, j int) bool { 389 + return sizes[names[i]] > sizes[names[j]] 390 + }) 391 + } else { 392 + sort.Strings(names) 393 + } 394 + 395 + if showAll { 396 + names = append([]string{".", ".."}, names...) 397 + } 398 + 399 + if reverse { 400 + reverseStrings(names) 401 + } 402 + 403 + var out, errOut strings.Builder 404 + exitCode := 0 405 + 406 + if recursive || showHeader { 407 + out.WriteString(p) 408 + out.WriteString(":\n") 409 + } 410 + 411 + switch { 412 + case longFormat: 413 + fmt.Fprintf(&out, "total %d\n", len(names)) 414 + for _, name := range names { 415 + var entryPath string 416 + switch name { 417 + case ".": 418 + entryPath = full 419 + case "..": 420 + entryPath = path.Dir(full) 421 + if entryPath == "" { 422 + entryPath = "." 423 + } 424 + default: 425 + entryPath = path.Join(full, name) 426 + } 427 + einfo, errE := fs.Stat(ec.FS, entryPath) 428 + if errE != nil { 429 + fmt.Fprintf(&errOut, "ls: cannot access '%s': %v\n", name, errE) 430 + exitCode = 2 431 + continue 432 + } 433 + suffix := "" 434 + if classifyFiles { 435 + if li, lerr := lstatFS(ec.FS, entryPath); lerr == nil { 436 + suffix = classifySuffix(li) 437 + } 438 + } else if einfo.IsDir() { 439 + suffix = "/" 440 + } 441 + out.WriteString(longFormatLine(einfo, name, suffix, humanReadable)) 442 + out.WriteString("\n") 443 + } 444 + 445 + case classifyFiles: 446 + classified := make([]string, 0, len(names)) 447 + for _, name := range names { 448 + if name == "." || name == ".." { 449 + classified = append(classified, name+"/") 450 + continue 451 + } 452 + entryPath := path.Join(full, name) 453 + suffix := "" 454 + if li, lerr := lstatFS(ec.FS, entryPath); lerr == nil { 455 + suffix = classifySuffix(li) 456 + } 457 + classified = append(classified, name+suffix) 458 + } 459 + out.WriteString(strings.Join(classified, "\n")) 460 + if len(classified) > 0 { 461 + out.WriteString("\n") 462 + } 463 + 464 + default: 465 + out.WriteString(strings.Join(names, "\n")) 466 + if len(names) > 0 { 467 + out.WriteString("\n") 468 + } 469 + } 470 + 471 + if recursive { 472 + var subdirs []string 473 + for _, name := range names { 474 + if name == "." || name == ".." { 475 + continue 476 + } 477 + entry, ok := entryByName[name] 478 + isDir := false 479 + if ok { 480 + if entry.IsDir() { 481 + isDir = true 482 + } else if entry.Type()&fs.ModeSymlink != 0 { 483 + if einfo, e := fs.Stat(ec.FS, path.Join(full, name)); e == nil && einfo.IsDir() { 484 + isDir = true 485 + } 486 + } 487 + } 488 + if isDir { 489 + subdirs = append(subdirs, name) 490 + } 491 + } 492 + sort.Strings(subdirs) 493 + if reverse { 494 + reverseStrings(subdirs) 495 + } 496 + 497 + for _, name := range subdirs { 498 + var subPath string 499 + if p == "." { 500 + subPath = "./" + name 501 + } else { 502 + subPath = p + "/" + name 503 + } 504 + subOut, subErr, subCode := listPath(ctx, ec, subPath, showAll, showAlmostAll, longFormat, recursive, false, reverse, humanReadable, sortBySize, classifyFiles) 505 + out.WriteString("\n") 506 + out.WriteString(subOut) 507 + errOut.WriteString(subErr) 508 + if subCode != 0 { 509 + exitCode = subCode 510 + } 511 + } 512 + } 513 + 514 + return out.String(), errOut.String(), exitCode 515 + }
+298
command/internal/ls/ls_test.go
··· 1 + package ls 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "io/fs" 7 + "testing" 8 + "testing/fstest" 9 + "time" 10 + 11 + "tangled.org/xeiaso.net/kefka/command" 12 + ) 13 + 14 + // fixedTime is far enough in the past that formatDate always emits the 15 + // year-form ("Jan 15 2020"), keeping expected output stable as the 16 + // test clock advances. 17 + var fixedTime = time.Date(2020, 1, 15, 12, 0, 0, 0, time.UTC) 18 + 19 + func newTestFS() fstest.MapFS { 20 + return fstest.MapFS{ 21 + ".": {Mode: fs.ModeDir | 0o755, ModTime: fixedTime}, 22 + "alpha.txt": {Data: []byte("aaaa"), ModTime: fixedTime}, // 4 bytes 23 + "beta.txt": {Data: []byte("bbbbbbbb"), ModTime: fixedTime}, // 8 bytes 24 + "gamma.txt": {Data: []byte("cc"), ModTime: fixedTime}, // 2 bytes 25 + ".hidden": {Data: []byte("h"), ModTime: fixedTime}, // 1 byte hidden 26 + "script.sh": {Data: []byte("#!"), Mode: 0o755, ModTime: fixedTime}, // executable 27 + "huge.bin": {Data: bytes.Repeat([]byte("x"), 1500), ModTime: fixedTime}, 28 + "sub": {Mode: fs.ModeDir | 0o755, ModTime: fixedTime}, 29 + "sub/inner.txt": {Data: []byte("inner"), ModTime: fixedTime}, 30 + "sub/deeper": {Mode: fs.ModeDir | 0o755, ModTime: fixedTime}, 31 + "sub/deeper/leaf.txt": {Data: []byte("L"), ModTime: fixedTime}, 32 + } 33 + } 34 + 35 + func TestExec(t *testing.T) { 36 + tests := []struct { 37 + name string 38 + dir string 39 + args []string 40 + wantStdout string 41 + wantStderr string 42 + wantErr bool 43 + }{ 44 + { 45 + name: "default plain listing", 46 + args: []string{}, 47 + wantStdout: "alpha.txt\nbeta.txt\ngamma.txt\nhuge.bin\nscript.sh\nsub\n", 48 + }, 49 + { 50 + name: "show all includes dot entries and hidden files", 51 + args: []string{"-a"}, 52 + wantStdout: ".\n..\n.hidden\nalpha.txt\nbeta.txt\ngamma.txt\nhuge.bin\nscript.sh\nsub\n", 53 + }, 54 + { 55 + name: "almost all hides dot entries but shows hidden files", 56 + args: []string{"-A"}, 57 + wantStdout: ".hidden\nalpha.txt\nbeta.txt\ngamma.txt\nhuge.bin\nscript.sh\nsub\n", 58 + }, 59 + { 60 + name: "reverse alphabetic order", 61 + args: []string{"-r"}, 62 + wantStdout: "sub\nscript.sh\nhuge.bin\ngamma.txt\nbeta.txt\nalpha.txt\n", 63 + }, 64 + { 65 + name: "sort by size descending", 66 + args: []string{"-S"}, 67 + wantStdout: "huge.bin\nbeta.txt\nalpha.txt\ngamma.txt\nscript.sh\nsub\n", 68 + }, 69 + { 70 + name: "directory only short", 71 + args: []string{"-d", "sub"}, 72 + wantStdout: "sub\n", 73 + }, 74 + { 75 + name: "directory only long uses real stat time", 76 + args: []string{"-dl", "sub"}, 77 + wantStdout: "drwxr-xr-x 1 user user 0 Jan 15 2020 sub/\n", 78 + }, 79 + { 80 + name: "classify suffixes for dir and executable", 81 + args: []string{"-F"}, 82 + wantStdout: "alpha.txt\nbeta.txt\ngamma.txt\nhuge.bin\nscript.sh*\nsub/\n", 83 + }, 84 + { 85 + name: "long format", 86 + args: []string{"-l"}, 87 + wantStdout: "total 6\n" + 88 + "-rw-r--r-- 1 user user 4 Jan 15 2020 alpha.txt\n" + 89 + "-rw-r--r-- 1 user user 8 Jan 15 2020 beta.txt\n" + 90 + "-rw-r--r-- 1 user user 2 Jan 15 2020 gamma.txt\n" + 91 + "-rw-r--r-- 1 user user 1500 Jan 15 2020 huge.bin\n" + 92 + "-rw-r--r-- 1 user user 2 Jan 15 2020 script.sh\n" + 93 + "drwxr-xr-x 1 user user 0 Jan 15 2020 sub/\n", 94 + }, 95 + { 96 + name: "long human readable", 97 + args: []string{"-lh"}, 98 + wantStdout: "total 6\n" + 99 + "-rw-r--r-- 1 user user 4 Jan 15 2020 alpha.txt\n" + 100 + "-rw-r--r-- 1 user user 8 Jan 15 2020 beta.txt\n" + 101 + "-rw-r--r-- 1 user user 2 Jan 15 2020 gamma.txt\n" + 102 + "-rw-r--r-- 1 user user 1.5K Jan 15 2020 huge.bin\n" + 103 + "-rw-r--r-- 1 user user 2 Jan 15 2020 script.sh\n" + 104 + "drwxr-xr-x 1 user user 0 Jan 15 2020 sub/\n", 105 + }, 106 + { 107 + name: "long all uses real stat times for dot entries", 108 + args: []string{"-la"}, 109 + wantStdout: "total 9\n" + 110 + "drwxr-xr-x 1 user user 0 Jan 15 2020 ./\n" + 111 + "drwxr-xr-x 1 user user 0 Jan 15 2020 ../\n" + 112 + "-rw-r--r-- 1 user user 1 Jan 15 2020 .hidden\n" + 113 + "-rw-r--r-- 1 user user 4 Jan 15 2020 alpha.txt\n" + 114 + "-rw-r--r-- 1 user user 8 Jan 15 2020 beta.txt\n" + 115 + "-rw-r--r-- 1 user user 2 Jan 15 2020 gamma.txt\n" + 116 + "-rw-r--r-- 1 user user 1500 Jan 15 2020 huge.bin\n" + 117 + "-rw-r--r-- 1 user user 2 Jan 15 2020 script.sh\n" + 118 + "drwxr-xr-x 1 user user 0 Jan 15 2020 sub/\n", 119 + }, 120 + { 121 + name: "recursive descends into subdirectories", 122 + args: []string{"-R", "sub"}, 123 + wantStdout: "sub:\n" + 124 + "deeper\n" + 125 + "inner.txt\n" + 126 + "\n" + 127 + "sub/deeper:\n" + 128 + "leaf.txt\n", 129 + }, 130 + { 131 + name: "glob matches text files", 132 + args: []string{"*.txt"}, 133 + wantStdout: "alpha.txt\nbeta.txt\ngamma.txt\n", 134 + }, 135 + { 136 + name: "glob with no matches", 137 + args: []string{"*.nope"}, 138 + wantStderr: "ls: *.nope: No such file or directory\n", 139 + wantErr: true, 140 + }, 141 + { 142 + name: "missing path reports stderr and exit error", 143 + args: []string{"nope"}, 144 + wantStderr: "ls: nope: No such file or directory\n", 145 + wantErr: true, 146 + }, 147 + { 148 + name: "multiple paths get headers and blank separator", 149 + args: []string{"sub", "."}, 150 + wantStdout: "sub:\n" + 151 + "deeper\n" + 152 + "inner.txt\n" + 153 + "\n" + 154 + ".:\n" + 155 + "alpha.txt\n" + 156 + "beta.txt\n" + 157 + "gamma.txt\n" + 158 + "huge.bin\n" + 159 + "script.sh\n" + 160 + "sub\n", 161 + }, 162 + { 163 + name: "single file argument", 164 + args: []string{"alpha.txt"}, 165 + wantStdout: "alpha.txt\n", 166 + }, 167 + { 168 + name: "single file argument long", 169 + args: []string{"-l", "alpha.txt"}, 170 + wantStdout: "-rw-r--r-- 1 user user 4 Jan 15 2020 alpha.txt\n", 171 + }, 172 + { 173 + name: "ec.Dir scopes the listing", 174 + dir: "sub", 175 + args: []string{}, 176 + wantStdout: "deeper\ninner.txt\n", 177 + }, 178 + { 179 + name: "unknown flag returns error", 180 + args: []string{"--no-such-flag"}, 181 + wantErr: true, 182 + }, 183 + } 184 + 185 + for _, tc := range tests { 186 + t.Run(tc.name, func(t *testing.T) { 187 + var stdout, stderr bytes.Buffer 188 + dir := tc.dir 189 + if dir == "" { 190 + dir = "." 191 + } 192 + ec := &command.ExecContext{ 193 + Stdout: &stdout, 194 + Stderr: &stderr, 195 + Dir: dir, 196 + FS: newTestFS(), 197 + } 198 + err := Impl{}.Exec(context.Background(), ec, tc.args) 199 + if tc.wantErr && err == nil { 200 + t.Fatalf("expected error, got nil") 201 + } 202 + if !tc.wantErr && err != nil { 203 + t.Fatalf("unexpected error: %v", err) 204 + } 205 + if got := stdout.String(); got != tc.wantStdout { 206 + t.Errorf("stdout mismatch\nwant:\n%q\ngot:\n%q", tc.wantStdout, got) 207 + } 208 + if got := stderr.String(); got != tc.wantStderr { 209 + t.Errorf("stderr mismatch\nwant:\n%q\ngot:\n%q", tc.wantStderr, got) 210 + } 211 + }) 212 + } 213 + } 214 + 215 + func TestExec_NilContext(t *testing.T) { 216 + if err := (Impl{}).Exec(context.Background(), nil, nil); err == nil { 217 + t.Fatal("expected error for nil ExecContext") 218 + } 219 + } 220 + 221 + func TestExec_NoFS(t *testing.T) { 222 + ec := &command.ExecContext{ 223 + Stdout: &bytes.Buffer{}, 224 + Stderr: &bytes.Buffer{}, 225 + } 226 + if err := (Impl{}).Exec(context.Background(), ec, nil); err == nil { 227 + t.Fatal("expected error for missing filesystem") 228 + } 229 + } 230 + 231 + func TestFormatHumanSize(t *testing.T) { 232 + tests := []struct { 233 + bytes int64 234 + want string 235 + }{ 236 + {0, "0"}, 237 + {500, "500"}, 238 + {1023, "1023"}, 239 + {1024, "1.0K"}, 240 + {1536, "1.5K"}, 241 + {10 * 1024, "10K"}, 242 + {1024 * 1024, "1.0M"}, 243 + {1500 * 1024, "1.5M"}, 244 + {10 * 1024 * 1024, "10M"}, 245 + {1024 * 1024 * 1024, "1.0G"}, 246 + {int64(1.5 * 1024 * 1024 * 1024), "1.5G"}, 247 + } 248 + for _, tc := range tests { 249 + t.Run(tc.want, func(t *testing.T) { 250 + if got := formatHumanSize(tc.bytes); got != tc.want { 251 + t.Errorf("formatHumanSize(%d) = %q, want %q", tc.bytes, got, tc.want) 252 + } 253 + }) 254 + } 255 + } 256 + 257 + func TestFormatDate(t *testing.T) { 258 + now := time.Now() 259 + 260 + // Far in the past renders as month/day/year. 261 + old := time.Date(2020, 7, 4, 9, 30, 0, 0, time.UTC) 262 + if got, want := formatDate(old), "Jul 4 2020"; got != want { 263 + t.Errorf("formatDate(%v) = %q, want %q", old, got, want) 264 + } 265 + 266 + // Within the last 6 months renders as month/day/HH:MM. 267 + recent := now.Add(-3 * 24 * time.Hour) 268 + got := formatDate(recent) 269 + month := recent.Month().String()[:3] 270 + wantPrefix := month + " " 271 + if !bytes.HasPrefix([]byte(got), []byte(wantPrefix)) { 272 + t.Errorf("formatDate(%v) = %q, want prefix %q", recent, got, wantPrefix) 273 + } 274 + if len(got) != len("Jan 15 12:34") { 275 + t.Errorf("formatDate(%v) = %q, expected HH:MM form (len 12)", recent, got) 276 + } 277 + } 278 + 279 + func TestResolvePath(t *testing.T) { 280 + tests := []struct { 281 + dir, in, want string 282 + }{ 283 + {"", "foo", "foo"}, 284 + {".", "foo", "foo"}, 285 + {".", ".", "."}, 286 + {"sub", "foo", "sub/foo"}, 287 + {"sub", ".", "sub"}, 288 + {"sub", "../bar", "bar"}, 289 + {".", "/abs/path", "abs/path"}, 290 + {".", "/", "."}, 291 + } 292 + for _, tc := range tests { 293 + ec := &command.ExecContext{Dir: tc.dir} 294 + if got := resolvePath(ec, tc.in); got != tc.want { 295 + t.Errorf("resolvePath(dir=%q, in=%q) = %q, want %q", tc.dir, tc.in, got, tc.want) 296 + } 297 + } 298 + }