Skip to main content

crawlkit_engine/
compare.rs

1use std::path::Path;
2
3use rusqlite::Connection;
4use serde::Serialize;
5use thiserror::Error;
6
7use crate::CrawlError;
8
9/// Errors specific to comparison operations.
10#[derive(Debug, Error)]
11pub enum CompareError {
12    /// SQLite error.
13    #[error("database error: {0}")]
14    Database(#[from] rusqlite::Error),
15
16    /// I/O error.
17    #[error("io error: {0}")]
18    Io(#[from] std::io::Error),
19}
20
21impl From<CompareError> for CrawlError {
22    fn from(e: CompareError) -> Self {
23        CrawlError::Storage(e.to_string())
24    }
25}
26
27/// A single page entry for comparison (keyed by URL).
28#[derive(Debug, Clone, Serialize)]
29struct ComparePage {
30    url: String,
31    status_code: u16,
32    title: Option<String>,
33    word_count: Option<usize>,
34    body_size: Option<usize>,
35}
36
37/// The type of change detected for a page.
38#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
39pub enum ChangeKind {
40    /// Page exists only in the baseline.
41    Removed,
42    /// Page exists only in the target.
43    Added,
44    /// HTTP status code changed.
45    StatusChanged { from: u16, to: u16 },
46    /// Page title changed.
47    TitleChanged {
48        from: Option<String>,
49        to: Option<String>,
50    },
51    /// Word count changed significantly (>10% or >100 words).
52    ContentChanged {
53        from: Option<usize>,
54        to: Option<usize>,
55    },
56    /// Body size changed significantly (>10%).
57    SizeChanged {
58        from: Option<usize>,
59        to: Option<usize>,
60    },
61}
62
63/// A single diff entry.
64#[derive(Debug, Clone, Serialize)]
65pub struct DiffEntry {
66    /// The page URL.
67    pub url: String,
68    /// What changed.
69    pub change: ChangeKind,
70}
71
72/// A Core Web Vitals regression or improvement between crawls.
73#[derive(Debug, Clone, Serialize)]
74pub struct CwvChange {
75    /// The page URL.
76    pub url: String,
77    /// Which metric changed (e.g. "LCP", "CLS", "INP", "FCP", "TTFB").
78    pub metric_name: String,
79    /// Previous value (p75).
80    pub old_value: Option<f64>,
81    /// New value (p75).
82    pub new_value: Option<f64>,
83    /// True if the metric worsened by >10%.
84    pub regression: bool,
85}
86
87/// Result of comparing two crawl databases.
88#[derive(Debug, Clone, Serialize)]
89pub struct CrawlDiff {
90    /// Total pages in the baseline crawl.
91    pub baseline_pages: usize,
92    /// Total pages in the target crawl.
93    pub target_pages: usize,
94    /// Pages added in the target.
95    pub added: Vec<DiffEntry>,
96    /// Pages removed from the baseline.
97    pub removed: Vec<DiffEntry>,
98    /// Pages with status changes.
99    pub status_changes: Vec<DiffEntry>,
100    /// Pages with title changes.
101    pub title_changes: Vec<DiffEntry>,
102    /// Pages with content changes.
103    pub content_changes: Vec<DiffEntry>,
104    /// Pages with size changes.
105    pub size_changes: Vec<DiffEntry>,
106    /// Core Web Vitals regressions/improvements.
107    pub cwv_changes: Vec<CwvChange>,
108}
109
110impl CrawlDiff {
111    /// Total number of changes detected.
112    pub fn total_changes(&self) -> usize {
113        self.added.len()
114            + self.removed.len()
115            + self.status_changes.len()
116            + self.title_changes.len()
117            + self.content_changes.len()
118            + self.size_changes.len()
119            + self.cwv_changes.len()
120    }
121
122    /// True if no changes were detected.
123    pub fn is_empty(&self) -> bool {
124        self.total_changes() == 0
125    }
126}
127
128/// Compare two SQLite crawl databases by their latest crawl.
129///
130/// `baseline_path` and `target_path` are paths to crawlkit SQLite databases.
131/// The function finds the most recent crawl in each database and compares
132/// their pages by URL.
133pub fn compare_crawls(baseline_path: &Path, target_path: &Path) -> Result<CrawlDiff, CompareError> {
134    let baseline_conn = Connection::open(baseline_path)?;
135    let target_conn = Connection::open(target_path)?;
136
137    let baseline_crawl = latest_crawl_id(&baseline_conn)?;
138    let target_crawl = latest_crawl_id(&target_conn)?;
139
140    compare_crawl_ids(&baseline_conn, &baseline_crawl, &target_conn, &target_crawl)
141}
142
143/// Compare two specific crawl IDs within the same or different databases.
144pub fn compare_crawl_ids(
145    baseline_conn: &Connection,
146    baseline_crawl_id: &str,
147    target_conn: &Connection,
148    target_crawl_id: &str,
149) -> Result<CrawlDiff, CompareError> {
150    let baseline_pages = load_pages(baseline_conn, baseline_crawl_id)?;
151    let target_pages = load_pages(target_conn, target_crawl_id)?;
152
153    let baseline_map: std::collections::HashMap<&str, &ComparePage> =
154        baseline_pages.iter().map(|p| (p.url.as_str(), p)).collect();
155    let target_map: std::collections::HashMap<&str, &ComparePage> =
156        target_pages.iter().map(|p| (p.url.as_str(), p)).collect();
157
158    let mut added = Vec::new();
159    let mut removed = Vec::new();
160    let mut status_changes = Vec::new();
161    let mut title_changes = Vec::new();
162    let mut content_changes = Vec::new();
163    let mut size_changes = Vec::new();
164
165    // Pages in target but not baseline → added
166    for url in target_map.keys() {
167        if !baseline_map.contains_key(url) {
168            added.push(DiffEntry {
169                url: url.to_string(),
170                change: ChangeKind::Added,
171            });
172        }
173    }
174
175    // Pages in baseline but not target → removed
176    for url in baseline_map.keys() {
177        if !target_map.contains_key(url) {
178            removed.push(DiffEntry {
179                url: url.to_string(),
180                change: ChangeKind::Removed,
181            });
182        }
183    }
184
185    // Pages in both → check for changes
186    for (url, &baseline_page) in &baseline_map {
187        if let Some(&target_page) = target_map.get(url) {
188            if baseline_page.status_code != target_page.status_code {
189                status_changes.push(DiffEntry {
190                    url: url.to_string(),
191                    change: ChangeKind::StatusChanged {
192                        from: baseline_page.status_code,
193                        to: target_page.status_code,
194                    },
195                });
196            }
197
198            if baseline_page.title != target_page.title {
199                title_changes.push(DiffEntry {
200                    url: url.to_string(),
201                    change: ChangeKind::TitleChanged {
202                        from: baseline_page.title.clone(),
203                        to: target_page.title.clone(),
204                    },
205                });
206            }
207
208            if significant_content_change(&baseline_page.word_count, &target_page.word_count) {
209                content_changes.push(DiffEntry {
210                    url: url.to_string(),
211                    change: ChangeKind::ContentChanged {
212                        from: baseline_page.word_count,
213                        to: target_page.word_count,
214                    },
215                });
216            }
217
218            if significant_size_change(&baseline_page.body_size, &target_page.body_size) {
219                size_changes.push(DiffEntry {
220                    url: url.to_string(),
221                    change: ChangeKind::SizeChanged {
222                        from: baseline_page.body_size,
223                        to: target_page.body_size,
224                    },
225                });
226            }
227        }
228    }
229
230    // Compare CrUX metrics for URLs present in both crawls
231    let baseline_crux = load_crux(baseline_conn, baseline_crawl_id)?;
232    let target_crux = load_crux(target_conn, target_crawl_id)?;
233
234    let baseline_crux_map: std::collections::HashMap<&str, &CompareCrux> =
235        baseline_crux.iter().map(|c| (c.url.as_str(), c)).collect();
236    let target_crux_map: std::collections::HashMap<&str, &CompareCrux> =
237        target_crux.iter().map(|c| (c.url.as_str(), c)).collect();
238
239    let mut cwv_changes = Vec::new();
240
241    for (url, &b) in &baseline_crux_map {
242        if let Some(&t) = target_crux_map.get(url) {
243            check_cwv_regression(url, "LCP", b.lcp_p75, t.lcp_p75, &mut cwv_changes);
244            check_cwv_regression(url, "CLS", b.cls_p75, t.cls_p75, &mut cwv_changes);
245            check_cwv_regression(url, "INP", b.inp_p75, t.inp_p75, &mut cwv_changes);
246            check_cwv_regression(url, "FCP", b.fcp_p75, t.fcp_p75, &mut cwv_changes);
247            check_cwv_regression(url, "TTFB", b.ttfb_p75, t.ttfb_p75, &mut cwv_changes);
248        }
249    }
250
251    Ok(CrawlDiff {
252        baseline_pages: baseline_pages.len(),
253        target_pages: target_pages.len(),
254        added,
255        removed,
256        status_changes,
257        title_changes,
258        content_changes,
259        size_changes,
260        cwv_changes,
261    })
262}
263
264fn check_cwv_regression(
265    url: &str,
266    metric_name: &str,
267    old: Option<f64>,
268    new: Option<f64>,
269    out: &mut Vec<CwvChange>,
270) {
271    if let (Some(o), Some(n)) = (old, new) {
272        let pct = if o.abs() > f64::EPSILON {
273            (n - o) / o.abs()
274        } else {
275            return;
276        };
277        if pct > 0.10 {
278            out.push(CwvChange {
279                url: url.to_string(),
280                metric_name: metric_name.to_string(),
281                old_value: Some(o),
282                new_value: Some(n),
283                regression: true,
284            });
285        }
286    }
287}
288
289/// Write a diff to JSON.
290pub fn diff_to_json(diff: &CrawlDiff, pretty: bool) -> Result<String, serde_json::Error> {
291    if pretty {
292        serde_json::to_string_pretty(diff)
293    } else {
294        serde_json::to_string(diff)
295    }
296}
297
298/// Write a diff to Markdown.
299pub fn diff_to_markdown(diff: &CrawlDiff) -> String {
300    let mut md = String::new();
301
302    md.push_str("# Crawl Comparison\n\n");
303    md.push_str(&format!(
304        "Baseline: **{}** pages  \nTarget: **{}** pages\n\n",
305        diff.baseline_pages, diff.target_pages
306    ));
307    md.push_str(&format!("**Total changes: {}**\n\n", diff.total_changes()));
308
309    if !diff.added.is_empty() {
310        md.push_str(&format!("## Added Pages ({})\n\n", diff.added.len()));
311        for e in &diff.added {
312            md.push_str(&format!("- `{}`\n", e.url));
313        }
314        md.push('\n');
315    }
316
317    if !diff.removed.is_empty() {
318        md.push_str(&format!("## Removed Pages ({})\n\n", diff.removed.len()));
319        for e in &diff.removed {
320            md.push_str(&format!("- `{}`\n", e.url));
321        }
322        md.push('\n');
323    }
324
325    if !diff.status_changes.is_empty() {
326        md.push_str(&format!(
327            "## Status Changes ({})\n\n",
328            diff.status_changes.len()
329        ));
330        md.push_str("| URL | From | To |\n|---|---|---|\n");
331        for e in &diff.status_changes {
332            if let ChangeKind::StatusChanged { from, to } = &e.change {
333                md.push_str(&format!("| {} | {from} | {to} |\n", e.url));
334            }
335        }
336        md.push('\n');
337    }
338
339    if !diff.title_changes.is_empty() {
340        md.push_str(&format!(
341            "## Title Changes ({})\n\n",
342            diff.title_changes.len()
343        ));
344        md.push_str("| URL | Old Title | New Title |\n|---|---|---|\n");
345        for e in &diff.title_changes {
346            if let ChangeKind::TitleChanged { from, to } = &e.change {
347                md.push_str(&format!(
348                    "| {} | {} | {} |\n",
349                    e.url,
350                    from.as_deref().unwrap_or("—"),
351                    to.as_deref().unwrap_or("—")
352                ));
353            }
354        }
355        md.push('\n');
356    }
357
358    if !diff.content_changes.is_empty() {
359        md.push_str(&format!(
360            "## Content Changes ({})\n\n",
361            diff.content_changes.len()
362        ));
363        for e in &diff.content_changes {
364            if let ChangeKind::ContentChanged { from, to } = &e.change {
365                md.push_str(&format!(
366                    "- `{}`: {} → {} words\n",
367                    e.url,
368                    from.map(|v| v.to_string()).unwrap_or_else(|| "—".into()),
369                    to.map(|v| v.to_string()).unwrap_or_else(|| "—".into()),
370                ));
371            }
372        }
373        md.push('\n');
374    }
375
376    if !diff.size_changes.is_empty() {
377        md.push_str(&format!(
378            "## Size Changes ({})\n\n",
379            diff.size_changes.len()
380        ));
381        for e in &diff.size_changes {
382            if let ChangeKind::SizeChanged { from, to } = &e.change {
383                md.push_str(&format!(
384                    "- `{}`: {} → {} bytes\n",
385                    e.url,
386                    from.map(|v| v.to_string()).unwrap_or_else(|| "—".into()),
387                    to.map(|v| v.to_string()).unwrap_or_else(|| "—".into()),
388                ));
389            }
390        }
391        md.push('\n');
392    }
393
394    if !diff.cwv_changes.is_empty() {
395        md.push_str(&format!(
396            "## Core Web Vitals Changes ({})\n\n",
397            diff.cwv_changes.len()
398        ));
399        md.push_str(
400            "| URL | Metric | Old (p75) | New (p75) | Regression |\n|---|---|---|---|---|\n",
401        );
402        for c in &diff.cwv_changes {
403            let old_str = c
404                .old_value
405                .map(|v| format!("{v:.2}"))
406                .unwrap_or_else(|| "—".into());
407            let new_str = c
408                .new_value
409                .map(|v| format!("{v:.2}"))
410                .unwrap_or_else(|| "—".into());
411            let regression = if c.regression { "YES" } else { "no" };
412            md.push_str(&format!(
413                "| {} | {} | {} | {} | {} |\n",
414                c.url, c.metric_name, old_str, new_str, regression,
415            ));
416        }
417        md.push('\n');
418    }
419
420    if diff.is_empty() {
421        md.push_str("No changes detected.\n");
422    }
423
424    md
425}
426
427// ---------------------------------------------------------------------------
428// Internal helpers
429// ---------------------------------------------------------------------------
430
431fn latest_crawl_id(conn: &Connection) -> Result<String, CompareError> {
432    conn.query_row(
433        "SELECT id FROM crawls ORDER BY start_time DESC LIMIT 1",
434        [],
435        |row| row.get::<_, String>(0),
436    )
437    .map_err(CompareError::from)
438}
439
440fn load_pages(conn: &Connection, crawl_id: &str) -> Result<Vec<ComparePage>, CompareError> {
441    let mut stmt = conn.prepare(
442        "SELECT url, status_code, title, word_count, body_size FROM pages WHERE crawl_id = ?1",
443    )?;
444    let pages = stmt
445        .query_map([crawl_id], |row| {
446            Ok(ComparePage {
447                url: row.get(0)?,
448                status_code: row.get(1)?,
449                title: row.get(2)?,
450                word_count: row.get::<_, Option<i64>>(3)?.map(|v| v as usize),
451                body_size: row.get::<_, Option<i64>>(4)?.map(|v| v as usize),
452            })
453        })?
454        .collect::<Result<Vec<_>, _>>()?;
455    Ok(pages)
456}
457
458#[derive(Debug, Clone)]
459struct CompareCrux {
460    url: String,
461    lcp_p75: Option<f64>,
462    inp_p75: Option<f64>,
463    cls_p75: Option<f64>,
464    fcp_p75: Option<f64>,
465    ttfb_p75: Option<f64>,
466}
467
468fn load_crux(conn: &Connection, crawl_id: &str) -> Result<Vec<CompareCrux>, CompareError> {
469    let mut stmt = conn.prepare(
470        "SELECT cm.url, cm.lcp_p75, cm.inp_p75, cm.cls_p75, cm.fcp_p75, cm.ttfb_p75
471         FROM crux_metrics cm
472         JOIN pages p ON cm.page_id = p.id
473         WHERE p.crawl_id = ?1",
474    )?;
475    let rows = stmt
476        .query_map([crawl_id], |row| {
477            Ok(CompareCrux {
478                url: row.get(0)?,
479                lcp_p75: row.get(1)?,
480                inp_p75: row.get(2)?,
481                cls_p75: row.get(3)?,
482                fcp_p75: row.get(4)?,
483                ttfb_p75: row.get(5)?,
484            })
485        })?
486        .collect::<Result<Vec<_>, _>>()?;
487    Ok(rows)
488}
489
490/// A content change is significant if word count differs by >10% or >100 words.
491fn significant_content_change(from: &Option<usize>, to: &Option<usize>) -> bool {
492    match (from, to) {
493        (None, Some(_)) | (Some(_), None) => true,
494        (Some(f), Some(t)) => {
495            let diff = (*f as isize - *t as isize).unsigned_abs();
496            let threshold_pct = (*f as f64 * 0.10) as usize;
497            let threshold_abs = 100;
498            diff > threshold_pct || diff > threshold_abs
499        }
500        (None, None) => false,
501    }
502}
503
504/// A size change is significant if it differs by >10%.
505fn significant_size_change(from: &Option<usize>, to: &Option<usize>) -> bool {
506    match (from, to) {
507        (None, Some(_)) | (Some(_), None) => true,
508        (Some(f), Some(t)) => {
509            let diff = (*f as isize - *t as isize).unsigned_abs();
510            diff > (*f as f64 * 0.10) as usize
511        }
512        (None, None) => false,
513    }
514}
515
516#[cfg(test)]
517mod tests {
518    use super::*;
519    use crate::storage::{PageData, Storage};
520    use chrono::Utc;
521    use url::Url;
522
523    fn make_page(
524        id: &str,
525        url: &str,
526        status: u16,
527        title: &str,
528        words: usize,
529        size: usize,
530    ) -> PageData {
531        PageData {
532            id: id.into(),
533            url: Url::parse(url).unwrap(),
534            final_url: Url::parse(url).unwrap(),
535            status_code: status,
536            title: Some(title.into()),
537            description: None,
538            canonical_url: None,
539            word_count: Some(words),
540            load_time_ms: Some(200),
541            body_size: Some(size),
542            fetched_at: Utc::now(),
543            links: vec![],
544            tenant_id: None,
545        }
546    }
547
548    fn create_baseline_db(path: &Path) {
549        let storage = Storage::new(path).unwrap();
550        let crawl_id = storage.start_crawl("https://example.com", None).unwrap();
551        let pages = vec![
552            make_page("p1", "https://example.com/", 200, "Home", 1000, 4096),
553            make_page("p2", "https://example.com/about", 200, "About", 500, 2048),
554            make_page(
555                "p3",
556                "https://example.com/contact",
557                200,
558                "Contact",
559                300,
560                1024,
561            ),
562        ];
563        storage.insert_pages(&crawl_id, &pages).unwrap();
564        storage.finish_crawl(&crawl_id, 3, 0).unwrap();
565    }
566
567    fn create_target_db(path: &Path) {
568        let storage = Storage::new(path).unwrap();
569        let crawl_id = storage.start_crawl("https://example.com", None).unwrap();
570        let pages = vec![
571            make_page("p1", "https://example.com/", 200, "Home", 1000, 4096), // unchanged
572            make_page(
573                "p2",
574                "https://example.com/about",
575                301,
576                "About v2",
577                600,
578                2500,
579            ), // status + title + content changed
580            // p3 removed
581            make_page("p4", "https://example.com/blog", 200, "Blog", 800, 3072), // new page
582        ];
583        storage.insert_pages(&crawl_id, &pages).unwrap();
584        storage.finish_crawl(&crawl_id, 3, 0).unwrap();
585    }
586
587    #[test]
588    fn test_compare_detects_changes() {
589        let dir = tempfile::tempdir().unwrap();
590        let baseline_path = dir.path().join("baseline.db");
591        let target_path = dir.path().join("target.db");
592
593        create_baseline_db(&baseline_path);
594        create_target_db(&target_path);
595
596        let diff = compare_crawls(&baseline_path, &target_path).unwrap();
597
598        assert_eq!(diff.baseline_pages, 3);
599        assert_eq!(diff.target_pages, 3);
600        assert!(!diff.is_empty());
601
602        // p4 is new
603        assert_eq!(diff.added.len(), 1);
604        assert_eq!(diff.added[0].url, "https://example.com/blog");
605
606        // p3 was removed
607        assert_eq!(diff.removed.len(), 1);
608        assert_eq!(diff.removed[0].url, "https://example.com/contact");
609
610        // p2 status changed 200 → 301
611        assert_eq!(diff.status_changes.len(), 1);
612        assert_eq!(diff.status_changes[0].url, "https://example.com/about");
613
614        // p2 title changed
615        assert_eq!(diff.title_changes.len(), 1);
616        assert_eq!(diff.title_changes[0].url, "https://example.com/about");
617
618        // p2 content changed (500 → 600, +20% > threshold)
619        assert_eq!(diff.content_changes.len(), 1);
620        assert_eq!(diff.content_changes[0].url, "https://example.com/about");
621    }
622
623    #[test]
624    fn test_compare_no_changes() {
625        let dir = tempfile::tempdir().unwrap();
626        let path_a = dir.path().join("a.db");
627        let path_b = dir.path().join("b.db");
628
629        // Create identical databases
630        let storage_a = Storage::new(&path_a).unwrap();
631        let crawl_id = storage_a.start_crawl("https://example.com", None).unwrap();
632        let pages = vec![make_page(
633            "p1",
634            "https://example.com/",
635            200,
636            "Home",
637            1000,
638            4096,
639        )];
640        storage_a.insert_pages(&crawl_id, &pages).unwrap();
641        storage_a.finish_crawl(&crawl_id, 1, 0).unwrap();
642
643        let storage_b = Storage::new(&path_b).unwrap();
644        let crawl_id = storage_b.start_crawl("https://example.com", None).unwrap();
645        storage_b.insert_pages(&crawl_id, &pages).unwrap();
646        storage_b.finish_crawl(&crawl_id, 1, 0).unwrap();
647
648        let diff = compare_crawls(&path_a, &path_b).unwrap();
649        assert!(diff.is_empty());
650        assert_eq!(diff.total_changes(), 0);
651    }
652
653    #[test]
654    fn test_diff_to_json() {
655        let diff = CrawlDiff {
656            baseline_pages: 10,
657            target_pages: 12,
658            added: vec![DiffEntry {
659                url: "https://example.com/new".into(),
660                change: ChangeKind::Added,
661            }],
662            removed: vec![],
663            status_changes: vec![],
664            title_changes: vec![],
665            content_changes: vec![],
666            size_changes: vec![],
667            cwv_changes: vec![],
668        };
669
670        let json = diff_to_json(&diff, true).unwrap();
671        assert!(json.contains("baseline_pages"));
672        assert!(json.contains("Added"));
673        assert!(json.contains("https://example.com/new"));
674
675        let compact = diff_to_json(&diff, false).unwrap();
676        assert!(!compact.contains('\n'));
677    }
678
679    #[test]
680    fn test_diff_to_markdown() {
681        let diff = CrawlDiff {
682            baseline_pages: 5,
683            target_pages: 7,
684            added: vec![
685                DiffEntry {
686                    url: "https://example.com/new1".into(),
687                    change: ChangeKind::Added,
688                },
689                DiffEntry {
690                    url: "https://example.com/new2".into(),
691                    change: ChangeKind::Added,
692                },
693            ],
694            removed: vec![DiffEntry {
695                url: "https://example.com/old".into(),
696                change: ChangeKind::Removed,
697            }],
698            status_changes: vec![DiffEntry {
699                url: "https://example.com/changed".into(),
700                change: ChangeKind::StatusChanged { from: 200, to: 404 },
701            }],
702            title_changes: vec![],
703            content_changes: vec![],
704            size_changes: vec![],
705            cwv_changes: vec![],
706        };
707
708        let md = diff_to_markdown(&diff);
709        assert!(md.contains("# Crawl Comparison"));
710        assert!(md.contains("Baseline: **5** pages"));
711        assert!(md.contains("Target: **7** pages"));
712        assert!(md.contains("## Added Pages (2)"));
713        assert!(md.contains("## Removed Pages (1)"));
714        assert!(md.contains("## Status Changes (1)"));
715        assert!(md.contains("200"));
716        assert!(md.contains("404"));
717    }
718
719    #[test]
720    fn test_diff_empty_markdown() {
721        let diff = CrawlDiff {
722            baseline_pages: 3,
723            target_pages: 3,
724            added: vec![],
725            removed: vec![],
726            status_changes: vec![],
727            title_changes: vec![],
728            content_changes: vec![],
729            size_changes: vec![],
730            cwv_changes: vec![],
731        };
732
733        let md = diff_to_markdown(&diff);
734        assert!(md.contains("No changes detected"));
735    }
736
737    #[test]
738    fn test_significant_content_change() {
739        assert!(!significant_content_change(&Some(1000), &Some(1050))); // +5% < 100
740        assert!(significant_content_change(&Some(1000), &Some(1200))); // +20%
741        assert!(significant_content_change(&Some(100), &Some(50))); // -50%
742        assert!(significant_content_change(&None, &Some(100)));
743        assert!(significant_content_change(&Some(100), &None));
744        assert!(!significant_content_change(&None, &None));
745    }
746
747    #[test]
748    fn test_significant_size_change() {
749        assert!(!significant_size_change(&Some(1000), &Some(1050))); // +5%
750        assert!(significant_size_change(&Some(1000), &Some(1200))); // +20%
751        assert!(significant_size_change(&None, &Some(100)));
752        assert!(!significant_size_change(&None, &None));
753    }
754}