Skip to main content

asimov_module/
index.rs

1// This is free and unencumbered software released into the public domain.
2
3use crate::ModuleManifest;
4use alloc::{string::String, vec::Vec};
5use thiserror::Error;
6
7/// The URL of the public index of ASIMOV modules, in JSONL format.
8pub const INDEX_URL: &str =
9    "https://raw.githubusercontent.com/asimov-modules/asimov-modules/master/index.jsonl";
10
11/// A snapshot of the index of publicly available ASIMOV modules.
12#[derive(Clone, Debug, Default)]
13pub struct Index {
14    modules: Vec<ModuleManifest>,
15}
16
17impl Index {
18    /// Fetches the index from [`INDEX_URL`].
19    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    /// Searches the index for modules matching the given query.
57    ///
58    /// The query is split into whitespace-separated terms, and a module matches
59    /// when every term occurs, case-insensitively, as a substring of any of the
60    /// module's name, label, title, summary, links, provided programs, or
61    /// handled inputs. An empty query matches every module.
62    ///
63    /// The matching modules are returned in index order.
64    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        // parse an index in JSONL format, one module manifest per line
87        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
102/// The searchable fields of a module manifest, lowercased and newline-separated.
103fn 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);