Compare commits

3 Commits

Author SHA1 Message Date
Ein Anderssono 9cd048d9f3 v0.9.4: add doctor diagnostics
pipeline / test (push) Has been cancelled
pipeline / build (push) Has been cancelled
2026-06-15 02:47:44 +02:00
Ein Anderssono 98320c8235 v0.9.3: add cleanup command
pipeline / test (push) Has been cancelled
pipeline / build (push) Has been cancelled
2026-06-15 02:40:12 +02:00
Ein Anderssono 84e70bda76 v0.9.2: add manifest repair
pipeline / test (push) Has been cancelled
pipeline / build (push) Has been cancelled
2026-06-15 02:32:20 +02:00
7 changed files with 541 additions and 8 deletions
+24
View File
@@ -2,6 +2,30 @@
This changelog is maintained from git history plus the published Gitea release series. Future releases should update this file and publish matching release notes on the release page.
## v0.9.4
Doctor release.
- Add `doctor` to check Photos access and optional backup health.
- Add `doctor --out <dir>` to report backup directory, manifest entries, and failure count.
- Add `doctor --json` for scriptable diagnostics.
## v0.9.3
Cleanup release.
- Add `cleanup --out <dir>` to remove files not referenced by the manifest.
- Add `cleanup --dry-run` to preview orphaned files before deletion.
- Preserve manifest/log/failure files, `.photoscli`, and sidecars for manifest-referenced media.
## v0.9.2
Manifest repair release.
- Add `manifest repair --out <dir>` to fill missing manifest size metadata from files on disk.
- Add `manifest repair --checksum sha256` to fill missing manifest checksums.
- Add `manifest repair --dry-run` to preview repairs without writing manifest updates.
## v0.9.1
Deep checksum verification release.
+1 -1
View File
@@ -1,6 +1,6 @@
BINARY := ./bin/photoscli
MODULE := gitea.k3s.k0.nu/tools/photocli
VERSION := 0.9.1
VERSION := 0.9.4
RELEASE_ZIP := ./bin/photoscli-$(VERSION)-macos-arm64.zip
RELEASE_NOTES := RELEASE_NOTES.md
BRIDGE_DIR := bridge
+3
View File
@@ -118,6 +118,9 @@ Verify a backup later:
```bash
./bin/photoscli verify --out ./photos-backup --manifest sqlite
./bin/photoscli verify --out ./photos-backup --deep
./bin/photoscli manifest repair --out ./photos-backup --checksum sha256 --dry-run
./bin/photoscli cleanup --out ./photos-backup --dry-run
./bin/photoscli doctor --out ./photos-backup
```
## Commands
+7 -7
View File
@@ -1,18 +1,18 @@
# v0.9.1
# v0.9.4
This release adds deep checksum verification for manifest-backed backups.
This release adds `doctor` diagnostics for Photos access and backups.
## Highlights
- Add `verify --deep` to recompute SHA-256 checksums when manifest entries include checksums.
- Report `checksum-mismatch` when file contents differ from the manifest checksum.
- Keep normal `verify` unchanged unless `--deep` is selected.
- `--deep` works with JSONL and SQLite manifests.
- Add `doctor` to check Photos access.
- Add `doctor --out <dir>` to report backup directory, manifest entries, and failure count.
- Add `doctor --json` for scriptable diagnostics.
- `doctor` is read-only and does not modify backup data.
## Assets
- `photoscli`: Apple Silicon macOS binary (`darwin/arm64`).
- `photoscli-0.9.1-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG.
- `photoscli-0.9.4-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG.
- `USERGUIDE.md`: standalone user guide.
Intel Macs are not currently a supported release target.
+28
View File
@@ -838,6 +838,34 @@ Deep-verify checksum-backed manifests:
`--deep` recomputes SHA-256 only for manifest entries that already have checksum metadata.
Repair missing manifest metadata from files already present on disk:
```bash
./bin/photoscli manifest repair --out ./PhotosBackup --checksum sha256 --dry-run
./bin/photoscli manifest repair --out ./PhotosBackup --checksum sha256
```
Repair fills missing size/checksum metadata and skips missing, zero-byte, or unreadable files.
Preview and remove files not referenced by the manifest:
```bash
./bin/photoscli cleanup --out ./PhotosBackup --dry-run
./bin/photoscli cleanup --out ./PhotosBackup
```
Cleanup preserves manifest/log/failure files, `.photoscli`, and sidecars next to manifest-referenced media.
Run read-only diagnostics:
```bash
./bin/photoscli doctor
./bin/photoscli doctor --out ./PhotosBackup
./bin/photoscli doctor --out ./PhotosBackup --json
```
Doctor checks Photos access and, when `--out` is provided, backup directory status, manifest entries, and failure count.
## Safe Operating Practices
- Run `--dry-run` before the first large backup.
+231
View File
@@ -8,6 +8,7 @@ import (
"encoding/xml"
"fmt"
"io"
"io/fs"
"os"
"path/filepath"
"sort"
@@ -92,6 +93,12 @@ func run(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
return cmdDiff(args[1:], stdout, stderr, bridge)
case "verify":
return cmdVerify(args[1:], stdout, stderr)
case "manifest":
return cmdManifest(args[1:], stdout, stderr)
case "cleanup":
return cmdCleanup(args[1:], stdout, stderr)
case "doctor":
return cmdDoctor(args[1:], stdout, stderr, bridge)
case "retry-failed":
return cmdRetryFailed(args[1:], stdout, stderr, bridge)
case "failures":
@@ -137,6 +144,9 @@ USAGE
photoscli report --out <dir> [--manifest jsonl|sqlite]
photoscli diff --album-id <id-or-title> --out <dir> [--manifest jsonl|sqlite]
photoscli verify --out <dir> [--manifest jsonl|sqlite]
photoscli manifest repair --out <dir> [--manifest jsonl|sqlite] [--checksum sha256] [--dry-run]
photoscli cleanup --out <dir> [--manifest jsonl|sqlite] [--dry-run]
photoscli doctor [--out <dir>] [--manifest jsonl|sqlite] [--json]
photoscli retry-failed --out <dir>
photoscli retry-failed --out <dir> --clear-on-success
photoscli failures list --out <dir>
@@ -182,6 +192,15 @@ COMMANDS
--sidecar to require photoscli schema/generator and exported filename metadata.
Add --deep to verify manifest SHA-256 checksums when present.
manifest repair --out <dir> [--manifest jsonl|sqlite] [--checksum sha256] [--dry-run]
Fill missing manifest size/checksum metadata from files that exist on disk.
cleanup --out <dir> [--manifest jsonl|sqlite] [--dry-run]
Remove files not referenced by the manifest. Use --dry-run first.
doctor [--out <dir>] [--manifest jsonl|sqlite] [--json]
Check Photos access and optional backup/manifest health without changing data.
retry-failed --out <dir>
Retry assets previously written to failures.jsonl.
@@ -2383,6 +2402,218 @@ func verifySidecar(stdout io.Writer, outDir, id, checkPath string, strict bool)
return 0
}
func cmdDoctor(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int {
result := map[string]any{"photos_access": "ok"}
problems := 0
if rc := mustAuth(stderr, bridge); rc != exitOK {
result["photos_access"] = "denied"
problems++
}
outDir := flagVal(args, "--out")
if outDir != "" {
result["out"] = outDir
if info, err := statFunc(outDir); err != nil || !info.IsDir() {
result["backup_dir"] = "missing"
problems++
} else {
result["backup_dir"] = "ok"
mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
entries, err := loadManifestEntries(outDir, mf)
if err != nil {
result["manifest"] = "error"
problems++
} else {
result["manifest"] = string(mf)
result["entries"] = len(entries)
}
failures := loadFailures(outDir)
result["failures"] = len(failures)
if len(failures) > 0 {
problems++
}
}
}
if hasFlag(args, "--json") {
result["problems"] = problems
if err := json.NewEncoder(stdout).Encode(result); err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
} else {
keys := make([]string, 0, len(result))
for k := range result {
keys = append(keys, k)
}
sort.Strings(keys)
for _, k := range keys {
fmt.Fprintf(stdout, "%s\t%v\n", k, result[k])
}
fmt.Fprintf(stdout, "problems\t%d\n", problems)
}
if problems > 0 {
return exitPartial
}
return exitOK
}
func cmdCleanup(args []string, stdout, stderr io.Writer) int {
outDir := flagVal(args, "--out")
if outDir == "" {
fmt.Fprintln(stderr, "error: --out is required")
return exitErr
}
mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
entries, err := loadManifestEntries(outDir, mf)
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
keep := map[string]bool{
"downloads.jsonl": true,
"downloads.db": true,
"export.log": true,
"failures.jsonl": true,
}
for _, e := range entries {
checkPath := e.Path
if checkPath == "" {
checkPath = e.Filename
}
if checkPath == "" {
continue
}
clean := filepath.Clean(checkPath)
if clean == "." || strings.HasPrefix(clean, "..") || filepath.IsAbs(clean) {
continue
}
keep[clean] = true
keep[filepath.Clean(sidecarPath(clean))] = true
keep[filepath.Clean(jsonSidecarPath(clean))] = true
}
dryRun := hasFlag(args, "--dry-run")
removed := 0
skipped := 0
_ = filepath.WalkDir(outDir, func(path string, d fs.DirEntry, _ error) error {
if path == outDir {
return nil
}
rel, _ := filepath.Rel(outDir, path)
rel = filepath.Clean(rel)
if d.IsDir() {
if rel == ".photoscli" || strings.HasPrefix(rel, ".photoscli"+string(os.PathSeparator)) {
return filepath.SkipDir
}
return nil
}
if keep[rel] {
return nil
}
fmt.Fprintf(stdout, "%s\torphan\n", rel)
removed++
if !dryRun {
if err := removeFunc(path); err != nil {
skipped++
}
}
return nil
})
fmt.Fprintf(stdout, "removed\t%d\nskipped\t%d\n", removed, skipped)
return exitOK
}
func cmdManifest(args []string, stdout, stderr io.Writer) int {
if len(args) < 1 || args[0] != "repair" {
fmt.Fprintln(stderr, "error: expected manifest repair")
return exitErr
}
outDir := flagVal(args, "--out")
if outDir == "" {
fmt.Fprintln(stderr, "error: --out is required")
return exitErr
}
checksumMode := flagValWithDefault(args, "--checksum", "none")
if checksumMode != "none" && checksumMode != "sha256" {
fmt.Fprintf(stderr, "error: --checksum must be none or sha256, got %q\n", checksumMode)
return exitErr
}
mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl"))
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
m, err := manifest.Open(outDir, mf)
if err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
defer m.Close()
reader := m.(manifest.EntryReader)
if !hasFlag(args, "--dry-run") {
if err := m.OpenAppend(); err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
}
repaired := 0
skipped := 0
for id, entry := range reader.Entries() {
checkPath := entry.Path
if checkPath == "" {
checkPath = entry.Filename
}
if checkPath == "" {
skipped++
continue
}
fullPath := filepath.Join(outDir, checkPath)
info, err := statFunc(fullPath)
if err != nil || info.IsDir() || info.Size() == 0 {
skipped++
continue
}
updated := entry
updated.ID = id
changed := false
if updated.Size != info.Size() {
updated.Size = info.Size()
changed = true
}
if checksumMode == "sha256" && updated.Checksum == "" {
checksum, err := fileSHA256(fullPath)
if err != nil {
skipped++
continue
}
updated.Checksum = checksum
changed = true
}
if !changed {
continue
}
repaired++
fmt.Fprintf(stdout, "%s\t%s\trepaired\n", id, checkPath)
if !hasFlag(args, "--dry-run") {
m.AddEntry(updated)
}
}
if !hasFlag(args, "--dry-run") {
if err := m.Save(); err != nil {
fmt.Fprintf(stderr, "error: %v\n", err)
return exitErr
}
}
fmt.Fprintf(stdout, "repaired\t%d\nskipped\t%d\n", repaired, skipped)
return exitOK
}
func cmdSidecar(args []string, stdout, stderr io.Writer) int {
if len(args) < 1 || args[0] != "inspect" {
fmt.Fprintln(stderr, "error: expected sidecar inspect <file.xmp>")
+247
View File
@@ -39,6 +39,15 @@ type errWriter struct{}
func (errWriter) Write([]byte) (int, error) { return 0, fmt.Errorf("write") }
type testFileInfo struct{ size int64 }
func (t testFileInfo) Name() string { return "test" }
func (t testFileInfo) Size() int64 { return t.size }
func (t testFileInfo) Mode() os.FileMode { return 0644 }
func (t testFileInfo) ModTime() time.Time { return time.Time{} }
func (t testFileInfo) IsDir() bool { return false }
func (t testFileInfo) Sys() any { return nil }
type noEntryManifest struct{}
func (noEntryManifest) Has(string) bool { return false }
@@ -4306,6 +4315,244 @@ func TestVerifyDeepChecksums(t *testing.T) {
}
}
func TestManifestRepair(t *testing.T) {
dir := t.TempDir()
if err := os.WriteFile(filepath.Join(dir, "photo.jpg"), []byte("abc"), 0644); err != nil {
t.Fatal(err)
}
m := manifest.LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.AddEntry(manifest.Entry{ID: "x1", Filename: "photo.jpg", Path: "photo.jpg", Size: 0, Cloud: "local", Exported: time.Now().Unix()})
m.AddEntry(manifest.Entry{ID: "missing", Filename: "missing.jpg", Path: "missing.jpg", Size: 0, Cloud: "local", Exported: time.Now().Unix()})
m.Close()
out, stderr, rc := runWith([]string{"manifest", "repair", "--out", dir, "--checksum", "sha256", "--dry-run"}, &mockBridge{})
if rc != exitOK || stderr != "" || !strings.Contains(out, "x1\tphoto.jpg\trepaired") || !strings.Contains(out, "skipped\t1") {
t.Fatalf("dry repair rc=%d out=%q stderr=%q", rc, out, stderr)
}
if got := manifest.LoadJSONL(dir).Entries()["x1"].Checksum; got != "" {
t.Fatalf("dry run wrote checksum %q", got)
}
out, stderr, rc = runWith([]string{"manifest", "repair", "--out", dir, "--checksum", "sha256"}, &mockBridge{})
if rc != exitOK || stderr != "" || !strings.Contains(out, "repaired\t1") {
t.Fatalf("repair rc=%d out=%q stderr=%q", rc, out, stderr)
}
entry := manifest.LoadJSONL(dir).Entries()["x1"]
if entry.Size != 3 || entry.Checksum != "ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad" {
t.Fatalf("entry not repaired: %+v", entry)
}
out, stderr, rc = runWith([]string{"manifest", "repair", "--out", dir, "--checksum", "sha256"}, &mockBridge{})
if rc != exitOK || stderr != "" || !strings.Contains(out, "repaired\t0") {
t.Fatalf("second repair rc=%d out=%q stderr=%q", rc, out, stderr)
}
}
func TestManifestRepairErrors(t *testing.T) {
dir := t.TempDir()
_, stderr, rc := runWith([]string{"manifest"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "expected manifest repair") {
t.Fatalf("manifest missing subcommand rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"manifest", "repair"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "--out") {
t.Fatalf("manifest repair missing out rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"manifest", "repair", "--out", dir, "--checksum", "bad"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "--checksum") {
t.Fatalf("manifest repair bad checksum rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"manifest", "repair", "--out", dir, "--manifest", "bad"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "manifest") {
t.Fatalf("manifest repair bad manifest rc=%d stderr=%q", rc, stderr)
}
fileOut := filepath.Join(dir, "file-out")
if err := os.WriteFile(fileOut, []byte("x"), 0644); err != nil {
t.Fatal(err)
}
_, stderr, rc = runWith([]string{"manifest", "repair", "--out", fileOut}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "error:") {
t.Fatalf("manifest repair open append rc=%d stderr=%q", rc, stderr)
}
badDBDir := t.TempDir()
if err := os.WriteFile(manifest.SQLitePath(badDBDir), []byte("not sqlite"), 0644); err != nil {
t.Fatal(err)
}
_, stderr, rc = runWith([]string{"manifest", "repair", "--out", badDBDir}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "error:") {
t.Fatalf("manifest repair open rc=%d stderr=%q", rc, stderr)
}
saveDir := t.TempDir()
sm := manifest.LoadJSONL(saveDir)
if err := sm.OpenAppend(); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(filepath.Join(saveDir, "photo.jpg"), []byte("abc"), 0644); err != nil {
t.Fatal(err)
}
sm.AddEntry(manifest.Entry{ID: "x1", Filename: "photo.jpg", Path: "photo.jpg", Exported: time.Now().Unix()})
sm.Close()
oldHook := manifest.SetJSONLSaveHook(func() error { return fmt.Errorf("save") })
_, stderr, rc = runWith([]string{"manifest", "repair", "--out", saveDir}, &mockBridge{})
manifest.SetJSONLSaveHook(oldHook)
if rc != exitErr || !strings.Contains(stderr, "save") {
t.Fatalf("manifest repair save rc=%d stderr=%q", rc, stderr)
}
}
func TestManifestRepairBranches(t *testing.T) {
dir := t.TempDir()
m := manifest.LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.AddEntry(manifest.Entry{ID: "empty"})
m.AddEntry(manifest.Entry{ID: "stat-only", Filename: "missing.jpg", Path: "missing.jpg", Checksum: "", Exported: time.Now().Unix()})
m.AddEntry(manifest.Entry{ID: "hash-fail", Filename: "hash.jpg", Path: "hash.jpg", Checksum: "", Exported: time.Now().Unix()})
m.Close()
oldStat := statFunc
statFunc = func(path string) (os.FileInfo, error) {
if strings.HasSuffix(path, "hash.jpg") {
return testFileInfo{size: 3}, nil
}
return oldStat(path)
}
out, stderr, rc := runWith([]string{"manifest", "repair", "--out", dir, "--checksum", "sha256", "--dry-run"}, &mockBridge{})
statFunc = oldStat
if rc != exitOK || stderr != "" || !strings.Contains(out, "skipped\t3") {
t.Fatalf("manifest repair branches rc=%d out=%q stderr=%q", rc, out, stderr)
}
}
func TestCleanup(t *testing.T) {
dir := t.TempDir()
for _, path := range []string{"keep.jpg", "keep.xmp", "keep.json", "orphan.jpg", "export.log", "failures.jsonl", filepath.Join(".photoscli", "geocode-cache.jsonl")} {
full := filepath.Join(dir, path)
if err := os.MkdirAll(filepath.Dir(full), 0755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(full, []byte("data"), 0644); err != nil {
t.Fatal(err)
}
}
m := manifest.LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.AddEntry(manifest.Entry{ID: "x1", Filename: "keep.jpg", Path: "keep.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
m.AddEntry(manifest.Entry{ID: "fallback", Filename: "fallback.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
m.AddEntry(manifest.Entry{ID: "empty", Exported: time.Now().Unix()})
m.AddEntry(manifest.Entry{ID: "badrel", Filename: "../bad.jpg", Path: "../bad.jpg", Exported: time.Now().Unix()})
m.Close()
if err := os.WriteFile(filepath.Join(dir, "fallback.jpg"), []byte("data"), 0644); err != nil {
t.Fatal(err)
}
if err := os.Mkdir(filepath.Join(dir, "subdir"), 0755); err != nil {
t.Fatal(err)
}
out, stderr, rc := runWith([]string{"cleanup", "--out", dir, "--dry-run"}, &mockBridge{})
if rc != exitOK || stderr != "" || !strings.Contains(out, "orphan.jpg\torphan") || !strings.Contains(out, "removed\t1") {
t.Fatalf("cleanup dry rc=%d out=%q stderr=%q", rc, out, stderr)
}
if _, err := os.Stat(filepath.Join(dir, "orphan.jpg")); err != nil {
t.Fatalf("dry-run removed orphan: %v", err)
}
out, stderr, rc = runWith([]string{"cleanup", "--out", dir}, &mockBridge{})
if rc != exitOK || stderr != "" || !strings.Contains(out, "removed\t1") {
t.Fatalf("cleanup rc=%d out=%q stderr=%q", rc, out, stderr)
}
if _, err := os.Stat(filepath.Join(dir, "orphan.jpg")); !os.IsNotExist(err) {
t.Fatalf("orphan still exists or bad error: %v", err)
}
for _, path := range []string{"keep.jpg", "keep.xmp", "keep.json", "fallback.jpg", "downloads.jsonl", "export.log", "failures.jsonl", filepath.Join(".photoscli", "geocode-cache.jsonl")} {
if _, err := os.Stat(filepath.Join(dir, path)); err != nil {
t.Fatalf("kept file missing %s: %v", path, err)
}
}
}
func TestCleanupErrors(t *testing.T) {
dir := t.TempDir()
_, stderr, rc := runWith([]string{"cleanup"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "--out") {
t.Fatalf("cleanup missing out rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"cleanup", "--out", dir, "--manifest", "bad"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "manifest") {
t.Fatalf("cleanup bad manifest rc=%d stderr=%q", rc, stderr)
}
badDBDir := t.TempDir()
if err := os.WriteFile(manifest.SQLitePath(badDBDir), []byte("not sqlite"), 0644); err != nil {
t.Fatal(err)
}
_, stderr, rc = runWith([]string{"cleanup", "--out", badDBDir, "--manifest", "sqlite"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "error:") {
t.Fatalf("cleanup load error rc=%d stderr=%q", rc, stderr)
}
_, stderr, rc = runWith([]string{"cleanup", "--out", filepath.Join(dir, "missing")}, &mockBridge{})
if rc != exitOK || stderr != "" {
t.Fatalf("cleanup missing root rc=%d stderr=%q", rc, stderr)
}
dir = t.TempDir()
oldRemove := removeFunc
removeFunc = func(string) error { return fmt.Errorf("remove") }
if err := os.WriteFile(filepath.Join(dir, "orphan.jpg"), []byte("data"), 0644); err != nil {
t.Fatal(err)
}
out, stderr, rc := runWith([]string{"cleanup", "--out", dir}, &mockBridge{})
removeFunc = oldRemove
if rc != exitOK || stderr != "" || !strings.Contains(out, "skipped\t1") {
t.Fatalf("cleanup remove error rc=%d out=%q stderr=%q", rc, out, stderr)
}
}
func TestDoctor(t *testing.T) {
dir := t.TempDir()
m := manifest.LoadJSONL(dir)
if err := m.OpenAppend(); err != nil {
t.Fatal(err)
}
m.AddEntry(manifest.Entry{ID: "x1", Filename: "photo.jpg", Path: "photo.jpg", Size: 4, Cloud: "local", Exported: time.Now().Unix()})
m.Close()
out, stderr, rc := runWith([]string{"doctor", "--out", dir}, &mockBridge{})
if rc != exitOK || !strings.Contains(stderr, "access granted") || !strings.Contains(out, "backup_dir\tok") || !strings.Contains(out, "entries\t1") || !strings.Contains(out, "problems\t0") {
t.Fatalf("doctor rc=%d out=%q stderr=%q", rc, out, stderr)
}
out, stderr, rc = runWith([]string{"doctor", "--out", dir, "--json"}, &mockBridge{})
if rc != exitOK || !strings.Contains(out, `"entries":1`) || !strings.Contains(out, `"problems":0`) {
t.Fatalf("doctor json rc=%d out=%q stderr=%q", rc, out, stderr)
}
}
func TestDoctorProblems(t *testing.T) {
dir := t.TempDir()
appendFailure(dir, pendingAsset{asset: photos.Asset{ID: "x1", Filename: "photo.jpg"}}, fmt.Errorf("failed"))
out, stderr, rc := runWith([]string{"doctor", "--out", dir}, &mockBridge{accessErr: fmt.Errorf("denied")})
if rc != exitPartial || !strings.Contains(stderr, "denied") || !strings.Contains(out, "photos_access\tdenied") || !strings.Contains(out, "failures\t1") {
t.Fatalf("doctor problems rc=%d out=%q stderr=%q", rc, out, stderr)
}
_, stderr, rc = runWith([]string{"doctor", "--out", dir, "--manifest", "bad"}, &mockBridge{})
if rc != exitErr || !strings.Contains(stderr, "manifest") {
t.Fatalf("doctor bad manifest rc=%d stderr=%q", rc, stderr)
}
out, stderr, rc = runWith([]string{"doctor", "--out", filepath.Join(dir, "missing")}, &mockBridge{})
if rc != exitPartial || !strings.Contains(out, "backup_dir\tmissing") {
t.Fatalf("doctor missing dir rc=%d out=%q stderr=%q", rc, out, stderr)
}
badDBDir := t.TempDir()
if err := os.WriteFile(manifest.SQLitePath(badDBDir), []byte("not sqlite"), 0644); err != nil {
t.Fatal(err)
}
out, stderr, rc = runWith([]string{"doctor", "--out", badDBDir, "--manifest", "sqlite"}, &mockBridge{})
if rc != exitPartial || !strings.Contains(out, "manifest\terror") {
t.Fatalf("doctor manifest error rc=%d out=%q stderr=%q", rc, out, stderr)
}
stderrBuf := &bytes.Buffer{}
if rc := cmdDoctor([]string{"--json"}, errWriter{}, stderrBuf, &mockBridge{}); rc != exitErr || !strings.Contains(stderrBuf.String(), "error:") {
t.Fatalf("doctor json writer error rc=%d stderr=%q", rc, stderrBuf.String())
}
}
func TestVerifySidecarBranches(t *testing.T) {
dir := t.TempDir()
subdir := filepath.Join(dir, "sub")