1use anyhow::{bail, Result};
2use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
3use std::collections::HashMap;
4
5#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct Component {
11 pub name: String,
12 pub attrs: HashMap<String, String>,
14 pub open_start: usize,
16 pub open_end: usize,
18 pub close_start: usize,
20 pub close_end: usize,
22}
23
24impl Component {
25 #[allow(dead_code)] pub fn content<'a>(&self, doc: &'a str) -> &'a str {
28 &doc[self.open_end..self.close_start]
29 }
30
31 pub fn patch_mode(&self) -> Option<&str> {
35 self.attrs.get("patch").map(|s| s.as_str())
36 .or_else(|| self.attrs.get("mode").map(|s| s.as_str()))
37 }
38
39 pub fn replace_content(&self, doc: &str, new_content: &str) -> String {
42 let mut result = String::with_capacity(doc.len() + new_content.len());
43 result.push_str(&doc[..self.open_end]);
44 result.push_str(new_content);
45 result.push_str(&doc[self.close_start..]);
46 result
47 }
48
49 pub fn append_with_caret(&self, doc: &str, content: &str, caret_offset: Option<usize>) -> String {
56 let existing = &doc[self.open_end..self.close_start];
57
58 if let Some(caret) = caret_offset {
59 if caret > self.open_end && caret <= self.close_start {
61 let insert_at = doc[..caret].rfind('\n')
63 .map(|i| i + 1)
64 .unwrap_or(self.open_end);
65
66 let insert_at = insert_at.max(self.open_end);
68
69 let mut result = String::with_capacity(doc.len() + content.len() + 1);
70 result.push_str(&doc[..insert_at]);
71 result.push_str(content.trim_end());
72 result.push('\n');
73 result.push_str(&doc[insert_at..]);
74 return result;
75 }
76 }
77
78 let mut result = String::with_capacity(doc.len() + content.len() + 1);
80 result.push_str(&doc[..self.open_end]);
81 result.push_str(existing.trim_end());
82 result.push('\n');
83 result.push_str(content.trim_end());
84 result.push('\n');
85 result.push_str(&doc[self.close_start..]);
86 result
87 }
88
89 pub fn append_with_boundary(&self, doc: &str, content: &str, boundary_id: &str) -> String {
95 let boundary_marker = format!("<!-- agent:boundary:{} -->", boundary_id);
96 let content_region = &doc[self.open_end..self.close_start];
97 let code_ranges = find_code_ranges(doc);
98
99 let mut search_from = 0;
101 let found_pos = loop {
102 match content_region[search_from..].find(&boundary_marker) {
103 Some(rel_pos) => {
104 let abs_pos = self.open_end + search_from + rel_pos;
105 if code_ranges.iter().any(|&(cs, ce)| abs_pos >= cs && abs_pos < ce) {
106 search_from += rel_pos + boundary_marker.len();
108 continue;
109 }
110 break Some(abs_pos);
111 }
112 None => break None,
113 }
114 };
115
116 if let Some(abs_pos) = found_pos {
117 let line_start = doc[..abs_pos]
119 .rfind('\n')
120 .map(|i| i + 1)
121 .unwrap_or(self.open_end)
122 .max(self.open_end);
123
124 let marker_end = abs_pos + boundary_marker.len();
126 let line_end = if marker_end < self.close_start
127 && doc.as_bytes().get(marker_end) == Some(&b'\n')
128 {
129 marker_end + 1
130 } else {
131 marker_end
132 };
133 let line_end = line_end.min(self.close_start);
134
135 let mut result = String::with_capacity(doc.len() + content.len());
137 result.push_str(&doc[..line_start]);
138 result.push_str(content.trim_end());
139 result.push('\n');
140 result.push_str(&doc[line_end..]);
141 return result;
142 }
143
144 self.append_with_caret(doc, content, None)
146 }
147}
148
149fn is_valid_name(name: &str) -> bool {
151 if name.is_empty() {
152 return false;
153 }
154 let first = name.as_bytes()[0];
155 if !first.is_ascii_alphanumeric() {
156 return false;
157 }
158 name.bytes()
159 .all(|b| b.is_ascii_alphanumeric() || b == b'-')
160}
161
162pub fn is_agent_marker(comment_text: &str) -> bool {
166 let trimmed = comment_text.trim();
167 if let Some(rest) = trimmed.strip_prefix("/agent:") {
168 is_valid_name(rest)
169 } else if let Some(rest) = trimmed.strip_prefix("agent:") {
170 let name_part = rest.split_whitespace().next().unwrap_or("");
172 is_valid_name(name_part)
173 } else {
174 false
175 }
176}
177
178fn parse_attrs(attr_text: &str) -> HashMap<String, String> {
183 let mut attrs = HashMap::new();
184 for token in attr_text.split_whitespace() {
185 if let Some((key, value)) = token.split_once('=')
186 && !key.is_empty()
187 && !value.is_empty()
188 {
189 attrs.insert(key.to_string(), value.to_string());
190 }
191 }
192 attrs
193}
194
195pub fn find_code_ranges(doc: &str) -> Vec<(usize, usize)> {
201 let mut ranges = Vec::new();
202 let parser = Parser::new_ext(doc, Options::empty());
203 let mut iter = parser.into_offset_iter();
204 while let Some((event, range)) = iter.next() {
205 match event {
206 Event::Code(_) => {
208 ranges.push((range.start, range.end));
209 }
210 Event::Start(Tag::CodeBlock(_)) => {
212 let block_start = range.start;
213 let mut block_end = range.end;
214 for (inner_event, inner_range) in iter.by_ref() {
215 block_end = inner_range.end;
216 if matches!(inner_event, Event::End(TagEnd::CodeBlock)) {
217 break;
218 }
219 }
220 ranges.push((block_start, block_end));
221 }
222 _ => {}
223 }
224 }
225 ranges
226}
227
228pub fn parse(doc: &str) -> Result<Vec<Component>> {
234 let bytes = doc.as_bytes();
235 let len = bytes.len();
236 let code_ranges = find_code_ranges(doc);
237 let mut templates: Vec<Component> = Vec::new();
238 let mut stack: Vec<(String, HashMap<String, String>, usize, usize)> = Vec::new();
240 let mut pos = 0;
241
242 while pos + 4 <= len {
243 if &bytes[pos..pos + 4] != b"<!--" {
245 pos += 1;
246 continue;
247 }
248
249 if code_ranges.iter().any(|&(start, end)| pos >= start && pos < end) {
251 pos += 4;
252 continue;
253 }
254
255 let marker_start = pos;
256
257 let close = match find_comment_end(bytes, pos + 4) {
259 Some(c) => c,
260 None => {
261 pos += 4;
262 continue;
263 }
264 };
265
266 let inner = &doc[marker_start + 4..close - 3]; let trimmed = inner.trim();
269
270 let mut marker_end = close;
272 if marker_end < len && bytes[marker_end] == b'\n' {
273 marker_end += 1;
274 }
275
276 if let Some(name) = trimmed.strip_prefix("/agent:") {
277 if !is_valid_name(name) {
279 bail!("invalid component name: '{}'", name);
280 }
281 match stack.pop() {
282 Some((open_name, open_attrs, open_start, open_end)) => {
283 if open_name != name {
284 bail!(
285 "mismatched component: opened '{}' but closed '{}'",
286 open_name,
287 name
288 );
289 }
290 templates.push(Component {
291 name: name.to_string(),
292 attrs: open_attrs,
293 open_start,
294 open_end,
295 close_start: marker_start,
296 close_end: marker_end,
297 });
298 }
299 None => bail!("closing marker <!-- /agent:{} --> without matching open", name),
300 }
301 } else if let Some(rest) = trimmed.strip_prefix("agent:") {
302 if rest.starts_with("boundary:") {
304 pos = close;
305 continue;
306 }
307 let mut parts = rest.splitn(2, |c: char| c.is_whitespace());
309 let name = parts.next().unwrap_or("");
310 let attr_text = parts.next().unwrap_or("");
311 if !is_valid_name(name) {
312 bail!("invalid component name: '{}'", name);
313 }
314 let attrs = parse_attrs(attr_text);
315 stack.push((name.to_string(), attrs, marker_start, marker_end));
316 }
317
318 pos = close;
319 }
320
321 if let Some((name, _, _, _)) = stack.last() {
322 bail!(
323 "unclosed component: <!-- agent:{} --> without matching close",
324 name
325 );
326 }
327
328 templates.sort_by_key(|t| t.open_start);
329 Ok(templates)
330}
331
332fn find_comment_end(bytes: &[u8], start: usize) -> Option<usize> {
334 let len = bytes.len();
335 let mut i = start;
336 while i + 3 <= len {
337 if &bytes[i..i + 3] == b"-->" {
338 return Some(i + 3);
339 }
340 i += 1;
341 }
342 None
343}
344
345#[cfg(test)]
346mod tests {
347 use super::*;
348
349 #[test]
350 fn single_range() {
351 let doc = "before\n<!-- agent:status -->\nHello\n<!-- /agent:status -->\nafter\n";
352 let ranges = parse(doc).unwrap();
353 assert_eq!(ranges.len(), 1);
354 assert_eq!(ranges[0].name, "status");
355 assert_eq!(ranges[0].content(doc), "Hello\n");
356 }
357
358 #[test]
359 fn nested_ranges() {
360 let doc = "\
361<!-- agent:outer -->
362<!-- agent:inner -->
363content
364<!-- /agent:inner -->
365<!-- /agent:outer -->
366";
367 let ranges = parse(doc).unwrap();
368 assert_eq!(ranges.len(), 2);
369 assert_eq!(ranges[0].name, "outer");
371 assert_eq!(ranges[1].name, "inner");
372 assert_eq!(ranges[1].content(doc), "content\n");
373 }
374
375 #[test]
376 fn siblings() {
377 let doc = "\
378<!-- agent:a -->
379alpha
380<!-- /agent:a -->
381<!-- agent:b -->
382beta
383<!-- /agent:b -->
384";
385 let ranges = parse(doc).unwrap();
386 assert_eq!(ranges.len(), 2);
387 assert_eq!(ranges[0].name, "a");
388 assert_eq!(ranges[0].content(doc), "alpha\n");
389 assert_eq!(ranges[1].name, "b");
390 assert_eq!(ranges[1].content(doc), "beta\n");
391 }
392
393 #[test]
394 fn no_ranges() {
395 let doc = "# Just a document\n\nWith no range templates.\n";
396 let ranges = parse(doc).unwrap();
397 assert!(ranges.is_empty());
398 }
399
400 #[test]
401 fn unmatched_open_error() {
402 let doc = "<!-- agent:orphan -->\nContent\n";
403 let err = parse(doc).unwrap_err();
404 assert!(err.to_string().contains("unclosed component"));
405 }
406
407 #[test]
408 fn unmatched_close_error() {
409 let doc = "Content\n<!-- /agent:orphan -->\n";
410 let err = parse(doc).unwrap_err();
411 assert!(err.to_string().contains("without matching open"));
412 }
413
414 #[test]
415 fn mismatched_names_error() {
416 let doc = "<!-- agent:foo -->\n<!-- /agent:bar -->\n";
417 let err = parse(doc).unwrap_err();
418 assert!(err.to_string().contains("mismatched"));
419 }
420
421 #[test]
422 fn invalid_name() {
423 let doc = "<!-- agent:-bad -->\n<!-- /agent:-bad -->\n";
424 let err = parse(doc).unwrap_err();
425 assert!(err.to_string().contains("invalid component name"));
426 }
427
428 #[test]
429 fn name_validation() {
430 assert!(is_valid_name("status"));
431 assert!(is_valid_name("my-section"));
432 assert!(is_valid_name("a1"));
433 assert!(is_valid_name("A"));
434 assert!(!is_valid_name(""));
435 assert!(!is_valid_name("-bad"));
436 assert!(!is_valid_name("has space"));
437 assert!(!is_valid_name("has_underscore"));
438 }
439
440 #[test]
441 fn content_extraction() {
442 let doc = "<!-- agent:x -->\nfoo\nbar\n<!-- /agent:x -->\n";
443 let ranges = parse(doc).unwrap();
444 assert_eq!(ranges[0].content(doc), "foo\nbar\n");
445 }
446
447 #[test]
448 fn replace_roundtrip() {
449 let doc = "before\n<!-- agent:s -->\nold\n<!-- /agent:s -->\nafter\n";
450 let ranges = parse(doc).unwrap();
451 let new_doc = ranges[0].replace_content(doc, "new\n");
452 assert_eq!(
453 new_doc,
454 "before\n<!-- agent:s -->\nnew\n<!-- /agent:s -->\nafter\n"
455 );
456 let ranges2 = parse(&new_doc).unwrap();
458 assert_eq!(ranges2.len(), 1);
459 assert_eq!(ranges2[0].content(&new_doc), "new\n");
460 }
461
462 #[test]
463 fn is_agent_marker_yes() {
464 assert!(is_agent_marker(" agent:status "));
465 assert!(is_agent_marker("/agent:status"));
466 assert!(is_agent_marker("agent:my-thing"));
467 assert!(is_agent_marker(" /agent:A1 "));
468 }
469
470 #[test]
471 fn is_agent_marker_no() {
472 assert!(!is_agent_marker("just a comment"));
473 assert!(!is_agent_marker("agent:"));
474 assert!(!is_agent_marker("/agent:"));
475 assert!(!is_agent_marker("agent:-bad"));
476 assert!(!is_agent_marker("some agent:fake stuff"));
477 }
478
479 #[test]
480 fn regular_comments_ignored() {
481 let doc = "<!-- just a comment -->\n<!-- agent:x -->\ndata\n<!-- /agent:x -->\n";
482 let ranges = parse(doc).unwrap();
483 assert_eq!(ranges.len(), 1);
484 assert_eq!(ranges[0].name, "x");
485 }
486
487 #[test]
488 fn multiline_comment_ignored() {
489 let doc = "\
490<!--
491multi
492line
493comment
494-->
495<!-- agent:s -->
496content
497<!-- /agent:s -->
498";
499 let ranges = parse(doc).unwrap();
500 assert_eq!(ranges.len(), 1);
501 assert_eq!(ranges[0].name, "s");
502 }
503
504 #[test]
505 fn empty_content() {
506 let doc = "<!-- agent:empty --><!-- /agent:empty -->\n";
507 let ranges = parse(doc).unwrap();
508 assert_eq!(ranges.len(), 1);
509 assert_eq!(ranges[0].content(doc), "");
510 }
511
512 #[test]
513 fn markers_in_fenced_code_block_ignored() {
514 let doc = "\
515<!-- agent:real -->
516content
517<!-- /agent:real -->
518```markdown
519<!-- agent:fake -->
520this is just an example
521<!-- /agent:fake -->
522```
523";
524 let ranges = parse(doc).unwrap();
525 assert_eq!(ranges.len(), 1);
526 assert_eq!(ranges[0].name, "real");
527 }
528
529 #[test]
530 fn markers_in_inline_code_ignored() {
531 let doc = "\
532Use `<!-- agent:example -->` markers for components.
533<!-- agent:real -->
534content
535<!-- /agent:real -->
536";
537 let ranges = parse(doc).unwrap();
538 assert_eq!(ranges.len(), 1);
539 assert_eq!(ranges[0].name, "real");
540 }
541
542 #[test]
543 fn markers_in_tilde_fence_ignored() {
544 let doc = "\
545<!-- agent:x -->
546data
547<!-- /agent:x -->
548~~~
549<!-- agent:y -->
550example
551<!-- /agent:y -->
552~~~
553";
554 let ranges = parse(doc).unwrap();
555 assert_eq!(ranges.len(), 1);
556 assert_eq!(ranges[0].name, "x");
557 }
558
559 #[test]
560 fn markers_in_indented_fenced_code_block_ignored() {
561 let doc = "\
563<!-- agent:exchange -->
564Content here.
565<!-- /agent:exchange -->
566
567 ```markdown
568 <!-- agent:fake -->
569 demo without closing tag
570 ```
571";
572 let ranges = parse(doc).unwrap();
573 assert_eq!(ranges.len(), 1);
574 assert_eq!(ranges[0].name, "exchange");
575 }
576
577 #[test]
578 fn indented_fence_inside_component_ignored() {
579 let doc = "\
581<!-- agent:exchange -->
582Here's how to set up:
583
584 ```markdown
585 <!-- agent:status -->
586 Your status here
587 ```
588
589Done explaining.
590<!-- /agent:exchange -->
591";
592 let ranges = parse(doc).unwrap();
593 assert_eq!(ranges.len(), 1);
594 assert_eq!(ranges[0].name, "exchange");
595 }
596
597 #[test]
598 fn deeply_indented_fence_ignored() {
599 let doc = "\
601<!-- agent:x -->
602ok
603<!-- /agent:x -->
604 ```
605 <!-- agent:y -->
606 inside fence
607 ```
608";
609 let ranges = parse(doc).unwrap();
610 assert_eq!(ranges.len(), 1);
611 assert_eq!(ranges[0].name, "x");
612 }
613
614 #[test]
615 fn indented_fence_code_ranges_detected() {
616 let doc = "before\n ```\n code\n ```\nafter\n";
617 let ranges = find_code_ranges(doc);
618 assert_eq!(ranges.len(), 1);
619 assert!(doc[ranges[0].0..ranges[0].1].contains("code"));
620 }
621
622 #[test]
623 fn code_ranges_detected() {
624 let doc = "before\n```\ncode\n```\nafter `inline` end\n";
625 let ranges = find_code_ranges(doc);
626 assert_eq!(ranges.len(), 2);
627 assert!(doc[ranges[0].0..ranges[0].1].contains("code"));
629 assert!(doc[ranges[1].0..ranges[1].1].contains("inline"));
631 }
632
633 #[test]
634 fn code_ranges_double_backtick() {
635 let doc = "text `` `<!--` `` more\n";
637 let ranges = find_code_ranges(doc);
638 assert_eq!(ranges.len(), 1);
639 let span = &doc[ranges[0].0..ranges[0].1];
640 assert!(span.contains("<!--"), "double-backtick span should contain <!--: {:?}", span);
641 }
642
643 #[test]
644 fn code_ranges_double_backtick_does_not_match_single() {
645 let doc = "text `` foo ` bar `` end\n";
647 let ranges = find_code_ranges(doc);
648 assert_eq!(ranges.len(), 1);
649 let span = &doc[ranges[0].0..ranges[0].1];
650 assert_eq!(span, "`` foo ` bar ``");
651 }
652
653 #[test]
654 fn double_backtick_comment_before_agent_marker() {
655 let doc = "\
657<!-- agent:exchange -->\n\
658text `` `<!--` `` description\n\
659new content here\n\
660<!-- /agent:exchange -->\n";
661 let components = parse(doc).unwrap();
662 assert_eq!(components.len(), 1);
663 assert_eq!(components[0].name, "exchange");
664 assert!(components[0].content(doc).contains("new content here"));
665 }
666
667 #[test]
670 fn parse_component_with_mode_attr() {
671 let doc = "<!-- agent:exchange mode=append -->\nContent\n<!-- /agent:exchange -->\n";
672 let components = parse(doc).unwrap();
673 assert_eq!(components.len(), 1);
674 assert_eq!(components[0].name, "exchange");
675 assert_eq!(components[0].attrs.get("mode").map(|s| s.as_str()), Some("append"));
676 assert_eq!(components[0].content(doc), "Content\n");
677 }
678
679 #[test]
680 fn parse_component_with_multiple_attrs() {
681 let doc = "<!-- agent:log mode=prepend timestamp=true -->\nData\n<!-- /agent:log -->\n";
682 let components = parse(doc).unwrap();
683 assert_eq!(components.len(), 1);
684 assert_eq!(components[0].name, "log");
685 assert_eq!(components[0].attrs.get("mode").map(|s| s.as_str()), Some("prepend"));
686 assert_eq!(components[0].attrs.get("timestamp").map(|s| s.as_str()), Some("true"));
687 }
688
689 #[test]
690 fn parse_component_no_attrs_backward_compat() {
691 let doc = "<!-- agent:status -->\nOK\n<!-- /agent:status -->\n";
692 let components = parse(doc).unwrap();
693 assert_eq!(components.len(), 1);
694 assert_eq!(components[0].name, "status");
695 assert!(components[0].attrs.is_empty());
696 }
697
698 #[test]
699 fn is_agent_marker_with_attrs() {
700 assert!(is_agent_marker(" agent:exchange mode=append "));
701 assert!(is_agent_marker("agent:status mode=replace"));
702 assert!(is_agent_marker("agent:log mode=prepend timestamp=true"));
703 }
704
705 #[test]
706 fn closing_tag_unchanged_with_attrs() {
707 let doc = "<!-- agent:status mode=replace -->\n- [x] Done\n<!-- /agent:status -->\n";
709 let components = parse(doc).unwrap();
710 assert_eq!(components.len(), 1);
711 let new_doc = components[0].replace_content(doc, "- [ ] Todo\n");
712 assert!(new_doc.contains("<!-- agent:status mode=replace -->"));
713 assert!(new_doc.contains("<!-- /agent:status -->"));
714 assert!(new_doc.contains("- [ ] Todo"));
715 }
716
717 #[test]
718 fn parse_component_with_patch_attr() {
719 let doc = "<!-- agent:exchange patch=append -->\nContent\n<!-- /agent:exchange -->\n";
720 let components = parse(doc).unwrap();
721 assert_eq!(components.len(), 1);
722 assert_eq!(components[0].name, "exchange");
723 assert_eq!(components[0].patch_mode(), Some("append"));
724 assert_eq!(components[0].content(doc), "Content\n");
725 }
726
727 #[test]
728 fn patch_attr_takes_precedence_over_mode() {
729 let doc = "<!-- agent:exchange patch=replace mode=append -->\nContent\n<!-- /agent:exchange -->\n";
730 let components = parse(doc).unwrap();
731 assert_eq!(components[0].patch_mode(), Some("replace"));
732 }
733
734 #[test]
735 fn mode_attr_backward_compat() {
736 let doc = "<!-- agent:exchange mode=append -->\nContent\n<!-- /agent:exchange -->\n";
737 let components = parse(doc).unwrap();
738 assert_eq!(components[0].patch_mode(), Some("append"));
739 }
740
741 #[test]
742 fn no_patch_or_mode_attr() {
743 let doc = "<!-- agent:exchange -->\nContent\n<!-- /agent:exchange -->\n";
744 let components = parse(doc).unwrap();
745 assert_eq!(components[0].patch_mode(), None);
746 }
747
748 #[test]
751 fn single_backtick_component_tag_ignored() {
752 let doc = "\
754Use `<!-- agent:pending patch=replace -->` to mark pending sections.
755<!-- agent:real -->
756content
757<!-- /agent:real -->
758";
759 let components = parse(doc).unwrap();
760 assert_eq!(components.len(), 1);
761 assert_eq!(components[0].name, "real");
762 }
763
764 #[test]
765 fn double_backtick_component_tag_ignored() {
766 let doc = "\
768Use ``<!-- agent:pending patch=replace -->`` to mark pending sections.
769<!-- agent:real -->
770content
771<!-- /agent:real -->
772";
773 let components = parse(doc).unwrap();
774 assert_eq!(components.len(), 1);
775 assert_eq!(components[0].name, "real");
776 }
777
778 #[test]
779 fn component_tags_not_in_backticks_still_work() {
780 let doc = "\
782<!-- agent:a -->
783alpha
784<!-- /agent:a -->
785<!-- agent:b patch=append -->
786beta
787<!-- /agent:b -->
788";
789 let components = parse(doc).unwrap();
790 assert_eq!(components.len(), 2);
791 assert_eq!(components[0].name, "a");
792 assert_eq!(components[1].name, "b");
793 assert_eq!(components[1].patch_mode(), Some("append"));
794 }
795
796 #[test]
797 fn mixed_backtick_and_real_tags() {
798 let doc = "\
800Here is an example: `<!-- agent:fake -->` and ``<!-- /agent:fake -->``.
801<!-- agent:real -->
802real content
803<!-- /agent:real -->
804Another example: `<!-- agent:also-fake patch=replace -->` is just documentation.
805";
806 let components = parse(doc).unwrap();
807 assert_eq!(components.len(), 1);
808 assert_eq!(components[0].name, "real");
809 assert_eq!(components[0].content(doc), "real content\n");
810 }
811
812 #[test]
813 fn inline_code_mid_line_with_surrounding_text_ignored() {
814 let doc = "\
817Wrap markers like `<!-- agent:status -->` in backticks to show them literally.
818<!-- agent:real -->
819actual content
820<!-- /agent:real -->
821";
822 let components = parse(doc).unwrap();
823 assert_eq!(components.len(), 1);
824 assert_eq!(components[0].name, "real");
825 assert_eq!(components[0].content(doc), "actual content\n");
826 }
827
828 #[test]
829 fn parse_attrs_unit() {
830 let attrs = parse_attrs("mode=append");
831 assert_eq!(attrs.get("mode").map(|s| s.as_str()), Some("append"));
832
833 let attrs = parse_attrs("mode=replace timestamp=true");
834 assert_eq!(attrs.len(), 2);
835
836 let attrs = parse_attrs("");
837 assert!(attrs.is_empty());
838
839 let attrs = parse_attrs("mode=append broken novalue=");
841 assert_eq!(attrs.len(), 1);
842 assert_eq!(attrs.get("mode").map(|s| s.as_str()), Some("append"));
843 }
844
845 #[test]
846 fn append_with_boundary_skips_code_block() {
847 let boundary_id = "real-uuid";
850 let doc = format!(
851 "<!-- agent:exchange patch=append -->\n\
852 user prompt\n\
853 ```\n\
854 <!-- agent:boundary:{boundary_id} -->\n\
855 ```\n\
856 more user text\n\
857 <!-- agent:boundary:{boundary_id} -->\n\
858 <!-- /agent:exchange -->\n"
859 );
860 let components = parse(&doc).unwrap();
861 let comp = &components[0];
862 let result = comp.append_with_boundary(&doc, "### Re: Response\n\nContent here.", boundary_id);
863
864 assert!(result.contains("### Re: Response"));
867 assert!(result.contains("more user text"));
868 assert!(result.contains(&format!("<!-- agent:boundary:{boundary_id} -->\n```")));
870 assert!(!result.contains(&format!("more user text\n<!-- agent:boundary:{boundary_id} -->\n<!-- /agent:exchange -->")));
872 }
873
874 #[test]
875 fn append_with_boundary_no_code_block() {
876 let boundary_id = "simple-uuid";
878 let doc = format!(
879 "<!-- agent:exchange patch=append -->\n\
880 user prompt\n\
881 <!-- agent:boundary:{boundary_id} -->\n\
882 <!-- /agent:exchange -->\n"
883 );
884 let components = parse(&doc).unwrap();
885 let comp = &components[0];
886 let result = comp.append_with_boundary(&doc, "### Re: Answer\n\nDone.", boundary_id);
887
888 assert!(result.contains("### Re: Answer"));
889 assert!(result.contains("user prompt"));
890 assert!(!result.contains("agent:boundary:"));
892 }
893}