sunxdcc 0.1.1

A small, unofficial wrapper for the SunXDCC search engine
Documentation
//! `sunxdcc` is a small, unofficial Rust wrapper for the SunXDCC search engine's HTTP API.

#![deny(rustdoc::broken_intra_doc_links)]
#![deny(missing_docs)]
#![allow(clippy::redundant_field_names)]
#![forbid(unsafe_code)]

use itertools::izip;
use serde::Deserialize;
use thiserror::Error;
use url::Url;

const BASE_URL: &str = "https://sunxdcc.com/deliver.php";

/// Represents the errors that can occur when retrieving search results.
#[derive(Debug, Error)]
pub enum Error {
    /// An request error occurred.
    #[error("request error")]
    Request(#[from] reqwest::Error),
    /// A response contains malformed results.
    #[error("malformed response: {0}")]
    Malformed(String),
}

/// Represents the raw results from a single search request's response.
#[derive(Deserialize)]
struct RawResult {
    network: Vec<String>,
    channel: Vec<String>,
    bot: Vec<String>,
    fsize: Vec<String>,
    fname: Vec<String>,
    packnum: Vec<String>,
    gets: Vec<String>,
    botrec: Vec<String>,
}

impl RawResult {
    /// Are the contents of this `RawResult` consistent?
    ///
    /// Internally, a `RawResult` is a bunch of adjacent lists, and is
    /// "consistent" if and only if all lists are the same length.
    fn is_consistent(&self) -> bool {
        self.network.len() == self.channel.len()
            && self.channel.len() == self.bot.len()
            && self.channel.len() == self.fsize.len()
            && self.channel.len() == self.fname.len()
            && self.channel.len() == self.packnum.len()
            && self.channel.len() == self.gets.len()
            && self.channel.len() == self.botrec.len()
    }

    /// Consume this `RawResult`, constructing into `results`.
    fn consume(self, results: &mut Vec<SearchResult>) -> Result<(), Error> {
        if !self.is_consistent() {
            return Err(Error::Malformed("mismatch in adjacent list sizes".into()));
        }

        // Each result is inserted in reverse order, so that we can `pop` them later.
        for (network, channel, bot, fsize, fname, packnum, gets, botrec) in izip!(
            self.network.into_iter().rev(),
            self.channel.into_iter().rev(),
            self.bot.into_iter().rev(),
            self.fsize.into_iter().rev(),
            self.fname.into_iter().rev(),
            self.packnum.into_iter().rev(),
            self.gets.into_iter().rev(),
            self.botrec.into_iter().rev(),
        ) {
            let botrec = match botrec.as_str() {
                "Na" => None,
                _ => Some(botrec),
            };

            results.push(SearchResult {
                network: network,
                channel: channel,
                bot: bot,
                filesize: fsize,
                filename: fname,
                packet_number: packnum,
                download_count: gets,
                upload_speed: botrec,
            })
        }

        Ok(())
    }
}

/// Represents a single result from SunXDCC's search API.
#[derive(Debug)]
pub struct SearchResult {
    /// The IRC network that this result's bot is on.
    ///
    /// This is typically the fully-qualified domain name (e.g. `irc.foo.net`) or
    /// IP address for the record.
    pub network: String,

    /// The IRC channel that this result's bot is on.
    ///
    /// This is typically formatted as `"#channelname"`.
    pub channel: String,

    /// The IRC bot's name.
    pub bot: String,

    /// The size of the file.
    ///
    /// This is typically formatted as `"[XXXS]"`, e.g. `"[123M]"` for 123MB.
    pub filesize: String,

    /// The filename.
    pub filename: String,

    /// The packet number for this result.
    ///
    /// This is typically formatted as `"#XXX"`, e.g. `"#123"` for packet number 123.
    pub packet_number: String,

    /// The file's download count.
    ///
    /// This is typically formatted as `"Xx"`, e.g `"5x"` for 5 downloads.
    pub download_count: String,

    /// The serving IRC bot's maximum upload speed, if known.
    ///
    /// This is typically formatted as `"XXXX.YYkB/s"`, e.g. `"1000.25kB/s"`.
    pub upload_speed: Option<String>,
}

/// A stateful iteration container for search results.
#[derive(Debug)]
pub struct SearchResults<'search> {
    /// The client to use for all requests.
    client: reqwest::blocking::Client,
    /// The search query.
    query: &'search str,
    /// The current result page.
    current_page: usize,
    /// The current list of results.
    current_results: Vec<SearchResult>,
}

impl<'search> SearchResults<'search> {
    fn new(query: &'search str) -> Self {
        // Each query returns a maximum number of 50 results, so reserve at least
        // that many elements in our `current_results` buffer.
        Self {
            client: reqwest::blocking::Client::new(),
            query: query,
            current_page: 0,
            current_results: Vec::with_capacity(50),
        }
    }

    /// Refresh our internal state, fetching more results from the API if available.
    ///
    /// This function doesn't check whether the current results have been fully consumed;
    /// callers must take care to fully consume all current results to avoid silently
    ///
    fn refresh(&mut self) -> Result<(), Error> {
        self.current_results.clear();

        // Unwrap safety: BASE_URL is a correct URL and our parameters cannot cause an error.
        #[allow(clippy::unwrap_used)]
        let url = Url::parse_with_params(
            BASE_URL,
            &[
                ("sterm", self.query),
                ("page", &self.current_page.to_string()),
            ],
        )
        .unwrap();

        self.client
            .get(url)
            .send()?
            .json::<RawResult>()?
            .consume(&mut self.current_results)?;

        self.current_page += 1;

        Ok(())
    }
}

impl Iterator for SearchResults<'_> {
    type Item = Result<SearchResult, Error>;

    fn next(&mut self) -> Option<Self::Item> {
        // If we're just starting or we've exhausted our results, refresh our state.
        if self.current_page == 0 || self.current_results.is_empty() {
            if let Err(e) = self.refresh() {
                return Some(Err(e));
            }
        }

        // NOTE: This produces results in the correct order, despite the `pop`.
        // See the implementation of RawResult::consume.
        Ok(self.current_results.pop()).transpose()
    }
}

/// Search SunXDCC for the given `query`.
///
/// The returned `SearchResults` is an [`Iterator`](Iterator) over individual
/// [`SearchResult`](SearchResult) items.
///
/// ```no_run
/// # use sunxdcc;
/// for result in sunxdcc::search("the hitchhiker's guide to the galaxy") {
///     println!("{:?}", result.unwrap());
/// }
/// ```
pub fn search(query: &str) -> SearchResults {
    SearchResults::new(query)
}