use crate::ModuleManifest;
use alloc::{string::String, vec::Vec};
use thiserror::Error;
pub const INDEX_URL: &str =
"https://raw.githubusercontent.com/asimov-modules/asimov-modules/master/index.jsonl";
#[derive(Clone, Debug, Default)]
pub struct Index {
modules: Vec<ModuleManifest>,
}
impl Index {
pub async fn fetch() -> Result<Self, FetchIndexError> {
let client = reqwest::Client::builder()
.user_agent("asimov-module-registry")
.connect_timeout(std::time::Duration::from_secs(10))
.read_timeout(std::time::Duration::from_secs(30))
.build()
.expect("Failed to build HTTP client");
Self::fetch_from(&client, INDEX_URL).await
}
pub async fn fetch_from(
client: &reqwest::Client,
url: impl AsRef<str>,
) -> Result<Self, FetchIndexError> {
let response = client
.get(url.as_ref())
.send()
.await
.inspect_err(|err| tracing::debug!(?err))?;
if !response.status().is_success() {
Err(HttpError::NotSuccess(response.status()))?;
}
let content = response
.text()
.await
.inspect_err(|err| tracing::debug!(?err))?;
Ok(content.parse()?)
}
pub fn modules(&self) -> &[ModuleManifest] {
&self.modules
}
pub fn search(&self, query: impl AsRef<str>) -> impl Iterator<Item = &ModuleManifest> {
let terms: Vec<String> = query
.as_ref()
.split_whitespace()
.map(|term| term.to_lowercase())
.collect();
self.modules.iter().filter(move |module| {
if terms.is_empty() {
return true;
}
let haystack = searchable_text(module);
terms.iter().all(|term| haystack.contains(term.as_str()))
})
}
}
impl core::str::FromStr for Index {
type Err = ParseIndexError;
fn from_str(input: &str) -> Result<Self, Self::Err> {
let modules = input
.lines()
.enumerate()
.filter(|(_, line)| !line.trim().is_empty())
.map(|(line_index, line)| {
serde_json::from_str::<ModuleManifest>(line)
.inspect_err(|err| tracing::debug!(?err, ?line))
.map_err(|err| ParseIndexError(line_index + 1, err))
})
.collect::<Result<Vec<_>, _>>()?;
Ok(Self { modules })
}
}
fn searchable_text(module: &ModuleManifest) -> String {
let handles = &module.handles;
let fields = [
Some(module.name.as_str()),
module.label.as_deref(),
module.title.as_deref(),
module.summary.as_deref(),
];
let lists = [
&module.links,
&module.provides.programs,
&handles.url_protocols,
&handles.url_prefixes,
&handles.url_patterns,
&handles.file_extensions,
&handles.content_types,
];
let mut text = String::new();
for field in fields.into_iter().flatten() {
text.push_str(field);
text.push('\n');
}
for item in lists.into_iter().flatten() {
text.push_str(item);
text.push('\n');
}
text.to_lowercase()
}
#[derive(Debug, Error)]
pub enum FetchIndexError {
#[error(transparent)]
Http(#[from] HttpError),
#[error(transparent)]
Parse(#[from] ParseIndexError),
}
impl From<reqwest::Error> for FetchIndexError {
fn from(value: reqwest::Error) -> Self {
FetchIndexError::Http(HttpError::Http(value))
}
}
#[derive(Debug, Error)]
pub enum HttpError {
#[error("HTTP request failed: {0}")]
Http(#[from] reqwest::Error),
#[error("HTTP status code was not successful: {0}")]
NotSuccess(reqwest::StatusCode),
}
#[derive(Debug, Error)]
#[error("failed to deserialize module index on line {0}: {1}")]
pub struct ParseIndexError(pub usize, #[source] pub serde_json::Error);