1use crate::ModuleManifest;
4use alloc::{string::String, vec::Vec};
5use thiserror::Error;
6
7pub const INDEX_URL: &str =
9 "https://raw.githubusercontent.com/asimov-modules/asimov-modules/master/index.jsonl";
10
11#[derive(Clone, Debug, Default)]
13pub struct Index {
14 modules: Vec<ModuleManifest>,
15}
16
17impl Index {
18 pub async fn fetch() -> Result<Self, FetchIndexError> {
20 let client = reqwest::Client::builder()
21 .user_agent("asimov-module-registry")
22 .connect_timeout(std::time::Duration::from_secs(10))
23 .read_timeout(std::time::Duration::from_secs(30))
24 .build()
25 .expect("Failed to build HTTP client");
26
27 Self::fetch_from(&client, INDEX_URL).await
28 }
29
30 pub async fn fetch_from(
31 client: &reqwest::Client,
32 url: impl AsRef<str>,
33 ) -> Result<Self, FetchIndexError> {
34 let response = client
35 .get(url.as_ref())
36 .send()
37 .await
38 .inspect_err(|err| tracing::debug!(?err))?;
39
40 if !response.status().is_success() {
41 Err(HttpError::NotSuccess(response.status()))?;
42 }
43
44 let content = response
45 .text()
46 .await
47 .inspect_err(|err| tracing::debug!(?err))?;
48
49 Ok(content.parse()?)
50 }
51
52 pub fn modules(&self) -> &[ModuleManifest] {
53 &self.modules
54 }
55
56 pub fn search(&self, query: impl AsRef<str>) -> impl Iterator<Item = &ModuleManifest> {
65 let terms: Vec<String> = query
66 .as_ref()
67 .split_whitespace()
68 .map(|term| term.to_lowercase())
69 .collect();
70
71 self.modules.iter().filter(move |module| {
72 if terms.is_empty() {
73 return true;
74 }
75
76 let haystack = searchable_text(module);
77 terms.iter().all(|term| haystack.contains(term.as_str()))
78 })
79 }
80}
81
82impl core::str::FromStr for Index {
83 type Err = ParseIndexError;
84
85 fn from_str(input: &str) -> Result<Self, Self::Err> {
86 let modules = input
88 .lines()
89 .enumerate()
90 .filter(|(_, line)| !line.trim().is_empty())
91 .map(|(line_index, line)| {
92 serde_json::from_str::<ModuleManifest>(line)
93 .inspect_err(|err| tracing::debug!(?err, ?line))
94 .map_err(|err| ParseIndexError(line_index + 1, err))
95 })
96 .collect::<Result<Vec<_>, _>>()?;
97
98 Ok(Self { modules })
99 }
100}
101
102fn searchable_text(module: &ModuleManifest) -> String {
104 let handles = &module.handles;
105
106 let fields = [
107 Some(module.name.as_str()),
108 module.label.as_deref(),
109 module.title.as_deref(),
110 module.summary.as_deref(),
111 ];
112
113 let lists = [
114 &module.links,
115 &module.provides.programs,
116 &handles.url_protocols,
117 &handles.url_prefixes,
118 &handles.url_patterns,
119 &handles.file_extensions,
120 &handles.content_types,
121 ];
122
123 let mut text = String::new();
124 for field in fields.into_iter().flatten() {
125 text.push_str(field);
126 text.push('\n');
127 }
128 for item in lists.into_iter().flatten() {
129 text.push_str(item);
130 text.push('\n');
131 }
132
133 text.to_lowercase()
134}
135
136#[derive(Debug, Error)]
137pub enum FetchIndexError {
138 #[error(transparent)]
139 Http(#[from] HttpError),
140 #[error(transparent)]
141 Parse(#[from] ParseIndexError),
142}
143
144impl From<reqwest::Error> for FetchIndexError {
145 fn from(value: reqwest::Error) -> Self {
146 FetchIndexError::Http(HttpError::Http(value))
147 }
148}
149
150#[derive(Debug, Error)]
151pub enum HttpError {
152 #[error("HTTP request failed: {0}")]
153 Http(#[from] reqwest::Error),
154 #[error("HTTP status code was not successful: {0}")]
155 NotSuccess(reqwest::StatusCode),
156}
157
158#[derive(Debug, Error)]
159#[error("failed to deserialize module index on line {0}: {1}")]
160pub struct ParseIndexError(pub usize, #[source] pub serde_json::Error);