summaryrefslogtreecommitdiff
path: root/internal
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-03-03 22:32:41 +0200
committerPaul Buetow <paul@buetow.org>2026-03-03 22:32:41 +0200
commitb2cf337e0a4182dba6d0cbe18c0606e607d143c7 (patch)
treeedb0f4106049d4c97a7d06fbde551d09ab3ae48a /internal
parente6e4f9584d1531ca5522ba816aa0adc2af342e3f (diff)
Task 354: add worktime DB reader/writer
Diffstat (limited to 'internal')
-rw-r--r--internal/worktime/db.go168
-rw-r--r--internal/worktime/db_test.go180
2 files changed, 348 insertions, 0 deletions
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.<hostname>.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")
+ }
+}