1use 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
18const 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 #[arg(long, conflicts_with = "local")]
49 pub global: bool,
50
51 #[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 {
63 key: String,
65 value: String,
67 },
68 Get {
70 key: String,
72 },
73 List,
75 Show {
77 #[arg(long, default_value = "toml")]
78 format: OutputFormat,
79 },
80 Unset {
82 key: String,
84 },
85 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 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 fn apply_key_to_config(config: &mut CliConfig, key: &str, raw_value: &str) -> Result<()> {
253 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 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 let path = Self::scope_path(scope)?;
425 let mut config = load_cli_config(&path)?.unwrap_or_default();
426
427 Self::apply_key_to_config(&mut config, key, raw_value)?;
429
430 config.validate().map_err(|e| anyhow::anyhow!("{}", e))?;
432
433 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 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 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
616fn 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
624fn 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 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}