Skip to main content

cliban_sync/linear/
render.rs

1//! Pure text: what a Linear issue looks like as a cliban description, and what
2//! cliban progress looks like in Linear.
3//!
4//! Everything here is `&str` in, `String` out. No network, no database — which
5//! is what makes the merge rules testable, and the merge rules are the part
6//! that can quietly destroy someone's work.
7
8use cliban_core::schema::ActivityLogEntry;
9use cliban_core::{sections, time};
10
11use super::model;
12
13/// Anchors in the cliban description contract.
14pub const SPEC: &str = "Spec";
15pub const PLAN: &str = "Plan";
16
17/// Markers delimiting the region of a Linear description that cliban owns.
18/// HTML comments, so they are invisible in Linear's rendered view — a human
19/// reading the issue sees the content, not the plumbing.
20pub const FENCE_BEGIN_PREFIX: &str = "<!-- cliban:begin";
21pub const FENCE_END: &str = "<!-- cliban:end -->";
22
23/// The `## Spec` body for an imported issue: Linear's description verbatim,
24/// with a provenance line so anyone reading the cliban issue can get back to
25/// the source.
26pub 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
36/// The full description for a newly imported issue: the spec, plus an empty
37/// `## Plan` so `cliban issue tick` has a section to work against immediately
38/// rather than failing with "no ## Plan section".
39pub 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
47/// Re-import: refresh the Linear-owned `## Spec` and leave every other section
48/// byte-identical. This is the load-bearing guarantee of the whole bridge — an
49/// agent's half-ticked `## Plan` must survive a refresh.
50pub fn refresh_description(existing: &str, issue: &model::Issue) -> String {
51    sections::replace_section(existing, SPEC, &spec_body(issue))
52}
53
54/// Splice `inner` into the cliban-owned fenced region of a Linear description,
55/// leaving all surrounding prose untouched. Appends the region when absent.
56///
57/// A begin marker with no matching end is treated as running to the end of the
58/// description. That recovers from a human deleting the end marker without
59/// duplicating the block, which is the failure mode that would actually happen.
60pub 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
85/// Byte range of the existing fenced block, markers included.
86fn 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        // Begin without end: cliban owns everything from the marker onward.
96        None => Some((start, desc.len())),
97    }
98}
99
100/// Byte offset of the first line whose trimmed start matches `prefix`.
101fn 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
112/// Length of the line beginning at `at`, trailing newline included.
113fn 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/// One task's tick count.
122#[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
130/// Count ticked steps per task in a `## Plan` body.
131///
132/// Read-only, and deliberately mirrors the CLI `descmd` step rule rather than
133/// inventing its own: a step is a GFM checkbox at **column zero**, so indented
134/// child bullets are prose, not steps. Getting this wrong would only misreport
135/// a comment, never corrupt a description — which is why counting lives here
136/// while mutation stays in `descmd`.
137pub 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
169/// `1: short title` → `(Some(1), "short title")`.
170fn 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
177/// The comment `push` posts on the Linear issue: where the plan stands and
178/// what happened since the last sync.
179///
180/// Additive by construction — a comment can never destroy anything a human
181/// wrote, which is why it is on by default while the description rewrite is
182/// opt-in.
183pub 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        // The two things a refresh must never touch.
278        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        // Malformed, but it must not panic on `out.last_mut()` being None.
372        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}