c5a963bfd9
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>
401 lines
12 KiB
Go
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
|
|
}
|