Skip to main content

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