1use agent_doc_element::element;
23use anyhow::{Context, Result, anyhow, bail};
24use std::collections::{BTreeMap, HashSet};
25
26pub const IN_PROGRESS_MARKER: &str = "๐ง";
27
28#[derive(Debug, Clone, Copy, PartialEq, Eq)]
35pub enum PendingState {
36 Open,
37 Gated,
38 Done,
39}
40
41#[derive(Debug, Clone, Copy, PartialEq, Eq)]
42pub enum PendingListMarker {
43 Bullet,
44 Ordered(usize),
45}
46
47impl PendingListMarker {
48 fn render_prefix(self, ordered_index: Option<usize>) -> String {
49 match ordered_index {
50 Some(index) => format!("{index}."),
51 None => match self {
52 PendingListMarker::Bullet => "-".to_string(),
53 PendingListMarker::Ordered(index) => format!("{index}."),
54 },
55 }
56 }
57
58 fn is_ordered(self) -> bool {
59 matches!(self, PendingListMarker::Ordered(_))
60 }
61}
62
63impl PendingState {
64 pub fn box_char(self) -> char {
66 match self {
67 PendingState::Open => ' ',
68 PendingState::Gated => '/',
69 PendingState::Done => 'x',
70 }
71 }
72
73 #[allow(dead_code)]
77 pub fn from_box_char(c: char) -> Option<PendingState> {
78 match c {
79 ' ' => Some(PendingState::Open),
80 '/' => Some(PendingState::Gated),
81 'x' | 'X' => Some(PendingState::Done),
82 _ => None,
83 }
84 }
85}
86
87#[derive(Debug, Clone, Copy, PartialEq, Eq)]
93#[allow(dead_code)]
94pub enum PendingOp {
95 Gate,
97 Ungate,
99 MarkDone,
101}
102
103#[derive(Debug, Clone, Copy, PartialEq, Eq)]
105pub enum TransitionResult {
106 Transition(PendingState),
107 NoOp,
108}
109
110pub fn validate_transition(from: PendingState, op: PendingOp) -> Result<TransitionResult> {
126 use PendingOp::*;
127 use PendingState as S;
128 use TransitionResult::*;
129 match (from, op) {
130 (S::Open, Gate) => Ok(Transition(S::Gated)),
131 (S::Open, Ungate) => bail!("cannot ungate Open item: source must be `[/]`"),
132 (S::Open, MarkDone) => Ok(Transition(S::Done)),
133 (S::Gated, Gate) => Ok(NoOp),
134 (S::Gated, Ungate) => Ok(Transition(S::Open)),
135 (S::Gated, MarkDone) => Ok(Transition(S::Done)),
136 (S::Done, Gate) => {
137 bail!("cannot gate Done item: add a new pending item for the follow-up gate")
138 }
139 (S::Done, Ungate) => bail!("cannot ungate Done item: source must be `[/]`"),
140 (S::Done, MarkDone) => Ok(NoOp),
141 }
142}
143
144#[derive(Debug, Clone, PartialEq, Eq)]
146pub struct PendingItem {
147 pub marker: PendingListMarker,
150 pub id: String,
154 pub state: PendingState,
156 pub gate_type: Option<String>,
159 pub in_progress: bool,
162 pub text: String,
164 pub continuation: String,
167}
168
169impl PendingItem {
170 pub fn render(&self) -> String {
173 self.render_with_ordered_index(None)
174 }
175
176 fn render_with_ordered_index(&self, ordered_index: Option<usize>) -> String {
177 let checkbox = match (&self.state, &self.gate_type) {
178 (PendingState::Gated, Some(gt)) => format!("[/{}]", gt),
179 _ => format!("[{}]", self.state.box_char()),
180 };
181 let in_progress = if self.in_progress {
182 format!(" {IN_PROGRESS_MARKER}")
183 } else {
184 String::new()
185 };
186 let mut out = format!(
187 "{} {}{} [#{}] {}",
188 self.marker.render_prefix(ordered_index),
189 checkbox,
190 in_progress,
191 self.id,
192 self.text
193 );
194 if !self.continuation.is_empty() {
195 out.push('\n');
196 out.push_str(&self.continuation);
197 }
198 out
199 }
200
201 pub fn is_done(&self) -> bool {
203 matches!(self.state, PendingState::Done)
204 }
205}
206
207#[derive(Debug, Clone, PartialEq, Eq)]
208enum PendingSegment {
209 Text(String),
210 Item {
211 item: PendingItem,
212 has_newline: bool,
213 },
214}
215
216#[derive(Debug, Clone, PartialEq, Eq, Default)]
217struct PendingLayout {
218 segments: Vec<PendingSegment>,
219}
220
221impl PendingLayout {
222 fn parse(body: &str) -> Self {
223 if body.is_empty() {
224 return Self {
225 segments: Vec::new(),
226 };
227 }
228
229 let mut segments = Vec::new();
230 let lines: Vec<&str> = body.split_inclusive('\n').collect();
231 let mut index = 0usize;
232 while index < lines.len() {
233 let raw_line = lines[index];
234 let has_newline = raw_line.ends_with('\n');
235 let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
236 if let Some(mut item) = parse_item_line(line) {
237 index += 1;
238 let mut continuation = String::new();
239 let mut pending_blank_lines = String::new();
240 while index < lines.len() {
241 let next_raw = lines[index];
242 let next_line = next_raw.strip_suffix('\n').unwrap_or(next_raw);
243 if parse_item_line(next_line).is_some() {
244 break;
245 }
246 if next_line.is_empty() {
247 pending_blank_lines.push_str(next_raw);
248 index += 1;
249 continue;
250 }
251 if is_indented_continuation_line(next_line) {
252 continuation.push_str(&pending_blank_lines);
253 pending_blank_lines.clear();
254 continuation.push_str(next_raw);
255 index += 1;
256 continue;
257 }
258 break;
259 }
260 item.continuation = continuation;
261 segments.push(PendingSegment::Item { item, has_newline });
262 if !pending_blank_lines.is_empty() {
263 segments.push(PendingSegment::Text(pending_blank_lines));
264 }
265 } else {
266 segments.push(PendingSegment::Text(raw_line.to_string()));
267 index += 1;
268 }
269 }
270 Self { segments }
271 }
272
273 fn render(&self) -> String {
274 let mut out = String::new();
275 let ordered_mode = self
276 .segments
277 .iter()
278 .filter_map(|segment| match segment {
279 PendingSegment::Item { item, .. } => Some(item.marker.is_ordered()),
280 PendingSegment::Text(_) => None,
281 })
282 .any(|is_ordered| is_ordered);
283 let mut ordered_index = 1usize;
284 for segment in &self.segments {
285 match segment {
286 PendingSegment::Text(raw) => out.push_str(raw),
287 PendingSegment::Item { item, has_newline } => {
288 let render = if ordered_mode {
289 let render = item.render_with_ordered_index(Some(ordered_index));
290 ordered_index += 1;
291 render
292 } else {
293 item.render()
294 };
295 out.push_str(&render);
296 if *has_newline && item.continuation.is_empty() {
297 out.push('\n');
298 }
299 }
300 }
301 }
302 out
303 }
304
305 fn items(&self) -> Vec<PendingItem> {
306 self.segments
307 .iter()
308 .filter_map(|segment| match segment {
309 PendingSegment::Item { item, .. } => Some(item.clone()),
310 PendingSegment::Text(_) => None,
311 })
312 .collect()
313 }
314
315 fn first_item_index(&self) -> Option<usize> {
316 self.segments
317 .iter()
318 .position(|segment| matches!(segment, PendingSegment::Item { .. }))
319 }
320
321 fn ensure_separator_before(&mut self, index: usize) {
322 if index == 0 {
323 return;
324 }
325 match &mut self.segments[index - 1] {
326 PendingSegment::Text(raw) => {
327 if !raw.is_empty() && !raw.ends_with('\n') {
328 raw.push('\n');
329 }
330 }
331 PendingSegment::Item { has_newline, .. } => {
332 *has_newline = true;
333 }
334 }
335 }
336
337 fn insert_first_item(&mut self, item: PendingItem) {
338 let index = self.first_item_index().unwrap_or(self.segments.len());
339 self.insert_item_at(index, item);
340 }
341
342 fn item_segment_index(&self, id: &str) -> Option<usize> {
345 self.segments.iter().position(
346 |segment| matches!(segment, PendingSegment::Item { item, .. } if item.id == id),
347 )
348 }
349
350 fn last_item_index_plus_one(&self) -> usize {
353 self.segments
354 .iter()
355 .rposition(|segment| matches!(segment, PendingSegment::Item { .. }))
356 .map(|idx| idx + 1)
357 .unwrap_or_else(|| self.first_item_index().unwrap_or(self.segments.len()))
358 }
359
360 fn insert_item_at(&mut self, index: usize, item: PendingItem) {
362 self.ensure_separator_before(index);
363 self.segments.insert(
364 index,
365 PendingSegment::Item {
366 item,
367 has_newline: true,
368 },
369 );
370 }
371
372 fn replace_items<F>(&self, mut replacer: F) -> Self
373 where
374 F: FnMut(&PendingItem) -> Option<PendingItem>,
375 {
376 let mut segments = Vec::with_capacity(self.segments.len());
377 for segment in &self.segments {
378 match segment {
379 PendingSegment::Text(raw) => segments.push(PendingSegment::Text(raw.clone())),
380 PendingSegment::Item { item, has_newline } => {
381 if let Some(next_item) = replacer(item) {
382 segments.push(PendingSegment::Item {
383 item: next_item,
384 has_newline: *has_newline,
385 });
386 }
387 }
388 }
389 }
390 Self { segments }
391 }
392
393 fn non_item_segments(&self) -> Vec<String> {
394 self.segments
395 .iter()
396 .filter_map(|segment| match segment {
397 PendingSegment::Text(raw) => Some(raw.clone()),
398 PendingSegment::Item { .. } => None,
399 })
400 .collect()
401 }
402}
403
404#[derive(Debug, Clone, PartialEq, Eq)]
405pub struct ShadowPendingItem {
406 pub id: String,
407 pub text: String,
408 pub line: usize,
409}
410
411impl ShadowPendingItem {
412 pub fn reference(&self) -> String {
413 format!("#{} (line {})", self.id, self.line)
414 }
415}
416
417pub fn format_shadow_refs(items: &[ShadowPendingItem]) -> String {
418 items
419 .iter()
420 .map(ShadowPendingItem::reference)
421 .collect::<Vec<_>>()
422 .join(", ")
423}
424
425#[derive(Debug, Clone, PartialEq, Eq, Default)]
426pub struct ShadowPendingReport {
427 pub duplicated_in_live_backlog: Vec<ShadowPendingItem>,
428 pub shadow_only: Vec<ShadowPendingItem>,
429}
430
431#[derive(Debug, Clone, PartialEq, Eq)]
432pub struct MalformedPendingItemLine {
433 pub id: String,
434 pub line: usize,
435 pub text: String,
436}
437
438impl MalformedPendingItemLine {
439 pub fn reference(&self) -> String {
440 format!("#{} (line {}): {}", self.id, self.line, self.text)
441 }
442}
443
444#[derive(Debug, Clone, PartialEq, Eq)]
445pub struct MalformedTrackedItemRef {
446 pub component: String,
447 pub item: MalformedPendingItemLine,
448}
449
450impl MalformedTrackedItemRef {
451 pub fn reference(&self) -> String {
452 format!("{} {}", self.component, self.item.reference())
453 }
454}
455
456pub fn detect_malformed_item_lines(body: &str) -> Vec<MalformedPendingItemLine> {
460 body.lines()
461 .enumerate()
462 .filter_map(|(idx, line)| {
463 if parse_item_line(line).is_some() {
464 return None;
465 }
466 let trimmed = line.trim();
467 let (id, id_start) = find_valid_hash_id(trimmed)?;
468 let prefix = &trimmed[..id_start];
469 if !prefix_contains_task_checkbox(prefix) {
470 return None;
471 }
472 Some(MalformedPendingItemLine {
473 id: id.to_string(),
474 line: idx + 1,
475 text: trimmed.to_string(),
476 })
477 })
478 .collect()
479}
480
481pub fn malformed_tracked_item_refs_in_components(
482 content: &str,
483 components: &[element::Component],
484) -> Vec<MalformedTrackedItemRef> {
485 components
486 .iter()
487 .filter(|component| element::is_tracked_work_component(&component.name))
488 .flat_map(|component| {
489 let component_name = component.name.clone();
490 detect_malformed_item_lines(component.content(content))
491 .into_iter()
492 .map(move |item| MalformedTrackedItemRef {
493 component: component_name.clone(),
494 item,
495 })
496 })
497 .collect()
498}
499
500pub fn malformed_tracked_item_refs(content: &str) -> Vec<MalformedTrackedItemRef> {
501 let Ok(components) = element::parse(content) else {
502 return Vec::new();
503 };
504 malformed_tracked_item_refs_in_components(content, &components)
505}
506
507pub fn malformed_tracked_item_interruption_message(refs: &[String]) -> String {
508 format!(
509 "[session-check] INTERRUPTED: malformed tracked checklist item(s) in live backlog/icebox: {}. Repair the checklist prefix before closeout so pending guards can prove the item state",
510 refs.join("; ")
511 )
512}
513
514fn find_valid_hash_id(line: &str) -> Option<(&str, usize)> {
515 let start = line.find("[#")?;
516 let after = &line[start + 2..];
517 let close = after.find(']')?;
518 let id = &after[..close];
519 is_valid_pending_id(id).then_some((id, start))
520}
521
522fn prefix_contains_task_checkbox(prefix: &str) -> bool {
523 prefix.contains("[ ]")
524 || prefix.contains("[/]")
525 || prefix.contains("[x]")
526 || prefix.contains("[X]")
527 || prefix
528 .split("[/")
529 .nth(1)
530 .and_then(|tail| tail.split(']').next())
531 .is_some_and(|gate| {
532 !gate.is_empty()
533 && gate
534 .chars()
535 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
536 })
537}
538
539pub fn parse_items(body: &str) -> (String, Vec<PendingItem>, String) {
549 let layout = PendingLayout::parse(body);
550 let Some(first_item) = layout.first_item_index() else {
551 return (body.to_string(), Vec::new(), String::new());
552 };
553 let last_item = layout
554 .segments
555 .iter()
556 .rposition(|segment| matches!(segment, PendingSegment::Item { .. }))
557 .unwrap_or(first_item);
558
559 let mut prelude = String::new();
560 for segment in &layout.segments[..first_item] {
561 if let PendingSegment::Text(raw) = segment {
562 prelude.push_str(raw);
563 }
564 }
565
566 let mut postlude = String::new();
567 if last_item + 1 < layout.segments.len() {
568 for segment in &layout.segments[last_item + 1..] {
569 if let PendingSegment::Text(raw) = segment {
570 postlude.push_str(raw);
571 }
572 }
573 }
574
575 let mut items = Vec::new();
576 for segment in &layout.segments[first_item..=last_item] {
577 if let PendingSegment::Item { item, .. } = segment {
578 items.push(item.clone());
579 }
580 }
581
582 (prelude, items, postlude)
583}
584
585#[derive(Debug, Clone, PartialEq, Eq)]
586pub struct TrackedComponentItemDrop {
587 pub component: String,
588 pub before: usize,
589 pub after: usize,
590}
591
592pub fn tracked_component_item_counts(content: &str) -> BTreeMap<String, usize> {
598 let mut counts = BTreeMap::new();
599 let Ok(components) = element::parse(content) else {
600 return counts;
601 };
602 for component in &components {
603 if component.name == "exchange" {
604 continue;
605 }
606 let (_, items, _) = parse_items(component.content(content));
607 if !items.is_empty() {
608 counts.insert(component.name.clone(), items.len());
609 }
610 }
611 counts
612}
613
614pub fn completed_items(body: &str) -> Vec<PendingItem> {
615 let (_, items, _) = parse_items(body);
616 items.into_iter().filter(PendingItem::is_done).collect()
617}
618
619pub fn completed_tracked_items_in_components(
620 content: &str,
621 components: &[element::Component],
622) -> Vec<PendingItem> {
623 components
624 .iter()
625 .filter(|component| element::is_tracked_work_component(&component.name))
626 .flat_map(|component| completed_items(component.content(content)))
627 .collect()
628}
629
630pub fn completed_tracked_items_in_content(content: &str) -> Result<Vec<PendingItem>> {
631 let components = element::parse(content)?;
632 Ok(completed_tracked_items_in_components(content, &components))
633}
634
635pub fn tracked_item_ref(item: &PendingItem) -> String {
636 if item.id.is_empty() {
637 format!("<missing-id> {}", item.text)
638 } else {
639 format!("#{}", item.id)
640 }
641}
642
643pub fn tracked_item_refs(items: &[PendingItem]) -> Vec<String> {
644 items.iter().map(tracked_item_ref).collect()
645}
646
647pub fn ensure_no_completed_tracked_items(content: &str, surface: &str) -> Result<()> {
648 let completed = completed_tracked_items_in_content(content).with_context(|| {
649 format!("failed to parse {surface} components during pending reap check")
650 })?;
651 if completed.is_empty() {
652 return Ok(());
653 }
654
655 let refs = tracked_item_refs(&completed).join(", ");
656 bail!("pending maintenance left completed tracked items in the {surface}: {refs}");
657}
658
659pub fn component_matches_tracked_surface(name: &str, surface: &str) -> bool {
660 if element::is_backlog_component(surface) {
661 element::is_backlog_component(name)
662 } else {
663 name == surface
664 }
665}
666
667pub fn maintenance_surface_label(surface: &str) -> &'static str {
668 if element::is_backlog_component(surface) {
669 "pending"
670 } else if element::is_review_component(surface) {
671 "review"
672 } else {
673 "icebox"
674 }
675}
676
677pub fn should_reap_already_done_mirrors(surface: &str) -> bool {
678 element::is_backlog_component(surface) || element::is_review_component(surface)
679}
680
681pub fn should_reap_ops_proof_completions(surface: &str) -> bool {
682 element::is_backlog_component(surface) || element::is_review_component(surface)
683}
684
685pub fn tracked_body_for_reorder(content: &str) -> Option<&str> {
686 element::parse(content).ok().and_then(|comps| {
687 comps
688 .into_iter()
689 .find(|component| element::is_backlog_component(&component.name))
690 .map(|component| component.content(content))
691 })
692}
693
694pub fn review_counts(content: &str) -> (usize, usize) {
695 let Some(body) = element::parse(content).ok().and_then(|comps| {
696 comps
697 .into_iter()
698 .find(|component| element::is_review_component(&component.name))
699 .map(|component| component.content(content).to_string())
700 }) else {
701 return (0, 0);
702 };
703 let (_, items, _) = parse_items(&body);
704 let review_items: Vec<_> = items.into_iter().filter(|item| !item.is_done()).collect();
705 let gated = review_items
706 .iter()
707 .filter(|item| matches!(item.state, PendingState::Gated))
708 .count();
709 (review_items.len(), gated)
710}
711
712pub fn open_tracked_work_ids_in_content(content: &str) -> Vec<String> {
713 let Ok(components) = element::parse(content) else {
714 return Vec::new();
715 };
716 components
717 .into_iter()
718 .filter(|component| element::is_tracked_work_component(&component.name))
719 .flat_map(|component| {
720 let (_, items, _) = parse_items(component.content(content));
721 items
722 })
723 .filter(|item| !item.is_done())
724 .map(|item| item.id)
725 .collect()
726}
727
728pub fn open_backlog_ids_in_content(content: &str) -> Vec<String> {
729 let Ok(components) = element::parse(content) else {
730 return Vec::new();
731 };
732 components
733 .into_iter()
734 .filter(|component| element::is_backlog_component(&component.name))
735 .flat_map(|component| {
736 let (_, items, _) = parse_items(component.content(content));
737 items
738 })
739 .filter(|item| !item.is_done())
740 .map(|item| item.id)
741 .filter(|id| !id.is_empty())
742 .collect()
743}
744
745pub fn tracked_work_ids_from_component_body(body: &str) -> HashSet<String> {
746 let (_, items, _) = parse_items(body);
747 items
748 .into_iter()
749 .filter(|item| !item.is_done())
750 .map(|item| normalize_pending_id(&item.id))
751 .filter(|id| !id.is_empty())
752 .collect()
753}
754
755pub fn tracked_work_ids_for_target(
756 content: &str,
757 preferred_component: Option<&str>,
758) -> Result<HashSet<String>> {
759 let components = element::parse(content)?;
760 let component = preferred_component
761 .and_then(|name| components.iter().find(|component| component.name == name))
762 .or_else(|| {
763 components
764 .iter()
765 .find(|component| element::is_backlog_component(&component.name))
766 })
767 .or_else(|| {
768 components
769 .iter()
770 .find(|component| element::is_tracked_work_component(&component.name))
771 });
772 Ok(component
773 .map(|component| tracked_work_ids_from_component_body(component.content(content)))
774 .unwrap_or_default())
775}
776
777pub fn dropped_tracked_component_items(before: &str, after: &str) -> Vec<TrackedComponentItemDrop> {
779 let before_counts = tracked_component_item_counts(before);
780 let after_counts = tracked_component_item_counts(after);
781
782 before_counts
783 .into_iter()
784 .filter_map(|(component, before)| {
785 let after = after_counts.get(&component).copied().unwrap_or(0);
786 (after < before).then_some(TrackedComponentItemDrop {
787 component,
788 before,
789 after,
790 })
791 })
792 .collect()
793}
794
795fn parse_item_line(line: &str) -> Option<PendingItem> {
800 let (marker, rest) = parse_parent_list_marker(line)?;
801 let rest = rest.trim_start();
802
803 let (state, gate_type, after_box) = if let Some(r) = rest.strip_prefix("[ ]") {
805 (PendingState::Open, None, r.trim_start())
806 } else if let Some(r) = rest.strip_prefix("[/]") {
807 (PendingState::Gated, None, r.trim_start())
808 } else if let Some(inner) = rest.strip_prefix("[/") {
809 if let Some(close) = inner.find(']') {
811 let gt = &inner[..close];
812 if !gt.is_empty()
813 && gt
814 .chars()
815 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
816 {
817 let r = &inner[close + 1..];
818 (PendingState::Gated, Some(gt.to_lowercase()), r.trim_start())
819 } else {
820 (PendingState::Open, None, rest)
821 }
822 } else {
823 (PendingState::Open, None, rest)
824 }
825 } else if let Some(r) = rest.strip_prefix("[x]") {
826 (PendingState::Done, None, r.trim_start())
827 } else if let Some(r) = rest.strip_prefix("[X]") {
828 (PendingState::Done, None, r.trim_start())
829 } else if let Some(r) = rest.strip_prefix("[~]") {
830 (PendingState::Open, None, r.trim_start())
833 } else {
834 (PendingState::Open, None, rest)
835 };
836
837 let (in_progress, after_box) = consume_in_progress_marker(after_box);
838
839 let (id, text) = if let Some(after_hash) = after_box.strip_prefix("[#") {
841 if let Some(close) = after_hash.find(']') {
842 let id_raw = &after_hash[..close];
843 let tail = after_hash[close + 1..].trim_start();
844 if is_valid_pending_id(id_raw) {
845 (id_raw.to_lowercase(), tail.to_string())
846 } else if id_raw.is_empty() {
847 (String::new(), tail.to_string())
849 } else {
850 (String::new(), after_box.to_string())
851 }
852 } else {
853 (String::new(), after_box.to_string())
854 }
855 } else {
856 (String::new(), after_box.to_string())
857 };
858
859 let text = if id.is_empty() {
866 text
867 } else {
868 strip_redundant_self_id_tag(&text, &id)
869 };
870
871 Some(PendingItem {
872 marker,
873 id,
874 state,
875 gate_type,
876 in_progress,
877 text: text.trim_end().to_string(),
878 continuation: String::new(),
879 })
880}
881
882fn consume_in_progress_marker(text: &str) -> (bool, &str) {
883 let mut rest = text.trim_start();
884 let mut seen = false;
885 while let Some(after_marker) = rest.strip_prefix(IN_PROGRESS_MARKER) {
886 seen = true;
887 rest = after_marker.trim_start();
888 }
889 (seen, rest)
890}
891
892fn strip_redundant_self_id_tag(text: &str, id: &str) -> String {
896 let mut out = String::with_capacity(text.len());
897 let mut rest = text;
898 let mut stripped = false;
899 while let Some(pos) = rest.find("[#") {
900 if !stripped {
901 let after = &rest[pos + 2..];
902 if let Some(close) = after.find(']') {
903 let tok = &after[..close];
904 if is_valid_pending_id(tok) && tok.eq_ignore_ascii_case(id) {
905 out.push_str(&rest[..pos]);
906 rest = &after[close + 1..];
907 stripped = true;
908 continue;
909 }
910 }
911 }
912 let keep_to = pos + 2;
913 out.push_str(&rest[..keep_to]);
914 rest = &rest[keep_to..];
915 }
916 out.push_str(rest);
917 if stripped {
918 collapse_inline_spaces(&out)
919 } else {
920 out
921 }
922}
923
924fn collapse_inline_spaces(text: &str) -> String {
927 let mut out = String::with_capacity(text.len());
928 let mut prev_space = false;
929 for ch in text.chars() {
930 if ch == ' ' || ch == '\t' {
931 if !prev_space {
932 out.push(' ');
933 }
934 prev_space = true;
935 } else {
936 prev_space = false;
937 out.push(ch);
938 }
939 }
940 out.trim().to_string()
941}
942
943fn parse_parent_list_marker(line: &str) -> Option<(PendingListMarker, &str)> {
944 if let Some(rest) = line.strip_prefix("- ") {
945 return Some((PendingListMarker::Bullet, rest));
946 }
947
948 let digit_len = line.bytes().take_while(|b| b.is_ascii_digit()).count();
949 if digit_len == 0 {
950 return None;
951 }
952
953 let (digits, tail) = line.split_at(digit_len);
954 let tail = tail.strip_prefix('.')?;
955 if !tail.starts_with(char::is_whitespace) {
956 return None;
957 }
958 let rest = tail.trim_start();
959 let ordinal = digits.parse::<usize>().ok()?;
960 Some((PendingListMarker::Ordered(ordinal), rest))
961}
962
963fn is_indented_continuation_line(line: &str) -> bool {
964 line.starts_with(' ') || line.starts_with('\t')
965}
966
967fn is_structural_inter_item_line(line: &str) -> bool {
968 let trimmed = line.trim();
969 trimmed.starts_with('#')
970 || trimmed.starts_with("<!--")
971 || matches!(trimmed, "---" | "***" | "___")
972}
973
974fn split_reapable_trailing_text_segment(raw: &str) -> Option<(String, String)> {
975 if raw.is_empty() {
976 return None;
977 }
978
979 let mut reap = String::new();
980 let mut keep = String::new();
981 let mut pending_blank = String::new();
982 let mut reaping = true;
983
984 for segment in raw.split_inclusive('\n') {
985 let line = segment.strip_suffix('\n').unwrap_or(segment);
986 if !reaping {
987 keep.push_str(segment);
988 continue;
989 }
990 if line.trim().is_empty() {
991 pending_blank.push_str(segment);
992 continue;
993 }
994 if is_structural_inter_item_line(line) {
995 keep.push_str(&pending_blank);
996 pending_blank.clear();
997 keep.push_str(segment);
998 reaping = false;
999 continue;
1000 }
1001 reap.push_str(&pending_blank);
1002 pending_blank.clear();
1003 reap.push_str(segment);
1004 }
1005
1006 if reap.is_empty() {
1007 return None;
1008 }
1009 reap.push_str(&pending_blank);
1010 Some((reap, keep))
1011}
1012
1013pub fn is_valid_pending_id(s: &str) -> bool {
1014 !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
1015}
1016
1017pub fn normalize_pending_id(id: &str) -> String {
1018 id.trim().trim_start_matches('#').to_ascii_lowercase()
1019}
1020
1021#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1022pub enum TrackedWorkList {
1023 Backlog,
1024 Icebox,
1025}
1026
1027impl TrackedWorkList {
1028 pub fn label(self) -> &'static str {
1029 match self {
1030 Self::Backlog => "backlog",
1031 Self::Icebox => "icebox",
1032 }
1033 }
1034
1035 pub fn matches_component_name(self, name: &str) -> bool {
1036 match self {
1037 Self::Backlog => element::is_backlog_component(name),
1038 Self::Icebox => element::is_icebox_component(name),
1039 }
1040 }
1041}
1042
1043pub fn find_tracked_work_component_in_content(
1044 content: &str,
1045 list: TrackedWorkList,
1046) -> Result<element::Component> {
1047 let components = element::parse(content).context("failed to parse components")?;
1048 components
1049 .into_iter()
1050 .find(|component| list.matches_component_name(&component.name))
1051 .with_context(|| format!("document has no {} component", list.label()))
1052}
1053
1054pub fn document_active_identities(content: &str) -> BTreeMap<String, Vec<String>> {
1061 let mut sources: BTreeMap<String, Vec<String>> = BTreeMap::new();
1062 if let Ok((frontmatter, _)) = agent_doc_frontmatter::frontmatter::parse(content) {
1063 for key in frontmatter.prompt_presets.keys() {
1064 let id = normalize_pending_id(key);
1065 if !id.is_empty() {
1066 sources
1067 .entry(id)
1068 .or_default()
1069 .push("prompt_presets".to_string());
1070 }
1071 }
1072 }
1073 if let Ok(components) = element::parse(content) {
1074 for component in components {
1075 let label = if element::is_backlog_component(&component.name) {
1076 "agent:backlog"
1077 } else if element::is_review_component(&component.name) {
1078 "agent:review"
1079 } else if element::is_icebox_component(&component.name) {
1080 "agent:icebox"
1081 } else {
1082 continue;
1083 };
1084 let (_, items, _) = parse_items(component.content(content));
1085 for item in items.into_iter().filter(|item| !item.is_done()) {
1086 let id = normalize_pending_id(&item.id);
1087 if !id.is_empty() {
1088 sources.entry(id).or_default().push(label.to_string());
1089 }
1090 }
1091 }
1092 }
1093 sources
1094}
1095
1096pub fn detect_identity_collisions(content: &str) -> Vec<String> {
1102 document_active_identities(content)
1103 .into_iter()
1104 .filter(|(_, srcs)| srcs.len() > 1)
1105 .map(|(id, srcs)| format!("#{id} ({})", srcs.join(" + ")))
1106 .collect()
1107}
1108
1109pub fn identity_collision_for_new_id(content: &str, candidate_id: &str) -> Option<Vec<String>> {
1111 let candidate_id = normalize_pending_id(candidate_id);
1112 if candidate_id.is_empty() {
1113 return None;
1114 }
1115 document_active_identities(content)
1116 .get(&candidate_id)
1117 .filter(|sources| !sources.is_empty())
1118 .cloned()
1119}
1120
1121#[derive(Debug, Clone, PartialEq, Eq)]
1122pub struct ExplicitIdCollision {
1123 pub candidate_id: String,
1124 pub sources: Vec<String>,
1125}
1126
1127pub fn explicit_new_item_id_collision(
1130 full_content: &str,
1131 item: &str,
1132) -> Option<ExplicitIdCollision> {
1133 let candidate_id = explicit_custom_id(item)?;
1134 let candidate_id = normalize_pending_id(&candidate_id);
1135 if candidate_id.is_empty() {
1136 return None;
1137 }
1138 let sources = identity_collision_for_new_id(full_content, &candidate_id)?;
1139 Some(ExplicitIdCollision {
1140 candidate_id,
1141 sources,
1142 })
1143}
1144
1145pub fn ensure_new_item_explicit_id_available(full_content: &str, item: &str) -> Result<()> {
1148 let Some(collision) = explicit_new_item_id_collision(full_content, item) else {
1149 return Ok(());
1150 };
1151 let candidate = collision.candidate_id;
1152 let sources = collision.sources.join(" + ");
1153 bail!(
1154 "pending add: refusing to add item with explicit id `#{candidate}` โ that identity is already active under {sources}. Each #id must have exactly one active meaning per document so `do #id`, queue generation, and \"top backlog item\" stay unambiguous (#preset-item-id-collision-enforce). Choose a different id, or rename the existing {sources} entry first."
1155 );
1156}
1157
1158pub fn find_open_tracked_work_component_in_content(
1159 content: &str,
1160 id: &str,
1161) -> Result<element::Component> {
1162 let id = normalize_pending_id(id);
1163 let components = element::parse(content).context("failed to parse components")?;
1164 components
1165 .into_iter()
1166 .find(|component| {
1167 if !element::is_tracked_work_component(&component.name) {
1168 return false;
1169 }
1170 let (_, items, _) = parse_items(component.content(content));
1171 items
1172 .into_iter()
1173 .any(|item| item.id == id && item.state != PendingState::Done)
1174 })
1175 .with_context(|| format!("id not found in backlog/icebox: {id}"))
1176}
1177
1178pub fn open_tracked_work_component_name_in_content(
1179 content: &str,
1180 id: &str,
1181) -> Result<Option<String>> {
1182 let id = normalize_pending_id(id);
1183 let components = element::parse(content).context("failed to parse components")?;
1184 for component in components {
1185 if !element::is_tracked_work_component(&component.name) {
1186 continue;
1187 }
1188 let (_, items, _) = parse_items(component.content(content));
1189 if items
1190 .into_iter()
1191 .any(|item| item.id == id && item.state != PendingState::Done)
1192 {
1193 return Ok(Some(component.name));
1194 }
1195 }
1196 Ok(None)
1197}
1198
1199pub fn content_has_resolved_tracked_work_id(content: &str, id: &str) -> Result<bool> {
1200 let id = normalize_pending_id(id);
1201 if id.is_empty() {
1202 return Ok(false);
1203 }
1204
1205 let components = element::parse(content).context("failed to parse components")?;
1206 let archive_ref = format!("[#{id}]");
1207 for component in components {
1208 let body = component.content(content);
1209 if element::is_backlog_done_component(&component.name)
1210 && body
1211 .lines()
1212 .any(|line| line.to_ascii_lowercase().contains(&archive_ref))
1213 {
1214 return Ok(true);
1215 }
1216 if element::is_tracked_work_component(&component.name) {
1217 let (_, items, _) = parse_items(body);
1218 if items
1219 .into_iter()
1220 .any(|item| item.id == id && item.state == PendingState::Done)
1221 {
1222 return Ok(true);
1223 }
1224 }
1225 }
1226 Ok(false)
1227}
1228
1229pub fn trim_tracked_parent_prefix(line: &str) -> &str {
1230 let trimmed = line.trim();
1231 if let Some(rest) = trimmed.strip_prefix("- ") {
1232 return rest.trim_start();
1233 }
1234
1235 let digit_len = trimmed.bytes().take_while(|b| b.is_ascii_digit()).count();
1236 if digit_len == 0 {
1237 return trimmed;
1238 }
1239 let (_, tail) = trimmed.split_at(digit_len);
1240 let Some(tail) = tail.strip_prefix('.') else {
1241 return trimmed;
1242 };
1243 if !tail.starts_with(char::is_whitespace) {
1244 return trimmed;
1245 }
1246 tail.trim_start()
1247}
1248
1249pub fn op_remove_matching_tracked_line(body: &str, target: &str, contains: bool) -> (String, bool) {
1250 let lines: Vec<&str> = body.lines().collect();
1251 let new_lines: Vec<String> = if contains {
1252 lines
1253 .iter()
1254 .filter(|line| !line.contains(target))
1255 .map(|line| line.to_string())
1256 .collect()
1257 } else {
1258 lines
1259 .iter()
1260 .filter(|line| trim_tracked_parent_prefix(line) != target)
1261 .map(|line| line.to_string())
1262 .collect()
1263 };
1264 let removed = new_lines.len() != lines.len();
1265 (new_lines.join("\n"), removed)
1266}
1267
1268pub fn printable_tracked_work_lines(body: &str) -> Vec<String> {
1270 body.lines()
1271 .map(str::trim)
1272 .filter(|line| !line.is_empty())
1273 .map(str::to_string)
1274 .collect()
1275}
1276
1277#[derive(Debug, Clone, PartialEq, Eq, Hash)]
1278pub struct SymptomDedupeKey {
1279 pub invariant_id: String,
1280 pub document_id: String,
1281 pub component: String,
1282 pub content_hash: String,
1283}
1284
1285impl SymptomDedupeKey {
1286 pub fn new(
1287 invariant_id: impl Into<String>,
1288 document_id: impl Into<String>,
1289 component: impl Into<String>,
1290 content_hash: impl Into<String>,
1291 ) -> Result<Self> {
1292 Ok(Self {
1293 invariant_id: validate_symptom_token("invariant", invariant_id.into())?,
1294 document_id: validate_symptom_token("document", document_id.into())?,
1295 component: validate_symptom_token("component", component.into())?,
1296 content_hash: validate_symptom_token("content_hash", content_hash.into())?,
1297 })
1298 }
1299
1300 pub fn marker(&self) -> String {
1301 format!(
1302 "[symptom-key invariant={} document={} component={} content_hash={}]",
1303 self.invariant_id, self.document_id, self.component, self.content_hash
1304 )
1305 }
1306
1307 pub fn log_fields(&self) -> String {
1308 format!(
1309 "invariant={} document={} component={} content_hash={}",
1310 self.invariant_id, self.document_id, self.component, self.content_hash
1311 )
1312 }
1313}
1314
1315#[derive(Debug, Clone, PartialEq, Eq)]
1316pub struct PendingAddOutcome {
1317 pub body: String,
1318 pub id: String,
1319 pub inserted: bool,
1320 pub deduped_key: Option<SymptomDedupeKey>,
1321}
1322
1323#[derive(Debug, Clone, PartialEq, Eq)]
1324pub struct PendingAddBatchItemOutcome {
1325 pub id: String,
1326 pub inserted: bool,
1327 pub deduped_key: Option<SymptomDedupeKey>,
1328}
1329
1330#[derive(Debug, Clone, PartialEq, Eq)]
1331pub struct PendingAddBatchOutcome {
1332 pub body: String,
1333 pub outcomes: Vec<PendingAddBatchItemOutcome>,
1334}
1335
1336pub fn symptom_dedupe_key_from_text(text: &str) -> Result<Option<SymptomDedupeKey>> {
1337 let Some(marker_start) = text.find("[symptom-key ") else {
1338 return Ok(None);
1339 };
1340 let marker_body_start = marker_start + "[symptom-key ".len();
1341 let Some(marker_end_rel) = text[marker_body_start..].find(']') else {
1342 bail!("symptom-key marker is missing closing `]`");
1343 };
1344 let marker_body = &text[marker_body_start..marker_body_start + marker_end_rel];
1345
1346 let mut invariant_id = None;
1347 let mut document_id = None;
1348 let mut component = None;
1349 let mut content_hash = None;
1350 for field in marker_body.split_whitespace() {
1351 let Some((name, value)) = field.split_once('=') else {
1352 bail!("symptom-key field must be key=value: {field}");
1353 };
1354 let slot = match name {
1355 "invariant" => &mut invariant_id,
1356 "document" => &mut document_id,
1357 "component" => &mut component,
1358 "content_hash" => &mut content_hash,
1359 _ => bail!("unknown symptom-key field `{name}`"),
1360 };
1361 if slot.is_some() {
1362 bail!("duplicate symptom-key field `{name}`");
1363 }
1364 *slot = Some(validate_symptom_token(name, value.to_string())?);
1365 }
1366
1367 Ok(Some(SymptomDedupeKey::new(
1368 invariant_id.context("symptom-key missing invariant")?,
1369 document_id.context("symptom-key missing document")?,
1370 component.context("symptom-key missing component")?,
1371 content_hash.context("symptom-key missing content_hash")?,
1372 )?))
1373}
1374
1375fn validate_symptom_token(field: &str, value: String) -> Result<String> {
1376 if value.trim().is_empty() {
1377 bail!("symptom-key {field} must not be empty");
1378 }
1379 if value.trim() != value || !value.chars().all(is_symptom_token_char) {
1380 bail!("symptom-key {field} must be a single field-safe token: {value}");
1381 }
1382 Ok(value)
1383}
1384
1385fn is_symptom_token_char(ch: char) -> bool {
1386 ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | ':' | '/')
1387}
1388
1389fn strip_symptom_dedupe_key_marker(text: &str) -> String {
1390 let Some(marker_start) = text.find("[symptom-key ") else {
1391 return text.trim().to_string();
1392 };
1393 let marker_body_start = marker_start + "[symptom-key ".len();
1394 let Some(marker_end_rel) = text[marker_body_start..].find(']') else {
1395 return text.trim().to_string();
1396 };
1397 let marker_end = marker_body_start + marker_end_rel + 1;
1398 let mut out = String::with_capacity(text.len());
1399 out.push_str(&text[..marker_start]);
1400 out.push_str(&text[marker_end..]);
1401 collapse_inline_spaces(&out)
1402}
1403
1404fn symptom_evidence_line(text: &str) -> String {
1405 let evidence = strip_symptom_dedupe_key_marker(text);
1406 if evidence.is_empty() {
1407 " evidence: repeated symptom".to_string()
1408 } else {
1409 format!(" evidence: {evidence}")
1410 }
1411}
1412
1413fn append_unique_continuation_line(continuation: &str, line: &str) -> String {
1414 if continuation
1415 .lines()
1416 .any(|existing| existing.trim() == line.trim())
1417 {
1418 return continuation.to_string();
1419 }
1420 let mut out = continuation.to_string();
1421 if !out.is_empty() && !out.ends_with('\n') {
1422 out.push('\n');
1423 }
1424 out.push_str(line);
1425 out.push('\n');
1426 out
1427}
1428
1429#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1430enum LeadingCustomIdPrefix {
1431 Explicit,
1432 Bracketed,
1433 BarePlaceholder,
1434}
1435
1436fn detect_leading_custom_id_prefix(text: &str) -> Option<LeadingCustomIdPrefix> {
1437 let trimmed = text.trim_start();
1438 if trimmed.starts_with("id=") {
1439 return Some(LeadingCustomIdPrefix::Explicit);
1440 }
1441 let after_hash = trimmed.strip_prefix("[#")?;
1442 let close = after_hash.find(']')?;
1443 if close == 0 {
1444 Some(LeadingCustomIdPrefix::BarePlaceholder)
1445 } else {
1446 Some(LeadingCustomIdPrefix::Bracketed)
1447 }
1448}
1449
1450pub(crate) fn ensure_no_leading_custom_id_prefix(text: &str, context: &str) -> Result<()> {
1451 match detect_leading_custom_id_prefix(text) {
1452 None => Ok(()),
1453 Some(LeadingCustomIdPrefix::BarePlaceholder) => bail!(
1454 "{}: bare `[#]` placeholder is invalid โ omit it or use `id=<id> <text>`",
1455 context
1456 ),
1457 Some(LeadingCustomIdPrefix::Explicit) | Some(LeadingCustomIdPrefix::Bracketed) => bail!(
1458 "{}: duplicate leading custom id prefix in item text โ use exactly one leading `id=<id>` or `[#id]` prefix",
1459 context
1460 ),
1461 }
1462}
1463
1464pub fn ensure_no_new_leading_custom_id_prefix(
1465 item_id: &str,
1466 text: &str,
1467 existing_ids: &HashSet<String>,
1468 context: &str,
1469) -> Result<()> {
1470 match detect_leading_custom_id_prefix(text) {
1471 None => Ok(()),
1472 Some(LeadingCustomIdPrefix::BarePlaceholder) => bail!(
1473 "{}: bare `[#]` placeholder is invalid โ omit it or use `id=<id> <text>`",
1474 context
1475 ),
1476 Some(LeadingCustomIdPrefix::Explicit) => bail!(
1477 "{}: duplicate leading custom id prefix in item text โ use exactly one leading `id=<id>` or `[#id]` prefix",
1478 context
1479 ),
1480 Some(LeadingCustomIdPrefix::Bracketed) if existing_ids.contains(item_id) => Ok(()),
1481 Some(LeadingCustomIdPrefix::Bracketed) => bail!(
1482 "{}: duplicate leading custom id prefix in item text โ use exactly one leading `id=<id>` or `[#id]` prefix",
1483 context
1484 ),
1485 }
1486}
1487
1488fn extract_inline_tag_id(text: &str) -> Option<(String, String)> {
1489 let mut search_from = 0;
1490 while search_from < text.len() {
1491 let remaining = &text[search_from..];
1492 let pos = match remaining.find("[#") {
1493 Some(p) => p,
1494 None => break,
1495 };
1496 let abs_pos = search_from + pos;
1497 let after_open = match text.get(abs_pos + 2..) {
1498 Some(s) => s,
1499 None => {
1500 search_from = abs_pos + 2;
1501 continue;
1502 }
1503 };
1504 let close = match after_open.find(']') {
1505 Some(c) => c,
1506 None => {
1507 search_from = abs_pos + 2;
1508 continue;
1509 }
1510 };
1511 let raw_id = &after_open[..close];
1512 if !raw_id.is_empty() && is_valid_pending_id(raw_id) {
1513 let tag_end = abs_pos + 2 + close + 1;
1514 let before = text[..abs_pos].trim_end();
1515 let after = text[tag_end..].trim_start();
1516 let new_text = if before.is_empty() {
1517 after.to_string()
1518 } else if after.is_empty() {
1519 before.to_string()
1520 } else {
1521 format!("{} {}", before, after)
1522 };
1523 if !new_text.is_empty() {
1524 return Some((raw_id.to_lowercase(), new_text));
1525 }
1526 }
1527 search_from = abs_pos + 2;
1528 }
1529 None
1530}
1531
1532fn leading_bare_tag_id(text: &str) -> Option<(String, String)> {
1544 let trimmed = text.trim_start();
1545 let after_hash = trimmed.strip_prefix('#')?;
1546 let end = after_hash
1547 .find(|c: char| !(c.is_ascii_alphanumeric() || c == '-'))
1548 .unwrap_or(after_hash.len());
1549 if end == 0 {
1550 return None;
1551 }
1552 let token = &after_hash[..end];
1553 if !is_valid_pending_id(token) {
1554 return None;
1555 }
1556 let separator = after_hash[end..].chars().next()?;
1560 if !separator.is_whitespace() {
1561 return None;
1562 }
1563 let rest = after_hash[end..].trim_start();
1564 if rest.is_empty() {
1565 return None;
1566 }
1567 Some((token.to_lowercase(), rest.to_string()))
1568}
1569
1570fn custom_id_error(raw_id: &str) -> anyhow::Error {
1571 anyhow!(
1572 "pending add: invalid custom id `{}` โ ids must be non-empty ASCII alphanumeric strings (hyphen allowed)",
1573 raw_id.trim()
1574 )
1575}
1576
1577fn parse_explicit_custom_id_prefix(rest: &str) -> Result<(Option<String>, String)> {
1578 let Some((raw_id, remainder)) = rest.split_once(char::is_whitespace) else {
1579 bail!(
1580 "pending add: custom id prefix must be followed by item text (expected `id=<id> <text>`)"
1581 );
1582 };
1583 let custom_id = raw_id.trim().trim_start_matches('#');
1584 if custom_id.is_empty() {
1585 bail!("pending add: empty custom id after `id=` โ expected `id=<id> <text>`");
1586 }
1587 if !is_valid_pending_id(custom_id) {
1588 return Err(custom_id_error(raw_id));
1589 }
1590 let remainder = remainder.trim();
1591 if remainder.is_empty() {
1592 bail!(
1593 "pending add: custom id prefix must be followed by item text (expected `id=<id> <text>`)"
1594 );
1595 }
1596 ensure_no_leading_custom_id_prefix(remainder, "pending add")?;
1597 Ok((Some(custom_id.to_lowercase()), remainder.to_string()))
1598}
1599
1600fn parse_bracketed_custom_id_prefix(trimmed: &str) -> Result<(Option<String>, String)> {
1601 let Some(after_hash) = trimmed.strip_prefix("[#") else {
1602 return Ok((None, trimmed.to_string()));
1603 };
1604 let Some(close) = after_hash.find(']') else {
1605 return Ok((None, trimmed.to_string()));
1606 };
1607 let raw_id = &after_hash[..close];
1608 let remainder = after_hash[close + 1..].trim_start();
1609 if raw_id.is_empty() {
1610 bail!("pending add: bare `[#]` placeholder is invalid โ use `id=<id> <text>` or omit it");
1611 }
1612 if !is_valid_pending_id(raw_id) {
1613 return Err(custom_id_error(raw_id));
1614 }
1615 if remainder.is_empty() {
1616 bail!(
1617 "pending add: bracketed custom id prefix must be followed by item text (expected `[#id] <text>`)"
1618 );
1619 }
1620 ensure_no_leading_custom_id_prefix(remainder, "pending add")?;
1621 Ok((Some(raw_id.to_lowercase()), remainder.to_string()))
1622}
1623
1624fn parse_custom_id_prefix(text: &str) -> Result<(Option<String>, String)> {
1625 let trimmed = text.trim();
1626 if let Some(rest) = trimmed.strip_prefix("id=") {
1627 return parse_explicit_custom_id_prefix(rest);
1628 }
1629 parse_bracketed_custom_id_prefix(trimmed)
1630}
1631
1632pub fn explicit_custom_id(item: &str) -> Option<String> {
1640 match parse_custom_id_prefix(item) {
1641 Ok((id, _)) => {
1642 if id.is_some() {
1643 id
1644 } else {
1645 leading_bare_tag_id(item).map(|(id, _)| id)
1649 }
1650 }
1651 Err(_) => None,
1652 }
1653}
1654
1655#[test]
1656fn existing_item_may_keep_leading_alias_tag() {
1657 let mut existing_ids = HashSet::new();
1658 existing_ids.insert("yckq".to_string());
1659
1660 ensure_no_new_leading_custom_id_prefix(
1661 "yckq",
1662 "[#ss01] ShipStation fix",
1663 &existing_ids,
1664 "pending/backlog patch",
1665 )
1666 .expect("existing alias tag should not be rejected");
1667}
1668
1669#[test]
1670fn new_item_still_rejects_leading_alias_tag() {
1671 let existing_ids = HashSet::new();
1672 let err = ensure_no_new_leading_custom_id_prefix(
1673 "yckq",
1674 "[#ss01] ShipStation fix",
1675 &existing_ids,
1676 "pending/backlog patch",
1677 )
1678 .expect_err("new item alias tag should still be rejected");
1679 assert!(
1680 err.to_string()
1681 .contains("duplicate leading custom id prefix"),
1682 "unexpected error: {}",
1683 err
1684 );
1685}
1686
1687#[allow(dead_code)]
1689pub fn render_items(prelude: &str, items: &[PendingItem], postlude: &str) -> String {
1690 let mut out = String::new();
1691 let ordered_mode = items.iter().any(|item| item.marker.is_ordered());
1692 let mut ordered_index = 1usize;
1693 out.push_str(prelude);
1694 if !prelude.is_empty() && !prelude.ends_with('\n') {
1695 out.push('\n');
1696 }
1697 for item in items {
1698 let render = if ordered_mode {
1699 let render = item.render_with_ordered_index(Some(ordered_index));
1700 ordered_index += 1;
1701 render
1702 } else {
1703 item.render()
1704 };
1705 out.push_str(&render);
1706 if item.continuation.is_empty() || !item.continuation.ends_with('\n') {
1707 out.push('\n');
1708 }
1709 }
1710 if !postlude.is_empty() {
1711 out.push_str(postlude);
1712 if !postlude.ends_with('\n') {
1713 out.push('\n');
1714 }
1715 }
1716 out
1717}
1718
1719pub fn op_take_item(body: &str, id: &str) -> Result<(String, PendingItem)> {
1721 let id = normalize_pending_id(id);
1722 let layout = PendingLayout::parse(body);
1723 let mut taken = None;
1724 let rewritten = layout.replace_items(|item| {
1725 if item.id == id {
1726 taken = Some(item.clone());
1727 None
1728 } else {
1729 Some(item.clone())
1730 }
1731 });
1732 let Some(item) = taken else {
1733 bail!("id not found in pending list: {}", id);
1734 };
1735 Ok((rewritten.render(), item))
1736}
1737
1738pub fn op_take_all_by_id(body: &str, id: &str) -> (String, Vec<PendingItem>) {
1745 let id = normalize_pending_id(id);
1746 let layout = PendingLayout::parse(body);
1747 let mut taken = Vec::new();
1748 let rewritten = layout.replace_items(|item| {
1749 if item.id == id {
1750 taken.push(item.clone());
1751 None
1752 } else {
1753 Some(item.clone())
1754 }
1755 });
1756 (rewritten.render(), taken)
1757}
1758
1759pub fn op_dedupe_identical_items(body: &str) -> (String, Vec<String>) {
1766 let layout = PendingLayout::parse(body);
1767 let mut seen: Vec<PendingItem> = Vec::new();
1768 let mut deduped_ids: Vec<String> = Vec::new();
1769 let rewritten = layout.replace_items(|item| {
1770 if seen.iter().any(|prev| prev == item) {
1771 if !deduped_ids.contains(&item.id) {
1772 deduped_ids.push(item.id.clone());
1773 }
1774 None
1775 } else {
1776 seen.push(item.clone());
1777 Some(item.clone())
1778 }
1779 });
1780 (rewritten.render(), deduped_ids)
1781}
1782
1783pub fn op_insert_item_first(body: &str, item: PendingItem) -> String {
1785 let mut layout = PendingLayout::parse(body);
1786 layout.insert_first_item(item);
1787 layout.render()
1788}
1789
1790pub fn op_append_items(body: &str, items: &[PendingItem]) -> String {
1792 let mut layout = PendingLayout::parse(body);
1793 let insert_at = layout
1794 .segments
1795 .iter()
1796 .rposition(|segment| matches!(segment, PendingSegment::Item { .. }))
1797 .map(|idx| idx + 1)
1798 .unwrap_or_else(|| layout.first_item_index().unwrap_or(layout.segments.len()));
1799 layout.ensure_separator_before(insert_at);
1800 for (offset, item) in items.iter().enumerate() {
1801 layout.segments.insert(
1802 insert_at + offset,
1803 PendingSegment::Item {
1804 item: item.clone(),
1805 has_newline: true,
1806 },
1807 );
1808 }
1809 layout.render()
1810}
1811
1812pub fn op_take_items_by_state(body: &str, state: PendingState) -> (String, Vec<PendingItem>) {
1814 let layout = PendingLayout::parse(body);
1815 let mut taken = Vec::new();
1816 let rewritten = layout.replace_items(|item| {
1817 if item.state == state {
1818 taken.push(item.clone());
1819 None
1820 } else {
1821 Some(item.clone())
1822 }
1823 });
1824 (rewritten.render(), taken)
1825}
1826
1827pub fn op_take_active_items_by_ids(
1829 body: &str,
1830 ids: &HashSet<String>,
1831) -> (String, Vec<PendingItem>) {
1832 if ids.is_empty() {
1833 return (body.to_string(), Vec::new());
1834 }
1835 let ids: HashSet<String> = ids.iter().map(|id| normalize_pending_id(id)).collect();
1836 let layout = PendingLayout::parse(body);
1837 let mut taken = Vec::new();
1838 let rewritten = layout.replace_items(|item| {
1839 if !item.is_done() && ids.contains(&item.id) {
1840 taken.push(item.clone());
1841 None
1842 } else {
1843 Some(item.clone())
1844 }
1845 });
1846 (rewritten.render(), taken)
1847}
1848
1849pub fn canonicalize_preserving_non_item_lines(body: &str) -> String {
1850 PendingLayout::parse(body).render()
1851}
1852
1853fn parse_pending_edit_payload(new_text: &str) -> Result<(String, String)> {
1854 let synthetic = format!("- [ ] [#edit] {}", new_text);
1855 let layout = PendingLayout::parse(&synthetic);
1856 let items = layout.items();
1857 if items.len() != 1 {
1858 bail!(
1859 "pending edit: multiline text may only describe one parent item; use indented continuation lines for child subtasks"
1860 );
1861 }
1862 if layout
1863 .non_item_segments()
1864 .into_iter()
1865 .any(|segment| !segment.trim().is_empty())
1866 {
1867 bail!(
1868 "pending edit: multiline text may only contain indented continuation lines after the first line"
1869 );
1870 }
1871 let mut item = items.into_iter().next().expect("single parsed edit item");
1872 if !item.continuation.is_empty() && !item.continuation.ends_with('\n') {
1873 item.continuation.push('\n');
1874 }
1875 Ok((item.text, item.continuation))
1876}
1877
1878fn trim_boundary_blank_segments(mut segments: Vec<String>) -> Vec<String> {
1879 while matches!(segments.first(), Some(segment) if segment.trim().is_empty()) {
1880 segments.remove(0);
1881 }
1882 while matches!(segments.last(), Some(segment) if segment.trim().is_empty()) {
1883 segments.pop();
1884 }
1885 segments
1886}
1887
1888pub fn preserves_non_item_structure(lhs: &str, rhs: &str) -> bool {
1889 trim_boundary_blank_segments(PendingLayout::parse(lhs).non_item_segments())
1890 == trim_boundary_blank_segments(PendingLayout::parse(rhs).non_item_segments())
1891}
1892
1893pub fn merge_partial_backlog_prefix(current_body: &str, target_body: &str) -> Option<String> {
1894 let current = PendingLayout::parse(current_body);
1895 let target = PendingLayout::parse(target_body);
1896
1897 let current_texts: Vec<(usize, String)> = current
1898 .segments
1899 .iter()
1900 .enumerate()
1901 .filter_map(|(idx, segment)| match segment {
1902 PendingSegment::Text(raw) if !raw.trim().is_empty() => Some((idx, raw.clone())),
1903 _ => None,
1904 })
1905 .collect();
1906 let target_texts: Vec<String> = target
1907 .segments
1908 .iter()
1909 .filter_map(|segment| match segment {
1910 PendingSegment::Text(raw) if !raw.trim().is_empty() => Some(raw.clone()),
1911 _ => None,
1912 })
1913 .collect();
1914
1915 if current_texts.is_empty()
1916 || target_texts.is_empty()
1917 || target_texts.len() >= current_texts.len()
1918 || !target_texts
1919 .iter()
1920 .zip(current_texts.iter())
1921 .all(|(target_text, (_, current_text))| target_text == current_text)
1922 {
1923 return None;
1924 }
1925
1926 let tail_start = current_texts[target_texts.len()].0;
1927 let mut segments = target.segments.clone();
1928 segments.extend(current.segments[tail_start..].iter().cloned());
1929 Some(PendingLayout { segments }.render())
1930}
1931
1932pub fn detect_shadow_open_items(doc: &str) -> Result<ShadowPendingReport> {
1933 let components = agent_doc_element::element::parse(doc)?;
1934 let Some(backlog_component) = components
1935 .iter()
1936 .find(|component| agent_doc_element::element::is_backlog_component(&component.name))
1937 else {
1938 return Ok(ShadowPendingReport::default());
1939 };
1940
1941 let (_, live_items, _) = parse_items(backlog_component.content(doc));
1942 let live_open_ids: HashSet<String> = live_items
1943 .into_iter()
1944 .filter(|item| !item.id.is_empty() && !item.is_done())
1945 .map(|item| item.id)
1946 .collect();
1947
1948 let excluded_ranges: Vec<(usize, usize)> = components
1949 .iter()
1950 .filter(|component| {
1951 agent_doc_element::element::is_tracked_work_component(&component.name)
1952 || component.name == "exchange"
1953 || component.name == "queue"
1963 })
1964 .map(|component| (component.open_start, component.close_end))
1965 .collect();
1966 let code_ranges = agent_doc_element::element::find_code_ranges(doc);
1967
1968 let mut report = ShadowPendingReport::default();
1969 let mut seen_ids = HashSet::new();
1970 let mut offset = 0usize;
1971
1972 for (line_idx, raw_line) in doc.split_inclusive('\n').enumerate() {
1973 let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
1974 let line_start = offset;
1975 let line_end = offset + raw_line.len();
1976 offset = line_end;
1977
1978 if excluded_ranges
1979 .iter()
1980 .any(|(start, end)| line_start < *end && line_end > *start)
1981 {
1982 continue;
1983 }
1984 if code_ranges
1985 .iter()
1986 .any(|(start, end)| line_start < *end && line_end > *start)
1987 {
1988 continue;
1989 }
1990
1991 let Some(item) = parse_item_line(line) else {
1992 continue;
1993 };
1994 if item.id.is_empty() || item.is_done() || !seen_ids.insert(item.id.clone()) {
1995 continue;
1996 }
1997
1998 let shadow = ShadowPendingItem {
1999 id: item.id.clone(),
2000 text: item.text,
2001 line: line_idx + 1,
2002 };
2003 if live_open_ids.contains(&item.id) {
2004 report.duplicated_in_live_backlog.push(shadow);
2005 } else {
2006 report.shadow_only.push(shadow);
2007 }
2008 }
2009
2010 Ok(report)
2011}
2012
2013#[derive(Debug, Clone, PartialEq, Eq)]
2014pub struct DroppedBacklogItem {
2015 pub id: String,
2016 pub text: String,
2017}
2018
2019impl DroppedBacklogItem {
2020 pub fn reference(&self) -> String {
2021 format!("#{}", self.id)
2022 }
2023}
2024
2025pub fn format_dropped_refs(items: &[DroppedBacklogItem]) -> String {
2026 items
2027 .iter()
2028 .map(DroppedBacklogItem::reference)
2029 .collect::<Vec<_>>()
2030 .join(", ")
2031}
2032
2033#[derive(Debug, Clone, PartialEq, Eq, Default)]
2034pub struct DroppedBacklogReport {
2035 pub dropped: Vec<DroppedBacklogItem>,
2036}
2037
2038#[allow(dead_code)]
2052pub fn detect_dropped_from_history(
2053 current_doc: &str,
2054 baseline_doc: &str,
2055 done_ids: &HashSet<String>,
2056) -> Result<DroppedBacklogReport> {
2057 detect_dropped_from_history_with_extra_current_ids(
2058 current_doc,
2059 baseline_doc,
2060 done_ids,
2061 &HashSet::new(),
2062 )
2063}
2064
2065pub fn detect_dropped_from_history_with_extra_current_ids(
2066 current_doc: &str,
2067 baseline_doc: &str,
2068 done_ids: &HashSet<String>,
2069 extra_current_ids: &HashSet<String>,
2070) -> Result<DroppedBacklogReport> {
2071 let baseline_components = agent_doc_element::element::parse(baseline_doc)?;
2072 let Some(baseline_backlog) = baseline_components
2073 .iter()
2074 .find(|c| agent_doc_element::element::is_backlog_component(&c.name))
2075 else {
2076 return Ok(DroppedBacklogReport::default());
2077 };
2078
2079 let (_, baseline_items, _) = parse_items(baseline_backlog.content(baseline_doc));
2080 let baseline_open: Vec<(String, String)> = baseline_items
2081 .into_iter()
2082 .filter(|item| !item.id.is_empty() && !item.is_done())
2083 .map(|item| (item.id, item.text))
2084 .collect();
2085
2086 if baseline_open.is_empty() {
2087 return Ok(DroppedBacklogReport::default());
2088 }
2089
2090 let current_components = agent_doc_element::element::parse(current_doc)?;
2091
2092 let mut current_ids: HashSet<String> = HashSet::new();
2093
2094 for comp in ¤t_components {
2095 if agent_doc_element::element::is_tracked_work_component(&comp.name) {
2096 let (_, items, _) = parse_items(comp.content(current_doc));
2097 for item in items {
2098 if !item.id.is_empty() {
2099 current_ids.insert(item.id);
2100 }
2101 }
2102 } else if agent_doc_element::element::is_backlog_done_component(&comp.name) {
2103 current_ids.extend(extract_pending_ids_from_text(comp.content(current_doc)));
2104 }
2105 }
2106 current_ids.extend(extra_current_ids.iter().cloned());
2107
2108 let excluded_ranges: Vec<(usize, usize)> = current_components
2109 .iter()
2110 .filter(|c| agent_doc_element::element::is_tracked_work_component(&c.name))
2111 .map(|c| (c.open_start, c.close_end))
2112 .collect();
2113 let code_ranges = agent_doc_element::element::find_code_ranges(current_doc);
2114
2115 let mut offset = 0usize;
2116 for raw_line in current_doc.split_inclusive('\n') {
2117 let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
2118 let line_start = offset;
2119 let line_end = offset + raw_line.len();
2120 offset = line_end;
2121
2122 if excluded_ranges
2123 .iter()
2124 .any(|(start, end)| line_start < *end && line_end > *start)
2125 {
2126 continue;
2127 }
2128 if code_ranges
2129 .iter()
2130 .any(|(start, end)| line_start < *end && line_end > *start)
2131 {
2132 continue;
2133 }
2134
2135 if let Some(item) = parse_item_line(line)
2136 && !item.id.is_empty()
2137 {
2138 current_ids.insert(item.id);
2139 }
2140 }
2141
2142 let mut dropped = Vec::new();
2143 for (id, text) in baseline_open {
2144 if done_ids.contains(&id) {
2145 continue;
2146 }
2147 if !current_ids.contains(&id) {
2148 dropped.push(DroppedBacklogItem { id, text });
2149 }
2150 }
2151
2152 Ok(DroppedBacklogReport { dropped })
2153}
2154
2155pub fn active_item_ids(body: &str) -> Vec<String> {
2162 let today = today_civil_day();
2163 PendingLayout::parse(body)
2164 .items()
2165 .into_iter()
2166 .filter(|item| matches!(item.state, PendingState::Open) && !item.id.is_empty())
2167 .filter(|item| !item_precondition_unmet(&item.text, today))
2168 .map(|item| item.id.clone())
2169 .collect()
2170}
2171
2172pub fn set_in_progress_item_ids(body: &str, ids: &HashSet<String>) -> (String, bool) {
2175 let normalized_ids: HashSet<String> = ids
2176 .iter()
2177 .map(|id| normalize_pending_id(id))
2178 .filter(|id| !id.is_empty())
2179 .collect();
2180 let mut changed = false;
2181 let rewritten = PendingLayout::parse(body).replace_items(|item| {
2182 let mut next = item.clone();
2183 let should_mark = !next.id.is_empty()
2184 && !matches!(next.state, PendingState::Done)
2185 && normalized_ids.contains(&next.id.to_ascii_lowercase());
2186 if next.in_progress != should_mark {
2187 next.in_progress = should_mark;
2188 changed = true;
2189 }
2190 Some(next)
2191 });
2192 let new_body = rewritten.render();
2193 if new_body != body {
2194 changed = true;
2195 }
2196 (new_body, changed)
2197}
2198
2199fn item_has_enqueue_marker(text: &str) -> bool {
2200 let lower = text.to_ascii_lowercase();
2201 lower.contains(":inbox_tray:") || lower.split_whitespace().any(token_is_enqueue_marker)
2202}
2203
2204fn token_is_enqueue_marker(token: &str) -> bool {
2205 let trimmed =
2206 token.trim_matches(|ch: char| matches!(ch, '[' | ']' | '(' | ')' | ':' | ',' | '.' | ';'));
2207 matches!(
2208 trimmed,
2209 "/enqueue" | "**enqueue**" | "__enqueue__" | "*enqueue*" | "_enqueue_" | "`enqueue`"
2210 )
2211}
2212
2213pub fn active_enqueue_item_ids(body: &str) -> Vec<String> {
2219 let today = today_civil_day();
2220 PendingLayout::parse(body)
2221 .items()
2222 .into_iter()
2223 .filter(|item| matches!(item.state, PendingState::Open) && !item.id.is_empty())
2224 .filter(|item| item_has_enqueue_marker(&item.text))
2225 .filter(|item| !item_precondition_unmet(&item.text, today))
2226 .map(|item| item.id.clone())
2227 .collect()
2228}
2229
2230pub const PRIORITY_RANK_UNSET: u8 = 10;
2236
2237pub fn item_priority_rank(text: &str) -> u8 {
2238 for token in text.split_whitespace() {
2239 if let Some(value) = token.strip_prefix("priority=")
2240 && let Ok(n) = value.parse::<u8>()
2241 && (1..=9).contains(&n)
2242 {
2243 return n;
2244 }
2245 }
2246 PRIORITY_RANK_UNSET
2247}
2248
2249pub fn active_item_priorities(body: &str) -> Vec<(String, u8)> {
2252 let today = today_civil_day();
2253 PendingLayout::parse(body)
2254 .items()
2255 .into_iter()
2256 .filter(|item| matches!(item.state, PendingState::Open) && !item.id.is_empty())
2257 .filter(|item| !item_precondition_unmet(&item.text, today))
2258 .map(|item| (item.id.clone(), item_priority_rank(&item.text)))
2259 .collect()
2260}
2261
2262pub fn item_not_before_day(text: &str) -> Option<i64> {
2269 const TOK: &str = "not-before=";
2270 let mut search = 0;
2271 while let Some(rel) = text[search..].find(TOK) {
2272 let idx = search + rel;
2273 let boundary_ok = idx == 0
2274 || !text[..idx]
2275 .chars()
2276 .next_back()
2277 .map(|c| c.is_ascii_alphanumeric() || c == '-')
2278 .unwrap_or(false);
2279 search = idx + TOK.len();
2280 if !boundary_ok {
2281 continue;
2282 }
2283 let value: String = text[search..]
2284 .chars()
2285 .take_while(|c| c.is_ascii_digit() || *c == '-')
2286 .collect();
2287 if let Some(day) = parse_ymd_to_civil_day(&value) {
2288 return Some(day);
2289 }
2290 }
2291 None
2292}
2293
2294fn parse_ymd_to_civil_day(value: &str) -> Option<i64> {
2295 let parts: Vec<&str> = value.split('-').collect();
2296 if parts.len() != 3 {
2297 return None;
2298 }
2299 let y: i64 = parts[0].parse().ok()?;
2300 let m: u32 = parts[1].parse().ok()?;
2301 let d: u32 = parts[2].parse().ok()?;
2302 if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
2303 return None;
2304 }
2305 Some(days_from_civil(y, m, d))
2306}
2307
2308fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
2311 let y = if m <= 2 { y - 1 } else { y };
2312 let era = (if y >= 0 { y } else { y - 399 }) / 400;
2313 let yoe = y - era * 400;
2314 let mp = ((m + 9) % 12) as i64;
2315 let doy = (153 * mp + 2) / 5 + d as i64 - 1;
2316 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
2317 era * 146097 + doe - 719468
2318}
2319
2320pub fn today_civil_day() -> i64 {
2324 use std::time::{SystemTime, UNIX_EPOCH};
2325 SystemTime::now()
2326 .duration_since(UNIX_EPOCH)
2327 .map(|d| (d.as_secs() / 86_400) as i64)
2328 .unwrap_or(0)
2329}
2330
2331pub fn item_precondition_unmet(text: &str, today: i64) -> bool {
2336 item_not_before_day(text).is_some_and(|day| day > today)
2337}
2338
2339pub fn item_after_deps(text: &str) -> Vec<String> {
2346 let mut deps = Vec::new();
2347 let bytes = text.as_bytes();
2348 let mut search = 0;
2349 while let Some(rel) = text[search..].find("after=") {
2350 let idx = search + rel;
2351 search = idx + "after=".len();
2352 if idx > 0 && bytes[idx - 1].is_ascii_alphanumeric() {
2353 continue;
2354 }
2355 let value = &text[search..];
2356 let chunk: String = value
2357 .chars()
2358 .take_while(|c| {
2359 c.is_ascii_alphanumeric() || matches!(c, '#' | '-' | '_' | ',' | '[' | ']')
2360 })
2361 .collect();
2362 for part in chunk.split(',') {
2363 let id = part
2364 .trim()
2365 .trim_matches(|c| matches!(c, '#' | '[' | ']'))
2366 .to_ascii_lowercase();
2367 if !id.is_empty() && is_valid_pending_id(&id) {
2368 deps.push(id);
2369 }
2370 }
2371 }
2372 deps
2373}
2374
2375pub fn active_item_after_deps(body: &str) -> Vec<(String, Vec<String>)> {
2378 PendingLayout::parse(body)
2379 .items()
2380 .into_iter()
2381 .filter(|item| matches!(item.state, PendingState::Open) && !item.id.is_empty())
2382 .map(|item| (item.id.clone(), item_after_deps(&item.text)))
2383 .collect()
2384}
2385
2386pub const CLEAN_SESSION_TAG: &str = "[clean-session]";
2391pub const OPERATOR_VERIFY_TAG: &str = "[operator-verify]";
2392pub const FOCUSED_CYCLE_TAG: &str = "[focused-cycle]";
2393
2394#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
2416pub struct ExecutionContext {
2417 pub clean_session_required: bool,
2418 pub operator_verify_required: bool,
2419 pub focused_cycle_required: bool,
2420}
2421
2422impl ExecutionContext {
2423 pub fn is_deferred(&self) -> bool {
2425 self.clean_session_required || self.operator_verify_required || self.focused_cycle_required
2426 }
2427
2428 pub fn loop_undrainable(&self) -> bool {
2434 self.operator_verify_required || self.focused_cycle_required
2435 }
2436
2437 pub fn supervisor_undrainable(&self) -> bool {
2447 self.operator_verify_required
2448 }
2449}
2450
2451pub fn item_clean_session_required(text: &str) -> bool {
2455 text_has_bracket_tag(text, "clean-session")
2456}
2457
2458pub fn item_operator_verify_required(text: &str) -> bool {
2461 text_has_bracket_tag(text, "operator-verify")
2462}
2463
2464pub fn item_focused_cycle_required(text: &str) -> bool {
2467 text_has_bracket_tag(text, "focused-cycle")
2468}
2469
2470pub fn item_execution_context(text: &str) -> ExecutionContext {
2473 ExecutionContext {
2474 clean_session_required: item_clean_session_required(text),
2475 operator_verify_required: item_operator_verify_required(text),
2476 focused_cycle_required: item_focused_cycle_required(text),
2477 }
2478}
2479
2480fn text_has_bracket_tag(text: &str, tag: &str) -> bool {
2481 text.split_whitespace().any(|token| {
2482 let trimmed = token.trim_matches(|c: char| matches!(c, '(' | ')' | ',' | '.' | ';' | ':'));
2483 trimmed.len() == tag.len() + 2
2484 && trimmed.starts_with('[')
2485 && trimmed.ends_with(']')
2486 && trimmed[1..trimmed.len() - 1].eq_ignore_ascii_case(tag)
2487 })
2488}
2489
2490pub fn strip_execution_context_tags(text: &str) -> String {
2494 let kept: Vec<&str> = text
2495 .split_whitespace()
2496 .filter(|token| {
2497 let trimmed =
2498 token.trim_matches(|c: char| matches!(c, '(' | ')' | ',' | '.' | ';' | ':'));
2499 !(trimmed.eq_ignore_ascii_case(CLEAN_SESSION_TAG)
2500 || trimmed.eq_ignore_ascii_case(OPERATOR_VERIFY_TAG)
2501 || trimmed.eq_ignore_ascii_case(FOCUSED_CYCLE_TAG))
2502 })
2503 .collect();
2504 kept.join(" ")
2505}
2506
2507pub fn active_item_execution_contexts(body: &str) -> Vec<(String, ExecutionContext)> {
2511 PendingLayout::parse(body)
2512 .items()
2513 .into_iter()
2514 .filter(|item| matches!(item.state, PendingState::Open) && !item.id.is_empty())
2515 .map(|item| (item.id.clone(), item_execution_context(&item.text)))
2516 .collect()
2517}
2518
2519pub fn sort_by_priority(body: &str) -> Option<String> {
2524 let layout = PendingLayout::parse(body);
2525 let positions: Vec<usize> = layout
2526 .segments
2527 .iter()
2528 .enumerate()
2529 .filter_map(|(i, s)| matches!(s, PendingSegment::Item { .. }).then_some(i))
2530 .collect();
2531 if positions.len() < 2 {
2532 return None;
2533 }
2534 let mut slots: Vec<(usize, PendingItem)> = positions
2535 .iter()
2536 .enumerate()
2537 .map(|(slot, &pos)| match &layout.segments[pos] {
2538 PendingSegment::Item { item, .. } => (slot, item.clone()),
2539 _ => unreachable!(),
2540 })
2541 .collect();
2542 let before: Vec<usize> = slots.iter().map(|(slot, _)| *slot).collect();
2543 slots.sort_by_key(|(_, item)| item_priority_rank(&item.text));
2544 let after: Vec<usize> = slots.iter().map(|(slot, _)| *slot).collect();
2545 if before == after {
2546 return None;
2547 }
2548 let mut segments = layout.segments.clone();
2549 for (target, &pos) in positions.iter().enumerate() {
2550 let item = slots[target].1.clone();
2551 if let PendingSegment::Item { has_newline, .. } = layout.segments[pos] {
2552 segments[pos] = PendingSegment::Item { item, has_newline };
2553 }
2554 }
2555 Some(PendingLayout { segments }.render())
2556}
2557
2558pub fn extract_pending_ids_from_text(text: &str) -> HashSet<String> {
2559 let mut ids = HashSet::new();
2560 let mut rest = text;
2561 while let Some(start) = rest.find("[#") {
2562 let after = &rest[start + 2..];
2563 let Some(end) = after.find(']') else {
2564 break;
2565 };
2566 let id = &after[..end];
2567 if is_valid_pending_id(id) {
2568 ids.insert(id.to_ascii_lowercase());
2569 }
2570 rest = &after[end + 1..];
2571 }
2572 ids
2573}
2574
2575pub fn extract_pending_hash_ids(text: &str) -> Vec<String> {
2581 let mut ids = Vec::new();
2582 let chars = text.char_indices().collect::<Vec<_>>();
2583 let mut idx = 0usize;
2584
2585 while idx < chars.len() {
2586 let (byte_idx, ch) = chars[idx];
2587 if ch != '#' {
2588 idx += 1;
2589 continue;
2590 }
2591
2592 let start = byte_idx + ch.len_utf8();
2593 let mut end = start;
2594 let mut cursor = idx + 1;
2595 while cursor < chars.len() {
2596 let (next_byte, next_ch) = chars[cursor];
2597 if next_ch.is_ascii_alphanumeric() || next_ch == '-' || next_ch == '_' {
2598 end = next_byte + next_ch.len_utf8();
2599 cursor += 1;
2600 continue;
2601 }
2602 break;
2603 }
2604
2605 if end > start {
2606 let id = normalize_pending_id(&text[start..end]);
2607 if !ids.iter().any(|existing| existing == &id) {
2608 ids.push(id);
2609 }
2610 }
2611 idx = cursor.max(idx + 1);
2612 }
2613
2614 ids
2615}
2616
2617pub fn extract_done_item_own_ids(text: &str) -> HashSet<String> {
2628 let mut ids = HashSet::new();
2629 for line in text.lines() {
2630 let trimmed = line.trim_start();
2631 if !(trimmed.starts_with("- ") || trimmed.starts_with("* ")) {
2634 continue;
2635 }
2636 if let Some(start) = trimmed.find("[#") {
2639 let after = &trimmed[start + 2..];
2640 if let Some(end) = after.find(']') {
2641 let id = &after[..end];
2642 if is_valid_pending_id(id) {
2643 ids.insert(id.to_ascii_lowercase());
2644 }
2645 }
2646 }
2647 }
2648 ids
2649}
2650
2651pub fn generate_hash_n(text: &str, doc_id: &str, counter: u64, width: usize) -> String {
2657 use sha2::{Digest, Sha256};
2658 let mut hasher = Sha256::new();
2659 hasher.update(text.as_bytes());
2660 hasher.update(b":");
2661 hasher.update(doc_id.as_bytes());
2662 hasher.update(b":");
2663 hasher.update(counter.to_le_bytes());
2664 let digest = hasher.finalize();
2665
2666 const ALPHABET: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz"; let width = width.clamp(4, 8);
2671 let mut out = String::with_capacity(width);
2672
2673 let b0 = digest[0] as u32;
2676 let b1 = digest[1] as u32;
2677 let b2 = digest[2] as u32;
2678 let v: u32 = (b0 << 16) | (b1 << 8) | b2;
2679 out.push(ALPHABET[((v >> 15) & 0x1f) as usize] as char);
2680 out.push(ALPHABET[((v >> 10) & 0x1f) as usize] as char);
2681 out.push(ALPHABET[((v >> 5) & 0x1f) as usize] as char);
2682 out.push(ALPHABET[(v & 0x1f) as usize] as char);
2683
2684 if width > 4 {
2689 let e0 = digest[3] as u32;
2690 let e1 = digest[4] as u32;
2691 let e2 = digest[5] as u32;
2692 let extra: u32 = (e0 << 16) | (e1 << 8) | e2;
2693 for i in 0..(width - 4) {
2694 let shift = 15 - (i as u32) * 5;
2695 out.push(ALPHABET[((extra >> shift) & 0x1f) as usize] as char);
2696 }
2697 }
2698 out
2699}
2700
2701fn assign_unique_hash(text: &str, doc_id: &str, taken: &HashSet<String>) -> String {
2707 const RETRIES_PER_WIDTH: u64 = 4;
2711 let mut counter: u64 = 0;
2712 loop {
2713 let width = std::cmp::min(4 + (counter / RETRIES_PER_WIDTH) as usize, 8);
2717 let id = generate_hash_n(text, doc_id, counter, width);
2718 if !taken.contains(&id) {
2719 return id;
2720 }
2721 counter = counter.saturating_add(1);
2722 }
2723}
2724
2725fn assign_unique_child_hash(
2729 parent_id: &str,
2730 text: &str,
2731 doc_id: &str,
2732 taken: &HashSet<String>,
2733) -> String {
2734 const RETRIES_PER_WIDTH: u64 = 4;
2735 let seed = format!("{parent_id}:{text}");
2736 let mut counter: u64 = 0;
2737 loop {
2738 let width = std::cmp::min(4 + (counter / RETRIES_PER_WIDTH) as usize, 8);
2739 let suffix = generate_hash_n(&seed, doc_id, counter, width);
2740 let candidate = format!("{parent_id}-{suffix}");
2741 if !taken.contains(&candidate) {
2742 return candidate;
2743 }
2744 counter = counter.saturating_add(1);
2745 }
2746}
2747
2748fn split_indent(line: &str) -> (&str, &str) {
2749 let idx = line
2750 .char_indices()
2751 .find_map(|(idx, ch)| (!ch.is_whitespace()).then_some(idx))
2752 .unwrap_or(line.len());
2753 line.split_at(idx)
2754}
2755
2756fn normalize_nested_subtasks(
2757 continuation: &str,
2758 parent_id: &str,
2759 doc_id: &str,
2760 taken: &mut HashSet<String>,
2761) -> (String, bool) {
2762 if continuation.is_empty() {
2763 return (String::new(), false);
2764 }
2765
2766 let mut changed = false;
2767 let mut out = String::with_capacity(continuation.len());
2768 for raw_line in continuation.split_inclusive('\n') {
2769 let has_newline = raw_line.ends_with('\n');
2770 let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
2771 if !is_indented_continuation_line(line) {
2772 out.push_str(raw_line);
2773 continue;
2774 }
2775
2776 let (indent, trimmed) = split_indent(line);
2777 let Some(mut child) = parse_item_line(trimmed) else {
2778 out.push_str(raw_line);
2779 continue;
2780 };
2781
2782 let duplicate_existing_id = !child.id.is_empty() && taken.contains(&child.id);
2783 if child.id.is_empty() || duplicate_existing_id {
2784 child.id = assign_unique_child_hash(parent_id, &child.text, doc_id, taken);
2785 taken.insert(child.id.clone());
2786 changed = true;
2787 } else {
2788 taken.insert(child.id.clone());
2789 }
2790
2791 let rendered = child.render();
2792 if rendered != trimmed {
2793 changed = true;
2794 }
2795 out.push_str(indent);
2796 out.push_str(&rendered);
2797 if has_newline {
2798 out.push('\n');
2799 }
2800 }
2801 (out, changed)
2802}
2803
2804pub fn backfill(body: &str, doc_id: &str, existing_ids: &HashSet<String>) -> (String, bool) {
2810 let layout = PendingLayout::parse(body);
2811 let items = layout.items();
2812 let mut taken: HashSet<String> = existing_ids.clone();
2813 for item in &items {
2814 if !item.id.is_empty() {
2815 taken.insert(item.id.clone());
2816 }
2817 }
2818
2819 let mut changed = false;
2820 let rewritten = layout.replace_items(|item| {
2821 if item.text.trim().is_empty() && item.continuation.trim().is_empty() {
2828 changed = true;
2829 return None;
2830 }
2831 let mut next = item.clone();
2832 if next.id.is_empty() {
2833 let id = assign_unique_hash(&next.text, doc_id, &taken);
2834 taken.insert(id.clone());
2835 next.id = id;
2836 changed = true;
2837 }
2838 let (continuation, nested_changed) =
2839 normalize_nested_subtasks(&next.continuation, &next.id, doc_id, &mut taken);
2840 if nested_changed {
2841 next.continuation = continuation;
2842 changed = true;
2843 }
2844 Some(next)
2845 });
2846
2847 let new_body = rewritten.render();
2848
2849 if new_body != body {
2852 changed = true;
2853 }
2854 (new_body, changed)
2855}
2856
2857pub fn canonicalize_tracked_work_body(body: &str, doc_id: &str) -> String {
2860 let (canonical, _) = backfill(body, doc_id, &HashSet::new());
2861 canonical
2862}
2863
2864#[allow(dead_code)]
2867pub fn reap(body: &str) -> Result<(String, Vec<String>)> {
2868 let (new_body, removed) = reap_with_items(body)?;
2869 let ids = removed.iter().map(|i| i.id.clone()).collect();
2870 Ok((new_body, ids))
2871}
2872
2873pub fn reap_with_items(body: &str) -> Result<(String, Vec<PendingItem>)> {
2876 let layout = PendingLayout::parse(body);
2877 let items = layout.items();
2878 let missing_done: Vec<&PendingItem> = items
2879 .iter()
2880 .filter(|item| item.is_done() && item.id.is_empty())
2881 .collect();
2882 if !missing_done.is_empty() {
2883 let refs = missing_done
2884 .iter()
2885 .map(|item| format!("\"{}\"", item.text))
2886 .collect::<Vec<_>>()
2887 .join(", ");
2888 bail!(
2889 "pending reap requires ids for completed items; run backfill first (missing ids on done item(s): {})",
2890 refs
2891 );
2892 }
2893 let mut removed = Vec::new();
2894 let mut segments = Vec::with_capacity(layout.segments.len());
2895 let mut index = 0usize;
2896 while index < layout.segments.len() {
2897 match &layout.segments[index] {
2898 PendingSegment::Text(raw) => {
2899 segments.push(PendingSegment::Text(raw.clone()));
2900 index += 1;
2901 }
2902 PendingSegment::Item { item, has_newline } => {
2903 if !item.is_done() {
2904 segments.push(PendingSegment::Item {
2905 item: item.clone(),
2906 has_newline: *has_newline,
2907 });
2908 index += 1;
2909 continue;
2910 }
2911
2912 let mut removed_item = item.clone();
2913 let mut next_index = index + 1;
2914 while let Some(PendingSegment::Text(raw)) = layout.segments.get(next_index) {
2915 if let Some((reap_text, keep_text)) = split_reapable_trailing_text_segment(raw)
2916 {
2917 removed_item.continuation.push_str(&reap_text);
2918 next_index += 1;
2919 if !keep_text.is_empty() {
2920 segments.push(PendingSegment::Text(keep_text));
2921 break;
2922 }
2923 } else {
2924 segments.push(PendingSegment::Text(raw.clone()));
2925 next_index += 1;
2926 break;
2927 }
2928 }
2929 if !removed_item.id.is_empty() {
2930 removed.push(removed_item);
2931 }
2932 index = next_index;
2933 }
2934 }
2935 }
2936 let new_layout = PendingLayout { segments };
2937 if removed.is_empty() {
2938 return Ok((body.to_string(), removed));
2939 }
2940 let new_body = new_layout.render();
2941 Ok((new_body, removed))
2942}
2943
2944pub fn extract_items_by_id(body: &str, ids: &[String]) -> Result<(String, String, Vec<String>)> {
2947 let requested: Vec<String> = ids.iter().map(|id| id.trim().to_lowercase()).collect();
2948 let mut matched_ids = Vec::new();
2949 let mut moved_items = Vec::new();
2950 let remaining = PendingLayout::parse(body).replace_items(|item| {
2951 if requested.contains(&item.id) {
2952 matched_ids.push(item.id.clone());
2953 moved_items.push(item.clone());
2954 None
2955 } else {
2956 Some(item.clone())
2957 }
2958 });
2959 let moved_body = render_items("", &moved_items, "");
2960 Ok((remaining.render(), moved_body, matched_ids))
2961}
2962
2963pub fn detect_reorder(snapshot_body: &str, current_body: &str) -> Option<Vec<String>> {
2966 let (_, snap_items, _) = parse_items(snapshot_body);
2967 let (_, cur_items, _) = parse_items(current_body);
2968
2969 let snap_ids: Vec<String> = snap_items
2970 .iter()
2971 .filter(|i| !i.id.is_empty())
2972 .map(|i| i.id.clone())
2973 .collect();
2974 let cur_ids: Vec<String> = cur_items
2975 .iter()
2976 .filter(|i| !i.id.is_empty())
2977 .map(|i| i.id.clone())
2978 .collect();
2979
2980 if snap_ids.len() != cur_ids.len() {
2981 return None;
2982 }
2983 let snap_set: HashSet<&String> = snap_ids.iter().collect();
2984 let cur_set: HashSet<&String> = cur_ids.iter().collect();
2985 if snap_set != cur_set {
2986 return None;
2987 }
2988 if snap_ids == cur_ids {
2989 return None;
2990 }
2991 Some(cur_ids)
2992}
2993
2994#[derive(Debug, Clone, Copy, PartialEq, Eq)]
3002pub enum AddPosition<'a> {
3003 First,
3005 Last,
3007 After(&'a str),
3009 Before(&'a str),
3011}
3012
3013pub fn op_add(body: &str, text: &str, doc_id: &str, gated: bool) -> Result<(String, String)> {
3014 op_add_at(body, text, doc_id, gated, AddPosition::First)
3015}
3016
3017pub fn op_add_with_outcome(
3018 body: &str,
3019 text: &str,
3020 doc_id: &str,
3021 gated: bool,
3022) -> Result<PendingAddOutcome> {
3023 op_add_at_with_outcome(body, text, doc_id, gated, AddPosition::First)
3024}
3025
3026pub fn op_prepend_many_with_outcomes(
3031 body: &str,
3032 items: &[String],
3033 doc_id: &str,
3034 gated: bool,
3035) -> Result<PendingAddBatchOutcome> {
3036 let mut body = body.to_string();
3037 let mut outcomes = Vec::with_capacity(items.len());
3038 for item in items.iter().rev() {
3039 let outcome = op_add_with_outcome(&body, item, doc_id, gated)?;
3040 body = outcome.body;
3041 outcomes.push(PendingAddBatchItemOutcome {
3042 id: outcome.id,
3043 inserted: outcome.inserted,
3044 deduped_key: outcome.deduped_key,
3045 });
3046 }
3047 outcomes.reverse();
3048 Ok(PendingAddBatchOutcome { body, outcomes })
3049}
3050
3051pub fn op_add_at(
3055 body: &str,
3056 text: &str,
3057 doc_id: &str,
3058 gated: bool,
3059 position: AddPosition<'_>,
3060) -> Result<(String, String)> {
3061 let outcome = op_add_at_with_outcome(body, text, doc_id, gated, position)?;
3062 Ok((outcome.body, outcome.id))
3063}
3064
3065pub fn op_add_at_with_outcome(
3068 body: &str,
3069 text: &str,
3070 doc_id: &str,
3071 gated: bool,
3072 position: AddPosition<'_>,
3073) -> Result<PendingAddOutcome> {
3074 let (custom_id, text) = parse_custom_id_prefix(text)?;
3075 let mut text = text.trim().to_string();
3076 if text.is_empty() {
3077 bail!("pending add: text must be non-empty");
3078 }
3079 if text.starts_with("[ ]")
3080 || text.starts_with("[/]")
3081 || text.starts_with("[x]")
3082 || text.starts_with("[X]")
3083 {
3084 bail!(
3085 "pending add: text must not start with a state marker ([ ], [/], [x]); use --pending-add-gated for gated items"
3086 );
3087 }
3088
3089 let mut inline_custom_id = None;
3090 if custom_id.is_none() {
3091 if let Some((tag_id, cleaned)) = leading_bare_tag_id(&text) {
3092 inline_custom_id = Some(tag_id);
3093 text = cleaned;
3094 } else if let Some((tag_id, cleaned)) = extract_inline_tag_id(&text) {
3095 inline_custom_id = Some(tag_id);
3096 text = cleaned;
3097 }
3098 }
3099
3100 let mut layout = PendingLayout::parse(body);
3101 let items = layout.items();
3102
3103 if let Some(key) = symptom_dedupe_key_from_text(&text)?
3104 && let Some(existing) = items
3105 .iter()
3106 .find(|item| {
3107 item.state != PendingState::Done
3108 && symptom_dedupe_key_from_text(&item.text)
3109 .ok()
3110 .flatten()
3111 .as_ref()
3112 == Some(&key)
3113 })
3114 .cloned()
3115 {
3116 let evidence = symptom_evidence_line(&text);
3117 let rewritten = layout.replace_items(|item| {
3118 if item.id == existing.id {
3119 let mut next = item.clone();
3120 next.continuation = append_unique_continuation_line(&next.continuation, &evidence);
3121 Some(next)
3122 } else {
3123 Some(item.clone())
3124 }
3125 });
3126 return Ok(PendingAddOutcome {
3127 body: rewritten.render(),
3128 id: existing.id,
3129 inserted: false,
3130 deduped_key: Some(key),
3131 });
3132 }
3133
3134 if let Some(existing) = items
3139 .iter()
3140 .find(|i| i.state != PendingState::Done && i.text == text)
3141 {
3142 return Ok(PendingAddOutcome {
3143 body: body.to_string(),
3144 id: existing.id.clone(),
3145 inserted: false,
3146 deduped_key: None,
3147 });
3148 }
3149 if items.iter().any(|i| i.text == text) {
3150 bail!(
3151 "pending add: duplicate completed item text already exists: {}",
3152 text
3153 );
3154 }
3155
3156 let mut taken: HashSet<String> = items
3157 .iter()
3158 .filter(|i| !i.id.is_empty())
3159 .map(|i| i.id.clone())
3160 .collect();
3161
3162 let id = if let Some(custom_id) = custom_id {
3163 if taken.contains(&custom_id) {
3164 bail!("pending add: custom id already exists: {}", custom_id);
3165 }
3166 custom_id
3167 } else if let Some(inline_id) = inline_custom_id {
3168 if taken.contains(&inline_id) {
3169 bail!("pending add: inline tag id already exists: {}", inline_id);
3170 }
3171 inline_id
3172 } else {
3173 assign_unique_hash(&text, doc_id, &taken)
3174 };
3175 taken.insert(id.clone());
3176
3177 let new_item = PendingItem {
3178 marker: PendingListMarker::Bullet,
3179 id: id.clone(),
3180 state: if gated {
3181 PendingState::Gated
3182 } else {
3183 PendingState::Open
3184 },
3185 gate_type: None,
3186 in_progress: false,
3187 text,
3188 continuation: String::new(),
3189 };
3190 match position {
3191 AddPosition::First => layout.insert_first_item(new_item),
3192 AddPosition::Last => {
3193 let index = layout.last_item_index_plus_one();
3194 layout.insert_item_at(index, new_item);
3195 }
3196 AddPosition::After(anchor) => {
3197 let anchor = normalize_pending_id(anchor);
3198 let Some(idx) = layout.item_segment_index(&anchor) else {
3199 bail!("pending add: anchor id not found for --pending-add-after: {anchor}");
3200 };
3201 layout.insert_item_at(idx + 1, new_item);
3202 }
3203 AddPosition::Before(anchor) => {
3204 let anchor = normalize_pending_id(anchor);
3205 let Some(idx) = layout.item_segment_index(&anchor) else {
3206 bail!("pending add: anchor id not found for --pending-add-before: {anchor}");
3207 };
3208 layout.insert_item_at(idx, new_item);
3209 }
3210 }
3211 Ok(PendingAddOutcome {
3212 body: layout.render(),
3213 id,
3214 inserted: true,
3215 deduped_key: None,
3216 })
3217}
3218
3219pub fn op_done(body: &str, id: &str) -> Result<String> {
3222 let id = normalize_pending_id(id);
3223 let mut found = false;
3224 let layout = PendingLayout::parse(body).replace_items(|item| {
3225 if item.id == id {
3226 found = true;
3227 let mut next = item.clone();
3228 next.state = PendingState::Done;
3229 next.gate_type = None;
3230 Some(next)
3231 } else {
3232 Some(item.clone())
3233 }
3234 });
3235 if !found {
3236 return Err(anyhow!("pending done: no item with id [#{}]", id));
3237 }
3238 Ok(layout.render())
3239}
3240
3241pub fn op_gate(body: &str, id: &str) -> Result<String> {
3247 let id = normalize_pending_id(id);
3248 let layout = PendingLayout::parse(body);
3249 let current = layout
3250 .items()
3251 .into_iter()
3252 .find(|item| item.id == id)
3253 .ok_or_else(|| anyhow!("pending gate: no item with id [#{}]", id))?;
3254 let transition = validate_transition(current.state, PendingOp::Gate)?;
3255 let mut found = false;
3256 let mut changed = false;
3257 let rewritten = layout.replace_items(|item| {
3258 if item.id != id {
3259 return Some(item.clone());
3260 }
3261 found = true;
3262 match transition {
3263 TransitionResult::Transition(next) => {
3264 changed = true;
3265 let mut next_item = item.clone();
3266 next_item.state = next;
3267 Some(next_item)
3268 }
3269 TransitionResult::NoOp => Some(item.clone()),
3270 }
3271 });
3272 debug_assert!(found, "validated item must be present during replacement");
3273 if !changed {
3274 Ok(body.to_string())
3275 } else {
3276 Ok(rewritten.render())
3277 }
3278}
3279
3280pub fn op_ungate(body: &str, id: &str) -> Result<String> {
3286 let id = normalize_pending_id(id);
3287 let layout = PendingLayout::parse(body);
3288 let current = layout
3289 .items()
3290 .into_iter()
3291 .find(|item| item.id == id)
3292 .ok_or_else(|| anyhow!("pending ungate: no item with id [#{}]", id))?;
3293 let transition = validate_transition(current.state, PendingOp::Ungate)?;
3294 let mut found = false;
3295 let mut changed = false;
3296 let rewritten = layout.replace_items(|item| {
3297 if item.id != id {
3298 return Some(item.clone());
3299 }
3300 found = true;
3301 match transition {
3302 TransitionResult::Transition(next) => {
3303 changed = true;
3304 let mut next_item = item.clone();
3305 next_item.state = next;
3306 next_item.gate_type = None;
3307 Some(next_item)
3308 }
3309 TransitionResult::NoOp => Some(item.clone()),
3310 }
3311 });
3312 debug_assert!(found, "validated item must be present during replacement");
3313 if !changed {
3314 Ok(body.to_string())
3315 } else {
3316 Ok(rewritten.render())
3317 }
3318}
3319
3320pub fn op_edit(body: &str, id: &str, new_text: &str) -> Result<String> {
3322 let new_text = new_text.trim();
3323 if new_text.is_empty() {
3324 bail!("pending edit: text must be non-empty");
3325 }
3326 let (parsed_text, parsed_continuation) = parse_pending_edit_payload(new_text)?;
3327 let id = normalize_pending_id(id);
3328 let mut found = false;
3329 let layout = PendingLayout::parse(body).replace_items(|item| {
3330 if item.id == id {
3331 found = true;
3332 let mut next = item.clone();
3333 next.text = parsed_text.clone();
3334 next.continuation = parsed_continuation.clone();
3335 Some(next)
3336 } else {
3337 Some(item.clone())
3338 }
3339 });
3340 if !found {
3341 return Err(anyhow!("pending edit: no item with id [#{}]", id));
3342 }
3343 Ok(layout.render())
3344}
3345
3346pub fn op_edit_many(body: &str, edits: &[(String, String)]) -> Result<String> {
3348 let mut next = body.to_string();
3349 for (id, text) in edits {
3350 next = op_edit(&next, id, text)?;
3351 }
3352 Ok(next)
3353}
3354
3355pub fn op_clear(body: &str) -> Result<String> {
3357 let cleared = PendingLayout::parse(body).replace_items(|_| None);
3358 Ok(cleared.render())
3359}
3360
3361pub fn op_reorder(body: &str, ids: &[String]) -> Result<String> {
3364 let layout = PendingLayout::parse(body);
3365 let items = layout.items();
3366 let requested: Vec<String> = ids.iter().map(|s| normalize_pending_id(s)).collect();
3367 for id in &requested {
3368 if !items.iter().any(|i| i.id == *id) {
3369 bail!("pending reorder: no item with id [#{}]", id);
3370 }
3371 }
3372 let mut remaining: Vec<PendingItem> = items.clone();
3373 let mut ordered: Vec<PendingItem> = Vec::new();
3374 for id in &requested {
3375 if let Some(pos) = remaining.iter().position(|i| i.id == *id) {
3376 ordered.push(remaining.remove(pos));
3377 }
3378 }
3379 ordered.extend(remaining);
3380 let mut ordered_iter = ordered.into_iter();
3381 let reordered = layout.replace_items(|_| ordered_iter.next());
3382 Ok(reordered.render())
3383}
3384
3385pub fn op_resolve_gate(body: &str, gate_type: &str) -> (String, Vec<String>) {
3390 let gt = gate_type.trim().to_lowercase();
3391 let mut resolved = Vec::new();
3392 let layout = PendingLayout::parse(body).replace_items(|item| {
3393 if item.state == PendingState::Gated && item.gate_type.as_deref() == Some(gt.as_str()) {
3394 let mut next = item.clone();
3395 next.state = PendingState::Done;
3396 next.gate_type = None;
3397 resolved.push(next.id.clone());
3398 Some(next)
3399 } else {
3400 Some(item.clone())
3401 }
3402 });
3403 (layout.render(), resolved)
3404}
3405
3406pub fn op_set_gate_type(body: &str, id: &str, gate_type: &str) -> Result<String> {
3409 let id = normalize_pending_id(id);
3410 let gt = gate_type.trim().to_lowercase();
3411 if gt.is_empty()
3412 || !gt
3413 .chars()
3414 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
3415 {
3416 bail!("invalid gate type: must be alphanumeric/dash/underscore");
3417 }
3418 let layout = PendingLayout::parse(body);
3419 let current = layout
3420 .items()
3421 .into_iter()
3422 .find(|item| item.id == id)
3423 .ok_or_else(|| anyhow!("pending set-gate-type: no item with id [#{}]", id))?;
3424 if current.state != PendingState::Gated {
3425 bail!(
3426 "pending set-gate-type: item [#{}] must be gated ([/]) to set a typed gate, current state: [{}]",
3427 id,
3428 current.state.box_char()
3429 );
3430 }
3431 let mut found = false;
3432 let rewritten = layout.replace_items(|item| {
3433 if item.id != id {
3434 return Some(item.clone());
3435 }
3436 found = true;
3437 let mut next = item.clone();
3438 next.gate_type = Some(gt.clone());
3439 Some(next)
3440 });
3441 debug_assert!(found, "validated item must be present during replacement");
3442 Ok(rewritten.render())
3443}
3444
3445pub fn op_set_gate_verify(body: &str, id: &str, spec: &str, set_at: u64) -> Result<String> {
3453 let id = normalize_pending_id(id);
3454 let mut predicate = crate::gate_verify::parse_predicate_spec(spec);
3455 if !predicate.is_actionable() {
3456 bail!(
3457 "pending set-verify: spec must include verify=... and/or disproof=..., got: {}",
3458 spec
3459 );
3460 }
3461 predicate.set_at = Some(set_at);
3462
3463 let layout = PendingLayout::parse(body);
3464 let current = layout
3465 .items()
3466 .into_iter()
3467 .find(|item| item.id == id)
3468 .ok_or_else(|| anyhow!("pending set-verify: no item with id [#{}]", id))?;
3469 if current.state != PendingState::Gated {
3470 bail!(
3471 "pending set-verify: item [#{}] must be gated ([/]) to set a verify predicate, current state: [{}]",
3472 id,
3473 current.state.box_char()
3474 );
3475 }
3476 let mut found = false;
3477 let rewritten = layout.replace_items(|item| {
3478 if item.id != id {
3479 return Some(item.clone());
3480 }
3481 found = true;
3482 let mut next = item.clone();
3483 next.text = crate::gate_verify::upsert_annotation(&next.text, &predicate);
3484 Some(next)
3485 });
3486 debug_assert!(found, "validated item must be present during replacement");
3487 Ok(rewritten.render())
3488}
3489
3490#[cfg(test)]
3491mod tests {
3492 use super::*;
3493
3494 const DOC_ID: &str = "test-doc";
3495
3496 fn ids() -> HashSet<String> {
3497 HashSet::new()
3498 }
3499
3500 const TRACKED_COMPONENT_DOC: &str = concat!(
3501 "---\nagent_doc_session: drop-test\nagent_doc_format: template\n---\n\n",
3502 "## Exchange\n\n",
3503 "<!-- agent:exchange patch=append -->\n",
3504 "### Re: topic one\n\nResponse one.\n",
3505 "<!-- /agent:exchange -->\n\n",
3506 "## Backlog\n\n",
3507 "<!-- agent:backlog -->\n",
3508 "- [ ] [#a1] item one\n",
3509 "- [ ] [#a2] item two\n",
3510 "- [ ] [#a3] item three\n",
3511 "<!-- /agent:backlog -->\n\n",
3512 "## Review\n\n",
3513 "<!-- agent:review -->\n",
3514 "- [/] [#r1] review one\n",
3515 "<!-- /agent:review -->\n",
3516 );
3517
3518 #[test]
3519 fn tracked_component_item_counts_counts_backlog_and_review() {
3520 let counts = tracked_component_item_counts(TRACKED_COMPONENT_DOC);
3521 assert_eq!(counts.get("backlog").copied(), Some(3));
3522 assert_eq!(counts.get("review").copied(), Some(1));
3523 assert_eq!(counts.get("exchange"), None);
3524 }
3525
3526 #[test]
3527 fn completed_tracked_items_are_projected_from_tracked_components() {
3528 let doc = concat!(
3529 "<!-- agent:exchange -->\n",
3530 "- [x] [#not-tracked] response list\n",
3531 "<!-- /agent:exchange -->\n",
3532 "<!-- agent:backlog -->\n",
3533 "- [ ] [#open] open item\n",
3534 "- [x] [#done] done item\n",
3535 "<!-- /agent:backlog -->\n",
3536 "<!-- agent:review -->\n",
3537 "- [X] [#review-done] review done item\n",
3538 "<!-- /agent:review -->\n",
3539 );
3540 let completed = completed_tracked_items_in_content(doc).unwrap();
3541 assert_eq!(
3542 completed
3543 .iter()
3544 .map(|item| item.id.as_str())
3545 .collect::<Vec<_>>(),
3546 vec!["done", "review-done"]
3547 );
3548 assert_eq!(
3549 tracked_item_refs(&completed),
3550 vec!["#done".to_string(), "#review-done".to_string()]
3551 );
3552 assert!(ensure_no_completed_tracked_items(TRACKED_COMPONENT_DOC, "working tree").is_ok());
3553 let err = ensure_no_completed_tracked_items(doc, "working tree")
3554 .expect_err("completed tracked items should fail the guard")
3555 .to_string();
3556 assert!(err.contains("working tree"));
3557 assert!(err.contains("#done"));
3558 assert!(err.contains("#review-done"));
3559 }
3560
3561 #[test]
3562 fn tracked_surface_policy_handles_pending_alias_review_and_icebox() {
3563 assert!(component_matches_tracked_surface("backlog", "pending"));
3564 assert!(component_matches_tracked_surface("pending", "backlog"));
3565 assert!(component_matches_tracked_surface("review", "review"));
3566 assert!(!component_matches_tracked_surface("icebox", "review"));
3567
3568 assert_eq!(maintenance_surface_label("backlog"), "pending");
3569 assert_eq!(maintenance_surface_label("review"), "review");
3570 assert_eq!(maintenance_surface_label("icebox"), "icebox");
3571
3572 assert!(should_reap_already_done_mirrors("backlog"));
3573 assert!(should_reap_already_done_mirrors("review"));
3574 assert!(!should_reap_already_done_mirrors("icebox"));
3575
3576 assert!(should_reap_ops_proof_completions("pending"));
3577 assert!(should_reap_ops_proof_completions("review"));
3578 assert!(!should_reap_ops_proof_completions("icebox"));
3579 }
3580
3581 #[test]
3582 fn tracked_surface_body_and_review_counts_are_document_projections() {
3583 assert_eq!(
3584 tracked_body_for_reorder(TRACKED_COMPONENT_DOC).map(str::trim),
3585 Some("- [ ] [#a1] item one\n- [ ] [#a2] item two\n- [ ] [#a3] item three")
3586 );
3587 assert_eq!(review_counts(TRACKED_COMPONENT_DOC), (1, 1));
3588
3589 let no_review = "<!-- agent:backlog -->\n- [ ] [#a] item\n<!-- /agent:backlog -->\n";
3590 assert_eq!(review_counts(no_review), (0, 0));
3591 }
3592
3593 #[test]
3594 fn open_tracked_work_ids_in_content_lists_non_done_tracked_items() {
3595 let doc = concat!(
3596 "<!-- agent:exchange -->\n",
3597 "### Re: ignored\n\nbody\n",
3598 "<!-- /agent:exchange -->\n",
3599 "<!-- agent:backlog -->\n",
3600 "- [ ] [#open] open backlog\n",
3601 "- [/] [#gated] gated backlog\n",
3602 "- [x] [#done] done backlog\n",
3603 "<!-- /agent:backlog -->\n",
3604 "<!-- agent:review -->\n",
3605 "- [ ] [#review] open review\n",
3606 "<!-- /agent:review -->\n",
3607 "<!-- agent:icebox -->\n",
3608 "- [ ] [#ice] open icebox\n",
3609 "<!-- /agent:icebox -->\n",
3610 );
3611
3612 assert_eq!(
3613 open_tracked_work_ids_in_content(doc),
3614 vec![
3615 "open".to_string(),
3616 "gated".to_string(),
3617 "review".to_string(),
3618 "ice".to_string(),
3619 ]
3620 );
3621 }
3622
3623 #[test]
3624 fn open_backlog_ids_in_content_lists_only_non_done_backlog_items() {
3625 let doc = concat!(
3626 "<!-- agent:backlog -->\n",
3627 "- [ ] [#open] open backlog\n",
3628 "- [/] [#gated] gated backlog\n",
3629 "- [x] [#done] done backlog\n",
3630 "<!-- /agent:backlog -->\n",
3631 "<!-- agent:review -->\n",
3632 "- [ ] [#review] open review\n",
3633 "<!-- /agent:review -->\n",
3634 );
3635
3636 assert_eq!(
3637 open_backlog_ids_in_content(doc),
3638 vec!["open".to_string(), "gated".to_string()]
3639 );
3640 }
3641
3642 #[test]
3643 fn tracked_work_ids_from_component_body_lists_normalized_non_done_items() {
3644 let ids = tracked_work_ids_from_component_body(concat!(
3645 "- [ ] [#OPEN] open item\n",
3646 "- [/] [#Gated] gated item\n",
3647 "- [x] [#done] done item\n",
3648 ));
3649
3650 assert_eq!(
3651 ids,
3652 ["open", "gated"].into_iter().map(str::to_string).collect()
3653 );
3654 }
3655
3656 #[test]
3657 fn tracked_work_ids_for_target_prefers_requested_component_then_backlog() {
3658 let doc = concat!(
3659 "<!-- agent:review -->\n",
3660 "- [ ] [#review] review item\n",
3661 "<!-- /agent:review -->\n",
3662 "<!-- agent:backlog -->\n",
3663 "- [ ] [#backlog] backlog item\n",
3664 "<!-- /agent:backlog -->\n",
3665 "<!-- agent:icebox -->\n",
3666 "- [ ] [#ice] icebox item\n",
3667 "<!-- /agent:icebox -->\n",
3668 );
3669
3670 assert_eq!(
3671 tracked_work_ids_for_target(doc, Some("review")).unwrap(),
3672 ["review"].into_iter().map(str::to_string).collect()
3673 );
3674 assert_eq!(
3675 tracked_work_ids_for_target(doc, None).unwrap(),
3676 ["backlog"].into_iter().map(str::to_string).collect()
3677 );
3678 }
3679
3680 #[test]
3681 fn dropped_tracked_component_items_is_empty_when_only_exchange_changes() {
3682 let after = TRACKED_COMPONENT_DOC.replace("Response one.", "*Compacted.*");
3683 assert!(dropped_tracked_component_items(TRACKED_COMPONENT_DOC, &after).is_empty());
3684 }
3685
3686 #[test]
3687 fn dropped_tracked_component_items_reports_decreased_component_count() {
3688 let after = TRACKED_COMPONENT_DOC.replace("- [ ] [#a2] item two\n", "");
3689 assert_eq!(
3690 dropped_tracked_component_items(TRACKED_COMPONENT_DOC, &after),
3691 vec![TrackedComponentItemDrop {
3692 component: "backlog".to_string(),
3693 before: 3,
3694 after: 2,
3695 }]
3696 );
3697 }
3698
3699 #[test]
3700 fn extract_pending_hash_ids_preserves_order_and_accepts_bare_ids() {
3701 let ids = extract_pending_hash_ids("do [#alpha] then #beta then #alpha and #under_score");
3702 assert_eq!(ids, vec!["alpha", "beta", "under_score"]);
3703 }
3704
3705 #[test]
3706 fn extract_done_item_own_ids_ignores_prose_citations() {
3707 let archive = concat!(
3711 "# Agent Doc Completed Work\n\n",
3712 "- 2026-06-19 [#733r] reconcile queue heads.\n",
3713 "- 2026-06-19 [#ftstrike] strike free-text heads regardless of position, ",
3714 "e.g. a head behind do [#fullboundary] is now struck. See [#semmerge].\n",
3715 );
3716 let got = extract_done_item_own_ids(archive);
3717 assert!(got.contains("733r"));
3718 assert!(got.contains("ftstrike"));
3719 assert!(
3720 !got.contains("fullboundary"),
3721 "a [#id] cited in prose must NOT be treated as a done item id: {got:?}"
3722 );
3723 assert!(
3724 !got.contains("semmerge"),
3725 "trailing prose citation excluded: {got:?}"
3726 );
3727 assert!(extract_pending_ids_from_text(archive).contains("fullboundary"));
3729 }
3730
3731 #[test]
3732 fn extract_done_item_own_ids_handles_checkbox_and_skips_prose_lines() {
3733 let body = concat!(
3734 "- [x] [#foo] done thing depends on [#bar].\n",
3735 " continuation prose mentioning [#baz]\n",
3736 "- [/] [#gated] gated item\n",
3737 "plain prose line with [#qux]\n",
3738 );
3739 let got = extract_done_item_own_ids(body);
3740 assert!(got.contains("foo"));
3741 assert!(got.contains("gated"));
3742 assert!(
3743 !got.contains("bar"),
3744 "same-line prose citation excluded: {got:?}"
3745 );
3746 assert!(
3747 !got.contains("baz"),
3748 "continuation-line citation excluded: {got:?}"
3749 );
3750 assert!(
3751 !got.contains("qux"),
3752 "non-list prose line excluded: {got:?}"
3753 );
3754 }
3755
3756 #[test]
3757 fn active_item_ids_returns_open_items_in_order() {
3758 let body = concat!(
3759 "- [ ] [#first] one\n",
3760 "- [/] [#gated] blocked\n",
3761 "- [x] [#done] finished\n",
3762 "- [ ] [#second] two\n",
3763 );
3764 assert_eq!(active_item_ids(body), vec!["first", "second"]);
3765 }
3766
3767 #[test]
3768 fn in_progress_marker_after_checkbox_preserves_pending_id() {
3769 let body = "- [ ] ๐ง [#first] one\n- [/] ๐ง [#gated] blocked\n";
3770 let (_, items, _) = parse_items(body);
3771
3772 assert_eq!(items[0].id, "first");
3773 assert!(items[0].in_progress);
3774 assert_eq!(items[1].id, "gated");
3775 assert!(items[1].in_progress);
3776 assert_eq!(active_item_ids(body), vec!["first"]);
3777 assert_eq!(render_items("", &items, ""), body);
3778 }
3779
3780 #[test]
3781 fn set_in_progress_item_ids_moves_marker_and_clears_stale_items() {
3782 let body = "- [ ] ๐ง [#old] old\n- [ ] [#new] new\n";
3783 let ids = ["new".to_string()].into_iter().collect();
3784
3785 let (updated, changed) = set_in_progress_item_ids(body, &ids);
3786
3787 assert!(changed);
3788 assert_eq!(updated, "- [ ] [#old] old\n- [ ] ๐ง [#new] new\n");
3789 }
3790
3791 #[test]
3792 fn set_in_progress_item_ids_never_marks_done_items() {
3793 let body = concat!(
3794 "- [x] ๐ง [#done] finished\n",
3795 "- [x] [#target] already finished\n",
3796 "- [/] [#review] active review\n",
3797 );
3798 let ids = [
3799 "done".to_string(),
3800 "target".to_string(),
3801 "review".to_string(),
3802 ]
3803 .into_iter()
3804 .collect();
3805
3806 let (updated, changed) = set_in_progress_item_ids(body, &ids);
3807
3808 assert!(changed);
3809 assert_eq!(
3810 updated,
3811 concat!(
3812 "- [x] [#done] finished\n",
3813 "- [x] [#target] already finished\n",
3814 "- [/] ๐ง [#review] active review\n",
3815 )
3816 );
3817 }
3818
3819 #[test]
3820 fn active_item_ids_empty_for_empty_body() {
3821 assert!(active_item_ids("").is_empty());
3822 }
3823
3824 #[test]
3825 fn active_item_ids_holds_future_not_before_items() {
3826 let body = concat!(
3831 "- [ ] [#now] ready now\n",
3832 "- [ ] [#past] not-before=2020-01-01 already due\n",
3833 "- [ ] [#future] not-before=2999-12-31 scheduled later\n",
3834 );
3835 assert_eq!(active_item_ids(body), vec!["now", "past"]);
3836 assert_eq!(
3837 active_item_priorities(body)
3838 .into_iter()
3839 .map(|(id, _)| id)
3840 .collect::<Vec<_>>(),
3841 vec!["now", "past"]
3842 );
3843 }
3844
3845 #[test]
3846 fn active_enqueue_item_ids_holds_future_not_before_items() {
3847 let body = concat!(
3849 "- [ ] [#mark] /enqueue not-before=2999-12-31 scheduled later\n",
3850 "- [ ] [#ready] /enqueue not-before=2020-01-01 due\n",
3851 );
3852 assert_eq!(active_enqueue_item_ids(body), vec!["ready"]);
3853 }
3854
3855 #[test]
3856 fn item_not_before_day_parses_and_validates() {
3857 assert_eq!(item_not_before_day("not-before=1970-01-01 x"), Some(0));
3858 assert_eq!(item_not_before_day("text not-before=1970-01-02 x"), Some(1));
3859 assert_eq!(item_not_before_day("xnot-before=2020-01-01"), None);
3861 assert_eq!(item_not_before_day("not-before=2020-13-01"), None);
3863 assert_eq!(item_not_before_day("not-before=garbage"), None);
3864 assert_eq!(item_not_before_day("no token here"), None);
3865 }
3866
3867 #[test]
3868 fn item_precondition_unmet_compares_against_today() {
3869 let today = days_from_civil(2026, 6, 18);
3870 assert!(item_precondition_unmet("not-before=2026-06-19 x", today));
3871 assert!(!item_precondition_unmet("not-before=2026-06-18 x", today)); assert!(!item_precondition_unmet("not-before=2026-06-17 x", today));
3873 assert!(!item_precondition_unmet("no precondition", today));
3874 }
3875
3876 #[test]
3877 fn active_enqueue_item_ids_returns_marked_open_items() {
3878 let body = concat!(
3879 "- [ ] [#inbox] :inbox_tray: one\n",
3880 "- [/] [#gated] :inbox_tray: blocked\n",
3881 "- [x] [#done] **enqueue** finished\n",
3882 "- [ ] [#bold] **enqueue** two\n",
3883 "- [ ] [#slash] /enqueue three\n",
3884 "- [ ] [#plain] enqueue should not be a marker\n",
3885 "- [ ] untracked :inbox_tray: no id\n",
3886 );
3887 assert_eq!(
3888 active_enqueue_item_ids(body),
3889 vec!["inbox", "bold", "slash"]
3890 );
3891 }
3892
3893 #[test]
3894 fn execution_context_tags_parse_booleans() {
3895 let clean = item_execution_context("[clean-session] needs a quiet session");
3897 assert!(clean.clean_session_required);
3898 assert!(!clean.operator_verify_required);
3899 assert!(clean.is_deferred());
3900
3901 let oper = item_execution_context("ship it [operator-verify] live drive");
3902 assert!(!oper.clean_session_required);
3903 assert!(oper.operator_verify_required);
3904 assert!(oper.is_deferred());
3905
3906 let plain = item_execution_context("just do the thing");
3908 assert!(!plain.clean_session_required);
3909 assert!(!plain.operator_verify_required);
3910 assert!(!plain.is_deferred());
3911
3912 let both =
3914 item_execution_context("[recommended] [TOP] [clean-session] [operator-verify] go");
3915 assert!(both.clean_session_required);
3916 assert!(both.operator_verify_required);
3917
3918 let prose = item_execution_context("run from a clean session please");
3920 assert!(!prose.clean_session_required);
3921 }
3922
3923 #[test]
3924 fn focused_cycle_tag_is_loop_undrainable_but_clean_session_is_not() {
3925 let focused = item_execution_context("[focused-cycle] supervisor-core merge change");
3929 assert!(focused.focused_cycle_required);
3930 assert!(focused.is_deferred());
3931 assert!(
3932 focused.loop_undrainable(),
3933 "[focused-cycle] must be undrainable by the queue loop"
3934 );
3935
3936 let clean = item_execution_context("[clean-session] needs a quiet session");
3939 assert!(clean.is_deferred());
3940 assert!(
3941 !clean.loop_undrainable(),
3942 "[clean-session] drains in place โ never loop_undrainable"
3943 );
3944
3945 let oper = item_execution_context("[operator-verify] live drive");
3947 assert!(oper.loop_undrainable());
3948
3949 assert!(
3953 !focused.supervisor_undrainable(),
3954 "[focused-cycle] is drained by the supervisor clear-and-continue path"
3955 );
3956 assert!(
3957 oper.supervisor_undrainable(),
3958 "[operator-verify] needs a human โ undrainable by any agent scope"
3959 );
3960 assert!(
3961 !clean.supervisor_undrainable(),
3962 "[clean-session] drains everywhere"
3963 );
3964
3965 let plain = item_execution_context("just implement #6b5h");
3967 assert!(!plain.loop_undrainable());
3968 assert!(!plain.supervisor_undrainable());
3969 assert!(!plain.is_deferred());
3970
3971 assert_eq!(
3973 strip_execution_context_tags("[recommended] [focused-cycle] rework the merge seam"),
3974 "[recommended] rework the merge seam"
3975 );
3976 }
3977
3978 #[test]
3979 fn execution_context_tags_stripped_from_display_text() {
3980 assert_eq!(
3982 strip_execution_context_tags("[clean-session] fix the converge path"),
3983 "fix the converge path"
3984 );
3985 assert_eq!(
3986 strip_execution_context_tags("[recommended] [operator-verify] live verify the flow"),
3987 "[recommended] live verify the flow"
3988 );
3989 assert_eq!(
3990 strip_execution_context_tags("[clean-session] [operator-verify] both"),
3991 "both"
3992 );
3993 assert_eq!(
3995 strip_execution_context_tags("plain item text"),
3996 "plain item text"
3997 );
3998 }
3999
4000 #[test]
4001 fn active_item_execution_contexts_skips_closed_and_idless() {
4002 let body = concat!(
4004 "- [ ] [#a] [clean-session] needs quiet\n",
4005 "- [x] [#b] [operator-verify] already done\n",
4006 "- [ ] [#c] plain drainable\n",
4007 );
4008 let ctxs = active_item_execution_contexts(body);
4009 assert_eq!(ctxs.len(), 2);
4010 assert_eq!(ctxs[0].0, "a");
4011 assert!(ctxs[0].1.clean_session_required);
4012 assert_eq!(ctxs[1].0, "c");
4013 assert!(!ctxs[1].1.is_deferred());
4014 }
4015
4016 #[test]
4017 fn item_priority_rank_parses_token() {
4018 assert_eq!(item_priority_rank("priority=1 do the thing"), 1);
4019 assert_eq!(item_priority_rank("text priority=5 more"), 5);
4020 assert_eq!(item_priority_rank("no token here"), PRIORITY_RANK_UNSET);
4021 assert_eq!(
4022 item_priority_rank("priority=0 out of range"),
4023 PRIORITY_RANK_UNSET
4024 );
4025 assert_eq!(
4026 item_priority_rank("priority=12 out of range"),
4027 PRIORITY_RANK_UNSET
4028 );
4029 }
4030
4031 #[test]
4032 fn sort_by_priority_orders_ascending_stable() {
4033 let body = concat!(
4034 "- [ ] [#a] priority=3 third\n",
4035 "- [ ] [#b] no priority\n",
4036 "- [ ] [#c] priority=1 first\n",
4037 "- [ ] [#d] priority=1 also first\n",
4038 );
4039 let sorted = sort_by_priority(body).expect("order should change");
4040 let ids: Vec<&str> = sorted
4042 .lines()
4043 .filter_map(|l| l.split("[#").nth(1))
4044 .filter_map(|s| s.split(']').next())
4045 .collect();
4046 assert_eq!(ids, vec!["c", "d", "a", "b"]);
4047 }
4048
4049 #[test]
4050 fn sort_by_priority_idempotent_when_ordered() {
4051 let body = "- [ ] [#a] priority=1 x\n- [ ] [#b] priority=2 y\n";
4052 assert!(sort_by_priority(body).is_none());
4053 }
4054
4055 #[test]
4056 fn active_item_priorities_pairs_open_ids_with_rank() {
4057 let body = concat!(
4058 "- [ ] [#a] priority=2 one\n",
4059 "- [/] [#g] priority=1 gated\n",
4060 "- [ ] [#b] two\n",
4061 );
4062 assert_eq!(
4063 active_item_priorities(body),
4064 vec![
4065 ("a".to_string(), 2u8),
4066 ("b".to_string(), PRIORITY_RANK_UNSET)
4067 ]
4068 );
4069 }
4070
4071 #[test]
4072 fn op_take_active_items_by_ids_removes_open_and_gated_matches_only() {
4073 let body = concat!(
4074 "intro\n",
4075 "- [ ] [#open] remove open\n",
4076 " child line\n",
4077 "- [/] [#gated] remove gated\n",
4078 "- [x] [#done] keep explicit done\n",
4079 "- [ ] [#keep] keep open\n",
4080 );
4081 let ids: HashSet<String> = ["#open".to_string(), "gated".to_string(), "done".to_string()]
4082 .into_iter()
4083 .collect();
4084
4085 let (new_body, removed) = op_take_active_items_by_ids(body, &ids);
4086
4087 assert_eq!(
4088 removed
4089 .iter()
4090 .map(|item| item.id.as_str())
4091 .collect::<Vec<_>>(),
4092 vec!["open", "gated"]
4093 );
4094 assert!(!new_body.contains("[#open]"));
4095 assert!(!new_body.contains("child line"));
4096 assert!(!new_body.contains("[#gated]"));
4097 assert!(new_body.contains("[#done] keep explicit done"));
4098 assert!(new_body.contains("[#keep] keep open"));
4099 assert!(new_body.starts_with("intro\n"));
4100 }
4101
4102 #[test]
4103 fn parse_empty_body() {
4104 let (p, items, post) = parse_items("");
4105 assert_eq!(p, "");
4106 assert!(items.is_empty());
4107 assert_eq!(post, "");
4108 }
4109
4110 #[test]
4111 fn parse_fully_migrated() {
4112 let body = "- [ ] [#a3f2] first\n- [x] [#b1c4] second\n";
4113 let (_, items, _) = parse_items(body);
4114 assert_eq!(items.len(), 2);
4115 assert_eq!(items[0].marker, PendingListMarker::Bullet);
4116 assert_eq!(items[0].id, "a3f2");
4117 assert_eq!(items[0].state, PendingState::Open);
4118 assert_eq!(items[0].text, "first");
4119 assert_eq!(items[1].id, "b1c4");
4120 assert_eq!(items[1].state, PendingState::Done);
4121 }
4122
4123 #[test]
4124 fn detects_malformed_tracked_checklist_lines() {
4125 let body = concat!(
4126 "_- [ ] [#pcops] damaged prefix\n",
4127 "- [ ] [#keep1] valid item\n",
4128 "plain note mentioning [#note1]\n",
4129 );
4130
4131 let malformed = detect_malformed_item_lines(body);
4132
4133 assert_eq!(malformed.len(), 1);
4134 assert_eq!(malformed[0].id, "pcops");
4135 assert_eq!(malformed[0].line, 1);
4136 assert!(malformed[0].text.contains("damaged prefix"));
4137 }
4138
4139 #[test]
4140 fn malformed_tracked_item_refs_scan_tracked_components() {
4141 let doc = concat!(
4142 "<!-- agent:backlog -->\n",
4143 "_- [ ] [#backlog1] damaged backlog\n",
4144 "<!-- /agent:backlog -->\n\n",
4145 "<!-- agent:review -->\n",
4146 "_- [ ] [#review1] damaged review\n",
4147 "<!-- /agent:review -->\n\n",
4148 "<!-- agent:exchange -->\n",
4149 "_- [ ] [#exchange1] ignored exchange\n",
4150 "<!-- /agent:exchange -->\n",
4151 );
4152
4153 let refs = malformed_tracked_item_refs(doc);
4154
4155 assert_eq!(refs.len(), 2);
4156 assert_eq!(refs[0].component, "backlog");
4157 assert_eq!(refs[0].item.id, "backlog1");
4158 assert_eq!(
4159 refs[0].reference(),
4160 "backlog #backlog1 (line 1): _- [ ] [#backlog1] damaged backlog"
4161 );
4162 assert_eq!(refs[1].component, "review");
4163 assert_eq!(refs[1].item.id, "review1");
4164 }
4165
4166 #[test]
4167 fn malformed_tracked_item_interruption_message_formats_refs() {
4168 let message = malformed_tracked_item_interruption_message(&[
4169 "backlog #a1 (line 1): _- [ ] [#a1] damaged".to_string(),
4170 ]);
4171
4172 assert!(message.contains("malformed tracked checklist item"));
4173 assert!(message.contains("backlog #a1 (line 1)"));
4174 assert!(message.contains("pending guards can prove the item state"));
4175 }
4176
4177 #[test]
4178 fn parse_hyphenated_id() {
4179 let body = "- [ ] [#tmuxcrash-abcd] child task\n";
4180 let (_, items, _) = parse_items(body);
4181 assert_eq!(items.len(), 1);
4182 assert_eq!(items[0].id, "tmuxcrash-abcd");
4183 assert_eq!(items[0].text, "child task");
4184 }
4185
4186 #[test]
4187 fn parse_ordered_items() {
4188 let body = "1. [ ] [#a3f2] first\n2. [x] [#b1c4] second\n";
4189 let (_, items, _) = parse_items(body);
4190 assert_eq!(items.len(), 2);
4191 assert_eq!(items[0].marker, PendingListMarker::Ordered(1));
4192 assert_eq!(items[0].id, "a3f2");
4193 assert_eq!(items[1].marker, PendingListMarker::Ordered(2));
4194 assert_eq!(items[1].state, PendingState::Done);
4195 }
4196
4197 #[test]
4198 fn strips_redundant_self_id_tag_from_text() {
4199 let body = "- [ ] [#stale-x] [recommended] [#stale-x] Evaluate the retire path.\n";
4201 let (_, items, _) = parse_items(body);
4202 assert_eq!(items.len(), 1);
4203 assert_eq!(items[0].id, "stale-x");
4204 assert_eq!(items[0].text, "[recommended] Evaluate the retire path.");
4205 }
4206
4207 #[test]
4208 fn preserves_cross_reference_ids_in_text() {
4209 let body = "- [ ] [#a] depends on [#b] and [#c] downstream.\n";
4211 let (_, items, _) = parse_items(body);
4212 assert_eq!(items.len(), 1);
4213 assert_eq!(items[0].id, "a");
4214 assert_eq!(items[0].text, "depends on [#b] and [#c] downstream.");
4215 }
4216
4217 #[test]
4218 fn parse_gated_state() {
4219 let body = "- [/] [#eg0w] CommitLock โ gate: v0.32.5\n";
4220 let (_, items, _) = parse_items(body);
4221 assert_eq!(items.len(), 1);
4222 assert_eq!(items[0].state, PendingState::Gated);
4223 assert_eq!(items[0].id, "eg0w");
4224 assert_eq!(items[0].text, "CommitLock โ gate: v0.32.5");
4225 }
4226
4227 #[test]
4228 fn parse_all_three_states() {
4229 let body = "- [ ] [#a3f2] open\n- [/] [#b1c4] gated\n- [x] [#c9e0] done\n";
4230 let (_, items, _) = parse_items(body);
4231 assert_eq!(items[0].state, PendingState::Open);
4232 assert_eq!(items[1].state, PendingState::Gated);
4233 assert_eq!(items[2].state, PendingState::Done);
4234 }
4235
4236 #[test]
4237 fn parse_legacy_tilde_checkbox_preserves_hash_id() {
4238 let body = "- [~] [#q6js] in-progress legacy item\n";
4239 let (_, items, _) = parse_items(body);
4240 assert_eq!(items.len(), 1);
4241 assert_eq!(items[0].state, PendingState::Open);
4242 assert_eq!(items[0].id, "q6js");
4243 assert_eq!(items[0].text, "in-progress legacy item");
4244 }
4245
4246 #[test]
4247 fn parse_checkbox_only_no_id() {
4248 let body = "- [ ] just text\n- [x] done item\n";
4249 let (_, items, _) = parse_items(body);
4250 assert_eq!(items.len(), 2);
4251 assert_eq!(items[0].id, "");
4252 assert_eq!(items[0].text, "just text");
4253 assert_eq!(items[1].state, PendingState::Done);
4254 }
4255
4256 #[test]
4257 fn parse_legacy_no_checkbox() {
4258 let body = "- legacy one\n- legacy two\n";
4259 let (_, items, _) = parse_items(body);
4260 assert_eq!(items.len(), 2);
4261 assert_eq!(items[0].text, "legacy one");
4262 assert_eq!(items[0].state, PendingState::Open);
4263 assert_eq!(items[0].id, "");
4264 }
4265
4266 #[test]
4267 fn parse_mixed() {
4268 let body = "- [ ] [#a3f2] migrated\n- [ ] partial\n- legacy\n";
4269 let (_, items, _) = parse_items(body);
4270 assert_eq!(items.len(), 3);
4271 assert_eq!(items[0].id, "a3f2");
4272 assert_eq!(items[1].id, "");
4273 assert_eq!(items[1].text, "partial");
4274 assert_eq!(items[2].text, "legacy");
4275 }
4276
4277 #[test]
4278 fn parse_nested_lines_attach_to_parent_item() {
4279 let body = concat!(
4280 "- [ ] [#a3f2] parent task\n",
4281 " - dependency one\n",
4282 " - dependency two\n",
4283 "- [ ] [#b1c4] sibling task\n"
4284 );
4285 let (_, items, _) = parse_items(body);
4286 assert_eq!(items.len(), 2);
4287 assert_eq!(items[0].id, "a3f2");
4288 assert_eq!(items[0].text, "parent task");
4289 assert_eq!(
4290 items[0].continuation,
4291 " - dependency one\n - dependency two\n"
4292 );
4293 assert_eq!(items[1].id, "b1c4");
4294 assert!(items[1].continuation.is_empty());
4295 }
4296
4297 #[test]
4298 fn parse_nested_lines_attach_to_ordered_parent_item() {
4299 let body = concat!(
4300 "1. [ ] [#a3f2] parent task\n",
4301 " 1. dependency one\n",
4302 " 2. dependency two\n",
4303 "2. [ ] [#b1c4] sibling task\n"
4304 );
4305 let (_, items, _) = parse_items(body);
4306 assert_eq!(items.len(), 2);
4307 assert_eq!(items[0].marker, PendingListMarker::Ordered(1));
4308 assert_eq!(
4309 items[0].continuation,
4310 " 1. dependency one\n 2. dependency two\n"
4311 );
4312 assert_eq!(items[1].marker, PendingListMarker::Ordered(2));
4313 }
4314
4315 #[test]
4316 fn render_roundtrip_canonical() {
4317 let body = "- [ ] [#a3f2] first\n- [x] [#b1c4] second\n";
4318 let (p, items, post) = parse_items(body);
4319 let out = render_items(&p, &items, &post);
4320 assert_eq!(out, body);
4321 }
4322
4323 #[test]
4324 fn render_roundtrip_all_three_states() {
4325 let body = "- [ ] [#a3f2] open\n- [/] [#b1c4] gated โ gate: v0.32.5\n- [x] [#c9e0] done\n";
4326 let (p, items, post) = parse_items(body);
4327 let out = render_items(&p, &items, &post);
4328 assert_eq!(out, body);
4329 }
4330
4331 #[test]
4332 fn render_roundtrip_ordered_list() {
4333 let body = "1. [ ] [#a3f2] open\n2. [/] [#b1c4] gated\n3. [x] [#c9e0] done\n";
4334 let (p, items, post) = parse_items(body);
4335 let out = render_items(&p, &items, &post);
4336 assert_eq!(out, body);
4337 }
4338
4339 #[test]
4340 fn render_emits_slash_for_gated() {
4341 let item = PendingItem {
4342 marker: PendingListMarker::Bullet,
4343 id: "eg0w".to_string(),
4344 state: PendingState::Gated,
4345 gate_type: None,
4346 in_progress: false,
4347 text: "CommitLock".to_string(),
4348 continuation: String::new(),
4349 };
4350 assert_eq!(item.render(), "- [/] [#eg0w] CommitLock");
4351 }
4352
4353 #[test]
4354 fn backfill_adds_hashes() {
4355 let body = "- legacy one\n- legacy two\n";
4356 let (new_body, changed) = backfill(body, DOC_ID, &ids());
4357 assert!(changed);
4358 let (_, items, _) = parse_items(&new_body);
4359 assert_eq!(items.len(), 2);
4360 assert!(!items[0].id.is_empty());
4361 assert!(!items[1].id.is_empty());
4362 assert_ne!(items[0].id, items[1].id);
4363 assert!(new_body.contains("- [ ] [#"));
4364 }
4365
4366 #[test]
4367 fn backfill_idempotent() {
4368 let body = "- [ ] [#a3f2] first\n";
4369 let (new_body, changed) = backfill(body, DOC_ID, &ids());
4370 assert!(!changed, "fully-migrated body should not change");
4371 assert_eq!(new_body, body);
4372 }
4373
4374 #[test]
4375 fn backfill_normalizes_checkbox_only() {
4376 let body = "- [ ] no id here\n";
4377 let (new_body, changed) = backfill(body, DOC_ID, &ids());
4378 assert!(changed);
4379 assert!(new_body.contains("[#"));
4380 }
4381
4382 #[test]
4383 fn backfill_drops_content_less_empty_bullet() {
4384 let body = "- [ ]\n";
4387 let (new_body, changed) = backfill(body, DOC_ID, &ids());
4388 assert!(changed);
4389 assert!(
4390 !new_body.contains("[#"),
4391 "empty bullet must not get a phantom id: {new_body:?}"
4392 );
4393 let (_, items, _) = parse_items(&new_body);
4394 assert!(
4395 items.is_empty(),
4396 "empty bullet should be dropped: {items:?}"
4397 );
4398 }
4399
4400 #[test]
4401 fn backfill_drops_id_only_empty_item_self_heal() {
4402 let body = "- [ ] [#existing] real item\n- [ ] [#1k5y]\n";
4405 let (new_body, changed) = backfill(body, DOC_ID, &ids());
4406 assert!(changed);
4407 assert!(new_body.contains("[#existing] real item"));
4408 assert!(
4409 !new_body.contains("[#1k5y]"),
4410 "phantom id-only item must be dropped: {new_body:?}"
4411 );
4412 let (_, items, _) = parse_items(&new_body);
4413 assert_eq!(items.len(), 1);
4414 }
4415
4416 #[test]
4417 fn backfill_keeps_empty_text_with_continuation() {
4418 let body = "- [ ] [#p1]\n - detail line\n";
4421 let (new_body, _changed) = backfill(body, DOC_ID, &ids());
4422 assert!(
4423 new_body.contains("[#p1]"),
4424 "item with continuation must survive: {new_body:?}"
4425 );
4426 assert!(new_body.contains("detail line"));
4427 }
4428
4429 #[test]
4430 fn backfill_never_inserts_gated() {
4431 let body = "- legacy item awaiting v0.32.5\n";
4434 let (new_body, _) = backfill(body, DOC_ID, &ids());
4435 assert!(new_body.contains("- [ ] "));
4436 assert!(!new_body.contains("- [/] "));
4437 }
4438
4439 #[test]
4440 fn backfill_preserves_existing_gated() {
4441 let body = "- [/] [#eg0w] CommitLock โ gate: v0.32.5\n";
4442 let (new_body, changed) = backfill(body, DOC_ID, &ids());
4443 assert!(!changed);
4444 assert_eq!(new_body, body);
4445 }
4446
4447 #[test]
4448 fn backfill_preserves_id_behind_legacy_tilde_checkbox() {
4449 let body = "- [~] [#q6js] pcpc5e3 08b cut-over proof\n";
4450 let (new_body, changed) = backfill(body, DOC_ID, &ids());
4451 assert!(changed);
4452 assert_eq!(new_body, "- [ ] [#q6js] pcpc5e3 08b cut-over proof\n");
4453 assert_eq!(new_body.matches("[#").count(), 1);
4454
4455 let (_, items, _) = parse_items(&new_body);
4456 assert_eq!(items.len(), 1);
4457 assert_eq!(items[0].state, PendingState::Open);
4458 assert_eq!(items[0].id, "q6js");
4459 assert_eq!(items[0].text, "pcpc5e3 08b cut-over proof");
4460 }
4461
4462 #[test]
4463 fn backfill_preserves_interleaved_headers_and_blank_lines() {
4464 let body = concat!(
4465 "### Active\n",
4466 "- legacy one\n",
4467 "\n",
4468 "### Later\n",
4469 "- [ ] [#keep1] keep section\n"
4470 );
4471 let (new_body, changed) = backfill(body, DOC_ID, &ids());
4472 assert!(changed);
4473 assert!(new_body.contains("### Active"));
4474 assert!(new_body.contains("\n\n### Later\n"));
4475 assert!(new_body.contains("[#keep1] keep section"));
4476 let lines: Vec<&str> = new_body.lines().collect();
4477 assert_eq!(lines[0], "### Active");
4478 assert!(lines[1].starts_with("- [ ] [#"));
4479 assert_eq!(lines[3], "### Later");
4480 }
4481
4482 #[test]
4483 fn backfill_assigns_nested_subtask_ids_prefixed_by_parent() {
4484 let body = concat!(
4485 "- parent task\n",
4486 " - child dependency\n",
4487 " - child subtask\n",
4488 "- sibling task\n"
4489 );
4490 let (new_body, changed) = backfill(body, DOC_ID, &ids());
4491 assert!(changed);
4492 assert_eq!(new_body.matches("[#").count(), 4, "got: {new_body}");
4493 assert!(new_body.contains(" - [ ] [#"));
4494 let lines: Vec<&str> = new_body.lines().collect();
4495 let parent_line = lines[0];
4496 let parent_id = parent_line
4497 .split("[#")
4498 .nth(1)
4499 .and_then(|rest| rest.split(']').next())
4500 .expect("parent id");
4501 assert!(
4502 lines[1].contains(&format!("[#{}-", parent_id)),
4503 "expected first child id prefixed by parent id, got: {}",
4504 lines[1]
4505 );
4506 assert!(
4507 lines[2].contains(&format!("[#{}-", parent_id)),
4508 "expected second child id prefixed by parent id, got: {}",
4509 lines[2]
4510 );
4511 }
4512
4513 #[test]
4514 fn backfill_preserves_existing_nested_subtask_ids() {
4515 let body = concat!(
4516 "- [ ] [#tmuxcrash] parent task\n",
4517 " - [ ] [#tmuxcrash-abcd] child dependency\n"
4518 );
4519 let (new_body, changed) = backfill(body, DOC_ID, &ids());
4520 assert!(!changed);
4521 assert_eq!(new_body, body);
4522 }
4523
4524 #[test]
4525 fn backfill_reassigns_duplicate_existing_nested_subtask_ids() {
4526 let body = concat!(
4527 "- [ ] [#tmuxcrash] parent task\n",
4528 " - [ ] [#tmuxcrash-abcd] child dependency\n",
4529 " - [ ] [#tmuxcrash-abcd] child subtask\n"
4530 );
4531 let (new_body, changed) = backfill(body, DOC_ID, &ids());
4532 assert!(changed);
4533 let lines: Vec<&str> = new_body.lines().collect();
4534 assert_eq!(lines.len(), 3, "got: {new_body}");
4535 assert!(lines[1].contains("[#tmuxcrash-abcd]"));
4536 let second_child_id = lines[2]
4537 .split("[#")
4538 .nth(1)
4539 .and_then(|rest| rest.split(']').next())
4540 .expect("second child id");
4541 assert_ne!(second_child_id, "tmuxcrash-abcd");
4542 assert!(second_child_id.starts_with("tmuxcrash-"));
4543 }
4544
4545 #[test]
4546 fn backfill_preserves_ordered_parent_items() {
4547 let body = concat!("1. legacy one\n", "2. [ ] [#keep1] keep section\n");
4548 let (new_body, changed) = backfill(body, DOC_ID, &ids());
4549 assert!(changed);
4550 assert!(new_body.starts_with("1. [ ] [#"));
4551 assert!(new_body.contains("2. [ ] [#keep1] keep section"));
4552 }
4553
4554 #[test]
4555 fn reap_skips_gated() {
4556 let body = "- [/] [#eg0w] gated\n- [x] [#c9e0] done\n";
4557 let (new_body, removed) = reap(body).unwrap();
4558 assert_eq!(removed, vec!["c9e0"]);
4559 assert!(new_body.contains("[#eg0w]"));
4560 assert!(!new_body.contains("[#c9e0]"));
4561 }
4562
4563 #[test]
4564 fn reap_removes_checked() {
4565 let body = "- [ ] [#a3f2] keep\n- [x] [#b1c4] drop\n- [ ] [#c5d6] keep2\n";
4566 let (new_body, removed) = reap(body).unwrap();
4567 assert_eq!(removed, vec!["b1c4"]);
4568 assert!(new_body.contains("a3f2"));
4569 assert!(!new_body.contains("b1c4"));
4570 assert!(new_body.contains("c5d6"));
4571 }
4572
4573 #[test]
4574 fn reap_removes_flush_left_spill_with_completed_parent() {
4575 let body = concat!(
4576 "- [x] [#b1c4] drop\n",
4577 "Commands:\n",
4578 " cargo test -p agent-doc backlog::\n",
4579 "Diff:\n",
4580 "@@ -1 +1 @@\n",
4581 "- [ ] [#c5d6] keep\n"
4582 );
4583 let (new_body, removed) = reap(body).unwrap();
4584 assert_eq!(removed, vec!["b1c4"]);
4585 assert!(!new_body.contains("[#b1c4]"));
4586 assert!(!new_body.contains("Commands:"));
4587 assert!(!new_body.contains("@@ -1 +1 @@"));
4588 assert!(new_body.contains("- [ ] [#c5d6] keep"));
4589 }
4590
4591 #[test]
4592 fn reap_preserves_following_heading_text() {
4593 let body = concat!(
4594 "- [x] [#b1c4] drop\n",
4595 "\n",
4596 "## Next Group\n",
4597 "- [ ] [#c5d6] keep\n"
4598 );
4599 let (new_body, removed) = reap(body).unwrap();
4600 assert_eq!(removed, vec!["b1c4"]);
4601 assert!(!new_body.contains("[#b1c4]"));
4602 assert!(new_body.contains("## Next Group"));
4603 assert!(new_body.contains("- [ ] [#c5d6] keep"));
4604 }
4605
4606 #[test]
4607 fn reap_noop_when_none_checked() {
4608 let body = "- [ ] [#a3f2] keep\n";
4609 let (new_body, removed) = reap(body).unwrap();
4610 assert!(removed.is_empty());
4611 assert_eq!(new_body, body);
4612 }
4613
4614 #[test]
4615 fn reap_errors_when_completed_item_is_missing_id() {
4616 let body = "- [x] legacy done without id\n- [ ] [#keep1] keep\n";
4617 let err = reap(body).unwrap_err();
4618 assert!(
4619 err.to_string()
4620 .contains("pending reap requires ids for completed items"),
4621 "unexpected error: {err}"
4622 );
4623 }
4624
4625 #[test]
4626 fn reap_with_items_archives_malformed_flush_left_spill_with_parent() {
4627 let body = concat!(
4628 "- [x] [#b1c4] drop\n",
4629 "Commands:\n",
4630 " cargo test -p agent-doc backlog::\n"
4631 );
4632 let (_new_body, removed) = reap_with_items(body).unwrap();
4633 assert_eq!(removed.len(), 1);
4634 assert_eq!(removed[0].id, "b1c4");
4635 assert_eq!(
4636 removed[0].continuation,
4637 "Commands:\n cargo test -p agent-doc backlog::\n"
4638 );
4639 }
4640
4641 #[test]
4642 fn detect_reorder_same_set_different_order() {
4643 let snap = "- [ ] [#a1b2] one\n- [ ] [#c3d4] two\n";
4644 let cur = "- [ ] [#c3d4] two\n- [ ] [#a1b2] one\n";
4645 let result = detect_reorder(snap, cur);
4646 assert_eq!(result, Some(vec!["c3d4".to_string(), "a1b2".to_string()]));
4647 }
4648
4649 #[test]
4650 fn detect_reorder_none_when_sets_differ() {
4651 let snap = "- [ ] [#a1b2] one\n";
4652 let cur = "- [ ] [#a1b2] one\n- [ ] [#c3d4] two\n";
4653 assert_eq!(detect_reorder(snap, cur), None);
4654 }
4655
4656 #[test]
4657 fn detect_reorder_none_when_same_order() {
4658 let snap = "- [ ] [#a1b2] one\n- [ ] [#c3d4] two\n";
4659 assert_eq!(detect_reorder(snap, snap), None);
4660 }
4661
4662 #[test]
4663 fn op_add_inserts_new_item_with_hash() {
4664 let body = "";
4665 let (new_body, id) = op_add(body, "first task", DOC_ID, false).unwrap();
4666 assert!(new_body.contains("- [ ] [#"));
4667 assert!(new_body.contains("first task"));
4668 assert!(!id.is_empty());
4669 }
4670
4671 #[test]
4672 fn op_add_prepends_before_existing_items() {
4673 let body = "- [ ] [#a1b2] existing task\n- [ ] [#c3d4] later task\n";
4674 let (new_body, _id) = op_add(body, "new first task", DOC_ID, false).unwrap();
4675 let lines: Vec<&str> = new_body.lines().collect();
4676 assert!(
4677 lines[0].contains("new first task"),
4678 "expected new item first, got: {}",
4679 new_body
4680 );
4681 assert!(
4682 lines[1].contains("existing task"),
4683 "expected previous first item second, got: {}",
4684 new_body
4685 );
4686 }
4687
4688 #[test]
4689 fn op_add_identical_active_item_is_idempotent() {
4690 let body = "- [ ] [#a1b2] deploy\n";
4691 let outcome = op_add_with_outcome(body, "deploy", DOC_ID, false).unwrap();
4692
4693 assert_eq!(outcome.body, body);
4694 assert_eq!(outcome.id, "a1b2");
4695 assert!(!outcome.inserted);
4696 assert!(outcome.deduped_key.is_none());
4697 }
4698
4699 #[test]
4700 fn op_add_at_after_inserts_immediately_after_anchor() {
4701 let body = "- [ ] [#a1b2] first\n- [ ] [#c3d4] third\n";
4703 let (new_body, _id) =
4704 op_add_at(body, "second", DOC_ID, false, AddPosition::After("a1b2")).unwrap();
4705 let lines: Vec<&str> = new_body.lines().collect();
4706 assert!(lines[0].contains("first"), "{new_body}");
4707 assert!(lines[1].contains("second"), "{new_body}");
4708 assert!(lines[2].contains("third"), "{new_body}");
4709 }
4710
4711 #[test]
4712 fn op_add_at_before_inserts_immediately_before_anchor() {
4713 let body = "- [ ] [#a1b2] first\n- [ ] [#c3d4] third\n";
4714 let (new_body, _id) =
4715 op_add_at(body, "zeroth", DOC_ID, false, AddPosition::Before("a1b2")).unwrap();
4716 let lines: Vec<&str> = new_body.lines().collect();
4717 assert!(lines[0].contains("zeroth"), "{new_body}");
4718 assert!(lines[1].contains("first"), "{new_body}");
4719 }
4720
4721 #[test]
4722 fn op_add_at_last_appends_at_tail() {
4723 let body = "- [ ] [#a1b2] first\n- [ ] [#c3d4] second\n";
4725 let (new_body, _id) =
4726 op_add_at(body, "tail item", DOC_ID, false, AddPosition::Last).unwrap();
4727 let lines: Vec<&str> = new_body.lines().collect();
4728 assert!(lines[0].contains("first"), "{new_body}");
4729 assert!(lines[2].contains("tail item"), "{new_body}");
4730 }
4731
4732 #[test]
4733 fn op_add_at_after_chains_build_deterministic_order() {
4734 let body = "- [ ] [#a1b2] A\n";
4736 let (b1, _) =
4737 op_add_at(body, "id=bbbb B", DOC_ID, false, AddPosition::After("a1b2")).unwrap();
4738 let (b2, _) = op_add_at(&b1, "C", DOC_ID, false, AddPosition::After("bbbb")).unwrap();
4739 let lines: Vec<&str> = b2.lines().collect();
4740 assert!(lines[0].contains(" A"), "{b2}");
4741 assert!(lines[1].contains(" B"), "{b2}");
4742 assert!(lines[2].contains(" C"), "{b2}");
4743 }
4744
4745 #[test]
4746 fn op_add_at_unknown_anchor_errors() {
4747 let body = "- [ ] [#a1b2] first\n";
4748 let err = op_add_at(body, "x", DOC_ID, false, AddPosition::After("nope")).unwrap_err();
4749 assert!(err.to_string().contains("anchor id not found"), "{err}");
4750 }
4751
4752 #[test]
4753 fn op_add_preserves_section_headers() {
4754 let body = concat!(
4755 "### Active\n",
4756 "- [ ] [#a1b2] existing task\n",
4757 "### Later\n",
4758 "- [ ] [#c3d4] later task\n"
4759 );
4760 let (new_body, _id) = op_add(body, "new first task", DOC_ID, false).unwrap();
4761 let lines: Vec<&str> = new_body.lines().collect();
4762 assert_eq!(lines[0], "### Active");
4763 assert!(lines[1].contains("new first task"), "got: {}", new_body);
4764 assert!(lines[2].contains("existing task"), "got: {}", new_body);
4765 assert_eq!(lines[3], "### Later");
4766 assert!(lines[4].contains("later task"), "got: {}", new_body);
4767 }
4768
4769 #[test]
4770 fn op_append_items_preserves_order_after_existing_items() {
4771 let body = concat!(
4772 "### Active\n",
4773 "- [ ] [#a1b2] existing task\n",
4774 "\n",
4775 "### Notes\n",
4776 "Keep this note.\n",
4777 );
4778 let appended = vec![
4779 PendingItem {
4780 marker: PendingListMarker::Bullet,
4781 id: "b2c3".to_string(),
4782 state: PendingState::Open,
4783 gate_type: None,
4784 in_progress: false,
4785 text: "first appended task".to_string(),
4786 continuation: String::new(),
4787 },
4788 PendingItem {
4789 marker: PendingListMarker::Bullet,
4790 id: "d4e5".to_string(),
4791 state: PendingState::Gated,
4792 gate_type: Some("release".to_string()),
4793 in_progress: false,
4794 text: "second appended task".to_string(),
4795 continuation: String::new(),
4796 },
4797 ];
4798
4799 let new_body = op_append_items(body, &appended);
4800
4801 assert_eq!(
4802 new_body,
4803 concat!(
4804 "### Active\n",
4805 "- [ ] [#a1b2] existing task\n",
4806 "- [ ] [#b2c3] first appended task\n",
4807 "- [/release] [#d4e5] second appended task\n",
4808 "\n",
4809 "### Notes\n",
4810 "Keep this note.\n",
4811 )
4812 );
4813 }
4814
4815 #[test]
4816 fn op_add_renumbers_ordered_lists() {
4817 let body = "1. [ ] [#a1b2] existing task\n2. [ ] [#c3d4] later task\n";
4818 let (new_body, _id) = op_add(body, "new first task", DOC_ID, false).unwrap();
4819 let lines: Vec<&str> = new_body.lines().collect();
4820 assert!(lines[0].starts_with("1. "), "got: {}", new_body);
4821 assert!(lines[0].contains("new first task"), "got: {}", new_body);
4822 assert!(lines[1].starts_with("2. "), "got: {}", new_body);
4823 assert!(lines[1].contains("existing task"), "got: {}", new_body);
4824 assert!(lines[2].starts_with("3. "), "got: {}", new_body);
4825 assert!(lines[2].contains("later task"), "got: {}", new_body);
4826 }
4827
4828 #[test]
4829 fn op_add_accepts_custom_id_prefix() {
4830 let (new_body, id) = op_add("", "id=ship1 release checklist", DOC_ID, false).unwrap();
4831 assert_eq!(id, "ship1");
4832 assert!(new_body.contains("- [ ] [#ship1] release checklist"));
4833 }
4834
4835 #[test]
4836 fn op_add_accepts_custom_id_prefix_with_hash_marker() {
4837 let (new_body, id) = op_add("", "id=#ship1 release checklist", DOC_ID, false).unwrap();
4838 assert_eq!(id, "ship1");
4839 assert!(new_body.contains("- [ ] [#ship1] release checklist"));
4840 }
4841
4842 #[test]
4843 fn op_add_accepts_bracketed_custom_id_prefix() {
4844 let (new_body, id) = op_add("", "[#ship1] release checklist", DOC_ID, false).unwrap();
4845 assert_eq!(id, "ship1");
4846 assert!(new_body.contains("- [ ] [#ship1] release checklist"));
4847 }
4848
4849 #[test]
4850 fn op_add_accepts_long_bracketed_custom_id_prefix() {
4851 let (new_body, id) = op_add("", "[#sdig2matrix] release checklist", DOC_ID, false).unwrap();
4852 assert_eq!(id, "sdig2matrix");
4853 assert!(new_body.contains("- [ ] [#sdig2matrix] release checklist"));
4854 }
4855
4856 #[test]
4857 fn op_add_accepts_hyphenated_custom_id_prefix() {
4858 let (new_body, id) =
4859 op_add("", "id=tmuxcrash-abcd release checklist", DOC_ID, false).unwrap();
4860 assert_eq!(id, "tmuxcrash-abcd");
4861 assert!(new_body.contains("- [ ] [#tmuxcrash-abcd] release checklist"));
4862 }
4863
4864 #[test]
4865 fn op_add_accepts_hyphenated_bracketed_custom_id_prefix() {
4866 let (new_body, id) =
4867 op_add("", "[#tmuxcrash-abcd] release checklist", DOC_ID, false).unwrap();
4868 assert_eq!(id, "tmuxcrash-abcd");
4869 assert!(new_body.contains("- [ ] [#tmuxcrash-abcd] release checklist"));
4870 }
4871
4872 #[test]
4873 fn op_add_rejects_bare_bracket_placeholder_prefix() {
4874 let err = op_add("", "[#] release checklist", DOC_ID, false).unwrap_err();
4875 assert!(format!("{}", err).contains("bare `[#]` placeholder"));
4876 }
4877
4878 #[test]
4879 fn op_add_rejects_empty_explicit_custom_id_prefix() {
4880 let err = op_add("", "id= release checklist", DOC_ID, false).unwrap_err();
4881 assert!(format!("{}", err).contains("empty custom id"));
4882 }
4883
4884 #[test]
4885 fn op_add_rejects_duplicate_custom_id_prefix() {
4886 let body = "- [ ] [#ship1] existing task\n";
4887 let err = op_add(body, "id=ship1 new task", DOC_ID, false).unwrap_err();
4888 assert!(format!("{}", err).contains("custom id already exists"));
4889 }
4890
4891 #[test]
4892 fn op_add_rejects_missing_text_after_custom_id_prefix() {
4893 let err = op_add("", "id=ship1", DOC_ID, false).unwrap_err();
4894 assert!(format!("{}", err).contains("custom id prefix must be followed by item text"));
4895 }
4896
4897 #[test]
4898 fn op_add_rejects_missing_text_after_bracketed_custom_id_prefix() {
4899 let err = op_add("", "[#ship1]", DOC_ID, false).unwrap_err();
4900 assert!(
4901 format!("{}", err).contains("bracketed custom id prefix must be followed by item text")
4902 );
4903 }
4904
4905 #[test]
4906 fn op_add_rejects_stacked_bracketed_custom_id_prefixes() {
4907 let err = op_add("", "[#ship1] [#ship2] release checklist", DOC_ID, false).unwrap_err();
4908 assert!(
4909 format!("{}", err).contains("duplicate leading custom id prefix"),
4910 "unexpected error: {}",
4911 err
4912 );
4913 }
4914
4915 #[test]
4916 fn op_add_rejects_stacked_mixed_custom_id_prefixes() {
4917 let err = op_add("", "id=ship1 [#ship2] release checklist", DOC_ID, false).unwrap_err();
4918 assert!(
4919 format!("{}", err).contains("duplicate leading custom id prefix"),
4920 "unexpected error: {}",
4921 err
4922 );
4923 }
4924
4925 #[test]
4926 fn op_add_rejects_empty() {
4927 assert!(op_add("", " ", DOC_ID, false).is_err());
4928 }
4929
4930 #[test]
4931 fn op_add_rejects_state_marker_prefix() {
4932 for marker in &["[ ] task", "[/] task", "[x] task", "[X] task"] {
4933 let err = op_add("", marker, DOC_ID, false).unwrap_err();
4934 let msg = format!("{}", err);
4935 assert!(
4936 msg.contains("state marker"),
4937 "expected state marker error for '{}', got: {}",
4938 marker,
4939 msg
4940 );
4941 }
4942 }
4943
4944 #[test]
4945 fn op_add_duplicate_text_is_idempotent() {
4946 let (body, id) = op_add("", "Wire Sift into corky", DOC_ID, false).unwrap();
4947 let outcome = op_add_with_outcome(&body, "Wire Sift into corky", DOC_ID, false).unwrap();
4948
4949 assert_eq!(outcome.body, body);
4950 assert_eq!(outcome.id, id);
4951 assert!(!outcome.inserted);
4952 assert!(outcome.deduped_key.is_none());
4953 }
4954
4955 #[test]
4956 fn op_prepend_many_preserves_sequence_order_at_front() {
4957 let body = "- [ ] [#abcd] existing item\n";
4958 let outcome = op_prepend_many_with_outcomes(
4959 body,
4960 &["first new".to_string(), "second new".to_string()],
4961 DOC_ID,
4962 false,
4963 )
4964 .unwrap();
4965 let lines: Vec<&str> = outcome.body.lines().collect();
4966 assert!(lines[0].contains("first new"), "{}", outcome.body);
4967 assert!(lines[1].contains("second new"), "{}", outcome.body);
4968 assert!(lines[2].contains("existing item"), "{}", outcome.body);
4969 assert_eq!(outcome.outcomes.len(), 2);
4970 assert!(outcome.outcomes.iter().all(|item| item.inserted));
4971 }
4972
4973 #[test]
4974 fn op_prepend_many_returns_inserted_ids_in_caller_order() {
4975 let outcome = op_prepend_many_with_outcomes(
4976 "",
4977 &[
4978 "id=first01 first item".to_string(),
4979 "second auto item".to_string(),
4980 "[#third03] third item".to_string(),
4981 ],
4982 DOC_ID,
4983 false,
4984 )
4985 .unwrap();
4986 let ids: Vec<&str> = outcome
4987 .outcomes
4988 .iter()
4989 .map(|item| item.id.as_str())
4990 .collect();
4991 assert_eq!(ids[0], "first01");
4992 assert!(!ids[1].is_empty());
4993 assert_eq!(ids[2], "third03");
4994 let lines: Vec<&str> = outcome.body.lines().collect();
4995 assert!(
4996 lines[0].contains("[#first01] first item"),
4997 "{}",
4998 outcome.body
4999 );
5000 assert!(lines[1].contains("second auto item"), "{}", outcome.body);
5001 assert!(
5002 lines[2].contains("[#third03] third item"),
5003 "{}",
5004 outcome.body
5005 );
5006 }
5007
5008 #[test]
5009 fn op_prepend_many_reports_deduped_symptom_outcomes() {
5010 let key = SymptomDedupeKey::new("stale_queue_pause", "doc-abc", "queue", "sha256:feedface")
5011 .unwrap();
5012 let first = format!("stale queue pause {}", key.marker());
5013 let first_outcome = op_add_with_outcome("", &first, DOC_ID, false).unwrap();
5014 let repeat = format!("stale queue pause observed again {}", key.marker());
5015 let outcome = op_prepend_many_with_outcomes(
5016 &first_outcome.body,
5017 &["new regular task".to_string(), repeat],
5018 DOC_ID,
5019 false,
5020 )
5021 .unwrap();
5022 assert_eq!(outcome.outcomes.len(), 2);
5023 assert!(outcome.outcomes[0].inserted);
5024 assert!(!outcome.outcomes[1].inserted);
5025 assert_eq!(outcome.outcomes[1].id, first_outcome.id);
5026 assert_eq!(outcome.outcomes[1].deduped_key.as_ref(), Some(&key));
5027 assert!(
5028 outcome.body.contains("new regular task"),
5029 "{}",
5030 outcome.body
5031 );
5032 assert!(
5033 outcome
5034 .body
5035 .contains(" evidence: stale queue pause observed again"),
5036 "{}",
5037 outcome.body
5038 );
5039 }
5040
5041 #[test]
5042 fn op_add_dedupes_symptom_key_by_attaching_evidence() {
5043 let key = SymptomDedupeKey::new("stale_queue_pause", "doc-abc", "queue", "sha256:feedface")
5044 .unwrap();
5045 let first = format!("stale queue pause {}", key.marker());
5046 let first_outcome = op_add_with_outcome("", &first, DOC_ID, false).unwrap();
5047 assert!(first_outcome.inserted);
5048
5049 let repeat = format!("stale queue pause observed again {}", key.marker());
5050 let repeat_outcome =
5051 op_add_with_outcome(&first_outcome.body, &repeat, DOC_ID, false).unwrap();
5052
5053 assert!(!repeat_outcome.inserted);
5054 assert_eq!(repeat_outcome.id, first_outcome.id);
5055 assert_eq!(repeat_outcome.deduped_key.as_ref(), Some(&key));
5056 assert_eq!(repeat_outcome.body.matches("[#").count(), 1);
5057 assert!(repeat_outcome.body.contains("stale queue pause "));
5058 assert!(
5059 repeat_outcome
5060 .body
5061 .contains(" evidence: stale queue pause observed again")
5062 );
5063 }
5064
5065 #[test]
5066 fn parse_bare_hash_placeholder_strips_marker() {
5067 let item = parse_item_line("- [ ] [#] Wire Sift into corky").unwrap();
5068 assert_eq!(item.id, "");
5069 assert_eq!(item.text, "Wire Sift into corky");
5070 }
5071
5072 #[test]
5073 fn backfill_strips_bare_hash_placeholder() {
5074 let body = "- [ ] [#] task with placeholder\n";
5075 let (new_body, changed) = backfill(body, DOC_ID, &ids());
5076 assert!(changed);
5077 assert!(new_body.contains("[#"), "should have a hash id");
5078 let hash_count = new_body.matches("[#").count();
5080 assert_eq!(hash_count, 1, "expected exactly one [# in: {}", new_body);
5081 assert!(new_body.contains("task with placeholder"));
5082 assert!(
5083 !new_body.contains("[#] task"),
5084 "bare [#] should not survive in text"
5085 );
5086 }
5087
5088 #[test]
5089 fn parse_bare_hash_placeholder_no_checkbox() {
5090 let item = parse_item_line("- [#] no checkbox").unwrap();
5092 assert_eq!(item.id, "");
5093 assert_eq!(item.state, PendingState::Open);
5094 assert_eq!(item.text, "no checkbox");
5095 }
5096
5097 #[test]
5098 fn parse_bare_hash_placeholder_gated() {
5099 let item = parse_item_line("- [/] [#] gated task").unwrap();
5101 assert_eq!(item.id, "");
5102 assert_eq!(item.state, PendingState::Gated);
5103 assert_eq!(item.text, "gated task");
5104 }
5105
5106 #[test]
5107 fn backfill_strips_multiple_bare_placeholders() {
5108 let body = "- [ ] [#] first task\n- [ ] [#] second task\n";
5110 let (new_body, changed) = backfill(body, DOC_ID, &ids());
5111 assert!(changed);
5112 let (_, items, _) = parse_items(&new_body);
5113 assert_eq!(items.len(), 2);
5114 assert!(!items[0].id.is_empty(), "first should have id");
5115 assert!(!items[1].id.is_empty(), "second should have id");
5116 assert_ne!(items[0].id, items[1].id, "ids should be unique");
5117 assert!(
5119 !items[0].text.contains("[#]"),
5120 "first text has residual [#]: {}",
5121 items[0].text
5122 );
5123 assert!(
5124 !items[1].text.contains("[#]"),
5125 "second text has residual [#]: {}",
5126 items[1].text
5127 );
5128 }
5129
5130 #[test]
5131 fn backfill_preserves_long_custom_id() {
5132 let body = "- [ ] [#sdig2matrix] Fixture evidence matrix\n";
5133 let (new_body, changed) = backfill(body, DOC_ID, &ids());
5134 assert!(!changed);
5135 assert_eq!(new_body, body);
5136 }
5137
5138 #[test]
5139 fn canonicalize_tracked_work_body_backfills_missing_id() {
5140 let body = "- write focused extraction tests\n";
5141
5142 let canonical = canonicalize_tracked_work_body(body, DOC_ID);
5143
5144 assert!(canonical.starts_with("- [ ] [#"), "{canonical}");
5145 assert!(canonical.contains("write focused extraction tests"));
5146 }
5147
5148 #[test]
5149 fn backfill_idempotent_after_placeholder_strip() {
5150 let body = "- [ ] [#] task\n";
5152 let (first_pass, _) = backfill(body, DOC_ID, &ids());
5153 let (second_pass, changed) = backfill(&first_pass, DOC_ID, &ids());
5154 assert!(
5155 !changed,
5156 "second backfill should be no-op, got: {}",
5157 second_pass
5158 );
5159 assert_eq!(first_pass, second_pass);
5160 }
5161
5162 #[test]
5163 fn op_add_dedup_case_sensitive() {
5164 let (body, _) = op_add("", "Wire Sift", DOC_ID, false).unwrap();
5166 let result = op_add(&body, "wire sift", DOC_ID, false);
5167 assert!(result.is_ok(), "different case should not be duplicate");
5168 }
5169
5170 #[test]
5171 fn op_add_dedup_across_states() {
5172 let (body, id) = op_add("", "deploy to prod", DOC_ID, true).unwrap();
5175 let outcome = op_add_with_outcome(&body, "deploy to prod", DOC_ID, false).unwrap();
5176
5177 assert_eq!(outcome.body, body);
5178 assert_eq!(outcome.id, id);
5179 assert!(!outcome.inserted);
5180 assert!(outcome.deduped_key.is_none());
5181 }
5182
5183 #[test]
5184 fn op_add_gated_produces_gated_item() {
5185 let (new_body, id) = op_add("", "gated task", DOC_ID, true).unwrap();
5186 assert!(new_body.contains("[/]"), "expected [/] in: {}", new_body);
5187 assert!(new_body.contains(&format!("[#{}]", id)));
5188 assert!(new_body.contains("gated task"));
5189 }
5190
5191 #[test]
5192 fn op_add_returns_assigned_id() {
5193 let (body, id1) = op_add("", "task one", DOC_ID, false).unwrap();
5194 assert!(!id1.is_empty());
5195 assert!(body.contains(&format!("[#{}]", id1)));
5196 let (body2, id2) = op_add(&body, "task two", DOC_ID, false).unwrap();
5197 assert_ne!(id1, id2);
5198 assert!(body2.contains(&format!("[#{}]", id2)));
5199 }
5200
5201 #[test]
5202 fn op_add_extracts_inline_tag_as_id() {
5203 let (new_body, id) = op_add(
5204 "",
5205 "2026-05-11 [#nopatchbackopencode] OpenCode agent-doc turn completed",
5206 DOC_ID,
5207 false,
5208 )
5209 .unwrap();
5210 assert_eq!(id, "nopatchbackopencode");
5211 assert!(
5212 new_body.contains(
5213 "- [ ] [#nopatchbackopencode] 2026-05-11 OpenCode agent-doc turn completed"
5214 ),
5215 "got: {}",
5216 new_body
5217 );
5218 }
5219
5220 #[test]
5221 fn op_add_inline_tag_strips_from_text() {
5222 let (new_body, id) = op_add("", "fix [#mybug] the thing", DOC_ID, false).unwrap();
5223 assert_eq!(id, "mybug");
5224 assert!(
5225 new_body.contains("- [ ] [#mybug] fix the thing"),
5226 "got: {}",
5227 new_body
5228 );
5229 }
5230
5231 #[test]
5232 fn op_add_inline_tag_at_end() {
5233 let (new_body, id) = op_add("", "deploy the thing [#deploy1]", DOC_ID, false).unwrap();
5234 assert_eq!(id, "deploy1");
5235 assert!(
5236 new_body.contains("- [ ] [#deploy1] deploy the thing"),
5237 "got: {}",
5238 new_body
5239 );
5240 }
5241
5242 #[test]
5243 fn op_add_inline_tag_dedup_uses_cleaned_text() {
5244 let (body, _) = op_add("", "fix [#mybug] the thing", DOC_ID, false).unwrap();
5245 let outcome = op_add_with_outcome(&body, "fix [#mybug] the thing", DOC_ID, false).unwrap();
5246
5247 assert_eq!(outcome.body, body);
5248 assert_eq!(outcome.id, "mybug");
5249 assert!(!outcome.inserted);
5250 assert!(outcome.deduped_key.is_none());
5251 }
5252
5253 #[test]
5254 fn op_add_inline_tag_rejects_existing_id() {
5255 let body = "- [ ] [#mybug] existing task\n";
5256 let err = op_add(body, "fix [#mybug] new task", DOC_ID, false).unwrap_err();
5257 assert!(
5258 format!("{}", err).contains("inline tag id already exists"),
5259 "expected inline tag id already exists, got: {}",
5260 err
5261 );
5262 }
5263
5264 #[test]
5265 fn op_add_inline_tag_ignores_invalid_tag() {
5266 let (new_body, id) =
5267 op_add("", "see [#not-a-valid tag] because spaces", DOC_ID, false).unwrap();
5268 assert_ne!(id, "not-a-valid");
5269 assert!(
5270 new_body.contains("see [#not-a-valid tag] because spaces"),
5271 "got: {}",
5272 new_body
5273 );
5274 }
5275
5276 #[test]
5277 fn op_add_leading_prefix_takes_precedence_over_inline() {
5278 let (new_body, id) =
5279 op_add("", "[#myid] text with [#other] inline", DOC_ID, false).unwrap();
5280 assert_eq!(id, "myid");
5281 assert!(
5282 new_body.contains("- [ ] [#myid] text with [#other] inline"),
5283 "got: {}",
5284 new_body
5285 );
5286 }
5287
5288 #[test]
5289 fn op_add_promotes_leading_bare_tag_to_id() {
5290 let (new_body, id) = op_add(
5295 "",
5296 "#mergestatemachine3 JB+VSCode report attach/detach through SM",
5297 DOC_ID,
5298 false,
5299 )
5300 .unwrap();
5301 assert_eq!(id, "mergestatemachine3");
5302 assert!(
5303 new_body
5304 .contains("- [ ] [#mergestatemachine3] JB+VSCode report attach/detach through SM"),
5305 "got: {}",
5306 new_body
5307 );
5308 }
5309
5310 #[test]
5311 fn op_add_leading_bare_tag_with_trailing_equals_keeps_auto_hash() {
5312 let (new_body, id) =
5316 op_add("", "#lazilyspecpin=Pin the vocabulary", DOC_ID, false).unwrap();
5317 assert_ne!(id, "lazilyspecpin");
5318 assert!(
5319 new_body.contains("#lazilyspecpin=Pin the vocabulary"),
5320 "got: {}",
5321 new_body
5322 );
5323 }
5324
5325 #[test]
5326 fn op_add_leading_bare_tag_alone_is_reference_not_id() {
5327 let (new_body, id) = op_add("", "#somereference", DOC_ID, false).unwrap();
5330 assert_ne!(id, "somereference");
5331 assert!(new_body.contains("#somereference"), "got: {}", new_body);
5332 }
5333
5334 #[test]
5335 fn op_add_leading_bare_tag_rejects_existing_id() {
5336 let body = "- [ ] [#mergestatemachine3] existing task\n";
5337 let err = op_add(body, "#mergestatemachine3 new task", DOC_ID, false).unwrap_err();
5338 assert!(
5339 format!("{}", err).contains("inline tag id already exists"),
5340 "expected inline tag id already exists, got: {}",
5341 err
5342 );
5343 }
5344
5345 #[test]
5346 fn op_done_marks_checked() {
5347 let body = "- [ ] [#a1b2] task\n";
5348 let new_body = op_done(body, "a1b2").unwrap();
5349 assert!(new_body.contains("[x]"));
5350 }
5351
5352 #[test]
5353 fn op_done_accepts_hash_prefixed_id() {
5354 let body = "- [ ] [#a1b2] task\n";
5355 let new_body = op_done(body, "#a1b2").unwrap();
5356 assert!(new_body.contains("- [x] [#a1b2] task"));
5357 }
5358
5359 #[test]
5360 fn op_done_unknown_id_errors() {
5361 let body = "- [ ] [#a1b2] task\n";
5362 assert!(op_done(body, "zzzz").is_err());
5363 }
5364
5365 #[test]
5366 fn op_edit_preserves_hash() {
5367 let body = "- [ ] [#a1b2] original\n";
5368 let new_body = op_edit(body, "a1b2", "updated").unwrap();
5369 assert!(new_body.contains("[#a1b2]"));
5370 assert!(new_body.contains("updated"));
5371 assert!(!new_body.contains("original"));
5372 }
5373
5374 #[test]
5375 fn op_edit_many_applies_edits_in_order() {
5376 let body = "- [ ] [#a1b2] original one\n- [ ] [#c3d4] original two\n";
5377
5378 let new_body = op_edit_many(
5379 body,
5380 &[
5381 ("a1b2".to_string(), "updated one".to_string()),
5382 (
5383 "c3d4".to_string(),
5384 "updated two\n - retained detail".to_string(),
5385 ),
5386 ],
5387 )
5388 .unwrap();
5389
5390 assert!(new_body.contains("- [ ] [#a1b2] updated one"));
5391 assert!(new_body.contains("- [ ] [#c3d4] updated two\n - retained detail"));
5392 assert!(!new_body.contains("original one"));
5393 assert!(!new_body.contains("original two"));
5394 }
5395
5396 #[test]
5397 fn op_edit_multiline_replaces_existing_continuation() {
5398 let body = concat!(
5399 "- [ ] [#tmuxcrash] parent task\n",
5400 " - [ ] [#tmuxcrash-old1] stale child\n",
5401 " - [ ] [#tmuxcrash-old2] stale child two\n",
5402 "- [ ] [#keep1] sibling task\n"
5403 );
5404 let new_body = op_edit(
5405 body,
5406 "tmuxcrash",
5407 "parent task revised\n - fresh child\n - fresh child two",
5408 )
5409 .unwrap();
5410 assert!(new_body.contains("[#tmuxcrash] parent task revised"));
5411 assert!(new_body.contains(" - fresh child\n"));
5412 assert!(new_body.contains(" - fresh child two"));
5413 assert!(!new_body.contains("stale child"));
5414 assert!(!new_body.contains("stale child two"));
5415 assert!(new_body.contains(" - fresh child two\n- [ ] [#keep1] sibling task"));
5416 }
5417
5418 #[test]
5419 fn op_edit_rejects_unindented_multiline_follow_up() {
5420 let body = "- [ ] [#a1b2] original\n";
5421 let err = op_edit(body, "a1b2", "updated\nsecond parent").unwrap_err();
5422 assert!(
5423 format!("{}", err)
5424 .contains("multiline text may only contain indented continuation lines"),
5425 "got: {err}"
5426 );
5427 }
5428
5429 #[test]
5430 fn op_clear_empties_items() {
5431 let body = "- [ ] [#a1b2] one\n- [ ] [#c3d4] two\n";
5432 let new_body = op_clear(body).unwrap();
5433 assert!(!new_body.contains("[#"));
5434 }
5435
5436 #[test]
5437 fn op_clear_preserves_headers_and_spacing() {
5438 let body = concat!(
5439 "### Active\n",
5440 "- [ ] [#a1b2] one\n",
5441 "\n",
5442 "### Later\n",
5443 "- [ ] [#c3d4] two\n"
5444 );
5445 let new_body = op_clear(body).unwrap();
5446 assert_eq!(new_body, "### Active\n\n### Later\n");
5447 }
5448
5449 #[test]
5450 fn op_reorder_reorders_by_id() {
5451 let body = "- [ ] [#a1b2] first\n- [ ] [#c3d4] second\n- [ ] [#e5f6] third\n";
5452 let new_body = op_reorder(body, &["e5f6".to_string(), "a1b2".to_string()]).unwrap();
5453 let (_, items, _) = parse_items(&new_body);
5454 assert_eq!(items[0].id, "e5f6");
5455 assert_eq!(items[1].id, "a1b2");
5456 assert_eq!(items[2].id, "c3d4");
5457 }
5458
5459 #[test]
5460 fn op_reorder_keeps_headers_in_place() {
5461 let body = concat!(
5462 "### Active\n",
5463 "- [ ] [#a1b2] first\n",
5464 "### Later\n",
5465 "- [ ] [#c3d4] second\n",
5466 "- [ ] [#e5f6] third\n"
5467 );
5468 let new_body = op_reorder(body, &["e5f6".to_string(), "a1b2".to_string()]).unwrap();
5469 let lines: Vec<&str> = new_body.lines().collect();
5470 assert_eq!(lines[0], "### Active");
5471 assert!(lines[1].contains("[#e5f6] third"), "got: {}", new_body);
5472 assert_eq!(lines[2], "### Later");
5473 assert!(lines[3].contains("[#a1b2] first"), "got: {}", new_body);
5474 assert!(lines[4].contains("[#c3d4] second"), "got: {}", new_body);
5475 }
5476
5477 #[test]
5478 fn op_reorder_moves_nested_subtasks_with_parent_item() {
5479 let body = concat!(
5480 "- [ ] [#a1b2] first\n",
5481 " - child a\n",
5482 "- [ ] [#c3d4] second\n",
5483 " - child b\n"
5484 );
5485 let new_body = op_reorder(body, &["c3d4".to_string()]).unwrap();
5486 assert_eq!(
5487 new_body,
5488 concat!(
5489 "- [ ] [#c3d4] second\n",
5490 " - child b\n",
5491 "- [ ] [#a1b2] first\n",
5492 " - child a\n"
5493 )
5494 );
5495 }
5496
5497 #[test]
5498 fn op_reorder_renumbers_ordered_lists() {
5499 let body = "1. [ ] [#a1b2] first\n2. [ ] [#c3d4] second\n3. [ ] [#e5f6] third\n";
5500 let new_body = op_reorder(body, &["e5f6".to_string(), "a1b2".to_string()]).unwrap();
5501 let lines: Vec<&str> = new_body.lines().collect();
5502 assert_eq!(lines[0], "1. [ ] [#e5f6] third");
5503 assert_eq!(lines[1], "2. [ ] [#a1b2] first");
5504 assert_eq!(lines[2], "3. [ ] [#c3d4] second");
5505 }
5506
5507 #[test]
5508 fn op_reorder_unknown_id_errors() {
5509 let body = "- [ ] [#a1b2] one\n";
5510 assert!(op_reorder(body, &["zzzz".to_string()]).is_err());
5511 }
5512
5513 #[test]
5516 fn validate_transition_full_matrix() {
5517 use PendingOp::*;
5518 use PendingState::*;
5519 use TransitionResult::*;
5520
5521 assert_eq!(validate_transition(Open, Gate).unwrap(), Transition(Gated));
5523 assert!(validate_transition(Open, Ungate).is_err());
5524 assert_eq!(
5525 validate_transition(Open, MarkDone).unwrap(),
5526 Transition(Done)
5527 );
5528
5529 assert_eq!(validate_transition(Gated, Gate).unwrap(), NoOp);
5531 assert_eq!(
5532 validate_transition(Gated, Ungate).unwrap(),
5533 Transition(Open)
5534 );
5535 assert_eq!(
5536 validate_transition(Gated, MarkDone).unwrap(),
5537 Transition(Done)
5538 );
5539
5540 assert!(validate_transition(Done, Gate).is_err());
5542 assert!(validate_transition(Done, Ungate).is_err());
5543 assert_eq!(validate_transition(Done, MarkDone).unwrap(), NoOp);
5544 }
5545
5546 #[test]
5547 fn op_gate_open_to_gated() {
5548 let body = "- [ ] [#a1b2] task\n";
5549 let new_body = op_gate(body, "a1b2").unwrap();
5550 assert!(new_body.contains("- [/] [#a1b2]"));
5551 }
5552
5553 #[test]
5554 fn op_gate_gated_is_noop() {
5555 let body = "- [/] [#a1b2] task\n";
5556 let new_body = op_gate(body, "a1b2").unwrap();
5557 assert_eq!(new_body, body);
5559 }
5560
5561 #[test]
5562 fn op_gate_done_errors() {
5563 let body = "- [x] [#a1b2] task\n";
5564 let err = op_gate(body, "a1b2").unwrap_err();
5565 let msg = format!("{}", err);
5566 assert!(msg.contains("cannot gate Done item"), "got: {}", msg);
5567 }
5568
5569 #[test]
5570 fn op_gate_unknown_id_errors() {
5571 let body = "- [ ] [#a1b2] task\n";
5572 assert!(op_gate(body, "zzzz").is_err());
5573 }
5574
5575 #[test]
5576 fn op_ungate_gated_to_open() {
5577 let body = "- [/] [#a1b2] task\n";
5578 let new_body = op_ungate(body, "a1b2").unwrap();
5579 assert!(new_body.contains("- [ ] [#a1b2]"));
5580 }
5581
5582 #[test]
5583 fn op_ungate_open_errors() {
5584 let body = "- [ ] [#a1b2] task\n";
5585 let err = op_ungate(body, "a1b2").unwrap_err();
5586 let msg = format!("{}", err);
5587 assert!(msg.contains("cannot ungate Open"), "got: {}", msg);
5588 }
5589
5590 #[test]
5591 fn op_ungate_done_errors() {
5592 let body = "- [x] [#a1b2] task\n";
5593 let err = op_ungate(body, "a1b2").unwrap_err();
5594 let msg = format!("{}", err);
5595 assert!(msg.contains("cannot ungate Done"), "got: {}", msg);
5596 }
5597
5598 #[test]
5599 fn op_ungate_unknown_id_errors() {
5600 let body = "- [/] [#a1b2] task\n";
5601 assert!(op_ungate(body, "zzzz").is_err());
5602 }
5603
5604 #[test]
5605 fn op_gate_preserves_other_items_and_text() {
5606 let body = "- [ ] [#a1b2] one\n- [ ] [#c3d4] two โ gate: v0.32.6\n- [x] [#e5f6] three\n";
5607 let new_body = op_gate(body, "c3d4").unwrap();
5608 let (_, items, _) = parse_items(&new_body);
5609 assert_eq!(items[0].state, PendingState::Open);
5610 assert_eq!(items[1].state, PendingState::Gated);
5611 assert_eq!(items[1].text, "two โ gate: v0.32.6");
5612 assert_eq!(items[2].state, PendingState::Done);
5613 }
5614
5615 #[test]
5616 fn generate_hash_n_width4_deterministic_and_short() {
5617 let h = generate_hash_n("text", "doc", 0, 4);
5618 assert_eq!(h.len(), 4);
5619 assert_eq!(h, generate_hash_n("text", "doc", 0, 4));
5620 assert_ne!(h, generate_hash_n("text", "doc", 1, 4));
5621 }
5622
5623 #[test]
5624 fn generate_hash_n_width4_matches_pre_extension_formula() {
5625 let cases = [
5628 ("text", "doc", 0u64, "he5f"),
5629 ("refactor preflight", "abc123", 7, "wkvb"),
5630 ("", "", 42, "ywpk"),
5631 ("long text with spaces", "doc_id_long", 99, "mpy2"),
5632 ];
5633 for (t, d, c, expected) in cases {
5634 assert_eq!(generate_hash_n(t, d, c, 4), expected);
5635 }
5636 }
5637
5638 #[test]
5639 fn generate_hash_n_widths_have_correct_length() {
5640 for w in 4..=8 {
5641 let h = generate_hash_n("text", "doc", 0, w);
5642 assert_eq!(h.len(), w, "width {} produced len {}", w, h.len());
5643 }
5644 assert_eq!(generate_hash_n("x", "y", 0, 1).len(), 4);
5646 assert_eq!(generate_hash_n("x", "y", 0, 20).len(), 8);
5647 }
5648
5649 #[test]
5650 fn generate_hash_n_wider_extends_shorter() {
5651 let h4 = generate_hash_n("text", "doc", 0, 4);
5654 let h5 = generate_hash_n("text", "doc", 0, 5);
5655 let h8 = generate_hash_n("text", "doc", 0, 8);
5656 assert!(h5.starts_with(&h4), "h5={} h4={}", h5, h4);
5657 assert!(h8.starts_with(&h4), "h8={} h4={}", h8, h4);
5658 assert!(h8.starts_with(&h5), "h8={} h5={}", h8, h5);
5659 }
5660
5661 #[test]
5662 fn assign_unique_hash_extends_on_collision() {
5663 let h4 = generate_hash_n("item", "doc", 0, 4);
5668 let mut taken = HashSet::new();
5669 taken.insert(h4.clone());
5670 let id = assign_unique_hash("item", "doc", &taken);
5671 assert_ne!(id, h4);
5672 assert!((4..=8).contains(&id.len()));
5673 }
5674
5675 #[test]
5676 fn assign_unique_hash_widens_when_counter_exhausted_at_width4() {
5677 let mut taken = HashSet::new();
5680 for c in 0..=3u64 {
5681 taken.insert(generate_hash_n("x", "d", c, 4));
5682 }
5683 let id = assign_unique_hash("x", "d", &taken);
5684 assert!(!taken.contains(&id));
5685 assert!((4..=8).contains(&id.len()));
5688 }
5689
5690 #[test]
5691 fn backfill_assigns_collision_free_ids_under_pressure() {
5692 let mut body = String::new();
5694 for i in 0..50 {
5695 body.push_str(&format!("- item {}\n", i));
5696 }
5697 let (out, changed) = backfill(&body, "doc", &HashSet::new());
5698 assert!(changed);
5699 let (_, items, _) = parse_items(&out);
5700 assert_eq!(items.len(), 50);
5701 let ids: HashSet<String> = items.iter().map(|i| i.id.clone()).collect();
5702 assert_eq!(ids.len(), 50, "ids must be unique");
5703 for id in &ids {
5704 assert!(
5705 (4..=8).contains(&id.len()),
5706 "id {} has width {}",
5707 id,
5708 id.len()
5709 );
5710 }
5711 }
5712
5713 #[test]
5716 fn parse_typed_gate_release() {
5717 let body = "- [/release] [#a1b2] Release v0.32.4\n";
5718 let (_, items, _) = parse_items(body);
5719 assert_eq!(items.len(), 1);
5720 assert_eq!(items[0].state, PendingState::Gated);
5721 assert_eq!(items[0].gate_type, Some("release".to_string()));
5722 assert_eq!(items[0].text, "Release v0.32.4");
5723 }
5724
5725 #[test]
5726 fn parse_typed_gate_deploy() {
5727 let body = "- [/deploy] [#c3d4] Push CDN config\n";
5728 let (_, items, _) = parse_items(body);
5729 assert_eq!(items[0].state, PendingState::Gated);
5730 assert_eq!(items[0].gate_type, Some("deploy".to_string()));
5731 }
5732
5733 #[test]
5734 fn parse_untyped_gate_has_no_gate_type() {
5735 let body = "- [/] [#a1b2] waiting\n";
5736 let (_, items, _) = parse_items(body);
5737 assert_eq!(items[0].state, PendingState::Gated);
5738 assert_eq!(items[0].gate_type, None);
5739 }
5740
5741 #[test]
5742 fn parse_open_has_no_gate_type() {
5743 let body = "- [ ] [#a1b2] task\n";
5744 let (_, items, _) = parse_items(body);
5745 assert_eq!(items[0].gate_type, None);
5746 }
5747
5748 #[test]
5749 fn render_typed_gate() {
5750 let item = PendingItem {
5751 marker: PendingListMarker::Bullet,
5752 id: "a1b2".to_string(),
5753 state: PendingState::Gated,
5754 gate_type: Some("release".to_string()),
5755 in_progress: false,
5756 text: "Release v0.32.4".to_string(),
5757 continuation: String::new(),
5758 };
5759 assert_eq!(item.render(), "- [/release] [#a1b2] Release v0.32.4");
5760 }
5761
5762 #[test]
5763 fn render_roundtrip_typed_gate() {
5764 let body = "- [/release] [#a1b2] Release v0.32.4\n- [/deploy] [#c3d4] Push\n- [/] [#e5f6] Generic\n";
5765 let (p, items, post) = parse_items(body);
5766 let out = render_items(&p, &items, &post);
5767 assert_eq!(out, body);
5768 }
5769
5770 #[test]
5771 fn op_resolve_gate_flips_matching() {
5772 let body = "- [/release] [#a1b2] Release v0.32.4\n- [/deploy] [#c3d4] Deploy\n- [/] [#e5f6] Generic gate\n";
5773 let (new_body, resolved) = op_resolve_gate(body, "release");
5774 assert_eq!(resolved, vec!["a1b2"]);
5775 let (_, items, _) = parse_items(&new_body);
5776 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()));
5780 assert_eq!(items[2].state, PendingState::Gated); assert_eq!(items[2].gate_type, None);
5782 }
5783
5784 #[test]
5785 fn op_resolve_gate_no_match() {
5786 let body = "- [/release] [#a1b2] Release\n- [/] [#c3d4] Generic\n";
5787 let (new_body, resolved) = op_resolve_gate(body, "deploy");
5788 assert!(resolved.is_empty());
5789 assert_eq!(new_body, body);
5790 }
5791
5792 #[test]
5793 fn op_resolve_gate_ignores_untyped() {
5794 let body = "- [/] [#a1b2] Generic gate\n";
5795 let (_, resolved) = op_resolve_gate(body, "release");
5796 assert!(resolved.is_empty());
5797 }
5798
5799 #[test]
5800 fn op_resolve_gate_multiple_same_type() {
5801 let body = "- [/release] [#a1b2] First\n- [/release] [#c3d4] Second\n";
5802 let (_, resolved) = op_resolve_gate(body, "release");
5803 assert_eq!(resolved, vec!["a1b2", "c3d4"]);
5804 }
5805
5806 #[test]
5807 fn op_set_gate_type_on_gated() {
5808 let body = "- [/] [#a1b2] Release v0.32.4\n";
5809 let new_body = op_set_gate_type(body, "a1b2", "release").unwrap();
5810 assert!(new_body.contains("[/release]"));
5811 let (_, items, _) = parse_items(&new_body);
5812 assert_eq!(items[0].gate_type, Some("release".to_string()));
5813 }
5814
5815 #[test]
5816 fn op_set_gate_type_errors_on_open() {
5817 let body = "- [ ] [#a1b2] task\n";
5818 assert!(op_set_gate_type(body, "a1b2", "release").is_err());
5819 }
5820
5821 #[test]
5822 fn op_set_gate_verify_round_trips_predicate() {
5823 let body = "- [/] [#saev] early receipt live verify\n";
5824 let new_body = op_set_gate_verify(
5825 body,
5826 "saev",
5827 "verify=ops_log:early_receipt_accepted;disproof=false receipt-timeout",
5828 1749526200,
5829 )
5830 .unwrap();
5831 let (_, items, _) = parse_items(&new_body);
5832 assert_eq!(items[0].state, PendingState::Gated);
5834 assert_eq!(items[0].gate_type, None);
5835 let pred = crate::gate_verify::parse_gate_predicate(&items[0].text).unwrap();
5836 assert_eq!(pred.verify.as_deref(), Some("early_receipt_accepted"));
5837 assert_eq!(pred.disproof.as_deref(), Some("false receipt-timeout"));
5838 assert_eq!(pred.set_at, Some(1749526200));
5839 }
5840
5841 #[test]
5842 fn op_set_gate_verify_errors_on_open() {
5843 let body = "- [ ] [#a1b2] task\n";
5844 assert!(op_set_gate_verify(body, "a1b2", "verify=ops_log:m", 1).is_err());
5845 }
5846
5847 #[test]
5848 fn op_set_gate_verify_errors_on_empty_spec() {
5849 let body = "- [/] [#a1b2] task\n";
5850 assert!(op_set_gate_verify(body, "a1b2", "noop=1", 1).is_err());
5851 }
5852
5853 #[test]
5854 fn op_set_gate_type_errors_on_done() {
5855 let body = "- [x] [#a1b2] task\n";
5856 assert!(op_set_gate_type(body, "a1b2", "release").is_err());
5857 }
5858
5859 #[test]
5860 fn op_set_gate_type_replaces_existing() {
5861 let body = "- [/release] [#a1b2] task\n";
5862 let new_body = op_set_gate_type(body, "a1b2", "deploy").unwrap();
5863 assert!(new_body.contains("[/deploy]"));
5864 assert!(!new_body.contains("[/release]"));
5865 }
5866
5867 #[test]
5868 fn parse_typed_gate_case_insensitive() {
5869 let body = "- [/Release] [#a1b2] task\n";
5870 let (_, items, _) = parse_items(body);
5871 assert_eq!(items[0].gate_type, Some("release".to_string()));
5872 }
5873
5874 #[test]
5875 fn parse_typed_gate_with_hyphens_underscores() {
5876 let body =
5877 "- [/code-review] [#a1b2] Review PR\n- [/pre_release] [#c3d4] Pre-release check\n";
5878 let (_, items, _) = parse_items(body);
5879 assert_eq!(items[0].gate_type, Some("code-review".to_string()));
5880 assert_eq!(items[1].gate_type, Some("pre_release".to_string()));
5881 }
5882
5883 #[test]
5884 fn detect_shadow_open_items_classifies_duplicate_and_shadow_only_ids() {
5885 let doc = concat!(
5886 "## Pending / Not Built\n\n",
5887 "<!-- agent:backlog -->\n",
5888 "- [ ] [#live1] Keep in backlog\n",
5889 "- [/] [#gate1] Gated live item\n",
5890 "<!-- /agent:backlog -->\n\n",
5891 "<!-- parked copy\n",
5892 "- [ ] [#live1] Keep in backlog\n",
5893 "- [ ] [#lost1] Drifted out of backlog\n",
5894 "- [x] [#done1] Already done\n",
5895 "-->\n"
5896 );
5897
5898 let report = detect_shadow_open_items(doc).unwrap();
5899 assert_eq!(
5900 report
5901 .duplicated_in_live_backlog
5902 .iter()
5903 .map(|item| item.id.as_str())
5904 .collect::<Vec<_>>(),
5905 vec!["live1"]
5906 );
5907 assert_eq!(
5908 report
5909 .shadow_only
5910 .iter()
5911 .map(|item| item.id.as_str())
5912 .collect::<Vec<_>>(),
5913 vec!["lost1"]
5914 );
5915 }
5916
5917 #[test]
5918 fn detect_shadow_open_items_ignores_agent_queue_id_heads() {
5919 let doc = concat!(
5925 "<!-- agent:backlog -->\n",
5926 "- [ ] [#live1] Keep in backlog\n",
5927 "<!-- /agent:backlog -->\n\n",
5928 "<!-- agent:queue -->\n",
5929 "- do [#live1]\n",
5930 "- [#reaped1]: 0.2.170 installed\n",
5931 "- do [#reaped2]\n",
5932 "<!-- /agent:queue -->\n\n",
5933 "<!-- parked copy\n",
5934 "- [ ] [#lost1] Drifted out of backlog\n",
5935 "-->\n",
5936 );
5937
5938 let report = detect_shadow_open_items(doc).unwrap();
5939 assert!(
5941 report
5942 .shadow_only
5943 .iter()
5944 .all(|item| item.id != "reaped1" && item.id != "reaped2"),
5945 "reaped/mirrored queue id heads must not be shadow_only: {:?}",
5946 report.shadow_only
5947 );
5948 assert!(
5949 report.duplicated_in_live_backlog.is_empty(),
5950 "queue is excluded from the scan, so the mirrored do [#live1] head is not a duplicate: {:?}",
5951 report.duplicated_in_live_backlog
5952 );
5953 assert_eq!(
5955 report
5956 .shadow_only
5957 .iter()
5958 .map(|item| item.id.as_str())
5959 .collect::<Vec<_>>(),
5960 vec!["lost1"]
5961 );
5962 }
5963
5964 #[test]
5965 fn detect_shadow_open_items_ignores_indented_nested_ids() {
5966 let doc = concat!(
5967 "<!-- agent:backlog -->\n",
5968 "- [ ] [#live1] Parent task\n",
5969 " - [ ] [#nested1] Nested checklist item\n",
5970 "<!-- /agent:backlog -->\n"
5971 );
5972
5973 let report = detect_shadow_open_items(doc).unwrap();
5974 assert!(report.duplicated_in_live_backlog.is_empty());
5975 assert!(report.shadow_only.is_empty());
5976 }
5977
5978 #[test]
5979 fn extract_items_by_id_preserves_nested_subtasks() {
5980 let body = concat!(
5981 "### Active\n",
5982 "- [ ] [#move1] Parent task\n",
5983 " - child dependency\n",
5984 "- [ ] [#keep1] Keep task\n"
5985 );
5986 let (remaining, moved, matched) =
5987 extract_items_by_id(body, &["move1".to_string()]).unwrap();
5988 assert_eq!(matched, vec!["move1".to_string()]);
5989 assert!(remaining.contains("### Active\n"));
5990 assert!(!remaining.contains("[#move1]"));
5991 assert!(remaining.contains("[#keep1] Keep task"));
5992 assert_eq!(
5993 moved,
5994 concat!("- [ ] [#move1] Parent task\n", " - child dependency\n")
5995 );
5996 }
5997
5998 #[test]
5999 fn extract_items_by_id_preserves_ordered_list_style() {
6000 let body = concat!(
6001 "1. [ ] [#move1] Parent task\n",
6002 "2. [ ] [#keep1] Keep task\n",
6003 "3. [ ] [#keep2] Keep task two\n"
6004 );
6005 let (remaining, moved, matched) =
6006 extract_items_by_id(body, &["move1".to_string()]).unwrap();
6007 assert_eq!(matched, vec!["move1".to_string()]);
6008 assert_eq!(moved, "1. [ ] [#move1] Parent task\n");
6009 assert_eq!(
6010 remaining,
6011 concat!(
6012 "1. [ ] [#keep1] Keep task\n",
6013 "2. [ ] [#keep2] Keep task two\n"
6014 )
6015 );
6016 }
6017
6018 #[test]
6019 fn detect_shadow_open_items_ignores_icebox_and_code_blocks() {
6020 let doc = concat!(
6021 "## Pending / Not Built\n\n",
6022 "<!-- agent:backlog -->\n",
6023 "- [ ] [#live1] Keep in backlog\n",
6024 "<!-- /agent:backlog -->\n\n",
6025 "<!-- agent:icebox -->\n",
6026 "- [ ] [#cold1] Intentionally parked\n",
6027 "<!-- /agent:icebox -->\n\n",
6028 "```md\n",
6029 "- [ ] [#code1] Example only\n",
6030 "```\n"
6031 );
6032
6033 let report = detect_shadow_open_items(doc).unwrap();
6034 assert!(report.duplicated_in_live_backlog.is_empty());
6035 assert!(report.shadow_only.is_empty());
6036 }
6037
6038 #[test]
6039 fn detect_shadow_open_items_ignores_exchange_transcript_items() {
6040 let doc = concat!(
6041 "## Exchange\n\n",
6042 "<!-- agent:exchange patch=append -->\n",
6043 "โฏ What are #next-steps to implement the planned ipc features?\n\n",
6044 "### Re: What are #next-steps to implement the planned ipc features?\n\n",
6045 "1. [#ipc1] finalize the lazily-serde contract so message shapes are stable.\n\n",
6046 "### Session Summary\n\n",
6047 "*Compacted.*\n\n",
6048 "Icebox:\n",
6049 "- [ ] [#cold1] Intentionally parked\n",
6050 "- [ ] [#cold2] Still parked\n\n",
6051 "โฏ #code-review\n",
6052 "<!-- agent:boundary:test -->\n",
6053 "<!-- /agent:exchange -->\n\n",
6054 "<!-- agent:backlog -->\n",
6055 "<!-- /agent:backlog -->\n\n",
6056 "<!-- agent:icebox -->\n",
6057 "- [ ] [#ipc2] Live backlog item\n",
6058 "- [ ] [#cold1] Intentionally parked\n",
6059 "- [ ] [#cold2] Still parked\n",
6060 "<!-- /agent:icebox -->\n"
6061 );
6062
6063 let report = detect_shadow_open_items(doc).unwrap();
6064 assert!(report.duplicated_in_live_backlog.is_empty());
6065 assert!(report.shadow_only.is_empty());
6066 }
6067
6068 #[test]
6069 fn detect_dropped_from_history_catches_missing_item() {
6070 let baseline = concat!(
6071 "<!-- agent:backlog -->\n",
6072 "- [ ] [#keep1] Still here\n",
6073 "- [ ] [#gone1] Was open in baseline\n",
6074 "- [ ] [#gone2] Also open in baseline\n",
6075 "<!-- /agent:backlog -->\n"
6076 );
6077 let current = concat!(
6078 "<!-- agent:backlog -->\n",
6079 "- [ ] [#keep1] Still here\n",
6080 "<!-- /agent:backlog -->\n"
6081 );
6082 let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
6083 let ids: Vec<&str> = report.dropped.iter().map(|i| i.id.as_str()).collect();
6084 assert_eq!(ids, vec!["gone1", "gone2"]);
6085 }
6086
6087 #[test]
6088 fn detect_dropped_from_history_allows_done_in_live() {
6089 let baseline = concat!(
6090 "<!-- agent:backlog -->\n",
6091 "- [ ] [#item1] Was open\n",
6092 "<!-- /agent:backlog -->\n"
6093 );
6094 let current = concat!(
6095 "<!-- agent:backlog -->\n",
6096 "- [x] [#item1] Now done\n",
6097 "<!-- /agent:backlog -->\n"
6098 );
6099 let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
6100 assert!(report.dropped.is_empty());
6101 }
6102
6103 #[test]
6104 fn detect_dropped_from_history_allows_done_ids() {
6105 let baseline = concat!(
6106 "<!-- agent:backlog -->\n",
6107 "- [ ] [#item1] Was open\n",
6108 "<!-- /agent:backlog -->\n"
6109 );
6110 let current = concat!("<!-- agent:backlog -->\n", "<!-- /agent:backlog -->\n");
6111 let mut done = HashSet::new();
6112 done.insert("item1".to_string());
6113 let report = detect_dropped_from_history(current, baseline, &done).unwrap();
6114 assert!(report.dropped.is_empty());
6115 }
6116
6117 #[test]
6118 fn detect_dropped_from_history_allows_completed_archive_id() {
6119 let baseline = concat!(
6120 "<!-- agent:backlog -->\n",
6121 "- [ ] [#item1] Was open\n",
6122 "<!-- /agent:backlog -->\n"
6123 );
6124 let current = concat!(
6125 "<!-- agent:backlog -->\n",
6126 "<!-- /agent:backlog -->\n\n",
6127 "<!-- agent:done -->\n",
6128 "- 2026-05-10 [#item1] Was open\n",
6129 "<!-- /agent:done -->\n"
6130 );
6131 let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
6132 assert!(report.dropped.is_empty());
6133 }
6134
6135 #[test]
6136 fn detect_dropped_from_history_rejects_removed_completed_archive_alias() {
6137 let baseline = concat!(
6138 "<!-- agent:backlog -->\n",
6139 "- [ ] [#item1] Was open\n",
6140 "<!-- /agent:backlog -->\n"
6141 );
6142 let current = concat!(
6143 "<!-- agent:backlog -->\n",
6144 "<!-- /agent:backlog -->\n\n",
6145 "<!-- agent:pending-done -->\n",
6146 "- 2026-05-10 [#item1] Was open\n",
6147 "<!-- /agent:pending-done -->\n"
6148 );
6149 let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
6150 assert_eq!(report.dropped.len(), 1);
6151 assert_eq!(report.dropped[0].id, "item1");
6152 }
6153
6154 #[test]
6155 fn detect_dropped_from_history_allows_icebox() {
6156 let baseline = concat!(
6157 "<!-- agent:backlog -->\n",
6158 "- [ ] [#item1] Was open\n",
6159 "<!-- /agent:backlog -->\n"
6160 );
6161 let current = concat!(
6162 "<!-- agent:backlog -->\n",
6163 "<!-- /agent:backlog -->\n",
6164 "<!-- agent:icebox -->\n",
6165 "- [ ] [#item1] Archived\n",
6166 "<!-- /agent:icebox -->\n"
6167 );
6168 let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
6169 assert!(report.dropped.is_empty());
6170 }
6171
6172 #[test]
6173 fn detect_dropped_from_history_allows_shadow() {
6174 let baseline = concat!(
6175 "<!-- agent:backlog -->\n",
6176 "- [ ] [#item1] Was open\n",
6177 "<!-- /agent:backlog -->\n"
6178 );
6179 let current = concat!(
6180 "<!-- agent:backlog -->\n",
6181 "<!-- /agent:backlog -->\n\n",
6182 "<!-- parked\n",
6183 "- [ ] [#item1] Drifted to shadow\n",
6184 "-->\n"
6185 );
6186 let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
6187 assert!(report.dropped.is_empty());
6188 }
6189
6190 #[test]
6191 fn detect_dropped_from_history_ignores_baseline_done_items() {
6192 let baseline = concat!(
6193 "<!-- agent:backlog -->\n",
6194 "- [x] [#done1] Already done in baseline\n",
6195 "- [/] [#gate1] Gated in baseline\n",
6196 "<!-- /agent:backlog -->\n"
6197 );
6198 let current = concat!("<!-- agent:backlog -->\n", "<!-- /agent:backlog -->\n");
6199 let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
6200 let ids: Vec<&str> = report.dropped.iter().map(|i| i.id.as_str()).collect();
6201 assert_eq!(ids, vec!["gate1"]);
6202 }
6203
6204 #[test]
6205 fn detect_dropped_from_history_no_baseline_backlog() {
6206 let baseline = "# Just a document\nNo backlog here.\n";
6207 let current = concat!(
6208 "<!-- agent:backlog -->\n",
6209 "- [ ] [#item1] New item\n",
6210 "<!-- /agent:backlog -->\n"
6211 );
6212 let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
6213 assert!(report.dropped.is_empty());
6214 }
6215
6216 #[test]
6217 fn detect_dropped_from_history_ignores_code_blocks_in_current() {
6218 let baseline = concat!(
6219 "<!-- agent:backlog -->\n",
6220 "- [ ] [#item1] Was open\n",
6221 "<!-- /agent:backlog -->\n"
6222 );
6223 let current = concat!(
6224 "<!-- agent:backlog -->\n",
6225 "<!-- /agent:backlog -->\n\n",
6226 "```md\n",
6227 "- [ ] [#item1] In code block only\n",
6228 "```\n"
6229 );
6230 let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
6231 let ids: Vec<&str> = report.dropped.iter().map(|i| i.id.as_str()).collect();
6232 assert_eq!(ids, vec!["item1"]);
6233 }
6234
6235 #[test]
6236 fn remove_matching_tracked_line_uses_parent_prefix_for_exact_match() {
6237 let body = "1. [ ] [#one] First\n- [ ] [#two] Second\n - child detail";
6238
6239 let (updated, removed) = op_remove_matching_tracked_line(body, "[ ] [#one] First", false);
6240
6241 assert!(removed);
6242 assert_eq!(updated, "- [ ] [#two] Second\n - child detail");
6243 }
6244
6245 #[test]
6246 fn remove_matching_tracked_line_supports_contains_match() {
6247 let body = "- [ ] [#one] First\n- [ ] [#two] Second";
6248
6249 let (updated, removed) = op_remove_matching_tracked_line(body, "#two", true);
6250
6251 assert!(removed);
6252 assert_eq!(updated, "- [ ] [#one] First");
6253 }
6254
6255 #[test]
6256 fn printable_tracked_work_lines_trims_and_skips_blanks() {
6257 let body = "\n - [ ] [#one] First \n\n - child detail\n";
6258
6259 let lines = printable_tracked_work_lines(body);
6260
6261 assert_eq!(lines, vec!["- [ ] [#one] First", "- child detail"]);
6262 }
6263
6264 #[test]
6265 fn tracked_work_component_lookup_finds_requested_list() {
6266 let content = concat!(
6267 "<!-- agent:backlog -->\n",
6268 "- [ ] [#live1] Live\n",
6269 "<!-- /agent:backlog -->\n\n",
6270 "<!-- agent:icebox -->\n",
6271 "- [ ] [#cold1] Parked\n",
6272 "<!-- /agent:icebox -->\n"
6273 );
6274
6275 let component =
6276 find_tracked_work_component_in_content(content, TrackedWorkList::Icebox).unwrap();
6277
6278 assert_eq!(component.name, "icebox");
6279 assert!(component.content(content).contains("[#cold1]"));
6280 }
6281
6282 #[test]
6283 fn open_tracked_work_component_name_ignores_done_items() {
6284 let content = concat!(
6285 "<!-- agent:backlog -->\n",
6286 "- [x] [#same1] Done in backlog\n",
6287 "<!-- /agent:backlog -->\n\n",
6288 "<!-- agent:icebox -->\n",
6289 "- [ ] [#same1] Open in icebox\n",
6290 "<!-- /agent:icebox -->\n"
6291 );
6292
6293 let component = open_tracked_work_component_name_in_content(content, "same1").unwrap();
6294
6295 assert_eq!(component.as_deref(), Some("icebox"));
6296 }
6297
6298 #[test]
6299 fn active_identities_include_prompt_presets_and_active_tracked_items() {
6300 let content = concat!(
6301 "---\n",
6302 "prompt_presets:\n",
6303 " '#Next-Steps': Any follow-up items?\n",
6304 "---\n\n",
6305 "<!-- agent:backlog -->\n",
6306 "- [ ] [#alpha] Active backlog item\n",
6307 "- [x] [#done1] Finished backlog item\n",
6308 "<!-- /agent:backlog -->\n\n",
6309 "<!-- agent:review -->\n",
6310 "- [/] [#review1] Active review item\n",
6311 "<!-- /agent:review -->\n\n",
6312 "<!-- agent:icebox -->\n",
6313 "- [ ] [#cold1] Parked item\n",
6314 "<!-- /agent:icebox -->\n\n",
6315 "<!-- agent:done -->\n",
6316 "- 2026-06-30 [#arch1] Archived\n",
6317 "<!-- /agent:done -->\n"
6318 );
6319
6320 let sources = document_active_identities(content);
6321
6322 assert_eq!(sources.get("next-steps").unwrap(), &vec!["prompt_presets"]);
6323 assert_eq!(sources.get("alpha").unwrap(), &vec!["agent:backlog"]);
6324 assert_eq!(sources.get("review1").unwrap(), &vec!["agent:review"]);
6325 assert_eq!(sources.get("cold1").unwrap(), &vec!["agent:icebox"]);
6326 assert!(!sources.contains_key("done1"), "{sources:?}");
6327 assert!(!sources.contains_key("arch1"), "{sources:?}");
6328 }
6329
6330 #[test]
6331 fn detect_identity_collisions_flags_preset_and_tracked_item_ambiguity() {
6332 let content = concat!(
6333 "---\n",
6334 "prompt_presets:\n",
6335 " '#next-steps': Any follow-up items?\n",
6336 "---\n\n",
6337 "<!-- agent:backlog -->\n",
6338 "- [ ] [#next-steps] Active backlog item\n",
6339 "<!-- /agent:backlog -->\n\n",
6340 "<!-- agent:review -->\n",
6341 "- [/] [#dup7] Active review item\n",
6342 "<!-- /agent:review -->\n\n",
6343 "<!-- agent:icebox -->\n",
6344 "- [ ] [#dup7] Parked item\n",
6345 "<!-- /agent:icebox -->\n"
6346 );
6347
6348 let collisions = detect_identity_collisions(content);
6349
6350 assert_eq!(collisions.len(), 2, "{collisions:?}");
6351 assert!(
6352 collisions
6353 .iter()
6354 .any(|collision| collision.contains("#dup7")
6355 && collision.contains("agent:review + agent:icebox")),
6356 "{collisions:?}"
6357 );
6358 assert!(
6359 collisions
6360 .iter()
6361 .any(|collision| collision.contains("#next-steps")
6362 && collision.contains("prompt_presets + agent:backlog")),
6363 "{collisions:?}"
6364 );
6365 }
6366
6367 #[test]
6368 fn identity_collision_for_new_id_reports_existing_sources() {
6369 let content = concat!(
6370 "---\nprompt_presets:\n '#next-steps': x\n---\n\n",
6371 "<!-- agent:backlog -->\n- [ ] [#alpha] active\n<!-- /agent:backlog -->\n"
6372 );
6373
6374 assert_eq!(
6375 identity_collision_for_new_id(content, "next-steps"),
6376 Some(vec!["prompt_presets".to_string()])
6377 );
6378 assert_eq!(
6379 identity_collision_for_new_id(content, "#ALPHA"),
6380 Some(vec!["agent:backlog".to_string()])
6381 );
6382 assert_eq!(identity_collision_for_new_id(content, "fresh01"), None);
6383 assert_eq!(identity_collision_for_new_id(content, ""), None);
6384 }
6385
6386 #[test]
6387 fn explicit_new_item_id_collision_reports_existing_active_sources() {
6388 let content = concat!(
6389 "---\nprompt_presets:\n '#next-steps': x\n---\n\n",
6390 "<!-- agent:backlog -->\n",
6391 "- [ ] [#alpha] Active backlog item\n",
6392 "<!-- /agent:backlog -->\n"
6393 );
6394
6395 assert_eq!(
6396 explicit_new_item_id_collision(content, "id=NEXT-STEPS add follow-up"),
6397 Some(ExplicitIdCollision {
6398 candidate_id: "next-steps".to_string(),
6399 sources: vec!["prompt_presets".to_string()],
6400 })
6401 );
6402 assert_eq!(
6403 explicit_new_item_id_collision(content, "[#ALPHA] duplicate"),
6404 Some(ExplicitIdCollision {
6405 candidate_id: "alpha".to_string(),
6406 sources: vec!["agent:backlog".to_string()],
6407 })
6408 );
6409 assert_eq!(
6410 explicit_new_item_id_collision(content, "mention #alpha without explicit prefix"),
6411 None
6412 );
6413 }
6414
6415 #[test]
6416 fn ensure_new_item_explicit_id_available_rejects_ambiguous_insert() {
6417 let content = concat!(
6418 "---\nprompt_presets:\n '#deploy': x\n---\n\n",
6419 "<!-- agent:icebox -->\n",
6420 "- [ ] [#deploy] Already active in icebox\n",
6421 "<!-- /agent:icebox -->\n"
6422 );
6423
6424 let err =
6425 ensure_new_item_explicit_id_available(content, "id=deploy add duplicate").unwrap_err();
6426 let msg = format!("{err:#}");
6427 assert!(msg.contains("#deploy"), "{msg}");
6428 assert!(msg.contains("prompt_presets + agent:icebox"), "{msg}");
6429 assert!(msg.contains("#preset-item-id-collision-enforce"), "{msg}");
6430 }
6431
6432 #[test]
6433 fn resolved_tracked_work_id_detects_done_component_and_checked_items() {
6434 let archive_content = concat!(
6435 "<!-- agent:done -->\n",
6436 "- 2026-06-30 [#arch1] Archived\n",
6437 "<!-- /agent:done -->\n"
6438 );
6439 assert!(content_has_resolved_tracked_work_id(archive_content, "ARCH1").unwrap());
6440
6441 let checked_content = concat!(
6442 "<!-- agent:backlog -->\n",
6443 "- [x] [#done1] Done inline\n",
6444 "<!-- /agent:backlog -->\n"
6445 );
6446 assert!(content_has_resolved_tracked_work_id(checked_content, "done1").unwrap());
6447 }
6448}