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,288 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"runtime"
|
||||
)
|
||||
|
||||
// SettingSource records where a resolved setting's value came from.
|
||||
// Defined high-to-low priority order is implicit in the resolver:
|
||||
// CLIFlag > EnvVar > ConfigFileSrc > Builtin. PlatformOverride is a
|
||||
// special-case post-resolution adjustment (currently used only for
|
||||
// session_mode on native Windows).
|
||||
type SettingSource int
|
||||
|
||||
const (
|
||||
// Builtin: ctask's compiled-in default.
|
||||
Builtin SettingSource = iota
|
||||
// ConfigFileSrc: value came from the global config.yaml.
|
||||
// Named with the "Src" suffix to avoid colliding with the
|
||||
// ConfigFile type in this package.
|
||||
ConfigFileSrc
|
||||
// EnvVar: value came from an environment variable.
|
||||
EnvVar
|
||||
// CLIFlag: value came from a CLI flag. Reserved for v0.6 Phase 2
|
||||
// (--agent) and later flags; no Phase 1 setting reaches this source.
|
||||
CLIFlag
|
||||
// PlatformOverride: ctask itself overrode the user-configured value
|
||||
// because the requested setting is unsupported on the current
|
||||
// platform (currently: persistent session_mode on native Windows).
|
||||
PlatformOverride
|
||||
)
|
||||
|
||||
// String returns the human-readable label used in doctor/info output.
|
||||
func (s SettingSource) String() string {
|
||||
switch s {
|
||||
case Builtin:
|
||||
return "built-in default"
|
||||
case ConfigFileSrc:
|
||||
return "config file"
|
||||
case EnvVar:
|
||||
return "env var"
|
||||
case CLIFlag:
|
||||
return "CLI flag"
|
||||
case PlatformOverride:
|
||||
return "platform override"
|
||||
default:
|
||||
return fmt.Sprintf("unknown source (%d)", int(s))
|
||||
}
|
||||
}
|
||||
|
||||
// ResolvedSetting carries a single setting's effective value plus
|
||||
// provenance. Override (if non-nil) chains to the next-lower-priority
|
||||
// source that contributed a value but was overridden — letting
|
||||
// doctor/info render lines like "source: CTASK_AGENT env var
|
||||
// (overrides config file: claude)".
|
||||
type ResolvedSetting struct {
|
||||
Key string
|
||||
Value string
|
||||
Source SettingSource
|
||||
EnvName string // for EnvVar source, the env-var name
|
||||
Override *ResolvedSetting
|
||||
}
|
||||
|
||||
// Resolver carries the loaded config file plus a snapshot of relevant
|
||||
// env vars so callers can compute each setting's effective value and
|
||||
// its source. Build one with LoadResolver() per command invocation;
|
||||
// reuse across calls within the same invocation to keep doctor/info
|
||||
// output internally consistent.
|
||||
type Resolver struct {
|
||||
ConfigPath string // path consulted (regardless of whether file exists)
|
||||
ConfigErr error // non-nil when config file failed to load
|
||||
cfg *ConfigFile
|
||||
|
||||
envCtaskRoot string
|
||||
envProjectRoot string
|
||||
envSeedDir string
|
||||
envAgent string
|
||||
envSessionMode string
|
||||
envEditor string
|
||||
}
|
||||
|
||||
// configPathForTest, when non-empty, overrides ConfigFilePath() for
|
||||
// the duration of a test. The legacy production code path consults
|
||||
// ConfigFilePath() directly.
|
||||
var configPathForTest string
|
||||
|
||||
// SetConfigPathForTest points the resolver at path for the duration
|
||||
// of t. Use this in any test that depends on Builtin-source resolution
|
||||
// (so a real ~/.config/ctask/config.yaml on the developer host cannot
|
||||
// pollute the assertion). Pass a path under t.TempDir() to guarantee
|
||||
// the file does not exist.
|
||||
func SetConfigPathForTest(t interface{ Cleanup(func()) }, path string) {
|
||||
prev := configPathForTest
|
||||
configPathForTest = path
|
||||
t.Cleanup(func() { configPathForTest = prev })
|
||||
}
|
||||
|
||||
// isNativeWindowsForTest lets tests simulate the native-Windows-no-WSL
|
||||
// platform regardless of the actual GOOS. Production callers use the
|
||||
// real check via isNativeWindows().
|
||||
var isNativeWindowsForTest func() bool
|
||||
|
||||
// SetIsNativeWindowsForTest replaces the native-Windows detector for
|
||||
// the duration of t. Use it from tests outside the config package
|
||||
// that need to exercise the session_mode platform-override path
|
||||
// (or, conversely, disable the override on a Windows host so a test
|
||||
// can assert pre-override layering). Restored via t.Cleanup.
|
||||
func SetIsNativeWindowsForTest(t interface{ Cleanup(func()) }, f func() bool) {
|
||||
prev := isNativeWindowsForTest
|
||||
isNativeWindowsForTest = f
|
||||
t.Cleanup(func() { isNativeWindowsForTest = prev })
|
||||
}
|
||||
|
||||
// isNativeWindows reports whether ctask is running on Windows outside
|
||||
// of a WSL distro. WSL exports WSL_DISTRO_NAME for any process started
|
||||
// from a WSL shell.
|
||||
func isNativeWindows() bool {
|
||||
if isNativeWindowsForTest != nil {
|
||||
return isNativeWindowsForTest()
|
||||
}
|
||||
return runtime.GOOS == "windows" && os.Getenv("WSL_DISTRO_NAME") == ""
|
||||
}
|
||||
|
||||
// resolverPath returns the config-file path the resolver should read,
|
||||
// honoring the test override when present.
|
||||
func resolverPath() string {
|
||||
if configPathForTest != "" {
|
||||
return configPathForTest
|
||||
}
|
||||
return ConfigFilePath()
|
||||
}
|
||||
|
||||
// LoadResolver reads the config file (if present) and snapshots the
|
||||
// environment variables the resolver needs. Always returns a non-nil
|
||||
// Resolver — when the config file is missing or invalid, the resolver
|
||||
// still serves builtin and env-derived values.
|
||||
func LoadResolver() *Resolver {
|
||||
r := &Resolver{ConfigPath: resolverPath()}
|
||||
cfg, err := LoadConfigFile(r.ConfigPath)
|
||||
if err != nil {
|
||||
r.ConfigErr = err
|
||||
} else if cfg != nil {
|
||||
r.cfg = cfg
|
||||
}
|
||||
r.envCtaskRoot = os.Getenv("CTASK_ROOT")
|
||||
r.envProjectRoot = os.Getenv("CTASK_PROJECT_ROOT")
|
||||
r.envSeedDir = os.Getenv("CTASK_SEED_DIR")
|
||||
r.envAgent = os.Getenv("CTASK_AGENT")
|
||||
r.envSessionMode = os.Getenv("CTASK_SESSION_MODE")
|
||||
r.envEditor = os.Getenv("EDITOR")
|
||||
return r
|
||||
}
|
||||
|
||||
// stringSetting layers Builtin → ConfigFileSrc → EnvVar for a single
|
||||
// string-valued key. Empty values at any layer are treated as "not
|
||||
// supplied" and skipped. transform, when non-nil, is applied to every
|
||||
// non-empty layer's value (used for path expansion).
|
||||
func (r *Resolver) stringSetting(key, envName, builtin, cfgVal, envVal string, transform func(string) string) ResolvedSetting {
|
||||
apply := func(v string) string {
|
||||
if transform == nil {
|
||||
return v
|
||||
}
|
||||
return transform(v)
|
||||
}
|
||||
|
||||
s := ResolvedSetting{Key: key, Value: apply(builtin), Source: Builtin}
|
||||
if cfgVal != "" {
|
||||
prev := s
|
||||
s = ResolvedSetting{
|
||||
Key: key,
|
||||
Value: apply(cfgVal),
|
||||
Source: ConfigFileSrc,
|
||||
Override: &prev,
|
||||
}
|
||||
}
|
||||
if envVal != "" {
|
||||
prev := s
|
||||
s = ResolvedSetting{
|
||||
Key: key,
|
||||
Value: apply(envVal),
|
||||
Source: EnvVar,
|
||||
EnvName: envName,
|
||||
Override: &prev,
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// CtaskRoot resolves the workspace root path with source attribution.
|
||||
func (r *Resolver) CtaskRoot() ResolvedSetting {
|
||||
cfgVal := ""
|
||||
if r.cfg != nil {
|
||||
cfgVal = r.cfg.CtaskRoot
|
||||
}
|
||||
return r.stringSetting("ctask_root", "CTASK_ROOT",
|
||||
defaultCtaskRoot(), cfgVal, r.envCtaskRoot, expandPath)
|
||||
}
|
||||
|
||||
// ProjectRoot resolves the project-workspace root. Its built-in
|
||||
// default is derived from the resolved ctask_root, mirroring the
|
||||
// SearchRoots() v0.5 fallback (<ctask_root>/projects). The Builtin
|
||||
// chain therefore contains the derived path, not an empty string.
|
||||
func (r *Resolver) ProjectRoot() ResolvedSetting {
|
||||
cfgVal := ""
|
||||
if r.cfg != nil {
|
||||
cfgVal = r.cfg.ProjectRoot
|
||||
}
|
||||
builtin := filepath.Join(r.CtaskRoot().Value, "projects")
|
||||
return r.stringSetting("project_root", "CTASK_PROJECT_ROOT",
|
||||
builtin, cfgVal, r.envProjectRoot, expandPath)
|
||||
}
|
||||
|
||||
// SeedDir resolves the user general seed directory. Built-in default
|
||||
// is the platform-default seed location.
|
||||
func (r *Resolver) SeedDir() ResolvedSetting {
|
||||
cfgVal := ""
|
||||
if r.cfg != nil {
|
||||
cfgVal = r.cfg.SeedDir
|
||||
}
|
||||
return r.stringSetting("seed_dir", "CTASK_SEED_DIR",
|
||||
defaultSeedDir("seed"), cfgVal, r.envSeedDir, expandPath)
|
||||
}
|
||||
|
||||
// DefaultAgent resolves the default agent command.
|
||||
func (r *Resolver) DefaultAgent() ResolvedSetting {
|
||||
cfgVal := ""
|
||||
if r.cfg != nil {
|
||||
cfgVal = r.cfg.DefaultAgent
|
||||
}
|
||||
return r.stringSetting("default_agent", "CTASK_AGENT",
|
||||
"claude", cfgVal, r.envAgent, nil)
|
||||
}
|
||||
|
||||
// DefaultCategory resolves the default category for new workspaces.
|
||||
// No env-var equivalent in v0.6 Phase 1.
|
||||
func (r *Resolver) DefaultCategory() ResolvedSetting {
|
||||
cfgVal := ""
|
||||
if r.cfg != nil {
|
||||
cfgVal = r.cfg.DefaultCategory
|
||||
}
|
||||
return r.stringSetting("default_category", "",
|
||||
"tasks", cfgVal, "", nil)
|
||||
}
|
||||
|
||||
// Editor resolves the user's text editor (used by future ctask edit).
|
||||
// Env var is the conventional EDITOR.
|
||||
func (r *Resolver) Editor() ResolvedSetting {
|
||||
cfgVal := ""
|
||||
if r.cfg != nil {
|
||||
cfgVal = r.cfg.Editor
|
||||
}
|
||||
return r.stringSetting("editor", "EDITOR",
|
||||
"", cfgVal, r.envEditor, nil)
|
||||
}
|
||||
|
||||
// SessionMode resolves the launch session mode. Layers Builtin (direct)
|
||||
// → ConfigFile → EnvVar, then applies the native-Windows platform
|
||||
// override: when the resolved value is "persistent" but the host is
|
||||
// native Windows, the value is forced to "direct" with PlatformOverride
|
||||
// as the source and the previously resolved setting chained as
|
||||
// Override (so doctor can render "configured: persistent").
|
||||
func (r *Resolver) SessionMode() ResolvedSetting {
|
||||
cfgVal := ""
|
||||
if r.cfg != nil {
|
||||
cfgVal = r.cfg.SessionMode
|
||||
}
|
||||
s := r.stringSetting("session_mode", "CTASK_SESSION_MODE",
|
||||
"direct", cfgVal, r.envSessionMode, nil)
|
||||
|
||||
if s.Value == "persistent" && isNativeWindows() {
|
||||
prev := s
|
||||
s = ResolvedSetting{
|
||||
Key: "session_mode",
|
||||
Value: "direct",
|
||||
Source: PlatformOverride,
|
||||
Override: &prev,
|
||||
}
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
// defaultCtaskRoot returns the built-in default for the workspace root.
|
||||
func defaultCtaskRoot() string {
|
||||
home, _ := os.UserHomeDir()
|
||||
return filepath.Join(home, "ai-workspaces")
|
||||
}
|
||||
Reference in New Issue
Block a user