asimov_module/models/
module_manifest.rs

1// This is free and unencumbered software released into the public domain.
2
3use alloc::{collections::BTreeMap, string::String, vec::Vec};
4
5#[derive(Clone, Debug, Default, PartialEq, Eq)]
6#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
7pub struct ModuleManifest {
8    pub name: String,
9    pub label: String,
10    pub summary: String,
11    pub links: Vec<String>,
12
13    #[cfg_attr(
14        feature = "serde",
15        serde(default, skip_serializing_if = "Provides::is_empty")
16    )]
17    pub provides: Provides,
18
19    #[cfg_attr(
20        feature = "serde",
21        serde(default, skip_serializing_if = "Handles::is_empty")
22    )]
23    pub handles: Handles,
24
25    #[cfg_attr(
26        feature = "serde",
27        serde(alias = "configuration", skip_serializing_if = "Option::is_none")
28    )]
29    pub config: Option<Configuration>,
30
31    #[cfg_attr(feature = "serde", serde(skip_serializing_if = "Option::is_none"))]
32    pub requires: Option<Requires>,
33}
34
35#[cfg(feature = "std")]
36#[derive(Debug, thiserror::Error)]
37pub enum ReadVarError {
38    #[error("variable named `{0}` not found in module manifest")]
39    UnknownVar(String),
40
41    #[error("a value for variable `{0}` was not configured")]
42    UnconfiguredVar(String),
43
44    #[error("failed to read variable `{name}`: {source}")]
45    Io {
46        name: String,
47        #[source]
48        source: std::io::Error,
49    },
50}
51
52impl ModuleManifest {
53    #[cfg(all(feature = "std", feature = "serde"))]
54    pub fn read_manifest(module_name: &str) -> std::io::Result<Self> {
55        let directory = asimov_env::paths::asimov_root().join("modules");
56        let search_paths = [
57            ("installed", "json"),
58            ("installed", "yaml"), // legacy, new installs are converted to JSON
59            ("", "yaml"),          // legacy, new installs go to `installed/`
60        ];
61
62        for (sub_dir, ext) in search_paths {
63            let file = std::path::PathBuf::from(sub_dir)
64                .join(module_name)
65                .with_extension(ext);
66
67            match std::fs::read(directory.join(&file)) {
68                Ok(content) if ext == "json" => {
69                    return serde_json::from_slice(&content).map_err(std::io::Error::other);
70                },
71                Ok(content) if ext == "yaml" => {
72                    return serde_yaml_ng::from_slice(&content).map_err(std::io::Error::other);
73                },
74                Ok(_) => unreachable!(),
75
76                Err(err) if err.kind() == std::io::ErrorKind::NotFound => continue,
77                Err(err) => return Err(err),
78            }
79        }
80
81        Err(std::io::ErrorKind::NotFound.into())
82    }
83
84    #[cfg(feature = "std")]
85    pub fn read_variables(
86        &self,
87        profile: Option<&str>,
88    ) -> Result<std::collections::BTreeMap<String, String>, ReadVarError> {
89        self.config
90            .as_ref()
91            .map(|c| c.variables.as_slice())
92            .unwrap_or_default()
93            .iter()
94            .map(|var| Ok((var.name.clone(), self.variable(&var.name, profile)?)))
95            .collect()
96    }
97
98    #[cfg(feature = "std")]
99    pub fn variable(&self, key: &str, profile: Option<&str>) -> Result<String, ReadVarError> {
100        let Some(var) = self
101            .config
102            .as_ref()
103            .and_then(|conf| conf.variables.iter().find(|var| var.name == key))
104        else {
105            return Err(ReadVarError::UnknownVar(key.into()));
106        };
107
108        if let Some(value) = var
109            .environment
110            .as_deref()
111            .and_then(|env_name| std::env::var(env_name).ok())
112        {
113            return Ok(value);
114        }
115
116        let profile = profile.unwrap_or("default");
117        let path = asimov_env::paths::asimov_root()
118            .join("configs")
119            .join(profile)
120            .join(&self.name)
121            .join(key);
122
123        std::fs::read_to_string(&path).or_else(|err| {
124            if err.kind() == std::io::ErrorKind::NotFound {
125                var.default_value
126                    .clone()
127                    .ok_or_else(|| ReadVarError::UnconfiguredVar(key.into()))
128            } else {
129                Err(ReadVarError::Io {
130                    name: key.into(),
131                    source: err,
132                })
133            }
134        })
135    }
136}
137
138#[derive(Clone, Debug, Default, PartialEq, Eq)]
139#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
140pub struct Provides {
141    pub programs: Vec<String>,
142}
143
144impl Provides {
145    pub fn is_empty(&self) -> bool {
146        self.programs.is_empty()
147    }
148}
149
150#[cfg(feature = "serde")]
151fn empty_vec_if_null<'de, D, T>(deserializer: D) -> Result<Vec<T>, D::Error>
152where
153    D: serde::Deserializer<'de>,
154    T: serde::Deserialize<'de>,
155{
156    use serde::Deserialize;
157    Ok(Option::<Vec<T>>::deserialize(deserializer)?.unwrap_or_default())
158}
159
160#[derive(Clone, Debug, Default, PartialEq, Eq)]
161#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
162pub struct Handles {
163    #[cfg_attr(
164        feature = "serde",
165        serde(
166            default,
167            deserialize_with = "empty_vec_if_null",
168            skip_serializing_if = "Vec::is_empty"
169        )
170    )]
171    pub url_protocols: Vec<String>,
172
173    #[cfg_attr(
174        feature = "serde",
175        serde(
176            default,
177            deserialize_with = "empty_vec_if_null",
178            skip_serializing_if = "Vec::is_empty"
179        )
180    )]
181    pub url_prefixes: Vec<String>,
182
183    #[cfg_attr(
184        feature = "serde",
185        serde(
186            default,
187            deserialize_with = "empty_vec_if_null",
188            skip_serializing_if = "Vec::is_empty"
189        )
190    )]
191    pub url_patterns: Vec<String>,
192
193    #[cfg_attr(
194        feature = "serde",
195        serde(
196            default,
197            deserialize_with = "empty_vec_if_null",
198            skip_serializing_if = "Vec::is_empty"
199        )
200    )]
201    pub file_extensions: Vec<String>,
202
203    #[cfg_attr(
204        feature = "serde",
205        serde(
206            default,
207            deserialize_with = "empty_vec_if_null",
208            skip_serializing_if = "Vec::is_empty"
209        )
210    )]
211    pub content_types: Vec<String>,
212}
213
214impl Handles {
215    pub fn is_empty(&self) -> bool {
216        self.url_protocols.is_empty()
217            && self.url_prefixes.is_empty()
218            && self.url_patterns.is_empty()
219            && self.file_extensions.is_empty()
220            && self.content_types.is_empty()
221    }
222}
223
224#[derive(Clone, Debug, Default, PartialEq, Eq)]
225#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
226pub struct Configuration {
227    #[cfg_attr(
228        feature = "serde",
229        serde(default, skip_serializing_if = "Vec::is_empty")
230    )]
231    pub variables: Vec<ConfigurationVariable>,
232}
233
234#[derive(Clone, Debug, Default, PartialEq, Eq)]
235#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
236pub struct ConfigurationVariable {
237    /// The name of the variable. Configured variables are by default saved in
238    /// `~/.asimov/configs/$profile/$module/$name`.
239    pub name: String,
240
241    /// Optional description to provide information about the variable.
242    #[cfg_attr(
243        feature = "serde",
244        serde(default, alias = "desc", skip_serializing_if = "Option::is_none")
245    )]
246    pub description: Option<String>,
247
248    /// Optional name of an environment variable to check for a value before checking for a
249    /// configured or a default value.
250    #[cfg_attr(
251        feature = "serde",
252        serde(default, alias = "env", skip_serializing_if = "Option::is_none")
253    )]
254    pub environment: Option<String>,
255
256    /// Optional default value to use as a fallback. If a default value is present the user
257    /// configuration of the value is not required.
258    #[cfg_attr(
259        feature = "serde",
260        serde(default, alias = "default", skip_serializing_if = "Option::is_none")
261    )]
262    pub default_value: Option<String>,
263}
264
265#[derive(Clone, Debug, Default, PartialEq, Eq)]
266#[cfg_attr(feature = "serde", derive(serde::Deserialize, serde::Serialize))]
267pub struct Requires {
268    /// List of modules that this module depends on.
269    #[cfg_attr(
270        feature = "serde",
271        serde(
272            default,
273            deserialize_with = "empty_vec_if_null",
274            skip_serializing_if = "Vec::is_empty"
275        )
276    )]
277    pub modules: Vec<String>,
278
279    #[cfg_attr(
280        feature = "serde",
281        serde(default, skip_serializing_if = "BTreeMap::is_empty")
282    )]
283    pub models: BTreeMap<String, RequiredModel>,
284}
285
286#[derive(Clone, Debug, PartialEq, Eq)]
287#[cfg_attr(
288    feature = "serde",
289    derive(serde::Deserialize, serde::Serialize),
290    serde(untagged)
291)]
292pub enum RequiredModel {
293    /// Just a direct URL string:
294    /// ```yaml
295    /// hf:first/model: model_file.bin
296    /// ```
297    Url(String),
298
299    /// Multiple variants:
300    /// ```yaml
301    /// hf:second/model:
302    ///   small: model_small.bin
303    ///   medium: model_medium.bin
304    ///   large: model_large.bin
305    /// ```
306    #[cfg_attr(
307        feature = "serde",
308        serde(deserialize_with = "ordered::deserialize_ordered")
309    )]
310    Choices(Vec<(String, String)>),
311}
312
313#[cfg(feature = "serde")]
314mod ordered {
315    use super::*;
316    use serde::{
317        Deserializer,
318        de::{MapAccess, Visitor},
319    };
320    use std::fmt;
321
322    pub fn deserialize_ordered<'de, D>(deserializer: D) -> Result<Vec<(String, String)>, D::Error>
323    where
324        D: Deserializer<'de>,
325    {
326        struct OrderedVisitor;
327
328        impl<'de> Visitor<'de> for OrderedVisitor {
329            type Value = Vec<(String, String)>;
330
331            fn expecting(&self, f: &mut fmt::Formatter) -> fmt::Result {
332                f.write_str("a map of string keys to string values (preserving order)")
333            }
334
335            fn visit_map<A>(self, mut access: A) -> Result<Self::Value, A::Error>
336            where
337                A: MapAccess<'de>,
338            {
339                let mut items = Vec::with_capacity(access.size_hint().unwrap_or(0));
340                while let Some((k, v)) = access.next_entry::<String, String>()? {
341                    items.push((k, v));
342                }
343                Ok(items)
344            }
345        }
346
347        deserializer.deserialize_map(OrderedVisitor)
348    }
349}
350
351#[cfg(test)]
352mod tests {
353    use super::*;
354    use std::vec;
355
356    #[test]
357    fn test_deser() {
358        let yaml = r#"
359name: example
360label: Example
361summary: Example Module
362links:
363  - https://github.com/asimov-platform/asimov.rs/tree/master/lib/asimov-module
364
365provides:
366  programs:
367    - asimov-example-module
368
369handles:
370  content_types:
371    - content_type
372  file_extensions:
373    - file_extension
374  url_patterns:
375    - pattern
376  url_prefixes:
377    - prefix
378  url_protocols:
379    - protocol
380
381config:
382  variables:
383    - name: api_key
384      description: "api key to authorize requests"
385      default_value: "foobar"
386      environment: API_KEY
387
388requires:
389  modules:
390    - other
391  models:
392    hf:first/model: first_url
393    hf:second/model:
394      small: small_url
395      medium: medium_url
396      large: large_url
397"#;
398
399        let dec: ModuleManifest = serde_yaml_ng::from_str(yaml).expect("deser should succeed");
400
401        assert_eq!("example", dec.name);
402        assert_eq!("Example", dec.label);
403        assert_eq!("Example Module", dec.summary);
404
405        assert_eq!(
406            vec!["https://github.com/asimov-platform/asimov.rs/tree/master/lib/asimov-module"],
407            dec.links
408        );
409
410        assert_eq!(1, dec.provides.programs.len());
411        assert_eq!(
412            "asimov-example-module",
413            dec.provides.programs.first().unwrap()
414        );
415
416        assert_eq!(
417            "content_type",
418            dec.handles
419                .content_types
420                .first()
421                .expect("should have content_types")
422        );
423
424        assert_eq!(
425            "file_extension",
426            dec.handles
427                .file_extensions
428                .first()
429                .expect("should have file_extensions")
430        );
431
432        assert_eq!(
433            "pattern",
434            dec.handles
435                .url_patterns
436                .first()
437                .expect("should have url_patterns")
438        );
439
440        assert_eq!(
441            "prefix",
442            dec.handles
443                .url_prefixes
444                .first()
445                .expect("should have url_prefixes")
446        );
447
448        assert_eq!(
449            "protocol",
450            dec.handles
451                .url_protocols
452                .first()
453                .expect("should have url_protocols")
454        );
455
456        assert_eq!(
457            Some(&ConfigurationVariable {
458                name: "api_key".into(),
459                description: Some("api key to authorize requests".into()),
460                environment: Some("API_KEY".into()),
461                default_value: Some("foobar".into())
462            }),
463            dec.config.expect("should have config").variables.first()
464        );
465
466        let requires = dec.requires.expect("should have requires");
467
468        assert_eq!(1, requires.modules.len());
469        assert_eq!("other", requires.modules.first().unwrap());
470
471        assert_eq!(2, requires.models.len());
472
473        assert_eq!(
474            RequiredModel::Url("first_url".into()),
475            requires.models["hf:first/model"]
476        );
477
478        assert_eq!(
479            RequiredModel::Choices(vec![
480                ("small".into(), "small_url".into()),
481                ("medium".into(), "medium_url".into()),
482                ("large".into(), "large_url".into())
483            ]),
484            requires.models["hf:second/model"]
485        );
486    }
487}