package config import ( "fmt" "os" "path/filepath" "runtime" "gopkg.in/yaml.v3" ) // CurrentConfigSchemaVersion is the highest config-file schema version // this binary understands. A future schema version bump must add the // matching parser support alongside this constant. const CurrentConfigSchemaVersion = 1 // ConfigFile is the strict YAML schema for the global ctask config file. // All fields are optional; missing fields fall back to built-in defaults // via the Resolver. Unknown keys invalidate the entire file (no partial // apply) per the v0.6 spec. type ConfigFile struct { SchemaVersion int `yaml:"schema_version"` CtaskRoot string `yaml:"ctask_root"` ProjectRoot string `yaml:"project_root"` SeedDir string `yaml:"seed_dir"` DefaultAgent string `yaml:"default_agent"` DefaultCategory string `yaml:"default_category"` Editor string `yaml:"editor"` SessionMode string `yaml:"session_mode"` } // configFileKnownKeys returns the set of YAML keys recognized by the // ConfigFile struct. Kept in lockstep with the yaml tags above. func configFileKnownKeys() map[string]struct{} { return map[string]struct{}{ "schema_version": {}, "ctask_root": {}, "project_root": {}, "seed_dir": {}, "default_agent": {}, "default_category": {}, "editor": {}, "session_mode": {}, } } // LoadConfigFile reads and parses the config file at path. Returns: // - (nil, nil) the file does not exist (caller falls back to defaults) // - (nil, err) any I/O, parse, unknown-key, or schema-version error // - (cfg, nil) on success // // Unknown top-level keys invalidate the entire file with a descriptive // error; ctask never partially applies a file with typos. Future schema // versions are rejected with an upgrade message. func LoadConfigFile(path string) (*ConfigFile, error) { data, err := os.ReadFile(path) if err != nil { if os.IsNotExist(err) { return nil, nil } return nil, err } // First pass: parse into a generic map of YAML nodes so we can // inspect raw key names without trusting the typed-struct unmarshal // (yaml.v3 silently drops unknown keys by default). var raw map[string]yaml.Node if err := yaml.Unmarshal(data, &raw); err != nil { return nil, fmt.Errorf("parsing config file: %w", err) } known := configFileKnownKeys() for key := range raw { if _, ok := known[key]; !ok { return nil, fmt.Errorf("unknown key: %q", key) } } // Second pass: typed unmarshal. var cfg ConfigFile if err := yaml.Unmarshal(data, &cfg); err != nil { return nil, fmt.Errorf("parsing config file: %w", err) } if cfg.SchemaVersion > CurrentConfigSchemaVersion { return nil, fmt.Errorf( "config file requires schema version %d, but this binary supports up to version %d. Please upgrade ctask.", cfg.SchemaVersion, CurrentConfigSchemaVersion) } return &cfg, nil } // ConfigFilePath returns the platform-appropriate global config path. // // Linux / macOS / WSL: // - $XDG_CONFIG_HOME/ctask/config.yaml when XDG_CONFIG_HOME is set // - ~/.config/ctask/config.yaml otherwise // // Native Windows: // - %APPDATA%\ctask\config.yaml (fallback: %USERPROFILE%\AppData\Roaming\ctask\config.yaml) // // The path is returned regardless of whether the file exists; callers // stat the path themselves. func ConfigFilePath() string { if runtime.GOOS == "windows" { appData := os.Getenv("APPDATA") if appData == "" { home, _ := os.UserHomeDir() appData = filepath.Join(home, "AppData", "Roaming") } return filepath.Join(appData, "ctask", "config.yaml") } if xdg := os.Getenv("XDG_CONFIG_HOME"); xdg != "" { return filepath.Join(xdg, "ctask", "config.yaml") } home, _ := os.UserHomeDir() return filepath.Join(home, ".config", "ctask", "config.yaml") }