package main import ( "bufio" "fmt" "os" "slices" "strings" ) // ioBufSize is the buffer size for the buffered stdout writers. const ioBufSize = 1 << 20 // minGroupSize is the smallest number of members that makes a // duplicate group. const minGroupSize = 2 // scanRec is one file record from the database. The signature (size, // head, tail) is the duplicate key; mtime is informational only and // used by scan for change detection. type scanRec struct { size int64 mtime int64 head string tail string path string } // loadRecords opens the database and reads every file record for the // report and trees subcommands. Any database problem — including a // missing database — is fatal. func loadRecords() []scanRec { dbPath := databasePath() db, err := openReportDatabase(dbPath) if err != nil { fatalf("%v", err) } defer func() { _ = db.Close() }() recs, err := loadFileRows(db) if err != nil { fatalf("database %s: %v", dbPath, err) } return recs } // dupeGroup is one set of candidate-duplicate files: identical size, // head hash, and tail hash. paths is sorted lexicographically; the // first entry is the group's "first", the rest are dupes. type dupeGroup struct { size int64 paths []string } // runReport implements the report subcommand: it reads every record // from the database and prints the file-level duplicates report as TSV // on stdout. It never touches the scanned filesystem; its only I/O is // the database, stdout, and stderr. func runReport() { recs := loadRecords() dupes := collectDupeGroups(recs) out := bufio.NewWriterSize(os.Stdout, ioBufSize) _, err := fmt.Fprintln(out, "first\tdupe\tsize") if err != nil { fatalf("write stdout: %v", err) } dupeFiles := 0 var reclaimable int64 for _, g := range dupes { for _, p := range g.paths[1:] { _, err = fmt.Fprintf(out, "%s\t%s\t%d\n", g.paths[0], p, g.size) if err != nil { fatalf("write stdout: %v", err) } dupeFiles++ reclaimable += g.size } } err = out.Flush() if err != nil { fatalf("write stdout: %v", err) } fmt.Fprintf(os.Stderr, "report: %d records read, %d duplicate groups, %d dupe files, "+ "%s reclaimable\n", len(recs), len(dupes), dupeFiles, humanBytes(reclaimable)) } // collectDupeGroups groups records by signature and returns every group // with two or more paths, each group's paths sorted lexicographically, // groups ordered by size descending then by first path ascending. func collectDupeGroups(recs []scanRec) []dupeGroup { groups := make(map[fileSig][]string) for _, r := range recs { k := fileSig{size: r.size, head: r.head, tail: r.tail} groups[k] = append(groups[k], r.path) } var dupes []dupeGroup for k, paths := range groups { if len(paths) < minGroupSize { continue } slices.Sort(paths) dupes = append(dupes, dupeGroup{size: k.size, paths: paths}) } // Biggest reclaimable space first; ties broken by first path. slices.SortFunc(dupes, func(a, b dupeGroup) int { if a.size != b.size { if a.size > b.size { return -1 } return 1 } return strings.Compare(a.paths[0], b.paths[0]) }) return dupes } // humanBytes formats a byte count in human units (binary prefixes). func humanBytes(n int64) string { const unit = 1024 if n < unit { return fmt.Sprintf("%d B", n) } div, exp := int64(unit), 0 for m := n / unit; m >= unit; m /= unit { div *= unit exp++ } return fmt.Sprintf("%.1f %ciB", float64(n)/float64(div), "KMGTPE"[exp]) }