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 }