1use std::fmt;
29use std::path::{Path, PathBuf};
30
31use indexmap::IndexMap;
32use serde::Deserialize;
33use serde_yaml_ng::{Mapping, Value};
34
35use crate::defaults::Layer;
36use crate::model::Target;
37use crate::{Error, Result};
38
39pub const DIR: &str = "profiles";
41
42#[derive(Debug, Clone, PartialEq)]
44pub struct Profile {
45 pub name: String,
47 pub path: PathBuf,
49 pub description: Option<String>,
51 pub target: Option<Target>,
53 pub spec: Value,
55 pub defaults: Value,
57 pub inputs: IndexMap<String, String>,
59}
60
61#[derive(Deserialize)]
62#[serde(deny_unknown_fields)]
63struct File {
64 #[serde(default)]
65 description: Option<String>,
66 #[serde(default)]
67 target: Option<Target>,
68 #[serde(default)]
69 spec: Option<Value>,
70 #[serde(default)]
71 defaults: Option<Value>,
72 #[serde(default)]
73 inputs: IndexMap<String, Value>,
74}
75
76#[must_use]
78pub fn valid_name(name: &str) -> bool {
79 !name.is_empty() && !name.starts_with('-') && !name.ends_with('-') && name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
80}
81
82pub fn path(project: &Path, name: &str) -> Result<PathBuf> {
89 if !valid_name(name) {
90 return Err(Error::ProfileName(name.to_owned()));
91 }
92 let found: Vec<PathBuf> = ["yml", "yaml"]
93 .iter()
94 .map(|e| project.join(DIR).join(format!("{name}.{e}")))
95 .filter(|p| p.is_file())
96 .collect();
97 match found.as_slice() {
98 [one] => Ok(one.clone()),
99 [] => Err(Error::NoProfile {
100 name: name.to_owned(),
101 folder: project.join(DIR),
102 known: list(project),
103 }),
104 _ => Err(Error::TwoProfiles { name: name.to_owned() }),
105 }
106}
107
108#[must_use]
110pub fn list(project: &Path) -> Vec<String> {
111 let Ok(entries) = std::fs::read_dir(project.join(DIR)) else { return vec![] };
112 let mut names: Vec<String> = entries
113 .filter_map(|e| {
114 let p = e.ok()?.path();
115 let ext = p.extension()?.to_str()?;
116 if !p.is_file() || !["yml", "yaml"].contains(&ext) {
117 return None;
118 }
119 let stem = p.file_stem()?.to_str()?.to_owned();
120 valid_name(&stem).then_some(stem)
121 })
122 .collect();
123 names.sort();
124 names.dedup();
125 names
126}
127
128pub fn load(project: &Path, name: &str) -> Result<Profile> {
134 let path = path(project, name)?;
135 let text = std::fs::read_to_string(&path).map_err(|source| Error::Read { path: path.clone(), source })?;
136 parse(name, path, &text)
137}
138
139pub fn parse(name: &str, path: PathBuf, text: &str) -> Result<Profile> {
147 let file: File = match serde_yaml_ng::from_str(text) {
148 Ok(file) => file,
149 Err(source) => {
150 return Err(Error::ProfileYaml {
151 name: name.to_owned(),
152 path,
153 source,
154 });
155 }
156 };
157 let mapping = |v: Option<Value>, key: &'static str| -> Result<Value> {
158 match v {
159 None | Some(Value::Null) => Ok(Value::Mapping(Mapping::new())),
160 Some(m @ Value::Mapping(_)) => Ok(m),
161 Some(_) => Err(Error::ProfileMapping {
162 name: name.to_owned(),
163 path: path.clone(),
164 key,
165 }),
166 }
167 };
168 let spec = mapping(file.spec, "spec")?;
169 let defaults = mapping(file.defaults, "defaults")?;
170 let mut inputs = IndexMap::new();
171 for (k, v) in file.inputs {
172 let value = match v {
173 Value::String(s) => s,
174 Value::Number(n) => n.to_string(),
175 Value::Bool(b) => b.to_string(),
176 _ => {
177 return Err(Error::ProfileInput {
178 name: name.to_owned(),
179 path,
180 input: k,
181 });
182 }
183 };
184 inputs.insert(k, value);
185 }
186 Ok(Profile {
187 name: name.to_owned(),
188 path,
189 description: file.description,
190 target: file.target,
191 spec,
192 defaults,
193 inputs,
194 })
195}
196
197impl Profile {
198 #[must_use]
200 pub fn source(&self) -> String {
201 format!("profile {} ({})", self.name, self.path.display())
202 }
203
204 #[must_use]
206 pub fn layer(&self) -> Layer {
207 Layer {
208 source: self.source(),
209 value: self.defaults.clone(),
210 }
211 }
212
213 #[must_use]
216 pub fn check(&self, spec: &Value) -> Vec<ProfileProblem> {
217 let mut problems = Vec::new();
218 let names = |section: &str| -> Vec<String> {
219 spec.get(section)
220 .and_then(Value::as_mapping)
221 .map(|m| m.keys().filter_map(|k| k.as_str().map(str::to_owned)).collect())
222 .unwrap_or_default()
223 };
224 for section in ["machines", "networks"] {
225 let known = names(section);
226 if let Some(m) = self.spec.get(section).and_then(Value::as_mapping) {
227 for k in m.keys() {
228 let k = k.as_str().unwrap_or_default();
229 if !known.iter().any(|n| n == k) {
230 problems.push(ProfileProblem::Unknown {
231 profile: self.name.clone(),
232 section,
233 name: k.to_owned(),
234 });
235 }
236 }
237 }
238 }
239 let declared: Vec<String> = spec
240 .get("inputs")
241 .and_then(Value::as_sequence)
242 .map(|s| s.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect())
243 .unwrap_or_default();
244 for k in self.inputs.keys() {
245 if !declared.iter().any(|d| d == k) {
246 problems.push(ProfileProblem::Input {
247 profile: self.name.clone(),
248 input: k.clone(),
249 });
250 }
251 }
252 problems
253 }
254
255 pub fn pin(&self, asked: Option<&str>) -> Result<Option<String>> {
261 match (self.target, asked) {
262 (Some(t), None) => Ok(Some(t.id().to_owned())),
263 (Some(t), Some(a)) if a != t.id() => Err(Error::ProfileTarget {
264 profile: self.name.clone(),
265 target: t,
266 asked: a.to_owned(),
267 }),
268 (_, a) => Ok(a.map(str::to_owned)),
269 }
270 }
271}
272
273#[derive(Debug, Clone, PartialEq, Eq)]
275#[non_exhaustive]
276pub enum ProfileProblem {
277 Unknown {
279 profile: String,
281 section: &'static str,
283 name: String,
285 },
286 Input {
288 profile: String,
290 input: String,
292 },
293}
294
295impl fmt::Display for ProfileProblem {
296 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
297 match self {
298 ProfileProblem::Unknown { profile, section, name } => {
299 let one = section.strip_suffix('s').unwrap_or(section);
300 write!(f, "profile `{profile}`: `spec.{section}.{name}`: the spec has no {one} `{name}`")
301 }
302 ProfileProblem::Input { profile, input } => write!(f, "profile `{profile}`: input `{input}` isn't one of the spec's `inputs`"),
303 }
304 }
305}