Skip to main content

bn/commands/
adopt.rs

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
12/// Find the next available child number for a parent.
13/// Scans .beans/ for existing children ({parent_id}.{N}-*.md or {parent_id}.{N}.yaml),
14/// finds highest N, returns N+1.
15fn 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        // Look for files matching "{parent_id}.{N}-*.md" (new format)
31        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                    // Extract the number part before the hyphen
35                    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        // Also support legacy format for backward compatibility: {parent_id}.{N}.yaml
46        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
62/// Adopt existing beans as children of a parent bean.
63///
64/// This command:
65/// 1. Validates that the parent bean exists
66/// 2. For each child ID:
67///    - Loads the bean
68///    - Assigns a new ID: `{parent_id}.{N}` (where N is sequential)
69///    - Sets the bean's `parent` field to `parent_id`
70///    - Renames the file to match the new ID
71/// 3. Updates all dependency references across ALL beans
72/// 4. Rebuilds the index
73///
74/// # Arguments
75/// * `beans_dir` - Path to the `.beans/` directory
76/// * `parent_id` - The ID of the parent bean
77/// * `child_ids` - List of bean IDs to adopt as children
78///
79/// # Returns
80/// A map of old_id -> new_id for the adopted beans
81pub fn cmd_adopt(
82    beans_dir: &Path,
83    parent_id: &str,
84    child_ids: &[String],
85) -> Result<HashMap<String, String>> {
86    // Validate parent exists
87    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    // Track ID mappings: old_id -> new_id
93    let mut id_map: HashMap<String, String> = HashMap::new();
94
95    // Find the starting child number
96    let mut next_num = next_child_number(beans_dir, parent_id)?;
97
98    // Process each child
99    for old_id in child_ids {
100        // Load the child bean
101        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        // Compute new ID
107        let new_id = format!("{}.{}", parent_id, next_num);
108        next_num += 1;
109
110        // Update bean fields
111        bean.id = new_id.clone();
112        bean.parent = Some(parent_id.to_string());
113        bean.updated_at = Utc::now();
114
115        // Compute new file path
116        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        // Write the updated bean to the new path
121        bean.to_file(&new_path)
122            .with_context(|| format!("Failed to write bean to {}", new_path.display()))?;
123
124        // Remove the old file (if it's different from the new path)
125        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        // Track the mapping
132        id_map.insert(old_id.clone(), new_id.clone());
133        println!("Adopted {} -> {} (under {})", old_id, new_id, parent_id);
134    }
135
136    // Update dependencies across all beans
137    if !id_map.is_empty() {
138        update_all_dependencies(beans_dir, &id_map)?;
139    }
140
141    // Rebuild the index
142    let index = Index::build(beans_dir)?;
143    index.save(beans_dir)?;
144
145    Ok(id_map)
146}
147
148/// Update dependency references in all beans based on the ID mapping.
149///
150/// Scans all bean files in the directory and replaces any dependency IDs
151/// that appear in the id_map with their new values.
152fn 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        // Only process bean files (.md with hyphen or .yaml)
166        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        // Load the bean
177        let mut bean = match Bean::from_file(&path) {
178            Ok(b) => b,
179            Err(_) => continue, // Skip files that can't be parsed
180        };
181
182        // Check if any dependencies need updating
183        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        // Also check and update the parent field if it was remapped
196        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        // Save if modified
204        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        // Create parent bean
255        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        // Create child bean
261        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        // Adopt
267        let result = cmd_adopt(&beans_dir, "1", &["2".to_string()]).unwrap();
268
269        // Verify mapping
270        assert_eq!(result.get("2"), Some(&"1.1".to_string()));
271
272        // Verify old file is gone
273        assert!(!beans_dir.join("2-child-task.md").exists());
274
275        // Verify new file exists
276        assert!(beans_dir.join("1.1-child-task.md").exists());
277
278        // Verify bean content
279        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        // Create parent
290        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        // Create children
296        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        // Adopt all three
306        let result = cmd_adopt(
307            &beans_dir,
308            "1",
309            &["2".to_string(), "3".to_string(), "4".to_string()],
310        )
311        .unwrap();
312
313        // Verify mappings (should be sequential)
314        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        // Verify files
319        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        // Create parent with existing child
329        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        // Create new bean to adopt
343        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        // Adopt - should get 1.2, not 1.1
349        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        // Create parent
360        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        // Create bean to adopt
366        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        // Create bean that depends on the one being adopted
372        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        // Adopt bean 2 under parent 1
379        cmd_adopt(&beans_dir, "1", &["2".to_string()]).unwrap();
380
381        // Verify dependent bean's dependencies were updated
382        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        // Create only the child, no parent
391        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        // Try to adopt under non-existent parent
397        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        // Create only the parent
410        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        // Try to adopt non-existent child
416        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        // Create parent and child
429        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        // Adopt
440        cmd_adopt(&beans_dir, "1", &["2".to_string()]).unwrap();
441
442        // Load index and verify
443        let index = Index::load(&beans_dir).unwrap();
444
445        // Should have 2 beans: parent (1) and adopted child (1.1)
446        assert_eq!(index.beans.len(), 2);
447
448        // Find the adopted bean in the index
449        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        // Old ID should not be in index
454        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        // Create existing children
474        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); // Next after 5
480    }
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        // Create children under different parents
489        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        // Should only count children of parent "1"
494        let num = next_child_number(&beans_dir, "1").unwrap();
495        assert_eq!(num, 2);
496    }
497}