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(command): port stat from just-bash

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

+419
+169
command/internal/stat/stat.go
··· 1 + package stat 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "os" 9 + "path" 10 + "strings" 11 + "time" 12 + 13 + "github.com/pborman/getopt/v2" 14 + "mvdan.cc/sh/v3/interp" 15 + "tangled.org/xeiaso.net/kefka/command" 16 + ) 17 + 18 + type Impl struct{} 19 + 20 + var formatTime = func(t time.Time) string { 21 + return t.UTC().Format("2006-01-02T15:04:05.000Z") 22 + } 23 + 24 + func (Impl) Exec(ctx context.Context, ec *command.ExecContext, args []string) error { 25 + if ec == nil { 26 + return errors.New("stat: nil ExecContext") 27 + } 28 + if ec.FS == nil { 29 + return errors.New("stat: ExecContext has no filesystem") 30 + } 31 + 32 + stdout := ec.Stdout 33 + if stdout == nil { 34 + stdout = io.Discard 35 + } 36 + stderr := ec.Stderr 37 + if stderr == nil { 38 + stderr = io.Discard 39 + } 40 + 41 + set := getopt.New() 42 + set.SetProgram("stat") 43 + set.SetParameters("FILE...") 44 + 45 + usage := func() { 46 + fmt.Fprint(stderr, "Usage: stat [OPTION]... FILE...\n") 47 + fmt.Fprint(stderr, "Display file or file system status.\n\n") 48 + fmt.Fprint(stderr, " -c FORMAT use the specified FORMAT instead of the default\n") 49 + fmt.Fprint(stderr, " --help display this help and exit\n") 50 + } 51 + set.SetUsage(usage) 52 + 53 + format := set.String('c', "", "use the specified FORMAT instead of the default") 54 + helpFlag := set.BoolLong("help", 0, "display this help and exit") 55 + 56 + if err := set.Getopt(append([]string{"stat"}, args...), nil); err != nil { 57 + fmt.Fprintf(stderr, "stat: %s\n", err) 58 + usage() 59 + return interp.ExitStatus(1) 60 + } 61 + 62 + if *helpFlag { 63 + usage() 64 + return nil 65 + } 66 + 67 + files := set.Args() 68 + if len(files) == 0 { 69 + fmt.Fprint(stderr, "stat: missing operand\n") 70 + return interp.ExitStatus(1) 71 + } 72 + 73 + hasError := false 74 + for _, file := range files { 75 + full := resolvePath(ec, file) 76 + info, err := ec.FS.Stat(full) 77 + if err != nil { 78 + fmt.Fprintf(stderr, "stat: cannot stat '%s': No such file or directory\n", file) 79 + hasError = true 80 + continue 81 + } 82 + 83 + if *format != "" { 84 + io.WriteString(stdout, applyFormat(*format, file, info)+"\n") 85 + continue 86 + } 87 + 88 + mode := info.Mode().Perm() 89 + modeOctal := fmt.Sprintf("%04o", uint32(mode)) 90 + modeStr := formatMode(mode, info.IsDir()) 91 + size := info.Size() 92 + blocks := (size + 511) / 512 93 + fmt.Fprintf(stdout, " File: %s\n", file) 94 + fmt.Fprintf(stdout, " Size: %d\t\tBlocks: %d\n", size, blocks) 95 + fmt.Fprintf(stdout, "Access: (%s/%s)\n", modeOctal, modeStr) 96 + fmt.Fprintf(stdout, "Modify: %s\n", formatTime(info.ModTime())) 97 + } 98 + 99 + if hasError { 100 + return interp.ExitStatus(1) 101 + } 102 + return nil 103 + } 104 + 105 + func applyFormat(format, file string, info os.FileInfo) string { 106 + mode := info.Mode().Perm() 107 + modeOctal := fmt.Sprintf("%o", uint32(mode)) 108 + modeStr := formatMode(mode, info.IsDir()) 109 + fileType := "regular file" 110 + if info.IsDir() { 111 + fileType = "directory" 112 + } 113 + out := format 114 + out = strings.ReplaceAll(out, "%n", file) 115 + out = strings.ReplaceAll(out, "%N", "'"+file+"'") 116 + out = strings.ReplaceAll(out, "%s", fmt.Sprintf("%d", info.Size())) 117 + out = strings.ReplaceAll(out, "%F", fileType) 118 + out = strings.ReplaceAll(out, "%a", modeOctal) 119 + out = strings.ReplaceAll(out, "%A", modeStr) 120 + out = strings.ReplaceAll(out, "%u", "1000") 121 + out = strings.ReplaceAll(out, "%U", "user") 122 + out = strings.ReplaceAll(out, "%g", "1000") 123 + out = strings.ReplaceAll(out, "%G", "group") 124 + return out 125 + } 126 + 127 + func formatMode(mode os.FileMode, isDir bool) string { 128 + var b strings.Builder 129 + if isDir { 130 + b.WriteByte('d') 131 + } else { 132 + b.WriteByte('-') 133 + } 134 + bits := []struct { 135 + bit os.FileMode 136 + char byte 137 + }{ 138 + {0o400, 'r'}, {0o200, 'w'}, {0o100, 'x'}, 139 + {0o040, 'r'}, {0o020, 'w'}, {0o010, 'x'}, 140 + {0o004, 'r'}, {0o002, 'w'}, {0o001, 'x'}, 141 + } 142 + for _, bp := range bits { 143 + if mode&bp.bit != 0 { 144 + b.WriteByte(bp.char) 145 + } else { 146 + b.WriteByte('-') 147 + } 148 + } 149 + return b.String() 150 + } 151 + 152 + func resolvePath(ec *command.ExecContext, p string) string { 153 + dir := ec.Dir 154 + if dir == "" { 155 + dir = "." 156 + } 157 + if path.IsAbs(p) { 158 + p = strings.TrimPrefix(p, "/") 159 + if p == "" { 160 + return "." 161 + } 162 + return path.Clean(p) 163 + } 164 + joined := path.Join(dir, p) 165 + if joined == "" { 166 + return "." 167 + } 168 + return joined 169 + }
+250
command/internal/stat/stat_test.go
··· 1 + package stat 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "os" 7 + "strings" 8 + "testing" 9 + "time" 10 + 11 + "github.com/go-git/go-billy/v5" 12 + "github.com/go-git/go-billy/v5/memfs" 13 + "tangled.org/xeiaso.net/kefka/command" 14 + ) 15 + 16 + func newFS(t *testing.T) billy.Filesystem { 17 + t.Helper() 18 + fs := memfs.New() 19 + write := func(name string, data []byte, perm os.FileMode) { 20 + f, err := fs.OpenFile(name, os.O_CREATE|os.O_WRONLY, perm) 21 + if err != nil { 22 + t.Fatal(err) 23 + } 24 + f.Write(data) 25 + f.Close() 26 + } 27 + write("hello.txt", []byte("hello"), 0o644) 28 + write("script.sh", bytes.Repeat([]byte("x"), 600), 0o755) 29 + if err := fs.MkdirAll("dir", 0o755); err != nil { 30 + t.Fatal(err) 31 + } 32 + return fs 33 + } 34 + 35 + func withFixedTime(t *testing.T) { 36 + t.Helper() 37 + prev := formatTime 38 + formatTime = func(time.Time) string { return "2024-01-15T10:30:45.123Z" } 39 + t.Cleanup(func() { formatTime = prev }) 40 + } 41 + 42 + func run(t *testing.T, args []string, fs billy.Filesystem) (string, string, error) { 43 + t.Helper() 44 + var stdout, stderr bytes.Buffer 45 + ec := &command.ExecContext{ 46 + Stdout: &stdout, 47 + Stderr: &stderr, 48 + Dir: ".", 49 + FS: fs, 50 + } 51 + err := Impl{}.Exec(context.Background(), ec, args) 52 + return stdout.String(), stderr.String(), err 53 + } 54 + 55 + func TestStat(t *testing.T) { 56 + tests := []struct { 57 + name string 58 + args []string 59 + wantStdout string 60 + wantStderr string 61 + wantErr bool 62 + }{ 63 + { 64 + name: "default format on regular file", 65 + args: []string{"hello.txt"}, 66 + wantStdout: " File: hello.txt\n" + 67 + " Size: 5\t\tBlocks: 1\n" + 68 + "Access: (0644/-rw-r--r--)\n" + 69 + "Modify: 2024-01-15T10:30:45.123Z\n", 70 + }, 71 + { 72 + name: "default format on directory", 73 + args: []string{"dir"}, 74 + wantStdout: " File: dir\n" + 75 + " Size: 0\t\tBlocks: 0\n" + 76 + "Access: (0755/drwxr-xr-x)\n" + 77 + "Modify: 2024-01-15T10:30:45.123Z\n", 78 + }, 79 + { 80 + name: "default format computes blocks via ceiling", 81 + args: []string{"script.sh"}, 82 + wantStdout: " File: script.sh\n" + 83 + " Size: 600\t\tBlocks: 2\n" + 84 + "Access: (0755/-rwxr-xr-x)\n" + 85 + "Modify: 2024-01-15T10:30:45.123Z\n", 86 + }, 87 + { 88 + name: "custom format %n prints file name", 89 + args: []string{"-c", "%n", "hello.txt"}, 90 + wantStdout: "hello.txt\n", 91 + }, 92 + { 93 + name: "custom format %N quotes file name", 94 + args: []string{"-c", "%N", "hello.txt"}, 95 + wantStdout: "'hello.txt'\n", 96 + }, 97 + { 98 + name: "custom format %s prints size", 99 + args: []string{"-c", "%s", "hello.txt"}, 100 + wantStdout: "5\n", 101 + }, 102 + { 103 + name: "custom format %F regular file", 104 + args: []string{"-c", "%F", "hello.txt"}, 105 + wantStdout: "regular file\n", 106 + }, 107 + { 108 + name: "custom format %F directory", 109 + args: []string{"-c", "%F", "dir"}, 110 + wantStdout: "directory\n", 111 + }, 112 + { 113 + name: "custom format %a octal mode no padding", 114 + args: []string{"-c", "%a", "hello.txt"}, 115 + wantStdout: "644\n", 116 + }, 117 + { 118 + name: "custom format %A symbolic mode", 119 + args: []string{"-c", "%A", "hello.txt"}, 120 + wantStdout: "-rw-r--r--\n", 121 + }, 122 + { 123 + name: "custom format owner placeholders", 124 + args: []string{"-c", "%u %U %g %G", "hello.txt"}, 125 + wantStdout: "1000 user 1000 group\n", 126 + }, 127 + { 128 + name: "custom format combined", 129 + args: []string{"-c", "%n %s %A", "hello.txt"}, 130 + wantStdout: "hello.txt 5 -rw-r--r--\n", 131 + }, 132 + { 133 + name: "long form -c with equals", 134 + args: []string{"-c%a", "hello.txt"}, 135 + wantStdout: "644\n", 136 + }, 137 + { 138 + name: "multiple files in default format", 139 + args: []string{"hello.txt", "dir"}, 140 + wantStdout: " File: hello.txt\n" + 141 + " Size: 5\t\tBlocks: 1\n" + 142 + "Access: (0644/-rw-r--r--)\n" + 143 + "Modify: 2024-01-15T10:30:45.123Z\n" + 144 + " File: dir\n" + 145 + " Size: 0\t\tBlocks: 0\n" + 146 + "Access: (0755/drwxr-xr-x)\n" + 147 + "Modify: 2024-01-15T10:30:45.123Z\n", 148 + }, 149 + { 150 + name: "missing operand reports error", 151 + args: nil, 152 + wantStderr: "stat: missing operand\n", 153 + wantErr: true, 154 + }, 155 + { 156 + name: "missing file reports error and continues", 157 + args: []string{"nope.txt"}, 158 + wantStderr: "stat: cannot stat 'nope.txt': No such file or directory\n", 159 + wantErr: true, 160 + }, 161 + { 162 + name: "missing file mixed with present file", 163 + args: []string{"hello.txt", "nope.txt"}, 164 + wantStdout: " File: hello.txt\n" + 165 + " Size: 5\t\tBlocks: 1\n" + 166 + "Access: (0644/-rw-r--r--)\n" + 167 + "Modify: 2024-01-15T10:30:45.123Z\n", 168 + wantStderr: "stat: cannot stat 'nope.txt': No such file or directory\n", 169 + wantErr: true, 170 + }, 171 + { 172 + name: "unknown flag returns error", 173 + args: []string{"-Z", "hello.txt"}, 174 + wantErr: true, 175 + }, 176 + } 177 + 178 + for _, tt := range tests { 179 + t.Run(tt.name, func(t *testing.T) { 180 + withFixedTime(t) 181 + stdout, stderr, err := run(t, tt.args, newFS(t)) 182 + if tt.wantErr && err == nil { 183 + t.Fatalf("expected error, got nil; stdout=%q stderr=%q", stdout, stderr) 184 + } 185 + if !tt.wantErr && err != nil { 186 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 187 + } 188 + if stdout != tt.wantStdout { 189 + t.Errorf("stdout mismatch\n got: %q\nwant: %q", stdout, tt.wantStdout) 190 + } 191 + if tt.wantStderr != "" && !strings.Contains(stderr, tt.wantStderr) { 192 + t.Errorf("stderr = %q, want substring %q", stderr, tt.wantStderr) 193 + } 194 + }) 195 + } 196 + } 197 + 198 + func TestHelp(t *testing.T) { 199 + stdout, stderr, err := run(t, []string{"--help"}, newFS(t)) 200 + if err != nil { 201 + t.Fatalf("unexpected error: %v", err) 202 + } 203 + if stdout != "" { 204 + t.Errorf("expected empty stdout, got %q", stdout) 205 + } 206 + if !strings.Contains(stderr, "Usage: stat [OPTION]... FILE...") { 207 + t.Errorf("usage line missing from stderr: %q", stderr) 208 + } 209 + if !strings.Contains(stderr, "-c FORMAT") { 210 + t.Errorf("-c flag missing from help: %q", stderr) 211 + } 212 + } 213 + 214 + func TestExec_NilContext(t *testing.T) { 215 + if err := (Impl{}).Exec(context.Background(), nil, nil); err == nil { 216 + t.Fatal("expected error for nil ExecContext") 217 + } 218 + } 219 + 220 + func TestExec_NoFS(t *testing.T) { 221 + ec := &command.ExecContext{ 222 + Stdout: &bytes.Buffer{}, 223 + Stderr: &bytes.Buffer{}, 224 + } 225 + if err := (Impl{}).Exec(context.Background(), ec, []string{"hello.txt"}); err == nil { 226 + t.Fatal("expected error for missing filesystem") 227 + } 228 + } 229 + 230 + func TestFormatMode(t *testing.T) { 231 + tests := []struct { 232 + name string 233 + mode os.FileMode 234 + isDir bool 235 + want string 236 + }{ 237 + {"regular 644", 0o644, false, "-rw-r--r--"}, 238 + {"regular 755", 0o755, false, "-rwxr-xr-x"}, 239 + {"directory 755", 0o755, true, "drwxr-xr-x"}, 240 + {"none", 0o000, false, "----------"}, 241 + {"all", 0o777, false, "-rwxrwxrwx"}, 242 + } 243 + for _, tt := range tests { 244 + t.Run(tt.name, func(t *testing.T) { 245 + if got := formatMode(tt.mode, tt.isDir); got != tt.want { 246 + t.Errorf("formatMode(%o, %v) = %q, want %q", tt.mode, tt.isDir, got, tt.want) 247 + } 248 + }) 249 + } 250 + }