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