Skip to main content

actr_cli/commands/
discovery.rs

1//! Discovery Command Implementation
2//!
3//! Demonstrates multi-level reuse patterns: Service Discovery -> Validation -> Optional Install
4
5use anyhow::Result;
6use async_trait::async_trait;
7use clap::Args;
8
9use crate::core::{
10    ActrCliError, Command, CommandContext, CommandResult, ComponentType, ConfigManager,
11    DependencyResolver, DependencySpec, Fingerprint, FingerprintValidator, NetworkCheckOptions,
12    NetworkValidator, ResolvedDependency, ServiceDetails, ServiceDiscovery, ServiceInfo,
13};
14
15/// Standalone discovery mode — bypass local project config.
16/// When set, `actr registry discover` doesn't need `manifest.toml` in cwd.
17#[derive(Debug, Clone)]
18pub struct StandaloneDiscoverConfig {
19    pub endpoint: url::Url,
20    pub realm_id: u64,
21    pub realm_secret: String,
22}
23
24/// Discovery command
25#[derive(Args, Debug)]
26#[command(
27    about = "Discover network services",
28    long_about = "Discover Actor services in the network, view available services and choose to install\n\n\
29    Examples:\n  \
30    # Discover services using the local project's actr.toml config\n  \
31    actr registry discover --list-only\n\n  \
32    # Discover services on a remote actrix server (standalone, no local project needed)\n  \
33    actr registry discover --list-only \\\n    \
34    --endpoint http://124.71.231.251:9080/ais \\\n    \
35    --realm-id 33554433 \\\n    \
36    --realm-secret rs_xxx"
37)]
38pub struct DiscoveryCommand {
39    /// Service name filter pattern (e.g., user-*)
40    #[arg(long, value_name = "PATTERN")]
41    pub filter: Option<String>,
42
43    /// Show detailed information
44    #[arg(long)]
45    pub verbose: bool,
46
47    /// Automatically install selected services
48    #[arg(long)]
49    pub auto_install: bool,
50
51    /// List discovered services and exit without interactive selection
52    #[arg(long)]
53    pub list_only: bool,
54
55    /// Standalone mode: actrix AIS endpoint URL (e.g. http://124.71.231.251:9080/ais)
56    ///
57    /// When specified together with --realm-id and --realm-secret, runs in standalone
58    /// mode that doesn't require a local project's manifest.toml / actr.toml.
59    #[arg(long, value_name = "URL")]
60    pub endpoint: Option<url::Url>,
61
62    /// Standalone mode: realm ID to query against
63    #[arg(long, value_name = "REALM_ID")]
64    pub realm_id: Option<u64>,
65
66    /// Standalone mode: realm secret for authentication
67    #[arg(long, value_name = "SECRET")]
68    pub realm_secret: Option<String>,
69}
70
71impl DiscoveryCommand {
72    /// If all three standalone flags are provided, return a `StandaloneDiscoverConfig`.
73    pub fn standalone_config(&self) -> Option<StandaloneDiscoverConfig> {
74        match (&self.endpoint, self.realm_id, &self.realm_secret) {
75            (Some(endpoint), Some(realm_id), Some(secret)) if !secret.is_empty() => {
76                Some(StandaloneDiscoverConfig {
77                    endpoint: endpoint.clone(),
78                    realm_id,
79                    realm_secret: secret.clone(),
80                })
81            }
82            _ => None,
83        }
84    }
85}
86
87#[async_trait]
88impl Command for DiscoveryCommand {
89    async fn execute(&self, context: &CommandContext) -> Result<CommandResult> {
90        // Get reusable components needed for all paths (discovery + display).
91        // ConfigManager is only required for export/add-config actions;
92        // --list-only and pure discovery work without it.
93        let (service_discovery, user_interface) = {
94            let container = context.container.lock().unwrap();
95            (
96                container.get_service_discovery()?,
97                container.get_user_interface()?,
98            )
99        };
100
101        // Phase 1: Service Discovery
102
103        let filter = self.create_service_filter();
104        let services = service_discovery.discover_services(filter.as_ref()).await?;
105        tracing::debug!("Discovered services: {:?}", services);
106
107        if services.is_empty() {
108            println!("â„šī¸ No available Actor services discovered in the current network");
109            return Ok(CommandResult::Success("No services discovered".to_string()));
110        }
111
112        println!("🔍 Discovered Actor services:");
113        // Display discovered services table
114        self.display_services_table(&services);
115
116        if self.list_only {
117            return Ok(CommandResult::Success("Services listed".to_string()));
118        }
119
120        // Selection Phase
121        let service_options: Vec<String> = services.iter().map(|s| s.name.clone()).collect();
122
123        let selected_index = match user_interface
124            .select_from_list(&service_options, "Select a service to view (Esc to quit)")
125            .await
126        {
127            Ok(index) => index,
128            Err(err) if Self::is_operation_cancelled(&err) => {
129                return Ok(CommandResult::Success("Operation cancelled".to_string()));
130            }
131            Err(err) => return Err(err),
132        };
133
134        let selected_service = &services[selected_index];
135        let mut selected_details = None;
136
137        if self.verbose {
138            let details = service_discovery
139                .get_service_details(&selected_service.name)
140                .await?;
141            self.display_service_details(&details);
142            selected_details = Some(details);
143        }
144
145        // Action menu prompt
146        let menu_prompt = format!("Options for {}", selected_service.name);
147
148        // Action menu items (as shown in screenshot)
149        let action_menu = vec![
150            "[1] View service details (fingerprint, publication time)".to_string(),
151            "[2] Export proto files".to_string(),
152            "[3] Add to configuration file".to_string(),
153        ];
154
155        let action_choice = match user_interface
156            .select_from_list(&action_menu, &menu_prompt)
157            .await
158        {
159            Ok(choice) => choice,
160            Err(err) if Self::is_operation_cancelled(&err) => {
161                return Ok(CommandResult::Success("Operation cancelled".to_string()));
162            }
163            Err(err) => return Err(err),
164        };
165
166        match action_choice {
167            0 => {
168                if let Some(details) = selected_details.as_ref() {
169                    self.display_service_details(details);
170                } else {
171                    let details = service_discovery
172                        .get_service_details(&selected_service.name)
173                        .await?;
174                    self.display_service_details(&details);
175                }
176                Ok(CommandResult::Success(
177                    "Service details displayed".to_string(),
178                ))
179            }
180            1 => {
181                // Export proto files — requires ConfigManager
182                let config_manager = {
183                    let container = context.container.lock().unwrap();
184                    container.get_config_manager()?
185                };
186                self.export_proto_files(selected_service, &service_discovery, &config_manager)
187                    .await?;
188                Ok(CommandResult::Success("Proto files exported".to_string()))
189            }
190            2 => {
191                // Add to configuration file - core flow of reuse architecture
192                self.add_to_config_with_validation(selected_service, context)
193                    .await
194            }
195            _ => Ok(CommandResult::Success("Invalid choice".to_string())),
196        }
197    }
198
199    fn required_components(&self) -> Vec<ComponentType> {
200        // Minimal components for discovery + display.
201        // ConfigManager and validators are only needed for export/add-config
202        // actions and are obtained lazily from the container when required.
203        vec![
204            ComponentType::ServiceDiscovery, // Core service discovery
205            ComponentType::UserInterface,    // User interface
206        ]
207    }
208
209    fn name(&self) -> &str {
210        "discovery"
211    }
212
213    fn description(&self) -> &str {
214        "Discover available Actor services in the network (Reuse architecture + check-first)"
215    }
216}
217
218impl DiscoveryCommand {
219    pub fn new(filter: Option<String>, verbose: bool, auto_install: bool) -> Self {
220        Self {
221            filter,
222            verbose,
223            auto_install,
224            list_only: false,
225            endpoint: None,
226            realm_id: None,
227            realm_secret: None,
228        }
229    }
230
231    // Create from clap Args
232    pub fn from_args(args: &DiscoveryCommand) -> Self {
233        DiscoveryCommand {
234            filter: args.filter.clone(),
235            verbose: args.verbose,
236            auto_install: args.auto_install,
237            list_only: args.list_only,
238            endpoint: args.endpoint.clone(),
239            realm_id: args.realm_id,
240            realm_secret: args.realm_secret.clone(),
241        }
242    }
243
244    /// Create service filter
245    fn create_service_filter(&self) -> Option<crate::core::ServiceFilter> {
246        self.filter
247            .as_ref()
248            .map(|pattern| crate::core::ServiceFilter {
249                name_pattern: Some(pattern.clone()),
250                version_range: None,
251                tags: None,
252            })
253    }
254
255    fn is_operation_cancelled(err: &anyhow::Error) -> bool {
256        matches!(
257            err.downcast_ref::<ActrCliError>(),
258            Some(ActrCliError::OperationCancelled)
259        )
260    }
261
262    #[allow(clippy::too_many_arguments)]
263    async fn validate_dependency(
264        &self,
265        service: &ServiceInfo,
266        dependency_spec: &DependencySpec,
267        expected_fingerprint: Option<&str>,
268        check_conflicts: bool,
269        existing_specs: &[DependencySpec],
270        dependency_resolver: &std::sync::Arc<dyn DependencyResolver>,
271        service_discovery: &std::sync::Arc<dyn ServiceDiscovery>,
272        network_validator: &std::sync::Arc<dyn NetworkValidator>,
273        fingerprint_validator: &std::sync::Arc<dyn FingerprintValidator>,
274    ) -> Result<()> {
275        println!();
276        println!("🔍 Validating dependency...");
277
278        let mut failures = Vec::new();
279
280        match service_discovery
281            .check_service_availability(&service.name)
282            .await
283        {
284            Ok(status) => {
285                if status.is_available {
286                    println!("  ├─ ✅ Service availability");
287                } else {
288                    println!("  ├─ ❌ Service availability");
289                    failures.push(format!("Service '{}' not found in registry", service.name));
290                }
291            }
292            Err(e) => {
293                println!("  ├─ ❌ Service availability");
294                failures.push(format!("Service availability check failed: {e}"));
295            }
296        }
297
298        match network_validator
299            .check_connectivity(&service.name, &NetworkCheckOptions::default())
300            .await
301        {
302            Ok(connectivity) => {
303                if connectivity.is_reachable {
304                    println!("  ├─ ✅ Network connectivity");
305                } else {
306                    println!("  ├─ ❌ Network connectivity");
307                    let detail = connectivity.error.as_deref().unwrap_or("unknown error");
308                    failures.push(format!(
309                        "Network connectivity failed for '{}': {}",
310                        service.name, detail
311                    ));
312                }
313            }
314            Err(e) => {
315                println!("  ├─ ❌ Network connectivity");
316                failures.push(format!("Network connectivity check failed: {e}"));
317            }
318        }
319
320        if let Some(expected_fingerprint) = expected_fingerprint.filter(|fp| !fp.is_empty()) {
321            match fingerprint_validator
322                .compute_service_fingerprint(service)
323                .await
324            {
325                Ok(actual) => {
326                    let expected = Fingerprint {
327                        algorithm: actual.algorithm.clone(),
328                        value: expected_fingerprint.to_string(),
329                    };
330                    let is_valid = fingerprint_validator
331                        .verify_fingerprint(&expected, &actual)
332                        .await
333                        .unwrap_or(false);
334                    if is_valid {
335                        println!("  ├─ ✅ Fingerprint match");
336                    } else {
337                        println!("  ├─ ❌ Fingerprint match");
338                        failures.push(format!("Fingerprint mismatch for '{}'", service.name));
339                    }
340                }
341                Err(e) => {
342                    println!("  ├─ ❌ Fingerprint check");
343                    failures.push(format!("Fingerprint check failed: {e}"));
344                }
345            }
346        } else {
347            println!("  ├─ âš ī¸  Fingerprint missing; skipping check");
348        }
349
350        if check_conflicts {
351            let mut resolved = Vec::with_capacity(existing_specs.len() + 1);
352            for spec in existing_specs {
353                resolved.push(ResolvedDependency {
354                    spec: spec.clone(),
355                    fingerprint: spec.fingerprint.clone().unwrap_or_default(),
356                    proto_files: Vec::new(),
357                });
358            }
359            resolved.push(ResolvedDependency {
360                spec: dependency_spec.clone(),
361                fingerprint: dependency_spec.fingerprint.clone().unwrap_or_default(),
362                proto_files: Vec::new(),
363            });
364
365            match dependency_resolver.check_conflicts(&resolved).await {
366                Ok(conflicts) => {
367                    if conflicts.is_empty() {
368                        println!("  ├─ ✅ Dependency conflicts");
369                    } else {
370                        println!("  ├─ ❌ Dependency conflicts");
371                        let details = conflicts
372                            .iter()
373                            .map(|conflict| conflict.description.clone())
374                            .collect::<Vec<_>>()
375                            .join(", ");
376                        failures.push(format!("Dependency conflicts: {details}"));
377                    }
378                }
379                Err(e) => {
380                    println!("  ├─ ❌ Dependency conflicts");
381                    failures.push(format!("Dependency conflict check failed: {e}"));
382                }
383            }
384        } else {
385            println!("  ├─ âš ī¸  Dependency conflict check skipped (already configured)");
386        }
387
388        if failures.is_empty() {
389            println!("  └─ ✅ Validation passed");
390            Ok(())
391        } else {
392            println!("  └─ ❌ Validation failed");
393            Err(ActrCliError::ValidationFailed {
394                details: failures.join("; "),
395            }
396            .into())
397        }
398    }
399
400    /// Display services table
401    fn display_services_table(&self, services: &[ServiceInfo]) {
402        println!();
403        // Total width limit is 160
404        const TOTAL_MAX_WIDTH: usize = 160;
405        // Border and separator overhead
406        const BORDER_OVERHEAD: usize = 7;
407
408        // Calculate the maximum width of each column
409        let name_width = services
410            .iter()
411            .map(|s| s.name.chars().count())
412            .max()
413            .unwrap_or(0)
414            .max("Service Name".len());
415
416        let tags_width = services
417            .iter()
418            .map(|s| s.tags.join(", ").chars().count())
419            .max()
420            .unwrap_or(0)
421            .max("Tags".len());
422
423        let desc_width = services
424            .iter()
425            .map(|s| {
426                s.description
427                    .as_deref()
428                    .unwrap_or("No description")
429                    .chars()
430                    .count()
431            })
432            .max()
433            .unwrap_or(0)
434            .max("Description".len());
435
436        let name_w = name_width;
437        let tags_w = tags_width;
438        let mut desc_w = desc_width;
439
440        // If the total width is exceeded, truncate the Description
441        if name_w + tags_w + desc_w + BORDER_OVERHEAD > TOTAL_MAX_WIDTH {
442            let available = TOTAL_MAX_WIDTH - BORDER_OVERHEAD;
443            let used = name_w + tags_w;
444            desc_w = available.saturating_sub(used).max(10); // Description min 10 chars
445        }
446
447        // Generate table header
448        let top_border = format!(
449            "┌─{}─â”Ŧ─{}─â”Ŧ─{}─┐",
450            "─".repeat(name_w),
451            "─".repeat(tags_w),
452            "─".repeat(desc_w)
453        );
454        let header = format!(
455            "│ {:width$} │ {:tags_w$} │ {:desc_w$} │",
456            "Service Name",
457            "Tags",
458            "Description",
459            width = name_w,
460            tags_w = tags_w,
461            desc_w = desc_w
462        );
463        let separator = format!(
464            "├─{}─â”ŧ─{}─â”ŧ─{}─┤",
465            "─".repeat(name_w),
466            "─".repeat(tags_w),
467            "─".repeat(desc_w)
468        );
469        let bottom_border = format!(
470            "└─{}─┴─{}─┴─{}─┘",
471            "─".repeat(name_w),
472            "─".repeat(tags_w),
473            "─".repeat(desc_w)
474        );
475
476        println!("{top_border}");
477        println!("{header}");
478        println!("{separator}");
479
480        for service in services {
481            let tags_str = service.tags.join(", ");
482            let description = service
483                .description
484                .as_deref()
485                .unwrap_or("No description")
486                .chars()
487                .take(desc_w)
488                .collect::<String>();
489
490            println!(
491                "│ {:name_w$} │ {:tags_w$} │ {:desc_w$} │",
492                service.name,
493                tags_str.chars().take(tags_w).collect::<String>(),
494                description,
495                name_w = name_w,
496                tags_w = tags_w,
497                desc_w = desc_w
498            );
499        }
500
501        println!("{bottom_border}");
502        println!();
503    }
504
505    /// Display service info
506    fn display_service_info(&self, service: &ServiceInfo) {
507        println!("📋 Selected service: {}", service.name);
508        if let Some(desc) = &service.description {
509            println!("📝 Description: {desc}");
510        }
511        println!("🔐 Fingerprint: {}", service.fingerprint);
512        let time = service
513            .published_at
514            .and_then(|published_at| chrono::DateTime::from_timestamp(published_at, 0))
515            .map(|dt| {
516                dt.with_timezone(&chrono::Local)
517                    .format("%Y-%m-%d %H:%M:%S")
518                    .to_string()
519            })
520            .unwrap_or_else(|| "Unknown".to_string());
521        println!("📅 Publication Time: {}", time);
522        println!(
523            "đŸˇī¸  Tags: {}",
524            if service.tags.is_empty() {
525                "(none)".to_string()
526            } else {
527                service.tags.join(", ")
528            }
529        );
530        println!("📊 Methods count: {}", service.methods.len());
531        println!();
532    }
533
534    #[allow(unused)]
535    /// Display service details
536    fn display_service_details(&self, details: &ServiceDetails) {
537        println!("📖 {} Detailed Information:", details.info.name);
538        println!("════════════════════════════════════════");
539        self.display_service_info(&details.info);
540        println!("📋 Available Methods:");
541        if details.info.methods.is_empty() {
542            println!("  (None)");
543        } else {
544            for method in &details.info.methods {
545                println!(
546                    "  â€ĸ {}: {} → {}",
547                    method.name, method.input_type, method.output_type
548                );
549            }
550        }
551
552        if !details.dependencies.is_empty() {
553            println!();
554            println!("🔗 Dependent Services:");
555            for dep in &details.dependencies {
556                println!("  â€ĸ {dep}");
557            }
558        }
559
560        println!();
561        println!("📁 Proto Files:");
562        if details.proto_files.is_empty() {
563            println!("  (None)");
564        } else {
565            for proto in &details.proto_files {
566                println!("  â€ĸ {} ({} services)", proto.name, proto.services.len());
567            }
568        }
569
570        println!();
571    }
572
573    /// Export proto files
574    async fn export_proto_files(
575        &self,
576        service: &ServiceInfo,
577        service_discovery: &std::sync::Arc<dyn ServiceDiscovery>,
578        config_manager: &std::sync::Arc<dyn ConfigManager>,
579    ) -> Result<()> {
580        println!("📤 Exporting proto files for {}...", service.name);
581
582        let proto_files = service_discovery.get_service_proto(&service.name).await?;
583
584        let output_dir = config_manager
585            .get_project_root()
586            .join("exports")
587            .join("remote")
588            .join(&service.name);
589        std::fs::create_dir_all(&output_dir)?;
590
591        for proto in &proto_files {
592            let file_path = output_dir.join(&proto.name);
593            if let Some(parent) = file_path.parent() {
594                std::fs::create_dir_all(parent)?;
595            }
596            std::fs::write(&file_path, &proto.content)?;
597            println!("✅ Exported: {}", file_path.display());
598        }
599
600        println!("🎉 Export completed, total {} files", proto_files.len());
601        Ok(())
602    }
603
604    /// Add to configuration file - core flow of reuse architecture
605    async fn add_to_config_with_validation(
606        &self,
607        service: &ServiceInfo,
608        context: &CommandContext,
609    ) -> Result<CommandResult> {
610        let (
611            config_manager,
612            user_interface,
613            dependency_resolver,
614            service_discovery,
615            network_validator,
616            fingerprint_validator,
617        ) = {
618            let container = context.container.lock().unwrap();
619            (
620                container.get_config_manager()?,
621                container.get_user_interface()?,
622                container.get_dependency_resolver()?,
623                container.get_service_discovery()?,
624                container.get_network_validator()?,
625                container.get_fingerprint_validator()?,
626            )
627        };
628
629        // Convert to dependency spec
630        let dependency_spec = DependencySpec {
631            alias: service.name.clone(),
632            actr_type: Some(service.actr_type.clone()),
633            name: service.name.clone(),
634            fingerprint: Some(service.fingerprint.clone()),
635        };
636
637        // Check if a dependency with the same name already exists
638        let config = config_manager
639            .load_config(
640                config_manager
641                    .get_project_root()
642                    .join("manifest.toml")
643                    .as_path(),
644            )
645            .await?;
646
647        let existing_by_name = config.dependencies.iter().find(|dep| {
648            dep.service
649                .as_ref()
650                .map(|s| s.name.as_str())
651                .or_else(|| dep.actr_type.as_ref().map(|t| t.name.as_str()))
652                .unwrap_or(dep.alias.as_str())
653                == service.name
654        });
655        let existing_by_alias = config
656            .dependencies
657            .iter()
658            .find(|dep| dep.alias == dependency_spec.alias);
659
660        if let Some(existing) = existing_by_alias
661            && existing
662                .service
663                .as_ref()
664                .map(|s| s.name.as_str())
665                .or_else(|| existing.actr_type.as_ref().map(|t| t.name.as_str()))
666                .unwrap_or(existing.alias.as_str())
667                != service.name
668        {
669            return Err(ActrCliError::Dependency {
670                message: format!(
671                    "Dependency alias '{}' already exists for '{}'",
672                    existing.alias,
673                    existing
674                        .service
675                        .as_ref()
676                        .map(|s| s.name.as_str())
677                        .or_else(|| existing.actr_type.as_ref().map(|t| t.name.as_str()))
678                        .unwrap_or(existing.alias.as_str())
679                ),
680            }
681            .into());
682        }
683
684        let should_update_config = existing_by_name.is_none();
685        if let Some(existing) = existing_by_name {
686            println!(
687                "â„šī¸  Dependency with name '{}' already exists (alias: '{}')",
688                service.name, existing.alias
689            );
690            if let (Some(existing_fp), Some(discovered_fp)) = (
691                existing.service.as_ref().map(|s| s.fingerprint.as_str()),
692                dependency_spec.fingerprint.as_deref(),
693            ) && existing_fp != discovered_fp
694            {
695                println!(
696                    "âš ī¸  Fingerprint mismatch: config '{}' vs discovery '{}'",
697                    existing_fp, discovered_fp
698                );
699            }
700            println!("   Skipping configuration update");
701        }
702
703        let expected_fingerprint = existing_by_name
704            .and_then(|dep| dep.service.as_ref().map(|s| s.fingerprint.clone()))
705            .or_else(|| dependency_spec.fingerprint.clone());
706        let existing_specs = dependency_resolver.resolve_spec(&config).await?;
707        self.validate_dependency(
708            service,
709            &dependency_spec,
710            expected_fingerprint.as_deref(),
711            should_update_config,
712            &existing_specs,
713            &dependency_resolver,
714            &service_discovery,
715            &network_validator,
716            &fingerprint_validator,
717        )
718        .await?;
719
720        if should_update_config {
721            println!("📝 Adding {} to configuration file...", service.name);
722            let backup = config_manager.backup_config().await?;
723            match config_manager.update_dependency(&dependency_spec).await {
724                Ok(_) => {
725                    config_manager.remove_backup(backup).await?;
726                    println!("✅ Added {} to configuration file", service.name);
727                }
728                Err(e) => {
729                    config_manager.restore_backup(backup).await?;
730                    return Err(ActrCliError::Config {
731                        message: format!("Configuration update failed: {e}"),
732                    }
733                    .into());
734                }
735            }
736        }
737
738        // Ask if user wants to install immediately
739        println!();
740        let should_install = if self.auto_install {
741            true
742        } else {
743            user_interface
744                .confirm("🤔 Install this dependency now?")
745                .await?
746        };
747
748        if should_install {
749            // Reuse install flow
750            println!();
751            println!("đŸ“Ļ Installing {}...", service.name);
752
753            let install_pipeline = {
754                let mut container = context.container.lock().unwrap();
755                match container.get_install_pipeline() {
756                    Ok(pipeline) => pipeline,
757                    Err(_) => {
758                        println!("â„šī¸ Install pipeline is not implemented yet; skipping.");
759                        return Ok(CommandResult::Success(
760                            "Dependency added; install pending".to_string(),
761                        ));
762                    }
763                }
764            };
765
766            match install_pipeline
767                .install_dependencies(&[dependency_spec])
768                .await
769            {
770                Ok(install_result) => {
771                    println!("  ├─ đŸ“Ļ Cache proto files ✅");
772                    println!("  ├─ 🔒 Update lock file ✅");
773                    println!("  └─ ✅ Installation complete");
774                    println!();
775                    println!("💡 Tip: Run 'actr gen' to generate the latest code");
776
777                    Ok(CommandResult::Install(install_result))
778                }
779                Err(e) => {
780                    eprintln!("❌ Installation failed: {e}");
781                    Ok(CommandResult::Success(
782                        "Dependency added but installation failed".to_string(),
783                    ))
784                }
785            }
786        } else {
787            println!("✅ Dependency added to configuration file");
788            println!("💡 Tip: Run 'actr deps install' to install dependencies");
789            Ok(CommandResult::Success(
790                "Dependency added to configuration".to_string(),
791            ))
792        }
793    }
794}
795
796impl Default for DiscoveryCommand {
797    fn default() -> Self {
798        Self::new(None, false, false)
799    }
800}
801
802#[cfg(test)]
803mod tests {
804    use super::*;
805
806    #[test]
807    fn test_create_service_filter() {
808        let cmd = DiscoveryCommand::new(Some("user-*".to_string()), false, false);
809        let filter = cmd.create_service_filter();
810
811        assert!(filter.is_some());
812        let filter = filter.unwrap();
813        assert_eq!(filter.name_pattern, Some("user-*".to_string()));
814    }
815
816    #[test]
817    fn test_create_service_filter_none() {
818        let cmd = DiscoveryCommand::new(None, false, false);
819        let filter = cmd.create_service_filter();
820
821        assert!(filter.is_none());
822    }
823
824    #[test]
825    fn test_required_components() {
826        let cmd = DiscoveryCommand::default();
827        let components = cmd.required_components();
828
829        // Discovery requires only ServiceDiscovery + UserInterface up front.
830        // ConfigManager and validators are obtained lazily when needed.
831        assert!(components.contains(&ComponentType::ServiceDiscovery));
832        assert!(components.contains(&ComponentType::UserInterface));
833        assert!(!components.contains(&ComponentType::ConfigManager));
834        assert!(!components.contains(&ComponentType::DependencyResolver));
835        assert!(!components.contains(&ComponentType::NetworkValidator));
836        assert!(!components.contains(&ComponentType::FingerprintValidator));
837    }
838}