1use std::collections::{HashMap, HashSet};
18use std::path::PathBuf;
19use std::sync::Arc;
20use std::sync::atomic::{AtomicBool, Ordering};
21
22use chrono::Utc;
23use colored::Colorize;
24use indicatif::{ProgressBar, ProgressStyle};
25use serde::{Deserialize, Serialize};
26
27use super::config::{SourceConfigGenerator, SourcesConfig};
28use super::discover_ssh_hosts;
29use super::index::{IndexProgress, RemoteIndexer};
30use super::install::{InstallProgress, RemoteInstaller};
31use super::interactive::{confirm_action, run_host_selection};
32use super::probe::{CassStatus, HostProbeResult, deduplicate_probe_results, probe_hosts_parallel};
33
34#[derive(Debug, Clone)]
36pub struct SetupOptions {
37 pub dry_run: bool,
39 pub non_interactive: bool,
41 pub hosts: Option<Vec<String>>,
43 pub skip_install: bool,
45 pub skip_index: bool,
47 pub skip_sync: bool,
49 pub timeout: u64,
51 pub resume: bool,
53 pub verbose: bool,
55 pub json: bool,
57}
58
59impl Default for SetupOptions {
60 fn default() -> Self {
61 Self {
62 dry_run: false,
63 non_interactive: false,
64 hosts: None,
65 skip_install: false,
66 skip_index: false,
67 skip_sync: false,
68 timeout: 10,
69 resume: false,
70 verbose: false,
71 json: false,
72 }
73 }
74}
75
76#[derive(Debug, Clone, PartialEq, Eq)]
77struct SelectedHostNameConflict {
78 kept_host_name: String,
79 skipped_host_name: String,
80 kept_source_name: String,
81}
82
83fn dedupe_selected_hosts_by_generated_name(
84 selected_hosts: Vec<&HostProbeResult>,
85) -> (Vec<&HostProbeResult>, Vec<SelectedHostNameConflict>) {
86 let mut selected = Vec::new();
87 let mut conflicts = Vec::new();
88 let mut seen_name_keys: HashMap<String, (String, String)> = HashMap::new();
89
90 for host in selected_hosts {
91 let generated_name = super::config::normalize_generated_remote_source_name(&host.host_name);
92 let generated_name_key = super::config::source_name_key(&generated_name);
93 if let Some((kept_host_name, kept_source_name)) = seen_name_keys.get(&generated_name_key) {
94 conflicts.push(SelectedHostNameConflict {
95 kept_host_name: kept_host_name.clone(),
96 skipped_host_name: host.host_name.clone(),
97 kept_source_name: kept_source_name.clone(),
98 });
99 continue;
100 }
101
102 seen_name_keys.insert(generated_name_key, (host.host_name.clone(), generated_name));
103 selected.push(host);
104 }
105
106 (selected, conflicts)
107}
108
109fn setup_should_index_host(
110 host: &HostProbeResult,
111 completed_installs: &HashSet<&str>,
112 planned_installs: &HashSet<&str>,
113) -> bool {
114 if matches!(
115 host.cass_status,
116 CassStatus::Indexed { session_count, .. } if session_count > 0
117 ) {
118 return false;
119 }
120
121 let host_name = host.host_name.as_str();
122 completed_installs.contains(host_name)
126 || planned_installs.contains(host_name)
127 || RemoteIndexer::needs_indexing(host)
128}
129
130#[derive(Debug, Clone, Serialize, Deserialize, Default)]
132pub struct SetupState {
133 pub discovery_complete: bool,
135 pub discovered_hosts: usize,
137 pub discovered_host_names: Vec<String>,
139 pub probing_complete: bool,
141 #[serde(default)]
143 pub probed_hosts: Vec<HostProbeResult>,
144 pub selection_complete: bool,
146 pub selected_host_names: Vec<String>,
148 pub installation_complete: bool,
150 pub completed_installs: Vec<String>,
152 pub indexing_complete: bool,
154 pub completed_indexes: Vec<String>,
156 pub configuration_complete: bool,
158 pub sync_complete: bool,
160 pub current_operation: Option<String>,
162 pub started_at: Option<String>,
164}
165
166impl SetupState {
167 fn path() -> PathBuf {
169 dirs::cache_dir()
170 .unwrap_or_else(|| PathBuf::from("."))
171 .join("cass")
172 .join("setup_state.json")
173 }
174
175 pub fn load() -> Result<Option<Self>, SetupError> {
177 let path = Self::path();
178 match std::fs::read_to_string(&path) {
179 Ok(content) => {
180 let state = serde_json::from_str(&content).map_err(SetupError::Json)?;
181 Ok(Some(state))
182 }
183 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(None),
184 Err(e) => Err(SetupError::Io(e)),
185 }
186 }
187
188 pub fn save(&self) -> Result<(), SetupError> {
190 let path = Self::path();
191 if let Some(parent) = path.parent() {
192 std::fs::create_dir_all(parent).map_err(SetupError::Io)?;
193 }
194 let content = serde_json::to_string_pretty(self).map_err(SetupError::Json)?;
195 std::fs::write(&path, content).map_err(SetupError::Io)?;
196 Ok(())
197 }
198
199 pub fn clear() -> Result<(), SetupError> {
201 let path = Self::path();
202 match std::fs::remove_file(&path) {
203 Ok(()) => Ok(()),
204 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
205 Err(e) => Err(SetupError::Io(e)),
206 }
207 }
208
209 pub fn has_progress(&self) -> bool {
211 self.discovery_complete
212 || self.probing_complete
213 || self.selection_complete
214 || self.installation_complete
215 || self.indexing_complete
216 || self.configuration_complete
217 }
218}
219
220#[derive(Debug, thiserror::Error)]
222pub enum SetupError {
223 #[error("IO error: {0}")]
225 Io(std::io::Error),
226 #[error("JSON error: {0}")]
228 Json(serde_json::Error),
229 #[error("Config error: {0}")]
231 Config(super::config::ConfigError),
232 #[error("Install error: {0}")]
234 Install(super::install::InstallError),
235 #[error("Index error: {0}")]
237 Index(super::index::IndexError),
238 #[error("Interactive error: {0}")]
240 Interactive(super::interactive::InteractiveError),
241 #[error("Setup cancelled by user")]
243 Cancelled,
244 #[error("No SSH hosts found or selected")]
246 NoHosts,
247 #[error("Setup interrupted")]
249 Interrupted,
250}
251
252#[derive(Debug)]
254pub struct SetupResult {
255 pub sources_added: usize,
257 pub hosts_installed: usize,
259 pub hosts_indexed: usize,
261 pub total_sessions: u64,
263 pub dry_run: bool,
265}
266
267fn print_phase_header(phase: &str) {
269 println!();
270 println!(
271 "{}",
272 format!("┌─ {} ", phase).bold().on_bright_black().white()
273 );
274}
275
276fn print_phase_done(message: &str) {
278 println!("│ {} {}", "✓".green(), message);
279 println!("└{}", "─".repeat(70).dimmed());
280}
281
282pub fn run_setup(opts: &SetupOptions) -> Result<SetupResult, SetupError> {
284 let interrupted = Arc::new(AtomicBool::new(false));
286
287 let mut state = if opts.resume {
289 SetupState::load()?.unwrap_or_default()
290 } else {
291 SetupState::default()
292 };
293
294 if state.started_at.is_none() {
295 state.started_at = Some(Utc::now().to_rfc3339());
296 }
297
298 let check_interrupted = || {
300 if interrupted.load(Ordering::SeqCst) {
301 Err(SetupError::Interrupted)
302 } else {
303 Ok(())
304 }
305 };
306
307 if !opts.json {
309 println!();
310 println!(
311 "{}",
312 "╭─────────────────────────────────────────────────────────────────────────────╮"
313 .bright_blue()
314 );
315 println!(
316 "{}",
317 "│ cass sources setup │"
318 .bright_blue()
319 );
320 println!(
321 "{}",
322 "╰─────────────────────────────────────────────────────────────────────────────╯"
323 .bright_blue()
324 );
325
326 if opts.dry_run {
327 println!();
328 println!("{}", " [DRY RUN - no changes will be made]".yellow());
329 }
330
331 if opts.resume && state.has_progress() {
332 println!();
333 println!("{}", " Resuming from previous session...".cyan());
334 }
335 }
336
337 let discovered_hosts = if !state.discovery_complete {
341 check_interrupted()?;
342
343 if !opts.json {
344 print_phase_header("Phase 1: Discovery");
345 }
346
347 let hosts = if let Some(ref specific_hosts) = opts.hosts {
348 specific_hosts
350 .iter()
351 .map(|h| super::config::DiscoveredHost {
352 name: h.clone(),
353 hostname: None,
354 user: None,
355 port: None,
356 identity_file: None,
357 })
358 .collect()
359 } else {
360 discover_ssh_hosts()
362 };
363
364 state.discovered_hosts = hosts.len();
365 state.discovered_host_names = hosts.iter().map(|h| h.name.clone()).collect();
366 state.discovery_complete = true;
367 state.save()?;
368
369 if !opts.json {
370 if opts.hosts.is_some() {
371 print_phase_done(&format!("Using {} specified host(s)", hosts.len()));
372 } else {
373 print_phase_done(&format!("Found {} SSH hosts in ~/.ssh/config", hosts.len()));
374 }
375 }
376
377 hosts
378 } else {
379 state
381 .discovered_host_names
382 .iter()
383 .map(|name| super::config::DiscoveredHost {
384 name: name.clone(),
385 hostname: None,
386 user: None,
387 port: None,
388 identity_file: None,
389 })
390 .collect()
391 };
392
393 if discovered_hosts.is_empty() {
394 if !opts.json {
395 println!();
396 println!(
397 "{}",
398 " No SSH hosts found. Add hosts to ~/.ssh/config or use --hosts.".yellow()
399 );
400 }
401 SetupState::clear()?;
402 return Err(SetupError::NoHosts);
403 }
404
405 let probed_hosts = if !state.probing_complete {
409 check_interrupted()?;
410
411 if !opts.json {
412 print_phase_header("Phase 2: Probing hosts");
413 }
414
415 let progress = if !opts.json {
416 let pb = ProgressBar::new(discovered_hosts.len() as u64);
417 pb.set_style(
418 ProgressStyle::default_bar()
419 .template("│ {bar:50.cyan/blue} {pos}/{len} {msg}")
420 .expect("valid progress bar template")
421 .progress_chars("██░"),
422 );
423 Some(pb)
424 } else {
425 None
426 };
427
428 let progress_clone = progress.clone();
429 let results = probe_hosts_parallel(
430 &discovered_hosts,
431 opts.timeout,
432 move |completed, total, name| {
433 if let Some(ref pb) = progress_clone {
434 pb.set_position(completed as u64);
435 pb.set_message(format!("{}/{} - {}", completed, total, name));
436 }
437 },
438 );
439
440 if let Some(pb) = &progress {
441 pb.finish_and_clear();
442 }
443
444 let (results, merged_aliases) = deduplicate_probe_results(results);
446
447 let reachable = results.iter().filter(|p| p.reachable).count();
448 let with_cass = results
449 .iter()
450 .filter(|p| p.cass_status.is_installed())
451 .count();
452
453 state.probed_hosts = results.clone();
454 state.probing_complete = true;
455 state.save()?;
456
457 if !opts.json {
458 print_phase_done(&format!(
459 "{} reachable, {} with cass installed",
460 reachable, with_cass
461 ));
462
463 if !merged_aliases.is_empty() {
465 let total_merged: usize = merged_aliases.values().map(|v| v.len()).sum();
466 println!(
467 "│ {} {} duplicate alias(es) merged (same machine):",
468 "ℹ".blue(),
469 total_merged
470 );
471 let mut sorted_merges: Vec<_> = merged_aliases.iter().collect();
473 sorted_merges.sort_by_key(|(k, _)| *k);
474 for (kept, aliases) in sorted_merges {
475 let mut sorted_aliases = aliases.clone();
476 sorted_aliases.sort();
477 println!(
478 "│ {} ← {}",
479 kept.bold(),
480 sorted_aliases.join(", ").dimmed()
481 );
482 }
483 }
484 }
485
486 results
487 } else {
488 state.probed_hosts.clone()
489 };
490
491 let reachable_hosts: Vec<_> = probed_hosts.iter().filter(|p| p.reachable).collect();
492
493 if reachable_hosts.is_empty() {
494 if !opts.json {
495 println!();
496 println!(
497 "{}",
498 " No reachable hosts found. Check SSH connectivity.".yellow()
499 );
500 }
501 SetupState::clear()?;
502 return Err(SetupError::NoHosts);
503 }
504
505 let selection_performed = !state.selection_complete;
509 let mut selected_hosts: Vec<&HostProbeResult> = if !state.selection_complete {
510 check_interrupted()?;
511
512 if !opts.json {
513 print_phase_header("Phase 3: Host Selection");
514 }
515
516 let existing_config = SourcesConfig::load().unwrap_or_default();
517 let existing_name_keys: HashSet<_> = existing_config.configured_name_keys();
518
519 if opts.non_interactive {
520 let mut selected_name_keys = existing_name_keys.clone();
522 let auto_selected: Vec<_> = reachable_hosts
523 .iter()
524 .filter(|h| {
525 let generated_name =
526 super::config::normalize_generated_remote_source_name(&h.host_name);
527 selected_name_keys.insert(super::config::source_name_key(&generated_name))
528 })
529 .copied()
530 .collect();
531
532 auto_selected
533 } else {
534 let probes_for_selection: Vec<HostProbeResult> =
537 reachable_hosts.iter().map(|p| (*p).clone()).collect();
538
539 match run_host_selection(&probes_for_selection, &existing_name_keys) {
540 Ok((result, display_infos)) => {
541 let selected: Vec<_> = result
543 .selected_indices
544 .iter()
545 .filter_map(|&idx| {
546 display_infos.get(idx).and_then(|info| {
547 reachable_hosts
548 .iter()
549 .find(|h| h.host_name == info.hostname)
550 .copied()
551 })
552 })
553 .collect();
554 selected
555 }
556 Err(e) => {
557 state.save()?;
558 return Err(SetupError::Interactive(e));
559 }
560 }
561 }
562 } else {
563 state
565 .selected_host_names
566 .iter()
567 .filter_map(|name| probed_hosts.iter().find(|h| h.host_name == *name))
568 .collect()
569 };
570
571 let (deduped_selected_hosts, selection_conflicts) =
572 dedupe_selected_hosts_by_generated_name(selected_hosts);
573 selected_hosts = deduped_selected_hosts;
574
575 if selection_performed && !opts.json {
576 let selection_message = if opts.non_interactive {
577 format!(
578 "Auto-selected {} hosts (non-interactive)",
579 selected_hosts.len()
580 )
581 } else {
582 format!("Selected {} hosts", selected_hosts.len())
583 };
584 print_phase_done(&selection_message);
585 }
586
587 if !selection_conflicts.is_empty() && !opts.json {
588 println!(
589 "│ {} skipped {} host(s) because their generated source names conflict:",
590 "Warning:".yellow().bold(),
591 selection_conflicts.len()
592 );
593 for conflict in &selection_conflicts {
594 println!(
595 "│ - {} skipped; conflicts with {} as source '{}'",
596 conflict.skipped_host_name, conflict.kept_host_name, conflict.kept_source_name
597 );
598 }
599 println!(
600 "│ Edit host aliases or use 'cass sources add --name ...' later if you need distinct source IDs."
601 );
602 }
603
604 let selected_host_names: Vec<String> =
605 selected_hosts.iter().map(|h| h.host_name.clone()).collect();
606 if !state.selection_complete || state.selected_host_names != selected_host_names {
607 state.selected_host_names = selected_host_names;
608 state.selection_complete = true;
609 state.save()?;
610 }
611
612 if selected_hosts.is_empty() {
613 if !opts.json {
614 println!();
615 println!("{}", " No hosts selected. Setup cancelled.".yellow());
616 }
617 SetupState::clear()?;
618 return Ok(SetupResult {
619 sources_added: 0,
620 hosts_installed: 0,
621 hosts_indexed: 0,
622 total_sessions: 0,
623 dry_run: opts.dry_run,
624 });
625 }
626
627 let mut hosts_installed = 0;
631 let mut dry_run_planned_install_host_names: HashSet<String> = HashSet::new();
632
633 if !opts.skip_install && !state.installation_complete {
634 check_interrupted()?;
635
636 let needs_install: Vec<_> = selected_hosts
637 .iter()
638 .filter(|h| !h.cass_status.is_installed())
639 .filter(|h| !state.completed_installs.contains(&h.host_name))
640 .collect();
641
642 if !needs_install.is_empty() {
643 if !opts.json {
644 print_phase_header("Phase 4: Installing cass");
645 }
646
647 if opts.dry_run {
648 if !opts.json {
649 println!("│ Would install cass on {} hosts:", needs_install.len());
650 for host in &needs_install {
651 println!("│ - {}", host.host_name);
652 }
653 println!("└{}", "─".repeat(70).dimmed());
654 }
655 dry_run_planned_install_host_names
656 .extend(needs_install.iter().map(|host| host.host_name.clone()));
657 hosts_installed = needs_install.len();
658 } else {
659 let proceed = if opts.non_interactive {
661 true
662 } else {
663 confirm_action(
664 &format!("Install cass on {} hosts?", needs_install.len()),
665 true,
666 )
667 .unwrap_or(false)
668 };
669
670 if proceed {
671 for host in needs_install {
672 check_interrupted()?;
673
674 state.current_operation = Some(format!("Installing on {}", host.host_name));
675 state.save()?;
676
677 let Some(system_info) = host.system_info.clone() else {
680 if !opts.json {
681 println!(
682 "│ {} {} skipped (no system info)",
683 "⚠".yellow(),
684 host.host_name
685 );
686 }
687 continue;
688 };
689 let Some(resources) = host.resources.clone() else {
690 if !opts.json {
691 println!(
692 "│ {} {} skipped (no resource info)",
693 "⚠".yellow(),
694 host.host_name
695 );
696 }
697 continue;
698 };
699 let installer =
700 RemoteInstaller::new(host.host_name.clone(), system_info, resources);
701
702 if !opts.json {
703 println!("│ Installing on {}...", host.host_name);
704 }
705
706 let host_name_for_progress = host.host_name.clone();
707 let verbose = opts.verbose;
708 let json = opts.json;
709 let progress_callback = move |progress: InstallProgress| {
710 if verbose && !json {
711 println!(
712 "│ {}: {} ({}%)",
713 host_name_for_progress,
714 progress.stage, progress.percent.unwrap_or(0)
716 );
717 }
718 };
719
720 match installer.install(progress_callback) {
721 Ok(_) => {
722 if !opts.json {
723 println!("│ {} {} installed", "✓".green(), host.host_name);
724 }
725 state.completed_installs.push(host.host_name.clone());
726 state.save()?;
727 hosts_installed += 1;
728 }
729 Err(e) => {
730 if !opts.json {
731 println!("│ {} {} failed: {}", "✗".red(), host.host_name, e);
732 }
733 if opts.verbose {
734 eprintln!(" Install error: {e}");
735 }
736 }
737 }
738 }
739
740 if !opts.json {
741 print_phase_done(&format!("Installed cass on {} hosts", hosts_installed));
742 }
743 } else if !opts.json {
744 println!("│ Skipping installation.");
745 println!("└{}", "─".repeat(70).dimmed());
746 }
747 }
748 }
749
750 if !opts.dry_run {
751 let completed: HashSet<&str> = state
752 .completed_installs
753 .iter()
754 .map(std::string::String::as_str)
755 .collect();
756 let remaining_installs = selected_hosts
757 .iter()
758 .filter(|h| !h.cass_status.is_installed())
759 .filter(|h| !completed.contains(h.host_name.as_str()))
760 .count();
761 state.installation_complete = remaining_installs == 0;
762 state.save()?;
763 }
764 }
765
766 let mut hosts_indexed = 0;
770
771 if !opts.skip_index && !state.indexing_complete {
772 check_interrupted()?;
773
774 let completed_install_host_names: HashSet<&str> = state
775 .completed_installs
776 .iter()
777 .map(std::string::String::as_str)
778 .collect();
779 let dry_run_planned_install_host_names: HashSet<&str> = dry_run_planned_install_host_names
780 .iter()
781 .map(std::string::String::as_str)
782 .collect();
783 let needs_index: Vec<_> = selected_hosts
784 .iter()
785 .filter(|h| {
786 setup_should_index_host(
787 h,
788 &completed_install_host_names,
789 &dry_run_planned_install_host_names,
790 )
791 })
792 .filter(|h| !state.completed_indexes.contains(&h.host_name))
793 .collect();
794
795 if !needs_index.is_empty() {
796 if !opts.json {
797 print_phase_header("Phase 5: Indexing sessions");
798 }
799
800 if opts.dry_run {
801 if !opts.json {
802 println!("│ Would index sessions on {} hosts", needs_index.len());
803 println!("└{}", "─".repeat(70).dimmed());
804 }
805 hosts_indexed = needs_index.len();
806 } else {
807 for host in needs_index {
808 check_interrupted()?;
809
810 state.current_operation = Some(format!("Indexing on {}", host.host_name));
811 state.save()?;
812
813 if !opts.json {
814 println!("│ Indexing on {}...", host.host_name);
815 }
816
817 let indexer = RemoteIndexer::with_defaults(host.host_name.clone());
819
820 let host_name_for_progress = host.host_name.clone();
821 let verbose = opts.verbose;
822 let json = opts.json;
823 let progress_callback = move |progress: IndexProgress| {
824 if verbose && !json {
825 let pct = progress.percent.unwrap_or(0);
826 println!(
827 "│ {}: {} ({}%)",
828 host_name_for_progress,
829 progress.stage, pct
831 );
832 }
833 };
834
835 match indexer.run_index(progress_callback) {
836 Ok(result) => {
837 if !opts.json {
838 println!("│ {} {} indexed", "✓".green(), host.host_name);
839 if opts.verbose
840 && let Some(artifact) = &result.artifact_manifest
841 {
842 if artifact.success {
843 println!(
844 "│ {} artifact proof {} ({} chunks)",
845 "✓".green(),
846 artifact
847 .bundle_id
848 .as_deref()
849 .unwrap_or("bundle id unavailable"),
850 artifact.chunk_count.unwrap_or(0)
851 );
852 } else {
853 println!(
854 "│ {} artifact proof unavailable: {}",
855 "⚠".yellow(),
856 artifact
857 .error
858 .as_deref()
859 .unwrap_or("unknown artifact manifest error")
860 );
861 }
862 }
863 }
864 state.completed_indexes.push(host.host_name.clone());
865 state.save()?;
866 hosts_indexed += 1;
867 }
868 Err(e) => {
869 if !opts.json {
870 println!(
871 "│ {} Index error on {}: {}",
872 "✗".red(),
873 host.host_name,
874 e
875 );
876 }
877 }
878 }
879 }
880
881 if !opts.json {
882 print_phase_done(&format!("Indexed {} hosts", hosts_indexed));
883 }
884 }
885 }
886
887 if !opts.dry_run {
888 let completed: HashSet<&str> = state
889 .completed_indexes
890 .iter()
891 .map(std::string::String::as_str)
892 .collect();
893 let completed_install_host_names: HashSet<&str> = state
894 .completed_installs
895 .iter()
896 .map(std::string::String::as_str)
897 .collect();
898 let pending_install_host_names: HashSet<&str> = if opts.skip_install {
899 HashSet::new()
900 } else {
901 selected_hosts
902 .iter()
903 .filter(|h| !h.cass_status.is_installed())
904 .filter(|h| !completed_install_host_names.contains(h.host_name.as_str()))
905 .map(|h| h.host_name.as_str())
906 .collect()
907 };
908 let remaining_indexes = selected_hosts
909 .iter()
910 .filter(|h| {
911 setup_should_index_host(
912 h,
913 &completed_install_host_names,
914 &pending_install_host_names,
915 )
916 })
917 .filter(|h| !completed.contains(h.host_name.as_str()))
918 .count();
919 state.indexing_complete = remaining_indexes == 0;
920 state.save()?;
921 }
922 }
923
924 let mut sources_added = 0;
928
929 if !state.configuration_complete {
930 check_interrupted()?;
931
932 if !opts.json {
933 print_phase_header("Phase 6: Configuring sources");
934 }
935
936 let mut config = SourcesConfig::load().unwrap_or_default();
937 let generator = SourceConfigGenerator::new();
938
939 let probes: Vec<(&str, &HostProbeResult)> = selected_hosts
941 .iter()
942 .map(|h| (h.host_name.as_str(), *h))
943 .collect();
944
945 let preview = generator.generate_preview(&probes, &config.configured_name_keys());
946
947 if opts.dry_run {
948 if !opts.json {
949 preview.display();
950 println!("└{}", "─".repeat(70).dimmed());
951 }
952 sources_added = preview.add_count();
953 } else {
954 let (added, _skipped) = config.merge_preview(&preview).map_err(SetupError::Config)?;
956 sources_added = added;
957
958 if added > 0 {
959 config.write_with_backup().map_err(SetupError::Config)?;
960 }
961
962 if !opts.json {
963 print_phase_done(&format!("Added {} sources to configuration", added));
964 }
965 }
966
967 state.configuration_complete = true;
968 state.save()?;
969 }
970
971 if !opts.skip_sync && !opts.dry_run && !state.sync_complete {
975 check_interrupted()?;
976
977 if !opts.json {
978 print_phase_header("Phase 7: Syncing data");
979 println!("│ Run 'cass sources sync' to sync session data from remotes.");
980 println!("└{}", "─".repeat(70).dimmed());
981 }
982
983 state.sync_complete = true;
987 state.save()?;
988 }
989
990 if !opts.json {
994 print_phase_header("Setup Complete");
995
996 let total_sessions: u64 = selected_hosts
997 .iter()
998 .filter_map(|h| {
999 if let CassStatus::Indexed { session_count, .. } = &h.cass_status {
1000 Some(*session_count)
1001 } else {
1002 None
1003 }
1004 })
1005 .sum();
1006
1007 if opts.dry_run {
1008 println!("│");
1009 println!("│ {} Dry run complete. No changes were made.", "ℹ".blue());
1010 println!("│ Run without --dry-run to execute setup.");
1011 } else {
1012 println!("│");
1013 println!("│ {} {} sources configured", "✓".green(), sources_added);
1014 if hosts_installed > 0 {
1015 println!(
1016 "│ {} cass installed on {} hosts",
1017 "✓".green(),
1018 hosts_installed
1019 );
1020 }
1021 if hosts_indexed > 0 {
1022 println!("│ {} {} hosts indexed", "✓".green(), hosts_indexed);
1023 }
1024 println!(
1025 "│ {} ~{} sessions now searchable",
1026 "✓".green(),
1027 total_sessions
1028 );
1029 println!("│");
1030 println!(
1031 "│ Run '{}' to search across all machines",
1032 "cass search <query>".cyan()
1033 );
1034 }
1035
1036 println!("└{}", "─".repeat(70).dimmed());
1037 }
1038
1039 SetupState::clear()?;
1041
1042 let total_sessions: u64 = selected_hosts
1043 .iter()
1044 .filter_map(|h| {
1045 if let CassStatus::Indexed { session_count, .. } = &h.cass_status {
1046 Some(*session_count)
1047 } else {
1048 None
1049 }
1050 })
1051 .sum();
1052
1053 Ok(SetupResult {
1054 sources_added,
1055 hosts_installed,
1056 hosts_indexed,
1057 total_sessions,
1058 dry_run: opts.dry_run,
1059 })
1060}
1061
1062#[cfg(test)]
1063mod tests {
1064 use super::*;
1065
1066 #[test]
1067 fn test_setup_options_default() {
1068 let opts = SetupOptions::default();
1069 assert!(!opts.dry_run);
1070 assert!(!opts.non_interactive);
1071 assert!(opts.hosts.is_none());
1072 assert!(!opts.skip_install);
1073 assert!(!opts.skip_index);
1074 assert!(!opts.skip_sync);
1075 assert_eq!(opts.timeout, 10);
1076 assert!(!opts.resume);
1077 assert!(!opts.verbose);
1078 assert!(!opts.json);
1079 }
1080
1081 #[test]
1082 fn test_setup_state_default() {
1083 let state = SetupState::default();
1084 assert!(!state.discovery_complete);
1085 assert_eq!(state.discovered_hosts, 0);
1086 assert!(state.discovered_host_names.is_empty());
1087 assert!(!state.probing_complete);
1088 assert!(state.probed_hosts.is_empty());
1089 assert!(!state.selection_complete);
1090 assert!(state.selected_host_names.is_empty());
1091 assert!(!state.installation_complete);
1092 assert!(state.completed_installs.is_empty());
1093 assert!(!state.indexing_complete);
1094 assert!(state.completed_indexes.is_empty());
1095 assert!(!state.configuration_complete);
1096 assert!(!state.sync_complete);
1097 assert!(state.current_operation.is_none());
1098 assert!(state.started_at.is_none());
1099 }
1100
1101 #[test]
1102 fn test_setup_state_has_progress_empty() {
1103 let state = SetupState::default();
1104 assert!(!state.has_progress());
1105 }
1106
1107 #[test]
1108 fn test_setup_state_has_progress_discovery() {
1109 let state = SetupState {
1110 discovery_complete: true,
1111 ..Default::default()
1112 };
1113 assert!(state.has_progress());
1114 }
1115
1116 #[test]
1117 fn test_setup_state_has_progress_probing() {
1118 let state = SetupState {
1119 probing_complete: true,
1120 ..Default::default()
1121 };
1122 assert!(state.has_progress());
1123 }
1124
1125 #[test]
1126 fn test_setup_state_has_progress_selection() {
1127 let state = SetupState {
1128 selection_complete: true,
1129 ..Default::default()
1130 };
1131 assert!(state.has_progress());
1132 }
1133
1134 #[test]
1135 fn test_setup_state_has_progress_installation() {
1136 let state = SetupState {
1137 installation_complete: true,
1138 ..Default::default()
1139 };
1140 assert!(state.has_progress());
1141 }
1142
1143 #[test]
1144 fn test_setup_state_has_progress_indexing() {
1145 let state = SetupState {
1146 indexing_complete: true,
1147 ..Default::default()
1148 };
1149 assert!(state.has_progress());
1150 }
1151
1152 #[test]
1153 fn test_setup_state_has_progress_configuration() {
1154 let state = SetupState {
1155 configuration_complete: true,
1156 ..Default::default()
1157 };
1158 assert!(state.has_progress());
1159 }
1160
1161 #[test]
1162 fn test_setup_state_serde_roundtrip() {
1163 let state = SetupState {
1164 discovery_complete: true,
1165 discovered_hosts: 5,
1166 discovered_host_names: vec!["host1".to_string(), "host2".to_string()],
1167 selected_host_names: vec!["host1".to_string()],
1168 started_at: Some("2025-01-01T00:00:00Z".to_string()),
1169 ..Default::default()
1170 };
1171
1172 let json = serde_json::to_string(&state).unwrap();
1173 let deserialized: SetupState = serde_json::from_str(&json).unwrap();
1174
1175 assert_eq!(deserialized.discovery_complete, state.discovery_complete);
1176 assert_eq!(deserialized.discovered_hosts, state.discovered_hosts);
1177 assert_eq!(
1178 deserialized.discovered_host_names,
1179 state.discovered_host_names
1180 );
1181 assert_eq!(deserialized.selected_host_names, state.selected_host_names);
1182 assert_eq!(deserialized.started_at, state.started_at);
1183 }
1184
1185 #[test]
1186 fn test_setup_error_display_cancelled() {
1187 let err = SetupError::Cancelled;
1188 assert_eq!(format!("{err}"), "Setup cancelled by user");
1189 }
1190
1191 #[test]
1192 fn test_setup_error_display_no_hosts() {
1193 let err = SetupError::NoHosts;
1194 assert_eq!(format!("{err}"), "No SSH hosts found or selected");
1195 }
1196
1197 #[test]
1198 fn test_setup_error_display_interrupted() {
1199 let err = SetupError::Interrupted;
1200 assert_eq!(format!("{err}"), "Setup interrupted");
1201 }
1202
1203 #[test]
1204 fn test_setup_error_display_io() {
1205 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
1206 let err = SetupError::Io(io_err);
1207 assert!(format!("{err}").contains("IO error"));
1208 }
1209
1210 #[test]
1211 fn test_setup_error_source_is_preserved_as_none() {
1212 let errors = [
1213 SetupError::Cancelled,
1214 SetupError::NoHosts,
1215 SetupError::Interrupted,
1216 SetupError::Io(std::io::Error::other("io")),
1217 SetupError::Json(serde_json::from_str::<serde_json::Value>("{").unwrap_err()),
1218 ];
1219
1220 for err in errors {
1221 assert!(std::error::Error::source(&err).is_none(), "{err}");
1222 }
1223 }
1224
1225 #[test]
1226 fn test_setup_result_structure() {
1227 let result = SetupResult {
1228 sources_added: 3,
1229 hosts_installed: 1,
1230 hosts_indexed: 2,
1231 total_sessions: 150,
1232 dry_run: false,
1233 };
1234 assert_eq!(result.sources_added, 3);
1235 assert_eq!(result.hosts_installed, 1);
1236 assert_eq!(result.hosts_indexed, 2);
1237 assert_eq!(result.total_sessions, 150);
1238 assert!(!result.dry_run);
1239 }
1240
1241 #[test]
1242 fn test_setup_result_dry_run() {
1243 let result = SetupResult {
1244 sources_added: 5,
1245 hosts_installed: 0,
1246 hosts_indexed: 0,
1247 total_sessions: 0,
1248 dry_run: true,
1249 };
1250 assert!(result.dry_run);
1251 assert_eq!(result.sources_added, 5);
1252 }
1253
1254 fn make_selected_probe(host_name: &str) -> HostProbeResult {
1255 HostProbeResult {
1256 host_name: host_name.to_string(),
1257 reachable: true,
1258 connection_time_ms: 0,
1259 cass_status: CassStatus::NotFound,
1260 detected_agents: Vec::new(),
1261 system_info: None,
1262 resources: None,
1263 error: None,
1264 }
1265 }
1266
1267 fn make_selected_probe_with_status(
1268 host_name: &str,
1269 cass_status: CassStatus,
1270 ) -> HostProbeResult {
1271 HostProbeResult {
1272 host_name: host_name.to_string(),
1273 reachable: true,
1274 connection_time_ms: 0,
1275 cass_status,
1276 detected_agents: Vec::new(),
1277 system_info: None,
1278 resources: None,
1279 error: None,
1280 }
1281 }
1282
1283 #[test]
1284 fn test_setup_indexing_eligibility_skips_missing_cass_without_install() {
1285 let host = make_selected_probe("fresh-host");
1286 let completed_installs = HashSet::new();
1287 let planned_installs = HashSet::new();
1288
1289 assert!(!setup_should_index_host(
1290 &host,
1291 &completed_installs,
1292 &planned_installs
1293 ));
1294 }
1295
1296 #[test]
1297 fn test_setup_indexing_eligibility_indexes_host_installed_this_run() {
1298 let host = make_selected_probe("fresh-host");
1299 let completed_installs = HashSet::from(["fresh-host"]);
1300 let planned_installs = HashSet::new();
1301
1302 assert!(setup_should_index_host(
1303 &host,
1304 &completed_installs,
1305 &planned_installs
1306 ));
1307 }
1308
1309 #[test]
1310 fn test_setup_indexing_eligibility_indexes_host_planned_for_dry_run_install() {
1311 let host = make_selected_probe("fresh-host");
1312 let completed_installs = HashSet::new();
1313 let planned_installs = HashSet::from(["fresh-host"]);
1314
1315 assert!(setup_should_index_host(
1316 &host,
1317 &completed_installs,
1318 &planned_installs
1319 ));
1320 }
1321
1322 #[test]
1323 fn test_setup_indexing_eligibility_keeps_pending_install_as_remaining_work() {
1324 let host = make_selected_probe("fresh-host");
1325 let completed_installs = HashSet::new();
1326 let pending_installs = HashSet::from(["fresh-host"]);
1327
1328 assert!(setup_should_index_host(
1329 &host,
1330 &completed_installs,
1331 &pending_installs
1332 ));
1333 }
1334
1335 #[test]
1336 fn test_setup_indexing_eligibility_uses_probe_status_for_existing_cass() {
1337 let host = make_selected_probe_with_status(
1338 "existing-host",
1339 CassStatus::InstalledNotIndexed {
1340 version: "0.1.0".to_string(),
1341 },
1342 );
1343 let completed_installs = HashSet::new();
1344 let planned_installs = HashSet::new();
1345
1346 assert!(setup_should_index_host(
1347 &host,
1348 &completed_installs,
1349 &planned_installs
1350 ));
1351 }
1352
1353 #[test]
1354 fn test_setup_indexing_eligibility_skips_indexed_sessions_even_if_install_recorded() {
1355 let host = make_selected_probe_with_status(
1356 "indexed-host",
1357 CassStatus::Indexed {
1358 version: "0.1.0".to_string(),
1359 session_count: 42,
1360 last_indexed: Some("2026-05-06T00:00:00Z".to_string()),
1361 },
1362 );
1363 let completed_installs = HashSet::from(["indexed-host"]);
1364 let planned_installs = HashSet::from(["indexed-host"]);
1365
1366 assert!(!setup_should_index_host(
1367 &host,
1368 &completed_installs,
1369 &planned_installs
1370 ));
1371 }
1372
1373 #[test]
1374 fn test_dedupe_selected_hosts_by_generated_name_case_insensitive() {
1375 let laptop_upper = make_selected_probe("Laptop");
1376 let laptop_lower = make_selected_probe("laptop");
1377
1378 let (selected, conflicts) =
1379 dedupe_selected_hosts_by_generated_name(vec![&laptop_upper, &laptop_lower]);
1380
1381 assert_eq!(selected.len(), 1);
1382 assert_eq!(selected[0].host_name, "Laptop");
1383 assert_eq!(conflicts.len(), 1);
1384 assert_eq!(conflicts[0].kept_host_name, "Laptop");
1385 assert_eq!(conflicts[0].skipped_host_name, "laptop");
1386 assert_eq!(conflicts[0].kept_source_name, "Laptop");
1387 }
1388
1389 #[test]
1390 fn test_dedupe_selected_hosts_by_generated_name_reserved_local_alias() {
1391 let local_lower = make_selected_probe("local");
1392 let local_upper = make_selected_probe("LOCAL");
1393
1394 let (selected, conflicts) =
1395 dedupe_selected_hosts_by_generated_name(vec![&local_lower, &local_upper]);
1396
1397 assert_eq!(selected.len(), 1);
1398 assert_eq!(selected[0].host_name, "local");
1399 assert_eq!(conflicts.len(), 1);
1400 assert_eq!(conflicts[0].kept_host_name, "local");
1401 assert_eq!(conflicts[0].skipped_host_name, "LOCAL");
1402 assert_eq!(conflicts[0].kept_source_name, "local-ssh");
1403 }
1404
1405 #[test]
1406 fn test_setup_state_path() {
1407 let path = SetupState::path();
1408 assert!(path.ends_with("setup_state.json"));
1409 assert!(path.to_string_lossy().contains("cass"));
1410 }
1411}