Skip to main content

crawlkit_engine/
storage.rs

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/// Errors specific to storage operations.
14///
15/// Wraps SQLite database errors and URL parsing errors that can occur
16/// during crawl data persistence.
17#[derive(Debug, Error)]
18pub enum StorageError {
19    /// SQLite database error.
20    #[error("database error: {0}")]
21    Database(#[from] rusqlite::Error),
22
23    /// URL parsing error during retrieval.
24    #[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/// Severity level for an issue/finding.
35///
36/// Used by analyzers to classify the importance of detected issues.
37/// Stored in the database as a lowercase string for querying.
38#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
39pub enum Severity {
40    /// Critical issue requiring immediate attention.
41    Critical,
42    /// Error that should be fixed.
43    Error,
44    /// Warning suggesting improvement.
45    Warning,
46    /// Informational note.
47    Info,
48}
49
50impl Severity {
51    /// Convert to the string representation used in the database.
52    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    /// Parse from the database string representation.
62    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/// Category of an analysis finding.
74///
75/// Groups related issues for filtering and reporting. Stored in the
76/// database as a lowercase string. Custom categories use a `custom:` prefix.
77#[derive(Debug, Clone, PartialEq, Eq, Hash, serde::Serialize, serde::Deserialize)]
78pub enum IssueCategory {
79    /// HTTP-related issues (status codes, redirects, headers).
80    Http,
81    /// SEO issues (title, meta, canonical, robots).
82    Seo,
83    /// Content issues (word count, thin content, readability).
84    Content,
85    /// Link issues (broken links, redirect links, nofollow).
86    Links,
87    /// Image issues (missing alt, oversized, format).
88    Images,
89    /// Structured data issues (JSON-LD, microdata).
90    Schema,
91    /// Security issues (mixed content, headers).
92    Security,
93    /// Performance issues (page size, load time).
94    Performance,
95    /// Mobile-friendliness issues.
96    Mobile,
97    /// Accessibility issues (alt text, ARIA, contrast).
98    Accessibility,
99    /// Social metadata issues (Open Graph, Twitter Cards).
100    Social,
101    /// Custom analyzer issue.
102    Custom(String),
103}
104
105impl IssueCategory {
106    /// Convert to the string representation used in the database.
107    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    /// Parse from the database string representation.
125    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                // Strip "custom:" prefix if present
140                let name = other.strip_prefix("custom:").unwrap_or(other);
141                IssueCategory::Custom(name.to_string())
142            }
143        }
144    }
145}
146
147/// A page extracted from the crawl, with full data for storage.
148///
149/// Contains all metadata needed to persist a crawled page to the database,
150/// including URL, status, content metrics, and discovered links.
151#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
152pub struct PageData {
153    /// Unique page identifier.
154    pub id: String,
155    /// The URL of the page.
156    pub url: Url,
157    /// The final URL after redirects.
158    pub final_url: Url,
159    /// The HTTP status code.
160    pub status_code: u16,
161    /// The page title.
162    pub title: Option<String>,
163    /// The meta description.
164    pub description: Option<String>,
165    /// The canonical URL.
166    pub canonical_url: Option<Url>,
167    /// Word count of the page body.
168    pub word_count: Option<usize>,
169    /// Response time in milliseconds.
170    pub load_time_ms: Option<u64>,
171    /// Body size in bytes.
172    pub body_size: Option<usize>,
173    /// When this page was fetched.
174    pub fetched_at: DateTime<Utc>,
175    /// Links discovered on this page.
176    pub links: Vec<Url>,
177}
178
179/// An issue/finding detected during analysis.
180///
181/// Persisted to the database with a reference to the page it belongs to.
182/// Includes category, severity, machine-readable code, and recommendation.
183#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
184pub struct Issue {
185    /// Unique issue identifier.
186    pub id: String,
187    /// The page this issue belongs to.
188    pub page_id: String,
189    /// Issue category.
190    pub category: IssueCategory,
191    /// Issue severity.
192    pub severity: Severity,
193    /// Machine-readable issue code (e.g. "SEO001").
194    pub code: String,
195    /// Human-readable title.
196    pub title: String,
197    /// Detailed description.
198    pub description: String,
199    /// CSS selector of the affected element, if applicable.
200    pub element: Option<String>,
201    /// Recommendation for fixing the issue.
202    pub recommendation: String,
203}
204
205/// Filters for querying issues.
206///
207/// All fields are optional. When set, they narrow the query results.
208/// When `None`, that filter dimension is not applied.
209#[derive(Debug, Clone, Default)]
210pub struct IssueFilter {
211    /// Filter by severity.
212    pub severity: Option<Severity>,
213    /// Filter by category.
214    pub category: Option<IssueCategory>,
215    /// Filter by page ID.
216    pub page_id: Option<String>,
217    /// Filter by issue code prefix.
218    pub code_prefix: Option<String>,
219}
220
221/// Aggregate crawl statistics.
222///
223/// Summary of pages crawled, issues found, and performance metrics.
224/// Returned by [`Storage::get_stats`].
225#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
226pub struct CrawlStats {
227    /// Total number of pages crawled.
228    pub total_pages: usize,
229    /// Total number of issues found.
230    pub total_issues: usize,
231    /// Issues by severity.
232    pub issues_by_severity: std::collections::HashMap<String, usize>,
233    /// Issues by category.
234    pub issues_by_category: std::collections::HashMap<String, usize>,
235    /// Average response time in milliseconds.
236    pub avg_response_time_ms: Option<f64>,
237    /// Total body size in bytes.
238    pub total_body_size: Option<usize>,
239}
240
241/// Cache statistics for the LRU page cache.
242#[derive(Debug, Clone)]
243pub struct CacheStats {
244    /// Maximum cache capacity.
245    pub capacity: usize,
246    /// Current number of cached entries.
247    pub size: usize,
248    /// Cache hit count (since last reset).
249    pub hits: usize,
250    /// Cache miss count (since last reset).
251    pub misses: usize,
252}
253
254/// SQLite-backed storage for crawl data.
255///
256/// Uses WAL mode for concurrent-safe access and provides
257/// batch-friendly insert methods for performance. Includes an LRU cache
258/// for frequently accessed pages and memory usage tracking.
259pub struct Storage {
260    conn: Mutex<Connection>,
261    /// LRU cache for recently accessed pages.
262    page_cache: Mutex<LruCache<String, PageData>>,
263    /// Approximate memory usage in bytes.
264    memory_usage: AtomicUsize,
265    /// Whether memory-mapped I/O is enabled (for read-heavy workloads).
266    mmap_enabled: bool,
267}
268
269impl Storage {
270    /// Lock and return the underlying connection guard.
271    ///
272    /// Provides direct access to the SQLite connection for advanced
273    /// queries or transactions not covered by the convenience methods.
274    pub fn conn(&self) -> parking_lot::MutexGuard<'_, Connection> {
275        self.conn.lock()
276    }
277
278    /// Open or create a SQLite database at the given path.
279    ///
280    /// Enables WAL mode, memory-mapped I/O, and creates the schema if it
281    /// doesn't exist. Uses a default LRU cache of 1000 entries.
282    ///
283    /// # Errors
284    ///
285    /// Returns [`StorageError::Database`] if the database cannot be opened
286    /// or the schema cannot be created.
287    pub fn new(path: &Path) -> Result<Self, StorageError> {
288        let conn = Connection::open(path)?;
289
290        // Enable WAL mode for concurrent-safe reads/writes
291        // Enable memory-mapped I/O for faster reads (256MB)
292        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    /// Create in-memory storage for testing.
313    ///
314    /// Uses an in-memory SQLite database with WAL mode enabled.
315    /// Ideal for unit tests that need fast, isolated storage.
316    ///
317    /// # Errors
318    ///
319    /// Returns [`StorageError::Database`] if the in-memory database
320    /// cannot be created.
321    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    /// Create storage with a custom LRU cache size.
341    ///
342    /// Use this when the default 1000-entry cache is not appropriate
343    /// for your workload. Larger caches improve read performance for
344    /// repeat queries but use more memory.
345    ///
346    /// # Errors
347    ///
348    /// Returns [`StorageError::Database`] if the database cannot be opened.
349    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    /// Returns the approximate memory usage in bytes.
373    pub fn memory_usage(&self) -> usize {
374        self.memory_usage.load(Ordering::Relaxed)
375    }
376
377    /// Returns whether memory-mapped I/O is enabled.
378    pub fn is_mmap_enabled(&self) -> bool {
379        self.mmap_enabled
380    }
381
382    /// Returns cache statistics (hits, misses, current size).
383    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, // Would need to track these separately
389            misses: 0,
390        }
391    }
392
393    /// Clears the page cache.
394    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    /// Create the database schema if it doesn't already exist.
405    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    /// Start a new crawl and return its ID.
502    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    /// Finish a crawl, recording final statistics.
517    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    /// Insert a single page into the database under the given crawl.
537    /// Uses a single SQLite transaction for the page row + all link rows
538    /// to avoid per-statement fsync overhead.
539    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        // Insert links within the same transaction
563        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    /// Insert a batch of pages for performance.
584    /// Wraps all inserts in a single SQLite transaction for O(n) vs O(n*fsync).
585    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    /// Insert a single issue/finding into the database.
636    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    /// Insert a batch of issues for performance.
657    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    /// Retrieve pages with a limit.
680    ///
681    /// Results are not cached because the cache type (`LruCache<String, PageData>`)
682    /// cannot store `Vec<PageData>`. The query is fast with proper indexing.
683    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    /// Retrieve issues/finding with optional filters.
722    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    /// Get aggregate statistics for a crawl.
784    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    /// Get the latest crawl ID.
863    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    /// Get all links for a crawl, grouped by source URL.
879    ///
880    /// Returns `Vec<(source_url, Vec<target_url>)>` suitable for
881    /// feeding into `BacklinkAnalyzer::load_from_crawl_data`.
882    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    /// Get all external links for a crawl.
910    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    /// Get all page URLs for a crawl.
935    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    /// Store CrUX metrics for a page.
950    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    /// Get CrUX metrics for a page.
980    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    /// Get CrUX metrics for all pages in a crawl.
1005    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/// CrUX metrics for a single page.
1038#[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        // Schema should already exist; verify by inserting
1088        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        // Get all
1124        let retrieved = storage
1125            .get_issues(&crawl_id, &IssueFilter::default())
1126            .unwrap();
1127        assert_eq!(retrieved.len(), 2);
1128
1129        // Filter by severity
1130        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        // Filter by category
1139        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        // Verify no panic on double finish
1187        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}