1use 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
19pub 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
201pub 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
232fn 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 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 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 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 let bean_path = beans_dir.join(format!("{}-{}.md", bean_id, slug));
406 bean.to_file(&bean_path)?;
407
408 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 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 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 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 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 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 let index = Index::build(beans_dir)?;
543 index.save(beans_dir)?;
544
545 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 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
707fn is_blocked(entry: &IndexEntry, index: &Index) -> bool {
713 !resolve_blocked(entry, index).is_empty()
714}
715
716fn 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 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
746fn 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
762fn 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 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 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}