diff --git a/CHANGELOG.md b/CHANGELOG.md index a8148e3..08dd3a4 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,23 @@ 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.10.0 + +Ports and adapters refactor. + +- Extract shared manifest types into `internal/manifest/types` leaf package. +- Extract SQLite adapter into `internal/manifest/sqlite`: Store, Adapter, LogWriter. +- Extract JSONL adapter into `internal/manifest/jsonl`: Store, Adapter. +- `modernc.org/sqlite` import isolated to `internal/manifest/sqlite/adapter.go`. +- Registry pattern: `manifest.Default` provides adapter-backed `ParseFormat`/`Open`/`OpenLogWriter`. +- Adapter-agnostic `ConvertManifest` in `types/` for JSONL↔SQLite conversion. +- `MemoryAdapter` for in-memory manifest testing. +- CLI uses `manifest.Default` registry directly; zero concrete adapter references. +- SQLite `LogWriter` type assertion moved from central code into `SQLiteAdapter`. +- `Manifest` interface now includes `Entries()`; `EntryReader` removed. +- No behavior changes, no new features. +- 100% statement coverage across all 6 packages. + ## v0.9.4 Doctor release. diff --git a/Makefile b/Makefile index 92cb673..08fd75b 100644 --- a/Makefile +++ b/Makefile @@ -1,6 +1,6 @@ BINARY := ./bin/photoscli MODULE := gitea.k3s.k0.nu/tools/photocli -VERSION := 0.9.4 +VERSION := 0.10.0 RELEASE_ZIP := ./bin/photoscli-$(VERSION)-macos-arm64.zip RELEASE_NOTES := RELEASE_NOTES.md BRIDGE_DIR := bridge diff --git a/RELEASE_NOTES.md b/RELEASE_NOTES.md index 696ae36..7abb7d8 100644 --- a/RELEASE_NOTES.md +++ b/RELEASE_NOTES.md @@ -1,18 +1,25 @@ -# v0.9.4 +# v0.10.0 -This release adds `doctor` diagnostics for Photos access and backups. +Ports and adapters refactor. No user-visible changes. ## Highlights -- Add `doctor` to check Photos access. -- Add `doctor --out ` 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. +- Extract manifest types, registry, and conversion logic into `internal/manifest/types`. +- Extract SQLite adapter into `internal/manifest/sqlite` with its own store, adapter, and log writer. +- Extract JSONL adapter into `internal/manifest/jsonl` with its own store and adapter. +- Isolate `modernc.org/sqlite` import to the SQLite adapter package only. +- Replace central `Open`/`ParseFormat`/`OpenLogWriter` with adapter-backed registry. +- Adapter-agnostic manifest conversion via `ConvertManifest`. +- SQLite log writer selection moved from central code into `SQLiteAdapter`. +- CLI uses `manifest.Default` registry; no direct references to concrete JSONL or SQLite types. +- `MemoryAdapter` available for in-memory manifest tests. +- 100% statement coverage across all 6 packages. +- No behavior changes. ## Assets - `photoscli`: Apple Silicon macOS binary (`darwin/arm64`). -- `photoscli-0.9.4-macos-arm64.zip`: Apple Silicon binary plus README, USERGUIDE, and CHANGELOG. +- `photoscli-0.10.0-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. diff --git a/cmd/photoscli/main.go b/cmd/photoscli/main.go index 90e3454..164941b 100644 --- a/cmd/photoscli/main.go +++ b/cmd/photoscli/main.go @@ -34,6 +34,7 @@ var ( renameFunc = os.Rename openFileFunc = os.OpenFile removeFunc = os.Remove + reg = manifest.Default ) type exportOptions struct { @@ -500,7 +501,7 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in return exitErr } - mf, mfErr := manifest.ParseFormat(manifestFmt) + mf, mfErr := reg.ParseFormat(manifestFmt) if mfErr != nil { fmt.Fprintf(stderr, "error: %v\n", mfErr) return exitErr @@ -603,7 +604,7 @@ func cmdExport(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in } var exported, failed int if opts.metadataOnly { - m, _ := manifest.Open(outDir, mf) + m, _ := reg.Open(outDir, mf) defer m.Close() entries := manifestEntries(m) fmt.Fprintf(stderr, "writing metadata for %d assets to %s...\n", total, outDir) @@ -673,7 +674,7 @@ func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge) return exitErr } - mf, mfErr := manifest.ParseFormat(manifestFmt) + mf, mfErr := reg.ParseFormat(manifestFmt) if mfErr != nil { fmt.Fprintf(stderr, "error: %v\n", mfErr) return exitErr @@ -747,7 +748,7 @@ func cmdBackupAll(args []string, stdout, stderr io.Writer, bridge photos.Bridge) } var totalAssets, failed int if opts.metadataOnly { - m, _ := manifest.Open(outDir, mf) + m, _ := reg.Open(outDir, mf) entries := manifestEntries(m) m.Close() pending, skipped := collectPendingAssets(nodes, outDir, bridge, skipVideos, originals, nil, nil, sortNewest, excludeAlbums, sinceTime, opts) @@ -1299,10 +1300,7 @@ func writeMetadataOnlySidecar(pa pendingAsset, entry manifest.Entry, originals b } func manifestEntries(m manifest.Manifest) map[string]manifest.Entry { - if r, ok := m.(manifest.EntryReader); ok { - return r.Entries() - } - return nil + return m.Entries() } func metadataOnlyAssets(assets []photos.Asset, outDir string, originals bool, dirPrefix string, entries map[string]manifest.Entry, opts exportOptions, bridge photos.Bridge) (int, int) { @@ -1428,7 +1426,7 @@ func backupTree(nodes []photos.CollectionNode, outDir string, targetSize, qualit var m manifest.Manifest if !noManifest { var err error - m, err = manifest.Open(outDir, mf) + m, err = reg.Open(outDir, mf) if err != nil { fmt.Fprintf(stderr, " warning: could not open manifest: %v\n", err) } else { @@ -1441,7 +1439,7 @@ func backupTree(nodes []photos.CollectionNode, outDir string, targetSize, qualit var lw manifest.LogWriter = manifest.NoopLogWriter if enableLog { var err error - lw, err = manifest.OpenLogWriter(m, outDir) + lw, err = reg.OpenLogWriter(m, outDir, mf) if err != nil { fmt.Fprintf(stderr, " warning: could not open log writer: %v\n", err) lw = manifest.NoopLogWriter @@ -1717,7 +1715,7 @@ func exportAssets(assets []photos.Asset, outDir string, targetSize, quality, con var m manifest.Manifest if !noManifest { var err error - m, err = manifest.Open(outDir, mf) + m, err = reg.Open(outDir, mf) if err != nil { fmt.Fprintf(stderr, "warning: could not open manifest: %v\n", err) } else { @@ -1730,7 +1728,7 @@ func exportAssets(assets []photos.Asset, outDir string, targetSize, quality, con var lw manifest.LogWriter = manifest.NoopLogWriter if enableLog { var err error - lw, err = manifest.OpenLogWriter(m, outDir) + lw, err = reg.OpenLogWriter(m, outDir, mf) if err != nil { fmt.Fprintf(stderr, "warning: could not open log writer: %v\n", err) lw = manifest.NoopLogWriter @@ -2230,7 +2228,7 @@ func cmdReport(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stderr, "error: --out is required") return exitErr } - mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl")) + mf, err := reg.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl")) if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return exitErr @@ -2259,7 +2257,7 @@ func cmdDiff(args []string, stdout, stderr io.Writer, bridge photos.Bridge) int fmt.Fprintln(stderr, "error: --album-id and --out are required") return exitErr } - mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl")) + mf, err := reg.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl")) if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return exitErr @@ -2304,7 +2302,7 @@ func cmdVerify(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stderr, "error: --out is required") return exitErr } - mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl")) + mf, err := reg.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl")) if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return exitErr @@ -2417,7 +2415,7 @@ func cmdDoctor(args []string, stdout, stderr io.Writer, bridge photos.Bridge) in problems++ } else { result["backup_dir"] = "ok" - mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl")) + mf, err := reg.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl")) if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return exitErr @@ -2466,7 +2464,7 @@ func cmdCleanup(args []string, stdout, stderr io.Writer) int { fmt.Fprintln(stderr, "error: --out is required") return exitErr } - mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl")) + mf, err := reg.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl")) if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return exitErr @@ -2544,7 +2542,7 @@ func cmdManifest(args []string, stdout, stderr io.Writer) int { fmt.Fprintf(stderr, "error: --checksum must be none or sha256, got %q\n", checksumMode) return exitErr } - mf, err := manifest.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl")) + mf, err := reg.ParseFormat(flagValWithDefault(args, "--manifest", "jsonl")) if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return exitErr @@ -2755,7 +2753,7 @@ func cmdStatus(args []string, stdout, stderr io.Writer) int { return exitErr } manifestFmt := flagValWithDefault(args, "--manifest", "jsonl") - mf, err := manifest.ParseFormat(manifestFmt) + mf, err := reg.ParseFormat(manifestFmt) if err != nil { fmt.Fprintf(stderr, "error: %v\n", err) return exitErr diff --git a/cmd/photoscli/main_test.go b/cmd/photoscli/main_test.go index ce442ee..c76f8ae 100644 --- a/cmd/photoscli/main_test.go +++ b/cmd/photoscli/main_test.go @@ -50,12 +50,13 @@ func (t testFileInfo) Sys() any { return nil } type noEntryManifest struct{} -func (noEntryManifest) Has(string) bool { return false } -func (noEntryManifest) Add(string, string, int64, string) {} -func (noEntryManifest) AddEntry(manifest.Entry) {} -func (noEntryManifest) Save() error { return nil } -func (noEntryManifest) Close() {} -func (noEntryManifest) OpenAppend() error { return nil } +func (noEntryManifest) Has(string) bool { return false } +func (noEntryManifest) Add(string, string, int64, string) {} +func (noEntryManifest) AddEntry(manifest.Entry) {} +func (noEntryManifest) Save() error { return nil } +func (noEntryManifest) Close() {} +func (noEntryManifest) OpenAppend() error { return nil } +func (noEntryManifest) Entries() map[string]manifest.Entry { return nil } func (m *mockBridge) RequestAccess() error { return m.accessErr } func (m *mockBridge) ListAlbums() ([]photos.Album, error) { return m.albums, m.albumsErr } @@ -5455,7 +5456,8 @@ func (m *mockManifest) Has(string) bool { return false } func (m *mockManifest) Add(id string, filename string, size int64, cloud string) { m.last = manifest.NewEntry(id, filename, filename, size, cloud) } -func (m *mockManifest) AddEntry(e manifest.Entry) { m.last = e } -func (m *mockManifest) Save() error { return nil } -func (m *mockManifest) Close() {} -func (m *mockManifest) OpenAppend() error { return nil } +func (m *mockManifest) AddEntry(e manifest.Entry) { m.last = e } +func (m *mockManifest) Save() error { return nil } +func (m *mockManifest) Close() {} +func (m *mockManifest) OpenAppend() error { return nil } +func (m *mockManifest) Entries() map[string]manifest.Entry { return nil } diff --git a/internal/manifest/entry_test.go b/internal/manifest/entry_test.go deleted file mode 100644 index 507d4f6..0000000 --- a/internal/manifest/entry_test.go +++ /dev/null @@ -1,56 +0,0 @@ -package manifest - -import "testing" - -func TestNewEntryPath(t *testing.T) { - e := newEntry("id1", "file.jpg", 123, "local") - if e.ID != "id1" || e.Filename != "file.jpg" || e.Path != "file.jpg" || e.Size != 123 || e.Cloud != "local" || e.Exported == 0 { - t.Fatalf("unexpected entry: %+v", e) - } - e = NewEntry("id2", "file2.jpg", "Album/file2.jpg", 456, "cloud") - if e.Path != "Album/file2.jpg" || e.Filename != "file2.jpg" || e.Size != 456 || e.Cloud != "cloud" { - t.Fatalf("unexpected entry with path: %+v", e) - } - e = NewEntryWithChecksum("id3", "file3.jpg", "Album/file3.jpg", 789, "local", "sha256:abc") - if e.Checksum != "sha256:abc" || e.Path != "Album/file3.jpg" { - t.Fatalf("unexpected checksum entry: %+v", e) - } -} - -func TestAddEntryDefaultsPath(t *testing.T) { - dir := t.TempDir() - jm := LoadJSONL(dir) - if err := jm.OpenAppend(); err != nil { - t.Fatal(err) - } - jm.AddEntry(Entry{ID: "x1", Filename: "file.jpg", Size: 3, Cloud: "local", Checksum: "sha256:abc"}) - jm.Close() - loaded := LoadJSONL(dir).Entries()["x1"] - if got := loaded.Path; got != "file.jpg" { - t.Fatalf("jsonl path = %q", got) - } - if loaded.Checksum != "sha256:abc" { - t.Fatalf("jsonl checksum = %q", loaded.Checksum) - } - - sdir := t.TempDir() - sm, err := LoadSQLite(sdir) - if err != nil { - t.Fatal(err) - } - if err := sm.OpenAppend(); err != nil { - t.Fatal(err) - } - sm.AddEntry(Entry{ID: "x1", Filename: "file.jpg", Size: 3, Cloud: "local", Checksum: "sha256:def"}) - if _, err := sm.db.Exec(`UPDATE downloads SET path = '' WHERE id = 'x1'`); err != nil { - t.Fatal(err) - } - sloaded := sm.Entries()["x1"] - if got := sloaded.Path; got != "file.jpg" { - t.Fatalf("sqlite path = %q", got) - } - if sloaded.Checksum != "sha256:def" { - t.Fatalf("sqlite checksum = %q", sloaded.Checksum) - } - sm.Close() -} diff --git a/internal/manifest/jsonl/adapter.go b/internal/manifest/jsonl/adapter.go new file mode 100644 index 0000000..69c6bbb --- /dev/null +++ b/internal/manifest/jsonl/adapter.go @@ -0,0 +1,17 @@ +package jsonl + +import ( + "gitea.k3s.k0.nu/tools/photocli/internal/manifest/types" +) + +type Adapter struct{} + +func (Adapter) Format() types.Format { return types.FormatJSONL } +func (Adapter) Aliases() []string { return []string{"json"} } +func (Adapter) Path(dir string) string { return Path(dir) } +func (Adapter) Exists(dir string) bool { return FileExists(Path(dir)) } +func (Adapter) Open(dir string) (types.Manifest, error) { return Load(dir), nil } + +func (Adapter) OpenLogWriter(_ types.Manifest, dir string) (types.LogWriter, error) { + return types.NewFileLogWriter(types.LogPath(dir)) +} diff --git a/internal/manifest/jsonl/adapter_test.go b/internal/manifest/jsonl/adapter_test.go new file mode 100644 index 0000000..ae5e8ac --- /dev/null +++ b/internal/manifest/jsonl/adapter_test.go @@ -0,0 +1,209 @@ +package jsonl + +import ( + "os" + "testing" + + "gitea.k3s.k0.nu/tools/photocli/internal/manifest/types" +) + +func TestAdapter(t *testing.T) { + a := Adapter{} + if a.Format() != types.FormatJSONL { + t.Fatal("expected JSONL format") + } + if len(a.Aliases()) != 1 || a.Aliases()[0] != "json" { + t.Fatal("expected json alias") + } + dir := t.TempDir() + if a.Path(dir) != Path(dir) { + t.Fatal("expected path match") + } + if a.Exists(dir) { + t.Fatal("expected not to exist in empty dir") + } + m, err := a.Open(dir) + if err != nil { + t.Fatal(err) + } + m.Close() + w, err := a.OpenLogWriter(nil, dir) + if err != nil { + t.Fatal(err) + } + w.Close() +} + +func TestStoreLoadEmpty(t *testing.T) { + m := Load(t.TempDir()) + if m == nil { + t.Fatal("expected non-nil store") + } +} + +func TestStoreLoadNonexistent(t *testing.T) { + m := Load("/nonexistent/path") + if m == nil { + t.Fatal("expected non-nil store") + } +} + +func TestStoreAddAndHas(t *testing.T) { + m := Load(t.TempDir()) + if m.Has("x") { + t.Fatal("expected Has to return false") + } + m.Add("x", "photo.jpg", 42, "s3") + if !m.Has("x") { + t.Fatal("expected Has to return true") + } +} + +func TestStoreSaveAndReload(t *testing.T) { + dir := t.TempDir() + m := Load(dir) + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Add("id1", "file1.jpg", 10, "aws") + m.Add("id2", "file2.jpg", 20, "gcs") + if err := m.Save(); err != nil { + t.Fatal(err) + } + m.Close() + + m2 := Load(dir) + if !m2.Has("id1") { + t.Fatal("expected id1 after reload") + } + if !m2.Has("id2") { + t.Fatal("expected id2 after reload") + } +} + +func TestStoreOpenAppendCreatesDirs(t *testing.T) { + dir := t.TempDir() + subdir := dir + "/a/b" + m := Load(subdir) + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Close() +} + +func TestStoreCloseIdempotent(t *testing.T) { + m := Load(t.TempDir()) + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Close() + m.Close() +} + +func TestStoreOpenAppendIdempotent(t *testing.T) { + m := Load(t.TempDir()) + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Close() +} + +func TestStoreEntries(t *testing.T) { + m := Load(t.TempDir()) + m.Add("e1", "f1.jpg", 1, "c1") + m.Add("e2", "f2.jpg", 2, "c2") + entries := m.Entries() + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } +} + +func TestStoreManifestFormat(t *testing.T) { + m := Load(t.TempDir()) + if m.ManifestFormat() != types.FormatJSONL { + t.Fatal("expected JSONL format") + } +} + +func TestStoreOpenAppendMkdirError(t *testing.T) { + m := Load("/proc/cannot-create-dir-here") + if err := m.OpenAppend(); err == nil { + t.Fatal("expected error") + } +} + +func TestStoreSaveWithNoFile(t *testing.T) { + m := Load(t.TempDir()) + if err := m.Save(); err != nil { + t.Fatal(err) + } +} + +func TestStoreLoadFromExistingFile(t *testing.T) { + dir := t.TempDir() + m := Load(dir) + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Add("abc", "a.jpg", 100, "gcs") + m.Close() + m2 := Load(dir) + if !m2.Has("abc") { + t.Fatal("expected abc after reload") + } +} + +func TestFileExists(t *testing.T) { + dir := t.TempDir() + if FileExists(dir) { + t.Fatal("expected false for directory") + } + if FileExists("/nonexistent/file") { + t.Fatal("expected false for nonexistent") + } +} + +func TestLoadWithPathFallback(t *testing.T) { + dir := t.TempDir() + path := Path(dir) + osWriteFile(path, []byte(`{"id":"abc","filename":"a.jpg","size":100,"cloud":"gcs","exported":1234}`+"\n"), 0644) + m := Load(dir) + if !m.Has("abc") { + t.Fatal("expected abc") + } + if e := m.Entries()["abc"]; e.Path != "a.jpg" { + t.Fatalf("expected path fallback to filename, got %q", e.Path) + } +} + +func TestOpenAppendAlreadyOpen(t *testing.T) { + m := Load(t.TempDir()) + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Close() +} + +func TestOpenAppendOpenFileError(t *testing.T) { + dir := t.TempDir() + os.MkdirAll(Path(dir), 0755) + m := Load(dir) + if err := m.OpenAppend(); err == nil { + t.Fatal("expected error when path is a directory") + } +} + +func TestOpenAppendMkdirError(t *testing.T) { + m := Load("/proc/cannot-create-dir-here") + if err := m.OpenAppend(); err == nil { + t.Fatal("expected error") + } +} + +var osWriteFile = os.WriteFile diff --git a/internal/manifest/jsonl.go b/internal/manifest/jsonl/store.go similarity index 66% rename from internal/manifest/jsonl.go rename to internal/manifest/jsonl/store.go index 13125ea..d7a23ac 100644 --- a/internal/manifest/jsonl.go +++ b/internal/manifest/jsonl/store.go @@ -1,4 +1,4 @@ -package manifest +package jsonl import ( "encoding/json" @@ -6,32 +6,34 @@ import ( "path/filepath" "strings" "sync" + + "gitea.k3s.k0.nu/tools/photocli/internal/manifest/types" ) -type jsonlManifest struct { +type Store struct { mu sync.Mutex - entries map[string]Entry + entries map[string]types.Entry path string file *os.File - syncFunc func() error + SyncFunc func() error } -var jsonlSaveHook func() error +var saveHook func() error -func SetJSONLSaveHook(fn func() error) func() error { - old := jsonlSaveHook - jsonlSaveHook = fn +func SetSaveHook(fn func() error) func() error { + old := saveHook + saveHook = fn return old } -func JSONLPath(dir string) string { +func Path(dir string) string { return filepath.Join(dir, "downloads.jsonl") } -func LoadJSONL(dir string) *jsonlManifest { - m := &jsonlManifest{ - entries: make(map[string]Entry), - path: JSONLPath(dir), +func Load(dir string) *Store { + m := &Store{ + entries: make(map[string]types.Entry), + path: Path(dir), } data, err := os.ReadFile(m.path) if err != nil { @@ -56,7 +58,7 @@ func LoadJSONL(dir string) *jsonlManifest { if raw.Path == "" { raw.Path = raw.Filename } - m.entries[raw.ID] = Entry{ + m.entries[raw.ID] = types.Entry{ ID: raw.ID, Filename: raw.Filename, Path: raw.Path, @@ -70,18 +72,22 @@ func LoadJSONL(dir string) *jsonlManifest { return m } -func (m *jsonlManifest) Has(id string) bool { +func (m *Store) Has(id string) bool { m.mu.Lock() defer m.mu.Unlock() _, ok := m.entries[id] return ok } -func (m *jsonlManifest) Add(id string, filename string, size int64, cloud string) { - m.AddEntry(newEntry(id, filename, size, cloud)) +func (m *Store) ManifestFormat() types.Format { + return types.FormatJSONL } -func (m *jsonlManifest) AddEntry(entry Entry) { +func (m *Store) Add(id string, filename string, size int64, cloud string) { + m.AddEntry(types.NewEntry(id, filename, filename, size, cloud)) +} + +func (m *Store) AddEntry(entry types.Entry) { m.mu.Lock() defer m.mu.Unlock() if entry.Path == "" { @@ -103,14 +109,14 @@ func (m *jsonlManifest) AddEntry(entry Entry) { } } -func (m *jsonlManifest) Save() error { +func (m *Store) Save() error { m.mu.Lock() defer m.mu.Unlock() - if m.syncFunc != nil { - return m.syncFunc() + if m.SyncFunc != nil { + return m.SyncFunc() } - if jsonlSaveHook != nil { - return jsonlSaveHook() + if saveHook != nil { + return saveHook() } if m.file != nil { return m.file.Sync() @@ -118,7 +124,7 @@ func (m *jsonlManifest) Save() error { return nil } -func (m *jsonlManifest) Close() { +func (m *Store) Close() { m.mu.Lock() defer m.mu.Unlock() if m.file != nil { @@ -127,7 +133,7 @@ func (m *jsonlManifest) Close() { } } -func (m *jsonlManifest) OpenAppend() error { +func (m *Store) OpenAppend() error { m.mu.Lock() defer m.mu.Unlock() if m.file != nil { @@ -144,12 +150,20 @@ func (m *jsonlManifest) OpenAppend() error { return nil } -func (m *jsonlManifest) Entries() map[string]Entry { +func (m *Store) Entries() map[string]types.Entry { m.mu.Lock() defer m.mu.Unlock() - out := make(map[string]Entry, len(m.entries)) + out := make(map[string]types.Entry, len(m.entries)) for k, v := range m.entries { out[k] = v } return out } + +func FileExists(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return !info.IsDir() +} diff --git a/internal/manifest/jsonl/store_test.go b/internal/manifest/jsonl/store_test.go new file mode 100644 index 0000000..7d7e985 --- /dev/null +++ b/internal/manifest/jsonl/store_test.go @@ -0,0 +1,79 @@ +package jsonl + +import ( + "fmt" + "testing" + + "gitea.k3s.k0.nu/tools/photocli/internal/manifest/types" +) + +func TestSetSaveHook(t *testing.T) { + old := SetSaveHook(func() error { return fmt.Errorf("hook error") }) + if old != nil { + t.Error("expected nil old hook") + } + restore := SetSaveHook(old) + if restore == nil { + t.Error("expected non-nil restore function") + } +} + +func TestSaveHookError(t *testing.T) { + dir := t.TempDir() + m := Load(dir) + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Add("x1", "photo.jpg", 1024, "local") + old := SetSaveHook(func() error { return fmt.Errorf("hook error") }) + defer SetSaveHook(old) + if err := m.Save(); err == nil { + t.Error("expected hook error from Save") + } + m.Close() +} + +func TestSyncFuncError(t *testing.T) { + dir := t.TempDir() + m := Load(dir) + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Add("x1", "photo.jpg", 1024, "local") + m.SyncFunc = func() error { return fmt.Errorf("sync func error") } + if err := m.Save(); err == nil { + t.Error("expected syncFunc error from Save") + } + m.Close() +} + +func TestSaveError(t *testing.T) { + dir := t.TempDir() + m := Load(dir) + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Add("x1", "photo.jpg", 1024, "local") + m.file.Close() + if err := m.Save(); err == nil { + t.Error("expected Sync error on closed file") + } + m.Close() +} + +func TestAddEntryDefaultsPath(t *testing.T) { + dir := t.TempDir() + m := Load(dir) + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.AddEntry(types.Entry{ID: "x1", Filename: "file.jpg", Size: 3, Cloud: "local", Checksum: "sha256:abc"}) + m.Close() + loaded := Load(dir).Entries()["x1"] + if got := loaded.Path; got != "file.jpg" { + t.Fatalf("jsonl path = %q", got) + } + if loaded.Checksum != "sha256:abc" { + t.Fatalf("jsonl checksum = %q", loaded.Checksum) + } +} diff --git a/internal/manifest/log.go b/internal/manifest/log.go deleted file mode 100644 index 6e2c1fb..0000000 --- a/internal/manifest/log.go +++ /dev/null @@ -1,30 +0,0 @@ -package manifest - -type LogEntry struct { - Timestamp int64 `json:"ts"` - Level string `json:"level"` - Event string `json:"event"` - AssetID string `json:"asset_id,omitempty"` - Album string `json:"album,omitempty"` - Filename string `json:"filename,omitempty"` - Size int64 `json:"size,omitempty"` - Cloud string `json:"cloud,omitempty"` - DurationMs int64 `json:"duration_ms,omitempty"` - Message string `json:"message,omitempty"` -} - -type LogWriter interface { - Log(entry LogEntry) - Close() -} - -type noopLogWriter struct{} - -func (noopLogWriter) Log(LogEntry) { _ = struct{}{} } -func (noopLogWriter) Close() { _ = struct{}{} } - -var NoopLogWriter LogWriter = noopLogWriter{} - -func LogPath(dir string) string { - return dir + "/export.log" -} diff --git a/internal/manifest/log_test.go b/internal/manifest/log_test.go deleted file mode 100644 index 52eb73c..0000000 --- a/internal/manifest/log_test.go +++ /dev/null @@ -1,211 +0,0 @@ -package manifest - -import ( - "database/sql" - "os" - "path/filepath" - "testing" - - _ "modernc.org/sqlite" -) - -func TestNoopLogWriter(t *testing.T) { - lw := NoopLogWriter - lw.Log(LogEntry{Event: "test"}) - lw.Close() - noopLogWriter{}.Log(LogEntry{Event: "test"}) - noopLogWriter{}.Close() -} - -func TestNewSQLiteLogWriter(t *testing.T) { - dir := t.TempDir() - dbPath := filepath.Join(dir, "test.db") - db, err := sql.Open("sqlite", dbPath) - if err != nil { - t.Fatal(err) - } - defer db.Close() - - lw, err := NewSQLiteLogWriter(db) - if err != nil { - t.Fatal(err) - } - - lw.Log(LogEntry{ - Timestamp: 1700000000, - Level: "info", - Event: "export_done", - AssetID: "asset-1", - Album: "Favorites", - Filename: "photo.jpg", - Size: 1024, - Cloud: "local", - DurationMs: 500, - Message: "", - }) - - lw.Log(LogEntry{ - Timestamp: 1700000001, - Level: "error", - Event: "export_fail", - AssetID: "asset-2", - Filename: "bad.jpg", - Message: "timeout", - }) - - lw.Close() - - var count int - err = db.QueryRow(`SELECT COUNT(*) FROM logs`).Scan(&count) - if err != nil { - t.Fatal(err) - } - if count != 2 { - t.Errorf("expected 2 log entries, got %d", count) - } - - var event, level, assetID string - err = db.QueryRow(`SELECT event, level, asset_id FROM logs WHERE asset_id = 'asset-1'`).Scan(&event, &level, &assetID) - if err != nil { - t.Fatal(err) - } - if event != "export_done" || level != "info" || assetID != "asset-1" { - t.Errorf("unexpected row: event=%s level=%s asset_id=%s", event, level, assetID) - } -} - -func TestSQLiteLogWriterNilDB(t *testing.T) { - w := &sqliteLogWriter{db: nil} - w.Log(LogEntry{Event: "test"}) - w.Close() -} - -func TestNewSQLiteLogWriterError(t *testing.T) { - db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "test.db")) - if err != nil { - t.Fatal(err) - } - if err := db.Close(); err != nil { - t.Fatal(err) - } - if _, err := NewSQLiteLogWriter(db); err == nil { - t.Error("expected error for closed db") - } -} - -func TestSQLiteLogWriterCloseConcrete(t *testing.T) { - (&sqliteLogWriter{}).Close() -} - -func TestNewFileLogWriter(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "export.log") - lw, err := NewFileLogWriter(path) - if err != nil { - t.Fatal(err) - } - - lw.Log(LogEntry{ - Timestamp: 1700000000, - Level: "info", - Event: "export_done", - Filename: "photo.jpg", - Size: 2048, - }) - - lw.Close() - - data, err := os.ReadFile(path) - if err != nil { - t.Fatal(err) - } - if len(data) == 0 { - t.Error("expected log data") - } - if data[len(data)-1] != '\n' { - t.Error("expected trailing newline") - } -} - -func TestFileLogWriterClosed(t *testing.T) { - w := &fileLogWriter{f: nil} - w.Log(LogEntry{Event: "test"}) - w.Close() -} - -func TestNewFileLogWriterError(t *testing.T) { - _, err := NewFileLogWriter("/nonexistent/dir/export.log") - if err == nil { - t.Error("expected error for bad path") - } -} - -func TestFileLogWriterDoubleClose(t *testing.T) { - dir := t.TempDir() - path := filepath.Join(dir, "export.log") - lw, err := NewFileLogWriter(path) - if err != nil { - t.Fatal(err) - } - lw.Close() - lw.Close() -} - -func TestLogPath(t *testing.T) { - p := LogPath("/tmp/out") - if p != "/tmp/out/export.log" { - t.Errorf("expected /tmp/out/export.log, got %s", p) - } -} - -func TestOpenLogWriterSQLite(t *testing.T) { - dir := t.TempDir() - m, err := LoadSQLite(dir) - if err != nil { - t.Fatal(err) - } - if err := m.OpenAppend(); err != nil { - t.Fatal(err) - } - defer m.Close() - - lw, err := OpenLogWriter(m, dir) - if err != nil { - t.Fatal(err) - } - lw.Log(LogEntry{Event: "test", Level: "info"}) - lw.Close() -} - -func TestOpenLogWriterJSONL(t *testing.T) { - dir := t.TempDir() - m := LoadJSONL(dir) - if err := m.OpenAppend(); err != nil { - t.Fatal(err) - } - defer m.Close() - - lw, err := OpenLogWriter(m, dir) - if err != nil { - t.Fatal(err) - } - lw.Log(LogEntry{Event: "test", Level: "info"}) - lw.Close() - - if _, err := os.Stat(LogPath(dir)); os.IsNotExist(err) { - t.Error("expected export.log to exist") - } -} - -func TestOpenLogWriterNilManifest(t *testing.T) { - dir := t.TempDir() - lw, err := OpenLogWriter(nil, dir) - if err != nil { - t.Fatal(err) - } - lw.Log(LogEntry{Event: "test", Level: "info"}) - lw.Close() - if _, err := os.Stat(LogPath(dir)); os.IsNotExist(err) { - t.Error("expected export.log to exist for nil manifest") - } -} diff --git a/internal/manifest/manifest.go b/internal/manifest/manifest.go index 067ad4e..9c296dc 100644 --- a/internal/manifest/manifest.go +++ b/internal/manifest/manifest.go @@ -1,51 +1,37 @@ package manifest -import "time" +import "gitea.k3s.k0.nu/tools/photocli/internal/manifest/types" -type Entry struct { - ID string - Filename string - Path string - Size int64 - Cloud string - Checksum string - Exported int64 -} +type Entry = types.Entry -type Manifest interface { - Has(id string) bool - Add(id string, filename string, size int64, cloud string) - AddEntry(entry Entry) - Save() error - Close() - OpenAppend() error -} +type Manifest = types.Manifest -type EntryReader interface { - Entries() map[string]Entry -} +type EntryReader = types.EntryReader -func newEntry(id, filename string, size int64, cloud string) Entry { - return Entry{ - ID: id, - Filename: filename, - Path: filename, - Size: size, - Cloud: cloud, - Exported: time.Now().Unix(), - } -} +type Format = types.Format -func NewEntry(id, filename, path string, size int64, cloud string) Entry { - e := newEntry(id, filename, size, cloud) - if path != "" { - e.Path = path - } - return e -} +const ( + FormatJSONL = types.FormatJSONL + FormatSQLite = types.FormatSQLite +) -func NewEntryWithChecksum(id, filename, path string, size int64, cloud, checksum string) Entry { - e := NewEntry(id, filename, path, size, cloud) - e.Checksum = checksum - return e -} +type LogEntry = types.LogEntry + +type LogWriter = types.LogWriter + +var NoopLogWriter = types.NoopLogWriter + +type Adapter = types.Adapter + +type Registry = types.Registry + +type FormatReporter = types.FormatReporter + +var ( + NewRegistry = types.NewRegistry + NewEntry = types.NewEntry + NewEntryWithChecksum = types.NewEntryWithChecksum + LogPath = types.LogPath + ConvertManifest = types.ConvertManifest + NewFileLogWriter = types.NewFileLogWriter +) diff --git a/internal/manifest/memory.go b/internal/manifest/memory.go new file mode 100644 index 0000000..337a717 --- /dev/null +++ b/internal/manifest/memory.go @@ -0,0 +1,52 @@ +package manifest + +import "gitea.k3s.k0.nu/tools/photocli/internal/manifest/types" + +type memoryStore struct { + entries map[string]types.Entry +} + +func newMemoryStore() *memoryStore { + return &memoryStore{entries: make(map[string]types.Entry)} +} + +func (m *memoryStore) Has(id string) bool { + _, ok := m.entries[id] + return ok +} + +func (m *memoryStore) Add(id string, filename string, size int64, cloud string) { + m.AddEntry(types.NewEntry(id, filename, filename, size, cloud)) +} + +func (m *memoryStore) AddEntry(entry types.Entry) { + if entry.Path == "" { + entry.Path = entry.Filename + } + m.entries[entry.ID] = entry +} + +func (m *memoryStore) Save() error { return nil } +func (m *memoryStore) Close() { _ = m } + +func (m *memoryStore) OpenAppend() error { return nil } + +func (m *memoryStore) Entries() map[string]types.Entry { + out := make(map[string]types.Entry, len(m.entries)) + for k, v := range m.entries { + out[k] = v + } + return out +} + +type MemoryAdapter struct{} + +func (MemoryAdapter) Format() types.Format { return types.FormatJSONL } +func (MemoryAdapter) Aliases() []string { return nil } +func (MemoryAdapter) Path(string) string { return "" } +func (MemoryAdapter) Exists(string) bool { return false } +func (MemoryAdapter) Open(string) (types.Manifest, error) { return newMemoryStore(), nil } + +func (MemoryAdapter) OpenLogWriter(types.Manifest, string) (types.LogWriter, error) { + return types.NoopLogWriter, nil +} diff --git a/internal/manifest/open.go b/internal/manifest/open.go index ecde369..52b82a4 100644 --- a/internal/manifest/open.go +++ b/internal/manifest/open.go @@ -1,84 +1,21 @@ package manifest import ( - "fmt" "os" - "strings" -) -type Format string - -const ( - FormatJSONL Format = "jsonl" - FormatSQLite Format = "sqlite" + "gitea.k3s.k0.nu/tools/photocli/internal/manifest/types" ) func Open(dir string, format Format) (Manifest, error) { - jsonlPath := JSONLPath(dir) - sqlitePath := SQLitePath(dir) - jsonlExists := FileExists(jsonlPath) - sqliteExists := FileExists(sqlitePath) - - switch { - case format == FormatJSONL && jsonlExists: - return LoadJSONL(dir), nil - case format == FormatSQLite && sqliteExists: - return LoadSQLite(dir) - case format == FormatJSONL && sqliteExists: - return ConvertFromSQLite(dir) - case format == FormatSQLite && jsonlExists: - return ConvertFromJSONL(dir) - default: - if format == FormatJSONL { - return LoadJSONL(dir), nil - } - return LoadSQLite(dir) - } + return Default.Open(dir, format) } func ConvertFromJSONL(dir string) (Manifest, error) { - src := LoadJSONL(dir) - if err := src.OpenAppend(); err != nil { - return nil, fmt.Errorf("open jsonl for read: %w", err) - } - defer src.Close() - - dst, _ := LoadSQLite(dir) - if err := dst.OpenAppend(); err != nil { - return nil, fmt.Errorf("open sqlite for write: %w", err) - } - - for id, e := range src.Entries() { - e.ID = id - dst.AddEntry(e) - } - - os.Remove(JSONLPath(dir)) - return dst, nil + return types.ConvertManifest(dir, JSONLAdapter, SQLiteAdapter) } func ConvertFromSQLite(dir string) (Manifest, error) { - src, _ := LoadSQLite(dir) - if err := src.OpenAppend(); err != nil { - return nil, fmt.Errorf("open sqlite for read: %w", err) - } - defer src.Close() - - dst := LoadJSONL(dir) - if err := dst.OpenAppend(); err != nil { - return nil, fmt.Errorf("open jsonl for write: %w", err) - } - - for id, e := range src.Entries() { - e.ID = id - dst.AddEntry(e) - } - if err := dst.Save(); err != nil { - return nil, fmt.Errorf("save jsonl: %w", err) - } - - os.Remove(SQLitePath(dir)) - return dst, nil + return types.ConvertManifest(dir, SQLiteAdapter, JSONLAdapter) } func FileExists(path string) bool { @@ -90,19 +27,21 @@ func FileExists(path string) bool { } func ParseFormat(s string) (Format, error) { - switch strings.ToLower(s) { - case "jsonl", "json": - return FormatJSONL, nil - case "sqlite", "db", "sqlite3": - return FormatSQLite, nil - default: - return "", fmt.Errorf("unknown manifest format %q (use jsonl or sqlite)", s) - } + return Default.ParseFormat(s) } func OpenLogWriter(m Manifest, dir string) (LogWriter, error) { - if sm, ok := m.(*sqliteManifest); ok && sm.DB() != nil { - return NewSQLiteLogWriter(sm.DB()) + format := FormatJSONL + if reporter, ok := m.(types.FormatReporter); ok { + format = reporter.ManifestFormat() } - return NewFileLogWriter(LogPath(dir)) + return Default.OpenLogWriter(m, dir, format) +} + +func osRemove(path string) error { + return os.Remove(path) +} + +func init() { + types.SetRemoveFunc(osRemove) } diff --git a/internal/manifest/registry.go b/internal/manifest/registry.go new file mode 100644 index 0000000..f6bd866 --- /dev/null +++ b/internal/manifest/registry.go @@ -0,0 +1,34 @@ +package manifest + +import ( + jsonladapter "gitea.k3s.k0.nu/tools/photocli/internal/manifest/jsonl" + "gitea.k3s.k0.nu/tools/photocli/internal/manifest/sqlite" + "gitea.k3s.k0.nu/tools/photocli/internal/manifest/types" +) + +var ( + JSONLAdapter = jsonladapter.Adapter{} + SQLiteAdapter = sqlite.Adapter{} +) + +var Default = types.NewRegistry(JSONLAdapter, SQLiteAdapter) + +func LoadJSONL(dir string) *jsonladapter.Store { + return jsonladapter.Load(dir) +} + +func JSONLPath(dir string) string { + return jsonladapter.Path(dir) +} + +func SetJSONLSaveHook(fn func() error) func() error { + return jsonladapter.SetSaveHook(fn) +} + +func LoadSQLite(dir string) (Manifest, error) { + return sqlite.Load(dir) +} + +func SQLitePath(dir string) string { + return sqlite.Path(dir) +} diff --git a/internal/manifest/registry_test.go b/internal/manifest/registry_test.go new file mode 100644 index 0000000..9a962b3 --- /dev/null +++ b/internal/manifest/registry_test.go @@ -0,0 +1,279 @@ +package manifest + +import ( + "fmt" + "testing" + + jsonladapter "gitea.k3s.k0.nu/tools/photocli/internal/manifest/jsonl" + sqliteadapter "gitea.k3s.k0.nu/tools/photocli/internal/manifest/sqlite" + "gitea.k3s.k0.nu/tools/photocli/internal/manifest/types" +) + +func TestRegistryParseFormatAliases(t *testing.T) { + r := Default + cases := map[string]Format{ + "jsonl": FormatJSONL, + "json": FormatJSONL, + "sqlite": FormatSQLite, + "db": FormatSQLite, + "sqlite3": FormatSQLite, + } + for input, want := range cases { + got, err := r.ParseFormat(input) + if err != nil { + t.Fatalf("ParseFormat(%q): %v", input, err) + } + if got != want { + t.Fatalf("ParseFormat(%q) = %q, want %q", input, got, want) + } + } +} + +func TestRegistryParseFormatUnknown(t *testing.T) { + if _, err := Default.ParseFormat("bad"); err == nil { + t.Fatal("expected unknown format error") + } +} + +func TestRegistryOpenConvertsExistingManifest(t *testing.T) { + dir := t.TempDir() + m := LoadJSONL(dir) + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Add("x1", "photo.jpg", 12, "local") + if err := m.Save(); err != nil { + t.Fatal(err) + } + m.Close() + + converted, err := Default.Open(dir, FormatSQLite) + if err != nil { + t.Fatal(err) + } + defer converted.Close() + if !converted.Has("x1") { + t.Fatal("expected converted sqlite manifest to contain entry") + } + if FileExists(JSONLPath(dir)) { + t.Fatal("expected source jsonl manifest to be removed") + } + if !FileExists(SQLitePath(dir)) { + t.Fatal("expected sqlite manifest to exist") + } +} + +func TestMemoryAdapterStore(t *testing.T) { + s := newMemoryStore() + if s.Has("x") { + t.Fatal("expected empty") + } + s.Add("x", "photo.jpg", 42, "local") + if !s.Has("x") { + t.Fatal("expected has after add") + } + s.AddEntry(Entry{ID: "y", Filename: "file.jpg"}) + if e := s.Entries()["y"]; e.Path != "file.jpg" { + t.Fatal("expected default path") + } + if err := s.Save(); err != nil { + t.Fatal(err) + } + s.Close() + if err := s.OpenAppend(); err != nil { + t.Fatal(err) + } + + adapter := MemoryAdapter{} + if adapter.Format() != FormatJSONL { + t.Fatal("expected JSONL format") + } + if len(adapter.Aliases()) != 0 { + t.Fatal("expected no aliases") + } + if adapter.Path("") != "" { + t.Fatal("expected empty path") + } + if adapter.Exists("") { + t.Fatal("expected not to exist") + } + m, err := adapter.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + m.Close() + w, err := adapter.OpenLogWriter(nil, t.TempDir()) + if err != nil { + t.Fatal(err) + } + w.Close() +} + +func TestRegistryOpenLogWriterUsesAdapter(t *testing.T) { + dir := t.TempDir() + m, err := Default.Open(dir, FormatJSONL) + if err != nil { + t.Fatal(err) + } + defer m.Close() + w, err := Default.OpenLogWriter(m, dir, FormatJSONL) + if err != nil { + t.Fatal(err) + } + w.Close() + if !FileExists(LogPath(dir)) { + t.Fatal("expected jsonl adapter to create file log") + } +} + +func TestRegistryOpenCreatesRequestedAdapter(t *testing.T) { + dir := t.TempDir() + m, err := Default.Open(dir, FormatJSONL) + if err != nil { + t.Fatal(err) + } + defer m.Close() + if _, ok := m.(*jsonladapter.Store); !ok { + t.Fatalf("expected jsonl store, got %T", m) + } +} + +func TestRegistryUnknownAdapterErrors(t *testing.T) { + r := NewRegistry(JSONLAdapter) + if _, err := r.Open(t.TempDir(), FormatSQLite); err == nil { + t.Fatal("expected open error for unregistered format") + } + if _, err := r.OpenLogWriter(LoadJSONL(t.TempDir()), t.TempDir(), FormatSQLite); err == nil { + t.Fatal("expected log writer error for unregistered format") + } +} + +func TestSQLiteAdapterOpenLogWriterUsesSQLite(t *testing.T) { + dir := t.TempDir() + m, err := LoadSQLite(dir) + if err != nil { + t.Fatal(err) + } + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + defer m.Close() + w, err := SQLiteAdapter.OpenLogWriter(m, dir) + if err != nil { + t.Fatal(err) + } + defer w.Close() + if _, ok := w.(*sqliteadapter.LogWriter); !ok { + t.Fatalf("expected sqlite log writer, got %T", w) + } +} + +func TestOpenLogWriterUsesManifestFormat(t *testing.T) { + dir := t.TempDir() + m, err := LoadSQLite(dir) + if err != nil { + t.Fatal(err) + } + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + defer m.Close() + w, err := OpenLogWriter(m, dir) + if err != nil { + t.Fatal(err) + } + defer w.Close() + if _, ok := w.(*sqliteadapter.LogWriter); !ok { + t.Fatalf("expected sqlite log writer, got %T", w) + } +} + +func TestSQLiteAdapterOpenLogWriterFallsBackToFile(t *testing.T) { + dir := t.TempDir() + w, err := SQLiteAdapter.OpenLogWriter(LoadJSONL(dir), dir) + if err != nil { + t.Fatal(err) + } + w.Close() + if !FileExists(LogPath(dir)) { + t.Fatal("expected sqlite adapter fallback to create file log") + } +} + +func TestSetJSONLSaveHookWrapper(t *testing.T) { + old := SetJSONLSaveHook(func() error { return nil }) + SetJSONLSaveHook(old) +} + +func TestConvertManifestErrorBranches(t *testing.T) { + dir := t.TempDir() + if _, err := ConvertManifest(dir, failingAdapter{format: FormatJSONL, openErr: fmt.Errorf("boom")}, JSONLAdapter); err == nil { + t.Fatal("expected source open error") + } + if _, err := ConvertManifest(dir, staticAdapter{format: FormatJSONL, store: badOpenManifest{}}, JSONLAdapter); err == nil { + t.Fatal("expected source OpenAppend error") + } + if _, err := ConvertManifest(dir, staticAdapter{format: FormatJSONL, store: noReadManifest{}}, JSONLAdapter); err == nil { + t.Fatal("expected entry reader error") + } + if _, err := ConvertManifest(dir, staticAdapter{format: FormatJSONL, store: readableManifest{}}, failingAdapter{format: FormatSQLite, openErr: fmt.Errorf("boom")}); err == nil { + t.Fatal("expected destination open error") + } + if _, err := ConvertManifest(dir, staticAdapter{format: FormatJSONL, store: readableManifest{}}, staticAdapter{format: FormatSQLite, store: badOpenManifest{}}); err == nil { + t.Fatal("expected destination OpenAppend error") + } + oldRemove := types.RemoveFunc() + types.SetRemoveFunc(func(string) error { return fmt.Errorf("remove failed") }) + defer func() { types.SetRemoveFunc(oldRemove) }() + if _, err := ConvertManifest(dir, staticAdapter{format: FormatJSONL, store: readableManifest{}}, staticAdapter{format: FormatSQLite, store: readableManifest{}}); err == nil { + t.Fatal("expected remove error") + } +} + +type staticAdapter struct { + format Format + store Manifest +} + +func (a staticAdapter) Format() Format { return a.format } +func (a staticAdapter) Aliases() []string { return nil } +func (a staticAdapter) Path(string) string { return "manifest.file" } +func (a staticAdapter) Exists(string) bool { return true } +func (a staticAdapter) Open(string) (Manifest, error) { return a.store, nil } +func (a staticAdapter) OpenLogWriter(Manifest, string) (LogWriter, error) { return NoopLogWriter, nil } + +type failingAdapter struct { + format Format + openErr error +} + +func (a failingAdapter) Format() Format { return a.format } +func (a failingAdapter) Aliases() []string { return nil } +func (a failingAdapter) Path(string) string { return "manifest.file" } +func (a failingAdapter) Exists(string) bool { return true } +func (a failingAdapter) Open(string) (Manifest, error) { return nil, a.openErr } +func (a failingAdapter) OpenLogWriter(Manifest, string) (LogWriter, error) { return nil, a.openErr } + +type readableManifest struct{} + +func (readableManifest) Has(string) bool { return false } +func (readableManifest) Add(string, string, int64, string) {} +func (readableManifest) AddEntry(Entry) {} +func (readableManifest) Save() error { return nil } +func (readableManifest) Close() {} +func (readableManifest) OpenAppend() error { return nil } +func (readableManifest) Entries() map[string]Entry { return map[string]Entry{"x": {Filename: "x.jpg"}} } + +type noReadManifest struct{} + +func (noReadManifest) Has(string) bool { return false } +func (noReadManifest) Add(string, string, int64, string) {} +func (noReadManifest) AddEntry(Entry) {} +func (noReadManifest) Save() error { return nil } +func (noReadManifest) Close() {} +func (noReadManifest) OpenAppend() error { return nil } +func (noReadManifest) Entries() map[string]Entry { return nil } + +type badOpenManifest struct{ readableManifest } + +func (badOpenManifest) OpenAppend() error { return fmt.Errorf("open failed") } diff --git a/internal/manifest/sqlite/adapter.go b/internal/manifest/sqlite/adapter.go new file mode 100644 index 0000000..09348cc --- /dev/null +++ b/internal/manifest/sqlite/adapter.go @@ -0,0 +1,21 @@ +package sqlite + +import ( + _ "modernc.org/sqlite" + + "gitea.k3s.k0.nu/tools/photocli/internal/manifest/types" +) + +type Adapter struct{} + +func (Adapter) Format() types.Format { return types.FormatSQLite } +func (Adapter) Aliases() []string { return []string{"db", "sqlite3"} } +func (Adapter) Path(dir string) string { return Path(dir) } +func (Adapter) Exists(dir string) bool { return FileExists(Path(dir)) } +func (Adapter) Open(dir string) (types.Manifest, error) { return Load(dir) } +func (Adapter) OpenLogWriter(m types.Manifest, dir string) (types.LogWriter, error) { + if sm, ok := m.(*Store); ok && sm.DB() != nil { + return NewLogWriter(sm.DB()) + } + return types.NewFileLogWriter(types.LogPath(dir)) +} diff --git a/internal/manifest/sqlite/adapter_test.go b/internal/manifest/sqlite/adapter_test.go new file mode 100644 index 0000000..d0ee122 --- /dev/null +++ b/internal/manifest/sqlite/adapter_test.go @@ -0,0 +1,290 @@ +package sqlite + +import ( + "database/sql" + "fmt" + "path/filepath" + "testing" + + _ "modernc.org/sqlite" + + "gitea.k3s.k0.nu/tools/photocli/internal/manifest/types" +) + +func TestAdapter(t *testing.T) { + a := Adapter{} + if a.Format() != types.FormatSQLite { + t.Fatal("expected SQLite format") + } + if len(a.Aliases()) != 2 { + t.Fatal("expected 2 aliases") + } + dir := t.TempDir() + if a.Path(dir) != Path(dir) { + t.Fatal("expected path match") + } + if a.Exists(dir) { + t.Fatal("expected not to exist in empty dir") + } + m, err := a.Open(dir) + if err != nil { + t.Fatal(err) + } + m.Close() + w, err := a.OpenLogWriter(nil, dir) + if err != nil { + t.Fatal(err) + } + w.Close() +} + +func TestAdapterOpenLogWriterSQLite(t *testing.T) { + dir := t.TempDir() + m, err := Load(dir) + if err != nil { + t.Fatal(err) + } + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + defer m.Close() + w, err := Adapter{}.OpenLogWriter(m, dir) + if err != nil { + t.Fatal(err) + } + defer w.Close() + if _, ok := w.(*LogWriter); !ok { + t.Fatalf("expected sqlite log writer, got %T", w) + } +} + +func TestStoreLoadEmpty(t *testing.T) { + m, err := Load(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if m == nil { + t.Fatal("expected non-nil store") + } + m.Close() +} + +func TestStoreLoadNonexistent(t *testing.T) { + m, err := Load("/nonexistent/path") + if err != nil { + t.Fatal(err) + } + if m == nil { + t.Fatal("expected non-nil store") + } + m.Close() +} + +func TestStoreAddHasSaveClose(t *testing.T) { + m, err := Load(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Add("sid1", "sfile.jpg", 99, "azure") + if !m.Has("sid1") { + t.Fatal("expected Has to return true") + } + if m.Has("nope") { + t.Fatal("expected Has to return false") + } + if err := m.Save(); err != nil { + t.Fatal(err) + } + m.Close() +} + +func TestStoreRoundTrip(t *testing.T) { + dir := t.TempDir() + m, err := Load(dir) + if err != nil { + t.Fatal(err) + } + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Add("rid1", "rfile1.jpg", 10, "aws") + m.Add("rid2", "rfile2.jpg", 20, "gcs") + if err := m.Save(); err != nil { + t.Fatal(err) + } + m.Close() + + m2, err := Load(dir) + if err != nil { + t.Fatal(err) + } + if err := m2.OpenAppend(); err != nil { + t.Fatal(err) + } + defer m2.Close() + if !m2.Has("rid1") { + t.Fatal("expected rid1 after reload") + } + if !m2.Has("rid2") { + t.Fatal("expected rid2 after reload") + } +} + +func TestStoreEntries(t *testing.T) { + m, err := Load(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + defer m.Close() + m.Add("se1", "sf1.jpg", 1, "sc1") + m.Add("se2", "sf2.jpg", 2, "sc2") + entries := m.Entries() + if len(entries) != 2 { + t.Fatalf("expected 2 entries, got %d", len(entries)) + } +} + +func TestStoreCloseIdempotent(t *testing.T) { + m, err := Load(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Close() + m.Close() +} + +func TestStoreOpenAppendIdempotent(t *testing.T) { + m, err := Load(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Close() +} + +func TestStoreManifestFormat(t *testing.T) { + m, err := Load(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if m.ManifestFormat() != types.FormatSQLite { + t.Fatal("expected SQLite format") + } +} + +func TestStoreSave(t *testing.T) { + m, err := Load(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if err := m.Save(); err != nil { + t.Fatal(err) + } +} + +func TestStoreOpenAppendCreateDir(t *testing.T) { + dir := t.TempDir() + subdir := filepath.Join(dir, "subdir") + m, err := Load(subdir) + if err != nil { + t.Fatal(err) + } + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Close() +} + +func TestStoreCloseWithoutOpen(t *testing.T) { + m, err := Load(t.TempDir()) + if err != nil { + t.Fatal(err) + } + m.Close() +} + +func TestStoreEntriesWithoutOpen(t *testing.T) { + m, err := Load(t.TempDir()) + if err != nil { + t.Fatal(err) + } + entries := m.Entries() + if entries != nil { + t.Errorf("Entries without open should return nil, got %v", entries) + } +} + +func TestStoreHasWithoutOpen(t *testing.T) { + m, err := Load(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if m.Has("x") { + t.Fatal("Has should return false without open") + } +} + +func TestStoreAddEntryWithoutOpen(t *testing.T) { + m, err := Load(t.TempDir()) + if err != nil { + t.Fatal(err) + } + m.AddEntry(types.Entry{ID: "x", Filename: "f.jpg"}) +} + +func TestSetOpener(t *testing.T) { + old := SetOpener(func(driverName, dataSourceName string) (*sql.DB, error) { + return nil, fmt.Errorf("test") + }) + if old != nil { + t.Fatal("expected nil old opener") + } + restore := SetOpener(old) + if restore == nil { + t.Fatal("expected non-nil restore") + } + SetOpener(nil) +} + +func TestFileExists(t *testing.T) { + dir := t.TempDir() + if FileExists(dir) { + t.Fatal("expected false for directory") + } + if FileExists("/nonexistent/file") { + t.Fatal("expected false for nonexistent") + } +} + +func TestLogWriterClose(t *testing.T) { + w := &LogWriter{} + w.Close() +} + +func TestOpenerWithOverride(t *testing.T) { + SetOpener(func(driverName, dataSourceName string) (*sql.DB, error) { + return nil, nil + }) + if opener() == nil { + t.Fatal("expected override") + } + SetOpener(nil) +} + +func TestLogWriterCloseDirect(t *testing.T) { + (&LogWriter{}).Close() +} diff --git a/internal/manifest/sqlite_log.go b/internal/manifest/sqlite/log_writer.go similarity index 74% rename from internal/manifest/sqlite_log.go rename to internal/manifest/sqlite/log_writer.go index 30e8146..0e988c0 100644 --- a/internal/manifest/sqlite_log.go +++ b/internal/manifest/sqlite/log_writer.go @@ -1,14 +1,16 @@ -package manifest +package sqlite import ( "database/sql" + + "gitea.k3s.k0.nu/tools/photocli/internal/manifest/types" ) -type sqliteLogWriter struct { +type LogWriter struct { db *sql.DB } -func NewSQLiteLogWriter(db *sql.DB) (LogWriter, error) { +func NewLogWriter(db *sql.DB) (types.LogWriter, error) { _, err := db.Exec(`CREATE TABLE IF NOT EXISTS logs ( id INTEGER PRIMARY KEY AUTOINCREMENT, ts INTEGER NOT NULL, @@ -27,10 +29,10 @@ func NewSQLiteLogWriter(db *sql.DB) (LogWriter, error) { } _, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_logs_ts ON logs(ts)`) _, _ = db.Exec(`CREATE INDEX IF NOT EXISTS idx_logs_event ON logs(event)`) - return &sqliteLogWriter{db: db}, nil + return &LogWriter{db: db}, nil } -func (w *sqliteLogWriter) Log(e LogEntry) { +func (w *LogWriter) Log(e types.LogEntry) { if w.db == nil { return } @@ -38,4 +40,4 @@ func (w *sqliteLogWriter) Log(e LogEntry) { e.Timestamp, e.Level, e.Event, e.AssetID, e.Album, e.Filename, e.Size, e.Cloud, e.DurationMs, e.Message) } -func (w *sqliteLogWriter) Close() { _ = w } +func (w *LogWriter) Close() { _ = w } diff --git a/internal/manifest/sqlite.go b/internal/manifest/sqlite/store.go similarity index 62% rename from internal/manifest/sqlite.go rename to internal/manifest/sqlite/store.go index 8957f19..9e2d7a4 100644 --- a/internal/manifest/sqlite.go +++ b/internal/manifest/sqlite/store.go @@ -1,4 +1,4 @@ -package manifest +package sqlite import ( "database/sql" @@ -6,40 +6,46 @@ import ( "os" "path/filepath" - _ "modernc.org/sqlite" + "gitea.k3s.k0.nu/tools/photocli/internal/manifest/types" ) -type sqliteManifest struct { +type OpenerFunc func(driverName, dataSourceName string) (*sql.DB, error) + +var openerOverride OpenerFunc + +func SetOpener(fn OpenerFunc) (old OpenerFunc) { + old = openerOverride + openerOverride = fn + return old +} + +type Store struct { path string db *sql.DB - open sqlOpenerFunc + open OpenerFunc execFunc func(query string, args ...any) (sql.Result, error) } -type sqlOpenerFunc func(driverName, dataSourceName string) (*sql.DB, error) - -var sqliteOpenFunc sqlOpenerFunc - -func defaultSQLOpener() sqlOpenerFunc { - if sqliteOpenFunc != nil { - return sqliteOpenFunc - } - return sql.Open -} - -func SQLitePath(dir string) string { +func Path(dir string) string { return filepath.Join(dir, "downloads.db") } -func LoadSQLite(dir string) (*sqliteManifest, error) { - m := &sqliteManifest{ - path: SQLitePath(dir), - open: defaultSQLOpener(), +func Load(dir string) (*Store, error) { + m := &Store{ + path: Path(dir), + open: opener(), } return m, nil } -func (m *sqliteManifest) OpenAppend() error { +func opener() OpenerFunc { + if openerOverride != nil { + return openerOverride + } + return sql.Open +} + +func (m *Store) OpenAppend() error { if m.db != nil { return nil } @@ -82,7 +88,13 @@ func (m *sqliteManifest) OpenAppend() error { return nil } -func (m *sqliteManifest) Has(id string) bool { +func (m *Store) DB() *sql.DB { return m.db } +func (m *Store) SetOpen(fn OpenerFunc) { m.open = fn } +func (m *Store) SetExecFunc(fn func(query string, args ...any) (sql.Result, error)) { + m.execFunc = fn +} + +func (m *Store) Has(id string) bool { if m.db == nil { return false } @@ -94,11 +106,15 @@ func (m *sqliteManifest) Has(id string) bool { return count > 0 } -func (m *sqliteManifest) Add(id string, filename string, size int64, cloud string) { - m.AddEntry(newEntry(id, filename, size, cloud)) +func (m *Store) ManifestFormat() types.Format { + return types.FormatSQLite } -func (m *sqliteManifest) AddEntry(entry Entry) { +func (m *Store) Add(id string, filename string, size int64, cloud string) { + m.AddEntry(types.NewEntry(id, filename, filename, size, cloud)) +} + +func (m *Store) AddEntry(entry types.Entry) { if m.db == nil { return } @@ -109,33 +125,27 @@ func (m *sqliteManifest) AddEntry(entry Entry) { entry.ID, entry.Filename, entry.Path, entry.Size, entry.Cloud, entry.Checksum, entry.Exported) } -func (m *sqliteManifest) Save() error { - return nil -} +func (m *Store) Save() error { return nil } -func (m *sqliteManifest) Close() { +func (m *Store) Close() { if m.db != nil { m.db.Close() m.db = nil } } -func (m *sqliteManifest) DB() *sql.DB { - return m.db -} - -func (m *sqliteManifest) Entries() map[string]Entry { +func (m *Store) Entries() map[string]types.Entry { if m.db == nil { return nil } - out := make(map[string]Entry) + out := make(map[string]types.Entry) rows, err := m.db.Query(`SELECT id, filename, path, size, cloud, checksum, exported FROM downloads`) if err != nil { return out } defer rows.Close() for rows.Next() { - var e Entry + var e types.Entry if err := rows.Scan(&e.ID, &e.Filename, &e.Path, &e.Size, &e.Cloud, &e.Checksum, &e.Exported); err == nil { if e.Path == "" { e.Path = e.Filename @@ -145,3 +155,11 @@ func (m *sqliteManifest) Entries() map[string]Entry { } return out } + +func FileExists(path string) bool { + info, err := os.Stat(path) + if err != nil { + return false + } + return !info.IsDir() +} diff --git a/internal/manifest/sqlite/store_test.go b/internal/manifest/sqlite/store_test.go new file mode 100644 index 0000000..f4eb720 --- /dev/null +++ b/internal/manifest/sqlite/store_test.go @@ -0,0 +1,323 @@ +package sqlite + +import ( + "database/sql" + "fmt" + "path/filepath" + "strings" + "testing" + + _ "modernc.org/sqlite" + + "gitea.k3s.k0.nu/tools/photocli/internal/manifest/types" +) + +func TestStoreOpenAppendSQLError(t *testing.T) { + dir := t.TempDir() + m, err := Load(dir) + if err != nil { + t.Fatal(err) + } + m.SetOpen(func(driverName, dataSourceName string) (*sql.DB, error) { + return nil, fmt.Errorf("simulated open error") + }) + if err := m.OpenAppend(); err == nil { + t.Error("expected error from sql.Open failure") + } +} + +func TestStoreOpenAppendCreateTableError(t *testing.T) { + dir := t.TempDir() + m, err := Load(dir) + if err != nil { + t.Fatal(err) + } + realOpen := m.open + m.SetOpen(func(driverName, dataSourceName string) (*sql.DB, error) { + db, err := realOpen(driverName, dataSourceName) + if err != nil { + return nil, err + } + db.Close() + return db, nil + }) + if err := m.OpenAppend(); err == nil { + t.Error("expected error from closed DB CREATE TABLE") + } +} + +func TestStoreHasAfterClose(t *testing.T) { + dir := t.TempDir() + m, err := Load(dir) + if err != nil { + t.Fatal(err) + } + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Add("x1", "photo.jpg", 1024, "local") + m.Close() + if m.Has("x1") { + t.Error("Has should return false after Close") + } +} + +func TestStoreEntriesAfterClose(t *testing.T) { + dir := t.TempDir() + m, err := Load(dir) + if err != nil { + t.Fatal(err) + } + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Add("x1", "photo.jpg", 1024, "local") + m.Close() + entries := m.Entries() + if entries != nil { + t.Errorf("Entries should return nil after Close, got %d entries", len(entries)) + } +} + +func TestStoreOpenAppendMkdirAllError(t *testing.T) { + m, err := Load("/proc/cannot-write") + if err != nil { + t.Fatal(err) + } + if err := m.OpenAppend(); err == nil { + t.Error("expected error creating dir under /proc") + m.Close() + } +} + +func TestStoreOpenAppendNilOpener(t *testing.T) { + dir := t.TempDir() + m, err := Load(dir) + if err != nil { + t.Fatal(err) + } + m.open = nil + if err := m.OpenAppend(); err != nil { + t.Errorf("expected nil opener to use sql.Open fallback, got err: %v", err) + } + m.Close() +} + +func TestStoreOpenAppendCreateIndexError(t *testing.T) { + dir := t.TempDir() + m, err := Load(dir) + if err != nil { + t.Fatal(err) + } + realOpen := m.open + m.open = func(driverName, dataSourceName string) (*sql.DB, error) { + db, err := realOpen(driverName, dataSourceName) + if err != nil { + return nil, err + } + db.Close() + return db, nil + } + if err := m.OpenAppend(); err == nil { + t.Error("expected error from closed DB") + } +} + +func TestStoreHasQueryError(t *testing.T) { + dir := t.TempDir() + m, err := Load(dir) + if err != nil { + t.Fatal(err) + } + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Add("x1", "photo.jpg", 1024, "local") + closedDB := m.DB() + closedDB.Close() + if m.Has("x1") { + t.Error("Has should return false with broken DB connection") + } +} + +func TestStoreEntriesQueryError(t *testing.T) { + dir := t.TempDir() + m, err := Load(dir) + if err != nil { + t.Fatal(err) + } + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Add("x1", "photo.jpg", 1024, "local") + closedDB := m.DB() + closedDB.Close() + entries := m.Entries() + if entries == nil { + t.Error("Entries should return non-nil map with broken DB connection") + } + if len(entries) != 0 { + t.Errorf("Entries should be empty with broken DB connection, got %d", len(entries)) + } +} + +func TestStoreHasQueryErrorWithOpenDB(t *testing.T) { + dir := t.TempDir() + m, err := Load(dir) + if err != nil { + t.Fatal(err) + } + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Add("x1", "photo.jpg", 1024, "local") + closedDB := m.DB() + closedDB.Close() + if m.Has("x1") { + t.Error("Has should return false with closed DB") + } +} + +func TestStoreEntriesQueryErrorWithOpenDB(t *testing.T) { + dir := t.TempDir() + m, err := Load(dir) + if err != nil { + t.Fatal(err) + } + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.Add("x1", "photo.jpg", 1024, "local") + closedDB := m.DB() + closedDB.Close() + entries := m.Entries() + if entries != nil && len(entries) != 0 { + t.Errorf("Entries should be empty with closed DB, got %d", len(entries)) + } +} + +func TestStoreCreateIndexError(t *testing.T) { + dir := t.TempDir() + m, err := Load(dir) + if err != nil { + t.Fatal(err) + } + realOpen := m.open + m.open = func(driverName, dataSourceName string) (*sql.DB, error) { + db, err := realOpen(driverName, dataSourceName) + if err != nil { + return nil, err + } + m.SetExecFunc(func(query string, args ...any) (sql.Result, error) { + if strings.Contains(query, "CREATE INDEX") { + return nil, fmt.Errorf("injected CREATE INDEX error") + } + return db.Exec(query, args...) + }) + return db, nil + } + if err := m.OpenAppend(); err == nil { + t.Error("expected error from CREATE INDEX") + m.Close() + } +} + +func TestAddEntryDefaultsPath(t *testing.T) { + dir := t.TempDir() + m, err := Load(dir) + if err != nil { + t.Fatal(err) + } + if err := m.OpenAppend(); err != nil { + t.Fatal(err) + } + m.AddEntry(types.Entry{ID: "x1", Filename: "file.jpg", Size: 3, Cloud: "local", Checksum: "sha256:def"}) + if _, err := m.DB().Exec(`UPDATE downloads SET path = '' WHERE id = 'x1'`); err != nil { + t.Fatal(err) + } + sloaded := m.Entries()["x1"] + if got := sloaded.Path; got != "file.jpg" { + t.Fatalf("sqlite path = %q", got) + } + if sloaded.Checksum != "sha256:def" { + t.Fatalf("sqlite checksum = %q", sloaded.Checksum) + } + m.Close() +} + +func TestNewLogWriter(t *testing.T) { + dir := t.TempDir() + dbPath := filepath.Join(dir, "test.db") + db, err := sql.Open("sqlite", dbPath) + if err != nil { + t.Fatal(err) + } + defer db.Close() + + lw, err := NewLogWriter(db) + if err != nil { + t.Fatal(err) + } + + lw.Log(types.LogEntry{ + Timestamp: 1700000000, + Level: "info", + Event: "export_done", + AssetID: "asset-1", + Album: "Favorites", + Filename: "photo.jpg", + Size: 1024, + Cloud: "local", + DurationMs: 500, + }) + lw.Log(types.LogEntry{ + Timestamp: 1700000001, + Level: "error", + Event: "export_fail", + AssetID: "asset-2", + Filename: "bad.jpg", + Message: "timeout", + }) + lw.Close() + + var count int + err = db.QueryRow(`SELECT COUNT(*) FROM logs`).Scan(&count) + if err != nil { + t.Fatal(err) + } + if count != 2 { + t.Errorf("expected 2 log entries, got %d", count) + } + + var event, level, assetID string + err = db.QueryRow(`SELECT event, level, asset_id FROM logs WHERE asset_id = 'asset-1'`).Scan(&event, &level, &assetID) + if err != nil { + t.Fatal(err) + } + if event != "export_done" || level != "info" || assetID != "asset-1" { + t.Errorf("unexpected row: event=%s level=%s asset_id=%s", event, level, assetID) + } +} + +func TestLogWriterNilDB(t *testing.T) { + w := &LogWriter{db: nil} + w.Log(types.LogEntry{Event: "test"}) + w.Close() +} + +func TestNewLogWriterError(t *testing.T) { + db, err := sql.Open("sqlite", filepath.Join(t.TempDir(), "test.db")) + if err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + if _, err := NewLogWriter(db); err == nil { + t.Error("expected error for closed db") + } +} + +func TestLogWriterCloseConcrete(t *testing.T) { + (&LogWriter{}).Close() +} diff --git a/internal/manifest/sqlite_internal_test.go b/internal/manifest/sqlite_internal_test.go deleted file mode 100644 index 970c4fa..0000000 --- a/internal/manifest/sqlite_internal_test.go +++ /dev/null @@ -1,402 +0,0 @@ -package manifest - -import ( - "database/sql" - "fmt" - "os" - "strings" - "testing" - - _ "modernc.org/sqlite" -) - -func TestSetJSONLSaveHook(t *testing.T) { - old := SetJSONLSaveHook(func() error { return fmt.Errorf("hook error") }) - if old != nil { - t.Error("expected nil old hook") - } - restore := SetJSONLSaveHook(old) - if restore == nil { - t.Error("expected non-nil restore function") - } -} - -func TestJSONLSaveHookError(t *testing.T) { - dir := t.TempDir() - m := LoadJSONL(dir) - if err := m.OpenAppend(); err != nil { - t.Fatal(err) - } - m.Add("x1", "photo.jpg", 1024, "local") - old := SetJSONLSaveHook(func() error { return fmt.Errorf("hook error") }) - defer SetJSONLSaveHook(old) - if err := m.Save(); err == nil { - t.Error("expected hook error from Save") - } - m.Close() -} - -func TestJSONLSyncFuncError(t *testing.T) { - dir := t.TempDir() - m := LoadJSONL(dir) - if err := m.OpenAppend(); err != nil { - t.Fatal(err) - } - m.Add("x1", "photo.jpg", 1024, "local") - m.syncFunc = func() error { return fmt.Errorf("sync func error") } - if err := m.Save(); err == nil { - t.Error("expected syncFunc error from Save") - } - m.Close() -} - -func TestSQLiteOpenAppendSQLError(t *testing.T) { - dir := t.TempDir() - m, err := LoadSQLite(dir) - if err != nil { - t.Fatal(err) - } - m.open = func(driverName, dataSourceName string) (*sql.DB, error) { - return nil, fmt.Errorf("simulated open error") - } - if err := m.OpenAppend(); err == nil { - t.Error("expected error from sql.Open failure") - } -} - -func TestSQLiteOpenAppendCreateTableError(t *testing.T) { - dir := t.TempDir() - m, err := LoadSQLite(dir) - if err != nil { - t.Fatal(err) - } - realOpen := m.open - m.open = func(driverName, dataSourceName string) (*sql.DB, error) { - db, err := realOpen(driverName, dataSourceName) - if err != nil { - return nil, err - } - db.Close() - return db, nil - } - if err := m.OpenAppend(); err == nil { - t.Error("expected error from closed DB CREATE TABLE") - } -} - -func TestSQLiteHasAfterClose(t *testing.T) { - dir := t.TempDir() - m, err := LoadSQLite(dir) - if err != nil { - t.Fatal(err) - } - if err := m.OpenAppend(); err != nil { - t.Fatal(err) - } - m.Add("x1", "photo.jpg", 1024, "local") - m.Close() - if m.Has("x1") { - t.Error("Has should return false after Close") - } -} - -func TestSQLiteEntriesAfterClose(t *testing.T) { - dir := t.TempDir() - m, err := LoadSQLite(dir) - if err != nil { - t.Fatal(err) - } - if err := m.OpenAppend(); err != nil { - t.Fatal(err) - } - m.Add("x1", "photo.jpg", 1024, "local") - m.Close() - entries := m.Entries() - if entries != nil { - t.Errorf("Entries should return nil after Close, got %d entries", len(entries)) - } -} - -func TestSQLiteOpenAppendMkdirAllError(t *testing.T) { - m, err := LoadSQLite("/proc/cannot-write") - if err != nil { - t.Fatal(err) - } - if err := m.OpenAppend(); err == nil { - t.Error("expected error creating dir under /proc") - m.Close() - } -} - -func TestSQLiteOpenAppendNilOpener(t *testing.T) { - dir := t.TempDir() - m, err := LoadSQLite(dir) - if err != nil { - t.Fatal(err) - } - m.open = nil - if err := m.OpenAppend(); err != nil { - t.Errorf("expected nil opener to use sql.Open fallback, got err: %v", err) - } - m.Close() -} - -func TestSQLiteOpenAppendCreateIndexError(t *testing.T) { - dir := t.TempDir() - m, err := LoadSQLite(dir) - if err != nil { - t.Fatal(err) - } - realOpen := m.open - m.open = func(driverName, dataSourceName string) (*sql.DB, error) { - db, err := realOpen(driverName, dataSourceName) - if err != nil { - return nil, err - } - db.Close() - return db, nil - } - if err := m.OpenAppend(); err == nil { - t.Error("expected error from closed DB") - } -} - -func TestSQLiteHasQueryError(t *testing.T) { - dir := t.TempDir() - m, err := LoadSQLite(dir) - if err != nil { - t.Fatal(err) - } - if err := m.OpenAppend(); err != nil { - t.Fatal(err) - } - m.Add("x1", "photo.jpg", 1024, "local") - closedDB := m.db - closedDB.Close() - result := m.Has("x1") - if result { - t.Error("Has should return false with broken DB connection") - } -} - -func TestSQLiteEntriesQueryError(t *testing.T) { - dir := t.TempDir() - m, err := LoadSQLite(dir) - if err != nil { - t.Fatal(err) - } - if err := m.OpenAppend(); err != nil { - t.Fatal(err) - } - m.Add("x1", "photo.jpg", 1024, "local") - closedDB := m.db - closedDB.Close() - entries := m.Entries() - if entries == nil { - t.Error("Entries should return non-nil map with broken DB connection") - } - if len(entries) != 0 { - t.Errorf("Entries should be empty with broken DB connection, got %d", len(entries)) - } -} - -func TestSQLiteHasQueryErrorWithOpenDB(t *testing.T) { - dir := t.TempDir() - m, err := LoadSQLite(dir) - if err != nil { - t.Fatal(err) - } - if err := m.OpenAppend(); err != nil { - t.Fatal(err) - } - m.Add("x1", "photo.jpg", 1024, "local") - closedDB := m.db - closedDB.Close() - if m.Has("x1") { - t.Error("Has should return false with closed DB") - } -} - -func TestSQLiteEntriesQueryErrorWithOpenDB(t *testing.T) { - dir := t.TempDir() - m, err := LoadSQLite(dir) - if err != nil { - t.Fatal(err) - } - if err := m.OpenAppend(); err != nil { - t.Fatal(err) - } - m.Add("x1", "photo.jpg", 1024, "local") - closedDB := m.db - closedDB.Close() - entries := m.Entries() - if entries != nil && len(entries) != 0 { - t.Errorf("Entries should be empty with closed DB, got %d", len(entries)) - } -} - -func TestSQLiteCreateIndexError(t *testing.T) { - dir := t.TempDir() - m, err := LoadSQLite(dir) - if err != nil { - t.Fatal(err) - } - realOpen := m.open - callCount := 0 - m.open = func(driverName, dataSourceName string) (*sql.DB, error) { - db, err := realOpen(driverName, dataSourceName) - if err != nil { - return nil, err - } - m.execFunc = func(query string, args ...any) (sql.Result, error) { - if strings.Contains(query, "CREATE INDEX") { - return nil, fmt.Errorf("injected CREATE INDEX error") - } - callCount++ - return db.Exec(query, args...) - } - return db, nil - } - if err := m.OpenAppend(); err == nil { - t.Error("expected error from CREATE INDEX") - m.Close() - } -} - -func TestConvertFromJSONLOpenAppendSQLError(t *testing.T) { - dir := t.TempDir() - m := LoadJSONL(dir) - if err := m.OpenAppend(); err != nil { - t.Fatal(err) - } - m.Add("x1", "photo.jpg", 1024, "local") - m.Close() - oldOpen := sqliteOpenFunc - sqliteOpenFunc = func(driverName, dataSourceName string) (*sql.DB, error) { - return nil, fmt.Errorf("simulated sqlite open error") - } - defer func() { sqliteOpenFunc = oldOpen }() - _, err := ConvertFromJSONL(dir) - if err == nil { - t.Error("expected error from dst.OpenAppend during ConvertFromJSONL") - } -} - -func TestConvertFromJSONLDstOpenAppendError(t *testing.T) { - dir := t.TempDir() - m := LoadJSONL(dir) - if err := m.OpenAppend(); err != nil { - t.Fatal(err) - } - m.Add("x1", "photo.jpg", 1024, "local") - m.Close() - realOpen := defaultSQLOpener() - oldOpen := sqliteOpenFunc - sqliteOpenFunc = func(driverName, dataSourceName string) (*sql.DB, error) { - db, err := realOpen(driverName, dataSourceName) - if err != nil { - return nil, err - } - db.Close() - return db, nil - } - defer func() { sqliteOpenFunc = oldOpen }() - _, err := ConvertFromJSONL(dir) - if err == nil { - t.Error("expected error from dst.OpenAppend during ConvertFromJSONL") - } -} - -func TestConvertFromSQLiteSrcOpenAppendError(t *testing.T) { - dir := t.TempDir() - src, err := LoadSQLite(dir) - if err != nil { - t.Fatal(err) - } - if err := src.OpenAppend(); err != nil { - t.Fatal(err) - } - src.Add("x1", "photo.jpg", 1024, "local") - src.Close() - realOpen := defaultSQLOpener() - callCount := 0 - oldOpen := sqliteOpenFunc - sqliteOpenFunc = func(driverName, dataSourceName string) (*sql.DB, error) { - callCount++ - db, err := realOpen(driverName, dataSourceName) - if err != nil { - return nil, err - } - if callCount > 0 { - db.Close() - } - return db, nil - } - defer func() { sqliteOpenFunc = oldOpen }() - _, err = ConvertFromSQLite(dir) - if err == nil { - t.Error("expected error from src.OpenAppend during ConvertFromSQLite") - } -} - -func TestConvertFromSQLiteDstOpenAppendError(t *testing.T) { - dir := t.TempDir() - src, err := LoadSQLite(dir) - if err != nil { - t.Fatal(err) - } - if err := src.OpenAppend(); err != nil { - t.Fatal(err) - } - src.Add("x1", "photo.jpg", 1024, "local") - src.Close() - jsonlPath := JSONLPath(dir) - f, err := os.Create(jsonlPath) - if err != nil { - t.Fatal(err) - } - f.Close() - os.Chmod(jsonlPath, 0444) - defer os.Chmod(jsonlPath, 0644) - _, err = ConvertFromSQLite(dir) - if err == nil { - t.Error("expected error from dst.OpenAppend during ConvertFromSQLite") - } -} - -func TestJSONLSaveError(t *testing.T) { - dir := t.TempDir() - m := LoadJSONL(dir) - if err := m.OpenAppend(); err != nil { - t.Fatal(err) - } - m.Add("x1", "photo.jpg", 1024, "local") - m.file.Close() - if err := m.Save(); err == nil { - t.Error("expected Sync error on closed file") - } - m.Close() -} - -func TestConvertFromSQLiteSaveError(t *testing.T) { - dir := t.TempDir() - src, err := LoadSQLite(dir) - if err != nil { - t.Fatal(err) - } - if err := src.OpenAppend(); err != nil { - t.Fatal(err) - } - src.Add("x1", "photo.jpg", 1024, "local") - src.Close() - oldHook := jsonlSaveHook - jsonlSaveHook = func() error { return fmt.Errorf("simulated sync error") } - defer func() { jsonlSaveHook = oldHook }() - _, err = ConvertFromSQLite(dir) - if err == nil { - t.Error("expected error from dst.Save during ConvertFromSQLite") - } - if err != nil && !strings.Contains(err.Error(), "save jsonl") { - t.Errorf("expected save jsonl error, got: %v", err) - } -} diff --git a/internal/manifest/file_log.go b/internal/manifest/types/file_log.go similarity index 97% rename from internal/manifest/file_log.go rename to internal/manifest/types/file_log.go index b2cdb0d..fa517d8 100644 --- a/internal/manifest/file_log.go +++ b/internal/manifest/types/file_log.go @@ -1,4 +1,4 @@ -package manifest +package types import ( "encoding/json" diff --git a/internal/manifest/types/registry.go b/internal/manifest/types/registry.go new file mode 100644 index 0000000..6da8725 --- /dev/null +++ b/internal/manifest/types/registry.go @@ -0,0 +1,117 @@ +package types + +import ( + "fmt" + "strings" +) + +type Adapter interface { + Format() Format + Aliases() []string + Path(dir string) string + Exists(dir string) bool + Open(dir string) (Manifest, error) + OpenLogWriter(m Manifest, dir string) (LogWriter, error) +} + +type Registry struct { + adapters []Adapter +} + +func NewRegistry(adapters ...Adapter) Registry { + return Registry{adapters: adapters} +} + +func (r Registry) ParseFormat(s string) (Format, error) { + want := strings.ToLower(s) + for _, adapter := range r.adapters { + if want == string(adapter.Format()) { + return adapter.Format(), nil + } + for _, alias := range adapter.Aliases() { + if want == strings.ToLower(alias) { + return adapter.Format(), nil + } + } + } + return "", fmt.Errorf("unknown manifest format %q (use jsonl or sqlite)", s) +} + +func (r Registry) Open(dir string, format Format) (Manifest, error) { + dst, err := r.adapter(format) + if err != nil { + return nil, err + } + if dst.Exists(dir) { + return dst.Open(dir) + } + for _, src := range r.adapters { + if src.Format() != dst.Format() && src.Exists(dir) { + return ConvertManifest(dir, src, dst) + } + } + return dst.Open(dir) +} + +func (r Registry) OpenLogWriter(m Manifest, dir string, format Format) (LogWriter, error) { + adapter, err := r.adapter(format) + if err != nil { + return nil, err + } + return adapter.OpenLogWriter(m, dir) +} + +func (r Registry) adapter(format Format) (Adapter, error) { + for _, adapter := range r.adapters { + if adapter.Format() == format { + return adapter, nil + } + } + return nil, fmt.Errorf("unknown manifest format %q (use jsonl or sqlite)", format) +} + +func ConvertManifest(dir string, src Adapter, dst Adapter) (Manifest, error) { + source, err := src.Open(dir) + if err != nil { + return nil, fmt.Errorf("open %s for read: %w", src.Format(), err) + } + if err := source.OpenAppend(); err != nil { + source.Close() + return nil, fmt.Errorf("open %s for read: %w", src.Format(), err) + } + defer source.Close() + + target, err := dst.Open(dir) + if err != nil { + return nil, fmt.Errorf("open %s for write: %w", dst.Format(), err) + } + if err := target.OpenAppend(); err != nil { + target.Close() + return nil, fmt.Errorf("open %s for write: %w", dst.Format(), err) + } + + for id, entry := range source.Entries() { + entry.ID = id + target.AddEntry(entry) + } + if err := target.Save(); err != nil { + target.Close() + return nil, fmt.Errorf("save %s: %w", dst.Format(), err) + } + + if err := removeFile(src.Path(dir)); err != nil { + target.Close() + return nil, err + } + return target, nil +} + +var removeFile func(string) error + +func SetRemoveFunc(fn func(string) error) { + removeFile = fn +} + +func RemoveFunc() func(string) error { + return removeFile +} diff --git a/internal/manifest/types/registry_test.go b/internal/manifest/types/registry_test.go new file mode 100644 index 0000000..411b310 --- /dev/null +++ b/internal/manifest/types/registry_test.go @@ -0,0 +1,266 @@ +package types + +import ( + "fmt" + "testing" +) + +func TestNewRegistry(t *testing.T) { + r := NewRegistry() + if _, err := r.ParseFormat("jsonl"); err == nil { + t.Fatal("expected error from empty registry") + } +} + +func TestRegistryParseFormatAliases(t *testing.T) { + r := NewRegistry(testAdapter{format: FormatJSONL, aliases: []string{"json"}}, testAdapter{format: FormatSQLite, aliases: []string{"db", "sqlite3"}}) + cases := map[string]Format{ + "jsonl": FormatJSONL, + "json": FormatJSONL, + "sqlite": FormatSQLite, + "db": FormatSQLite, + "sqlite3": FormatSQLite, + } + for input, want := range cases { + got, err := r.ParseFormat(input) + if err != nil { + t.Fatalf("ParseFormat(%q): %v", input, err) + } + if got != want { + t.Fatalf("ParseFormat(%q) = %q, want %q", input, got, want) + } + } +} + +func TestRegistryParseFormatUnknown(t *testing.T) { + r := NewRegistry() + if _, err := r.ParseFormat("bad"); err == nil { + t.Fatal("expected unknown format error") + } +} + +func TestRegistryOpenDefaultsToOpen(t *testing.T) { + r := NewRegistry(testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}, exists: false}) + m, err := r.Open(t.TempDir(), FormatJSONL) + if err != nil { + t.Fatal(err) + } + m.Close() +} + +func TestRegistryOpenUnknownFormat(t *testing.T) { + r := NewRegistry() + if _, err := r.Open(t.TempDir(), FormatSQLite); err == nil { + t.Fatal("expected error") + } +} + +func TestRegistryOpenNoConversionFallback(t *testing.T) { + r := NewRegistry( + testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}, exists: false}, + testAdapter{format: FormatSQLite, store: &testStore{entries: map[string]Entry{}}, exists: false}, + ) + m, err := r.Open(t.TempDir(), FormatJSONL) + if err != nil { + t.Fatal(err) + } + m.Close() +} + +func TestRegistryOpenSameFormatAdapterInLoop(t *testing.T) { + r := NewRegistry( + testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}, exists: false}, + testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}, exists: false}, + ) + m, err := r.Open(t.TempDir(), FormatJSONL) + if err != nil { + t.Fatal(err) + } + m.Close() +} + +func TestRegistryOpenOtherFormatNotExists(t *testing.T) { + r := NewRegistry( + testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}, exists: false}, + testAdapter{format: FormatSQLite, store: &testStore{entries: map[string]Entry{}}, exists: false}, + ) + m, err := r.Open(t.TempDir(), FormatJSONL) + if err != nil { + t.Fatal(err) + } + m.Close() +} + +func TestRegistryOpenExistingManifest(t *testing.T) { + r := NewRegistry( + testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}, exists: true}, + ) + m, err := r.Open(t.TempDir(), FormatJSONL) + if err != nil { + t.Fatal(err) + } + m.Close() +} + +func TestRegistryOpenConverts(t *testing.T) { + dir := t.TempDir() + src := &testStore{entries: map[string]Entry{"x1": {ID: "x1", Filename: "photo.jpg", Path: "photo.jpg"}}} + dst := &testStore{entries: map[string]Entry{}} + r := NewRegistry( + testAdapter{format: FormatJSONL, store: src, exists: true}, + testAdapter{format: FormatSQLite, store: dst, exists: false}, + ) + SetRemoveFunc(func(string) error { return nil }) + m, err := r.Open(dir, FormatSQLite) + if err != nil { + t.Fatal(err) + } + m.Close() + if _, ok := dst.entries["x1"]; !ok { + t.Fatal("expected converted entry") + } +} + +func TestRegistryOpenLogWriter(t *testing.T) { + r := NewRegistry(testAdapter{format: FormatJSONL, logWriter: NoopLogWriter}) + w, err := r.OpenLogWriter(nil, t.TempDir(), FormatJSONL) + if err != nil { + t.Fatal(err) + } + w.Close() +} + +func TestRegistryOpenLogWriterUnknown(t *testing.T) { + r := NewRegistry() + if _, err := r.OpenLogWriter(nil, t.TempDir(), FormatSQLite); err == nil { + t.Fatal("expected error") + } +} + +func TestConvertManifest(t *testing.T) { + dir := t.TempDir() + src := &testStore{entries: map[string]Entry{"x1": {ID: "x1", Filename: "photo.jpg", Path: "photo.jpg"}}} + dst := &testStore{entries: map[string]Entry{}} + SetRemoveFunc(func(string) error { return nil }) + m, err := ConvertManifest(dir, testAdapter{format: FormatJSONL, store: src, exists: true}, testAdapter{format: FormatSQLite, store: dst, exists: false}) + if err != nil { + t.Fatal(err) + } + m.Close() + if _, ok := dst.entries["x1"]; !ok { + t.Fatal("expected converted entry") + } +} + +func TestConvertManifestSourceOpenError(t *testing.T) { + if _, err := ConvertManifest(t.TempDir(), testAdapter{format: FormatJSONL, openErr: fmt.Errorf("boom")}, testAdapter{format: FormatSQLite, store: &testStore{}}); err == nil { + t.Fatal("expected error") + } +} + +func TestConvertManifestSourceOpenAppendError(t *testing.T) { + if _, err := ConvertManifest(t.TempDir(), testAdapter{format: FormatJSONL, store: &testStore{openAppendErr: fmt.Errorf("boom")}}, testAdapter{format: FormatSQLite, store: &testStore{}}); err == nil { + t.Fatal("expected error") + } +} + +func TestConvertManifestDstOpenError(t *testing.T) { + if _, err := ConvertManifest(t.TempDir(), testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}}, testAdapter{format: FormatSQLite, openErr: fmt.Errorf("boom")}); err == nil { + t.Fatal("expected error") + } +} + +func TestConvertManifestDstOpenAppendError(t *testing.T) { + if _, err := ConvertManifest(t.TempDir(), testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}}, testAdapter{format: FormatSQLite, store: &testStore{openAppendErr: fmt.Errorf("boom")}}); err == nil { + t.Fatal("expected error") + } +} + +func TestConvertManifestDstSaveError(t *testing.T) { + if _, err := ConvertManifest(t.TempDir(), testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}}, testAdapter{format: FormatSQLite, store: &testStore{saveErr: fmt.Errorf("boom")}}); err == nil { + t.Fatal("expected error") + } +} + +func TestConvertManifestRemoveError(t *testing.T) { + SetRemoveFunc(func(string) error { return fmt.Errorf("remove failed") }) + defer SetRemoveFunc(nil) + if _, err := ConvertManifest(t.TempDir(), testAdapter{format: FormatJSONL, store: &testStore{entries: map[string]Entry{}}}, testAdapter{format: FormatSQLite, store: &testStore{}}); err == nil { + t.Fatal("expected error") + } +} + +func TestSetRemoveFunc(t *testing.T) { + SetRemoveFunc(func(string) error { return nil }) + if RemoveFunc() == nil { + t.Fatal("expected non-nil remove func") + } + SetRemoveFunc(nil) + if RemoveFunc() != nil { + t.Fatal("expected nil remove func") + } +} + +func TestNoopLogWriterMethods(t *testing.T) { + NoopLogWriter.Log(LogEntry{}) + NoopLogWriter.Close() + var n noopLogWriter + n.Log(LogEntry{}) + n.Close() +} + +type testAdapter struct { + format Format + aliases []string + store Manifest + exists bool + openErr error + logWriter LogWriter +} + +func (a testAdapter) Format() Format { return a.format } +func (a testAdapter) Aliases() []string { return a.aliases } +func (a testAdapter) Path(string) string { return "test" } +func (a testAdapter) Exists(string) bool { return a.exists } +func (a testAdapter) Open(string) (Manifest, error) { + if a.openErr != nil { + return nil, a.openErr + } + return a.store, nil +} +func (a testAdapter) OpenLogWriter(Manifest, string) (LogWriter, error) { + if a.logWriter != nil { + return a.logWriter, nil + } + return NoopLogWriter, nil +} + +type testStore struct { + entries map[string]Entry + openAppendErr error + saveErr error +} + +func (s *testStore) Has(id string) bool { + _, ok := s.entries[id] + return ok +} +func (s *testStore) Add(id string, filename string, size int64, cloud string) { + s.entries[id] = NewEntry(id, filename, filename, size, cloud) +} +func (s *testStore) AddEntry(e Entry) { + if e.Path == "" { + e.Path = e.Filename + } + s.entries[e.ID] = e +} +func (s *testStore) Save() error { return s.saveErr } +func (s *testStore) Close() {} +func (s *testStore) OpenAppend() error { return s.openAppendErr } +func (s *testStore) Entries() map[string]Entry { + out := make(map[string]Entry, len(s.entries)) + for k, v := range s.entries { + out[k] = v + } + return out +} diff --git a/internal/manifest/types/types.go b/internal/manifest/types/types.go new file mode 100644 index 0000000..a6d3178 --- /dev/null +++ b/internal/manifest/types/types.go @@ -0,0 +1,86 @@ +package types + +import "time" + +type Entry struct { + ID string + Filename string + Path string + Size int64 + Cloud string + Checksum string + Exported int64 +} + +type Manifest interface { + Has(id string) bool + Add(id string, filename string, size int64, cloud string) + AddEntry(entry Entry) + Save() error + Close() + OpenAppend() error + Entries() map[string]Entry +} + +type EntryReader = Manifest + +type Format string + +const ( + FormatJSONL Format = "jsonl" + FormatSQLite Format = "sqlite" +) + +type LogEntry struct { + Timestamp int64 `json:"ts"` + Level string `json:"level"` + Event string `json:"event"` + AssetID string `json:"asset_id,omitempty"` + Album string `json:"album,omitempty"` + Filename string `json:"filename,omitempty"` + Size int64 `json:"size,omitempty"` + Cloud string `json:"cloud,omitempty"` + DurationMs int64 `json:"duration_ms,omitempty"` + Message string `json:"message,omitempty"` +} + +type LogWriter interface { + Log(entry LogEntry) + Close() +} + +type noopLogWriter struct{} + +func (noopLogWriter) Log(LogEntry) { _ = struct{}{} } +func (noopLogWriter) Close() { _ = struct{}{} } + +var NoopLogWriter LogWriter = noopLogWriter{} + +type FormatReporter interface { + ManifestFormat() Format +} + +func LogPath(dir string) string { + return dir + "/export.log" +} + +func NewEntry(id, filename, path string, size int64, cloud string) Entry { + e := Entry{ + ID: id, + Filename: filename, + Path: filename, + Size: size, + Cloud: cloud, + Exported: time.Now().Unix(), + } + if path != "" { + e.Path = path + } + return e +} + +func NewEntryWithChecksum(id, filename, path string, size int64, cloud, checksum string) Entry { + e := NewEntry(id, filename, path, size, cloud) + e.Checksum = checksum + return e +} diff --git a/internal/manifest/types/types_test.go b/internal/manifest/types/types_test.go new file mode 100644 index 0000000..ddd9919 --- /dev/null +++ b/internal/manifest/types/types_test.go @@ -0,0 +1,77 @@ +package types + +import ( + "os" + "testing" +) + +func TestNewEntryPath(t *testing.T) { + e := NewEntry("id1", "file.jpg", "", 123, "local") + if e.ID != "id1" || e.Filename != "file.jpg" || e.Path != "file.jpg" || e.Size != 123 || e.Cloud != "local" || e.Exported == 0 { + t.Fatalf("unexpected entry: %+v", e) + } + e = NewEntry("id2", "file2.jpg", "Album/file2.jpg", 456, "cloud") + if e.Path != "Album/file2.jpg" || e.Filename != "file2.jpg" || e.Size != 456 || e.Cloud != "cloud" { + t.Fatalf("unexpected entry with path: %+v", e) + } + e = NewEntryWithChecksum("id3", "file3.jpg", "Album/file3.jpg", 789, "local", "sha256:abc") + if e.Checksum != "sha256:abc" || e.Path != "Album/file3.jpg" { + t.Fatalf("unexpected checksum entry: %+v", e) + } +} + +func TestNoopLogWriter(t *testing.T) { + NoopLogWriter.Log(LogEntry{Event: "test"}) + NoopLogWriter.Close() +} + +func TestNewFileLogWriter(t *testing.T) { + dir := t.TempDir() + path := LogPath(dir) + w, err := NewFileLogWriter(path) + if err != nil { + t.Fatal(err) + } + w.Log(LogEntry{Timestamp: 1700000000, Level: "info", Event: "export_done", Filename: "photo.jpg", Size: 2048}) + w.Close() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if len(data) == 0 { + t.Error("expected log data") + } + if data[len(data)-1] != '\n' { + t.Error("expected trailing newline") + } +} + +func TestFileLogWriterClosed(t *testing.T) { + w, err := NewFileLogWriter(LogPath(t.TempDir())) + if err != nil { + t.Fatal(err) + } + w.Close() + w.Log(LogEntry{Event: "after-close"}) +} + +func TestNewFileLogWriterError(t *testing.T) { + if _, err := NewFileLogWriter("/nonexistent/dir/export.log"); err == nil { + t.Error("expected error for bad path") + } +} + +func TestFileLogWriterDoubleClose(t *testing.T) { + w, err := NewFileLogWriter(LogPath(t.TempDir())) + if err != nil { + t.Fatal(err) + } + w.Close() + w.Close() +} + +func TestLogPath(t *testing.T) { + if got := LogPath("/tmp/out"); got != "/tmp/out/export.log" { + t.Fatalf("LogPath = %q", got) + } +}