Skip to main content

actr_cli/commands/
config.rs

1//! Config command implementation - manage project configuration
2//!
3//! Supports the following operations:
4//! - `actr config set <key> <value>` - Set a configuration value
5//! - `actr config get <key>` - Get a configuration value  
6//! - `actr config list` - List all configuration keys
7//! - `actr config show` - Show full configuration
8//! - `actr config unset <key>` - Remove a configuration value
9//! - `actr config test` - Test configuration file syntax
10
11use crate::core::{Command, CommandContext, CommandResult, ComponentType};
12use actr_config::{ConfigParser, RawConfig};
13use anyhow::{Result, bail};
14use async_trait::async_trait;
15use clap::{Args, Subcommand};
16use owo_colors::OwoColorize;
17use std::path::Path;
18
19#[derive(Args, Clone)]
20pub struct ConfigCommand {
21    /// Configuration file to load (defaults to Actr.toml)
22    #[arg(short = 'f', long = "file")]
23    pub config_file: Option<String>,
24
25    #[command(subcommand)]
26    pub command: ConfigSubcommand,
27}
28
29#[derive(Subcommand, Clone)]
30pub enum ConfigSubcommand {
31    /// Set a configuration value
32    Set {
33        /// Configuration key (e.g., "signaling.url", "build.output-dir")
34        key: String,
35        /// Configuration value
36        value: String,
37    },
38    /// Get a configuration value
39    Get {
40        /// Configuration key (e.g., "signaling.url")
41        key: String,
42    },
43    /// List all configuration keys
44    List,
45    /// Show full configuration
46    Show {
47        /// Output format
48        #[arg(long, default_value = "toml")]
49        format: OutputFormat,
50    },
51    /// Unset a configuration value
52    Unset {
53        /// Configuration key to remove
54        key: String,
55    },
56    /// Test configuration file syntax and validity
57    Test,
58}
59
60#[derive(Debug, Clone, clap::ValueEnum, Default)]
61pub enum OutputFormat {
62    /// TOML format (default)
63    #[default]
64    Toml,
65    /// JSON format
66    Json,
67    /// YAML format
68    Yaml,
69}
70
71impl ConfigCommand {
72    /// Get the configuration file path
73    fn config_path(&self) -> &str {
74        self.config_file.as_deref().unwrap_or("Actr.toml")
75    }
76}
77
78#[async_trait]
79impl Command for ConfigCommand {
80    async fn execute(&self, _ctx: &CommandContext) -> Result<CommandResult> {
81        let config_path = self.config_path();
82
83        match &self.command {
84            ConfigSubcommand::Set { key, value } => self.set_config(config_path, key, value).await,
85            ConfigSubcommand::Get { key } => self.get_config(config_path, key).await,
86            ConfigSubcommand::List => self.list_config(config_path).await,
87            ConfigSubcommand::Show { format } => self.show_config(config_path, format).await,
88            ConfigSubcommand::Unset { key } => self.unset_config(config_path, key).await,
89            ConfigSubcommand::Test => self.test_config(config_path).await,
90        }
91    }
92
93    fn required_components(&self) -> Vec<ComponentType> {
94        vec![] // Config command doesn't require any external components
95    }
96
97    fn name(&self) -> &str {
98        "config"
99    }
100
101    fn description(&self) -> &str {
102        "Manage project configuration"
103    }
104}
105
106impl ConfigCommand {
107    /// Set a configuration value
108    async fn set_config(&self, config_path: &str, key: &str, value: &str) -> Result<CommandResult> {
109        // Load existing config
110        if !Path::new(config_path).exists() {
111            bail!(
112                "Configuration file not found: {}. Run 'actr init' to create a project first.",
113                config_path
114            );
115        }
116
117        let mut raw_config = RawConfig::from_file(config_path)?;
118
119        // Parse the key path and set the value
120        self.set_nested_value(&mut raw_config, key, value)?;
121
122        // Save the updated configuration
123        raw_config.save_to_file(config_path)?;
124
125        Ok(CommandResult::Success(format!(
126            "{} Configuration updated: {} = {}",
127            "โœ…".green(),
128            key.cyan(),
129            value.yellow()
130        )))
131    }
132
133    /// Get a configuration value
134    async fn get_config(&self, config_path: &str, key: &str) -> Result<CommandResult> {
135        if !Path::new(config_path).exists() {
136            bail!("Configuration file not found: {}", config_path);
137        }
138
139        let raw_config = RawConfig::from_file(config_path)?;
140
141        // Get the nested value
142        let value = self.get_nested_value(&raw_config, key)?;
143
144        Ok(CommandResult::Success(value))
145    }
146
147    /// List all configuration keys
148    async fn list_config(&self, config_path: &str) -> Result<CommandResult> {
149        if !Path::new(config_path).exists() {
150            return Ok(CommandResult::Success(format!(
151                "{} No configuration file found at: {}",
152                "๐Ÿ“‹".yellow(),
153                config_path
154            )));
155        }
156
157        let raw_config = RawConfig::from_file(config_path)?;
158
159        let mut output = String::new();
160        output.push_str(&format!("{} Available configuration keys:\n", "๐Ÿ“‹".cyan()));
161        output.push('\n');
162
163        // Package settings
164        output.push_str(&format!("  {} Package:\n", "๐Ÿ“ฆ".blue()));
165        output.push_str("    package.name\n");
166        output.push_str("    package.actr_type.manufacturer\n");
167        output.push_str("    package.actr_type.name\n");
168        if raw_config.package.description.is_some() {
169            output.push_str("    package.description\n");
170        }
171
172        // System settings
173        output.push_str(&format!("\n  {} System:\n", "โš™๏ธ".blue()));
174        output.push_str("    signaling.url\n");
175        output.push_str("    deployment.realm_id\n");
176        output.push_str("    discovery.visible\n");
177        output.push_str("    storage.mailbox_path\n");
178
179        // WebRTC settings
180        output.push_str(&format!("\n  {} WebRTC:\n", "๐ŸŒ".blue()));
181        output.push_str("    webrtc.stun_urls\n");
182        output.push_str("    webrtc.turn_urls\n");
183        output.push_str("    webrtc.force_relay\n");
184
185        // Observability settings
186        output.push_str(&format!("\n  {} Observability:\n", "๐Ÿ“Š".blue()));
187        output.push_str("    observability.filter_level\n");
188        output.push_str("    observability.tracing_enabled\n");
189        output.push_str("    observability.tracing_endpoint\n");
190        output.push_str("    observability.tracing_service_name\n");
191
192        // Exports
193        if !raw_config.exports.is_empty() {
194            output.push_str(&format!(
195                "\n  {} Exports ({} files):\n",
196                "๐Ÿ“ค".blue(),
197                raw_config.exports.len()
198            ));
199            for (i, export) in raw_config.exports.iter().enumerate() {
200                output.push_str(&format!("    exports[{}] = {}\n", i, export.display()));
201            }
202        }
203
204        // Dependencies
205        if !raw_config.dependencies.is_empty() {
206            output.push_str(&format!(
207                "\n  {} Dependencies ({}):\n",
208                "๐Ÿ”—".blue(),
209                raw_config.dependencies.len()
210            ));
211            for key in raw_config.dependencies.keys() {
212                output.push_str(&format!("    dependencies.{}\n", key));
213            }
214        }
215
216        // Scripts
217        if !raw_config.scripts.is_empty() {
218            output.push_str(&format!(
219                "\n  {} Scripts ({}):\n",
220                "๐Ÿ“œ".blue(),
221                raw_config.scripts.len()
222            ));
223            for key in raw_config.scripts.keys() {
224                output.push_str(&format!("    scripts.{}\n", key));
225            }
226        }
227
228        Ok(CommandResult::Success(output))
229    }
230
231    /// Show full configuration
232    async fn show_config(&self, config_path: &str, format: &OutputFormat) -> Result<CommandResult> {
233        if !Path::new(config_path).exists() {
234            bail!("Configuration file not found: {}", config_path);
235        }
236
237        let raw_config = RawConfig::from_file(config_path)?;
238
239        // Output configuration in requested format
240        let output = match format {
241            OutputFormat::Toml => toml::to_string_pretty(&raw_config)?,
242            OutputFormat::Json => serde_json::to_string_pretty(&raw_config)?,
243            OutputFormat::Yaml => serde_yaml::to_string(&raw_config)?,
244        };
245
246        Ok(CommandResult::Success(output))
247    }
248
249    /// Unset a configuration value
250    async fn unset_config(&self, config_path: &str, key: &str) -> Result<CommandResult> {
251        if !Path::new(config_path).exists() {
252            bail!("Configuration file not found: {}", config_path);
253        }
254
255        let mut raw_config = RawConfig::from_file(config_path)?;
256
257        // Remove the nested value
258        self.unset_nested_value(&mut raw_config, key)?;
259
260        // Save the updated configuration
261        raw_config.save_to_file(config_path)?;
262
263        Ok(CommandResult::Success(format!(
264            "{} Configuration key '{}' removed successfully",
265            "โœ…".green(),
266            key.cyan()
267        )))
268    }
269
270    /// Test configuration file syntax and validation
271    async fn test_config(&self, config_path: &str) -> Result<CommandResult> {
272        if !Path::new(config_path).exists() {
273            bail!("Configuration file not found: {}", config_path);
274        }
275
276        let mut output = String::new();
277        output.push_str(&format!(
278            "{} Testing configuration file: {}\n\n",
279            "๐Ÿงช".cyan(),
280            config_path
281        ));
282
283        // Test 1: Raw TOML parsing
284        let raw_config = match RawConfig::from_file(config_path) {
285            Ok(config) => {
286                output.push_str(&format!(
287                    "{} Configuration file syntax is valid\n",
288                    "โœ…".green()
289                ));
290                config
291            }
292            Err(e) => {
293                bail!("Configuration file syntax error:\n   {}", e);
294            }
295        };
296
297        // Test 2: Full parsing and validation
298        match ConfigParser::from_file(config_path) {
299            Ok(config) => {
300                output.push_str(&format!(
301                    "{} Configuration validation passed\n",
302                    "โœ…".green()
303                ));
304
305                // Show summary
306                output.push_str(&format!("\n{} Configuration Summary:\n", "๐Ÿ“‹".cyan()));
307                output.push_str(&format!("  Package: {}\n", config.package.name.yellow()));
308                output.push_str(&format!(
309                    "  ActrType: {}+{}\n",
310                    config.package.actr_type.manufacturer.cyan(),
311                    config.package.actr_type.name.cyan()
312                ));
313
314                if let Some(desc) = &config.package.description {
315                    output.push_str(&format!("  Description: {}\n", desc));
316                }
317
318                output.push_str(&format!("  Realm: {}\n", config.realm.realm_id));
319                output.push_str(&format!("  Signaling URL: {}\n", config.signaling_url));
320                output.push_str(&format!(
321                    "  Visible in discovery: {}\n",
322                    config.visible_in_discovery
323                ));
324
325                if !config.dependencies.is_empty() {
326                    output.push_str(&format!(
327                        "  Dependencies: {} entries\n",
328                        config.dependencies.len()
329                    ));
330                }
331
332                if !config.scripts.is_empty() {
333                    output.push_str(&format!("  Scripts: {} entries\n", config.scripts.len()));
334                }
335
336                if !raw_config.exports.is_empty() {
337                    output.push_str(&format!(
338                        "  Exports: {} proto files\n",
339                        raw_config.exports.len()
340                    ));
341                }
342
343                output.push_str(&format!(
344                    "\n{} Configuration test completed successfully",
345                    "๐ŸŽฏ".green()
346                ));
347            }
348            Err(e) => {
349                output.push_str(&format!(
350                    "{} Configuration validation failed:\n",
351                    "โŒ".red()
352                ));
353                output.push_str(&format!("   {}\n", e));
354                bail!("Configuration validation failed: {}", e);
355            }
356        }
357
358        Ok(CommandResult::Success(output))
359    }
360
361    /// Set a nested configuration value using dot notation
362    fn set_nested_value(&self, config: &mut RawConfig, key: &str, value: &str) -> Result<()> {
363        let parts: Vec<&str> = key.split('.').collect();
364
365        match parts.as_slice() {
366            // Package configuration
367            ["package", "name"] => config.package.name = value.to_string(),
368            ["package", "description"] => config.package.description = Some(value.to_string()),
369            ["package", "actr_type", "manufacturer"] => {
370                config.package.actr_type.manufacturer = value.to_string()
371            }
372            ["package", "actr_type", "name"] => config.package.actr_type.name = value.to_string(),
373
374            // System signaling configuration
375            ["signaling", "url"] | ["system", "signaling", "url"] => {
376                config.system.signaling.url = Some(value.to_string())
377            }
378
379            // Deployment configuration
380            ["deployment", "realm_id"] | ["system", "deployment", "realm_id"] => {
381                config.system.deployment.realm_id = Some(
382                    value
383                        .parse()
384                        .map_err(|_| anyhow::anyhow!("deployment.realm_id must be a number"))?,
385                );
386            }
387
388            // Discovery configuration
389            ["discovery", "visible"] | ["system", "discovery", "visible"] => {
390                config.system.discovery.visible = Some(
391                    value
392                        .parse()
393                        .map_err(|_| anyhow::anyhow!("discovery.visible must be true or false"))?,
394                );
395            }
396
397            // Storage configuration
398            ["storage", "mailbox_path"] | ["system", "storage", "mailbox_path"] => {
399                config.system.storage.mailbox_path = Some(value.into());
400            }
401
402            // WebRTC configuration
403            ["webrtc", "stun_urls"] | ["system", "webrtc", "stun_urls"] => {
404                let urls: Vec<String> = value.split(',').map(|s| s.trim().to_string()).collect();
405                config.system.webrtc.stun_urls = urls;
406            }
407            ["webrtc", "turn_urls"] | ["system", "webrtc", "turn_urls"] => {
408                let urls: Vec<String> = value.split(',').map(|s| s.trim().to_string()).collect();
409                config.system.webrtc.turn_urls = urls;
410            }
411            ["webrtc", "force_relay"] | ["system", "webrtc", "force_relay"] => {
412                config.system.webrtc.force_relay = value
413                    .parse()
414                    .map_err(|_| anyhow::anyhow!("webrtc.force_relay must be true or false"))?;
415            }
416
417            // Observability configuration
418            ["observability", "filter_level"] | ["system", "observability", "filter_level"] => {
419                config.system.observability.filter_level = Some(value.to_string());
420            }
421            ["observability", "tracing_enabled"]
422            | ["system", "observability", "tracing_enabled"] => {
423                config.system.observability.tracing_enabled =
424                    Some(value.parse().map_err(|_| {
425                        anyhow::anyhow!("observability.tracing_enabled must be true or false")
426                    })?);
427            }
428            ["observability", "tracing_endpoint"]
429            | ["system", "observability", "tracing_endpoint"] => {
430                config.system.observability.tracing_endpoint = Some(value.to_string());
431            }
432            ["observability", "tracing_service_name"]
433            | ["system", "observability", "tracing_service_name"] => {
434                config.system.observability.tracing_service_name = Some(value.to_string());
435            }
436
437            // Scripts configuration
438            ["scripts", script_name] => {
439                config
440                    .scripts
441                    .insert(script_name.to_string(), value.to_string());
442            }
443
444            _ => bail!(
445                "Unknown or unsupported configuration key: {}\n\n๐Ÿ’ก Hint: Run 'actr config list' to see available keys",
446                key
447            ),
448        }
449
450        Ok(())
451    }
452
453    /// Get a nested configuration value using dot notation
454    fn get_nested_value(&self, config: &RawConfig, key: &str) -> Result<String> {
455        let parts: Vec<&str> = key.split('.').collect();
456
457        let value = match parts.as_slice() {
458            // Package configuration
459            ["package", "name"] => config.package.name.clone(),
460            ["package", "description"] => config.package.description.clone().unwrap_or_default(),
461            ["package", "actr_type", "manufacturer"] => {
462                config.package.actr_type.manufacturer.clone()
463            }
464            ["package", "actr_type", "name"] => config.package.actr_type.name.clone(),
465
466            // System signaling configuration
467            ["signaling", "url"] | ["system", "signaling", "url"] => {
468                config.system.signaling.url.clone().unwrap_or_default()
469            }
470
471            // Deployment configuration
472            ["deployment", "realm_id"] | ["system", "deployment", "realm_id"] => config
473                .system
474                .deployment
475                .realm_id
476                .map(|r| r.to_string())
477                .unwrap_or_default(),
478
479            // Discovery configuration
480            ["discovery", "visible"] | ["system", "discovery", "visible"] => config
481                .system
482                .discovery
483                .visible
484                .map(|v| v.to_string())
485                .unwrap_or_default(),
486
487            // Storage configuration
488            ["storage", "mailbox_path"] | ["system", "storage", "mailbox_path"] => config
489                .system
490                .storage
491                .mailbox_path
492                .as_ref()
493                .map(|p| p.display().to_string())
494                .unwrap_or_default(),
495
496            // WebRTC configuration
497            ["webrtc", "stun_urls"] | ["system", "webrtc", "stun_urls"] => {
498                config.system.webrtc.stun_urls.join(",")
499            }
500            ["webrtc", "turn_urls"] | ["system", "webrtc", "turn_urls"] => {
501                config.system.webrtc.turn_urls.join(",")
502            }
503            ["webrtc", "force_relay"] | ["system", "webrtc", "force_relay"] => {
504                config.system.webrtc.force_relay.to_string()
505            }
506
507            // Observability configuration
508            ["observability", "filter_level"] | ["system", "observability", "filter_level"] => {
509                config
510                    .system
511                    .observability
512                    .filter_level
513                    .clone()
514                    .unwrap_or_default()
515            }
516            ["observability", "tracing_enabled"]
517            | ["system", "observability", "tracing_enabled"] => config
518                .system
519                .observability
520                .tracing_enabled
521                .map(|v| v.to_string())
522                .unwrap_or_default(),
523            ["observability", "tracing_endpoint"]
524            | ["system", "observability", "tracing_endpoint"] => config
525                .system
526                .observability
527                .tracing_endpoint
528                .clone()
529                .unwrap_or_default(),
530            ["observability", "tracing_service_name"]
531            | ["system", "observability", "tracing_service_name"] => config
532                .system
533                .observability
534                .tracing_service_name
535                .clone()
536                .unwrap_or_default(),
537
538            // Scripts configuration
539            ["scripts", script_name] => config
540                .scripts
541                .get(*script_name)
542                .cloned()
543                .unwrap_or_default(),
544
545            // Dependencies (read-only summary)
546            ["dependencies", dep_name] => {
547                if let Some(dep) = config.dependencies.get(*dep_name) {
548                    match dep {
549                        actr_config::RawDependency::Empty {} => "{}".to_string(),
550                        actr_config::RawDependency::WithFingerprint {
551                            name,
552                            actr_type,
553                            fingerprint,
554                            realm,
555                        } => {
556                            let mut parts = vec![];
557                            if let Some(n) = name {
558                                parts.push(format!("name={}", n));
559                            }
560                            if let Some(t) = actr_type {
561                                parts.push(format!("actr_type={}", t));
562                            }
563                            parts.push(format!("fingerprint={}", fingerprint));
564                            if let Some(r) = realm {
565                                parts.push(format!("realm={}", r));
566                            }
567                            format!("{{ {} }}", parts.join(", "))
568                        }
569                    }
570                } else {
571                    bail!("Dependency not found: {}", dep_name);
572                }
573            }
574
575            _ => bail!(
576                "Unknown configuration key: {}\n\n๐Ÿ’ก Hint: Run 'actr config list' to see available keys",
577                key
578            ),
579        };
580
581        Ok(value)
582    }
583
584    /// Remove a nested configuration value using dot notation
585    fn unset_nested_value(&self, config: &mut RawConfig, key: &str) -> Result<()> {
586        let parts: Vec<&str> = key.split('.').collect();
587
588        match parts.as_slice() {
589            // Package optional fields
590            ["package", "description"] => config.package.description = None,
591
592            // System signaling configuration
593            ["signaling", "url"] | ["system", "signaling", "url"] => {
594                config.system.signaling.url = None
595            }
596
597            // Deployment configuration
598            ["deployment", "realm_id"] | ["system", "deployment", "realm_id"] => {
599                config.system.deployment.realm_id = None
600            }
601
602            // Discovery configuration
603            ["discovery", "visible"] | ["system", "discovery", "visible"] => {
604                config.system.discovery.visible = None
605            }
606
607            // Storage configuration
608            ["storage", "mailbox_path"] | ["system", "storage", "mailbox_path"] => {
609                config.system.storage.mailbox_path = None
610            }
611
612            // WebRTC configuration
613            ["webrtc", "stun_urls"] | ["system", "webrtc", "stun_urls"] => {
614                config.system.webrtc.stun_urls = vec![]
615            }
616            ["webrtc", "turn_urls"] | ["system", "webrtc", "turn_urls"] => {
617                config.system.webrtc.turn_urls = vec![]
618            }
619            ["webrtc", "force_relay"] | ["system", "webrtc", "force_relay"] => {
620                config.system.webrtc.force_relay = false
621            }
622
623            // Observability configuration
624            ["observability", "filter_level"] | ["system", "observability", "filter_level"] => {
625                config.system.observability.filter_level = None
626            }
627            ["observability", "tracing_enabled"]
628            | ["system", "observability", "tracing_enabled"] => {
629                config.system.observability.tracing_enabled = None
630            }
631            ["observability", "tracing_endpoint"]
632            | ["system", "observability", "tracing_endpoint"] => {
633                config.system.observability.tracing_endpoint = None
634            }
635            ["observability", "tracing_service_name"]
636            | ["system", "observability", "tracing_service_name"] => {
637                config.system.observability.tracing_service_name = None
638            }
639
640            // Scripts configuration
641            ["scripts", script_name] => {
642                config.scripts.remove(*script_name);
643            }
644
645            // Dependencies configuration
646            ["dependencies", dep_name] => {
647                config.dependencies.remove(*dep_name);
648            }
649
650            // Cannot unset required fields
651            ["package", "name"]
652            | ["package", "actr_type", "manufacturer"]
653            | ["package", "actr_type", "name"] => {
654                bail!("Cannot unset required configuration key: {}", key);
655            }
656
657            _ => bail!("Cannot unset configuration key: {}", key),
658        }
659
660        Ok(())
661    }
662}