Switch config from YAML to TOML
This commit is contained in:
@@ -6,70 +6,70 @@ import (
|
||||
"os"
|
||||
"regexp"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
"github.com/BurntSushi/toml"
|
||||
)
|
||||
|
||||
// envBracketRe matches only ${VAR} style env references, not bare $VAR.
|
||||
var envBracketRe = regexp.MustCompile(`\$\{([^}]+)\}`)
|
||||
|
||||
type Config struct {
|
||||
Matrix MatrixConfig `yaml:"matrix"`
|
||||
Posting PostingConfig `yaml:"posting"`
|
||||
Storage StorageConfig `yaml:"storage"`
|
||||
Web WebConfig `yaml:"web"`
|
||||
Sources []SourceConfig `yaml:"sources"`
|
||||
Matrix MatrixConfig `toml:"matrix"`
|
||||
Posting PostingConfig `toml:"posting"`
|
||||
Storage StorageConfig `toml:"storage"`
|
||||
Web WebConfig `toml:"web"`
|
||||
Sources []SourceConfig `toml:"sources"`
|
||||
}
|
||||
|
||||
// WebConfig controls the read-only HTTP interface (news.parodia.dev style).
|
||||
type WebConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
ListenAddr string `yaml:"listen_addr"` // e.g. ":8080" or "127.0.0.1:8080"
|
||||
SiteTitle string `yaml:"site_title"` // display name in the header
|
||||
BaseURL string `yaml:"base_url"` // public URL (used in metadata only)
|
||||
Enabled bool `toml:"enabled"`
|
||||
ListenAddr string `toml:"listen_addr"` // e.g. ":8080" or "127.0.0.1:8080"
|
||||
SiteTitle string `toml:"site_title"` // display name in the header
|
||||
BaseURL string `toml:"base_url"` // public URL (used in metadata only)
|
||||
}
|
||||
|
||||
type MatrixConfig struct {
|
||||
Homeserver string `yaml:"homeserver"`
|
||||
UserID string `yaml:"user_id"`
|
||||
Password string `yaml:"password"`
|
||||
PickleKey string `yaml:"pickle_key"`
|
||||
DisplayName string `yaml:"display_name"`
|
||||
DataDir string `yaml:"data_dir"`
|
||||
AdminRoom string `yaml:"admin_room"`
|
||||
Channels map[string]string `yaml:"channels"`
|
||||
Homeserver string `toml:"homeserver"`
|
||||
UserID string `toml:"user_id"`
|
||||
Password string `toml:"password"`
|
||||
PickleKey string `toml:"pickle_key"`
|
||||
DisplayName string `toml:"display_name"`
|
||||
DataDir string `toml:"data_dir"`
|
||||
AdminRoom string `toml:"admin_room"`
|
||||
Channels map[string]string `toml:"channels"`
|
||||
}
|
||||
|
||||
type PostingConfig struct {
|
||||
MinIntervalSeconds int `yaml:"min_interval_seconds"`
|
||||
BurstCapCount int `yaml:"burst_cap_count"`
|
||||
BurstCapWindowSeconds int `yaml:"burst_cap_window_seconds"`
|
||||
DedupCooldownHours int `yaml:"dedup_cooldown_hours"`
|
||||
MinIntervalSeconds int `toml:"min_interval_seconds"`
|
||||
BurstCapCount int `toml:"burst_cap_count"`
|
||||
BurstCapWindowSeconds int `toml:"burst_cap_window_seconds"`
|
||||
DedupCooldownHours int `toml:"dedup_cooldown_hours"`
|
||||
// DailyCapTotal is the hard global cap on posts across ALL channels in a
|
||||
// rolling 24-hour window. 0 disables the cap.
|
||||
DailyCapTotal int `yaml:"daily_cap_total"`
|
||||
RoundRobin RoundRobinConfig `yaml:"round_robin"`
|
||||
DailyCapTotal int `toml:"daily_cap_total"`
|
||||
RoundRobin RoundRobinConfig `toml:"round_robin"`
|
||||
}
|
||||
|
||||
// RoundRobinConfig switches Pete from immediate-on-classify posting to a
|
||||
// paced rotation: one story per IntervalHours, picking the next channel in
|
||||
// rotation order that has a postable story (skip-and-advance, newest-first).
|
||||
type RoundRobinConfig struct {
|
||||
Enabled bool `yaml:"enabled"`
|
||||
IntervalHours int `yaml:"interval_hours"`
|
||||
Enabled bool `toml:"enabled"`
|
||||
IntervalHours int `toml:"interval_hours"`
|
||||
}
|
||||
|
||||
type StorageConfig struct {
|
||||
DBPath string `yaml:"db_path"`
|
||||
RecentWindowHours int `yaml:"recent_window_hours"`
|
||||
DBPath string `toml:"db_path"`
|
||||
RecentWindowHours int `toml:"recent_window_hours"`
|
||||
}
|
||||
|
||||
type SourceConfig struct {
|
||||
Name string `yaml:"name"`
|
||||
FeedURL string `yaml:"feed_url"`
|
||||
Tier int `yaml:"tier"`
|
||||
PollIntervalMinutes int `yaml:"poll_interval_minutes"`
|
||||
DirectRoute string `yaml:"direct_route"`
|
||||
Enabled bool `yaml:"enabled"`
|
||||
Name string `toml:"name"`
|
||||
FeedURL string `toml:"feed_url"`
|
||||
Tier int `toml:"tier"`
|
||||
PollIntervalMinutes int `toml:"poll_interval_minutes"`
|
||||
DirectRoute string `toml:"direct_route"`
|
||||
Enabled bool `toml:"enabled"`
|
||||
}
|
||||
|
||||
func Load(path string) (*Config, error) {
|
||||
@@ -90,7 +90,7 @@ func Load(path string) (*Config, error) {
|
||||
})
|
||||
|
||||
var cfg Config
|
||||
if err := yaml.Unmarshal([]byte(expanded), &cfg); err != nil {
|
||||
if _, err := toml.Decode(expanded, &cfg); err != nil {
|
||||
return nil, fmt.Errorf("parse config: %w", err)
|
||||
}
|
||||
|
||||
|
||||
@@ -8,28 +8,29 @@ import (
|
||||
|
||||
// validMatrix is a reusable matrix config block for tests.
|
||||
const validMatrix = `
|
||||
matrix:
|
||||
homeserver: "https://matrix.example.org"
|
||||
user_id: "@pete:example.org"
|
||||
password: "testpass"
|
||||
channels:
|
||||
tech: "!tech:example.org"
|
||||
politics: "!politics:example.org"
|
||||
[matrix]
|
||||
homeserver = "https://matrix.example.org"
|
||||
user_id = "@pete:example.org"
|
||||
password = "testpass"
|
||||
[matrix.channels]
|
||||
tech = "!tech:example.org"
|
||||
politics = "!politics:example.org"
|
||||
`
|
||||
|
||||
func TestLoadValid(t *testing.T) {
|
||||
yaml := validMatrix + `
|
||||
storage:
|
||||
db_path: "/tmp/test.db"
|
||||
sources:
|
||||
- name: "Test Source"
|
||||
feed_url: "https://example.com/rss"
|
||||
tier: 1
|
||||
poll_interval_minutes: 20
|
||||
direct_route: "tech"
|
||||
enabled: true
|
||||
toml := validMatrix + `
|
||||
[storage]
|
||||
db_path = "/tmp/test.db"
|
||||
|
||||
[[sources]]
|
||||
name = "Test Source"
|
||||
feed_url = "https://example.com/rss"
|
||||
tier = 1
|
||||
poll_interval_minutes = 20
|
||||
direct_route = "tech"
|
||||
enabled = true
|
||||
`
|
||||
cfg := loadFromString(t, yaml)
|
||||
cfg := loadFromString(t, toml)
|
||||
|
||||
if cfg.Matrix.Homeserver != "https://matrix.example.org" {
|
||||
t.Errorf("homeserver = %q", cfg.Matrix.Homeserver)
|
||||
@@ -51,18 +52,18 @@ sources:
|
||||
func TestEnvVarExpansion(t *testing.T) {
|
||||
t.Setenv("TEST_PETE_PASS", "secret_pass_123")
|
||||
|
||||
yaml := `
|
||||
matrix:
|
||||
homeserver: "https://matrix.example.org"
|
||||
user_id: "@pete:example.org"
|
||||
password: "${TEST_PETE_PASS}"
|
||||
channels:
|
||||
tech: "!tech:example.org"
|
||||
storage:
|
||||
db_path: "/tmp/test.db"
|
||||
sources: []
|
||||
toml := `
|
||||
[matrix]
|
||||
homeserver = "https://matrix.example.org"
|
||||
user_id = "@pete:example.org"
|
||||
password = "${TEST_PETE_PASS}"
|
||||
[matrix.channels]
|
||||
tech = "!tech:example.org"
|
||||
|
||||
[storage]
|
||||
db_path = "/tmp/test.db"
|
||||
`
|
||||
cfg := loadFromString(t, yaml)
|
||||
cfg := loadFromString(t, toml)
|
||||
|
||||
if cfg.Matrix.Password != "secret_pass_123" {
|
||||
t.Errorf("password = %q, want secret_pass_123", cfg.Matrix.Password)
|
||||
@@ -70,18 +71,19 @@ sources: []
|
||||
}
|
||||
|
||||
func TestDefaults(t *testing.T) {
|
||||
yaml := validMatrix + `
|
||||
storage:
|
||||
db_path: "/tmp/test.db"
|
||||
sources:
|
||||
- name: "S"
|
||||
feed_url: "https://example.com/rss"
|
||||
tier: 1
|
||||
poll_interval_minutes: 20
|
||||
direct_route: "tech"
|
||||
enabled: true
|
||||
toml := validMatrix + `
|
||||
[storage]
|
||||
db_path = "/tmp/test.db"
|
||||
|
||||
[[sources]]
|
||||
name = "S"
|
||||
feed_url = "https://example.com/rss"
|
||||
tier = 1
|
||||
poll_interval_minutes = 20
|
||||
direct_route = "tech"
|
||||
enabled = true
|
||||
`
|
||||
cfg := loadFromString(t, yaml)
|
||||
cfg := loadFromString(t, toml)
|
||||
|
||||
if cfg.Matrix.DataDir != "./data" {
|
||||
t.Errorf("data_dir default = %q, want ./data", cfg.Matrix.DataDir)
|
||||
@@ -106,109 +108,125 @@ sources:
|
||||
func TestValidationErrors(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
yaml string
|
||||
toml string
|
||||
}{
|
||||
{"missing homeserver", `
|
||||
matrix:
|
||||
user_id: "@p:h"
|
||||
password: "pw"
|
||||
channels: {tech: "!t:e"}
|
||||
storage: {db_path: "/tmp/t.db"}
|
||||
[matrix]
|
||||
user_id = "@p:h"
|
||||
password = "pw"
|
||||
[matrix.channels]
|
||||
tech = "!t:e"
|
||||
[storage]
|
||||
db_path = "/tmp/t.db"
|
||||
`},
|
||||
{"missing user_id", `
|
||||
matrix:
|
||||
homeserver: "https://h"
|
||||
password: "pw"
|
||||
channels: {tech: "!t:e"}
|
||||
storage: {db_path: "/tmp/t.db"}
|
||||
[matrix]
|
||||
homeserver = "https://h"
|
||||
password = "pw"
|
||||
[matrix.channels]
|
||||
tech = "!t:e"
|
||||
[storage]
|
||||
db_path = "/tmp/t.db"
|
||||
`},
|
||||
{"missing password", `
|
||||
matrix:
|
||||
homeserver: "https://h"
|
||||
user_id: "@p:h"
|
||||
channels: {tech: "!t:e"}
|
||||
storage: {db_path: "/tmp/t.db"}
|
||||
[matrix]
|
||||
homeserver = "https://h"
|
||||
user_id = "@p:h"
|
||||
[matrix.channels]
|
||||
tech = "!t:e"
|
||||
[storage]
|
||||
db_path = "/tmp/t.db"
|
||||
`},
|
||||
{"no channels", `
|
||||
matrix:
|
||||
homeserver: "https://h"
|
||||
user_id: "@p:h"
|
||||
password: "pw"
|
||||
storage: {db_path: "/tmp/t.db"}
|
||||
[matrix]
|
||||
homeserver = "https://h"
|
||||
user_id = "@p:h"
|
||||
password = "pw"
|
||||
[storage]
|
||||
db_path = "/tmp/t.db"
|
||||
`},
|
||||
{"missing db_path", `
|
||||
matrix:
|
||||
homeserver: "https://h"
|
||||
user_id: "@p:h"
|
||||
password: "pw"
|
||||
channels: {tech: "!t:e"}
|
||||
storage: {}
|
||||
[matrix]
|
||||
homeserver = "https://h"
|
||||
user_id = "@p:h"
|
||||
password = "pw"
|
||||
[matrix.channels]
|
||||
tech = "!t:e"
|
||||
[storage]
|
||||
`},
|
||||
{"invalid tier", `
|
||||
matrix:
|
||||
homeserver: "https://h"
|
||||
user_id: "@p:h"
|
||||
password: "pw"
|
||||
channels: {tech: "!t:e"}
|
||||
storage: {db_path: "/tmp/t.db"}
|
||||
sources:
|
||||
- name: "S"
|
||||
feed_url: "https://e.com/rss"
|
||||
tier: 5
|
||||
poll_interval_minutes: 20
|
||||
direct_route: "tech"
|
||||
enabled: true
|
||||
[matrix]
|
||||
homeserver = "https://h"
|
||||
user_id = "@p:h"
|
||||
password = "pw"
|
||||
[matrix.channels]
|
||||
tech = "!t:e"
|
||||
[storage]
|
||||
db_path = "/tmp/t.db"
|
||||
[[sources]]
|
||||
name = "S"
|
||||
feed_url = "https://e.com/rss"
|
||||
tier = 5
|
||||
poll_interval_minutes = 20
|
||||
direct_route = "tech"
|
||||
enabled = true
|
||||
`},
|
||||
{"missing source name", `
|
||||
matrix:
|
||||
homeserver: "https://h"
|
||||
user_id: "@p:h"
|
||||
password: "pw"
|
||||
channels: {tech: "!t:e"}
|
||||
storage: {db_path: "/tmp/t.db"}
|
||||
sources:
|
||||
- feed_url: "https://e.com/rss"
|
||||
tier: 1
|
||||
poll_interval_minutes: 20
|
||||
direct_route: "tech"
|
||||
enabled: true
|
||||
[matrix]
|
||||
homeserver = "https://h"
|
||||
user_id = "@p:h"
|
||||
password = "pw"
|
||||
[matrix.channels]
|
||||
tech = "!t:e"
|
||||
[storage]
|
||||
db_path = "/tmp/t.db"
|
||||
[[sources]]
|
||||
feed_url = "https://e.com/rss"
|
||||
tier = 1
|
||||
poll_interval_minutes = 20
|
||||
direct_route = "tech"
|
||||
enabled = true
|
||||
`},
|
||||
{"enabled source missing direct_route", `
|
||||
matrix:
|
||||
homeserver: "https://h"
|
||||
user_id: "@p:h"
|
||||
password: "pw"
|
||||
channels: {tech: "!t:e"}
|
||||
storage: {db_path: "/tmp/t.db"}
|
||||
sources:
|
||||
- name: "S"
|
||||
feed_url: "https://e.com/rss"
|
||||
tier: 1
|
||||
poll_interval_minutes: 20
|
||||
enabled: true
|
||||
[matrix]
|
||||
homeserver = "https://h"
|
||||
user_id = "@p:h"
|
||||
password = "pw"
|
||||
[matrix.channels]
|
||||
tech = "!t:e"
|
||||
[storage]
|
||||
db_path = "/tmp/t.db"
|
||||
[[sources]]
|
||||
name = "S"
|
||||
feed_url = "https://e.com/rss"
|
||||
tier = 1
|
||||
poll_interval_minutes = 20
|
||||
enabled = true
|
||||
`},
|
||||
{"direct_route not in channels", `
|
||||
matrix:
|
||||
homeserver: "https://h"
|
||||
user_id: "@p:h"
|
||||
password: "pw"
|
||||
channels: {tech: "!t:e"}
|
||||
storage: {db_path: "/tmp/t.db"}
|
||||
sources:
|
||||
- name: "S"
|
||||
feed_url: "https://e.com/rss"
|
||||
tier: 1
|
||||
poll_interval_minutes: 20
|
||||
direct_route: "gaming"
|
||||
enabled: true
|
||||
[matrix]
|
||||
homeserver = "https://h"
|
||||
user_id = "@p:h"
|
||||
password = "pw"
|
||||
[matrix.channels]
|
||||
tech = "!t:e"
|
||||
[storage]
|
||||
db_path = "/tmp/t.db"
|
||||
[[sources]]
|
||||
name = "S"
|
||||
feed_url = "https://e.com/rss"
|
||||
tier = 1
|
||||
poll_interval_minutes = 20
|
||||
direct_route = "gaming"
|
||||
enabled = true
|
||||
`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
os.WriteFile(path, []byte(tt.yaml), 0o644)
|
||||
path := filepath.Join(dir, "config.toml")
|
||||
os.WriteFile(path, []byte(tt.toml), 0o644)
|
||||
_, err := Load(path)
|
||||
if err == nil {
|
||||
t.Error("expected validation error, got nil")
|
||||
@@ -219,31 +237,33 @@ sources:
|
||||
|
||||
func TestDisabledSourceSkipsDirectRouteCheck(t *testing.T) {
|
||||
// A disabled source without direct_route must not fail validation.
|
||||
yaml := `
|
||||
matrix:
|
||||
homeserver: "https://h"
|
||||
user_id: "@p:h"
|
||||
password: "pw"
|
||||
channels: {tech: "!t:e"}
|
||||
storage: {db_path: "/tmp/t.db"}
|
||||
sources:
|
||||
- name: "Off"
|
||||
feed_url: "https://e.com/rss"
|
||||
tier: 1
|
||||
poll_interval_minutes: 20
|
||||
enabled: false
|
||||
toml := `
|
||||
[matrix]
|
||||
homeserver = "https://h"
|
||||
user_id = "@p:h"
|
||||
password = "pw"
|
||||
[matrix.channels]
|
||||
tech = "!t:e"
|
||||
[storage]
|
||||
db_path = "/tmp/t.db"
|
||||
[[sources]]
|
||||
name = "Off"
|
||||
feed_url = "https://e.com/rss"
|
||||
tier = 1
|
||||
poll_interval_minutes = 20
|
||||
enabled = false
|
||||
`
|
||||
cfg := loadFromString(t, yaml)
|
||||
cfg := loadFromString(t, toml)
|
||||
if cfg.Sources[0].DirectRoute != "" {
|
||||
t.Errorf("expected empty direct_route, got %q", cfg.Sources[0].DirectRoute)
|
||||
}
|
||||
}
|
||||
|
||||
func loadFromString(t *testing.T, yamlContent string) *Config {
|
||||
func loadFromString(t *testing.T, tomlContent string) *Config {
|
||||
t.Helper()
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "config.yaml")
|
||||
if err := os.WriteFile(path, []byte(yamlContent), 0o644); err != nil {
|
||||
path := filepath.Join(dir, "config.toml")
|
||||
if err := os.WriteFile(path, []byte(tomlContent), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
cfg, err := Load(path)
|
||||
|
||||
Reference in New Issue
Block a user