Skip to main content

agent_context_builder/
lib.rs

1/*!
2agent-context-builder: compose LLM system prompts from named sections.
3
4Build a system prompt from independently managed named sections. Sections
5can be added, removed, reordered, and conditionally included. The final
6prompt is rendered in insertion or explicit priority order.
7
8```rust
9use agent_context_builder::ContextBuilder;
10
11let prompt = ContextBuilder::new()
12    .section("role", "You are a helpful assistant.")
13    .section("rules", "Always cite sources.")
14    .build();
15assert!(prompt.contains("helpful assistant"));
16```
17*/
18
19use std::collections::HashSet;
20
21#[derive(Debug, Clone)]
22struct Section {
23    name: String,
24    content: String,
25    priority: i32,
26    enabled: bool,
27}
28
29/// Builds a system prompt from named, ordered sections.
30#[derive(Debug, Default)]
31pub struct ContextBuilder {
32    sections: Vec<Section>,
33    separator: String,
34}
35
36impl ContextBuilder {
37    pub fn new() -> Self {
38        Self { sections: Vec::new(), separator: "\n\n".into() }
39    }
40
41    /// Set the separator between sections (default: double newline).
42    pub fn separator(mut self, sep: impl Into<String>) -> Self {
43        self.separator = sep.into();
44        self
45    }
46
47    /// Add a named section. If a section with this name exists, replace it.
48    pub fn section(mut self, name: impl Into<String>, content: impl Into<String>) -> Self {
49        let name = name.into();
50        if let Some(s) = self.sections.iter_mut().find(|s| s.name == name) {
51            s.content = content.into();
52        } else {
53            self.sections.push(Section { name, content: content.into(), priority: 0, enabled: true });
54        }
55        self
56    }
57
58    /// Add a section with explicit priority (higher = earlier in output).
59    pub fn section_with_priority(mut self, name: impl Into<String>, content: impl Into<String>, priority: i32) -> Self {
60        let name = name.into();
61        if let Some(s) = self.sections.iter_mut().find(|s| s.name == name) {
62            s.content = content.into();
63            s.priority = priority;
64        } else {
65            self.sections.push(Section { name, content: content.into(), priority, enabled: true });
66        }
67        self
68    }
69
70    /// Disable a section by name (it will be omitted from build output).
71    pub fn disable(mut self, name: &str) -> Self {
72        if let Some(s) = self.sections.iter_mut().find(|s| s.name == name) {
73            s.enabled = false;
74        }
75        self
76    }
77
78    /// Re-enable a previously disabled section.
79    pub fn enable(mut self, name: &str) -> Self {
80        if let Some(s) = self.sections.iter_mut().find(|s| s.name == name) {
81            s.enabled = true;
82        }
83        self
84    }
85
86    /// Remove a section entirely.
87    pub fn remove(mut self, name: &str) -> Self {
88        self.sections.retain(|s| s.name != name);
89        self
90    }
91
92    /// Get content of a section by name.
93    pub fn get(&self, name: &str) -> Option<&str> {
94        self.sections.iter().find(|s| s.name == name).map(|s| s.content.as_str())
95    }
96
97    /// Names of all sections (in render order, enabled and disabled).
98    pub fn section_names(&self) -> Vec<&str> {
99        let mut sorted = self.sections.iter().collect::<Vec<_>>();
100        sorted.sort_by(|a, b| b.priority.cmp(&a.priority));
101        sorted.iter().map(|s| s.name.as_str()).collect()
102    }
103
104    /// Count of enabled sections.
105    pub fn enabled_count(&self) -> usize {
106        self.sections.iter().filter(|s| s.enabled).count()
107    }
108
109    /// Render the prompt: enabled sections sorted by priority desc, then insertion order.
110    pub fn build(&self) -> String {
111        let mut sorted: Vec<&Section> = self.sections.iter().filter(|s| s.enabled).collect();
112        // Stable sort: higher priority first; equal priority keeps insertion order.
113        sorted.sort_by(|a, b| b.priority.cmp(&a.priority));
114        sorted.iter().map(|s| s.content.as_str()).collect::<Vec<_>>().join(&self.separator)
115    }
116
117    /// Check if a section with the given name exists.
118    pub fn has(&self, name: &str) -> bool {
119        self.sections.iter().any(|s| s.name == name)
120    }
121
122    /// All duplicate section names (should be empty in normal use).
123    pub fn duplicate_names(&self) -> Vec<String> {
124        let mut seen = HashSet::new();
125        let mut dups = Vec::new();
126        for s in &self.sections {
127            if !seen.insert(&s.name) {
128                dups.push(s.name.clone());
129            }
130        }
131        dups
132    }
133}
134
135#[cfg(test)]
136mod tests {
137    use super::*;
138
139    #[test]
140    fn single_section() {
141        let p = ContextBuilder::new().section("role", "You are helpful.").build();
142        assert_eq!(p, "You are helpful.");
143    }
144
145    #[test]
146    fn two_sections_joined() {
147        let p = ContextBuilder::new()
148            .section("a", "First.")
149            .section("b", "Second.")
150            .build();
151        assert!(p.contains("First."));
152        assert!(p.contains("Second."));
153    }
154
155    #[test]
156    fn default_separator_double_newline() {
157        let p = ContextBuilder::new()
158            .section("a", "A")
159            .section("b", "B")
160            .build();
161        assert!(p.contains("\n\nB") || p.contains("A\n\n"), "separator missing");
162    }
163
164    #[test]
165    fn custom_separator() {
166        let p = ContextBuilder::new()
167            .separator(" | ")
168            .section("a", "A")
169            .section("b", "B")
170            .build();
171        assert!(p.contains(" | "));
172    }
173
174    #[test]
175    fn replace_existing_section() {
176        let builder = ContextBuilder::new()
177            .section("role", "Old")
178            .section("role", "New");
179        assert_eq!(builder.get("role"), Some("New"));
180    }
181
182    #[test]
183    fn disable_omits_section() {
184        let p = ContextBuilder::new()
185            .section("a", "Show")
186            .section("b", "Hide")
187            .disable("b")
188            .build();
189        assert!(p.contains("Show"));
190        assert!(!p.contains("Hide"));
191    }
192
193    #[test]
194    fn enable_restores_section() {
195        let p = ContextBuilder::new()
196            .section("a", "Content")
197            .disable("a")
198            .enable("a")
199            .build();
200        assert!(p.contains("Content"));
201    }
202
203    #[test]
204    fn remove_deletes_section() {
205        let b = ContextBuilder::new()
206            .section("a", "keep")
207            .section("b", "gone")
208            .remove("b");
209        assert!(!b.has("b"));
210        assert!(b.has("a"));
211    }
212
213    #[test]
214    fn priority_ordering() {
215        let p = ContextBuilder::new()
216            .section_with_priority("low", "LOW", 1)
217            .section_with_priority("high", "HIGH", 10)
218            .build();
219        let high_pos = p.find("HIGH").unwrap();
220        let low_pos = p.find("LOW").unwrap();
221        assert!(high_pos < low_pos);
222    }
223
224    #[test]
225    fn has_section() {
226        let b = ContextBuilder::new().section("x", "v");
227        assert!(b.has("x"));
228        assert!(!b.has("y"));
229    }
230
231    #[test]
232    fn section_names_listed() {
233        let b = ContextBuilder::new().section("a", "").section("b", "");
234        let names = b.section_names();
235        assert!(names.contains(&"a"));
236        assert!(names.contains(&"b"));
237    }
238
239    #[test]
240    fn enabled_count() {
241        let b = ContextBuilder::new()
242            .section("a", "")
243            .section("b", "")
244            .disable("b");
245        assert_eq!(b.enabled_count(), 1);
246    }
247
248    #[test]
249    fn empty_builder_empty_string() {
250        assert_eq!(ContextBuilder::new().build(), "");
251    }
252
253    #[test]
254    fn no_duplicates_in_normal_use() {
255        let b = ContextBuilder::new().section("a", "1").section("b", "2");
256        assert!(b.duplicate_names().is_empty());
257    }
258}