Skip to main content

link_r/source/
crawl.rs

1// Author: support@lituus.io
2// Copyright: lituus-io, all rights reserved
3//
4//! The recursive HTTP crawler — the primary source.
5//!
6//! Breadth-first from a parent URL to a configurable depth, scoped (by default to
7//! the parent's path prefix), deduplicating by canonical URL so a page is fetched
8//! once, with a polite inter-request delay. Generic over a [`Fetcher`], so it is
9//! tested hermetically with a mock fetcher.
10
11use crate::error::{Error, Result};
12use crate::extract::html::extract_links;
13use crate::url_key::UrlKeyExt as _;
14
15/// Kind-aware outbound-link extraction for the crawl frontier: HTML uses the
16/// byte scanner; Markdown uses the zero-dep Markdown link scanner (when that
17/// extractor is compiled in); other kinds follow nothing.
18fn extract_links_for(
19    kind: crate::resource::ResourceKind,
20    bytes: &[u8],
21) -> Vec<compact_str::CompactString> {
22    match kind {
23        crate::resource::ResourceKind::Html => extract_links(bytes),
24        #[cfg(feature = "markdown")]
25        crate::resource::ResourceKind::Markdown => {
26            crate::extract::markdown::extract_md_links(&String::from_utf8_lossy(bytes))
27        }
28        _ => Vec::new(),
29    }
30}
31use crate::fetch::{FetchMeta, FetchOptions, Fetcher};
32use crate::payload::DocPayload;
33use crate::resource::{Page, Resource, SourceRef};
34use crate::source::Source;
35use crate::url_key::UrlKey;
36use bytes::Bytes;
37use compact_str::CompactString;
38use futures::stream::{FuturesUnordered, StreamExt};
39use std::collections::{HashSet, VecDeque};
40use std::time::{Duration, Instant};
41use url::Url;
42
43/// Default crawl depth.
44const DEFAULT_MAX_DEPTH: u16 = 2;
45/// Default cap on total pages fetched.
46const DEFAULT_MAX_PAGES: usize = 1000;
47/// Default per-page byte ceiling (2 MiB).
48const DEFAULT_MAX_BYTES: u64 = 2 * 1024 * 1024;
49/// Default number of fetches in flight at once.
50const DEFAULT_CONCURRENCY: usize = 8;
51/// Default retry budget per page for retriable errors.
52const DEFAULT_MAX_RETRIES: u32 = 2;
53/// Upper bound on any single backoff, clamping a hostile `Retry-After`.
54const MAX_RETRY_AFTER: Duration = Duration::from_secs(30);
55
56/// Which discovered links the crawler follows.
57#[derive(Clone, Debug, Default)]
58pub enum CrawlScope {
59    /// Only links at or below the parent URL's path (the default).
60    #[default]
61    PathPrefix,
62    /// Any link on the same host.
63    SameHost,
64    /// The host and its subdomains.
65    SameHostAndSubdomains,
66    /// Only hosts matching the allowlist (exact host or `.suffix`).
67    Allowlist(Vec<CompactString>),
68}
69
70impl CrawlScope {
71    /// Whether `candidate` is in scope relative to the crawl `root`. Allocation-free
72    /// (runs once per discovered link on the crawl hot path).
73    #[must_use]
74    pub fn allows(&self, root: &Url, candidate: &Url) -> bool {
75        let (Some(root_host), Some(cand_host)) = (root.host_str(), candidate.host_str()) else {
76            return false;
77        };
78        match self {
79            CrawlScope::PathPrefix => {
80                if cand_host != root_host {
81                    return false;
82                }
83                // `cp` is under `rp` iff it equals `rp` or extends it at a '/'
84                // boundary — a byte-slice check, no `format!` per link.
85                let (rp, cp) = (root.path().as_bytes(), candidate.path().as_bytes());
86                cp == rp
87                    || (cp.len() > rp.len()
88                        && cp.starts_with(rp)
89                        && (rp.ends_with(b"/") || cp[rp.len()] == b'/'))
90            }
91            CrawlScope::SameHost => cand_host == root_host,
92            CrawlScope::SameHostAndSubdomains => host_under(cand_host, root_host),
93            CrawlScope::Allowlist(list) => list.iter().any(|h| {
94                let base = h.as_str().strip_prefix('.').unwrap_or(h.as_str());
95                host_under(cand_host, base)
96            }),
97        }
98    }
99}
100
101/// Whether `host` equals `base` or is a subdomain of it (`a.b.dev` under `b.dev`).
102/// Allocation-free.
103fn host_under(host: &str, base: &str) -> bool {
104    host == base
105        || (host.len() > base.len()
106            && host.ends_with(base)
107            && host.as_bytes()[host.len() - base.len() - 1] == b'.')
108}
109
110/// Crawl configuration. Every field is honored; see [`CrawlSource`] builder methods.
111#[derive(Clone, Debug)]
112pub struct CrawlConfig {
113    /// Maximum link depth from the root (root is depth 0).
114    pub max_depth: u16,
115    /// Which links to follow.
116    pub scope: CrawlScope,
117    /// Hard cap on total pages fetched (runaway guard).
118    pub max_pages: usize,
119    /// Polite delay between fetches.
120    pub min_delay: Duration,
121    /// Per-page byte ceiling.
122    pub max_bytes: Option<u64>,
123    /// User-Agent override.
124    pub user_agent: Option<CompactString>,
125    /// A followed link's URL path must contain *all* of these substrings (empty =
126    /// no constraint). Composes with [`CrawlConfig::scope`]: use it to confine a
127    /// crawl to a sub-path that the host's URL scheme splits across segments (e.g.
128    /// GitHub's `/tree/` directories and `/blob/` files both under `/main/docs`).
129    pub require_path_contains: Vec<CompactString>,
130    /// Only index pages whose URL path ends with one of these extensions, e.g.
131    /// `["md"]` (empty = index every indexable kind). Non-matching pages are still
132    /// crawled for links — only their *indexing* is filtered.
133    pub accept_extensions: Vec<CompactString>,
134    /// Only index pages whose URL path contains *all* of these substrings (empty =
135    /// no constraint). Independent of [`CrawlConfig::require_path_contains`]: follow
136    /// broadly, index narrowly — e.g. follow GitHub `/tree/` dirs but index only the
137    /// canonical `/blob/` file views, collapsing the `/raw/`,`/commits/` duplicates.
138    pub index_path_contains: Vec<CompactString>,
139    /// Maximum fetches in flight at once (clamped to at least 1). Higher values
140    /// crawl faster; `min_delay` still paces request *starts* globally.
141    pub concurrency: usize,
142    /// Retry budget per page for retriable errors (timeouts, 429/5xx). Honors
143    /// `Retry-After` on rate limits; other retries back off exponentially.
144    pub max_retries: u32,
145    /// Known entity tags by canonical-URL key: fetches send `If-None-Match`
146    /// and a 304 skips the body transfer entirely (conditional re-crawl).
147    pub validators: std::collections::HashMap<UrlKey, CompactString>,
148    /// Known outbound links by canonical-URL key: when a page revalidates as
149    /// unchanged (no body to parse), its previously indexed children keep the
150    /// frontier going, so coverage never shrinks on a 304.
151    pub known_edges: std::collections::HashMap<UrlKey, Vec<Url>>,
152    /// Fetch and honor `robots.txt` per host (opt-in; requires the `robots` feature).
153    #[cfg(feature = "robots")]
154    pub respect_robots: bool,
155}
156
157impl Default for CrawlConfig {
158    fn default() -> Self {
159        Self {
160            max_depth: DEFAULT_MAX_DEPTH,
161            scope: CrawlScope::PathPrefix,
162            max_pages: DEFAULT_MAX_PAGES,
163            min_delay: Duration::ZERO,
164            max_bytes: Some(DEFAULT_MAX_BYTES),
165            user_agent: None,
166            require_path_contains: Vec::new(),
167            accept_extensions: Vec::new(),
168            index_path_contains: Vec::new(),
169            concurrency: DEFAULT_CONCURRENCY,
170            max_retries: DEFAULT_MAX_RETRIES,
171            validators: std::collections::HashMap::new(),
172            known_edges: std::collections::HashMap::new(),
173            #[cfg(feature = "robots")]
174            respect_robots: false,
175        }
176    }
177}
178
179/// A recursive HTTP crawler over a [`Fetcher`].
180#[derive(Debug)]
181pub struct CrawlSource<F: Fetcher> {
182    fetcher: F,
183    config: CrawlConfig,
184    /// Child pages that failed after retries during the last crawl (root
185    /// failures abort instead). Readable after the stream completes.
186    failed: std::sync::atomic::AtomicU32,
187    /// Pages revalidated as unchanged (HTTP 304) during the last crawl. A 304
188    /// transfers no body, so these pages never enter the page stream — without
189    /// this record they would be invisible to the caller, and a consumer
190    /// tracking freshness could never learn that the check happened.
191    /// A `Mutex`, not a `RefCell`: the stream must stay `Send`.
192    revalidated: std::sync::Mutex<Vec<Url>>,
193}
194
195impl<F: Fetcher> CrawlSource<F> {
196    /// Create a crawler with default configuration.
197    #[must_use]
198    pub fn new(fetcher: F) -> Self {
199        Self {
200            fetcher,
201            config: CrawlConfig::default(),
202            failed: std::sync::atomic::AtomicU32::new(0),
203            revalidated: std::sync::Mutex::new(Vec::new()),
204        }
205    }
206
207    /// Create with explicit configuration.
208    #[must_use]
209    pub fn with_config(fetcher: F, config: CrawlConfig) -> Self {
210        Self {
211            fetcher,
212            config,
213            failed: std::sync::atomic::AtomicU32::new(0),
214            revalidated: std::sync::Mutex::new(Vec::new()),
215        }
216    }
217
218    /// Set the crawl depth.
219    #[must_use]
220    pub fn depth(mut self, max_depth: u16) -> Self {
221        self.config.max_depth = max_depth;
222        self
223    }
224
225    /// Set the page cap.
226    #[must_use]
227    pub fn max_pages(mut self, max_pages: usize) -> Self {
228        self.config.max_pages = max_pages;
229        self
230    }
231
232    /// Set the crawl scope.
233    #[must_use]
234    pub fn scope(mut self, scope: CrawlScope) -> Self {
235        self.config.scope = scope;
236        self
237    }
238
239    /// Set the polite inter-request delay.
240    #[must_use]
241    pub fn min_delay(mut self, delay: Duration) -> Self {
242        self.config.min_delay = delay;
243        self
244    }
245
246    /// Set the maximum number of fetches in flight at once (clamped to ≥ 1).
247    #[must_use]
248    pub fn concurrency(mut self, concurrency: usize) -> Self {
249        self.config.concurrency = concurrency;
250        self
251    }
252
253    /// Set the per-page retry budget for retriable errors.
254    #[must_use]
255    pub fn max_retries(mut self, max_retries: u32) -> Self {
256        self.config.max_retries = max_retries;
257        self
258    }
259
260    /// Fetch and honor `robots.txt` per host (opt-in).
261    #[cfg(feature = "robots")]
262    #[must_use]
263    pub fn respect_robots(mut self, respect: bool) -> Self {
264        self.config.respect_robots = respect;
265        self
266    }
267
268    /// Require that every followed link's URL path contains `substring` (additive;
269    /// all required substrings must be present). Confines the crawl to a sub-path.
270    #[must_use]
271    pub fn require_path(mut self, substring: impl Into<CompactString>) -> Self {
272        self.config.require_path_contains.push(substring.into());
273        self
274    }
275
276    /// Only index pages whose URL path ends with `extension` (additive). Other
277    /// pages are still crawled for links.
278    #[must_use]
279    pub fn accept_extension(mut self, extension: impl AsRef<str>) -> Self {
280        self.config
281            .accept_extensions
282            .push(normalize_ext(extension.as_ref()));
283        self
284    }
285
286    /// Only index pages whose URL path contains `substring` (additive). Other pages
287    /// are still crawled for links — this narrows *indexing*, not crawling.
288    #[must_use]
289    pub fn index_path(mut self, substring: impl Into<CompactString>) -> Self {
290        self.config.index_path_contains.push(substring.into());
291        self
292    }
293
294    /// Provide known validators (`If-None-Match`) for conditional re-crawls.
295    #[must_use]
296    pub fn validators(
297        mut self,
298        entries: impl IntoIterator<Item = (UrlKey, CompactString)>,
299    ) -> Self {
300        self.config.validators = entries.into_iter().collect();
301        self
302    }
303
304    /// Provide previously indexed outbound links so 304 pages keep feeding
305    /// the frontier.
306    #[must_use]
307    pub fn known_edges(mut self, entries: impl IntoIterator<Item = (UrlKey, Vec<Url>)>) -> Self {
308        self.config.known_edges = entries.into_iter().collect();
309        self
310    }
311
312    /// Child pages that failed after retries during the most recent crawl.
313    #[must_use]
314    pub fn failed_count(&self) -> u32 {
315        self.failed.load(std::sync::atomic::Ordering::Relaxed)
316    }
317
318    /// Drain the pages revalidated as unchanged (HTTP 304) during the most
319    /// recent crawl. They transferred no body and yielded no [`Page`], so this
320    /// is the only place their revalidation is visible — a freshness tracker
321    /// consumes it to record "checked, unchanged" against each URL.
322    #[must_use]
323    pub fn take_revalidated(&self) -> Vec<Url> {
324        std::mem::take(&mut self.revalidated.lock().expect("revalidated lock poisoned"))
325    }
326
327    /// Read-only view of the configuration.
328    #[must_use]
329    pub fn config(&self) -> &CrawlConfig {
330        &self.config
331    }
332}
333
334/// Strip a leading dot and lowercase an extension (`".MD"` → `"md"`).
335fn normalize_ext(ext: &str) -> CompactString {
336    CompactString::from(ext.trim_start_matches('.').to_ascii_lowercase())
337}
338
339/// Whether `url`'s path contains every required substring (vacuously true if none).
340fn path_allowed(url: &Url, required: &[CompactString]) -> bool {
341    let path = url.path();
342    required.iter().all(|s| path.contains(s.as_str()))
343}
344
345/// The extension of a URL path's final segment, if any — borrowed, no allocation.
346/// Comparisons against it use `eq_ignore_ascii_case`.
347fn ext_of(path: &str) -> Option<&str> {
348    path.rsplit('/')
349        .next()
350        .unwrap_or("")
351        .rsplit_once('.')
352        .map(|(_, e)| e)
353}
354
355/// Whether `url`'s final path segment ends with one of `extensions` (vacuously
356/// true if the list is empty). `extensions` are already normalized (dot-stripped,
357/// lowercased) by [`normalize_ext`]; the compare is case-insensitive regardless.
358fn extension_allowed(url: &Url, extensions: &[CompactString]) -> bool {
359    if extensions.is_empty() {
360        return true;
361    }
362    ext_of(url.path()).is_some_and(|e| {
363        extensions
364            .iter()
365            .any(|allow| allow.as_str().eq_ignore_ascii_case(e))
366    })
367}
368
369/// Extensions that clearly point at non-document assets the crawler skips.
370const ASSET_EXTS: &[&str] = &[
371    "png", "jpg", "jpeg", "gif", "svg", "webp", "ico", "css", "js", "mjs", "woff", "woff2", "ttf",
372    "eot", "zip", "tar", "gz", "mp4", "mp3", "wav", "avi", "mov", "wasm", "map",
373];
374
375/// Whether a URL clearly points at a non-document asset we should not crawl.
376/// Allocation-free (`eq_ignore_ascii_case`, no per-link lowercasing).
377fn is_asset(url: &Url) -> bool {
378    ext_of(url.path()).is_some_and(|e| ASSET_EXTS.iter().any(|a| e.eq_ignore_ascii_case(a)))
379}
380
381/// The backoff before a given retry attempt: `Retry-After` (clamped) for rate
382/// limits, otherwise the shared jittered exponential backoff.
383fn backoff_for(err: &Error, attempt: u32) -> Duration {
384    match err {
385        Error::RateLimited { retry_after_ms } => {
386            Duration::from_millis(*retry_after_ms).min(MAX_RETRY_AFTER)
387        }
388        _ => objstore_r::net::backoff(attempt + 1),
389    }
390}
391
392/// One paced, retried fetch. The returned future **owns** its [`Resource`], so the
393/// `&resource` borrow handed to [`Fetcher::fetch`] lives entirely inside the future
394/// — this is what lets many fetches run concurrently in a `FuturesUnordered` while
395/// keeping the trait's borrowing signature. The requested `Url` is moved back out
396/// on completion (no clone), tagged with its crawl `depth`.
397#[allow(clippy::too_many_arguments)] // one bounded call site in the fill loop
398async fn fetch_one<'a, F: Fetcher>(
399    fetcher: &'a F,
400    url: Url,
401    depth: u16,
402    start_delay: Duration,
403    max_bytes: Option<u64>,
404    user_agent: Option<&'a str>,
405    max_retries: u32,
406    etag: Option<CompactString>,
407) -> (Url, u16, Result<(FetchMeta, Bytes)>) {
408    if !start_delay.is_zero() {
409        futures_timer::Delay::new(start_delay).await;
410    }
411    let resource = Resource::new(url);
412    let opts = FetchOptions {
413        if_none_match: etag.as_deref(),
414        max_bytes,
415        user_agent,
416    };
417    let mut attempt = 0u32;
418    let outcome = loop {
419        match fetcher.fetch(&resource, opts).await {
420            Ok(got) => break got.payload.into_bytes().await.map(|b| (got.meta, b)),
421            Err(err) if attempt < max_retries && err.is_retriable() => {
422                futures_timer::Delay::new(backoff_for(&err, attempt)).await;
423                attempt += 1;
424            }
425            Err(err) => break Err(err),
426        }
427    };
428    (resource.url, depth, outcome)
429}
430
431/// Global request-start pacer. Assigns each launch a monotonically increasing
432/// start slot so request *starts* are at least `interval` apart regardless of how
433/// many workers run — `interval == 0` yields full concurrency. It never blocks the
434/// crawl loop: the returned delay is awaited inside the worker future.
435struct Pacer {
436    /// Next free start slot **per host**: politeness is a per-server
437    /// courtesy, so a multi-host crawl (subdomains, allowlist) neither
438    /// over-throttles across hosts nor lets one host absorb another's slots.
439    next_free: std::collections::HashMap<CompactString, Instant>,
440}
441
442impl Pacer {
443    fn new() -> Self {
444        Self {
445            next_free: std::collections::HashMap::new(),
446        }
447    }
448
449    /// Reserve the next start slot for `host`; returns how long that worker
450    /// must sleep first.
451    fn reserve(&mut self, host: &str, interval: Duration) -> Duration {
452        if interval.is_zero() {
453            return Duration::ZERO;
454        }
455        let now = Instant::now();
456        let entry = self
457            .next_free
458            .entry(CompactString::from(host))
459            .or_insert(now);
460        let slot = (*entry).max(now);
461        *entry = slot + interval;
462        slot - now
463    }
464}
465
466/// Per-host `robots.txt` policy, fetched once and cached for the crawl.
467#[cfg(feature = "robots")]
468struct HostPolicy {
469    /// The parsed rules, or `None` to allow everything (missing/invalid robots).
470    robot: Option<texting_robots::Robot>,
471    /// The host's advertised crawl delay (folded into the pacer).
472    crawl_delay: Duration,
473}
474
475/// The decision for one candidate URL.
476#[cfg(feature = "robots")]
477enum Verdict {
478    Allowed { crawl_delay: Duration },
479    Disallowed,
480}
481
482/// Caches `robots.txt` per host (keyed by scheme+authority), fetched through the
483/// same [`Fetcher`] (so a host-scoped auth token is applied on the same host and
484/// no bearer leaks to a foreign host). Cheap: one fetch per host, and crawls are
485/// typically single-host.
486#[cfg(feature = "robots")]
487struct RobotsCache {
488    by_host: std::collections::HashMap<CompactString, HostPolicy>,
489    agent: CompactString,
490}
491
492#[cfg(feature = "robots")]
493impl RobotsCache {
494    /// Maximum bytes read for a `robots.txt` (they are small; guards abuse).
495    const ROBOTS_MAX_BYTES: u64 = 512 * 1024;
496
497    fn new(agent: CompactString) -> Self {
498        Self {
499            by_host: std::collections::HashMap::new(),
500            agent,
501        }
502    }
503
504    /// The scheme+authority key a robots policy is cached under.
505    fn host_key(url: &Url) -> CompactString {
506        let mut key = CompactString::from(url.scheme());
507        key.push_str("://");
508        if let Some(host) = url.host_str() {
509            key.push_str(host);
510        }
511        if let Some(port) = url.port() {
512            key.push(':');
513            key.push_str(&port.to_string());
514        }
515        key
516    }
517
518    async fn check<F: Fetcher>(
519        &mut self,
520        fetcher: &F,
521        url: &Url,
522        user_agent: Option<&str>,
523    ) -> Verdict {
524        let key = Self::host_key(url);
525        if !self.by_host.contains_key(&key) {
526            let policy = Self::fetch_policy(fetcher, url, &self.agent, user_agent).await;
527            self.by_host.insert(key.clone(), policy);
528        }
529        let policy = &self.by_host[&key];
530        match &policy.robot {
531            Some(robot) if !robot.allowed(url.as_str()) => Verdict::Disallowed,
532            _ => Verdict::Allowed {
533                crawl_delay: policy.crawl_delay,
534            },
535        }
536    }
537
538    async fn fetch_policy<F: Fetcher>(
539        fetcher: &F,
540        url: &Url,
541        agent: &str,
542        user_agent: Option<&str>,
543    ) -> HostPolicy {
544        // Any failure (network, non-200, invalid syntax) means "allow everything".
545        let allow_all = HostPolicy {
546            robot: None,
547            crawl_delay: Duration::ZERO,
548        };
549        let mut robots_url = url.clone();
550        robots_url.set_path("/robots.txt");
551        robots_url.set_query(None);
552        robots_url.set_fragment(None);
553        let resource = Resource::new(robots_url);
554        let opts = FetchOptions {
555            if_none_match: None,
556            max_bytes: Some(Self::ROBOTS_MAX_BYTES),
557            user_agent,
558        };
559        let Ok(resp) = fetcher.fetch(&resource, opts).await else {
560            return allow_all;
561        };
562        let Ok(bytes) = resp.payload.into_bytes().await else {
563            return allow_all;
564        };
565        match texting_robots::Robot::new(agent, &bytes) {
566            Ok(robot) => {
567                let crawl_delay = robot
568                    .delay
569                    .and_then(|d| (d.is_finite() && d >= 0.0).then(|| Duration::from_secs_f32(d)))
570                    .unwrap_or(Duration::ZERO);
571                HostPolicy {
572                    robot: Some(robot),
573                    crawl_delay,
574                }
575            }
576            Err(_) => allow_all,
577        }
578    }
579}
580
581impl<F: Fetcher> Source for CrawlSource<F> {
582    type Pages<'a>
583        = impl futures::Stream<Item = Result<Page<'a>>> + Send + 'a
584    where
585        Self: 'a;
586
587    fn kind(&self) -> &'static str {
588        "crawl"
589    }
590
591    #[allow(clippy::too_many_lines)] // one cohesive crawl state machine
592    fn discover<'a>(&'a self, root: &'a SourceRef) -> Self::Pages<'a> {
593        async_stream::try_stream! {
594            let SourceRef::Http { root: root_url } = root else {
595                return Err(Error::crawl("crawl", "CrawlSource requires an Http SourceRef"))?;
596            };
597
598            let cfg = &self.config;
599            let concurrency = cfg.concurrency.max(1);
600            let ua = cfg.user_agent.as_deref();
601
602            // Crawl state lives entirely in this single-threaded stream body — the
603            // frontier, visited set, and pacer are plain locals, so there are no
604            // locks and no shared-mutable state despite N concurrent fetches.
605            let mut visited: HashSet<UrlKey> = HashSet::new();
606            let mut frontier: VecDeque<(Url, u16)> = VecDeque::new();
607            visited.insert(UrlKey::from_url(root_url));
608            frontier.push_back((root_url.clone(), 0));
609
610            let mut pacer = Pacer::new();
611            let mut in_flight = FuturesUnordered::new();
612            let mut successes = 0usize;
613            // Per-crawl records: reset here so "most recent crawl" stays true
614            // when one source runs discover() more than once.
615            self.failed.store(0, std::sync::atomic::Ordering::Relaxed);
616            self.revalidated.lock().expect("revalidated lock poisoned").clear();
617
618            #[cfg(feature = "robots")]
619            let mut robots = RobotsCache::new(
620                cfg.user_agent
621                    .clone()
622                    .unwrap_or_else(|| CompactString::from(crate::fetch::DEFAULT_UA)),
623            );
624
625            loop {
626                // Fill idle slots up to `concurrency`, never launching past the page
627                // budget. A failed fetch releases its budget (successes doesn't grow),
628                // matching the sequential semantics.
629                while in_flight.len() < concurrency
630                    && successes + in_flight.len() < cfg.max_pages
631                {
632                    let Some((url, depth)) = frontier.pop_front() else {
633                        break;
634                    };
635                    // Pacing interval for this request start: `min_delay`, widened
636                    // by any robots.txt crawl-delay when robots is honored.
637                    #[cfg(feature = "robots")]
638                    let interval = if cfg.respect_robots {
639                        match robots.check(&self.fetcher, &url, ua).await {
640                            Verdict::Allowed { crawl_delay } => cfg.min_delay.max(crawl_delay),
641                            Verdict::Disallowed => {
642                                if depth == 0 {
643                                    return Err(Error::crawl(
644                                        "crawl",
645                                        "root URL is disallowed by robots.txt",
646                                    ))?;
647                                }
648                                continue; // skip a disallowed child
649                            }
650                        }
651                    } else {
652                        cfg.min_delay
653                    };
654                    #[cfg(not(feature = "robots"))]
655                    let interval = cfg.min_delay;
656
657                    let delay = pacer.reserve(url.host_str().unwrap_or(""), interval);
658                    let etag = cfg.validators.get(&UrlKey::from_url(&url)).cloned();
659                    in_flight.push(fetch_one(
660                        &self.fetcher,
661                        url,
662                        depth,
663                        delay,
664                        cfg.max_bytes,
665                        ua,
666                        cfg.max_retries,
667                        etag,
668                    ));
669                }
670
671                let Some((req_url, depth, outcome)) = in_flight.next().await else {
672                    break; // frontier drained and nothing in flight
673                };
674                let (meta, bytes) = match outcome {
675                    Ok(v) => v,
676                    Err(Error::NotModified { .. }) => {
677                        // Revalidated 304: no body transferred, nothing to
678                        // re-index — but the page's previously indexed children
679                        // keep feeding the frontier so coverage never shrinks,
680                        // and the check itself is recorded for freshness
681                        // consumers (it is invisible in the page stream).
682                        let key = UrlKey::from_url(&req_url);
683                        visited.insert(key);
684                        self.revalidated
685                            .lock()
686                            .expect("revalidated lock poisoned")
687                            .push(req_url.clone());
688                        if depth < cfg.max_depth {
689                            if let Some(children) = cfg.known_edges.get(&key) {
690                                for child in children {
691                                    if is_asset(child)
692                                        || !cfg.scope.allows(root_url, child)
693                                        || !path_allowed(child, &cfg.require_path_contains)
694                                    {
695                                        continue;
696                                    }
697                                    if visited.insert(UrlKey::from_url(child)) {
698                                        frontier.push_back((child.clone(), depth + 1));
699                                    }
700                                }
701                            }
702                        }
703                        continue;
704                    }
705                    Err(e) => {
706                        if depth == 0 {
707                            return Err(e)?; // the root must succeed
708                        }
709                        self.failed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
710                        continue; // skip a single failed page, freeing its budget
711                    }
712                };
713                successes += 1;
714
715                // Follow any redirect: index/scope/link-resolve against the final
716                // location so a redirect can't escape scope or split the dedup key.
717                let effective = meta.final_url.unwrap_or(req_url);
718                if depth > 0 && !cfg.scope.allows(root_url, &effective) {
719                    continue; // a child redirected out of scope
720                }
721                visited.insert(UrlKey::from_url(&effective));
722                let kind = meta.kind;
723
724                // Enqueue in-scope child links before yielding (we own the bytes).
725                if depth < cfg.max_depth && kind.is_linkable() {
726                    for href in extract_links_for(kind, &bytes) {
727                        let Ok(mut child) = effective.join(&href) else {
728                            continue;
729                        };
730                        child.set_fragment(None);
731                        if child.scheme() != "http" && child.scheme() != "https" {
732                            continue;
733                        }
734                        if is_asset(&child)
735                            || !cfg.scope.allows(root_url, &child)
736                            || !path_allowed(&child, &cfg.require_path_contains)
737                        {
738                            continue;
739                        }
740                        if visited.insert(UrlKey::from_url(&child)) {
741                            frontier.push_back((child, depth + 1));
742                        }
743                    }
744                }
745
746                // Index filters govern indexing only (links were already followed
747                // above). Skip yielding pages that don't match file-type / path.
748                if !extension_allowed(&effective, &cfg.accept_extensions)
749                    || !path_allowed(&effective, &cfg.index_path_contains)
750                {
751                    continue;
752                }
753
754                let mut out = Resource::new(effective).with_kind(kind);
755                if let Some(tag) = meta.etag {
756                    out = out.with_etag(tag);
757                }
758                yield Page::new(out, DocPayload::Owned(bytes));
759            }
760        }
761    }
762}
763
764#[cfg(test)]
765mod tests {
766    use super::*;
767    use crate::fetch::{FetchMeta, Fetched};
768    use crate::resource::ResourceKind;
769    use bytes::Bytes;
770    use futures::StreamExt;
771    use std::collections::HashMap;
772    use std::future::Ready;
773
774    /// An in-memory mock site: URL → (content-type kind, body).
775    struct MockSite {
776        pages: HashMap<String, (ResourceKind, Vec<u8>)>,
777    }
778
779    impl Fetcher for MockSite {
780        type FetchFuture<'a> = Ready<Result<Fetched<'a>>>;
781        fn fetch<'a>(
782            &'a self,
783            resource: &'a Resource,
784            _opts: FetchOptions<'a>,
785        ) -> Self::FetchFuture<'a> {
786            let key = crate::url_key::canonicalize(&resource.url);
787            let result = match self.pages.get(&key) {
788                Some((kind, body)) => Ok(Fetched {
789                    meta: FetchMeta {
790                        kind: *kind,
791                        etag: None,
792                        status: 200,
793                        final_url: None,
794                    },
795                    payload: DocPayload::Owned(Bytes::from(body.clone())),
796                }),
797                None => Err(Error::not_found(resource.url.as_str())),
798            };
799            std::future::ready(result)
800        }
801    }
802
803    fn page(href_targets: &[&str]) -> Vec<u8> {
804        use std::fmt::Write as _;
805        let mut html = String::from("<html><body><p>content here</p>");
806        for t in href_targets {
807            let _ = write!(html, "<a href=\"{t}\">link</a>");
808        }
809        html.push_str("</body></html>");
810        html.into_bytes()
811    }
812
813    fn mock_site() -> MockSite {
814        let mut pages = HashMap::new();
815        let h = ResourceKind::Html;
816        // root links to /docs/a and /docs/b; a links to /docs/c; also an off-scope link.
817        pages.insert(
818            "https://x.dev/docs".to_string(),
819            (
820                h,
821                page(&["/docs/a", "/docs/b", "https://other.dev/x", "/about"]),
822            ),
823        );
824        pages.insert("https://x.dev/docs/a".to_string(), (h, page(&["/docs/c"])));
825        pages.insert("https://x.dev/docs/b".to_string(), (h, page(&[])));
826        pages.insert("https://x.dev/docs/c".to_string(), (h, page(&[])));
827        pages.insert("https://x.dev/about".to_string(), (h, page(&[]))); // out of path scope
828        pages.insert("https://other.dev/x".to_string(), (h, page(&[]))); // off host
829        MockSite { pages }
830    }
831
832    async fn crawl_urls<F: Fetcher>(source: &CrawlSource<F>, root: &SourceRef) -> Vec<String> {
833        let mut urls = Vec::new();
834        let stream = source.discover(root);
835        futures::pin_mut!(stream);
836        while let Some(item) = stream.next().await {
837            urls.push(item.unwrap().resource.url.to_string());
838        }
839        urls
840    }
841
842    #[tokio::test]
843    async fn crawls_within_path_prefix_to_depth() {
844        let source = CrawlSource::new(mock_site()).depth(2);
845        let root = SourceRef::http("https://x.dev/docs").unwrap();
846        let urls = crawl_urls(&source, &root).await;
847
848        // path-prefix scope: /about and other.dev excluded; /docs/* included to depth 2.
849        assert!(urls.contains(&"https://x.dev/docs".to_string()));
850        assert!(urls.contains(&"https://x.dev/docs/a".to_string()));
851        assert!(urls.contains(&"https://x.dev/docs/b".to_string()));
852        assert!(urls.contains(&"https://x.dev/docs/c".to_string()));
853        assert!(!urls.iter().any(|u| u.contains("about")));
854        assert!(!urls.iter().any(|u| u.contains("other.dev")));
855    }
856
857    #[tokio::test]
858    async fn markdown_pages_are_followed_and_yield_links() {
859        let mut pages = HashMap::new();
860        pages.insert(
861            "https://x.dev/docs".to_string(),
862            (
863                ResourceKind::Markdown,
864                b"# Docs\n\nSee [the guide](/docs/guide) and <https://x.dev/docs/ref>.\n".to_vec(),
865            ),
866        );
867        pages.insert(
868            "https://x.dev/docs/guide".to_string(),
869            (ResourceKind::Markdown, b"# Guide\n\nBody text.\n".to_vec()),
870        );
871        pages.insert(
872            "https://x.dev/docs/ref".to_string(),
873            (ResourceKind::Markdown, b"# Ref\n\nBody text.\n".to_vec()),
874        );
875        let source = CrawlSource::new(MockSite { pages }).depth(2);
876        let root = SourceRef::http("https://x.dev/docs").unwrap();
877        let urls = crawl_urls(&source, &root).await;
878        assert!(
879            urls.contains(&"https://x.dev/docs/guide".to_string()),
880            "inline md link followed"
881        );
882        assert!(
883            urls.contains(&"https://x.dev/docs/ref".to_string()),
884            "autolink followed"
885        );
886    }
887
888    /// A site that answers 304 when the request carries the matching etag,
889    /// and counts full-body responses.
890    struct EtagSite {
891        pages: HashMap<String, (ResourceKind, Vec<u8>, &'static str)>,
892        bodies_served: std::sync::atomic::AtomicU32,
893    }
894
895    impl Fetcher for EtagSite {
896        type FetchFuture<'a> = Ready<Result<Fetched<'a>>>;
897        fn fetch<'a>(
898            &'a self,
899            resource: &'a Resource,
900            opts: FetchOptions<'a>,
901        ) -> Self::FetchFuture<'a> {
902            let key = crate::url_key::canonicalize(&resource.url);
903            let result = match self.pages.get(&key) {
904                Some((_, _, etag)) if opts.if_none_match == Some(etag) => {
905                    Err(Error::not_modified(resource.url.as_str()))
906                }
907                Some((kind, body, etag)) => {
908                    self.bodies_served
909                        .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
910                    Ok(Fetched {
911                        meta: FetchMeta {
912                            kind: *kind,
913                            etag: Some((*etag).into()),
914                            status: 200,
915                            final_url: None,
916                        },
917                        payload: DocPayload::Owned(Bytes::from(body.clone())),
918                    })
919                }
920                None => Err(Error::not_found(resource.url.as_str())),
921            };
922            std::future::ready(result)
923        }
924    }
925
926    #[tokio::test]
927    async fn revalidated_pages_transfer_no_body_and_known_edges_keep_coverage() {
928        let mut pages = HashMap::new();
929        let h = ResourceKind::Html;
930        pages.insert(
931            "https://x.dev/docs".to_string(),
932            (h, page(&["/docs/a"]), "\"r\""),
933        );
934        pages.insert(
935            "https://x.dev/docs/a".to_string(),
936            (h, page(&["/docs/c"]), "\"a\""),
937        );
938        pages.insert("https://x.dev/docs/c".to_string(), (h, page(&[]), "\"c\""));
939        let site = EtagSite {
940            pages,
941            bodies_served: std::sync::atomic::AtomicU32::new(0),
942        };
943
944        let root_url = url::Url::parse("https://x.dev/docs").unwrap();
945        let a_url = url::Url::parse("https://x.dev/docs/a").unwrap();
946        let c_url = url::Url::parse("https://x.dev/docs/c").unwrap();
947        let (rk, ak) = (UrlKey::from_url(&root_url), UrlKey::from_url(&a_url));
948
949        // Everything known + unchanged: zero bodies transferred, and the
950        // stored edges keep the whole frontier reachable (c is visited via
951        // known edges of a, itself revalidated).
952        let source = CrawlSource::new(site)
953            .depth(2)
954            .validators([
955                (rk, "\"r\"".into()),
956                (ak, "\"a\"".into()),
957                (UrlKey::from_url(&c_url), "\"c\"".into()),
958            ])
959            .known_edges([(rk, vec![a_url.clone()]), (ak, vec![c_url.clone()])]);
960        let root = SourceRef::http("https://x.dev/docs").unwrap();
961        let urls = crawl_urls(&source, &root).await;
962        assert!(urls.is_empty(), "no page yielded: everything revalidated");
963        assert_eq!(
964            source
965                .fetcher
966                .bodies_served
967                .load(std::sync::atomic::Ordering::Relaxed),
968            0
969        );
970
971        // Only the leaf changed: exactly one body transfers.
972        let mut pages = HashMap::new();
973        pages.insert(
974            "https://x.dev/docs".to_string(),
975            (h, page(&["/docs/a"]), "\"r\""),
976        );
977        pages.insert(
978            "https://x.dev/docs/a".to_string(),
979            (h, page(&["/docs/c"]), "\"a\""),
980        );
981        pages.insert("https://x.dev/docs/c".to_string(), (h, page(&[]), "\"c2\""));
982        let site = EtagSite {
983            pages,
984            bodies_served: std::sync::atomic::AtomicU32::new(0),
985        };
986        let source = CrawlSource::new(site)
987            .depth(2)
988            .validators([
989                (rk, "\"r\"".into()),
990                (ak, "\"a\"".into()),
991                (UrlKey::from_url(&c_url), "\"c\"".into()),
992            ])
993            .known_edges([(rk, vec![a_url]), (ak, vec![c_url])]);
994        let urls = crawl_urls(&source, &root).await;
995        assert_eq!(urls, vec!["https://x.dev/docs/c".to_string()]);
996        assert_eq!(
997            source
998                .fetcher
999                .bodies_served
1000                .load(std::sync::atomic::Ordering::Relaxed),
1001            1
1002        );
1003        assert_eq!(
1004            source.take_revalidated().len(),
1005            2,
1006            "root and a revalidated; c changed"
1007        );
1008    }
1009
1010    #[tokio::test]
1011    async fn child_failures_are_counted_not_silent() {
1012        let mut pages = HashMap::new();
1013        let h = ResourceKind::Html;
1014        pages.insert(
1015            "https://x.dev/docs".to_string(),
1016            (h, page(&["/docs/dead", "/docs/b"])),
1017        );
1018        pages.insert("https://x.dev/docs/b".to_string(), (h, page(&[])));
1019        // /docs/dead is absent → NotFound (non-retriable) → counted.
1020        let source = CrawlSource::new(MockSite { pages }).depth(1);
1021        let root = SourceRef::http("https://x.dev/docs").unwrap();
1022        let urls = crawl_urls(&source, &root).await;
1023        assert_eq!(urls.len(), 2, "root + b");
1024        assert_eq!(source.failed_count(), 1, "the dead child is counted");
1025    }
1026
1027    #[tokio::test]
1028    async fn depth_zero_only_fetches_root() {
1029        let source = CrawlSource::new(mock_site()).depth(0);
1030        let root = SourceRef::http("https://x.dev/docs").unwrap();
1031        let urls = crawl_urls(&source, &root).await;
1032        assert_eq!(urls, vec!["https://x.dev/docs".to_string()]);
1033    }
1034
1035    #[tokio::test]
1036    async fn depth_one_stops_before_grandchildren() {
1037        let source = CrawlSource::new(mock_site()).depth(1);
1038        let root = SourceRef::http("https://x.dev/docs").unwrap();
1039        let urls = crawl_urls(&source, &root).await;
1040        // root + a + b, but not c (a grandchild reached via a).
1041        assert!(urls.contains(&"https://x.dev/docs/a".to_string()));
1042        assert!(!urls.contains(&"https://x.dev/docs/c".to_string()));
1043    }
1044
1045    #[tokio::test]
1046    async fn max_pages_caps_the_crawl() {
1047        let source = CrawlSource::new(mock_site()).depth(5).max_pages(2);
1048        let root = SourceRef::http("https://x.dev/docs").unwrap();
1049        let urls = crawl_urls(&source, &root).await;
1050        assert_eq!(urls.len(), 2);
1051    }
1052
1053    #[tokio::test]
1054    async fn dedups_revisited_links() {
1055        // a page that links to itself and a sibling repeatedly.
1056        let mut pages = HashMap::new();
1057        pages.insert(
1058            "https://x.dev/docs".to_string(),
1059            (ResourceKind::Html, page(&["/docs", "/docs/a", "/docs/a"])),
1060        );
1061        pages.insert(
1062            "https://x.dev/docs/a".to_string(),
1063            (ResourceKind::Html, page(&["/docs"])),
1064        );
1065        let source = CrawlSource::new(MockSite { pages }).depth(3);
1066        let root = SourceRef::http("https://x.dev/docs").unwrap();
1067        let urls = crawl_urls(&source, &root).await;
1068        assert_eq!(urls.len(), 2, "each canonical URL fetched once");
1069    }
1070
1071    #[tokio::test]
1072    async fn same_host_scope_allows_other_paths() {
1073        let source = CrawlSource::new(mock_site())
1074            .depth(2)
1075            .scope(CrawlScope::SameHost);
1076        let root = SourceRef::http("https://x.dev/docs").unwrap();
1077        let urls = crawl_urls(&source, &root).await;
1078        assert!(
1079            urls.iter().any(|u| u.contains("/about")),
1080            "same-host allows /about"
1081        );
1082        assert!(!urls.iter().any(|u| u.contains("other.dev")));
1083    }
1084
1085    /// A GitHub-shaped site: `/tree/` directories and `/blob/` files, plus an
1086    /// out-of-docs source file, an external link, and an image asset.
1087    fn github_mock() -> MockSite {
1088        let mut pages = HashMap::new();
1089        let h = ResourceKind::Html;
1090        pages.insert(
1091            "https://gh.dev/o/s4/tree/main/docs".to_string(),
1092            (
1093                h,
1094                page(&[
1095                    "/o/s4/blob/main/docs/install.md",
1096                    "/o/s4/tree/main/docs/ops",
1097                    "/o/s4/blob/main/docs/diagram.png", // asset
1098                    "/o/s4/blob/main/src/lib.rs",       // out of /main/docs
1099                    "https://external.dev/x",           // off host
1100                ]),
1101            ),
1102        );
1103        pages.insert(
1104            "https://gh.dev/o/s4/blob/main/docs/install.md".to_string(),
1105            (h, page(&["/o/s4/blob/main/docs/configuration.md"])),
1106        );
1107        pages.insert(
1108            "https://gh.dev/o/s4/blob/main/docs/configuration.md".to_string(),
1109            (h, page(&[])),
1110        );
1111        pages.insert(
1112            "https://gh.dev/o/s4/tree/main/docs/ops".to_string(),
1113            (h, page(&["/o/s4/blob/main/docs/ops/runbook.md"])),
1114        );
1115        pages.insert(
1116            "https://gh.dev/o/s4/blob/main/docs/ops/runbook.md".to_string(),
1117            (h, page(&[])),
1118        );
1119        pages.insert(
1120            "https://gh.dev/o/s4/blob/main/src/lib.rs".to_string(),
1121            (h, page(&[])),
1122        );
1123        pages.insert("https://external.dev/x".to_string(), (h, page(&[])));
1124        MockSite { pages }
1125    }
1126
1127    #[tokio::test]
1128    async fn path_filter_confines_crawl_to_repo_subpath() {
1129        // Same-host keeps us on gh.dev; require_path confines to this repo's docs,
1130        // following both `/tree/` dirs and `/blob/` files.
1131        let source = CrawlSource::new(github_mock())
1132            .depth(4)
1133            .scope(CrawlScope::SameHost)
1134            .require_path("/o/s4")
1135            .require_path("/main/docs");
1136        let root = SourceRef::http("https://gh.dev/o/s4/tree/main/docs").unwrap();
1137        let urls = crawl_urls(&source, &root).await;
1138
1139        assert!(urls.iter().any(|u| u.ends_with("/docs/install.md")));
1140        assert!(urls.iter().any(|u| u.ends_with("/docs/configuration.md")));
1141        assert!(
1142            urls.iter().any(|u| u.ends_with("/docs/ops/runbook.md")),
1143            "followed tree → blob"
1144        );
1145        // Excluded: source file (no /main/docs), external host, image asset.
1146        assert!(!urls.iter().any(|u| u.contains("/src/lib.rs")));
1147        assert!(!urls.iter().any(|u| u.contains("external.dev")));
1148        assert!(!urls.iter().any(|u| u.contains(".png")));
1149    }
1150
1151    #[tokio::test]
1152    async fn file_type_filter_indexes_only_markdown() {
1153        let source = CrawlSource::new(github_mock())
1154            .depth(4)
1155            .scope(CrawlScope::SameHost)
1156            .require_path("/o/s4")
1157            .require_path("/main/docs")
1158            .accept_extension("md");
1159        let root = SourceRef::http("https://gh.dev/o/s4/tree/main/docs").unwrap();
1160        let urls = crawl_urls(&source, &root).await;
1161
1162        // Only `.md` pages are indexed; the `/tree/` directory pages are crawled
1163        // for links (so runbook.md is reached) but not themselves indexed.
1164        assert!(
1165            urls.iter().all(|u| u.rsplit('.').next() == Some("md")),
1166            "non-md indexed: {urls:?}"
1167        );
1168        assert!(
1169            urls.iter().any(|u| u.ends_with("/docs/ops/runbook.md")),
1170            "tree page was still followed"
1171        );
1172        assert!(!urls.iter().any(|u| u.ends_with("/tree/main/docs")));
1173        assert_eq!(urls.len(), 3); // install, configuration, runbook
1174    }
1175
1176    #[tokio::test]
1177    async fn index_path_filter_collapses_duplicate_views() {
1178        // GitHub exposes each file at /blob/, /raw/, and /commits/. Follow broadly
1179        // within /main/docs, but index only the canonical /blob/ view.
1180        let mut pages = HashMap::new();
1181        let h = ResourceKind::Html;
1182        pages.insert(
1183            "https://gh.dev/o/s4/tree/main/docs".to_string(),
1184            (h, page(&["/o/s4/blob/main/docs/a.md"])),
1185        );
1186        pages.insert(
1187            "https://gh.dev/o/s4/blob/main/docs/a.md".to_string(),
1188            (
1189                h,
1190                page(&["/o/s4/raw/main/docs/a.md", "/o/s4/commits/main/docs/a.md"]),
1191            ),
1192        );
1193        pages.insert(
1194            "https://gh.dev/o/s4/raw/main/docs/a.md".to_string(),
1195            (h, page(&[])),
1196        );
1197        pages.insert(
1198            "https://gh.dev/o/s4/commits/main/docs/a.md".to_string(),
1199            (h, page(&[])),
1200        );
1201
1202        let source = CrawlSource::new(MockSite { pages })
1203            .depth(4)
1204            .scope(CrawlScope::SameHost)
1205            .require_path("/main/docs")
1206            .index_path("/blob/");
1207        let root = SourceRef::http("https://gh.dev/o/s4/tree/main/docs").unwrap();
1208        let urls = crawl_urls(&source, &root).await;
1209
1210        // Exactly one indexed entry — the blob view; raw/commits/tree excluded.
1211        assert_eq!(urls.len(), 1, "duplicate views collapsed: {urls:?}");
1212        assert!(urls[0].contains("/blob/main/docs/a.md"));
1213        assert!(!urls
1214            .iter()
1215            .any(|u| u.contains("/raw/") || u.contains("/commits/")));
1216    }
1217
1218    #[test]
1219    fn extension_and_path_helpers() {
1220        let md = Url::parse("https://gh.dev/o/s4/blob/main/docs/a.md").unwrap();
1221        let dir = Url::parse("https://gh.dev/o/s4/tree/main/docs").unwrap();
1222        let exts = [CompactString::from("md")];
1223        assert!(extension_allowed(&md, &exts));
1224        assert!(!extension_allowed(&dir, &exts));
1225        assert!(extension_allowed(&dir, &[])); // empty = all
1226
1227        let req = [
1228            CompactString::from("/o/s4"),
1229            CompactString::from("/main/docs"),
1230        ];
1231        assert!(path_allowed(&md, &req));
1232        assert!(!path_allowed(
1233            &Url::parse("https://gh.dev/o/s4/blob/main/src/x.rs").unwrap(),
1234            &req
1235        ));
1236    }
1237
1238    #[tokio::test]
1239    async fn concurrency_yields_same_set_as_sequential() {
1240        let root = SourceRef::http("https://x.dev/docs").unwrap();
1241        let seq = CrawlSource::new(mock_site()).depth(3).concurrency(1);
1242        let par = CrawlSource::new(mock_site()).depth(3).concurrency(4);
1243        let mut a = crawl_urls(&seq, &root).await;
1244        let mut b = crawl_urls(&par, &root).await;
1245        a.sort();
1246        b.sort();
1247        assert_eq!(a, b, "result set must be independent of concurrency");
1248    }
1249
1250    #[test]
1251    fn backoff_is_bounded_and_honors_retry_after() {
1252        // Rate limit uses Retry-After, clamped to MAX_RETRY_AFTER.
1253        assert_eq!(
1254            backoff_for(&Error::rate_limited(5_000), 0),
1255            Duration::from_millis(5_000)
1256        );
1257        assert_eq!(
1258            backoff_for(&Error::rate_limited(10_000_000), 3),
1259            MAX_RETRY_AFTER
1260        );
1261        // Other retriable errors back off with the shared jittered curve.
1262        for attempt in 0..10 {
1263            let d = backoff_for(&Error::http(503, "x"), attempt);
1264            assert!(d <= Duration::from_secs(5), "{d:?}");
1265        }
1266    }
1267
1268    #[test]
1269    fn pacer_spaces_starts_per_host_and_is_free_when_zero() {
1270        let mut p = Pacer::new();
1271        assert_eq!(p.reserve("a.dev", Duration::ZERO), Duration::ZERO);
1272        assert_eq!(p.reserve("a.dev", Duration::ZERO), Duration::ZERO);
1273        // With an interval, successive reservations schedule ~interval apart.
1274        let mut p = Pacer::new();
1275        let d0 = p.reserve("a.dev", Duration::from_millis(20));
1276        let d1 = p.reserve("a.dev", Duration::from_millis(20));
1277        let d2 = p.reserve("a.dev", Duration::from_millis(20));
1278        assert!(d0 < Duration::from_millis(5), "first start is immediate");
1279        assert!(d1 >= Duration::from_millis(15), "second start paced");
1280        assert!(d2 >= Duration::from_millis(35), "third start paced");
1281        // A different host has its own schedule: politeness is per server.
1282        assert!(
1283            p.reserve("b.dev", Duration::from_millis(20)) < Duration::from_millis(5),
1284            "another host starts immediately"
1285        );
1286    }
1287
1288    /// A mock that records peak concurrent in-flight fetches and holds each fetch
1289    /// open briefly so overlap is observable.
1290    struct PeakSite {
1291        pages: HashMap<String, Vec<u8>>,
1292        current: std::sync::Arc<std::sync::atomic::AtomicUsize>,
1293        peak: std::sync::Arc<std::sync::atomic::AtomicUsize>,
1294    }
1295
1296    impl Fetcher for PeakSite {
1297        type FetchFuture<'a>
1298            = impl std::future::Future<Output = Result<Fetched<'a>>> + Send + 'a
1299        where
1300            Self: 'a;
1301        fn fetch<'a>(
1302            &'a self,
1303            resource: &'a Resource,
1304            _opts: FetchOptions<'a>,
1305        ) -> Self::FetchFuture<'a> {
1306            use std::sync::atomic::Ordering::SeqCst;
1307            async move {
1308                let cur = self.current.fetch_add(1, SeqCst) + 1;
1309                self.peak.fetch_max(cur, SeqCst);
1310                futures_timer::Delay::new(Duration::from_millis(10)).await;
1311                self.current.fetch_sub(1, SeqCst);
1312                match self.pages.get(&crate::url_key::canonicalize(&resource.url)) {
1313                    Some(body) => Ok(Fetched {
1314                        meta: FetchMeta {
1315                            kind: ResourceKind::Html,
1316                            etag: None,
1317                            status: 200,
1318                            final_url: None,
1319                        },
1320                        payload: DocPayload::Owned(Bytes::from(body.clone())),
1321                    }),
1322                    None => Err(Error::not_found(resource.url.as_str())),
1323                }
1324            }
1325        }
1326    }
1327
1328    #[tokio::test(flavor = "multi_thread", worker_threads = 2)]
1329    async fn peak_in_flight_never_exceeds_concurrency() {
1330        // A wide fan-out so many links are ready to fetch at once.
1331        let mut pages = HashMap::new();
1332        let children: Vec<String> = (0..12).map(|i| format!("/docs/{i}")).collect();
1333        let refs: Vec<&str> = children.iter().map(String::as_str).collect();
1334        pages.insert("https://x.dev/docs".to_string(), page(&refs));
1335        for c in &children {
1336            pages.insert(format!("https://x.dev{c}"), page(&[]));
1337        }
1338        let current = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1339        let peak = std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0));
1340        let site = PeakSite {
1341            pages,
1342            current: current.clone(),
1343            peak: peak.clone(),
1344        };
1345        let source = CrawlSource::new(site).depth(2).concurrency(4);
1346        let root = SourceRef::http("https://x.dev/docs").unwrap();
1347        let urls = crawl_urls(&source, &root).await;
1348        assert_eq!(urls.len(), 13); // root + 12 children
1349        let observed = peak.load(std::sync::atomic::Ordering::SeqCst);
1350        assert!(observed <= 4, "peak {observed} exceeded concurrency 4");
1351        assert!(observed >= 2, "expected real overlap, peak was {observed}");
1352    }
1353
1354    /// A mock that fails a URL a fixed number of times (with a chosen error) before
1355    /// serving it, counting attempts per URL.
1356    struct FlakySite {
1357        body: Vec<u8>,
1358        fail_times: u32,
1359        status: u16,
1360        attempts: std::sync::Mutex<HashMap<String, u32>>,
1361    }
1362
1363    impl Fetcher for FlakySite {
1364        type FetchFuture<'a>
1365            = std::future::Ready<Result<Fetched<'a>>>
1366        where
1367            Self: 'a;
1368        fn fetch<'a>(
1369            &'a self,
1370            resource: &'a Resource,
1371            _opts: FetchOptions<'a>,
1372        ) -> Self::FetchFuture<'a> {
1373            let key = crate::url_key::canonicalize(&resource.url);
1374            let n = {
1375                let mut map = self.attempts.lock().unwrap();
1376                let e = map.entry(key).or_insert(0);
1377                *e += 1;
1378                *e
1379            };
1380            let result = if n <= self.fail_times {
1381                Err(Error::http(self.status, resource.url.as_str()))
1382            } else {
1383                Ok(Fetched {
1384                    meta: FetchMeta {
1385                        kind: ResourceKind::Html,
1386                        etag: None,
1387                        status: 200,
1388                        final_url: None,
1389                    },
1390                    payload: DocPayload::Owned(Bytes::from(self.body.clone())),
1391                })
1392            };
1393            std::future::ready(result)
1394        }
1395    }
1396
1397    #[tokio::test]
1398    async fn retries_retriable_then_succeeds() {
1399        let site = FlakySite {
1400            body: page(&[]),
1401            fail_times: 2, // fails twice (503), succeeds on the third attempt
1402            status: 503,
1403            attempts: std::sync::Mutex::new(HashMap::new()),
1404        };
1405        let source = CrawlSource::new(site).depth(0).max_retries(2);
1406        let root = SourceRef::http("https://x.dev/docs").unwrap();
1407        let urls = crawl_urls(&source, &root).await;
1408        assert_eq!(urls, vec!["https://x.dev/docs".to_string()]);
1409    }
1410
1411    #[tokio::test]
1412    async fn non_retriable_is_not_retried() {
1413        // 404 is not retriable: exactly one attempt, root fails → crawl errors.
1414        let site = FlakySite {
1415            body: page(&[]),
1416            fail_times: 1,
1417            status: 404,
1418            attempts: std::sync::Mutex::new(HashMap::new()),
1419        };
1420        let source = CrawlSource::new(site).depth(0).max_retries(5);
1421        let root = SourceRef::http("https://x.dev/docs").unwrap();
1422        let stream = source.discover(&root);
1423        futures::pin_mut!(stream);
1424        let first = stream.next().await;
1425        assert!(matches!(first, Some(Err(_))), "root 404 should error");
1426        // 404 is non-retriable, so exactly one attempt was made.
1427        let attempts = source.fetcher.attempts.lock().unwrap();
1428        assert_eq!(attempts.values().copied().max(), Some(1));
1429    }
1430
1431    #[tokio::test]
1432    async fn exhausted_retries_abort_root() {
1433        // Always-503 root, exhausts retries → crawl errors (root must succeed).
1434        let site = FlakySite {
1435            body: page(&[]),
1436            fail_times: u32::MAX,
1437            status: 503,
1438            attempts: std::sync::Mutex::new(HashMap::new()),
1439        };
1440        let source = CrawlSource::new(site).depth(0).max_retries(1);
1441        let root = SourceRef::http("https://x.dev/docs").unwrap();
1442        let stream = source.discover(&root);
1443        futures::pin_mut!(stream);
1444        assert!(matches!(stream.next().await, Some(Err(_))));
1445        // 1 initial + 1 retry = 2 attempts.
1446        let attempts = source.fetcher.attempts.lock().unwrap();
1447        assert_eq!(attempts.values().copied().max(), Some(2));
1448    }
1449
1450    #[cfg(feature = "robots")]
1451    mod robots_tests {
1452        use super::*;
1453
1454        /// A mock site that also serves `/robots.txt` and counts how many times it
1455        /// was requested (to prove per-host caching).
1456        struct RobotsMock {
1457            pages: HashMap<String, Vec<u8>>,
1458            robots: HashMap<String, Vec<u8>>, // host_key -> robots.txt body
1459            robots_hits: std::sync::Mutex<HashMap<String, u32>>,
1460        }
1461
1462        impl Fetcher for RobotsMock {
1463            type FetchFuture<'a>
1464                = std::future::Ready<Result<Fetched<'a>>>
1465            where
1466                Self: 'a;
1467            fn fetch<'a>(
1468                &'a self,
1469                resource: &'a Resource,
1470                _opts: FetchOptions<'a>,
1471            ) -> Self::FetchFuture<'a> {
1472                let url = &resource.url;
1473                let result = if url.path() == "/robots.txt" {
1474                    let key = RobotsCache::host_key(url).to_string();
1475                    *self
1476                        .robots_hits
1477                        .lock()
1478                        .unwrap()
1479                        .entry(key.clone())
1480                        .or_insert(0) += 1;
1481                    match self.robots.get(&key) {
1482                        Some(body) => Ok(ok_page(ResourceKind::Text, body.clone())),
1483                        None => Err(Error::not_found(url.as_str())),
1484                    }
1485                } else {
1486                    match self.pages.get(&crate::url_key::canonicalize(url)) {
1487                        Some(body) => Ok(ok_page(ResourceKind::Html, body.clone())),
1488                        None => Err(Error::not_found(url.as_str())),
1489                    }
1490                };
1491                std::future::ready(result)
1492            }
1493        }
1494
1495        fn ok_page(kind: ResourceKind, body: Vec<u8>) -> Fetched<'static> {
1496            Fetched {
1497                meta: FetchMeta {
1498                    kind,
1499                    etag: None,
1500                    status: 200,
1501                    final_url: None,
1502                },
1503                payload: DocPayload::Owned(Bytes::from(body)),
1504            }
1505        }
1506
1507        fn site(robots_txt: Option<&str>) -> RobotsMock {
1508            let mut pages = HashMap::new();
1509            pages.insert(
1510                "https://x.dev/docs".to_string(),
1511                page(&["/docs/public", "/docs/private"]),
1512            );
1513            pages.insert("https://x.dev/docs/public".to_string(), page(&[]));
1514            pages.insert("https://x.dev/docs/private".to_string(), page(&[]));
1515            let mut robots = HashMap::new();
1516            if let Some(txt) = robots_txt {
1517                robots.insert("https://x.dev".to_string(), txt.as_bytes().to_vec());
1518            }
1519            RobotsMock {
1520                pages,
1521                robots,
1522                robots_hits: std::sync::Mutex::new(HashMap::new()),
1523            }
1524        }
1525
1526        #[tokio::test]
1527        async fn disallowed_child_is_skipped() {
1528            let src = CrawlSource::new(site(Some("User-agent: *\nDisallow: /docs/private")))
1529                .depth(2)
1530                .scope(CrawlScope::SameHost)
1531                .respect_robots(true);
1532            let root = SourceRef::http("https://x.dev/docs").unwrap();
1533            let urls = crawl_urls(&src, &root).await;
1534            assert!(urls.iter().any(|u| u.ends_with("/docs/public")));
1535            assert!(
1536                !urls.iter().any(|u| u.contains("/docs/private")),
1537                "robots-disallowed child must be skipped: {urls:?}"
1538            );
1539        }
1540
1541        #[tokio::test]
1542        async fn disallowed_root_errors() {
1543            let src = CrawlSource::new(site(Some("User-agent: *\nDisallow: /")))
1544                .depth(1)
1545                .respect_robots(true);
1546            let root = SourceRef::http("https://x.dev/docs").unwrap();
1547            let stream = src.discover(&root);
1548            futures::pin_mut!(stream);
1549            assert!(matches!(stream.next().await, Some(Err(_))));
1550        }
1551
1552        #[tokio::test]
1553        async fn missing_robots_allows_all() {
1554            let src = CrawlSource::new(site(None)) // 404 robots.txt => allow-all
1555                .depth(2)
1556                .scope(CrawlScope::SameHost)
1557                .respect_robots(true);
1558            let root = SourceRef::http("https://x.dev/docs").unwrap();
1559            let urls = crawl_urls(&src, &root).await;
1560            assert!(urls.iter().any(|u| u.ends_with("/docs/public")));
1561            assert!(urls.iter().any(|u| u.ends_with("/docs/private")));
1562        }
1563
1564        #[tokio::test]
1565        async fn robots_fetched_once_per_host() {
1566            let src = CrawlSource::new(site(Some("User-agent: *\nDisallow:")))
1567                .depth(2)
1568                .scope(CrawlScope::SameHost)
1569                .respect_robots(true);
1570            let root = SourceRef::http("https://x.dev/docs").unwrap();
1571            let _ = crawl_urls(&src, &root).await;
1572            let hits = src.fetcher.robots_hits.lock().unwrap();
1573            assert_eq!(hits.get("https://x.dev").copied(), Some(1));
1574        }
1575    }
1576}