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 uniq from just-bash

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

Xe Iaso 8f14853a 0c4f40f0

+404
+198
command/internal/uniq/uniq.go
··· 1 + package uniq 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "path" 9 + "strings" 10 + 11 + "github.com/pborman/getopt/v2" 12 + "mvdan.cc/sh/v3/interp" 13 + "tangled.org/xeiaso.net/kefka/command" 14 + ) 15 + 16 + type Impl struct{} 17 + 18 + func (Impl) Exec(_ context.Context, ec *command.ExecContext, args []string) error { 19 + if ec == nil { 20 + return errors.New("uniq: nil ExecContext") 21 + } 22 + 23 + stdout := ec.Stdout 24 + if stdout == nil { 25 + stdout = io.Discard 26 + } 27 + stderr := ec.Stderr 28 + if stderr == nil { 29 + stderr = io.Discard 30 + } 31 + 32 + set := getopt.New() 33 + set.SetProgram("uniq") 34 + set.SetParameters("[INPUT [OUTPUT]]") 35 + 36 + usage := func() { 37 + fmt.Fprint(stderr, "Usage: uniq [OPTION]... [INPUT [OUTPUT]]\n") 38 + fmt.Fprint(stderr, "report or omit repeated lines\n\n") 39 + fmt.Fprint(stderr, " -c, --count prefix lines by the number of occurrences\n") 40 + fmt.Fprint(stderr, " -d, --repeated only print duplicate lines\n") 41 + fmt.Fprint(stderr, " -i, --ignore-case ignore case when comparing\n") 42 + fmt.Fprint(stderr, " -u, --unique only print unique lines\n") 43 + fmt.Fprint(stderr, " --help display this help and exit\n") 44 + } 45 + set.SetUsage(usage) 46 + 47 + count := set.BoolLong("count", 'c', "prefix lines by the number of occurrences") 48 + duplicatesOnly := set.BoolLong("repeated", 'd', "only print duplicate lines") 49 + uniqueOnly := set.BoolLong("unique", 'u', "only print unique lines") 50 + ignoreCase := set.BoolLong("ignore-case", 'i', "ignore case when comparing") 51 + help := set.BoolLong("help", 0, "display this help and exit") 52 + 53 + if err := set.Getopt(append([]string{"uniq"}, args...), nil); err != nil { 54 + fmt.Fprintf(stderr, "uniq: %s\n", err) 55 + usage() 56 + return interp.ExitStatus(1) 57 + } 58 + if *help { 59 + usage() 60 + return nil 61 + } 62 + 63 + files := set.Args() 64 + content, err := readAndConcat(ec, files, stderr) 65 + if err != nil { 66 + return err 67 + } 68 + 69 + io.WriteString(stdout, processUniq(content, *count, *duplicatesOnly, *uniqueOnly, *ignoreCase)) 70 + return nil 71 + } 72 + 73 + func processUniq(content string, count, duplicatesOnly, uniqueOnly, ignoreCase bool) string { 74 + lines := strings.Split(content, "\n") 75 + if len(lines) > 0 && lines[len(lines)-1] == "" { 76 + lines = lines[:len(lines)-1] 77 + } 78 + if len(lines) == 0 { 79 + return "" 80 + } 81 + 82 + type entry struct { 83 + line string 84 + count int 85 + } 86 + 87 + equal := func(a, b string) bool { 88 + if ignoreCase { 89 + return strings.EqualFold(a, b) 90 + } 91 + return a == b 92 + } 93 + 94 + result := make([]entry, 0, len(lines)) 95 + current := lines[0] 96 + currentCount := 1 97 + for i := 1; i < len(lines); i++ { 98 + if equal(lines[i], current) { 99 + currentCount++ 100 + continue 101 + } 102 + result = append(result, entry{line: current, count: currentCount}) 103 + current = lines[i] 104 + currentCount = 1 105 + } 106 + result = append(result, entry{line: current, count: currentCount}) 107 + 108 + var out strings.Builder 109 + for _, e := range result { 110 + switch { 111 + case duplicatesOnly && e.count <= 1: 112 + continue 113 + case uniqueOnly && e.count != 1: 114 + continue 115 + } 116 + if count { 117 + fmt.Fprintf(&out, "%4d %s\n", e.count, e.line) 118 + } else { 119 + out.WriteString(e.line) 120 + out.WriteByte('\n') 121 + } 122 + } 123 + return out.String() 124 + } 125 + 126 + func readAndConcat(ec *command.ExecContext, files []string, stderr io.Writer) (string, error) { 127 + if len(files) == 0 { 128 + return readStdin(ec) 129 + } 130 + 131 + var b strings.Builder 132 + for _, f := range files { 133 + if f == "-" { 134 + data, err := readStdin(ec) 135 + if err != nil { 136 + return "", err 137 + } 138 + b.WriteString(data) 139 + continue 140 + } 141 + data, err := readFile(ec, f, stderr) 142 + if err != nil { 143 + return "", err 144 + } 145 + b.WriteString(data) 146 + } 147 + return b.String(), nil 148 + } 149 + 150 + func readStdin(ec *command.ExecContext) (string, error) { 151 + if ec.Stdin == nil { 152 + return "", nil 153 + } 154 + data, err := io.ReadAll(ec.Stdin) 155 + if err != nil { 156 + return "", interp.ExitStatus(1) 157 + } 158 + return string(data), nil 159 + } 160 + 161 + func readFile(ec *command.ExecContext, file string, stderr io.Writer) (string, error) { 162 + if ec.FS == nil { 163 + fmt.Fprintf(stderr, "uniq: %s: No such file or directory\n", file) 164 + return "", interp.ExitStatus(1) 165 + } 166 + full := resolvePath(ec, file) 167 + f, err := ec.FS.Open(full) 168 + if err != nil { 169 + fmt.Fprintf(stderr, "uniq: %s: No such file or directory\n", file) 170 + return "", interp.ExitStatus(1) 171 + } 172 + data, err := io.ReadAll(f) 173 + f.Close() 174 + if err != nil { 175 + fmt.Fprintf(stderr, "uniq: %s: %v\n", file, err) 176 + return "", interp.ExitStatus(1) 177 + } 178 + return string(data), nil 179 + } 180 + 181 + func resolvePath(ec *command.ExecContext, p string) string { 182 + dir := ec.Dir 183 + if dir == "" { 184 + dir = "." 185 + } 186 + if path.IsAbs(p) { 187 + p = strings.TrimPrefix(p, "/") 188 + if p == "" { 189 + return "." 190 + } 191 + return path.Clean(p) 192 + } 193 + joined := path.Join(dir, p) 194 + if joined == "" { 195 + return "." 196 + } 197 + return joined 198 + }
+206
command/internal/uniq/uniq_test.go
··· 1 + package uniq 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "os" 7 + "strings" 8 + "testing" 9 + 10 + "github.com/go-git/go-billy/v5" 11 + "github.com/go-git/go-billy/v5/memfs" 12 + "tangled.org/xeiaso.net/kefka/command" 13 + ) 14 + 15 + func newFS(t *testing.T) billy.Filesystem { 16 + t.Helper() 17 + fs := memfs.New() 18 + write := func(name string, data []byte) { 19 + f, err := fs.OpenFile(name, os.O_CREATE|os.O_WRONLY, 0o644) 20 + if err != nil { 21 + t.Fatal(err) 22 + } 23 + f.Write(data) 24 + f.Close() 25 + } 26 + write("dups.txt", []byte("a\na\nb\nc\nc\nc\nd\n")) 27 + write("mixed.txt", []byte("Apple\napple\nAPPLE\nbanana\n")) 28 + write("part1.txt", []byte("x\nx\n")) 29 + write("part2.txt", []byte("x\ny\n")) 30 + return fs 31 + } 32 + 33 + func run(t *testing.T, args []string, stdin string, fs billy.Filesystem) (string, string, error) { 34 + t.Helper() 35 + var stdout, stderr bytes.Buffer 36 + ec := &command.ExecContext{ 37 + Stdin: strings.NewReader(stdin), 38 + Stdout: &stdout, 39 + Stderr: &stderr, 40 + Dir: ".", 41 + FS: fs, 42 + } 43 + err := Impl{}.Exec(context.Background(), ec, args) 44 + return stdout.String(), stderr.String(), err 45 + } 46 + 47 + func TestUniq(t *testing.T) { 48 + tests := []struct { 49 + name string 50 + args []string 51 + stdin string 52 + wantStdout string 53 + wantErrSub string 54 + wantErr bool 55 + }{ 56 + { 57 + name: "default collapses adjacent duplicates from stdin", 58 + args: nil, 59 + stdin: "a\na\nb\nc\nc\nc\nd\n", 60 + wantStdout: "a\nb\nc\nd\n", 61 + }, 62 + { 63 + name: "default collapses adjacent duplicates from file", 64 + args: []string{"dups.txt"}, 65 + wantStdout: "a\nb\nc\nd\n", 66 + }, 67 + { 68 + name: "dash means stdin", 69 + args: []string{"-"}, 70 + stdin: "a\na\nb\n", 71 + wantStdout: "a\nb\n", 72 + }, 73 + { 74 + name: "empty input produces no output", 75 + args: nil, 76 + stdin: "", 77 + wantStdout: "", 78 + }, 79 + { 80 + name: "non-adjacent duplicates are kept", 81 + args: nil, 82 + stdin: "a\nb\na\nb\n", 83 + wantStdout: "a\nb\na\nb\n", 84 + }, 85 + { 86 + name: "input without trailing newline", 87 + args: nil, 88 + stdin: "a\na\nb", 89 + wantStdout: "a\nb\n", 90 + }, 91 + { 92 + name: "count prefixes occurrences", 93 + args: []string{"-c"}, 94 + stdin: "a\na\nb\nc\nc\nc\n", 95 + wantStdout: " 2 a\n 1 b\n 3 c\n", 96 + }, 97 + { 98 + name: "count via long flag", 99 + args: []string{"--count"}, 100 + stdin: "a\na\n", 101 + wantStdout: " 2 a\n", 102 + }, 103 + { 104 + name: "repeated only prints duplicate runs", 105 + args: []string{"-d"}, 106 + stdin: "a\na\nb\nc\nc\nc\nd\n", 107 + wantStdout: "a\nc\n", 108 + }, 109 + { 110 + name: "repeated long flag", 111 + args: []string{"--repeated"}, 112 + stdin: "a\nb\nb\n", 113 + wantStdout: "b\n", 114 + }, 115 + { 116 + name: "unique only prints singletons", 117 + args: []string{"-u"}, 118 + stdin: "a\na\nb\nc\nc\nc\nd\n", 119 + wantStdout: "b\nd\n", 120 + }, 121 + { 122 + name: "unique long flag", 123 + args: []string{"--unique"}, 124 + stdin: "a\nb\nb\n", 125 + wantStdout: "a\n", 126 + }, 127 + { 128 + name: "ignore-case folds adjacent variants", 129 + args: []string{"-i"}, 130 + stdin: "Apple\napple\nAPPLE\nbanana\n", 131 + wantStdout: "Apple\nbanana\n", 132 + }, 133 + { 134 + name: "ignore-case long flag from file", 135 + args: []string{"--ignore-case", "mixed.txt"}, 136 + wantStdout: "Apple\nbanana\n", 137 + }, 138 + { 139 + name: "count combined with ignore-case", 140 + args: []string{"-c", "-i"}, 141 + stdin: "Apple\napple\nbanana\n", 142 + wantStdout: " 2 Apple\n 1 banana\n", 143 + }, 144 + { 145 + name: "repeated and count together", 146 + args: []string{"-cd"}, 147 + stdin: "a\na\nb\nc\nc\nc\n", 148 + wantStdout: " 2 a\n 3 c\n", 149 + }, 150 + { 151 + name: "concatenates multiple file arguments", 152 + args: []string{"part1.txt", "part2.txt"}, 153 + wantStdout: "x\ny\n", 154 + }, 155 + { 156 + name: "missing file reports error", 157 + args: []string{"nope.txt"}, 158 + wantStdout: "", 159 + wantErrSub: "uniq: nope.txt: No such file or directory", 160 + wantErr: true, 161 + }, 162 + { 163 + name: "unknown flag returns error", 164 + args: []string{"--no-such-flag"}, 165 + wantErr: true, 166 + }, 167 + } 168 + 169 + for _, tt := range tests { 170 + t.Run(tt.name, func(t *testing.T) { 171 + stdout, stderr, err := run(t, tt.args, tt.stdin, newFS(t)) 172 + if tt.wantErr { 173 + if err == nil { 174 + t.Fatalf("expected error, got nil; stdout=%q stderr=%q", stdout, stderr) 175 + } 176 + } else if err != nil { 177 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 178 + } 179 + if stdout != tt.wantStdout { 180 + t.Errorf("stdout mismatch\n got: %q\nwant: %q", stdout, tt.wantStdout) 181 + } 182 + if tt.wantErrSub != "" && !strings.Contains(stderr, tt.wantErrSub) { 183 + t.Errorf("stderr = %q, want substring %q", stderr, tt.wantErrSub) 184 + } 185 + }) 186 + } 187 + } 188 + 189 + func TestHelp(t *testing.T) { 190 + stdout, stderr, err := run(t, []string{"--help"}, "", newFS(t)) 191 + if err != nil { 192 + t.Fatalf("unexpected error: %v", err) 193 + } 194 + if stdout != "" { 195 + t.Errorf("expected empty stdout, got %q", stdout) 196 + } 197 + if !strings.Contains(stderr, "Usage: uniq [OPTION]... [INPUT [OUTPUT]]") { 198 + t.Errorf("usage line missing from stderr: %q", stderr) 199 + } 200 + if !strings.Contains(stderr, "-c, --count") { 201 + t.Errorf("count flag missing from help: %q", stderr) 202 + } 203 + if !strings.Contains(stderr, "-i, --ignore-case") { 204 + t.Errorf("ignore-case flag missing from help: %q", stderr) 205 + } 206 + }