1use std::collections::HashMap;
2use std::fs;
3use std::path::Path;
4
5use anyhow::{Context, Result};
6use chrono::Utc;
7
8use crate::bean::Bean;
9use crate::discovery::find_bean_file;
10use crate::index::Index;
11
12fn next_child_number(beans_dir: &Path, parent_id: &str) -> Result<u32> {
16 let mut max_child: u32 = 0;
17
18 let dir_entries = fs::read_dir(beans_dir)
19 .with_context(|| format!("Failed to read directory: {}", beans_dir.display()))?;
20
21 for entry in dir_entries {
22 let entry = entry?;
23 let path = entry.path();
24
25 let filename = path
26 .file_name()
27 .and_then(|n| n.to_str())
28 .unwrap_or_default();
29
30 if let Some(name_without_ext) = filename.strip_suffix(".md") {
32 if let Some(name_without_parent) = name_without_ext.strip_prefix(parent_id) {
33 if let Some(after_dot) = name_without_parent.strip_prefix('.') {
34 let num_part = after_dot.split('-').next().unwrap_or_default();
36 if let Ok(child_num) = num_part.parse::<u32>() {
37 if child_num > max_child {
38 max_child = child_num;
39 }
40 }
41 }
42 }
43 }
44
45 if let Some(name_without_ext) = filename.strip_suffix(".yaml") {
47 if let Some(name_without_parent) = name_without_ext.strip_prefix(parent_id) {
48 if let Some(after_dot) = name_without_parent.strip_prefix('.') {
49 if let Ok(child_num) = after_dot.parse::<u32>() {
50 if child_num > max_child {
51 max_child = child_num;
52 }
53 }
54 }
55 }
56 }
57 }
58
59 Ok(max_child + 1)
60}
61
62pub fn cmd_adopt(
82 beans_dir: &Path,
83 parent_id: &str,
84 child_ids: &[String],
85) -> Result<HashMap<String, String>> {
86 let parent_path = find_bean_file(beans_dir, parent_id)
88 .with_context(|| format!("Parent bean '{}' not found", parent_id))?;
89 let _parent_bean = Bean::from_file(&parent_path)
90 .with_context(|| format!("Failed to load parent bean '{}'", parent_id))?;
91
92 let mut id_map: HashMap<String, String> = HashMap::new();
94
95 let mut next_num = next_child_number(beans_dir, parent_id)?;
97
98 for old_id in child_ids {
100 let old_path = find_bean_file(beans_dir, old_id)
102 .with_context(|| format!("Child bean '{}' not found", old_id))?;
103 let mut bean = Bean::from_file(&old_path)
104 .with_context(|| format!("Failed to load child bean '{}'", old_id))?;
105
106 let new_id = format!("{}.{}", parent_id, next_num);
108 next_num += 1;
109
110 bean.id = new_id.clone();
112 bean.parent = Some(parent_id.to_string());
113 bean.updated_at = Utc::now();
114
115 let slug = bean.slug.clone().unwrap_or_else(|| "unnamed".to_string());
117 let new_filename = format!("{}-{}.md", new_id, slug);
118 let new_path = beans_dir.join(&new_filename);
119
120 bean.to_file(&new_path)
122 .with_context(|| format!("Failed to write bean to {}", new_path.display()))?;
123
124 if old_path != new_path {
126 fs::remove_file(&old_path).with_context(|| {
127 format!("Failed to remove old bean file {}", old_path.display())
128 })?;
129 }
130
131 id_map.insert(old_id.clone(), new_id.clone());
133 println!("Adopted {} -> {} (under {})", old_id, new_id, parent_id);
134 }
135
136 if !id_map.is_empty() {
138 update_all_dependencies(beans_dir, &id_map)?;
139 }
140
141 let index = Index::build(beans_dir)?;
143 index.save(beans_dir)?;
144
145 Ok(id_map)
146}
147
148fn update_all_dependencies(beans_dir: &Path, id_map: &HashMap<String, String>) -> Result<()> {
153 let dir_entries = fs::read_dir(beans_dir)
154 .with_context(|| format!("Failed to read directory: {}", beans_dir.display()))?;
155
156 for entry in dir_entries {
157 let entry = entry?;
158 let path = entry.path();
159
160 let filename = path
161 .file_name()
162 .and_then(|n| n.to_str())
163 .unwrap_or_default();
164
165 let is_bean_file = (filename.ends_with(".md") && filename.contains('-'))
167 || (filename.ends_with(".yaml")
168 && filename != "config.yaml"
169 && filename != "index.yaml"
170 && filename != "bean.yaml");
171
172 if !is_bean_file {
173 continue;
174 }
175
176 let mut bean = match Bean::from_file(&path) {
178 Ok(b) => b,
179 Err(_) => continue, };
181
182 let mut modified = false;
184 let mut new_deps = Vec::new();
185
186 for dep in &bean.dependencies {
187 if let Some(new_id) = id_map.get(dep) {
188 new_deps.push(new_id.clone());
189 modified = true;
190 } else {
191 new_deps.push(dep.clone());
192 }
193 }
194
195 if let Some(ref parent) = bean.parent {
197 if let Some(new_parent) = id_map.get(parent) {
198 bean.parent = Some(new_parent.clone());
199 modified = true;
200 }
201 }
202
203 if modified {
205 bean.dependencies = new_deps;
206 bean.updated_at = Utc::now();
207 bean.to_file(&path)
208 .with_context(|| format!("Failed to update bean {}", path.display()))?;
209 }
210 }
211
212 Ok(())
213}
214
215#[cfg(test)]
216mod tests {
217 use super::*;
218 use crate::config::Config;
219 use tempfile::TempDir;
220
221 fn setup_beans_dir_with_config() -> (TempDir, std::path::PathBuf) {
222 let dir = TempDir::new().unwrap();
223 let beans_dir = dir.path().join(".beans");
224 fs::create_dir(&beans_dir).unwrap();
225
226 let config = Config {
227 project: "test".to_string(),
228 next_id: 10,
229 auto_close_parent: true,
230 max_tokens: 30000,
231 run: None,
232 plan: None,
233 max_loops: 10,
234 max_concurrent: 4,
235 poll_interval: 30,
236 extends: vec![],
237 rules_file: None,
238 file_locking: false,
239 on_close: None,
240 on_fail: None,
241 post_plan: None,
242 verify_timeout: None,
243 review: None,
244 };
245 config.save(&beans_dir).unwrap();
246
247 (dir, beans_dir)
248 }
249
250 #[test]
251 fn adopt_single_bean() {
252 let (_dir, beans_dir) = setup_beans_dir_with_config();
253
254 let mut parent = Bean::new("1", "Parent task");
256 parent.slug = Some("parent-task".to_string());
257 parent.acceptance = Some("Children complete".to_string());
258 parent.to_file(beans_dir.join("1-parent-task.md")).unwrap();
259
260 let mut child = Bean::new("2", "Child task");
262 child.slug = Some("child-task".to_string());
263 child.verify = Some("cargo test".to_string());
264 child.to_file(beans_dir.join("2-child-task.md")).unwrap();
265
266 let result = cmd_adopt(&beans_dir, "1", &["2".to_string()]).unwrap();
268
269 assert_eq!(result.get("2"), Some(&"1.1".to_string()));
271
272 assert!(!beans_dir.join("2-child-task.md").exists());
274
275 assert!(beans_dir.join("1.1-child-task.md").exists());
277
278 let adopted = Bean::from_file(beans_dir.join("1.1-child-task.md")).unwrap();
280 assert_eq!(adopted.id, "1.1");
281 assert_eq!(adopted.parent, Some("1".to_string()));
282 assert_eq!(adopted.title, "Child task");
283 }
284
285 #[test]
286 fn adopt_multiple_beans() {
287 let (_dir, beans_dir) = setup_beans_dir_with_config();
288
289 let mut parent = Bean::new("1", "Parent");
291 parent.slug = Some("parent".to_string());
292 parent.acceptance = Some("All done".to_string());
293 parent.to_file(beans_dir.join("1-parent.md")).unwrap();
294
295 for i in 2..=4 {
297 let mut child = Bean::new(i.to_string(), format!("Child {}", i));
298 child.slug = Some(format!("child-{}", i));
299 child.verify = Some("true".to_string());
300 child
301 .to_file(beans_dir.join(format!("{}-child-{}.md", i, i)))
302 .unwrap();
303 }
304
305 let result = cmd_adopt(
307 &beans_dir,
308 "1",
309 &["2".to_string(), "3".to_string(), "4".to_string()],
310 )
311 .unwrap();
312
313 assert_eq!(result.get("2"), Some(&"1.1".to_string()));
315 assert_eq!(result.get("3"), Some(&"1.2".to_string()));
316 assert_eq!(result.get("4"), Some(&"1.3".to_string()));
317
318 assert!(beans_dir.join("1.1-child-2.md").exists());
320 assert!(beans_dir.join("1.2-child-3.md").exists());
321 assert!(beans_dir.join("1.3-child-4.md").exists());
322 }
323
324 #[test]
325 fn adopt_with_existing_children() {
326 let (_dir, beans_dir) = setup_beans_dir_with_config();
327
328 let mut parent = Bean::new("1", "Parent");
330 parent.slug = Some("parent".to_string());
331 parent.acceptance = Some("Done".to_string());
332 parent.to_file(beans_dir.join("1-parent.md")).unwrap();
333
334 let mut existing_child = Bean::new("1.1", "Existing child");
335 existing_child.slug = Some("existing-child".to_string());
336 existing_child.parent = Some("1".to_string());
337 existing_child.verify = Some("true".to_string());
338 existing_child
339 .to_file(beans_dir.join("1.1-existing-child.md"))
340 .unwrap();
341
342 let mut new_bean = Bean::new("5", "New bean");
344 new_bean.slug = Some("new-bean".to_string());
345 new_bean.verify = Some("true".to_string());
346 new_bean.to_file(beans_dir.join("5-new-bean.md")).unwrap();
347
348 let result = cmd_adopt(&beans_dir, "1", &["5".to_string()]).unwrap();
350
351 assert_eq!(result.get("5"), Some(&"1.2".to_string()));
352 assert!(beans_dir.join("1.2-new-bean.md").exists());
353 }
354
355 #[test]
356 fn adopt_updates_dependencies() {
357 let (_dir, beans_dir) = setup_beans_dir_with_config();
358
359 let mut parent = Bean::new("1", "Parent");
361 parent.slug = Some("parent".to_string());
362 parent.acceptance = Some("Done".to_string());
363 parent.to_file(beans_dir.join("1-parent.md")).unwrap();
364
365 let mut to_adopt = Bean::new("2", "To adopt");
367 to_adopt.slug = Some("to-adopt".to_string());
368 to_adopt.verify = Some("true".to_string());
369 to_adopt.to_file(beans_dir.join("2-to-adopt.md")).unwrap();
370
371 let mut dependent = Bean::new("3", "Dependent");
373 dependent.slug = Some("dependent".to_string());
374 dependent.verify = Some("true".to_string());
375 dependent.dependencies = vec!["2".to_string()];
376 dependent.to_file(beans_dir.join("3-dependent.md")).unwrap();
377
378 cmd_adopt(&beans_dir, "1", &["2".to_string()]).unwrap();
380
381 let dependent_updated = Bean::from_file(beans_dir.join("3-dependent.md")).unwrap();
383 assert_eq!(dependent_updated.dependencies, vec!["1.1".to_string()]);
384 }
385
386 #[test]
387 fn adopt_fails_for_missing_parent() {
388 let (_dir, beans_dir) = setup_beans_dir_with_config();
389
390 let mut child = Bean::new("2", "Child");
392 child.slug = Some("child".to_string());
393 child.verify = Some("true".to_string());
394 child.to_file(beans_dir.join("2-child.md")).unwrap();
395
396 let result = cmd_adopt(&beans_dir, "99", &["2".to_string()]);
398 assert!(result.is_err());
399 assert!(result
400 .unwrap_err()
401 .to_string()
402 .contains("Parent bean '99' not found"));
403 }
404
405 #[test]
406 fn adopt_fails_for_missing_child() {
407 let (_dir, beans_dir) = setup_beans_dir_with_config();
408
409 let mut parent = Bean::new("1", "Parent");
411 parent.slug = Some("parent".to_string());
412 parent.acceptance = Some("Done".to_string());
413 parent.to_file(beans_dir.join("1-parent.md")).unwrap();
414
415 let result = cmd_adopt(&beans_dir, "1", &["99".to_string()]);
417 assert!(result.is_err());
418 assert!(result
419 .unwrap_err()
420 .to_string()
421 .contains("Child bean '99' not found"));
422 }
423
424 #[test]
425 fn adopt_rebuilds_index() {
426 let (_dir, beans_dir) = setup_beans_dir_with_config();
427
428 let mut parent = Bean::new("1", "Parent");
430 parent.slug = Some("parent".to_string());
431 parent.acceptance = Some("Done".to_string());
432 parent.to_file(beans_dir.join("1-parent.md")).unwrap();
433
434 let mut child = Bean::new("2", "Child");
435 child.slug = Some("child".to_string());
436 child.verify = Some("true".to_string());
437 child.to_file(beans_dir.join("2-child.md")).unwrap();
438
439 cmd_adopt(&beans_dir, "1", &["2".to_string()]).unwrap();
441
442 let index = Index::load(&beans_dir).unwrap();
444
445 assert_eq!(index.beans.len(), 2);
447
448 let adopted = index.beans.iter().find(|b| b.id == "1.1");
450 assert!(adopted.is_some());
451 assert_eq!(adopted.unwrap().parent, Some("1".to_string()));
452
453 assert!(!index.beans.iter().any(|b| b.id == "2"));
455 }
456
457 #[test]
458 fn next_child_number_empty() {
459 let dir = TempDir::new().unwrap();
460 let beans_dir = dir.path().join(".beans");
461 fs::create_dir(&beans_dir).unwrap();
462
463 let num = next_child_number(&beans_dir, "1").unwrap();
464 assert_eq!(num, 1);
465 }
466
467 #[test]
468 fn next_child_number_with_existing() {
469 let dir = TempDir::new().unwrap();
470 let beans_dir = dir.path().join(".beans");
471 fs::create_dir(&beans_dir).unwrap();
472
473 fs::write(beans_dir.join("1.1-child-one.md"), "test").unwrap();
475 fs::write(beans_dir.join("1.2-child-two.md"), "test").unwrap();
476 fs::write(beans_dir.join("1.5-child-five.md"), "test").unwrap();
477
478 let num = next_child_number(&beans_dir, "1").unwrap();
479 assert_eq!(num, 6); }
481
482 #[test]
483 fn next_child_number_ignores_other_parents() {
484 let dir = TempDir::new().unwrap();
485 let beans_dir = dir.path().join(".beans");
486 fs::create_dir(&beans_dir).unwrap();
487
488 fs::write(beans_dir.join("1.1-child.md"), "test").unwrap();
490 fs::write(beans_dir.join("2.1-child.md"), "test").unwrap();
491 fs::write(beans_dir.join("2.2-child.md"), "test").unwrap();
492
493 let num = next_child_number(&beans_dir, "1").unwrap();
495 assert_eq!(num, 2);
496 }
497}