Skip to main content

isoloom_spec/
load.rs

1//! Reading a spec: from its text, or from its project folder (the spec file found and read,
2//! merged with a profile and `-s` overrides, then parsed).
3
4use std::path::{Path, PathBuf};
5
6use crate::error::{Error, Result};
7use crate::{defaults, profile, syntax};
8
9/// The spec file names, at the root of a project: `isoloom.yml` (canonical) or `isoloom.yaml`.
10pub const SPEC_FILES: &[&str] = &[SPEC_FILE, "isoloom.yaml"];
11
12/// The spec file's usual name, the one a spec read from text is reported under.
13pub const SPEC_FILE: &str = "isoloom.yml";
14
15/// Reads a spec from YAML text, written in the format authors write ([`syntax`]).
16///
17/// # Errors
18///
19/// [`Error::Invalid`] with every problem found, coded and placed (`file` names the spec file
20/// in their first line).
21pub fn parse(yaml: &str) -> Result<syntax::Spec> {
22    parse_file(yaml, SPEC_FILE)
23}
24
25/// [`parse`], the problems naming the spec file `file`.
26fn parse_file(yaml: &str, file: &str) -> Result<syntax::Spec> {
27    syntax::parse(yaml).map_err(|problems| Error::Invalid {
28        file: file.to_owned(),
29        problems,
30    })
31}
32
33/// The spec file in `dir`: `isoloom.yml` or `isoloom.yaml`, never both.
34///
35/// # Errors
36///
37/// [`Error::NotFound`] when `dir` has neither, [`Error::Ambiguous`] when it has both.
38pub fn find(dir: &Path) -> Result<PathBuf> {
39    let found: Vec<PathBuf> = SPEC_FILES.iter().map(|f| dir.join(f)).filter(|p| p.is_file()).collect();
40    match found.as_slice() {
41        [one] => Ok(one.clone()),
42        [] => Err(Error::NotFound(dir.to_path_buf())),
43        _ => Err(Error::Ambiguous(dir.to_path_buf())),
44    }
45}
46
47/// Reads the spec in `dir` (see [`find`]).
48///
49/// # Errors
50///
51/// [`Error`]: no spec file (or two), one that can't be read, or its problems.
52pub fn load(dir: &Path) -> Result<syntax::Spec> {
53    load_with(dir, &[])
54}
55
56/// Reads the spec in `dir` with `-s key=value` overrides applied first (see
57/// [`defaults::override_spec`]); keys prefixed `defaults.` are left to the defaults.
58///
59/// # Errors
60///
61/// [`Error`], as [`load`], or an override that doesn't apply.
62pub fn load_with(dir: &Path, sets: &[String]) -> Result<syntax::Spec> {
63    load_profiled(dir, None, sets)
64}
65
66/// Reads the spec in `dir` with a profile's `spec` merged in (checked against the spec first:
67/// see [`profile::Profile::check`]), then `-s key=value` overrides.
68///
69/// # Errors
70///
71/// [`Error`], as [`load_with`], or a profile that names what the spec doesn't have.
72pub fn load_profiled(dir: &Path, profile: Option<&profile::Profile>, sets: &[String]) -> Result<syntax::Spec> {
73    let path = find(dir)?;
74    let file = path.file_name().map_or_else(|| SPEC_FILE.to_owned(), |f| f.to_string_lossy().into_owned());
75    let text = std::fs::read_to_string(&path).map_err(|source| Error::Read { path, source })?;
76    let text = match profile {
77        Some(p) => {
78            let doc: serde_yaml_ng::Value = serde_yaml_ng::from_str(&text)?;
79            let problems = p.check(&doc);
80            if !problems.is_empty() {
81                return Err(Error::ProfileMismatch { problems });
82            }
83            defaults::override_spec_with(&text, Some(&p.spec), sets)
84        }
85        None => defaults::override_spec(&text, sets),
86    }?;
87    parse_file(&text, &file)
88}
89
90#[cfg(test)]
91mod tests {
92    use std::path::PathBuf;
93
94    use super::*;
95
96    const SPEC: &str = "version: 1\nname: shop\nnetworks: { lab: { cidr: 10.1.0.0/24 } }\nmachines:\n  web: { os: debian@12, networks: { lab: 10 } }\n";
97
98    /// A fresh folder for test `name`, holding `files` (name, text).
99    fn folder(name: &str, files: &[(&str, &str)]) -> PathBuf {
100        let dir = crate::testing::folder(&format!("load-{name}")).unwrap();
101        for (file, text) in files {
102            std::fs::write(dir.join(file), text).unwrap();
103        }
104        dir
105    }
106
107    #[test]
108    fn text_reads_into_the_spec_or_its_problems_under_the_spec_files_name() {
109        assert_eq!(parse(SPEC).unwrap().name, "shop");
110        match parse("version: 1\nname: shop\nmachines:\n  web: { docker: { image: x } }\n") {
111            Err(Error::Invalid { file, problems }) => assert_eq!((file.as_str(), problems.len()), ("isoloom.yml", 1)),
112            other => panic!("{other:?}"),
113        }
114    }
115
116    #[test]
117    fn a_folder_has_one_spec_file() {
118        let none = folder("none", &[]);
119        assert!(matches!(find(&none), Err(Error::NotFound(d)) if d == none));
120        let yaml = folder("yaml", &[("isoloom.yaml", SPEC)]);
121        assert_eq!(find(&yaml).unwrap(), yaml.join("isoloom.yaml"));
122        let both = folder("both", &[("isoloom.yml", SPEC), ("isoloom.yaml", SPEC)]);
123        assert!(matches!(find(&both), Err(Error::Ambiguous(d)) if d == both));
124    }
125
126    #[test]
127    fn a_folder_reads_into_its_spec_and_overrides_apply_first() {
128        let dir = folder("overrides", &[("isoloom.yml", SPEC)]);
129        assert_eq!(load(&dir).unwrap().machines["web"].os.map(|os| os.to_string()), Some("debian@12".to_owned()));
130        let set = load_with(&dir, &["machines.web.os=alpine".to_owned(), "defaults.cloud.aws.region=x".to_owned()]).unwrap();
131        assert_eq!(set.machines["web"].os.map(|os| os.to_string()), Some("alpine@3.19".to_owned()));
132        assert!(matches!(load_with(&dir, &["nonsense".to_owned()]), Err(Error::SetShape { .. })));
133    }
134
135    #[test]
136    fn problems_name_the_file_they_are_in() {
137        let dir = folder("named", &[("isoloom.yaml", "version: 1\nname: shop\nmachines:\n  web: { vm: {} }\n")]);
138        match load(&dir) {
139            Err(Error::Invalid { file, .. }) => assert_eq!(file, "isoloom.yaml"),
140            other => panic!("{other:?}"),
141        }
142    }
143}