Files
weatherreporter/internal/state/filesystem.go

286 lines
8.2 KiB
Go

package state
import (
"context"
"encoding/json"
"fmt"
"os"
"path/filepath"
"sort"
"strings"
"gitea.maximumdirect.net/eric/weatherreporter/internal/adapters/scriptorium"
"gitea.maximumdirect.net/eric/weatherreporter/internal/briefing"
"gitea.maximumdirect.net/eric/weatherreporter/internal/config"
"gitea.maximumdirect.net/eric/weatherreporter/internal/promptinput"
"gitea.maximumdirect.net/eric/weatherreporter/internal/report"
)
type FilesystemStore struct {
root string
snapshotsDir string
reportsDir string
dataPackagesDir string
preflightDir string
}
type ArtifactPaths struct {
Briefing string `json:"briefing"`
Metadata string `json:"metadata"`
DataPackage string `json:"dataPackage"`
Preflight string `json:"preflight"`
RenderedReport string `json:"renderedReport,omitempty"`
}
func NewFilesystemStore(cfg config.WorkspaceConfig) (*FilesystemStore, error) {
if cfg.Root == "" {
return nil, fmt.Errorf("workspace root is required")
}
for name, value := range map[string]string{
"snapshots_dir": cfg.SnapshotsDir,
"reports_dir": cfg.ReportsDir,
"data_packages_dir": cfg.DataPackagesDir,
"preflight_dir": cfg.PreflightDir,
} {
if err := validateRelativeDir(name, value); err != nil {
return nil, err
}
}
return &FilesystemStore{
root: filepath.Clean(cfg.Root),
snapshotsDir: filepath.Clean(cfg.SnapshotsDir),
reportsDir: filepath.Clean(cfg.ReportsDir),
dataPackagesDir: filepath.Clean(cfg.DataPackagesDir),
preflightDir: filepath.Clean(cfg.PreflightDir),
}, nil
}
func (s *FilesystemStore) Paths(resolved report.Resolved) (ArtifactPaths, error) {
if s == nil {
return ArtifactPaths{}, fmt.Errorf("state store is required")
}
metadata := resolved.Metadata()
if metadata.RunID == "" {
return ArtifactPaths{}, fmt.Errorf("run id is required")
}
group, err := reportGroup(resolved.Definition.ID)
if err != nil {
return ArtifactPaths{}, err
}
validDate := resolved.ValidPeriod.Start.Format("2006-01-02")
filenameBase := metadata.RunID
return ArtifactPaths{
Briefing: s.join(s.snapshotsDir, group, validDate, filenameBase+".briefing.json"),
Metadata: s.join(s.snapshotsDir, group, validDate, filenameBase+".metadata.json"),
DataPackage: s.join(s.dataPackagesDir, group, validDate, filenameBase+".data_package.json"),
Preflight: s.join(s.preflightDir, group, validDate, filenameBase+".render.json"),
RenderedReport: s.join(s.reportsDir, group, filenameBase+".md"),
}, nil
}
func (s *FilesystemStore) SaveBriefing(_ context.Context, resolved report.Resolved, pkg briefing.Package) (string, error) {
paths, err := s.Paths(resolved)
if err != nil {
return "", err
}
if err := writeJSONAtomic(paths.Briefing, pkg); err != nil {
return "", err
}
return paths.Briefing, nil
}
func (s *FilesystemStore) SaveDataPackage(_ context.Context, resolved report.Resolved, pkg promptinput.Package) (string, error) {
paths, err := s.Paths(resolved)
if err != nil {
return "", err
}
if err := promptinput.Validate(pkg); err != nil {
return "", err
}
if err := writeJSONAtomic(paths.DataPackage, pkg); err != nil {
return "", err
}
return paths.DataPackage, nil
}
func (s *FilesystemStore) SavePreflight(_ context.Context, resolved report.Resolved, result *scriptorium.RenderResult) (string, error) {
if result == nil {
return "", fmt.Errorf("render result is required")
}
paths, err := s.Paths(resolved)
if err != nil {
return "", err
}
if err := writeJSONAtomic(paths.Preflight, result); err != nil {
return "", err
}
return paths.Preflight, nil
}
func (s *FilesystemStore) SaveMetadata(_ context.Context, metadata Metadata) (string, error) {
if metadata.RunID == "" {
return "", fmt.Errorf("metadata run id is required")
}
if metadata.BriefingPath == "" {
return "", fmt.Errorf("metadata briefing path is required")
}
if metadata.DataPackagePath == "" {
return "", fmt.Errorf("metadata data package path is required")
}
if metadata.PreflightPath == "" {
return "", fmt.Errorf("metadata preflight path is required")
}
path := metadataPathFromStored(metadata)
if path == "" {
return "", fmt.Errorf("metadata path cannot be resolved")
}
if err := writeJSONAtomic(path, metadata); err != nil {
return "", err
}
return path, nil
}
func (s *FilesystemStore) FindPriorDailySnapshot(_ context.Context, resolved report.Resolved) (*PriorSnapshot, error) {
if resolved.Definition.ComparisonStrategy != report.CompareSameValidDate {
return nil, nil
}
group, err := reportGroup(resolved.Definition.ID)
if err != nil {
return nil, err
}
if group != "daily" {
return nil, nil
}
paths, err := s.Paths(resolved)
if err != nil {
return nil, err
}
dir := filepath.Dir(paths.Metadata)
entries, err := os.ReadDir(dir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil
}
return nil, fmt.Errorf("read snapshot metadata directory %q: %w", dir, err)
}
var candidates []Metadata
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".metadata.json") {
continue
}
path := filepath.Join(dir, entry.Name())
var metadata Metadata
if err := readJSON(path, &metadata); err != nil {
return nil, err
}
if metadata.RunID == resolved.Metadata().RunID {
continue
}
if metadata.ReportID != report.DailyToday && metadata.ReportID != report.DailyTomorrow {
continue
}
if !sameValidDate(metadata, resolved) {
continue
}
if !metadata.GeneratedAt.Before(resolved.GeneratedAt) {
continue
}
candidates = append(candidates, metadata)
}
if len(candidates) == 0 {
return nil, nil
}
sort.Slice(candidates, func(i, j int) bool {
return candidates[i].GeneratedAt.After(candidates[j].GeneratedAt)
})
return &PriorSnapshot{
Metadata: candidates[0],
BriefingPath: candidates[0].BriefingPath,
}, nil
}
func (s *FilesystemStore) join(parts ...string) string {
all := append([]string{s.root}, parts...)
return filepath.Join(all...)
}
func validateRelativeDir(name string, value string) error {
if value == "" {
return fmt.Errorf("%s is required", name)
}
if filepath.IsAbs(value) {
return fmt.Errorf("%s must be relative to workspace root", name)
}
cleaned := filepath.Clean(value)
if cleaned == "." || cleaned == ".." || strings.HasPrefix(cleaned, ".."+string(filepath.Separator)) {
return fmt.Errorf("%s must stay within workspace root", name)
}
return nil
}
func reportGroup(id report.ID) (string, error) {
switch id {
case report.DailyToday, report.DailyTomorrow:
return "daily", nil
case report.ThreeDay:
return "three-day", nil
case report.Weekend:
return "weekend", nil
case report.Storm:
return "storm", nil
default:
return "", fmt.Errorf("unknown report %q", id)
}
}
func writeJSONAtomic(path string, value any) error {
data, err := json.MarshalIndent(value, "", " ")
if err != nil {
return fmt.Errorf("marshal %q: %w", path, err)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return fmt.Errorf("create directory %q: %w", filepath.Dir(path), err)
}
tmp, err := os.CreateTemp(filepath.Dir(path), "."+filepath.Base(path)+".*.tmp")
if err != nil {
return fmt.Errorf("create temporary file for %q: %w", path, err)
}
tmpName := tmp.Name()
defer os.Remove(tmpName)
if _, err := tmp.Write(data); err != nil {
tmp.Close()
return fmt.Errorf("write temporary file for %q: %w", path, err)
}
if err := tmp.Close(); err != nil {
return fmt.Errorf("close temporary file for %q: %w", path, err)
}
if err := os.Rename(tmpName, path); err != nil {
return fmt.Errorf("save %q: %w", path, err)
}
return nil
}
func readJSON(path string, target any) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read %q: %w", path, err)
}
if err := json.Unmarshal(data, target); err != nil {
return fmt.Errorf("decode %q: %w", path, err)
}
return nil
}
func metadataPathFromStored(metadata Metadata) string {
if metadata.BriefingPath == "" {
return ""
}
filename := metadata.RunID + ".metadata.json"
return filepath.Join(filepath.Dir(metadata.BriefingPath), filename)
}
func sameValidDate(metadata Metadata, resolved report.Resolved) bool {
return metadata.ValidPeriod.Start.Format("2006-01-02") == resolved.ValidPeriod.Start.Format("2006-01-02")
}