Skip to main content

agent_doc/
template.rs

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