Files
ctask/internal/config/configfile.go
T
typebasedio 6f80c8bf5c 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).
2026-05-14 21:47:25 -04:00

120 lines
3.7 KiB
Go

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")
}