1use crate::error::{Error, Result};
12use crate::extract::html::extract_links;
13use crate::url_key::UrlKeyExt as _;
14
15fn 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
43const DEFAULT_MAX_DEPTH: u16 = 2;
45const DEFAULT_MAX_PAGES: usize = 1000;
47const DEFAULT_MAX_BYTES: u64 = 2 * 1024 * 1024;
49const DEFAULT_CONCURRENCY: usize = 8;
51const DEFAULT_MAX_RETRIES: u32 = 2;
53const MAX_RETRY_AFTER: Duration = Duration::from_secs(30);
55
56#[derive(Clone, Debug, Default)]
58pub enum CrawlScope {
59 #[default]
61 PathPrefix,
62 SameHost,
64 SameHostAndSubdomains,
66 Allowlist(Vec<CompactString>),
68}
69
70impl CrawlScope {
71 #[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 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
101fn 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#[derive(Clone, Debug)]
112pub struct CrawlConfig {
113 pub max_depth: u16,
115 pub scope: CrawlScope,
117 pub max_pages: usize,
119 pub min_delay: Duration,
121 pub max_bytes: Option<u64>,
123 pub user_agent: Option<CompactString>,
125 pub require_path_contains: Vec<CompactString>,
130 pub accept_extensions: Vec<CompactString>,
134 pub index_path_contains: Vec<CompactString>,
139 pub concurrency: usize,
142 pub max_retries: u32,
145 pub validators: std::collections::HashMap<UrlKey, CompactString>,
148 pub known_edges: std::collections::HashMap<UrlKey, Vec<Url>>,
152 #[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#[derive(Debug)]
181pub struct CrawlSource<F: Fetcher> {
182 fetcher: F,
183 config: CrawlConfig,
184 failed: std::sync::atomic::AtomicU32,
187 revalidated: std::sync::Mutex<Vec<Url>>,
193}
194
195impl<F: Fetcher> CrawlSource<F> {
196 #[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 #[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 #[must_use]
220 pub fn depth(mut self, max_depth: u16) -> Self {
221 self.config.max_depth = max_depth;
222 self
223 }
224
225 #[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 #[must_use]
234 pub fn scope(mut self, scope: CrawlScope) -> Self {
235 self.config.scope = scope;
236 self
237 }
238
239 #[must_use]
241 pub fn min_delay(mut self, delay: Duration) -> Self {
242 self.config.min_delay = delay;
243 self
244 }
245
246 #[must_use]
248 pub fn concurrency(mut self, concurrency: usize) -> Self {
249 self.config.concurrency = concurrency;
250 self
251 }
252
253 #[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 #[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 #[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 #[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 #[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 #[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 #[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 #[must_use]
314 pub fn failed_count(&self) -> u32 {
315 self.failed.load(std::sync::atomic::Ordering::Relaxed)
316 }
317
318 #[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 #[must_use]
329 pub fn config(&self) -> &CrawlConfig {
330 &self.config
331 }
332}
333
334fn normalize_ext(ext: &str) -> CompactString {
336 CompactString::from(ext.trim_start_matches('.').to_ascii_lowercase())
337}
338
339fn path_allowed(url: &Url, required: &[CompactString]) -> bool {
341 let path = url.path();
342 required.iter().all(|s| path.contains(s.as_str()))
343}
344
345fn ext_of(path: &str) -> Option<&str> {
348 path.rsplit('/')
349 .next()
350 .unwrap_or("")
351 .rsplit_once('.')
352 .map(|(_, e)| e)
353}
354
355fn 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
369const 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
375fn 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
381fn 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#[allow(clippy::too_many_arguments)] async 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
431struct Pacer {
436 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 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#[cfg(feature = "robots")]
468struct HostPolicy {
469 robot: Option<texting_robots::Robot>,
471 crawl_delay: Duration,
473}
474
475#[cfg(feature = "robots")]
477enum Verdict {
478 Allowed { crawl_delay: Duration },
479 Disallowed,
480}
481
482#[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 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 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 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)] 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 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 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 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 #[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; }
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; };
674 let (meta, bytes) = match outcome {
675 Ok(v) => v,
676 Err(Error::NotModified { .. }) => {
677 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)?; }
709 self.failed.fetch_add(1, std::sync::atomic::Ordering::Relaxed);
710 continue; }
712 };
713 successes += 1;
714
715 let effective = meta.final_url.unwrap_or(req_url);
718 if depth > 0 && !cfg.scope.allows(root_url, &effective) {
719 continue; }
721 visited.insert(UrlKey::from_url(&effective));
722 let kind = meta.kind;
723
724 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 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 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 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(&[]))); pages.insert("https://other.dev/x".to_string(), (h, page(&[]))); 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 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 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 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 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 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 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 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 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", "/o/s4/blob/main/src/lib.rs", "https://external.dev/x", ]),
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 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 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 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); }
1175
1176 #[tokio::test]
1177 async fn index_path_filter_collapses_duplicate_views() {
1178 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 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, &[])); 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 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 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 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 assert!(
1283 p.reserve("b.dev", Duration::from_millis(20)) < Duration::from_millis(5),
1284 "another host starts immediately"
1285 );
1286 }
1287
1288 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 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); 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 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, 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 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 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 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 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 struct RobotsMock {
1457 pages: HashMap<String, Vec<u8>>,
1458 robots: HashMap<String, Vec<u8>>, 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)) .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}