Skip to main content

actr_cli/commands/
config.rs

1//! Config command implementation - manage CLI configuration layers.
2//!
3//! Supported locations:
4//! - Global: `~/.actr/config.toml`
5//! - Local override: `.actr/config.toml`
6
7use crate::config::loader::{global_config_path, load_cli_config, local_config_path};
8use crate::config::resolver::resolve_effective_cli_config;
9use crate::config::schema::CliConfig;
10use crate::core::{Command, CommandContext, CommandResult, ComponentType};
11use anyhow::{Context, Result, bail};
12use async_trait::async_trait;
13use clap::{Args, Subcommand};
14use owo_colors::OwoColorize;
15use std::path::{Path, PathBuf};
16use toml::Value;
17
18/// All known schema field paths — used to validate `set` keys.
19const KNOWN_KEYS: &[&str] = &[
20    "mfr.manufacturer",
21    "mfr.keychain",
22    "codegen.language",
23    "codegen.output",
24    "codegen.clean_before_generate",
25    "cache.dir",
26    "cache.auto_lock",
27    "cache.prefer_cache",
28    "ui.format",
29    "ui.verbose",
30    "ui.color",
31    "ui.non_interactive",
32    "network.signaling_url",
33    "network.ais_endpoint",
34    "network.realm_id",
35    "network.realm_secret",
36    "storage.hyper_data_dir",
37];
38
39fn parse_toml_document_value(content: &str, path: impl std::fmt::Display) -> Result<Value> {
40    let table = toml::from_str::<toml::Table>(content)
41        .with_context(|| format!("Failed to parse {path}"))?;
42    Ok(Value::Table(table))
43}
44
45#[derive(Args, Clone)]
46pub struct ConfigCommand {
47    /// Read or write the global CLI config (~/.actr/config.toml)
48    #[arg(long, conflicts_with = "local")]
49    pub global: bool,
50
51    /// Read or write the project-local CLI config (.actr/config.toml)
52    #[arg(long, conflicts_with = "global")]
53    pub local: bool,
54
55    #[command(subcommand)]
56    pub command: ConfigSubcommand,
57}
58
59#[derive(Subcommand, Clone)]
60pub enum ConfigSubcommand {
61    /// Set a configuration key to a value
62    Set {
63        /// Configuration key (e.g., mfr.manufacturer)
64        key: String,
65        /// Value to assign
66        value: String,
67    },
68    /// Get the current value of a configuration key
69    Get {
70        /// Configuration key (e.g., mfr.manufacturer)
71        key: String,
72    },
73    /// List all known schema fields with current effective values
74    List,
75    /// Show the raw TOML of the active scope
76    Show {
77        #[arg(long, default_value = "toml")]
78        format: OutputFormat,
79    },
80    /// Remove a configuration key
81    Unset {
82        /// Configuration key to remove (e.g., mfr.manufacturer)
83        key: String,
84    },
85    /// Validate syntax and schema of all config files
86    Test,
87}
88
89#[derive(Debug, Clone, clap::ValueEnum, Default)]
90pub enum OutputFormat {
91    #[default]
92    Toml,
93    Json,
94    Yaml,
95}
96
97#[derive(Clone, Copy, Debug, Eq, PartialEq)]
98enum ConfigScope {
99    Global,
100    Local,
101    Merged,
102}
103
104#[async_trait]
105impl Command for ConfigCommand {
106    async fn execute(&self, _ctx: &CommandContext) -> Result<CommandResult> {
107        match &self.command {
108            ConfigSubcommand::Set { key, value } => self.set_config(key, value).await,
109            ConfigSubcommand::Get { key } => self.get_config(key).await,
110            ConfigSubcommand::List => self.list_config().await,
111            ConfigSubcommand::Show { format } => self.show_config(format).await,
112            ConfigSubcommand::Unset { key } => self.unset_config(key).await,
113            ConfigSubcommand::Test => self.test_config().await,
114        }
115    }
116
117    fn required_components(&self) -> Vec<ComponentType> {
118        vec![]
119    }
120
121    fn name(&self) -> &str {
122        "config"
123    }
124
125    fn description(&self) -> &str {
126        "Manage layered CLI configuration (~/.actr/config.toml and .actr/config.toml)"
127    }
128}
129
130impl ConfigCommand {
131    fn read_scope(&self) -> ConfigScope {
132        if self.global {
133            ConfigScope::Global
134        } else if self.local {
135            ConfigScope::Local
136        } else {
137            ConfigScope::Merged
138        }
139    }
140
141    fn write_scope(&self) -> ConfigScope {
142        if self.global {
143            ConfigScope::Global
144        } else if self.local || Path::new("manifest.toml").exists() || Path::new(".actr").exists() {
145            ConfigScope::Local
146        } else {
147            ConfigScope::Global
148        }
149    }
150
151    fn scope_label(scope: ConfigScope) -> &'static str {
152        match scope {
153            ConfigScope::Global => "global",
154            ConfigScope::Local => "local",
155            ConfigScope::Merged => "merged",
156        }
157    }
158
159    fn scope_path(scope: ConfigScope) -> Result<PathBuf> {
160        match scope {
161            ConfigScope::Global => Ok(global_config_path()?),
162            ConfigScope::Local => Ok(local_config_path()),
163            ConfigScope::Merged => bail!("Merged scope does not map to a single file"),
164        }
165    }
166
167    /// Load the raw TOML Value for a specific scope.
168    fn load_scope_value(&self, scope: ConfigScope) -> Result<Value> {
169        match scope {
170            ConfigScope::Global => {
171                let path = global_config_path()?;
172                if !path.exists() {
173                    return Ok(Value::Table(toml::map::Map::new()));
174                }
175                let content = std::fs::read_to_string(&path)
176                    .with_context(|| format!("Failed to read {}", path.display()))?;
177                parse_toml_document_value(&content, path.display())
178            }
179            ConfigScope::Local => {
180                let path = local_config_path();
181                if !path.exists() {
182                    return Ok(Value::Table(toml::map::Map::new()));
183                }
184                let content = std::fs::read_to_string(&path)
185                    .with_context(|| format!("Failed to read {}", path.display()))?;
186                parse_toml_document_value(&content, path.display())
187            }
188            ConfigScope::Merged => self.load_merged_value(),
189        }
190    }
191
192    fn load_merged_value(&self) -> Result<Value> {
193        let global_path = global_config_path()?;
194        let mut merged = if global_path.exists() {
195            let content = std::fs::read_to_string(&global_path)
196                .with_context(|| format!("Failed to read {}", global_path.display()))?;
197            parse_toml_document_value(&content, global_path.display())?
198        } else {
199            Value::Table(toml::map::Map::new())
200        };
201
202        let local_path = local_config_path();
203        if local_path.exists() {
204            let content = std::fs::read_to_string(&local_path)
205                .with_context(|| format!("Failed to read {}", local_path.display()))?;
206            let local_value = parse_toml_document_value(&content, local_path.display())?;
207            Self::merge_values(&mut merged, local_value);
208        }
209        Ok(merged)
210    }
211
212    fn merge_values(base: &mut Value, overlay: Value) {
213        match (base, overlay) {
214            (Value::Table(base_table), Value::Table(overlay_table)) => {
215                for (key, overlay_value) in overlay_table {
216                    if let Some(base_value) = base_table.get_mut(&key) {
217                        Self::merge_values(base_value, overlay_value);
218                    } else {
219                        base_table.insert(key, overlay_value);
220                    }
221                }
222            }
223            (base_slot, overlay_value) => *base_slot = overlay_value,
224        }
225    }
226
227    fn get_nested_value<'a>(value: &'a Value, key: &str) -> Option<&'a Value> {
228        let mut current = value;
229        for part in key.split('.') {
230            current = match current {
231                Value::Table(table) => table.get(part)?,
232                _ => return None,
233            };
234        }
235        Some(current)
236    }
237
238    fn write_scope_file(scope: ConfigScope, config: &CliConfig) -> Result<PathBuf> {
239        let path = Self::scope_path(scope)?;
240        if let Some(parent) = path.parent() {
241            std::fs::create_dir_all(parent)
242                .with_context(|| format!("Failed to create {}", parent.display()))?;
243        }
244        let content = toml::to_string_pretty(config)
245            .with_context(|| format!("Failed to serialize config for {}", path.display()))?;
246        std::fs::write(&path, content)
247            .with_context(|| format!("Failed to write {}", path.display()))?;
248        Ok(path)
249    }
250
251    /// Apply a key=value setting to a `CliConfig` struct.
252    fn apply_key_to_config(config: &mut CliConfig, key: &str, raw_value: &str) -> Result<()> {
253        // Parse the value as TOML so we handle booleans/numbers correctly
254        let parsed_value: Value = raw_value
255            .parse::<Value>()
256            .unwrap_or_else(|_| Value::String(raw_value.to_string()));
257
258        match key {
259            "mfr.manufacturer" => {
260                config.mfr.manufacturer = Some(value_to_string(&parsed_value)?);
261            }
262            "mfr.keychain" => {
263                config.mfr.keychain = Some(value_to_string(&parsed_value)?);
264            }
265            "codegen.language" => {
266                config.codegen.language = Some(value_to_string(&parsed_value)?);
267            }
268            "codegen.output" => {
269                config.codegen.output = Some(value_to_string(&parsed_value)?);
270            }
271            "codegen.clean_before_generate" => {
272                config.codegen.clean_before_generate = Some(value_to_bool(&parsed_value, key)?);
273            }
274            "cache.dir" => {
275                config.cache.dir = Some(value_to_string(&parsed_value)?);
276            }
277            "cache.auto_lock" => {
278                config.cache.auto_lock = Some(value_to_bool(&parsed_value, key)?);
279            }
280            "cache.prefer_cache" => {
281                config.cache.prefer_cache = Some(value_to_bool(&parsed_value, key)?);
282            }
283            "network.signaling_url" => {
284                config.network.signaling_url = Some(value_to_string(&parsed_value)?);
285            }
286            "network.ais_endpoint" => {
287                config.network.ais_endpoint = Some(value_to_string(&parsed_value)?);
288            }
289            "network.realm_id" => {
290                config.network.realm_id = Some(value_to_u32(&parsed_value, key)?);
291            }
292            "network.realm_secret" => {
293                config.network.realm_secret = Some(value_to_string(&parsed_value)?);
294            }
295            "storage.hyper_data_dir" => {
296                config.storage.hyper_data_dir = Some(value_to_string(&parsed_value)?);
297            }
298            "ui.format" => {
299                config.ui.format = Some(value_to_string(&parsed_value)?);
300            }
301            "ui.verbose" => {
302                config.ui.verbose = Some(value_to_bool(&parsed_value, key)?);
303            }
304            "ui.color" => {
305                config.ui.color = Some(value_to_string(&parsed_value)?);
306            }
307            "ui.non_interactive" => {
308                config.ui.non_interactive = Some(value_to_bool(&parsed_value, key)?);
309            }
310            other => {
311                bail!(
312                    "Unknown configuration key '{}'. Known keys:\n{}",
313                    other,
314                    KNOWN_KEYS.join("\n")
315                );
316            }
317        }
318        Ok(())
319    }
320
321    /// Remove a key from a `CliConfig` struct.
322    fn unset_key_from_config(config: &mut CliConfig, key: &str) -> Result<bool> {
323        let was_set = match key {
324            "mfr.manufacturer" => {
325                let had = config.mfr.manufacturer.is_some();
326                config.mfr.manufacturer = None;
327                had
328            }
329            "mfr.keychain" => {
330                let had = config.mfr.keychain.is_some();
331                config.mfr.keychain = None;
332                had
333            }
334            "codegen.language" => {
335                let had = config.codegen.language.is_some();
336                config.codegen.language = None;
337                had
338            }
339            "codegen.output" => {
340                let had = config.codegen.output.is_some();
341                config.codegen.output = None;
342                had
343            }
344            "codegen.clean_before_generate" => {
345                let had = config.codegen.clean_before_generate.is_some();
346                config.codegen.clean_before_generate = None;
347                had
348            }
349            "cache.dir" => {
350                let had = config.cache.dir.is_some();
351                config.cache.dir = None;
352                had
353            }
354            "cache.auto_lock" => {
355                let had = config.cache.auto_lock.is_some();
356                config.cache.auto_lock = None;
357                had
358            }
359            "cache.prefer_cache" => {
360                let had = config.cache.prefer_cache.is_some();
361                config.cache.prefer_cache = None;
362                had
363            }
364            "network.signaling_url" => {
365                let had = config.network.signaling_url.is_some();
366                config.network.signaling_url = None;
367                had
368            }
369            "network.ais_endpoint" => {
370                let had = config.network.ais_endpoint.is_some();
371                config.network.ais_endpoint = None;
372                had
373            }
374            "network.realm_id" => {
375                let had = config.network.realm_id.is_some();
376                config.network.realm_id = None;
377                had
378            }
379            "network.realm_secret" => {
380                let had = config.network.realm_secret.is_some();
381                config.network.realm_secret = None;
382                had
383            }
384            "storage.hyper_data_dir" => {
385                let had = config.storage.hyper_data_dir.is_some();
386                config.storage.hyper_data_dir = None;
387                had
388            }
389            "ui.format" => {
390                let had = config.ui.format.is_some();
391                config.ui.format = None;
392                had
393            }
394            "ui.verbose" => {
395                let had = config.ui.verbose.is_some();
396                config.ui.verbose = None;
397                had
398            }
399            "ui.color" => {
400                let had = config.ui.color.is_some();
401                config.ui.color = None;
402                had
403            }
404            "ui.non_interactive" => {
405                let had = config.ui.non_interactive.is_some();
406                config.ui.non_interactive = None;
407                had
408            }
409            other => {
410                bail!(
411                    "Unknown configuration key '{}'. Known keys:\n{}",
412                    other,
413                    KNOWN_KEYS.join("\n")
414                );
415            }
416        };
417        Ok(was_set)
418    }
419
420    async fn set_config(&self, key: &str, raw_value: &str) -> Result<CommandResult> {
421        let scope = self.write_scope();
422
423        // Load existing config (or default)
424        let path = Self::scope_path(scope)?;
425        let mut config = load_cli_config(&path)?.unwrap_or_default();
426
427        // Apply the setting
428        Self::apply_key_to_config(&mut config, key, raw_value)?;
429
430        // Validate after change
431        config.validate().map_err(|e| anyhow::anyhow!("{}", e))?;
432
433        // Write back
434        let path = Self::write_scope_file(scope, &config)?;
435
436        Ok(CommandResult::Success(format!(
437            "{} Updated {} config: {} = {}\n{}",
438            "✅".green(),
439            Self::scope_label(scope).cyan(),
440            key.yellow(),
441            raw_value.green(),
442            path.display()
443        )))
444    }
445
446    async fn get_config(&self, key: &str) -> Result<CommandResult> {
447        let scope = self.read_scope();
448        let value = self.load_scope_value(scope)?;
449        let nested = Self::get_nested_value(&value, key).ok_or_else(|| {
450            anyhow::anyhow!(
451                "Configuration key '{}' not found in {} scope",
452                key,
453                Self::scope_label(scope)
454            )
455        })?;
456
457        let output = if matches!(nested, Value::Table(_) | Value::Array(_)) {
458            toml::to_string_pretty(nested)?
459        } else {
460            nested.to_string()
461        };
462
463        Ok(CommandResult::Success(output.trim().to_string()))
464    }
465
466    async fn list_config(&self) -> Result<CommandResult> {
467        // Resolve effective config to show all fields with current values
468        let effective = resolve_effective_cli_config()?;
469        let lines: Vec<String> = vec![
470            format!("mfr.manufacturer = {}", effective.mfr.manufacturer),
471            format!(
472                "mfr.keychain = {}",
473                effective.mfr.keychain.as_deref().unwrap_or("<not set>")
474            ),
475            format!("codegen.language = {}", effective.codegen.language),
476            format!("codegen.output = {}", effective.codegen.output),
477            format!(
478                "codegen.clean_before_generate = {}",
479                effective.codegen.clean_before_generate
480            ),
481            format!("cache.dir = {}", effective.cache.dir),
482            format!("cache.auto_lock = {}", effective.cache.auto_lock),
483            format!("cache.prefer_cache = {}", effective.cache.prefer_cache),
484            format!("ui.format = {}", effective.ui.format),
485            format!("ui.verbose = {}", effective.ui.verbose),
486            format!("ui.color = {}", effective.ui.color),
487            format!("ui.non_interactive = {}", effective.ui.non_interactive),
488            format!(
489                "network.signaling_url = {}",
490                effective.network.signaling_url
491            ),
492            format!("network.ais_endpoint = {}", effective.network.ais_endpoint),
493            format!(
494                "network.realm_id = {}",
495                effective
496                    .network
497                    .realm_id
498                    .map(|id| id.to_string())
499                    .unwrap_or_else(|| "<not set>".to_string())
500            ),
501            format!(
502                "network.realm_secret = {}",
503                effective
504                    .network
505                    .realm_secret
506                    .as_deref()
507                    .unwrap_or("<not set>")
508            ),
509            format!(
510                "storage.hyper_data_dir = {}",
511                effective.storage.hyper_data_dir.display()
512            ),
513        ];
514        Ok(CommandResult::Success(lines.join("\n")))
515    }
516
517    async fn show_config(&self, format: &OutputFormat) -> Result<CommandResult> {
518        let scope = self.read_scope();
519        let value = self.load_scope_value(scope)?;
520        let output = match format {
521            OutputFormat::Toml => toml::to_string_pretty(&value)?,
522            OutputFormat::Json => serde_json::to_string_pretty(&value)?,
523            OutputFormat::Yaml => serde_yaml::to_string(&value)?,
524        };
525        Ok(CommandResult::Success(output))
526    }
527
528    async fn unset_config(&self, key: &str) -> Result<CommandResult> {
529        let scope = self.write_scope();
530        let path = Self::scope_path(scope)?;
531        let mut config = load_cli_config(&path)?.unwrap_or_default();
532
533        let was_set = Self::unset_key_from_config(&mut config, key)?;
534        if !was_set {
535            bail!(
536                "Configuration key '{}' not found in {} scope",
537                key,
538                Self::scope_label(scope)
539            );
540        }
541        let path = Self::write_scope_file(scope, &config)?;
542        Ok(CommandResult::Success(format!(
543            "{} Removed {} from {} config\n{}",
544            "✅".green(),
545            key.cyan(),
546            Self::scope_label(scope),
547            path.display()
548        )))
549    }
550
551    async fn test_config(&self) -> Result<CommandResult> {
552        let scope = self.read_scope();
553        let mut lines = Vec::new();
554        match scope {
555            ConfigScope::Global => {
556                let path = global_config_path()?;
557                if let Some(config) = load_cli_config(&path)? {
558                    config.validate().map_err(|e| anyhow::anyhow!("{}", e))?;
559                }
560                lines.push(format!(
561                    "{} Global config syntax and schema are valid",
562                    "✅".green()
563                ));
564                lines.push(path.display().to_string());
565            }
566            ConfigScope::Local => {
567                let path = local_config_path();
568                if let Some(config) = load_cli_config(&path)? {
569                    config.validate().map_err(|e| anyhow::anyhow!("{}", e))?;
570                }
571                lines.push(format!(
572                    "{} Local config syntax and schema are valid",
573                    "✅".green()
574                ));
575                lines.push(path.display().to_string());
576            }
577            ConfigScope::Merged => {
578                let global_path = global_config_path()?;
579                let local_path = local_config_path();
580
581                if let Some(config) = load_cli_config(&global_path)? {
582                    config.validate().map_err(|e| anyhow::anyhow!("{}", e))?;
583                    lines.push(format!(
584                        "{} Global config parsed and validated",
585                        "✅".green()
586                    ));
587                } else {
588                    lines.push(format!(
589                        "{} Global config not found (using defaults)",
590                        "ℹ️".cyan()
591                    ));
592                }
593
594                if let Some(config) = load_cli_config(&local_path)? {
595                    config.validate().map_err(|e| anyhow::anyhow!("{}", e))?;
596                    lines.push(format!(
597                        "{} Local config parsed and validated",
598                        "✅".green()
599                    ));
600                } else {
601                    lines.push(format!(
602                        "{} Local config not found (using defaults)",
603                        "ℹ️".cyan()
604                    ));
605                }
606
607                // Validate the merged result
608                resolve_effective_cli_config()?;
609                lines.push(format!("{} Merged view is valid", "✅".green()));
610            }
611        }
612        Ok(CommandResult::Success(lines.join("\n")))
613    }
614}
615
616/// Convert a TOML Value to a String, extracting the inner string value.
617fn value_to_string(v: &Value) -> Result<String> {
618    match v {
619        Value::String(s) => Ok(s.clone()),
620        other => Ok(other.to_string()),
621    }
622}
623
624/// Convert a TOML Value to a bool.
625fn value_to_bool(v: &Value, key: &str) -> Result<bool> {
626    match v {
627        Value::Boolean(b) => Ok(*b),
628        Value::String(s) => match s.as_str() {
629            "true" => Ok(true),
630            "false" => Ok(false),
631            other => bail!(
632                "Key '{}' expects a boolean (true/false), got '{}'",
633                key,
634                other
635            ),
636        },
637        other => bail!("Key '{}' expects a boolean, got {:?}", key, other),
638    }
639}
640
641fn value_to_u32(v: &Value, key: &str) -> Result<u32> {
642    // Accept both numbers and strings (e.g., `1001` or `"1001"`).
643    let s = value_to_string(v)?;
644    s.parse::<u32>()
645        .map_err(|_| anyhow::anyhow!("Key '{}' expects a positive integer, got '{}'", key, s))
646}
647
648#[cfg(test)]
649mod tests {
650    use super::*;
651
652    #[test]
653    fn parses_full_toml_document_as_value_table() {
654        let value = parse_toml_document_value(
655            r#"
656[mfr]
657manufacturer = "demo1"
658
659[network]
660realm_id = 2368266035
661"#,
662            ".actr/config.toml",
663        )
664        .expect("config TOML should parse");
665
666        assert_eq!(
667            ConfigCommand::get_nested_value(&value, "mfr.manufacturer"),
668            Some(&Value::String("demo1".to_string()))
669        );
670    }
671}