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 pub tenant_id: Option<String>,
179}
180
181#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
186pub struct Issue {
187 pub id: String,
189 pub page_id: String,
191 pub category: IssueCategory,
193 pub severity: Severity,
195 pub code: String,
197 pub title: String,
199 pub description: String,
201 pub element: Option<String>,
203 pub recommendation: String,
205 pub tenant_id: Option<String>,
207}
208
209#[derive(Debug, Clone, Default)]
214pub struct IssueFilter {
215 pub severity: Option<Severity>,
217 pub category: Option<IssueCategory>,
219 pub page_id: Option<String>,
221 pub code_prefix: Option<String>,
223}
224
225#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
230pub struct CrawlStats {
231 pub total_pages: usize,
233 pub total_issues: usize,
235 pub issues_by_severity: std::collections::HashMap<String, usize>,
237 pub issues_by_category: std::collections::HashMap<String, usize>,
239 pub avg_response_time_ms: Option<f64>,
241 pub total_body_size: Option<usize>,
243}
244
245#[derive(Debug, Clone)]
247pub struct CacheStats {
248 pub capacity: usize,
250 pub size: usize,
252 pub hits: usize,
254 pub misses: usize,
256}
257
258pub struct Storage {
264 conn: Mutex<Connection>,
265 page_cache: Mutex<LruCache<String, PageData>>,
267 memory_usage: AtomicUsize,
269 mmap_enabled: bool,
271}
272
273impl Storage {
274 pub fn conn(&self) -> parking_lot::MutexGuard<'_, Connection> {
279 self.conn.lock()
280 }
281
282 pub fn new(path: &Path) -> Result<Self, StorageError> {
292 let conn = Connection::open(path)?;
293
294 conn.execute_batch(
297 "PRAGMA journal_mode=WAL;
298 PRAGMA foreign_keys=ON;
299 PRAGMA mmap_size=268435456;
300 PRAGMA cache_size=-64000;
301 PRAGMA synchronous=NORMAL;",
302 )?;
303
304 let storage = Self {
305 conn: Mutex::new(conn),
306 page_cache: Mutex::new(LruCache::new(
307 NonZeroUsize::new(1000).unwrap_or(NonZeroUsize::MIN),
308 )),
309 memory_usage: AtomicUsize::new(0),
310 mmap_enabled: true,
311 };
312 storage.create_schema()?;
313 Ok(storage)
314 }
315
316 pub fn new_in_memory() -> Result<Self, StorageError> {
326 let conn = Connection::open_in_memory()?;
327 conn.execute_batch(
328 "PRAGMA journal_mode=WAL;
329 PRAGMA foreign_keys=ON;
330 PRAGMA cache_size=-64000;",
331 )?;
332 let storage = Self {
333 conn: Mutex::new(conn),
334 page_cache: Mutex::new(LruCache::new(
335 NonZeroUsize::new(1000).unwrap_or(NonZeroUsize::MIN),
336 )),
337 memory_usage: AtomicUsize::new(0),
338 mmap_enabled: false,
339 };
340 storage.create_schema()?;
341 Ok(storage)
342 }
343
344 pub fn with_cache_size(path: &Path, cache_size: usize) -> Result<Self, StorageError> {
354 let conn = Connection::open(path)?;
355
356 conn.execute_batch(
357 "PRAGMA journal_mode=WAL;
358 PRAGMA foreign_keys=ON;
359 PRAGMA mmap_size=268435456;
360 PRAGMA cache_size=-64000;
361 PRAGMA synchronous=NORMAL;",
362 )?;
363
364 let storage = Self {
365 conn: Mutex::new(conn),
366 page_cache: Mutex::new(LruCache::new(
367 NonZeroUsize::new(cache_size).unwrap_or(NonZeroUsize::MIN),
368 )),
369 memory_usage: AtomicUsize::new(0),
370 mmap_enabled: true,
371 };
372 storage.create_schema()?;
373 Ok(storage)
374 }
375
376 pub fn memory_usage(&self) -> usize {
378 self.memory_usage.load(Ordering::Relaxed)
379 }
380
381 pub fn is_mmap_enabled(&self) -> bool {
383 self.mmap_enabled
384 }
385
386 pub fn cache_stats(&self) -> CacheStats {
388 let cache = self.page_cache.lock();
389 CacheStats {
390 capacity: cache.cap().get(),
391 size: cache.len(),
392 hits: 0, misses: 0,
394 }
395 }
396
397 pub fn clear_cache(&self) {
399 let mut cache = self.page_cache.lock();
400 let evicted = cache.len();
401 cache.clear();
402 if evicted > 0 {
403 self.memory_usage
404 .fetch_sub(evicted * std::mem::size_of::<PageData>(), Ordering::Relaxed);
405 }
406 }
407
408 fn create_schema(&self) -> Result<(), StorageError> {
410 let conn = self.conn.lock();
411 conn.execute_batch(
412 "
413 CREATE TABLE IF NOT EXISTS crawls (
414 id TEXT PRIMARY KEY,
415 start_time DATETIME NOT NULL,
416 end_time DATETIME,
417 target_url TEXT NOT NULL,
418 pages_crawled INTEGER DEFAULT 0,
419 total_issues INTEGER DEFAULT 0,
420 config_json TEXT
421 );
422
423 CREATE TABLE IF NOT EXISTS pages (
424 id TEXT PRIMARY KEY,
425 crawl_id TEXT NOT NULL REFERENCES crawls(id),
426 url TEXT NOT NULL,
427 final_url TEXT NOT NULL,
428 status_code INTEGER NOT NULL,
429 title TEXT,
430 description TEXT,
431 canonical TEXT,
432 word_count INTEGER,
433 load_time_ms INTEGER,
434 body_size INTEGER,
435 fetched_at DATETIME NOT NULL,
436 tenant_id TEXT,
437 UNIQUE(crawl_id, url)
438 );
439
440 CREATE TABLE IF NOT EXISTS links (
441 id TEXT PRIMARY KEY,
442 page_id TEXT NOT NULL REFERENCES pages(id),
443 source_url TEXT NOT NULL,
444 target_url TEXT NOT NULL,
445 anchor_text TEXT,
446 rel TEXT,
447 is_external BOOLEAN,
448 is_nofollow BOOLEAN
449 );
450
451 CREATE TABLE IF NOT EXISTS findings (
452 id TEXT PRIMARY KEY,
453 page_id TEXT NOT NULL REFERENCES pages(id),
454 category TEXT NOT NULL,
455 severity TEXT NOT NULL,
456 code TEXT NOT NULL,
457 title TEXT NOT NULL,
458 description TEXT NOT NULL,
459 element TEXT,
460 recommendation TEXT,
461 tenant_id TEXT
462 );
463
464 CREATE TABLE IF NOT EXISTS images (
465 id TEXT PRIMARY KEY,
466 page_id TEXT NOT NULL REFERENCES pages(id),
467 url TEXT NOT NULL,
468 alt TEXT,
469 width INTEGER,
470 height INTEGER,
471 format TEXT,
472 file_size INTEGER,
473 is_lazy_loaded BOOLEAN
474 );
475
476 CREATE TABLE IF NOT EXISTS schemas (
477 id TEXT PRIMARY KEY,
478 page_id TEXT NOT NULL REFERENCES pages(id),
479 schema_type TEXT NOT NULL,
480 data_json TEXT NOT NULL
481 );
482
483 CREATE TABLE IF NOT EXISTS crux_metrics (
484 id TEXT PRIMARY KEY,
485 page_id TEXT NOT NULL REFERENCES pages(id),
486 url TEXT NOT NULL,
487 lcp_p75 REAL,
488 inp_p75 REAL,
489 cls_p75 REAL,
490 fcp_p75 REAL,
491 ttfb_p75 REAL,
492 fetched_at DATETIME NOT NULL,
493 UNIQUE(page_id)
494 );
495
496 CREATE INDEX IF NOT EXISTS idx_pages_crawl ON pages(crawl_id);
497 CREATE INDEX IF NOT EXISTS idx_pages_tenant ON pages(tenant_id);
498 CREATE INDEX IF NOT EXISTS idx_links_source ON links(source_url);
499 CREATE INDEX IF NOT EXISTS idx_links_target ON links(target_url);
500 CREATE INDEX IF NOT EXISTS idx_findings_page ON findings(page_id);
501 CREATE INDEX IF NOT EXISTS idx_findings_category ON findings(category);
502 CREATE INDEX IF NOT EXISTS idx_findings_severity ON findings(severity);
503 CREATE INDEX IF NOT EXISTS idx_findings_tenant ON findings(tenant_id);
504 ",
505 )?;
506 Ok(())
507 }
508
509 pub fn start_crawl(
511 &self,
512 target_url: &str,
513 config_json: Option<&str>,
514 ) -> Result<String, StorageError> {
515 let crawl_id = uuid::Uuid::new_v4().to_string();
516 let conn = self.conn.lock();
517 conn.execute(
518 "INSERT INTO crawls (id, start_time, target_url, config_json) VALUES (?1, ?2, ?3, ?4)",
519 params![crawl_id, Utc::now().to_rfc3339(), target_url, config_json,],
520 )?;
521 Ok(crawl_id)
522 }
523
524 pub fn finish_crawl(
526 &self,
527 crawl_id: &str,
528 pages_crawled: usize,
529 total_issues: usize,
530 ) -> Result<(), StorageError> {
531 let conn = self.conn.lock();
532 conn.execute(
533 "UPDATE crawls SET end_time = ?1, pages_crawled = ?2, total_issues = ?3 WHERE id = ?4",
534 params![
535 Utc::now().to_rfc3339(),
536 pages_crawled,
537 total_issues,
538 crawl_id
539 ],
540 )?;
541 Ok(())
542 }
543
544 pub fn insert_page(&self, crawl_id: &str, page: &PageData) -> Result<(), StorageError> {
548 let conn = self.conn.lock();
549 let tx = conn.unchecked_transaction()?;
550
551 tx.execute(
552 "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, tenant_id)
553 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
554 params![
555 page.id,
556 crawl_id,
557 page.url.as_str(),
558 page.final_url.as_str(),
559 page.status_code,
560 page.title,
561 page.description,
562 page.canonical_url.as_ref().map(|u| u.as_str()),
563 page.word_count.map(|v| v as i64),
564 page.load_time_ms.map(|v| v as i64),
565 page.body_size.map(|v| v as i64),
566 page.fetched_at.to_rfc3339(),
567 page.tenant_id,
568 ],
569 )?;
570
571 let mut stmt = tx.prepare(
573 "INSERT INTO links (id, page_id, source_url, target_url, is_external) VALUES (?1, ?2, ?3, ?4, ?5)",
574 )?;
575 for link in &page.links {
576 let link_id = uuid::Uuid::new_v4().to_string();
577 let is_external = link.domain() != page.url.domain();
578 stmt.execute(params![
579 link_id,
580 page.id,
581 page.url.as_str(),
582 link.as_str(),
583 is_external,
584 ])?;
585 }
586 drop(stmt);
587
588 tx.commit()?;
589 Ok(())
590 }
591
592 pub fn insert_pages(&self, crawl_id: &str, pages: &[PageData]) -> Result<(), StorageError> {
595 if pages.is_empty() {
596 return Ok(());
597 }
598 let conn = self.conn.lock();
599 let tx = conn.unchecked_transaction()?;
600
601 let mut page_stmt = tx.prepare(
602 "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, tenant_id)
603 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10, ?11, ?12, ?13)",
604 )?;
605 let mut link_stmt = tx.prepare(
606 "INSERT INTO links (id, page_id, source_url, target_url, is_external) VALUES (?1, ?2, ?3, ?4, ?5)",
607 )?;
608
609 for page in pages {
610 page_stmt.execute(params![
611 page.id,
612 crawl_id,
613 page.url.as_str(),
614 page.final_url.as_str(),
615 page.status_code,
616 page.title,
617 page.description,
618 page.canonical_url.as_ref().map(|u| u.as_str()),
619 page.word_count.map(|v| v as i64),
620 page.load_time_ms.map(|v| v as i64),
621 page.body_size.map(|v| v as i64),
622 page.fetched_at.to_rfc3339(),
623 page.tenant_id,
624 ])?;
625
626 for link in &page.links {
627 let link_id = uuid::Uuid::new_v4().to_string();
628 let is_external = link.domain() != page.url.domain();
629 link_stmt.execute(params![
630 link_id,
631 page.id,
632 page.url.as_str(),
633 link.as_str(),
634 is_external,
635 ])?;
636 }
637 }
638
639 drop(link_stmt);
640 drop(page_stmt);
641 tx.commit()?;
642 Ok(())
643 }
644
645 pub fn insert_issue(&self, issue: &Issue) -> Result<(), StorageError> {
647 let conn = self.conn.lock();
648 conn.execute(
649 "INSERT INTO findings (id, page_id, category, severity, code, title, description, element, recommendation, tenant_id)
650 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
651 params![
652 issue.id,
653 issue.page_id,
654 issue.category.as_str(),
655 issue.severity.as_str(),
656 issue.code,
657 issue.title,
658 issue.description,
659 issue.element,
660 issue.recommendation,
661 issue.tenant_id,
662 ],
663 )?;
664 Ok(())
665 }
666
667 pub fn insert_issues(&self, issues: &[Issue]) -> Result<(), StorageError> {
669 let conn = self.conn.lock();
670 let mut stmt = conn.prepare(
671 "INSERT INTO findings (id, page_id, category, severity, code, title, description, element, recommendation, tenant_id)
672 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9, ?10)",
673 )?;
674 for issue in issues {
675 stmt.execute(params![
676 issue.id,
677 issue.page_id,
678 issue.category.as_str(),
679 issue.severity.as_str(),
680 issue.code,
681 issue.title,
682 issue.description,
683 issue.element,
684 issue.recommendation,
685 issue.tenant_id,
686 ])?;
687 }
688 Ok(())
689 }
690
691 pub fn get_pages(&self, crawl_id: &str, limit: usize) -> Result<Vec<PageData>, StorageError> {
696 let conn = self.conn.lock();
697 let mut stmt = conn.prepare(
698 "SELECT id, url, final_url, status_code, title, description, canonical, word_count, load_time_ms, body_size, fetched_at, tenant_id
699 FROM pages WHERE crawl_id = ?1 ORDER BY fetched_at ASC LIMIT ?2",
700 )?;
701
702 let pages = stmt
703 .query_map(params![crawl_id, limit as i64], |row| {
704 let url_str: String = row.get(1)?;
705 let final_url_str: String = row.get(2)?;
706 let canonical_str: Option<String> = row.get(6)?;
707 let fetched_at_str: String = row.get(10)?;
708
709 Ok(PageData {
710 id: row.get(0)?,
711 url: Url::parse(&url_str)
712 .unwrap_or_else(|_| unreachable!("about:invalid is always a valid URL")),
713 final_url: Url::parse(&final_url_str)
714 .unwrap_or_else(|_| unreachable!("about:invalid is always a valid URL")),
715 status_code: row.get(3)?,
716 title: row.get(4)?,
717 description: row.get(5)?,
718 canonical_url: canonical_str.and_then(|s| Url::parse(&s).ok()),
719 word_count: row.get::<_, Option<i64>>(7)?.map(|v| v as usize),
720 load_time_ms: row.get::<_, Option<i64>>(8)?.map(|v| v as u64),
721 body_size: row.get::<_, Option<i64>>(9)?.map(|v| v as usize),
722 fetched_at: DateTime::parse_from_rfc3339(&fetched_at_str)
723 .map(|dt| dt.with_timezone(&Utc))
724 .unwrap_or_else(|_| Utc::now()),
725 links: Vec::new(),
726 tenant_id: row.get(11)?,
727 })
728 })?
729 .collect::<Result<Vec<_>, _>>()?;
730
731 Ok(pages)
732 }
733
734 pub fn get_issues(
736 &self,
737 crawl_id: &str,
738 filters: &IssueFilter,
739 ) -> Result<Vec<Issue>, StorageError> {
740 let conn = self.conn.lock();
741
742 let mut query = String::from(
743 "SELECT f.id, f.page_id, f.category, f.severity, f.code, f.title, f.description, f.element, f.recommendation, f.tenant_id
744 FROM findings f
745 JOIN pages p ON f.page_id = p.id
746 WHERE p.crawl_id = ?1",
747 );
748 let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
749 param_values.push(Box::new(crawl_id.to_string()));
750
751 if let Some(ref severity) = filters.severity {
752 query.push_str(&format!(" AND f.severity = ?{}", param_values.len() + 1));
753 param_values.push(Box::new(severity.as_str().to_string()));
754 }
755 if let Some(ref category) = filters.category {
756 query.push_str(&format!(" AND f.category = ?{}", param_values.len() + 1));
757 param_values.push(Box::new(category.as_str()));
758 }
759 if let Some(ref page_id) = filters.page_id {
760 query.push_str(&format!(" AND f.page_id = ?{}", param_values.len() + 1));
761 param_values.push(Box::new(page_id.clone()));
762 }
763 if let Some(ref code_prefix) = filters.code_prefix {
764 query.push_str(&format!(" AND f.code LIKE ?{}", param_values.len() + 1));
765 param_values.push(Box::new(format!("{code_prefix}%")));
766 }
767
768 query.push_str(" ORDER BY f.id ASC");
769
770 let mut stmt = conn.prepare(&query)?;
771 let params_refs: Vec<&dyn rusqlite::types::ToSql> =
772 param_values.iter().map(|p| p.as_ref()).collect();
773
774 let issues = stmt
775 .query_map(params_refs.as_slice(), |row| {
776 let category_str: String = row.get(2)?;
777 let severity_str: String = row.get(3)?;
778
779 Ok(Issue {
780 id: row.get(0)?,
781 page_id: row.get(1)?,
782 category: IssueCategory::parse_category(&category_str),
783 severity: Severity::parse_severity(&severity_str).unwrap_or(Severity::Info),
784 code: row.get(4)?,
785 title: row.get(5)?,
786 description: row.get(6)?,
787 element: row.get(7)?,
788 recommendation: row.get(8)?,
789 tenant_id: row.get(9)?,
790 })
791 })?
792 .collect::<Result<Vec<_>, _>>()?;
793
794 Ok(issues)
795 }
796
797 pub fn get_pages_for_tenant(
803 &self,
804 crawl_id: &str,
805 tenant_id: &str,
806 limit: usize,
807 ) -> Result<Vec<PageData>, StorageError> {
808 let conn = self.conn.lock();
809 let mut stmt = conn.prepare(
810 "SELECT id, url, final_url, status_code, title, description, canonical, word_count, load_time_ms, body_size, fetched_at, tenant_id
811 FROM pages WHERE crawl_id = ?1 AND (tenant_id = ?2 OR tenant_id IS NULL)
812 ORDER BY fetched_at ASC LIMIT ?3",
813 )?;
814
815 let pages = stmt
816 .query_map(params![crawl_id, tenant_id, limit as i64], |row| {
817 let url_str: String = row.get(1)?;
818 let final_url_str: String = row.get(2)?;
819 let canonical_str: Option<String> = row.get(6)?;
820 let fetched_at_str: String = row.get(10)?;
821
822 Ok(PageData {
823 id: row.get(0)?,
824 url: Url::parse(&url_str)
825 .unwrap_or_else(|_| unreachable!("about:invalid is always a valid URL")),
826 final_url: Url::parse(&final_url_str)
827 .unwrap_or_else(|_| unreachable!("about:invalid is always a valid URL")),
828 status_code: row.get(3)?,
829 title: row.get(4)?,
830 description: row.get(5)?,
831 canonical_url: canonical_str.and_then(|s| Url::parse(&s).ok()),
832 word_count: row.get::<_, Option<i64>>(7)?.map(|v| v as usize),
833 load_time_ms: row.get::<_, Option<i64>>(8)?.map(|v| v as u64),
834 body_size: row.get::<_, Option<i64>>(9)?.map(|v| v as usize),
835 fetched_at: DateTime::parse_from_rfc3339(&fetched_at_str)
836 .map(|dt| dt.with_timezone(&Utc))
837 .unwrap_or_else(|_| Utc::now()),
838 links: Vec::new(),
839 tenant_id: row.get(11)?,
840 })
841 })?
842 .collect::<Result<Vec<_>, _>>()?;
843
844 Ok(pages)
845 }
846
847 pub fn get_issues_for_tenant(
853 &self,
854 crawl_id: &str,
855 tenant_id: &str,
856 filters: &IssueFilter,
857 ) -> Result<Vec<Issue>, StorageError> {
858 let conn = self.conn.lock();
859
860 let mut query = String::from(
861 "SELECT f.id, f.page_id, f.category, f.severity, f.code, f.title, f.description, f.element, f.recommendation, f.tenant_id
862 FROM findings f
863 JOIN pages p ON f.page_id = p.id
864 WHERE p.crawl_id = ?1 AND (f.tenant_id = ?2 OR f.tenant_id IS NULL)",
865 );
866 let mut param_values: Vec<Box<dyn rusqlite::types::ToSql>> = Vec::new();
867 param_values.push(Box::new(crawl_id.to_string()));
868 param_values.push(Box::new(tenant_id.to_string()));
869
870 if let Some(ref severity) = filters.severity {
871 query.push_str(&format!(" AND f.severity = ?{}", param_values.len() + 1));
872 param_values.push(Box::new(severity.as_str().to_string()));
873 }
874 if let Some(ref category) = filters.category {
875 query.push_str(&format!(" AND f.category = ?{}", param_values.len() + 1));
876 param_values.push(Box::new(category.as_str()));
877 }
878 if let Some(ref page_id) = filters.page_id {
879 query.push_str(&format!(" AND f.page_id = ?{}", param_values.len() + 1));
880 param_values.push(Box::new(page_id.clone()));
881 }
882 if let Some(ref code_prefix) = filters.code_prefix {
883 query.push_str(&format!(" AND f.code LIKE ?{}", param_values.len() + 1));
884 param_values.push(Box::new(format!("{code_prefix}%")));
885 }
886
887 query.push_str(" ORDER BY f.id ASC");
888
889 let mut stmt = conn.prepare(&query)?;
890 let params_refs: Vec<&dyn rusqlite::types::ToSql> =
891 param_values.iter().map(|p| p.as_ref()).collect();
892
893 let issues = stmt
894 .query_map(params_refs.as_slice(), |row| {
895 let category_str: String = row.get(2)?;
896 let severity_str: String = row.get(3)?;
897
898 Ok(Issue {
899 id: row.get(0)?,
900 page_id: row.get(1)?,
901 category: IssueCategory::parse_category(&category_str),
902 severity: Severity::parse_severity(&severity_str).unwrap_or(Severity::Info),
903 code: row.get(4)?,
904 title: row.get(5)?,
905 description: row.get(6)?,
906 element: row.get(7)?,
907 recommendation: row.get(8)?,
908 tenant_id: row.get(9)?,
909 })
910 })?
911 .collect::<Result<Vec<_>, _>>()?;
912
913 Ok(issues)
914 }
915
916 pub fn get_stats(&self, crawl_id: &str) -> Result<CrawlStats, StorageError> {
918 let conn = self.conn.lock();
919
920 let total_pages: usize = conn.query_row(
921 "SELECT COALESCE(COUNT(*), 0) FROM pages WHERE crawl_id = ?1",
922 params![crawl_id],
923 |row| row.get::<_, i64>(0),
924 )? as usize;
925
926 let total_issues: usize = conn
927 .query_row(
928 "SELECT COALESCE(COUNT(*), 0) FROM findings f JOIN pages p ON f.page_id = p.id WHERE p.crawl_id = ?1",
929 params![crawl_id],
930 |row| row.get::<_, i64>(0),
931 )?
932 as usize;
933
934 let mut issues_by_severity = std::collections::HashMap::new();
935 {
936 let mut stmt = conn.prepare(
937 "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",
938 )?;
939 let rows = stmt.query_map(params![crawl_id], |row| {
940 let sev: String = row.get(0)?;
941 let count: i64 = row.get(1)?;
942 Ok((sev, count as usize))
943 })?;
944 for row in rows {
945 let (sev, count) = row?;
946 issues_by_severity.insert(sev, count);
947 }
948 }
949
950 let mut issues_by_category = std::collections::HashMap::new();
951 {
952 let mut stmt = conn.prepare(
953 "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",
954 )?;
955 let rows = stmt.query_map(params![crawl_id], |row| {
956 let cat: String = row.get(0)?;
957 let count: i64 = row.get(1)?;
958 Ok((cat, count as usize))
959 })?;
960 for row in rows {
961 let (cat, count) = row?;
962 issues_by_category.insert(cat, count);
963 }
964 }
965
966 let avg_response_time_ms: Option<f64> = conn
967 .query_row(
968 "SELECT AVG(load_time_ms) FROM pages WHERE crawl_id = ?1 AND load_time_ms IS NOT NULL",
969 params![crawl_id],
970 |row| row.get::<_, Option<f64>>(0),
971 )
972 .ok()
973 .flatten();
974
975 let total_body_size: Option<usize> = conn
976 .query_row(
977 "SELECT SUM(body_size) FROM pages WHERE crawl_id = ?1 AND body_size IS NOT NULL",
978 params![crawl_id],
979 |row| row.get::<_, Option<i64>>(0),
980 )
981 .ok()
982 .flatten()
983 .map(|v| v as usize);
984
985 Ok(CrawlStats {
986 total_pages,
987 total_issues,
988 issues_by_severity,
989 issues_by_category,
990 avg_response_time_ms,
991 total_body_size,
992 })
993 }
994
995 pub fn get_latest_crawl_id(&self) -> Result<Option<String>, StorageError> {
997 let conn = self.conn.lock();
998 let result = conn.query_row(
999 "SELECT id FROM crawls ORDER BY start_time DESC LIMIT 1",
1000 [],
1001 |row| row.get::<_, String>(0),
1002 );
1003
1004 match result {
1005 Ok(id) => Ok(Some(id)),
1006 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1007 Err(e) => Err(StorageError::Database(e)),
1008 }
1009 }
1010
1011 pub fn get_links_for_crawl(
1016 &self,
1017 crawl_id: &str,
1018 ) -> Result<Vec<(String, Vec<String>)>, StorageError> {
1019 let conn = self.conn.lock();
1020 let mut stmt = conn.prepare(
1021 "SELECT l.source_url, l.target_url
1022 FROM links l
1023 JOIN pages p ON l.page_id = p.id
1024 WHERE p.crawl_id = ?1
1025 ORDER BY l.source_url",
1026 )?;
1027
1028 let rows = stmt.query_map(params![crawl_id], |row| {
1029 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1030 })?;
1031
1032 let mut links: std::collections::HashMap<String, Vec<String>> =
1033 std::collections::HashMap::new();
1034 for row in rows {
1035 let (source, target) = row?;
1036 links.entry(source).or_default().push(target);
1037 }
1038
1039 Ok(links.into_iter().collect())
1040 }
1041
1042 pub fn get_external_links(
1044 &self,
1045 crawl_id: &str,
1046 ) -> Result<Vec<(String, String)>, StorageError> {
1047 let conn = self.conn.lock();
1048 let mut stmt = conn.prepare(
1049 "SELECT l.source_url, l.target_url
1050 FROM links l
1051 JOIN pages p ON l.page_id = p.id
1052 WHERE p.crawl_id = ?1 AND l.is_external = 1",
1053 )?;
1054
1055 let rows = stmt.query_map(params![crawl_id], |row| {
1056 Ok((row.get::<_, String>(0)?, row.get::<_, String>(1)?))
1057 })?;
1058
1059 let mut links = Vec::new();
1060 for row in rows {
1061 links.push(row?);
1062 }
1063
1064 Ok(links)
1065 }
1066
1067 pub fn get_page_urls(&self, crawl_id: &str) -> Result<Vec<String>, StorageError> {
1069 let conn = self.conn.lock();
1070 let mut stmt = conn.prepare("SELECT url FROM pages WHERE crawl_id = ?1 ORDER BY url")?;
1071
1072 let rows = stmt.query_map(params![crawl_id], |row| row.get::<_, String>(0))?;
1073
1074 let mut urls = Vec::new();
1075 for row in rows {
1076 urls.push(row?);
1077 }
1078
1079 Ok(urls)
1080 }
1081
1082 pub fn insert_crux_metrics(
1084 &self,
1085 page_id: &str,
1086 url: &str,
1087 lcp_p75: Option<f64>,
1088 inp_p75: Option<f64>,
1089 cls_p75: Option<f64>,
1090 fcp_p75: Option<f64>,
1091 ttfb_p75: Option<f64>,
1092 ) -> Result<(), StorageError> {
1093 let conn = self.conn.lock();
1094 conn.execute(
1095 "INSERT OR REPLACE INTO crux_metrics (id, page_id, url, lcp_p75, inp_p75, cls_p75, fcp_p75, ttfb_p75, fetched_at)
1096 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7, ?8, ?9)",
1097 params![
1098 uuid::Uuid::new_v4().to_string(),
1099 page_id,
1100 url,
1101 lcp_p75,
1102 inp_p75,
1103 cls_p75,
1104 fcp_p75,
1105 ttfb_p75,
1106 chrono::Utc::now().to_rfc3339(),
1107 ],
1108 )?;
1109 Ok(())
1110 }
1111
1112 pub fn get_crux_metrics(&self, page_id: &str) -> Result<Option<CruxMetrics>, StorageError> {
1114 let conn = self.conn.lock();
1115 let result = conn.query_row(
1116 "SELECT url, lcp_p75, inp_p75, cls_p75, fcp_p75, ttfb_p75 FROM crux_metrics WHERE page_id = ?1",
1117 params![page_id],
1118 |row| {
1119 Ok(CruxMetrics {
1120 url: row.get(0)?,
1121 lcp_p75: row.get(1)?,
1122 inp_p75: row.get(2)?,
1123 cls_p75: row.get(3)?,
1124 fcp_p75: row.get(4)?,
1125 ttfb_p75: row.get(5)?,
1126 })
1127 },
1128 );
1129
1130 match result {
1131 Ok(m) => Ok(Some(m)),
1132 Err(rusqlite::Error::QueryReturnedNoRows) => Ok(None),
1133 Err(e) => Err(StorageError::Database(e)),
1134 }
1135 }
1136
1137 pub fn get_crux_metrics_for_crawl(
1139 &self,
1140 crawl_id: &str,
1141 ) -> Result<Vec<CruxMetrics>, StorageError> {
1142 let conn = self.conn.lock();
1143 let mut stmt = conn.prepare(
1144 "SELECT cm.url, cm.lcp_p75, cm.inp_p75, cm.cls_p75, cm.fcp_p75, cm.ttfb_p75
1145 FROM crux_metrics cm
1146 JOIN pages p ON cm.page_id = p.id
1147 WHERE p.crawl_id = ?1",
1148 )?;
1149
1150 let rows = stmt.query_map(params![crawl_id], |row| {
1151 Ok(CruxMetrics {
1152 url: row.get(0)?,
1153 lcp_p75: row.get(1)?,
1154 inp_p75: row.get(2)?,
1155 cls_p75: row.get(3)?,
1156 fcp_p75: row.get(4)?,
1157 ttfb_p75: row.get(5)?,
1158 })
1159 })?;
1160
1161 let mut metrics = Vec::new();
1162 for row in rows {
1163 metrics.push(row?);
1164 }
1165
1166 Ok(metrics)
1167 }
1168}
1169
1170#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
1172pub struct CruxMetrics {
1173 pub url: String,
1174 pub lcp_p75: Option<f64>,
1175 pub inp_p75: Option<f64>,
1176 pub cls_p75: Option<f64>,
1177 pub fcp_p75: Option<f64>,
1178 pub ttfb_p75: Option<f64>,
1179}
1180
1181#[cfg(test)]
1182#[allow(clippy::unwrap_used)]
1183mod tests {
1184 use super::*;
1185
1186 fn test_page(id: &str, url: &str, status: u16) -> PageData {
1187 PageData {
1188 id: id.to_string(),
1189 url: Url::parse(url).unwrap(),
1190 final_url: Url::parse(url).unwrap(),
1191 status_code: status,
1192 title: Some(format!("Page {id}")),
1193 description: None,
1194 canonical_url: None,
1195 word_count: Some(500),
1196 load_time_ms: Some(200),
1197 body_size: Some(1024),
1198 fetched_at: Utc::now(),
1199 links: vec![],
1200 tenant_id: None,
1201 }
1202 }
1203
1204 fn test_issue(id: &str, page_id: &str, category: IssueCategory, severity: Severity) -> Issue {
1205 Issue {
1206 id: id.to_string(),
1207 page_id: page_id.to_string(),
1208 category,
1209 severity,
1210 code: format!("{}001", id),
1211 title: format!("Issue {id}"),
1212 description: format!("Description for issue {id}"),
1213 element: None,
1214 recommendation: "Fix this".to_string(),
1215 tenant_id: None,
1216 }
1217 }
1218
1219 #[test]
1220 fn test_schema_creation() {
1221 let storage = Storage::new_in_memory().unwrap();
1222 let crawl_id = storage.start_crawl("https://example.com", None).unwrap();
1224 assert!(!crawl_id.is_empty());
1225 }
1226
1227 #[test]
1228 fn test_insert_and_get_pages() {
1229 let storage = Storage::new_in_memory().unwrap();
1230 let crawl_id = storage.start_crawl("https://example.com", None).unwrap();
1231
1232 let pages = vec![
1233 test_page("p1", "https://example.com/", 200),
1234 test_page("p2", "https://example.com/about", 200),
1235 ];
1236 storage.insert_pages(&crawl_id, &pages).unwrap();
1237
1238 let retrieved = storage.get_pages(&crawl_id, 10).unwrap();
1239 assert_eq!(retrieved.len(), 2);
1240 assert_eq!(retrieved[0].id, "p1");
1241 assert_eq!(retrieved[1].id, "p2");
1242 }
1243
1244 #[test]
1245 fn test_insert_and_get_issues() {
1246 let storage = Storage::new_in_memory().unwrap();
1247 let crawl_id = storage.start_crawl("https://example.com", None).unwrap();
1248
1249 let page = test_page("p1", "https://example.com/", 200);
1250 storage.insert_page(&crawl_id, &page).unwrap();
1251
1252 let issues = vec![
1253 test_issue("i1", "p1", IssueCategory::Seo, Severity::Error),
1254 test_issue("i2", "p1", IssueCategory::Images, Severity::Warning),
1255 ];
1256 storage.insert_issues(&issues).unwrap();
1257
1258 let retrieved = storage
1260 .get_issues(&crawl_id, &IssueFilter::default())
1261 .unwrap();
1262 assert_eq!(retrieved.len(), 2);
1263
1264 let filter = IssueFilter {
1266 severity: Some(Severity::Error),
1267 ..Default::default()
1268 };
1269 let retrieved = storage.get_issues(&crawl_id, &filter).unwrap();
1270 assert_eq!(retrieved.len(), 1);
1271 assert_eq!(retrieved[0].code, "i1001");
1272
1273 let filter = IssueFilter {
1275 category: Some(IssueCategory::Images),
1276 ..Default::default()
1277 };
1278 let retrieved = storage.get_issues(&crawl_id, &filter).unwrap();
1279 assert_eq!(retrieved.len(), 1);
1280 assert_eq!(retrieved[0].code, "i2001");
1281 }
1282
1283 #[test]
1284 fn test_get_stats() {
1285 let storage = Storage::new_in_memory().unwrap();
1286 let crawl_id = storage.start_crawl("https://example.com", None).unwrap();
1287
1288 let pages = vec![
1289 test_page("p1", "https://example.com/", 200),
1290 test_page("p2", "https://example.com/about", 200),
1291 ];
1292 storage.insert_pages(&crawl_id, &pages).unwrap();
1293
1294 let issues = vec![
1295 test_issue("i1", "p1", IssueCategory::Seo, Severity::Error),
1296 test_issue("i2", "p1", IssueCategory::Seo, Severity::Warning),
1297 test_issue("i3", "p2", IssueCategory::Images, Severity::Warning),
1298 ];
1299 storage.insert_issues(&issues).unwrap();
1300
1301 let stats = storage.get_stats(&crawl_id).unwrap();
1302 assert_eq!(stats.total_pages, 2);
1303 assert_eq!(stats.total_issues, 3);
1304 assert_eq!(stats.issues_by_severity.get("error"), Some(&1));
1305 assert_eq!(stats.issues_by_severity.get("warning"), Some(&2));
1306 assert_eq!(stats.issues_by_category.get("seo"), Some(&2));
1307 assert_eq!(stats.issues_by_category.get("images"), Some(&1));
1308 }
1309
1310 #[test]
1311 fn test_finish_crawl() {
1312 let storage = Storage::new_in_memory().unwrap();
1313 let crawl_id = storage.start_crawl("https://example.com", None).unwrap();
1314
1315 let page = test_page("p1", "https://example.com/", 200);
1316 storage.insert_page(&crawl_id, &page).unwrap();
1317 let issue = test_issue("i1", "p1", IssueCategory::Seo, Severity::Error);
1318 storage.insert_issue(&issue).unwrap();
1319
1320 storage.finish_crawl(&crawl_id, 1, 1).unwrap();
1321 storage.finish_crawl(&crawl_id, 1, 1).unwrap();
1323 }
1324
1325 #[test]
1326 fn test_severity_roundtrip() {
1327 for sev in [
1328 Severity::Critical,
1329 Severity::Error,
1330 Severity::Warning,
1331 Severity::Info,
1332 ] {
1333 let s = sev.as_str();
1334 assert_eq!(Severity::parse_severity(s), Some(sev));
1335 }
1336 assert_eq!(Severity::parse_severity("invalid"), None);
1337 }
1338
1339 #[test]
1340 fn test_category_roundtrip() {
1341 for cat in [
1342 IssueCategory::Http,
1343 IssueCategory::Seo,
1344 IssueCategory::Content,
1345 IssueCategory::Links,
1346 IssueCategory::Images,
1347 IssueCategory::Schema,
1348 IssueCategory::Security,
1349 IssueCategory::Performance,
1350 IssueCategory::Mobile,
1351 IssueCategory::Accessibility,
1352 IssueCategory::Social,
1353 ] {
1354 let s = cat.as_str();
1355 assert_eq!(IssueCategory::parse_category(&s), cat);
1356 }
1357 let custom = IssueCategory::Custom("myplugin".to_string());
1358 let s = custom.as_str();
1359 assert_eq!(IssueCategory::parse_category(&s), custom);
1360 }
1361
1362 #[test]
1363 fn test_pages_limit() {
1364 let storage = Storage::new_in_memory().unwrap();
1365 let crawl_id = storage.start_crawl("https://example.com", None).unwrap();
1366
1367 for i in 0..10 {
1368 let page = test_page(
1369 &format!("p{i}"),
1370 &format!("https://example.com/page{i}"),
1371 200,
1372 );
1373 storage.insert_page(&crawl_id, &page).unwrap();
1374 }
1375
1376 let pages = storage.get_pages(&crawl_id, 3).unwrap();
1377 assert_eq!(pages.len(), 3);
1378 }
1379
1380 #[test]
1381 fn test_issue_filter_by_page_id() {
1382 let storage = Storage::new_in_memory().unwrap();
1383 let crawl_id = storage.start_crawl("https://example.com", None).unwrap();
1384
1385 let p1 = test_page("p1", "https://example.com/", 200);
1386 let p2 = test_page("p2", "https://example.com/about", 200);
1387 storage.insert_page(&crawl_id, &p1).unwrap();
1388 storage.insert_page(&crawl_id, &p2).unwrap();
1389
1390 let issues = vec![
1391 test_issue("i1", "p1", IssueCategory::Seo, Severity::Error),
1392 test_issue("i2", "p2", IssueCategory::Seo, Severity::Error),
1393 ];
1394 storage.insert_issues(&issues).unwrap();
1395
1396 let filter = IssueFilter {
1397 page_id: Some("p1".to_string()),
1398 ..Default::default()
1399 };
1400 let retrieved = storage.get_issues(&crawl_id, &filter).unwrap();
1401 assert_eq!(retrieved.len(), 1);
1402 assert_eq!(retrieved[0].page_id, "p1");
1403 }
1404}