1use std::path::{Path, PathBuf};
5
6use crate::error::{Error, Result};
7use crate::{defaults, profile, syntax};
8
9pub const SPEC_FILES: &[&str] = &[SPEC_FILE, "isoloom.yaml"];
11
12pub const SPEC_FILE: &str = "isoloom.yml";
14
15pub fn parse(yaml: &str) -> Result<syntax::Spec> {
22 parse_file(yaml, SPEC_FILE)
23}
24
25fn 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
33pub 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
47pub fn load(dir: &Path) -> Result<syntax::Spec> {
53 load_with(dir, &[])
54}
55
56pub fn load_with(dir: &Path, sets: &[String]) -> Result<syntax::Spec> {
63 load_profiled(dir, None, sets)
64}
65
66pub 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 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}