package store import ( "bytes" "encoding/json" "errors" "fmt" "os" "path/filepath" "sync" "git.casaderoll.de/michael/urbm/internal/model" ) type Store struct { dir string mu sync.RWMutex } func New(dir string) *Store { return &Store{dir: dir} } func (s *Store) Init() error { if err := os.MkdirAll(s.dir, 0700); err != nil { return err } if _, err := os.Stat(s.configPath()); errors.Is(err, os.ErrNotExist) { return s.SaveConfig(model.DefaultConfig()) } return nil } func (s *Store) LoadConfig() (model.Config, error) { s.mu.RLock() defer s.mu.RUnlock() var c model.Config if err := readJSON(s.configPath(), &c); err != nil { return c, err } c = model.NormalizeConfig(c) return c, model.ValidateConfig(c) } func (s *Store) SaveConfig(c model.Config) error { c = model.NormalizeConfig(c) if err := model.ValidateConfig(c); err != nil { return err } s.mu.Lock() defer s.mu.Unlock() return writeJSONAtomic(s.configPath(), c, 0600) } func (s *Store) LoadRuns() ([]model.Run, error) { s.mu.RLock() defer s.mu.RUnlock() var runs []model.Run if err := readJSON(s.runsPath(), &runs); errors.Is(err, os.ErrNotExist) { return []model.Run{}, nil } else if err != nil { return nil, err } return runs, nil } func (s *Store) SaveRuns(runs []model.Run) error { s.mu.Lock() defer s.mu.Unlock() if len(runs) > 500 { runs = runs[len(runs)-500:] } return writeJSONAtomic(s.runsPath(), runs, 0600) } func (s *Store) LoadBackupMetrics() ([]model.BackupMetric, bool, error) { s.mu.RLock() defer s.mu.RUnlock() path := s.backupMetricsPath() b, err := os.ReadFile(path) legacySource := false if errors.Is(err, os.ErrNotExist) { path = s.legacyBackupMetricsPath() b, err = os.ReadFile(path) legacySource = true if errors.Is(err, os.ErrNotExist) { return []model.BackupMetric{}, false, nil } } if err != nil { return nil, false, err } b = bytes.TrimSpace(b) if len(b) == 0 { return []model.BackupMetric{}, false, nil } if b[0] == '[' { var metrics []model.BackupMetric if err := json.Unmarshal(b, &metrics); err != nil { return nil, true, fmt.Errorf("decode %s: %w", path, err) } return metrics, true, nil } lines := bytes.Split(b, []byte{'\n'}) metrics := make([]model.BackupMetric, 0, len(lines)) for index, line := range lines { line = bytes.TrimSpace(line) if len(line) == 0 { continue } var metric model.BackupMetric if err := json.Unmarshal(line, &metric); err != nil { return nil, legacySource, fmt.Errorf("decode %s line %d: %w", path, index+1, err) } metrics = append(metrics, metric) } return metrics, legacySource, nil } func (s *Store) SaveBackupMetrics(metrics []model.BackupMetric) error { s.mu.Lock() defer s.mu.Unlock() if len(metrics) > 5000 { metrics = metrics[len(metrics)-5000:] } var data bytes.Buffer encoder := json.NewEncoder(&data) for _, metric := range metrics { if err := encoder.Encode(metric); err != nil { return err } } return writeBytesAtomic(s.backupMetricsPath(), data.Bytes(), 0600) } func (s *Store) AppendBackupMetric(metric model.BackupMetric) error { s.mu.Lock() defer s.mu.Unlock() b, err := json.Marshal(metric) if err != nil { return err } b = append(b, '\n') file, err := os.OpenFile(s.backupMetricsPath(), os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0600) if err != nil { return err } if _, err := file.Write(b); err != nil { file.Close() return err } if err := file.Sync(); err != nil { file.Close() return err } return file.Close() } func (s *Store) configPath() string { return filepath.Join(s.dir, "config.json") } func (s *Store) runsPath() string { return filepath.Join(s.dir, "runs.json") } func (s *Store) backupMetricsPath() string { return filepath.Join(s.dir, "backup-metrics.jsonl") } func (s *Store) legacyBackupMetricsPath() string { return filepath.Join(s.dir, "backup-metrics.json") } func readJSON(path string, target any) error { b, err := os.ReadFile(path) if err != nil { return err } if err := json.Unmarshal(b, target); err != nil { return fmt.Errorf("decode %s: %w", path, err) } return nil } func writeJSONAtomic(path string, value any, mode os.FileMode) error { b, err := json.MarshalIndent(value, "", " ") if err != nil { return err } b = append(b, '\n') return writeBytesAtomic(path, b, mode) } func writeBytesAtomic(path string, b []byte, mode os.FileMode) error { tmp, err := os.CreateTemp(filepath.Dir(path), ".urbm-*") if err != nil { return err } tmpName := tmp.Name() defer os.Remove(tmpName) if err := tmp.Chmod(mode); err != nil { tmp.Close() return err } if _, err := tmp.Write(b); err != nil { tmp.Close() return err } if err := tmp.Sync(); err != nil { tmp.Close() return err } if err := tmp.Close(); err != nil { return err } return os.Rename(tmpName, path) }