use serde::{Deserialize, Serialize};
use std::path::{Component, Path, PathBuf};
use thiserror::Error;
use super::provenance::SourceKind;
pub use franken_agent_detection::{PathMapping, Platform};
const BUILT_IN_LOCAL_SOURCE_NAME: &str = "local";
const RESERVED_REMOTE_SOURCE_SUFFIX: &str = "-ssh";
pub(crate) fn source_name_key(name: &str) -> String {
name.trim().to_ascii_lowercase()
}
pub(crate) fn source_names_equal(lhs: &str, rhs: &str) -> bool {
source_name_key(lhs) == source_name_key(rhs)
}
pub(crate) fn agent_name_key(name: &str) -> String {
name.trim().to_ascii_lowercase().replace('-', "_")
}
fn normalize_agent_config_name(name: &str) -> Option<String> {
let normalized = match agent_name_key(name).as_str() {
"claude_code" => "claude".to_string(),
"open_claw" => "openclaw".to_string(),
other => other.to_string(),
};
(!normalized.is_empty()).then_some(normalized)
}
fn agent_config_names_equal(lhs: &str, rhs: &str) -> bool {
match (
normalize_agent_config_name(lhs),
normalize_agent_config_name(rhs),
) {
(Some(lhs), Some(rhs)) => lhs == rhs,
_ => false,
}
}
fn path_mapping_applies_to_agent(mapping: &PathMapping, agent: Option<&str>) -> bool {
match (
mapping.agents.as_ref(),
agent.and_then(|value| {
let trimmed = value.trim();
(!trimmed.is_empty()).then_some(trimmed)
}),
) {
(Some(agents), _) if agents.is_empty() => false,
(None, _) | (Some(_), None) => true,
(Some(agents), Some(actual)) => agents
.iter()
.any(|allowed| agent_config_names_equal(allowed, actual)),
}
}
#[derive(Error, Debug)]
pub enum ConfigError {
#[error("Failed to read config file: {0}")]
Read(#[from] std::io::Error),
#[error("Failed to parse config file: {0}")]
Parse(#[from] toml::de::Error),
#[error("Failed to serialize config: {0}")]
Serialize(#[from] toml::ser::Error),
#[error("Could not determine config directory")]
NoConfigDir,
#[error("Validation error: {0}")]
Validation(String),
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct SourcesConfig {
#[serde(default)]
pub sources: Vec<SourceDefinition>,
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub disabled_agents: Vec<String>,
}
#[derive(Debug, Clone, Default, Serialize, Deserialize)]
pub struct SourceDefinition {
pub name: String,
#[serde(rename = "type", default)]
pub source_type: SourceKind,
#[serde(default)]
pub host: Option<String>,
#[serde(default)]
pub paths: Vec<String>,
#[serde(default)]
pub sync_schedule: SyncSchedule,
#[serde(default)]
pub path_mappings: Vec<PathMapping>,
#[serde(default)]
pub platform: Option<Platform>,
}
impl SourceDefinition {
pub fn local(name: impl Into<String>) -> Self {
Self {
name: name.into(),
source_type: SourceKind::Local,
..Default::default()
}
}
pub fn ssh(name: impl Into<String>, host: impl Into<String>) -> Self {
Self {
name: name.into(),
source_type: SourceKind::Ssh,
host: Some(host.into()),
..Default::default()
}
}
pub fn is_remote(&self) -> bool {
matches!(self.source_type, SourceKind::Ssh)
}
pub fn validate(&self) -> Result<(), ConfigError> {
self.validate_structure()?;
self.validate_paths()
}
pub(crate) fn validate_name(&self) -> Result<(), ConfigError> {
validate_source_name(&self.name)
}
pub(crate) fn validate_structure(&self) -> Result<(), ConfigError> {
self.validate_name()?;
if self.is_remote() && self.host.is_none() {
return Err(ConfigError::Validation("SSH sources require a host".into()));
}
if self.is_remote()
&& let Some(host) = self.host.as_deref()
{
validate_ssh_host(host)?;
}
for (idx, mapping) in self.path_mappings.iter().enumerate() {
if mapping.from.trim().is_empty() {
return Err(ConfigError::Validation(format!(
"path_mappings[{idx}].from cannot be empty"
)));
}
if mapping.to.trim().is_empty() {
return Err(ConfigError::Validation(format!(
"path_mappings[{idx}].to cannot be empty"
)));
}
if let Some(agents) = mapping.agents.as_ref() {
if agents.is_empty() {
return Err(ConfigError::Validation(format!(
"path_mappings[{idx}].agents cannot be empty"
)));
}
if agents.iter().any(|agent| agent.trim().is_empty()) {
return Err(ConfigError::Validation(format!(
"path_mappings[{idx}].agents cannot contain empty agent names"
)));
}
}
}
Ok(())
}
fn validate_paths(&self) -> Result<(), ConfigError> {
for (idx, path) in self.paths.iter().enumerate() {
validate_source_path_entry(idx, path)?;
}
Ok(())
}
pub fn rewrite_path(&self, path: &str) -> String {
self.rewrite_path_for_agent(path, None)
}
pub fn rewrite_path_for_agent(&self, path: &str, agent: Option<&str>) -> String {
let mut mappings: Vec<_> = self
.path_mappings
.iter()
.filter(|m| path_mapping_applies_to_agent(m, agent))
.collect();
mappings.sort_by_key(|m| std::cmp::Reverse(m.from.len()));
for mapping in mappings {
if let Some(rewritten) = mapping.apply(path) {
return rewritten;
}
}
path.to_string()
}
}
pub(crate) fn normalize_generated_remote_source_name(name: &str) -> String {
let name = name.trim();
if source_names_equal(name, BUILT_IN_LOCAL_SOURCE_NAME) {
format!("{name}{RESERVED_REMOTE_SOURCE_SUFFIX}")
} else {
name.to_string()
}
}
fn has_dot_components(path: &Path) -> bool {
path.components()
.any(|c| matches!(c, Component::CurDir | Component::ParentDir))
}
fn validate_source_name(name: &str) -> Result<(), ConfigError> {
if name.trim().is_empty() {
return Err(ConfigError::Validation(
"Source name cannot be empty".into(),
));
}
if name.trim() != name {
return Err(ConfigError::Validation(
"Source name cannot have leading or trailing whitespace".into(),
));
}
if source_names_equal(name, BUILT_IN_LOCAL_SOURCE_NAME) {
return Err(ConfigError::Validation(format!(
"Source name '{}' is reserved for the built-in local source",
BUILT_IN_LOCAL_SOURCE_NAME
)));
}
if name.contains('/') || name.contains('\\') {
return Err(ConfigError::Validation(
"Source name cannot contain path separators".into(),
));
}
if has_dot_components(Path::new(name)) {
return Err(ConfigError::Validation(
"Source name cannot be '.' or '..'".into(),
));
}
Ok(())
}
fn validate_ssh_host(host: &str) -> Result<(), ConfigError> {
let trimmed = host.trim();
if trimmed.is_empty() {
return Err(ConfigError::Validation("SSH host cannot be empty".into()));
}
if trimmed != host {
return Err(ConfigError::Validation(
"SSH host cannot have leading or trailing whitespace".into(),
));
}
let host = trimmed;
if host.starts_with('-') {
return Err(ConfigError::Validation(
"SSH host cannot start with '-' (would be parsed as an ssh option)".into(),
));
}
if host.chars().any(|c| c.is_whitespace() || c.is_control()) {
return Err(ConfigError::Validation(
"SSH host cannot contain whitespace or control characters".into(),
));
}
if !ssh_host_has_safe_token_chars(host) {
return Err(ConfigError::Validation(
"SSH host may only contain ASCII letters, digits, '.', '-', '_', and '@'".into(),
));
}
validate_optional_user_host_shape(host).map_err(ConfigError::Validation)?;
Ok(())
}
pub(crate) fn source_path_entry_error(index: usize, path: &str) -> Option<String> {
if path.trim().is_empty() {
return Some(format!("paths[{index}] cannot be empty"));
}
if path.trim() != path {
return Some(format!(
"paths[{index}] cannot have leading or trailing whitespace"
));
}
if path.chars().any(char::is_control) {
return Some(format!("paths[{index}] cannot contain control characters"));
}
None
}
fn validate_source_path_entry(index: usize, path: &str) -> Result<(), ConfigError> {
match source_path_entry_error(index, path) {
Some(message) => Err(ConfigError::Validation(message)),
None => Ok(()),
}
}
pub(crate) fn ssh_host_has_safe_token_chars(host: &str) -> bool {
host.chars()
.all(|c| c.is_ascii_alphanumeric() || matches!(c, '.' | '-' | '_' | '@'))
}
pub(crate) fn validate_optional_user_host_shape(host: &str) -> Result<(), String> {
match host.split_once('@') {
Some((user, hostname)) if user.is_empty() || hostname.is_empty() => {
Err("SSH host must not have an empty user or hostname around '@'".into())
}
Some((_, hostname)) if hostname.contains('@') => {
Err("SSH host must contain at most one '@' separator".into())
}
_ => Ok(()),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "lowercase")]
pub enum SyncSchedule {
#[default]
Manual,
Hourly,
Daily,
}
const SYNC_SCHEDULE_MANUAL: &str = "manual";
const SYNC_SCHEDULE_HOURLY: &str = "hourly";
const SYNC_SCHEDULE_DAILY: &str = "daily";
impl std::fmt::Display for SyncSchedule {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(match self {
Self::Manual => SYNC_SCHEDULE_MANUAL,
Self::Hourly => SYNC_SCHEDULE_HOURLY,
Self::Daily => SYNC_SCHEDULE_DAILY,
})
}
}
impl SourcesConfig {
pub fn load() -> Result<Self, ConfigError> {
let config_path = Self::config_path()?;
if !config_path.exists() {
return Ok(Self::default());
}
let content = std::fs::read_to_string(&config_path)?;
let config: Self = toml::from_str(&content)?;
config.validate_for_load()?;
Ok(config)
}
pub fn load_from(path: &PathBuf) -> Result<Self, ConfigError> {
if !path.exists() {
return Ok(Self::default());
}
let content = std::fs::read_to_string(path)?;
let config: Self = toml::from_str(&content)?;
config.validate_for_load()?;
Ok(config)
}
pub fn save(&self) -> Result<(), ConfigError> {
let config_path = Self::config_path()?;
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
self.validate()?;
let content = toml::to_string_pretty(self)?;
let _: SourcesConfig = toml::from_str(&content)?;
let temp_path = unique_atomic_temp_path(&config_path);
std::fs::write(&temp_path, content)?;
sync_file_path(&temp_path)?;
replace_file_from_temp(&temp_path, &config_path)?;
Ok(())
}
pub fn save_to(&self, path: &Path) -> Result<(), ConfigError> {
if let Some(parent) = path.parent() {
std::fs::create_dir_all(parent)?;
}
self.validate()?;
let content = toml::to_string_pretty(self)?;
let _: SourcesConfig = toml::from_str(&content)?;
let temp_path = unique_atomic_temp_path(path);
std::fs::write(&temp_path, content)?;
sync_file_path(&temp_path)?;
replace_file_from_temp(&temp_path, path)?;
Ok(())
}
pub fn config_path() -> Result<PathBuf, ConfigError> {
config_path_from_parts(
dotenvy::var("XDG_CONFIG_HOME").ok().map(PathBuf::from),
dirs::config_dir(),
dirs::home_dir(),
)
}
pub fn validate(&self) -> Result<(), ConfigError> {
self.validate_with_path_entries(true)
}
fn validate_for_load(&self) -> Result<(), ConfigError> {
self.validate_with_path_entries(false)
}
fn validate_with_path_entries(&self, validate_paths: bool) -> Result<(), ConfigError> {
let mut seen_names = std::collections::HashSet::new();
for source in &self.sources {
if validate_paths {
source.validate()?;
} else {
source.validate_structure()?;
}
if !seen_names.insert(source_name_key(&source.name)) {
return Err(ConfigError::Validation(format!(
"Duplicate source name: {}",
source.name
)));
}
}
for (idx, agent) in self.disabled_agents.iter().enumerate() {
if normalize_agent_config_name(agent).is_none() {
return Err(ConfigError::Validation(format!(
"disabled_agents[{idx}] cannot be empty"
)));
}
}
Ok(())
}
pub fn find_source(&self, name: &str) -> Option<&SourceDefinition> {
self.sources
.iter()
.find(|s| source_names_equal(&s.name, name))
}
pub fn find_source_mut(&mut self, name: &str) -> Option<&mut SourceDefinition> {
self.sources
.iter_mut()
.find(|s| source_names_equal(&s.name, name))
}
pub fn add_source(&mut self, source: SourceDefinition) -> Result<(), ConfigError> {
source.validate()?;
if self
.sources
.iter()
.any(|s| source_names_equal(&s.name, &source.name))
{
return Err(ConfigError::Validation(format!(
"Source '{}' already exists",
source.name
)));
}
self.sources.push(source);
Ok(())
}
pub fn remove_source(&mut self, name: &str) -> bool {
let initial_len = self.sources.len();
self.sources.retain(|s| !source_names_equal(&s.name, name));
self.sources.len() < initial_len
}
pub fn remote_sources(&self) -> impl Iterator<Item = &SourceDefinition> {
self.sources.iter().filter(|s| s.is_remote())
}
pub fn configured_disabled_agents(&self) -> Vec<String> {
let mut disabled = self
.disabled_agents
.iter()
.filter_map(|agent| normalize_agent_config_name(agent))
.collect::<Vec<_>>();
disabled.sort();
disabled.dedup();
disabled
}
pub fn is_agent_disabled(&self, agent: &str) -> bool {
let Some(normalized) = normalize_agent_config_name(agent) else {
return false;
};
self.disabled_agents
.iter()
.filter_map(|candidate| normalize_agent_config_name(candidate))
.any(|candidate| candidate == normalized)
}
pub fn exclude_agent_from_indexing(&mut self, agent: &str) -> Result<bool, ConfigError> {
let normalized = normalize_agent_config_name(agent)
.ok_or_else(|| ConfigError::Validation("agent name cannot be empty".into()))?;
if self.is_agent_disabled(&normalized) {
return Ok(false);
}
self.disabled_agents.push(normalized);
Ok(true)
}
pub fn include_agent_in_indexing(&mut self, agent: &str) -> Result<bool, ConfigError> {
let normalized = normalize_agent_config_name(agent)
.ok_or_else(|| ConfigError::Validation("agent name cannot be empty".into()))?;
let initial_len = self.disabled_agents.len();
self.disabled_agents.retain(|existing| {
normalize_agent_config_name(existing).as_deref() != Some(&normalized)
});
Ok(self.disabled_agents.len() != initial_len)
}
}
fn config_path_from_parts(
xdg_config_home: Option<PathBuf>,
platform_config_dir: Option<PathBuf>,
home_dir: Option<PathBuf>,
) -> Result<PathBuf, ConfigError> {
if let Some(xdg_config) = xdg_config_home {
return Ok(xdg_config.join("cass").join("sources.toml"));
}
let platform_path = platform_config_dir.map(|p| p.join("cass").join("sources.toml"));
if let Some(ref path) = platform_path
&& path.exists()
{
return Ok(path.clone());
}
if let Some(home) = home_dir {
let dot_config_path = home.join(".config").join("cass").join("sources.toml");
if dot_config_path.exists() {
return Ok(dot_config_path);
}
}
platform_path.ok_or(ConfigError::NoConfigDir)
}
pub fn get_preset_paths(preset: &str) -> Result<Vec<String>, ConfigError> {
match preset {
"macos-defaults" | "macos" => Ok(vec![
"~/.claude/projects".into(),
"~/.codex/sessions".into(),
"~/Library/Application Support/Code/User/globalStorage/saoudrizwan.claude-dev".into(),
"~/Library/Application Support/Code/User/globalStorage/rooveterinaryinc.roo-cline"
.into(),
"~/Library/Application Support/Cursor/User/globalStorage/saoudrizwan.claude-dev".into(),
"~/Library/Application Support/Cursor/User/globalStorage/rooveterinaryinc.roo-cline"
.into(),
"~/Library/Application Support/com.openai.chat".into(),
"~/.gemini/tmp".into(),
"~/.pi/agent/sessions".into(),
"~/Library/Application Support/opencode/storage".into(),
"~/.continue/sessions".into(),
"~/.aider.chat.history.md".into(),
"~/.goose/sessions".into(),
]),
"linux-defaults" | "linux" => Ok(vec![
"~/.claude/projects".into(),
"~/.codex/sessions".into(),
"~/.config/Code/User/globalStorage/saoudrizwan.claude-dev".into(),
"~/.config/Code/User/globalStorage/rooveterinaryinc.roo-cline".into(),
"~/.config/Cursor/User/globalStorage/saoudrizwan.claude-dev".into(),
"~/.config/Cursor/User/globalStorage/rooveterinaryinc.roo-cline".into(),
"~/.gemini/tmp".into(),
"~/.pi/agent/sessions".into(),
"~/.local/share/opencode/storage".into(),
"~/.continue/sessions".into(),
"~/.aider.chat.history.md".into(),
"~/.goose/sessions".into(),
]),
_ => Err(ConfigError::Validation(format!(
"Unknown preset: '{}'. Valid presets: macos-defaults, linux-defaults",
preset
))),
}
}
#[derive(Debug, Clone)]
pub struct DiscoveredHost {
pub name: String,
pub hostname: Option<String>,
pub user: Option<String>,
pub port: Option<u16>,
pub identity_file: Option<String>,
}
impl DiscoveredHost {
pub fn connection_string(&self) -> String {
if let Some(user) = &self.user {
format!("{}@{}", user, self.name)
} else {
self.name.clone()
}
}
}
pub fn discover_ssh_hosts() -> Vec<DiscoveredHost> {
let ssh_config_path = dirs::home_dir()
.map(|h| h.join(".ssh").join("config"))
.unwrap_or_default();
if !ssh_config_path.exists() {
return Vec::new();
}
let content = match std::fs::read_to_string(&ssh_config_path) {
Ok(c) => c,
Err(_) => return Vec::new(),
};
parse_ssh_config(&content)
}
fn parse_ssh_config(content: &str) -> Vec<DiscoveredHost> {
let mut hosts = Vec::new();
let mut current_hosts: Vec<DiscoveredHost> = Vec::new();
for line in content.lines() {
let line = line.trim();
if line.is_empty() || line.starts_with('#') {
continue;
}
let (key, value) = if let Some(idx) = line.find(|c: char| c.is_whitespace() || c == '=') {
let k = &line[..idx];
let v = line[idx..].trim_start_matches(|c: char| c.is_whitespace() || c == '=');
(k.to_lowercase(), v)
} else {
continue;
};
match key.as_str() {
"host" => {
hosts.append(&mut current_hosts);
current_hosts = value
.split_whitespace()
.filter(|name| {
!name.starts_with('!') && !name.contains('*') && !name.contains('?')
})
.map(|name| DiscoveredHost {
name: name.to_string(),
hostname: None,
user: None,
port: None,
identity_file: None,
})
.collect();
}
"hostname" => {
for host in &mut current_hosts {
host.hostname = Some(value.to_string());
}
}
"user" => {
for host in &mut current_hosts {
host.user = Some(value.to_string());
}
}
"port" => {
for host in &mut current_hosts {
host.port = value.parse().ok();
}
}
"identityfile" => {
for host in &mut current_hosts {
host.identity_file = Some(value.to_string());
}
}
_ => {}
}
}
hosts.append(&mut current_hosts);
hosts
}
use std::collections::HashSet;
use colored::Colorize;
use super::probe::HostProbeResult;
#[derive(Debug, Clone)]
pub enum MergeResult {
Added(SourceDefinition),
AlreadyExists(String),
}
#[derive(Debug, Clone)]
pub enum SkipReason {
AlreadyConfigured,
GeneratedNameConflict(String),
InvalidSourceDefinition(String),
ProbeFailure(String),
UserDeselected,
}
#[derive(Debug, Clone)]
pub struct BackupInfo {
pub backup_path: Option<PathBuf>,
pub config_path: PathBuf,
}
#[derive(Debug, Clone)]
pub struct ConfigPreview {
pub sources_to_add: Vec<SourceDefinition>,
pub sources_skipped: Vec<(String, SkipReason)>,
}
impl ConfigPreview {
pub fn new() -> Self {
Self {
sources_to_add: Vec::new(),
sources_skipped: Vec::new(),
}
}
pub fn display(&self) {
println!();
println!("{}", "Configuration Preview".bold().underline());
if self.sources_to_add.is_empty() {
println!(" {}", "No new sources to add.".dimmed());
} else {
println!(" The following will be added to sources.toml:\n");
for source in &self.sources_to_add {
println!(" {}:", source.name.cyan());
println!(" {}:", "Paths".dimmed());
for path in &source.paths {
println!(" {}", path);
}
if !source.path_mappings.is_empty() {
println!(" {}:", "Mappings".dimmed());
for mapping in &source.path_mappings {
println!(" {} → {}", mapping.from, mapping.to);
}
}
println!();
}
}
if !self.sources_skipped.is_empty() {
println!(" {}:", "Skipped".dimmed());
for (name, reason) in &self.sources_skipped {
let reason_str = match reason {
SkipReason::AlreadyConfigured => "already configured",
SkipReason::GeneratedNameConflict(source_name) => {
println!(
" {} - {}",
name.dimmed(),
format!("conflicts with generated source name '{source_name}'")
.dimmed()
);
continue;
}
SkipReason::InvalidSourceDefinition(e) => e.as_str(),
SkipReason::ProbeFailure(e) => e.as_str(),
SkipReason::UserDeselected => "not selected",
};
println!(" {} - {}", name.dimmed(), reason_str.dimmed());
}
}
}
pub fn has_changes(&self) -> bool {
!self.sources_to_add.is_empty()
}
pub fn add_count(&self) -> usize {
self.sources_to_add.len()
}
}
impl Default for ConfigPreview {
fn default() -> Self {
Self::new()
}
}
pub struct SourceConfigGenerator {
local_home: PathBuf,
}
impl SourceConfigGenerator {
pub fn new() -> Self {
Self {
local_home: dirs::home_dir().unwrap_or_else(|| PathBuf::from("~")),
}
}
pub fn generate_source(&self, host_name: &str, probe: &HostProbeResult) -> SourceDefinition {
let paths = self.generate_paths(probe);
let path_mappings = self.generate_mappings(probe);
let platform = self.detect_platform(probe);
let name = normalize_generated_remote_source_name(host_name);
SourceDefinition {
name,
source_type: SourceKind::Ssh,
host: Some(host_name.to_string()), paths,
sync_schedule: SyncSchedule::Manual,
path_mappings,
platform,
}
}
fn generate_paths(&self, probe: &HostProbeResult) -> Vec<String> {
let mut paths = Vec::new();
for agent in &probe.detected_agents {
paths.push(agent.path.clone());
}
let mut seen = HashSet::new();
paths.retain(|p| seen.insert(p.clone()));
paths
}
fn generate_mappings(&self, probe: &HostProbeResult) -> Vec<PathMapping> {
let mut mappings = Vec::new();
if let Some(ref sys_info) = probe.system_info {
let remote_home = sys_info.remote_home.trim_end_matches('/');
if !remote_home.is_empty() && remote_home != "/" {
let remote_projects = format!("{}/projects", remote_home);
let local_projects = self.local_home.join("projects");
mappings.push(PathMapping::new(
remote_projects,
local_projects.to_string_lossy().to_string(),
));
mappings.push(PathMapping::new(
remote_home,
self.local_home.to_string_lossy().to_string(),
));
}
}
let has_data_projects = probe
.detected_agents
.iter()
.any(|a| a.path.starts_with("/data/"));
if has_data_projects {
let local_projects = self.local_home.join("projects");
mappings.push(PathMapping::new(
"/data/projects",
local_projects.to_string_lossy().to_string(),
));
}
mappings
}
fn detect_platform(&self, probe: &HostProbeResult) -> Option<Platform> {
probe
.system_info
.as_ref()
.and_then(|si| match si.os.to_lowercase().as_str() {
"darwin" => Some(Platform::Macos),
"linux" => Some(Platform::Linux),
"windows" => Some(Platform::Windows),
_ => None,
})
}
pub fn generate_preview(
&self,
probes: &[(&str, &HostProbeResult)],
already_configured: &HashSet<String>,
) -> ConfigPreview {
let mut preview = ConfigPreview::new();
let configured_name_keys: HashSet<_> = already_configured
.iter()
.map(|name| source_name_key(name))
.collect();
let mut preview_name_keys = configured_name_keys.clone();
for (host_name, probe) in probes {
if !probe.reachable {
let reason = probe
.error
.clone()
.unwrap_or_else(|| "unreachable".to_string());
preview
.sources_skipped
.push((host_name.to_string(), SkipReason::ProbeFailure(reason)));
continue;
}
let source = self.generate_source(host_name, probe);
let source_name_key = source_name_key(&source.name);
if configured_name_keys.contains(&source_name_key) {
preview
.sources_skipped
.push((source.name.clone(), SkipReason::AlreadyConfigured));
continue;
}
if let Err(err) = source.validate() {
preview.sources_skipped.push((
host_name.to_string(),
SkipReason::InvalidSourceDefinition(err.to_string()),
));
continue;
}
if !preview_name_keys.insert(source_name_key) {
preview.sources_skipped.push((
host_name.to_string(),
SkipReason::GeneratedNameConflict(source.name.clone()),
));
continue;
}
preview.sources_to_add.push(source);
}
preview
}
}
impl Default for SourceConfigGenerator {
fn default() -> Self {
Self::new()
}
}
impl SourcesConfig {
pub fn write_with_backup(&self) -> Result<BackupInfo, ConfigError> {
let config_path = Self::config_path()?;
if let Some(parent) = config_path.parent() {
std::fs::create_dir_all(parent)?;
}
let backup_path = if config_path.exists() {
let backup = unique_backup_path(&config_path);
std::fs::copy(&config_path, &backup)?;
Some(backup)
} else {
None
};
self.validate()?;
let toml_str = toml::to_string_pretty(self)?;
let parsed: SourcesConfig = toml::from_str(&toml_str)?;
parsed.validate()?;
let temp_path = unique_atomic_temp_path(&config_path);
std::fs::write(&temp_path, &toml_str)?;
sync_file_path(&temp_path)?;
replace_file_from_temp(&temp_path, &config_path)?;
Ok(BackupInfo {
backup_path,
config_path,
})
}
pub fn merge_source(&mut self, source: SourceDefinition) -> Result<MergeResult, ConfigError> {
source.validate()?;
if self
.sources
.iter()
.any(|s| source_names_equal(&s.name, &source.name))
{
return Ok(MergeResult::AlreadyExists(source.name));
}
let added = source.clone();
self.sources.push(source);
Ok(MergeResult::Added(added))
}
pub fn merge_preview(
&mut self,
preview: &ConfigPreview,
) -> Result<(usize, Vec<String>), ConfigError> {
let mut added = 0;
let mut skipped = Vec::new();
for source in &preview.sources_to_add {
match self.merge_source(source.clone())? {
MergeResult::Added(_) => added += 1,
MergeResult::AlreadyExists(name) => skipped.push(name),
}
}
Ok((added, skipped))
}
pub fn configured_names(&self) -> HashSet<String> {
self.sources.iter().map(|s| s.name.clone()).collect()
}
pub fn configured_name_keys(&self) -> HashSet<String> {
self.sources
.iter()
.map(|s| source_name_key(&s.name))
.collect()
}
}
fn replace_file_from_temp(temp_path: &Path, final_path: &Path) -> Result<(), std::io::Error> {
#[cfg(windows)]
{
match std::fs::rename(temp_path, final_path) {
Ok(()) => sync_parent_directory(final_path),
Err(first_err)
if final_path.exists()
&& matches!(
first_err.kind(),
std::io::ErrorKind::AlreadyExists | std::io::ErrorKind::PermissionDenied
) =>
{
let backup_path = unique_replace_backup_path(final_path);
std::fs::rename(final_path, &backup_path).map_err(|backup_err| {
let _ = std::fs::remove_file(temp_path);
std::io::Error::other(format!(
"failed preparing backup {} before replacing {}: first error: {}; backup error: {}",
backup_path.display(),
final_path.display(),
first_err,
backup_err
))
})?;
match std::fs::rename(temp_path, final_path) {
Ok(()) => {
let _ = std::fs::remove_file(&backup_path);
sync_parent_directory(final_path)
}
Err(second_err) => {
let restore_result = std::fs::rename(&backup_path, final_path);
match restore_result {
Ok(()) => {
let _ = std::fs::remove_file(temp_path);
sync_parent_directory(final_path).map_err(|sync_err| {
std::io::Error::other(format!(
"failed replacing {} with {}: first error: {}; second error: {}; restored original file but failed syncing parent directory: {}",
final_path.display(),
temp_path.display(),
first_err,
second_err,
sync_err
))
})?;
Err(std::io::Error::new(
second_err.kind(),
format!(
"failed replacing {} with {}: first error: {}; second error: {}; restored original file",
final_path.display(),
temp_path.display(),
first_err,
second_err
),
))
}
Err(restore_err) => Err(std::io::Error::other(format!(
"failed replacing {} with {}: first error: {}; second error: {}; restore error: {}; temp file retained at {}",
final_path.display(),
temp_path.display(),
first_err,
second_err,
restore_err,
temp_path.display()
))),
}
}
}
}
Err(rename_err) => Err(rename_err),
}
}
#[cfg(not(windows))]
{
std::fs::rename(temp_path, final_path)?;
sync_parent_directory(final_path)
}
}
fn sync_file_path(path: &Path) -> Result<(), std::io::Error> {
std::fs::File::open(path)?.sync_all()
}
#[cfg(not(windows))]
fn sync_parent_directory(path: &Path) -> Result<(), std::io::Error> {
let Some(parent) = path.parent() else {
return Ok(());
};
std::fs::File::open(parent)?.sync_all()
}
#[cfg(windows)]
fn sync_parent_directory(_path: &Path) -> Result<(), std::io::Error> {
Ok(())
}
fn unique_atomic_temp_path(path: &Path) -> PathBuf {
unique_atomic_sidecar_path(path, "tmp", "sources.toml")
}
fn unique_backup_path(path: &Path) -> PathBuf {
static NEXT_NONCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let nonce = NEXT_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or("sources.toml");
path.with_file_name(format!(
"{file_name}.backup.{}.{}.{}",
std::process::id(),
timestamp,
nonce
))
}
#[cfg(windows)]
fn unique_replace_backup_path(path: &Path) -> PathBuf {
unique_atomic_sidecar_path(path, "bak", "sources.toml")
}
fn unique_atomic_sidecar_path(path: &Path, suffix: &str, fallback_name: &str) -> PathBuf {
static NEXT_NONCE: std::sync::atomic::AtomicU64 = std::sync::atomic::AtomicU64::new(0);
let timestamp = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_nanos();
let nonce = NEXT_NONCE.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
let file_name = path
.file_name()
.and_then(|name| name.to_str())
.unwrap_or(fallback_name);
path.with_file_name(format!(
".{file_name}.{suffix}.{}.{}.{}",
std::process::id(),
timestamp,
nonce
))
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_empty_config_default() {
let config = SourcesConfig::default();
assert!(config.sources.is_empty());
}
#[test]
fn test_replace_file_from_temp_overwrites_existing_file() {
let temp = tempfile::tempdir().expect("tempdir");
let final_path = temp.path().join("sources.toml");
let first_tmp = temp.path().join("first.tmp");
let second_tmp = temp.path().join("second.tmp");
std::fs::write(&first_tmp, "first = true\n").expect("write first temp");
replace_file_from_temp(&first_tmp, &final_path).expect("initial replace");
assert_eq!(
std::fs::read_to_string(&final_path).expect("read first final"),
"first = true\n"
);
std::fs::write(&second_tmp, "second = true\n").expect("write second temp");
replace_file_from_temp(&second_tmp, &final_path).expect("overwrite replace");
assert_eq!(
std::fs::read_to_string(&final_path).expect("read second final"),
"second = true\n"
);
}
#[test]
fn test_unique_atomic_temp_path_changes_each_call() {
let final_path = Path::new("/tmp/sources.toml");
let first = unique_atomic_temp_path(final_path);
let second = unique_atomic_temp_path(final_path);
assert_ne!(first, second);
assert_eq!(first.parent(), final_path.parent());
assert_eq!(second.parent(), final_path.parent());
}
#[test]
fn test_unique_backup_path_changes_each_call() {
let final_path = Path::new("/tmp/sources.toml");
let first = unique_backup_path(final_path);
let second = unique_backup_path(final_path);
assert_ne!(first, second);
assert_eq!(first.parent(), final_path.parent());
assert_eq!(second.parent(), final_path.parent());
}
#[test]
fn test_config_path_from_parts_prefers_xdg_config_home() {
let temp = tempfile::tempdir().expect("tempdir");
let xdg_config_home = temp.path().join("xdg-config");
let platform_config_dir = temp.path().join("platform-config");
let home_dir = temp.path().join("home");
assert_eq!(
config_path_from_parts(
Some(xdg_config_home.clone()),
Some(platform_config_dir),
Some(home_dir)
)
.expect("path from xdg config home"),
xdg_config_home.join("cass").join("sources.toml")
);
}
#[test]
fn test_config_path_from_parts_prefers_existing_platform_path_before_dot_config() {
let temp = tempfile::tempdir().expect("tempdir");
let platform_config_dir = temp.path().join("platform-config");
let platform_path = platform_config_dir.join("cass").join("sources.toml");
let home_dir = temp.path().join("home");
let dot_config_path = home_dir.join(".config").join("cass").join("sources.toml");
std::fs::create_dir_all(platform_path.parent().expect("platform parent")).unwrap();
std::fs::create_dir_all(dot_config_path.parent().expect("dot-config parent")).unwrap();
std::fs::write(&platform_path, "").unwrap();
std::fs::write(&dot_config_path, "").unwrap();
assert_eq!(
config_path_from_parts(None, Some(platform_config_dir), Some(home_dir))
.expect("existing platform path"),
platform_path
);
}
#[test]
fn test_config_path_from_parts_uses_existing_dot_config_before_new_platform_path() {
let temp = tempfile::tempdir().expect("tempdir");
let platform_config_dir = temp.path().join("platform-config");
let home_dir = temp.path().join("home");
let dot_config_path = home_dir.join(".config").join("cass").join("sources.toml");
std::fs::create_dir_all(dot_config_path.parent().expect("dot-config parent")).unwrap();
std::fs::write(&dot_config_path, "").unwrap();
assert_eq!(
config_path_from_parts(None, Some(platform_config_dir), Some(home_dir))
.expect("existing dot-config path"),
dot_config_path
);
}
#[test]
fn test_source_definition_local() {
let source = SourceDefinition::local("test");
assert_eq!(source.name, "test");
assert_eq!(source.source_type, SourceKind::Local);
assert!(!source.is_remote());
}
#[test]
fn test_source_definition_ssh() {
let source = SourceDefinition::ssh("laptop", "user@laptop.local");
assert_eq!(source.name, "laptop");
assert_eq!(source.source_type, SourceKind::Ssh);
assert_eq!(source.host, Some("user@laptop.local".into()));
assert!(source.is_remote());
}
#[test]
fn test_source_validation_empty_name() {
let source = SourceDefinition::default();
assert!(source.validate().is_err());
let source = SourceDefinition::local(" ");
assert!(source.validate().is_err());
}
#[test]
fn test_source_validation_rejects_padded_names() {
let source = SourceDefinition::local(" laptop");
assert!(source.validate().is_err());
let source = SourceDefinition::local("laptop ");
assert!(source.validate().is_err());
}
#[test]
fn test_source_validation_dot_names() {
let source = SourceDefinition::local(".");
assert!(source.validate().is_err());
let source = SourceDefinition::local("..");
assert!(source.validate().is_err());
}
#[test]
fn test_source_validation_reserved_local_name() {
let source = SourceDefinition::ssh("local", "user@host");
assert!(source.validate().is_err());
let source = SourceDefinition::ssh("LOCAL", "user@host");
assert!(source.validate().is_err());
}
#[test]
fn test_normalize_generated_remote_source_name_disambiguates_local() {
assert_eq!(normalize_generated_remote_source_name("local"), "local-ssh");
assert_eq!(normalize_generated_remote_source_name("LOCAL"), "LOCAL-ssh");
assert_eq!(
normalize_generated_remote_source_name(" local "),
"local-ssh"
);
assert_eq!(normalize_generated_remote_source_name("laptop"), "laptop");
assert_eq!(normalize_generated_remote_source_name(" laptop "), "laptop");
}
#[test]
fn test_source_validation_ssh_without_host() {
let mut source = SourceDefinition::ssh("test", "host");
source.host = None;
assert!(source.validate().is_err());
}
#[test]
fn test_source_validation_ssh_host_hardening() {
let source = SourceDefinition::ssh("test", "user-name_1@host-name.example");
assert!(source.validate().is_ok());
let source = SourceDefinition::ssh("test", "ssh-config-alias");
assert!(source.validate().is_ok());
let source = SourceDefinition::ssh("test", "-oProxyCommand=evil");
assert!(source.validate().is_err());
let source = SourceDefinition::ssh("test", "user@host withspace");
assert!(source.validate().is_err());
for host in [
" user@host",
"user@host ",
"\tuser@host",
"user@host;touch /tmp/cass-owned",
"user@host`hostname`",
"user@host$(hostname)",
"user@host/../../secret",
"user@host:2222",
"üser@host",
"@host",
"user@",
"user@host@extra",
] {
let source = SourceDefinition::ssh("test", host);
assert!(
source.validate().is_err(),
"host should be rejected: {host:?}"
);
}
}
#[test]
fn test_source_validation_rejects_invalid_paths() {
for path in [
"",
" ",
" ~/.claude/projects",
"~/.claude/projects ",
"~/.claude\nprojects",
] {
let mut source = SourceDefinition::ssh("test", "user@host");
source.paths = vec![path.to_string()];
assert!(
source.validate().is_err(),
"path should be rejected: {path:?}"
);
}
let mut source = SourceDefinition::ssh("test", "user@host");
source.paths = vec!["~/Library/Application Support/Cursor/User/globalStorage".to_string()];
assert!(source.validate().is_ok());
}
#[test]
fn test_load_from_preserves_invalid_paths_for_operation_level_reporting() {
let temp = tempfile::tempdir().expect("tempdir");
let config_path = temp.path().join("sources.toml");
std::fs::write(
&config_path,
r#"
[[sources]]
name = "laptop"
type = "ssh"
host = "user@host"
paths = [" ~/.claude/projects", "~/.codex/sessions"]
"#,
)
.expect("write config");
let loaded = SourcesConfig::load_from(&config_path).expect("lenient load");
assert_eq!(loaded.sources.len(), 1);
assert_eq!(loaded.sources[0].paths[0], " ~/.claude/projects");
assert_eq!(loaded.sources[0].paths[1], "~/.codex/sessions");
assert!(
loaded.validate().is_err(),
"strict validation should still reject writing the malformed path"
);
}
#[test]
fn test_load_from_still_rejects_invalid_source_structure() {
let temp = tempfile::tempdir().expect("tempdir");
let config_path = temp.path().join("sources.toml");
std::fs::write(
&config_path,
r#"
[[sources]]
name = "laptop"
type = "ssh"
host = "user@host withspace"
paths = ["~/.claude/projects"]
"#,
)
.expect("write config");
assert!(
SourcesConfig::load_from(&config_path).is_err(),
"lenient load is only for per-path validation, not unsafe host structure"
);
}
#[test]
fn test_source_validation_path_mapping_empty_from() {
let mut source = SourceDefinition::local("test");
source.path_mappings.push(PathMapping::new("", "/Users/me"));
assert!(source.validate().is_err());
source.path_mappings.clear();
source
.path_mappings
.push(PathMapping::new(" ", "/Users/me"));
assert!(source.validate().is_err());
}
#[test]
fn test_source_validation_path_mapping_empty_to() {
let mut source = SourceDefinition::local("test");
source
.path_mappings
.push(PathMapping::new("/home/user", ""));
assert!(source.validate().is_err());
source.path_mappings.clear();
source
.path_mappings
.push(PathMapping::new("/home/user", " "));
assert!(source.validate().is_err());
}
#[test]
fn test_source_validation_path_mapping_empty_agent_names() {
let mut source = SourceDefinition::local("test");
source.path_mappings.push(PathMapping::with_agents(
"/home/user",
"/Users/me",
vec!["claude-code".into(), " ".into()],
));
assert!(source.validate().is_err());
}
#[test]
fn test_source_validation_path_mapping_empty_agents_list() {
let mut source = SourceDefinition::local("test");
source.path_mappings.push(PathMapping::with_agents(
"/home/user",
"/Users/me",
Vec::new(),
));
assert!(source.validate().is_err());
}
#[test]
fn test_path_mapping_new() {
let mapping = PathMapping::new("/home/user", "/Users/me");
assert_eq!(mapping.from, "/home/user");
assert_eq!(mapping.to, "/Users/me");
assert!(mapping.agents.is_none());
}
#[test]
fn test_path_mapping_with_agents() {
let mapping = PathMapping::with_agents(
"/home/user",
"/Users/me",
vec!["claude-code".into(), "cursor".into()],
);
assert_eq!(mapping.from, "/home/user");
assert_eq!(mapping.to, "/Users/me");
assert_eq!(
mapping.agents,
Some(vec!["claude-code".into(), "cursor".into()])
);
}
#[test]
fn test_path_mapping_apply() {
let mapping = PathMapping::new("/home/user/projects", "/Users/me/projects");
assert_eq!(
mapping.apply("/home/user/projects/myapp"),
Some("/Users/me/projects/myapp".into())
);
assert_eq!(mapping.apply("/opt/data"), None);
assert_eq!(mapping.apply("/data/home/user/projects"), None);
}
#[test]
fn test_path_mapping_applies_to_agent() {
let global = PathMapping::new("/home", "/Users");
assert!(path_mapping_applies_to_agent(&global, None));
assert!(path_mapping_applies_to_agent(&global, Some("claude-code")));
assert!(path_mapping_applies_to_agent(&global, Some("any-agent")));
let filtered = PathMapping::with_agents("/home", "/Users", vec!["claude-code".into()]);
assert!(path_mapping_applies_to_agent(&filtered, None));
let empty_filter = PathMapping::with_agents("/home", "/Users", Vec::new());
assert!(!path_mapping_applies_to_agent(&empty_filter, None));
assert!(path_mapping_applies_to_agent(
&filtered,
Some("claude-code")
));
assert!(!path_mapping_applies_to_agent(&filtered, Some("cursor")));
assert!(path_mapping_applies_to_agent(
&filtered,
Some("claude_code")
));
assert!(path_mapping_applies_to_agent(&filtered, Some("claude")));
let openclaw_filtered =
PathMapping::with_agents("/home", "/Users", vec!["openclaw".into()]);
assert!(path_mapping_applies_to_agent(
&openclaw_filtered,
Some("open-claw")
));
}
#[test]
fn test_path_rewriting() {
let mut source = SourceDefinition::local("test");
source.path_mappings.push(PathMapping::new(
"/home/user/projects",
"/Users/me/projects",
));
source
.path_mappings
.push(PathMapping::new("/home/user", "/Users/me"));
assert_eq!(
source.rewrite_path("/home/user/projects/myapp"),
"/Users/me/projects/myapp"
);
assert_eq!(source.rewrite_path("/home/user/other"), "/Users/me/other");
assert_eq!(source.rewrite_path("/opt/data"), "/opt/data");
}
#[test]
fn test_path_rewriting_with_agent_filter() {
let mut source = SourceDefinition::local("test");
source
.path_mappings
.push(PathMapping::new("/home/user", "/Users/me"));
source.path_mappings.push(PathMapping::with_agents(
"/home/user/projects",
"/Volumes/Work/projects",
vec!["claude-code".into()],
));
assert_eq!(
source.rewrite_path_for_agent("/home/user/projects/app", None),
"/Volumes/Work/projects/app"
);
assert_eq!(
source.rewrite_path_for_agent("/home/user/projects/app", Some("claude-code")),
"/Volumes/Work/projects/app"
);
assert_eq!(
source.rewrite_path_for_agent("/home/user/projects/app", Some("claude")),
"/Volumes/Work/projects/app"
);
assert_eq!(
source.rewrite_path_for_agent("/home/user/projects/app", Some("cursor")),
"/Users/me/projects/app"
);
assert_eq!(
source.rewrite_path_for_agent("/opt/data", Some("claude-code")),
"/opt/data"
);
}
#[test]
fn test_config_duplicate_names() {
let mut config = SourcesConfig::default();
config.sources.push(SourceDefinition::local("test"));
config.sources.push(SourceDefinition::local("test"));
assert!(config.validate().is_err());
}
#[test]
fn test_config_duplicate_names_case_insensitive() {
let mut config = SourcesConfig::default();
config
.sources
.push(SourceDefinition::ssh("Laptop", "user@laptop"));
config
.sources
.push(SourceDefinition::ssh("laptop", "user@other-host"));
assert!(config.validate().is_err());
}
#[test]
fn test_source_name_keys_trim_and_ignore_case() {
assert_eq!(source_name_key(" Laptop "), "laptop");
assert!(source_names_equal(" Laptop ", "laptop"));
}
#[test]
fn test_config_add_source() {
let mut config = SourcesConfig::default();
config.add_source(SourceDefinition::local("test")).unwrap();
assert_eq!(config.sources.len(), 1);
assert!(config.add_source(SourceDefinition::local("test")).is_err());
}
#[test]
fn test_config_add_source_case_insensitive_duplicate() {
let mut config = SourcesConfig::default();
config
.add_source(SourceDefinition::ssh("Laptop", "user@laptop"))
.unwrap();
assert!(
config
.add_source(SourceDefinition::ssh("laptop", "user@other-host"))
.is_err()
);
}
#[test]
fn test_config_remove_source() {
let mut config = SourcesConfig::default();
config.sources.push(SourceDefinition::local("test"));
assert!(config.remove_source("test"));
assert!(!config.remove_source("nonexistent"));
assert!(config.sources.is_empty());
}
#[test]
fn test_config_remove_source_case_insensitive() {
let mut config = SourcesConfig::default();
config
.sources
.push(SourceDefinition::ssh("Laptop", "user@laptop"));
assert!(config.remove_source("laptop"));
assert!(config.sources.is_empty());
}
#[test]
fn test_find_source_case_insensitive() {
let mut config = SourcesConfig::default();
config
.sources
.push(SourceDefinition::ssh("Laptop", "user@laptop"));
assert!(config.find_source("laptop").is_some());
assert!(config.find_source("LAPTOP").is_some());
assert!(config.find_source_mut("laptop").is_some());
}
#[test]
fn test_config_serialization_roundtrip() {
let mut config = SourcesConfig::default();
config.sources.push(SourceDefinition {
name: "laptop".into(),
source_type: SourceKind::Ssh,
host: Some("user@laptop.local".into()),
paths: vec!["~/.claude/projects".into()],
sync_schedule: SyncSchedule::Daily,
path_mappings: vec![PathMapping::new("/home/user", "/Users/me")],
platform: Some(Platform::Linux),
});
let serialized = toml::to_string_pretty(&config).unwrap();
let deserialized: SourcesConfig = toml::from_str(&serialized).unwrap();
assert_eq!(deserialized.sources.len(), 1);
assert_eq!(deserialized.sources[0].name, "laptop");
assert_eq!(deserialized.sources[0].sync_schedule, SyncSchedule::Daily);
assert_eq!(deserialized.sources[0].path_mappings.len(), 1);
assert_eq!(deserialized.sources[0].path_mappings[0].from, "/home/user");
assert_eq!(deserialized.sources[0].path_mappings[0].to, "/Users/me");
}
#[test]
fn test_path_mapping_serialization_with_agents() {
let mut config = SourcesConfig::default();
config.sources.push(SourceDefinition {
name: "remote".into(),
source_type: SourceKind::Ssh,
host: Some("user@server".into()),
paths: vec![],
sync_schedule: SyncSchedule::Manual,
path_mappings: vec![
PathMapping::new("/home/user", "/Users/me"),
PathMapping::with_agents("/opt/work", "/Volumes/Work", vec!["claude-code".into()]),
],
platform: None,
});
let serialized = toml::to_string_pretty(&config).unwrap();
let deserialized: SourcesConfig = toml::from_str(&serialized).unwrap();
assert_eq!(deserialized.sources[0].path_mappings.len(), 2);
assert!(deserialized.sources[0].path_mappings[0].agents.is_none());
assert_eq!(
deserialized.sources[0].path_mappings[1].agents,
Some(vec!["claude-code".into()])
);
}
#[test]
fn test_preset_paths() {
let macos = get_preset_paths("macos-defaults").unwrap();
assert!(!macos.is_empty());
assert!(macos.iter().any(|p| p.contains(".claude")));
let linux = get_preset_paths("linux-defaults").unwrap();
assert!(!linux.is_empty());
assert!(get_preset_paths("unknown").is_err());
}
#[test]
fn test_sync_schedule_display() {
assert_eq!(SyncSchedule::Manual.to_string(), SYNC_SCHEDULE_MANUAL);
assert_eq!(SyncSchedule::Hourly.to_string(), SYNC_SCHEDULE_HOURLY);
assert_eq!(SyncSchedule::Daily.to_string(), SYNC_SCHEDULE_DAILY);
}
#[test]
fn test_discover_ssh_hosts() {
let hosts = super::discover_ssh_hosts();
for host in hosts {
assert!(!host.name.is_empty());
}
}
#[test]
fn test_parse_ssh_config_splits_multiple_host_aliases() {
let hosts = super::parse_ssh_config(
r#"
Host alpha beta *.internal ?wild
HostName 192.0.2.10
User ubuntu
Port 2222
IdentityFile ~/.ssh/id_ed25519
Host gamma
User deploy
"#,
);
assert_eq!(hosts.len(), 3);
assert_eq!(hosts[0].name, "alpha");
assert_eq!(hosts[1].name, "beta");
assert_eq!(hosts[2].name, "gamma");
for host in &hosts[..2] {
assert_eq!(host.hostname.as_deref(), Some("192.0.2.10"));
assert_eq!(host.user.as_deref(), Some("ubuntu"));
assert_eq!(host.port, Some(2222));
assert_eq!(host.identity_file.as_deref(), Some("~/.ssh/id_ed25519"));
}
assert_eq!(hosts[2].user.as_deref(), Some("deploy"));
}
#[test]
fn test_parse_ssh_config_skips_negated_host_patterns() {
let hosts = super::parse_ssh_config(
r#"
Host * !bastion staging
User ubuntu
Host production !legacy-prod
User deploy
"#,
);
assert_eq!(hosts.len(), 2);
assert_eq!(hosts[0].name, "staging");
assert_eq!(hosts[0].user.as_deref(), Some("ubuntu"));
assert_eq!(hosts[1].name, "production");
assert_eq!(hosts[1].user.as_deref(), Some("deploy"));
}
#[test]
fn test_parse_ssh_config() {
let content = "
Host example
HostName example.com
User testuser
Host=another
Port=2222
IdentityFile = ~/.ssh/id_rsa
";
let hosts = parse_ssh_config(content);
assert_eq!(hosts.len(), 2);
assert_eq!(hosts[0].name, "example");
assert_eq!(hosts[0].hostname.as_deref(), Some("example.com"));
assert_eq!(hosts[0].user.as_deref(), Some("testuser"));
assert_eq!(hosts[1].name, "another");
assert_eq!(hosts[1].port, Some(2222));
assert_eq!(hosts[1].identity_file.as_deref(), Some("~/.ssh/id_rsa"));
}
use super::super::probe::{CassStatus, DetectedAgent, HostProbeResult, SystemInfo};
fn make_test_probe(
reachable: bool,
agents: Vec<DetectedAgent>,
sys_info: Option<SystemInfo>,
) -> HostProbeResult {
HostProbeResult {
host_name: "test-host".into(),
reachable,
connection_time_ms: 100,
cass_status: CassStatus::NotFound,
detected_agents: agents,
system_info: sys_info,
resources: None,
error: if reachable {
None
} else {
Some("connection refused".into())
},
}
}
fn make_test_agent(agent_type: &str, path: &str) -> DetectedAgent {
DetectedAgent {
agent_type: agent_type.into(),
path: path.into(),
estimated_sessions: Some(100),
estimated_size_mb: Some(50),
}
}
fn make_test_sys_info(os: &str, remote_home: &str) -> SystemInfo {
SystemInfo {
os: os.into(),
arch: "x86_64".into(),
distro: Some("Ubuntu 22.04".into()),
has_cargo: true,
has_cargo_binstall: true,
has_curl: true,
has_wget: true,
remote_home: remote_home.into(),
machine_id: None,
}
}
#[test]
fn test_source_config_generator_new() {
let generator = SourceConfigGenerator::new();
assert!(!generator.local_home.as_os_str().is_empty());
}
#[test]
fn test_generate_source_basic() {
let generator = SourceConfigGenerator::new();
let probe = make_test_probe(
true,
vec![make_test_agent("claude", "~/.claude/projects")],
Some(make_test_sys_info("linux", "/home/ubuntu")),
);
let source = generator.generate_source("my-server", &probe);
assert_eq!(source.name, "my-server");
assert_eq!(source.source_type, SourceKind::Ssh);
assert_eq!(source.host, Some("my-server".into()));
assert_eq!(source.sync_schedule, SyncSchedule::Manual);
assert!(!source.paths.is_empty());
assert!(source.paths.contains(&"~/.claude/projects".to_string()));
}
#[test]
fn test_generate_source_disambiguates_reserved_local_name() {
let generator = SourceConfigGenerator::new();
let probe = make_test_probe(
true,
vec![make_test_agent("claude", "~/.claude/projects")],
Some(make_test_sys_info("linux", "/home/ubuntu")),
);
let source = generator.generate_source("local", &probe);
assert_eq!(source.name, "local-ssh");
assert_eq!(source.host, Some("local".into()));
}
#[test]
fn test_generate_source_deduplicates_paths() {
let generator = SourceConfigGenerator::new();
let probe = make_test_probe(
true,
vec![
make_test_agent("claude", "~/.claude/projects"),
make_test_agent("claude-2", "~/.claude/projects"), ],
Some(make_test_sys_info("linux", "/home/user")),
);
let source = generator.generate_source("server", &probe);
assert_eq!(source.paths.len(), 1);
}
#[test]
fn test_generate_source_path_mappings() {
let generator = SourceConfigGenerator::new();
let probe = make_test_probe(
true,
vec![make_test_agent("claude", "~/.claude/projects")],
Some(make_test_sys_info("linux", "/home/ubuntu")),
);
let source = generator.generate_source("server", &probe);
assert!(!source.path_mappings.is_empty());
assert!(
source
.path_mappings
.iter()
.any(|m| m.from.contains("/home/ubuntu"))
);
}
#[test]
fn test_generate_source_platform_detection() {
let generator = SourceConfigGenerator::new();
let probe = make_test_probe(
true,
vec![],
Some(make_test_sys_info("linux", "/home/user")),
);
let source = generator.generate_source("server", &probe);
assert_eq!(source.platform, Some(Platform::Linux));
}
#[test]
fn test_generate_preview_basic() {
let generator = SourceConfigGenerator::new();
let probe = make_test_probe(
true,
vec![make_test_agent("claude", "~/.claude/projects")],
Some(make_test_sys_info("linux", "/home/user")),
);
let probes: Vec<(&str, &HostProbeResult)> = vec![("server1", &probe)];
let preview = generator.generate_preview(&probes, &HashSet::new());
assert_eq!(preview.sources_to_add.len(), 1);
assert!(preview.sources_skipped.is_empty());
assert!(preview.has_changes());
}
#[test]
fn test_generate_preview_skips_already_configured() {
let generator = SourceConfigGenerator::new();
let probe = make_test_probe(
true,
vec![make_test_agent("claude", "~/.claude/projects")],
Some(make_test_sys_info("linux", "/home/user")),
);
let probes: Vec<(&str, &HostProbeResult)> = vec![("server1", &probe)];
let mut configured = HashSet::new();
configured.insert("server1".to_string());
let preview = generator.generate_preview(&probes, &configured);
assert!(preview.sources_to_add.is_empty());
assert_eq!(preview.sources_skipped.len(), 1);
}
#[test]
fn test_generate_preview_skips_already_configured_case_insensitive() {
let generator = SourceConfigGenerator::new();
let probe = make_test_probe(
true,
vec![make_test_agent("claude", "~/.claude/projects")],
Some(make_test_sys_info("linux", "/home/user")),
);
let probes: Vec<(&str, &HostProbeResult)> = vec![("Laptop", &probe)];
let mut configured = HashSet::new();
configured.insert(source_name_key("laptop"));
let preview = generator.generate_preview(&probes, &configured);
assert!(preview.sources_to_add.is_empty());
assert_eq!(preview.sources_skipped.len(), 1);
}
#[test]
fn test_generate_preview_skips_already_configured_case_insensitively_with_raw_names() {
let generator = SourceConfigGenerator::new();
let probe = make_test_probe(
true,
vec![make_test_agent("claude", "~/.claude/projects")],
Some(make_test_sys_info("linux", "/home/user")),
);
let probes: Vec<(&str, &HostProbeResult)> = vec![("laptop", &probe)];
let mut configured = HashSet::new();
configured.insert("Laptop".to_string());
let preview = generator.generate_preview(&probes, &configured);
assert!(preview.sources_to_add.is_empty());
assert_eq!(preview.sources_skipped.len(), 1);
assert!(matches!(
preview.sources_skipped[0].1,
SkipReason::AlreadyConfigured
));
}
#[test]
fn test_generate_preview_preserves_already_configured_skip_for_invalid_probe_data() {
let generator = SourceConfigGenerator::new();
let probe = make_test_probe(
true,
vec![make_test_agent("claude", "bad\npath")],
Some(make_test_sys_info("linux", "/home/user")),
);
let probes: Vec<(&str, &HostProbeResult)> = vec![("server1", &probe)];
let mut configured = HashSet::new();
configured.insert("server1".to_string());
let preview = generator.generate_preview(&probes, &configured);
assert!(preview.sources_to_add.is_empty());
assert_eq!(preview.sources_skipped.len(), 1);
assert_eq!(preview.sources_skipped[0].0, "server1");
assert!(matches!(
preview.sources_skipped[0].1,
SkipReason::AlreadyConfigured
));
}
#[test]
fn test_generate_preview_skips_conflicting_generated_names_case_insensitive() {
let generator = SourceConfigGenerator::new();
let probe = make_test_probe(
true,
vec![make_test_agent("claude", "~/.claude/projects")],
Some(make_test_sys_info("linux", "/home/user")),
);
let probes: Vec<(&str, &HostProbeResult)> = vec![("Laptop", &probe), ("laptop", &probe)];
let preview = generator.generate_preview(&probes, &HashSet::new());
assert_eq!(preview.sources_to_add.len(), 1);
assert_eq!(preview.sources_to_add[0].name, "Laptop");
assert_eq!(preview.sources_skipped.len(), 1);
assert_eq!(preview.sources_skipped[0].0, "laptop");
assert!(matches!(
&preview.sources_skipped[0].1,
SkipReason::GeneratedNameConflict(name) if name == "laptop"
));
}
#[test]
fn test_generate_preview_invalid_source_does_not_shadow_later_valid_duplicate() {
let generator = SourceConfigGenerator::new();
let invalid_probe = make_test_probe(
true,
vec![make_test_agent("claude", "bad\npath")],
Some(make_test_sys_info("linux", "/home/user")),
);
let valid_probe = make_test_probe(
true,
vec![make_test_agent("claude", "~/.claude/projects")],
Some(make_test_sys_info("linux", "/home/user")),
);
let probes: Vec<(&str, &HostProbeResult)> =
vec![("Laptop", &invalid_probe), ("laptop", &valid_probe)];
let preview = generator.generate_preview(&probes, &HashSet::new());
assert_eq!(preview.sources_to_add.len(), 1);
assert_eq!(preview.sources_to_add[0].name, "laptop");
assert_eq!(preview.sources_skipped.len(), 1);
assert_eq!(preview.sources_skipped[0].0, "Laptop");
assert!(matches!(
&preview.sources_skipped[0].1,
SkipReason::InvalidSourceDefinition(message)
if message.contains("paths[0] cannot contain control characters")
));
}
#[test]
fn test_generate_preview_skips_invalid_generated_sources_before_merge() {
let generator = SourceConfigGenerator::new();
let invalid_host_probe = make_test_probe(
true,
vec![make_test_agent("claude", "~/.claude/projects")],
Some(make_test_sys_info("linux", "/home/user")),
);
let invalid_path_probe = make_test_probe(
true,
vec![make_test_agent("claude", "bad\npath")],
Some(make_test_sys_info("linux", "/home/user")),
);
let valid_probe = make_test_probe(
true,
vec![make_test_agent("claude", "~/.claude/projects")],
Some(make_test_sys_info("linux", "/home/user")),
);
let probes: Vec<(&str, &HostProbeResult)> = vec![
("bad host", &invalid_host_probe),
("path-host", &invalid_path_probe),
("server1", &valid_probe),
];
let preview = generator.generate_preview(&probes, &HashSet::new());
assert_eq!(preview.sources_to_add.len(), 1);
assert_eq!(preview.sources_to_add[0].name, "server1");
assert_eq!(preview.sources_skipped.len(), 2);
assert_eq!(preview.sources_skipped[0].0, "bad host");
assert!(matches!(
&preview.sources_skipped[0].1,
SkipReason::InvalidSourceDefinition(message)
if message.contains("SSH host cannot contain whitespace")
));
assert_eq!(preview.sources_skipped[1].0, "path-host");
assert!(matches!(
&preview.sources_skipped[1].1,
SkipReason::InvalidSourceDefinition(message)
if message.contains("paths[0] cannot contain control characters")
));
let mut config = SourcesConfig::default();
let (added, skipped) = config.merge_preview(&preview).unwrap();
assert_eq!(added, 1);
assert!(skipped.is_empty());
assert_eq!(config.sources.len(), 1);
assert_eq!(config.sources[0].name, "server1");
}
#[test]
fn test_merge_source() {
let mut config = SourcesConfig::default();
let source = SourceDefinition::ssh("new-server", "user@server");
let result = config.merge_source(source).unwrap();
assert!(matches!(result, MergeResult::Added(_)));
assert_eq!(config.sources.len(), 1);
}
#[test]
fn test_merge_source_already_exists() {
let mut config = SourcesConfig::default();
config.sources.push(SourceDefinition::ssh("server", "host"));
let source = SourceDefinition::ssh("server", "other-host");
let result = config.merge_source(source).unwrap();
assert!(matches!(result, MergeResult::AlreadyExists(_)));
assert_eq!(config.sources.len(), 1);
}
#[test]
fn test_merge_source_already_exists_case_insensitive() {
let mut config = SourcesConfig::default();
config.sources.push(SourceDefinition::ssh("Server", "host"));
let source = SourceDefinition::ssh("server", "other-host");
let result = config.merge_source(source).unwrap();
assert!(matches!(result, MergeResult::AlreadyExists(_)));
assert_eq!(config.sources.len(), 1);
}
#[test]
fn test_configured_names() {
let mut config = SourcesConfig::default();
config.sources.push(SourceDefinition::ssh("server1", "h1"));
config.sources.push(SourceDefinition::ssh("server2", "h2"));
let names = config.configured_names();
assert_eq!(names.len(), 2);
assert!(names.contains("server1"));
assert!(names.contains("server2"));
}
#[test]
fn test_exclude_and_include_agents_normalize_and_dedup() {
let mut config = SourcesConfig::default();
assert!(config.exclude_agent_from_indexing(" OpenClaw ").unwrap());
assert!(!config.exclude_agent_from_indexing("open-claw").unwrap());
assert!(config.is_agent_disabled("openclaw"));
assert_eq!(config.configured_disabled_agents(), vec!["openclaw"]);
assert!(config.include_agent_in_indexing("open_claw").unwrap());
assert!(!config.is_agent_disabled("openclaw"));
assert!(config.configured_disabled_agents().is_empty());
}
#[test]
fn test_exclude_agent_aliases_collapse_to_internal_connector_slug() {
let mut config = SourcesConfig::default();
assert!(config.exclude_agent_from_indexing("claude-code").unwrap());
assert!(config.is_agent_disabled("claude"));
assert!(config.is_agent_disabled("claude_code"));
assert_eq!(config.configured_disabled_agents(), vec!["claude"]);
}
#[test]
fn test_validate_rejects_empty_disabled_agent_entry() {
let mut config = SourcesConfig::default();
config.disabled_agents.push(" ".into());
let err = config
.validate()
.expect_err("disabled_agents entry should fail");
assert!(matches!(err, ConfigError::Validation(_)));
}
#[test]
fn test_sources_config_roundtrip_preserves_disabled_agents() {
let mut config = SourcesConfig::default();
config.exclude_agent_from_indexing("openclaw").unwrap();
config.exclude_agent_from_indexing("claude-code").unwrap();
let serialized = toml::to_string_pretty(&config).unwrap();
let deserialized: SourcesConfig = toml::from_str(&serialized).unwrap();
assert_eq!(
deserialized.configured_disabled_agents(),
vec!["claude", "openclaw"]
);
}
#[test]
fn test_configured_name_keys_normalize_case() {
let mut config = SourcesConfig::default();
config.sources.push(SourceDefinition::ssh("Server1", "h1"));
config.sources.push(SourceDefinition::ssh("server2", "h2"));
let names = config.configured_name_keys();
assert_eq!(names.len(), 2);
assert!(names.contains("server1"));
assert!(names.contains("server2"));
}
#[test]
fn test_save_to_rejects_invalid_config() {
let temp = tempfile::tempdir().expect("tempdir");
let path = temp.path().join("sources.toml");
let mut config = SourcesConfig::default();
config
.sources
.push(SourceDefinition::ssh("local", "user@host"));
let err = config
.save_to(&path)
.expect_err("save_to should reject invalid config");
assert!(matches!(err, ConfigError::Validation(_)));
assert!(!path.exists(), "invalid config should not be written");
}
#[test]
fn test_empty_remote_home_no_mappings() {
let generator = SourceConfigGenerator::new();
let mut sys_info = make_test_sys_info("linux", "");
sys_info.remote_home = "".into();
let probe = make_test_probe(
true,
vec![make_test_agent("claude", "~/.claude/projects")],
Some(sys_info),
);
let source = generator.generate_source("server", &probe);
assert!(source.path_mappings.is_empty());
}
#[test]
fn test_trailing_slash_remote_home_normalized() {
let generator = SourceConfigGenerator::new();
let mut sys_info = make_test_sys_info("linux", "/home/user/");
sys_info.remote_home = "/home/user/".into();
let probe = make_test_probe(
true,
vec![make_test_agent("claude", "~/.claude/projects")],
Some(sys_info),
);
let source = generator.generate_source("server", &probe);
assert!(!source.path_mappings.is_empty());
let projects_mapping = source
.path_mappings
.iter()
.find(|m| m.from.contains("projects"));
assert!(projects_mapping.is_some());
assert!(
!projects_mapping.unwrap().from.contains("//"),
"Path mapping should not contain double slashes: {}",
projects_mapping.unwrap().from
);
}
}