use regex::Regex;
use smallvec::SmallVec;
#[derive(Clone)]
pub struct Defaults {
entries: SmallVec<[DefaultEntry; 8]>,
}
#[derive(Clone)]
struct DefaultEntry {
key: String,
pattern: Regex,
pattern_str: String,
replacement: String,
source: ValueSource,
note: Option<String>,
}
#[derive(Clone)]
enum ValueSource {
Literal,
EnvVar(String),
}
#[derive(Debug, Clone)]
pub struct DefaultAnnotation {
pub key: String,
pub original_pattern: String,
pub replacement: String,
pub note: Option<String>,
pub source: String,
}
impl Defaults {
pub fn new() -> Self {
Self {
entries: SmallVec::new(),
}
}
pub fn set(mut self, key: &str, pattern: &str, replacement: &str) -> Self {
if let Ok(re) = Regex::new(pattern) {
self.entries.push(DefaultEntry {
key: key.to_string(),
pattern: re,
pattern_str: pattern.to_string(),
replacement: replacement.to_string(),
source: ValueSource::Literal,
note: None,
});
}
self
}
pub fn set_with_note(
mut self,
key: &str,
pattern: &str,
replacement: &str,
note: &str,
) -> Self {
if let Ok(re) = Regex::new(pattern) {
self.entries.push(DefaultEntry {
key: key.to_string(),
pattern: re,
pattern_str: pattern.to_string(),
replacement: replacement.to_string(),
source: ValueSource::Literal,
note: Some(note.to_string()),
});
}
self
}
pub fn from_env(mut self, key: &str, pattern: &str, env_var: &str, fallback: &str) -> Self {
let value = std::env::var(env_var).unwrap_or_else(|_| fallback.to_string());
if let Ok(re) = Regex::new(pattern) {
self.entries.push(DefaultEntry {
key: key.to_string(),
pattern: re,
pattern_str: pattern.to_string(),
replacement: value,
source: ValueSource::EnvVar(env_var.to_string()),
note: None,
});
}
self
}
pub fn from_env_with_note(
mut self,
key: &str,
pattern: &str,
env_var: &str,
fallback: &str,
note: &str,
) -> Self {
let value = std::env::var(env_var).unwrap_or_else(|_| fallback.to_string());
if let Ok(re) = Regex::new(pattern) {
self.entries.push(DefaultEntry {
key: key.to_string(),
pattern: re,
pattern_str: pattern.to_string(),
replacement: value,
source: ValueSource::EnvVar(env_var.to_string()),
note: Some(note.to_string()),
});
}
self
}
pub fn apply(&self, text: &str) -> String {
let mut result = text.to_string();
for entry in &self.entries {
result = entry
.pattern
.replace_all(&result, entry.replacement.as_str())
.into_owned();
}
result
}
pub fn context(&self) -> String {
if self.entries.is_empty() {
return String::new();
}
let mut lines = vec!["## Runtime Defaults".to_string()];
for entry in &self.entries {
let source = match &entry.source {
ValueSource::Literal => "literal".to_string(),
ValueSource::EnvVar(var) => format!("from env: {}", var),
};
let mut line = format!("- {}: {} ({})", entry.key, entry.replacement, source);
if let Some(ref note) = entry.note {
line.push_str(&format!(" — {}", note));
}
lines.push(line);
}
lines.join("\n")
}
pub fn annotations(&self) -> Vec<DefaultAnnotation> {
self.entries
.iter()
.map(|entry| DefaultAnnotation {
key: entry.key.clone(),
original_pattern: entry.pattern_str.clone(),
replacement: entry.replacement.clone(),
note: entry.note.clone(),
source: match &entry.source {
ValueSource::Literal => "literal".to_string(),
ValueSource::EnvVar(var) => format!("env:{}", var),
},
})
.collect()
}
pub fn merge(mut self, other: &Defaults) -> Self {
for other_entry in &other.entries {
self.entries.retain(|e| e.key != other_entry.key);
self.entries.push(other_entry.clone());
}
self
}
pub fn to_markdown_table(&self) -> String {
if self.entries.is_empty() {
return String::new();
}
let mut out = String::with_capacity(256);
out.push_str("| Key | Pattern | Replacement | Source | Note |\n");
out.push_str("|-----|---------|-------------|--------|------|\n");
for entry in &self.entries {
let source = match &entry.source {
ValueSource::Literal => "literal",
ValueSource::EnvVar(var) => var.as_str(),
};
let note = entry.note.as_deref().unwrap_or("");
out.push_str(&format!(
"| {} | `{}` | `{}` | {} | {} |\n",
entry.key, entry.pattern_str, entry.replacement, source, note
));
}
out
}
pub fn is_empty(&self) -> bool {
self.entries.is_empty()
}
pub fn len(&self) -> usize {
self.entries.len()
}
}
impl Default for Defaults {
fn default() -> Self {
Self::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_defaults_apply_single() {
let defaults = Defaults::new().set("email", r"admin@example\.com", "real@company.com");
let result = defaults.apply("user:admin@example.com");
assert_eq!(result, "user:real@company.com");
}
#[test]
fn test_defaults_apply_multiple() {
let defaults = Defaults::new()
.set("email", r"user:\S+@example\.com", "user:spicyzhug@test.com")
.set("project", r"my-gcp-project", "prod-project-123");
let text = "IAM: user:admin@example.com in my-gcp-project";
let result = defaults.apply(text);
assert!(result.contains("user:spicyzhug@test.com"));
assert!(result.contains("prod-project-123"));
assert!(!result.contains("example.com"));
assert!(!result.contains("my-gcp-project"));
}
#[test]
fn test_defaults_from_env() {
let defaults = Defaults::new().from_env(
"project",
r"my-gcp-project",
"KKACHI_TEST_NONEXISTENT_VAR_12345",
"fallback-project",
);
let result = defaults.apply("deploy to my-gcp-project");
assert_eq!(result, "deploy to fallback-project");
}
#[test]
fn test_defaults_from_env_with_var_set() {
std::env::set_var("KKACHI_TEST_PROJECT_ID", "env-project-value");
let defaults = Defaults::new().from_env(
"project",
r"my-gcp-project",
"KKACHI_TEST_PROJECT_ID",
"fallback-project",
);
let result = defaults.apply("deploy to my-gcp-project");
assert_eq!(result, "deploy to env-project-value");
std::env::remove_var("KKACHI_TEST_PROJECT_ID");
}
#[test]
fn test_defaults_context() {
let defaults = Defaults::new()
.set("email", r"admin@example\.com", "real@company.com")
.from_env(
"project",
r"my-project",
"KKACHI_TEST_NONEXISTENT_12345",
"fallback-proj",
);
let ctx = defaults.context();
assert!(ctx.contains("## Runtime Defaults"));
assert!(ctx.contains("email: real@company.com (literal)"));
assert!(ctx.contains("project: fallback-proj (from env: KKACHI_TEST_NONEXISTENT_12345)"));
}
#[test]
fn test_defaults_context_with_notes() {
let defaults = Defaults::new().set_with_note(
"email",
r"admin@example\.com",
"real@company.com",
"Replace with actual IAM user",
);
let ctx = defaults.context();
assert!(ctx.contains("Replace with actual IAM user"));
}
#[test]
fn test_defaults_annotations() {
let defaults = Defaults::new()
.set_with_note(
"email",
r"admin@example\.com",
"real@company.com",
"IAM user",
)
.from_env(
"project",
r"my-project",
"KKACHI_TEST_NONEXISTENT_12345",
"fallback",
);
let annotations = defaults.annotations();
assert_eq!(annotations.len(), 2);
assert_eq!(annotations[0].key, "email");
assert_eq!(annotations[0].replacement, "real@company.com");
assert_eq!(annotations[0].note.as_deref(), Some("IAM user"));
assert_eq!(annotations[0].source, "literal");
assert_eq!(annotations[1].key, "project");
assert!(annotations[1].source.starts_with("env:"));
}
#[test]
fn test_defaults_no_match() {
let defaults = Defaults::new().set("email", r"admin@example\.com", "real@company.com");
let text = "no matches here";
let result = defaults.apply(text);
assert_eq!(result, text);
}
#[test]
fn test_defaults_empty() {
let defaults = Defaults::new();
assert!(defaults.is_empty());
assert_eq!(defaults.len(), 0);
assert_eq!(defaults.context(), "");
assert!(defaults.annotations().is_empty());
assert_eq!(defaults.apply("unchanged"), "unchanged");
}
#[test]
fn test_defaults_multiple_occurrences() {
let defaults = Defaults::new().set("email", r"admin@example\.com", "real@company.com");
let text = "user:admin@example.com and group:admin@example.com";
let result = defaults.apply(text);
assert_eq!(result, "user:real@company.com and group:real@company.com");
}
#[test]
fn test_defaults_invalid_regex_skipped() {
let defaults = Defaults::new()
.set("bad", r"[invalid", "replacement")
.set("good", r"hello", "world");
assert_eq!(defaults.len(), 1);
assert_eq!(defaults.apply("hello"), "world");
}
}