v0.10.0: ports and adapters refactor
pipeline / test (push) Has been cancelled
pipeline / build (push) Has been cancelled

- Extract shared manifest types into internal/manifest/types leaf package.
- Extract SQLite adapter into internal/manifest/sqlite.
- Extract JSONL adapter into internal/manifest/jsonl.
- Isolate modernc.org/sqlite import to sqlite/adapter.go.
- Add adapter-backed registry with manifest.Default.
- Adapter-agnostic ConvertManifest in types/.
- MemoryAdapter for in-memory manifest testing.
- CLI uses manifest.Default registry directly.
- SQLite LogWriter type assertion moved into SQLiteAdapter.
- Manifest interface includes Entries(); EntryReader removed.
- No behavior changes. 100% coverage across all 6 packages.
This commit is contained in:
Ein Anderssono
2026-06-15 08:27:38 +02:00
parent 9cd048d9f3
commit c9ac014473
28 changed files with 2061 additions and 927 deletions
+17
View File
@@ -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))
}
+209
View File
@@ -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
+169
View File
@@ -0,0 +1,169 @@
package jsonl
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"sync"
"gitea.k3s.k0.nu/tools/photocli/internal/manifest/types"
)
type Store struct {
mu sync.Mutex
entries map[string]types.Entry
path string
file *os.File
SyncFunc func() error
}
var saveHook func() error
func SetSaveHook(fn func() error) func() error {
old := saveHook
saveHook = fn
return old
}
func Path(dir string) string {
return filepath.Join(dir, "downloads.jsonl")
}
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 {
return m
}
type entryWithID struct {
ID string `json:"id"`
Filename string `json:"filename"`
Path string `json:"path,omitempty"`
Size int64 `json:"size"`
Cloud string `json:"cloud"`
Checksum string `json:"checksum,omitempty"`
Exported int64 `json:"exported"`
}
for _, line := range strings.Split(string(data), "\n") {
line = strings.TrimSpace(line)
if line == "" {
continue
}
var raw entryWithID
if json.Unmarshal([]byte(line), &raw) == nil && raw.ID != "" {
if raw.Path == "" {
raw.Path = raw.Filename
}
m.entries[raw.ID] = types.Entry{
ID: raw.ID,
Filename: raw.Filename,
Path: raw.Path,
Size: raw.Size,
Cloud: raw.Cloud,
Checksum: raw.Checksum,
Exported: raw.Exported,
}
}
}
return m
}
func (m *Store) Has(id string) bool {
m.mu.Lock()
defer m.mu.Unlock()
_, ok := m.entries[id]
return ok
}
func (m *Store) ManifestFormat() types.Format {
return types.FormatJSONL
}
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 == "" {
entry.Path = entry.Filename
}
m.entries[entry.ID] = entry
if m.file != nil {
data, _ := json.Marshal(struct {
ID string `json:"id"`
Filename string `json:"filename"`
Path string `json:"path,omitempty"`
Size int64 `json:"size"`
Cloud string `json:"cloud"`
Checksum string `json:"checksum,omitempty"`
Exported int64 `json:"exported"`
}{ID: entry.ID, Filename: entry.Filename, Path: entry.Path, Size: entry.Size, Cloud: entry.Cloud, Checksum: entry.Checksum, Exported: entry.Exported})
m.file.Write(data)
m.file.Write([]byte("\n"))
}
}
func (m *Store) Save() error {
m.mu.Lock()
defer m.mu.Unlock()
if m.SyncFunc != nil {
return m.SyncFunc()
}
if saveHook != nil {
return saveHook()
}
if m.file != nil {
return m.file.Sync()
}
return nil
}
func (m *Store) Close() {
m.mu.Lock()
defer m.mu.Unlock()
if m.file != nil {
m.file.Close()
m.file = nil
}
}
func (m *Store) OpenAppend() error {
m.mu.Lock()
defer m.mu.Unlock()
if m.file != nil {
return nil
}
if err := os.MkdirAll(filepath.Dir(m.path), 0755); err != nil {
return err
}
f, err := os.OpenFile(m.path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return err
}
m.file = f
return nil
}
func (m *Store) Entries() map[string]types.Entry {
m.mu.Lock()
defer m.mu.Unlock()
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()
}
+79
View File
@@ -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)
}
}