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            // The boundary is consumed — the skill workflow should call
137            // `agent-doc boundary` again after each checkpoint write to
138            // re-insert it at the end of the exchange.
139            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        // Boundary not found — fall back to normal append
148        self.append_with_caret(doc, content, None)
149    }
150}
151
152/// Valid name: `[a-zA-Z0-9][a-zA-Z0-9-]*`
153fn 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
165/// True if the text inside `<!-- ... -->` is an agent component marker.
166///
167/// Matches `agent:NAME [attrs...]` (open) or `/agent:NAME` (close).
168pub 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        // Opening marker may have attributes after the name: `agent:NAME key=value`
174        let name_part = rest.split_whitespace().next().unwrap_or("");
175        is_valid_name(name_part)
176    } else {
177        false
178    }
179}
180
181/// Parse `key=value` pairs from the attribute portion of an opening marker.
182///
183/// Given the text after `agent:NAME `, parses space-separated `key=value` pairs.
184/// Values are unquoted (no quote support needed for simple mode values).
185fn 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
198/// Find byte ranges of code regions (fenced code blocks + inline code spans).
199/// Markers inside these ranges are treated as literal text, not component markers.
200///
201/// Uses `pulldown-cmark` AST parsing with `offset_iter()` to accurately detect
202/// code regions per the CommonMark spec.
203pub 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            // Inline code span: `code` or ``code``
210            Event::Code(_) => {
211                ranges.push((range.start, range.end));
212            }
213            // Fenced or indented code block: consume until End(CodeBlock)
214            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
231/// Parse all components from a document.
232///
233/// Uses a stack for nesting. Returns components sorted by `open_start`.
234/// Errors on unmatched open/close markers or invalid names.
235/// Skips markers inside fenced code blocks and inline code spans.
236pub 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    // Stack of (name, attrs, open_start, open_end)
242    let mut stack: Vec<(String, HashMap<String, String>, usize, usize)> = Vec::new();
243    let mut pos = 0;
244
245    while pos + 4 <= len {
246        // Look for `<!--`
247        if &bytes[pos..pos + 4] != b"<!--" {
248            pos += 1;
249            continue;
250        }
251
252        // Skip markers inside code regions
253        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        // Find closing `-->`
261        let close = match find_comment_end(bytes, pos + 4) {
262            Some(c) => c,
263            None => {
264                pos += 4;
265                continue;
266            }
267        };
268
269        // close points to the byte after `>`
270        let inner = &doc[marker_start + 4..close - 3]; // between `<!--` and `-->`
271        let trimmed = inner.trim();
272
273        // Determine end offset — consume trailing newline if present
274        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            // Closing marker
281            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            // Skip boundary markers — these are not component markers
306            if rest.starts_with("boundary:") {
307                pos = close;
308                continue;
309            }
310            // Opening marker — may have attributes: `agent:NAME key=value`
311            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
335/// Find the end of an HTML comment (`-->`), returning byte offset past `>`.
336fn 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        // Sorted by open_start — outer first
373        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        // Re-parse should work
460        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        // CommonMark allows up to 3 spaces before fence opener
565        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        // Indented code block inside a component should not cause mismatched errors
583        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        // Tabs and many spaces should still be detected as a fence
603        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        // Fenced block
631        assert!(doc[ranges[0].0..ranges[0].1].contains("code"));
632        // Inline span
633        assert!(doc[ranges[1].0..ranges[1].1].contains("inline"));
634    }
635
636    #[test]
637    fn code_ranges_double_backtick() {
638        // CommonMark: `` `<!--` `` is a code span containing `<!--`
639        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        // `` should not match a single ` close
649        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        // Regression: `` `<!--` `` followed by agent marker should not confuse the parser
659        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    // --- Inline attribute tests ---
671
672    #[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        // Closing tags never have attributes
711        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    // --- Inline backtick code span exclusion tests ---
752
753    #[test]
754    fn single_backtick_component_tag_ignored() {
755        // A component tag wrapped in single backticks should not be parsed
756        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        // A component tag wrapped in double backticks should not be parsed
770        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        // Tags outside of any backticks are parsed normally
784        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        // Some tags in backticks (ignored), some not (parsed)
802        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        // Edge case: component tag inside inline code span on a line with other content
818        // before and after — must not be parsed as a real component marker.
819        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        // Malformed tokens without = are ignored
843        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        // Boundary marker inside a code block should be ignored;
851        // the real marker outside should be used.
852        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        // Response should replace the REAL marker (outside code block),
868        // not the one inside the code block.
869        assert!(result.contains("### Re: Response"));
870        assert!(result.contains("more user text"));
871        // The code block example should be preserved
872        assert!(result.contains(&format!("<!-- agent:boundary:{boundary_id} -->\n```")));
873        // The real marker should be consumed (replaced by response)
874        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        // Normal case: boundary marker not in a code block
880        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        // Marker should be consumed (replaced by response)
894        assert!(!result.contains("agent:boundary:"));
895    }
896}