use std::fmt;
use std::path::{Path, PathBuf};
use indexmap::IndexMap;
use serde::Deserialize;
use serde_yaml_ng::{Mapping, Value};
use crate::defaults::Layer;
use crate::model::Target;
use crate::{Error, Result};
pub const DIR: &str = "profiles";
#[derive(Debug, Clone, PartialEq)]
pub struct Profile {
pub name: String,
pub path: PathBuf,
pub description: Option<String>,
pub target: Option<Target>,
pub spec: Value,
pub defaults: Value,
pub inputs: IndexMap<String, String>,
}
#[derive(Deserialize)]
#[serde(deny_unknown_fields)]
struct File {
#[serde(default)]
description: Option<String>,
#[serde(default)]
target: Option<Target>,
#[serde(default)]
spec: Option<Value>,
#[serde(default)]
defaults: Option<Value>,
#[serde(default)]
inputs: IndexMap<String, Value>,
}
#[must_use]
pub fn valid_name(name: &str) -> bool {
!name.is_empty() && !name.starts_with('-') && !name.ends_with('-') && name.chars().all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
}
pub fn path(project: &Path, name: &str) -> Result<PathBuf> {
if !valid_name(name) {
return Err(Error::ProfileName(name.to_owned()));
}
let found: Vec<PathBuf> = ["yml", "yaml"]
.iter()
.map(|e| project.join(DIR).join(format!("{name}.{e}")))
.filter(|p| p.is_file())
.collect();
match found.as_slice() {
[one] => Ok(one.clone()),
[] => Err(Error::NoProfile {
name: name.to_owned(),
folder: project.join(DIR),
known: list(project),
}),
_ => Err(Error::TwoProfiles { name: name.to_owned() }),
}
}
#[must_use]
pub fn list(project: &Path) -> Vec<String> {
let Ok(entries) = std::fs::read_dir(project.join(DIR)) else { return vec![] };
let mut names: Vec<String> = entries
.filter_map(|e| {
let p = e.ok()?.path();
let ext = p.extension()?.to_str()?;
if !p.is_file() || !["yml", "yaml"].contains(&ext) {
return None;
}
let stem = p.file_stem()?.to_str()?.to_owned();
valid_name(&stem).then_some(stem)
})
.collect();
names.sort();
names.dedup();
names
}
pub fn load(project: &Path, name: &str) -> Result<Profile> {
let path = path(project, name)?;
let text = std::fs::read_to_string(&path).map_err(|source| Error::Read { path: path.clone(), source })?;
parse(name, path, &text)
}
pub fn parse(name: &str, path: PathBuf, text: &str) -> Result<Profile> {
let file: File = match serde_yaml_ng::from_str(text) {
Ok(file) => file,
Err(source) => {
return Err(Error::ProfileYaml {
name: name.to_owned(),
path,
source,
});
}
};
let mapping = |v: Option<Value>, key: &'static str| -> Result<Value> {
match v {
None | Some(Value::Null) => Ok(Value::Mapping(Mapping::new())),
Some(m @ Value::Mapping(_)) => Ok(m),
Some(_) => Err(Error::ProfileMapping {
name: name.to_owned(),
path: path.clone(),
key,
}),
}
};
let spec = mapping(file.spec, "spec")?;
let defaults = mapping(file.defaults, "defaults")?;
let mut inputs = IndexMap::new();
for (k, v) in file.inputs {
let value = match v {
Value::String(s) => s,
Value::Number(n) => n.to_string(),
Value::Bool(b) => b.to_string(),
_ => {
return Err(Error::ProfileInput {
name: name.to_owned(),
path,
input: k,
});
}
};
inputs.insert(k, value);
}
Ok(Profile {
name: name.to_owned(),
path,
description: file.description,
target: file.target,
spec,
defaults,
inputs,
})
}
impl Profile {
#[must_use]
pub fn source(&self) -> String {
format!("profile {} ({})", self.name, self.path.display())
}
#[must_use]
pub fn layer(&self) -> Layer {
Layer {
source: self.source(),
value: self.defaults.clone(),
}
}
#[must_use]
pub fn check(&self, spec: &Value) -> Vec<ProfileProblem> {
let mut problems = Vec::new();
let names = |section: &str| -> Vec<String> {
spec.get(section)
.and_then(Value::as_mapping)
.map(|m| m.keys().filter_map(|k| k.as_str().map(str::to_owned)).collect())
.unwrap_or_default()
};
for section in ["machines", "networks"] {
let known = names(section);
if let Some(m) = self.spec.get(section).and_then(Value::as_mapping) {
for k in m.keys() {
let k = k.as_str().unwrap_or_default();
if !known.iter().any(|n| n == k) {
problems.push(ProfileProblem::Unknown {
profile: self.name.clone(),
section,
name: k.to_owned(),
});
}
}
}
}
let declared: Vec<String> = spec
.get("inputs")
.and_then(Value::as_sequence)
.map(|s| s.iter().filter_map(|v| v.as_str().map(str::to_owned)).collect())
.unwrap_or_default();
for k in self.inputs.keys() {
if !declared.iter().any(|d| d == k) {
problems.push(ProfileProblem::Input {
profile: self.name.clone(),
input: k.clone(),
});
}
}
problems
}
pub fn pin(&self, asked: Option<&str>) -> Result<Option<String>> {
match (self.target, asked) {
(Some(t), None) => Ok(Some(t.id().to_owned())),
(Some(t), Some(a)) if a != t.id() => Err(Error::ProfileTarget {
profile: self.name.clone(),
target: t,
asked: a.to_owned(),
}),
(_, a) => Ok(a.map(str::to_owned)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
#[non_exhaustive]
pub enum ProfileProblem {
Unknown {
profile: String,
section: &'static str,
name: String,
},
Input {
profile: String,
input: String,
},
}
impl fmt::Display for ProfileProblem {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
ProfileProblem::Unknown { profile, section, name } => {
let one = section.strip_suffix('s').unwrap_or(section);
write!(f, "profile `{profile}`: `spec.{section}.{name}`: the spec has no {one} `{name}`")
}
ProfileProblem::Input { profile, input } => write!(f, "profile `{profile}`: input `{input}` isn't one of the spec's `inputs`"),
}
}
}