Skip to content

Commit 894d7bc

Browse files
committed
refactor(stats): migrate to pkg/csvops library
Stats(ctx, StatsOptions) (StatsResult, error). Returns structured per-column data (Unique, UniqueCapped, Empty, Top []ValueCount) — desktop UI renders it as a table without re-parsing text. cmd/stats.go handles the tablewriter formatting. Tests cover basic counts, --max-unique cap behavior (and that early-seen values keep counting after the cap hits), top-N selection, and empty files.
1 parent a9d361c commit 894d7bc

3 files changed

Lines changed: 274 additions & 96 deletions

File tree

cmd/stats.go

Lines changed: 27 additions & 96 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,12 @@
11
package cmd
22

33
import (
4-
"encoding/csv"
4+
"context"
55
"fmt"
6-
"io"
76
"os"
8-
"sort"
97
"strings"
108

9+
"github.com/maherelgamil/csvops/pkg/csvops"
1110
"github.com/olekukonko/tablewriter"
1211
"github.com/schollz/progressbar/v3"
1312
"github.com/spf13/cobra"
@@ -26,116 +25,48 @@ var statsCmd = &cobra.Command{
2625
return fmt.Errorf("please provide an input file using --input")
2726
}
2827

29-
totalRows, err := countDataRows(statsInput, ',')
28+
var bar *progressbar.ProgressBar
29+
res, err := csvops.Stats(context.Background(), csvops.StatsOptions{
30+
Input: statsInput,
31+
MaxUnique: statsMaxUnique,
32+
Delimiter: ',',
33+
Progress: func(done, total int64) {
34+
if bar == nil {
35+
bar = progressbar.Default(total, "Analyzing")
36+
}
37+
_ = bar.Set64(done)
38+
},
39+
})
3040
if err != nil {
3141
return err
3242
}
3343

34-
file, err := os.Open(statsInput)
35-
if err != nil {
36-
return fmt.Errorf("failed to open file: %w", err)
37-
}
38-
defer file.Close()
39-
40-
reader := csv.NewReader(file)
41-
reader.FieldsPerRecord = -1
42-
43-
headers, err := reader.Read()
44-
if err != nil {
45-
return fmt.Errorf("failed to read headers: %w", err)
46-
}
47-
48-
columnCount := len(headers)
49-
50-
type columnStats struct {
51-
empty int
52-
uniques map[string]int
53-
capped bool
54-
totalVals int
55-
}
56-
57-
stats := make([]columnStats, columnCount)
58-
for i := 0; i < columnCount; i++ {
59-
stats[i].uniques = make(map[string]int)
60-
}
61-
62-
bar := progressbar.Default(totalRows, "Analyzing")
63-
rowCount := 0
64-
65-
for {
66-
row, err := reader.Read()
67-
if err == io.EOF {
68-
break
69-
}
70-
if err != nil {
71-
continue
72-
}
73-
rowCount++
74-
for i := range headers {
75-
cell := ""
76-
if i < len(row) {
77-
cell = strings.TrimSpace(row[i])
78-
}
79-
if cell == "" {
80-
stats[i].empty++
81-
continue
82-
}
83-
stats[i].totalVals++
84-
if statsMaxUnique > 0 && len(stats[i].uniques) >= statsMaxUnique {
85-
if _, exists := stats[i].uniques[cell]; exists {
86-
stats[i].uniques[cell]++
87-
} else {
88-
stats[i].capped = true
89-
}
90-
} else {
91-
stats[i].uniques[cell]++
92-
}
93-
}
94-
_ = bar.Add(1)
95-
}
96-
9744
fmt.Printf("\n📊 Stats for: %s\n", statsInput)
98-
fmt.Printf("Total Rows (excluding header): %d\n", rowCount)
99-
fmt.Printf("Columns: %d\n\n", columnCount)
45+
fmt.Printf("Total Rows (excluding header): %d\n", res.TotalRows)
46+
fmt.Printf("Columns: %d\n\n", len(res.Columns))
10047

10148
table := tablewriter.NewWriter(os.Stdout)
10249
table.SetHeader([]string{"Column", "Unique Values", "Empty Fields", "Top 3 Values"})
10350
table.SetAutoWrapText(false)
10451
table.SetRowLine(true)
10552
table.SetAlignment(tablewriter.ALIGN_LEFT)
10653

107-
type kv struct {
108-
Key string
109-
Count int
110-
}
111-
112-
for i, name := range headers {
113-
uniqueCount := fmt.Sprintf("%d", len(stats[i].uniques))
114-
if stats[i].capped {
115-
uniqueCount = fmt.Sprintf(">=%d (capped)", len(stats[i].uniques))
54+
for _, col := range res.Columns {
55+
unique := fmt.Sprintf("%d", col.Unique)
56+
if col.UniqueCapped {
57+
unique = fmt.Sprintf(">=%d (capped)", col.Unique)
11658
}
117-
118-
sorted := make([]kv, 0, len(stats[i].uniques))
119-
for k, v := range stats[i].uniques {
120-
sorted = append(sorted, kv{k, v})
59+
top := make([]string, 0, len(col.Top))
60+
for _, v := range col.Top {
61+
top = append(top, fmt.Sprintf("%s (%d)", v.Value, v.Count))
12162
}
122-
sort.Slice(sorted, func(a, b int) bool {
123-
return sorted[a].Count > sorted[b].Count
124-
})
125-
126-
topValues := []string{}
127-
for j := 0; j < len(sorted) && j < 3; j++ {
128-
topValues = append(topValues, fmt.Sprintf("%s (%d)", sorted[j].Key, sorted[j].Count))
129-
}
130-
13163
table.Append([]string{
132-
name,
133-
uniqueCount,
134-
fmt.Sprintf("%d", stats[i].empty),
135-
strings.Join(topValues, ", "),
64+
col.Name,
65+
unique,
66+
fmt.Sprintf("%d", col.Empty),
67+
strings.Join(top, ", "),
13668
})
13769
}
138-
13970
table.Render()
14071
return nil
14172
},

pkg/csvops/stats.go

Lines changed: 147 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,147 @@
1+
package csvops
2+
3+
import (
4+
"context"
5+
"encoding/csv"
6+
"fmt"
7+
"io"
8+
"os"
9+
"sort"
10+
"strings"
11+
)
12+
13+
// StatsOptions configures a Stats operation.
14+
type StatsOptions struct {
15+
Input string
16+
// MaxUnique caps the number of distinct values tracked per column.
17+
// 0 means unlimited. When a column hits the cap, new values are not
18+
// recorded but existing values' counts continue to increment and the
19+
// column is flagged as UniqueCapped.
20+
MaxUnique int
21+
Delimiter rune
22+
Progress Progress
23+
}
24+
25+
// ValueCount is a single (value, occurrence count) pair.
26+
type ValueCount struct {
27+
Value string
28+
Count int
29+
}
30+
31+
// ColumnStats is the per-column summary.
32+
type ColumnStats struct {
33+
Name string
34+
Unique int // number of distinct non-empty values seen (bounded by MaxUnique)
35+
UniqueCapped bool // true if MaxUnique was reached
36+
Empty int
37+
Top []ValueCount // top N by count (N=3)
38+
}
39+
40+
// StatsResult is returned from Stats.
41+
type StatsResult struct {
42+
TotalRows int64
43+
Columns []ColumnStats
44+
}
45+
46+
// Stats scans the CSV and returns row count plus per-column summary
47+
// (unique value count, empty cell count, and top 3 most frequent values).
48+
func Stats(ctx context.Context, opts StatsOptions) (StatsResult, error) {
49+
var res StatsResult
50+
51+
if opts.Input == "" {
52+
return res, fmt.Errorf("input is required")
53+
}
54+
if opts.Delimiter == 0 {
55+
opts.Delimiter = ','
56+
}
57+
58+
total, err := CountDataRows(opts.Input, opts.Delimiter)
59+
if err != nil {
60+
return res, err
61+
}
62+
63+
f, err := os.Open(opts.Input)
64+
if err != nil {
65+
return res, fmt.Errorf("open input: %w", err)
66+
}
67+
defer f.Close()
68+
69+
reader := csv.NewReader(f)
70+
reader.Comma = opts.Delimiter
71+
reader.FieldsPerRecord = -1
72+
73+
headers, err := reader.Read()
74+
if err != nil {
75+
return res, fmt.Errorf("read headers: %w", err)
76+
}
77+
78+
type acc struct {
79+
empty int
80+
uniques map[string]int
81+
capped bool
82+
}
83+
cols := make([]acc, len(headers))
84+
for i := range cols {
85+
cols[i].uniques = make(map[string]int)
86+
}
87+
88+
var processed int64
89+
for {
90+
if err := ctx.Err(); err != nil {
91+
return res, err
92+
}
93+
row, err := reader.Read()
94+
if err == io.EOF {
95+
break
96+
}
97+
if err != nil {
98+
continue
99+
}
100+
res.TotalRows++
101+
for i := range headers {
102+
cell := ""
103+
if i < len(row) {
104+
cell = strings.TrimSpace(row[i])
105+
}
106+
if cell == "" {
107+
cols[i].empty++
108+
continue
109+
}
110+
if opts.MaxUnique > 0 && len(cols[i].uniques) >= opts.MaxUnique {
111+
if _, exists := cols[i].uniques[cell]; exists {
112+
cols[i].uniques[cell]++
113+
} else {
114+
cols[i].capped = true
115+
}
116+
} else {
117+
cols[i].uniques[cell]++
118+
}
119+
}
120+
processed++
121+
safeProgress(opts.Progress, processed, total)
122+
}
123+
124+
res.Columns = make([]ColumnStats, len(headers))
125+
for i, name := range headers {
126+
c := &cols[i]
127+
sorted := make([]ValueCount, 0, len(c.uniques))
128+
for k, v := range c.uniques {
129+
sorted = append(sorted, ValueCount{Value: k, Count: v})
130+
}
131+
sort.Slice(sorted, func(a, b int) bool {
132+
return sorted[a].Count > sorted[b].Count
133+
})
134+
top := sorted
135+
if len(top) > 3 {
136+
top = top[:3]
137+
}
138+
res.Columns[i] = ColumnStats{
139+
Name: name,
140+
Unique: len(c.uniques),
141+
UniqueCapped: c.capped,
142+
Empty: c.empty,
143+
Top: top,
144+
}
145+
}
146+
return res, nil
147+
}

0 commit comments

Comments
 (0)