Skip to main content

agent_doc_core/
component.rs

1//! # Module: component
2//!
3//! ## Spec
4//! - Defines `Component`, the parsed representation of a bounded document region delimited by
5//!   `<!-- agent:name [attrs...] -->` (open) and `<!-- /agent:name -->` (close) HTML comments.
6//! - `parse(doc)` scans the raw document bytes for `<!--` / `-->` comment pairs, builds a
7//!   stack-based nesting model, and returns all `Component` values sorted by `open_start`.
8//! - Markers that appear inside fenced code blocks (backtick or tilde) or inline code spans are
9//!   skipped; `find_code_ranges(doc)` uses the `pulldown-cmark` AST (CommonMark-compliant) to
10//!   locate these regions.
11//! - `is_agent_marker(comment_text)` classifies whether the inner text of a comment is an
12//!   agent open/close marker vs. an ordinary HTML comment.
13//! - Component names must match `[a-zA-Z0-9][a-zA-Z0-9-]*`; invalid names, unmatched opens,
14//!   unmatched closes, and mismatched open/close pairs all return `Err`.
15//! - `boundary:*` prefixed markers (`<!-- agent:boundary:ID -->`) are recognised and skipped
16//!   during component parsing; they are not treated as component open tags.
17//! - Opening markers may carry space-separated `key=value` inline attributes
18//!   (e.g., `patch=append`). `patch=` takes precedence over the legacy `mode=` alias;
19//!   `patch_mode()` encapsulates this lookup.
20//! - The non-agent comment fast path only previews comment text on UTF-8 char boundaries, so
21//!   ordinary prose comments near multibyte glyphs never panic the parser.
22//! - Marker `open_end` / `close_end` byte offsets include a trailing newline when present,
23//!   so that content slices are clean.
24//! - `replace_content(doc, new_content)` rebuilds the document preserving both markers,
25//!   replacing only the bytes between them.
26//! - `append_with_caret(doc, content, caret_offset)` appends content after existing text, or
27//!   inserts before the caret line when the caret falls inside the component.
28//! - `append_with_boundary(doc, content, boundary_id)` locates `<!-- agent:boundary:ID -->`
29//!   inside the component (skipping any occurrence that lives inside a code block), replaces it
30//!   with the new content, and re-inserts a fresh boundary marker; falls back to
31//!   `append_with_caret` when no boundary is found.
32//!
33//! ## Agentic Contracts
34//! - `parse` is pure and deterministic: identical input always yields identical output.
35//! - All byte offsets (`open_start`, `open_end`, `close_start`, `close_end`) are valid UTF-8
36//!   char boundaries within the document string they were parsed from.
37//! - `content(doc)` returns exactly `&doc[open_end..close_start]` — no allocation.
38//! - `replace_content` and `append_with_*` never mutate the original `&str`; they return a
39//!   new `String` with all offsets consistent for a fresh `parse` call.
40//! - Markers inside any code region (fenced block or inline span) are never parsed as
41//!   components and never mutated by any append/replace operation.
42//! - `append_with_boundary` always re-inserts a boundary marker with a fresh UUID, preserving
43//!   the invariant that exactly one boundary exists inside the component after each write.
44//! - Unknown or malformed `key=value` tokens in inline attributes are silently discarded;
45//!   they never cause a parse error.
46//!
47//! ## Evals
48//! - single_range: doc with one component → one `Component`, correct name and content slice
49//! - nested_ranges: outer + inner components → two entries sorted outer-first
50//! - siblings: two adjacent components → two entries, each with correct content
51//! - no_ranges: plain markdown → empty vec, no error
52//! - unmatched_open_error: open without close → `Err` containing "unclosed component"
53//! - unmatched_close_error: close without open → `Err` containing "without matching open"
54//! - mismatched_names_error: `<!-- agent:foo -->…<!-- /agent:bar -->` → `Err` "mismatched"
55//! - invalid_name: name starting with `-` → `Err` "invalid component name"
56//! - markers_in_fenced_code_block_ignored: marker inside ``` block → not parsed as component
57//! - markers_in_inline_code_ignored: marker inside `` `…` `` span → not parsed
58//! - markers_in_tilde_fence_ignored: marker inside ~~~ block → not parsed
59//! - markers_in_indented_fenced_code_block_ignored: up-to-3-space-indented fence → not parsed
60//! - double_backtick_comment_before_agent_marker: `` `<!--` `` followed by real marker → one component
61//! - parse_component_with_patch_attr: `patch=append` on opening tag → `patch_mode()` = "append"
62//! - patch_attr_takes_precedence_over_mode: both `patch=` and `mode=` present → `patch=` wins
63//! - mode_attr_backward_compat: only `mode=append` present → `patch_mode()` = "append"
64//! - replace_roundtrip: replace content, re-parse → one component with new content
65//! - append_with_boundary_no_code_block: boundary found → content inserted, old ID consumed, new boundary present
66//! - append_with_boundary_skips_code_block: boundary inside code block skipped, real boundary used
67
68use anyhow::{Result, bail};
69use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
70use std::collections::HashMap;
71
72/// Canonical component name for the backlog/pending component.
73pub const BACKLOG_COMPONENT: &str = "backlog";
74
75/// Legacy alias for the backlog component.
76pub const BACKLOG_ALIAS: &str = "pending";
77
78/// Canonical component name for the completed/reaped backlog archive.
79pub const BACKLOG_DONE_COMPONENT: &str = "done";
80
81/// Canonical component name for tracked work awaiting human review.
82pub const REVIEW_COMPONENT: &str = "review";
83
84/// Canonical component name for the icebox component.
85pub const ICEBOX_COMPONENT: &str = "icebox";
86
87/// Canonical component name for the conversation exchange.
88const EXCHANGE_COMPONENT: &str = "exchange";
89
90/// Check whether a component name refers to the backlog component
91/// (accepts both canonical `"backlog"` and legacy `"pending"`).
92pub fn is_backlog_component(name: &str) -> bool {
93    name == BACKLOG_COMPONENT || name == BACKLOG_ALIAS
94}
95
96/// Check whether a component name refers to the completed/reaped backlog archive.
97pub fn is_backlog_done_component(name: &str) -> bool {
98    name == BACKLOG_DONE_COMPONENT
99}
100
101/// Check whether a component name refers to the review component.
102pub fn is_review_component(name: &str) -> bool {
103    name == REVIEW_COMPONENT
104}
105
106/// Check whether a component name refers to the icebox component.
107pub fn is_icebox_component(name: &str) -> bool {
108    name == ICEBOX_COMPONENT
109}
110
111/// Check whether a component name refers to tracked work that participates in
112/// `do #id` closeout (`agent:backlog` / legacy `agent:pending`,
113/// `agent:review`, and `agent:icebox`).
114pub fn is_tracked_work_component(name: &str) -> bool {
115    is_backlog_component(name) || is_review_component(name) || is_icebox_component(name)
116}
117
118/// Strip deprecated `patch=...` (and legacy `mode=...`) attributes from
119/// backlog/pending component opening tags. The backlog component's patch mode
120/// is always `replace` by built-in default; the inline attribute is redundant
121/// since the binary owns all backlog mutations via `--pending-*` flags.
122///
123/// Emits a deprecation warning to stderr for each stripped attribute.
124pub fn strip_backlog_patch_attr(doc: &str) -> String {
125    let mut result = doc.to_string();
126    let mut warned = false;
127
128    for name in &[BACKLOG_COMPONENT, BACKLOG_ALIAS] {
129        let prefix = format!("<!-- agent:{} ", name);
130        while let Some(start) = result.find(&prefix) {
131            let tag_start = start;
132            let rest = &result[start + prefix.len()..];
133            let Some(end_rel) = rest.find("-->") else {
134                break;
135            };
136            let attrs_text = &rest[..end_rel];
137            let tag_end = start + prefix.len() + end_rel + 3; // past `-->`
138
139            let tokens: Vec<&str> = attrs_text.split_whitespace().collect();
140            let has_patch_or_mode = tokens.iter().any(|t| {
141                t.split_once('=')
142                    .map(|(k, _)| k == "patch" || k == "mode")
143                    .unwrap_or(false)
144            });
145
146            if !has_patch_or_mode {
147                break;
148            }
149
150            if !warned {
151                eprintln!(
152                    "[deprecation] patch/mode attribute on agent:{} is deprecated and will be stripped — \
153                     the binary owns backlog mutations via --pending-* flags",
154                    name
155                );
156                warned = true;
157            }
158
159            let remaining: Vec<&str> = tokens
160                .iter()
161                .filter(|t| {
162                    t.split_once('=')
163                        .map(|(k, _)| k != "patch" && k != "mode")
164                        .unwrap_or(true)
165                })
166                .copied()
167                .collect();
168
169            let new_tag = if remaining.is_empty() {
170                format!("<!-- agent:{} -->", name)
171            } else {
172                format!("<!-- agent:{} {} -->", name, remaining.join(" "))
173            };
174
175            result.replace_range(tag_start..tag_end, &new_tag);
176        }
177    }
178
179    result
180}
181
182/// A parsed component in a document.
183///
184/// Components are bounded regions marked by `<!-- agent:name -->...<!-- /agent:name -->`.
185/// Opening tags may contain inline attributes: `<!-- agent:name key=value -->`.
186#[derive(Debug, Clone, PartialEq, Eq)]
187pub struct Component {
188    pub name: String,
189    /// Inline attributes parsed from the opening tag (e.g., `patch=append`).
190    pub attrs: HashMap<String, String>,
191    /// Byte offset of `<` in opening marker.
192    pub open_start: usize,
193    /// Byte offset past `>` in opening marker (includes trailing newline if present).
194    pub open_end: usize,
195    /// Byte offset of `<` in closing marker.
196    pub close_start: usize,
197    /// Byte offset past `>` in closing marker (includes trailing newline if present).
198    pub close_end: usize,
199}
200
201impl Component {
202    /// Extract the content between the opening and closing markers.
203    #[allow(dead_code)] // public API — used by tests and future consumers
204    pub fn content<'a>(&self, doc: &'a str) -> &'a str {
205        &doc[self.open_end..self.close_start]
206    }
207
208    /// Get the patch mode from inline attributes.
209    ///
210    /// Checks `patch=` first, falls back to `mode=` for backward compatibility.
211    pub fn patch_mode(&self) -> Option<&str> {
212        self.attrs
213            .get("patch")
214            .map(|s| s.as_str())
215            .or_else(|| self.attrs.get("mode").map(|s| s.as_str()))
216    }
217
218    /// Replace the content between markers, returning the new document.
219    /// The markers themselves are preserved.
220    pub fn replace_content(&self, doc: &str, new_content: &str) -> String {
221        let mut result = String::with_capacity(doc.len() + new_content.len());
222        result.push_str(&doc[..self.open_end]);
223        result.push_str(new_content);
224        result.push_str(&doc[self.close_start..]);
225        result
226    }
227
228    /// Append content into this component, inserting before the caret position
229    /// if the caret is inside the component. Falls back to normal append if the
230    /// caret is outside the component.
231    ///
232    /// `caret_offset`: byte offset of the caret in the document. Pass `None` for
233    /// normal append behavior.
234    pub fn append_with_caret(
235        &self,
236        doc: &str,
237        content: &str,
238        caret_offset: Option<usize>,
239    ) -> String {
240        let existing = &doc[self.open_end..self.close_start];
241        if append_patch_already_present(existing, content) {
242            return doc.to_string();
243        }
244
245        if let Some(caret) = caret_offset {
246            // Check if caret is inside this component
247            if caret > self.open_end && caret <= self.close_start {
248                // Find the line boundary before the caret
249                let insert_at = doc[..caret]
250                    .rfind('\n')
251                    .map(|i| i + 1)
252                    .unwrap_or(self.open_end);
253
254                // Clamp to component bounds
255                let insert_at = insert_at.max(self.open_end);
256                let prior = &doc[self.open_end..insert_at];
257
258                let mut result = String::with_capacity(doc.len() + content.len() + 1);
259                result.push_str(&doc[..insert_at]);
260                if needs_exchange_heading_separator(&self.name, prior, content) {
261                    result.push('\n');
262                }
263                result.push_str(content.trim_end());
264                result.push('\n');
265                result.push_str(&doc[insert_at..]);
266                return result;
267            }
268        }
269
270        // Normal append: add after existing content
271        let mut result = String::with_capacity(doc.len() + content.len() + 1);
272        result.push_str(&doc[..self.open_end]);
273        let trimmed_existing = existing.trim_end();
274        result.push_str(trimmed_existing);
275        if needs_exchange_heading_separator(&self.name, trimmed_existing, content) {
276            result.push('\n');
277        }
278        result.push('\n');
279        result.push_str(content.trim_end());
280        result.push('\n');
281        result.push_str(&doc[self.close_start..]);
282        result
283    }
284
285    /// Append content into this component at the boundary marker position.
286    ///
287    /// Finds `<!-- agent:boundary:ID -->` inside the component. If found,
288    /// inserts content at the line start of the boundary marker (replacing
289    /// the marker). Falls back to normal append if the boundary is not found.
290    pub fn append_with_boundary(&self, doc: &str, content: &str, boundary_id: &str) -> String {
291        let boundary_marker = format!("<!-- agent:boundary:{} -->", boundary_id);
292        let content_region = &doc[self.open_end..self.close_start];
293        if append_patch_already_present(content_region, content) {
294            return doc.to_string();
295        }
296        let code_ranges = find_code_ranges(doc);
297
298        // Search for boundary marker, skipping matches inside code blocks
299        let mut search_from = 0;
300        let found_pos = loop {
301            match content_region[search_from..].find(&boundary_marker) {
302                Some(rel_pos) => {
303                    let abs_pos = self.open_end + search_from + rel_pos;
304                    if code_ranges
305                        .iter()
306                        .any(|&(cs, ce)| abs_pos >= cs && abs_pos < ce)
307                    {
308                        // Inside a code block — skip and keep searching
309                        search_from += rel_pos + boundary_marker.len();
310                        continue;
311                    }
312                    break Some(abs_pos);
313                }
314                None => break None,
315            }
316        };
317
318        if let Some(abs_pos) = found_pos {
319            // Find start of the line containing the marker
320            let line_start = doc[..abs_pos]
321                .rfind('\n')
322                .map(|i| i + 1)
323                .unwrap_or(self.open_end)
324                .max(self.open_end);
325
326            // Find end of the marker line (including trailing newline)
327            let marker_end = abs_pos + boundary_marker.len();
328            let line_end = if marker_end < self.close_start
329                && doc.as_bytes().get(marker_end) == Some(&b'\n')
330            {
331                marker_end + 1
332            } else {
333                marker_end
334            };
335            let line_end = line_end.min(self.close_start);
336
337            // Replace the boundary marker with response content + new boundary.
338            // The boundary is consumed and re-inserted, matching the binary's
339            // post-patch behavior in apply_patches_with_overrides().
340            let new_id = crate::id::new_boundary_id();
341            let new_marker = crate::id::format_boundary_marker(&new_id);
342            let mut result = String::with_capacity(doc.len() + content.len() + new_marker.len());
343            let prior = &doc[self.open_end..line_start];
344            result.push_str(&doc[..line_start]);
345            if needs_exchange_heading_separator(&self.name, prior, content) {
346                result.push('\n');
347            }
348            result.push_str(content.trim_end());
349            result.push('\n');
350            result.push_str(&new_marker);
351            result.push('\n');
352            result.push_str(&doc[line_end..]);
353            return result;
354        }
355
356        // Boundary not found — fall back to normal append
357        self.append_with_caret(doc, content, None)
358    }
359}
360
361/// Valid name: `[a-zA-Z0-9][a-zA-Z0-9-]*`
362fn is_valid_name(name: &str) -> bool {
363    if name.is_empty() {
364        return false;
365    }
366    let first = name.as_bytes()[0];
367    if !first.is_ascii_alphanumeric() {
368        return false;
369    }
370    name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
371}
372
373fn append_patch_already_present(existing: &str, content: &str) -> bool {
374    let patch = normalize_append_patch_content(content);
375    if patch.is_empty() {
376        return false;
377    }
378    normalize_append_patch_content(existing).contains(&patch)
379}
380
381fn needs_exchange_heading_separator(component_name: &str, prior: &str, content: &str) -> bool {
382    component_name == EXCHANGE_COMPONENT
383        && !prior.trim().is_empty()
384        && !prior.ends_with("\n\n")
385        && starts_with_response_heading(content)
386}
387
388fn starts_with_response_heading(content: &str) -> bool {
389    content.trim_start().starts_with("### Re:")
390}
391
392fn normalize_append_patch_content(content: &str) -> String {
393    content
394        .lines()
395        .filter_map(|line| {
396            let trimmed = line.trim();
397            if trimmed.starts_with("<!-- agent:boundary:") && trimmed.ends_with(" -->") {
398                None
399            } else if markdown_heading_has_head_marker(line) {
400                Some(line.trim_end_matches(" (HEAD)").to_string())
401            } else {
402                Some(line.to_string())
403            }
404        })
405        .collect::<Vec<_>>()
406        .join("\n")
407        .trim()
408        .to_string()
409}
410
411fn markdown_heading_has_head_marker(line: &str) -> bool {
412    let trimmed = line.trim_start();
413    let hashes = trimmed.bytes().take_while(|b| *b == b'#').count();
414    (1..=6).contains(&hashes)
415        && trimmed.as_bytes().get(hashes) == Some(&b' ')
416        && line.ends_with(" (HEAD)")
417}
418
419/// True if the text inside `<!-- ... -->` is an agent component marker.
420///
421/// Matches `agent:NAME [attrs...]` (open) or `/agent:NAME` (close).
422pub fn is_agent_marker(comment_text: &str) -> bool {
423    let trimmed = comment_text.trim();
424    if let Some(rest) = trimmed.strip_prefix("/agent:") {
425        is_valid_name(rest)
426    } else if let Some(rest) = trimmed.strip_prefix("agent:") {
427        // Opening marker may have attributes after the name: `agent:NAME key=value`
428        let name_part = rest.split_whitespace().next().unwrap_or("");
429        is_valid_name(name_part)
430    } else {
431        false
432    }
433}
434
435fn bounded_preview(doc: &str, start: usize, max_bytes: usize) -> &str {
436    debug_assert!(doc.is_char_boundary(start));
437    let mut end = doc.len().min(start.saturating_add(max_bytes));
438    while end > start && !doc.is_char_boundary(end) {
439        end -= 1;
440    }
441    &doc[start..end]
442}
443
444/// Parse `key=value` pairs from the attribute portion of an opening marker.
445///
446/// Given the text after `agent:NAME `, parses space-separated `key=value` pairs.
447/// Values are unquoted (no quote support needed for simple mode values).
448pub fn parse_attrs(attr_text: &str) -> HashMap<String, String> {
449    let mut attrs = HashMap::new();
450    for token in attr_text.split_whitespace() {
451        if let Some((key, value)) = token.split_once('=') {
452            if !key.is_empty() && !value.is_empty() {
453                attrs.insert(key.to_string(), value.to_string());
454            }
455        } else if !token.is_empty() {
456            attrs.insert(token.to_string(), String::new());
457        }
458    }
459    attrs
460}
461
462/// Find byte ranges of code regions (fenced code blocks + inline code spans).
463/// Markers inside these ranges are treated as literal text, not component markers.
464///
465/// Uses `pulldown-cmark` AST parsing with `offset_iter()` to accurately detect
466/// code regions per the CommonMark spec.
467pub fn find_code_ranges(doc: &str) -> Vec<(usize, usize)> {
468    let t = std::time::Instant::now();
469    let mut ranges = Vec::new();
470    let parser = Parser::new_ext(doc, Options::empty());
471    let mut iter = parser.into_offset_iter();
472    while let Some((event, range)) = iter.next() {
473        match event {
474            // Inline code span: `code` or ``code``
475            Event::Code(_) => {
476                ranges.push((range.start, range.end));
477            }
478            // Fenced or indented code block: consume until End(CodeBlock)
479            Event::Start(Tag::CodeBlock(_)) => {
480                let block_start = range.start;
481                let mut block_end = range.end;
482                for (inner_event, inner_range) in iter.by_ref() {
483                    block_end = inner_range.end;
484                    if matches!(inner_event, Event::End(TagEnd::CodeBlock)) {
485                        break;
486                    }
487                }
488                ranges.push((block_start, block_end));
489            }
490            _ => {}
491        }
492    }
493    let elapsed = t.elapsed().as_millis();
494    if elapsed > 0 {
495        eprintln!("[perf] find_code_ranges: {}ms", elapsed);
496    }
497    ranges
498}
499
500/// Find byte ranges for ordinary HTML comments, skipping code spans/blocks and
501/// preserving `agent:` markers for the component parser.
502///
503/// If an ordinary comment is currently unterminated, treat the rest of the
504/// document as comment body. Users can be editing a scratch comment while an
505/// agent-doc cycle is running; prompt-like text in that transiently broken
506/// comment must not be interpreted as escaped exchange content and moved or
507/// stripped by repair.
508pub fn find_non_agent_html_comment_ranges(doc: &str) -> Vec<(usize, usize)> {
509    let code_ranges = find_code_ranges(doc);
510    let in_code = |pos: usize| {
511        code_ranges
512            .iter()
513            .any(|&(start, end)| pos >= start && pos < end)
514    };
515
516    let bytes = doc.as_bytes();
517    let len = bytes.len();
518    let mut ranges = Vec::new();
519    let mut pos = 0usize;
520    while pos + 4 <= len {
521        if &bytes[pos..pos + 4] != b"<!--" {
522            pos += 1;
523            continue;
524        }
525        if in_code(pos) {
526            pos += 4;
527            continue;
528        }
529        let Some(end) = find_comment_end(bytes, pos + 4) else {
530            let inner = &doc[pos + 4..];
531            if !is_agent_marker(inner) && !inner.trim_start().starts_with("agent:boundary:") {
532                ranges.push((pos, len));
533                break;
534            }
535            pos += 4;
536            continue;
537        };
538        let inner = &doc[pos + 4..end - 3];
539        if !is_agent_marker(inner) && !inner.trim().starts_with("agent:boundary:") {
540            ranges.push((pos, end));
541        }
542        pos = end;
543    }
544    ranges
545}
546
547/// Parse all components from a document.
548///
549/// Uses a stack for nesting. Returns components sorted by `open_start`.
550/// Errors on unmatched open/close markers or invalid names.
551/// Skips markers inside fenced code blocks and inline code spans.
552pub fn parse(doc: &str) -> Result<Vec<Component>> {
553    let bytes = doc.as_bytes();
554    let len = bytes.len();
555    let code_ranges = find_code_ranges(doc);
556    let mut templates: Vec<Component> = Vec::new();
557    // Stack of (name, attrs, open_start, open_end)
558    let mut stack: Vec<(String, HashMap<String, String>, usize, usize)> = Vec::new();
559    let mut pos = 0;
560
561    while pos + 4 <= len {
562        // Look for `<!--`
563        if &bytes[pos..pos + 4] != b"<!--" {
564            pos += 1;
565            continue;
566        }
567
568        // Skip markers inside code regions
569        if code_ranges
570            .iter()
571            .any(|&(start, end)| pos >= start && pos < end)
572        {
573            pos += 4;
574            continue;
575        }
576
577        // Quick peek: only consume `<!-- ... -->` if the text after `<!--`
578        // starts with ` agent:` or ` /agent:` (with optional whitespace).
579        // Non-agent `<!--` sequences (e.g., `<!-- )` in prose) must NOT be
580        // consumed — their `-->` search would eat real component close markers.
581        let peek_start = pos + 4;
582        let peek = bounded_preview(doc, peek_start, 20);
583        let peek_trimmed = peek.trim_start();
584        if !peek_trimmed.starts_with("agent:") && !peek_trimmed.starts_with("/agent:") {
585            pos += 1;
586            continue;
587        }
588
589        let marker_start = pos;
590
591        // Find closing `-->`
592        let close = match find_comment_end(bytes, pos + 4) {
593            Some(c) => c,
594            None => {
595                pos += 4;
596                continue;
597            }
598        };
599
600        // close points to the byte after `>`
601        let inner = &doc[marker_start + 4..close - 3]; // between `<!--` and `-->`
602        let trimmed = inner.trim();
603
604        // Determine end offset — consume trailing newline if present
605        let mut marker_end = close;
606        if marker_end < len && bytes[marker_end] == b'\n' {
607            marker_end += 1;
608        }
609
610        if let Some(name) = trimmed.strip_prefix("/agent:") {
611            // Closing marker
612            if !is_valid_name(name) {
613                bail!("invalid component name: '{}'", name);
614            }
615            match stack.pop() {
616                Some((open_name, open_attrs, open_start, open_end)) => {
617                    if open_name != name {
618                        bail!(
619                            "mismatched component: opened '{}' but closed '{}'",
620                            open_name,
621                            name
622                        );
623                    }
624                    templates.push(Component {
625                        name: name.to_string(),
626                        attrs: open_attrs,
627                        open_start,
628                        open_end,
629                        close_start: marker_start,
630                        close_end: marker_end,
631                    });
632                }
633                None => bail!(
634                    "closing marker <!-- /agent:{} --> without matching open",
635                    name
636                ),
637            }
638        } else if let Some(rest) = trimmed.strip_prefix("agent:") {
639            // Skip boundary markers — these are not component markers
640            if rest.starts_with("boundary:") {
641                pos = close;
642                continue;
643            }
644            // Opening marker — may have attributes: `agent:NAME key=value`
645            let mut parts = rest.splitn(2, |c: char| c.is_whitespace());
646            let name = parts.next().unwrap_or("");
647            let attr_text = parts.next().unwrap_or("");
648            if !is_valid_name(name) {
649                bail!("invalid component name: '{}'", name);
650            }
651            let attrs = parse_attrs(attr_text);
652            stack.push((name.to_string(), attrs, marker_start, marker_end));
653        }
654
655        pos = close;
656    }
657
658    if let Some((name, _, _, _)) = stack.last() {
659        bail!(
660            "unclosed component: <!-- agent:{} --> without matching close",
661            name
662        );
663    }
664
665    templates.sort_by_key(|t| t.open_start);
666    Ok(templates)
667}
668
669/// Find the end of an HTML comment (`-->`), returning byte offset past `>`.
670pub fn find_comment_end(bytes: &[u8], start: usize) -> Option<usize> {
671    let len = bytes.len();
672    let mut i = start;
673    while i + 3 <= len {
674        if &bytes[i..i + 3] == b"-->" {
675            return Some(i + 3);
676        }
677        i += 1;
678    }
679    None
680}
681
682/// Strip comments from document content for diff comparison.
683///
684/// Removes:
685/// - HTML comments `<!-- ... -->` (single and multiline) — EXCEPT agent range markers
686/// - Link reference comments `[//]: # (...)`
687///
688/// Skips `<!--` sequences inside fenced code blocks and inline backtick spans
689/// to prevent code examples containing `<!--` from being misinterpreted as
690/// comment starts.
691///
692/// This function is the shared implementation used by both `diff::compute` (binary)
693/// and external crates like `eval-runner`.
694pub fn strip_comments(content: &str) -> String {
695    let code_ranges = find_code_ranges(content);
696    let in_code = |pos: usize| {
697        code_ranges
698            .iter()
699            .any(|&(start, end)| pos >= start && pos < end)
700    };
701
702    let mut result = String::with_capacity(content.len());
703    let bytes = content.as_bytes();
704    let len = bytes.len();
705    let mut pos = 0;
706
707    while pos < len {
708        // Check for link reference comment: `[//]: # (...)`
709        if bytes[pos] == b'['
710            && !in_code(pos)
711            && is_line_start_at(bytes, pos)
712            && let Some(end) = match_link_ref_comment_at(bytes, pos)
713        {
714            pos = end;
715            continue;
716        }
717
718        // Check for HTML comment: `<!-- ... -->`
719        if pos + 4 <= len
720            && &bytes[pos..pos + 4] == b"<!--"
721            && !in_code(pos)
722            && let Some((end, inner)) = match_html_comment_at(content, pos)
723        {
724            if is_agent_marker(inner) {
725                // Preserve agent markers — copy them through
726                result.push_str(&content[pos..end]);
727                pos = end;
728            } else {
729                // Strip the comment (and trailing newline if on its own line)
730                let mut skip_to = end;
731                if is_line_start_at(bytes, pos) && skip_to < len && bytes[skip_to] == b'\n' {
732                    skip_to += 1;
733                }
734                pos = skip_to;
735            }
736            continue;
737        }
738
739        result.push(content[pos..].chars().next().unwrap());
740        pos += content[pos..].chars().next().unwrap().len_utf8();
741    }
742
743    result
744}
745
746/// True if `pos` is at the start of a line (pos == 0 or bytes[pos-1] == '\n').
747fn is_line_start_at(bytes: &[u8], pos: usize) -> bool {
748    pos == 0 || bytes[pos - 1] == b'\n'
749}
750
751/// Match `[//]: # (...)` starting at `pos`. Returns byte offset past the line end.
752fn match_link_ref_comment_at(bytes: &[u8], pos: usize) -> Option<usize> {
753    let prefix = b"[//]: # (";
754    let len = bytes.len();
755    if pos + prefix.len() > len || &bytes[pos..pos + prefix.len()] != prefix {
756        return None;
757    }
758    let mut i = pos + prefix.len();
759    while i < len && bytes[i] != b')' && bytes[i] != b'\n' {
760        i += 1;
761    }
762    if i < len && bytes[i] == b')' {
763        i += 1;
764        if i < len && bytes[i] == b'\n' {
765            i += 1;
766        }
767        Some(i)
768    } else {
769        None
770    }
771}
772
773/// Match `<!-- ... -->` starting at `pos`. Returns (end_offset, inner_text).
774fn match_html_comment_at(content: &str, pos: usize) -> Option<(usize, &str)> {
775    let bytes = content.as_bytes();
776    let len = bytes.len();
777    let mut i = pos + 4;
778    while i + 3 <= len {
779        if &bytes[i..i + 3] == b"-->" {
780            let inner = &content[pos + 4..i];
781            return Some((i + 3, inner));
782        }
783        i += 1;
784    }
785    None
786}
787
788#[cfg(test)]
789mod tests {
790    use super::*;
791
792    #[test]
793    fn single_range() {
794        let doc = "before\n<!-- agent:status -->\nHello\n<!-- /agent:status -->\nafter\n";
795        let ranges = parse(doc).unwrap();
796        assert_eq!(ranges.len(), 1);
797        assert_eq!(ranges[0].name, "status");
798        assert_eq!(ranges[0].content(doc), "Hello\n");
799    }
800
801    #[test]
802    fn nested_ranges() {
803        let doc = "\
804<!-- agent:outer -->
805<!-- agent:inner -->
806content
807<!-- /agent:inner -->
808<!-- /agent:outer -->
809";
810        let ranges = parse(doc).unwrap();
811        assert_eq!(ranges.len(), 2);
812        // Sorted by open_start — outer first
813        assert_eq!(ranges[0].name, "outer");
814        assert_eq!(ranges[1].name, "inner");
815        assert_eq!(ranges[1].content(doc), "content\n");
816    }
817
818    #[test]
819    fn siblings() {
820        let doc = "\
821<!-- agent:a -->
822alpha
823<!-- /agent:a -->
824<!-- agent:b -->
825beta
826<!-- /agent:b -->
827";
828        let ranges = parse(doc).unwrap();
829        assert_eq!(ranges.len(), 2);
830        assert_eq!(ranges[0].name, "a");
831        assert_eq!(ranges[0].content(doc), "alpha\n");
832        assert_eq!(ranges[1].name, "b");
833        assert_eq!(ranges[1].content(doc), "beta\n");
834    }
835
836    #[test]
837    fn no_ranges() {
838        let doc = "# Just a document\n\nWith no range templates.\n";
839        let ranges = parse(doc).unwrap();
840        assert!(ranges.is_empty());
841    }
842
843    #[test]
844    fn unmatched_open_error() {
845        let doc = "<!-- agent:orphan -->\nContent\n";
846        let err = parse(doc).unwrap_err();
847        assert!(err.to_string().contains("unclosed component"));
848    }
849
850    #[test]
851    fn unmatched_close_error() {
852        let doc = "Content\n<!-- /agent:orphan -->\n";
853        let err = parse(doc).unwrap_err();
854        assert!(err.to_string().contains("without matching open"));
855    }
856
857    #[test]
858    fn mismatched_names_error() {
859        let doc = "<!-- agent:foo -->\n<!-- /agent:bar -->\n";
860        let err = parse(doc).unwrap_err();
861        assert!(err.to_string().contains("mismatched"));
862    }
863
864    #[test]
865    fn invalid_name() {
866        let doc = "<!-- agent:-bad -->\n<!-- /agent:-bad -->\n";
867        let err = parse(doc).unwrap_err();
868        assert!(err.to_string().contains("invalid component name"));
869    }
870
871    #[test]
872    fn name_validation() {
873        assert!(is_valid_name("status"));
874        assert!(is_valid_name("my-section"));
875        assert!(is_valid_name("a1"));
876        assert!(is_valid_name("A"));
877        assert!(!is_valid_name(""));
878        assert!(!is_valid_name("-bad"));
879        assert!(!is_valid_name("has space"));
880        assert!(!is_valid_name("has_underscore"));
881    }
882
883    #[test]
884    fn content_extraction() {
885        let doc = "<!-- agent:x -->\nfoo\nbar\n<!-- /agent:x -->\n";
886        let ranges = parse(doc).unwrap();
887        assert_eq!(ranges[0].content(doc), "foo\nbar\n");
888    }
889
890    #[test]
891    fn replace_roundtrip() {
892        let doc = "before\n<!-- agent:s -->\nold\n<!-- /agent:s -->\nafter\n";
893        let ranges = parse(doc).unwrap();
894        let new_doc = ranges[0].replace_content(doc, "new\n");
895        assert_eq!(
896            new_doc,
897            "before\n<!-- agent:s -->\nnew\n<!-- /agent:s -->\nafter\n"
898        );
899        // Re-parse should work
900        let ranges2 = parse(&new_doc).unwrap();
901        assert_eq!(ranges2.len(), 1);
902        assert_eq!(ranges2[0].content(&new_doc), "new\n");
903    }
904
905    #[test]
906    fn is_agent_marker_yes() {
907        assert!(is_agent_marker(" agent:status "));
908        assert!(is_agent_marker("/agent:status"));
909        assert!(is_agent_marker("agent:my-thing"));
910        assert!(is_agent_marker(" /agent:A1 "));
911    }
912
913    #[test]
914    fn is_agent_marker_no() {
915        assert!(!is_agent_marker("just a comment"));
916        assert!(!is_agent_marker("agent:"));
917        assert!(!is_agent_marker("/agent:"));
918        assert!(!is_agent_marker("agent:-bad"));
919        assert!(!is_agent_marker("some agent:fake stuff"));
920    }
921
922    #[test]
923    fn regular_comments_ignored() {
924        let doc = "<!-- just a comment -->\n<!-- agent:x -->\ndata\n<!-- /agent:x -->\n";
925        let ranges = parse(doc).unwrap();
926        assert_eq!(ranges.len(), 1);
927        assert_eq!(ranges[0].name, "x");
928    }
929
930    #[test]
931    fn regular_comments_with_multibyte_preview_boundary_ignored() {
932        let doc = "\
933<!-- 123456789012345678❯ ordinary comment -->
934<!-- agent:x -->
935data
936<!-- /agent:x -->
937";
938        let ranges = parse(doc).unwrap();
939        assert_eq!(ranges.len(), 1);
940        assert_eq!(ranges[0].name, "x");
941    }
942
943    #[test]
944    fn multiline_comment_ignored() {
945        let doc = "\
946<!--
947multi
948line
949comment
950-->
951<!-- agent:s -->
952content
953<!-- /agent:s -->
954";
955        let ranges = parse(doc).unwrap();
956        assert_eq!(ranges.len(), 1);
957        assert_eq!(ranges[0].name, "s");
958    }
959
960    #[test]
961    fn empty_content() {
962        let doc = "<!-- agent:empty --><!-- /agent:empty -->\n";
963        let ranges = parse(doc).unwrap();
964        assert_eq!(ranges.len(), 1);
965        assert_eq!(ranges[0].content(doc), "");
966    }
967
968    #[test]
969    fn markers_in_fenced_code_block_ignored() {
970        let doc = "\
971<!-- agent:real -->
972content
973<!-- /agent:real -->
974```markdown
975<!-- agent:fake -->
976this is just an example
977<!-- /agent:fake -->
978```
979";
980        let ranges = parse(doc).unwrap();
981        assert_eq!(ranges.len(), 1);
982        assert_eq!(ranges[0].name, "real");
983    }
984
985    #[test]
986    fn markers_in_inline_code_ignored() {
987        let doc = "\
988Use `<!-- agent:example -->` markers for components.
989<!-- agent:real -->
990content
991<!-- /agent:real -->
992";
993        let ranges = parse(doc).unwrap();
994        assert_eq!(ranges.len(), 1);
995        assert_eq!(ranges[0].name, "real");
996    }
997
998    #[test]
999    fn markers_in_tilde_fence_ignored() {
1000        let doc = "\
1001<!-- agent:x -->
1002data
1003<!-- /agent:x -->
1004~~~
1005<!-- agent:y -->
1006example
1007<!-- /agent:y -->
1008~~~
1009";
1010        let ranges = parse(doc).unwrap();
1011        assert_eq!(ranges.len(), 1);
1012        assert_eq!(ranges[0].name, "x");
1013    }
1014
1015    #[test]
1016    fn markers_in_indented_fenced_code_block_ignored() {
1017        // CommonMark allows up to 3 spaces before fence opener
1018        let doc = "\
1019<!-- agent:exchange -->
1020Content here.
1021<!-- /agent:exchange -->
1022
1023  ```markdown
1024  <!-- agent:fake -->
1025  demo without closing tag
1026  ```
1027";
1028        let ranges = parse(doc).unwrap();
1029        assert_eq!(ranges.len(), 1);
1030        assert_eq!(ranges[0].name, "exchange");
1031    }
1032
1033    #[test]
1034    fn indented_fence_inside_component_ignored() {
1035        // Indented code block inside a component should not cause mismatched errors
1036        let doc = "\
1037<!-- agent:exchange -->
1038Here's how to set up:
1039
1040   ```markdown
1041   <!-- agent:status -->
1042   Your status here
1043   ```
1044
1045Done explaining.
1046<!-- /agent:exchange -->
1047";
1048        let ranges = parse(doc).unwrap();
1049        assert_eq!(ranges.len(), 1);
1050        assert_eq!(ranges[0].name, "exchange");
1051    }
1052
1053    #[test]
1054    fn deeply_indented_fence_ignored() {
1055        // Tabs and many spaces should still be detected as a fence
1056        let doc = "\
1057<!-- agent:x -->
1058ok
1059<!-- /agent:x -->
1060      ```
1061      <!-- agent:y -->
1062      inside fence
1063      ```
1064";
1065        let ranges = parse(doc).unwrap();
1066        assert_eq!(ranges.len(), 1);
1067        assert_eq!(ranges[0].name, "x");
1068    }
1069
1070    #[test]
1071    fn indented_fence_code_ranges_detected() {
1072        let doc = "before\n  ```\n  code\n  ```\nafter\n";
1073        let ranges = find_code_ranges(doc);
1074        assert_eq!(ranges.len(), 1);
1075        assert!(doc[ranges[0].0..ranges[0].1].contains("code"));
1076    }
1077
1078    #[test]
1079    fn code_ranges_detected() {
1080        let doc = "before\n```\ncode\n```\nafter `inline` end\n";
1081        let ranges = find_code_ranges(doc);
1082        assert_eq!(ranges.len(), 2);
1083        // Fenced block
1084        assert!(doc[ranges[0].0..ranges[0].1].contains("code"));
1085        // Inline span
1086        assert!(doc[ranges[1].0..ranges[1].1].contains("inline"));
1087    }
1088
1089    #[test]
1090    fn code_ranges_double_backtick() {
1091        // CommonMark: `` `<!--` `` is a code span containing `<!--`
1092        let doc = "text `` `<!--` `` more\n";
1093        let ranges = find_code_ranges(doc);
1094        assert_eq!(ranges.len(), 1);
1095        let span = &doc[ranges[0].0..ranges[0].1];
1096        assert!(
1097            span.contains("<!--"),
1098            "double-backtick span should contain <!--: {:?}",
1099            span
1100        );
1101    }
1102
1103    #[test]
1104    fn code_ranges_double_backtick_does_not_match_single() {
1105        // `` should not match a single ` close
1106        let doc = "text `` foo ` bar `` end\n";
1107        let ranges = find_code_ranges(doc);
1108        assert_eq!(ranges.len(), 1);
1109        let span = &doc[ranges[0].0..ranges[0].1];
1110        assert_eq!(span, "`` foo ` bar ``");
1111    }
1112
1113    #[test]
1114    fn double_backtick_comment_before_agent_marker() {
1115        // Regression: `` `<!--` `` followed by agent marker should not confuse the parser
1116        let doc = "\
1117<!-- agent:exchange -->\n\
1118text `` `<!--` `` description\n\
1119new content here\n\
1120<!-- /agent:exchange -->\n";
1121        let components = parse(doc).unwrap();
1122        assert_eq!(components.len(), 1);
1123        assert_eq!(components[0].name, "exchange");
1124        assert!(components[0].content(doc).contains("new content here"));
1125    }
1126
1127    // --- Inline attribute tests ---
1128
1129    #[test]
1130    fn parse_component_with_mode_attr() {
1131        let doc = "<!-- agent:exchange mode=append -->\nContent\n<!-- /agent:exchange -->\n";
1132        let components = parse(doc).unwrap();
1133        assert_eq!(components.len(), 1);
1134        assert_eq!(components[0].name, "exchange");
1135        assert_eq!(
1136            components[0].attrs.get("mode").map(|s| s.as_str()),
1137            Some("append")
1138        );
1139        assert_eq!(components[0].content(doc), "Content\n");
1140    }
1141
1142    #[test]
1143    fn parse_component_with_multiple_attrs() {
1144        let doc = "<!-- agent:log mode=prepend timestamp=true -->\nData\n<!-- /agent:log -->\n";
1145        let components = parse(doc).unwrap();
1146        assert_eq!(components.len(), 1);
1147        assert_eq!(components[0].name, "log");
1148        assert_eq!(
1149            components[0].attrs.get("mode").map(|s| s.as_str()),
1150            Some("prepend")
1151        );
1152        assert_eq!(
1153            components[0].attrs.get("timestamp").map(|s| s.as_str()),
1154            Some("true")
1155        );
1156    }
1157
1158    #[test]
1159    fn parse_component_no_attrs_backward_compat() {
1160        let doc = "<!-- agent:status -->\nOK\n<!-- /agent:status -->\n";
1161        let components = parse(doc).unwrap();
1162        assert_eq!(components.len(), 1);
1163        assert_eq!(components[0].name, "status");
1164        assert!(components[0].attrs.is_empty());
1165    }
1166
1167    #[test]
1168    fn is_agent_marker_with_attrs() {
1169        assert!(is_agent_marker(" agent:exchange mode=append "));
1170        assert!(is_agent_marker("agent:status mode=replace"));
1171        assert!(is_agent_marker("agent:log mode=prepend timestamp=true"));
1172    }
1173
1174    #[test]
1175    fn closing_tag_unchanged_with_attrs() {
1176        // Closing tags never have attributes
1177        let doc = "<!-- agent:status mode=replace -->\n- [x] Done\n<!-- /agent:status -->\n";
1178        let components = parse(doc).unwrap();
1179        assert_eq!(components.len(), 1);
1180        let new_doc = components[0].replace_content(doc, "- [ ] Todo\n");
1181        assert!(new_doc.contains("<!-- agent:status mode=replace -->"));
1182        assert!(new_doc.contains("<!-- /agent:status -->"));
1183        assert!(new_doc.contains("- [ ] Todo"));
1184    }
1185
1186    #[test]
1187    fn parse_component_with_patch_attr() {
1188        let doc = "<!-- agent:exchange patch=append -->\nContent\n<!-- /agent:exchange -->\n";
1189        let components = parse(doc).unwrap();
1190        assert_eq!(components.len(), 1);
1191        assert_eq!(components[0].name, "exchange");
1192        assert_eq!(components[0].patch_mode(), Some("append"));
1193        assert_eq!(components[0].content(doc), "Content\n");
1194    }
1195
1196    #[test]
1197    fn patch_attr_takes_precedence_over_mode() {
1198        let doc = "<!-- agent:exchange patch=replace mode=append -->\nContent\n<!-- /agent:exchange -->\n";
1199        let components = parse(doc).unwrap();
1200        assert_eq!(components[0].patch_mode(), Some("replace"));
1201    }
1202
1203    #[test]
1204    fn mode_attr_backward_compat() {
1205        let doc = "<!-- agent:exchange mode=append -->\nContent\n<!-- /agent:exchange -->\n";
1206        let components = parse(doc).unwrap();
1207        assert_eq!(components[0].patch_mode(), Some("append"));
1208    }
1209
1210    #[test]
1211    fn no_patch_or_mode_attr() {
1212        let doc = "<!-- agent:exchange -->\nContent\n<!-- /agent:exchange -->\n";
1213        let components = parse(doc).unwrap();
1214        assert_eq!(components[0].patch_mode(), None);
1215    }
1216
1217    // --- Inline backtick code span exclusion tests ---
1218
1219    #[test]
1220    fn single_backtick_component_tag_ignored() {
1221        // A component tag wrapped in single backticks should not be parsed
1222        let doc = "\
1223Use `<!-- agent:pending -->` to mark pending sections.
1224<!-- agent:real -->
1225content
1226<!-- /agent:real -->
1227";
1228        let components = parse(doc).unwrap();
1229        assert_eq!(components.len(), 1);
1230        assert_eq!(components[0].name, "real");
1231    }
1232
1233    #[test]
1234    fn double_backtick_component_tag_ignored() {
1235        // A component tag wrapped in double backticks should not be parsed
1236        let doc = "\
1237Use ``<!-- agent:pending -->`` to mark pending sections.
1238<!-- agent:real -->
1239content
1240<!-- /agent:real -->
1241";
1242        let components = parse(doc).unwrap();
1243        assert_eq!(components.len(), 1);
1244        assert_eq!(components[0].name, "real");
1245    }
1246
1247    #[test]
1248    fn component_tags_not_in_backticks_still_work() {
1249        // Tags outside of any backticks are parsed normally
1250        let doc = "\
1251<!-- agent:a -->
1252alpha
1253<!-- /agent:a -->
1254<!-- agent:b patch=append -->
1255beta
1256<!-- /agent:b -->
1257";
1258        let components = parse(doc).unwrap();
1259        assert_eq!(components.len(), 2);
1260        assert_eq!(components[0].name, "a");
1261        assert_eq!(components[1].name, "b");
1262        assert_eq!(components[1].patch_mode(), Some("append"));
1263    }
1264
1265    #[test]
1266    fn mixed_backtick_and_real_tags() {
1267        // Some tags in backticks (ignored), some not (parsed)
1268        let doc = "\
1269Here is an example: `<!-- agent:fake -->` and ``<!-- /agent:fake -->``.
1270<!-- agent:real -->
1271real content
1272<!-- /agent:real -->
1273Another example: `<!-- agent:also-fake patch=replace -->` is just documentation.
1274";
1275        let components = parse(doc).unwrap();
1276        assert_eq!(components.len(), 1);
1277        assert_eq!(components[0].name, "real");
1278        assert_eq!(components[0].content(doc), "real content\n");
1279    }
1280
1281    #[test]
1282    fn inline_code_mid_line_with_surrounding_text_ignored() {
1283        // Edge case: component tag inside inline code span on a line with other content
1284        // before and after — must not be parsed as a real component marker.
1285        let doc = "\
1286Wrap markers like `<!-- agent:status -->` in backticks to show them literally.
1287<!-- agent:real -->
1288actual content
1289<!-- /agent:real -->
1290";
1291        let components = parse(doc).unwrap();
1292        assert_eq!(components.len(), 1);
1293        assert_eq!(components[0].name, "real");
1294        assert_eq!(components[0].content(doc), "actual content\n");
1295    }
1296
1297    #[test]
1298    fn parse_attrs_unit() {
1299        let attrs = parse_attrs("mode=append");
1300        assert_eq!(attrs.get("mode").map(|s| s.as_str()), Some("append"));
1301
1302        let attrs = parse_attrs("mode=replace timestamp=true");
1303        assert_eq!(attrs.len(), 2);
1304
1305        let attrs = parse_attrs("");
1306        assert!(attrs.is_empty());
1307
1308        // Bare tokens (no =) are parsed as boolean flags with empty string values
1309        let attrs = parse_attrs("mode=append broken novalue=");
1310        assert_eq!(attrs.len(), 2);
1311        assert_eq!(attrs.get("mode").map(|s| s.as_str()), Some("append"));
1312        assert_eq!(attrs.get("broken").map(|s| s.as_str()), Some(""));
1313
1314        // auto flag (used by agent:queue)
1315        let attrs = parse_attrs("auto");
1316        assert_eq!(attrs.len(), 1);
1317        assert!(attrs.contains_key("auto"));
1318    }
1319
1320    #[test]
1321    fn append_with_boundary_skips_code_block() {
1322        // Boundary marker inside a code block should be ignored;
1323        // the real marker outside should be used.
1324        let boundary_id = "real-uuid";
1325        let doc = format!(
1326            "<!-- agent:exchange patch=append -->\n\
1327             user prompt\n\
1328             ```\n\
1329             <!-- agent:boundary:{boundary_id} -->\n\
1330             ```\n\
1331             more user text\n\
1332             <!-- agent:boundary:{boundary_id} -->\n\
1333             <!-- /agent:exchange -->\n"
1334        );
1335        let components = parse(&doc).unwrap();
1336        let comp = &components[0];
1337        let result =
1338            comp.append_with_boundary(&doc, "### Re: Response\n\nContent here.", boundary_id);
1339
1340        // Response should replace the REAL marker (outside code block),
1341        // not the one inside the code block.
1342        assert!(result.contains("### Re: Response"));
1343        assert!(result.contains("more user text"));
1344        // The code block example should be preserved
1345        assert!(result.contains(&format!("<!-- agent:boundary:{boundary_id} -->\n```")));
1346        // The real marker should be consumed (replaced by response)
1347        assert!(!result.contains(&format!(
1348            "more user text\n<!-- agent:boundary:{boundary_id} -->\n<!-- /agent:exchange -->"
1349        )));
1350    }
1351
1352    #[test]
1353    fn append_with_boundary_no_code_block() {
1354        // Normal case: boundary marker not in a code block
1355        let boundary_id = "simple-uuid";
1356        let doc = format!(
1357            "<!-- agent:exchange patch=append -->\n\
1358             user prompt\n\
1359             <!-- agent:boundary:{boundary_id} -->\n\
1360             <!-- /agent:exchange -->\n"
1361        );
1362        let components = parse(&doc).unwrap();
1363        let comp = &components[0];
1364        let result = comp.append_with_boundary(&doc, "### Re: Answer\n\nDone.", boundary_id);
1365
1366        assert!(result.contains("### Re: Answer"));
1367        assert!(result.contains("user prompt"));
1368        // Original marker should be consumed, but a NEW boundary re-inserted
1369        assert!(!result.contains(&format!("agent:boundary:{boundary_id}")));
1370        assert!(result.contains("agent:boundary:"));
1371    }
1372
1373    #[test]
1374    fn append_with_boundary_skips_already_present_content() {
1375        let boundary_id = "simple-uuid";
1376        let doc = format!(
1377            "<!-- agent:exchange patch=append -->\n\
1378             ### Re: Duplicate — gpt-5 (HEAD)\n\
1379             \n\
1380             Already applied.\n\
1381             <!-- agent:boundary:old-id -->\n\
1382             <!-- agent:boundary:{boundary_id} -->\n\
1383             <!-- /agent:exchange -->\n"
1384        );
1385        let components = parse(&doc).unwrap();
1386        let comp = &components[0];
1387        let result = comp.append_with_boundary(
1388            &doc,
1389            "### Re: Duplicate — gpt-5\n\nAlready applied.\n",
1390            boundary_id,
1391        );
1392
1393        assert_eq!(result, doc);
1394    }
1395
1396    #[test]
1397    fn append_with_caret_skips_already_present_content() {
1398        let doc = "<!-- agent:exchange patch=append -->\n\
1399                   User prompt.\n\
1400                   ### Re: Duplicate — gpt-5\n\
1401                   \n\
1402                   Already applied.\n\
1403                   <!-- /agent:exchange -->\n";
1404        let components = parse(doc).unwrap();
1405        let comp = &components[0];
1406        let result =
1407            comp.append_with_caret(doc, "### Re: Duplicate — gpt-5\n\nAlready applied.\n", None);
1408
1409        assert_eq!(result, doc);
1410    }
1411
1412    // --- strip_comments tests (moved from diff.rs) ---
1413
1414    #[test]
1415    fn strip_comments_removes_html_comment() {
1416        let result = strip_comments("before\n<!-- a comment -->\nafter\n");
1417        assert_eq!(result, "before\nafter\n");
1418    }
1419
1420    #[test]
1421    fn non_agent_html_comment_ranges_cover_multiline_body() {
1422        let doc = concat!(
1423            "before\n",
1424            "<!--\n",
1425            "do #hidden. spec-test-build-install-commit-push\n",
1426            "-->\n",
1427            "<!-- agent:exchange -->\n",
1428            "<!-- /agent:exchange -->\n"
1429        );
1430
1431        let ranges = find_non_agent_html_comment_ranges(doc);
1432        assert_eq!(ranges.len(), 1);
1433        let hidden = doc.find("do #hidden").unwrap();
1434        assert!(
1435            ranges
1436                .iter()
1437                .any(|&(start, end)| hidden >= start && hidden < end),
1438            "ordinary comment body should be inside a non-agent comment range"
1439        );
1440        assert!(
1441            ranges
1442                .iter()
1443                .all(|&(start, end)| !doc[start..end].contains("agent:exchange")),
1444            "agent component markers must not be treated as ordinary comments"
1445        );
1446    }
1447
1448    #[test]
1449    fn non_agent_html_comment_ranges_cover_unterminated_tail() {
1450        let doc = concat!(
1451            "before\n",
1452            "<!--\n",
1453            "do #hidden. spec-test-build-install-commit-push\n",
1454            "still typing\n"
1455        );
1456
1457        let ranges = find_non_agent_html_comment_ranges(doc);
1458        assert_eq!(ranges.len(), 1);
1459        let (start, end) = ranges[0];
1460        assert_eq!(
1461            &doc[start..end],
1462            "<!--\ndo #hidden. spec-test-build-install-commit-push\nstill typing\n"
1463        );
1464        assert_eq!(end, doc.len());
1465    }
1466
1467    #[test]
1468    fn strip_comments_preserves_agent_markers() {
1469        let input = "text\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
1470        let result = strip_comments(input);
1471        assert!(result.contains("<!-- agent:status -->"));
1472        assert!(result.contains("<!-- /agent:status -->"));
1473    }
1474
1475    #[test]
1476    fn strip_comments_removes_link_ref() {
1477        let result = strip_comments("[//]: # (hidden note)\nvisible\n");
1478        assert_eq!(result, "visible\n");
1479    }
1480
1481    #[test]
1482    fn html_comment_in_content_does_not_eat_close_marker() {
1483        let doc = "\
1484<!-- agent:pending -->
1485- [ ] [#abc] Rule: first line (not starting with ### or ❯ or <!-- ) gets prefix
1486<!-- /agent:pending -->
1487";
1488        let comps = parse(doc).unwrap();
1489        assert_eq!(comps.len(), 1);
1490        assert_eq!(comps[0].name, "pending");
1491        assert!(comps[0].content(doc).contains("<!-- )"));
1492    }
1493
1494    #[test]
1495    fn nested_html_comment_like_text_in_exchange() {
1496        let doc = "\
1497<!-- agent:exchange -->
1498User typed <!-- some note --> in the text.
1499<!-- /agent:exchange -->
1500";
1501        let comps = parse(doc).unwrap();
1502        assert_eq!(comps.len(), 1);
1503        assert_eq!(comps[0].name, "exchange");
1504    }
1505
1506    #[test]
1507    fn pending_item_with_literal_html_comment_opener() {
1508        // Regression: a pending item containing `<!-- ` (literal HTML comment start)
1509        // must not consume the `<!-- /agent:pending -->` close marker.
1510        let doc = "\
1511<!-- agent:pending -->
1512- [ ] [#r7hw] Component parser: non-agent `<!-- ` in content ate close markers
1513- [ ] [#xyz] Another item
1514<!-- /agent:pending -->
1515";
1516        let comps = parse(doc).unwrap();
1517        assert_eq!(comps.len(), 1);
1518        assert_eq!(comps[0].name, "pending");
1519        let content = comps[0].content(doc);
1520        assert!(content.contains("#r7hw"));
1521        assert!(content.contains("#xyz"));
1522    }
1523
1524    #[test]
1525    fn multiple_non_agent_html_comments_in_pending() {
1526        // Multiple `<!-- ... -->` fragments that are NOT agent markers.
1527        let doc = "\
1528<!-- agent:pending -->
1529- [ ] [#a] Fix rule: skip lines starting with <!-- or -->
1530- [ ] [#b] Handle <!-- partial comment
1531- [ ] [#c] Normal item
1532<!-- /agent:pending -->
1533";
1534        let comps = parse(doc).unwrap();
1535        assert_eq!(comps.len(), 1);
1536        assert_eq!(comps[0].name, "pending");
1537        let content = comps[0].content(doc);
1538        assert!(content.contains("#a"));
1539        assert!(content.contains("#b"));
1540        assert!(content.contains("#c"));
1541    }
1542
1543    #[test]
1544    fn exchange_and_pending_both_with_html_comments_in_content() {
1545        // Both components contain non-agent HTML comments — parser must handle
1546        // siblings correctly without eating across component boundaries.
1547        let doc = "\
1548<!-- agent:exchange -->
1549The rule checks for <!-- prefix before deciding.
1550### Re: topic — opus
1551Fix applied to skip non-agent <!-- sequences.
1552<!-- /agent:exchange -->
1553
1554<!-- agent:pending -->
1555- [ ] [#cfdy] Verify <!-- in pending items
1556<!-- /agent:pending -->
1557";
1558        let comps = parse(doc).unwrap();
1559        assert_eq!(comps.len(), 2);
1560        assert_eq!(comps[0].name, "exchange");
1561        assert_eq!(comps[1].name, "pending");
1562        assert!(comps[0].content(doc).contains("<!-- prefix"));
1563        assert!(comps[1].content(doc).contains("#cfdy"));
1564    }
1565
1566    #[test]
1567    fn is_backlog_component_accepts_both_names() {
1568        assert!(is_backlog_component("backlog"));
1569        assert!(is_backlog_component("pending"));
1570        assert!(!is_backlog_component("exchange"));
1571        assert!(!is_backlog_component("status"));
1572        assert!(!is_backlog_component("pending-done"));
1573        assert!(!is_backlog_component("backlog-done"));
1574        assert!(!is_backlog_component("done"));
1575    }
1576
1577    #[test]
1578    fn is_backlog_done_component_accepts_only_canonical_done() {
1579        assert!(is_backlog_done_component("done"));
1580        assert!(!is_backlog_done_component("backlog-done"));
1581        assert!(!is_backlog_done_component("pending-done"));
1582        assert!(!is_backlog_done_component("backlog"));
1583        assert!(!is_backlog_done_component("pending"));
1584        assert!(!is_backlog_done_component("icebox"));
1585    }
1586
1587    #[test]
1588    fn is_review_component_accepts_name() {
1589        assert!(is_review_component("review"));
1590        assert!(!is_review_component("backlog"));
1591        assert!(!is_review_component("icebox"));
1592    }
1593
1594    #[test]
1595    fn is_icebox_component_accepts_name() {
1596        assert!(is_icebox_component("icebox"));
1597        assert!(!is_icebox_component("backlog"));
1598        assert!(!is_icebox_component("exchange"));
1599        assert!(!is_icebox_component("icebox-archive"));
1600    }
1601
1602    #[test]
1603    fn tracked_work_component_includes_review() {
1604        assert!(is_tracked_work_component("backlog"));
1605        assert!(is_tracked_work_component("pending"));
1606        assert!(is_tracked_work_component("review"));
1607        assert!(is_tracked_work_component("icebox"));
1608        assert!(!is_tracked_work_component("done"));
1609    }
1610
1611    #[test]
1612    fn icebox_component_parsed() {
1613        let doc = "\
1614<!-- agent:icebox -->
1615- Parked idea
1616<!-- /agent:icebox -->
1617";
1618        let comps = parse(doc).unwrap();
1619        assert_eq!(comps.len(), 1);
1620        assert_eq!(comps[0].name, "icebox");
1621        assert!(is_icebox_component(&comps[0].name));
1622        assert!(comps[0].content(doc).contains("Parked idea"));
1623    }
1624
1625    #[test]
1626    fn backlog_component_parsed_from_new_marker() {
1627        let doc = "\
1628<!-- agent:backlog -->
1629- [ ] [#abc] First item
1630<!-- /agent:backlog -->
1631";
1632        let comps = parse(doc).unwrap();
1633        assert_eq!(comps.len(), 1);
1634        assert_eq!(comps[0].name, "backlog");
1635        assert!(is_backlog_component(&comps[0].name));
1636        assert!(comps[0].content(doc).contains("#abc"));
1637    }
1638
1639    #[test]
1640    fn legacy_pending_marker_still_parsed() {
1641        let doc = "\
1642<!-- agent:pending -->
1643- [ ] [#xyz] Legacy item
1644<!-- /agent:pending -->
1645";
1646        let comps = parse(doc).unwrap();
1647        assert_eq!(comps.len(), 1);
1648        assert_eq!(comps[0].name, "pending");
1649        assert!(is_backlog_component(&comps[0].name));
1650    }
1651
1652    #[test]
1653    fn strip_backlog_patch_attr_removes_patch_replace() {
1654        let doc = "<!-- agent:backlog patch=replace -->\n- item\n<!-- /agent:backlog -->\n";
1655        let result = strip_backlog_patch_attr(doc);
1656        assert_eq!(
1657            result,
1658            "<!-- agent:backlog -->\n- item\n<!-- /agent:backlog -->\n"
1659        );
1660    }
1661
1662    #[test]
1663    fn strip_backlog_patch_attr_removes_pending_patch_replace() {
1664        let doc = "<!-- agent:pending patch=replace -->\n- item\n<!-- /agent:pending -->\n";
1665        let result = strip_backlog_patch_attr(doc);
1666        assert_eq!(
1667            result,
1668            "<!-- agent:pending -->\n- item\n<!-- /agent:pending -->\n"
1669        );
1670    }
1671
1672    #[test]
1673    fn strip_backlog_patch_attr_removes_mode_replace() {
1674        let doc = "<!-- agent:backlog mode=replace -->\n- item\n<!-- /agent:backlog -->\n";
1675        let result = strip_backlog_patch_attr(doc);
1676        assert_eq!(
1677            result,
1678            "<!-- agent:backlog -->\n- item\n<!-- /agent:backlog -->\n"
1679        );
1680    }
1681
1682    #[test]
1683    fn strip_backlog_patch_attr_preserves_other_attrs() {
1684        let doc =
1685            "<!-- agent:backlog patch=replace max_lines=50 -->\n- item\n<!-- /agent:backlog -->\n";
1686        let result = strip_backlog_patch_attr(doc);
1687        assert_eq!(
1688            result,
1689            "<!-- agent:backlog max_lines=50 -->\n- item\n<!-- /agent:backlog -->\n"
1690        );
1691    }
1692
1693    #[test]
1694    fn strip_backlog_patch_attr_noop_for_exchange() {
1695        let doc = "<!-- agent:exchange patch=append -->\ncontent\n<!-- /agent:exchange -->\n";
1696        let result = strip_backlog_patch_attr(doc);
1697        assert_eq!(result, doc);
1698    }
1699
1700    #[test]
1701    fn strip_backlog_patch_attr_noop_when_no_attr() {
1702        let doc = "<!-- agent:backlog -->\n- item\n<!-- /agent:backlog -->\n";
1703        let result = strip_backlog_patch_attr(doc);
1704        assert_eq!(result, doc);
1705    }
1706}