Skip to main content

coding_agent_search/sources/
setup.rs

1//! Setup wizard for configuring remote sources.
2//!
3//! This module provides an interactive wizard that orchestrates the complete
4//! remote sources setup workflow:
5//!
6//! 1. Discovery - Find SSH hosts from ~/.ssh/config
7//! 2. Probing - Check host connectivity and cass status
8//! 3. Selection - Interactive host selection UI
9//! 4. Installation - Install cass on remotes that need it
10//! 5. Indexing - Run cass index on remotes
11//! 6. Configuration - Generate sources.toml entries
12//! 7. Sync - Initial sync of session data
13//!
14//! The wizard supports resume capability via state persistence, allowing
15//! interrupted setups to continue where they left off.
16
17use 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/// Options for the setup wizard.
35#[derive(Debug, Clone)]
36pub struct SetupOptions {
37    /// Preview what would happen without making changes.
38    pub dry_run: bool,
39    /// Skip interactive prompts (use defaults).
40    pub non_interactive: bool,
41    /// Specific hosts to configure (skips discovery/selection).
42    pub hosts: Option<Vec<String>>,
43    /// Skip cass installation on remotes.
44    pub skip_install: bool,
45    /// Skip indexing on remotes.
46    pub skip_index: bool,
47    /// Skip syncing after setup.
48    pub skip_sync: bool,
49    /// SSH connection timeout in seconds.
50    pub timeout: u64,
51    /// Continue from previous interrupted setup.
52    pub resume: bool,
53    /// Show detailed progress output.
54    pub verbose: bool,
55    /// Output as JSON.
56    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    // Probe status is captured before installation. Same-run installs and
123    // dry-run installation plans can make a NotFound probe indexable, but a
124    // skip-install or failed-install NotFound host must not be indexed.
125    completed_installs.contains(host_name)
126        || planned_installs.contains(host_name)
127        || RemoteIndexer::needs_indexing(host)
128}
129
130/// Persistent state for resumable setup.
131#[derive(Debug, Clone, Serialize, Deserialize, Default)]
132pub struct SetupState {
133    /// Whether discovery phase is complete.
134    pub discovery_complete: bool,
135    /// Number of discovered hosts.
136    pub discovered_hosts: usize,
137    /// Names of discovered hosts.
138    pub discovered_host_names: Vec<String>,
139    /// Whether probing phase is complete.
140    pub probing_complete: bool,
141    /// Probe results for each host.
142    #[serde(default)]
143    pub probed_hosts: Vec<HostProbeResult>,
144    /// Whether selection phase is complete.
145    pub selection_complete: bool,
146    /// Names of selected hosts.
147    pub selected_host_names: Vec<String>,
148    /// Whether installation phase is complete.
149    pub installation_complete: bool,
150    /// Hosts where installation completed.
151    pub completed_installs: Vec<String>,
152    /// Whether indexing phase is complete.
153    pub indexing_complete: bool,
154    /// Hosts where indexing completed.
155    pub completed_indexes: Vec<String>,
156    /// Whether configuration phase is complete.
157    pub configuration_complete: bool,
158    /// Whether sync phase is complete.
159    pub sync_complete: bool,
160    /// Current operation description (for display).
161    pub current_operation: Option<String>,
162    /// When setup started (ISO 8601 timestamp).
163    pub started_at: Option<String>,
164}
165
166impl SetupState {
167    /// Get the state file path.
168    fn path() -> PathBuf {
169        dirs::cache_dir()
170            .unwrap_or_else(|| PathBuf::from("."))
171            .join("cass")
172            .join("setup_state.json")
173    }
174
175    /// Load state from disk if it exists.
176    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    /// Save state to disk.
189    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    /// Clear state from disk.
205    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    /// Check if there's any progress to resume.
215    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/// Errors that can occur during setup.
392#[derive(Debug, thiserror::Error)]
393pub enum SetupError {
394    /// IO error.
395    #[error("IO error: {0}")]
396    Io(std::io::Error),
397    /// JSON serialization error.
398    #[error("JSON error: {0}")]
399    Json(serde_json::Error),
400    /// Configuration error.
401    #[error("Config error: {0}")]
402    Config(super::config::ConfigError),
403    /// Installation error.
404    #[error("Install error: {0}")]
405    Install(super::install::InstallError),
406    /// Index error.
407    #[error("Index error: {0}")]
408    Index(super::index::IndexError),
409    /// Interactive UI error.
410    #[error("Interactive error: {0}")]
411    Interactive(super::interactive::InteractiveError),
412    /// User cancelled.
413    #[error("Setup cancelled by user")]
414    Cancelled,
415    /// No hosts found.
416    #[error("No SSH hosts found or selected")]
417    NoHosts,
418    /// Setup interrupted.
419    #[error("Setup interrupted")]
420    Interrupted,
421}
422
423/// Result of the setup wizard.
424#[derive(Debug)]
425pub struct SetupResult {
426    /// Number of sources added.
427    pub sources_added: usize,
428    /// Number of hosts where cass was installed.
429    pub hosts_installed: usize,
430    /// Number of hosts that were indexed.
431    pub hosts_indexed: usize,
432    /// Total sessions now searchable.
433    pub total_sessions: u64,
434    /// Whether this was a dry run.
435    pub dry_run: bool,
436}
437
438/// Print a phase header.
439fn print_phase_header(phase: &str) {
440    println!();
441    println!(
442        "{}",
443        format!("┌─ {} ", phase).bold().on_bright_black().white()
444    );
445}
446
447/// Print phase completion.
448fn print_phase_done(message: &str) {
449    println!("│ {} {}", "✓".green(), message);
450    println!("└{}", "─".repeat(70).dimmed());
451}
452
453/// Run the setup wizard.
454pub fn run_setup(opts: &SetupOptions) -> Result<SetupResult, SetupError> {
455    // Set up interruption flag (Ctrl+C handled at CLI level)
456    let interrupted = Arc::new(AtomicBool::new(false));
457
458    // Load or create state
459    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    // Check for interruption helper
470    let check_interrupted = || {
471        if interrupted.load(Ordering::SeqCst) {
472            Err(SetupError::Interrupted)
473        } else {
474            Ok(())
475        }
476    };
477
478    // Print header
479    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    // =========================================================================
509    // Phase 1: Discovery
510    // =========================================================================
511    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            // User specified specific hosts
520            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            // Auto-discover from SSH config
532            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        // Reconstruct from saved state
551        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    // =========================================================================
577    // Phase 2: Probing
578    // =========================================================================
579    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        // Deduplicate hosts that resolve to the same machine (multiple SSH aliases)
616        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            // Show merged aliases if any
635            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                // Sort for deterministic output
643                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    // =========================================================================
677    // Phase 3: Selection
678    // =========================================================================
679    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            // Auto-select all reachable hosts not already configured
692            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            // Interactive selection
706            // Convert Vec<&HostProbeResult> to Vec<HostProbeResult> for the API
707            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                    // Convert selected indices to host names
713                    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        // Reconstruct from saved state
735        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    // =========================================================================
799    // Phase 4: Installation
800    // =========================================================================
801    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                // Confirm installation
831                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                        // Create installer for this specific host
849                        // Skip hosts without system info (they likely failed probing)
850                        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, // Uses Display impl
886                                    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    // =========================================================================
938    // Phase 5: Indexing
939    // =========================================================================
940    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                    // Create indexer for this specific host
989                    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, // Uses Display impl
1001                                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    // =========================================================================
1096    // Phase 6: Configuration
1097    // =========================================================================
1098    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        // Generate preview
1111        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            // Merge and save
1126            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    // =========================================================================
1143    // Phase 7: Sync
1144    // =========================================================================
1145    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        // Note: We don't actually run sync here because it can be long-running
1155        // and the user might want to control when it happens. We just mark it
1156        // as skipped and let them run it manually.
1157        state.sync_complete = true;
1158        state.save()?;
1159    }
1160
1161    // =========================================================================
1162    // Phase 8: Summary
1163    // =========================================================================
1164    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    // Clear state on success
1211    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}