use serde::Deserialize;
use std::sync::OnceLock;
#[derive(Debug, Clone, Deserialize)]
struct PermutationConfig {
suffixes: StringList,
prefixes: StringList,
transforms: Transforms,
}
#[derive(Debug, Clone, Deserialize)]
struct StringList {
values: Vec<String>,
}
#[derive(Debug, Clone, Deserialize)]
struct Transforms {
#[serde(rename = "dot_to_hyphen")]
dot_to_hyphen: bool,
#[serde(rename = "hyphen_to_dot")]
hyphen_to_dot: bool,
}
const BUILTIN_PERMUTATIONS: &str = include_str!("../rules/permutations.toml");
static PERMUTATIONS: OnceLock<PermutationConfig> = OnceLock::new();
fn builtin_permutations() -> &'static PermutationConfig {
PERMUTATIONS.get_or_init(|| {
match toml::from_str::<PermutationConfig>(BUILTIN_PERMUTATIONS) {
Ok(config) => config,
Err(e) => panic!("gossan-cloud: built-in permutations.toml is malformed: {e}"),
}
})
}
const MAX_BUCKET_LEN: usize = 63;
pub fn generate(org: &str) -> Vec<String> {
if org.len() > MAX_BUCKET_LEN {
return Vec::new();
}
let o = org.to_lowercase();
let config = builtin_permutations();
let suffixes = &config.suffixes.values;
let prefixes = &config.prefixes.values;
let mut candidates = std::collections::HashSet::new();
for suffix in suffixes {
for prefix in prefixes {
let name = format!("{}{}{}", prefix, o, suffix);
if name.len() >= 3 && name.len() <= 63 {
candidates.insert(name);
}
}
}
if config.transforms.dot_to_hyphen {
let transformed = o.replace('.', "-");
if transformed.len() >= 3 && transformed.len() <= 63 {
candidates.insert(transformed);
}
}
if config.transforms.hyphen_to_dot {
let transformed = o.replace('-', ".");
if transformed.len() >= 3 && transformed.len() <= 63 {
candidates.insert(transformed);
}
}
candidates.into_iter().collect()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn generate_includes_expected_prefix_and_suffix_forms() {
let candidates = generate("example");
assert!(candidates.contains(&"example-assets".to_string()));
assert!(candidates.contains(&"assets-example".to_string()));
assert!(candidates.contains(&"example".to_string()));
}
#[test]
fn generate_deduplicates_candidates() {
let candidates = generate("example");
let unique = candidates.iter().collect::<std::collections::HashSet<_>>();
assert_eq!(unique.len(), candidates.len());
}
#[test]
fn generate_normalizes_case_and_preserves_valid_lengths() {
let candidates = generate("ExAmPlE");
assert!(candidates.iter().all(|name| name == &name.to_lowercase()));
assert!(candidates.iter().all(|name| (3..=63).contains(&name.len())));
}
#[test]
fn permutations_load_from_toml() {
let config = builtin_permutations();
assert!(
!config.suffixes.values.is_empty(),
"should have suffixes from TOML"
);
assert!(
!config.prefixes.values.is_empty(),
"should have prefixes from TOML"
);
}
#[test]
fn permutations_include_expected_suffixes() {
let config = builtin_permutations();
let suffixes = &config.suffixes.values;
assert!(
suffixes.contains(&"".to_string()),
"should include empty suffix"
);
assert!(
suffixes.contains(&"-assets".to_string()),
"should include -assets"
);
assert!(
suffixes.contains(&"-prod".to_string()),
"should include -prod"
);
assert!(
suffixes.contains(&"-backup".to_string()),
"should include -backup"
);
}
#[test]
fn permutations_include_expected_prefixes() {
let config = builtin_permutations();
let prefixes = &config.prefixes.values;
assert!(
prefixes.contains(&"".to_string()),
"should include empty prefix"
);
assert!(
prefixes.contains(&"assets-".to_string()),
"should include assets-"
);
assert!(
prefixes.contains(&"dev-".to_string()),
"should include dev-"
);
}
#[test]
fn transforms_are_enabled() {
let config = builtin_permutations();
assert!(
config.transforms.dot_to_hyphen,
"dot_to_hyphen should be enabled"
);
assert!(
config.transforms.hyphen_to_dot,
"hyphen_to_dot should be enabled"
);
}
#[test]
fn generate_passes_guard_at_exactly_max_but_rejects_one_past() {
let at_max = generate(&"a".repeat(MAX_BUCKET_LEN));
assert!(
!at_max.is_empty(),
"org of exactly {MAX_BUCKET_LEN} bytes passes the guard (the bare name fits)"
);
assert!(
at_max.iter().all(|c| c.len() == MAX_BUCKET_LEN),
"at the max only the bare {MAX_BUCKET_LEN}-char form survives; decorated forms exceed 63: {at_max:?}"
);
let one_past = generate(&"a".repeat(MAX_BUCKET_LEN + 1));
assert!(
one_past.is_empty(),
"org of {} bytes (one past max) trips the OOM guard → empty",
MAX_BUCKET_LEN + 1
);
}
#[test]
fn generate_returns_empty_for_very_long_org() {
let org = "a".repeat(10_000);
let candidates = generate(&org);
assert!(
candidates.is_empty(),
"very long org must return empty (OOM guard)"
);
}
#[test]
fn generate_returns_some_for_org_one_below_max_bucket_len() {
let org = "a".repeat(MAX_BUCKET_LEN - 1);
let candidates = generate(&org);
assert!(
!candidates.is_empty(),
"org one byte under limit must produce candidates"
);
for c in &candidates {
assert!(
(3..=63).contains(&c.len()),
"candidate '{c}' outside [3,63] bounds"
);
}
}
#[test]
fn generate_empty_org_returns_empty_or_minimal() {
let candidates = generate("");
for c in &candidates {
assert!(
(3..=63).contains(&c.len()),
"candidate '{c}' outside [3,63] bounds for empty org"
);
}
}
#[test]
fn generate_single_char_org() {
let candidates = generate("x");
for c in &candidates {
assert!((3..=63).contains(&c.len()));
}
}
#[test]
fn generate_two_char_org() {
let candidates = generate("ab");
for c in &candidates {
assert!(
(3..=63).contains(&c.len()),
"candidate '{c}' violates length gate"
);
}
}
#[test]
fn generate_all_candidates_are_lowercase() {
for org in ["Example", "CORP", "MyOrg-2024"] {
let candidates = generate(org);
for c in &candidates {
assert_eq!(*c, c.to_lowercase(), "candidate '{c}' is not lowercase for org='{org}'");
}
}
}
#[test]
fn generate_no_candidate_exceeds_max_length() {
for org in ["example", "my-company", "acme-corp"] {
let candidates = generate(org);
for c in &candidates {
assert!(
c.len() <= 63,
"candidate '{c}' exceeds 63-char S3 limit for org='{org}'"
);
}
}
}
#[test]
fn generate_is_idempotent() {
let mut a = generate("stable-corp");
let mut b = generate("stable-corp");
a.sort();
b.sort();
assert_eq!(a, b, "generate must be idempotent for the same input");
}
#[test]
fn generate_dot_to_hyphen_transform_applied() {
let candidates = generate("my.org");
assert!(
candidates.contains(&"my-org".to_string()),
"dot_to_hyphen transform must produce 'my-org' from 'my.org'"
);
}
#[test]
fn generate_hyphen_to_dot_transform_applied() {
let candidates = generate("my-org");
assert!(
candidates.contains(&"my.org".to_string()),
"hyphen_to_dot transform must produce 'my.org' from 'my-org'"
);
}
}