1use anyhow::{Result, bail};
69use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
70use std::collections::HashMap;
71
72pub const BACKLOG_COMPONENT: &str = "backlog";
74
75pub const BACKLOG_ALIAS: &str = "pending";
77
78pub const BACKLOG_DONE_COMPONENT: &str = "done";
80
81pub const REVIEW_COMPONENT: &str = "review";
83
84pub const ICEBOX_COMPONENT: &str = "icebox";
86
87const EXCHANGE_COMPONENT: &str = "exchange";
89
90pub fn is_backlog_component(name: &str) -> bool {
93 name == BACKLOG_COMPONENT || name == BACKLOG_ALIAS
94}
95
96pub fn is_backlog_done_component(name: &str) -> bool {
98 name == BACKLOG_DONE_COMPONENT
99}
100
101pub fn is_review_component(name: &str) -> bool {
103 name == REVIEW_COMPONENT
104}
105
106pub fn is_icebox_component(name: &str) -> bool {
108 name == ICEBOX_COMPONENT
109}
110
111pub fn is_tracked_work_component(name: &str) -> bool {
115 is_backlog_component(name) || is_review_component(name) || is_icebox_component(name)
116}
117
118pub fn strip_backlog_patch_attr(doc: &str) -> String {
125 let mut result = doc.to_string();
126 let mut warned = false;
127
128 for name in &[BACKLOG_COMPONENT, BACKLOG_ALIAS] {
129 let prefix = format!("<!-- agent:{} ", name);
130 while let Some(start) = result.find(&prefix) {
131 let tag_start = start;
132 let rest = &result[start + prefix.len()..];
133 let Some(end_rel) = rest.find("-->") else {
134 break;
135 };
136 let attrs_text = &rest[..end_rel];
137 let tag_end = start + prefix.len() + end_rel + 3; let tokens: Vec<&str> = attrs_text.split_whitespace().collect();
140 let has_patch_or_mode = tokens.iter().any(|t| {
141 t.split_once('=')
142 .map(|(k, _)| k == "patch" || k == "mode")
143 .unwrap_or(false)
144 });
145
146 if !has_patch_or_mode {
147 break;
148 }
149
150 if !warned {
151 eprintln!(
152 "[deprecation] patch/mode attribute on agent:{} is deprecated and will be stripped — \
153 the binary owns backlog mutations via --pending-* flags",
154 name
155 );
156 warned = true;
157 }
158
159 let remaining: Vec<&str> = tokens
160 .iter()
161 .filter(|t| {
162 t.split_once('=')
163 .map(|(k, _)| k != "patch" && k != "mode")
164 .unwrap_or(true)
165 })
166 .copied()
167 .collect();
168
169 let new_tag = if remaining.is_empty() {
170 format!("<!-- agent:{} -->", name)
171 } else {
172 format!("<!-- agent:{} {} -->", name, remaining.join(" "))
173 };
174
175 result.replace_range(tag_start..tag_end, &new_tag);
176 }
177 }
178
179 result
180}
181
182#[derive(Debug, Clone, PartialEq, Eq)]
187pub struct Component {
188 pub name: String,
189 pub attrs: HashMap<String, String>,
191 pub open_start: usize,
193 pub open_end: usize,
195 pub close_start: usize,
197 pub close_end: usize,
199}
200
201impl Component {
202 #[allow(dead_code)] pub fn content<'a>(&self, doc: &'a str) -> &'a str {
205 &doc[self.open_end..self.close_start]
206 }
207
208 pub fn patch_mode(&self) -> Option<&str> {
212 self.attrs
213 .get("patch")
214 .map(|s| s.as_str())
215 .or_else(|| self.attrs.get("mode").map(|s| s.as_str()))
216 }
217
218 pub fn replace_content(&self, doc: &str, new_content: &str) -> String {
221 let mut result = String::with_capacity(doc.len() + new_content.len());
222 result.push_str(&doc[..self.open_end]);
223 result.push_str(new_content);
224 result.push_str(&doc[self.close_start..]);
225 result
226 }
227
228 pub fn append_with_caret(
235 &self,
236 doc: &str,
237 content: &str,
238 caret_offset: Option<usize>,
239 ) -> String {
240 let existing = &doc[self.open_end..self.close_start];
241 if append_patch_already_present(existing, content) {
242 return doc.to_string();
243 }
244
245 if let Some(caret) = caret_offset {
246 if caret > self.open_end && caret <= self.close_start {
248 let insert_at = doc[..caret]
250 .rfind('\n')
251 .map(|i| i + 1)
252 .unwrap_or(self.open_end);
253
254 let insert_at = insert_at.max(self.open_end);
256 let prior = &doc[self.open_end..insert_at];
257
258 let mut result = String::with_capacity(doc.len() + content.len() + 1);
259 result.push_str(&doc[..insert_at]);
260 if needs_exchange_heading_separator(&self.name, prior, content) {
261 result.push('\n');
262 }
263 result.push_str(content.trim_end());
264 result.push('\n');
265 result.push_str(&doc[insert_at..]);
266 return result;
267 }
268 }
269
270 let mut result = String::with_capacity(doc.len() + content.len() + 1);
272 result.push_str(&doc[..self.open_end]);
273 let trimmed_existing = existing.trim_end();
274 result.push_str(trimmed_existing);
275 if needs_exchange_heading_separator(&self.name, trimmed_existing, content) {
276 result.push('\n');
277 }
278 result.push('\n');
279 result.push_str(content.trim_end());
280 result.push('\n');
281 result.push_str(&doc[self.close_start..]);
282 result
283 }
284
285 pub fn append_with_boundary(&self, doc: &str, content: &str, boundary_id: &str) -> String {
291 let boundary_marker = format!("<!-- agent:boundary:{} -->", boundary_id);
292 let content_region = &doc[self.open_end..self.close_start];
293 if append_patch_already_present(content_region, content) {
294 return doc.to_string();
295 }
296 let code_ranges = find_code_ranges(doc);
297
298 let mut search_from = 0;
300 let found_pos = loop {
301 match content_region[search_from..].find(&boundary_marker) {
302 Some(rel_pos) => {
303 let abs_pos = self.open_end + search_from + rel_pos;
304 if code_ranges
305 .iter()
306 .any(|&(cs, ce)| abs_pos >= cs && abs_pos < ce)
307 {
308 search_from += rel_pos + boundary_marker.len();
310 continue;
311 }
312 break Some(abs_pos);
313 }
314 None => break None,
315 }
316 };
317
318 if let Some(abs_pos) = found_pos {
319 let line_start = doc[..abs_pos]
321 .rfind('\n')
322 .map(|i| i + 1)
323 .unwrap_or(self.open_end)
324 .max(self.open_end);
325
326 let marker_end = abs_pos + boundary_marker.len();
328 let line_end = if marker_end < self.close_start
329 && doc.as_bytes().get(marker_end) == Some(&b'\n')
330 {
331 marker_end + 1
332 } else {
333 marker_end
334 };
335 let line_end = line_end.min(self.close_start);
336
337 let new_id = crate::id::new_boundary_id();
341 let new_marker = crate::id::format_boundary_marker(&new_id);
342 let mut result = String::with_capacity(doc.len() + content.len() + new_marker.len());
343 let prior = &doc[self.open_end..line_start];
344 result.push_str(&doc[..line_start]);
345 if needs_exchange_heading_separator(&self.name, prior, content) {
346 result.push('\n');
347 }
348 result.push_str(content.trim_end());
349 result.push('\n');
350 result.push_str(&new_marker);
351 result.push('\n');
352 result.push_str(&doc[line_end..]);
353 return result;
354 }
355
356 self.append_with_caret(doc, content, None)
358 }
359}
360
361fn is_valid_name(name: &str) -> bool {
363 if name.is_empty() {
364 return false;
365 }
366 let first = name.as_bytes()[0];
367 if !first.is_ascii_alphanumeric() {
368 return false;
369 }
370 name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
371}
372
373fn append_patch_already_present(existing: &str, content: &str) -> bool {
374 let patch = normalize_append_patch_content(content);
375 if patch.is_empty() {
376 return false;
377 }
378 normalize_append_patch_content(existing).contains(&patch)
379}
380
381fn needs_exchange_heading_separator(component_name: &str, prior: &str, content: &str) -> bool {
382 component_name == EXCHANGE_COMPONENT
383 && !prior.trim().is_empty()
384 && !prior.ends_with("\n\n")
385 && starts_with_response_heading(content)
386}
387
388fn starts_with_response_heading(content: &str) -> bool {
389 content.trim_start().starts_with("### Re:")
390}
391
392fn normalize_append_patch_content(content: &str) -> String {
393 content
394 .lines()
395 .filter_map(|line| {
396 let trimmed = line.trim();
397 if trimmed.starts_with("<!-- agent:boundary:") && trimmed.ends_with(" -->") {
398 None
399 } else if markdown_heading_has_head_marker(line) {
400 Some(line.trim_end_matches(" (HEAD)").to_string())
401 } else {
402 Some(line.to_string())
403 }
404 })
405 .collect::<Vec<_>>()
406 .join("\n")
407 .trim()
408 .to_string()
409}
410
411fn markdown_heading_has_head_marker(line: &str) -> bool {
412 let trimmed = line.trim_start();
413 let hashes = trimmed.bytes().take_while(|b| *b == b'#').count();
414 (1..=6).contains(&hashes)
415 && trimmed.as_bytes().get(hashes) == Some(&b' ')
416 && line.ends_with(" (HEAD)")
417}
418
419pub fn is_agent_marker(comment_text: &str) -> bool {
423 let trimmed = comment_text.trim();
424 if let Some(rest) = trimmed.strip_prefix("/agent:") {
425 is_valid_name(rest)
426 } else if let Some(rest) = trimmed.strip_prefix("agent:") {
427 let name_part = rest.split_whitespace().next().unwrap_or("");
429 is_valid_name(name_part)
430 } else {
431 false
432 }
433}
434
435fn bounded_preview(doc: &str, start: usize, max_bytes: usize) -> &str {
436 debug_assert!(doc.is_char_boundary(start));
437 let mut end = doc.len().min(start.saturating_add(max_bytes));
438 while end > start && !doc.is_char_boundary(end) {
439 end -= 1;
440 }
441 &doc[start..end]
442}
443
444pub fn parse_attrs(attr_text: &str) -> HashMap<String, String> {
449 let mut attrs = HashMap::new();
450 for token in attr_text.split_whitespace() {
451 if let Some((key, value)) = token.split_once('=') {
452 if !key.is_empty() && !value.is_empty() {
453 attrs.insert(key.to_string(), value.to_string());
454 }
455 } else if !token.is_empty() {
456 attrs.insert(token.to_string(), String::new());
457 }
458 }
459 attrs
460}
461
462pub fn find_code_ranges(doc: &str) -> Vec<(usize, usize)> {
468 let t = std::time::Instant::now();
469 let mut ranges = Vec::new();
470 let parser = Parser::new_ext(doc, Options::empty());
471 let mut iter = parser.into_offset_iter();
472 while let Some((event, range)) = iter.next() {
473 match event {
474 Event::Code(_) => {
476 ranges.push((range.start, range.end));
477 }
478 Event::Start(Tag::CodeBlock(_)) => {
480 let block_start = range.start;
481 let mut block_end = range.end;
482 for (inner_event, inner_range) in iter.by_ref() {
483 block_end = inner_range.end;
484 if matches!(inner_event, Event::End(TagEnd::CodeBlock)) {
485 break;
486 }
487 }
488 ranges.push((block_start, block_end));
489 }
490 _ => {}
491 }
492 }
493 let elapsed = t.elapsed().as_millis();
494 if elapsed > 0 {
495 eprintln!("[perf] find_code_ranges: {}ms", elapsed);
496 }
497 ranges
498}
499
500pub fn find_non_agent_html_comment_ranges(doc: &str) -> Vec<(usize, usize)> {
509 let code_ranges = find_code_ranges(doc);
510 let in_code = |pos: usize| {
511 code_ranges
512 .iter()
513 .any(|&(start, end)| pos >= start && pos < end)
514 };
515
516 let bytes = doc.as_bytes();
517 let len = bytes.len();
518 let mut ranges = Vec::new();
519 let mut pos = 0usize;
520 while pos + 4 <= len {
521 if &bytes[pos..pos + 4] != b"<!--" {
522 pos += 1;
523 continue;
524 }
525 if in_code(pos) {
526 pos += 4;
527 continue;
528 }
529 let Some(end) = find_comment_end(bytes, pos + 4) else {
530 let inner = &doc[pos + 4..];
531 if !is_agent_marker(inner) && !inner.trim_start().starts_with("agent:boundary:") {
532 ranges.push((pos, len));
533 break;
534 }
535 pos += 4;
536 continue;
537 };
538 let inner = &doc[pos + 4..end - 3];
539 if !is_agent_marker(inner) && !inner.trim().starts_with("agent:boundary:") {
540 ranges.push((pos, end));
541 }
542 pos = end;
543 }
544 ranges
545}
546
547pub fn parse(doc: &str) -> Result<Vec<Component>> {
553 let bytes = doc.as_bytes();
554 let len = bytes.len();
555 let code_ranges = find_code_ranges(doc);
556 let mut templates: Vec<Component> = Vec::new();
557 let mut stack: Vec<(String, HashMap<String, String>, usize, usize)> = Vec::new();
559 let mut pos = 0;
560
561 while pos + 4 <= len {
562 if &bytes[pos..pos + 4] != b"<!--" {
564 pos += 1;
565 continue;
566 }
567
568 if code_ranges
570 .iter()
571 .any(|&(start, end)| pos >= start && pos < end)
572 {
573 pos += 4;
574 continue;
575 }
576
577 let peek_start = pos + 4;
582 let peek = bounded_preview(doc, peek_start, 20);
583 let peek_trimmed = peek.trim_start();
584 if !peek_trimmed.starts_with("agent:") && !peek_trimmed.starts_with("/agent:") {
585 pos += 1;
586 continue;
587 }
588
589 let marker_start = pos;
590
591 let close = match find_comment_end(bytes, pos + 4) {
593 Some(c) => c,
594 None => {
595 pos += 4;
596 continue;
597 }
598 };
599
600 let inner = &doc[marker_start + 4..close - 3]; let trimmed = inner.trim();
603
604 let mut marker_end = close;
606 if marker_end < len && bytes[marker_end] == b'\n' {
607 marker_end += 1;
608 }
609
610 if let Some(name) = trimmed.strip_prefix("/agent:") {
611 if !is_valid_name(name) {
613 bail!("invalid component name: '{}'", name);
614 }
615 match stack.pop() {
616 Some((open_name, open_attrs, open_start, open_end)) => {
617 if open_name != name {
618 bail!(
619 "mismatched component: opened '{}' but closed '{}'",
620 open_name,
621 name
622 );
623 }
624 templates.push(Component {
625 name: name.to_string(),
626 attrs: open_attrs,
627 open_start,
628 open_end,
629 close_start: marker_start,
630 close_end: marker_end,
631 });
632 }
633 None => bail!(
634 "closing marker <!-- /agent:{} --> without matching open",
635 name
636 ),
637 }
638 } else if let Some(rest) = trimmed.strip_prefix("agent:") {
639 if rest.starts_with("boundary:") {
641 pos = close;
642 continue;
643 }
644 let mut parts = rest.splitn(2, |c: char| c.is_whitespace());
646 let name = parts.next().unwrap_or("");
647 let attr_text = parts.next().unwrap_or("");
648 if !is_valid_name(name) {
649 bail!("invalid component name: '{}'", name);
650 }
651 let attrs = parse_attrs(attr_text);
652 stack.push((name.to_string(), attrs, marker_start, marker_end));
653 }
654
655 pos = close;
656 }
657
658 if let Some((name, _, _, _)) = stack.last() {
659 bail!(
660 "unclosed component: <!-- agent:{} --> without matching close",
661 name
662 );
663 }
664
665 templates.sort_by_key(|t| t.open_start);
666 Ok(templates)
667}
668
669pub fn find_comment_end(bytes: &[u8], start: usize) -> Option<usize> {
671 let len = bytes.len();
672 let mut i = start;
673 while i + 3 <= len {
674 if &bytes[i..i + 3] == b"-->" {
675 return Some(i + 3);
676 }
677 i += 1;
678 }
679 None
680}
681
682pub fn strip_comments(content: &str) -> String {
695 let code_ranges = find_code_ranges(content);
696 let in_code = |pos: usize| {
697 code_ranges
698 .iter()
699 .any(|&(start, end)| pos >= start && pos < end)
700 };
701
702 let mut result = String::with_capacity(content.len());
703 let bytes = content.as_bytes();
704 let len = bytes.len();
705 let mut pos = 0;
706
707 while pos < len {
708 if bytes[pos] == b'['
710 && !in_code(pos)
711 && is_line_start_at(bytes, pos)
712 && let Some(end) = match_link_ref_comment_at(bytes, pos)
713 {
714 pos = end;
715 continue;
716 }
717
718 if pos + 4 <= len
720 && &bytes[pos..pos + 4] == b"<!--"
721 && !in_code(pos)
722 && let Some((end, inner)) = match_html_comment_at(content, pos)
723 {
724 if is_agent_marker(inner) {
725 result.push_str(&content[pos..end]);
727 pos = end;
728 } else {
729 let mut skip_to = end;
731 if is_line_start_at(bytes, pos) && skip_to < len && bytes[skip_to] == b'\n' {
732 skip_to += 1;
733 }
734 pos = skip_to;
735 }
736 continue;
737 }
738
739 result.push(content[pos..].chars().next().unwrap());
740 pos += content[pos..].chars().next().unwrap().len_utf8();
741 }
742
743 result
744}
745
746fn is_line_start_at(bytes: &[u8], pos: usize) -> bool {
748 pos == 0 || bytes[pos - 1] == b'\n'
749}
750
751fn match_link_ref_comment_at(bytes: &[u8], pos: usize) -> Option<usize> {
753 let prefix = b"[//]: # (";
754 let len = bytes.len();
755 if pos + prefix.len() > len || &bytes[pos..pos + prefix.len()] != prefix {
756 return None;
757 }
758 let mut i = pos + prefix.len();
759 while i < len && bytes[i] != b')' && bytes[i] != b'\n' {
760 i += 1;
761 }
762 if i < len && bytes[i] == b')' {
763 i += 1;
764 if i < len && bytes[i] == b'\n' {
765 i += 1;
766 }
767 Some(i)
768 } else {
769 None
770 }
771}
772
773fn match_html_comment_at(content: &str, pos: usize) -> Option<(usize, &str)> {
775 let bytes = content.as_bytes();
776 let len = bytes.len();
777 let mut i = pos + 4;
778 while i + 3 <= len {
779 if &bytes[i..i + 3] == b"-->" {
780 let inner = &content[pos + 4..i];
781 return Some((i + 3, inner));
782 }
783 i += 1;
784 }
785 None
786}
787
788#[cfg(test)]
789mod tests {
790 use super::*;
791
792 #[test]
793 fn single_range() {
794 let doc = "before\n<!-- agent:status -->\nHello\n<!-- /agent:status -->\nafter\n";
795 let ranges = parse(doc).unwrap();
796 assert_eq!(ranges.len(), 1);
797 assert_eq!(ranges[0].name, "status");
798 assert_eq!(ranges[0].content(doc), "Hello\n");
799 }
800
801 #[test]
802 fn nested_ranges() {
803 let doc = "\
804<!-- agent:outer -->
805<!-- agent:inner -->
806content
807<!-- /agent:inner -->
808<!-- /agent:outer -->
809";
810 let ranges = parse(doc).unwrap();
811 assert_eq!(ranges.len(), 2);
812 assert_eq!(ranges[0].name, "outer");
814 assert_eq!(ranges[1].name, "inner");
815 assert_eq!(ranges[1].content(doc), "content\n");
816 }
817
818 #[test]
819 fn siblings() {
820 let doc = "\
821<!-- agent:a -->
822alpha
823<!-- /agent:a -->
824<!-- agent:b -->
825beta
826<!-- /agent:b -->
827";
828 let ranges = parse(doc).unwrap();
829 assert_eq!(ranges.len(), 2);
830 assert_eq!(ranges[0].name, "a");
831 assert_eq!(ranges[0].content(doc), "alpha\n");
832 assert_eq!(ranges[1].name, "b");
833 assert_eq!(ranges[1].content(doc), "beta\n");
834 }
835
836 #[test]
837 fn no_ranges() {
838 let doc = "# Just a document\n\nWith no range templates.\n";
839 let ranges = parse(doc).unwrap();
840 assert!(ranges.is_empty());
841 }
842
843 #[test]
844 fn unmatched_open_error() {
845 let doc = "<!-- agent:orphan -->\nContent\n";
846 let err = parse(doc).unwrap_err();
847 assert!(err.to_string().contains("unclosed component"));
848 }
849
850 #[test]
851 fn unmatched_close_error() {
852 let doc = "Content\n<!-- /agent:orphan -->\n";
853 let err = parse(doc).unwrap_err();
854 assert!(err.to_string().contains("without matching open"));
855 }
856
857 #[test]
858 fn mismatched_names_error() {
859 let doc = "<!-- agent:foo -->\n<!-- /agent:bar -->\n";
860 let err = parse(doc).unwrap_err();
861 assert!(err.to_string().contains("mismatched"));
862 }
863
864 #[test]
865 fn invalid_name() {
866 let doc = "<!-- agent:-bad -->\n<!-- /agent:-bad -->\n";
867 let err = parse(doc).unwrap_err();
868 assert!(err.to_string().contains("invalid component name"));
869 }
870
871 #[test]
872 fn name_validation() {
873 assert!(is_valid_name("status"));
874 assert!(is_valid_name("my-section"));
875 assert!(is_valid_name("a1"));
876 assert!(is_valid_name("A"));
877 assert!(!is_valid_name(""));
878 assert!(!is_valid_name("-bad"));
879 assert!(!is_valid_name("has space"));
880 assert!(!is_valid_name("has_underscore"));
881 }
882
883 #[test]
884 fn content_extraction() {
885 let doc = "<!-- agent:x -->\nfoo\nbar\n<!-- /agent:x -->\n";
886 let ranges = parse(doc).unwrap();
887 assert_eq!(ranges[0].content(doc), "foo\nbar\n");
888 }
889
890 #[test]
891 fn replace_roundtrip() {
892 let doc = "before\n<!-- agent:s -->\nold\n<!-- /agent:s -->\nafter\n";
893 let ranges = parse(doc).unwrap();
894 let new_doc = ranges[0].replace_content(doc, "new\n");
895 assert_eq!(
896 new_doc,
897 "before\n<!-- agent:s -->\nnew\n<!-- /agent:s -->\nafter\n"
898 );
899 let ranges2 = parse(&new_doc).unwrap();
901 assert_eq!(ranges2.len(), 1);
902 assert_eq!(ranges2[0].content(&new_doc), "new\n");
903 }
904
905 #[test]
906 fn is_agent_marker_yes() {
907 assert!(is_agent_marker(" agent:status "));
908 assert!(is_agent_marker("/agent:status"));
909 assert!(is_agent_marker("agent:my-thing"));
910 assert!(is_agent_marker(" /agent:A1 "));
911 }
912
913 #[test]
914 fn is_agent_marker_no() {
915 assert!(!is_agent_marker("just a comment"));
916 assert!(!is_agent_marker("agent:"));
917 assert!(!is_agent_marker("/agent:"));
918 assert!(!is_agent_marker("agent:-bad"));
919 assert!(!is_agent_marker("some agent:fake stuff"));
920 }
921
922 #[test]
923 fn regular_comments_ignored() {
924 let doc = "<!-- just a comment -->\n<!-- agent:x -->\ndata\n<!-- /agent:x -->\n";
925 let ranges = parse(doc).unwrap();
926 assert_eq!(ranges.len(), 1);
927 assert_eq!(ranges[0].name, "x");
928 }
929
930 #[test]
931 fn regular_comments_with_multibyte_preview_boundary_ignored() {
932 let doc = "\
933<!-- 123456789012345678❯ ordinary comment -->
934<!-- agent:x -->
935data
936<!-- /agent:x -->
937";
938 let ranges = parse(doc).unwrap();
939 assert_eq!(ranges.len(), 1);
940 assert_eq!(ranges[0].name, "x");
941 }
942
943 #[test]
944 fn multiline_comment_ignored() {
945 let doc = "\
946<!--
947multi
948line
949comment
950-->
951<!-- agent:s -->
952content
953<!-- /agent:s -->
954";
955 let ranges = parse(doc).unwrap();
956 assert_eq!(ranges.len(), 1);
957 assert_eq!(ranges[0].name, "s");
958 }
959
960 #[test]
961 fn empty_content() {
962 let doc = "<!-- agent:empty --><!-- /agent:empty -->\n";
963 let ranges = parse(doc).unwrap();
964 assert_eq!(ranges.len(), 1);
965 assert_eq!(ranges[0].content(doc), "");
966 }
967
968 #[test]
969 fn markers_in_fenced_code_block_ignored() {
970 let doc = "\
971<!-- agent:real -->
972content
973<!-- /agent:real -->
974```markdown
975<!-- agent:fake -->
976this is just an example
977<!-- /agent:fake -->
978```
979";
980 let ranges = parse(doc).unwrap();
981 assert_eq!(ranges.len(), 1);
982 assert_eq!(ranges[0].name, "real");
983 }
984
985 #[test]
986 fn markers_in_inline_code_ignored() {
987 let doc = "\
988Use `<!-- agent:example -->` markers for components.
989<!-- agent:real -->
990content
991<!-- /agent:real -->
992";
993 let ranges = parse(doc).unwrap();
994 assert_eq!(ranges.len(), 1);
995 assert_eq!(ranges[0].name, "real");
996 }
997
998 #[test]
999 fn markers_in_tilde_fence_ignored() {
1000 let doc = "\
1001<!-- agent:x -->
1002data
1003<!-- /agent:x -->
1004~~~
1005<!-- agent:y -->
1006example
1007<!-- /agent:y -->
1008~~~
1009";
1010 let ranges = parse(doc).unwrap();
1011 assert_eq!(ranges.len(), 1);
1012 assert_eq!(ranges[0].name, "x");
1013 }
1014
1015 #[test]
1016 fn markers_in_indented_fenced_code_block_ignored() {
1017 let doc = "\
1019<!-- agent:exchange -->
1020Content here.
1021<!-- /agent:exchange -->
1022
1023 ```markdown
1024 <!-- agent:fake -->
1025 demo without closing tag
1026 ```
1027";
1028 let ranges = parse(doc).unwrap();
1029 assert_eq!(ranges.len(), 1);
1030 assert_eq!(ranges[0].name, "exchange");
1031 }
1032
1033 #[test]
1034 fn indented_fence_inside_component_ignored() {
1035 let doc = "\
1037<!-- agent:exchange -->
1038Here's how to set up:
1039
1040 ```markdown
1041 <!-- agent:status -->
1042 Your status here
1043 ```
1044
1045Done explaining.
1046<!-- /agent:exchange -->
1047";
1048 let ranges = parse(doc).unwrap();
1049 assert_eq!(ranges.len(), 1);
1050 assert_eq!(ranges[0].name, "exchange");
1051 }
1052
1053 #[test]
1054 fn deeply_indented_fence_ignored() {
1055 let doc = "\
1057<!-- agent:x -->
1058ok
1059<!-- /agent:x -->
1060 ```
1061 <!-- agent:y -->
1062 inside fence
1063 ```
1064";
1065 let ranges = parse(doc).unwrap();
1066 assert_eq!(ranges.len(), 1);
1067 assert_eq!(ranges[0].name, "x");
1068 }
1069
1070 #[test]
1071 fn indented_fence_code_ranges_detected() {
1072 let doc = "before\n ```\n code\n ```\nafter\n";
1073 let ranges = find_code_ranges(doc);
1074 assert_eq!(ranges.len(), 1);
1075 assert!(doc[ranges[0].0..ranges[0].1].contains("code"));
1076 }
1077
1078 #[test]
1079 fn code_ranges_detected() {
1080 let doc = "before\n```\ncode\n```\nafter `inline` end\n";
1081 let ranges = find_code_ranges(doc);
1082 assert_eq!(ranges.len(), 2);
1083 assert!(doc[ranges[0].0..ranges[0].1].contains("code"));
1085 assert!(doc[ranges[1].0..ranges[1].1].contains("inline"));
1087 }
1088
1089 #[test]
1090 fn code_ranges_double_backtick() {
1091 let doc = "text `` `<!--` `` more\n";
1093 let ranges = find_code_ranges(doc);
1094 assert_eq!(ranges.len(), 1);
1095 let span = &doc[ranges[0].0..ranges[0].1];
1096 assert!(
1097 span.contains("<!--"),
1098 "double-backtick span should contain <!--: {:?}",
1099 span
1100 );
1101 }
1102
1103 #[test]
1104 fn code_ranges_double_backtick_does_not_match_single() {
1105 let doc = "text `` foo ` bar `` end\n";
1107 let ranges = find_code_ranges(doc);
1108 assert_eq!(ranges.len(), 1);
1109 let span = &doc[ranges[0].0..ranges[0].1];
1110 assert_eq!(span, "`` foo ` bar ``");
1111 }
1112
1113 #[test]
1114 fn double_backtick_comment_before_agent_marker() {
1115 let doc = "\
1117<!-- agent:exchange -->\n\
1118text `` `<!--` `` description\n\
1119new content here\n\
1120<!-- /agent:exchange -->\n";
1121 let components = parse(doc).unwrap();
1122 assert_eq!(components.len(), 1);
1123 assert_eq!(components[0].name, "exchange");
1124 assert!(components[0].content(doc).contains("new content here"));
1125 }
1126
1127 #[test]
1130 fn parse_component_with_mode_attr() {
1131 let doc = "<!-- agent:exchange mode=append -->\nContent\n<!-- /agent:exchange -->\n";
1132 let components = parse(doc).unwrap();
1133 assert_eq!(components.len(), 1);
1134 assert_eq!(components[0].name, "exchange");
1135 assert_eq!(
1136 components[0].attrs.get("mode").map(|s| s.as_str()),
1137 Some("append")
1138 );
1139 assert_eq!(components[0].content(doc), "Content\n");
1140 }
1141
1142 #[test]
1143 fn parse_component_with_multiple_attrs() {
1144 let doc = "<!-- agent:log mode=prepend timestamp=true -->\nData\n<!-- /agent:log -->\n";
1145 let components = parse(doc).unwrap();
1146 assert_eq!(components.len(), 1);
1147 assert_eq!(components[0].name, "log");
1148 assert_eq!(
1149 components[0].attrs.get("mode").map(|s| s.as_str()),
1150 Some("prepend")
1151 );
1152 assert_eq!(
1153 components[0].attrs.get("timestamp").map(|s| s.as_str()),
1154 Some("true")
1155 );
1156 }
1157
1158 #[test]
1159 fn parse_component_no_attrs_backward_compat() {
1160 let doc = "<!-- agent:status -->\nOK\n<!-- /agent:status -->\n";
1161 let components = parse(doc).unwrap();
1162 assert_eq!(components.len(), 1);
1163 assert_eq!(components[0].name, "status");
1164 assert!(components[0].attrs.is_empty());
1165 }
1166
1167 #[test]
1168 fn is_agent_marker_with_attrs() {
1169 assert!(is_agent_marker(" agent:exchange mode=append "));
1170 assert!(is_agent_marker("agent:status mode=replace"));
1171 assert!(is_agent_marker("agent:log mode=prepend timestamp=true"));
1172 }
1173
1174 #[test]
1175 fn closing_tag_unchanged_with_attrs() {
1176 let doc = "<!-- agent:status mode=replace -->\n- [x] Done\n<!-- /agent:status -->\n";
1178 let components = parse(doc).unwrap();
1179 assert_eq!(components.len(), 1);
1180 let new_doc = components[0].replace_content(doc, "- [ ] Todo\n");
1181 assert!(new_doc.contains("<!-- agent:status mode=replace -->"));
1182 assert!(new_doc.contains("<!-- /agent:status -->"));
1183 assert!(new_doc.contains("- [ ] Todo"));
1184 }
1185
1186 #[test]
1187 fn parse_component_with_patch_attr() {
1188 let doc = "<!-- agent:exchange patch=append -->\nContent\n<!-- /agent:exchange -->\n";
1189 let components = parse(doc).unwrap();
1190 assert_eq!(components.len(), 1);
1191 assert_eq!(components[0].name, "exchange");
1192 assert_eq!(components[0].patch_mode(), Some("append"));
1193 assert_eq!(components[0].content(doc), "Content\n");
1194 }
1195
1196 #[test]
1197 fn patch_attr_takes_precedence_over_mode() {
1198 let doc = "<!-- agent:exchange patch=replace mode=append -->\nContent\n<!-- /agent:exchange -->\n";
1199 let components = parse(doc).unwrap();
1200 assert_eq!(components[0].patch_mode(), Some("replace"));
1201 }
1202
1203 #[test]
1204 fn mode_attr_backward_compat() {
1205 let doc = "<!-- agent:exchange mode=append -->\nContent\n<!-- /agent:exchange -->\n";
1206 let components = parse(doc).unwrap();
1207 assert_eq!(components[0].patch_mode(), Some("append"));
1208 }
1209
1210 #[test]
1211 fn no_patch_or_mode_attr() {
1212 let doc = "<!-- agent:exchange -->\nContent\n<!-- /agent:exchange -->\n";
1213 let components = parse(doc).unwrap();
1214 assert_eq!(components[0].patch_mode(), None);
1215 }
1216
1217 #[test]
1220 fn single_backtick_component_tag_ignored() {
1221 let doc = "\
1223Use `<!-- agent:pending -->` to mark pending sections.
1224<!-- agent:real -->
1225content
1226<!-- /agent:real -->
1227";
1228 let components = parse(doc).unwrap();
1229 assert_eq!(components.len(), 1);
1230 assert_eq!(components[0].name, "real");
1231 }
1232
1233 #[test]
1234 fn double_backtick_component_tag_ignored() {
1235 let doc = "\
1237Use ``<!-- agent:pending -->`` to mark pending sections.
1238<!-- agent:real -->
1239content
1240<!-- /agent:real -->
1241";
1242 let components = parse(doc).unwrap();
1243 assert_eq!(components.len(), 1);
1244 assert_eq!(components[0].name, "real");
1245 }
1246
1247 #[test]
1248 fn component_tags_not_in_backticks_still_work() {
1249 let doc = "\
1251<!-- agent:a -->
1252alpha
1253<!-- /agent:a -->
1254<!-- agent:b patch=append -->
1255beta
1256<!-- /agent:b -->
1257";
1258 let components = parse(doc).unwrap();
1259 assert_eq!(components.len(), 2);
1260 assert_eq!(components[0].name, "a");
1261 assert_eq!(components[1].name, "b");
1262 assert_eq!(components[1].patch_mode(), Some("append"));
1263 }
1264
1265 #[test]
1266 fn mixed_backtick_and_real_tags() {
1267 let doc = "\
1269Here is an example: `<!-- agent:fake -->` and ``<!-- /agent:fake -->``.
1270<!-- agent:real -->
1271real content
1272<!-- /agent:real -->
1273Another example: `<!-- agent:also-fake patch=replace -->` is just documentation.
1274";
1275 let components = parse(doc).unwrap();
1276 assert_eq!(components.len(), 1);
1277 assert_eq!(components[0].name, "real");
1278 assert_eq!(components[0].content(doc), "real content\n");
1279 }
1280
1281 #[test]
1282 fn inline_code_mid_line_with_surrounding_text_ignored() {
1283 let doc = "\
1286Wrap markers like `<!-- agent:status -->` in backticks to show them literally.
1287<!-- agent:real -->
1288actual content
1289<!-- /agent:real -->
1290";
1291 let components = parse(doc).unwrap();
1292 assert_eq!(components.len(), 1);
1293 assert_eq!(components[0].name, "real");
1294 assert_eq!(components[0].content(doc), "actual content\n");
1295 }
1296
1297 #[test]
1298 fn parse_attrs_unit() {
1299 let attrs = parse_attrs("mode=append");
1300 assert_eq!(attrs.get("mode").map(|s| s.as_str()), Some("append"));
1301
1302 let attrs = parse_attrs("mode=replace timestamp=true");
1303 assert_eq!(attrs.len(), 2);
1304
1305 let attrs = parse_attrs("");
1306 assert!(attrs.is_empty());
1307
1308 let attrs = parse_attrs("mode=append broken novalue=");
1310 assert_eq!(attrs.len(), 2);
1311 assert_eq!(attrs.get("mode").map(|s| s.as_str()), Some("append"));
1312 assert_eq!(attrs.get("broken").map(|s| s.as_str()), Some(""));
1313
1314 let attrs = parse_attrs("auto");
1316 assert_eq!(attrs.len(), 1);
1317 assert!(attrs.contains_key("auto"));
1318 }
1319
1320 #[test]
1321 fn append_with_boundary_skips_code_block() {
1322 let boundary_id = "real-uuid";
1325 let doc = format!(
1326 "<!-- agent:exchange patch=append -->\n\
1327 user prompt\n\
1328 ```\n\
1329 <!-- agent:boundary:{boundary_id} -->\n\
1330 ```\n\
1331 more user text\n\
1332 <!-- agent:boundary:{boundary_id} -->\n\
1333 <!-- /agent:exchange -->\n"
1334 );
1335 let components = parse(&doc).unwrap();
1336 let comp = &components[0];
1337 let result =
1338 comp.append_with_boundary(&doc, "### Re: Response\n\nContent here.", boundary_id);
1339
1340 assert!(result.contains("### Re: Response"));
1343 assert!(result.contains("more user text"));
1344 assert!(result.contains(&format!("<!-- agent:boundary:{boundary_id} -->\n```")));
1346 assert!(!result.contains(&format!(
1348 "more user text\n<!-- agent:boundary:{boundary_id} -->\n<!-- /agent:exchange -->"
1349 )));
1350 }
1351
1352 #[test]
1353 fn append_with_boundary_no_code_block() {
1354 let boundary_id = "simple-uuid";
1356 let doc = format!(
1357 "<!-- agent:exchange patch=append -->\n\
1358 user prompt\n\
1359 <!-- agent:boundary:{boundary_id} -->\n\
1360 <!-- /agent:exchange -->\n"
1361 );
1362 let components = parse(&doc).unwrap();
1363 let comp = &components[0];
1364 let result = comp.append_with_boundary(&doc, "### Re: Answer\n\nDone.", boundary_id);
1365
1366 assert!(result.contains("### Re: Answer"));
1367 assert!(result.contains("user prompt"));
1368 assert!(!result.contains(&format!("agent:boundary:{boundary_id}")));
1370 assert!(result.contains("agent:boundary:"));
1371 }
1372
1373 #[test]
1374 fn append_with_boundary_skips_already_present_content() {
1375 let boundary_id = "simple-uuid";
1376 let doc = format!(
1377 "<!-- agent:exchange patch=append -->\n\
1378 ### Re: Duplicate — gpt-5 (HEAD)\n\
1379 \n\
1380 Already applied.\n\
1381 <!-- agent:boundary:old-id -->\n\
1382 <!-- agent:boundary:{boundary_id} -->\n\
1383 <!-- /agent:exchange -->\n"
1384 );
1385 let components = parse(&doc).unwrap();
1386 let comp = &components[0];
1387 let result = comp.append_with_boundary(
1388 &doc,
1389 "### Re: Duplicate — gpt-5\n\nAlready applied.\n",
1390 boundary_id,
1391 );
1392
1393 assert_eq!(result, doc);
1394 }
1395
1396 #[test]
1397 fn append_with_caret_skips_already_present_content() {
1398 let doc = "<!-- agent:exchange patch=append -->\n\
1399 User prompt.\n\
1400 ### Re: Duplicate — gpt-5\n\
1401 \n\
1402 Already applied.\n\
1403 <!-- /agent:exchange -->\n";
1404 let components = parse(doc).unwrap();
1405 let comp = &components[0];
1406 let result =
1407 comp.append_with_caret(doc, "### Re: Duplicate — gpt-5\n\nAlready applied.\n", None);
1408
1409 assert_eq!(result, doc);
1410 }
1411
1412 #[test]
1415 fn strip_comments_removes_html_comment() {
1416 let result = strip_comments("before\n<!-- a comment -->\nafter\n");
1417 assert_eq!(result, "before\nafter\n");
1418 }
1419
1420 #[test]
1421 fn non_agent_html_comment_ranges_cover_multiline_body() {
1422 let doc = concat!(
1423 "before\n",
1424 "<!--\n",
1425 "do #hidden. spec-test-build-install-commit-push\n",
1426 "-->\n",
1427 "<!-- agent:exchange -->\n",
1428 "<!-- /agent:exchange -->\n"
1429 );
1430
1431 let ranges = find_non_agent_html_comment_ranges(doc);
1432 assert_eq!(ranges.len(), 1);
1433 let hidden = doc.find("do #hidden").unwrap();
1434 assert!(
1435 ranges
1436 .iter()
1437 .any(|&(start, end)| hidden >= start && hidden < end),
1438 "ordinary comment body should be inside a non-agent comment range"
1439 );
1440 assert!(
1441 ranges
1442 .iter()
1443 .all(|&(start, end)| !doc[start..end].contains("agent:exchange")),
1444 "agent component markers must not be treated as ordinary comments"
1445 );
1446 }
1447
1448 #[test]
1449 fn non_agent_html_comment_ranges_cover_unterminated_tail() {
1450 let doc = concat!(
1451 "before\n",
1452 "<!--\n",
1453 "do #hidden. spec-test-build-install-commit-push\n",
1454 "still typing\n"
1455 );
1456
1457 let ranges = find_non_agent_html_comment_ranges(doc);
1458 assert_eq!(ranges.len(), 1);
1459 let (start, end) = ranges[0];
1460 assert_eq!(
1461 &doc[start..end],
1462 "<!--\ndo #hidden. spec-test-build-install-commit-push\nstill typing\n"
1463 );
1464 assert_eq!(end, doc.len());
1465 }
1466
1467 #[test]
1468 fn strip_comments_preserves_agent_markers() {
1469 let input = "text\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
1470 let result = strip_comments(input);
1471 assert!(result.contains("<!-- agent:status -->"));
1472 assert!(result.contains("<!-- /agent:status -->"));
1473 }
1474
1475 #[test]
1476 fn strip_comments_removes_link_ref() {
1477 let result = strip_comments("[//]: # (hidden note)\nvisible\n");
1478 assert_eq!(result, "visible\n");
1479 }
1480
1481 #[test]
1482 fn html_comment_in_content_does_not_eat_close_marker() {
1483 let doc = "\
1484<!-- agent:pending -->
1485- [ ] [#abc] Rule: first line (not starting with ### or ❯ or <!-- ) gets prefix
1486<!-- /agent:pending -->
1487";
1488 let comps = parse(doc).unwrap();
1489 assert_eq!(comps.len(), 1);
1490 assert_eq!(comps[0].name, "pending");
1491 assert!(comps[0].content(doc).contains("<!-- )"));
1492 }
1493
1494 #[test]
1495 fn nested_html_comment_like_text_in_exchange() {
1496 let doc = "\
1497<!-- agent:exchange -->
1498User typed <!-- some note --> in the text.
1499<!-- /agent:exchange -->
1500";
1501 let comps = parse(doc).unwrap();
1502 assert_eq!(comps.len(), 1);
1503 assert_eq!(comps[0].name, "exchange");
1504 }
1505
1506 #[test]
1507 fn pending_item_with_literal_html_comment_opener() {
1508 let doc = "\
1511<!-- agent:pending -->
1512- [ ] [#r7hw] Component parser: non-agent `<!-- ` in content ate close markers
1513- [ ] [#xyz] Another item
1514<!-- /agent:pending -->
1515";
1516 let comps = parse(doc).unwrap();
1517 assert_eq!(comps.len(), 1);
1518 assert_eq!(comps[0].name, "pending");
1519 let content = comps[0].content(doc);
1520 assert!(content.contains("#r7hw"));
1521 assert!(content.contains("#xyz"));
1522 }
1523
1524 #[test]
1525 fn multiple_non_agent_html_comments_in_pending() {
1526 let doc = "\
1528<!-- agent:pending -->
1529- [ ] [#a] Fix rule: skip lines starting with <!-- or -->
1530- [ ] [#b] Handle <!-- partial comment
1531- [ ] [#c] Normal item
1532<!-- /agent:pending -->
1533";
1534 let comps = parse(doc).unwrap();
1535 assert_eq!(comps.len(), 1);
1536 assert_eq!(comps[0].name, "pending");
1537 let content = comps[0].content(doc);
1538 assert!(content.contains("#a"));
1539 assert!(content.contains("#b"));
1540 assert!(content.contains("#c"));
1541 }
1542
1543 #[test]
1544 fn exchange_and_pending_both_with_html_comments_in_content() {
1545 let doc = "\
1548<!-- agent:exchange -->
1549The rule checks for <!-- prefix before deciding.
1550### Re: topic — opus
1551Fix applied to skip non-agent <!-- sequences.
1552<!-- /agent:exchange -->
1553
1554<!-- agent:pending -->
1555- [ ] [#cfdy] Verify <!-- in pending items
1556<!-- /agent:pending -->
1557";
1558 let comps = parse(doc).unwrap();
1559 assert_eq!(comps.len(), 2);
1560 assert_eq!(comps[0].name, "exchange");
1561 assert_eq!(comps[1].name, "pending");
1562 assert!(comps[0].content(doc).contains("<!-- prefix"));
1563 assert!(comps[1].content(doc).contains("#cfdy"));
1564 }
1565
1566 #[test]
1567 fn is_backlog_component_accepts_both_names() {
1568 assert!(is_backlog_component("backlog"));
1569 assert!(is_backlog_component("pending"));
1570 assert!(!is_backlog_component("exchange"));
1571 assert!(!is_backlog_component("status"));
1572 assert!(!is_backlog_component("pending-done"));
1573 assert!(!is_backlog_component("backlog-done"));
1574 assert!(!is_backlog_component("done"));
1575 }
1576
1577 #[test]
1578 fn is_backlog_done_component_accepts_only_canonical_done() {
1579 assert!(is_backlog_done_component("done"));
1580 assert!(!is_backlog_done_component("backlog-done"));
1581 assert!(!is_backlog_done_component("pending-done"));
1582 assert!(!is_backlog_done_component("backlog"));
1583 assert!(!is_backlog_done_component("pending"));
1584 assert!(!is_backlog_done_component("icebox"));
1585 }
1586
1587 #[test]
1588 fn is_review_component_accepts_name() {
1589 assert!(is_review_component("review"));
1590 assert!(!is_review_component("backlog"));
1591 assert!(!is_review_component("icebox"));
1592 }
1593
1594 #[test]
1595 fn is_icebox_component_accepts_name() {
1596 assert!(is_icebox_component("icebox"));
1597 assert!(!is_icebox_component("backlog"));
1598 assert!(!is_icebox_component("exchange"));
1599 assert!(!is_icebox_component("icebox-archive"));
1600 }
1601
1602 #[test]
1603 fn tracked_work_component_includes_review() {
1604 assert!(is_tracked_work_component("backlog"));
1605 assert!(is_tracked_work_component("pending"));
1606 assert!(is_tracked_work_component("review"));
1607 assert!(is_tracked_work_component("icebox"));
1608 assert!(!is_tracked_work_component("done"));
1609 }
1610
1611 #[test]
1612 fn icebox_component_parsed() {
1613 let doc = "\
1614<!-- agent:icebox -->
1615- Parked idea
1616<!-- /agent:icebox -->
1617";
1618 let comps = parse(doc).unwrap();
1619 assert_eq!(comps.len(), 1);
1620 assert_eq!(comps[0].name, "icebox");
1621 assert!(is_icebox_component(&comps[0].name));
1622 assert!(comps[0].content(doc).contains("Parked idea"));
1623 }
1624
1625 #[test]
1626 fn backlog_component_parsed_from_new_marker() {
1627 let doc = "\
1628<!-- agent:backlog -->
1629- [ ] [#abc] First item
1630<!-- /agent:backlog -->
1631";
1632 let comps = parse(doc).unwrap();
1633 assert_eq!(comps.len(), 1);
1634 assert_eq!(comps[0].name, "backlog");
1635 assert!(is_backlog_component(&comps[0].name));
1636 assert!(comps[0].content(doc).contains("#abc"));
1637 }
1638
1639 #[test]
1640 fn legacy_pending_marker_still_parsed() {
1641 let doc = "\
1642<!-- agent:pending -->
1643- [ ] [#xyz] Legacy item
1644<!-- /agent:pending -->
1645";
1646 let comps = parse(doc).unwrap();
1647 assert_eq!(comps.len(), 1);
1648 assert_eq!(comps[0].name, "pending");
1649 assert!(is_backlog_component(&comps[0].name));
1650 }
1651
1652 #[test]
1653 fn strip_backlog_patch_attr_removes_patch_replace() {
1654 let doc = "<!-- agent:backlog patch=replace -->\n- item\n<!-- /agent:backlog -->\n";
1655 let result = strip_backlog_patch_attr(doc);
1656 assert_eq!(
1657 result,
1658 "<!-- agent:backlog -->\n- item\n<!-- /agent:backlog -->\n"
1659 );
1660 }
1661
1662 #[test]
1663 fn strip_backlog_patch_attr_removes_pending_patch_replace() {
1664 let doc = "<!-- agent:pending patch=replace -->\n- item\n<!-- /agent:pending -->\n";
1665 let result = strip_backlog_patch_attr(doc);
1666 assert_eq!(
1667 result,
1668 "<!-- agent:pending -->\n- item\n<!-- /agent:pending -->\n"
1669 );
1670 }
1671
1672 #[test]
1673 fn strip_backlog_patch_attr_removes_mode_replace() {
1674 let doc = "<!-- agent:backlog mode=replace -->\n- item\n<!-- /agent:backlog -->\n";
1675 let result = strip_backlog_patch_attr(doc);
1676 assert_eq!(
1677 result,
1678 "<!-- agent:backlog -->\n- item\n<!-- /agent:backlog -->\n"
1679 );
1680 }
1681
1682 #[test]
1683 fn strip_backlog_patch_attr_preserves_other_attrs() {
1684 let doc =
1685 "<!-- agent:backlog patch=replace max_lines=50 -->\n- item\n<!-- /agent:backlog -->\n";
1686 let result = strip_backlog_patch_attr(doc);
1687 assert_eq!(
1688 result,
1689 "<!-- agent:backlog max_lines=50 -->\n- item\n<!-- /agent:backlog -->\n"
1690 );
1691 }
1692
1693 #[test]
1694 fn strip_backlog_patch_attr_noop_for_exchange() {
1695 let doc = "<!-- agent:exchange patch=append -->\ncontent\n<!-- /agent:exchange -->\n";
1696 let result = strip_backlog_patch_attr(doc);
1697 assert_eq!(result, doc);
1698 }
1699
1700 #[test]
1701 fn strip_backlog_patch_attr_noop_when_no_attr() {
1702 let doc = "<!-- agent:backlog -->\n- item\n<!-- /agent:backlog -->\n";
1703 let result = strip_backlog_patch_attr(doc);
1704 assert_eq!(result, doc);
1705 }
1706}