1use std::path::Path;
2
3use rusqlite::Connection;
4use serde::Serialize;
5use thiserror::Error;
6
7use crate::CrawlError;
8
9#[derive(Debug, Error)]
11pub enum CompareError {
12 #[error("database error: {0}")]
14 Database(#[from] rusqlite::Error),
15
16 #[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#[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#[derive(Debug, Clone, Serialize, PartialEq, Eq)]
39pub enum ChangeKind {
40 Removed,
42 Added,
44 StatusChanged { from: u16, to: u16 },
46 TitleChanged {
48 from: Option<String>,
49 to: Option<String>,
50 },
51 ContentChanged {
53 from: Option<usize>,
54 to: Option<usize>,
55 },
56 SizeChanged {
58 from: Option<usize>,
59 to: Option<usize>,
60 },
61}
62
63#[derive(Debug, Clone, Serialize)]
65pub struct DiffEntry {
66 pub url: String,
68 pub change: ChangeKind,
70}
71
72#[derive(Debug, Clone, Serialize)]
74pub struct CwvChange {
75 pub url: String,
77 pub metric_name: String,
79 pub old_value: Option<f64>,
81 pub new_value: Option<f64>,
83 pub regression: bool,
85}
86
87#[derive(Debug, Clone, Serialize)]
89pub struct CrawlDiff {
90 pub baseline_pages: usize,
92 pub target_pages: usize,
94 pub added: Vec<DiffEntry>,
96 pub removed: Vec<DiffEntry>,
98 pub status_changes: Vec<DiffEntry>,
100 pub title_changes: Vec<DiffEntry>,
102 pub content_changes: Vec<DiffEntry>,
104 pub size_changes: Vec<DiffEntry>,
106 pub cwv_changes: Vec<CwvChange>,
108}
109
110impl CrawlDiff {
111 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 pub fn is_empty(&self) -> bool {
124 self.total_changes() == 0
125 }
126}
127
128pub 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
143pub 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 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 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 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 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
289pub 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
298pub 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
427fn 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
490fn 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
504fn 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), make_page(
573 "p2",
574 "https://example.com/about",
575 301,
576 "About v2",
577 600,
578 2500,
579 ), make_page("p4", "https://example.com/blog", 200, "Blog", 800, 3072), ];
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 assert_eq!(diff.added.len(), 1);
604 assert_eq!(diff.added[0].url, "https://example.com/blog");
605
606 assert_eq!(diff.removed.len(), 1);
608 assert_eq!(diff.removed[0].url, "https://example.com/contact");
609
610 assert_eq!(diff.status_changes.len(), 1);
612 assert_eq!(diff.status_changes[0].url, "https://example.com/about");
613
614 assert_eq!(diff.title_changes.len(), 1);
616 assert_eq!(diff.title_changes[0].url, "https://example.com/about");
617
618 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 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))); assert!(significant_content_change(&Some(1000), &Some(1200))); assert!(significant_content_change(&Some(100), &Some(50))); 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))); assert!(significant_size_change(&Some(1000), &Some(1200))); assert!(significant_size_change(&None, &Some(100)));
752 assert!(!significant_size_change(&None, &None));
753 }
754}