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    /// Tenant ID for multi-tenancy.
178    pub tenant_id: Option<String>,
179}
180
181/// An issue/finding detected during analysis.
182///
183/// Persisted to the database with a reference to the page it belongs to.
184/// Includes category, severity, machine-readable code, and recommendation.
185#[derive(Debug, Clone, serde::Serialize, serde::Deserialize)]
186pub struct Issue {
187    /// Unique issue identifier.
188    pub id: String,
189    /// The page this issue belongs to.
190    pub page_id: String,
191    /// Issue category.
192    pub category: IssueCategory,
193    /// Issue severity.
194    pub severity: Severity,
195    /// Machine-readable issue code (e.g. "SEO001").
196    pub code: String,
197    /// Human-readable title.
198    pub title: String,
199    /// Detailed description.
200    pub description: String,
201    /// CSS selector of the affected element, if applicable.
202    pub element: Option<String>,
203    /// Recommendation for fixing the issue.
204    pub recommendation: String,
205    /// Tenant ID for multi-tenancy.
206    pub tenant_id: Option<String>,
207}
208
209/// Filters for querying issues.
210///
211/// All fields are optional. When set, they narrow the query results.
212/// When `None`, that filter dimension is not applied.
213#[derive(Debug, Clone, Default)]
214pub struct IssueFilter {
215    /// Filter by severity.
216    pub severity: Option<Severity>,
217    /// Filter by category.
218    pub category: Option<IssueCategory>,
219    /// Filter by page ID.
220    pub page_id: Option<String>,
221    /// Filter by issue code prefix.
222    pub code_prefix: Option<String>,
223}
224
225/// Aggregate crawl statistics.
226///
227/// Summary of pages crawled, issues found, and performance metrics.
228/// Returned by [`Storage::get_stats`].
229#[derive(Debug, Clone, Default, serde::Serialize, serde::Deserialize)]
230pub struct CrawlStats {
231    /// Total number of pages crawled.
232    pub total_pages: usize,
233    /// Total number of issues found.
234    pub total_issues: usize,
235    /// Issues by severity.
236    pub issues_by_severity: std::collections::HashMap<String, usize>,
237    /// Issues by category.
238    pub issues_by_category: std::collections::HashMap<String, usize>,
239    /// Average response time in milliseconds.
240    pub avg_response_time_ms: Option<f64>,
241    /// Total body size in bytes.
242    pub total_body_size: Option<usize>,
243}
244
245/// Cache statistics for the LRU page cache.
246#[derive(Debug, Clone)]
247pub struct CacheStats {
248    /// Maximum cache capacity.
249    pub capacity: usize,
250    /// Current number of cached entries.
251    pub size: usize,
252    /// Cache hit count (since last reset).
253    pub hits: usize,
254    /// Cache miss count (since last reset).
255    pub misses: usize,
256}
257
258/// SQLite-backed storage for crawl data.
259///
260/// Uses WAL mode for concurrent-safe access and provides
261/// batch-friendly insert methods for performance. Includes an LRU cache
262/// for frequently accessed pages and memory usage tracking.
263pub struct Storage {
264    conn: Mutex<Connection>,
265    /// LRU cache for recently accessed pages.
266    page_cache: Mutex<LruCache<String, PageData>>,
267    /// Approximate memory usage in bytes.
268    memory_usage: AtomicUsize,
269    /// Whether memory-mapped I/O is enabled (for read-heavy workloads).
270    mmap_enabled: bool,
271}
272
273impl Storage {
274    /// Lock and return the underlying connection guard.
275    ///
276    /// Provides direct access to the SQLite connection for advanced
277    /// queries or transactions not covered by the convenience methods.
278    pub fn conn(&self) -> parking_lot::MutexGuard<'_, Connection> {
279        self.conn.lock()
280    }
281
282    /// Open or create a SQLite database at the given path.
283    ///
284    /// Enables WAL mode, memory-mapped I/O, and creates the schema if it
285    /// doesn't exist. Uses a default LRU cache of 1000 entries.
286    ///
287    /// # Errors
288    ///
289    /// Returns [`StorageError::Database`] if the database cannot be opened
290    /// or the schema cannot be created.
291    pub fn new(path: &Path) -> Result<Self, StorageError> {
292        let conn = Connection::open(path)?;
293
294        // Enable WAL mode for concurrent-safe reads/writes
295        // Enable memory-mapped I/O for faster reads (256MB)
296        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    /// Create in-memory storage for testing.
317    ///
318    /// Uses an in-memory SQLite database with WAL mode enabled.
319    /// Ideal for unit tests that need fast, isolated storage.
320    ///
321    /// # Errors
322    ///
323    /// Returns [`StorageError::Database`] if the in-memory database
324    /// cannot be created.
325    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    /// Create storage with a custom LRU cache size.
345    ///
346    /// Use this when the default 1000-entry cache is not appropriate
347    /// for your workload. Larger caches improve read performance for
348    /// repeat queries but use more memory.
349    ///
350    /// # Errors
351    ///
352    /// Returns [`StorageError::Database`] if the database cannot be opened.
353    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    /// Returns the approximate memory usage in bytes.
377    pub fn memory_usage(&self) -> usize {
378        self.memory_usage.load(Ordering::Relaxed)
379    }
380
381    /// Returns whether memory-mapped I/O is enabled.
382    pub fn is_mmap_enabled(&self) -> bool {
383        self.mmap_enabled
384    }
385
386    /// Returns cache statistics (hits, misses, current size).
387    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, // Would need to track these separately
393            misses: 0,
394        }
395    }
396
397    /// Clears the page cache.
398    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    /// Create the database schema if it doesn't already exist.
409    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    /// Start a new crawl and return its ID.
510    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    /// Finish a crawl, recording final statistics.
525    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    /// Insert a single page into the database under the given crawl.
545    /// Uses a single SQLite transaction for the page row + all link rows
546    /// to avoid per-statement fsync overhead.
547    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        // Insert links within the same transaction
572        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    /// Insert a batch of pages for performance.
593    /// Wraps all inserts in a single SQLite transaction for O(n) vs O(n*fsync).
594    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    /// Insert a single issue/finding into the database.
646    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    /// Insert a batch of issues for performance.
668    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    /// Retrieve pages with a limit.
692    ///
693    /// Results are not cached because the cache type (`LruCache<String, PageData>`)
694    /// cannot store `Vec<PageData>`. The query is fast with proper indexing.
695    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    /// Retrieve issues/finding with optional filters.
735    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    /// Get pages for a specific tenant.
798    ///
799    /// Returns pages belonging to the given tenant, or pages with no tenant
800    /// assigned (shared/global data). This ensures tenant isolation at the
801    /// storage layer while still allowing access to unscoped data.
802    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    /// Get issues for a specific tenant.
848    ///
849    /// Returns issues belonging to the given tenant, or issues with no tenant
850    /// assigned (shared/global data). This ensures tenant isolation at the
851    /// storage layer while still allowing access to unscoped data.
852    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    /// Get aggregate statistics for a crawl.
917    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    /// Get the latest crawl ID.
996    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    /// Get all links for a crawl, grouped by source URL.
1012    ///
1013    /// Returns `Vec<(source_url, Vec<target_url>)>` suitable for
1014    /// feeding into `BacklinkAnalyzer::load_from_crawl_data`.
1015    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    /// Get all external links for a crawl.
1043    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    /// Get all page URLs for a crawl.
1068    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    /// Store CrUX metrics for a page.
1083    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    /// Get CrUX metrics for a page.
1113    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    /// Get CrUX metrics for all pages in a crawl.
1138    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/// CrUX metrics for a single page.
1171#[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        // Schema should already exist; verify by inserting
1223        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        // Get all
1259        let retrieved = storage
1260            .get_issues(&crawl_id, &IssueFilter::default())
1261            .unwrap();
1262        assert_eq!(retrieved.len(), 2);
1263
1264        // Filter by severity
1265        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        // Filter by category
1274        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        // Verify no panic on double finish
1322        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}