diff --git a/README.md b/README.md index fef6062a1f..eb5cf88925 100644 --- a/README.md +++ b/README.md @@ -194,7 +194,6 @@ outdated information. | Custom user agent | ![yes] | ![no] | ![no] | ![yes] | ![no] | ![yes] | ![no] | ![no] | | Relative URLs | ![yes] | ![yes] | ![no] | ![yes] | ![yes] | ![yes] | ![yes] | ![yes] | | Anchors/Fragments | ![yes] | ![no] | ![no] | ![no] | ![no] | ![yes] | ![yes] | ![no] | -| Skip relative URLs | ![yes] | ![no] | ![no] | ![maybe] | ![no] | ![no] | ![no] | ![no] | | Include patterns | ![yes]️ | ![yes] | ![no] | ![yes] | ![no] | ![no] | ![no] | ![no] | | Exclude patterns | ![yes] | ![no] | ![yes] | ![yes] | ![yes] | ![yes] | ![yes] | ![yes] | | Handle redirects | ![yes] | ![yes] | ![yes] | ![yes] | ![yes] | ![yes] | ![yes] | ![yes] | diff --git a/examples/collect_links/collect_links.rs b/examples/collect_links/collect_links.rs index c73dc2aef3..5820d6ceed 100644 --- a/examples/collect_links/collect_links.rs +++ b/examples/collect_links/collect_links.rs @@ -1,10 +1,10 @@ -use lychee_lib::{Collector, Input, InputSource, Result}; +use lychee_lib::{Collector, Input, InputSource, RequestError}; use reqwest::Url; use std::{collections::HashSet, path::PathBuf}; use tokio_stream::StreamExt; #[tokio::main] -async fn main() -> Result<()> { +async fn main() -> Result<(), Box> { // Collect all links from the following inputs let inputs = HashSet::from_iter([ Input::from_input_source(InputSource::RemoteUrl(Box::new( @@ -19,7 +19,7 @@ async fn main() -> Result<()> { .skip_ignored(false) // skip files that are ignored by git? (default=true) .use_html5ever(false) // use html5ever for parsing? (default=false) .collect_links(inputs) // base url or directory - .collect::>>() + .collect::, _>>() .await?; dbg!(links); diff --git a/lychee-bin/src/commands/check.rs b/lychee-bin/src/commands/check.rs index 8a8e22d8f6..e84726b623 100644 --- a/lychee-bin/src/commands/check.rs +++ b/lychee-bin/src/commands/check.rs @@ -10,9 +10,10 @@ use reqwest::Url; use tokio::sync::mpsc; use tokio_stream::wrappers::ReceiverStream; +use lychee_lib::InputSource; +use lychee_lib::RequestError; use lychee_lib::archive::Archive; use lychee_lib::{Client, ErrorKind, Request, Response, Uri}; -use lychee_lib::{InputSource, Result}; use lychee_lib::{ResponseBody, Status}; use crate::formatters::get_response_formatter; @@ -27,9 +28,9 @@ use super::CommandParams; pub(crate) async fn check( params: CommandParams, -) -> Result<(ResponseStats, Arc, ExitCode)> +) -> Result<(ResponseStats, Arc, ExitCode), ErrorKind> where - S: futures::Stream>, + S: futures::Stream>, { // Setup let (send_req, recv_req) = mpsc::channel(params.cfg.max_concurrency); @@ -176,36 +177,36 @@ async fn suggest_archived_links( // the show_results_task to finish async fn send_inputs_loop( requests: S, - send_req: mpsc::Sender>, + send_req: mpsc::Sender>, bar: Option, -) -> Result<()> +) -> Result<(), ErrorKind> where - S: futures::Stream>, + S: futures::Stream>, { tokio::pin!(requests); while let Some(request) = requests.next().await { - let request = request?; if let Some(pb) = &bar { pb.inc_length(1); - pb.set_message(request.to_string()); + match &request { + Ok(x) => pb.set_message(x.to_string()), + Err(e) => pb.set_message(e.to_string()), + } } - send_req - .send(Ok(request)) - .await - .expect("Cannot send request"); + send_req.send(request).await.expect("Cannot send request"); } Ok(()) } /// Reads from the request channel and updates the progress bar status async fn progress_bar_task( - mut recv_resp: mpsc::Receiver, + mut recv_resp: mpsc::Receiver>, verbose: Verbosity, pb: Option, formatter: Box, mut stats: ResponseStats, -) -> Result<(Option, ResponseStats)> { +) -> Result<(Option, ResponseStats), ErrorKind> { while let Some(response) = recv_resp.recv().await { + let response = response?; show_progress( &mut io::stderr(), pb.as_ref(), @@ -232,8 +233,8 @@ fn init_progress_bar(initial_message: &'static str) -> ProgressBar { } async fn request_channel_task( - recv_req: mpsc::Receiver>, - send_resp: mpsc::Sender, + recv_req: mpsc::Receiver>, + send_resp: mpsc::Sender>, max_concurrency: usize, client: Client, cache: Arc, @@ -243,8 +244,7 @@ async fn request_channel_task( StreamExt::for_each_concurrent( ReceiverStream::new(recv_req), max_concurrency, - |request: Result| async { - let request = request.expect("cannot read request"); + |request: Result| async { let response = handle( &client, cache.clone(), @@ -277,19 +277,32 @@ async fn check_url(client: &Client, request: Request) -> Response { Response::new( uri.clone(), Status::Error(ErrorKind::InvalidURI(uri.clone())), - source, + source.into(), ) }) } /// Handle a single request +/// +/// # Errors +/// +/// An Err is returned if and only if there was an error while loading +/// a *user-provided* input argument. Other errors, including errors in +/// link resolution and in resolved inputs, will be returned as Ok with +/// a failed response. async fn handle( client: &Client, cache: Arc, cache_exclude_status: HashSet, - request: Request, + request: Result, accept: HashSet, -) -> Response { +) -> Result { + // Note that the RequestError cases bypass the cache. + let request = match request { + Ok(x) => x, + Err(e) => return e.into_response(), + }; + let uri = request.uri.clone(); if let Some(v) = cache.get(&uri) { // Found a cached request @@ -304,7 +317,7 @@ async fn handle( // code. Status::from_cache_status(v.value().status, &accept) }; - return Response::new(uri.clone(), status, request.source); + return Ok(Response::new(uri.clone(), status, request.source.into())); } // Request was not cached; run a normal check @@ -318,11 +331,11 @@ async fn handle( // - Skip caching links for which the status code has been explicitly excluded from the cache. let status = response.status(); if ignore_cache(&uri, status, &cache_exclude_status) { - return response; + return Ok(response); } cache.insert(uri, status.into()); - response + Ok(response) } /// Returns `true` if the response should be ignored in the cache. @@ -351,7 +364,7 @@ fn show_progress( response: &Response, formatter: &dyn ResponseFormatter, verbose: &Verbosity, -) -> Result<()> { +) -> Result<(), ErrorKind> { // In case the log level is set to info, we want to show the detailed // response output. Otherwise, we only show the essential information // (typically the status code and the URL, but this is dependent on the @@ -402,7 +415,7 @@ mod tests { use crate::{formatters::get_response_formatter, options}; use http::StatusCode; use log::info; - use lychee_lib::{CacheStatus, ClientBuilder, ErrorKind, ResolvedInputSource, Uri}; + use lychee_lib::{CacheStatus, ClientBuilder, ErrorKind, Uri}; use super::*; @@ -412,7 +425,7 @@ mod tests { let response = Response::new( Uri::try_from("http://127.0.0.1").unwrap(), Status::Cached(CacheStatus::Ok(200)), - ResolvedInputSource::Stdin, + InputSource::Stdin, ); let formatter = get_response_formatter(&options::OutputMode::Plain); show_progress( @@ -434,7 +447,7 @@ mod tests { let response = Response::new( Uri::try_from("http://127.0.0.1").unwrap(), Status::Cached(CacheStatus::Ok(200)), - ResolvedInputSource::Stdin, + InputSource::Stdin, ); let formatter = get_response_formatter(&options::OutputMode::Plain); show_progress( diff --git a/lychee-bin/src/commands/dump.rs b/lychee-bin/src/commands/dump.rs index 26df6f4018..88570933e3 100644 --- a/lychee-bin/src/commands/dump.rs +++ b/lychee-bin/src/commands/dump.rs @@ -1,6 +1,7 @@ use log::error; +use log::warn; use lychee_lib::Request; -use lychee_lib::Result; +use lychee_lib::RequestError; use std::fs; use std::io::{self, Write}; use tokio_stream::StreamExt; @@ -11,9 +12,9 @@ use crate::verbosity::Verbosity; use super::CommandParams; /// Dump all detected links to stdout without checking them -pub(crate) async fn dump(params: CommandParams) -> Result +pub(crate) async fn dump(params: CommandParams) -> lychee_lib::Result where - S: futures::Stream>, + S: futures::Stream>, { let requests = params.requests; tokio::pin!(requests); @@ -25,7 +26,17 @@ where let mut writer = super::create_writer(params.cfg.output)?; while let Some(request) = requests.next().await { - let mut request = request?; + if let Err(e @ RequestError::UserInputContent { .. }) = request { + return Err(e.into_error()); + } + + let mut request = match request { + Ok(x) => x, + Err(e) => { + warn!("{e}"); + continue; + } + }; // Apply URI remappings (if any) params.client.remap(&mut request.uri)?; diff --git a/lychee-bin/src/commands/mod.rs b/lychee-bin/src/commands/mod.rs index 295f598dea..5b2c6f62db 100644 --- a/lychee-bin/src/commands/mod.rs +++ b/lychee-bin/src/commands/mod.rs @@ -14,11 +14,11 @@ use std::sync::Arc; use crate::cache::Cache; use crate::options::Config; -use lychee_lib::Result; +use lychee_lib::RequestError; use lychee_lib::{Client, Request}; /// Parameters passed to every command -pub(crate) struct CommandParams>> { +pub(crate) struct CommandParams>> { pub(crate) client: Client, pub(crate) cache: Arc, pub(crate) requests: S, @@ -30,7 +30,7 @@ pub(crate) struct CommandParams>> { /// # Errors /// /// Returns an error if the output file cannot be opened. -fn create_writer(output: Option) -> Result> { +fn create_writer(output: Option) -> lychee_lib::Result> { Ok(match output { Some(path) => Box::new(fs::OpenOptions::new().append(true).open(path)?), None => Box::new(io::stdout().lock()), diff --git a/lychee-bin/src/formatters/response/color.rs b/lychee-bin/src/formatters/response/color.rs index 38180f63be..ed43ce4a81 100644 --- a/lychee-bin/src/formatters/response/color.rs +++ b/lychee-bin/src/formatters/response/color.rs @@ -20,7 +20,9 @@ impl ColorFormatter { | Status::Unsupported(_) | Status::Cached(CacheStatus::Excluded | CacheStatus::Unsupported) => &DIM, Status::UnknownStatusCode(_) | Status::Timeout(_) => &YELLOW, - Status::Error(_) | Status::Cached(CacheStatus::Error(_)) => &PINK, + Status::Error(_) | Status::RequestError(_) | Status::Cached(CacheStatus::Error(_)) => { + &PINK + } } } diff --git a/lychee-bin/src/formatters/response/emoji.rs b/lychee-bin/src/formatters/response/emoji.rs index 0465ab9baa..9b779ffe7f 100644 --- a/lychee-bin/src/formatters/response/emoji.rs +++ b/lychee-bin/src/formatters/response/emoji.rs @@ -19,7 +19,9 @@ impl EmojiFormatter { | Status::Cached(CacheStatus::Excluded | CacheStatus::Unsupported) => "🚫", Status::Redirected(_, _) => "↪️", Status::UnknownStatusCode(_) | Status::Timeout(_) => "⚠️", - Status::Error(_) | Status::Cached(CacheStatus::Error(_)) => "❌", + Status::Error(_) | Status::RequestError(_) | Status::Cached(CacheStatus::Error(_)) => { + "❌" + } } } } diff --git a/lychee-bin/src/formatters/stats/markdown.rs b/lychee-bin/src/formatters/stats/markdown.rs index 01914541f9..6feddb4c0f 100644 --- a/lychee-bin/src/formatters/stats/markdown.rs +++ b/lychee-bin/src/formatters/stats/markdown.rs @@ -163,10 +163,7 @@ impl StatsFormatter for Markdown { #[cfg(test)] mod tests { use http::StatusCode; - use lychee_lib::{ - CacheStatus, InputSource, Redirects, ResolvedInputSource, Response, ResponseBody, Status, - Uri, - }; + use lychee_lib::{CacheStatus, InputSource, Redirects, Response, ResponseBody, Status, Uri}; use reqwest::Url; use crate::formatters::suggestion::Suggestion; @@ -228,7 +225,7 @@ mod tests { stats.add(Response::new( Uri::try_from("http://127.0.0.1").unwrap(), Status::Cached(CacheStatus::Error(Some(404))), - ResolvedInputSource::Stdin, + InputSource::Stdin, )); // Add suggestion @@ -252,7 +249,7 @@ mod tests { Url::parse("http://redirected.dev").unwrap(), ]), ), - ResolvedInputSource::Stdin, + InputSource::Stdin, )); let summary = MarkdownResponseStats(stats); diff --git a/lychee-bin/src/formatters/stats/mod.rs b/lychee-bin/src/formatters/stats/mod.rs index 7ab8a2509a..dc2d2233d6 100644 --- a/lychee-bin/src/formatters/stats/mod.rs +++ b/lychee-bin/src/formatters/stats/mod.rs @@ -55,14 +55,14 @@ where mod tests { use super::*; - use lychee_lib::{ErrorKind, ResolvedInputSource, Response, Status, Uri}; + use lychee_lib::{ErrorKind, Response, Status, Uri}; use url::Url; fn make_test_url(url: &str) -> Url { Url::parse(url).expect("Expected valid Website URI") } - fn make_test_response(url_str: &str, source: ResolvedInputSource) -> Response { + fn make_test_response(url_str: &str, source: InputSource) -> Response { let uri = Uri::from(make_test_url(url_str)); Response::new(uri, Status::Error(ErrorKind::EmptyUrl), source) @@ -74,18 +74,12 @@ mod tests { // Sorted list of test sources let test_sources = vec![ - ResolvedInputSource::RemoteUrl(Box::new(make_test_url("https://example.com/404"))), - ResolvedInputSource::RemoteUrl(Box::new(make_test_url("https://example.com/home"))), - ResolvedInputSource::RemoteUrl(Box::new(make_test_url("https://example.com/page/1"))), - ResolvedInputSource::RemoteUrl(Box::new(make_test_url("https://example.com/page/10"))), + InputSource::RemoteUrl(Box::new(make_test_url("https://example.com/404"))), + InputSource::RemoteUrl(Box::new(make_test_url("https://example.com/home"))), + InputSource::RemoteUrl(Box::new(make_test_url("https://example.com/page/1"))), + InputSource::RemoteUrl(Box::new(make_test_url("https://example.com/page/10"))), ]; - let unresolved_test_sources: Vec = test_sources - .iter() - .map(Clone::clone) - .map(Into::::into) - .collect(); - // Sorted list of test responses let test_response_urls = vec![ "https://example.com/", @@ -110,7 +104,7 @@ mod tests { .collect(); // Check that the input sources are sorted - assert_eq!(unresolved_test_sources, sorted_sources); + assert_eq!(test_sources, sorted_sources); // Check that the responses are sorted for (_, response_bodies) in sorted_errors { diff --git a/lychee-bin/src/stats.rs b/lychee-bin/src/stats.rs index 7d97013ead..e7614affb1 100644 --- a/lychee-bin/src/stats.rs +++ b/lychee-bin/src/stats.rs @@ -71,7 +71,7 @@ impl ResponseStats { pub(crate) const fn increment_status_counters(&mut self, status: &Status) { match status { Status::Ok(_) => self.successful += 1, - Status::Error(_) => self.errors += 1, + Status::Error(_) | Status::RequestError(_) => self.errors += 1, Status::UnknownStatusCode(_) => self.unknown += 1, Status::Timeout(_) => self.timeouts += 1, Status::Redirected(_, _) => self.redirects += 1, @@ -92,7 +92,7 @@ impl ResponseStats { /// Add a response status to the appropriate map (success, fail, excluded) fn add_response_status(&mut self, response: Response) { let status = response.status(); - let source: InputSource = response.source().clone().into(); + let source: InputSource = response.source().clone(); let status_map_entry = match status { _ if status.is_error() => self.error_map.entry(source).or_default(), Status::Ok(_) if self.detailed_stats => self.success_map.entry(source).or_default(), @@ -129,9 +129,7 @@ mod tests { use std::collections::{HashMap, HashSet}; use http::StatusCode; - use lychee_lib::{ - ErrorKind, InputSource, ResolvedInputSource, Response, ResponseBody, Status, Uri, - }; + use lychee_lib::{ErrorKind, InputSource, Response, ResponseBody, Status, Uri}; use reqwest::Url; use super::ResponseStats; @@ -145,7 +143,7 @@ mod tests { // and it's a lot faster to just generate a fake response fn mock_response(status: Status) -> Response { let uri = website("https://some-url.com/ok"); - Response::new(uri, status, ResolvedInputSource::Stdin) + Response::new(uri, status, InputSource::Stdin) } fn dummy_ok() -> Response { @@ -181,10 +179,7 @@ mod tests { let response = dummy_error(); let expected_error_map: HashMap> = - HashMap::from_iter([( - response.source().clone().into(), - HashSet::from_iter([response.1]), - )]); + HashMap::from_iter([(response.source().clone(), HashSet::from_iter([response.1]))]); assert_eq!(stats.error_map, expected_error_map); assert!(stats.success_map.is_empty()); @@ -204,7 +199,7 @@ mod tests { let mut expected_error_map: HashMap> = HashMap::new(); let response = dummy_error(); let entry = expected_error_map - .entry(response.source().clone().into()) + .entry(response.source().clone()) .or_default(); entry.insert(response.1); assert_eq!(stats.error_map, expected_error_map); @@ -212,7 +207,7 @@ mod tests { let mut expected_success_map: HashMap> = HashMap::new(); let response = dummy_ok(); let entry = expected_success_map - .entry(response.source().clone().into()) + .entry(response.source().clone()) .or_default(); entry.insert(response.1); assert_eq!(stats.success_map, expected_success_map); @@ -220,7 +215,7 @@ mod tests { let mut expected_excluded_map: HashMap> = HashMap::new(); let response = dummy_excluded(); let entry = expected_excluded_map - .entry(response.source().clone().into()) + .entry(response.source().clone()) .or_default(); entry.insert(response.1); assert_eq!(stats.excluded_map, expected_excluded_map); diff --git a/lychee-bin/tests/cli.rs b/lychee-bin/tests/cli.rs index a27eae8748..b435e3514e 100644 --- a/lychee-bin/tests/cli.rs +++ b/lychee-bin/tests/cli.rs @@ -1544,11 +1544,10 @@ The config file should contain every possible key for documentation purposes." .stdout(contains("This URI is available in HTTPS protocol, but HTTP is provided. Use 'https://example.com/' instead")); } - /// If `base-dir` is not set, don't throw an error in case we encounter + /// If `base-dir` is not set, an error should be thrown if we encounter /// an absolute local link (e.g. `/about`) within a file. - /// Instead, simply ignore the link. #[test] - fn test_ignore_absolute_local_links_without_base() { + fn test_absolute_local_links_without_base() { let offline_dir = fixtures_path!().join("offline"); cargo_bin_cmd!() @@ -1556,8 +1555,9 @@ The config file should contain every possible key for documentation purposes." .arg(offline_dir.join("index.html")) .env_clear() .assert() - .success() - .stdout(contains("0 Total")); + .failure() + .stdout(contains("5 Error")) + .stdout(contains("Error building URL").count(5)); } #[test] @@ -2999,6 +2999,29 @@ The config file should contain every possible key for documentation purposes." Ok(()) } + // An input which is invalid (no permission directory or invalid glob) + // should fail as a CLI error, not a link checking error. + #[test] + fn test_invalid_user_input_source() -> Result<()> { + cargo_bin_cmd!() + .arg("http://website.invalid") + .assert() + .failure() + .code(1); + + // maybe test with a directory with no write permissions? but there + // doesn't seem to be an equivalent to chmod on the windows API: + // https://doc.rust-lang.org/std/fs/struct.Permissions.html + + cargo_bin_cmd!() + .arg("invalid-glob[") + .assert() + .failure() + .code(1); + + Ok(()) + } + /// Invalid glob patterns should be checked and reported as a CLI parsing /// error before link checking. #[test] @@ -3047,7 +3070,8 @@ The config file should contain every possible key for documentation purposes." .arg(file) .assert() .failure() - .stderr(contains("Error: Preprocessor command 'program does not exist' failed: could not start: No such file or directory (os error 2)")); + .code(2) + .stdout(contains("Preprocessor command 'program does not exist' failed: could not start: No such file or directory")); } #[test] @@ -3060,8 +3084,10 @@ The config file should contain every possible key for documentation purposes." .arg(&file) .assert() .failure() - .stderr(contains(format!( - "Error: Preprocessor command '{}' failed: exited with non-zero code: ", script.as_os_str().to_str().unwrap() + .code(2) + .stdout(contains(format!( + "Preprocessor command '{}' failed: exited with non-zero code: ", + script.as_os_str().to_str().unwrap() ))); let script = fixtures_path!().join("pre").join("error_message.sh"); @@ -3071,8 +3097,10 @@ The config file should contain every possible key for documentation purposes." .arg(file) .assert() .failure() - .stderr(contains(format!( - "Error: Preprocessor command '{}' failed: exited with non-zero code: Some error message", script.as_os_str().to_str().unwrap() + .code(2) + .stdout(contains(format!( + "Preprocessor command '{}' failed: exited with non-zero code: Some error message", + script.as_os_str().to_str().unwrap() ))); } } diff --git a/lychee-lib/src/client.rs b/lychee-lib/src/client.rs index 63306bb91c..a6efa70dc9 100644 --- a/lychee-lib/src/client.rs +++ b/lychee-lib/src/client.rs @@ -494,7 +494,7 @@ impl Client { self.remap(uri)?; if self.is_excluded(uri) { - return Ok(Response::new(uri.clone(), Status::Excluded, source)); + return Ok(Response::new(uri.clone(), Status::Excluded, source.into())); } let status = match uri.scheme() { @@ -505,7 +505,7 @@ impl Client { _ => self.check_website(uri, credentials).await?, }; - Ok(Response::new(uri.clone(), status, source)) + Ok(Response::new(uri.clone(), status, source.into())) } /// Check a single file using the file checker. diff --git a/lychee-lib/src/collector.rs b/lychee-lib/src/collector.rs index 816d594f07..20c7d26630 100644 --- a/lychee-lib/src/collector.rs +++ b/lychee-lib/src/collector.rs @@ -5,8 +5,8 @@ use crate::filter::PathExcludes; use crate::types::resolver::UrlContentResolver; use crate::{ - Base, Input, Request, Result, basic_auth::BasicAuthExtractor, extract::Extractor, - types::FileExtensions, types::uri::raw::RawUri, utils::request, + Base, Input, LycheeResult, Request, RequestError, basic_auth::BasicAuthExtractor, + extract::Extractor, types::FileExtensions, types::uri::raw::RawUri, utils::request, }; use futures::TryStreamExt; use futures::{ @@ -72,7 +72,7 @@ impl Collector { /// /// Returns an `Err` if the `root_dir` is not an absolute path /// or if the reqwest `Client` fails to build - pub fn new(root_dir: Option, base: Option) -> Result { + pub fn new(root_dir: Option, base: Option) -> LycheeResult { if let Some(root_dir) = &root_dir && root_dir.is_relative() { @@ -180,7 +180,10 @@ impl Collector { /// Convenience method to fetch all unique links from inputs /// with the default extensions. - pub fn collect_links(self, inputs: HashSet) -> impl Stream> { + pub fn collect_links( + self, + inputs: HashSet, + ) -> impl Stream> { self.collect_links_from_file_types(inputs, crate::types::FileType::default_extensions()) } @@ -195,7 +198,7 @@ impl Collector { self, inputs: HashSet, extensions: FileExtensions, - ) -> impl Stream> { + ) -> impl Stream> { let skip_missing_inputs = self.skip_missing_inputs; let skip_hidden = self.skip_hidden; let skip_ignored = self.skip_ignored; @@ -255,7 +258,7 @@ impl Collector { base.as_ref(), basic_auth_extractor.as_ref(), ); - Result::Ok(stream::iter(requests.into_iter().map(Ok))) + Result::Ok(stream::iter(requests)) } }) .try_flatten() @@ -273,7 +276,7 @@ mod tests { use super::*; use crate::{ - Result, Uri, + LycheeResult, Uri, filter::PathExcludes, types::{FileType, Input, InputSource}, }; @@ -283,7 +286,7 @@ mod tests { inputs: HashSet, root_dir: Option, base: Option, - ) -> Result> { + ) -> LycheeResult> { let responses = Collector::new(root_dir, base)?.collect_links(inputs); Ok(responses.map(|r| r.unwrap().uri).collect().await) } @@ -297,7 +300,7 @@ mod tests { root_dir: Option, base: Option, extensions: FileExtensions, - ) -> Result> { + ) -> LycheeResult> { let responses = Collector::new(root_dir, base)? .include_verbatim(true) .collect_links_from_file_types(inputs, extensions); @@ -311,7 +314,7 @@ mod tests { const TEST_GLOB_2_MAIL: &str = "test@glob-2.io"; #[tokio::test] - async fn test_file_without_extension_is_plaintext() -> Result<()> { + async fn test_file_without_extension_is_plaintext() -> LycheeResult<()> { let temp_dir = tempfile::tempdir().unwrap(); // Treat as plaintext file (no extension) let file_path = temp_dir.path().join("README"); @@ -336,7 +339,7 @@ mod tests { } #[tokio::test] - async fn test_url_without_extension_is_html() -> Result<()> { + async fn test_url_without_extension_is_html() -> LycheeResult<()> { let input = Input::new("https://example.com/", None, true)?; let contents: Vec<_> = input .get_contents( @@ -357,7 +360,7 @@ mod tests { } #[tokio::test] - async fn test_collect_links() -> Result<()> { + async fn test_collect_links() -> LycheeResult<()> { let temp_dir = tempfile::tempdir().unwrap(); let temp_dir_path = temp_dir.path(); diff --git a/lychee-lib/src/lib.rs b/lychee-lib/src/lib.rs index c91cc65098..6c917fda92 100644 --- a/lychee-lib/src/lib.rs +++ b/lychee-lib/src/lib.rs @@ -95,7 +95,8 @@ pub use crate::{ types::{ AcceptRange, AcceptRangeError, Base, BasicAuthCredentials, BasicAuthSelector, CacheStatus, CookieJar, ErrorKind, FileExtensions, FileType, Input, InputContent, InputResolver, - InputSource, Preprocessor, Redirects, Request, ResolvedInputSource, Response, ResponseBody, - Result, Status, StatusCodeExcluder, StatusCodeSelector, uri::valid::Uri, + InputSource, LycheeResult, Preprocessor, Redirects, Request, RequestError, + ResolvedInputSource, Response, ResponseBody, Result, Status, StatusCodeExcluder, + StatusCodeSelector, uri::raw::RawUri, uri::valid::Uri, }, }; diff --git a/lychee-lib/src/retry.rs b/lychee-lib/src/retry.rs index a4217a0de4..8bf0ec7d10 100644 --- a/lychee-lib/src/retry.rs +++ b/lychee-lib/src/retry.rs @@ -120,6 +120,7 @@ impl RetryExt for Status { match self { Status::Ok(_) => false, Status::Error(err) => err.should_retry(), + Status::RequestError(_) => false, Status::Timeout(_) => true, Status::Redirected(_, _) => false, Status::UnknownStatusCode(_) => false, diff --git a/lychee-lib/src/types/cache.rs b/lychee-lib/src/types/cache.rs index 28deda165e..017d0770e1 100644 --- a/lychee-lib/src/types/cache.rs +++ b/lychee-lib/src/types/cache.rs @@ -85,6 +85,7 @@ impl From<&Status> for CacheStatus { } _ => Self::Error(None), }, + Status::RequestError(_) => Self::Error(None), } } } diff --git a/lychee-lib/src/types/error.rs b/lychee-lib/src/types/error.rs index b4c5f4ff7c..a2db4b2746 100644 --- a/lychee-lib/src/types/error.rs +++ b/lychee-lib/src/types/error.rs @@ -278,12 +278,12 @@ impl ErrorKind { ErrorKind::InvalidBase(base, reason) => Some(format!( "Invalid base URL or directory: '{base}'. {reason}", )), - ErrorKind::InvalidBaseJoin(text) => Some(format!( - "Cannot join '{text}' with base URL. Check relative path format", - )), - ErrorKind::InvalidPathToUri(path) => Some(format!( - "Cannot convert path to URI: '{path}'. Check path format", - )), + ErrorKind::InvalidBaseJoin(_) => Some("Check relative path format".to_string()), + ErrorKind::InvalidPathToUri(path) => match path { + path if path.starts_with('/') => + "To resolve root-relative links in local files, provide a root dir", + _ => "Check path format", + }.to_string().into(), ErrorKind::RootDirMustBeAbsolute(path_buf) => Some(format!( "Root directory must be absolute: '{}'. Use full path", path_buf.display() diff --git a/lychee-lib/src/types/input/input.rs b/lychee-lib/src/types/input/input.rs index 5080bc3b8e..eadd404b74 100644 --- a/lychee-lib/src/types/input/input.rs +++ b/lychee-lib/src/types/input/input.rs @@ -8,8 +8,8 @@ use super::content::InputContent; use super::source::{InputSource, ResolvedInputSource}; use crate::Preprocessor; use crate::filter::PathExcludes; -use crate::types::{FileType, file::FileExtensions, resolver::UrlContentResolver}; -use crate::{ErrorKind, Result}; +use crate::types::{FileType, RequestError, file::FileExtensions, resolver::UrlContentResolver}; +use crate::{ErrorKind, LycheeResult}; use async_stream::try_stream; use futures::stream::{Stream, StreamExt}; use std::path::{Path, PathBuf}; @@ -42,7 +42,7 @@ impl Input { input: &str, file_type_hint: Option, glob_ignore_case: bool, - ) -> Result { + ) -> LycheeResult { let source = InputSource::new(input, glob_ignore_case)?; Ok(Self { source, @@ -57,7 +57,7 @@ impl Input { /// Returns an error if: /// - the input does not exist (i.e. the path is invalid) /// - the input cannot be parsed as a URL - pub fn from_value(value: &str) -> Result { + pub fn from_value(value: &str) -> LycheeResult { Self::new(value, None, false) } @@ -95,20 +95,49 @@ impl Input { resolver: UrlContentResolver, excluded_paths: PathExcludes, preprocessor: Option, - ) -> impl Stream> { + ) -> impl Stream> { try_stream! { - // Handle simple cases that don't need resolution + let source = self.source.clone(); + + let user_input_error = + move |e: ErrorKind| RequestError::UserInputContent(source.clone(), e); + let discovered_input_error = + |e: ErrorKind| RequestError::GetInputContent(self.source.clone(), e); + + // Handle simple cases that don't need resolution. Also, perform + // simple *stateful* checks for more complex input sources. + // + // Stateless well-formedness checks (e.g., checking glob syntax) + // are done in InputSource::new. match self.source { InputSource::RemoteUrl(url) => { match resolver.url_contents(*url).await { Err(_) if skip_missing => (), - Err(e) => Err(e)?, + Err(e) => Err(user_input_error(e))?, Ok(content) => yield content, } return; } + InputSource::FsPath(ref path) => { + let is_readable = if path.is_dir() { + path.read_dir() + .map(|_| ()) + .map_err(|e| ErrorKind::DirTraversal(ignore::Error::Io(e))) + } else { + // This checks existence without requiring an open. Opening here, + // then re-opening later, might cause problems with pipes. This + // does not validate permissions. + path.metadata() + .map(|_| ()) + .map_err(|e| ErrorKind::ReadFileInput(e, path.clone())) + }; + + is_readable.map_err(user_input_error)?; + } InputSource::Stdin => { - yield Self::stdin_content(self.file_type_hint).await?; + yield Self::stdin_content(self.file_type_hint) + .await + .map_err(user_input_error)?; return; } InputSource::String(ref s) => { @@ -138,31 +167,35 @@ impl Input { }, ResolvedInputSource::RemoteUrl(url) => { resolver.url_contents(*url).await - }, + } ResolvedInputSource::Stdin => { Self::stdin_content(self.file_type_hint).await - }, + } ResolvedInputSource::String(s) => { Ok(Self::string_content(&s, self.file_type_hint)) - }, + } }; match content_result { Err(_) if skip_missing => (), - Err(e) if matches!(&e, ErrorKind::ReadFileInput(io_err, _) if io_err.kind() == std::io::ErrorKind::InvalidData) => { + Err(e) if matches!(&e, ErrorKind::ReadFileInput(io_err, _) if io_err.kind() == std::io::ErrorKind::InvalidData) => + { // If the file contains invalid UTF-8 (e.g. binary), we skip it if let ErrorKind::ReadFileInput(_, path) = &e { - log::warn!("Skipping file with invalid UTF-8 content: {}", path.display()); + log::warn!( + "Skipping file with invalid UTF-8 content: {}", + path.display() + ); } - }, - Err(e) => Err(e)?, + } + Err(e) => Err(discovered_input_error(e))?, Ok(content) => { sources_empty = false; yield content } } - }, - Err(e) => Err(e)?, + } + Err(e) => Err(discovered_input_error(e))?, } } @@ -190,7 +223,7 @@ impl Input { skip_hidden: bool, skip_ignored: bool, excluded_paths: &PathExcludes, - ) -> impl Stream> { + ) -> impl Stream> { InputResolver::resolve( &self, file_extensions, @@ -217,7 +250,7 @@ impl Input { pub async fn path_content + AsRef + Clone>( path: P, preprocessor: Option<&Preprocessor>, - ) -> Result { + ) -> LycheeResult { let path = path.into(); let content = Self::get_content(&path, preprocessor).await?; @@ -233,7 +266,7 @@ impl Input { /// # Errors /// /// Returns an error if stdin cannot be read - pub async fn stdin_content(file_type_hint: Option) -> Result { + pub async fn stdin_content(file_type_hint: Option) -> LycheeResult { let mut content = String::new(); let mut stdin = stdin(); stdin.read_to_string(&mut content).await?; @@ -255,7 +288,10 @@ impl Input { /// Get content of file. /// Get preprocessed file content if [`Preprocessor`] is [`Some`] - async fn get_content(path: &PathBuf, preprocessor: Option<&Preprocessor>) -> Result { + async fn get_content( + path: &PathBuf, + preprocessor: Option<&Preprocessor>, + ) -> LycheeResult { if let Some(pre) = preprocessor { pre.process(path) } else { @@ -269,7 +305,7 @@ impl Input { impl TryFrom<&str> for Input { type Error = crate::ErrorKind; - fn try_from(value: &str) -> std::result::Result { + fn try_from(value: &str) -> Result { Self::from_value(value) } } diff --git a/lychee-lib/src/types/mod.rs b/lychee-lib/src/types/mod.rs index da10e0aad6..3b019e3004 100644 --- a/lychee-lib/src/types/mod.rs +++ b/lychee-lib/src/types/mod.rs @@ -12,6 +12,7 @@ pub(crate) mod mail; mod preprocessor; pub(crate) mod redirect_history; mod request; +mod request_error; pub(crate) mod resolver; mod response; mod status; @@ -29,9 +30,13 @@ pub use input::{Input, InputContent, InputResolver, InputSource, ResolvedInputSo pub use preprocessor::Preprocessor; pub use redirect_history::Redirects; pub use request::Request; +pub use request_error::RequestError; pub use response::{Response, ResponseBody}; pub use status::Status; pub use status_code::*; /// The lychee `Result` type pub type Result = std::result::Result; + +/// The lychee `Result` type, aliased to avoid conflicting with [`std::result::Result`]. +pub type LycheeResult = Result; diff --git a/lychee-lib/src/types/request_error.rs b/lychee-lib/src/types/request_error.rs new file mode 100644 index 0000000000..aa06973340 --- /dev/null +++ b/lychee-lib/src/types/request_error.rs @@ -0,0 +1,90 @@ +use std::convert::TryFrom; +use std::sync::LazyLock; +use thiserror::Error; + +use crate::{ErrorKind, RawUri, Response, Status, Uri}; +use crate::{InputSource, ResolvedInputSource}; + +static ERROR_URI: LazyLock = LazyLock::new(|| Uri::try_from("error:").unwrap()); + +/// An error which occurs while trying to construct a [`Request`] object. +/// That is, an error which happens while trying to load links from an input +/// source. +#[derive(Error, Debug, PartialEq, Eq, Hash)] +pub enum RequestError { + /// Unable to construct a URL for a link appearing within the given source. + #[error("Error building URL for {0}: {2}")] + CreateRequestItem(RawUri, ResolvedInputSource, #[source] ErrorKind), + + /// Unable to load the content of an input source. + #[error("Error reading input '{0}': {1}")] + GetInputContent(InputSource, #[source] ErrorKind), + + /// Unable to load an input source directly specified by the user. + #[error("Error reading user input '{0}': {1}")] + UserInputContent(InputSource, #[source] ErrorKind), +} + +impl RequestError { + /// Get the underlying cause of this [`RequestError`]. + #[must_use] + pub const fn error(&self) -> &ErrorKind { + match self { + Self::CreateRequestItem(_, _, e) + | Self::GetInputContent(_, e) + | Self::UserInputContent(_, e) => e, + } + } + + /// Convert this [`RequestError`] into its source error. + #[must_use] + pub fn into_error(self) -> ErrorKind { + match self { + Self::CreateRequestItem(_, _, e) + | Self::GetInputContent(_, e) + | Self::UserInputContent(_, e) => e, + } + } + + /// Get (a clone of) the input source within which the error happened. + #[must_use] + pub fn input_source(&self) -> InputSource { + match self { + Self::CreateRequestItem(_, src, _) => src.clone().into(), + Self::GetInputContent(src, _) | Self::UserInputContent(src, _) => src.clone(), + } + } + + /// Convert this request error into a (failed) [`Response`] for reporting + /// purposes. + /// + /// # Errors + /// + /// If this `RequestError` was caused by failing to load a user-specified + /// input, the underlying cause of the `RequestError` will be returned + /// as an Err. This allows the error to be propagated back to the user. + pub fn into_response(self) -> Result { + match self { + RequestError::UserInputContent(_, e) => Err(e), + e => { + let src = e.input_source(); + Ok(Response::new( + ERROR_URI.clone(), + Status::RequestError(e), + src, + )) + } + } + } +} + +#[cfg(test)] +mod tests { + use super::ERROR_URI; + use std::sync::LazyLock; + + #[test] + fn test_error_url_parses() { + let _ = LazyLock::force(&ERROR_URI); + } +} diff --git a/lychee-lib/src/types/response.rs b/lychee-lib/src/types/response.rs index 96e62c117f..d194d686ae 100644 --- a/lychee-lib/src/types/response.rs +++ b/lychee-lib/src/types/response.rs @@ -3,7 +3,7 @@ use std::fmt::Display; use http::StatusCode; use serde::Serialize; -use crate::{ResolvedInputSource, Status, Uri}; +use crate::{InputSource, Status, Uri}; /// Response type returned by lychee after checking a URI // @@ -14,13 +14,13 @@ use crate::{ResolvedInputSource, Status, Uri}; // `pub(crate)` is insufficient, because the `stats` module is in the `bin` // crate crate. #[derive(Debug)] -pub struct Response(ResolvedInputSource, pub ResponseBody); +pub struct Response(InputSource, pub ResponseBody); impl Response { #[inline] #[must_use] /// Create new response - pub const fn new(uri: Uri, status: Status, source: ResolvedInputSource) -> Self { + pub const fn new(uri: Uri, status: Status, source: InputSource) -> Self { Response(source, ResponseBody { uri, status }) } @@ -35,7 +35,7 @@ impl Response { #[must_use] /// Retrieve the underlying source of the response /// (e.g. the input file or the URL) - pub const fn source(&self) -> &ResolvedInputSource { + pub const fn source(&self) -> &InputSource { &self.0 } diff --git a/lychee-lib/src/types/status.rs b/lychee-lib/src/types/status.rs index db8d80888c..f246c0bd34 100644 --- a/lychee-lib/src/types/status.rs +++ b/lychee-lib/src/types/status.rs @@ -3,6 +3,7 @@ use std::{collections::HashSet, fmt::Display}; use super::CacheStatus; use super::redirect_history::Redirects; use crate::ErrorKind; +use crate::RequestError; use http::StatusCode; use reqwest::Response; use serde::ser::SerializeStruct; @@ -25,6 +26,8 @@ pub enum Status { Ok(StatusCode), /// Failed request Error(ErrorKind), + /// Request could not be built + RequestError(RequestError), /// Request timed out Timeout(Option), /// Got redirected to different resource @@ -51,6 +54,7 @@ impl Display for Status { Status::Timeout(None) => f.write_str("Timeout"), Status::Unsupported(e) => write!(f, "Unsupported: {e}"), Status::Error(e) => write!(f, "{e}"), + Status::RequestError(e) => write!(f, "{e}"), Status::Cached(status) => write!(f, "{status}"), Status::Excluded => Ok(()), } @@ -153,6 +157,7 @@ impl Status { )) } Status::Error(e) => e.details(), + Status::RequestError(e) => e.error().details(), Status::Timeout(_) => None, Status::UnknownStatusCode(_) => None, Status::Unsupported(_) => None, @@ -174,7 +179,10 @@ impl Status { pub const fn is_error(&self) -> bool { matches!( self, - Status::Error(_) | Status::Cached(CacheStatus::Error(_)) | Status::Timeout(_) + Status::Error(_) + | Status::RequestError(_) + | Status::Cached(CacheStatus::Error(_)) + | Status::Timeout(_) ) } @@ -213,7 +221,7 @@ impl Status { Status::Redirected(_, _) => ICON_REDIRECTED, Status::UnknownStatusCode(_) => ICON_UNKNOWN, Status::Excluded => ICON_EXCLUDED, - Status::Error(_) => ICON_ERROR, + Status::Error(_) | Status::RequestError(_) => ICON_ERROR, Status::Timeout(_) => ICON_TIMEOUT, Status::Unsupported(_) => ICON_UNSUPPORTED, Status::Cached(_) => ICON_CACHED, @@ -260,6 +268,7 @@ impl Status { } _ => "ERROR".to_string(), }, + Status::RequestError(_) => "ERROR".to_string(), Status::Timeout(code) => match code { Some(code) => code.as_str().to_string(), None => "TIMEOUT".to_string(), diff --git a/lychee-lib/src/types/uri/raw.rs b/lychee-lib/src/types/uri/raw.rs index 99516ec2c1..026ee75821 100644 --- a/lychee-lib/src/types/uri/raw.rs +++ b/lychee-lib/src/types/uri/raw.rs @@ -2,7 +2,7 @@ use std::{fmt::Display, num::NonZeroUsize}; /// A raw URI that got extracted from a document with a fuzzy parser. /// Note that this can still be invalid according to stricter URI standards -#[derive(Clone, Debug, PartialEq, Eq)] +#[derive(Clone, Debug, PartialEq, Eq, Hash)] pub struct RawUri { /// Unparsed URI represented as a `String`. There is no guarantee that it /// can be parsed into a URI object @@ -23,7 +23,7 @@ pub struct RawUri { impl Display for RawUri { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { - write!(f, "{} (Attribute: {:?})", self.text, self.attribute) + write!(f, "{:?} (Attribute: {:?})", self.text, self.attribute) } } @@ -42,7 +42,7 @@ impl From<(&str, RawUriSpan)> for RawUri { /// A span of a [`RawUri`] in the document. /// /// The span can be used to give more precise error messages. -#[derive(Clone, Copy, Debug, PartialEq, Eq)] +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] pub struct RawUriSpan { /// The line of the URI. /// diff --git a/lychee-lib/src/utils/request.rs b/lychee-lib/src/utils/request.rs index df80ce9a7a..8d64b2fd67 100644 --- a/lychee-lib/src/utils/request.rs +++ b/lychee-lib/src/utils/request.rs @@ -1,13 +1,10 @@ -use log::warn; use percent_encoding::percent_decode_str; use reqwest::Url; -use std::{ - collections::HashSet, - path::{Path, PathBuf}, -}; +use std::collections::HashSet; +use std::path::{Path, PathBuf}; use crate::{ - Base, BasicAuthCredentials, ErrorKind, Request, Result, Uri, + Base, BasicAuthCredentials, ErrorKind, LycheeResult, Request, RequestError, Uri, basic_auth::BasicAuthExtractor, types::{ResolvedInputSource, uri::raw::RawUri}, utils::{path, url}, @@ -28,7 +25,7 @@ fn create_request( root_dir: Option<&PathBuf>, base: Option<&Base>, extractor: Option<&BasicAuthExtractor>, -) -> Result { +) -> LycheeResult { let uri = try_parse_into_uri(raw_uri, source, root_dir, base)?; let source = source.clone(); let element = raw_uri.element.clone(); @@ -54,7 +51,7 @@ fn try_parse_into_uri( source: &ResolvedInputSource, root_dir: Option<&PathBuf>, base: Option<&Base>, -) -> Result { +) -> LycheeResult { let text = prepend_root_dir_if_absolute_local_link(&raw_uri.text, root_dir); let uri = match Uri::try_from(raw_uri.clone()) { Ok(uri) => uri, @@ -90,7 +87,7 @@ fn create_uri_from_file_path( file_path: &Path, link_text: &str, ignore_absolute_local_links: bool, -) -> Result { +) -> LycheeResult { let target_path = if is_anchor(link_text) { // For anchors, we need to append the anchor to the file name. let file_name = file_path @@ -113,29 +110,39 @@ fn create_uri_from_file_path( } /// Create requests out of the collected URLs. -/// Only keeps "valid" URLs. This filters out anchors for example. +/// Returns a vector of valid URLs and errors. Valid URLs are deduplicated, +/// request errors are not deduplicated. /// /// If a URLs is ignored (because of the current settings), -/// it will not be added to the `HashSet`. +/// it will not be added to the results. pub(crate) fn create( uris: Vec, source: &ResolvedInputSource, root_dir: Option<&PathBuf>, base: Option<&Base>, extractor: Option<&BasicAuthExtractor>, -) -> HashSet { +) -> Vec> { let base = base.cloned().or_else(|| Base::from_source(source)); - uris.into_iter() - .filter_map(|raw_uri| { - match create_request(&raw_uri, source, root_dir, base.as_ref(), extractor) { - Ok(request) => Some(request), - Err(e) => { - warn!("Error creating request: {e:?}"); - None - } + let mut requests = HashSet::::new(); + let mut errors = Vec::::new(); + + for raw_uri in uris { + let result = create_request(&raw_uri, source, root_dir, base.as_ref(), extractor); + match result { + Ok(request) => { + requests.insert(request); } - }) + Err(e) => errors.push(RequestError::CreateRequestItem( + raw_uri.clone(), + source.clone(), + e, + )), + } + } + + (requests.into_iter().map(Result::Ok)) + .chain(errors.into_iter().map(Result::Err)) .collect() } @@ -154,7 +161,7 @@ fn resolve_and_create_url( src_path: &Path, dest_path: &str, ignore_absolute_local_links: bool, -) -> Result { +) -> LycheeResult { let (dest_path, fragment) = url::remove_get_params_and_separate_fragment(dest_path); // Decode the destination path to avoid double-encoding @@ -196,6 +203,25 @@ mod tests { use super::*; + /// Create requests from the given raw URIs and returns requests that were + /// constructed successfully, silently ignoring link parsing errors. + /// + /// This reduces the `Result` handling which is needed in test cases. Test + /// cases can still detect the unexpected appearance of errors by the + /// length being different. + fn create_ok_only( + uris: Vec, + source: &ResolvedInputSource, + root_dir: Option<&PathBuf>, + base: Option<&Base>, + extractor: Option<&BasicAuthExtractor>, + ) -> Vec { + create(uris, source, root_dir, base, extractor) + .into_iter() + .filter_map(Result::ok) + .collect() + } + fn raw_uri(text: &'static str) -> RawUri { RawUri { text: text.to_string(), @@ -227,7 +253,7 @@ mod tests { let source = ResolvedInputSource::String(Cow::Borrowed("")); let uris = vec![raw_uri("relative.html")]; - let requests = create(uris, &source, None, Some(&base), None); + let requests = create_ok_only(uris, &source, None, Some(&base), None); assert_eq!(requests.len(), 1); assert!( @@ -243,7 +269,7 @@ mod tests { let source = ResolvedInputSource::String(Cow::Borrowed("")); let uris = vec![raw_uri("https://another.com/page")]; - let requests = create(uris, &source, None, Some(&base), None); + let requests = create_ok_only(uris, &source, None, Some(&base), None); assert_eq!(requests.len(), 1); assert!( @@ -259,7 +285,7 @@ mod tests { let source = ResolvedInputSource::String(Cow::Borrowed("")); let uris = vec![raw_uri("/root-relative")]; - let requests = create(uris, &source, None, Some(&base), None); + let requests = create_ok_only(uris, &source, None, Some(&base), None); assert_eq!(requests.len(), 1); assert!( @@ -275,7 +301,7 @@ mod tests { let source = ResolvedInputSource::String(Cow::Borrowed("")); let uris = vec![raw_uri("../parent")]; - let requests = create(uris, &source, None, Some(&base), None); + let requests = create_ok_only(uris, &source, None, Some(&base), None); assert_eq!(requests.len(), 1); assert!( @@ -291,7 +317,7 @@ mod tests { let source = ResolvedInputSource::String(Cow::Borrowed("")); let uris = vec![raw_uri("#fragment")]; - let requests = create(uris, &source, None, Some(&base), None); + let requests = create_ok_only(uris, &source, None, Some(&base), None); assert_eq!(requests.len(), 1); assert!( @@ -307,7 +333,7 @@ mod tests { let source = ResolvedInputSource::FsPath(PathBuf::from("/some/page.html")); let uris = vec![raw_uri("relative.html")]; - let requests = create(uris, &source, Some(&root_dir), None, None); + let requests = create_ok_only(uris, &source, Some(&root_dir), None, None); assert_eq!(requests.len(), 1); assert!( @@ -323,7 +349,7 @@ mod tests { let source = ResolvedInputSource::FsPath(PathBuf::from("/some/page.html")); let uris = vec![raw_uri("https://another.com/page")]; - let requests = create(uris, &source, Some(&root_dir), None, None); + let requests = create_ok_only(uris, &source, Some(&root_dir), None, None); assert_eq!(requests.len(), 1); assert!( @@ -339,7 +365,7 @@ mod tests { let source = ResolvedInputSource::FsPath(PathBuf::from("/some/page.html")); let uris = vec![raw_uri("/root-relative")]; - let requests = create(uris, &source, Some(&root_dir), None, None); + let requests = create_ok_only(uris, &source, Some(&root_dir), None, None); assert_eq!(requests.len(), 1); assert!( @@ -355,7 +381,7 @@ mod tests { let source = ResolvedInputSource::FsPath(PathBuf::from("/some/page.html")); let uris = vec![raw_uri("../parent")]; - let requests = create(uris, &source, Some(&root_dir), None, None); + let requests = create_ok_only(uris, &source, Some(&root_dir), None, None); assert_eq!(requests.len(), 1); assert!( @@ -371,7 +397,7 @@ mod tests { let source = ResolvedInputSource::FsPath(PathBuf::from("/some/page.html")); let uris = vec![raw_uri("#fragment")]; - let requests = create(uris, &source, Some(&root_dir), None, None); + let requests = create_ok_only(uris, &source, Some(&root_dir), None, None); assert_eq!(requests.len(), 1); assert!( @@ -388,7 +414,7 @@ mod tests { let source = ResolvedInputSource::FsPath(PathBuf::from("/some/page.html")); let uris = vec![raw_uri("relative.html")]; - let requests = create(uris, &source, Some(&root_dir), Some(&base), None); + let requests = create_ok_only(uris, &source, Some(&root_dir), Some(&base), None); assert_eq!(requests.len(), 1); assert!( @@ -405,7 +431,7 @@ mod tests { let source = ResolvedInputSource::FsPath(PathBuf::from("/some/page.html")); let uris = vec![raw_uri("https://another.com/page")]; - let requests = create(uris, &source, Some(&root_dir), Some(&base), None); + let requests = create_ok_only(uris, &source, Some(&root_dir), Some(&base), None); assert_eq!(requests.len(), 1); assert!( @@ -422,7 +448,7 @@ mod tests { let source = ResolvedInputSource::FsPath(PathBuf::from("/some/page.html")); let uris = vec![raw_uri("/root-relative")]; - let requests = create(uris, &source, Some(&root_dir), Some(&base), None); + let requests = create_ok_only(uris, &source, Some(&root_dir), Some(&base), None); assert_eq!(requests.len(), 1); assert!( @@ -439,7 +465,7 @@ mod tests { let source = ResolvedInputSource::FsPath(PathBuf::from("/some/page.html")); let uris = vec![raw_uri("../parent")]; - let requests = create(uris, &source, Some(&root_dir), Some(&base), None); + let requests = create_ok_only(uris, &source, Some(&root_dir), Some(&base), None); assert_eq!(requests.len(), 1); assert!( @@ -456,7 +482,7 @@ mod tests { let source = ResolvedInputSource::FsPath(PathBuf::from("/some/page.html")); let uris = vec![raw_uri("#fragment")]; - let requests = create(uris, &source, Some(&root_dir), Some(&base), None); + let requests = create_ok_only(uris, &source, Some(&root_dir), Some(&base), None); assert_eq!(requests.len(), 1); assert!( @@ -471,7 +497,7 @@ mod tests { let source = ResolvedInputSource::String(Cow::Borrowed("")); let uris = vec![raw_uri("https://example.com/page")]; - let requests = create(uris, &source, None, None, None); + let requests = create_ok_only(uris, &source, None, None, None); assert_eq!(requests.len(), 1); assert!( @@ -509,6 +535,33 @@ mod tests { ); } + #[test] + fn test_create_request_from_relative_file_path_errors() { + // relative links unsupported from stdin + assert!( + create_request( + &raw_uri("file.html"), + &ResolvedInputSource::Stdin, + None, + None, + None, + ) + .is_err() + ); + + // error because no root-dir and no base-url + assert!( + create_request( + &raw_uri("/file.html"), + &ResolvedInputSource::FsPath(PathBuf::from("page.html")), + None, + None, + None, + ) + .is_err() + ); + } + #[test] fn test_create_request_from_absolute_file_path() { let base = Base::Local(PathBuf::from("/tmp/lychee"));