Skip to main content

thndrs_lib/core/config/
mod.rs

1//! TOML configuration loading and effective config resolution.
2//!
3//! Config files are optional. Supported paths are exactly:
4//! - Global: `~/.thndrs/config.toml`
5//! - Project: `.thndrs/config.toml`
6//!
7//! Malformed files and unknown keys are errors so users do not run with
8//! silently ignored settings.
9
10use std::collections::BTreeMap;
11use std::fs;
12use std::path::{Component, Path, PathBuf};
13
14use serde::Deserialize;
15use sha2::{Digest, Sha256};
16use thndrs_agent::context::ContextConfig;
17
18use crate::cli::{DEFAULT_TICK_RATE_MS, ReasoningEffort, ReasoningSummary, Theme, WebSearchMode};
19use crate::utils;
20
21static CONFIG_KEYS: [&str; 14] = [
22    "model",
23    "websearch",
24    "websearch_url",
25    "reasoning_effort",
26    "reasoning_summary",
27    "tick_rate_ms",
28    "theme",
29    "mouse",
30    "verbose",
31    "skill_dirs",
32    "session_dir",
33    "default_workspace",
34    "acp_agents",
35    "context",
36];
37
38#[derive(Debug, thiserror::Error)]
39pub enum ConfigError {
40    #[error("failed to read config {path}: {source}")]
41    Read {
42        path: PathBuf,
43        #[source]
44        source: std::io::Error,
45    },
46    #[error("failed to parse config {path}: {source}")]
47    Parse {
48        path: PathBuf,
49        #[source]
50        source: toml::de::Error,
51    },
52    #[error("invalid environment variable {name}: {message}")]
53    InvalidEnv { name: String, message: String },
54    #[error("unknown environment variable {name}")]
55    UnknownEnv { name: String },
56    #[error("secret-shaped key `{key}` is not allowed in config; use provider env vars instead")]
57    SecretInConfig { key: String },
58    #[error("invalid config {key}: {message}")]
59    InvalidConfig { key: String, message: String },
60    #[error("conflicting CLI flags: --mouse and --no-mouse cannot both be set")]
61    ConflictingMouseFlags,
62}
63
64/// Configuration for one external ACP agent.
65#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
66#[serde(default, deny_unknown_fields)]
67pub struct AcpAgentConfig {
68    /// Executable command launched over stdio.
69    pub command: String,
70    /// Command-line arguments passed after [`AcpAgentConfig::command`].
71    pub args: Vec<String>,
72    /// Environment variables passed to the ACP child process.
73    pub env: BTreeMap<String, String>,
74    /// Whether this agent is selectable.
75    pub enabled: bool,
76    /// Timeout for lifecycle requests in seconds.
77    pub timeout_secs: u64,
78}
79
80impl Default for AcpAgentConfig {
81    fn default() -> Self {
82        Self { command: String::new(), args: Vec::new(), env: BTreeMap::new(), enabled: true, timeout_secs: 60 }
83    }
84}
85
86impl AcpAgentConfig {
87    /// Return a copy with environment values redacted for diagnostics/metadata.
88    pub fn redacted(&self) -> Self {
89        let env = self
90            .env
91            .keys()
92            .map(|key| (key.clone(), "[redacted]".to_string()))
93            .collect();
94        Self { env, ..self.clone() }
95    }
96}
97
98/// Named ACP agent configurations.
99pub type AcpAgentsConfig = BTreeMap<String, AcpAgentConfig>;
100
101/// User-editable configuration loaded from TOML.
102///
103/// Only ordinary runtime keys are present. CLI-only flags (`print_prompt`,
104/// `cwd`, `no_mouse`) are not TOML keys. Secret-shaped keys
105/// are rejected before deserialization reaches this struct.
106#[derive(Clone, Debug, Default, Deserialize, Eq, PartialEq)]
107#[serde(default, deny_unknown_fields)]
108pub struct Config {
109    pub model: Option<String>,
110    pub websearch: Option<WebSearchMode>,
111    pub websearch_url: Option<String>,
112    pub reasoning_effort: Option<ReasoningEffort>,
113    pub reasoning_summary: Option<ReasoningSummary>,
114    pub tick_rate_ms: Option<u64>,
115    pub mouse: Option<bool>,
116    pub verbose: Option<bool>,
117    pub theme: Option<Theme>,
118    pub skill_dirs: Vec<PathBuf>,
119    pub session_dir: Option<PathBuf>,
120    pub default_workspace: Option<PathBuf>,
121    pub acp_agents: AcpAgentsConfig,
122    pub context: ContextConfig,
123}
124
125impl Config {
126    /// Merge `other` over `self`, keeping existing values when `other` omits a field.
127    pub fn merge(mut self, other: Config) -> Self {
128        self.model = other.model.or(self.model);
129        self.websearch = other.websearch.or(self.websearch);
130        self.websearch_url = other.websearch_url.or(self.websearch_url);
131        self.reasoning_effort = other.reasoning_effort.or(self.reasoning_effort);
132        self.reasoning_summary = other.reasoning_summary.or(self.reasoning_summary);
133        self.tick_rate_ms = other.tick_rate_ms.or(self.tick_rate_ms);
134        self.mouse = other.mouse.or(self.mouse);
135        self.verbose = other.verbose.or(self.verbose);
136        self.theme = other.theme.or(self.theme);
137        self.session_dir = other.session_dir.or(self.session_dir);
138        self.default_workspace = other.default_workspace.or(self.default_workspace);
139        self.skill_dirs.extend(other.skill_dirs);
140        self.acp_agents.extend(other.acp_agents);
141        if other.context != ContextConfig::default() {
142            self.context = other.context;
143        }
144        self
145    }
146
147    /// Return a copy with ACP environment values redacted.
148    pub fn redacted(&self) -> Self {
149        let mut redacted = self.clone();
150        redacted.acp_agents = redacted
151            .acp_agents
152            .iter()
153            .map(|(name, agent)| (name.clone(), agent.redacted()))
154            .collect();
155        redacted
156    }
157}
158
159/// The fully resolved configuration after merging all layers.
160///
161/// Records where each loaded value came from so prompt inspection, sessions,
162/// and export can explain provenance without leaking secrets.
163#[derive(Clone, Debug)]
164pub struct EffectiveConfig {
165    /// Final resolved runtime config values.
166    pub config: Config,
167    /// Loaded config file layers in precedence order (global first, then project).
168    pub layers: Vec<LoadedConfigLayer>,
169    /// Per-key origin tracking.
170    pub origins: BTreeMap<String, ConfigOrigin>,
171    /// Non-fatal diagnostics produced during loading.
172    pub diagnostics: Vec<String>,
173}
174
175/// A single loaded config file layer.
176#[derive(Clone, Debug)]
177pub struct LoadedConfigLayer {
178    pub source: ConfigSource,
179    pub config: Config,
180    pub path: Option<PathBuf>,
181    /// Redacted path label safe for diagnostics and persisted metadata.
182    pub display_path: Option<String>,
183    /// Lowercase hex SHA-256 of file bytes.
184    pub hash: Option<String>,
185}
186
187/// Where a config value originated.
188#[derive(Clone, Copy, Debug, Eq, PartialEq)]
189pub enum ConfigSource {
190    Default,
191    GlobalFile,
192    ProjectFile,
193    Environment,
194    CliFlag,
195}
196
197impl ConfigSource {
198    pub fn as_str(self) -> &'static str {
199        match self {
200            ConfigSource::Default => "default",
201            ConfigSource::GlobalFile => "global",
202            ConfigSource::ProjectFile => "project",
203            ConfigSource::Environment => "env",
204            ConfigSource::CliFlag => "cli",
205        }
206    }
207}
208
209/// Provenance label for a single config key.
210#[derive(Clone, Debug, Eq, PartialEq)]
211pub struct ConfigOrigin {
212    pub source: ConfigSource,
213    pub detail: String,
214}
215
216impl Default for ConfigOrigin {
217    fn default() -> Self {
218        Self { source: ConfigSource::Default, detail: "default".to_string() }
219    }
220}
221
222/// The single supported global config path: `~/.thndrs/config.toml`.
223pub fn global_config_path() -> Option<PathBuf> {
224    utils::home_dir().map(|home| home.join(".thndrs").join("config.toml"))
225}
226
227/// The single supported project config path: `<workspace>/.thndrs/config.toml`.
228pub fn project_config_path(workspace: &Path) -> PathBuf {
229    workspace.join(".thndrs").join("config.toml")
230}
231
232/// Write the selected model into a TOML config file.
233///
234/// Preserves existing config content and only replaces or inserts the top-level
235/// `model` key. Nested table keys named `model` are left untouched.
236pub fn write_model_config(path: &Path, model: &str) -> std::io::Result<()> {
237    let existing = match fs::read_to_string(path) {
238        Ok(content) => content,
239        Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
240        Err(err) => return Err(err),
241    };
242    let next = upsert_top_level_toml_string(&existing, "model", model);
243    if let Some(parent) = path.parent() {
244        fs::create_dir_all(parent)?;
245    }
246    fs::write(path, next)
247}
248
249/// Write the selected model into a TOML config file only when no top-level
250/// `model` key exists. Returns whether a key was written.
251pub fn write_model_config_if_missing(path: &Path, model: &str) -> std::io::Result<bool> {
252    let existing = match fs::read_to_string(path) {
253        Ok(content) => content,
254        Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
255        Err(err) => return Err(err),
256    };
257    if has_top_level_toml_key(&existing, "model") {
258        return Ok(false);
259    }
260    let next = upsert_top_level_toml_string(&existing, "model", model);
261    if let Some(parent) = path.parent() {
262        fs::create_dir_all(parent)?;
263    }
264    fs::write(path, next)?;
265    Ok(true)
266}
267
268/// Return whether a TOML config file contains a top-level `model` key.
269pub fn model_config_has_model(path: &Path) -> std::io::Result<bool> {
270    let existing = match fs::read_to_string(path) {
271        Ok(content) => content,
272        Err(err) if err.kind() == std::io::ErrorKind::NotFound => return Ok(false),
273        Err(err) => return Err(err),
274    };
275    Ok(has_top_level_toml_key(&existing, "model"))
276}
277
278/// Write the selected model into the project config and return the path used.
279pub fn write_project_model(workspace: &Path, model: &str) -> std::io::Result<PathBuf> {
280    let path = project_config_path(workspace);
281    write_model_config(&path, model)?;
282    Ok(path)
283}
284
285/// Write the selected reasoning effort into a TOML config file.
286///
287/// Preserves existing config content and only replaces or inserts the top-level
288/// `reasoning_effort` key. Nested table keys with the same name are left untouched.
289pub fn write_reasoning_effort_config(path: &Path, effort: ReasoningEffort) -> std::io::Result<()> {
290    let existing = match fs::read_to_string(path) {
291        Ok(content) => content,
292        Err(err) if err.kind() == std::io::ErrorKind::NotFound => String::new(),
293        Err(err) => return Err(err),
294    };
295    let next = upsert_top_level_toml_string(&existing, "reasoning_effort", effort.label());
296    if let Some(parent) = path.parent() {
297        fs::create_dir_all(parent)?;
298    }
299    fs::write(path, next)
300}
301
302/// Write the selected reasoning effort into the project config and return the path used.
303pub fn write_project_reasoning_effort(workspace: &Path, effort: ReasoningEffort) -> std::io::Result<PathBuf> {
304    let path = project_config_path(workspace);
305    write_reasoning_effort_config(&path, effort)?;
306    Ok(path)
307}
308
309fn upsert_top_level_toml_string(content: &str, key: &str, value: &str) -> String {
310    let assignment = format!("{key} = {}\n", toml_basic_string(value));
311    let mut output = String::new();
312    let mut wrote = false;
313
314    for line in content.lines() {
315        let trimmed = line.trim_start();
316        if !wrote && is_toml_key_assignment(trimmed, key) {
317            output.push_str(&assignment);
318            wrote = true;
319            continue;
320        }
321        if !wrote && trimmed.starts_with('[') {
322            output.push_str(&assignment);
323            wrote = true;
324        }
325        output.push_str(line);
326        output.push('\n');
327    }
328
329    if !wrote {
330        if !output.is_empty() && !output.ends_with('\n') {
331            output.push('\n');
332        }
333        output.push_str(&assignment);
334    }
335
336    output
337}
338
339fn has_top_level_toml_key(content: &str, key: &str) -> bool {
340    for line in content.lines() {
341        let trimmed = line.trim_start();
342        if trimmed.starts_with('[') {
343            return false;
344        }
345        if is_toml_key_assignment(trimmed, key) {
346            return true;
347        }
348    }
349    false
350}
351
352fn is_toml_key_assignment(line: &str, key: &str) -> bool {
353    let Some(rest) = line.strip_prefix(key) else {
354        return false;
355    };
356    rest.trim_start().starts_with('=')
357}
358
359fn toml_basic_string(value: &str) -> String {
360    let mut out = String::from("\"");
361    for ch in value.chars() {
362        match ch {
363            '"' => out.push_str("\\\""),
364            '\\' => out.push_str("\\\\"),
365            '\n' => out.push_str("\\n"),
366            '\r' => out.push_str("\\r"),
367            '\t' => out.push_str("\\t"),
368            '\u{08}' => out.push_str("\\b"),
369            '\u{0C}' => out.push_str("\\f"),
370            ch if ch.is_control() => out.push_str(&format!("\\u{:04X}", ch as u32)),
371            ch => out.push(ch),
372        }
373    }
374    out.push('"');
375    out
376}
377
378/// Keys that look like secrets and must not appear in TOML config.
379fn is_secret_shaped_key(key: &str) -> bool {
380    let lower = key.to_lowercase();
381    lower.ends_with("_api_key")
382        || lower.ends_with("_token")
383        || lower.ends_with("_secret")
384        || lower.ends_with("_password")
385        || lower.ends_with("secret")
386        || lower.ends_with("password")
387}
388
389/// Check raw TOML text for secret-shaped keys before deserialization.
390fn check_for_secret_keys(content: &str) -> Result<(), ConfigError> {
391    let parsed: toml::Value = match toml::from_str(content) {
392        Ok(v) => v,
393        Err(_) => return Ok(()), // parse errors are caught later by load_file
394    };
395    check_value_for_secret_keys(&parsed, None)
396}
397
398fn check_value_for_secret_keys(value: &toml::Value, parent: Option<&str>) -> Result<(), ConfigError> {
399    match value {
400        toml::Value::Table(table) => {
401            for (key, value) in table {
402                let dotted = match parent {
403                    Some(parent) => format!("{parent}.{key}"),
404                    None => key.clone(),
405                };
406                if is_secret_shaped_key(key) {
407                    return Err(ConfigError::SecretInConfig { key: dotted });
408                }
409                check_value_for_secret_keys(value, Some(&dotted))?;
410            }
411            Ok(())
412        }
413        toml::Value::Array(values) => {
414            for value in values {
415                check_value_for_secret_keys(value, parent)?;
416            }
417            Ok(())
418        }
419        _ => Ok(()),
420    }
421}
422
423fn load_file(path: &Path) -> Result<(Config, String), ConfigError> {
424    let content = fs::read_to_string(path).map_err(|source| ConfigError::Read { path: path.to_path_buf(), source })?;
425    check_for_secret_keys(&content)?;
426    let config: Config =
427        toml::from_str(&content).map_err(|source| ConfigError::Parse { path: path.to_path_buf(), source })?;
428    validate_config(&config)?;
429    let hash = sha256_hex(content.as_bytes());
430    Ok((config, hash))
431}
432
433fn validate_config(config: &Config) -> Result<(), ConfigError> {
434    for (name, agent) in &config.acp_agents {
435        validate_acp_agent_name(name)?;
436        if agent.command.trim().is_empty() {
437            return Err(ConfigError::InvalidConfig {
438                key: format!("acp_agents.{name}.command"),
439                message: "command is required".to_string(),
440            });
441        }
442    }
443    Ok(())
444}
445
446/// Validate an ACP agent name accepted by `acp:<name>` model ids.
447pub fn validate_acp_agent_name(name: &str) -> Result<(), ConfigError> {
448    if name.is_empty()
449        || !name
450            .bytes()
451            .all(|b| b.is_ascii_alphanumeric() || b == b'_' || b == b'-')
452    {
453        return Err(ConfigError::InvalidConfig {
454            key: format!("acp_agents.{name}"),
455            message: "name must match [A-Za-z0-9_-]+".to_string(),
456        });
457    }
458    Ok(())
459}
460
461fn sha256_hex(bytes: &[u8]) -> String {
462    let mut hasher = Sha256::new();
463    hasher.update(bytes);
464    let result = hasher.finalize();
465    hex_encode(&result)
466}
467
468fn hex_encode(bytes: &[u8]) -> String {
469    let mut out = String::with_capacity(bytes.len() * 2);
470    for byte in bytes {
471        out.push_str(&format!("{byte:02x}"));
472    }
473    out
474}
475
476pub fn load_env(
477    env_vars: &[(String, String)], origins: &mut BTreeMap<String, ConfigOrigin>, _diagnostics: &mut [String],
478) -> Result<Config, ConfigError> {
479    let mut config = Config::default();
480
481    for (key, value) in env_vars {
482        if !key.starts_with("THNDRS_") {
483            continue;
484        }
485        let config_key = &key["THNDRS_".len()..];
486        let lower = config_key.to_lowercase();
487
488        match lower.as_str() {
489            "model" => {
490                config.model = Some(value.clone());
491                origins.insert("model".to_string(), env_origin(key));
492            }
493            "websearch" => {
494                config.websearch = Some(parse_websearch_env(key, value)?);
495                origins.insert("websearch".to_string(), env_origin(key));
496            }
497            "websearch_url" => {
498                config.websearch_url = Some(value.clone());
499                origins.insert("websearch_url".to_string(), env_origin(key));
500            }
501            "reasoning_effort" => {
502                config.reasoning_effort = Some(parse_reasoning_effort_env(key, value)?);
503                origins.insert("reasoning_effort".to_string(), env_origin(key));
504            }
505            "reasoning_summary" => {
506                config.reasoning_summary = Some(parse_reasoning_summary_env(key, value)?);
507                origins.insert("reasoning_summary".to_string(), env_origin(key));
508            }
509            "tick_rate_ms" => {
510                config.tick_rate_ms = Some(parse_u64_env(key, value)?);
511                origins.insert("tick_rate_ms".to_string(), env_origin(key));
512            }
513            "theme" => {
514                config.theme = Some(parse_theme_env(key, value)?);
515                origins.insert("theme".to_string(), env_origin(key));
516            }
517            "mouse" => {
518                config.mouse = Some(parse_bool_env(key, value)?);
519                origins.insert("mouse".to_string(), env_origin(key));
520            }
521            "verbose" => {
522                config.verbose = Some(parse_bool_env(key, value)?);
523                origins.insert("verbose".to_string(), env_origin(key));
524            }
525            "skill_dirs" => {
526                config.skill_dirs = parse_path_list_env(value);
527                origins.insert("skill_dirs".to_string(), env_origin(key));
528            }
529            "session_dir" => {
530                config.session_dir = Some(PathBuf::from(value));
531                origins.insert("session_dir".to_string(), env_origin(key));
532            }
533            "default_workspace" => {
534                config.default_workspace = Some(PathBuf::from(value));
535                origins.insert("default_workspace".to_string(), env_origin(key));
536            }
537            _ => {
538                return Err(ConfigError::UnknownEnv { name: key.clone() });
539            }
540        }
541    }
542
543    Ok(config)
544}
545
546fn env_origin(env_var: &str) -> ConfigOrigin {
547    ConfigOrigin { source: ConfigSource::Environment, detail: env_var.to_string() }
548}
549
550fn parse_bool_env(name: &str, value: &str) -> Result<bool, ConfigError> {
551    match value.to_lowercase().as_str() {
552        "1" | "true" | "yes" | "on" => Ok(true),
553        "0" | "false" | "no" | "off" => Ok(false),
554        _ => Err(ConfigError::InvalidEnv {
555            name: name.to_string(),
556            message: format!("expected one of 1, 0, true, false, yes, no, on, off (got '{value}')"),
557        }),
558    }
559}
560
561fn parse_u64_env(name: &str, value: &str) -> Result<u64, ConfigError> {
562    value.parse().map_err(|_| ConfigError::InvalidEnv {
563        name: name.to_string(),
564        message: format!("expected a positive integer (got '{value}')"),
565    })
566}
567
568fn parse_websearch_env(name: &str, value: &str) -> Result<WebSearchMode, ConfigError> {
569    match value.to_lowercase().as_str() {
570        "duckduckgo" => Ok(WebSearchMode::DuckDuckGo),
571        "searxng" => Ok(WebSearchMode::Searxng),
572        "none" => Ok(WebSearchMode::None),
573        _ => Err(ConfigError::InvalidEnv {
574            name: name.to_string(),
575            message: format!("must be one of duckduckgo, searxng, none (got '{value}')"),
576        }),
577    }
578}
579
580fn parse_reasoning_effort_env(name: &str, value: &str) -> Result<ReasoningEffort, ConfigError> {
581    ReasoningEffort::parse(value).ok_or_else(|| ConfigError::InvalidEnv {
582        name: name.to_string(),
583        message: format!("must be one of auto, on, none, minimal, low, medium, high, xhigh, max (got '{value}')"),
584    })
585}
586
587fn parse_reasoning_summary_env(name: &str, value: &str) -> Result<ReasoningSummary, ConfigError> {
588    ReasoningSummary::parse(value).ok_or_else(|| ConfigError::InvalidEnv {
589        name: name.to_string(),
590        message: format!("must be one of off, auto (got '{value}')"),
591    })
592}
593
594fn parse_theme_env(name: &str, value: &str) -> Result<Theme, ConfigError> {
595    match value.to_lowercase().as_str() {
596        "eldritch-minimal" | "eldritch_minimal" | "eldritchminimal" => Ok(Theme::EldritchMinimal),
597        "iceberg-dark" | "iceberg_dark" | "icebergdark" => Ok(Theme::IcebergDark),
598        "catppuccin-mocha" | "catppuccin_mocha" | "catppuccinmocha" => Ok(Theme::CatppuccinMocha),
599        _ => Err(ConfigError::InvalidEnv { name: name.to_string(), message: format!("unknown theme '{value}'") }),
600    }
601}
602
603fn parse_path_list_env(value: &str) -> Vec<PathBuf> {
604    let separator = if cfg!(windows) { ';' } else { ':' };
605    value
606        .split(separator)
607        .filter(|s| !s.trim().is_empty())
608        .map(PathBuf::from)
609        .collect()
610}
611
612/// Load and merge config layers, producing an [`EffectiveConfig`].
613///
614/// Precedence here is environment > project config > global config > defaults.
615/// Command-line flags are applied by [`crate::cli::Cli`] after parsing.
616pub fn load_effective(workspace: &Path, env_vars: &[(String, String)]) -> Result<EffectiveConfig, ConfigError> {
617    let mut layers = Vec::new();
618    let mut origins: BTreeMap<String, ConfigOrigin> = BTreeMap::new();
619    let mut diagnostics = Vec::new();
620    let cwd = process_cwd();
621    let mut merged = default_config(workspace, &cwd);
622
623    if let Some(ref global_path) = global_config_path()
624        && global_path.is_file()
625    {
626        let (mut global_config, hash) = load_file(global_path)?;
627        let display_path = global_path_display(global_path);
628        let base = global_path.parent().unwrap_or(workspace);
629        resolve_config_paths(&mut global_config, base);
630        layers.push(LoadedConfigLayer {
631            source: ConfigSource::GlobalFile,
632            config: global_config.redacted(),
633            path: Some(global_path.clone()),
634            display_path: Some(display_path.clone()),
635            hash: Some(hash),
636        });
637        record_origins(&global_config, ConfigSource::GlobalFile, &display_path, &mut origins);
638        merged = merged.merge(global_config);
639    }
640
641    let project_path = project_config_path(workspace);
642    if project_path.is_file() {
643        let (mut project_config, hash) = load_file(&project_path)?;
644        let display_path = project_path_display(&project_path, workspace);
645        let base = project_path.parent().unwrap_or(workspace);
646        resolve_config_paths(&mut project_config, base);
647        layers.push(LoadedConfigLayer {
648            source: ConfigSource::ProjectFile,
649            config: project_config.redacted(),
650            path: Some(project_path.clone()),
651            display_path: Some(display_path.clone()),
652            hash: Some(hash),
653        });
654        record_origins(&project_config, ConfigSource::ProjectFile, &display_path, &mut origins);
655        merged = merged.merge(project_config);
656    }
657
658    let mut env_config = load_env(env_vars, &mut origins, &mut diagnostics)?;
659    if has_any_value(&env_config) {
660        resolve_config_paths(&mut env_config, &cwd);
661        merged = merged.merge(env_config);
662    }
663
664    deduplicate_paths(&mut merged.skill_dirs);
665
666    for key in CONFIG_KEYS {
667        origins.entry(key.to_string()).or_default();
668    }
669
670    Ok(EffectiveConfig { config: merged, layers, origins, diagnostics })
671}
672
673/// Resolve the workspace used to find project config when `--cwd` is omitted.
674///
675/// Project config cannot be loaded until the workspace is known, so this uses
676/// only defaults, global config, and environment variables. The full effective
677/// config is loaded afterward from the returned workspace.
678pub fn default_workspace_before_project_config(env_vars: &[(String, String)]) -> Result<PathBuf, ConfigError> {
679    let cwd = process_cwd();
680    let mut merged = default_config(Path::new("."), &cwd);
681
682    if let Some(ref global_path) = global_config_path()
683        && global_path.is_file()
684    {
685        let (mut global_config, _) = load_file(global_path)?;
686        let base = global_path.parent().unwrap_or_else(|| Path::new("."));
687        resolve_config_paths(&mut global_config, base);
688        merged = merged.merge(global_config);
689    }
690
691    let mut origins = BTreeMap::new();
692    let mut diagnostics = Vec::new();
693    let mut env_config = load_env(env_vars, &mut origins, &mut diagnostics)?;
694    if has_any_value(&env_config) {
695        resolve_config_paths(&mut env_config, &cwd);
696        merged = merged.merge(env_config);
697    }
698
699    Ok(merged.default_workspace.unwrap_or(cwd))
700}
701
702fn record_origins(config: &Config, source: ConfigSource, detail: &str, origins: &mut BTreeMap<String, ConfigOrigin>) {
703    if config.model.is_some() {
704        origins.insert("model".to_string(), ConfigOrigin { source, detail: detail.to_string() });
705    }
706    if config.websearch.is_some() {
707        origins.insert(
708            "websearch".to_string(),
709            ConfigOrigin { source, detail: detail.to_string() },
710        );
711    }
712    if config.websearch_url.is_some() {
713        origins.insert(
714            "websearch_url".to_string(),
715            ConfigOrigin { source, detail: detail.to_string() },
716        );
717    }
718    if config.reasoning_effort.is_some() {
719        origins.insert(
720            "reasoning_effort".to_string(),
721            ConfigOrigin { source, detail: detail.to_string() },
722        );
723    }
724    if config.reasoning_summary.is_some() {
725        origins.insert(
726            "reasoning_summary".to_string(),
727            ConfigOrigin { source, detail: detail.to_string() },
728        );
729    }
730    if config.tick_rate_ms.is_some() {
731        origins.insert(
732            "tick_rate_ms".to_string(),
733            ConfigOrigin { source, detail: detail.to_string() },
734        );
735    }
736    if config.theme.is_some() {
737        origins.insert("theme".to_string(), ConfigOrigin { source, detail: detail.to_string() });
738    }
739    if config.mouse.is_some() {
740        origins.insert("mouse".to_string(), ConfigOrigin { source, detail: detail.to_string() });
741    }
742    if config.verbose.is_some() {
743        origins.insert(
744            "verbose".to_string(),
745            ConfigOrigin { source, detail: detail.to_string() },
746        );
747    }
748    if !config.skill_dirs.is_empty() {
749        origins.insert(
750            "skill_dirs".to_string(),
751            ConfigOrigin { source, detail: detail.to_string() },
752        );
753    }
754    if config.session_dir.is_some() {
755        origins.insert(
756            "session_dir".to_string(),
757            ConfigOrigin { source, detail: detail.to_string() },
758        );
759    }
760    if config.default_workspace.is_some() {
761        origins.insert(
762            "default_workspace".to_string(),
763            ConfigOrigin { source, detail: detail.to_string() },
764        );
765    }
766    if !config.acp_agents.is_empty() {
767        origins.insert(
768            "acp_agents".to_string(),
769            ConfigOrigin { source, detail: detail.to_string() },
770        );
771        for name in config.acp_agents.keys() {
772            origins.insert(
773                format!("acp_agents.{name}"),
774                ConfigOrigin { source, detail: detail.to_string() },
775            );
776        }
777    }
778}
779
780fn has_any_value(config: &Config) -> bool {
781    config.model.is_some()
782        || config.websearch.is_some()
783        || config.websearch_url.is_some()
784        || config.reasoning_effort.is_some()
785        || config.reasoning_summary.is_some()
786        || config.tick_rate_ms.is_some()
787        || config.theme.is_some()
788        || config.mouse.is_some()
789        || config.verbose.is_some()
790        || !config.skill_dirs.is_empty()
791        || config.session_dir.is_some()
792        || config.default_workspace.is_some()
793        || !config.acp_agents.is_empty()
794}
795
796fn default_config(workspace: &Path, cwd: &Path) -> Config {
797    Config {
798        model: None,
799        websearch: Some(WebSearchMode::DuckDuckGo),
800        websearch_url: None,
801        reasoning_effort: Some(ReasoningEffort::default()),
802        reasoning_summary: Some(ReasoningSummary::default()),
803        tick_rate_ms: Some(DEFAULT_TICK_RATE_MS),
804        mouse: Some(false),
805        verbose: Some(false),
806        theme: Some(Theme::default()),
807        skill_dirs: Vec::new(),
808        session_dir: Some(resolve_path(&workspace.join(".thndrs").join("sessions"), cwd)),
809        default_workspace: Some(cwd.to_path_buf()),
810        acp_agents: BTreeMap::new(),
811        context: ContextConfig::default(),
812    }
813}
814
815fn global_path_display(path: &Path) -> String {
816    if let Some(home) = utils::home_dir()
817        && let Ok(rel) = path.strip_prefix(&home)
818    {
819        return format!("~/{}", rel.display());
820    }
821    path.display().to_string()
822}
823
824/// Render global config path using `~` when it is under the current home directory.
825pub fn global_config_path_display(path: &Path) -> String {
826    global_path_display(path)
827}
828
829fn project_path_display(path: &Path, workspace: &Path) -> String {
830    if let Ok(rel) = path.strip_prefix(workspace) {
831        return rel.display().to_string();
832    }
833    path.display().to_string()
834}
835
836/// Render project config paths relative to workspace when possible.
837pub fn project_config_path_display(path: &Path, workspace: &Path) -> String {
838    project_path_display(path, workspace)
839}
840
841/// Resolve relative path config values against the config file that declared them.
842///
843/// `skill_dirs`, `session_dir`, and `default_workspace` are resolved relative
844/// to their declaring file's parent directory. Environment values resolve
845/// against the process cwd. CLI values resolve against the process cwd.
846#[cfg(test)]
847pub fn resolve_paths(config: &mut Config, layers: &[LoadedConfigLayer], workspace: &Path) {
848    let mut resolved_skill_dirs = Vec::new();
849    for layer in layers {
850        let base = layer.path.as_ref().and_then(|p| p.parent()).unwrap_or(workspace);
851        for dir in &layer.config.skill_dirs {
852            let resolved = resolve_path(dir, base);
853            if !resolved_skill_dirs.contains(&resolved) {
854                resolved_skill_dirs.push(resolved);
855            }
856        }
857    }
858
859    config.skill_dirs = resolved_skill_dirs;
860    resolve_config_paths(config, workspace);
861}
862
863fn resolve_config_paths(config: &mut Config, base: &Path) {
864    for dir in &mut config.skill_dirs {
865        *dir = resolve_path(dir, base);
866    }
867    if let Some(session_dir) = &mut config.session_dir {
868        *session_dir = resolve_path(session_dir, base);
869    }
870    if let Some(default_workspace) = &mut config.default_workspace {
871        *default_workspace = resolve_path(default_workspace, base);
872    }
873}
874
875fn resolve_path(path: &Path, base: &Path) -> PathBuf {
876    if path.is_absolute() { normalize_path(path) } else { normalize_path(base.join(path)) }
877}
878
879pub fn resolve_cli_path(path: &Path) -> PathBuf {
880    resolve_path(path, &process_cwd())
881}
882
883fn process_cwd() -> PathBuf {
884    std::env::current_dir()
885        .map(normalize_path)
886        .unwrap_or_else(|_| PathBuf::from("."))
887}
888
889fn normalize_path(path: impl AsRef<Path>) -> PathBuf {
890    let mut normalized = PathBuf::new();
891    for component in path.as_ref().components() {
892        match component {
893            Component::CurDir => {}
894            Component::ParentDir => {
895                normalized.pop();
896            }
897            Component::Prefix(prefix) => normalized.push(prefix.as_os_str()),
898            Component::RootDir => normalized.push(component.as_os_str()),
899            Component::Normal(part) => normalized.push(part),
900        }
901    }
902    normalized
903}
904
905pub fn deduplicate_paths(paths: &mut Vec<PathBuf>) {
906    let mut deduped = Vec::new();
907    for path in paths.drain(..) {
908        if !deduped.contains(&path) {
909            deduped.push(path);
910        }
911    }
912    *paths = deduped;
913}
914
915#[cfg(test)]
916mod tests;