claude_agent/context/
builder.rs

1//! Context Builder for Progressive Disclosure
2
3use std::path::{Path, PathBuf};
4
5use crate::client::DEFAULT_MODEL;
6use crate::common::{Index, IndexRegistry};
7use crate::skills::SkillIndex;
8
9use super::ContextResult;
10use super::memory_loader::MemoryLoader;
11use super::orchestrator::PromptOrchestrator;
12use super::rule_index::RuleIndex;
13use super::static_context::StaticContext;
14
15pub struct ContextBuilder {
16    system_prompt: Option<String>,
17    claude_md: Option<String>,
18    skill_registry: IndexRegistry<SkillIndex>,
19    rule_registry: IndexRegistry<RuleIndex>,
20    working_dir: Option<PathBuf>,
21    model: String,
22}
23
24impl Default for ContextBuilder {
25    fn default() -> Self {
26        Self::new()
27    }
28}
29
30impl ContextBuilder {
31    pub fn new() -> Self {
32        Self {
33            system_prompt: None,
34            claude_md: None,
35            skill_registry: IndexRegistry::new(),
36            rule_registry: IndexRegistry::new(),
37            working_dir: None,
38            model: DEFAULT_MODEL.to_string(),
39        }
40    }
41
42    pub fn model(mut self, model: impl Into<String>) -> Self {
43        self.model = model.into();
44        self
45    }
46
47    pub fn system_prompt(mut self, prompt: impl Into<String>) -> Self {
48        self.system_prompt = Some(prompt.into());
49        self
50    }
51
52    pub fn claude_md(mut self, content: impl Into<String>) -> Self {
53        self.claude_md = Some(content.into());
54        self
55    }
56
57    pub fn working_dir(mut self, path: impl Into<PathBuf>) -> Self {
58        self.working_dir = Some(path.into());
59        self
60    }
61
62    pub fn with_skill(mut self, skill: SkillIndex) -> Self {
63        self.skill_registry.register(skill);
64        self
65    }
66
67    pub fn with_skills(mut self, skills: impl IntoIterator<Item = SkillIndex>) -> Self {
68        self.skill_registry.register_all(skills);
69        self
70    }
71
72    pub fn with_skill_registry(mut self, registry: IndexRegistry<SkillIndex>) -> Self {
73        self.skill_registry = registry;
74        self
75    }
76
77    pub fn with_rule(mut self, rule: RuleIndex) -> Self {
78        self.rule_registry.register(rule);
79        self
80    }
81
82    pub fn with_rules(mut self, rules: impl IntoIterator<Item = RuleIndex>) -> Self {
83        self.rule_registry.register_all(rules);
84        self
85    }
86
87    pub fn with_rule_registry(mut self, registry: IndexRegistry<RuleIndex>) -> Self {
88        self.rule_registry = registry;
89        self
90    }
91
92    pub async fn load_from_directory(mut self, dir: impl AsRef<Path>) -> Self {
93        let dir = dir.as_ref();
94        let loader = MemoryLoader::new();
95
96        if let Ok(content) = loader.load(dir).await {
97            let combined = content.combined_claude_md();
98            if !combined.is_empty() {
99                self.claude_md = Some(match self.claude_md {
100                    Some(existing) => format!("{}\n\n{}", existing, combined),
101                    None => combined,
102                });
103            }
104
105            // Register rules from directory
106            for rule in content.rule_indices {
107                self.rule_registry.register(rule);
108            }
109        }
110
111        self
112    }
113
114    pub fn build(self) -> ContextResult<PromptOrchestrator> {
115        let mut static_context = StaticContext::new();
116
117        if let Some(ref prompt) = self.system_prompt {
118            static_context = static_context.with_system_prompt(prompt.clone());
119        }
120
121        if let Some(ref md) = self.claude_md {
122            static_context = static_context.with_claude_md(md.clone());
123        }
124
125        let skill_summary = self.build_skill_summary();
126        if !skill_summary.is_empty() {
127            static_context = static_context.with_skill_summary(skill_summary);
128        }
129
130        // Build rules summary for static context
131        let rules_summary = self.build_rules_summary();
132        if !rules_summary.is_empty() {
133            static_context = static_context.with_rules_summary(rules_summary);
134        }
135
136        let orchestrator = PromptOrchestrator::new(static_context, &self.model)
137            .with_rule_registry(self.rule_registry)
138            .with_skill_registry(self.skill_registry);
139
140        Ok(orchestrator)
141    }
142
143    fn build_skill_summary(&self) -> String {
144        if self.skill_registry.is_empty() {
145            return String::new();
146        }
147
148        let mut lines = vec!["# Available Skills".to_string()];
149        for skill in self.skill_registry.iter() {
150            lines.push(skill.to_summary_line());
151        }
152        lines.join("\n")
153    }
154
155    fn build_rules_summary(&self) -> String {
156        if self.rule_registry.is_empty() {
157            return String::new();
158        }
159
160        let mut lines = vec!["# Available Rules".to_string()];
161        for rule in self.rule_registry.sorted_by_priority() {
162            lines.push(rule.to_summary_line());
163        }
164        lines.join("\n")
165    }
166}
167
168#[cfg(test)]
169mod tests {
170    use super::*;
171
172    #[test]
173    fn test_context_builder_basic() {
174        let orchestrator = ContextBuilder::new()
175            .system_prompt("You are helpful")
176            .claude_md("# Project\nA test project")
177            .model("claude-sonnet-4-5")
178            .build()
179            .unwrap();
180
181        let static_context = orchestrator.static_context();
182        assert!(static_context.system_prompt.contains("helpful"));
183        assert!(static_context.claude_md.contains("test project"));
184    }
185
186    #[test]
187    fn test_context_builder_with_skills() {
188        let skill = SkillIndex::new("test", "A test skill");
189
190        let orchestrator = ContextBuilder::new().with_skill(skill).build().unwrap();
191
192        assert!(!orchestrator.static_context().skill_index_summary.is_empty());
193    }
194
195    #[tokio::test]
196    async fn test_load_from_directory() {
197        use tempfile::tempdir;
198        use tokio::fs;
199
200        let dir = tempdir().unwrap();
201        fs::write(dir.path().join("CLAUDE.md"), "# Test Project")
202            .await
203            .unwrap();
204
205        let rules_dir = dir.path().join(".claude").join("rules");
206        fs::create_dir_all(&rules_dir).await.unwrap();
207        fs::write(
208            rules_dir.join("test.md"),
209            r#"---
210paths: **/*.rs
211---
212
213# Test Rule"#,
214        )
215        .await
216        .unwrap();
217
218        let orchestrator = ContextBuilder::new()
219            .load_from_directory(dir.path())
220            .await
221            .build()
222            .unwrap();
223
224        assert!(
225            orchestrator
226                .static_context()
227                .claude_md
228                .contains("Test Project")
229        );
230        // Rules are now in the rule_registry, not a separate engine
231        // We can verify through the rules summary in static context
232        assert!(!orchestrator.static_context().rules_summary.is_empty());
233    }
234
235    #[tokio::test]
236    async fn test_rule_registry_integration() {
237        use crate::common::ContentSource;
238
239        let rule = RuleIndex::new("test-rule")
240            .with_description("Test description")
241            .with_paths(vec!["**/*.rs".into()])
242            .with_source(ContentSource::in_memory("Rule content"));
243
244        let orchestrator = ContextBuilder::new().with_rule(rule).build().unwrap();
245
246        // Check that rule is in the registry
247        let registry = orchestrator.rule_registry().await;
248        assert!(registry.contains("test-rule"));
249    }
250}