1use regex::Regex;
32use smallvec::SmallVec;
33
34#[derive(Clone)]
39pub struct Defaults {
40 entries: SmallVec<[DefaultEntry; 8]>,
41}
42
43#[derive(Clone)]
44struct DefaultEntry {
45 key: String,
46 pattern: Regex,
47 pattern_str: String,
48 replacement: String,
49 source: ValueSource,
50 note: Option<String>,
51}
52
53#[derive(Clone)]
54enum ValueSource {
55 Literal,
56 EnvVar(String),
57}
58
59#[derive(Debug, Clone)]
61pub struct DefaultAnnotation {
62 pub key: String,
64 pub original_pattern: String,
66 pub replacement: String,
68 pub note: Option<String>,
70 pub source: String,
72}
73
74impl Defaults {
75 pub fn new() -> Self {
77 Self {
78 entries: SmallVec::new(),
79 }
80 }
81
82 pub fn set(mut self, key: &str, pattern: &str, replacement: &str) -> Self {
84 if let Ok(re) = Regex::new(pattern) {
85 self.entries.push(DefaultEntry {
86 key: key.to_string(),
87 pattern: re,
88 pattern_str: pattern.to_string(),
89 replacement: replacement.to_string(),
90 source: ValueSource::Literal,
91 note: None,
92 });
93 }
94 self
95 }
96
97 pub fn set_with_note(
99 mut self,
100 key: &str,
101 pattern: &str,
102 replacement: &str,
103 note: &str,
104 ) -> Self {
105 if let Ok(re) = Regex::new(pattern) {
106 self.entries.push(DefaultEntry {
107 key: key.to_string(),
108 pattern: re,
109 pattern_str: pattern.to_string(),
110 replacement: replacement.to_string(),
111 source: ValueSource::Literal,
112 note: Some(note.to_string()),
113 });
114 }
115 self
116 }
117
118 pub fn from_env(mut self, key: &str, pattern: &str, env_var: &str, fallback: &str) -> Self {
120 let value = std::env::var(env_var).unwrap_or_else(|_| fallback.to_string());
121 if let Ok(re) = Regex::new(pattern) {
122 self.entries.push(DefaultEntry {
123 key: key.to_string(),
124 pattern: re,
125 pattern_str: pattern.to_string(),
126 replacement: value,
127 source: ValueSource::EnvVar(env_var.to_string()),
128 note: None,
129 });
130 }
131 self
132 }
133
134 pub fn from_env_with_note(
136 mut self,
137 key: &str,
138 pattern: &str,
139 env_var: &str,
140 fallback: &str,
141 note: &str,
142 ) -> Self {
143 let value = std::env::var(env_var).unwrap_or_else(|_| fallback.to_string());
144 if let Ok(re) = Regex::new(pattern) {
145 self.entries.push(DefaultEntry {
146 key: key.to_string(),
147 pattern: re,
148 pattern_str: pattern.to_string(),
149 replacement: value,
150 source: ValueSource::EnvVar(env_var.to_string()),
151 note: Some(note.to_string()),
152 });
153 }
154 self
155 }
156
157 pub fn apply(&self, text: &str) -> String {
159 let mut result = text.to_string();
160 for entry in &self.entries {
161 result = entry
162 .pattern
163 .replace_all(&result, entry.replacement.as_str())
164 .into_owned();
165 }
166 result
167 }
168
169 pub fn context(&self) -> String {
178 if self.entries.is_empty() {
179 return String::new();
180 }
181
182 let mut lines = vec!["## Runtime Defaults".to_string()];
183 for entry in &self.entries {
184 let source = match &entry.source {
185 ValueSource::Literal => "literal".to_string(),
186 ValueSource::EnvVar(var) => format!("from env: {}", var),
187 };
188 let mut line = format!("- {}: {} ({})", entry.key, entry.replacement, source);
189 if let Some(ref note) = entry.note {
190 line.push_str(&format!(" — {}", note));
191 }
192 lines.push(line);
193 }
194 lines.join("\n")
195 }
196
197 pub fn annotations(&self) -> Vec<DefaultAnnotation> {
199 self.entries
200 .iter()
201 .map(|entry| DefaultAnnotation {
202 key: entry.key.clone(),
203 original_pattern: entry.pattern_str.clone(),
204 replacement: entry.replacement.clone(),
205 note: entry.note.clone(),
206 source: match &entry.source {
207 ValueSource::Literal => "literal".to_string(),
208 ValueSource::EnvVar(var) => format!("env:{}", var),
209 },
210 })
211 .collect()
212 }
213
214 pub fn merge(mut self, other: &Defaults) -> Self {
219 for other_entry in &other.entries {
220 self.entries.retain(|e| e.key != other_entry.key);
221 self.entries.push(other_entry.clone());
222 }
223 self
224 }
225
226 pub fn to_markdown_table(&self) -> String {
230 if self.entries.is_empty() {
231 return String::new();
232 }
233
234 let mut out = String::with_capacity(256);
235 out.push_str("| Key | Pattern | Replacement | Source | Note |\n");
236 out.push_str("|-----|---------|-------------|--------|------|\n");
237
238 for entry in &self.entries {
239 let source = match &entry.source {
240 ValueSource::Literal => "literal",
241 ValueSource::EnvVar(var) => var.as_str(),
242 };
243 let note = entry.note.as_deref().unwrap_or("");
244 out.push_str(&format!(
245 "| {} | `{}` | `{}` | {} | {} |\n",
246 entry.key, entry.pattern_str, entry.replacement, source, note
247 ));
248 }
249
250 out
251 }
252
253 pub fn is_empty(&self) -> bool {
255 self.entries.is_empty()
256 }
257
258 pub fn len(&self) -> usize {
260 self.entries.len()
261 }
262}
263
264impl Default for Defaults {
265 fn default() -> Self {
266 Self::new()
267 }
268}
269
270#[cfg(test)]
275mod tests {
276 use super::*;
277
278 #[test]
279 fn test_defaults_apply_single() {
280 let defaults = Defaults::new().set("email", r"admin@example\.com", "real@company.com");
281
282 let result = defaults.apply("user:admin@example.com");
283 assert_eq!(result, "user:real@company.com");
284 }
285
286 #[test]
287 fn test_defaults_apply_multiple() {
288 let defaults = Defaults::new()
289 .set("email", r"user:\S+@example\.com", "user:spicyzhug@test.com")
290 .set("project", r"my-gcp-project", "prod-project-123");
291
292 let text = "IAM: user:admin@example.com in my-gcp-project";
293 let result = defaults.apply(text);
294 assert!(result.contains("user:spicyzhug@test.com"));
295 assert!(result.contains("prod-project-123"));
296 assert!(!result.contains("example.com"));
297 assert!(!result.contains("my-gcp-project"));
298 }
299
300 #[test]
301 fn test_defaults_from_env() {
302 let defaults = Defaults::new().from_env(
304 "project",
305 r"my-gcp-project",
306 "KKACHI_TEST_NONEXISTENT_VAR_12345",
307 "fallback-project",
308 );
309
310 let result = defaults.apply("deploy to my-gcp-project");
311 assert_eq!(result, "deploy to fallback-project");
312 }
313
314 #[test]
315 fn test_defaults_from_env_with_var_set() {
316 std::env::set_var("KKACHI_TEST_PROJECT_ID", "env-project-value");
317 let defaults = Defaults::new().from_env(
318 "project",
319 r"my-gcp-project",
320 "KKACHI_TEST_PROJECT_ID",
321 "fallback-project",
322 );
323
324 let result = defaults.apply("deploy to my-gcp-project");
325 assert_eq!(result, "deploy to env-project-value");
326 std::env::remove_var("KKACHI_TEST_PROJECT_ID");
327 }
328
329 #[test]
330 fn test_defaults_context() {
331 let defaults = Defaults::new()
332 .set("email", r"admin@example\.com", "real@company.com")
333 .from_env(
334 "project",
335 r"my-project",
336 "KKACHI_TEST_NONEXISTENT_12345",
337 "fallback-proj",
338 );
339
340 let ctx = defaults.context();
341 assert!(ctx.contains("## Runtime Defaults"));
342 assert!(ctx.contains("email: real@company.com (literal)"));
343 assert!(ctx.contains("project: fallback-proj (from env: KKACHI_TEST_NONEXISTENT_12345)"));
344 }
345
346 #[test]
347 fn test_defaults_context_with_notes() {
348 let defaults = Defaults::new().set_with_note(
349 "email",
350 r"admin@example\.com",
351 "real@company.com",
352 "Replace with actual IAM user",
353 );
354
355 let ctx = defaults.context();
356 assert!(ctx.contains("Replace with actual IAM user"));
357 }
358
359 #[test]
360 fn test_defaults_annotations() {
361 let defaults = Defaults::new()
362 .set_with_note(
363 "email",
364 r"admin@example\.com",
365 "real@company.com",
366 "IAM user",
367 )
368 .from_env(
369 "project",
370 r"my-project",
371 "KKACHI_TEST_NONEXISTENT_12345",
372 "fallback",
373 );
374
375 let annotations = defaults.annotations();
376 assert_eq!(annotations.len(), 2);
377
378 assert_eq!(annotations[0].key, "email");
379 assert_eq!(annotations[0].replacement, "real@company.com");
380 assert_eq!(annotations[0].note.as_deref(), Some("IAM user"));
381 assert_eq!(annotations[0].source, "literal");
382
383 assert_eq!(annotations[1].key, "project");
384 assert!(annotations[1].source.starts_with("env:"));
385 }
386
387 #[test]
388 fn test_defaults_no_match() {
389 let defaults = Defaults::new().set("email", r"admin@example\.com", "real@company.com");
390
391 let text = "no matches here";
392 let result = defaults.apply(text);
393 assert_eq!(result, text);
394 }
395
396 #[test]
397 fn test_defaults_empty() {
398 let defaults = Defaults::new();
399 assert!(defaults.is_empty());
400 assert_eq!(defaults.len(), 0);
401 assert_eq!(defaults.context(), "");
402 assert!(defaults.annotations().is_empty());
403 assert_eq!(defaults.apply("unchanged"), "unchanged");
404 }
405
406 #[test]
407 fn test_defaults_multiple_occurrences() {
408 let defaults = Defaults::new().set("email", r"admin@example\.com", "real@company.com");
409
410 let text = "user:admin@example.com and group:admin@example.com";
411 let result = defaults.apply(text);
412 assert_eq!(result, "user:real@company.com and group:real@company.com");
413 }
414
415 #[test]
416 fn test_defaults_invalid_regex_skipped() {
417 let defaults = Defaults::new()
419 .set("bad", r"[invalid", "replacement")
420 .set("good", r"hello", "world");
421
422 assert_eq!(defaults.len(), 1);
423 assert_eq!(defaults.apply("hello"), "world");
424 }
425}