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