1use std::path::Path;
6
7use anyhow::{Context, Result, bail};
8use regex::Regex;
9
10use super::config::{RoleType, TeamConfig};
11use super::config_diff;
12use super::hierarchy;
13use super::team_config_path;
14use crate::cli::ScaleCommand;
15
16pub fn run(project_root: &Path, command: ScaleCommand) -> Result<()> {
18 let config_path = team_config_path(project_root);
19 if !config_path.exists() {
20 bail!("No team config found. Run `batty init` first.");
21 }
22
23 match command {
24 ScaleCommand::Engineers { count } => scale_engineers(project_root, count),
25 ScaleCommand::AddManager { name } => add_manager(project_root, &name),
26 ScaleCommand::RemoveManager { name } => remove_manager(project_root, &name),
27 ScaleCommand::Status => show_topology(project_root),
28 }
29}
30
31fn scale_engineers(project_root: &Path, count: u32) -> Result<()> {
33 if count == 0 {
34 bail!("Engineer count must be at least 1.");
35 }
36
37 let config_path = team_config_path(project_root);
38 let config = TeamConfig::load(&config_path)?;
39
40 let eng_role = config
42 .roles
43 .iter()
44 .find(|r| r.role_type == RoleType::Engineer)
45 .context("No engineer role found in team config")?;
46
47 let old_count = eng_role.instances;
48 if old_count == count {
49 println!("Engineers already at {count} instances. No change needed.");
50 return Ok(());
51 }
52
53 let content = std::fs::read_to_string(&config_path).context("failed to read team.yaml")?;
55 let updated = update_role_instances(&content, &eng_role.name, count)?;
56 std::fs::write(&config_path, &updated).context("failed to write updated team.yaml")?;
57
58 let new_config = TeamConfig::load(&config_path)?;
60 let diff = config_diff::diff_configs(&config, &new_config)?;
61
62 if count > old_count {
63 println!(
64 "Scaled engineers from {} to {} (+{} agents).",
65 old_count,
66 count,
67 diff.added.len()
68 );
69 } else {
70 println!(
71 "Scaled engineers from {} to {} (-{} agents).",
72 old_count,
73 count,
74 diff.removed.len()
75 );
76 }
77 println!("Daemon will detect the change and reconcile topology.");
78 Ok(())
79}
80
81fn add_manager(project_root: &Path, name: &str) -> Result<()> {
83 let config_path = team_config_path(project_root);
84 let config = TeamConfig::load(&config_path)?;
85
86 if config.roles.iter().any(|r| r.name == name) {
88 bail!("Role '{name}' already exists in team config.");
89 }
90
91 let existing_mgr = config
93 .roles
94 .iter()
95 .find(|r| r.role_type == RoleType::Manager);
96 let agent = existing_mgr
97 .and_then(|m| m.agent.clone())
98 .unwrap_or_else(|| "claude".to_string());
99 let prompt = existing_mgr
100 .and_then(|m| m.prompt.clone())
101 .unwrap_or_else(|| "batty_manager.md".to_string());
102
103 let content = std::fs::read_to_string(&config_path).context("failed to read team.yaml")?;
105
106 let role_block = format!(
107 "\n - name: {name}\n role_type: manager\n agent: {agent}\n instances: 1\n prompt: {prompt}\n talks_to: [architect, engineer]\n"
108 );
109
110 let updated = content.trim_end().to_string() + &role_block;
111 std::fs::write(&config_path, &updated).context("failed to write updated team.yaml")?;
112
113 println!("Added manager role '{name}'. Daemon will spawn the new agent.");
114 Ok(())
115}
116
117fn remove_manager(project_root: &Path, name: &str) -> Result<()> {
119 let config_path = team_config_path(project_root);
120 let config = TeamConfig::load(&config_path)?;
121
122 let role = config
123 .roles
124 .iter()
125 .find(|r| r.name == name)
126 .context(format!("Role '{name}' not found in team config"))?;
127
128 if role.role_type != RoleType::Manager {
129 bail!(
130 "Role '{name}' is not a manager (it's a {:?}).",
131 role.role_type
132 );
133 }
134
135 let manager_count = config
137 .roles
138 .iter()
139 .filter(|r| r.role_type == RoleType::Manager)
140 .count();
141 if manager_count <= 1 {
142 bail!("Cannot remove the last manager. At least one manager is required.");
143 }
144
145 let content = std::fs::read_to_string(&config_path).context("failed to read team.yaml")?;
147 let updated = remove_role_block(&content, name)?;
148 std::fs::write(&config_path, &updated).context("failed to write updated team.yaml")?;
149
150 println!("Removed manager role '{name}'. Daemon will gracefully shut down the agent.");
151 Ok(())
152}
153
154fn show_topology(project_root: &Path) -> Result<()> {
156 let config_path = team_config_path(project_root);
157 let config = TeamConfig::load(&config_path)?;
158 let members = hierarchy::resolve_hierarchy(&config)?;
159
160 println!("Team: {}", config.name);
161 println!();
162
163 for role in &config.roles {
164 let type_str = match role.role_type {
165 RoleType::User => "user",
166 RoleType::Architect => "architect",
167 RoleType::Manager => "manager",
168 RoleType::Engineer => "engineer",
169 };
170 let member_names: Vec<&str> = members
171 .iter()
172 .filter(|m| m.role_name == role.name)
173 .map(|m| m.name.as_str())
174 .collect();
175 println!(
176 " {:12} {:10} instances={:<3} members=[{}]",
177 role.name,
178 type_str,
179 role.instances,
180 member_names.join(", ")
181 );
182 }
183
184 let agent_count = members
185 .iter()
186 .filter(|m| m.role_type != RoleType::User)
187 .count();
188 println!();
189 println!("Total agent members: {agent_count}");
190 Ok(())
191}
192
193fn update_role_instances(yaml: &str, role_name: &str, new_count: u32) -> Result<String> {
201 let lines: Vec<&str> = yaml.lines().collect();
204 let mut result = Vec::new();
205 let mut in_target_role = false;
206 let mut updated = false;
207 let instances_re = Regex::new(r"^(\s+instances:\s*)\d+").unwrap();
208
209 for line in &lines {
210 let trimmed = line.trim();
212 if trimmed.starts_with("- name:") {
213 let name_val = trimmed.strip_prefix("- name:").unwrap_or("").trim();
214 in_target_role = name_val == role_name;
215 }
216
217 if in_target_role && !updated {
218 if let Some(caps) = instances_re.captures(line) {
219 let prefix = caps.get(1).unwrap().as_str();
220 result.push(format!("{prefix}{new_count}"));
221 updated = true;
222 continue;
223 }
224 }
225
226 result.push(line.to_string());
227 }
228
229 if !updated {
230 bail!("Could not find instances field for role '{role_name}' in team.yaml");
231 }
232
233 Ok(result.join("\n") + "\n")
234}
235
236fn remove_role_block(yaml: &str, role_name: &str) -> Result<String> {
238 let lines: Vec<&str> = yaml.lines().collect();
239 let mut result = Vec::new();
240 let mut skipping = false;
241 let mut found = false;
242
243 for line in &lines {
244 let trimmed = line.trim();
245 if trimmed.starts_with("- name:") {
246 let name_val = trimmed.strip_prefix("- name:").unwrap_or("").trim();
247 if name_val == role_name {
248 skipping = true;
249 found = true;
250 continue;
251 }
252 skipping = false;
253 }
254
255 if !skipping {
256 result.push(*line);
257 }
258 }
259
260 if !found {
261 bail!("Could not find role '{role_name}' in team.yaml");
262 }
263
264 Ok(result.join("\n") + "\n")
265}
266
267#[cfg(test)]
272mod tests {
273 use super::*;
274
275 const SAMPLE_YAML: &str = r#"name: test-team
276
277roles:
278 - name: architect
279 role_type: architect
280 agent: claude
281 instances: 1
282
283 - name: manager
284 role_type: manager
285 agent: claude
286 instances: 1
287 prompt: batty_manager.md
288 talks_to: [architect, engineer]
289
290 - name: secondary-mgr
291 role_type: manager
292 agent: claude
293 instances: 1
294 prompt: batty_manager.md
295
296 - name: engineer
297 role_type: engineer
298 agent: claude
299 instances: 3
300 prompt: batty_engineer.md
301 talks_to: [manager]
302 use_worktrees: true
303"#;
304
305 #[test]
306 fn update_instances_for_engineer() {
307 let result = update_role_instances(SAMPLE_YAML, "engineer", 6).unwrap();
308 assert!(result.contains("instances: 6"));
309 let config: serde_yaml::Value = serde_yaml::from_str(&result).unwrap();
311 let roles = config["roles"].as_sequence().unwrap();
312 let eng = roles
313 .iter()
314 .find(|r| r["name"].as_str() == Some("engineer"))
315 .unwrap();
316 assert_eq!(eng["instances"].as_u64(), Some(6));
317 let mgr = roles
319 .iter()
320 .find(|r| r["name"].as_str() == Some("manager"))
321 .unwrap();
322 assert_eq!(mgr["instances"].as_u64(), Some(1));
323 }
324
325 #[test]
326 fn update_instances_for_manager() {
327 let result = update_role_instances(SAMPLE_YAML, "manager", 3).unwrap();
328 let config: serde_yaml::Value = serde_yaml::from_str(&result).unwrap();
329 let roles = config["roles"].as_sequence().unwrap();
330 let mgr = roles
331 .iter()
332 .find(|r| r["name"].as_str() == Some("manager"))
333 .unwrap();
334 assert_eq!(mgr["instances"].as_u64(), Some(3));
335 }
336
337 #[test]
338 fn update_instances_missing_role_errors() {
339 let result = update_role_instances(SAMPLE_YAML, "nonexistent", 5);
340 assert!(result.is_err());
341 }
342
343 #[test]
344 fn remove_role_block_removes_manager() {
345 let result = remove_role_block(SAMPLE_YAML, "secondary-mgr").unwrap();
346 assert!(!result.contains("secondary-mgr"));
347 assert!(result.contains("architect"));
349 assert!(result.contains("manager"));
350 assert!(result.contains("engineer"));
351 let config: serde_yaml::Value = serde_yaml::from_str(&result).unwrap();
353 let roles = config["roles"].as_sequence().unwrap();
354 assert_eq!(roles.len(), 3);
355 }
356
357 #[test]
358 fn remove_role_block_missing_errors() {
359 let result = remove_role_block(SAMPLE_YAML, "nonexistent");
360 assert!(result.is_err());
361 }
362
363 #[test]
364 fn remove_last_role_block_works() {
365 let result = remove_role_block(SAMPLE_YAML, "engineer").unwrap();
367 assert!(!result.contains("- name: engineer"));
368 let config: serde_yaml::Value = serde_yaml::from_str(&result).unwrap();
370 let roles = config["roles"].as_sequence().unwrap();
371 assert_eq!(roles.len(), 3);
372 }
373}