Skip to main content

agent_doc/
component.rs

1use anyhow::{bail, Result};
2use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
3use std::collections::HashMap;
4
5/// A parsed component in a document.
6///
7/// Components are bounded regions marked by `<!-- agent:name -->...<!-- /agent:name -->`.
8/// Opening tags may contain inline attributes: `<!-- agent:name key=value -->`.
9#[derive(Debug, Clone, PartialEq, Eq)]
10pub struct Component {
11    pub name: String,
12    /// Inline attributes parsed from the opening tag (e.g., `patch=append`).
13    pub attrs: HashMap<String, String>,
14    /// Byte offset of `<` in opening marker.
15    pub open_start: usize,
16    /// Byte offset past `>` in opening marker (includes trailing newline if present).
17    pub open_end: usize,
18    /// Byte offset of `<` in closing marker.
19    pub close_start: usize,
20    /// Byte offset past `>` in closing marker (includes trailing newline if present).
21    pub close_end: usize,
22}
23
24impl Component {
25    /// Extract the content between the opening and closing markers.
26    #[allow(dead_code)] // public API — used by tests and future consumers
27    pub fn content<'a>(&self, doc: &'a str) -> &'a str {
28        &doc[self.open_end..self.close_start]
29    }
30
31    /// Get the patch mode from inline attributes.
32    ///
33    /// Checks `patch=` first, falls back to `mode=` for backward compatibility.
34    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    /// Replace the content between markers, returning the new document.
40    /// The markers themselves are preserved.
41    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    /// Append content into this component, inserting before the caret position
50    /// if the caret is inside the component. Falls back to normal append if the
51    /// caret is outside the component.
52    ///
53    /// `caret_offset`: byte offset of the caret in the document. Pass `None` for
54    /// normal append behavior.
55    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            // Check if caret is inside this component
60            if caret > self.open_end && caret <= self.close_start {
61                // Find the line boundary before the caret
62                let insert_at = doc[..caret].rfind('\n')
63                    .map(|i| i + 1)
64                    .unwrap_or(self.open_end);
65
66                // Clamp to component bounds
67                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        // Normal append: add after existing content
79        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    /// Append content into this component at the boundary marker position.
90    ///
91    /// Finds `<!-- agent:boundary:ID -->` inside the component. If found,
92    /// inserts content at the line start of the boundary marker (replacing
93    /// the marker). Falls back to normal append if the boundary is not found.
94    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        // Search for boundary marker, skipping matches inside code blocks
100        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                        // Inside a code block — skip and keep searching
107                        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            // Find start of the line containing the marker
118            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            // Find end of the marker line (including trailing newline)
125            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            // Replace the boundary marker line with the response content
136            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        // Boundary not found — fall back to normal append
145        self.append_with_caret(doc, content, None)
146    }
147}
148
149/// Valid name: `[a-zA-Z0-9][a-zA-Z0-9-]*`
150fn 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
162/// True if the text inside `<!-- ... -->` is an agent component marker.
163///
164/// Matches `agent:NAME [attrs...]` (open) or `/agent:NAME` (close).
165pub 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        // Opening marker may have attributes after the name: `agent:NAME key=value`
171        let name_part = rest.split_whitespace().next().unwrap_or("");
172        is_valid_name(name_part)
173    } else {
174        false
175    }
176}
177
178/// Parse `key=value` pairs from the attribute portion of an opening marker.
179///
180/// Given the text after `agent:NAME `, parses space-separated `key=value` pairs.
181/// Values are unquoted (no quote support needed for simple mode values).
182fn 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
195/// Find byte ranges of code regions (fenced code blocks + inline code spans).
196/// Markers inside these ranges are treated as literal text, not component markers.
197///
198/// Uses `pulldown-cmark` AST parsing with `offset_iter()` to accurately detect
199/// code regions per the CommonMark spec.
200pub 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            // Inline code span: `code` or ``code``
207            Event::Code(_) => {
208                ranges.push((range.start, range.end));
209            }
210            // Fenced or indented code block: consume until End(CodeBlock)
211            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
228/// Parse all components from a document.
229///
230/// Uses a stack for nesting. Returns components sorted by `open_start`.
231/// Errors on unmatched open/close markers or invalid names.
232/// Skips markers inside fenced code blocks and inline code spans.
233pub 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    // Stack of (name, attrs, open_start, open_end)
239    let mut stack: Vec<(String, HashMap<String, String>, usize, usize)> = Vec::new();
240    let mut pos = 0;
241
242    while pos + 4 <= len {
243        // Look for `<!--`
244        if &bytes[pos..pos + 4] != b"<!--" {
245            pos += 1;
246            continue;
247        }
248
249        // Skip markers inside code regions
250        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        // Find closing `-->`
258        let close = match find_comment_end(bytes, pos + 4) {
259            Some(c) => c,
260            None => {
261                pos += 4;
262                continue;
263            }
264        };
265
266        // close points to the byte after `>`
267        let inner = &doc[marker_start + 4..close - 3]; // between `<!--` and `-->`
268        let trimmed = inner.trim();
269
270        // Determine end offset — consume trailing newline if present
271        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            // Closing marker
278            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            // Skip boundary markers — these are not component markers
303            if rest.starts_with("boundary:") {
304                pos = close;
305                continue;
306            }
307            // Opening marker — may have attributes: `agent:NAME key=value`
308            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
332/// Find the end of an HTML comment (`-->`), returning byte offset past `>`.
333fn 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        // Sorted by open_start — outer first
370        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        // Re-parse should work
457        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        // CommonMark allows up to 3 spaces before fence opener
562        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        // Indented code block inside a component should not cause mismatched errors
580        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        // Tabs and many spaces should still be detected as a fence
600        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        // Fenced block
628        assert!(doc[ranges[0].0..ranges[0].1].contains("code"));
629        // Inline span
630        assert!(doc[ranges[1].0..ranges[1].1].contains("inline"));
631    }
632
633    #[test]
634    fn code_ranges_double_backtick() {
635        // CommonMark: `` `<!--` `` is a code span containing `<!--`
636        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        // `` should not match a single ` close
646        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        // Regression: `` `<!--` `` followed by agent marker should not confuse the parser
656        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    // --- Inline attribute tests ---
668
669    #[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        // Closing tags never have attributes
708        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    // --- Inline backtick code span exclusion tests ---
749
750    #[test]
751    fn single_backtick_component_tag_ignored() {
752        // A component tag wrapped in single backticks should not be parsed
753        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        // A component tag wrapped in double backticks should not be parsed
767        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        // Tags outside of any backticks are parsed normally
781        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        // Some tags in backticks (ignored), some not (parsed)
799        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        // Edge case: component tag inside inline code span on a line with other content
815        // before and after — must not be parsed as a real component marker.
816        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        // Malformed tokens without = are ignored
840        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        // Boundary marker inside a code block should be ignored;
848        // the real marker outside should be used.
849        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        // Response should replace the REAL marker (outside code block),
865        // not the one inside the code block.
866        assert!(result.contains("### Re: Response"));
867        assert!(result.contains("more user text"));
868        // The code block example should be preserved
869        assert!(result.contains(&format!("<!-- agent:boundary:{boundary_id} -->\n```")));
870        // The real marker should be replaced
871        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        // Normal case: boundary marker not in a code block
877        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        // Marker should be replaced
891        assert!(!result.contains("agent:boundary:"));
892    }
893}