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
49pub fn load_project_config(cwd: &Path) -> ProjectConfig {
53 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 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 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
86fn parse_rho_md(content: &str, path: PathBuf) -> ProjectConfig {
88 let trimmed = content.trim_start();
89 if !trimmed.starts_with("---") {
90 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; 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 let mut in_list: Option<String> = None;
122 let mut list_items: Vec<String> = Vec::new();
123 let mut in_hooks = false;
125 let mut current_hook: Option<HookConfig> = None;
126 let mut hooks: Vec<HookConfig> = Vec::new();
127 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 if in_agents {
137 if trimmed_line.starts_with("- ") {
138 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 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 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 }
177 }
178
179 if in_hooks {
181 if trimmed_line.starts_with("- ") {
182 if let Some(hook) = current_hook.take() {
184 hooks.push(hook);
185 }
186 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 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 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 }
223 }
224
225 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 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 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 config.allowed_tools = Some(
275 value.split(',').map(|s| s.trim().to_string()).collect(),
276 );
277 }
278 _ => {}
279 }
280 }
281 }
282
283 if let Some(ref key) = in_list {
285 apply_list_field(&mut config, key, &list_items);
286 }
287
288 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 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 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}