1use 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#[derive(Debug, Clone)]
18pub struct StandaloneDiscoverConfig {
19 pub endpoint: url::Url,
20 pub realm_id: u64,
21 pub realm_secret: String,
22}
23
24#[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 #[arg(long, value_name = "PATTERN")]
41 pub filter: Option<String>,
42
43 #[arg(long)]
45 pub verbose: bool,
46
47 #[arg(long)]
49 pub auto_install: bool,
50
51 #[arg(long)]
53 pub list_only: bool,
54
55 #[arg(long, value_name = "URL")]
60 pub endpoint: Option<url::Url>,
61
62 #[arg(long, value_name = "REALM_ID")]
64 pub realm_id: Option<u64>,
65
66 #[arg(long, value_name = "SECRET")]
68 pub realm_secret: Option<String>,
69}
70
71impl DiscoveryCommand {
72 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 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 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 self.display_services_table(&services);
115
116 if self.list_only {
117 return Ok(CommandResult::Success("Services listed".to_string()));
118 }
119
120 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 let menu_prompt = format!("Options for {}", selected_service.name);
147
148 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 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 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 vec![
204 ComponentType::ServiceDiscovery, ComponentType::UserInterface, ]
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 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 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 fn display_services_table(&self, services: &[ServiceInfo]) {
402 println!();
403 const TOTAL_MAX_WIDTH: usize = 160;
405 const BORDER_OVERHEAD: usize = 7;
407
408 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 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); }
446
447 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 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 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 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 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 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 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 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 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 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}