use std::borrow::Cow;
use std::num::{ParseFloatError, ParseIntError};
use std::sync::Arc;
#[derive(Debug)]
struct InnerEngine {
client: reqwest::Client,
url: Cow<'static, str>,
}
impl Default for InnerEngine {
fn default() -> Self {
Self {
client: reqwest::Client::default(),
url: Cow::Borrowed("https://sunxdcc.com/deliver.php"),
}
}
}
#[derive(Debug, serde::Serialize)]
struct QueryParams<'a> {
sterm: &'a str,
page: u8,
}
#[derive(Clone, Debug, Default)]
pub struct Engine(Arc<InnerEngine>);
impl Engine {
pub async fn search(&self, query: &str, page: u8) -> reqwest::Result<Vec<Entry>> {
let res = self
.0
.client
.get(self.0.url.as_ref())
.query(&QueryParams { sterm: query, page })
.send()
.await?;
res.error_for_status_ref()?;
let body: Response = res.json().await?;
Ok(body.into())
}
}
#[derive(Debug, serde::Deserialize)]
struct Response {
botrec: Vec<String>,
network: Vec<String>,
bot: Vec<String>,
channel: Vec<String>,
packnum: Vec<String>,
gets: Vec<String>,
fsize: Vec<String>,
fname: Vec<String>,
}
impl Response {
fn into(self) -> Vec<Entry> {
self.fname
.into_iter()
.zip(self.fsize)
.zip(self.gets)
.zip(self.packnum)
.zip(self.channel)
.zip(self.network)
.zip(self.bot)
.zip(self.botrec)
.enumerate()
.filter_map(
|(
index,
(
((((((fname, fsize), downloads), packnum), channel), network), bot_name),
bot_speed,
),
)| {
Entry::try_decode(
fname, fsize, downloads, packnum, channel, network, bot_name, bot_speed,
)
.inspect_err(|err| {
tracing::debug!("unable to decode entry {index}: {err:?}");
})
.ok()
},
)
.collect::<Vec<_>>()
}
}
#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, serde::Deserialize, serde::Serialize)]
pub struct Entry {
pub filename: String,
pub filesize: u64,
pub downloads: u64,
pub packnum: u64,
pub channel: String,
pub network: String,
pub bot_name: String,
pub bot_speed: u64,
}
impl Entry {
#[allow(clippy::too_many_arguments)]
fn try_decode(
fname: String,
fsize: String,
downloads: String,
packnum: String,
channel: String,
network: String,
bot_name: String,
bot_speed: String,
) -> Result<Self, DecodingError> {
Ok(Self {
filename: fname,
filesize: decode_filesize(fsize)?,
downloads: decode_downloads(downloads)?,
packnum: decode_packnum(packnum)?,
channel,
network,
bot_name,
bot_speed: decode_speed(bot_speed)?,
})
}
}
#[derive(Clone, Debug, PartialEq, thiserror::Error)]
pub enum DecodingError {
#[error("invalid {field:?} format, expected {expected:?}, received {value:?}")]
InvalidFormat {
field: &'static str,
value: String,
expected: &'static str,
},
#[error("invalid number in field {field:?}, expected a float, received {value:?}")]
InvalidFloat {
field: &'static str,
value: String,
error: ParseFloatError,
},
#[error("invalid number in field {field:?}, expected a int, received {value:?}")]
InvalidInt {
field: &'static str,
value: String,
error: ParseIntError,
},
}
const FILESIZE_FIELD: &str = "filesize";
const FILESIZE_FORMAT: &str = "[1.1M]";
fn decode_filesize(value: String) -> Result<u64, DecodingError> {
let Some(stripped) = value
.as_str()
.strip_prefix("[")
.and_then(|v| v.strip_suffix("]"))
else {
return Err(DecodingError::InvalidFormat {
field: FILESIZE_FIELD,
value,
expected: FILESIZE_FORMAT,
});
};
let Some(last_char) = stripped.chars().last() else {
return Err(DecodingError::InvalidFormat {
field: FILESIZE_FIELD,
value,
expected: FILESIZE_FORMAT,
});
};
let factor = match last_char.to_ascii_lowercase() {
'k' => 1024.0,
'm' => 1024.0 * 1024.0,
'g' => 1024.0 * 1024.0 * 1024.0,
't' => 1024.0 * 1024.0 * 1024.0 * 1024.0,
'p' => 1024.0 * 1024.0 * 1024.0 * 1024.0 * 1024.0,
'0'..='9' => 1.0,
_ => {
return Err(DecodingError::InvalidFormat {
field: FILESIZE_FIELD,
value,
expected: FILESIZE_FORMAT,
});
}
};
let number = if last_char.is_numeric() {
stripped
} else {
&stripped[..stripped.len() - 1]
};
let number = number
.trim()
.parse::<f64>()
.map_err(|error| DecodingError::InvalidFloat {
field: FILESIZE_FIELD,
value,
error,
})?;
let number = (number * factor) as u64;
Ok(number)
}
const GETS_FIELD: &str = "gets";
const GETS_FORMAT: &str = "42x";
fn decode_downloads(value: String) -> Result<u64, DecodingError> {
let Some(stripped) = value.strip_suffix('x') else {
return Err(DecodingError::InvalidFormat {
field: GETS_FIELD,
value,
expected: GETS_FORMAT,
});
};
stripped
.parse::<u64>()
.map_err(|error| DecodingError::InvalidInt {
field: GETS_FIELD,
value,
error,
})
}
const SPEED_FIELD: &str = "botrec";
const SPEED_FORMAT: &str = "123.4kB/s";
fn decode_speed(value: String) -> Result<u64, DecodingError> {
let number_size = value
.chars()
.take_while(|c| c.is_numeric() || *c == '.')
.count();
let Some((number, unit)) = value.split_at_checked(number_size) else {
return Err(DecodingError::InvalidFormat {
field: SPEED_FIELD,
value,
expected: SPEED_FORMAT,
});
};
let factor = match unit {
"B/s" => 1.0,
"kB/s" => 1024.0,
"MB/s" => 1024.0 * 1024.0,
"GB/s" => 1024.0 * 1024.0 * 1024.0,
"TB/s" => 1024.0 * 1024.0 * 1024.0 * 1024.0,
"PB/s" => 1024.0 * 1024.0 * 1024.0 * 1024.0 * 1024.0,
_ => {
return Err(DecodingError::InvalidFormat {
field: SPEED_FIELD,
value,
expected: SPEED_FORMAT,
});
}
};
let number = number
.parse::<f64>()
.map_err(|error| DecodingError::InvalidFloat {
field: SPEED_FIELD,
value,
error,
})?;
Ok((number * factor) as u64)
}
const PACKNUM_FIELD: &str = "packnum";
const PACKNUM_FORMAT: &str = "#42";
fn decode_packnum(value: String) -> Result<u64, DecodingError> {
let Some(number) = value.strip_prefix("#") else {
return Err(DecodingError::InvalidFormat {
field: PACKNUM_FIELD,
value,
expected: PACKNUM_FORMAT,
});
};
number
.parse::<u64>()
.map_err(|error| DecodingError::InvalidInt {
field: PACKNUM_FIELD,
value,
error,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[tokio::test]
async fn should_search_for_ubuntu() {
let mut src = mockito::Server::new_async().await;
let engine = Engine(Arc::new(InnerEngine {
client: Default::default(),
url: Cow::Owned(format!("{}/deliver.php", src.url())),
}));
let mock = src
.mock("GET", "/deliver.php?sterm=ubuntu&page=0")
.expect(1)
.with_body(include_str!("../resources/ubuntu.json"))
.create_async()
.await;
let list = engine.search("ubuntu", 0).await.unwrap();
assert_eq!(list.len(), 38);
assert!(list[0].filename.contains("Ubuntu"));
assert_eq!(list[0].filesize, 1503238553);
mock.assert_async().await;
}
#[test_case::test_case("[ 112]", 112; "without letter")]
#[test_case::test_case("[ 1k]", 1024; "simple kilo with dot")]
#[test_case::test_case("[ 1M]", 1024 * 1024; "simple mega without dot")]
#[test_case::test_case("[1.2M]", 1258291; "simple mega with dot")]
#[test_case::test_case("[1.2G]", 1288490188; "simple giga with dot")]
#[test_case::test_case("[1.2T]", 1319413953331; "simple tera with dot")]
fn should_decode_filesize(input: &str, expected: u64) {
assert_eq!(decode_filesize(input.into()).unwrap(), expected);
}
#[test_case::test_case("[ 12R]"; "invalid factor")]
fn shouldnt_decode_filesize(input: &str) {
assert!(decode_filesize(input.into()).is_err());
}
#[test_case::test_case("0x", 0; "zero")]
#[test_case::test_case("42x", 42; "2 digits")]
fn should_decode_downloads(input: &str, expected: u64) {
assert_eq!(decode_downloads(input.into()).unwrap(), expected);
}
#[test_case::test_case("12B/s", 12; "B/s")]
#[test_case::test_case("114012.3kB/s", 116748595; "kB/s")]
fn should_decode_speed(input: &str, expected: u64) {
assert_eq!(decode_speed(input.into()).unwrap(), expected);
}
#[test_case::test_case("#1", 1; "single digit")]
#[test_case::test_case("#1234", 1234; "multiple digits")]
fn should_decode_packnum(input: &str, expected: u64) {
assert_eq!(decode_packnum(input.into()).unwrap(), expected);
}
}