From b2cf337e0a4182dba6d0cbe18c0606e607d143c7 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Tue, 3 Mar 2026 22:32:41 +0200 Subject: Task 354: add worktime DB reader/writer --- internal/worktime/db.go | 168 ++++++++++++++++++++++++++++++++++++++++ internal/worktime/db_test.go | 180 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 348 insertions(+) create mode 100644 internal/worktime/db.go create mode 100644 internal/worktime/db_test.go (limited to 'internal') diff --git a/internal/worktime/db.go b/internal/worktime/db.go new file mode 100644 index 0000000..ad87c4d --- /dev/null +++ b/internal/worktime/db.go @@ -0,0 +1,168 @@ +package worktime + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "sort" + "strings" +) + +const dbFilePattern = "db.*.json" + +// Entry is a single worktime event in a host database. +type Entry struct { + Action string `json:"action"` + What string `json:"what"` + Epoch int64 `json:"epoch"` + Source string `json:"source"` + Human string `json:"human"` + Value int64 `json:"value,omitempty"` + Descr string `json:"descr,omitempty"` +} + +// Database is the on-disk JSON structure used by worktime. +type Database struct { + Entries map[string][]Entry `json:"entries"` +} + +// LoadAll reads all db.*.json files from dbDir, merges entries, and sorts by epoch. +func LoadAll(dbDir string) ([]Entry, error) { + if strings.TrimSpace(dbDir) == "" { + return nil, errors.New("db directory must not be empty") + } + + dbFiles, err := filepath.Glob(filepath.Join(dbDir, dbFilePattern)) + if err != nil { + return nil, fmt.Errorf("glob databases in %q: %w", dbDir, err) + } + + entries := make([]Entry, 0) + for _, dbFile := range dbFiles { + db, err := loadDatabaseFile(dbFile) + if err != nil { + return nil, err + } + for _, hostEntries := range db.Entries { + entries = append(entries, hostEntries...) + } + } + + sortEntries(entries) + return entries, nil +} + +// LoadHost reads one host database from dbDir. Missing files return an empty host section. +func LoadHost(dbDir, hostname string) (Database, error) { + host, err := normalizeHostname(hostname) + if err != nil { + return Database{}, err + } + if strings.TrimSpace(dbDir) == "" { + return Database{}, errors.New("db directory must not be empty") + } + + dbFile := filepath.Join(dbDir, dbFileName(host)) + db, err := loadDatabaseFile(dbFile) + if err != nil { + if os.IsNotExist(err) { + return newHostDatabase(host), nil + } + return Database{}, err + } + + if _, ok := db.Entries[host]; !ok { + db.Entries[host] = []Entry{} + } + sortEntries(db.Entries[host]) + return db, nil +} + +// SaveHost writes one host database to dbDir as db..json. +func SaveHost(dbDir, hostname string, db Database) error { + host, err := normalizeHostname(hostname) + if err != nil { + return err + } + if strings.TrimSpace(dbDir) == "" { + return errors.New("db directory must not be empty") + } + + if db.Entries == nil { + db.Entries = map[string][]Entry{} + } + if _, ok := db.Entries[host]; !ok { + db.Entries[host] = []Entry{} + } + sortEntries(db.Entries[host]) + + data, err := json.MarshalIndent(db, "", " ") + if err != nil { + return fmt.Errorf("encode database for host %q: %w", host, err) + } + data = append(data, '\n') + + if err := os.MkdirAll(dbDir, 0o755); err != nil { + return fmt.Errorf("create db directory %q: %w", dbDir, err) + } + + dbFile := filepath.Join(dbDir, dbFileName(host)) + if err := os.WriteFile(dbFile, data, 0o644); err != nil { + return fmt.Errorf("write db file %q: %w", dbFile, err) + } + + return nil +} + +func loadDatabaseFile(dbFile string) (Database, error) { + var db Database + + data, err := os.ReadFile(dbFile) + if err != nil { + return db, err + } + + if err := json.Unmarshal(data, &db); err != nil { + return db, fmt.Errorf("parse db file %q: %w", dbFile, err) + } + + if db.Entries == nil { + db.Entries = map[string][]Entry{} + } + + return db, nil +} + +func sortEntries(entries []Entry) { + sort.SliceStable(entries, func(i, j int) bool { + if entries[i].Epoch != entries[j].Epoch { + return entries[i].Epoch < entries[j].Epoch + } + if entries[i].Source != entries[j].Source { + return entries[i].Source < entries[j].Source + } + return entries[i].Action < entries[j].Action + }) +} + +func normalizeHostname(hostname string) (string, error) { + host := strings.TrimSpace(hostname) + if host == "" { + return "", errors.New("hostname must not be empty") + } + return host, nil +} + +func dbFileName(hostname string) string { + return "db." + hostname + ".json" +} + +func newHostDatabase(hostname string) Database { + return Database{ + Entries: map[string][]Entry{ + hostname: {}, + }, + } +} diff --git a/internal/worktime/db_test.go b/internal/worktime/db_test.go new file mode 100644 index 0000000..d011a9e --- /dev/null +++ b/internal/worktime/db_test.go @@ -0,0 +1,180 @@ +package worktime + +import ( + "os" + "path/filepath" + "strings" + "testing" +) + +func TestLoadHostMissingFileReturnsEmptyDatabase(t *testing.T) { + dbDir := t.TempDir() + + db, err := LoadHost(dbDir, "host-a") + if err != nil { + t.Fatalf("LoadHost() error = %v", err) + } + + hostEntries, ok := db.Entries["host-a"] + if !ok { + t.Fatal("LoadHost() missing host section") + } + if len(hostEntries) != 0 { + t.Fatalf("LoadHost() entries len = %d, want 0", len(hostEntries)) + } +} + +func TestLoadHostRejectsEmptyHostname(t *testing.T) { + _, err := LoadHost(t.TempDir(), "") + if err == nil { + t.Fatal("LoadHost() error = nil, want error") + } +} + +func TestSaveHostRejectsEmptyHostname(t *testing.T) { + err := SaveHost(t.TempDir(), " ", Database{}) + if err == nil { + t.Fatal("SaveHost() error = nil, want error") + } +} + +func TestSaveHostAndLoadHostRoundTrip(t *testing.T) { + dbDir := filepath.Join(t.TempDir(), "nested", "db") + host := "workstation" + + input := Database{ + Entries: map[string][]Entry{ + host: { + { + Action: "add", + What: "work", + Epoch: 20, + Source: host, + Human: "Tue 01.01.2026 10:00:00", + Value: 1800, + Descr: "later", + }, + { + Action: "login", + What: "work", + Epoch: 10, + Source: host, + Human: "Tue 01.01.2026 09:00:00", + }, + }, + }, + } + + if err := SaveHost(dbDir, host, input); err != nil { + t.Fatalf("SaveHost() error = %v", err) + } + + dbFile := filepath.Join(dbDir, "db."+host+".json") + if _, err := os.Stat(dbFile); err != nil { + t.Fatalf("db file not created: %v", err) + } + + output, err := LoadHost(dbDir, host) + if err != nil { + t.Fatalf("LoadHost() error = %v", err) + } + + hostEntries := output.Entries[host] + if len(hostEntries) != 2 { + t.Fatalf("entries len = %d, want 2", len(hostEntries)) + } + + if hostEntries[0].Epoch != 10 || hostEntries[1].Epoch != 20 { + t.Fatalf("entries not sorted by epoch: %+v", hostEntries) + } +} + +func TestLoadAllMergesAndSortsEntries(t *testing.T) { + dbDir := t.TempDir() + + dbA := Database{ + Entries: map[string][]Entry{ + "host-a": { + {Action: "add", What: "work", Epoch: 30, Source: "host-a", Human: "h3", Value: 60}, + {Action: "login", What: "work", Epoch: 10, Source: "host-a", Human: "h1"}, + }, + }, + } + dbB := Database{ + Entries: map[string][]Entry{ + "host-b": { + {Action: "logout", What: "work", Epoch: 20, Source: "host-b", Human: "h2"}, + }, + }, + } + + if err := SaveHost(dbDir, "host-a", dbA); err != nil { + t.Fatalf("SaveHost(host-a) error = %v", err) + } + if err := SaveHost(dbDir, "host-b", dbB); err != nil { + t.Fatalf("SaveHost(host-b) error = %v", err) + } + + entries, err := LoadAll(dbDir) + if err != nil { + t.Fatalf("LoadAll() error = %v", err) + } + + if len(entries) != 3 { + t.Fatalf("entries len = %d, want 3", len(entries)) + } + + if entries[0].Epoch != 10 || entries[1].Epoch != 20 || entries[2].Epoch != 30 { + t.Fatalf("entries not merged/sorted: %+v", entries) + } +} + +func TestLoadAllOnMissingDirectoryReturnsEmptySlice(t *testing.T) { + dbDir := filepath.Join(t.TempDir(), "does-not-exist") + + entries, err := LoadAll(dbDir) + if err != nil { + t.Fatalf("LoadAll() error = %v", err) + } + + if len(entries) != 0 { + t.Fatalf("entries len = %d, want 0", len(entries)) + } +} + +func TestLoadHostInvalidJSON(t *testing.T) { + dbDir := t.TempDir() + badFile := filepath.Join(dbDir, "db.host-a.json") + if err := os.WriteFile(badFile, []byte(`{"entries":`), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + _, err := LoadHost(dbDir, "host-a") + if err == nil { + t.Fatal("LoadHost() error = nil, want parse error") + } + + if !strings.Contains(err.Error(), "parse db file") { + t.Fatalf("LoadHost() error = %v, want parse db file context", err) + } +} + +func TestLoadAllInvalidJSON(t *testing.T) { + dbDir := t.TempDir() + badFile := filepath.Join(dbDir, "db.host-a.json") + if err := os.WriteFile(badFile, []byte(`{"entries":`), 0o644); err != nil { + t.Fatalf("WriteFile() error = %v", err) + } + + _, err := LoadAll(dbDir) + if err == nil { + t.Fatal("LoadAll() error = nil, want parse error") + } +} + +func TestLoadAllRejectsEmptyDirectory(t *testing.T) { + _, err := LoadAll("") + if err == nil { + t.Fatal("LoadAll() error = nil, want error") + } +} -- cgit v1.2.3