use super::*;
use std::path::PathBuf;
fn empty_config() -> SshConfigFile {
SshConfigFile {
elements: Vec::new(),
path: PathBuf::from("/tmp/test_config"),
crlf: false,
bom: 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(),
compartment: String::new(),
vault_role: String::new(),
vault_addr: 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,
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,
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,
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,
false,
);
let result = sync_provider(
&mut config,
&MockProvider,
&remote,
§ion,
false,
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,
false,
);
assert_eq!(config.host_entries().len(), 1);
let result = sync_provider(
&mut config,
&MockProvider,
&[],
§ion,
true,
false,
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,
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,
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,
bom: 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,
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,
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,
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,
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,
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,
false,
);
let entries = config.host_entries();
assert_eq!(entries[0].provider_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,
false,
);
assert_eq!(config.host_entries()[0].provider_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,
false,
);
assert_eq!(result.updated, 1);
assert_eq!(
config.host_entries()[0].provider_tags,
vec!["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,
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,
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,
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,
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,
bom: 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,
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,
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,
false,
);
assert_eq!(config.host_entries().len(), 2);
let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false, 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,
false,
);
assert_eq!(config.host_entries()[0].provider_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,
false,
);
assert_eq!(result.updated, 1);
assert!(config.host_entries()[0].provider_tags.is_empty());
}
#[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,
bom: 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,
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,
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,
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,
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,
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,
false,
);
let new_section = ProviderSection {
alias_prefix: "ocean".to_string(),
..section
};
let result = sync_provider(
&mut config,
&MockProvider,
&remote,
&new_section,
false,
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,
false,
);
let result = sync_provider(
&mut config,
&MockProvider,
&remote,
§ion,
false,
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,
bom: false,
};
let section = make_section();
sync_provider(
&mut config,
&MockProvider,
&[],
§ion,
true,
false,
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(),
compartment: String::new(),
vault_role: String::new(),
vault_addr: 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,
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,
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,
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,
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,
false,
);
assert_eq!(config.host_entries()[0].provider_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,
false,
);
assert_eq!(result.updated, 1);
assert_eq!(config.host_entries()[0].provider_tags, vec!["nyc1"]);
assert_eq!(config.host_entries()[0].tags, vec!["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,
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,
false,
);
assert_eq!(result.updated, 1);
let ptags = &config.host_entries()[0].provider_tags;
assert!(ptags.contains(&"nyc1".to_string()));
assert!(ptags.contains(&"v2".to_string()));
let tags = &config.host_entries()[0].tags;
assert!(tags.contains(&"critical".to_string()));
assert!(!tags.contains(&"nyc1".to_string()));
}
#[test]
fn test_sync_migration_cleans_overlapping_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,
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(
&mut config,
&MockProvider,
&remote,
§ion,
false,
false,
false,
);
assert_eq!(result.updated, 1);
assert_eq!(config.host_entries()[0].provider_tags, vec!["nyc1"]);
assert_eq!(config.host_entries()[0].tags, vec!["prod"]);
}
#[test]
fn test_sync_provider_tags_cleared_remotely() {
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,
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(
&mut config,
&MockProvider,
&remote,
§ion,
false,
false,
false,
);
assert_eq!(result.updated, 1);
assert!(config.host_entries()[0].tags.is_empty());
}
#[test]
fn test_sync_provider_tags_cleared_user_tags_survive() {
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,
false,
);
config.set_host_tags("do-web-1", &["my-custom".to_string()]);
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,
false,
);
assert_eq!(result.updated, 1);
assert!(config.host_entries()[0].provider_tags.is_empty());
assert_eq!(config.host_entries()[0].tags, vec!["my-custom"]);
}
#[test]
fn test_sync_provider_tags_exact_match_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(), "nyc1".to_string()],
)];
sync_provider(
&mut config,
&MockProvider,
&remote,
§ion,
false,
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(
&mut config,
&MockProvider,
&remote,
§ion,
false,
false,
false,
);
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,
false,
);
assert_eq!(config.host_entries()[0].provider_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,
false,
);
assert_eq!(result.unchanged, 1);
assert_eq!(config.host_entries()[0].provider_tags, vec!["prod"]);
}
#[test]
fn test_sync_provider_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,
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(
&mut config,
&MockProvider,
&remote,
§ion,
false,
false,
false,
);
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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
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,
false,
);
let result = sync_provider(
&mut config,
&MockProvider,
&remote,
§ion,
false,
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,
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,
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,
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,
false,
);
assert_eq!(result.updated, 1);
let entries = config.host_entries();
let entry = entries.iter().find(|e| e.alias == "do-web").unwrap();
assert!(entry.provider_tags.iter().any(|t| t == "prod"));
assert!(entry.provider_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,
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,
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 == "my-custom"));
assert!(!entry.tags.iter().any(|t| t == "prod")); }
#[test]
fn test_sync_provider_tags_replaces_with_migration() {
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,
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(
&mut config,
&MockProvider,
&remote2,
§ion,
false,
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.provider_tags.iter().any(|t| t == "prod"));
assert!(entry.provider_tags.iter().any(|t| t == "new-tag"));
assert!(!entry.tags.iter().any(|t| t == "prod"));
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,
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,
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,
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,
false,
);
assert_eq!(config.host_entries().len(), 2);
let result = sync_provider(
&mut config,
&MockProvider,
&[],
§ion,
true,
false,
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,
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,
false,
);
let result = sync_provider(
&mut config,
&MockProvider,
&[],
§ion,
true,
false,
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,
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,
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,
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,
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,
false,
);
assert_eq!(config.host_entries().len(), 1);
let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false, 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,
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,
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,
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,
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,
false,
);
let entries = config.host_entries();
assert_eq!(entries[0].provider_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,
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,
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(),
compartment: String::new(),
vault_role: String::new(),
vault_addr: 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,
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,
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(),
compartment: String::new(),
vault_role: String::new(),
vault_addr: 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,
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,
false,
);
assert_eq!(config.host_entries().len(), 2);
let result = sync_provider(
&mut config,
&MockProvider,
&[],
&do_section,
true,
false,
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,
false,
);
assert_eq!(config.host_entries()[0].alias, "do-old-name");
assert_eq!(config.host_entries()[0].provider_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,
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.provider_tags.contains(&"staging".to_string()));
assert!(entry.provider_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,
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,
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,
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,
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,
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,
false,
);
assert_eq!(result.added, 100);
assert_eq!(config.host_entries().len(), 100);
let result2 = sync_provider(
&mut config,
&MockProvider,
&remote,
§ion,
false,
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,
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,
false,
);
assert_eq!(result.updated, 1);
assert!(result.renames.is_empty());
assert_eq!(config.host_entries()[0].alias, "do-web"); }
#[test]
fn test_sync_provider_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,
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(
&mut config,
&MockProvider,
&remote2,
§ion,
false,
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_eq!(entry.provider_tags, vec!["production"]);
assert!(entry.tags.contains(&"custom".to_string()));
assert!(entry.tags.contains(&"staging".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,
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,
bom: 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,
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,
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,
false,
);
let result = sync_provider(
&mut config,
&MockProvider,
&[],
§ion,
false,
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,
false,
);
let new_section = ProviderSection {
alias_prefix: "ocean".to_string(),
..section
};
let result = sync_provider(
&mut config,
&MockProvider,
&remote,
&new_section,
false,
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,
bom: 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,
bom: 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,
bom: 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,
bom: 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,
bom: 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,
bom: 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,
bom: 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,
bom: 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,
bom: 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,
bom: 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,
bom: 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,
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,
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,
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,
false,
);
assert_eq!(result.unchanged, 1);
assert_eq!(result.updated, 1);
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,
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,
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,
bom: 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,
false,
);
let result = sync_provider(&mut config, &MockProvider, &[], §ion, true, false, 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,
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,
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,
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,
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,
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,
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_provider_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(
&mut config,
&MockProvider,
&remote,
§ion,
false,
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,
false,
);
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,
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,
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,
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,
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,
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,
false,
);
let result = sync_provider(
&mut config,
&MockProvider,
&remote,
§ion,
false,
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,
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,
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,
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,
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,
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,
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,
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,
false,
);
assert_eq!(result.updated, 1);
assert_eq!(config.host_entries()[0].provider_meta.len(), 2);
}
#[test]
fn test_sync_upgrade_migration() {
let content = "\
Host do-web-1
HostName 1.2.3.4
User root
# purple:tags prod,us-east,my-custom
# purple:provider digitalocean:123
";
let mut config = SshConfigFile {
elements: SshConfigFile::parse_content(content),
path: PathBuf::from("/tmp/test_config"),
crlf: false,
bom: false,
};
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(), "us-east".to_string()],
)];
let result = sync_provider(
&mut config,
&MockProvider,
&remote,
§ion,
false,
false,
false,
);
assert_eq!(result.updated, 1);
let entry = &config.host_entries()[0];
let mut ptags = entry.provider_tags.clone();
ptags.sort();
assert_eq!(ptags, vec!["prod", "us-east"]);
assert_eq!(entry.tags, vec!["my-custom"]);
}
#[test]
fn test_sync_duplicate_user_provider_tag() {
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,
false,
);
assert_eq!(config.host_entries()[0].provider_tags, vec!["prod"]);
config.set_host_tags("do-web-1", &["prod".to_string(), "custom".to_string()]);
assert_eq!(config.host_entries()[0].tags, vec!["prod", "custom"]);
sync_provider(
&mut config,
&MockProvider,
&remote,
§ion,
false,
false,
false,
);
let entry = &config.host_entries()[0];
assert!(
!entry.tags.contains(&"prod".to_string()),
"User tag 'prod' should be cleaned since it duplicates a provider tag"
);
assert!(
entry.tags.contains(&"custom".to_string()),
"User tag 'custom' should be preserved"
);
assert_eq!(entry.provider_tags, vec!["prod"]);
}
#[test]
fn test_sync_set_provider_tags_empty_writes_sentinel() {
let content = "\
Host do-web-1
HostName 1.2.3.4
# purple:provider_tags prod
# purple:provider digitalocean:123
";
let mut config = SshConfigFile {
elements: SshConfigFile::parse_content(content),
path: PathBuf::from("/tmp/test_config"),
crlf: false,
bom: false,
};
config.set_host_provider_tags("do-web-1", &[]);
let serialized = config.serialize();
assert!(
serialized.contains("# purple:provider_tags"),
"empty sentinel should exist. Got:\n{}",
serialized
);
assert!(
!serialized.contains("# purple:provider_tags "),
"sentinel should have no trailing content. Got:\n{}",
serialized
);
assert!(serialized.contains("Host do-web-1"));
assert!(serialized.contains("purple:provider digitalocean:123"));
}
#[test]
fn test_sync_set_provider_does_not_clobber_provider_tags() {
let content = "\
Host do-web-1
HostName 1.2.3.4
# purple:provider digitalocean:123
# purple:provider_tags prod
";
let mut config = SshConfigFile {
elements: SshConfigFile::parse_content(content),
path: PathBuf::from("/tmp/test_config"),
crlf: false,
bom: false,
};
config.set_host_provider("do-web-1", "digitalocean", "456");
let serialized = config.serialize();
assert!(
serialized.contains("# purple:provider_tags prod"),
"provider_tags should survive set_provider. Got:\n{}",
serialized
);
assert!(
serialized.contains("# purple:provider digitalocean:456"),
"provider marker should be updated. Got:\n{}",
serialized
);
}
#[test]
fn test_sync_provider_tags_roundtrip() {
let content = "\
Host do-web-1
HostName 1.2.3.4
User root
# purple:provider_tags prod,us-east
# purple:provider digitalocean:123
";
let config = SshConfigFile {
elements: SshConfigFile::parse_content(content),
path: PathBuf::from("/tmp/test_config"),
crlf: false,
bom: false,
};
let entries = config.host_entries();
assert_eq!(entries.len(), 1);
let mut ptags = entries[0].provider_tags.clone();
ptags.sort();
assert_eq!(ptags, vec!["prod", "us-east"]);
let serialized = config.serialize();
let config2 = SshConfigFile {
elements: SshConfigFile::parse_content(&serialized),
path: PathBuf::from("/tmp/test_config"),
crlf: false,
bom: false,
};
let entries2 = config2.host_entries();
assert_eq!(entries2.len(), 1);
let mut ptags2 = entries2[0].provider_tags.clone();
ptags2.sort();
assert_eq!(ptags2, vec!["prod", "us-east"]);
}
#[test]
fn test_sync_first_migration_empty_remote_writes_sentinel() {
let mut config = SshConfigFile {
elements: SshConfigFile::parse_content(
"Host do-web-1\n HostName 1.2.3.4\n # purple:provider digitalocean:123\n # purple:tags prod\n",
),
path: PathBuf::from("/tmp/test_config"),
crlf: false,
bom: false,
};
let section = make_section();
let entries = config.host_entries();
assert!(!entries[0].has_provider_tags);
assert_eq!(entries[0].tags, vec!["prod"]);
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,
false,
);
assert_eq!(result.updated, 1);
let entries = config.host_entries();
assert!(entries[0].has_provider_tags);
assert!(entries[0].provider_tags.is_empty());
assert_eq!(entries[0].tags, vec!["prod"]);
let result2 = sync_provider(
&mut config,
&MockProvider,
&remote,
§ion,
false,
false,
false,
);
assert_eq!(result2.unchanged, 1);
}
#[test]
fn test_sync_marks_stale_when_host_disappears() {
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,
false,
);
assert_eq!(config.host_entries().len(), 1);
let result = sync_provider(
&mut config,
&MockProvider,
&[],
§ion,
false,
false,
false,
);
assert_eq!(result.stale, 1);
assert_eq!(result.removed, 0);
let entries = config.host_entries();
assert_eq!(entries.len(), 1);
assert!(entries[0].stale.is_some());
}
#[test]
fn test_sync_clears_stale_when_host_returns() {
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,
false,
);
sync_provider(
&mut config,
&MockProvider,
&[],
§ion,
false,
false,
false,
);
assert!(config.host_entries()[0].stale.is_some());
let result = sync_provider(
&mut config,
&MockProvider,
&remote,
§ion,
false,
false,
false,
);
assert_eq!(result.updated, 1);
assert!(config.host_entries()[0].stale.is_none());
}
#[test]
fn test_sync_stale_timestamp_preserved_not_refreshed() {
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,
false,
);
sync_provider(
&mut config,
&MockProvider,
&[],
§ion,
false,
false,
false,
);
let ts1 = config.host_entries()[0].stale.unwrap();
sync_provider(
&mut config,
&MockProvider,
&[],
§ion,
false,
false,
false,
);
let ts2 = config.host_entries()[0].stale.unwrap();
assert_eq!(ts1, ts2);
}
#[test]
fn test_sync_stale_host_returns_with_new_ip() {
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,
false,
);
sync_provider(
&mut config,
&MockProvider,
&[],
§ion,
false,
false,
false,
);
let remote_new = vec![ProviderHost::new(
"1".to_string(),
"web".to_string(),
"9.9.9.9".to_string(),
Vec::new(),
)];
let result = sync_provider(
&mut config,
&MockProvider,
&remote_new,
§ion,
false,
false,
false,
);
assert_eq!(result.updated, 1);
let entries = config.host_entries();
assert!(entries[0].stale.is_none());
assert_eq!(entries[0].hostname, "9.9.9.9");
}
#[test]
fn test_sync_remove_deleted_still_hard_deletes() {
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,
false,
);
let result = sync_provider(
&mut config,
&MockProvider,
&[],
§ion,
true,
false,
false,
);
assert_eq!(result.removed, 1);
assert_eq!(result.stale, 0);
assert!(config.host_entries().is_empty());
}
#[test]
fn test_sync_partial_failure_no_stale_marking() {
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,
false,
);
let result = sync_provider(
&mut config,
&MockProvider,
&[],
§ion,
false,
true,
false,
);
assert_eq!(result.stale, 0);
assert!(config.host_entries()[0].stale.is_none());
}
#[test]
fn test_sync_dry_run_reports_stale_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(),
)];
sync_provider(
&mut config,
&MockProvider,
&remote,
§ion,
false,
false,
false,
);
let result = sync_provider(
&mut config,
&MockProvider,
&[],
§ion,
false,
false,
true,
);
assert_eq!(result.stale, 1);
assert!(config.host_entries()[0].stale.is_none()); }
#[test]
fn test_sync_top_level_host_marked_stale() {
let config_str = "\
Host do-web
HostName 1.2.3.4
# purple:provider digitalocean:1
";
let mut config = SshConfigFile {
elements: SshConfigFile::parse_content(config_str),
path: PathBuf::from("/tmp/test_config"),
crlf: false,
bom: false,
};
let section = make_section();
let result = sync_provider(
&mut config,
&MockProvider,
&[],
§ion,
false,
false,
false,
);
assert_eq!(result.stale, 1);
}
#[test]
fn test_sync_multiple_hosts_disappear() {
let mut config = empty_config();
let section = make_section();
let remote = vec![
ProviderHost::new("1".into(), "web".into(), "1.1.1.1".into(), Vec::new()),
ProviderHost::new("2".into(), "db".into(), "2.2.2.2".into(), Vec::new()),
ProviderHost::new("3".into(), "app".into(), "3.3.3.3".into(), Vec::new()),
];
sync_provider(
&mut config,
&MockProvider,
&remote,
§ion,
false,
false,
false,
);
assert_eq!(config.host_entries().len(), 3);
let remaining = vec![ProviderHost::new(
"2".into(),
"db".into(),
"2.2.2.2".into(),
Vec::new(),
)];
let result = sync_provider(
&mut config,
&MockProvider,
&remaining,
§ion,
false,
false,
false,
);
assert_eq!(result.stale, 2);
assert_eq!(result.unchanged, 1);
let entries = config.host_entries();
assert!(
entries
.iter()
.find(|e| e.alias == "do-web")
.unwrap()
.stale
.is_some()
);
assert!(
entries
.iter()
.find(|e| e.alias == "do-db")
.unwrap()
.stale
.is_none()
);
assert!(
entries
.iter()
.find(|e| e.alias == "do-app")
.unwrap()
.stale
.is_some()
);
}
#[test]
fn test_sync_already_stale_then_remove_deleted() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new(
"1".into(),
"web".into(),
"1.1.1.1".into(),
Vec::new(),
)];
sync_provider(
&mut config,
&MockProvider,
&remote,
§ion,
false,
false,
false,
);
sync_provider(
&mut config,
&MockProvider,
&[],
§ion,
false,
false,
false,
);
assert!(config.host_entries()[0].stale.is_some());
let result = sync_provider(
&mut config,
&MockProvider,
&[],
§ion,
true,
false,
false,
);
assert_eq!(result.removed, 1);
assert!(config.host_entries().is_empty());
}
#[test]
fn test_sync_stale_cross_provider_isolation() {
let mut config = empty_config();
let do_section = make_section();
let vultr_section = ProviderSection {
alias_prefix: "vultr".to_string(),
..make_section()
};
let do_remote = vec![ProviderHost::new(
"1".into(),
"web".into(),
"1.1.1.1".into(),
Vec::new(),
)];
sync_provider(
&mut config,
&MockProvider,
&do_remote,
&do_section,
false,
false,
false,
);
let vultr_remote = vec![ProviderHost::new(
"1".into(),
"db".into(),
"2.2.2.2".into(),
Vec::new(),
)];
sync_provider(
&mut config,
&MockProvider2,
&vultr_remote,
&vultr_section,
false,
false,
false,
);
let result = sync_provider(
&mut config,
&MockProvider,
&[],
&do_section,
false,
false,
false,
);
assert_eq!(result.stale, 1);
let entries = config.host_entries();
assert!(
entries
.iter()
.find(|e| e.alias == "do-web")
.unwrap()
.stale
.is_some()
);
assert!(
entries
.iter()
.find(|e| e.alias == "vultr-db")
.unwrap()
.stale
.is_none()
);
}
#[test]
fn test_sync_stale_host_returns_with_tag_changes() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new(
"1".into(),
"web".into(),
"1.1.1.1".into(),
vec!["prod".into()],
)];
sync_provider(
&mut config,
&MockProvider,
&remote,
§ion,
false,
false,
false,
);
sync_provider(
&mut config,
&MockProvider,
&[],
§ion,
false,
false,
false,
);
assert!(config.host_entries()[0].stale.is_some());
let remote_new = vec![ProviderHost::new(
"1".into(),
"web".into(),
"1.1.1.1".into(),
vec!["staging".into()],
)];
let result = sync_provider(
&mut config,
&MockProvider,
&remote_new,
§ion,
false,
false,
false,
);
assert_eq!(result.updated, 1);
let entries = config.host_entries();
assert!(entries[0].stale.is_none());
assert!(entries[0].provider_tags.contains(&"staging".to_string()));
}
#[test]
fn test_sync_stale_result_count_includes_already_stale() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new(
"1".into(),
"web".into(),
"1.2.3.4".into(),
Vec::new(),
)];
sync_provider(
&mut config,
&MockProvider,
&remote,
§ion,
false,
false,
false,
);
let r1 = sync_provider(
&mut config,
&MockProvider,
&[],
§ion,
false,
false,
false,
);
assert_eq!(r1.stale, 1);
let r2 = sync_provider(
&mut config,
&MockProvider,
&[],
§ion,
false,
false,
false,
);
assert_eq!(r2.stale, 1);
}
#[test]
fn test_sync_stale_config_byte_identical_after_clear() {
let mut config = empty_config();
let section = make_section();
let remote = vec![
ProviderHost::new(
"1".into(),
"web".into(),
"1.1.1.1".into(),
vec!["prod".into()],
),
ProviderHost::new("2".into(), "db".into(), "2.2.2.2".into(), Vec::new()),
];
sync_provider(
&mut config,
&MockProvider,
&remote,
§ion,
false,
false,
false,
);
let config_after_add = config.serialize();
sync_provider(
&mut config,
&MockProvider,
&[],
§ion,
false,
false,
false,
);
let config_after_stale = config.serialize();
assert_ne!(config_after_stale, config_after_add);
sync_provider(
&mut config,
&MockProvider,
&remote,
§ion,
false,
false,
false,
);
let config_after_return = config.serialize();
assert_eq!(
config_after_return, config_after_add,
"config must be byte-identical after stale->return cycle"
);
}
#[test]
fn test_sync_stale_preserves_neighboring_hosts() {
let config_str = "\
Host manual
HostName 10.0.0.1
User admin
";
let mut config = SshConfigFile {
elements: SshConfigFile::parse_content(config_str),
path: PathBuf::from("/tmp/test_config"),
crlf: false,
bom: false,
};
let section = make_section();
let remote = vec![ProviderHost::new(
"1".into(),
"web".into(),
"1.1.1.1".into(),
Vec::new(),
)];
sync_provider(
&mut config,
&MockProvider,
&remote,
§ion,
false,
false,
false,
);
sync_provider(
&mut config,
&MockProvider,
&[],
§ion,
false,
false,
false,
);
let output = config.serialize();
assert!(
output.contains("Host manual"),
"manual host lost after stale marking"
);
assert!(
output.contains("HostName 10.0.0.1"),
"manual host directives lost after stale marking"
);
assert!(
output.contains("User admin"),
"manual host user lost after stale marking"
);
}
#[test]
fn test_sync_stale_then_purge_leaves_clean_config() {
let config_str = "\
Host manual
HostName 10.0.0.1
User admin
";
let mut config = SshConfigFile {
elements: SshConfigFile::parse_content(config_str),
path: PathBuf::from("/tmp/test_config"),
crlf: false,
bom: false,
};
let section = make_section();
let remote = vec![
ProviderHost::new("1".into(), "web".into(), "1.1.1.1".into(), Vec::new()),
ProviderHost::new("2".into(), "db".into(), "2.2.2.2".into(), Vec::new()),
];
sync_provider(
&mut config,
&MockProvider,
&remote,
§ion,
false,
false,
false,
);
sync_provider(
&mut config,
&MockProvider,
&[],
§ion,
false,
false,
false,
);
let stale = config.stale_hosts();
for (alias, _) in &stale {
config.delete_host(alias);
}
let output = config.serialize();
assert!(output.contains("Host manual"));
assert!(output.contains("HostName 10.0.0.1"));
assert!(!output.contains("purple:stale"));
assert!(!output.contains("purple:group"));
assert!(
!output.contains("\n\n\n"),
"excessive blank lines after purge:\n{}",
output
);
}
#[test]
fn test_sync_stale_empty_ip_return_preserves_hostname() {
let mut config = empty_config();
let section = make_section();
let remote = vec![ProviderHost::new(
"1".into(),
"web".into(),
"1.1.1.1".into(),
Vec::new(),
)];
sync_provider(
&mut config,
&MockProvider,
&remote,
§ion,
false,
false,
false,
);
sync_provider(
&mut config,
&MockProvider,
&[],
§ion,
false,
false,
false,
);
assert!(config.host_entries()[0].stale.is_some());
let remote_empty_ip = vec![ProviderHost::new(
"1".into(),
"web".into(),
"".into(),
Vec::new(),
)];
let result = sync_provider(
&mut config,
&MockProvider,
&remote_empty_ip,
§ion,
false,
false,
false,
);
assert_eq!(result.updated, 1);
assert!(config.host_entries()[0].stale.is_none());
assert_eq!(config.host_entries()[0].hostname, "1.1.1.1");
}
#[test]
fn test_sync_insert_adds_blank_line_before_next_group() {
let config_str = "\
# purple:group DigitalOcean
Host do-web
HostName 1.1.1.1
User root
# purple:provider digitalocean:111
# purple:group Hetzner
Host hz-build
HostName 2.2.2.2
User ci
# purple:provider hetzner:222
";
let mut config = SshConfigFile {
elements: SshConfigFile::parse_content(config_str),
path: PathBuf::from("/tmp/test_config"),
crlf: false,
bom: false,
};
let section = make_section();
let remote = vec![
ProviderHost::new("111".into(), "web".into(), "1.1.1.1".into(), Vec::new()),
ProviderHost::new("333".into(), "db".into(), "3.3.3.3".into(), Vec::new()),
];
sync_provider(
&mut config,
&MockProvider,
&remote,
§ion,
false,
false,
false,
);
let output = config.serialize();
assert!(
output.contains("\n\n# purple:group Hetzner"),
"missing blank line before next group header:\n{}",
output
);
assert!(
!output.contains("\n\n\n"),
"triple blank lines found:\n{}",
output
);
}
#[test]
fn test_sync_insert_blank_line_real_world_scenario() {
let config_str = "\
# purple:group DigitalOcean
Host do-signalproxy
HostName 128.199.41.235
User root
IdentityFile ~/.ssh/id_ed25519
# purple:provider digitalocean:517532225
# purple:meta region=ams3,size=s-1vcpu-512mb-10gb,status=active
Port 60022
# purple:provider_tags
# purple:tags signal
# purple:group Proxmox VE
Host pve-testvm
HostName 192.168.1.100
User root
# purple:provider proxmox:100
";
let mut config = SshConfigFile {
elements: SshConfigFile::parse_content(config_str),
path: PathBuf::from("/tmp/test_config"),
crlf: false,
bom: false,
};
let section = make_section();
let remote = vec![
ProviderHost::new(
"517532225".into(),
"signalproxy-nl".into(),
"128.199.41.235".into(),
Vec::new(),
),
ProviderHost::new(
"560734563".into(),
"ubuntu-nyc1".into(),
"167.172.128.123".into(),
Vec::new(),
),
];
sync_provider(
&mut config,
&MockProvider,
&remote,
§ion,
false,
false,
false,
);
let output = config.serialize();
assert!(
output.contains("\n\n# purple:group Proxmox VE"),
"missing blank line before Proxmox group:\n{}",
output
);
assert!(output.contains("Host do-signalproxy") || output.contains("Host do-signalproxy-nl"));
assert!(output.contains("Host do-ubuntu-nyc1"));
assert!(output.contains("Host pve-testvm"));
assert!(
!output.contains("\n\n\n"),
"triple blank lines:\n{}",
output
);
}