Files
joakim c5a963bfd9 fix: make LoadConfig read-only to prevent panic on read-only filesystems
LoadConfig() tried to create directories and write opal.yml as a side
effect of loading config. On the server (where /etc/opal is in systemd
ReadOnlyPaths), this failed, returning nil. All internal GetConfig()
callers discarded the error, passing nil to BuildUrgencyCoefficients()
which panicked on nil dereference.

Redesign the config system with layered, read-only loading:
- Defaults (always present) → YAML file (if exists) → OPAL_ env vars
- LoadConfig never writes to the filesystem or returns nil
- File creation moved to explicit InitConfig() for CLI first-run/setup
- SaveConfig uses yaml.Marshal instead of manual field-by-field Viper
  calls, eliminating the three-place maintenance burden

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-02-16 00:04:54 +01:00

401 lines
12 KiB
Go

package engine
import (
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/spf13/viper"
"go.yaml.in/yaml/v3"
)
type Config struct {
DefaultFilter string `mapstructure:"default_filter" yaml:"default_filter"`
DefaultSort string `mapstructure:"default_sort" yaml:"default_sort"`
DefaultReport string `mapstructure:"default_report" yaml:"default_report"`
ColorOutput bool `mapstructure:"color_output" yaml:"color_output"`
WeekStartDay string `mapstructure:"week_start_day" yaml:"week_start_day"`
DefaultDueTime string `mapstructure:"default_due_time" yaml:"default_due_time"`
// Urgency coefficients
UrgencyDue float64 `mapstructure:"urgency_due_coefficient" yaml:"urgency_due_coefficient"`
UrgencyPriorityH float64 `mapstructure:"urgency_priority_h_coefficient" yaml:"urgency_priority_h_coefficient"`
UrgencyPriorityM float64 `mapstructure:"urgency_priority_m_coefficient" yaml:"urgency_priority_m_coefficient"`
UrgencyPriorityD float64 `mapstructure:"urgency_priority_d_coefficient" yaml:"urgency_priority_d_coefficient"`
UrgencyPriorityL float64 `mapstructure:"urgency_priority_l_coefficient" yaml:"urgency_priority_l_coefficient"`
UrgencyActive float64 `mapstructure:"urgency_active_coefficient" yaml:"urgency_active_coefficient"`
UrgencyAge float64 `mapstructure:"urgency_age_coefficient" yaml:"urgency_age_coefficient"`
UrgencyAgeMax int `mapstructure:"urgency_age_max" yaml:"urgency_age_max"`
UrgencyTags float64 `mapstructure:"urgency_tags_coefficient" yaml:"urgency_tags_coefficient"`
UrgencyProject float64 `mapstructure:"urgency_project_coefficient" yaml:"urgency_project_coefficient"`
UrgencyWaiting float64 `mapstructure:"urgency_waiting_coefficient" yaml:"urgency_waiting_coefficient"`
UrgencyUrgentTag string `mapstructure:"urgency_urgent_tag" yaml:"urgency_urgent_tag"`
UrgencyUrgentCoeff float64 `mapstructure:"urgency_urgent_coefficient" yaml:"urgency_urgent_coefficient"`
NextLimit int `mapstructure:"next_limit" yaml:"next_limit"`
// Sync settings
SyncEnabled bool `mapstructure:"sync_enabled" yaml:"sync_enabled"`
SyncURL string `mapstructure:"sync_url" yaml:"sync_url"`
SyncAPIKey string `mapstructure:"sync_api_key" yaml:"sync_api_key"`
SyncClientID string `mapstructure:"sync_client_id" yaml:"sync_client_id"`
SyncStrategy string `mapstructure:"sync_strategy" yaml:"sync_strategy"`
SyncQueueOffline bool `mapstructure:"sync_queue_offline" yaml:"sync_queue_offline"`
}
var globalConfig *Config
// Global variables for flag/programmatic overrides
var (
configDirOverride string
dataDirOverride string
)
// SetConfigDirOverride sets the config directory override (typically from --config-dir flag)
func SetConfigDirOverride(dir string) {
configDirOverride = dir
}
// SetDataDirOverride sets the data directory override (typically from --data-dir flag)
func SetDataDirOverride(dir string) {
dataDirOverride = dir
}
// GetConfigDir returns the configuration directory path
// Resolution priority:
// 1. Flag override (via SetConfigDirOverride)
// 2. OPAL_CONFIG_DIR environment variable
// 3. XDG_CONFIG_HOME/opal
// 4. /etc/opal (with fallback to ~/.config/opal if not writable)
func GetConfigDir() (string, error) {
// Priority 1: Flag override
if configDirOverride != "" {
return configDirOverride, nil
}
// Priority 2: Environment variable
if dir := os.Getenv("OPAL_CONFIG_DIR"); dir != "" {
return dir, nil
}
// Priority 3: XDG_CONFIG_HOME
if xdgConfig := os.Getenv("XDG_CONFIG_HOME"); xdgConfig != "" {
return filepath.Join(xdgConfig, "opal"), nil
}
// Priority 4: Try /etc/opal, fallback to ~/.config/opal if not writable
etcOpal := "/etc/opal"
if isWritable(etcOpal) {
return etcOpal, nil
}
// Fallback to user config directory
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get home directory: %w", err)
}
return filepath.Join(home, ".config", "opal"), nil
}
// GetDataDir returns the data directory path
// Resolution priority:
// 1. Flag override (via SetDataDirOverride)
// 2. OPAL_DATA_DIR environment variable
// 3. XDG_DATA_HOME/opal
// 4. /var/lib/opal (with fallback to ~/.local/share/opal if not writable)
func GetDataDir() (string, error) {
// Priority 1: Flag override
if dataDirOverride != "" {
return dataDirOverride, nil
}
// Priority 2: Environment variable
if dir := os.Getenv("OPAL_DATA_DIR"); dir != "" {
return dir, nil
}
// Priority 3: XDG_DATA_HOME
if xdgData := os.Getenv("XDG_DATA_HOME"); xdgData != "" {
return filepath.Join(xdgData, "opal"), nil
}
// Priority 4: Try /var/lib/opal, fallback to ~/.local/share/opal if not writable
varLibOpal := "/var/lib/opal"
if isWritable(varLibOpal) {
return varLibOpal, nil
}
// Fallback to user data directory
home, err := os.UserHomeDir()
if err != nil {
return "", fmt.Errorf("failed to get home directory: %w", err)
}
return filepath.Join(home, ".local", "share", "opal"), nil
}
// isWritable checks if a directory exists and is writable, or if parent exists and is writable
func isWritable(path string) bool {
// Check if path exists
info, err := os.Stat(path)
if err == nil {
// Path exists, check if it's a directory and writable
if !info.IsDir() {
return false
}
// Test write permission by trying to create a temp file
testFile := filepath.Join(path, ".write-test")
f, err := os.Create(testFile)
if err != nil {
return false
}
f.Close()
os.Remove(testFile)
return true
}
// Path doesn't exist, check if parent is writable
parent := filepath.Dir(path)
parentInfo, err := os.Stat(parent)
if err != nil {
return false
}
if !parentInfo.IsDir() {
return false
}
// Test if we can create in parent
testFile := filepath.Join(parent, ".write-test")
f, err := os.Create(testFile)
if err != nil {
return false
}
f.Close()
os.Remove(testFile)
return true
}
// GetDBPath returns the path to the SQLite database
// Resolution priority:
// 1. OPAL_DB_PATH environment variable
// 2. {data-dir}/opal.db
func GetDBPath() (string, error) {
// Priority 1: OPAL_DB_PATH override
if dbPath := os.Getenv("OPAL_DB_PATH"); dbPath != "" {
return dbPath, nil
}
// Priority 2: Data directory + opal.db
dataDir, err := GetDataDir()
if err != nil {
return "", err
}
return filepath.Join(dataDir, "opal.db"), nil
}
// GetConfigPath returns the path to the config file
func GetConfigPath() (string, error) {
configDir, err := GetConfigDir()
if err != nil {
return "", err
}
return filepath.Join(configDir, "opal.yml"), nil
}
// GetSyncQueuePath returns the path to the sync queue file
func GetSyncQueuePath() (string, error) {
dataDir, err := GetDataDir()
if err != nil {
return "", err
}
return filepath.Join(dataDir, "sync_queue.json"), nil
}
// GetSyncConflictLogPath returns the path to the sync conflict log
func GetSyncConflictLogPath() (string, error) {
dataDir, err := GetDataDir()
if err != nil {
return "", err
}
return filepath.Join(dataDir, "sync_conflicts.log"), nil
}
// ConfigExists checks if a configuration file exists
func ConfigExists() bool {
configPath, err := GetConfigPath()
if err != nil {
return false
}
_, err = os.Stat(configPath)
return err == nil
}
// IsFirstRun checks if this is the first time opal is being run
func IsFirstRun() bool {
return !ConfigExists()
}
// DefaultConfig returns a Config populated with all default values.
// This is the single source of truth for defaults.
func DefaultConfig() *Config {
return &Config{
DefaultFilter: "status:pending",
DefaultSort: "due,priority",
DefaultReport: "list",
ColorOutput: true,
WeekStartDay: "monday",
DefaultDueTime: "",
UrgencyDue: 12.0,
UrgencyPriorityH: 6.0,
UrgencyPriorityM: 3.9,
UrgencyPriorityD: 1.8,
UrgencyPriorityL: 0.0,
UrgencyActive: 4.0,
UrgencyAge: 2.0,
UrgencyAgeMax: 365,
UrgencyTags: 1.0,
UrgencyProject: 1.0,
UrgencyWaiting: -3.0,
UrgencyUrgentTag: "next",
UrgencyUrgentCoeff: 15.0,
NextLimit: 5,
SyncEnabled: false,
SyncURL: "",
SyncAPIKey: "",
SyncClientID: "",
SyncStrategy: "last-write-wins",
SyncQueueOffline: true,
}
}
// setViperDefaults registers all default values with a Viper instance.
func setViperDefaults(v *viper.Viper) {
d := DefaultConfig()
v.SetDefault("default_filter", d.DefaultFilter)
v.SetDefault("default_sort", d.DefaultSort)
v.SetDefault("default_report", d.DefaultReport)
v.SetDefault("color_output", d.ColorOutput)
v.SetDefault("week_start_day", d.WeekStartDay)
v.SetDefault("default_due_time", d.DefaultDueTime)
v.SetDefault("urgency_due_coefficient", d.UrgencyDue)
v.SetDefault("urgency_priority_h_coefficient", d.UrgencyPriorityH)
v.SetDefault("urgency_priority_m_coefficient", d.UrgencyPriorityM)
v.SetDefault("urgency_priority_d_coefficient", d.UrgencyPriorityD)
v.SetDefault("urgency_priority_l_coefficient", d.UrgencyPriorityL)
v.SetDefault("urgency_active_coefficient", d.UrgencyActive)
v.SetDefault("urgency_age_coefficient", d.UrgencyAge)
v.SetDefault("urgency_age_max", d.UrgencyAgeMax)
v.SetDefault("urgency_tags_coefficient", d.UrgencyTags)
v.SetDefault("urgency_project_coefficient", d.UrgencyProject)
v.SetDefault("urgency_waiting_coefficient", d.UrgencyWaiting)
v.SetDefault("urgency_urgent_tag", d.UrgencyUrgentTag)
v.SetDefault("urgency_urgent_coefficient", d.UrgencyUrgentCoeff)
v.SetDefault("next_limit", d.NextLimit)
v.SetDefault("sync_enabled", d.SyncEnabled)
v.SetDefault("sync_url", d.SyncURL)
v.SetDefault("sync_api_key", d.SyncAPIKey)
v.SetDefault("sync_client_id", d.SyncClientID)
v.SetDefault("sync_strategy", d.SyncStrategy)
v.SetDefault("sync_queue_offline", d.SyncQueueOffline)
}
// LoadConfig loads configuration using layered sources:
// 1. Hardcoded defaults (always present)
// 2. YAML config file (optional, read-only — never created as a side effect)
// 3. Environment variables with OPAL_ prefix (override everything)
//
// Returns a valid *Config even if no config file exists. Never returns nil.
// Returns an error only if the config file exists but is malformed.
func LoadConfig() (*Config, error) {
if globalConfig != nil {
return globalConfig, nil
}
v := viper.New()
v.SetConfigType("yaml")
// Layer 1: Hardcoded defaults
setViperDefaults(v)
// Layer 2: YAML config file (optional, read-only)
if configPath, err := GetConfigPath(); err == nil {
v.SetConfigFile(configPath)
if err := v.ReadInConfig(); err != nil {
// Distinguish "file doesn't exist" from "file is malformed"
if _, statErr := os.Stat(configPath); statErr == nil {
// File exists but couldn't be parsed
return nil, fmt.Errorf("config file %s is invalid: %w", configPath, err)
}
// File doesn't exist — that's fine, defaults apply
}
}
// Layer 3: Environment variable overrides (OPAL_DEFAULT_FILTER, etc.)
v.SetEnvPrefix("OPAL")
v.AutomaticEnv()
cfg := &Config{}
if err := v.Unmarshal(cfg); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
globalConfig = cfg
return cfg, nil
}
// InitConfig creates the config directory and writes a default opal.yml.
// This should be called explicitly during CLI first-run or setup, never as a
// side effect of loading config.
func InitConfig() error {
configDir, err := GetConfigDir()
if err != nil {
return err
}
if err := os.MkdirAll(configDir, 0755); err != nil {
return fmt.Errorf("failed to create config directory: %w", err)
}
return SaveConfig(DefaultConfig())
}
// SaveConfig writes the configuration to the opal.yml file.
func SaveConfig(cfg *Config) error {
configPath, err := GetConfigPath()
if err != nil {
return err
}
data, err := yaml.Marshal(cfg)
if err != nil {
return fmt.Errorf("failed to marshal config: %w", err)
}
if err := os.WriteFile(configPath, data, 0644); err != nil {
return fmt.Errorf("failed to write config file: %w", err)
}
// Update the cached singleton
globalConfig = cfg
return nil
}
// GetConfig returns the loaded config or loads it if not already loaded.
// Never returns nil — falls back to defaults if no config file exists.
func GetConfig() (*Config, error) {
if globalConfig != nil {
return globalConfig, nil
}
return LoadConfig()
}
// GetWeekStart returns the configured week start day
func (c *Config) GetWeekStart() time.Weekday {
if strings.ToLower(c.WeekStartDay) == "sunday" {
return time.Sunday
}
return time.Monday // default
}
// GetDefaultDueTime returns the configured default due time (HH:MM format or empty)
func (c *Config) GetDefaultDueTime() string {
return c.DefaultDueTime
}