diff --git a/internal/restic/runner.go b/internal/restic/runner.go index ab79ac2..4d1ab59 100644 --- a/internal/restic/runner.go +++ b/internal/restic/runner.go @@ -293,6 +293,53 @@ func runWithPump(cmd *exec.Cmd, handle LineHandler) error { return nil } +// RunUnlock executes `restic unlock`. Returns nil on a clean exit. +func (e Env) RunUnlock(ctx context.Context, handle LineHandler) error { + cmd := exec.CommandContext(ctx, e.Bin, "unlock") + cmd.Env = e.envSlice() + cmd.Dir = e.WorkDir + return runWithPump(cmd, handle) +} + +// RepoStats mirrors `restic stats --json --mode raw-data` output. +type RepoStats struct { + TotalSize int64 `json:"total_size"` + TotalUncompressed int64 `json:"total_uncompressed_size"` + SnapshotsCount int64 `json:"snapshots_count"` + TotalFileCount int64 `json:"total_file_count"` + TotalBlobCount int64 `json:"total_blob_count"` +} + +// RunStats executes `restic stats --json --mode raw-data` and parses +// the (single-line) JSON response. Tees raw output to handle so the +// caller can still log it. Returns an error if no JSON-shaped line +// arrived on stdout. +func (e Env) RunStats(ctx context.Context, handle LineHandler) (*RepoStats, error) { + cmd := exec.CommandContext(ctx, e.Bin, "stats", "--json", "--mode", "raw-data") + cmd.Env = e.envSlice() + cmd.Dir = e.WorkDir + var out *RepoStats + capture := func(stream, line string, ev any) { + if stream == "stdout" && strings.HasPrefix(line, "{") { + var s RepoStats + if json.Unmarshal([]byte(line), &s) == nil { + cp := s + out = &cp + } + } + if handle != nil { + handle(stream, line, ev) + } + } + if err := runWithPump(cmd, capture); err != nil { + return nil, err + } + if out == nil { + return nil, fmt.Errorf("restic stats: no JSON in output") + } + return out, nil +} + // CheckResult summarizes a `restic check` invocation. LockPresent is // true if the stderr stream contained a stale-lock signal (caller is // expected to surface this in the UI so the operator can run unlock). diff --git a/internal/restic/runner_test.go b/internal/restic/runner_test.go index ba91e43..70870fd 100644 --- a/internal/restic/runner_test.go +++ b/internal/restic/runner_test.go @@ -96,3 +96,56 @@ func TestRunCheckSubsetArg(t *testing.T) { } t.Fatalf("expected %q in captured output; got: %v", want, *lines) } + +// --- B3: RunUnlock + RunStats --- + +func TestRunUnlockInvokesUnlock(t *testing.T) { + bin := setupScriptBin(t, `echo "$@"`) + env := Env{Bin: bin} + lines, h := captureLines() + if err := env.RunUnlock(context.Background(), h); err != nil { + t.Fatalf("RunUnlock: %v", err) + } + for _, l := range *lines { + if strings.Contains(l, "unlock") { + return + } + } + t.Fatalf("expected 'unlock' in captured output; got: %v", *lines) +} + +func TestRunStatsParsesJSON(t *testing.T) { + bin := setupScriptBin(t, `echo '{"total_size":1234,"total_uncompressed_size":5678,"snapshots_count":3,"total_file_count":100,"total_blob_count":50}'`) + env := Env{Bin: bin} + stats, err := env.RunStats(context.Background(), nil) + if err != nil { + t.Fatalf("RunStats: %v", err) + } + if stats.TotalSize != 1234 { + t.Fatalf("TotalSize: got %d, want 1234", stats.TotalSize) + } + if stats.TotalUncompressed != 5678 { + t.Fatalf("TotalUncompressed: got %d, want 5678", stats.TotalUncompressed) + } + if stats.SnapshotsCount != 3 { + t.Fatalf("SnapshotsCount: got %d, want 3", stats.SnapshotsCount) + } + if stats.TotalFileCount != 100 { + t.Fatalf("TotalFileCount: got %d, want 100", stats.TotalFileCount) + } + if stats.TotalBlobCount != 50 { + t.Fatalf("TotalBlobCount: got %d, want 50", stats.TotalBlobCount) + } +} + +func TestRunStatsErrorsWithoutJSON(t *testing.T) { + bin := setupScriptBin(t, `echo "no json here"`) + env := Env{Bin: bin} + _, err := env.RunStats(context.Background(), nil) + if err == nil { + t.Fatal("expected error when no JSON in output") + } + if !strings.Contains(err.Error(), "no JSON in output") { + t.Fatalf("unexpected error: %v", err) + } +}