1use 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#[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 #[arg(value_name = "SERVICE_NAME")]
30 pub packages: Vec<String>,
31
32 #[arg(short = 'f', long = "file")]
34 pub config_file: Option<String>,
35
36 #[arg(short, long)]
38 pub verbose: bool,
39
40 #[arg(long, default_value = "10")]
42 pub timeout: u64,
43
44 #[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 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 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 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 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 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 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 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}