Skip to main content

agent_doc_core/
pending.rs

1//! # Module: pending
2//!
3//! Pure functions for parsing and mutating the `agent:pending` component body.
4//!
5//! Each pending item carries:
6//! - a GFM task-list checkbox (`- [ ]` / `1. [ ]` or `- [x]` / `1. [x]`)
7//! - an id prefix rendered as `[#xxxx]` (generated hash or caller-provided custom id)
8//! - free-form text
9//!
10//! Canonical unordered form: `- [ ] [#a3f2] refactor preflight commit path`
11//! Canonical ordered form: `1. [ ] [#a3f2] refactor preflight commit path`
12//!
13//! This module is I/O-free. Callers (`pending_cmd.rs`, `preflight.rs`, `write.rs`)
14//! handle reading/writing files, locking, and git commits.
15//!
16//! ## Spec
17//! - Parser accepts legacy forms and normalizes via `backfill`.
18//! - IDs are stable across edits/reorders; generated once (or supplied on insert), preserved thereafter.
19//! - `reap` removes `- [x]` items. `detect_reorder` diffs id order between snapshot/current.
20//! - Mutation ops (`op_add/done/edit/clear/reorder`) return a new body string, never mutate in place.
21
22use anyhow::{Result, anyhow, bail};
23use std::collections::HashSet;
24
25/// Lifecycle state for a pending item, encoded by its GFM checkbox.
26///
27/// - `Open` (`[ ]`) — active or not started; default for new items.
28/// - `Gated` (`[/]`) — code-complete, awaiting an external gate (release,
29///   telemetry, field validation). Never auto-reaped.
30/// - `Done` (`[x]`) — fully complete; reaped on the next preflight cycle.
31#[derive(Debug, Clone, Copy, PartialEq, Eq)]
32pub enum PendingState {
33    Open,
34    Gated,
35    Done,
36}
37
38#[derive(Debug, Clone, Copy, PartialEq, Eq)]
39pub enum PendingListMarker {
40    Bullet,
41    Ordered(usize),
42}
43
44impl PendingListMarker {
45    fn render_prefix(self, ordered_index: Option<usize>) -> String {
46        match ordered_index {
47            Some(index) => format!("{index}."),
48            None => match self {
49                PendingListMarker::Bullet => "-".to_string(),
50                PendingListMarker::Ordered(index) => format!("{index}."),
51            },
52        }
53    }
54
55    fn is_ordered(self) -> bool {
56        matches!(self, PendingListMarker::Ordered(_))
57    }
58}
59
60impl PendingState {
61    /// Single character for the GFM checkbox body.
62    pub fn box_char(self) -> char {
63        match self {
64            PendingState::Open => ' ',
65            PendingState::Gated => '/',
66            PendingState::Done => 'x',
67        }
68    }
69
70    /// Parse the inside of a `[…]` checkbox. Accepts `[X]` as `Done`.
71    /// Currently only used by tests; kept on the public API for parser callers
72    /// that may need to inspect a single checkbox char in isolation.
73    #[allow(dead_code)]
74    pub fn from_box_char(c: char) -> Option<PendingState> {
75        match c {
76            ' ' => Some(PendingState::Open),
77            '/' => Some(PendingState::Gated),
78            'x' | 'X' => Some(PendingState::Done),
79            _ => None,
80        }
81    }
82}
83
84/// Mutating operation on a pending item — used by the state-transition matrix.
85///
86/// `MarkDone` is referenced by the matrix tests and reserved for the `op_done`
87/// migration path (Phase 3); it is not yet wired through the CLI primitives,
88/// which keep the unconditional `op_done` semantics from Phase 1.
89#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90#[allow(dead_code)]
91pub enum PendingOp {
92    /// `[ ] → [/]` — code-complete, awaiting external gate.
93    Gate,
94    /// `[/] → [ ]` — return a gated item to active.
95    Ungate,
96    /// `[ ] | [/] → [x]` — fully complete.
97    MarkDone,
98}
99
100/// Outcome of `validate_transition`. `NoOp` means "already in target state, do nothing".
101#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum TransitionResult {
103    Transition(PendingState),
104    NoOp,
105}
106
107/// Apply the state-machine matrix from `specs/pending-system.md` §4.
108///
109/// | from \ op | Gate     | Ungate   | MarkDone |
110/// |-----------|----------|----------|----------|
111/// | Open      | → Gated  | error    | → Done   |
112/// | Gated     | no-op    | → Open   | → Done   |
113/// | Done      | error    | error    | no-op    |
114///
115/// Rationale (spec §4):
116/// - `gate` from Done is an error: a fully-complete item cannot be re-gated; the
117///   intended workflow is to add a new pending item describing the follow-up gate.
118/// - `ungate` from Open or Done is an error: ungate is the inverse of gate, not
119///   a generic "reset" — it requires an explicit `[/]` source.
120/// - `Gate` on Gated and `MarkDone` on Done are idempotent no-ops, not errors,
121///   so the granular CLI flags can be re-run safely (skill retries, watch loops).
122pub fn validate_transition(from: PendingState, op: PendingOp) -> Result<TransitionResult> {
123    use PendingOp::*;
124    use PendingState as S;
125    use TransitionResult::*;
126    match (from, op) {
127        (S::Open, Gate) => Ok(Transition(S::Gated)),
128        (S::Open, Ungate) => bail!("cannot ungate Open item: source must be `[/]`"),
129        (S::Open, MarkDone) => Ok(Transition(S::Done)),
130        (S::Gated, Gate) => Ok(NoOp),
131        (S::Gated, Ungate) => Ok(Transition(S::Open)),
132        (S::Gated, MarkDone) => Ok(Transition(S::Done)),
133        (S::Done, Gate) => {
134            bail!("cannot gate Done item: add a new pending item for the follow-up gate")
135        }
136        (S::Done, Ungate) => bail!("cannot ungate Done item: source must be `[/]`"),
137        (S::Done, MarkDone) => Ok(NoOp),
138    }
139}
140
141/// A parsed pending list item.
142#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct PendingItem {
144    /// Parent list marker. `-` is the default; ordered lists store the parsed
145    /// source ordinal and are renumbered canonically on render.
146    pub marker: PendingListMarker,
147    /// Pending item id (no `#` prefix). Generated ids are lowercase base32; custom ids
148    /// and nested subtask ids may be hyphenated ASCII alphanumeric strings and are
149    /// normalized to lowercase.
150    pub id: String,
151    /// Lifecycle state encoded by the GFM checkbox.
152    pub state: PendingState,
153    /// Optional typed gate (e.g., "release" for `[/release]`, "deploy" for `[/deploy]`).
154    /// Only meaningful when `state == Gated`. `None` means untyped `[/]`.
155    pub gate_type: Option<String>,
156    /// Bullet text after the hash prefix.
157    pub text: String,
158    /// Raw indented continuation lines that belong to this item (for nested
159    /// lists, dependency notes, etc.). Stored without the leading item line.
160    pub continuation: String,
161}
162
163impl PendingItem {
164    /// Render to canonical `- [<state>] [#id] text` form.
165    /// Typed gates render as `[/release]`, `[/deploy]`, etc.
166    pub fn render(&self) -> String {
167        self.render_with_ordered_index(None)
168    }
169
170    fn render_with_ordered_index(&self, ordered_index: Option<usize>) -> String {
171        let checkbox = match (&self.state, &self.gate_type) {
172            (PendingState::Gated, Some(gt)) => format!("[/{}]", gt),
173            _ => format!("[{}]", self.state.box_char()),
174        };
175        let mut out = format!(
176            "{} {} [#{}] {}",
177            self.marker.render_prefix(ordered_index),
178            checkbox,
179            self.id,
180            self.text
181        );
182        if !self.continuation.is_empty() {
183            out.push('\n');
184            out.push_str(&self.continuation);
185        }
186        out
187    }
188
189    /// Convenience: true when state is `Done` (`[x]`).
190    pub fn is_done(&self) -> bool {
191        matches!(self.state, PendingState::Done)
192    }
193}
194
195#[derive(Debug, Clone, PartialEq, Eq)]
196enum PendingSegment {
197    Text(String),
198    Item {
199        item: PendingItem,
200        has_newline: bool,
201    },
202}
203
204#[derive(Debug, Clone, PartialEq, Eq, Default)]
205struct PendingLayout {
206    segments: Vec<PendingSegment>,
207}
208
209impl PendingLayout {
210    fn parse(body: &str) -> Self {
211        if body.is_empty() {
212            return Self {
213                segments: Vec::new(),
214            };
215        }
216
217        let mut segments = Vec::new();
218        let lines: Vec<&str> = body.split_inclusive('\n').collect();
219        let mut index = 0usize;
220        while index < lines.len() {
221            let raw_line = lines[index];
222            let has_newline = raw_line.ends_with('\n');
223            let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
224            if let Some(mut item) = parse_item_line(line) {
225                index += 1;
226                let mut continuation = String::new();
227                let mut pending_blank_lines = String::new();
228                while index < lines.len() {
229                    let next_raw = lines[index];
230                    let next_line = next_raw.strip_suffix('\n').unwrap_or(next_raw);
231                    if parse_item_line(next_line).is_some() {
232                        break;
233                    }
234                    if next_line.is_empty() {
235                        pending_blank_lines.push_str(next_raw);
236                        index += 1;
237                        continue;
238                    }
239                    if is_indented_continuation_line(next_line) {
240                        continuation.push_str(&pending_blank_lines);
241                        pending_blank_lines.clear();
242                        continuation.push_str(next_raw);
243                        index += 1;
244                        continue;
245                    }
246                    break;
247                }
248                item.continuation = continuation;
249                segments.push(PendingSegment::Item { item, has_newline });
250                if !pending_blank_lines.is_empty() {
251                    segments.push(PendingSegment::Text(pending_blank_lines));
252                }
253            } else {
254                segments.push(PendingSegment::Text(raw_line.to_string()));
255                index += 1;
256            }
257        }
258        Self { segments }
259    }
260
261    fn render(&self) -> String {
262        let mut out = String::new();
263        let ordered_mode = self
264            .segments
265            .iter()
266            .filter_map(|segment| match segment {
267                PendingSegment::Item { item, .. } => Some(item.marker.is_ordered()),
268                PendingSegment::Text(_) => None,
269            })
270            .any(|is_ordered| is_ordered);
271        let mut ordered_index = 1usize;
272        for segment in &self.segments {
273            match segment {
274                PendingSegment::Text(raw) => out.push_str(raw),
275                PendingSegment::Item { item, has_newline } => {
276                    let render = if ordered_mode {
277                        let render = item.render_with_ordered_index(Some(ordered_index));
278                        ordered_index += 1;
279                        render
280                    } else {
281                        item.render()
282                    };
283                    out.push_str(&render);
284                    if *has_newline && item.continuation.is_empty() {
285                        out.push('\n');
286                    }
287                }
288            }
289        }
290        out
291    }
292
293    fn items(&self) -> Vec<PendingItem> {
294        self.segments
295            .iter()
296            .filter_map(|segment| match segment {
297                PendingSegment::Item { item, .. } => Some(item.clone()),
298                PendingSegment::Text(_) => None,
299            })
300            .collect()
301    }
302
303    fn first_item_index(&self) -> Option<usize> {
304        self.segments
305            .iter()
306            .position(|segment| matches!(segment, PendingSegment::Item { .. }))
307    }
308
309    fn ensure_separator_before(&mut self, index: usize) {
310        if index == 0 {
311            return;
312        }
313        match &mut self.segments[index - 1] {
314            PendingSegment::Text(raw) => {
315                if !raw.is_empty() && !raw.ends_with('\n') {
316                    raw.push('\n');
317                }
318            }
319            PendingSegment::Item { has_newline, .. } => {
320                *has_newline = true;
321            }
322        }
323    }
324
325    fn insert_first_item(&mut self, item: PendingItem) {
326        let index = self.first_item_index().unwrap_or(self.segments.len());
327        self.ensure_separator_before(index);
328        self.segments.insert(
329            index,
330            PendingSegment::Item {
331                item,
332                has_newline: true,
333            },
334        );
335    }
336
337    fn replace_items<F>(&self, mut replacer: F) -> Self
338    where
339        F: FnMut(&PendingItem) -> Option<PendingItem>,
340    {
341        let mut segments = Vec::with_capacity(self.segments.len());
342        for segment in &self.segments {
343            match segment {
344                PendingSegment::Text(raw) => segments.push(PendingSegment::Text(raw.clone())),
345                PendingSegment::Item { item, has_newline } => {
346                    if let Some(next_item) = replacer(item) {
347                        segments.push(PendingSegment::Item {
348                            item: next_item,
349                            has_newline: *has_newline,
350                        });
351                    }
352                }
353            }
354        }
355        Self { segments }
356    }
357
358    fn non_item_segments(&self) -> Vec<String> {
359        self.segments
360            .iter()
361            .filter_map(|segment| match segment {
362                PendingSegment::Text(raw) => Some(raw.clone()),
363                PendingSegment::Item { .. } => None,
364            })
365            .collect()
366    }
367}
368
369#[derive(Debug, Clone, PartialEq, Eq)]
370pub struct ShadowPendingItem {
371    pub id: String,
372    pub text: String,
373    pub line: usize,
374}
375
376impl ShadowPendingItem {
377    pub fn reference(&self) -> String {
378        format!("#{} (line {})", self.id, self.line)
379    }
380}
381
382#[derive(Debug, Clone, PartialEq, Eq, Default)]
383pub struct ShadowPendingReport {
384    pub duplicated_in_live_backlog: Vec<ShadowPendingItem>,
385    pub shadow_only: Vec<ShadowPendingItem>,
386}
387
388#[derive(Debug, Clone, PartialEq, Eq)]
389pub struct MalformedPendingItemLine {
390    pub id: String,
391    pub line: usize,
392    pub text: String,
393}
394
395impl MalformedPendingItemLine {
396    pub fn reference(&self) -> String {
397        format!("#{} (line {}): {}", self.id, self.line, self.text)
398    }
399}
400
401/// Find lines that look like tracked checklist items but are not parseable as
402/// live pending items. These lines are dangerous during closeout because guards
403/// that operate on parsed items would otherwise treat the matching id as absent.
404pub fn detect_malformed_item_lines(body: &str) -> Vec<MalformedPendingItemLine> {
405    body.lines()
406        .enumerate()
407        .filter_map(|(idx, line)| {
408            if parse_item_line(line).is_some() {
409                return None;
410            }
411            let trimmed = line.trim();
412            let (id, id_start) = find_valid_hash_id(trimmed)?;
413            let prefix = &trimmed[..id_start];
414            if !prefix_contains_task_checkbox(prefix) {
415                return None;
416            }
417            Some(MalformedPendingItemLine {
418                id: id.to_string(),
419                line: idx + 1,
420                text: trimmed.to_string(),
421            })
422        })
423        .collect()
424}
425
426fn find_valid_hash_id(line: &str) -> Option<(&str, usize)> {
427    let start = line.find("[#")?;
428    let after = &line[start + 2..];
429    let close = after.find(']')?;
430    let id = &after[..close];
431    is_valid_pending_id(id).then_some((id, start))
432}
433
434fn prefix_contains_task_checkbox(prefix: &str) -> bool {
435    prefix.contains("[ ]")
436        || prefix.contains("[/]")
437        || prefix.contains("[x]")
438        || prefix.contains("[X]")
439        || prefix
440            .split("[/")
441            .nth(1)
442            .and_then(|tail| tail.split(']').next())
443            .is_some_and(|gate| {
444                !gate.is_empty()
445                    && gate
446                        .chars()
447                        .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
448            })
449}
450
451/// Parse the pending component body into (prelude, items, postlude).
452///
453/// - Prelude: leading non-list lines (whitespace, non tracked parent lines).
454/// - Items: parsed tracked entries (`- ...` / `1. ...`, legacy or fully-migrated).
455/// - Postlude: trailing non-list lines after the last item.
456///
457/// Interleaved non-item lines between backlog entries are intentionally not
458/// surfaced here. Mutation helpers preserve those lines through `PendingLayout`;
459/// `parse_items` remains the lossy item-only view used by reorder/diff checks.
460pub fn parse_items(body: &str) -> (String, Vec<PendingItem>, String) {
461    let layout = PendingLayout::parse(body);
462    let Some(first_item) = layout.first_item_index() else {
463        return (body.to_string(), Vec::new(), String::new());
464    };
465    let last_item = layout
466        .segments
467        .iter()
468        .rposition(|segment| matches!(segment, PendingSegment::Item { .. }))
469        .unwrap_or(first_item);
470
471    let mut prelude = String::new();
472    for segment in &layout.segments[..first_item] {
473        if let PendingSegment::Text(raw) = segment {
474            prelude.push_str(raw);
475        }
476    }
477
478    let mut postlude = String::new();
479    if last_item + 1 < layout.segments.len() {
480        for segment in &layout.segments[last_item + 1..] {
481            if let PendingSegment::Text(raw) = segment {
482                postlude.push_str(raw);
483            }
484        }
485    }
486
487    let mut items = Vec::new();
488    for segment in &layout.segments[first_item..=last_item] {
489        if let PendingSegment::Item { item, .. } = segment {
490            items.push(item.clone());
491        }
492    }
493
494    (prelude, items, postlude)
495}
496
497/// Parse a single list item line into a `PendingItem` (id optional).
498///
499/// Returns `None` when the line is not a list item. When the id is missing,
500/// the returned item has an empty id — callers must run `backfill` to assign one.
501fn parse_item_line(line: &str) -> Option<PendingItem> {
502    let (marker, rest) = parse_parent_list_marker(line)?;
503    let rest = rest.trim_start();
504
505    // Checkbox? Supports typed gates: [/release], [/deploy], etc.
506    let (state, gate_type, after_box) = if let Some(r) = rest.strip_prefix("[ ]") {
507        (PendingState::Open, None, r.trim_start())
508    } else if let Some(r) = rest.strip_prefix("[/]") {
509        (PendingState::Gated, None, r.trim_start())
510    } else if let Some(inner) = rest.strip_prefix("[/") {
511        // Typed gate: [/release], [/deploy], etc.
512        if let Some(close) = inner.find(']') {
513            let gt = &inner[..close];
514            if !gt.is_empty()
515                && gt
516                    .chars()
517                    .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
518            {
519                let r = &inner[close + 1..];
520                (PendingState::Gated, Some(gt.to_lowercase()), r.trim_start())
521            } else {
522                (PendingState::Open, None, rest)
523            }
524        } else {
525            (PendingState::Open, None, rest)
526        }
527    } else if let Some(r) = rest.strip_prefix("[x]") {
528        (PendingState::Done, None, r.trim_start())
529    } else if let Some(r) = rest.strip_prefix("[X]") {
530        (PendingState::Done, None, r.trim_start())
531    } else {
532        (PendingState::Open, None, rest)
533    };
534
535    // Hash id?
536    let (id, text) = if let Some(after_hash) = after_box.strip_prefix("[#") {
537        if let Some(close) = after_hash.find(']') {
538            let id_raw = &after_hash[..close];
539            let tail = after_hash[close + 1..].trim_start();
540            if is_valid_pending_id(id_raw) {
541                (id_raw.to_lowercase(), tail.to_string())
542            } else if id_raw.is_empty() {
543                // Bare [#] placeholder — consume it, text starts after ]
544                (String::new(), tail.to_string())
545            } else {
546                (String::new(), after_box.to_string())
547            }
548        } else {
549            (String::new(), after_box.to_string())
550        }
551    } else {
552        (String::new(), after_box.to_string())
553    };
554
555    Some(PendingItem {
556        marker,
557        id,
558        state,
559        gate_type,
560        text: text.trim_end().to_string(),
561        continuation: String::new(),
562    })
563}
564
565fn parse_parent_list_marker(line: &str) -> Option<(PendingListMarker, &str)> {
566    if let Some(rest) = line.strip_prefix("- ") {
567        return Some((PendingListMarker::Bullet, rest));
568    }
569
570    let digit_len = line.bytes().take_while(|b| b.is_ascii_digit()).count();
571    if digit_len == 0 {
572        return None;
573    }
574
575    let (digits, tail) = line.split_at(digit_len);
576    let tail = tail.strip_prefix('.')?;
577    if !tail.starts_with(char::is_whitespace) {
578        return None;
579    }
580    let rest = tail.trim_start();
581    let ordinal = digits.parse::<usize>().ok()?;
582    Some((PendingListMarker::Ordered(ordinal), rest))
583}
584
585fn is_indented_continuation_line(line: &str) -> bool {
586    line.starts_with(' ') || line.starts_with('\t')
587}
588
589fn is_structural_inter_item_line(line: &str) -> bool {
590    let trimmed = line.trim();
591    trimmed.starts_with('#')
592        || trimmed.starts_with("<!--")
593        || matches!(trimmed, "---" | "***" | "___")
594}
595
596fn split_reapable_trailing_text_segment(raw: &str) -> Option<(String, String)> {
597    if raw.is_empty() {
598        return None;
599    }
600
601    let mut reap = String::new();
602    let mut keep = String::new();
603    let mut pending_blank = String::new();
604    let mut reaping = true;
605
606    for segment in raw.split_inclusive('\n') {
607        let line = segment.strip_suffix('\n').unwrap_or(segment);
608        if !reaping {
609            keep.push_str(segment);
610            continue;
611        }
612        if line.trim().is_empty() {
613            pending_blank.push_str(segment);
614            continue;
615        }
616        if is_structural_inter_item_line(line) {
617            keep.push_str(&pending_blank);
618            pending_blank.clear();
619            keep.push_str(segment);
620            reaping = false;
621            continue;
622        }
623        reap.push_str(&pending_blank);
624        pending_blank.clear();
625        reap.push_str(segment);
626    }
627
628    if reap.is_empty() {
629        return None;
630    }
631    reap.push_str(&pending_blank);
632    Some((reap, keep))
633}
634
635pub fn is_valid_pending_id(s: &str) -> bool {
636    !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
637}
638
639pub fn normalize_pending_id(id: &str) -> String {
640    id.trim().trim_start_matches('#').to_ascii_lowercase()
641}
642
643#[derive(Debug, Clone, Copy, PartialEq, Eq)]
644enum LeadingCustomIdPrefix {
645    Explicit,
646    Bracketed,
647    BarePlaceholder,
648}
649
650fn detect_leading_custom_id_prefix(text: &str) -> Option<LeadingCustomIdPrefix> {
651    let trimmed = text.trim_start();
652    if trimmed.starts_with("id=") {
653        return Some(LeadingCustomIdPrefix::Explicit);
654    }
655    let after_hash = trimmed.strip_prefix("[#")?;
656    let close = after_hash.find(']')?;
657    if close == 0 {
658        Some(LeadingCustomIdPrefix::BarePlaceholder)
659    } else {
660        Some(LeadingCustomIdPrefix::Bracketed)
661    }
662}
663
664pub(crate) fn ensure_no_leading_custom_id_prefix(text: &str, context: &str) -> Result<()> {
665    match detect_leading_custom_id_prefix(text) {
666        None => Ok(()),
667        Some(LeadingCustomIdPrefix::BarePlaceholder) => bail!(
668            "{}: bare `[#]` placeholder is invalid — omit it or use `id=<id> <text>`",
669            context
670        ),
671        Some(LeadingCustomIdPrefix::Explicit) | Some(LeadingCustomIdPrefix::Bracketed) => bail!(
672            "{}: duplicate leading custom id prefix in item text — use exactly one leading `id=<id>` or `[#id]` prefix",
673            context
674        ),
675    }
676}
677
678pub fn ensure_no_new_leading_custom_id_prefix(
679    item_id: &str,
680    text: &str,
681    existing_ids: &HashSet<String>,
682    context: &str,
683) -> Result<()> {
684    match detect_leading_custom_id_prefix(text) {
685        None => Ok(()),
686        Some(LeadingCustomIdPrefix::BarePlaceholder) => bail!(
687            "{}: bare `[#]` placeholder is invalid — omit it or use `id=<id> <text>`",
688            context
689        ),
690        Some(LeadingCustomIdPrefix::Explicit) => bail!(
691            "{}: duplicate leading custom id prefix in item text — use exactly one leading `id=<id>` or `[#id]` prefix",
692            context
693        ),
694        Some(LeadingCustomIdPrefix::Bracketed) if existing_ids.contains(item_id) => Ok(()),
695        Some(LeadingCustomIdPrefix::Bracketed) => bail!(
696            "{}: duplicate leading custom id prefix in item text — use exactly one leading `id=<id>` or `[#id]` prefix",
697            context
698        ),
699    }
700}
701
702fn extract_inline_tag_id(text: &str) -> Option<(String, String)> {
703    let mut search_from = 0;
704    while search_from < text.len() {
705        let remaining = &text[search_from..];
706        let pos = match remaining.find("[#") {
707            Some(p) => p,
708            None => break,
709        };
710        let abs_pos = search_from + pos;
711        let after_open = match text.get(abs_pos + 2..) {
712            Some(s) => s,
713            None => {
714                search_from = abs_pos + 2;
715                continue;
716            }
717        };
718        let close = match after_open.find(']') {
719            Some(c) => c,
720            None => {
721                search_from = abs_pos + 2;
722                continue;
723            }
724        };
725        let raw_id = &after_open[..close];
726        if !raw_id.is_empty() && is_valid_pending_id(raw_id) {
727            let tag_end = abs_pos + 2 + close + 1;
728            let before = text[..abs_pos].trim_end();
729            let after = text[tag_end..].trim_start();
730            let new_text = if before.is_empty() {
731                after.to_string()
732            } else if after.is_empty() {
733                before.to_string()
734            } else {
735                format!("{} {}", before, after)
736            };
737            if !new_text.is_empty() {
738                return Some((raw_id.to_lowercase(), new_text));
739            }
740        }
741        search_from = abs_pos + 2;
742    }
743    None
744}
745
746fn custom_id_error(raw_id: &str) -> anyhow::Error {
747    anyhow!(
748        "pending add: invalid custom id `{}` — ids must be non-empty ASCII alphanumeric strings (hyphen allowed)",
749        raw_id.trim()
750    )
751}
752
753fn parse_explicit_custom_id_prefix(rest: &str) -> Result<(Option<String>, String)> {
754    let Some((raw_id, remainder)) = rest.split_once(char::is_whitespace) else {
755        bail!(
756            "pending add: custom id prefix must be followed by item text (expected `id=<id> <text>`)"
757        );
758    };
759    let custom_id = raw_id.trim().trim_start_matches('#');
760    if custom_id.is_empty() {
761        bail!("pending add: empty custom id after `id=` — expected `id=<id> <text>`");
762    }
763    if !is_valid_pending_id(custom_id) {
764        return Err(custom_id_error(raw_id));
765    }
766    let remainder = remainder.trim();
767    if remainder.is_empty() {
768        bail!(
769            "pending add: custom id prefix must be followed by item text (expected `id=<id> <text>`)"
770        );
771    }
772    ensure_no_leading_custom_id_prefix(remainder, "pending add")?;
773    Ok((Some(custom_id.to_lowercase()), remainder.to_string()))
774}
775
776fn parse_bracketed_custom_id_prefix(trimmed: &str) -> Result<(Option<String>, String)> {
777    let Some(after_hash) = trimmed.strip_prefix("[#") else {
778        return Ok((None, trimmed.to_string()));
779    };
780    let Some(close) = after_hash.find(']') else {
781        return Ok((None, trimmed.to_string()));
782    };
783    let raw_id = &after_hash[..close];
784    let remainder = after_hash[close + 1..].trim_start();
785    if raw_id.is_empty() {
786        bail!("pending add: bare `[#]` placeholder is invalid — use `id=<id> <text>` or omit it");
787    }
788    if !is_valid_pending_id(raw_id) {
789        return Err(custom_id_error(raw_id));
790    }
791    if remainder.is_empty() {
792        bail!(
793            "pending add: bracketed custom id prefix must be followed by item text (expected `[#id] <text>`)"
794        );
795    }
796    ensure_no_leading_custom_id_prefix(remainder, "pending add")?;
797    Ok((Some(raw_id.to_lowercase()), remainder.to_string()))
798}
799
800fn parse_custom_id_prefix(text: &str) -> Result<(Option<String>, String)> {
801    let trimmed = text.trim();
802    if let Some(rest) = trimmed.strip_prefix("id=") {
803        return parse_explicit_custom_id_prefix(rest);
804    }
805    parse_bracketed_custom_id_prefix(trimmed)
806}
807
808#[test]
809fn existing_item_may_keep_leading_alias_tag() {
810    let mut existing_ids = HashSet::new();
811    existing_ids.insert("yckq".to_string());
812
813    ensure_no_new_leading_custom_id_prefix(
814        "yckq",
815        "[#ss01] ShipStation fix",
816        &existing_ids,
817        "pending/backlog patch",
818    )
819    .expect("existing alias tag should not be rejected");
820}
821
822#[test]
823fn new_item_still_rejects_leading_alias_tag() {
824    let existing_ids = HashSet::new();
825    let err = ensure_no_new_leading_custom_id_prefix(
826        "yckq",
827        "[#ss01] ShipStation fix",
828        &existing_ids,
829        "pending/backlog patch",
830    )
831    .expect_err("new item alias tag should still be rejected");
832    assert!(
833        err.to_string()
834            .contains("duplicate leading custom id prefix"),
835        "unexpected error: {}",
836        err
837    );
838}
839
840/// Serialize items back to a body string.
841#[allow(dead_code)]
842pub fn render_items(prelude: &str, items: &[PendingItem], postlude: &str) -> String {
843    let mut out = String::new();
844    let ordered_mode = items.iter().any(|item| item.marker.is_ordered());
845    let mut ordered_index = 1usize;
846    out.push_str(prelude);
847    if !prelude.is_empty() && !prelude.ends_with('\n') {
848        out.push('\n');
849    }
850    for item in items {
851        let render = if ordered_mode {
852            let render = item.render_with_ordered_index(Some(ordered_index));
853            ordered_index += 1;
854            render
855        } else {
856            item.render()
857        };
858        out.push_str(&render);
859        if item.continuation.is_empty() || !item.continuation.ends_with('\n') {
860            out.push('\n');
861        }
862    }
863    if !postlude.is_empty() {
864        out.push_str(postlude);
865        if !postlude.ends_with('\n') {
866            out.push('\n');
867        }
868    }
869    out
870}
871
872/// Remove a tracked item by id and return the updated body plus the removed item.
873pub fn op_take_item(body: &str, id: &str) -> Result<(String, PendingItem)> {
874    let id = normalize_pending_id(id);
875    let layout = PendingLayout::parse(body);
876    let mut taken = None;
877    let rewritten = layout.replace_items(|item| {
878        if item.id == id {
879            taken = Some(item.clone());
880            None
881        } else {
882            Some(item.clone())
883        }
884    });
885    let Some(item) = taken else {
886        bail!("id not found in pending list: {}", id);
887    };
888    Ok((rewritten.render(), item))
889}
890
891/// Insert an existing tracked item at the first item slot of a component body.
892pub fn op_insert_item_first(body: &str, item: PendingItem) -> String {
893    let mut layout = PendingLayout::parse(body);
894    layout.insert_first_item(item);
895    layout.render()
896}
897
898/// Append existing tracked items after the current item list, preserving order.
899pub fn op_append_items(body: &str, items: &[PendingItem]) -> String {
900    let mut layout = PendingLayout::parse(body);
901    let insert_at = layout
902        .segments
903        .iter()
904        .rposition(|segment| matches!(segment, PendingSegment::Item { .. }))
905        .map(|idx| idx + 1)
906        .unwrap_or_else(|| layout.first_item_index().unwrap_or(layout.segments.len()));
907    layout.ensure_separator_before(insert_at);
908    for (offset, item) in items.iter().enumerate() {
909        layout.segments.insert(
910            insert_at + offset,
911            PendingSegment::Item {
912                item: item.clone(),
913                has_newline: true,
914            },
915        );
916    }
917    layout.render()
918}
919
920/// Extract all items matching `state`, returning the updated body and removed items.
921pub fn op_take_items_by_state(body: &str, state: PendingState) -> (String, Vec<PendingItem>) {
922    let layout = PendingLayout::parse(body);
923    let mut taken = Vec::new();
924    let rewritten = layout.replace_items(|item| {
925        if item.state == state {
926            taken.push(item.clone());
927            None
928        } else {
929            Some(item.clone())
930        }
931    });
932    (rewritten.render(), taken)
933}
934
935pub fn canonicalize_preserving_non_item_lines(body: &str) -> String {
936    PendingLayout::parse(body).render()
937}
938
939fn parse_pending_edit_payload(new_text: &str) -> Result<(String, String)> {
940    let synthetic = format!("- [ ] [#edit] {}", new_text);
941    let layout = PendingLayout::parse(&synthetic);
942    let items = layout.items();
943    if items.len() != 1 {
944        bail!(
945            "pending edit: multiline text may only describe one parent item; use indented continuation lines for child subtasks"
946        );
947    }
948    if layout
949        .non_item_segments()
950        .into_iter()
951        .any(|segment| !segment.trim().is_empty())
952    {
953        bail!(
954            "pending edit: multiline text may only contain indented continuation lines after the first line"
955        );
956    }
957    let mut item = items.into_iter().next().expect("single parsed edit item");
958    if !item.continuation.is_empty() && !item.continuation.ends_with('\n') {
959        item.continuation.push('\n');
960    }
961    Ok((item.text, item.continuation))
962}
963
964fn trim_boundary_blank_segments(mut segments: Vec<String>) -> Vec<String> {
965    while matches!(segments.first(), Some(segment) if segment.trim().is_empty()) {
966        segments.remove(0);
967    }
968    while matches!(segments.last(), Some(segment) if segment.trim().is_empty()) {
969        segments.pop();
970    }
971    segments
972}
973
974pub fn preserves_non_item_structure(lhs: &str, rhs: &str) -> bool {
975    trim_boundary_blank_segments(PendingLayout::parse(lhs).non_item_segments())
976        == trim_boundary_blank_segments(PendingLayout::parse(rhs).non_item_segments())
977}
978
979pub fn merge_partial_backlog_prefix(
980    current_body: &str,
981    target_body: &str,
982) -> Option<String> {
983    let current = PendingLayout::parse(current_body);
984    let target = PendingLayout::parse(target_body);
985
986    let current_texts: Vec<(usize, String)> = current
987        .segments
988        .iter()
989        .enumerate()
990        .filter_map(|(idx, segment)| match segment {
991            PendingSegment::Text(raw) if !raw.trim().is_empty() => Some((idx, raw.clone())),
992            _ => None,
993        })
994        .collect();
995    let target_texts: Vec<String> = target
996        .segments
997        .iter()
998        .filter_map(|segment| match segment {
999            PendingSegment::Text(raw) if !raw.trim().is_empty() => Some(raw.clone()),
1000            _ => None,
1001        })
1002        .collect();
1003
1004    if current_texts.is_empty()
1005        || target_texts.is_empty()
1006        || target_texts.len() >= current_texts.len()
1007        || !target_texts
1008            .iter()
1009            .zip(current_texts.iter())
1010            .all(|(target_text, (_, current_text))| target_text == current_text)
1011    {
1012        return None;
1013    }
1014
1015    let tail_start = current_texts[target_texts.len()].0;
1016    let mut segments = target.segments.clone();
1017    segments.extend(current.segments[tail_start..].iter().cloned());
1018    Some(PendingLayout { segments }.render())
1019}
1020
1021pub fn detect_shadow_open_items(doc: &str) -> Result<ShadowPendingReport> {
1022    let components = crate::component::parse(doc)?;
1023    let Some(backlog_component) = components
1024        .iter()
1025        .find(|component| crate::component::is_backlog_component(&component.name))
1026    else {
1027        return Ok(ShadowPendingReport::default());
1028    };
1029
1030    let (_, live_items, _) = parse_items(backlog_component.content(doc));
1031    let live_open_ids: HashSet<String> = live_items
1032        .into_iter()
1033        .filter(|item| !item.id.is_empty() && !item.is_done())
1034        .map(|item| item.id)
1035        .collect();
1036
1037    let excluded_ranges: Vec<(usize, usize)> = components
1038        .iter()
1039        .filter(|component| crate::component::is_tracked_work_component(&component.name))
1040        .map(|component| (component.open_start, component.close_end))
1041        .chain(compact_exchange_summary_ranges(doc, &components))
1042        .collect();
1043    let code_ranges = crate::component::find_code_ranges(doc);
1044
1045    let mut report = ShadowPendingReport::default();
1046    let mut seen_ids = HashSet::new();
1047    let mut offset = 0usize;
1048
1049    for (line_idx, raw_line) in doc.split_inclusive('\n').enumerate() {
1050        let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
1051        let line_start = offset;
1052        let line_end = offset + raw_line.len();
1053        offset = line_end;
1054
1055        if excluded_ranges
1056            .iter()
1057            .any(|(start, end)| line_start < *end && line_end > *start)
1058        {
1059            continue;
1060        }
1061        if code_ranges
1062            .iter()
1063            .any(|(start, end)| line_start < *end && line_end > *start)
1064        {
1065            continue;
1066        }
1067
1068        let Some(item) = parse_item_line(line) else {
1069            continue;
1070        };
1071        if item.id.is_empty() || item.is_done() || !seen_ids.insert(item.id.clone()) {
1072            continue;
1073        }
1074
1075        let shadow = ShadowPendingItem {
1076            id: item.id.clone(),
1077            text: item.text,
1078            line: line_idx + 1,
1079        };
1080        if live_open_ids.contains(&item.id) {
1081            report.duplicated_in_live_backlog.push(shadow);
1082        } else {
1083            report.shadow_only.push(shadow);
1084        }
1085    }
1086
1087    Ok(report)
1088}
1089
1090fn compact_exchange_summary_ranges<'a>(
1091    doc: &'a str,
1092    components: &'a [crate::component::Component],
1093) -> impl Iterator<Item = (usize, usize)> + 'a {
1094    components.iter().filter_map(|component| {
1095        if component.name != "exchange" {
1096            return None;
1097        }
1098
1099        let content = component.content(doc);
1100        let mut offset = 0usize;
1101        let mut summary_start = None;
1102        let mut summary_end = None;
1103        for raw_line in content.split_inclusive('\n') {
1104            let line = raw_line.strip_suffix('\n').unwrap_or(raw_line).trim();
1105            let abs_start = component.open_end + offset;
1106            offset += raw_line.len();
1107
1108            if summary_start.is_none() {
1109                if line == "### Session Summary" {
1110                    summary_start = Some(abs_start);
1111                }
1112                continue;
1113            }
1114
1115            if line.starts_with('❯')
1116                || line.starts_with("### Re:")
1117                || line.starts_with("#### Re:")
1118                || line.starts_with("##### Re:")
1119                || line.starts_with("<!-- agent:boundary:")
1120            {
1121                summary_end = Some(abs_start);
1122                break;
1123            }
1124        }
1125
1126        summary_start.map(|start| (start, summary_end.unwrap_or(component.close_start)))
1127    })
1128}
1129
1130#[derive(Debug, Clone, PartialEq, Eq)]
1131pub struct DroppedBacklogItem {
1132    pub id: String,
1133    pub text: String,
1134}
1135
1136impl DroppedBacklogItem {
1137    pub fn reference(&self) -> String {
1138        format!("#{}", self.id)
1139    }
1140}
1141
1142#[derive(Debug, Clone, PartialEq, Eq, Default)]
1143pub struct DroppedBacklogReport {
1144    pub dropped: Vec<DroppedBacklogItem>,
1145}
1146
1147/// Compare current document against a baseline to find open backlog items that
1148/// existed in the baseline but are completely absent from the current document.
1149///
1150/// "Completely absent" means the item's ID does not appear in:
1151/// - the live `agent:backlog` (any state: open, gated, or done)
1152/// - the `agent:icebox` component
1153/// - shadow/commented sections outside the live backlog
1154///
1155/// Items found in shadow sections are NOT reported here — the shadow guard
1156/// (`detect_shadow_open_items`) handles those separately.
1157///
1158/// `done_ids` allows callers to exclude items that were explicitly marked done
1159/// during the current cycle (from `cycle_state.pending_done_ids`).
1160#[allow(dead_code)]
1161pub fn detect_dropped_from_history(
1162    current_doc: &str,
1163    baseline_doc: &str,
1164    done_ids: &HashSet<String>,
1165) -> Result<DroppedBacklogReport> {
1166    detect_dropped_from_history_with_extra_current_ids(
1167        current_doc,
1168        baseline_doc,
1169        done_ids,
1170        &HashSet::new(),
1171    )
1172}
1173
1174pub fn detect_dropped_from_history_with_extra_current_ids(
1175    current_doc: &str,
1176    baseline_doc: &str,
1177    done_ids: &HashSet<String>,
1178    extra_current_ids: &HashSet<String>,
1179) -> Result<DroppedBacklogReport> {
1180    let baseline_components = crate::component::parse(baseline_doc)?;
1181    let Some(baseline_backlog) = baseline_components
1182        .iter()
1183        .find(|c| crate::component::is_backlog_component(&c.name))
1184    else {
1185        return Ok(DroppedBacklogReport::default());
1186    };
1187
1188    let (_, baseline_items, _) = parse_items(baseline_backlog.content(baseline_doc));
1189    let baseline_open: Vec<(String, String)> = baseline_items
1190        .into_iter()
1191        .filter(|item| !item.id.is_empty() && !item.is_done())
1192        .map(|item| (item.id, item.text))
1193        .collect();
1194
1195    if baseline_open.is_empty() {
1196        return Ok(DroppedBacklogReport::default());
1197    }
1198
1199    let current_components = crate::component::parse(current_doc)?;
1200
1201    let mut current_ids: HashSet<String> = HashSet::new();
1202
1203    for comp in &current_components {
1204        if crate::component::is_tracked_work_component(&comp.name) {
1205            let (_, items, _) = parse_items(comp.content(current_doc));
1206            for item in items {
1207                if !item.id.is_empty() {
1208                    current_ids.insert(item.id);
1209                }
1210            }
1211        } else if crate::component::is_backlog_done_component(&comp.name) {
1212            current_ids.extend(extract_pending_ids_from_text(comp.content(current_doc)));
1213        }
1214    }
1215    current_ids.extend(extra_current_ids.iter().cloned());
1216
1217    let excluded_ranges: Vec<(usize, usize)> = current_components
1218        .iter()
1219        .filter(|c| crate::component::is_tracked_work_component(&c.name))
1220        .map(|c| (c.open_start, c.close_end))
1221        .collect();
1222    let code_ranges = crate::component::find_code_ranges(current_doc);
1223
1224    let mut offset = 0usize;
1225    for raw_line in current_doc.split_inclusive('\n') {
1226        let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
1227        let line_start = offset;
1228        let line_end = offset + raw_line.len();
1229        offset = line_end;
1230
1231        if excluded_ranges
1232            .iter()
1233            .any(|(start, end)| line_start < *end && line_end > *start)
1234        {
1235            continue;
1236        }
1237        if code_ranges
1238            .iter()
1239            .any(|(start, end)| line_start < *end && line_end > *start)
1240        {
1241            continue;
1242        }
1243
1244        if let Some(item) = parse_item_line(line)
1245            && !item.id.is_empty()
1246        {
1247            current_ids.insert(item.id);
1248        }
1249    }
1250
1251    let mut dropped = Vec::new();
1252    for (id, text) in baseline_open {
1253        if done_ids.contains(&id) {
1254            continue;
1255        }
1256        if !current_ids.contains(&id) {
1257            dropped.push(DroppedBacklogItem { id, text });
1258        }
1259    }
1260
1261    Ok(DroppedBacklogReport { dropped })
1262}
1263
1264pub fn extract_pending_ids_from_text(text: &str) -> HashSet<String> {
1265    let mut ids = HashSet::new();
1266    let mut rest = text;
1267    while let Some(start) = rest.find("[#") {
1268        let after = &rest[start + 2..];
1269        let Some(end) = after.find(']') else {
1270            break;
1271        };
1272        let id = &after[..end];
1273        if is_valid_pending_id(id) {
1274            ids.insert(id.to_ascii_lowercase());
1275        }
1276        rest = &after[end + 1..];
1277    }
1278    ids
1279}
1280
1281/// Generate a stable 4-char base32 hash from `(text, doc_id, counter)`.
1282///
1283/// Backward-compat thin wrapper over [`generate_hash_n`] at width 4. Existing
1284/// docs keep their 4-char IDs on re-backfill because width-4 output is
1285/// bit-identical to the original formula. Kept in the public API for
1286/// backward compatibility and as the canonical entry point for width-4
1287/// hashing in tests.
1288#[allow(dead_code)]
1289pub fn generate_hash(text: &str, doc_id: &str, counter: u64) -> String {
1290    generate_hash_n(text, doc_id, counter, 4)
1291}
1292
1293/// Generate a stable variable-width base32 hash. `width` is clamped to `[4, 8]`
1294/// — the spec §1 ceiling on collision extension.
1295///
1296/// Width-4 output is bit-identical to the pre-#14z4 `generate_hash` formula,
1297/// so lazy backfill on existing docs is a no-op.
1298pub fn generate_hash_n(text: &str, doc_id: &str, counter: u64, width: usize) -> String {
1299    use sha2::{Digest, Sha256};
1300    let mut hasher = Sha256::new();
1301    hasher.update(text.as_bytes());
1302    hasher.update(b":");
1303    hasher.update(doc_id.as_bytes());
1304    hasher.update(b":");
1305    hasher.update(counter.to_le_bytes());
1306    let digest = hasher.finalize();
1307
1308    // Base32 lowercase alphabet (no 0/1/8/9 per crockford would complicate collisions;
1309    // stick with full a-z/0-9 subset for simplicity).
1310    const ALPHABET: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz"; // 32 chars
1311
1312    let width = width.clamp(4, 8);
1313    let mut out = String::with_capacity(width);
1314
1315    // First 4 chars: preserve the original bit packing so width-4 output is
1316    // stable across the #14z4 refactor. (bottom 20 bits of b0<<16 | b1<<8 | b2)
1317    let b0 = digest[0] as u32;
1318    let b1 = digest[1] as u32;
1319    let b2 = digest[2] as u32;
1320    let v: u32 = (b0 << 16) | (b1 << 8) | b2;
1321    out.push(ALPHABET[((v >> 15) & 0x1f) as usize] as char);
1322    out.push(ALPHABET[((v >> 10) & 0x1f) as usize] as char);
1323    out.push(ALPHABET[((v >> 5) & 0x1f) as usize] as char);
1324    out.push(ALPHABET[(v & 0x1f) as usize] as char);
1325
1326    // Extra chars (5..=8): draw from bytes 3..=5 of the digest. Same 5-bit
1327    // packing layout, starting from a fresh 24-bit window so layer N+1 is not
1328    // a mechanical continuation of layer N (prevents widening from aliasing
1329    // to a near-neighbor that also collides).
1330    if width > 4 {
1331        let e0 = digest[3] as u32;
1332        let e1 = digest[4] as u32;
1333        let e2 = digest[5] as u32;
1334        let extra: u32 = (e0 << 16) | (e1 << 8) | e2;
1335        for i in 0..(width - 4) {
1336            let shift = 15 - (i as u32) * 5;
1337            out.push(ALPHABET[((extra >> shift) & 0x1f) as usize] as char);
1338        }
1339    }
1340    out
1341}
1342
1343/// Assign a hash id that does not collide with `taken`.
1344///
1345/// Starts at width 4 and extends up to the spec §1 ceiling of 8. Counter
1346/// cycles within each width before widening — at width 4 that's ~1M values
1347/// before we touch width 5, so normal docs never widen in practice.
1348fn assign_unique_hash(text: &str, doc_id: &str, taken: &HashSet<String>) -> String {
1349    // Per-width retry budget: small because a single widening step gives
1350    // another 5 bits of entropy, which is a much bigger win than continuing
1351    // to spin at the old width.
1352    const RETRIES_PER_WIDTH: u64 = 4;
1353    let mut counter: u64 = 0;
1354    loop {
1355        // width = 4 + (counter / 4), clamped at 8 (spec §1 ceiling).
1356        // Once we hit width 8, keep spinning counter forever — at 40 bits of
1357        // entropy a further collision is effectively impossible in practice.
1358        let width = std::cmp::min(4 + (counter / RETRIES_PER_WIDTH) as usize, 8);
1359        let id = generate_hash_n(text, doc_id, counter, width);
1360        if !taken.contains(&id) {
1361            return id;
1362        }
1363        counter = counter.saturating_add(1);
1364    }
1365}
1366
1367/// Assign a nested subtask id using the parent id as a visible prefix.
1368///
1369/// Shape: `<parent_id>-<suffix>`, where `suffix` is a stable 4..=8 char hash.
1370fn assign_unique_child_hash(
1371    parent_id: &str,
1372    text: &str,
1373    doc_id: &str,
1374    taken: &HashSet<String>,
1375) -> String {
1376    const RETRIES_PER_WIDTH: u64 = 4;
1377    let seed = format!("{parent_id}:{text}");
1378    let mut counter: u64 = 0;
1379    loop {
1380        let width = std::cmp::min(4 + (counter / RETRIES_PER_WIDTH) as usize, 8);
1381        let suffix = generate_hash_n(&seed, doc_id, counter, width);
1382        let candidate = format!("{parent_id}-{suffix}");
1383        if !taken.contains(&candidate) {
1384            return candidate;
1385        }
1386        counter = counter.saturating_add(1);
1387    }
1388}
1389
1390fn split_indent(line: &str) -> (&str, &str) {
1391    let idx = line
1392        .char_indices()
1393        .find_map(|(idx, ch)| (!ch.is_whitespace()).then_some(idx))
1394        .unwrap_or(line.len());
1395    line.split_at(idx)
1396}
1397
1398fn normalize_nested_subtasks(
1399    continuation: &str,
1400    parent_id: &str,
1401    doc_id: &str,
1402    taken: &mut HashSet<String>,
1403) -> (String, bool) {
1404    if continuation.is_empty() {
1405        return (String::new(), false);
1406    }
1407
1408    let mut changed = false;
1409    let mut out = String::with_capacity(continuation.len());
1410    for raw_line in continuation.split_inclusive('\n') {
1411        let has_newline = raw_line.ends_with('\n');
1412        let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
1413        if !is_indented_continuation_line(line) {
1414            out.push_str(raw_line);
1415            continue;
1416        }
1417
1418        let (indent, trimmed) = split_indent(line);
1419        let Some(mut child) = parse_item_line(trimmed) else {
1420            out.push_str(raw_line);
1421            continue;
1422        };
1423
1424        let duplicate_existing_id = !child.id.is_empty() && taken.contains(&child.id);
1425        if child.id.is_empty() || duplicate_existing_id {
1426            child.id = assign_unique_child_hash(parent_id, &child.text, doc_id, taken);
1427            taken.insert(child.id.clone());
1428            changed = true;
1429        } else {
1430            taken.insert(child.id.clone());
1431        }
1432
1433        let rendered = child.render();
1434        if rendered != trimmed {
1435            changed = true;
1436        }
1437        out.push_str(indent);
1438        out.push_str(&rendered);
1439        if has_newline {
1440            out.push('\n');
1441        }
1442    }
1443    (out, changed)
1444}
1445
1446/// Lazy backfill: ensure every item has a hash id and a checkbox.
1447///
1448/// - Items missing a hash get a new one (guaranteed unique within the component).
1449/// - Checkboxes are normalized (default `[ ]`).
1450/// - Returns `(new_body, changed)`. `changed = false` when the body was already canonical.
1451pub fn backfill(body: &str, doc_id: &str, existing_ids: &HashSet<String>) -> (String, bool) {
1452    let layout = PendingLayout::parse(body);
1453    let items = layout.items();
1454    let mut taken: HashSet<String> = existing_ids.clone();
1455    for item in &items {
1456        if !item.id.is_empty() {
1457            taken.insert(item.id.clone());
1458        }
1459    }
1460
1461    let mut changed = false;
1462    let rewritten = layout.replace_items(|item| {
1463        let mut next = item.clone();
1464        if next.id.is_empty() {
1465            let id = assign_unique_hash(&next.text, doc_id, &taken);
1466            taken.insert(id.clone());
1467            next.id = id;
1468            changed = true;
1469        }
1470        let (continuation, nested_changed) =
1471            normalize_nested_subtasks(&next.continuation, &next.id, doc_id, &mut taken);
1472        if nested_changed {
1473            next.continuation = continuation;
1474            changed = true;
1475        }
1476        Some(next)
1477    });
1478
1479    let new_body = rewritten.render();
1480
1481    // Also mark as changed when the canonical render differs from the input
1482    // (e.g., legacy whitespace / missing checkbox normalization).
1483    if new_body != body {
1484        changed = true;
1485    }
1486    (new_body, changed)
1487}
1488
1489/// Reap `[x]` items. `[/]` (gated) items are never reaped.
1490/// Returns `(new_body, removed_ids)`.
1491#[allow(dead_code)]
1492pub fn reap(body: &str) -> Result<(String, Vec<String>)> {
1493    let (new_body, removed) = reap_with_items(body)?;
1494    let ids = removed.iter().map(|i| i.id.clone()).collect();
1495    Ok((new_body, ids))
1496}
1497
1498/// Reap `[x]` items and return the removed items (with text), not just ids.
1499/// Used by preflight to archive reaped items to an `agent:done` component.
1500pub fn reap_with_items(body: &str) -> Result<(String, Vec<PendingItem>)> {
1501    let layout = PendingLayout::parse(body);
1502    let items = layout.items();
1503    let missing_done: Vec<&PendingItem> = items
1504        .iter()
1505        .filter(|item| item.is_done() && item.id.is_empty())
1506        .collect();
1507    if !missing_done.is_empty() {
1508        let refs = missing_done
1509            .iter()
1510            .map(|item| format!("\"{}\"", item.text))
1511            .collect::<Vec<_>>()
1512            .join(", ");
1513        bail!(
1514            "pending reap requires ids for completed items; run backfill first (missing ids on done item(s): {})",
1515            refs
1516        );
1517    }
1518    let mut removed = Vec::new();
1519    let mut segments = Vec::with_capacity(layout.segments.len());
1520    let mut index = 0usize;
1521    while index < layout.segments.len() {
1522        match &layout.segments[index] {
1523            PendingSegment::Text(raw) => {
1524                segments.push(PendingSegment::Text(raw.clone()));
1525                index += 1;
1526            }
1527            PendingSegment::Item { item, has_newline } => {
1528                if !item.is_done() {
1529                    segments.push(PendingSegment::Item {
1530                        item: item.clone(),
1531                        has_newline: *has_newline,
1532                    });
1533                    index += 1;
1534                    continue;
1535                }
1536
1537                let mut removed_item = item.clone();
1538                let mut next_index = index + 1;
1539                while let Some(PendingSegment::Text(raw)) = layout.segments.get(next_index) {
1540                    if let Some((reap_text, keep_text)) = split_reapable_trailing_text_segment(raw)
1541                    {
1542                        removed_item.continuation.push_str(&reap_text);
1543                        next_index += 1;
1544                        if !keep_text.is_empty() {
1545                            segments.push(PendingSegment::Text(keep_text));
1546                            break;
1547                        }
1548                    } else {
1549                        segments.push(PendingSegment::Text(raw.clone()));
1550                        next_index += 1;
1551                        break;
1552                    }
1553                }
1554                if !removed_item.id.is_empty() {
1555                    removed.push(removed_item);
1556                }
1557                index = next_index;
1558            }
1559        }
1560    }
1561    let new_layout = PendingLayout { segments };
1562    if removed.is_empty() {
1563        return Ok((body.to_string(), removed));
1564    }
1565    let new_body = new_layout.render();
1566    Ok((new_body, removed))
1567}
1568
1569/// Remove specific tracked items by id while preserving surrounding non-item
1570/// structure in the source body. Returns `(remaining_body, moved_body, matched_ids)`.
1571pub fn extract_items_by_id(body: &str, ids: &[String]) -> Result<(String, String, Vec<String>)> {
1572    let requested: Vec<String> = ids.iter().map(|id| id.trim().to_lowercase()).collect();
1573    let mut matched_ids = Vec::new();
1574    let mut moved_items = Vec::new();
1575    let remaining = PendingLayout::parse(body).replace_items(|item| {
1576        if requested.contains(&item.id) {
1577            matched_ids.push(item.id.clone());
1578            moved_items.push(item.clone());
1579            None
1580        } else {
1581            Some(item.clone())
1582        }
1583    });
1584    let moved_body = render_items("", &moved_items, "");
1585    Ok((remaining.render(), moved_body, matched_ids))
1586}
1587
1588/// Detect reorder: returns `Some(current_order)` when id-sets match but order differs.
1589/// Returns `None` when id-sets differ or order is identical.
1590pub fn detect_reorder(snapshot_body: &str, current_body: &str) -> Option<Vec<String>> {
1591    let (_, snap_items, _) = parse_items(snapshot_body);
1592    let (_, cur_items, _) = parse_items(current_body);
1593
1594    let snap_ids: Vec<String> = snap_items
1595        .iter()
1596        .filter(|i| !i.id.is_empty())
1597        .map(|i| i.id.clone())
1598        .collect();
1599    let cur_ids: Vec<String> = cur_items
1600        .iter()
1601        .filter(|i| !i.id.is_empty())
1602        .map(|i| i.id.clone())
1603        .collect();
1604
1605    if snap_ids.len() != cur_ids.len() {
1606        return None;
1607    }
1608    let snap_set: HashSet<&String> = snap_ids.iter().collect();
1609    let cur_set: HashSet<&String> = cur_ids.iter().collect();
1610    if snap_set != cur_set {
1611        return None;
1612    }
1613    if snap_ids == cur_ids {
1614        return None;
1615    }
1616    Some(cur_ids)
1617}
1618
1619/// Insert a new item at the beginning of the body. Binary assigns hash and `[ ]`
1620/// (or `[/]` if `gated`).
1621/// Returns `(new_body, assigned_id)`.
1622pub fn op_add(body: &str, text: &str, doc_id: &str, gated: bool) -> Result<(String, String)> {
1623    let (custom_id, text) = parse_custom_id_prefix(text)?;
1624    let mut text = text.trim().to_string();
1625    if text.is_empty() {
1626        bail!("pending add: text must be non-empty");
1627    }
1628    if text.starts_with("[ ]")
1629        || text.starts_with("[/]")
1630        || text.starts_with("[x]")
1631        || text.starts_with("[X]")
1632    {
1633        bail!(
1634            "pending add: text must not start with a state marker ([ ], [/], [x]); use --pending-add-gated for gated items"
1635        );
1636    }
1637
1638    let mut inline_custom_id = None;
1639    if custom_id.is_none()
1640        && let Some((tag_id, cleaned)) = extract_inline_tag_id(&text)
1641    {
1642        inline_custom_id = Some(tag_id);
1643        text = cleaned;
1644    }
1645
1646    let mut layout = PendingLayout::parse(body);
1647    let items = layout.items();
1648
1649    // Dedup: reject if an item with identical text already exists.
1650    if items.iter().any(|i| i.text == text) {
1651        bail!("pending add: duplicate item text already exists: {}", text);
1652    }
1653
1654    let mut taken: HashSet<String> = items
1655        .iter()
1656        .filter(|i| !i.id.is_empty())
1657        .map(|i| i.id.clone())
1658        .collect();
1659
1660    let id = if let Some(custom_id) = custom_id {
1661        if taken.contains(&custom_id) {
1662            bail!("pending add: custom id already exists: {}", custom_id);
1663        }
1664        custom_id
1665    } else if let Some(inline_id) = inline_custom_id {
1666        if taken.contains(&inline_id) {
1667            bail!("pending add: inline tag id already exists: {}", inline_id);
1668        }
1669        inline_id
1670    } else {
1671        assign_unique_hash(&text, doc_id, &taken)
1672    };
1673    taken.insert(id.clone());
1674
1675    layout.insert_first_item(PendingItem {
1676        marker: PendingListMarker::Bullet,
1677        id: id.clone(),
1678        state: if gated {
1679            PendingState::Gated
1680        } else {
1681            PendingState::Open
1682        },
1683        gate_type: None,
1684        text,
1685        continuation: String::new(),
1686    });
1687    Ok((layout.render(), id))
1688}
1689
1690/// Mark an item `[x]` by id. Phase 1: state-machine validation lives in
1691/// the upcoming `pending_cmd` layer; this primitive forces Done unconditionally.
1692pub fn op_done(body: &str, id: &str) -> Result<String> {
1693    let id = normalize_pending_id(id);
1694    let mut found = false;
1695    let layout = PendingLayout::parse(body).replace_items(|item| {
1696        if item.id == id {
1697            found = true;
1698            let mut next = item.clone();
1699            next.state = PendingState::Done;
1700            next.gate_type = None;
1701            Some(next)
1702        } else {
1703            Some(item.clone())
1704        }
1705    });
1706    if !found {
1707        return Err(anyhow!("pending done: no item with id [#{}]", id));
1708    }
1709    Ok(layout.render())
1710}
1711
1712/// Transition an item to `Gated` (`[/]`) by id.
1713///
1714/// - `Open → Gated`: state mutates.
1715/// - `Gated → Gated`: idempotent no-op (returns body unchanged).
1716/// - `Done → *`: error (cannot re-gate a completed item).
1717pub fn op_gate(body: &str, id: &str) -> Result<String> {
1718    let id = normalize_pending_id(id);
1719    let layout = PendingLayout::parse(body);
1720    let current = layout
1721        .items()
1722        .into_iter()
1723        .find(|item| item.id == id)
1724        .ok_or_else(|| anyhow!("pending gate: no item with id [#{}]", id))?;
1725    let transition = validate_transition(current.state, PendingOp::Gate)?;
1726    let mut found = false;
1727    let mut changed = false;
1728    let rewritten = layout.replace_items(|item| {
1729        if item.id != id {
1730            return Some(item.clone());
1731        }
1732        found = true;
1733        match transition {
1734            TransitionResult::Transition(next) => {
1735                changed = true;
1736                let mut next_item = item.clone();
1737                next_item.state = next;
1738                Some(next_item)
1739            }
1740            TransitionResult::NoOp => Some(item.clone()),
1741        }
1742    });
1743    debug_assert!(found, "validated item must be present during replacement");
1744    if !changed {
1745        Ok(body.to_string())
1746    } else {
1747        Ok(rewritten.render())
1748    }
1749}
1750
1751/// Transition an item to `Open` (`[ ]`) by id.
1752///
1753/// - `Gated → Open`: state mutates.
1754/// - `Open → *`: error (no source `[/]`).
1755/// - `Done → *`: error (cannot ungate a completed item).
1756pub fn op_ungate(body: &str, id: &str) -> Result<String> {
1757    let id = normalize_pending_id(id);
1758    let layout = PendingLayout::parse(body);
1759    let current = layout
1760        .items()
1761        .into_iter()
1762        .find(|item| item.id == id)
1763        .ok_or_else(|| anyhow!("pending ungate: no item with id [#{}]", id))?;
1764    let transition = validate_transition(current.state, PendingOp::Ungate)?;
1765    let mut found = false;
1766    let mut changed = false;
1767    let rewritten = layout.replace_items(|item| {
1768        if item.id != id {
1769            return Some(item.clone());
1770        }
1771        found = true;
1772        match transition {
1773            TransitionResult::Transition(next) => {
1774                changed = true;
1775                let mut next_item = item.clone();
1776                next_item.state = next;
1777                next_item.gate_type = None;
1778                Some(next_item)
1779            }
1780            TransitionResult::NoOp => Some(item.clone()),
1781        }
1782    });
1783    debug_assert!(found, "validated item must be present during replacement");
1784    if !changed {
1785        Ok(body.to_string())
1786    } else {
1787        Ok(rewritten.render())
1788    }
1789}
1790
1791/// Edit an item's text, preserving its hash id.
1792pub fn op_edit(body: &str, id: &str, new_text: &str) -> Result<String> {
1793    let new_text = new_text.trim();
1794    if new_text.is_empty() {
1795        bail!("pending edit: text must be non-empty");
1796    }
1797    let (parsed_text, parsed_continuation) = parse_pending_edit_payload(new_text)?;
1798    let id = normalize_pending_id(id);
1799    let mut found = false;
1800    let layout = PendingLayout::parse(body).replace_items(|item| {
1801        if item.id == id {
1802            found = true;
1803            let mut next = item.clone();
1804            next.text = parsed_text.clone();
1805            next.continuation = parsed_continuation.clone();
1806            Some(next)
1807        } else {
1808            Some(item.clone())
1809        }
1810    });
1811    if !found {
1812        return Err(anyhow!("pending edit: no item with id [#{}]", id));
1813    }
1814    Ok(layout.render())
1815}
1816
1817/// Clear all items from the body. Non-item lines, including headers, are preserved.
1818pub fn op_clear(body: &str) -> Result<String> {
1819    let cleared = PendingLayout::parse(body).replace_items(|_| None);
1820    Ok(cleared.render())
1821}
1822
1823/// Reorder items by id. Listed ids come first (in the given order); unlisted ids
1824/// keep their relative order and follow.
1825pub fn op_reorder(body: &str, ids: &[String]) -> Result<String> {
1826    let layout = PendingLayout::parse(body);
1827    let items = layout.items();
1828    let requested: Vec<String> = ids.iter().map(|s| normalize_pending_id(s)).collect();
1829    for id in &requested {
1830        if !items.iter().any(|i| i.id == *id) {
1831            bail!("pending reorder: no item with id [#{}]", id);
1832        }
1833    }
1834    let mut remaining: Vec<PendingItem> = items.clone();
1835    let mut ordered: Vec<PendingItem> = Vec::new();
1836    for id in &requested {
1837        if let Some(pos) = remaining.iter().position(|i| i.id == *id) {
1838            ordered.push(remaining.remove(pos));
1839        }
1840    }
1841    ordered.extend(remaining);
1842    let mut ordered_iter = ordered.into_iter();
1843    let reordered = layout.replace_items(|_| ordered_iter.next());
1844    Ok(reordered.render())
1845}
1846
1847/// Resolve all gated items matching a typed gate. Finds items with `[/<gate_type>]`
1848/// and flips them to `[x]`. Returns `(new_body, resolved_ids)`.
1849///
1850/// Only matches typed gates — untyped `[/]` items are never resolved by this op.
1851pub fn op_resolve_gate(body: &str, gate_type: &str) -> (String, Vec<String>) {
1852    let gt = gate_type.trim().to_lowercase();
1853    let mut resolved = Vec::new();
1854    let layout = PendingLayout::parse(body).replace_items(|item| {
1855        if item.state == PendingState::Gated && item.gate_type.as_deref() == Some(gt.as_str()) {
1856            let mut next = item.clone();
1857            next.state = PendingState::Done;
1858            next.gate_type = None;
1859            resolved.push(next.id.clone());
1860            Some(next)
1861        } else {
1862            Some(item.clone())
1863        }
1864    });
1865    (layout.render(), resolved)
1866}
1867
1868/// Set a typed gate on a gated item. The item must already be in `[/]` state.
1869/// Transitions `[/] → [/<gate_type>]`. Errors if the item is not gated.
1870pub fn op_set_gate_type(body: &str, id: &str, gate_type: &str) -> Result<String> {
1871    let id = normalize_pending_id(id);
1872    let gt = gate_type.trim().to_lowercase();
1873    if gt.is_empty()
1874        || !gt
1875            .chars()
1876            .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
1877    {
1878        bail!("invalid gate type: must be alphanumeric/dash/underscore");
1879    }
1880    let layout = PendingLayout::parse(body);
1881    let current = layout
1882        .items()
1883        .into_iter()
1884        .find(|item| item.id == id)
1885        .ok_or_else(|| anyhow!("pending set-gate-type: no item with id [#{}]", id))?;
1886    if current.state != PendingState::Gated {
1887        bail!(
1888            "pending set-gate-type: item [#{}] must be gated ([/]) to set a typed gate, current state: [{}]",
1889            id,
1890            current.state.box_char()
1891        );
1892    }
1893    let mut found = false;
1894    let rewritten = layout.replace_items(|item| {
1895        if item.id != id {
1896            return Some(item.clone());
1897        }
1898        found = true;
1899        let mut next = item.clone();
1900        next.gate_type = Some(gt.clone());
1901        Some(next)
1902    });
1903    debug_assert!(found, "validated item must be present during replacement");
1904    Ok(rewritten.render())
1905}
1906
1907#[cfg(test)]
1908mod tests {
1909    use super::*;
1910
1911    const DOC_ID: &str = "test-doc";
1912
1913    fn ids() -> HashSet<String> {
1914        HashSet::new()
1915    }
1916
1917    #[test]
1918    fn parse_empty_body() {
1919        let (p, items, post) = parse_items("");
1920        assert_eq!(p, "");
1921        assert!(items.is_empty());
1922        assert_eq!(post, "");
1923    }
1924
1925    #[test]
1926    fn parse_fully_migrated() {
1927        let body = "- [ ] [#a3f2] first\n- [x] [#b1c4] second\n";
1928        let (_, items, _) = parse_items(body);
1929        assert_eq!(items.len(), 2);
1930        assert_eq!(items[0].marker, PendingListMarker::Bullet);
1931        assert_eq!(items[0].id, "a3f2");
1932        assert_eq!(items[0].state, PendingState::Open);
1933        assert_eq!(items[0].text, "first");
1934        assert_eq!(items[1].id, "b1c4");
1935        assert_eq!(items[1].state, PendingState::Done);
1936    }
1937
1938    #[test]
1939    fn detects_malformed_tracked_checklist_lines() {
1940        let body = concat!(
1941            "_- [ ] [#pcops] damaged prefix\n",
1942            "- [ ] [#keep1] valid item\n",
1943            "plain note mentioning [#note1]\n",
1944        );
1945
1946        let malformed = detect_malformed_item_lines(body);
1947
1948        assert_eq!(malformed.len(), 1);
1949        assert_eq!(malformed[0].id, "pcops");
1950        assert_eq!(malformed[0].line, 1);
1951        assert!(malformed[0].text.contains("damaged prefix"));
1952    }
1953
1954    #[test]
1955    fn parse_hyphenated_id() {
1956        let body = "- [ ] [#tmuxcrash-abcd] child task\n";
1957        let (_, items, _) = parse_items(body);
1958        assert_eq!(items.len(), 1);
1959        assert_eq!(items[0].id, "tmuxcrash-abcd");
1960        assert_eq!(items[0].text, "child task");
1961    }
1962
1963    #[test]
1964    fn parse_ordered_items() {
1965        let body = "1. [ ] [#a3f2] first\n2. [x] [#b1c4] second\n";
1966        let (_, items, _) = parse_items(body);
1967        assert_eq!(items.len(), 2);
1968        assert_eq!(items[0].marker, PendingListMarker::Ordered(1));
1969        assert_eq!(items[0].id, "a3f2");
1970        assert_eq!(items[1].marker, PendingListMarker::Ordered(2));
1971        assert_eq!(items[1].state, PendingState::Done);
1972    }
1973
1974    #[test]
1975    fn parse_gated_state() {
1976        let body = "- [/] [#eg0w] CommitLock — gate: v0.32.5\n";
1977        let (_, items, _) = parse_items(body);
1978        assert_eq!(items.len(), 1);
1979        assert_eq!(items[0].state, PendingState::Gated);
1980        assert_eq!(items[0].id, "eg0w");
1981        assert_eq!(items[0].text, "CommitLock — gate: v0.32.5");
1982    }
1983
1984    #[test]
1985    fn parse_all_three_states() {
1986        let body = "- [ ] [#a3f2] open\n- [/] [#b1c4] gated\n- [x] [#c9e0] done\n";
1987        let (_, items, _) = parse_items(body);
1988        assert_eq!(items[0].state, PendingState::Open);
1989        assert_eq!(items[1].state, PendingState::Gated);
1990        assert_eq!(items[2].state, PendingState::Done);
1991    }
1992
1993    #[test]
1994    fn parse_checkbox_only_no_id() {
1995        let body = "- [ ] just text\n- [x] done item\n";
1996        let (_, items, _) = parse_items(body);
1997        assert_eq!(items.len(), 2);
1998        assert_eq!(items[0].id, "");
1999        assert_eq!(items[0].text, "just text");
2000        assert_eq!(items[1].state, PendingState::Done);
2001    }
2002
2003    #[test]
2004    fn parse_legacy_no_checkbox() {
2005        let body = "- legacy one\n- legacy two\n";
2006        let (_, items, _) = parse_items(body);
2007        assert_eq!(items.len(), 2);
2008        assert_eq!(items[0].text, "legacy one");
2009        assert_eq!(items[0].state, PendingState::Open);
2010        assert_eq!(items[0].id, "");
2011    }
2012
2013    #[test]
2014    fn parse_mixed() {
2015        let body = "- [ ] [#a3f2] migrated\n- [ ] partial\n- legacy\n";
2016        let (_, items, _) = parse_items(body);
2017        assert_eq!(items.len(), 3);
2018        assert_eq!(items[0].id, "a3f2");
2019        assert_eq!(items[1].id, "");
2020        assert_eq!(items[1].text, "partial");
2021        assert_eq!(items[2].text, "legacy");
2022    }
2023
2024    #[test]
2025    fn parse_nested_lines_attach_to_parent_item() {
2026        let body = concat!(
2027            "- [ ] [#a3f2] parent task\n",
2028            "  - dependency one\n",
2029            "  - dependency two\n",
2030            "- [ ] [#b1c4] sibling task\n"
2031        );
2032        let (_, items, _) = parse_items(body);
2033        assert_eq!(items.len(), 2);
2034        assert_eq!(items[0].id, "a3f2");
2035        assert_eq!(items[0].text, "parent task");
2036        assert_eq!(
2037            items[0].continuation,
2038            "  - dependency one\n  - dependency two\n"
2039        );
2040        assert_eq!(items[1].id, "b1c4");
2041        assert!(items[1].continuation.is_empty());
2042    }
2043
2044    #[test]
2045    fn parse_nested_lines_attach_to_ordered_parent_item() {
2046        let body = concat!(
2047            "1. [ ] [#a3f2] parent task\n",
2048            "   1. dependency one\n",
2049            "   2. dependency two\n",
2050            "2. [ ] [#b1c4] sibling task\n"
2051        );
2052        let (_, items, _) = parse_items(body);
2053        assert_eq!(items.len(), 2);
2054        assert_eq!(items[0].marker, PendingListMarker::Ordered(1));
2055        assert_eq!(
2056            items[0].continuation,
2057            "   1. dependency one\n   2. dependency two\n"
2058        );
2059        assert_eq!(items[1].marker, PendingListMarker::Ordered(2));
2060    }
2061
2062    #[test]
2063    fn render_roundtrip_canonical() {
2064        let body = "- [ ] [#a3f2] first\n- [x] [#b1c4] second\n";
2065        let (p, items, post) = parse_items(body);
2066        let out = render_items(&p, &items, &post);
2067        assert_eq!(out, body);
2068    }
2069
2070    #[test]
2071    fn render_roundtrip_all_three_states() {
2072        let body = "- [ ] [#a3f2] open\n- [/] [#b1c4] gated — gate: v0.32.5\n- [x] [#c9e0] done\n";
2073        let (p, items, post) = parse_items(body);
2074        let out = render_items(&p, &items, &post);
2075        assert_eq!(out, body);
2076    }
2077
2078    #[test]
2079    fn render_roundtrip_ordered_list() {
2080        let body = "1. [ ] [#a3f2] open\n2. [/] [#b1c4] gated\n3. [x] [#c9e0] done\n";
2081        let (p, items, post) = parse_items(body);
2082        let out = render_items(&p, &items, &post);
2083        assert_eq!(out, body);
2084    }
2085
2086    #[test]
2087    fn render_emits_slash_for_gated() {
2088        let item = PendingItem {
2089            marker: PendingListMarker::Bullet,
2090            id: "eg0w".to_string(),
2091            state: PendingState::Gated,
2092            gate_type: None,
2093            text: "CommitLock".to_string(),
2094            continuation: String::new(),
2095        };
2096        assert_eq!(item.render(), "- [/] [#eg0w] CommitLock");
2097    }
2098
2099    #[test]
2100    fn backfill_adds_hashes() {
2101        let body = "- legacy one\n- legacy two\n";
2102        let (new_body, changed) = backfill(body, DOC_ID, &ids());
2103        assert!(changed);
2104        let (_, items, _) = parse_items(&new_body);
2105        assert_eq!(items.len(), 2);
2106        assert!(!items[0].id.is_empty());
2107        assert!(!items[1].id.is_empty());
2108        assert_ne!(items[0].id, items[1].id);
2109        assert!(new_body.contains("- [ ] [#"));
2110    }
2111
2112    #[test]
2113    fn backfill_idempotent() {
2114        let body = "- [ ] [#a3f2] first\n";
2115        let (new_body, changed) = backfill(body, DOC_ID, &ids());
2116        assert!(!changed, "fully-migrated body should not change");
2117        assert_eq!(new_body, body);
2118    }
2119
2120    #[test]
2121    fn backfill_normalizes_checkbox_only() {
2122        let body = "- [ ] no id here\n";
2123        let (new_body, changed) = backfill(body, DOC_ID, &ids());
2124        assert!(changed);
2125        assert!(new_body.contains("[#"));
2126    }
2127
2128    #[test]
2129    fn backfill_never_inserts_gated() {
2130        // Legacy items with no checkbox must default to Open `[ ]`,
2131        // never Gated `[/]`. Gated state is always operator-explicit.
2132        let body = "- legacy item awaiting v0.32.5\n";
2133        let (new_body, _) = backfill(body, DOC_ID, &ids());
2134        assert!(new_body.contains("- [ ] "));
2135        assert!(!new_body.contains("- [/] "));
2136    }
2137
2138    #[test]
2139    fn backfill_preserves_existing_gated() {
2140        let body = "- [/] [#eg0w] CommitLock — gate: v0.32.5\n";
2141        let (new_body, changed) = backfill(body, DOC_ID, &ids());
2142        assert!(!changed);
2143        assert_eq!(new_body, body);
2144    }
2145
2146    #[test]
2147    fn backfill_preserves_interleaved_headers_and_blank_lines() {
2148        let body = concat!(
2149            "### Active\n",
2150            "- legacy one\n",
2151            "\n",
2152            "### Later\n",
2153            "- [ ] [#keep1] keep section\n"
2154        );
2155        let (new_body, changed) = backfill(body, DOC_ID, &ids());
2156        assert!(changed);
2157        assert!(new_body.contains("### Active"));
2158        assert!(new_body.contains("\n\n### Later\n"));
2159        assert!(new_body.contains("[#keep1] keep section"));
2160        let lines: Vec<&str> = new_body.lines().collect();
2161        assert_eq!(lines[0], "### Active");
2162        assert!(lines[1].starts_with("- [ ] [#"));
2163        assert_eq!(lines[3], "### Later");
2164    }
2165
2166    #[test]
2167    fn backfill_assigns_nested_subtask_ids_prefixed_by_parent() {
2168        let body = concat!(
2169            "- parent task\n",
2170            "  - child dependency\n",
2171            "  - child subtask\n",
2172            "- sibling task\n"
2173        );
2174        let (new_body, changed) = backfill(body, DOC_ID, &ids());
2175        assert!(changed);
2176        assert_eq!(new_body.matches("[#").count(), 4, "got: {new_body}");
2177        assert!(new_body.contains("  - [ ] [#"));
2178        let lines: Vec<&str> = new_body.lines().collect();
2179        let parent_line = lines[0];
2180        let parent_id = parent_line
2181            .split("[#")
2182            .nth(1)
2183            .and_then(|rest| rest.split(']').next())
2184            .expect("parent id");
2185        assert!(
2186            lines[1].contains(&format!("[#{}-", parent_id)),
2187            "expected first child id prefixed by parent id, got: {}",
2188            lines[1]
2189        );
2190        assert!(
2191            lines[2].contains(&format!("[#{}-", parent_id)),
2192            "expected second child id prefixed by parent id, got: {}",
2193            lines[2]
2194        );
2195    }
2196
2197    #[test]
2198    fn backfill_preserves_existing_nested_subtask_ids() {
2199        let body = concat!(
2200            "- [ ] [#tmuxcrash] parent task\n",
2201            "  - [ ] [#tmuxcrash-abcd] child dependency\n"
2202        );
2203        let (new_body, changed) = backfill(body, DOC_ID, &ids());
2204        assert!(!changed);
2205        assert_eq!(new_body, body);
2206    }
2207
2208    #[test]
2209    fn backfill_reassigns_duplicate_existing_nested_subtask_ids() {
2210        let body = concat!(
2211            "- [ ] [#tmuxcrash] parent task\n",
2212            "  - [ ] [#tmuxcrash-abcd] child dependency\n",
2213            "  - [ ] [#tmuxcrash-abcd] child subtask\n"
2214        );
2215        let (new_body, changed) = backfill(body, DOC_ID, &ids());
2216        assert!(changed);
2217        let lines: Vec<&str> = new_body.lines().collect();
2218        assert_eq!(lines.len(), 3, "got: {new_body}");
2219        assert!(lines[1].contains("[#tmuxcrash-abcd]"));
2220        let second_child_id = lines[2]
2221            .split("[#")
2222            .nth(1)
2223            .and_then(|rest| rest.split(']').next())
2224            .expect("second child id");
2225        assert_ne!(second_child_id, "tmuxcrash-abcd");
2226        assert!(second_child_id.starts_with("tmuxcrash-"));
2227    }
2228
2229    #[test]
2230    fn backfill_preserves_ordered_parent_items() {
2231        let body = concat!("1. legacy one\n", "2. [ ] [#keep1] keep section\n");
2232        let (new_body, changed) = backfill(body, DOC_ID, &ids());
2233        assert!(changed);
2234        assert!(new_body.starts_with("1. [ ] [#"));
2235        assert!(new_body.contains("2. [ ] [#keep1] keep section"));
2236    }
2237
2238    #[test]
2239    fn reap_skips_gated() {
2240        let body = "- [/] [#eg0w] gated\n- [x] [#c9e0] done\n";
2241        let (new_body, removed) = reap(body).unwrap();
2242        assert_eq!(removed, vec!["c9e0"]);
2243        assert!(new_body.contains("[#eg0w]"));
2244        assert!(!new_body.contains("[#c9e0]"));
2245    }
2246
2247    #[test]
2248    fn reap_removes_checked() {
2249        let body = "- [ ] [#a3f2] keep\n- [x] [#b1c4] drop\n- [ ] [#c5d6] keep2\n";
2250        let (new_body, removed) = reap(body).unwrap();
2251        assert_eq!(removed, vec!["b1c4"]);
2252        assert!(new_body.contains("a3f2"));
2253        assert!(!new_body.contains("b1c4"));
2254        assert!(new_body.contains("c5d6"));
2255    }
2256
2257    #[test]
2258    fn reap_removes_flush_left_spill_with_completed_parent() {
2259        let body = concat!(
2260            "- [x] [#b1c4] drop\n",
2261            "Commands:\n",
2262            "  cargo test -p agent-doc pending::\n",
2263            "Diff:\n",
2264            "@@ -1 +1 @@\n",
2265            "- [ ] [#c5d6] keep\n"
2266        );
2267        let (new_body, removed) = reap(body).unwrap();
2268        assert_eq!(removed, vec!["b1c4"]);
2269        assert!(!new_body.contains("[#b1c4]"));
2270        assert!(!new_body.contains("Commands:"));
2271        assert!(!new_body.contains("@@ -1 +1 @@"));
2272        assert!(new_body.contains("- [ ] [#c5d6] keep"));
2273    }
2274
2275    #[test]
2276    fn reap_preserves_following_heading_text() {
2277        let body = concat!(
2278            "- [x] [#b1c4] drop\n",
2279            "\n",
2280            "## Next Group\n",
2281            "- [ ] [#c5d6] keep\n"
2282        );
2283        let (new_body, removed) = reap(body).unwrap();
2284        assert_eq!(removed, vec!["b1c4"]);
2285        assert!(!new_body.contains("[#b1c4]"));
2286        assert!(new_body.contains("## Next Group"));
2287        assert!(new_body.contains("- [ ] [#c5d6] keep"));
2288    }
2289
2290    #[test]
2291    fn reap_noop_when_none_checked() {
2292        let body = "- [ ] [#a3f2] keep\n";
2293        let (new_body, removed) = reap(body).unwrap();
2294        assert!(removed.is_empty());
2295        assert_eq!(new_body, body);
2296    }
2297
2298    #[test]
2299    fn reap_errors_when_completed_item_is_missing_id() {
2300        let body = "- [x] legacy done without id\n- [ ] [#keep1] keep\n";
2301        let err = reap(body).unwrap_err();
2302        assert!(
2303            err.to_string()
2304                .contains("pending reap requires ids for completed items"),
2305            "unexpected error: {err}"
2306        );
2307    }
2308
2309    #[test]
2310    fn reap_with_items_archives_malformed_flush_left_spill_with_parent() {
2311        let body = concat!(
2312            "- [x] [#b1c4] drop\n",
2313            "Commands:\n",
2314            "  cargo test -p agent-doc pending::\n"
2315        );
2316        let (_new_body, removed) = reap_with_items(body).unwrap();
2317        assert_eq!(removed.len(), 1);
2318        assert_eq!(removed[0].id, "b1c4");
2319        assert_eq!(
2320            removed[0].continuation,
2321            "Commands:\n  cargo test -p agent-doc pending::\n"
2322        );
2323    }
2324
2325    #[test]
2326    fn detect_reorder_same_set_different_order() {
2327        let snap = "- [ ] [#a1b2] one\n- [ ] [#c3d4] two\n";
2328        let cur = "- [ ] [#c3d4] two\n- [ ] [#a1b2] one\n";
2329        let result = detect_reorder(snap, cur);
2330        assert_eq!(result, Some(vec!["c3d4".to_string(), "a1b2".to_string()]));
2331    }
2332
2333    #[test]
2334    fn detect_reorder_none_when_sets_differ() {
2335        let snap = "- [ ] [#a1b2] one\n";
2336        let cur = "- [ ] [#a1b2] one\n- [ ] [#c3d4] two\n";
2337        assert_eq!(detect_reorder(snap, cur), None);
2338    }
2339
2340    #[test]
2341    fn detect_reorder_none_when_same_order() {
2342        let snap = "- [ ] [#a1b2] one\n- [ ] [#c3d4] two\n";
2343        assert_eq!(detect_reorder(snap, snap), None);
2344    }
2345
2346    #[test]
2347    fn op_add_inserts_new_item_with_hash() {
2348        let body = "";
2349        let (new_body, id) = op_add(body, "first task", DOC_ID, false).unwrap();
2350        assert!(new_body.contains("- [ ] [#"));
2351        assert!(new_body.contains("first task"));
2352        assert!(!id.is_empty());
2353    }
2354
2355    #[test]
2356    fn op_add_prepends_before_existing_items() {
2357        let body = "- [ ] [#a1b2] existing task\n- [ ] [#c3d4] later task\n";
2358        let (new_body, _id) = op_add(body, "new first task", DOC_ID, false).unwrap();
2359        let lines: Vec<&str> = new_body.lines().collect();
2360        assert!(
2361            lines[0].contains("new first task"),
2362            "expected new item first, got: {}",
2363            new_body
2364        );
2365        assert!(
2366            lines[1].contains("existing task"),
2367            "expected previous first item second, got: {}",
2368            new_body
2369        );
2370    }
2371
2372    #[test]
2373    fn op_add_preserves_section_headers() {
2374        let body = concat!(
2375            "### Active\n",
2376            "- [ ] [#a1b2] existing task\n",
2377            "### Later\n",
2378            "- [ ] [#c3d4] later task\n"
2379        );
2380        let (new_body, _id) = op_add(body, "new first task", DOC_ID, false).unwrap();
2381        let lines: Vec<&str> = new_body.lines().collect();
2382        assert_eq!(lines[0], "### Active");
2383        assert!(lines[1].contains("new first task"), "got: {}", new_body);
2384        assert!(lines[2].contains("existing task"), "got: {}", new_body);
2385        assert_eq!(lines[3], "### Later");
2386        assert!(lines[4].contains("later task"), "got: {}", new_body);
2387    }
2388
2389    #[test]
2390    fn op_append_items_preserves_order_after_existing_items() {
2391        let body = concat!(
2392            "### Active\n",
2393            "- [ ] [#a1b2] existing task\n",
2394            "\n",
2395            "### Notes\n",
2396            "Keep this note.\n",
2397        );
2398        let appended = vec![
2399            PendingItem {
2400                marker: PendingListMarker::Bullet,
2401                id: "b2c3".to_string(),
2402                state: PendingState::Open,
2403                gate_type: None,
2404                text: "first appended task".to_string(),
2405                continuation: String::new(),
2406            },
2407            PendingItem {
2408                marker: PendingListMarker::Bullet,
2409                id: "d4e5".to_string(),
2410                state: PendingState::Gated,
2411                gate_type: Some("release".to_string()),
2412                text: "second appended task".to_string(),
2413                continuation: String::new(),
2414            },
2415        ];
2416
2417        let new_body = op_append_items(body, &appended);
2418
2419        assert_eq!(
2420            new_body,
2421            concat!(
2422                "### Active\n",
2423                "- [ ] [#a1b2] existing task\n",
2424                "- [ ] [#b2c3] first appended task\n",
2425                "- [/release] [#d4e5] second appended task\n",
2426                "\n",
2427                "### Notes\n",
2428                "Keep this note.\n",
2429            )
2430        );
2431    }
2432
2433    #[test]
2434    fn op_add_renumbers_ordered_lists() {
2435        let body = "1. [ ] [#a1b2] existing task\n2. [ ] [#c3d4] later task\n";
2436        let (new_body, _id) = op_add(body, "new first task", DOC_ID, false).unwrap();
2437        let lines: Vec<&str> = new_body.lines().collect();
2438        assert!(lines[0].starts_with("1. "), "got: {}", new_body);
2439        assert!(lines[0].contains("new first task"), "got: {}", new_body);
2440        assert!(lines[1].starts_with("2. "), "got: {}", new_body);
2441        assert!(lines[1].contains("existing task"), "got: {}", new_body);
2442        assert!(lines[2].starts_with("3. "), "got: {}", new_body);
2443        assert!(lines[2].contains("later task"), "got: {}", new_body);
2444    }
2445
2446    #[test]
2447    fn op_add_accepts_custom_id_prefix() {
2448        let (new_body, id) = op_add("", "id=ship1 release checklist", DOC_ID, false).unwrap();
2449        assert_eq!(id, "ship1");
2450        assert!(new_body.contains("- [ ] [#ship1] release checklist"));
2451    }
2452
2453    #[test]
2454    fn op_add_accepts_custom_id_prefix_with_hash_marker() {
2455        let (new_body, id) = op_add("", "id=#ship1 release checklist", DOC_ID, false).unwrap();
2456        assert_eq!(id, "ship1");
2457        assert!(new_body.contains("- [ ] [#ship1] release checklist"));
2458    }
2459
2460    #[test]
2461    fn op_add_accepts_bracketed_custom_id_prefix() {
2462        let (new_body, id) = op_add("", "[#ship1] release checklist", DOC_ID, false).unwrap();
2463        assert_eq!(id, "ship1");
2464        assert!(new_body.contains("- [ ] [#ship1] release checklist"));
2465    }
2466
2467    #[test]
2468    fn op_add_accepts_long_bracketed_custom_id_prefix() {
2469        let (new_body, id) = op_add("", "[#sdig2matrix] release checklist", DOC_ID, false).unwrap();
2470        assert_eq!(id, "sdig2matrix");
2471        assert!(new_body.contains("- [ ] [#sdig2matrix] release checklist"));
2472    }
2473
2474    #[test]
2475    fn op_add_accepts_hyphenated_custom_id_prefix() {
2476        let (new_body, id) =
2477            op_add("", "id=tmuxcrash-abcd release checklist", DOC_ID, false).unwrap();
2478        assert_eq!(id, "tmuxcrash-abcd");
2479        assert!(new_body.contains("- [ ] [#tmuxcrash-abcd] release checklist"));
2480    }
2481
2482    #[test]
2483    fn op_add_accepts_hyphenated_bracketed_custom_id_prefix() {
2484        let (new_body, id) =
2485            op_add("", "[#tmuxcrash-abcd] release checklist", DOC_ID, false).unwrap();
2486        assert_eq!(id, "tmuxcrash-abcd");
2487        assert!(new_body.contains("- [ ] [#tmuxcrash-abcd] release checklist"));
2488    }
2489
2490    #[test]
2491    fn op_add_rejects_bare_bracket_placeholder_prefix() {
2492        let err = op_add("", "[#] release checklist", DOC_ID, false).unwrap_err();
2493        assert!(format!("{}", err).contains("bare `[#]` placeholder"));
2494    }
2495
2496    #[test]
2497    fn op_add_rejects_empty_explicit_custom_id_prefix() {
2498        let err = op_add("", "id=  release checklist", DOC_ID, false).unwrap_err();
2499        assert!(format!("{}", err).contains("empty custom id"));
2500    }
2501
2502    #[test]
2503    fn op_add_rejects_duplicate_custom_id_prefix() {
2504        let body = "- [ ] [#ship1] existing task\n";
2505        let err = op_add(body, "id=ship1 new task", DOC_ID, false).unwrap_err();
2506        assert!(format!("{}", err).contains("custom id already exists"));
2507    }
2508
2509    #[test]
2510    fn op_add_rejects_missing_text_after_custom_id_prefix() {
2511        let err = op_add("", "id=ship1", DOC_ID, false).unwrap_err();
2512        assert!(format!("{}", err).contains("custom id prefix must be followed by item text"));
2513    }
2514
2515    #[test]
2516    fn op_add_rejects_missing_text_after_bracketed_custom_id_prefix() {
2517        let err = op_add("", "[#ship1]", DOC_ID, false).unwrap_err();
2518        assert!(
2519            format!("{}", err).contains("bracketed custom id prefix must be followed by item text")
2520        );
2521    }
2522
2523    #[test]
2524    fn op_add_rejects_stacked_bracketed_custom_id_prefixes() {
2525        let err = op_add("", "[#ship1] [#ship2] release checklist", DOC_ID, false).unwrap_err();
2526        assert!(
2527            format!("{}", err).contains("duplicate leading custom id prefix"),
2528            "unexpected error: {}",
2529            err
2530        );
2531    }
2532
2533    #[test]
2534    fn op_add_rejects_stacked_mixed_custom_id_prefixes() {
2535        let err = op_add("", "id=ship1 [#ship2] release checklist", DOC_ID, false).unwrap_err();
2536        assert!(
2537            format!("{}", err).contains("duplicate leading custom id prefix"),
2538            "unexpected error: {}",
2539            err
2540        );
2541    }
2542
2543    #[test]
2544    fn op_add_rejects_empty() {
2545        assert!(op_add("", "   ", DOC_ID, false).is_err());
2546    }
2547
2548    #[test]
2549    fn op_add_rejects_state_marker_prefix() {
2550        for marker in &["[ ] task", "[/] task", "[x] task", "[X] task"] {
2551            let err = op_add("", marker, DOC_ID, false).unwrap_err();
2552            let msg = format!("{}", err);
2553            assert!(
2554                msg.contains("state marker"),
2555                "expected state marker error for '{}', got: {}",
2556                marker,
2557                msg
2558            );
2559        }
2560    }
2561
2562    #[test]
2563    fn op_add_rejects_duplicate_text() {
2564        let (body, _id1) = op_add("", "Wire Sift into corky", DOC_ID, false).unwrap();
2565        let err = op_add(&body, "Wire Sift into corky", DOC_ID, false).unwrap_err();
2566        let msg = format!("{}", err);
2567        assert!(
2568            msg.contains("duplicate"),
2569            "expected duplicate error, got: {}",
2570            msg
2571        );
2572    }
2573
2574    #[test]
2575    fn parse_bare_hash_placeholder_strips_marker() {
2576        let item = parse_item_line("- [ ] [#] Wire Sift into corky").unwrap();
2577        assert_eq!(item.id, "");
2578        assert_eq!(item.text, "Wire Sift into corky");
2579    }
2580
2581    #[test]
2582    fn backfill_strips_bare_hash_placeholder() {
2583        let body = "- [ ] [#] task with placeholder\n";
2584        let (new_body, changed) = backfill(body, DOC_ID, &ids());
2585        assert!(changed);
2586        assert!(new_body.contains("[#"), "should have a hash id");
2587        // The bare [#] should be consumed — only one [# in the output
2588        let hash_count = new_body.matches("[#").count();
2589        assert_eq!(hash_count, 1, "expected exactly one [# in: {}", new_body);
2590        assert!(new_body.contains("task with placeholder"));
2591        assert!(
2592            !new_body.contains("[#] task"),
2593            "bare [#] should not survive in text"
2594        );
2595    }
2596
2597    #[test]
2598    fn parse_bare_hash_placeholder_no_checkbox() {
2599        // `- [#] text` — no checkbox, bare placeholder
2600        let item = parse_item_line("- [#] no checkbox").unwrap();
2601        assert_eq!(item.id, "");
2602        assert_eq!(item.state, PendingState::Open);
2603        assert_eq!(item.text, "no checkbox");
2604    }
2605
2606    #[test]
2607    fn parse_bare_hash_placeholder_gated() {
2608        // `- [/] [#] gated task` — gated with bare placeholder
2609        let item = parse_item_line("- [/] [#] gated task").unwrap();
2610        assert_eq!(item.id, "");
2611        assert_eq!(item.state, PendingState::Gated);
2612        assert_eq!(item.text, "gated task");
2613    }
2614
2615    #[test]
2616    fn backfill_strips_multiple_bare_placeholders() {
2617        // Multiple items each with bare [#] — all should get real IDs
2618        let body = "- [ ] [#] first task\n- [ ] [#] second task\n";
2619        let (new_body, changed) = backfill(body, DOC_ID, &ids());
2620        assert!(changed);
2621        let (_, items, _) = parse_items(&new_body);
2622        assert_eq!(items.len(), 2);
2623        assert!(!items[0].id.is_empty(), "first should have id");
2624        assert!(!items[1].id.is_empty(), "second should have id");
2625        assert_ne!(items[0].id, items[1].id, "ids should be unique");
2626        // No residual [#] in text
2627        assert!(
2628            !items[0].text.contains("[#]"),
2629            "first text has residual [#]: {}",
2630            items[0].text
2631        );
2632        assert!(
2633            !items[1].text.contains("[#]"),
2634            "second text has residual [#]: {}",
2635            items[1].text
2636        );
2637    }
2638
2639    #[test]
2640    fn backfill_preserves_long_custom_id() {
2641        let body = "- [ ] [#sdig2matrix] Fixture evidence matrix\n";
2642        let (new_body, changed) = backfill(body, DOC_ID, &ids());
2643        assert!(!changed);
2644        assert_eq!(new_body, body);
2645    }
2646
2647    #[test]
2648    fn backfill_idempotent_after_placeholder_strip() {
2649        // After stripping [#] and assigning ID, second backfill should be a no-op
2650        let body = "- [ ] [#] task\n";
2651        let (first_pass, _) = backfill(body, DOC_ID, &ids());
2652        let (second_pass, changed) = backfill(&first_pass, DOC_ID, &ids());
2653        assert!(
2654            !changed,
2655            "second backfill should be no-op, got: {}",
2656            second_pass
2657        );
2658        assert_eq!(first_pass, second_pass);
2659    }
2660
2661    #[test]
2662    fn op_add_dedup_case_sensitive() {
2663        // Different casing should NOT be considered duplicate
2664        let (body, _) = op_add("", "Wire Sift", DOC_ID, false).unwrap();
2665        let result = op_add(&body, "wire sift", DOC_ID, false);
2666        assert!(result.is_ok(), "different case should not be duplicate");
2667    }
2668
2669    #[test]
2670    fn op_add_dedup_across_states() {
2671        // Item exists as gated — adding same text as open should still dedup
2672        let (body, _) = op_add("", "deploy to prod", DOC_ID, true).unwrap();
2673        let err = op_add(&body, "deploy to prod", DOC_ID, false).unwrap_err();
2674        assert!(format!("{}", err).contains("duplicate"));
2675    }
2676
2677    #[test]
2678    fn op_add_gated_produces_gated_item() {
2679        let (new_body, id) = op_add("", "gated task", DOC_ID, true).unwrap();
2680        assert!(new_body.contains("[/]"), "expected [/] in: {}", new_body);
2681        assert!(new_body.contains(&format!("[#{}]", id)));
2682        assert!(new_body.contains("gated task"));
2683    }
2684
2685    #[test]
2686    fn op_add_returns_assigned_id() {
2687        let (body, id1) = op_add("", "task one", DOC_ID, false).unwrap();
2688        assert!(!id1.is_empty());
2689        assert!(body.contains(&format!("[#{}]", id1)));
2690        let (body2, id2) = op_add(&body, "task two", DOC_ID, false).unwrap();
2691        assert_ne!(id1, id2);
2692        assert!(body2.contains(&format!("[#{}]", id2)));
2693    }
2694
2695    #[test]
2696    fn op_add_extracts_inline_tag_as_id() {
2697        let (new_body, id) = op_add(
2698            "",
2699            "2026-05-11 [#nopatchbackopencode] OpenCode agent-doc turn completed",
2700            DOC_ID,
2701            false,
2702        )
2703        .unwrap();
2704        assert_eq!(id, "nopatchbackopencode");
2705        assert!(
2706            new_body.contains(
2707                "- [ ] [#nopatchbackopencode] 2026-05-11 OpenCode agent-doc turn completed"
2708            ),
2709            "got: {}",
2710            new_body
2711        );
2712    }
2713
2714    #[test]
2715    fn op_add_inline_tag_strips_from_text() {
2716        let (new_body, id) = op_add("", "fix [#mybug] the thing", DOC_ID, false).unwrap();
2717        assert_eq!(id, "mybug");
2718        assert!(
2719            new_body.contains("- [ ] [#mybug] fix the thing"),
2720            "got: {}",
2721            new_body
2722        );
2723    }
2724
2725    #[test]
2726    fn op_add_inline_tag_at_end() {
2727        let (new_body, id) = op_add("", "deploy the thing [#deploy1]", DOC_ID, false).unwrap();
2728        assert_eq!(id, "deploy1");
2729        assert!(
2730            new_body.contains("- [ ] [#deploy1] deploy the thing"),
2731            "got: {}",
2732            new_body
2733        );
2734    }
2735
2736    #[test]
2737    fn op_add_inline_tag_dedup_uses_cleaned_text() {
2738        let (body, _) = op_add("", "fix [#mybug] the thing", DOC_ID, false).unwrap();
2739        let err = op_add(&body, "fix [#mybug] the thing", DOC_ID, false).unwrap_err();
2740        assert!(
2741            format!("{}", err).contains("duplicate"),
2742            "expected duplicate error, got: {}",
2743            err
2744        );
2745    }
2746
2747    #[test]
2748    fn op_add_inline_tag_rejects_existing_id() {
2749        let body = "- [ ] [#mybug] existing task\n";
2750        let err = op_add(body, "fix [#mybug] new task", DOC_ID, false).unwrap_err();
2751        assert!(
2752            format!("{}", err).contains("inline tag id already exists"),
2753            "expected inline tag id already exists, got: {}",
2754            err
2755        );
2756    }
2757
2758    #[test]
2759    fn op_add_inline_tag_ignores_invalid_tag() {
2760        let (new_body, id) =
2761            op_add("", "see [#not-a-valid tag] because spaces", DOC_ID, false).unwrap();
2762        assert_ne!(id, "not-a-valid");
2763        assert!(
2764            new_body.contains("see [#not-a-valid tag] because spaces"),
2765            "got: {}",
2766            new_body
2767        );
2768    }
2769
2770    #[test]
2771    fn op_add_leading_prefix_takes_precedence_over_inline() {
2772        let (new_body, id) =
2773            op_add("", "[#myid] text with [#other] inline", DOC_ID, false).unwrap();
2774        assert_eq!(id, "myid");
2775        assert!(
2776            new_body.contains("- [ ] [#myid] text with [#other] inline"),
2777            "got: {}",
2778            new_body
2779        );
2780    }
2781
2782    #[test]
2783    fn op_done_marks_checked() {
2784        let body = "- [ ] [#a1b2] task\n";
2785        let new_body = op_done(body, "a1b2").unwrap();
2786        assert!(new_body.contains("[x]"));
2787    }
2788
2789    #[test]
2790    fn op_done_accepts_hash_prefixed_id() {
2791        let body = "- [ ] [#a1b2] task\n";
2792        let new_body = op_done(body, "#a1b2").unwrap();
2793        assert!(new_body.contains("- [x] [#a1b2] task"));
2794    }
2795
2796    #[test]
2797    fn op_done_unknown_id_errors() {
2798        let body = "- [ ] [#a1b2] task\n";
2799        assert!(op_done(body, "zzzz").is_err());
2800    }
2801
2802    #[test]
2803    fn op_edit_preserves_hash() {
2804        let body = "- [ ] [#a1b2] original\n";
2805        let new_body = op_edit(body, "a1b2", "updated").unwrap();
2806        assert!(new_body.contains("[#a1b2]"));
2807        assert!(new_body.contains("updated"));
2808        assert!(!new_body.contains("original"));
2809    }
2810
2811    #[test]
2812    fn op_edit_multiline_replaces_existing_continuation() {
2813        let body = concat!(
2814            "- [ ] [#tmuxcrash] parent task\n",
2815            "  - [ ] [#tmuxcrash-old1] stale child\n",
2816            "  - [ ] [#tmuxcrash-old2] stale child two\n",
2817            "- [ ] [#keep1] sibling task\n"
2818        );
2819        let new_body = op_edit(
2820            body,
2821            "tmuxcrash",
2822            "parent task revised\n  - fresh child\n  - fresh child two",
2823        )
2824        .unwrap();
2825        assert!(new_body.contains("[#tmuxcrash] parent task revised"));
2826        assert!(new_body.contains("  - fresh child\n"));
2827        assert!(new_body.contains("  - fresh child two"));
2828        assert!(!new_body.contains("stale child"));
2829        assert!(!new_body.contains("stale child two"));
2830        assert!(new_body.contains("  - fresh child two\n- [ ] [#keep1] sibling task"));
2831    }
2832
2833    #[test]
2834    fn op_edit_rejects_unindented_multiline_follow_up() {
2835        let body = "- [ ] [#a1b2] original\n";
2836        let err = op_edit(body, "a1b2", "updated\nsecond parent").unwrap_err();
2837        assert!(
2838            format!("{}", err)
2839                .contains("multiline text may only contain indented continuation lines"),
2840            "got: {err}"
2841        );
2842    }
2843
2844    #[test]
2845    fn op_clear_empties_items() {
2846        let body = "- [ ] [#a1b2] one\n- [ ] [#c3d4] two\n";
2847        let new_body = op_clear(body).unwrap();
2848        assert!(!new_body.contains("[#"));
2849    }
2850
2851    #[test]
2852    fn op_clear_preserves_headers_and_spacing() {
2853        let body = concat!(
2854            "### Active\n",
2855            "- [ ] [#a1b2] one\n",
2856            "\n",
2857            "### Later\n",
2858            "- [ ] [#c3d4] two\n"
2859        );
2860        let new_body = op_clear(body).unwrap();
2861        assert_eq!(new_body, "### Active\n\n### Later\n");
2862    }
2863
2864    #[test]
2865    fn op_reorder_reorders_by_id() {
2866        let body = "- [ ] [#a1b2] first\n- [ ] [#c3d4] second\n- [ ] [#e5f6] third\n";
2867        let new_body = op_reorder(body, &["e5f6".to_string(), "a1b2".to_string()]).unwrap();
2868        let (_, items, _) = parse_items(&new_body);
2869        assert_eq!(items[0].id, "e5f6");
2870        assert_eq!(items[1].id, "a1b2");
2871        assert_eq!(items[2].id, "c3d4");
2872    }
2873
2874    #[test]
2875    fn op_reorder_keeps_headers_in_place() {
2876        let body = concat!(
2877            "### Active\n",
2878            "- [ ] [#a1b2] first\n",
2879            "### Later\n",
2880            "- [ ] [#c3d4] second\n",
2881            "- [ ] [#e5f6] third\n"
2882        );
2883        let new_body = op_reorder(body, &["e5f6".to_string(), "a1b2".to_string()]).unwrap();
2884        let lines: Vec<&str> = new_body.lines().collect();
2885        assert_eq!(lines[0], "### Active");
2886        assert!(lines[1].contains("[#e5f6] third"), "got: {}", new_body);
2887        assert_eq!(lines[2], "### Later");
2888        assert!(lines[3].contains("[#a1b2] first"), "got: {}", new_body);
2889        assert!(lines[4].contains("[#c3d4] second"), "got: {}", new_body);
2890    }
2891
2892    #[test]
2893    fn op_reorder_moves_nested_subtasks_with_parent_item() {
2894        let body = concat!(
2895            "- [ ] [#a1b2] first\n",
2896            "  - child a\n",
2897            "- [ ] [#c3d4] second\n",
2898            "  - child b\n"
2899        );
2900        let new_body = op_reorder(body, &["c3d4".to_string()]).unwrap();
2901        assert_eq!(
2902            new_body,
2903            concat!(
2904                "- [ ] [#c3d4] second\n",
2905                "  - child b\n",
2906                "- [ ] [#a1b2] first\n",
2907                "  - child a\n"
2908            )
2909        );
2910    }
2911
2912    #[test]
2913    fn op_reorder_renumbers_ordered_lists() {
2914        let body = "1. [ ] [#a1b2] first\n2. [ ] [#c3d4] second\n3. [ ] [#e5f6] third\n";
2915        let new_body = op_reorder(body, &["e5f6".to_string(), "a1b2".to_string()]).unwrap();
2916        let lines: Vec<&str> = new_body.lines().collect();
2917        assert_eq!(lines[0], "1. [ ] [#e5f6] third");
2918        assert_eq!(lines[1], "2. [ ] [#a1b2] first");
2919        assert_eq!(lines[2], "3. [ ] [#c3d4] second");
2920    }
2921
2922    #[test]
2923    fn op_reorder_unknown_id_errors() {
2924        let body = "- [ ] [#a1b2] one\n";
2925        assert!(op_reorder(body, &["zzzz".to_string()]).is_err());
2926    }
2927
2928    // ---- Phase 2: state matrix + gate/ungate ----
2929
2930    #[test]
2931    fn validate_transition_full_matrix() {
2932        use PendingOp::*;
2933        use PendingState::*;
2934        use TransitionResult::*;
2935
2936        // Open
2937        assert_eq!(validate_transition(Open, Gate).unwrap(), Transition(Gated));
2938        assert!(validate_transition(Open, Ungate).is_err());
2939        assert_eq!(
2940            validate_transition(Open, MarkDone).unwrap(),
2941            Transition(Done)
2942        );
2943
2944        // Gated
2945        assert_eq!(validate_transition(Gated, Gate).unwrap(), NoOp);
2946        assert_eq!(
2947            validate_transition(Gated, Ungate).unwrap(),
2948            Transition(Open)
2949        );
2950        assert_eq!(
2951            validate_transition(Gated, MarkDone).unwrap(),
2952            Transition(Done)
2953        );
2954
2955        // Done
2956        assert!(validate_transition(Done, Gate).is_err());
2957        assert!(validate_transition(Done, Ungate).is_err());
2958        assert_eq!(validate_transition(Done, MarkDone).unwrap(), NoOp);
2959    }
2960
2961    #[test]
2962    fn op_gate_open_to_gated() {
2963        let body = "- [ ] [#a1b2] task\n";
2964        let new_body = op_gate(body, "a1b2").unwrap();
2965        assert!(new_body.contains("- [/] [#a1b2]"));
2966    }
2967
2968    #[test]
2969    fn op_gate_gated_is_noop() {
2970        let body = "- [/] [#a1b2] task\n";
2971        let new_body = op_gate(body, "a1b2").unwrap();
2972        // No-op: body unchanged byte-for-byte.
2973        assert_eq!(new_body, body);
2974    }
2975
2976    #[test]
2977    fn op_gate_done_errors() {
2978        let body = "- [x] [#a1b2] task\n";
2979        let err = op_gate(body, "a1b2").unwrap_err();
2980        let msg = format!("{}", err);
2981        assert!(msg.contains("cannot gate Done item"), "got: {}", msg);
2982    }
2983
2984    #[test]
2985    fn op_gate_unknown_id_errors() {
2986        let body = "- [ ] [#a1b2] task\n";
2987        assert!(op_gate(body, "zzzz").is_err());
2988    }
2989
2990    #[test]
2991    fn op_ungate_gated_to_open() {
2992        let body = "- [/] [#a1b2] task\n";
2993        let new_body = op_ungate(body, "a1b2").unwrap();
2994        assert!(new_body.contains("- [ ] [#a1b2]"));
2995    }
2996
2997    #[test]
2998    fn op_ungate_open_errors() {
2999        let body = "- [ ] [#a1b2] task\n";
3000        let err = op_ungate(body, "a1b2").unwrap_err();
3001        let msg = format!("{}", err);
3002        assert!(msg.contains("cannot ungate Open"), "got: {}", msg);
3003    }
3004
3005    #[test]
3006    fn op_ungate_done_errors() {
3007        let body = "- [x] [#a1b2] task\n";
3008        let err = op_ungate(body, "a1b2").unwrap_err();
3009        let msg = format!("{}", err);
3010        assert!(msg.contains("cannot ungate Done"), "got: {}", msg);
3011    }
3012
3013    #[test]
3014    fn op_ungate_unknown_id_errors() {
3015        let body = "- [/] [#a1b2] task\n";
3016        assert!(op_ungate(body, "zzzz").is_err());
3017    }
3018
3019    #[test]
3020    fn op_gate_preserves_other_items_and_text() {
3021        let body = "- [ ] [#a1b2] one\n- [ ] [#c3d4] two — gate: v0.32.6\n- [x] [#e5f6] three\n";
3022        let new_body = op_gate(body, "c3d4").unwrap();
3023        let (_, items, _) = parse_items(&new_body);
3024        assert_eq!(items[0].state, PendingState::Open);
3025        assert_eq!(items[1].state, PendingState::Gated);
3026        assert_eq!(items[1].text, "two — gate: v0.32.6");
3027        assert_eq!(items[2].state, PendingState::Done);
3028    }
3029
3030    #[test]
3031    fn generate_hash_deterministic_and_short() {
3032        let h = generate_hash("text", "doc", 0);
3033        assert_eq!(h.len(), 4);
3034        assert_eq!(h, generate_hash("text", "doc", 0));
3035        assert_ne!(h, generate_hash("text", "doc", 1));
3036    }
3037
3038    #[test]
3039    fn generate_hash_n_width4_matches_generate_hash() {
3040        // Width-4 output must be bit-identical to the pre-#14z4 formula so
3041        // existing docs don't churn their IDs on re-backfill.
3042        let cases = [
3043            ("text", "doc", 0u64),
3044            ("refactor preflight", "abc123", 7),
3045            ("", "", 42),
3046            ("long text with spaces", "doc_id_long", 99),
3047        ];
3048        for (t, d, c) in cases {
3049            assert_eq!(generate_hash(t, d, c), generate_hash_n(t, d, c, 4));
3050        }
3051    }
3052
3053    #[test]
3054    fn generate_hash_n_widths_have_correct_length() {
3055        for w in 4..=8 {
3056            let h = generate_hash_n("text", "doc", 0, w);
3057            assert_eq!(h.len(), w, "width {} produced len {}", w, h.len());
3058        }
3059        // Out-of-range widths clamp to [4, 8].
3060        assert_eq!(generate_hash_n("x", "y", 0, 1).len(), 4);
3061        assert_eq!(generate_hash_n("x", "y", 0, 20).len(), 8);
3062    }
3063
3064    #[test]
3065    fn generate_hash_n_wider_extends_shorter() {
3066        // A wider hash must start with the shorter hash as a prefix so
3067        // visible widening is explainable to humans.
3068        let h4 = generate_hash_n("text", "doc", 0, 4);
3069        let h5 = generate_hash_n("text", "doc", 0, 5);
3070        let h8 = generate_hash_n("text", "doc", 0, 8);
3071        assert!(h5.starts_with(&h4), "h5={} h4={}", h5, h4);
3072        assert!(h8.starts_with(&h4), "h8={} h4={}", h8, h4);
3073        assert!(h8.starts_with(&h5), "h8={} h5={}", h8, h5);
3074    }
3075
3076    #[test]
3077    fn assign_unique_hash_extends_on_collision() {
3078        // Pre-populate `taken` with the width-4 hash of "item". The next
3079        // assignment for the same text must either reuse the width-4 slot
3080        // with a different counter OR widen. Either way the result must
3081        // differ from the pre-populated value and be valid.
3082        let h4 = generate_hash_n("item", "doc", 0, 4);
3083        let mut taken = HashSet::new();
3084        taken.insert(h4.clone());
3085        let id = assign_unique_hash("item", "doc", &taken);
3086        assert_ne!(id, h4);
3087        assert!((4..=8).contains(&id.len()));
3088    }
3089
3090    #[test]
3091    fn assign_unique_hash_widens_when_counter_exhausted_at_width4() {
3092        // Pre-populate `taken` with EVERY width-4 hash the retry loop would
3093        // try at counters 0..=3. Assignment must widen to 5 chars.
3094        let mut taken = HashSet::new();
3095        for c in 0..=3u64 {
3096            taken.insert(generate_hash_n("x", "d", c, 4));
3097        }
3098        let id = assign_unique_hash("x", "d", &taken);
3099        assert!(!taken.contains(&id));
3100        // Either width-4 (an untried counter) or width-5+. Accept both —
3101        // the important invariant is uniqueness, not forced widening.
3102        assert!((4..=8).contains(&id.len()));
3103    }
3104
3105    #[test]
3106    fn backfill_assigns_collision_free_ids_under_pressure() {
3107        // Stress test: backfill 50 items. All must get unique 4..=8-char ids.
3108        let mut body = String::new();
3109        for i in 0..50 {
3110            body.push_str(&format!("- item {}\n", i));
3111        }
3112        let (out, changed) = backfill(&body, "doc", &HashSet::new());
3113        assert!(changed);
3114        let (_, items, _) = parse_items(&out);
3115        assert_eq!(items.len(), 50);
3116        let ids: HashSet<String> = items.iter().map(|i| i.id.clone()).collect();
3117        assert_eq!(ids.len(), 50, "ids must be unique");
3118        for id in &ids {
3119            assert!(
3120                (4..=8).contains(&id.len()),
3121                "id {} has width {}",
3122                id,
3123                id.len()
3124            );
3125        }
3126    }
3127
3128    // ---- Typed gates ----
3129
3130    #[test]
3131    fn parse_typed_gate_release() {
3132        let body = "- [/release] [#a1b2] Release v0.32.4\n";
3133        let (_, items, _) = parse_items(body);
3134        assert_eq!(items.len(), 1);
3135        assert_eq!(items[0].state, PendingState::Gated);
3136        assert_eq!(items[0].gate_type, Some("release".to_string()));
3137        assert_eq!(items[0].text, "Release v0.32.4");
3138    }
3139
3140    #[test]
3141    fn parse_typed_gate_deploy() {
3142        let body = "- [/deploy] [#c3d4] Push CDN config\n";
3143        let (_, items, _) = parse_items(body);
3144        assert_eq!(items[0].state, PendingState::Gated);
3145        assert_eq!(items[0].gate_type, Some("deploy".to_string()));
3146    }
3147
3148    #[test]
3149    fn parse_untyped_gate_has_no_gate_type() {
3150        let body = "- [/] [#a1b2] waiting\n";
3151        let (_, items, _) = parse_items(body);
3152        assert_eq!(items[0].state, PendingState::Gated);
3153        assert_eq!(items[0].gate_type, None);
3154    }
3155
3156    #[test]
3157    fn parse_open_has_no_gate_type() {
3158        let body = "- [ ] [#a1b2] task\n";
3159        let (_, items, _) = parse_items(body);
3160        assert_eq!(items[0].gate_type, None);
3161    }
3162
3163    #[test]
3164    fn render_typed_gate() {
3165        let item = PendingItem {
3166            marker: PendingListMarker::Bullet,
3167            id: "a1b2".to_string(),
3168            state: PendingState::Gated,
3169            gate_type: Some("release".to_string()),
3170            text: "Release v0.32.4".to_string(),
3171            continuation: String::new(),
3172        };
3173        assert_eq!(item.render(), "- [/release] [#a1b2] Release v0.32.4");
3174    }
3175
3176    #[test]
3177    fn render_roundtrip_typed_gate() {
3178        let body = "- [/release] [#a1b2] Release v0.32.4\n- [/deploy] [#c3d4] Push\n- [/] [#e5f6] Generic\n";
3179        let (p, items, post) = parse_items(body);
3180        let out = render_items(&p, &items, &post);
3181        assert_eq!(out, body);
3182    }
3183
3184    #[test]
3185    fn op_resolve_gate_flips_matching() {
3186        let body = "- [/release] [#a1b2] Release v0.32.4\n- [/deploy] [#c3d4] Deploy\n- [/] [#e5f6] Generic gate\n";
3187        let (new_body, resolved) = op_resolve_gate(body, "release");
3188        assert_eq!(resolved, vec!["a1b2"]);
3189        let (_, items, _) = parse_items(&new_body);
3190        assert_eq!(items[0].state, PendingState::Done); // was [/release]
3191        assert_eq!(items[0].gate_type, None); // cleared
3192        assert_eq!(items[1].state, PendingState::Gated); // [/deploy] untouched
3193        assert_eq!(items[1].gate_type, Some("deploy".to_string()));
3194        assert_eq!(items[2].state, PendingState::Gated); // [/] untouched
3195        assert_eq!(items[2].gate_type, None);
3196    }
3197
3198    #[test]
3199    fn op_resolve_gate_no_match() {
3200        let body = "- [/release] [#a1b2] Release\n- [/] [#c3d4] Generic\n";
3201        let (new_body, resolved) = op_resolve_gate(body, "deploy");
3202        assert!(resolved.is_empty());
3203        assert_eq!(new_body, body);
3204    }
3205
3206    #[test]
3207    fn op_resolve_gate_ignores_untyped() {
3208        let body = "- [/] [#a1b2] Generic gate\n";
3209        let (_, resolved) = op_resolve_gate(body, "release");
3210        assert!(resolved.is_empty());
3211    }
3212
3213    #[test]
3214    fn op_resolve_gate_multiple_same_type() {
3215        let body = "- [/release] [#a1b2] First\n- [/release] [#c3d4] Second\n";
3216        let (_, resolved) = op_resolve_gate(body, "release");
3217        assert_eq!(resolved, vec!["a1b2", "c3d4"]);
3218    }
3219
3220    #[test]
3221    fn op_set_gate_type_on_gated() {
3222        let body = "- [/] [#a1b2] Release v0.32.4\n";
3223        let new_body = op_set_gate_type(body, "a1b2", "release").unwrap();
3224        assert!(new_body.contains("[/release]"));
3225        let (_, items, _) = parse_items(&new_body);
3226        assert_eq!(items[0].gate_type, Some("release".to_string()));
3227    }
3228
3229    #[test]
3230    fn op_set_gate_type_errors_on_open() {
3231        let body = "- [ ] [#a1b2] task\n";
3232        assert!(op_set_gate_type(body, "a1b2", "release").is_err());
3233    }
3234
3235    #[test]
3236    fn op_set_gate_type_errors_on_done() {
3237        let body = "- [x] [#a1b2] task\n";
3238        assert!(op_set_gate_type(body, "a1b2", "release").is_err());
3239    }
3240
3241    #[test]
3242    fn op_set_gate_type_replaces_existing() {
3243        let body = "- [/release] [#a1b2] task\n";
3244        let new_body = op_set_gate_type(body, "a1b2", "deploy").unwrap();
3245        assert!(new_body.contains("[/deploy]"));
3246        assert!(!new_body.contains("[/release]"));
3247    }
3248
3249    #[test]
3250    fn parse_typed_gate_case_insensitive() {
3251        let body = "- [/Release] [#a1b2] task\n";
3252        let (_, items, _) = parse_items(body);
3253        assert_eq!(items[0].gate_type, Some("release".to_string()));
3254    }
3255
3256    #[test]
3257    fn parse_typed_gate_with_hyphens_underscores() {
3258        let body =
3259            "- [/code-review] [#a1b2] Review PR\n- [/pre_release] [#c3d4] Pre-release check\n";
3260        let (_, items, _) = parse_items(body);
3261        assert_eq!(items[0].gate_type, Some("code-review".to_string()));
3262        assert_eq!(items[1].gate_type, Some("pre_release".to_string()));
3263    }
3264
3265    #[test]
3266    fn detect_shadow_open_items_classifies_duplicate_and_shadow_only_ids() {
3267        let doc = concat!(
3268            "## Pending / Not Built\n\n",
3269            "<!-- agent:backlog -->\n",
3270            "- [ ] [#live1] Keep in backlog\n",
3271            "- [/] [#gate1] Gated live item\n",
3272            "<!-- /agent:backlog -->\n\n",
3273            "<!-- parked copy\n",
3274            "- [ ] [#live1] Keep in backlog\n",
3275            "- [ ] [#lost1] Drifted out of backlog\n",
3276            "- [x] [#done1] Already done\n",
3277            "-->\n"
3278        );
3279
3280        let report = detect_shadow_open_items(doc).unwrap();
3281        assert_eq!(
3282            report
3283                .duplicated_in_live_backlog
3284                .iter()
3285                .map(|item| item.id.as_str())
3286                .collect::<Vec<_>>(),
3287            vec!["live1"]
3288        );
3289        assert_eq!(
3290            report
3291                .shadow_only
3292                .iter()
3293                .map(|item| item.id.as_str())
3294                .collect::<Vec<_>>(),
3295            vec!["lost1"]
3296        );
3297    }
3298
3299    #[test]
3300    fn detect_shadow_open_items_ignores_indented_nested_ids() {
3301        let doc = concat!(
3302            "<!-- agent:backlog -->\n",
3303            "- [ ] [#live1] Parent task\n",
3304            "  - [ ] [#nested1] Nested checklist item\n",
3305            "<!-- /agent:backlog -->\n"
3306        );
3307
3308        let report = detect_shadow_open_items(doc).unwrap();
3309        assert!(report.duplicated_in_live_backlog.is_empty());
3310        assert!(report.shadow_only.is_empty());
3311    }
3312
3313    #[test]
3314    fn extract_items_by_id_preserves_nested_subtasks() {
3315        let body = concat!(
3316            "### Active\n",
3317            "- [ ] [#move1] Parent task\n",
3318            "  - child dependency\n",
3319            "- [ ] [#keep1] Keep task\n"
3320        );
3321        let (remaining, moved, matched) =
3322            extract_items_by_id(body, &["move1".to_string()]).unwrap();
3323        assert_eq!(matched, vec!["move1".to_string()]);
3324        assert!(remaining.contains("### Active\n"));
3325        assert!(!remaining.contains("[#move1]"));
3326        assert!(remaining.contains("[#keep1] Keep task"));
3327        assert_eq!(
3328            moved,
3329            concat!("- [ ] [#move1] Parent task\n", "  - child dependency\n")
3330        );
3331    }
3332
3333    #[test]
3334    fn extract_items_by_id_preserves_ordered_list_style() {
3335        let body = concat!(
3336            "1. [ ] [#move1] Parent task\n",
3337            "2. [ ] [#keep1] Keep task\n",
3338            "3. [ ] [#keep2] Keep task two\n"
3339        );
3340        let (remaining, moved, matched) =
3341            extract_items_by_id(body, &["move1".to_string()]).unwrap();
3342        assert_eq!(matched, vec!["move1".to_string()]);
3343        assert_eq!(moved, "1. [ ] [#move1] Parent task\n");
3344        assert_eq!(
3345            remaining,
3346            concat!(
3347                "1. [ ] [#keep1] Keep task\n",
3348                "2. [ ] [#keep2] Keep task two\n"
3349            )
3350        );
3351    }
3352
3353    #[test]
3354    fn detect_shadow_open_items_ignores_icebox_and_code_blocks() {
3355        let doc = concat!(
3356            "## Pending / Not Built\n\n",
3357            "<!-- agent:backlog -->\n",
3358            "- [ ] [#live1] Keep in backlog\n",
3359            "<!-- /agent:backlog -->\n\n",
3360            "<!-- agent:icebox -->\n",
3361            "- [ ] [#cold1] Intentionally parked\n",
3362            "<!-- /agent:icebox -->\n\n",
3363            "```md\n",
3364            "- [ ] [#code1] Example only\n",
3365            "```\n"
3366        );
3367
3368        let report = detect_shadow_open_items(doc).unwrap();
3369        assert!(report.duplicated_in_live_backlog.is_empty());
3370        assert!(report.shadow_only.is_empty());
3371    }
3372
3373    #[test]
3374    fn detect_shadow_open_items_ignores_compact_exchange_summary_items() {
3375        let doc = concat!(
3376            "## Exchange\n\n",
3377            "<!-- agent:exchange patch=append -->\n",
3378            "### Session Summary\n\n",
3379            "*Compacted.*\n\n",
3380            "Icebox:\n",
3381            "- [ ] [#cold1] Intentionally parked\n",
3382            "- [ ] [#cold2] Still parked\n\n",
3383            "❯ #code-review\n",
3384            "<!-- agent:boundary:test -->\n",
3385            "<!-- /agent:exchange -->\n\n",
3386            "<!-- agent:backlog -->\n",
3387            "<!-- /agent:backlog -->\n\n",
3388            "<!-- agent:icebox -->\n",
3389            "- [ ] [#cold1] Intentionally parked\n",
3390            "- [ ] [#cold2] Still parked\n",
3391            "<!-- /agent:icebox -->\n"
3392        );
3393
3394        let report = detect_shadow_open_items(doc).unwrap();
3395        assert!(report.duplicated_in_live_backlog.is_empty());
3396        assert!(report.shadow_only.is_empty());
3397    }
3398
3399    #[test]
3400    fn detect_dropped_from_history_catches_missing_item() {
3401        let baseline = concat!(
3402            "<!-- agent:backlog -->\n",
3403            "- [ ] [#keep1] Still here\n",
3404            "- [ ] [#gone1] Was open in baseline\n",
3405            "- [ ] [#gone2] Also open in baseline\n",
3406            "<!-- /agent:backlog -->\n"
3407        );
3408        let current = concat!(
3409            "<!-- agent:backlog -->\n",
3410            "- [ ] [#keep1] Still here\n",
3411            "<!-- /agent:backlog -->\n"
3412        );
3413        let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
3414        let ids: Vec<&str> = report.dropped.iter().map(|i| i.id.as_str()).collect();
3415        assert_eq!(ids, vec!["gone1", "gone2"]);
3416    }
3417
3418    #[test]
3419    fn detect_dropped_from_history_allows_done_in_live() {
3420        let baseline = concat!(
3421            "<!-- agent:backlog -->\n",
3422            "- [ ] [#item1] Was open\n",
3423            "<!-- /agent:backlog -->\n"
3424        );
3425        let current = concat!(
3426            "<!-- agent:backlog -->\n",
3427            "- [x] [#item1] Now done\n",
3428            "<!-- /agent:backlog -->\n"
3429        );
3430        let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
3431        assert!(report.dropped.is_empty());
3432    }
3433
3434    #[test]
3435    fn detect_dropped_from_history_allows_done_ids() {
3436        let baseline = concat!(
3437            "<!-- agent:backlog -->\n",
3438            "- [ ] [#item1] Was open\n",
3439            "<!-- /agent:backlog -->\n"
3440        );
3441        let current = concat!("<!-- agent:backlog -->\n", "<!-- /agent:backlog -->\n");
3442        let mut done = HashSet::new();
3443        done.insert("item1".to_string());
3444        let report = detect_dropped_from_history(current, baseline, &done).unwrap();
3445        assert!(report.dropped.is_empty());
3446    }
3447
3448    #[test]
3449    fn detect_dropped_from_history_allows_completed_archive_id() {
3450        let baseline = concat!(
3451            "<!-- agent:backlog -->\n",
3452            "- [ ] [#item1] Was open\n",
3453            "<!-- /agent:backlog -->\n"
3454        );
3455        let current = concat!(
3456            "<!-- agent:backlog -->\n",
3457            "<!-- /agent:backlog -->\n\n",
3458            "<!-- agent:done -->\n",
3459            "- 2026-05-10 [#item1] Was open\n",
3460            "<!-- /agent:done -->\n"
3461        );
3462        let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
3463        assert!(report.dropped.is_empty());
3464    }
3465
3466    #[test]
3467    fn detect_dropped_from_history_rejects_removed_completed_archive_alias() {
3468        let baseline = concat!(
3469            "<!-- agent:backlog -->\n",
3470            "- [ ] [#item1] Was open\n",
3471            "<!-- /agent:backlog -->\n"
3472        );
3473        let current = concat!(
3474            "<!-- agent:backlog -->\n",
3475            "<!-- /agent:backlog -->\n\n",
3476            "<!-- agent:pending-done -->\n",
3477            "- 2026-05-10 [#item1] Was open\n",
3478            "<!-- /agent:pending-done -->\n"
3479        );
3480        let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
3481        assert_eq!(report.dropped.len(), 1);
3482        assert_eq!(report.dropped[0].id, "item1");
3483    }
3484
3485    #[test]
3486    fn detect_dropped_from_history_allows_icebox() {
3487        let baseline = concat!(
3488            "<!-- agent:backlog -->\n",
3489            "- [ ] [#item1] Was open\n",
3490            "<!-- /agent:backlog -->\n"
3491        );
3492        let current = concat!(
3493            "<!-- agent:backlog -->\n",
3494            "<!-- /agent:backlog -->\n",
3495            "<!-- agent:icebox -->\n",
3496            "- [ ] [#item1] Archived\n",
3497            "<!-- /agent:icebox -->\n"
3498        );
3499        let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
3500        assert!(report.dropped.is_empty());
3501    }
3502
3503    #[test]
3504    fn detect_dropped_from_history_allows_shadow() {
3505        let baseline = concat!(
3506            "<!-- agent:backlog -->\n",
3507            "- [ ] [#item1] Was open\n",
3508            "<!-- /agent:backlog -->\n"
3509        );
3510        let current = concat!(
3511            "<!-- agent:backlog -->\n",
3512            "<!-- /agent:backlog -->\n\n",
3513            "<!-- parked\n",
3514            "- [ ] [#item1] Drifted to shadow\n",
3515            "-->\n"
3516        );
3517        let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
3518        assert!(report.dropped.is_empty());
3519    }
3520
3521    #[test]
3522    fn detect_dropped_from_history_ignores_baseline_done_items() {
3523        let baseline = concat!(
3524            "<!-- agent:backlog -->\n",
3525            "- [x] [#done1] Already done in baseline\n",
3526            "- [/] [#gate1] Gated in baseline\n",
3527            "<!-- /agent:backlog -->\n"
3528        );
3529        let current = concat!("<!-- agent:backlog -->\n", "<!-- /agent:backlog -->\n");
3530        let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
3531        let ids: Vec<&str> = report.dropped.iter().map(|i| i.id.as_str()).collect();
3532        assert_eq!(ids, vec!["gate1"]);
3533    }
3534
3535    #[test]
3536    fn detect_dropped_from_history_no_baseline_backlog() {
3537        let baseline = "# Just a document\nNo backlog here.\n";
3538        let current = concat!(
3539            "<!-- agent:backlog -->\n",
3540            "- [ ] [#item1] New item\n",
3541            "<!-- /agent:backlog -->\n"
3542        );
3543        let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
3544        assert!(report.dropped.is_empty());
3545    }
3546
3547    #[test]
3548    fn detect_dropped_from_history_ignores_code_blocks_in_current() {
3549        let baseline = concat!(
3550            "<!-- agent:backlog -->\n",
3551            "- [ ] [#item1] Was open\n",
3552            "<!-- /agent:backlog -->\n"
3553        );
3554        let current = concat!(
3555            "<!-- agent:backlog -->\n",
3556            "<!-- /agent:backlog -->\n\n",
3557            "```md\n",
3558            "- [ ] [#item1] In code block only\n",
3559            "```\n"
3560        );
3561        let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
3562        let ids: Vec<&str> = report.dropped.iter().map(|i| i.id.as_str()).collect();
3563        assert_eq!(ids, vec!["item1"]);
3564    }
3565}