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

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

Xe Iaso 17810c2c 263b3de8

+793
+345
command/internal/split/split.go
··· 1 + package split 2 + 3 + import ( 4 + "context" 5 + "errors" 6 + "fmt" 7 + "io" 8 + "os" 9 + "path" 10 + "regexp" 11 + "strconv" 12 + "strings" 13 + 14 + "github.com/pborman/getopt/v2" 15 + "mvdan.cc/sh/v3/interp" 16 + "tangled.org/xeiaso.net/kefka/command" 17 + ) 18 + 19 + type Impl struct{} 20 + 21 + const maxOutputFiles = 100_000 22 + 23 + type splitMode int 24 + 25 + const ( 26 + modeLines splitMode = iota 27 + modeBytes 28 + modeChunks 29 + ) 30 + 31 + type chunk struct { 32 + content []byte 33 + hasContent bool 34 + } 35 + 36 + func (Impl) Exec(ctx context.Context, ec *command.ExecContext, args []string) error { 37 + if ec == nil { 38 + return errors.New("split: nil ExecContext") 39 + } 40 + 41 + stderr := ec.Stderr 42 + if stderr == nil { 43 + stderr = io.Discard 44 + } 45 + 46 + set := getopt.New() 47 + set.SetProgram("split") 48 + set.SetParameters("[FILE [PREFIX]]") 49 + 50 + usage := func() { 51 + fmt.Fprint(stderr, "Usage: split [OPTION]... [FILE [PREFIX]]\n") 52 + fmt.Fprint(stderr, "Output pieces of FILE to PREFIXaa, PREFIXab, ...;\n") 53 + fmt.Fprint(stderr, "default size is 1000 lines, and default PREFIX is 'x'.\n\n") 54 + fmt.Fprint(stderr, " -l N put N lines per output file\n") 55 + fmt.Fprint(stderr, " -b SIZE put SIZE bytes per output file (K, M, G suffixes)\n") 56 + fmt.Fprint(stderr, " -n CHUNKS split into CHUNKS equal-sized files\n") 57 + fmt.Fprint(stderr, " -d, --numeric-suffixes use numeric suffixes (00, 01, ...) instead of alphabetic\n") 58 + fmt.Fprint(stderr, " -a LENGTH use suffixes of length LENGTH (default: 2)\n") 59 + fmt.Fprint(stderr, " --additional-suffix=SUFFIX append SUFFIX to file names\n") 60 + fmt.Fprint(stderr, " --help display this help and exit\n") 61 + } 62 + set.SetUsage(usage) 63 + 64 + linesStr := set.String('l', "", "put N lines per output file") 65 + bytesStr := set.String('b', "", "put SIZE bytes per output file") 66 + chunksStr := set.String('n', "", "split into CHUNKS equal-sized files") 67 + suffixLenStr := set.String('a', "", "use suffixes of length LENGTH") 68 + useNumeric := set.BoolLong("numeric-suffixes", 'd', "use numeric suffixes") 69 + additionalSuffix := set.StringLong("additional-suffix", 0, "", "append SUFFIX to file names") 70 + helpFlag := set.BoolLong("help", 0, "display this help and exit") 71 + 72 + var modeOrder []byte 73 + callback := func(opt getopt.Option) bool { 74 + switch opt.ShortName() { 75 + case "l", "b", "n": 76 + modeOrder = append(modeOrder, opt.ShortName()[0]) 77 + } 78 + return true 79 + } 80 + 81 + if err := set.Getopt(append([]string{"split"}, args...), callback); err != nil { 82 + fmt.Fprintf(stderr, "split: %s\n", err) 83 + usage() 84 + return interp.ExitStatus(1) 85 + } 86 + if *helpFlag { 87 + usage() 88 + return nil 89 + } 90 + 91 + mode := modeLines 92 + linesPerFile := 1000 93 + bytesPerFile := 0 94 + numChunks := 0 95 + suffixLength := 2 96 + 97 + if len(modeOrder) > 0 { 98 + switch modeOrder[len(modeOrder)-1] { 99 + case 'l': 100 + mode = modeLines 101 + case 'b': 102 + mode = modeBytes 103 + case 'n': 104 + mode = modeChunks 105 + } 106 + } 107 + 108 + if *linesStr != "" { 109 + n, err := strconv.Atoi(*linesStr) 110 + if err != nil || n < 1 { 111 + fmt.Fprintf(stderr, "split: invalid number of lines: '%s'\n", *linesStr) 112 + return interp.ExitStatus(1) 113 + } 114 + linesPerFile = n 115 + } 116 + if *bytesStr != "" { 117 + n, ok := parseSize(*bytesStr) 118 + if !ok { 119 + fmt.Fprintf(stderr, "split: invalid number of bytes: '%s'\n", *bytesStr) 120 + return interp.ExitStatus(1) 121 + } 122 + bytesPerFile = n 123 + } 124 + if *chunksStr != "" { 125 + n, err := strconv.Atoi(*chunksStr) 126 + if err != nil || n < 1 { 127 + fmt.Fprintf(stderr, "split: invalid number of chunks: '%s'\n", *chunksStr) 128 + return interp.ExitStatus(1) 129 + } 130 + numChunks = n 131 + } 132 + if *suffixLenStr != "" { 133 + n, err := strconv.Atoi(*suffixLenStr) 134 + if err != nil || n < 1 { 135 + fmt.Fprintf(stderr, "split: invalid suffix length: '%s'\n", *suffixLenStr) 136 + return interp.ExitStatus(1) 137 + } 138 + suffixLength = n 139 + } 140 + 141 + positional := set.Args() 142 + inputFile := "-" 143 + prefix := "x" 144 + if len(positional) >= 1 { 145 + inputFile = positional[0] 146 + } 147 + if len(positional) >= 2 { 148 + prefix = positional[1] 149 + } 150 + 151 + content, err := readInput(ec, inputFile, stderr) 152 + if err != nil { 153 + return err 154 + } 155 + if len(content) == 0 { 156 + return nil 157 + } 158 + 159 + var chunks []chunk 160 + switch mode { 161 + case modeLines: 162 + chunks = splitByLines(content, linesPerFile) 163 + case modeBytes: 164 + chunks = splitByBytes(content, bytesPerFile) 165 + case modeChunks: 166 + chunks = splitIntoChunks(content, numChunks) 167 + } 168 + 169 + if len(chunks) > maxOutputFiles { 170 + fmt.Fprintf(stderr, "split: too many output files (%d), limit is %d\n", len(chunks), maxOutputFiles) 171 + return interp.ExitStatus(1) 172 + } 173 + 174 + if ec.FS == nil { 175 + return errors.New("split: ExecContext has no filesystem") 176 + } 177 + 178 + for i, ch := range chunks { 179 + if !ch.hasContent { 180 + continue 181 + } 182 + filename := prefix + generateSuffix(i, *useNumeric, suffixLength) + *additionalSuffix 183 + full := resolvePath(ec, filename) 184 + f, err := ec.FS.OpenFile(full, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644) 185 + if err != nil { 186 + fmt.Fprintf(stderr, "split: %s: %v\n", filename, err) 187 + return interp.ExitStatus(1) 188 + } 189 + if _, err := f.Write(ch.content); err != nil { 190 + f.Close() 191 + fmt.Fprintf(stderr, "split: %s: %v\n", filename, err) 192 + return interp.ExitStatus(1) 193 + } 194 + f.Close() 195 + } 196 + 197 + return nil 198 + } 199 + 200 + func readInput(ec *command.ExecContext, inputFile string, stderr io.Writer) ([]byte, error) { 201 + if inputFile == "-" { 202 + if ec.Stdin == nil { 203 + return nil, nil 204 + } 205 + return io.ReadAll(ec.Stdin) 206 + } 207 + if ec.FS == nil { 208 + fmt.Fprintf(stderr, "split: %s: No such file or directory\n", inputFile) 209 + return nil, interp.ExitStatus(1) 210 + } 211 + f, err := ec.FS.Open(resolvePath(ec, inputFile)) 212 + if err != nil { 213 + fmt.Fprintf(stderr, "split: %s: No such file or directory\n", inputFile) 214 + return nil, interp.ExitStatus(1) 215 + } 216 + defer f.Close() 217 + return io.ReadAll(f) 218 + } 219 + 220 + var sizeRe = regexp.MustCompile(`(?i)^(\d+)([KMGTPEZY]?)([B]?)$`) 221 + 222 + func parseSize(s string) (int, bool) { 223 + m := sizeRe.FindStringSubmatch(s) 224 + if m == nil { 225 + return 0, false 226 + } 227 + n, err := strconv.Atoi(m[1]) 228 + if err != nil || n < 1 { 229 + return 0, false 230 + } 231 + multipliers := map[string]int{ 232 + "": 1, 233 + "K": 1024, 234 + "M": 1024 * 1024, 235 + "G": 1024 * 1024 * 1024, 236 + "T": 1024 * 1024 * 1024 * 1024, 237 + "P": 1024 * 1024 * 1024 * 1024 * 1024, 238 + } 239 + mult, ok := multipliers[strings.ToUpper(m[2])] 240 + if !ok { 241 + return 0, false 242 + } 243 + return n * mult, true 244 + } 245 + 246 + func generateSuffix(index int, useNumeric bool, length int) string { 247 + if useNumeric { 248 + s := strconv.Itoa(index) 249 + if len(s) < length { 250 + s = strings.Repeat("0", length-len(s)) + s 251 + } 252 + return s 253 + } 254 + const chars = "abcdefghijklmnopqrstuvwxyz" 255 + suffix := make([]byte, length) 256 + remaining := index 257 + for i := length - 1; i >= 0; i-- { 258 + suffix[i] = chars[remaining%26] 259 + remaining /= 26 260 + } 261 + return string(suffix) 262 + } 263 + 264 + func splitByLines(content []byte, linesPerFile int) []chunk { 265 + s := string(content) 266 + lines := strings.Split(s, "\n") 267 + hasTrailingNewline := strings.HasSuffix(s, "\n") && lines[len(lines)-1] == "" 268 + if hasTrailingNewline { 269 + lines = lines[:len(lines)-1] 270 + } 271 + 272 + var chunks []chunk 273 + for i := 0; i < len(lines); i += linesPerFile { 274 + end := i + linesPerFile 275 + if end > len(lines) { 276 + end = len(lines) 277 + } 278 + chunkLines := lines[i:end] 279 + isLastChunk := i+linesPerFile >= len(lines) 280 + var c string 281 + if isLastChunk && !hasTrailingNewline { 282 + c = strings.Join(chunkLines, "\n") 283 + } else { 284 + c = strings.Join(chunkLines, "\n") + "\n" 285 + } 286 + chunks = append(chunks, chunk{content: []byte(c), hasContent: true}) 287 + } 288 + return chunks 289 + } 290 + 291 + func splitByBytes(content []byte, bytesPerFile int) []chunk { 292 + var chunks []chunk 293 + for i := 0; i < len(content); i += bytesPerFile { 294 + end := i + bytesPerFile 295 + if end > len(content) { 296 + end = len(content) 297 + } 298 + b := content[i:end] 299 + chunks = append(chunks, chunk{content: b, hasContent: len(b) > 0}) 300 + } 301 + return chunks 302 + } 303 + 304 + func splitIntoChunks(content []byte, numChunks int) []chunk { 305 + if numChunks < 1 { 306 + return nil 307 + } 308 + bytesPerChunk := (len(content) + numChunks - 1) / numChunks 309 + if bytesPerChunk < 1 { 310 + bytesPerChunk = 1 311 + } 312 + chunks := make([]chunk, 0, numChunks) 313 + for i := 0; i < numChunks; i++ { 314 + start := i * bytesPerChunk 315 + if start > len(content) { 316 + start = len(content) 317 + } 318 + end := start + bytesPerChunk 319 + if end > len(content) { 320 + end = len(content) 321 + } 322 + b := content[start:end] 323 + chunks = append(chunks, chunk{content: b, hasContent: len(b) > 0}) 324 + } 325 + return chunks 326 + } 327 + 328 + func resolvePath(ec *command.ExecContext, p string) string { 329 + dir := ec.Dir 330 + if dir == "" { 331 + dir = "." 332 + } 333 + if path.IsAbs(p) { 334 + p = strings.TrimPrefix(p, "/") 335 + if p == "" { 336 + return "." 337 + } 338 + return path.Clean(p) 339 + } 340 + joined := path.Join(dir, p) 341 + if joined == "" { 342 + return "." 343 + } 344 + return joined 345 + }
+448
command/internal/split/split_test.go
··· 1 + package split 2 + 3 + import ( 4 + "bytes" 5 + "context" 6 + "io" 7 + "os" 8 + "strings" 9 + "testing" 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) { 20 + f, err := fs.OpenFile(name, os.O_CREATE|os.O_WRONLY, 0o644) 21 + if err != nil { 22 + t.Fatal(err) 23 + } 24 + f.Write(data) 25 + f.Close() 26 + } 27 + write("five-lines.txt", []byte("a\nb\nc\nd\ne\n")) 28 + write("no-trailing.txt", []byte("a\nb\nc")) 29 + write("ten-bytes.bin", []byte("0123456789")) 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 readFile(t *testing.T, fs billy.Filesystem, name string) string { 48 + t.Helper() 49 + f, err := fs.Open(name) 50 + if err != nil { 51 + t.Fatalf("open %s: %v", name, err) 52 + } 53 + defer f.Close() 54 + data, err := io.ReadAll(f) 55 + if err != nil { 56 + t.Fatalf("read %s: %v", name, err) 57 + } 58 + return string(data) 59 + } 60 + 61 + func fileExists(fs billy.Filesystem, name string) bool { 62 + _, err := fs.Stat(name) 63 + return err == nil 64 + } 65 + 66 + func TestSplit_lines(t *testing.T) { 67 + tests := []struct { 68 + name string 69 + args []string 70 + stdin string 71 + wantFiles map[string]string 72 + }{ 73 + { 74 + name: "default 1000 lines from stdin", 75 + args: nil, 76 + stdin: "a\nb\nc\n", 77 + wantFiles: map[string]string{ 78 + "xaa": "a\nb\nc\n", 79 + }, 80 + }, 81 + { 82 + name: "split by 2 lines", 83 + args: []string{"-l", "2"}, 84 + stdin: "a\nb\nc\nd\ne\n", 85 + wantFiles: map[string]string{ 86 + "xaa": "a\nb\n", 87 + "xab": "c\nd\n", 88 + "xac": "e\n", 89 + }, 90 + }, 91 + { 92 + name: "split by 2 lines from file", 93 + args: []string{"-l", "2", "five-lines.txt"}, 94 + wantFiles: map[string]string{ 95 + "xaa": "a\nb\n", 96 + "xab": "c\nd\n", 97 + "xac": "e\n", 98 + }, 99 + }, 100 + { 101 + name: "no trailing newline preserved on last chunk", 102 + args: []string{"-l", "2", "no-trailing.txt"}, 103 + wantFiles: map[string]string{ 104 + "xaa": "a\nb\n", 105 + "xab": "c", 106 + }, 107 + }, 108 + { 109 + name: "combined short form -l2", 110 + args: []string{"-l2", "five-lines.txt"}, 111 + wantFiles: map[string]string{ 112 + "xaa": "a\nb\n", 113 + "xab": "c\nd\n", 114 + "xac": "e\n", 115 + }, 116 + }, 117 + { 118 + name: "dash means stdin", 119 + args: []string{"-l", "1", "-"}, 120 + stdin: "x\ny\n", 121 + wantFiles: map[string]string{ 122 + "xaa": "x\n", 123 + "xab": "y\n", 124 + }, 125 + }, 126 + } 127 + 128 + for _, tt := range tests { 129 + t.Run(tt.name, func(t *testing.T) { 130 + fs := newFS(t) 131 + _, stderr, err := run(t, tt.args, tt.stdin, fs) 132 + if err != nil { 133 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 134 + } 135 + for name, want := range tt.wantFiles { 136 + got := readFile(t, fs, name) 137 + if got != want { 138 + t.Errorf("%s = %q, want %q", name, got, want) 139 + } 140 + } 141 + }) 142 + } 143 + } 144 + 145 + func TestSplit_bytes(t *testing.T) { 146 + tests := []struct { 147 + name string 148 + args []string 149 + stdin string 150 + wantFiles map[string]string 151 + }{ 152 + { 153 + name: "split by 4 bytes from stdin", 154 + args: []string{"-b", "4"}, 155 + stdin: "0123456789", 156 + wantFiles: map[string]string{ 157 + "xaa": "0123", 158 + "xab": "4567", 159 + "xac": "89", 160 + }, 161 + }, 162 + { 163 + name: "split by bytes from file", 164 + args: []string{"-b", "3", "ten-bytes.bin"}, 165 + wantFiles: map[string]string{ 166 + "xaa": "012", 167 + "xab": "345", 168 + "xac": "678", 169 + "xad": "9", 170 + }, 171 + }, 172 + { 173 + name: "K suffix", 174 + args: []string{"-b", "1K"}, 175 + stdin: strings.Repeat("a", 2048), 176 + wantFiles: map[string]string{ 177 + "xaa": strings.Repeat("a", 1024), 178 + "xab": strings.Repeat("a", 1024), 179 + }, 180 + }, 181 + { 182 + name: "combined short form -b4", 183 + args: []string{"-b4"}, 184 + stdin: "abcdefgh", 185 + wantFiles: map[string]string{ 186 + "xaa": "abcd", 187 + "xab": "efgh", 188 + }, 189 + }, 190 + } 191 + 192 + for _, tt := range tests { 193 + t.Run(tt.name, func(t *testing.T) { 194 + fs := newFS(t) 195 + _, stderr, err := run(t, tt.args, tt.stdin, fs) 196 + if err != nil { 197 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 198 + } 199 + for name, want := range tt.wantFiles { 200 + got := readFile(t, fs, name) 201 + if got != want { 202 + t.Errorf("%s = %q, want %q", name, got, want) 203 + } 204 + } 205 + }) 206 + } 207 + } 208 + 209 + func TestSplit_chunks(t *testing.T) { 210 + fs := newFS(t) 211 + _, stderr, err := run(t, []string{"-n", "5", "ten-bytes.bin"}, "", fs) 212 + if err != nil { 213 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 214 + } 215 + want := map[string]string{ 216 + "xaa": "01", 217 + "xab": "23", 218 + "xac": "45", 219 + "xad": "67", 220 + "xae": "89", 221 + } 222 + for name, expected := range want { 223 + got := readFile(t, fs, name) 224 + if got != expected { 225 + t.Errorf("%s = %q, want %q", name, got, expected) 226 + } 227 + } 228 + } 229 + 230 + func TestSplit_chunksUneven(t *testing.T) { 231 + // 10 bytes / 3 chunks → ceil = 4 bytes per chunk: "0123", "4567", "89" 232 + fs := newFS(t) 233 + _, stderr, err := run(t, []string{"-n", "3", "ten-bytes.bin"}, "", fs) 234 + if err != nil { 235 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 236 + } 237 + want := map[string]string{ 238 + "xaa": "0123", 239 + "xab": "4567", 240 + "xac": "89", 241 + } 242 + for name, expected := range want { 243 + got := readFile(t, fs, name) 244 + if got != expected { 245 + t.Errorf("%s = %q, want %q", name, got, expected) 246 + } 247 + } 248 + } 249 + 250 + func TestSplit_numericSuffix(t *testing.T) { 251 + fs := newFS(t) 252 + _, stderr, err := run(t, []string{"-d", "-l", "1", "five-lines.txt"}, "", fs) 253 + if err != nil { 254 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 255 + } 256 + want := map[string]string{ 257 + "x00": "a\n", 258 + "x01": "b\n", 259 + "x02": "c\n", 260 + "x03": "d\n", 261 + "x04": "e\n", 262 + } 263 + for name, expected := range want { 264 + got := readFile(t, fs, name) 265 + if got != expected { 266 + t.Errorf("%s = %q, want %q", name, got, expected) 267 + } 268 + } 269 + } 270 + 271 + func TestSplit_numericSuffixLong(t *testing.T) { 272 + fs := newFS(t) 273 + _, stderr, err := run(t, []string{"--numeric-suffixes", "-l", "1", "five-lines.txt"}, "", fs) 274 + if err != nil { 275 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 276 + } 277 + if !fileExists(fs, "x00") { 278 + t.Errorf("expected x00 to exist") 279 + } 280 + } 281 + 282 + func TestSplit_suffixLength(t *testing.T) { 283 + fs := newFS(t) 284 + _, stderr, err := run(t, []string{"-a", "3", "-d", "-l", "1", "five-lines.txt"}, "", fs) 285 + if err != nil { 286 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 287 + } 288 + if !fileExists(fs, "x000") { 289 + t.Errorf("expected x000 to exist") 290 + } 291 + if !fileExists(fs, "x004") { 292 + t.Errorf("expected x004 to exist") 293 + } 294 + if fileExists(fs, "x00") { 295 + t.Errorf("x00 should not exist with -a 3") 296 + } 297 + } 298 + 299 + func TestSplit_additionalSuffix(t *testing.T) { 300 + fs := newFS(t) 301 + _, stderr, err := run(t, []string{"--additional-suffix=.txt", "-l", "2", "five-lines.txt"}, "", fs) 302 + if err != nil { 303 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 304 + } 305 + want := map[string]string{ 306 + "xaa.txt": "a\nb\n", 307 + "xab.txt": "c\nd\n", 308 + "xac.txt": "e\n", 309 + } 310 + for name, expected := range want { 311 + got := readFile(t, fs, name) 312 + if got != expected { 313 + t.Errorf("%s = %q, want %q", name, got, expected) 314 + } 315 + } 316 + } 317 + 318 + func TestSplit_customPrefix(t *testing.T) { 319 + fs := newFS(t) 320 + _, stderr, err := run(t, []string{"-l", "2", "five-lines.txt", "part_"}, "", fs) 321 + if err != nil { 322 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 323 + } 324 + if !fileExists(fs, "part_aa") { 325 + t.Errorf("expected part_aa to exist") 326 + } 327 + if !fileExists(fs, "part_ac") { 328 + t.Errorf("expected part_ac to exist") 329 + } 330 + } 331 + 332 + func TestSplit_emptyInput(t *testing.T) { 333 + fs := newFS(t) 334 + _, stderr, err := run(t, nil, "", fs) 335 + if err != nil { 336 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 337 + } 338 + if fileExists(fs, "xaa") { 339 + t.Errorf("no files should be written for empty input") 340 + } 341 + } 342 + 343 + func TestSplit_errors(t *testing.T) { 344 + tests := []struct { 345 + name string 346 + args []string 347 + stdin string 348 + wantErr bool 349 + wantErrSub string 350 + }{ 351 + { 352 + name: "missing file", 353 + args: []string{"nope.txt"}, 354 + wantErr: true, 355 + wantErrSub: "split: nope.txt: No such file or directory", 356 + }, 357 + { 358 + name: "invalid lines value", 359 + args: []string{"-l", "abc"}, 360 + stdin: "x", 361 + wantErr: true, 362 + wantErrSub: "split: invalid number of lines: 'abc'", 363 + }, 364 + { 365 + name: "zero lines value", 366 + args: []string{"-l", "0"}, 367 + stdin: "x", 368 + wantErr: true, 369 + wantErrSub: "split: invalid number of lines: '0'", 370 + }, 371 + { 372 + name: "invalid bytes value", 373 + args: []string{"-b", "1Z"}, 374 + stdin: "x", 375 + wantErr: true, 376 + wantErrSub: "split: invalid number of bytes: '1Z'", 377 + }, 378 + { 379 + name: "invalid chunks value", 380 + args: []string{"-n", "0"}, 381 + stdin: "x", 382 + wantErr: true, 383 + wantErrSub: "split: invalid number of chunks: '0'", 384 + }, 385 + { 386 + name: "invalid suffix length", 387 + args: []string{"-a", "abc"}, 388 + stdin: "x", 389 + wantErr: true, 390 + wantErrSub: "split: invalid suffix length: 'abc'", 391 + }, 392 + { 393 + name: "unknown flag", 394 + args: []string{"--no-such-flag"}, 395 + wantErr: true, 396 + }, 397 + } 398 + 399 + for _, tt := range tests { 400 + t.Run(tt.name, func(t *testing.T) { 401 + _, stderr, err := run(t, tt.args, tt.stdin, newFS(t)) 402 + if tt.wantErr { 403 + if err == nil { 404 + t.Fatalf("expected error, got nil; stderr=%q", stderr) 405 + } 406 + } else if err != nil { 407 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 408 + } 409 + if tt.wantErrSub != "" && !strings.Contains(stderr, tt.wantErrSub) { 410 + t.Errorf("stderr = %q, want substring %q", stderr, tt.wantErrSub) 411 + } 412 + }) 413 + } 414 + } 415 + 416 + func TestSplit_modeLastFlagWins(t *testing.T) { 417 + // -l 2 then -b 4: bytes mode wins 418 + fs := newFS(t) 419 + _, stderr, err := run(t, []string{"-l", "2", "-b", "4"}, "abcdefgh", fs) 420 + if err != nil { 421 + t.Fatalf("unexpected error: %v; stderr=%q", err, stderr) 422 + } 423 + if got := readFile(t, fs, "xaa"); got != "abcd" { 424 + t.Errorf("xaa = %q, want %q", got, "abcd") 425 + } 426 + if got := readFile(t, fs, "xab"); got != "efgh" { 427 + t.Errorf("xab = %q, want %q", got, "efgh") 428 + } 429 + } 430 + 431 + func TestSplit_help(t *testing.T) { 432 + stdout, stderr, err := run(t, []string{"--help"}, "", newFS(t)) 433 + if err != nil { 434 + t.Fatalf("unexpected error: %v", err) 435 + } 436 + if stdout != "" { 437 + t.Errorf("expected empty stdout, got %q", stdout) 438 + } 439 + if !strings.Contains(stderr, "Usage: split [OPTION]... [FILE [PREFIX]]") { 440 + t.Errorf("usage line missing from stderr: %q", stderr) 441 + } 442 + if !strings.Contains(stderr, "-l N") { 443 + t.Errorf("-l flag missing from help: %q", stderr) 444 + } 445 + if !strings.Contains(stderr, "--additional-suffix") { 446 + t.Errorf("--additional-suffix flag missing from help: %q", stderr) 447 + } 448 + }