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        }
545    }
546
547    fn create_baseline_db(path: &Path) {
548        let storage = Storage::new(path).unwrap();
549        let crawl_id = storage.start_crawl("https://example.com", None).unwrap();
550        let pages = vec![
551            make_page("p1", "https://example.com/", 200, "Home", 1000, 4096),
552            make_page("p2", "https://example.com/about", 200, "About", 500, 2048),
553            make_page(
554                "p3",
555                "https://example.com/contact",
556                200,
557                "Contact",
558                300,
559                1024,
560            ),
561        ];
562        storage.insert_pages(&crawl_id, &pages).unwrap();
563        storage.finish_crawl(&crawl_id, 3, 0).unwrap();
564    }
565
566    fn create_target_db(path: &Path) {
567        let storage = Storage::new(path).unwrap();
568        let crawl_id = storage.start_crawl("https://example.com", None).unwrap();
569        let pages = vec![
570            make_page("p1", "https://example.com/", 200, "Home", 1000, 4096), // unchanged
571            make_page(
572                "p2",
573                "https://example.com/about",
574                301,
575                "About v2",
576                600,
577                2500,
578            ), // status + title + content changed
579            // p3 removed
580            make_page("p4", "https://example.com/blog", 200, "Blog", 800, 3072), // new page
581        ];
582        storage.insert_pages(&crawl_id, &pages).unwrap();
583        storage.finish_crawl(&crawl_id, 3, 0).unwrap();
584    }
585
586    #[test]
587    fn test_compare_detects_changes() {
588        let dir = tempfile::tempdir().unwrap();
589        let baseline_path = dir.path().join("baseline.db");
590        let target_path = dir.path().join("target.db");
591
592        create_baseline_db(&baseline_path);
593        create_target_db(&target_path);
594
595        let diff = compare_crawls(&baseline_path, &target_path).unwrap();
596
597        assert_eq!(diff.baseline_pages, 3);
598        assert_eq!(diff.target_pages, 3);
599        assert!(!diff.is_empty());
600
601        // p4 is new
602        assert_eq!(diff.added.len(), 1);
603        assert_eq!(diff.added[0].url, "https://example.com/blog");
604
605        // p3 was removed
606        assert_eq!(diff.removed.len(), 1);
607        assert_eq!(diff.removed[0].url, "https://example.com/contact");
608
609        // p2 status changed 200 → 301
610        assert_eq!(diff.status_changes.len(), 1);
611        assert_eq!(diff.status_changes[0].url, "https://example.com/about");
612
613        // p2 title changed
614        assert_eq!(diff.title_changes.len(), 1);
615        assert_eq!(diff.title_changes[0].url, "https://example.com/about");
616
617        // p2 content changed (500 → 600, +20% > threshold)
618        assert_eq!(diff.content_changes.len(), 1);
619        assert_eq!(diff.content_changes[0].url, "https://example.com/about");
620    }
621
622    #[test]
623    fn test_compare_no_changes() {
624        let dir = tempfile::tempdir().unwrap();
625        let path_a = dir.path().join("a.db");
626        let path_b = dir.path().join("b.db");
627
628        // Create identical databases
629        let storage_a = Storage::new(&path_a).unwrap();
630        let crawl_id = storage_a.start_crawl("https://example.com", None).unwrap();
631        let pages = vec![make_page(
632            "p1",
633            "https://example.com/",
634            200,
635            "Home",
636            1000,
637            4096,
638        )];
639        storage_a.insert_pages(&crawl_id, &pages).unwrap();
640        storage_a.finish_crawl(&crawl_id, 1, 0).unwrap();
641
642        let storage_b = Storage::new(&path_b).unwrap();
643        let crawl_id = storage_b.start_crawl("https://example.com", None).unwrap();
644        storage_b.insert_pages(&crawl_id, &pages).unwrap();
645        storage_b.finish_crawl(&crawl_id, 1, 0).unwrap();
646
647        let diff = compare_crawls(&path_a, &path_b).unwrap();
648        assert!(diff.is_empty());
649        assert_eq!(diff.total_changes(), 0);
650    }
651
652    #[test]
653    fn test_diff_to_json() {
654        let diff = CrawlDiff {
655            baseline_pages: 10,
656            target_pages: 12,
657            added: vec![DiffEntry {
658                url: "https://example.com/new".into(),
659                change: ChangeKind::Added,
660            }],
661            removed: vec![],
662            status_changes: vec![],
663            title_changes: vec![],
664            content_changes: vec![],
665            size_changes: vec![],
666            cwv_changes: vec![],
667        };
668
669        let json = diff_to_json(&diff, true).unwrap();
670        assert!(json.contains("baseline_pages"));
671        assert!(json.contains("Added"));
672        assert!(json.contains("https://example.com/new"));
673
674        let compact = diff_to_json(&diff, false).unwrap();
675        assert!(!compact.contains('\n'));
676    }
677
678    #[test]
679    fn test_diff_to_markdown() {
680        let diff = CrawlDiff {
681            baseline_pages: 5,
682            target_pages: 7,
683            added: vec![
684                DiffEntry {
685                    url: "https://example.com/new1".into(),
686                    change: ChangeKind::Added,
687                },
688                DiffEntry {
689                    url: "https://example.com/new2".into(),
690                    change: ChangeKind::Added,
691                },
692            ],
693            removed: vec![DiffEntry {
694                url: "https://example.com/old".into(),
695                change: ChangeKind::Removed,
696            }],
697            status_changes: vec![DiffEntry {
698                url: "https://example.com/changed".into(),
699                change: ChangeKind::StatusChanged { from: 200, to: 404 },
700            }],
701            title_changes: vec![],
702            content_changes: vec![],
703            size_changes: vec![],
704            cwv_changes: vec![],
705        };
706
707        let md = diff_to_markdown(&diff);
708        assert!(md.contains("# Crawl Comparison"));
709        assert!(md.contains("Baseline: **5** pages"));
710        assert!(md.contains("Target: **7** pages"));
711        assert!(md.contains("## Added Pages (2)"));
712        assert!(md.contains("## Removed Pages (1)"));
713        assert!(md.contains("## Status Changes (1)"));
714        assert!(md.contains("200"));
715        assert!(md.contains("404"));
716    }
717
718    #[test]
719    fn test_diff_empty_markdown() {
720        let diff = CrawlDiff {
721            baseline_pages: 3,
722            target_pages: 3,
723            added: vec![],
724            removed: vec![],
725            status_changes: vec![],
726            title_changes: vec![],
727            content_changes: vec![],
728            size_changes: vec![],
729            cwv_changes: vec![],
730        };
731
732        let md = diff_to_markdown(&diff);
733        assert!(md.contains("No changes detected"));
734    }
735
736    #[test]
737    fn test_significant_content_change() {
738        assert!(!significant_content_change(&Some(1000), &Some(1050))); // +5% < 100
739        assert!(significant_content_change(&Some(1000), &Some(1200))); // +20%
740        assert!(significant_content_change(&Some(100), &Some(50))); // -50%
741        assert!(significant_content_change(&None, &Some(100)));
742        assert!(significant_content_change(&Some(100), &None));
743        assert!(!significant_content_change(&None, &None));
744    }
745
746    #[test]
747    fn test_significant_size_change() {
748        assert!(!significant_size_change(&Some(1000), &Some(1050))); // +5%
749        assert!(significant_size_change(&Some(1000), &Some(1200))); // +20%
750        assert!(significant_size_change(&None, &Some(100)));
751        assert!(!significant_size_change(&None, &None));
752    }
753}