mana_core/
agent_presets.rs1use std::process::Command;
7
8#[derive(Debug, Clone, PartialEq)]
10pub struct AgentPreset {
11 pub name: &'static str,
13 pub run_template: &'static str,
15 pub plan_template: &'static str,
17 pub version_cmd: &'static str,
19}
20
21#[derive(Debug, Clone, PartialEq)]
23pub struct DetectedAgent {
24 pub name: String,
26 pub path: String,
28 pub version: Option<String>,
30}
31
32const PRESETS: &[AgentPreset] = &[
37 AgentPreset {
38 name: "pi",
39 run_template: "pi @.mana/{id}-*.md \"implement and mana close {id}\"",
40 plan_template: "pi @.mana/{id}-*.md \"plan into children with mana create --parent {id}\"",
41 version_cmd: "pi --version",
42 },
43 AgentPreset {
44 name: "claude",
45 run_template: "claude -p \"implement unit {id} and run mana close {id}\"",
46 plan_template: "claude -p \"unit {id} is too large, split with mana create --parent {id}\"",
47 version_cmd: "claude --version",
48 },
49 AgentPreset {
50 name: "aider",
51 run_template: "aider --message \"implement unit {id}, verify with mana close {id}\"",
52 plan_template: "aider --message \"plan unit {id} into children with mana create\"",
53 version_cmd: "aider --version",
54 },
55];
56
57#[must_use]
59pub fn all_presets() -> &'static [AgentPreset] {
60 PRESETS
61}
62
63#[must_use]
65pub fn get_preset(name: &str) -> Option<&'static AgentPreset> {
66 let lower = name.to_ascii_lowercase();
67 PRESETS.iter().find(|p| p.name == lower)
68}
69
70pub fn detect_agents() -> Vec<DetectedAgent> {
75 PRESETS
76 .iter()
77 .filter_map(|preset| {
78 let path = which_binary(preset.name)?;
79 let version = probe_version(preset.version_cmd);
80 Some(DetectedAgent {
81 name: preset.name.to_string(),
82 path,
83 version,
84 })
85 })
86 .collect()
87}
88
89impl AgentPreset {
90 #[must_use]
92 pub fn run_cmd(&self, id: &str) -> String {
93 self.run_template.replace("{id}", id)
94 }
95
96 #[must_use]
98 pub fn plan_cmd(&self, id: &str) -> String {
99 self.plan_template.replace("{id}", id)
100 }
101}
102
103fn which_binary(name: &str) -> Option<String> {
109 let output = Command::new("which").arg(name).output().ok()?;
110 if output.status.success() {
111 let s = String::from_utf8_lossy(&output.stdout).trim().to_string();
112 if s.is_empty() {
113 None
114 } else {
115 Some(s)
116 }
117 } else {
118 None
119 }
120}
121
122fn probe_version(version_cmd: &str) -> Option<String> {
124 let parts: Vec<&str> = version_cmd.split_whitespace().collect();
125 let (bin, args) = parts.split_first()?;
126 let output = Command::new(bin).args(args).output().ok()?;
127 if output.status.success() {
128 let stdout = String::from_utf8_lossy(&output.stdout);
129 let first_line = stdout.lines().next()?.trim().to_string();
130 if first_line.is_empty() {
131 None
132 } else {
133 Some(first_line)
134 }
135 } else {
136 None
137 }
138}
139
140#[cfg(test)]
145mod tests {
146 use super::*;
147
148 #[test]
149 fn all_presets_returns_at_least_three() {
150 let presets = all_presets();
151 assert!(
152 presets.len() >= 3,
153 "expected at least 3 presets, got {}",
154 presets.len()
155 );
156 }
157
158 #[test]
159 fn get_preset_pi_returns_correct_templates() {
160 let preset = get_preset("pi").expect("pi preset should exist");
161 assert_eq!(preset.name, "pi");
162 assert!(preset.run_template.contains("{id}"));
163 assert!(preset.plan_template.contains("{id}"));
164 assert_eq!(preset.version_cmd, "pi --version");
165 }
166
167 #[test]
168 fn get_preset_claude() {
169 let preset = get_preset("claude").expect("claude preset should exist");
170 assert_eq!(preset.name, "claude");
171 assert!(preset.run_template.contains("{id}"));
172 assert!(preset.plan_template.contains("{id}"));
173 }
174
175 #[test]
176 fn get_preset_aider() {
177 let preset = get_preset("aider").expect("aider preset should exist");
178 assert_eq!(preset.name, "aider");
179 assert!(preset.run_template.contains("{id}"));
180 assert!(preset.plan_template.contains("{id}"));
181 }
182
183 #[test]
184 fn get_preset_nonexistent_returns_none() {
185 assert!(get_preset("nonexistent").is_none());
186 }
187
188 #[test]
189 fn get_preset_is_case_insensitive() {
190 assert!(get_preset("Pi").is_some());
191 assert!(get_preset("CLAUDE").is_some());
192 }
193
194 #[test]
195 fn all_templates_contain_id_placeholder() {
196 for preset in all_presets() {
197 assert!(
198 preset.run_template.contains("{id}"),
199 "{} run_template missing {{id}}",
200 preset.name
201 );
202 assert!(
203 preset.plan_template.contains("{id}"),
204 "{} plan_template missing {{id}}",
205 preset.name
206 );
207 }
208 }
209
210 #[test]
211 fn run_cmd_expands_id() {
212 let preset = get_preset("pi").unwrap();
213 let cmd = preset.run_cmd("42");
214 assert!(cmd.contains("42"));
215 assert!(!cmd.contains("{id}"));
216 }
217
218 #[test]
219 fn plan_cmd_expands_id() {
220 let preset = get_preset("claude").unwrap();
221 let cmd = preset.plan_cmd("7.1");
222 assert!(cmd.contains("7.1"));
223 assert!(!cmd.contains("{id}"));
224 }
225
226 #[test]
227 fn detect_agents_returns_vec() {
228 let agents = detect_agents();
231 for agent in &agents {
232 assert!(!agent.name.is_empty());
233 assert!(!agent.path.is_empty());
234 }
235 }
236}