Skip to main content

rho_core/
config.rs

1use std::path::{Path, PathBuf};
2
3use crate::hooks::HookConfig;
4use crate::types::ThinkingLevel;
5
6#[derive(Debug, Clone)]
7pub struct AgentDef {
8    pub name: String,
9    pub tools: Option<String>,
10    pub model: Option<String>,
11    pub description: Option<String>,
12}
13
14#[derive(Debug, Clone)]
15pub struct ProjectConfig {
16    pub model: Option<String>,
17    pub thinking: Option<ThinkingLevel>,
18    pub system_prompt: Option<String>,
19    pub system_prompt_append: Option<String>,
20    pub allowed_tools: Option<Vec<String>>,
21    pub validation_commands: Vec<String>,
22    pub compact_threshold: Option<f64>,
23    pub memories: bool,
24    pub source: Option<PathBuf>,
25    pub post_tools_hooks: Vec<HookConfig>,
26    pub agents: Vec<AgentDef>,
27    pub max_agent_depth: Option<usize>,
28}
29
30impl Default for ProjectConfig {
31    fn default() -> Self {
32        Self {
33            model: None,
34            thinking: None,
35            system_prompt: None,
36            system_prompt_append: None,
37            allowed_tools: None,
38            validation_commands: Vec::new(),
39            compact_threshold: None,
40            memories: true,
41            source: None,
42            post_tools_hooks: Vec::new(),
43            agents: Vec::new(),
44            max_agent_depth: None,
45        }
46    }
47}
48
49/// Load project configuration from RHO.md or CLAUDE.md.
50///
51/// Discovery order: RHO.md in cwd -> CLAUDE.md in cwd -> ~/.rho/RHO.md
52pub fn load_project_config(cwd: &Path) -> ProjectConfig {
53    // Try RHO.md first
54    let rho_md = cwd.join("RHO.md");
55    if rho_md.is_file() {
56        if let Ok(content) = std::fs::read_to_string(&rho_md) {
57            return parse_rho_md(&content, rho_md);
58        }
59    }
60
61    // Fallback to CLAUDE.md
62    let claude_md = cwd.join("CLAUDE.md");
63    if claude_md.is_file() {
64        if let Ok(content) = std::fs::read_to_string(&claude_md) {
65            return ProjectConfig {
66                system_prompt_append: Some(content),
67                source: Some(claude_md),
68                ..Default::default()
69            };
70        }
71    }
72
73    // Global fallback
74    if let Some(home) = dirs::home_dir() {
75        let global_rho = home.join(".rho").join("RHO.md");
76        if global_rho.is_file() {
77            if let Ok(content) = std::fs::read_to_string(&global_rho) {
78                return parse_rho_md(&content, global_rho);
79            }
80        }
81    }
82
83    ProjectConfig::default()
84}
85
86/// Parse RHO.md with YAML frontmatter + markdown body.
87fn parse_rho_md(content: &str, path: PathBuf) -> ProjectConfig {
88    let trimmed = content.trim_start();
89    if !trimmed.starts_with("---") {
90        // No frontmatter — treat entire file as system_prompt_append
91        return ProjectConfig {
92            system_prompt_append: Some(content.to_string()),
93            source: Some(path),
94            ..Default::default()
95        };
96    }
97
98    let after_first = &trimmed[3..];
99    let Some(end) = after_first.find("\n---") else {
100        return ProjectConfig {
101            system_prompt_append: Some(content.to_string()),
102            source: Some(path),
103            ..Default::default()
104        };
105    };
106
107    let frontmatter = &after_first[..end];
108    let body_start = 3 + end + 4; // "---" + frontmatter + "\n---"
109    let body = trimmed[body_start..].trim_start_matches('\n');
110
111    let mut config = ProjectConfig {
112        source: Some(path),
113        ..Default::default()
114    };
115
116    if !body.is_empty() {
117        config.system_prompt_append = Some(body.to_string());
118    }
119
120    // Parse simple YAML frontmatter (key: value lines)
121    let mut in_list: Option<String> = None;
122    let mut list_items: Vec<String> = Vec::new();
123    // State for parsing post_tools_hooks (nested YAML objects in a list)
124    let mut in_hooks = false;
125    let mut current_hook: Option<HookConfig> = None;
126    let mut hooks: Vec<HookConfig> = Vec::new();
127    // State for parsing agents (nested YAML objects in a list)
128    let mut in_agents = false;
129    let mut current_agent: Option<AgentDef> = None;
130    let mut agents: Vec<AgentDef> = Vec::new();
131
132    for line in frontmatter.lines() {
133        let trimmed_line = line.trim();
134
135        // Handle agents parsing
136        if in_agents {
137            if trimmed_line.starts_with("- ") {
138                // New agent entry — finalize previous one
139                if let Some(agent) = current_agent.take() {
140                    agents.push(agent);
141                }
142                let rest = trimmed_line.strip_prefix("- ").unwrap().trim();
143                let mut agent = AgentDef {
144                    name: String::new(),
145                    tools: None,
146                    model: None,
147                    description: None,
148                };
149                if let Some((k, v)) = rest.split_once(':') {
150                    let k = k.trim();
151                    let v = v.trim();
152                    apply_agent_field(&mut agent, k, v);
153                }
154                current_agent = Some(agent);
155                continue;
156            } else if trimmed_line.is_empty() {
157                continue;
158            } else if line.starts_with("    ") || line.starts_with("\t\t") || line.starts_with("  ") {
159                // Indented line within an agent block
160                if let Some(ref mut agent) = current_agent {
161                    if let Some((k, v)) = trimmed_line.split_once(':') {
162                        let k = k.trim();
163                        let v = v.trim();
164                        apply_agent_field(agent, k, v);
165                    }
166                }
167                continue;
168            } else {
169                // End of agents section
170                if let Some(agent) = current_agent.take() {
171                    agents.push(agent);
172                }
173                config.agents = std::mem::take(&mut agents);
174                in_agents = false;
175                // Fall through to normal parsing for this line
176            }
177        }
178
179        // Handle post_tools_hooks parsing
180        if in_hooks {
181            if trimmed_line.starts_with("- ") {
182                // New hook entry — finalize previous one
183                if let Some(hook) = current_hook.take() {
184                    hooks.push(hook);
185                }
186                // Parse "- name: value" or just "- name:"
187                let rest = trimmed_line.strip_prefix("- ").unwrap().trim();
188                let mut hook = HookConfig {
189                    name: String::new(),
190                    command: String::new(),
191                    timeout: 30,
192                    inject_on_failure: true,
193                    trigger_tools: None,
194                };
195                if let Some((k, v)) = rest.split_once(':') {
196                    let k = k.trim();
197                    let v = v.trim();
198                    apply_hook_field(&mut hook, k, v);
199                }
200                current_hook = Some(hook);
201                continue;
202            } else if trimmed_line.is_empty() {
203                continue;
204            } else if line.starts_with("    ") || line.starts_with("\t\t") || line.starts_with("  ") {
205                // Indented line within a hook block
206                if let Some(ref mut hook) = current_hook {
207                    if let Some((k, v)) = trimmed_line.split_once(':') {
208                        let k = k.trim();
209                        let v = v.trim();
210                        apply_hook_field(hook, k, v);
211                    }
212                }
213                continue;
214            } else {
215                // End of hooks section — non-indented, non-list line
216                if let Some(hook) = current_hook.take() {
217                    hooks.push(hook);
218                }
219                config.post_tools_hooks = std::mem::take(&mut hooks);
220                in_hooks = false;
221                // Fall through to normal parsing for this line
222            }
223        }
224
225        // Check if this is a list item for current key
226        if let Some(ref key) = in_list {
227            if let Some(item) = trimmed_line.strip_prefix("- ") {
228                list_items.push(item.trim().to_string());
229                continue;
230            } else {
231                // End of list — apply accumulated items
232                apply_list_field(&mut config, key, &list_items);
233                list_items.clear();
234                in_list = None;
235            }
236        }
237
238        if let Some((key, value)) = trimmed_line.split_once(':') {
239            let key = key.trim();
240            let value = value.trim();
241
242            if value.is_empty() {
243                if key == "post_tools_hooks" {
244                    in_hooks = true;
245                    continue;
246                }
247                if key == "agents" {
248                    in_agents = true;
249                    continue;
250                }
251                // Could be start of a list
252                in_list = Some(key.to_string());
253                continue;
254            }
255
256            match key {
257                "model" => config.model = Some(value.to_string()),
258                "thinking" => config.thinking = Some(parse_thinking(value)),
259                "compact_threshold" => {
260                    if let Ok(v) = value.parse::<f64>() {
261                        config.compact_threshold = Some(v);
262                    }
263                }
264                "memories" => {
265                    config.memories = value != "false";
266                }
267                "max_agent_depth" => {
268                    if let Ok(v) = value.parse::<usize>() {
269                        config.max_agent_depth = Some(v);
270                    }
271                }
272                "allowed_tools" => {
273                    // Inline comma-separated list
274                    config.allowed_tools = Some(
275                        value.split(',').map(|s| s.trim().to_string()).collect(),
276                    );
277                }
278                _ => {}
279            }
280        }
281    }
282
283    // Flush any trailing list
284    if let Some(ref key) = in_list {
285        apply_list_field(&mut config, key, &list_items);
286    }
287
288    // Flush any trailing hooks
289    if in_hooks {
290        if let Some(hook) = current_hook.take() {
291            hooks.push(hook);
292        }
293        config.post_tools_hooks = hooks;
294    }
295
296    // Flush any trailing agents
297    if in_agents {
298        if let Some(agent) = current_agent.take() {
299            agents.push(agent);
300        }
301        config.agents = agents;
302    }
303
304    config
305}
306
307fn apply_agent_field(agent: &mut AgentDef, key: &str, value: &str) {
308    match key {
309        "name" => agent.name = value.to_string(),
310        "tools" => agent.tools = Some(value.to_string()),
311        "model" => agent.model = Some(value.to_string()),
312        "description" => agent.description = Some(value.to_string()),
313        _ => {}
314    }
315}
316
317fn apply_hook_field(hook: &mut HookConfig, key: &str, value: &str) {
318    match key {
319        "name" => hook.name = value.to_string(),
320        "command" => hook.command = value.to_string(),
321        "timeout" => {
322            if let Ok(t) = value.parse::<u64>() {
323                hook.timeout = t;
324            }
325        }
326        "inject_on_failure" => {
327            hook.inject_on_failure = value != "false";
328        }
329        "trigger_tools" => {
330            // Parse "[write, edit, bash]" or "write, edit, bash"
331            let cleaned = value.trim_start_matches('[').trim_end_matches(']');
332            let tools: Vec<String> = cleaned.split(',').map(|s| s.trim().to_string()).filter(|s| !s.is_empty()).collect();
333            if !tools.is_empty() {
334                hook.trigger_tools = Some(tools);
335            }
336        }
337        _ => {}
338    }
339}
340
341fn apply_list_field(config: &mut ProjectConfig, key: &str, items: &[String]) {
342    match key {
343        "validation_commands" => config.validation_commands = items.to_vec(),
344        "allowed_tools" => config.allowed_tools = Some(items.to_vec()),
345        _ => {}
346    }
347}
348
349fn parse_thinking(s: &str) -> ThinkingLevel {
350    match s.to_lowercase().as_str() {
351        "minimal" => ThinkingLevel::Minimal,
352        "low" => ThinkingLevel::Low,
353        "medium" => ThinkingLevel::Medium,
354        "high" => ThinkingLevel::High,
355        _ => ThinkingLevel::Off,
356    }
357}
358
359#[cfg(test)]
360mod tests {
361    use super::*;
362
363    #[test]
364    fn parse_rho_md_with_all_fields() {
365        let content = "\
366---
367model: claude-opus-4-6
368thinking: medium
369compact_threshold: 0.8
370validation_commands:
371  - cargo test --quiet
372  - cargo clippy --quiet -- -D warnings
373---
374
375# Project Instructions
376
377This is a Rust workspace. Always run `cargo test` after changes.
378";
379        let config = parse_rho_md(content, PathBuf::from("RHO.md"));
380        assert_eq!(config.model.as_deref(), Some("claude-opus-4-6"));
381        assert_eq!(config.thinking, Some(ThinkingLevel::Medium));
382        assert_eq!(config.compact_threshold, Some(0.8));
383        assert_eq!(config.validation_commands.len(), 2);
384        assert_eq!(config.validation_commands[0], "cargo test --quiet");
385        assert_eq!(
386            config.validation_commands[1],
387            "cargo clippy --quiet -- -D warnings"
388        );
389        assert!(config.system_prompt_append.unwrap().contains("Rust workspace"));
390    }
391
392    #[test]
393    fn parse_rho_md_no_frontmatter() {
394        let content = "# Just a markdown file\n\nNo frontmatter here.";
395        let config = parse_rho_md(content, PathBuf::from("RHO.md"));
396        assert!(config.model.is_none());
397        assert!(config.system_prompt_append.unwrap().contains("Just a markdown file"));
398    }
399
400    #[test]
401    fn parse_claude_md_fallback() {
402        let tmp = tempfile::tempdir().unwrap();
403        let claude_md = tmp.path().join("CLAUDE.md");
404        std::fs::write(&claude_md, "# Instructions\nAlways test.").unwrap();
405
406        let config = load_project_config(tmp.path());
407        assert_eq!(config.source.as_deref(), Some(claude_md.as_path()));
408        assert!(config.system_prompt_append.unwrap().contains("Always test"));
409    }
410
411    #[test]
412    fn parse_rho_md_inline_tools() {
413        let content = "---\nallowed_tools: read, grep, find\n---\n";
414        let config = parse_rho_md(content, PathBuf::from("RHO.md"));
415        let tools = config.allowed_tools.unwrap();
416        assert_eq!(tools, vec!["read", "grep", "find"]);
417    }
418
419    #[test]
420    fn parse_rho_md_agents_block() {
421        let content = "\
422---
423max_agent_depth: 3
424agents:
425  - name: researcher
426    tools: read,grep,find
427    model: claude-haiku
428    description: Research agent for code analysis
429  - name: test-runner
430    tools: bash,read
431    description: Runs tests and reports results
432---
433
434# Instructions
435";
436        let config = parse_rho_md(content, PathBuf::from("RHO.md"));
437        assert_eq!(config.max_agent_depth, Some(3));
438        assert_eq!(config.agents.len(), 2);
439
440        assert_eq!(config.agents[0].name, "researcher");
441        assert_eq!(config.agents[0].tools.as_deref(), Some("read,grep,find"));
442        assert_eq!(config.agents[0].model.as_deref(), Some("claude-haiku"));
443        assert_eq!(
444            config.agents[0].description.as_deref(),
445            Some("Research agent for code analysis")
446        );
447
448        assert_eq!(config.agents[1].name, "test-runner");
449        assert_eq!(config.agents[1].tools.as_deref(), Some("bash,read"));
450        assert!(config.agents[1].model.is_none());
451        assert_eq!(
452            config.agents[1].description.as_deref(),
453            Some("Runs tests and reports results")
454        );
455    }
456
457    #[test]
458    fn parse_rho_md_no_agents_backwards_compat() {
459        let content = "\
460---
461model: claude-sonnet
462---
463
464# Instructions
465";
466        let config = parse_rho_md(content, PathBuf::from("RHO.md"));
467        assert!(config.agents.is_empty());
468        assert!(config.max_agent_depth.is_none());
469    }
470
471    #[test]
472    fn load_empty_dir() {
473        let tmp = tempfile::tempdir().unwrap();
474        let config = load_project_config(tmp.path());
475        assert!(config.model.is_none());
476        assert!(config.system_prompt_append.is_none());
477        assert!(config.source.is_none());
478    }
479}