Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
69 changes: 67 additions & 2 deletions cmd/squirrel/index.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 <volume>` cobra command. The
Expand Down Expand Up @@ -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.
Expand All @@ -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
}
159 changes: 159 additions & 0 deletions cmd/squirrel/polish_test.go
Original file line number Diff line number Diff line change
@@ -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
}
4 changes: 3 additions & 1 deletion cmd/squirrel/restore.go
Original file line number Diff line number Diff line change
Expand Up @@ -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")
}
Expand Down
5 changes: 5 additions & 0 deletions cmd/squirrel/restore_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Loading
Loading