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: apply max_lines trimming to components that have it configured.
374    // Precedence: inline attr > components.toml > unlimited (0).
375    // Re-parse after each replacement (offsets change) and iterate up to 3 times
376    // until stable — trimming one component cannot grow another, so 2 passes suffice
377    // in practice; the third is a safety bound.
378    {
379        let max_lines_configs = load_max_lines_configs(file);
380        'stability: for _ in 0..3 {
381            let Ok(components) = component::parse(&result) else { break };
382            for comp in &components {
383                let max_lines = comp
384                    .attrs
385                    .get("max_lines")
386                    .and_then(|s| s.parse::<usize>().ok())
387                    .or_else(|| max_lines_configs.get(&comp.name).copied())
388                    .unwrap_or(0);
389                if max_lines > 0 {
390                    let content = comp.content(&result);
391                    let trimmed = limit_lines(content, max_lines);
392                    if trimmed.len() != content.len() {
393                        let trimmed = format!("{}\n", trimmed.trim_end());
394                        result = comp.replace_content(&result, &trimmed);
395                        // Re-parse from scratch — offsets are now stale.
396                        continue 'stability;
397                    }
398                }
399            }
400            break; // No component needed trimming — stable.
401        }
402    }
403
404    // Post-patch: ensure a boundary exists at the end of the exchange component.
405    // This is unconditional for template docs with an exchange — the boundary must
406    // always exist for checkpoint writes to work. Checking the original doc's content
407    // causes a snowball: once one cycle loses the boundary, every subsequent cycle
408    // also loses it because the check always finds nothing.
409    {
410        if let Ok(components) = component::parse(&result)
411            && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
412            && find_boundary_in_component(&result, exchange).is_none()
413        {
414            // Boundary was consumed — re-insert at end of exchange
415            let id = uuid::Uuid::new_v4().to_string();
416            let marker = format!("<!-- agent:boundary:{} -->", id);
417            let content = exchange.content(&result);
418            let new_content = format!("{}\n{}\n", content.trim_end(), marker);
419            result = exchange.replace_content(&result, &new_content);
420            eprintln!("[template] re-inserted boundary {} at end of exchange", &id[..id.len().min(8)]);
421        }
422    }
423
424    Ok(result)
425}
426
427/// Reposition the boundary marker to the end of the exchange component.
428///
429/// Removes all existing boundaries and inserts a fresh one at the end of
430/// the exchange. This is the same pre-patch logic used in
431/// `apply_patches_with_overrides()`, extracted for use by the IPC write path.
432///
433/// Returns the document unchanged if no exchange component exists.
434pub fn reposition_boundary_to_end(doc: &str) -> String {
435    reposition_boundary_to_end_with_summary(doc, None)
436}
437
438/// Reposition boundary with an optional human-readable summary suffix.
439///
440/// The summary is slugified and appended to the boundary ID:
441/// `a0cfeb34:agent-doc` instead of just `a0cfeb34`.
442pub fn reposition_boundary_to_end_with_summary(doc: &str, summary: Option<&str>) -> String {
443    let mut result = remove_all_boundaries(doc);
444    if let Ok(components) = component::parse(&result)
445        && let Some(exchange) = components.iter().find(|c| c.name == "exchange")
446    {
447        let id = crate::new_boundary_id_with_summary(summary);
448        let marker = crate::format_boundary_marker(&id);
449        let content = exchange.content(&result);
450        let new_content = format!("{}\n{}\n", content.trim_end(), marker);
451        result = exchange.replace_content(&result, &new_content);
452    }
453    result
454}
455
456/// Remove all boundary markers from a document (line-level removal).
457/// Skips boundaries inside fenced code blocks (lesson #13).
458fn remove_all_boundaries(doc: &str) -> String {
459    let prefix = "<!-- agent:boundary:";
460    let suffix = " -->";
461    let code_ranges = component::find_code_ranges(doc);
462    let in_code = |pos: usize| code_ranges.iter().any(|&(start, end)| pos >= start && pos < end);
463    let mut result = String::with_capacity(doc.len());
464    let mut offset = 0;
465    for line in doc.lines() {
466        let trimmed = line.trim();
467        let is_boundary = trimmed.starts_with(prefix) && trimmed.ends_with(suffix);
468        if is_boundary && !in_code(offset) {
469            // Skip boundary marker lines outside code blocks
470            offset += line.len() + 1; // +1 for newline
471            continue;
472        }
473        result.push_str(line);
474        result.push('\n');
475        offset += line.len() + 1;
476    }
477    if !doc.ends_with('\n') && result.ends_with('\n') {
478        result.pop();
479    }
480    result
481}
482
483/// Find a boundary marker ID inside a component's content, skipping code blocks.
484fn find_boundary_in_component(doc: &str, comp: &Component) -> Option<String> {
485    let prefix = "<!-- agent:boundary:";
486    let suffix = " -->";
487    let content_region = &doc[comp.open_end..comp.close_start];
488    let code_ranges = component::find_code_ranges(doc);
489    let mut search_from = 0;
490    while let Some(start) = content_region[search_from..].find(prefix) {
491        let abs_start = comp.open_end + search_from + start;
492        if code_ranges.iter().any(|&(cs, ce)| abs_start >= cs && abs_start < ce) {
493            search_from += start + prefix.len();
494            continue;
495        }
496        let after_prefix = &content_region[search_from + start + prefix.len()..];
497        if let Some(end) = after_prefix.find(suffix) {
498            return Some(after_prefix[..end].trim().to_string());
499        }
500        break;
501    }
502    None
503}
504
505/// Get template info for a document (for plugin rendering).
506pub fn template_info(file: &Path) -> Result<TemplateInfo> {
507    let doc = std::fs::read_to_string(file)
508        .with_context(|| format!("failed to read {}", file.display()))?;
509
510    let (fm, _body) = crate::frontmatter::parse(&doc)?;
511    let template_mode = fm.resolve_mode().is_template();
512
513    let components = component::parse(&doc)
514        .with_context(|| format!("failed to parse components in {}", file.display()))?;
515
516    let configs = load_component_configs(file);
517
518    let component_infos: Vec<ComponentInfo> = components
519        .iter()
520        .map(|comp| {
521            let content = comp.content(&doc).to_string();
522            // Inline attr > components.toml > built-in default
523            let mode = comp.patch_mode().map(|s| s.to_string())
524                .or_else(|| configs.get(&comp.name).cloned())
525                .unwrap_or_else(|| default_mode(&comp.name).to_string());
526            // Compute line number from byte offset
527            let line = doc[..comp.open_start].matches('\n').count() + 1;
528            ComponentInfo {
529                name: comp.name.clone(),
530                mode,
531                content,
532                line,
533                max_entries: None, // TODO: read from components.toml
534            }
535        })
536        .collect();
537
538    Ok(TemplateInfo {
539        template_mode,
540        components: component_infos,
541    })
542}
543
544/// Load component mode configs from `.agent-doc/components.toml`.
545/// Returns a map of component_name → mode string.
546fn load_component_configs(file: &Path) -> std::collections::HashMap<String, String> {
547    let mut result = std::collections::HashMap::new();
548    let root = find_project_root(file);
549    if let Some(root) = root {
550        let config_path = root.join(".agent-doc/components.toml");
551        if config_path.exists()
552            && let Ok(content) = std::fs::read_to_string(&config_path)
553            && let Ok(table) = content.parse::<toml::Table>()
554        {
555            for (name, value) in &table {
556                // "patch" is the primary key; "mode" is a backward-compatible alias
557                if let Some(mode) = value.get("patch").and_then(|v| v.as_str())
558                    .or_else(|| value.get("mode").and_then(|v| v.as_str()))
559                {
560                    result.insert(name.clone(), mode.to_string());
561                }
562            }
563        }
564    }
565    result
566}
567
568/// Load max_lines settings from `.agent-doc/components.toml`.
569fn load_max_lines_configs(file: &Path) -> std::collections::HashMap<String, usize> {
570    let mut result = std::collections::HashMap::new();
571    let root = find_project_root(file);
572    if let Some(root) = root {
573        let config_path = root.join(".agent-doc/components.toml");
574        if config_path.exists()
575            && let Ok(content) = std::fs::read_to_string(&config_path)
576            && let Ok(table) = content.parse::<toml::Table>()
577        {
578            for (name, value) in &table {
579                if let Some(max_lines) = value.get("max_lines").and_then(|v| v.as_integer())
580                    && max_lines > 0
581                {
582                    result.insert(name.clone(), max_lines as usize);
583                }
584            }
585        }
586    }
587    result
588}
589
590/// Default mode for a component by name.
591/// `exchange` and `findings` default to `append`; all others default to `replace`.
592fn default_mode(name: &str) -> &'static str {
593    match name {
594        "exchange" | "findings" => "append",
595        _ => "replace",
596    }
597}
598
599/// Trim content to the last N lines.
600fn limit_lines(content: &str, max_lines: usize) -> String {
601    let lines: Vec<&str> = content.lines().collect();
602    if lines.len() <= max_lines {
603        return content.to_string();
604    }
605    lines[lines.len() - max_lines..].join("\n")
606}
607
608/// Apply mode logic (replace/append/prepend).
609fn apply_mode(mode: &str, existing: &str, new_content: &str) -> String {
610    match mode {
611        "append" => format!("{}{}", existing, new_content),
612        "prepend" => format!("{}{}", new_content, existing),
613        _ => new_content.to_string(), // "replace" default
614    }
615}
616
617fn find_project_root(file: &Path) -> Option<std::path::PathBuf> {
618    let canonical = file.canonicalize().ok()?;
619    let mut dir = canonical.parent()?;
620    loop {
621        if dir.join(".agent-doc").is_dir() {
622            return Some(dir.to_path_buf());
623        }
624        dir = dir.parent()?;
625    }
626}
627
628/// Find `needle` in `haystack` starting at `from`, skipping occurrences inside code ranges.
629/// Returns the byte offset of the match within `haystack` (absolute, not relative to `from`).
630fn find_outside_code(needle: &str, haystack: &str, from: usize, code_ranges: &[(usize, usize)]) -> Option<usize> {
631    let mut search_start = from;
632    loop {
633        let rel = haystack[search_start..].find(needle)?;
634        let abs = search_start + rel;
635        if code_ranges.iter().any(|&(start, end)| abs >= start && abs < end) {
636            // Inside a code block — skip past this occurrence
637            search_start = abs + needle.len();
638            continue;
639        }
640        return Some(abs);
641    }
642}
643
644
645#[cfg(test)]
646mod tests {
647    use super::*;
648    use tempfile::TempDir;
649
650    fn setup_project() -> TempDir {
651        let dir = TempDir::new().unwrap();
652        std::fs::create_dir_all(dir.path().join(".agent-doc/snapshots")).unwrap();
653        dir
654    }
655
656    #[test]
657    fn parse_single_patch() {
658        let response = "<!-- patch:status -->\nBuild passing.\n<!-- /patch:status -->\n";
659        let (patches, unmatched) = parse_patches(response).unwrap();
660        assert_eq!(patches.len(), 1);
661        assert_eq!(patches[0].name, "status");
662        assert_eq!(patches[0].content, "Build passing.\n");
663        assert!(unmatched.is_empty());
664    }
665
666    #[test]
667    fn parse_multiple_patches() {
668        let response = "\
669<!-- patch:status -->
670All green.
671<!-- /patch:status -->
672
673<!-- patch:log -->
674- New entry
675<!-- /patch:log -->
676";
677        let (patches, unmatched) = parse_patches(response).unwrap();
678        assert_eq!(patches.len(), 2);
679        assert_eq!(patches[0].name, "status");
680        assert_eq!(patches[0].content, "All green.\n");
681        assert_eq!(patches[1].name, "log");
682        assert_eq!(patches[1].content, "- New entry\n");
683        assert!(unmatched.is_empty());
684    }
685
686    #[test]
687    fn parse_with_unmatched_content() {
688        let response = "Some free text.\n\n<!-- patch:status -->\nOK\n<!-- /patch:status -->\n\nTrailing text.\n";
689        let (patches, unmatched) = parse_patches(response).unwrap();
690        assert_eq!(patches.len(), 1);
691        assert_eq!(patches[0].name, "status");
692        assert!(unmatched.contains("Some free text."));
693        assert!(unmatched.contains("Trailing text."));
694    }
695
696    #[test]
697    fn parse_empty_response() {
698        let (patches, unmatched) = parse_patches("").unwrap();
699        assert!(patches.is_empty());
700        assert!(unmatched.is_empty());
701    }
702
703    #[test]
704    fn parse_no_patches() {
705        let response = "Just a plain response with no patch blocks.";
706        let (patches, unmatched) = parse_patches(response).unwrap();
707        assert!(patches.is_empty());
708        assert_eq!(unmatched, "Just a plain response with no patch blocks.");
709    }
710
711    #[test]
712    fn apply_patches_replace() {
713        let dir = setup_project();
714        let doc_path = dir.path().join("test.md");
715        let doc = "# Dashboard\n\n<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
716        std::fs::write(&doc_path, doc).unwrap();
717
718        let patches = vec![PatchBlock {
719            name: "status".to_string(),
720            content: "new\n".to_string(),
721            attrs: Default::default(),
722        }];
723        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
724        assert!(result.contains("new\n"));
725        assert!(!result.contains("\nold\n"));
726        assert!(result.contains("<!-- agent:status -->"));
727    }
728
729    #[test]
730    fn apply_patches_unmatched_creates_exchange() {
731        let dir = setup_project();
732        let doc_path = dir.path().join("test.md");
733        let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
734        std::fs::write(&doc_path, doc).unwrap();
735
736        let result = apply_patches(doc, &[], "Extra info here", &doc_path).unwrap();
737        assert!(result.contains("<!-- agent:exchange -->"));
738        assert!(result.contains("Extra info here"));
739        assert!(result.contains("<!-- /agent:exchange -->"));
740    }
741
742    #[test]
743    fn apply_patches_unmatched_appends_to_existing_exchange() {
744        let dir = setup_project();
745        let doc_path = dir.path().join("test.md");
746        let doc = "<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
747        std::fs::write(&doc_path, doc).unwrap();
748
749        let result = apply_patches(doc, &[], "new stuff", &doc_path).unwrap();
750        assert!(result.contains("previous"));
751        assert!(result.contains("new stuff"));
752        // Should not create a second exchange component
753        assert_eq!(result.matches("<!-- agent:exchange -->").count(), 1);
754    }
755
756    #[test]
757    fn apply_patches_missing_component_routes_to_exchange() {
758        let dir = setup_project();
759        let doc_path = dir.path().join("test.md");
760        let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n\n<!-- agent:exchange -->\nprevious\n<!-- /agent:exchange -->\n";
761        std::fs::write(&doc_path, doc).unwrap();
762
763        let patches = vec![PatchBlock {
764            name: "nonexistent".to_string(),
765            content: "overflow data\n".to_string(),
766            attrs: Default::default(),
767        }];
768        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
769        // Missing component content should be routed to exchange
770        assert!(result.contains("overflow data"), "missing patch content should appear in exchange");
771        assert!(result.contains("previous"), "existing exchange content should be preserved");
772    }
773
774    #[test]
775    fn apply_patches_missing_component_creates_exchange() {
776        let dir = setup_project();
777        let doc_path = dir.path().join("test.md");
778        let doc = "# Dashboard\n\n<!-- agent:status -->\nok\n<!-- /agent:status -->\n";
779        std::fs::write(&doc_path, doc).unwrap();
780
781        let patches = vec![PatchBlock {
782            name: "nonexistent".to_string(),
783            content: "overflow data\n".to_string(),
784            attrs: Default::default(),
785        }];
786        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
787        // Should auto-create exchange component
788        assert!(result.contains("<!-- agent:exchange -->"), "should create exchange component");
789        assert!(result.contains("overflow data"), "overflow content should be in exchange");
790    }
791
792    #[test]
793    fn is_template_mode_detection() {
794        assert!(is_template_mode(Some("template")));
795        assert!(!is_template_mode(Some("append")));
796        assert!(!is_template_mode(None));
797    }
798
799    #[test]
800    fn template_info_works() {
801        let dir = setup_project();
802        let doc_path = dir.path().join("test.md");
803        let doc = "---\nagent_doc_format: template\n---\n\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
804        std::fs::write(&doc_path, doc).unwrap();
805
806        let info = template_info(&doc_path).unwrap();
807        assert!(info.template_mode);
808        assert_eq!(info.components.len(), 1);
809        assert_eq!(info.components[0].name, "status");
810        assert_eq!(info.components[0].content, "content\n");
811    }
812
813    #[test]
814    fn template_info_legacy_mode_works() {
815        let dir = setup_project();
816        let doc_path = dir.path().join("test.md");
817        let doc = "---\nresponse_mode: template\n---\n\n<!-- agent:status -->\ncontent\n<!-- /agent:status -->\n";
818        std::fs::write(&doc_path, doc).unwrap();
819
820        let info = template_info(&doc_path).unwrap();
821        assert!(info.template_mode);
822    }
823
824    #[test]
825    fn template_info_append_mode() {
826        let dir = setup_project();
827        let doc_path = dir.path().join("test.md");
828        let doc = "---\nagent_doc_format: append\n---\n\n# Doc\n";
829        std::fs::write(&doc_path, doc).unwrap();
830
831        let info = template_info(&doc_path).unwrap();
832        assert!(!info.template_mode);
833        assert!(info.components.is_empty());
834    }
835
836    #[test]
837    fn parse_patches_ignores_markers_in_fenced_code_block() {
838        let response = "\
839<!-- patch:exchange -->
840Here is how you use component markers:
841
842```markdown
843<!-- agent:exchange -->
844example content
845<!-- /agent:exchange -->
846```
847
848<!-- /patch:exchange -->
849";
850        let (patches, unmatched) = parse_patches(response).unwrap();
851        assert_eq!(patches.len(), 1);
852        assert_eq!(patches[0].name, "exchange");
853        assert!(patches[0].content.contains("```markdown"));
854        assert!(patches[0].content.contains("<!-- agent:exchange -->"));
855        assert!(unmatched.is_empty());
856    }
857
858    #[test]
859    fn parse_patches_ignores_patch_markers_in_fenced_code_block() {
860        // Patch markers inside a code block should not be treated as real patches
861        let response = "\
862<!-- patch:exchange -->
863Real content here.
864
865```markdown
866<!-- patch:fake -->
867This is just an example.
868<!-- /patch:fake -->
869```
870
871<!-- /patch:exchange -->
872";
873        let (patches, unmatched) = parse_patches(response).unwrap();
874        assert_eq!(patches.len(), 1, "should only find the outer real patch");
875        assert_eq!(patches[0].name, "exchange");
876        assert!(patches[0].content.contains("<!-- patch:fake -->"), "code block content should be preserved");
877        assert!(unmatched.is_empty());
878    }
879
880    #[test]
881    fn parse_patches_ignores_markers_in_tilde_fence() {
882        let response = "\
883<!-- patch:status -->
884OK
885<!-- /patch:status -->
886
887~~~
888<!-- patch:fake -->
889example
890<!-- /patch:fake -->
891~~~
892";
893        let (patches, _unmatched) = parse_patches(response).unwrap();
894        // Only the real patch should be found; the fake one inside ~~~ is ignored
895        assert_eq!(patches.len(), 1);
896        assert_eq!(patches[0].name, "status");
897    }
898
899    #[test]
900    fn parse_patches_ignores_closing_marker_in_code_block() {
901        // The closing marker for a real patch is inside a code block,
902        // so the parser should skip it and find the real closing marker outside
903        let response = "\
904<!-- patch:exchange -->
905Example:
906
907```
908<!-- /patch:exchange -->
909```
910
911Real content continues.
912<!-- /patch:exchange -->
913";
914        let (patches, _unmatched) = parse_patches(response).unwrap();
915        assert_eq!(patches.len(), 1);
916        assert_eq!(patches[0].name, "exchange");
917        assert!(patches[0].content.contains("Real content continues."));
918    }
919
920    #[test]
921    fn parse_patches_normal_markers_still_work() {
922        // Sanity check: normal patch parsing without code blocks still works
923        let response = "\
924<!-- patch:status -->
925All systems go.
926<!-- /patch:status -->
927<!-- patch:log -->
928- Entry 1
929<!-- /patch:log -->
930";
931        let (patches, unmatched) = parse_patches(response).unwrap();
932        assert_eq!(patches.len(), 2);
933        assert_eq!(patches[0].name, "status");
934        assert_eq!(patches[0].content, "All systems go.\n");
935        assert_eq!(patches[1].name, "log");
936        assert_eq!(patches[1].content, "- Entry 1\n");
937        assert!(unmatched.is_empty());
938    }
939
940    // --- Inline attribute mode resolution tests ---
941
942    #[test]
943    fn inline_attr_mode_overrides_config() {
944        // Component has mode=replace inline, but components.toml says append
945        let dir = setup_project();
946        let doc_path = dir.path().join("test.md");
947        // Write config with append mode for status
948        std::fs::write(
949            dir.path().join(".agent-doc/components.toml"),
950            "[status]\nmode = \"append\"\n",
951        ).unwrap();
952        // But the inline attr says replace
953        let doc = "<!-- agent:status mode=replace -->\nold\n<!-- /agent:status -->\n";
954        std::fs::write(&doc_path, doc).unwrap();
955
956        let patches = vec![PatchBlock {
957            name: "status".to_string(),
958            content: "new\n".to_string(),
959            attrs: Default::default(),
960        }];
961        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
962        // Inline replace should win over config append
963        assert!(result.contains("new\n"));
964        assert!(!result.contains("old\n"));
965    }
966
967    #[test]
968    fn inline_attr_mode_overrides_default() {
969        // exchange defaults to append, but inline says replace
970        let dir = setup_project();
971        let doc_path = dir.path().join("test.md");
972        let doc = "<!-- agent:exchange mode=replace -->\nold\n<!-- /agent:exchange -->\n";
973        std::fs::write(&doc_path, doc).unwrap();
974
975        let patches = vec![PatchBlock {
976            name: "exchange".to_string(),
977            content: "new\n".to_string(),
978            attrs: Default::default(),
979        }];
980        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
981        assert!(result.contains("new\n"));
982        assert!(!result.contains("old\n"));
983    }
984
985    #[test]
986    fn no_inline_attr_falls_back_to_config() {
987        // No inline attr → falls back to components.toml
988        let dir = setup_project();
989        let doc_path = dir.path().join("test.md");
990        std::fs::write(
991            dir.path().join(".agent-doc/components.toml"),
992            "[status]\nmode = \"append\"\n",
993        ).unwrap();
994        let doc = "<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
995        std::fs::write(&doc_path, doc).unwrap();
996
997        let patches = vec![PatchBlock {
998            name: "status".to_string(),
999            content: "new\n".to_string(),
1000            attrs: Default::default(),
1001        }];
1002        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1003        // Config says append, so both old and new should be present
1004        assert!(result.contains("old\n"));
1005        assert!(result.contains("new\n"));
1006    }
1007
1008    #[test]
1009    fn no_inline_attr_no_config_falls_back_to_default() {
1010        // No inline attr, no config → built-in defaults
1011        let dir = setup_project();
1012        let doc_path = dir.path().join("test.md");
1013        let doc = "<!-- agent:exchange -->\nold\n<!-- /agent:exchange -->\n";
1014        std::fs::write(&doc_path, doc).unwrap();
1015
1016        let patches = vec![PatchBlock {
1017            name: "exchange".to_string(),
1018            content: "new\n".to_string(),
1019            attrs: Default::default(),
1020        }];
1021        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1022        // exchange defaults to append
1023        assert!(result.contains("old\n"));
1024        assert!(result.contains("new\n"));
1025    }
1026
1027    #[test]
1028    fn inline_patch_attr_overrides_config() {
1029        // Component has patch=replace inline, but components.toml says append
1030        let dir = setup_project();
1031        let doc_path = dir.path().join("test.md");
1032        std::fs::write(
1033            dir.path().join(".agent-doc/components.toml"),
1034            "[status]\nmode = \"append\"\n",
1035        ).unwrap();
1036        let doc = "<!-- agent:status patch=replace -->\nold\n<!-- /agent:status -->\n";
1037        std::fs::write(&doc_path, doc).unwrap();
1038
1039        let patches = vec![PatchBlock {
1040            name: "status".to_string(),
1041            content: "new\n".to_string(),
1042            attrs: Default::default(),
1043        }];
1044        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1045        assert!(result.contains("new\n"));
1046        assert!(!result.contains("old\n"));
1047    }
1048
1049    #[test]
1050    fn inline_patch_attr_overrides_mode_attr() {
1051        // Both patch= and mode= present; patch= wins
1052        let dir = setup_project();
1053        let doc_path = dir.path().join("test.md");
1054        let doc = "<!-- agent:exchange patch=replace mode=append -->\nold\n<!-- /agent:exchange -->\n";
1055        std::fs::write(&doc_path, doc).unwrap();
1056
1057        let patches = vec![PatchBlock {
1058            name: "exchange".to_string(),
1059            content: "new\n".to_string(),
1060            attrs: Default::default(),
1061        }];
1062        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1063        assert!(result.contains("new\n"));
1064        assert!(!result.contains("old\n"));
1065    }
1066
1067    #[test]
1068    fn toml_patch_key_works() {
1069        // components.toml uses `patch = "append"` instead of `mode = "append"`
1070        let dir = setup_project();
1071        let doc_path = dir.path().join("test.md");
1072        std::fs::write(
1073            dir.path().join(".agent-doc/components.toml"),
1074            "[status]\npatch = \"append\"\n",
1075        ).unwrap();
1076        let doc = "<!-- agent:status -->\nold\n<!-- /agent:status -->\n";
1077        std::fs::write(&doc_path, doc).unwrap();
1078
1079        let patches = vec![PatchBlock {
1080            name: "status".to_string(),
1081            content: "new\n".to_string(),
1082            attrs: Default::default(),
1083        }];
1084        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1085        assert!(result.contains("old\n"));
1086        assert!(result.contains("new\n"));
1087    }
1088
1089    #[test]
1090    fn stream_override_beats_inline_attr() {
1091        // Stream mode overrides should still beat inline attrs
1092        let dir = setup_project();
1093        let doc_path = dir.path().join("test.md");
1094        let doc = "<!-- agent:exchange mode=append -->\nold\n<!-- /agent:exchange -->\n";
1095        std::fs::write(&doc_path, doc).unwrap();
1096
1097        let patches = vec![PatchBlock {
1098            name: "exchange".to_string(),
1099            content: "new\n".to_string(),
1100            attrs: Default::default(),
1101        }];
1102        let mut overrides = std::collections::HashMap::new();
1103        overrides.insert("exchange".to_string(), "replace".to_string());
1104        let result = apply_patches_with_overrides(doc, &patches, "", &doc_path, &overrides).unwrap();
1105        // Stream override (replace) should win over inline attr (append)
1106        assert!(result.contains("new\n"));
1107        assert!(!result.contains("old\n"));
1108    }
1109
1110    #[test]
1111    fn apply_patches_ignores_component_tags_in_code_blocks() {
1112        // Component tags inside a fenced code block should not be patch targets.
1113        // Only the real top-level component should receive the patch content.
1114        let dir = setup_project();
1115        let doc_path = dir.path().join("test.md");
1116        let doc = "\
1117# Scaffold Guide
1118
1119Here is an example of a component:
1120
1121```markdown
1122<!-- agent:status -->
1123example scaffold content
1124<!-- /agent:status -->
1125```
1126
1127<!-- agent:status -->
1128real status content
1129<!-- /agent:status -->
1130";
1131        std::fs::write(&doc_path, doc).unwrap();
1132
1133        let patches = vec![PatchBlock {
1134            name: "status".to_string(),
1135            content: "patched status\n".to_string(),
1136            attrs: Default::default(),
1137        }];
1138        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1139
1140        // The real component should be patched
1141        assert!(result.contains("patched status\n"), "real component should receive the patch");
1142        // The code block example should be untouched
1143        assert!(result.contains("example scaffold content"), "code block content should be preserved");
1144        // The code block's markers should still be there
1145        assert!(result.contains("```markdown\n<!-- agent:status -->"), "code block markers should be preserved");
1146    }
1147
1148    #[test]
1149    fn unmatched_content_uses_boundary_marker() {
1150        let dir = setup_project();
1151        let file = dir.path().join("test.md");
1152        let doc = concat!(
1153            "---\nagent_doc_format: template\n---\n",
1154            "<!-- agent:exchange patch=append -->\n",
1155            "User prompt here.\n",
1156            "<!-- agent:boundary:test-uuid-123 -->\n",
1157            "<!-- /agent:exchange -->\n",
1158        );
1159        std::fs::write(&file, doc).unwrap();
1160
1161        // No patch blocks — only unmatched content (simulates skill not wrapping in patch blocks)
1162        let patches = vec![];
1163        let unmatched = "### Re: Response\n\nResponse content here.\n";
1164
1165        let result = apply_patches(doc, &patches, unmatched, &file).unwrap();
1166
1167        // Response should be inserted at the boundary marker position (after prompt)
1168        let prompt_pos = result.find("User prompt here.").unwrap();
1169        let response_pos = result.find("### Re: Response").unwrap();
1170        assert!(
1171            response_pos > prompt_pos,
1172            "response should appear after the user prompt (boundary insertion)"
1173        );
1174
1175        // Boundary marker should be consumed (replaced by response)
1176        assert!(
1177            !result.contains("test-uuid-123"),
1178            "boundary marker should be consumed after insertion"
1179        );
1180    }
1181
1182    #[test]
1183    fn explicit_patch_uses_boundary_marker() {
1184        let dir = setup_project();
1185        let file = dir.path().join("test.md");
1186        let doc = concat!(
1187            "---\nagent_doc_format: template\n---\n",
1188            "<!-- agent:exchange patch=append -->\n",
1189            "User prompt here.\n",
1190            "<!-- agent:boundary:patch-uuid-456 -->\n",
1191            "<!-- /agent:exchange -->\n",
1192        );
1193        std::fs::write(&file, doc).unwrap();
1194
1195        // Explicit patch block targeting exchange
1196        let patches = vec![PatchBlock {
1197            name: "exchange".to_string(),
1198            content: "### Re: Response\n\nResponse content.\n".to_string(),
1199            attrs: Default::default(),
1200        }];
1201
1202        let result = apply_patches(doc, &patches, "", &file).unwrap();
1203
1204        // Response should be after prompt (boundary consumed)
1205        let prompt_pos = result.find("User prompt here.").unwrap();
1206        let response_pos = result.find("### Re: Response").unwrap();
1207        assert!(
1208            response_pos > prompt_pos,
1209            "response should appear after user prompt"
1210        );
1211
1212        // Boundary marker should be consumed
1213        assert!(
1214            !result.contains("patch-uuid-456"),
1215            "boundary marker should be consumed by explicit patch"
1216        );
1217    }
1218
1219    #[test]
1220    fn boundary_reinserted_even_when_original_doc_has_no_boundary() {
1221        // Regression: the snowball bug — once one cycle loses the boundary,
1222        // every subsequent cycle also loses it because orig_had_boundary finds nothing.
1223        let dir = setup_project();
1224        let file = dir.path().join("test.md");
1225        // Document with exchange but NO boundary marker
1226        let doc = "<!-- agent:exchange patch=append -->\nUser prompt here.\n<!-- /agent:exchange -->\n";
1227        std::fs::write(&file, doc).unwrap();
1228
1229        let response = "<!-- patch:exchange -->\nAgent response.\n<!-- /patch:exchange -->\n";
1230        let (patches, unmatched) = parse_patches(response).unwrap();
1231        let result = apply_patches(doc, &patches, &unmatched, &file).unwrap();
1232
1233        // Must have a boundary at end of exchange, even though original had none
1234        assert!(
1235            result.contains("<!-- agent:boundary:"),
1236            "boundary must be re-inserted even when original doc had no boundary: {result}"
1237        );
1238    }
1239
1240    #[test]
1241    fn boundary_survives_multiple_cycles() {
1242        // Simulate two consecutive write cycles — boundary must persist
1243        let dir = setup_project();
1244        let file = dir.path().join("test.md");
1245        let doc = "<!-- agent:exchange patch=append -->\nPrompt 1.\n<!-- /agent:exchange -->\n";
1246        std::fs::write(&file, doc).unwrap();
1247
1248        // Cycle 1
1249        let response1 = "<!-- patch:exchange -->\nResponse 1.\n<!-- /patch:exchange -->\n";
1250        let (patches1, unmatched1) = parse_patches(response1).unwrap();
1251        let result1 = apply_patches(doc, &patches1, &unmatched1, &file).unwrap();
1252        assert!(result1.contains("<!-- agent:boundary:"), "cycle 1 must have boundary");
1253
1254        // Cycle 2 — use cycle 1's output as the new doc (simulates next write)
1255        let response2 = "<!-- patch:exchange -->\nResponse 2.\n<!-- /patch:exchange -->\n";
1256        let (patches2, unmatched2) = parse_patches(response2).unwrap();
1257        let result2 = apply_patches(&result1, &patches2, &unmatched2, &file).unwrap();
1258        assert!(result2.contains("<!-- agent:boundary:"), "cycle 2 must have boundary");
1259    }
1260
1261    #[test]
1262    fn remove_all_boundaries_skips_code_blocks() {
1263        let doc = "before\n```\n<!-- agent:boundary:fake-id -->\n```\nafter\n<!-- agent:boundary:real-id -->\nend\n";
1264        let result = remove_all_boundaries(doc);
1265        // The one inside the code block should survive
1266        assert!(
1267            result.contains("<!-- agent:boundary:fake-id -->"),
1268            "boundary inside code block must be preserved: {result}"
1269        );
1270        // The one outside should be removed
1271        assert!(
1272            !result.contains("<!-- agent:boundary:real-id -->"),
1273            "boundary outside code block must be removed: {result}"
1274        );
1275    }
1276
1277    #[test]
1278    fn reposition_boundary_moves_to_end() {
1279        let doc = "\
1280<!-- agent:exchange -->
1281Previous response.
1282<!-- agent:boundary:old-id -->
1283User prompt here.
1284<!-- /agent:exchange -->";
1285        let result = reposition_boundary_to_end(doc);
1286        // Old boundary should be gone
1287        assert!(!result.contains("old-id"), "old boundary should be removed");
1288        // New boundary should exist
1289        assert!(result.contains("<!-- agent:boundary:"), "new boundary should be inserted");
1290        // New boundary should be after the user prompt, before close tag
1291        let boundary_pos = result.find("<!-- agent:boundary:").unwrap();
1292        let prompt_pos = result.find("User prompt here.").unwrap();
1293        let close_pos = result.find("<!-- /agent:exchange -->").unwrap();
1294        assert!(boundary_pos > prompt_pos, "boundary should be after user prompt");
1295        assert!(boundary_pos < close_pos, "boundary should be before close tag");
1296    }
1297
1298    #[test]
1299    fn reposition_boundary_no_exchange_unchanged() {
1300        let doc = "\
1301<!-- agent:output -->
1302Some content.
1303<!-- /agent:output -->";
1304        let result = reposition_boundary_to_end(doc);
1305        assert!(!result.contains("<!-- agent:boundary:"), "no boundary should be added to non-exchange");
1306    }
1307
1308    #[test]
1309    fn max_lines_inline_attr_trims_content() {
1310        let dir = setup_project();
1311        let doc_path = dir.path().join("test.md");
1312        let doc = "<!-- agent:log patch=replace max_lines=3 -->\nold\n<!-- /agent:log -->\n";
1313        std::fs::write(&doc_path, doc).unwrap();
1314
1315        let patches = vec![PatchBlock {
1316            name: "log".to_string(),
1317            content: "line1\nline2\nline3\nline4\nline5\n".to_string(),
1318            attrs: Default::default(),
1319        }];
1320        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1321        assert!(!result.contains("line1"));
1322        assert!(!result.contains("line2"));
1323        assert!(result.contains("line3"));
1324        assert!(result.contains("line4"));
1325        assert!(result.contains("line5"));
1326    }
1327
1328    #[test]
1329    fn max_lines_noop_when_under_limit() {
1330        let dir = setup_project();
1331        let doc_path = dir.path().join("test.md");
1332        let doc = "<!-- agent:log patch=replace max_lines=10 -->\nold\n<!-- /agent:log -->\n";
1333        std::fs::write(&doc_path, doc).unwrap();
1334
1335        let patches = vec![PatchBlock {
1336            name: "log".to_string(),
1337            content: "line1\nline2\n".to_string(),
1338            attrs: Default::default(),
1339        }];
1340        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1341        assert!(result.contains("line1"));
1342        assert!(result.contains("line2"));
1343    }
1344
1345    #[test]
1346    fn max_lines_from_components_toml() {
1347        let dir = setup_project();
1348        let doc_path = dir.path().join("test.md");
1349        std::fs::write(
1350            dir.path().join(".agent-doc/components.toml"),
1351            "[log]\npatch = \"replace\"\nmax_lines = 2\n",
1352        )
1353        .unwrap();
1354        let doc = "<!-- agent:log -->\nold\n<!-- /agent:log -->\n";
1355        std::fs::write(&doc_path, doc).unwrap();
1356
1357        let patches = vec![PatchBlock {
1358            name: "log".to_string(),
1359            content: "a\nb\nc\nd\n".to_string(),
1360            attrs: Default::default(),
1361        }];
1362        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1363        assert!(!result.contains("\na\n"));
1364        assert!(!result.contains("\nb\n"));
1365        assert!(result.contains("c"));
1366        assert!(result.contains("d"));
1367    }
1368
1369    #[test]
1370    fn max_lines_inline_beats_toml() {
1371        let dir = setup_project();
1372        let doc_path = dir.path().join("test.md");
1373        std::fs::write(
1374            dir.path().join(".agent-doc/components.toml"),
1375            "[log]\nmax_lines = 1\n",
1376        )
1377        .unwrap();
1378        let doc = "<!-- agent:log patch=replace max_lines=3 -->\nold\n<!-- /agent:log -->\n";
1379        std::fs::write(&doc_path, doc).unwrap();
1380
1381        let patches = vec![PatchBlock {
1382            name: "log".to_string(),
1383            content: "a\nb\nc\nd\n".to_string(),
1384            attrs: Default::default(),
1385        }];
1386        let result = apply_patches(doc, &patches, "", &doc_path).unwrap();
1387        // Inline max_lines=3 should win over toml max_lines=1
1388        assert!(result.contains("b"));
1389        assert!(result.contains("c"));
1390        assert!(result.contains("d"));
1391    }
1392
1393    #[test]
1394    fn parse_patch_with_transfer_source_attr() {
1395        let response = "<!-- patch:exchange transfer-source=\"tasks/eval-runner.md\" -->\nTransferred content.\n<!-- /patch:exchange -->\n";
1396        let (patches, unmatched) = parse_patches(response).unwrap();
1397        assert_eq!(patches.len(), 1);
1398        assert_eq!(patches[0].name, "exchange");
1399        assert_eq!(patches[0].content, "Transferred content.\n");
1400        assert_eq!(
1401            patches[0].attrs.get("transfer-source"),
1402            Some(&"\"tasks/eval-runner.md\"".to_string())
1403        );
1404        assert!(unmatched.is_empty());
1405    }
1406
1407    #[test]
1408    fn parse_patch_without_attrs() {
1409        let response = "<!-- patch:exchange -->\nContent.\n<!-- /patch:exchange -->\n";
1410        let (patches, _) = parse_patches(response).unwrap();
1411        assert_eq!(patches.len(), 1);
1412        assert!(patches[0].attrs.is_empty());
1413    }
1414
1415    #[test]
1416    fn parse_patch_with_multiple_attrs() {
1417        let response = "<!-- patch:output mode=replace max_lines=50 -->\nContent.\n<!-- /patch:output -->\n";
1418        let (patches, _) = parse_patches(response).unwrap();
1419        assert_eq!(patches.len(), 1);
1420        assert_eq!(patches[0].name, "output");
1421        assert_eq!(patches[0].attrs.get("mode"), Some(&"replace".to_string()));
1422        assert_eq!(patches[0].attrs.get("max_lines"), Some(&"50".to_string()));
1423    }
1424}