Skip to main content

thndrs_lib/cli/commands/
config.rs

1//! `thndrs config` command definitions.
2
3use crate::config;
4use crate::context;
5use crate::tools::shell::redact_secrets;
6use clap::{Args, Subcommand};
7use std::fs;
8use std::io::{self, BufRead, Write};
9use std::path::{Path, PathBuf};
10use std::process::Command;
11
12use crate::cli::{Cli, WebSearchMode};
13
14/// Config file inspection and editing commands.
15#[derive(Clone, Debug, Eq, PartialEq, Subcommand)]
16pub enum ConfigCommand {
17    /// Print global and project config paths.
18    Path,
19    /// Show effective config and diagnostics.
20    Show(ConfigShowCommand),
21    /// Open a config file in `$EDITOR`.
22    Edit(ConfigEditCommand),
23}
24
25/// `thndrs config show` options.
26#[derive(Clone, Debug, Eq, PartialEq, Args)]
27pub struct ConfigShowCommand {
28    /// Print values in redacted format.
29    #[arg(long)]
30    pub redacted: bool,
31}
32
33/// `thndrs config edit` options.
34#[derive(Clone, Debug, Eq, PartialEq, Args)]
35pub struct ConfigEditCommand {
36    /// Edit the global config file.
37    #[arg(long, conflicts_with = "project")]
38    pub global: bool,
39    /// Edit the project config file.
40    #[arg(long, conflicts_with = "global")]
41    pub project: bool,
42}
43
44/// Run `thndrs config`.
45pub fn run(cli: &Cli, command: &ConfigCommand) -> io::Result<()> {
46    let stdout = io::stdout();
47    let mut lock = stdout.lock();
48    run_with_writer(cli, command, &mut lock)
49}
50
51/// Run `thndrs config` with an injected writer.
52pub fn run_with_writer<W: Write>(cli: &Cli, command: &ConfigCommand, writer: &mut W) -> io::Result<()> {
53    match command {
54        ConfigCommand::Path => run_config_path(cli, writer),
55        ConfigCommand::Show(command) => run_config_show(cli, command, writer),
56        ConfigCommand::Edit(command) => run_config_edit(cli, command, writer),
57    }
58}
59
60fn run_config_path<W: Write>(cli: &Cli, writer: &mut W) -> io::Result<()> {
61    let workspace = context::discover_workspace_root(&cli.cwd);
62    let global_path = match config::global_config_path() {
63        Some(path) => config::global_config_path_display(&path),
64        None => String::from("<not available>"),
65    };
66    let project_path = config::project_config_path(&workspace);
67
68    writeln!(writer, "global: {global_path}")?;
69    writeln!(
70        writer,
71        "project: {}",
72        config::project_config_path_display(&project_path, &workspace),
73    )?;
74    Ok(())
75}
76
77fn run_config_show<W: Write>(cli: &Cli, command: &ConfigShowCommand, writer: &mut W) -> io::Result<()> {
78    if !command.redacted {
79        return Err(io::Error::new(
80            io::ErrorKind::InvalidInput,
81            "thndrs config show requires --redacted for safe output",
82        ));
83    }
84
85    let workspace = context::discover_workspace_root(&cli.cwd);
86    let env_vars: Vec<(String, String)> = std::env::vars().collect();
87    let effective = config::load_effective(&workspace, &env_vars).map_err(io::Error::other)?;
88    let effective_config = effective.config.redacted();
89
90    writeln!(writer, "effective_config:")?;
91    write_config(&effective_config, writer)?;
92
93    writeln!(writer, "loaded_files:")?;
94    if effective.layers.is_empty() {
95        writeln!(writer, "  <none>")?;
96    } else {
97        for layer in &effective.layers {
98            let path = layer.display_path.as_deref().unwrap_or("<none>");
99            let hash = layer.hash.as_deref().unwrap_or("<none>");
100            writeln!(writer, "  {}: {} ({hash})", layer.source.as_str(), path)?;
101        }
102    }
103
104    writeln!(writer, "origins:")?;
105    for (key, origin) in &effective.origins {
106        writeln!(writer, "  {key}: {}: {}", origin.source.as_str(), origin.detail)?;
107    }
108
109    writeln!(writer, "diagnostics:")?;
110    if effective.diagnostics.is_empty() {
111        writeln!(writer, "  <none>")?;
112    } else {
113        for diagnostic in &effective.diagnostics {
114            writeln!(writer, "  - {}", redact_secrets(diagnostic))?;
115        }
116    }
117
118    Ok(())
119}
120
121fn run_config_edit<W: Write>(cli: &Cli, command: &ConfigEditCommand, writer: &mut W) -> io::Result<()> {
122    let (scope, path) = select_config_path(cli, command)?;
123    let mut input = io::stdin().lock();
124    ensure_parent_directory(command, &path, &mut input, writer)?;
125
126    if !path.exists() {
127        fs::write(&path, "").map_err(|error| {
128            io::Error::new(
129                error.kind(),
130                format!("failed to create {} config file {}: {error}", scope, path.display()),
131            )
132        })?;
133    }
134
135    let editor = resolve_editor(std::env::var("EDITOR").ok(), scope, &path, writer)?;
136    launch_editor(&editor, &path)
137}
138
139fn resolve_editor<W: Write>(
140    editor: Option<String>, scope: ConfigScope, path: &Path, writer: &mut W,
141) -> io::Result<String> {
142    editor.filter(|editor| !editor.trim().is_empty()).ok_or_else(|| {
143        let _ = writeln!(writer, "cannot open {scope} config file because EDITOR is not set");
144        let _ = writeln!(writer, "set EDITOR to your preferred editor and re-run this command");
145        let _ = writeln!(writer, "path: {}", path.display());
146        io::Error::new(io::ErrorKind::NotFound, "$EDITOR is not set")
147    })
148}
149
150fn select_config_path(cli: &Cli, command: &ConfigEditCommand) -> io::Result<(ConfigScope, PathBuf)> {
151    let workspace = context::discover_workspace_root(&cli.cwd);
152
153    match (command.global, command.project) {
154        (true, false) => config::global_config_path()
155            .map(|path| (ConfigScope::Global, path))
156            .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "HOME is not available")),
157        (false, true) => Ok((ConfigScope::Project, config::project_config_path(&workspace))),
158        (true, true) => Err(io::Error::new(
159            io::ErrorKind::InvalidInput,
160            "--global and --project cannot both be set",
161        )),
162        (false, false) => Err(io::Error::new(
163            io::ErrorKind::InvalidInput,
164            "choose one of --global or --project for config edit",
165        )),
166    }
167}
168
169#[derive(Clone, Copy, Debug, Eq, PartialEq)]
170enum ConfigScope {
171    Global,
172    Project,
173}
174
175impl std::fmt::Display for ConfigScope {
176    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
177        match self {
178            Self::Global => f.write_str("global"),
179            Self::Project => f.write_str("project"),
180        }
181    }
182}
183
184fn ensure_parent_directory<W: Write, R: BufRead>(
185    command: &ConfigEditCommand, path: &Path, input: &mut R, writer: &mut W,
186) -> io::Result<()> {
187    let parent = match path.parent() {
188        Some(parent) => parent,
189        None => return Ok(()),
190    };
191
192    if parent.exists() {
193        return Ok(());
194    }
195
196    let scope = if command.global { "global" } else { "project" };
197    writeln!(writer, "{scope} config directory does not exist: {}", parent.display())?;
198    if !confirm_parent_creation(input, writer)? {
199        return Err(io::Error::new(
200            io::ErrorKind::InvalidInput,
201            "parent directory creation declined",
202        ));
203    }
204
205    fs::create_dir_all(parent).map_err(|error| {
206        io::Error::new(
207            error.kind(),
208            format!("failed to create config directory {}: {error}", parent.display()),
209        )
210    })?;
211
212    Ok(())
213}
214
215fn confirm_parent_creation<W: Write, R: BufRead>(input: &mut R, writer: &mut W) -> io::Result<bool> {
216    write!(writer, "create it before opening the editor? [y/N]: ")?;
217    writer.flush()?;
218
219    let mut response = String::new();
220    input.read_line(&mut response).map_err(io::Error::other)?;
221    let normalized = response.trim().to_ascii_lowercase();
222    if normalized.is_empty() { Ok(false) } else { Ok(normalized == "y" || normalized == "yes") }
223}
224
225fn launch_editor(editor: &str, path: &Path) -> io::Result<()> {
226    let mut parts = editor.split_whitespace();
227    let program = parts
228        .next()
229        .filter(|value| !value.is_empty())
230        .ok_or_else(|| io::Error::new(io::ErrorKind::InvalidInput, "$EDITOR is invalid: empty command"))?;
231
232    let status = Command::new(program)
233        .args(parts)
234        .arg(path)
235        .status()
236        .map_err(io::Error::other)?;
237    if status.success() {
238        return Ok(());
239    }
240
241    Err(io::Error::other(format!(
242        "editor exited with status {}",
243        status
244            .code()
245            .map(|code| code.to_string())
246            .unwrap_or_else(|| String::from("aborted"))
247    )))
248}
249
250fn write_config<W: Write>(config: &config::Config, writer: &mut W) -> io::Result<()> {
251    writeln!(writer, "  model: {}", config.model.as_deref().unwrap_or("<unset>"))?;
252    writeln!(
253        writer,
254        "  websearch: {}",
255        config.websearch.unwrap_or(WebSearchMode::DuckDuckGo).label()
256    )?;
257    writeln!(
258        writer,
259        "  reasoning_effort: {}",
260        config.reasoning_effort.unwrap_or_default().label()
261    )?;
262    writeln!(
263        writer,
264        "  reasoning_summary: {}",
265        config.reasoning_summary.unwrap_or_default().label()
266    )?;
267    writeln!(writer, "  tick_rate_ms: {}", config.tick_rate_ms.unwrap_or(0))?;
268    writeln!(writer, "  mouse: {}", config.mouse.unwrap_or(false))?;
269    writeln!(writer, "  verbose: {}", config.verbose.unwrap_or(false))?;
270    writeln!(writer, "  theme: {:?}", config.theme.unwrap_or_default())?;
271    if config.skill_dirs.is_empty() {
272        writeln!(writer, "  skill_dirs: []")?;
273    } else {
274        writeln!(writer, "  skill_dirs:")?;
275        for dir in &config.skill_dirs {
276            writeln!(writer, "    {}", dir.display())?;
277        }
278    }
279
280    if let Some(session_dir) = &config.session_dir {
281        writeln!(writer, "  session_dir: {}", session_dir.display())?;
282    } else {
283        writeln!(writer, "  session_dir: <unset>")?;
284    }
285
286    if let Some(default_workspace) = &config.default_workspace {
287        writeln!(writer, "  default_workspace: {}", default_workspace.display())?;
288    } else {
289        writeln!(writer, "  default_workspace: <unset>")?;
290    }
291
292    if config.acp_agents.is_empty() {
293        writeln!(writer, "  acp_agents: []")?;
294    } else {
295        writeln!(writer, "  acp_agents:")?;
296        for (name, agent) in &config.acp_agents {
297            writeln!(writer, "    {name}:")?;
298            writeln!(writer, "      command: {}", agent.command)?;
299            writeln!(writer, "      args: {:?}", agent.args)?;
300            if agent.env.is_empty() {
301                writeln!(writer, "      env: []")?;
302            } else {
303                writeln!(writer, "      env:")?;
304                for (key, value) in &agent.env {
305                    writeln!(writer, "        {key} = {value}")?;
306                }
307            }
308            writeln!(writer, "      enabled: {}", agent.enabled)?;
309            writeln!(writer, "      timeout_secs: {}", agent.timeout_secs)?;
310        }
311    }
312
313    Ok(())
314}
315
316#[cfg(test)]
317mod tests {
318    use super::*;
319    use crate::cli::commands::config::{ConfigCommand, ConfigShowCommand};
320    use std::io::Cursor;
321
322    fn run_config_output(cli: &Cli, command: &ConfigCommand) -> io::Result<String> {
323        let mut output = Cursor::new(Vec::new());
324        run_with_writer(cli, command, &mut output)?;
325        String::from_utf8(output.into_inner())
326            .map_err(|_| io::Error::new(io::ErrorKind::InvalidData, "output is not valid UTF-8"))
327    }
328
329    #[test]
330    fn config_path_prints_paths() {
331        let temp = tempfile::tempdir().expect("tempdir");
332        let workspace = temp.path().join("workspace");
333        std::fs::create_dir_all(&workspace).expect("create workspace");
334
335        let cli = Cli::try_parse_from([
336            "thndrs",
337            "--cwd",
338            workspace.to_str().expect("workspace path"),
339            "config",
340            "path",
341        ])
342        .expect("parse config path");
343        let output = run_config_output(&cli, &ConfigCommand::Path).expect("run path");
344
345        assert!(output.contains("global: "));
346        assert!(output.contains("project: .thndrs/config.toml"));
347    }
348
349    #[test]
350    fn config_show_requires_redacted() {
351        let cli = Cli::try_parse_from(["thndrs", "config", "show"]).expect("parse show");
352        let error = run_with_writer(
353            &cli,
354            &ConfigCommand::Show(ConfigShowCommand { redacted: false }),
355            &mut std::io::sink(),
356        )
357        .expect_err("show should require --redacted");
358        assert_eq!(error.kind(), io::ErrorKind::InvalidInput);
359        assert!(error.to_string().contains("--redacted"));
360    }
361
362    #[test]
363    fn config_show_masks_acp_env_values() {
364        let tmp = tempfile::tempdir().expect("tempdir");
365        let workspace = tmp.path().join("workspace");
366        std::fs::create_dir_all(workspace.join(".thndrs")).expect("create config dir");
367        std::fs::write(
368            workspace.join(".thndrs").join("config.toml"),
369            r#"
370            [acp_agents.local]
371            command = "agent"
372            env = { FOO = "plain-secret" }
373            "#,
374        )
375        .expect("write config");
376
377        let cli = Cli::try_parse_from([
378            "thndrs",
379            "--cwd",
380            workspace.to_str().expect("workspace path"),
381            "config",
382            "show",
383            "--redacted",
384        ])
385        .expect("parse show");
386        let output =
387            run_config_output(&cli, &ConfigCommand::Show(ConfigShowCommand { redacted: true })).expect("run show");
388
389        assert!(output.contains("effective_config:"));
390        assert!(output.contains("[redacted]"));
391        assert!(!output.contains("plain-secret"));
392        assert!(output.contains("loaded_files:"));
393        assert!(output.contains("origins:"));
394        assert!(output.contains("diagnostics:"));
395    }
396
397    #[test]
398    fn config_edit_reports_missing_editor() {
399        let temp = tempfile::tempdir().expect("tempdir");
400        let path = temp.path().join(".thndrs").join("config.toml");
401        let mut output = Cursor::new(Vec::new());
402        let err = resolve_editor(None, ConfigScope::Project, &path, &mut output);
403        let output = String::from_utf8(output.into_inner()).expect("output utf8");
404
405        assert!(err.is_err());
406        let err = err.expect_err("missing editor error");
407        assert!(matches!(
408            err.kind(),
409            io::ErrorKind::NotFound | io::ErrorKind::InvalidInput
410        ));
411        assert!(output.contains("cannot open project config file because EDITOR is not set"));
412        assert!(output.contains("set EDITOR to your preferred editor and re-run this command"));
413    }
414}