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
98        if let Some(rel_pos) = content_region.find(&boundary_marker) {
99            let abs_pos = self.open_end + rel_pos;
100
101            // Find start of the line containing the marker
102            let line_start = doc[..abs_pos]
103                .rfind('\n')
104                .map(|i| i + 1)
105                .unwrap_or(self.open_end)
106                .max(self.open_end);
107
108            // Find end of the marker line (including trailing newline)
109            let marker_end = abs_pos + boundary_marker.len();
110            let line_end = if marker_end < self.close_start
111                && doc.as_bytes().get(marker_end) == Some(&b'\n')
112            {
113                marker_end + 1
114            } else {
115                marker_end
116            };
117            let line_end = line_end.min(self.close_start);
118
119            // Replace the boundary marker line with the response content
120            let mut result = String::with_capacity(doc.len() + content.len());
121            result.push_str(&doc[..line_start]);
122            result.push_str(content.trim_end());
123            result.push('\n');
124            result.push_str(&doc[line_end..]);
125            return result;
126        }
127
128        // Boundary not found — fall back to normal append
129        self.append_with_caret(doc, content, None)
130    }
131}
132
133/// Valid name: `[a-zA-Z0-9][a-zA-Z0-9-]*`
134fn is_valid_name(name: &str) -> bool {
135    if name.is_empty() {
136        return false;
137    }
138    let first = name.as_bytes()[0];
139    if !first.is_ascii_alphanumeric() {
140        return false;
141    }
142    name.bytes()
143        .all(|b| b.is_ascii_alphanumeric() || b == b'-')
144}
145
146/// True if the text inside `<!-- ... -->` is an agent component marker.
147///
148/// Matches `agent:NAME [attrs...]` (open) or `/agent:NAME` (close).
149pub fn is_agent_marker(comment_text: &str) -> bool {
150    let trimmed = comment_text.trim();
151    if let Some(rest) = trimmed.strip_prefix("/agent:") {
152        is_valid_name(rest)
153    } else if let Some(rest) = trimmed.strip_prefix("agent:") {
154        // Opening marker may have attributes after the name: `agent:NAME key=value`
155        let name_part = rest.split_whitespace().next().unwrap_or("");
156        is_valid_name(name_part)
157    } else {
158        false
159    }
160}
161
162/// Parse `key=value` pairs from the attribute portion of an opening marker.
163///
164/// Given the text after `agent:NAME `, parses space-separated `key=value` pairs.
165/// Values are unquoted (no quote support needed for simple mode values).
166fn parse_attrs(attr_text: &str) -> HashMap<String, String> {
167    let mut attrs = HashMap::new();
168    for token in attr_text.split_whitespace() {
169        if let Some((key, value)) = token.split_once('=')
170            && !key.is_empty()
171            && !value.is_empty()
172        {
173            attrs.insert(key.to_string(), value.to_string());
174        }
175    }
176    attrs
177}
178
179/// Find byte ranges of code regions (fenced code blocks + inline code spans).
180/// Markers inside these ranges are treated as literal text, not component markers.
181///
182/// Uses `pulldown-cmark` AST parsing with `offset_iter()` to accurately detect
183/// code regions per the CommonMark spec.
184pub fn find_code_ranges(doc: &str) -> Vec<(usize, usize)> {
185    let mut ranges = Vec::new();
186    let parser = Parser::new_ext(doc, Options::empty());
187    let mut iter = parser.into_offset_iter();
188    while let Some((event, range)) = iter.next() {
189        match event {
190            // Inline code span: `code` or ``code``
191            Event::Code(_) => {
192                ranges.push((range.start, range.end));
193            }
194            // Fenced or indented code block: consume until End(CodeBlock)
195            Event::Start(Tag::CodeBlock(_)) => {
196                let block_start = range.start;
197                let mut block_end = range.end;
198                for (inner_event, inner_range) in iter.by_ref() {
199                    block_end = inner_range.end;
200                    if matches!(inner_event, Event::End(TagEnd::CodeBlock)) {
201                        break;
202                    }
203                }
204                ranges.push((block_start, block_end));
205            }
206            _ => {}
207        }
208    }
209    ranges
210}
211
212/// Parse all components from a document.
213///
214/// Uses a stack for nesting. Returns components sorted by `open_start`.
215/// Errors on unmatched open/close markers or invalid names.
216/// Skips markers inside fenced code blocks and inline code spans.
217pub fn parse(doc: &str) -> Result<Vec<Component>> {
218    let bytes = doc.as_bytes();
219    let len = bytes.len();
220    let code_ranges = find_code_ranges(doc);
221    let mut templates: Vec<Component> = Vec::new();
222    // Stack of (name, attrs, open_start, open_end)
223    let mut stack: Vec<(String, HashMap<String, String>, usize, usize)> = Vec::new();
224    let mut pos = 0;
225
226    while pos + 4 <= len {
227        // Look for `<!--`
228        if &bytes[pos..pos + 4] != b"<!--" {
229            pos += 1;
230            continue;
231        }
232
233        // Skip markers inside code regions
234        if code_ranges.iter().any(|&(start, end)| pos >= start && pos < end) {
235            pos += 4;
236            continue;
237        }
238
239        let marker_start = pos;
240
241        // Find closing `-->`
242        let close = match find_comment_end(bytes, pos + 4) {
243            Some(c) => c,
244            None => {
245                pos += 4;
246                continue;
247            }
248        };
249
250        // close points to the byte after `>`
251        let inner = &doc[marker_start + 4..close - 3]; // between `<!--` and `-->`
252        let trimmed = inner.trim();
253
254        // Determine end offset — consume trailing newline if present
255        let mut marker_end = close;
256        if marker_end < len && bytes[marker_end] == b'\n' {
257            marker_end += 1;
258        }
259
260        if let Some(name) = trimmed.strip_prefix("/agent:") {
261            // Closing marker
262            if !is_valid_name(name) {
263                bail!("invalid component name: '{}'", name);
264            }
265            match stack.pop() {
266                Some((open_name, open_attrs, open_start, open_end)) => {
267                    if open_name != name {
268                        bail!(
269                            "mismatched component: opened '{}' but closed '{}'",
270                            open_name,
271                            name
272                        );
273                    }
274                    templates.push(Component {
275                        name: name.to_string(),
276                        attrs: open_attrs,
277                        open_start,
278                        open_end,
279                        close_start: marker_start,
280                        close_end: marker_end,
281                    });
282                }
283                None => bail!("closing marker <!-- /agent:{} --> without matching open", name),
284            }
285        } else if let Some(rest) = trimmed.strip_prefix("agent:") {
286            // Skip boundary markers — these are not component markers
287            if rest.starts_with("boundary:") {
288                pos = close;
289                continue;
290            }
291            // Opening marker — may have attributes: `agent:NAME key=value`
292            let mut parts = rest.splitn(2, |c: char| c.is_whitespace());
293            let name = parts.next().unwrap_or("");
294            let attr_text = parts.next().unwrap_or("");
295            if !is_valid_name(name) {
296                bail!("invalid component name: '{}'", name);
297            }
298            let attrs = parse_attrs(attr_text);
299            stack.push((name.to_string(), attrs, marker_start, marker_end));
300        }
301
302        pos = close;
303    }
304
305    if let Some((name, _, _, _)) = stack.last() {
306        bail!(
307            "unclosed component: <!-- agent:{} --> without matching close",
308            name
309        );
310    }
311
312    templates.sort_by_key(|t| t.open_start);
313    Ok(templates)
314}
315
316/// Find the end of an HTML comment (`-->`), returning byte offset past `>`.
317fn find_comment_end(bytes: &[u8], start: usize) -> Option<usize> {
318    let len = bytes.len();
319    let mut i = start;
320    while i + 3 <= len {
321        if &bytes[i..i + 3] == b"-->" {
322            return Some(i + 3);
323        }
324        i += 1;
325    }
326    None
327}
328
329#[cfg(test)]
330mod tests {
331    use super::*;
332
333    #[test]
334    fn single_range() {
335        let doc = "before\n<!-- agent:status -->\nHello\n<!-- /agent:status -->\nafter\n";
336        let ranges = parse(doc).unwrap();
337        assert_eq!(ranges.len(), 1);
338        assert_eq!(ranges[0].name, "status");
339        assert_eq!(ranges[0].content(doc), "Hello\n");
340    }
341
342    #[test]
343    fn nested_ranges() {
344        let doc = "\
345<!-- agent:outer -->
346<!-- agent:inner -->
347content
348<!-- /agent:inner -->
349<!-- /agent:outer -->
350";
351        let ranges = parse(doc).unwrap();
352        assert_eq!(ranges.len(), 2);
353        // Sorted by open_start — outer first
354        assert_eq!(ranges[0].name, "outer");
355        assert_eq!(ranges[1].name, "inner");
356        assert_eq!(ranges[1].content(doc), "content\n");
357    }
358
359    #[test]
360    fn siblings() {
361        let doc = "\
362<!-- agent:a -->
363alpha
364<!-- /agent:a -->
365<!-- agent:b -->
366beta
367<!-- /agent:b -->
368";
369        let ranges = parse(doc).unwrap();
370        assert_eq!(ranges.len(), 2);
371        assert_eq!(ranges[0].name, "a");
372        assert_eq!(ranges[0].content(doc), "alpha\n");
373        assert_eq!(ranges[1].name, "b");
374        assert_eq!(ranges[1].content(doc), "beta\n");
375    }
376
377    #[test]
378    fn no_ranges() {
379        let doc = "# Just a document\n\nWith no range templates.\n";
380        let ranges = parse(doc).unwrap();
381        assert!(ranges.is_empty());
382    }
383
384    #[test]
385    fn unmatched_open_error() {
386        let doc = "<!-- agent:orphan -->\nContent\n";
387        let err = parse(doc).unwrap_err();
388        assert!(err.to_string().contains("unclosed component"));
389    }
390
391    #[test]
392    fn unmatched_close_error() {
393        let doc = "Content\n<!-- /agent:orphan -->\n";
394        let err = parse(doc).unwrap_err();
395        assert!(err.to_string().contains("without matching open"));
396    }
397
398    #[test]
399    fn mismatched_names_error() {
400        let doc = "<!-- agent:foo -->\n<!-- /agent:bar -->\n";
401        let err = parse(doc).unwrap_err();
402        assert!(err.to_string().contains("mismatched"));
403    }
404
405    #[test]
406    fn invalid_name() {
407        let doc = "<!-- agent:-bad -->\n<!-- /agent:-bad -->\n";
408        let err = parse(doc).unwrap_err();
409        assert!(err.to_string().contains("invalid component name"));
410    }
411
412    #[test]
413    fn name_validation() {
414        assert!(is_valid_name("status"));
415        assert!(is_valid_name("my-section"));
416        assert!(is_valid_name("a1"));
417        assert!(is_valid_name("A"));
418        assert!(!is_valid_name(""));
419        assert!(!is_valid_name("-bad"));
420        assert!(!is_valid_name("has space"));
421        assert!(!is_valid_name("has_underscore"));
422    }
423
424    #[test]
425    fn content_extraction() {
426        let doc = "<!-- agent:x -->\nfoo\nbar\n<!-- /agent:x -->\n";
427        let ranges = parse(doc).unwrap();
428        assert_eq!(ranges[0].content(doc), "foo\nbar\n");
429    }
430
431    #[test]
432    fn replace_roundtrip() {
433        let doc = "before\n<!-- agent:s -->\nold\n<!-- /agent:s -->\nafter\n";
434        let ranges = parse(doc).unwrap();
435        let new_doc = ranges[0].replace_content(doc, "new\n");
436        assert_eq!(
437            new_doc,
438            "before\n<!-- agent:s -->\nnew\n<!-- /agent:s -->\nafter\n"
439        );
440        // Re-parse should work
441        let ranges2 = parse(&new_doc).unwrap();
442        assert_eq!(ranges2.len(), 1);
443        assert_eq!(ranges2[0].content(&new_doc), "new\n");
444    }
445
446    #[test]
447    fn is_agent_marker_yes() {
448        assert!(is_agent_marker(" agent:status "));
449        assert!(is_agent_marker("/agent:status"));
450        assert!(is_agent_marker("agent:my-thing"));
451        assert!(is_agent_marker(" /agent:A1 "));
452    }
453
454    #[test]
455    fn is_agent_marker_no() {
456        assert!(!is_agent_marker("just a comment"));
457        assert!(!is_agent_marker("agent:"));
458        assert!(!is_agent_marker("/agent:"));
459        assert!(!is_agent_marker("agent:-bad"));
460        assert!(!is_agent_marker("some agent:fake stuff"));
461    }
462
463    #[test]
464    fn regular_comments_ignored() {
465        let doc = "<!-- just a comment -->\n<!-- agent:x -->\ndata\n<!-- /agent:x -->\n";
466        let ranges = parse(doc).unwrap();
467        assert_eq!(ranges.len(), 1);
468        assert_eq!(ranges[0].name, "x");
469    }
470
471    #[test]
472    fn multiline_comment_ignored() {
473        let doc = "\
474<!--
475multi
476line
477comment
478-->
479<!-- agent:s -->
480content
481<!-- /agent:s -->
482";
483        let ranges = parse(doc).unwrap();
484        assert_eq!(ranges.len(), 1);
485        assert_eq!(ranges[0].name, "s");
486    }
487
488    #[test]
489    fn empty_content() {
490        let doc = "<!-- agent:empty --><!-- /agent:empty -->\n";
491        let ranges = parse(doc).unwrap();
492        assert_eq!(ranges.len(), 1);
493        assert_eq!(ranges[0].content(doc), "");
494    }
495
496    #[test]
497    fn markers_in_fenced_code_block_ignored() {
498        let doc = "\
499<!-- agent:real -->
500content
501<!-- /agent:real -->
502```markdown
503<!-- agent:fake -->
504this is just an example
505<!-- /agent:fake -->
506```
507";
508        let ranges = parse(doc).unwrap();
509        assert_eq!(ranges.len(), 1);
510        assert_eq!(ranges[0].name, "real");
511    }
512
513    #[test]
514    fn markers_in_inline_code_ignored() {
515        let doc = "\
516Use `<!-- agent:example -->` markers for components.
517<!-- agent:real -->
518content
519<!-- /agent:real -->
520";
521        let ranges = parse(doc).unwrap();
522        assert_eq!(ranges.len(), 1);
523        assert_eq!(ranges[0].name, "real");
524    }
525
526    #[test]
527    fn markers_in_tilde_fence_ignored() {
528        let doc = "\
529<!-- agent:x -->
530data
531<!-- /agent:x -->
532~~~
533<!-- agent:y -->
534example
535<!-- /agent:y -->
536~~~
537";
538        let ranges = parse(doc).unwrap();
539        assert_eq!(ranges.len(), 1);
540        assert_eq!(ranges[0].name, "x");
541    }
542
543    #[test]
544    fn markers_in_indented_fenced_code_block_ignored() {
545        // CommonMark allows up to 3 spaces before fence opener
546        let doc = "\
547<!-- agent:exchange -->
548Content here.
549<!-- /agent:exchange -->
550
551  ```markdown
552  <!-- agent:fake -->
553  demo without closing tag
554  ```
555";
556        let ranges = parse(doc).unwrap();
557        assert_eq!(ranges.len(), 1);
558        assert_eq!(ranges[0].name, "exchange");
559    }
560
561    #[test]
562    fn indented_fence_inside_component_ignored() {
563        // Indented code block inside a component should not cause mismatched errors
564        let doc = "\
565<!-- agent:exchange -->
566Here's how to set up:
567
568   ```markdown
569   <!-- agent:status -->
570   Your status here
571   ```
572
573Done explaining.
574<!-- /agent:exchange -->
575";
576        let ranges = parse(doc).unwrap();
577        assert_eq!(ranges.len(), 1);
578        assert_eq!(ranges[0].name, "exchange");
579    }
580
581    #[test]
582    fn deeply_indented_fence_ignored() {
583        // Tabs and many spaces should still be detected as a fence
584        let doc = "\
585<!-- agent:x -->
586ok
587<!-- /agent:x -->
588      ```
589      <!-- agent:y -->
590      inside fence
591      ```
592";
593        let ranges = parse(doc).unwrap();
594        assert_eq!(ranges.len(), 1);
595        assert_eq!(ranges[0].name, "x");
596    }
597
598    #[test]
599    fn indented_fence_code_ranges_detected() {
600        let doc = "before\n  ```\n  code\n  ```\nafter\n";
601        let ranges = find_code_ranges(doc);
602        assert_eq!(ranges.len(), 1);
603        assert!(doc[ranges[0].0..ranges[0].1].contains("code"));
604    }
605
606    #[test]
607    fn code_ranges_detected() {
608        let doc = "before\n```\ncode\n```\nafter `inline` end\n";
609        let ranges = find_code_ranges(doc);
610        assert_eq!(ranges.len(), 2);
611        // Fenced block
612        assert!(doc[ranges[0].0..ranges[0].1].contains("code"));
613        // Inline span
614        assert!(doc[ranges[1].0..ranges[1].1].contains("inline"));
615    }
616
617    #[test]
618    fn code_ranges_double_backtick() {
619        // CommonMark: `` `<!--` `` is a code span containing `<!--`
620        let doc = "text `` `<!--` `` more\n";
621        let ranges = find_code_ranges(doc);
622        assert_eq!(ranges.len(), 1);
623        let span = &doc[ranges[0].0..ranges[0].1];
624        assert!(span.contains("<!--"), "double-backtick span should contain <!--: {:?}", span);
625    }
626
627    #[test]
628    fn code_ranges_double_backtick_does_not_match_single() {
629        // `` should not match a single ` close
630        let doc = "text `` foo ` bar `` end\n";
631        let ranges = find_code_ranges(doc);
632        assert_eq!(ranges.len(), 1);
633        let span = &doc[ranges[0].0..ranges[0].1];
634        assert_eq!(span, "`` foo ` bar ``");
635    }
636
637    #[test]
638    fn double_backtick_comment_before_agent_marker() {
639        // Regression: `` `<!--` `` followed by agent marker should not confuse the parser
640        let doc = "\
641<!-- agent:exchange -->\n\
642text `` `<!--` `` description\n\
643new content here\n\
644<!-- /agent:exchange -->\n";
645        let components = parse(doc).unwrap();
646        assert_eq!(components.len(), 1);
647        assert_eq!(components[0].name, "exchange");
648        assert!(components[0].content(doc).contains("new content here"));
649    }
650
651    // --- Inline attribute tests ---
652
653    #[test]
654    fn parse_component_with_mode_attr() {
655        let doc = "<!-- agent:exchange mode=append -->\nContent\n<!-- /agent:exchange -->\n";
656        let components = parse(doc).unwrap();
657        assert_eq!(components.len(), 1);
658        assert_eq!(components[0].name, "exchange");
659        assert_eq!(components[0].attrs.get("mode").map(|s| s.as_str()), Some("append"));
660        assert_eq!(components[0].content(doc), "Content\n");
661    }
662
663    #[test]
664    fn parse_component_with_multiple_attrs() {
665        let doc = "<!-- agent:log mode=prepend timestamp=true -->\nData\n<!-- /agent:log -->\n";
666        let components = parse(doc).unwrap();
667        assert_eq!(components.len(), 1);
668        assert_eq!(components[0].name, "log");
669        assert_eq!(components[0].attrs.get("mode").map(|s| s.as_str()), Some("prepend"));
670        assert_eq!(components[0].attrs.get("timestamp").map(|s| s.as_str()), Some("true"));
671    }
672
673    #[test]
674    fn parse_component_no_attrs_backward_compat() {
675        let doc = "<!-- agent:status -->\nOK\n<!-- /agent:status -->\n";
676        let components = parse(doc).unwrap();
677        assert_eq!(components.len(), 1);
678        assert_eq!(components[0].name, "status");
679        assert!(components[0].attrs.is_empty());
680    }
681
682    #[test]
683    fn is_agent_marker_with_attrs() {
684        assert!(is_agent_marker(" agent:exchange mode=append "));
685        assert!(is_agent_marker("agent:status mode=replace"));
686        assert!(is_agent_marker("agent:log mode=prepend timestamp=true"));
687    }
688
689    #[test]
690    fn closing_tag_unchanged_with_attrs() {
691        // Closing tags never have attributes
692        let doc = "<!-- agent:status mode=replace -->\n- [x] Done\n<!-- /agent:status -->\n";
693        let components = parse(doc).unwrap();
694        assert_eq!(components.len(), 1);
695        let new_doc = components[0].replace_content(doc, "- [ ] Todo\n");
696        assert!(new_doc.contains("<!-- agent:status mode=replace -->"));
697        assert!(new_doc.contains("<!-- /agent:status -->"));
698        assert!(new_doc.contains("- [ ] Todo"));
699    }
700
701    #[test]
702    fn parse_component_with_patch_attr() {
703        let doc = "<!-- agent:exchange patch=append -->\nContent\n<!-- /agent:exchange -->\n";
704        let components = parse(doc).unwrap();
705        assert_eq!(components.len(), 1);
706        assert_eq!(components[0].name, "exchange");
707        assert_eq!(components[0].patch_mode(), Some("append"));
708        assert_eq!(components[0].content(doc), "Content\n");
709    }
710
711    #[test]
712    fn patch_attr_takes_precedence_over_mode() {
713        let doc = "<!-- agent:exchange patch=replace mode=append -->\nContent\n<!-- /agent:exchange -->\n";
714        let components = parse(doc).unwrap();
715        assert_eq!(components[0].patch_mode(), Some("replace"));
716    }
717
718    #[test]
719    fn mode_attr_backward_compat() {
720        let doc = "<!-- agent:exchange mode=append -->\nContent\n<!-- /agent:exchange -->\n";
721        let components = parse(doc).unwrap();
722        assert_eq!(components[0].patch_mode(), Some("append"));
723    }
724
725    #[test]
726    fn no_patch_or_mode_attr() {
727        let doc = "<!-- agent:exchange -->\nContent\n<!-- /agent:exchange -->\n";
728        let components = parse(doc).unwrap();
729        assert_eq!(components[0].patch_mode(), None);
730    }
731
732    // --- Inline backtick code span exclusion tests ---
733
734    #[test]
735    fn single_backtick_component_tag_ignored() {
736        // A component tag wrapped in single backticks should not be parsed
737        let doc = "\
738Use `<!-- agent:pending patch=replace -->` to mark pending sections.
739<!-- agent:real -->
740content
741<!-- /agent:real -->
742";
743        let components = parse(doc).unwrap();
744        assert_eq!(components.len(), 1);
745        assert_eq!(components[0].name, "real");
746    }
747
748    #[test]
749    fn double_backtick_component_tag_ignored() {
750        // A component tag wrapped in double backticks should not be parsed
751        let doc = "\
752Use ``<!-- agent:pending patch=replace -->`` to mark pending sections.
753<!-- agent:real -->
754content
755<!-- /agent:real -->
756";
757        let components = parse(doc).unwrap();
758        assert_eq!(components.len(), 1);
759        assert_eq!(components[0].name, "real");
760    }
761
762    #[test]
763    fn component_tags_not_in_backticks_still_work() {
764        // Tags outside of any backticks are parsed normally
765        let doc = "\
766<!-- agent:a -->
767alpha
768<!-- /agent:a -->
769<!-- agent:b patch=append -->
770beta
771<!-- /agent:b -->
772";
773        let components = parse(doc).unwrap();
774        assert_eq!(components.len(), 2);
775        assert_eq!(components[0].name, "a");
776        assert_eq!(components[1].name, "b");
777        assert_eq!(components[1].patch_mode(), Some("append"));
778    }
779
780    #[test]
781    fn mixed_backtick_and_real_tags() {
782        // Some tags in backticks (ignored), some not (parsed)
783        let doc = "\
784Here is an example: `<!-- agent:fake -->` and ``<!-- /agent:fake -->``.
785<!-- agent:real -->
786real content
787<!-- /agent:real -->
788Another example: `<!-- agent:also-fake patch=replace -->` is just documentation.
789";
790        let components = parse(doc).unwrap();
791        assert_eq!(components.len(), 1);
792        assert_eq!(components[0].name, "real");
793        assert_eq!(components[0].content(doc), "real content\n");
794    }
795
796    #[test]
797    fn inline_code_mid_line_with_surrounding_text_ignored() {
798        // Edge case: component tag inside inline code span on a line with other content
799        // before and after — must not be parsed as a real component marker.
800        let doc = "\
801Wrap markers like `<!-- agent:status -->` in backticks to show them literally.
802<!-- agent:real -->
803actual content
804<!-- /agent:real -->
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), "actual content\n");
810    }
811
812    #[test]
813    fn parse_attrs_unit() {
814        let attrs = parse_attrs("mode=append");
815        assert_eq!(attrs.get("mode").map(|s| s.as_str()), Some("append"));
816
817        let attrs = parse_attrs("mode=replace timestamp=true");
818        assert_eq!(attrs.len(), 2);
819
820        let attrs = parse_attrs("");
821        assert!(attrs.is_empty());
822
823        // Malformed tokens without = are ignored
824        let attrs = parse_attrs("mode=append broken novalue=");
825        assert_eq!(attrs.len(), 1);
826        assert_eq!(attrs.get("mode").map(|s| s.as_str()), Some("append"));
827    }
828}