Skip to main content

coding_agent_search/sources/
interactive.rs

1//! Interactive terminal prompts for the remote sources setup wizard.
2//!
3//! This module provides rich interactive components using dialoguer, including:
4//! - Multi-select host picker with multi-line item display
5//! - Confirmation prompts for destructive operations
6//!
7//! # Design Decision: dialoguer vs inquire
8//!
9//! We chose dialoguer because:
10//! 1. It integrates well with indicatif (already used for progress bars)
11//! 2. It's actively maintained and widely used
12//! 3. It supports ANSI styling in items via the console crate
13//!
14//! # Multi-line Item Display
15//!
16//! Standard dialoguer MultiSelect shows single-line items. We achieve multi-line
17//! display by embedding ANSI escape sequences and newlines directly in item strings:
18//!
19//! ```text
20//! [x] css
21//!     209.145.54.164 • ubuntu
22//!     ✓ cass v0.1.50 installed • 1,234 sessions
23//!     Claude ✓  Codex ✓  Cursor ✓
24//! ```
25//!
26//! # Example
27//!
28//! ```rust,ignore
29//! use coding_agent_search::sources::interactive::{
30//!     HostSelector, HostDisplayInfo, HostState, CassStatusDisplay
31//! };
32//!
33//! let hosts = vec![
34//!     HostDisplayInfo {
35//!         name: "css".into(),
36//!         hostname: "209.145.54.164".into(),
37//!         username: "ubuntu".into(),
38//!         cass_status: CassStatusDisplay::Installed { version: "0.1.50".into(), sessions: 1234 },
39//!         detected_agents: vec!["claude".into(), "codex".into()],
40//!         reachable: true,
41//!         error: None,
42//!         state: HostState::ReadyToSync,
43//!         system_info: Some("ubuntu 22.04 • 45GB free".into()),
44//!     },
45//!     // ... more hosts
46//! ];
47//!
48//! let selector = HostSelector::new(hosts);
49//! let selected = selector.prompt()?;
50//! ```
51
52use std::collections::HashSet;
53use std::fmt;
54use std::io::IsTerminal;
55
56use colored::Colorize;
57use dialoguer::{Confirm, MultiSelect, theme::ColorfulTheme};
58
59use super::probe::{CassStatus, HostProbeResult};
60
61// =============================================================================
62// Types
63// =============================================================================
64
65/// State of a host for selection purposes.
66///
67/// Determines how the host appears in the UI and whether it's selectable.
68#[derive(Debug, Clone, Copy, PartialEq, Eq)]
69pub enum HostState {
70    /// cass installed and indexed - ready to sync immediately.
71    ReadyToSync,
72    /// cass installed but needs indexing.
73    NeedsIndexing,
74    /// cass not found - needs installation.
75    NeedsInstall,
76    /// SSH connection failed.
77    Unreachable,
78    /// Already configured in sources.toml.
79    AlreadyConfigured,
80}
81
82impl HostState {
83    /// Get the status badge for display (right-aligned).
84    pub fn status_badge(&self) -> String {
85        match self {
86            HostState::ReadyToSync => format!("{} Ready to sync", "✓".green()),
87            HostState::NeedsIndexing => format!("{} Needs indexing", "⚡".yellow()),
88            HostState::NeedsInstall => format!("{} Needs install", "⚠".yellow()),
89            HostState::Unreachable => format!("{} Unreachable", "✗".red()),
90            HostState::AlreadyConfigured => format!("{} Already setup", "═".cyan()),
91        }
92    }
93
94    /// Check if this host state is selectable.
95    pub fn is_selectable(&self) -> bool {
96        matches!(
97            self,
98            HostState::ReadyToSync | HostState::NeedsIndexing | HostState::NeedsInstall
99        )
100    }
101
102    /// Check if this host should be pre-selected.
103    pub fn should_preselect(&self) -> bool {
104        // Pre-select ready and needs-indexing hosts; don't pre-select needs-install
105        matches!(self, HostState::ReadyToSync | HostState::NeedsIndexing)
106    }
107}
108
109/// Display information for a remote host in the selection UI.
110#[derive(Debug, Clone)]
111pub struct HostDisplayInfo {
112    /// SSH config name (e.g., "css", "laptop")
113    pub name: String,
114    /// IP address or hostname
115    pub hostname: String,
116    /// SSH username
117    pub username: String,
118    /// cass installation status on this host
119    pub cass_status: CassStatusDisplay,
120    /// Detected coding agents on this host
121    pub detected_agents: Vec<String>,
122    /// Whether this host is reachable
123    pub reachable: bool,
124    /// Optional error message if unreachable
125    pub error: Option<String>,
126    /// Host state for selection purposes
127    pub state: HostState,
128    /// OS and free disk space info
129    pub system_info: Option<String>,
130}
131
132/// cass installation status for display purposes.
133#[derive(Debug, Clone)]
134pub enum CassStatusDisplay {
135    /// cass is installed with known version and session count
136    Installed { version: String, sessions: u64 },
137    /// cass is installed but not indexed
138    InstalledNotIndexed { version: String },
139    /// cass is not installed but agent data was detected
140    NotInstalled,
141    /// Could not determine status (e.g., probe failed)
142    Unknown,
143}
144
145/// Result of host selection.
146#[derive(Debug, Clone)]
147pub struct HostSelectionResult {
148    /// Indices of selected hosts in the original hosts list.
149    pub selected_indices: Vec<usize>,
150    /// Hosts that need cass installation.
151    pub needs_install: Vec<usize>,
152    /// Hosts that have cass but need indexing.
153    pub needs_indexing: Vec<usize>,
154    /// Hosts ready for sync (cass installed and indexed).
155    pub ready_for_sync: Vec<usize>,
156}
157
158// =============================================================================
159// Host Selector
160// =============================================================================
161
162/// Interactive multi-select host picker with rich display.
163pub struct HostSelector {
164    hosts: Vec<HostDisplayInfo>,
165    theme: ColorfulTheme,
166}
167
168impl HostSelector {
169    /// Create a new host selector with the given hosts.
170    pub fn new(hosts: Vec<HostDisplayInfo>) -> Self {
171        Self {
172            hosts,
173            theme: ColorfulTheme::default(),
174        }
175    }
176
177    /// Format a single host for multi-line display.
178    ///
179    /// Returns a string with ANSI formatting suitable for terminal display.
180    /// Format matches the mockup in the bead spec:
181    /// ```text
182    /// [x] css                                                    ✓ Ready to sync
183    ///     209.145.54.164 • ubuntu 22.04 • 45GB free
184    ///     ✓ cass v0.1.50 • 1,234 sessions indexed
185    ///     Claude ✓  Codex ✓  Cursor ✓  Gemini ✓
186    /// ```
187    fn format_host(&self, host: &HostDisplayInfo) -> String {
188        let mut lines = Vec::new();
189
190        // Line 1: Host name with right-aligned status badge
191        // Note: ANSI codes make length calculation tricky, so we use a fixed width
192        let status_badge = host.state.status_badge();
193        let name_line = format!("{}  {}", host.name.bold(), status_badge);
194        lines.push(name_line);
195
196        // Line 2: Hostname, OS, disk space (dimmed)
197        let system_info = host.system_info.as_deref().unwrap_or("");
198        let host_info = if system_info.is_empty() {
199            format!(
200                "    {} • {}",
201                host.hostname.dimmed(),
202                host.username.dimmed()
203            )
204        } else {
205            format!(
206                "    {} • {} • {}",
207                host.hostname.dimmed(),
208                host.username.dimmed(),
209                system_info.dimmed()
210            )
211        };
212        lines.push(host_info);
213
214        // Line 3: cass status
215        let status_line = match &host.cass_status {
216            CassStatusDisplay::Installed { version, sessions } => {
217                format!(
218                    "    {} cass v{} • {} sessions indexed",
219                    "✓".green(),
220                    version,
221                    sessions
222                )
223            }
224            CassStatusDisplay::InstalledNotIndexed { version } => {
225                format!(
226                    "    {} cass v{} • {} (will index)",
227                    "⚡".yellow(),
228                    version,
229                    "not indexed".yellow()
230                )
231            }
232            CassStatusDisplay::NotInstalled => {
233                format!(
234                    "    {} cass not installed (will install via cargo)",
235                    "✗".yellow()
236                )
237            }
238            CassStatusDisplay::Unknown => {
239                format!("    {} status unknown", "?".dimmed())
240            }
241        };
242        lines.push(status_line);
243
244        // Line 4: Detected agents (if any)
245        if !host.detected_agents.is_empty() {
246            let agents: Vec<String> = host
247                .detected_agents
248                .iter()
249                .map(|a| {
250                    // Capitalize first letter for display
251                    let display_name = if a.is_empty() {
252                        a.clone()
253                    } else {
254                        let mut chars = a.chars();
255                        match chars.next() {
256                            Some(first) => first.to_uppercase().chain(chars).collect(),
257                            None => a.clone(),
258                        }
259                    };
260                    format!("{} {}", display_name.cyan(), "✓".green())
261                })
262                .collect();
263            let agents_line = format!("    {}", agents.join("  "));
264            lines.push(agents_line);
265        }
266
267        // Line 5: Error if unreachable
268        if !host.reachable {
269            let error_msg = host.error.as_deref().unwrap_or("unreachable");
270            let error_line = format!("    {} {}", "⚠".red(), error_msg.red());
271            lines.push(error_line);
272        }
273
274        // Line 6: Already configured message
275        if host.state == HostState::AlreadyConfigured {
276            lines.push(format!(
277                "    {}",
278                "Use 'cass sources edit' to modify".dimmed()
279            ));
280        }
281
282        lines.join("\n")
283    }
284
285    /// Show the interactive multi-select prompt.
286    ///
287    /// Returns the selection result or an error if the prompt was cancelled.
288    pub fn prompt(&self) -> Result<HostSelectionResult, InteractiveError> {
289        if self.hosts.is_empty() {
290            return Err(InteractiveError::NoHosts);
291        }
292
293        // Only show selectable hosts (filter out unreachable and already-configured)
294        let selectable_hosts: Vec<(usize, &HostDisplayInfo)> = self
295            .hosts
296            .iter()
297            .enumerate()
298            .filter(|(_, h)| h.state.is_selectable())
299            .collect();
300
301        if selectable_hosts.is_empty() {
302            return Err(InteractiveError::NoSelectableHosts);
303        }
304
305        // Format selectable hosts for display
306        let items: Vec<String> = selectable_hosts
307            .iter()
308            .map(|(_, h)| self.format_host(h))
309            .collect();
310
311        // Pre-select based on HostState
312        let defaults: Vec<bool> = selectable_hosts
313            .iter()
314            .map(|(_, h)| h.state.should_preselect())
315            .collect();
316
317        // Show the prompt
318        println!();
319        println!(
320            "{}",
321            "Select hosts to configure as sources:".bold().underline()
322        );
323        println!(
324            "{}",
325            "[space] toggle  [a] all  [enter] confirm  [q] quit".dimmed()
326        );
327        println!();
328
329        let selected_in_filtered = MultiSelect::with_theme(&self.theme)
330            .items(&items)
331            .defaults(&defaults)
332            .interact_opt()
333            .map_err(|e| InteractiveError::IoError(e.to_string()))?
334            .ok_or(InteractiveError::Cancelled)?;
335
336        // Map filtered indices back to original indices
337        let selected: Vec<usize> = selected_in_filtered
338            .iter()
339            .filter_map(|&i| selectable_hosts.get(i).map(|(orig_idx, _)| *orig_idx))
340            .collect();
341
342        // Categorize selections by state
343        let mut needs_install = Vec::new();
344        let mut needs_indexing = Vec::new();
345        let mut ready_for_sync = Vec::new();
346
347        for &idx in &selected {
348            if let Some(host) = self.hosts.get(idx) {
349                match host.state {
350                    HostState::ReadyToSync => ready_for_sync.push(idx),
351                    HostState::NeedsIndexing => needs_indexing.push(idx),
352                    HostState::NeedsInstall => needs_install.push(idx),
353                    _ => {} // Unreachable and AlreadyConfigured are not selectable
354                }
355            }
356        }
357
358        Ok(HostSelectionResult {
359            selected_indices: selected,
360            needs_install,
361            needs_indexing,
362            ready_for_sync,
363        })
364    }
365
366    /// Get host info by index.
367    pub fn get_host(&self, index: usize) -> Option<&HostDisplayInfo> {
368        self.hosts.get(index)
369    }
370}
371
372// =============================================================================
373// Confirmation Prompts
374// =============================================================================
375
376/// Ask for confirmation before a destructive operation.
377pub fn confirm_action(message: &str, default: bool) -> Result<bool, InteractiveError> {
378    Confirm::with_theme(&ColorfulTheme::default())
379        .with_prompt(message)
380        .default(default)
381        .interact()
382        .map_err(|e| InteractiveError::IoError(e.to_string()))
383}
384
385/// Ask for confirmation with a detailed explanation.
386pub fn confirm_with_details(
387    action: &str,
388    details: &[&str],
389    default: bool,
390) -> Result<bool, InteractiveError> {
391    println!();
392    println!("{}", action.bold());
393    for detail in details {
394        println!("  • {}", detail);
395    }
396    println!();
397
398    confirm_action("Proceed?", default)
399}
400
401// =============================================================================
402// Probe Result Conversion
403// =============================================================================
404
405/// Convert a probe result to display info for the selection UI.
406///
407/// # Arguments
408/// * `probe` - The probe result from SSH probing
409/// * `already_configured` - Set of normalized source-name keys already configured
410pub fn probe_to_display_info(
411    probe: &HostProbeResult,
412    already_configured: &HashSet<String>,
413) -> HostDisplayInfo {
414    let generated_name = super::config::normalize_generated_remote_source_name(&probe.host_name);
415
416    // Determine host state
417    let state = if already_configured.contains(&super::config::source_name_key(&generated_name)) {
418        HostState::AlreadyConfigured
419    } else if !probe.reachable {
420        HostState::Unreachable
421    } else {
422        match &probe.cass_status {
423            CassStatus::Indexed { session_count, .. } if *session_count > 0 => {
424                HostState::ReadyToSync
425            }
426            CassStatus::Indexed { .. } => HostState::NeedsIndexing, // 0 sessions
427            CassStatus::InstalledNotIndexed { .. } => HostState::NeedsIndexing,
428            CassStatus::NotFound | CassStatus::Unknown => HostState::NeedsInstall,
429        }
430    };
431
432    // Convert cass status
433    let cass_status = match &probe.cass_status {
434        CassStatus::Indexed {
435            version,
436            session_count,
437            ..
438        } => CassStatusDisplay::Installed {
439            version: version.clone(),
440            sessions: *session_count,
441        },
442        CassStatus::InstalledNotIndexed { version } => CassStatusDisplay::InstalledNotIndexed {
443            version: version.clone(),
444        },
445        CassStatus::NotFound => CassStatusDisplay::NotInstalled,
446        CassStatus::Unknown => CassStatusDisplay::Unknown,
447    };
448
449    // Format system info string
450    let system_info = probe.system_info.as_ref().map(|si| {
451        // Use distro if present and non-empty, otherwise fall back to OS
452        let os_info = si
453            .distro
454            .as_deref()
455            .filter(|d| !d.is_empty())
456            .unwrap_or(&si.os);
457        if let Some(res) = &probe.resources {
458            let disk_gb = res.disk_available_mb / 1024;
459            format!("{} • {}GB free", os_info, disk_gb)
460        } else {
461            os_info.to_string()
462        }
463    });
464
465    // Extract detected agent names
466    let detected_agents: Vec<String> = probe
467        .detected_agents
468        .iter()
469        .map(|a| a.agent_type.clone())
470        .collect();
471
472    // Use probe host name as the display hostname
473    let hostname = probe.host_name.clone();
474
475    let username = probe
476        .system_info
477        .as_ref()
478        .and_then(|si| {
479            // Extract username from remote_home path like "/home/ubuntu"
480            // Filter out empty results from paths like "/" or ""
481            si.remote_home
482                .rsplit('/')
483                .find(|s| !s.is_empty())
484                .map(String::from)
485        })
486        .unwrap_or_else(|| "user".to_string());
487
488    HostDisplayInfo {
489        name: probe.host_name.clone(),
490        hostname,
491        username,
492        cass_status,
493        detected_agents,
494        reachable: probe.reachable,
495        error: probe.error.clone(),
496        state,
497        system_info,
498    }
499}
500
501/// Run the interactive host selection flow.
502///
503/// This is the main entry point for host selection. It:
504/// 1. Converts probe results to display info
505/// 2. Shows the interactive multi-select prompt
506/// 3. Returns the selection result
507///
508/// # Arguments
509/// * `probed_hosts` - Results from probing SSH hosts
510/// * `already_configured` - Set of normalized source-name keys already configured
511///
512/// # Returns
513/// The selected hosts and their categorization, or an error if cancelled.
514pub fn run_host_selection(
515    probed_hosts: &[HostProbeResult],
516    already_configured: &HashSet<String>,
517) -> Result<(HostSelectionResult, Vec<HostDisplayInfo>), InteractiveError> {
518    // Check for TTY
519    if !std::io::stdin().is_terminal() {
520        return Err(InteractiveError::NotATty);
521    }
522
523    // Convert probe results to display info
524    let hosts: Vec<HostDisplayInfo> = probed_hosts
525        .iter()
526        .map(|p| probe_to_display_info(p, already_configured))
527        .collect();
528
529    // Show non-selectable hosts info
530    let unreachable_count = hosts
531        .iter()
532        .filter(|h| h.state == HostState::Unreachable)
533        .count();
534    let configured_count = hosts
535        .iter()
536        .filter(|h| h.state == HostState::AlreadyConfigured)
537        .count();
538
539    if unreachable_count > 0 || configured_count > 0 {
540        println!();
541        if unreachable_count > 0 {
542            println!(
543                "{}",
544                format!(
545                    "  {} {} unreachable (check SSH config)",
546                    "⚠".yellow(),
547                    unreachable_count
548                )
549                .dimmed()
550            );
551        }
552        if configured_count > 0 {
553            println!(
554                "{}",
555                format!("  {} {} already configured", "═".cyan(), configured_count).dimmed()
556            );
557        }
558    }
559
560    // Run selection
561    let selector = HostSelector::new(hosts.clone());
562    let result = selector.prompt()?;
563
564    // Show summary
565    let install_count = result.needs_install.len();
566    let index_count = result.needs_indexing.len();
567    let sync_count = result.ready_for_sync.len();
568    let total = result.selected_indices.len();
569
570    if total > 0 {
571        println!();
572        let mut parts = Vec::new();
573        if sync_count > 0 {
574            parts.push(format!("{} ready to sync", sync_count));
575        }
576        if index_count > 0 {
577            parts.push(format!("{} needs indexing", index_count));
578        }
579        if install_count > 0 {
580            // Estimate install time: ~3 min per host for cargo install
581            let est_mins = install_count * 3;
582            parts.push(format!(
583                "{} needs install (~{} min)",
584                install_count, est_mins
585            ));
586        }
587        println!(
588            "  {} selected: {}",
589            total.to_string().bold(),
590            parts.join(", ")
591        );
592    }
593
594    Ok((result, hosts))
595}
596
597// =============================================================================
598// Errors
599// =============================================================================
600
601/// Errors from interactive prompts.
602#[derive(Debug)]
603pub enum InteractiveError {
604    /// User cancelled the prompt.
605    Cancelled,
606    /// No hosts available to select.
607    NoHosts,
608    /// Hosts exist but none are selectable (all unreachable or already configured).
609    NoSelectableHosts,
610    /// Not running in a TTY (interactive mode required).
611    NotATty,
612    /// IO error during prompt.
613    IoError(String),
614}
615
616impl fmt::Display for InteractiveError {
617    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
618        match self {
619            InteractiveError::Cancelled => write!(f, "Operation cancelled by user"),
620            InteractiveError::NoHosts => write!(f, "No hosts available for selection"),
621            InteractiveError::NoSelectableHosts => {
622                write!(
623                    f,
624                    "No selectable hosts (all unreachable or already configured)"
625                )
626            }
627            InteractiveError::NotATty => {
628                write!(
629                    f,
630                    "Interactive selection requires a terminal.\n\n\
631                     For non-interactive use:\n  \
632                     cass sources setup --hosts css,csd,yto\n  \
633                     cass sources setup --non-interactive  # select all reachable"
634                )
635            }
636            InteractiveError::IoError(msg) => write!(f, "IO error: {}", msg),
637        }
638    }
639}
640
641impl std::error::Error for InteractiveError {}
642
643// =============================================================================
644// Tests
645// =============================================================================
646
647#[cfg(test)]
648mod tests {
649    use super::*;
650
651    #[test]
652    fn test_host_display_info_creation() {
653        let host = HostDisplayInfo {
654            name: "laptop".into(),
655            hostname: "192.168.1.100".into(),
656            username: "user".into(),
657            cass_status: CassStatusDisplay::Installed {
658                version: "0.1.50".into(),
659                sessions: 123,
660            },
661            detected_agents: vec!["claude".into(), "codex".into()],
662            reachable: true,
663            error: None,
664            state: HostState::ReadyToSync,
665            system_info: Some("ubuntu 22.04 • 45GB free".into()),
666        };
667
668        assert_eq!(host.name, "laptop");
669        assert!(host.reachable);
670        assert!(matches!(
671            host.cass_status,
672            CassStatusDisplay::Installed { .. }
673        ));
674        assert_eq!(host.state, HostState::ReadyToSync);
675    }
676
677    #[test]
678    fn test_host_selector_format() {
679        let hosts = vec![HostDisplayInfo {
680            name: "test-host".into(),
681            hostname: "10.0.0.1".into(),
682            username: "testuser".into(),
683            cass_status: CassStatusDisplay::NotInstalled,
684            detected_agents: vec!["claude".into()],
685            reachable: true,
686            error: None,
687            state: HostState::NeedsInstall,
688            system_info: None,
689        }];
690
691        let selector = HostSelector::new(hosts);
692        let formatted = selector.format_host(&selector.hosts[0]);
693
694        // Check that formatting includes expected content
695        assert!(formatted.contains("test-host"));
696        assert!(formatted.contains("10.0.0.1"));
697        assert!(formatted.contains("testuser"));
698        assert!(formatted.contains("cass not installed"));
699        // Agent names are capitalized for display (claude -> Claude)
700        assert!(formatted.contains("Claude"));
701        // Should contain status badge
702        assert!(formatted.contains("Needs install"));
703    }
704
705    #[test]
706    fn test_host_selector_empty() {
707        let selector = HostSelector::new(vec![]);
708        // Can't actually call prompt() in tests, but we can verify error handling
709        assert!(selector.hosts.is_empty());
710    }
711
712    #[test]
713    fn test_cass_status_display_variants() {
714        let installed = CassStatusDisplay::Installed {
715            version: "0.1.50".into(),
716            sessions: 100,
717        };
718        let not_installed = CassStatusDisplay::NotInstalled;
719        let unknown = CassStatusDisplay::Unknown;
720
721        assert!(matches!(installed, CassStatusDisplay::Installed { .. }));
722        assert!(matches!(not_installed, CassStatusDisplay::NotInstalled));
723        assert!(matches!(unknown, CassStatusDisplay::Unknown));
724    }
725
726    #[test]
727    fn test_host_selection_result() {
728        let result = HostSelectionResult {
729            selected_indices: vec![0, 2, 3],
730            needs_install: vec![2],
731            needs_indexing: vec![],
732            ready_for_sync: vec![0, 3],
733        };
734
735        assert_eq!(result.selected_indices.len(), 3);
736        assert_eq!(result.needs_install.len(), 1);
737        assert_eq!(result.needs_indexing.len(), 0);
738        assert_eq!(result.ready_for_sync.len(), 2);
739    }
740
741    #[test]
742    fn test_interactive_error_display() {
743        let cancelled = InteractiveError::Cancelled;
744        let no_hosts = InteractiveError::NoHosts;
745        let io_error = InteractiveError::IoError("test error".into());
746
747        assert!(cancelled.to_string().contains("cancelled"));
748        assert!(no_hosts.to_string().contains("No hosts"));
749        assert!(io_error.to_string().contains("test error"));
750    }
751
752    #[test]
753    fn test_unreachable_host_format() {
754        let hosts = vec![HostDisplayInfo {
755            name: "unreachable-host".into(),
756            hostname: "10.0.0.99".into(),
757            username: "user".into(),
758            cass_status: CassStatusDisplay::Unknown,
759            detected_agents: vec![],
760            reachable: false,
761            error: Some("Connection timed out".into()),
762            state: HostState::Unreachable,
763            system_info: None,
764        }];
765
766        let selector = HostSelector::new(hosts);
767        let formatted = selector.format_host(&selector.hosts[0]);
768
769        assert!(formatted.contains("unreachable-host"));
770        assert!(formatted.contains("Connection timed out"));
771        assert!(formatted.contains("Unreachable"));
772    }
773
774    #[test]
775    fn test_host_state_properties() {
776        // Test is_selectable
777        assert!(HostState::ReadyToSync.is_selectable());
778        assert!(HostState::NeedsIndexing.is_selectable());
779        assert!(HostState::NeedsInstall.is_selectable());
780        assert!(!HostState::Unreachable.is_selectable());
781        assert!(!HostState::AlreadyConfigured.is_selectable());
782
783        // Test should_preselect
784        assert!(HostState::ReadyToSync.should_preselect());
785        assert!(HostState::NeedsIndexing.should_preselect());
786        assert!(!HostState::NeedsInstall.should_preselect());
787        assert!(!HostState::Unreachable.should_preselect());
788        assert!(!HostState::AlreadyConfigured.should_preselect());
789    }
790
791    #[test]
792    fn test_host_state_status_badges() {
793        let badge = HostState::ReadyToSync.status_badge();
794        assert!(badge.contains("Ready to sync"));
795
796        let badge = HostState::NeedsIndexing.status_badge();
797        assert!(badge.contains("Needs indexing"));
798
799        let badge = HostState::NeedsInstall.status_badge();
800        assert!(badge.contains("Needs install"));
801
802        let badge = HostState::Unreachable.status_badge();
803        assert!(badge.contains("Unreachable"));
804
805        let badge = HostState::AlreadyConfigured.status_badge();
806        assert!(badge.contains("Already setup"));
807    }
808
809    #[test]
810    fn test_probe_to_display_info() {
811        let probe = HostProbeResult {
812            host_name: "test-server".into(),
813            reachable: true,
814            connection_time_ms: 50,
815            cass_status: CassStatus::Indexed {
816                version: "0.1.50".into(),
817                session_count: 100,
818                last_indexed: None,
819            },
820            detected_agents: vec![],
821            system_info: None,
822            resources: None,
823            error: None,
824        };
825
826        let already_configured = HashSet::new();
827        let display = probe_to_display_info(&probe, &already_configured);
828
829        assert_eq!(display.name, "test-server");
830        assert_eq!(display.state, HostState::ReadyToSync);
831        assert!(matches!(
832            display.cass_status,
833            CassStatusDisplay::Installed { sessions: 100, .. }
834        ));
835    }
836
837    #[test]
838    fn test_probe_to_display_info_already_configured() {
839        let probe = HostProbeResult {
840            host_name: "configured-host".into(),
841            reachable: true,
842            connection_time_ms: 50,
843            cass_status: CassStatus::Indexed {
844                version: "0.1.50".into(),
845                session_count: 100,
846                last_indexed: None,
847            },
848            detected_agents: vec![],
849            system_info: None,
850            resources: None,
851            error: None,
852        };
853
854        let mut already_configured = HashSet::new();
855        already_configured.insert("configured-host".into());
856        let display = probe_to_display_info(&probe, &already_configured);
857
858        assert_eq!(display.state, HostState::AlreadyConfigured);
859    }
860
861    #[test]
862    fn test_probe_to_display_info_already_configured_case_insensitive() {
863        let probe = HostProbeResult {
864            host_name: "Configured-Host".into(),
865            reachable: true,
866            connection_time_ms: 50,
867            cass_status: CassStatus::Indexed {
868                version: "0.1.50".into(),
869                session_count: 100,
870                last_indexed: None,
871            },
872            detected_agents: vec![],
873            system_info: None,
874            resources: None,
875            error: None,
876        };
877
878        let mut already_configured = HashSet::new();
879        already_configured.insert(super::super::config::source_name_key("configured-host"));
880        let display = probe_to_display_info(&probe, &already_configured);
881
882        assert_eq!(display.state, HostState::AlreadyConfigured);
883    }
884
885    #[test]
886    fn test_probe_to_display_info_reserved_local_ssh_alias_already_configured() {
887        let probe = HostProbeResult {
888            host_name: "local".into(),
889            reachable: true,
890            connection_time_ms: 50,
891            cass_status: CassStatus::Indexed {
892                version: "0.1.50".into(),
893                session_count: 100,
894                last_indexed: None,
895            },
896            detected_agents: vec![],
897            system_info: None,
898            resources: None,
899            error: None,
900        };
901
902        let mut already_configured = HashSet::new();
903        already_configured.insert(super::super::config::source_name_key("local-ssh"));
904        let display = probe_to_display_info(&probe, &already_configured);
905
906        assert_eq!(display.state, HostState::AlreadyConfigured);
907    }
908
909    #[test]
910    fn test_installed_not_indexed_status() {
911        let status = CassStatusDisplay::InstalledNotIndexed {
912            version: "0.1.50".into(),
913        };
914        assert!(matches!(
915            status,
916            CassStatusDisplay::InstalledNotIndexed { .. }
917        ));
918    }
919
920    #[test]
921    fn test_probe_to_display_info_username_extraction() {
922        use super::super::probe::SystemInfo;
923
924        // Normal case: /home/ubuntu -> ubuntu
925        let probe = HostProbeResult {
926            host_name: "test".into(),
927            reachable: true,
928            connection_time_ms: 50,
929            cass_status: CassStatus::NotFound,
930            detected_agents: vec![],
931            system_info: Some(SystemInfo {
932                os: "Linux".into(),
933                arch: "x86_64".into(),
934                distro: None,
935                has_cargo: false,
936                has_cargo_binstall: false,
937                has_curl: false,
938                has_wget: false,
939                remote_home: "/home/ubuntu".into(),
940                machine_id: None,
941            }),
942            resources: None,
943            error: None,
944        };
945        let display = probe_to_display_info(&probe, &HashSet::new());
946        assert_eq!(display.username, "ubuntu");
947
948        // Edge case: root path "/" -> should fall back to "user"
949        let probe_root = HostProbeResult {
950            host_name: "test".into(),
951            reachable: true,
952            connection_time_ms: 50,
953            cass_status: CassStatus::NotFound,
954            detected_agents: vec![],
955            system_info: Some(SystemInfo {
956                os: "Linux".into(),
957                arch: "x86_64".into(),
958                distro: None,
959                has_cargo: false,
960                has_cargo_binstall: false,
961                has_curl: false,
962                has_wget: false,
963                remote_home: "/".into(),
964                machine_id: None,
965            }),
966            resources: None,
967            error: None,
968        };
969        let display_root = probe_to_display_info(&probe_root, &HashSet::new());
970        assert_eq!(display_root.username, "user");
971
972        // Edge case: empty path -> should fall back to "user"
973        let probe_empty = HostProbeResult {
974            host_name: "test".into(),
975            reachable: true,
976            connection_time_ms: 50,
977            cass_status: CassStatus::NotFound,
978            detected_agents: vec![],
979            system_info: Some(SystemInfo {
980                os: "Linux".into(),
981                arch: "x86_64".into(),
982                distro: None,
983                has_cargo: false,
984                has_cargo_binstall: false,
985                has_curl: false,
986                has_wget: false,
987                remote_home: "".into(),
988                machine_id: None,
989            }),
990            resources: None,
991            error: None,
992        };
993        let display_empty = probe_to_display_info(&probe_empty, &HashSet::new());
994        assert_eq!(display_empty.username, "user");
995    }
996
997    #[test]
998    fn test_probe_to_display_info_empty_distro_fallback() {
999        use super::super::probe::SystemInfo;
1000
1001        // When distro is Some(""), should fall back to OS name
1002        let probe = HostProbeResult {
1003            host_name: "test".into(),
1004            reachable: true,
1005            connection_time_ms: 50,
1006            cass_status: CassStatus::NotFound,
1007            detected_agents: vec![],
1008            system_info: Some(SystemInfo {
1009                os: "Linux".into(),
1010                arch: "x86_64".into(),
1011                distro: Some("".into()), // Empty string distro
1012                has_cargo: false,
1013                has_cargo_binstall: false,
1014                has_curl: false,
1015                has_wget: false,
1016                remote_home: "/home/user".into(),
1017                machine_id: None,
1018            }),
1019            resources: None,
1020            error: None,
1021        };
1022        let display = probe_to_display_info(&probe, &HashSet::new());
1023        // system_info should show "Linux" not empty string
1024        assert!(display.system_info.as_ref().unwrap().contains("Linux"));
1025    }
1026}