Skip to main content

bn/mcp/
tools.rs

1//! MCP tool definitions and handlers.
2//!
3//! Each tool maps to a beans operation. Handlers work directly with
4//! Bean/Index types to avoid stdout pollution from CLI commands.
5
6use std::path::Path;
7
8use anyhow::{Context, Result};
9use chrono::Utc;
10use serde_json::{json, Value};
11
12use crate::bean::{Bean, Status};
13use crate::config::Config;
14use crate::discovery::find_bean_file;
15use crate::index::{Index, IndexEntry};
16use crate::mcp::protocol::ToolDefinition;
17use crate::util::{natural_cmp, title_to_slug};
18
19/// Return all MCP tool definitions.
20pub fn tool_definitions() -> Vec<ToolDefinition> {
21    vec![
22        ToolDefinition {
23            name: "list_beans".to_string(),
24            description: "List beans with optional status and priority filters".to_string(),
25            input_schema: json!({
26                "type": "object",
27                "properties": {
28                    "status": {
29                        "type": "string",
30                        "enum": ["open", "in_progress", "closed"],
31                        "description": "Filter by status"
32                    },
33                    "priority": {
34                        "type": "integer",
35                        "minimum": 0,
36                        "maximum": 4,
37                        "description": "Filter by priority (0-4, where P0 is highest)"
38                    },
39                    "parent": {
40                        "type": "string",
41                        "description": "Filter by parent bean ID"
42                    }
43                }
44            }),
45        },
46        ToolDefinition {
47            name: "show_bean".to_string(),
48            description: "Get full bean details including description, acceptance criteria, verify command, and history".to_string(),
49            input_schema: json!({
50                "type": "object",
51                "properties": {
52                    "id": {
53                        "type": "string",
54                        "description": "Bean ID"
55                    }
56                },
57                "required": ["id"]
58            }),
59        },
60        ToolDefinition {
61            name: "ready_beans".to_string(),
62            description: "Get beans ready to work on (open, has verify command, all dependencies resolved)".to_string(),
63            input_schema: json!({
64                "type": "object",
65                "properties": {}
66            }),
67        },
68        ToolDefinition {
69            name: "create_bean".to_string(),
70            description: "Create a new bean (task/spec for agents)".to_string(),
71            input_schema: json!({
72                "type": "object",
73                "properties": {
74                    "title": {
75                        "type": "string",
76                        "description": "Bean title"
77                    },
78                    "description": {
79                        "type": "string",
80                        "description": "Full description / agent context (markdown)"
81                    },
82                    "verify": {
83                        "type": "string",
84                        "description": "Shell command that must exit 0 to close the bean"
85                    },
86                    "parent": {
87                        "type": "string",
88                        "description": "Parent bean ID (creates a child bean)"
89                    },
90                    "priority": {
91                        "type": "integer",
92                        "minimum": 0,
93                        "maximum": 4,
94                        "description": "Priority 0-4 (P0 highest, default P2)"
95                    },
96                    "acceptance": {
97                        "type": "string",
98                        "description": "Acceptance criteria"
99                    },
100                    "deps": {
101                        "type": "string",
102                        "description": "Comma-separated dependency bean IDs"
103                    }
104                },
105                "required": ["title"]
106            }),
107        },
108        ToolDefinition {
109            name: "claim_bean".to_string(),
110            description: "Claim a bean for work (sets status to in_progress)".to_string(),
111            input_schema: json!({
112                "type": "object",
113                "properties": {
114                    "id": {
115                        "type": "string",
116                        "description": "Bean ID to claim"
117                    },
118                    "by": {
119                        "type": "string",
120                        "description": "Who is claiming (agent name or user)"
121                    }
122                },
123                "required": ["id"]
124            }),
125        },
126        ToolDefinition {
127            name: "close_bean".to_string(),
128            description: "Close a bean (runs verify gate first if configured). Returns error if verify fails.".to_string(),
129            input_schema: json!({
130                "type": "object",
131                "properties": {
132                    "id": {
133                        "type": "string",
134                        "description": "Bean ID to close"
135                    },
136                    "force": {
137                        "type": "boolean",
138                        "description": "Skip verify command (force close)",
139                        "default": false
140                    },
141                    "reason": {
142                        "type": "string",
143                        "description": "Close reason"
144                    }
145                },
146                "required": ["id"]
147            }),
148        },
149        ToolDefinition {
150            name: "verify_bean".to_string(),
151            description: "Run a bean's verify command without closing it. Returns pass/fail and output.".to_string(),
152            input_schema: json!({
153                "type": "object",
154                "properties": {
155                    "id": {
156                        "type": "string",
157                        "description": "Bean ID to verify"
158                    }
159                },
160                "required": ["id"]
161            }),
162        },
163        ToolDefinition {
164            name: "context_bean".to_string(),
165            description: "Get assembled context for a bean (reads files referenced in description)".to_string(),
166            input_schema: json!({
167                "type": "object",
168                "properties": {
169                    "id": {
170                        "type": "string",
171                        "description": "Bean ID"
172                    }
173                },
174                "required": ["id"]
175            }),
176        },
177        ToolDefinition {
178            name: "status".to_string(),
179            description: "Project status overview: claimed, ready, goals, and blocked beans".to_string(),
180            input_schema: json!({
181                "type": "object",
182                "properties": {}
183            }),
184        },
185        ToolDefinition {
186            name: "tree".to_string(),
187            description: "Hierarchical bean tree showing parent-child relationships and status".to_string(),
188            input_schema: json!({
189                "type": "object",
190                "properties": {
191                    "id": {
192                        "type": "string",
193                        "description": "Root bean ID (shows full tree if omitted)"
194                    }
195                }
196            }),
197        },
198    ]
199}
200
201// ---------------------------------------------------------------------------
202// Tool Handlers
203// ---------------------------------------------------------------------------
204
205/// Dispatch a tool call to the appropriate handler.
206pub fn handle_tool_call(name: &str, args: &Value, beans_dir: &Path) -> Value {
207    let result = match name {
208        "list_beans" => handle_list_beans(args, beans_dir),
209        "show_bean" => handle_show_bean(args, beans_dir),
210        "ready_beans" => handle_ready_beans(beans_dir),
211        "create_bean" => handle_create_bean(args, beans_dir),
212        "claim_bean" => handle_claim_bean(args, beans_dir),
213        "close_bean" => handle_close_bean(args, beans_dir),
214        "verify_bean" => handle_verify_bean(args, beans_dir),
215        "context_bean" => handle_context_bean(args, beans_dir),
216        "status" => handle_status(beans_dir),
217        "tree" => handle_tree(args, beans_dir),
218        _ => Err(anyhow::anyhow!("Unknown tool: {}", name)),
219    };
220
221    match result {
222        Ok(text) => json!({
223            "content": [{ "type": "text", "text": text }]
224        }),
225        Err(e) => json!({
226            "content": [{ "type": "text", "text": format!("Error: {}", e) }],
227            "isError": true
228        }),
229    }
230}
231
232// ---------------------------------------------------------------------------
233// Individual Handlers
234// ---------------------------------------------------------------------------
235
236fn handle_list_beans(args: &Value, beans_dir: &Path) -> Result<String> {
237    let index = Index::load_or_rebuild(beans_dir)?;
238
239    let status_filter = args
240        .get("status")
241        .and_then(|v| v.as_str())
242        .and_then(crate::util::parse_status);
243
244    let priority_filter = args
245        .get("priority")
246        .and_then(|v| v.as_u64())
247        .map(|v| v as u8);
248
249    let parent_filter = args.get("parent").and_then(|v| v.as_str());
250
251    let filtered: Vec<&IndexEntry> = index
252        .beans
253        .iter()
254        .filter(|entry| {
255            if let Some(status) = status_filter {
256                if entry.status != status {
257                    return false;
258                }
259            } else if entry.status == Status::Closed {
260                // Exclude closed by default
261                return false;
262            }
263            if let Some(priority) = priority_filter {
264                if entry.priority != priority {
265                    return false;
266                }
267            }
268            if let Some(parent) = parent_filter {
269                if entry.parent.as_deref() != Some(parent) {
270                    return false;
271                }
272            }
273            true
274        })
275        .collect();
276
277    let entries: Vec<Value> = filtered
278        .iter()
279        .map(|e| {
280            json!({
281                "id": e.id,
282                "title": e.title,
283                "status": format!("{}", e.status),
284                "priority": format!("P{}", e.priority),
285                "parent": e.parent,
286                "has_verify": e.has_verify,
287                "claimed_by": e.claimed_by,
288            })
289        })
290        .collect();
291
292    serde_json::to_string_pretty(&json!({ "beans": entries, "count": entries.len() }))
293        .context("Failed to serialize bean list")
294}
295
296fn handle_show_bean(args: &Value, beans_dir: &Path) -> Result<String> {
297    let id = args
298        .get("id")
299        .and_then(|v| v.as_str())
300        .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
301
302    crate::util::validate_bean_id(id)?;
303    let bean_path = find_bean_file(beans_dir, id)?;
304    let bean = Bean::from_file(&bean_path)?;
305
306    serde_json::to_string_pretty(&bean).context("Failed to serialize bean")
307}
308
309fn handle_ready_beans(beans_dir: &Path) -> Result<String> {
310    let index = Index::load_or_rebuild(beans_dir)?;
311
312    let mut ready: Vec<&IndexEntry> = index
313        .beans
314        .iter()
315        .filter(|entry| {
316            entry.has_verify
317                && entry.status == Status::Open
318                && resolve_blocked(entry, &index).is_empty()
319        })
320        .collect();
321
322    ready.sort_by(|a, b| match a.priority.cmp(&b.priority) {
323        std::cmp::Ordering::Equal => natural_cmp(&a.id, &b.id),
324        other => other,
325    });
326
327    let entries: Vec<Value> = ready
328        .iter()
329        .map(|e| {
330            json!({
331                "id": e.id,
332                "title": e.title,
333                "priority": format!("P{}", e.priority),
334            })
335        })
336        .collect();
337
338    serde_json::to_string_pretty(&json!({ "ready": entries, "count": entries.len() }))
339        .context("Failed to serialize ready beans")
340}
341
342fn handle_create_bean(args: &Value, beans_dir: &Path) -> Result<String> {
343    let title = args
344        .get("title")
345        .and_then(|v| v.as_str())
346        .ok_or_else(|| anyhow::anyhow!("Missing required parameter: title"))?;
347
348    let description = args.get("description").and_then(|v| v.as_str());
349    let verify = args.get("verify").and_then(|v| v.as_str());
350    let parent = args.get("parent").and_then(|v| v.as_str());
351    let priority = args
352        .get("priority")
353        .and_then(|v| v.as_u64())
354        .map(|v| v as u8);
355    let acceptance = args.get("acceptance").and_then(|v| v.as_str());
356    let deps = args.get("deps").and_then(|v| v.as_str());
357
358    if let Some(p) = priority {
359        crate::bean::validate_priority(p)?;
360    }
361
362    // Determine bean ID
363    let mut config = Config::load(beans_dir)?;
364    let bean_id = if let Some(parent_id) = parent {
365        crate::util::validate_bean_id(parent_id)?;
366        crate::commands::create::assign_child_id(beans_dir, parent_id)?
367    } else {
368        let id = config.increment_id();
369        config.save(beans_dir)?;
370        id.to_string()
371    };
372
373    let slug = title_to_slug(title);
374    let mut bean = Bean::try_new(&bean_id, title)?;
375    bean.slug = Some(slug.clone());
376
377    if let Some(desc) = description {
378        bean.description = Some(desc.to_string());
379    }
380    if let Some(v) = verify {
381        bean.verify = Some(v.to_string());
382    }
383    if let Some(p) = parent {
384        bean.parent = Some(p.to_string());
385    }
386    if let Some(p) = priority {
387        bean.priority = p;
388    }
389    if let Some(a) = acceptance {
390        bean.acceptance = Some(a.to_string());
391    }
392    if let Some(d) = deps {
393        bean.dependencies = d.split(',').map(|s| s.trim().to_string()).collect();
394    }
395
396    // Calculate tokens
397    let project_dir = beans_dir
398        .parent()
399        .ok_or_else(|| anyhow::anyhow!("Cannot determine project root"))?;
400    let tokens = crate::tokens::calculate_tokens(&bean, project_dir);
401    bean.tokens = Some(tokens);
402    bean.tokens_updated = Some(Utc::now());
403
404    // Write bean file
405    let bean_path = beans_dir.join(format!("{}-{}.md", bean_id, slug));
406    bean.to_file(&bean_path)?;
407
408    // Rebuild index
409    let index = Index::build(beans_dir)?;
410    index.save(beans_dir)?;
411
412    Ok(format!(
413        "Created bean {}: {} ({}k tokens)",
414        bean_id,
415        title,
416        tokens / 1000
417    ))
418}
419
420fn handle_claim_bean(args: &Value, beans_dir: &Path) -> Result<String> {
421    let id = args
422        .get("id")
423        .and_then(|v| v.as_str())
424        .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
425    let by = args.get("by").and_then(|v| v.as_str());
426
427    crate::util::validate_bean_id(id)?;
428    let bean_path = find_bean_file(beans_dir, id)?;
429    let mut bean = Bean::from_file(&bean_path)?;
430
431    if bean.status != Status::Open {
432        anyhow::bail!(
433            "Bean {} is {} — only open beans can be claimed",
434            id,
435            bean.status
436        );
437    }
438
439    let now = Utc::now();
440    bean.status = Status::InProgress;
441    bean.claimed_by = by.map(|s| s.to_string());
442    bean.claimed_at = Some(now);
443    bean.updated_at = now;
444
445    bean.to_file(&bean_path)?;
446
447    // Rebuild index
448    let index = Index::build(beans_dir)?;
449    index.save(beans_dir)?;
450
451    let claimer = by.unwrap_or("anonymous");
452    Ok(format!(
453        "Claimed bean {}: {} (by {})",
454        id, bean.title, claimer
455    ))
456}
457
458fn handle_close_bean(args: &Value, beans_dir: &Path) -> Result<String> {
459    let id = args
460        .get("id")
461        .and_then(|v| v.as_str())
462        .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
463    let force = args.get("force").and_then(|v| v.as_bool()).unwrap_or(false);
464    let reason = args.get("reason").and_then(|v| v.as_str());
465
466    crate::util::validate_bean_id(id)?;
467    let bean_path = find_bean_file(beans_dir, id)?;
468    let mut bean = Bean::from_file(&bean_path)?;
469
470    // Run verify if configured and not forced
471    if let Some(ref verify_cmd) = bean.verify {
472        if !force {
473            let project_root = beans_dir
474                .parent()
475                .ok_or_else(|| anyhow::anyhow!("Cannot determine project root"))?;
476
477            let output = std::process::Command::new("sh")
478                .args(["-c", verify_cmd])
479                .current_dir(project_root)
480                .output()
481                .with_context(|| format!("Failed to execute verify: {}", verify_cmd))?;
482
483            if !output.status.success() {
484                let stderr = String::from_utf8_lossy(&output.stderr);
485                let stdout = String::from_utf8_lossy(&output.stdout);
486                let combined = format!("{}{}", stdout, stderr);
487                let snippet = if combined.len() > 2000 {
488                    format!("...{}", &combined[combined.len() - 2000..])
489                } else {
490                    combined.to_string()
491                };
492
493                bean.attempts += 1;
494                bean.updated_at = Utc::now();
495                bean.to_file(&bean_path)?;
496
497                // Rebuild index to reflect attempt count
498                let index = Index::build(beans_dir)?;
499                index.save(beans_dir)?;
500
501                anyhow::bail!(
502                    "Verify failed for bean {} (attempt {})\nCommand: {}\nOutput:\n{}",
503                    id,
504                    bean.attempts,
505                    verify_cmd,
506                    snippet.trim()
507                );
508            }
509        }
510    }
511
512    // Close the bean
513    let now = Utc::now();
514    bean.status = Status::Closed;
515    bean.closed_at = Some(now);
516    bean.close_reason = reason.map(|s| s.to_string());
517    bean.updated_at = now;
518
519    bean.to_file(&bean_path)?;
520
521    // Archive the bean
522    let slug = bean
523        .slug
524        .clone()
525        .unwrap_or_else(|| title_to_slug(&bean.title));
526    let ext = bean_path
527        .extension()
528        .and_then(|e| e.to_str())
529        .unwrap_or("md");
530    let today = chrono::Local::now().naive_local().date();
531    let archive_path = crate::discovery::archive_path_for_bean(beans_dir, id, &slug, ext, today);
532
533    if let Some(parent) = archive_path.parent() {
534        std::fs::create_dir_all(parent)?;
535    }
536    std::fs::rename(&bean_path, &archive_path)?;
537
538    bean.is_archived = true;
539    bean.to_file(&archive_path)?;
540
541    // Rebuild index
542    let index = Index::build(beans_dir)?;
543    index.save(beans_dir)?;
544
545    // Check auto-close parent
546    if let Some(parent_id) = &bean.parent {
547        let auto_close = Config::load(beans_dir)
548            .map(|c| c.auto_close_parent)
549            .unwrap_or(true);
550        if auto_close {
551            if let Ok(true) = all_children_closed(beans_dir, parent_id) {
552                let _ = auto_close_parent(beans_dir, parent_id);
553            }
554        }
555    }
556
557    Ok(format!("Closed bean {}: {}", id, bean.title))
558}
559
560fn handle_verify_bean(args: &Value, beans_dir: &Path) -> Result<String> {
561    let id = args
562        .get("id")
563        .and_then(|v| v.as_str())
564        .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
565
566    crate::util::validate_bean_id(id)?;
567    let bean_path = find_bean_file(beans_dir, id)?;
568    let bean = Bean::from_file(&bean_path)?;
569
570    let verify_cmd = match &bean.verify {
571        Some(cmd) => cmd.clone(),
572        None => return Ok(format!("Bean {} has no verify command", id)),
573    };
574
575    let project_root = beans_dir
576        .parent()
577        .ok_or_else(|| anyhow::anyhow!("Cannot determine project root"))?;
578
579    let output = std::process::Command::new("sh")
580        .args(["-c", &verify_cmd])
581        .current_dir(project_root)
582        .output()
583        .with_context(|| format!("Failed to execute verify: {}", verify_cmd))?;
584
585    let stdout = String::from_utf8_lossy(&output.stdout);
586    let stderr = String::from_utf8_lossy(&output.stderr);
587    let passed = output.status.success();
588
589    Ok(serde_json::to_string_pretty(&json!({
590        "id": id,
591        "passed": passed,
592        "command": verify_cmd,
593        "exit_code": output.status.code(),
594        "stdout": truncate_str(&stdout, 2000),
595        "stderr": truncate_str(&stderr, 2000),
596    }))?)
597}
598
599fn handle_context_bean(args: &Value, beans_dir: &Path) -> Result<String> {
600    let id = args
601        .get("id")
602        .and_then(|v| v.as_str())
603        .ok_or_else(|| anyhow::anyhow!("Missing required parameter: id"))?;
604
605    crate::util::validate_bean_id(id)?;
606    let bean_path = find_bean_file(beans_dir, id)?;
607    let bean = Bean::from_file(&bean_path)?;
608
609    let project_dir = beans_dir
610        .parent()
611        .ok_or_else(|| anyhow::anyhow!("Cannot determine project root"))?;
612
613    let description = bean.description.as_deref().unwrap_or("");
614    let paths = crate::ctx_assembler::extract_paths(description);
615
616    if paths.is_empty() {
617        return Ok(format!("Bean {}: no file paths found in description", id));
618    }
619
620    let context = crate::ctx_assembler::assemble_context(paths, project_dir)
621        .context("Failed to assemble context")?;
622
623    Ok(context)
624}
625
626fn handle_status(beans_dir: &Path) -> Result<String> {
627    let index = Index::load_or_rebuild(beans_dir)?;
628
629    let mut claimed = Vec::new();
630    let mut ready = Vec::new();
631    let mut goals = Vec::new();
632    let mut blocked = Vec::new();
633
634    for entry in &index.beans {
635        match entry.status {
636            Status::InProgress => claimed.push(entry),
637            Status::Open => {
638                if is_blocked(entry, &index) {
639                    blocked.push(entry);
640                } else if entry.has_verify {
641                    ready.push(entry);
642                } else {
643                    goals.push(entry);
644                }
645            }
646            Status::Closed => {}
647        }
648    }
649
650    let format_entries = |entries: &[&IndexEntry]| -> Vec<Value> {
651        entries
652            .iter()
653            .map(|e| {
654                json!({
655                    "id": e.id,
656                    "title": e.title,
657                    "priority": format!("P{}", e.priority),
658                    "claimed_by": e.claimed_by,
659                })
660            })
661            .collect()
662    };
663
664    serde_json::to_string_pretty(&json!({
665        "claimed": format_entries(&claimed),
666        "ready": format_entries(&ready),
667        "goals": format_entries(&goals),
668        "blocked": format_entries(&blocked),
669        "summary": format!(
670            "{} claimed, {} ready, {} goals, {} blocked",
671            claimed.len(), ready.len(), goals.len(), blocked.len()
672        )
673    }))
674    .context("Failed to serialize status")
675}
676
677fn handle_tree(args: &Value, beans_dir: &Path) -> Result<String> {
678    let index = Index::load_or_rebuild(beans_dir)?;
679    let root_id = args.get("id").and_then(|v| v.as_str());
680
681    let mut output = String::new();
682
683    if let Some(root) = root_id {
684        render_subtree(&index, root, "", true, &mut output);
685    } else {
686        // Find root beans (no parent)
687        let roots: Vec<&IndexEntry> = index.beans.iter().filter(|e| e.parent.is_none()).collect();
688
689        for (i, root) in roots.iter().enumerate() {
690            let is_last = i == roots.len() - 1;
691            let status_icon = status_icon(root.status);
692            output.push_str(&format!("{} {} {}\n", status_icon, root.id, root.title));
693            render_children(&index, &root.id, "  ", &mut output);
694            if !is_last {
695                output.push('\n');
696            }
697        }
698    }
699
700    if output.is_empty() {
701        Ok("No beans found.".to_string())
702    } else {
703        Ok(output)
704    }
705}
706
707// ---------------------------------------------------------------------------
708// Helper Functions
709// ---------------------------------------------------------------------------
710
711/// Check if a bean is blocked by any dependency.
712fn is_blocked(entry: &IndexEntry, index: &Index) -> bool {
713    !resolve_blocked(entry, index).is_empty()
714}
715
716/// Return list of dependency IDs that are not closed.
717fn resolve_blocked(entry: &IndexEntry, index: &Index) -> Vec<String> {
718    let mut blocked_by = Vec::new();
719
720    for dep_id in &entry.dependencies {
721        if let Some(dep_entry) = index.beans.iter().find(|e| &e.id == dep_id) {
722            if dep_entry.status != Status::Closed {
723                blocked_by.push(dep_id.clone());
724            }
725        } else {
726            blocked_by.push(dep_id.clone());
727        }
728    }
729
730    // Smart dependencies: requires vs sibling produces
731    for required in &entry.requires {
732        if let Some(producer) = index
733            .beans
734            .iter()
735            .find(|e| e.id != entry.id && e.parent == entry.parent && e.produces.contains(required))
736        {
737            if producer.status != Status::Closed && !blocked_by.contains(&producer.id) {
738                blocked_by.push(producer.id.clone());
739            }
740        }
741    }
742
743    blocked_by
744}
745
746/// Check if all children of a parent bean are closed.
747fn all_children_closed(beans_dir: &Path, parent_id: &str) -> Result<bool> {
748    let index = Index::load_or_rebuild(beans_dir)?;
749    let children: Vec<&IndexEntry> = index
750        .beans
751        .iter()
752        .filter(|e| e.parent.as_deref() == Some(parent_id))
753        .collect();
754
755    if children.is_empty() {
756        return Ok(false);
757    }
758
759    Ok(children.iter().all(|c| c.status == Status::Closed))
760}
761
762/// Auto-close a parent bean when all children are closed.
763fn auto_close_parent(beans_dir: &Path, parent_id: &str) -> Result<()> {
764    let bean_path = find_bean_file(beans_dir, parent_id)?;
765    let mut bean = Bean::from_file(&bean_path)?;
766
767    if bean.status == Status::Closed {
768        return Ok(());
769    }
770
771    let now = Utc::now();
772    bean.status = Status::Closed;
773    bean.closed_at = Some(now);
774    bean.close_reason = Some("All children closed".to_string());
775    bean.updated_at = now;
776    bean.to_file(&bean_path)?;
777
778    // Archive
779    let slug = bean
780        .slug
781        .clone()
782        .unwrap_or_else(|| title_to_slug(&bean.title));
783    let ext = bean_path
784        .extension()
785        .and_then(|e| e.to_str())
786        .unwrap_or("md");
787    let today = chrono::Local::now().naive_local().date();
788    let archive_path =
789        crate::discovery::archive_path_for_bean(beans_dir, parent_id, &slug, ext, today);
790    if let Some(parent) = archive_path.parent() {
791        std::fs::create_dir_all(parent)?;
792    }
793    std::fs::rename(&bean_path, &archive_path)?;
794    bean.is_archived = true;
795    bean.to_file(&archive_path)?;
796
797    // Rebuild index
798    let index = Index::build(beans_dir)?;
799    index.save(beans_dir)?;
800
801    Ok(())
802}
803
804fn status_icon(status: Status) -> &'static str {
805    match status {
806        Status::Open => "[ ]",
807        Status::InProgress => "[-]",
808        Status::Closed => "[x]",
809    }
810}
811
812fn render_subtree(index: &Index, id: &str, prefix: &str, _is_last: bool, output: &mut String) {
813    if let Some(entry) = index.beans.iter().find(|e| e.id == id) {
814        let icon = status_icon(entry.status);
815        output.push_str(&format!(
816            "{}{} {} {}\n",
817            prefix, icon, entry.id, entry.title
818        ));
819        render_children(index, id, &format!("{}  ", prefix), output);
820    }
821}
822
823fn render_children(index: &Index, parent_id: &str, prefix: &str, output: &mut String) {
824    let children: Vec<&IndexEntry> = index
825        .beans
826        .iter()
827        .filter(|e| e.parent.as_deref() == Some(parent_id))
828        .collect();
829
830    for child in &children {
831        let icon = status_icon(child.status);
832        output.push_str(&format!(
833            "{}{} {} {}\n",
834            prefix, icon, child.id, child.title
835        ));
836        render_children(index, &child.id, &format!("{}  ", prefix), output);
837    }
838}
839
840fn truncate_str(s: &str, max: usize) -> String {
841    if s.len() > max {
842        format!("...{}", &s[s.len() - max..])
843    } else {
844        s.to_string()
845    }
846}