Skip to main content

crawlkit_engine/
export.rs

1use rusqlite::Connection;
2use serde::Serialize;
3use thiserror::Error;
4
5use crate::storage::{CrawlStats, Storage, StorageError};
6use crate::CrawlError;
7
8/// Errors specific to export operations.
9#[derive(Debug, Error)]
10pub enum ExportError {
11    /// I/O error during write.
12    #[error("io error: {0}")]
13    Io(#[from] std::io::Error),
14
15    /// CSV serialization error.
16    #[error("csv error: {0}")]
17    Csv(#[from] csv::Error),
18
19    /// JSON serialization error.
20    #[error("json error: {0}")]
21    Json(#[from] serde_json::Error),
22
23    /// SQLite error.
24    #[error("database error: {0}")]
25    Database(#[from] rusqlite::Error),
26
27    /// Storage error.
28    #[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// ---------------------------------------------------------------------------
39// CSV Export
40// ---------------------------------------------------------------------------
41
42/// Column selector for CSV export.
43#[derive(Debug, Clone, Default)]
44pub struct CsvColumnSelector {
45    /// If true, include the page URL.
46    pub url: bool,
47    /// If true, include status code.
48    pub status_code: bool,
49    /// If true, include page title.
50    pub title: bool,
51    /// If true, include meta description.
52    pub description: bool,
53    /// If true, include canonical URL.
54    pub canonical: bool,
55    /// If true, include word count.
56    pub word_count: bool,
57    /// If true, include load time in ms.
58    pub load_time_ms: bool,
59    /// If true, include body size in bytes.
60    pub body_size: bool,
61    /// If true, include fetched_at timestamp.
62    pub fetched_at: bool,
63    /// If true, include issue count per page.
64    pub issue_count: bool,
65    /// If true, include all issues as JSON array.
66    pub issues_json: bool,
67    /// If true, include all links as JSON array.
68    pub links_json: bool,
69}
70
71impl CsvColumnSelector {
72    /// All columns enabled.
73    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    /// Return ordered list of enabled column names.
91    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
133/// Export crawl data to CSV.
134///
135/// One row per page. Nested data (issues, links) is JSON-encoded.
136pub 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
197// ---------------------------------------------------------------------------
198// JSON Export
199// ---------------------------------------------------------------------------
200
201/// Schema version for JSON exports.
202pub const JSON_SCHEMA_VERSION: &str = "1.0";
203
204/// Top-level JSON export structure.
205#[derive(Serialize)]
206pub struct JsonExport {
207    /// Schema version.
208    pub schema_version: String,
209    /// Crawl metadata.
210    pub crawl: JsonCrawlMeta,
211    /// All pages.
212    pub pages: Vec<JsonPage>,
213    /// Aggregate stats.
214    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
258/// Export crawl data to a structured JSON value.
259pub 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
315// ---------------------------------------------------------------------------
316// Markdown Summary
317// ---------------------------------------------------------------------------
318
319/// Generate a Markdown summary report.
320pub 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    // Summary
339    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    // Issues by severity
355    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    // Issues by category
365    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    // Top issues
375    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
394// ---------------------------------------------------------------------------
395// HTML Report
396// ---------------------------------------------------------------------------
397
398/// Generate a self-contained HTML report.
399pub 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> &middot; {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
612// ---------------------------------------------------------------------------
613// Internal helpers
614// ---------------------------------------------------------------------------
615
616fn 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                    // CLS is unitless
850                    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                    // ms for INP/FCP/TTFB
859                    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    // Average across all pages
881    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    // --- CSV tests ---
1088
1089    #[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        // p1 has 2 issues, p3 has 2 issues, p2 has 1 issue
1144        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); // header only
1159    }
1160
1161    // --- JSON tests ---
1162
1163    #[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        // Pretty-printed has newlines
1175        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        // Compact has no extra whitespace
1187        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    // --- Markdown tests ---
1212
1213    #[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")); // total pages
1227        assert!(md.contains("5")); // total issues
1228    }
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    // --- HTML tests ---
1242
1243    #[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")); // zero counts
1267    }
1268
1269    // --- Helper access tests ---
1270
1271    #[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}