use std::collections::HashSet;
use std::fmt;
use std::io::IsTerminal;
use colored::Colorize;
use dialoguer::{Confirm, MultiSelect, theme::ColorfulTheme};
use super::probe::{CassStatus, HostProbeResult};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum HostState {
ReadyToSync,
NeedsIndexing,
NeedsInstall,
Unreachable,
AlreadyConfigured,
}
impl HostState {
pub fn status_badge(&self) -> String {
match self {
HostState::ReadyToSync => format!("{} Ready to sync", "✓".green()),
HostState::NeedsIndexing => format!("{} Needs indexing", "⚡".yellow()),
HostState::NeedsInstall => format!("{} Needs install", "⚠".yellow()),
HostState::Unreachable => format!("{} Unreachable", "✗".red()),
HostState::AlreadyConfigured => format!("{} Already setup", "═".cyan()),
}
}
pub fn is_selectable(&self) -> bool {
matches!(
self,
HostState::ReadyToSync | HostState::NeedsIndexing | HostState::NeedsInstall
)
}
pub fn should_preselect(&self) -> bool {
matches!(self, HostState::ReadyToSync | HostState::NeedsIndexing)
}
}
#[derive(Debug, Clone)]
pub struct HostDisplayInfo {
pub name: String,
pub hostname: String,
pub username: String,
pub cass_status: CassStatusDisplay,
pub detected_agents: Vec<String>,
pub reachable: bool,
pub error: Option<String>,
pub state: HostState,
pub system_info: Option<String>,
}
#[derive(Debug, Clone)]
pub enum CassStatusDisplay {
Installed { version: String, sessions: u64 },
InstalledNotIndexed { version: String },
NotInstalled,
Unknown,
}
#[derive(Debug, Clone)]
pub struct HostSelectionResult {
pub selected_indices: Vec<usize>,
pub needs_install: Vec<usize>,
pub needs_indexing: Vec<usize>,
pub ready_for_sync: Vec<usize>,
}
pub struct HostSelector {
hosts: Vec<HostDisplayInfo>,
theme: ColorfulTheme,
}
impl HostSelector {
pub fn new(hosts: Vec<HostDisplayInfo>) -> Self {
Self {
hosts,
theme: ColorfulTheme::default(),
}
}
fn format_host(&self, host: &HostDisplayInfo) -> String {
let mut lines = Vec::new();
let status_badge = host.state.status_badge();
let name_line = format!("{} {}", host.name.bold(), status_badge);
lines.push(name_line);
let system_info = host.system_info.as_deref().unwrap_or("");
let host_info = if system_info.is_empty() {
format!(
" {} • {}",
host.hostname.dimmed(),
host.username.dimmed()
)
} else {
format!(
" {} • {} • {}",
host.hostname.dimmed(),
host.username.dimmed(),
system_info.dimmed()
)
};
lines.push(host_info);
let status_line = match &host.cass_status {
CassStatusDisplay::Installed { version, sessions } => {
format!(
" {} cass v{} • {} sessions indexed",
"✓".green(),
version,
sessions
)
}
CassStatusDisplay::InstalledNotIndexed { version } => {
format!(
" {} cass v{} • {} (will index)",
"⚡".yellow(),
version,
"not indexed".yellow()
)
}
CassStatusDisplay::NotInstalled => {
format!(
" {} cass not installed (will install via cargo)",
"✗".yellow()
)
}
CassStatusDisplay::Unknown => {
format!(" {} status unknown", "?".dimmed())
}
};
lines.push(status_line);
if !host.detected_agents.is_empty() {
let agents: Vec<String> = host
.detected_agents
.iter()
.map(|a| {
let display_name = if a.is_empty() {
a.clone()
} else {
let mut chars = a.chars();
match chars.next() {
Some(first) => first.to_uppercase().chain(chars).collect(),
None => a.clone(),
}
};
format!("{} {}", display_name.cyan(), "✓".green())
})
.collect();
let agents_line = format!(" {}", agents.join(" "));
lines.push(agents_line);
}
if !host.reachable {
let error_msg = host.error.as_deref().unwrap_or("unreachable");
let error_line = format!(" {} {}", "⚠".red(), error_msg.red());
lines.push(error_line);
}
if host.state == HostState::AlreadyConfigured {
lines.push(format!(
" {}",
"Use 'cass sources edit' to modify".dimmed()
));
}
lines.join("\n")
}
pub fn prompt(&self) -> Result<HostSelectionResult, InteractiveError> {
if self.hosts.is_empty() {
return Err(InteractiveError::NoHosts);
}
let selectable_hosts: Vec<(usize, &HostDisplayInfo)> = self
.hosts
.iter()
.enumerate()
.filter(|(_, h)| h.state.is_selectable())
.collect();
if selectable_hosts.is_empty() {
return Err(InteractiveError::NoSelectableHosts);
}
let items: Vec<String> = selectable_hosts
.iter()
.map(|(_, h)| self.format_host(h))
.collect();
let defaults: Vec<bool> = selectable_hosts
.iter()
.map(|(_, h)| h.state.should_preselect())
.collect();
println!();
println!(
"{}",
"Select hosts to configure as sources:".bold().underline()
);
println!(
"{}",
"[space] toggle [a] all [enter] confirm [q] quit".dimmed()
);
println!();
let selected_in_filtered = MultiSelect::with_theme(&self.theme)
.items(&items)
.defaults(&defaults)
.interact_opt()
.map_err(|e| InteractiveError::IoError(e.to_string()))?
.ok_or(InteractiveError::Cancelled)?;
let selected: Vec<usize> = selected_in_filtered
.iter()
.filter_map(|&i| selectable_hosts.get(i).map(|(orig_idx, _)| *orig_idx))
.collect();
let mut needs_install = Vec::new();
let mut needs_indexing = Vec::new();
let mut ready_for_sync = Vec::new();
for &idx in &selected {
if let Some(host) = self.hosts.get(idx) {
match host.state {
HostState::ReadyToSync => ready_for_sync.push(idx),
HostState::NeedsIndexing => needs_indexing.push(idx),
HostState::NeedsInstall => needs_install.push(idx),
_ => {} }
}
}
Ok(HostSelectionResult {
selected_indices: selected,
needs_install,
needs_indexing,
ready_for_sync,
})
}
pub fn get_host(&self, index: usize) -> Option<&HostDisplayInfo> {
self.hosts.get(index)
}
}
pub fn confirm_action(message: &str, default: bool) -> Result<bool, InteractiveError> {
Confirm::with_theme(&ColorfulTheme::default())
.with_prompt(message)
.default(default)
.interact()
.map_err(|e| InteractiveError::IoError(e.to_string()))
}
pub fn confirm_with_details(
action: &str,
details: &[&str],
default: bool,
) -> Result<bool, InteractiveError> {
println!();
println!("{}", action.bold());
for detail in details {
println!(" • {}", detail);
}
println!();
confirm_action("Proceed?", default)
}
pub fn probe_to_display_info(
probe: &HostProbeResult,
already_configured: &HashSet<String>,
) -> HostDisplayInfo {
let generated_name = super::config::normalize_generated_remote_source_name(&probe.host_name);
let state = if already_configured.contains(&super::config::source_name_key(&generated_name)) {
HostState::AlreadyConfigured
} else if !probe.reachable {
HostState::Unreachable
} else {
match &probe.cass_status {
CassStatus::Indexed { session_count, .. } if *session_count > 0 => {
HostState::ReadyToSync
}
CassStatus::Indexed { .. } => HostState::NeedsIndexing, CassStatus::InstalledNotIndexed { .. } => HostState::NeedsIndexing,
CassStatus::NotFound | CassStatus::Unknown => HostState::NeedsInstall,
}
};
let cass_status = match &probe.cass_status {
CassStatus::Indexed {
version,
session_count,
..
} => CassStatusDisplay::Installed {
version: version.clone(),
sessions: *session_count,
},
CassStatus::InstalledNotIndexed { version } => CassStatusDisplay::InstalledNotIndexed {
version: version.clone(),
},
CassStatus::NotFound => CassStatusDisplay::NotInstalled,
CassStatus::Unknown => CassStatusDisplay::Unknown,
};
let system_info = probe.system_info.as_ref().map(|si| {
let os_info = si
.distro
.as_deref()
.filter(|d| !d.is_empty())
.unwrap_or(&si.os);
if let Some(res) = &probe.resources {
let disk_gb = res.disk_available_mb / 1024;
format!("{} • {}GB free", os_info, disk_gb)
} else {
os_info.to_string()
}
});
let detected_agents: Vec<String> = probe
.detected_agents
.iter()
.map(|a| a.agent_type.clone())
.collect();
let hostname = probe.host_name.clone();
let username = probe
.system_info
.as_ref()
.and_then(|si| {
si.remote_home
.rsplit('/')
.find(|s| !s.is_empty())
.map(String::from)
})
.unwrap_or_else(|| "user".to_string());
HostDisplayInfo {
name: probe.host_name.clone(),
hostname,
username,
cass_status,
detected_agents,
reachable: probe.reachable,
error: probe.error.clone(),
state,
system_info,
}
}
pub fn run_host_selection(
probed_hosts: &[HostProbeResult],
already_configured: &HashSet<String>,
) -> Result<(HostSelectionResult, Vec<HostDisplayInfo>), InteractiveError> {
if !std::io::stdin().is_terminal() {
return Err(InteractiveError::NotATty);
}
let hosts: Vec<HostDisplayInfo> = probed_hosts
.iter()
.map(|p| probe_to_display_info(p, already_configured))
.collect();
let unreachable_count = hosts
.iter()
.filter(|h| h.state == HostState::Unreachable)
.count();
let configured_count = hosts
.iter()
.filter(|h| h.state == HostState::AlreadyConfigured)
.count();
if unreachable_count > 0 || configured_count > 0 {
println!();
if unreachable_count > 0 {
println!(
"{}",
format!(
" {} {} unreachable (check SSH config)",
"⚠".yellow(),
unreachable_count
)
.dimmed()
);
}
if configured_count > 0 {
println!(
"{}",
format!(" {} {} already configured", "═".cyan(), configured_count).dimmed()
);
}
}
let selector = HostSelector::new(hosts.clone());
let result = selector.prompt()?;
let install_count = result.needs_install.len();
let index_count = result.needs_indexing.len();
let sync_count = result.ready_for_sync.len();
let total = result.selected_indices.len();
if total > 0 {
println!();
let mut parts = Vec::new();
if sync_count > 0 {
parts.push(format!("{} ready to sync", sync_count));
}
if index_count > 0 {
parts.push(format!("{} needs indexing", index_count));
}
if install_count > 0 {
let est_mins = install_count * 3;
parts.push(format!(
"{} needs install (~{} min)",
install_count, est_mins
));
}
println!(
" {} selected: {}",
total.to_string().bold(),
parts.join(", ")
);
}
Ok((result, hosts))
}
#[derive(Debug)]
pub enum InteractiveError {
Cancelled,
NoHosts,
NoSelectableHosts,
NotATty,
IoError(String),
}
impl fmt::Display for InteractiveError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
InteractiveError::Cancelled => write!(f, "Operation cancelled by user"),
InteractiveError::NoHosts => write!(f, "No hosts available for selection"),
InteractiveError::NoSelectableHosts => {
write!(
f,
"No selectable hosts (all unreachable or already configured)"
)
}
InteractiveError::NotATty => {
write!(
f,
"Interactive selection requires a terminal.\n\n\
For non-interactive use:\n \
cass sources setup --hosts css,csd,yto\n \
cass sources setup --non-interactive # select all reachable"
)
}
InteractiveError::IoError(msg) => write!(f, "IO error: {}", msg),
}
}
}
impl std::error::Error for InteractiveError {}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_host_display_info_creation() {
let host = HostDisplayInfo {
name: "laptop".into(),
hostname: "192.168.1.100".into(),
username: "user".into(),
cass_status: CassStatusDisplay::Installed {
version: "0.1.50".into(),
sessions: 123,
},
detected_agents: vec!["claude".into(), "codex".into()],
reachable: true,
error: None,
state: HostState::ReadyToSync,
system_info: Some("ubuntu 22.04 • 45GB free".into()),
};
assert_eq!(host.name, "laptop");
assert!(host.reachable);
assert!(matches!(
host.cass_status,
CassStatusDisplay::Installed { .. }
));
assert_eq!(host.state, HostState::ReadyToSync);
}
#[test]
fn test_host_selector_format() {
let hosts = vec![HostDisplayInfo {
name: "test-host".into(),
hostname: "10.0.0.1".into(),
username: "testuser".into(),
cass_status: CassStatusDisplay::NotInstalled,
detected_agents: vec!["claude".into()],
reachable: true,
error: None,
state: HostState::NeedsInstall,
system_info: None,
}];
let selector = HostSelector::new(hosts);
let formatted = selector.format_host(&selector.hosts[0]);
assert!(formatted.contains("test-host"));
assert!(formatted.contains("10.0.0.1"));
assert!(formatted.contains("testuser"));
assert!(formatted.contains("cass not installed"));
assert!(formatted.contains("Claude"));
assert!(formatted.contains("Needs install"));
}
#[test]
fn test_host_selector_empty() {
let selector = HostSelector::new(vec![]);
assert!(selector.hosts.is_empty());
}
#[test]
fn test_cass_status_display_variants() {
let installed = CassStatusDisplay::Installed {
version: "0.1.50".into(),
sessions: 100,
};
let not_installed = CassStatusDisplay::NotInstalled;
let unknown = CassStatusDisplay::Unknown;
assert!(matches!(installed, CassStatusDisplay::Installed { .. }));
assert!(matches!(not_installed, CassStatusDisplay::NotInstalled));
assert!(matches!(unknown, CassStatusDisplay::Unknown));
}
#[test]
fn test_host_selection_result() {
let result = HostSelectionResult {
selected_indices: vec![0, 2, 3],
needs_install: vec![2],
needs_indexing: vec![],
ready_for_sync: vec![0, 3],
};
assert_eq!(result.selected_indices.len(), 3);
assert_eq!(result.needs_install.len(), 1);
assert_eq!(result.needs_indexing.len(), 0);
assert_eq!(result.ready_for_sync.len(), 2);
}
#[test]
fn test_interactive_error_display() {
let cancelled = InteractiveError::Cancelled;
let no_hosts = InteractiveError::NoHosts;
let io_error = InteractiveError::IoError("test error".into());
assert!(cancelled.to_string().contains("cancelled"));
assert!(no_hosts.to_string().contains("No hosts"));
assert!(io_error.to_string().contains("test error"));
}
#[test]
fn test_unreachable_host_format() {
let hosts = vec![HostDisplayInfo {
name: "unreachable-host".into(),
hostname: "10.0.0.99".into(),
username: "user".into(),
cass_status: CassStatusDisplay::Unknown,
detected_agents: vec![],
reachable: false,
error: Some("Connection timed out".into()),
state: HostState::Unreachable,
system_info: None,
}];
let selector = HostSelector::new(hosts);
let formatted = selector.format_host(&selector.hosts[0]);
assert!(formatted.contains("unreachable-host"));
assert!(formatted.contains("Connection timed out"));
assert!(formatted.contains("Unreachable"));
}
#[test]
fn test_host_state_properties() {
assert!(HostState::ReadyToSync.is_selectable());
assert!(HostState::NeedsIndexing.is_selectable());
assert!(HostState::NeedsInstall.is_selectable());
assert!(!HostState::Unreachable.is_selectable());
assert!(!HostState::AlreadyConfigured.is_selectable());
assert!(HostState::ReadyToSync.should_preselect());
assert!(HostState::NeedsIndexing.should_preselect());
assert!(!HostState::NeedsInstall.should_preselect());
assert!(!HostState::Unreachable.should_preselect());
assert!(!HostState::AlreadyConfigured.should_preselect());
}
#[test]
fn test_host_state_status_badges() {
let badge = HostState::ReadyToSync.status_badge();
assert!(badge.contains("Ready to sync"));
let badge = HostState::NeedsIndexing.status_badge();
assert!(badge.contains("Needs indexing"));
let badge = HostState::NeedsInstall.status_badge();
assert!(badge.contains("Needs install"));
let badge = HostState::Unreachable.status_badge();
assert!(badge.contains("Unreachable"));
let badge = HostState::AlreadyConfigured.status_badge();
assert!(badge.contains("Already setup"));
}
#[test]
fn test_probe_to_display_info() {
let probe = HostProbeResult {
host_name: "test-server".into(),
reachable: true,
connection_time_ms: 50,
cass_status: CassStatus::Indexed {
version: "0.1.50".into(),
session_count: 100,
last_indexed: None,
},
detected_agents: vec![],
system_info: None,
resources: None,
error: None,
};
let already_configured = HashSet::new();
let display = probe_to_display_info(&probe, &already_configured);
assert_eq!(display.name, "test-server");
assert_eq!(display.state, HostState::ReadyToSync);
assert!(matches!(
display.cass_status,
CassStatusDisplay::Installed { sessions: 100, .. }
));
}
#[test]
fn test_probe_to_display_info_already_configured() {
let probe = HostProbeResult {
host_name: "configured-host".into(),
reachable: true,
connection_time_ms: 50,
cass_status: CassStatus::Indexed {
version: "0.1.50".into(),
session_count: 100,
last_indexed: None,
},
detected_agents: vec![],
system_info: None,
resources: None,
error: None,
};
let mut already_configured = HashSet::new();
already_configured.insert("configured-host".into());
let display = probe_to_display_info(&probe, &already_configured);
assert_eq!(display.state, HostState::AlreadyConfigured);
}
#[test]
fn test_probe_to_display_info_already_configured_case_insensitive() {
let probe = HostProbeResult {
host_name: "Configured-Host".into(),
reachable: true,
connection_time_ms: 50,
cass_status: CassStatus::Indexed {
version: "0.1.50".into(),
session_count: 100,
last_indexed: None,
},
detected_agents: vec![],
system_info: None,
resources: None,
error: None,
};
let mut already_configured = HashSet::new();
already_configured.insert(super::super::config::source_name_key("configured-host"));
let display = probe_to_display_info(&probe, &already_configured);
assert_eq!(display.state, HostState::AlreadyConfigured);
}
#[test]
fn test_probe_to_display_info_reserved_local_ssh_alias_already_configured() {
let probe = HostProbeResult {
host_name: "local".into(),
reachable: true,
connection_time_ms: 50,
cass_status: CassStatus::Indexed {
version: "0.1.50".into(),
session_count: 100,
last_indexed: None,
},
detected_agents: vec![],
system_info: None,
resources: None,
error: None,
};
let mut already_configured = HashSet::new();
already_configured.insert(super::super::config::source_name_key("local-ssh"));
let display = probe_to_display_info(&probe, &already_configured);
assert_eq!(display.state, HostState::AlreadyConfigured);
}
#[test]
fn test_installed_not_indexed_status() {
let status = CassStatusDisplay::InstalledNotIndexed {
version: "0.1.50".into(),
};
assert!(matches!(
status,
CassStatusDisplay::InstalledNotIndexed { .. }
));
}
#[test]
fn test_probe_to_display_info_username_extraction() {
use super::super::probe::SystemInfo;
let probe = HostProbeResult {
host_name: "test".into(),
reachable: true,
connection_time_ms: 50,
cass_status: CassStatus::NotFound,
detected_agents: vec![],
system_info: Some(SystemInfo {
os: "Linux".into(),
arch: "x86_64".into(),
distro: None,
has_cargo: false,
has_cargo_binstall: false,
has_curl: false,
has_wget: false,
remote_home: "/home/ubuntu".into(),
machine_id: None,
}),
resources: None,
error: None,
};
let display = probe_to_display_info(&probe, &HashSet::new());
assert_eq!(display.username, "ubuntu");
let probe_root = HostProbeResult {
host_name: "test".into(),
reachable: true,
connection_time_ms: 50,
cass_status: CassStatus::NotFound,
detected_agents: vec![],
system_info: Some(SystemInfo {
os: "Linux".into(),
arch: "x86_64".into(),
distro: None,
has_cargo: false,
has_cargo_binstall: false,
has_curl: false,
has_wget: false,
remote_home: "/".into(),
machine_id: None,
}),
resources: None,
error: None,
};
let display_root = probe_to_display_info(&probe_root, &HashSet::new());
assert_eq!(display_root.username, "user");
let probe_empty = HostProbeResult {
host_name: "test".into(),
reachable: true,
connection_time_ms: 50,
cass_status: CassStatus::NotFound,
detected_agents: vec![],
system_info: Some(SystemInfo {
os: "Linux".into(),
arch: "x86_64".into(),
distro: None,
has_cargo: false,
has_cargo_binstall: false,
has_curl: false,
has_wget: false,
remote_home: "".into(),
machine_id: None,
}),
resources: None,
error: None,
};
let display_empty = probe_to_display_info(&probe_empty, &HashSet::new());
assert_eq!(display_empty.username, "user");
}
#[test]
fn test_probe_to_display_info_empty_distro_fallback() {
use super::super::probe::SystemInfo;
let probe = HostProbeResult {
host_name: "test".into(),
reachable: true,
connection_time_ms: 50,
cass_status: CassStatus::NotFound,
detected_agents: vec![],
system_info: Some(SystemInfo {
os: "Linux".into(),
arch: "x86_64".into(),
distro: Some("".into()), has_cargo: false,
has_cargo_binstall: false,
has_curl: false,
has_wget: false,
remote_home: "/home/user".into(),
machine_id: None,
}),
resources: None,
error: None,
};
let display = probe_to_display_info(&probe, &HashSet::new());
assert!(display.system_info.as_ref().unwrap().contains("Linux"));
}
}