1use anyhow::{Result, anyhow, bail};
23use std::collections::HashSet;
24
25#[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 pub fn box_char(self) -> char {
63 match self {
64 PendingState::Open => ' ',
65 PendingState::Gated => '/',
66 PendingState::Done => 'x',
67 }
68 }
69
70 #[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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
90#[allow(dead_code)]
91pub enum PendingOp {
92 Gate,
94 Ungate,
96 MarkDone,
98}
99
100#[derive(Debug, Clone, Copy, PartialEq, Eq)]
102pub enum TransitionResult {
103 Transition(PendingState),
104 NoOp,
105}
106
107pub 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#[derive(Debug, Clone, PartialEq, Eq)]
143pub struct PendingItem {
144 pub marker: PendingListMarker,
147 pub id: String,
151 pub state: PendingState,
153 pub gate_type: Option<String>,
156 pub text: String,
158 pub continuation: String,
161}
162
163impl PendingItem {
164 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 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
401pub 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
451pub 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
497fn parse_item_line(line: &str) -> Option<PendingItem> {
502 let (marker, rest) = parse_parent_list_marker(line)?;
503 let rest = rest.trim_start();
504
505 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 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 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 (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#[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
872pub 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
891pub 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
898pub 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
920pub 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#[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 ¤t_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#[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
1293pub 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 const ALPHABET: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz"; let width = width.clamp(4, 8);
1313 let mut out = String::with_capacity(width);
1314
1315 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 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
1343fn assign_unique_hash(text: &str, doc_id: &str, taken: &HashSet<String>) -> String {
1349 const RETRIES_PER_WIDTH: u64 = 4;
1353 let mut counter: u64 = 0;
1354 loop {
1355 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
1367fn 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
1446pub 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 if new_body != body {
1484 changed = true;
1485 }
1486 (new_body, changed)
1487}
1488
1489#[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
1498pub 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
1569pub 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
1588pub 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
1619pub 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 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
1690pub 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
1712pub 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
1751pub 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
1791pub 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
1817pub fn op_clear(body: &str) -> Result<String> {
1819 let cleared = PendingLayout::parse(body).replace_items(|_| None);
1820 Ok(cleared.render())
1821}
1822
1823pub 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
1847pub 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
1868pub 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 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 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 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 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 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 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 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 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 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 #[test]
2931 fn validate_transition_full_matrix() {
2932 use PendingOp::*;
2933 use PendingState::*;
2934 use TransitionResult::*;
2935
2936 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 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 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 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 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 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 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 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 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 assert!((4..=8).contains(&id.len()));
3103 }
3104
3105 #[test]
3106 fn backfill_assigns_collision_free_ids_under_pressure() {
3107 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 #[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); assert_eq!(items[0].gate_type, None); assert_eq!(items[1].state, PendingState::Gated); assert_eq!(items[1].gate_type, Some("deploy".to_string()));
3194 assert_eq!(items[2].state, PendingState::Gated); 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}