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