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:
@@ -8,6 +8,8 @@ import (
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/warrenronsiek/ctask/internal/config"
|
||||
)
|
||||
|
||||
// captureStdout runs fn while capturing os.Stdout and returns the output.
|
||||
@@ -196,6 +198,10 @@ func TestCheckTmuxConfiguredAndPresent(t *testing.T) {
|
||||
if _, err := exec.LookPath("tmux"); err != nil {
|
||||
t.Skip("tmux not on PATH")
|
||||
}
|
||||
// Disable the v0.6 platform override so this test can verify the
|
||||
// post-resolver doctor output for persistent mode without being
|
||||
// short-circuited on native Windows.
|
||||
config.SetIsNativeWindowsForTest(t, func() bool { return false })
|
||||
os.Setenv("CTASK_SESSION_MODE", "persistent")
|
||||
defer os.Unsetenv("CTASK_SESSION_MODE")
|
||||
out := captureStdout(t, func() {
|
||||
@@ -211,6 +217,10 @@ func TestCheckTmuxConfiguredAndPresent(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestCheckTmuxConfiguredAndMissing(t *testing.T) {
|
||||
// Disable the v0.6 platform override so the test reaches the
|
||||
// tmux lookup path (rather than being short-circuited on native
|
||||
// Windows hosts).
|
||||
config.SetIsNativeWindowsForTest(t, func() bool { return false })
|
||||
orig := os.Getenv("PATH")
|
||||
defer os.Setenv("PATH", orig)
|
||||
os.Setenv("PATH", "")
|
||||
|
||||
@@ -4,6 +4,8 @@ import (
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/warrenronsiek/ctask/internal/config"
|
||||
)
|
||||
|
||||
// Tests in this file mutate package globals (isTTYCheck, runWorkspaceEntry).
|
||||
@@ -18,6 +20,12 @@ func TestNewDirectModeSkipsPreflight(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestNewDirectFlagUnderPersistentEmitsWarningAndProceeds(t *testing.T) {
|
||||
// Disable the v0.6 platform override: this test verifies the
|
||||
// --direct bypass warning that fires when persistent mode is in
|
||||
// effect. On native Windows the resolver coerces persistent → direct
|
||||
// before this function sees it, which is correct production
|
||||
// behavior but bypasses the codepath under test.
|
||||
config.SetIsNativeWindowsForTest(t, func() bool { return false })
|
||||
os.Setenv("CTASK_SESSION_MODE", "persistent")
|
||||
t.Cleanup(func() { os.Unsetenv("CTASK_SESSION_MODE") })
|
||||
|
||||
|
||||
+49
-39
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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" {
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user