Skip to content

Commit 9192e18

Browse files
committed
refactor(dedupe): migrate to pkg/csvops library
Mirrors the split refactor: Dedupe(ctx, DedupeOptions) (DedupeResult, error) with progress callback and context cancellation. cmd/dedupe.go is now a thin flag-parsing wrapper. Tests moved from cmd/ to pkg/csvops/ and broadened to cover the recent fixes: - output preserves original file order (regression for the map-iteration bug) - determinism across repeated runs - composite keys, case sensitivity, in-place overwrite, unknown-key errors
1 parent 6ffe3d8 commit 9192e18

4 files changed

Lines changed: 415 additions & 211 deletions

File tree

cmd/dedupe.go

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

33
import (
4-
"encoding/csv"
4+
"context"
55
"fmt"
6-
"io"
7-
"os"
8-
"sort"
96
"strings"
107

8+
"github.com/maherelgamil/csvops/pkg/csvops"
119
"github.com/schollz/progressbar/v3"
1210
"github.com/spf13/cobra"
1311
)
@@ -24,169 +22,31 @@ var dedupeCmd = &cobra.Command{
2422
Use: "dedupe",
2523
Short: "Remove duplicate rows from a CSV file based on key column(s)",
2624
RunE: func(cmd *cobra.Command, args []string) error {
27-
totalLines, err := countDataRows(dedupeInput, ',')
25+
var bar *progressbar.ProgressBar
26+
res, err := csvops.Dedupe(context.Background(), csvops.DedupeOptions{
27+
Input: dedupeInput,
28+
Output: dedupeOutput,
29+
KeyColumns: strings.Split(dedupeKeyColumns, ","),
30+
KeepLast: dedupeKeepLast,
31+
CaseSensitive: caseSensitiveDedupe,
32+
Delimiter: ',',
33+
Progress: func(done, total int64) {
34+
if bar == nil {
35+
bar = progressbar.Default(total, "Deduplicating")
36+
}
37+
_ = bar.Set64(done)
38+
},
39+
})
2840
if err != nil {
2941
return err
3042
}
3143

32-
inFile, err := os.Open(dedupeInput)
33-
if err != nil {
34-
return fmt.Errorf("failed to open input file: %w", err)
35-
}
36-
defer inFile.Close()
37-
38-
reader := csv.NewReader(inFile)
39-
headers, err := reader.Read()
40-
if err != nil {
41-
return fmt.Errorf("failed to read headers: %w", err)
42-
}
43-
44-
keyCols := strings.Split(dedupeKeyColumns, ",")
45-
keyIndexes := make([]int, 0, len(keyCols))
46-
for _, key := range keyCols {
47-
lookup := key
48-
if !caseSensitiveDedupe {
49-
lookup = strings.ToLower(lookup)
50-
}
51-
found := false
52-
for i, h := range headers {
53-
colName := h
54-
if !caseSensitiveDedupe {
55-
colName = strings.ToLower(h)
56-
}
57-
if colName == lookup {
58-
keyIndexes = append(keyIndexes, i)
59-
found = true
60-
break
61-
}
62-
}
63-
if !found {
64-
return fmt.Errorf("column %q not found in headers", key)
65-
}
66-
}
67-
68-
tempPath := dedupeOutput + ".tmp"
69-
outFile, err := os.Create(tempPath)
70-
if err != nil {
71-
return fmt.Errorf("failed to create temp output file: %w", err)
72-
}
73-
74-
writer := csv.NewWriter(outFile)
75-
if err := writer.Write(headers); err != nil {
76-
outFile.Close()
77-
return fmt.Errorf("failed to write header: %w", err)
78-
}
79-
80-
bar := progressbar.Default(totalLines, "Deduplicating")
81-
duplicates := 0
82-
unique := 0
83-
84-
if dedupeKeepLast {
85-
// Must buffer everything: we only know "last" after seeing all rows.
86-
rows := [][]string{}
87-
seen := make(map[string]int) // key -> index into rows
88-
rowIdx := 0
89-
for {
90-
row, err := reader.Read()
91-
if err == io.EOF {
92-
break
93-
}
94-
if err != nil {
95-
_ = bar.Add(1)
96-
continue
97-
}
98-
if len(row) < len(headers) {
99-
_ = bar.Add(1)
100-
continue
101-
}
102-
key := buildDedupeKey(row, keyIndexes, caseSensitiveDedupe)
103-
if _, exists := seen[key]; exists {
104-
duplicates++
105-
}
106-
rows = append(rows, row)
107-
seen[key] = rowIdx
108-
rowIdx++
109-
_ = bar.Add(1)
110-
}
111-
// Emit kept rows in original file order.
112-
kept := make([]int, 0, len(seen))
113-
for _, idx := range seen {
114-
kept = append(kept, idx)
115-
}
116-
sort.Ints(kept)
117-
for _, idx := range kept {
118-
if err := writer.Write(rows[idx]); err != nil {
119-
outFile.Close()
120-
return fmt.Errorf("failed to write row: %w", err)
121-
}
122-
}
123-
unique = len(kept)
124-
} else {
125-
// keep-first: stream rows directly as we encounter them.
126-
seen := make(map[string]struct{})
127-
for {
128-
row, err := reader.Read()
129-
if err == io.EOF {
130-
break
131-
}
132-
if err != nil {
133-
_ = bar.Add(1)
134-
continue
135-
}
136-
if len(row) < len(headers) {
137-
_ = bar.Add(1)
138-
continue
139-
}
140-
key := buildDedupeKey(row, keyIndexes, caseSensitiveDedupe)
141-
if _, exists := seen[key]; exists {
142-
duplicates++
143-
} else {
144-
seen[key] = struct{}{}
145-
if err := writer.Write(row); err != nil {
146-
outFile.Close()
147-
return fmt.Errorf("failed to write row: %w", err)
148-
}
149-
unique++
150-
}
151-
_ = bar.Add(1)
152-
}
153-
}
154-
155-
writer.Flush()
156-
if err := writer.Error(); err != nil {
157-
outFile.Close()
158-
return fmt.Errorf("writer error: %w", err)
159-
}
160-
if err := outFile.Close(); err != nil {
161-
return fmt.Errorf("failed to close output: %w", err)
162-
}
163-
164-
// Close input before rename for Windows compatibility when overwriting in place.
165-
if dedupeOutput == dedupeInput {
166-
inFile.Close()
167-
}
168-
if err := os.Rename(tempPath, dedupeOutput); err != nil {
169-
return fmt.Errorf("failed to rename temp file: %w", err)
170-
}
171-
17244
fmt.Printf("\n✅ Duplicates removed. Output written to %s\n", dedupeOutput)
173-
fmt.Printf("📊 Total rows: %d | Unique: %d | Duplicates removed: %d\n", totalLines, unique, duplicates)
45+
fmt.Printf("📊 Total rows: %d | Unique: %d | Duplicates removed: %d\n", res.TotalRows, res.UniqueRows, res.Duplicates)
17446
return nil
17547
},
17648
}
17749

178-
func buildDedupeKey(row []string, indexes []int, caseSensitive bool) string {
179-
parts := make([]string, len(indexes))
180-
for i, idx := range indexes {
181-
v := row[idx]
182-
if !caseSensitive {
183-
v = strings.ToLower(v)
184-
}
185-
parts[i] = v
186-
}
187-
return strings.Join(parts, "||")
188-
}
189-
19050
func init() {
19151
rootCmd.AddCommand(dedupeCmd)
19252

cmd/dedupe_test.go

Lines changed: 0 additions & 53 deletions
This file was deleted.

0 commit comments

Comments
 (0)