Skip to main content

agent_doc/
template.rs

1//! # Module: template
2//!
3//! Template-mode support for in-place response documents. Parses structured patch
4//! blocks from agent responses and applies them to named component slots in the
5//! document, with boundary marker lifecycle management for CRDT-safe stream writes.
6//!
7//! ## Spec
8//! - `parse_patches`: Scans agent response text for `<!-- patch:name -->...<!-- /patch:name -->`
9//!   blocks and returns a list of `PatchBlock` values plus any unmatched text (content outside
10//!   patch blocks). Markers inside fenced code blocks (``` or ~~~) and inline code spans are
11//!   ignored so examples in responses are never mis-parsed as real patches.
12//! - `apply_patches`: Delegates to `apply_patches_with_overrides` with an empty override map.
13//!   Applies parsed patches to matching `<!-- agent:name -->` components in the document.
14//! - `apply_patches_with_overrides`: Core patch application pipeline:
15//!   1. Pre-patch: strips all existing boundary markers, inserts a fresh boundary at the end
16//!      of the `exchange` component (keyed to the file stem).
17//!   2. Applies each patch using mode resolution: stream overrides > inline attr (`patch=` or
18//!      `mode=`) > `.agent-doc/config.toml ([components] section)` > built-in defaults (`exchange`/`findings`
19//!      default to `append`, all others to `replace`).
20//!   3. For `append` mode, uses boundary-aware insertion when a boundary marker exists.
21//!   4. Patches targeting missing component names are routed as overflow to `exchange`/`output`.
22//!   5. Unmatched text and overflow are merged and appended to `exchange`/`output` (or an
23//!      auto-created `exchange` component if none exists).
24//!   6. Post-patch: if the boundary was consumed, re-inserts a fresh one at the end of exchange.
25//! - `reposition_boundary_to_end`: Removes all boundaries and inserts a new one at the end of
26//!   `exchange`. Used by the IPC write path to keep boundary position current.
27//! - `reposition_boundary_to_end_with_summary`: Same, with optional human-readable suffix on
28//!   the boundary ID (e.g. `a0cfeb34:agent-doc`).
29//! - `template_info`: Reads a document file, resolves its template mode flag, and returns a
30//!   serializable `TemplateInfo` with per-component name, resolved mode, content, and line
31//!   number. Used by editor plugins for rendering.
32//! - `is_template_mode` (test-only): Legacy helper to detect `mode = "template"` string.
33//!
34//! ## Agentic Contracts
35//! - `parse_patches` is pure and infallible for valid UTF-8; it returns `Ok` even for empty
36//!   or patch-free responses.
37//! - Patch markers inside fenced code blocks are never extracted as real patches. Agents may
38//!   include example markers in code blocks without triggering unintended writes.
39//! - Component patches are applied in reverse document order so earlier byte offsets remain
40//!   valid throughout the operation.
41//! - A boundary marker always exists at the end of `exchange` after `apply_patches_with_overrides`
42//!   returns. Callers that perform incremental (CRDT/stream) writes may rely on this invariant.
43//! - Missing-component patches never cause errors; content is silently routed to `exchange`/
44//!   `output` with a diagnostic written to stderr.
45//! - Mode precedence is deterministic: stream override > inline attr (`patch=` > `mode=`) >
46//!   `config.toml ([components] section)` (`patch` key > `mode` key) > built-in default. Callers can rely on this
47//!   ordering when constructing overrides for stream mode.
48//! - `template_info` reads the document from disk; callers must ensure the file is flushed
49//!   before calling (especially in the IPC write path).
50//!
51//! ## Evals
52//! - `parse_single_patch`: single patch block → one `PatchBlock`, empty unmatched
53//! - `parse_multiple_patches`: two sequential patch blocks → two `PatchBlock`s in order, empty unmatched
54//! - `parse_with_unmatched_content`: text before and after patch block → unmatched contains both text segments
55//! - `parse_empty_response`: empty string → zero patches, empty unmatched
56//! - `parse_no_patches`: plain text with no markers → zero patches, full text in unmatched
57//! - `parse_patches_ignores_markers_in_fenced_code_block`: agent:component markers inside ``` are preserved as content
58//! - `parse_patches_ignores_patch_markers_in_fenced_code_block`: nested patch markers inside ``` are not parsed as patches
59//! - `parse_patches_ignores_markers_in_tilde_fence`: patch markers inside ~~~ are ignored
60//! - `parse_patches_ignores_closing_marker_in_code_block`: closing marker inside code block is skipped; real close is found
61//! - `parse_patches_normal_markers_still_work`: sanity — two back-to-back patches parse correctly
62//! - `apply_patches_replace`: patch to non-exchange component replaces existing content
63//! - `apply_patches_unmatched_creates_exchange`: unmatched text auto-creates `<!-- agent:exchange -->` when absent
64//! - `apply_patches_unmatched_appends_to_existing_exchange`: unmatched text appends to existing exchange; no duplicate component
65//! - `apply_patches_missing_component_routes_to_exchange`: patch targeting unknown component name appears in exchange
66//! - `apply_patches_missing_component_creates_exchange`: missing component + no exchange → auto-creates exchange with overflow
67//! - `inline_attr_mode_overrides_config`: `mode=replace` on tag wins over `config.toml ([components] section)` append config
68//! - `inline_attr_mode_overrides_default`: `mode=replace` on exchange wins over built-in append default
69//! - `no_inline_attr_falls_back_to_config`: no inline attr → `config.toml ([components] section)` append config applies
70//! - `no_inline_attr_no_config_falls_back_to_default`: no attr, no config → exchange defaults to append
71//! - `inline_patch_attr_overrides_config`: `patch=replace` on tag wins over `config.toml ([components] section)` append config
72//! - `template_info_works`: template-mode doc → `TemplateInfo.template_mode = true`, component list populated
73//! - `template_info_legacy_mode_works`: `response_mode: template` frontmatter key recognized
74//! - `template_info_append_mode`: non-template doc → `template_mode = false`, empty component list
75//! - `is_template_mode_detection`: `Some("template")` → true; other strings and `None` → false
76//! - (aspirational) `apply_patches_boundary_invariant`: after any apply_patches call with an exchange component, a boundary marker exists at end of exchange
77//! - (aspirational) `reposition_boundary_removes_stale`: multiple stale boundaries are reduced to exactly one at end of exchange
78
79use anyhow::{Context, Result};
80use serde::Serialize;
81use std::path::Path;
82
83use crate::component::{self, find_comment_end, Component};
84use crate::project_config;
85
86/// A parsed patch directive from an agent response.
87#[derive(Debug, Clone)]
88pub struct PatchBlock {
89    pub name: String,
90    pub content: String,
91    /// Attributes from the patch marker (e.g., `transfer-source="path"`).
92    #[allow(dead_code)]
93    pub attrs: std::collections::HashMap<String, String>,
94}
95
96impl PatchBlock {
97    /// Create a PatchBlock with no attributes.
98    pub fn new(name: impl Into<String>, content: impl Into<String>) -> Self {
99        PatchBlock {
100            name: name.into(),
101            content: content.into(),
102            attrs: std::collections::HashMap::new(),
103        }
104    }
105}
106
107/// Template info output for plugins.
108#[derive(Debug, Serialize)]
109pub struct TemplateInfo {
110    pub template_mode: bool,
111    pub components: Vec<ComponentInfo>,
112}
113
114/// Per-component info for plugin rendering.
115#[derive(Debug, Serialize)]
116pub struct ComponentInfo {
117    pub name: String,
118    pub mode: String,
119    pub content: String,
120    pub line: usize,
121    #[serde(skip_serializing_if = "Option::is_none")]
122    pub max_entries: Option<usize>,
123}
124
125/// Check if a document is in template mode (deprecated — use `fm.resolve_mode().is_template()`).
126#[cfg(test)]
127pub fn is_template_mode(mode: Option<&str>) -> bool {
128    matches!(mode, Some("template"))
129}
130
131/// Parse `<!-- patch:name -->...<!-- /patch:name -->` blocks from an agent response.
132///
133/// Content outside patch blocks is collected as "unmatched" and returned separately.
134/// Markers inside fenced code blocks (``` or ~~~) and inline code spans are ignored.
135///
136/// Also accepts the canonical `<!-- replace:pending -->...<!-- /replace:pending -->`
137/// form as a synonym for `<!-- patch:pending -->...<!-- /patch:pending -->`. The
138/// `replace:` prefix signals full-replacement semantics and is the canonical name
139/// for pending mutations going forward. `patch:pending` is still parsed for one
140/// release with a deprecation warning emitted to stderr. See #25ag.
141pub fn parse_patches(response: &str) -> Result<(Vec<PatchBlock>, String)> {
142    let bytes = response.as_bytes();
143    let len = bytes.len();
144    let code_ranges = component::find_code_ranges(response);
145    let mut patches = Vec::new();
146    let mut unmatched = String::new();
147    let mut pos = 0;
148    let mut last_end = 0;
149
150    while pos + 4 <= len {
151        if &bytes[pos..pos + 4] != b"<!--" {
152            pos += 1;
153            continue;
154        }
155
156        // Skip markers inside code regions
157        if code_ranges.iter().any(|&(start, end)| pos >= start && pos < end) {
158            pos += 4;
159            continue;
160        }
161
162        let marker_start = pos;
163
164        // Find closing -->
165        let close = match find_comment_end(bytes, pos + 4) {
166            Some(c) => c,
167            None => {
168                pos += 4;
169                continue;
170            }
171        };
172
173        let inner = &response[marker_start + 4..close - 3];
174        let trimmed = inner.trim();
175
176        // Recognize two prefix forms:
177        //   - `patch:<name>`     — original form (deprecated for pending component)
178        //   - `replace:pending`  — canonical form for the pending component (#25ag)
179        let parsed_prefix: Option<(&str, &str)> = if let Some(rest) = trimmed.strip_prefix("patch:") {
180            Some(("patch", rest))
181        } else if let Some(rest) = trimmed.strip_prefix("replace:") {
182            // Only `replace:pending` is accepted. Other `replace:*` names fall
183            // through as unmatched to avoid silently broadening the grammar.
184            let rest_trim = rest.trim_start();
185            let name_end = rest_trim
186                .find(|c: char| c.is_whitespace())
187                .unwrap_or(rest_trim.len());
188            if &rest_trim[..name_end] == "pending" {
189                Some(("replace", rest))
190            } else {
191                None
192            }
193        } else {
194            None
195        };
196
197        if let Some((prefix_kind, rest)) = parsed_prefix {
198            let rest = rest.trim();
199            if rest.is_empty() || rest.starts_with('/') {
200                pos = close;
201                continue;
202            }
203
204            // Split name from attributes: "exchange transfer-source=path" -> ("exchange", attrs)
205            let (name, attrs) = if let Some(space_idx) = rest.find(char::is_whitespace) {
206                let name = &rest[..space_idx];
207                let attr_text = rest[space_idx..].trim();
208                (name, component::parse_attrs(attr_text))
209            } else {
210                (rest, std::collections::HashMap::new())
211            };
212
213            // Deprecation warning: `patch:pending` is deprecated in favor of
214            // `replace:pending`. Warn once per parse call on first occurrence.
215            if prefix_kind == "patch" && name == "pending" {
216                eprintln!(
217                    "warning: `<!-- patch:pending -->` is deprecated — use `<!-- replace:pending -->` instead (see #25ag)"
218                );
219            }
220
221            // Consume trailing newline after opening marker
222            let mut content_start = close;
223            if content_start < len && bytes[content_start] == b'\n' {
224                content_start += 1;
225            }
226
227            // Collect unmatched text before this patch block
228            let before = &response[last_end..marker_start];
229            let trimmed_before = before.trim();
230            if !trimmed_before.is_empty() {
231                if !unmatched.is_empty() {
232                    unmatched.push('\n');
233                }
234                unmatched.push_str(trimmed_before);
235            }
236
237            // Find the matching close: <!-- /<prefix>:name --> (skipping code blocks).
238            // The close must use the same prefix as the open.
239            let close_marker = format!("<!-- /{}:{} -->", prefix_kind, name);
240            if let Some(close_pos) = find_outside_code(&close_marker, response, content_start, &code_ranges) {
241                let content = &response[content_start..close_pos];
242                patches.push(PatchBlock {
243                    name: name.to_string(),
244                    content: content.to_string(),
245                    attrs,
246                });
247
248                let mut end = close_pos + close_marker.len();
249                if end < len && bytes[end] == b'\n' {
250                    end += 1;
251                }
252                last_end = end;
253                pos = end;
254                continue;
255            }
256
257            // No matching close found — consume the orphaned opening marker
258            // so it doesn't leak into unmatched text (#p2xm).
259            let mut orphan_end = close;
260            if orphan_end < len && bytes[orphan_end] == b'\n' {
261                orphan_end += 1;
262            }
263            last_end = orphan_end;
264            pos = orphan_end;
265            continue;
266        }
267
268        pos = close;
269    }
270
271    // Collect any trailing unmatched text
272    if last_end < len {
273        let trailing = response[last_end..].trim();
274        if !trailing.is_empty() {
275            if !unmatched.is_empty() {
276                unmatched.push('\n');
277            }
278            unmatched.push_str(trailing);
279        }
280    }
281
282    Ok((patches, unmatched))
283}
284
285/// Apply patch blocks to a document's components.
286///
287/// For each patch block, finds the matching `<!-- agent:name -->` component
288/// and replaces its content. Uses patch.rs mode logic (replace/append/prepend)
289/// based on `.agent-doc/config.toml ([components] section)` config.
290///
291/// Returns the modified document. Unmatched content (outside patch blocks)
292/// is appended to `<!-- agent:output -->` if it exists, or creates one at the end.
293pub fn apply_patches(doc: &str, patches: &[PatchBlock], unmatched: &str, file: &Path) -> Result<String> {
294    apply_patches_with_overrides(doc, patches, unmatched, file, &std::collections::HashMap::new())
295}
296
297/// Strip trailing bare `❯` lines from exchange-bound content.
298///
299/// `❯` is the user's submit-prompt glyph. When an agent ends a response with a bare
300/// `❯` line, the post-patch boundary marker lands directly under it, producing a
301/// phantom prompt row on every cycle. This is a code-enforced invariant (see
302/// `runbooks/code-enforced-directives.md`): the binary strips trailing bare-`❯`
303/// lines so agents cannot produce the bug even if they forget the rule.
304///
305/// Only strips lines that contain nothing but `❯` and whitespace. `❯` appearing
306/// inside content lines (e.g. `❯ How do I…`) is preserved. Multiple trailing bare
307/// lines collapse to zero.
308pub(crate) fn strip_trailing_caret_lines(content: &str) -> String {
309    let trailing_nl = content.ends_with('\n');
310    let mut lines: Vec<&str> = content.split('\n').collect();
311    // split('\n') on a trailing-newline string yields an empty final element; ignore
312    // it so we consider only real trailing lines.
313    if trailing_nl {
314        lines.pop();
315    }
316    while let Some(last) = lines.last() {
317        let t = last.trim();
318        if t == "❯" {
319            lines.pop();
320        } else {
321            break;
322        }
323    }
324    let mut out = lines.join("\n");
325    if trailing_nl {
326        out.push('\n');
327    }
328    out
329}
330
331/// Apply patches with per-component mode overrides (e.g., stream mode forces "replace"
332/// for cumulative buffers even on append-mode components like exchange).
333pub fn apply_patches_with_overrides(
334    doc: &str,
335    patches: &[PatchBlock],
336    unmatched: &str,
337    file: &Path,
338    mode_overrides: &std::collections::HashMap<String, String>,
339) -> Result<String> {
340    // Pre-patch: ensure a fresh boundary exists in the exchange component.
341    // Remove any stale boundaries from previous cycles, then insert a new one
342    // at the end of the exchange. This is deterministic — belongs in the binary,
343    // not the SKILL workflow.
344    let summary = file.file_stem().and_then(|s| s.to_str());
345    let mut result = remove_all_boundaries(doc);
346    if let Ok(components) = component::parse(&result)
347        && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
348    {
349        let id = crate::new_boundary_id_with_summary(summary);
350        let marker = crate::format_boundary_marker(&id);
351        let content = exchange.content(&result);
352        let new_content = format!("{}\n{}\n", content.trim_end(), marker);
353        result = exchange.replace_content(&result, &new_content);
354        eprintln!("[template] pre-patch boundary {} inserted at end of exchange", id);
355    }
356
357    // Apply patches in reverse order (by position) to preserve byte offsets
358    let components = component::parse(&result)
359        .context("failed to parse components")?;
360
361    // Load component configs
362    let configs = load_component_configs(file);
363
364    // Build a list of (component_index, patch) pairs, sorted by component position descending.
365    // Patches targeting missing components are collected as overflow and routed to
366    // exchange/output (same as unmatched content) — this avoids silent failures when
367    // the agent uses a wrong component name.
368    let mut ops: Vec<(usize, &PatchBlock)> = Vec::new();
369    let mut overflow = String::new();
370    for patch in patches {
371        if let Some(idx) = components.iter().position(|c| c.name == patch.name) {
372            ops.push((idx, patch));
373        } else {
374            let available: Vec<&str> = components.iter().map(|c| c.name.as_str()).collect();
375            eprintln!(
376                "[template] patch target '{}' not found, routing to exchange/output. Available: {}",
377                patch.name,
378                available.join(", ")
379            );
380            if !overflow.is_empty() {
381                overflow.push('\n');
382            }
383            overflow.push_str(&patch.content);
384        }
385    }
386
387    // Sort by position descending so replacements don't shift earlier offsets
388    ops.sort_by(|a, b| b.0.cmp(&a.0));
389
390    for (idx, patch) in &ops {
391        let comp = &components[*idx];
392        // Mode precedence: stream overrides > inline attr > config.toml ([components] section) > built-in default
393        let mode = mode_overrides.get(&patch.name)
394            .map(|s| s.as_str())
395            .or_else(|| comp.patch_mode())
396            .or_else(|| configs.get(&patch.name).map(|s| s.as_str()))
397            .unwrap_or_else(|| default_mode(&patch.name));
398        // Strip trailing bare `❯` lines for exchange-bound patches so a phantom
399        // prompt row never lands above the post-patch boundary marker.
400        let patch_content: std::borrow::Cow<'_, str> = if patch.name == "exchange" {
401            std::borrow::Cow::Owned(strip_trailing_caret_lines(&patch.content))
402        } else {
403            std::borrow::Cow::Borrowed(patch.content.as_str())
404        };
405        // For append mode, use boundary-aware insertion when a marker exists
406        if mode == "append"
407            && let Some(bid) = find_boundary_in_component(&result, comp)
408        {
409            result = comp.append_with_boundary(&result, &patch_content, &bid);
410            continue;
411        }
412        let new_content = apply_mode(mode, comp.content(&result), &patch_content);
413        result = comp.replace_content(&result, &new_content);
414    }
415
416    // Merge overflow (from missing-component patches) with unmatched content
417    let mut all_unmatched = String::new();
418    if !overflow.is_empty() {
419        all_unmatched.push_str(&overflow);
420    }
421    if !unmatched.is_empty() {
422        if !all_unmatched.is_empty() {
423            all_unmatched.push('\n');
424        }
425        all_unmatched.push_str(unmatched);
426    }
427
428    // Handle unmatched content
429    if !all_unmatched.is_empty() {
430        // Re-parse after patches applied
431        let components = component::parse(&result)
432            .context("failed to re-parse components after patching")?;
433
434        if let Some(output_comp) = components.iter().find(|c| c.name == "exchange" || c.name == "output") {
435            // Unmatched content lands in exchange/output — strip trailing bare `❯`
436            // lines so a phantom prompt row never precedes the boundary marker.
437            let stripped = if output_comp.name == "exchange" {
438                strip_trailing_caret_lines(&all_unmatched)
439            } else {
440                all_unmatched.clone()
441            };
442            let unmatched = &stripped;
443            // Try boundary-aware append first (preserves prompt ordering)
444            if let Some(bid) = find_boundary_in_component(&result, output_comp) {
445                eprintln!("[template] unmatched content: using boundary {} for insertion", &bid[..bid.len().min(8)]);
446                result = output_comp.append_with_boundary(&result, unmatched, &bid);
447            } else {
448                // No boundary — plain append to exchange/output component
449                let existing = output_comp.content(&result);
450                let new_content = if existing.trim().is_empty() {
451                    format!("{}\n", unmatched)
452                } else {
453                    format!("{}{}\n", existing, unmatched)
454                };
455                result = output_comp.replace_content(&result, &new_content);
456            }
457        } else {
458            // Auto-create exchange component at the end — always strip trailing `❯`.
459            let stripped = strip_trailing_caret_lines(&all_unmatched);
460            if !result.ends_with('\n') {
461                result.push('\n');
462            }
463            result.push_str("\n<!-- agent:exchange -->\n");
464            result.push_str(&stripped);
465            result.push_str("\n<!-- /agent:exchange -->\n");
466        }
467    }
468
469    // Post-patch: remove consecutive duplicate lines from exchange (prevents agent
470    // echo of user prompt when patch content starts with already-appended content).
471    result = dedup_exchange_adjacent_lines(&result);
472
473    // Post-patch: apply max_lines trimming to components that have it configured.
474    // Precedence: inline attr > config.toml ([components] section) > unlimited (0).
475    // Re-parse after each replacement (offsets change) and iterate up to 3 times
476    // until stable — trimming one component cannot grow another, so 2 passes suffice
477    // in practice; the third is a safety bound.
478    {
479        let max_lines_configs = load_max_lines_configs(file);
480        'stability: for _ in 0..3 {
481            let Ok(components) = component::parse(&result) else { break };
482            for comp in &components {
483                let max_lines = comp
484                    .attrs
485                    .get("max_lines")
486                    .and_then(|s| s.parse::<usize>().ok())
487                    .or_else(|| max_lines_configs.get(&comp.name).copied())
488                    .unwrap_or(0);
489                if max_lines > 0 {
490                    let content = comp.content(&result);
491                    let trimmed = limit_lines(content, max_lines);
492                    if trimmed.len() != content.len() {
493                        let trimmed = format!("{}\n", trimmed.trim_end());
494                        result = comp.replace_content(&result, &trimmed);
495                        // Re-parse from scratch — offsets are now stale.
496                        continue 'stability;
497                    }
498                }
499            }
500            break; // No component needed trimming — stable.
501        }
502    }
503
504    // Post-patch: ensure a boundary exists at the end of the exchange component.
505    // This is unconditional for template docs with an exchange — the boundary must
506    // always exist for checkpoint writes to work. Checking the original doc's content
507    // causes a snowball: once one cycle loses the boundary, every subsequent cycle
508    // also loses it because the check always finds nothing.
509    {
510        if let Ok(components) = component::parse(&result)
511            && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
512            && find_boundary_in_component(&result, exchange).is_none()
513        {
514            // Boundary was consumed — re-insert at end of exchange
515            let id = uuid::Uuid::new_v4().to_string();
516            let marker = format!("<!-- agent:boundary:{} -->", id);
517            let content = exchange.content(&result);
518            let new_content = format!("{}\n{}\n", content.trim_end(), marker);
519            result = exchange.replace_content(&result, &new_content);
520            eprintln!("[template] re-inserted boundary {} at end of exchange", &id[..id.len().min(8)]);
521        }
522    }
523
524    Ok(result)
525}
526
527/// Reposition the boundary marker to the end of the exchange component.
528///
529/// Removes all existing boundaries and inserts a fresh one at the end of
530/// the exchange. This is the same pre-patch logic used in
531/// `apply_patches_with_overrides()`, extracted for use by the IPC write path.
532///
533/// Returns the document unchanged if no exchange component exists.
534pub fn reposition_boundary_to_end(doc: &str) -> String {
535    reposition_boundary_to_end_with_summary(doc, None)
536}
537
538/// Reposition boundary with an optional human-readable summary suffix.
539///
540/// The summary is slugified and appended to the boundary ID:
541/// `a0cfeb34:agent-doc` instead of just `a0cfeb34`.
542pub fn reposition_boundary_to_end_with_summary(doc: &str, summary: Option<&str>) -> String {
543    reposition_boundary_to_end_with_baseline(doc, summary, None)
544}
545
546/// Reposition boundary, with an optional set of baseline `### Re:` headings
547/// (typically extracted from git HEAD). When a baseline is supplied, every
548/// `### Re:` heading in the current exchange whose normalized text is NOT in
549/// the baseline is treated as "new this cycle" and receives a ` (HEAD)` suffix.
550/// Headings already present in the baseline are stripped of any stale
551/// ` (HEAD)` suffix. When the baseline is `None`, behavior matches the legacy
552/// `annotate_latest_re_heading_with_head` path: only the last `### Re:` heading
553/// gets the marker.
554pub fn reposition_boundary_to_end_with_baseline(
555    doc: &str,
556    summary: Option<&str>,
557    baseline_headings: Option<&std::collections::HashSet<String>>,
558) -> String {
559    let mut result = remove_all_boundaries(doc);
560    if let Ok(components) = component::parse(&result)
561        && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
562    {
563        let id = crate::new_boundary_id_with_summary(summary);
564        let marker = crate::format_boundary_marker(&id);
565        let content = exchange.content(&result).to_string();
566        let annotated = annotate_re_headings_with_head(&content, baseline_headings);
567        let new_content = format!("{}\n{}\n", annotated.trim_end(), marker);
568        result = exchange.replace_content(&result, &new_content);
569    }
570    result
571}
572
573/// Extract the set of stripped `### Re:` heading lines from the `exchange`
574/// component of a document. Used by the commit path to build a baseline of
575/// headings already present in `git HEAD` so the reposition step can mark all
576/// new-this-cycle headings (not just the last one) with ` (HEAD)`.
577///
578/// Returns an empty set if the document has no `exchange` component or no
579/// matching headings. Headings inside fenced code blocks are skipped.
580pub fn exchange_baseline_headings(doc: &str) -> std::collections::HashSet<String> {
581    if let Ok(components) = component::parse(doc)
582        && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
583    {
584        return collect_re_headings(exchange.content(doc));
585    }
586    std::collections::HashSet::new()
587}
588
589/// Collect normalized `### Re:` heading lines from a chunk of exchange content.
590/// Each entry is the heading line with any trailing ` (HEAD)` suffix and
591/// trailing whitespace removed. Headings inside fenced code blocks are skipped.
592fn collect_re_headings(content: &str) -> std::collections::HashSet<String> {
593    let code_ranges = component::find_code_ranges(content);
594    let in_code = |pos: usize| code_ranges.iter().any(|&(s, e)| pos >= s && pos < e);
595    let mut set = std::collections::HashSet::new();
596    let mut offset = 0usize;
597    for line in content.split_inclusive('\n') {
598        let line_start = offset;
599        offset += line.len();
600        if in_code(line_start) {
601            continue;
602        }
603        let body = line.trim_end_matches('\n').trim_end_matches('\r');
604        let trimmed = body.trim_start();
605        let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
606        if hash_count == 0 || hash_count > 6 {
607            continue;
608        }
609        let after_hash = &trimmed[hash_count..];
610        if !after_hash.starts_with(' ') {
611            continue;
612        }
613        if !after_hash.trim_start().starts_with("Re:") {
614            continue;
615        }
616        let stripped = body
617            .trim_start()
618            .trim_end()
619            .trim_end_matches(" (HEAD)")
620            .to_string();
621        set.insert(stripped);
622    }
623    set
624}
625
626/// Strip ` (HEAD)` suffix from all `### Re:` heading lines, then append
627/// ` (HEAD)` to every heading that is NEW relative to `baseline`. When
628/// `baseline` is `None`, the legacy behavior is preserved: only the last
629/// `### Re:` heading receives ` (HEAD)`. Leaves content unchanged if no such
630/// heading exists. Skips headings inside fenced code blocks.
631///
632/// This is the symmetric counterpart to `git::strip_head_markers`: this adds
633/// the marker on the working-tree / snapshot side; strip_head_markers removes
634/// it on the git-staging side so the committed blob stays clean.
635///
636/// Operates on exchange component content, where `### Re:` is the canonical
637/// response heading format (h3). We only touch `### Re:` headings — NOT bold
638/// pseudo-headers (`**Re: ...**`) — matching the SKILL.md response contract.
639///
640/// When baseline is supplied (typically from git HEAD via
641/// `exchange_baseline_headings`), a patchback containing multiple `### Re:`
642/// sections in a single cycle gets ` (HEAD)` appended to EVERY new heading,
643/// so every newly-added heading line shows as modified in the git gutter.
644pub(crate) fn annotate_re_headings_with_head(
645    content: &str,
646    baseline: Option<&std::collections::HashSet<String>>,
647) -> String {
648    let code_ranges = component::find_code_ranges(content);
649    let in_code = |pos: usize| code_ranges.iter().any(|&(s, e)| pos >= s && pos < e);
650
651    let mut lines: Vec<String> = content.split_inclusive('\n').map(|s| s.to_string()).collect();
652    let mut re_indices: Vec<usize> = Vec::new();
653    let mut offset = 0usize;
654
655    for (idx, line) in lines.iter_mut().enumerate() {
656        let line_start = offset;
657        offset += line.len();
658        if in_code(line_start) {
659            continue;
660        }
661        let had_newline = line.ends_with('\n');
662        let body_ref = line.trim_end_matches('\n').trim_end_matches('\r');
663        let trimmed = body_ref.trim_start();
664        let hash_count = trimmed.chars().take_while(|&c| c == '#').count();
665        if hash_count == 0 || hash_count > 6 {
666            continue;
667        }
668        let after_hash = &trimmed[hash_count..];
669        if !after_hash.starts_with(' ') {
670            continue;
671        }
672        if !after_hash.trim_start().starts_with("Re:") {
673            continue;
674        }
675        // Strip existing (HEAD) suffix (robust against trailing whitespace).
676        let stripped = body_ref.trim_end().trim_end_matches(" (HEAD)");
677        *line = if had_newline {
678            format!("{stripped}\n")
679        } else {
680            stripped.to_string()
681        };
682        re_indices.push(idx);
683    }
684
685    // Decide which heading lines receive (HEAD).
686    // - With baseline: every Re: heading whose normalized text is NOT in the
687    //   baseline set is treated as new this cycle and gets (HEAD). When the
688    //   baseline filter yields zero new headings (common: a turn that doesn't
689    //   open a new Re: section), fall back to marking the last Re: heading so
690    //   the working tree always retains a single "head" marker. Without this
691    //   fallback, the commit path strips the prior cycle's (HEAD) on line 613
692    //   and leaves nothing marked — regressing the visual head pointer.
693    // - Without baseline: legacy behavior — only the last Re: heading.
694    let mark_indices: Vec<usize> = match baseline {
695        Some(baseline_set) => {
696            let filtered: Vec<usize> = re_indices
697                .iter()
698                .copied()
699                .filter(|&idx| {
700                    let line = &lines[idx];
701                    let key = line
702                        .trim_end_matches('\n')
703                        .trim_end_matches('\r')
704                        .trim_start()
705                        .trim_end();
706                    !baseline_set.contains(key)
707                })
708                .collect();
709            if filtered.is_empty() {
710                re_indices.last().copied().into_iter().collect()
711            } else {
712                filtered
713            }
714        }
715        None => re_indices.last().copied().into_iter().collect(),
716    };
717
718    for idx in mark_indices {
719        let line = &lines[idx];
720        let had_newline = line.ends_with('\n');
721        let body = line.trim_end_matches('\n').trim_end_matches('\r');
722        lines[idx] = if had_newline {
723            format!("{body} (HEAD)\n")
724        } else {
725            format!("{body} (HEAD)")
726        };
727    }
728
729    lines.concat()
730}
731
732
733/// Remove all boundary markers from a document (line-level removal).
734/// Skips boundaries inside fenced code blocks (lesson #13).
735fn remove_all_boundaries(doc: &str) -> String {
736    let prefix = "<!-- agent:boundary:";
737    let suffix = " -->";
738    let code_ranges = component::find_code_ranges(doc);
739    let in_code = |pos: usize| code_ranges.iter().any(|&(start, end)| pos >= start && pos < end);
740    let mut result = String::with_capacity(doc.len());
741    let mut offset = 0;
742    for line in doc.lines() {
743        let trimmed = line.trim();
744        let is_boundary = trimmed.starts_with(prefix) && trimmed.ends_with(suffix);
745        if is_boundary && !in_code(offset) {
746            // Skip boundary marker lines outside code blocks
747            offset += line.len() + 1; // +1 for newline
748            continue;
749        }
750        result.push_str(line);
751        result.push('\n');
752        offset += line.len() + 1;
753    }
754    if !doc.ends_with('\n') && result.ends_with('\n') {
755        result.pop();
756    }
757    result
758}
759
760/// Find a boundary marker ID inside a component's content, skipping code blocks.
761fn find_boundary_in_component(doc: &str, comp: &Component) -> Option<String> {
762    let prefix = "<!-- agent:boundary:";
763    let suffix = " -->";
764    let content_region = &doc[comp.open_end..comp.close_start];
765    let code_ranges = component::find_code_ranges(doc);
766    let mut search_from = 0;
767    while let Some(start) = content_region[search_from..].find(prefix) {
768        let abs_start = comp.open_end + search_from + start;
769        if code_ranges.iter().any(|&(cs, ce)| abs_start >= cs && abs_start < ce) {
770            search_from += start + prefix.len();
771            continue;
772        }
773        let after_prefix = &content_region[search_from + start + prefix.len()..];
774        if let Some(end) = after_prefix.find(suffix) {
775            return Some(after_prefix[..end].trim().to_string());
776        }
777        break;
778    }
779    None
780}
781
782/// Get template info for a document (for plugin rendering).
783pub fn template_info(file: &Path) -> Result<TemplateInfo> {
784    let doc = std::fs::read_to_string(file)
785        .with_context(|| format!("failed to read {}", file.display()))?;
786
787    let (fm, _body) = crate::frontmatter::parse(&doc)?;
788    let template_mode = fm.resolve_mode().is_template();
789
790    let components = component::parse(&doc)
791        .with_context(|| format!("failed to parse components in {}", file.display()))?;
792
793    let configs = load_component_configs(file);
794
795    let component_infos: Vec<ComponentInfo> = components
796        .iter()
797        .map(|comp| {
798            let content = comp.content(&doc).to_string();
799            // Inline attr > config.toml ([components] section) > built-in default
800            let mode = comp.patch_mode().map(|s| s.to_string())
801                .or_else(|| configs.get(&comp.name).cloned())
802                .unwrap_or_else(|| default_mode(&comp.name).to_string());
803            // Compute line number from byte offset
804            let line = doc[..comp.open_start].matches('\n').count() + 1;
805            ComponentInfo {
806                name: comp.name.clone(),
807                mode,
808                content,
809                line,
810                max_entries: None, // TODO: read from config.toml ([components] section)
811            }
812        })
813        .collect();
814
815    Ok(TemplateInfo {
816        template_mode,
817        components: component_infos,
818    })
819}
820
821/// Load component mode configs from `.agent-doc/config.toml` (under [components] section).
822/// Returns a map of component_name → mode string.
823/// Resolves the project root by walking up from the document's parent directory.
824fn load_component_configs(file: &Path) -> std::collections::HashMap<String, String> {
825    let proj_cfg = load_project_from_doc(file);
826    proj_cfg
827        .components
828        .iter()
829        .map(|(name, cfg): (&String, &project_config::ComponentConfig)| (name.clone(), cfg.patch.clone()))
830        .collect()
831}
832
833/// Load max_lines settings from `.agent-doc/config.toml` (under [components] section).
834/// Resolves the project root by walking up from the document's parent directory.
835fn load_max_lines_configs(file: &Path) -> std::collections::HashMap<String, usize> {
836    let proj_cfg = load_project_from_doc(file);
837    proj_cfg
838        .components
839        .iter()
840        .filter(|(_, cfg)| cfg.max_lines > 0)
841        .map(|(name, cfg): (&String, &project_config::ComponentConfig)| (name.clone(), cfg.max_lines))
842        .collect()
843}
844
845/// Resolve project config by walking up from a document path to find `.agent-doc/config.toml`.
846fn load_project_from_doc(file: &Path) -> project_config::ProjectConfig {
847    let start = file.parent().unwrap_or(file);
848    let mut current = start;
849    loop {
850        let candidate = current.join(".agent-doc").join("config.toml");
851        if candidate.exists() {
852            return project_config::load_project_from(&candidate);
853        }
854        match current.parent() {
855            Some(p) if p != current => current = p,
856            _ => break,
857        }
858    }
859    // Fall back to CWD-based resolution
860    project_config::load_project()
861}
862
863/// Default mode for a component by name.
864/// `exchange` and `findings` default to `append`; all others default to `replace`.
865fn default_mode(name: &str) -> &'static str {
866    match name {
867        "exchange" | "findings" => "append",
868        _ => "replace",
869    }
870}
871
872/// Trim content to the last N lines.
873fn limit_lines(content: &str, max_lines: usize) -> String {
874    let lines: Vec<&str> = content.lines().collect();
875    if lines.len() <= max_lines {
876        return content.to_string();
877    }
878    lines[lines.len() - max_lines..].join("\n")
879}
880
881/// Remove consecutive identical non-blank lines in the exchange component.
882///
883/// Prevents agent echoes of user prompts from creating duplicates when
884/// `apply_mode("append")` concatenates existing content that already ends
885/// with the first line(s) of the new patch content.
886///
887/// Only non-blank lines are subject to deduplication — blank lines are
888/// intentional separators and are never collapsed.
889fn dedup_exchange_adjacent_lines(doc: &str) -> String {
890    let Ok(components) = component::parse(doc) else {
891        return doc.to_string();
892    };
893    let Some(exchange) = components.iter().find(|c| c.name == "exchange") else {
894        return doc.to_string();
895    };
896    let content = exchange.content(doc);
897    let mut deduped = String::with_capacity(content.len());
898    let mut prev_nonempty: Option<&str> = None;
899    for line in content.lines() {
900        if !line.trim().is_empty() && prev_nonempty == Some(line) {
901            // Skip exact duplicate adjacent non-blank line
902            continue;
903        }
904        deduped.push_str(line);
905        deduped.push('\n');
906        if !line.trim().is_empty() {
907            prev_nonempty = Some(line);
908        }
909    }
910    // Preserve original trailing-newline behaviour
911    if !content.ends_with('\n') && deduped.ends_with('\n') {
912        deduped.pop();
913    }
914    if deduped == content {
915        return doc.to_string();
916    }
917    exchange.replace_content(doc, &deduped)
918}
919
920/// Apply mode logic (replace/append/prepend).
921fn apply_mode(mode: &str, existing: &str, new_content: &str) -> String {
922    match mode {
923        "append" => {
924            let stripped = strip_leading_overlap(existing, new_content);
925            format!("{}{}", existing, stripped)
926        }
927        "prepend" => format!("{}{}", new_content, existing),
928        _ => new_content.to_string(), // "replace" default
929    }
930}
931
932/// Strip the last non-blank line of `existing` from the start of `new_content` if present.
933///
934/// When an agent echoes the user's last prompt as the first line of its patch,
935/// append mode would duplicate that line. This strips the overlap before concatenation.
936fn strip_leading_overlap<'a>(existing: &str, new_content: &'a str) -> &'a str {
937    let last_nonempty = existing.lines().rfind(|l| !l.trim().is_empty());
938    let Some(last) = last_nonempty else {
939        return new_content;
940    };
941    let test = format!("{}\n", last);
942    if new_content.starts_with(test.as_str()) {
943        &new_content[test.len()..]
944    } else {
945        new_content
946    }
947}
948
949#[allow(dead_code)]
950fn find_project_root(file: &Path) -> Option<std::path::PathBuf> {
951    let canonical = file.canonicalize().ok()?;
952    let mut dir = canonical.parent()?;
953    loop {
954        if dir.join(".agent-doc").is_dir() {
955            return Some(dir.to_path_buf());
956        }
957        dir = dir.parent()?;
958    }
959}
960
961/// Find `needle` in `haystack` starting at `from`, skipping occurrences inside code ranges.
962/// Returns the byte offset of the match within `haystack` (absolute, not relative to `from`).
963fn find_outside_code(needle: &str, haystack: &str, from: usize, code_ranges: &[(usize, usize)]) -> Option<usize> {
964    let mut search_start = from;
965    loop {
966        let rel = haystack[search_start..].find(needle)?;
967        let abs = search_start + rel;
968        if code_ranges.iter().any(|&(start, end)| abs >= start && abs < end) {
969            // Inside a code block — skip past this occurrence
970            search_start = abs + needle.len();
971            continue;
972        }
973        return Some(abs);
974    }
975}
976
977
978#[cfg(test)]
979mod tests {
980    use super::*;
981    use tempfile::TempDir;
982
983    fn setup_project() -> TempDir {
984        let dir = TempDir::new().unwrap();
985        std::fs::create_dir_all(dir.path().join(".agent-doc/snapshots")).unwrap();
986        dir
987    }
988
989    #[test]
990    fn parse_single_patch() {
991        let response = "<!-- patch:status -->\nBuild passing.\n<!-- /patch:status -->\n";
992        let (patches, unmatched) = parse_patches(response).unwrap();
993        assert_eq!(patches.len(), 1);
994        assert_eq!(patches[0].name, "status");
995        assert_eq!(patches[0].content, "Build passing.\n");
996        assert!(unmatched.is_empty());
997    }
998
999    #[test]
1000    fn parse_multiple_patches() {
1001        let response = "\
1002<!-- patch:status -->
1003All green.
1004<!-- /patch:status -->
1005
1006<!-- patch:log -->
1007- New entry
1008<!-- /patch:log -->
1009";
1010        let (patches, unmatched) = parse_patches(response).unwrap();
1011        assert_eq!(patches.len(), 2);
1012        assert_eq!(patches[0].name, "status");
1013        assert_eq!(patches[0].content, "All green.\n");
1014        assert_eq!(patches[1].name, "log");
1015        assert_eq!(patches[1].content, "- New entry\n");
1016        assert!(unmatched.is_empty());
1017    }
1018
1019    #[test]
1020    fn parse_with_unmatched_content() {
1021        let response = "Some free text.\n\n<!-- patch:status -->\nOK\n<!-- /patch:status -->\n\nTrailing text.\n";
1022        let (patches, unmatched) = parse_patches(response).unwrap();
1023        assert_eq!(patches.len(), 1);
1024        assert_eq!(patches[0].name, "status");
1025        assert!(unmatched.contains("Some free text."));
1026        assert!(unmatched.contains("Trailing text."));
1027    }
1028
1029    #[test]
1030    fn parse_empty_response() {
1031        let (patches, unmatched) = parse_patches("").unwrap();
1032        assert!(patches.is_empty());
1033        assert!(unmatched.is_empty());
1034    }
1035
1036    #[test]
1037    fn parse_no_patches() {
1038        let response = "Just a plain response with no patch blocks.";
1039        let (patches, unmatched) = parse_patches(response).unwrap();
1040        assert!(patches.is_empty());
1041        assert_eq!(unmatched, "Just a plain response with no patch blocks.");
1042    }
1043
1044    #[test]
1045    fn apply_patches_replace() {
1046        let dir = setup_project();
1047        let doc_path = dir.path().join("test.md");
1048        let doc = "# Dashboard\n\n<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
1049        std::fs::write(&doc_path, doc).unwrap();
1050
1051        let patches = vec![PatchBlock {
1052            name: "status".to_string(),
1053            content: "new\n".to_string(),
1054            attrs: Default::default(),
1055        }];
1056        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1057        assert!(result.contains("new\n"));
1058        assert!(!result.contains("\nold\n"));
1059        assert!(result.contains("<!-- agent:status -->"));
1060    }
1061
1062    #[test]
1063    fn apply_patches_unmatched_creates_exchange() {
1064        let dir = setup_project();
1065        let doc_path = dir.path().join("test.md");
1066        let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
1067        std::fs::write(&doc_path, doc).unwrap();
1068
1069        let result = apply_patches(doc, &[], "Extra info here", &doc_path).unwrap();
1070        assert!(result.contains("<!-- agent:exchange -->"));
1071        assert!(result.contains("Extra info here"));
1072        assert!(result.contains("<!-- /agent:exchange -->"));
1073    }
1074
1075    #[test]
1076    fn apply_patches_unmatched_appends_to_existing_exchange() {
1077        let dir = setup_project();
1078        let doc_path = dir.path().join("test.md");
1079        let doc = "<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
1080        std::fs::write(&doc_path, doc).unwrap();
1081
1082        let result = apply_patches(doc, &[], "new stuff", &doc_path).unwrap();
1083        assert!(result.contains("previous"));
1084        assert!(result.contains("new stuff"));
1085        // Should not create a second exchange component
1086        assert_eq!(result.matches("<!-- agent:exchange -->").count(), 1);
1087    }
1088
1089    #[test]
1090    fn apply_patches_missing_component_routes_to_exchange() {
1091        let dir = setup_project();
1092        let doc_path = dir.path().join("test.md");
1093        let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
1094        std::fs::write(&doc_path, doc).unwrap();
1095
1096        let patches = vec![PatchBlock {
1097            name: "nonexistent".to_string(),
1098            content: "overflow data\n".to_string(),
1099            attrs: Default::default(),
1100        }];
1101        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1102        // Missing component content should be routed to exchange
1103        assert!(result.contains("overflow data"), "missing patch content should appear in exchange");
1104        assert!(result.contains("previous"), "existing exchange content should be preserved");
1105    }
1106
1107    #[test]
1108    fn apply_patches_missing_component_creates_exchange() {
1109        let dir = setup_project();
1110        let doc_path = dir.path().join("test.md");
1111        let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
1112        std::fs::write(&doc_path, doc).unwrap();
1113
1114        let patches = vec![PatchBlock {
1115            name: "nonexistent".to_string(),
1116            content: "overflow data\n".to_string(),
1117            attrs: Default::default(),
1118        }];
1119        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1120        // Should auto-create exchange component
1121        assert!(result.contains("<!-- agent:exchange -->"), "should create exchange component");
1122        assert!(result.contains("overflow data"), "overflow content should be in exchange");
1123    }
1124
1125    #[test]
1126    fn is_template_mode_detection() {
1127        assert!(is_template_mode(Some("template")));
1128        assert!(!is_template_mode(Some("append")));
1129        assert!(!is_template_mode(None));
1130    }
1131
1132    #[test]
1133    fn template_info_works() {
1134        let dir = setup_project();
1135        let doc_path = dir.path().join("test.md");
1136        let doc = "---\nagent_doc_format: template\n---\n\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
1137        std::fs::write(&doc_path, doc).unwrap();
1138
1139        let info = template_info(&doc_path).unwrap();
1140        assert!(info.template_mode);
1141        assert_eq!(info.components.len(), 1);
1142        assert_eq!(info.components[0].name, "status");
1143        assert_eq!(info.components[0].content, "content\n");
1144    }
1145
1146    #[test]
1147    fn template_info_legacy_mode_works() {
1148        let dir = setup_project();
1149        let doc_path = dir.path().join("test.md");
1150        let doc = "---\nresponse_mode: template\n---\n\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
1151        std::fs::write(&doc_path, doc).unwrap();
1152
1153        let info = template_info(&doc_path).unwrap();
1154        assert!(info.template_mode);
1155    }
1156
1157    #[test]
1158    fn template_info_append_mode() {
1159        let dir = setup_project();
1160        let doc_path = dir.path().join("test.md");
1161        let doc = "---\nagent_doc_format: append\n---\n\n# Doc\n";
1162        std::fs::write(&doc_path, doc).unwrap();
1163
1164        let info = template_info(&doc_path).unwrap();
1165        assert!(!info.template_mode);
1166        assert!(info.components.is_empty());
1167    }
1168
1169    #[test]
1170    fn parse_patches_ignores_markers_in_fenced_code_block() {
1171        let response = "\
1172<!-- patch:exchange -->
1173Here is how you use component markers:
1174
1175```markdown
1176<!-- agent:exchange -->
1177example content
1178<!-- /agent:exchange -->
1179```
1180
1181<!-- /patch:exchange -->
1182";
1183        let (patches, unmatched) = parse_patches(response).unwrap();
1184        assert_eq!(patches.len(), 1);
1185        assert_eq!(patches[0].name, "exchange");
1186        assert!(patches[0].content.contains("```markdown"));
1187        assert!(patches[0].content.contains("<!-- agent:exchange -->"));
1188        assert!(unmatched.is_empty());
1189    }
1190
1191    #[test]
1192    fn parse_patches_ignores_patch_markers_in_fenced_code_block() {
1193        // Patch markers inside a code block should not be treated as real patches
1194        let response = "\
1195<!-- patch:exchange -->
1196Real content here.
1197
1198```markdown
1199<!-- patch:fake -->
1200This is just an example.
1201<!-- /patch:fake -->
1202```
1203
1204<!-- /patch:exchange -->
1205";
1206        let (patches, unmatched) = parse_patches(response).unwrap();
1207        assert_eq!(patches.len(), 1, "should only find the outer real patch");
1208        assert_eq!(patches[0].name, "exchange");
1209        assert!(patches[0].content.contains("<!-- patch:fake -->"), "code block content should be preserved");
1210        assert!(unmatched.is_empty());
1211    }
1212
1213    #[test]
1214    fn parse_patches_ignores_markers_in_tilde_fence() {
1215        let response = "\
1216<!-- patch:status -->
1217OK
1218<!-- /patch:status -->
1219
1220~~~
1221<!-- patch:fake -->
1222example
1223<!-- /patch:fake -->
1224~~~
1225";
1226        let (patches, _unmatched) = parse_patches(response).unwrap();
1227        // Only the real patch should be found; the fake one inside ~~~ is ignored
1228        assert_eq!(patches.len(), 1);
1229        assert_eq!(patches[0].name, "status");
1230    }
1231
1232    #[test]
1233    fn parse_patches_ignores_closing_marker_in_code_block() {
1234        // The closing marker for a real patch is inside a code block,
1235        // so the parser should skip it and find the real closing marker outside
1236        let response = "\
1237<!-- patch:exchange -->
1238Example:
1239
1240```
1241<!-- /patch:exchange -->
1242```
1243
1244Real content continues.
1245<!-- /patch:exchange -->
1246";
1247        let (patches, _unmatched) = parse_patches(response).unwrap();
1248        assert_eq!(patches.len(), 1);
1249        assert_eq!(patches[0].name, "exchange");
1250        assert!(patches[0].content.contains("Real content continues."));
1251    }
1252
1253    #[test]
1254    fn parse_patches_normal_markers_still_work() {
1255        // Sanity check: normal patch parsing without code blocks still works
1256        let response = "\
1257<!-- patch:status -->
1258All systems go.
1259<!-- /patch:status -->
1260<!-- patch:log -->
1261- Entry 1
1262<!-- /patch:log -->
1263";
1264        let (patches, unmatched) = parse_patches(response).unwrap();
1265        assert_eq!(patches.len(), 2);
1266        assert_eq!(patches[0].name, "status");
1267        assert_eq!(patches[0].content, "All systems go.\n");
1268        assert_eq!(patches[1].name, "log");
1269        assert_eq!(patches[1].content, "- Entry 1\n");
1270        assert!(unmatched.is_empty());
1271    }
1272
1273    #[test]
1274    fn parse_patches_orphaned_opener_does_not_leak_into_unmatched() {
1275        // Bug #p2xm: an unclosed `<!-- patch:exchange -->` was leaking into
1276        // unmatched text and getting appended to exchange verbatim.
1277        let response = "\
1278Some real content here.
1279<!-- patch:exchange -->
1280This opener has no matching close.
1281";
1282        let (patches, unmatched) = parse_patches(response).unwrap();
1283        assert!(patches.is_empty(), "orphaned opener should not produce a patch");
1284        assert_eq!(
1285            unmatched, "Some real content here.\nThis opener has no matching close.",
1286            "unmatched should contain text before and after the orphaned marker, but not the marker itself"
1287        );
1288    }
1289
1290    #[test]
1291    fn parse_patches_orphaned_opener_between_valid_patches() {
1292        // Orphaned opener between two valid patches — only the valid ones parse,
1293        // text around the orphan becomes unmatched, marker itself is consumed.
1294        let response = "\
1295<!-- patch:status -->
1296All good.
1297<!-- /patch:status -->
1298Interstitial text.
1299<!-- patch:exchange -->
1300<!-- patch:log -->
1301- Log entry
1302<!-- /patch:log -->
1303";
1304        let (patches, unmatched) = parse_patches(response).unwrap();
1305        assert_eq!(patches.len(), 2);
1306        assert_eq!(patches[0].name, "status");
1307        assert_eq!(patches[1].name, "log");
1308        assert_eq!(unmatched, "Interstitial text.");
1309    }
1310
1311    // --- Inline attribute mode resolution tests ---
1312
1313    #[test]
1314    fn inline_attr_mode_overrides_config() {
1315        // Component has mode=replace inline, but config.toml says append
1316        let dir = setup_project();
1317        let doc_path = dir.path().join("test.md");
1318        // Write config with append mode for status
1319        std::fs::write(
1320            dir.path().join(".agent-doc/config.toml"),
1321            "[components.status]\npatch = \"append\"\n",
1322        ).unwrap();
1323        // But the inline attr says replace
1324        let doc = "<!-- agent:status mode=replace -->\nold\n<!-- /agent:status -->\n";
1325        std::fs::write(&doc_path, doc).unwrap();
1326
1327        let patches = vec![PatchBlock {
1328            name: "status".to_string(),
1329            content: "new\n".to_string(),
1330            attrs: Default::default(),
1331        }];
1332        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1333        // Inline replace should win over config append
1334        assert!(result.contains("new\n"));
1335        assert!(!result.contains("old\n"));
1336    }
1337
1338    #[test]
1339    fn inline_attr_mode_overrides_default() {
1340        // exchange defaults to append, but inline says replace
1341        let dir = setup_project();
1342        let doc_path = dir.path().join("test.md");
1343        let doc = "<!-- agent:exchange mode=replace -->\nold\n<!-- /agent:exchange -->\n";
1344        std::fs::write(&doc_path, doc).unwrap();
1345
1346        let patches = vec![PatchBlock {
1347            name: "exchange".to_string(),
1348            content: "new\n".to_string(),
1349            attrs: Default::default(),
1350        }];
1351        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1352        assert!(result.contains("new\n"));
1353        assert!(!result.contains("old\n"));
1354    }
1355
1356    #[test]
1357    fn no_inline_attr_falls_back_to_config() {
1358        // No inline attr → falls back to config.toml ([components] section)
1359        let dir = setup_project();
1360        let doc_path = dir.path().join("test.md");
1361        std::fs::write(
1362            dir.path().join(".agent-doc/config.toml"),
1363            "[components.status]\npatch = \"append\"\n",
1364        ).unwrap();
1365        let doc = "<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
1366        std::fs::write(&doc_path, doc).unwrap();
1367
1368        let patches = vec![PatchBlock {
1369            name: "status".to_string(),
1370            content: "new\n".to_string(),
1371            attrs: Default::default(),
1372        }];
1373        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1374        // Config says append, so both old and new should be present
1375        assert!(result.contains("old\n"));
1376        assert!(result.contains("new\n"));
1377    }
1378
1379    #[test]
1380    fn no_inline_attr_no_config_falls_back_to_default() {
1381        // No inline attr, no config → built-in defaults
1382        let dir = setup_project();
1383        let doc_path = dir.path().join("test.md");
1384        let doc = "<!-- agent:exchange -->\nold\n<!-- /agent:exchange -->\n";
1385        std::fs::write(&doc_path, doc).unwrap();
1386
1387        let patches = vec![PatchBlock {
1388            name: "exchange".to_string(),
1389            content: "new\n".to_string(),
1390            attrs: Default::default(),
1391        }];
1392        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1393        // exchange defaults to append
1394        assert!(result.contains("old\n"));
1395        assert!(result.contains("new\n"));
1396    }
1397
1398    #[test]
1399    fn inline_patch_attr_overrides_config() {
1400        // Component has patch=replace inline, but config.toml says append
1401        let dir = setup_project();
1402        let doc_path = dir.path().join("test.md");
1403        std::fs::write(
1404            dir.path().join(".agent-doc/config.toml"),
1405            "[components.status]\npatch = \"append\"\n",
1406        ).unwrap();
1407        let doc = "<!-- agent:status patch=replace -->\nold\n<!-- /agent:status -->\n";
1408        std::fs::write(&doc_path, doc).unwrap();
1409
1410        let patches = vec![PatchBlock {
1411            name: "status".to_string(),
1412            content: "new\n".to_string(),
1413            attrs: Default::default(),
1414        }];
1415        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1416        assert!(result.contains("new\n"));
1417        assert!(!result.contains("old\n"));
1418    }
1419
1420    #[test]
1421    fn inline_patch_attr_overrides_mode_attr() {
1422        // Both patch= and mode= present; patch= wins
1423        let dir = setup_project();
1424        let doc_path = dir.path().join("test.md");
1425        let doc = "<!-- agent:exchange patch=replace mode=append -->\nold\n<!-- /agent:exchange -->\n";
1426        std::fs::write(&doc_path, doc).unwrap();
1427
1428        let patches = vec![PatchBlock {
1429            name: "exchange".to_string(),
1430            content: "new\n".to_string(),
1431            attrs: Default::default(),
1432        }];
1433        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1434        assert!(result.contains("new\n"));
1435        assert!(!result.contains("old\n"));
1436    }
1437
1438    #[test]
1439    fn toml_patch_key_works() {
1440        // config.toml uses `[components.status]` with `patch = "append"`
1441        let dir = setup_project();
1442        let doc_path = dir.path().join("test.md");
1443        std::fs::write(
1444            dir.path().join(".agent-doc/config.toml"),
1445            "[components.status]\npatch = \"append\"\n",
1446        ).unwrap();
1447        let doc = "<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
1448        std::fs::write(&doc_path, doc).unwrap();
1449
1450        let patches = vec![PatchBlock {
1451            name: "status".to_string(),
1452            content: "new\n".to_string(),
1453            attrs: Default::default(),
1454        }];
1455        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1456        assert!(result.contains("old\n"));
1457        assert!(result.contains("new\n"));
1458    }
1459
1460    #[test]
1461    fn stream_override_beats_inline_attr() {
1462        // Stream mode overrides should still beat inline attrs
1463        let dir = setup_project();
1464        let doc_path = dir.path().join("test.md");
1465        let doc = "<!-- agent:exchange mode=append -->\nold\n<!-- /agent:exchange -->\n";
1466        std::fs::write(&doc_path, doc).unwrap();
1467
1468        let patches = vec![PatchBlock {
1469            name: "exchange".to_string(),
1470            content: "new\n".to_string(),
1471            attrs: Default::default(),
1472        }];
1473        let mut overrides = std::collections::HashMap::new();
1474        overrides.insert("exchange".to_string(), "replace".to_string());
1475        let result = apply_patches_with_overrides(doc, &patches, "", &doc_path, &overrides).unwrap();
1476        // Stream override (replace) should win over inline attr (append)
1477        assert!(result.contains("new\n"));
1478        assert!(!result.contains("old\n"));
1479    }
1480
1481    #[test]
1482    fn apply_patches_ignores_component_tags_in_code_blocks() {
1483        // Component tags inside a fenced code block should not be patch targets.
1484        // Only the real top-level component should receive the patch content.
1485        let dir = setup_project();
1486        let doc_path = dir.path().join("test.md");
1487        let doc = "\
1488# Scaffold Guide
1489
1490Here is an example of a component:
1491
1492```markdown
1493<!-- agent:status -->
1494example scaffold content
1495<!-- /agent:status -->
1496```
1497
1498<!-- agent:status -->
1499real status content
1500<!-- /agent:status -->
1501";
1502        std::fs::write(&doc_path, doc).unwrap();
1503
1504        let patches = vec![PatchBlock {
1505            name: "status".to_string(),
1506            content: "patched status\n".to_string(),
1507            attrs: Default::default(),
1508        }];
1509        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1510
1511        // The real component should be patched
1512        assert!(result.contains("patched status\n"), "real component should receive the patch");
1513        // The code block example should be untouched
1514        assert!(result.contains("example scaffold content"), "code block content should be preserved");
1515        // The code block's markers should still be there
1516        assert!(result.contains("```markdown\n<!-- agent:status -->"), "code block markers should be preserved");
1517    }
1518
1519    #[test]
1520    fn unmatched_content_uses_boundary_marker() {
1521        let dir = setup_project();
1522        let file = dir.path().join("test.md");
1523        let doc = concat!(
1524            "---\nagent_doc_format: template\n---\n",
1525            "<!-- agent:exchange patch=append -->\n",
1526            "User prompt here.\n",
1527            "<!-- agent:boundary:test-uuid-123 -->\n",
1528            "<!-- /agent:exchange -->\n",
1529        );
1530        std::fs::write(&file, doc).unwrap();
1531
1532        // No patch blocks — only unmatched content (simulates skill not wrapping in patch blocks)
1533        let patches = vec![];
1534        let unmatched = "### Re: Response\n\nResponse content here.\n";
1535
1536        let result = apply_patches(doc, &patches, unmatched, &file).unwrap();
1537
1538        // Response should be inserted at the boundary marker position (after prompt)
1539        let prompt_pos = result.find("User prompt here.").unwrap();
1540        let response_pos = result.find("### Re: Response").unwrap();
1541        assert!(
1542            response_pos > prompt_pos,
1543            "response should appear after the user prompt (boundary insertion)"
1544        );
1545
1546        // Boundary marker should be consumed (replaced by response)
1547        assert!(
1548            !result.contains("test-uuid-123"),
1549            "boundary marker should be consumed after insertion"
1550        );
1551    }
1552
1553    #[test]
1554    fn explicit_patch_uses_boundary_marker() {
1555        let dir = setup_project();
1556        let file = dir.path().join("test.md");
1557        let doc = concat!(
1558            "---\nagent_doc_format: template\n---\n",
1559            "<!-- agent:exchange patch=append -->\n",
1560            "User prompt here.\n",
1561            "<!-- agent:boundary:patch-uuid-456 -->\n",
1562            "<!-- /agent:exchange -->\n",
1563        );
1564        std::fs::write(&file, doc).unwrap();
1565
1566        // Explicit patch block targeting exchange
1567        let patches = vec![PatchBlock {
1568            name: "exchange".to_string(),
1569            content: "### Re: Response\n\nResponse content.\n".to_string(),
1570            attrs: Default::default(),
1571        }];
1572
1573        let result = apply_patches(doc, &patches, "", &file).unwrap();
1574
1575        // Response should be after prompt (boundary consumed)
1576        let prompt_pos = result.find("User prompt here.").unwrap();
1577        let response_pos = result.find("### Re: Response").unwrap();
1578        assert!(
1579            response_pos > prompt_pos,
1580            "response should appear after user prompt"
1581        );
1582
1583        // Boundary marker should be consumed
1584        assert!(
1585            !result.contains("patch-uuid-456"),
1586            "boundary marker should be consumed by explicit patch"
1587        );
1588    }
1589
1590    #[test]
1591    fn boundary_reinserted_even_when_original_doc_has_no_boundary() {
1592        // Regression: the snowball bug — once one cycle loses the boundary,
1593        // every subsequent cycle also loses it because orig_had_boundary finds nothing.
1594        let dir = setup_project();
1595        let file = dir.path().join("test.md");
1596        // Document with exchange but NO boundary marker
1597        let doc = "<!-- agent:exchange patch=append -->\nUser prompt here.\n<!-- /agent:exchange -->\n";
1598        std::fs::write(&file, doc).unwrap();
1599
1600        let response = "<!-- patch:exchange -->\nAgent response.\n<!-- /patch:exchange -->\n";
1601        let (patches, unmatched) = parse_patches(response).unwrap();
1602        let result = apply_patches(doc, &patches, &unmatched, &file).unwrap();
1603
1604        // Must have a boundary at end of exchange, even though original had none
1605        assert!(
1606            result.contains("<!-- agent:boundary:"),
1607            "boundary must be re-inserted even when original doc had no boundary: {result}"
1608        );
1609    }
1610
1611    #[test]
1612    fn boundary_survives_multiple_cycles() {
1613        // Simulate two consecutive write cycles — boundary must persist
1614        let dir = setup_project();
1615        let file = dir.path().join("test.md");
1616        let doc = "<!-- agent:exchange patch=append -->\nPrompt 1.\n<!-- /agent:exchange -->\n";
1617        std::fs::write(&file, doc).unwrap();
1618
1619        // Cycle 1
1620        let response1 = "<!-- patch:exchange -->\nResponse 1.\n<!-- /patch:exchange -->\n";
1621        let (patches1, unmatched1) = parse_patches(response1).unwrap();
1622        let result1 = apply_patches(doc, &patches1, &unmatched1, &file).unwrap();
1623        assert!(result1.contains("<!-- agent:boundary:"), "cycle 1 must have boundary");
1624
1625        // Cycle 2 — use cycle 1's output as the new doc (simulates next write)
1626        let response2 = "<!-- patch:exchange -->\nResponse 2.\n<!-- /patch:exchange -->\n";
1627        let (patches2, unmatched2) = parse_patches(response2).unwrap();
1628        let result2 = apply_patches(&result1, &patches2, &unmatched2, &file).unwrap();
1629        assert!(result2.contains("<!-- agent:boundary:"), "cycle 2 must have boundary");
1630    }
1631
1632    #[test]
1633    fn remove_all_boundaries_skips_code_blocks() {
1634        let doc = "before\n```\n<!-- agent:boundary:fake-id -->\n```\nafter\n<!-- agent:boundary:real-id -->\nend\n";
1635        let result = remove_all_boundaries(doc);
1636        // The one inside the code block should survive
1637        assert!(
1638            result.contains("<!-- agent:boundary:fake-id -->"),
1639            "boundary inside code block must be preserved: {result}"
1640        );
1641        // The one outside should be removed
1642        assert!(
1643            !result.contains("<!-- agent:boundary:real-id -->"),
1644            "boundary outside code block must be removed: {result}"
1645        );
1646    }
1647
1648    #[test]
1649    fn reposition_boundary_moves_to_end() {
1650        let doc = "\
1651<!-- agent:exchange -->
1652Previous response.
1653<!-- agent:boundary:old-id -->
1654User prompt here.
1655<!-- /agent:exchange -->";
1656        let result = reposition_boundary_to_end(doc);
1657        // Old boundary should be gone
1658        assert!(!result.contains("old-id"), "old boundary should be removed");
1659        // New boundary should exist
1660        assert!(result.contains("<!-- agent:boundary:"), "new boundary should be inserted");
1661        // New boundary should be after the user prompt, before close tag
1662        let boundary_pos = result.find("<!-- agent:boundary:").unwrap();
1663        let prompt_pos = result.find("User prompt here.").unwrap();
1664        let close_pos = result.find("<!-- /agent:exchange -->").unwrap();
1665        assert!(boundary_pos > prompt_pos, "boundary should be after user prompt");
1666        assert!(boundary_pos < close_pos, "boundary should be before close tag");
1667    }
1668
1669    #[test]
1670    fn reposition_boundary_no_exchange_unchanged() {
1671        let doc = "\
1672<!-- agent:output -->
1673Some content.
1674<!-- /agent:output -->";
1675        let result = reposition_boundary_to_end(doc);
1676        assert!(!result.contains("<!-- agent:boundary:"), "no boundary should be added to non-exchange");
1677    }
1678
1679    #[test]
1680    fn reposition_appends_head_to_last_re_heading() {
1681        // #hdap: reposition must append ` (HEAD)` to the last `### Re:`
1682        // heading inside the exchange component, stripping any stale
1683        // `(HEAD)` suffix from earlier headings.
1684        let doc = "\
1685<!-- agent:exchange -->
1686### Re: older (HEAD)
1687old body
1688### Re: newer
1689new body
1690<!-- /agent:exchange -->";
1691        let result = reposition_boundary_to_end(doc);
1692        assert!(
1693            !result.contains("### Re: older (HEAD)"),
1694            "stale (HEAD) on prior heading must be stripped; got:\n{result}"
1695        );
1696        assert!(
1697            result.contains("### Re: older\n"),
1698            "older heading must remain (without HEAD); got:\n{result}"
1699        );
1700        assert!(
1701            result.contains("### Re: newer (HEAD)"),
1702            "latest heading must get (HEAD); got:\n{result}"
1703        );
1704        assert_eq!(
1705            result.matches("(HEAD)").count(),
1706            1,
1707            "exactly one (HEAD) in result; got:\n{result}"
1708        );
1709    }
1710
1711    #[test]
1712    fn reposition_head_annotation_no_re_heading_unchanged() {
1713        // No `### Re:` headings → no (HEAD) added, content passes through.
1714        let doc = "\
1715<!-- agent:exchange -->
1716User text with no response headings.
1717<!-- /agent:exchange -->";
1718        let result = reposition_boundary_to_end(doc);
1719        assert!(!result.contains("(HEAD)"), "no heading → no (HEAD); got:\n{result}");
1720    }
1721
1722    #[test]
1723    fn reposition_head_annotation_skips_code_fence() {
1724        // ### Re: inside a fenced code block must NOT be treated as a heading.
1725        let doc = "\
1726<!-- agent:exchange -->
1727### Re: real heading
1728```markdown
1729### Re: fake heading in code fence
1730```
1731<!-- /agent:exchange -->";
1732        let result = reposition_boundary_to_end(doc);
1733        assert!(
1734            result.contains("### Re: real heading (HEAD)"),
1735            "real heading outside fence gets (HEAD); got:\n{result}"
1736        );
1737        assert!(
1738            result.contains("### Re: fake heading in code fence\n"),
1739            "fenced heading must be untouched; got:\n{result}"
1740        );
1741        assert_eq!(
1742            result.matches("(HEAD)").count(),
1743            1,
1744            "exactly one (HEAD) — fenced heading ignored; got:\n{result}"
1745        );
1746    }
1747
1748    #[test]
1749    fn reposition_with_baseline_marks_all_new_re_headings() {
1750        // Patchback with multiple `### Re:` headings: every heading NOT in
1751        // the baseline (git HEAD) gets (HEAD); every heading IN the baseline
1752        // does not. This matches the "all patchback top-level headers" rule.
1753        let doc = "\
1754<!-- agent:exchange -->
1755### Re: old-1
1756body a
1757### Re: old-2 (HEAD)
1758body b
1759### Re: new-1
1760body c
1761### Re: new-2
1762body d
1763<!-- /agent:exchange -->";
1764        // Baseline contains just the two "old" headings (no (HEAD), as HEAD
1765        // blob is always stripped by the commit staging path).
1766        let mut baseline = std::collections::HashSet::new();
1767        baseline.insert("### Re: old-1".to_string());
1768        baseline.insert("### Re: old-2".to_string());
1769
1770        let result = reposition_boundary_to_end_with_baseline(doc, None, Some(&baseline));
1771
1772        // Both old headings lose (HEAD).
1773        assert!(result.contains("### Re: old-1\n"), "old-1 must not have (HEAD); got:\n{result}");
1774        assert!(result.contains("### Re: old-2\n"), "old-2 must not have (HEAD); got:\n{result}");
1775        // Both new headings get (HEAD).
1776        assert!(result.contains("### Re: new-1 (HEAD)"), "new-1 must get (HEAD); got:\n{result}");
1777        assert!(result.contains("### Re: new-2 (HEAD)"), "new-2 must get (HEAD); got:\n{result}");
1778        // Exactly two (HEAD)s — one per new heading.
1779        assert_eq!(
1780            result.matches("(HEAD)").count(),
1781            2,
1782            "exactly two (HEAD) markers; got:\n{result}"
1783        );
1784    }
1785
1786    #[test]
1787    fn reposition_with_empty_baseline_marks_every_re_heading() {
1788        // First cycle / untracked file: baseline is empty. All headings are
1789        // "new", so all get (HEAD).
1790        let doc = "\
1791<!-- agent:exchange -->
1792### Re: first
1793a
1794### Re: second
1795b
1796<!-- /agent:exchange -->";
1797        let baseline: std::collections::HashSet<String> = std::collections::HashSet::new();
1798        let result = reposition_boundary_to_end_with_baseline(doc, None, Some(&baseline));
1799        assert!(result.contains("### Re: first (HEAD)"), "first gets (HEAD); got:\n{result}");
1800        assert!(result.contains("### Re: second (HEAD)"), "second gets (HEAD); got:\n{result}");
1801        assert_eq!(
1802            result.matches("(HEAD)").count(),
1803            2,
1804            "exactly two (HEAD) markers; got:\n{result}"
1805        );
1806    }
1807
1808    #[test]
1809    fn exchange_baseline_headings_extracts_stripped_re_lines() {
1810        let doc = "\
1811<!-- agent:exchange -->
1812### Re: one (HEAD)
1813body
1814### Re: two
1815more body
1816### Not a Re heading
1817body
1818<!-- /agent:exchange -->";
1819        let set = exchange_baseline_headings(doc);
1820        assert!(set.contains("### Re: one"), "stripped one present; got: {set:?}");
1821        assert!(set.contains("### Re: two"), "two present; got: {set:?}");
1822        assert_eq!(set.len(), 2, "only Re: headings; got: {set:?}");
1823    }
1824
1825    #[test]
1826    fn exchange_baseline_headings_normalizes_leading_whitespace() {
1827        // HEAD has an indented heading; set entry must be trim_start'd so a
1828        // non-indented working-tree heading matches it.
1829        let doc = "\
1830<!-- agent:exchange -->
1831  ### Re: indented
1832body
1833### Re: flush
1834more
1835<!-- /agent:exchange -->";
1836        let set = exchange_baseline_headings(doc);
1837        assert!(set.contains("### Re: indented"), "indented entry normalized; got: {set:?}");
1838        assert!(set.contains("### Re: flush"), "flush entry present; got: {set:?}");
1839    }
1840
1841    #[test]
1842    fn reposition_with_baseline_matches_indented_heading() {
1843        // Baseline has "### Re: foo" (flush). Working tree has "  ### Re: foo"
1844        // (indented). trim_start normalization makes the lookup recognize the
1845        // indented heading as already-in-baseline. Because the baseline filter
1846        // then yields zero "new" headings, the fallback kicks in and marks the
1847        // last Re: heading anyway — preserving the head pointer. The key point
1848        // is that normalization works (the heading is recognized), not that
1849        // (HEAD) is absent.
1850        let doc = "\
1851<!-- agent:exchange -->
1852  ### Re: foo
1853body
1854### Re: bar (HEAD)
1855body2
1856<!-- /agent:exchange -->";
1857        let mut baseline = std::collections::HashSet::new();
1858        baseline.insert("### Re: foo".to_string());
1859        baseline.insert("### Re: bar".to_string());
1860        let result =
1861            reposition_boundary_to_end_with_baseline(doc, None, Some(&baseline));
1862        // Both headings are in baseline → filter is empty → fallback marks
1863        // the LAST Re: heading only. "### Re: foo" stays unmarked (proving
1864        // trim_start normalization worked — without it, foo would be
1865        // treated as new and also get (HEAD)).
1866        assert!(
1867            result.contains("  ### Re: foo\n"),
1868            "indented heading must remain unmarked; got:\n{result}"
1869        );
1870        assert!(
1871            result.contains("### Re: bar (HEAD)"),
1872            "last heading gets fallback (HEAD) marker; got:\n{result}"
1873        );
1874        assert_eq!(
1875            result.matches("(HEAD)").count(),
1876            1,
1877            "exactly one (HEAD) via fallback; got:\n{result}"
1878        );
1879    }
1880
1881    #[test]
1882    fn baseline_filter_empty_falls_back_to_last_heading() {
1883        // When every Re: heading in the working tree is already in baseline
1884        // (i.e., the current turn adds no new Re: sections), the filter is
1885        // empty. The fallback must mark the last heading so the working tree
1886        // retains a single "head" marker across empty-Re cycles.
1887        let doc = "\
1888<!-- agent:exchange -->
1889### Re: older
1890body
1891### Re: newer (HEAD)
1892more
1893<!-- /agent:exchange -->";
1894        let mut baseline = std::collections::HashSet::new();
1895        baseline.insert("### Re: older".to_string());
1896        baseline.insert("### Re: newer".to_string());
1897        let result =
1898            reposition_boundary_to_end_with_baseline(doc, None, Some(&baseline));
1899        assert!(
1900            result.contains("### Re: newer (HEAD)"),
1901            "last heading retains (HEAD) via fallback; got:\n{result}"
1902        );
1903        assert!(
1904            result.contains("### Re: older\n"),
1905            "older heading remains unmarked; got:\n{result}"
1906        );
1907        assert_eq!(
1908            result.matches("(HEAD)").count(),
1909            1,
1910            "exactly one (HEAD) marker after fallback; got:\n{result}"
1911        );
1912    }
1913
1914    #[test]
1915    fn reposition_head_annotation_strips_multiple_stale() {
1916        // Multiple stale (HEAD)s on prior headings → all stripped, only last gets it.
1917        let doc = "\
1918<!-- agent:exchange -->
1919### Re: one (HEAD)
1920a
1921### Re: two (HEAD)
1922b
1923### Re: three
1924c
1925<!-- /agent:exchange -->";
1926        let result = reposition_boundary_to_end(doc);
1927        assert_eq!(
1928            result.matches("(HEAD)").count(),
1929            1,
1930            "exactly one (HEAD) after reposition; got:\n{result}"
1931        );
1932        assert!(result.contains("### Re: three (HEAD)"));
1933        assert!(result.contains("### Re: one\n"));
1934        assert!(result.contains("### Re: two\n"));
1935    }
1936
1937    #[test]
1938    fn max_lines_inline_attr_trims_content() {
1939        let dir = setup_project();
1940        let doc_path = dir.path().join("test.md");
1941        let doc = "<!-- agent:log patch=replace max_lines=3 -->\nold\n<!-- /agent:log -->\n";
1942        std::fs::write(&doc_path, doc).unwrap();
1943
1944        let patches = vec![PatchBlock {
1945            name: "log".to_string(),
1946            content: "line1\nline2\nline3\nline4\nline5\n".to_string(),
1947            attrs: Default::default(),
1948        }];
1949        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1950        assert!(!result.contains("line1"));
1951        assert!(!result.contains("line2"));
1952        assert!(result.contains("line3"));
1953        assert!(result.contains("line4"));
1954        assert!(result.contains("line5"));
1955    }
1956
1957    #[test]
1958    fn max_lines_noop_when_under_limit() {
1959        let dir = setup_project();
1960        let doc_path = dir.path().join("test.md");
1961        let doc = "<!-- agent:log patch=replace max_lines=10 -->\nold\n<!-- /agent:log -->\n";
1962        std::fs::write(&doc_path, doc).unwrap();
1963
1964        let patches = vec![PatchBlock {
1965            name: "log".to_string(),
1966            content: "line1\nline2\n".to_string(),
1967            attrs: Default::default(),
1968        }];
1969        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1970        assert!(result.contains("line1"));
1971        assert!(result.contains("line2"));
1972    }
1973
1974    #[test]
1975    fn max_lines_from_components_toml() {
1976        let dir = setup_project();
1977        let doc_path = dir.path().join("test.md");
1978        std::fs::write(
1979            dir.path().join(".agent-doc/config.toml"),
1980            "[components.log]\npatch = \"replace\"\nmax_lines = 2\n",
1981        )
1982        .unwrap();
1983        let doc = "<!-- agent:log -->\nold\n<!-- /agent:log -->\n";
1984        std::fs::write(&doc_path, doc).unwrap();
1985
1986        let patches = vec![PatchBlock {
1987            name: "log".to_string(),
1988            content: "a\nb\nc\nd\n".to_string(),
1989            attrs: Default::default(),
1990        }];
1991        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1992        assert!(!result.contains("\na\n"));
1993        assert!(!result.contains("\nb\n"));
1994        assert!(result.contains("c"));
1995        assert!(result.contains("d"));
1996    }
1997
1998    #[test]
1999    fn max_lines_inline_beats_toml() {
2000        let dir = setup_project();
2001        let doc_path = dir.path().join("test.md");
2002        std::fs::write(
2003            dir.path().join(".agent-doc/config.toml"),
2004            "[components.log]\nmax_lines = 1\n",
2005        )
2006        .unwrap();
2007        let doc = "<!-- agent:log patch=replace max_lines=3 -->\nold\n<!-- /agent:log -->\n";
2008        std::fs::write(&doc_path, doc).unwrap();
2009
2010        let patches = vec![PatchBlock {
2011            name: "log".to_string(),
2012            content: "a\nb\nc\nd\n".to_string(),
2013            attrs: Default::default(),
2014        }];
2015        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
2016        // Inline max_lines=3 should win over toml max_lines=1
2017        assert!(result.contains("b"));
2018        assert!(result.contains("c"));
2019        assert!(result.contains("d"));
2020    }
2021
2022    #[test]
2023    fn parse_patch_with_transfer_source_attr() {
2024        let response = "<!-- patch:exchange transfer-source=\"tasks/eval-runner.md\" -->\nTransferred content.\n<!-- /patch:exchange -->\n";
2025        let (patches, unmatched) = parse_patches(response).unwrap();
2026        assert_eq!(patches.len(), 1);
2027        assert_eq!(patches[0].name, "exchange");
2028        assert_eq!(patches[0].content, "Transferred content.\n");
2029        assert_eq!(
2030            patches[0].attrs.get("transfer-source"),
2031            Some(&"\"tasks/eval-runner.md\"".to_string())
2032        );
2033        assert!(unmatched.is_empty());
2034    }
2035
2036    #[test]
2037    fn parse_patch_without_attrs() {
2038        let response = "<!-- patch:exchange -->\nContent.\n<!-- /patch:exchange -->\n";
2039        let (patches, _) = parse_patches(response).unwrap();
2040        assert_eq!(patches.len(), 1);
2041        assert!(patches[0].attrs.is_empty());
2042    }
2043
2044    #[test]
2045    fn parse_patch_with_multiple_attrs() {
2046        let response = "<!-- patch:output mode=replace max_lines=50 -->\nContent.\n<!-- /patch:output -->\n";
2047        let (patches, _) = parse_patches(response).unwrap();
2048        assert_eq!(patches.len(), 1);
2049        assert_eq!(patches[0].name, "output");
2050        assert_eq!(patches[0].attrs.get("mode"), Some(&"replace".to_string()));
2051        assert_eq!(patches[0].attrs.get("max_lines"), Some(&"50".to_string()));
2052    }
2053
2054    #[test]
2055    fn apply_patches_dedup_exchange_adjacent_echo() {
2056        // Simulates the bug: agent echoes user prompt as first line of exchange patch.
2057        // The existing exchange already ends with the prompt line.
2058        // After apply_patches, the prompt should appear exactly once.
2059        let dir = setup_project();
2060        let doc_path = dir.path().join("test.md");
2061        let doc = "\
2062<!-- agent:exchange patch=append -->
2063❯ How do I configure .mise.toml?
2064<!-- /agent:exchange -->
2065";
2066        std::fs::write(&doc_path, doc).unwrap();
2067
2068        // Agent echoes the prompt as first line of its response patch
2069        let patches = vec![PatchBlock {
2070            name: "exchange".to_string(),
2071            content: "❯ How do I configure .mise.toml?\n\n### Re: configure .mise.toml\n\nUse `[env]` section.\n".to_string(),
2072            attrs: Default::default(),
2073        }];
2074        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
2075
2076        let count = result.matches("❯ How do I configure .mise.toml?").count();
2077        assert_eq!(count, 1, "prompt line should appear exactly once, got:\n{result}");
2078        assert!(result.contains("### Re: configure .mise.toml"), "response heading should be present");
2079        assert!(result.contains("Use `[env]` section."), "response body should be present");
2080    }
2081
2082    #[test]
2083    fn apply_patches_dedup_preserves_blank_lines() {
2084        // Blank lines between sections must not be collapsed by dedup.
2085        let dir = setup_project();
2086        let doc_path = dir.path().join("test.md");
2087        let doc = "\
2088<!-- agent:exchange patch=append -->
2089Previous response.
2090<!-- /agent:exchange -->
2091";
2092        std::fs::write(&doc_path, doc).unwrap();
2093
2094        let patches = vec![PatchBlock {
2095            name: "exchange".to_string(),
2096            content: "\n\n### Re: something\n\nAnswer here.\n".to_string(),
2097            attrs: Default::default(),
2098        }];
2099        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
2100        assert!(result.contains("Previous response."), "existing content preserved");
2101        assert!(result.contains("### Re: something"), "response heading present");
2102        // Multiple blank lines should survive (dedup only targets non-blank)
2103        assert!(result.contains('\n'), "blank lines preserved");
2104    }
2105
2106    #[test]
2107    fn apply_mode_append_strips_leading_overlap() {
2108        // When new_content starts with the last non-blank line of existing,
2109        // apply_mode("append") should not duplicate that line.
2110        let existing = "❯ How do I configure .mise.toml?\n";
2111        let new_content = "❯ How do I configure .mise.toml?\n\n### Re: configure\n\nUse `[env]`.\n";
2112        let result = apply_mode("append", existing, new_content);
2113        let count = result.matches("❯ How do I configure .mise.toml?").count();
2114        assert_eq!(count, 1, "overlap line should appear exactly once");
2115        assert!(result.contains("### Re: configure"));
2116    }
2117
2118    #[test]
2119    fn strip_trailing_caret_removes_bare_prompt_line() {
2120        let content = "Answer text.\n❯\n";
2121        assert_eq!(strip_trailing_caret_lines(content), "Answer text.\n");
2122    }
2123
2124    #[test]
2125    fn strip_trailing_caret_removes_multiple_trailing_lines() {
2126        let content = "Answer.\n❯\n❯\n";
2127        assert_eq!(strip_trailing_caret_lines(content), "Answer.\n");
2128    }
2129
2130    #[test]
2131    fn strip_trailing_caret_preserves_mid_content_caret() {
2132        // `❯` mid-content (e.g. user prompt quoted in response) must survive.
2133        let content = "### Re: topic\n\n❯ user question echoed\n\nAnswer.\n";
2134        assert_eq!(strip_trailing_caret_lines(content), content);
2135    }
2136
2137    #[test]
2138    fn strip_trailing_caret_preserves_caret_with_text() {
2139        // Line that starts with `❯ ` and has other text is user content; don't strip.
2140        let content = "Answer.\n❯ follow-up\n";
2141        assert_eq!(strip_trailing_caret_lines(content), content);
2142    }
2143
2144    #[test]
2145    fn strip_trailing_caret_handles_no_trailing_newline() {
2146        let content = "Answer.\n❯";
2147        assert_eq!(strip_trailing_caret_lines(content), "Answer.");
2148    }
2149
2150    #[test]
2151    fn strip_trailing_caret_noop_when_no_caret() {
2152        let content = "Answer.\n";
2153        assert_eq!(strip_trailing_caret_lines(content), content);
2154    }
2155
2156    #[test]
2157    fn apply_patches_strips_trailing_caret_from_exchange() {
2158        let doc = "---\nagent_doc_format: template\n---\n\n<!-- agent:exchange -->\n❯ prior question\n<!-- /agent:exchange -->\n";
2159        let patches = vec![PatchBlock {
2160            name: "exchange".to_string(),
2161            content: "### Re: thing\n\nAnswer.\n❯\n".to_string(),
2162            attrs: Default::default(),
2163        }];
2164        let doc_path = std::path::PathBuf::from("/tmp/test.md");
2165        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
2166        // Extract just the exchange component content
2167        let components = component::parse(&result).unwrap();
2168        let exchange = components.iter().find(|c| c.name == "exchange").unwrap();
2169        let content = exchange.content(&result);
2170        // No bare `❯` on its own line immediately before the boundary marker.
2171        let has_bare_caret_before_boundary = content
2172            .lines()
2173            .collect::<Vec<_>>()
2174            .windows(2)
2175            .any(|w| w[0].trim() == "❯" && w[1].starts_with("<!-- agent:boundary"));
2176        assert!(
2177            !has_bare_caret_before_boundary,
2178            "bare ❯ line must not appear before boundary marker. content:\n{}",
2179            content
2180        );
2181    }
2182
2183    #[test]
2184    fn apply_patches_preserves_caret_in_non_exchange() {
2185        // A patch targeting a non-exchange component should preserve trailing `❯`
2186        // (no special rule there).
2187        let doc = "---\nagent_doc_format: template\n---\n\n<!-- agent:exchange -->\n<!-- /agent:exchange -->\n\n<!-- agent:notes patch=replace -->\n<!-- /agent:notes -->\n";
2188        let patches = vec![PatchBlock {
2189            name: "notes".to_string(),
2190            content: "note body\n❯\n".to_string(),
2191            attrs: Default::default(),
2192        }];
2193        let doc_path = std::path::PathBuf::from("/tmp/test.md");
2194        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
2195        let components = component::parse(&result).unwrap();
2196        let notes = components.iter().find(|c| c.name == "notes").unwrap();
2197        assert!(notes.content(&result).contains("❯"), "non-exchange content retains ❯");
2198    }
2199
2200    #[test]
2201    fn apply_mode_append_no_overlap_unchanged() {
2202        // When new_content does NOT start with the last non-blank line of existing,
2203        // apply_mode("append") should concatenate normally.
2204        let existing = "Previous content.\n";
2205        let new_content = "### Re: something\n\nAnswer.\n";
2206        let result = apply_mode("append", existing, new_content);
2207        assert_eq!(result, "Previous content.\n### Re: something\n\nAnswer.\n");
2208    }
2209}