1use anyhow::{Context, Result, anyhow, bail};
23use std::collections::HashSet;
24
25pub const IN_PROGRESS_MARKER: &str = "๐ง";
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
34pub enum PendingState {
35 Open,
36 Gated,
37 Done,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum PendingListMarker {
42 Bullet,
43 Ordered(usize),
44}
45
46impl PendingListMarker {
47 fn render_prefix(self, ordered_index: Option<usize>) -> String {
48 match ordered_index {
49 Some(index) => format!("{index}."),
50 None => match self {
51 PendingListMarker::Bullet => "-".to_string(),
52 PendingListMarker::Ordered(index) => format!("{index}."),
53 },
54 }
55 }
56
57 fn is_ordered(self) -> bool {
58 matches!(self, PendingListMarker::Ordered(_))
59 }
60}
61
62impl PendingState {
63 pub fn box_char(self) -> char {
65 match self {
66 PendingState::Open => ' ',
67 PendingState::Gated => '/',
68 PendingState::Done => 'x',
69 }
70 }
71
72 #[allow(dead_code)]
76 pub fn from_box_char(c: char) -> Option<PendingState> {
77 match c {
78 ' ' => Some(PendingState::Open),
79 '/' => Some(PendingState::Gated),
80 'x' | 'X' => Some(PendingState::Done),
81 _ => None,
82 }
83 }
84}
85
86#[derive(Debug, Clone, Copy, PartialEq, Eq)]
92#[allow(dead_code)]
93pub enum PendingOp {
94 Gate,
96 Ungate,
98 MarkDone,
100}
101
102#[derive(Debug, Clone, Copy, PartialEq, Eq)]
104pub enum TransitionResult {
105 Transition(PendingState),
106 NoOp,
107}
108
109pub fn validate_transition(from: PendingState, op: PendingOp) -> Result<TransitionResult> {
125 use PendingOp::*;
126 use PendingState as S;
127 use TransitionResult::*;
128 match (from, op) {
129 (S::Open, Gate) => Ok(Transition(S::Gated)),
130 (S::Open, Ungate) => bail!("cannot ungate Open item: source must be `[/]`"),
131 (S::Open, MarkDone) => Ok(Transition(S::Done)),
132 (S::Gated, Gate) => Ok(NoOp),
133 (S::Gated, Ungate) => Ok(Transition(S::Open)),
134 (S::Gated, MarkDone) => Ok(Transition(S::Done)),
135 (S::Done, Gate) => {
136 bail!("cannot gate Done item: add a new pending item for the follow-up gate")
137 }
138 (S::Done, Ungate) => bail!("cannot ungate Done item: source must be `[/]`"),
139 (S::Done, MarkDone) => Ok(NoOp),
140 }
141}
142
143#[derive(Debug, Clone, PartialEq, Eq)]
145pub struct PendingItem {
146 pub marker: PendingListMarker,
149 pub id: String,
153 pub state: PendingState,
155 pub gate_type: Option<String>,
158 pub in_progress: bool,
161 pub text: String,
163 pub continuation: String,
166}
167
168impl PendingItem {
169 pub fn render(&self) -> String {
172 self.render_with_ordered_index(None)
173 }
174
175 fn render_with_ordered_index(&self, ordered_index: Option<usize>) -> String {
176 let checkbox = match (&self.state, &self.gate_type) {
177 (PendingState::Gated, Some(gt)) => format!("[/{}]", gt),
178 _ => format!("[{}]", self.state.box_char()),
179 };
180 let in_progress = if self.in_progress {
181 format!(" {IN_PROGRESS_MARKER}")
182 } else {
183 String::new()
184 };
185 let mut out = format!(
186 "{} {}{} [#{}] {}",
187 self.marker.render_prefix(ordered_index),
188 checkbox,
189 in_progress,
190 self.id,
191 self.text
192 );
193 if !self.continuation.is_empty() {
194 out.push('\n');
195 out.push_str(&self.continuation);
196 }
197 out
198 }
199
200 pub fn is_done(&self) -> bool {
202 matches!(self.state, PendingState::Done)
203 }
204}
205
206#[derive(Debug, Clone, PartialEq, Eq)]
207enum PendingSegment {
208 Text(String),
209 Item {
210 item: PendingItem,
211 has_newline: bool,
212 },
213}
214
215#[derive(Debug, Clone, PartialEq, Eq, Default)]
216struct PendingLayout {
217 segments: Vec<PendingSegment>,
218}
219
220impl PendingLayout {
221 fn parse(body: &str) -> Self {
222 if body.is_empty() {
223 return Self {
224 segments: Vec::new(),
225 };
226 }
227
228 let mut segments = Vec::new();
229 let lines: Vec<&str> = body.split_inclusive('\n').collect();
230 let mut index = 0usize;
231 while index < lines.len() {
232 let raw_line = lines[index];
233 let has_newline = raw_line.ends_with('\n');
234 let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
235 if let Some(mut item) = parse_item_line(line) {
236 index += 1;
237 let mut continuation = String::new();
238 let mut pending_blank_lines = String::new();
239 while index < lines.len() {
240 let next_raw = lines[index];
241 let next_line = next_raw.strip_suffix('\n').unwrap_or(next_raw);
242 if parse_item_line(next_line).is_some() {
243 break;
244 }
245 if next_line.is_empty() {
246 pending_blank_lines.push_str(next_raw);
247 index += 1;
248 continue;
249 }
250 if is_indented_continuation_line(next_line) {
251 continuation.push_str(&pending_blank_lines);
252 pending_blank_lines.clear();
253 continuation.push_str(next_raw);
254 index += 1;
255 continue;
256 }
257 break;
258 }
259 item.continuation = continuation;
260 segments.push(PendingSegment::Item { item, has_newline });
261 if !pending_blank_lines.is_empty() {
262 segments.push(PendingSegment::Text(pending_blank_lines));
263 }
264 } else {
265 segments.push(PendingSegment::Text(raw_line.to_string()));
266 index += 1;
267 }
268 }
269 Self { segments }
270 }
271
272 fn render(&self) -> String {
273 let mut out = String::new();
274 let ordered_mode = self
275 .segments
276 .iter()
277 .filter_map(|segment| match segment {
278 PendingSegment::Item { item, .. } => Some(item.marker.is_ordered()),
279 PendingSegment::Text(_) => None,
280 })
281 .any(|is_ordered| is_ordered);
282 let mut ordered_index = 1usize;
283 for segment in &self.segments {
284 match segment {
285 PendingSegment::Text(raw) => out.push_str(raw),
286 PendingSegment::Item { item, has_newline } => {
287 let render = if ordered_mode {
288 let render = item.render_with_ordered_index(Some(ordered_index));
289 ordered_index += 1;
290 render
291 } else {
292 item.render()
293 };
294 out.push_str(&render);
295 if *has_newline && item.continuation.is_empty() {
296 out.push('\n');
297 }
298 }
299 }
300 }
301 out
302 }
303
304 fn items(&self) -> Vec<PendingItem> {
305 self.segments
306 .iter()
307 .filter_map(|segment| match segment {
308 PendingSegment::Item { item, .. } => Some(item.clone()),
309 PendingSegment::Text(_) => None,
310 })
311 .collect()
312 }
313
314 fn first_item_index(&self) -> Option<usize> {
315 self.segments
316 .iter()
317 .position(|segment| matches!(segment, PendingSegment::Item { .. }))
318 }
319
320 fn ensure_separator_before(&mut self, index: usize) {
321 if index == 0 {
322 return;
323 }
324 match &mut self.segments[index - 1] {
325 PendingSegment::Text(raw) => {
326 if !raw.is_empty() && !raw.ends_with('\n') {
327 raw.push('\n');
328 }
329 }
330 PendingSegment::Item { has_newline, .. } => {
331 *has_newline = true;
332 }
333 }
334 }
335
336 fn insert_first_item(&mut self, item: PendingItem) {
337 let index = self.first_item_index().unwrap_or(self.segments.len());
338 self.insert_item_at(index, item);
339 }
340
341 fn item_segment_index(&self, id: &str) -> Option<usize> {
344 self.segments.iter().position(
345 |segment| matches!(segment, PendingSegment::Item { item, .. } if item.id == id),
346 )
347 }
348
349 fn last_item_index_plus_one(&self) -> usize {
352 self.segments
353 .iter()
354 .rposition(|segment| matches!(segment, PendingSegment::Item { .. }))
355 .map(|idx| idx + 1)
356 .unwrap_or_else(|| self.first_item_index().unwrap_or(self.segments.len()))
357 }
358
359 fn insert_item_at(&mut self, index: usize, item: PendingItem) {
361 self.ensure_separator_before(index);
362 self.segments.insert(
363 index,
364 PendingSegment::Item {
365 item,
366 has_newline: true,
367 },
368 );
369 }
370
371 fn replace_items<F>(&self, mut replacer: F) -> Self
372 where
373 F: FnMut(&PendingItem) -> Option<PendingItem>,
374 {
375 let mut segments = Vec::with_capacity(self.segments.len());
376 for segment in &self.segments {
377 match segment {
378 PendingSegment::Text(raw) => segments.push(PendingSegment::Text(raw.clone())),
379 PendingSegment::Item { item, has_newline } => {
380 if let Some(next_item) = replacer(item) {
381 segments.push(PendingSegment::Item {
382 item: next_item,
383 has_newline: *has_newline,
384 });
385 }
386 }
387 }
388 }
389 Self { segments }
390 }
391
392 fn non_item_segments(&self) -> Vec<String> {
393 self.segments
394 .iter()
395 .filter_map(|segment| match segment {
396 PendingSegment::Text(raw) => Some(raw.clone()),
397 PendingSegment::Item { .. } => None,
398 })
399 .collect()
400 }
401}
402
403#[derive(Debug, Clone, PartialEq, Eq)]
404pub struct ShadowPendingItem {
405 pub id: String,
406 pub text: String,
407 pub line: usize,
408}
409
410impl ShadowPendingItem {
411 pub fn reference(&self) -> String {
412 format!("#{} (line {})", self.id, self.line)
413 }
414}
415
416#[derive(Debug, Clone, PartialEq, Eq, Default)]
417pub struct ShadowPendingReport {
418 pub duplicated_in_live_backlog: Vec<ShadowPendingItem>,
419 pub shadow_only: Vec<ShadowPendingItem>,
420}
421
422#[derive(Debug, Clone, PartialEq, Eq)]
423pub struct MalformedPendingItemLine {
424 pub id: String,
425 pub line: usize,
426 pub text: String,
427}
428
429impl MalformedPendingItemLine {
430 pub fn reference(&self) -> String {
431 format!("#{} (line {}): {}", self.id, self.line, self.text)
432 }
433}
434
435pub fn detect_malformed_item_lines(body: &str) -> Vec<MalformedPendingItemLine> {
439 body.lines()
440 .enumerate()
441 .filter_map(|(idx, line)| {
442 if parse_item_line(line).is_some() {
443 return None;
444 }
445 let trimmed = line.trim();
446 let (id, id_start) = find_valid_hash_id(trimmed)?;
447 let prefix = &trimmed[..id_start];
448 if !prefix_contains_task_checkbox(prefix) {
449 return None;
450 }
451 Some(MalformedPendingItemLine {
452 id: id.to_string(),
453 line: idx + 1,
454 text: trimmed.to_string(),
455 })
456 })
457 .collect()
458}
459
460fn find_valid_hash_id(line: &str) -> Option<(&str, usize)> {
461 let start = line.find("[#")?;
462 let after = &line[start + 2..];
463 let close = after.find(']')?;
464 let id = &after[..close];
465 is_valid_pending_id(id).then_some((id, start))
466}
467
468fn prefix_contains_task_checkbox(prefix: &str) -> bool {
469 prefix.contains("[ ]")
470 || prefix.contains("[/]")
471 || prefix.contains("[x]")
472 || prefix.contains("[X]")
473 || prefix
474 .split("[/")
475 .nth(1)
476 .and_then(|tail| tail.split(']').next())
477 .is_some_and(|gate| {
478 !gate.is_empty()
479 && gate
480 .chars()
481 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
482 })
483}
484
485pub fn parse_items(body: &str) -> (String, Vec<PendingItem>, String) {
495 let layout = PendingLayout::parse(body);
496 let Some(first_item) = layout.first_item_index() else {
497 return (body.to_string(), Vec::new(), String::new());
498 };
499 let last_item = layout
500 .segments
501 .iter()
502 .rposition(|segment| matches!(segment, PendingSegment::Item { .. }))
503 .unwrap_or(first_item);
504
505 let mut prelude = String::new();
506 for segment in &layout.segments[..first_item] {
507 if let PendingSegment::Text(raw) = segment {
508 prelude.push_str(raw);
509 }
510 }
511
512 let mut postlude = String::new();
513 if last_item + 1 < layout.segments.len() {
514 for segment in &layout.segments[last_item + 1..] {
515 if let PendingSegment::Text(raw) = segment {
516 postlude.push_str(raw);
517 }
518 }
519 }
520
521 let mut items = Vec::new();
522 for segment in &layout.segments[first_item..=last_item] {
523 if let PendingSegment::Item { item, .. } = segment {
524 items.push(item.clone());
525 }
526 }
527
528 (prelude, items, postlude)
529}
530
531fn parse_item_line(line: &str) -> Option<PendingItem> {
536 let (marker, rest) = parse_parent_list_marker(line)?;
537 let rest = rest.trim_start();
538
539 let (state, gate_type, after_box) = if let Some(r) = rest.strip_prefix("[ ]") {
541 (PendingState::Open, None, r.trim_start())
542 } else if let Some(r) = rest.strip_prefix("[/]") {
543 (PendingState::Gated, None, r.trim_start())
544 } else if let Some(inner) = rest.strip_prefix("[/") {
545 if let Some(close) = inner.find(']') {
547 let gt = &inner[..close];
548 if !gt.is_empty()
549 && gt
550 .chars()
551 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
552 {
553 let r = &inner[close + 1..];
554 (PendingState::Gated, Some(gt.to_lowercase()), r.trim_start())
555 } else {
556 (PendingState::Open, None, rest)
557 }
558 } else {
559 (PendingState::Open, None, rest)
560 }
561 } else if let Some(r) = rest.strip_prefix("[x]") {
562 (PendingState::Done, None, r.trim_start())
563 } else if let Some(r) = rest.strip_prefix("[X]") {
564 (PendingState::Done, None, r.trim_start())
565 } else if let Some(r) = rest.strip_prefix("[~]") {
566 (PendingState::Open, None, r.trim_start())
569 } else {
570 (PendingState::Open, None, rest)
571 };
572
573 let (in_progress, after_box) = consume_in_progress_marker(after_box);
574
575 let (id, text) = if let Some(after_hash) = after_box.strip_prefix("[#") {
577 if let Some(close) = after_hash.find(']') {
578 let id_raw = &after_hash[..close];
579 let tail = after_hash[close + 1..].trim_start();
580 if is_valid_pending_id(id_raw) {
581 (id_raw.to_lowercase(), tail.to_string())
582 } else if id_raw.is_empty() {
583 (String::new(), tail.to_string())
585 } else {
586 (String::new(), after_box.to_string())
587 }
588 } else {
589 (String::new(), after_box.to_string())
590 }
591 } else {
592 (String::new(), after_box.to_string())
593 };
594
595 let text = if id.is_empty() {
602 text
603 } else {
604 strip_redundant_self_id_tag(&text, &id)
605 };
606
607 Some(PendingItem {
608 marker,
609 id,
610 state,
611 gate_type,
612 in_progress,
613 text: text.trim_end().to_string(),
614 continuation: String::new(),
615 })
616}
617
618fn consume_in_progress_marker(text: &str) -> (bool, &str) {
619 let mut rest = text.trim_start();
620 let mut seen = false;
621 while let Some(after_marker) = rest.strip_prefix(IN_PROGRESS_MARKER) {
622 seen = true;
623 rest = after_marker.trim_start();
624 }
625 (seen, rest)
626}
627
628fn strip_redundant_self_id_tag(text: &str, id: &str) -> String {
632 let mut out = String::with_capacity(text.len());
633 let mut rest = text;
634 let mut stripped = false;
635 while let Some(pos) = rest.find("[#") {
636 if !stripped {
637 let after = &rest[pos + 2..];
638 if let Some(close) = after.find(']') {
639 let tok = &after[..close];
640 if is_valid_pending_id(tok) && tok.eq_ignore_ascii_case(id) {
641 out.push_str(&rest[..pos]);
642 rest = &after[close + 1..];
643 stripped = true;
644 continue;
645 }
646 }
647 }
648 let keep_to = pos + 2;
649 out.push_str(&rest[..keep_to]);
650 rest = &rest[keep_to..];
651 }
652 out.push_str(rest);
653 if stripped {
654 collapse_inline_spaces(&out)
655 } else {
656 out
657 }
658}
659
660fn collapse_inline_spaces(text: &str) -> String {
663 let mut out = String::with_capacity(text.len());
664 let mut prev_space = false;
665 for ch in text.chars() {
666 if ch == ' ' || ch == '\t' {
667 if !prev_space {
668 out.push(' ');
669 }
670 prev_space = true;
671 } else {
672 prev_space = false;
673 out.push(ch);
674 }
675 }
676 out.trim().to_string()
677}
678
679fn parse_parent_list_marker(line: &str) -> Option<(PendingListMarker, &str)> {
680 if let Some(rest) = line.strip_prefix("- ") {
681 return Some((PendingListMarker::Bullet, rest));
682 }
683
684 let digit_len = line.bytes().take_while(|b| b.is_ascii_digit()).count();
685 if digit_len == 0 {
686 return None;
687 }
688
689 let (digits, tail) = line.split_at(digit_len);
690 let tail = tail.strip_prefix('.')?;
691 if !tail.starts_with(char::is_whitespace) {
692 return None;
693 }
694 let rest = tail.trim_start();
695 let ordinal = digits.parse::<usize>().ok()?;
696 Some((PendingListMarker::Ordered(ordinal), rest))
697}
698
699fn is_indented_continuation_line(line: &str) -> bool {
700 line.starts_with(' ') || line.starts_with('\t')
701}
702
703fn is_structural_inter_item_line(line: &str) -> bool {
704 let trimmed = line.trim();
705 trimmed.starts_with('#')
706 || trimmed.starts_with("<!--")
707 || matches!(trimmed, "---" | "***" | "___")
708}
709
710fn split_reapable_trailing_text_segment(raw: &str) -> Option<(String, String)> {
711 if raw.is_empty() {
712 return None;
713 }
714
715 let mut reap = String::new();
716 let mut keep = String::new();
717 let mut pending_blank = String::new();
718 let mut reaping = true;
719
720 for segment in raw.split_inclusive('\n') {
721 let line = segment.strip_suffix('\n').unwrap_or(segment);
722 if !reaping {
723 keep.push_str(segment);
724 continue;
725 }
726 if line.trim().is_empty() {
727 pending_blank.push_str(segment);
728 continue;
729 }
730 if is_structural_inter_item_line(line) {
731 keep.push_str(&pending_blank);
732 pending_blank.clear();
733 keep.push_str(segment);
734 reaping = false;
735 continue;
736 }
737 reap.push_str(&pending_blank);
738 pending_blank.clear();
739 reap.push_str(segment);
740 }
741
742 if reap.is_empty() {
743 return None;
744 }
745 reap.push_str(&pending_blank);
746 Some((reap, keep))
747}
748
749pub fn is_valid_pending_id(s: &str) -> bool {
750 !s.is_empty() && s.chars().all(|c| c.is_ascii_alphanumeric() || c == '-')
751}
752
753pub fn normalize_pending_id(id: &str) -> String {
754 id.trim().trim_start_matches('#').to_ascii_lowercase()
755}
756
757#[derive(Debug, Clone, PartialEq, Eq, Hash)]
758pub struct SymptomDedupeKey {
759 pub invariant_id: String,
760 pub document_id: String,
761 pub component: String,
762 pub content_hash: String,
763}
764
765impl SymptomDedupeKey {
766 pub fn new(
767 invariant_id: impl Into<String>,
768 document_id: impl Into<String>,
769 component: impl Into<String>,
770 content_hash: impl Into<String>,
771 ) -> Result<Self> {
772 Ok(Self {
773 invariant_id: validate_symptom_token("invariant", invariant_id.into())?,
774 document_id: validate_symptom_token("document", document_id.into())?,
775 component: validate_symptom_token("component", component.into())?,
776 content_hash: validate_symptom_token("content_hash", content_hash.into())?,
777 })
778 }
779
780 pub fn marker(&self) -> String {
781 format!(
782 "[symptom-key invariant={} document={} component={} content_hash={}]",
783 self.invariant_id, self.document_id, self.component, self.content_hash
784 )
785 }
786
787 pub fn log_fields(&self) -> String {
788 format!(
789 "invariant={} document={} component={} content_hash={}",
790 self.invariant_id, self.document_id, self.component, self.content_hash
791 )
792 }
793}
794
795#[derive(Debug, Clone, PartialEq, Eq)]
796pub struct PendingAddOutcome {
797 pub body: String,
798 pub id: String,
799 pub inserted: bool,
800 pub deduped_key: Option<SymptomDedupeKey>,
801}
802
803pub fn symptom_dedupe_key_from_text(text: &str) -> Result<Option<SymptomDedupeKey>> {
804 let Some(marker_start) = text.find("[symptom-key ") else {
805 return Ok(None);
806 };
807 let marker_body_start = marker_start + "[symptom-key ".len();
808 let Some(marker_end_rel) = text[marker_body_start..].find(']') else {
809 bail!("symptom-key marker is missing closing `]`");
810 };
811 let marker_body = &text[marker_body_start..marker_body_start + marker_end_rel];
812
813 let mut invariant_id = None;
814 let mut document_id = None;
815 let mut component = None;
816 let mut content_hash = None;
817 for field in marker_body.split_whitespace() {
818 let Some((name, value)) = field.split_once('=') else {
819 bail!("symptom-key field must be key=value: {field}");
820 };
821 let slot = match name {
822 "invariant" => &mut invariant_id,
823 "document" => &mut document_id,
824 "component" => &mut component,
825 "content_hash" => &mut content_hash,
826 _ => bail!("unknown symptom-key field `{name}`"),
827 };
828 if slot.is_some() {
829 bail!("duplicate symptom-key field `{name}`");
830 }
831 *slot = Some(validate_symptom_token(name, value.to_string())?);
832 }
833
834 Ok(Some(SymptomDedupeKey::new(
835 invariant_id.context("symptom-key missing invariant")?,
836 document_id.context("symptom-key missing document")?,
837 component.context("symptom-key missing component")?,
838 content_hash.context("symptom-key missing content_hash")?,
839 )?))
840}
841
842fn validate_symptom_token(field: &str, value: String) -> Result<String> {
843 if value.trim().is_empty() {
844 bail!("symptom-key {field} must not be empty");
845 }
846 if value.trim() != value || !value.chars().all(is_symptom_token_char) {
847 bail!("symptom-key {field} must be a single field-safe token: {value}");
848 }
849 Ok(value)
850}
851
852fn is_symptom_token_char(ch: char) -> bool {
853 ch.is_ascii_alphanumeric() || matches!(ch, '_' | '-' | '.' | ':' | '/')
854}
855
856fn strip_symptom_dedupe_key_marker(text: &str) -> String {
857 let Some(marker_start) = text.find("[symptom-key ") else {
858 return text.trim().to_string();
859 };
860 let marker_body_start = marker_start + "[symptom-key ".len();
861 let Some(marker_end_rel) = text[marker_body_start..].find(']') else {
862 return text.trim().to_string();
863 };
864 let marker_end = marker_body_start + marker_end_rel + 1;
865 let mut out = String::with_capacity(text.len());
866 out.push_str(&text[..marker_start]);
867 out.push_str(&text[marker_end..]);
868 collapse_inline_spaces(&out)
869}
870
871fn symptom_evidence_line(text: &str) -> String {
872 let evidence = strip_symptom_dedupe_key_marker(text);
873 if evidence.is_empty() {
874 " evidence: repeated symptom".to_string()
875 } else {
876 format!(" evidence: {evidence}")
877 }
878}
879
880fn append_unique_continuation_line(continuation: &str, line: &str) -> String {
881 if continuation
882 .lines()
883 .any(|existing| existing.trim() == line.trim())
884 {
885 return continuation.to_string();
886 }
887 let mut out = continuation.to_string();
888 if !out.is_empty() && !out.ends_with('\n') {
889 out.push('\n');
890 }
891 out.push_str(line);
892 out.push('\n');
893 out
894}
895
896#[derive(Debug, Clone, Copy, PartialEq, Eq)]
897enum LeadingCustomIdPrefix {
898 Explicit,
899 Bracketed,
900 BarePlaceholder,
901}
902
903fn detect_leading_custom_id_prefix(text: &str) -> Option<LeadingCustomIdPrefix> {
904 let trimmed = text.trim_start();
905 if trimmed.starts_with("id=") {
906 return Some(LeadingCustomIdPrefix::Explicit);
907 }
908 let after_hash = trimmed.strip_prefix("[#")?;
909 let close = after_hash.find(']')?;
910 if close == 0 {
911 Some(LeadingCustomIdPrefix::BarePlaceholder)
912 } else {
913 Some(LeadingCustomIdPrefix::Bracketed)
914 }
915}
916
917pub(crate) fn ensure_no_leading_custom_id_prefix(text: &str, context: &str) -> Result<()> {
918 match detect_leading_custom_id_prefix(text) {
919 None => Ok(()),
920 Some(LeadingCustomIdPrefix::BarePlaceholder) => bail!(
921 "{}: bare `[#]` placeholder is invalid โ omit it or use `id=<id> <text>`",
922 context
923 ),
924 Some(LeadingCustomIdPrefix::Explicit) | Some(LeadingCustomIdPrefix::Bracketed) => bail!(
925 "{}: duplicate leading custom id prefix in item text โ use exactly one leading `id=<id>` or `[#id]` prefix",
926 context
927 ),
928 }
929}
930
931pub fn ensure_no_new_leading_custom_id_prefix(
932 item_id: &str,
933 text: &str,
934 existing_ids: &HashSet<String>,
935 context: &str,
936) -> Result<()> {
937 match detect_leading_custom_id_prefix(text) {
938 None => Ok(()),
939 Some(LeadingCustomIdPrefix::BarePlaceholder) => bail!(
940 "{}: bare `[#]` placeholder is invalid โ omit it or use `id=<id> <text>`",
941 context
942 ),
943 Some(LeadingCustomIdPrefix::Explicit) => bail!(
944 "{}: duplicate leading custom id prefix in item text โ use exactly one leading `id=<id>` or `[#id]` prefix",
945 context
946 ),
947 Some(LeadingCustomIdPrefix::Bracketed) if existing_ids.contains(item_id) => Ok(()),
948 Some(LeadingCustomIdPrefix::Bracketed) => bail!(
949 "{}: duplicate leading custom id prefix in item text โ use exactly one leading `id=<id>` or `[#id]` prefix",
950 context
951 ),
952 }
953}
954
955fn extract_inline_tag_id(text: &str) -> Option<(String, String)> {
956 let mut search_from = 0;
957 while search_from < text.len() {
958 let remaining = &text[search_from..];
959 let pos = match remaining.find("[#") {
960 Some(p) => p,
961 None => break,
962 };
963 let abs_pos = search_from + pos;
964 let after_open = match text.get(abs_pos + 2..) {
965 Some(s) => s,
966 None => {
967 search_from = abs_pos + 2;
968 continue;
969 }
970 };
971 let close = match after_open.find(']') {
972 Some(c) => c,
973 None => {
974 search_from = abs_pos + 2;
975 continue;
976 }
977 };
978 let raw_id = &after_open[..close];
979 if !raw_id.is_empty() && is_valid_pending_id(raw_id) {
980 let tag_end = abs_pos + 2 + close + 1;
981 let before = text[..abs_pos].trim_end();
982 let after = text[tag_end..].trim_start();
983 let new_text = if before.is_empty() {
984 after.to_string()
985 } else if after.is_empty() {
986 before.to_string()
987 } else {
988 format!("{} {}", before, after)
989 };
990 if !new_text.is_empty() {
991 return Some((raw_id.to_lowercase(), new_text));
992 }
993 }
994 search_from = abs_pos + 2;
995 }
996 None
997}
998
999fn leading_bare_tag_id(text: &str) -> Option<(String, String)> {
1011 let trimmed = text.trim_start();
1012 let after_hash = trimmed.strip_prefix('#')?;
1013 let end = after_hash
1014 .find(|c: char| !(c.is_ascii_alphanumeric() || c == '-'))
1015 .unwrap_or(after_hash.len());
1016 if end == 0 {
1017 return None;
1018 }
1019 let token = &after_hash[..end];
1020 if !is_valid_pending_id(token) {
1021 return None;
1022 }
1023 let separator = after_hash[end..].chars().next()?;
1027 if !separator.is_whitespace() {
1028 return None;
1029 }
1030 let rest = after_hash[end..].trim_start();
1031 if rest.is_empty() {
1032 return None;
1033 }
1034 Some((token.to_lowercase(), rest.to_string()))
1035}
1036
1037fn custom_id_error(raw_id: &str) -> anyhow::Error {
1038 anyhow!(
1039 "pending add: invalid custom id `{}` โ ids must be non-empty ASCII alphanumeric strings (hyphen allowed)",
1040 raw_id.trim()
1041 )
1042}
1043
1044fn parse_explicit_custom_id_prefix(rest: &str) -> Result<(Option<String>, String)> {
1045 let Some((raw_id, remainder)) = rest.split_once(char::is_whitespace) else {
1046 bail!(
1047 "pending add: custom id prefix must be followed by item text (expected `id=<id> <text>`)"
1048 );
1049 };
1050 let custom_id = raw_id.trim().trim_start_matches('#');
1051 if custom_id.is_empty() {
1052 bail!("pending add: empty custom id after `id=` โ expected `id=<id> <text>`");
1053 }
1054 if !is_valid_pending_id(custom_id) {
1055 return Err(custom_id_error(raw_id));
1056 }
1057 let remainder = remainder.trim();
1058 if remainder.is_empty() {
1059 bail!(
1060 "pending add: custom id prefix must be followed by item text (expected `id=<id> <text>`)"
1061 );
1062 }
1063 ensure_no_leading_custom_id_prefix(remainder, "pending add")?;
1064 Ok((Some(custom_id.to_lowercase()), remainder.to_string()))
1065}
1066
1067fn parse_bracketed_custom_id_prefix(trimmed: &str) -> Result<(Option<String>, String)> {
1068 let Some(after_hash) = trimmed.strip_prefix("[#") else {
1069 return Ok((None, trimmed.to_string()));
1070 };
1071 let Some(close) = after_hash.find(']') else {
1072 return Ok((None, trimmed.to_string()));
1073 };
1074 let raw_id = &after_hash[..close];
1075 let remainder = after_hash[close + 1..].trim_start();
1076 if raw_id.is_empty() {
1077 bail!("pending add: bare `[#]` placeholder is invalid โ use `id=<id> <text>` or omit it");
1078 }
1079 if !is_valid_pending_id(raw_id) {
1080 return Err(custom_id_error(raw_id));
1081 }
1082 if remainder.is_empty() {
1083 bail!(
1084 "pending add: bracketed custom id prefix must be followed by item text (expected `[#id] <text>`)"
1085 );
1086 }
1087 ensure_no_leading_custom_id_prefix(remainder, "pending add")?;
1088 Ok((Some(raw_id.to_lowercase()), remainder.to_string()))
1089}
1090
1091fn parse_custom_id_prefix(text: &str) -> Result<(Option<String>, String)> {
1092 let trimmed = text.trim();
1093 if let Some(rest) = trimmed.strip_prefix("id=") {
1094 return parse_explicit_custom_id_prefix(rest);
1095 }
1096 parse_bracketed_custom_id_prefix(trimmed)
1097}
1098
1099pub fn explicit_custom_id(item: &str) -> Option<String> {
1107 match parse_custom_id_prefix(item) {
1108 Ok((id, _)) => {
1109 if id.is_some() {
1110 id
1111 } else {
1112 leading_bare_tag_id(item).map(|(id, _)| id)
1116 }
1117 }
1118 Err(_) => None,
1119 }
1120}
1121
1122#[test]
1123fn existing_item_may_keep_leading_alias_tag() {
1124 let mut existing_ids = HashSet::new();
1125 existing_ids.insert("yckq".to_string());
1126
1127 ensure_no_new_leading_custom_id_prefix(
1128 "yckq",
1129 "[#ss01] ShipStation fix",
1130 &existing_ids,
1131 "pending/backlog patch",
1132 )
1133 .expect("existing alias tag should not be rejected");
1134}
1135
1136#[test]
1137fn new_item_still_rejects_leading_alias_tag() {
1138 let existing_ids = HashSet::new();
1139 let err = ensure_no_new_leading_custom_id_prefix(
1140 "yckq",
1141 "[#ss01] ShipStation fix",
1142 &existing_ids,
1143 "pending/backlog patch",
1144 )
1145 .expect_err("new item alias tag should still be rejected");
1146 assert!(
1147 err.to_string()
1148 .contains("duplicate leading custom id prefix"),
1149 "unexpected error: {}",
1150 err
1151 );
1152}
1153
1154#[allow(dead_code)]
1156pub fn render_items(prelude: &str, items: &[PendingItem], postlude: &str) -> String {
1157 let mut out = String::new();
1158 let ordered_mode = items.iter().any(|item| item.marker.is_ordered());
1159 let mut ordered_index = 1usize;
1160 out.push_str(prelude);
1161 if !prelude.is_empty() && !prelude.ends_with('\n') {
1162 out.push('\n');
1163 }
1164 for item in items {
1165 let render = if ordered_mode {
1166 let render = item.render_with_ordered_index(Some(ordered_index));
1167 ordered_index += 1;
1168 render
1169 } else {
1170 item.render()
1171 };
1172 out.push_str(&render);
1173 if item.continuation.is_empty() || !item.continuation.ends_with('\n') {
1174 out.push('\n');
1175 }
1176 }
1177 if !postlude.is_empty() {
1178 out.push_str(postlude);
1179 if !postlude.ends_with('\n') {
1180 out.push('\n');
1181 }
1182 }
1183 out
1184}
1185
1186pub fn op_take_item(body: &str, id: &str) -> Result<(String, PendingItem)> {
1188 let id = normalize_pending_id(id);
1189 let layout = PendingLayout::parse(body);
1190 let mut taken = None;
1191 let rewritten = layout.replace_items(|item| {
1192 if item.id == id {
1193 taken = Some(item.clone());
1194 None
1195 } else {
1196 Some(item.clone())
1197 }
1198 });
1199 let Some(item) = taken else {
1200 bail!("id not found in pending list: {}", id);
1201 };
1202 Ok((rewritten.render(), item))
1203}
1204
1205pub fn op_take_all_by_id(body: &str, id: &str) -> (String, Vec<PendingItem>) {
1212 let id = normalize_pending_id(id);
1213 let layout = PendingLayout::parse(body);
1214 let mut taken = Vec::new();
1215 let rewritten = layout.replace_items(|item| {
1216 if item.id == id {
1217 taken.push(item.clone());
1218 None
1219 } else {
1220 Some(item.clone())
1221 }
1222 });
1223 (rewritten.render(), taken)
1224}
1225
1226pub fn op_dedupe_identical_items(body: &str) -> (String, Vec<String>) {
1233 let layout = PendingLayout::parse(body);
1234 let mut seen: Vec<PendingItem> = Vec::new();
1235 let mut deduped_ids: Vec<String> = Vec::new();
1236 let rewritten = layout.replace_items(|item| {
1237 if seen.iter().any(|prev| prev == item) {
1238 if !deduped_ids.contains(&item.id) {
1239 deduped_ids.push(item.id.clone());
1240 }
1241 None
1242 } else {
1243 seen.push(item.clone());
1244 Some(item.clone())
1245 }
1246 });
1247 (rewritten.render(), deduped_ids)
1248}
1249
1250pub fn op_insert_item_first(body: &str, item: PendingItem) -> String {
1252 let mut layout = PendingLayout::parse(body);
1253 layout.insert_first_item(item);
1254 layout.render()
1255}
1256
1257pub fn op_append_items(body: &str, items: &[PendingItem]) -> String {
1259 let mut layout = PendingLayout::parse(body);
1260 let insert_at = layout
1261 .segments
1262 .iter()
1263 .rposition(|segment| matches!(segment, PendingSegment::Item { .. }))
1264 .map(|idx| idx + 1)
1265 .unwrap_or_else(|| layout.first_item_index().unwrap_or(layout.segments.len()));
1266 layout.ensure_separator_before(insert_at);
1267 for (offset, item) in items.iter().enumerate() {
1268 layout.segments.insert(
1269 insert_at + offset,
1270 PendingSegment::Item {
1271 item: item.clone(),
1272 has_newline: true,
1273 },
1274 );
1275 }
1276 layout.render()
1277}
1278
1279pub fn op_take_items_by_state(body: &str, state: PendingState) -> (String, Vec<PendingItem>) {
1281 let layout = PendingLayout::parse(body);
1282 let mut taken = Vec::new();
1283 let rewritten = layout.replace_items(|item| {
1284 if item.state == state {
1285 taken.push(item.clone());
1286 None
1287 } else {
1288 Some(item.clone())
1289 }
1290 });
1291 (rewritten.render(), taken)
1292}
1293
1294pub fn op_take_active_items_by_ids(
1296 body: &str,
1297 ids: &HashSet<String>,
1298) -> (String, Vec<PendingItem>) {
1299 if ids.is_empty() {
1300 return (body.to_string(), Vec::new());
1301 }
1302 let ids: HashSet<String> = ids.iter().map(|id| normalize_pending_id(id)).collect();
1303 let layout = PendingLayout::parse(body);
1304 let mut taken = Vec::new();
1305 let rewritten = layout.replace_items(|item| {
1306 if !item.is_done() && ids.contains(&item.id) {
1307 taken.push(item.clone());
1308 None
1309 } else {
1310 Some(item.clone())
1311 }
1312 });
1313 (rewritten.render(), taken)
1314}
1315
1316pub fn canonicalize_preserving_non_item_lines(body: &str) -> String {
1317 PendingLayout::parse(body).render()
1318}
1319
1320fn parse_pending_edit_payload(new_text: &str) -> Result<(String, String)> {
1321 let synthetic = format!("- [ ] [#edit] {}", new_text);
1322 let layout = PendingLayout::parse(&synthetic);
1323 let items = layout.items();
1324 if items.len() != 1 {
1325 bail!(
1326 "pending edit: multiline text may only describe one parent item; use indented continuation lines for child subtasks"
1327 );
1328 }
1329 if layout
1330 .non_item_segments()
1331 .into_iter()
1332 .any(|segment| !segment.trim().is_empty())
1333 {
1334 bail!(
1335 "pending edit: multiline text may only contain indented continuation lines after the first line"
1336 );
1337 }
1338 let mut item = items.into_iter().next().expect("single parsed edit item");
1339 if !item.continuation.is_empty() && !item.continuation.ends_with('\n') {
1340 item.continuation.push('\n');
1341 }
1342 Ok((item.text, item.continuation))
1343}
1344
1345fn trim_boundary_blank_segments(mut segments: Vec<String>) -> Vec<String> {
1346 while matches!(segments.first(), Some(segment) if segment.trim().is_empty()) {
1347 segments.remove(0);
1348 }
1349 while matches!(segments.last(), Some(segment) if segment.trim().is_empty()) {
1350 segments.pop();
1351 }
1352 segments
1353}
1354
1355pub fn preserves_non_item_structure(lhs: &str, rhs: &str) -> bool {
1356 trim_boundary_blank_segments(PendingLayout::parse(lhs).non_item_segments())
1357 == trim_boundary_blank_segments(PendingLayout::parse(rhs).non_item_segments())
1358}
1359
1360pub fn merge_partial_backlog_prefix(current_body: &str, target_body: &str) -> Option<String> {
1361 let current = PendingLayout::parse(current_body);
1362 let target = PendingLayout::parse(target_body);
1363
1364 let current_texts: Vec<(usize, String)> = current
1365 .segments
1366 .iter()
1367 .enumerate()
1368 .filter_map(|(idx, segment)| match segment {
1369 PendingSegment::Text(raw) if !raw.trim().is_empty() => Some((idx, raw.clone())),
1370 _ => None,
1371 })
1372 .collect();
1373 let target_texts: Vec<String> = target
1374 .segments
1375 .iter()
1376 .filter_map(|segment| match segment {
1377 PendingSegment::Text(raw) if !raw.trim().is_empty() => Some(raw.clone()),
1378 _ => None,
1379 })
1380 .collect();
1381
1382 if current_texts.is_empty()
1383 || target_texts.is_empty()
1384 || target_texts.len() >= current_texts.len()
1385 || !target_texts
1386 .iter()
1387 .zip(current_texts.iter())
1388 .all(|(target_text, (_, current_text))| target_text == current_text)
1389 {
1390 return None;
1391 }
1392
1393 let tail_start = current_texts[target_texts.len()].0;
1394 let mut segments = target.segments.clone();
1395 segments.extend(current.segments[tail_start..].iter().cloned());
1396 Some(PendingLayout { segments }.render())
1397}
1398
1399pub fn detect_shadow_open_items(doc: &str) -> Result<ShadowPendingReport> {
1400 let components = crate::component::parse(doc)?;
1401 let Some(backlog_component) = components
1402 .iter()
1403 .find(|component| crate::component::is_backlog_component(&component.name))
1404 else {
1405 return Ok(ShadowPendingReport::default());
1406 };
1407
1408 let (_, live_items, _) = parse_items(backlog_component.content(doc));
1409 let live_open_ids: HashSet<String> = live_items
1410 .into_iter()
1411 .filter(|item| !item.id.is_empty() && !item.is_done())
1412 .map(|item| item.id)
1413 .collect();
1414
1415 let excluded_ranges: Vec<(usize, usize)> = components
1416 .iter()
1417 .filter(|component| {
1418 crate::component::is_tracked_work_component(&component.name)
1419 || component.name == "exchange"
1420 || component.name == "queue"
1430 })
1431 .map(|component| (component.open_start, component.close_end))
1432 .collect();
1433 let code_ranges = crate::component::find_code_ranges(doc);
1434
1435 let mut report = ShadowPendingReport::default();
1436 let mut seen_ids = HashSet::new();
1437 let mut offset = 0usize;
1438
1439 for (line_idx, raw_line) in doc.split_inclusive('\n').enumerate() {
1440 let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
1441 let line_start = offset;
1442 let line_end = offset + raw_line.len();
1443 offset = line_end;
1444
1445 if excluded_ranges
1446 .iter()
1447 .any(|(start, end)| line_start < *end && line_end > *start)
1448 {
1449 continue;
1450 }
1451 if code_ranges
1452 .iter()
1453 .any(|(start, end)| line_start < *end && line_end > *start)
1454 {
1455 continue;
1456 }
1457
1458 let Some(item) = parse_item_line(line) else {
1459 continue;
1460 };
1461 if item.id.is_empty() || item.is_done() || !seen_ids.insert(item.id.clone()) {
1462 continue;
1463 }
1464
1465 let shadow = ShadowPendingItem {
1466 id: item.id.clone(),
1467 text: item.text,
1468 line: line_idx + 1,
1469 };
1470 if live_open_ids.contains(&item.id) {
1471 report.duplicated_in_live_backlog.push(shadow);
1472 } else {
1473 report.shadow_only.push(shadow);
1474 }
1475 }
1476
1477 Ok(report)
1478}
1479
1480#[derive(Debug, Clone, PartialEq, Eq)]
1481pub struct DroppedBacklogItem {
1482 pub id: String,
1483 pub text: String,
1484}
1485
1486impl DroppedBacklogItem {
1487 pub fn reference(&self) -> String {
1488 format!("#{}", self.id)
1489 }
1490}
1491
1492#[derive(Debug, Clone, PartialEq, Eq, Default)]
1493pub struct DroppedBacklogReport {
1494 pub dropped: Vec<DroppedBacklogItem>,
1495}
1496
1497#[allow(dead_code)]
1511pub fn detect_dropped_from_history(
1512 current_doc: &str,
1513 baseline_doc: &str,
1514 done_ids: &HashSet<String>,
1515) -> Result<DroppedBacklogReport> {
1516 detect_dropped_from_history_with_extra_current_ids(
1517 current_doc,
1518 baseline_doc,
1519 done_ids,
1520 &HashSet::new(),
1521 )
1522}
1523
1524pub fn detect_dropped_from_history_with_extra_current_ids(
1525 current_doc: &str,
1526 baseline_doc: &str,
1527 done_ids: &HashSet<String>,
1528 extra_current_ids: &HashSet<String>,
1529) -> Result<DroppedBacklogReport> {
1530 let baseline_components = crate::component::parse(baseline_doc)?;
1531 let Some(baseline_backlog) = baseline_components
1532 .iter()
1533 .find(|c| crate::component::is_backlog_component(&c.name))
1534 else {
1535 return Ok(DroppedBacklogReport::default());
1536 };
1537
1538 let (_, baseline_items, _) = parse_items(baseline_backlog.content(baseline_doc));
1539 let baseline_open: Vec<(String, String)> = baseline_items
1540 .into_iter()
1541 .filter(|item| !item.id.is_empty() && !item.is_done())
1542 .map(|item| (item.id, item.text))
1543 .collect();
1544
1545 if baseline_open.is_empty() {
1546 return Ok(DroppedBacklogReport::default());
1547 }
1548
1549 let current_components = crate::component::parse(current_doc)?;
1550
1551 let mut current_ids: HashSet<String> = HashSet::new();
1552
1553 for comp in ¤t_components {
1554 if crate::component::is_tracked_work_component(&comp.name) {
1555 let (_, items, _) = parse_items(comp.content(current_doc));
1556 for item in items {
1557 if !item.id.is_empty() {
1558 current_ids.insert(item.id);
1559 }
1560 }
1561 } else if crate::component::is_backlog_done_component(&comp.name) {
1562 current_ids.extend(extract_pending_ids_from_text(comp.content(current_doc)));
1563 }
1564 }
1565 current_ids.extend(extra_current_ids.iter().cloned());
1566
1567 let excluded_ranges: Vec<(usize, usize)> = current_components
1568 .iter()
1569 .filter(|c| crate::component::is_tracked_work_component(&c.name))
1570 .map(|c| (c.open_start, c.close_end))
1571 .collect();
1572 let code_ranges = crate::component::find_code_ranges(current_doc);
1573
1574 let mut offset = 0usize;
1575 for raw_line in current_doc.split_inclusive('\n') {
1576 let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
1577 let line_start = offset;
1578 let line_end = offset + raw_line.len();
1579 offset = line_end;
1580
1581 if excluded_ranges
1582 .iter()
1583 .any(|(start, end)| line_start < *end && line_end > *start)
1584 {
1585 continue;
1586 }
1587 if code_ranges
1588 .iter()
1589 .any(|(start, end)| line_start < *end && line_end > *start)
1590 {
1591 continue;
1592 }
1593
1594 if let Some(item) = parse_item_line(line)
1595 && !item.id.is_empty()
1596 {
1597 current_ids.insert(item.id);
1598 }
1599 }
1600
1601 let mut dropped = Vec::new();
1602 for (id, text) in baseline_open {
1603 if done_ids.contains(&id) {
1604 continue;
1605 }
1606 if !current_ids.contains(&id) {
1607 dropped.push(DroppedBacklogItem { id, text });
1608 }
1609 }
1610
1611 Ok(DroppedBacklogReport { dropped })
1612}
1613
1614pub fn active_item_ids(body: &str) -> Vec<String> {
1621 let today = today_civil_day();
1622 PendingLayout::parse(body)
1623 .items()
1624 .into_iter()
1625 .filter(|item| matches!(item.state, PendingState::Open) && !item.id.is_empty())
1626 .filter(|item| !item_precondition_unmet(&item.text, today))
1627 .map(|item| item.id.clone())
1628 .collect()
1629}
1630
1631pub fn set_in_progress_item_ids(body: &str, ids: &HashSet<String>) -> (String, bool) {
1634 let normalized_ids: HashSet<String> = ids
1635 .iter()
1636 .map(|id| normalize_pending_id(id))
1637 .filter(|id| !id.is_empty())
1638 .collect();
1639 let mut changed = false;
1640 let rewritten = PendingLayout::parse(body).replace_items(|item| {
1641 let mut next = item.clone();
1642 let should_mark = !next.id.is_empty()
1643 && !matches!(next.state, PendingState::Done)
1644 && normalized_ids.contains(&next.id.to_ascii_lowercase());
1645 if next.in_progress != should_mark {
1646 next.in_progress = should_mark;
1647 changed = true;
1648 }
1649 Some(next)
1650 });
1651 let new_body = rewritten.render();
1652 if new_body != body {
1653 changed = true;
1654 }
1655 (new_body, changed)
1656}
1657
1658fn item_has_enqueue_marker(text: &str) -> bool {
1659 let lower = text.to_ascii_lowercase();
1660 lower.contains(":inbox_tray:") || lower.split_whitespace().any(token_is_enqueue_marker)
1661}
1662
1663fn token_is_enqueue_marker(token: &str) -> bool {
1664 let trimmed =
1665 token.trim_matches(|ch: char| matches!(ch, '[' | ']' | '(' | ')' | ':' | ',' | '.' | ';'));
1666 matches!(
1667 trimmed,
1668 "/enqueue" | "**enqueue**" | "__enqueue__" | "*enqueue*" | "_enqueue_" | "`enqueue`"
1669 )
1670}
1671
1672pub fn active_enqueue_item_ids(body: &str) -> Vec<String> {
1678 let today = today_civil_day();
1679 PendingLayout::parse(body)
1680 .items()
1681 .into_iter()
1682 .filter(|item| matches!(item.state, PendingState::Open) && !item.id.is_empty())
1683 .filter(|item| item_has_enqueue_marker(&item.text))
1684 .filter(|item| !item_precondition_unmet(&item.text, today))
1685 .map(|item| item.id.clone())
1686 .collect()
1687}
1688
1689pub const PRIORITY_RANK_UNSET: u8 = 10;
1695
1696pub fn item_priority_rank(text: &str) -> u8 {
1697 for token in text.split_whitespace() {
1698 if let Some(value) = token.strip_prefix("priority=")
1699 && let Ok(n) = value.parse::<u8>()
1700 && (1..=9).contains(&n)
1701 {
1702 return n;
1703 }
1704 }
1705 PRIORITY_RANK_UNSET
1706}
1707
1708pub fn active_item_priorities(body: &str) -> Vec<(String, u8)> {
1711 let today = today_civil_day();
1712 PendingLayout::parse(body)
1713 .items()
1714 .into_iter()
1715 .filter(|item| matches!(item.state, PendingState::Open) && !item.id.is_empty())
1716 .filter(|item| !item_precondition_unmet(&item.text, today))
1717 .map(|item| (item.id.clone(), item_priority_rank(&item.text)))
1718 .collect()
1719}
1720
1721pub fn item_not_before_day(text: &str) -> Option<i64> {
1728 const TOK: &str = "not-before=";
1729 let mut search = 0;
1730 while let Some(rel) = text[search..].find(TOK) {
1731 let idx = search + rel;
1732 let boundary_ok = idx == 0
1733 || !text[..idx]
1734 .chars()
1735 .next_back()
1736 .map(|c| c.is_ascii_alphanumeric() || c == '-')
1737 .unwrap_or(false);
1738 search = idx + TOK.len();
1739 if !boundary_ok {
1740 continue;
1741 }
1742 let value: String = text[search..]
1743 .chars()
1744 .take_while(|c| c.is_ascii_digit() || *c == '-')
1745 .collect();
1746 if let Some(day) = parse_ymd_to_civil_day(&value) {
1747 return Some(day);
1748 }
1749 }
1750 None
1751}
1752
1753fn parse_ymd_to_civil_day(value: &str) -> Option<i64> {
1754 let parts: Vec<&str> = value.split('-').collect();
1755 if parts.len() != 3 {
1756 return None;
1757 }
1758 let y: i64 = parts[0].parse().ok()?;
1759 let m: u32 = parts[1].parse().ok()?;
1760 let d: u32 = parts[2].parse().ok()?;
1761 if !(1..=12).contains(&m) || !(1..=31).contains(&d) {
1762 return None;
1763 }
1764 Some(days_from_civil(y, m, d))
1765}
1766
1767fn days_from_civil(y: i64, m: u32, d: u32) -> i64 {
1770 let y = if m <= 2 { y - 1 } else { y };
1771 let era = (if y >= 0 { y } else { y - 399 }) / 400;
1772 let yoe = y - era * 400;
1773 let mp = ((m + 9) % 12) as i64;
1774 let doy = (153 * mp + 2) / 5 + d as i64 - 1;
1775 let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1776 era * 146097 + doe - 719468
1777}
1778
1779pub fn today_civil_day() -> i64 {
1783 use std::time::{SystemTime, UNIX_EPOCH};
1784 SystemTime::now()
1785 .duration_since(UNIX_EPOCH)
1786 .map(|d| (d.as_secs() / 86_400) as i64)
1787 .unwrap_or(0)
1788}
1789
1790pub fn item_precondition_unmet(text: &str, today: i64) -> bool {
1795 item_not_before_day(text).is_some_and(|day| day > today)
1796}
1797
1798pub fn item_after_deps(text: &str) -> Vec<String> {
1805 let mut deps = Vec::new();
1806 let bytes = text.as_bytes();
1807 let mut search = 0;
1808 while let Some(rel) = text[search..].find("after=") {
1809 let idx = search + rel;
1810 search = idx + "after=".len();
1811 if idx > 0 && bytes[idx - 1].is_ascii_alphanumeric() {
1812 continue;
1813 }
1814 let value = &text[search..];
1815 let chunk: String = value
1816 .chars()
1817 .take_while(|c| {
1818 c.is_ascii_alphanumeric() || matches!(c, '#' | '-' | '_' | ',' | '[' | ']')
1819 })
1820 .collect();
1821 for part in chunk.split(',') {
1822 let id = part
1823 .trim()
1824 .trim_matches(|c| matches!(c, '#' | '[' | ']'))
1825 .to_ascii_lowercase();
1826 if !id.is_empty() && is_valid_pending_id(&id) {
1827 deps.push(id);
1828 }
1829 }
1830 }
1831 deps
1832}
1833
1834pub fn active_item_after_deps(body: &str) -> Vec<(String, Vec<String>)> {
1837 PendingLayout::parse(body)
1838 .items()
1839 .into_iter()
1840 .filter(|item| matches!(item.state, PendingState::Open) && !item.id.is_empty())
1841 .map(|item| (item.id.clone(), item_after_deps(&item.text)))
1842 .collect()
1843}
1844
1845pub const CLEAN_SESSION_TAG: &str = "[clean-session]";
1850pub const OPERATOR_VERIFY_TAG: &str = "[operator-verify]";
1851pub const FOCUSED_CYCLE_TAG: &str = "[focused-cycle]";
1852
1853#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
1875pub struct ExecutionContext {
1876 pub clean_session_required: bool,
1877 pub operator_verify_required: bool,
1878 pub focused_cycle_required: bool,
1879}
1880
1881impl ExecutionContext {
1882 pub fn is_deferred(&self) -> bool {
1884 self.clean_session_required || self.operator_verify_required || self.focused_cycle_required
1885 }
1886
1887 pub fn loop_undrainable(&self) -> bool {
1893 self.operator_verify_required || self.focused_cycle_required
1894 }
1895
1896 pub fn supervisor_undrainable(&self) -> bool {
1906 self.operator_verify_required
1907 }
1908}
1909
1910pub fn item_clean_session_required(text: &str) -> bool {
1914 text_has_bracket_tag(text, "clean-session")
1915}
1916
1917pub fn item_operator_verify_required(text: &str) -> bool {
1920 text_has_bracket_tag(text, "operator-verify")
1921}
1922
1923pub fn item_focused_cycle_required(text: &str) -> bool {
1926 text_has_bracket_tag(text, "focused-cycle")
1927}
1928
1929pub fn item_execution_context(text: &str) -> ExecutionContext {
1932 ExecutionContext {
1933 clean_session_required: item_clean_session_required(text),
1934 operator_verify_required: item_operator_verify_required(text),
1935 focused_cycle_required: item_focused_cycle_required(text),
1936 }
1937}
1938
1939fn text_has_bracket_tag(text: &str, tag: &str) -> bool {
1940 text.split_whitespace().any(|token| {
1941 let trimmed = token.trim_matches(|c: char| matches!(c, '(' | ')' | ',' | '.' | ';' | ':'));
1942 trimmed.len() == tag.len() + 2
1943 && trimmed.starts_with('[')
1944 && trimmed.ends_with(']')
1945 && trimmed[1..trimmed.len() - 1].eq_ignore_ascii_case(tag)
1946 })
1947}
1948
1949pub fn strip_execution_context_tags(text: &str) -> String {
1953 let kept: Vec<&str> = text
1954 .split_whitespace()
1955 .filter(|token| {
1956 let trimmed =
1957 token.trim_matches(|c: char| matches!(c, '(' | ')' | ',' | '.' | ';' | ':'));
1958 !(trimmed.eq_ignore_ascii_case(CLEAN_SESSION_TAG)
1959 || trimmed.eq_ignore_ascii_case(OPERATOR_VERIFY_TAG)
1960 || trimmed.eq_ignore_ascii_case(FOCUSED_CYCLE_TAG))
1961 })
1962 .collect();
1963 kept.join(" ")
1964}
1965
1966pub fn active_item_execution_contexts(body: &str) -> Vec<(String, ExecutionContext)> {
1970 PendingLayout::parse(body)
1971 .items()
1972 .into_iter()
1973 .filter(|item| matches!(item.state, PendingState::Open) && !item.id.is_empty())
1974 .map(|item| (item.id.clone(), item_execution_context(&item.text)))
1975 .collect()
1976}
1977
1978pub fn sort_by_priority(body: &str) -> Option<String> {
1983 let layout = PendingLayout::parse(body);
1984 let positions: Vec<usize> = layout
1985 .segments
1986 .iter()
1987 .enumerate()
1988 .filter_map(|(i, s)| matches!(s, PendingSegment::Item { .. }).then_some(i))
1989 .collect();
1990 if positions.len() < 2 {
1991 return None;
1992 }
1993 let mut slots: Vec<(usize, PendingItem)> = positions
1994 .iter()
1995 .enumerate()
1996 .map(|(slot, &pos)| match &layout.segments[pos] {
1997 PendingSegment::Item { item, .. } => (slot, item.clone()),
1998 _ => unreachable!(),
1999 })
2000 .collect();
2001 let before: Vec<usize> = slots.iter().map(|(slot, _)| *slot).collect();
2002 slots.sort_by_key(|(_, item)| item_priority_rank(&item.text));
2003 let after: Vec<usize> = slots.iter().map(|(slot, _)| *slot).collect();
2004 if before == after {
2005 return None;
2006 }
2007 let mut segments = layout.segments.clone();
2008 for (target, &pos) in positions.iter().enumerate() {
2009 let item = slots[target].1.clone();
2010 if let PendingSegment::Item { has_newline, .. } = layout.segments[pos] {
2011 segments[pos] = PendingSegment::Item { item, has_newline };
2012 }
2013 }
2014 Some(PendingLayout { segments }.render())
2015}
2016
2017pub fn extract_pending_ids_from_text(text: &str) -> HashSet<String> {
2018 let mut ids = HashSet::new();
2019 let mut rest = text;
2020 while let Some(start) = rest.find("[#") {
2021 let after = &rest[start + 2..];
2022 let Some(end) = after.find(']') else {
2023 break;
2024 };
2025 let id = &after[..end];
2026 if is_valid_pending_id(id) {
2027 ids.insert(id.to_ascii_lowercase());
2028 }
2029 rest = &after[end + 1..];
2030 }
2031 ids
2032}
2033
2034pub fn extract_done_item_own_ids(text: &str) -> HashSet<String> {
2045 let mut ids = HashSet::new();
2046 for line in text.lines() {
2047 let trimmed = line.trim_start();
2048 if !(trimmed.starts_with("- ") || trimmed.starts_with("* ")) {
2051 continue;
2052 }
2053 if let Some(start) = trimmed.find("[#") {
2056 let after = &trimmed[start + 2..];
2057 if let Some(end) = after.find(']') {
2058 let id = &after[..end];
2059 if is_valid_pending_id(id) {
2060 ids.insert(id.to_ascii_lowercase());
2061 }
2062 }
2063 }
2064 }
2065 ids
2066}
2067
2068#[allow(dead_code)]
2076pub fn generate_hash(text: &str, doc_id: &str, counter: u64) -> String {
2077 generate_hash_n(text, doc_id, counter, 4)
2078}
2079
2080pub fn generate_hash_n(text: &str, doc_id: &str, counter: u64, width: usize) -> String {
2086 use sha2::{Digest, Sha256};
2087 let mut hasher = Sha256::new();
2088 hasher.update(text.as_bytes());
2089 hasher.update(b":");
2090 hasher.update(doc_id.as_bytes());
2091 hasher.update(b":");
2092 hasher.update(counter.to_le_bytes());
2093 let digest = hasher.finalize();
2094
2095 const ALPHABET: &[u8] = b"0123456789abcdefghjkmnpqrstvwxyz"; let width = width.clamp(4, 8);
2100 let mut out = String::with_capacity(width);
2101
2102 let b0 = digest[0] as u32;
2105 let b1 = digest[1] as u32;
2106 let b2 = digest[2] as u32;
2107 let v: u32 = (b0 << 16) | (b1 << 8) | b2;
2108 out.push(ALPHABET[((v >> 15) & 0x1f) as usize] as char);
2109 out.push(ALPHABET[((v >> 10) & 0x1f) as usize] as char);
2110 out.push(ALPHABET[((v >> 5) & 0x1f) as usize] as char);
2111 out.push(ALPHABET[(v & 0x1f) as usize] as char);
2112
2113 if width > 4 {
2118 let e0 = digest[3] as u32;
2119 let e1 = digest[4] as u32;
2120 let e2 = digest[5] as u32;
2121 let extra: u32 = (e0 << 16) | (e1 << 8) | e2;
2122 for i in 0..(width - 4) {
2123 let shift = 15 - (i as u32) * 5;
2124 out.push(ALPHABET[((extra >> shift) & 0x1f) as usize] as char);
2125 }
2126 }
2127 out
2128}
2129
2130fn assign_unique_hash(text: &str, doc_id: &str, taken: &HashSet<String>) -> String {
2136 const RETRIES_PER_WIDTH: u64 = 4;
2140 let mut counter: u64 = 0;
2141 loop {
2142 let width = std::cmp::min(4 + (counter / RETRIES_PER_WIDTH) as usize, 8);
2146 let id = generate_hash_n(text, doc_id, counter, width);
2147 if !taken.contains(&id) {
2148 return id;
2149 }
2150 counter = counter.saturating_add(1);
2151 }
2152}
2153
2154fn assign_unique_child_hash(
2158 parent_id: &str,
2159 text: &str,
2160 doc_id: &str,
2161 taken: &HashSet<String>,
2162) -> String {
2163 const RETRIES_PER_WIDTH: u64 = 4;
2164 let seed = format!("{parent_id}:{text}");
2165 let mut counter: u64 = 0;
2166 loop {
2167 let width = std::cmp::min(4 + (counter / RETRIES_PER_WIDTH) as usize, 8);
2168 let suffix = generate_hash_n(&seed, doc_id, counter, width);
2169 let candidate = format!("{parent_id}-{suffix}");
2170 if !taken.contains(&candidate) {
2171 return candidate;
2172 }
2173 counter = counter.saturating_add(1);
2174 }
2175}
2176
2177fn split_indent(line: &str) -> (&str, &str) {
2178 let idx = line
2179 .char_indices()
2180 .find_map(|(idx, ch)| (!ch.is_whitespace()).then_some(idx))
2181 .unwrap_or(line.len());
2182 line.split_at(idx)
2183}
2184
2185fn normalize_nested_subtasks(
2186 continuation: &str,
2187 parent_id: &str,
2188 doc_id: &str,
2189 taken: &mut HashSet<String>,
2190) -> (String, bool) {
2191 if continuation.is_empty() {
2192 return (String::new(), false);
2193 }
2194
2195 let mut changed = false;
2196 let mut out = String::with_capacity(continuation.len());
2197 for raw_line in continuation.split_inclusive('\n') {
2198 let has_newline = raw_line.ends_with('\n');
2199 let line = raw_line.strip_suffix('\n').unwrap_or(raw_line);
2200 if !is_indented_continuation_line(line) {
2201 out.push_str(raw_line);
2202 continue;
2203 }
2204
2205 let (indent, trimmed) = split_indent(line);
2206 let Some(mut child) = parse_item_line(trimmed) else {
2207 out.push_str(raw_line);
2208 continue;
2209 };
2210
2211 let duplicate_existing_id = !child.id.is_empty() && taken.contains(&child.id);
2212 if child.id.is_empty() || duplicate_existing_id {
2213 child.id = assign_unique_child_hash(parent_id, &child.text, doc_id, taken);
2214 taken.insert(child.id.clone());
2215 changed = true;
2216 } else {
2217 taken.insert(child.id.clone());
2218 }
2219
2220 let rendered = child.render();
2221 if rendered != trimmed {
2222 changed = true;
2223 }
2224 out.push_str(indent);
2225 out.push_str(&rendered);
2226 if has_newline {
2227 out.push('\n');
2228 }
2229 }
2230 (out, changed)
2231}
2232
2233pub fn backfill(body: &str, doc_id: &str, existing_ids: &HashSet<String>) -> (String, bool) {
2239 let layout = PendingLayout::parse(body);
2240 let items = layout.items();
2241 let mut taken: HashSet<String> = existing_ids.clone();
2242 for item in &items {
2243 if !item.id.is_empty() {
2244 taken.insert(item.id.clone());
2245 }
2246 }
2247
2248 let mut changed = false;
2249 let rewritten = layout.replace_items(|item| {
2250 if item.text.trim().is_empty() && item.continuation.trim().is_empty() {
2257 changed = true;
2258 return None;
2259 }
2260 let mut next = item.clone();
2261 if next.id.is_empty() {
2262 let id = assign_unique_hash(&next.text, doc_id, &taken);
2263 taken.insert(id.clone());
2264 next.id = id;
2265 changed = true;
2266 }
2267 let (continuation, nested_changed) =
2268 normalize_nested_subtasks(&next.continuation, &next.id, doc_id, &mut taken);
2269 if nested_changed {
2270 next.continuation = continuation;
2271 changed = true;
2272 }
2273 Some(next)
2274 });
2275
2276 let new_body = rewritten.render();
2277
2278 if new_body != body {
2281 changed = true;
2282 }
2283 (new_body, changed)
2284}
2285
2286#[allow(dead_code)]
2289pub fn reap(body: &str) -> Result<(String, Vec<String>)> {
2290 let (new_body, removed) = reap_with_items(body)?;
2291 let ids = removed.iter().map(|i| i.id.clone()).collect();
2292 Ok((new_body, ids))
2293}
2294
2295pub fn reap_with_items(body: &str) -> Result<(String, Vec<PendingItem>)> {
2298 let layout = PendingLayout::parse(body);
2299 let items = layout.items();
2300 let missing_done: Vec<&PendingItem> = items
2301 .iter()
2302 .filter(|item| item.is_done() && item.id.is_empty())
2303 .collect();
2304 if !missing_done.is_empty() {
2305 let refs = missing_done
2306 .iter()
2307 .map(|item| format!("\"{}\"", item.text))
2308 .collect::<Vec<_>>()
2309 .join(", ");
2310 bail!(
2311 "pending reap requires ids for completed items; run backfill first (missing ids on done item(s): {})",
2312 refs
2313 );
2314 }
2315 let mut removed = Vec::new();
2316 let mut segments = Vec::with_capacity(layout.segments.len());
2317 let mut index = 0usize;
2318 while index < layout.segments.len() {
2319 match &layout.segments[index] {
2320 PendingSegment::Text(raw) => {
2321 segments.push(PendingSegment::Text(raw.clone()));
2322 index += 1;
2323 }
2324 PendingSegment::Item { item, has_newline } => {
2325 if !item.is_done() {
2326 segments.push(PendingSegment::Item {
2327 item: item.clone(),
2328 has_newline: *has_newline,
2329 });
2330 index += 1;
2331 continue;
2332 }
2333
2334 let mut removed_item = item.clone();
2335 let mut next_index = index + 1;
2336 while let Some(PendingSegment::Text(raw)) = layout.segments.get(next_index) {
2337 if let Some((reap_text, keep_text)) = split_reapable_trailing_text_segment(raw)
2338 {
2339 removed_item.continuation.push_str(&reap_text);
2340 next_index += 1;
2341 if !keep_text.is_empty() {
2342 segments.push(PendingSegment::Text(keep_text));
2343 break;
2344 }
2345 } else {
2346 segments.push(PendingSegment::Text(raw.clone()));
2347 next_index += 1;
2348 break;
2349 }
2350 }
2351 if !removed_item.id.is_empty() {
2352 removed.push(removed_item);
2353 }
2354 index = next_index;
2355 }
2356 }
2357 }
2358 let new_layout = PendingLayout { segments };
2359 if removed.is_empty() {
2360 return Ok((body.to_string(), removed));
2361 }
2362 let new_body = new_layout.render();
2363 Ok((new_body, removed))
2364}
2365
2366pub fn extract_items_by_id(body: &str, ids: &[String]) -> Result<(String, String, Vec<String>)> {
2369 let requested: Vec<String> = ids.iter().map(|id| id.trim().to_lowercase()).collect();
2370 let mut matched_ids = Vec::new();
2371 let mut moved_items = Vec::new();
2372 let remaining = PendingLayout::parse(body).replace_items(|item| {
2373 if requested.contains(&item.id) {
2374 matched_ids.push(item.id.clone());
2375 moved_items.push(item.clone());
2376 None
2377 } else {
2378 Some(item.clone())
2379 }
2380 });
2381 let moved_body = render_items("", &moved_items, "");
2382 Ok((remaining.render(), moved_body, matched_ids))
2383}
2384
2385pub fn detect_reorder(snapshot_body: &str, current_body: &str) -> Option<Vec<String>> {
2388 let (_, snap_items, _) = parse_items(snapshot_body);
2389 let (_, cur_items, _) = parse_items(current_body);
2390
2391 let snap_ids: Vec<String> = snap_items
2392 .iter()
2393 .filter(|i| !i.id.is_empty())
2394 .map(|i| i.id.clone())
2395 .collect();
2396 let cur_ids: Vec<String> = cur_items
2397 .iter()
2398 .filter(|i| !i.id.is_empty())
2399 .map(|i| i.id.clone())
2400 .collect();
2401
2402 if snap_ids.len() != cur_ids.len() {
2403 return None;
2404 }
2405 let snap_set: HashSet<&String> = snap_ids.iter().collect();
2406 let cur_set: HashSet<&String> = cur_ids.iter().collect();
2407 if snap_set != cur_set {
2408 return None;
2409 }
2410 if snap_ids == cur_ids {
2411 return None;
2412 }
2413 Some(cur_ids)
2414}
2415
2416#[derive(Debug, Clone, Copy, PartialEq, Eq)]
2424pub enum AddPosition<'a> {
2425 First,
2427 Last,
2429 After(&'a str),
2431 Before(&'a str),
2433}
2434
2435pub fn op_add(body: &str, text: &str, doc_id: &str, gated: bool) -> Result<(String, String)> {
2436 op_add_at(body, text, doc_id, gated, AddPosition::First)
2437}
2438
2439pub fn op_add_with_outcome(
2440 body: &str,
2441 text: &str,
2442 doc_id: &str,
2443 gated: bool,
2444) -> Result<PendingAddOutcome> {
2445 op_add_at_with_outcome(body, text, doc_id, gated, AddPosition::First)
2446}
2447
2448pub fn op_add_at(
2452 body: &str,
2453 text: &str,
2454 doc_id: &str,
2455 gated: bool,
2456 position: AddPosition<'_>,
2457) -> Result<(String, String)> {
2458 let outcome = op_add_at_with_outcome(body, text, doc_id, gated, position)?;
2459 Ok((outcome.body, outcome.id))
2460}
2461
2462pub fn op_add_at_with_outcome(
2465 body: &str,
2466 text: &str,
2467 doc_id: &str,
2468 gated: bool,
2469 position: AddPosition<'_>,
2470) -> Result<PendingAddOutcome> {
2471 let (custom_id, text) = parse_custom_id_prefix(text)?;
2472 let mut text = text.trim().to_string();
2473 if text.is_empty() {
2474 bail!("pending add: text must be non-empty");
2475 }
2476 if text.starts_with("[ ]")
2477 || text.starts_with("[/]")
2478 || text.starts_with("[x]")
2479 || text.starts_with("[X]")
2480 {
2481 bail!(
2482 "pending add: text must not start with a state marker ([ ], [/], [x]); use --pending-add-gated for gated items"
2483 );
2484 }
2485
2486 let mut inline_custom_id = None;
2487 if custom_id.is_none() {
2488 if let Some((tag_id, cleaned)) = leading_bare_tag_id(&text) {
2489 inline_custom_id = Some(tag_id);
2490 text = cleaned;
2491 } else if let Some((tag_id, cleaned)) = extract_inline_tag_id(&text) {
2492 inline_custom_id = Some(tag_id);
2493 text = cleaned;
2494 }
2495 }
2496
2497 let mut layout = PendingLayout::parse(body);
2498 let items = layout.items();
2499
2500 if let Some(key) = symptom_dedupe_key_from_text(&text)?
2501 && let Some(existing) = items
2502 .iter()
2503 .find(|item| {
2504 item.state != PendingState::Done
2505 && symptom_dedupe_key_from_text(&item.text)
2506 .ok()
2507 .flatten()
2508 .as_ref()
2509 == Some(&key)
2510 })
2511 .cloned()
2512 {
2513 let evidence = symptom_evidence_line(&text);
2514 let rewritten = layout.replace_items(|item| {
2515 if item.id == existing.id {
2516 let mut next = item.clone();
2517 next.continuation = append_unique_continuation_line(&next.continuation, &evidence);
2518 Some(next)
2519 } else {
2520 Some(item.clone())
2521 }
2522 });
2523 return Ok(PendingAddOutcome {
2524 body: rewritten.render(),
2525 id: existing.id,
2526 inserted: false,
2527 deduped_key: Some(key),
2528 });
2529 }
2530
2531 if items.iter().any(|i| i.text == text) {
2533 bail!("pending add: duplicate item text already exists: {}", text);
2534 }
2535
2536 let mut taken: HashSet<String> = items
2537 .iter()
2538 .filter(|i| !i.id.is_empty())
2539 .map(|i| i.id.clone())
2540 .collect();
2541
2542 let id = if let Some(custom_id) = custom_id {
2543 if taken.contains(&custom_id) {
2544 bail!("pending add: custom id already exists: {}", custom_id);
2545 }
2546 custom_id
2547 } else if let Some(inline_id) = inline_custom_id {
2548 if taken.contains(&inline_id) {
2549 bail!("pending add: inline tag id already exists: {}", inline_id);
2550 }
2551 inline_id
2552 } else {
2553 assign_unique_hash(&text, doc_id, &taken)
2554 };
2555 taken.insert(id.clone());
2556
2557 let new_item = PendingItem {
2558 marker: PendingListMarker::Bullet,
2559 id: id.clone(),
2560 state: if gated {
2561 PendingState::Gated
2562 } else {
2563 PendingState::Open
2564 },
2565 gate_type: None,
2566 in_progress: false,
2567 text,
2568 continuation: String::new(),
2569 };
2570 match position {
2571 AddPosition::First => layout.insert_first_item(new_item),
2572 AddPosition::Last => {
2573 let index = layout.last_item_index_plus_one();
2574 layout.insert_item_at(index, new_item);
2575 }
2576 AddPosition::After(anchor) => {
2577 let anchor = normalize_pending_id(anchor);
2578 let Some(idx) = layout.item_segment_index(&anchor) else {
2579 bail!("pending add: anchor id not found for --pending-add-after: {anchor}");
2580 };
2581 layout.insert_item_at(idx + 1, new_item);
2582 }
2583 AddPosition::Before(anchor) => {
2584 let anchor = normalize_pending_id(anchor);
2585 let Some(idx) = layout.item_segment_index(&anchor) else {
2586 bail!("pending add: anchor id not found for --pending-add-before: {anchor}");
2587 };
2588 layout.insert_item_at(idx, new_item);
2589 }
2590 }
2591 Ok(PendingAddOutcome {
2592 body: layout.render(),
2593 id,
2594 inserted: true,
2595 deduped_key: None,
2596 })
2597}
2598
2599pub fn op_done(body: &str, id: &str) -> Result<String> {
2602 let id = normalize_pending_id(id);
2603 let mut found = false;
2604 let layout = PendingLayout::parse(body).replace_items(|item| {
2605 if item.id == id {
2606 found = true;
2607 let mut next = item.clone();
2608 next.state = PendingState::Done;
2609 next.gate_type = None;
2610 Some(next)
2611 } else {
2612 Some(item.clone())
2613 }
2614 });
2615 if !found {
2616 return Err(anyhow!("pending done: no item with id [#{}]", id));
2617 }
2618 Ok(layout.render())
2619}
2620
2621pub fn op_gate(body: &str, id: &str) -> Result<String> {
2627 let id = normalize_pending_id(id);
2628 let layout = PendingLayout::parse(body);
2629 let current = layout
2630 .items()
2631 .into_iter()
2632 .find(|item| item.id == id)
2633 .ok_or_else(|| anyhow!("pending gate: no item with id [#{}]", id))?;
2634 let transition = validate_transition(current.state, PendingOp::Gate)?;
2635 let mut found = false;
2636 let mut changed = false;
2637 let rewritten = layout.replace_items(|item| {
2638 if item.id != id {
2639 return Some(item.clone());
2640 }
2641 found = true;
2642 match transition {
2643 TransitionResult::Transition(next) => {
2644 changed = true;
2645 let mut next_item = item.clone();
2646 next_item.state = next;
2647 Some(next_item)
2648 }
2649 TransitionResult::NoOp => Some(item.clone()),
2650 }
2651 });
2652 debug_assert!(found, "validated item must be present during replacement");
2653 if !changed {
2654 Ok(body.to_string())
2655 } else {
2656 Ok(rewritten.render())
2657 }
2658}
2659
2660pub fn op_ungate(body: &str, id: &str) -> Result<String> {
2666 let id = normalize_pending_id(id);
2667 let layout = PendingLayout::parse(body);
2668 let current = layout
2669 .items()
2670 .into_iter()
2671 .find(|item| item.id == id)
2672 .ok_or_else(|| anyhow!("pending ungate: no item with id [#{}]", id))?;
2673 let transition = validate_transition(current.state, PendingOp::Ungate)?;
2674 let mut found = false;
2675 let mut changed = false;
2676 let rewritten = layout.replace_items(|item| {
2677 if item.id != id {
2678 return Some(item.clone());
2679 }
2680 found = true;
2681 match transition {
2682 TransitionResult::Transition(next) => {
2683 changed = true;
2684 let mut next_item = item.clone();
2685 next_item.state = next;
2686 next_item.gate_type = None;
2687 Some(next_item)
2688 }
2689 TransitionResult::NoOp => Some(item.clone()),
2690 }
2691 });
2692 debug_assert!(found, "validated item must be present during replacement");
2693 if !changed {
2694 Ok(body.to_string())
2695 } else {
2696 Ok(rewritten.render())
2697 }
2698}
2699
2700pub fn op_edit(body: &str, id: &str, new_text: &str) -> Result<String> {
2702 let new_text = new_text.trim();
2703 if new_text.is_empty() {
2704 bail!("pending edit: text must be non-empty");
2705 }
2706 let (parsed_text, parsed_continuation) = parse_pending_edit_payload(new_text)?;
2707 let id = normalize_pending_id(id);
2708 let mut found = false;
2709 let layout = PendingLayout::parse(body).replace_items(|item| {
2710 if item.id == id {
2711 found = true;
2712 let mut next = item.clone();
2713 next.text = parsed_text.clone();
2714 next.continuation = parsed_continuation.clone();
2715 Some(next)
2716 } else {
2717 Some(item.clone())
2718 }
2719 });
2720 if !found {
2721 return Err(anyhow!("pending edit: no item with id [#{}]", id));
2722 }
2723 Ok(layout.render())
2724}
2725
2726pub fn op_clear(body: &str) -> Result<String> {
2728 let cleared = PendingLayout::parse(body).replace_items(|_| None);
2729 Ok(cleared.render())
2730}
2731
2732pub fn op_reorder(body: &str, ids: &[String]) -> Result<String> {
2735 let layout = PendingLayout::parse(body);
2736 let items = layout.items();
2737 let requested: Vec<String> = ids.iter().map(|s| normalize_pending_id(s)).collect();
2738 for id in &requested {
2739 if !items.iter().any(|i| i.id == *id) {
2740 bail!("pending reorder: no item with id [#{}]", id);
2741 }
2742 }
2743 let mut remaining: Vec<PendingItem> = items.clone();
2744 let mut ordered: Vec<PendingItem> = Vec::new();
2745 for id in &requested {
2746 if let Some(pos) = remaining.iter().position(|i| i.id == *id) {
2747 ordered.push(remaining.remove(pos));
2748 }
2749 }
2750 ordered.extend(remaining);
2751 let mut ordered_iter = ordered.into_iter();
2752 let reordered = layout.replace_items(|_| ordered_iter.next());
2753 Ok(reordered.render())
2754}
2755
2756pub fn op_resolve_gate(body: &str, gate_type: &str) -> (String, Vec<String>) {
2761 let gt = gate_type.trim().to_lowercase();
2762 let mut resolved = Vec::new();
2763 let layout = PendingLayout::parse(body).replace_items(|item| {
2764 if item.state == PendingState::Gated && item.gate_type.as_deref() == Some(gt.as_str()) {
2765 let mut next = item.clone();
2766 next.state = PendingState::Done;
2767 next.gate_type = None;
2768 resolved.push(next.id.clone());
2769 Some(next)
2770 } else {
2771 Some(item.clone())
2772 }
2773 });
2774 (layout.render(), resolved)
2775}
2776
2777pub fn op_set_gate_type(body: &str, id: &str, gate_type: &str) -> Result<String> {
2780 let id = normalize_pending_id(id);
2781 let gt = gate_type.trim().to_lowercase();
2782 if gt.is_empty()
2783 || !gt
2784 .chars()
2785 .all(|c| c.is_ascii_alphanumeric() || c == '-' || c == '_')
2786 {
2787 bail!("invalid gate type: must be alphanumeric/dash/underscore");
2788 }
2789 let layout = PendingLayout::parse(body);
2790 let current = layout
2791 .items()
2792 .into_iter()
2793 .find(|item| item.id == id)
2794 .ok_or_else(|| anyhow!("pending set-gate-type: no item with id [#{}]", id))?;
2795 if current.state != PendingState::Gated {
2796 bail!(
2797 "pending set-gate-type: item [#{}] must be gated ([/]) to set a typed gate, current state: [{}]",
2798 id,
2799 current.state.box_char()
2800 );
2801 }
2802 let mut found = false;
2803 let rewritten = layout.replace_items(|item| {
2804 if item.id != id {
2805 return Some(item.clone());
2806 }
2807 found = true;
2808 let mut next = item.clone();
2809 next.gate_type = Some(gt.clone());
2810 Some(next)
2811 });
2812 debug_assert!(found, "validated item must be present during replacement");
2813 Ok(rewritten.render())
2814}
2815
2816pub fn op_set_gate_verify(body: &str, id: &str, spec: &str, set_at: u64) -> Result<String> {
2824 let id = normalize_pending_id(id);
2825 let mut predicate = crate::gate_verify::parse_predicate_spec(spec);
2826 if !predicate.is_actionable() {
2827 bail!(
2828 "pending set-verify: spec must include verify=... and/or disproof=..., got: {}",
2829 spec
2830 );
2831 }
2832 predicate.set_at = Some(set_at);
2833
2834 let layout = PendingLayout::parse(body);
2835 let current = layout
2836 .items()
2837 .into_iter()
2838 .find(|item| item.id == id)
2839 .ok_or_else(|| anyhow!("pending set-verify: no item with id [#{}]", id))?;
2840 if current.state != PendingState::Gated {
2841 bail!(
2842 "pending set-verify: item [#{}] must be gated ([/]) to set a verify predicate, current state: [{}]",
2843 id,
2844 current.state.box_char()
2845 );
2846 }
2847 let mut found = false;
2848 let rewritten = layout.replace_items(|item| {
2849 if item.id != id {
2850 return Some(item.clone());
2851 }
2852 found = true;
2853 let mut next = item.clone();
2854 next.text = crate::gate_verify::upsert_annotation(&next.text, &predicate);
2855 Some(next)
2856 });
2857 debug_assert!(found, "validated item must be present during replacement");
2858 Ok(rewritten.render())
2859}
2860
2861#[cfg(test)]
2862mod tests {
2863 use super::*;
2864
2865 const DOC_ID: &str = "test-doc";
2866
2867 fn ids() -> HashSet<String> {
2868 HashSet::new()
2869 }
2870
2871 #[test]
2872 fn extract_done_item_own_ids_ignores_prose_citations() {
2873 let archive = concat!(
2877 "# Agent Doc Completed Work\n\n",
2878 "- 2026-06-19 [#733r] reconcile queue heads.\n",
2879 "- 2026-06-19 [#ftstrike] strike free-text heads regardless of position, ",
2880 "e.g. a head behind do [#fullboundary] is now struck. See [#semmerge].\n",
2881 );
2882 let got = extract_done_item_own_ids(archive);
2883 assert!(got.contains("733r"));
2884 assert!(got.contains("ftstrike"));
2885 assert!(
2886 !got.contains("fullboundary"),
2887 "a [#id] cited in prose must NOT be treated as a done item id: {got:?}"
2888 );
2889 assert!(
2890 !got.contains("semmerge"),
2891 "trailing prose citation excluded: {got:?}"
2892 );
2893 assert!(extract_pending_ids_from_text(archive).contains("fullboundary"));
2895 }
2896
2897 #[test]
2898 fn extract_done_item_own_ids_handles_checkbox_and_skips_prose_lines() {
2899 let body = concat!(
2900 "- [x] [#foo] done thing depends on [#bar].\n",
2901 " continuation prose mentioning [#baz]\n",
2902 "- [/] [#gated] gated item\n",
2903 "plain prose line with [#qux]\n",
2904 );
2905 let got = extract_done_item_own_ids(body);
2906 assert!(got.contains("foo"));
2907 assert!(got.contains("gated"));
2908 assert!(
2909 !got.contains("bar"),
2910 "same-line prose citation excluded: {got:?}"
2911 );
2912 assert!(
2913 !got.contains("baz"),
2914 "continuation-line citation excluded: {got:?}"
2915 );
2916 assert!(
2917 !got.contains("qux"),
2918 "non-list prose line excluded: {got:?}"
2919 );
2920 }
2921
2922 #[test]
2923 fn active_item_ids_returns_open_items_in_order() {
2924 let body = concat!(
2925 "- [ ] [#first] one\n",
2926 "- [/] [#gated] blocked\n",
2927 "- [x] [#done] finished\n",
2928 "- [ ] [#second] two\n",
2929 );
2930 assert_eq!(active_item_ids(body), vec!["first", "second"]);
2931 }
2932
2933 #[test]
2934 fn in_progress_marker_after_checkbox_preserves_pending_id() {
2935 let body = "- [ ] ๐ง [#first] one\n- [/] ๐ง [#gated] blocked\n";
2936 let (_, items, _) = parse_items(body);
2937
2938 assert_eq!(items[0].id, "first");
2939 assert!(items[0].in_progress);
2940 assert_eq!(items[1].id, "gated");
2941 assert!(items[1].in_progress);
2942 assert_eq!(active_item_ids(body), vec!["first"]);
2943 assert_eq!(render_items("", &items, ""), body);
2944 }
2945
2946 #[test]
2947 fn set_in_progress_item_ids_moves_marker_and_clears_stale_items() {
2948 let body = "- [ ] ๐ง [#old] old\n- [ ] [#new] new\n";
2949 let ids = ["new".to_string()].into_iter().collect();
2950
2951 let (updated, changed) = set_in_progress_item_ids(body, &ids);
2952
2953 assert!(changed);
2954 assert_eq!(updated, "- [ ] [#old] old\n- [ ] ๐ง [#new] new\n");
2955 }
2956
2957 #[test]
2958 fn set_in_progress_item_ids_never_marks_done_items() {
2959 let body = concat!(
2960 "- [x] ๐ง [#done] finished\n",
2961 "- [x] [#target] already finished\n",
2962 "- [/] [#review] active review\n",
2963 );
2964 let ids = [
2965 "done".to_string(),
2966 "target".to_string(),
2967 "review".to_string(),
2968 ]
2969 .into_iter()
2970 .collect();
2971
2972 let (updated, changed) = set_in_progress_item_ids(body, &ids);
2973
2974 assert!(changed);
2975 assert_eq!(
2976 updated,
2977 concat!(
2978 "- [x] [#done] finished\n",
2979 "- [x] [#target] already finished\n",
2980 "- [/] ๐ง [#review] active review\n",
2981 )
2982 );
2983 }
2984
2985 #[test]
2986 fn active_item_ids_empty_for_empty_body() {
2987 assert!(active_item_ids("").is_empty());
2988 }
2989
2990 #[test]
2991 fn active_item_ids_holds_future_not_before_items() {
2992 let body = concat!(
2997 "- [ ] [#now] ready now\n",
2998 "- [ ] [#past] not-before=2020-01-01 already due\n",
2999 "- [ ] [#future] not-before=2999-12-31 scheduled later\n",
3000 );
3001 assert_eq!(active_item_ids(body), vec!["now", "past"]);
3002 assert_eq!(
3003 active_item_priorities(body)
3004 .into_iter()
3005 .map(|(id, _)| id)
3006 .collect::<Vec<_>>(),
3007 vec!["now", "past"]
3008 );
3009 }
3010
3011 #[test]
3012 fn active_enqueue_item_ids_holds_future_not_before_items() {
3013 let body = concat!(
3015 "- [ ] [#mark] /enqueue not-before=2999-12-31 scheduled later\n",
3016 "- [ ] [#ready] /enqueue not-before=2020-01-01 due\n",
3017 );
3018 assert_eq!(active_enqueue_item_ids(body), vec!["ready"]);
3019 }
3020
3021 #[test]
3022 fn item_not_before_day_parses_and_validates() {
3023 assert_eq!(item_not_before_day("not-before=1970-01-01 x"), Some(0));
3024 assert_eq!(item_not_before_day("text not-before=1970-01-02 x"), Some(1));
3025 assert_eq!(item_not_before_day("xnot-before=2020-01-01"), None);
3027 assert_eq!(item_not_before_day("not-before=2020-13-01"), None);
3029 assert_eq!(item_not_before_day("not-before=garbage"), None);
3030 assert_eq!(item_not_before_day("no token here"), None);
3031 }
3032
3033 #[test]
3034 fn item_precondition_unmet_compares_against_today() {
3035 let today = days_from_civil(2026, 6, 18);
3036 assert!(item_precondition_unmet("not-before=2026-06-19 x", today));
3037 assert!(!item_precondition_unmet("not-before=2026-06-18 x", today)); assert!(!item_precondition_unmet("not-before=2026-06-17 x", today));
3039 assert!(!item_precondition_unmet("no precondition", today));
3040 }
3041
3042 #[test]
3043 fn active_enqueue_item_ids_returns_marked_open_items() {
3044 let body = concat!(
3045 "- [ ] [#inbox] :inbox_tray: one\n",
3046 "- [/] [#gated] :inbox_tray: blocked\n",
3047 "- [x] [#done] **enqueue** finished\n",
3048 "- [ ] [#bold] **enqueue** two\n",
3049 "- [ ] [#slash] /enqueue three\n",
3050 "- [ ] [#plain] enqueue should not be a marker\n",
3051 "- [ ] untracked :inbox_tray: no id\n",
3052 );
3053 assert_eq!(
3054 active_enqueue_item_ids(body),
3055 vec!["inbox", "bold", "slash"]
3056 );
3057 }
3058
3059 #[test]
3060 fn execution_context_tags_parse_booleans() {
3061 let clean = item_execution_context("[clean-session] needs a quiet session");
3063 assert!(clean.clean_session_required);
3064 assert!(!clean.operator_verify_required);
3065 assert!(clean.is_deferred());
3066
3067 let oper = item_execution_context("ship it [operator-verify] live drive");
3068 assert!(!oper.clean_session_required);
3069 assert!(oper.operator_verify_required);
3070 assert!(oper.is_deferred());
3071
3072 let plain = item_execution_context("just do the thing");
3074 assert!(!plain.clean_session_required);
3075 assert!(!plain.operator_verify_required);
3076 assert!(!plain.is_deferred());
3077
3078 let both =
3080 item_execution_context("[recommended] [TOP] [clean-session] [operator-verify] go");
3081 assert!(both.clean_session_required);
3082 assert!(both.operator_verify_required);
3083
3084 let prose = item_execution_context("run from a clean session please");
3086 assert!(!prose.clean_session_required);
3087 }
3088
3089 #[test]
3090 fn focused_cycle_tag_is_loop_undrainable_but_clean_session_is_not() {
3091 let focused = item_execution_context("[focused-cycle] supervisor-core merge change");
3095 assert!(focused.focused_cycle_required);
3096 assert!(focused.is_deferred());
3097 assert!(
3098 focused.loop_undrainable(),
3099 "[focused-cycle] must be undrainable by the queue loop"
3100 );
3101
3102 let clean = item_execution_context("[clean-session] needs a quiet session");
3105 assert!(clean.is_deferred());
3106 assert!(
3107 !clean.loop_undrainable(),
3108 "[clean-session] drains in place โ never loop_undrainable"
3109 );
3110
3111 let oper = item_execution_context("[operator-verify] live drive");
3113 assert!(oper.loop_undrainable());
3114
3115 assert!(
3119 !focused.supervisor_undrainable(),
3120 "[focused-cycle] is drained by the supervisor clear-and-continue path"
3121 );
3122 assert!(
3123 oper.supervisor_undrainable(),
3124 "[operator-verify] needs a human โ undrainable by any agent scope"
3125 );
3126 assert!(
3127 !clean.supervisor_undrainable(),
3128 "[clean-session] drains everywhere"
3129 );
3130
3131 let plain = item_execution_context("just implement #6b5h");
3133 assert!(!plain.loop_undrainable());
3134 assert!(!plain.supervisor_undrainable());
3135 assert!(!plain.is_deferred());
3136
3137 assert_eq!(
3139 strip_execution_context_tags("[recommended] [focused-cycle] rework the merge seam"),
3140 "[recommended] rework the merge seam"
3141 );
3142 }
3143
3144 #[test]
3145 fn execution_context_tags_stripped_from_display_text() {
3146 assert_eq!(
3148 strip_execution_context_tags("[clean-session] fix the converge path"),
3149 "fix the converge path"
3150 );
3151 assert_eq!(
3152 strip_execution_context_tags("[recommended] [operator-verify] live verify the flow"),
3153 "[recommended] live verify the flow"
3154 );
3155 assert_eq!(
3156 strip_execution_context_tags("[clean-session] [operator-verify] both"),
3157 "both"
3158 );
3159 assert_eq!(
3161 strip_execution_context_tags("plain item text"),
3162 "plain item text"
3163 );
3164 }
3165
3166 #[test]
3167 fn active_item_execution_contexts_skips_closed_and_idless() {
3168 let body = concat!(
3170 "- [ ] [#a] [clean-session] needs quiet\n",
3171 "- [x] [#b] [operator-verify] already done\n",
3172 "- [ ] [#c] plain drainable\n",
3173 );
3174 let ctxs = active_item_execution_contexts(body);
3175 assert_eq!(ctxs.len(), 2);
3176 assert_eq!(ctxs[0].0, "a");
3177 assert!(ctxs[0].1.clean_session_required);
3178 assert_eq!(ctxs[1].0, "c");
3179 assert!(!ctxs[1].1.is_deferred());
3180 }
3181
3182 #[test]
3183 fn item_priority_rank_parses_token() {
3184 assert_eq!(item_priority_rank("priority=1 do the thing"), 1);
3185 assert_eq!(item_priority_rank("text priority=5 more"), 5);
3186 assert_eq!(item_priority_rank("no token here"), PRIORITY_RANK_UNSET);
3187 assert_eq!(
3188 item_priority_rank("priority=0 out of range"),
3189 PRIORITY_RANK_UNSET
3190 );
3191 assert_eq!(
3192 item_priority_rank("priority=12 out of range"),
3193 PRIORITY_RANK_UNSET
3194 );
3195 }
3196
3197 #[test]
3198 fn sort_by_priority_orders_ascending_stable() {
3199 let body = concat!(
3200 "- [ ] [#a] priority=3 third\n",
3201 "- [ ] [#b] no priority\n",
3202 "- [ ] [#c] priority=1 first\n",
3203 "- [ ] [#d] priority=1 also first\n",
3204 );
3205 let sorted = sort_by_priority(body).expect("order should change");
3206 let ids: Vec<&str> = sorted
3208 .lines()
3209 .filter_map(|l| l.split("[#").nth(1))
3210 .filter_map(|s| s.split(']').next())
3211 .collect();
3212 assert_eq!(ids, vec!["c", "d", "a", "b"]);
3213 }
3214
3215 #[test]
3216 fn sort_by_priority_idempotent_when_ordered() {
3217 let body = "- [ ] [#a] priority=1 x\n- [ ] [#b] priority=2 y\n";
3218 assert!(sort_by_priority(body).is_none());
3219 }
3220
3221 #[test]
3222 fn active_item_priorities_pairs_open_ids_with_rank() {
3223 let body = concat!(
3224 "- [ ] [#a] priority=2 one\n",
3225 "- [/] [#g] priority=1 gated\n",
3226 "- [ ] [#b] two\n",
3227 );
3228 assert_eq!(
3229 active_item_priorities(body),
3230 vec![
3231 ("a".to_string(), 2u8),
3232 ("b".to_string(), PRIORITY_RANK_UNSET)
3233 ]
3234 );
3235 }
3236
3237 #[test]
3238 fn op_take_active_items_by_ids_removes_open_and_gated_matches_only() {
3239 let body = concat!(
3240 "intro\n",
3241 "- [ ] [#open] remove open\n",
3242 " child line\n",
3243 "- [/] [#gated] remove gated\n",
3244 "- [x] [#done] keep explicit done\n",
3245 "- [ ] [#keep] keep open\n",
3246 );
3247 let ids: HashSet<String> = ["#open".to_string(), "gated".to_string(), "done".to_string()]
3248 .into_iter()
3249 .collect();
3250
3251 let (new_body, removed) = op_take_active_items_by_ids(body, &ids);
3252
3253 assert_eq!(
3254 removed
3255 .iter()
3256 .map(|item| item.id.as_str())
3257 .collect::<Vec<_>>(),
3258 vec!["open", "gated"]
3259 );
3260 assert!(!new_body.contains("[#open]"));
3261 assert!(!new_body.contains("child line"));
3262 assert!(!new_body.contains("[#gated]"));
3263 assert!(new_body.contains("[#done] keep explicit done"));
3264 assert!(new_body.contains("[#keep] keep open"));
3265 assert!(new_body.starts_with("intro\n"));
3266 }
3267
3268 #[test]
3269 fn parse_empty_body() {
3270 let (p, items, post) = parse_items("");
3271 assert_eq!(p, "");
3272 assert!(items.is_empty());
3273 assert_eq!(post, "");
3274 }
3275
3276 #[test]
3277 fn parse_fully_migrated() {
3278 let body = "- [ ] [#a3f2] first\n- [x] [#b1c4] second\n";
3279 let (_, items, _) = parse_items(body);
3280 assert_eq!(items.len(), 2);
3281 assert_eq!(items[0].marker, PendingListMarker::Bullet);
3282 assert_eq!(items[0].id, "a3f2");
3283 assert_eq!(items[0].state, PendingState::Open);
3284 assert_eq!(items[0].text, "first");
3285 assert_eq!(items[1].id, "b1c4");
3286 assert_eq!(items[1].state, PendingState::Done);
3287 }
3288
3289 #[test]
3290 fn detects_malformed_tracked_checklist_lines() {
3291 let body = concat!(
3292 "_- [ ] [#pcops] damaged prefix\n",
3293 "- [ ] [#keep1] valid item\n",
3294 "plain note mentioning [#note1]\n",
3295 );
3296
3297 let malformed = detect_malformed_item_lines(body);
3298
3299 assert_eq!(malformed.len(), 1);
3300 assert_eq!(malformed[0].id, "pcops");
3301 assert_eq!(malformed[0].line, 1);
3302 assert!(malformed[0].text.contains("damaged prefix"));
3303 }
3304
3305 #[test]
3306 fn parse_hyphenated_id() {
3307 let body = "- [ ] [#tmuxcrash-abcd] child task\n";
3308 let (_, items, _) = parse_items(body);
3309 assert_eq!(items.len(), 1);
3310 assert_eq!(items[0].id, "tmuxcrash-abcd");
3311 assert_eq!(items[0].text, "child task");
3312 }
3313
3314 #[test]
3315 fn parse_ordered_items() {
3316 let body = "1. [ ] [#a3f2] first\n2. [x] [#b1c4] second\n";
3317 let (_, items, _) = parse_items(body);
3318 assert_eq!(items.len(), 2);
3319 assert_eq!(items[0].marker, PendingListMarker::Ordered(1));
3320 assert_eq!(items[0].id, "a3f2");
3321 assert_eq!(items[1].marker, PendingListMarker::Ordered(2));
3322 assert_eq!(items[1].state, PendingState::Done);
3323 }
3324
3325 #[test]
3326 fn strips_redundant_self_id_tag_from_text() {
3327 let body = "- [ ] [#stale-x] [recommended] [#stale-x] Evaluate the retire path.\n";
3329 let (_, items, _) = parse_items(body);
3330 assert_eq!(items.len(), 1);
3331 assert_eq!(items[0].id, "stale-x");
3332 assert_eq!(items[0].text, "[recommended] Evaluate the retire path.");
3333 }
3334
3335 #[test]
3336 fn preserves_cross_reference_ids_in_text() {
3337 let body = "- [ ] [#a] depends on [#b] and [#c] downstream.\n";
3339 let (_, items, _) = parse_items(body);
3340 assert_eq!(items.len(), 1);
3341 assert_eq!(items[0].id, "a");
3342 assert_eq!(items[0].text, "depends on [#b] and [#c] downstream.");
3343 }
3344
3345 #[test]
3346 fn parse_gated_state() {
3347 let body = "- [/] [#eg0w] CommitLock โ gate: v0.32.5\n";
3348 let (_, items, _) = parse_items(body);
3349 assert_eq!(items.len(), 1);
3350 assert_eq!(items[0].state, PendingState::Gated);
3351 assert_eq!(items[0].id, "eg0w");
3352 assert_eq!(items[0].text, "CommitLock โ gate: v0.32.5");
3353 }
3354
3355 #[test]
3356 fn parse_all_three_states() {
3357 let body = "- [ ] [#a3f2] open\n- [/] [#b1c4] gated\n- [x] [#c9e0] done\n";
3358 let (_, items, _) = parse_items(body);
3359 assert_eq!(items[0].state, PendingState::Open);
3360 assert_eq!(items[1].state, PendingState::Gated);
3361 assert_eq!(items[2].state, PendingState::Done);
3362 }
3363
3364 #[test]
3365 fn parse_legacy_tilde_checkbox_preserves_hash_id() {
3366 let body = "- [~] [#q6js] in-progress legacy item\n";
3367 let (_, items, _) = parse_items(body);
3368 assert_eq!(items.len(), 1);
3369 assert_eq!(items[0].state, PendingState::Open);
3370 assert_eq!(items[0].id, "q6js");
3371 assert_eq!(items[0].text, "in-progress legacy item");
3372 }
3373
3374 #[test]
3375 fn parse_checkbox_only_no_id() {
3376 let body = "- [ ] just text\n- [x] done item\n";
3377 let (_, items, _) = parse_items(body);
3378 assert_eq!(items.len(), 2);
3379 assert_eq!(items[0].id, "");
3380 assert_eq!(items[0].text, "just text");
3381 assert_eq!(items[1].state, PendingState::Done);
3382 }
3383
3384 #[test]
3385 fn parse_legacy_no_checkbox() {
3386 let body = "- legacy one\n- legacy two\n";
3387 let (_, items, _) = parse_items(body);
3388 assert_eq!(items.len(), 2);
3389 assert_eq!(items[0].text, "legacy one");
3390 assert_eq!(items[0].state, PendingState::Open);
3391 assert_eq!(items[0].id, "");
3392 }
3393
3394 #[test]
3395 fn parse_mixed() {
3396 let body = "- [ ] [#a3f2] migrated\n- [ ] partial\n- legacy\n";
3397 let (_, items, _) = parse_items(body);
3398 assert_eq!(items.len(), 3);
3399 assert_eq!(items[0].id, "a3f2");
3400 assert_eq!(items[1].id, "");
3401 assert_eq!(items[1].text, "partial");
3402 assert_eq!(items[2].text, "legacy");
3403 }
3404
3405 #[test]
3406 fn parse_nested_lines_attach_to_parent_item() {
3407 let body = concat!(
3408 "- [ ] [#a3f2] parent task\n",
3409 " - dependency one\n",
3410 " - dependency two\n",
3411 "- [ ] [#b1c4] sibling task\n"
3412 );
3413 let (_, items, _) = parse_items(body);
3414 assert_eq!(items.len(), 2);
3415 assert_eq!(items[0].id, "a3f2");
3416 assert_eq!(items[0].text, "parent task");
3417 assert_eq!(
3418 items[0].continuation,
3419 " - dependency one\n - dependency two\n"
3420 );
3421 assert_eq!(items[1].id, "b1c4");
3422 assert!(items[1].continuation.is_empty());
3423 }
3424
3425 #[test]
3426 fn parse_nested_lines_attach_to_ordered_parent_item() {
3427 let body = concat!(
3428 "1. [ ] [#a3f2] parent task\n",
3429 " 1. dependency one\n",
3430 " 2. dependency two\n",
3431 "2. [ ] [#b1c4] sibling task\n"
3432 );
3433 let (_, items, _) = parse_items(body);
3434 assert_eq!(items.len(), 2);
3435 assert_eq!(items[0].marker, PendingListMarker::Ordered(1));
3436 assert_eq!(
3437 items[0].continuation,
3438 " 1. dependency one\n 2. dependency two\n"
3439 );
3440 assert_eq!(items[1].marker, PendingListMarker::Ordered(2));
3441 }
3442
3443 #[test]
3444 fn render_roundtrip_canonical() {
3445 let body = "- [ ] [#a3f2] first\n- [x] [#b1c4] second\n";
3446 let (p, items, post) = parse_items(body);
3447 let out = render_items(&p, &items, &post);
3448 assert_eq!(out, body);
3449 }
3450
3451 #[test]
3452 fn render_roundtrip_all_three_states() {
3453 let body = "- [ ] [#a3f2] open\n- [/] [#b1c4] gated โ gate: v0.32.5\n- [x] [#c9e0] done\n";
3454 let (p, items, post) = parse_items(body);
3455 let out = render_items(&p, &items, &post);
3456 assert_eq!(out, body);
3457 }
3458
3459 #[test]
3460 fn render_roundtrip_ordered_list() {
3461 let body = "1. [ ] [#a3f2] open\n2. [/] [#b1c4] gated\n3. [x] [#c9e0] done\n";
3462 let (p, items, post) = parse_items(body);
3463 let out = render_items(&p, &items, &post);
3464 assert_eq!(out, body);
3465 }
3466
3467 #[test]
3468 fn render_emits_slash_for_gated() {
3469 let item = PendingItem {
3470 marker: PendingListMarker::Bullet,
3471 id: "eg0w".to_string(),
3472 state: PendingState::Gated,
3473 gate_type: None,
3474 in_progress: false,
3475 text: "CommitLock".to_string(),
3476 continuation: String::new(),
3477 };
3478 assert_eq!(item.render(), "- [/] [#eg0w] CommitLock");
3479 }
3480
3481 #[test]
3482 fn backfill_adds_hashes() {
3483 let body = "- legacy one\n- legacy two\n";
3484 let (new_body, changed) = backfill(body, DOC_ID, &ids());
3485 assert!(changed);
3486 let (_, items, _) = parse_items(&new_body);
3487 assert_eq!(items.len(), 2);
3488 assert!(!items[0].id.is_empty());
3489 assert!(!items[1].id.is_empty());
3490 assert_ne!(items[0].id, items[1].id);
3491 assert!(new_body.contains("- [ ] [#"));
3492 }
3493
3494 #[test]
3495 fn backfill_idempotent() {
3496 let body = "- [ ] [#a3f2] first\n";
3497 let (new_body, changed) = backfill(body, DOC_ID, &ids());
3498 assert!(!changed, "fully-migrated body should not change");
3499 assert_eq!(new_body, body);
3500 }
3501
3502 #[test]
3503 fn backfill_normalizes_checkbox_only() {
3504 let body = "- [ ] no id here\n";
3505 let (new_body, changed) = backfill(body, DOC_ID, &ids());
3506 assert!(changed);
3507 assert!(new_body.contains("[#"));
3508 }
3509
3510 #[test]
3511 fn backfill_drops_content_less_empty_bullet() {
3512 let body = "- [ ]\n";
3515 let (new_body, changed) = backfill(body, DOC_ID, &ids());
3516 assert!(changed);
3517 assert!(
3518 !new_body.contains("[#"),
3519 "empty bullet must not get a phantom id: {new_body:?}"
3520 );
3521 let (_, items, _) = parse_items(&new_body);
3522 assert!(
3523 items.is_empty(),
3524 "empty bullet should be dropped: {items:?}"
3525 );
3526 }
3527
3528 #[test]
3529 fn backfill_drops_id_only_empty_item_self_heal() {
3530 let body = "- [ ] [#existing] real item\n- [ ] [#1k5y]\n";
3533 let (new_body, changed) = backfill(body, DOC_ID, &ids());
3534 assert!(changed);
3535 assert!(new_body.contains("[#existing] real item"));
3536 assert!(
3537 !new_body.contains("[#1k5y]"),
3538 "phantom id-only item must be dropped: {new_body:?}"
3539 );
3540 let (_, items, _) = parse_items(&new_body);
3541 assert_eq!(items.len(), 1);
3542 }
3543
3544 #[test]
3545 fn backfill_keeps_empty_text_with_continuation() {
3546 let body = "- [ ] [#p1]\n - detail line\n";
3549 let (new_body, _changed) = backfill(body, DOC_ID, &ids());
3550 assert!(
3551 new_body.contains("[#p1]"),
3552 "item with continuation must survive: {new_body:?}"
3553 );
3554 assert!(new_body.contains("detail line"));
3555 }
3556
3557 #[test]
3558 fn backfill_never_inserts_gated() {
3559 let body = "- legacy item awaiting v0.32.5\n";
3562 let (new_body, _) = backfill(body, DOC_ID, &ids());
3563 assert!(new_body.contains("- [ ] "));
3564 assert!(!new_body.contains("- [/] "));
3565 }
3566
3567 #[test]
3568 fn backfill_preserves_existing_gated() {
3569 let body = "- [/] [#eg0w] CommitLock โ gate: v0.32.5\n";
3570 let (new_body, changed) = backfill(body, DOC_ID, &ids());
3571 assert!(!changed);
3572 assert_eq!(new_body, body);
3573 }
3574
3575 #[test]
3576 fn backfill_preserves_id_behind_legacy_tilde_checkbox() {
3577 let body = "- [~] [#q6js] pcpc5e3 08b cut-over proof\n";
3578 let (new_body, changed) = backfill(body, DOC_ID, &ids());
3579 assert!(changed);
3580 assert_eq!(new_body, "- [ ] [#q6js] pcpc5e3 08b cut-over proof\n");
3581 assert_eq!(new_body.matches("[#").count(), 1);
3582
3583 let (_, items, _) = parse_items(&new_body);
3584 assert_eq!(items.len(), 1);
3585 assert_eq!(items[0].state, PendingState::Open);
3586 assert_eq!(items[0].id, "q6js");
3587 assert_eq!(items[0].text, "pcpc5e3 08b cut-over proof");
3588 }
3589
3590 #[test]
3591 fn backfill_preserves_interleaved_headers_and_blank_lines() {
3592 let body = concat!(
3593 "### Active\n",
3594 "- legacy one\n",
3595 "\n",
3596 "### Later\n",
3597 "- [ ] [#keep1] keep section\n"
3598 );
3599 let (new_body, changed) = backfill(body, DOC_ID, &ids());
3600 assert!(changed);
3601 assert!(new_body.contains("### Active"));
3602 assert!(new_body.contains("\n\n### Later\n"));
3603 assert!(new_body.contains("[#keep1] keep section"));
3604 let lines: Vec<&str> = new_body.lines().collect();
3605 assert_eq!(lines[0], "### Active");
3606 assert!(lines[1].starts_with("- [ ] [#"));
3607 assert_eq!(lines[3], "### Later");
3608 }
3609
3610 #[test]
3611 fn backfill_assigns_nested_subtask_ids_prefixed_by_parent() {
3612 let body = concat!(
3613 "- parent task\n",
3614 " - child dependency\n",
3615 " - child subtask\n",
3616 "- sibling task\n"
3617 );
3618 let (new_body, changed) = backfill(body, DOC_ID, &ids());
3619 assert!(changed);
3620 assert_eq!(new_body.matches("[#").count(), 4, "got: {new_body}");
3621 assert!(new_body.contains(" - [ ] [#"));
3622 let lines: Vec<&str> = new_body.lines().collect();
3623 let parent_line = lines[0];
3624 let parent_id = parent_line
3625 .split("[#")
3626 .nth(1)
3627 .and_then(|rest| rest.split(']').next())
3628 .expect("parent id");
3629 assert!(
3630 lines[1].contains(&format!("[#{}-", parent_id)),
3631 "expected first child id prefixed by parent id, got: {}",
3632 lines[1]
3633 );
3634 assert!(
3635 lines[2].contains(&format!("[#{}-", parent_id)),
3636 "expected second child id prefixed by parent id, got: {}",
3637 lines[2]
3638 );
3639 }
3640
3641 #[test]
3642 fn backfill_preserves_existing_nested_subtask_ids() {
3643 let body = concat!(
3644 "- [ ] [#tmuxcrash] parent task\n",
3645 " - [ ] [#tmuxcrash-abcd] child dependency\n"
3646 );
3647 let (new_body, changed) = backfill(body, DOC_ID, &ids());
3648 assert!(!changed);
3649 assert_eq!(new_body, body);
3650 }
3651
3652 #[test]
3653 fn backfill_reassigns_duplicate_existing_nested_subtask_ids() {
3654 let body = concat!(
3655 "- [ ] [#tmuxcrash] parent task\n",
3656 " - [ ] [#tmuxcrash-abcd] child dependency\n",
3657 " - [ ] [#tmuxcrash-abcd] child subtask\n"
3658 );
3659 let (new_body, changed) = backfill(body, DOC_ID, &ids());
3660 assert!(changed);
3661 let lines: Vec<&str> = new_body.lines().collect();
3662 assert_eq!(lines.len(), 3, "got: {new_body}");
3663 assert!(lines[1].contains("[#tmuxcrash-abcd]"));
3664 let second_child_id = lines[2]
3665 .split("[#")
3666 .nth(1)
3667 .and_then(|rest| rest.split(']').next())
3668 .expect("second child id");
3669 assert_ne!(second_child_id, "tmuxcrash-abcd");
3670 assert!(second_child_id.starts_with("tmuxcrash-"));
3671 }
3672
3673 #[test]
3674 fn backfill_preserves_ordered_parent_items() {
3675 let body = concat!("1. legacy one\n", "2. [ ] [#keep1] keep section\n");
3676 let (new_body, changed) = backfill(body, DOC_ID, &ids());
3677 assert!(changed);
3678 assert!(new_body.starts_with("1. [ ] [#"));
3679 assert!(new_body.contains("2. [ ] [#keep1] keep section"));
3680 }
3681
3682 #[test]
3683 fn reap_skips_gated() {
3684 let body = "- [/] [#eg0w] gated\n- [x] [#c9e0] done\n";
3685 let (new_body, removed) = reap(body).unwrap();
3686 assert_eq!(removed, vec!["c9e0"]);
3687 assert!(new_body.contains("[#eg0w]"));
3688 assert!(!new_body.contains("[#c9e0]"));
3689 }
3690
3691 #[test]
3692 fn reap_removes_checked() {
3693 let body = "- [ ] [#a3f2] keep\n- [x] [#b1c4] drop\n- [ ] [#c5d6] keep2\n";
3694 let (new_body, removed) = reap(body).unwrap();
3695 assert_eq!(removed, vec!["b1c4"]);
3696 assert!(new_body.contains("a3f2"));
3697 assert!(!new_body.contains("b1c4"));
3698 assert!(new_body.contains("c5d6"));
3699 }
3700
3701 #[test]
3702 fn reap_removes_flush_left_spill_with_completed_parent() {
3703 let body = concat!(
3704 "- [x] [#b1c4] drop\n",
3705 "Commands:\n",
3706 " cargo test -p agent-doc pending::\n",
3707 "Diff:\n",
3708 "@@ -1 +1 @@\n",
3709 "- [ ] [#c5d6] keep\n"
3710 );
3711 let (new_body, removed) = reap(body).unwrap();
3712 assert_eq!(removed, vec!["b1c4"]);
3713 assert!(!new_body.contains("[#b1c4]"));
3714 assert!(!new_body.contains("Commands:"));
3715 assert!(!new_body.contains("@@ -1 +1 @@"));
3716 assert!(new_body.contains("- [ ] [#c5d6] keep"));
3717 }
3718
3719 #[test]
3720 fn reap_preserves_following_heading_text() {
3721 let body = concat!(
3722 "- [x] [#b1c4] drop\n",
3723 "\n",
3724 "## Next Group\n",
3725 "- [ ] [#c5d6] keep\n"
3726 );
3727 let (new_body, removed) = reap(body).unwrap();
3728 assert_eq!(removed, vec!["b1c4"]);
3729 assert!(!new_body.contains("[#b1c4]"));
3730 assert!(new_body.contains("## Next Group"));
3731 assert!(new_body.contains("- [ ] [#c5d6] keep"));
3732 }
3733
3734 #[test]
3735 fn reap_noop_when_none_checked() {
3736 let body = "- [ ] [#a3f2] keep\n";
3737 let (new_body, removed) = reap(body).unwrap();
3738 assert!(removed.is_empty());
3739 assert_eq!(new_body, body);
3740 }
3741
3742 #[test]
3743 fn reap_errors_when_completed_item_is_missing_id() {
3744 let body = "- [x] legacy done without id\n- [ ] [#keep1] keep\n";
3745 let err = reap(body).unwrap_err();
3746 assert!(
3747 err.to_string()
3748 .contains("pending reap requires ids for completed items"),
3749 "unexpected error: {err}"
3750 );
3751 }
3752
3753 #[test]
3754 fn reap_with_items_archives_malformed_flush_left_spill_with_parent() {
3755 let body = concat!(
3756 "- [x] [#b1c4] drop\n",
3757 "Commands:\n",
3758 " cargo test -p agent-doc pending::\n"
3759 );
3760 let (_new_body, removed) = reap_with_items(body).unwrap();
3761 assert_eq!(removed.len(), 1);
3762 assert_eq!(removed[0].id, "b1c4");
3763 assert_eq!(
3764 removed[0].continuation,
3765 "Commands:\n cargo test -p agent-doc pending::\n"
3766 );
3767 }
3768
3769 #[test]
3770 fn detect_reorder_same_set_different_order() {
3771 let snap = "- [ ] [#a1b2] one\n- [ ] [#c3d4] two\n";
3772 let cur = "- [ ] [#c3d4] two\n- [ ] [#a1b2] one\n";
3773 let result = detect_reorder(snap, cur);
3774 assert_eq!(result, Some(vec!["c3d4".to_string(), "a1b2".to_string()]));
3775 }
3776
3777 #[test]
3778 fn detect_reorder_none_when_sets_differ() {
3779 let snap = "- [ ] [#a1b2] one\n";
3780 let cur = "- [ ] [#a1b2] one\n- [ ] [#c3d4] two\n";
3781 assert_eq!(detect_reorder(snap, cur), None);
3782 }
3783
3784 #[test]
3785 fn detect_reorder_none_when_same_order() {
3786 let snap = "- [ ] [#a1b2] one\n- [ ] [#c3d4] two\n";
3787 assert_eq!(detect_reorder(snap, snap), None);
3788 }
3789
3790 #[test]
3791 fn op_add_inserts_new_item_with_hash() {
3792 let body = "";
3793 let (new_body, id) = op_add(body, "first task", DOC_ID, false).unwrap();
3794 assert!(new_body.contains("- [ ] [#"));
3795 assert!(new_body.contains("first task"));
3796 assert!(!id.is_empty());
3797 }
3798
3799 #[test]
3800 fn op_add_prepends_before_existing_items() {
3801 let body = "- [ ] [#a1b2] existing task\n- [ ] [#c3d4] later task\n";
3802 let (new_body, _id) = op_add(body, "new first task", DOC_ID, false).unwrap();
3803 let lines: Vec<&str> = new_body.lines().collect();
3804 assert!(
3805 lines[0].contains("new first task"),
3806 "expected new item first, got: {}",
3807 new_body
3808 );
3809 assert!(
3810 lines[1].contains("existing task"),
3811 "expected previous first item second, got: {}",
3812 new_body
3813 );
3814 }
3815
3816 #[test]
3817 fn op_add_at_after_inserts_immediately_after_anchor() {
3818 let body = "- [ ] [#a1b2] first\n- [ ] [#c3d4] third\n";
3820 let (new_body, _id) =
3821 op_add_at(body, "second", DOC_ID, false, AddPosition::After("a1b2")).unwrap();
3822 let lines: Vec<&str> = new_body.lines().collect();
3823 assert!(lines[0].contains("first"), "{new_body}");
3824 assert!(lines[1].contains("second"), "{new_body}");
3825 assert!(lines[2].contains("third"), "{new_body}");
3826 }
3827
3828 #[test]
3829 fn op_add_at_before_inserts_immediately_before_anchor() {
3830 let body = "- [ ] [#a1b2] first\n- [ ] [#c3d4] third\n";
3831 let (new_body, _id) =
3832 op_add_at(body, "zeroth", DOC_ID, false, AddPosition::Before("a1b2")).unwrap();
3833 let lines: Vec<&str> = new_body.lines().collect();
3834 assert!(lines[0].contains("zeroth"), "{new_body}");
3835 assert!(lines[1].contains("first"), "{new_body}");
3836 }
3837
3838 #[test]
3839 fn op_add_at_last_appends_at_tail() {
3840 let body = "- [ ] [#a1b2] first\n- [ ] [#c3d4] second\n";
3842 let (new_body, _id) =
3843 op_add_at(body, "tail item", DOC_ID, false, AddPosition::Last).unwrap();
3844 let lines: Vec<&str> = new_body.lines().collect();
3845 assert!(lines[0].contains("first"), "{new_body}");
3846 assert!(lines[2].contains("tail item"), "{new_body}");
3847 }
3848
3849 #[test]
3850 fn op_add_at_after_chains_build_deterministic_order() {
3851 let body = "- [ ] [#a1b2] A\n";
3853 let (b1, _) =
3854 op_add_at(body, "id=bbbb B", DOC_ID, false, AddPosition::After("a1b2")).unwrap();
3855 let (b2, _) = op_add_at(&b1, "C", DOC_ID, false, AddPosition::After("bbbb")).unwrap();
3856 let lines: Vec<&str> = b2.lines().collect();
3857 assert!(lines[0].contains(" A"), "{b2}");
3858 assert!(lines[1].contains(" B"), "{b2}");
3859 assert!(lines[2].contains(" C"), "{b2}");
3860 }
3861
3862 #[test]
3863 fn op_add_at_unknown_anchor_errors() {
3864 let body = "- [ ] [#a1b2] first\n";
3865 let err = op_add_at(body, "x", DOC_ID, false, AddPosition::After("nope")).unwrap_err();
3866 assert!(err.to_string().contains("anchor id not found"), "{err}");
3867 }
3868
3869 #[test]
3870 fn op_add_preserves_section_headers() {
3871 let body = concat!(
3872 "### Active\n",
3873 "- [ ] [#a1b2] existing task\n",
3874 "### Later\n",
3875 "- [ ] [#c3d4] later task\n"
3876 );
3877 let (new_body, _id) = op_add(body, "new first task", DOC_ID, false).unwrap();
3878 let lines: Vec<&str> = new_body.lines().collect();
3879 assert_eq!(lines[0], "### Active");
3880 assert!(lines[1].contains("new first task"), "got: {}", new_body);
3881 assert!(lines[2].contains("existing task"), "got: {}", new_body);
3882 assert_eq!(lines[3], "### Later");
3883 assert!(lines[4].contains("later task"), "got: {}", new_body);
3884 }
3885
3886 #[test]
3887 fn op_append_items_preserves_order_after_existing_items() {
3888 let body = concat!(
3889 "### Active\n",
3890 "- [ ] [#a1b2] existing task\n",
3891 "\n",
3892 "### Notes\n",
3893 "Keep this note.\n",
3894 );
3895 let appended = vec![
3896 PendingItem {
3897 marker: PendingListMarker::Bullet,
3898 id: "b2c3".to_string(),
3899 state: PendingState::Open,
3900 gate_type: None,
3901 in_progress: false,
3902 text: "first appended task".to_string(),
3903 continuation: String::new(),
3904 },
3905 PendingItem {
3906 marker: PendingListMarker::Bullet,
3907 id: "d4e5".to_string(),
3908 state: PendingState::Gated,
3909 gate_type: Some("release".to_string()),
3910 in_progress: false,
3911 text: "second appended task".to_string(),
3912 continuation: String::new(),
3913 },
3914 ];
3915
3916 let new_body = op_append_items(body, &appended);
3917
3918 assert_eq!(
3919 new_body,
3920 concat!(
3921 "### Active\n",
3922 "- [ ] [#a1b2] existing task\n",
3923 "- [ ] [#b2c3] first appended task\n",
3924 "- [/release] [#d4e5] second appended task\n",
3925 "\n",
3926 "### Notes\n",
3927 "Keep this note.\n",
3928 )
3929 );
3930 }
3931
3932 #[test]
3933 fn op_add_renumbers_ordered_lists() {
3934 let body = "1. [ ] [#a1b2] existing task\n2. [ ] [#c3d4] later task\n";
3935 let (new_body, _id) = op_add(body, "new first task", DOC_ID, false).unwrap();
3936 let lines: Vec<&str> = new_body.lines().collect();
3937 assert!(lines[0].starts_with("1. "), "got: {}", new_body);
3938 assert!(lines[0].contains("new first task"), "got: {}", new_body);
3939 assert!(lines[1].starts_with("2. "), "got: {}", new_body);
3940 assert!(lines[1].contains("existing task"), "got: {}", new_body);
3941 assert!(lines[2].starts_with("3. "), "got: {}", new_body);
3942 assert!(lines[2].contains("later task"), "got: {}", new_body);
3943 }
3944
3945 #[test]
3946 fn op_add_accepts_custom_id_prefix() {
3947 let (new_body, id) = op_add("", "id=ship1 release checklist", DOC_ID, false).unwrap();
3948 assert_eq!(id, "ship1");
3949 assert!(new_body.contains("- [ ] [#ship1] release checklist"));
3950 }
3951
3952 #[test]
3953 fn op_add_accepts_custom_id_prefix_with_hash_marker() {
3954 let (new_body, id) = op_add("", "id=#ship1 release checklist", DOC_ID, false).unwrap();
3955 assert_eq!(id, "ship1");
3956 assert!(new_body.contains("- [ ] [#ship1] release checklist"));
3957 }
3958
3959 #[test]
3960 fn op_add_accepts_bracketed_custom_id_prefix() {
3961 let (new_body, id) = op_add("", "[#ship1] release checklist", DOC_ID, false).unwrap();
3962 assert_eq!(id, "ship1");
3963 assert!(new_body.contains("- [ ] [#ship1] release checklist"));
3964 }
3965
3966 #[test]
3967 fn op_add_accepts_long_bracketed_custom_id_prefix() {
3968 let (new_body, id) = op_add("", "[#sdig2matrix] release checklist", DOC_ID, false).unwrap();
3969 assert_eq!(id, "sdig2matrix");
3970 assert!(new_body.contains("- [ ] [#sdig2matrix] release checklist"));
3971 }
3972
3973 #[test]
3974 fn op_add_accepts_hyphenated_custom_id_prefix() {
3975 let (new_body, id) =
3976 op_add("", "id=tmuxcrash-abcd release checklist", DOC_ID, false).unwrap();
3977 assert_eq!(id, "tmuxcrash-abcd");
3978 assert!(new_body.contains("- [ ] [#tmuxcrash-abcd] release checklist"));
3979 }
3980
3981 #[test]
3982 fn op_add_accepts_hyphenated_bracketed_custom_id_prefix() {
3983 let (new_body, id) =
3984 op_add("", "[#tmuxcrash-abcd] release checklist", DOC_ID, false).unwrap();
3985 assert_eq!(id, "tmuxcrash-abcd");
3986 assert!(new_body.contains("- [ ] [#tmuxcrash-abcd] release checklist"));
3987 }
3988
3989 #[test]
3990 fn op_add_rejects_bare_bracket_placeholder_prefix() {
3991 let err = op_add("", "[#] release checklist", DOC_ID, false).unwrap_err();
3992 assert!(format!("{}", err).contains("bare `[#]` placeholder"));
3993 }
3994
3995 #[test]
3996 fn op_add_rejects_empty_explicit_custom_id_prefix() {
3997 let err = op_add("", "id= release checklist", DOC_ID, false).unwrap_err();
3998 assert!(format!("{}", err).contains("empty custom id"));
3999 }
4000
4001 #[test]
4002 fn op_add_rejects_duplicate_custom_id_prefix() {
4003 let body = "- [ ] [#ship1] existing task\n";
4004 let err = op_add(body, "id=ship1 new task", DOC_ID, false).unwrap_err();
4005 assert!(format!("{}", err).contains("custom id already exists"));
4006 }
4007
4008 #[test]
4009 fn op_add_rejects_missing_text_after_custom_id_prefix() {
4010 let err = op_add("", "id=ship1", DOC_ID, false).unwrap_err();
4011 assert!(format!("{}", err).contains("custom id prefix must be followed by item text"));
4012 }
4013
4014 #[test]
4015 fn op_add_rejects_missing_text_after_bracketed_custom_id_prefix() {
4016 let err = op_add("", "[#ship1]", DOC_ID, false).unwrap_err();
4017 assert!(
4018 format!("{}", err).contains("bracketed custom id prefix must be followed by item text")
4019 );
4020 }
4021
4022 #[test]
4023 fn op_add_rejects_stacked_bracketed_custom_id_prefixes() {
4024 let err = op_add("", "[#ship1] [#ship2] release checklist", DOC_ID, false).unwrap_err();
4025 assert!(
4026 format!("{}", err).contains("duplicate leading custom id prefix"),
4027 "unexpected error: {}",
4028 err
4029 );
4030 }
4031
4032 #[test]
4033 fn op_add_rejects_stacked_mixed_custom_id_prefixes() {
4034 let err = op_add("", "id=ship1 [#ship2] release checklist", DOC_ID, false).unwrap_err();
4035 assert!(
4036 format!("{}", err).contains("duplicate leading custom id prefix"),
4037 "unexpected error: {}",
4038 err
4039 );
4040 }
4041
4042 #[test]
4043 fn op_add_rejects_empty() {
4044 assert!(op_add("", " ", DOC_ID, false).is_err());
4045 }
4046
4047 #[test]
4048 fn op_add_rejects_state_marker_prefix() {
4049 for marker in &["[ ] task", "[/] task", "[x] task", "[X] task"] {
4050 let err = op_add("", marker, DOC_ID, false).unwrap_err();
4051 let msg = format!("{}", err);
4052 assert!(
4053 msg.contains("state marker"),
4054 "expected state marker error for '{}', got: {}",
4055 marker,
4056 msg
4057 );
4058 }
4059 }
4060
4061 #[test]
4062 fn op_add_rejects_duplicate_text() {
4063 let (body, _id1) = op_add("", "Wire Sift into corky", DOC_ID, false).unwrap();
4064 let err = op_add(&body, "Wire Sift into corky", DOC_ID, false).unwrap_err();
4065 let msg = format!("{}", err);
4066 assert!(
4067 msg.contains("duplicate"),
4068 "expected duplicate error, got: {}",
4069 msg
4070 );
4071 }
4072
4073 #[test]
4074 fn op_add_dedupes_symptom_key_by_attaching_evidence() {
4075 let key = SymptomDedupeKey::new("stale_queue_pause", "doc-abc", "queue", "sha256:feedface")
4076 .unwrap();
4077 let first = format!("stale queue pause {}", key.marker());
4078 let first_outcome = op_add_with_outcome("", &first, DOC_ID, false).unwrap();
4079 assert!(first_outcome.inserted);
4080
4081 let repeat = format!("stale queue pause observed again {}", key.marker());
4082 let repeat_outcome =
4083 op_add_with_outcome(&first_outcome.body, &repeat, DOC_ID, false).unwrap();
4084
4085 assert!(!repeat_outcome.inserted);
4086 assert_eq!(repeat_outcome.id, first_outcome.id);
4087 assert_eq!(repeat_outcome.deduped_key.as_ref(), Some(&key));
4088 assert_eq!(repeat_outcome.body.matches("[#").count(), 1);
4089 assert!(repeat_outcome.body.contains("stale queue pause "));
4090 assert!(
4091 repeat_outcome
4092 .body
4093 .contains(" evidence: stale queue pause observed again")
4094 );
4095 }
4096
4097 #[test]
4098 fn parse_bare_hash_placeholder_strips_marker() {
4099 let item = parse_item_line("- [ ] [#] Wire Sift into corky").unwrap();
4100 assert_eq!(item.id, "");
4101 assert_eq!(item.text, "Wire Sift into corky");
4102 }
4103
4104 #[test]
4105 fn backfill_strips_bare_hash_placeholder() {
4106 let body = "- [ ] [#] task with placeholder\n";
4107 let (new_body, changed) = backfill(body, DOC_ID, &ids());
4108 assert!(changed);
4109 assert!(new_body.contains("[#"), "should have a hash id");
4110 let hash_count = new_body.matches("[#").count();
4112 assert_eq!(hash_count, 1, "expected exactly one [# in: {}", new_body);
4113 assert!(new_body.contains("task with placeholder"));
4114 assert!(
4115 !new_body.contains("[#] task"),
4116 "bare [#] should not survive in text"
4117 );
4118 }
4119
4120 #[test]
4121 fn parse_bare_hash_placeholder_no_checkbox() {
4122 let item = parse_item_line("- [#] no checkbox").unwrap();
4124 assert_eq!(item.id, "");
4125 assert_eq!(item.state, PendingState::Open);
4126 assert_eq!(item.text, "no checkbox");
4127 }
4128
4129 #[test]
4130 fn parse_bare_hash_placeholder_gated() {
4131 let item = parse_item_line("- [/] [#] gated task").unwrap();
4133 assert_eq!(item.id, "");
4134 assert_eq!(item.state, PendingState::Gated);
4135 assert_eq!(item.text, "gated task");
4136 }
4137
4138 #[test]
4139 fn backfill_strips_multiple_bare_placeholders() {
4140 let body = "- [ ] [#] first task\n- [ ] [#] second task\n";
4142 let (new_body, changed) = backfill(body, DOC_ID, &ids());
4143 assert!(changed);
4144 let (_, items, _) = parse_items(&new_body);
4145 assert_eq!(items.len(), 2);
4146 assert!(!items[0].id.is_empty(), "first should have id");
4147 assert!(!items[1].id.is_empty(), "second should have id");
4148 assert_ne!(items[0].id, items[1].id, "ids should be unique");
4149 assert!(
4151 !items[0].text.contains("[#]"),
4152 "first text has residual [#]: {}",
4153 items[0].text
4154 );
4155 assert!(
4156 !items[1].text.contains("[#]"),
4157 "second text has residual [#]: {}",
4158 items[1].text
4159 );
4160 }
4161
4162 #[test]
4163 fn backfill_preserves_long_custom_id() {
4164 let body = "- [ ] [#sdig2matrix] Fixture evidence matrix\n";
4165 let (new_body, changed) = backfill(body, DOC_ID, &ids());
4166 assert!(!changed);
4167 assert_eq!(new_body, body);
4168 }
4169
4170 #[test]
4171 fn backfill_idempotent_after_placeholder_strip() {
4172 let body = "- [ ] [#] task\n";
4174 let (first_pass, _) = backfill(body, DOC_ID, &ids());
4175 let (second_pass, changed) = backfill(&first_pass, DOC_ID, &ids());
4176 assert!(
4177 !changed,
4178 "second backfill should be no-op, got: {}",
4179 second_pass
4180 );
4181 assert_eq!(first_pass, second_pass);
4182 }
4183
4184 #[test]
4185 fn op_add_dedup_case_sensitive() {
4186 let (body, _) = op_add("", "Wire Sift", DOC_ID, false).unwrap();
4188 let result = op_add(&body, "wire sift", DOC_ID, false);
4189 assert!(result.is_ok(), "different case should not be duplicate");
4190 }
4191
4192 #[test]
4193 fn op_add_dedup_across_states() {
4194 let (body, _) = op_add("", "deploy to prod", DOC_ID, true).unwrap();
4196 let err = op_add(&body, "deploy to prod", DOC_ID, false).unwrap_err();
4197 assert!(format!("{}", err).contains("duplicate"));
4198 }
4199
4200 #[test]
4201 fn op_add_gated_produces_gated_item() {
4202 let (new_body, id) = op_add("", "gated task", DOC_ID, true).unwrap();
4203 assert!(new_body.contains("[/]"), "expected [/] in: {}", new_body);
4204 assert!(new_body.contains(&format!("[#{}]", id)));
4205 assert!(new_body.contains("gated task"));
4206 }
4207
4208 #[test]
4209 fn op_add_returns_assigned_id() {
4210 let (body, id1) = op_add("", "task one", DOC_ID, false).unwrap();
4211 assert!(!id1.is_empty());
4212 assert!(body.contains(&format!("[#{}]", id1)));
4213 let (body2, id2) = op_add(&body, "task two", DOC_ID, false).unwrap();
4214 assert_ne!(id1, id2);
4215 assert!(body2.contains(&format!("[#{}]", id2)));
4216 }
4217
4218 #[test]
4219 fn op_add_extracts_inline_tag_as_id() {
4220 let (new_body, id) = op_add(
4221 "",
4222 "2026-05-11 [#nopatchbackopencode] OpenCode agent-doc turn completed",
4223 DOC_ID,
4224 false,
4225 )
4226 .unwrap();
4227 assert_eq!(id, "nopatchbackopencode");
4228 assert!(
4229 new_body.contains(
4230 "- [ ] [#nopatchbackopencode] 2026-05-11 OpenCode agent-doc turn completed"
4231 ),
4232 "got: {}",
4233 new_body
4234 );
4235 }
4236
4237 #[test]
4238 fn op_add_inline_tag_strips_from_text() {
4239 let (new_body, id) = op_add("", "fix [#mybug] the thing", DOC_ID, false).unwrap();
4240 assert_eq!(id, "mybug");
4241 assert!(
4242 new_body.contains("- [ ] [#mybug] fix the thing"),
4243 "got: {}",
4244 new_body
4245 );
4246 }
4247
4248 #[test]
4249 fn op_add_inline_tag_at_end() {
4250 let (new_body, id) = op_add("", "deploy the thing [#deploy1]", DOC_ID, false).unwrap();
4251 assert_eq!(id, "deploy1");
4252 assert!(
4253 new_body.contains("- [ ] [#deploy1] deploy the thing"),
4254 "got: {}",
4255 new_body
4256 );
4257 }
4258
4259 #[test]
4260 fn op_add_inline_tag_dedup_uses_cleaned_text() {
4261 let (body, _) = op_add("", "fix [#mybug] the thing", DOC_ID, false).unwrap();
4262 let err = op_add(&body, "fix [#mybug] the thing", DOC_ID, false).unwrap_err();
4263 assert!(
4264 format!("{}", err).contains("duplicate"),
4265 "expected duplicate error, got: {}",
4266 err
4267 );
4268 }
4269
4270 #[test]
4271 fn op_add_inline_tag_rejects_existing_id() {
4272 let body = "- [ ] [#mybug] existing task\n";
4273 let err = op_add(body, "fix [#mybug] new task", DOC_ID, false).unwrap_err();
4274 assert!(
4275 format!("{}", err).contains("inline tag id already exists"),
4276 "expected inline tag id already exists, got: {}",
4277 err
4278 );
4279 }
4280
4281 #[test]
4282 fn op_add_inline_tag_ignores_invalid_tag() {
4283 let (new_body, id) =
4284 op_add("", "see [#not-a-valid tag] because spaces", DOC_ID, false).unwrap();
4285 assert_ne!(id, "not-a-valid");
4286 assert!(
4287 new_body.contains("see [#not-a-valid tag] because spaces"),
4288 "got: {}",
4289 new_body
4290 );
4291 }
4292
4293 #[test]
4294 fn op_add_leading_prefix_takes_precedence_over_inline() {
4295 let (new_body, id) =
4296 op_add("", "[#myid] text with [#other] inline", DOC_ID, false).unwrap();
4297 assert_eq!(id, "myid");
4298 assert!(
4299 new_body.contains("- [ ] [#myid] text with [#other] inline"),
4300 "got: {}",
4301 new_body
4302 );
4303 }
4304
4305 #[test]
4306 fn op_add_promotes_leading_bare_tag_to_id() {
4307 let (new_body, id) = op_add(
4312 "",
4313 "#mergestatemachine3 JB+VSCode report attach/detach through SM",
4314 DOC_ID,
4315 false,
4316 )
4317 .unwrap();
4318 assert_eq!(id, "mergestatemachine3");
4319 assert!(
4320 new_body
4321 .contains("- [ ] [#mergestatemachine3] JB+VSCode report attach/detach through SM"),
4322 "got: {}",
4323 new_body
4324 );
4325 }
4326
4327 #[test]
4328 fn op_add_leading_bare_tag_with_trailing_equals_keeps_auto_hash() {
4329 let (new_body, id) =
4333 op_add("", "#lazilyspecpin=Pin the vocabulary", DOC_ID, false).unwrap();
4334 assert_ne!(id, "lazilyspecpin");
4335 assert!(
4336 new_body.contains("#lazilyspecpin=Pin the vocabulary"),
4337 "got: {}",
4338 new_body
4339 );
4340 }
4341
4342 #[test]
4343 fn op_add_leading_bare_tag_alone_is_reference_not_id() {
4344 let (new_body, id) = op_add("", "#somereference", DOC_ID, false).unwrap();
4347 assert_ne!(id, "somereference");
4348 assert!(new_body.contains("#somereference"), "got: {}", new_body);
4349 }
4350
4351 #[test]
4352 fn op_add_leading_bare_tag_rejects_existing_id() {
4353 let body = "- [ ] [#mergestatemachine3] existing task\n";
4354 let err = op_add(body, "#mergestatemachine3 new task", DOC_ID, false).unwrap_err();
4355 assert!(
4356 format!("{}", err).contains("inline tag id already exists"),
4357 "expected inline tag id already exists, got: {}",
4358 err
4359 );
4360 }
4361
4362 #[test]
4363 fn op_done_marks_checked() {
4364 let body = "- [ ] [#a1b2] task\n";
4365 let new_body = op_done(body, "a1b2").unwrap();
4366 assert!(new_body.contains("[x]"));
4367 }
4368
4369 #[test]
4370 fn op_done_accepts_hash_prefixed_id() {
4371 let body = "- [ ] [#a1b2] task\n";
4372 let new_body = op_done(body, "#a1b2").unwrap();
4373 assert!(new_body.contains("- [x] [#a1b2] task"));
4374 }
4375
4376 #[test]
4377 fn op_done_unknown_id_errors() {
4378 let body = "- [ ] [#a1b2] task\n";
4379 assert!(op_done(body, "zzzz").is_err());
4380 }
4381
4382 #[test]
4383 fn op_edit_preserves_hash() {
4384 let body = "- [ ] [#a1b2] original\n";
4385 let new_body = op_edit(body, "a1b2", "updated").unwrap();
4386 assert!(new_body.contains("[#a1b2]"));
4387 assert!(new_body.contains("updated"));
4388 assert!(!new_body.contains("original"));
4389 }
4390
4391 #[test]
4392 fn op_edit_multiline_replaces_existing_continuation() {
4393 let body = concat!(
4394 "- [ ] [#tmuxcrash] parent task\n",
4395 " - [ ] [#tmuxcrash-old1] stale child\n",
4396 " - [ ] [#tmuxcrash-old2] stale child two\n",
4397 "- [ ] [#keep1] sibling task\n"
4398 );
4399 let new_body = op_edit(
4400 body,
4401 "tmuxcrash",
4402 "parent task revised\n - fresh child\n - fresh child two",
4403 )
4404 .unwrap();
4405 assert!(new_body.contains("[#tmuxcrash] parent task revised"));
4406 assert!(new_body.contains(" - fresh child\n"));
4407 assert!(new_body.contains(" - fresh child two"));
4408 assert!(!new_body.contains("stale child"));
4409 assert!(!new_body.contains("stale child two"));
4410 assert!(new_body.contains(" - fresh child two\n- [ ] [#keep1] sibling task"));
4411 }
4412
4413 #[test]
4414 fn op_edit_rejects_unindented_multiline_follow_up() {
4415 let body = "- [ ] [#a1b2] original\n";
4416 let err = op_edit(body, "a1b2", "updated\nsecond parent").unwrap_err();
4417 assert!(
4418 format!("{}", err)
4419 .contains("multiline text may only contain indented continuation lines"),
4420 "got: {err}"
4421 );
4422 }
4423
4424 #[test]
4425 fn op_clear_empties_items() {
4426 let body = "- [ ] [#a1b2] one\n- [ ] [#c3d4] two\n";
4427 let new_body = op_clear(body).unwrap();
4428 assert!(!new_body.contains("[#"));
4429 }
4430
4431 #[test]
4432 fn op_clear_preserves_headers_and_spacing() {
4433 let body = concat!(
4434 "### Active\n",
4435 "- [ ] [#a1b2] one\n",
4436 "\n",
4437 "### Later\n",
4438 "- [ ] [#c3d4] two\n"
4439 );
4440 let new_body = op_clear(body).unwrap();
4441 assert_eq!(new_body, "### Active\n\n### Later\n");
4442 }
4443
4444 #[test]
4445 fn op_reorder_reorders_by_id() {
4446 let body = "- [ ] [#a1b2] first\n- [ ] [#c3d4] second\n- [ ] [#e5f6] third\n";
4447 let new_body = op_reorder(body, &["e5f6".to_string(), "a1b2".to_string()]).unwrap();
4448 let (_, items, _) = parse_items(&new_body);
4449 assert_eq!(items[0].id, "e5f6");
4450 assert_eq!(items[1].id, "a1b2");
4451 assert_eq!(items[2].id, "c3d4");
4452 }
4453
4454 #[test]
4455 fn op_reorder_keeps_headers_in_place() {
4456 let body = concat!(
4457 "### Active\n",
4458 "- [ ] [#a1b2] first\n",
4459 "### Later\n",
4460 "- [ ] [#c3d4] second\n",
4461 "- [ ] [#e5f6] third\n"
4462 );
4463 let new_body = op_reorder(body, &["e5f6".to_string(), "a1b2".to_string()]).unwrap();
4464 let lines: Vec<&str> = new_body.lines().collect();
4465 assert_eq!(lines[0], "### Active");
4466 assert!(lines[1].contains("[#e5f6] third"), "got: {}", new_body);
4467 assert_eq!(lines[2], "### Later");
4468 assert!(lines[3].contains("[#a1b2] first"), "got: {}", new_body);
4469 assert!(lines[4].contains("[#c3d4] second"), "got: {}", new_body);
4470 }
4471
4472 #[test]
4473 fn op_reorder_moves_nested_subtasks_with_parent_item() {
4474 let body = concat!(
4475 "- [ ] [#a1b2] first\n",
4476 " - child a\n",
4477 "- [ ] [#c3d4] second\n",
4478 " - child b\n"
4479 );
4480 let new_body = op_reorder(body, &["c3d4".to_string()]).unwrap();
4481 assert_eq!(
4482 new_body,
4483 concat!(
4484 "- [ ] [#c3d4] second\n",
4485 " - child b\n",
4486 "- [ ] [#a1b2] first\n",
4487 " - child a\n"
4488 )
4489 );
4490 }
4491
4492 #[test]
4493 fn op_reorder_renumbers_ordered_lists() {
4494 let body = "1. [ ] [#a1b2] first\n2. [ ] [#c3d4] second\n3. [ ] [#e5f6] third\n";
4495 let new_body = op_reorder(body, &["e5f6".to_string(), "a1b2".to_string()]).unwrap();
4496 let lines: Vec<&str> = new_body.lines().collect();
4497 assert_eq!(lines[0], "1. [ ] [#e5f6] third");
4498 assert_eq!(lines[1], "2. [ ] [#a1b2] first");
4499 assert_eq!(lines[2], "3. [ ] [#c3d4] second");
4500 }
4501
4502 #[test]
4503 fn op_reorder_unknown_id_errors() {
4504 let body = "- [ ] [#a1b2] one\n";
4505 assert!(op_reorder(body, &["zzzz".to_string()]).is_err());
4506 }
4507
4508 #[test]
4511 fn validate_transition_full_matrix() {
4512 use PendingOp::*;
4513 use PendingState::*;
4514 use TransitionResult::*;
4515
4516 assert_eq!(validate_transition(Open, Gate).unwrap(), Transition(Gated));
4518 assert!(validate_transition(Open, Ungate).is_err());
4519 assert_eq!(
4520 validate_transition(Open, MarkDone).unwrap(),
4521 Transition(Done)
4522 );
4523
4524 assert_eq!(validate_transition(Gated, Gate).unwrap(), NoOp);
4526 assert_eq!(
4527 validate_transition(Gated, Ungate).unwrap(),
4528 Transition(Open)
4529 );
4530 assert_eq!(
4531 validate_transition(Gated, MarkDone).unwrap(),
4532 Transition(Done)
4533 );
4534
4535 assert!(validate_transition(Done, Gate).is_err());
4537 assert!(validate_transition(Done, Ungate).is_err());
4538 assert_eq!(validate_transition(Done, MarkDone).unwrap(), NoOp);
4539 }
4540
4541 #[test]
4542 fn op_gate_open_to_gated() {
4543 let body = "- [ ] [#a1b2] task\n";
4544 let new_body = op_gate(body, "a1b2").unwrap();
4545 assert!(new_body.contains("- [/] [#a1b2]"));
4546 }
4547
4548 #[test]
4549 fn op_gate_gated_is_noop() {
4550 let body = "- [/] [#a1b2] task\n";
4551 let new_body = op_gate(body, "a1b2").unwrap();
4552 assert_eq!(new_body, body);
4554 }
4555
4556 #[test]
4557 fn op_gate_done_errors() {
4558 let body = "- [x] [#a1b2] task\n";
4559 let err = op_gate(body, "a1b2").unwrap_err();
4560 let msg = format!("{}", err);
4561 assert!(msg.contains("cannot gate Done item"), "got: {}", msg);
4562 }
4563
4564 #[test]
4565 fn op_gate_unknown_id_errors() {
4566 let body = "- [ ] [#a1b2] task\n";
4567 assert!(op_gate(body, "zzzz").is_err());
4568 }
4569
4570 #[test]
4571 fn op_ungate_gated_to_open() {
4572 let body = "- [/] [#a1b2] task\n";
4573 let new_body = op_ungate(body, "a1b2").unwrap();
4574 assert!(new_body.contains("- [ ] [#a1b2]"));
4575 }
4576
4577 #[test]
4578 fn op_ungate_open_errors() {
4579 let body = "- [ ] [#a1b2] task\n";
4580 let err = op_ungate(body, "a1b2").unwrap_err();
4581 let msg = format!("{}", err);
4582 assert!(msg.contains("cannot ungate Open"), "got: {}", msg);
4583 }
4584
4585 #[test]
4586 fn op_ungate_done_errors() {
4587 let body = "- [x] [#a1b2] task\n";
4588 let err = op_ungate(body, "a1b2").unwrap_err();
4589 let msg = format!("{}", err);
4590 assert!(msg.contains("cannot ungate Done"), "got: {}", msg);
4591 }
4592
4593 #[test]
4594 fn op_ungate_unknown_id_errors() {
4595 let body = "- [/] [#a1b2] task\n";
4596 assert!(op_ungate(body, "zzzz").is_err());
4597 }
4598
4599 #[test]
4600 fn op_gate_preserves_other_items_and_text() {
4601 let body = "- [ ] [#a1b2] one\n- [ ] [#c3d4] two โ gate: v0.32.6\n- [x] [#e5f6] three\n";
4602 let new_body = op_gate(body, "c3d4").unwrap();
4603 let (_, items, _) = parse_items(&new_body);
4604 assert_eq!(items[0].state, PendingState::Open);
4605 assert_eq!(items[1].state, PendingState::Gated);
4606 assert_eq!(items[1].text, "two โ gate: v0.32.6");
4607 assert_eq!(items[2].state, PendingState::Done);
4608 }
4609
4610 #[test]
4611 fn generate_hash_deterministic_and_short() {
4612 let h = generate_hash("text", "doc", 0);
4613 assert_eq!(h.len(), 4);
4614 assert_eq!(h, generate_hash("text", "doc", 0));
4615 assert_ne!(h, generate_hash("text", "doc", 1));
4616 }
4617
4618 #[test]
4619 fn generate_hash_n_width4_matches_generate_hash() {
4620 let cases = [
4623 ("text", "doc", 0u64),
4624 ("refactor preflight", "abc123", 7),
4625 ("", "", 42),
4626 ("long text with spaces", "doc_id_long", 99),
4627 ];
4628 for (t, d, c) in cases {
4629 assert_eq!(generate_hash(t, d, c), generate_hash_n(t, d, c, 4));
4630 }
4631 }
4632
4633 #[test]
4634 fn generate_hash_n_widths_have_correct_length() {
4635 for w in 4..=8 {
4636 let h = generate_hash_n("text", "doc", 0, w);
4637 assert_eq!(h.len(), w, "width {} produced len {}", w, h.len());
4638 }
4639 assert_eq!(generate_hash_n("x", "y", 0, 1).len(), 4);
4641 assert_eq!(generate_hash_n("x", "y", 0, 20).len(), 8);
4642 }
4643
4644 #[test]
4645 fn generate_hash_n_wider_extends_shorter() {
4646 let h4 = generate_hash_n("text", "doc", 0, 4);
4649 let h5 = generate_hash_n("text", "doc", 0, 5);
4650 let h8 = generate_hash_n("text", "doc", 0, 8);
4651 assert!(h5.starts_with(&h4), "h5={} h4={}", h5, h4);
4652 assert!(h8.starts_with(&h4), "h8={} h4={}", h8, h4);
4653 assert!(h8.starts_with(&h5), "h8={} h5={}", h8, h5);
4654 }
4655
4656 #[test]
4657 fn assign_unique_hash_extends_on_collision() {
4658 let h4 = generate_hash_n("item", "doc", 0, 4);
4663 let mut taken = HashSet::new();
4664 taken.insert(h4.clone());
4665 let id = assign_unique_hash("item", "doc", &taken);
4666 assert_ne!(id, h4);
4667 assert!((4..=8).contains(&id.len()));
4668 }
4669
4670 #[test]
4671 fn assign_unique_hash_widens_when_counter_exhausted_at_width4() {
4672 let mut taken = HashSet::new();
4675 for c in 0..=3u64 {
4676 taken.insert(generate_hash_n("x", "d", c, 4));
4677 }
4678 let id = assign_unique_hash("x", "d", &taken);
4679 assert!(!taken.contains(&id));
4680 assert!((4..=8).contains(&id.len()));
4683 }
4684
4685 #[test]
4686 fn backfill_assigns_collision_free_ids_under_pressure() {
4687 let mut body = String::new();
4689 for i in 0..50 {
4690 body.push_str(&format!("- item {}\n", i));
4691 }
4692 let (out, changed) = backfill(&body, "doc", &HashSet::new());
4693 assert!(changed);
4694 let (_, items, _) = parse_items(&out);
4695 assert_eq!(items.len(), 50);
4696 let ids: HashSet<String> = items.iter().map(|i| i.id.clone()).collect();
4697 assert_eq!(ids.len(), 50, "ids must be unique");
4698 for id in &ids {
4699 assert!(
4700 (4..=8).contains(&id.len()),
4701 "id {} has width {}",
4702 id,
4703 id.len()
4704 );
4705 }
4706 }
4707
4708 #[test]
4711 fn parse_typed_gate_release() {
4712 let body = "- [/release] [#a1b2] Release v0.32.4\n";
4713 let (_, items, _) = parse_items(body);
4714 assert_eq!(items.len(), 1);
4715 assert_eq!(items[0].state, PendingState::Gated);
4716 assert_eq!(items[0].gate_type, Some("release".to_string()));
4717 assert_eq!(items[0].text, "Release v0.32.4");
4718 }
4719
4720 #[test]
4721 fn parse_typed_gate_deploy() {
4722 let body = "- [/deploy] [#c3d4] Push CDN config\n";
4723 let (_, items, _) = parse_items(body);
4724 assert_eq!(items[0].state, PendingState::Gated);
4725 assert_eq!(items[0].gate_type, Some("deploy".to_string()));
4726 }
4727
4728 #[test]
4729 fn parse_untyped_gate_has_no_gate_type() {
4730 let body = "- [/] [#a1b2] waiting\n";
4731 let (_, items, _) = parse_items(body);
4732 assert_eq!(items[0].state, PendingState::Gated);
4733 assert_eq!(items[0].gate_type, None);
4734 }
4735
4736 #[test]
4737 fn parse_open_has_no_gate_type() {
4738 let body = "- [ ] [#a1b2] task\n";
4739 let (_, items, _) = parse_items(body);
4740 assert_eq!(items[0].gate_type, None);
4741 }
4742
4743 #[test]
4744 fn render_typed_gate() {
4745 let item = PendingItem {
4746 marker: PendingListMarker::Bullet,
4747 id: "a1b2".to_string(),
4748 state: PendingState::Gated,
4749 gate_type: Some("release".to_string()),
4750 in_progress: false,
4751 text: "Release v0.32.4".to_string(),
4752 continuation: String::new(),
4753 };
4754 assert_eq!(item.render(), "- [/release] [#a1b2] Release v0.32.4");
4755 }
4756
4757 #[test]
4758 fn render_roundtrip_typed_gate() {
4759 let body = "- [/release] [#a1b2] Release v0.32.4\n- [/deploy] [#c3d4] Push\n- [/] [#e5f6] Generic\n";
4760 let (p, items, post) = parse_items(body);
4761 let out = render_items(&p, &items, &post);
4762 assert_eq!(out, body);
4763 }
4764
4765 #[test]
4766 fn op_resolve_gate_flips_matching() {
4767 let body = "- [/release] [#a1b2] Release v0.32.4\n- [/deploy] [#c3d4] Deploy\n- [/] [#e5f6] Generic gate\n";
4768 let (new_body, resolved) = op_resolve_gate(body, "release");
4769 assert_eq!(resolved, vec!["a1b2"]);
4770 let (_, items, _) = parse_items(&new_body);
4771 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()));
4775 assert_eq!(items[2].state, PendingState::Gated); assert_eq!(items[2].gate_type, None);
4777 }
4778
4779 #[test]
4780 fn op_resolve_gate_no_match() {
4781 let body = "- [/release] [#a1b2] Release\n- [/] [#c3d4] Generic\n";
4782 let (new_body, resolved) = op_resolve_gate(body, "deploy");
4783 assert!(resolved.is_empty());
4784 assert_eq!(new_body, body);
4785 }
4786
4787 #[test]
4788 fn op_resolve_gate_ignores_untyped() {
4789 let body = "- [/] [#a1b2] Generic gate\n";
4790 let (_, resolved) = op_resolve_gate(body, "release");
4791 assert!(resolved.is_empty());
4792 }
4793
4794 #[test]
4795 fn op_resolve_gate_multiple_same_type() {
4796 let body = "- [/release] [#a1b2] First\n- [/release] [#c3d4] Second\n";
4797 let (_, resolved) = op_resolve_gate(body, "release");
4798 assert_eq!(resolved, vec!["a1b2", "c3d4"]);
4799 }
4800
4801 #[test]
4802 fn op_set_gate_type_on_gated() {
4803 let body = "- [/] [#a1b2] Release v0.32.4\n";
4804 let new_body = op_set_gate_type(body, "a1b2", "release").unwrap();
4805 assert!(new_body.contains("[/release]"));
4806 let (_, items, _) = parse_items(&new_body);
4807 assert_eq!(items[0].gate_type, Some("release".to_string()));
4808 }
4809
4810 #[test]
4811 fn op_set_gate_type_errors_on_open() {
4812 let body = "- [ ] [#a1b2] task\n";
4813 assert!(op_set_gate_type(body, "a1b2", "release").is_err());
4814 }
4815
4816 #[test]
4817 fn op_set_gate_verify_round_trips_predicate() {
4818 let body = "- [/] [#saev] early-ack live verify\n";
4819 let new_body = op_set_gate_verify(
4820 body,
4821 "saev",
4822 "verify=ops_log:early_ack_pending;disproof=false ack-timeout",
4823 1749526200,
4824 )
4825 .unwrap();
4826 let (_, items, _) = parse_items(&new_body);
4827 assert_eq!(items[0].state, PendingState::Gated);
4829 assert_eq!(items[0].gate_type, None);
4830 let pred = crate::gate_verify::parse_gate_predicate(&items[0].text).unwrap();
4831 assert_eq!(pred.verify.as_deref(), Some("early_ack_pending"));
4832 assert_eq!(pred.disproof.as_deref(), Some("false ack-timeout"));
4833 assert_eq!(pred.set_at, Some(1749526200));
4834 }
4835
4836 #[test]
4837 fn op_set_gate_verify_errors_on_open() {
4838 let body = "- [ ] [#a1b2] task\n";
4839 assert!(op_set_gate_verify(body, "a1b2", "verify=ops_log:m", 1).is_err());
4840 }
4841
4842 #[test]
4843 fn op_set_gate_verify_errors_on_empty_spec() {
4844 let body = "- [/] [#a1b2] task\n";
4845 assert!(op_set_gate_verify(body, "a1b2", "noop=1", 1).is_err());
4846 }
4847
4848 #[test]
4849 fn op_set_gate_type_errors_on_done() {
4850 let body = "- [x] [#a1b2] task\n";
4851 assert!(op_set_gate_type(body, "a1b2", "release").is_err());
4852 }
4853
4854 #[test]
4855 fn op_set_gate_type_replaces_existing() {
4856 let body = "- [/release] [#a1b2] task\n";
4857 let new_body = op_set_gate_type(body, "a1b2", "deploy").unwrap();
4858 assert!(new_body.contains("[/deploy]"));
4859 assert!(!new_body.contains("[/release]"));
4860 }
4861
4862 #[test]
4863 fn parse_typed_gate_case_insensitive() {
4864 let body = "- [/Release] [#a1b2] task\n";
4865 let (_, items, _) = parse_items(body);
4866 assert_eq!(items[0].gate_type, Some("release".to_string()));
4867 }
4868
4869 #[test]
4870 fn parse_typed_gate_with_hyphens_underscores() {
4871 let body =
4872 "- [/code-review] [#a1b2] Review PR\n- [/pre_release] [#c3d4] Pre-release check\n";
4873 let (_, items, _) = parse_items(body);
4874 assert_eq!(items[0].gate_type, Some("code-review".to_string()));
4875 assert_eq!(items[1].gate_type, Some("pre_release".to_string()));
4876 }
4877
4878 #[test]
4879 fn detect_shadow_open_items_classifies_duplicate_and_shadow_only_ids() {
4880 let doc = concat!(
4881 "## Pending / Not Built\n\n",
4882 "<!-- agent:backlog -->\n",
4883 "- [ ] [#live1] Keep in backlog\n",
4884 "- [/] [#gate1] Gated live item\n",
4885 "<!-- /agent:backlog -->\n\n",
4886 "<!-- parked copy\n",
4887 "- [ ] [#live1] Keep in backlog\n",
4888 "- [ ] [#lost1] Drifted out of backlog\n",
4889 "- [x] [#done1] Already done\n",
4890 "-->\n"
4891 );
4892
4893 let report = detect_shadow_open_items(doc).unwrap();
4894 assert_eq!(
4895 report
4896 .duplicated_in_live_backlog
4897 .iter()
4898 .map(|item| item.id.as_str())
4899 .collect::<Vec<_>>(),
4900 vec!["live1"]
4901 );
4902 assert_eq!(
4903 report
4904 .shadow_only
4905 .iter()
4906 .map(|item| item.id.as_str())
4907 .collect::<Vec<_>>(),
4908 vec!["lost1"]
4909 );
4910 }
4911
4912 #[test]
4913 fn detect_shadow_open_items_ignores_agent_queue_id_heads() {
4914 let doc = concat!(
4920 "<!-- agent:backlog -->\n",
4921 "- [ ] [#live1] Keep in backlog\n",
4922 "<!-- /agent:backlog -->\n\n",
4923 "<!-- agent:queue -->\n",
4924 "- do [#live1]\n",
4925 "- [#reaped1]: 0.2.170 installed\n",
4926 "- do [#reaped2]\n",
4927 "<!-- /agent:queue -->\n\n",
4928 "<!-- parked copy\n",
4929 "- [ ] [#lost1] Drifted out of backlog\n",
4930 "-->\n",
4931 );
4932
4933 let report = detect_shadow_open_items(doc).unwrap();
4934 assert!(
4936 report
4937 .shadow_only
4938 .iter()
4939 .all(|item| item.id != "reaped1" && item.id != "reaped2"),
4940 "reaped/mirrored queue id heads must not be shadow_only: {:?}",
4941 report.shadow_only
4942 );
4943 assert!(
4944 report.duplicated_in_live_backlog.is_empty(),
4945 "queue is excluded from the scan, so the mirrored do [#live1] head is not a duplicate: {:?}",
4946 report.duplicated_in_live_backlog
4947 );
4948 assert_eq!(
4950 report
4951 .shadow_only
4952 .iter()
4953 .map(|item| item.id.as_str())
4954 .collect::<Vec<_>>(),
4955 vec!["lost1"]
4956 );
4957 }
4958
4959 #[test]
4960 fn detect_shadow_open_items_ignores_indented_nested_ids() {
4961 let doc = concat!(
4962 "<!-- agent:backlog -->\n",
4963 "- [ ] [#live1] Parent task\n",
4964 " - [ ] [#nested1] Nested checklist item\n",
4965 "<!-- /agent:backlog -->\n"
4966 );
4967
4968 let report = detect_shadow_open_items(doc).unwrap();
4969 assert!(report.duplicated_in_live_backlog.is_empty());
4970 assert!(report.shadow_only.is_empty());
4971 }
4972
4973 #[test]
4974 fn extract_items_by_id_preserves_nested_subtasks() {
4975 let body = concat!(
4976 "### Active\n",
4977 "- [ ] [#move1] Parent task\n",
4978 " - child dependency\n",
4979 "- [ ] [#keep1] Keep task\n"
4980 );
4981 let (remaining, moved, matched) =
4982 extract_items_by_id(body, &["move1".to_string()]).unwrap();
4983 assert_eq!(matched, vec!["move1".to_string()]);
4984 assert!(remaining.contains("### Active\n"));
4985 assert!(!remaining.contains("[#move1]"));
4986 assert!(remaining.contains("[#keep1] Keep task"));
4987 assert_eq!(
4988 moved,
4989 concat!("- [ ] [#move1] Parent task\n", " - child dependency\n")
4990 );
4991 }
4992
4993 #[test]
4994 fn extract_items_by_id_preserves_ordered_list_style() {
4995 let body = concat!(
4996 "1. [ ] [#move1] Parent task\n",
4997 "2. [ ] [#keep1] Keep task\n",
4998 "3. [ ] [#keep2] Keep task two\n"
4999 );
5000 let (remaining, moved, matched) =
5001 extract_items_by_id(body, &["move1".to_string()]).unwrap();
5002 assert_eq!(matched, vec!["move1".to_string()]);
5003 assert_eq!(moved, "1. [ ] [#move1] Parent task\n");
5004 assert_eq!(
5005 remaining,
5006 concat!(
5007 "1. [ ] [#keep1] Keep task\n",
5008 "2. [ ] [#keep2] Keep task two\n"
5009 )
5010 );
5011 }
5012
5013 #[test]
5014 fn detect_shadow_open_items_ignores_icebox_and_code_blocks() {
5015 let doc = concat!(
5016 "## Pending / Not Built\n\n",
5017 "<!-- agent:backlog -->\n",
5018 "- [ ] [#live1] Keep in backlog\n",
5019 "<!-- /agent:backlog -->\n\n",
5020 "<!-- agent:icebox -->\n",
5021 "- [ ] [#cold1] Intentionally parked\n",
5022 "<!-- /agent:icebox -->\n\n",
5023 "```md\n",
5024 "- [ ] [#code1] Example only\n",
5025 "```\n"
5026 );
5027
5028 let report = detect_shadow_open_items(doc).unwrap();
5029 assert!(report.duplicated_in_live_backlog.is_empty());
5030 assert!(report.shadow_only.is_empty());
5031 }
5032
5033 #[test]
5034 fn detect_shadow_open_items_ignores_exchange_transcript_items() {
5035 let doc = concat!(
5036 "## Exchange\n\n",
5037 "<!-- agent:exchange patch=append -->\n",
5038 "โฏ What are #next-steps to implement the planned ipc features?\n\n",
5039 "### Re: What are #next-steps to implement the planned ipc features?\n\n",
5040 "1. [#ipc1] finalize the lazily-serde contract so message shapes are stable.\n\n",
5041 "### Session Summary\n\n",
5042 "*Compacted.*\n\n",
5043 "Icebox:\n",
5044 "- [ ] [#cold1] Intentionally parked\n",
5045 "- [ ] [#cold2] Still parked\n\n",
5046 "โฏ #code-review\n",
5047 "<!-- agent:boundary:test -->\n",
5048 "<!-- /agent:exchange -->\n\n",
5049 "<!-- agent:backlog -->\n",
5050 "<!-- /agent:backlog -->\n\n",
5051 "<!-- agent:icebox -->\n",
5052 "- [ ] [#ipc2] Live backlog item\n",
5053 "- [ ] [#cold1] Intentionally parked\n",
5054 "- [ ] [#cold2] Still parked\n",
5055 "<!-- /agent:icebox -->\n"
5056 );
5057
5058 let report = detect_shadow_open_items(doc).unwrap();
5059 assert!(report.duplicated_in_live_backlog.is_empty());
5060 assert!(report.shadow_only.is_empty());
5061 }
5062
5063 #[test]
5064 fn detect_dropped_from_history_catches_missing_item() {
5065 let baseline = concat!(
5066 "<!-- agent:backlog -->\n",
5067 "- [ ] [#keep1] Still here\n",
5068 "- [ ] [#gone1] Was open in baseline\n",
5069 "- [ ] [#gone2] Also open in baseline\n",
5070 "<!-- /agent:backlog -->\n"
5071 );
5072 let current = concat!(
5073 "<!-- agent:backlog -->\n",
5074 "- [ ] [#keep1] Still here\n",
5075 "<!-- /agent:backlog -->\n"
5076 );
5077 let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
5078 let ids: Vec<&str> = report.dropped.iter().map(|i| i.id.as_str()).collect();
5079 assert_eq!(ids, vec!["gone1", "gone2"]);
5080 }
5081
5082 #[test]
5083 fn detect_dropped_from_history_allows_done_in_live() {
5084 let baseline = concat!(
5085 "<!-- agent:backlog -->\n",
5086 "- [ ] [#item1] Was open\n",
5087 "<!-- /agent:backlog -->\n"
5088 );
5089 let current = concat!(
5090 "<!-- agent:backlog -->\n",
5091 "- [x] [#item1] Now done\n",
5092 "<!-- /agent:backlog -->\n"
5093 );
5094 let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
5095 assert!(report.dropped.is_empty());
5096 }
5097
5098 #[test]
5099 fn detect_dropped_from_history_allows_done_ids() {
5100 let baseline = concat!(
5101 "<!-- agent:backlog -->\n",
5102 "- [ ] [#item1] Was open\n",
5103 "<!-- /agent:backlog -->\n"
5104 );
5105 let current = concat!("<!-- agent:backlog -->\n", "<!-- /agent:backlog -->\n");
5106 let mut done = HashSet::new();
5107 done.insert("item1".to_string());
5108 let report = detect_dropped_from_history(current, baseline, &done).unwrap();
5109 assert!(report.dropped.is_empty());
5110 }
5111
5112 #[test]
5113 fn detect_dropped_from_history_allows_completed_archive_id() {
5114 let baseline = concat!(
5115 "<!-- agent:backlog -->\n",
5116 "- [ ] [#item1] Was open\n",
5117 "<!-- /agent:backlog -->\n"
5118 );
5119 let current = concat!(
5120 "<!-- agent:backlog -->\n",
5121 "<!-- /agent:backlog -->\n\n",
5122 "<!-- agent:done -->\n",
5123 "- 2026-05-10 [#item1] Was open\n",
5124 "<!-- /agent:done -->\n"
5125 );
5126 let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
5127 assert!(report.dropped.is_empty());
5128 }
5129
5130 #[test]
5131 fn detect_dropped_from_history_rejects_removed_completed_archive_alias() {
5132 let baseline = concat!(
5133 "<!-- agent:backlog -->\n",
5134 "- [ ] [#item1] Was open\n",
5135 "<!-- /agent:backlog -->\n"
5136 );
5137 let current = concat!(
5138 "<!-- agent:backlog -->\n",
5139 "<!-- /agent:backlog -->\n\n",
5140 "<!-- agent:pending-done -->\n",
5141 "- 2026-05-10 [#item1] Was open\n",
5142 "<!-- /agent:pending-done -->\n"
5143 );
5144 let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
5145 assert_eq!(report.dropped.len(), 1);
5146 assert_eq!(report.dropped[0].id, "item1");
5147 }
5148
5149 #[test]
5150 fn detect_dropped_from_history_allows_icebox() {
5151 let baseline = concat!(
5152 "<!-- agent:backlog -->\n",
5153 "- [ ] [#item1] Was open\n",
5154 "<!-- /agent:backlog -->\n"
5155 );
5156 let current = concat!(
5157 "<!-- agent:backlog -->\n",
5158 "<!-- /agent:backlog -->\n",
5159 "<!-- agent:icebox -->\n",
5160 "- [ ] [#item1] Archived\n",
5161 "<!-- /agent:icebox -->\n"
5162 );
5163 let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
5164 assert!(report.dropped.is_empty());
5165 }
5166
5167 #[test]
5168 fn detect_dropped_from_history_allows_shadow() {
5169 let baseline = concat!(
5170 "<!-- agent:backlog -->\n",
5171 "- [ ] [#item1] Was open\n",
5172 "<!-- /agent:backlog -->\n"
5173 );
5174 let current = concat!(
5175 "<!-- agent:backlog -->\n",
5176 "<!-- /agent:backlog -->\n\n",
5177 "<!-- parked\n",
5178 "- [ ] [#item1] Drifted to shadow\n",
5179 "-->\n"
5180 );
5181 let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
5182 assert!(report.dropped.is_empty());
5183 }
5184
5185 #[test]
5186 fn detect_dropped_from_history_ignores_baseline_done_items() {
5187 let baseline = concat!(
5188 "<!-- agent:backlog -->\n",
5189 "- [x] [#done1] Already done in baseline\n",
5190 "- [/] [#gate1] Gated in baseline\n",
5191 "<!-- /agent:backlog -->\n"
5192 );
5193 let current = concat!("<!-- agent:backlog -->\n", "<!-- /agent:backlog -->\n");
5194 let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
5195 let ids: Vec<&str> = report.dropped.iter().map(|i| i.id.as_str()).collect();
5196 assert_eq!(ids, vec!["gate1"]);
5197 }
5198
5199 #[test]
5200 fn detect_dropped_from_history_no_baseline_backlog() {
5201 let baseline = "# Just a document\nNo backlog here.\n";
5202 let current = concat!(
5203 "<!-- agent:backlog -->\n",
5204 "- [ ] [#item1] New item\n",
5205 "<!-- /agent:backlog -->\n"
5206 );
5207 let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
5208 assert!(report.dropped.is_empty());
5209 }
5210
5211 #[test]
5212 fn detect_dropped_from_history_ignores_code_blocks_in_current() {
5213 let baseline = concat!(
5214 "<!-- agent:backlog -->\n",
5215 "- [ ] [#item1] Was open\n",
5216 "<!-- /agent:backlog -->\n"
5217 );
5218 let current = concat!(
5219 "<!-- agent:backlog -->\n",
5220 "<!-- /agent:backlog -->\n\n",
5221 "```md\n",
5222 "- [ ] [#item1] In code block only\n",
5223 "```\n"
5224 );
5225 let report = detect_dropped_from_history(current, baseline, &HashSet::new()).unwrap();
5226 let ids: Vec<&str> = report.dropped.iter().map(|i| i.id.as_str()).collect();
5227 assert_eq!(ids, vec!["item1"]);
5228 }
5229}