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
+39
View File
@@ -0,0 +1,39 @@
package types
import (
"encoding/json"
"os"
"sync"
)
type fileLogWriter struct {
mu sync.Mutex
f *os.File
}
func NewFileLogWriter(path string) (LogWriter, error) {
f, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0644)
if err != nil {
return nil, err
}
return &fileLogWriter{f: f}, nil
}
func (w *fileLogWriter) Log(e LogEntry) {
data, _ := json.Marshal(e)
w.mu.Lock()
defer w.mu.Unlock()
if w.f != nil {
w.f.Write(data)
w.f.Write([]byte("\n"))
}
}
func (w *fileLogWriter) Close() {
w.mu.Lock()
defer w.mu.Unlock()
if w.f != nil {
w.f.Close()
w.f = nil
}
}
+117
View File
@@ -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
}
+266
View File
@@ -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
}
+86
View File
@@ -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
}
+77
View File
@@ -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)
}
}