Skip to main content

coding_agent_search/sources/
config.rs

1//! Configuration types for remote sources.
2//!
3//! This module defines the data structures for configuring remote sources
4//! that cass can sync agent sessions from. Configuration is stored in TOML
5//! format at `~/.config/cass/sources.toml` (or XDG equivalent).
6//!
7//! # Example Configuration
8//!
9//! ```toml
10//! [[sources]]
11//! name = "laptop"
12//! type = "ssh"
13//! host = "user@laptop.local"
14//! paths = ["~/.claude/projects", "~/.cursor"]
15//! sync_schedule = "manual"
16//!
17//! [[sources]]
18//! name = "workstation"
19//! type = "ssh"
20//! host = "user@work.example.com"
21//! paths = ["~/.claude/projects"]
22//! sync_schedule = "daily"
23//!
24//! # Path mappings rewrite remote paths to local equivalents
25//! [[sources.path_mappings]]
26//! from = "/home/user/projects"
27//! to = "/Users/me/projects"
28//!
29//! # Agent-specific mappings only apply when viewing specific agent sessions
30//! [[sources.path_mappings]]
31//! from = "/opt/work"
32//! to = "/Volumes/Work"
33//! agents = ["claude-code"]
34//!
35//! # Disable noisy connectors globally, including the built-in local source.
36//! disabled_agents = ["openclaw"]
37//! ```
38
39use serde::{Deserialize, Serialize};
40use std::path::{Component, Path, PathBuf};
41use thiserror::Error;
42
43use super::provenance::SourceKind;
44
45// Re-export types from franken_agent_detection.
46pub use franken_agent_detection::{PathMapping, Platform};
47
48const BUILT_IN_LOCAL_SOURCE_NAME: &str = "local";
49const RESERVED_REMOTE_SOURCE_SUFFIX: &str = "-ssh";
50
51pub(crate) fn source_name_key(name: &str) -> String {
52    name.trim().to_ascii_lowercase()
53}
54
55pub(crate) fn source_names_equal(lhs: &str, rhs: &str) -> bool {
56    source_name_key(lhs) == source_name_key(rhs)
57}
58
59pub(crate) fn agent_name_key(name: &str) -> String {
60    name.trim().to_ascii_lowercase().replace('-', "_")
61}
62
63fn normalize_agent_config_name(name: &str) -> Option<String> {
64    let normalized = match agent_name_key(name).as_str() {
65        "claude_code" => "claude".to_string(),
66        "open_claw" => "openclaw".to_string(),
67        other => other.to_string(),
68    };
69    (!normalized.is_empty()).then_some(normalized)
70}
71
72fn agent_config_names_equal(lhs: &str, rhs: &str) -> bool {
73    match (
74        normalize_agent_config_name(lhs),
75        normalize_agent_config_name(rhs),
76    ) {
77        (Some(lhs), Some(rhs)) => lhs == rhs,
78        _ => false,
79    }
80}
81
82fn path_mapping_applies_to_agent(mapping: &PathMapping, agent: Option<&str>) -> bool {
83    match (
84        mapping.agents.as_ref(),
85        agent.and_then(|value| {
86            let trimmed = value.trim();
87            (!trimmed.is_empty()).then_some(trimmed)
88        }),
89    ) {
90        (Some(agents), _) if agents.is_empty() => false,
91        (None, _) | (Some(_), None) => true,
92        (Some(agents), Some(actual)) => agents
93            .iter()
94            .any(|allowed| agent_config_names_equal(allowed, actual)),
95    }
96}
97
98/// Errors that can occur when loading or saving source configuration.
99#[derive(Error, Debug)]
100pub enum ConfigError {
101    #[error("Failed to read config file: {0}")]
102    Read(#[from] std::io::Error),
103
104    #[error("Failed to parse config file: {0}")]
105    Parse(#[from] toml::de::Error),
106
107    #[error("Failed to serialize config: {0}")]
108    Serialize(#[from] toml::ser::Error),
109
110    #[error("Could not determine config directory")]
111    NoConfigDir,
112
113    #[error("Validation error: {0}")]
114    Validation(String),
115}
116
117/// Root configuration containing all source definitions.
118#[derive(Debug, Clone, Serialize, Deserialize, Default)]
119pub struct SourcesConfig {
120    /// List of configured sources.
121    #[serde(default)]
122    pub sources: Vec<SourceDefinition>,
123
124    /// Connectors to skip during indexing even if their files exist locally or
125    /// in configured remote mirrors.
126    #[serde(default, skip_serializing_if = "Vec::is_empty")]
127    pub disabled_agents: Vec<String>,
128}
129
130/// Definition of a single source (local or remote).
131#[derive(Debug, Clone, Default, Serialize, Deserialize)]
132pub struct SourceDefinition {
133    /// Friendly name for this source (e.g., "laptop", "workstation").
134    /// This becomes the `source_id` used throughout the system.
135    pub name: String,
136
137    /// Connection type (local, ssh, etc.).
138    #[serde(rename = "type", default)]
139    pub source_type: SourceKind,
140
141    /// Remote host for SSH connections (e.g., "user@laptop.local").
142    #[serde(default)]
143    pub host: Option<String>,
144
145    /// Paths to sync from this source.
146    /// For SSH sources, these are remote paths.
147    /// Supports ~ expansion.
148    #[serde(default)]
149    pub paths: Vec<String>,
150
151    /// When to automatically sync this source.
152    #[serde(default)]
153    pub sync_schedule: SyncSchedule,
154
155    /// Path mappings for workspace rewriting.
156    /// Maps remote paths to local equivalents.
157    /// Example: "/home/user/projects" -> "/Users/me/projects"
158    #[serde(default)]
159    pub path_mappings: Vec<PathMapping>,
160
161    /// Platform hint for default paths (macos, linux).
162    #[serde(default)]
163    pub platform: Option<Platform>,
164}
165
166impl SourceDefinition {
167    /// Create a new local source definition.
168    pub fn local(name: impl Into<String>) -> Self {
169        Self {
170            name: name.into(),
171            source_type: SourceKind::Local,
172            ..Default::default()
173        }
174    }
175
176    /// Create a new SSH source definition.
177    pub fn ssh(name: impl Into<String>, host: impl Into<String>) -> Self {
178        Self {
179            name: name.into(),
180            source_type: SourceKind::Ssh,
181            host: Some(host.into()),
182            ..Default::default()
183        }
184    }
185
186    /// Check if this source requires SSH connectivity.
187    pub fn is_remote(&self) -> bool {
188        matches!(self.source_type, SourceKind::Ssh)
189    }
190
191    /// Validate the source definition.
192    pub fn validate(&self) -> Result<(), ConfigError> {
193        self.validate_structure()?;
194        self.validate_paths()
195    }
196
197    pub(crate) fn validate_name(&self) -> Result<(), ConfigError> {
198        validate_source_name(&self.name)
199    }
200
201    pub(crate) fn validate_structure(&self) -> Result<(), ConfigError> {
202        self.validate_name()?;
203
204        if self.is_remote() && self.host.is_none() {
205            return Err(ConfigError::Validation("SSH sources require a host".into()));
206        }
207
208        if self.is_remote()
209            && let Some(host) = self.host.as_deref()
210        {
211            validate_ssh_host(host)?;
212        }
213
214        for (idx, mapping) in self.path_mappings.iter().enumerate() {
215            if mapping.from.trim().is_empty() {
216                return Err(ConfigError::Validation(format!(
217                    "path_mappings[{idx}].from cannot be empty"
218                )));
219            }
220
221            if mapping.to.trim().is_empty() {
222                return Err(ConfigError::Validation(format!(
223                    "path_mappings[{idx}].to cannot be empty"
224                )));
225            }
226
227            if let Some(agents) = mapping.agents.as_ref() {
228                if agents.is_empty() {
229                    return Err(ConfigError::Validation(format!(
230                        "path_mappings[{idx}].agents cannot be empty"
231                    )));
232                }
233
234                if agents.iter().any(|agent| agent.trim().is_empty()) {
235                    return Err(ConfigError::Validation(format!(
236                        "path_mappings[{idx}].agents cannot contain empty agent names"
237                    )));
238                }
239            }
240        }
241
242        Ok(())
243    }
244
245    fn validate_paths(&self) -> Result<(), ConfigError> {
246        for (idx, path) in self.paths.iter().enumerate() {
247            validate_source_path_entry(idx, path)?;
248        }
249
250        Ok(())
251    }
252
253    /// Apply path mapping to rewrite a workspace path.
254    ///
255    /// Uses longest-prefix matching. If an agent is specified,
256    /// only mappings that apply to that agent are considered.
257    pub fn rewrite_path(&self, path: &str) -> String {
258        self.rewrite_path_for_agent(path, None)
259    }
260
261    /// Apply path mapping for a specific agent.
262    ///
263    /// Uses longest-prefix matching, filtering by agent.
264    pub fn rewrite_path_for_agent(&self, path: &str, agent: Option<&str>) -> String {
265        // Sort by prefix length descending for longest-prefix match
266        let mut mappings: Vec<_> = self
267            .path_mappings
268            .iter()
269            .filter(|m| path_mapping_applies_to_agent(m, agent))
270            .collect();
271        mappings.sort_by_key(|m| std::cmp::Reverse(m.from.len()));
272
273        for mapping in mappings {
274            if let Some(rewritten) = mapping.apply(path) {
275                return rewritten;
276            }
277        }
278
279        path.to_string()
280    }
281}
282
283/// Adjust an auto-generated remote source name to avoid reserved built-in IDs.
284pub(crate) fn normalize_generated_remote_source_name(name: &str) -> String {
285    let name = name.trim();
286    if source_names_equal(name, BUILT_IN_LOCAL_SOURCE_NAME) {
287        format!("{name}{RESERVED_REMOTE_SOURCE_SUFFIX}")
288    } else {
289        name.to_string()
290    }
291}
292
293fn has_dot_components(path: &Path) -> bool {
294    path.components()
295        .any(|c| matches!(c, Component::CurDir | Component::ParentDir))
296}
297
298fn validate_source_name(name: &str) -> Result<(), ConfigError> {
299    if name.trim().is_empty() {
300        return Err(ConfigError::Validation(
301            "Source name cannot be empty".into(),
302        ));
303    }
304
305    if name.trim() != name {
306        return Err(ConfigError::Validation(
307            "Source name cannot have leading or trailing whitespace".into(),
308        ));
309    }
310
311    if source_names_equal(name, BUILT_IN_LOCAL_SOURCE_NAME) {
312        return Err(ConfigError::Validation(format!(
313            "Source name '{}' is reserved for the built-in local source",
314            BUILT_IN_LOCAL_SOURCE_NAME
315        )));
316    }
317
318    if name.contains('/') || name.contains('\\') {
319        return Err(ConfigError::Validation(
320            "Source name cannot contain path separators".into(),
321        ));
322    }
323
324    if has_dot_components(Path::new(name)) {
325        return Err(ConfigError::Validation(
326            "Source name cannot be '.' or '..'".into(),
327        ));
328    }
329
330    Ok(())
331}
332
333fn validate_ssh_host(host: &str) -> Result<(), ConfigError> {
334    let trimmed = host.trim();
335
336    if trimmed.is_empty() {
337        return Err(ConfigError::Validation("SSH host cannot be empty".into()));
338    }
339
340    if trimmed != host {
341        return Err(ConfigError::Validation(
342            "SSH host cannot have leading or trailing whitespace".into(),
343        ));
344    }
345
346    let host = trimmed;
347
348    if host.starts_with('-') {
349        return Err(ConfigError::Validation(
350            "SSH host cannot start with '-' (would be parsed as an ssh option)".into(),
351        ));
352    }
353
354    if host.chars().any(|c| c.is_whitespace() || c.is_control()) {
355        return Err(ConfigError::Validation(
356            "SSH host cannot contain whitespace or control characters".into(),
357        ));
358    }
359
360    if !ssh_host_has_safe_token_chars(host) {
361        return Err(ConfigError::Validation(
362            "SSH host may only contain ASCII letters, digits, '.', '-', '_', and '@'".into(),
363        ));
364    }
365
366    validate_optional_user_host_shape(host).map_err(ConfigError::Validation)?;
367
368    Ok(())
369}
370
371pub(crate) fn source_path_entry_error(index: usize, path: &str) -> Option<String> {
372    if path.trim().is_empty() {
373        return Some(format!("paths[{index}] cannot be empty"));
374    }
375
376    if path.trim() != path {
377        return Some(format!(
378            "paths[{index}] cannot have leading or trailing whitespace"
379        ));
380    }
381
382    if path.chars().any(char::is_control) {
383        return Some(format!("paths[{index}] cannot contain control characters"));
384    }
385
386    None
387}
388
389fn validate_source_path_entry(index: usize, path: &str) -> Result<(), ConfigError> {
390    match source_path_entry_error(index, path) {
391        Some(message) => Err(ConfigError::Validation(message)),
392        None => Ok(()),
393    }
394}
395
396pub(crate) fn ssh_host_has_safe_token_chars(host: &str) -> bool {
397    host.chars()
398        .all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '@'))
399}
400
401pub(crate) fn validate_optional_user_host_shape(host: &str) -> Result<(), String> {
402    match host.split_once('@') {
403        Some((user, hostname)) if user.is_empty() || hostname.is_empty() => {
404            Err("SSH host must not have an empty user or hostname around '@'".into())
405        }
406        Some((_, hostname)) if hostname.contains('@') => {
407            Err("SSH host must contain at most one '@' separator".into())
408        }
409        _ => Ok(()),
410    }
411}
412
413/// Sync schedule for remote sources.
414#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
415#[serde(rename_all = "lowercase")]
416pub enum SyncSchedule {
417    /// Only sync when explicitly requested.
418    #[default]
419    Manual,
420    /// Sync every hour.
421    Hourly,
422    /// Sync once per day.
423    Daily,
424}
425
426const SYNC_SCHEDULE_MANUAL: &str = "manual";
427const SYNC_SCHEDULE_HOURLY: &str = "hourly";
428const SYNC_SCHEDULE_DAILY: &str = "daily";
429
430impl std::fmt::Display for SyncSchedule {
431    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
432        f.write_str(match self {
433            Self::Manual => SYNC_SCHEDULE_MANUAL,
434            Self::Hourly => SYNC_SCHEDULE_HOURLY,
435            Self::Daily => SYNC_SCHEDULE_DAILY,
436        })
437    }
438}
439
440impl SourcesConfig {
441    /// Load configuration from the default location.
442    ///
443    /// Returns an empty config if the file doesn't exist.
444    pub fn load() -> Result<Self, ConfigError> {
445        let config_path = Self::config_path()?;
446
447        if !config_path.exists() {
448            return Ok(Self::default());
449        }
450
451        let content = std::fs::read_to_string(&config_path)?;
452        let config: Self = toml::from_str(&content)?;
453
454        config.validate_for_load()?;
455
456        Ok(config)
457    }
458
459    /// Load configuration from a specific path.
460    pub fn load_from(path: &PathBuf) -> Result<Self, ConfigError> {
461        if !path.exists() {
462            return Ok(Self::default());
463        }
464
465        let content = std::fs::read_to_string(path)?;
466        let config: Self = toml::from_str(&content)?;
467        config.validate_for_load()?;
468
469        Ok(config)
470    }
471
472    /// Save configuration to the default location.
473    pub fn save(&self) -> Result<(), ConfigError> {
474        let config_path = Self::config_path()?;
475
476        // Create parent directories if needed
477        if let Some(parent) = config_path.parent() {
478            std::fs::create_dir_all(parent)?;
479        }
480
481        self.validate()?;
482        let content = toml::to_string_pretty(self)?;
483        let _: SourcesConfig = toml::from_str(&content)?;
484        let temp_path = unique_atomic_temp_path(&config_path);
485        std::fs::write(&temp_path, content)?;
486        sync_file_path(&temp_path)?;
487        replace_file_from_temp(&temp_path, &config_path)?;
488
489        Ok(())
490    }
491
492    /// Save configuration to a specific path.
493    pub fn save_to(&self, path: &Path) -> Result<(), ConfigError> {
494        if let Some(parent) = path.parent() {
495            std::fs::create_dir_all(parent)?;
496        }
497
498        self.validate()?;
499        let content = toml::to_string_pretty(self)?;
500        let _: SourcesConfig = toml::from_str(&content)?;
501        let temp_path = unique_atomic_temp_path(path);
502        std::fs::write(&temp_path, content)?;
503        sync_file_path(&temp_path)?;
504        replace_file_from_temp(&temp_path, path)?;
505
506        Ok(())
507    }
508
509    /// Get the default configuration file path.
510    ///
511    /// Uses XDG conventions:
512    /// - Primary: `$XDG_CONFIG_HOME/cass/sources.toml`
513    /// - Fallback: platform-specific config dir (e.g., `~/.config/cass/sources.toml` on Linux)
514    pub fn config_path() -> Result<PathBuf, ConfigError> {
515        config_path_from_parts(
516            dotenvy::var("XDG_CONFIG_HOME").ok().map(PathBuf::from),
517            dirs::config_dir(),
518            dirs::home_dir(),
519        )
520    }
521
522    /// Validate all sources in the configuration.
523    pub fn validate(&self) -> Result<(), ConfigError> {
524        self.validate_with_path_entries(true)
525    }
526
527    fn validate_for_load(&self) -> Result<(), ConfigError> {
528        self.validate_with_path_entries(false)
529    }
530
531    fn validate_with_path_entries(&self, validate_paths: bool) -> Result<(), ConfigError> {
532        // Check for duplicate names
533        let mut seen_names = std::collections::HashSet::new();
534        for source in &self.sources {
535            if validate_paths {
536                source.validate()?;
537            } else {
538                source.validate_structure()?;
539            }
540
541            if !seen_names.insert(source_name_key(&source.name)) {
542                return Err(ConfigError::Validation(format!(
543                    "Duplicate source name: {}",
544                    source.name
545                )));
546            }
547        }
548
549        for (idx, agent) in self.disabled_agents.iter().enumerate() {
550            if normalize_agent_config_name(agent).is_none() {
551                return Err(ConfigError::Validation(format!(
552                    "disabled_agents[{idx}] cannot be empty"
553                )));
554            }
555        }
556
557        Ok(())
558    }
559
560    /// Find a source by name.
561    pub fn find_source(&self, name: &str) -> Option<&SourceDefinition> {
562        self.sources
563            .iter()
564            .find(|s| source_names_equal(&s.name, name))
565    }
566
567    /// Find a source by name (mutable).
568    pub fn find_source_mut(&mut self, name: &str) -> Option<&mut SourceDefinition> {
569        self.sources
570            .iter_mut()
571            .find(|s| source_names_equal(&s.name, name))
572    }
573
574    /// Add a new source. Returns error if name already exists.
575    pub fn add_source(&mut self, source: SourceDefinition) -> Result<(), ConfigError> {
576        source.validate()?;
577
578        if self
579            .sources
580            .iter()
581            .any(|s| source_names_equal(&s.name, &source.name))
582        {
583            return Err(ConfigError::Validation(format!(
584                "Source '{}' already exists",
585                source.name
586            )));
587        }
588
589        self.sources.push(source);
590        Ok(())
591    }
592
593    /// Remove a source by name. Returns true if found and removed.
594    pub fn remove_source(&mut self, name: &str) -> bool {
595        let initial_len = self.sources.len();
596        self.sources.retain(|s| !source_names_equal(&s.name, name));
597        self.sources.len() < initial_len
598    }
599
600    /// Get all remote sources (SSH type).
601    pub fn remote_sources(&self) -> impl Iterator<Item = &SourceDefinition> {
602        self.sources.iter().filter(|s| s.is_remote())
603    }
604
605    pub fn configured_disabled_agents(&self) -> Vec<String> {
606        let mut disabled = self
607            .disabled_agents
608            .iter()
609            .filter_map(|agent| normalize_agent_config_name(agent))
610            .collect::<Vec<_>>();
611        disabled.sort();
612        disabled.dedup();
613        disabled
614    }
615
616    pub fn is_agent_disabled(&self, agent: &str) -> bool {
617        let Some(normalized) = normalize_agent_config_name(agent) else {
618            return false;
619        };
620        self.disabled_agents
621            .iter()
622            .filter_map(|candidate| normalize_agent_config_name(candidate))
623            .any(|candidate| candidate == normalized)
624    }
625
626    pub fn exclude_agent_from_indexing(&mut self, agent: &str) -> Result<bool, ConfigError> {
627        let normalized = normalize_agent_config_name(agent)
628            .ok_or_else(|| ConfigError::Validation("agent name cannot be empty".into()))?;
629        if self.is_agent_disabled(&normalized) {
630            return Ok(false);
631        }
632        self.disabled_agents.push(normalized);
633        Ok(true)
634    }
635
636    pub fn include_agent_in_indexing(&mut self, agent: &str) -> Result<bool, ConfigError> {
637        let normalized = normalize_agent_config_name(agent)
638            .ok_or_else(|| ConfigError::Validation("agent name cannot be empty".into()))?;
639        let initial_len = self.disabled_agents.len();
640        self.disabled_agents.retain(|existing| {
641            normalize_agent_config_name(existing).as_deref() != Some(&normalized)
642        });
643        Ok(self.disabled_agents.len() != initial_len)
644    }
645}
646
647fn config_path_from_parts(
648    xdg_config_home: Option<PathBuf>,
649    platform_config_dir: Option<PathBuf>,
650    home_dir: Option<PathBuf>,
651) -> Result<PathBuf, ConfigError> {
652    // Respect XDG_CONFIG_HOME first (important for testing and Linux users).
653    if let Some(xdg_config) = xdg_config_home {
654        return Ok(xdg_config.join("cass").join("sources.toml"));
655    }
656
657    // Check the platform-specific config dir (e.g. ~/Library/Application Support/ on macOS).
658    let platform_path = platform_config_dir.map(|p| p.join("cass").join("sources.toml"));
659    if let Some(ref path) = platform_path
660        && path.exists()
661    {
662        return Ok(path.clone());
663    }
664
665    // Fallback: check ~/.config/cass/sources.toml for users who follow XDG
666    // conventions without setting XDG_CONFIG_HOME.
667    if let Some(home) = home_dir {
668        let dot_config_path = home.join(".config").join("cass").join("sources.toml");
669        if dot_config_path.exists() {
670            return Ok(dot_config_path);
671        }
672    }
673
674    // Neither exists: return the platform path for creation (original behavior).
675    platform_path.ok_or(ConfigError::NoConfigDir)
676}
677
678/// Get preset paths for a given platform.
679///
680/// These are the default agent session directories for each platform.
681pub fn get_preset_paths(preset: &str) -> Result<Vec<String>, ConfigError> {
682    match preset {
683        "macos-defaults" | "macos" => Ok(vec![
684            "~/.claude/projects".into(),
685            "~/.codex/sessions".into(),
686            "~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev".into(),
687            "~/Library/Application Support/Code/User/globalStorage/rooveterinaryinc.roo-cline"
688                .into(),
689            "~/Library/Application Support/Cursor/User/globalStorage/saoudrizwan.claude-dev".into(),
690            "~/Library/Application Support/Cursor/User/globalStorage/rooveterinaryinc.roo-cline"
691                .into(),
692            "~/Library/Application Support/com.openai.chat".into(),
693            "~/.gemini/tmp".into(),
694            "~/.pi/agent/sessions".into(),
695            "~/Library/Application Support/opencode/storage".into(),
696            "~/.continue/sessions".into(),
697            "~/.aider.chat.history.md".into(),
698            "~/.goose/sessions".into(),
699        ]),
700        "linux-defaults" | "linux" => Ok(vec![
701            "~/.claude/projects".into(),
702            "~/.codex/sessions".into(),
703            "~/.config/Code/User/globalStorage/saoudrizwan.claude-dev".into(),
704            "~/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline".into(),
705            "~/.config/Cursor/User/globalStorage/saoudrizwan.claude-dev".into(),
706            "~/.config/Cursor/User/globalStorage/rooveterinaryinc.roo-cline".into(),
707            "~/.gemini/tmp".into(),
708            "~/.pi/agent/sessions".into(),
709            "~/.local/share/opencode/storage".into(),
710            "~/.continue/sessions".into(),
711            "~/.aider.chat.history.md".into(),
712            "~/.goose/sessions".into(),
713        ]),
714        _ => Err(ConfigError::Validation(format!(
715            "Unknown preset: '{}'. Valid presets: macos-defaults, linux-defaults",
716            preset
717        ))),
718    }
719}
720
721// =============================================================================
722// SSH Config Discovery
723// =============================================================================
724
725/// Discovered SSH host from ~/.ssh/config
726#[derive(Debug, Clone)]
727pub struct DiscoveredHost {
728    /// Host alias from SSH config
729    pub name: String,
730    /// Hostname or IP address
731    pub hostname: Option<String>,
732    /// Username
733    pub user: Option<String>,
734    /// Port (defaults to 22)
735    pub port: Option<u16>,
736    /// Identity file path
737    pub identity_file: Option<String>,
738}
739
740impl DiscoveredHost {
741    /// Get the SSH connection string (user@host or just host)
742    pub fn connection_string(&self) -> String {
743        if let Some(user) = &self.user {
744            format!("{}@{}", user, self.name)
745        } else {
746            self.name.clone()
747        }
748    }
749}
750
751/// Discover SSH hosts from ~/.ssh/config.
752///
753/// Parses the SSH config file and returns a list of discovered hosts
754/// that could be used as remote sources.
755pub fn discover_ssh_hosts() -> Vec<DiscoveredHost> {
756    let ssh_config_path = dirs::home_dir()
757        .map(|h| h.join(".ssh").join("config"))
758        .unwrap_or_default();
759
760    if !ssh_config_path.exists() {
761        return Vec::new();
762    }
763
764    let content = match std::fs::read_to_string(&ssh_config_path) {
765        Ok(c) => c,
766        Err(_) => return Vec::new(),
767    };
768
769    parse_ssh_config(&content)
770}
771
772/// Parse SSH config file content into discovered hosts.
773fn parse_ssh_config(content: &str) -> Vec<DiscoveredHost> {
774    let mut hosts = Vec::new();
775    let mut current_hosts: Vec<DiscoveredHost> = Vec::new();
776
777    for line in content.lines() {
778        let line = line.trim();
779
780        // Skip comments and empty lines
781        if line.is_empty() || line.starts_with('#') {
782            continue;
783        }
784
785        // Parse key-value pairs
786        let (key, value) = if let Some(idx) = line.find(|c: char| c.is_whitespace() || c == '=') {
787            let k = &line[..idx];
788            let v = line[idx..].trim_start_matches(|c: char| c.is_whitespace() || c == '=');
789            (k.to_lowercase(), v)
790        } else {
791            continue;
792        };
793
794        match key.as_str() {
795            "host" => {
796                hosts.append(&mut current_hosts);
797                current_hosts = value
798                    .split_whitespace()
799                    .filter(|name| {
800                        !name.starts_with('!') && !name.contains('*') && !name.contains('?')
801                    })
802                    .map(|name| DiscoveredHost {
803                        name: name.to_string(),
804                        hostname: None,
805                        user: None,
806                        port: None,
807                        identity_file: None,
808                    })
809                    .collect();
810            }
811            "hostname" => {
812                for host in &mut current_hosts {
813                    host.hostname = Some(value.to_string());
814                }
815            }
816            "user" => {
817                for host in &mut current_hosts {
818                    host.user = Some(value.to_string());
819                }
820            }
821            "port" => {
822                for host in &mut current_hosts {
823                    host.port = value.parse().ok();
824                }
825            }
826            "identityfile" => {
827                for host in &mut current_hosts {
828                    host.identity_file = Some(value.to_string());
829                }
830            }
831            _ => {}
832        }
833    }
834
835    // Don't forget the last host block.
836    hosts.append(&mut current_hosts);
837
838    hosts
839}
840
841// =============================================================================
842// Source Configuration Generator
843// =============================================================================
844
845use std::collections::HashSet;
846
847use colored::Colorize;
848
849use super::probe::HostProbeResult;
850
851/// Result of merging a source into existing configuration.
852#[derive(Debug, Clone)]
853pub enum MergeResult {
854    /// Source was added successfully.
855    Added(SourceDefinition),
856    /// Source already exists with this name.
857    AlreadyExists(String),
858}
859
860/// Reason why a source was skipped during config generation.
861#[derive(Debug, Clone)]
862pub enum SkipReason {
863    /// Already configured in sources.toml.
864    AlreadyConfigured,
865    /// Another selected host generates the same source name.
866    GeneratedNameConflict(String),
867    /// Generated source definition failed validation.
868    InvalidSourceDefinition(String),
869    /// Probe failed (unreachable, timeout, etc.).
870    ProbeFailure(String),
871    /// User deselected this host.
872    UserDeselected,
873}
874
875/// Information about a backup created before config modification.
876#[derive(Debug, Clone)]
877pub struct BackupInfo {
878    /// Path to the backup file (None if no existing config).
879    pub backup_path: Option<PathBuf>,
880    /// Path to the config file.
881    pub config_path: PathBuf,
882}
883
884/// Preview of configuration changes before writing.
885#[derive(Debug, Clone)]
886pub struct ConfigPreview {
887    /// Sources that will be added.
888    pub sources_to_add: Vec<SourceDefinition>,
889    /// Sources that were skipped with reasons.
890    pub sources_skipped: Vec<(String, SkipReason)>,
891}
892
893impl ConfigPreview {
894    /// Create a new empty preview.
895    pub fn new() -> Self {
896        Self {
897            sources_to_add: Vec::new(),
898            sources_skipped: Vec::new(),
899        }
900    }
901
902    /// Display the preview to the user.
903    pub fn display(&self) {
904        println!();
905        println!("{}", "Configuration Preview".bold().underline());
906
907        if self.sources_to_add.is_empty() {
908            println!("  {}", "No new sources to add.".dimmed());
909        } else {
910            println!("  The following will be added to sources.toml:\n");
911
912            for source in &self.sources_to_add {
913                println!("  {}:", source.name.cyan());
914                println!("    {}:", "Paths".dimmed());
915                for path in &source.paths {
916                    println!("      {}", path);
917                }
918                if !source.path_mappings.is_empty() {
919                    println!("    {}:", "Mappings".dimmed());
920                    for mapping in &source.path_mappings {
921                        println!("      {} → {}", mapping.from, mapping.to);
922                    }
923                }
924                println!();
925            }
926        }
927
928        if !self.sources_skipped.is_empty() {
929            println!("  {}:", "Skipped".dimmed());
930            for (name, reason) in &self.sources_skipped {
931                let reason_str = match reason {
932                    SkipReason::AlreadyConfigured => "already configured",
933                    SkipReason::GeneratedNameConflict(source_name) => {
934                        println!(
935                            "    {} - {}",
936                            name.dimmed(),
937                            format!("conflicts with generated source name '{source_name}'")
938                                .dimmed()
939                        );
940                        continue;
941                    }
942                    SkipReason::InvalidSourceDefinition(e) => e.as_str(),
943                    SkipReason::ProbeFailure(e) => e.as_str(),
944                    SkipReason::UserDeselected => "not selected",
945                };
946                println!("    {} - {}", name.dimmed(), reason_str.dimmed());
947            }
948        }
949    }
950
951    /// Check if there are any sources to add.
952    pub fn has_changes(&self) -> bool {
953        !self.sources_to_add.is_empty()
954    }
955
956    /// Get the count of sources to add.
957    pub fn add_count(&self) -> usize {
958        self.sources_to_add.len()
959    }
960}
961
962impl Default for ConfigPreview {
963    fn default() -> Self {
964        Self::new()
965    }
966}
967
968/// Generator for creating source configurations from probe results.
969///
970/// Takes probe results and generates appropriate `SourceDefinition` objects
971/// with intelligent path and mapping defaults.
972pub struct SourceConfigGenerator {
973    /// Local home directory for mapping generation.
974    local_home: PathBuf,
975}
976
977impl SourceConfigGenerator {
978    /// Create a new config generator.
979    pub fn new() -> Self {
980        Self {
981            local_home: dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")),
982        }
983    }
984
985    /// Generate a complete SourceDefinition from a probe result.
986    ///
987    /// # Arguments
988    /// * `host_name` - The SSH config host alias
989    /// * `probe` - The probe result containing system and agent info
990    pub fn generate_source(&self, host_name: &str, probe: &HostProbeResult) -> SourceDefinition {
991        let paths = self.generate_paths(probe);
992        let path_mappings = self.generate_mappings(probe);
993        let platform = self.detect_platform(probe);
994        let name = normalize_generated_remote_source_name(host_name);
995
996        SourceDefinition {
997            name,
998            source_type: SourceKind::Ssh,
999            host: Some(host_name.to_string()), // Use SSH alias
1000            paths,
1001            sync_schedule: SyncSchedule::Manual,
1002            path_mappings,
1003            platform,
1004        }
1005    }
1006
1007    /// Generate paths based on detected agent data.
1008    ///
1009    /// Only includes paths where agent data was actually detected,
1010    /// rather than guessing all possible paths.
1011    fn generate_paths(&self, probe: &HostProbeResult) -> Vec<String> {
1012        let mut paths = Vec::new();
1013
1014        for agent in &probe.detected_agents {
1015            // Use the detected path directly
1016            paths.push(agent.path.clone());
1017        }
1018
1019        // Deduplicate while preserving order
1020        let mut seen = HashSet::new();
1021        paths.retain(|p| seen.insert(p.clone()));
1022
1023        paths
1024    }
1025
1026    /// Generate path mappings for workspace rewriting.
1027    ///
1028    /// Creates mappings from remote paths to local equivalents:
1029    /// - Remote home/projects → Local home/projects
1030    /// - /data/projects → Local home/projects (common server pattern)
1031    fn generate_mappings(&self, probe: &HostProbeResult) -> Vec<PathMapping> {
1032        let mut mappings = Vec::new();
1033
1034        // Get remote home from system info
1035        if let Some(ref sys_info) = probe.system_info {
1036            // Normalize remote_home by trimming trailing slashes to avoid double slashes
1037            let remote_home = sys_info.remote_home.trim_end_matches('/');
1038
1039            // Don't create mappings if remote_home is empty or root
1040            if !remote_home.is_empty() && remote_home != "/" {
1041                // Map remote home/projects to local home/projects
1042                let remote_projects = format!("{}/projects", remote_home);
1043                let local_projects = self.local_home.join("projects");
1044
1045                mappings.push(PathMapping::new(
1046                    remote_projects,
1047                    local_projects.to_string_lossy().to_string(),
1048                ));
1049
1050                // Also map remote home directly (more general fallback)
1051                mappings.push(PathMapping::new(
1052                    remote_home,
1053                    self.local_home.to_string_lossy().to_string(),
1054                ));
1055            }
1056        }
1057
1058        // Check for /data/projects pattern (common on servers)
1059        let has_data_projects = probe
1060            .detected_agents
1061            .iter()
1062            .any(|a| a.path.starts_with("/data/"));
1063
1064        if has_data_projects {
1065            let local_projects = self.local_home.join("projects");
1066            mappings.push(PathMapping::new(
1067                "/data/projects",
1068                local_projects.to_string_lossy().to_string(),
1069            ));
1070        }
1071
1072        mappings
1073    }
1074
1075    /// Detect platform from probe results.
1076    fn detect_platform(&self, probe: &HostProbeResult) -> Option<Platform> {
1077        probe
1078            .system_info
1079            .as_ref()
1080            .and_then(|si| match si.os.to_lowercase().as_str() {
1081                "darwin" => Some(Platform::Macos),
1082                "linux" => Some(Platform::Linux),
1083                "windows" => Some(Platform::Windows),
1084                _ => None,
1085            })
1086    }
1087
1088    /// Generate a ConfigPreview from probe results.
1089    ///
1090    /// # Arguments
1091    /// * `probes` - List of (host_name, probe_result) tuples for selected hosts
1092    /// * `already_configured` - Set of normalized source-name keys already configured
1093    pub fn generate_preview(
1094        &self,
1095        probes: &[(&str, &HostProbeResult)],
1096        already_configured: &HashSet<String>,
1097    ) -> ConfigPreview {
1098        let mut preview = ConfigPreview::new();
1099        let configured_name_keys: HashSet<_> = already_configured
1100            .iter()
1101            .map(|name| source_name_key(name))
1102            .collect();
1103        let mut preview_name_keys = configured_name_keys.clone();
1104
1105        for (host_name, probe) in probes {
1106            // Skip if probe failed
1107            if !probe.reachable {
1108                let reason = probe
1109                    .error
1110                    .clone()
1111                    .unwrap_or_else(|| "unreachable".to_string());
1112                preview
1113                    .sources_skipped
1114                    .push((host_name.to_string(), SkipReason::ProbeFailure(reason)));
1115                continue;
1116            }
1117
1118            // Generate source definition before duplicate checks so we compare
1119            // using the same canonical naming rules as the saved config.
1120            let source = self.generate_source(host_name, probe);
1121            let source_name_key = source_name_key(&source.name);
1122            if configured_name_keys.contains(&source_name_key) {
1123                preview
1124                    .sources_skipped
1125                    .push((source.name.clone(), SkipReason::AlreadyConfigured));
1126                continue;
1127            }
1128            if let Err(err) = source.validate() {
1129                preview.sources_skipped.push((
1130                    host_name.to_string(),
1131                    SkipReason::InvalidSourceDefinition(err.to_string()),
1132                ));
1133                continue;
1134            }
1135            if !preview_name_keys.insert(source_name_key) {
1136                preview.sources_skipped.push((
1137                    host_name.to_string(),
1138                    SkipReason::GeneratedNameConflict(source.name.clone()),
1139                ));
1140                continue;
1141            }
1142            preview.sources_to_add.push(source);
1143        }
1144
1145        preview
1146    }
1147}
1148
1149impl Default for SourceConfigGenerator {
1150    fn default() -> Self {
1151        Self::new()
1152    }
1153}
1154
1155impl SourcesConfig {
1156    /// Write configuration with backup.
1157    ///
1158    /// Creates a uniquely named backup of the existing config (if any)
1159    /// before writing the new configuration atomically.
1160    pub fn write_with_backup(&self) -> Result<BackupInfo, ConfigError> {
1161        let config_path = Self::config_path()?;
1162
1163        // Create parent directories if needed
1164        if let Some(parent) = config_path.parent() {
1165            std::fs::create_dir_all(parent)?;
1166        }
1167
1168        // Create backup if file exists
1169        let backup_path = if config_path.exists() {
1170            let backup = unique_backup_path(&config_path);
1171            std::fs::copy(&config_path, &backup)?;
1172            Some(backup)
1173        } else {
1174            None
1175        };
1176
1177        // Validate config before writing (round-trip check included below)
1178        self.validate()?;
1179        let toml_str = toml::to_string_pretty(self)?;
1180        let parsed: SourcesConfig = toml::from_str(&toml_str)?;
1181        parsed.validate()?;
1182
1183        // Write atomically (temp file + rename)
1184        let temp_path = unique_atomic_temp_path(&config_path);
1185        std::fs::write(&temp_path, &toml_str)?;
1186        sync_file_path(&temp_path)?;
1187        replace_file_from_temp(&temp_path, &config_path)?;
1188
1189        Ok(BackupInfo {
1190            backup_path,
1191            config_path,
1192        })
1193    }
1194
1195    /// Merge a source into the configuration.
1196    ///
1197    /// Returns `MergeResult::Added` if the source was added,
1198    /// or `MergeResult::AlreadyExists` if a source with the same name exists.
1199    pub fn merge_source(&mut self, source: SourceDefinition) -> Result<MergeResult, ConfigError> {
1200        // Validate the source first
1201        source.validate()?;
1202
1203        // Check if already exists
1204        if self
1205            .sources
1206            .iter()
1207            .any(|s| source_names_equal(&s.name, &source.name))
1208        {
1209            return Ok(MergeResult::AlreadyExists(source.name));
1210        }
1211
1212        let added = source.clone();
1213        self.sources.push(source);
1214        Ok(MergeResult::Added(added))
1215    }
1216
1217    /// Merge multiple sources from a preview.
1218    ///
1219    /// Returns a tuple of (added_count, skipped_names).
1220    pub fn merge_preview(
1221        &mut self,
1222        preview: &ConfigPreview,
1223    ) -> Result<(usize, Vec<String>), ConfigError> {
1224        let mut added = 0;
1225        let mut skipped = Vec::new();
1226
1227        for source in &preview.sources_to_add {
1228            match self.merge_source(source.clone())? {
1229                MergeResult::Added(_) => added += 1,
1230                MergeResult::AlreadyExists(name) => skipped.push(name),
1231            }
1232        }
1233
1234        Ok((added, skipped))
1235    }
1236
1237    /// Get set of configured source names.
1238    pub fn configured_names(&self) -> HashSet<String> {
1239        self.sources.iter().map(|s| s.name.clone()).collect()
1240    }
1241
1242    /// Get normalized source-name keys for duplicate detection and lookups.
1243    pub fn configured_name_keys(&self) -> HashSet<String> {
1244        self.sources
1245            .iter()
1246            .map(|s| source_name_key(&s.name))
1247            .collect()
1248    }
1249}
1250
1251fn replace_file_from_temp(temp_path: &Path, final_path: &Path) -> Result<(), std::io::Error> {
1252    #[cfg(windows)]
1253    {
1254        match std::fs::rename(temp_path, final_path) {
1255            Ok(()) => sync_parent_directory(final_path),
1256            Err(first_err)
1257                if final_path.exists()
1258                    && matches!(
1259                        first_err.kind(),
1260                        std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::PermissionDenied
1261                    ) =>
1262            {
1263                let backup_path = unique_replace_backup_path(final_path);
1264                std::fs::rename(final_path, &backup_path).map_err(|backup_err| {
1265                    let _ = std::fs::remove_file(temp_path);
1266                    std::io::Error::other(format!(
1267                        "failed preparing backup {} before replacing {}: first error: {}; backup error: {}",
1268                        backup_path.display(),
1269                        final_path.display(),
1270                        first_err,
1271                        backup_err
1272                    ))
1273                })?;
1274                match std::fs::rename(temp_path, final_path) {
1275                    Ok(()) => {
1276                        let _ = std::fs::remove_file(&backup_path);
1277                        sync_parent_directory(final_path)
1278                    }
1279                    Err(second_err) => {
1280                        let restore_result = std::fs::rename(&backup_path, final_path);
1281                        match restore_result {
1282                            Ok(()) => {
1283                                let _ = std::fs::remove_file(temp_path);
1284                                sync_parent_directory(final_path).map_err(|sync_err| {
1285                                    std::io::Error::other(format!(
1286                                        "failed replacing {} with {}: first error: {}; second error: {}; restored original file but failed syncing parent directory: {}",
1287                                        final_path.display(),
1288                                        temp_path.display(),
1289                                        first_err,
1290                                        second_err,
1291                                        sync_err
1292                                    ))
1293                                })?;
1294                                Err(std::io::Error::new(
1295                                    second_err.kind(),
1296                                    format!(
1297                                        "failed replacing {} with {}: first error: {}; second error: {}; restored original file",
1298                                        final_path.display(),
1299                                        temp_path.display(),
1300                                        first_err,
1301                                        second_err
1302                                    ),
1303                                ))
1304                            }
1305                            Err(restore_err) => Err(std::io::Error::other(format!(
1306                                "failed replacing {} with {}: first error: {}; second error: {}; restore error: {}; temp file retained at {}",
1307                                final_path.display(),
1308                                temp_path.display(),
1309                                first_err,
1310                                second_err,
1311                                restore_err,
1312                                temp_path.display()
1313                            ))),
1314                        }
1315                    }
1316                }
1317            }
1318            Err(rename_err) => Err(rename_err),
1319        }
1320    }
1321
1322    #[cfg(not(windows))]
1323    {
1324        std::fs::rename(temp_path, final_path)?;
1325        sync_parent_directory(final_path)
1326    }
1327}
1328
1329fn sync_file_path(path: &Path) -> Result<(), std::io::Error> {
1330    std::fs::File::open(path)?.sync_all()
1331}
1332
1333#[cfg(not(windows))]
1334fn sync_parent_directory(path: &Path) -> Result<(), std::io::Error> {
1335    let Some(parent) = path.parent() else {
1336        return Ok(());
1337    };
1338    std::fs::File::open(parent)?.sync_all()
1339}
1340
1341#[cfg(windows)]
1342fn sync_parent_directory(_path: &Path) -> Result<(), std::io::Error> {
1343    Ok(())
1344}
1345
1346fn unique_atomic_temp_path(path: &Path) -> PathBuf {
1347    unique_atomic_sidecar_path(path, "tmp", "sources.toml")
1348}
1349
1350fn unique_backup_path(path: &Path) -> PathBuf {
1351    static NEXT_NONCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1352
1353    let timestamp = std::time::SystemTime::now()
1354        .duration_since(std::time::UNIX_EPOCH)
1355        .unwrap_or_default()
1356        .as_nanos();
1357    let nonce = NEXT_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1358    let file_name = path
1359        .file_name()
1360        .and_then(|name| name.to_str())
1361        .unwrap_or("sources.toml");
1362
1363    path.with_file_name(format!(
1364        "{file_name}.backup.{}.{}.{}",
1365        std::process::id(),
1366        timestamp,
1367        nonce
1368    ))
1369}
1370
1371#[cfg(windows)]
1372fn unique_replace_backup_path(path: &Path) -> PathBuf {
1373    unique_atomic_sidecar_path(path, "bak", "sources.toml")
1374}
1375
1376fn unique_atomic_sidecar_path(path: &Path, suffix: &str, fallback_name: &str) -> PathBuf {
1377    static NEXT_NONCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1378
1379    let timestamp = std::time::SystemTime::now()
1380        .duration_since(std::time::UNIX_EPOCH)
1381        .unwrap_or_default()
1382        .as_nanos();
1383    let nonce = NEXT_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1384    let file_name = path
1385        .file_name()
1386        .and_then(|name| name.to_str())
1387        .unwrap_or(fallback_name);
1388
1389    path.with_file_name(format!(
1390        ".{file_name}.{suffix}.{}.{}.{}",
1391        std::process::id(),
1392        timestamp,
1393        nonce
1394    ))
1395}
1396
1397#[cfg(test)]
1398mod tests {
1399    use super::*;
1400
1401    #[test]
1402    fn test_empty_config_default() {
1403        let config = SourcesConfig::default();
1404        assert!(config.sources.is_empty());
1405    }
1406
1407    #[test]
1408    fn test_replace_file_from_temp_overwrites_existing_file() {
1409        let temp = tempfile::tempdir().expect("tempdir");
1410        let final_path = temp.path().join("sources.toml");
1411        let first_tmp = temp.path().join("first.tmp");
1412        let second_tmp = temp.path().join("second.tmp");
1413
1414        std::fs::write(&first_tmp, "first = true\n").expect("write first temp");
1415        replace_file_from_temp(&first_tmp, &final_path).expect("initial replace");
1416        assert_eq!(
1417            std::fs::read_to_string(&final_path).expect("read first final"),
1418            "first = true\n"
1419        );
1420
1421        std::fs::write(&second_tmp, "second = true\n").expect("write second temp");
1422        replace_file_from_temp(&second_tmp, &final_path).expect("overwrite replace");
1423        assert_eq!(
1424            std::fs::read_to_string(&final_path).expect("read second final"),
1425            "second = true\n"
1426        );
1427    }
1428
1429    #[test]
1430    fn test_unique_atomic_temp_path_changes_each_call() {
1431        let final_path = Path::new("/tmp/sources.toml");
1432        let first = unique_atomic_temp_path(final_path);
1433        let second = unique_atomic_temp_path(final_path);
1434
1435        assert_ne!(first, second);
1436        assert_eq!(first.parent(), final_path.parent());
1437        assert_eq!(second.parent(), final_path.parent());
1438    }
1439
1440    #[test]
1441    fn test_unique_backup_path_changes_each_call() {
1442        let final_path = Path::new("/tmp/sources.toml");
1443        let first = unique_backup_path(final_path);
1444        let second = unique_backup_path(final_path);
1445
1446        assert_ne!(first, second);
1447        assert_eq!(first.parent(), final_path.parent());
1448        assert_eq!(second.parent(), final_path.parent());
1449    }
1450
1451    #[test]
1452    fn test_config_path_from_parts_prefers_xdg_config_home() {
1453        let temp = tempfile::tempdir().expect("tempdir");
1454        let xdg_config_home = temp.path().join("xdg-config");
1455        let platform_config_dir = temp.path().join("platform-config");
1456        let home_dir = temp.path().join("home");
1457
1458        assert_eq!(
1459            config_path_from_parts(
1460                Some(xdg_config_home.clone()),
1461                Some(platform_config_dir),
1462                Some(home_dir)
1463            )
1464            .expect("path from xdg config home"),
1465            xdg_config_home.join("cass").join("sources.toml")
1466        );
1467    }
1468
1469    #[test]
1470    fn test_config_path_from_parts_prefers_existing_platform_path_before_dot_config() {
1471        let temp = tempfile::tempdir().expect("tempdir");
1472        let platform_config_dir = temp.path().join("platform-config");
1473        let platform_path = platform_config_dir.join("cass").join("sources.toml");
1474        let home_dir = temp.path().join("home");
1475        let dot_config_path = home_dir.join(".config").join("cass").join("sources.toml");
1476        std::fs::create_dir_all(platform_path.parent().expect("platform parent")).unwrap();
1477        std::fs::create_dir_all(dot_config_path.parent().expect("dot-config parent")).unwrap();
1478        std::fs::write(&platform_path, "").unwrap();
1479        std::fs::write(&dot_config_path, "").unwrap();
1480
1481        assert_eq!(
1482            config_path_from_parts(None, Some(platform_config_dir), Some(home_dir))
1483                .expect("existing platform path"),
1484            platform_path
1485        );
1486    }
1487
1488    #[test]
1489    fn test_config_path_from_parts_uses_existing_dot_config_before_new_platform_path() {
1490        let temp = tempfile::tempdir().expect("tempdir");
1491        let platform_config_dir = temp.path().join("platform-config");
1492        let home_dir = temp.path().join("home");
1493        let dot_config_path = home_dir.join(".config").join("cass").join("sources.toml");
1494        std::fs::create_dir_all(dot_config_path.parent().expect("dot-config parent")).unwrap();
1495        std::fs::write(&dot_config_path, "").unwrap();
1496
1497        assert_eq!(
1498            config_path_from_parts(None, Some(platform_config_dir), Some(home_dir))
1499                .expect("existing dot-config path"),
1500            dot_config_path
1501        );
1502    }
1503
1504    #[test]
1505    fn test_source_definition_local() {
1506        let source = SourceDefinition::local("test");
1507        assert_eq!(source.name, "test");
1508        assert_eq!(source.source_type, SourceKind::Local);
1509        assert!(!source.is_remote());
1510    }
1511
1512    #[test]
1513    fn test_source_definition_ssh() {
1514        let source = SourceDefinition::ssh("laptop", "user@laptop.local");
1515        assert_eq!(source.name, "laptop");
1516        assert_eq!(source.source_type, SourceKind::Ssh);
1517        assert_eq!(source.host, Some("user@laptop.local".into()));
1518        assert!(source.is_remote());
1519    }
1520
1521    #[test]
1522    fn test_source_validation_empty_name() {
1523        let source = SourceDefinition::default();
1524        assert!(source.validate().is_err());
1525
1526        let source = SourceDefinition::local("   ");
1527        assert!(source.validate().is_err());
1528    }
1529
1530    #[test]
1531    fn test_source_validation_rejects_padded_names() {
1532        let source = SourceDefinition::local(" laptop");
1533        assert!(source.validate().is_err());
1534
1535        let source = SourceDefinition::local("laptop ");
1536        assert!(source.validate().is_err());
1537    }
1538
1539    #[test]
1540    fn test_source_validation_dot_names() {
1541        let source = SourceDefinition::local(".");
1542        assert!(source.validate().is_err());
1543
1544        let source = SourceDefinition::local("..");
1545        assert!(source.validate().is_err());
1546    }
1547
1548    #[test]
1549    fn test_source_validation_reserved_local_name() {
1550        let source = SourceDefinition::ssh("local", "user@host");
1551        assert!(source.validate().is_err());
1552
1553        let source = SourceDefinition::ssh("LOCAL", "user@host");
1554        assert!(source.validate().is_err());
1555    }
1556
1557    #[test]
1558    fn test_normalize_generated_remote_source_name_disambiguates_local() {
1559        assert_eq!(normalize_generated_remote_source_name("local"), "local-ssh");
1560        assert_eq!(normalize_generated_remote_source_name("LOCAL"), "LOCAL-ssh");
1561        assert_eq!(
1562            normalize_generated_remote_source_name(" local "),
1563            "local-ssh"
1564        );
1565        assert_eq!(normalize_generated_remote_source_name("laptop"), "laptop");
1566        assert_eq!(normalize_generated_remote_source_name(" laptop "), "laptop");
1567    }
1568
1569    #[test]
1570    fn test_source_validation_ssh_without_host() {
1571        let mut source = SourceDefinition::ssh("test", "host");
1572        source.host = None;
1573        assert!(source.validate().is_err());
1574    }
1575
1576    #[test]
1577    fn test_source_validation_ssh_host_hardening() {
1578        let source = SourceDefinition::ssh("test", "user-name_1@host-name.example");
1579        assert!(source.validate().is_ok());
1580
1581        let source = SourceDefinition::ssh("test", "ssh-config-alias");
1582        assert!(source.validate().is_ok());
1583
1584        let source = SourceDefinition::ssh("test", "-oProxyCommand=evil");
1585        assert!(source.validate().is_err());
1586
1587        let source = SourceDefinition::ssh("test", "user@host withspace");
1588        assert!(source.validate().is_err());
1589
1590        for host in [
1591            " user@host",
1592            "user@host ",
1593            "\tuser@host",
1594            "user@host;touch /tmp/cass-owned",
1595            "user@host`hostname`",
1596            "user@host$(hostname)",
1597            "user@host/../../secret",
1598            "user@host:2222",
1599            "üser@host",
1600            "@host",
1601            "user@",
1602            "user@host@extra",
1603        ] {
1604            let source = SourceDefinition::ssh("test", host);
1605            assert!(
1606                source.validate().is_err(),
1607                "host should be rejected: {host:?}"
1608            );
1609        }
1610    }
1611
1612    #[test]
1613    fn test_source_validation_rejects_invalid_paths() {
1614        for path in [
1615            "",
1616            "   ",
1617            " ~/.claude/projects",
1618            "~/.claude/projects ",
1619            "~/.claude\nprojects",
1620        ] {
1621            let mut source = SourceDefinition::ssh("test", "user@host");
1622            source.paths = vec![path.to_string()];
1623            assert!(
1624                source.validate().is_err(),
1625                "path should be rejected: {path:?}"
1626            );
1627        }
1628
1629        let mut source = SourceDefinition::ssh("test", "user@host");
1630        source.paths = vec!["~/Library/Application Support/Cursor/User/globalStorage".to_string()];
1631        assert!(source.validate().is_ok());
1632    }
1633
1634    #[test]
1635    fn test_load_from_preserves_invalid_paths_for_operation_level_reporting() {
1636        let temp = tempfile::tempdir().expect("tempdir");
1637        let config_path = temp.path().join("sources.toml");
1638        std::fs::write(
1639            &config_path,
1640            r#"
1641[[sources]]
1642name = "laptop"
1643type = "ssh"
1644host = "user@host"
1645paths = [" ~/.claude/projects", "~/.codex/sessions"]
1646"#,
1647        )
1648        .expect("write config");
1649
1650        let loaded = SourcesConfig::load_from(&config_path).expect("lenient load");
1651        assert_eq!(loaded.sources.len(), 1);
1652        assert_eq!(loaded.sources[0].paths[0], " ~/.claude/projects");
1653        assert_eq!(loaded.sources[0].paths[1], "~/.codex/sessions");
1654        assert!(
1655            loaded.validate().is_err(),
1656            "strict validation should still reject writing the malformed path"
1657        );
1658    }
1659
1660    #[test]
1661    fn test_load_from_still_rejects_invalid_source_structure() {
1662        let temp = tempfile::tempdir().expect("tempdir");
1663        let config_path = temp.path().join("sources.toml");
1664        std::fs::write(
1665            &config_path,
1666            r#"
1667[[sources]]
1668name = "laptop"
1669type = "ssh"
1670host = "user@host withspace"
1671paths = ["~/.claude/projects"]
1672"#,
1673        )
1674        .expect("write config");
1675
1676        assert!(
1677            SourcesConfig::load_from(&config_path).is_err(),
1678            "lenient load is only for per-path validation, not unsafe host structure"
1679        );
1680    }
1681
1682    #[test]
1683    fn test_source_validation_path_mapping_empty_from() {
1684        let mut source = SourceDefinition::local("test");
1685        source.path_mappings.push(PathMapping::new("", "/Users/me"));
1686        assert!(source.validate().is_err());
1687
1688        source.path_mappings.clear();
1689        source
1690            .path_mappings
1691            .push(PathMapping::new("   ", "/Users/me"));
1692        assert!(source.validate().is_err());
1693    }
1694
1695    #[test]
1696    fn test_source_validation_path_mapping_empty_to() {
1697        let mut source = SourceDefinition::local("test");
1698        source
1699            .path_mappings
1700            .push(PathMapping::new("/home/user", ""));
1701        assert!(source.validate().is_err());
1702
1703        source.path_mappings.clear();
1704        source
1705            .path_mappings
1706            .push(PathMapping::new("/home/user", "   "));
1707        assert!(source.validate().is_err());
1708    }
1709
1710    #[test]
1711    fn test_source_validation_path_mapping_empty_agent_names() {
1712        let mut source = SourceDefinition::local("test");
1713        source.path_mappings.push(PathMapping::with_agents(
1714            "/home/user",
1715            "/Users/me",
1716            vec!["claude-code".into(), "   ".into()],
1717        ));
1718        assert!(source.validate().is_err());
1719    }
1720
1721    #[test]
1722    fn test_source_validation_path_mapping_empty_agents_list() {
1723        let mut source = SourceDefinition::local("test");
1724        source.path_mappings.push(PathMapping::with_agents(
1725            "/home/user",
1726            "/Users/me",
1727            Vec::new(),
1728        ));
1729        assert!(source.validate().is_err());
1730    }
1731
1732    #[test]
1733    fn test_path_mapping_new() {
1734        let mapping = PathMapping::new("/home/user", "/Users/me");
1735        assert_eq!(mapping.from, "/home/user");
1736        assert_eq!(mapping.to, "/Users/me");
1737        assert!(mapping.agents.is_none());
1738    }
1739
1740    #[test]
1741    fn test_path_mapping_with_agents() {
1742        let mapping = PathMapping::with_agents(
1743            "/home/user",
1744            "/Users/me",
1745            vec!["claude-code".into(), "cursor".into()],
1746        );
1747        assert_eq!(mapping.from, "/home/user");
1748        assert_eq!(mapping.to, "/Users/me");
1749        assert_eq!(
1750            mapping.agents,
1751            Some(vec!["claude-code".into(), "cursor".into()])
1752        );
1753    }
1754
1755    #[test]
1756    fn test_path_mapping_apply() {
1757        let mapping = PathMapping::new("/home/user/projects", "/Users/me/projects");
1758
1759        // Matching prefix
1760        assert_eq!(
1761            mapping.apply("/home/user/projects/myapp"),
1762            Some("/Users/me/projects/myapp".into())
1763        );
1764
1765        // Non-matching prefix
1766        assert_eq!(mapping.apply("/opt/data"), None);
1767
1768        // Partial match (not at start)
1769        assert_eq!(mapping.apply("/data/home/user/projects"), None);
1770    }
1771
1772    #[test]
1773    fn test_path_mapping_applies_to_agent() {
1774        // This test pins the semantics of the *cass wrapper*
1775        // (`path_mapping_applies_to_agent`) rather than the upstream
1776        // `PathMapping::applies_to_agent` method. Cass intentionally uses a
1777        // permissive wrapper: when the caller doesn't specify an agent, even
1778        // mappings that are scoped to a specific agent still apply. Upstream
1779        // (`franken_agent_detection`) uses a stricter default (`(Some, None)
1780        // => false`) because its scan-time usage wants to skip
1781        // agent-specific mappings when the agent is unknown. Both semantics
1782        // are correct in their own context; cass's tests must exercise the
1783        // cass wrapper to avoid coupling to whichever default franken picks.
1784
1785        // Mapping with no agent filter — applies in every case.
1786        let global = PathMapping::new("/home", "/Users");
1787        assert!(path_mapping_applies_to_agent(&global, None));
1788        assert!(path_mapping_applies_to_agent(&global, Some("claude-code")));
1789        assert!(path_mapping_applies_to_agent(&global, Some("any-agent")));
1790
1791        // Mapping with agent filter.
1792        let filtered = PathMapping::with_agents("/home", "/Users", vec!["claude-code".into()]);
1793        // No agent specified → cass wrapper matches (permissive default).
1794        assert!(path_mapping_applies_to_agent(&filtered, None));
1795        // An explicitly empty allow-list is invalid config and should not match
1796        // defensively if one is constructed in code.
1797        let empty_filter = PathMapping::with_agents("/home", "/Users", Vec::new());
1798        assert!(!path_mapping_applies_to_agent(&empty_filter, None));
1799        // Agent matches the allow-list.
1800        assert!(path_mapping_applies_to_agent(
1801            &filtered,
1802            Some("claude-code")
1803        ));
1804        // Agent not in the allow-list.
1805        assert!(!path_mapping_applies_to_agent(&filtered, Some("cursor")));
1806        // Hyphen/underscore normalization: `claude_code` must match the
1807        // allow-list entry `claude-code` because cass normalizes agent slugs
1808        // before comparison.
1809        assert!(path_mapping_applies_to_agent(
1810            &filtered,
1811            Some("claude_code")
1812        ));
1813        assert!(path_mapping_applies_to_agent(&filtered, Some("claude")));
1814
1815        let openclaw_filtered =
1816            PathMapping::with_agents("/home", "/Users", vec!["openclaw".into()]);
1817        assert!(path_mapping_applies_to_agent(
1818            &openclaw_filtered,
1819            Some("open-claw")
1820        ));
1821    }
1822
1823    #[test]
1824    fn test_path_rewriting() {
1825        let mut source = SourceDefinition::local("test");
1826        source.path_mappings.push(PathMapping::new(
1827            "/home/user/projects",
1828            "/Users/me/projects",
1829        ));
1830        source
1831            .path_mappings
1832            .push(PathMapping::new("/home/user", "/Users/me"));
1833
1834        // Longest prefix should match
1835        assert_eq!(
1836            source.rewrite_path("/home/user/projects/myapp"),
1837            "/Users/me/projects/myapp"
1838        );
1839
1840        // Shorter prefix
1841        assert_eq!(source.rewrite_path("/home/user/other"), "/Users/me/other");
1842
1843        // No match
1844        assert_eq!(source.rewrite_path("/opt/data"), "/opt/data");
1845    }
1846
1847    #[test]
1848    fn test_path_rewriting_with_agent_filter() {
1849        let mut source = SourceDefinition::local("test");
1850        // Global mapping
1851        source
1852            .path_mappings
1853            .push(PathMapping::new("/home/user", "/Users/me"));
1854        // Agent-specific mapping
1855        source.path_mappings.push(PathMapping::with_agents(
1856            "/home/user/projects",
1857            "/Volumes/Work/projects",
1858            vec!["claude-code".into()],
1859        ));
1860
1861        // Without agent filter, both mappings apply (longest match wins)
1862        assert_eq!(
1863            source.rewrite_path_for_agent("/home/user/projects/app", None),
1864            "/Volumes/Work/projects/app"
1865        );
1866
1867        // With claude-code agent, use specific mapping
1868        assert_eq!(
1869            source.rewrite_path_for_agent("/home/user/projects/app", Some("claude-code")),
1870            "/Volumes/Work/projects/app"
1871        );
1872        assert_eq!(
1873            source.rewrite_path_for_agent("/home/user/projects/app", Some("claude")),
1874            "/Volumes/Work/projects/app"
1875        );
1876
1877        // With cursor agent, falls back to global mapping
1878        assert_eq!(
1879            source.rewrite_path_for_agent("/home/user/projects/app", Some("cursor")),
1880            "/Users/me/projects/app"
1881        );
1882
1883        // Non-matching path
1884        assert_eq!(
1885            source.rewrite_path_for_agent("/opt/data", Some("claude-code")),
1886            "/opt/data"
1887        );
1888    }
1889
1890    #[test]
1891    fn test_config_duplicate_names() {
1892        let mut config = SourcesConfig::default();
1893        config.sources.push(SourceDefinition::local("test"));
1894        config.sources.push(SourceDefinition::local("test"));
1895
1896        assert!(config.validate().is_err());
1897    }
1898
1899    #[test]
1900    fn test_config_duplicate_names_case_insensitive() {
1901        let mut config = SourcesConfig::default();
1902        config
1903            .sources
1904            .push(SourceDefinition::ssh("Laptop", "user@laptop"));
1905        config
1906            .sources
1907            .push(SourceDefinition::ssh("laptop", "user@other-host"));
1908
1909        assert!(config.validate().is_err());
1910    }
1911
1912    #[test]
1913    fn test_source_name_keys_trim_and_ignore_case() {
1914        assert_eq!(source_name_key(" Laptop "), "laptop");
1915        assert!(source_names_equal(" Laptop ", "laptop"));
1916    }
1917
1918    #[test]
1919    fn test_config_add_source() {
1920        let mut config = SourcesConfig::default();
1921        config.add_source(SourceDefinition::local("test")).unwrap();
1922
1923        assert_eq!(config.sources.len(), 1);
1924
1925        // Adding duplicate should fail
1926        assert!(config.add_source(SourceDefinition::local("test")).is_err());
1927    }
1928
1929    #[test]
1930    fn test_config_add_source_case_insensitive_duplicate() {
1931        let mut config = SourcesConfig::default();
1932        config
1933            .add_source(SourceDefinition::ssh("Laptop", "user@laptop"))
1934            .unwrap();
1935
1936        assert!(
1937            config
1938                .add_source(SourceDefinition::ssh("laptop", "user@other-host"))
1939                .is_err()
1940        );
1941    }
1942
1943    #[test]
1944    fn test_config_remove_source() {
1945        let mut config = SourcesConfig::default();
1946        config.sources.push(SourceDefinition::local("test"));
1947
1948        assert!(config.remove_source("test"));
1949        assert!(!config.remove_source("nonexistent"));
1950        assert!(config.sources.is_empty());
1951    }
1952
1953    #[test]
1954    fn test_config_remove_source_case_insensitive() {
1955        let mut config = SourcesConfig::default();
1956        config
1957            .sources
1958            .push(SourceDefinition::ssh("Laptop", "user@laptop"));
1959
1960        assert!(config.remove_source("laptop"));
1961        assert!(config.sources.is_empty());
1962    }
1963
1964    #[test]
1965    fn test_find_source_case_insensitive() {
1966        let mut config = SourcesConfig::default();
1967        config
1968            .sources
1969            .push(SourceDefinition::ssh("Laptop", "user@laptop"));
1970
1971        assert!(config.find_source("laptop").is_some());
1972        assert!(config.find_source("LAPTOP").is_some());
1973        assert!(config.find_source_mut("laptop").is_some());
1974    }
1975
1976    #[test]
1977    fn test_config_serialization_roundtrip() {
1978        let mut config = SourcesConfig::default();
1979        config.sources.push(SourceDefinition {
1980            name: "laptop".into(),
1981            source_type: SourceKind::Ssh,
1982            host: Some("user@laptop.local".into()),
1983            paths: vec!["~/.claude/projects".into()],
1984            sync_schedule: SyncSchedule::Daily,
1985            path_mappings: vec![PathMapping::new("/home/user", "/Users/me")],
1986            platform: Some(Platform::Linux),
1987        });
1988
1989        let serialized = toml::to_string_pretty(&config).unwrap();
1990        let deserialized: SourcesConfig = toml::from_str(&serialized).unwrap();
1991
1992        assert_eq!(deserialized.sources.len(), 1);
1993        assert_eq!(deserialized.sources[0].name, "laptop");
1994        assert_eq!(deserialized.sources[0].sync_schedule, SyncSchedule::Daily);
1995        assert_eq!(deserialized.sources[0].path_mappings.len(), 1);
1996        assert_eq!(deserialized.sources[0].path_mappings[0].from, "/home/user");
1997        assert_eq!(deserialized.sources[0].path_mappings[0].to, "/Users/me");
1998    }
1999
2000    #[test]
2001    fn test_path_mapping_serialization_with_agents() {
2002        let mut config = SourcesConfig::default();
2003        config.sources.push(SourceDefinition {
2004            name: "remote".into(),
2005            source_type: SourceKind::Ssh,
2006            host: Some("user@server".into()),
2007            paths: vec![],
2008            sync_schedule: SyncSchedule::Manual,
2009            path_mappings: vec![
2010                PathMapping::new("/home/user", "/Users/me"),
2011                PathMapping::with_agents("/opt/work", "/Volumes/Work", vec!["claude-code".into()]),
2012            ],
2013            platform: None,
2014        });
2015
2016        let serialized = toml::to_string_pretty(&config).unwrap();
2017        let deserialized: SourcesConfig = toml::from_str(&serialized).unwrap();
2018
2019        assert_eq!(deserialized.sources[0].path_mappings.len(), 2);
2020        // First mapping has no agents filter
2021        assert!(deserialized.sources[0].path_mappings[0].agents.is_none());
2022        // Second mapping has agents filter
2023        assert_eq!(
2024            deserialized.sources[0].path_mappings[1].agents,
2025            Some(vec!["claude-code".into()])
2026        );
2027    }
2028
2029    #[test]
2030    fn test_preset_paths() {
2031        let macos = get_preset_paths("macos-defaults").unwrap();
2032        assert!(!macos.is_empty());
2033        assert!(macos.iter().any(|p| p.contains(".claude")));
2034
2035        let linux = get_preset_paths("linux-defaults").unwrap();
2036        assert!(!linux.is_empty());
2037
2038        assert!(get_preset_paths("unknown").is_err());
2039    }
2040
2041    #[test]
2042    fn test_sync_schedule_display() {
2043        assert_eq!(SyncSchedule::Manual.to_string(), SYNC_SCHEDULE_MANUAL);
2044        assert_eq!(SyncSchedule::Hourly.to_string(), SYNC_SCHEDULE_HOURLY);
2045        assert_eq!(SyncSchedule::Daily.to_string(), SYNC_SCHEDULE_DAILY);
2046    }
2047
2048    #[test]
2049    fn test_discover_ssh_hosts() {
2050        // Just test that the function doesn't panic
2051        let hosts = super::discover_ssh_hosts();
2052        // Could be empty if no ~/.ssh/config exists
2053        for host in hosts {
2054            assert!(!host.name.is_empty());
2055        }
2056    }
2057
2058    #[test]
2059    fn test_parse_ssh_config_splits_multiple_host_aliases() {
2060        let hosts = super::parse_ssh_config(
2061            r#"
2062Host alpha beta *.internal ?wild
2063  HostName 192.0.2.10
2064  User ubuntu
2065  Port 2222
2066  IdentityFile ~/.ssh/id_ed25519
2067
2068Host gamma
2069  User deploy
2070"#,
2071        );
2072
2073        assert_eq!(hosts.len(), 3);
2074        assert_eq!(hosts[0].name, "alpha");
2075        assert_eq!(hosts[1].name, "beta");
2076        assert_eq!(hosts[2].name, "gamma");
2077        for host in &hosts[..2] {
2078            assert_eq!(host.hostname.as_deref(), Some("192.0.2.10"));
2079            assert_eq!(host.user.as_deref(), Some("ubuntu"));
2080            assert_eq!(host.port, Some(2222));
2081            assert_eq!(host.identity_file.as_deref(), Some("~/.ssh/id_ed25519"));
2082        }
2083        assert_eq!(hosts[2].user.as_deref(), Some("deploy"));
2084    }
2085
2086    #[test]
2087    fn test_parse_ssh_config_skips_negated_host_patterns() {
2088        let hosts = super::parse_ssh_config(
2089            r#"
2090Host * !bastion staging
2091  User ubuntu
2092
2093Host production !legacy-prod
2094  User deploy
2095"#,
2096        );
2097
2098        assert_eq!(hosts.len(), 2);
2099        assert_eq!(hosts[0].name, "staging");
2100        assert_eq!(hosts[0].user.as_deref(), Some("ubuntu"));
2101        assert_eq!(hosts[1].name, "production");
2102        assert_eq!(hosts[1].user.as_deref(), Some("deploy"));
2103    }
2104
2105    #[test]
2106    fn test_parse_ssh_config() {
2107        let content = "
2108            Host example
2109                HostName example.com
2110                User testuser
2111
2112            Host=another
2113                Port=2222
2114                IdentityFile = ~/.ssh/id_rsa
2115        ";
2116        let hosts = parse_ssh_config(content);
2117        assert_eq!(hosts.len(), 2);
2118        assert_eq!(hosts[0].name, "example");
2119        assert_eq!(hosts[0].hostname.as_deref(), Some("example.com"));
2120        assert_eq!(hosts[0].user.as_deref(), Some("testuser"));
2121
2122        assert_eq!(hosts[1].name, "another");
2123        assert_eq!(hosts[1].port, Some(2222));
2124        assert_eq!(hosts[1].identity_file.as_deref(), Some("~/.ssh/id_rsa"));
2125    }
2126
2127    // ==========================================================================
2128    // Source Config Generator Tests
2129    // ==========================================================================
2130
2131    use super::super::probe::{CassStatus, DetectedAgent, HostProbeResult, SystemInfo};
2132
2133    fn make_test_probe(
2134        reachable: bool,
2135        agents: Vec<DetectedAgent>,
2136        sys_info: Option<SystemInfo>,
2137    ) -> HostProbeResult {
2138        HostProbeResult {
2139            host_name: "test-host".into(),
2140            reachable,
2141            connection_time_ms: 100,
2142            cass_status: CassStatus::NotFound,
2143            detected_agents: agents,
2144            system_info: sys_info,
2145            resources: None,
2146            error: if reachable {
2147                None
2148            } else {
2149                Some("connection refused".into())
2150            },
2151        }
2152    }
2153
2154    fn make_test_agent(agent_type: &str, path: &str) -> DetectedAgent {
2155        DetectedAgent {
2156            agent_type: agent_type.into(),
2157            path: path.into(),
2158            estimated_sessions: Some(100),
2159            estimated_size_mb: Some(50),
2160        }
2161    }
2162
2163    fn make_test_sys_info(os: &str, remote_home: &str) -> SystemInfo {
2164        SystemInfo {
2165            os: os.into(),
2166            arch: "x86_64".into(),
2167            distro: Some("Ubuntu 22.04".into()),
2168            has_cargo: true,
2169            has_cargo_binstall: true,
2170            has_curl: true,
2171            has_wget: true,
2172            remote_home: remote_home.into(),
2173            machine_id: None,
2174        }
2175    }
2176
2177    #[test]
2178    fn test_source_config_generator_new() {
2179        let generator = SourceConfigGenerator::new();
2180        assert!(!generator.local_home.as_os_str().is_empty());
2181    }
2182
2183    #[test]
2184    fn test_generate_source_basic() {
2185        let generator = SourceConfigGenerator::new();
2186        let probe = make_test_probe(
2187            true,
2188            vec![make_test_agent("claude", "~/.claude/projects")],
2189            Some(make_test_sys_info("linux", "/home/ubuntu")),
2190        );
2191
2192        let source = generator.generate_source("my-server", &probe);
2193
2194        assert_eq!(source.name, "my-server");
2195        assert_eq!(source.source_type, SourceKind::Ssh);
2196        assert_eq!(source.host, Some("my-server".into()));
2197        assert_eq!(source.sync_schedule, SyncSchedule::Manual);
2198        assert!(!source.paths.is_empty());
2199        assert!(source.paths.contains(&"~/.claude/projects".to_string()));
2200    }
2201
2202    #[test]
2203    fn test_generate_source_disambiguates_reserved_local_name() {
2204        let generator = SourceConfigGenerator::new();
2205        let probe = make_test_probe(
2206            true,
2207            vec![make_test_agent("claude", "~/.claude/projects")],
2208            Some(make_test_sys_info("linux", "/home/ubuntu")),
2209        );
2210
2211        let source = generator.generate_source("local", &probe);
2212
2213        assert_eq!(source.name, "local-ssh");
2214        assert_eq!(source.host, Some("local".into()));
2215    }
2216
2217    #[test]
2218    fn test_generate_source_deduplicates_paths() {
2219        let generator = SourceConfigGenerator::new();
2220        let probe = make_test_probe(
2221            true,
2222            vec![
2223                make_test_agent("claude", "~/.claude/projects"),
2224                make_test_agent("claude-2", "~/.claude/projects"), // Duplicate
2225            ],
2226            Some(make_test_sys_info("linux", "/home/user")),
2227        );
2228
2229        let source = generator.generate_source("server", &probe);
2230        assert_eq!(source.paths.len(), 1);
2231    }
2232
2233    #[test]
2234    fn test_generate_source_path_mappings() {
2235        let generator = SourceConfigGenerator::new();
2236        let probe = make_test_probe(
2237            true,
2238            vec![make_test_agent("claude", "~/.claude/projects")],
2239            Some(make_test_sys_info("linux", "/home/ubuntu")),
2240        );
2241
2242        let source = generator.generate_source("server", &probe);
2243        assert!(!source.path_mappings.is_empty());
2244        assert!(
2245            source
2246                .path_mappings
2247                .iter()
2248                .any(|m| m.from.contains("/home/ubuntu"))
2249        );
2250    }
2251
2252    #[test]
2253    fn test_generate_source_platform_detection() {
2254        let generator = SourceConfigGenerator::new();
2255        let probe = make_test_probe(
2256            true,
2257            vec![],
2258            Some(make_test_sys_info("linux", "/home/user")),
2259        );
2260        let source = generator.generate_source("server", &probe);
2261        assert_eq!(source.platform, Some(Platform::Linux));
2262    }
2263
2264    #[test]
2265    fn test_generate_preview_basic() {
2266        let generator = SourceConfigGenerator::new();
2267        let probe = make_test_probe(
2268            true,
2269            vec![make_test_agent("claude", "~/.claude/projects")],
2270            Some(make_test_sys_info("linux", "/home/user")),
2271        );
2272
2273        let probes: Vec<(&str, &HostProbeResult)> = vec![("server1", &probe)];
2274        let preview = generator.generate_preview(&probes, &HashSet::new());
2275
2276        assert_eq!(preview.sources_to_add.len(), 1);
2277        assert!(preview.sources_skipped.is_empty());
2278        assert!(preview.has_changes());
2279    }
2280
2281    #[test]
2282    fn test_generate_preview_skips_already_configured() {
2283        let generator = SourceConfigGenerator::new();
2284        let probe = make_test_probe(
2285            true,
2286            vec![make_test_agent("claude", "~/.claude/projects")],
2287            Some(make_test_sys_info("linux", "/home/user")),
2288        );
2289
2290        let probes: Vec<(&str, &HostProbeResult)> = vec![("server1", &probe)];
2291        let mut configured = HashSet::new();
2292        configured.insert("server1".to_string());
2293
2294        let preview = generator.generate_preview(&probes, &configured);
2295        assert!(preview.sources_to_add.is_empty());
2296        assert_eq!(preview.sources_skipped.len(), 1);
2297    }
2298
2299    #[test]
2300    fn test_generate_preview_skips_already_configured_case_insensitive() {
2301        let generator = SourceConfigGenerator::new();
2302        let probe = make_test_probe(
2303            true,
2304            vec![make_test_agent("claude", "~/.claude/projects")],
2305            Some(make_test_sys_info("linux", "/home/user")),
2306        );
2307
2308        let probes: Vec<(&str, &HostProbeResult)> = vec![("Laptop", &probe)];
2309        let mut configured = HashSet::new();
2310        configured.insert(source_name_key("laptop"));
2311
2312        let preview = generator.generate_preview(&probes, &configured);
2313        assert!(preview.sources_to_add.is_empty());
2314        assert_eq!(preview.sources_skipped.len(), 1);
2315    }
2316
2317    #[test]
2318    fn test_generate_preview_skips_already_configured_case_insensitively_with_raw_names() {
2319        let generator = SourceConfigGenerator::new();
2320        let probe = make_test_probe(
2321            true,
2322            vec![make_test_agent("claude", "~/.claude/projects")],
2323            Some(make_test_sys_info("linux", "/home/user")),
2324        );
2325
2326        let probes: Vec<(&str, &HostProbeResult)> = vec![("laptop", &probe)];
2327        let mut configured = HashSet::new();
2328        configured.insert("Laptop".to_string());
2329
2330        let preview = generator.generate_preview(&probes, &configured);
2331
2332        assert!(preview.sources_to_add.is_empty());
2333        assert_eq!(preview.sources_skipped.len(), 1);
2334        assert!(matches!(
2335            preview.sources_skipped[0].1,
2336            SkipReason::AlreadyConfigured
2337        ));
2338    }
2339
2340    #[test]
2341    fn test_generate_preview_preserves_already_configured_skip_for_invalid_probe_data() {
2342        let generator = SourceConfigGenerator::new();
2343        let probe = make_test_probe(
2344            true,
2345            vec![make_test_agent("claude", "bad\npath")],
2346            Some(make_test_sys_info("linux", "/home/user")),
2347        );
2348
2349        let probes: Vec<(&str, &HostProbeResult)> = vec![("server1", &probe)];
2350        let mut configured = HashSet::new();
2351        configured.insert("server1".to_string());
2352
2353        let preview = generator.generate_preview(&probes, &configured);
2354
2355        assert!(preview.sources_to_add.is_empty());
2356        assert_eq!(preview.sources_skipped.len(), 1);
2357        assert_eq!(preview.sources_skipped[0].0, "server1");
2358        assert!(matches!(
2359            preview.sources_skipped[0].1,
2360            SkipReason::AlreadyConfigured
2361        ));
2362    }
2363
2364    #[test]
2365    fn test_generate_preview_skips_conflicting_generated_names_case_insensitive() {
2366        let generator = SourceConfigGenerator::new();
2367        let probe = make_test_probe(
2368            true,
2369            vec![make_test_agent("claude", "~/.claude/projects")],
2370            Some(make_test_sys_info("linux", "/home/user")),
2371        );
2372
2373        let probes: Vec<(&str, &HostProbeResult)> = vec![("Laptop", &probe), ("laptop", &probe)];
2374        let preview = generator.generate_preview(&probes, &HashSet::new());
2375
2376        assert_eq!(preview.sources_to_add.len(), 1);
2377        assert_eq!(preview.sources_to_add[0].name, "Laptop");
2378        assert_eq!(preview.sources_skipped.len(), 1);
2379        assert_eq!(preview.sources_skipped[0].0, "laptop");
2380        assert!(matches!(
2381            &preview.sources_skipped[0].1,
2382            SkipReason::GeneratedNameConflict(name) if name == "laptop"
2383        ));
2384    }
2385
2386    #[test]
2387    fn test_generate_preview_invalid_source_does_not_shadow_later_valid_duplicate() {
2388        let generator = SourceConfigGenerator::new();
2389        let invalid_probe = make_test_probe(
2390            true,
2391            vec![make_test_agent("claude", "bad\npath")],
2392            Some(make_test_sys_info("linux", "/home/user")),
2393        );
2394        let valid_probe = make_test_probe(
2395            true,
2396            vec![make_test_agent("claude", "~/.claude/projects")],
2397            Some(make_test_sys_info("linux", "/home/user")),
2398        );
2399
2400        let probes: Vec<(&str, &HostProbeResult)> =
2401            vec![("Laptop", &invalid_probe), ("laptop", &valid_probe)];
2402        let preview = generator.generate_preview(&probes, &HashSet::new());
2403
2404        assert_eq!(preview.sources_to_add.len(), 1);
2405        assert_eq!(preview.sources_to_add[0].name, "laptop");
2406        assert_eq!(preview.sources_skipped.len(), 1);
2407        assert_eq!(preview.sources_skipped[0].0, "Laptop");
2408        assert!(matches!(
2409            &preview.sources_skipped[0].1,
2410            SkipReason::InvalidSourceDefinition(message)
2411                if message.contains("paths[0] cannot contain control characters")
2412        ));
2413    }
2414
2415    #[test]
2416    fn test_generate_preview_skips_invalid_generated_sources_before_merge() {
2417        let generator = SourceConfigGenerator::new();
2418        let invalid_host_probe = make_test_probe(
2419            true,
2420            vec![make_test_agent("claude", "~/.claude/projects")],
2421            Some(make_test_sys_info("linux", "/home/user")),
2422        );
2423        let invalid_path_probe = make_test_probe(
2424            true,
2425            vec![make_test_agent("claude", "bad\npath")],
2426            Some(make_test_sys_info("linux", "/home/user")),
2427        );
2428        let valid_probe = make_test_probe(
2429            true,
2430            vec![make_test_agent("claude", "~/.claude/projects")],
2431            Some(make_test_sys_info("linux", "/home/user")),
2432        );
2433
2434        let probes: Vec<(&str, &HostProbeResult)> = vec![
2435            ("bad host", &invalid_host_probe),
2436            ("path-host", &invalid_path_probe),
2437            ("server1", &valid_probe),
2438        ];
2439        let preview = generator.generate_preview(&probes, &HashSet::new());
2440
2441        assert_eq!(preview.sources_to_add.len(), 1);
2442        assert_eq!(preview.sources_to_add[0].name, "server1");
2443        assert_eq!(preview.sources_skipped.len(), 2);
2444        assert_eq!(preview.sources_skipped[0].0, "bad host");
2445        assert!(matches!(
2446            &preview.sources_skipped[0].1,
2447            SkipReason::InvalidSourceDefinition(message)
2448                if message.contains("SSH host cannot contain whitespace")
2449        ));
2450        assert_eq!(preview.sources_skipped[1].0, "path-host");
2451        assert!(matches!(
2452            &preview.sources_skipped[1].1,
2453            SkipReason::InvalidSourceDefinition(message)
2454                if message.contains("paths[0] cannot contain control characters")
2455        ));
2456
2457        let mut config = SourcesConfig::default();
2458        let (added, skipped) = config.merge_preview(&preview).unwrap();
2459        assert_eq!(added, 1);
2460        assert!(skipped.is_empty());
2461        assert_eq!(config.sources.len(), 1);
2462        assert_eq!(config.sources[0].name, "server1");
2463    }
2464
2465    #[test]
2466    fn test_merge_source() {
2467        let mut config = SourcesConfig::default();
2468        let source = SourceDefinition::ssh("new-server", "user@server");
2469
2470        let result = config.merge_source(source).unwrap();
2471        assert!(matches!(result, MergeResult::Added(_)));
2472        assert_eq!(config.sources.len(), 1);
2473    }
2474
2475    #[test]
2476    fn test_merge_source_already_exists() {
2477        let mut config = SourcesConfig::default();
2478        config.sources.push(SourceDefinition::ssh("server", "host"));
2479
2480        let source = SourceDefinition::ssh("server", "other-host");
2481        let result = config.merge_source(source).unwrap();
2482        assert!(matches!(result, MergeResult::AlreadyExists(_)));
2483        assert_eq!(config.sources.len(), 1);
2484    }
2485
2486    #[test]
2487    fn test_merge_source_already_exists_case_insensitive() {
2488        let mut config = SourcesConfig::default();
2489        config.sources.push(SourceDefinition::ssh("Server", "host"));
2490
2491        let source = SourceDefinition::ssh("server", "other-host");
2492        let result = config.merge_source(source).unwrap();
2493        assert!(matches!(result, MergeResult::AlreadyExists(_)));
2494        assert_eq!(config.sources.len(), 1);
2495    }
2496
2497    #[test]
2498    fn test_configured_names() {
2499        let mut config = SourcesConfig::default();
2500        config.sources.push(SourceDefinition::ssh("server1", "h1"));
2501        config.sources.push(SourceDefinition::ssh("server2", "h2"));
2502
2503        let names = config.configured_names();
2504        assert_eq!(names.len(), 2);
2505        assert!(names.contains("server1"));
2506        assert!(names.contains("server2"));
2507    }
2508
2509    #[test]
2510    fn test_exclude_and_include_agents_normalize_and_dedup() {
2511        let mut config = SourcesConfig::default();
2512
2513        assert!(config.exclude_agent_from_indexing(" OpenClaw ").unwrap());
2514        assert!(!config.exclude_agent_from_indexing("open-claw").unwrap());
2515        assert!(config.is_agent_disabled("openclaw"));
2516        assert_eq!(config.configured_disabled_agents(), vec!["openclaw"]);
2517
2518        assert!(config.include_agent_in_indexing("open_claw").unwrap());
2519        assert!(!config.is_agent_disabled("openclaw"));
2520        assert!(config.configured_disabled_agents().is_empty());
2521    }
2522
2523    #[test]
2524    fn test_exclude_agent_aliases_collapse_to_internal_connector_slug() {
2525        let mut config = SourcesConfig::default();
2526
2527        assert!(config.exclude_agent_from_indexing("claude-code").unwrap());
2528        assert!(config.is_agent_disabled("claude"));
2529        assert!(config.is_agent_disabled("claude_code"));
2530        assert_eq!(config.configured_disabled_agents(), vec!["claude"]);
2531    }
2532
2533    #[test]
2534    fn test_validate_rejects_empty_disabled_agent_entry() {
2535        let mut config = SourcesConfig::default();
2536        config.disabled_agents.push("   ".into());
2537        let err = config
2538            .validate()
2539            .expect_err("disabled_agents entry should fail");
2540        assert!(matches!(err, ConfigError::Validation(_)));
2541    }
2542
2543    #[test]
2544    fn test_sources_config_roundtrip_preserves_disabled_agents() {
2545        let mut config = SourcesConfig::default();
2546        config.exclude_agent_from_indexing("openclaw").unwrap();
2547        config.exclude_agent_from_indexing("claude-code").unwrap();
2548
2549        let serialized = toml::to_string_pretty(&config).unwrap();
2550        let deserialized: SourcesConfig = toml::from_str(&serialized).unwrap();
2551
2552        assert_eq!(
2553            deserialized.configured_disabled_agents(),
2554            vec!["claude", "openclaw"]
2555        );
2556    }
2557
2558    #[test]
2559    fn test_configured_name_keys_normalize_case() {
2560        let mut config = SourcesConfig::default();
2561        config.sources.push(SourceDefinition::ssh("Server1", "h1"));
2562        config.sources.push(SourceDefinition::ssh("server2", "h2"));
2563
2564        let names = config.configured_name_keys();
2565        assert_eq!(names.len(), 2);
2566        assert!(names.contains("server1"));
2567        assert!(names.contains("server2"));
2568    }
2569
2570    #[test]
2571    fn test_save_to_rejects_invalid_config() {
2572        let temp = tempfile::tempdir().expect("tempdir");
2573        let path = temp.path().join("sources.toml");
2574
2575        let mut config = SourcesConfig::default();
2576        config
2577            .sources
2578            .push(SourceDefinition::ssh("local", "user@host"));
2579
2580        let err = config
2581            .save_to(&path)
2582            .expect_err("save_to should reject invalid config");
2583        assert!(matches!(err, ConfigError::Validation(_)));
2584        assert!(!path.exists(), "invalid config should not be written");
2585    }
2586
2587    #[test]
2588    fn test_empty_remote_home_no_mappings() {
2589        let generator = SourceConfigGenerator::new();
2590        let mut sys_info = make_test_sys_info("linux", "");
2591        sys_info.remote_home = "".into();
2592
2593        let probe = make_test_probe(
2594            true,
2595            vec![make_test_agent("claude", "~/.claude/projects")],
2596            Some(sys_info),
2597        );
2598
2599        let source = generator.generate_source("server", &probe);
2600        assert!(source.path_mappings.is_empty());
2601    }
2602
2603    #[test]
2604    fn test_trailing_slash_remote_home_normalized() {
2605        let generator = SourceConfigGenerator::new();
2606        // Remote home with trailing slash should be normalized
2607        let mut sys_info = make_test_sys_info("linux", "/home/user/");
2608        sys_info.remote_home = "/home/user/".into(); // Explicitly set with trailing slash
2609
2610        let probe = make_test_probe(
2611            true,
2612            vec![make_test_agent("claude", "~/.claude/projects")],
2613            Some(sys_info),
2614        );
2615
2616        let source = generator.generate_source("server", &probe);
2617
2618        // Should have mappings without double slashes
2619        assert!(!source.path_mappings.is_empty());
2620        // The projects mapping should NOT have double slashes
2621        let projects_mapping = source
2622            .path_mappings
2623            .iter()
2624            .find(|m| m.from.contains("projects"));
2625        assert!(projects_mapping.is_some());
2626        // Check no double slashes
2627        assert!(
2628            !projects_mapping.unwrap().from.contains("//"),
2629            "Path mapping should not contain double slashes: {}",
2630            projects_mapping.unwrap().from
2631        );
2632    }
2633}