Skip to main content

agent_doc_core/
component.rs

1//! # Module: component
2//!
3//! ## Spec
4//! - Defines `Component`, the parsed representation of a bounded document region delimited by
5//!   `<!-- agent:name [attrs...] -->` (open) and `<!-- /agent:name -->` (close) HTML comments.
6//! - `parse(doc)` scans the raw document bytes for `<!--` / `-->` comment pairs, builds a
7//!   stack-based nesting model, and returns all `Component` values sorted by `open_start`.
8//! - Markers that appear inside fenced code blocks (backtick or tilde) or inline code spans are
9//!   skipped; `find_code_ranges(doc)` uses the `pulldown-cmark` AST (CommonMark-compliant) to
10//!   locate these regions.
11//! - `is_agent_marker(comment_text)` classifies whether the inner text of a comment is an
12//!   agent open/close marker vs. an ordinary HTML comment.
13//! - Component names must match `[a-zA-Z0-9][a-zA-Z0-9-]*`; invalid names, unmatched opens,
14//!   unmatched closes, and mismatched open/close pairs all return `Err`.
15//! - `boundary:*` prefixed markers (`<!-- agent:boundary:ID -->`) are recognised and skipped
16//!   during component parsing; they are not treated as component open tags.
17//! - Opening markers may carry space-separated `key=value` inline attributes
18//!   (e.g., `patch=append`). `patch=` takes precedence over the legacy `mode=` alias;
19//!   `patch_mode()` encapsulates this lookup.
20//! - The non-agent comment fast path only previews comment text on UTF-8 char boundaries, so
21//!   ordinary prose comments near multibyte glyphs never panic the parser.
22//! - Marker `open_end` / `close_end` byte offsets include a trailing newline when present,
23//!   so that content slices are clean.
24//! - `replace_content(doc, new_content)` rebuilds the document preserving both markers,
25//!   replacing only the bytes between them.
26//! - `append_with_caret(doc, content, caret_offset)` appends content after existing text, or
27//!   inserts before the caret line when the caret falls inside the component.
28//! - `append_with_boundary(doc, content, boundary_id)` locates `<!-- agent:boundary:ID -->`
29//!   inside the component (skipping any occurrence that lives inside a code block), replaces it
30//!   with the new content, and re-inserts a fresh boundary marker; falls back to
31//!   `append_with_caret` when no boundary is found.
32//!
33//! ## Agentic Contracts
34//! - `parse` is pure and deterministic: identical input always yields identical output.
35//! - All byte offsets (`open_start`, `open_end`, `close_start`, `close_end`) are valid UTF-8
36//!   char boundaries within the document string they were parsed from.
37//! - `content(doc)` returns exactly `&doc[open_end..close_start]` — no allocation.
38//! - `replace_content` and `append_with_*` never mutate the original `&str`; they return a
39//!   new `String` with all offsets consistent for a fresh `parse` call.
40//! - Markers inside any code region (fenced block or inline span) are never parsed as
41//!   components and never mutated by any append/replace operation.
42//! - `append_with_boundary` always re-inserts a boundary marker with a fresh UUID, preserving
43//!   the invariant that exactly one boundary exists inside the component after each write.
44//! - Unknown or malformed `key=value` tokens in inline attributes are silently discarded;
45//!   they never cause a parse error.
46//!
47//! ## Evals
48//! - single_range: doc with one component → one `Component`, correct name and content slice
49//! - nested_ranges: outer + inner components → two entries sorted outer-first
50//! - siblings: two adjacent components → two entries, each with correct content
51//! - no_ranges: plain markdown → empty vec, no error
52//! - unmatched_open_error: open without close → `Err` containing "unclosed component"
53//! - unmatched_close_error: close without open → `Err` containing "without matching open"
54//! - mismatched_names_error: `<!-- agent:foo -->…<!-- /agent:bar -->` → `Err` "mismatched"
55//! - invalid_name: name starting with `-` → `Err` "invalid component name"
56//! - markers_in_fenced_code_block_ignored: marker inside ``` block → not parsed as component
57//! - markers_in_inline_code_ignored: marker inside `` `…` `` span → not parsed
58//! - markers_in_tilde_fence_ignored: marker inside ~~~ block → not parsed
59//! - markers_in_indented_fenced_code_block_ignored: up-to-3-space-indented fence → not parsed
60//! - double_backtick_comment_before_agent_marker: `` `<!--` `` followed by real marker → one component
61//! - parse_component_with_patch_attr: `patch=append` on opening tag → `patch_mode()` = "append"
62//! - patch_attr_takes_precedence_over_mode: both `patch=` and `mode=` present → `patch=` wins
63//! - mode_attr_backward_compat: only `mode=append` present → `patch_mode()` = "append"
64//! - replace_roundtrip: replace content, re-parse → one component with new content
65//! - append_with_boundary_no_code_block: boundary found → content inserted, old ID consumed, new boundary present
66//! - append_with_boundary_skips_code_block: boundary inside code block skipped, real boundary used
67
68use anyhow::{Result, bail};
69use pulldown_cmark::{Event, Options, Parser, Tag, TagEnd};
70use std::collections::HashMap;
71
72/// Canonical component name for the backlog/pending component.
73pub const BACKLOG_COMPONENT: &str = "backlog";
74
75/// Legacy alias for the backlog component.
76pub const BACKLOG_ALIAS: &str = "pending";
77
78/// Canonical component name for the completed/reaped backlog archive.
79pub const BACKLOG_DONE_COMPONENT: &str = "done";
80
81/// Canonical component name for tracked work awaiting human review.
82pub const REVIEW_COMPONENT: &str = "review";
83
84/// Canonical component name for the icebox component.
85pub const ICEBOX_COMPONENT: &str = "icebox";
86
87/// Canonical component name for the conversation exchange.
88const EXCHANGE_COMPONENT: &str = "exchange";
89
90/// Check whether a component name refers to the backlog component
91/// (accepts both canonical `"backlog"` and legacy `"pending"`).
92pub fn is_backlog_component(name: &str) -> bool {
93    name == BACKLOG_COMPONENT || name == BACKLOG_ALIAS
94}
95
96/// Check whether a component name refers to the completed/reaped backlog archive.
97pub fn is_backlog_done_component(name: &str) -> bool {
98    name == BACKLOG_DONE_COMPONENT
99}
100
101/// Check whether a component name refers to the review component.
102pub fn is_review_component(name: &str) -> bool {
103    name == REVIEW_COMPONENT
104}
105
106/// Check whether a component name refers to the icebox component.
107pub fn is_icebox_component(name: &str) -> bool {
108    name == ICEBOX_COMPONENT
109}
110
111/// Check whether a component name refers to tracked work that participates in
112/// `do #id` closeout (`agent:backlog` / legacy `agent:pending`,
113/// `agent:review`, and `agent:icebox`).
114pub fn is_tracked_work_component(name: &str) -> bool {
115    is_backlog_component(name) || is_review_component(name) || is_icebox_component(name)
116}
117
118/// Strip deprecated `patch=...` (and legacy `mode=...`) attributes from
119/// backlog/pending component opening tags. The backlog component's patch mode
120/// is always `replace` by built-in default; the inline attribute is redundant
121/// since the binary owns all backlog mutations via `--pending-*` flags.
122///
123/// Emits a deprecation warning to stderr for each stripped attribute.
124pub fn strip_backlog_patch_attr(doc: &str) -> String {
125    let mut result = doc.to_string();
126    let mut warned = false;
127
128    for name in &[BACKLOG_COMPONENT, BACKLOG_ALIAS] {
129        let prefix = format!("<!-- agent:{} ", name);
130        while let Some(start) = result.find(&prefix) {
131            let tag_start = start;
132            let rest = &result[start + prefix.len()..];
133            let Some(end_rel) = rest.find("-->") else {
134                break;
135            };
136            let attrs_text = &rest[..end_rel];
137            let tag_end = start + prefix.len() + end_rel + 3; // past `-->`
138
139            let tokens: Vec<&str> = attrs_text.split_whitespace().collect();
140            let has_patch_or_mode = tokens.iter().any(|t| {
141                t.split_once('=')
142                    .map(|(k, _)| k == "patch" || k == "mode")
143                    .unwrap_or(false)
144            });
145
146            if !has_patch_or_mode {
147                break;
148            }
149
150            if !warned {
151                eprintln!(
152                    "[deprecation] patch/mode attribute on agent:{} is deprecated and will be stripped — \
153                     the binary owns backlog mutations via --pending-* flags",
154                    name
155                );
156                warned = true;
157            }
158
159            let remaining: Vec<&str> = tokens
160                .iter()
161                .filter(|t| {
162                    t.split_once('=')
163                        .map(|(k, _)| k != "patch" && k != "mode")
164                        .unwrap_or(true)
165                })
166                .copied()
167                .collect();
168
169            let new_tag = if remaining.is_empty() {
170                format!("<!-- agent:{} -->", name)
171            } else {
172                format!("<!-- agent:{} {} -->", name, remaining.join(" "))
173            };
174
175            result.replace_range(tag_start..tag_end, &new_tag);
176        }
177    }
178
179    result
180}
181
182/// A parsed component in a document.
183///
184/// Components are bounded regions marked by `<!-- agent:name -->...<!-- /agent:name -->`.
185/// Opening tags may contain inline attributes: `<!-- agent:name key=value -->`.
186#[derive(Debug, Clone, PartialEq, Eq)]
187pub struct Component {
188    pub name: String,
189    /// Inline attributes parsed from the opening tag (e.g., `patch=append`).
190    pub attrs: HashMap<String, String>,
191    /// Byte offset of `<` in opening marker.
192    pub open_start: usize,
193    /// Byte offset past `>` in opening marker (includes trailing newline if present).
194    pub open_end: usize,
195    /// Byte offset of `<` in closing marker.
196    pub close_start: usize,
197    /// Byte offset past `>` in closing marker (includes trailing newline if present).
198    pub close_end: usize,
199}
200
201impl Component {
202    /// Extract the content between the opening and closing markers.
203    #[allow(dead_code)] // public API — used by tests and future consumers
204    pub fn content<'a>(&self, doc: &'a str) -> &'a str {
205        &doc[self.open_end..self.close_start]
206    }
207
208    /// Get the patch mode from inline attributes.
209    ///
210    /// Checks `patch=` first, falls back to `mode=` for backward compatibility.
211    pub fn patch_mode(&self) -> Option<&str> {
212        self.attrs
213            .get("patch")
214            .map(|s| s.as_str())
215            .or_else(|| self.attrs.get("mode").map(|s| s.as_str()))
216    }
217
218    /// Replace the content between markers, returning the new document.
219    /// The markers themselves are preserved.
220    pub fn replace_content(&self, doc: &str, new_content: &str) -> String {
221        let mut result = String::with_capacity(doc.len() + new_content.len());
222        result.push_str(&doc[..self.open_end]);
223        result.push_str(new_content);
224        result.push_str(&doc[self.close_start..]);
225        result
226    }
227
228    /// Append content into this component, inserting before the caret position
229    /// if the caret is inside the component. Falls back to normal append if the
230    /// caret is outside the component.
231    ///
232    /// `caret_offset`: byte offset of the caret in the document. Pass `None` for
233    /// normal append behavior.
234    pub fn append_with_caret(
235        &self,
236        doc: &str,
237        content: &str,
238        caret_offset: Option<usize>,
239    ) -> String {
240        let existing = &doc[self.open_end..self.close_start];
241        if append_patch_already_present(existing, content) {
242            return doc.to_string();
243        }
244
245        if let Some(caret) = caret_offset {
246            // Check if caret is inside this component
247            if caret > self.open_end && caret <= self.close_start {
248                // Find the line boundary before the caret
249                let insert_at = doc[..caret]
250                    .rfind('\n')
251                    .map(|i| i + 1)
252                    .unwrap_or(self.open_end);
253
254                // Clamp to component bounds
255                let insert_at = insert_at.max(self.open_end);
256                let prior = &doc[self.open_end..insert_at];
257
258                let mut result = String::with_capacity(doc.len() + content.len() + 1);
259                result.push_str(&doc[..insert_at]);
260                if needs_exchange_heading_separator(&self.name, prior, content) {
261                    result.push('\n');
262                }
263                result.push_str(content.trim_end());
264                result.push('\n');
265                result.push_str(&doc[insert_at..]);
266                return result;
267            }
268        }
269
270        // Normal append: add after existing content
271        let mut result = String::with_capacity(doc.len() + content.len() + 1);
272        result.push_str(&doc[..self.open_end]);
273        let trimmed_existing = existing.trim_end();
274        result.push_str(trimmed_existing);
275        if needs_exchange_heading_separator(&self.name, trimmed_existing, content) {
276            result.push('\n');
277        }
278        result.push('\n');
279        result.push_str(content.trim_end());
280        result.push('\n');
281        result.push_str(&doc[self.close_start..]);
282        result
283    }
284
285    /// Append content into this component at the boundary marker position.
286    ///
287    /// Finds `<!-- agent:boundary:ID -->` inside the component. If found,
288    /// inserts content at the line start of the boundary marker (replacing
289    /// the marker). Falls back to normal append if the boundary is not found.
290    pub fn append_with_boundary(&self, doc: &str, content: &str, boundary_id: &str) -> String {
291        let boundary_marker = format!("<!-- agent:boundary:{} -->", boundary_id);
292        let content_region = &doc[self.open_end..self.close_start];
293        if append_patch_already_present(content_region, content) {
294            return doc.to_string();
295        }
296        let code_ranges = find_code_ranges(doc);
297
298        // Search for boundary marker, skipping matches inside code blocks
299        let mut search_from = 0;
300        let found_pos = loop {
301            match content_region[search_from..].find(&boundary_marker) {
302                Some(rel_pos) => {
303                    let abs_pos = self.open_end + search_from + rel_pos;
304                    if code_ranges
305                        .iter()
306                        .any(|&(cs, ce)| abs_pos >= cs && abs_pos < ce)
307                    {
308                        // Inside a code block — skip and keep searching
309                        search_from += rel_pos + boundary_marker.len();
310                        continue;
311                    }
312                    break Some(abs_pos);
313                }
314                None => break None,
315            }
316        };
317
318        if let Some(abs_pos) = found_pos {
319            // Find start of the line containing the marker
320            let line_start = doc[..abs_pos]
321                .rfind('\n')
322                .map(|i| i + 1)
323                .unwrap_or(self.open_end)
324                .max(self.open_end);
325
326            // Find end of the marker line (including trailing newline)
327            let marker_end = abs_pos + boundary_marker.len();
328            let line_end = if marker_end < self.close_start
329                && doc.as_bytes().get(marker_end) == Some(&b'\n')
330            {
331                marker_end + 1
332            } else {
333                marker_end
334            };
335            let line_end = line_end.min(self.close_start);
336
337            // Replace the boundary marker with response content + new boundary.
338            // The boundary is consumed and re-inserted, matching the binary's
339            // post-patch behavior in apply_patches_with_overrides().
340            let new_id = crate::id::new_boundary_id();
341            let new_marker = crate::id::format_boundary_marker(&new_id);
342            let mut result = String::with_capacity(doc.len() + content.len() + new_marker.len());
343            let prior = &doc[self.open_end..line_start];
344            result.push_str(&doc[..line_start]);
345            if needs_exchange_heading_separator(&self.name, prior, content) {
346                result.push('\n');
347            }
348            result.push_str(content.trim_end());
349            result.push('\n');
350            result.push_str(&new_marker);
351            result.push('\n');
352            result.push_str(&doc[line_end..]);
353            return result;
354        }
355
356        // Boundary not found — fall back to normal append
357        self.append_with_caret(doc, content, None)
358    }
359}
360
361/// 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
974/// Detect structural corruption that must never be adopted as a snapshot base
975/// (`#dupcontent`). Returns `Some(reason)` when the document is corrupt:
976/// - a parse failure (mismatched / unclosed markers),
977/// - more than one opening marker for a singleton component (duplicate block),
978/// - an unterminated double-quoted attribute on a component opening marker.
979///
980/// Returns `None` when the document is structurally sound. Used to fail closed
981/// before adopting a `content_ours` / `ack_content_sidecar` IPC buffer whose
982/// prior CRDT merge produced duplicate singleton blocks or a split attribute,
983/// so the corrupt buffer never reaches disk.
984pub fn structural_corruption_reason(doc: &str) -> Option<String> {
985    let components = match parse(doc) {
986        Ok(c) => c,
987        Err(e) => return Some(format!("parse_error: {e}")),
988    };
989
990    // Duplicate singleton component blocks.
991    let mut counts: HashMap<&str, usize> = HashMap::new();
992    for c in &components {
993        if let Some(canonical) = canonical_singleton_name(&c.name) {
994            *counts.entry(canonical).or_insert(0) += 1;
995        }
996    }
997    let mut dupes: Vec<(&str, usize)> = counts
998        .iter()
999        .filter(|&(_, &n)| n > 1)
1000        .map(|(&k, &n)| (k, n))
1001        .collect();
1002    if !dupes.is_empty() {
1003        dupes.sort();
1004        let detail = dupes
1005            .iter()
1006            .map(|(k, n)| format!("{k}={n}"))
1007            .collect::<Vec<_>>()
1008            .join(",");
1009        return Some(format!("duplicate_singleton_component:{detail}"));
1010    }
1011
1012    // Unterminated double-quoted attribute in a component opening marker.
1013    for c in &components {
1014        let marker = &doc[c.open_start..c.open_end];
1015        if marker_has_unterminated_quote(marker) {
1016            return Some(format!("malformed_attr:{}", c.name));
1017        }
1018    }
1019
1020    None
1021}
1022
1023/// Find the end of an HTML comment (`-->`), returning byte offset past `>`.
1024pub fn find_comment_end(bytes: &[u8], start: usize) -> Option<usize> {
1025    let len = bytes.len();
1026    let mut i = start;
1027    while i + 3 <= len {
1028        if &bytes[i..i + 3] == b"-->" {
1029            return Some(i + 3);
1030        }
1031        i += 1;
1032    }
1033    None
1034}
1035
1036/// Strip comments from document content for diff comparison.
1037///
1038/// Removes:
1039/// - HTML comments `<!-- ... -->` (single and multiline) — EXCEPT agent range markers
1040/// - Link reference comments `[//]: # (...)`
1041///
1042/// Skips `<!--` sequences inside fenced code blocks and inline backtick spans
1043/// to prevent code examples containing `<!--` from being misinterpreted as
1044/// comment starts.
1045///
1046/// This function is the shared implementation used by both `diff::compute` (binary)
1047/// and external crates like `eval-runner`.
1048pub fn strip_comments(content: &str) -> String {
1049    let code_ranges = find_code_ranges(content);
1050    let in_code = |pos: usize| {
1051        code_ranges
1052            .iter()
1053            .any(|&(start, end)| pos >= start && pos < end)
1054    };
1055
1056    let mut result = String::with_capacity(content.len());
1057    let bytes = content.as_bytes();
1058    let len = bytes.len();
1059    let mut pos = 0;
1060
1061    while pos < len {
1062        // Check for link reference comment: `[//]: # (...)`
1063        if bytes[pos] == b'['
1064            && !in_code(pos)
1065            && is_line_start_at(bytes, pos)
1066            && let Some(end) = match_link_ref_comment_at(bytes, pos)
1067        {
1068            pos = end;
1069            continue;
1070        }
1071
1072        // Check for HTML comment: `<!-- ... -->`
1073        if pos + 4 <= len
1074            && &bytes[pos..pos + 4] == b"<!--"
1075            && !in_code(pos)
1076            && let Some((end, inner)) = match_html_comment_at(content, pos)
1077        {
1078            if is_agent_marker(inner) {
1079                // Preserve agent markers — copy them through
1080                result.push_str(&content[pos..end]);
1081                pos = end;
1082            } else {
1083                // Strip the comment (and trailing newline if on its own line)
1084                let mut skip_to = end;
1085                if is_line_start_at(bytes, pos) && skip_to < len && bytes[skip_to] == b'\n' {
1086                    skip_to += 1;
1087                }
1088                pos = skip_to;
1089            }
1090            continue;
1091        }
1092
1093        result.push(content[pos..].chars().next().unwrap());
1094        pos += content[pos..].chars().next().unwrap().len_utf8();
1095    }
1096
1097    result
1098}
1099
1100/// True if `pos` is at the start of a line (pos == 0 or bytes[pos-1] == '\n').
1101fn is_line_start_at(bytes: &[u8], pos: usize) -> bool {
1102    pos == 0 || bytes[pos - 1] == b'\n'
1103}
1104
1105/// Match `[//]: # (...)` starting at `pos`. Returns byte offset past the line end.
1106fn match_link_ref_comment_at(bytes: &[u8], pos: usize) -> Option<usize> {
1107    let prefix = b"[//]: # (";
1108    let len = bytes.len();
1109    if pos + prefix.len() > len || &bytes[pos..pos + prefix.len()] != prefix {
1110        return None;
1111    }
1112    let mut i = pos + prefix.len();
1113    while i < len && bytes[i] != b')' && bytes[i] != b'\n' {
1114        i += 1;
1115    }
1116    if i < len && bytes[i] == b')' {
1117        i += 1;
1118        if i < len && bytes[i] == b'\n' {
1119            i += 1;
1120        }
1121        Some(i)
1122    } else {
1123        None
1124    }
1125}
1126
1127/// Match `<!-- ... -->` starting at `pos`. Returns (end_offset, inner_text).
1128fn match_html_comment_at(content: &str, pos: usize) -> Option<(usize, &str)> {
1129    let bytes = content.as_bytes();
1130    let len = bytes.len();
1131    let mut i = pos + 4;
1132    while i + 3 <= len {
1133        if &bytes[i..i + 3] == b"-->" {
1134            let inner = &content[pos + 4..i];
1135            return Some((i + 3, inner));
1136        }
1137        i += 1;
1138    }
1139    None
1140}
1141
1142#[cfg(test)]
1143mod tests {
1144    use super::*;
1145
1146    #[test]
1147    fn single_range() {
1148        let doc = "before\n<!-- agent:status -->\nHello\n<!-- /agent:status -->\nafter\n";
1149        let ranges = parse(doc).unwrap();
1150        assert_eq!(ranges.len(), 1);
1151        assert_eq!(ranges[0].name, "status");
1152        assert_eq!(ranges[0].content(doc), "Hello\n");
1153    }
1154
1155    #[test]
1156    fn nested_ranges() {
1157        let doc = "\
1158<!-- agent:outer -->
1159<!-- agent:inner -->
1160content
1161<!-- /agent:inner -->
1162<!-- /agent:outer -->
1163";
1164        let ranges = parse(doc).unwrap();
1165        assert_eq!(ranges.len(), 2);
1166        // Sorted by open_start — outer first
1167        assert_eq!(ranges[0].name, "outer");
1168        assert_eq!(ranges[1].name, "inner");
1169        assert_eq!(ranges[1].content(doc), "content\n");
1170    }
1171
1172    #[test]
1173    fn siblings() {
1174        let doc = "\
1175<!-- agent:a -->
1176alpha
1177<!-- /agent:a -->
1178<!-- agent:b -->
1179beta
1180<!-- /agent:b -->
1181";
1182        let ranges = parse(doc).unwrap();
1183        assert_eq!(ranges.len(), 2);
1184        assert_eq!(ranges[0].name, "a");
1185        assert_eq!(ranges[0].content(doc), "alpha\n");
1186        assert_eq!(ranges[1].name, "b");
1187        assert_eq!(ranges[1].content(doc), "beta\n");
1188    }
1189
1190    #[test]
1191    fn no_ranges() {
1192        let doc = "# Just a document\n\nWith no range templates.\n";
1193        let ranges = parse(doc).unwrap();
1194        assert!(ranges.is_empty());
1195    }
1196
1197    #[test]
1198    fn unmatched_open_error() {
1199        let doc = "<!-- agent:orphan -->\nContent\n";
1200        let err = parse(doc).unwrap_err();
1201        assert!(err.to_string().contains("unclosed component"));
1202    }
1203
1204    #[test]
1205    fn unmatched_close_error() {
1206        let doc = "Content\n<!-- /agent:orphan -->\n";
1207        let err = parse(doc).unwrap_err();
1208        assert!(err.to_string().contains("without matching open"));
1209    }
1210
1211    #[test]
1212    fn mismatched_names_error() {
1213        let doc = "<!-- agent:foo -->\n<!-- /agent:bar -->\n";
1214        let err = parse(doc).unwrap_err();
1215        assert!(err.to_string().contains("mismatched"));
1216    }
1217
1218    #[test]
1219    fn invalid_name() {
1220        let doc = "<!-- agent:-bad -->\n<!-- /agent:-bad -->\n";
1221        let err = parse(doc).unwrap_err();
1222        assert!(err.to_string().contains("invalid component name"));
1223    }
1224
1225    #[test]
1226    fn name_validation() {
1227        assert!(is_valid_name("status"));
1228        assert!(is_valid_name("my-section"));
1229        assert!(is_valid_name("a1"));
1230        assert!(is_valid_name("A"));
1231        assert!(!is_valid_name(""));
1232        assert!(!is_valid_name("-bad"));
1233        assert!(!is_valid_name("has space"));
1234        assert!(!is_valid_name("has_underscore"));
1235    }
1236
1237    #[test]
1238    fn content_extraction() {
1239        let doc = "<!-- agent:x -->\nfoo\nbar\n<!-- /agent:x -->\n";
1240        let ranges = parse(doc).unwrap();
1241        assert_eq!(ranges[0].content(doc), "foo\nbar\n");
1242    }
1243
1244    #[test]
1245    fn replace_roundtrip() {
1246        let doc = "before\n<!-- agent:s -->\nold\n<!-- /agent:s -->\nafter\n";
1247        let ranges = parse(doc).unwrap();
1248        let new_doc = ranges[0].replace_content(doc, "new\n");
1249        assert_eq!(
1250            new_doc,
1251            "before\n<!-- agent:s -->\nnew\n<!-- /agent:s -->\nafter\n"
1252        );
1253        // Re-parse should work
1254        let ranges2 = parse(&new_doc).unwrap();
1255        assert_eq!(ranges2.len(), 1);
1256        assert_eq!(ranges2[0].content(&new_doc), "new\n");
1257    }
1258
1259    #[test]
1260    fn is_agent_marker_yes() {
1261        assert!(is_agent_marker(" agent:status "));
1262        assert!(is_agent_marker("/agent:status"));
1263        assert!(is_agent_marker("agent:my-thing"));
1264        assert!(is_agent_marker(" /agent:A1 "));
1265    }
1266
1267    #[test]
1268    fn is_agent_marker_no() {
1269        assert!(!is_agent_marker("just a comment"));
1270        assert!(!is_agent_marker("agent:"));
1271        assert!(!is_agent_marker("/agent:"));
1272        assert!(!is_agent_marker("agent:-bad"));
1273        assert!(!is_agent_marker("some agent:fake stuff"));
1274    }
1275
1276    #[test]
1277    fn regular_comments_ignored() {
1278        let doc = "<!-- just a comment -->\n<!-- agent:x -->\ndata\n<!-- /agent:x -->\n";
1279        let ranges = parse(doc).unwrap();
1280        assert_eq!(ranges.len(), 1);
1281        assert_eq!(ranges[0].name, "x");
1282    }
1283
1284    #[test]
1285    fn regular_comments_with_multibyte_preview_boundary_ignored() {
1286        let doc = "\
1287<!-- 123456789012345678❯ ordinary comment -->
1288<!-- agent:x -->
1289data
1290<!-- /agent:x -->
1291";
1292        let ranges = parse(doc).unwrap();
1293        assert_eq!(ranges.len(), 1);
1294        assert_eq!(ranges[0].name, "x");
1295    }
1296
1297    #[test]
1298    fn multiline_comment_ignored() {
1299        let doc = "\
1300<!--
1301multi
1302line
1303comment
1304-->
1305<!-- agent:s -->
1306content
1307<!-- /agent:s -->
1308";
1309        let ranges = parse(doc).unwrap();
1310        assert_eq!(ranges.len(), 1);
1311        assert_eq!(ranges[0].name, "s");
1312    }
1313
1314    #[test]
1315    fn empty_content() {
1316        let doc = "<!-- agent:empty --><!-- /agent:empty -->\n";
1317        let ranges = parse(doc).unwrap();
1318        assert_eq!(ranges.len(), 1);
1319        assert_eq!(ranges[0].content(doc), "");
1320    }
1321
1322    #[test]
1323    fn markers_in_fenced_code_block_ignored() {
1324        let doc = "\
1325<!-- agent:real -->
1326content
1327<!-- /agent:real -->
1328```markdown
1329<!-- agent:fake -->
1330this is just an example
1331<!-- /agent:fake -->
1332```
1333";
1334        let ranges = parse(doc).unwrap();
1335        assert_eq!(ranges.len(), 1);
1336        assert_eq!(ranges[0].name, "real");
1337    }
1338
1339    #[test]
1340    fn markers_in_inline_code_ignored() {
1341        let doc = "\
1342Use `<!-- agent:example -->` markers for components.
1343<!-- agent:real -->
1344content
1345<!-- /agent:real -->
1346";
1347        let ranges = parse(doc).unwrap();
1348        assert_eq!(ranges.len(), 1);
1349        assert_eq!(ranges[0].name, "real");
1350    }
1351
1352    #[test]
1353    fn markers_in_tilde_fence_ignored() {
1354        let doc = "\
1355<!-- agent:x -->
1356data
1357<!-- /agent:x -->
1358~~~
1359<!-- agent:y -->
1360example
1361<!-- /agent:y -->
1362~~~
1363";
1364        let ranges = parse(doc).unwrap();
1365        assert_eq!(ranges.len(), 1);
1366        assert_eq!(ranges[0].name, "x");
1367    }
1368
1369    #[test]
1370    fn markers_in_indented_fenced_code_block_ignored() {
1371        // CommonMark allows up to 3 spaces before fence opener
1372        let doc = "\
1373<!-- agent:exchange -->
1374Content here.
1375<!-- /agent:exchange -->
1376
1377  ```markdown
1378  <!-- agent:fake -->
1379  demo without closing tag
1380  ```
1381";
1382        let ranges = parse(doc).unwrap();
1383        assert_eq!(ranges.len(), 1);
1384        assert_eq!(ranges[0].name, "exchange");
1385    }
1386
1387    #[test]
1388    fn indented_fence_inside_component_ignored() {
1389        // Indented code block inside a component should not cause mismatched errors
1390        let doc = "\
1391<!-- agent:exchange -->
1392Here's how to set up:
1393
1394   ```markdown
1395   <!-- agent:status -->
1396   Your status here
1397   ```
1398
1399Done explaining.
1400<!-- /agent:exchange -->
1401";
1402        let ranges = parse(doc).unwrap();
1403        assert_eq!(ranges.len(), 1);
1404        assert_eq!(ranges[0].name, "exchange");
1405    }
1406
1407    #[test]
1408    fn deeply_indented_fence_ignored() {
1409        // Tabs and many spaces should still be detected as a fence
1410        let doc = "\
1411<!-- agent:x -->
1412ok
1413<!-- /agent:x -->
1414      ```
1415      <!-- agent:y -->
1416      inside fence
1417      ```
1418";
1419        let ranges = parse(doc).unwrap();
1420        assert_eq!(ranges.len(), 1);
1421        assert_eq!(ranges[0].name, "x");
1422    }
1423
1424    #[test]
1425    fn indented_fence_code_ranges_detected() {
1426        let doc = "before\n  ```\n  code\n  ```\nafter\n";
1427        let ranges = find_code_ranges(doc);
1428        assert_eq!(ranges.len(), 1);
1429        assert!(doc[ranges[0].0..ranges[0].1].contains("code"));
1430    }
1431
1432    #[test]
1433    fn code_ranges_detected() {
1434        let doc = "before\n```\ncode\n```\nafter `inline` end\n";
1435        let ranges = find_code_ranges(doc);
1436        assert_eq!(ranges.len(), 2);
1437        // Fenced block
1438        assert!(doc[ranges[0].0..ranges[0].1].contains("code"));
1439        // Inline span
1440        assert!(doc[ranges[1].0..ranges[1].1].contains("inline"));
1441    }
1442
1443    #[test]
1444    fn code_ranges_double_backtick() {
1445        // CommonMark: `` `<!--` `` is a code span containing `<!--`
1446        let doc = "text `` `<!--` `` more\n";
1447        let ranges = find_code_ranges(doc);
1448        assert_eq!(ranges.len(), 1);
1449        let span = &doc[ranges[0].0..ranges[0].1];
1450        assert!(
1451            span.contains("<!--"),
1452            "double-backtick span should contain <!--: {:?}",
1453            span
1454        );
1455    }
1456
1457    #[test]
1458    fn code_ranges_double_backtick_does_not_match_single() {
1459        // `` should not match a single ` close
1460        let doc = "text `` foo ` bar `` end\n";
1461        let ranges = find_code_ranges(doc);
1462        assert_eq!(ranges.len(), 1);
1463        let span = &doc[ranges[0].0..ranges[0].1];
1464        assert_eq!(span, "`` foo ` bar ``");
1465    }
1466
1467    #[test]
1468    fn double_backtick_comment_before_agent_marker() {
1469        // Regression: `` `<!--` `` followed by agent marker should not confuse the parser
1470        let doc = "\
1471<!-- agent:exchange -->\n\
1472text `` `<!--` `` description\n\
1473new content here\n\
1474<!-- /agent:exchange -->\n";
1475        let components = parse(doc).unwrap();
1476        assert_eq!(components.len(), 1);
1477        assert_eq!(components[0].name, "exchange");
1478        assert!(components[0].content(doc).contains("new content here"));
1479    }
1480
1481    #[test]
1482    fn quoted_agent_marker_in_prose_is_not_parsed() {
1483        // #0kjc: a marker quoted by name in prose is content, not a real marker.
1484        // Without quote-masking the dangling `<!-- agent:queue -->` inside the
1485        // quotes would open an unmatched component and error the whole parse.
1486        let doc = "\
1487<!-- agent:exchange -->\n\
1488Operator note: \"<!-- agent:queue -->\" attributes were dropped on compaction.\n\
1489new content here\n\
1490<!-- /agent:exchange -->\n";
1491        let components = parse(doc).unwrap();
1492        assert_eq!(components.len(), 1);
1493        assert_eq!(components[0].name, "exchange");
1494        assert!(components[0].content(doc).contains("new content here"));
1495        assert!(
1496            components[0]
1497                .content(doc)
1498                .contains("attributes were dropped")
1499        );
1500    }
1501
1502    #[test]
1503    fn quoted_id_token_in_prose_is_content() {
1504        // #0kjc: a `#id`-looking token in a quoted span is prose, never a tag.
1505        let doc = "\
1506<!-- agent:exchange -->\n\
1507The operator asked to clear \"#advance-review\" but no such line exists.\n\
1508<!-- /agent:exchange -->\n";
1509        let components = parse(doc).unwrap();
1510        assert_eq!(components.len(), 1);
1511        assert!(components[0].content(doc).contains("#advance-review"));
1512    }
1513
1514    #[test]
1515    fn fenced_agent_marker_in_prose_is_not_parsed() {
1516        // Regression guard: code-fence masking (pre-#0kjc) must keep working — a
1517        // marker inside a fenced block stays content.
1518        let doc = "\
1519<!-- agent:exchange -->\n\
1520```\n\
1521<!-- agent:queue -->\n\
1522```\n\
1523new content here\n\
1524<!-- /agent:exchange -->\n";
1525        let components = parse(doc).unwrap();
1526        assert_eq!(components.len(), 1);
1527        assert_eq!(components[0].name, "exchange");
1528        assert!(components[0].content(doc).contains("new content here"));
1529    }
1530
1531    #[test]
1532    fn real_marker_with_quoted_preset_attr_still_parses() {
1533        // #0kjc safety: a real marker carrying a double-quoted attribute value
1534        // must NOT be masked — its `<!--` precedes the quotes, so its start byte
1535        // is never inside a quoted span.
1536        let doc = "\
1537<!-- agent:queue preset=\"#spec-test-build-install-commit-push\" priority go -->\n\
1538- :pushpin: do [#thing]\n\
1539<!-- /agent:queue -->\n";
1540        let components = parse(doc).unwrap();
1541        assert_eq!(components.len(), 1);
1542        assert_eq!(components[0].name, "queue");
1543        // The marker is NOT masked (proving the safety property); the attr
1544        // parser stores the raw quoted value, same as before this change.
1545        assert!(
1546            components[0]
1547                .attrs
1548                .get("preset")
1549                .is_some_and(|v| v.contains("#spec-test-build-install-commit-push")),
1550            "preset attr should survive quote-masking, got {:?}",
1551            components[0].attrs.get("preset")
1552        );
1553    }
1554
1555    #[test]
1556    fn find_quoted_ranges_is_same_line_and_fail_safe() {
1557        // Balanced same-line span is masked; a span never crosses a newline.
1558        let doc = "a \"bc\" d\n\"ef\n";
1559        let ranges = find_quoted_ranges(doc);
1560        // First line: `"bc"` (bytes 2..6). Second line: unterminated `"ef`
1561        // masks to end-of-line only (not into the next line).
1562        assert_eq!(ranges.len(), 2);
1563        assert_eq!(&doc[ranges[0].0..ranges[0].1], "\"bc\"");
1564        assert_eq!(&doc[ranges[1].0..ranges[1].1], "\"ef");
1565    }
1566
1567    // --- Inline attribute tests ---
1568
1569    #[test]
1570    fn parse_component_with_mode_attr() {
1571        let doc = "<!-- agent:exchange mode=append -->\nContent\n<!-- /agent:exchange -->\n";
1572        let components = parse(doc).unwrap();
1573        assert_eq!(components.len(), 1);
1574        assert_eq!(components[0].name, "exchange");
1575        assert_eq!(
1576            components[0].attrs.get("mode").map(|s| s.as_str()),
1577            Some("append")
1578        );
1579        assert_eq!(components[0].content(doc), "Content\n");
1580    }
1581
1582    #[test]
1583    fn parse_component_with_multiple_attrs() {
1584        let doc = "<!-- agent:log mode=prepend timestamp=true -->\nData\n<!-- /agent:log -->\n";
1585        let components = parse(doc).unwrap();
1586        assert_eq!(components.len(), 1);
1587        assert_eq!(components[0].name, "log");
1588        assert_eq!(
1589            components[0].attrs.get("mode").map(|s| s.as_str()),
1590            Some("prepend")
1591        );
1592        assert_eq!(
1593            components[0].attrs.get("timestamp").map(|s| s.as_str()),
1594            Some("true")
1595        );
1596    }
1597
1598    #[test]
1599    fn parse_component_no_attrs_backward_compat() {
1600        let doc = "<!-- agent:status -->\nOK\n<!-- /agent:status -->\n";
1601        let components = parse(doc).unwrap();
1602        assert_eq!(components.len(), 1);
1603        assert_eq!(components[0].name, "status");
1604        assert!(components[0].attrs.is_empty());
1605    }
1606
1607    #[test]
1608    fn is_agent_marker_with_attrs() {
1609        assert!(is_agent_marker(" agent:exchange mode=append "));
1610        assert!(is_agent_marker("agent:status mode=replace"));
1611        assert!(is_agent_marker("agent:log mode=prepend timestamp=true"));
1612    }
1613
1614    #[test]
1615    fn closing_tag_unchanged_with_attrs() {
1616        // Closing tags never have attributes
1617        let doc = "<!-- agent:status mode=replace -->\n- [x] Done\n<!-- /agent:status -->\n";
1618        let components = parse(doc).unwrap();
1619        assert_eq!(components.len(), 1);
1620        let new_doc = components[0].replace_content(doc, "- [ ] Todo\n");
1621        assert!(new_doc.contains("<!-- agent:status mode=replace -->"));
1622        assert!(new_doc.contains("<!-- /agent:status -->"));
1623        assert!(new_doc.contains("- [ ] Todo"));
1624    }
1625
1626    #[test]
1627    fn parse_component_with_patch_attr() {
1628        let doc = "<!-- agent:exchange patch=append -->\nContent\n<!-- /agent:exchange -->\n";
1629        let components = parse(doc).unwrap();
1630        assert_eq!(components.len(), 1);
1631        assert_eq!(components[0].name, "exchange");
1632        assert_eq!(components[0].patch_mode(), Some("append"));
1633        assert_eq!(components[0].content(doc), "Content\n");
1634    }
1635
1636    #[test]
1637    fn patch_attr_takes_precedence_over_mode() {
1638        let doc = "<!-- agent:exchange patch=replace mode=append -->\nContent\n<!-- /agent:exchange -->\n";
1639        let components = parse(doc).unwrap();
1640        assert_eq!(components[0].patch_mode(), Some("replace"));
1641    }
1642
1643    #[test]
1644    fn mode_attr_backward_compat() {
1645        let doc = "<!-- agent:exchange mode=append -->\nContent\n<!-- /agent:exchange -->\n";
1646        let components = parse(doc).unwrap();
1647        assert_eq!(components[0].patch_mode(), Some("append"));
1648    }
1649
1650    #[test]
1651    fn no_patch_or_mode_attr() {
1652        let doc = "<!-- agent:exchange -->\nContent\n<!-- /agent:exchange -->\n";
1653        let components = parse(doc).unwrap();
1654        assert_eq!(components[0].patch_mode(), None);
1655    }
1656
1657    // --- Inline backtick code span exclusion tests ---
1658
1659    #[test]
1660    fn single_backtick_component_tag_ignored() {
1661        // A component tag wrapped in single backticks should not be parsed
1662        let doc = "\
1663Use `<!-- agent:pending -->` to mark pending sections.
1664<!-- agent:real -->
1665content
1666<!-- /agent:real -->
1667";
1668        let components = parse(doc).unwrap();
1669        assert_eq!(components.len(), 1);
1670        assert_eq!(components[0].name, "real");
1671    }
1672
1673    #[test]
1674    fn double_backtick_component_tag_ignored() {
1675        // A component tag wrapped in double backticks should not be parsed
1676        let doc = "\
1677Use ``<!-- agent:pending -->`` to mark pending sections.
1678<!-- agent:real -->
1679content
1680<!-- /agent:real -->
1681";
1682        let components = parse(doc).unwrap();
1683        assert_eq!(components.len(), 1);
1684        assert_eq!(components[0].name, "real");
1685    }
1686
1687    #[test]
1688    fn component_tags_not_in_backticks_still_work() {
1689        // Tags outside of any backticks are parsed normally
1690        let doc = "\
1691<!-- agent:a -->
1692alpha
1693<!-- /agent:a -->
1694<!-- agent:b patch=append -->
1695beta
1696<!-- /agent:b -->
1697";
1698        let components = parse(doc).unwrap();
1699        assert_eq!(components.len(), 2);
1700        assert_eq!(components[0].name, "a");
1701        assert_eq!(components[1].name, "b");
1702        assert_eq!(components[1].patch_mode(), Some("append"));
1703    }
1704
1705    #[test]
1706    fn mixed_backtick_and_real_tags() {
1707        // Some tags in backticks (ignored), some not (parsed)
1708        let doc = "\
1709Here is an example: `<!-- agent:fake -->` and ``<!-- /agent:fake -->``.
1710<!-- agent:real -->
1711real content
1712<!-- /agent:real -->
1713Another example: `<!-- agent:also-fake patch=replace -->` is just documentation.
1714";
1715        let components = parse(doc).unwrap();
1716        assert_eq!(components.len(), 1);
1717        assert_eq!(components[0].name, "real");
1718        assert_eq!(components[0].content(doc), "real content\n");
1719    }
1720
1721    #[test]
1722    fn inline_code_mid_line_with_surrounding_text_ignored() {
1723        // Edge case: component tag inside inline code span on a line with other content
1724        // before and after — must not be parsed as a real component marker.
1725        let doc = "\
1726Wrap markers like `<!-- agent:status -->` in backticks to show them literally.
1727<!-- agent:real -->
1728actual content
1729<!-- /agent:real -->
1730";
1731        let components = parse(doc).unwrap();
1732        assert_eq!(components.len(), 1);
1733        assert_eq!(components[0].name, "real");
1734        assert_eq!(components[0].content(doc), "actual content\n");
1735    }
1736
1737    #[test]
1738    fn parse_attrs_unit() {
1739        let attrs = parse_attrs("mode=append");
1740        assert_eq!(attrs.get("mode").map(|s| s.as_str()), Some("append"));
1741
1742        let attrs = parse_attrs("mode=replace timestamp=true");
1743        assert_eq!(attrs.len(), 2);
1744
1745        let attrs = parse_attrs("");
1746        assert!(attrs.is_empty());
1747
1748        // Bare tokens (no =) are parsed as boolean flags with empty string values
1749        let attrs = parse_attrs("mode=append broken novalue=");
1750        assert_eq!(attrs.len(), 2);
1751        assert_eq!(attrs.get("mode").map(|s| s.as_str()), Some("append"));
1752        assert_eq!(attrs.get("broken").map(|s| s.as_str()), Some(""));
1753
1754        // auto flag (used by agent:queue)
1755        let attrs = parse_attrs("auto");
1756        assert_eq!(attrs.len(), 1);
1757        assert!(attrs.contains_key("auto"));
1758    }
1759
1760    #[test]
1761    fn append_with_boundary_skips_code_block() {
1762        // Boundary marker inside a code block should be ignored;
1763        // the real marker outside should be used.
1764        let boundary_id = "real-uuid";
1765        let doc = format!(
1766            "<!-- agent:exchange patch=append -->\n\
1767             user prompt\n\
1768             ```\n\
1769             <!-- agent:boundary:{boundary_id} -->\n\
1770             ```\n\
1771             more user text\n\
1772             <!-- agent:boundary:{boundary_id} -->\n\
1773             <!-- /agent:exchange -->\n"
1774        );
1775        let components = parse(&doc).unwrap();
1776        let comp = &components[0];
1777        let result =
1778            comp.append_with_boundary(&doc, "### Re: Response\n\nContent here.", boundary_id);
1779
1780        // Response should replace the REAL marker (outside code block),
1781        // not the one inside the code block.
1782        assert!(result.contains("### Re: Response"));
1783        assert!(result.contains("more user text"));
1784        // The code block example should be preserved
1785        assert!(result.contains(&format!("<!-- agent:boundary:{boundary_id} -->\n```")));
1786        // The real marker should be consumed (replaced by response)
1787        assert!(!result.contains(&format!(
1788            "more user text\n<!-- agent:boundary:{boundary_id} -->\n<!-- /agent:exchange -->"
1789        )));
1790    }
1791
1792    #[test]
1793    fn append_with_boundary_no_code_block() {
1794        // Normal case: boundary marker not in a code block
1795        let boundary_id = "simple-uuid";
1796        let doc = format!(
1797            "<!-- agent:exchange patch=append -->\n\
1798             user prompt\n\
1799             <!-- agent:boundary:{boundary_id} -->\n\
1800             <!-- /agent:exchange -->\n"
1801        );
1802        let components = parse(&doc).unwrap();
1803        let comp = &components[0];
1804        let result = comp.append_with_boundary(&doc, "### Re: Answer\n\nDone.", boundary_id);
1805
1806        assert!(result.contains("### Re: Answer"));
1807        assert!(result.contains("user prompt"));
1808        // Original marker should be consumed, but a NEW boundary re-inserted
1809        assert!(!result.contains(&format!("agent:boundary:{boundary_id}")));
1810        assert!(result.contains("agent:boundary:"));
1811    }
1812
1813    #[test]
1814    fn append_with_boundary_skips_already_present_content() {
1815        let boundary_id = "simple-uuid";
1816        let doc = format!(
1817            "<!-- agent:exchange patch=append -->\n\
1818             ### Re: Duplicate — gpt-5 (HEAD)\n\
1819             \n\
1820             Already applied.\n\
1821             <!-- agent:boundary:old-id -->\n\
1822             <!-- agent:boundary:{boundary_id} -->\n\
1823             <!-- /agent:exchange -->\n"
1824        );
1825        let components = parse(&doc).unwrap();
1826        let comp = &components[0];
1827        let result = comp.append_with_boundary(
1828            &doc,
1829            "### Re: Duplicate — gpt-5\n\nAlready applied.\n",
1830            boundary_id,
1831        );
1832
1833        assert_eq!(result, doc);
1834    }
1835
1836    #[test]
1837    fn append_with_caret_skips_already_present_content() {
1838        let doc = "<!-- agent:exchange patch=append -->\n\
1839                   User prompt.\n\
1840                   ### Re: Duplicate — gpt-5\n\
1841                   \n\
1842                   Already applied.\n\
1843                   <!-- /agent:exchange -->\n";
1844        let components = parse(doc).unwrap();
1845        let comp = &components[0];
1846        let result =
1847            comp.append_with_caret(doc, "### Re: Duplicate — gpt-5\n\nAlready applied.\n", None);
1848
1849        assert_eq!(result, doc);
1850    }
1851
1852    // --- strip_comments tests (moved from diff.rs) ---
1853
1854    #[test]
1855    fn strip_comments_removes_html_comment() {
1856        let result = strip_comments("before\n<!-- a comment -->\nafter\n");
1857        assert_eq!(result, "before\nafter\n");
1858    }
1859
1860    #[test]
1861    fn non_agent_html_comment_ranges_cover_multiline_body() {
1862        let doc = concat!(
1863            "before\n",
1864            "<!--\n",
1865            "do #hidden. spec-test-build-install-commit-push\n",
1866            "-->\n",
1867            "<!-- agent:exchange -->\n",
1868            "<!-- /agent:exchange -->\n"
1869        );
1870
1871        let ranges = find_non_agent_html_comment_ranges(doc);
1872        assert_eq!(ranges.len(), 1);
1873        let hidden = doc.find("do #hidden").unwrap();
1874        assert!(
1875            ranges
1876                .iter()
1877                .any(|&(start, end)| hidden >= start && hidden < end),
1878            "ordinary comment body should be inside a non-agent comment range"
1879        );
1880        assert!(
1881            ranges
1882                .iter()
1883                .all(|&(start, end)| !doc[start..end].contains("agent:exchange")),
1884            "agent component markers must not be treated as ordinary comments"
1885        );
1886    }
1887
1888    #[test]
1889    fn non_agent_html_comment_ranges_cover_unterminated_tail() {
1890        let doc = concat!(
1891            "before\n",
1892            "<!--\n",
1893            "do #hidden. spec-test-build-install-commit-push\n",
1894            "still typing\n"
1895        );
1896
1897        let ranges = find_non_agent_html_comment_ranges(doc);
1898        assert_eq!(ranges.len(), 1);
1899        let (start, end) = ranges[0];
1900        assert_eq!(
1901            &doc[start..end],
1902            "<!--\ndo #hidden. spec-test-build-install-commit-push\nstill typing\n"
1903        );
1904        assert_eq!(end, doc.len());
1905    }
1906
1907    #[test]
1908    fn strip_comments_preserves_agent_markers() {
1909        let input = "text\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
1910        let result = strip_comments(input);
1911        assert!(result.contains("<!-- agent:status -->"));
1912        assert!(result.contains("<!-- /agent:status -->"));
1913    }
1914
1915    #[test]
1916    fn strip_comments_removes_link_ref() {
1917        let result = strip_comments("[//]: # (hidden note)\nvisible\n");
1918        assert_eq!(result, "visible\n");
1919    }
1920
1921    #[test]
1922    fn html_comment_in_content_does_not_eat_close_marker() {
1923        let doc = "\
1924<!-- agent:pending -->
1925- [ ] [#abc] Rule: first line (not starting with ### or ❯ or <!-- ) gets prefix
1926<!-- /agent:pending -->
1927";
1928        let comps = parse(doc).unwrap();
1929        assert_eq!(comps.len(), 1);
1930        assert_eq!(comps[0].name, "pending");
1931        assert!(comps[0].content(doc).contains("<!-- )"));
1932    }
1933
1934    #[test]
1935    fn nested_html_comment_like_text_in_exchange() {
1936        let doc = "\
1937<!-- agent:exchange -->
1938User typed <!-- some note --> in the text.
1939<!-- /agent:exchange -->
1940";
1941        let comps = parse(doc).unwrap();
1942        assert_eq!(comps.len(), 1);
1943        assert_eq!(comps[0].name, "exchange");
1944    }
1945
1946    #[test]
1947    fn pending_item_with_literal_html_comment_opener() {
1948        // Regression: a pending item containing `<!-- ` (literal HTML comment start)
1949        // must not consume the `<!-- /agent:pending -->` close marker.
1950        let doc = "\
1951<!-- agent:pending -->
1952- [ ] [#r7hw] Component parser: non-agent `<!-- ` in content ate close markers
1953- [ ] [#xyz] Another item
1954<!-- /agent:pending -->
1955";
1956        let comps = parse(doc).unwrap();
1957        assert_eq!(comps.len(), 1);
1958        assert_eq!(comps[0].name, "pending");
1959        let content = comps[0].content(doc);
1960        assert!(content.contains("#r7hw"));
1961        assert!(content.contains("#xyz"));
1962    }
1963
1964    #[test]
1965    fn multiple_non_agent_html_comments_in_pending() {
1966        // Multiple `<!-- ... -->` fragments that are NOT agent markers.
1967        let doc = "\
1968<!-- agent:pending -->
1969- [ ] [#a] Fix rule: skip lines starting with <!-- or -->
1970- [ ] [#b] Handle <!-- partial comment
1971- [ ] [#c] Normal item
1972<!-- /agent:pending -->
1973";
1974        let comps = parse(doc).unwrap();
1975        assert_eq!(comps.len(), 1);
1976        assert_eq!(comps[0].name, "pending");
1977        let content = comps[0].content(doc);
1978        assert!(content.contains("#a"));
1979        assert!(content.contains("#b"));
1980        assert!(content.contains("#c"));
1981    }
1982
1983    #[test]
1984    fn exchange_and_pending_both_with_html_comments_in_content() {
1985        // Both components contain non-agent HTML comments — parser must handle
1986        // siblings correctly without eating across component boundaries.
1987        let doc = "\
1988<!-- agent:exchange -->
1989The rule checks for <!-- prefix before deciding.
1990### Re: topic — opus
1991Fix applied to skip non-agent <!-- sequences.
1992<!-- /agent:exchange -->
1993
1994<!-- agent:pending -->
1995- [ ] [#cfdy] Verify <!-- in pending items
1996<!-- /agent:pending -->
1997";
1998        let comps = parse(doc).unwrap();
1999        assert_eq!(comps.len(), 2);
2000        assert_eq!(comps[0].name, "exchange");
2001        assert_eq!(comps[1].name, "pending");
2002        assert!(comps[0].content(doc).contains("<!-- prefix"));
2003        assert!(comps[1].content(doc).contains("#cfdy"));
2004    }
2005
2006    #[test]
2007    fn is_backlog_component_accepts_both_names() {
2008        assert!(is_backlog_component("backlog"));
2009        assert!(is_backlog_component("pending"));
2010        assert!(!is_backlog_component("exchange"));
2011        assert!(!is_backlog_component("status"));
2012        assert!(!is_backlog_component("pending-done"));
2013        assert!(!is_backlog_component("backlog-done"));
2014        assert!(!is_backlog_component("done"));
2015    }
2016
2017    #[test]
2018    fn is_backlog_done_component_accepts_only_canonical_done() {
2019        assert!(is_backlog_done_component("done"));
2020        assert!(!is_backlog_done_component("backlog-done"));
2021        assert!(!is_backlog_done_component("pending-done"));
2022        assert!(!is_backlog_done_component("backlog"));
2023        assert!(!is_backlog_done_component("pending"));
2024        assert!(!is_backlog_done_component("icebox"));
2025    }
2026
2027    #[test]
2028    fn is_review_component_accepts_name() {
2029        assert!(is_review_component("review"));
2030        assert!(!is_review_component("backlog"));
2031        assert!(!is_review_component("icebox"));
2032    }
2033
2034    #[test]
2035    fn is_icebox_component_accepts_name() {
2036        assert!(is_icebox_component("icebox"));
2037        assert!(!is_icebox_component("backlog"));
2038        assert!(!is_icebox_component("exchange"));
2039        assert!(!is_icebox_component("icebox-archive"));
2040    }
2041
2042    #[test]
2043    fn tracked_work_component_includes_review() {
2044        assert!(is_tracked_work_component("backlog"));
2045        assert!(is_tracked_work_component("pending"));
2046        assert!(is_tracked_work_component("review"));
2047        assert!(is_tracked_work_component("icebox"));
2048        assert!(!is_tracked_work_component("done"));
2049    }
2050
2051    #[test]
2052    fn icebox_component_parsed() {
2053        let doc = "\
2054<!-- agent:icebox -->
2055- Parked idea
2056<!-- /agent:icebox -->
2057";
2058        let comps = parse(doc).unwrap();
2059        assert_eq!(comps.len(), 1);
2060        assert_eq!(comps[0].name, "icebox");
2061        assert!(is_icebox_component(&comps[0].name));
2062        assert!(comps[0].content(doc).contains("Parked idea"));
2063    }
2064
2065    #[test]
2066    fn backlog_component_parsed_from_new_marker() {
2067        let doc = "\
2068<!-- agent:backlog -->
2069- [ ] [#abc] First item
2070<!-- /agent:backlog -->
2071";
2072        let comps = parse(doc).unwrap();
2073        assert_eq!(comps.len(), 1);
2074        assert_eq!(comps[0].name, "backlog");
2075        assert!(is_backlog_component(&comps[0].name));
2076        assert!(comps[0].content(doc).contains("#abc"));
2077    }
2078
2079    #[test]
2080    fn legacy_pending_marker_still_parsed() {
2081        let doc = "\
2082<!-- agent:pending -->
2083- [ ] [#xyz] Legacy item
2084<!-- /agent:pending -->
2085";
2086        let comps = parse(doc).unwrap();
2087        assert_eq!(comps.len(), 1);
2088        assert_eq!(comps[0].name, "pending");
2089        assert!(is_backlog_component(&comps[0].name));
2090    }
2091
2092    #[test]
2093    fn strip_backlog_patch_attr_removes_patch_replace() {
2094        let doc = "<!-- agent:backlog patch=replace -->\n- item\n<!-- /agent:backlog -->\n";
2095        let result = strip_backlog_patch_attr(doc);
2096        assert_eq!(
2097            result,
2098            "<!-- agent:backlog -->\n- item\n<!-- /agent:backlog -->\n"
2099        );
2100    }
2101
2102    #[test]
2103    fn strip_backlog_patch_attr_removes_pending_patch_replace() {
2104        let doc = "<!-- agent:pending patch=replace -->\n- item\n<!-- /agent:pending -->\n";
2105        let result = strip_backlog_patch_attr(doc);
2106        assert_eq!(
2107            result,
2108            "<!-- agent:pending -->\n- item\n<!-- /agent:pending -->\n"
2109        );
2110    }
2111
2112    #[test]
2113    fn strip_backlog_patch_attr_removes_mode_replace() {
2114        let doc = "<!-- agent:backlog mode=replace -->\n- item\n<!-- /agent:backlog -->\n";
2115        let result = strip_backlog_patch_attr(doc);
2116        assert_eq!(
2117            result,
2118            "<!-- agent:backlog -->\n- item\n<!-- /agent:backlog -->\n"
2119        );
2120    }
2121
2122    #[test]
2123    fn strip_backlog_patch_attr_preserves_other_attrs() {
2124        let doc =
2125            "<!-- agent:backlog patch=replace max_lines=50 -->\n- item\n<!-- /agent:backlog -->\n";
2126        let result = strip_backlog_patch_attr(doc);
2127        assert_eq!(
2128            result,
2129            "<!-- agent:backlog max_lines=50 -->\n- item\n<!-- /agent:backlog -->\n"
2130        );
2131    }
2132
2133    #[test]
2134    fn strip_backlog_patch_attr_noop_for_exchange() {
2135        let doc = "<!-- agent:exchange patch=append -->\ncontent\n<!-- /agent:exchange -->\n";
2136        let result = strip_backlog_patch_attr(doc);
2137        assert_eq!(result, doc);
2138    }
2139
2140    #[test]
2141    fn strip_backlog_patch_attr_noop_when_no_attr() {
2142        let doc = "<!-- agent:backlog -->\n- item\n<!-- /agent:backlog -->\n";
2143        let result = strip_backlog_patch_attr(doc);
2144        assert_eq!(result, doc);
2145    }
2146
2147    #[test]
2148    fn converge_queue_auto_strips_auto() {
2149        let doc = "<!-- agent:queue auto -->\n- do [#x]\n<!-- /agent:queue -->\n";
2150        let result = converge_queue_auto(doc, false).expect("tag changed");
2151        assert_eq!(
2152            result,
2153            "<!-- agent:queue -->\n- do [#x]\n<!-- /agent:queue -->\n"
2154        );
2155    }
2156
2157    #[test]
2158    fn converge_queue_auto_adds_auto() {
2159        let doc = "<!-- agent:queue -->\n- do [#x]\n<!-- /agent:queue -->\n";
2160        let result = converge_queue_auto(doc, true).expect("tag changed");
2161        assert_eq!(
2162            result,
2163            "<!-- agent:queue auto -->\n- do [#x]\n<!-- /agent:queue -->\n"
2164        );
2165    }
2166
2167    #[test]
2168    fn converge_queue_auto_preserves_other_attrs() {
2169        let doc = "<!-- agent:queue auto patch=append -->\n- do [#x]\n<!-- /agent:queue -->\n";
2170        let result = converge_queue_auto(doc, false).expect("tag changed");
2171        assert_eq!(
2172            result,
2173            "<!-- agent:queue patch=append -->\n- do [#x]\n<!-- /agent:queue -->\n"
2174        );
2175    }
2176
2177    #[test]
2178    fn converge_queue_auto_normalizes_boolean_attrs_without_corrupting_preset() {
2179        let doc = concat!(
2180            "<!-- agent:queue auto=true priority=true preset=\"#spec-test-build-install-commit-push\"=true go=true -->\n",
2181            "- do [#x]\n",
2182            "<!-- /agent:queue -->\n",
2183        );
2184        let result = converge_queue_auto(doc, false).expect("tag normalized");
2185        assert_eq!(
2186            result,
2187            concat!(
2188                "<!-- agent:queue priority preset=\"#spec-test-build-install-commit-push\" go -->\n",
2189                "- do [#x]\n",
2190                "<!-- /agent:queue -->\n",
2191            )
2192        );
2193    }
2194
2195    #[test]
2196    fn converge_queue_auto_noop_when_already_matching() {
2197        let active = "<!-- agent:queue auto -->\n- do [#x]\n<!-- /agent:queue -->\n";
2198        assert_eq!(converge_queue_auto(active, true), None);
2199        let inactive = "<!-- agent:queue -->\n- do [#x]\n<!-- /agent:queue -->\n";
2200        assert_eq!(converge_queue_auto(inactive, false), None);
2201    }
2202
2203    #[test]
2204    fn converge_queue_auto_none_without_queue_component() {
2205        let doc = "<!-- agent:exchange -->\nhi\n<!-- /agent:exchange -->\n";
2206        assert_eq!(converge_queue_auto(doc, false), None);
2207    }
2208
2209    // #prompt-duplicated-while-typing: dedup must be agnostic to the `❯ ` user-prompt
2210    // prefix, since a synthesized exchange patch and the live buffer can differ only
2211    // by it. Otherwise the prompt re-appends and duplicates while typing.
2212    #[test]
2213    fn append_patch_already_present_ignores_user_prompt_prefix() {
2214        assert!(
2215            append_patch_already_present("expand the section", "❯ expand the section"),
2216            "bare buffer vs prefixed patch must dedup"
2217        );
2218        assert!(
2219            append_patch_already_present("❯ expand the section", "expand the section"),
2220            "prefixed buffer vs bare patch must dedup"
2221        );
2222        assert!(
2223            append_patch_already_present("❯ expand the section", "❯ expand the section"),
2224            "both prefixed must dedup"
2225        );
2226    }
2227
2228    #[test]
2229    fn append_patch_distinct_prompts_not_deduped() {
2230        assert!(
2231            !append_patch_already_present("❯ expand the section", "❯ summarize the section"),
2232            "genuinely distinct prompts must not be treated as duplicates"
2233        );
2234    }
2235
2236    // #prompt-duplicated-while-typing (L1 structural buffer-snapshot race),
2237    // captured live in tasks/resume.md 2026-05-29: the synthesized patch held a
2238    // half-typed snapshot ("- [#s93y]: Add to") while the live buffer had been
2239    // typed further ("- [#s93y]: Add to resume"). The two regions are identical
2240    // except that one line; the old `contains` check broke at the divergence and
2241    // re-appended the entire tail. Dedup must recognize the snapshot relationship.
2242    #[test]
2243    fn append_patch_dedupes_midblock_typing_snapshot() {
2244        let live = concat!(
2245            "- [#yfg1]: I dropped the real-time asset-generation. Kept the investor demos.\n",
2246            "- [#bndq]: confirmed\n",
2247            "- do [#s75b]\n",
2248            "- do [#vapk]\n",
2249            "- [#s93y]: Add to resume\n",
2250            "- [#ca8w]\n",
2251            "Add MonsterRodHolders and EFS to the resume if not already added.",
2252        );
2253        let snapshot = concat!(
2254            "- [#yfg1]: I dropped the real-time asset-generation. Kept the investor demos.\n",
2255            "- [#bndq]: confirmed\n",
2256            "- do [#s75b]\n",
2257            "- do [#vapk]\n",
2258            "- [#s93y]: Add to\n",
2259            "- [#ca8w]\n",
2260            "Add MonsterRodHolders and EFS to the resume if not already added.",
2261        );
2262        assert!(
2263            append_patch_already_present(live, snapshot),
2264            "earlier keystroke snapshot must dedup against the live, further-typed region"
2265        );
2266    }
2267
2268    #[test]
2269    fn append_patch_typing_snapshot_does_not_collapse_distinct_lines() {
2270        // A genuinely distinct prompt block (not a prefix snapshot) must still
2271        // append — the divergent line is not a prefix of the live line.
2272        let live = "- do task one\n- and the second thing\n- finally";
2273        let distinct = "- do task one\n- and a different thing\n- finally";
2274        assert!(
2275            !append_patch_already_present(live, distinct),
2276            "a mid-line replacement (not a prefix) is a distinct edit, not a snapshot"
2277        );
2278    }
2279
2280    #[test]
2281    fn append_with_boundary_does_not_duplicate_typing_snapshot() {
2282        // The boundary stays high in the exchange; the live region below it has
2283        // been typed past the synthesized patch's snapshot. Appending the stale
2284        // snapshot must be a full no-op (document unchanged), not a duplication.
2285        let doc = concat!(
2286            "<!-- agent:exchange patch=append -->\n",
2287            "### Re: prior — opus-4-8\n\n",
2288            "Answer.\n",
2289            "<!-- agent:boundary:70bccf9b -->\n",
2290            "- [#yfg1]: ok\n",
2291            "- [#s93y]: Add to resume\n",
2292            "- [#ca8w]\n",
2293            "Add MonsterRodHolders and EFS to the resume if not already added.\n",
2294            "<!-- /agent:exchange -->\n",
2295        );
2296        let components = parse(doc).unwrap();
2297        let exchange = components.iter().find(|c| c.name == "exchange").unwrap();
2298        let snapshot = concat!(
2299            "- [#yfg1]: ok\n",
2300            "- [#s93y]: Add to\n",
2301            "- [#ca8w]\n",
2302            "Add MonsterRodHolders and EFS to the resume if not already added.",
2303        );
2304        let result = exchange.append_with_boundary(doc, snapshot, "70bccf9b");
2305        assert_eq!(
2306            result, doc,
2307            "stale typing-snapshot append must be a no-op:\n{result}"
2308        );
2309        assert_eq!(
2310            result.matches("<!-- /agent:exchange -->").count(),
2311            1,
2312            "exchange close marker must not duplicate:\n{result}"
2313        );
2314    }
2315
2316    #[test]
2317    fn append_with_caret_does_not_duplicate_prefixed_prompt() {
2318        // The live buffer already holds the bare typed prompt after the boundary; a
2319        // synthesized exchange patch carries the same prompt with the `❯ ` prefix.
2320        // Appending must be a no-op (single copy), not a duplicate.
2321        let doc = concat!(
2322            "<!-- agent:exchange patch=append -->\n",
2323            "### Re: prior — opus-4-8\n\n",
2324            "Answer.\n",
2325            "<!-- agent:boundary:abc12345 -->\n",
2326            "expand the Confidential AI Startup section\n",
2327            "<!-- /agent:exchange -->\n",
2328        );
2329        let components = parse(doc).unwrap();
2330        let exchange = components.iter().find(|c| c.name == "exchange").unwrap();
2331        let result =
2332            exchange.append_with_caret(doc, "❯ expand the Confidential AI Startup section", None);
2333        assert_eq!(
2334            result
2335                .matches("expand the Confidential AI Startup section")
2336                .count(),
2337            1,
2338            "prefixed synthesized patch must not duplicate the already-typed prompt:\n{result}"
2339        );
2340    }
2341
2342    // --- #dupcontent: structural_corruption_reason ---
2343
2344    const CLEAN_DOC: &str = "<!-- agent:status -->\nok\n<!-- /agent:status -->\n\
2345<!-- agent:exchange -->\nq\n<!-- /agent:exchange -->\n\
2346<!-- agent:queue preset=\"#spec-test-commit-push\" priority go -->\n- do [#x]\n<!-- /agent:queue -->\n\
2347<!-- agent:backlog -->\n- [ ] [#y] thing\n<!-- /agent:backlog -->\n";
2348
2349    #[test]
2350    fn structural_corruption_clean_doc_is_sound() {
2351        assert_eq!(structural_corruption_reason(CLEAN_DOC), None);
2352    }
2353
2354    #[test]
2355    fn structural_corruption_flags_duplicate_singleton_queue() {
2356        // The live #dupcontent corruption: two agent:queue blocks ingested from
2357        // a bad content_ours CRDT merge.
2358        let doc = "<!-- agent:queue -->\n- a\n<!-- /agent:queue -->\n\
2359<!-- agent:exchange -->\nq\n<!-- /agent:exchange -->\n\
2360<!-- agent:queue preset=\"#spec-test-commit-push\" priority go -->\n- b\n<!-- /agent:queue -->\n";
2361        let reason = structural_corruption_reason(doc).expect("two queue blocks must be flagged");
2362        assert!(
2363            reason.contains("duplicate_singleton_component") && reason.contains("queue=2"),
2364            "reason was: {reason}"
2365        );
2366    }
2367
2368    #[test]
2369    fn structural_corruption_flags_backlog_alias_duplicate() {
2370        // Legacy `pending` alias + canonical `backlog` collapse to one singleton.
2371        let doc = "<!-- agent:backlog -->\n- [ ] [#a] x\n<!-- /agent:backlog -->\n\
2372<!-- agent:pending -->\n- [ ] [#b] y\n<!-- /agent:pending -->\n";
2373        let reason = structural_corruption_reason(doc).expect("backlog+pending must be flagged");
2374        assert!(reason.contains("backlog=2"), "reason was: {reason}");
2375    }
2376
2377    #[test]
2378    fn structural_corruption_flags_unterminated_quote_attr() {
2379        // The truncated merge-boundary marker `<!-- agent:queue tasks=" -->`.
2380        let doc = "<!-- agent:exchange -->\nq\n<!-- /agent:exchange -->\n\
2381<!-- agent:queue tasks=\" -->\n- a\n<!-- /agent:queue -->\n";
2382        let reason =
2383            structural_corruption_reason(doc).expect("unterminated quote attr must be flagged");
2384        assert!(
2385            reason.starts_with("malformed_attr:queue"),
2386            "reason was: {reason}"
2387        );
2388    }
2389
2390    #[test]
2391    fn structural_corruption_allows_balanced_quoted_attr() {
2392        // A real queue marker carrying a balanced quoted attribute is sound.
2393        let doc = "<!-- agent:queue preset=\"#a\" go -->\n- a\n<!-- /agent:queue -->\n";
2394        assert_eq!(structural_corruption_reason(doc), None);
2395    }
2396
2397    #[test]
2398    fn structural_corruption_allows_repeated_non_singleton() {
2399        // `log` is not a singleton; repeats must NOT be flagged.
2400        let doc = "<!-- agent:log -->\na\n<!-- /agent:log -->\n\
2401<!-- agent:log -->\nb\n<!-- /agent:log -->\n";
2402        assert_eq!(structural_corruption_reason(doc), None);
2403    }
2404
2405    #[test]
2406    fn structural_corruption_flags_parse_failure() {
2407        // Unclosed marker → parse error → corrupt.
2408        let doc = "<!-- agent:queue -->\n- a\n";
2409        let reason = structural_corruption_reason(doc).expect("unclosed marker must be flagged");
2410        assert!(reason.starts_with("parse_error"), "reason was: {reason}");
2411    }
2412}