1use rusqlite::Connection;
2use serde::Serialize;
3use thiserror::Error;
4
5use crate::storage::{CrawlStats, Storage, StorageError};
6use crate::CrawlError;
7
8#[derive(Debug, Error)]
10pub enum ExportError {
11 #[error("io error: {0}")]
13 Io(#[from] std::io::Error),
14
15 #[error("csv error: {0}")]
17 Csv(#[from] csv::Error),
18
19 #[error("json error: {0}")]
21 Json(#[from] serde_json::Error),
22
23 #[error("database error: {0}")]
25 Database(#[from] rusqlite::Error),
26
27 #[error("storage error: {0}")]
29 Storage(#[from] StorageError),
30}
31
32impl From<ExportError> for CrawlError {
33 fn from(e: ExportError) -> Self {
34 CrawlError::Storage(e.to_string())
35 }
36}
37
38#[derive(Debug, Clone, Default)]
44pub struct CsvColumnSelector {
45 pub url: bool,
47 pub status_code: bool,
49 pub title: bool,
51 pub description: bool,
53 pub canonical: bool,
55 pub word_count: bool,
57 pub load_time_ms: bool,
59 pub body_size: bool,
61 pub fetched_at: bool,
63 pub issue_count: bool,
65 pub issues_json: bool,
67 pub links_json: bool,
69}
70
71impl CsvColumnSelector {
72 pub fn all() -> Self {
74 Self {
75 url: true,
76 status_code: true,
77 title: true,
78 description: true,
79 canonical: true,
80 word_count: true,
81 load_time_ms: true,
82 body_size: true,
83 fetched_at: true,
84 issue_count: true,
85 issues_json: true,
86 links_json: true,
87 }
88 }
89
90 pub fn headers(&self) -> Vec<&'static str> {
92 let mut h = Vec::new();
93 if self.url {
94 h.push("url");
95 }
96 if self.status_code {
97 h.push("status_code");
98 }
99 if self.title {
100 h.push("title");
101 }
102 if self.description {
103 h.push("description");
104 }
105 if self.canonical {
106 h.push("canonical");
107 }
108 if self.word_count {
109 h.push("word_count");
110 }
111 if self.load_time_ms {
112 h.push("load_time_ms");
113 }
114 if self.body_size {
115 h.push("body_size");
116 }
117 if self.fetched_at {
118 h.push("fetched_at");
119 }
120 if self.issue_count {
121 h.push("issue_count");
122 }
123 if self.issues_json {
124 h.push("issues");
125 }
126 if self.links_json {
127 h.push("links");
128 }
129 h
130 }
131}
132
133pub fn export_csv(
137 conn: &Connection,
138 crawl_id: &str,
139 selector: &CsvColumnSelector,
140) -> Result<Vec<u8>, ExportError> {
141 let mut wtr = csv::Writer::from_writer(Vec::new());
142 wtr.write_record(selector.headers())?;
143
144 let pages = read_pages(conn, crawl_id)?;
145 for page in &pages {
146 let issues = read_issues_for_page(conn, &page.id)?;
147 let links = read_links_for_page(conn, &page.id)?;
148 let issue_count = issues.len();
149
150 let mut record: Vec<String> = Vec::new();
151 if selector.url {
152 record.push(page.url.clone());
153 }
154 if selector.status_code {
155 record.push(page.status_code.to_string());
156 }
157 if selector.title {
158 record.push(page.title.clone().unwrap_or_default());
159 }
160 if selector.description {
161 record.push(page.description.clone().unwrap_or_default());
162 }
163 if selector.canonical {
164 record.push(page.canonical.clone().unwrap_or_default());
165 }
166 if selector.word_count {
167 record.push(page.word_count.map(|v| v.to_string()).unwrap_or_default());
168 }
169 if selector.load_time_ms {
170 record.push(page.load_time_ms.map(|v| v.to_string()).unwrap_or_default());
171 }
172 if selector.body_size {
173 record.push(page.body_size.map(|v| v.to_string()).unwrap_or_default());
174 }
175 if selector.fetched_at {
176 record.push(page.fetched_at.clone());
177 }
178 if selector.issue_count {
179 record.push(issue_count.to_string());
180 }
181 if selector.issues_json {
182 record.push(serde_json::to_string(&issues).unwrap_or_default());
183 }
184 if selector.links_json {
185 record.push(serde_json::to_string(&links).unwrap_or_default());
186 }
187 wtr.write_record(&record)?;
188 }
189
190 wtr.flush()?;
191 let bytes = wtr
192 .into_inner()
193 .map_err(|e| ExportError::Io(std::io::Error::other(e.to_string())))?;
194 Ok(bytes)
195}
196
197pub const JSON_SCHEMA_VERSION: &str = "1.0";
203
204#[derive(Serialize)]
206pub struct JsonExport {
207 pub schema_version: String,
209 pub crawl: JsonCrawlMeta,
211 pub pages: Vec<JsonPage>,
213 pub stats: CrawlStats,
215}
216
217#[derive(Serialize)]
218pub struct JsonCrawlMeta {
219 pub id: String,
220 pub target_url: String,
221 pub start_time: Option<String>,
222 pub end_time: Option<String>,
223 pub pages_crawled: usize,
224 pub total_issues: usize,
225}
226
227#[derive(Serialize)]
228pub struct JsonPage {
229 pub id: String,
230 pub url: String,
231 pub final_url: String,
232 pub status_code: u16,
233 pub title: Option<String>,
234 pub description: Option<String>,
235 pub canonical: Option<String>,
236 pub word_count: Option<usize>,
237 pub load_time_ms: Option<u64>,
238 pub body_size: Option<usize>,
239 pub fetched_at: String,
240 #[serde(skip_serializing_if = "Vec::is_empty")]
241 pub issues: Vec<JsonIssue>,
242 #[serde(skip_serializing_if = "Vec::is_empty")]
243 pub links: Vec<String>,
244}
245
246#[derive(Serialize)]
247pub struct JsonIssue {
248 pub id: String,
249 pub category: String,
250 pub severity: String,
251 pub code: String,
252 pub title: String,
253 pub description: String,
254 pub element: Option<String>,
255 pub recommendation: String,
256}
257
258pub fn export_json(storage: &Storage, crawl_id: &str, pretty: bool) -> Result<String, ExportError> {
260 let stats = storage.get_stats(crawl_id)?;
261 let conn = storage.conn();
262 let meta = read_crawl_meta(&conn, crawl_id)?;
263 let pages = read_pages(&conn, crawl_id)?;
264
265 let json_pages: Vec<JsonPage> = pages
266 .iter()
267 .map(|p| {
268 let issues = read_issues_for_page(&conn, &p.id)
269 .unwrap_or_default()
270 .into_iter()
271 .map(|i| JsonIssue {
272 id: i.id,
273 category: i.category,
274 severity: i.severity,
275 code: i.code,
276 title: i.title,
277 description: i.description,
278 element: i.element,
279 recommendation: i.recommendation,
280 })
281 .collect();
282 let links = read_links_for_page(&conn, &p.id).unwrap_or_default();
283 JsonPage {
284 id: p.id.clone(),
285 url: p.url.clone(),
286 final_url: p.final_url.clone(),
287 status_code: p.status_code,
288 title: p.title.clone(),
289 description: p.description.clone(),
290 canonical: p.canonical.clone(),
291 word_count: p.word_count,
292 load_time_ms: p.load_time_ms,
293 body_size: p.body_size,
294 fetched_at: p.fetched_at.clone(),
295 issues,
296 links,
297 }
298 })
299 .collect();
300
301 let export = JsonExport {
302 schema_version: JSON_SCHEMA_VERSION.to_string(),
303 crawl: meta,
304 pages: json_pages,
305 stats,
306 };
307
308 if pretty {
309 Ok(serde_json::to_string_pretty(&export)?)
310 } else {
311 Ok(serde_json::to_string(&export)?)
312 }
313}
314
315pub fn export_markdown(storage: &Storage, crawl_id: &str) -> Result<String, ExportError> {
321 let stats = storage.get_stats(crawl_id)?;
322 let conn = storage.conn();
323 let meta = read_crawl_meta(&conn, crawl_id)?;
324 let top_issues = read_top_issues(&conn, crawl_id, 10)?;
325
326 let mut md = String::new();
327
328 md.push_str(&format!("# Crawl Report — `{}`\n\n", meta.target_url));
329 md.push_str(&format!("**Crawl ID:** `{}` \n", crawl_id));
330 if let Some(ref start) = meta.start_time {
331 md.push_str(&format!("**Started:** {start} \n"));
332 }
333 if let Some(ref end) = meta.end_time {
334 md.push_str(&format!("**Finished:** {end} \n"));
335 }
336 md.push('\n');
337
338 md.push_str("## Summary\n\n");
340 md.push_str("| Metric | Value |\n|---|---|\n");
341 md.push_str(&format!("| Pages crawled | {} |\n", stats.total_pages));
342 md.push_str(&format!("| Total issues | {} |\n", stats.total_issues));
343 if let Some(avg) = stats.avg_response_time_ms {
344 md.push_str(&format!("| Avg response time | {avg:.0} ms |\n"));
345 }
346 if let Some(size) = stats.total_body_size {
347 md.push_str(&format!(
348 "| Total body size | {:.2} KB |\n",
349 size as f64 / 1024.0
350 ));
351 }
352 md.push('\n');
353
354 md.push_str("## Issues by Severity\n\n");
356 let severity_order = ["critical", "error", "warning", "info"];
357 md.push_str("| Severity | Count |\n|---|---|\n");
358 for sev in &severity_order {
359 let count = stats.issues_by_severity.get(*sev).unwrap_or(&0);
360 md.push_str(&format!("| {sev} | {count} |\n"));
361 }
362 md.push('\n');
363
364 md.push_str("## Issues by Category\n\n");
366 md.push_str("| Category | Count |\n|---|---|\n");
367 let mut cats: Vec<_> = stats.issues_by_category.iter().collect();
368 cats.sort_by(|a, b| b.1.cmp(a.1));
369 for (cat, count) in &cats {
370 md.push_str(&format!("| {cat} | {count} |\n"));
371 }
372 md.push('\n');
373
374 if !top_issues.is_empty() {
376 md.push_str("## Top Issues\n\n");
377 md.push_str("| # | Severity | Code | Title | Pages |\n|---|---|---|---|---|\n");
378 for (i, ti) in top_issues.iter().enumerate() {
379 md.push_str(&format!(
380 "| {} | {} | {} | {} | {} |\n",
381 i + 1,
382 ti.severity,
383 ti.code,
384 ti.title,
385 ti.affected_pages
386 ));
387 }
388 md.push('\n');
389 }
390
391 Ok(md)
392}
393
394pub fn export_html(storage: &Storage, crawl_id: &str) -> Result<String, ExportError> {
400 let stats = storage.get_stats(crawl_id)?;
401 let conn = storage.conn();
402 let meta = read_crawl_meta(&conn, crawl_id)?;
403 let top_issues = read_top_issues(&conn, crawl_id, 20)?;
404 let pages = read_pages(&conn, crawl_id)?;
405 let crux_metrics = read_crux_metrics(&conn, crawl_id)?;
406
407 let severity_color = |s: &str| -> &'static str {
408 match s {
409 "critical" => "#dc2626",
410 "error" => "#ea580c",
411 "warning" => "#ca8a04",
412 "info" => "#2563eb",
413 _ => "#6b7280",
414 }
415 };
416
417 let mut rows = String::new();
418 for page in &pages {
419 let issue_count = count_issues_for_page(&conn, &page.id).unwrap_or(0);
420 let status_class = if page.status_code >= 400 {
421 "status-error"
422 } else if page.status_code >= 300 {
423 "status-redirect"
424 } else {
425 "status-ok"
426 };
427 rows.push_str(&format!(
428 r#"<tr>
429 <td><a href="{url}" target="_blank">{url}</a></td>
430 <td class="{status_class}">{status}</td>
431 <td>{title}</td>
432 <td class="num">{wc}</td>
433 <td class="num">{lt}</td>
434 <td class="num">{ic}</td>
435</tr>"#,
436 url = page.url,
437 status = page.status_code,
438 title = page.title.as_deref().unwrap_or("—"),
439 wc = page
440 .word_count
441 .map(|v| v.to_string())
442 .unwrap_or_else(|| "—".into()),
443 lt = page
444 .load_time_ms
445 .map(|v| format!("{v}ms"))
446 .unwrap_or_else(|| "—".into()),
447 ic = issue_count,
448 ));
449 }
450
451 let mut issue_rows = String::new();
452 for ti in &top_issues {
453 let color = severity_color(&ti.severity);
454 issue_rows.push_str(&format!(
455 r#"<tr>
456 <td><span class="badge" style="background:{color}">{sev}</span></td>
457 <td>{code}</td>
458 <td>{title}</td>
459 <td class="num">{pages}</td>
460</tr>"#,
461 color = color,
462 sev = ti.severity,
463 code = ti.code,
464 title = ti.title,
465 pages = ti.affected_pages,
466 ));
467 }
468
469 let severity_bars: String = severity_order()
470 .iter()
471 .map(|sev| {
472 let count = stats.issues_by_severity.get(*sev).unwrap_or(&0);
473 let color = severity_color(sev);
474 let pct = if stats.total_issues > 0 {
475 (*count as f64 / stats.total_issues as f64) * 100.0
476 } else {
477 0.0
478 };
479 format!(
480 r#"<div class="bar" role="meter" aria-valuenow="{pct:.0}" aria-valuemin="0" aria-valuemax="100" aria-label="{sev}: {count}" style="width:{pct:.1}%;background:{color}"></div>"#,
481 )
482 })
483 .collect();
484
485 let html = format!(
486 r#"<!DOCTYPE html>
487<html lang="en">
488<head>
489<meta charset="utf-8">
490<meta name="viewport" content="width=device-width, initial-scale=1">
491<title>Crawl Report — {target}</title>
492<style>
493 :root {{ --bg: #f9fafb; --fg: #111827; --card: #fff; --border: #e5e7eb; --muted: #6b7280; }}
494 @media (prefers-color-scheme: dark) {{
495 :root {{ --bg: #111827; --fg: #f9fafb; --card: #1f2937; --border: #374151; --muted: #9ca3af; }}
496 }}
497 * {{ box-sizing: border-box; margin: 0; padding: 0; }}
498 body {{ font-family: system-ui, -apple-system, sans-serif; background: var(--bg); color: var(--fg); line-height: 1.6; padding: 2rem; }}
499 h1 {{ font-size: 1.5rem; margin-bottom: .5rem; }}
500 h2 {{ font-size: 1.25rem; margin: 2rem 0 1rem; border-bottom: 1px solid var(--border); padding-bottom: .5rem; }}
501 .meta {{ color: var(--muted); font-size: .875rem; margin-bottom: 1.5rem; }}
502 .cards {{ display: grid; grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); gap: 1rem; margin-bottom: 1.5rem; }}
503 .card {{ background: var(--card); border: 1px solid var(--border); border-radius: .5rem; padding: 1rem; }}
504 .card .value {{ font-size: 1.75rem; font-weight: 700; }}
505 .card .label {{ color: var(--muted); font-size: .8rem; text-transform: uppercase; letter-spacing: .05em; }}
506 .bar-chart {{ display: flex; height: 1.25rem; border-radius: .25rem; overflow: hidden; background: var(--border); margin-top: .5rem; }}
507 .bar {{ height: 100%; }}
508 table {{ width: 100%; border-collapse: collapse; font-size: .875rem; }}
509 th, td {{ text-align: left; padding: .5rem .75rem; border-bottom: 1px solid var(--border); }}
510 th {{ font-weight: 600; background: var(--card); position: sticky; top: 0; }}
511 .num {{ text-align: right; }}
512 .status-ok {{ color: #16a34a; }}
513 .status-redirect {{ color: #ca8a04; }}
514 .status-error {{ color: #dc2626; }}
515 .badge {{ display: inline-block; padding: .125rem .5rem; border-radius: .25rem; color: #fff; font-size: .75rem; font-weight: 600; }}
516 .search {{ margin-bottom: 1rem; }}
517 .search input {{ width: 100%; padding: .5rem .75rem; border: 1px solid var(--border); border-radius: .375rem; background: var(--card); color: var(--fg); font-size: .875rem; }}
518 a {{ color: #2563eb; text-decoration: none; }}
519 a:hover {{ text-decoration: underline; }}
520 .sr-only {{ position: absolute; width: 1px; height: 1px; padding: 0; margin: -1px; overflow: hidden; clip: rect(0,0,0,0); border: 0; }}
521 .cwv-good {{ color: #16a34a; font-weight: 600; }}
522 .cwv-avg {{ color: #ca8a04; font-weight: 600; }}
523 .cwv-poor {{ color: #dc2626; font-weight: 600; }}
524 .cwv-na {{ color: var(--muted); font-style: italic; }}
525 @media (max-width: 640px) {{
526 body {{ padding: 1rem; }}
527 .cards {{ grid-template-columns: repeat(2, 1fr); }}
528 }}
529</style>
530</head>
531<body>
532<h1>Crawl Report</h1>
533<div class="meta">
534 <code>{target}</code> · {crawl_id}
535</div>
536
537<div class="cards">
538 <div class="card"><div class="value">{total_pages}</div><div class="label">Pages</div></div>
539 <div class="card"><div class="value">{total_issues}</div><div class="label">Issues</div></div>
540 <div class="card"><div class="value">{avg_time}</div><div class="label">Avg Load</div></div>
541 <div class="card"><div class="value">{total_size}</div><div class="label">Total Size</div></div>
542</div>
543
544{cwv_summary_cards}
545
546<h2>Issue Distribution</h2>
547<div class="bar-chart" role="img" aria-label="Issue distribution by severity">{severity_bars}</div>
548
549<h2>Issues by Category</h2>
550<table>
551<thead><tr><th scope="col">Category</th><th scope="col" style="text-align:right">Count</th></tr></thead>
552<tbody>{category_rows}</tbody>
553</table>
554
555<h2>Top Issues</h2>
556<table>
557<thead><tr><th scope="col">Severity</th><th scope="col">Code</th><th scope="col">Title</th><th scope="col" style="text-align:right">Pages</th></tr></thead>
558<tbody>{issue_rows}</tbody>
559</table>
560
561{cwv_detail_section}
562
563<h2>All Pages</h2>
564<div class="search"><label for="search" class="sr-only">Filter pages</label><input type="text" id="search" placeholder="Filter pages..." oninput="filterTable()"></div>
565<div style="overflow-x:auto">
566<table id="pages-table">
567<thead><tr><th scope="col">URL</th><th scope="col">Status</th><th scope="col">Title</th><th scope="col" style="text-align:right">Words</th><th scope="col" style="text-align:right">Load</th><th scope="col" style="text-align:right">Issues</th></tr></thead>
568<tbody>{rows}</tbody>
569</table>
570</div>
571
572<script>
573function filterTable() {{
574 const q = document.getElementById('search').value.toLowerCase();
575 const rows = document.querySelectorAll('#pages-table tbody tr');
576 rows.forEach(r => {{ r.style.display = r.textContent.toLowerCase().includes(q) ? '' : 'none'; }});
577}}
578</script>
579</body>
580</html>"#,
581 target = meta.target_url,
582 crawl_id = crawl_id,
583 total_pages = stats.total_pages,
584 total_issues = stats.total_issues,
585 avg_time = stats
586 .avg_response_time_ms
587 .map(|v| format!("{v:.0}ms"))
588 .unwrap_or_else(|| "—".into()),
589 total_size = stats
590 .total_body_size
591 .map(|v| format!("{:.1} KB", v as f64 / 1024.0))
592 .unwrap_or_else(|| "—".into()),
593 cwv_summary_cards = build_cwv_summary_cards(&crux_metrics),
594 severity_bars = severity_bars,
595 category_rows = {
596 let mut cats: Vec<_> = stats.issues_by_category.iter().collect();
597 cats.sort_by(|a, b| b.1.cmp(a.1));
598 cats.iter()
599 .map(|(cat, count)| {
600 format!("<tr><td>{cat}</td><td class=\"num\">{count}</td></tr>")
601 })
602 .collect::<String>()
603 },
604 issue_rows = issue_rows,
605 cwv_detail_section = build_cwv_detail_section(&crux_metrics),
606 rows = rows,
607 );
608
609 Ok(html)
610}
611
612fn severity_order() -> &'static [&'static str] {
617 &["critical", "error", "warning", "info"]
618}
619
620#[derive(Debug, serde::Serialize)]
621struct PageRow {
622 id: String,
623 url: String,
624 final_url: String,
625 status_code: u16,
626 title: Option<String>,
627 description: Option<String>,
628 canonical: Option<String>,
629 word_count: Option<usize>,
630 load_time_ms: Option<u64>,
631 body_size: Option<usize>,
632 fetched_at: String,
633}
634
635#[derive(Debug, serde::Serialize)]
636struct IssueRow {
637 id: String,
638 category: String,
639 severity: String,
640 code: String,
641 title: String,
642 description: String,
643 element: Option<String>,
644 recommendation: String,
645}
646
647#[derive(Debug, serde::Serialize)]
648struct TopIssue {
649 severity: String,
650 code: String,
651 title: String,
652 affected_pages: usize,
653}
654
655fn read_crawl_meta(conn: &Connection, crawl_id: &str) -> Result<JsonCrawlMeta, ExportError> {
656 conn.query_row(
657 "SELECT id, target_url, start_time, end_time, pages_crawled, total_issues FROM crawls WHERE id = ?1",
658 [crawl_id],
659 |row| {
660 Ok(JsonCrawlMeta {
661 id: row.get(0)?,
662 target_url: row.get(1)?,
663 start_time: row.get(2)?,
664 end_time: row.get(3)?,
665 pages_crawled: row.get::<_, i64>(4)? as usize,
666 total_issues: row.get::<_, i64>(5)? as usize,
667 })
668 },
669 )
670 .map_err(ExportError::from)
671}
672
673fn read_pages(conn: &Connection, crawl_id: &str) -> Result<Vec<PageRow>, ExportError> {
674 let mut stmt = conn.prepare(
675 "SELECT id, url, final_url, status_code, title, description, canonical, word_count, load_time_ms, body_size, fetched_at
676 FROM pages WHERE crawl_id = ?1 ORDER BY fetched_at ASC",
677 )?;
678 let rows = stmt
679 .query_map([crawl_id], |row| {
680 Ok(PageRow {
681 id: row.get(0)?,
682 url: row.get(1)?,
683 final_url: row.get(2)?,
684 status_code: row.get(3)?,
685 title: row.get(4)?,
686 description: row.get(5)?,
687 canonical: row.get(6)?,
688 word_count: row.get::<_, Option<i64>>(7)?.map(|v| v as usize),
689 load_time_ms: row.get::<_, Option<i64>>(8)?.map(|v| v as u64),
690 body_size: row.get::<_, Option<i64>>(9)?.map(|v| v as usize),
691 fetched_at: row.get(10)?,
692 })
693 })?
694 .collect::<Result<Vec<_>, _>>()?;
695 Ok(rows)
696}
697
698fn read_issues_for_page(conn: &Connection, page_id: &str) -> Result<Vec<IssueRow>, ExportError> {
699 let mut stmt = conn.prepare(
700 "SELECT id, category, severity, code, title, description, element, recommendation
701 FROM findings WHERE page_id = ?1",
702 )?;
703 let rows = stmt
704 .query_map([page_id], |row| {
705 Ok(IssueRow {
706 id: row.get(0)?,
707 category: row.get(1)?,
708 severity: row.get(2)?,
709 code: row.get(3)?,
710 title: row.get(4)?,
711 description: row.get(5)?,
712 element: row.get(6)?,
713 recommendation: row.get(7)?,
714 })
715 })?
716 .collect::<Result<Vec<_>, _>>()?;
717 Ok(rows)
718}
719
720fn count_issues_for_page(conn: &Connection, page_id: &str) -> Result<usize, ExportError> {
721 let count: i64 = conn.query_row(
722 "SELECT COUNT(*) FROM findings WHERE page_id = ?1",
723 [page_id],
724 |row| row.get(0),
725 )?;
726 Ok(count as usize)
727}
728
729fn read_links_for_page(conn: &Connection, page_id: &str) -> Result<Vec<String>, ExportError> {
730 let mut stmt = conn.prepare("SELECT target_url FROM links WHERE page_id = ?1")?;
731 let rows = stmt
732 .query_map([page_id], |row| row.get::<_, String>(0))?
733 .collect::<Result<Vec<_>, _>>()?;
734 Ok(rows)
735}
736
737#[derive(Debug, Clone)]
738struct CruxRow {
739 url: String,
740 lcp_p75: Option<f64>,
741 inp_p75: Option<f64>,
742 cls_p75: Option<f64>,
743 fcp_p75: Option<f64>,
744 ttfb_p75: Option<f64>,
745}
746
747fn read_crux_metrics(conn: &Connection, crawl_id: &str) -> Result<Vec<CruxRow>, ExportError> {
748 let mut stmt = conn.prepare(
749 "SELECT cm.url, cm.lcp_p75, cm.inp_p75, cm.cls_p75, cm.fcp_p75, cm.ttfb_p75
750 FROM crux_metrics cm
751 JOIN pages p ON cm.page_id = p.id
752 WHERE p.crawl_id = ?1",
753 )?;
754 let rows = stmt
755 .query_map([crawl_id], |row| {
756 Ok(CruxRow {
757 url: row.get(0)?,
758 lcp_p75: row.get(1)?,
759 inp_p75: row.get(2)?,
760 cls_p75: row.get(3)?,
761 fcp_p75: row.get(4)?,
762 ttfb_p75: row.get(5)?,
763 })
764 })?
765 .collect::<Result<Vec<_>, _>>()?;
766 Ok(rows)
767}
768
769fn read_top_issues(
770 conn: &Connection,
771 crawl_id: &str,
772 limit: usize,
773) -> Result<Vec<TopIssue>, ExportError> {
774 let mut stmt = conn.prepare(
775 "SELECT f.severity, f.code, f.title, COUNT(DISTINCT f.page_id) as affected_pages
776 FROM findings f
777 JOIN pages p ON f.page_id = p.id
778 WHERE p.crawl_id = ?1
779 GROUP BY f.severity, f.code, f.title
780 ORDER BY
781 CASE f.severity WHEN 'critical' THEN 0 WHEN 'error' THEN 1 WHEN 'warning' THEN 2 ELSE 3 END,
782 affected_pages DESC
783 LIMIT ?2",
784 )?;
785 let rows = stmt
786 .query_map(rusqlite::params![crawl_id, limit as i64], |row| {
787 Ok(TopIssue {
788 severity: row.get(0)?,
789 code: row.get(1)?,
790 title: row.get(2)?,
791 affected_pages: row.get::<_, i64>(3)? as usize,
792 })
793 })?
794 .collect::<Result<Vec<_>, _>>()?;
795 Ok(rows)
796}
797
798fn cwv_lcp_class(val: f64) -> &'static str {
799 if val < 2500.0 {
800 "cwv-good"
801 } else if val < 4000.0 {
802 "cwv-avg"
803 } else {
804 "cwv-poor"
805 }
806}
807
808fn cwv_cls_class(val: f64) -> &'static str {
809 if val < 0.1 {
810 "cwv-good"
811 } else if val < 0.25 {
812 "cwv-avg"
813 } else {
814 "cwv-poor"
815 }
816}
817
818fn cwv_inp_class(val: f64) -> &'static str {
819 if val < 200.0 {
820 "cwv-good"
821 } else if val < 500.0 {
822 "cwv-avg"
823 } else {
824 "cwv-poor"
825 }
826}
827
828fn cwv_generic_class(_val: f64) -> &'static str {
829 "cwv-good"
830}
831
832fn build_cwv_summary_cards(metrics: &[CruxRow]) -> String {
833 if metrics.is_empty() {
834 return String::new();
835 }
836
837 let fmt = |val: Option<f64>, unit: &str| -> (String, &'static str) {
838 match val {
839 Some(v) => {
840 let cls = if unit == "ms" {
841 if v < 2500.0 {
842 "cwv-good"
843 } else if v < 4000.0 {
844 "cwv-avg"
845 } else {
846 "cwv-poor"
847 }
848 } else if unit.is_empty() {
849 if v < 0.1 {
851 "cwv-good"
852 } else if v < 0.25 {
853 "cwv-avg"
854 } else {
855 "cwv-poor"
856 }
857 } else {
858 if v < 200.0 {
860 "cwv-good"
861 } else if v < 500.0 {
862 "cwv-avg"
863 } else {
864 "cwv-poor"
865 }
866 };
867 let display = if unit.is_empty() {
868 format!("{v:.3}")
869 } else if unit == "ms" {
870 format!("{:.0}ms", v)
871 } else {
872 format!("{v:.0}{unit}")
873 };
874 (display, cls)
875 }
876 None => ("N/A".into(), "cwv-na"),
877 }
878 };
879
880 let avg_lcp = average(metrics.iter().filter_map(|m| m.lcp_p75));
882 let avg_cls = average(metrics.iter().filter_map(|m| m.cls_p75));
883 let avg_inp = average(metrics.iter().filter_map(|m| m.inp_p75));
884
885 let (lcp_display, lcp_cls) = fmt(avg_lcp, "ms");
886 let (cls_display, cls_cls_val) = fmt(avg_cls, "");
887 let (inp_display, inp_cls) = fmt(avg_inp, "ms");
888
889 format!(
890 r#"<div class="cards">
891 <div class="card"><div class="value {lcp_cls}">{lcp_display}</div><div class="label">Avg LCP (p75)</div></div>
892 <div class="card"><div class="value {cls_cls_val}">{cls_display}</div><div class="label">Avg CLS (p75)</div></div>
893 <div class="card"><div class="value {inp_cls}">{inp_display}</div><div class="label">Avg INP (p75)</div></div>
894 <div class="card"><div class="value">{}</div><div class="label">Pages with CrUX</div></div>
895</div>"#,
896 metrics.len(),
897 )
898}
899
900fn build_cwv_detail_section(metrics: &[CruxRow]) -> String {
901 if metrics.is_empty() {
902 return String::new();
903 }
904
905 let mut rows = String::new();
906 for m in metrics {
907 let lcp_cls = m.lcp_p75.map(cwv_lcp_class).unwrap_or("cwv-na");
908 let cls_cls = m.cls_p75.map(cwv_cls_class).unwrap_or("cwv-na");
909 let inp_cls = m.inp_p75.map(cwv_inp_class).unwrap_or("cwv-na");
910 let fcp_cls = m.fcp_p75.map(cwv_generic_class).unwrap_or("cwv-na");
911 let ttfb_cls = m.ttfb_p75.map(cwv_generic_class).unwrap_or("cwv-na");
912
913 rows.push_str(&format!(
914 r#"<tr>
915 <td><a href="{url}" target="_blank">{url}</a></td>
916 <td class="num {lcp_cls}">{lcp}</td>
917 <td class="num {cls_cls}">{cls}</td>
918 <td class="num {inp_cls}">{inp}</td>
919 <td class="num {fcp_cls}">{fcp}</td>
920 <td class="num {ttfb_cls}">{ttfb}</td>
921</tr>"#,
922 url = m.url,
923 lcp = m
924 .lcp_p75
925 .map(|v| format!("{:.0}ms", v))
926 .unwrap_or_else(|| "—".into()),
927 cls = m
928 .cls_p75
929 .map(|v| format!("{v:.3}"))
930 .unwrap_or_else(|| "—".into()),
931 inp = m
932 .inp_p75
933 .map(|v| format!("{:.0}ms", v))
934 .unwrap_or_else(|| "—".into()),
935 fcp = m
936 .fcp_p75
937 .map(|v| format!("{:.0}ms", v))
938 .unwrap_or_else(|| "—".into()),
939 ttfb = m
940 .ttfb_p75
941 .map(|v| format!("{:.0}ms", v))
942 .unwrap_or_else(|| "—".into()),
943 ));
944 }
945
946 format!(
947 r#"<h2>Core Web Vitals</h2>
948<table>
949<thead><tr><th scope="col">URL</th><th scope="col" style="text-align:right">LCP (p75)</th><th scope="col" style="text-align:right">CLS (p75)</th><th scope="col" style="text-align:right">INP (p75)</th><th scope="col" style="text-align:right">FCP (p75)</th><th scope="col" style="text-align:right">TTFB (p75)</th></tr></thead>
950<tbody>{rows}</tbody>
951</table>"#
952 )
953}
954
955fn average<'a>(iter: impl Iterator<Item = f64> + 'a) -> Option<f64> {
956 let mut sum = 0.0;
957 let mut count = 0usize;
958 for v in iter {
959 sum += v;
960 count += 1;
961 }
962 if count > 0 {
963 Some(sum / count as f64)
964 } else {
965 None
966 }
967}
968
969#[cfg(test)]
970mod tests {
971 use super::*;
972 use crate::storage::{Issue, IssueCategory, PageData, Severity, Storage};
973 use chrono::Utc;
974 use url::Url;
975
976 fn seed_data(storage: &Storage, crawl_id: &str) {
977 let pages = vec![
978 PageData {
979 id: "p1".into(),
980 url: Url::parse("https://example.com/").unwrap(),
981 final_url: Url::parse("https://example.com/").unwrap(),
982 status_code: 200,
983 title: Some("Home".into()),
984 description: Some("Home page".into()),
985 canonical_url: Some(Url::parse("https://example.com/").unwrap()),
986 word_count: Some(1200),
987 load_time_ms: Some(150),
988 body_size: Some(4096),
989 fetched_at: Utc::now(),
990 links: vec![Url::parse("https://example.com/about").unwrap()],
991 },
992 PageData {
993 id: "p2".into(),
994 url: Url::parse("https://example.com/about").unwrap(),
995 final_url: Url::parse("https://example.com/about").unwrap(),
996 status_code: 404,
997 title: None,
998 description: None,
999 canonical_url: None,
1000 word_count: Some(50),
1001 load_time_ms: Some(80),
1002 body_size: Some(512),
1003 fetched_at: Utc::now(),
1004 links: vec![],
1005 },
1006 PageData {
1007 id: "p3".into(),
1008 url: Url::parse("https://example.com/blog").unwrap(),
1009 final_url: Url::parse("https://example.com/blog").unwrap(),
1010 status_code: 200,
1011 title: Some("Blog".into()),
1012 description: Some("Blog listing".into()),
1013 canonical_url: None,
1014 word_count: Some(3000),
1015 load_time_ms: Some(300),
1016 body_size: Some(8192),
1017 fetched_at: Utc::now(),
1018 links: vec![
1019 Url::parse("https://example.com/").unwrap(),
1020 Url::parse("https://external.com/other").unwrap(),
1021 ],
1022 },
1023 ];
1024 storage.insert_pages(crawl_id, &pages).unwrap();
1025
1026 let issues = vec![
1027 Issue {
1028 id: "i1".into(),
1029 page_id: "p1".into(),
1030 category: IssueCategory::Seo,
1031 severity: Severity::Error,
1032 code: "SEO001".into(),
1033 title: "Missing meta description".into(),
1034 description: "Page has no meta description".into(),
1035 element: Some("meta[name=description]".into()),
1036 recommendation: "Add a meta description".into(),
1037 },
1038 Issue {
1039 id: "i2".into(),
1040 page_id: "p2".into(),
1041 category: IssueCategory::Http,
1042 severity: Severity::Critical,
1043 code: "HTTP001".into(),
1044 title: "404 Not Found".into(),
1045 description: "Page returns 404".into(),
1046 element: None,
1047 recommendation: "Fix or remove the page".into(),
1048 },
1049 Issue {
1050 id: "i3".into(),
1051 page_id: "p1".into(),
1052 category: IssueCategory::Images,
1053 severity: Severity::Warning,
1054 code: "IMG001".into(),
1055 title: "Image missing alt text".into(),
1056 description: "An image has no alt attribute".into(),
1057 element: Some("img.logo".into()),
1058 recommendation: "Add descriptive alt text".into(),
1059 },
1060 Issue {
1061 id: "i4".into(),
1062 page_id: "p3".into(),
1063 category: IssueCategory::Seo,
1064 severity: Severity::Warning,
1065 code: "SEO002".into(),
1066 title: "No canonical tag".into(),
1067 description: "Page is missing a canonical URL".into(),
1068 element: None,
1069 recommendation: "Add a canonical link tag".into(),
1070 },
1071 Issue {
1072 id: "i5".into(),
1073 page_id: "p3".into(),
1074 category: IssueCategory::Performance,
1075 severity: Severity::Info,
1076 code: "PERF001".into(),
1077 title: "Slow load time".into(),
1078 description: "Page took over 200ms to load".into(),
1079 element: None,
1080 recommendation: "Optimize page load speed".into(),
1081 },
1082 ];
1083 storage.insert_issues(&issues).unwrap();
1084 storage.finish_crawl(crawl_id, 3, 5).unwrap();
1085 }
1086
1087 #[test]
1090 fn test_csv_all_columns() {
1091 let storage = Storage::new_in_memory().unwrap();
1092 let crawl_id = storage.start_crawl("https://example.com", None).unwrap();
1093 seed_data(&storage, &crawl_id);
1094
1095 let conn = &*storage.conn();
1096 let selector = CsvColumnSelector::all();
1097 let csv_bytes = export_csv(conn, &crawl_id, &selector).unwrap();
1098 let csv_str = String::from_utf8(csv_bytes).unwrap();
1099
1100 assert!(csv_str.contains("url,status_code,title"));
1101 assert!(csv_str.contains("https://example.com/"));
1102 assert!(csv_str.contains("https://example.com/about"));
1103 assert!(csv_str.contains("https://example.com/blog"));
1104 }
1105
1106 #[test]
1107 fn test_csv_subset_columns() {
1108 let storage = Storage::new_in_memory().unwrap();
1109 let crawl_id = storage.start_crawl("https://example.com", None).unwrap();
1110 seed_data(&storage, &crawl_id);
1111
1112 let conn = &*storage.conn();
1113 let selector = CsvColumnSelector {
1114 url: true,
1115 status_code: true,
1116 issue_count: true,
1117 ..Default::default()
1118 };
1119 let csv_bytes = export_csv(conn, &crawl_id, &selector).unwrap();
1120 let csv_str = String::from_utf8(csv_bytes).unwrap();
1121
1122 let lines: Vec<&str> = csv_str.lines().collect();
1123 assert!(lines[0].contains("url"));
1124 assert!(lines[0].contains("status_code"));
1125 assert!(lines[0].contains("issue_count"));
1126 assert!(!lines[0].contains("title"));
1127 }
1128
1129 #[test]
1130 fn test_csv_nested_json_escaping() {
1131 let storage = Storage::new_in_memory().unwrap();
1132 let crawl_id = storage.start_crawl("https://example.com", None).unwrap();
1133 seed_data(&storage, &crawl_id);
1134
1135 let conn = &*storage.conn();
1136 let selector = CsvColumnSelector {
1137 issues_json: true,
1138 ..Default::default()
1139 };
1140 let csv_bytes = export_csv(conn, &crawl_id, &selector).unwrap();
1141 let csv_str = String::from_utf8(csv_bytes).unwrap();
1142
1143 assert!(csv_str.contains("Missing meta description"));
1145 }
1146
1147 #[test]
1148 fn test_csv_empty_crawl() {
1149 let storage = Storage::new_in_memory().unwrap();
1150 let crawl_id = storage.start_crawl("https://example.com", None).unwrap();
1151
1152 let conn = &*storage.conn();
1153 let selector = CsvColumnSelector::all();
1154 let csv_bytes = export_csv(conn, &crawl_id, &selector).unwrap();
1155 let csv_str = String::from_utf8(csv_bytes).unwrap();
1156
1157 let lines: Vec<&str> = csv_str.lines().collect();
1158 assert_eq!(lines.len(), 1); }
1160
1161 #[test]
1164 fn test_json_pretty() {
1165 let storage = Storage::new_in_memory().unwrap();
1166 let crawl_id = storage.start_crawl("https://example.com", None).unwrap();
1167 seed_data(&storage, &crawl_id);
1168
1169 let json_str = export_json(&storage, &crawl_id, true).unwrap();
1170
1171 assert!(json_str.contains("schema_version"));
1172 assert!(json_str.contains(JSON_SCHEMA_VERSION));
1173 assert!(json_str.contains("https://example.com/"));
1174 assert!(json_str.contains('\n'));
1176 }
1177
1178 #[test]
1179 fn test_json_compact() {
1180 let storage = Storage::new_in_memory().unwrap();
1181 let crawl_id = storage.start_crawl("https://example.com", None).unwrap();
1182 seed_data(&storage, &crawl_id);
1183
1184 let json_str = export_json(&storage, &crawl_id, false).unwrap();
1185
1186 assert!(!json_str.contains('\n'));
1188 let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1189 assert_eq!(v["schema_version"], JSON_SCHEMA_VERSION);
1190 assert_eq!(v["crawl"]["pages_crawled"], 3);
1191 }
1192
1193 #[test]
1194 fn test_json_page_issues_and_links() {
1195 let storage = Storage::new_in_memory().unwrap();
1196 let crawl_id = storage.start_crawl("https://example.com", None).unwrap();
1197 seed_data(&storage, &crawl_id);
1198
1199 let json_str = export_json(&storage, &crawl_id, true).unwrap();
1200 let v: serde_json::Value = serde_json::from_str(&json_str).unwrap();
1201
1202 let pages = v["pages"].as_array().unwrap();
1203 let home = pages
1204 .iter()
1205 .find(|p| p["url"] == "https://example.com/")
1206 .unwrap();
1207 assert_eq!(home["issues"].as_array().unwrap().len(), 2);
1208 assert_eq!(home["links"].as_array().unwrap().len(), 1);
1209 }
1210
1211 #[test]
1214 fn test_markdown_structure() {
1215 let storage = Storage::new_in_memory().unwrap();
1216 let crawl_id = storage.start_crawl("https://example.com", None).unwrap();
1217 seed_data(&storage, &crawl_id);
1218
1219 let md = export_markdown(&storage, &crawl_id).unwrap();
1220
1221 assert!(md.contains("# Crawl Report"));
1222 assert!(md.contains("## Summary"));
1223 assert!(md.contains("## Issues by Severity"));
1224 assert!(md.contains("## Issues by Category"));
1225 assert!(md.contains("## Top Issues"));
1226 assert!(md.contains("3")); assert!(md.contains("5")); }
1229
1230 #[test]
1231 fn test_markdown_empty_crawl() {
1232 let storage = Storage::new_in_memory().unwrap();
1233 let crawl_id = storage.start_crawl("https://example.com", None).unwrap();
1234
1235 let md = export_markdown(&storage, &crawl_id).unwrap();
1236
1237 assert!(md.contains("| Pages crawled | 0 |"));
1238 assert!(md.contains("| Total issues | 0 |"));
1239 }
1240
1241 #[test]
1244 fn test_html_structure() {
1245 let storage = Storage::new_in_memory().unwrap();
1246 let crawl_id = storage.start_crawl("https://example.com", None).unwrap();
1247 seed_data(&storage, &crawl_id);
1248
1249 let html = export_html(&storage, &crawl_id).unwrap();
1250
1251 assert!(html.contains("<!DOCTYPE html>"));
1252 assert!(html.contains("Crawl Report"));
1253 assert!(html.contains("status-error"));
1254 assert!(html.contains("https://example.com/"));
1255 assert!(html.contains("filterTable()"));
1256 }
1257
1258 #[test]
1259 fn test_html_empty_crawl() {
1260 let storage = Storage::new_in_memory().unwrap();
1261 let crawl_id = storage.start_crawl("https://example.com", None).unwrap();
1262
1263 let html = export_html(&storage, &crawl_id).unwrap();
1264
1265 assert!(html.contains("<!DOCTYPE html>"));
1266 assert!(html.contains("0")); }
1268
1269 #[test]
1272 fn test_column_selector_headers() {
1273 let sel = CsvColumnSelector::all();
1274 assert_eq!(sel.headers().len(), 12);
1275
1276 let sel = CsvColumnSelector {
1277 url: true,
1278 status_code: true,
1279 ..Default::default()
1280 };
1281 assert_eq!(sel.headers(), vec!["url", "status_code"]);
1282 }
1283}