1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
24pub enum Detail {
25 Summary,
27 #[default]
29 Outline,
30 Full,
32}
33
34#[derive(Debug, Clone, Copy)]
36pub struct Includes {
37 pub body: bool,
39 pub title: bool,
41 pub labels: bool,
43 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#[derive(Debug, Clone, Default)]
61pub struct Filter {
62 pub sections: Vec<String>,
65 pub min_change_chars: u32,
68 pub kinds: Vec<ChangeKind>,
71}
72
73#[derive(Debug, Clone, Serialize)]
77pub struct CompareOutput {
78 pub page: PageHeader,
80 #[serde(skip_serializing_if = "Option::is_none")]
82 pub versions: Option<VersionPair>,
83 pub summary: SummaryBlock,
85 #[serde(skip_serializing_if = "Option::is_none")]
88 pub title_change: Option<TitleChange>,
89 #[serde(skip_serializing_if = "Option::is_none")]
91 pub labels: Option<LabelChange>,
92 #[serde(skip_serializing_if = "Vec::is_empty")]
94 pub sections: Vec<SectionRecord>,
95 pub truncated: bool,
97 #[serde(skip_serializing_if = "Option::is_none")]
99 pub continuation: Option<Continuation>,
100}
101
102#[derive(Debug, Clone, Serialize)]
104pub struct PageHeader {
105 pub id: String,
107 pub title: String,
109 #[serde(skip_serializing_if = "Option::is_none")]
111 pub url: Option<String>,
112}
113
114#[derive(Debug, Clone, Serialize)]
116pub struct VersionPair {
117 pub from: VersionInfo,
119 pub to: VersionInfo,
121}
122
123#[derive(Debug, Clone, Serialize, Default)]
125pub struct VersionInfo {
126 pub number: u32,
128 #[serde(skip_serializing_if = "String::is_empty")]
130 pub created_at: String,
131 #[serde(skip_serializing_if = "String::is_empty")]
133 pub author: String,
134 #[serde(skip_serializing_if = "String::is_empty")]
136 pub message: String,
137}
138
139#[derive(Debug, Clone, Serialize)]
141pub struct SummaryBlock {
142 pub total_changes: u32,
144 pub by_kind: ByKind,
146 pub net: NetCounts,
148}
149
150#[derive(Debug, Clone, Default, Serialize)]
152pub struct ByKind {
153 pub sections_added: u32,
155 pub sections_removed: u32,
157 pub sections_modified: u32,
159 pub sections_moved: u32,
161 pub paragraphs_modified: u32,
163 pub tables_modified: u32,
165}
166
167#[derive(Debug, Clone, Default, Serialize)]
169pub struct NetCounts {
170 pub chars_added: u32,
172 pub chars_removed: u32,
174 pub words_added: u32,
176 pub words_removed: u32,
178}
179
180#[derive(Debug, Clone, Serialize)]
182pub struct TitleChange {
183 pub from: String,
185 pub to: String,
187}
188
189#[derive(Debug, Clone, Default, Serialize)]
191pub struct LabelChange {
192 #[serde(skip_serializing_if = "Vec::is_empty")]
194 pub added: Vec<String>,
195 #[serde(skip_serializing_if = "Vec::is_empty")]
197 pub removed: Vec<String>,
198}
199
200#[derive(Debug, Clone, Serialize)]
202pub struct SectionRecord {
203 pub heading: String,
205 pub path: String,
207 pub change: ChangeKind,
209 pub summary: String,
211 pub cursor: String,
213 #[serde(skip_serializing_if = "Vec::is_empty")]
215 pub diff: Vec<NodeDelta>,
216}
217
218#[derive(Debug, Clone, Default, Serialize)]
220pub struct Continuation {
221 #[serde(skip_serializing_if = "Option::is_none")]
223 pub next_cursor: Option<String>,
224}
225
226#[derive(Debug, Clone, Serialize, Deserialize)]
230pub struct Cursor {
231 pub page_id: String,
233 pub from_v: u32,
235 pub to_v: u32,
237 pub section_path: String,
239}
240
241impl Cursor {
242 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 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#[derive(Debug, Clone, Default)]
262pub struct CompareContext {
263 pub page_id: String,
265 pub page_title: String,
267 pub page_url: Option<String>,
269 pub from_version: VersionInfo,
271 pub to_version: VersionInfo,
273 pub from_title: String,
275 pub to_title: String,
277 pub from_labels: Vec<String>,
279 pub to_labels: Vec<String>,
281}
282
283pub const DEFAULT_OUTPUT_BUDGET: usize = 16 * 1024;
286
287pub 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, §ions, 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
335pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
357pub enum SectionFormat {
358 #[default]
360 Unified,
361 SideBySide,
363 MarkdownInline,
365}
366
367pub 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 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
472fn 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
571fn 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
651fn trim_to_budget(
654 diff: &Diff,
655 ctx: &CompareContext,
656 sections: &[SectionRecord],
657 include: Includes,
658 budget_bytes: usize,
659) -> Result<(usize, usize)> {
660 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 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
695fn 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 §ion.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 §ion.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 §ion.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#[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 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 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, )
1187 .unwrap();
1188 assert!(out.truncated);
1189 let cont = out.continuation.as_ref().expect("continuation present");
1190 assert!(cont.next_cursor.is_some());
1191 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 let s = "ééééé"; assert_eq!(truncate(s, 5), s);
1308 }
1309
1310 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 #[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(§ion);
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(§ion), "added");
1549 }
1550
1551 #[test]
1552 fn summarize_delta_covers_every_variant() {
1553 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 #[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(), to_text: "bc".to_string(), words_added: 1,
1612 words_removed: 1,
1613 }),
1614 NodeDelta::CodeBlock(CodeBlockDelta {
1615 language: None,
1616 from_text: "xx".to_string(), to_text: "yyy".to_string(), }),
1619 NodeDelta::Added(snapshot_delta("foo")), NodeDelta::Removed(snapshot_delta("bar")), NodeDelta::Table(TableDelta {
1622 cells: vec![CellDelta {
1623 row: 0,
1624 col: 0,
1625 from_text: "12".to_string(), to_text: "34".to_string(), }],
1628 }),
1629 NodeDelta::List(ListDelta {
1630 items_added: vec!["a".to_string()], items_removed: vec!["bb".to_string()], items_modified: vec![("xx".to_string(), "yy".to_string())], }),
1634 NodeDelta::Opaque(OpaqueDelta {
1635 node_type: "panel".to_string(),
1636 from_summary: "p".to_string(), to_summary: "qq".to_string(), }),
1639 ],
1640 };
1641 assert_eq!(
1642 section_change_chars(§ion),
1643 1 + 2 + 2 + 3 + 3 + 3 + 2 + 2 + 1 + 2 + 2 + 2 + 1 + 2
1644 );
1645 }
1646
1647 #[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(§ions);
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); 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 #[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, )
1766 .unwrap();
1767 assert!(out.truncated);
1768 assert!(out.sections.is_empty());
1769 let cont = out.continuation.as_ref().expect("continuation set");
1771 assert!(cont.next_cursor.is_some());
1772 }
1773
1774 #[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 #[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 #[test]
1815 fn unified_format_emits_unchanged_lines_in_code_block_diff() {
1816 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 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 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 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 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}