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