1use 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#[derive(Clone, Debug, Deserialize, Eq, PartialEq)]
66#[serde(default, deny_unknown_fields)]
67pub struct AcpAgentConfig {
68 pub command: String,
70 pub args: Vec<String>,
72 pub env: BTreeMap<String, String>,
74 pub enabled: bool,
76 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 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
98pub type AcpAgentsConfig = BTreeMap<String, AcpAgentConfig>;
100
101#[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 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 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#[derive(Clone, Debug)]
164pub struct EffectiveConfig {
165 pub config: Config,
167 pub layers: Vec<LoadedConfigLayer>,
169 pub origins: BTreeMap<String, ConfigOrigin>,
171 pub diagnostics: Vec<String>,
173}
174
175#[derive(Clone, Debug)]
177pub struct LoadedConfigLayer {
178 pub source: ConfigSource,
179 pub config: Config,
180 pub path: Option<PathBuf>,
181 pub display_path: Option<String>,
183 pub hash: Option<String>,
185}
186
187#[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#[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
222pub fn global_config_path() -> Option<PathBuf> {
224 utils::home_dir().map(|home| home.join(".thndrs").join("config.toml"))
225}
226
227pub fn project_config_path(workspace: &Path) -> PathBuf {
229 workspace.join(".thndrs").join("config.toml")
230}
231
232pub 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
249pub 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
268pub 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
278pub 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
285pub 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
302pub 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
378fn 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
389fn 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(()), };
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
446pub 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
612pub 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
673pub 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
824pub 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
836pub fn project_config_path_display(path: &Path, workspace: &Path) -> String {
838 project_path_display(path, workspace)
839}
840
841#[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;