From fbb3345518872346a629e7ccbec81f09532765f9 Mon Sep 17 00:00:00 2001 From: Lukasz Kasprzak Date: Thu, 23 Jul 2026 13:18:34 +0200 Subject: config: TOML load + seed --- go.mod | 2 + go.sum | 2 + internal/config/config.go | 159 +++++++++++++++++++++++++++++++++++++++++ internal/config/config.toml | 30 ++++++++ internal/config/config_test.go | 57 +++++++++++++++ 5 files changed, 250 insertions(+) create mode 100644 internal/config/config.go create mode 100644 internal/config/config.toml create mode 100644 internal/config/config_test.go diff --git a/go.mod b/go.mod index 7d5a27b..9852557 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,5 @@ module github.com/lukaszkasprzak/lectio go 1.24.0 + +require github.com/pelletier/go-toml/v2 v2.4.2 diff --git a/go.sum b/go.sum index e69de29..df426f0 100644 --- a/go.sum +++ b/go.sum @@ -0,0 +1,2 @@ +github.com/pelletier/go-toml/v2 v2.4.2 h1:M2fKKbmyvI+hGId/D0W64qDBMVhJnNR10O5gIbMc//Q= +github.com/pelletier/go-toml/v2 v2.4.2/go.mod h1:2gIqNv+qfxSVS7cM2xJQKtLSTLUE9V8t9Stt+h56mCY= diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..4e11c56 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,159 @@ +// Package config loads lectio's TOML configuration file. +// +// Resolution order: LECTIO_CONFIG env var -> ~/.config/lectio/config.toml +// (auto-seeded from the embedded default on first run, honoring +// XDG_CONFIG_HOME via os.UserConfigDir()) -> built-in defaults. TOML that +// fails to parse falls back to defaults with a warning on stderr. +package config + +import ( + _ "embed" + "fmt" + "os" + "path/filepath" + + "github.com/pelletier/go-toml/v2" +) + +// seedTOML is the embedded default config.toml, written to disk on first +// run and parsed as the fallback default values. +// +//go:embed config.toml +var seedTOML []byte + +// validVersions are the five scripture versions lectio understands. +var validVersions = map[string]bool{ + "pl": true, + "wuj": true, + "vul": true, + "grb": true, + "drb": true, +} + +// Config holds lectio's user-configurable settings. +type Config struct { + SchemaVersion int `toml:"schema_version"` + Lectionary string `toml:"lectionary"` + TraditionalLang string `toml:"traditional_lang"` + Versions []string `toml:"versions"` + DefaultVersion string `toml:"default_version"` + Width int `toml:"width"` + All bool `toml:"all"` + Offline bool `toml:"offline"` + Parts map[string]map[string]bool `toml:"parts"` +} + +// PartShown reports whether a part renders: true unless explicitly set false. +func (c Config) PartShown(lectionary, partID string) bool { + if m, ok := c.Parts[lectionary]; ok { + if v, ok := m[partID]; ok { + return v + } + } + return true +} + +// Default returns lectio's built-in configuration, used when no config file +// is found and as the base that a partial config file overrides. +func Default() Config { + return Config{ + SchemaVersion: 1, + Lectionary: "new", + TraditionalLang: "pl", + Versions: []string{"pl", "wuj", "vul", "grb", "drb"}, + DefaultVersion: "pl", + Width: 0, + All: false, + Offline: false, + Parts: nil, + } +} + +// configPath resolves the config file location: LECTIO_CONFIG if set, +// otherwise the default XDG-aware path under os.UserConfigDir(). The bool +// reports whether this is the default (seedable) location. +func configPath() (path string, isDefault bool, err error) { + if p := os.Getenv("LECTIO_CONFIG"); p != "" { + return p, false, nil + } + dir, err := os.UserConfigDir() + if err != nil { + return "", false, err + } + return filepath.Join(dir, "lectio", "config.toml"), true, nil +} + +// seedIfMissing writes the embedded default config to path if nothing is +// there yet. +func seedIfMissing(path string) error { + if _, err := os.Stat(path); err == nil { + return nil + } else if !os.IsNotExist(err) { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return err + } + return os.WriteFile(path, seedTOML, 0o644) +} + +// Load reads lectio's configuration, following the package's resolution +// order (see the package doc comment). It always returns a usable Config: +// on a missing config dir or a TOML parse error it falls back to Default() +// (warning on stderr in the parse-error case). It returns a non-nil error +// only when the loaded config contains a semantically invalid value. +func Load() (Config, error) { + def := Default() + + path, isDefault, err := configPath() + if err != nil { + fmt.Fprintf(os.Stderr, "lectio: warning: could not resolve config directory: %v; using defaults\n", err) + return def, nil + } + + if isDefault { + if err := seedIfMissing(path); err != nil { + return def, err + } + } + + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return def, nil + } + return def, err + } + + cfg := def + if err := toml.Unmarshal(data, &cfg); err != nil { + fmt.Fprintf(os.Stderr, "lectio: warning: invalid config at %s: %v; using defaults\n", path, err) + return def, nil + } + + if err := validate(cfg); err != nil { + return Config{}, err + } + + return cfg, nil +} + +// validate checks the semantic constraints Load enforces beyond what TOML +// unmarshaling itself catches. +func validate(cfg Config) error { + if cfg.Lectionary != "new" && cfg.Lectionary != "traditional" { + return fmt.Errorf("config: invalid lectionary %q (must be \"new\" or \"traditional\")", cfg.Lectionary) + } + if cfg.TraditionalLang != "pl" && cfg.TraditionalLang != "en" { + return fmt.Errorf("config: invalid traditional_lang %q (must be \"pl\" or \"en\")", cfg.TraditionalLang) + } + for _, v := range cfg.Versions { + if !validVersions[v] { + return fmt.Errorf("config: invalid version %q in versions (must be one of pl, wuj, vul, grb, drb)", v) + } + } + if !validVersions[cfg.DefaultVersion] { + return fmt.Errorf("config: invalid default_version %q (must be one of pl, wuj, vul, grb, drb)", cfg.DefaultVersion) + } + return nil +} diff --git a/internal/config/config.toml b/internal/config/config.toml new file mode 100644 index 0000000..d31e2c4 --- /dev/null +++ b/internal/config/config.toml @@ -0,0 +1,30 @@ +schema_version = 1 +lectionary = "new" # "new" (niedziela.pl) or "traditional" (missalemeum, 1962) +traditional_lang = "pl" # vernacular for traditional propers: "pl" or "en" +versions = ["pl", "wuj", "vul", "grb", "drb"] # compare set + TUI cycle order +default_version = "pl" # TUI start / `lectio show` default +width = 0 # CLI wrap width; 0 = detect terminal +all = false # default to all parts (true) or just the gospel (false) +offline = false # true = never fetch; read only harvested sigla + cache + +# Which parts to show. Both tables are commented out -> every part is shown. +# Uncomment a table and set a part to false to hide it; parts you don't list +# stay shown. (Parsed as a map: a part is hidden only if explicitly false.) +# +# [parts.new] +# pierwsze_czytanie = true # 1. czytanie (1st reading) +# psalm = true # Psalm +# drugie_czytanie = true # 2. czytanie (2nd reading, on feasts) +# aklamacja = true # Aklamacja (acclamation) +# ewangelia = true # Ewangelia (gospel) +# +# [parts.traditional] +# introitus = true # Introit +# oratio = true # Collect +# lectio = true # Epistle +# graduale = true # Gradual / Alleluia / Tract +# evangelium = true # Gospel +# offertorium = true # Offertory +# secreta = true # Secret +# communio = true # Communion +# postcommunio = true # Postcommunion diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..94c33cd --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,57 @@ +package config + +import ( + "os" + "path/filepath" + "testing" +) + +func TestLoadSeeds(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + cfg, err := Load() + if err != nil { + t.Fatal(err) + } + if cfg.DefaultVersion != "pl" || cfg.Offline { + t.Errorf("defaults wrong: %+v", cfg) + } + if cfg.Lectionary != "new" { + t.Errorf("lectionary default wrong: %+v", cfg) + } + if _, err := os.Stat(filepath.Join(dir, "lectio", "config.toml")); err != nil { + t.Error("config not seeded") + } +} + +func TestLoadOverride(t *testing.T) { + dir := t.TempDir() + t.Setenv("XDG_CONFIG_HOME", dir) + os.MkdirAll(filepath.Join(dir, "lectio"), 0o755) + os.WriteFile(filepath.Join(dir, "lectio", "config.toml"), + []byte("offline = true\ndefault_version = \"wuj\"\n"), 0o644) + cfg, _ := Load() + if !cfg.Offline || cfg.DefaultVersion != "wuj" { + t.Errorf("override not applied: %+v", cfg) + } +} + +func TestPartShown(t *testing.T) { + var empty Config + if !empty.PartShown("new", "psalm") { + t.Error("nil Parts: psalm should be shown") + } + if !empty.PartShown("new", "ewangelia") { + t.Error("nil Parts: ewangelia should be shown") + } + + hidden := Config{Parts: map[string]map[string]bool{ + "new": {"psalm": false}, + }} + if hidden.PartShown("new", "psalm") { + t.Error("psalm explicitly false should be hidden") + } + if !hidden.PartShown("new", "ewangelia") { + t.Error("ewangelia not mentioned should still be shown") + } +} -- cgit v1.3