feat(v0.6): config file parser + resolver + source attribution
Phase 1 foundation. Adds: - internal/config/configfile.go — strict-key YAML parser for the global config file. Unknown top-level keys invalidate the entire file (no partial apply); future schema versions are rejected with an upgrade message. Platform-appropriate path (XDG_CONFIG_HOME or ~/.config on Unix, %APPDATA% on native Windows). - internal/config/resolver.go — Resolver / ResolvedSetting / SettingSource (Builtin, ConfigFileSrc, EnvVar, CLIFlag, PlatformOverride). Each setting carries provenance plus an Override chain so callers can render "env var (overrides config file: X)" lines. session_mode applies the native-Windows platform override at the resolver layer with the configured value chained as Override. Exports SetConfigPathForTest and SetIsNativeWindowsForTest so cross-package tests can isolate themselves from host config or simulate the override. - internal/config/config.go — legacy ResolveRoot / ResolveAgent / ResolveSeedDir / ResolveProjectRoot / ResolveSessionMode now delegate to LoadResolver so config-file values take effect for entry commands. ResolveProjectRoot preserves the "" sentinel for built-in source so SearchRoots and doctor checkProjectRoot keep their existing fallback semantics. ResolveSessionMode preserves the v0.5.3 unknown-value stderr warning, distinguishing env-var and config-file sources in the message. - Tests: 6 LoadConfigFile cases (missing, basic, partial, unknown key, future schema, malformed YAML), 3 ConfigFilePath cases (XDG / ~/.config / %APPDATA%), 11 resolver cases including layering, override chains, path expansion, platform override, and SettingSource string rendering. - Test isolation: vulnerable tests in config_test.go and config_roots_test.go that asserted Builtin-source defaults now call SetConfigPathForTest to point at a nonexistent path, insulating them from developer-host config files. Three cmd tests that asserted persistent session_mode behavior now call SetIsNativeWindowsForTest to disable the platform override (Phase 1 introduces the override; the layering-only behavior these tests cover is tested separately in resolver_test.go). No new dependencies (gopkg.in/yaml.v3 was already in go.mod). No version bump (lands at the end of Phase 3 per the v0.6 spec).
This commit is contained in:
@@ -0,0 +1,119 @@
|
||||
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")
|
||||
}
|
||||
Reference in New Issue
Block a user