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