Skip to content

Commit 6ffe3d8

Browse files
committed
fix: anchor csvops binary ignore so pkg/csvops directory is tracked
Previous unanchored 'csvops' pattern also matched the pkg/csvops library directory, silently dropping it from the last commit.
1 parent cfff16c commit 6ffe3d8

5 files changed

Lines changed: 313 additions & 1 deletion

File tree

.gitignore

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,5 +2,5 @@
22
/.idea
33
*.csv
44
/dist
5-
csvops
5+
/csvops
66
*.db

pkg/csvops/count.go

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,38 @@
1+
package csvops
2+
3+
import (
4+
"encoding/csv"
5+
"fmt"
6+
"io"
7+
"os"
8+
)
9+
10+
// CountDataRows counts non-header data rows in a CSV file, treating the first
11+
// line as a header. Returns 0 for an empty file or a header-only file.
12+
func CountDataRows(path string, delim rune) (int64, error) {
13+
f, err := os.Open(path)
14+
if err != nil {
15+
return 0, fmt.Errorf("open %s: %w", path, err)
16+
}
17+
defer f.Close()
18+
19+
r := csv.NewReader(f)
20+
r.Comma = delim
21+
r.FieldsPerRecord = -1
22+
23+
var total int64
24+
for {
25+
_, err := r.Read()
26+
if err == io.EOF {
27+
break
28+
}
29+
if err != nil {
30+
continue
31+
}
32+
total++
33+
}
34+
if total > 0 {
35+
total-- // exclude header
36+
}
37+
return total, nil
38+
}

pkg/csvops/csvops.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,15 @@
1+
// Package csvops provides reusable CSV operations (split, dedupe, filter, merge,
2+
// stats, preview, to-sqlite) as a library. Both the csvops CLI and the desktop
3+
// app depend on this package.
4+
package csvops
5+
6+
// Progress is an optional callback invoked during long-running operations.
7+
// total may be 0 when not known in advance; done monotonically increases.
8+
type Progress func(done, total int64)
9+
10+
// safeProgress is a no-op if p is nil.
11+
func safeProgress(p Progress, done, total int64) {
12+
if p != nil {
13+
p(done, total)
14+
}
15+
}

pkg/csvops/split.go

Lines changed: 140 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,140 @@
1+
package csvops
2+
3+
import (
4+
"context"
5+
"encoding/csv"
6+
"fmt"
7+
"io"
8+
"os"
9+
"path/filepath"
10+
)
11+
12+
// SplitOptions configures a Split operation.
13+
type SplitOptions struct {
14+
Input string
15+
OutputDir string
16+
RowsPerFile int
17+
WithHeader bool
18+
Delimiter rune
19+
Progress Progress
20+
}
21+
22+
// SplitResult is returned from Split.
23+
type SplitResult struct {
24+
RowsProcessed int64
25+
FilesCreated int
26+
}
27+
28+
// Split streams the CSV at opts.Input and writes chunks of RowsPerFile rows
29+
// into opts.OutputDir as part_1.csv, part_2.csv, ...
30+
func Split(ctx context.Context, opts SplitOptions) (SplitResult, error) {
31+
var res SplitResult
32+
33+
if opts.Input == "" {
34+
return res, fmt.Errorf("input is required")
35+
}
36+
if opts.RowsPerFile <= 0 {
37+
return res, fmt.Errorf("RowsPerFile must be > 0")
38+
}
39+
if opts.Delimiter == 0 {
40+
opts.Delimiter = ','
41+
}
42+
if opts.OutputDir == "" {
43+
opts.OutputDir = "."
44+
}
45+
46+
if err := os.MkdirAll(opts.OutputDir, 0o755); err != nil {
47+
return res, fmt.Errorf("create output dir: %w", err)
48+
}
49+
50+
total, err := CountDataRows(opts.Input, opts.Delimiter)
51+
if err != nil {
52+
return res, err
53+
}
54+
55+
f, err := os.Open(opts.Input)
56+
if err != nil {
57+
return res, fmt.Errorf("open input: %w", err)
58+
}
59+
defer f.Close()
60+
61+
r := csv.NewReader(f)
62+
r.Comma = opts.Delimiter
63+
r.FieldsPerRecord = -1
64+
65+
var header []string
66+
if opts.WithHeader {
67+
h, err := r.Read()
68+
if err != nil {
69+
return res, fmt.Errorf("read header: %w", err)
70+
}
71+
header = h
72+
}
73+
74+
buf := make([][]string, 0, opts.RowsPerFile)
75+
part := 1
76+
77+
flush := func() error {
78+
if len(buf) == 0 {
79+
return nil
80+
}
81+
if err := writeSplitChunk(opts.OutputDir, part, header, buf, opts.Delimiter, opts.WithHeader); err != nil {
82+
return err
83+
}
84+
part++
85+
buf = buf[:0]
86+
return nil
87+
}
88+
89+
for {
90+
if err := ctx.Err(); err != nil {
91+
return res, err
92+
}
93+
row, err := r.Read()
94+
if err == io.EOF {
95+
break
96+
}
97+
if err != nil {
98+
return res, fmt.Errorf("read row: %w", err)
99+
}
100+
buf = append(buf, row)
101+
res.RowsProcessed++
102+
safeProgress(opts.Progress, res.RowsProcessed, total)
103+
104+
if len(buf) == opts.RowsPerFile {
105+
if err := flush(); err != nil {
106+
return res, err
107+
}
108+
}
109+
}
110+
if err := flush(); err != nil {
111+
return res, err
112+
}
113+
res.FilesCreated = part - 1
114+
return res, nil
115+
}
116+
117+
func writeSplitChunk(dir string, part int, header []string, rows [][]string, delim rune, withHeader bool) error {
118+
path := filepath.Join(dir, fmt.Sprintf("part_%d.csv", part))
119+
f, err := os.Create(path)
120+
if err != nil {
121+
return fmt.Errorf("create %s: %w", path, err)
122+
}
123+
defer f.Close()
124+
125+
w := csv.NewWriter(f)
126+
w.Comma = delim
127+
128+
if withHeader && len(header) > 0 {
129+
if err := w.Write(header); err != nil {
130+
return err
131+
}
132+
}
133+
for _, row := range rows {
134+
if err := w.Write(row); err != nil {
135+
return err
136+
}
137+
}
138+
w.Flush()
139+
return w.Error()
140+
}

pkg/csvops/split_test.go

Lines changed: 119 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,119 @@
1+
package csvops
2+
3+
import (
4+
"context"
5+
"os"
6+
"path/filepath"
7+
"strings"
8+
"testing"
9+
)
10+
11+
func writeCSV(t *testing.T, path, body string) {
12+
t.Helper()
13+
if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
14+
t.Fatal(err)
15+
}
16+
}
17+
18+
func readFile(t *testing.T, path string) string {
19+
t.Helper()
20+
b, err := os.ReadFile(path)
21+
if err != nil {
22+
t.Fatal(err)
23+
}
24+
return string(b)
25+
}
26+
27+
func TestSplit_BasicWithHeader(t *testing.T) {
28+
dir := t.TempDir()
29+
in := filepath.Join(dir, "in.csv")
30+
out := filepath.Join(dir, "parts")
31+
writeCSV(t, in, "id,name\n1,a\n2,b\n3,c\n4,d\n5,e\n")
32+
33+
res, err := Split(context.Background(), SplitOptions{
34+
Input: in,
35+
OutputDir: out,
36+
RowsPerFile: 2,
37+
WithHeader: true,
38+
Delimiter: ',',
39+
})
40+
if err != nil {
41+
t.Fatal(err)
42+
}
43+
if res.RowsProcessed != 5 {
44+
t.Errorf("RowsProcessed = %d, want 5", res.RowsProcessed)
45+
}
46+
if res.FilesCreated != 3 {
47+
t.Errorf("FilesCreated = %d, want 3", res.FilesCreated)
48+
}
49+
50+
got := readFile(t, filepath.Join(out, "part_1.csv"))
51+
if !strings.HasPrefix(got, "id,name\n") {
52+
t.Errorf("part_1.csv missing header: %q", got)
53+
}
54+
55+
got3 := readFile(t, filepath.Join(out, "part_3.csv"))
56+
if !strings.Contains(got3, "5,e") {
57+
t.Errorf("part_3.csv missing trailing row: %q", got3)
58+
}
59+
}
60+
61+
func TestSplit_ProgressCallbackInvoked(t *testing.T) {
62+
dir := t.TempDir()
63+
in := filepath.Join(dir, "in.csv")
64+
writeCSV(t, in, "h\n1\n2\n3\n")
65+
66+
var calls int
67+
var lastDone, lastTotal int64
68+
_, err := Split(context.Background(), SplitOptions{
69+
Input: in,
70+
OutputDir: filepath.Join(dir, "out"),
71+
RowsPerFile: 1,
72+
WithHeader: true,
73+
Delimiter: ',',
74+
Progress: func(done, total int64) {
75+
calls++
76+
lastDone, lastTotal = done, total
77+
},
78+
})
79+
if err != nil {
80+
t.Fatal(err)
81+
}
82+
if calls != 3 {
83+
t.Errorf("progress called %d times, want 3", calls)
84+
}
85+
if lastDone != 3 || lastTotal != 3 {
86+
t.Errorf("final progress = (%d,%d), want (3,3)", lastDone, lastTotal)
87+
}
88+
}
89+
90+
func TestSplit_CancelledContext(t *testing.T) {
91+
dir := t.TempDir()
92+
in := filepath.Join(dir, "in.csv")
93+
writeCSV(t, in, "h\n1\n2\n3\n")
94+
95+
ctx, cancel := context.WithCancel(context.Background())
96+
cancel()
97+
98+
_, err := Split(ctx, SplitOptions{
99+
Input: in,
100+
OutputDir: filepath.Join(dir, "out"),
101+
RowsPerFile: 1,
102+
WithHeader: true,
103+
Delimiter: ',',
104+
})
105+
if err != context.Canceled {
106+
t.Errorf("err = %v, want context.Canceled", err)
107+
}
108+
}
109+
110+
func TestSplit_ValidatesInputs(t *testing.T) {
111+
_, err := Split(context.Background(), SplitOptions{RowsPerFile: 10})
112+
if err == nil {
113+
t.Error("expected error for missing input")
114+
}
115+
_, err = Split(context.Background(), SplitOptions{Input: "x", RowsPerFile: 0})
116+
if err == nil {
117+
t.Error("expected error for RowsPerFile=0")
118+
}
119+
}

0 commit comments

Comments
 (0)