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 path_entry_exists(final_path)
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                    std::io::Error::other(format!(
1260                        "failed preparing backup {} before replacing {}: first error: {}; backup error: {}",
1261                        backup_path.display(),
1262                        final_path.display(),
1263                        first_err,
1264                        backup_err
1265                    ))
1266                })?;
1267                match std::fs::rename(temp_path, final_path) {
1268                    Ok(()) => sync_parent_directory(final_path),
1269                    Err(second_err) => {
1270                        let restore_result = std::fs::rename(&backup_path, final_path);
1271                        match restore_result {
1272                            Ok(()) => {
1273                                sync_parent_directory(final_path).map_err(|sync_err| {
1274                                    std::io::Error::other(format!(
1275                                        "failed replacing {} with {}: first error: {}; second error: {}; restored original file but failed syncing parent directory: {}",
1276                                        final_path.display(),
1277                                        temp_path.display(),
1278                                        first_err,
1279                                        second_err,
1280                                        sync_err
1281                                    ))
1282                                })?;
1283                                Err(std::io::Error::new(
1284                                    second_err.kind(),
1285                                    format!(
1286                                        "failed replacing {} with {}: first error: {}; second error: {}; restored original file",
1287                                        final_path.display(),
1288                                        temp_path.display(),
1289                                        first_err,
1290                                        second_err
1291                                    ),
1292                                ))
1293                            }
1294                            Err(restore_err) => Err(std::io::Error::other(format!(
1295                                "failed replacing {} with {}: first error: {}; second error: {}; restore error: {}; temp file retained at {}",
1296                                final_path.display(),
1297                                temp_path.display(),
1298                                first_err,
1299                                second_err,
1300                                restore_err,
1301                                temp_path.display()
1302                            ))),
1303                        }
1304                    }
1305                }
1306            }
1307            Err(rename_err) => Err(rename_err),
1308        }
1309    }
1310
1311    #[cfg(not(windows))]
1312    {
1313        std::fs::rename(temp_path, final_path)?;
1314        sync_parent_directory(final_path)
1315    }
1316}
1317
1318#[cfg(any(windows, test))]
1319fn path_entry_exists(path: &Path) -> bool {
1320    match std::fs::symlink_metadata(path) {
1321        Ok(_) => true,
1322        Err(err) if err.kind() == std::io::ErrorKind::NotFound => false,
1323        Err(_) => true,
1324    }
1325}
1326
1327#[cfg(not(windows))]
1328fn sync_parent_directory(path: &Path) -> Result<(), std::io::Error> {
1329    let Some(parent) = path.parent() else {
1330        return Ok(());
1331    };
1332    std::fs::File::open(parent)?.sync_all()
1333}
1334
1335#[cfg(windows)]
1336fn sync_parent_directory(_path: &Path) -> Result<(), std::io::Error> {
1337    Ok(())
1338}
1339
1340fn unique_atomic_temp_path(path: &Path) -> PathBuf {
1341    unique_atomic_sidecar_path(path, "tmp", "sources.toml")
1342}
1343
1344fn write_sources_config_temp_file(path: &Path, contents: &[u8]) -> Result<PathBuf, std::io::Error> {
1345    for _ in 0..100 {
1346        let temp_path = unique_atomic_temp_path(path);
1347        match write_sources_config_temp_file_at(&temp_path, contents) {
1348            Ok(()) => return Ok(temp_path),
1349            Err(err) if err.kind() == std::io::ErrorKind::AlreadyExists => continue,
1350            Err(err) => return Err(err),
1351        }
1352    }
1353
1354    Err(std::io::Error::new(
1355        std::io::ErrorKind::AlreadyExists,
1356        format!(
1357            "failed to allocate unique sources config temp path for {}",
1358            path.display()
1359        ),
1360    ))
1361}
1362
1363fn write_sources_config_temp_file_at(path: &Path, contents: &[u8]) -> Result<(), std::io::Error> {
1364    use std::io::Write;
1365
1366    let mut file = std::fs::OpenOptions::new()
1367        .write(true)
1368        .create_new(true)
1369        .open(path)?;
1370    file.write_all(contents)?;
1371    file.sync_all()
1372}
1373
1374fn unique_backup_path(path: &Path) -> PathBuf {
1375    static NEXT_NONCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1376
1377    let timestamp = std::time::SystemTime::now()
1378        .duration_since(std::time::UNIX_EPOCH)
1379        .unwrap_or_default()
1380        .as_nanos();
1381    let nonce = NEXT_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1382    let file_name = path
1383        .file_name()
1384        .and_then(|name| name.to_str())
1385        .unwrap_or("sources.toml");
1386
1387    path.with_file_name(format!(
1388        "{file_name}.backup.{}.{}.{}",
1389        std::process::id(),
1390        timestamp,
1391        nonce
1392    ))
1393}
1394
1395#[cfg(windows)]
1396fn unique_replace_backup_path(path: &Path) -> PathBuf {
1397    unique_atomic_sidecar_path(path, "bak", "sources.toml")
1398}
1399
1400fn unique_atomic_sidecar_path(path: &Path, suffix: &str, fallback_name: &str) -> PathBuf {
1401    static NEXT_NONCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
1402
1403    let timestamp = std::time::SystemTime::now()
1404        .duration_since(std::time::UNIX_EPOCH)
1405        .unwrap_or_default()
1406        .as_nanos();
1407    let nonce = NEXT_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
1408    let file_name = path
1409        .file_name()
1410        .and_then(|name| name.to_str())
1411        .unwrap_or(fallback_name);
1412
1413    path.with_file_name(format!(
1414        ".{file_name}.{suffix}.{}.{}.{}",
1415        std::process::id(),
1416        timestamp,
1417        nonce
1418    ))
1419}
1420
1421#[cfg(test)]
1422mod tests {
1423    use super::*;
1424
1425    #[test]
1426    fn test_empty_config_default() {
1427        let config = SourcesConfig::default();
1428        assert!(config.sources.is_empty());
1429    }
1430
1431    #[test]
1432    fn test_replace_file_from_temp_overwrites_existing_file() {
1433        let temp = tempfile::tempdir().expect("tempdir");
1434        let final_path = temp.path().join("sources.toml");
1435        let first_tmp = temp.path().join("first.tmp");
1436        let second_tmp = temp.path().join("second.tmp");
1437
1438        std::fs::write(&first_tmp, "first = true\n").expect("write first temp");
1439        replace_file_from_temp(&first_tmp, &final_path).expect("initial replace");
1440        assert_eq!(
1441            std::fs::read_to_string(&final_path).expect("read first final"),
1442            "first = true\n"
1443        );
1444
1445        std::fs::write(&second_tmp, "second = true\n").expect("write second temp");
1446        replace_file_from_temp(&second_tmp, &final_path).expect("overwrite replace");
1447        assert_eq!(
1448            std::fs::read_to_string(&final_path).expect("read second final"),
1449            "second = true\n"
1450        );
1451    }
1452
1453    #[test]
1454    fn test_unique_atomic_temp_path_changes_each_call() {
1455        let final_path = Path::new("/tmp/sources.toml");
1456        let first = unique_atomic_temp_path(final_path);
1457        let second = unique_atomic_temp_path(final_path);
1458
1459        assert_ne!(first, second);
1460        assert_eq!(first.parent(), final_path.parent());
1461        assert_eq!(second.parent(), final_path.parent());
1462    }
1463
1464    #[cfg(unix)]
1465    #[test]
1466    fn test_sources_config_temp_write_refuses_existing_symlink() {
1467        use std::os::unix::fs::symlink;
1468
1469        let temp = tempfile::tempdir().expect("tempdir");
1470        let protected = temp.path().join("protected.toml");
1471        let temp_path = temp.path().join(".sources.toml.tmp");
1472
1473        std::fs::write(&protected, b"protected = true\n").expect("write protected target");
1474        symlink(&protected, &temp_path).expect("create temp symlink");
1475
1476        let err = write_sources_config_temp_file_at(&temp_path, b"[[sources]]\n")
1477            .expect_err("existing temp symlink must be rejected");
1478
1479        assert_eq!(err.kind(), std::io::ErrorKind::AlreadyExists);
1480        assert_eq!(
1481            std::fs::read(&protected).expect("read protected target"),
1482            b"protected = true\n"
1483        );
1484        assert!(
1485            std::fs::symlink_metadata(&temp_path)
1486                .expect("temp path metadata")
1487                .file_type()
1488                .is_symlink(),
1489            "failed temp write should leave the existing symlink untouched"
1490        );
1491    }
1492
1493    #[test]
1494    fn test_unique_backup_path_changes_each_call() {
1495        let final_path = Path::new("/tmp/sources.toml");
1496        let first = unique_backup_path(final_path);
1497        let second = unique_backup_path(final_path);
1498
1499        assert_ne!(first, second);
1500        assert_eq!(first.parent(), final_path.parent());
1501        assert_eq!(second.parent(), final_path.parent());
1502    }
1503
1504    #[cfg(unix)]
1505    #[test]
1506    fn test_replace_file_from_temp_replaces_symlink_without_following() {
1507        use std::os::unix::fs::symlink;
1508
1509        let temp = tempfile::tempdir().expect("tempdir");
1510        let final_path = temp.path().join("sources.toml");
1511        let protected = temp.path().join("protected.toml");
1512        let temp_path = temp.path().join("sources.tmp");
1513
1514        std::fs::write(&protected, b"protected = true\n").expect("write protected target");
1515        symlink(&protected, &final_path).expect("create final symlink");
1516        std::fs::write(&temp_path, b"new_config = true\n").expect("write replacement temp");
1517
1518        replace_file_from_temp(&temp_path, &final_path).expect("replace symlink path");
1519
1520        assert_eq!(
1521            std::fs::read(&protected).expect("read protected target"),
1522            b"protected = true\n"
1523        );
1524        assert!(
1525            !std::fs::symlink_metadata(&final_path)
1526                .expect("final path metadata")
1527                .file_type()
1528                .is_symlink(),
1529            "replace should publish at the symlink path instead of following it"
1530        );
1531        assert_eq!(
1532            std::fs::read(&final_path).expect("read replacement config"),
1533            b"new_config = true\n"
1534        );
1535    }
1536
1537    #[cfg(unix)]
1538    #[test]
1539    fn test_path_entry_exists_detects_dangling_symlink() {
1540        use std::os::unix::fs::symlink;
1541
1542        let temp = tempfile::tempdir().expect("tempdir");
1543        let path = temp.path().join("sources.toml");
1544        let missing_target = temp.path().join("missing.toml");
1545
1546        symlink(&missing_target, &path).expect("create dangling symlink");
1547
1548        assert!(!path.exists(), "Path::exists follows the missing target");
1549        assert!(
1550            path_entry_exists(&path),
1551            "replacement fallback must detect the symlink path entry itself"
1552        );
1553    }
1554
1555    #[test]
1556    fn test_config_path_from_parts_prefers_xdg_config_home() {
1557        let temp = tempfile::tempdir().expect("tempdir");
1558        let xdg_config_home = temp.path().join("xdg-config");
1559        let platform_config_dir = temp.path().join("platform-config");
1560        let home_dir = temp.path().join("home");
1561
1562        assert_eq!(
1563            config_path_from_parts(
1564                Some(xdg_config_home.clone()),
1565                Some(platform_config_dir),
1566                Some(home_dir)
1567            )
1568            .expect("path from xdg config home"),
1569            xdg_config_home.join("cass").join("sources.toml")
1570        );
1571    }
1572
1573    #[test]
1574    fn test_config_path_from_parts_prefers_existing_platform_path_before_dot_config() {
1575        let temp = tempfile::tempdir().expect("tempdir");
1576        let platform_config_dir = temp.path().join("platform-config");
1577        let platform_path = platform_config_dir.join("cass").join("sources.toml");
1578        let home_dir = temp.path().join("home");
1579        let dot_config_path = home_dir.join(".config").join("cass").join("sources.toml");
1580        std::fs::create_dir_all(platform_path.parent().expect("platform parent")).unwrap();
1581        std::fs::create_dir_all(dot_config_path.parent().expect("dot-config parent")).unwrap();
1582        std::fs::write(&platform_path, "").unwrap();
1583        std::fs::write(&dot_config_path, "").unwrap();
1584
1585        assert_eq!(
1586            config_path_from_parts(None, Some(platform_config_dir), Some(home_dir))
1587                .expect("existing platform path"),
1588            platform_path
1589        );
1590    }
1591
1592    #[test]
1593    fn test_config_path_from_parts_uses_existing_dot_config_before_new_platform_path() {
1594        let temp = tempfile::tempdir().expect("tempdir");
1595        let platform_config_dir = temp.path().join("platform-config");
1596        let home_dir = temp.path().join("home");
1597        let dot_config_path = home_dir.join(".config").join("cass").join("sources.toml");
1598        std::fs::create_dir_all(dot_config_path.parent().expect("dot-config parent")).unwrap();
1599        std::fs::write(&dot_config_path, "").unwrap();
1600
1601        assert_eq!(
1602            config_path_from_parts(None, Some(platform_config_dir), Some(home_dir))
1603                .expect("existing dot-config path"),
1604            dot_config_path
1605        );
1606    }
1607
1608    #[test]
1609    fn test_source_definition_local() {
1610        let source = SourceDefinition::local("test");
1611        assert_eq!(source.name, "test");
1612        assert_eq!(source.source_type, SourceKind::Local);
1613        assert!(!source.is_remote());
1614    }
1615
1616    #[test]
1617    fn test_source_definition_ssh() {
1618        let source = SourceDefinition::ssh("laptop", "user@laptop.local");
1619        assert_eq!(source.name, "laptop");
1620        assert_eq!(source.source_type, SourceKind::Ssh);
1621        assert_eq!(source.host, Some("user@laptop.local".into()));
1622        assert!(source.is_remote());
1623    }
1624
1625    #[test]
1626    fn test_source_validation_empty_name() {
1627        let source = SourceDefinition::default();
1628        assert!(source.validate().is_err());
1629
1630        let source = SourceDefinition::local("   ");
1631        assert!(source.validate().is_err());
1632    }
1633
1634    #[test]
1635    fn test_source_validation_rejects_padded_names() {
1636        let source = SourceDefinition::local(" laptop");
1637        assert!(source.validate().is_err());
1638
1639        let source = SourceDefinition::local("laptop ");
1640        assert!(source.validate().is_err());
1641    }
1642
1643    #[test]
1644    fn test_source_validation_dot_names() {
1645        let source = SourceDefinition::local(".");
1646        assert!(source.validate().is_err());
1647
1648        let source = SourceDefinition::local("..");
1649        assert!(source.validate().is_err());
1650    }
1651
1652    #[test]
1653    fn test_source_validation_reserved_local_name() {
1654        let source = SourceDefinition::ssh("local", "user@host");
1655        assert!(source.validate().is_err());
1656
1657        let source = SourceDefinition::ssh("LOCAL", "user@host");
1658        assert!(source.validate().is_err());
1659    }
1660
1661    #[test]
1662    fn test_normalize_generated_remote_source_name_disambiguates_local() {
1663        assert_eq!(normalize_generated_remote_source_name("local"), "local-ssh");
1664        assert_eq!(normalize_generated_remote_source_name("LOCAL"), "LOCAL-ssh");
1665        assert_eq!(
1666            normalize_generated_remote_source_name(" local "),
1667            "local-ssh"
1668        );
1669        assert_eq!(normalize_generated_remote_source_name("laptop"), "laptop");
1670        assert_eq!(normalize_generated_remote_source_name(" laptop "), "laptop");
1671    }
1672
1673    #[test]
1674    fn test_source_validation_ssh_without_host() {
1675        let mut source = SourceDefinition::ssh("test", "host");
1676        source.host = None;
1677        assert!(source.validate().is_err());
1678    }
1679
1680    #[test]
1681    fn test_source_validation_ssh_host_hardening() {
1682        let source = SourceDefinition::ssh("test", "user-name_1@host-name.example");
1683        assert!(source.validate().is_ok());
1684
1685        let source = SourceDefinition::ssh("test", "ssh-config-alias");
1686        assert!(source.validate().is_ok());
1687
1688        let source = SourceDefinition::ssh("test", "-oProxyCommand=evil");
1689        assert!(source.validate().is_err());
1690
1691        let source = SourceDefinition::ssh("test", "user@host withspace");
1692        assert!(source.validate().is_err());
1693
1694        for host in [
1695            " user@host",
1696            "user@host ",
1697            "\tuser@host",
1698            "user@host;touch /tmp/cass-owned",
1699            "user@host`hostname`",
1700            "user@host$(hostname)",
1701            "user@host/../../secret",
1702            "user@host:2222",
1703            "üser@host",
1704            "@host",
1705            "user@",
1706            "user@host@extra",
1707        ] {
1708            let source = SourceDefinition::ssh("test", host);
1709            assert!(
1710                source.validate().is_err(),
1711                "host should be rejected: {host:?}"
1712            );
1713        }
1714    }
1715
1716    #[test]
1717    fn test_source_validation_rejects_invalid_paths() {
1718        for path in [
1719            "",
1720            "   ",
1721            " ~/.claude/projects",
1722            "~/.claude/projects ",
1723            "~/.claude\nprojects",
1724        ] {
1725            let mut source = SourceDefinition::ssh("test", "user@host");
1726            source.paths = vec![path.to_string()];
1727            assert!(
1728                source.validate().is_err(),
1729                "path should be rejected: {path:?}"
1730            );
1731        }
1732
1733        let mut source = SourceDefinition::ssh("test", "user@host");
1734        source.paths = vec!["~/Library/Application Support/Cursor/User/globalStorage".to_string()];
1735        assert!(source.validate().is_ok());
1736    }
1737
1738    #[test]
1739    fn test_load_from_preserves_invalid_paths_for_operation_level_reporting() {
1740        let temp = tempfile::tempdir().expect("tempdir");
1741        let config_path = temp.path().join("sources.toml");
1742        std::fs::write(
1743            &config_path,
1744            r#"
1745[[sources]]
1746name = "laptop"
1747type = "ssh"
1748host = "user@host"
1749paths = [" ~/.claude/projects", "~/.codex/sessions"]
1750"#,
1751        )
1752        .expect("write config");
1753
1754        let loaded = SourcesConfig::load_from(&config_path).expect("lenient load");
1755        assert_eq!(loaded.sources.len(), 1);
1756        assert_eq!(loaded.sources[0].paths[0], " ~/.claude/projects");
1757        assert_eq!(loaded.sources[0].paths[1], "~/.codex/sessions");
1758        assert!(
1759            loaded.validate().is_err(),
1760            "strict validation should still reject writing the malformed path"
1761        );
1762    }
1763
1764    #[test]
1765    fn test_load_from_still_rejects_invalid_source_structure() {
1766        let temp = tempfile::tempdir().expect("tempdir");
1767        let config_path = temp.path().join("sources.toml");
1768        std::fs::write(
1769            &config_path,
1770            r#"
1771[[sources]]
1772name = "laptop"
1773type = "ssh"
1774host = "user@host withspace"
1775paths = ["~/.claude/projects"]
1776"#,
1777        )
1778        .expect("write config");
1779
1780        assert!(
1781            SourcesConfig::load_from(&config_path).is_err(),
1782            "lenient load is only for per-path validation, not unsafe host structure"
1783        );
1784    }
1785
1786    #[test]
1787    fn test_source_validation_path_mapping_empty_from() {
1788        let mut source = SourceDefinition::local("test");
1789        source.path_mappings.push(PathMapping::new("", "/Users/me"));
1790        assert!(source.validate().is_err());
1791
1792        source.path_mappings.clear();
1793        source
1794            .path_mappings
1795            .push(PathMapping::new("   ", "/Users/me"));
1796        assert!(source.validate().is_err());
1797    }
1798
1799    #[test]
1800    fn test_source_validation_path_mapping_empty_to() {
1801        let mut source = SourceDefinition::local("test");
1802        source
1803            .path_mappings
1804            .push(PathMapping::new("/home/user", ""));
1805        assert!(source.validate().is_err());
1806
1807        source.path_mappings.clear();
1808        source
1809            .path_mappings
1810            .push(PathMapping::new("/home/user", "   "));
1811        assert!(source.validate().is_err());
1812    }
1813
1814    #[test]
1815    fn test_source_validation_path_mapping_empty_agent_names() {
1816        let mut source = SourceDefinition::local("test");
1817        source.path_mappings.push(PathMapping::with_agents(
1818            "/home/user",
1819            "/Users/me",
1820            vec!["claude-code".into(), "   ".into()],
1821        ));
1822        assert!(source.validate().is_err());
1823    }
1824
1825    #[test]
1826    fn test_source_validation_path_mapping_empty_agents_list() {
1827        let mut source = SourceDefinition::local("test");
1828        source.path_mappings.push(PathMapping::with_agents(
1829            "/home/user",
1830            "/Users/me",
1831            Vec::new(),
1832        ));
1833        assert!(source.validate().is_err());
1834    }
1835
1836    #[test]
1837    fn test_path_mapping_new() {
1838        let mapping = PathMapping::new("/home/user", "/Users/me");
1839        assert_eq!(mapping.from, "/home/user");
1840        assert_eq!(mapping.to, "/Users/me");
1841        assert!(mapping.agents.is_none());
1842    }
1843
1844    #[test]
1845    fn test_path_mapping_with_agents() {
1846        let mapping = PathMapping::with_agents(
1847            "/home/user",
1848            "/Users/me",
1849            vec!["claude-code".into(), "cursor".into()],
1850        );
1851        assert_eq!(mapping.from, "/home/user");
1852        assert_eq!(mapping.to, "/Users/me");
1853        assert_eq!(
1854            mapping.agents,
1855            Some(vec!["claude-code".into(), "cursor".into()])
1856        );
1857    }
1858
1859    #[test]
1860    fn test_path_mapping_apply() {
1861        let mapping = PathMapping::new("/home/user/projects", "/Users/me/projects");
1862
1863        // Matching prefix
1864        assert_eq!(
1865            mapping.apply("/home/user/projects/myapp"),
1866            Some("/Users/me/projects/myapp".into())
1867        );
1868
1869        // Non-matching prefix
1870        assert_eq!(mapping.apply("/opt/data"), None);
1871
1872        // Partial match (not at start)
1873        assert_eq!(mapping.apply("/data/home/user/projects"), None);
1874    }
1875
1876    #[test]
1877    fn test_path_mapping_applies_to_agent() {
1878        // This test pins the semantics of the *cass wrapper*
1879        // (`path_mapping_applies_to_agent`) rather than the upstream
1880        // `PathMapping::applies_to_agent` method. Cass intentionally uses a
1881        // permissive wrapper: when the caller doesn't specify an agent, even
1882        // mappings that are scoped to a specific agent still apply. Upstream
1883        // (`franken_agent_detection`) uses a stricter default (`(Some, None)
1884        // => false`) because its scan-time usage wants to skip
1885        // agent-specific mappings when the agent is unknown. Both semantics
1886        // are correct in their own context; cass's tests must exercise the
1887        // cass wrapper to avoid coupling to whichever default franken picks.
1888
1889        // Mapping with no agent filter — applies in every case.
1890        let global = PathMapping::new("/home", "/Users");
1891        assert!(path_mapping_applies_to_agent(&global, None));
1892        assert!(path_mapping_applies_to_agent(&global, Some("claude-code")));
1893        assert!(path_mapping_applies_to_agent(&global, Some("any-agent")));
1894
1895        // Mapping with agent filter.
1896        let filtered = PathMapping::with_agents("/home", "/Users", vec!["claude-code".into()]);
1897        // No agent specified → cass wrapper matches (permissive default).
1898        assert!(path_mapping_applies_to_agent(&filtered, None));
1899        // An explicitly empty allow-list is invalid config and should not match
1900        // defensively if one is constructed in code.
1901        let empty_filter = PathMapping::with_agents("/home", "/Users", Vec::new());
1902        assert!(!path_mapping_applies_to_agent(&empty_filter, None));
1903        // Agent matches the allow-list.
1904        assert!(path_mapping_applies_to_agent(
1905            &filtered,
1906            Some("claude-code")
1907        ));
1908        // Agent not in the allow-list.
1909        assert!(!path_mapping_applies_to_agent(&filtered, Some("cursor")));
1910        // Hyphen/underscore normalization: `claude_code` must match the
1911        // allow-list entry `claude-code` because cass normalizes agent slugs
1912        // before comparison.
1913        assert!(path_mapping_applies_to_agent(
1914            &filtered,
1915            Some("claude_code")
1916        ));
1917        assert!(path_mapping_applies_to_agent(&filtered, Some("claude")));
1918
1919        let openclaw_filtered =
1920            PathMapping::with_agents("/home", "/Users", vec!["openclaw".into()]);
1921        assert!(path_mapping_applies_to_agent(
1922            &openclaw_filtered,
1923            Some("open-claw")
1924        ));
1925    }
1926
1927    #[test]
1928    fn test_path_rewriting() {
1929        let mut source = SourceDefinition::local("test");
1930        source.path_mappings.push(PathMapping::new(
1931            "/home/user/projects",
1932            "/Users/me/projects",
1933        ));
1934        source
1935            .path_mappings
1936            .push(PathMapping::new("/home/user", "/Users/me"));
1937
1938        // Longest prefix should match
1939        assert_eq!(
1940            source.rewrite_path("/home/user/projects/myapp"),
1941            "/Users/me/projects/myapp"
1942        );
1943
1944        // Shorter prefix
1945        assert_eq!(source.rewrite_path("/home/user/other"), "/Users/me/other");
1946
1947        // No match
1948        assert_eq!(source.rewrite_path("/opt/data"), "/opt/data");
1949    }
1950
1951    #[test]
1952    fn test_path_rewriting_with_agent_filter() {
1953        let mut source = SourceDefinition::local("test");
1954        // Global mapping
1955        source
1956            .path_mappings
1957            .push(PathMapping::new("/home/user", "/Users/me"));
1958        // Agent-specific mapping
1959        source.path_mappings.push(PathMapping::with_agents(
1960            "/home/user/projects",
1961            "/Volumes/Work/projects",
1962            vec!["claude-code".into()],
1963        ));
1964
1965        // Without agent filter, both mappings apply (longest match wins)
1966        assert_eq!(
1967            source.rewrite_path_for_agent("/home/user/projects/app", None),
1968            "/Volumes/Work/projects/app"
1969        );
1970
1971        // With claude-code agent, use specific mapping
1972        assert_eq!(
1973            source.rewrite_path_for_agent("/home/user/projects/app", Some("claude-code")),
1974            "/Volumes/Work/projects/app"
1975        );
1976        assert_eq!(
1977            source.rewrite_path_for_agent("/home/user/projects/app", Some("claude")),
1978            "/Volumes/Work/projects/app"
1979        );
1980
1981        // With cursor agent, falls back to global mapping
1982        assert_eq!(
1983            source.rewrite_path_for_agent("/home/user/projects/app", Some("cursor")),
1984            "/Users/me/projects/app"
1985        );
1986
1987        // Non-matching path
1988        assert_eq!(
1989            source.rewrite_path_for_agent("/opt/data", Some("claude-code")),
1990            "/opt/data"
1991        );
1992    }
1993
1994    #[test]
1995    fn test_config_duplicate_names() {
1996        let mut config = SourcesConfig::default();
1997        config.sources.push(SourceDefinition::local("test"));
1998        config.sources.push(SourceDefinition::local("test"));
1999
2000        assert!(config.validate().is_err());
2001    }
2002
2003    #[test]
2004    fn test_config_duplicate_names_case_insensitive() {
2005        let mut config = SourcesConfig::default();
2006        config
2007            .sources
2008            .push(SourceDefinition::ssh("Laptop", "user@laptop"));
2009        config
2010            .sources
2011            .push(SourceDefinition::ssh("laptop", "user@other-host"));
2012
2013        assert!(config.validate().is_err());
2014    }
2015
2016    #[test]
2017    fn test_source_name_keys_trim_and_ignore_case() {
2018        assert_eq!(source_name_key(" Laptop "), "laptop");
2019        assert!(source_names_equal(" Laptop ", "laptop"));
2020    }
2021
2022    #[test]
2023    fn test_config_add_source() {
2024        let mut config = SourcesConfig::default();
2025        config.add_source(SourceDefinition::local("test")).unwrap();
2026
2027        assert_eq!(config.sources.len(), 1);
2028
2029        // Adding duplicate should fail
2030        assert!(config.add_source(SourceDefinition::local("test")).is_err());
2031    }
2032
2033    #[test]
2034    fn test_config_add_source_case_insensitive_duplicate() {
2035        let mut config = SourcesConfig::default();
2036        config
2037            .add_source(SourceDefinition::ssh("Laptop", "user@laptop"))
2038            .unwrap();
2039
2040        assert!(
2041            config
2042                .add_source(SourceDefinition::ssh("laptop", "user@other-host"))
2043                .is_err()
2044        );
2045    }
2046
2047    #[test]
2048    fn test_config_remove_source() {
2049        let mut config = SourcesConfig::default();
2050        config.sources.push(SourceDefinition::local("test"));
2051
2052        assert!(config.remove_source("test"));
2053        assert!(!config.remove_source("nonexistent"));
2054        assert!(config.sources.is_empty());
2055    }
2056
2057    #[test]
2058    fn test_config_remove_source_case_insensitive() {
2059        let mut config = SourcesConfig::default();
2060        config
2061            .sources
2062            .push(SourceDefinition::ssh("Laptop", "user@laptop"));
2063
2064        assert!(config.remove_source("laptop"));
2065        assert!(config.sources.is_empty());
2066    }
2067
2068    #[test]
2069    fn test_find_source_case_insensitive() {
2070        let mut config = SourcesConfig::default();
2071        config
2072            .sources
2073            .push(SourceDefinition::ssh("Laptop", "user@laptop"));
2074
2075        assert!(config.find_source("laptop").is_some());
2076        assert!(config.find_source("LAPTOP").is_some());
2077        assert!(config.find_source_mut("laptop").is_some());
2078    }
2079
2080    #[test]
2081    fn test_config_serialization_roundtrip() {
2082        let mut config = SourcesConfig::default();
2083        config.sources.push(SourceDefinition {
2084            name: "laptop".into(),
2085            source_type: SourceKind::Ssh,
2086            host: Some("user@laptop.local".into()),
2087            paths: vec!["~/.claude/projects".into()],
2088            sync_schedule: SyncSchedule::Daily,
2089            path_mappings: vec![PathMapping::new("/home/user", "/Users/me")],
2090            platform: Some(Platform::Linux),
2091        });
2092
2093        let serialized = toml::to_string_pretty(&config).unwrap();
2094        let deserialized: SourcesConfig = toml::from_str(&serialized).unwrap();
2095
2096        assert_eq!(deserialized.sources.len(), 1);
2097        assert_eq!(deserialized.sources[0].name, "laptop");
2098        assert_eq!(deserialized.sources[0].sync_schedule, SyncSchedule::Daily);
2099        assert_eq!(deserialized.sources[0].path_mappings.len(), 1);
2100        assert_eq!(deserialized.sources[0].path_mappings[0].from, "/home/user");
2101        assert_eq!(deserialized.sources[0].path_mappings[0].to, "/Users/me");
2102    }
2103
2104    #[test]
2105    fn test_path_mapping_serialization_with_agents() {
2106        let mut config = SourcesConfig::default();
2107        config.sources.push(SourceDefinition {
2108            name: "remote".into(),
2109            source_type: SourceKind::Ssh,
2110            host: Some("user@server".into()),
2111            paths: vec![],
2112            sync_schedule: SyncSchedule::Manual,
2113            path_mappings: vec![
2114                PathMapping::new("/home/user", "/Users/me"),
2115                PathMapping::with_agents("/opt/work", "/Volumes/Work", vec!["claude-code".into()]),
2116            ],
2117            platform: None,
2118        });
2119
2120        let serialized = toml::to_string_pretty(&config).unwrap();
2121        let deserialized: SourcesConfig = toml::from_str(&serialized).unwrap();
2122
2123        assert_eq!(deserialized.sources[0].path_mappings.len(), 2);
2124        // First mapping has no agents filter
2125        assert!(deserialized.sources[0].path_mappings[0].agents.is_none());
2126        // Second mapping has agents filter
2127        assert_eq!(
2128            deserialized.sources[0].path_mappings[1].agents,
2129            Some(vec!["claude-code".into()])
2130        );
2131    }
2132
2133    #[test]
2134    fn test_preset_paths() {
2135        let macos = get_preset_paths("macos-defaults").unwrap();
2136        assert!(!macos.is_empty());
2137        assert!(macos.iter().any(|p| p.contains(".claude")));
2138
2139        let linux = get_preset_paths("linux-defaults").unwrap();
2140        assert!(!linux.is_empty());
2141
2142        assert!(get_preset_paths("unknown").is_err());
2143    }
2144
2145    #[test]
2146    fn test_sync_schedule_display() {
2147        assert_eq!(SyncSchedule::Manual.to_string(), SYNC_SCHEDULE_MANUAL);
2148        assert_eq!(SyncSchedule::Hourly.to_string(), SYNC_SCHEDULE_HOURLY);
2149        assert_eq!(SyncSchedule::Daily.to_string(), SYNC_SCHEDULE_DAILY);
2150    }
2151
2152    #[test]
2153    fn test_discover_ssh_hosts() {
2154        // Just test that the function doesn't panic
2155        let hosts = super::discover_ssh_hosts();
2156        // Could be empty if no ~/.ssh/config exists
2157        for host in hosts {
2158            assert!(!host.name.is_empty());
2159        }
2160    }
2161
2162    #[test]
2163    fn test_parse_ssh_config_splits_multiple_host_aliases() {
2164        let hosts = super::parse_ssh_config(
2165            r#"
2166Host alpha beta *.internal ?wild
2167  HostName 192.0.2.10
2168  User ubuntu
2169  Port 2222
2170  IdentityFile ~/.ssh/id_ed25519
2171
2172Host gamma
2173  User deploy
2174"#,
2175        );
2176
2177        assert_eq!(hosts.len(), 3);
2178        assert_eq!(hosts[0].name, "alpha");
2179        assert_eq!(hosts[1].name, "beta");
2180        assert_eq!(hosts[2].name, "gamma");
2181        for host in &hosts[..2] {
2182            assert_eq!(host.hostname.as_deref(), Some("192.0.2.10"));
2183            assert_eq!(host.user.as_deref(), Some("ubuntu"));
2184            assert_eq!(host.port, Some(2222));
2185            assert_eq!(host.identity_file.as_deref(), Some("~/.ssh/id_ed25519"));
2186        }
2187        assert_eq!(hosts[2].user.as_deref(), Some("deploy"));
2188    }
2189
2190    #[test]
2191    fn test_parse_ssh_config_skips_negated_host_patterns() {
2192        let hosts = super::parse_ssh_config(
2193            r#"
2194Host * !bastion staging
2195  User ubuntu
2196
2197Host production !legacy-prod
2198  User deploy
2199"#,
2200        );
2201
2202        assert_eq!(hosts.len(), 2);
2203        assert_eq!(hosts[0].name, "staging");
2204        assert_eq!(hosts[0].user.as_deref(), Some("ubuntu"));
2205        assert_eq!(hosts[1].name, "production");
2206        assert_eq!(hosts[1].user.as_deref(), Some("deploy"));
2207    }
2208
2209    #[test]
2210    fn test_parse_ssh_config() {
2211        let content = "
2212            Host example
2213                HostName example.com
2214                User testuser
2215
2216            Host=another
2217                Port=2222
2218                IdentityFile = ~/.ssh/id_rsa
2219        ";
2220        let hosts = parse_ssh_config(content);
2221        assert_eq!(hosts.len(), 2);
2222        assert_eq!(hosts[0].name, "example");
2223        assert_eq!(hosts[0].hostname.as_deref(), Some("example.com"));
2224        assert_eq!(hosts[0].user.as_deref(), Some("testuser"));
2225
2226        assert_eq!(hosts[1].name, "another");
2227        assert_eq!(hosts[1].port, Some(2222));
2228        assert_eq!(hosts[1].identity_file.as_deref(), Some("~/.ssh/id_rsa"));
2229    }
2230
2231    // ==========================================================================
2232    // Source Config Generator Tests
2233    // ==========================================================================
2234
2235    use super::super::probe::{CassStatus, DetectedAgent, HostProbeResult, SystemInfo};
2236
2237    fn make_test_probe(
2238        reachable: bool,
2239        agents: Vec<DetectedAgent>,
2240        sys_info: Option<SystemInfo>,
2241    ) -> HostProbeResult {
2242        HostProbeResult {
2243            host_name: "test-host".into(),
2244            reachable,
2245            connection_time_ms: 100,
2246            cass_status: CassStatus::NotFound,
2247            detected_agents: agents,
2248            system_info: sys_info,
2249            resources: None,
2250            error: if reachable {
2251                None
2252            } else {
2253                Some("connection refused".into())
2254            },
2255        }
2256    }
2257
2258    fn make_test_agent(agent_type: &str, path: &str) -> DetectedAgent {
2259        DetectedAgent {
2260            agent_type: agent_type.into(),
2261            path: path.into(),
2262            estimated_sessions: Some(100),
2263            estimated_size_mb: Some(50),
2264        }
2265    }
2266
2267    fn make_test_sys_info(os: &str, remote_home: &str) -> SystemInfo {
2268        SystemInfo {
2269            os: os.into(),
2270            arch: "x86_64".into(),
2271            distro: Some("Ubuntu 22.04".into()),
2272            has_cargo: true,
2273            has_cargo_binstall: true,
2274            has_curl: true,
2275            has_wget: true,
2276            remote_home: remote_home.into(),
2277            machine_id: None,
2278        }
2279    }
2280
2281    #[test]
2282    fn test_source_config_generator_new() {
2283        let generator = SourceConfigGenerator::new();
2284        assert!(!generator.local_home.as_os_str().is_empty());
2285    }
2286
2287    #[test]
2288    fn test_generate_source_basic() {
2289        let generator = SourceConfigGenerator::new();
2290        let probe = make_test_probe(
2291            true,
2292            vec![make_test_agent("claude", "~/.claude/projects")],
2293            Some(make_test_sys_info("linux", "/home/ubuntu")),
2294        );
2295
2296        let source = generator.generate_source("my-server", &probe);
2297
2298        assert_eq!(source.name, "my-server");
2299        assert_eq!(source.source_type, SourceKind::Ssh);
2300        assert_eq!(source.host, Some("my-server".into()));
2301        assert_eq!(source.sync_schedule, SyncSchedule::Manual);
2302        assert!(!source.paths.is_empty());
2303        assert!(source.paths.contains(&"~/.claude/projects".to_string()));
2304    }
2305
2306    #[test]
2307    fn test_generate_source_disambiguates_reserved_local_name() {
2308        let generator = SourceConfigGenerator::new();
2309        let probe = make_test_probe(
2310            true,
2311            vec![make_test_agent("claude", "~/.claude/projects")],
2312            Some(make_test_sys_info("linux", "/home/ubuntu")),
2313        );
2314
2315        let source = generator.generate_source("local", &probe);
2316
2317        assert_eq!(source.name, "local-ssh");
2318        assert_eq!(source.host, Some("local".into()));
2319    }
2320
2321    #[test]
2322    fn test_generate_source_deduplicates_paths() {
2323        let generator = SourceConfigGenerator::new();
2324        let probe = make_test_probe(
2325            true,
2326            vec![
2327                make_test_agent("claude", "~/.claude/projects"),
2328                make_test_agent("claude-2", "~/.claude/projects"), // Duplicate
2329            ],
2330            Some(make_test_sys_info("linux", "/home/user")),
2331        );
2332
2333        let source = generator.generate_source("server", &probe);
2334        assert_eq!(source.paths.len(), 1);
2335    }
2336
2337    #[test]
2338    fn test_generate_source_path_mappings() {
2339        let generator = SourceConfigGenerator::new();
2340        let probe = make_test_probe(
2341            true,
2342            vec![make_test_agent("claude", "~/.claude/projects")],
2343            Some(make_test_sys_info("linux", "/home/ubuntu")),
2344        );
2345
2346        let source = generator.generate_source("server", &probe);
2347        assert!(!source.path_mappings.is_empty());
2348        assert!(
2349            source
2350                .path_mappings
2351                .iter()
2352                .any(|m| m.from.contains("/home/ubuntu"))
2353        );
2354    }
2355
2356    #[test]
2357    fn test_generate_source_platform_detection() {
2358        let generator = SourceConfigGenerator::new();
2359        let probe = make_test_probe(
2360            true,
2361            vec![],
2362            Some(make_test_sys_info("linux", "/home/user")),
2363        );
2364        let source = generator.generate_source("server", &probe);
2365        assert_eq!(source.platform, Some(Platform::Linux));
2366    }
2367
2368    #[test]
2369    fn test_generate_preview_basic() {
2370        let generator = SourceConfigGenerator::new();
2371        let probe = make_test_probe(
2372            true,
2373            vec![make_test_agent("claude", "~/.claude/projects")],
2374            Some(make_test_sys_info("linux", "/home/user")),
2375        );
2376
2377        let probes: Vec<(&str, &HostProbeResult)> = vec![("server1", &probe)];
2378        let preview = generator.generate_preview(&probes, &HashSet::new());
2379
2380        assert_eq!(preview.sources_to_add.len(), 1);
2381        assert!(preview.sources_skipped.is_empty());
2382        assert!(preview.has_changes());
2383    }
2384
2385    #[test]
2386    fn test_generate_preview_skips_already_configured() {
2387        let generator = SourceConfigGenerator::new();
2388        let probe = make_test_probe(
2389            true,
2390            vec![make_test_agent("claude", "~/.claude/projects")],
2391            Some(make_test_sys_info("linux", "/home/user")),
2392        );
2393
2394        let probes: Vec<(&str, &HostProbeResult)> = vec![("server1", &probe)];
2395        let mut configured = HashSet::new();
2396        configured.insert("server1".to_string());
2397
2398        let preview = generator.generate_preview(&probes, &configured);
2399        assert!(preview.sources_to_add.is_empty());
2400        assert_eq!(preview.sources_skipped.len(), 1);
2401    }
2402
2403    #[test]
2404    fn test_generate_preview_skips_already_configured_case_insensitive() {
2405        let generator = SourceConfigGenerator::new();
2406        let probe = make_test_probe(
2407            true,
2408            vec![make_test_agent("claude", "~/.claude/projects")],
2409            Some(make_test_sys_info("linux", "/home/user")),
2410        );
2411
2412        let probes: Vec<(&str, &HostProbeResult)> = vec![("Laptop", &probe)];
2413        let mut configured = HashSet::new();
2414        configured.insert(source_name_key("laptop"));
2415
2416        let preview = generator.generate_preview(&probes, &configured);
2417        assert!(preview.sources_to_add.is_empty());
2418        assert_eq!(preview.sources_skipped.len(), 1);
2419    }
2420
2421    #[test]
2422    fn test_generate_preview_skips_already_configured_case_insensitively_with_raw_names() {
2423        let generator = SourceConfigGenerator::new();
2424        let probe = make_test_probe(
2425            true,
2426            vec![make_test_agent("claude", "~/.claude/projects")],
2427            Some(make_test_sys_info("linux", "/home/user")),
2428        );
2429
2430        let probes: Vec<(&str, &HostProbeResult)> = vec![("laptop", &probe)];
2431        let mut configured = HashSet::new();
2432        configured.insert("Laptop".to_string());
2433
2434        let preview = generator.generate_preview(&probes, &configured);
2435
2436        assert!(preview.sources_to_add.is_empty());
2437        assert_eq!(preview.sources_skipped.len(), 1);
2438        assert!(matches!(
2439            preview.sources_skipped[0].1,
2440            SkipReason::AlreadyConfigured
2441        ));
2442    }
2443
2444    #[test]
2445    fn test_generate_preview_preserves_already_configured_skip_for_invalid_probe_data() {
2446        let generator = SourceConfigGenerator::new();
2447        let probe = make_test_probe(
2448            true,
2449            vec![make_test_agent("claude", "bad\npath")],
2450            Some(make_test_sys_info("linux", "/home/user")),
2451        );
2452
2453        let probes: Vec<(&str, &HostProbeResult)> = vec![("server1", &probe)];
2454        let mut configured = HashSet::new();
2455        configured.insert("server1".to_string());
2456
2457        let preview = generator.generate_preview(&probes, &configured);
2458
2459        assert!(preview.sources_to_add.is_empty());
2460        assert_eq!(preview.sources_skipped.len(), 1);
2461        assert_eq!(preview.sources_skipped[0].0, "server1");
2462        assert!(matches!(
2463            preview.sources_skipped[0].1,
2464            SkipReason::AlreadyConfigured
2465        ));
2466    }
2467
2468    #[test]
2469    fn test_generate_preview_skips_conflicting_generated_names_case_insensitive() {
2470        let generator = SourceConfigGenerator::new();
2471        let probe = make_test_probe(
2472            true,
2473            vec![make_test_agent("claude", "~/.claude/projects")],
2474            Some(make_test_sys_info("linux", "/home/user")),
2475        );
2476
2477        let probes: Vec<(&str, &HostProbeResult)> = vec![("Laptop", &probe), ("laptop", &probe)];
2478        let preview = generator.generate_preview(&probes, &HashSet::new());
2479
2480        assert_eq!(preview.sources_to_add.len(), 1);
2481        assert_eq!(preview.sources_to_add[0].name, "Laptop");
2482        assert_eq!(preview.sources_skipped.len(), 1);
2483        assert_eq!(preview.sources_skipped[0].0, "laptop");
2484        assert!(matches!(
2485            &preview.sources_skipped[0].1,
2486            SkipReason::GeneratedNameConflict(name) if name == "laptop"
2487        ));
2488    }
2489
2490    #[test]
2491    fn test_generate_preview_invalid_source_does_not_shadow_later_valid_duplicate() {
2492        let generator = SourceConfigGenerator::new();
2493        let invalid_probe = make_test_probe(
2494            true,
2495            vec![make_test_agent("claude", "bad\npath")],
2496            Some(make_test_sys_info("linux", "/home/user")),
2497        );
2498        let valid_probe = make_test_probe(
2499            true,
2500            vec![make_test_agent("claude", "~/.claude/projects")],
2501            Some(make_test_sys_info("linux", "/home/user")),
2502        );
2503
2504        let probes: Vec<(&str, &HostProbeResult)> =
2505            vec![("Laptop", &invalid_probe), ("laptop", &valid_probe)];
2506        let preview = generator.generate_preview(&probes, &HashSet::new());
2507
2508        assert_eq!(preview.sources_to_add.len(), 1);
2509        assert_eq!(preview.sources_to_add[0].name, "laptop");
2510        assert_eq!(preview.sources_skipped.len(), 1);
2511        assert_eq!(preview.sources_skipped[0].0, "Laptop");
2512        assert!(matches!(
2513            &preview.sources_skipped[0].1,
2514            SkipReason::InvalidSourceDefinition(message)
2515                if message.contains("paths[0] cannot contain control characters")
2516        ));
2517    }
2518
2519    #[test]
2520    fn test_generate_preview_skips_invalid_generated_sources_before_merge() {
2521        let generator = SourceConfigGenerator::new();
2522        let invalid_host_probe = make_test_probe(
2523            true,
2524            vec![make_test_agent("claude", "~/.claude/projects")],
2525            Some(make_test_sys_info("linux", "/home/user")),
2526        );
2527        let invalid_path_probe = make_test_probe(
2528            true,
2529            vec![make_test_agent("claude", "bad\npath")],
2530            Some(make_test_sys_info("linux", "/home/user")),
2531        );
2532        let valid_probe = make_test_probe(
2533            true,
2534            vec![make_test_agent("claude", "~/.claude/projects")],
2535            Some(make_test_sys_info("linux", "/home/user")),
2536        );
2537
2538        let probes: Vec<(&str, &HostProbeResult)> = vec![
2539            ("bad host", &invalid_host_probe),
2540            ("path-host", &invalid_path_probe),
2541            ("server1", &valid_probe),
2542        ];
2543        let preview = generator.generate_preview(&probes, &HashSet::new());
2544
2545        assert_eq!(preview.sources_to_add.len(), 1);
2546        assert_eq!(preview.sources_to_add[0].name, "server1");
2547        assert_eq!(preview.sources_skipped.len(), 2);
2548        assert_eq!(preview.sources_skipped[0].0, "bad host");
2549        assert!(matches!(
2550            &preview.sources_skipped[0].1,
2551            SkipReason::InvalidSourceDefinition(message)
2552                if message.contains("SSH host cannot contain whitespace")
2553        ));
2554        assert_eq!(preview.sources_skipped[1].0, "path-host");
2555        assert!(matches!(
2556            &preview.sources_skipped[1].1,
2557            SkipReason::InvalidSourceDefinition(message)
2558                if message.contains("paths[0] cannot contain control characters")
2559        ));
2560
2561        let mut config = SourcesConfig::default();
2562        let (added, skipped) = config.merge_preview(&preview).unwrap();
2563        assert_eq!(added, 1);
2564        assert!(skipped.is_empty());
2565        assert_eq!(config.sources.len(), 1);
2566        assert_eq!(config.sources[0].name, "server1");
2567    }
2568
2569    #[test]
2570    fn test_merge_source() {
2571        let mut config = SourcesConfig::default();
2572        let source = SourceDefinition::ssh("new-server", "user@server");
2573
2574        let result = config.merge_source(source).unwrap();
2575        assert!(matches!(result, MergeResult::Added(_)));
2576        assert_eq!(config.sources.len(), 1);
2577    }
2578
2579    #[test]
2580    fn test_merge_source_already_exists() {
2581        let mut config = SourcesConfig::default();
2582        config.sources.push(SourceDefinition::ssh("server", "host"));
2583
2584        let source = SourceDefinition::ssh("server", "other-host");
2585        let result = config.merge_source(source).unwrap();
2586        assert!(matches!(result, MergeResult::AlreadyExists(_)));
2587        assert_eq!(config.sources.len(), 1);
2588    }
2589
2590    #[test]
2591    fn test_merge_source_already_exists_case_insensitive() {
2592        let mut config = SourcesConfig::default();
2593        config.sources.push(SourceDefinition::ssh("Server", "host"));
2594
2595        let source = SourceDefinition::ssh("server", "other-host");
2596        let result = config.merge_source(source).unwrap();
2597        assert!(matches!(result, MergeResult::AlreadyExists(_)));
2598        assert_eq!(config.sources.len(), 1);
2599    }
2600
2601    #[test]
2602    fn test_configured_names() {
2603        let mut config = SourcesConfig::default();
2604        config.sources.push(SourceDefinition::ssh("server1", "h1"));
2605        config.sources.push(SourceDefinition::ssh("server2", "h2"));
2606
2607        let names = config.configured_names();
2608        assert_eq!(names.len(), 2);
2609        assert!(names.contains("server1"));
2610        assert!(names.contains("server2"));
2611    }
2612
2613    #[test]
2614    fn test_exclude_and_include_agents_normalize_and_dedup() {
2615        let mut config = SourcesConfig::default();
2616
2617        assert!(config.exclude_agent_from_indexing(" OpenClaw ").unwrap());
2618        assert!(!config.exclude_agent_from_indexing("open-claw").unwrap());
2619        assert!(config.is_agent_disabled("openclaw"));
2620        assert_eq!(config.configured_disabled_agents(), vec!["openclaw"]);
2621
2622        assert!(config.include_agent_in_indexing("open_claw").unwrap());
2623        assert!(!config.is_agent_disabled("openclaw"));
2624        assert!(config.configured_disabled_agents().is_empty());
2625    }
2626
2627    #[test]
2628    fn test_exclude_agent_aliases_collapse_to_internal_connector_slug() {
2629        let mut config = SourcesConfig::default();
2630
2631        assert!(config.exclude_agent_from_indexing("claude-code").unwrap());
2632        assert!(config.is_agent_disabled("claude"));
2633        assert!(config.is_agent_disabled("claude_code"));
2634        assert_eq!(config.configured_disabled_agents(), vec!["claude"]);
2635    }
2636
2637    #[test]
2638    fn test_validate_rejects_empty_disabled_agent_entry() {
2639        let mut config = SourcesConfig::default();
2640        config.disabled_agents.push("   ".into());
2641        let err = config
2642            .validate()
2643            .expect_err("disabled_agents entry should fail");
2644        assert!(matches!(err, ConfigError::Validation(_)));
2645    }
2646
2647    #[test]
2648    fn test_sources_config_roundtrip_preserves_disabled_agents() {
2649        let mut config = SourcesConfig::default();
2650        config.exclude_agent_from_indexing("openclaw").unwrap();
2651        config.exclude_agent_from_indexing("claude-code").unwrap();
2652
2653        let serialized = toml::to_string_pretty(&config).unwrap();
2654        let deserialized: SourcesConfig = toml::from_str(&serialized).unwrap();
2655
2656        assert_eq!(
2657            deserialized.configured_disabled_agents(),
2658            vec!["claude", "openclaw"]
2659        );
2660    }
2661
2662    #[test]
2663    fn test_configured_name_keys_normalize_case() {
2664        let mut config = SourcesConfig::default();
2665        config.sources.push(SourceDefinition::ssh("Server1", "h1"));
2666        config.sources.push(SourceDefinition::ssh("server2", "h2"));
2667
2668        let names = config.configured_name_keys();
2669        assert_eq!(names.len(), 2);
2670        assert!(names.contains("server1"));
2671        assert!(names.contains("server2"));
2672    }
2673
2674    #[test]
2675    fn test_save_to_rejects_invalid_config() {
2676        let temp = tempfile::tempdir().expect("tempdir");
2677        let path = temp.path().join("sources.toml");
2678
2679        let mut config = SourcesConfig::default();
2680        config
2681            .sources
2682            .push(SourceDefinition::ssh("local", "user@host"));
2683
2684        let err = config
2685            .save_to(&path)
2686            .expect_err("save_to should reject invalid config");
2687        assert!(matches!(err, ConfigError::Validation(_)));
2688        assert!(!path.exists(), "invalid config should not be written");
2689    }
2690
2691    #[test]
2692    fn test_empty_remote_home_no_mappings() {
2693        let generator = SourceConfigGenerator::new();
2694        let mut sys_info = make_test_sys_info("linux", "");
2695        sys_info.remote_home = "".into();
2696
2697        let probe = make_test_probe(
2698            true,
2699            vec![make_test_agent("claude", "~/.claude/projects")],
2700            Some(sys_info),
2701        );
2702
2703        let source = generator.generate_source("server", &probe);
2704        assert!(source.path_mappings.is_empty());
2705    }
2706
2707    #[test]
2708    fn test_trailing_slash_remote_home_normalized() {
2709        let generator = SourceConfigGenerator::new();
2710        // Remote home with trailing slash should be normalized
2711        let mut sys_info = make_test_sys_info("linux", "/home/user/");
2712        sys_info.remote_home = "/home/user/".into(); // Explicitly set with trailing slash
2713
2714        let probe = make_test_probe(
2715            true,
2716            vec![make_test_agent("claude", "~/.claude/projects")],
2717            Some(sys_info),
2718        );
2719
2720        let source = generator.generate_source("server", &probe);
2721
2722        // Should have mappings without double slashes
2723        assert!(!source.path_mappings.is_empty());
2724        // The projects mapping should NOT have double slashes
2725        let projects_mapping = source
2726            .path_mappings
2727            .iter()
2728            .find(|m| m.from.contains("projects"));
2729        assert!(projects_mapping.is_some());
2730        // Check no double slashes
2731        assert!(
2732            !projects_mapping.unwrap().from.contains("//"),
2733            "Path mapping should not contain double slashes: {}",
2734            projects_mapping.unwrap().from
2735        );
2736    }
2737}