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