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