| 38 | } |
| 39 | |
| 40 | fn render_cli(node: &CanonicalNode) -> String { |
| 41 | let mut s = String::new(); |
| 42 | |
| 43 | // Header: human key + title. |
| 44 | s.push_str(&format!("{} {}\n", node.human_key, node.title)); |
| 45 | s.push_str(&"─".repeat(48)); |
| 46 | s.push('\n'); |
| 47 | |
| 48 | // Spine fields — status is REGENERATED here from the spine, not authored. |
| 49 | s.push_str(&format!("Status: {}\n", node.status)); |
| 50 | if let Some(p) = &node.priority { |
| 51 | s.push_str(&format!("Priority: {p}\n")); |
| 52 | } |
| 53 | if let Some(v) = &node.view { |
| 54 | s.push_str(&format!("View: {v}\n")); |
| 55 | } |
| 56 | if let Some(m) = &node.motivated_by { |
| 57 | s.push_str(&format!("Motivated: {m}\n")); |
| 58 | } |
| 59 | if !node.informed_by.is_empty() { |
| 60 | s.push_str(&format!("InformedBy: {}\n", node.informed_by.join(", "))); |
| 61 | } |
| 62 | |
| 63 | // Why (unconstrained prose). |
| 64 | if let Some(why) = &node.why { |
| 65 | s.push_str("\nWhy\n"); |
| 66 | s.push_str(why); |
| 67 | s.push('\n'); |
| 68 | } |
| 69 | |
| 70 | // Acceptance criteria. |
| 71 | if !node.has_acceptance_criterion.is_empty() { |
| 72 | s.push_str("\nAcceptance Criteria\n"); |
| 73 | for ac in &node.has_acceptance_criterion { |
| 74 | let mark = if ac.ac_status == "met" { "x" } else { " " }; |
| 75 | s.push_str(&format!(" [{mark}] {}\n", ac.text)); |
| 76 | if ac.ac_status == "met" { |
| 77 | if let (Some(by), Some(ev)) = (&ac.verified_by, &ac.evidence) { |
| 78 | s.push_str(&format!(" verified by {by} · {ev}\n")); |
| 79 | } |
| 80 | } |
| 81 | } |
| 82 | } |
| 83 | |
| 84 | // Tasks. |
| 85 | if !node.has_task.is_empty() { |
| 86 | s.push_str("\nTasks\n"); |
| 87 | for t in &node.has_task { |
| 88 | let mark = if t.task_status == "done" { "x" } else { " " }; |
| 89 | s.push_str(&format!(" [{mark}] {}\n", t.text)); |
| 90 | if !t.satisfies.is_empty() { |
| 91 | // `as_slice` so a legacy scalar renders identically to a list. |
| 92 | s.push_str(&format!( |
| 93 | " satisfies: {}\n", |
| 94 | t.satisfies.as_slice().join(", ") |
| 95 | )); |
| 96 | } |
| 97 | if !t.touches_file.is_empty() { |