Skip to main content

mana/mcp/
tools.rs

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