Skip to main content

omni_dev/atlassian/
diff_format.rs

1//! Output rendering for the Confluence `compare` and `compare_section` tools.
2//!
3//! Turns a [`Diff`] (from [`super::diff`]) plus surrounding metadata into the
4//! YAML output schema described in issue #706. Three detail levels:
5//!
6//! - **Summary** — counts only.
7//! - **Outline** — per-section change kind + one-line summaries + cursors.
8//! - **Full** — embeds per-section deltas, budget-truncated with continuation.
9//!
10//! Cursors are stateless: an opaque base64url-encoded JSON record carrying
11//! `{page_id, from_v, to_v, section_path}` so `confluence_compare_section`
12//! can re-fetch both sides without server state.
13
14use anyhow::{Context, Result};
15use base64::Engine;
16use serde::{Deserialize, Serialize};
17
18use crate::atlassian::diff::{
19    ChangeKind, Diff, DiffStats, NodeDelta, NodeSnapshot, ParagraphDelta, SectionDiff,
20};
21
22/// Detail level for the compare output.
23#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum Detail {
25    /// Counts only — no per-section information.
26    Summary,
27    /// Per-section change kind, one-line summaries, drill-in cursors.
28    #[default]
29    Outline,
30    /// Embed per-section deltas. Budget-truncated.
31    Full,
32}
33
34/// Which top-level fields to include in the output.
35#[derive(Debug, Clone, Copy)]
36pub struct Includes {
37    /// Whether to include body diffs (sections, summary).
38    pub body: bool,
39    /// Whether to include the title-change record.
40    pub title: bool,
41    /// Whether to include the labels add/remove record.
42    pub labels: bool,
43    /// Whether to include the metadata (versions header) — almost always
44    /// `true`; here for parity with the issue's `include` parameter.
45    pub metadata: bool,
46}
47
48impl Default for Includes {
49    fn default() -> Self {
50        Self {
51            body: true,
52            title: true,
53            labels: false,
54            metadata: true,
55        }
56    }
57}
58
59/// Filter applied to the rendered output (post-diff).
60#[derive(Debug, Clone, Default)]
61pub struct Filter {
62    /// Restrict to sections whose path matches one of the given strings.
63    /// Empty = no path filter.
64    pub sections: Vec<String>,
65    /// Drop section deltas whose total `from + to` text is shorter than
66    /// `min_change_chars`. `0` = no filter.
67    pub min_change_chars: u32,
68    /// Restrict to sections classified as one of the listed kinds. Empty
69    /// = no filter.
70    pub kinds: Vec<ChangeKind>,
71}
72
73// ── Output schema ────────────────────────────────────────────────────
74
75/// Top-level YAML output for `confluence_compare`.
76#[derive(Debug, Clone, Serialize)]
77pub struct CompareOutput {
78    /// Page identity header.
79    pub page: PageHeader,
80    /// Version pair header.
81    #[serde(skip_serializing_if = "Option::is_none")]
82    pub versions: Option<VersionPair>,
83    /// Aggregate counts.
84    pub summary: SummaryBlock,
85    /// Title-change record (None when titles are identical or when `title`
86    /// is excluded from `include`).
87    #[serde(skip_serializing_if = "Option::is_none")]
88    pub title_change: Option<TitleChange>,
89    /// Label changes (None when `labels` is excluded).
90    #[serde(skip_serializing_if = "Option::is_none")]
91    pub labels: Option<LabelChange>,
92    /// Section-level diffs (omitted when `detail` = `summary`).
93    #[serde(skip_serializing_if = "Vec::is_empty")]
94    pub sections: Vec<SectionRecord>,
95    /// Whether output was truncated by the budget.
96    pub truncated: bool,
97    /// Continuation cursor when `truncated` is true.
98    #[serde(skip_serializing_if = "Option::is_none")]
99    pub continuation: Option<Continuation>,
100}
101
102/// Page identity header for the compare output.
103#[derive(Debug, Clone, Serialize)]
104pub struct PageHeader {
105    /// Page ID.
106    pub id: String,
107    /// Page title (the `to` version's title).
108    pub title: String,
109    /// Optional rendered URL.
110    #[serde(skip_serializing_if = "Option::is_none")]
111    pub url: Option<String>,
112}
113
114/// Pair of version metadata records (`from` / `to`).
115#[derive(Debug, Clone, Serialize)]
116pub struct VersionPair {
117    /// `from` side of the diff.
118    pub from: VersionInfo,
119    /// `to` side of the diff.
120    pub to: VersionInfo,
121}
122
123/// Per-version metadata.
124#[derive(Debug, Clone, Serialize, Default)]
125pub struct VersionInfo {
126    /// 1-based version number.
127    pub number: u32,
128    /// ISO 8601 creation timestamp.
129    #[serde(skip_serializing_if = "String::is_empty")]
130    pub created_at: String,
131    /// Author display name or account id.
132    #[serde(skip_serializing_if = "String::is_empty")]
133    pub author: String,
134    /// Version comment.
135    #[serde(skip_serializing_if = "String::is_empty")]
136    pub message: String,
137}
138
139/// Aggregate counts across all sections.
140#[derive(Debug, Clone, Serialize)]
141pub struct SummaryBlock {
142    /// Sum of all change-kind counters in [`ByKind`].
143    pub total_changes: u32,
144    /// Counts grouped by change kind.
145    pub by_kind: ByKind,
146    /// Net character / word changes.
147    pub net: NetCounts,
148}
149
150/// Counts grouped by change kind.
151#[derive(Debug, Clone, Default, Serialize)]
152pub struct ByKind {
153    /// Sections present only in `to`.
154    pub sections_added: u32,
155    /// Sections present only in `from`.
156    pub sections_removed: u32,
157    /// Sections present on both sides with content edits.
158    pub sections_modified: u32,
159    /// Sections present on both sides at different positions.
160    pub sections_moved: u32,
161    /// Number of paragraph-shaped block edits.
162    pub paragraphs_modified: u32,
163    /// Number of tables with at least one modified cell.
164    pub tables_modified: u32,
165}
166
167/// Net character and word change counts across the entire diff.
168#[derive(Debug, Clone, Default, Serialize)]
169pub struct NetCounts {
170    /// Total characters added across all prose deltas.
171    pub chars_added: u32,
172    /// Total characters removed across all prose deltas.
173    pub chars_removed: u32,
174    /// Total words added across all prose deltas.
175    pub words_added: u32,
176    /// Total words removed across all prose deltas.
177    pub words_removed: u32,
178}
179
180/// Title-change record. Emitted only when titles differ.
181#[derive(Debug, Clone, Serialize)]
182pub struct TitleChange {
183    /// Title before.
184    pub from: String,
185    /// Title after.
186    pub to: String,
187}
188
189/// Label changes between two versions.
190#[derive(Debug, Clone, Default, Serialize)]
191pub struct LabelChange {
192    /// Labels in `to` but not `from`.
193    #[serde(skip_serializing_if = "Vec::is_empty")]
194    pub added: Vec<String>,
195    /// Labels in `from` but not `to`.
196    #[serde(skip_serializing_if = "Vec::is_empty")]
197    pub removed: Vec<String>,
198}
199
200/// One section's record in the output.
201#[derive(Debug, Clone, Serialize)]
202pub struct SectionRecord {
203    /// Heading text (empty for the document preamble).
204    pub heading: String,
205    /// Heading-anchor path (e.g. `/h2#background`).
206    pub path: String,
207    /// Coarse change classification.
208    pub change: ChangeKind,
209    /// One-line human-readable summary.
210    pub summary: String,
211    /// Opaque drill-in cursor for `confluence_compare_section`.
212    pub cursor: String,
213    /// Per-block deltas (only emitted in `Full` detail).
214    #[serde(skip_serializing_if = "Vec::is_empty")]
215    pub diff: Vec<NodeDelta>,
216}
217
218/// Continuation pointer when output was truncated.
219#[derive(Debug, Clone, Default, Serialize)]
220pub struct Continuation {
221    /// Cursor to pass to a follow-up call. None when there's no more data.
222    #[serde(skip_serializing_if = "Option::is_none")]
223    pub next_cursor: Option<String>,
224}
225
226// ── Cursor encoding ──────────────────────────────────────────────────
227
228/// Opaque cursor payload carried in `cursor` and `continuation.next_cursor`.
229#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct Cursor {
231    /// Confluence page ID.
232    pub page_id: String,
233    /// `from` version number.
234    pub from_v: u32,
235    /// `to` version number.
236    pub to_v: u32,
237    /// Section path (e.g. `/h2#background`).
238    pub section_path: String,
239}
240
241impl Cursor {
242    /// Encodes the cursor as a base64url string.
243    pub fn encode(&self) -> Result<String> {
244        let json = serde_json::to_vec(self).context("Failed to serialize cursor")?;
245        Ok(base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(json))
246    }
247
248    /// Decodes a base64url cursor string.
249    pub fn decode(s: &str) -> Result<Self> {
250        let bytes = base64::engine::general_purpose::URL_SAFE_NO_PAD
251            .decode(s)
252            .context("Cursor is not valid base64url")?;
253        let cur: Self = serde_json::from_slice(&bytes).context("Cursor JSON is malformed")?;
254        Ok(cur)
255    }
256}
257
258// ── Render input ─────────────────────────────────────────────────────
259
260/// Side-channel data the renderer needs beyond the [`Diff`] itself.
261#[derive(Debug, Clone, Default)]
262pub struct CompareContext {
263    /// Confluence page ID.
264    pub page_id: String,
265    /// Page title (the `to` side's title).
266    pub page_title: String,
267    /// Optional rendered page URL.
268    pub page_url: Option<String>,
269    /// `from`-side version metadata.
270    pub from_version: VersionInfo,
271    /// `to`-side version metadata.
272    pub to_version: VersionInfo,
273    /// `from`-side page title (used for title-change detection).
274    pub from_title: String,
275    /// `to`-side page title.
276    pub to_title: String,
277    /// `from`-side labels.
278    pub from_labels: Vec<String>,
279    /// `to`-side labels.
280    pub to_labels: Vec<String>,
281}
282
283/// Approximate output budget, in bytes of YAML. The default of ~16 KiB
284/// corresponds to roughly 4000 tokens at 4 chars/token.
285pub const DEFAULT_OUTPUT_BUDGET: usize = 16 * 1024;
286
287// ── Render entry points ──────────────────────────────────────────────
288
289/// Renders a [`Diff`] into a [`CompareOutput`] at the given detail level.
290///
291/// The renderer is "render-then-trim": it builds the full output, then
292/// drops trailing sections (and sets `truncated = true`) until the
293/// serialized YAML fits within `budget_bytes`. Section ordering is
294/// preserved, so sections at the head of the list are kept and the
295/// tail is shed first.
296pub fn render(
297    mut diff: Diff,
298    ctx: &CompareContext,
299    detail: Detail,
300    include: Includes,
301    filter: &Filter,
302    budget_bytes: usize,
303) -> Result<CompareOutput> {
304    apply_filter(&mut diff, filter);
305
306    let mut sections = if matches!(detail, Detail::Summary) {
307        Vec::new()
308    } else {
309        build_section_records(&diff, ctx, detail)?
310    };
311
312    let mut truncated = false;
313    let mut continuation: Option<Continuation> = None;
314
315    if !sections.is_empty() {
316        let (kept, drop_first_idx) = trim_to_budget(&diff, ctx, &sections, include, budget_bytes)?;
317        if kept < sections.len() {
318            truncated = true;
319            let next_cursor = sections.get(drop_first_idx).map(|s| s.cursor.clone());
320            continuation = Some(Continuation { next_cursor });
321            sections.truncate(kept);
322        }
323    }
324
325    Ok(build_compare_output(
326        &diff,
327        ctx,
328        sections,
329        include,
330        truncated,
331        continuation,
332    ))
333}
334
335/// Renders a single section diff in the requested format. Used by
336/// `confluence_compare_section`.
337pub fn render_section(diff: &Diff, cursor: &Cursor, format: SectionFormat) -> Result<String> {
338    let section = diff
339        .sections
340        .iter()
341        .find(|s| s.path == cursor.section_path)
342        .with_context(|| {
343            format!(
344                "Section not found for cursor path \"{}\"",
345                cursor.section_path
346            )
347        })?;
348    Ok(match format {
349        SectionFormat::Unified => render_section_unified(section),
350        SectionFormat::SideBySide => render_section_side_by_side(section),
351        SectionFormat::MarkdownInline => render_section_markdown_inline(section),
352    })
353}
354
355/// Output format for `render_section`.
356#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
357pub enum SectionFormat {
358    /// Unified diff (`+`/`-` line markers).
359    #[default]
360    Unified,
361    /// Side-by-side: `from` on the left, `to` on the right.
362    SideBySide,
363    /// Markdown with inline `+added+`/`-removed-` markers.
364    MarkdownInline,
365}
366
367// ── Filter ───────────────────────────────────────────────────────────
368
369/// Applies a [`Filter`] in place. Sections that fail the predicate are
370/// removed from the diff.
371pub fn apply_filter(diff: &mut Diff, filter: &Filter) {
372    let path_filter = !filter.sections.is_empty();
373    let kind_filter = !filter.kinds.is_empty();
374    let min_chars = filter.min_change_chars;
375
376    diff.sections.retain(|s| {
377        if path_filter && !filter.sections.iter().any(|p| p == &s.path) {
378            return false;
379        }
380        if kind_filter && !filter.kinds.contains(&s.change) {
381            return false;
382        }
383        if min_chars > 0 && section_change_chars(s) < min_chars {
384            return false;
385        }
386        true
387    });
388
389    // Recompute aggregate stats over the surviving sections.
390    diff.stats = aggregate_stats(&diff.sections);
391}
392
393fn section_change_chars(s: &SectionDiff) -> u32 {
394    let mut total: u32 = 0;
395    for delta in &s.deltas {
396        match delta {
397            NodeDelta::Paragraph(p) => {
398                total += p.from_text.chars().count() as u32;
399                total += p.to_text.chars().count() as u32;
400            }
401            NodeDelta::CodeBlock(c) => {
402                total += c.from_text.chars().count() as u32;
403                total += c.to_text.chars().count() as u32;
404            }
405            NodeDelta::Added(n) | NodeDelta::Removed(n) => {
406                total += n.text.chars().count() as u32;
407            }
408            NodeDelta::Table(t) => {
409                for cell in &t.cells {
410                    total += cell.from_text.chars().count() as u32;
411                    total += cell.to_text.chars().count() as u32;
412                }
413            }
414            NodeDelta::List(l) => {
415                for s in &l.items_added {
416                    total += s.chars().count() as u32;
417                }
418                for s in &l.items_removed {
419                    total += s.chars().count() as u32;
420                }
421                for (a, b) in &l.items_modified {
422                    total += a.chars().count() as u32;
423                    total += b.chars().count() as u32;
424                }
425            }
426            NodeDelta::Opaque(o) => {
427                total += o.from_summary.chars().count() as u32;
428                total += o.to_summary.chars().count() as u32;
429            }
430        }
431    }
432    total
433}
434
435fn aggregate_stats(sections: &[SectionDiff]) -> DiffStats {
436    let mut stats = DiffStats::default();
437    for s in sections {
438        match s.change {
439            ChangeKind::Added => stats.sections_added += 1,
440            ChangeKind::Removed => stats.sections_removed += 1,
441            ChangeKind::Modified => stats.sections_modified += 1,
442            ChangeKind::Moved => stats.sections_moved += 1,
443            ChangeKind::Unchanged => {}
444        }
445        for delta in &s.deltas {
446            accumulate_stats(&mut stats, delta);
447        }
448    }
449    stats
450}
451
452fn accumulate_stats(stats: &mut DiffStats, delta: &NodeDelta) {
453    match delta {
454        NodeDelta::Paragraph(p) => {
455            stats.paragraphs_modified += 1;
456            stats.words_added += p.words_added;
457            stats.words_removed += p.words_removed;
458        }
459        NodeDelta::Table(_) => stats.tables_modified += 1,
460        NodeDelta::Added(s) => {
461            stats.chars_added += s.text.chars().count() as u32;
462            stats.words_added += s.text.split_whitespace().count() as u32;
463        }
464        NodeDelta::Removed(s) => {
465            stats.chars_removed += s.text.chars().count() as u32;
466            stats.words_removed += s.text.split_whitespace().count() as u32;
467        }
468        NodeDelta::CodeBlock(_) | NodeDelta::List(_) | NodeDelta::Opaque(_) => {}
469    }
470}
471
472// ── Section record assembly ──────────────────────────────────────────
473
474fn build_section_records(
475    diff: &Diff,
476    ctx: &CompareContext,
477    detail: Detail,
478) -> Result<Vec<SectionRecord>> {
479    let mut records = Vec::with_capacity(diff.sections.len());
480    for section in &diff.sections {
481        let cursor = Cursor {
482            page_id: ctx.page_id.clone(),
483            from_v: ctx.from_version.number,
484            to_v: ctx.to_version.number,
485            section_path: section.path.clone(),
486        }
487        .encode()?;
488        let summary = summarize_section(section);
489        let diff_payload = if matches!(detail, Detail::Full) {
490            section.deltas.clone()
491        } else {
492            Vec::new()
493        };
494        records.push(SectionRecord {
495            heading: section.heading.clone(),
496            path: section.path.clone(),
497            change: section.change,
498            summary,
499            cursor,
500            diff: diff_payload,
501        });
502    }
503    Ok(records)
504}
505
506fn summarize_section(section: &SectionDiff) -> String {
507    if section.deltas.is_empty() {
508        return change_label(section.change).to_string();
509    }
510    let parts: Vec<String> = section.deltas.iter().take(3).map(summarize_delta).collect();
511    let mut text = parts.join("; ");
512    if section.deltas.len() > 3 {
513        text.push_str(&format!(" (+{} more)", section.deltas.len() - 3));
514    }
515    text
516}
517
518fn summarize_delta(delta: &NodeDelta) -> String {
519    match delta {
520        NodeDelta::Paragraph(p) => summarize_paragraph(p),
521        NodeDelta::CodeBlock(_) => "code edit".to_string(),
522        NodeDelta::Table(t) => format!("table: {} cell(s) changed", t.cells.len()),
523        NodeDelta::List(l) => {
524            let mut parts: Vec<String> = Vec::new();
525            if !l.items_added.is_empty() {
526                parts.push(format!("+{} item(s)", l.items_added.len()));
527            }
528            if !l.items_removed.is_empty() {
529                parts.push(format!("-{} item(s)", l.items_removed.len()));
530            }
531            if !l.items_modified.is_empty() {
532                parts.push(format!("~{} item(s)", l.items_modified.len()));
533            }
534            if parts.is_empty() {
535                "list edit".to_string()
536            } else {
537                format!("list: {}", parts.join(", "))
538            }
539        }
540        NodeDelta::Added(s) => format!("+{}", truncate(&s.text, 60)),
541        NodeDelta::Removed(s) => format!("-{}", truncate(&s.text, 60)),
542        NodeDelta::Opaque(o) => format!("{} changed", o.node_type),
543    }
544}
545
546fn summarize_paragraph(p: &ParagraphDelta) -> String {
547    let from = truncate(&p.from_text, 30);
548    let to = truncate(&p.to_text, 30);
549    format!("\"{from}\" → \"{to}\"")
550}
551
552fn truncate(s: &str, max: usize) -> String {
553    if s.chars().count() <= max {
554        s.to_string()
555    } else {
556        let truncated: String = s.chars().take(max.saturating_sub(1)).collect();
557        format!("{truncated}…")
558    }
559}
560
561fn change_label(change: ChangeKind) -> &'static str {
562    match change {
563        ChangeKind::Added => "added",
564        ChangeKind::Removed => "removed",
565        ChangeKind::Modified => "modified",
566        ChangeKind::Moved => "moved",
567        ChangeKind::Unchanged => "unchanged",
568    }
569}
570
571// ── Output assembly ──────────────────────────────────────────────────
572
573fn build_compare_output(
574    diff: &Diff,
575    ctx: &CompareContext,
576    sections: Vec<SectionRecord>,
577    include: Includes,
578    truncated: bool,
579    continuation: Option<Continuation>,
580) -> CompareOutput {
581    let title_change = if include.title && ctx.from_title != ctx.to_title {
582        Some(TitleChange {
583            from: ctx.from_title.clone(),
584            to: ctx.to_title.clone(),
585        })
586    } else {
587        None
588    };
589    let labels = if include.labels {
590        Some(diff_labels(&ctx.from_labels, &ctx.to_labels))
591    } else {
592        None
593    };
594    let versions = if include.metadata {
595        Some(VersionPair {
596            from: ctx.from_version.clone(),
597            to: ctx.to_version.clone(),
598        })
599    } else {
600        None
601    };
602    let stats = &diff.stats;
603    let summary = SummaryBlock {
604        total_changes: stats.sections_added
605            + stats.sections_removed
606            + stats.sections_modified
607            + stats.sections_moved
608            + stats.paragraphs_modified
609            + stats.tables_modified,
610        by_kind: ByKind {
611            sections_added: stats.sections_added,
612            sections_removed: stats.sections_removed,
613            sections_modified: stats.sections_modified,
614            sections_moved: stats.sections_moved,
615            paragraphs_modified: stats.paragraphs_modified,
616            tables_modified: stats.tables_modified,
617        },
618        net: NetCounts {
619            chars_added: stats.chars_added,
620            chars_removed: stats.chars_removed,
621            words_added: stats.words_added,
622            words_removed: stats.words_removed,
623        },
624    };
625
626    CompareOutput {
627        page: PageHeader {
628            id: ctx.page_id.clone(),
629            title: ctx.page_title.clone(),
630            url: ctx.page_url.clone(),
631        },
632        versions,
633        summary,
634        title_change,
635        labels,
636        sections: if include.body { sections } else { Vec::new() },
637        truncated,
638        continuation,
639    }
640}
641
642fn diff_labels(from: &[String], to: &[String]) -> LabelChange {
643    let from_set: std::collections::BTreeSet<&String> = from.iter().collect();
644    let to_set: std::collections::BTreeSet<&String> = to.iter().collect();
645    LabelChange {
646        added: to_set.difference(&from_set).map(|s| (*s).clone()).collect(),
647        removed: from_set.difference(&to_set).map(|s| (*s).clone()).collect(),
648    }
649}
650
651// ── Budget trimming ─────────────────────────────────────────────────
652
653fn trim_to_budget(
654    diff: &Diff,
655    ctx: &CompareContext,
656    sections: &[SectionRecord],
657    include: Includes,
658    budget_bytes: usize,
659) -> Result<(usize, usize)> {
660    // Render with all sections; if it fits, no truncation.
661    let full = build_compare_output(diff, ctx, sections.to_vec(), include, false, None);
662    let yaml = crate::data::yaml::to_yaml(&full)?;
663    if yaml.len() <= budget_bytes {
664        return Ok((sections.len(), sections.len()));
665    }
666
667    // Otherwise, find the largest prefix that fits via binary search.
668    let (mut lo, mut hi) = (0usize, sections.len());
669    let mut best = 0usize;
670    while lo <= hi {
671        let mid = (lo + hi) / 2;
672        let trial = build_compare_output(
673            diff,
674            ctx,
675            sections.iter().take(mid).cloned().collect(),
676            include,
677            true,
678            Some(Continuation {
679                next_cursor: sections.get(mid).map(|s| s.cursor.clone()),
680            }),
681        );
682        let yaml = crate::data::yaml::to_yaml(&trial)?;
683        if yaml.len() <= budget_bytes {
684            best = mid;
685            lo = mid + 1;
686        } else if mid == 0 {
687            break;
688        } else {
689            hi = mid - 1;
690        }
691    }
692    Ok((best, best))
693}
694
695// ── Section formatters (unified / side-by-side / markdown_inline) ────
696
697fn render_section_unified(section: &SectionDiff) -> String {
698    let mut out = String::new();
699    out.push_str(&format!(
700        "@@ {} ({}) @@\n",
701        section.path,
702        change_label(section.change)
703    ));
704    for delta in &section.deltas {
705        push_unified_delta(delta, &mut out);
706    }
707    out
708}
709
710fn push_unified_delta(delta: &NodeDelta, out: &mut String) {
711    match delta {
712        NodeDelta::Paragraph(p) => {
713            for line in p.from_text.lines() {
714                out.push_str(&format!("- {line}\n"));
715            }
716            for line in p.to_text.lines() {
717                out.push_str(&format!("+ {line}\n"));
718            }
719        }
720        NodeDelta::CodeBlock(c) => {
721            out.push_str(&format!(
722                "  ```{}\n",
723                c.language.as_deref().unwrap_or_default()
724            ));
725            push_text_diff(&c.from_text, &c.to_text, out);
726            out.push_str("  ```\n");
727        }
728        NodeDelta::Table(t) => {
729            for cell in &t.cells {
730                out.push_str(&format!(
731                    "  table[{}][{}]:\n- {}\n+ {}\n",
732                    cell.row, cell.col, cell.from_text, cell.to_text
733                ));
734            }
735        }
736        NodeDelta::List(l) => {
737            for s in &l.items_removed {
738                out.push_str(&format!("- {s}\n"));
739            }
740            for s in &l.items_added {
741                out.push_str(&format!("+ {s}\n"));
742            }
743            for (a, b) in &l.items_modified {
744                out.push_str(&format!("- {a}\n+ {b}\n"));
745            }
746        }
747        NodeDelta::Added(NodeSnapshot { text, .. }) => {
748            for line in text.lines() {
749                out.push_str(&format!("+ {line}\n"));
750            }
751        }
752        NodeDelta::Removed(NodeSnapshot { text, .. }) => {
753            for line in text.lines() {
754                out.push_str(&format!("- {line}\n"));
755            }
756        }
757        NodeDelta::Opaque(o) => {
758            out.push_str(&format!("  ({} changed)\n", o.node_type));
759            for line in o.from_summary.lines() {
760                out.push_str(&format!("- {line}\n"));
761            }
762            for line in o.to_summary.lines() {
763                out.push_str(&format!("+ {line}\n"));
764            }
765        }
766    }
767}
768
769fn push_text_diff(from: &str, to: &str, out: &mut String) {
770    use similar::{ChangeTag, TextDiff};
771    let diff = TextDiff::from_lines(from, to);
772    for change in diff.iter_all_changes() {
773        let prefix = match change.tag() {
774            ChangeTag::Insert => '+',
775            ChangeTag::Delete => '-',
776            ChangeTag::Equal => ' ',
777        };
778        let text = change.value();
779        out.push_str(&format!("{prefix} {text}"));
780        if !text.ends_with('\n') {
781            out.push('\n');
782        }
783    }
784}
785
786fn render_section_side_by_side(section: &SectionDiff) -> String {
787    let mut out = String::new();
788    out.push_str(&format!(
789        "── {} ({}) ──\n",
790        section.path,
791        change_label(section.change)
792    ));
793    let width: usize = 40;
794    for delta in &section.deltas {
795        match delta {
796            NodeDelta::Paragraph(p) => {
797                push_columns(&p.from_text, &p.to_text, width, &mut out);
798            }
799            NodeDelta::CodeBlock(c) => {
800                push_columns(&c.from_text, &c.to_text, width, &mut out);
801            }
802            NodeDelta::Table(t) => {
803                for cell in &t.cells {
804                    push_columns(
805                        &format!("[{},{}] {}", cell.row, cell.col, cell.from_text),
806                        &format!("[{},{}] {}", cell.row, cell.col, cell.to_text),
807                        width,
808                        &mut out,
809                    );
810                }
811            }
812            NodeDelta::List(l) => {
813                for s in &l.items_removed {
814                    push_columns(s, "", width, &mut out);
815                }
816                for s in &l.items_added {
817                    push_columns("", s, width, &mut out);
818                }
819                for (a, b) in &l.items_modified {
820                    push_columns(a, b, width, &mut out);
821                }
822            }
823            NodeDelta::Added(s) => push_columns("", &s.text, width, &mut out),
824            NodeDelta::Removed(s) => push_columns(&s.text, "", width, &mut out),
825            NodeDelta::Opaque(o) => push_columns(&o.from_summary, &o.to_summary, width, &mut out),
826        }
827    }
828    out
829}
830
831fn push_columns(left: &str, right: &str, width: usize, out: &mut String) {
832    out.push_str(&format!(
833        "{:<width$} | {}\n",
834        truncate(left, width),
835        truncate(right, width)
836    ));
837}
838
839fn render_section_markdown_inline(section: &SectionDiff) -> String {
840    use similar::{ChangeTag, TextDiff};
841    let mut out = String::new();
842    out.push_str(&format!(
843        "### {} ({})\n\n",
844        if section.heading.is_empty() {
845            "Preamble"
846        } else {
847            section.heading.as_str()
848        },
849        change_label(section.change)
850    ));
851    for delta in &section.deltas {
852        match delta {
853            NodeDelta::Paragraph(p) => {
854                let diff = TextDiff::from_words(&p.from_text, &p.to_text);
855                for change in diff.iter_all_changes() {
856                    let val = change.value();
857                    match change.tag() {
858                        ChangeTag::Insert => out.push_str(&format!("**+{val}**")),
859                        ChangeTag::Delete => out.push_str(&format!("~~-{val}~~")),
860                        ChangeTag::Equal => out.push_str(val),
861                    }
862                }
863                out.push_str("\n\n");
864            }
865            NodeDelta::CodeBlock(c) => {
866                out.push_str(&format!(
867                    "```{}\n",
868                    c.language.as_deref().unwrap_or_default()
869                ));
870                push_text_diff(&c.from_text, &c.to_text, &mut out);
871                out.push_str("```\n\n");
872            }
873            NodeDelta::Table(t) => {
874                for cell in &t.cells {
875                    out.push_str(&format!(
876                        "- table[{},{}]: ~~{}~~ → **{}**\n",
877                        cell.row, cell.col, cell.from_text, cell.to_text
878                    ));
879                }
880                out.push('\n');
881            }
882            NodeDelta::List(l) => {
883                for s in &l.items_added {
884                    out.push_str(&format!("- **+{s}**\n"));
885                }
886                for s in &l.items_removed {
887                    out.push_str(&format!("- ~~{s}~~\n"));
888                }
889                for (a, b) in &l.items_modified {
890                    out.push_str(&format!("- ~~{a}~~ → **{b}**\n"));
891                }
892                out.push('\n');
893            }
894            NodeDelta::Added(s) => {
895                out.push_str(&format!("**+ {}**\n\n", s.text));
896            }
897            NodeDelta::Removed(s) => {
898                out.push_str(&format!("~~- {}~~\n\n", s.text));
899            }
900            NodeDelta::Opaque(o) => {
901                out.push_str(&format!(
902                    "**{} changed:** ~~{}~~ → **{}**\n\n",
903                    o.node_type, o.from_summary, o.to_summary
904                ));
905            }
906        }
907    }
908    out
909}
910
911// ── Tests ────────────────────────────────────────────────────────────
912
913#[cfg(test)]
914#[allow(clippy::unwrap_used, clippy::expect_used)]
915mod tests {
916    use super::*;
917    use crate::atlassian::adf::{AdfDocument, AdfNode};
918    use crate::atlassian::diff::{
919        diff_documents, CellDelta, CodeBlockDelta, DiffOptions, ListDelta, OpaqueDelta,
920        ParagraphDelta, TableDelta,
921    };
922
923    fn doc(content: Vec<AdfNode>) -> AdfDocument {
924        AdfDocument {
925            version: 1,
926            doc_type: "doc".to_string(),
927            content,
928        }
929    }
930    fn p(text: &str) -> AdfNode {
931        AdfNode::paragraph(vec![AdfNode::text(text)])
932    }
933    fn h(level: u8, text: &str) -> AdfNode {
934        AdfNode::heading(level, vec![AdfNode::text(text)])
935    }
936
937    fn ctx() -> CompareContext {
938        CompareContext {
939            page_id: "12345".to_string(),
940            page_title: "Spec".to_string(),
941            page_url: Some("https://example.atlassian.net/wiki/...".to_string()),
942            from_version: VersionInfo {
943                number: 4,
944                created_at: "2026-05-08T10:00:00Z".to_string(),
945                author: "alice".to_string(),
946                message: String::new(),
947            },
948            to_version: VersionInfo {
949                number: 5,
950                created_at: "2026-05-09T10:00:00Z".to_string(),
951                author: "bob".to_string(),
952                message: "rev".to_string(),
953            },
954            from_title: "Spec v0.9".to_string(),
955            to_title: "Spec v1.0".to_string(),
956            from_labels: vec!["draft".to_string(), "wip".to_string()],
957            to_labels: vec!["draft".to_string(), "approved".to_string()],
958        }
959    }
960
961    #[test]
962    fn cursor_round_trip() {
963        let cur = Cursor {
964            page_id: "12345".to_string(),
965            from_v: 4,
966            to_v: 5,
967            section_path: "/h2#background".to_string(),
968        };
969        let s = cur.encode().unwrap();
970        let back = Cursor::decode(&s).unwrap();
971        assert_eq!(back.page_id, cur.page_id);
972        assert_eq!(back.from_v, cur.from_v);
973        assert_eq!(back.to_v, cur.to_v);
974        assert_eq!(back.section_path, cur.section_path);
975    }
976
977    #[test]
978    fn cursor_decode_rejects_garbage() {
979        assert!(Cursor::decode("!!!").is_err());
980        assert!(Cursor::decode("not-json").is_err());
981    }
982
983    #[test]
984    fn render_summary_omits_sections() {
985        let from = doc(vec![h(2, "A"), p("a")]);
986        let to = doc(vec![h(2, "A"), p("a edited")]);
987        let diff = diff_documents(&from, &to, &DiffOptions::default());
988        let out = render(
989            diff,
990            &ctx(),
991            Detail::Summary,
992            Includes::default(),
993            &Filter::default(),
994            DEFAULT_OUTPUT_BUDGET,
995        )
996        .unwrap();
997        assert!(out.sections.is_empty());
998        assert_eq!(out.summary.by_kind.sections_modified, 1);
999    }
1000
1001    #[test]
1002    fn render_outline_includes_sections_without_diff_payload() {
1003        let from = doc(vec![h(2, "A"), p("a")]);
1004        let to = doc(vec![h(2, "A"), p("a edited"), h(2, "B"), p("b")]);
1005        let diff = diff_documents(&from, &to, &DiffOptions::default());
1006        let out = render(
1007            diff,
1008            &ctx(),
1009            Detail::Outline,
1010            Includes::default(),
1011            &Filter::default(),
1012            DEFAULT_OUTPUT_BUDGET,
1013        )
1014        .unwrap();
1015        assert_eq!(out.sections.len(), 2);
1016        for section in &out.sections {
1017            assert!(section.diff.is_empty());
1018            assert!(!section.cursor.is_empty());
1019        }
1020    }
1021
1022    #[test]
1023    fn render_full_includes_per_section_deltas() {
1024        let from = doc(vec![h(2, "A"), p("alpha")]);
1025        let to = doc(vec![h(2, "A"), p("alpha edited")]);
1026        let diff = diff_documents(&from, &to, &DiffOptions::default());
1027        let out = render(
1028            diff,
1029            &ctx(),
1030            Detail::Full,
1031            Includes::default(),
1032            &Filter::default(),
1033            DEFAULT_OUTPUT_BUDGET,
1034        )
1035        .unwrap();
1036        assert!(!out.sections[0].diff.is_empty());
1037    }
1038
1039    #[test]
1040    fn title_change_emitted_when_titles_differ() {
1041        let from = doc(vec![]);
1042        let to = doc(vec![]);
1043        let diff = diff_documents(&from, &to, &DiffOptions::default());
1044        let out = render(
1045            diff,
1046            &ctx(),
1047            Detail::Outline,
1048            Includes::default(),
1049            &Filter::default(),
1050            DEFAULT_OUTPUT_BUDGET,
1051        )
1052        .unwrap();
1053        let tc = out.title_change.unwrap();
1054        assert_eq!(tc.from, "Spec v0.9");
1055        assert_eq!(tc.to, "Spec v1.0");
1056    }
1057
1058    #[test]
1059    fn title_change_omitted_when_titles_match() {
1060        let mut c = ctx();
1061        c.from_title = c.to_title.clone();
1062        let from = doc(vec![]);
1063        let to = doc(vec![]);
1064        let diff = diff_documents(&from, &to, &DiffOptions::default());
1065        let out = render(
1066            diff,
1067            &c,
1068            Detail::Outline,
1069            Includes::default(),
1070            &Filter::default(),
1071            DEFAULT_OUTPUT_BUDGET,
1072        )
1073        .unwrap();
1074        assert!(out.title_change.is_none());
1075    }
1076
1077    #[test]
1078    fn label_change_diff_added_and_removed() {
1079        let from = doc(vec![]);
1080        let to = doc(vec![]);
1081        let diff = diff_documents(&from, &to, &DiffOptions::default());
1082        let inc = Includes {
1083            labels: true,
1084            ..Includes::default()
1085        };
1086        let out = render(
1087            diff,
1088            &ctx(),
1089            Detail::Outline,
1090            inc,
1091            &Filter::default(),
1092            DEFAULT_OUTPUT_BUDGET,
1093        )
1094        .unwrap();
1095        let lc = out.labels.unwrap();
1096        assert_eq!(lc.added, vec!["approved".to_string()]);
1097        assert_eq!(lc.removed, vec!["wip".to_string()]);
1098    }
1099
1100    #[test]
1101    fn filter_by_path_drops_other_sections() {
1102        let from = doc(vec![h(2, "A"), p("a"), h(2, "B"), p("b")]);
1103        let to = doc(vec![h(2, "A"), p("a edit"), h(2, "B"), p("b edit")]);
1104        let diff = diff_documents(&from, &to, &DiffOptions::default());
1105        let filter = Filter {
1106            sections: vec!["/h2#a".to_string()],
1107            ..Filter::default()
1108        };
1109        let out = render(
1110            diff,
1111            &ctx(),
1112            Detail::Outline,
1113            Includes::default(),
1114            &filter,
1115            DEFAULT_OUTPUT_BUDGET,
1116        )
1117        .unwrap();
1118        assert_eq!(out.sections.len(), 1);
1119        assert_eq!(out.sections[0].path, "/h2#a");
1120    }
1121
1122    #[test]
1123    fn filter_by_kind_drops_unchanged() {
1124        let from = doc(vec![h(2, "A"), p("a"), h(2, "B"), p("b")]);
1125        let to = doc(vec![h(2, "A"), p("a edit"), h(2, "B"), p("b")]);
1126        let diff = diff_documents(&from, &to, &DiffOptions::default());
1127        let filter = Filter {
1128            kinds: vec![ChangeKind::Modified],
1129            ..Filter::default()
1130        };
1131        let out = render(
1132            diff,
1133            &ctx(),
1134            Detail::Outline,
1135            Includes::default(),
1136            &filter,
1137            DEFAULT_OUTPUT_BUDGET,
1138        )
1139        .unwrap();
1140        assert_eq!(out.sections.len(), 1);
1141        assert_eq!(out.sections[0].path, "/h2#a");
1142    }
1143
1144    #[test]
1145    fn filter_min_change_chars_drops_small_edits() {
1146        let from = doc(vec![h(2, "A"), p("ab"), h(2, "B"), p("aaaaaaaaaa")]);
1147        let to = doc(vec![h(2, "A"), p("ac"), h(2, "B"), p("bbbbbbbbbb")]);
1148        let diff = diff_documents(&from, &to, &DiffOptions::default());
1149        let filter = Filter {
1150            min_change_chars: 10,
1151            ..Filter::default()
1152        };
1153        let out = render(
1154            diff,
1155            &ctx(),
1156            Detail::Outline,
1157            Includes::default(),
1158            &filter,
1159            DEFAULT_OUTPUT_BUDGET,
1160        )
1161        .unwrap();
1162        // Section A's text is 4 chars total ("ab" + "ac"); below 10. B is 20.
1163        assert_eq!(out.sections.len(), 1);
1164        assert_eq!(out.sections[0].path, "/h2#b");
1165    }
1166
1167    #[test]
1168    fn budget_truncates_and_sets_continuation_cursor() {
1169        // Make many sections with substantial diffs so the output blows the budget.
1170        let mut from_blocks: Vec<AdfNode> = Vec::new();
1171        let mut to_blocks: Vec<AdfNode> = Vec::new();
1172        for i in 0..50 {
1173            from_blocks.push(h(2, &format!("section-{i}")));
1174            from_blocks.push(p(&"alpha ".repeat(20)));
1175            to_blocks.push(h(2, &format!("section-{i}")));
1176            to_blocks.push(p(&"beta ".repeat(20)));
1177        }
1178        let diff = diff_documents(&doc(from_blocks), &doc(to_blocks), &DiffOptions::default());
1179        let out = render(
1180            diff,
1181            &ctx(),
1182            Detail::Full,
1183            Includes::default(),
1184            &Filter::default(),
1185            2048, // tiny budget
1186        )
1187        .unwrap();
1188        assert!(out.truncated);
1189        let cont = out.continuation.as_ref().expect("continuation present");
1190        assert!(cont.next_cursor.is_some());
1191        // Decode the cursor and verify it points to a real section.
1192        let cur = Cursor::decode(cont.next_cursor.as_ref().unwrap()).unwrap();
1193        assert_eq!(cur.page_id, "12345");
1194        assert!(cur.section_path.starts_with("/h2#"));
1195    }
1196
1197    #[test]
1198    fn budget_not_truncated_when_output_fits() {
1199        let from = doc(vec![h(2, "A"), p("a")]);
1200        let to = doc(vec![h(2, "A"), p("a edit")]);
1201        let diff = diff_documents(&from, &to, &DiffOptions::default());
1202        let out = render(
1203            diff,
1204            &ctx(),
1205            Detail::Full,
1206            Includes::default(),
1207            &Filter::default(),
1208            DEFAULT_OUTPUT_BUDGET,
1209        )
1210        .unwrap();
1211        assert!(!out.truncated);
1212        assert!(out.continuation.is_none());
1213    }
1214
1215    #[test]
1216    fn render_section_unified_format() {
1217        let from = doc(vec![h(2, "A"), p("hello world")]);
1218        let to = doc(vec![h(2, "A"), p("hello there")]);
1219        let diff = diff_documents(&from, &to, &DiffOptions::default());
1220        let cur = Cursor {
1221            page_id: "p".to_string(),
1222            from_v: 1,
1223            to_v: 2,
1224            section_path: "/h2#a".to_string(),
1225        };
1226        let out = render_section(&diff, &cur, SectionFormat::Unified).unwrap();
1227        assert!(out.contains("- hello world"));
1228        assert!(out.contains("+ hello there"));
1229        assert!(out.contains("/h2#a"));
1230    }
1231
1232    #[test]
1233    fn render_section_side_by_side_format() {
1234        let from = doc(vec![h(2, "A"), p("alpha")]);
1235        let to = doc(vec![h(2, "A"), p("beta")]);
1236        let diff = diff_documents(&from, &to, &DiffOptions::default());
1237        let cur = Cursor {
1238            page_id: "p".to_string(),
1239            from_v: 1,
1240            to_v: 2,
1241            section_path: "/h2#a".to_string(),
1242        };
1243        let out = render_section(&diff, &cur, SectionFormat::SideBySide).unwrap();
1244        assert!(out.contains("alpha"));
1245        assert!(out.contains("beta"));
1246        assert!(out.contains('|'));
1247    }
1248
1249    #[test]
1250    fn render_section_markdown_inline_format() {
1251        let from = doc(vec![h(2, "A"), p("hello world")]);
1252        let to = doc(vec![h(2, "A"), p("hello universe")]);
1253        let diff = diff_documents(&from, &to, &DiffOptions::default());
1254        let cur = Cursor {
1255            page_id: "p".to_string(),
1256            from_v: 1,
1257            to_v: 2,
1258            section_path: "/h2#a".to_string(),
1259        };
1260        let out = render_section(&diff, &cur, SectionFormat::MarkdownInline).unwrap();
1261        assert!(out.contains("hello"));
1262        assert!(out.contains("universe"));
1263    }
1264
1265    #[test]
1266    fn render_section_unknown_path_errors() {
1267        let from = doc(vec![h(2, "A"), p("a")]);
1268        let to = doc(vec![h(2, "A"), p("b")]);
1269        let diff = diff_documents(&from, &to, &DiffOptions::default());
1270        let cur = Cursor {
1271            page_id: "p".to_string(),
1272            from_v: 1,
1273            to_v: 2,
1274            section_path: "/h2#nope".to_string(),
1275        };
1276        let err = render_section(&diff, &cur, SectionFormat::Unified).unwrap_err();
1277        assert!(err.to_string().contains("Section not found"));
1278    }
1279
1280    #[test]
1281    fn body_excluded_when_include_body_false() {
1282        let from = doc(vec![h(2, "A"), p("a")]);
1283        let to = doc(vec![h(2, "A"), p("a edit")]);
1284        let diff = diff_documents(&from, &to, &DiffOptions::default());
1285        let inc = Includes {
1286            body: false,
1287            ..Includes::default()
1288        };
1289        let out = render(
1290            diff,
1291            &ctx(),
1292            Detail::Outline,
1293            inc,
1294            &Filter::default(),
1295            DEFAULT_OUTPUT_BUDGET,
1296        )
1297        .unwrap();
1298        assert!(out.sections.is_empty());
1299    }
1300
1301    #[test]
1302    fn truncate_helper_handles_unicode() {
1303        assert_eq!(truncate("hello", 10), "hello");
1304        assert_eq!(truncate("hello world", 5), "hell…");
1305        // Multibyte: ensure char count, not byte count.
1306        let s = "ééééé"; // 5 chars, 10 bytes
1307        assert_eq!(truncate(s, 5), s);
1308    }
1309
1310    // ── Section formatters: every NodeDelta variant ───────────────
1311
1312    /// Builds a single-section `Diff` whose only delta is the one supplied.
1313    /// Used to exercise format renderers without going through the full
1314    /// `diff_documents` pipeline.
1315    fn diff_with_single_delta(delta: NodeDelta) -> Diff {
1316        let section = SectionDiff {
1317            heading: "S".to_string(),
1318            path: "/h2#s".to_string(),
1319            change: ChangeKind::Modified,
1320            deltas: vec![delta],
1321        };
1322        Diff {
1323            sections: vec![section],
1324            stats: DiffStats::default(),
1325        }
1326    }
1327
1328    fn cursor() -> Cursor {
1329        Cursor {
1330            page_id: "p".to_string(),
1331            from_v: 1,
1332            to_v: 2,
1333            section_path: "/h2#s".to_string(),
1334        }
1335    }
1336
1337    fn cell_delta() -> CellDelta {
1338        CellDelta {
1339            row: 1,
1340            col: 2,
1341            from_text: "old".to_string(),
1342            to_text: "new".to_string(),
1343        }
1344    }
1345
1346    fn snapshot_delta(text: &str) -> NodeSnapshot {
1347        NodeSnapshot {
1348            node_type: "paragraph".to_string(),
1349            text: text.to_string(),
1350        }
1351    }
1352
1353    #[test]
1354    fn unified_format_emits_table_cell_lines() {
1355        let diff = diff_with_single_delta(NodeDelta::Table(TableDelta {
1356            cells: vec![cell_delta()],
1357        }));
1358        let out = render_section(&diff, &cursor(), SectionFormat::Unified).unwrap();
1359        assert!(out.contains("table[1][2]"));
1360        assert!(out.contains("- old"));
1361        assert!(out.contains("+ new"));
1362    }
1363
1364    #[test]
1365    fn unified_format_emits_list_lines() {
1366        let diff = diff_with_single_delta(NodeDelta::List(ListDelta {
1367            items_added: vec!["new-item".to_string()],
1368            items_removed: vec!["old-item".to_string()],
1369            items_modified: vec![("a".to_string(), "b".to_string())],
1370        }));
1371        let out = render_section(&diff, &cursor(), SectionFormat::Unified).unwrap();
1372        assert!(out.contains("- old-item"));
1373        assert!(out.contains("+ new-item"));
1374        assert!(out.contains("- a"));
1375        assert!(out.contains("+ b"));
1376    }
1377
1378    #[test]
1379    fn unified_format_emits_added_removed_snapshots() {
1380        let added = diff_with_single_delta(NodeDelta::Added(snapshot_delta("first\nsecond")));
1381        let out = render_section(&added, &cursor(), SectionFormat::Unified).unwrap();
1382        assert!(out.contains("+ first"));
1383        assert!(out.contains("+ second"));
1384
1385        let removed = diff_with_single_delta(NodeDelta::Removed(snapshot_delta("gone\nbye")));
1386        let out = render_section(&removed, &cursor(), SectionFormat::Unified).unwrap();
1387        assert!(out.contains("- gone"));
1388        assert!(out.contains("- bye"));
1389    }
1390
1391    #[test]
1392    fn unified_format_emits_opaque_block() {
1393        let diff = diff_with_single_delta(NodeDelta::Opaque(OpaqueDelta {
1394            node_type: "panel".to_string(),
1395            from_summary: "before".to_string(),
1396            to_summary: "after".to_string(),
1397        }));
1398        let out = render_section(&diff, &cursor(), SectionFormat::Unified).unwrap();
1399        assert!(out.contains("(panel changed)"));
1400        assert!(out.contains("- before"));
1401        assert!(out.contains("+ after"));
1402    }
1403
1404    #[test]
1405    fn unified_format_emits_code_block_lines() {
1406        let diff = diff_with_single_delta(NodeDelta::CodeBlock(CodeBlockDelta {
1407            language: Some("rust".to_string()),
1408            from_text: "fn one() {}".to_string(),
1409            to_text: "fn one() {}\nfn two() {}".to_string(),
1410        }));
1411        let out = render_section(&diff, &cursor(), SectionFormat::Unified).unwrap();
1412        assert!(out.contains("```rust"));
1413        assert!(out.contains("+ fn two() {}"));
1414    }
1415
1416    #[test]
1417    fn side_by_side_format_emits_all_variants() {
1418        let cases: Vec<NodeDelta> = vec![
1419            NodeDelta::Paragraph(ParagraphDelta {
1420                from_text: "alpha".to_string(),
1421                to_text: "beta".to_string(),
1422                words_added: 1,
1423                words_removed: 1,
1424            }),
1425            NodeDelta::CodeBlock(CodeBlockDelta {
1426                language: None,
1427                from_text: "old".to_string(),
1428                to_text: "new".to_string(),
1429            }),
1430            NodeDelta::Table(TableDelta {
1431                cells: vec![cell_delta()],
1432            }),
1433            NodeDelta::List(ListDelta {
1434                items_added: vec!["x".to_string()],
1435                items_removed: vec!["y".to_string()],
1436                items_modified: vec![("a".to_string(), "b".to_string())],
1437            }),
1438            NodeDelta::Added(snapshot_delta("plus")),
1439            NodeDelta::Removed(snapshot_delta("minus")),
1440            NodeDelta::Opaque(OpaqueDelta {
1441                node_type: "panel".to_string(),
1442                from_summary: "from".to_string(),
1443                to_summary: "to".to_string(),
1444            }),
1445        ];
1446        for delta in cases {
1447            let diff = diff_with_single_delta(delta);
1448            let out = render_section(&diff, &cursor(), SectionFormat::SideBySide).unwrap();
1449            assert!(out.contains('|'));
1450            assert!(out.contains("/h2#s"));
1451        }
1452    }
1453
1454    #[test]
1455    fn markdown_inline_format_emits_all_variants() {
1456        let cases: Vec<NodeDelta> = vec![
1457            NodeDelta::CodeBlock(CodeBlockDelta {
1458                language: Some("rust".to_string()),
1459                from_text: "fn a() {}".to_string(),
1460                to_text: "fn a() { 1 }".to_string(),
1461            }),
1462            NodeDelta::Table(TableDelta {
1463                cells: vec![cell_delta()],
1464            }),
1465            NodeDelta::List(ListDelta {
1466                items_added: vec!["x".to_string()],
1467                items_removed: vec!["y".to_string()],
1468                items_modified: vec![("a".to_string(), "b".to_string())],
1469            }),
1470            NodeDelta::Added(snapshot_delta("plus")),
1471            NodeDelta::Removed(snapshot_delta("minus")),
1472            NodeDelta::Opaque(OpaqueDelta {
1473                node_type: "panel".to_string(),
1474                from_summary: "from".to_string(),
1475                to_summary: "to".to_string(),
1476            }),
1477        ];
1478        for delta in cases {
1479            let diff = diff_with_single_delta(delta);
1480            let out = render_section(&diff, &cursor(), SectionFormat::MarkdownInline).unwrap();
1481            assert!(out.contains("###"));
1482        }
1483    }
1484
1485    #[test]
1486    fn markdown_inline_uses_preamble_label_when_heading_empty() {
1487        let section = SectionDiff {
1488            heading: String::new(),
1489            path: String::new(),
1490            change: ChangeKind::Modified,
1491            deltas: vec![NodeDelta::Paragraph(ParagraphDelta {
1492                from_text: "a".to_string(),
1493                to_text: "b".to_string(),
1494                words_added: 1,
1495                words_removed: 1,
1496            })],
1497        };
1498        let diff = Diff {
1499            sections: vec![section],
1500            stats: DiffStats::default(),
1501        };
1502        let cur = Cursor {
1503            page_id: "p".to_string(),
1504            from_v: 1,
1505            to_v: 2,
1506            section_path: String::new(),
1507        };
1508        let out = render_section(&diff, &cur, SectionFormat::MarkdownInline).unwrap();
1509        assert!(out.contains("Preamble"));
1510    }
1511
1512    // ── Summarizers ───────────────────────────────────────────────
1513
1514    #[test]
1515    fn summarize_section_truncates_long_delta_list() {
1516        let mk_para = |a: &str, b: &str| {
1517            NodeDelta::Paragraph(ParagraphDelta {
1518                from_text: a.to_string(),
1519                to_text: b.to_string(),
1520                words_added: 1,
1521                words_removed: 1,
1522            })
1523        };
1524        let section = SectionDiff {
1525            heading: "S".to_string(),
1526            path: "/h2#s".to_string(),
1527            change: ChangeKind::Modified,
1528            deltas: vec![
1529                mk_para("a1", "a2"),
1530                mk_para("b1", "b2"),
1531                mk_para("c1", "c2"),
1532                mk_para("d1", "d2"),
1533                mk_para("e1", "e2"),
1534            ],
1535        };
1536        let summary = summarize_section(&section);
1537        assert!(summary.contains("(+2 more)"));
1538    }
1539
1540    #[test]
1541    fn summarize_section_returns_change_label_when_empty() {
1542        let section = SectionDiff {
1543            heading: "S".to_string(),
1544            path: "/h2#s".to_string(),
1545            change: ChangeKind::Added,
1546            deltas: vec![],
1547        };
1548        assert_eq!(summarize_section(&section), "added");
1549    }
1550
1551    #[test]
1552    fn summarize_delta_covers_every_variant() {
1553        // Each variant exercises a distinct match arm in summarize_delta.
1554        let s = summarize_delta(&NodeDelta::CodeBlock(CodeBlockDelta {
1555            language: None,
1556            from_text: "a".to_string(),
1557            to_text: "b".to_string(),
1558        }));
1559        assert!(s.contains("code"));
1560
1561        let s = summarize_delta(&NodeDelta::Table(TableDelta {
1562            cells: vec![cell_delta(), cell_delta()],
1563        }));
1564        assert!(s.contains("table") && s.contains("2 cell"));
1565
1566        let s = summarize_delta(&NodeDelta::List(ListDelta {
1567            items_added: vec!["x".to_string()],
1568            items_removed: vec!["y".to_string()],
1569            items_modified: vec![("a".to_string(), "b".to_string())],
1570        }));
1571        assert!(s.contains("+1") && s.contains("-1") && s.contains("~1"));
1572
1573        let s = summarize_delta(&NodeDelta::List(ListDelta::default()));
1574        assert_eq!(s, "list edit");
1575
1576        let s = summarize_delta(&NodeDelta::Added(snapshot_delta("plus")));
1577        assert!(s.starts_with('+'));
1578
1579        let s = summarize_delta(&NodeDelta::Removed(snapshot_delta("minus")));
1580        assert!(s.starts_with('-'));
1581
1582        let s = summarize_delta(&NodeDelta::Opaque(OpaqueDelta {
1583            node_type: "panel".to_string(),
1584            from_summary: "x".to_string(),
1585            to_summary: "y".to_string(),
1586        }));
1587        assert!(s.contains("panel changed"));
1588    }
1589
1590    #[test]
1591    fn change_label_covers_every_kind() {
1592        assert_eq!(change_label(ChangeKind::Added), "added");
1593        assert_eq!(change_label(ChangeKind::Removed), "removed");
1594        assert_eq!(change_label(ChangeKind::Modified), "modified");
1595        assert_eq!(change_label(ChangeKind::Moved), "moved");
1596        assert_eq!(change_label(ChangeKind::Unchanged), "unchanged");
1597    }
1598
1599    // ── section_change_chars ──────────────────────────────────────
1600
1601    #[test]
1602    fn section_change_chars_sums_every_delta_variant() {
1603        let section = SectionDiff {
1604            heading: "S".to_string(),
1605            path: "/h2#s".to_string(),
1606            change: ChangeKind::Modified,
1607            deltas: vec![
1608                NodeDelta::Paragraph(ParagraphDelta {
1609                    from_text: "a".to_string(), // 1
1610                    to_text: "bc".to_string(),  // 2
1611                    words_added: 1,
1612                    words_removed: 1,
1613                }),
1614                NodeDelta::CodeBlock(CodeBlockDelta {
1615                    language: None,
1616                    from_text: "xx".to_string(), // 2
1617                    to_text: "yyy".to_string(),  // 3
1618                }),
1619                NodeDelta::Added(snapshot_delta("foo")), // 3
1620                NodeDelta::Removed(snapshot_delta("bar")), // 3
1621                NodeDelta::Table(TableDelta {
1622                    cells: vec![CellDelta {
1623                        row: 0,
1624                        col: 0,
1625                        from_text: "12".to_string(), // 2
1626                        to_text: "34".to_string(),   // 2
1627                    }],
1628                }),
1629                NodeDelta::List(ListDelta {
1630                    items_added: vec!["a".to_string()],                         // 1
1631                    items_removed: vec!["bb".to_string()],                      // 2
1632                    items_modified: vec![("xx".to_string(), "yy".to_string())], // 2 + 2
1633                }),
1634                NodeDelta::Opaque(OpaqueDelta {
1635                    node_type: "panel".to_string(),
1636                    from_summary: "p".to_string(), // 1
1637                    to_summary: "qq".to_string(),  // 2
1638                }),
1639            ],
1640        };
1641        assert_eq!(
1642            section_change_chars(&section),
1643            1 + 2 + 2 + 3 + 3 + 3 + 2 + 2 + 1 + 2 + 2 + 2 + 1 + 2
1644        );
1645    }
1646
1647    // ── aggregate_stats ───────────────────────────────────────────
1648
1649    #[test]
1650    fn aggregate_stats_counts_each_change_kind_and_delta() {
1651        let mk_para = || {
1652            NodeDelta::Paragraph(ParagraphDelta {
1653                from_text: "a".to_string(),
1654                to_text: "b".to_string(),
1655                words_added: 2,
1656                words_removed: 1,
1657            })
1658        };
1659        let mk_table = || {
1660            NodeDelta::Table(TableDelta {
1661                cells: vec![cell_delta()],
1662            })
1663        };
1664        let mk_added = || NodeDelta::Added(snapshot_delta("hello world"));
1665        let mk_removed = || NodeDelta::Removed(snapshot_delta("bye now"));
1666
1667        let sections = vec![
1668            SectionDiff {
1669                heading: "A".to_string(),
1670                path: "/h2#a".to_string(),
1671                change: ChangeKind::Added,
1672                deltas: vec![mk_added()],
1673            },
1674            SectionDiff {
1675                heading: "B".to_string(),
1676                path: "/h2#b".to_string(),
1677                change: ChangeKind::Removed,
1678                deltas: vec![mk_removed()],
1679            },
1680            SectionDiff {
1681                heading: "C".to_string(),
1682                path: "/h2#c".to_string(),
1683                change: ChangeKind::Modified,
1684                deltas: vec![mk_para(), mk_table()],
1685            },
1686            SectionDiff {
1687                heading: "D".to_string(),
1688                path: "/h2#d".to_string(),
1689                change: ChangeKind::Moved,
1690                deltas: vec![],
1691            },
1692            SectionDiff {
1693                heading: "E".to_string(),
1694                path: "/h2#e".to_string(),
1695                change: ChangeKind::Unchanged,
1696                deltas: vec![],
1697            },
1698        ];
1699        let stats = aggregate_stats(&sections);
1700        assert_eq!(stats.sections_added, 1);
1701        assert_eq!(stats.sections_removed, 1);
1702        assert_eq!(stats.sections_modified, 1);
1703        assert_eq!(stats.sections_moved, 1);
1704        assert_eq!(stats.paragraphs_modified, 1);
1705        assert_eq!(stats.tables_modified, 1);
1706        assert_eq!(stats.words_added, 2 + 2); // para 2 + added "hello world" 2
1707        assert!(stats.words_removed >= 1);
1708        assert!(stats.chars_added > 0);
1709        assert!(stats.chars_removed > 0);
1710    }
1711
1712    #[test]
1713    fn apply_filter_recomputes_stats() {
1714        let mut diff = Diff {
1715            sections: vec![
1716                SectionDiff {
1717                    heading: "A".to_string(),
1718                    path: "/h2#a".to_string(),
1719                    change: ChangeKind::Modified,
1720                    deltas: vec![NodeDelta::Paragraph(ParagraphDelta {
1721                        from_text: "a".to_string(),
1722                        to_text: "b".to_string(),
1723                        words_added: 1,
1724                        words_removed: 1,
1725                    })],
1726                },
1727                SectionDiff {
1728                    heading: "B".to_string(),
1729                    path: "/h2#b".to_string(),
1730                    change: ChangeKind::Added,
1731                    deltas: vec![NodeDelta::Added(snapshot_delta("new"))],
1732                },
1733            ],
1734            stats: DiffStats {
1735                sections_modified: 1,
1736                sections_added: 1,
1737                paragraphs_modified: 1,
1738                ..DiffStats::default()
1739            },
1740        };
1741        let filter = Filter {
1742            sections: vec!["/h2#a".to_string()],
1743            ..Filter::default()
1744        };
1745        apply_filter(&mut diff, &filter);
1746        assert_eq!(diff.sections.len(), 1);
1747        assert_eq!(diff.stats.sections_added, 0);
1748        assert_eq!(diff.stats.sections_modified, 1);
1749    }
1750
1751    // ── Budget edge cases ─────────────────────────────────────────
1752
1753    #[test]
1754    fn budget_truncates_to_zero_when_no_section_fits() {
1755        let from = doc(vec![h(2, "A"), p(&"alpha ".repeat(100))]);
1756        let to = doc(vec![h(2, "A"), p(&"beta ".repeat(100))]);
1757        let diff = diff_documents(&from, &to, &DiffOptions::default());
1758        let out = render(
1759            diff,
1760            &ctx(),
1761            Detail::Full,
1762            Includes::default(),
1763            &Filter::default(),
1764            64, // way too small for even one section
1765        )
1766        .unwrap();
1767        assert!(out.truncated);
1768        assert!(out.sections.is_empty());
1769        // Continuation should still report the next cursor (the first section).
1770        let cont = out.continuation.as_ref().expect("continuation set");
1771        assert!(cont.next_cursor.is_some());
1772    }
1773
1774    // ── render_section error path ─────────────────────────────────
1775
1776    #[test]
1777    fn render_section_unknown_section_path_errors() {
1778        let diff = diff_with_single_delta(NodeDelta::Added(snapshot_delta("x")));
1779        let cur = Cursor {
1780            page_id: "p".to_string(),
1781            from_v: 1,
1782            to_v: 2,
1783            section_path: "/h2#missing".to_string(),
1784        };
1785        let err = render_section(&diff, &cur, SectionFormat::Unified).unwrap_err();
1786        assert!(err.to_string().contains("Section not found"));
1787    }
1788
1789    // ── Includes / metadata exclusion ─────────────────────────────
1790
1791    #[test]
1792    fn metadata_excluded_omits_versions() {
1793        let from = doc(vec![]);
1794        let to = doc(vec![]);
1795        let diff = diff_documents(&from, &to, &DiffOptions::default());
1796        let inc = Includes {
1797            metadata: false,
1798            ..Includes::default()
1799        };
1800        let out = render(
1801            diff,
1802            &ctx(),
1803            Detail::Outline,
1804            inc,
1805            &Filter::default(),
1806            DEFAULT_OUTPUT_BUDGET,
1807        )
1808        .unwrap();
1809        assert!(out.versions.is_none());
1810    }
1811
1812    // ── Filter stat recomputation: kinds + min_change_chars ───────
1813
1814    #[test]
1815    fn unified_format_emits_unchanged_lines_in_code_block_diff() {
1816        // Mixed code-block edit: keeps `fn one() {}`, adds `fn two() {}`,
1817        // exercising the `ChangeTag::Equal` branch of `push_text_diff`.
1818        let diff = diff_with_single_delta(NodeDelta::CodeBlock(CodeBlockDelta {
1819            language: Some("rust".to_string()),
1820            from_text: "fn one() {}\n".to_string(),
1821            to_text: "fn one() {}\nfn two() {}\n".to_string(),
1822        }));
1823        let out = render_section(&diff, &cursor(), SectionFormat::Unified).unwrap();
1824        // The "  " (Equal) prefix indicates an unchanged line in the
1825        // unified output.
1826        assert!(out.contains("  fn one"), "got: {out}");
1827        assert!(out.contains("+ fn two"));
1828    }
1829
1830    #[test]
1831    fn aggregate_stats_handles_code_list_opaque_no_op_branch() {
1832        // CodeBlock / List / Opaque deltas don't bump any per-block counter
1833        // in `accumulate_stats`. Apply a filter that re-runs `aggregate_stats`
1834        // over a section containing one of each, and verify the no-op arm
1835        // is exercised (chars/words stay zero, sections_modified == 1).
1836        let mut diff = Diff {
1837            sections: vec![SectionDiff {
1838                heading: "S".to_string(),
1839                path: "/h2#s".to_string(),
1840                change: ChangeKind::Modified,
1841                deltas: vec![
1842                    NodeDelta::CodeBlock(CodeBlockDelta {
1843                        language: None,
1844                        from_text: "old".to_string(),
1845                        to_text: "new".to_string(),
1846                    }),
1847                    NodeDelta::List(ListDelta {
1848                        items_added: vec!["x".to_string()],
1849                        items_removed: Vec::new(),
1850                        items_modified: Vec::new(),
1851                    }),
1852                    NodeDelta::Opaque(OpaqueDelta {
1853                        node_type: "panel".to_string(),
1854                        from_summary: "a".to_string(),
1855                        to_summary: "b".to_string(),
1856                    }),
1857                ],
1858            }],
1859            stats: DiffStats::default(),
1860        };
1861        // No filter constraints, but apply_filter recomputes stats.
1862        apply_filter(&mut diff, &Filter::default());
1863        assert_eq!(diff.stats.sections_modified, 1);
1864        assert_eq!(diff.stats.paragraphs_modified, 0);
1865        assert_eq!(diff.stats.tables_modified, 0);
1866        assert_eq!(diff.stats.chars_added, 0);
1867        assert_eq!(diff.stats.chars_removed, 0);
1868    }
1869
1870    #[test]
1871    fn filter_min_chars_recomputes_after_drop() {
1872        let from = doc(vec![h(2, "A"), p("x"), h(2, "B"), p(&"a".repeat(50))]);
1873        let to = doc(vec![h(2, "A"), p("y"), h(2, "B"), p(&"b".repeat(50))]);
1874        let diff = diff_documents(&from, &to, &DiffOptions::default());
1875        let filter = Filter {
1876            min_change_chars: 50,
1877            ..Filter::default()
1878        };
1879        let out = render(
1880            diff,
1881            &ctx(),
1882            Detail::Outline,
1883            Includes::default(),
1884            &filter,
1885            DEFAULT_OUTPUT_BUDGET,
1886        )
1887        .unwrap();
1888        // Only section B (>= 50 chars) survives.
1889        assert_eq!(out.sections.len(), 1);
1890        assert_eq!(out.sections[0].path, "/h2#b");
1891        assert_eq!(out.summary.by_kind.sections_modified, 1);
1892    }
1893}