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/components.toml` > 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//!   `components.toml` (`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 `components.toml` 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 → `components.toml` 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 `components.toml` 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};
84
85/// A parsed patch directive from an agent response.
86#[derive(Debug, Clone)]
87pub struct PatchBlock {
88    pub name: String,
89    pub content: String,
90    /// Attributes from the patch marker (e.g., `transfer-source="path"`).
91    #[allow(dead_code)]
92    pub attrs: std::collections::HashMap<String, String>,
93}
94
95impl PatchBlock {
96    /// Create a PatchBlock with no attributes.
97    pub fn new(name: impl Into<String>, content: impl Into<String>) -> Self {
98        PatchBlock {
99            name: name.into(),
100            content: content.into(),
101            attrs: std::collections::HashMap::new(),
102        }
103    }
104}
105
106/// Template info output for plugins.
107#[derive(Debug, Serialize)]
108pub struct TemplateInfo {
109    pub template_mode: bool,
110    pub components: Vec<ComponentInfo>,
111}
112
113/// Per-component info for plugin rendering.
114#[derive(Debug, Serialize)]
115pub struct ComponentInfo {
116    pub name: String,
117    pub mode: String,
118    pub content: String,
119    pub line: usize,
120    #[serde(skip_serializing_if = "Option::is_none")]
121    pub max_entries: Option<usize>,
122}
123
124/// Check if a document is in template mode (deprecated — use `fm.resolve_mode().is_template()`).
125#[cfg(test)]
126pub fn is_template_mode(mode: Option<&str>) -> bool {
127    matches!(mode, Some("template"))
128}
129
130/// Parse `<!-- patch:name -->...<!-- /patch:name -->` blocks from an agent response.
131///
132/// Content outside patch blocks is collected as "unmatched" and returned separately.
133/// Markers inside fenced code blocks (``` or ~~~) and inline code spans are ignored.
134pub fn parse_patches(response: &str) -> Result<(Vec<PatchBlock>, String)> {
135    let bytes = response.as_bytes();
136    let len = bytes.len();
137    let code_ranges = component::find_code_ranges(response);
138    let mut patches = Vec::new();
139    let mut unmatched = String::new();
140    let mut pos = 0;
141    let mut last_end = 0;
142
143    while pos + 4 <= len {
144        if &bytes[pos..pos + 4] != b"<!--" {
145            pos += 1;
146            continue;
147        }
148
149        // Skip markers inside code regions
150        if code_ranges.iter().any(|&(start, end)| pos >= start && pos < end) {
151            pos += 4;
152            continue;
153        }
154
155        let marker_start = pos;
156
157        // Find closing -->
158        let close = match find_comment_end(bytes, pos + 4) {
159            Some(c) => c,
160            None => {
161                pos += 4;
162                continue;
163            }
164        };
165
166        let inner = &response[marker_start + 4..close - 3];
167        let trimmed = inner.trim();
168
169        if let Some(rest) = trimmed.strip_prefix("patch:") {
170            let rest = rest.trim();
171            if rest.is_empty() || rest.starts_with('/') {
172                pos = close;
173                continue;
174            }
175
176            // Split name from attributes: "exchange transfer-source=path" -> ("exchange", attrs)
177            let (name, attrs) = if let Some(space_idx) = rest.find(char::is_whitespace) {
178                let name = &rest[..space_idx];
179                let attr_text = rest[space_idx..].trim();
180                (name, component::parse_attrs(attr_text))
181            } else {
182                (rest, std::collections::HashMap::new())
183            };
184
185            // Consume trailing newline after opening marker
186            let mut content_start = close;
187            if content_start < len && bytes[content_start] == b'\n' {
188                content_start += 1;
189            }
190
191            // Collect unmatched text before this patch block
192            let before = &response[last_end..marker_start];
193            let trimmed_before = before.trim();
194            if !trimmed_before.is_empty() {
195                if !unmatched.is_empty() {
196                    unmatched.push('\n');
197                }
198                unmatched.push_str(trimmed_before);
199            }
200
201            // Find the matching close: <!-- /patch:name --> (skipping code blocks)
202            let close_marker = format!("<!-- /patch:{} -->", name);
203            if let Some(close_pos) = find_outside_code(&close_marker, response, content_start, &code_ranges) {
204                let content = &response[content_start..close_pos];
205                patches.push(PatchBlock {
206                    name: name.to_string(),
207                    content: content.to_string(),
208                    attrs,
209                });
210
211                let mut end = close_pos + close_marker.len();
212                if end < len && bytes[end] == b'\n' {
213                    end += 1;
214                }
215                last_end = end;
216                pos = end;
217                continue;
218            }
219        }
220
221        pos = close;
222    }
223
224    // Collect any trailing unmatched text
225    if last_end < len {
226        let trailing = response[last_end..].trim();
227        if !trailing.is_empty() {
228            if !unmatched.is_empty() {
229                unmatched.push('\n');
230            }
231            unmatched.push_str(trailing);
232        }
233    }
234
235    Ok((patches, unmatched))
236}
237
238/// Apply patch blocks to a document's components.
239///
240/// For each patch block, finds the matching `<!-- agent:name -->` component
241/// and replaces its content. Uses patch.rs mode logic (replace/append/prepend)
242/// based on `.agent-doc/components.toml` config.
243///
244/// Returns the modified document. Unmatched content (outside patch blocks)
245/// is appended to `<!-- agent:output -->` if it exists, or creates one at the end.
246pub fn apply_patches(doc: &str, patches: &[PatchBlock], unmatched: &str, file: &Path) -> Result<String> {
247    apply_patches_with_overrides(doc, patches, unmatched, file, &std::collections::HashMap::new())
248}
249
250/// Apply patches with per-component mode overrides (e.g., stream mode forces "replace"
251/// for cumulative buffers even on append-mode components like exchange).
252pub fn apply_patches_with_overrides(
253    doc: &str,
254    patches: &[PatchBlock],
255    unmatched: &str,
256    file: &Path,
257    mode_overrides: &std::collections::HashMap<String, String>,
258) -> Result<String> {
259    // Pre-patch: ensure a fresh boundary exists in the exchange component.
260    // Remove any stale boundaries from previous cycles, then insert a new one
261    // at the end of the exchange. This is deterministic — belongs in the binary,
262    // not the SKILL workflow.
263    let summary = file.file_stem().and_then(|s| s.to_str());
264    let mut result = remove_all_boundaries(doc);
265    if let Ok(components) = component::parse(&result)
266        && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
267    {
268        let id = crate::new_boundary_id_with_summary(summary);
269        let marker = crate::format_boundary_marker(&id);
270        let content = exchange.content(&result);
271        let new_content = format!("{}\n{}\n", content.trim_end(), marker);
272        result = exchange.replace_content(&result, &new_content);
273        eprintln!("[template] pre-patch boundary {} inserted at end of exchange", id);
274    }
275
276    // Apply patches in reverse order (by position) to preserve byte offsets
277    let components = component::parse(&result)
278        .context("failed to parse components")?;
279
280    // Load component configs
281    let configs = load_component_configs(file);
282
283    // Build a list of (component_index, patch) pairs, sorted by component position descending.
284    // Patches targeting missing components are collected as overflow and routed to
285    // exchange/output (same as unmatched content) — this avoids silent failures when
286    // the agent uses a wrong component name.
287    let mut ops: Vec<(usize, &PatchBlock)> = Vec::new();
288    let mut overflow = String::new();
289    for patch in patches {
290        if let Some(idx) = components.iter().position(|c| c.name == patch.name) {
291            ops.push((idx, patch));
292        } else {
293            let available: Vec<&str> = components.iter().map(|c| c.name.as_str()).collect();
294            eprintln!(
295                "[template] patch target '{}' not found, routing to exchange/output. Available: {}",
296                patch.name,
297                available.join(", ")
298            );
299            if !overflow.is_empty() {
300                overflow.push('\n');
301            }
302            overflow.push_str(&patch.content);
303        }
304    }
305
306    // Sort by position descending so replacements don't shift earlier offsets
307    ops.sort_by(|a, b| b.0.cmp(&a.0));
308
309    for (idx, patch) in &ops {
310        let comp = &components[*idx];
311        // Mode precedence: stream overrides > inline attr > components.toml > built-in default
312        let mode = mode_overrides.get(&patch.name)
313            .map(|s| s.as_str())
314            .or_else(|| comp.patch_mode())
315            .or_else(|| configs.get(&patch.name).map(|s| s.as_str()))
316            .unwrap_or_else(|| default_mode(&patch.name));
317        // For append mode, use boundary-aware insertion when a marker exists
318        if mode == "append"
319            && let Some(bid) = find_boundary_in_component(&result, comp)
320        {
321            result = comp.append_with_boundary(&result, &patch.content, &bid);
322            continue;
323        }
324        let new_content = apply_mode(mode, comp.content(&result), &patch.content);
325        result = comp.replace_content(&result, &new_content);
326    }
327
328    // Merge overflow (from missing-component patches) with unmatched content
329    let mut all_unmatched = String::new();
330    if !overflow.is_empty() {
331        all_unmatched.push_str(&overflow);
332    }
333    if !unmatched.is_empty() {
334        if !all_unmatched.is_empty() {
335            all_unmatched.push('\n');
336        }
337        all_unmatched.push_str(unmatched);
338    }
339
340    // Handle unmatched content
341    if !all_unmatched.is_empty() {
342        let unmatched = &all_unmatched;
343        // Re-parse after patches applied
344        let components = component::parse(&result)
345            .context("failed to re-parse components after patching")?;
346
347        if let Some(output_comp) = components.iter().find(|c| c.name == "exchange" || c.name == "output") {
348            // Try boundary-aware append first (preserves prompt ordering)
349            if let Some(bid) = find_boundary_in_component(&result, output_comp) {
350                eprintln!("[template] unmatched content: using boundary {} for insertion", &bid[..bid.len().min(8)]);
351                result = output_comp.append_with_boundary(&result, unmatched, &bid);
352            } else {
353                // No boundary — plain append to exchange/output component
354                let existing = output_comp.content(&result);
355                let new_content = if existing.trim().is_empty() {
356                    format!("{}\n", unmatched)
357                } else {
358                    format!("{}{}\n", existing, unmatched)
359                };
360                result = output_comp.replace_content(&result, &new_content);
361            }
362        } else {
363            // Auto-create exchange component at the end
364            if !result.ends_with('\n') {
365                result.push('\n');
366            }
367            result.push_str("\n<!-- agent:exchange -->\n");
368            result.push_str(unmatched);
369            result.push_str("\n<!-- /agent:exchange -->\n");
370        }
371    }
372
373    // Post-patch: remove consecutive duplicate lines from exchange (prevents agent
374    // echo of user prompt when patch content starts with already-appended content).
375    result = dedup_exchange_adjacent_lines(&result);
376
377    // Post-patch: apply max_lines trimming to components that have it configured.
378    // Precedence: inline attr > components.toml > unlimited (0).
379    // Re-parse after each replacement (offsets change) and iterate up to 3 times
380    // until stable — trimming one component cannot grow another, so 2 passes suffice
381    // in practice; the third is a safety bound.
382    {
383        let max_lines_configs = load_max_lines_configs(file);
384        'stability: for _ in 0..3 {
385            let Ok(components) = component::parse(&result) else { break };
386            for comp in &components {
387                let max_lines = comp
388                    .attrs
389                    .get("max_lines")
390                    .and_then(|s| s.parse::<usize>().ok())
391                    .or_else(|| max_lines_configs.get(&comp.name).copied())
392                    .unwrap_or(0);
393                if max_lines > 0 {
394                    let content = comp.content(&result);
395                    let trimmed = limit_lines(content, max_lines);
396                    if trimmed.len() != content.len() {
397                        let trimmed = format!("{}\n", trimmed.trim_end());
398                        result = comp.replace_content(&result, &trimmed);
399                        // Re-parse from scratch — offsets are now stale.
400                        continue 'stability;
401                    }
402                }
403            }
404            break; // No component needed trimming — stable.
405        }
406    }
407
408    // Post-patch: ensure a boundary exists at the end of the exchange component.
409    // This is unconditional for template docs with an exchange — the boundary must
410    // always exist for checkpoint writes to work. Checking the original doc's content
411    // causes a snowball: once one cycle loses the boundary, every subsequent cycle
412    // also loses it because the check always finds nothing.
413    {
414        if let Ok(components) = component::parse(&result)
415            && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
416            && find_boundary_in_component(&result, exchange).is_none()
417        {
418            // Boundary was consumed — re-insert at end of exchange
419            let id = uuid::Uuid::new_v4().to_string();
420            let marker = format!("<!-- agent:boundary:{} -->", id);
421            let content = exchange.content(&result);
422            let new_content = format!("{}\n{}\n", content.trim_end(), marker);
423            result = exchange.replace_content(&result, &new_content);
424            eprintln!("[template] re-inserted boundary {} at end of exchange", &id[..id.len().min(8)]);
425        }
426    }
427
428    Ok(result)
429}
430
431/// Reposition the boundary marker to the end of the exchange component.
432///
433/// Removes all existing boundaries and inserts a fresh one at the end of
434/// the exchange. This is the same pre-patch logic used in
435/// `apply_patches_with_overrides()`, extracted for use by the IPC write path.
436///
437/// Returns the document unchanged if no exchange component exists.
438pub fn reposition_boundary_to_end(doc: &str) -> String {
439    reposition_boundary_to_end_with_summary(doc, None)
440}
441
442/// Reposition boundary with an optional human-readable summary suffix.
443///
444/// The summary is slugified and appended to the boundary ID:
445/// `a0cfeb34:agent-doc` instead of just `a0cfeb34`.
446pub fn reposition_boundary_to_end_with_summary(doc: &str, summary: Option<&str>) -> String {
447    let mut result = remove_all_boundaries(doc);
448    if let Ok(components) = component::parse(&result)
449        && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
450    {
451        let id = crate::new_boundary_id_with_summary(summary);
452        let marker = crate::format_boundary_marker(&id);
453        let content = exchange.content(&result);
454        let new_content = format!("{}\n{}\n", content.trim_end(), marker);
455        result = exchange.replace_content(&result, &new_content);
456    }
457    result
458}
459
460/// Remove all boundary markers from a document (line-level removal).
461/// Skips boundaries inside fenced code blocks (lesson #13).
462fn remove_all_boundaries(doc: &str) -> String {
463    let prefix = "<!-- agent:boundary:";
464    let suffix = " -->";
465    let code_ranges = component::find_code_ranges(doc);
466    let in_code = |pos: usize| code_ranges.iter().any(|&(start, end)| pos >= start && pos < end);
467    let mut result = String::with_capacity(doc.len());
468    let mut offset = 0;
469    for line in doc.lines() {
470        let trimmed = line.trim();
471        let is_boundary = trimmed.starts_with(prefix) && trimmed.ends_with(suffix);
472        if is_boundary && !in_code(offset) {
473            // Skip boundary marker lines outside code blocks
474            offset += line.len() + 1; // +1 for newline
475            continue;
476        }
477        result.push_str(line);
478        result.push('\n');
479        offset += line.len() + 1;
480    }
481    if !doc.ends_with('\n') && result.ends_with('\n') {
482        result.pop();
483    }
484    result
485}
486
487/// Find a boundary marker ID inside a component's content, skipping code blocks.
488fn find_boundary_in_component(doc: &str, comp: &Component) -> Option<String> {
489    let prefix = "<!-- agent:boundary:";
490    let suffix = " -->";
491    let content_region = &doc[comp.open_end..comp.close_start];
492    let code_ranges = component::find_code_ranges(doc);
493    let mut search_from = 0;
494    while let Some(start) = content_region[search_from..].find(prefix) {
495        let abs_start = comp.open_end + search_from + start;
496        if code_ranges.iter().any(|&(cs, ce)| abs_start >= cs && abs_start < ce) {
497            search_from += start + prefix.len();
498            continue;
499        }
500        let after_prefix = &content_region[search_from + start + prefix.len()..];
501        if let Some(end) = after_prefix.find(suffix) {
502            return Some(after_prefix[..end].trim().to_string());
503        }
504        break;
505    }
506    None
507}
508
509/// Get template info for a document (for plugin rendering).
510pub fn template_info(file: &Path) -> Result<TemplateInfo> {
511    let doc = std::fs::read_to_string(file)
512        .with_context(|| format!("failed to read {}", file.display()))?;
513
514    let (fm, _body) = crate::frontmatter::parse(&doc)?;
515    let template_mode = fm.resolve_mode().is_template();
516
517    let components = component::parse(&doc)
518        .with_context(|| format!("failed to parse components in {}", file.display()))?;
519
520    let configs = load_component_configs(file);
521
522    let component_infos: Vec<ComponentInfo> = components
523        .iter()
524        .map(|comp| {
525            let content = comp.content(&doc).to_string();
526            // Inline attr > components.toml > built-in default
527            let mode = comp.patch_mode().map(|s| s.to_string())
528                .or_else(|| configs.get(&comp.name).cloned())
529                .unwrap_or_else(|| default_mode(&comp.name).to_string());
530            // Compute line number from byte offset
531            let line = doc[..comp.open_start].matches('\n').count() + 1;
532            ComponentInfo {
533                name: comp.name.clone(),
534                mode,
535                content,
536                line,
537                max_entries: None, // TODO: read from components.toml
538            }
539        })
540        .collect();
541
542    Ok(TemplateInfo {
543        template_mode,
544        components: component_infos,
545    })
546}
547
548/// Load component mode configs from `.agent-doc/components.toml`.
549/// Returns a map of component_name → mode string.
550fn load_component_configs(file: &Path) -> std::collections::HashMap<String, String> {
551    let mut result = std::collections::HashMap::new();
552    let root = find_project_root(file);
553    if let Some(root) = root {
554        let config_path = root.join(".agent-doc/components.toml");
555        if config_path.exists()
556            && let Ok(content) = std::fs::read_to_string(&config_path)
557            && let Ok(table) = content.parse::<toml::Table>()
558        {
559            for (name, value) in &table {
560                // "patch" is the primary key; "mode" is a backward-compatible alias
561                if let Some(mode) = value.get("patch").and_then(|v| v.as_str())
562                    .or_else(|| value.get("mode").and_then(|v| v.as_str()))
563                {
564                    result.insert(name.clone(), mode.to_string());
565                }
566            }
567        }
568    }
569    result
570}
571
572/// Load max_lines settings from `.agent-doc/components.toml`.
573fn load_max_lines_configs(file: &Path) -> std::collections::HashMap<String, usize> {
574    let mut result = std::collections::HashMap::new();
575    let root = find_project_root(file);
576    if let Some(root) = root {
577        let config_path = root.join(".agent-doc/components.toml");
578        if config_path.exists()
579            && let Ok(content) = std::fs::read_to_string(&config_path)
580            && let Ok(table) = content.parse::<toml::Table>()
581        {
582            for (name, value) in &table {
583                if let Some(max_lines) = value.get("max_lines").and_then(|v| v.as_integer())
584                    && max_lines > 0
585                {
586                    result.insert(name.clone(), max_lines as usize);
587                }
588            }
589        }
590    }
591    result
592}
593
594/// Default mode for a component by name.
595/// `exchange` and `findings` default to `append`; all others default to `replace`.
596fn default_mode(name: &str) -> &'static str {
597    match name {
598        "exchange" | "findings" => "append",
599        _ => "replace",
600    }
601}
602
603/// Trim content to the last N lines.
604fn limit_lines(content: &str, max_lines: usize) -> String {
605    let lines: Vec<&str> = content.lines().collect();
606    if lines.len() <= max_lines {
607        return content.to_string();
608    }
609    lines[lines.len() - max_lines..].join("\n")
610}
611
612/// Remove consecutive identical non-blank lines in the exchange component.
613///
614/// Prevents agent echoes of user prompts from creating duplicates when
615/// `apply_mode("append")` concatenates existing content that already ends
616/// with the first line(s) of the new patch content.
617///
618/// Only non-blank lines are subject to deduplication — blank lines are
619/// intentional separators and are never collapsed.
620fn dedup_exchange_adjacent_lines(doc: &str) -> String {
621    let Ok(components) = component::parse(doc) else {
622        return doc.to_string();
623    };
624    let Some(exchange) = components.iter().find(|c| c.name == "exchange") else {
625        return doc.to_string();
626    };
627    let content = exchange.content(doc);
628    let mut deduped = String::with_capacity(content.len());
629    let mut prev_nonempty: Option<&str> = None;
630    for line in content.lines() {
631        if !line.trim().is_empty() && prev_nonempty == Some(line) {
632            // Skip exact duplicate adjacent non-blank line
633            continue;
634        }
635        deduped.push_str(line);
636        deduped.push('\n');
637        if !line.trim().is_empty() {
638            prev_nonempty = Some(line);
639        }
640    }
641    // Preserve original trailing-newline behaviour
642    if !content.ends_with('\n') && deduped.ends_with('\n') {
643        deduped.pop();
644    }
645    if deduped == content {
646        return doc.to_string();
647    }
648    exchange.replace_content(doc, &deduped)
649}
650
651/// Apply mode logic (replace/append/prepend).
652fn apply_mode(mode: &str, existing: &str, new_content: &str) -> String {
653    match mode {
654        "append" => {
655            let stripped = strip_leading_overlap(existing, new_content);
656            format!("{}{}", existing, stripped)
657        }
658        "prepend" => format!("{}{}", new_content, existing),
659        _ => new_content.to_string(), // "replace" default
660    }
661}
662
663/// Strip the last non-blank line of `existing` from the start of `new_content` if present.
664///
665/// When an agent echoes the user's last prompt as the first line of its patch,
666/// append mode would duplicate that line. This strips the overlap before concatenation.
667fn strip_leading_overlap<'a>(existing: &str, new_content: &'a str) -> &'a str {
668    let last_nonempty = existing.lines().rfind(|l| !l.trim().is_empty());
669    let Some(last) = last_nonempty else {
670        return new_content;
671    };
672    let test = format!("{}\n", last);
673    if new_content.starts_with(test.as_str()) {
674        &new_content[test.len()..]
675    } else {
676        new_content
677    }
678}
679
680fn find_project_root(file: &Path) -> Option<std::path::PathBuf> {
681    let canonical = file.canonicalize().ok()?;
682    let mut dir = canonical.parent()?;
683    loop {
684        if dir.join(".agent-doc").is_dir() {
685            return Some(dir.to_path_buf());
686        }
687        dir = dir.parent()?;
688    }
689}
690
691/// Find `needle` in `haystack` starting at `from`, skipping occurrences inside code ranges.
692/// Returns the byte offset of the match within `haystack` (absolute, not relative to `from`).
693fn find_outside_code(needle: &str, haystack: &str, from: usize, code_ranges: &[(usize, usize)]) -> Option<usize> {
694    let mut search_start = from;
695    loop {
696        let rel = haystack[search_start..].find(needle)?;
697        let abs = search_start + rel;
698        if code_ranges.iter().any(|&(start, end)| abs >= start && abs < end) {
699            // Inside a code block — skip past this occurrence
700            search_start = abs + needle.len();
701            continue;
702        }
703        return Some(abs);
704    }
705}
706
707
708#[cfg(test)]
709mod tests {
710    use super::*;
711    use tempfile::TempDir;
712
713    fn setup_project() -> TempDir {
714        let dir = TempDir::new().unwrap();
715        std::fs::create_dir_all(dir.path().join(".agent-doc/snapshots")).unwrap();
716        dir
717    }
718
719    #[test]
720    fn parse_single_patch() {
721        let response = "<!-- patch:status -->\nBuild passing.\n<!-- /patch:status -->\n";
722        let (patches, unmatched) = parse_patches(response).unwrap();
723        assert_eq!(patches.len(), 1);
724        assert_eq!(patches[0].name, "status");
725        assert_eq!(patches[0].content, "Build passing.\n");
726        assert!(unmatched.is_empty());
727    }
728
729    #[test]
730    fn parse_multiple_patches() {
731        let response = "\
732<!-- patch:status -->
733All green.
734<!-- /patch:status -->
735
736<!-- patch:log -->
737- New entry
738<!-- /patch:log -->
739";
740        let (patches, unmatched) = parse_patches(response).unwrap();
741        assert_eq!(patches.len(), 2);
742        assert_eq!(patches[0].name, "status");
743        assert_eq!(patches[0].content, "All green.\n");
744        assert_eq!(patches[1].name, "log");
745        assert_eq!(patches[1].content, "- New entry\n");
746        assert!(unmatched.is_empty());
747    }
748
749    #[test]
750    fn parse_with_unmatched_content() {
751        let response = "Some free text.\n\n<!-- patch:status -->\nOK\n<!-- /patch:status -->\n\nTrailing text.\n";
752        let (patches, unmatched) = parse_patches(response).unwrap();
753        assert_eq!(patches.len(), 1);
754        assert_eq!(patches[0].name, "status");
755        assert!(unmatched.contains("Some free text."));
756        assert!(unmatched.contains("Trailing text."));
757    }
758
759    #[test]
760    fn parse_empty_response() {
761        let (patches, unmatched) = parse_patches("").unwrap();
762        assert!(patches.is_empty());
763        assert!(unmatched.is_empty());
764    }
765
766    #[test]
767    fn parse_no_patches() {
768        let response = "Just a plain response with no patch blocks.";
769        let (patches, unmatched) = parse_patches(response).unwrap();
770        assert!(patches.is_empty());
771        assert_eq!(unmatched, "Just a plain response with no patch blocks.");
772    }
773
774    #[test]
775    fn apply_patches_replace() {
776        let dir = setup_project();
777        let doc_path = dir.path().join("test.md");
778        let doc = "# Dashboard\n\n<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
779        std::fs::write(&doc_path, doc).unwrap();
780
781        let patches = vec![PatchBlock {
782            name: "status".to_string(),
783            content: "new\n".to_string(),
784            attrs: Default::default(),
785        }];
786        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
787        assert!(result.contains("new\n"));
788        assert!(!result.contains("\nold\n"));
789        assert!(result.contains("<!-- agent:status -->"));
790    }
791
792    #[test]
793    fn apply_patches_unmatched_creates_exchange() {
794        let dir = setup_project();
795        let doc_path = dir.path().join("test.md");
796        let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
797        std::fs::write(&doc_path, doc).unwrap();
798
799        let result = apply_patches(doc, &[], "Extra info here", &doc_path).unwrap();
800        assert!(result.contains("<!-- agent:exchange -->"));
801        assert!(result.contains("Extra info here"));
802        assert!(result.contains("<!-- /agent:exchange -->"));
803    }
804
805    #[test]
806    fn apply_patches_unmatched_appends_to_existing_exchange() {
807        let dir = setup_project();
808        let doc_path = dir.path().join("test.md");
809        let doc = "<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
810        std::fs::write(&doc_path, doc).unwrap();
811
812        let result = apply_patches(doc, &[], "new stuff", &doc_path).unwrap();
813        assert!(result.contains("previous"));
814        assert!(result.contains("new stuff"));
815        // Should not create a second exchange component
816        assert_eq!(result.matches("<!-- agent:exchange -->").count(), 1);
817    }
818
819    #[test]
820    fn apply_patches_missing_component_routes_to_exchange() {
821        let dir = setup_project();
822        let doc_path = dir.path().join("test.md");
823        let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
824        std::fs::write(&doc_path, doc).unwrap();
825
826        let patches = vec![PatchBlock {
827            name: "nonexistent".to_string(),
828            content: "overflow data\n".to_string(),
829            attrs: Default::default(),
830        }];
831        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
832        // Missing component content should be routed to exchange
833        assert!(result.contains("overflow data"), "missing patch content should appear in exchange");
834        assert!(result.contains("previous"), "existing exchange content should be preserved");
835    }
836
837    #[test]
838    fn apply_patches_missing_component_creates_exchange() {
839        let dir = setup_project();
840        let doc_path = dir.path().join("test.md");
841        let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
842        std::fs::write(&doc_path, doc).unwrap();
843
844        let patches = vec![PatchBlock {
845            name: "nonexistent".to_string(),
846            content: "overflow data\n".to_string(),
847            attrs: Default::default(),
848        }];
849        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
850        // Should auto-create exchange component
851        assert!(result.contains("<!-- agent:exchange -->"), "should create exchange component");
852        assert!(result.contains("overflow data"), "overflow content should be in exchange");
853    }
854
855    #[test]
856    fn is_template_mode_detection() {
857        assert!(is_template_mode(Some("template")));
858        assert!(!is_template_mode(Some("append")));
859        assert!(!is_template_mode(None));
860    }
861
862    #[test]
863    fn template_info_works() {
864        let dir = setup_project();
865        let doc_path = dir.path().join("test.md");
866        let doc = "---\nagent_doc_format: template\n---\n\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
867        std::fs::write(&doc_path, doc).unwrap();
868
869        let info = template_info(&doc_path).unwrap();
870        assert!(info.template_mode);
871        assert_eq!(info.components.len(), 1);
872        assert_eq!(info.components[0].name, "status");
873        assert_eq!(info.components[0].content, "content\n");
874    }
875
876    #[test]
877    fn template_info_legacy_mode_works() {
878        let dir = setup_project();
879        let doc_path = dir.path().join("test.md");
880        let doc = "---\nresponse_mode: template\n---\n\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
881        std::fs::write(&doc_path, doc).unwrap();
882
883        let info = template_info(&doc_path).unwrap();
884        assert!(info.template_mode);
885    }
886
887    #[test]
888    fn template_info_append_mode() {
889        let dir = setup_project();
890        let doc_path = dir.path().join("test.md");
891        let doc = "---\nagent_doc_format: append\n---\n\n# Doc\n";
892        std::fs::write(&doc_path, doc).unwrap();
893
894        let info = template_info(&doc_path).unwrap();
895        assert!(!info.template_mode);
896        assert!(info.components.is_empty());
897    }
898
899    #[test]
900    fn parse_patches_ignores_markers_in_fenced_code_block() {
901        let response = "\
902<!-- patch:exchange -->
903Here is how you use component markers:
904
905```markdown
906<!-- agent:exchange -->
907example content
908<!-- /agent:exchange -->
909```
910
911<!-- /patch:exchange -->
912";
913        let (patches, unmatched) = parse_patches(response).unwrap();
914        assert_eq!(patches.len(), 1);
915        assert_eq!(patches[0].name, "exchange");
916        assert!(patches[0].content.contains("```markdown"));
917        assert!(patches[0].content.contains("<!-- agent:exchange -->"));
918        assert!(unmatched.is_empty());
919    }
920
921    #[test]
922    fn parse_patches_ignores_patch_markers_in_fenced_code_block() {
923        // Patch markers inside a code block should not be treated as real patches
924        let response = "\
925<!-- patch:exchange -->
926Real content here.
927
928```markdown
929<!-- patch:fake -->
930This is just an example.
931<!-- /patch:fake -->
932```
933
934<!-- /patch:exchange -->
935";
936        let (patches, unmatched) = parse_patches(response).unwrap();
937        assert_eq!(patches.len(), 1, "should only find the outer real patch");
938        assert_eq!(patches[0].name, "exchange");
939        assert!(patches[0].content.contains("<!-- patch:fake -->"), "code block content should be preserved");
940        assert!(unmatched.is_empty());
941    }
942
943    #[test]
944    fn parse_patches_ignores_markers_in_tilde_fence() {
945        let response = "\
946<!-- patch:status -->
947OK
948<!-- /patch:status -->
949
950~~~
951<!-- patch:fake -->
952example
953<!-- /patch:fake -->
954~~~
955";
956        let (patches, _unmatched) = parse_patches(response).unwrap();
957        // Only the real patch should be found; the fake one inside ~~~ is ignored
958        assert_eq!(patches.len(), 1);
959        assert_eq!(patches[0].name, "status");
960    }
961
962    #[test]
963    fn parse_patches_ignores_closing_marker_in_code_block() {
964        // The closing marker for a real patch is inside a code block,
965        // so the parser should skip it and find the real closing marker outside
966        let response = "\
967<!-- patch:exchange -->
968Example:
969
970```
971<!-- /patch:exchange -->
972```
973
974Real content continues.
975<!-- /patch:exchange -->
976";
977        let (patches, _unmatched) = parse_patches(response).unwrap();
978        assert_eq!(patches.len(), 1);
979        assert_eq!(patches[0].name, "exchange");
980        assert!(patches[0].content.contains("Real content continues."));
981    }
982
983    #[test]
984    fn parse_patches_normal_markers_still_work() {
985        // Sanity check: normal patch parsing without code blocks still works
986        let response = "\
987<!-- patch:status -->
988All systems go.
989<!-- /patch:status -->
990<!-- patch:log -->
991- Entry 1
992<!-- /patch:log -->
993";
994        let (patches, unmatched) = parse_patches(response).unwrap();
995        assert_eq!(patches.len(), 2);
996        assert_eq!(patches[0].name, "status");
997        assert_eq!(patches[0].content, "All systems go.\n");
998        assert_eq!(patches[1].name, "log");
999        assert_eq!(patches[1].content, "- Entry 1\n");
1000        assert!(unmatched.is_empty());
1001    }
1002
1003    // --- Inline attribute mode resolution tests ---
1004
1005    #[test]
1006    fn inline_attr_mode_overrides_config() {
1007        // Component has mode=replace inline, but components.toml says append
1008        let dir = setup_project();
1009        let doc_path = dir.path().join("test.md");
1010        // Write config with append mode for status
1011        std::fs::write(
1012            dir.path().join(".agent-doc/components.toml"),
1013            "[status]\nmode = \"append\"\n",
1014        ).unwrap();
1015        // But the inline attr says replace
1016        let doc = "<!-- agent:status mode=replace -->\nold\n<!-- /agent:status -->\n";
1017        std::fs::write(&doc_path, doc).unwrap();
1018
1019        let patches = vec![PatchBlock {
1020            name: "status".to_string(),
1021            content: "new\n".to_string(),
1022            attrs: Default::default(),
1023        }];
1024        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1025        // Inline replace should win over config append
1026        assert!(result.contains("new\n"));
1027        assert!(!result.contains("old\n"));
1028    }
1029
1030    #[test]
1031    fn inline_attr_mode_overrides_default() {
1032        // exchange defaults to append, but inline says replace
1033        let dir = setup_project();
1034        let doc_path = dir.path().join("test.md");
1035        let doc = "<!-- agent:exchange mode=replace -->\nold\n<!-- /agent:exchange -->\n";
1036        std::fs::write(&doc_path, doc).unwrap();
1037
1038        let patches = vec![PatchBlock {
1039            name: "exchange".to_string(),
1040            content: "new\n".to_string(),
1041            attrs: Default::default(),
1042        }];
1043        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1044        assert!(result.contains("new\n"));
1045        assert!(!result.contains("old\n"));
1046    }
1047
1048    #[test]
1049    fn no_inline_attr_falls_back_to_config() {
1050        // No inline attr → falls back to components.toml
1051        let dir = setup_project();
1052        let doc_path = dir.path().join("test.md");
1053        std::fs::write(
1054            dir.path().join(".agent-doc/components.toml"),
1055            "[status]\nmode = \"append\"\n",
1056        ).unwrap();
1057        let doc = "<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
1058        std::fs::write(&doc_path, doc).unwrap();
1059
1060        let patches = vec![PatchBlock {
1061            name: "status".to_string(),
1062            content: "new\n".to_string(),
1063            attrs: Default::default(),
1064        }];
1065        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1066        // Config says append, so both old and new should be present
1067        assert!(result.contains("old\n"));
1068        assert!(result.contains("new\n"));
1069    }
1070
1071    #[test]
1072    fn no_inline_attr_no_config_falls_back_to_default() {
1073        // No inline attr, no config → built-in defaults
1074        let dir = setup_project();
1075        let doc_path = dir.path().join("test.md");
1076        let doc = "<!-- agent:exchange -->\nold\n<!-- /agent:exchange -->\n";
1077        std::fs::write(&doc_path, doc).unwrap();
1078
1079        let patches = vec![PatchBlock {
1080            name: "exchange".to_string(),
1081            content: "new\n".to_string(),
1082            attrs: Default::default(),
1083        }];
1084        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1085        // exchange defaults to append
1086        assert!(result.contains("old\n"));
1087        assert!(result.contains("new\n"));
1088    }
1089
1090    #[test]
1091    fn inline_patch_attr_overrides_config() {
1092        // Component has patch=replace inline, but components.toml says append
1093        let dir = setup_project();
1094        let doc_path = dir.path().join("test.md");
1095        std::fs::write(
1096            dir.path().join(".agent-doc/components.toml"),
1097            "[status]\nmode = \"append\"\n",
1098        ).unwrap();
1099        let doc = "<!-- agent:status patch=replace -->\nold\n<!-- /agent:status -->\n";
1100        std::fs::write(&doc_path, doc).unwrap();
1101
1102        let patches = vec![PatchBlock {
1103            name: "status".to_string(),
1104            content: "new\n".to_string(),
1105            attrs: Default::default(),
1106        }];
1107        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1108        assert!(result.contains("new\n"));
1109        assert!(!result.contains("old\n"));
1110    }
1111
1112    #[test]
1113    fn inline_patch_attr_overrides_mode_attr() {
1114        // Both patch= and mode= present; patch= wins
1115        let dir = setup_project();
1116        let doc_path = dir.path().join("test.md");
1117        let doc = "<!-- agent:exchange patch=replace mode=append -->\nold\n<!-- /agent:exchange -->\n";
1118        std::fs::write(&doc_path, doc).unwrap();
1119
1120        let patches = vec![PatchBlock {
1121            name: "exchange".to_string(),
1122            content: "new\n".to_string(),
1123            attrs: Default::default(),
1124        }];
1125        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1126        assert!(result.contains("new\n"));
1127        assert!(!result.contains("old\n"));
1128    }
1129
1130    #[test]
1131    fn toml_patch_key_works() {
1132        // components.toml uses `patch = "append"` instead of `mode = "append"`
1133        let dir = setup_project();
1134        let doc_path = dir.path().join("test.md");
1135        std::fs::write(
1136            dir.path().join(".agent-doc/components.toml"),
1137            "[status]\npatch = \"append\"\n",
1138        ).unwrap();
1139        let doc = "<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
1140        std::fs::write(&doc_path, doc).unwrap();
1141
1142        let patches = vec![PatchBlock {
1143            name: "status".to_string(),
1144            content: "new\n".to_string(),
1145            attrs: Default::default(),
1146        }];
1147        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1148        assert!(result.contains("old\n"));
1149        assert!(result.contains("new\n"));
1150    }
1151
1152    #[test]
1153    fn stream_override_beats_inline_attr() {
1154        // Stream mode overrides should still beat inline attrs
1155        let dir = setup_project();
1156        let doc_path = dir.path().join("test.md");
1157        let doc = "<!-- agent:exchange mode=append -->\nold\n<!-- /agent:exchange -->\n";
1158        std::fs::write(&doc_path, doc).unwrap();
1159
1160        let patches = vec![PatchBlock {
1161            name: "exchange".to_string(),
1162            content: "new\n".to_string(),
1163            attrs: Default::default(),
1164        }];
1165        let mut overrides = std::collections::HashMap::new();
1166        overrides.insert("exchange".to_string(), "replace".to_string());
1167        let result = apply_patches_with_overrides(doc, &patches, "", &doc_path, &overrides).unwrap();
1168        // Stream override (replace) should win over inline attr (append)
1169        assert!(result.contains("new\n"));
1170        assert!(!result.contains("old\n"));
1171    }
1172
1173    #[test]
1174    fn apply_patches_ignores_component_tags_in_code_blocks() {
1175        // Component tags inside a fenced code block should not be patch targets.
1176        // Only the real top-level component should receive the patch content.
1177        let dir = setup_project();
1178        let doc_path = dir.path().join("test.md");
1179        let doc = "\
1180# Scaffold Guide
1181
1182Here is an example of a component:
1183
1184```markdown
1185<!-- agent:status -->
1186example scaffold content
1187<!-- /agent:status -->
1188```
1189
1190<!-- agent:status -->
1191real status content
1192<!-- /agent:status -->
1193";
1194        std::fs::write(&doc_path, doc).unwrap();
1195
1196        let patches = vec![PatchBlock {
1197            name: "status".to_string(),
1198            content: "patched status\n".to_string(),
1199            attrs: Default::default(),
1200        }];
1201        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1202
1203        // The real component should be patched
1204        assert!(result.contains("patched status\n"), "real component should receive the patch");
1205        // The code block example should be untouched
1206        assert!(result.contains("example scaffold content"), "code block content should be preserved");
1207        // The code block's markers should still be there
1208        assert!(result.contains("```markdown\n<!-- agent:status -->"), "code block markers should be preserved");
1209    }
1210
1211    #[test]
1212    fn unmatched_content_uses_boundary_marker() {
1213        let dir = setup_project();
1214        let file = dir.path().join("test.md");
1215        let doc = concat!(
1216            "---\nagent_doc_format: template\n---\n",
1217            "<!-- agent:exchange patch=append -->\n",
1218            "User prompt here.\n",
1219            "<!-- agent:boundary:test-uuid-123 -->\n",
1220            "<!-- /agent:exchange -->\n",
1221        );
1222        std::fs::write(&file, doc).unwrap();
1223
1224        // No patch blocks — only unmatched content (simulates skill not wrapping in patch blocks)
1225        let patches = vec![];
1226        let unmatched = "### Re: Response\n\nResponse content here.\n";
1227
1228        let result = apply_patches(doc, &patches, unmatched, &file).unwrap();
1229
1230        // Response should be inserted at the boundary marker position (after prompt)
1231        let prompt_pos = result.find("User prompt here.").unwrap();
1232        let response_pos = result.find("### Re: Response").unwrap();
1233        assert!(
1234            response_pos > prompt_pos,
1235            "response should appear after the user prompt (boundary insertion)"
1236        );
1237
1238        // Boundary marker should be consumed (replaced by response)
1239        assert!(
1240            !result.contains("test-uuid-123"),
1241            "boundary marker should be consumed after insertion"
1242        );
1243    }
1244
1245    #[test]
1246    fn explicit_patch_uses_boundary_marker() {
1247        let dir = setup_project();
1248        let file = dir.path().join("test.md");
1249        let doc = concat!(
1250            "---\nagent_doc_format: template\n---\n",
1251            "<!-- agent:exchange patch=append -->\n",
1252            "User prompt here.\n",
1253            "<!-- agent:boundary:patch-uuid-456 -->\n",
1254            "<!-- /agent:exchange -->\n",
1255        );
1256        std::fs::write(&file, doc).unwrap();
1257
1258        // Explicit patch block targeting exchange
1259        let patches = vec![PatchBlock {
1260            name: "exchange".to_string(),
1261            content: "### Re: Response\n\nResponse content.\n".to_string(),
1262            attrs: Default::default(),
1263        }];
1264
1265        let result = apply_patches(doc, &patches, "", &file).unwrap();
1266
1267        // Response should be after prompt (boundary consumed)
1268        let prompt_pos = result.find("User prompt here.").unwrap();
1269        let response_pos = result.find("### Re: Response").unwrap();
1270        assert!(
1271            response_pos > prompt_pos,
1272            "response should appear after user prompt"
1273        );
1274
1275        // Boundary marker should be consumed
1276        assert!(
1277            !result.contains("patch-uuid-456"),
1278            "boundary marker should be consumed by explicit patch"
1279        );
1280    }
1281
1282    #[test]
1283    fn boundary_reinserted_even_when_original_doc_has_no_boundary() {
1284        // Regression: the snowball bug — once one cycle loses the boundary,
1285        // every subsequent cycle also loses it because orig_had_boundary finds nothing.
1286        let dir = setup_project();
1287        let file = dir.path().join("test.md");
1288        // Document with exchange but NO boundary marker
1289        let doc = "<!-- agent:exchange patch=append -->\nUser prompt here.\n<!-- /agent:exchange -->\n";
1290        std::fs::write(&file, doc).unwrap();
1291
1292        let response = "<!-- patch:exchange -->\nAgent response.\n<!-- /patch:exchange -->\n";
1293        let (patches, unmatched) = parse_patches(response).unwrap();
1294        let result = apply_patches(doc, &patches, &unmatched, &file).unwrap();
1295
1296        // Must have a boundary at end of exchange, even though original had none
1297        assert!(
1298            result.contains("<!-- agent:boundary:"),
1299            "boundary must be re-inserted even when original doc had no boundary: {result}"
1300        );
1301    }
1302
1303    #[test]
1304    fn boundary_survives_multiple_cycles() {
1305        // Simulate two consecutive write cycles — boundary must persist
1306        let dir = setup_project();
1307        let file = dir.path().join("test.md");
1308        let doc = "<!-- agent:exchange patch=append -->\nPrompt 1.\n<!-- /agent:exchange -->\n";
1309        std::fs::write(&file, doc).unwrap();
1310
1311        // Cycle 1
1312        let response1 = "<!-- patch:exchange -->\nResponse 1.\n<!-- /patch:exchange -->\n";
1313        let (patches1, unmatched1) = parse_patches(response1).unwrap();
1314        let result1 = apply_patches(doc, &patches1, &unmatched1, &file).unwrap();
1315        assert!(result1.contains("<!-- agent:boundary:"), "cycle 1 must have boundary");
1316
1317        // Cycle 2 — use cycle 1's output as the new doc (simulates next write)
1318        let response2 = "<!-- patch:exchange -->\nResponse 2.\n<!-- /patch:exchange -->\n";
1319        let (patches2, unmatched2) = parse_patches(response2).unwrap();
1320        let result2 = apply_patches(&result1, &patches2, &unmatched2, &file).unwrap();
1321        assert!(result2.contains("<!-- agent:boundary:"), "cycle 2 must have boundary");
1322    }
1323
1324    #[test]
1325    fn remove_all_boundaries_skips_code_blocks() {
1326        let doc = "before\n```\n<!-- agent:boundary:fake-id -->\n```\nafter\n<!-- agent:boundary:real-id -->\nend\n";
1327        let result = remove_all_boundaries(doc);
1328        // The one inside the code block should survive
1329        assert!(
1330            result.contains("<!-- agent:boundary:fake-id -->"),
1331            "boundary inside code block must be preserved: {result}"
1332        );
1333        // The one outside should be removed
1334        assert!(
1335            !result.contains("<!-- agent:boundary:real-id -->"),
1336            "boundary outside code block must be removed: {result}"
1337        );
1338    }
1339
1340    #[test]
1341    fn reposition_boundary_moves_to_end() {
1342        let doc = "\
1343<!-- agent:exchange -->
1344Previous response.
1345<!-- agent:boundary:old-id -->
1346User prompt here.
1347<!-- /agent:exchange -->";
1348        let result = reposition_boundary_to_end(doc);
1349        // Old boundary should be gone
1350        assert!(!result.contains("old-id"), "old boundary should be removed");
1351        // New boundary should exist
1352        assert!(result.contains("<!-- agent:boundary:"), "new boundary should be inserted");
1353        // New boundary should be after the user prompt, before close tag
1354        let boundary_pos = result.find("<!-- agent:boundary:").unwrap();
1355        let prompt_pos = result.find("User prompt here.").unwrap();
1356        let close_pos = result.find("<!-- /agent:exchange -->").unwrap();
1357        assert!(boundary_pos > prompt_pos, "boundary should be after user prompt");
1358        assert!(boundary_pos < close_pos, "boundary should be before close tag");
1359    }
1360
1361    #[test]
1362    fn reposition_boundary_no_exchange_unchanged() {
1363        let doc = "\
1364<!-- agent:output -->
1365Some content.
1366<!-- /agent:output -->";
1367        let result = reposition_boundary_to_end(doc);
1368        assert!(!result.contains("<!-- agent:boundary:"), "no boundary should be added to non-exchange");
1369    }
1370
1371    #[test]
1372    fn max_lines_inline_attr_trims_content() {
1373        let dir = setup_project();
1374        let doc_path = dir.path().join("test.md");
1375        let doc = "<!-- agent:log patch=replace max_lines=3 -->\nold\n<!-- /agent:log -->\n";
1376        std::fs::write(&doc_path, doc).unwrap();
1377
1378        let patches = vec![PatchBlock {
1379            name: "log".to_string(),
1380            content: "line1\nline2\nline3\nline4\nline5\n".to_string(),
1381            attrs: Default::default(),
1382        }];
1383        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1384        assert!(!result.contains("line1"));
1385        assert!(!result.contains("line2"));
1386        assert!(result.contains("line3"));
1387        assert!(result.contains("line4"));
1388        assert!(result.contains("line5"));
1389    }
1390
1391    #[test]
1392    fn max_lines_noop_when_under_limit() {
1393        let dir = setup_project();
1394        let doc_path = dir.path().join("test.md");
1395        let doc = "<!-- agent:log patch=replace max_lines=10 -->\nold\n<!-- /agent:log -->\n";
1396        std::fs::write(&doc_path, doc).unwrap();
1397
1398        let patches = vec![PatchBlock {
1399            name: "log".to_string(),
1400            content: "line1\nline2\n".to_string(),
1401            attrs: Default::default(),
1402        }];
1403        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1404        assert!(result.contains("line1"));
1405        assert!(result.contains("line2"));
1406    }
1407
1408    #[test]
1409    fn max_lines_from_components_toml() {
1410        let dir = setup_project();
1411        let doc_path = dir.path().join("test.md");
1412        std::fs::write(
1413            dir.path().join(".agent-doc/components.toml"),
1414            "[log]\npatch = \"replace\"\nmax_lines = 2\n",
1415        )
1416        .unwrap();
1417        let doc = "<!-- agent:log -->\nold\n<!-- /agent:log -->\n";
1418        std::fs::write(&doc_path, doc).unwrap();
1419
1420        let patches = vec![PatchBlock {
1421            name: "log".to_string(),
1422            content: "a\nb\nc\nd\n".to_string(),
1423            attrs: Default::default(),
1424        }];
1425        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1426        assert!(!result.contains("\na\n"));
1427        assert!(!result.contains("\nb\n"));
1428        assert!(result.contains("c"));
1429        assert!(result.contains("d"));
1430    }
1431
1432    #[test]
1433    fn max_lines_inline_beats_toml() {
1434        let dir = setup_project();
1435        let doc_path = dir.path().join("test.md");
1436        std::fs::write(
1437            dir.path().join(".agent-doc/components.toml"),
1438            "[log]\nmax_lines = 1\n",
1439        )
1440        .unwrap();
1441        let doc = "<!-- agent:log patch=replace max_lines=3 -->\nold\n<!-- /agent:log -->\n";
1442        std::fs::write(&doc_path, doc).unwrap();
1443
1444        let patches = vec![PatchBlock {
1445            name: "log".to_string(),
1446            content: "a\nb\nc\nd\n".to_string(),
1447            attrs: Default::default(),
1448        }];
1449        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1450        // Inline max_lines=3 should win over toml max_lines=1
1451        assert!(result.contains("b"));
1452        assert!(result.contains("c"));
1453        assert!(result.contains("d"));
1454    }
1455
1456    #[test]
1457    fn parse_patch_with_transfer_source_attr() {
1458        let response = "<!-- patch:exchange transfer-source=\"tasks/eval-runner.md\" -->\nTransferred content.\n<!-- /patch:exchange -->\n";
1459        let (patches, unmatched) = parse_patches(response).unwrap();
1460        assert_eq!(patches.len(), 1);
1461        assert_eq!(patches[0].name, "exchange");
1462        assert_eq!(patches[0].content, "Transferred content.\n");
1463        assert_eq!(
1464            patches[0].attrs.get("transfer-source"),
1465            Some(&"\"tasks/eval-runner.md\"".to_string())
1466        );
1467        assert!(unmatched.is_empty());
1468    }
1469
1470    #[test]
1471    fn parse_patch_without_attrs() {
1472        let response = "<!-- patch:exchange -->\nContent.\n<!-- /patch:exchange -->\n";
1473        let (patches, _) = parse_patches(response).unwrap();
1474        assert_eq!(patches.len(), 1);
1475        assert!(patches[0].attrs.is_empty());
1476    }
1477
1478    #[test]
1479    fn parse_patch_with_multiple_attrs() {
1480        let response = "<!-- patch:output mode=replace max_lines=50 -->\nContent.\n<!-- /patch:output -->\n";
1481        let (patches, _) = parse_patches(response).unwrap();
1482        assert_eq!(patches.len(), 1);
1483        assert_eq!(patches[0].name, "output");
1484        assert_eq!(patches[0].attrs.get("mode"), Some(&"replace".to_string()));
1485        assert_eq!(patches[0].attrs.get("max_lines"), Some(&"50".to_string()));
1486    }
1487
1488    #[test]
1489    fn apply_patches_dedup_exchange_adjacent_echo() {
1490        // Simulates the bug: agent echoes user prompt as first line of exchange patch.
1491        // The existing exchange already ends with the prompt line.
1492        // After apply_patches, the prompt should appear exactly once.
1493        let dir = setup_project();
1494        let doc_path = dir.path().join("test.md");
1495        let doc = "\
1496<!-- agent:exchange patch=append -->
1497❯ How do I configure .mise.toml?
1498<!-- /agent:exchange -->
1499";
1500        std::fs::write(&doc_path, doc).unwrap();
1501
1502        // Agent echoes the prompt as first line of its response patch
1503        let patches = vec![PatchBlock {
1504            name: "exchange".to_string(),
1505            content: "❯ How do I configure .mise.toml?\n\n### Re: configure .mise.toml\n\nUse `[env]` section.\n".to_string(),
1506            attrs: Default::default(),
1507        }];
1508        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1509
1510        let count = result.matches("❯ How do I configure .mise.toml?").count();
1511        assert_eq!(count, 1, "prompt line should appear exactly once, got:\n{result}");
1512        assert!(result.contains("### Re: configure .mise.toml"), "response heading should be present");
1513        assert!(result.contains("Use `[env]` section."), "response body should be present");
1514    }
1515
1516    #[test]
1517    fn apply_patches_dedup_preserves_blank_lines() {
1518        // Blank lines between sections must not be collapsed by dedup.
1519        let dir = setup_project();
1520        let doc_path = dir.path().join("test.md");
1521        let doc = "\
1522<!-- agent:exchange patch=append -->
1523Previous response.
1524<!-- /agent:exchange -->
1525";
1526        std::fs::write(&doc_path, doc).unwrap();
1527
1528        let patches = vec![PatchBlock {
1529            name: "exchange".to_string(),
1530            content: "\n\n### Re: something\n\nAnswer here.\n".to_string(),
1531            attrs: Default::default(),
1532        }];
1533        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1534        assert!(result.contains("Previous response."), "existing content preserved");
1535        assert!(result.contains("### Re: something"), "response heading present");
1536        // Multiple blank lines should survive (dedup only targets non-blank)
1537        assert!(result.contains('\n'), "blank lines preserved");
1538    }
1539
1540    #[test]
1541    fn apply_mode_append_strips_leading_overlap() {
1542        // When new_content starts with the last non-blank line of existing,
1543        // apply_mode("append") should not duplicate that line.
1544        let existing = "❯ How do I configure .mise.toml?\n";
1545        let new_content = "❯ How do I configure .mise.toml?\n\n### Re: configure\n\nUse `[env]`.\n";
1546        let result = apply_mode("append", existing, new_content);
1547        let count = result.matches("❯ How do I configure .mise.toml?").count();
1548        assert_eq!(count, 1, "overlap line should appear exactly once");
1549        assert!(result.contains("### Re: configure"));
1550    }
1551
1552    #[test]
1553    fn apply_mode_append_no_overlap_unchanged() {
1554        // When new_content does NOT start with the last non-blank line of existing,
1555        // apply_mode("append") should concatenate normally.
1556        let existing = "Previous content.\n";
1557        let new_content = "### Re: something\n\nAnswer.\n";
1558        let result = apply_mode("append", existing, new_content);
1559        assert_eq!(result, "Previous content.\n### Re: something\n\nAnswer.\n");
1560    }
1561}