Skip to main content

agent_doc_element/
element.rs

1//! # Module: element
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/// Converge the `agent:queue` opening-tag `auto` attribute to `want_auto`.
362///
363/// A content patch (the only editor-visible IPC seam for template documents)
364/// replaces a component's *body* between its markers — it cannot add or remove
365/// an attribute on the `<!-- agent:queue auto -->` opening tag. After a queue
366/// halt strips `auto` on disk, a live route-owned editor buffer that still shows
367/// `<!-- agent:queue auto -->` re-diverges from the committed inactive snapshot on
368/// its next flush, regenerating the snapshot/HEAD drift loop on every preflight
369/// (`#adoc-queue-ipc-buffer-divergence`). Editor plugins call this (via the
370/// `agent_doc_converge_queue_auto` FFI export) to converge the live buffer's queue
371/// tag to the committed shape.
372///
373/// Returns `Some(new_doc)` when the tag changed, `None` when there is no `queue`
374/// component or the tag already matches `want_auto`. Other opening-tag attributes
375/// (e.g. `patch=append`) are preserved.
376pub fn converge_queue_auto(doc: &str, want_auto: bool) -> Option<String> {
377    let components = parse(doc).ok()?;
378    let queue = components.iter().find(|c| c.name == "queue")?;
379    let raw_tag = &doc[queue.open_start..queue.open_end];
380    let new_tag = set_auto_in_queue_tag(raw_tag, want_auto);
381    if new_tag == *raw_tag {
382        return None;
383    }
384    let mut result = String::with_capacity(doc.len());
385    result.push_str(&doc[..queue.open_start]);
386    result.push_str(&new_tag);
387    result.push_str(&doc[queue.open_end..]);
388    Some(result)
389}
390
391/// Rebuild an `agent:queue` opening tag with the `auto` attribute present or
392/// absent per `want_auto`, preserving every other token (the `agent:queue` name,
393/// `patch=…`, etc.) and any trailing whitespace/newline after `-->`.
394fn set_auto_in_queue_tag(tag: &str, want_auto: bool) -> String {
395    let Some((mut tokens, tail)) = queue_tag_tokens(tag) else {
396        return tag.to_string();
397    };
398    tokens = tokens
399        .into_iter()
400        .map(|token| normalize_queue_tag_token(&token))
401        .collect();
402    let has_auto = tokens.iter().any(|token| token == "auto");
403    if want_auto == has_auto {
404        return format!("<!-- {} -->{}", tokens.join(" "), tail);
405    }
406    if want_auto {
407        // Insert `auto` right after the `agent:queue` name token so it stays the
408        // first attribute (matches how route/queue activation writes the tag).
409        let insert_at = if tokens.is_empty() { 0 } else { 1 };
410        tokens.insert(insert_at, "auto".to_string());
411    } else {
412        tokens.retain(|token| token != "auto");
413    }
414    format!("<!-- {} -->{}", tokens.join(" "), tail)
415}
416
417fn queue_tag_tokens(tag: &str) -> Option<(Vec<String>, &str)> {
418    let close_idx = tag.find("-->")?;
419    let core = &tag[..close_idx + 3];
420    let tail = &tag[close_idx + 3..];
421    let inner = core
422        .trim_start_matches("<!--")
423        .trim_end_matches("-->")
424        .trim();
425    Some((split_marker_tokens(inner), tail))
426}
427
428fn split_marker_tokens(inner: &str) -> Vec<String> {
429    let mut tokens = Vec::new();
430    let mut current = String::new();
431    let mut quote: Option<char> = None;
432    for ch in inner.chars() {
433        if ch.is_whitespace() && quote.is_none() {
434            if !current.is_empty() {
435                tokens.push(std::mem::take(&mut current));
436            }
437            continue;
438        }
439        if matches!(ch, '"' | '\'') {
440            if quote == Some(ch) {
441                quote = None;
442            } else if quote.is_none() {
443                quote = Some(ch);
444            }
445        }
446        current.push(ch);
447    }
448    if !current.is_empty() {
449        tokens.push(current);
450    }
451    tokens
452}
453
454fn normalize_queue_tag_token(token: &str) -> String {
455    let Some((key, value)) = token.split_once('=') else {
456        return token.to_string();
457    };
458    if is_queue_boolean_attr(key) && value.eq_ignore_ascii_case("true") {
459        return key.to_string();
460    }
461    if key == "preset"
462        && let Some(stripped) = strip_malformed_true_suffix(value)
463    {
464        return format!("{key}={stripped}");
465    }
466    token.to_string()
467}
468
469fn is_queue_boolean_attr(key: &str) -> bool {
470    matches!(key, "auto" | "priority" | "go" | "start" | "stop")
471}
472
473fn strip_malformed_true_suffix(value: &str) -> Option<&str> {
474    let stripped = value.strip_suffix("=true")?;
475    if (stripped.starts_with('"') && stripped.ends_with('"'))
476        || (stripped.starts_with('\'') && stripped.ends_with('\''))
477    {
478        Some(stripped)
479    } else {
480        None
481    }
482}
483
484/// Valid name: `[a-zA-Z0-9][a-zA-Z0-9-]*`
485fn is_valid_name(name: &str) -> bool {
486    if name.is_empty() {
487        return false;
488    }
489    let first = name.as_bytes()[0];
490    if !first.is_ascii_alphanumeric() {
491        return false;
492    }
493    name.bytes().all(|b| b.is_ascii_alphanumeric() || b == b'-')
494}
495
496fn append_patch_already_present(existing: &str, content: &str) -> bool {
497    let patch = normalize_append_patch_content(content);
498    if patch.is_empty() {
499        return false;
500    }
501    let existing_norm = normalize_append_patch_content(existing);
502    if existing_norm.contains(&patch) {
503        return true;
504    }
505    // #prompt-duplicated-while-typing (L1 structural buffer-snapshot race): a
506    // synthesized boundary-aware exchange patch carries an *earlier* keystroke
507    // snapshot of the live user region (T1 — e.g. a half-typed line "Add to ")
508    // while the editor buffer kept being typed (T2 — "Add to resume"). The two
509    // texts then diverge mid-region, so the `contains` substring check above
510    // misses the duplicate and the whole tail re-appends from the divergence
511    // point. Recognize when the patch aligns to a contiguous run of the existing
512    // region under per-line prefix matching (each patch line is a prefix of, or
513    // equal to, the aligned existing line) and treat it as already present.
514    // This can only *suppress* re-appending text the live region already
515    // supersedes — the no-op keeps the longer live buffer — so it can never
516    // delete content or collapse a genuinely distinct prompt.
517    patch_is_typing_snapshot_of(&existing_norm, &patch)
518}
519
520/// True when `patch` is an in-progress keystroke-snapshot of a contiguous run
521/// already present in `existing`: every patch line equals, or is a non-empty
522/// prefix of, the aligned existing line, with at least one such prefix
523/// divergence (an exact run is already handled by the `contains` check). Both
524/// inputs must already be normalized via `normalize_append_patch_content`.
525fn patch_is_typing_snapshot_of(existing: &str, patch: &str) -> bool {
526    let e: Vec<&str> = existing.lines().collect();
527    let p: Vec<&str> = patch.lines().collect();
528    if p.is_empty() || p.len() > e.len() {
529        return false;
530    }
531    'outer: for start in 0..=(e.len() - p.len()) {
532        let mut saw_prefix_divergence = false;
533        for (j, pl) in p.iter().enumerate() {
534            let el = e[start + j];
535            if pl == &el {
536                continue;
537            }
538            // An empty patch line must match exactly — `starts_with("")` is
539            // always true and would let a blank line align to anything.
540            if !pl.is_empty() && el.starts_with(pl) {
541                saw_prefix_divergence = true;
542                continue;
543            }
544            continue 'outer;
545        }
546        if saw_prefix_divergence {
547            return true;
548        }
549    }
550    false
551}
552
553fn needs_exchange_heading_separator(component_name: &str, prior: &str, content: &str) -> bool {
554    component_name == EXCHANGE_COMPONENT
555        && !prior.trim().is_empty()
556        && !prior.ends_with("\n\n")
557        && starts_with_response_heading(content)
558}
559
560fn starts_with_response_heading(content: &str) -> bool {
561    content.trim_start().starts_with("### Re:")
562}
563
564fn normalize_append_patch_content(content: &str) -> String {
565    content
566        .lines()
567        .filter_map(|line| {
568            let trimmed = line.trim();
569            if trimmed.starts_with("<!-- agent:boundary:") && trimmed.ends_with(" -->") {
570                None
571            } else if markdown_heading_has_head_marker(line) {
572                Some(strip_user_prompt_prefix(line.trim_end_matches(" (HEAD)")))
573            } else {
574                Some(strip_user_prompt_prefix(line))
575            }
576        })
577        .collect::<Vec<_>>()
578        .join("\n")
579        .trim()
580        .to_string()
581}
582
583/// Strip a leading `❯ ` / `❯` user-prompt prefix from a line for dedup comparison.
584///
585/// The exchange user-prompt normalization adds a `❯ ` prefix to user lines, but a
586/// synthesized boundary-aware exchange patch and the live editor buffer can differ
587/// only by that prefix (one normalized, the other not). Without stripping it, the
588/// `append_patch_already_present` `contains` check misses the duplicate and the
589/// prompt is re-appended — the `#prompt-duplicated-while-typing` duplication. The
590/// `❯` glyph is a presentation prefix, not prompt content, so stripping it for
591/// comparison cannot drop a genuinely distinct prompt. Lines without the prefix are
592/// returned unchanged.
593fn strip_user_prompt_prefix(line: &str) -> String {
594    let trimmed_start = line.trim_start();
595    if let Some(rest) = trimmed_start.strip_prefix("❯ ") {
596        rest.to_string()
597    } else if let Some(rest) = trimmed_start.strip_prefix('❯') {
598        rest.trim_start().to_string()
599    } else {
600        line.to_string()
601    }
602}
603
604fn markdown_heading_has_head_marker(line: &str) -> bool {
605    let trimmed = line.trim_start();
606    let hashes = trimmed.bytes().take_while(|b| *b == b'#').count();
607    (1..=6).contains(&hashes)
608        && trimmed.as_bytes().get(hashes) == Some(&b' ')
609        && line.ends_with(" (HEAD)")
610}
611
612/// True if the text inside `<!-- ... -->` is an agent component marker.
613///
614/// Matches `agent:NAME [attrs...]` (open) or `/agent:NAME` (close).
615pub fn is_agent_marker(comment_text: &str) -> bool {
616    let trimmed = comment_text.trim();
617    if let Some(rest) = trimmed.strip_prefix("/agent:") {
618        is_valid_name(rest)
619    } else if let Some(rest) = trimmed.strip_prefix("agent:") {
620        // Opening marker may have attributes after the name: `agent:NAME key=value`
621        let name_part = rest.split_whitespace().next().unwrap_or("");
622        is_valid_name(name_part)
623    } else {
624        false
625    }
626}
627
628fn bounded_preview(doc: &str, start: usize, max_bytes: usize) -> &str {
629    debug_assert!(doc.is_char_boundary(start));
630    let mut end = doc.len().min(start.saturating_add(max_bytes));
631    while end > start && !doc.is_char_boundary(end) {
632        end -= 1;
633    }
634    &doc[start..end]
635}
636
637/// Parse `key=value` pairs from the attribute portion of an opening marker.
638///
639/// Given the text after `agent:NAME `, parses space-separated `key=value` pairs.
640/// Values are unquoted (no quote support needed for simple mode values).
641pub fn parse_attrs(attr_text: &str) -> HashMap<String, String> {
642    let mut attrs = HashMap::new();
643    for token in attr_text.split_whitespace() {
644        if let Some((key, value)) = token.split_once('=') {
645            if !key.is_empty() && !value.is_empty() {
646                attrs.insert(key.to_string(), value.to_string());
647            }
648        } else if !token.is_empty() {
649            attrs.insert(token.to_string(), String::new());
650        }
651    }
652    attrs
653}
654
655/// Find byte ranges of code regions (fenced code blocks + inline code spans).
656/// Markers inside these ranges are treated as literal text, not component markers.
657///
658/// Uses `pulldown-cmark` AST parsing with `offset_iter()` to accurately detect
659/// code regions per the CommonMark spec.
660pub fn find_code_ranges(doc: &str) -> Vec<(usize, usize)> {
661    let t = std::time::Instant::now();
662    let mut ranges = Vec::new();
663    let parser = Parser::new_ext(doc, Options::empty());
664    let mut iter = parser.into_offset_iter();
665    while let Some((event, range)) = iter.next() {
666        match event {
667            // Inline code span: `code` or ``code``
668            Event::Code(_) => {
669                ranges.push((range.start, range.end));
670            }
671            // Fenced or indented code block: consume until End(CodeBlock)
672            Event::Start(Tag::CodeBlock(_)) => {
673                let block_start = range.start;
674                let mut block_end = range.end;
675                for (inner_event, inner_range) in iter.by_ref() {
676                    block_end = inner_range.end;
677                    if matches!(inner_event, Event::End(TagEnd::CodeBlock)) {
678                        break;
679                    }
680                }
681                ranges.push((block_start, block_end));
682            }
683            _ => {}
684        }
685    }
686    let elapsed = t.elapsed().as_millis();
687    if elapsed > 0 {
688        eprintln!("[perf] find_code_ranges: {}ms", elapsed);
689    }
690    ranges
691}
692
693/// Find byte ranges of same-line double-quoted spans (`"..."`) — `#0kjc`/`#mdastquote`.
694///
695/// A `<!-- agent:* -->` marker (or other tag-looking token) embedded inside a
696/// double-quoted prose span — for example a queue/backlog item that quotes a
697/// marker by name like `"<!-- agent:queue -->"` — is content, not a real
698/// document tag, and must not be parsed as one (same family as the code-span /
699/// code-fence masking in [`find_code_ranges`], and as `#lintfence`).
700///
701/// Quotes are scoped to a single line, so an unterminated quote only masks the
702/// rest of its own line (fail-safe: it can never swallow a real marker on a
703/// later line). The opening `"` is included in the returned span. A real
704/// marker's own attribute value (for example `preset="..."`) sits to the right
705/// of the marker's `<!--`, so the marker's start byte is never inside one of
706/// these spans and stays parseable. Mirrors the `\"`-escape handling of
707/// `crate::gate_verify`'s `strip_quoted_spans` (`#gng8`).
708pub fn find_quoted_ranges(doc: &str) -> Vec<(usize, usize)> {
709    let mut ranges = Vec::new();
710    let mut offset = 0usize;
711    for raw_line in doc.split_inclusive('\n') {
712        let line_start = offset;
713        offset += raw_line.len();
714        let bytes = raw_line.as_bytes();
715        let line_content_len = raw_line.trim_end_matches(['\n', '\r']).len();
716        let mut i = 0usize;
717        while i < bytes.len() {
718            if bytes[i] != b'"' {
719                i += 1;
720                continue;
721            }
722            let span_start = line_start + i;
723            let mut j = i + 1;
724            let mut closed = false;
725            while j < bytes.len() {
726                match bytes[j] {
727                    b'\\' => {
728                        j += 2;
729                        continue;
730                    }
731                    b'"' => {
732                        closed = true;
733                        break;
734                    }
735                    b'\n' | b'\r' => break,
736                    _ => j += 1,
737                }
738            }
739            if closed {
740                ranges.push((span_start, line_start + j + 1));
741                i = j + 1;
742            } else {
743                // Unterminated quote: mask to end of line (fail-safe), then stop
744                // scanning this line.
745                ranges.push((span_start, line_start + line_content_len));
746                break;
747            }
748        }
749    }
750    ranges
751}
752
753/// Find byte ranges for ordinary HTML comments, skipping code spans/blocks and
754/// preserving `agent:` markers for the component parser.
755///
756/// If an ordinary comment is currently unterminated, treat the rest of the
757/// document as comment body. Users can be editing a scratch comment while an
758/// agent-doc cycle is running; prompt-like text in that transiently broken
759/// comment must not be interpreted as escaped exchange content and moved or
760/// stripped by repair.
761pub fn find_non_agent_html_comment_ranges(doc: &str) -> Vec<(usize, usize)> {
762    let code_ranges = find_code_ranges(doc);
763    let quoted_ranges = find_quoted_ranges(doc);
764    let in_code = |pos: usize| {
765        code_ranges
766            .iter()
767            .chain(quoted_ranges.iter())
768            .any(|&(start, end)| pos >= start && pos < end)
769    };
770
771    let bytes = doc.as_bytes();
772    let len = bytes.len();
773    let mut ranges = Vec::new();
774    let mut pos = 0usize;
775    while pos + 4 <= len {
776        if &bytes[pos..pos + 4] != b"<!--" {
777            pos += 1;
778            continue;
779        }
780        if in_code(pos) {
781            pos += 4;
782            continue;
783        }
784        let Some(end) = find_comment_end(bytes, pos + 4) else {
785            let inner = &doc[pos + 4..];
786            if !is_agent_marker(inner) && !inner.trim_start().starts_with("agent:boundary:") {
787                ranges.push((pos, len));
788                break;
789            }
790            pos += 4;
791            continue;
792        };
793        let inner = &doc[pos + 4..end - 3];
794        if !is_agent_marker(inner) && !inner.trim().starts_with("agent:boundary:") {
795            ranges.push((pos, end));
796        }
797        pos = end;
798    }
799    ranges
800}
801
802/// Parse all components from a document.
803///
804/// Uses a stack for nesting. Returns components sorted by `open_start`.
805/// Errors on unmatched open/close markers or invalid names.
806/// Skips markers inside fenced code blocks and inline code spans.
807pub fn parse(doc: &str) -> Result<Vec<Component>> {
808    let bytes = doc.as_bytes();
809    let len = bytes.len();
810    let code_ranges = find_code_ranges(doc);
811    let quoted_ranges = find_quoted_ranges(doc);
812    let mut templates: Vec<Component> = Vec::new();
813    // Stack of (name, attrs, open_start, open_end)
814    let mut stack: Vec<(String, HashMap<String, String>, usize, usize)> = Vec::new();
815    let mut pos = 0;
816
817    while pos + 4 <= len {
818        // Look for `<!--`
819        if &bytes[pos..pos + 4] != b"<!--" {
820            pos += 1;
821            continue;
822        }
823
824        // Skip markers inside code regions or double-quoted prose spans
825        // (a quoted `"<!-- agent:* -->"` is content, not a real marker — `#0kjc`).
826        if code_ranges
827            .iter()
828            .chain(quoted_ranges.iter())
829            .any(|&(start, end)| pos >= start && pos < end)
830        {
831            pos += 4;
832            continue;
833        }
834
835        // Quick peek: only consume `<!-- ... -->` if the text after `<!--`
836        // starts with ` agent:` or ` /agent:` (with optional whitespace).
837        // Non-agent `<!--` sequences (e.g., `<!-- )` in prose) must NOT be
838        // consumed — their `-->` search would eat real component close markers.
839        let peek_start = pos + 4;
840        let peek = bounded_preview(doc, peek_start, 20);
841        let peek_trimmed = peek.trim_start();
842        if !peek_trimmed.starts_with("agent:") && !peek_trimmed.starts_with("/agent:") {
843            pos += 1;
844            continue;
845        }
846
847        let marker_start = pos;
848
849        // Find closing `-->`
850        let close = match find_comment_end(bytes, pos + 4) {
851            Some(c) => c,
852            None => {
853                pos += 4;
854                continue;
855            }
856        };
857
858        // close points to the byte after `>`
859        let inner = &doc[marker_start + 4..close - 3]; // between `<!--` and `-->`
860        let trimmed = inner.trim();
861
862        // Determine end offset — consume trailing newline if present
863        let mut marker_end = close;
864        if marker_end < len && bytes[marker_end] == b'\n' {
865            marker_end += 1;
866        }
867
868        if let Some(name) = trimmed.strip_prefix("/agent:") {
869            // Closing marker
870            if !is_valid_name(name) {
871                bail!("invalid component name: '{}'", name);
872            }
873            match stack.pop() {
874                Some((open_name, open_attrs, open_start, open_end)) => {
875                    if open_name != name {
876                        bail!(
877                            "mismatched component: opened '{}' but closed '{}'",
878                            open_name,
879                            name
880                        );
881                    }
882                    templates.push(Component {
883                        name: name.to_string(),
884                        attrs: open_attrs,
885                        open_start,
886                        open_end,
887                        close_start: marker_start,
888                        close_end: marker_end,
889                    });
890                }
891                None => bail!(
892                    "closing marker <!-- /agent:{} --> without matching open",
893                    name
894                ),
895            }
896        } else if let Some(rest) = trimmed.strip_prefix("agent:") {
897            // Skip boundary markers — these are not component markers
898            if rest.starts_with("boundary:") {
899                pos = close;
900                continue;
901            }
902            // Opening marker — may have attributes: `agent:NAME key=value`
903            let mut parts = rest.splitn(2, |c: char| c.is_whitespace());
904            let name = parts.next().unwrap_or("");
905            let attr_text = parts.next().unwrap_or("");
906            if !is_valid_name(name) {
907                bail!("invalid component name: '{}'", name);
908            }
909            let attrs = parse_attrs(attr_text);
910            stack.push((name.to_string(), attrs, marker_start, marker_end));
911        }
912
913        pos = close;
914    }
915
916    if let Some((name, _, _, _)) = stack.last() {
917        bail!(
918            "unclosed component: <!-- agent:{} --> without matching close",
919            name
920        );
921    }
922
923    templates.sort_by_key(|t| t.open_start);
924    Ok(templates)
925}
926
927/// Singleton components that must appear at most once in a structurally valid
928/// document. Mirrors the singleton list compaction's item-count guard uses
929/// (`#compactdropitem`). Components not listed here (e.g. `log`) may repeat.
930const SINGLETON_COMPONENT_NAMES: &[&str] = &[
931    EXCHANGE_COMPONENT,
932    "status",
933    "queue",
934    BACKLOG_COMPONENT,
935    REVIEW_COMPONENT,
936    ICEBOX_COMPONENT,
937    BACKLOG_DONE_COMPONENT,
938];
939
940/// Map a component name to its canonical singleton key, collapsing the legacy
941/// backlog alias (`pending` → `backlog`). Returns `None` for names that may
942/// legitimately repeat.
943fn canonical_singleton_name(name: &str) -> Option<&'static str> {
944    if is_backlog_component(name) {
945        return Some(BACKLOG_COMPONENT);
946    }
947    SINGLETON_COMPONENT_NAMES
948        .iter()
949        .copied()
950        .find(|&s| s == name)
951}
952
953/// True when `marker` contains an odd number of unescaped double-quotes — an
954/// unterminated quoted attribute value (e.g. `<!-- agent:queue tasks=" -->`, a
955/// CRDT merge-boundary split). Backslash escapes are honored to match
956/// [`find_quoted_ranges`] semantics.
957fn marker_has_unterminated_quote(marker: &str) -> bool {
958    let mut open = false;
959    let mut escaped = false;
960    for ch in marker.chars() {
961        if escaped {
962            escaped = false;
963            continue;
964        }
965        match ch {
966            '\\' => escaped = true,
967            '"' => open = !open,
968            _ => {}
969        }
970    }
971    open
972}
973
974fn byte_in_ranges(offset: usize, ranges: &[(usize, usize)]) -> bool {
975    ranges
976        .iter()
977        .any(|&(start, end)| offset >= start && offset < end)
978}
979
980/// Detect agent-looking HTML comment markers that were truncated before their
981/// same-line `-->` terminator. The normal parser only sees complete comments,
982/// so a CRDT/editor split like `<!-- /agent:exchange --` can otherwise be
983/// skipped or consume a later unrelated terminator.
984pub fn malformed_agent_comment_reason(doc: &str) -> Option<String> {
985    let code_ranges = find_code_ranges(doc);
986    let quoted_ranges = find_quoted_ranges(doc);
987    let mut offset = 0usize;
988    for (line_idx, raw_line) in doc.split_inclusive('\n').enumerate() {
989        let line = raw_line.trim_end_matches(['\n', '\r']);
990        let leading = line.len() - line.trim_start_matches([' ', '\t']).len();
991        let marker_start = offset + leading;
992        let trimmed = &line[leading..];
993        offset += raw_line.len();
994
995        if !trimmed.starts_with("<!--") {
996            continue;
997        }
998        if byte_in_ranges(marker_start, &code_ranges)
999            || byte_in_ranges(marker_start, &quoted_ranges)
1000        {
1001            continue;
1002        }
1003        let inner = trimmed["<!--".len()..].trim_start();
1004        if (inner.starts_with("agent:") || inner.starts_with("/agent:")) && !trimmed.contains("-->")
1005        {
1006            let preview = trimmed.chars().take(80).collect::<String>();
1007            return Some(format!(
1008                "malformed_agent_comment:line{}:{preview}",
1009                line_idx + 1
1010            ));
1011        }
1012    }
1013    None
1014}
1015
1016/// Detect structural corruption that must never be adopted as a snapshot base
1017/// (`#dupcontent`). Returns `Some(reason)` when the document is corrupt:
1018/// - a parse failure (mismatched / unclosed markers),
1019/// - more than one opening marker for a singleton component (duplicate block),
1020/// - an unterminated double-quoted attribute on a component opening marker.
1021///
1022/// Returns `None` when the document is structurally sound. Used to fail closed
1023/// before adopting a `content_ours` / `ack_content_sidecar` IPC buffer whose
1024/// prior CRDT merge produced duplicate singleton blocks or a split attribute,
1025/// so the corrupt buffer never reaches disk.
1026pub fn structural_corruption_reason(doc: &str) -> Option<String> {
1027    if let Some(reason) = malformed_agent_comment_reason(doc) {
1028        return Some(reason);
1029    }
1030
1031    let components = match parse(doc) {
1032        Ok(c) => c,
1033        Err(e) => return Some(format!("parse_error: {e}")),
1034    };
1035
1036    // Duplicate singleton component blocks.
1037    let mut counts: HashMap<&str, usize> = HashMap::new();
1038    for c in &components {
1039        if let Some(canonical) = canonical_singleton_name(&c.name) {
1040            *counts.entry(canonical).or_insert(0) += 1;
1041        }
1042    }
1043    let mut dupes: Vec<(&str, usize)> = counts
1044        .iter()
1045        .filter(|&(_, &n)| n > 1)
1046        .map(|(&k, &n)| (k, n))
1047        .collect();
1048    if !dupes.is_empty() {
1049        dupes.sort();
1050        let detail = dupes
1051            .iter()
1052            .map(|(k, n)| format!("{k}={n}"))
1053            .collect::<Vec<_>>()
1054            .join(",");
1055        return Some(format!("duplicate_singleton_component:{detail}"));
1056    }
1057
1058    // Unterminated double-quoted attribute in a component opening marker.
1059    for c in &components {
1060        let marker = &doc[c.open_start..c.open_end];
1061        if marker_has_unterminated_quote(marker) {
1062            return Some(format!("malformed_attr:{}", c.name));
1063        }
1064    }
1065
1066    None
1067}
1068
1069/// Find the end of an HTML comment (`-->`), returning byte offset past `>`.
1070pub fn find_comment_end(bytes: &[u8], start: usize) -> Option<usize> {
1071    let len = bytes.len();
1072    let mut i = start;
1073    while i + 3 <= len {
1074        if &bytes[i..i + 3] == b"-->" {
1075            return Some(i + 3);
1076        }
1077        i += 1;
1078    }
1079    None
1080}
1081
1082/// Strip comments from document content for diff comparison.
1083///
1084/// Removes:
1085/// - HTML comments `<!-- ... -->` (single and multiline) — EXCEPT agent range markers
1086/// - Link reference comments `[//]: # (...)`
1087///
1088/// Skips `<!--` sequences inside fenced code blocks and inline backtick spans
1089/// to prevent code examples containing `<!--` from being misinterpreted as
1090/// comment starts.
1091///
1092/// This function is the shared implementation used by both `diff::compute` (binary)
1093/// and external crates like `eval-runner`.
1094pub fn strip_comments(content: &str) -> String {
1095    let code_ranges = find_code_ranges(content);
1096    let in_code = |pos: usize| {
1097        code_ranges
1098            .iter()
1099            .any(|&(start, end)| pos >= start && pos < end)
1100    };
1101
1102    let mut result = String::with_capacity(content.len());
1103    let bytes = content.as_bytes();
1104    let len = bytes.len();
1105    let mut pos = 0;
1106
1107    while pos < len {
1108        // Check for link reference comment: `[//]: # (...)`
1109        if bytes[pos] == b'['
1110            && !in_code(pos)
1111            && is_line_start_at(bytes, pos)
1112            && let Some(end) = match_link_ref_comment_at(bytes, pos)
1113        {
1114            pos = end;
1115            continue;
1116        }
1117
1118        // Check for HTML comment: `<!-- ... -->`
1119        if pos + 4 <= len
1120            && &bytes[pos..pos + 4] == b"<!--"
1121            && !in_code(pos)
1122            && let Some((end, inner)) = match_html_comment_at(content, pos)
1123        {
1124            if is_agent_marker(inner) {
1125                // Preserve agent markers — copy them through
1126                result.push_str(&content[pos..end]);
1127                pos = end;
1128            } else {
1129                // Strip the comment (and trailing newline if on its own line)
1130                let mut skip_to = end;
1131                if is_line_start_at(bytes, pos) && skip_to < len && bytes[skip_to] == b'\n' {
1132                    skip_to += 1;
1133                }
1134                pos = skip_to;
1135            }
1136            continue;
1137        }
1138
1139        result.push(content[pos..].chars().next().unwrap());
1140        pos += content[pos..].chars().next().unwrap().len_utf8();
1141    }
1142
1143    result
1144}
1145
1146/// True if `pos` is at the start of a line (pos == 0 or bytes[pos-1] == '\n').
1147fn is_line_start_at(bytes: &[u8], pos: usize) -> bool {
1148    pos == 0 || bytes[pos - 1] == b'\n'
1149}
1150
1151/// Match `[//]: # (...)` starting at `pos`. Returns byte offset past the line end.
1152fn match_link_ref_comment_at(bytes: &[u8], pos: usize) -> Option<usize> {
1153    let prefix = b"[//]: # (";
1154    let len = bytes.len();
1155    if pos + prefix.len() > len || &bytes[pos..pos + prefix.len()] != prefix {
1156        return None;
1157    }
1158    let mut i = pos + prefix.len();
1159    while i < len && bytes[i] != b')' && bytes[i] != b'\n' {
1160        i += 1;
1161    }
1162    if i < len && bytes[i] == b')' {
1163        i += 1;
1164        if i < len && bytes[i] == b'\n' {
1165            i += 1;
1166        }
1167        Some(i)
1168    } else {
1169        None
1170    }
1171}
1172
1173/// Match `<!-- ... -->` starting at `pos`. Returns (end_offset, inner_text).
1174fn match_html_comment_at(content: &str, pos: usize) -> Option<(usize, &str)> {
1175    let bytes = content.as_bytes();
1176    let len = bytes.len();
1177    let mut i = pos + 4;
1178    while i + 3 <= len {
1179        if &bytes[i..i + 3] == b"-->" {
1180            let inner = &content[pos + 4..i];
1181            return Some((i + 3, inner));
1182        }
1183        i += 1;
1184    }
1185    None
1186}
1187
1188#[cfg(test)]
1189mod tests {
1190    use super::*;
1191
1192    #[test]
1193    fn single_range() {
1194        let doc = "before\n<!-- agent:status -->\nHello\n<!-- /agent:status -->\nafter\n";
1195        let ranges = parse(doc).unwrap();
1196        assert_eq!(ranges.len(), 1);
1197        assert_eq!(ranges[0].name, "status");
1198        assert_eq!(ranges[0].content(doc), "Hello\n");
1199    }
1200
1201    #[test]
1202    fn nested_ranges() {
1203        let doc = "\
1204<!-- agent:outer -->
1205<!-- agent:inner -->
1206content
1207<!-- /agent:inner -->
1208<!-- /agent:outer -->
1209";
1210        let ranges = parse(doc).unwrap();
1211        assert_eq!(ranges.len(), 2);
1212        // Sorted by open_start — outer first
1213        assert_eq!(ranges[0].name, "outer");
1214        assert_eq!(ranges[1].name, "inner");
1215        assert_eq!(ranges[1].content(doc), "content\n");
1216    }
1217
1218    #[test]
1219    fn siblings() {
1220        let doc = "\
1221<!-- agent:a -->
1222alpha
1223<!-- /agent:a -->
1224<!-- agent:b -->
1225beta
1226<!-- /agent:b -->
1227";
1228        let ranges = parse(doc).unwrap();
1229        assert_eq!(ranges.len(), 2);
1230        assert_eq!(ranges[0].name, "a");
1231        assert_eq!(ranges[0].content(doc), "alpha\n");
1232        assert_eq!(ranges[1].name, "b");
1233        assert_eq!(ranges[1].content(doc), "beta\n");
1234    }
1235
1236    #[test]
1237    fn no_ranges() {
1238        let doc = "# Just a document\n\nWith no range templates.\n";
1239        let ranges = parse(doc).unwrap();
1240        assert!(ranges.is_empty());
1241    }
1242
1243    #[test]
1244    fn unmatched_open_error() {
1245        let doc = "<!-- agent:orphan -->\nContent\n";
1246        let err = parse(doc).unwrap_err();
1247        assert!(err.to_string().contains("unclosed component"));
1248    }
1249
1250    #[test]
1251    fn unmatched_close_error() {
1252        let doc = "Content\n<!-- /agent:orphan -->\n";
1253        let err = parse(doc).unwrap_err();
1254        assert!(err.to_string().contains("without matching open"));
1255    }
1256
1257    #[test]
1258    fn mismatched_names_error() {
1259        let doc = "<!-- agent:foo -->\n<!-- /agent:bar -->\n";
1260        let err = parse(doc).unwrap_err();
1261        assert!(err.to_string().contains("mismatched"));
1262    }
1263
1264    #[test]
1265    fn invalid_name() {
1266        let doc = "<!-- agent:-bad -->\n<!-- /agent:-bad -->\n";
1267        let err = parse(doc).unwrap_err();
1268        assert!(err.to_string().contains("invalid component name"));
1269    }
1270
1271    #[test]
1272    fn name_validation() {
1273        assert!(is_valid_name("status"));
1274        assert!(is_valid_name("my-section"));
1275        assert!(is_valid_name("a1"));
1276        assert!(is_valid_name("A"));
1277        assert!(!is_valid_name(""));
1278        assert!(!is_valid_name("-bad"));
1279        assert!(!is_valid_name("has space"));
1280        assert!(!is_valid_name("has_underscore"));
1281    }
1282
1283    #[test]
1284    fn content_extraction() {
1285        let doc = "<!-- agent:x -->\nfoo\nbar\n<!-- /agent:x -->\n";
1286        let ranges = parse(doc).unwrap();
1287        assert_eq!(ranges[0].content(doc), "foo\nbar\n");
1288    }
1289
1290    #[test]
1291    fn replace_roundtrip() {
1292        let doc = "before\n<!-- agent:s -->\nold\n<!-- /agent:s -->\nafter\n";
1293        let ranges = parse(doc).unwrap();
1294        let new_doc = ranges[0].replace_content(doc, "new\n");
1295        assert_eq!(
1296            new_doc,
1297            "before\n<!-- agent:s -->\nnew\n<!-- /agent:s -->\nafter\n"
1298        );
1299        // Re-parse should work
1300        let ranges2 = parse(&new_doc).unwrap();
1301        assert_eq!(ranges2.len(), 1);
1302        assert_eq!(ranges2[0].content(&new_doc), "new\n");
1303    }
1304
1305    #[test]
1306    fn is_agent_marker_yes() {
1307        assert!(is_agent_marker(" agent:status "));
1308        assert!(is_agent_marker("/agent:status"));
1309        assert!(is_agent_marker("agent:my-thing"));
1310        assert!(is_agent_marker(" /agent:A1 "));
1311    }
1312
1313    #[test]
1314    fn is_agent_marker_no() {
1315        assert!(!is_agent_marker("just a comment"));
1316        assert!(!is_agent_marker("agent:"));
1317        assert!(!is_agent_marker("/agent:"));
1318        assert!(!is_agent_marker("agent:-bad"));
1319        assert!(!is_agent_marker("some agent:fake stuff"));
1320    }
1321
1322    #[test]
1323    fn regular_comments_ignored() {
1324        let doc = "<!-- just a comment -->\n<!-- agent:x -->\ndata\n<!-- /agent:x -->\n";
1325        let ranges = parse(doc).unwrap();
1326        assert_eq!(ranges.len(), 1);
1327        assert_eq!(ranges[0].name, "x");
1328    }
1329
1330    #[test]
1331    fn regular_comments_with_multibyte_preview_boundary_ignored() {
1332        let doc = "\
1333<!-- 123456789012345678❯ ordinary comment -->
1334<!-- agent:x -->
1335data
1336<!-- /agent:x -->
1337";
1338        let ranges = parse(doc).unwrap();
1339        assert_eq!(ranges.len(), 1);
1340        assert_eq!(ranges[0].name, "x");
1341    }
1342
1343    #[test]
1344    fn multiline_comment_ignored() {
1345        let doc = "\
1346<!--
1347multi
1348line
1349comment
1350-->
1351<!-- agent:s -->
1352content
1353<!-- /agent:s -->
1354";
1355        let ranges = parse(doc).unwrap();
1356        assert_eq!(ranges.len(), 1);
1357        assert_eq!(ranges[0].name, "s");
1358    }
1359
1360    #[test]
1361    fn empty_content() {
1362        let doc = "<!-- agent:empty --><!-- /agent:empty -->\n";
1363        let ranges = parse(doc).unwrap();
1364        assert_eq!(ranges.len(), 1);
1365        assert_eq!(ranges[0].content(doc), "");
1366    }
1367
1368    #[test]
1369    fn markers_in_fenced_code_block_ignored() {
1370        let doc = "\
1371<!-- agent:real -->
1372content
1373<!-- /agent:real -->
1374```markdown
1375<!-- agent:fake -->
1376this is just an example
1377<!-- /agent:fake -->
1378```
1379";
1380        let ranges = parse(doc).unwrap();
1381        assert_eq!(ranges.len(), 1);
1382        assert_eq!(ranges[0].name, "real");
1383    }
1384
1385    #[test]
1386    fn markers_in_inline_code_ignored() {
1387        let doc = "\
1388Use `<!-- agent:example -->` markers for components.
1389<!-- agent:real -->
1390content
1391<!-- /agent:real -->
1392";
1393        let ranges = parse(doc).unwrap();
1394        assert_eq!(ranges.len(), 1);
1395        assert_eq!(ranges[0].name, "real");
1396    }
1397
1398    #[test]
1399    fn markers_in_tilde_fence_ignored() {
1400        let doc = "\
1401<!-- agent:x -->
1402data
1403<!-- /agent:x -->
1404~~~
1405<!-- agent:y -->
1406example
1407<!-- /agent:y -->
1408~~~
1409";
1410        let ranges = parse(doc).unwrap();
1411        assert_eq!(ranges.len(), 1);
1412        assert_eq!(ranges[0].name, "x");
1413    }
1414
1415    #[test]
1416    fn markers_in_indented_fenced_code_block_ignored() {
1417        // CommonMark allows up to 3 spaces before fence opener
1418        let doc = "\
1419<!-- agent:exchange -->
1420Content here.
1421<!-- /agent:exchange -->
1422
1423  ```markdown
1424  <!-- agent:fake -->
1425  demo without closing tag
1426  ```
1427";
1428        let ranges = parse(doc).unwrap();
1429        assert_eq!(ranges.len(), 1);
1430        assert_eq!(ranges[0].name, "exchange");
1431    }
1432
1433    #[test]
1434    fn indented_fence_inside_component_ignored() {
1435        // Indented code block inside a component should not cause mismatched errors
1436        let doc = "\
1437<!-- agent:exchange -->
1438Here's how to set up:
1439
1440   ```markdown
1441   <!-- agent:status -->
1442   Your status here
1443   ```
1444
1445Done explaining.
1446<!-- /agent:exchange -->
1447";
1448        let ranges = parse(doc).unwrap();
1449        assert_eq!(ranges.len(), 1);
1450        assert_eq!(ranges[0].name, "exchange");
1451    }
1452
1453    #[test]
1454    fn deeply_indented_fence_ignored() {
1455        // Tabs and many spaces should still be detected as a fence
1456        let doc = "\
1457<!-- agent:x -->
1458ok
1459<!-- /agent:x -->
1460      ```
1461      <!-- agent:y -->
1462      inside fence
1463      ```
1464";
1465        let ranges = parse(doc).unwrap();
1466        assert_eq!(ranges.len(), 1);
1467        assert_eq!(ranges[0].name, "x");
1468    }
1469
1470    #[test]
1471    fn indented_fence_code_ranges_detected() {
1472        let doc = "before\n  ```\n  code\n  ```\nafter\n";
1473        let ranges = find_code_ranges(doc);
1474        assert_eq!(ranges.len(), 1);
1475        assert!(doc[ranges[0].0..ranges[0].1].contains("code"));
1476    }
1477
1478    #[test]
1479    fn code_ranges_detected() {
1480        let doc = "before\n```\ncode\n```\nafter `inline` end\n";
1481        let ranges = find_code_ranges(doc);
1482        assert_eq!(ranges.len(), 2);
1483        // Fenced block
1484        assert!(doc[ranges[0].0..ranges[0].1].contains("code"));
1485        // Inline span
1486        assert!(doc[ranges[1].0..ranges[1].1].contains("inline"));
1487    }
1488
1489    #[test]
1490    fn code_ranges_double_backtick() {
1491        // CommonMark: `` `<!--` `` is a code span containing `<!--`
1492        let doc = "text `` `<!--` `` more\n";
1493        let ranges = find_code_ranges(doc);
1494        assert_eq!(ranges.len(), 1);
1495        let span = &doc[ranges[0].0..ranges[0].1];
1496        assert!(
1497            span.contains("<!--"),
1498            "double-backtick span should contain <!--: {:?}",
1499            span
1500        );
1501    }
1502
1503    #[test]
1504    fn code_ranges_double_backtick_does_not_match_single() {
1505        // `` should not match a single ` close
1506        let doc = "text `` foo ` bar `` end\n";
1507        let ranges = find_code_ranges(doc);
1508        assert_eq!(ranges.len(), 1);
1509        let span = &doc[ranges[0].0..ranges[0].1];
1510        assert_eq!(span, "`` foo ` bar ``");
1511    }
1512
1513    #[test]
1514    fn double_backtick_comment_before_agent_marker() {
1515        // Regression: `` `<!--` `` followed by agent marker should not confuse the parser
1516        let doc = "\
1517<!-- agent:exchange -->\n\
1518text `` `<!--` `` description\n\
1519new content here\n\
1520<!-- /agent:exchange -->\n";
1521        let components = parse(doc).unwrap();
1522        assert_eq!(components.len(), 1);
1523        assert_eq!(components[0].name, "exchange");
1524        assert!(components[0].content(doc).contains("new content here"));
1525    }
1526
1527    #[test]
1528    fn quoted_agent_marker_in_prose_is_not_parsed() {
1529        // #0kjc: a marker quoted by name in prose is content, not a real marker.
1530        // Without quote-masking the dangling `<!-- agent:queue -->` inside the
1531        // quotes would open an unmatched component and error the whole parse.
1532        let doc = "\
1533<!-- agent:exchange -->\n\
1534Operator note: \"<!-- agent:queue -->\" attributes were dropped on compaction.\n\
1535new content here\n\
1536<!-- /agent:exchange -->\n";
1537        let components = parse(doc).unwrap();
1538        assert_eq!(components.len(), 1);
1539        assert_eq!(components[0].name, "exchange");
1540        assert!(components[0].content(doc).contains("new content here"));
1541        assert!(
1542            components[0]
1543                .content(doc)
1544                .contains("attributes were dropped")
1545        );
1546    }
1547
1548    #[test]
1549    fn quoted_id_token_in_prose_is_content() {
1550        // #0kjc: a `#id`-looking token in a quoted span is prose, never a tag.
1551        let doc = "\
1552<!-- agent:exchange -->\n\
1553The operator asked to clear \"#advance-review\" but no such line exists.\n\
1554<!-- /agent:exchange -->\n";
1555        let components = parse(doc).unwrap();
1556        assert_eq!(components.len(), 1);
1557        assert!(components[0].content(doc).contains("#advance-review"));
1558    }
1559
1560    #[test]
1561    fn fenced_agent_marker_in_prose_is_not_parsed() {
1562        // Regression guard: code-fence masking (pre-#0kjc) must keep working — a
1563        // marker inside a fenced block stays content.
1564        let doc = "\
1565<!-- agent:exchange -->\n\
1566```\n\
1567<!-- agent:queue -->\n\
1568```\n\
1569new content here\n\
1570<!-- /agent:exchange -->\n";
1571        let components = parse(doc).unwrap();
1572        assert_eq!(components.len(), 1);
1573        assert_eq!(components[0].name, "exchange");
1574        assert!(components[0].content(doc).contains("new content here"));
1575    }
1576
1577    #[test]
1578    fn real_marker_with_quoted_preset_attr_still_parses() {
1579        // #0kjc safety: a real marker carrying a double-quoted attribute value
1580        // must NOT be masked — its `<!--` precedes the quotes, so its start byte
1581        // is never inside a quoted span.
1582        let doc = "\
1583<!-- agent:queue preset=\"#spec-test-build-install-commit-push\" priority go -->\n\
1584- :pushpin: do [#thing]\n\
1585<!-- /agent:queue -->\n";
1586        let components = parse(doc).unwrap();
1587        assert_eq!(components.len(), 1);
1588        assert_eq!(components[0].name, "queue");
1589        // The marker is NOT masked (proving the safety property); the attr
1590        // parser stores the raw quoted value, same as before this change.
1591        assert!(
1592            components[0]
1593                .attrs
1594                .get("preset")
1595                .is_some_and(|v| v.contains("#spec-test-build-install-commit-push")),
1596            "preset attr should survive quote-masking, got {:?}",
1597            components[0].attrs.get("preset")
1598        );
1599    }
1600
1601    #[test]
1602    fn find_quoted_ranges_is_same_line_and_fail_safe() {
1603        // Balanced same-line span is masked; a span never crosses a newline.
1604        let doc = "a \"bc\" d\n\"ef\n";
1605        let ranges = find_quoted_ranges(doc);
1606        // First line: `"bc"` (bytes 2..6). Second line: unterminated `"ef`
1607        // masks to end-of-line only (not into the next line).
1608        assert_eq!(ranges.len(), 2);
1609        assert_eq!(&doc[ranges[0].0..ranges[0].1], "\"bc\"");
1610        assert_eq!(&doc[ranges[1].0..ranges[1].1], "\"ef");
1611    }
1612
1613    // --- Inline attribute tests ---
1614
1615    #[test]
1616    fn parse_component_with_mode_attr() {
1617        let doc = "<!-- agent:exchange mode=append -->\nContent\n<!-- /agent:exchange -->\n";
1618        let components = parse(doc).unwrap();
1619        assert_eq!(components.len(), 1);
1620        assert_eq!(components[0].name, "exchange");
1621        assert_eq!(
1622            components[0].attrs.get("mode").map(|s| s.as_str()),
1623            Some("append")
1624        );
1625        assert_eq!(components[0].content(doc), "Content\n");
1626    }
1627
1628    #[test]
1629    fn parse_component_with_multiple_attrs() {
1630        let doc = "<!-- agent:log mode=prepend timestamp=true -->\nData\n<!-- /agent:log -->\n";
1631        let components = parse(doc).unwrap();
1632        assert_eq!(components.len(), 1);
1633        assert_eq!(components[0].name, "log");
1634        assert_eq!(
1635            components[0].attrs.get("mode").map(|s| s.as_str()),
1636            Some("prepend")
1637        );
1638        assert_eq!(
1639            components[0].attrs.get("timestamp").map(|s| s.as_str()),
1640            Some("true")
1641        );
1642    }
1643
1644    #[test]
1645    fn parse_component_no_attrs_backward_compat() {
1646        let doc = "<!-- agent:status -->\nOK\n<!-- /agent:status -->\n";
1647        let components = parse(doc).unwrap();
1648        assert_eq!(components.len(), 1);
1649        assert_eq!(components[0].name, "status");
1650        assert!(components[0].attrs.is_empty());
1651    }
1652
1653    #[test]
1654    fn is_agent_marker_with_attrs() {
1655        assert!(is_agent_marker(" agent:exchange mode=append "));
1656        assert!(is_agent_marker("agent:status mode=replace"));
1657        assert!(is_agent_marker("agent:log mode=prepend timestamp=true"));
1658    }
1659
1660    #[test]
1661    fn closing_tag_unchanged_with_attrs() {
1662        // Closing tags never have attributes
1663        let doc = "<!-- agent:status mode=replace -->\n- [x] Done\n<!-- /agent:status -->\n";
1664        let components = parse(doc).unwrap();
1665        assert_eq!(components.len(), 1);
1666        let new_doc = components[0].replace_content(doc, "- [ ] Todo\n");
1667        assert!(new_doc.contains("<!-- agent:status mode=replace -->"));
1668        assert!(new_doc.contains("<!-- /agent:status -->"));
1669        assert!(new_doc.contains("- [ ] Todo"));
1670    }
1671
1672    #[test]
1673    fn parse_component_with_patch_attr() {
1674        let doc = "<!-- agent:exchange patch=append -->\nContent\n<!-- /agent:exchange -->\n";
1675        let components = parse(doc).unwrap();
1676        assert_eq!(components.len(), 1);
1677        assert_eq!(components[0].name, "exchange");
1678        assert_eq!(components[0].patch_mode(), Some("append"));
1679        assert_eq!(components[0].content(doc), "Content\n");
1680    }
1681
1682    #[test]
1683    fn patch_attr_takes_precedence_over_mode() {
1684        let doc = "<!-- agent:exchange patch=replace mode=append -->\nContent\n<!-- /agent:exchange -->\n";
1685        let components = parse(doc).unwrap();
1686        assert_eq!(components[0].patch_mode(), Some("replace"));
1687    }
1688
1689    #[test]
1690    fn mode_attr_backward_compat() {
1691        let doc = "<!-- agent:exchange mode=append -->\nContent\n<!-- /agent:exchange -->\n";
1692        let components = parse(doc).unwrap();
1693        assert_eq!(components[0].patch_mode(), Some("append"));
1694    }
1695
1696    #[test]
1697    fn no_patch_or_mode_attr() {
1698        let doc = "<!-- agent:exchange -->\nContent\n<!-- /agent:exchange -->\n";
1699        let components = parse(doc).unwrap();
1700        assert_eq!(components[0].patch_mode(), None);
1701    }
1702
1703    // --- Inline backtick code span exclusion tests ---
1704
1705    #[test]
1706    fn single_backtick_component_tag_ignored() {
1707        // A component tag wrapped in single backticks should not be parsed
1708        let doc = "\
1709Use `<!-- agent:pending -->` to mark pending sections.
1710<!-- agent:real -->
1711content
1712<!-- /agent:real -->
1713";
1714        let components = parse(doc).unwrap();
1715        assert_eq!(components.len(), 1);
1716        assert_eq!(components[0].name, "real");
1717    }
1718
1719    #[test]
1720    fn double_backtick_component_tag_ignored() {
1721        // A component tag wrapped in double backticks should not be parsed
1722        let doc = "\
1723Use ``<!-- agent:pending -->`` to mark pending sections.
1724<!-- agent:real -->
1725content
1726<!-- /agent:real -->
1727";
1728        let components = parse(doc).unwrap();
1729        assert_eq!(components.len(), 1);
1730        assert_eq!(components[0].name, "real");
1731    }
1732
1733    #[test]
1734    fn component_tags_not_in_backticks_still_work() {
1735        // Tags outside of any backticks are parsed normally
1736        let doc = "\
1737<!-- agent:a -->
1738alpha
1739<!-- /agent:a -->
1740<!-- agent:b patch=append -->
1741beta
1742<!-- /agent:b -->
1743";
1744        let components = parse(doc).unwrap();
1745        assert_eq!(components.len(), 2);
1746        assert_eq!(components[0].name, "a");
1747        assert_eq!(components[1].name, "b");
1748        assert_eq!(components[1].patch_mode(), Some("append"));
1749    }
1750
1751    #[test]
1752    fn mixed_backtick_and_real_tags() {
1753        // Some tags in backticks (ignored), some not (parsed)
1754        let doc = "\
1755Here is an example: `<!-- agent:fake -->` and ``<!-- /agent:fake -->``.
1756<!-- agent:real -->
1757real content
1758<!-- /agent:real -->
1759Another example: `<!-- agent:also-fake patch=replace -->` is just documentation.
1760";
1761        let components = parse(doc).unwrap();
1762        assert_eq!(components.len(), 1);
1763        assert_eq!(components[0].name, "real");
1764        assert_eq!(components[0].content(doc), "real content\n");
1765    }
1766
1767    #[test]
1768    fn inline_code_mid_line_with_surrounding_text_ignored() {
1769        // Edge case: component tag inside inline code span on a line with other content
1770        // before and after — must not be parsed as a real component marker.
1771        let doc = "\
1772Wrap markers like `<!-- agent:status -->` in backticks to show them literally.
1773<!-- agent:real -->
1774actual content
1775<!-- /agent:real -->
1776";
1777        let components = parse(doc).unwrap();
1778        assert_eq!(components.len(), 1);
1779        assert_eq!(components[0].name, "real");
1780        assert_eq!(components[0].content(doc), "actual content\n");
1781    }
1782
1783    #[test]
1784    fn parse_attrs_unit() {
1785        let attrs = parse_attrs("mode=append");
1786        assert_eq!(attrs.get("mode").map(|s| s.as_str()), Some("append"));
1787
1788        let attrs = parse_attrs("mode=replace timestamp=true");
1789        assert_eq!(attrs.len(), 2);
1790
1791        let attrs = parse_attrs("");
1792        assert!(attrs.is_empty());
1793
1794        // Bare tokens (no =) are parsed as boolean flags with empty string values
1795        let attrs = parse_attrs("mode=append broken novalue=");
1796        assert_eq!(attrs.len(), 2);
1797        assert_eq!(attrs.get("mode").map(|s| s.as_str()), Some("append"));
1798        assert_eq!(attrs.get("broken").map(|s| s.as_str()), Some(""));
1799
1800        // auto flag (used by agent:queue)
1801        let attrs = parse_attrs("auto");
1802        assert_eq!(attrs.len(), 1);
1803        assert!(attrs.contains_key("auto"));
1804    }
1805
1806    #[test]
1807    fn append_with_boundary_skips_code_block() {
1808        // Boundary marker inside a code block should be ignored;
1809        // the real marker outside should be used.
1810        let boundary_id = "real-uuid";
1811        let doc = format!(
1812            "<!-- agent:exchange patch=append -->\n\
1813             user prompt\n\
1814             ```\n\
1815             <!-- agent:boundary:{boundary_id} -->\n\
1816             ```\n\
1817             more user text\n\
1818             <!-- agent:boundary:{boundary_id} -->\n\
1819             <!-- /agent:exchange -->\n"
1820        );
1821        let components = parse(&doc).unwrap();
1822        let comp = &components[0];
1823        let result =
1824            comp.append_with_boundary(&doc, "### Re: Response\n\nContent here.", boundary_id);
1825
1826        // Response should replace the REAL marker (outside code block),
1827        // not the one inside the code block.
1828        assert!(result.contains("### Re: Response"));
1829        assert!(result.contains("more user text"));
1830        // The code block example should be preserved
1831        assert!(result.contains(&format!("<!-- agent:boundary:{boundary_id} -->\n```")));
1832        // The real marker should be consumed (replaced by response)
1833        assert!(!result.contains(&format!(
1834            "more user text\n<!-- agent:boundary:{boundary_id} -->\n<!-- /agent:exchange -->"
1835        )));
1836    }
1837
1838    #[test]
1839    fn append_with_boundary_no_code_block() {
1840        // Normal case: boundary marker not in a code block
1841        let boundary_id = "simple-uuid";
1842        let doc = format!(
1843            "<!-- agent:exchange patch=append -->\n\
1844             user prompt\n\
1845             <!-- agent:boundary:{boundary_id} -->\n\
1846             <!-- /agent:exchange -->\n"
1847        );
1848        let components = parse(&doc).unwrap();
1849        let comp = &components[0];
1850        let result = comp.append_with_boundary(&doc, "### Re: Answer\n\nDone.", boundary_id);
1851
1852        assert!(result.contains("### Re: Answer"));
1853        assert!(result.contains("user prompt"));
1854        // Original marker should be consumed, but a NEW boundary re-inserted
1855        assert!(!result.contains(&format!("agent:boundary:{boundary_id}")));
1856        assert!(result.contains("agent:boundary:"));
1857    }
1858
1859    #[test]
1860    fn append_with_boundary_skips_already_present_content() {
1861        let boundary_id = "simple-uuid";
1862        let doc = format!(
1863            "<!-- agent:exchange patch=append -->\n\
1864             ### Re: Duplicate — gpt-5 (HEAD)\n\
1865             \n\
1866             Already applied.\n\
1867             <!-- agent:boundary:old-id -->\n\
1868             <!-- agent:boundary:{boundary_id} -->\n\
1869             <!-- /agent:exchange -->\n"
1870        );
1871        let components = parse(&doc).unwrap();
1872        let comp = &components[0];
1873        let result = comp.append_with_boundary(
1874            &doc,
1875            "### Re: Duplicate — gpt-5\n\nAlready applied.\n",
1876            boundary_id,
1877        );
1878
1879        assert_eq!(result, doc);
1880    }
1881
1882    #[test]
1883    fn append_with_caret_skips_already_present_content() {
1884        let doc = "<!-- agent:exchange patch=append -->\n\
1885                   User prompt.\n\
1886                   ### Re: Duplicate — gpt-5\n\
1887                   \n\
1888                   Already applied.\n\
1889                   <!-- /agent:exchange -->\n";
1890        let components = parse(doc).unwrap();
1891        let comp = &components[0];
1892        let result =
1893            comp.append_with_caret(doc, "### Re: Duplicate — gpt-5\n\nAlready applied.\n", None);
1894
1895        assert_eq!(result, doc);
1896    }
1897
1898    // --- strip_comments tests (moved from diff.rs) ---
1899
1900    #[test]
1901    fn strip_comments_removes_html_comment() {
1902        let result = strip_comments("before\n<!-- a comment -->\nafter\n");
1903        assert_eq!(result, "before\nafter\n");
1904    }
1905
1906    #[test]
1907    fn non_agent_html_comment_ranges_cover_multiline_body() {
1908        let doc = concat!(
1909            "before\n",
1910            "<!--\n",
1911            "do #hidden. spec-test-build-install-commit-push\n",
1912            "-->\n",
1913            "<!-- agent:exchange -->\n",
1914            "<!-- /agent:exchange -->\n"
1915        );
1916
1917        let ranges = find_non_agent_html_comment_ranges(doc);
1918        assert_eq!(ranges.len(), 1);
1919        let hidden = doc.find("do #hidden").unwrap();
1920        assert!(
1921            ranges
1922                .iter()
1923                .any(|&(start, end)| hidden >= start && hidden < end),
1924            "ordinary comment body should be inside a non-agent comment range"
1925        );
1926        assert!(
1927            ranges
1928                .iter()
1929                .all(|&(start, end)| !doc[start..end].contains("agent:exchange")),
1930            "agent component markers must not be treated as ordinary comments"
1931        );
1932    }
1933
1934    #[test]
1935    fn non_agent_html_comment_ranges_cover_unterminated_tail() {
1936        let doc = concat!(
1937            "before\n",
1938            "<!--\n",
1939            "do #hidden. spec-test-build-install-commit-push\n",
1940            "still typing\n"
1941        );
1942
1943        let ranges = find_non_agent_html_comment_ranges(doc);
1944        assert_eq!(ranges.len(), 1);
1945        let (start, end) = ranges[0];
1946        assert_eq!(
1947            &doc[start..end],
1948            "<!--\ndo #hidden. spec-test-build-install-commit-push\nstill typing\n"
1949        );
1950        assert_eq!(end, doc.len());
1951    }
1952
1953    #[test]
1954    fn strip_comments_preserves_agent_markers() {
1955        let input = "text\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
1956        let result = strip_comments(input);
1957        assert!(result.contains("<!-- agent:status -->"));
1958        assert!(result.contains("<!-- /agent:status -->"));
1959    }
1960
1961    #[test]
1962    fn strip_comments_removes_link_ref() {
1963        let result = strip_comments("[//]: # (hidden note)\nvisible\n");
1964        assert_eq!(result, "visible\n");
1965    }
1966
1967    #[test]
1968    fn html_comment_in_content_does_not_eat_close_marker() {
1969        let doc = "\
1970<!-- agent:pending -->
1971- [ ] [#abc] Rule: first line (not starting with ### or ❯ or <!-- ) gets prefix
1972<!-- /agent:pending -->
1973";
1974        let comps = parse(doc).unwrap();
1975        assert_eq!(comps.len(), 1);
1976        assert_eq!(comps[0].name, "pending");
1977        assert!(comps[0].content(doc).contains("<!-- )"));
1978    }
1979
1980    #[test]
1981    fn nested_html_comment_like_text_in_exchange() {
1982        let doc = "\
1983<!-- agent:exchange -->
1984User typed <!-- some note --> in the text.
1985<!-- /agent:exchange -->
1986";
1987        let comps = parse(doc).unwrap();
1988        assert_eq!(comps.len(), 1);
1989        assert_eq!(comps[0].name, "exchange");
1990    }
1991
1992    #[test]
1993    fn pending_item_with_literal_html_comment_opener() {
1994        // Regression: a pending item containing `<!-- ` (literal HTML comment start)
1995        // must not consume the `<!-- /agent:pending -->` close marker.
1996        let doc = "\
1997<!-- agent:pending -->
1998- [ ] [#r7hw] Component parser: non-agent `<!-- ` in content ate close markers
1999- [ ] [#xyz] Another item
2000<!-- /agent:pending -->
2001";
2002        let comps = parse(doc).unwrap();
2003        assert_eq!(comps.len(), 1);
2004        assert_eq!(comps[0].name, "pending");
2005        let content = comps[0].content(doc);
2006        assert!(content.contains("#r7hw"));
2007        assert!(content.contains("#xyz"));
2008    }
2009
2010    #[test]
2011    fn multiple_non_agent_html_comments_in_pending() {
2012        // Multiple `<!-- ... -->` fragments that are NOT agent markers.
2013        let doc = "\
2014<!-- agent:pending -->
2015- [ ] [#a] Fix rule: skip lines starting with <!-- or -->
2016- [ ] [#b] Handle <!-- partial comment
2017- [ ] [#c] Normal item
2018<!-- /agent:pending -->
2019";
2020        let comps = parse(doc).unwrap();
2021        assert_eq!(comps.len(), 1);
2022        assert_eq!(comps[0].name, "pending");
2023        let content = comps[0].content(doc);
2024        assert!(content.contains("#a"));
2025        assert!(content.contains("#b"));
2026        assert!(content.contains("#c"));
2027    }
2028
2029    #[test]
2030    fn exchange_and_pending_both_with_html_comments_in_content() {
2031        // Both components contain non-agent HTML comments — parser must handle
2032        // siblings correctly without eating across component boundaries.
2033        let doc = "\
2034<!-- agent:exchange -->
2035The rule checks for <!-- prefix before deciding.
2036### Re: topic — opus
2037Fix applied to skip non-agent <!-- sequences.
2038<!-- /agent:exchange -->
2039
2040<!-- agent:pending -->
2041- [ ] [#cfdy] Verify <!-- in pending items
2042<!-- /agent:pending -->
2043";
2044        let comps = parse(doc).unwrap();
2045        assert_eq!(comps.len(), 2);
2046        assert_eq!(comps[0].name, "exchange");
2047        assert_eq!(comps[1].name, "pending");
2048        assert!(comps[0].content(doc).contains("<!-- prefix"));
2049        assert!(comps[1].content(doc).contains("#cfdy"));
2050    }
2051
2052    #[test]
2053    fn is_backlog_component_accepts_both_names() {
2054        assert!(is_backlog_component("backlog"));
2055        assert!(is_backlog_component("pending"));
2056        assert!(!is_backlog_component("exchange"));
2057        assert!(!is_backlog_component("status"));
2058        assert!(!is_backlog_component("pending-done"));
2059        assert!(!is_backlog_component("backlog-done"));
2060        assert!(!is_backlog_component("done"));
2061    }
2062
2063    #[test]
2064    fn is_backlog_done_component_accepts_only_canonical_done() {
2065        assert!(is_backlog_done_component("done"));
2066        assert!(!is_backlog_done_component("backlog-done"));
2067        assert!(!is_backlog_done_component("pending-done"));
2068        assert!(!is_backlog_done_component("backlog"));
2069        assert!(!is_backlog_done_component("pending"));
2070        assert!(!is_backlog_done_component("icebox"));
2071    }
2072
2073    #[test]
2074    fn is_review_component_accepts_name() {
2075        assert!(is_review_component("review"));
2076        assert!(!is_review_component("backlog"));
2077        assert!(!is_review_component("icebox"));
2078    }
2079
2080    #[test]
2081    fn is_icebox_component_accepts_name() {
2082        assert!(is_icebox_component("icebox"));
2083        assert!(!is_icebox_component("backlog"));
2084        assert!(!is_icebox_component("exchange"));
2085        assert!(!is_icebox_component("icebox-archive"));
2086    }
2087
2088    #[test]
2089    fn tracked_work_component_includes_review() {
2090        assert!(is_tracked_work_component("backlog"));
2091        assert!(is_tracked_work_component("pending"));
2092        assert!(is_tracked_work_component("review"));
2093        assert!(is_tracked_work_component("icebox"));
2094        assert!(!is_tracked_work_component("done"));
2095    }
2096
2097    #[test]
2098    fn icebox_component_parsed() {
2099        let doc = "\
2100<!-- agent:icebox -->
2101- Parked idea
2102<!-- /agent:icebox -->
2103";
2104        let comps = parse(doc).unwrap();
2105        assert_eq!(comps.len(), 1);
2106        assert_eq!(comps[0].name, "icebox");
2107        assert!(is_icebox_component(&comps[0].name));
2108        assert!(comps[0].content(doc).contains("Parked idea"));
2109    }
2110
2111    #[test]
2112    fn backlog_component_parsed_from_new_marker() {
2113        let doc = "\
2114<!-- agent:backlog -->
2115- [ ] [#abc] First item
2116<!-- /agent:backlog -->
2117";
2118        let comps = parse(doc).unwrap();
2119        assert_eq!(comps.len(), 1);
2120        assert_eq!(comps[0].name, "backlog");
2121        assert!(is_backlog_component(&comps[0].name));
2122        assert!(comps[0].content(doc).contains("#abc"));
2123    }
2124
2125    #[test]
2126    fn legacy_pending_marker_still_parsed() {
2127        let doc = "\
2128<!-- agent:pending -->
2129- [ ] [#xyz] Legacy item
2130<!-- /agent:pending -->
2131";
2132        let comps = parse(doc).unwrap();
2133        assert_eq!(comps.len(), 1);
2134        assert_eq!(comps[0].name, "pending");
2135        assert!(is_backlog_component(&comps[0].name));
2136    }
2137
2138    #[test]
2139    fn strip_backlog_patch_attr_removes_patch_replace() {
2140        let doc = "<!-- agent:backlog patch=replace -->\n- item\n<!-- /agent:backlog -->\n";
2141        let result = strip_backlog_patch_attr(doc);
2142        assert_eq!(
2143            result,
2144            "<!-- agent:backlog -->\n- item\n<!-- /agent:backlog -->\n"
2145        );
2146    }
2147
2148    #[test]
2149    fn strip_backlog_patch_attr_removes_pending_patch_replace() {
2150        let doc = "<!-- agent:pending patch=replace -->\n- item\n<!-- /agent:pending -->\n";
2151        let result = strip_backlog_patch_attr(doc);
2152        assert_eq!(
2153            result,
2154            "<!-- agent:pending -->\n- item\n<!-- /agent:pending -->\n"
2155        );
2156    }
2157
2158    #[test]
2159    fn strip_backlog_patch_attr_removes_mode_replace() {
2160        let doc = "<!-- agent:backlog mode=replace -->\n- item\n<!-- /agent:backlog -->\n";
2161        let result = strip_backlog_patch_attr(doc);
2162        assert_eq!(
2163            result,
2164            "<!-- agent:backlog -->\n- item\n<!-- /agent:backlog -->\n"
2165        );
2166    }
2167
2168    #[test]
2169    fn strip_backlog_patch_attr_preserves_other_attrs() {
2170        let doc =
2171            "<!-- agent:backlog patch=replace max_lines=50 -->\n- item\n<!-- /agent:backlog -->\n";
2172        let result = strip_backlog_patch_attr(doc);
2173        assert_eq!(
2174            result,
2175            "<!-- agent:backlog max_lines=50 -->\n- item\n<!-- /agent:backlog -->\n"
2176        );
2177    }
2178
2179    #[test]
2180    fn strip_backlog_patch_attr_noop_for_exchange() {
2181        let doc = "<!-- agent:exchange patch=append -->\ncontent\n<!-- /agent:exchange -->\n";
2182        let result = strip_backlog_patch_attr(doc);
2183        assert_eq!(result, doc);
2184    }
2185
2186    #[test]
2187    fn strip_backlog_patch_attr_noop_when_no_attr() {
2188        let doc = "<!-- agent:backlog -->\n- item\n<!-- /agent:backlog -->\n";
2189        let result = strip_backlog_patch_attr(doc);
2190        assert_eq!(result, doc);
2191    }
2192
2193    #[test]
2194    fn converge_queue_auto_strips_auto() {
2195        let doc = "<!-- agent:queue auto -->\n- do [#x]\n<!-- /agent:queue -->\n";
2196        let result = converge_queue_auto(doc, false).expect("tag changed");
2197        assert_eq!(
2198            result,
2199            "<!-- agent:queue -->\n- do [#x]\n<!-- /agent:queue -->\n"
2200        );
2201    }
2202
2203    #[test]
2204    fn converge_queue_auto_adds_auto() {
2205        let doc = "<!-- agent:queue -->\n- do [#x]\n<!-- /agent:queue -->\n";
2206        let result = converge_queue_auto(doc, true).expect("tag changed");
2207        assert_eq!(
2208            result,
2209            "<!-- agent:queue auto -->\n- do [#x]\n<!-- /agent:queue -->\n"
2210        );
2211    }
2212
2213    #[test]
2214    fn converge_queue_auto_preserves_other_attrs() {
2215        let doc = "<!-- agent:queue auto patch=append -->\n- do [#x]\n<!-- /agent:queue -->\n";
2216        let result = converge_queue_auto(doc, false).expect("tag changed");
2217        assert_eq!(
2218            result,
2219            "<!-- agent:queue patch=append -->\n- do [#x]\n<!-- /agent:queue -->\n"
2220        );
2221    }
2222
2223    #[test]
2224    fn converge_queue_auto_normalizes_boolean_attrs_without_corrupting_preset() {
2225        let doc = concat!(
2226            "<!-- agent:queue auto=true priority=true preset=\"#spec-test-build-install-commit-push\"=true go=true -->\n",
2227            "- do [#x]\n",
2228            "<!-- /agent:queue -->\n",
2229        );
2230        let result = converge_queue_auto(doc, false).expect("tag normalized");
2231        assert_eq!(
2232            result,
2233            concat!(
2234                "<!-- agent:queue priority preset=\"#spec-test-build-install-commit-push\" go -->\n",
2235                "- do [#x]\n",
2236                "<!-- /agent:queue -->\n",
2237            )
2238        );
2239    }
2240
2241    #[test]
2242    fn converge_queue_auto_noop_when_already_matching() {
2243        let active = "<!-- agent:queue auto -->\n- do [#x]\n<!-- /agent:queue -->\n";
2244        assert_eq!(converge_queue_auto(active, true), None);
2245        let inactive = "<!-- agent:queue -->\n- do [#x]\n<!-- /agent:queue -->\n";
2246        assert_eq!(converge_queue_auto(inactive, false), None);
2247    }
2248
2249    #[test]
2250    fn converge_queue_auto_none_without_queue_component() {
2251        let doc = "<!-- agent:exchange -->\nhi\n<!-- /agent:exchange -->\n";
2252        assert_eq!(converge_queue_auto(doc, false), None);
2253    }
2254
2255    // #prompt-duplicated-while-typing: dedup must be agnostic to the `❯ ` user-prompt
2256    // prefix, since a synthesized exchange patch and the live buffer can differ only
2257    // by it. Otherwise the prompt re-appends and duplicates while typing.
2258    #[test]
2259    fn append_patch_already_present_ignores_user_prompt_prefix() {
2260        assert!(
2261            append_patch_already_present("expand the section", "❯ expand the section"),
2262            "bare buffer vs prefixed patch must dedup"
2263        );
2264        assert!(
2265            append_patch_already_present("❯ expand the section", "expand the section"),
2266            "prefixed buffer vs bare patch must dedup"
2267        );
2268        assert!(
2269            append_patch_already_present("❯ expand the section", "❯ expand the section"),
2270            "both prefixed must dedup"
2271        );
2272    }
2273
2274    #[test]
2275    fn append_patch_distinct_prompts_not_deduped() {
2276        assert!(
2277            !append_patch_already_present("❯ expand the section", "❯ summarize the section"),
2278            "genuinely distinct prompts must not be treated as duplicates"
2279        );
2280    }
2281
2282    // #prompt-duplicated-while-typing (L1 structural buffer-snapshot race),
2283    // captured live in tasks/resume.md 2026-05-29: the synthesized patch held a
2284    // half-typed snapshot ("- [#s93y]: Add to") while the live buffer had been
2285    // typed further ("- [#s93y]: Add to resume"). The two regions are identical
2286    // except that one line; the old `contains` check broke at the divergence and
2287    // re-appended the entire tail. Dedup must recognize the snapshot relationship.
2288    #[test]
2289    fn append_patch_dedupes_midblock_typing_snapshot() {
2290        let live = concat!(
2291            "- [#yfg1]: I dropped the real-time asset-generation. Kept the investor demos.\n",
2292            "- [#bndq]: confirmed\n",
2293            "- do [#s75b]\n",
2294            "- do [#vapk]\n",
2295            "- [#s93y]: Add to resume\n",
2296            "- [#ca8w]\n",
2297            "Add SampleOrders and sample portal to the resume if not already added.",
2298        );
2299        let snapshot = concat!(
2300            "- [#yfg1]: I dropped the real-time asset-generation. Kept the investor demos.\n",
2301            "- [#bndq]: confirmed\n",
2302            "- do [#s75b]\n",
2303            "- do [#vapk]\n",
2304            "- [#s93y]: Add to\n",
2305            "- [#ca8w]\n",
2306            "Add SampleOrders and sample portal to the resume if not already added.",
2307        );
2308        assert!(
2309            append_patch_already_present(live, snapshot),
2310            "earlier keystroke snapshot must dedup against the live, further-typed region"
2311        );
2312    }
2313
2314    #[test]
2315    fn append_patch_typing_snapshot_does_not_collapse_distinct_lines() {
2316        // A genuinely distinct prompt block (not a prefix snapshot) must still
2317        // append — the divergent line is not a prefix of the live line.
2318        let live = "- do task one\n- and the second thing\n- finally";
2319        let distinct = "- do task one\n- and a different thing\n- finally";
2320        assert!(
2321            !append_patch_already_present(live, distinct),
2322            "a mid-line replacement (not a prefix) is a distinct edit, not a snapshot"
2323        );
2324    }
2325
2326    #[test]
2327    fn append_with_boundary_does_not_duplicate_typing_snapshot() {
2328        // The boundary stays high in the exchange; the live region below it has
2329        // been typed past the synthesized patch's snapshot. Appending the stale
2330        // snapshot must be a full no-op (document unchanged), not a duplication.
2331        let doc = concat!(
2332            "<!-- agent:exchange patch=append -->\n",
2333            "### Re: prior — opus-4-8\n\n",
2334            "Answer.\n",
2335            "<!-- agent:boundary:70bccf9b -->\n",
2336            "- [#yfg1]: ok\n",
2337            "- [#s93y]: Add to resume\n",
2338            "- [#ca8w]\n",
2339            "Add SampleOrders and sample portal to the resume if not already added.\n",
2340            "<!-- /agent:exchange -->\n",
2341        );
2342        let components = parse(doc).unwrap();
2343        let exchange = components.iter().find(|c| c.name == "exchange").unwrap();
2344        let snapshot = concat!(
2345            "- [#yfg1]: ok\n",
2346            "- [#s93y]: Add to\n",
2347            "- [#ca8w]\n",
2348            "Add SampleOrders and sample portal to the resume if not already added.",
2349        );
2350        let result = exchange.append_with_boundary(doc, snapshot, "70bccf9b");
2351        assert_eq!(
2352            result, doc,
2353            "stale typing-snapshot append must be a no-op:\n{result}"
2354        );
2355        assert_eq!(
2356            result.matches("<!-- /agent:exchange -->").count(),
2357            1,
2358            "exchange close marker must not duplicate:\n{result}"
2359        );
2360    }
2361
2362    #[test]
2363    fn append_with_caret_does_not_duplicate_prefixed_prompt() {
2364        // The live buffer already holds the bare typed prompt after the boundary; a
2365        // synthesized exchange patch carries the same prompt with the `❯ ` prefix.
2366        // Appending must be a no-op (single copy), not a duplicate.
2367        let doc = concat!(
2368            "<!-- agent:exchange patch=append -->\n",
2369            "### Re: prior — opus-4-8\n\n",
2370            "Answer.\n",
2371            "<!-- agent:boundary:abc12345 -->\n",
2372            "expand the Confidential AI Startup section\n",
2373            "<!-- /agent:exchange -->\n",
2374        );
2375        let components = parse(doc).unwrap();
2376        let exchange = components.iter().find(|c| c.name == "exchange").unwrap();
2377        let result =
2378            exchange.append_with_caret(doc, "❯ expand the Confidential AI Startup section", None);
2379        assert_eq!(
2380            result
2381                .matches("expand the Confidential AI Startup section")
2382                .count(),
2383            1,
2384            "prefixed synthesized patch must not duplicate the already-typed prompt:\n{result}"
2385        );
2386    }
2387
2388    // --- #dupcontent: structural_corruption_reason ---
2389
2390    const CLEAN_DOC: &str = "<!-- agent:status -->\nok\n<!-- /agent:status -->\n\
2391<!-- agent:exchange -->\nq\n<!-- /agent:exchange -->\n\
2392<!-- agent:queue preset=\"#spec-test-commit-push\" priority go -->\n- do [#x]\n<!-- /agent:queue -->\n\
2393<!-- agent:backlog -->\n- [ ] [#y] thing\n<!-- /agent:backlog -->\n";
2394
2395    #[test]
2396    fn structural_corruption_clean_doc_is_sound() {
2397        assert_eq!(structural_corruption_reason(CLEAN_DOC), None);
2398    }
2399
2400    #[test]
2401    fn structural_corruption_flags_duplicate_singleton_queue() {
2402        // The live #dupcontent corruption: two agent:queue blocks ingested from
2403        // a bad content_ours CRDT merge.
2404        let doc = "<!-- agent:queue -->\n- a\n<!-- /agent:queue -->\n\
2405<!-- agent:exchange -->\nq\n<!-- /agent:exchange -->\n\
2406<!-- agent:queue preset=\"#spec-test-commit-push\" priority go -->\n- b\n<!-- /agent:queue -->\n";
2407        let reason = structural_corruption_reason(doc).expect("two queue blocks must be flagged");
2408        assert!(
2409            reason.contains("duplicate_singleton_component") && reason.contains("queue=2"),
2410            "reason was: {reason}"
2411        );
2412    }
2413
2414    #[test]
2415    fn structural_corruption_flags_backlog_alias_duplicate() {
2416        // Legacy `pending` alias + canonical `backlog` collapse to one singleton.
2417        let doc = "<!-- agent:backlog -->\n- [ ] [#a] x\n<!-- /agent:backlog -->\n\
2418<!-- agent:pending -->\n- [ ] [#b] y\n<!-- /agent:pending -->\n";
2419        let reason = structural_corruption_reason(doc).expect("backlog+pending must be flagged");
2420        assert!(reason.contains("backlog=2"), "reason was: {reason}");
2421    }
2422
2423    #[test]
2424    fn structural_corruption_flags_unterminated_quote_attr() {
2425        // The truncated merge-boundary marker `<!-- agent:queue tasks=" -->`.
2426        let doc = "<!-- agent:exchange -->\nq\n<!-- /agent:exchange -->\n\
2427<!-- agent:queue tasks=\" -->\n- a\n<!-- /agent:queue -->\n";
2428        let reason =
2429            structural_corruption_reason(doc).expect("unterminated quote attr must be flagged");
2430        assert!(
2431            reason.starts_with("malformed_attr:queue"),
2432            "reason was: {reason}"
2433        );
2434    }
2435
2436    #[test]
2437    fn structural_corruption_flags_truncated_agent_comment() {
2438        let doc = "<!-- agent:queue -->\n- a\n<!-- /agent:queue ->\n\
2439<!-- /agent:exchange --\n";
2440        let reason =
2441            structural_corruption_reason(doc).expect("truncated agent marker must be flagged");
2442        assert!(
2443            reason.starts_with("malformed_agent_comment:"),
2444            "reason was: {reason}"
2445        );
2446    }
2447
2448    #[test]
2449    fn structural_corruption_ignores_truncated_agent_comment_inside_code_fence() {
2450        let doc = "```md\n<!-- /agent:queue ->\n<!-- /agent:exchange --\n```\n";
2451        assert_eq!(structural_corruption_reason(doc), None);
2452    }
2453
2454    #[test]
2455    fn structural_corruption_ignores_truncated_agent_comment_inside_quotes() {
2456        let doc = "\"<!-- /agent:queue ->\"\n";
2457        assert_eq!(structural_corruption_reason(doc), None);
2458    }
2459
2460    #[test]
2461    fn structural_corruption_allows_balanced_quoted_attr() {
2462        // A real queue marker carrying a balanced quoted attribute is sound.
2463        let doc = "<!-- agent:queue preset=\"#a\" go -->\n- a\n<!-- /agent:queue -->\n";
2464        assert_eq!(structural_corruption_reason(doc), None);
2465    }
2466
2467    #[test]
2468    fn structural_corruption_allows_repeated_non_singleton() {
2469        // `log` is not a singleton; repeats must NOT be flagged.
2470        let doc = "<!-- agent:log -->\na\n<!-- /agent:log -->\n\
2471<!-- agent:log -->\nb\n<!-- /agent:log -->\n";
2472        assert_eq!(structural_corruption_reason(doc), None);
2473    }
2474
2475    #[test]
2476    fn structural_corruption_flags_parse_failure() {
2477        // Unclosed marker → parse error → corrupt.
2478        let doc = "<!-- agent:queue -->\n- a\n";
2479        let reason = structural_corruption_reason(doc).expect("unclosed marker must be flagged");
2480        assert!(reason.starts_with("parse_error"), "reason was: {reason}");
2481    }
2482}