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