Skip to content

Commit 6f0366d

Browse files
committed
refactor(preview): migrate to pkg/csvops library
Preview(ctx, PreviewOptions) (PreviewResult, error). Returns Headers and Rows in memory — desktop UI pipes straight into a table view. Row-level parse errors are returned via SkipErrors rather than printed, so callers decide how to surface them.
1 parent 894d7bc commit 6f0366d

3 files changed

Lines changed: 167 additions & 33 deletions

File tree

cmd/preview.go

Lines changed: 17 additions & 33 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
package cmd
22

33
import (
4-
"encoding/csv"
4+
"context"
55
"fmt"
6-
"io"
76
"os"
87

8+
"github.com/maherelgamil/csvops/pkg/csvops"
99
"github.com/olekukonko/tablewriter"
1010
"github.com/spf13/cobra"
1111
)
@@ -24,37 +24,21 @@ var previewCmd = &cobra.Command{
2424
return fmt.Errorf("please provide an input file using --input")
2525
}
2626

27-
file, err := os.Open(previewInput)
27+
res, err := csvops.Preview(context.Background(), csvops.PreviewOptions{
28+
Input: previewInput,
29+
Rows: previewRows,
30+
NoHeader: previewNoHeader,
31+
Delimiter: ',',
32+
})
2833
if err != nil {
29-
return fmt.Errorf("failed to open file: %w", err)
34+
return err
3035
}
31-
defer file.Close()
3236

33-
reader := csv.NewReader(file)
34-
reader.FieldsPerRecord = -1
35-
36-
var headers []string
37-
if !previewNoHeader {
38-
headers, err = reader.Read()
39-
if err != nil {
40-
return fmt.Errorf("failed to read header: %w", err)
41-
}
42-
}
43-
44-
rows := [][]string{}
45-
for len(rows) < previewRows {
46-
record, err := reader.Read()
47-
if err == io.EOF {
48-
break
49-
}
50-
if err != nil {
51-
fmt.Printf("⚠️ Skipping row due to error: %v\n", err)
52-
continue
53-
}
54-
rows = append(rows, record)
37+
for _, e := range res.SkipErrors {
38+
fmt.Printf("⚠️ Skipping row due to error: %v\n", e)
5539
}
5640

57-
if len(rows) == 0 {
41+
if len(res.Rows) == 0 {
5842
fmt.Println("⚠️ No data rows found")
5943
return nil
6044
}
@@ -65,15 +49,15 @@ var previewCmd = &cobra.Command{
6549
table.SetAlignment(tablewriter.ALIGN_LEFT)
6650
table.SetRowLine(true)
6751

68-
if !previewNoHeader {
69-
table.SetHeader(headers)
52+
if len(res.Headers) > 0 {
53+
table.SetHeader(res.Headers)
7054
}
71-
for _, row := range rows {
55+
for _, row := range res.Rows {
7256
table.Append(row)
7357
}
74-
7558
table.Render()
76-
fmt.Printf("\n📄 Showing %d row(s) from '%s'\n", len(rows), previewInput)
59+
60+
fmt.Printf("\n📄 Showing %d row(s) from '%s'\n", len(res.Rows), previewInput)
7761
return nil
7862
},
7963
}

pkg/csvops/preview.go

Lines changed: 79 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
package csvops
2+
3+
import (
4+
"context"
5+
"encoding/csv"
6+
"errors"
7+
"fmt"
8+
"io"
9+
"os"
10+
)
11+
12+
// PreviewOptions configures a Preview operation.
13+
type PreviewOptions struct {
14+
Input string
15+
Rows int // max rows to return
16+
NoHeader bool // if true, the first row is treated as data
17+
Delimiter rune
18+
}
19+
20+
// PreviewResult is returned from Preview.
21+
type PreviewResult struct {
22+
Headers []string // empty when NoHeader is true
23+
Rows [][]string // up to opts.Rows rows
24+
SkipErrors []error // per-row parse errors that were skipped
25+
}
26+
27+
// Preview reads the first Rows data rows of the CSV and returns them in memory.
28+
// Unlike the other ops it does not stream — callers expect a bounded slice.
29+
func Preview(ctx context.Context, opts PreviewOptions) (PreviewResult, error) {
30+
var res PreviewResult
31+
32+
if opts.Input == "" {
33+
return res, fmt.Errorf("input is required")
34+
}
35+
if opts.Rows <= 0 {
36+
opts.Rows = 5
37+
}
38+
if opts.Delimiter == 0 {
39+
opts.Delimiter = ','
40+
}
41+
42+
f, err := os.Open(opts.Input)
43+
if err != nil {
44+
return res, fmt.Errorf("open input: %w", err)
45+
}
46+
defer f.Close()
47+
48+
reader := csv.NewReader(f)
49+
reader.Comma = opts.Delimiter
50+
reader.FieldsPerRecord = -1
51+
52+
if !opts.NoHeader {
53+
h, err := reader.Read()
54+
if err != nil {
55+
if errors.Is(err, io.EOF) {
56+
return res, nil
57+
}
58+
return res, fmt.Errorf("read header: %w", err)
59+
}
60+
res.Headers = h
61+
}
62+
63+
res.Rows = make([][]string, 0, opts.Rows)
64+
for len(res.Rows) < opts.Rows {
65+
if err := ctx.Err(); err != nil {
66+
return res, err
67+
}
68+
rec, err := reader.Read()
69+
if errors.Is(err, io.EOF) {
70+
break
71+
}
72+
if err != nil {
73+
res.SkipErrors = append(res.SkipErrors, err)
74+
continue
75+
}
76+
res.Rows = append(res.Rows, rec)
77+
}
78+
return res, nil
79+
}

pkg/csvops/preview_test.go

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package csvops
2+
3+
import (
4+
"context"
5+
"path/filepath"
6+
"reflect"
7+
"testing"
8+
)
9+
10+
func TestPreview_WithHeader(t *testing.T) {
11+
dir := t.TempDir()
12+
in := filepath.Join(dir, "in.csv")
13+
writeCSV(t, in, "id,name\n1,a\n2,b\n3,c\n4,d\n")
14+
15+
res, err := Preview(context.Background(), PreviewOptions{Input: in, Rows: 2})
16+
if err != nil {
17+
t.Fatal(err)
18+
}
19+
if !reflect.DeepEqual(res.Headers, []string{"id", "name"}) {
20+
t.Errorf("headers = %v", res.Headers)
21+
}
22+
if len(res.Rows) != 2 {
23+
t.Errorf("rows = %d, want 2", len(res.Rows))
24+
}
25+
if !reflect.DeepEqual(res.Rows[0], []string{"1", "a"}) {
26+
t.Errorf("row[0] = %v", res.Rows[0])
27+
}
28+
}
29+
30+
func TestPreview_NoHeader(t *testing.T) {
31+
dir := t.TempDir()
32+
in := filepath.Join(dir, "in.csv")
33+
writeCSV(t, in, "1,a\n2,b\n")
34+
35+
res, err := Preview(context.Background(), PreviewOptions{Input: in, Rows: 5, NoHeader: true})
36+
if err != nil {
37+
t.Fatal(err)
38+
}
39+
if len(res.Headers) != 0 {
40+
t.Errorf("expected no headers, got %v", res.Headers)
41+
}
42+
if len(res.Rows) != 2 {
43+
t.Errorf("rows = %d, want 2", len(res.Rows))
44+
}
45+
}
46+
47+
func TestPreview_AsksForMoreThanAvailable(t *testing.T) {
48+
dir := t.TempDir()
49+
in := filepath.Join(dir, "in.csv")
50+
writeCSV(t, in, "h\n1\n")
51+
res, err := Preview(context.Background(), PreviewOptions{Input: in, Rows: 100})
52+
if err != nil {
53+
t.Fatal(err)
54+
}
55+
if len(res.Rows) != 1 {
56+
t.Errorf("rows = %d, want 1", len(res.Rows))
57+
}
58+
}
59+
60+
func TestPreview_EmptyFile(t *testing.T) {
61+
dir := t.TempDir()
62+
in := filepath.Join(dir, "in.csv")
63+
writeCSV(t, in, "")
64+
res, err := Preview(context.Background(), PreviewOptions{Input: in, Rows: 5})
65+
if err != nil {
66+
t.Fatal(err)
67+
}
68+
if len(res.Headers) != 0 || len(res.Rows) != 0 {
69+
t.Errorf("empty file should yield empty result, got %+v", res)
70+
}
71+
}

0 commit comments

Comments
 (0)