1use std::collections::{HashMap, HashSet};
18use std::path::{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 self.save_to_path(&path)
192 }
193
194 fn save_to_path(&self, path: &Path) -> Result<(), SetupError> {
195 if let Some(parent) = path.parent() {
196 std::fs::create_dir_all(parent).map_err(SetupError::Io)?;
197 }
198 let content = serde_json::to_vec_pretty(self).map_err(SetupError::Json)?;
199 let temp_path = write_setup_state_temp_file(path, &content).map_err(SetupError::Io)?;
200 replace_setup_state_from_temp(&temp_path, path).map_err(SetupError::Io)?;
201 Ok(())
202 }
203
204 pub fn clear() -> Result<(), SetupError> {
206 let path = Self::path();
207 match std::fs::remove_file(&path) {
208 Ok(()) => Ok(()),
209 Err(e) if e.kind() == std::io::ErrorKind::NotFound => Ok(()),
210 Err(e) => Err(SetupError::Io(e)),
211 }
212 }
213
214 pub fn has_progress(&self) -> bool {
216 self.discovery_complete
217 || self.probing_complete
218 || self.selection_complete
219 || self.installation_complete
220 || self.indexing_complete
221 || self.configuration_complete
222 }
223}
224
225fn write_setup_state_temp_file(path: &Path, contents: &[u8]) -> Result<PathBuf, std::io::Error> {
226 for _ in 0..100 {
227 let temp_path = unique_setup_state_temp_path(path)?;
228 match write_setup_state_temp_file_at(&temp_path, contents) {
229 Ok(()) => return Ok(temp_path),
230 Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
231 Err(err) => return Err(err),
232 }
233 }
234
235 Err(std::io::Error::new(
236 std::io::ErrorKind::AlreadyExists,
237 format!(
238 "failed to allocate unique setup state temp path for {}",
239 path.display()
240 ),
241 ))
242}
243
244fn write_setup_state_temp_file_at(path: &Path, contents: &[u8]) -> Result<(), std::io::Error> {
245 use std::io::Write;
246
247 let mut file = std::fs::OpenOptions::new()
248 .write(true)
249 .create_new(true)
250 .open(path)?;
251 file.write_all(contents)?;
252 file.sync_all()
253}
254
255fn unique_setup_state_temp_path(path: &Path) -> Result<PathBuf, std::io::Error> {
256 let timestamp = std::time::SystemTime::now()
257 .duration_since(std::time::UNIX_EPOCH)
258 .unwrap_or_default()
259 .as_nanos();
260 let nonce = setup_state_temp_path_nonce()?;
261 let file_name = path
262 .file_name()
263 .and_then(|name| name.to_str())
264 .unwrap_or("setup_state.json");
265
266 Ok(path.with_file_name(format!(".{file_name}.tmp.{timestamp}.{nonce:016x}")))
267}
268
269fn setup_state_temp_path_nonce() -> Result<u64, std::io::Error> {
270 let rng = ring::rand::SystemRandom::new();
271 let mut bytes = [0u8; 8];
272 ring::rand::SecureRandom::fill(&rng, &mut bytes)
273 .map_err(|_| std::io::Error::other("secure random generation failed"))?;
274 Ok(u64::from_le_bytes(bytes))
275}
276
277#[cfg(not(windows))]
278fn replace_setup_state_from_temp(
279 temp_path: &Path,
280 final_path: &Path,
281) -> Result<(), std::io::Error> {
282 std::fs::rename(temp_path, final_path)?;
283 sync_setup_state_parent_directory(final_path)
284}
285
286#[cfg(windows)]
287fn replace_setup_state_from_temp(
288 temp_path: &Path,
289 final_path: &Path,
290) -> Result<(), std::io::Error> {
291 match std::fs::rename(temp_path, final_path) {
292 Ok(()) => sync_setup_state_parent_directory(final_path),
293 Err(first_err)
294 if setup_state_path_entry_exists(final_path)
295 && matches!(
296 first_err.kind(),
297 std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::PermissionDenied
298 ) =>
299 {
300 let backup_path = unique_setup_state_replace_backup_path(final_path)?;
301 std::fs::rename(final_path, &backup_path).map_err(|backup_err| {
302 std::io::Error::other(format!(
303 "failed preparing backup {} before replacing {}: first error: {}; backup error: {}",
304 backup_path.display(),
305 final_path.display(),
306 first_err,
307 backup_err
308 ))
309 })?;
310 match std::fs::rename(temp_path, final_path) {
311 Ok(()) => sync_setup_state_parent_directory(final_path),
312 Err(second_err) => {
313 let restore_result = std::fs::rename(&backup_path, final_path);
314 match restore_result {
315 Ok(()) => {
316 sync_setup_state_parent_directory(final_path).map_err(|sync_err| {
317 std::io::Error::other(format!(
318 "failed replacing {} with {}: first error: {}; second error: {}; restored original file but failed syncing parent directory: {}",
319 final_path.display(),
320 temp_path.display(),
321 first_err,
322 second_err,
323 sync_err
324 ))
325 })?;
326 Err(std::io::Error::new(
327 second_err.kind(),
328 format!(
329 "failed replacing {} with {}: first error: {}; second error: {}; restored original file",
330 final_path.display(),
331 temp_path.display(),
332 first_err,
333 second_err
334 ),
335 ))
336 }
337 Err(restore_err) => Err(std::io::Error::other(format!(
338 "failed replacing {} with {}: first error: {}; second error: {}; restore error: {}; temp file retained at {}",
339 final_path.display(),
340 temp_path.display(),
341 first_err,
342 second_err,
343 restore_err,
344 temp_path.display()
345 ))),
346 }
347 }
348 }
349 }
350 Err(rename_err) => Err(rename_err),
351 }
352}
353
354#[cfg(any(windows, test))]
355fn setup_state_path_entry_exists(path: &Path) -> bool {
356 match std::fs::symlink_metadata(path) {
357 Ok(_) => true,
358 Err(err) if err.kind() == std::io::ErrorKind::NotFound => false,
359 Err(_) => true,
360 }
361}
362
363#[cfg(windows)]
364fn unique_setup_state_replace_backup_path(path: &Path) -> Result<PathBuf, std::io::Error> {
365 let timestamp = std::time::SystemTime::now()
366 .duration_since(std::time::UNIX_EPOCH)
367 .unwrap_or_default()
368 .as_nanos();
369 let nonce = setup_state_temp_path_nonce()?;
370 let file_name = path
371 .file_name()
372 .and_then(|name| name.to_str())
373 .unwrap_or("setup_state.json");
374
375 Ok(path.with_file_name(format!(".{file_name}.bak.{timestamp}.{nonce:016x}")))
376}
377
378#[cfg(not(windows))]
379fn sync_setup_state_parent_directory(path: &Path) -> Result<(), std::io::Error> {
380 let Some(parent) = path.parent() else {
381 return Ok(());
382 };
383 std::fs::File::open(parent)?.sync_all()
384}
385
386#[cfg(windows)]
387fn sync_setup_state_parent_directory(_path: &Path) -> Result<(), std::io::Error> {
388 Ok(())
389}
390
391#[derive(Debug, thiserror::Error)]
393pub enum SetupError {
394 #[error("IO error: {0}")]
396 Io(std::io::Error),
397 #[error("JSON error: {0}")]
399 Json(serde_json::Error),
400 #[error("Config error: {0}")]
402 Config(super::config::ConfigError),
403 #[error("Install error: {0}")]
405 Install(super::install::InstallError),
406 #[error("Index error: {0}")]
408 Index(super::index::IndexError),
409 #[error("Interactive error: {0}")]
411 Interactive(super::interactive::InteractiveError),
412 #[error("Setup cancelled by user")]
414 Cancelled,
415 #[error("No SSH hosts found or selected")]
417 NoHosts,
418 #[error("Setup interrupted")]
420 Interrupted,
421}
422
423#[derive(Debug)]
425pub struct SetupResult {
426 pub sources_added: usize,
428 pub hosts_installed: usize,
430 pub hosts_indexed: usize,
432 pub total_sessions: u64,
434 pub dry_run: bool,
436}
437
438fn print_phase_header(phase: &str) {
440 println!();
441 println!(
442 "{}",
443 format!("┌─ {} ", phase).bold().on_bright_black().white()
444 );
445}
446
447fn print_phase_done(message: &str) {
449 println!("│ {} {}", "✓".green(), message);
450 println!("└{}", "─".repeat(70).dimmed());
451}
452
453pub fn run_setup(opts: &SetupOptions) -> Result<SetupResult, SetupError> {
455 let interrupted = Arc::new(AtomicBool::new(false));
457
458 let mut state = if opts.resume {
460 SetupState::load()?.unwrap_or_default()
461 } else {
462 SetupState::default()
463 };
464
465 if state.started_at.is_none() {
466 state.started_at = Some(Utc::now().to_rfc3339());
467 }
468
469 let check_interrupted = || {
471 if interrupted.load(Ordering::SeqCst) {
472 Err(SetupError::Interrupted)
473 } else {
474 Ok(())
475 }
476 };
477
478 if !opts.json {
480 println!();
481 println!(
482 "{}",
483 "╭─────────────────────────────────────────────────────────────────────────────╮"
484 .bright_blue()
485 );
486 println!(
487 "{}",
488 "│ cass sources setup │"
489 .bright_blue()
490 );
491 println!(
492 "{}",
493 "╰─────────────────────────────────────────────────────────────────────────────╯"
494 .bright_blue()
495 );
496
497 if opts.dry_run {
498 println!();
499 println!("{}", " [DRY RUN - no changes will be made]".yellow());
500 }
501
502 if opts.resume && state.has_progress() {
503 println!();
504 println!("{}", " Resuming from previous session...".cyan());
505 }
506 }
507
508 let discovered_hosts = if !state.discovery_complete {
512 check_interrupted()?;
513
514 if !opts.json {
515 print_phase_header("Phase 1: Discovery");
516 }
517
518 let hosts = if let Some(ref specific_hosts) = opts.hosts {
519 specific_hosts
521 .iter()
522 .map(|h| super::config::DiscoveredHost {
523 name: h.clone(),
524 hostname: None,
525 user: None,
526 port: None,
527 identity_file: None,
528 })
529 .collect()
530 } else {
531 discover_ssh_hosts()
533 };
534
535 state.discovered_hosts = hosts.len();
536 state.discovered_host_names = hosts.iter().map(|h| h.name.clone()).collect();
537 state.discovery_complete = true;
538 state.save()?;
539
540 if !opts.json {
541 if opts.hosts.is_some() {
542 print_phase_done(&format!("Using {} specified host(s)", hosts.len()));
543 } else {
544 print_phase_done(&format!("Found {} SSH hosts in ~/.ssh/config", hosts.len()));
545 }
546 }
547
548 hosts
549 } else {
550 state
552 .discovered_host_names
553 .iter()
554 .map(|name| super::config::DiscoveredHost {
555 name: name.clone(),
556 hostname: None,
557 user: None,
558 port: None,
559 identity_file: None,
560 })
561 .collect()
562 };
563
564 if discovered_hosts.is_empty() {
565 if !opts.json {
566 println!();
567 println!(
568 "{}",
569 " No SSH hosts found. Add hosts to ~/.ssh/config or use --hosts.".yellow()
570 );
571 }
572 SetupState::clear()?;
573 return Err(SetupError::NoHosts);
574 }
575
576 let probed_hosts = if !state.probing_complete {
580 check_interrupted()?;
581
582 if !opts.json {
583 print_phase_header("Phase 2: Probing hosts");
584 }
585
586 let progress = if !opts.json {
587 let pb = ProgressBar::new(discovered_hosts.len() as u64);
588 pb.set_style(
589 ProgressStyle::default_bar()
590 .template("│ {bar:50.cyan/blue} {pos}/{len} {msg}")
591 .expect("valid progress bar template")
592 .progress_chars("██░"),
593 );
594 Some(pb)
595 } else {
596 None
597 };
598
599 let progress_clone = progress.clone();
600 let results = probe_hosts_parallel(
601 &discovered_hosts,
602 opts.timeout,
603 move |completed, total, name| {
604 if let Some(ref pb) = progress_clone {
605 pb.set_position(completed as u64);
606 pb.set_message(format!("{}/{} - {}", completed, total, name));
607 }
608 },
609 );
610
611 if let Some(pb) = &progress {
612 pb.finish_and_clear();
613 }
614
615 let (results, merged_aliases) = deduplicate_probe_results(results);
617
618 let reachable = results.iter().filter(|p| p.reachable).count();
619 let with_cass = results
620 .iter()
621 .filter(|p| p.cass_status.is_installed())
622 .count();
623
624 state.probed_hosts = results.clone();
625 state.probing_complete = true;
626 state.save()?;
627
628 if !opts.json {
629 print_phase_done(&format!(
630 "{} reachable, {} with cass installed",
631 reachable, with_cass
632 ));
633
634 if !merged_aliases.is_empty() {
636 let total_merged: usize = merged_aliases.values().map(|v| v.len()).sum();
637 println!(
638 "│ {} {} duplicate alias(es) merged (same machine):",
639 "ℹ".blue(),
640 total_merged
641 );
642 let mut sorted_merges: Vec<_> = merged_aliases.iter().collect();
644 sorted_merges.sort_by_key(|(k, _)| *k);
645 for (kept, aliases) in sorted_merges {
646 let mut sorted_aliases = aliases.clone();
647 sorted_aliases.sort();
648 println!(
649 "│ {} ← {}",
650 kept.bold(),
651 sorted_aliases.join(", ").dimmed()
652 );
653 }
654 }
655 }
656
657 results
658 } else {
659 state.probed_hosts.clone()
660 };
661
662 let reachable_hosts: Vec<_> = probed_hosts.iter().filter(|p| p.reachable).collect();
663
664 if reachable_hosts.is_empty() {
665 if !opts.json {
666 println!();
667 println!(
668 "{}",
669 " No reachable hosts found. Check SSH connectivity.".yellow()
670 );
671 }
672 SetupState::clear()?;
673 return Err(SetupError::NoHosts);
674 }
675
676 let selection_performed = !state.selection_complete;
680 let mut selected_hosts: Vec<&HostProbeResult> = if !state.selection_complete {
681 check_interrupted()?;
682
683 if !opts.json {
684 print_phase_header("Phase 3: Host Selection");
685 }
686
687 let existing_config = SourcesConfig::load().unwrap_or_default();
688 let existing_name_keys: HashSet<_> = existing_config.configured_name_keys();
689
690 if opts.non_interactive {
691 let mut selected_name_keys = existing_name_keys.clone();
693 let auto_selected: Vec<_> = reachable_hosts
694 .iter()
695 .filter(|h| {
696 let generated_name =
697 super::config::normalize_generated_remote_source_name(&h.host_name);
698 selected_name_keys.insert(super::config::source_name_key(&generated_name))
699 })
700 .copied()
701 .collect();
702
703 auto_selected
704 } else {
705 let probes_for_selection: Vec<HostProbeResult> =
708 reachable_hosts.iter().map(|p| (*p).clone()).collect();
709
710 match run_host_selection(&probes_for_selection, &existing_name_keys) {
711 Ok((result, display_infos)) => {
712 let selected: Vec<_> = result
714 .selected_indices
715 .iter()
716 .filter_map(|&idx| {
717 display_infos.get(idx).and_then(|info| {
718 reachable_hosts
719 .iter()
720 .find(|h| h.host_name == info.hostname)
721 .copied()
722 })
723 })
724 .collect();
725 selected
726 }
727 Err(e) => {
728 state.save()?;
729 return Err(SetupError::Interactive(e));
730 }
731 }
732 }
733 } else {
734 state
736 .selected_host_names
737 .iter()
738 .filter_map(|name| probed_hosts.iter().find(|h| h.host_name == *name))
739 .collect()
740 };
741
742 let (deduped_selected_hosts, selection_conflicts) =
743 dedupe_selected_hosts_by_generated_name(selected_hosts);
744 selected_hosts = deduped_selected_hosts;
745
746 if selection_performed && !opts.json {
747 let selection_message = if opts.non_interactive {
748 format!(
749 "Auto-selected {} hosts (non-interactive)",
750 selected_hosts.len()
751 )
752 } else {
753 format!("Selected {} hosts", selected_hosts.len())
754 };
755 print_phase_done(&selection_message);
756 }
757
758 if !selection_conflicts.is_empty() && !opts.json {
759 println!(
760 "│ {} skipped {} host(s) because their generated source names conflict:",
761 "Warning:".yellow().bold(),
762 selection_conflicts.len()
763 );
764 for conflict in &selection_conflicts {
765 println!(
766 "│ - {} skipped; conflicts with {} as source '{}'",
767 conflict.skipped_host_name, conflict.kept_host_name, conflict.kept_source_name
768 );
769 }
770 println!(
771 "│ Edit host aliases or use 'cass sources add --name ...' later if you need distinct source IDs."
772 );
773 }
774
775 let selected_host_names: Vec<String> =
776 selected_hosts.iter().map(|h| h.host_name.clone()).collect();
777 if !state.selection_complete || state.selected_host_names != selected_host_names {
778 state.selected_host_names = selected_host_names;
779 state.selection_complete = true;
780 state.save()?;
781 }
782
783 if selected_hosts.is_empty() {
784 if !opts.json {
785 println!();
786 println!("{}", " No hosts selected. Setup cancelled.".yellow());
787 }
788 SetupState::clear()?;
789 return Ok(SetupResult {
790 sources_added: 0,
791 hosts_installed: 0,
792 hosts_indexed: 0,
793 total_sessions: 0,
794 dry_run: opts.dry_run,
795 });
796 }
797
798 let mut hosts_installed = 0;
802 let mut dry_run_planned_install_host_names: HashSet<String> = HashSet::new();
803
804 if !opts.skip_install && !state.installation_complete {
805 check_interrupted()?;
806
807 let needs_install: Vec<_> = selected_hosts
808 .iter()
809 .filter(|h| !h.cass_status.is_installed())
810 .filter(|h| !state.completed_installs.contains(&h.host_name))
811 .collect();
812
813 if !needs_install.is_empty() {
814 if !opts.json {
815 print_phase_header("Phase 4: Installing cass");
816 }
817
818 if opts.dry_run {
819 if !opts.json {
820 println!("│ Would install cass on {} hosts:", needs_install.len());
821 for host in &needs_install {
822 println!("│ - {}", host.host_name);
823 }
824 println!("└{}", "─".repeat(70).dimmed());
825 }
826 dry_run_planned_install_host_names
827 .extend(needs_install.iter().map(|host| host.host_name.clone()));
828 hosts_installed = needs_install.len();
829 } else {
830 let proceed = if opts.non_interactive {
832 true
833 } else {
834 confirm_action(
835 &format!("Install cass on {} hosts?", needs_install.len()),
836 true,
837 )
838 .unwrap_or(false)
839 };
840
841 if proceed {
842 for host in needs_install {
843 check_interrupted()?;
844
845 state.current_operation = Some(format!("Installing on {}", host.host_name));
846 state.save()?;
847
848 let Some(system_info) = host.system_info.clone() else {
851 if !opts.json {
852 println!(
853 "│ {} {} skipped (no system info)",
854 "⚠".yellow(),
855 host.host_name
856 );
857 }
858 continue;
859 };
860 let Some(resources) = host.resources.clone() else {
861 if !opts.json {
862 println!(
863 "│ {} {} skipped (no resource info)",
864 "⚠".yellow(),
865 host.host_name
866 );
867 }
868 continue;
869 };
870 let installer =
871 RemoteInstaller::new(host.host_name.clone(), system_info, resources);
872
873 if !opts.json {
874 println!("│ Installing on {}...", host.host_name);
875 }
876
877 let host_name_for_progress = host.host_name.clone();
878 let verbose = opts.verbose;
879 let json = opts.json;
880 let progress_callback = move |progress: InstallProgress| {
881 if verbose && !json {
882 println!(
883 "│ {}: {} ({}%)",
884 host_name_for_progress,
885 progress.stage, progress.percent.unwrap_or(0)
887 );
888 }
889 };
890
891 match installer.install(progress_callback) {
892 Ok(_) => {
893 if !opts.json {
894 println!("│ {} {} installed", "✓".green(), host.host_name);
895 }
896 state.completed_installs.push(host.host_name.clone());
897 state.save()?;
898 hosts_installed += 1;
899 }
900 Err(e) => {
901 if !opts.json {
902 println!("│ {} {} failed: {}", "✗".red(), host.host_name, e);
903 }
904 if opts.verbose {
905 eprintln!(" Install error: {e}");
906 }
907 }
908 }
909 }
910
911 if !opts.json {
912 print_phase_done(&format!("Installed cass on {} hosts", hosts_installed));
913 }
914 } else if !opts.json {
915 println!("│ Skipping installation.");
916 println!("└{}", "─".repeat(70).dimmed());
917 }
918 }
919 }
920
921 if !opts.dry_run {
922 let completed: HashSet<&str> = state
923 .completed_installs
924 .iter()
925 .map(std::string::String::as_str)
926 .collect();
927 let remaining_installs = selected_hosts
928 .iter()
929 .filter(|h| !h.cass_status.is_installed())
930 .filter(|h| !completed.contains(h.host_name.as_str()))
931 .count();
932 state.installation_complete = remaining_installs == 0;
933 state.save()?;
934 }
935 }
936
937 let mut hosts_indexed = 0;
941
942 if !opts.skip_index && !state.indexing_complete {
943 check_interrupted()?;
944
945 let completed_install_host_names: HashSet<&str> = state
946 .completed_installs
947 .iter()
948 .map(std::string::String::as_str)
949 .collect();
950 let dry_run_planned_install_host_names: HashSet<&str> = dry_run_planned_install_host_names
951 .iter()
952 .map(std::string::String::as_str)
953 .collect();
954 let needs_index: Vec<_> = selected_hosts
955 .iter()
956 .filter(|h| {
957 setup_should_index_host(
958 h,
959 &completed_install_host_names,
960 &dry_run_planned_install_host_names,
961 )
962 })
963 .filter(|h| !state.completed_indexes.contains(&h.host_name))
964 .collect();
965
966 if !needs_index.is_empty() {
967 if !opts.json {
968 print_phase_header("Phase 5: Indexing sessions");
969 }
970
971 if opts.dry_run {
972 if !opts.json {
973 println!("│ Would index sessions on {} hosts", needs_index.len());
974 println!("└{}", "─".repeat(70).dimmed());
975 }
976 hosts_indexed = needs_index.len();
977 } else {
978 for host in needs_index {
979 check_interrupted()?;
980
981 state.current_operation = Some(format!("Indexing on {}", host.host_name));
982 state.save()?;
983
984 if !opts.json {
985 println!("│ Indexing on {}...", host.host_name);
986 }
987
988 let indexer = RemoteIndexer::with_defaults(host.host_name.clone());
990
991 let host_name_for_progress = host.host_name.clone();
992 let verbose = opts.verbose;
993 let json = opts.json;
994 let progress_callback = move |progress: IndexProgress| {
995 if verbose && !json {
996 let pct = progress.percent.unwrap_or(0);
997 println!(
998 "│ {}: {} ({}%)",
999 host_name_for_progress,
1000 progress.stage, pct
1002 );
1003 }
1004 };
1005
1006 match indexer.run_index(progress_callback) {
1007 Ok(result) => {
1008 if !opts.json {
1009 println!("│ {} {} indexed", "✓".green(), host.host_name);
1010 if opts.verbose
1011 && let Some(artifact) = &result.artifact_manifest
1012 {
1013 if artifact.success {
1014 println!(
1015 "│ {} artifact proof {} ({} chunks)",
1016 "✓".green(),
1017 artifact
1018 .bundle_id
1019 .as_deref()
1020 .unwrap_or("bundle id unavailable"),
1021 artifact.chunk_count.unwrap_or(0)
1022 );
1023 } else {
1024 println!(
1025 "│ {} artifact proof unavailable: {}",
1026 "⚠".yellow(),
1027 artifact
1028 .error
1029 .as_deref()
1030 .unwrap_or("unknown artifact manifest error")
1031 );
1032 }
1033 }
1034 }
1035 state.completed_indexes.push(host.host_name.clone());
1036 state.save()?;
1037 hosts_indexed += 1;
1038 }
1039 Err(e) => {
1040 if !opts.json {
1041 println!(
1042 "│ {} Index error on {}: {}",
1043 "✗".red(),
1044 host.host_name,
1045 e
1046 );
1047 }
1048 }
1049 }
1050 }
1051
1052 if !opts.json {
1053 print_phase_done(&format!("Indexed {} hosts", hosts_indexed));
1054 }
1055 }
1056 }
1057
1058 if !opts.dry_run {
1059 let completed: HashSet<&str> = state
1060 .completed_indexes
1061 .iter()
1062 .map(std::string::String::as_str)
1063 .collect();
1064 let completed_install_host_names: HashSet<&str> = state
1065 .completed_installs
1066 .iter()
1067 .map(std::string::String::as_str)
1068 .collect();
1069 let pending_install_host_names: HashSet<&str> = if opts.skip_install {
1070 HashSet::new()
1071 } else {
1072 selected_hosts
1073 .iter()
1074 .filter(|h| !h.cass_status.is_installed())
1075 .filter(|h| !completed_install_host_names.contains(h.host_name.as_str()))
1076 .map(|h| h.host_name.as_str())
1077 .collect()
1078 };
1079 let remaining_indexes = selected_hosts
1080 .iter()
1081 .filter(|h| {
1082 setup_should_index_host(
1083 h,
1084 &completed_install_host_names,
1085 &pending_install_host_names,
1086 )
1087 })
1088 .filter(|h| !completed.contains(h.host_name.as_str()))
1089 .count();
1090 state.indexing_complete = remaining_indexes == 0;
1091 state.save()?;
1092 }
1093 }
1094
1095 let mut sources_added = 0;
1099
1100 if !state.configuration_complete {
1101 check_interrupted()?;
1102
1103 if !opts.json {
1104 print_phase_header("Phase 6: Configuring sources");
1105 }
1106
1107 let mut config = SourcesConfig::load().unwrap_or_default();
1108 let generator = SourceConfigGenerator::new();
1109
1110 let probes: Vec<(&str, &HostProbeResult)> = selected_hosts
1112 .iter()
1113 .map(|h| (h.host_name.as_str(), *h))
1114 .collect();
1115
1116 let preview = generator.generate_preview(&probes, &config.configured_name_keys());
1117
1118 if opts.dry_run {
1119 if !opts.json {
1120 preview.display();
1121 println!("└{}", "─".repeat(70).dimmed());
1122 }
1123 sources_added = preview.add_count();
1124 } else {
1125 let (added, _skipped) = config.merge_preview(&preview).map_err(SetupError::Config)?;
1127 sources_added = added;
1128
1129 if added > 0 {
1130 config.write_with_backup().map_err(SetupError::Config)?;
1131 }
1132
1133 if !opts.json {
1134 print_phase_done(&format!("Added {} sources to configuration", added));
1135 }
1136 }
1137
1138 state.configuration_complete = true;
1139 state.save()?;
1140 }
1141
1142 if !opts.skip_sync && !opts.dry_run && !state.sync_complete {
1146 check_interrupted()?;
1147
1148 if !opts.json {
1149 print_phase_header("Phase 7: Syncing data");
1150 println!("│ Run 'cass sources sync' to sync session data from remotes.");
1151 println!("└{}", "─".repeat(70).dimmed());
1152 }
1153
1154 state.sync_complete = true;
1158 state.save()?;
1159 }
1160
1161 if !opts.json {
1165 print_phase_header("Setup Complete");
1166
1167 let total_sessions: u64 = selected_hosts
1168 .iter()
1169 .filter_map(|h| {
1170 if let CassStatus::Indexed { session_count, .. } = &h.cass_status {
1171 Some(*session_count)
1172 } else {
1173 None
1174 }
1175 })
1176 .sum();
1177
1178 if opts.dry_run {
1179 println!("│");
1180 println!("│ {} Dry run complete. No changes were made.", "ℹ".blue());
1181 println!("│ Run without --dry-run to execute setup.");
1182 } else {
1183 println!("│");
1184 println!("│ {} {} sources configured", "✓".green(), sources_added);
1185 if hosts_installed > 0 {
1186 println!(
1187 "│ {} cass installed on {} hosts",
1188 "✓".green(),
1189 hosts_installed
1190 );
1191 }
1192 if hosts_indexed > 0 {
1193 println!("│ {} {} hosts indexed", "✓".green(), hosts_indexed);
1194 }
1195 println!(
1196 "│ {} ~{} sessions now searchable",
1197 "✓".green(),
1198 total_sessions
1199 );
1200 println!("│");
1201 println!(
1202 "│ Run '{}' to search across all machines",
1203 "cass search <query>".cyan()
1204 );
1205 }
1206
1207 println!("└{}", "─".repeat(70).dimmed());
1208 }
1209
1210 SetupState::clear()?;
1212
1213 let total_sessions: u64 = selected_hosts
1214 .iter()
1215 .filter_map(|h| {
1216 if let CassStatus::Indexed { session_count, .. } = &h.cass_status {
1217 Some(*session_count)
1218 } else {
1219 None
1220 }
1221 })
1222 .sum();
1223
1224 Ok(SetupResult {
1225 sources_added,
1226 hosts_installed,
1227 hosts_indexed,
1228 total_sessions,
1229 dry_run: opts.dry_run,
1230 })
1231}
1232
1233#[cfg(test)]
1234mod tests {
1235 use super::*;
1236
1237 type SetupTestResult = Result<(), Box<dyn std::error::Error>>;
1238
1239 fn setup_test_error(message: impl Into<String>) -> Box<dyn std::error::Error> {
1240 std::io::Error::other(message.into()).into()
1241 }
1242
1243 fn ensure_setup_test(condition: bool, message: impl Into<String>) -> SetupTestResult {
1244 if condition {
1245 Ok(())
1246 } else {
1247 Err(setup_test_error(message))
1248 }
1249 }
1250
1251 #[test]
1252 fn test_setup_options_default() {
1253 let opts = SetupOptions::default();
1254 assert!(!opts.dry_run);
1255 assert!(!opts.non_interactive);
1256 assert!(opts.hosts.is_none());
1257 assert!(!opts.skip_install);
1258 assert!(!opts.skip_index);
1259 assert!(!opts.skip_sync);
1260 assert_eq!(opts.timeout, 10);
1261 assert!(!opts.resume);
1262 assert!(!opts.verbose);
1263 assert!(!opts.json);
1264 }
1265
1266 #[test]
1267 fn test_setup_state_default() {
1268 let state = SetupState::default();
1269 assert!(!state.discovery_complete);
1270 assert_eq!(state.discovered_hosts, 0);
1271 assert!(state.discovered_host_names.is_empty());
1272 assert!(!state.probing_complete);
1273 assert!(state.probed_hosts.is_empty());
1274 assert!(!state.selection_complete);
1275 assert!(state.selected_host_names.is_empty());
1276 assert!(!state.installation_complete);
1277 assert!(state.completed_installs.is_empty());
1278 assert!(!state.indexing_complete);
1279 assert!(state.completed_indexes.is_empty());
1280 assert!(!state.configuration_complete);
1281 assert!(!state.sync_complete);
1282 assert!(state.current_operation.is_none());
1283 assert!(state.started_at.is_none());
1284 }
1285
1286 #[test]
1287 fn test_setup_state_has_progress_empty() {
1288 let state = SetupState::default();
1289 assert!(!state.has_progress());
1290 }
1291
1292 #[test]
1293 fn test_setup_state_has_progress_discovery() {
1294 let state = SetupState {
1295 discovery_complete: true,
1296 ..Default::default()
1297 };
1298 assert!(state.has_progress());
1299 }
1300
1301 #[test]
1302 fn test_setup_state_has_progress_probing() {
1303 let state = SetupState {
1304 probing_complete: true,
1305 ..Default::default()
1306 };
1307 assert!(state.has_progress());
1308 }
1309
1310 #[test]
1311 fn test_setup_state_has_progress_selection() {
1312 let state = SetupState {
1313 selection_complete: true,
1314 ..Default::default()
1315 };
1316 assert!(state.has_progress());
1317 }
1318
1319 #[test]
1320 fn test_setup_state_has_progress_installation() {
1321 let state = SetupState {
1322 installation_complete: true,
1323 ..Default::default()
1324 };
1325 assert!(state.has_progress());
1326 }
1327
1328 #[test]
1329 fn test_setup_state_has_progress_indexing() {
1330 let state = SetupState {
1331 indexing_complete: true,
1332 ..Default::default()
1333 };
1334 assert!(state.has_progress());
1335 }
1336
1337 #[test]
1338 fn test_setup_state_has_progress_configuration() {
1339 let state = SetupState {
1340 configuration_complete: true,
1341 ..Default::default()
1342 };
1343 assert!(state.has_progress());
1344 }
1345
1346 #[test]
1347 fn test_setup_state_serde_roundtrip() {
1348 let state = SetupState {
1349 discovery_complete: true,
1350 discovered_hosts: 5,
1351 discovered_host_names: vec!["host1".to_string(), "host2".to_string()],
1352 selected_host_names: vec!["host1".to_string()],
1353 started_at: Some("2025-01-01T00:00:00Z".to_string()),
1354 ..Default::default()
1355 };
1356
1357 let json = serde_json::to_string(&state).unwrap();
1358 let deserialized: SetupState = serde_json::from_str(&json).unwrap();
1359
1360 assert_eq!(deserialized.discovery_complete, state.discovery_complete);
1361 assert_eq!(deserialized.discovered_hosts, state.discovered_hosts);
1362 assert_eq!(
1363 deserialized.discovered_host_names,
1364 state.discovered_host_names
1365 );
1366 assert_eq!(deserialized.selected_host_names, state.selected_host_names);
1367 assert_eq!(deserialized.started_at, state.started_at);
1368 }
1369
1370 #[test]
1371 fn test_setup_state_save_to_path_round_trips() -> SetupTestResult {
1372 let temp = tempfile::tempdir()?;
1373 let path = temp.path().join("setup_state.json");
1374 let state = SetupState {
1375 discovery_complete: true,
1376 discovered_hosts: 2,
1377 discovered_host_names: vec!["alpha".to_string(), "beta".to_string()],
1378 current_operation: Some("probing".to_string()),
1379 started_at: Some("2026-05-28T00:00:00Z".to_string()),
1380 ..Default::default()
1381 };
1382
1383 state.save_to_path(&path)?;
1384
1385 let loaded: SetupState = serde_json::from_slice(&std::fs::read(&path)?)?;
1386 ensure_setup_test(
1387 loaded.discovery_complete == state.discovery_complete,
1388 "discovery_complete should round-trip",
1389 )?;
1390 ensure_setup_test(
1391 loaded.discovered_hosts == state.discovered_hosts,
1392 "discovered_hosts should round-trip",
1393 )?;
1394 ensure_setup_test(
1395 loaded.discovered_host_names == state.discovered_host_names,
1396 "discovered_host_names should round-trip",
1397 )?;
1398 ensure_setup_test(
1399 loaded.current_operation == state.current_operation,
1400 "current_operation should round-trip",
1401 )?;
1402 ensure_setup_test(
1403 loaded.started_at == state.started_at,
1404 "started_at should round-trip",
1405 )?;
1406 Ok(())
1407 }
1408
1409 #[cfg(unix)]
1410 #[test]
1411 fn test_setup_state_save_replaces_symlink_without_following() -> SetupTestResult {
1412 use std::os::unix::fs::symlink;
1413
1414 let temp = tempfile::tempdir()?;
1415 let path = temp.path().join("setup_state.json");
1416 let protected = temp.path().join("protected.json");
1417 let state = SetupState {
1418 probing_complete: true,
1419 selected_host_names: vec!["remote-box".to_string()],
1420 current_operation: Some("configuring".to_string()),
1421 ..Default::default()
1422 };
1423
1424 std::fs::write(&protected, b"protected")?;
1425 symlink(&protected, &path)?;
1426
1427 state.save_to_path(&path)?;
1428
1429 ensure_setup_test(
1430 std::fs::read(&protected)? == b"protected",
1431 "protected target should not be overwritten",
1432 )?;
1433 ensure_setup_test(
1434 !std::fs::symlink_metadata(&path)?.file_type().is_symlink(),
1435 "setup state save should replace the symlink path itself",
1436 )?;
1437 let loaded: SetupState = serde_json::from_slice(&std::fs::read(&path)?)?;
1438 ensure_setup_test(
1439 loaded.probing_complete == state.probing_complete,
1440 "probing_complete should round-trip after symlink replacement",
1441 )?;
1442 ensure_setup_test(
1443 loaded.selected_host_names == state.selected_host_names,
1444 "selected_host_names should round-trip after symlink replacement",
1445 )?;
1446 ensure_setup_test(
1447 loaded.current_operation == state.current_operation,
1448 "current_operation should round-trip after symlink replacement",
1449 )?;
1450 Ok(())
1451 }
1452
1453 #[cfg(unix)]
1454 #[test]
1455 fn test_setup_state_path_entry_exists_detects_dangling_symlink() -> SetupTestResult {
1456 use std::os::unix::fs::symlink;
1457
1458 let temp = tempfile::tempdir()?;
1459 let path = temp.path().join("setup_state.json");
1460 let missing_target = temp.path().join("missing.json");
1461
1462 symlink(&missing_target, &path)?;
1463
1464 ensure_setup_test(!path.exists(), "Path::exists follows the missing target")?;
1465 ensure_setup_test(
1466 setup_state_path_entry_exists(&path),
1467 "replacement fallback must detect the symlink path entry itself",
1468 )?;
1469 Ok(())
1470 }
1471
1472 #[test]
1473 fn test_setup_error_display_cancelled() {
1474 let err = SetupError::Cancelled;
1475 assert_eq!(format!("{err}"), "Setup cancelled by user");
1476 }
1477
1478 #[test]
1479 fn test_setup_error_display_no_hosts() {
1480 let err = SetupError::NoHosts;
1481 assert_eq!(format!("{err}"), "No SSH hosts found or selected");
1482 }
1483
1484 #[test]
1485 fn test_setup_error_display_interrupted() {
1486 let err = SetupError::Interrupted;
1487 assert_eq!(format!("{err}"), "Setup interrupted");
1488 }
1489
1490 #[test]
1491 fn test_setup_error_display_io() {
1492 let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
1493 let err = SetupError::Io(io_err);
1494 assert!(format!("{err}").contains("IO error"));
1495 }
1496
1497 #[test]
1498 fn test_setup_error_source_is_preserved_as_none() {
1499 let errors = [
1500 SetupError::Cancelled,
1501 SetupError::NoHosts,
1502 SetupError::Interrupted,
1503 SetupError::Io(std::io::Error::other("io")),
1504 SetupError::Json(serde_json::from_str::<serde_json::Value>("{").unwrap_err()),
1505 ];
1506
1507 for err in errors {
1508 assert!(std::error::Error::source(&err).is_none(), "{err}");
1509 }
1510 }
1511
1512 #[test]
1513 fn test_setup_result_structure() {
1514 let result = SetupResult {
1515 sources_added: 3,
1516 hosts_installed: 1,
1517 hosts_indexed: 2,
1518 total_sessions: 150,
1519 dry_run: false,
1520 };
1521 assert_eq!(result.sources_added, 3);
1522 assert_eq!(result.hosts_installed, 1);
1523 assert_eq!(result.hosts_indexed, 2);
1524 assert_eq!(result.total_sessions, 150);
1525 assert!(!result.dry_run);
1526 }
1527
1528 #[test]
1529 fn test_setup_result_dry_run() {
1530 let result = SetupResult {
1531 sources_added: 5,
1532 hosts_installed: 0,
1533 hosts_indexed: 0,
1534 total_sessions: 0,
1535 dry_run: true,
1536 };
1537 assert!(result.dry_run);
1538 assert_eq!(result.sources_added, 5);
1539 }
1540
1541 fn make_selected_probe(host_name: &str) -> HostProbeResult {
1542 HostProbeResult {
1543 host_name: host_name.to_string(),
1544 reachable: true,
1545 connection_time_ms: 0,
1546 cass_status: CassStatus::NotFound,
1547 detected_agents: Vec::new(),
1548 system_info: None,
1549 resources: None,
1550 error: None,
1551 }
1552 }
1553
1554 fn make_selected_probe_with_status(
1555 host_name: &str,
1556 cass_status: CassStatus,
1557 ) -> HostProbeResult {
1558 HostProbeResult {
1559 host_name: host_name.to_string(),
1560 reachable: true,
1561 connection_time_ms: 0,
1562 cass_status,
1563 detected_agents: Vec::new(),
1564 system_info: None,
1565 resources: None,
1566 error: None,
1567 }
1568 }
1569
1570 #[test]
1571 fn test_setup_indexing_eligibility_skips_missing_cass_without_install() {
1572 let host = make_selected_probe("fresh-host");
1573 let completed_installs = HashSet::new();
1574 let planned_installs = HashSet::new();
1575
1576 assert!(!setup_should_index_host(
1577 &host,
1578 &completed_installs,
1579 &planned_installs
1580 ));
1581 }
1582
1583 #[test]
1584 fn test_setup_indexing_eligibility_indexes_host_installed_this_run() {
1585 let host = make_selected_probe("fresh-host");
1586 let completed_installs = HashSet::from(["fresh-host"]);
1587 let planned_installs = HashSet::new();
1588
1589 assert!(setup_should_index_host(
1590 &host,
1591 &completed_installs,
1592 &planned_installs
1593 ));
1594 }
1595
1596 #[test]
1597 fn test_setup_indexing_eligibility_indexes_host_planned_for_dry_run_install() {
1598 let host = make_selected_probe("fresh-host");
1599 let completed_installs = HashSet::new();
1600 let planned_installs = HashSet::from(["fresh-host"]);
1601
1602 assert!(setup_should_index_host(
1603 &host,
1604 &completed_installs,
1605 &planned_installs
1606 ));
1607 }
1608
1609 #[test]
1610 fn test_setup_indexing_eligibility_keeps_pending_install_as_remaining_work() {
1611 let host = make_selected_probe("fresh-host");
1612 let completed_installs = HashSet::new();
1613 let pending_installs = HashSet::from(["fresh-host"]);
1614
1615 assert!(setup_should_index_host(
1616 &host,
1617 &completed_installs,
1618 &pending_installs
1619 ));
1620 }
1621
1622 #[test]
1623 fn test_setup_indexing_eligibility_uses_probe_status_for_existing_cass() {
1624 let host = make_selected_probe_with_status(
1625 "existing-host",
1626 CassStatus::InstalledNotIndexed {
1627 version: "0.1.0".to_string(),
1628 },
1629 );
1630 let completed_installs = HashSet::new();
1631 let planned_installs = HashSet::new();
1632
1633 assert!(setup_should_index_host(
1634 &host,
1635 &completed_installs,
1636 &planned_installs
1637 ));
1638 }
1639
1640 #[test]
1641 fn test_setup_indexing_eligibility_skips_indexed_sessions_even_if_install_recorded() {
1642 let host = make_selected_probe_with_status(
1643 "indexed-host",
1644 CassStatus::Indexed {
1645 version: "0.1.0".to_string(),
1646 session_count: 42,
1647 last_indexed: Some("2026-05-06T00:00:00Z".to_string()),
1648 },
1649 );
1650 let completed_installs = HashSet::from(["indexed-host"]);
1651 let planned_installs = HashSet::from(["indexed-host"]);
1652
1653 assert!(!setup_should_index_host(
1654 &host,
1655 &completed_installs,
1656 &planned_installs
1657 ));
1658 }
1659
1660 #[test]
1661 fn test_dedupe_selected_hosts_by_generated_name_case_insensitive() {
1662 let laptop_upper = make_selected_probe("Laptop");
1663 let laptop_lower = make_selected_probe("laptop");
1664
1665 let (selected, conflicts) =
1666 dedupe_selected_hosts_by_generated_name(vec![&laptop_upper, &laptop_lower]);
1667
1668 assert_eq!(selected.len(), 1);
1669 assert_eq!(selected[0].host_name, "Laptop");
1670 assert_eq!(conflicts.len(), 1);
1671 assert_eq!(conflicts[0].kept_host_name, "Laptop");
1672 assert_eq!(conflicts[0].skipped_host_name, "laptop");
1673 assert_eq!(conflicts[0].kept_source_name, "Laptop");
1674 }
1675
1676 #[test]
1677 fn test_dedupe_selected_hosts_by_generated_name_reserved_local_alias() {
1678 let local_lower = make_selected_probe("local");
1679 let local_upper = make_selected_probe("LOCAL");
1680
1681 let (selected, conflicts) =
1682 dedupe_selected_hosts_by_generated_name(vec![&local_lower, &local_upper]);
1683
1684 assert_eq!(selected.len(), 1);
1685 assert_eq!(selected[0].host_name, "local");
1686 assert_eq!(conflicts.len(), 1);
1687 assert_eq!(conflicts[0].kept_host_name, "local");
1688 assert_eq!(conflicts[0].skipped_host_name, "LOCAL");
1689 assert_eq!(conflicts[0].kept_source_name, "local-ssh");
1690 }
1691
1692 #[test]
1693 fn test_setup_state_path() {
1694 let path = SetupState::path();
1695 assert!(path.ends_with("setup_state.json"));
1696 assert!(path.to_string_lossy().contains("cass"));
1697 }
1698}