1use std::path::Path;
2
3#[derive(Debug, Clone)]
4pub struct Command {
5 pub name: String,
6 pub description: String,
7 pub template: String,
8}
9
10pub fn builtin_commands() -> Vec<Command> {
12 vec![
13 Command {
14 name: "research".into(),
15 description: "Research the codebase for a topic".into(),
16 template: "Research the codebase to understand and document: {args}\n\n\
17 Use read, grep, and find tools to explore. Return findings with file:line references. \
18 Do not modify any files."
19 .into(),
20 },
21 Command {
22 name: "plan".into(),
23 description: "Create an implementation plan".into(),
24 template: "Create an implementation plan for: {args}\n\n\
25 Analyze the existing codebase to understand the current architecture. \
26 Identify all files that need to change. Write the plan to IMPLEMENTATION_PLAN.md \
27 with clear tasks marked [TODO]."
28 .into(),
29 },
30 Command {
31 name: "implement".into(),
32 description: "Implement the next task from the plan".into(),
33 template: "Read IMPLEMENTATION_PLAN.md. Pick the next [TODO] or [CURRENT] task. \
34 Implement it fully. Run validation commands if configured. \
35 Update the task status to [DONE] in the plan. \
36 Commit the changes with a descriptive message."
37 .into(),
38 },
39 Command {
40 name: "validate".into(),
41 description: "Run validation commands".into(),
42 template: "Run the project's validation commands to check for errors. \
43 If validation commands are configured in RHO.md, run those. \
44 Otherwise run: cargo test && cargo clippy -- -D warnings\n\n\
45 Report any failures and suggest fixes."
46 .into(),
47 },
48 Command {
49 name: "commit".into(),
50 description: "Stage and commit changes".into(),
51 template: "Review the current git status and diff. Stage the relevant changed files. \
52 Generate a concise, descriptive commit message that explains what changed and why. \
53 Create the commit."
54 .into(),
55 },
56 ]
57}
58
59pub fn discover_commands(cwd: &Path) -> Vec<Command> {
61 let mut commands = Vec::new();
62
63 let dirs = [
64 cwd.join(".rho/commands"),
65 dirs::home_dir()
66 .map(|h| h.join(".rho/commands"))
67 .unwrap_or_default(),
68 ];
69
70 for dir in &dirs {
71 if let Ok(entries) = std::fs::read_dir(dir) {
72 for entry in entries.flatten() {
73 let path = entry.path();
74 if path.extension().and_then(|e| e.to_str()) != Some("md") {
75 continue;
76 }
77 let Some(stem) = path.file_stem().and_then(|s| s.to_str()) else {
78 continue;
79 };
80 let Ok(content) = std::fs::read_to_string(&path) else {
81 continue;
82 };
83
84 if let Some(cmd) = parse_command_file(stem, &content) {
85 commands.push(cmd);
86 }
87 }
88 }
89 }
90
91 commands
92}
93
94fn parse_command_file(name: &str, content: &str) -> Option<Command> {
95 let trimmed = content.trim_start();
96
97 if trimmed.starts_with("---") {
99 let after_first = &trimmed[3..];
100 if let Some(end) = after_first.find("\n---") {
101 let frontmatter = &after_first[..end];
102 let body_start = 3 + end + 4;
103 let body = trimmed[body_start..].trim().to_string();
104
105 let mut description = name.to_string();
106 for line in frontmatter.lines() {
107 if let Some(val) = line.strip_prefix("description:") {
108 description = val.trim().to_string();
109 }
110 }
111
112 return Some(Command {
113 name: name.to_string(),
114 description,
115 template: body,
116 });
117 }
118 }
119
120 Some(Command {
122 name: name.to_string(),
123 description: format!("Custom command: {name}"),
124 template: content.to_string(),
125 })
126}
127
128pub fn resolve_command(name: &str, args: &str, cwd: &Path) -> Option<String> {
131 let user_cmds = discover_commands(cwd);
133 if let Some(cmd) = user_cmds.iter().find(|c| c.name == name) {
134 return Some(cmd.template.replace("{args}", args));
135 }
136
137 let builtins = builtin_commands();
139 builtins
140 .iter()
141 .find(|c| c.name == name)
142 .map(|cmd| cmd.template.replace("{args}", args))
143}
144
145pub fn all_commands(cwd: &Path) -> Vec<Command> {
147 let mut commands = builtin_commands();
148 let user_cmds = discover_commands(cwd);
149
150 for user_cmd in user_cmds {
151 if let Some(existing) = commands.iter_mut().find(|c| c.name == user_cmd.name) {
152 *existing = user_cmd;
153 } else {
154 commands.push(user_cmd);
155 }
156 }
157
158 commands
159}
160
161#[cfg(test)]
162mod tests {
163 use super::*;
164
165 #[test]
166 fn builtin_commands_exist() {
167 let cmds = builtin_commands();
168 assert!(cmds.iter().any(|c| c.name == "research"));
169 assert!(cmds.iter().any(|c| c.name == "plan"));
170 assert!(cmds.iter().any(|c| c.name == "implement"));
171 assert!(cmds.iter().any(|c| c.name == "validate"));
172 assert!(cmds.iter().any(|c| c.name == "commit"));
173 }
174
175 #[test]
176 fn resolve_builtin_command() {
177 let tmp = tempfile::tempdir().unwrap();
178 let result = resolve_command("research", "auth module", tmp.path());
179 assert!(result.is_some());
180 let expanded = result.unwrap();
181 assert!(expanded.contains("auth module"));
182 assert!(!expanded.contains("{args}"));
183 }
184
185 #[test]
186 fn resolve_unknown_command() {
187 let tmp = tempfile::tempdir().unwrap();
188 assert!(resolve_command("nonexistent", "", tmp.path()).is_none());
189 }
190
191 #[test]
192 fn parse_command_file_with_frontmatter() {
193 let content = "---\ndescription: My custom command\n---\nDo {args} now.";
194 let cmd = parse_command_file("custom", content).unwrap();
195 assert_eq!(cmd.name, "custom");
196 assert_eq!(cmd.description, "My custom command");
197 assert_eq!(cmd.template, "Do {args} now.");
198 }
199
200 #[test]
201 fn parse_command_file_no_frontmatter() {
202 let content = "Just do the thing with {args}.";
203 let cmd = parse_command_file("simple", content).unwrap();
204 assert_eq!(cmd.name, "simple");
205 assert_eq!(cmd.template, content);
206 }
207
208 #[test]
209 fn user_commands_override_builtins() {
210 let tmp = tempfile::tempdir().unwrap();
211 let cmd_dir = tmp.path().join(".rho/commands");
212 std::fs::create_dir_all(&cmd_dir).unwrap();
213 std::fs::write(
214 cmd_dir.join("research.md"),
215 "---\ndescription: Custom research\n---\nMy custom research for {args}.",
216 )
217 .unwrap();
218
219 let result = resolve_command("research", "auth", tmp.path()).unwrap();
220 assert!(result.contains("My custom research"));
221 }
222}