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