1use anyhow::{bail, Result};
67use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
68use std::collections::HashMap;
69
70#[derive(Debug, Clone, PartialEq, Eq)]
75pub struct Component {
76 pub name: String,
77 pub attrs: HashMap<String, String>,
79 pub open_start: usize,
81 pub open_end: usize,
83 pub close_start: usize,
85 pub close_end: usize,
87}
88
89impl Component {
90 #[allow(dead_code)] pub fn content<'a>(&self, doc: &'a str) -> &'a str {
93 &doc[self.open_end..self.close_start]
94 }
95
96 pub fn patch_mode(&self) -> Option<&str> {
100 self.attrs.get("patch").map(|s| s.as_str())
101 .or_else(|| self.attrs.get("mode").map(|s| s.as_str()))
102 }
103
104 pub fn replace_content(&self, doc: &str, new_content: &str) -> String {
107 let mut result = String::with_capacity(doc.len() + new_content.len());
108 result.push_str(&doc[..self.open_end]);
109 result.push_str(new_content);
110 result.push_str(&doc[self.close_start..]);
111 result
112 }
113
114 pub fn append_with_caret(&self, doc: &str, content: &str, caret_offset: Option<usize>) -> String {
121 let existing = &doc[self.open_end..self.close_start];
122
123 if let Some(caret) = caret_offset {
124 if caret > self.open_end && caret <= self.close_start {
126 let insert_at = doc[..caret].rfind('\n')
128 .map(|i| i + 1)
129 .unwrap_or(self.open_end);
130
131 let insert_at = insert_at.max(self.open_end);
133
134 let mut result = String::with_capacity(doc.len() + content.len() + 1);
135 result.push_str(&doc[..insert_at]);
136 result.push_str(content.trim_end());
137 result.push('\n');
138 result.push_str(&doc[insert_at..]);
139 return result;
140 }
141 }
142
143 let mut result = String::with_capacity(doc.len() + content.len() + 1);
145 result.push_str(&doc[..self.open_end]);
146 result.push_str(existing.trim_end());
147 result.push('\n');
148 result.push_str(content.trim_end());
149 result.push('\n');
150 result.push_str(&doc[self.close_start..]);
151 result
152 }
153
154 pub fn append_with_boundary(&self, doc: &str, content: &str, boundary_id: &str) -> String {
160 let boundary_marker = format!("<!-- agent:boundary:{} -->", boundary_id);
161 let content_region = &doc[self.open_end..self.close_start];
162 let code_ranges = find_code_ranges(doc);
163
164 let mut search_from = 0;
166 let found_pos = loop {
167 match content_region[search_from..].find(&boundary_marker) {
168 Some(rel_pos) => {
169 let abs_pos = self.open_end + search_from + rel_pos;
170 if code_ranges.iter().any(|&(cs, ce)| abs_pos >= cs && abs_pos < ce) {
171 search_from += rel_pos + boundary_marker.len();
173 continue;
174 }
175 break Some(abs_pos);
176 }
177 None => break None,
178 }
179 };
180
181 if let Some(abs_pos) = found_pos {
182 let line_start = doc[..abs_pos]
184 .rfind('\n')
185 .map(|i| i + 1)
186 .unwrap_or(self.open_end)
187 .max(self.open_end);
188
189 let marker_end = abs_pos + boundary_marker.len();
191 let line_end = if marker_end < self.close_start
192 && doc.as_bytes().get(marker_end) == Some(&b'\n')
193 {
194 marker_end + 1
195 } else {
196 marker_end
197 };
198 let line_end = line_end.min(self.close_start);
199
200 let new_id = crate::new_boundary_id();
204 let new_marker = crate::format_boundary_marker(&new_id);
205 let mut result = String::with_capacity(doc.len() + content.len() + new_marker.len());
206 result.push_str(&doc[..line_start]);
207 result.push_str(content.trim_end());
208 result.push('\n');
209 result.push_str(&new_marker);
210 result.push('\n');
211 result.push_str(&doc[line_end..]);
212 return result;
213 }
214
215 self.append_with_caret(doc, content, None)
217 }
218}
219
220fn is_valid_name(name: &str) -> bool {
222 if name.is_empty() {
223 return false;
224 }
225 let first = name.as_bytes()[0];
226 if !first.is_ascii_alphanumeric() {
227 return false;
228 }
229 name.bytes()
230 .all(|b| b.is_ascii_alphanumeric() || b == b'-')
231}
232
233pub fn is_agent_marker(comment_text: &str) -> bool {
237 let trimmed = comment_text.trim();
238 if let Some(rest) = trimmed.strip_prefix("/agent:") {
239 is_valid_name(rest)
240 } else if let Some(rest) = trimmed.strip_prefix("agent:") {
241 let name_part = rest.split_whitespace().next().unwrap_or("");
243 is_valid_name(name_part)
244 } else {
245 false
246 }
247}
248
249pub fn parse_attrs(attr_text: &str) -> HashMap<String, String> {
254 let mut attrs = HashMap::new();
255 for token in attr_text.split_whitespace() {
256 if let Some((key, value)) = token.split_once('=')
257 && !key.is_empty()
258 && !value.is_empty()
259 {
260 attrs.insert(key.to_string(), value.to_string());
261 }
262 }
263 attrs
264}
265
266pub fn find_code_ranges(doc: &str) -> Vec<(usize, usize)> {
272 let t = std::time::Instant::now();
273 let mut ranges = Vec::new();
274 let parser = Parser::new_ext(doc, Options::empty());
275 let mut iter = parser.into_offset_iter();
276 while let Some((event, range)) = iter.next() {
277 match event {
278 Event::Code(_) => {
280 ranges.push((range.start, range.end));
281 }
282 Event::Start(Tag::CodeBlock(_)) => {
284 let block_start = range.start;
285 let mut block_end = range.end;
286 for (inner_event, inner_range) in iter.by_ref() {
287 block_end = inner_range.end;
288 if matches!(inner_event, Event::End(TagEnd::CodeBlock)) {
289 break;
290 }
291 }
292 ranges.push((block_start, block_end));
293 }
294 _ => {}
295 }
296 }
297 let elapsed = t.elapsed().as_millis();
298 if elapsed > 0 {
299 eprintln!("[perf] find_code_ranges: {}ms", elapsed);
300 }
301 ranges
302}
303
304pub fn parse(doc: &str) -> Result<Vec<Component>> {
310 let bytes = doc.as_bytes();
311 let len = bytes.len();
312 let code_ranges = find_code_ranges(doc);
313 let mut templates: Vec<Component> = Vec::new();
314 let mut stack: Vec<(String, HashMap<String, String>, usize, usize)> = Vec::new();
316 let mut pos = 0;
317
318 while pos + 4 <= len {
319 if &bytes[pos..pos + 4] != b"<!--" {
321 pos += 1;
322 continue;
323 }
324
325 if code_ranges.iter().any(|&(start, end)| pos >= start && pos < end) {
327 pos += 4;
328 continue;
329 }
330
331 let marker_start = pos;
332
333 let close = match find_comment_end(bytes, pos + 4) {
335 Some(c) => c,
336 None => {
337 pos += 4;
338 continue;
339 }
340 };
341
342 let inner = &doc[marker_start + 4..close - 3]; let trimmed = inner.trim();
345
346 let mut marker_end = close;
348 if marker_end < len && bytes[marker_end] == b'\n' {
349 marker_end += 1;
350 }
351
352 if let Some(name) = trimmed.strip_prefix("/agent:") {
353 if !is_valid_name(name) {
355 bail!("invalid component name: '{}'", name);
356 }
357 match stack.pop() {
358 Some((open_name, open_attrs, open_start, open_end)) => {
359 if open_name != name {
360 bail!(
361 "mismatched component: opened '{}' but closed '{}'",
362 open_name,
363 name
364 );
365 }
366 templates.push(Component {
367 name: name.to_string(),
368 attrs: open_attrs,
369 open_start,
370 open_end,
371 close_start: marker_start,
372 close_end: marker_end,
373 });
374 }
375 None => bail!("closing marker <!-- /agent:{} --> without matching open", name),
376 }
377 } else if let Some(rest) = trimmed.strip_prefix("agent:") {
378 if rest.starts_with("boundary:") {
380 pos = close;
381 continue;
382 }
383 let mut parts = rest.splitn(2, |c: char| c.is_whitespace());
385 let name = parts.next().unwrap_or("");
386 let attr_text = parts.next().unwrap_or("");
387 if !is_valid_name(name) {
388 bail!("invalid component name: '{}'", name);
389 }
390 let attrs = parse_attrs(attr_text);
391 stack.push((name.to_string(), attrs, marker_start, marker_end));
392 }
393
394 pos = close;
395 }
396
397 if let Some((name, _, _, _)) = stack.last() {
398 bail!(
399 "unclosed component: <!-- agent:{} --> without matching close",
400 name
401 );
402 }
403
404 templates.sort_by_key(|t| t.open_start);
405 Ok(templates)
406}
407
408pub(crate) fn find_comment_end(bytes: &[u8], start: usize) -> Option<usize> {
410 let len = bytes.len();
411 let mut i = start;
412 while i + 3 <= len {
413 if &bytes[i..i + 3] == b"-->" {
414 return Some(i + 3);
415 }
416 i += 1;
417 }
418 None
419}
420
421pub fn strip_comments(content: &str) -> String {
434 let code_ranges = find_code_ranges(content);
435 let in_code = |pos: usize| code_ranges.iter().any(|&(start, end)| pos >= start && pos < end);
436
437 let mut result = String::with_capacity(content.len());
438 let bytes = content.as_bytes();
439 let len = bytes.len();
440 let mut pos = 0;
441
442 while pos < len {
443 if bytes[pos] == b'['
445 && !in_code(pos)
446 && is_line_start_at(bytes, pos)
447 && let Some(end) = match_link_ref_comment_at(bytes, pos)
448 {
449 pos = end;
450 continue;
451 }
452
453 if pos + 4 <= len
455 && &bytes[pos..pos + 4] == b"<!--"
456 && !in_code(pos)
457 && let Some((end, inner)) = match_html_comment_at(content, pos)
458 {
459 if is_agent_marker(inner) {
460 result.push_str(&content[pos..end]);
462 pos = end;
463 } else {
464 let mut skip_to = end;
466 if is_line_start_at(bytes, pos) && skip_to < len && bytes[skip_to] == b'\n' {
467 skip_to += 1;
468 }
469 pos = skip_to;
470 }
471 continue;
472 }
473
474 result.push(content[pos..].chars().next().unwrap());
475 pos += content[pos..].chars().next().unwrap().len_utf8();
476 }
477
478 result
479}
480
481fn is_line_start_at(bytes: &[u8], pos: usize) -> bool {
483 pos == 0 || bytes[pos - 1] == b'\n'
484}
485
486fn match_link_ref_comment_at(bytes: &[u8], pos: usize) -> Option<usize> {
488 let prefix = b"[//]: # (";
489 let len = bytes.len();
490 if pos + prefix.len() > len || &bytes[pos..pos + prefix.len()] != prefix {
491 return None;
492 }
493 let mut i = pos + prefix.len();
494 while i < len && bytes[i] != b')' && bytes[i] != b'\n' {
495 i += 1;
496 }
497 if i < len && bytes[i] == b')' {
498 i += 1;
499 if i < len && bytes[i] == b'\n' {
500 i += 1;
501 }
502 Some(i)
503 } else {
504 None
505 }
506}
507
508fn match_html_comment_at(content: &str, pos: usize) -> Option<(usize, &str)> {
510 let bytes = content.as_bytes();
511 let len = bytes.len();
512 let mut i = pos + 4;
513 while i + 3 <= len {
514 if &bytes[i..i + 3] == b"-->" {
515 let inner = &content[pos + 4..i];
516 return Some((i + 3, inner));
517 }
518 i += 1;
519 }
520 None
521}
522
523#[cfg(test)]
524mod tests {
525 use super::*;
526
527 #[test]
528 fn single_range() {
529 let doc = "before\n<!-- agent:status -->\nHello\n<!-- /agent:status -->\nafter\n";
530 let ranges = parse(doc).unwrap();
531 assert_eq!(ranges.len(), 1);
532 assert_eq!(ranges[0].name, "status");
533 assert_eq!(ranges[0].content(doc), "Hello\n");
534 }
535
536 #[test]
537 fn nested_ranges() {
538 let doc = "\
539<!-- agent:outer -->
540<!-- agent:inner -->
541content
542<!-- /agent:inner -->
543<!-- /agent:outer -->
544";
545 let ranges = parse(doc).unwrap();
546 assert_eq!(ranges.len(), 2);
547 assert_eq!(ranges[0].name, "outer");
549 assert_eq!(ranges[1].name, "inner");
550 assert_eq!(ranges[1].content(doc), "content\n");
551 }
552
553 #[test]
554 fn siblings() {
555 let doc = "\
556<!-- agent:a -->
557alpha
558<!-- /agent:a -->
559<!-- agent:b -->
560beta
561<!-- /agent:b -->
562";
563 let ranges = parse(doc).unwrap();
564 assert_eq!(ranges.len(), 2);
565 assert_eq!(ranges[0].name, "a");
566 assert_eq!(ranges[0].content(doc), "alpha\n");
567 assert_eq!(ranges[1].name, "b");
568 assert_eq!(ranges[1].content(doc), "beta\n");
569 }
570
571 #[test]
572 fn no_ranges() {
573 let doc = "# Just a document\n\nWith no range templates.\n";
574 let ranges = parse(doc).unwrap();
575 assert!(ranges.is_empty());
576 }
577
578 #[test]
579 fn unmatched_open_error() {
580 let doc = "<!-- agent:orphan -->\nContent\n";
581 let err = parse(doc).unwrap_err();
582 assert!(err.to_string().contains("unclosed component"));
583 }
584
585 #[test]
586 fn unmatched_close_error() {
587 let doc = "Content\n<!-- /agent:orphan -->\n";
588 let err = parse(doc).unwrap_err();
589 assert!(err.to_string().contains("without matching open"));
590 }
591
592 #[test]
593 fn mismatched_names_error() {
594 let doc = "<!-- agent:foo -->\n<!-- /agent:bar -->\n";
595 let err = parse(doc).unwrap_err();
596 assert!(err.to_string().contains("mismatched"));
597 }
598
599 #[test]
600 fn invalid_name() {
601 let doc = "<!-- agent:-bad -->\n<!-- /agent:-bad -->\n";
602 let err = parse(doc).unwrap_err();
603 assert!(err.to_string().contains("invalid component name"));
604 }
605
606 #[test]
607 fn name_validation() {
608 assert!(is_valid_name("status"));
609 assert!(is_valid_name("my-section"));
610 assert!(is_valid_name("a1"));
611 assert!(is_valid_name("A"));
612 assert!(!is_valid_name(""));
613 assert!(!is_valid_name("-bad"));
614 assert!(!is_valid_name("has space"));
615 assert!(!is_valid_name("has_underscore"));
616 }
617
618 #[test]
619 fn content_extraction() {
620 let doc = "<!-- agent:x -->\nfoo\nbar\n<!-- /agent:x -->\n";
621 let ranges = parse(doc).unwrap();
622 assert_eq!(ranges[0].content(doc), "foo\nbar\n");
623 }
624
625 #[test]
626 fn replace_roundtrip() {
627 let doc = "before\n<!-- agent:s -->\nold\n<!-- /agent:s -->\nafter\n";
628 let ranges = parse(doc).unwrap();
629 let new_doc = ranges[0].replace_content(doc, "new\n");
630 assert_eq!(
631 new_doc,
632 "before\n<!-- agent:s -->\nnew\n<!-- /agent:s -->\nafter\n"
633 );
634 let ranges2 = parse(&new_doc).unwrap();
636 assert_eq!(ranges2.len(), 1);
637 assert_eq!(ranges2[0].content(&new_doc), "new\n");
638 }
639
640 #[test]
641 fn is_agent_marker_yes() {
642 assert!(is_agent_marker(" agent:status "));
643 assert!(is_agent_marker("/agent:status"));
644 assert!(is_agent_marker("agent:my-thing"));
645 assert!(is_agent_marker(" /agent:A1 "));
646 }
647
648 #[test]
649 fn is_agent_marker_no() {
650 assert!(!is_agent_marker("just a comment"));
651 assert!(!is_agent_marker("agent:"));
652 assert!(!is_agent_marker("/agent:"));
653 assert!(!is_agent_marker("agent:-bad"));
654 assert!(!is_agent_marker("some agent:fake stuff"));
655 }
656
657 #[test]
658 fn regular_comments_ignored() {
659 let doc = "<!-- just a comment -->\n<!-- agent:x -->\ndata\n<!-- /agent:x -->\n";
660 let ranges = parse(doc).unwrap();
661 assert_eq!(ranges.len(), 1);
662 assert_eq!(ranges[0].name, "x");
663 }
664
665 #[test]
666 fn multiline_comment_ignored() {
667 let doc = "\
668<!--
669multi
670line
671comment
672-->
673<!-- agent:s -->
674content
675<!-- /agent:s -->
676";
677 let ranges = parse(doc).unwrap();
678 assert_eq!(ranges.len(), 1);
679 assert_eq!(ranges[0].name, "s");
680 }
681
682 #[test]
683 fn empty_content() {
684 let doc = "<!-- agent:empty --><!-- /agent:empty -->\n";
685 let ranges = parse(doc).unwrap();
686 assert_eq!(ranges.len(), 1);
687 assert_eq!(ranges[0].content(doc), "");
688 }
689
690 #[test]
691 fn markers_in_fenced_code_block_ignored() {
692 let doc = "\
693<!-- agent:real -->
694content
695<!-- /agent:real -->
696```markdown
697<!-- agent:fake -->
698this is just an example
699<!-- /agent:fake -->
700```
701";
702 let ranges = parse(doc).unwrap();
703 assert_eq!(ranges.len(), 1);
704 assert_eq!(ranges[0].name, "real");
705 }
706
707 #[test]
708 fn markers_in_inline_code_ignored() {
709 let doc = "\
710Use `<!-- agent:example -->` markers for components.
711<!-- agent:real -->
712content
713<!-- /agent:real -->
714";
715 let ranges = parse(doc).unwrap();
716 assert_eq!(ranges.len(), 1);
717 assert_eq!(ranges[0].name, "real");
718 }
719
720 #[test]
721 fn markers_in_tilde_fence_ignored() {
722 let doc = "\
723<!-- agent:x -->
724data
725<!-- /agent:x -->
726~~~
727<!-- agent:y -->
728example
729<!-- /agent:y -->
730~~~
731";
732 let ranges = parse(doc).unwrap();
733 assert_eq!(ranges.len(), 1);
734 assert_eq!(ranges[0].name, "x");
735 }
736
737 #[test]
738 fn markers_in_indented_fenced_code_block_ignored() {
739 let doc = "\
741<!-- agent:exchange -->
742Content here.
743<!-- /agent:exchange -->
744
745 ```markdown
746 <!-- agent:fake -->
747 demo without closing tag
748 ```
749";
750 let ranges = parse(doc).unwrap();
751 assert_eq!(ranges.len(), 1);
752 assert_eq!(ranges[0].name, "exchange");
753 }
754
755 #[test]
756 fn indented_fence_inside_component_ignored() {
757 let doc = "\
759<!-- agent:exchange -->
760Here's how to set up:
761
762 ```markdown
763 <!-- agent:status -->
764 Your status here
765 ```
766
767Done explaining.
768<!-- /agent:exchange -->
769";
770 let ranges = parse(doc).unwrap();
771 assert_eq!(ranges.len(), 1);
772 assert_eq!(ranges[0].name, "exchange");
773 }
774
775 #[test]
776 fn deeply_indented_fence_ignored() {
777 let doc = "\
779<!-- agent:x -->
780ok
781<!-- /agent:x -->
782 ```
783 <!-- agent:y -->
784 inside fence
785 ```
786";
787 let ranges = parse(doc).unwrap();
788 assert_eq!(ranges.len(), 1);
789 assert_eq!(ranges[0].name, "x");
790 }
791
792 #[test]
793 fn indented_fence_code_ranges_detected() {
794 let doc = "before\n ```\n code\n ```\nafter\n";
795 let ranges = find_code_ranges(doc);
796 assert_eq!(ranges.len(), 1);
797 assert!(doc[ranges[0].0..ranges[0].1].contains("code"));
798 }
799
800 #[test]
801 fn code_ranges_detected() {
802 let doc = "before\n```\ncode\n```\nafter `inline` end\n";
803 let ranges = find_code_ranges(doc);
804 assert_eq!(ranges.len(), 2);
805 assert!(doc[ranges[0].0..ranges[0].1].contains("code"));
807 assert!(doc[ranges[1].0..ranges[1].1].contains("inline"));
809 }
810
811 #[test]
812 fn code_ranges_double_backtick() {
813 let doc = "text `` `<!--` `` more\n";
815 let ranges = find_code_ranges(doc);
816 assert_eq!(ranges.len(), 1);
817 let span = &doc[ranges[0].0..ranges[0].1];
818 assert!(span.contains("<!--"), "double-backtick span should contain <!--: {:?}", span);
819 }
820
821 #[test]
822 fn code_ranges_double_backtick_does_not_match_single() {
823 let doc = "text `` foo ` bar `` end\n";
825 let ranges = find_code_ranges(doc);
826 assert_eq!(ranges.len(), 1);
827 let span = &doc[ranges[0].0..ranges[0].1];
828 assert_eq!(span, "`` foo ` bar ``");
829 }
830
831 #[test]
832 fn double_backtick_comment_before_agent_marker() {
833 let doc = "\
835<!-- agent:exchange -->\n\
836text `` `<!--` `` description\n\
837new content here\n\
838<!-- /agent:exchange -->\n";
839 let components = parse(doc).unwrap();
840 assert_eq!(components.len(), 1);
841 assert_eq!(components[0].name, "exchange");
842 assert!(components[0].content(doc).contains("new content here"));
843 }
844
845 #[test]
848 fn parse_component_with_mode_attr() {
849 let doc = "<!-- agent:exchange mode=append -->\nContent\n<!-- /agent:exchange -->\n";
850 let components = parse(doc).unwrap();
851 assert_eq!(components.len(), 1);
852 assert_eq!(components[0].name, "exchange");
853 assert_eq!(components[0].attrs.get("mode").map(|s| s.as_str()), Some("append"));
854 assert_eq!(components[0].content(doc), "Content\n");
855 }
856
857 #[test]
858 fn parse_component_with_multiple_attrs() {
859 let doc = "<!-- agent:log mode=prepend timestamp=true -->\nData\n<!-- /agent:log -->\n";
860 let components = parse(doc).unwrap();
861 assert_eq!(components.len(), 1);
862 assert_eq!(components[0].name, "log");
863 assert_eq!(components[0].attrs.get("mode").map(|s| s.as_str()), Some("prepend"));
864 assert_eq!(components[0].attrs.get("timestamp").map(|s| s.as_str()), Some("true"));
865 }
866
867 #[test]
868 fn parse_component_no_attrs_backward_compat() {
869 let doc = "<!-- agent:status -->\nOK\n<!-- /agent:status -->\n";
870 let components = parse(doc).unwrap();
871 assert_eq!(components.len(), 1);
872 assert_eq!(components[0].name, "status");
873 assert!(components[0].attrs.is_empty());
874 }
875
876 #[test]
877 fn is_agent_marker_with_attrs() {
878 assert!(is_agent_marker(" agent:exchange mode=append "));
879 assert!(is_agent_marker("agent:status mode=replace"));
880 assert!(is_agent_marker("agent:log mode=prepend timestamp=true"));
881 }
882
883 #[test]
884 fn closing_tag_unchanged_with_attrs() {
885 let doc = "<!-- agent:status mode=replace -->\n- [x] Done\n<!-- /agent:status -->\n";
887 let components = parse(doc).unwrap();
888 assert_eq!(components.len(), 1);
889 let new_doc = components[0].replace_content(doc, "- [ ] Todo\n");
890 assert!(new_doc.contains("<!-- agent:status mode=replace -->"));
891 assert!(new_doc.contains("<!-- /agent:status -->"));
892 assert!(new_doc.contains("- [ ] Todo"));
893 }
894
895 #[test]
896 fn parse_component_with_patch_attr() {
897 let doc = "<!-- agent:exchange patch=append -->\nContent\n<!-- /agent:exchange -->\n";
898 let components = parse(doc).unwrap();
899 assert_eq!(components.len(), 1);
900 assert_eq!(components[0].name, "exchange");
901 assert_eq!(components[0].patch_mode(), Some("append"));
902 assert_eq!(components[0].content(doc), "Content\n");
903 }
904
905 #[test]
906 fn patch_attr_takes_precedence_over_mode() {
907 let doc = "<!-- agent:exchange patch=replace mode=append -->\nContent\n<!-- /agent:exchange -->\n";
908 let components = parse(doc).unwrap();
909 assert_eq!(components[0].patch_mode(), Some("replace"));
910 }
911
912 #[test]
913 fn mode_attr_backward_compat() {
914 let doc = "<!-- agent:exchange mode=append -->\nContent\n<!-- /agent:exchange -->\n";
915 let components = parse(doc).unwrap();
916 assert_eq!(components[0].patch_mode(), Some("append"));
917 }
918
919 #[test]
920 fn no_patch_or_mode_attr() {
921 let doc = "<!-- agent:exchange -->\nContent\n<!-- /agent:exchange -->\n";
922 let components = parse(doc).unwrap();
923 assert_eq!(components[0].patch_mode(), None);
924 }
925
926 #[test]
929 fn single_backtick_component_tag_ignored() {
930 let doc = "\
932Use `<!-- agent:pending patch=replace -->` to mark pending sections.
933<!-- agent:real -->
934content
935<!-- /agent:real -->
936";
937 let components = parse(doc).unwrap();
938 assert_eq!(components.len(), 1);
939 assert_eq!(components[0].name, "real");
940 }
941
942 #[test]
943 fn double_backtick_component_tag_ignored() {
944 let doc = "\
946Use ``<!-- agent:pending patch=replace -->`` to mark pending sections.
947<!-- agent:real -->
948content
949<!-- /agent:real -->
950";
951 let components = parse(doc).unwrap();
952 assert_eq!(components.len(), 1);
953 assert_eq!(components[0].name, "real");
954 }
955
956 #[test]
957 fn component_tags_not_in_backticks_still_work() {
958 let doc = "\
960<!-- agent:a -->
961alpha
962<!-- /agent:a -->
963<!-- agent:b patch=append -->
964beta
965<!-- /agent:b -->
966";
967 let components = parse(doc).unwrap();
968 assert_eq!(components.len(), 2);
969 assert_eq!(components[0].name, "a");
970 assert_eq!(components[1].name, "b");
971 assert_eq!(components[1].patch_mode(), Some("append"));
972 }
973
974 #[test]
975 fn mixed_backtick_and_real_tags() {
976 let doc = "\
978Here is an example: `<!-- agent:fake -->` and ``<!-- /agent:fake -->``.
979<!-- agent:real -->
980real content
981<!-- /agent:real -->
982Another example: `<!-- agent:also-fake patch=replace -->` is just documentation.
983";
984 let components = parse(doc).unwrap();
985 assert_eq!(components.len(), 1);
986 assert_eq!(components[0].name, "real");
987 assert_eq!(components[0].content(doc), "real content\n");
988 }
989
990 #[test]
991 fn inline_code_mid_line_with_surrounding_text_ignored() {
992 let doc = "\
995Wrap markers like `<!-- agent:status -->` in backticks to show them literally.
996<!-- agent:real -->
997actual content
998<!-- /agent:real -->
999";
1000 let components = parse(doc).unwrap();
1001 assert_eq!(components.len(), 1);
1002 assert_eq!(components[0].name, "real");
1003 assert_eq!(components[0].content(doc), "actual content\n");
1004 }
1005
1006 #[test]
1007 fn parse_attrs_unit() {
1008 let attrs = parse_attrs("mode=append");
1009 assert_eq!(attrs.get("mode").map(|s| s.as_str()), Some("append"));
1010
1011 let attrs = parse_attrs("mode=replace timestamp=true");
1012 assert_eq!(attrs.len(), 2);
1013
1014 let attrs = parse_attrs("");
1015 assert!(attrs.is_empty());
1016
1017 let attrs = parse_attrs("mode=append broken novalue=");
1019 assert_eq!(attrs.len(), 1);
1020 assert_eq!(attrs.get("mode").map(|s| s.as_str()), Some("append"));
1021 }
1022
1023 #[test]
1024 fn append_with_boundary_skips_code_block() {
1025 let boundary_id = "real-uuid";
1028 let doc = format!(
1029 "<!-- agent:exchange patch=append -->\n\
1030 user prompt\n\
1031 ```\n\
1032 <!-- agent:boundary:{boundary_id} -->\n\
1033 ```\n\
1034 more user text\n\
1035 <!-- agent:boundary:{boundary_id} -->\n\
1036 <!-- /agent:exchange -->\n"
1037 );
1038 let components = parse(&doc).unwrap();
1039 let comp = &components[0];
1040 let result = comp.append_with_boundary(&doc, "### Re: Response\n\nContent here.", boundary_id);
1041
1042 assert!(result.contains("### Re: Response"));
1045 assert!(result.contains("more user text"));
1046 assert!(result.contains(&format!("<!-- agent:boundary:{boundary_id} -->\n```")));
1048 assert!(!result.contains(&format!("more user text\n<!-- agent:boundary:{boundary_id} -->\n<!-- /agent:exchange -->")));
1050 }
1051
1052 #[test]
1053 fn append_with_boundary_no_code_block() {
1054 let boundary_id = "simple-uuid";
1056 let doc = format!(
1057 "<!-- agent:exchange patch=append -->\n\
1058 user prompt\n\
1059 <!-- agent:boundary:{boundary_id} -->\n\
1060 <!-- /agent:exchange -->\n"
1061 );
1062 let components = parse(&doc).unwrap();
1063 let comp = &components[0];
1064 let result = comp.append_with_boundary(&doc, "### Re: Answer\n\nDone.", boundary_id);
1065
1066 assert!(result.contains("### Re: Answer"));
1067 assert!(result.contains("user prompt"));
1068 assert!(!result.contains(&format!("agent:boundary:{boundary_id}")));
1070 assert!(result.contains("agent:boundary:"));
1071 }
1072
1073 #[test]
1076 fn strip_comments_removes_html_comment() {
1077 let result = strip_comments("before\n<!-- a comment -->\nafter\n");
1078 assert_eq!(result, "before\nafter\n");
1079 }
1080
1081 #[test]
1082 fn strip_comments_preserves_agent_markers() {
1083 let input = "text\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
1084 let result = strip_comments(input);
1085 assert!(result.contains("<!-- agent:status -->"));
1086 assert!(result.contains("<!-- /agent:status -->"));
1087 }
1088
1089 #[test]
1090 fn strip_comments_removes_link_ref() {
1091 let result = strip_comments("[//]: # (hidden note)\nvisible\n");
1092 assert_eq!(result, "visible\n");
1093 }
1094}