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
226const DIGEST_FINDINGS: usize = 3;
229
230pub fn progress_digest(
238 cliban_key: &str,
239 status: &str,
240 plan_body: Option<&str>,
241 activity: &[ActivityLogEntry],
242) -> String {
243 let mut out = format!("**cliban `{cliban_key}`** — status: `{status}`\n");
244
245 if let Some(plan) = plan_body {
246 let tasks = plan_progress(plan);
247 let done: usize = tasks.iter().map(|t| t.done).sum();
248 let total: usize = tasks.iter().map(|t| t.total).sum();
249 if total > 0 {
250 out.push_str(&format!("\n**Plan: {done}/{total} steps**\n\n"));
251 for task in &tasks {
252 let check = if task.total > 0 && task.done == task.total {
253 "x"
254 } else {
255 " "
256 };
257 out.push_str(&format!(
258 "- [{check}] Task {}: {} — {}/{}\n",
259 task.number, task.title, task.done, task.total
260 ));
261 }
262 }
263 }
264
265 let findings: Vec<&ActivityLogEntry> = activity.iter().filter(|e| e.kind == "log").collect();
269
270 if let Some(status_line) = findings.iter().rev().find_map(|e| test_status(&e.message)) {
271 out.push_str(&format!("\n**Tests:** {status_line}\n"));
272 }
273
274 if !findings.is_empty() {
275 out.push_str("\n**Latest findings**\n\n");
276 let skip = findings.len().saturating_sub(DIGEST_FINDINGS);
277 for entry in &findings[skip..] {
278 out.push_str(&format!(
279 "- `{}` — {}\n",
280 time::format_usec(entry.ts),
281 entry.message.trim()
282 ));
283 }
284 }
285
286 out.push_str(
287 "\n---\n_This comment is maintained by cliban and updated in place on each push._\n",
288 );
289 out
290}
291
292fn test_status(message: &str) -> Option<String> {
297 let mut passed: Option<u64> = None;
298 let mut failed: Option<u64> = None;
299 let tokens: Vec<&str> = message
300 .split_whitespace()
301 .map(|t| t.trim_matches(|c: char| !c.is_alphanumeric()))
302 .collect();
303 for pair in tokens.windows(2) {
304 let Ok(n) = pair[0].parse::<u64>() else {
305 continue;
306 };
307 match pair[1].to_ascii_lowercase().as_str() {
308 "passed" => passed = Some(n),
309 "failed" => failed = Some(n),
310 _ => {}
311 }
312 }
313 let mut parts = Vec::new();
314 if let Some(n) = passed {
315 parts.push(format!("{n} passed"));
316 }
317 if let Some(n) = failed {
318 parts.push(format!("{n} failed"));
319 }
320 if parts.is_empty() {
321 None
322 } else {
323 Some(parts.join(" / "))
324 }
325}
326
327#[cfg(test)]
328mod tests {
329 use super::*;
330
331 fn issue(desc: Option<&str>) -> model::Issue {
332 serde_json::from_value(serde_json::json!({
333 "id": "uuid-1",
334 "identifier": "ENG-412",
335 "title": "Fix the thing",
336 "description": desc,
337 "url": "https://linear.app/acme/issue/ENG-412",
338 "updatedAt": "2026-07-29T12:00:00.000Z",
339 "priority": 2,
340 "dueDate": null,
341 "state": {"id": "s1", "name": "Todo", "type": "unstarted", "position": 1.0},
342 "team": {"id": "t1", "key": "ENG", "name": "Engineering"},
343 "labels": {"nodes": []}
344 }))
345 .unwrap()
346 }
347
348 #[test]
349 fn spec_body_carries_the_description_and_a_link_home() {
350 let body = spec_body(&issue(Some("The bug is real.")));
351 assert!(body.starts_with("The bug is real."));
352 assert!(body.contains("[ENG-412](https://linear.app/acme/issue/ENG-412)"));
353 }
354
355 #[test]
356 fn spec_body_of_an_empty_issue_is_just_provenance() {
357 let body = spec_body(&issue(None));
358 assert!(body.starts_with("_Imported from"));
359 assert!(!body.starts_with("\n"));
360 }
361
362 #[test]
363 fn initial_description_has_both_contract_sections() {
364 let desc = initial_description(&issue(Some("spec text")));
365 assert!(sections::find_section(&desc, SPEC).2);
366 assert!(sections::find_section(&desc, PLAN).2, "tick needs ## Plan");
367 }
368
369 #[test]
370 fn refresh_replaces_spec_and_preserves_a_half_ticked_plan() {
371 let existing = "## Spec\n\nold spec\n\n## Plan\n\n### Task 1: wire it\n\n\
372 - [x] **Step 1: done**\n- [ ] **Step 2: todo**\n\n\
373 ## Activity Log\n\n2026-07-01T10:00Z note started\n";
374 let out = refresh_description(existing, &issue(Some("NEW spec")));
375
376 assert!(out.contains("NEW spec"));
377 assert!(!out.contains("old spec"));
378 assert!(out.contains("- [x] **Step 1: done**"));
380 assert!(out.contains("- [ ] **Step 2: todo**"));
381 assert!(out.contains("2026-07-01T10:00Z note started"));
382 }
383
384 #[test]
385 fn refresh_is_idempotent() {
386 let existing = "## Spec\n\nold\n\n## Plan\n\n### Task 1: x\n\n- [ ] **Step 1: a**\n";
387 let once = refresh_description(existing, &issue(Some("new")));
388 let twice = refresh_description(&once, &issue(Some("new")));
389 assert_eq!(once, twice);
390 }
391
392 #[test]
393 fn fence_appends_when_absent_and_leaves_prose_alone() {
394 let out = apply_fence("Human wrote this.\n", "PROJ-42", "plan mirror");
395 assert!(out.starts_with("Human wrote this."));
396 assert!(out.contains("<!-- cliban:begin PROJ-42 -->"));
397 assert!(out.contains("plan mirror"));
398 assert!(out.contains(FENCE_END));
399 }
400
401 #[test]
402 fn fence_replaces_only_its_own_region() {
403 let existing = "Above the fence.\n\n\
404 <!-- cliban:begin PROJ-42 -->\nOLD MIRROR\n<!-- cliban:end -->\n\n\
405 Below the fence.\n";
406 let out = apply_fence(existing, "PROJ-42", "NEW MIRROR");
407 assert!(out.contains("Above the fence."));
408 assert!(out.contains("Below the fence."));
409 assert!(out.contains("NEW MIRROR"));
410 assert!(!out.contains("OLD MIRROR"));
411 }
412
413 #[test]
414 fn fence_is_idempotent_and_does_not_multiply() {
415 let mut desc = "Human prose.\n".to_string();
416 for _ in 0..3 {
417 desc = apply_fence(&desc, "PROJ-42", "mirror");
418 }
419 assert_eq!(desc.matches(FENCE_BEGIN_PREFIX).count(), 1);
420 assert_eq!(desc.matches(FENCE_END).count(), 1);
421 assert_eq!(desc.matches("Human prose.").count(), 1);
422 }
423
424 #[test]
425 fn fence_recovers_from_a_deleted_end_marker_without_duplicating() {
426 let existing = "Prose.\n\n<!-- cliban:begin PROJ-42 -->\nstale mirror\n";
427 let out = apply_fence(existing, "PROJ-42", "fresh");
428 assert_eq!(out.matches(FENCE_BEGIN_PREFIX).count(), 1);
429 assert!(out.contains("fresh"));
430 assert!(!out.contains("stale mirror"));
431 assert!(out.contains("Prose."));
432 }
433
434 #[test]
435 fn plan_progress_counts_only_column_zero_checkboxes() {
436 let plan = "\n### Task 1: setup\n\n\
437 - [x] **Step 1: a**\n\
438 - [x] **Step 2: b**\n\
439 \x20 - [ ] an indented child bullet, not a step\n\n\
440 ### Task 2: build\n\n\
441 - [ ] **Step 1: c**\n";
442 let tasks = plan_progress(plan);
443 assert_eq!(tasks.len(), 2);
444 assert_eq!(
445 tasks[0],
446 TaskProgress {
447 number: 1,
448 title: "setup".into(),
449 done: 2,
450 total: 2
451 }
452 );
453 assert_eq!(
454 tasks[1],
455 TaskProgress {
456 number: 2,
457 title: "build".into(),
458 done: 0,
459 total: 1
460 }
461 );
462 }
463
464 #[test]
465 fn plan_progress_of_an_empty_plan_is_empty_not_a_panic() {
466 assert!(plan_progress("").is_empty());
467 assert!(plan_progress("\n_No plan yet._\n").is_empty());
468 }
469
470 #[test]
471 fn plan_progress_ignores_steps_before_any_task_heading() {
472 assert!(plan_progress("- [x] **orphan step**\n").is_empty());
474 }
475
476 #[test]
477 fn progress_comment_reports_totals_and_marks_finished_tasks() {
478 let plan = "\n### Task 1: setup\n\n- [x] **Step 1: a**\n\n\
479 ### Task 2: build\n\n- [ ] **Step 1: b**\n";
480 let out = progress_comment("PROJ-42", "in-progress", Some(plan), &[]);
481 assert!(out.contains("**cliban `PROJ-42`**"));
482 assert!(out.contains("Plan: 1/2 steps"));
483 assert!(out.contains("- [x] Task 1: setup — 1/1"));
484 assert!(out.contains("- [ ] Task 2: build — 0/1"));
485 }
486
487 #[test]
488 fn progress_comment_without_a_plan_still_reports_status() {
489 let out = progress_comment("PROJ-42", "done", None, &[]);
490 assert!(out.contains("status: `done`"));
491 assert!(!out.contains("Plan:"));
492 }
493
494 fn entry(ts: &str, kind: &str, message: &str) -> ActivityLogEntry {
497 ActivityLogEntry {
498 id: 0,
499 issue_id: 1,
500 ts: time::parse_ts(ts).unwrap(),
501 kind: kind.into(),
502 message: message.into(),
503 extra: "{}".into(),
504 inserted_at: time::parse_ts(ts).unwrap(),
505 updated_at: time::parse_ts(ts).unwrap(),
506 }
507 }
508
509 #[test]
510 fn digest_reports_plan_progress_and_the_footer() {
511 let plan = "\n### Task 1: setup\n\n- [x] **Step 1: a**\n- [ ] **Step 2: b**\n";
512 let out = progress_digest("PROJ-42", "in-progress", Some(plan), &[]);
513 assert!(out.contains("**cliban `PROJ-42`**"));
514 assert!(out.contains("Plan: 1/2 steps"));
515 assert!(
516 out.contains("maintained by cliban"),
517 "the footer tells a human why the comment keeps changing:\n{out}"
518 );
519 }
520
521 #[test]
522 fn digest_keeps_only_the_last_three_log_findings_and_skips_bookkeeping() {
523 let activity = vec![
524 entry("2026-08-01T10:00:00Z", "log", "finding one"),
525 entry("2026-08-01T10:01:00Z", "status", "backlog → in-progress"),
526 entry("2026-08-01T10:02:00Z", "log", "finding two"),
527 entry("2026-08-01T10:03:00Z", "tick", "ticked Task 1 Step 1"),
528 entry("2026-08-01T10:04:00Z", "log", "finding three"),
529 entry("2026-08-01T10:05:00Z", "log", "finding four"),
530 ];
531 let out = progress_digest("PROJ-42", "in-progress", None, &activity);
532 assert!(
533 !out.contains("finding one"),
534 "oldest finding must age out:\n{out}"
535 );
536 assert!(out.contains("finding two"));
537 assert!(out.contains("finding three"));
538 assert!(out.contains("finding four"));
539 assert!(
540 !out.contains("backlog → in-progress") && !out.contains("ticked Task 1"),
541 "bookkeeping entries are not findings:\n{out}"
542 );
543 }
544
545 #[test]
546 fn digest_surfaces_the_latest_test_status_a_log_entry_carries() {
547 let activity = vec![
548 entry("2026-08-01T10:00:00Z", "log", "suite red: 3 failed"),
549 entry(
550 "2026-08-01T11:00:00Z",
551 "log",
552 "green again: 447 passed / 0 failed",
553 ),
554 ];
555 let out = progress_digest("PROJ-42", "in-progress", None, &activity);
556 assert!(
557 out.contains("**Tests:** 447 passed / 0 failed"),
558 "the newest test status should be pulled out on its own line:\n{out}"
559 );
560 }
561
562 #[test]
563 fn digest_without_test_talk_has_no_tests_line() {
564 let activity = vec![entry("2026-08-01T10:00:00Z", "log", "wired the client")];
565 let out = progress_digest("PROJ-42", "in-progress", None, &activity);
566 assert!(!out.contains("**Tests:**"), "{out}");
567 }
568
569 #[test]
570 fn digest_of_a_bare_issue_is_still_valid_markdown_with_status_and_footer() {
571 let out = progress_digest("PROJ-42", "backlog", None, &[]);
572 assert!(out.contains("status: `backlog`"));
573 assert!(out.contains("maintained by cliban"));
574 assert!(!out.contains("Plan:"));
575 assert!(!out.contains("**Latest findings**"));
576 }
577}