Skip to main content

kkachi/recursive/
defaults.rs

1// Copyright © 2025 lituus-io <spicyzhug@gmail.com>
2// All Rights Reserved.
3// Licensed under PolyForm Noncommercial 1.0.0
4
5//! Runtime defaults for regex substitution on LLM output.
6//!
7//! Provides a [`Defaults`] type that stores regex→replacement pairs with metadata.
8//! It integrates at two points:
9//!
10//! 1. **Prompt injection** — [`Defaults::context()`] renders a human-readable summary
11//! 2. **Output transform** — [`Defaults::apply()`] does regex replacements before validation
12//!
13//! # Example
14//!
15//! ```
16//! use kkachi::recursive::Defaults;
17//!
18//! let defaults = Defaults::new()
19//!     .set("email", r"admin@example\.com", "real@company.com")
20//!     .set_with_note("project", r"my-project", "prod-project-123",
21//!         "Replace with actual GCP project ID");
22//!
23//! let output = defaults.apply("user:admin@example.com in my-project");
24//! assert!(output.contains("real@company.com"));
25//! assert!(output.contains("prod-project-123"));
26//!
27//! let ctx = defaults.context();
28//! assert!(ctx.contains("email"));
29//! ```
30
31use regex::Regex;
32use smallvec::SmallVec;
33
34/// A collection of runtime default values applied via regex substitution.
35///
36/// Used to replace known-bad placeholders in LLM output before validation,
37/// and to generate context strings for prompt injection.
38#[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/// Annotation metadata for a single default entry.
60#[derive(Debug, Clone)]
61pub struct DefaultAnnotation {
62    /// Key name for this default.
63    pub key: String,
64    /// The original regex pattern string.
65    pub original_pattern: String,
66    /// The replacement value.
67    pub replacement: String,
68    /// Optional human-readable note.
69    pub note: Option<String>,
70    /// Source description: "literal" or "env:VAR_NAME".
71    pub source: String,
72}
73
74impl Defaults {
75    /// Create an empty defaults collection.
76    pub fn new() -> Self {
77        Self {
78            entries: SmallVec::new(),
79        }
80    }
81
82    /// Add a literal regex substitution.
83    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    /// Add a literal regex substitution with a human-readable annotation note.
98    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    /// Read replacement value from an environment variable; use fallback if unset.
119    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    /// Read replacement value from an environment variable with a note.
135    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    /// Apply all regex substitutions to text. Returns transformed text.
158    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    /// Generate a context string for prompt injection.
170    ///
171    /// Renders as:
172    /// ```text
173    /// ## Runtime Defaults
174    /// - email: real@company.com (literal)
175    /// - project: prod-123 (from env: GCP_PROJECT)
176    /// ```
177    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    /// Get annotation metadata for all entries.
198    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    /// Merge another Defaults collection into this one.
215    ///
216    /// Entries from `other` are appended. If `other` has an entry with the
217    /// same key as one already in `self`, the one from `other` replaces it.
218    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    /// Render all defaults as a markdown table.
227    ///
228    /// Returns a table with columns: Key, Pattern, Replacement, Source, Note.
229    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    /// Check if there are no entries.
254    pub fn is_empty(&self) -> bool {
255        self.entries.is_empty()
256    }
257
258    /// Get the number of entries.
259    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// ============================================================================
271// Tests
272// ============================================================================
273
274#[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        // Test with a non-existent env var (should use fallback)
303        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        // Invalid regex should be silently skipped
418        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}