diff --git a/cmd/squirrel/index.go b/cmd/squirrel/index.go index 1172c60..e17e50f 100644 --- a/cmd/squirrel/index.go +++ b/cmd/squirrel/index.go @@ -2,10 +2,13 @@ package main import ( "fmt" + "time" "github.com/spf13/cobra" + "github.com/mbertschler/squirrel/config" "github.com/mbertschler/squirrel/index" + "github.com/mbertschler/squirrel/store" ) // newIndexCmd returns the `squirrel index ` cobra command. The @@ -55,6 +58,11 @@ func runIndex(cmd *cobra.Command, volumeName string, progress bool, opts index.O } defer s.Close() + // Captured before the walk (so it reflects the prior run, not this + // one): its presence tells a first index from a re-index, and its + // timestamp feeds the catch-up note. + priorNs, hadPrior := priorIndexRun(cmd, s, vol.Name) + // Progress renders to stderr so a redirected stdout still captures only // the final summary line. The line is erased right after Index returns // (both paths) so any error list and the summary print on a clean line. @@ -79,10 +87,67 @@ func runIndex(cmd *cobra.Command, volumeName string, progress bool, opts index.O if opts.DryRun { prefix = "(dry-run) " } - fmt.Fprintf(cmd.OutOrStdout(), "%sadded=%d modified=%d unchanged=%d missing=%d errors=%d\n", - prefix, rep.Added, rep.Modified, rep.Unchanged, rep.Missing, rep.Errors) + // Naming the volume keeps back-to-back index runs legible instead of + // three anonymous count lines (friction F11a). + fmt.Fprintf(cmd.OutOrStdout(), "%s%s: added=%d modified=%d unchanged=%d missing=%d errors=%d\n", + prefix, vol.Name, rep.Added, rep.Modified, rep.Unchanged, rep.Missing, rep.Errors) + printIndexAdvisories(cmd, vol, rep, priorNs, hadPrior) if rep.Errors > 0 { return fmt.Errorf("encountered %d error(s) during walk", rep.Errors) } return nil } + +// printIndexAdvisories surfaces the two affirmative-feedback lines the +// friction walk asked for: an empty-path warning on a first index (F8) +// and a catch-up note after a burst of new files following a quiet gap +// (F24). Both are advisory — neither changes the exit code. +func printIndexAdvisories(cmd *cobra.Command, vol *config.Volume, rep index.Report, priorNs int64, hadPrior bool) { + if !hadPrior && rep.Errors == 0 && !rep.SawFiles() { + fmt.Fprintf(cmd.ErrOrStderr(), + "warning: volume %q at %s: no files found — new volume, empty directory, or wrong mount?\n", + vol.Name, vol.Path) + return + } + if hadPrior && rep.SawFiles() { + if note, ok := catchUpNote(rep.Added, time.Since(time.Unix(0, priorNs))); ok { + fmt.Fprintln(cmd.OutOrStdout(), note) + } + } +} + +// priorIndexRun returns the start time of the most recent successful index +// run of the volume, and whether one exists. Best-effort: any lookup miss +// (volume never indexed, store error) reports "no prior", which is the safe +// default for both advisories. +func priorIndexRun(cmd *cobra.Command, s *store.Store, volName string) (int64, bool) { + v, err := s.GetVolumeByName(cmd.Context(), volName) + if err != nil { + return 0, false + } + run, err := s.LatestSuccessfulIndexRun(cmd.Context(), v.ID) + if err != nil { + return 0, false + } + return run.StartedAtNs, true +} + +// Catch-up heuristic thresholds: a burst of new files after a long quiet +// gap is the moment squirrel earns the most trust (a trip's worth of +// photos landing in one tick), and it should not blend into routine churn. +// The bounds are deliberately conservative so ordinary cadence runs stay +// silent. +const ( + catchUpMinFiles = 25 + catchUpMinQuiet = 7 * 24 * time.Hour +) + +// catchUpNote returns a one-line "N new files after D days" summary when a +// run added enough files after a long enough quiet gap, and whether it +// applies (friction F24). +func catchUpNote(added int, quiet time.Duration) (string, bool) { + if added < catchUpMinFiles || quiet < catchUpMinQuiet { + return "", false + } + return fmt.Sprintf("catch-up: %d new files after %d days of quiet", added, int(quiet.Hours())/24), true +} diff --git a/cmd/squirrel/polish_test.go b/cmd/squirrel/polish_test.go new file mode 100644 index 0000000..627b0ae --- /dev/null +++ b/cmd/squirrel/polish_test.go @@ -0,0 +1,159 @@ +package main + +import ( + "database/sql" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/mbertschler/squirrel/store" +) + +// TestRunNeedsAttention pins the --failed predicate: only the non-clean +// terminal states qualify (F19). +func TestRunNeedsAttention(t *testing.T) { + cases := map[string]bool{ + store.RunStatusFailed: true, + store.RunStatusRefused: true, + store.RunStatusAborted: true, + store.RunStatusPartial: true, + store.RunStatusSuccess: false, + store.RunStatusRunning: false, + } + for status, want := range cases { + if got := runNeedsAttention(status); got != want { + t.Errorf("runNeedsAttention(%q) = %v, want %v", status, got, want) + } + } +} + +// TestRunIsInteresting pins the --changes predicate: clean successes that +// touched nothing fold; anything that moved files, resolved a conflict, or +// needs attention shows (F19). +func TestRunIsInteresting(t *testing.T) { + conflicts := map[int64]int{7: 2} + tests := []struct { + name string + r store.Run + want bool + }{ + {"clean no-op", store.Run{ID: 1, Status: store.RunStatusSuccess, FileCount: 0}, false}, + {"success moved files", store.Run{ID: 2, Status: store.RunStatusSuccess, FileCount: 5}, true}, + {"failed", store.Run{ID: 3, Status: store.RunStatusFailed}, true}, + {"refused", store.Run{ID: 4, Status: store.RunStatusRefused}, true}, + {"success with conflict", store.Run{ID: 7, Status: store.RunStatusSuccess, FileCount: 0}, true}, + } + for _, tc := range tests { + if got := runIsInteresting(tc.r, conflicts); got != tc.want { + t.Errorf("%s: runIsInteresting = %v, want %v", tc.name, got, tc.want) + } + } +} + +// TestFilterRuns exercises both filters end to end, including that the +// descending order is preserved. +func TestFilterRuns(t *testing.T) { + runs := []store.Run{ + {ID: 5, Status: store.RunStatusSuccess, FileCount: 0}, // no-op + {ID: 4, Status: store.RunStatusSuccess, FileCount: 3}, // change + {ID: 3, Status: store.RunStatusFailed}, // failed + {ID: 2, Status: store.RunStatusRefused}, // refused + {ID: 1, Status: store.RunStatusSuccess, FileCount: 0}, // no-op + } + conflicts := map[int64]int{} + + if ids := runIDs(filterRuns(runs, conflicts, true, false)); !sameIDs(ids, []int64{3, 2}) { + t.Fatalf("--failed ids = %v, want [3 2]", ids) + } + if ids := runIDs(filterRuns(runs, conflicts, false, true)); !sameIDs(ids, []int64{4, 3, 2}) { + t.Fatalf("--changes ids = %v, want [4 3 2]", ids) + } +} + +// TestPeerDirectionArrow / TestPeerLabelDirection cover F18: the initiator +// records runs.shallow, the receiver leaves it NULL, and the arrow follows. +func TestPeerDirectionArrow(t *testing.T) { + if got := peerDirectionArrow(store.Run{Shallow: sql.NullBool{Valid: true}}); got != "→" { + t.Errorf("initiator arrow = %q, want →", got) + } + if got := peerDirectionArrow(store.Run{Shallow: sql.NullBool{}}); got != "←" { + t.Errorf("receiver arrow = %q, want ←", got) + } +} + +func TestPeerLabelDirection(t *testing.T) { + nodes := map[int64]string{9: "htpc"} + out := peerLabel(store.Run{PeerNodeID: sql.NullInt64{Int64: 9, Valid: true}, Shallow: sql.NullBool{Valid: true}}, nodes) + if !strings.HasPrefix(out, "→ htpc") { + t.Errorf("outbound peer label = %q, want prefix %q", out, "→ htpc") + } + in := peerLabel(store.Run{PeerNodeID: sql.NullInt64{Int64: 9, Valid: true}}, nodes) + if !strings.HasPrefix(in, "← htpc") { + t.Errorf("inbound peer label = %q, want prefix %q", in, "← htpc") + } +} + +// TestCatchUpNote pins the F24 heuristic and its rendering. +func TestCatchUpNote(t *testing.T) { + if _, ok := catchUpNote(catchUpMinFiles, catchUpMinQuiet); !ok { + t.Fatal("expected catch-up at the thresholds") + } + note, ok := catchUpNote(404, 21*24*time.Hour) + if !ok || !strings.Contains(note, "404 new files") || !strings.Contains(note, "21 days") { + t.Fatalf("catch-up note = %q ok=%v", note, ok) + } + if _, ok := catchUpNote(catchUpMinFiles-1, catchUpMinQuiet); ok { + t.Error("below file threshold should not trigger") + } + if _, ok := catchUpNote(catchUpMinFiles, catchUpMinQuiet-time.Hour); ok { + t.Error("below quiet threshold should not trigger") + } +} + +// TestCLIIndexNamesVolume covers F11a: the index summary names the volume. +func TestCLIIndexNamesVolume(t *testing.T) { + src := t.TempDir() + writeTestFile(t, filepath.Join(src, "a.txt"), "hello") + f := writeConfigFor(t, map[string]string{"docs": src}) + out := runCLI(t, "--config", f.configPath, "index", "docs") + if !strings.Contains(out, "docs: added=1") { + t.Fatalf("index summary should name the volume: %q", out) + } +} + +// TestCLIIndexEmptyVolumeWarns covers F8: a first index of an empty tree +// warns, and a subsequent populated re-index does not. +func TestCLIIndexEmptyVolumeWarns(t *testing.T) { + src := t.TempDir() // empty + f := writeConfigFor(t, map[string]string{"docs": src}) + out := runCLI(t, "--config", f.configPath, "index", "docs") + if !strings.Contains(out, "no files found") { + t.Fatalf("empty first index should warn: %q", out) + } + writeTestFile(t, filepath.Join(src, "a.txt"), "hi") + out2 := runCLI(t, "--config", f.configPath, "index", "docs") + if strings.Contains(out2, "no files found") { + t.Fatalf("populated re-index should not warn: %q", out2) + } +} + +func runIDs(runs []store.Run) []int64 { + out := make([]int64, len(runs)) + for i, r := range runs { + out[i] = r.ID + } + return out +} + +func sameIDs(a, b []int64) bool { + if len(a) != len(b) { + return false + } + for i := range a { + if a[i] != b[i] { + return false + } + } + return true +} diff --git a/cmd/squirrel/restore.go b/cmd/squirrel/restore.go index deab82e..2a6076d 100644 --- a/cmd/squirrel/restore.go +++ b/cmd/squirrel/restore.go @@ -100,7 +100,9 @@ func runRestore(cmd *cobra.Command, volumeName, fromName string, opts sync.Resto } rep, runErr := sync.Restore(cmd.Context(), s, rcl, vol, dest, opts) - printSyncReport(out, rep, runErr) + // Restore pulls destination → local, so flip the arrow: the sync-shaped + // "volume → destination" would misreport the byte-flow direction. + printSyncReport(out, rep, runErr, true) if runErr != nil || rep.Status != "success" { return fmt.Errorf("restore did not complete cleanly") } diff --git a/cmd/squirrel/restore_test.go b/cmd/squirrel/restore_test.go index b83b67f..5d6d25a 100644 --- a/cmd/squirrel/restore_test.go +++ b/cmd/squirrel/restore_test.go @@ -43,6 +43,11 @@ func TestCLIRestoreRoundTrip(t *testing.T) { if !strings.Contains(out, "status=success") { t.Fatalf("restore did not succeed:\n%s", out) } + // The restore arrow flips to destination → volume: bytes flow that + // way, unlike a sync's volume → destination (restore-arrow friction). + if !strings.Contains(out, "scratch → pics") { + t.Fatalf("restore arrow should read destination → volume:\n%s", out) + } for _, name := range []string{"a.txt", "b.txt"} { path := filepath.Join(f.volumeDir, name) body, err := os.ReadFile(path) diff --git a/cmd/squirrel/runs.go b/cmd/squirrel/runs.go index 67e96a0..c21456e 100644 --- a/cmd/squirrel/runs.go +++ b/cmd/squirrel/runs.go @@ -15,29 +15,35 @@ import ( // newRunsCmd returns the `squirrel runs` cobra command. It lists rows from // the runs table, most-recent first, with optional volume filtering and a -// configurable cap. Runs of every status (running, success, failed, partial) -// are surfaced so the user can see in-flight or failed indexing alongside -// successful ones. +// configurable cap. Runs of every kind (index, sync, audit, restore, +// offload) and every status (running, success, failed, partial, refused, +// aborted) are surfaced so in-flight, failed, or refused work shows up +// alongside successful runs. --failed and --changes narrow steady-state +// noise (friction F19). func newRunsCmd() *cobra.Command { var ( - volumeName string - limit int + volumeName string + limit int + onlyFailed bool + onlyChanges bool ) cmd := &cobra.Command{ Use: "runs", - Short: "List index runs (most recent first)", + Short: "List runs of every kind (most recent first)", Args: cobra.NoArgs, RunE: func(cmd *cobra.Command, _ []string) error { - return runRuns(cmd, volumeName, limit) + return runRuns(cmd, volumeName, limit, onlyFailed, onlyChanges) }, } cmd.Flags().StringVar(&volumeName, "volume", "", "filter to runs against this volume name") cmd.Flags().IntVar(&limit, "limit", 20, "maximum number of runs to show (0 for no limit)") + cmd.Flags().BoolVar(&onlyFailed, "failed", false, "show only runs that need attention (failed, refused, aborted, or partial)") + cmd.Flags().BoolVar(&onlyChanges, "changes", false, "hide clean no-op runs; show only runs that moved content or need attention") cmd.AddCommand(newRunsFailCmd()) return cmd } -func runRuns(cmd *cobra.Command, volumeName string, limit int) error { +func runRuns(cmd *cobra.Command, volumeName string, limit int, onlyFailed, onlyChanges bool) error { cfg, err := tryLoadConfig(cmd) if err != nil { return err @@ -48,18 +54,7 @@ func runRuns(cmd *cobra.Command, volumeName string, limit int) error { } defer s.Close() - opts := store.ListRunsOpts{Limit: limit, Descending: true} - if volumeName != "" { - v, err := s.GetVolumeByName(cmd.Context(), volumeName) - if err != nil { - if store.IsNotFound(err) { - return fmt.Errorf("no volume named %q", volumeName) - } - return fmt.Errorf("lookup volume %q: %w", volumeName, err) - } - opts.VolumeID = &v.ID - } - runs, err := s.ListRuns(cmd.Context(), opts) + runs, conflicts, err := gatherRuns(cmd, s, volumeName, limit, onlyFailed, onlyChanges) if err != nil { return err } @@ -71,10 +66,6 @@ func runRuns(cmd *cobra.Command, volumeName string, limit int) error { if err != nil { return err } - conflicts, err := loadConflictCounts(cmd, s, runs) - if err != nil { - return err - } if err := printRuns(cmd.OutOrStdout(), runs, volumes, nodes, conflicts); err != nil { return err } @@ -104,6 +95,123 @@ func printActiveAlarms(w io.Writer, alarms []store.DestinationAlarm) { } } +// gatherRuns resolves the optional volume filter, fetches runs, applies the +// --failed/--changes predicates, and returns the rows to display plus the +// conflict counts for their CONFLICTS column. When a predicate is active the +// fetch is unbounded (opts.Limit=0) so the SQL LIMIT can't cap before the +// predicate runs and silently drop matches; the result is truncated to limit +// after filtering instead. +// +// Conflict counts are loaded only where they are actually needed, never +// across the whole unbounded fetch: --failed needs none for filtering, and +// the CONFLICTS column only covers the displayed rows. This keeps the id set +// handed to the count query small (and the query itself batches ids), so a +// large peer-sync history can't overflow SQLite's bound-parameter cap. +func gatherRuns(cmd *cobra.Command, s *store.Store, volumeName string, limit int, onlyFailed, onlyChanges bool) ([]store.Run, map[int64]int, error) { + filtering := onlyFailed || onlyChanges + opts := store.ListRunsOpts{Limit: limit, Descending: true} + if filtering { + opts.Limit = 0 + } + if volumeName != "" { + v, err := s.GetVolumeByName(cmd.Context(), volumeName) + if err != nil { + if store.IsNotFound(err) { + return nil, nil, fmt.Errorf("no volume named %q", volumeName) + } + return nil, nil, fmt.Errorf("lookup volume %q: %w", volumeName, err) + } + opts.VolumeID = &v.ID + } + runs, err := s.ListRuns(cmd.Context(), opts) + if err != nil { + return nil, nil, err + } + if filtering { + filterConflicts, err := conflictCountsForFilter(cmd, s, runs, onlyFailed) + if err != nil { + return nil, nil, err + } + runs = filterRuns(runs, filterConflicts, onlyFailed, onlyChanges) + if limit > 0 && len(runs) > limit { + runs = runs[:limit] + } + } + // Load the CONFLICTS-column counts for exactly the displayed rows. + conflicts, err := loadConflictCounts(cmd, s, runs) + if err != nil { + return nil, nil, err + } + return runs, conflicts, nil +} + +// conflictCountsForFilter loads only the conflict counts the active filter +// needs. --failed consults none — an attention-worthy run is unconditionally +// kept — so it returns an empty map. --changes needs them only for the +// ambiguous rows (a clean success that touched no files), so it counts just +// those rather than the whole unbounded fetch. +func conflictCountsForFilter(cmd *cobra.Command, s *store.Store, runs []store.Run, onlyFailed bool) (map[int64]int, error) { + if onlyFailed { + return map[int64]int{}, nil + } + var candidates []store.Run + for _, r := range runs { + if r.Status == store.RunStatusSuccess && r.FileCount == 0 { + candidates = append(candidates, r) + } + } + return loadConflictCounts(cmd, s, candidates) +} + +// filterRuns applies the --failed / --changes predicates in one pass, +// preserving the descending order the store returned. +func filterRuns(runs []store.Run, conflicts map[int64]int, onlyFailed, onlyChanges bool) []store.Run { + out := make([]store.Run, 0, len(runs)) + for _, r := range runs { + if onlyFailed && !runNeedsAttention(r.Status) { + continue + } + if onlyChanges && !runIsInteresting(r, conflicts) { + continue + } + out = append(out, r) + } + return out +} + +// runNeedsAttention reports whether a run ended in a state an operator +// should look at: a mid-flight failure, a preflight refusal, a reaped +// (aborted) run, or a partial completion. +func runNeedsAttention(status string) bool { + switch status { + case store.RunStatusFailed, store.RunStatusRefused, store.RunStatusAborted, store.RunStatusPartial: + return true + } + return false +} + +// runIsInteresting reports whether a run is worth showing under --changes: +// anything needing attention, still running, that touched files, or that +// resolved conflicts. A clean success that touched nothing is the routine +// no-op the filter hides. +// +// runs.file_count conflates transferred with already-correct for bucket and +// index runs (it is the total files considered, not the delta), so an +// in-sync bucket sync or an all-unchanged re-index still shows here — the +// filter deliberately errs toward showing rather than risk hiding a run +// that moved content. It reliably folds peer-sync no-ops, whose file_count +// is the receiver-verified (i.e. transferred) count and so is zero when +// nothing moved. +func runIsInteresting(r store.Run, conflicts map[int64]int) bool { + if r.Status != store.RunStatusSuccess { + return true + } + if r.FileCount > 0 { + return true + } + return conflicts[r.ID] > 0 +} + // loadNodeNames builds an id→name map for the peer_node_id values // referenced by the listing. Only peer-sync rows carry a non-NULL // peer_node_id, so this typically resolves to a small set of distinct @@ -200,10 +308,25 @@ func peerLabel(r store.Run, nodes map[int64]string) string { if !ok { name = fmt.Sprintf("id=%d", r.PeerNodeID.Int64) } + label := peerDirectionArrow(r) + " " + name if r.CorrelatedRunID.Valid { - return fmt.Sprintf("%s correlated=%d", name, r.CorrelatedRunID.Int64) + return fmt.Sprintf("%s correlated=%d", label, r.CorrelatedRunID.Int64) + } + return label +} + +// peerDirectionArrow labels which way a peer-sync ran so inbound and +// outbound are distinguishable in the audit trail (friction F18): the +// destination column holds a peer name in both directions. Only the +// initiator records runs.shallow — BeginSyncRunIfClear sets it, while the +// receiver's BeginPeerSyncRun leaves it NULL (see store.Run.Shallow) — so a +// set shallow flag marks the row as the side that pushed. "→" is outbound +// (we pushed to the peer), "←" inbound (the peer pushed to us). +func peerDirectionArrow(r store.Run) string { + if r.Shallow.Valid { + return "→" } - return name + return "←" } // conflictLabel formats the per-run conflict count for the listing. diff --git a/cmd/squirrel/sync.go b/cmd/squirrel/sync.go index 9abcc6f..7189767 100644 --- a/cmd/squirrel/sync.go +++ b/cmd/squirrel/sync.go @@ -74,11 +74,17 @@ func runSync(cmd *cobra.Command, volumeName, destinationName string, progress bo if opts.Shallow { fmt.Fprintln(out, shallowSyncWarning) } - if err := sync.EnsureMinVersion(cmd.Context(), rcl, out, sync.ShallowForPairs(pairs, opts.Shallow)); err != nil { - return err - } - if err := writeRcloneConfigLogged(out, rcl, cfg); err != nil { - return err + // Skip the rclone preamble entirely when every pair targets kopia: + // kopia never invokes rclone, so a version check is pointless and + // "rclone.conf updated" is misleading noise on a kopia-only sync + // (friction F11b). + if pairsNeedRclone(pairs) { + if err := sync.EnsureMinVersion(cmd.Context(), rcl, out, sync.ShallowForPairs(pairs, opts.Shallow)); err != nil { + return err + } + if err := writeRcloneConfigLogged(out, rcl, cfg); err != nil { + return err + } } // One snapshotter shared across every pair: the VACUUM INTO snapshot // is taken once per invocation and fanned out (decision #1). Disabled @@ -104,7 +110,7 @@ func runSync(cmd *cobra.Command, volumeName, destinationName string, progress bo if pp != nil { pp.clear() } - printSyncReport(out, rep, err) + printSyncReport(out, rep, err, false) if err != nil || rep.Status != "success" { anyFailed = true } @@ -146,6 +152,20 @@ func rcloneConfigPathFor(cfg *config.Config) string { return filepath.Join(filepath.Dir(cfg.Path), "rclone.conf") } +// pairsNeedRclone reports whether any pair in the batch drives rclone. +// Peer nodes (Destination nil) transfer bytes with rclone, as do every +// non-kopia bucket destination; only a batch composed entirely of kopia +// destinations skips rclone. Used to suppress the rclone preamble on a +// kopia-only sync (F11b). +func pairsNeedRclone(pairs []sync.Pair) bool { + for _, p := range pairs { + if p.Destination == nil || p.Destination.Type != "kopia" { + return true + } + } + return false +} + // writeRcloneConfigLogged renders the rclone.conf and logs a single line // when the file actually changed. An unexpected rewrite is worth // surfacing: the config is derived deterministically from squirrel's @@ -162,7 +182,10 @@ func writeRcloneConfigLogged(out io.Writer, rcl *sync.Rclone, cfg *config.Config return nil } -func printSyncReport(w io.Writer, rep sync.Report, runErr error) { +// printSyncReport renders one pair's outcome. reverse flips the arrow to +// destination → volume for restores, whose bytes flow the other way from +// a sync (the restore-arrow friction item). +func printSyncReport(w io.Writer, rep sync.Report, runErr error, reverse bool) { r := rep.RcloneResult for _, msg := range rep.Warnings { fmt.Fprintf(w, "warning: %s\n", msg) @@ -170,30 +193,19 @@ func printSyncReport(w io.Writer, rep sync.Report, runErr error) { for _, msg := range rep.NodePendingWarnings { fmt.Fprintf(w, "warning: peer reports %s\n", msg) } - switch rep.Verification.Method { - case sync.VerifyMethodKopia: - // Kopia pushes have no rclone counters; render the snapshot's - // own numbers instead. - fmt.Fprintf(w, "%s → %s status=%s files=%d bytes=%d snapshot=%s verified=%t run=%d\n", - rep.Volume, rep.Destination, rep.Status, - rep.Verification.Files, rep.Verification.Bytes, - rep.Verification.SnapshotID, rep.Verification.Verified(), rep.RunID, - ) - case sync.VerifyMethodPresenceSize: - // Content-addressed pushes count objects, with skipped = hashes - // the destination already recorded, entries = manifest segment - // lines, and fingerprints = provider checksums captured for the - // fresh uploads. - fmt.Fprintf(w, "%s → %s status=%s objects=%d skipped=%d errors=%d bytes=%d entries=%d fingerprints=%d run=%d\n", - rep.Volume, rep.Destination, rep.Status, - r.Transferred, r.Checked, r.DisplayErrors(), r.Bytes, - rep.Verification.Files, rep.Fingerprints, rep.RunID, - ) - default: - fmt.Fprintf(w, "%s → %s status=%s transferred=%d checked=%d errors=%d bytes=%d run=%d\n", - rep.Volume, rep.Destination, rep.Status, - r.Transferred, r.Checked, r.DisplayErrors(), r.Bytes, rep.RunID, - ) + src, dst := rep.Volume, rep.Destination + if reverse { + src, dst = rep.Destination, rep.Volume + } + // A run that never reached a terminal state — a preflight error before + // its runs row was allocated (unindexed volume, "already running") — + // carries an empty status and run=0; the counter line would read + // "status= … run=0", pure noise. Skip it and let the error below carry + // the message. Gates that mint a 'refused' row (#157) do have a status + // and run id, so they still print as their own condition (friction + // F11d). + if rep.Status != "" { + printSyncSummaryLine(w, rep, src, dst) } if rep.NodeReceiverRunID != 0 { fmt.Fprintf(w, " receiver_run=%d matched=%d mismatched=%d missing=%d conflicts=%d\n", @@ -247,3 +259,37 @@ func printSyncReport(w io.Writer, rep sync.Report, runErr error) { fmt.Fprintf(w, " %v\n", runErr) } } + +// printSyncSummaryLine renders the one-line counter summary for a pair, +// keyed off the handler's verification method. src/dst carry the already +// direction-resolved endpoints. The default (rclone bucket / peer / restore) +// line reports already_correct so an in-sync no-op is distinguishable from +// an empty one — transferred=0 alone is ambiguous (friction F7). +func printSyncSummaryLine(w io.Writer, rep sync.Report, src, dst string) { + r := rep.RcloneResult + switch rep.Verification.Method { + case sync.VerifyMethodKopia: + // Kopia pushes have no rclone counters; render the snapshot's + // own numbers instead. + fmt.Fprintf(w, "%s → %s status=%s files=%d bytes=%d snapshot=%s verified=%t run=%d\n", + src, dst, rep.Status, + rep.Verification.Files, rep.Verification.Bytes, + rep.Verification.SnapshotID, rep.Verification.Verified(), rep.RunID, + ) + case sync.VerifyMethodPresenceSize: + // Content-addressed pushes count objects, with skipped = hashes + // the destination already recorded, entries = manifest segment + // lines, and fingerprints = provider checksums captured for the + // fresh uploads. + fmt.Fprintf(w, "%s → %s status=%s objects=%d skipped=%d errors=%d bytes=%d entries=%d fingerprints=%d run=%d\n", + src, dst, rep.Status, + r.Transferred, r.Checked, r.DisplayErrors(), r.Bytes, + rep.Verification.Files, rep.Fingerprints, rep.RunID, + ) + default: + fmt.Fprintf(w, "%s → %s status=%s transferred=%d already_correct=%d errors=%d bytes=%d run=%d\n", + src, dst, rep.Status, + r.Transferred, rep.AlreadyCorrect, r.DisplayErrors(), r.Bytes, rep.RunID, + ) + } +} diff --git a/cmd/squirrel/sync_test.go b/cmd/squirrel/sync_test.go index 5902cbe..dcedf01 100644 --- a/cmd/squirrel/sync_test.go +++ b/cmd/squirrel/sync_test.go @@ -42,6 +42,22 @@ func TestCLISyncHappyPath(t *testing.T) { } } +// TestCLISyncAlreadyCorrect covers F7: a second sync of an unchanged +// volume reports already_correct so an in-sync no-op is distinguishable +// from an empty one (transferred=0 alone is ambiguous). +func TestCLISyncAlreadyCorrect(t *testing.T) { + requireRcloneCLI(t) + f := writeSyncFixture(t) + writeTestFile(t, filepath.Join(f.volumeDir, "a.txt"), "alpha") + writeTestFile(t, filepath.Join(f.volumeDir, "b.txt"), "beta") + runCLI(t, "--config", f.configPath, "index", f.volumeName) + runCLI(t, "--config", f.configPath, "sync", "pics") // first sync transfers + out := runCLI(t, "--config", f.configPath, "sync", "pics") + if !strings.Contains(out, "transferred=0") || !strings.Contains(out, "already_correct=2") { + t.Fatalf("second sync should report transferred=0 already_correct=2: %q", out) + } +} + func TestCLISyncUnknownDestinationFlag(t *testing.T) { f := writeSyncFixture(t) _, err := runCLIExpectErr(t, "--config", f.configPath, "sync", "pics", "--to", "ghost") @@ -105,6 +121,11 @@ sync_to = ["mirror"] if !strings.Contains(out, "snapshot=snap123") || !strings.Contains(out, "verified=true") { t.Fatalf("output missing the kopia snapshot summary:\n%s", out) } + // F11b: a kopia-only sync never touches rclone, so it must not print + // the "rclone.conf updated" line. + if strings.Contains(out, "rclone.conf updated") { + t.Fatalf("kopia-only sync should not log an rclone.conf update:\n%s", out) + } } func TestCLISyncRequiresConfig(t *testing.T) { diff --git a/index/index.go b/index/index.go index b9c43df..d219c5f 100644 --- a/index/index.go +++ b/index/index.go @@ -66,6 +66,14 @@ type Report struct { RunID int64 } +// SawFiles reports whether the walk observed any live file (added, +// modified, or unchanged). Zero across all three means the tree held no +// regular files this run — an empty or wrong-mounted volume path, which +// callers surface as a warning on first index since it is otherwise +// indistinguishable from a healthy no-op (friction F8). This package never +// writes to stderr, so the decision is returned rather than printed. +func (r Report) SawFiles() bool { return r.Added+r.Modified+r.Unchanged > 0 } + // ErrAlreadyRunning is returned from Index when store.BeginIndexRunIfClear // refuses to start because another index- or audit-kind run is already in // flight against the same volume. Callers can errors.As against this type diff --git a/store/files.go b/store/files.go index 107a2c6..9b355e9 100644 --- a/store/files.go +++ b/store/files.go @@ -922,6 +922,23 @@ func (s *Store) ListPresentPathsUnder(ctx context.Context, volumeID int64) (map[ return out, rows.Err() } +// CountPresentFilesInVolume returns the number of live (status='present') +// file rows in the volume. The peer-sync summary uses it to report the +// already-correct count: under the Merkle walk only differing folders +// reach /plan, so already-correct is derived as present-total minus the +// paths the sync acted on rather than counted from the (partial) +// disposition list. +func (s *Store) CountPresentFilesInVolume(ctx context.Context, volumeID int64) (int64, error) { + var n int64 + err := s.db.QueryRowContext(ctx, + `SELECT COUNT(*) FROM `+fileFromJoin+` + WHERE f.status = 'present' AND fo.volume_id = ?`, volumeID).Scan(&n) + if err != nil { + return 0, fmt.Errorf("count present files in volume %d: %w", volumeID, err) + } + return n, nil +} + // ListPresentFilesInFolder returns every present file row in one // folder, ordered by name ascending so the caller's downstream // processing is deterministic. Used by the Merkle walk's initiator diff --git a/store/runs.go b/store/runs.go index 027e4ac..98b51c5 100644 --- a/store/runs.go +++ b/store/runs.go @@ -400,11 +400,31 @@ func (s *Store) GetRun(ctx context.Context, id int64) (Run, error) { // from the sync-package directory naming convention. func (s *Store) CountFilesFirstSeenByRunWithPathPrefix(ctx context.Context, runIDs []int64, pathPrefix string) (map[int64]int, error) { out := make(map[int64]int, len(runIDs)) + // Batch the id set: each query binds len(chunk)+2 parameters, so an + // unbatched call over a large peer-sync history would overflow SQLite's + // bound-parameter cap (SQLITE_MAX_VARIABLE_NUMBER) and fail with "too + // many SQL variables". 400 keeps every query well under any cap. + const idsPerQuery = 400 + for start := 0; start < len(runIDs); start += idsPerQuery { + end := start + idsPerQuery + if end > len(runIDs) { + end = len(runIDs) + } + if err := s.countFilesFirstSeenChunk(ctx, runIDs[start:end], pathPrefix, out); err != nil { + return nil, err + } + } + return out, nil +} + +// countFilesFirstSeenChunk runs the grouped count for one id batch and folds +// the results into out. Non-zero counts only; absent keys mean zero. +func (s *Store) countFilesFirstSeenChunk(ctx context.Context, runIDs []int64, pathPrefix string, out map[int64]int) error { if len(runIDs) == 0 { - return out, nil + return nil } placeholders := strings.Repeat("?,", len(runIDs)-1) + "?" - args := make([]any, 0, len(runIDs)+1) + args := make([]any, 0, len(runIDs)+2) for _, id := range runIDs { args = append(args, id) } @@ -416,18 +436,18 @@ func (s *Store) CountFilesFirstSeenByRunWithPathPrefix(ctx context.Context, runI AND (fo.path = ? OR fo.path LIKE ? ESCAPE '\') GROUP BY f.first_seen_run_id`, args...) if err != nil { - return nil, fmt.Errorf("count files by run: %w", err) + return fmt.Errorf("count files by run: %w", err) } defer rows.Close() for rows.Next() { var id int64 var n int if err := rows.Scan(&id, &n); err != nil { - return nil, fmt.Errorf("scan count row: %w", err) + return fmt.Errorf("scan count row: %w", err) } out[id] = n } - return out, rows.Err() + return rows.Err() } // escapeLikePrefix escapes %, _ and \ in s so it can be embedded into a diff --git a/store/store_test.go b/store/store_test.go index 6eefdd0..477f997 100644 --- a/store/store_test.go +++ b/store/store_test.go @@ -2523,6 +2523,42 @@ func TestCountFilesFirstSeenByRunWithPathPrefix(t *testing.T) { } } +// TestCountFilesFirstSeenBatchesLargeIDSet guards the Copilot #179 fix: an +// id set far larger than SQLite's bound-parameter cap must be batched, not +// fail with "too many SQL variables". A real matching row buried among the +// synthetic ids confirms the per-batch results still merge correctly. +func TestCountFilesFirstSeenBatchesLargeIDSet(t *testing.T) { + s, err := Open(filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatalf("Open: %v", err) + } + defer s.Close() + ctx := context.Background() + volID := makeVolume(t, s, "/v") + run := makeRun(t, s, volID) + if err := s.Upsert(ctx, FileRow{ + VolumeID: volID, Path: ".squirrel-conflicts/run-1/a", Blake3: digest(0x42), + SizeBytes: 1, MtimeNs: 1, Status: StatusPresent, + FirstSeenRunID: run, LastSeenRunID: run, IndexedAtNs: 1, + }, nil); err != nil { + t.Fatalf("upsert: %v", err) + } + // Far more ids than any SQLITE_MAX_VARIABLE_NUMBER a single query could + // bind; the real run id is appended after 5000 synthetic non-matches. + ids := make([]int64, 0, 5001) + for i := int64(1); i <= 5000; i++ { + ids = append(ids, i*1000) + } + ids = append(ids, run) + counts, err := s.CountFilesFirstSeenByRunWithPathPrefix(ctx, ids, ".squirrel-conflicts") + if err != nil { + t.Fatalf("large id set errored (batching regressed?): %v", err) + } + if counts[run] != 1 { + t.Fatalf("counts[run] = %d, want 1", counts[run]) + } +} + // TestListPresentByOrigin pins the two filter modes: valid nodeID // returns rows attributed to that peer, NULL nodeID returns rows // without provenance (local writes). Superseded and missing rows diff --git a/sync/kopia.go b/sync/kopia.go index 9915ce8..15d4d64 100644 --- a/sync/kopia.go +++ b/sync/kopia.go @@ -8,6 +8,7 @@ import ( "os" "os/exec" "path/filepath" + "regexp" "strconv" "strings" @@ -15,6 +16,16 @@ import ( "github.com/mbertschler/squirrel/store" ) +// ansiEscapeRE matches ANSI CSI escape sequences (colour among them). +// kopia paints its progress and error output for a terminal; folding that +// raw into an error would leak escape sequences into squirrel's structured +// agent log and the runs.error column, so the stderr tail is stripped +// first (friction F11c). +var ansiEscapeRE = regexp.MustCompile("\x1b\\[[0-9;?]*[ -/]*[@-~]") + +// stripANSI removes ANSI escape sequences from s. +func stripANSI(s string) string { return ansiEscapeRE.ReplaceAllString(s, "") } + // Kopia is a configured kopia wrapper. Like rclone, kopia is treated as // an opaque child process: squirrel owns the argv, points every // invocation at a destination-scoped config file under ConfigDir @@ -71,7 +82,7 @@ func (k *Kopia) run(ctx context.Context, cfgFile, password string, args ...strin cmd.Stderr = &stderr if err := cmd.Run(); err != nil { verb := strings.Join(args[:min(2, len(args))], " ") - if msg := strings.TrimSpace(stderr.String()); msg != "" { + if msg := stripANSI(strings.TrimSpace(stderr.String())); msg != "" { return stdout.Bytes(), fmt.Errorf("kopia %s: %w: %s", verb, err, msg) } return stdout.Bytes(), fmt.Errorf("kopia %s: %w", verb, err) diff --git a/sync/kopia_test.go b/sync/kopia_test.go index bae2e13..46b9070 100644 --- a/sync/kopia_test.go +++ b/sync/kopia_test.go @@ -611,3 +611,20 @@ func TestKopiaAdvanceScopedToCapturedPresentSet(t *testing.T) { t.Fatalf("verify method = %q, want %q", vector[0].VerifyMethod, store.VerifyMethodKopia) } } + +// TestStripANSI covers F11c: kopia paints its output for a terminal, and +// those escape sequences must not leak into the error folded into +// squirrel's structured log / runs.error column. +func TestStripANSI(t *testing.T) { + cases := map[string]string{ + "\x1b[31merror:\x1b[0m disk full": "error: disk full", + "plain message": "plain message", + "\x1b[1;33mwarn\x1b[0m \x1b[2Kcleared": "warn cleared", + "": "", + } + for in, want := range cases { + if got := stripANSI(in); got != want { + t.Errorf("stripANSI(%q) = %q, want %q", in, got, want) + } + } +} diff --git a/sync/node.go b/sync/node.go index ba92d79..3dc0b08 100644 --- a/sync/node.go +++ b/sync/node.go @@ -217,6 +217,7 @@ func (d *nodeSyncDriver) run() error { // the original path is empty, so rclone treats the entry like a // fresh transfer. d.report.NodeConflicts = plan.Conflicts + d.recordAlreadyCorrect(plan) if !d.opts.DryRun { advance, err := captureDurabilityAdvance(d.ctx, d.store, d.volID) if err != nil { @@ -236,6 +237,29 @@ func (d *nodeSyncDriver) run() error { return nil } +// recordAlreadyCorrect derives the count of paths the receiver already +// held correctly for the summary (F7). Under the Merkle walk only +// differing folders reach /plan, so the identical-folder files never +// appear as dispositions; already-correct is therefore present-total +// minus the paths the sync acted on (every non-already-correct +// disposition). Best-effort: a count error leaves the field zero rather +// than failing the sync over a cosmetic number. +func (d *nodeSyncDriver) recordAlreadyCorrect(plan syncproto.PlanResponse) { + actionable := 0 + for _, disp := range plan.Dispositions { + if disp.Disposition != syncproto.DispositionAlreadyCorrect { + actionable++ + } + } + present, err := d.store.CountPresentFilesInVolume(d.ctx, d.volID) + if err != nil { + return + } + if ac := present - int64(actionable); ac > 0 { + d.report.AlreadyCorrect = ac + } +} + // phaseBegin opens a session with the receiver. The initiator's own // runs row is inserted first so the (peer_node_id, correlated_run_id) // pair lands the right way around: the receiver's id becomes our diff --git a/sync/sync.go b/sync/sync.go index 4fcf57f..a625f48 100644 --- a/sync/sync.go +++ b/sync/sync.go @@ -120,6 +120,15 @@ type Report struct { RunID int64 RcloneResult RunResult Status string // success / partial / failed + // AlreadyCorrect counts the paths the destination already held + // correctly, so a no-op-because-in-sync is distinguishable from a + // no-op-because-empty in the summary (transferred=0 alone is + // ambiguous — friction F7). For rclone bucket pushes and restores it + // is rclone's already-matching count; for peer syncs the handler + // derives it as present-total minus the paths the sync acted on + // (the Merkle walk never sends identical folders to /plan, so it + // cannot be counted from the disposition list alone). + AlreadyCorrect int64 // Verification is the handler's typed durability report for this // push: which comparison backed it, what the tool counted, and — // via Verified() — whether the destination's copy was @@ -490,6 +499,10 @@ func beginRestoreRun(ctx context.Context, s *store.Store, dryRun bool, volID int // to the rclone outcome on this very run. func finishRun(ctx context.Context, s *store.Store, dryRun bool, runID int64, rep *Report) { rep.Status = deriveStatus(rep.RcloneResult) + // rclone's "checks" are the files it found already matching at the + // destination and did not transfer — exactly the already-correct + // count the summary reports (F7). Peer syncs set this themselves. + rep.AlreadyCorrect = rep.RcloneResult.Checked if dryRun || runID == 0 { return } diff --git a/tui/runs.go b/tui/runs.go index 43eabdd..73548b1 100644 --- a/tui/runs.go +++ b/tui/runs.go @@ -28,9 +28,14 @@ type runsModel struct { rows []store.Run volumesByID map[int64]store.Volume filterKind string // empty = no filter + fold bool // collapse consecutive no-op rows (F19) + foldedCount int // no-op rows hidden by the current fold loaded bool loadErr error selectedDetail *store.Run + // displayRuns aligns 1:1 with the table's rows: the run behind each + // row, or nil for a fold-marker row (which has no detail view). + displayRuns []*store.Run } type runsDataMsg struct { @@ -55,7 +60,7 @@ func newRunsModel(s *store.Store) *runsModel { Background(colourAccent). Bold(false) t.SetStyles(style) - return &runsModel{store: s, table: t} + return &runsModel{store: s, table: t, fold: true} } func (m *runsModel) Init() tea.Cmd { return m.fetch() } @@ -102,13 +107,11 @@ func (m *runsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } switch msg.String() { case "enter": - if len(m.rows) == 0 { - return m, nil - } + // A fold-marker row maps to nil in displayRuns and has no + // detail view; ignore enter on it. idx := m.table.Cursor() - filtered := m.filteredRows() - if idx >= 0 && idx < len(filtered) { - r := filtered[idx] + if idx >= 0 && idx < len(m.displayRuns) && m.displayRuns[idx] != nil { + r := *m.displayRuns[idx] m.selectedDetail = &r } return m, nil @@ -127,6 +130,10 @@ func (m *runsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "A": m.setFilter("") return m, nil + case "f": + m.fold = !m.fold + m.applyFilter() + return m, nil } } var cmd tea.Cmd @@ -144,9 +151,18 @@ func (m *runsModel) View() string { if m.selectedDetail != nil { return m.renderDetail(*m.selectedDetail) } - header := styleHeader.Render("Runs") + " " + m.renderFilterChips() + header := styleHeader.Render("Runs") + " " + m.renderFilterChips() + m.renderFoldChip() return header + "\n" + m.table.View() + "\n" + - styleMuted.Render("i index · s sync · a audit · r restore · A all · enter detail") + styleMuted.Render("i index · s sync · a audit · r restore · A all · f fold · enter detail") +} + +// renderFoldChip notes how many no-op rows the fold is hiding, so a folded +// view never looks like runs simply went missing. +func (m *runsModel) renderFoldChip() string { + if !m.fold || m.foldedCount == 0 { + return "" + } + return " " + styleMuted.Render(fmt.Sprintf("(%d no-op rows folded — f to expand)", m.foldedCount)) } // renderFilterChips renders the active-filter pill, or muted text when no @@ -238,23 +254,84 @@ func (m *runsModel) applyFilter() { filtered := m.filteredRows() now := time.Now() rows := make([]table.Row, 0, len(filtered)) - for _, r := range filtered { - rows = append(rows, table.Row{ - fmt.Sprintf("#%d", r.ID), - r.Kind, - m.volumeName(r.VolumeID), - nullStr(r.Destination), - r.Status, - whenAgo(r.EndedAtNs, now), - runDuration(r), - fmt.Sprintf("%d", r.FileCount), - shallowGlyph(r.Shallow), - }) + display := make([]*store.Run, 0, len(filtered)) + m.foldedCount = 0 + for i := 0; i < len(filtered); { + // Collapse a maximal run of consecutive no-op rows into one marker, + // but only when there is more than one — a lone no-op isn't worth + // a fold line (F19). + if m.fold && runIsNoOp(filtered[i]) { + j := i + for j < len(filtered) && runIsNoOp(filtered[j]) { + j++ + } + if n := j - i; n > 1 { + rows = append(rows, foldMarkerRow(n)) + display = append(display, nil) + m.foldedCount += n + i = j + continue + } + } + r := filtered[i] + rows = append(rows, m.runTableRow(r, now)) + display = append(display, &r) + i++ } + m.displayRuns = display m.table.SetRows(rows) m.resizeColumns() } +// runTableRow renders one run as a table row. +func (m *runsModel) runTableRow(r store.Run, now time.Time) table.Row { + return table.Row{ + fmt.Sprintf("#%d", r.ID), + r.Kind, + m.volumeName(r.VolumeID), + destinationCell(r), + r.Status, + whenAgo(r.EndedAtNs, now), + runDuration(r), + fmt.Sprintf("%d", r.FileCount), + shallowGlyph(r.Shallow), + } +} + +// foldMarkerRow is the placeholder shown in place of a collapsed block of +// no-op rows. It carries the same column count as a real row so the table +// layout stays aligned. +func foldMarkerRow(n int) table.Row { + return table.Row{"⋯", "", fmt.Sprintf("%d no-op runs folded", n), "", "", "", "", "", ""} +} + +// runIsNoOp reports whether a run is routine no-op noise — a clean success +// that touched no files. Mirrors the CLI's `runs --changes` fold rule; a +// peer-sync no-op has file_count 0, so those collapse, while bucket/index +// no-ops (file_count counts files considered, not moved) stay visible. +func runIsNoOp(r store.Run) bool { + return r.Status == store.RunStatusSuccess && r.FileCount == 0 +} + +// destinationCell renders the DESTINATION column, prefixing a peer-sync row +// with its initiation-direction arrow so inbound and outbound are +// distinguishable (F18): "→" outbound (we pushed), "←" inbound (peer +// pushed). Only the initiator records runs.shallow, so a set flag marks the +// pushing side (see store.Run.Shallow). +func destinationCell(r store.Run) string { + if !r.Destination.Valid { + return nullStr(r.Destination) + } + if !r.PeerNodeID.Valid { + return r.Destination.String + } + arrow := "→" + if !r.Shallow.Valid { + arrow = "←" + } + return arrow + " " + r.Destination.String +} + func (m *runsModel) resizeColumns() { cols := []table.Column{ {Title: "ID", Width: 6}, diff --git a/tui/runs_polish_test.go b/tui/runs_polish_test.go new file mode 100644 index 0000000..09792d2 --- /dev/null +++ b/tui/runs_polish_test.go @@ -0,0 +1,47 @@ +package tui + +import ( + "database/sql" + "testing" + + "github.com/mbertschler/squirrel/store" +) + +// TestDestinationCell covers the F18 direction arrow in the runs table: +// bucket rows render the plain name, peer rows prefix the initiation arrow +// (→ outbound / ← inbound, keyed off runs.shallow). +func TestDestinationCell(t *testing.T) { + bucket := store.Run{Destination: sql.NullString{String: "s3archive", Valid: true}} + if got := destinationCell(bucket); got != "s3archive" { + t.Errorf("bucket cell = %q, want %q", got, "s3archive") + } + outbound := store.Run{ + Destination: sql.NullString{String: "htpc", Valid: true}, + PeerNodeID: sql.NullInt64{Int64: 3, Valid: true}, + Shallow: sql.NullBool{Valid: true}, + } + if got := destinationCell(outbound); got != "→ htpc" { + t.Errorf("outbound cell = %q, want %q", got, "→ htpc") + } + inbound := store.Run{ + Destination: sql.NullString{String: "laptop", Valid: true}, + PeerNodeID: sql.NullInt64{Int64: 4, Valid: true}, + } + if got := destinationCell(inbound); got != "← laptop" { + t.Errorf("inbound cell = %q, want %q", got, "← laptop") + } +} + +// TestRunIsNoOp covers the F19 fold rule: a clean success that touched no +// files is a no-op; anything else is not. +func TestRunIsNoOp(t *testing.T) { + if !runIsNoOp(store.Run{Status: store.RunStatusSuccess, FileCount: 0}) { + t.Error("clean 0-file success should be a no-op") + } + if runIsNoOp(store.Run{Status: store.RunStatusSuccess, FileCount: 3}) { + t.Error("success that touched files is not a no-op") + } + if runIsNoOp(store.Run{Status: store.RunStatusFailed}) { + t.Error("failed run is not a no-op") + } +}