enqueuemail/
lib.rs

1//! This crate provides a collection of functions to establish a HTTPS
2//! connection to a remote URL, retrieve the HTML content and extract
3//! its title.
4#![warn(rust_2018_idioms)]
5#![warn(missing_docs, missing_debug_implementations)]
6#![warn(anonymous_parameters, bare_trait_objects, unreachable_pub)]
7#![deny(unused)]
8#![deny(unused_variables)]
9#![forbid(unsafe_code)]
10// #![deny(missing_docs)]
11
12use async_std::io;
13use async_std::net::TcpStream;
14use async_tls::TlsConnector;
15use futures::io::AsyncWriteExt;
16use httparse::Header;
17use httparse::Response;
18use nom::bytes::complete::tag;
19use nom::bytes::complete::take_until;
20use nom::error::ErrorKind;
21use nom::sequence::tuple;
22use nom::IResult;
23use rustls::ClientConfig;
24use std::boxed::Box;
25use std::convert::TryFrom;
26use std::error::Error;
27use std::io::Cursor;
28use std::net::SocketAddr;
29use std::net::ToSocketAddrs;
30use std::path::Path;
31use std::path::PathBuf;
32use std::str::from_utf8;
33use std::sync::Arc;
34use std::time::Duration;
35use structopt::StructOpt;
36use url::Host;
37use url::ParseError;
38use url::Url;
39use x11_clipboard::Clipboard;
40
41#[macro_use]
42extern crate log;
43
44// Lint And Attributes Documentation References
45//
46// https://doc.rust-lang.org/reference/attributes.html
47// https://doc.rust-lang.org/rustc/lints/listing/index.html
48// https://doc.rust-lang.org/rustc/lints/groups.html
49
50#[allow(trivial_casts)]
51
52const TITLE_TAG_OPEN: &str = "<title>";
53const TITLE_TAG_CLOSE: &str = "</title>";
54
55/// Options contains the CLI available options offered to the binary tool.
56///
57/// This struct maps all the options that are available to the CLI user.
58/// of the binary `enqueue-email`.
59#[derive(Debug, StructOpt)]
60pub struct Options {
61    /// The URL to bookmark and enqueue. This excludes scheme, host, port
62    /// and domain. This option has the priority over the Clipboard.
63    #[structopt(short = "u", long = "url")]
64    pub url: Option<String>,
65
66    /// Use the URL currently on the top of the Clipboard.
67    /// This option disables all the other defined options.
68    #[structopt(short = "c", long = "clipboard")]
69    pub clipboard: bool,
70
71    /// The host to connect to.
72    #[structopt(short = "h", long = "host")]
73    pub host: Option<String>,
74
75    /// The port to connect to.
76    #[structopt(short = "p", long = "port", default_value = "443")]
77    pub port: u16,
78
79    /// The scheme protocol of the URI.
80    #[structopt(short = "s", long = "scheme", default_value = "https")]
81    pub scheme: String,
82
83    /// The path component in the URI. This follows the definitions of
84    /// RFC2396 and RFC3986
85    /// (https://en.wikipedia.org/wiki/Uniform_Resource_Identifier).
86    #[structopt(short = "t", long = "path", default_value = "/")]
87    pub path: String,
88
89    /// An optional query component preceded by a question mark (?),
90    /// containing a query string of non-hierarchical data.
91    #[structopt(short = "q", long = "query")]
92    pub query: Option<String>,
93
94    /// An optional fragment component preceded by a hash (#).
95    #[structopt(short = "f", long = "fragment")]
96    pub fragment: Option<String>,
97
98    /// The domain to connect to. This may be different from the host!
99    #[structopt(short = "d", long = "domain")]
100    pub domain: Option<String>,
101
102    /// A file with a certificate authority chain, allows to connect
103    /// to certificate authories not included in the default set.
104    #[structopt(short = "", long = "cafile", parse(from_os_str))]
105    pub cafile: Option<PathBuf>,
106
107    /// Run msmt-queue and flush all mail currently in queue.
108    /// This wraps the command 'msmtp-queue -r'.
109    #[structopt(short = "r", long = "run")]
110    pub run: bool,
111}
112
113/// Creates a TLSConnector with a specific CA file if provided.
114pub async fn connector(cafile: &Option<PathBuf>) -> Result<TlsConnector, Box<dyn Error>> {
115    if let Some(cafile) = cafile {
116        connector_for_ca_file(cafile).await.map_err(|ioerr| {
117            let e: Box<dyn Error> = Box::new(ioerr);
118            e
119        })
120    } else {
121        Ok(TlsConnector::default())
122    }
123}
124
125async fn connector_for_ca_file(cafile: &Path) -> Result<TlsConnector, io::Error> {
126    let mut config = ClientConfig::new();
127    let file = async_std::fs::read(cafile).await?;
128    let mut pem = Cursor::new(file);
129    config
130        .root_store
131        .add_pem_file(&mut pem)
132        .map_err(|_| io::Error::new(io::ErrorKind::InvalidInput, "invalid cert"))?;
133    Ok(TlsConnector::from(Arc::new(config)))
134}
135
136/// Extract the page title tag content from a raw but well formed HTML content.
137///
138/// The implementation of this functions is a Nom parser.
139///
140/// Type Definition [nom::IResult](https://docs.rs/nom/5.1.2/nom/type.IResult.html).
141///
142/// Holds the result of parsing functions
143/// type IResult<I, O, E = (I, ErrorKind)> = Result<(I, O), Err<E>>;
144/// It depends on I, the input type, O, the output type, and E, the error type
145/// (by default u32).
146///
147/// The Ok side is a pair containing the remainder of the input (the part of the
148/// data that was not parsed) and the produced value. The Err side contains an
149/// instance of nom::Err.
150/// let (_, (_, _, title)) = match tuple::<_, _, VerboseError<_>, _>(...
151///
152/// Function
153/// [nom::bytes::complete::take_until](https://docs.rs/nom/5.1.2/nom/bytes/complete/fn.take_until.html)
154///
155/// Returns the longest input slice till it matches the pattern.
156///
157/// It doesn't consume the pattern. It will return
158/// Err(Err::Error((_rest, ErrorKind::TakeUntil)))
159/// if the pattern wasn't met.
160///
161/// Function std::str::from_utf8
162/// Converts a vector of bytes to a String.
163///
164/// A string (String) is made of bytes (u8), and a vector of bytes (Vec<u8>) is
165/// made of bytes, so this function converts between the two. Not all byte
166/// slices are valid Strings, however: String requires that it is valid UTF-8.
167/// from_utf8() checks to ensure that the bytes are valid UTF-8, and then does
168/// the conversion.
169///
170/// Also uses
171/// [from_utft8](https://doc.rust-lang.org/std/string/struct.String.html#method.from_utf8).
172pub fn page_title_from_html(content: &[u8]) -> Result<Option<String>, Box<dyn Error>> {
173    let content: &str = match from_utf8(&content) {
174        Ok(v) => v,
175        Err(e) => {
176            let e: Box<dyn Error> = From::from(format!("Invalid UTF-8 sequence: {}", e));
177            return Err(e);
178        }
179    };
180
181    let has_tag: Result<(&str, &str), nom::Err<(&str, ErrorKind)>> = until_open_title_tag(content);
182    if let Ok((content, _before_tag)) = has_tag {
183        let parsed: IResult<&str, (_, _, &str), (&str, ErrorKind)> = tuple((
184            take_until(TITLE_TAG_OPEN),
185            tag(TITLE_TAG_OPEN),
186            take_until(TITLE_TAG_CLOSE),
187        ))(content);
188        let (_, (_, _, title)) = match parsed {
189            Ok(r) => r,
190            Err(_) => {
191                let e: Box<dyn Error> = From::from("parsing error");
192                return Err(e);
193            }
194        };
195
196        return Ok(Some(String::from(title.trim())));
197    }
198    Ok(None)
199}
200
201fn until_open_title_tag(s: &str) -> IResult<&str, &str> {
202    take_until(TITLE_TAG_OPEN)(s)
203}
204
205/// Read the "redirect location" from a HTTP response.
206///
207/// The Ok variant of the Results, if Some, includes the Location of the
208/// redirect, read from the response headers, otherwise is Ok<None>.
209pub fn has_redirect(
210    response: &Response<'_, '_>,
211    origin: &Url,
212) -> Result<Option<Url>, Box<dyn Error>> {
213    match &response.code {
214        None => {
215            let e: Box<dyn Error> =
216                From::from("HTTP response parsing error: could not find StatusCode");
217            Err(e)
218        }
219        Some(c @ 300..=308) => {
220            for Header { name, value } in response.headers.iter() {
221                if name == &"Location" {
222                    let dest: &str = from_utf8(&value).unwrap();
223                    let dest: Url = match Url::parse(dest) {
224                        Ok(url) => url,
225                        Err(ParseError::RelativeUrlWithoutBase) => origin
226                            .join(dest)
227                            .expect("unparseable relative redirect destination"),
228                        Err(_) => {
229                            // Due to the handling above, this path seems unreachable.
230                            unreachable!();
231                        }
232                    };
233                    debug!("HTTP redirect: {} -> {}", c, dest);
234                    return Ok(Some(dest));
235                }
236            }
237            let e: Box<dyn Error> = From::from(format!("HTTP redirect without location: {}", c));
238            Err(e)
239        }
240        Some(c) => {
241            debug!("HTTP StatusCode: {}", c);
242            Ok(None)
243        }
244    }
245}
246
247/// Jump is the result of a connection.
248///
249/// A redirect can direct to the Next URL, othewise
250/// Landing has the page content as Vec<u8>.
251#[derive(Debug)]
252pub enum Jump {
253    /// The redirect location as Url.
254    Next(Url),
255    /// The content of the destination URL, as vector of chars.
256    Landing(Vec<u8>),
257}
258
259/// Uses the given TLS connector and the URL parts to retrieve the
260/// HTML page content or the next location in case of a redirect.
261///
262/// HTTP URL conforms to the syntax of a generic URI.
263/// The URI generic syntax consists of a hierarchical sequence of five
264/// components:
265///
266/// URI = scheme:[//authority]path[?query][#fragment]
267/// authority = [userinfo@]host[:port]
268/// HTTP URL = URI
269///
270/// The TCP stream, HTTP stream, and TLS stream each expect a different subset
271/// of a URL. TCP takes localhost:8080, HTTP takes http://localhost:8080/foo/bar,
272/// TLS takes localhost.
273pub async fn page_content(connector: &TlsConnector, parts: &Parts) -> Result<Jump, Box<dyn Error>> {
274    let socket_addr: &SocketAddr = &parts.addr;
275    let domain: &str = &parts.domain;
276
277    // Open a normal TCP connection, just as you are used to.
278    let tcp_stream: TcpStream = match TcpStream::connect(&socket_addr).await {
279        Ok(t) => t,
280        Err(e) => {
281            let e: Box<dyn Error> = From::from(format!("TCPStream error: {}", e));
282            return Err(e);
283        }
284    };
285
286    // Use the connector to start the handshake process. This
287    // consumes the TCP stream to ensure you are not reusing it.
288    // Awaiting the handshake gives you an encrypted stream back
289    // which you can use like any other.
290    let mut tls_stream = match connector.connect(&domain, tcp_stream).await {
291        Ok(ts) => ts,
292        Err(e) => {
293            let e: Box<dyn Error> = From::from(format!("TLSStream error: {}", e));
294            return Err(e);
295        }
296    };
297
298    // We write our crafted HTTP request to it.
299    tls_stream
300        .write_all(parts.http_request().as_bytes())
301        .await?;
302
303    // And write the content in the content variable.
304    let mut response_content: Vec<u8> = Vec::with_capacity(1024);
305    io::copy(&mut tls_stream, &mut response_content).await?;
306
307    let mut headers: [Header<'_>; 32] = [httparse::EMPTY_HEADER; 32];
308    let mut response: Response<'_, '_> = Response::new(&mut headers);
309    response.parse(&response_content)?;
310
311    match has_redirect(&response, &parts.url)? {
312        Some(url) => Ok(Jump::Next(url)),
313        None => Ok(Jump::Landing(response_content)),
314    }
315}
316
317fn clipboard_content() -> Result<String, Box<dyn Error>> {
318    let clipboard = Clipboard::new().unwrap();
319    let clipboard_content = clipboard.load(
320        clipboard.setter.atoms.clipboard,
321        clipboard.setter.atoms.utf8_string,
322        clipboard.setter.atoms.property,
323        Duration::from_secs(3),
324    )?;
325    let clipboard_content = String::from_utf8(clipboard_content).expect("UTF-8 content");
326    debug!("clipboard content: {}", &clipboard_content);
327    Ok(clipboard_content)
328}
329
330/// The hierarchical sequence of components of a URI.
331///
332/// Each URI begins with a scheme name that refers to a specification
333/// for assigning identifiers within that scheme. As such, the URI
334/// syntax is a federated and extensible naming system wherein each
335/// scheme's specification may further restrict the syntax and
336/// semantics of identifiers using that scheme. The URI generic syntax
337/// is a superset of the syntax of all URI schemes. It was first
338/// defined in RFC 2396, published in August 1998, and finalized in
339/// RFC 3986, published in January 2005.
340///
341/// [URI syntax](https://en.wikipedia.org/wiki/Uniform_Resource_Identifier#Generic_syntax)
342#[derive(Clone, Debug)]
343pub struct Parts {
344    /// A Uniform Resource Locator (URL), colloquially termed a web
345    /// address, is a reference to a web resource that specifies
346    /// its location on a computer network and a mechanism for
347    /// retrieving it. A URL is a specific type of Uniform Resource
348    /// Identifier (URI).
349    pub url: Url,
350    /// The registered host name of an URL.
351    pub host: Host<String>,
352    /// The port number of the URL.
353    pub port: u16,
354    /// An internet socket address, either IPv4 or IPv6.
355    pub addr: SocketAddr,
356    /// The domain name (not an IP address) of the given host.
357    pub domain: String,
358    /// the path for this URL, as a percent-encoded ASCII string. For
359    /// cannot-be-a-base URLs, this is an arbitrary string that
360    /// doesn’t start with '/'. For other URLs, this starts with a '/'
361    /// slash and continues with slash-separated path segments.
362    pub path: String,
363    /// The URL's query string, if any, as a percent-encoded ASCII string.
364    pub query: String,
365    /// A fragment is the part of the URL after the # symbol. The
366    /// fragment is optional and, if present, contains a fragment
367    /// identifier that identifies a secondary resource, such as a
368    /// section heading of a document.
369    ///
370    /// In HTML, the fragment identifier is usually the id attribute
371    /// of a an element that is scrolled to on load. Browsers
372    /// typically will not send the fragment portion of a URL to the
373    /// server.
374    pub fragment: String,
375}
376
377impl Parts {
378    /// Create a bare bones HTTP GET request.
379    ///
380    /// The reference is the
381    /// [RFC7230](https://tools.ietf.org/rfcmarkup?doc=7230)
382    pub fn http_request(&self) -> String {
383        let query = if self.query != "" && !self.query.starts_with('?') {
384            format!("?{}", self.query)
385        } else {
386            self.query.clone()
387        };
388        let fragment = if self.fragment != "" && !self.fragment.starts_with('#') {
389            format!("#{}", self.fragment)
390        } else {
391            self.fragment.clone()
392        };
393        let http_request: String = format!(
394            "GET {}{}{} HTTP/1.0\r\nHost: {}\r\n\r\n",
395            self.path, query, fragment, self.domain,
396        );
397        debug!("HTTP request: {}", &http_request);
398        http_request
399    }
400}
401
402impl TryFrom<&Options> for Parts {
403    // type Error = &'static str;
404    type Error = Box<dyn Error>;
405
406    fn try_from(options: &Options) -> Result<Self, Self::Error> {
407        // The options --clipboard, --url and --host are mutually exclusive.
408        let mutex_opts: [bool; 3] = [
409            options.clipboard,
410            options.url.is_some(),
411            options.host.is_some(),
412        ];
413        match mutex_opts {
414            [true, false, false] | [false, true, false] | [false, false, true] => {}
415            [false, false, false]
416            | [true, true, false]
417            | [true, false, true]
418            | [false, true, true]
419            | [true, true, true] => {
420                let e: Box<dyn Error> =
421                    From::from("use one and only one of --clipboard, --url or --host");
422                return Err(e);
423            }
424        }
425
426        let url: Url = if options.clipboard {
427            Url::parse(&clipboard_content().expect("clipboard is not readable"))
428                .expect("Clipboard is not a URL")
429        } else if let Some(u) = &options.url {
430            Url::parse(&u).expect("Unparseable URL")
431        } else if let Some(h) = &options.host {
432            Url::parse(&format!("{}://{}", options.scheme, h)).expect("Unparseable scheme and host")
433        } else {
434            unreachable!("use only one of --clipboard, --url or --host")
435        };
436
437        let host: Host<String> = if let Some(h) = &options.host {
438            Host::parse(&h).expect("Unparseable Host")
439        } else {
440            url.host().expect("URL without host").to_owned()
441        };
442        debug!("host name: {}", &host);
443
444        let port: u16 = match options.port {
445            0 => match url.scheme() {
446                "https" => 443,
447                "http" => {
448                    let e: Box<dyn Error> =
449                        From::from("HTTP standard ports is not yet suported".to_string());
450                    return Err(e);
451                }
452                s => {
453                    let e: Box<dyn Error> = From::from(format!(
454                        "Only HTTP(S) standard ports are suported. {} given",
455                        s
456                    ));
457                    return Err(e);
458                }
459            },
460            p => p,
461        };
462        debug!("port number: {}", &port);
463
464        // Check if the provided host exists.
465        // *Note*: this conversion will attemp the network resolution of the
466        // given host!
467        //
468        // Using:
469        // - https://docs.rs/async-std/1.6.2/async_std/net/enum.SocketAddr.html
470        //   The implementation of to_socket_addrs: Converts this
471        //   object to an iterator of resolved SocketAddrs.
472        //   The returned iterator may not actually yield any values
473        //   depending on the outcome of any resolution performed.
474        //   Note that this function may block the current thread
475        //   while resolution is performed.
476        //   https://doc.rust-lang.org/nightly/src/std/net/addr.rs.html#921-923
477        let addr: SocketAddr = (host.to_string().as_str(), options.port)
478            .to_socket_addrs()?
479            .next()
480            .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))?;
481        debug!("socket address: {}", &addr);
482
483        // If no domain was passed, the host is also the domain to connect to.
484        let domain: String = if let Some(d) = &options.domain {
485            d.to_owned()
486        } else {
487            host.to_string()
488        };
489        debug!("domain {}", &domain);
490
491        let path: String = match &options.host {
492            None => (&url.path()).to_string(),
493            _ => options.path.to_string(),
494        };
495
496        let query: String = match &options.query {
497            Some(q) => q.to_owned(),
498            None => url.query().unwrap_or("").to_string(),
499        };
500
501        let fragment: String = match &options.fragment {
502            Some(f) => f.to_owned(),
503            None => url.fragment().unwrap_or("").to_string(),
504        };
505
506        Ok(Parts {
507            url,
508            host,
509            port,
510            addr,
511            query,
512            fragment,
513            domain,
514            path,
515        })
516    }
517}
518
519impl TryFrom<&str> for Parts {
520    type Error = Box<dyn Error>;
521
522    fn try_from(url: &str) -> Result<Self, Self::Error> {
523        let url: Url = Url::parse(&url).expect("Unparseable URL");
524        Self::try_from(&url)
525    }
526}
527
528impl TryFrom<&Url> for Parts {
529    type Error = Box<dyn Error>;
530
531    fn try_from(url: &Url) -> Result<Self, Self::Error> {
532        let host: Host<String> = url.host().expect("URL without host").to_owned();
533        let port: u16 = match url.scheme() {
534            "https" => 443,
535            "http" => 80,
536            s => {
537                let e: Box<dyn Error> = From::from(format!(
538                    "Only HTTP(S) standard ports are suported. {} given",
539                    s
540                ));
541                return Err(e);
542            }
543        };
544        let addr: SocketAddr = (host.to_string().as_str(), port)
545            .to_socket_addrs()?
546            .next()
547            .ok_or_else(|| io::Error::from(io::ErrorKind::NotFound))?;
548        // On the assumption that host and domain are the same in this case.
549        let domain: String = host.to_string();
550        let path: String = (&url.path()).to_string();
551        let query: String = url.query().unwrap_or("").to_string();
552        let fragment: String = url.fragment().unwrap_or("").to_string();
553
554        Ok(Parts {
555            url: url.clone(),
556            host,
557            port,
558            addr,
559            query,
560            fragment,
561            domain,
562            path,
563        })
564    }
565}
566
567#[cfg(test)]
568mod tests {
569    use super::*;
570    use async_std::task;
571    use std::fs;
572    use std::time::Instant;
573
574    #[allow(dead_code)]
575    fn init() {
576        let _ = env_logger::builder().is_test(true).try_init();
577    }
578
579    #[test]
580    fn test_connector_passes_without_cafile() -> Result<(), String> {
581        match task::block_on(async { connector(&None).await }) {
582            Ok(_) => Ok(()),
583            Err(_) => Err(String::from("connector should use the system CA files")),
584        }
585    }
586
587    #[test]
588    fn test_connector_fails_with_nonexisting_cafile() -> Result<(), String> {
589        let cafile: &Option<PathBuf> = &Some(PathBuf::from("/nope"));
590        match task::block_on(async { connector(cafile).await }) {
591            Ok(_) => Err(String::from(
592                "connector should fail when the CA file does not exist",
593            )),
594            Err(_) => Ok(()),
595        }
596    }
597
598    #[test]
599    #[should_panic(expected = "No such file or directory")]
600    fn test_connector_fails_with_nonexisting_cafile_with_failure_message() {
601        let cafile: &Option<PathBuf> = &Some(PathBuf::from("/nope"));
602        task::block_on(async { connector(cafile).await }).unwrap();
603    }
604
605    #[test]
606    fn test_connector_for_ca_file_passes() -> Result<(), String> {
607        // Considering the Cargo.toml path as "crate root"
608        // https://doc.rust-lang.org/cargo/reference/config.html#environment-variables
609        let cafile: &Path =
610            &Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/files/doc_rust-lang_org.crt");
611        match task::block_on(async { connector_for_ca_file(cafile).await }) {
612            Ok(_) => Ok(()),
613            Err(_) => Err(String::from("could not load the test PEM certificate")),
614        }
615    }
616
617    #[test]
618    fn test_page_title_from_html_passes_with_valid_html() -> Result<(), String> {
619        let page: &Path =
620            &Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/files/home_with_title.html");
621        match page_title_from_html(
622            &fs::read(page).map_err(|_| String::from("could not open HTML test page"))?,
623        ) {
624            Ok(Some(title)) if title == String::from("Rust Programming Language") => Ok(()),
625            Ok(Some(title)) => Err(format!("unexpected title: \"{}\"", title)),
626            Ok(None) => Err(From::from("could not find a title in the page")),
627
628            _ => Err(String::from(
629                "could not extract the title from the HTML page content",
630            )),
631        }
632    }
633
634    #[test]
635    fn test_page_title_from_html_passes_with_valid_html_without_title() -> Result<(), String> {
636        let page: &Path =
637            &Path::new(env!("CARGO_MANIFEST_DIR")).join("tests/files/home_without_title.html");
638        match page_title_from_html(
639            &fs::read(page).map_err(|_| String::from("could not open HTML test page"))?,
640        ) {
641            Ok(Some(title)) => Err(format!("unexpected title: \"{}\"", title)),
642            Ok(None) => Ok(()),
643            Err(e) => Err(format!("unexpected error: {}", e)),
644        }
645    }
646
647    #[test]
648    fn test_page_title_from_html_fails_with_non_utf8_characters() -> Result<(), String> {
649        match page_title_from_html(&vec![0, 159, 146, 150]) {
650            Ok(_) => Err(format!(
651                "unexpected success extracting the title from non UTF8 characters"
652            )),
653            Err(e)
654                if (*e).to_string()
655                    == "Invalid UTF-8 sequence: invalid utf-8 sequence of 1 bytes from index 1" =>
656            {
657                Ok(())
658            }
659            Err(e) => Err(format!("unexpected error: {}", e)),
660        }
661    }
662
663    #[test]
664    #[ignore]
665    fn test_page_title_from_html_with_attributes() -> Result<(), String> {
666        unimplemented!()
667    }
668
669    #[test]
670    fn test_has_redirect() -> Result<(), String> {
671        let origin: Url = Url::parse("https://doc.rust-lang.org/std")
672            .map_err(|e| format!("could not parse origin URL: {}", e))?;
673        let destination: Url = Url::parse("https://doc.rust-lang.org/stable/std/")
674            .map_err(|e| format!("could not parse destination URL: {}", e))?;
675        let mut headers: Vec<Header<'_>> = Vec::new();
676        let redirect_header = Header {
677            name: "Location",
678            value: destination.as_str().as_bytes(),
679        };
680        headers.push(redirect_header);
681        let mut response = Response::new(&mut headers);
682        response.code = Some(301);
683        match has_redirect(&response, &origin) {
684            Ok(Some(u)) if u == destination => Ok(()),
685            Ok(Some(u)) => Err(format!("unexpected redirect location: \"{}\"", u)),
686            Ok(None) => Err(format!("could not find HTTP redirect location")),
687            Err(e) => Err(format!("could not parse HTTP Response: {}", e)),
688        }
689    }
690
691    #[test]
692    fn test_has_no_redirect() -> Result<(), String> {
693        let origin: Url = Url::parse("https://doc.rust-lang.org/std")
694            .map_err(|e| format!("could not parse origin URL: {}", e))?;
695        let mut headers: Vec<Header<'_>> = Vec::new();
696        let mut response = Response::new(&mut headers);
697        response.code = Some(200);
698        match has_redirect(&response, &origin) {
699            Ok(None) => Ok(()),
700            Ok(Some(u)) => Err(format!("unexpected redirect location: \"{}\"", u)),
701            Err(e) => Err(format!("could not parse HTTP Response: {}", e)),
702        }
703    }
704
705    #[test]
706    fn test_has_redirect_fails_without_status_code() -> Result<(), String> {
707        let origin: Url = Url::parse("https://doc.rust-lang.org/std")
708            .map_err(|e| format!("could not parse origin URL: {}", e))?;
709        let mut headers: Vec<Header<'_>> = Vec::new();
710        let mut response = Response::new(&mut headers);
711        response.code = None;
712        match has_redirect(&response, &origin) {
713            Ok(_) => Err(From::from("unexpected success in redirect detection")),
714            Err(e)
715                if (*e).to_string() == "HTTP response parsing error: could not find StatusCode" =>
716            {
717                Ok(())
718            }
719            Err(e) => Err(format!("unexpected error message: {}", e)),
720        }
721    }
722
723    #[test]
724    #[ignore]
725    fn test_has_redirect_fails_with_unparseable_redirect_location() -> Result<(), String> {
726        let origin: Url = Url::parse("https://doc.rust-lang.org/std")
727            .map_err(|e| format!("could not parse origin URL: {}", e))?;
728        let mut headers: Vec<Header<'_>> = Vec::new();
729        let redirect_header = Header {
730            name: "Location",
731            value: "unreachable".as_bytes(),
732        };
733        headers.push(redirect_header);
734        let mut response = Response::new(&mut headers);
735        response.code = Some(301);
736        match has_redirect(&response, &origin) {
737            Ok(l) => Err(format!("unexpected success in redirect detection: {:?}", l)),
738            Err(e)
739                if (*e).to_string() == "HTTP response parsing error: could not find StatusCode" =>
740            {
741                Ok(())
742            }
743            Err(e) => Err(format!("unexpected error message: {}", e)),
744        }
745    }
746
747    #[test]
748    fn test_has_redirect_fails_without_redirect_location() -> Result<(), String> {
749        let origin: Url = Url::parse("https://doc.rust-lang.org/std")
750            .map_err(|e| format!("could not parse origin URL: {}", e))?;
751        let mut headers: Vec<Header<'_>> = Vec::new();
752        let mut response = Response::new(&mut headers);
753        response.code = Some(301);
754        match has_redirect(&response, &origin) {
755            Ok(l) => Err(format!("unexpected success in redirect detection: {:?}", l)),
756            Err(e) if (*e).to_string() == "HTTP redirect without location: 301" => Ok(()),
757            Err(e) => Err(format!("unexpected error message: {}", e)),
758        }
759    }
760
761    #[test]
762    fn test_has_redirect_passes_with_a_relative_redirect() -> Result<(), String> {
763        let origin: Url = Url::parse("https://doc.rust-lang.org/stable")
764            .map_err(|e| format!("could not parse origin URL: {}", e))?;
765        let mut headers: Vec<Header<'_>> = Vec::new();
766        let redirect_header = Header {
767            name: "Location",
768            value: "/stable/std/".as_bytes(),
769        };
770        headers.push(redirect_header);
771        let mut response = Response::new(&mut headers);
772        response.code = Some(301);
773        match has_redirect(&response, &origin) {
774            Ok(None) => Err(From::from("missing redirect detection")),
775            Ok(Some(l)) if l.to_string() == "https://doc.rust-lang.org/stable/std/" => Ok(()),
776            Ok(Some(l)) => Err(format!("unexpected redirect location: {}", l.to_string())),
777            Err(e) => Err(format!("could not detect redirection: {}", e)),
778        }
779    }
780
781    #[cfg(feature = "live-tests")]
782    mod livetests {
783        use super::*;
784        use std::net::IpAddr;
785        use std::net::Ipv4Addr;
786
787        #[test]
788        fn test_page_content_fails_with_http_only_url() -> Result<(), String> {
789            let connector: TlsConnector = TlsConnector::default();
790            let url: Url = Url::parse("http://example.org")
791                .map_err(|e| format!("could not parse URL: {}", e))?;
792            let parts: Parts = Parts::try_from(&url)
793                .map_err(|e| format!("could not parse parts from URL: {}", e))?;
794            match task::block_on(async { page_content(&connector, &parts).await }) {
795                Ok(_) => Err(format!("unexpected OK connection: HTTP should fail")),
796                Err(e) if (*e).to_string() == "TLSStream error: received corrupt message" => Ok(()),
797                Err(e) => Err(format!("unexpected error message: {}", e)),
798            }
799        }
800
801        #[test]
802        fn test_page_content_fails_with_broken_address() -> Result<(), String> {
803            let connector: TlsConnector = TlsConnector::default();
804            let parts: Parts = Parts {
805                url: Url::parse("http://example.org").unwrap(),
806                host: Host::parse("example.org").unwrap(),
807                port: 443,
808                addr: SocketAddr::new(IpAddr::V4(Ipv4Addr::new(127, 0, 0, 1)), 8080),
809                domain: String::new(),
810                path: String::new(),
811                query: String::new(),
812                fragment: String::new(),
813            };
814            match task::block_on(async { page_content(&connector, &parts).await }) {
815                Ok(_) => Err(format!("unexpected OK connection: HTTP should fail")),
816                Err(e)
817                    if (*e).to_string() == "TCPStream error: Connection refused (os error 111)" =>
818                {
819                    Ok(())
820                }
821                Err(e) => Err(format!("unexpected error message: {}", e)),
822            }
823        }
824
825        #[test]
826        fn test_page_content_from_url_with_jump_next() -> Result<(), String> {
827            let connector: TlsConnector = TlsConnector::default();
828            let url: Url = Url::parse("https://wikipedia.org")
829                .map_err(|e| format!("could not parse URL: {}", e))?;
830            let next: Url = Url::parse("https://www.wikipedia.org")
831                .map_err(|e| format!("could not parse URL: {}", e))?;
832            let parts: Parts = Parts::try_from(&url)
833                .map_err(|e| format!("could not parse parts from URL: {}", e))?;
834            match task::block_on(async { page_content(&connector, &parts).await }) {
835                Ok(Jump::Next(n)) if n == next => Ok(()),
836                Ok(Jump::Next(n)) => Err(format!("unexpected Jump::Next URL: {}", n)),
837                Ok(Jump::Landing(_)) => Err(format!("unexpected Jump::Landing")),
838                Err(e) => Err(format!("could not run live test: {}", e)),
839            }
840        }
841
842        #[test]
843        fn test_page_content_from_url_with_jump_landing() -> Result<(), String> {
844            let connector: TlsConnector = TlsConnector::default();
845            let url: Url = Url::parse("https://example.org")
846                .map_err(|e| format!("could not parse URL: {}", e))?;
847            let parts: Parts = Parts::try_from(&url)
848                .map_err(|e| format!("could not parse parts from URL: {}", e))?;
849            let http_ok_header: &str = "HTTP/1.0 200 OK";
850            match task::block_on(async { page_content(&connector, &parts).await }) {
851                Ok(Jump::Landing(content))
852                    if from_utf8(&content[0..http_ok_header.len()]) == Ok(http_ok_header) =>
853                {
854                    Ok(())
855                }
856                Ok(Jump::Landing(content)) => Err(format!(
857                    "unexpected HTTP header: {:?}",
858                    from_utf8(&content[0..http_ok_header.len()])
859                )),
860                Ok(Jump::Next(n)) => Err(format!("unexpected Jump::Next: {}", n)),
861                Err(e) => Err(format!("could not run live test: {}", e)),
862            }
863        }
864
865        mod parts {
866            use super::*;
867
868            #[test]
869            fn test_parts_try_from_options_fails_when_empty() -> Result<(), String> {
870                let options: Options = Options {
871                    url: None,              // Option<String>
872                    clipboard: false,       // bool
873                    host: None,             // Option<String>
874                    port: 0u16,             // u16
875                    scheme: "".to_string(), // String
876                    path: "".to_string(),   // String
877                    query: None,            // Option<String>
878                    fragment: None,         // Option<String>
879                    domain: None,           // Option<String>
880                    cafile: None,           // Option<PathBuf>
881                    run: false,             // bool
882                };
883                match Parts::try_from(&options) {
884                    Ok(_) => Err(format!("unexpected successful conversion into Parts")),
885                    Err(e)
886                        if (*e).to_string()
887                            == "use one and only one of --clipboard, --url or --host" =>
888                    {
889                        Ok(())
890                    }
891                    Err(e) => Err(format!("could not create Options: {}", e)),
892                }
893            }
894
895            #[test]
896            fn test_parts_try_from_options_fails_with_both_url_and_host() -> Result<(), String> {
897                let options: Options = Options {
898                    url: Some("url".to_string()),   // Option<String>
899                    clipboard: false,               // bool
900                    host: Some("host".to_string()), // Option<String>
901                    port: 0u16,                     // u16
902                    scheme: "".to_string(),         // String
903                    path: "".to_string(),           // String
904                    query: None,                    // Option<String>
905                    fragment: None,                 // Option<String>
906                    domain: None,                   // Option<String>
907                    cafile: None,                   // Option<PathBuf>
908                    run: false,                     // bool
909                };
910                match Parts::try_from(&options) {
911                    Ok(_) => Err(format!("unexpected successful conversion into Parts")),
912                    Err(e)
913                        if (*e).to_string()
914                            == "use one and only one of --clipboard, --url or --host" =>
915                    {
916                        Ok(())
917                    }
918                    Err(e) => Err(format!("could not create Options: {}", e)),
919                }
920            }
921
922            #[test]
923            fn test_parts_try_from_options_fails_with_both_clipboard_and_host() -> Result<(), String>
924            {
925                let options: Options = Options {
926                    url: None,                      // Option<String>
927                    clipboard: true,                // bool
928                    host: Some("host".to_string()), // Option<String>
929                    port: 0u16,                     // u16
930                    scheme: "".to_string(),         // String
931                    path: "".to_string(),           // String
932                    query: None,                    // Option<String>
933                    fragment: None,                 // Option<String>
934                    domain: None,                   // Option<String>
935                    cafile: None,                   // Option<PathBuf>
936                    run: false,                     // bool
937                };
938                match Parts::try_from(&options) {
939                    Ok(_) => Err(format!("unexpected successful conversion into Parts")),
940                    Err(e)
941                        if (*e).to_string()
942                            == "use one and only one of --clipboard, --url or --host" =>
943                    {
944                        Ok(())
945                    }
946                    Err(e) => Err(format!("could not create Options: {}", e)),
947                }
948            }
949
950            #[test]
951            fn test_parts_try_from_options_fails_with_both_clipboard_and_url() -> Result<(), String>
952            {
953                let options: Options = Options {
954                    url: Some("url".to_string()), // Option<String>
955                    clipboard: true,              // bool
956                    host: None,                   // Option<String>
957                    port: 0u16,                   // u16
958                    scheme: "".to_string(),       // String
959                    path: "".to_string(),         // String
960                    query: None,                  // Option<String>
961                    fragment: None,               // Option<String>
962                    domain: None,                 // Option<String>
963                    cafile: None,                 // Option<PathBuf>
964                    run: false,                   // bool
965                };
966                match Parts::try_from(&options) {
967                    Ok(_) => Err(format!("unexpected successful conversion into Parts")),
968                    Err(e)
969                        if (*e).to_string()
970                            == "use one and only one of --clipboard, --url or --host" =>
971                    {
972                        Ok(())
973                    }
974                    Err(e) => Err(format!("could not create Options: {}", e)),
975                }
976            }
977
978            #[test]
979            fn test_parts_try_from_options_fails_with_both_clipboard_and_host_and_url()
980            -> Result<(), String> {
981                let options: Options = Options {
982                    url: Some("url".to_string()),   // Option<String>
983                    clipboard: true,                // bool
984                    host: Some("host".to_string()), // Option<String>
985                    port: 0u16,                     // u16
986                    scheme: "".to_string(),         // String
987                    path: "".to_string(),           // String
988                    query: None,                    // Option<String>
989                    fragment: None,                 // Option<String>
990                    domain: None,                   // Option<String>
991                    cafile: None,                   // Option<PathBuf>
992                    run: false,                     // bool
993                };
994                match Parts::try_from(&options) {
995                    Ok(_) => Err(format!("unexpected successful conversion into Parts")),
996                    Err(e)
997                        if (*e).to_string()
998                            == "use one and only one of --clipboard, --url or --host" =>
999                    {
1000                        Ok(())
1001                    }
1002                    Err(e) => Err(format!("could not create Options: {}", e)),
1003                }
1004            }
1005
1006            #[test]
1007            fn test_parts_try_from_options_fails_with_non_existing_url() -> Result<(), String> {
1008                let options: Options = Options {
1009                    url: Some("https://unresolvable.example.org".to_string()), // Option<String>
1010                    clipboard: false,                                          // bool
1011                    host: None,                                                // Option<String>
1012                    port: 0u16,                                                // u16
1013                    scheme: "".to_string(),                                    // String
1014                    path: "".to_string(),                                      // String
1015                    query: None,                                               // Option<String>
1016                    fragment: None,                                            // Option<String>
1017                    domain: None,                                              // Option<String>
1018                    cafile: None,                                              // Option<PathBuf>
1019                    run: false,                                                // bool
1020                };
1021                match Parts::try_from(&options) {
1022                    Ok(_) => Err(format!("unexpected successful conversion into Parts")),
1023                    Err(e)
1024                        if e.to_string()
1025                            == "failed to lookup address information: Name or service not known" =>
1026                    {
1027                        Ok(())
1028                    }
1029                    Err(e) => Err(format!(
1030                        "unexpected error creating Parts from a URL: {}",
1031                        e.to_string()
1032                    )),
1033                }
1034            }
1035
1036            #[test]
1037            fn test_parts_try_from_options_fails_with_non_supported_scheme() -> Result<(), String> {
1038                let options: Options = Options {
1039                    url: Some("news://example.org".to_string()), // Option<String>
1040                    clipboard: false,                            // bool
1041                    host: None,                                  // Option<String>
1042                    port: 0u16,                                  // u16
1043                    scheme: "".to_string(),                      // String
1044                    path: "".to_string(),                        // String
1045                    query: None,                                 // Option<String>
1046                    fragment: None,                              // Option<String>
1047                    domain: None,                                // Option<String>
1048                    cafile: None,                                // Option<PathBuf>
1049                    run: false,                                  // bool
1050                };
1051                match Parts::try_from(&options) {
1052                    Ok(_) => Err(format!("unexpected successful conversion into Parts")),
1053                    Err(e)
1054                        if e.to_string()
1055                            == "Only HTTP(S) standard ports are suported. news given" =>
1056                    {
1057                        Ok(())
1058                    }
1059                    Err(e) => Err(format!(
1060                        "unexpected error creating Parts from a URL: {}",
1061                        e.to_string()
1062                    )),
1063                }
1064            }
1065
1066            fn validate_parts_and_url(parts: Parts, url: Option<String>) -> Result<(), String> {
1067                if let Some(url) = url {
1068                    if parts.url != Url::parse(&url).unwrap() {
1069                        return Err(format!(
1070                            "could not create Options with the correct URL, got: {}",
1071                            parts.url
1072                        ));
1073                    }
1074                }
1075                if parts.host != Host::parse("example.org").unwrap() {
1076                    return Err(format!(
1077                        "could not create Options with the correct Host, got: {}",
1078                        parts.host
1079                    ));
1080                }
1081                if parts.port != 443 {
1082                    return Err(format!(
1083                        "could not create Options with the correct port, got: {}",
1084                        parts.port
1085                    ));
1086                }
1087                if parts.path != "/news/today.html".to_string() {
1088                    return Err(format!(
1089                        "could not create Options with the correct URL path, got: {}",
1090                        parts.path
1091                    ));
1092                }
1093                if parts.query != "tag=top".to_string() {
1094                    return Err(format!(
1095                        "could not create Options with the correct query, got: {}",
1096                        parts.query
1097                    ));
1098                }
1099                if parts.fragment != "headline".to_string() {
1100                    return Err(format!(
1101                        "could not create Options with the correct fragment, got: {}",
1102                        parts.fragment
1103                    ));
1104                }
1105                if parts.domain != "example.org".to_string() {
1106                    return Err(format!(
1107                        "could not create Options with the correct domain, got: {}",
1108                        parts.domain
1109                    ));
1110                }
1111
1112                Ok(())
1113            }
1114
1115            #[test]
1116            fn test_parts_try_from_options_passes_with_existing_url() -> Result<(), String> {
1117                let url: String =
1118                    "https://example.org/news/today.html?tag=top#headline".to_string();
1119                let options: Options = Options {
1120                    url: Some(url.clone()), // Option<String>
1121                    clipboard: false,       // bool
1122                    host: None,             // Option<String>
1123                    port: 0u16,             // u16
1124                    scheme: "".to_string(), // String
1125                    path: "".to_string(),   // String
1126                    query: None,            // Option<String>
1127                    fragment: None,         // Option<String>
1128                    domain: None,           // Option<String>
1129                    cafile: None,           // Option<PathBuf>
1130                    run: false,             // bool
1131                };
1132                match Parts::try_from(&options) {
1133                    Ok(parts) => validate_parts_and_url(parts, options.url),
1134                    Err(e) => Err(format!("could not create Options: {}", e)),
1135                }
1136            }
1137
1138            #[test]
1139            fn test_parts_try_from_options_fails_with_http_url() -> Result<(), String> {
1140                let url: String = "http://example.org".to_string();
1141                let options: Options = Options {
1142                    url: Some(url.clone()), // Option<String>
1143                    clipboard: false,       // bool
1144                    host: None,             // Option<String>
1145                    port: 0u16,             // u16
1146                    scheme: "".to_string(), // String
1147                    path: "".to_string(),   // String
1148                    query: None,            // Option<String>
1149                    fragment: None,         // Option<String>
1150                    domain: None,           // Option<String>
1151                    cafile: None,           // Option<PathBuf>
1152                    run: false,             // bool
1153                };
1154                match Parts::try_from(&options) {
1155                    Ok(_) => Err(format!("unexpected successful conversion into Parts")),
1156                    Err(e) if e.to_string() == "HTTP standard ports is not yet suported" => Ok(()),
1157                    Err(e) => Err(format!(
1158                        "unexpected error creating Parts from a URL: {}",
1159                        e.to_string()
1160                    )),
1161                }
1162            }
1163
1164            #[test]
1165            fn test_parts_try_from_options_passes_ignoring_non_https_port_when_given()
1166            -> Result<(), String> {
1167                let url: String = "http://example.org".to_string();
1168                let options: Options = Options {
1169                    url: Some(url.clone()), // Option<String>
1170                    clipboard: false,       // bool
1171                    host: None,             // Option<String>
1172                    port: 8080u16,          // u16
1173                    scheme: "".to_string(), // String
1174                    path: "".to_string(),   // String
1175                    query: None,            // Option<String>
1176                    fragment: None,         // Option<String>
1177                    domain: None,           // Option<String>
1178                    cafile: None,           // Option<PathBuf>
1179                    run: false,             // bool
1180                };
1181                match Parts::try_from(&options) {
1182                    Ok(_) => Ok(()),
1183                    Err(e) => Err(format!(
1184                        "could not create Parts from a URL: {}",
1185                        e.to_string()
1186                    )),
1187                }
1188            }
1189
1190            #[test]
1191            fn test_parts_try_from_options_passes_with_another_domain() -> Result<(), String> {
1192                let url: String = "https://example.com".to_string();
1193                let options: Options = Options {
1194                    url: Some(url.clone()),                   // Option<String>
1195                    clipboard: false,                         // bool
1196                    host: None,                               // Option<String>
1197                    port: 0u16,                               // u16
1198                    scheme: "".to_string(),                   // String
1199                    path: "".to_string(),                     // String
1200                    query: None,                              // Option<String>
1201                    fragment: None,                           // Option<String>
1202                    domain: Some("unresolvable".to_string()), // Option<String>
1203                    cafile: None,                             // Option<PathBuf>
1204                    run: false,                               // bool
1205                };
1206                match Parts::try_from(&options) {
1207                    Ok(_) => Ok(()),
1208                    Err(e) => Err(format!(
1209                        "could not create Parts from a URL: {}",
1210                        e.to_string()
1211                    )),
1212                }
1213            }
1214
1215            #[test]
1216            fn test_parts_try_from_options_passes_with_existing_clipboard() -> Result<(), String> {
1217                let url: String =
1218                    "https://example.org/news/today.html?tag=top#headline".to_string();
1219                let clipboard = Clipboard::new().unwrap();
1220                let atom_clipboard = clipboard.setter.atoms.clipboard;
1221                let atom_utf8string = clipboard.setter.atoms.utf8_string;
1222                clipboard
1223                    .store(atom_clipboard, atom_utf8string, url.as_bytes())
1224                    .map_err(|e| format!("could not store a value in the clipboard: {}", e))?;
1225
1226                let options: Options = Options {
1227                    url: None,              // Option<String>
1228                    clipboard: true,        // bool
1229                    host: None,             // Option<String>
1230                    port: 0u16,             // u16
1231                    scheme: "".to_string(), // String
1232                    path: "".to_string(),   // String
1233                    query: None,            // Option<String>
1234                    fragment: None,         // Option<String>
1235                    domain: None,           // Option<String>
1236                    cafile: None,           // Option<PathBuf>
1237                    run: false,             // bool
1238                };
1239                match Parts::try_from(&options) {
1240                    Ok(parts) => validate_parts_and_url(parts, options.url),
1241                    Err(e) => Err(format!("could not create Options: {}", e)),
1242                }
1243            }
1244
1245            #[test]
1246            fn test_parts_try_from_options_passes_with_existing_host() -> Result<(), String> {
1247                let options: Options = Options {
1248                    url: None,                              // Option<String>
1249                    clipboard: false,                       // bool
1250                    host: Some("example.org".to_string()),  // Option<String>
1251                    port: 0u16,                             // u16
1252                    scheme: "https".to_string(),            // String
1253                    path: "/news/today.html".to_string(),   // String
1254                    query: Some("tag=top".to_string()),     // Option<String>
1255                    fragment: Some("headline".to_string()), // Option<String>
1256                    domain: None,                           // Option<String>
1257                    cafile: None,                           // Option<PathBuf>
1258                    run: false,                             // bool
1259                };
1260                match Parts::try_from(&options) {
1261                    Ok(parts) => validate_parts_and_url(parts, options.url),
1262                    Err(e) => Err(format!("could not create Options: {}", e)),
1263                }
1264            }
1265
1266            #[test]
1267            fn test_parts_try_from_str_passes_with_existing_url() -> Result<(), String> {
1268                let url: &str = "https://example.org/news/today.html?tag=top#headline";
1269                match Parts::try_from(url) {
1270                    Ok(parts) => validate_parts_and_url(parts, Some(url.to_string())),
1271                    Err(e) => Err(format!("could not create Options: {}", e)),
1272                }
1273            }
1274
1275            #[test]
1276            fn test_parts_try_from_str_fails_with_non_existing_url() -> Result<(), String> {
1277                let url: &str = "https://unresolvable.example.org/news/today.html?tag=top#headline";
1278                let parts: Result<Parts, Box<dyn Error>> = TryFrom::<&str>::try_from(url);
1279                match parts {
1280                    Ok(_) => Err(format!("unexpected successful conversion into Parts")),
1281                    Err(e)
1282                        if e.to_string()
1283                            == "failed to lookup address information: Name or service not known" =>
1284                    {
1285                        Ok(())
1286                    }
1287                    Err(e) => Err(format!(
1288                        "unexpected error creating Parts from a URL: {}",
1289                        e.to_string()
1290                    )),
1291                }
1292            }
1293
1294            #[test]
1295            fn test_parts_try_from_str_fails_with_non_supported_scheme() -> Result<(), String> {
1296                let url: &str = "news://example.org/news/today";
1297                let parts: Result<Parts, Box<dyn Error>> = TryFrom::<&str>::try_from(url);
1298                match parts {
1299                    Ok(_) => Err(format!("unexpected successful conversion into Parts")),
1300                    Err(e)
1301                        if e.to_string()
1302                            == "Only HTTP(S) standard ports are suported. news given" =>
1303                    {
1304                        Ok(())
1305                    }
1306                    Err(e) => Err(format!(
1307                        "unexpected error creating Parts from a URL: {}",
1308                        e.to_string()
1309                    )),
1310                }
1311            }
1312
1313            #[test]
1314            fn test_parts_try_from_url_passes_with_existing_url() -> Result<(), String> {
1315                let url: &str = "https://example.org/news/today.html?tag=top#headline";
1316                let url: Url = Url::parse(url).expect("cannot parse test URL");
1317                let parts: Result<Parts, Box<dyn Error>> = TryFrom::<&Url>::try_from(&url);
1318                match parts {
1319                    Ok(parts) => validate_parts_and_url(parts, Some(url.to_string())),
1320                    Err(e) => Err(format!("could not create Options: {}", e)),
1321                }
1322            }
1323
1324            #[test]
1325            fn test_parts_try_from_url_fails_with_non_existing_url() -> Result<(), String> {
1326                let url: &str = "https://unresolvable.example.org/news/today.html?tag=top#headline";
1327                let url: Url = Url::parse(url).expect("cannot parse test URL");
1328                let parts: Result<Parts, Box<dyn Error>> = TryFrom::<&Url>::try_from(&url);
1329                match parts {
1330                    Ok(_) => Err(format!("unexpected successful conversion into Parts")),
1331                    Err(e)
1332                        if e.to_string()
1333                            == "failed to lookup address information: Name or service not known" =>
1334                    {
1335                        Ok(())
1336                    }
1337                    Err(e) => Err(format!(
1338                        "unexpected error creating Parts from a URL: {}",
1339                        e.to_string()
1340                    )),
1341                }
1342            }
1343
1344            #[test]
1345            fn test_parts_try_from_url_fails_with_non_supported_scheme() -> Result<(), String> {
1346                let url: &str = "news://example.org/news/today";
1347                let url: Url = Url::parse(url).expect("cannot parse test URL");
1348                let parts: Result<Parts, Box<dyn Error>> = TryFrom::<&Url>::try_from(&url);
1349                match parts {
1350                    Ok(_) => Err(format!("unexpected successful conversion into Parts")),
1351                    Err(e)
1352                        if e.to_string()
1353                            == "Only HTTP(S) standard ports are suported. news given" =>
1354                    {
1355                        Ok(())
1356                    }
1357                    Err(e) => Err(format!(
1358                        "unexpected error creating Parts from a URL: {}",
1359                        e.to_string()
1360                    )),
1361                }
1362            }
1363        }
1364    }
1365
1366    #[test]
1367    fn test_clipboard_content_passes_trivially() -> Result<(), String> {
1368        // Note: this test reduces to testing an external dependency and it
1369        // doesn't have much raison d'être. This test would / will be more
1370        // meaningful when a direct implementation of the clipboard will
1371        // replace the dependency from x11_clipboard::Clipboard.
1372
1373        let expected = format!("{:?}", Instant::now());
1374        let clipboard = Clipboard::new().unwrap();
1375        let atom_clipboard = clipboard.setter.atoms.clipboard;
1376        let atom_utf8string = clipboard.setter.atoms.utf8_string;
1377        clipboard
1378            .store(atom_clipboard, atom_utf8string, expected.as_bytes())
1379            .map_err(|e| format!("could not store a value in the clipboard: {}", e))?;
1380        match clipboard_content()
1381            .map_err(|e| format!("could not get a value from the cliboard: {}", e))
1382        {
1383            Ok(content) if content == expected => Ok(()),
1384            Ok(content) => Err(format!("unexpected paste content: {}", content)),
1385            Err(e) => Err(format!("could not read the clipboard: {}", e)),
1386        }
1387    }
1388
1389    #[test]
1390    fn test_parts_http_request_passes_without_url_query() -> Result<(), String> {
1391        let parts: Parts = Parts::try_from("https://example.org/resource")
1392            .map_err(|e| format!("could not create a Parts structure from a str: {}", e))?;
1393        let expected: String = format!("GET /resource HTTP/1.0\r\nHost: example.org\r\n\r\n",);
1394        match parts.http_request() {
1395            req if req == expected => Ok(()),
1396            req => Err(format!("unexpected HTTP request: {}", req)),
1397        }
1398    }
1399
1400    #[test]
1401    fn test_parts_http_request_passes_with_url_query_and_fragment() -> Result<(), String> {
1402        let parts: Parts =
1403            Parts::try_from("https://example.org/resource.html?tag=news#headline")
1404                .map_err(|e| format!("could not create a Parts structure from a str: {}", e))?;
1405        let expected: String =
1406            format!("GET /resource.html?tag=news#headline HTTP/1.0\r\nHost: example.org\r\n\r\n",);
1407        match parts.http_request() {
1408            req if req == expected => Ok(()),
1409            req => Err(format!("unexpected HTTP request: {}", req)),
1410        }
1411    }
1412
1413    #[test]
1414    fn test_parts_http_request_passes_with_url_query() -> Result<(), String> {
1415        let parts: Parts = Parts::try_from("https://www.example.org/resource.html?tag=news")
1416            .map_err(|e| format!("could not create a Parts structure from a str: {}", e))?;
1417        let expected: String =
1418            format!("GET /resource.html?tag=news HTTP/1.0\r\nHost: www.example.org\r\n\r\n",);
1419        match parts.http_request() {
1420            req if req == expected => Ok(()),
1421            req => Err(format!("unexpected HTTP request: {}", req)),
1422        }
1423    }
1424
1425    #[test]
1426    fn test_parts_http_request_passes_with_url_fragment() -> Result<(), String> {
1427        let parts: Parts = Parts::try_from("https://www.example.org/resource.html#headline")
1428            .map_err(|e| format!("could not create a Parts structure from a str: {}", e))?;
1429        let expected: String =
1430            format!("GET /resource.html#headline HTTP/1.0\r\nHost: www.example.org\r\n\r\n",);
1431        match parts.http_request() {
1432            req if req == expected => Ok(()),
1433            req => Err(format!("unexpected HTTP request: {}", req)),
1434        }
1435    }
1436}