1use chrono::{DateTime, Utc};
2use lru::LruCache;
3use parking_lot::Mutex;
4use rusqlite::{params, Connection};
5use std::num::NonZeroUsize;
6use std::path::Path;
7use std::sync::atomic::{AtomicUsize, Ordering};
8use thiserror::Error;
9use url::Url;
10
11use crate::CrawlError;
12
13#[derive(Debug, Error)]
18pub enum StorageError {
19 #[error("database error: {0}")]
21 Database(#[from] rusqlite::Error),
22
23 #[error("invalid URL in database: {0}")]
25 InvalidUrl(#[from] url::ParseError),
26}
27
28impl From<StorageError> for CrawlError {
29 fn from(e: StorageError) -> Self {
30 CrawlError::Storage(e.to_string())
31 }
32}
33
34#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
39pub enum Severity {
40 Critical,
42 Error,
44 Warning,
46 Info,
48}
49
50impl Severity {
51 pub fn as_str(&self) -> &'static str {
53 match self {
54 Severity::Critical => "critical",
55 Severity::Error => "error",
56 Severity::Warning => "warning",
57 Severity::Info => "info",
58 }
59 }
60
61 pub fn parse_severity(s: &str) -> Option<Self> {
63 match s {
64 "critical" => Some(Severity::Critical),
65 "error" => Some(Severity::Error),
66 "warning" => Some(Severity::Warning),
67 "info" => Some(Severity::Info),
68 _ => None,
69 }
70 }
71}
72
73#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
78pub enum IssueCategory {
79 Http,
81 Seo,
83 Content,
85 Links,
87 Images,
89 Schema,
91 Security,
93 Performance,
95 Mobile,
97 Accessibility,
99 Social,
101 Custom(String),
103}
104
105impl IssueCategory {
106 pub fn as_str(&self) -> String {
108 match self {
109 IssueCategory::Http => "http".to_string(),
110 IssueCategory::Seo => "seo".to_string(),
111 IssueCategory::Content => "content".to_string(),
112 IssueCategory::Links => "links".to_string(),
113 IssueCategory::Images => "images".to_string(),
114 IssueCategory::Schema => "schema".to_string(),
115 IssueCategory::Security => "security".to_string(),
116 IssueCategory::Performance => "performance".to_string(),
117 IssueCategory::Mobile => "mobile".to_string(),
118 IssueCategory::Accessibility => "accessibility".to_string(),
119 IssueCategory::Social => "social".to_string(),
120 IssueCategory::Custom(name) => format!("custom:{name}"),
121 }
122 }
123
124 pub fn parse_category(s: &str) -> Self {
126 match s {
127 "http" => IssueCategory::Http,
128 "seo" => IssueCategory::Seo,
129 "content" => IssueCategory::Content,
130 "links" => IssueCategory::Links,
131 "images" => IssueCategory::Images,
132 "schema" => IssueCategory::Schema,
133 "security" => IssueCategory::Security,
134 "performance" => IssueCategory::Performance,
135 "mobile" => IssueCategory::Mobile,
136 "accessibility" => IssueCategory::Accessibility,
137 "social" => IssueCategory::Social,
138 other => {
139 let name = other.strip_prefix("custom:").unwrap_or(other);
141 IssueCategory::Custom(name.to_string())
142 }
143 }
144 }
145}
146
147#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
152pub struct PageData {
153 pub id: String,
155 pub url: Url,
157 pub final_url: Url,
159 pub status_code: u16,
161 pub title: Option<String>,
163 pub description: Option<String>,
165 pub canonical_url: Option<Url>,
167 pub word_count: Option<usize>,
169 pub load_time_ms: Option<u64>,
171 pub body_size: Option<usize>,
173 pub fetched_at: DateTime<Utc>,
175 pub links: Vec<Url>,
177}
178
179#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
184pub struct Issue {
185 pub id: String,
187 pub page_id: String,
189 pub category: IssueCategory,
191 pub severity: Severity,
193 pub code: String,
195 pub title: String,
197 pub description: String,
199 pub element: Option<String>,
201 pub recommendation: String,
203}
204
205#[derive(Debug, Clone, Default)]
210pub struct IssueFilter {
211 pub severity: Option<Severity>,
213 pub category: Option<IssueCategory>,
215 pub page_id: Option<String>,
217 pub code_prefix: Option<String>,
219}
220
221#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
226pub struct CrawlStats {
227 pub total_pages: usize,
229 pub total_issues: usize,
231 pub issues_by_severity: std::collections::HashMap<String, usize>,
233 pub issues_by_category: std::collections::HashMap<String, usize>,
235 pub avg_response_time_ms: Option<f64>,
237 pub total_body_size: Option<usize>,
239}
240
241#[derive(Debug, Clone)]
243pub struct CacheStats {
244 pub capacity: usize,
246 pub size: usize,
248 pub hits: usize,
250 pub misses: usize,
252}
253
254pub struct Storage {
260 conn: Mutex<Connection>,
261 page_cache: Mutex<LruCache<String, PageData>>,
263 memory_usage: AtomicUsize,
265 mmap_enabled: bool,
267}
268
269impl Storage {
270 pub fn conn(&self) -> parking_lot::MutexGuard<'_, Connection> {
275 self.conn.lock()
276 }
277
278 pub fn new(path: &Path) -> Result<Self, StorageError> {
288 let conn = Connection::open(path)?;
289
290 conn.execute_batch(
293 "PRAGMA journal_mode=WAL;
294 PRAGMA foreign_keys=ON;
295 PRAGMA mmap_size=268435456;
296 PRAGMA cache_size=-64000;
297 PRAGMA synchronous=NORMAL;",
298 )?;
299
300 let storage = Self {
301 conn: Mutex::new(conn),
302 page_cache: Mutex::new(LruCache::new(
303 NonZeroUsize::new(1000).unwrap_or(NonZeroUsize::MIN),
304 )),
305 memory_usage: AtomicUsize::new(0),
306 mmap_enabled: true,
307 };
308 storage.create_schema()?;
309 Ok(storage)
310 }
311
312 pub fn new_in_memory() -> Result<Self, StorageError> {
322 let conn = Connection::open_in_memory()?;
323 conn.execute_batch(
324 "PRAGMA journal_mode=WAL;
325 PRAGMA foreign_keys=ON;
326 PRAGMA cache_size=-64000;",
327 )?;
328 let storage = Self {
329 conn: Mutex::new(conn),
330 page_cache: Mutex::new(LruCache::new(
331 NonZeroUsize::new(1000).unwrap_or(NonZeroUsize::MIN),
332 )),
333 memory_usage: AtomicUsize::new(0),
334 mmap_enabled: false,
335 };
336 storage.create_schema()?;
337 Ok(storage)
338 }
339
340 pub fn with_cache_size(path: &Path, cache_size: usize) -> Result<Self, StorageError> {
350 let conn = Connection::open(path)?;
351
352 conn.execute_batch(
353 "PRAGMA journal_mode=WAL;
354 PRAGMA foreign_keys=ON;
355 PRAGMA mmap_size=268435456;
356 PRAGMA cache_size=-64000;
357 PRAGMA synchronous=NORMAL;",
358 )?;
359
360 let storage = Self {
361 conn: Mutex::new(conn),
362 page_cache: Mutex::new(LruCache::new(
363 NonZeroUsize::new(cache_size).unwrap_or(NonZeroUsize::MIN),
364 )),
365 memory_usage: AtomicUsize::new(0),
366 mmap_enabled: true,
367 };
368 storage.create_schema()?;
369 Ok(storage)
370 }
371
372 pub fn memory_usage(&self) -> usize {
374 self.memory_usage.load(Ordering::Relaxed)
375 }
376
377 pub fn is_mmap_enabled(&self) -> bool {
379 self.mmap_enabled
380 }
381
382 pub fn cache_stats(&self) -> CacheStats {
384 let cache = self.page_cache.lock();
385 CacheStats {
386 capacity: cache.cap().get(),
387 size: cache.len(),
388 hits: 0, misses: 0,
390 }
391 }
392
393 pub fn clear_cache(&self) {
395 let mut cache = self.page_cache.lock();
396 let evicted = cache.len();
397 cache.clear();
398 if evicted > 0 {
399 self.memory_usage
400 .fetch_sub(evicted * std::mem::size_of::<PageData>(), Ordering::Relaxed);
401 }
402 }
403
404 fn create_schema(&self) -> Result<(), StorageError> {
406 let conn = self.conn.lock();
407 conn.execute_batch(
408 "
409 CREATE TABLE IF NOT EXISTS crawls (
410 id TEXT PRIMARY KEY,
411 start_time DATETIME NOT NULL,
412 end_time DATETIME,
413 target_url TEXT NOT NULL,
414 pages_crawled INTEGER DEFAULT 0,
415 total_issues INTEGER DEFAULT 0,
416 config_json TEXT
417 );
418
419 CREATE TABLE IF NOT EXISTS pages (
420 id TEXT PRIMARY KEY,
421 crawl_id TEXT NOT NULL REFERENCES crawls(id),
422 url TEXT NOT NULL,
423 final_url TEXT NOT NULL,
424 status_code INTEGER NOT NULL,
425 title TEXT,
426 description TEXT,
427 canonical TEXT,
428 word_count INTEGER,
429 load_time_ms INTEGER,
430 body_size INTEGER,
431 fetched_at DATETIME NOT NULL,
432 UNIQUE(crawl_id, url)
433 );
434
435 CREATE TABLE IF NOT EXISTS links (
436 id TEXT PRIMARY KEY,
437 page_id TEXT NOT NULL REFERENCES pages(id),
438 source_url TEXT NOT NULL,
439 target_url TEXT NOT NULL,
440 anchor_text TEXT,
441 rel TEXT,
442 is_external BOOLEAN,
443 is_nofollow BOOLEAN
444 );
445
446 CREATE TABLE IF NOT EXISTS findings (
447 id TEXT PRIMARY KEY,
448 page_id TEXT NOT NULL REFERENCES pages(id),
449 category TEXT NOT NULL,
450 severity TEXT NOT NULL,
451 code TEXT NOT NULL,
452 title TEXT NOT NULL,
453 description TEXT NOT NULL,
454 element TEXT,
455 recommendation TEXT
456 );
457
458 CREATE TABLE IF NOT EXISTS images (
459 id TEXT PRIMARY KEY,
460 page_id TEXT NOT NULL REFERENCES pages(id),
461 url TEXT NOT NULL,
462 alt TEXT,
463 width INTEGER,
464 height INTEGER,
465 format TEXT,
466 file_size INTEGER,
467 is_lazy_loaded BOOLEAN
468 );
469
470 CREATE TABLE IF NOT EXISTS schemas (
471 id TEXT PRIMARY KEY,
472 page_id TEXT NOT NULL REFERENCES pages(id),
473 schema_type TEXT NOT NULL,
474 data_json TEXT NOT NULL
475 );
476
477 CREATE TABLE IF NOT EXISTS crux_metrics (
478 id TEXT PRIMARY KEY,
479 page_id TEXT NOT NULL REFERENCES pages(id),
480 url TEXT NOT NULL,
481 lcp_p75 REAL,
482 inp_p75 REAL,
483 cls_p75 REAL,
484 fcp_p75 REAL,
485 ttfb_p75 REAL,
486 fetched_at DATETIME NOT NULL,
487 UNIQUE(page_id)
488 );
489
490 CREATE INDEX IF NOT EXISTS idx_pages_crawl ON pages(crawl_id);
491 CREATE INDEX IF NOT EXISTS idx_links_source ON links(source_url);
492 CREATE INDEX IF NOT EXISTS idx_links_target ON links(target_url);
493 CREATE INDEX IF NOT EXISTS idx_findings_page ON findings(page_id);
494 CREATE INDEX IF NOT EXISTS idx_findings_category ON findings(category);
495 CREATE INDEX IF NOT EXISTS idx_findings_severity ON findings(severity);
496 ",
497 )?;
498 Ok(())
499 }
500
501 pub fn start_crawl(
503 &self,
504 target_url: &str,
505 config_json: Option<&str>,
506 ) -> Result<String, StorageError> {
507 let crawl_id = uuid::Uuid::new_v4().to_string();
508 let conn = self.conn.lock();
509 conn.execute(
510 "INSERT INTO crawls (id, start_time, target_url, config_json) VALUES (?1, ?2, ?3, ?4)",
511 params![crawl_id, Utc::now().to_rfc3339(), target_url, config_json,],
512 )?;
513 Ok(crawl_id)
514 }
515
516 pub fn finish_crawl(
518 &self,
519 crawl_id: &str,
520 pages_crawled: usize,
521 total_issues: usize,
522 ) -> Result<(), StorageError> {
523 let conn = self.conn.lock();
524 conn.execute(
525 "UPDATE crawls SET end_time = ?1, pages_crawled = ?2, total_issues = ?3 WHERE id = ?4",
526 params![
527 Utc::now().to_rfc3339(),
528 pages_crawled,
529 total_issues,
530 crawl_id
531 ],
532 )?;
533 Ok(())
534 }
535
536 pub fn insert_page(&self, crawl_id: &str, page: &PageData) -> Result<(), StorageError> {
540 let conn = self.conn.lock();
541 let tx = conn.unchecked_transaction()?;
542
543 tx.execute(
544 "INSERT OR REPLACE INTO pages (id, crawl_id, url, final_url, status_code, title, description, canonical, word_count, load_time_ms, body_size, fetched_at)
545 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
546 params![
547 page.id,
548 crawl_id,
549 page.url.as_str(),
550 page.final_url.as_str(),
551 page.status_code,
552 page.title,
553 page.description,
554 page.canonical_url.as_ref().map(|u| u.as_str()),
555 page.word_count.map(|v| v as i64),
556 page.load_time_ms.map(|v| v as i64),
557 page.body_size.map(|v| v as i64),
558 page.fetched_at.to_rfc3339(),
559 ],
560 )?;
561
562 let mut stmt = tx.prepare(
564 "INSERT INTO links (id, page_id, source_url, target_url, is_external) VALUES (?1, ?2, ?3, ?4, ?5)",
565 )?;
566 for link in &page.links {
567 let link_id = uuid::Uuid::new_v4().to_string();
568 let is_external = link.domain() != page.url.domain();
569 stmt.execute(params![
570 link_id,
571 page.id,
572 page.url.as_str(),
573 link.as_str(),
574 is_external,
575 ])?;
576 }
577 drop(stmt);
578
579 tx.commit()?;
580 Ok(())
581 }
582
583 pub fn insert_pages(&self, crawl_id: &str, pages: &[PageData]) -> Result<(), StorageError> {
586 if pages.is_empty() {
587 return Ok(());
588 }
589 let conn = self.conn.lock();
590 let tx = conn.unchecked_transaction()?;
591
592 let mut page_stmt = tx.prepare(
593 "INSERT OR REPLACE INTO pages (id, crawl_id, url, final_url, status_code, title, description, canonical, word_count, load_time_ms, body_size, fetched_at)
594 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12)",
595 )?;
596 let mut link_stmt = tx.prepare(
597 "INSERT INTO links (id, page_id, source_url, target_url, is_external) VALUES (?1, ?2, ?3, ?4, ?5)",
598 )?;
599
600 for page in pages {
601 page_stmt.execute(params![
602 page.id,
603 crawl_id,
604 page.url.as_str(),
605 page.final_url.as_str(),
606 page.status_code,
607 page.title,
608 page.description,
609 page.canonical_url.as_ref().map(|u| u.as_str()),
610 page.word_count.map(|v| v as i64),
611 page.load_time_ms.map(|v| v as i64),
612 page.body_size.map(|v| v as i64),
613 page.fetched_at.to_rfc3339(),
614 ])?;
615
616 for link in &page.links {
617 let link_id = uuid::Uuid::new_v4().to_string();
618 let is_external = link.domain() != page.url.domain();
619 link_stmt.execute(params![
620 link_id,
621 page.id,
622 page.url.as_str(),
623 link.as_str(),
624 is_external,
625 ])?;
626 }
627 }
628
629 drop(link_stmt);
630 drop(page_stmt);
631 tx.commit()?;
632 Ok(())
633 }
634
635 pub fn insert_issue(&self, issue: &Issue) -> Result<(), StorageError> {
637 let conn = self.conn.lock();
638 conn.execute(
639 "INSERT INTO findings (id, page_id, category, severity, code, title, description, element, recommendation)
640 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
641 params![
642 issue.id,
643 issue.page_id,
644 issue.category.as_str(),
645 issue.severity.as_str(),
646 issue.code,
647 issue.title,
648 issue.description,
649 issue.element,
650 issue.recommendation,
651 ],
652 )?;
653 Ok(())
654 }
655
656 pub fn insert_issues(&self, issues: &[Issue]) -> Result<(), StorageError> {
658 let conn = self.conn.lock();
659 let mut stmt = conn.prepare(
660 "INSERT INTO findings (id, page_id, category, severity, code, title, description, element, recommendation)
661 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
662 )?;
663 for issue in issues {
664 stmt.execute(params![
665 issue.id,
666 issue.page_id,
667 issue.category.as_str(),
668 issue.severity.as_str(),
669 issue.code,
670 issue.title,
671 issue.description,
672 issue.element,
673 issue.recommendation,
674 ])?;
675 }
676 Ok(())
677 }
678
679 pub fn get_pages(&self, crawl_id: &str, limit: usize) -> Result<Vec<PageData>, StorageError> {
684 let conn = self.conn.lock();
685 let mut stmt = conn.prepare(
686 "SELECT id, url, final_url, status_code, title, description, canonical, word_count, load_time_ms, body_size, fetched_at
687 FROM pages WHERE crawl_id = ?1 ORDER BY fetched_at ASC LIMIT ?2",
688 )?;
689
690 let pages = stmt
691 .query_map(params![crawl_id, limit as i64], |row| {
692 let url_str: String = row.get(1)?;
693 let final_url_str: String = row.get(2)?;
694 let canonical_str: Option<String> = row.get(6)?;
695 let fetched_at_str: String = row.get(10)?;
696
697 Ok(PageData {
698 id: row.get(0)?,
699 url: Url::parse(&url_str)
700 .unwrap_or_else(|_| unreachable!("about:invalid is always a valid URL")),
701 final_url: Url::parse(&final_url_str)
702 .unwrap_or_else(|_| unreachable!("about:invalid is always a valid URL")),
703 status_code: row.get(3)?,
704 title: row.get(4)?,
705 description: row.get(5)?,
706 canonical_url: canonical_str.and_then(|s| Url::parse(&s).ok()),
707 word_count: row.get::<_, Option<i64>>(7)?.map(|v| v as usize),
708 load_time_ms: row.get::<_, Option<i64>>(8)?.map(|v| v as u64),
709 body_size: row.get::<_, Option<i64>>(9)?.map(|v| v as usize),
710 fetched_at: DateTime::parse_from_rfc3339(&fetched_at_str)
711 .map(|dt| dt.with_timezone(&Utc))
712 .unwrap_or_else(|_| Utc::now()),
713 links: Vec::new(),
714 })
715 })?
716 .collect::<Result<Vec<_>, _>>()?;
717
718 Ok(pages)
719 }
720
721 pub fn get_issues(
723 &self,
724 crawl_id: &str,
725 filters: &IssueFilter,
726 ) -> Result<Vec<Issue>, StorageError> {
727 let conn = self.conn.lock();
728
729 let mut query = String::from(
730 "SELECT f.id, f.page_id, f.category, f.severity, f.code, f.title, f.description, f.element, f.recommendation
731 FROM findings f
732 JOIN pages p ON f.page_id = p.id
733 WHERE p.crawl_id = ?1",
734 );
735 let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
736 param_values.push(Box::new(crawl_id.to_string()));
737
738 if let Some(ref severity) = filters.severity {
739 query.push_str(&format!(" AND f.severity = ?{}", param_values.len() + 1));
740 param_values.push(Box::new(severity.as_str().to_string()));
741 }
742 if let Some(ref category) = filters.category {
743 query.push_str(&format!(" AND f.category = ?{}", param_values.len() + 1));
744 param_values.push(Box::new(category.as_str()));
745 }
746 if let Some(ref page_id) = filters.page_id {
747 query.push_str(&format!(" AND f.page_id = ?{}", param_values.len() + 1));
748 param_values.push(Box::new(page_id.clone()));
749 }
750 if let Some(ref code_prefix) = filters.code_prefix {
751 query.push_str(&format!(" AND f.code LIKE ?{}", param_values.len() + 1));
752 param_values.push(Box::new(format!("{code_prefix}%")));
753 }
754
755 query.push_str(" ORDER BY f.id ASC");
756
757 let mut stmt = conn.prepare(&query)?;
758 let params_refs: Vec<&dyn rusqlite::types::ToSql> =
759 param_values.iter().map(|p| p.as_ref()).collect();
760
761 let issues = stmt
762 .query_map(params_refs.as_slice(), |row| {
763 let category_str: String = row.get(2)?;
764 let severity_str: String = row.get(3)?;
765
766 Ok(Issue {
767 id: row.get(0)?,
768 page_id: row.get(1)?,
769 category: IssueCategory::parse_category(&category_str),
770 severity: Severity::parse_severity(&severity_str).unwrap_or(Severity::Info),
771 code: row.get(4)?,
772 title: row.get(5)?,
773 description: row.get(6)?,
774 element: row.get(7)?,
775 recommendation: row.get(8)?,
776 })
777 })?
778 .collect::<Result<Vec<_>, _>>()?;
779
780 Ok(issues)
781 }
782
783 pub fn get_stats(&self, crawl_id: &str) -> Result<CrawlStats, StorageError> {
785 let conn = self.conn.lock();
786
787 let total_pages: usize = conn.query_row(
788 "SELECT COALESCE(COUNT(*), 0) FROM pages WHERE crawl_id = ?1",
789 params![crawl_id],
790 |row| row.get::<_, i64>(0),
791 )? as usize;
792
793 let total_issues: usize = conn
794 .query_row(
795 "SELECT COALESCE(COUNT(*), 0) FROM findings f JOIN pages p ON f.page_id = p.id WHERE p.crawl_id = ?1",
796 params![crawl_id],
797 |row| row.get::<_, i64>(0),
798 )?
799 as usize;
800
801 let mut issues_by_severity = std::collections::HashMap::new();
802 {
803 let mut stmt = conn.prepare(
804 "SELECT f.severity, COUNT(*) FROM findings f JOIN pages p ON f.page_id = p.id WHERE p.crawl_id = ?1 GROUP BY f.severity",
805 )?;
806 let rows = stmt.query_map(params![crawl_id], |row| {
807 let sev: String = row.get(0)?;
808 let count: i64 = row.get(1)?;
809 Ok((sev, count as usize))
810 })?;
811 for row in rows {
812 let (sev, count) = row?;
813 issues_by_severity.insert(sev, count);
814 }
815 }
816
817 let mut issues_by_category = std::collections::HashMap::new();
818 {
819 let mut stmt = conn.prepare(
820 "SELECT f.category, COUNT(*) FROM findings f JOIN pages p ON f.page_id = p.id WHERE p.crawl_id = ?1 GROUP BY f.category",
821 )?;
822 let rows = stmt.query_map(params![crawl_id], |row| {
823 let cat: String = row.get(0)?;
824 let count: i64 = row.get(1)?;
825 Ok((cat, count as usize))
826 })?;
827 for row in rows {
828 let (cat, count) = row?;
829 issues_by_category.insert(cat, count);
830 }
831 }
832
833 let avg_response_time_ms: Option<f64> = conn
834 .query_row(
835 "SELECT AVG(load_time_ms) FROM pages WHERE crawl_id = ?1 AND load_time_ms IS NOT NULL",
836 params![crawl_id],
837 |row| row.get::<_, Option<f64>>(0),
838 )
839 .ok()
840 .flatten();
841
842 let total_body_size: Option<usize> = conn
843 .query_row(
844 "SELECT SUM(body_size) FROM pages WHERE crawl_id = ?1 AND body_size IS NOT NULL",
845 params![crawl_id],
846 |row| row.get::<_, Option<i64>>(0),
847 )
848 .ok()
849 .flatten()
850 .map(|v| v as usize);
851
852 Ok(CrawlStats {
853 total_pages,
854 total_issues,
855 issues_by_severity,
856 issues_by_category,
857 avg_response_time_ms,
858 total_body_size,
859 })
860 }
861
862 pub fn get_latest_crawl_id(&self) -> Result<Option<String>, StorageError> {
864 let conn = self.conn.lock();
865 let result = conn.query_row(
866 "SELECT id FROM crawls ORDER BY start_time DESC LIMIT 1",
867 [],
868 |row| row.get::<_, String>(0),
869 );
870
871 match result {
872 Ok(id) => Ok(Some(id)),
873 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
874 Err(e) => Err(StorageError::Database(e)),
875 }
876 }
877
878 pub fn get_links_for_crawl(
883 &self,
884 crawl_id: &str,
885 ) -> Result<Vec<(String, Vec<String>)>, StorageError> {
886 let conn = self.conn.lock();
887 let mut stmt = conn.prepare(
888 "SELECT l.source_url, l.target_url
889 FROM links l
890 JOIN pages p ON l.page_id = p.id
891 WHERE p.crawl_id = ?1
892 ORDER BY l.source_url",
893 )?;
894
895 let rows = stmt.query_map(params![crawl_id], |row| {
896 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
897 })?;
898
899 let mut links: std::collections::HashMap<String, Vec<String>> =
900 std::collections::HashMap::new();
901 for row in rows {
902 let (source, target) = row?;
903 links.entry(source).or_default().push(target);
904 }
905
906 Ok(links.into_iter().collect())
907 }
908
909 pub fn get_external_links(
911 &self,
912 crawl_id: &str,
913 ) -> Result<Vec<(String, String)>, StorageError> {
914 let conn = self.conn.lock();
915 let mut stmt = conn.prepare(
916 "SELECT l.source_url, l.target_url
917 FROM links l
918 JOIN pages p ON l.page_id = p.id
919 WHERE p.crawl_id = ?1 AND l.is_external = 1",
920 )?;
921
922 let rows = stmt.query_map(params![crawl_id], |row| {
923 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
924 })?;
925
926 let mut links = Vec::new();
927 for row in rows {
928 links.push(row?);
929 }
930
931 Ok(links)
932 }
933
934 pub fn get_page_urls(&self, crawl_id: &str) -> Result<Vec<String>, StorageError> {
936 let conn = self.conn.lock();
937 let mut stmt = conn.prepare("SELECT url FROM pages WHERE crawl_id = ?1 ORDER BY url")?;
938
939 let rows = stmt.query_map(params![crawl_id], |row| row.get::<_, String>(0))?;
940
941 let mut urls = Vec::new();
942 for row in rows {
943 urls.push(row?);
944 }
945
946 Ok(urls)
947 }
948
949 pub fn insert_crux_metrics(
951 &self,
952 page_id: &str,
953 url: &str,
954 lcp_p75: Option<f64>,
955 inp_p75: Option<f64>,
956 cls_p75: Option<f64>,
957 fcp_p75: Option<f64>,
958 ttfb_p75: Option<f64>,
959 ) -> Result<(), StorageError> {
960 let conn = self.conn.lock();
961 conn.execute(
962 "INSERT OR REPLACE INTO crux_metrics (id, page_id, url, lcp_p75, inp_p75, cls_p75, fcp_p75, ttfb_p75, fetched_at)
963 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
964 params![
965 uuid::Uuid::new_v4().to_string(),
966 page_id,
967 url,
968 lcp_p75,
969 inp_p75,
970 cls_p75,
971 fcp_p75,
972 ttfb_p75,
973 chrono::Utc::now().to_rfc3339(),
974 ],
975 )?;
976 Ok(())
977 }
978
979 pub fn get_crux_metrics(&self, page_id: &str) -> Result<Option<CruxMetrics>, StorageError> {
981 let conn = self.conn.lock();
982 let result = conn.query_row(
983 "SELECT url, lcp_p75, inp_p75, cls_p75, fcp_p75, ttfb_p75 FROM crux_metrics WHERE page_id = ?1",
984 params![page_id],
985 |row| {
986 Ok(CruxMetrics {
987 url: row.get(0)?,
988 lcp_p75: row.get(1)?,
989 inp_p75: row.get(2)?,
990 cls_p75: row.get(3)?,
991 fcp_p75: row.get(4)?,
992 ttfb_p75: row.get(5)?,
993 })
994 },
995 );
996
997 match result {
998 Ok(m) => Ok(Some(m)),
999 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1000 Err(e) => Err(StorageError::Database(e)),
1001 }
1002 }
1003
1004 pub fn get_crux_metrics_for_crawl(
1006 &self,
1007 crawl_id: &str,
1008 ) -> Result<Vec<CruxMetrics>, StorageError> {
1009 let conn = self.conn.lock();
1010 let mut stmt = conn.prepare(
1011 "SELECT cm.url, cm.lcp_p75, cm.inp_p75, cm.cls_p75, cm.fcp_p75, cm.ttfb_p75
1012 FROM crux_metrics cm
1013 JOIN pages p ON cm.page_id = p.id
1014 WHERE p.crawl_id = ?1",
1015 )?;
1016
1017 let rows = stmt.query_map(params![crawl_id], |row| {
1018 Ok(CruxMetrics {
1019 url: row.get(0)?,
1020 lcp_p75: row.get(1)?,
1021 inp_p75: row.get(2)?,
1022 cls_p75: row.get(3)?,
1023 fcp_p75: row.get(4)?,
1024 ttfb_p75: row.get(5)?,
1025 })
1026 })?;
1027
1028 let mut metrics = Vec::new();
1029 for row in rows {
1030 metrics.push(row?);
1031 }
1032
1033 Ok(metrics)
1034 }
1035}
1036
1037#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1039pub struct CruxMetrics {
1040 pub url: String,
1041 pub lcp_p75: Option<f64>,
1042 pub inp_p75: Option<f64>,
1043 pub cls_p75: Option<f64>,
1044 pub fcp_p75: Option<f64>,
1045 pub ttfb_p75: Option<f64>,
1046}
1047
1048#[cfg(test)]
1049#[allow(clippy::unwrap_used)]
1050mod tests {
1051 use super::*;
1052
1053 fn test_page(id: &str, url: &str, status: u16) -> PageData {
1054 PageData {
1055 id: id.to_string(),
1056 url: Url::parse(url).unwrap(),
1057 final_url: Url::parse(url).unwrap(),
1058 status_code: status,
1059 title: Some(format!("Page {id}")),
1060 description: None,
1061 canonical_url: None,
1062 word_count: Some(500),
1063 load_time_ms: Some(200),
1064 body_size: Some(1024),
1065 fetched_at: Utc::now(),
1066 links: vec![],
1067 }
1068 }
1069
1070 fn test_issue(id: &str, page_id: &str, category: IssueCategory, severity: Severity) -> Issue {
1071 Issue {
1072 id: id.to_string(),
1073 page_id: page_id.to_string(),
1074 category,
1075 severity,
1076 code: format!("{}001", id),
1077 title: format!("Issue {id}"),
1078 description: format!("Description for issue {id}"),
1079 element: None,
1080 recommendation: "Fix this".to_string(),
1081 }
1082 }
1083
1084 #[test]
1085 fn test_schema_creation() {
1086 let storage = Storage::new_in_memory().unwrap();
1087 let crawl_id = storage.start_crawl("https://example.com", None).unwrap();
1089 assert!(!crawl_id.is_empty());
1090 }
1091
1092 #[test]
1093 fn test_insert_and_get_pages() {
1094 let storage = Storage::new_in_memory().unwrap();
1095 let crawl_id = storage.start_crawl("https://example.com", None).unwrap();
1096
1097 let pages = vec![
1098 test_page("p1", "https://example.com/", 200),
1099 test_page("p2", "https://example.com/about", 200),
1100 ];
1101 storage.insert_pages(&crawl_id, &pages).unwrap();
1102
1103 let retrieved = storage.get_pages(&crawl_id, 10).unwrap();
1104 assert_eq!(retrieved.len(), 2);
1105 assert_eq!(retrieved[0].id, "p1");
1106 assert_eq!(retrieved[1].id, "p2");
1107 }
1108
1109 #[test]
1110 fn test_insert_and_get_issues() {
1111 let storage = Storage::new_in_memory().unwrap();
1112 let crawl_id = storage.start_crawl("https://example.com", None).unwrap();
1113
1114 let page = test_page("p1", "https://example.com/", 200);
1115 storage.insert_page(&crawl_id, &page).unwrap();
1116
1117 let issues = vec![
1118 test_issue("i1", "p1", IssueCategory::Seo, Severity::Error),
1119 test_issue("i2", "p1", IssueCategory::Images, Severity::Warning),
1120 ];
1121 storage.insert_issues(&issues).unwrap();
1122
1123 let retrieved = storage
1125 .get_issues(&crawl_id, &IssueFilter::default())
1126 .unwrap();
1127 assert_eq!(retrieved.len(), 2);
1128
1129 let filter = IssueFilter {
1131 severity: Some(Severity::Error),
1132 ..Default::default()
1133 };
1134 let retrieved = storage.get_issues(&crawl_id, &filter).unwrap();
1135 assert_eq!(retrieved.len(), 1);
1136 assert_eq!(retrieved[0].code, "i1001");
1137
1138 let filter = IssueFilter {
1140 category: Some(IssueCategory::Images),
1141 ..Default::default()
1142 };
1143 let retrieved = storage.get_issues(&crawl_id, &filter).unwrap();
1144 assert_eq!(retrieved.len(), 1);
1145 assert_eq!(retrieved[0].code, "i2001");
1146 }
1147
1148 #[test]
1149 fn test_get_stats() {
1150 let storage = Storage::new_in_memory().unwrap();
1151 let crawl_id = storage.start_crawl("https://example.com", None).unwrap();
1152
1153 let pages = vec![
1154 test_page("p1", "https://example.com/", 200),
1155 test_page("p2", "https://example.com/about", 200),
1156 ];
1157 storage.insert_pages(&crawl_id, &pages).unwrap();
1158
1159 let issues = vec![
1160 test_issue("i1", "p1", IssueCategory::Seo, Severity::Error),
1161 test_issue("i2", "p1", IssueCategory::Seo, Severity::Warning),
1162 test_issue("i3", "p2", IssueCategory::Images, Severity::Warning),
1163 ];
1164 storage.insert_issues(&issues).unwrap();
1165
1166 let stats = storage.get_stats(&crawl_id).unwrap();
1167 assert_eq!(stats.total_pages, 2);
1168 assert_eq!(stats.total_issues, 3);
1169 assert_eq!(stats.issues_by_severity.get("error"), Some(&1));
1170 assert_eq!(stats.issues_by_severity.get("warning"), Some(&2));
1171 assert_eq!(stats.issues_by_category.get("seo"), Some(&2));
1172 assert_eq!(stats.issues_by_category.get("images"), Some(&1));
1173 }
1174
1175 #[test]
1176 fn test_finish_crawl() {
1177 let storage = Storage::new_in_memory().unwrap();
1178 let crawl_id = storage.start_crawl("https://example.com", None).unwrap();
1179
1180 let page = test_page("p1", "https://example.com/", 200);
1181 storage.insert_page(&crawl_id, &page).unwrap();
1182 let issue = test_issue("i1", "p1", IssueCategory::Seo, Severity::Error);
1183 storage.insert_issue(&issue).unwrap();
1184
1185 storage.finish_crawl(&crawl_id, 1, 1).unwrap();
1186 storage.finish_crawl(&crawl_id, 1, 1).unwrap();
1188 }
1189
1190 #[test]
1191 fn test_severity_roundtrip() {
1192 for sev in [
1193 Severity::Critical,
1194 Severity::Error,
1195 Severity::Warning,
1196 Severity::Info,
1197 ] {
1198 let s = sev.as_str();
1199 assert_eq!(Severity::parse_severity(s), Some(sev));
1200 }
1201 assert_eq!(Severity::parse_severity("invalid"), None);
1202 }
1203
1204 #[test]
1205 fn test_category_roundtrip() {
1206 for cat in [
1207 IssueCategory::Http,
1208 IssueCategory::Seo,
1209 IssueCategory::Content,
1210 IssueCategory::Links,
1211 IssueCategory::Images,
1212 IssueCategory::Schema,
1213 IssueCategory::Security,
1214 IssueCategory::Performance,
1215 IssueCategory::Mobile,
1216 IssueCategory::Accessibility,
1217 IssueCategory::Social,
1218 ] {
1219 let s = cat.as_str();
1220 assert_eq!(IssueCategory::parse_category(&s), cat);
1221 }
1222 let custom = IssueCategory::Custom("myplugin".to_string());
1223 let s = custom.as_str();
1224 assert_eq!(IssueCategory::parse_category(&s), custom);
1225 }
1226
1227 #[test]
1228 fn test_pages_limit() {
1229 let storage = Storage::new_in_memory().unwrap();
1230 let crawl_id = storage.start_crawl("https://example.com", None).unwrap();
1231
1232 for i in 0..10 {
1233 let page = test_page(
1234 &format!("p{i}"),
1235 &format!("https://example.com/page{i}"),
1236 200,
1237 );
1238 storage.insert_page(&crawl_id, &page).unwrap();
1239 }
1240
1241 let pages = storage.get_pages(&crawl_id, 3).unwrap();
1242 assert_eq!(pages.len(), 3);
1243 }
1244
1245 #[test]
1246 fn test_issue_filter_by_page_id() {
1247 let storage = Storage::new_in_memory().unwrap();
1248 let crawl_id = storage.start_crawl("https://example.com", None).unwrap();
1249
1250 let p1 = test_page("p1", "https://example.com/", 200);
1251 let p2 = test_page("p2", "https://example.com/about", 200);
1252 storage.insert_page(&crawl_id, &p1).unwrap();
1253 storage.insert_page(&crawl_id, &p2).unwrap();
1254
1255 let issues = vec![
1256 test_issue("i1", "p1", IssueCategory::Seo, Severity::Error),
1257 test_issue("i2", "p2", IssueCategory::Seo, Severity::Error),
1258 ];
1259 storage.insert_issues(&issues).unwrap();
1260
1261 let filter = IssueFilter {
1262 page_id: Some("p1".to_string()),
1263 ..Default::default()
1264 };
1265 let retrieved = storage.get_issues(&crawl_id, &filter).unwrap();
1266 assert_eq!(retrieved.len(), 1);
1267 assert_eq!(retrieved[0].page_id, "p1");
1268 }
1269}