Skip to main content

crawlkit_engine/
queue.rs

1use std::cmp::Ordering;
2use std::collections::BinaryHeap;
3
4use chrono::{DateTime, Utc};
5use dashmap::DashSet;
6use parking_lot::Mutex;
7use url::Url;
8
9use crate::CrawlConfig;
10
11/// Priority score for a URL entry (lower = higher priority).
12///
13/// Constants are provided for common priority levels. Custom values
14/// can be created with [`Priority::new`].
15#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
16pub struct Priority(u8);
17
18impl Priority {
19    /// Highest priority (e.g. sitemap, seed URLs).
20    pub const HIGHEST: Self = Self(0);
21    /// High priority (e.g. linked from important pages).
22    pub const HIGH: Self = Self(32);
23    /// Normal priority (default).
24    pub const NORMAL: Self = Self(64);
25    /// Low priority (e.g. deep pages, low-value links).
26    pub const LOW: Self = Self(128);
27    /// Lowest priority (e.g. archived, noindex pages).
28    pub const LOWEST: Self = Self(255);
29
30    /// Creates a priority from a raw u8 value.
31    pub fn new(value: u8) -> Self {
32        Self(value)
33    }
34
35    /// Returns the raw priority value.
36    pub fn value(&self) -> u8 {
37        self.0
38    }
39}
40
41impl Default for Priority {
42    fn default() -> Self {
43        Self::NORMAL
44    }
45}
46
47impl PartialOrd for Priority {
48    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
49        Some(self.cmp(other))
50    }
51}
52
53impl Ord for Priority {
54    fn cmp(&self, other: &Self) -> Ordering {
55        self.0.cmp(&other.0)
56    }
57}
58
59use serde::{Deserialize, Serialize};
60
61/// A URL entry in the crawl queue.
62///
63/// Contains the URL, its canonical form, crawl depth, priority,
64/// discovery timestamp, and optional referrer. Ordered by priority
65/// (lower value = higher priority) for the min-heap queue.
66#[derive(Debug, Clone, Serialize, Deserialize)]
67pub struct QueueEntry {
68    /// The URL to crawl.
69    pub url: Url,
70    /// The canonical (normalized) URL for deduplication.
71    pub canonical_url: Url,
72    /// Crawl depth from the seed URL (0 = seed).
73    pub depth: usize,
74    /// Priority score (lower = higher priority).
75    pub priority: Priority,
76    /// When this URL was discovered.
77    pub discovered_at: DateTime<Utc>,
78    /// The URL that discovered this one.
79    pub referrer: Option<Url>,
80}
81
82// BinaryHeap is a max-heap, so we invert the ordering for min-heap behavior.
83impl PartialEq for QueueEntry {
84    fn eq(&self, other: &Self) -> bool {
85        self.priority == other.priority && self.url == other.url
86    }
87}
88
89impl Eq for QueueEntry {}
90
91impl PartialOrd for QueueEntry {
92    fn partial_cmp(&self, other: &Self) -> Option<Ordering> {
93        Some(self.cmp(other))
94    }
95}
96
97impl Ord for QueueEntry {
98    fn cmp(&self, other: &Self) -> Ordering {
99        // Min-heap: lower priority value = higher priority = should come first
100        // BinaryHeap pops the largest, so we reverse the comparison.
101        other
102            .priority
103            .cmp(&self.priority)
104            .then_with(|| other.url.as_str().cmp(self.url.as_str()))
105    }
106}
107
108/// Scope control for URL filtering.
109///
110/// Defines which domains and paths are allowed or blocked during crawling.
111/// Supports wildcard patterns for domain matching (e.g., "*.example.com").
112#[derive(Debug, Clone, Default)]
113pub struct ScopeConfig {
114    /// Allowed domain patterns (e.g. `["example.com", "*.example.org"]`).
115    pub allowed_domains: Vec<String>,
116    /// Blocked domain patterns.
117    pub blocked_domains: Vec<String>,
118    /// Allowed URL path prefixes.
119    pub allowed_paths: Vec<String>,
120    /// Blocked URL path prefixes.
121    pub blocked_paths: Vec<String>,
122    /// Maximum crawl depth (None = unlimited).
123    pub max_depth: Option<usize>,
124}
125
126impl From<&CrawlConfig> for ScopeConfig {
127    fn from(config: &CrawlConfig) -> Self {
128        Self {
129            allowed_domains: Vec::new(),
130            blocked_domains: Vec::new(),
131            allowed_paths: config.allowed_patterns.clone(),
132            blocked_paths: config.disallowed_patterns.clone(),
133            max_depth: None,
134        }
135    }
136}
137
138/// Priority URL queue with deduplication and scope control.
139///
140/// Binary heap-based priority queue that ensures URLs are crawled in
141/// priority order. Automatically deduplicates URLs and enforces scope
142/// control (allowed/blocked domains and paths).
143///
144/// # Examples
145///
146/// ```rust
147/// use crawlkit_engine::queue::{UrlQueue, ScopeConfig, Priority};
148/// use url::Url;
149///
150/// let queue = UrlQueue::new(ScopeConfig::default());
151/// let url = Url::parse("https://example.com").unwrap();
152/// queue.push(url, 0, Priority::HIGH);
153/// assert_eq!(queue.len(), 1);
154/// ```
155pub struct UrlQueue {
156    heap: Mutex<BinaryHeap<QueueEntry>>,
157    seen: DashSet<String>,
158    scope: ScopeConfig,
159    domain_counts: dashmap::DashMap<String, usize>,
160}
161
162impl UrlQueue {
163    /// Creates a new empty queue with the given scope configuration.
164    pub fn new(scope: ScopeConfig) -> Self {
165        Self {
166            heap: Mutex::new(BinaryHeap::new()),
167            seen: DashSet::new(),
168            scope,
169            domain_counts: dashmap::DashMap::new(),
170        }
171    }
172
173    /// Creates a queue from a `CrawlConfig`.
174    pub fn from_crawl_config(config: &CrawlConfig) -> Self {
175        Self::new(ScopeConfig::from(config))
176    }
177
178    /// Pushes a URL into the queue with the given depth and priority.
179    ///
180    /// Returns `true` if the URL was added, `false` if it was a duplicate
181    /// or rejected by scope control.
182    pub fn push(&self, url: Url, depth: usize, priority: Priority) -> bool {
183        self.push_with_referrer(url, depth, priority, None)
184    }
185
186    /// Pushes a URL with an optional referrer.
187    ///
188    /// Performs deduplication, depth check, and scope filtering.
189    pub fn push_with_referrer(
190        &self,
191        url: Url,
192        depth: usize,
193        priority: Priority,
194        referrer: Option<Url>,
195    ) -> bool {
196        // Deduplication check
197        let url_str = url.to_string();
198        if !self.seen.insert(url_str) {
199            return false;
200        }
201
202        // Depth check
203        if let Some(max_depth) = self.scope.max_depth {
204            if depth > max_depth {
205                return false;
206            }
207        }
208
209        // Scope check
210        if !self.is_in_scope(&url) {
211            return false;
212        }
213
214        // Track domain
215        if let Some(domain) = url.domain() {
216            *self.domain_counts.entry(domain.to_string()).or_insert(0) += 1;
217        }
218
219        let entry = QueueEntry {
220            url: url.clone(),
221            canonical_url: url,
222            depth,
223            priority,
224            discovered_at: Utc::now(),
225            referrer,
226        };
227
228        self.heap.lock().push(entry);
229        true
230    }
231
232    /// Pops the highest-priority entry from the queue.
233    pub fn pop(&self) -> Option<QueueEntry> {
234        self.heap.lock().pop()
235    }
236
237    /// Returns the number of entries in the queue.
238    pub fn len(&self) -> usize {
239        self.heap.lock().len()
240    }
241
242    /// Returns `true` if the queue is empty.
243    pub fn is_empty(&self) -> bool {
244        self.heap.lock().is_empty()
245    }
246
247    /// Returns the number of unique URLs seen (including popped ones).
248    pub fn seen_count(&self) -> usize {
249        self.seen.len()
250    }
251
252    /// Returns the number of URLs discovered for a given domain.
253    pub fn domain_count(&self, domain: &str) -> usize {
254        self.domain_counts.get(domain).map_or(0, |c| *c)
255    }
256
257    /// Returns all unique domains that have been queued.
258    pub fn domains(&self) -> Vec<String> {
259        self.domain_counts
260            .iter()
261            .map(|entry| entry.key().clone())
262            .collect()
263    }
264
265    /// Peeks at the highest-priority entry without removing it.
266    pub fn peek(&self) -> Option<QueueEntry> {
267        self.heap.lock().peek().cloned()
268    }
269
270    /// Drains all entries from the queue.
271    pub fn drain(&self) -> Vec<QueueEntry> {
272        let mut entries = Vec::new();
273        let mut heap = self.heap.lock();
274        while let Some(entry) = heap.pop() {
275            entries.push(entry);
276        }
277        entries
278    }
279
280    /// Checks if a URL is within the configured scope.
281    fn is_in_scope(&self, url: &Url) -> bool {
282        let domain = match url.domain() {
283            Some(d) => d,
284            None => return false,
285        };
286
287        // Check blocked domains
288        for pattern in &self.scope.blocked_domains {
289            if domain_matches_pattern(domain, pattern) {
290                return false;
291            }
292        }
293
294        // Check allowed domains (if non-empty, only those are allowed)
295        if !self.scope.allowed_domains.is_empty() {
296            let mut allowed = false;
297            for pattern in &self.scope.allowed_domains {
298                if domain_matches_pattern(domain, pattern) {
299                    allowed = true;
300                    break;
301                }
302            }
303            if !allowed {
304                return false;
305            }
306        }
307
308        let path = url.path();
309
310        // Check blocked paths
311        for pattern in &self.scope.blocked_paths {
312            if path.starts_with(pattern) {
313                return false;
314            }
315        }
316
317        // Check allowed paths (if non-empty, only those are allowed)
318        if !self.scope.allowed_paths.is_empty() {
319            let mut allowed = false;
320            for pattern in &self.scope.allowed_paths {
321                if path.starts_with(pattern) {
322                    allowed = true;
323                    break;
324                }
325            }
326            if !allowed {
327                return false;
328            }
329        }
330
331        true
332    }
333}
334
335/// Checks if a domain matches a pattern (supports leading `*` wildcard).
336fn domain_matches_pattern(domain: &str, pattern: &str) -> bool {
337    if let Some(suffix) = pattern.strip_prefix("*.") {
338        domain.ends_with(&format!(".{suffix}"))
339    } else {
340        domain == pattern
341    }
342}
343
344#[cfg(test)]
345#[allow(clippy::unwrap_used)]
346mod tests {
347    use super::*;
348
349    #[test]
350    fn test_priority_ordering() {
351        assert!(Priority::HIGHEST < Priority::HIGH);
352        assert!(Priority::HIGH < Priority::NORMAL);
353        assert!(Priority::NORMAL < Priority::LOW);
354        assert!(Priority::LOW < Priority::LOWEST);
355    }
356
357    #[test]
358    fn test_queue_push_and_pop() {
359        let queue = UrlQueue::new(ScopeConfig::default());
360        let url1 = Url::parse("https://example.com/a").unwrap();
361        let url2 = Url::parse("https://example.com/b").unwrap();
362
363        assert!(queue.push(url1.clone(), 0, Priority::LOW));
364        assert!(queue.push(url2.clone(), 0, Priority::HIGH));
365
366        assert_eq!(queue.len(), 2);
367
368        // HIGH priority should come first
369        let entry = queue.pop().unwrap();
370        assert_eq!(entry.url, url2);
371        assert_eq!(entry.priority, Priority::HIGH);
372
373        let entry = queue.pop().unwrap();
374        assert_eq!(entry.url, url1);
375        assert_eq!(entry.priority, Priority::LOW);
376    }
377
378    #[test]
379    fn test_queue_deduplication() {
380        let queue = UrlQueue::new(ScopeConfig::default());
381        let url = Url::parse("https://example.com/page").unwrap();
382
383        assert!(queue.push(url.clone(), 0, Priority::NORMAL));
384        assert!(!queue.push(url, 1, Priority::HIGH)); // duplicate
385
386        assert_eq!(queue.len(), 1);
387        assert_eq!(queue.seen_count(), 1);
388    }
389
390    #[test]
391    fn test_queue_depth_limit() {
392        let scope = ScopeConfig {
393            max_depth: Some(2),
394            ..Default::default()
395        };
396        let queue = UrlQueue::new(scope);
397
398        assert!(queue.push(
399            Url::parse("https://example.com/p0").unwrap(),
400            0,
401            Priority::NORMAL,
402        ));
403        assert!(queue.push(
404            Url::parse("https://example.com/p1").unwrap(),
405            1,
406            Priority::NORMAL,
407        ));
408        assert!(queue.push(
409            Url::parse("https://example.com/p2").unwrap(),
410            2,
411            Priority::NORMAL,
412        ));
413        assert!(!queue.push(
414            Url::parse("https://example.com/p3").unwrap(),
415            3,
416            Priority::NORMAL,
417        )); // exceeds max depth
418
419        assert_eq!(queue.len(), 3);
420    }
421
422    #[test]
423    fn test_queue_domain_scope() {
424        let scope = ScopeConfig {
425            allowed_domains: vec!["example.com".to_string()],
426            ..Default::default()
427        };
428        let queue = UrlQueue::new(scope);
429
430        let url1 = Url::parse("https://example.com/page").unwrap();
431        let url2 = Url::parse("https://other.com/page").unwrap();
432
433        assert!(queue.push(url1, 0, Priority::NORMAL));
434        assert!(!queue.push(url2, 0, Priority::NORMAL)); // not in allowed domains
435
436        assert_eq!(queue.len(), 1);
437    }
438
439    #[test]
440    fn test_queue_blocked_domain() {
441        let scope = ScopeConfig {
442            blocked_domains: vec!["spam.com".to_string()],
443            ..Default::default()
444        };
445        let queue = UrlQueue::new(scope);
446
447        let url1 = Url::parse("https://example.com/page").unwrap();
448        let url2 = Url::parse("https://spam.com/page").unwrap();
449
450        assert!(queue.push(url1, 0, Priority::NORMAL));
451        assert!(!queue.push(url2, 0, Priority::NORMAL)); // blocked
452
453        assert_eq!(queue.len(), 1);
454    }
455
456    #[test]
457    fn test_queue_wildcard_domain_pattern() {
458        let scope = ScopeConfig {
459            allowed_domains: vec!["*.example.com".to_string()],
460            ..Default::default()
461        };
462        let queue = UrlQueue::new(scope);
463
464        assert!(queue.push(
465            Url::parse("https://www.example.com/page").unwrap(),
466            0,
467            Priority::NORMAL,
468        ));
469        assert!(queue.push(
470            Url::parse("https://sub.example.com/page").unwrap(),
471            0,
472            Priority::NORMAL,
473        ));
474        assert!(!queue.push(
475            Url::parse("https://example.com/page").unwrap(),
476            0,
477            Priority::NORMAL,
478        )); // doesn't match *.example.com
479
480        assert_eq!(queue.len(), 2);
481    }
482
483    #[test]
484    fn test_queue_domain_tracking() {
485        let queue = UrlQueue::new(ScopeConfig::default());
486
487        queue.push(Url::parse("https://a.com/1").unwrap(), 0, Priority::NORMAL);
488        queue.push(Url::parse("https://a.com/2").unwrap(), 0, Priority::NORMAL);
489        queue.push(Url::parse("https://b.com/1").unwrap(), 0, Priority::NORMAL);
490
491        assert_eq!(queue.domain_count("a.com"), 2);
492        assert_eq!(queue.domain_count("b.com"), 1);
493        assert_eq!(queue.domain_count("c.com"), 0);
494
495        let mut domains = queue.domains();
496        domains.sort();
497        assert_eq!(domains, vec!["a.com", "b.com"]);
498    }
499
500    #[test]
501    fn test_queue_is_empty() {
502        let queue = UrlQueue::new(ScopeConfig::default());
503        assert!(queue.is_empty());
504
505        queue.push(
506            Url::parse("https://example.com").unwrap(),
507            0,
508            Priority::NORMAL,
509        );
510        assert!(!queue.is_empty());
511    }
512
513    #[test]
514    fn test_queue_peek() {
515        let queue = UrlQueue::new(ScopeConfig::default());
516        assert!(queue.peek().is_none());
517
518        let url = Url::parse("https://example.com").unwrap();
519        queue.push(url, 0, Priority::NORMAL);
520
521        let peeked = queue.peek().unwrap();
522        assert_eq!(peeked.url.as_str(), "https://example.com/");
523        assert_eq!(queue.len(), 1); // peek doesn't remove
524    }
525
526    #[test]
527    fn test_queue_drain() {
528        let queue = UrlQueue::new(ScopeConfig::default());
529        queue.push(Url::parse("https://a.com").unwrap(), 0, Priority::HIGH);
530        queue.push(Url::parse("https://b.com").unwrap(), 0, Priority::LOW);
531
532        let entries = queue.drain();
533        assert_eq!(entries.len(), 2);
534        assert!(queue.is_empty());
535    }
536
537    #[test]
538    fn test_domain_matches_pattern() {
539        assert!(domain_matches_pattern("example.com", "example.com"));
540        assert!(domain_matches_pattern("www.example.com", "*.example.com"));
541        assert!(domain_matches_pattern("sub.example.com", "*.example.com"));
542        assert!(!domain_matches_pattern("example.com", "*.example.com"));
543        assert!(!domain_matches_pattern("notexample.com", "example.com"));
544        assert!(!domain_matches_pattern("evil-example.com", "*.example.com"));
545    }
546
547    #[test]
548    fn test_queue_push_with_referrer() {
549        let queue = UrlQueue::new(ScopeConfig::default());
550        let url = Url::parse("https://example.com/page").unwrap();
551        let referrer = Url::parse("https://example.com/other").unwrap();
552
553        queue.push_with_referrer(url, 0, Priority::NORMAL, Some(referrer.clone()));
554
555        let entry = queue.pop().unwrap();
556        assert_eq!(entry.referrer.as_ref(), Some(&referrer));
557    }
558
559    #[test]
560    fn test_scope_config_from_crawl_config() {
561        let crawl_config = CrawlConfig {
562            allowed_patterns: vec!["/blog".to_string()],
563            disallowed_patterns: vec!["/admin".to_string()],
564            ..Default::default()
565        };
566
567        let scope = ScopeConfig::from(&crawl_config);
568        assert_eq!(scope.allowed_paths, vec!["/blog"]);
569        assert_eq!(scope.blocked_paths, vec!["/admin"]);
570    }
571
572    #[test]
573    fn test_queue_path_scope() {
574        let scope = ScopeConfig {
575            blocked_paths: vec!["/admin".to_string()],
576            ..Default::default()
577        };
578        let queue = UrlQueue::new(scope);
579
580        assert!(queue.push(
581            Url::parse("https://example.com/page").unwrap(),
582            0,
583            Priority::NORMAL,
584        ));
585        assert!(!queue.push(
586            Url::parse("https://example.com/admin/secret").unwrap(),
587            0,
588            Priority::NORMAL,
589        ));
590
591        assert_eq!(queue.len(), 1);
592    }
593}