11package cmd
22
33import (
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-
19050func init () {
19151 rootCmd .AddCommand (dedupeCmd )
19252
0 commit comments