Skip to main content

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::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 skill(mut self, skill: SkillIndex) -> Self {
63        self.skill_registry.register(skill);
64        self
65    }
66
67    pub fn skills(mut self, skills: impl IntoIterator<Item = SkillIndex>) -> Self {
68        self.skill_registry.register_all(skills);
69        self
70    }
71
72    pub fn skill_registry(mut self, registry: IndexRegistry<SkillIndex>) -> Self {
73        self.skill_registry = registry;
74        self
75    }
76
77    pub fn rule(mut self, rule: RuleIndex) -> Self {
78        self.rule_registry.register(rule);
79        self
80    }
81
82    pub fn rules(mut self, rules: impl IntoIterator<Item = RuleIndex>) -> Self {
83        self.rule_registry.register_all(rules);
84        self
85    }
86
87    pub fn 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.system_prompt(prompt.clone());
119        }
120
121        if let Some(ref md) = self.claude_md {
122            static_context = static_context.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.skill_summary(skill_summary);
128        }
129
130        let rules_summary = self.build_rules_summary();
131        if !rules_summary.is_empty() {
132            static_context = static_context.rules_summary(rules_summary);
133        }
134
135        let orchestrator = PromptOrchestrator::new(static_context, &self.model)
136            .rule_registry(self.rule_registry)
137            .skill_registry(self.skill_registry);
138
139        Ok(orchestrator)
140    }
141
142    fn build_skill_summary(&self) -> String {
143        let summary = self.skill_registry.build_summary();
144        if summary.is_empty() {
145            return String::new();
146        }
147        format!("# Available Skills\n{summary}")
148    }
149
150    fn build_rules_summary(&self) -> String {
151        let summary = self.rule_registry.build_priority_summary();
152        if summary.is_empty() {
153            return String::new();
154        }
155        format!("# Available Rules\n{summary}")
156    }
157}
158
159#[cfg(test)]
160mod tests {
161    use super::*;
162
163    #[test]
164    fn test_context_builder_basic() {
165        let orchestrator = ContextBuilder::new()
166            .system_prompt("You are helpful")
167            .claude_md("# Project\nA test project")
168            .model("claude-sonnet-4-5")
169            .build()
170            .unwrap();
171
172        let static_context = orchestrator.static_context();
173        assert!(static_context.system_prompt.contains("helpful"));
174        assert!(static_context.claude_md.contains("test project"));
175    }
176
177    #[test]
178    fn test_context_builder_with_skills() {
179        let skill = SkillIndex::new("test", "A test skill");
180
181        let orchestrator = ContextBuilder::new().skill(skill).build().unwrap();
182
183        assert!(!orchestrator.static_context().skill_summary.is_empty());
184    }
185
186    #[tokio::test]
187    async fn test_load_from_directory() {
188        use tempfile::tempdir;
189        use tokio::fs;
190
191        let dir = tempdir().unwrap();
192        fs::write(dir.path().join("CLAUDE.md"), "# Test Project")
193            .await
194            .unwrap();
195
196        let rules_dir = dir.path().join(".claude").join("rules");
197        fs::create_dir_all(&rules_dir).await.unwrap();
198        fs::write(
199            rules_dir.join("test.md"),
200            r#"---
201paths: **/*.rs
202---
203
204# Test Rule"#,
205        )
206        .await
207        .unwrap();
208
209        let orchestrator = ContextBuilder::new()
210            .load_from_directory(dir.path())
211            .await
212            .build()
213            .unwrap();
214
215        assert!(
216            orchestrator
217                .static_context()
218                .claude_md
219                .contains("Test Project")
220        );
221        // Rules are now in the rule_registry, not a separate engine
222        // We can verify through the rules summary in static context
223        assert!(!orchestrator.static_context().rules_summary.is_empty());
224    }
225
226    #[tokio::test]
227    async fn test_rule_registry_integration() {
228        use crate::common::ContentSource;
229
230        let rule = RuleIndex::new("test-rule")
231            .description("Test description")
232            .paths(vec!["**/*.rs".into()])
233            .source(ContentSource::in_memory("Rule content"));
234
235        let orchestrator = ContextBuilder::new().rule(rule).build().unwrap();
236
237        // Check that rule is in the registry
238        let registry = orchestrator.get_rule_registry().await;
239        assert!(registry.contains("test-rule"));
240    }
241}