1use cliban_core::schema::ActivityLogEntry;
9use cliban_core::{sections, time};
10
11use super::model;
12
13pub const SPEC: &str = "Spec";
15pub const PLAN: &str = "Plan";
16
17pub const FENCE_BEGIN_PREFIX: &str = "<!-- cliban:begin";
21pub const FENCE_END: &str = "<!-- cliban:end -->";
22
23pub fn spec_body(issue: &model::Issue) -> String {
27 let desc = issue.description_text();
28 let provenance = format!("_Imported from [{}]({})._", issue.identifier, issue.url);
29 if desc.is_empty() {
30 provenance
31 } else {
32 format!("{desc}\n\n{provenance}")
33 }
34}
35
36pub fn initial_description(issue: &model::Issue) -> String {
40 format!(
41 "## Spec\n\n{}\n\n## Plan\n\n_No plan yet. Add tasks as `### Task N: title` \
42 with `- [ ] **Step M: ...**` steps._\n",
43 spec_body(issue)
44 )
45}
46
47pub fn refresh_description(existing: &str, issue: &model::Issue) -> String {
51 sections::replace_section(existing, SPEC, &spec_body(issue))
52}
53
54pub fn apply_fence(existing: &str, cliban_key: &str, inner: &str) -> String {
61 let block = format!(
62 "{FENCE_BEGIN_PREFIX} {cliban_key} -->\n{}\n{FENCE_END}",
63 inner.trim_end()
64 );
65
66 match fence_range(existing) {
67 Some((start, end)) => {
68 let mut out = String::with_capacity(existing.len() + block.len());
69 out.push_str(&existing[..start]);
70 out.push_str(&block);
71 out.push_str(&existing[end..]);
72 out
73 }
74 None => {
75 let base = existing.trim_end();
76 if base.is_empty() {
77 format!("{block}\n")
78 } else {
79 format!("{base}\n\n{block}\n")
80 }
81 }
82 }
83}
84
85fn fence_range(desc: &str) -> Option<(usize, usize)> {
87 let start = find_line_starting_with(desc, FENCE_BEGIN_PREFIX)?;
88 let after_begin = &desc[start..];
89 match find_line_starting_with(after_begin, FENCE_END) {
90 Some(rel) => {
91 let end_line_start = start + rel;
92 let end = end_line_start + line_len_at(desc, end_line_start);
93 Some((start, end))
94 }
95 None => Some((start, desc.len())),
97 }
98}
99
100fn find_line_starting_with(s: &str, prefix: &str) -> Option<usize> {
102 let mut offset = 0usize;
103 for line in s.split_inclusive('\n') {
104 if line.trim_start().starts_with(prefix) {
105 return Some(offset);
106 }
107 offset += line.len();
108 }
109 None
110}
111
112fn line_len_at(s: &str, at: usize) -> usize {
114 s[at..]
115 .split_inclusive('\n')
116 .next()
117 .map(str::len)
118 .unwrap_or(0)
119}
120
121#[derive(Debug, Clone, PartialEq, Eq)]
123pub struct TaskProgress {
124 pub number: i32,
125 pub title: String,
126 pub done: usize,
127 pub total: usize,
128}
129
130pub fn plan_progress(plan_body: &str) -> Vec<TaskProgress> {
138 let mut out: Vec<TaskProgress> = Vec::new();
139 for line in plan_body.lines() {
140 if let Some(rest) = line.strip_prefix("### Task ") {
141 let (number, title) = split_task_heading(rest);
142 if let Some(number) = number {
143 out.push(TaskProgress {
144 number,
145 title,
146 done: 0,
147 total: 0,
148 });
149 }
150 continue;
151 }
152 let checked = if line.starts_with("- [x] ") || line.starts_with("- [X] ") {
153 Some(true)
154 } else if line.starts_with("- [ ] ") {
155 Some(false)
156 } else {
157 None
158 };
159 if let (Some(checked), Some(task)) = (checked, out.last_mut()) {
160 task.total += 1;
161 if checked {
162 task.done += 1;
163 }
164 }
165 }
166 out
167}
168
169fn split_task_heading(rest: &str) -> (Option<i32>, String) {
171 match rest.split_once(':') {
172 Some((num, title)) => (num.trim().parse().ok(), title.trim().to_string()),
173 None => (None, String::new()),
174 }
175}
176
177pub fn progress_comment(
184 cliban_key: &str,
185 status: &str,
186 plan_body: Option<&str>,
187 activity: &[ActivityLogEntry],
188) -> String {
189 let mut out = format!("**cliban `{cliban_key}`** — status: `{status}`\n");
190
191 if let Some(plan) = plan_body {
192 let tasks = plan_progress(plan);
193 let done: usize = tasks.iter().map(|t| t.done).sum();
194 let total: usize = tasks.iter().map(|t| t.total).sum();
195 if total > 0 {
196 out.push_str(&format!("\n**Plan: {done}/{total} steps**\n\n"));
197 for task in &tasks {
198 let check = if task.total > 0 && task.done == task.total {
199 "x"
200 } else {
201 " "
202 };
203 out.push_str(&format!(
204 "- [{check}] Task {}: {} — {}/{}\n",
205 task.number, task.title, task.done, task.total
206 ));
207 }
208 }
209 }
210
211 if !activity.is_empty() {
212 out.push_str("\n**Activity**\n\n");
213 for entry in activity {
214 out.push_str(&format!(
215 "- `{}` {} — {}\n",
216 time::format_usec(entry.ts),
217 entry.kind,
218 entry.message.trim()
219 ));
220 }
221 }
222
223 out
224}
225
226#[cfg(test)]
227mod tests {
228 use super::*;
229
230 fn issue(desc: Option<&str>) -> model::Issue {
231 serde_json::from_value(serde_json::json!({
232 "id": "uuid-1",
233 "identifier": "ENG-412",
234 "title": "Fix the thing",
235 "description": desc,
236 "url": "https://linear.app/acme/issue/ENG-412",
237 "updatedAt": "2026-07-29T12:00:00.000Z",
238 "priority": 2,
239 "dueDate": null,
240 "state": {"id": "s1", "name": "Todo", "type": "unstarted", "position": 1.0},
241 "team": {"id": "t1", "key": "ENG", "name": "Engineering"},
242 "labels": {"nodes": []}
243 }))
244 .unwrap()
245 }
246
247 #[test]
248 fn spec_body_carries_the_description_and_a_link_home() {
249 let body = spec_body(&issue(Some("The bug is real.")));
250 assert!(body.starts_with("The bug is real."));
251 assert!(body.contains("[ENG-412](https://linear.app/acme/issue/ENG-412)"));
252 }
253
254 #[test]
255 fn spec_body_of_an_empty_issue_is_just_provenance() {
256 let body = spec_body(&issue(None));
257 assert!(body.starts_with("_Imported from"));
258 assert!(!body.starts_with("\n"));
259 }
260
261 #[test]
262 fn initial_description_has_both_contract_sections() {
263 let desc = initial_description(&issue(Some("spec text")));
264 assert!(sections::find_section(&desc, SPEC).2);
265 assert!(sections::find_section(&desc, PLAN).2, "tick needs ## Plan");
266 }
267
268 #[test]
269 fn refresh_replaces_spec_and_preserves_a_half_ticked_plan() {
270 let existing = "## Spec\n\nold spec\n\n## Plan\n\n### Task 1: wire it\n\n\
271 - [x] **Step 1: done**\n- [ ] **Step 2: todo**\n\n\
272 ## Activity Log\n\n2026-07-01T10:00Z note started\n";
273 let out = refresh_description(existing, &issue(Some("NEW spec")));
274
275 assert!(out.contains("NEW spec"));
276 assert!(!out.contains("old spec"));
277 assert!(out.contains("- [x] **Step 1: done**"));
279 assert!(out.contains("- [ ] **Step 2: todo**"));
280 assert!(out.contains("2026-07-01T10:00Z note started"));
281 }
282
283 #[test]
284 fn refresh_is_idempotent() {
285 let existing = "## Spec\n\nold\n\n## Plan\n\n### Task 1: x\n\n- [ ] **Step 1: a**\n";
286 let once = refresh_description(existing, &issue(Some("new")));
287 let twice = refresh_description(&once, &issue(Some("new")));
288 assert_eq!(once, twice);
289 }
290
291 #[test]
292 fn fence_appends_when_absent_and_leaves_prose_alone() {
293 let out = apply_fence("Human wrote this.\n", "PROJ-42", "plan mirror");
294 assert!(out.starts_with("Human wrote this."));
295 assert!(out.contains("<!-- cliban:begin PROJ-42 -->"));
296 assert!(out.contains("plan mirror"));
297 assert!(out.contains(FENCE_END));
298 }
299
300 #[test]
301 fn fence_replaces_only_its_own_region() {
302 let existing = "Above the fence.\n\n\
303 <!-- cliban:begin PROJ-42 -->\nOLD MIRROR\n<!-- cliban:end -->\n\n\
304 Below the fence.\n";
305 let out = apply_fence(existing, "PROJ-42", "NEW MIRROR");
306 assert!(out.contains("Above the fence."));
307 assert!(out.contains("Below the fence."));
308 assert!(out.contains("NEW MIRROR"));
309 assert!(!out.contains("OLD MIRROR"));
310 }
311
312 #[test]
313 fn fence_is_idempotent_and_does_not_multiply() {
314 let mut desc = "Human prose.\n".to_string();
315 for _ in 0..3 {
316 desc = apply_fence(&desc, "PROJ-42", "mirror");
317 }
318 assert_eq!(desc.matches(FENCE_BEGIN_PREFIX).count(), 1);
319 assert_eq!(desc.matches(FENCE_END).count(), 1);
320 assert_eq!(desc.matches("Human prose.").count(), 1);
321 }
322
323 #[test]
324 fn fence_recovers_from_a_deleted_end_marker_without_duplicating() {
325 let existing = "Prose.\n\n<!-- cliban:begin PROJ-42 -->\nstale mirror\n";
326 let out = apply_fence(existing, "PROJ-42", "fresh");
327 assert_eq!(out.matches(FENCE_BEGIN_PREFIX).count(), 1);
328 assert!(out.contains("fresh"));
329 assert!(!out.contains("stale mirror"));
330 assert!(out.contains("Prose."));
331 }
332
333 #[test]
334 fn plan_progress_counts_only_column_zero_checkboxes() {
335 let plan = "\n### Task 1: setup\n\n\
336 - [x] **Step 1: a**\n\
337 - [x] **Step 2: b**\n\
338 \x20 - [ ] an indented child bullet, not a step\n\n\
339 ### Task 2: build\n\n\
340 - [ ] **Step 1: c**\n";
341 let tasks = plan_progress(plan);
342 assert_eq!(tasks.len(), 2);
343 assert_eq!(
344 tasks[0],
345 TaskProgress {
346 number: 1,
347 title: "setup".into(),
348 done: 2,
349 total: 2
350 }
351 );
352 assert_eq!(
353 tasks[1],
354 TaskProgress {
355 number: 2,
356 title: "build".into(),
357 done: 0,
358 total: 1
359 }
360 );
361 }
362
363 #[test]
364 fn plan_progress_of_an_empty_plan_is_empty_not_a_panic() {
365 assert!(plan_progress("").is_empty());
366 assert!(plan_progress("\n_No plan yet._\n").is_empty());
367 }
368
369 #[test]
370 fn plan_progress_ignores_steps_before_any_task_heading() {
371 assert!(plan_progress("- [x] **orphan step**\n").is_empty());
373 }
374
375 #[test]
376 fn progress_comment_reports_totals_and_marks_finished_tasks() {
377 let plan = "\n### Task 1: setup\n\n- [x] **Step 1: a**\n\n\
378 ### Task 2: build\n\n- [ ] **Step 1: b**\n";
379 let out = progress_comment("PROJ-42", "in-progress", Some(plan), &[]);
380 assert!(out.contains("**cliban `PROJ-42`**"));
381 assert!(out.contains("Plan: 1/2 steps"));
382 assert!(out.contains("- [x] Task 1: setup — 1/1"));
383 assert!(out.contains("- [ ] Task 2: build — 0/1"));
384 }
385
386 #[test]
387 fn progress_comment_without_a_plan_still_reports_status() {
388 let out = progress_comment("PROJ-42", "done", None, &[]);
389 assert!(out.contains("status: `done`"));
390 assert!(!out.contains("Plan:"));
391 }
392}