Skip to main content

mq_crawler/
crawler.rs

1use crate::http_client::HttpClient;
2use crate::robots::RobotsTxt;
3use crossbeam::queue::SegQueue;
4use dashmap::mapref::entry::Entry;
5use dashmap::{DashMap, DashSet};
6use miette::miette;
7use mq_markdown::ConversionOptions;
8use scraper::{Html, Selector};
9use serde::{Deserialize, Serialize};
10use std::io::Write;
11use std::path::Path;
12use std::sync::Arc;
13use std::sync::atomic::{AtomicUsize, Ordering};
14use std::time::{Duration, Instant};
15use std::{fs, io};
16use tokio::sync::{Notify, RwLock, Semaphore};
17use tokio::time::sleep;
18use url::Url;
19
20#[derive(Clone, Debug, Default)]
21pub enum OutputFormat {
22    #[default]
23    Text,
24    Json,
25}
26
27#[derive(Debug, Default)]
28pub struct CrawlResult {
29    pub start_time: Option<Instant>,
30    pub end_time: Option<Instant>,
31    pub pages_crawled: usize,
32    pub pages_skipped_robots: usize,
33    pub pages_failed: usize,
34    pub links_discovered: usize,
35    pub total_pages_visited: usize,
36}
37
38#[derive(Debug, Default, Serialize, Deserialize)]
39pub struct CrawlResultStats {
40    pub pages_crawled: usize,
41    pub pages_skipped_robots: usize,
42    pub pages_failed: usize,
43    pub links_discovered: usize,
44    pub total_pages_visited: usize,
45    pub duration_secs: Option<f64>,
46}
47
48impl CrawlResult {
49    pub fn duration(&self) -> Option<Duration> {
50        if let (Some(start), Some(end)) = (self.start_time, self.end_time) {
51            Some(end.duration_since(start))
52        } else {
53            None
54        }
55    }
56
57    pub fn to_stats(&self) -> CrawlResultStats {
58        CrawlResultStats {
59            pages_crawled: self.pages_crawled,
60            pages_skipped_robots: self.pages_skipped_robots,
61            pages_failed: self.pages_failed,
62            links_discovered: self.links_discovered,
63            total_pages_visited: self.total_pages_visited,
64            duration_secs: self.duration().map(|d| d.as_secs_f64()),
65        }
66    }
67
68    pub fn write_stats_to_stderr(&self, format: &OutputFormat) {
69        let stderr = io::stderr();
70        let mut handle = stderr.lock();
71
72        match format {
73            OutputFormat::Text => {
74                let _ = writeln!(handle, "\n=== Crawl Statistics ===");
75                let _ = writeln!(handle, "Pages crawled successfully: {}", self.pages_crawled);
76                let _ = writeln!(handle, "Pages skipped (robots.txt): {}", self.pages_skipped_robots);
77                let _ = writeln!(handle, "Pages failed: {}", self.pages_failed);
78                let _ = writeln!(handle, "Total pages visited: {}", self.total_pages_visited);
79                let _ = writeln!(handle, "Links discovered: {}", self.links_discovered);
80
81                if let Some(duration) = self.duration() {
82                    let _ = writeln!(handle, "Total duration: {:.2}s", duration.as_secs_f64());
83                }
84                let _ = writeln!(handle, "========================\n");
85            }
86            OutputFormat::Json => {
87                let stats = self.to_stats();
88                if let Ok(json) = serde_json::to_string_pretty(&stats) {
89                    let _ = writeln!(handle, "{}", json);
90                }
91            }
92        }
93    }
94}
95
96/// A snapshot of crawl progress that can be written to disk and later
97/// reloaded to resume an interrupted crawl via `--resume-from`.
98#[derive(Debug, Default, Serialize, Deserialize)]
99pub struct CrawlCheckpoint {
100    /// URLs that have already been visited (queued, in-flight, or completed).
101    pub visited: Vec<Url>,
102    /// URLs still pending, paired with their crawl depth.
103    pub frontier: Vec<(Url, usize)>,
104    /// Cumulative statistics from the crawl(s) that produced this checkpoint.
105    pub stats: CrawlResultStats,
106}
107
108impl CrawlCheckpoint {
109    /// Loads a checkpoint previously written by [`Crawler::save_checkpoint`].
110    pub fn load(path: &str) -> Result<Self, String> {
111        let data = fs::read_to_string(path).map_err(|e| format!("Failed to read checkpoint file '{}': {}", path, e))?;
112        serde_json::from_str(&data).map_err(|e| format!("Failed to parse checkpoint file '{}': {}", path, e))
113    }
114}
115
116// Helper function to sanitize filename components
117fn sanitize_filename_component(component: &str, max_len: usize) -> String {
118    let mut sanitized: String = component
119        .chars()
120        .filter(|c| c.is_alphanumeric() || *c == '-' || *c == '_')
121        .collect();
122
123    if sanitized.is_empty() {
124        sanitized.push_str("empty");
125    }
126
127    if sanitized.chars().count() > max_len {
128        sanitized = sanitized.chars().take(max_len).collect();
129    }
130    sanitized
131}
132
133// Replace the placeholder extract_links function with this:
134fn extract_links(html_content: &str, base_url: &Url) -> Vec<Url> {
135    if html_content.is_empty() {
136        return Vec::new();
137    }
138
139    let mut found_urls = Vec::new();
140    let document = Html::parse_document(html_content);
141    let link_selector = Selector::parse("a[href]").expect("Failed to parse 'a[href]' selector"); // Should not panic with valid selector
142
143    for element in document.select(&link_selector) {
144        if let Some(href_attr) = element.value().attr("href") {
145            match base_url.join(href_attr) {
146                Ok(mut new_url) => {
147                    // Remove fragment, if any
148                    new_url.set_fragment(None);
149                    found_urls.push(new_url);
150                }
151                Err(e) => {
152                    tracing::debug!(
153                        "Failed to parse or join URL '{}' with base '{}': {}",
154                        href_attr,
155                        base_url,
156                        e
157                    );
158                }
159            }
160        }
161    }
162    tracing::debug!("Extracted {} links from {}", found_urls.len(), base_url);
163    found_urls
164}
165
166#[derive(Debug, Clone)]
167pub struct Crawler {
168    allowed_domains: Option<Vec<String>>,
169    checkpoint_interval_pages: usize,
170    checkpoint_path: Option<String>,
171    conversion_options: mq_markdown::ConversionOptions,
172    crawl_delay: Duration,
173    custom_robots_path: Option<String>,
174    concurrency: usize,
175    depth_limit: Option<usize>,
176    domain_last_request: Arc<DashMap<String, Instant>>,
177    format: OutputFormat,
178    http_client: HttpClient,
179    initial_domain: String,
180    max_pages: Option<usize>,
181    mq_query: String,
182    output_path: Option<String>,
183    result: Arc<RwLock<CrawlResult>>,
184    robots_cache: Arc<DashMap<String, Arc<RobotsTxt>>>,
185    to_visit: Arc<SegQueue<(Url, usize)>>,
186    notify: Arc<Notify>,
187    user_agent: String,
188    visited: Arc<DashSet<Url>>,
189}
190
191impl Crawler {
192    #[allow(clippy::too_many_arguments)]
193    pub async fn new(
194        http_client: HttpClient,
195        start_url: Url,
196        crawl_delay_secs: f64,
197        custom_robots_path: Option<String>,
198        mq_query: Option<String>,
199        output_path: Option<String>,
200        concurrency: usize,
201        format: OutputFormat,
202        conversion_options: mq_markdown::ConversionOptions,
203        depth_limit: Option<usize>,
204        allowed_domains: Option<Vec<String>>,
205        additional_seed_urls: Vec<Url>,
206        max_pages: Option<usize>,
207        checkpoint_path: Option<String>,
208        checkpoint_interval_pages: usize,
209        resume_checkpoint: Option<CrawlCheckpoint>,
210    ) -> Result<Self, String> {
211        let initial_domain = start_url
212            .domain()
213            .ok_or_else(|| "Start URL has no domain".to_string())?
214            .to_string();
215        let user_agent = format!("mq crawler/0.1 ({})", env!("CARGO_PKG_HOMEPAGE"));
216
217        let to_visit = SegQueue::new();
218        let visited = DashSet::new();
219        let mut initial_result = CrawlResult::default();
220
221        if let Some(checkpoint) = resume_checkpoint {
222            tracing::info!(
223                "Resuming crawl from checkpoint: {} visited, {} queued.",
224                checkpoint.visited.len(),
225                checkpoint.frontier.len()
226            );
227            for url in checkpoint.visited {
228                visited.insert(url);
229            }
230            for (url, depth) in checkpoint.frontier {
231                to_visit.push((url, depth));
232            }
233            initial_result.pages_crawled = checkpoint.stats.pages_crawled;
234            initial_result.pages_skipped_robots = checkpoint.stats.pages_skipped_robots;
235            initial_result.pages_failed = checkpoint.stats.pages_failed;
236            initial_result.links_discovered = checkpoint.stats.links_discovered;
237        } else {
238            to_visit.push((start_url.clone(), 0));
239            for seed_url in additional_seed_urls {
240                to_visit.push((seed_url, 0));
241            }
242        }
243
244        Ok(Self {
245            allowed_domains,
246            checkpoint_interval_pages: checkpoint_interval_pages.max(1),
247            checkpoint_path,
248            http_client,
249            to_visit: Arc::new(to_visit),
250            visited: Arc::new(visited),
251            robots_cache: Arc::new(DashMap::new()),
252            crawl_delay: Duration::from_secs_f64(crawl_delay_secs),
253            domain_last_request: Arc::new(DashMap::new()),
254            max_pages,
255            mq_query: mq_query.unwrap_or("identity()".to_string()),
256            user_agent,
257            output_path,
258            initial_domain,
259            custom_robots_path,
260            result: Arc::new(RwLock::new(initial_result)),
261            notify: Arc::new(Notify::new()),
262            concurrency: concurrency.max(1),
263            format,
264            conversion_options,
265            depth_limit,
266        })
267    }
268
269    async fn get_or_fetch_robots(&self, url_to_check: &Url) -> Result<Arc<RobotsTxt>, String> {
270        let domain = url_to_check
271            .domain()
272            .ok_or_else(|| "URL has no domain".to_string())?
273            .to_string();
274
275        // Check if we already have this domain's robots cached
276        {
277            if let Some(robots) = self.robots_cache.get(&domain) {
278                return Ok(robots.clone());
279            }
280        }
281
282        // Use the stored custom_robots_path
283        let robots = RobotsTxt::fetch(&self.http_client, url_to_check, self.custom_robots_path.as_deref()).await?;
284        let arc_robots = Arc::new(robots);
285
286        // Cache the result
287        self.robots_cache.insert(domain.clone(), arc_robots.clone());
288        Ok(arc_robots)
289    }
290
291    pub async fn run(&mut self) -> Result<(), String> {
292        // Record start time
293        {
294            let mut result = self.result.write().await;
295            result.start_time = Some(Instant::now());
296        }
297
298        let mut startup_info = format!(
299            "Crawler run initiated. User-Agent: '{}'. Initial domain: '{}'. Crawl delay: {:?}. Concurrency: {}.",
300            self.user_agent, self.initial_domain, self.crawl_delay, self.concurrency
301        );
302        if let Some(ref path) = self.custom_robots_path {
303            startup_info.push_str(&format!(" Custom robots.txt path: '{}'.", path));
304        }
305        if let Some(ref path) = self.output_path {
306            startup_info.push_str(&format!(" Outputting to directory: '{}'.", path));
307        } else {
308            startup_info.push_str(" Outputting to stdout.");
309        }
310        tracing::info!("{}", startup_info);
311
312        if let Some(ref output_dir_str) = self.output_path {
313            let output_dir = Path::new(output_dir_str);
314            if !output_dir.exists() {
315                fs::create_dir_all(output_dir)
316                    .map_err(|e| format!("Failed to create output directory '{}': {}", output_dir_str, e))?;
317                tracing::info!("Created output directory: {:?}", output_dir);
318            } else if !output_dir.is_dir() {
319                return Err(format!(
320                    "Specified output path '{}' exists but is not a directory.",
321                    output_dir_str
322                ));
323            }
324        }
325
326        self.run_parallel().await
327    }
328
329    async fn run_parallel(&mut self) -> Result<(), String> {
330        let semaphore = Arc::new(Semaphore::new(self.concurrency));
331        let active_tasks = Arc::new(AtomicUsize::new(0));
332        let mut pages_at_last_checkpoint = self.result.read().await.pages_crawled;
333
334        loop {
335            let max_pages_reached = self.max_pages.is_some_and(|max| self.visited.len() >= max);
336
337            // Termination condition: no tasks in flight, and either the queue is
338            // empty or we have hit the --max-pages limit.
339            if (self.to_visit.is_empty() || max_pages_reached) && active_tasks.load(Ordering::SeqCst) == 0 {
340                break;
341            }
342
343            // Register interest in the next notification *before* draining the
344            // queue so we cannot miss a wakeup that arrives while we are spawning.
345            let notified = self.notify.notified();
346
347            // Drain the queue, spawning tasks up to the semaphore limit
348            if !max_pages_reached {
349                while let Some((url, depth)) = self.to_visit.pop() {
350                    if self.should_skip_url_without_visited_check(&url) || !self.visited.insert(url.clone()) {
351                        continue;
352                    }
353
354                    let permit = semaphore.clone().acquire_owned().await.expect("Semaphore closed");
355                    active_tasks.fetch_add(1, Ordering::SeqCst);
356
357                    let crawler = self.clone();
358                    let active_tasks_clone = active_tasks.clone();
359                    let notify_clone = self.notify.clone();
360                    tokio::spawn(async move {
361                        let _permit = permit;
362                        crawler.process_url_with_rate_limit(url, depth).await;
363                        active_tasks_clone.fetch_sub(1, Ordering::SeqCst);
364                        // Wake the main loop so it can either spawn new tasks or
365                        // detect the termination condition.
366                        notify_clone.notify_one();
367                    });
368
369                    if self.max_pages.is_some_and(|max| self.visited.len() >= max) {
370                        tracing::info!(
371                            "Reached --max-pages limit ({}); no further URLs will be queued.",
372                            self.max_pages.expect("checked by is_some_and above")
373                        );
374                        break;
375                    }
376                }
377            }
378
379            self.maybe_save_checkpoint(&mut pages_at_last_checkpoint).await;
380
381            // Block until a task finishes or a new URL is enqueued.
382            // If notify_one() was already called since we created `notified`
383            // above, this returns immediately without spinning.
384            notified.await;
385        }
386
387        if self.checkpoint_path.is_some()
388            && let Err(e) = self.save_checkpoint().await
389        {
390            tracing::warn!("Failed to save final checkpoint: {}", e);
391        }
392
393        self.finalize_crawl().await;
394        Ok(())
395    }
396
397    /// Saves a checkpoint if `--checkpoint-path` is set and at least
398    /// `checkpoint_interval_pages` pages have been crawled since the last save.
399    async fn maybe_save_checkpoint(&self, pages_at_last_checkpoint: &mut usize) {
400        if self.checkpoint_path.is_none() {
401            return;
402        }
403
404        let pages_crawled = self.result.read().await.pages_crawled;
405        if pages_crawled < pages_at_last_checkpoint.saturating_add(self.checkpoint_interval_pages) {
406            return;
407        }
408
409        *pages_at_last_checkpoint = pages_crawled;
410        if let Err(e) = self.save_checkpoint().await {
411            tracing::warn!("Failed to save checkpoint: {}", e);
412        }
413    }
414
415    /// Snapshots the current visited set and pending frontier to
416    /// `checkpoint_path`, so the crawl can later be resumed with `--resume-from`.
417    /// The write is atomic (temp file + rename) so a crash mid-write cannot
418    /// leave behind a corrupt checkpoint.
419    async fn save_checkpoint(&self) -> Result<(), String> {
420        let Some(path) = self.checkpoint_path.clone() else {
421            return Ok(());
422        };
423
424        let visited: Vec<Url> = self.visited.iter().map(|entry| entry.clone()).collect();
425        let frontier = self.snapshot_frontier();
426        let stats = self.result.read().await.to_stats();
427        let visited_len = visited.len();
428        let frontier_len = frontier.len();
429
430        let checkpoint = CrawlCheckpoint {
431            visited,
432            frontier,
433            stats,
434        };
435        let json =
436            serde_json::to_string_pretty(&checkpoint).map_err(|e| format!("Failed to serialize checkpoint: {}", e))?;
437
438        let tmp_path = format!("{}.tmp", path);
439        fs::write(&tmp_path, json).map_err(|e| format!("Failed to write checkpoint to '{}': {}", tmp_path, e))?;
440        fs::rename(&tmp_path, &path).map_err(|e| format!("Failed to finalize checkpoint file '{}': {}", path, e))?;
441
442        tracing::info!(
443            "Checkpoint saved to '{}' ({} visited, {} queued).",
444            path,
445            visited_len,
446            frontier_len
447        );
448        Ok(())
449    }
450
451    /// Drains `to_visit` and pushes every item back, returning a snapshot of
452    /// its contents. Safe to call concurrently with producers pushing new
453    /// items; items pushed during the drain may be missing from the snapshot
454    /// but are never lost from the queue itself.
455    fn snapshot_frontier(&self) -> Vec<(Url, usize)> {
456        let mut items = Vec::new();
457        while let Some(item) = self.to_visit.pop() {
458            items.push(item);
459        }
460        for item in &items {
461            self.to_visit.push(item.clone());
462        }
463        items
464    }
465
466    fn should_skip_url_without_visited_check(&self, url: &Url) -> bool {
467        let Some(domain) = url.domain() else {
468            tracing::info!("Skipping URL with no domain: {}", url);
469            return true;
470        };
471
472        let allowed = match &self.allowed_domains {
473            None => domain == self.initial_domain,
474            Some(list) if list.is_empty() => true,
475            Some(list) => list.iter().any(|d| d == domain),
476        };
477
478        if !allowed {
479            tracing::info!("Skipping URL from disallowed domain: {}", url);
480            return true;
481        }
482
483        false
484    }
485
486    async fn wait_for_domain_rate_limit(&self, domain: &str) {
487        if self.crawl_delay.is_zero() {
488            return;
489        }
490        loop {
491            let now = Instant::now();
492            // Hold the shard lock for the entire check-and-update so concurrent
493            // tasks cannot both observe the same (or absent) timestamp and
494            // simultaneously bypass the intended per-domain delay.
495            let wait = match self.domain_last_request.entry(domain.to_string()) {
496                Entry::Occupied(mut e) => {
497                    let elapsed = now.duration_since(*e.get());
498                    if elapsed >= self.crawl_delay {
499                        e.insert(now);
500                        None
501                    } else {
502                        Some(self.crawl_delay - elapsed)
503                    }
504                }
505                Entry::Vacant(e) => {
506                    e.insert(now);
507                    None
508                }
509            };
510            // The entry (and its shard lock) is dropped before any await point.
511            match wait {
512                None => return,
513                Some(dur) => sleep(dur).await,
514            }
515        }
516    }
517
518    async fn process_url_with_rate_limit(&self, url: Url, depth: usize) {
519        if let Some(domain) = url.domain() {
520            self.wait_for_domain_rate_limit(domain).await;
521        }
522        self.process_url(url, depth).await;
523    }
524
525    async fn process_url(&self, current_url: Url, current_depth: usize) {
526        tracing::info!("Processing URL: {} (depth: {})", current_url, current_depth);
527
528        // URL is already marked as visited in run_parallel, no need to check again
529        let robots_rules = match self.get_or_fetch_robots(&current_url).await {
530            Ok(rules) => rules,
531            Err(e) => {
532                tracing::error!("Failed to fetch robots.txt for {}: {}", current_url, e);
533                return;
534            }
535        };
536
537        if !robots_rules.is_allowed(&current_url, &self.user_agent) {
538            tracing::warn!("Skipping URL disallowed by robots.txt: {}", current_url);
539            {
540                let mut result = self.result.write().await;
541                result.pages_skipped_robots += 1;
542            }
543            return;
544        }
545
546        match self.http_client.fetch(current_url.clone()).await {
547            Ok(html_content) => {
548                tracing::info!("Applying mq query to content from {}", current_url);
549
550                let query = self.mq_query.clone();
551                let html_content_clone = html_content.clone();
552                // Resolve relative links against the page actually being crawled, not one static base.
553                let mut conversion_options = self.conversion_options.clone();
554                conversion_options.base_url = Some(current_url.to_string());
555                let current_url_clone = current_url.clone();
556                let self_clone = self.clone();
557                let new_links = tokio::task::spawn_blocking(move || {
558                    if let Err(e) = Self::execute_query(&query, &html_content_clone, conversion_options).map(|md| {
559                        if let Err(e) = self_clone.output_markdown(&current_url_clone, md.as_str()) {
560                            tracing::error!("Failed to output markdown for {}: {}", current_url_clone, e);
561                        }
562                    }) {
563                        tracing::error!(
564                            "Failed to execute mq query on content from {}: {}",
565                            current_url_clone,
566                            e
567                        );
568                    }
569
570                    extract_links(&html_content, &current_url_clone)
571                })
572                .await;
573
574                match new_links {
575                    Ok(new_links) => {
576                        {
577                            let mut result = self.result.write().await;
578                            result.links_discovered += new_links.len();
579                            result.pages_crawled += 1;
580                        }
581
582                        {
583                            let next_depth = current_depth + 1;
584                            for link in new_links {
585                                if !self.visited.contains(&link)
586                                    && !self.should_skip_url_without_visited_check(&link)
587                                    && self.depth_limit.is_none_or(|limit| next_depth <= limit)
588                                {
589                                    self.to_visit.push((link, next_depth));
590                                    // Wake the main loop to pick up the new URL.
591                                    self.notify.notify_one();
592                                }
593                            }
594                        }
595                    }
596                    Err(e) => {
597                        tracing::error!("Failed to execute mq query on content from {}: {}", current_url, e);
598                    }
599                }
600            }
601            Err(e) => {
602                tracing::error!("Failed to fetch URL {}: {}", current_url, e);
603                {
604                    let mut result = self.result.write().await;
605                    result.pages_failed += 1;
606                }
607            }
608        }
609    }
610
611    fn execute_query(query: &str, input: &str, conversion_options: ConversionOptions) -> miette::Result<String> {
612        let input = mq_lang::parse_html_input_with_options(input, conversion_options)?;
613        let mut mq_engine = mq_lang::DefaultEngine::default();
614        mq_engine.load_builtin_module();
615
616        match mq_engine
617            .eval(query, input.into_iter())
618            .map_err(|e| miette!(format!("Error evaluating mq query: {}", e)))
619        {
620            Ok(values) => Ok(mq_markdown::Markdown::new(
621                values
622                    .into_iter()
623                    .map(|value| match value {
624                        mq_lang::RuntimeValue::Markdown(node, _) => mq_lang::Shared::unwrap_or_clone(node),
625                        _ => value.to_string().into(),
626                    })
627                    .collect(),
628            )
629            .to_string()),
630            Err(e) => Err(miette!(e)),
631        }
632    }
633
634    async fn finalize_crawl(&self) {
635        // Record end time and final statistics
636        {
637            let mut result = self.result.write().await;
638            result.end_time = Some(Instant::now());
639            let visited_len = self.visited.len();
640            result.total_pages_visited = visited_len;
641        }
642
643        // Write statistics to stderr
644        {
645            let result = self.result.read().await;
646            result.write_stats_to_stderr(&self.format);
647        }
648    }
649
650    fn output_markdown(&self, url: &Url, markdown: &str) -> Result<(), String> {
651        tracing::debug!("Preparing to output markdown for {}", url);
652        if let Some(ref output_dir_str) = self.output_path {
653            // Renamed path_str to output_dir_str
654            let domain_str = url.domain().unwrap_or("unknown_domain");
655            let path_str = url.path();
656
657            // Sanitize domain and path components
658            let sane_domain = sanitize_filename_component(domain_str, 50);
659
660            let sane_path = if path_str == "/" || path_str.is_empty() {
661                "index".to_string()
662            } else {
663                sanitize_filename_component(path_str.trim_matches('/'), 100)
664            };
665
666            let filename = format!("{}_{}.md", sane_domain, sane_path);
667            let output_dir = Path::new(output_dir_str);
668            let output_file_path = output_dir.join(&filename);
669            tracing::info!("Saving markdown for {} to: {:?}", url, output_file_path);
670
671            let mut file = fs::File::create(&output_file_path)
672                .map_err(|e| format!("Failed to create output file '{:?}': {}", output_file_path, e))?;
673            file.write_all(markdown.as_bytes())
674                .map_err(|e| format!("Failed to write markdown to file '{:?}': {}", output_file_path, e))?;
675            tracing::debug!("Successfully wrote {} bytes to {:?}", markdown.len(), output_file_path);
676        } else {
677            let filename = format!(
678                "{}_{}.md",
679                sanitize_filename_component(url.domain().unwrap_or("unknown_domain"), 50),
680                if url.path() == "/" || url.path().is_empty() {
681                    "index".to_string()
682                } else {
683                    sanitize_filename_component(url.path().trim_matches('/'), 100)
684                }
685            );
686            writeln!(io::stderr().lock(), "Filename: {} --", filename)
687                .map_err(|e| format!("Failed to write to stderr: {}", e))?;
688            let stdout = io::stdout();
689            let mut handle = stdout.lock();
690            handle
691                .write_all(markdown.as_bytes())
692                .map_err(|e| format!("Failed to write markdown to stdout: {}", e))?;
693            writeln!(handle).map_err(|e| format!("Failed to write newline to stdout: {}", e))?;
694            handle
695                .flush()
696                .map_err(|e| format!("Failed to flush stdout after writing markdown: {}", e))?;
697        }
698        Ok(())
699    }
700}
701
702#[cfg(test)]
703mod tests {
704    use super::*;
705    use httpmock::{Method::GET, MockServer};
706    use rstest::rstest;
707    use url::Url;
708
709    #[rstest]
710    #[case(
711        "<html><body><p>No links here.</p></body></html>",
712        "http://example.com",
713        vec![]
714    )]
715    #[case(
716        r#"<html><body><a href="http://example.com/page1">Page 1</a></body></html>"#,
717        "http://example.com",
718        vec!["http://example.com/page1"]
719    )]
720    #[case(
721        r#"<html><body><a href="/page2">Page 2</a></body></html>"#,
722        "http://example.com/path/",
723        vec!["http://example.com/page2"]
724    )]
725    #[case(
726        r#"<html><body><a href="page3#section">Page 3</a></body></html>"#,
727        "http://example.com/",
728        vec!["http://example.com/page3"]
729    )]
730    #[case(
731        r##"
732            <html><body>
733                <a href="https://othersite.com/abs">Absolute</a>
734                <a href="relative/link">Relative</a>
735                <a href="../another">Another Relative</a>
736                <a href="#fragmentonly">Fragment Only</a>
737                <a href="page?query=val">With Query</a>
738            </body></html>
739        "##,
740        "http://example.com/folder1/folder2/current.html",
741        vec![
742            "https://othersite.com/abs",
743            "http://example.com/folder1/folder2/relative/link",
744            "http://example.com/folder1/another",
745            "http://example.com/folder1/folder2/current.html",
746            "http://example.com/folder1/folder2/page?query=val"
747        ]
748    )]
749    #[case(
750        r#"<html><body><a href="">Empty Href</a></body></html>"#,
751        "http://example.com/page.html",
752        vec!["http://example.com/page.html"]
753    )]
754    #[case(
755        r#"<html><body><a href="http://[::1]:namedport">Malformed</a></body></html>"#,
756        "http://example.com",
757        vec![]
758    )]
759    #[case(
760        "",
761        "http://example.com",
762        vec![]
763    )]
764    fn test_extract_links(#[case] html: &str, #[case] base_url: &str, #[case] expected_urls: Vec<&str>) {
765        let base = Url::parse(base_url).unwrap();
766        let links = extract_links(html, &base);
767
768        assert_eq!(
769            links.len(),
770            expected_urls.len(),
771            "Link count mismatch for base_url: {}",
772            base_url
773        );
774
775        for expected_url_str in expected_urls {
776            let expected_url = Url::parse(expected_url_str).unwrap();
777            assert!(
778                links.contains(&expected_url),
779                "Expected link {} not found in {:?}",
780                expected_url_str,
781                links
782            );
783        }
784    }
785
786    #[rstest]
787    #[case("abcDEF-123_foo", 20, "abcDEF-123_foo")]
788    #[case("!@#abc/\\:*?\"<>|", 20, "abc")]
789    #[case("", 10, "empty")]
790    #[case("valid_name", 5, "valid")]
791    #[case("___", 10, "___")]
792    #[case("foo bar-baz_123", 20, "foobar-baz_123")]
793    #[case("日本語テスト", 10, "日本語テスト")]
794    fn test_sanitize_filename_component(#[case] input: &str, #[case] max_len: usize, #[case] expected: &str) {
795        let result = sanitize_filename_component(input, max_len);
796        assert_eq!(result, expected, "input: {}", input);
797    }
798
799    #[test]
800    fn test_crawl_result_duration_some() {
801        let start = Instant::now();
802        let end = start + Duration::from_secs(5);
803        let result = CrawlResult {
804            start_time: Some(start),
805            end_time: Some(end),
806            ..Default::default()
807        };
808        assert_eq!(result.duration(), Some(Duration::from_secs(5)));
809    }
810
811    #[test]
812    fn test_crawl_result_duration_none() {
813        let result = CrawlResult {
814            start_time: None,
815            end_time: None,
816            ..Default::default()
817        };
818        assert_eq!(result.duration(), None);
819
820        let result = CrawlResult {
821            start_time: Some(Instant::now()),
822            end_time: None,
823            ..Default::default()
824        };
825        assert_eq!(result.duration(), None);
826
827        let result = CrawlResult {
828            start_time: None,
829            end_time: Some(Instant::now()),
830            ..Default::default()
831        };
832        assert_eq!(result.duration(), None);
833    }
834
835    /// Helper to build a Crawler for unit tests without sending any HTTP requests.
836    /// Uses the `.invalid` TLD (RFC 2606 reserved) to make clear no real domain is intended.
837    async fn make_test_crawler(
838        start_domain: &str,
839        allowed_domains: Option<Vec<String>>,
840        depth_limit: Option<usize>,
841    ) -> Crawler {
842        let start_url = Url::parse(&format!("http://{}/", start_domain)).unwrap();
843        let http_client = HttpClient::new_reqwest(30.0).unwrap();
844        Crawler::new(
845            http_client,
846            start_url,
847            0.0,
848            None,
849            None,
850            None,
851            1,
852            OutputFormat::Text,
853            mq_markdown::ConversionOptions::default(),
854            depth_limit,
855            allowed_domains,
856            Vec::new(),
857            None,
858            None,
859            1,
860            None,
861        )
862        .await
863        .unwrap()
864    }
865
866    #[tokio::test]
867    async fn test_should_skip_url_no_domain() {
868        let crawler = make_test_crawler("start.invalid", None, None).await;
869
870        // URL without domain (e.g. data: URI) should be skipped
871        let no_domain = Url::parse("data:text/plain,hello").unwrap();
872        assert!(crawler.should_skip_url_without_visited_check(&no_domain));
873    }
874
875    #[tokio::test]
876    async fn test_should_skip_url_same_domain_allowed() {
877        let crawler = make_test_crawler("start.invalid", None, None).await;
878        let same = Url::parse("http://start.invalid/page").unwrap();
879        assert!(!crawler.should_skip_url_without_visited_check(&same));
880    }
881
882    #[tokio::test]
883    async fn test_should_skip_url_different_domain_denied() {
884        let crawler = make_test_crawler("start.invalid", None, None).await;
885        let other = Url::parse("http://other.invalid/page").unwrap();
886        assert!(crawler.should_skip_url_without_visited_check(&other));
887    }
888
889    #[tokio::test]
890    async fn test_should_skip_url_empty_allowed_list_permits_all() {
891        let crawler = make_test_crawler("start.invalid", Some(vec![]), None).await;
892        let other = Url::parse("http://other.invalid/page").unwrap();
893        assert!(!crawler.should_skip_url_without_visited_check(&other));
894    }
895
896    #[tokio::test]
897    async fn test_should_skip_url_explicit_allowed_list() {
898        let allowed = vec!["start.invalid".to_string(), "extra.invalid".to_string()];
899        let crawler = make_test_crawler("start.invalid", Some(allowed), None).await;
900
901        let allowed_url = Url::parse("http://extra.invalid/page").unwrap();
902        let denied_url = Url::parse("http://denied.invalid/page").unwrap();
903
904        assert!(!crawler.should_skip_url_without_visited_check(&allowed_url));
905        assert!(crawler.should_skip_url_without_visited_check(&denied_url));
906    }
907
908    #[tokio::test]
909    async fn test_crawler_new_with_depth_limit() {
910        let start_url = Url::parse("http://start.invalid/").unwrap();
911        let http_client = HttpClient::new_reqwest(30.0).unwrap();
912        let crawler = Crawler::new(
913            http_client,
914            start_url,
915            1.0,
916            None,
917            None,
918            None,
919            1,
920            OutputFormat::Text,
921            mq_markdown::ConversionOptions::default(),
922            Some(2),
923            None,
924            Vec::new(),
925            None,
926            None,
927            1,
928            None,
929        )
930        .await
931        .unwrap();
932
933        assert_eq!(crawler.depth_limit, Some(2));
934    }
935
936    #[tokio::test]
937    async fn test_crawler_new_with_unlimited_depth() {
938        let start_url = Url::parse("http://start.invalid/").unwrap();
939        let http_client = HttpClient::new_reqwest(30.0).unwrap();
940        let crawler = Crawler::new(
941            http_client,
942            start_url,
943            1.0,
944            None,
945            None,
946            None,
947            1,
948            OutputFormat::Text,
949            mq_markdown::ConversionOptions::default(),
950            None,
951            None,
952            Vec::new(),
953            None,
954            None,
955            1,
956            None,
957        )
958        .await
959        .unwrap();
960
961        assert_eq!(crawler.depth_limit, None);
962    }
963
964    #[tokio::test]
965    async fn test_crawler_new_with_additional_seed_urls() {
966        let start_url = Url::parse("http://start.invalid/").unwrap();
967        let http_client = HttpClient::new_reqwest(30.0).unwrap();
968        let seed_urls = vec![
969            Url::parse("http://start.invalid/from-sitemap-1").unwrap(),
970            Url::parse("http://start.invalid/from-sitemap-2").unwrap(),
971        ];
972        let crawler = Crawler::new(
973            http_client,
974            start_url.clone(),
975            0.0,
976            None,
977            None,
978            None,
979            1,
980            OutputFormat::Text,
981            mq_markdown::ConversionOptions::default(),
982            None,
983            None,
984            seed_urls.clone(),
985            None,
986            None,
987            1,
988            None,
989        )
990        .await
991        .unwrap();
992
993        let mut queued = Vec::new();
994        while let Some((url, depth)) = crawler.to_visit.pop() {
995            queued.push((url, depth));
996        }
997
998        assert_eq!(queued.len(), 3);
999        assert!(queued.contains(&(start_url, 0)));
1000        for seed_url in seed_urls {
1001            assert!(queued.contains(&(seed_url, 0)));
1002        }
1003    }
1004
1005    #[tokio::test]
1006    async fn test_max_pages_limits_total_visited() {
1007        let server = MockServer::start_async().await;
1008        let port = server.address().port();
1009
1010        let index_mock = server
1011            .mock_async(|when, then| {
1012                when.method(GET).path("/");
1013                then.status(200).body(
1014                    r#"<html><body>
1015                        <a href="/page1">1</a>
1016                        <a href="/page2">2</a>
1017                        <a href="/page3">3</a>
1018                        <a href="/page4">4</a>
1019                        <a href="/page5">5</a>
1020                    </body></html>"#,
1021                );
1022            })
1023            .await;
1024        let page1_mock = server
1025            .mock_async(|when, then| {
1026                when.method(GET).path("/page1");
1027                then.status(200).body("<html><body>Page 1</body></html>");
1028            })
1029            .await;
1030
1031        let start_url = Url::parse(&format!("http://localhost:{}/", port)).unwrap();
1032        let http_client = HttpClient::new_reqwest(30.0).unwrap();
1033
1034        let mut crawler = Crawler::new(
1035            http_client,
1036            start_url,
1037            0.0,
1038            None,
1039            None,
1040            None,
1041            1,
1042            OutputFormat::Text,
1043            mq_markdown::ConversionOptions::default(),
1044            None,
1045            None,
1046            Vec::new(),
1047            Some(2),
1048            None,
1049            1,
1050            None,
1051        )
1052        .await
1053        .unwrap();
1054
1055        crawler.run().await.unwrap();
1056
1057        assert_eq!(crawler.visited.len(), 2);
1058        assert!(!crawler.to_visit.is_empty(), "unvisited URLs should remain queued");
1059        index_mock.assert_async().await;
1060        page1_mock.assert_async().await;
1061    }
1062
1063    #[tokio::test]
1064    async fn test_save_checkpoint_captures_visited_and_frontier() {
1065        let start_url = Url::parse("http://start.invalid/").unwrap();
1066        let http_client = HttpClient::new_reqwest(30.0).unwrap();
1067        let checkpoint_file = tempfile::NamedTempFile::new().unwrap();
1068        let checkpoint_path = checkpoint_file.path().to_string_lossy().to_string();
1069
1070        let crawler = Crawler::new(
1071            http_client,
1072            start_url.clone(),
1073            0.0,
1074            None,
1075            None,
1076            None,
1077            1,
1078            OutputFormat::Text,
1079            mq_markdown::ConversionOptions::default(),
1080            None,
1081            None,
1082            Vec::new(),
1083            None,
1084            Some(checkpoint_path.clone()),
1085            1,
1086            None,
1087        )
1088        .await
1089        .unwrap();
1090
1091        // Drain the default seeded start URL so the frontier reflects only
1092        // the mid-crawl state set up below.
1093        while crawler.to_visit.pop().is_some() {}
1094
1095        // Simulate mid-crawl state: one page visited, one still queued.
1096        crawler.visited.insert(start_url.clone());
1097        let queued_url = Url::parse("http://start.invalid/page1").unwrap();
1098        crawler.to_visit.push((queued_url.clone(), 1));
1099
1100        crawler.save_checkpoint().await.unwrap();
1101
1102        let loaded = CrawlCheckpoint::load(&checkpoint_path).unwrap();
1103        assert_eq!(loaded.visited, vec![start_url]);
1104        assert_eq!(loaded.frontier, vec![(queued_url, 1)]);
1105
1106        // The frontier snapshot must not have drained the live queue.
1107        assert!(!crawler.to_visit.is_empty());
1108    }
1109
1110    #[tokio::test]
1111    async fn test_resume_from_checkpoint_restores_state() {
1112        let start_url = Url::parse("http://start.invalid/").unwrap();
1113        let http_client = HttpClient::new_reqwest(30.0).unwrap();
1114
1115        let visited_url = Url::parse("http://start.invalid/already-visited").unwrap();
1116        let queued_url = Url::parse("http://start.invalid/still-queued").unwrap();
1117        let checkpoint = CrawlCheckpoint {
1118            visited: vec![visited_url.clone()],
1119            frontier: vec![(queued_url.clone(), 3)],
1120            stats: CrawlResultStats {
1121                pages_crawled: 5,
1122                pages_skipped_robots: 1,
1123                pages_failed: 2,
1124                links_discovered: 7,
1125                total_pages_visited: 1,
1126                duration_secs: None,
1127            },
1128        };
1129
1130        let crawler = Crawler::new(
1131            http_client,
1132            start_url.clone(),
1133            0.0,
1134            None,
1135            None,
1136            None,
1137            1,
1138            OutputFormat::Text,
1139            mq_markdown::ConversionOptions::default(),
1140            None,
1141            None,
1142            Vec::new(),
1143            None,
1144            None,
1145            1,
1146            Some(checkpoint),
1147        )
1148        .await
1149        .unwrap();
1150
1151        // The checkpoint's visited set is restored, and the start URL is not
1152        // re-queued alongside it.
1153        assert!(crawler.visited.contains(&visited_url));
1154        assert_eq!(crawler.visited.len(), 1);
1155
1156        let mut queued = Vec::new();
1157        while let Some(item) = crawler.to_visit.pop() {
1158            queued.push(item);
1159        }
1160        assert_eq!(queued, vec![(queued_url, 3)]);
1161
1162        let result = crawler.result.read().await;
1163        assert_eq!(result.pages_crawled, 5);
1164        assert_eq!(result.pages_skipped_robots, 1);
1165        assert_eq!(result.pages_failed, 2);
1166        assert_eq!(result.links_discovered, 7);
1167    }
1168}