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:
2026-05-14 21:47:25 -04:00
parent 8304545840
commit 6f80c8bf5c
9 changed files with 970 additions and 39 deletions
+49 -39
View File
@@ -9,39 +9,34 @@ import (
)
// ResolveRoot returns the absolute workspace root path.
// Reads CTASK_ROOT env var, falls back to ~/ai-workspaces (or %USERPROFILE%\ai-workspaces on Windows).
// v0.6: resolution order is CLI flag > CTASK_ROOT env var > config file
// (ctask_root key) > built-in default (~/ai-workspaces or
// %USERPROFILE%\ai-workspaces). No CLI flag participates in Phase 1.
func ResolveRoot() string {
root := os.Getenv("CTASK_ROOT")
if root == "" {
home, _ := os.UserHomeDir()
return filepath.Join(home, "ai-workspaces")
}
return expandPath(root)
return LoadResolver().CtaskRoot().Value
}
// ResolveAgent returns the agent command.
// Reads CTASK_AGENT env var, falls back to "claude".
// v0.6: resolution order is CLI flag > CTASK_AGENT env var > config file
// (default_agent key) > built-in default ("claude"). No CLI flag
// participates in Phase 1.
func ResolveAgent() string {
agent := os.Getenv("CTASK_AGENT")
if agent == "" {
return "claude"
}
return agent
return LoadResolver().DefaultAgent().Value
}
// ResolveSeedDir returns the user general seed directory.
// Reads CTASK_SEED_DIR; falls back to %APPDATA%\ctask\seed on Windows or
// ~/.config/ctask/seed on Unix.
// v0.6: resolution order is CTASK_SEED_DIR env var > config file
// (seed_dir key) > built-in default (%APPDATA%\ctask\seed on Windows,
// ~/.config/ctask/seed on Unix).
func ResolveSeedDir() string {
if v := os.Getenv("CTASK_SEED_DIR"); v != "" {
return expandPath(v)
}
return defaultSeedDir("seed")
return LoadResolver().SeedDir().Value
}
// ResolveProjectSeedDir returns the user project seed directory.
// Reads CTASK_SEED_PROJECT_DIR; falls back to %APPDATA%\ctask\seed-project on Windows
// or ~/.config/ctask/seed-project on Unix.
// v0.6 Phase 1 does not give this setting a config-file equivalent
// (spec lists only seed_dir, singular).
func ResolveProjectSeedDir() string {
if v := os.Getenv("CTASK_SEED_PROJECT_DIR"); v != "" {
return expandPath(v)
@@ -50,14 +45,20 @@ func ResolveProjectSeedDir() string {
}
// ResolveProjectRoot returns the project workspace root override.
// Returns empty string if CTASK_PROJECT_ROOT is not set; callers should fall back
// to ResolveRoot() in that case.
// Returns empty string when no user-supplied value is set (config file
// or env var). The "" sentinel is load-bearing for SearchRoots() and
// doctor's checkProjectRoot, both of which apply their own fallback
// (the v0.5 $CTASK_ROOT/projects/ default).
//
// v0.6: a project_root value supplied via the config file or
// CTASK_PROJECT_ROOT env var is returned verbatim; a missing/empty
// value still returns "".
func ResolveProjectRoot() string {
v := os.Getenv("CTASK_PROJECT_ROOT")
if v == "" {
s := LoadResolver().ProjectRoot()
if s.Source == Builtin {
return ""
}
return expandPath(v)
return s.Value
}
// SearchRoots returns the deduplicated list of workspace roots that all query
@@ -149,23 +150,32 @@ func defaultSeedDir(leaf string) string {
return filepath.Join(home, ".config", "ctask", leaf)
}
// ResolveSessionMode returns "direct" or "persistent" based on CTASK_SESSION_MODE.
// Default (unset/empty) is "direct". Any other value falls back to "direct"
// after printing a stderr warning. Used by entry commands (new, resume, last,
// open) to dispatch between the standard session.Run path and the tmux-backed
// persistent path.
// ResolveSessionMode returns "direct" or "persistent" — the effective
// launch session mode after resolving CLI flag > env var > config file
// > built-in default ("direct"), with the platform-override rule
// applied (persistent → direct on native Windows). Unknown values from
// the env var or config file are coerced to "direct" with a one-line
// stderr warning, preserving the v0.5.3 behavior.
//
// Used by entry commands (new, resume, last, open) to dispatch between
// the standard session.Run path and the tmux-backed persistent path.
func ResolveSessionMode() string {
v := os.Getenv("CTASK_SESSION_MODE")
switch v {
case "", "direct":
return "direct"
case "persistent":
return "persistent"
default:
fmt.Fprintf(os.Stderr,
"[ctask] warning: CTASK_SESSION_MODE=%q is not recognized; using direct mode\n", v)
return "direct"
r := LoadResolver()
s := r.SessionMode()
if s.Value == "direct" || s.Value == "persistent" {
return s.Value
}
// Unknown value path — preserve the v0.5.3 stderr warning. Source
// of the bad value is reported so the user can find where to fix it.
switch s.Source {
case EnvVar:
fmt.Fprintf(os.Stderr,
"[ctask] warning: CTASK_SESSION_MODE=%q is not recognized; using direct mode\n", s.Value)
case ConfigFileSrc:
fmt.Fprintf(os.Stderr,
"[ctask] warning: config session_mode=%q is not recognized; using direct mode\n", s.Value)
}
return "direct"
}
// expandPath expands a leading ~/ and resolves to an absolute path when possible.
+2
View File
@@ -9,6 +9,7 @@ import (
func TestSearchRootsUnsetProjectRoot(t *testing.T) {
// v0.5: when CTASK_PROJECT_ROOT is unset, SearchRoots also appends
// $CTASK_ROOT/projects/ so default-location projects are findable.
SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml"))
os.Unsetenv("CTASK_PROJECT_ROOT")
os.Setenv("CTASK_ROOT", t.TempDir())
defer os.Unsetenv("CTASK_ROOT")
@@ -55,6 +56,7 @@ func TestSearchRootsSameRootDedupes(t *testing.T) {
}
func TestSearchRootsAppendsProjectsDirWhenUnset(t *testing.T) {
SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml"))
taskRoot := t.TempDir()
os.Setenv("CTASK_ROOT", taskRoot)
os.Unsetenv("CTASK_PROJECT_ROOT")
+11
View File
@@ -10,6 +10,7 @@ import (
)
func TestDefaultRoot(t *testing.T) {
SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml"))
os.Unsetenv("CTASK_ROOT")
root := ResolveRoot()
home, _ := os.UserHomeDir()
@@ -39,6 +40,7 @@ func TestRootResolvesRelative(t *testing.T) {
}
func TestDefaultAgent(t *testing.T) {
SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml"))
os.Unsetenv("CTASK_AGENT")
agent := ResolveAgent()
if agent != "claude" {
@@ -71,6 +73,7 @@ func TestRootResolvesTilde(t *testing.T) {
}
func TestResolveSeedDirDefault(t *testing.T) {
SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml"))
os.Unsetenv("CTASK_SEED_DIR")
got := ResolveSeedDir()
home, _ := os.UserHomeDir()
@@ -129,6 +132,7 @@ func TestResolveProjectSeedDirOverride(t *testing.T) {
}
func TestResolveProjectRootEmptyWhenUnset(t *testing.T) {
SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml"))
os.Unsetenv("CTASK_PROJECT_ROOT")
got := ResolveProjectRoot()
if got != "" {
@@ -195,6 +199,7 @@ func TestEnvVarsLaunchDirEmpty(t *testing.T) {
}
func TestResolveSessionModeDefault(t *testing.T) {
SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml"))
os.Unsetenv("CTASK_SESSION_MODE")
if got := ResolveSessionMode(); got != "direct" {
t.Errorf("default: got %q, want %q", got, "direct")
@@ -202,6 +207,7 @@ func TestResolveSessionModeDefault(t *testing.T) {
}
func TestResolveSessionModeEmpty(t *testing.T) {
SetConfigPathForTest(t, filepath.Join(t.TempDir(), "no-config.yaml"))
os.Setenv("CTASK_SESSION_MODE", "")
defer os.Unsetenv("CTASK_SESSION_MODE")
if got := ResolveSessionMode(); got != "direct" {
@@ -218,6 +224,11 @@ func TestResolveSessionModeDirect(t *testing.T) {
}
func TestResolveSessionModePersistent(t *testing.T) {
// Disable the v0.6 platform override so this test can verify the
// pure layering behavior (env var → persistent). The platform
// override is covered separately by
// TestResolverSessionModePlatformOverride.
SetIsNativeWindowsForTest(t, func() bool { return false })
os.Setenv("CTASK_SESSION_MODE", "persistent")
defer os.Unsetenv("CTASK_SESSION_MODE")
if got := ResolveSessionMode(); got != "persistent" {
+119
View File
@@ -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")
}
+193
View File
@@ -0,0 +1,193 @@
package config
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// TestLoadConfigFileMissingReturnsNil — a missing config file is not an
// error; callers fall back to env vars / built-in defaults. The
// resolver tracks "no file" as INFO state.
func TestLoadConfigFileMissingReturnsNil(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "nonexistent.yaml")
cfg, err := LoadConfigFile(path)
if err != nil {
t.Fatalf("missing file should not error, got %v", err)
}
if cfg != nil {
t.Errorf("missing file should return nil cfg, got %+v", cfg)
}
}
// TestLoadConfigFileBasic — all known keys parse into the typed struct.
func TestLoadConfigFileBasic(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
body := []byte(`schema_version: 1
ctask_root: ~/ai-workspaces
project_root: ~/ai-workspaces/projects
seed_dir: ~/.config/ctask/seed
default_agent: opencode
default_category: tasks
editor: code
session_mode: persistent
`)
if err := os.WriteFile(path, body, 0644); err != nil {
t.Fatalf("write: %v", err)
}
cfg, err := LoadConfigFile(path)
if err != nil {
t.Fatalf("LoadConfigFile: %v", err)
}
if cfg == nil {
t.Fatal("expected non-nil cfg")
}
if cfg.SchemaVersion != 1 {
t.Errorf("SchemaVersion: got %d, want 1", cfg.SchemaVersion)
}
if cfg.DefaultAgent != "opencode" {
t.Errorf("DefaultAgent: got %q, want opencode", cfg.DefaultAgent)
}
if cfg.SessionMode != "persistent" {
t.Errorf("SessionMode: got %q, want persistent", cfg.SessionMode)
}
}
// TestLoadConfigFilePartial — only some keys set; rest remain zero-valued.
func TestLoadConfigFilePartial(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
body := []byte("default_agent: opencode\n")
if err := os.WriteFile(path, body, 0644); err != nil {
t.Fatalf("write: %v", err)
}
cfg, err := LoadConfigFile(path)
if err != nil {
t.Fatalf("LoadConfigFile: %v", err)
}
if cfg.DefaultAgent != "opencode" {
t.Errorf("DefaultAgent: got %q, want opencode", cfg.DefaultAgent)
}
if cfg.CtaskRoot != "" {
t.Errorf("CtaskRoot: expected empty (unset), got %q", cfg.CtaskRoot)
}
if cfg.SessionMode != "" {
t.Errorf("SessionMode: expected empty (unset), got %q", cfg.SessionMode)
}
}
// TestLoadConfigFileUnknownKey — strict-key rejection per spec.
// Entire file is invalidated; the error names the offending key.
func TestLoadConfigFileUnknownKey(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
body := []byte("default_agnet: foo\n") // typo
if err := os.WriteFile(path, body, 0644); err != nil {
t.Fatalf("write: %v", err)
}
cfg, err := LoadConfigFile(path)
if err == nil {
t.Fatalf("expected error for unknown key, got cfg=%+v", cfg)
}
if cfg != nil {
t.Errorf("expected nil cfg on error, got %+v", cfg)
}
if !strings.Contains(err.Error(), "default_agnet") {
t.Errorf("error should name unknown key, got: %v", err)
}
}
// TestLoadConfigFileSchemaVersionFuture — a config requesting a higher
// schema version than this binary supports is rejected with an upgrade
// message. No partial parsing.
func TestLoadConfigFileSchemaVersionFuture(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
body := []byte("schema_version: 2\ndefault_agent: claude\n")
if err := os.WriteFile(path, body, 0644); err != nil {
t.Fatalf("write: %v", err)
}
cfg, err := LoadConfigFile(path)
if err == nil {
t.Fatalf("expected error for future schema version, got cfg=%+v", cfg)
}
if cfg != nil {
t.Errorf("expected nil cfg on schema-version error, got %+v", cfg)
}
if !strings.Contains(err.Error(), "schema version") {
t.Errorf("error should mention schema version, got: %v", err)
}
if !strings.Contains(err.Error(), "upgrade") {
t.Errorf("error should mention upgrade, got: %v", err)
}
}
// TestLoadConfigFileMalformedYAML — invalid YAML produces an error.
func TestLoadConfigFileMalformedYAML(t *testing.T) {
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
body := []byte("default_agent: [not closed\n")
if err := os.WriteFile(path, body, 0644); err != nil {
t.Fatalf("write: %v", err)
}
cfg, err := LoadConfigFile(path)
if err == nil {
t.Errorf("expected error for malformed YAML, got cfg=%+v", cfg)
}
if cfg != nil {
t.Errorf("expected nil cfg on YAML error, got %+v", cfg)
}
}
// TestConfigFilePathLinux — Unix paths honor XDG_CONFIG_HOME.
func TestConfigFilePathLinux(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix-path semantics")
}
t.Setenv("XDG_CONFIG_HOME", "/tmp/xdgtest")
got := ConfigFilePath()
want := filepath.Join("/tmp/xdgtest", "ctask", "config.yaml")
if got != want {
t.Errorf("ConfigFilePath with XDG: got %q, want %q", got, want)
}
}
// TestConfigFilePathLinuxDefault — fallback to ~/.config when XDG unset.
func TestConfigFilePathLinuxDefault(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix-path semantics")
}
t.Setenv("XDG_CONFIG_HOME", "")
got := ConfigFilePath()
home, _ := os.UserHomeDir()
want := filepath.Join(home, ".config", "ctask", "config.yaml")
if got != want {
t.Errorf("ConfigFilePath default: got %q, want %q", got, want)
}
}
// TestConfigFilePathWindows — %APPDATA% path on native Windows.
func TestConfigFilePathWindows(t *testing.T) {
if runtime.GOOS != "windows" {
t.Skip("Windows-only")
}
appData := os.Getenv("APPDATA")
if appData == "" {
t.Skip("APPDATA unset on this host")
}
got := ConfigFilePath()
want := filepath.Join(appData, "ctask", "config.yaml")
if got != want {
t.Errorf("ConfigFilePath on Windows: got %q, want %q", got, want)
}
}
+288
View File
@@ -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")
}
+290
View File
@@ -0,0 +1,290 @@
package config
import (
"os"
"path/filepath"
"runtime"
"strings"
"testing"
)
// writeConfig is a test helper that writes body to a temp directory's
// config.yaml, points ctaskConfigPathOverrideForTest at it, and returns
// the path. Test cleanup restores the override.
func writeConfig(t *testing.T, body string) string {
t.Helper()
dir := t.TempDir()
path := filepath.Join(dir, "config.yaml")
if err := os.WriteFile(path, []byte(body), 0644); err != nil {
t.Fatalf("writeConfig: %v", err)
}
prev := configPathForTest
configPathForTest = path
t.Cleanup(func() { configPathForTest = prev })
return path
}
// noConfig points the resolver at a nonexistent path for the duration of
// a single test. Used to assert "no config file" behavior even when the
// host happens to have a real ~/.config/ctask/config.yaml.
func noConfig(t *testing.T) {
t.Helper()
dir := t.TempDir()
prev := configPathForTest
configPathForTest = filepath.Join(dir, "nonexistent.yaml")
t.Cleanup(func() { configPathForTest = prev })
}
// clearConfigEnv unsets every env var the resolver reads. Use this in
// every resolver test to insulate from inherited shell state. t.Setenv
// is preferred when a single value is needed (it auto-restores).
func clearConfigEnv(t *testing.T) {
t.Helper()
for _, name := range []string{
"CTASK_ROOT", "CTASK_PROJECT_ROOT", "CTASK_SEED_DIR",
"CTASK_AGENT", "CTASK_SESSION_MODE", "EDITOR",
} {
t.Setenv(name, "")
os.Unsetenv(name)
}
}
// TestResolverNoConfigUsesBuiltins — when no config file exists and no
// env vars are set, every setting resolves to Builtin source.
func TestResolverNoConfigUsesBuiltins(t *testing.T) {
clearConfigEnv(t)
noConfig(t)
r := LoadResolver()
if r.ConfigErr != nil {
t.Errorf("missing file should not be an error, got %v", r.ConfigErr)
}
if s := r.CtaskRoot(); s.Source != Builtin {
t.Errorf("CtaskRoot source: got %v, want Builtin", s.Source)
}
if s := r.DefaultAgent(); s.Source != Builtin || s.Value != "claude" {
t.Errorf("DefaultAgent: got %+v, want value=claude source=Builtin", s)
}
if s := r.SessionMode(); s.Source != Builtin || s.Value != "direct" {
t.Errorf("SessionMode: got %+v, want value=direct source=Builtin", s)
}
if s := r.DefaultCategory(); s.Source != Builtin || s.Value != "tasks" {
t.Errorf("DefaultCategory: got %+v, want value=tasks source=Builtin", s)
}
}
// TestResolverConfigFilePartial — only default_agent set in config; that
// setting resolves to ConfigFileSrc, others stay Builtin.
func TestResolverConfigFilePartial(t *testing.T) {
clearConfigEnv(t)
writeConfig(t, "default_agent: opencode\n")
r := LoadResolver()
if r.ConfigErr != nil {
t.Fatalf("ConfigErr: %v", r.ConfigErr)
}
s := r.DefaultAgent()
if s.Source != ConfigFileSrc {
t.Errorf("DefaultAgent source: got %v, want ConfigFileSrc", s.Source)
}
if s.Value != "opencode" {
t.Errorf("DefaultAgent value: got %q, want opencode", s.Value)
}
if s.Override == nil || s.Override.Source != Builtin {
t.Errorf("DefaultAgent Override should chain to Builtin, got %+v", s.Override)
}
if s := r.CtaskRoot(); s.Source != Builtin {
t.Errorf("CtaskRoot should remain Builtin, got %v", s.Source)
}
}
// TestResolverEnvOverridesConfig — env var beats config file; the
// override chain points back at the config-file setting.
func TestResolverEnvOverridesConfig(t *testing.T) {
clearConfigEnv(t)
writeConfig(t, "default_agent: opencode\n")
t.Setenv("CTASK_AGENT", "aider")
r := LoadResolver()
s := r.DefaultAgent()
if s.Source != EnvVar {
t.Errorf("source: got %v, want EnvVar", s.Source)
}
if s.Value != "aider" {
t.Errorf("value: got %q, want aider", s.Value)
}
if s.EnvName != "CTASK_AGENT" {
t.Errorf("EnvName: got %q, want CTASK_AGENT", s.EnvName)
}
if s.Override == nil || s.Override.Source != ConfigFileSrc {
t.Fatalf("override should chain to ConfigFileSrc, got %+v", s.Override)
}
if s.Override.Value != "opencode" {
t.Errorf("override value: got %q, want opencode", s.Override.Value)
}
}
// TestResolverConfigFileUnknownKeyInvalidatesFile — strict-key error
// recorded on ConfigErr; settings fall back to env/builtin only.
func TestResolverConfigFileUnknownKeyInvalidatesFile(t *testing.T) {
clearConfigEnv(t)
writeConfig(t, "default_agnet: foo\n")
r := LoadResolver()
if r.ConfigErr == nil {
t.Fatalf("expected ConfigErr from unknown key, got nil")
}
if !strings.Contains(r.ConfigErr.Error(), "default_agnet") {
t.Errorf("ConfigErr should name unknown key, got %v", r.ConfigErr)
}
if s := r.DefaultAgent(); s.Source != Builtin {
t.Errorf("DefaultAgent should fall back to Builtin when config invalid, got %v", s.Source)
}
}
// TestResolverConfigSchemaVersionFutureRejected — same as configfile
// test, but viewed through the resolver.
func TestResolverConfigSchemaVersionFutureRejected(t *testing.T) {
clearConfigEnv(t)
writeConfig(t, "schema_version: 2\ndefault_agent: opencode\n")
r := LoadResolver()
if r.ConfigErr == nil {
t.Fatalf("expected ConfigErr from future schema version")
}
if !strings.Contains(r.ConfigErr.Error(), "schema version") {
t.Errorf("ConfigErr should mention schema version, got %v", r.ConfigErr)
}
if s := r.DefaultAgent(); s.Source != Builtin {
t.Errorf("DefaultAgent should fall back to Builtin, got %v", s.Source)
}
}
// TestResolverPathExpansion — ~/ in config values is expanded.
func TestResolverPathExpansion(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("tilde expansion not typical on Windows")
}
clearConfigEnv(t)
writeConfig(t, "ctask_root: ~/ai-workspaces\n")
r := LoadResolver()
s := r.CtaskRoot()
if !filepath.IsAbs(s.Value) {
t.Errorf("CtaskRoot should be absolute after expansion, got %q", s.Value)
}
if strings.HasPrefix(s.Value, "~") {
t.Errorf("CtaskRoot tilde not expanded: %q", s.Value)
}
}
// TestResolverSessionModePlatformOverride — config requests persistent
// mode, but the simulated host is native Windows. Resolver should
// return Value="direct" with Source=PlatformOverride and Override
// chained back to the ConfigFile setting.
func TestResolverSessionModePlatformOverride(t *testing.T) {
clearConfigEnv(t)
writeConfig(t, "session_mode: persistent\n")
prev := isNativeWindowsForTest
isNativeWindowsForTest = func() bool { return true }
t.Cleanup(func() { isNativeWindowsForTest = prev })
r := LoadResolver()
s := r.SessionMode()
if s.Value != "direct" {
t.Errorf("value: got %q, want direct (platform override)", s.Value)
}
if s.Source != PlatformOverride {
t.Errorf("source: got %v, want PlatformOverride", s.Source)
}
if s.Override == nil {
t.Fatalf("override should chain to configured value, got nil")
}
if s.Override.Value != "persistent" {
t.Errorf("override value: got %q, want persistent", s.Override.Value)
}
if s.Override.Source != ConfigFileSrc {
t.Errorf("override source: got %v, want ConfigFileSrc", s.Override.Source)
}
}
// TestResolverSessionModeNoOverrideWhenNotPersistent — even on native
// Windows, direct mode doesn't trip the override.
func TestResolverSessionModeNoOverrideWhenNotPersistent(t *testing.T) {
clearConfigEnv(t)
writeConfig(t, "session_mode: direct\n")
prev := isNativeWindowsForTest
isNativeWindowsForTest = func() bool { return true }
t.Cleanup(func() { isNativeWindowsForTest = prev })
r := LoadResolver()
s := r.SessionMode()
if s.Value != "direct" {
t.Errorf("value: got %q, want direct", s.Value)
}
if s.Source != ConfigFileSrc {
t.Errorf("source: got %v, want ConfigFileSrc (no override needed)", s.Source)
}
}
// TestSettingSourceString — human-readable strings for doctor/info.
func TestSettingSourceString(t *testing.T) {
cases := []struct {
s SettingSource
want string
}{
{Builtin, "built-in default"},
{ConfigFileSrc, "config file"},
{EnvVar, "env var"},
{CLIFlag, "CLI flag"},
{PlatformOverride, "platform override"},
}
for _, c := range cases {
if got := c.s.String(); got != c.want {
t.Errorf("%v.String(): got %q, want %q", c.s, got, c.want)
}
}
}
// TestResolverProjectRootDefaultsToCtaskRootProjects — built-in default
// for project_root is <ctask_root>/projects, mirroring the existing
// SearchRoots() fallback behavior introduced in v0.5.
func TestResolverProjectRootDefaultsToCtaskRootProjects(t *testing.T) {
clearConfigEnv(t)
noConfig(t)
tmp := t.TempDir()
t.Setenv("CTASK_ROOT", tmp)
r := LoadResolver()
want := filepath.Join(tmp, "projects")
s := r.ProjectRoot()
if filepath.Clean(s.Value) != filepath.Clean(want) {
t.Errorf("ProjectRoot value: got %q, want %q", s.Value, want)
}
if s.Source != Builtin {
t.Errorf("ProjectRoot source: got %v, want Builtin (derived from CtaskRoot)", s.Source)
}
}
// TestResolverEditorFromConfigAndEnv — Editor honors EDITOR env var when
// set, then config, then empty default.
func TestResolverEditorFromConfigAndEnv(t *testing.T) {
clearConfigEnv(t)
writeConfig(t, "editor: vim\n")
t.Setenv("EDITOR", "nano")
r := LoadResolver()
s := r.Editor()
if s.Value != "nano" || s.Source != EnvVar {
t.Errorf("Editor with both env and config: got %+v, want value=nano source=EnvVar", s)
}
if s.Override == nil || s.Override.Value != "vim" {
t.Errorf("Editor override should chain to config (vim), got %+v", s.Override)
}
}