Skip to main content

rho_core/
commands.rs

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
10/// Built-in commands compiled into the binary.
11pub 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
59/// Discover user command overrides from .rho/commands/.
60pub 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    // Try to parse frontmatter for description
98    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    // No frontmatter — entire file is template
121    Some(Command {
122        name: name.to_string(),
123        description: format!("Custom command: {name}"),
124        template: content.to_string(),
125    })
126}
127
128/// Resolve a command by name, expanding {args} in the template.
129/// User commands override built-ins by name.
130pub fn resolve_command(name: &str, args: &str, cwd: &Path) -> Option<String> {
131    // Check user commands first (override built-ins)
132    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    // Fall back to built-ins
138    let builtins = builtin_commands();
139    builtins
140        .iter()
141        .find(|c| c.name == name)
142        .map(|cmd| cmd.template.replace("{args}", args))
143}
144
145/// List all available commands (builtins + user, user overrides by name).
146pub 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}