use std::collections::HashMap;
use crate::ssh_config::model::{ConfigElement, HostEntry, SshConfigFile};
use super::config::ProviderSection;
use super::{Provider, ProviderHost};
#[derive(Debug, Default)]
pub struct SyncResult {
pub added: usize,
pub updated: usize,
pub removed: usize,
pub unchanged: usize,
pub renames: Vec<(String, String)>,
}
fn sanitize_name(name: &str) -> String {
let mut result = String::new();
for c in name.chars() {
if c.is_ascii_alphanumeric() {
result.push(c.to_ascii_lowercase());
} else if !result.ends_with('-') {
result.push('-');
}
}
let trimmed = result.trim_matches('-').to_string();
if trimmed.is_empty() {
"server".to_string()
} else {
trimmed
}
}
fn build_alias(prefix: &str, sanitized: &str) -> String {
if prefix.is_empty() {
sanitized.to_string()
} else {
format!("{}-{}", prefix, sanitized)
}
}
fn is_volatile_meta(key: &str) -> bool {
key == "status"
}
pub fn sync_provider(
config: &mut SshConfigFile,
provider: &dyn Provider,
remote_hosts: &[ProviderHost],
section: &ProviderSection,
remove_deleted: bool,
dry_run: bool,
) -> SyncResult {
sync_provider_with_options(
config,
provider,
remote_hosts,
section,
remove_deleted,
dry_run,
false,
)
}
pub fn sync_provider_with_options(
config: &mut SshConfigFile,
provider: &dyn Provider,
remote_hosts: &[ProviderHost],
section: &ProviderSection,
remove_deleted: bool,
dry_run: bool,
reset_tags: bool,
) -> SyncResult {
let mut result = SyncResult::default();
let existing = config.find_hosts_by_provider(provider.name());
let mut existing_map: HashMap<String, String> = HashMap::new();
for (alias, server_id) in &existing {
existing_map
.entry(server_id.clone())
.or_insert_with(|| alias.clone());
}
let entries_map: HashMap<String, HostEntry> = config
.host_entries()
.into_iter()
.map(|e| (e.alias.clone(), e))
.collect();
let mut remote_ids: std::collections::HashSet<String> = std::collections::HashSet::new();
let mut needs_header = !dry_run && existing_map.is_empty();
for remote in remote_hosts {
if !remote_ids.insert(remote.server_id.clone()) {
continue; }
if remote.ip.is_empty() {
if existing_map.contains_key(&remote.server_id) {
result.unchanged += 1;
}
continue;
}
if let Some(existing_alias) = existing_map.get(&remote.server_id) {
if let Some(entry) = entries_map.get(existing_alias) {
if entry.source_file.is_some() {
result.unchanged += 1;
continue;
}
let sanitized = sanitize_name(&remote.name);
let expected_alias = build_alias(§ion.alias_prefix, &sanitized);
let alias_changed = *existing_alias != expected_alias;
let ip_changed = entry.hostname != remote.ip;
let meta_changed = {
let mut local: Vec<(&str, &str)> = entry
.provider_meta
.iter()
.filter(|(k, _)| !is_volatile_meta(k))
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
local.sort();
let mut remote_m: Vec<(&str, &str)> = remote
.metadata
.iter()
.filter(|(k, _)| !is_volatile_meta(k))
.map(|(k, v)| (k.as_str(), v.as_str()))
.collect();
remote_m.sort();
local != remote_m
};
let trimmed_remote: Vec<String> =
remote.tags.iter().map(|t| t.trim().to_string()).collect();
let tags_changed = if reset_tags {
let mut sorted_local: Vec<String> =
entry.tags.iter().map(|t| t.to_lowercase()).collect();
sorted_local.sort();
let mut sorted_remote: Vec<String> =
trimmed_remote.iter().map(|t| t.to_lowercase()).collect();
sorted_remote.sort();
sorted_local != sorted_remote
} else {
trimmed_remote.iter().any(|rt| {
!entry
.tags
.iter()
.any(|lt| lt.eq_ignore_ascii_case(rt))
})
};
if alias_changed || ip_changed || tags_changed || meta_changed {
if dry_run {
result.updated += 1;
} else {
let new_alias = if alias_changed {
config.deduplicate_alias_excluding(
&expected_alias,
Some(existing_alias),
)
} else {
existing_alias.clone()
};
let alias_changed = new_alias != *existing_alias;
if alias_changed || ip_changed || tags_changed || meta_changed {
if alias_changed || ip_changed {
let updated = HostEntry {
alias: new_alias.clone(),
hostname: remote.ip.clone(),
..entry.clone()
};
config.update_host(existing_alias, &updated);
}
let tags_alias =
if alias_changed { &new_alias } else { existing_alias };
if tags_changed {
if reset_tags {
config.set_host_tags(tags_alias, &trimmed_remote);
} else {
let mut merged = entry.tags.clone();
for rt in &trimmed_remote {
if !merged.iter().any(|t| t.eq_ignore_ascii_case(rt)) {
merged.push(rt.clone());
}
}
config.set_host_tags(tags_alias, &merged);
}
}
if alias_changed {
config.set_host_provider(
&new_alias,
provider.name(),
&remote.server_id,
);
result.renames.push((existing_alias.clone(), new_alias.clone()));
}
if meta_changed {
config.set_host_meta(tags_alias, &remote.metadata);
}
result.updated += 1;
} else {
result.unchanged += 1;
}
}
} else {
result.unchanged += 1;
}
} else {
result.unchanged += 1;
}
} else {
let sanitized = sanitize_name(&remote.name);
let base_alias = build_alias(§ion.alias_prefix, &sanitized);
let alias = if dry_run {
base_alias
} else {
config.deduplicate_alias(&base_alias)
};
if !dry_run {
let wrote_header = needs_header;
if needs_header {
if !config.elements.is_empty() && !config.last_element_has_trailing_blank() {
config
.elements
.push(ConfigElement::GlobalLine(String::new()));
}
config
.elements
.push(ConfigElement::GlobalLine(format!(
"# purple:group {}",
super::provider_display_name(provider.name())
)));
needs_header = false;
}
let entry = HostEntry {
alias: alias.clone(),
hostname: remote.ip.clone(),
user: section.user.clone(),
identity_file: section.identity_file.clone(),
tags: remote.tags.clone(),
provider: Some(provider.name().to_string()),
..Default::default()
};
if !wrote_header
&& !config.elements.is_empty()
&& !config.last_element_has_trailing_blank()
{
config
.elements
.push(ConfigElement::GlobalLine(String::new()));
}
let block = SshConfigFile::entry_to_block(&entry);
config.elements.push(ConfigElement::HostBlock(block));
config.set_host_provider(&alias, provider.name(), &remote.server_id);
if !remote.tags.is_empty() {
config.set_host_tags(&alias, &remote.tags);
}
if !remote.metadata.is_empty() {
config.set_host_meta(&alias, &remote.metadata);
}
}
result.added += 1;
}
}
if remove_deleted && !dry_run {
let to_remove: Vec<String> = existing_map
.iter()
.filter(|(id, _)| !remote_ids.contains(id.as_str()))
.filter(|(_, alias)| {
entries_map
.get(alias.as_str())
.is_none_or(|e| e.source_file.is_none())
})
.map(|(_, alias)| alias.clone())
.collect();
for alias in &to_remove {
config.delete_host(alias);
}
result.removed = to_remove.len();
if config.find_hosts_by_provider(provider.name()).is_empty() {
let header_text = format!("# purple:group {}", super::provider_display_name(provider.name()));
config
.elements
.retain(|e| !matches!(e, ConfigElement::GlobalLine(line) if line == &header_text));
}
} else if remove_deleted {
result.removed = existing_map
.iter()
.filter(|(id, _)| !remote_ids.contains(id.as_str()))
.filter(|(_, alias)| {
entries_map
.get(alias.as_str())
.is_none_or(|e| e.source_file.is_none())
})
.count();
}
result
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn empty_config() -> SshConfigFile {
SshConfigFile {
elements: Vec::new(),
path: PathBuf::from("/tmp/test_config"),
crlf: false,
}
}
fn make_section() -> ProviderSection {
ProviderSection {
provider: "digitalocean".to_string(),
token: "test".to_string(),
alias_prefix: "do".to_string(),
user: "root".to_string(),
identity_file: String::new(),
url: String::new(),
verify_tls: true,
auto_sync: true,
profile: String::new(),
regions: String::new(),
project: String::new(),
}
}
struct MockProvider;
impl Provider for MockProvider {
fn name(&self) -> &str {
"digitalocean"
}
fn short_label(&self) -> &str {
"do"
}
fn fetch_hosts_cancellable(
&self,
_token: &str,
_cancel: &std::sync::atomic::AtomicBool,
) -> Result<Vec<ProviderHost>, super::super::ProviderError> {
Ok(Vec::new())
}
}
#[test]
fn test_build_alias() {
assert_eq!(build_alias("do", "web-1"), "do-web-1");
assert_eq!(build_alias("", "web-1"), "web-1");
assert_eq!(build_alias("ocean", "db"), "ocean-db");
}
#[test]
fn test_sanitize_name() {
assert_eq!(sanitize_name("web-1"), "web-1");
assert_eq!(sanitize_name("My Server"), "my-server");
assert_eq!(sanitize_name("test.prod.us"), "test-prod-us");
assert_eq!(sanitize_name("--weird--"), "weird");
assert_eq!(sanitize_name("UPPER"), "upper");
assert_eq!(sanitize_name("a--b"), "a-b");
assert_eq!(sanitize_name(""), "server");
assert_eq!(sanitize_name("..."), "server");
}
#[test]
fn test_sync_adds_new_hosts() {
let mut config = empty_config();
let section = make_section();
let remote = vec![
ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new()),
ProviderHost::new("456".to_string(), "db-1".to_string(), "5.6.7.8".to_string(), Vec::new()),
];
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(result.added, 2);
assert_eq!(result.updated, 0);
assert_eq!(result.unchanged, 0);
let entries = config.host_entries();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].alias, "do-web-1");
assert_eq!(entries[0].hostname, "1.2.3.4");
assert_eq!(entries[1].alias, "do-db-1");
}
#[test]
fn test_sync_updates_changed_ip() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "9.8.7.6".to_string(), Vec::new())];
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(result.updated, 1);
assert_eq!(result.added, 0);
let entries = config.host_entries();
assert_eq!(entries[0].hostname, "9.8.7.6");
}
#[test]
fn test_sync_unchanged() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(result.unchanged, 1);
assert_eq!(result.added, 0);
assert_eq!(result.updated, 0);
}
#[test]
fn test_sync_removes_deleted() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(config.host_entries().len(), 1);
let result =
sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
assert_eq!(result.removed, 1);
assert_eq!(config.host_entries().len(), 0);
}
#[test]
fn test_sync_dry_run_no_mutations() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, true);
assert_eq!(result.added, 1);
assert_eq!(config.host_entries().len(), 0); }
#[test]
fn test_sync_dedup_server_id_in_response() {
let mut config = empty_config();
let section = make_section();
let remote = vec![
ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new()),
ProviderHost::new("123".to_string(), "web-1-dup".to_string(), "5.6.7.8".to_string(), Vec::new()),
];
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(result.added, 1);
assert_eq!(config.host_entries().len(), 1);
assert_eq!(config.host_entries()[0].alias, "do-web-1");
}
#[test]
fn test_sync_duplicate_local_server_id_keeps_first() {
let content = "\
Host do-web-1
HostName 1.2.3.4
# purple:provider digitalocean:123
Host do-web-1-copy
HostName 1.2.3.4
# purple:provider digitalocean:123
";
let mut config = SshConfigFile {
elements: SshConfigFile::parse_content(content),
path: PathBuf::from("/tmp/test_config"),
crlf: false,
};
let section = make_section();
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "5.6.7.8".to_string(), Vec::new())];
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(result.updated, 1);
assert_eq!(result.added, 0);
let entries = config.host_entries();
let first = entries.iter().find(|e| e.alias == "do-web-1").unwrap();
assert_eq!(first.hostname, "5.6.7.8");
let copy = entries.iter().find(|e| e.alias == "do-web-1-copy").unwrap();
assert_eq!(copy.hostname, "1.2.3.4");
}
#[test]
fn test_sync_no_duplicate_header_on_repeated_sync() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let remote = vec![
ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new()),
ProviderHost::new("456".to_string(), "db-1".to_string(), "5.6.7.8".to_string(), Vec::new()),
];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let header_count = config
.elements
.iter()
.filter(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"))
.count();
assert_eq!(header_count, 1);
assert_eq!(config.host_entries().len(), 2);
}
#[test]
fn test_sync_removes_orphan_header() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let has_header = config
.elements
.iter()
.any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"));
assert!(has_header);
let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
assert_eq!(result.removed, 1);
let has_header = config
.elements
.iter()
.any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# purple:group DigitalOcean"));
assert!(!has_header);
}
#[test]
fn test_sync_writes_provider_tags() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["production".to_string(), "us-east".to_string()])];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let entries = config.host_entries();
assert_eq!(entries[0].tags, vec!["production", "us-east"]);
}
#[test]
fn test_sync_updates_changed_tags() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["staging".to_string()])];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(config.host_entries()[0].tags, vec!["staging"]);
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["production".to_string(), "us-east".to_string()])];
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(result.updated, 1);
assert_eq!(
config.host_entries()[0].tags,
vec!["staging", "production", "us-east"]
);
}
#[test]
fn test_sync_combined_add_update_remove() {
let mut config = empty_config();
let section = make_section();
let remote = vec![
ProviderHost::new("1".to_string(), "web".to_string(), "1.1.1.1".to_string(), Vec::new()),
ProviderHost::new("2".to_string(), "db".to_string(), "2.2.2.2".to_string(), Vec::new()),
];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(config.host_entries().len(), 2);
let remote = vec![
ProviderHost::new("1".to_string(), "web".to_string(), "9.9.9.9".to_string(), Vec::new()),
ProviderHost::new("3".to_string(), "cache".to_string(), "3.3.3.3".to_string(), Vec::new()),
];
let result =
sync_provider(&mut config, &MockProvider, &remote, §ion, true, false);
assert_eq!(result.updated, 1);
assert_eq!(result.added, 1);
assert_eq!(result.removed, 1);
let entries = config.host_entries();
assert_eq!(entries.len(), 2); assert_eq!(entries[0].alias, "do-web");
assert_eq!(entries[0].hostname, "9.9.9.9");
assert_eq!(entries[1].alias, "do-cache");
}
#[test]
fn test_sync_tag_order_insensitive() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["beta".to_string(), "alpha".to_string()])];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["alpha".to_string(), "beta".to_string()])];
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(result.unchanged, 1);
assert_eq!(result.updated, 0);
}
fn config_with_include_provider_host() -> SshConfigFile {
use crate::ssh_config::model::{IncludeDirective, IncludedFile};
let content = "Host do-included\n HostName 1.2.3.4\n User root\n # purple:provider digitalocean:inc1\n";
let included_elements = SshConfigFile::parse_content(content);
SshConfigFile {
elements: vec![ConfigElement::Include(IncludeDirective {
raw_line: "Include conf.d/*".to_string(),
pattern: "conf.d/*".to_string(),
resolved_files: vec![IncludedFile {
path: PathBuf::from("/tmp/included.conf"),
elements: included_elements,
}],
})],
path: PathBuf::from("/tmp/test_config"),
crlf: false,
}
}
#[test]
fn test_sync_include_host_skips_update() {
let mut config = config_with_include_provider_host();
let section = make_section();
let remote = vec![ProviderHost::new("inc1".to_string(), "included".to_string(), "9.9.9.9".to_string(), Vec::new())];
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(result.unchanged, 1);
assert_eq!(result.updated, 0);
assert_eq!(result.added, 0);
let entries = config.host_entries();
let included = entries.iter().find(|e| e.alias == "do-included").unwrap();
assert_eq!(included.hostname, "1.2.3.4");
}
#[test]
fn test_sync_include_host_skips_remove() {
let mut config = config_with_include_provider_host();
let section = make_section();
let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
assert_eq!(result.removed, 0);
assert_eq!(config.host_entries().len(), 1);
}
#[test]
fn test_sync_dry_run_remove_count() {
let mut config = empty_config();
let section = make_section();
let remote = vec![
ProviderHost::new("1".to_string(), "web".to_string(), "1.1.1.1".to_string(), Vec::new()),
ProviderHost::new("2".to_string(), "db".to_string(), "2.2.2.2".to_string(), Vec::new()),
];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(config.host_entries().len(), 2);
let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, true);
assert_eq!(result.removed, 2);
assert_eq!(config.host_entries().len(), 2); }
#[test]
fn test_sync_tags_cleared_remotely_preserved_locally() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["production".to_string()])];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(config.host_entries()[0].tags, vec!["production"]);
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(result.unchanged, 1);
assert_eq!(config.host_entries()[0].tags, vec!["production"]);
}
#[test]
fn test_sync_deduplicates_alias() {
let content = "Host do-web-1\n HostName 10.0.0.1\n";
let mut config = SshConfigFile {
elements: SshConfigFile::parse_content(content),
path: PathBuf::from("/tmp/test_config"),
crlf: false,
};
let section = make_section();
let remote = vec![ProviderHost::new("999".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let entries = config.host_entries();
assert_eq!(entries.len(), 2);
assert_eq!(entries[0].alias, "do-web-1");
assert_eq!(entries[1].alias, "do-web-1-2");
}
#[test]
fn test_sync_renames_on_prefix_change() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(config.host_entries()[0].alias, "do-web-1");
let new_section = ProviderSection {
alias_prefix: "ocean".to_string(),
..section
};
let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
assert_eq!(result.updated, 1);
assert_eq!(result.unchanged, 0);
let entries = config.host_entries();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].alias, "ocean-web-1");
assert_eq!(entries[0].hostname, "1.2.3.4");
}
#[test]
fn test_sync_rename_and_ip_change() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let new_section = ProviderSection {
alias_prefix: "ocean".to_string(),
..section
};
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "9.9.9.9".to_string(), Vec::new())];
let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
assert_eq!(result.updated, 1);
let entries = config.host_entries();
assert_eq!(entries[0].alias, "ocean-web-1");
assert_eq!(entries[0].hostname, "9.9.9.9");
}
#[test]
fn test_sync_rename_dry_run_no_mutation() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let new_section = ProviderSection {
alias_prefix: "ocean".to_string(),
..section
};
let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, true);
assert_eq!(result.updated, 1);
assert_eq!(config.host_entries()[0].alias, "do-web-1");
}
#[test]
fn test_sync_no_rename_when_prefix_unchanged() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(result.unchanged, 1);
assert_eq!(result.updated, 0);
assert_eq!(config.host_entries()[0].alias, "do-web-1");
}
#[test]
fn test_sync_manual_comment_survives_cleanup() {
let content = "# DigitalOcean\nHost do-web\n HostName 1.2.3.4\n User root\n # purple:provider digitalocean:123\n";
let mut config = SshConfigFile {
elements: SshConfigFile::parse_content(content),
path: PathBuf::from("/tmp/test_config"),
crlf: false,
};
let section = make_section();
sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
let has_manual = config
.elements
.iter()
.any(|e| matches!(e, ConfigElement::GlobalLine(line) if line == "# DigitalOcean"));
assert!(has_manual, "Manual comment without purple:group prefix should survive cleanup");
}
#[test]
fn test_sync_rename_skips_included_host() {
let mut config = config_with_include_provider_host();
let new_section = ProviderSection {
provider: "digitalocean".to_string(),
token: "test".to_string(),
alias_prefix: "ocean".to_string(), user: "root".to_string(),
identity_file: String::new(),
url: String::new(),
verify_tls: true,
auto_sync: true,
profile: String::new(),
regions: String::new(),
project: String::new(),
};
let remote = vec![ProviderHost::new("inc1".to_string(), "included".to_string(), "1.2.3.4".to_string(), Vec::new())];
let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
assert_eq!(result.unchanged, 1);
assert_eq!(result.updated, 0);
assert_eq!(config.host_entries()[0].alias, "do-included");
}
#[test]
fn test_sync_rename_stable_with_manual_collision() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(config.host_entries()[0].alias, "do-web-1");
let manual = HostEntry {
alias: "ocean-web-1".to_string(),
hostname: "5.5.5.5".to_string(),
..Default::default()
};
config.add_host(&manual);
let new_section = ProviderSection {
alias_prefix: "ocean".to_string(),
..section.clone()
};
let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
assert_eq!(result.updated, 1);
let entries = config.host_entries();
let provider_host = entries.iter().find(|e| e.hostname == "1.2.3.4").unwrap();
assert_eq!(provider_host.alias, "ocean-web-1-2");
let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, false);
assert_eq!(result.unchanged, 1, "Should be unchanged on repeat sync");
let entries = config.host_entries();
let provider_host = entries.iter().find(|e| e.hostname == "1.2.3.4").unwrap();
assert_eq!(provider_host.alias, "ocean-web-1-2", "Alias should be stable across syncs");
}
#[test]
fn test_sync_preserves_user_tags() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["nyc1".to_string()])];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(config.host_entries()[0].tags, vec!["nyc1"]);
config.set_host_tags("do-web-1", &["nyc1".to_string(), "prod".to_string()]);
assert_eq!(config.host_entries()[0].tags, vec!["nyc1", "prod"]);
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(result.unchanged, 1);
assert_eq!(config.host_entries()[0].tags, vec!["nyc1", "prod"]);
}
#[test]
fn test_sync_merges_new_provider_tag_with_user_tags() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["nyc1".to_string()])];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
config.set_host_tags("do-web-1", &["nyc1".to_string(), "critical".to_string()]);
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["nyc1".to_string(), "v2".to_string()])];
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(result.updated, 1);
let tags = &config.host_entries()[0].tags;
assert!(tags.contains(&"nyc1".to_string()));
assert!(tags.contains(&"critical".to_string()));
assert!(tags.contains(&"v2".to_string()));
}
#[test]
fn test_sync_reset_tags_replaces_local_tags() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["nyc1".to_string()])];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
config.set_host_tags("do-web-1", &["nyc1".to_string(), "prod".to_string()]);
assert_eq!(config.host_entries()[0].tags, vec!["nyc1", "prod"]);
let result = sync_provider_with_options(
&mut config, &MockProvider, &remote, §ion, false, false, true,
);
assert_eq!(result.updated, 1);
assert_eq!(config.host_entries()[0].tags, vec!["nyc1"]);
}
#[test]
fn test_sync_reset_tags_clears_stale_tags() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["staging".to_string()])];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
let result = sync_provider_with_options(
&mut config, &MockProvider, &remote, §ion, false, false, true,
);
assert_eq!(result.updated, 1);
assert!(config.host_entries()[0].tags.is_empty());
}
#[test]
fn test_sync_reset_tags_unchanged_when_matching() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["prod".to_string(), "nyc1".to_string()])];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["nyc1".to_string(), "prod".to_string()])];
let result = sync_provider_with_options(
&mut config, &MockProvider, &remote, §ion, false, false, true,
);
assert_eq!(result.unchanged, 1);
}
#[test]
fn test_sync_merge_case_insensitive() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["prod".to_string()])];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(config.host_entries()[0].tags, vec!["prod"]);
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["Prod".to_string()])];
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(result.unchanged, 1);
assert_eq!(config.host_entries()[0].tags, vec!["prod"]);
}
#[test]
fn test_sync_reset_tags_case_insensitive_unchanged() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["prod".to_string()])];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let remote = vec![ProviderHost::new("123".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), vec!["Prod".to_string()])];
let result = sync_provider_with_options(
&mut config, &MockProvider, &remote, §ion, false, false, true,
);
assert_eq!(result.unchanged, 1);
}
#[test]
fn test_sync_empty_ip_not_added() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("100".to_string(), "stopped-vm".to_string(), String::new(), Vec::new())];
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(result.added, 0);
assert_eq!(config.host_entries().len(), 0);
}
#[test]
fn test_sync_empty_ip_existing_host_unchanged() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("100".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(config.host_entries().len(), 1);
assert_eq!(config.host_entries()[0].hostname, "1.2.3.4");
let remote = vec![ProviderHost::new("100".to_string(), "web".to_string(), String::new(), Vec::new())];
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(result.unchanged, 1);
assert_eq!(result.updated, 0);
assert_eq!(config.host_entries()[0].hostname, "1.2.3.4");
}
#[test]
fn test_sync_remove_skips_empty_ip_hosts() {
let mut config = empty_config();
let section = make_section();
let remote = vec![
ProviderHost::new("100".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new()),
ProviderHost::new("200".to_string(), "db".to_string(), "5.6.7.8".to_string(), Vec::new()),
];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(config.host_entries().len(), 2);
let remote = vec![
ProviderHost::new("100".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new()),
ProviderHost::new("200".to_string(), "db".to_string(), String::new(), Vec::new()),
];
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, true, false);
assert_eq!(result.removed, 0);
assert_eq!(result.unchanged, 2);
assert_eq!(config.host_entries().len(), 2);
}
#[test]
fn test_sync_remove_deletes_truly_gone_hosts() {
let mut config = empty_config();
let section = make_section();
let remote = vec![
ProviderHost::new("100".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new()),
ProviderHost::new("200".to_string(), "db".to_string(), "5.6.7.8".to_string(), Vec::new()),
];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(config.host_entries().len(), 2);
let remote = vec![ProviderHost::new("100".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, true, false);
assert_eq!(result.removed, 1);
assert_eq!(config.host_entries().len(), 1);
assert_eq!(config.host_entries()[0].alias, "do-web");
}
#[test]
fn test_sync_mixed_resolved_empty_and_missing() {
let mut config = empty_config();
let section = make_section();
let remote = vec![
ProviderHost::new("1".to_string(), "running".to_string(), "1.1.1.1".to_string(), Vec::new()),
ProviderHost::new("2".to_string(), "stopped".to_string(), "2.2.2.2".to_string(), Vec::new()),
ProviderHost::new("3".to_string(), "deleted".to_string(), "3.3.3.3".to_string(), Vec::new()),
];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(config.host_entries().len(), 3);
let remote = vec![
ProviderHost::new("1".to_string(), "running".to_string(), "9.9.9.9".to_string(), Vec::new()),
ProviderHost::new("2".to_string(), "stopped".to_string(), String::new(), Vec::new()),
];
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, true, false);
assert_eq!(result.updated, 1);
assert_eq!(result.unchanged, 1);
assert_eq!(result.removed, 1);
let entries = config.host_entries();
assert_eq!(entries.len(), 2);
let running = entries.iter().find(|e| e.alias == "do-running").unwrap();
assert_eq!(running.hostname, "9.9.9.9");
let stopped = entries.iter().find(|e| e.alias == "do-stopped").unwrap();
assert_eq!(stopped.hostname, "2.2.2.2");
}
#[test]
fn test_sanitize_name_unicode() {
assert_eq!(sanitize_name("서버-1"), "1");
}
#[test]
fn test_sanitize_name_numbers_only() {
assert_eq!(sanitize_name("12345"), "12345");
}
#[test]
fn test_sanitize_name_mixed_special_chars() {
assert_eq!(sanitize_name("web@server#1!"), "web-server-1");
}
#[test]
fn test_sanitize_name_tabs_and_newlines() {
assert_eq!(sanitize_name("web\tserver\n1"), "web-server-1");
}
#[test]
fn test_sanitize_name_consecutive_specials() {
assert_eq!(sanitize_name("a!!!b"), "a-b");
}
#[test]
fn test_sanitize_name_trailing_special() {
assert_eq!(sanitize_name("web-"), "web");
}
#[test]
fn test_sanitize_name_leading_special() {
assert_eq!(sanitize_name("-web"), "web");
}
#[test]
fn test_build_alias_prefix_with_hyphen() {
assert_eq!(build_alias("do-", "web-1"), "do--web-1");
}
#[test]
fn test_build_alias_long_names() {
assert_eq!(build_alias("my-provider", "my-very-long-server-name"), "my-provider-my-very-long-server-name");
}
#[test]
fn test_sync_applies_user_from_section() {
let mut config = empty_config();
let mut section = make_section();
section.user = "admin".to_string();
let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let entries = config.host_entries();
assert_eq!(entries[0].user, "admin");
}
#[test]
fn test_sync_applies_identity_file_from_section() {
let mut config = empty_config();
let mut section = make_section();
section.identity_file = "~/.ssh/id_rsa".to_string();
let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let entries = config.host_entries();
assert_eq!(entries[0].identity_file, "~/.ssh/id_rsa");
}
#[test]
fn test_sync_empty_user_not_set() {
let mut config = empty_config();
let mut section = make_section();
section.user = String::new(); let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let entries = config.host_entries();
assert!(entries[0].user.is_empty());
}
#[test]
fn test_sync_result_default() {
let result = SyncResult::default();
assert_eq!(result.added, 0);
assert_eq!(result.updated, 0);
assert_eq!(result.removed, 0);
assert_eq!(result.unchanged, 0);
assert!(result.renames.is_empty());
}
#[test]
fn test_sync_server_name_change_updates_alias() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("1".to_string(), "old-name".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(config.host_entries()[0].alias, "do-old-name");
let remote_renamed = vec![ProviderHost::new("1".to_string(), "new-name".to_string(), "1.2.3.4".to_string(), Vec::new())];
let result = sync_provider(&mut config, &MockProvider, &remote_renamed, §ion, false, false);
assert!(!result.renames.is_empty() || result.updated > 0);
}
#[test]
fn test_sync_idempotent_same_data() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["prod".to_string()])];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(result.added, 0);
assert_eq!(result.updated, 0);
assert_eq!(result.unchanged, 1);
}
#[test]
fn test_sync_tag_merge_case_insensitive_no_duplicate() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["Prod".to_string()])];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let remote2 = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["prod".to_string()])];
let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, false);
assert_eq!(result.unchanged, 1);
assert_eq!(result.updated, 0);
}
#[test]
fn test_sync_tag_merge_adds_new_remote_tag() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["prod".to_string()])];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let remote2 = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["prod".to_string(), "us-east".to_string()])];
let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, false);
assert_eq!(result.updated, 1);
let entries = config.host_entries();
let entry = entries.iter().find(|e| e.alias == "do-web").unwrap();
assert!(entry.tags.iter().any(|t| t == "prod"));
assert!(entry.tags.iter().any(|t| t == "us-east"));
}
#[test]
fn test_sync_tag_merge_preserves_local_tags() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["prod".to_string()])];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
config.set_host_tags("do-web", &["prod".to_string(), "my-custom".to_string()]);
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(result.unchanged, 1);
let entries = config.host_entries();
let entry = entries.iter().find(|e| e.alias == "do-web").unwrap();
assert!(entry.tags.iter().any(|t| t == "my-custom"));
}
#[test]
fn test_sync_reset_tags_replaces_local() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["prod".to_string()])];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
config.set_host_tags("do-web", &["prod".to_string(), "my-custom".to_string()]);
let remote2 = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["prod".to_string(), "new-tag".to_string()])];
let result = sync_provider_with_options(&mut config, &MockProvider, &remote2, §ion, false, false, true);
assert_eq!(result.updated, 1);
let entries = config.host_entries();
let entry = entries.iter().find(|e| e.alias == "do-web").unwrap();
assert!(entry.tags.iter().any(|t| t == "new-tag"));
assert!(!entry.tags.iter().any(|t| t == "my-custom"));
}
#[test]
fn test_sync_rename_and_ip_change_simultaneously() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("1".to_string(), "old-name".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let remote2 = vec![ProviderHost::new("1".to_string(), "new-name".to_string(), "9.8.7.6".to_string(), Vec::new())];
let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, false);
assert_eq!(result.updated, 1);
assert_eq!(result.renames.len(), 1);
assert_eq!(result.renames[0].0, "do-old-name");
assert_eq!(result.renames[0].1, "do-new-name");
let entries = config.host_entries();
let entry = entries.iter().find(|e| e.alias == "do-new-name").unwrap();
assert_eq!(entry.hostname, "9.8.7.6");
}
#[test]
fn test_sync_duplicate_server_id_deduped() {
let mut config = empty_config();
let section = make_section();
let remote = vec![
ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new()),
ProviderHost::new("1".to_string(), "web-copy".to_string(), "5.6.7.8".to_string(), Vec::new()), ];
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(result.added, 1); assert_eq!(config.host_entries().len(), 1);
}
#[test]
fn test_sync_remove_all_when_remote_empty() {
let mut config = empty_config();
let section = make_section();
let remote = vec![
ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new()),
ProviderHost::new("2".to_string(), "db".to_string(), "5.6.7.8".to_string(), Vec::new()),
];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(config.host_entries().len(), 2);
let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
assert_eq!(result.removed, 2);
assert_eq!(config.host_entries().len(), 0);
}
#[test]
fn test_sync_adds_group_header_on_first_host() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let has_header = config.elements.iter().any(|e| {
matches!(e, ConfigElement::GlobalLine(line) if line.contains("purple:group") && line.contains("DigitalOcean"))
});
assert!(has_header);
}
#[test]
fn test_sync_removes_header_when_all_hosts_deleted() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
assert_eq!(result.removed, 1);
let has_header = config.elements.iter().any(|e| {
matches!(e, ConfigElement::GlobalLine(line) if line.contains("purple:group") && line.contains("DigitalOcean"))
});
assert!(!has_header);
}
#[test]
fn test_sync_identity_file_set_on_new_host() {
let mut config = empty_config();
let mut section = make_section();
section.identity_file = "~/.ssh/do_key".to_string();
let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let entries = config.host_entries();
assert_eq!(entries[0].identity_file, "~/.ssh/do_key");
}
#[test]
fn test_sync_alias_collision_dedup() {
let mut config = empty_config();
let section = make_section();
let remote = vec![
ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new()),
ProviderHost::new("2".to_string(), "web".to_string(), "5.6.7.8".to_string(), Vec::new()), ];
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(result.added, 2);
let entries = config.host_entries();
let aliases: Vec<&str> = entries.iter().map(|e| e.alias.as_str()).collect();
assert!(aliases.contains(&"do-web"));
assert!(aliases.contains(&"do-web-2")); }
#[test]
fn test_sync_empty_alias_prefix() {
let mut config = empty_config();
let mut section = make_section();
section.alias_prefix = String::new();
let remote = vec![ProviderHost::new("1".to_string(), "web-1".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let entries = config.host_entries();
assert_eq!(entries[0].alias, "web-1"); }
#[test]
fn test_sync_dry_run_add_count() {
let mut config = empty_config();
let section = make_section();
let remote = vec![
ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new()),
ProviderHost::new("2".to_string(), "db".to_string(), "5.6.7.8".to_string(), Vec::new()),
];
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, true);
assert_eq!(result.added, 2);
assert_eq!(config.host_entries().len(), 0);
}
#[test]
fn test_sync_dry_run_remove_count_preserves_config() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(config.host_entries().len(), 1);
let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, true);
assert_eq!(result.removed, 1);
assert_eq!(config.host_entries().len(), 1);
}
#[test]
fn test_sync_result_counts_add_up() {
let mut config = empty_config();
let section = make_section();
let remote = vec![
ProviderHost::new("1".to_string(), "a".to_string(), "1.1.1.1".to_string(), Vec::new()),
ProviderHost::new("2".to_string(), "b".to_string(), "2.2.2.2".to_string(), Vec::new()),
ProviderHost::new("3".to_string(), "c".to_string(), "3.3.3.3".to_string(), Vec::new()),
];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let remote2 = vec![
ProviderHost::new("1".to_string(), "a".to_string(), "1.1.1.1".to_string(), Vec::new()), ProviderHost::new("2".to_string(), "b".to_string(), "9.9.9.9".to_string(), Vec::new()), ];
let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, true, false);
assert_eq!(result.unchanged, 1);
assert_eq!(result.updated, 1);
assert_eq!(result.removed, 1);
assert_eq!(result.added, 0);
}
#[test]
fn test_sync_multiple_renames() {
let mut config = empty_config();
let section = make_section();
let remote = vec![
ProviderHost::new("1".to_string(), "old-a".to_string(), "1.1.1.1".to_string(), Vec::new()),
ProviderHost::new("2".to_string(), "old-b".to_string(), "2.2.2.2".to_string(), Vec::new()),
];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let remote2 = vec![
ProviderHost::new("1".to_string(), "new-a".to_string(), "1.1.1.1".to_string(), Vec::new()),
ProviderHost::new("2".to_string(), "new-b".to_string(), "2.2.2.2".to_string(), Vec::new()),
];
let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, false);
assert_eq!(result.renames.len(), 2);
assert_eq!(result.updated, 2);
}
#[test]
fn test_sync_tag_whitespace_trimmed_on_store() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec![" production ".to_string(), " us-east ".to_string()])];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let entries = config.host_entries();
assert_eq!(entries[0].tags, vec!["production", "us-east"]);
}
#[test]
fn test_sync_tag_trimmed_remote_triggers_merge() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["production".to_string()])];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let remote2 = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec![" production ".to_string()])]; let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, false);
assert_eq!(result.unchanged, 1);
}
struct MockProvider2;
impl Provider for MockProvider2 {
fn name(&self) -> &str {
"vultr"
}
fn short_label(&self) -> &str {
"vultr"
}
fn fetch_hosts_cancellable(
&self,
_token: &str,
_cancel: &std::sync::atomic::AtomicBool,
) -> Result<Vec<ProviderHost>, super::super::ProviderError> {
Ok(Vec::new())
}
}
#[test]
fn test_sync_two_providers_independent() {
let mut config = empty_config();
let do_section = make_section(); let vultr_section = ProviderSection {
provider: "vultr".to_string(),
token: "test".to_string(),
alias_prefix: "vultr".to_string(),
user: String::new(),
identity_file: String::new(),
url: String::new(),
verify_tls: true,
auto_sync: true,
profile: String::new(),
regions: String::new(),
project: String::new(),
};
let do_remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &do_remote, &do_section, false, false);
let vultr_remote = vec![ProviderHost::new("abc".to_string(), "web".to_string(), "5.6.7.8".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider2, &vultr_remote, &vultr_section, false, false);
let entries = config.host_entries();
assert_eq!(entries.len(), 2);
let aliases: Vec<&str> = entries.iter().map(|e| e.alias.as_str()).collect();
assert!(aliases.contains(&"do-web"));
assert!(aliases.contains(&"vultr-web"));
}
#[test]
fn test_sync_remove_only_affects_own_provider() {
let mut config = empty_config();
let do_section = make_section();
let vultr_section = ProviderSection {
provider: "vultr".to_string(),
token: "test".to_string(),
alias_prefix: "vultr".to_string(),
user: String::new(),
identity_file: String::new(),
url: String::new(),
verify_tls: true,
auto_sync: true,
profile: String::new(),
regions: String::new(),
project: String::new(),
};
let do_remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &do_remote, &do_section, false, false);
let vultr_remote = vec![ProviderHost::new("abc".to_string(), "db".to_string(), "5.6.7.8".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider2, &vultr_remote, &vultr_section, false, false);
assert_eq!(config.host_entries().len(), 2);
let result = sync_provider(&mut config, &MockProvider, &[], &do_section, true, false);
assert_eq!(result.removed, 1);
let entries = config.host_entries();
assert_eq!(entries.len(), 1);
assert_eq!(entries[0].alias, "vultr-db");
}
#[test]
fn test_sync_rename_and_tag_change_simultaneously() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("1".to_string(), "old-name".to_string(), "1.2.3.4".to_string(), vec!["staging".to_string()])];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(config.host_entries()[0].alias, "do-old-name");
assert_eq!(config.host_entries()[0].tags, vec!["staging"]);
let remote2 = vec![ProviderHost::new("1".to_string(), "new-name".to_string(), "1.2.3.4".to_string(), vec!["staging".to_string(), "prod".to_string()])];
let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, false);
assert_eq!(result.updated, 1);
assert_eq!(result.renames.len(), 1);
let entries = config.host_entries();
let entry = entries.iter().find(|e| e.alias == "do-new-name").unwrap();
assert!(entry.tags.contains(&"staging".to_string()));
assert!(entry.tags.contains(&"prod".to_string()));
}
#[test]
fn test_sync_all_symbol_name_uses_server_fallback() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("1".to_string(), "!!!".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let entries = config.host_entries();
assert_eq!(entries[0].alias, "do-server");
}
#[test]
fn test_sync_unicode_name_uses_ascii_fallback() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("1".to_string(), "서버".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let entries = config.host_entries();
assert_eq!(entries[0].alias, "do-server");
}
#[test]
fn test_sync_dry_run_update_preserves_config() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let remote2 = vec![ProviderHost::new("1".to_string(), "web".to_string(), "9.9.9.9".to_string(), Vec::new())];
let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, true);
assert_eq!(result.updated, 1);
assert_eq!(config.host_entries()[0].hostname, "1.2.3.4");
}
#[test]
fn test_sync_empty_remote_empty_config_noop() {
let mut config = empty_config();
let section = make_section();
let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
assert_eq!(result.added, 0);
assert_eq!(result.updated, 0);
assert_eq!(result.removed, 0);
assert_eq!(result.unchanged, 0);
assert!(config.host_entries().is_empty());
}
#[test]
fn test_sync_large_batch() {
let mut config = empty_config();
let section = make_section();
let remote: Vec<ProviderHost> = (0..100)
.map(|i| ProviderHost::new(format!("{}", i), format!("server-{}", i), format!("10.0.0.{}", i % 256), vec!["batch".to_string()]))
.collect();
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(result.added, 100);
assert_eq!(config.host_entries().len(), 100);
let result2 = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(result2.unchanged, 100);
assert_eq!(result2.added, 0);
}
#[test]
fn test_sync_rename_self_exclusion_no_collision() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(config.host_entries()[0].alias, "do-web");
let remote2 = vec![ProviderHost::new("1".to_string(), "web".to_string(), "9.9.9.9".to_string(), Vec::new())];
let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, false);
assert_eq!(result.updated, 1);
assert!(result.renames.is_empty());
assert_eq!(config.host_entries()[0].alias, "do-web"); }
#[test]
fn test_sync_reset_tags_with_rename() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("1".to_string(), "old-name".to_string(), "1.2.3.4".to_string(), vec!["staging".to_string()])];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
config.set_host_tags("do-old-name", &["staging".to_string(), "custom".to_string()]);
let remote2 = vec![ProviderHost::new("1".to_string(), "new-name".to_string(), "1.2.3.4".to_string(), vec!["production".to_string()])];
let result = sync_provider_with_options(
&mut config, &MockProvider, &remote2, §ion, false, false, true,
);
assert_eq!(result.updated, 1);
assert_eq!(result.renames.len(), 1);
let entries = config.host_entries();
let entry = entries.iter().find(|e| e.alias == "do-new-name").unwrap();
assert_eq!(entry.tags, vec!["production"]);
assert!(!entry.tags.contains(&"custom".to_string()));
}
#[test]
fn test_sync_empty_ip_with_tags_not_added() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("1".to_string(), "stopped".to_string(), String::new(), vec!["prod".to_string()])];
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(result.added, 0);
assert!(config.host_entries().is_empty());
}
#[test]
fn test_sync_orphaned_provider_marker_counts_unchanged() {
let content = "\
Host do-web
HostName 1.2.3.4
# purple:provider digitalocean:123
";
let mut config = SshConfigFile {
elements: SshConfigFile::parse_content(content),
path: PathBuf::from("/tmp/test_config"),
crlf: false,
};
let section = make_section();
let remote = vec![ProviderHost::new("123".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(result.unchanged, 1);
}
#[test]
fn test_sync_no_double_blank_between_hosts() {
let mut config = empty_config();
let section = make_section();
let remote = vec![
ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new()),
ProviderHost::new("2".to_string(), "db".to_string(), "5.6.7.8".to_string(), Vec::new()),
];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let mut prev_blank = false;
for elem in &config.elements {
if let ConfigElement::GlobalLine(line) = elem {
let is_blank = line.trim().is_empty();
assert!(!(prev_blank && is_blank), "Found consecutive blank lines");
prev_blank = is_blank;
} else {
prev_blank = false;
}
}
}
#[test]
fn test_sync_without_remove_flag_keeps_deleted() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let result = sync_provider(&mut config, &MockProvider, &[], §ion, false, false);
assert_eq!(result.removed, 0);
assert_eq!(config.host_entries().len(), 1); }
#[test]
fn test_sync_dry_run_rename_no_renames_tracked() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("1".to_string(), "old".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let new_section = ProviderSection {
alias_prefix: "ocean".to_string(),
..section
};
let result = sync_provider(&mut config, &MockProvider, &remote, &new_section, false, true);
assert_eq!(result.updated, 1);
assert!(result.renames.is_empty());
}
#[test]
fn test_sanitize_name_whitespace_only() {
assert_eq!(sanitize_name(" "), "server");
}
#[test]
fn test_sanitize_name_single_char() {
assert_eq!(sanitize_name("a"), "a");
assert_eq!(sanitize_name("Z"), "z");
assert_eq!(sanitize_name("5"), "5");
}
#[test]
fn test_sanitize_name_single_special_char() {
assert_eq!(sanitize_name("!"), "server");
assert_eq!(sanitize_name("-"), "server");
assert_eq!(sanitize_name("."), "server");
}
#[test]
fn test_sanitize_name_emoji() {
assert_eq!(sanitize_name("server🚀"), "server");
assert_eq!(sanitize_name("🔥hot🔥"), "hot");
}
#[test]
fn test_sanitize_name_long_mixed_separators() {
assert_eq!(sanitize_name("a!@#$%^&*()b"), "a-b");
}
#[test]
fn test_sanitize_name_dots_and_underscores() {
assert_eq!(sanitize_name("web.prod_us-east"), "web-prod-us-east");
}
#[test]
fn test_find_hosts_by_provider_in_includes() {
use crate::ssh_config::model::{IncludeDirective, IncludedFile};
let include_content = "Host do-included\n HostName 1.2.3.4\n # purple:provider digitalocean:inc1\n";
let included_elements = SshConfigFile::parse_content(include_content);
let config = SshConfigFile {
elements: vec![ConfigElement::Include(IncludeDirective {
raw_line: "Include conf.d/*".to_string(),
pattern: "conf.d/*".to_string(),
resolved_files: vec![IncludedFile {
path: PathBuf::from("/tmp/included.conf"),
elements: included_elements,
}],
})],
path: PathBuf::from("/tmp/test_config"),
crlf: false,
};
let hosts = config.find_hosts_by_provider("digitalocean");
assert_eq!(hosts.len(), 1);
assert_eq!(hosts[0].0, "do-included");
assert_eq!(hosts[0].1, "inc1");
}
#[test]
fn test_find_hosts_by_provider_mixed_includes_and_toplevel() {
use crate::ssh_config::model::{IncludeDirective, IncludedFile};
let top_content = "Host do-web\n HostName 1.2.3.4\n # purple:provider digitalocean:1\n";
let top_elements = SshConfigFile::parse_content(top_content);
let inc_content = "Host do-db\n HostName 5.6.7.8\n # purple:provider digitalocean:2\n";
let inc_elements = SshConfigFile::parse_content(inc_content);
let mut elements = top_elements;
elements.push(ConfigElement::Include(IncludeDirective {
raw_line: "Include conf.d/*".to_string(),
pattern: "conf.d/*".to_string(),
resolved_files: vec![IncludedFile {
path: PathBuf::from("/tmp/included.conf"),
elements: inc_elements,
}],
}));
let config = SshConfigFile {
elements,
path: PathBuf::from("/tmp/test_config"),
crlf: false,
};
let hosts = config.find_hosts_by_provider("digitalocean");
assert_eq!(hosts.len(), 2);
}
#[test]
fn test_find_hosts_by_provider_empty_includes() {
use crate::ssh_config::model::{IncludeDirective, IncludedFile};
let config = SshConfigFile {
elements: vec![ConfigElement::Include(IncludeDirective {
raw_line: "Include conf.d/*".to_string(),
pattern: "conf.d/*".to_string(),
resolved_files: vec![IncludedFile {
path: PathBuf::from("/tmp/empty.conf"),
elements: vec![],
}],
})],
path: PathBuf::from("/tmp/test_config"),
crlf: false,
};
let hosts = config.find_hosts_by_provider("digitalocean");
assert!(hosts.is_empty());
}
#[test]
fn test_find_hosts_by_provider_wrong_provider_name() {
let content = "Host do-web\n HostName 1.2.3.4\n # purple:provider digitalocean:1\n";
let config = SshConfigFile {
elements: SshConfigFile::parse_content(content),
path: PathBuf::from("/tmp/test_config"),
crlf: false,
};
let hosts = config.find_hosts_by_provider("vultr");
assert!(hosts.is_empty());
}
#[test]
fn test_deduplicate_alias_excluding_self() {
let content = "Host do-web\n HostName 1.2.3.4\n";
let config = SshConfigFile {
elements: SshConfigFile::parse_content(content),
path: PathBuf::from("/tmp/test_config"),
crlf: false,
};
let alias = config.deduplicate_alias_excluding("do-web", Some("do-web"));
assert_eq!(alias, "do-web"); }
#[test]
fn test_deduplicate_alias_excluding_other() {
let content = "Host do-web\n HostName 1.2.3.4\n";
let config = SshConfigFile {
elements: SshConfigFile::parse_content(content),
path: PathBuf::from("/tmp/test_config"),
crlf: false,
};
let alias = config.deduplicate_alias_excluding("do-web", Some("do-db"));
assert_eq!(alias, "do-web-2"); }
#[test]
fn test_deduplicate_alias_excluding_chain() {
let content = "Host do-web\n HostName 1.1.1.1\n\nHost do-web-2\n HostName 2.2.2.2\n";
let config = SshConfigFile {
elements: SshConfigFile::parse_content(content),
path: PathBuf::from("/tmp/test_config"),
crlf: false,
};
let alias = config.deduplicate_alias_excluding("do-web", Some("do-web"));
assert_eq!(alias, "do-web");
}
#[test]
fn test_deduplicate_alias_excluding_none() {
let content = "Host do-web\n HostName 1.2.3.4\n";
let config = SshConfigFile {
elements: SshConfigFile::parse_content(content),
path: PathBuf::from("/tmp/test_config"),
crlf: false,
};
let alias = config.deduplicate_alias_excluding("do-web", None);
assert_eq!(alias, "do-web-2");
}
#[test]
fn test_set_host_tags_empty_clears_tags() {
let content = "Host do-web\n HostName 1.2.3.4\n # purple:tags prod,staging\n";
let mut config = SshConfigFile {
elements: SshConfigFile::parse_content(content),
path: PathBuf::from("/tmp/test_config"),
crlf: false,
};
config.set_host_tags("do-web", &[]);
let entries = config.host_entries();
assert!(entries[0].tags.is_empty());
}
#[test]
fn test_set_host_provider_updates_existing() {
let content = "Host do-web\n HostName 1.2.3.4\n # purple:provider digitalocean:old-id\n";
let mut config = SshConfigFile {
elements: SshConfigFile::parse_content(content),
path: PathBuf::from("/tmp/test_config"),
crlf: false,
};
config.set_host_provider("do-web", "digitalocean", "new-id");
let hosts = config.find_hosts_by_provider("digitalocean");
assert_eq!(hosts.len(), 1);
assert_eq!(hosts[0].1, "new-id");
}
#[test]
fn test_sync_recognizes_include_hosts_prevents_duplicate_add() {
use crate::ssh_config::model::{IncludeDirective, IncludedFile};
let include_content = "Host do-web\n HostName 1.2.3.4\n # purple:provider digitalocean:123\n";
let included_elements = SshConfigFile::parse_content(include_content);
let mut config = SshConfigFile {
elements: vec![ConfigElement::Include(IncludeDirective {
raw_line: "Include conf.d/*".to_string(),
pattern: "conf.d/*".to_string(),
resolved_files: vec![IncludedFile {
path: PathBuf::from("/tmp/included.conf"),
elements: included_elements,
}],
})],
path: PathBuf::from("/tmp/test_config"),
crlf: false,
};
let section = make_section();
let remote = vec![ProviderHost::new("123".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(result.unchanged, 1);
assert_eq!(result.added, 0);
let top_hosts = config.elements.iter().filter(|e| matches!(e, ConfigElement::HostBlock(_))).count();
assert_eq!(top_hosts, 0, "No host blocks added to top-level config");
}
#[test]
fn test_sync_dedup_resolves_back_to_same_alias_unchanged() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(config.host_entries()[0].alias, "do-web");
let other = vec![ProviderHost::new("2".to_string(), "new-web".to_string(), "5.5.5.5".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &other, §ion, false, false);
let remote_same = vec![
ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new()),
ProviderHost::new("2".to_string(), "new-web".to_string(), "5.5.5.5".to_string(), Vec::new()),
];
let result = sync_provider(&mut config, &MockProvider, &remote_same, §ion, false, false);
assert_eq!(result.unchanged, 2);
assert_eq!(result.updated, 0);
assert!(result.renames.is_empty());
}
#[test]
fn test_sync_host_in_entries_map_but_alias_changed_by_another_provider() {
let mut config = empty_config();
let section = make_section();
let remote = vec![
ProviderHost::new("1".to_string(), "web".to_string(), "1.1.1.1".to_string(), Vec::new()),
ProviderHost::new("2".to_string(), "web".to_string(), "2.2.2.2".to_string(), Vec::new()),
];
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(result.added, 2);
let entries = config.host_entries();
assert_eq!(entries[0].alias, "do-web");
assert_eq!(entries[1].alias, "do-web-2");
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(result.unchanged, 2);
}
#[test]
fn test_sync_dry_run_remove_excludes_included_hosts() {
use crate::ssh_config::model::{IncludeDirective, IncludedFile};
let include_content =
"Host do-included\n HostName 1.1.1.1\n # purple:provider digitalocean:inc1\n";
let included_elements = SshConfigFile::parse_content(include_content);
let mut config = SshConfigFile {
elements: vec![ConfigElement::Include(IncludeDirective {
raw_line: "Include conf.d/*".to_string(),
pattern: "conf.d/*".to_string(),
resolved_files: vec![IncludedFile {
path: PathBuf::from("/tmp/included.conf"),
elements: included_elements,
}],
})],
path: PathBuf::from("/tmp/test_config"),
crlf: false,
};
let section = make_section();
let remote = vec![ProviderHost::new("top1".to_string(), "toplevel".to_string(), "2.2.2.2".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, true);
assert_eq!(result.removed, 1, "Only top-level host counted in dry-run remove");
}
#[test]
fn test_sync_group_header_with_existing_trailing_blank() {
let mut config = empty_config();
config.elements.push(ConfigElement::GlobalLine("# some comment".to_string()));
config.elements.push(ConfigElement::GlobalLine(String::new()));
let section = make_section();
let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(result.added, 1);
let blank_count = config
.elements
.iter()
.filter(|e| matches!(e, ConfigElement::GlobalLine(l) if l.is_empty()))
.count();
assert_eq!(blank_count, 1, "No extra blank line when one already exists");
}
#[test]
fn test_sync_no_group_header_for_second_host() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let header_count_before = config
.elements
.iter()
.filter(|e| matches!(e, ConfigElement::GlobalLine(l) if l.starts_with("# purple:group")))
.count();
assert_eq!(header_count_before, 1);
let remote2 = vec![
ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new()),
ProviderHost::new("2".to_string(), "db".to_string(), "5.5.5.5".to_string(), Vec::new()),
];
sync_provider(&mut config, &MockProvider, &remote2, §ion, false, false);
let header_count_after = config
.elements
.iter()
.filter(|e| matches!(e, ConfigElement::GlobalLine(l) if l.starts_with("# purple:group")))
.count();
assert_eq!(header_count_after, 1, "No duplicate group header");
}
#[test]
fn test_sync_duplicate_server_id_in_remote_skipped() {
let mut config = empty_config();
let section = make_section();
let remote = vec![
ProviderHost::new("dup".to_string(), "first".to_string(), "1.1.1.1".to_string(), Vec::new()),
ProviderHost::new("dup".to_string(), "second".to_string(), "2.2.2.2".to_string(), Vec::new()),
];
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(result.added, 1, "Only the first instance is added");
assert_eq!(config.host_entries()[0].alias, "do-first");
}
#[test]
fn test_sync_empty_ip_existing_host_counted_unchanged() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let remote2 = vec![ProviderHost::new("1".to_string(), "web".to_string(), String::new(), Vec::new())];
let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, true);
assert_eq!(result.unchanged, 1);
assert_eq!(result.removed, 0, "Host with empty IP not removed");
assert_eq!(config.host_entries()[0].hostname, "1.2.3.4");
}
#[test]
fn test_sync_reset_tags_case_insensitive_no_update() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["Production".to_string()])];
sync_provider_with_options(
&mut config, &MockProvider, &remote, §ion, false, false, true,
);
let remote2 = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), vec!["production".to_string()])];
let result = sync_provider_with_options(
&mut config, &MockProvider, &remote2, §ion, false, false, true,
);
assert_eq!(result.unchanged, 1, "Case-insensitive tag match = unchanged");
}
#[test]
fn test_sync_remove_cleans_up_group_header() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new("1".to_string(), "web".to_string(), "1.2.3.4".to_string(), Vec::new())];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let has_header = config.elements.iter().any(|e| {
matches!(e, ConfigElement::GlobalLine(l) if l.starts_with("# purple:group"))
});
assert!(has_header, "Group header present after add");
let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false);
assert_eq!(result.removed, 1);
let has_header_after = config.elements.iter().any(|e| {
matches!(e, ConfigElement::GlobalLine(l) if l.starts_with("# purple:group"))
});
assert!(!has_header_after, "Group header removed when all hosts gone");
}
#[test]
fn test_sync_adds_host_with_metadata() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost {
server_id: "1".to_string(),
name: "web".to_string(),
ip: "1.2.3.4".to_string(),
tags: Vec::new(),
metadata: vec![
("region".to_string(), "nyc3".to_string()),
("plan".to_string(), "s-1vcpu-1gb".to_string()),
],
}];
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(result.added, 1);
let entries = config.host_entries();
assert_eq!(entries[0].provider_meta.len(), 2);
assert_eq!(entries[0].provider_meta[0], ("region".to_string(), "nyc3".to_string()));
assert_eq!(entries[0].provider_meta[1], ("plan".to_string(), "s-1vcpu-1gb".to_string()));
}
#[test]
fn test_sync_updates_changed_metadata() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost {
server_id: "1".to_string(),
name: "web".to_string(),
ip: "1.2.3.4".to_string(),
tags: Vec::new(),
metadata: vec![("region".to_string(), "nyc3".to_string())],
}];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let remote2 = vec![ProviderHost {
server_id: "1".to_string(),
name: "web".to_string(),
ip: "1.2.3.4".to_string(),
tags: Vec::new(),
metadata: vec![
("region".to_string(), "sfo3".to_string()),
("plan".to_string(), "s-2vcpu-2gb".to_string()),
],
}];
let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, false);
assert_eq!(result.updated, 1);
let entries = config.host_entries();
assert_eq!(entries[0].provider_meta.len(), 2);
assert_eq!(entries[0].provider_meta[0].1, "sfo3");
assert_eq!(entries[0].provider_meta[1].1, "s-2vcpu-2gb");
}
#[test]
fn test_sync_metadata_unchanged_no_update() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost {
server_id: "1".to_string(),
name: "web".to_string(),
ip: "1.2.3.4".to_string(),
tags: Vec::new(),
metadata: vec![("region".to_string(), "nyc3".to_string())],
}];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let result = sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(result.unchanged, 1);
assert_eq!(result.updated, 0);
}
#[test]
fn test_sync_metadata_order_insensitive() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost {
server_id: "1".to_string(),
name: "web".to_string(),
ip: "1.2.3.4".to_string(),
tags: Vec::new(),
metadata: vec![
("region".to_string(), "nyc3".to_string()),
("plan".to_string(), "s-1vcpu-1gb".to_string()),
],
}];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let remote2 = vec![ProviderHost {
server_id: "1".to_string(),
name: "web".to_string(),
ip: "1.2.3.4".to_string(),
tags: Vec::new(),
metadata: vec![
("plan".to_string(), "s-1vcpu-1gb".to_string()),
("region".to_string(), "nyc3".to_string()),
],
}];
let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, false);
assert_eq!(result.unchanged, 1);
assert_eq!(result.updated, 0);
}
#[test]
fn test_sync_metadata_with_rename() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost {
server_id: "1".to_string(),
name: "old-name".to_string(),
ip: "1.2.3.4".to_string(),
tags: Vec::new(),
metadata: vec![("region".to_string(), "nyc3".to_string())],
}];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
assert_eq!(config.host_entries()[0].provider_meta[0].1, "nyc3");
let remote2 = vec![ProviderHost {
server_id: "1".to_string(),
name: "new-name".to_string(),
ip: "1.2.3.4".to_string(),
tags: Vec::new(),
metadata: vec![("region".to_string(), "sfo3".to_string())],
}];
let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, false);
assert_eq!(result.updated, 1);
assert!(!result.renames.is_empty());
let entries = config.host_entries();
assert_eq!(entries[0].alias, "do-new-name");
assert_eq!(entries[0].provider_meta[0].1, "sfo3");
}
#[test]
fn test_sync_metadata_dry_run_no_mutation() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost {
server_id: "1".to_string(),
name: "web".to_string(),
ip: "1.2.3.4".to_string(),
tags: Vec::new(),
metadata: vec![("region".to_string(), "nyc3".to_string())],
}];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let remote2 = vec![ProviderHost {
server_id: "1".to_string(),
name: "web".to_string(),
ip: "1.2.3.4".to_string(),
tags: Vec::new(),
metadata: vec![("region".to_string(), "sfo3".to_string())],
}];
let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, true);
assert_eq!(result.updated, 1);
assert_eq!(config.host_entries()[0].provider_meta[0].1, "nyc3");
}
#[test]
fn test_sync_metadata_only_change_triggers_update() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost {
server_id: "1".to_string(),
name: "web".to_string(),
ip: "1.2.3.4".to_string(),
tags: vec!["prod".to_string()],
metadata: vec![("region".to_string(), "nyc3".to_string())],
}];
sync_provider(&mut config, &MockProvider, &remote, §ion, false, false);
let remote2 = vec![ProviderHost {
server_id: "1".to_string(),
name: "web".to_string(),
ip: "1.2.3.4".to_string(),
tags: vec!["prod".to_string()],
metadata: vec![
("region".to_string(), "nyc3".to_string()),
("plan".to_string(), "s-1vcpu-1gb".to_string()),
],
}];
let result = sync_provider(&mut config, &MockProvider, &remote2, §ion, false, false);
assert_eq!(result.updated, 1);
assert_eq!(config.host_entries()[0].provider_meta.len(), 2);
}
}