actr_cli/commands/
check.rs

1//! Check command implementation - verify Actor-RTC service availability
2//!
3//! The check command validates that services are available in the registry
4//! and optionally verifies they match the configured dependencies.
5
6use crate::core::{
7    ActrCliError, AvailabilityStatus, Command, CommandContext, CommandResult, ComponentType,
8    ConnectivityStatus, HealthStatus, NetworkServiceDiscovery, ServiceDiscovery,
9};
10use actr_config::{Config, ConfigParser, LockFile};
11use anyhow::Result;
12use async_trait::async_trait;
13use clap::Args;
14use std::collections::{HashMap, HashSet};
15use std::path::{Path, PathBuf};
16use std::sync::Arc;
17use std::time::Duration;
18use tracing::{debug, error, info};
19
20/// Check command - validates service availability
21#[derive(Args, Debug)]
22#[command(
23    about = "Validate project dependencies",
24    long_about = "Validate that services are available in the registry and match the configured dependencies"
25)]
26pub struct CheckCommand {
27    /// Service names to check (e.g., "user-service", "order-service")
28    /// If not provided, checks all services from the configuration file
29    #[arg(value_name = "SERVICE_NAME")]
30    pub packages: Vec<String>,
31
32    /// Configuration file to load services from (defaults to Actr.toml)
33    #[arg(short = 'f', long = "file")]
34    pub config_file: Option<String>,
35
36    /// Show detailed connection information
37    #[arg(short, long)]
38    pub verbose: bool,
39
40    /// Timeout for each service check in seconds
41    #[arg(long, default_value = "10")]
42    pub timeout: u64,
43
44    /// Also verify services are installed in Actr.lock.toml
45    #[arg(long)]
46    pub lock: bool,
47}
48
49#[async_trait]
50impl Command for CheckCommand {
51    async fn execute(&self, context: &CommandContext) -> Result<CommandResult> {
52        let config_path = self.config_file.as_deref().unwrap_or("Actr.toml");
53        let config_path = self.resolve_config_path(context, config_path);
54        let mut loaded_config: Option<Config> = None;
55
56        // Determine which service packages to check
57        let packages_to_check = if self.packages.is_empty() {
58            info!(
59                "🔍 Loading services from configuration: {}",
60                config_path.display()
61            );
62            let config = self.load_config(&config_path)?;
63            let packages = self.load_packages_from_config(&config, &config_path);
64            loaded_config = Some(config);
65            packages
66        } else {
67            info!("🔍 Checking provided services");
68            self.packages.clone()
69        };
70
71        if packages_to_check.is_empty() {
72            info!("â„šī¸ No services to check");
73            return Ok(CommandResult::Success("No services to check".to_string()));
74        }
75
76        let service_discovery =
77            self.resolve_service_discovery(context, &config_path, loaded_config.as_ref())?;
78        let (network_validator, fingerprint_validator) = {
79            let container = context.container.lock().unwrap();
80            (
81                container.get_network_validator()?,
82                container.get_fingerprint_validator()?,
83            )
84        };
85
86        // Use loaded_config directly if available, otherwise load from file if it exists
87        // Load config if not already loaded and file exists
88        if loaded_config.is_none() && config_path.exists() {
89            let config = self.load_config(&config_path)?;
90            loaded_config = Some(config);
91        }
92        let fingerprint_config = loaded_config.as_ref();
93        let expected_fingerprints = self.collect_expected_fingerprints(fingerprint_config);
94
95        let lock_file = if self.lock {
96            info!("🔒 Checking Actr.lock.toml");
97            Some(self.load_lock_file(&config_path)?)
98        } else {
99            None
100        };
101        let lock_entries = lock_file
102            .as_ref()
103            .map(|lock| {
104                lock.dependencies
105                    .iter()
106                    .cloned()
107                    .map(|dep| (dep.name.clone(), dep))
108                    .collect::<HashMap<_, _>>()
109            })
110            .unwrap_or_default();
111
112        info!("đŸ“Ļ Checking {} services...", packages_to_check.len());
113
114        let mut total_checked = 0;
115        let mut available_count = 0;
116        let mut unavailable_count = 0;
117        let mut network_failures = 0;
118        let mut fingerprint_mismatches = 0;
119        let mut lock_mismatches = 0;
120        let mut missing_in_lock: Vec<String> = Vec::new();
121        let mut results: Vec<ServiceCheckReport> = Vec::new();
122        let mut problem_services: HashSet<String> = HashSet::new();
123
124        for package in &packages_to_check {
125            total_checked += 1;
126            let expected_fingerprint = expected_fingerprints.get(package).cloned();
127            let lock_entry = lock_entries.get(package);
128
129            let mut report = ServiceCheckReport::new(package.clone());
130            report.fingerprint_expected = expected_fingerprint.clone();
131
132            let check_result = self
133                .check_service(package.as_str(), &service_discovery)
134                .await;
135            match check_result {
136                Ok(status) => {
137                    report.availability = Some(status.clone());
138                    if status.is_available {
139                        available_count += 1;
140                    } else {
141                        unavailable_count += 1;
142                        problem_services.insert(package.clone());
143                    }
144                }
145                Err(e) => {
146                    report.availability_error = Some(e.to_string());
147                    unavailable_count += 1;
148                    problem_services.insert(package.clone());
149                }
150            }
151
152            if report.is_available() {
153                report.connectivity_checked = true;
154                match network_validator.check_connectivity(package).await {
155                    Ok(connectivity) => {
156                        if !connectivity.is_reachable {
157                            network_failures += 1;
158                            problem_services.insert(package.clone());
159                        }
160                        report.connectivity = Some(connectivity);
161                    }
162                    Err(e) => {
163                        network_failures += 1;
164                        problem_services.insert(package.clone());
165                        report.connectivity_error = Some(e.to_string());
166                    }
167                }
168            }
169
170            // Fetch fingerprint if verbose, expected fingerprint exists, or lock check is enabled
171            let should_fetch_fingerprint =
172                self.verbose || report.fingerprint_expected.is_some() || self.lock;
173            if should_fetch_fingerprint && report.is_available() {
174                report.fingerprint_checked = true;
175                match service_discovery.get_service_details(package).await {
176                    Ok(details) => match fingerprint_validator
177                        .compute_service_fingerprint(&details.info)
178                        .await
179                    {
180                        Ok(actual) => {
181                            report.fingerprint_actual = Some(actual.value);
182                        }
183                        Err(e) => {
184                            report.fingerprint_error = Some(e.to_string());
185                        }
186                    },
187                    Err(e) => {
188                        report.fingerprint_error = Some(e.to_string());
189                    }
190                }
191            }
192
193            if let (Some(expected), Some(actual)) = (
194                report.fingerprint_expected.as_deref(),
195                report.fingerprint_actual.as_deref(),
196            ) {
197                let matched = expected == actual;
198                report.fingerprint_match = Some(matched);
199                if !matched {
200                    fingerprint_mismatches += 1;
201                    problem_services.insert(package.clone());
202                }
203            }
204
205            if self.lock {
206                report.lock_detail.checked = true;
207                if let Some(lock_entry) = lock_entry {
208                    report.lock_detail.present = true;
209                    report.lock_detail.fingerprint = Some(lock_entry.fingerprint.clone());
210                    if let Some(actual) = report.fingerprint_actual.as_deref() {
211                        let matched = lock_entry.fingerprint == actual;
212                        report.lock_detail.is_match = Some(matched);
213                        if !matched {
214                            lock_mismatches += 1;
215                            problem_services.insert(package.clone());
216                        }
217                    }
218                } else {
219                    missing_in_lock.push(package.clone());
220                    problem_services.insert(package.clone());
221                }
222            }
223
224            results.push(report);
225        }
226
227        // Summary
228        info!("");
229        info!("📊 Service Check Summary:");
230        info!("   Total checked: {}", total_checked);
231        info!("   ✅ Available: {}", available_count);
232        info!("   ❌ Unavailable: {}", unavailable_count);
233        info!("   🌐 Network failures: {}", network_failures);
234        info!("   🔐 Fingerprint mismatches: {}", fingerprint_mismatches);
235        if self.lock {
236            info!("   🔒 Missing in Actr.lock.toml: {}", missing_in_lock.len());
237            info!("   🔒 Lock mismatches: {}", lock_mismatches);
238        }
239
240        // Detailed output if verbose
241        if self.verbose {
242            info!("");
243            info!("📋 Detailed Results:");
244            for report in &results {
245                info!("   🔎 [{}]", report.name);
246                match (&report.availability, &report.availability_error) {
247                    (Some(status), _) => {
248                        if status.is_available {
249                            info!("      Availability: available");
250                        } else {
251                            info!("      Availability: unavailable");
252                        }
253                        info!("      Health: {}", format_health(&status.health));
254                    }
255                    (None, Some(error_msg)) => {
256                        info!("      Availability: error ({})", error_msg);
257                    }
258                    _ => {
259                        info!("      Availability: unknown");
260                    }
261                }
262
263                if report.connectivity_checked {
264                    if let Some(connectivity) = &report.connectivity {
265                        let latency = connectivity
266                            .response_time_ms
267                            .map(|value| format!("{value}ms"))
268                            .unwrap_or_else(|| "unknown".to_string());
269                        let reachability = if connectivity.is_reachable {
270                            "reachable"
271                        } else {
272                            "unreachable"
273                        };
274                        info!(
275                            "      Connectivity: {} (latency: {})",
276                            reachability, latency
277                        );
278                        if let Some(error) = connectivity.error.as_deref() {
279                            info!("      Connectivity error: {}", error);
280                        }
281                    } else if let Some(error) = report.connectivity_error.as_deref() {
282                        info!("      Connectivity: error ({})", error);
283                    } else {
284                        info!("      Connectivity: unknown");
285                    }
286                } else {
287                    info!("      Connectivity: skipped");
288                }
289
290                if report.fingerprint_checked || report.fingerprint_expected.is_some() {
291                    let expected = report.fingerprint_expected.as_deref().unwrap_or("-");
292                    let actual = report.fingerprint_actual.as_deref().unwrap_or("-");
293                    let matched = match report.fingerprint_match {
294                        Some(true) => "match",
295                        Some(false) => "mismatch",
296                        None => "unknown",
297                    };
298                    info!(
299                        "      Fingerprint: expected={} actual={} match={}",
300                        expected, actual, matched
301                    );
302                    if let Some(error) = report.fingerprint_error.as_deref() {
303                        info!("      Fingerprint error: {}", error);
304                    }
305                } else {
306                    info!("      Fingerprint: skipped");
307                }
308
309                info!("      Lock: {}", format_lock_detail(&report.lock_detail));
310            }
311        }
312
313        if !problem_services.is_empty() {
314            let mut problems = Vec::new();
315            if unavailable_count > 0 {
316                problems.push(format!("{} services are unavailable", unavailable_count));
317            }
318            if network_failures > 0 {
319                problems.push(format!(
320                    "{} services failed network checks",
321                    network_failures
322                ));
323            }
324            if fingerprint_mismatches > 0 {
325                problems.push(format!(
326                    "{} services failed fingerprint checks",
327                    fingerprint_mismatches
328                ));
329            }
330            if self.lock && !missing_in_lock.is_empty() {
331                problems.push(format!(
332                    "{} services are missing from Actr.lock.toml",
333                    missing_in_lock.len()
334                ));
335            }
336            if self.lock && lock_mismatches > 0 {
337                problems.push(format!("{} services failed lock checks", lock_mismatches));
338            }
339            let message = if problems.is_empty() {
340                "Service checks failed".to_string()
341            } else {
342                problems.join(", ")
343            };
344            error!("âš ī¸ {message}");
345            return Err(ActrCliError::Dependency { message }.into());
346        }
347
348        if self.lock {
349            info!("🎉 All services passed checks and match Actr.lock.toml!");
350        } else {
351            info!("🎉 All services passed availability checks!");
352        }
353
354        Ok(CommandResult::Success(format!(
355            "Checked {} services, all available",
356            total_checked
357        )))
358    }
359
360    fn required_components(&self) -> Vec<ComponentType> {
361        vec![
362            ComponentType::ServiceDiscovery,
363            ComponentType::NetworkValidator,
364            ComponentType::FingerprintValidator,
365        ]
366    }
367
368    fn name(&self) -> &str {
369        "check"
370    }
371
372    fn description(&self) -> &str {
373        "Validate that services are available in the registry"
374    }
375}
376
377impl CheckCommand {
378    fn resolve_config_path(&self, context: &CommandContext, config_path: &str) -> PathBuf {
379        let path = Path::new(config_path);
380        if path.is_absolute() {
381            path.to_path_buf()
382        } else {
383            context.working_dir.join(path)
384        }
385    }
386
387    fn load_config(&self, config_path: &Path) -> Result<Config> {
388        Ok(
389            ConfigParser::from_file(config_path).map_err(|e| ActrCliError::Config {
390                message: format!("Failed to load config {}: {e}", config_path.display()),
391            })?,
392        )
393    }
394
395    /// Load service names from configuration file
396    fn load_packages_from_config(&self, config: &Config, config_path: &Path) -> Vec<String> {
397        let mut packages = Vec::new();
398
399        for dependency in &config.dependencies {
400            packages.push(dependency.name.clone());
401            debug!(
402                "Added service: {} (alias: {})",
403                dependency.name, dependency.alias
404            );
405        }
406
407        if packages.is_empty() {
408            info!(
409                "â„šī¸ No dependencies found in configuration file: {}",
410                config_path.display()
411            );
412        } else {
413            info!("📋 Found {} services in configuration", packages.len());
414        }
415
416        packages
417    }
418
419    fn resolve_service_discovery(
420        &self,
421        context: &CommandContext,
422        config_path: &Path,
423        config: Option<&Config>,
424    ) -> Result<Arc<dyn ServiceDiscovery>> {
425        if self.config_file.is_some() {
426            let config = match config {
427                Some(config) => config.clone(),
428                None => self.load_config(config_path)?,
429            };
430            return Ok(Arc::new(NetworkServiceDiscovery::new(config)));
431        }
432
433        let container = context.container.lock().unwrap();
434        match container.get_service_discovery() {
435            Ok(service_discovery) => Ok(service_discovery),
436            Err(_) => {
437                let config = match config {
438                    Some(config) => config.clone(),
439                    None => self.load_config(config_path)?,
440                };
441                Ok(Arc::new(NetworkServiceDiscovery::new(config)))
442            }
443        }
444    }
445
446    fn collect_expected_fingerprints(&self, config: Option<&Config>) -> HashMap<String, String> {
447        let mut expected = HashMap::new();
448        if let Some(config) = config {
449            for dependency in &config.dependencies {
450                if let Some(fingerprint) = dependency.fingerprint.as_ref()
451                    && !fingerprint.is_empty()
452                {
453                    expected.insert(dependency.name.clone(), fingerprint.clone());
454                }
455            }
456        }
457        expected
458    }
459
460    fn load_lock_file(&self, config_path: &Path) -> Result<LockFile> {
461        let lock_file_path = config_path
462            .parent()
463            .unwrap_or_else(|| Path::new("."))
464            .join("Actr.lock.toml");
465
466        if !lock_file_path.exists() {
467            return Err(ActrCliError::Config {
468                message: format!("Lock file not found: {}", lock_file_path.display()),
469            }
470            .into());
471        }
472
473        let lock_file = LockFile::from_file(&lock_file_path).map_err(|e| ActrCliError::Config {
474            message: format!("Failed to load lock file {}: {e}", lock_file_path.display()),
475        })?;
476        Ok(lock_file)
477    }
478
479    /// Check service availability using ServiceDiscovery
480    async fn check_service(
481        &self,
482        service_name: &str,
483        service_discovery: &Arc<dyn ServiceDiscovery>,
484    ) -> Result<AvailabilityStatus> {
485        debug!("Checking service availability: {}", service_name);
486
487        let status = if self.timeout == 0 {
488            service_discovery
489                .check_service_availability(service_name)
490                .await?
491        } else {
492            let duration = Duration::from_secs(self.timeout);
493            match tokio::time::timeout(
494                duration,
495                service_discovery.check_service_availability(service_name),
496            )
497            .await
498            {
499                Ok(result) => result?,
500                Err(_) => {
501                    return Err(ActrCliError::Network {
502                        message: format!(
503                            "Timeout after {}s while checking {}",
504                            self.timeout, service_name
505                        ),
506                    }
507                    .into());
508                }
509            }
510        };
511
512        Ok(status)
513    }
514}
515
516struct ServiceCheckReport {
517    name: String,
518    availability: Option<AvailabilityStatus>,
519    availability_error: Option<String>,
520    connectivity_checked: bool,
521    connectivity: Option<ConnectivityStatus>,
522    connectivity_error: Option<String>,
523    fingerprint_checked: bool,
524    fingerprint_expected: Option<String>,
525    fingerprint_actual: Option<String>,
526    fingerprint_match: Option<bool>,
527    fingerprint_error: Option<String>,
528    lock_detail: LockCheckDetail,
529}
530
531impl ServiceCheckReport {
532    fn new(name: String) -> Self {
533        Self {
534            name,
535            availability: None,
536            availability_error: None,
537            connectivity_checked: false,
538            connectivity: None,
539            connectivity_error: None,
540            fingerprint_checked: false,
541            fingerprint_expected: None,
542            fingerprint_actual: None,
543            fingerprint_match: None,
544            fingerprint_error: None,
545            lock_detail: LockCheckDetail::skipped(),
546        }
547    }
548
549    fn is_available(&self) -> bool {
550        self.availability
551            .as_ref()
552            .map(|status| status.is_available)
553            .unwrap_or(false)
554    }
555}
556
557struct LockCheckDetail {
558    checked: bool,
559    present: bool,
560    fingerprint: Option<String>,
561    is_match: Option<bool>,
562    error: Option<String>,
563}
564
565impl LockCheckDetail {
566    fn skipped() -> Self {
567        Self {
568            checked: false,
569            present: false,
570            fingerprint: None,
571            is_match: None,
572            error: None,
573        }
574    }
575}
576
577fn format_health(health: &HealthStatus) -> &'static str {
578    match health {
579        HealthStatus::Healthy => "healthy",
580        HealthStatus::Degraded => "degraded",
581        HealthStatus::Unhealthy => "unhealthy",
582        HealthStatus::Unknown => "unknown",
583    }
584}
585
586fn format_lock_detail(detail: &LockCheckDetail) -> String {
587    if !detail.checked {
588        return "skipped".to_string();
589    }
590    if !detail.present {
591        return "missing".to_string();
592    }
593
594    let fingerprint = detail.fingerprint.as_deref().unwrap_or("-");
595    let matched = match detail.is_match {
596        Some(true) => "match",
597        Some(false) => "mismatch",
598        None => "unknown",
599    };
600
601    if let Some(error) = detail.error.as_deref() {
602        format!(
603            "present (fingerprint={}, match={}, error={})",
604            fingerprint, matched, error
605        )
606    } else {
607        format!("present (fingerprint={}, match={})", fingerprint, matched)
608    }
609}