use alloc::collections::BTreeMap;
use core::str;
use std::collections::HashSet;
use derive_more::{AsRef, Display, Into};
use minijinja::{AutoEscape, Environment, UndefinedBehavior};
use schemars::JsonSchema;
use serde::{Deserialize, Serialize};
use crate::errors::{ParseError, Result, VarError};
use crate::profile::PAYLOAD;
use crate::resolve::Layer;
use crate::target::Target;
use crate::tree::Tree;
#[derive(
Clone,
PartialEq,
Eq,
PartialOrd,
Ord,
Hash,
AsRef,
Display,
derive_more::Debug,
Into,
Serialize,
Deserialize,
JsonSchema,
)]
#[as_ref(str)]
#[debug("{_0:?}")]
#[into(String)]
#[serde(try_from = "String", into = "String")]
#[schemars(inline)]
pub struct VarName(Box<str>);
#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
#[serde(deny_unknown_fields)]
pub struct VarSpec {
#[serde(default)]
pub prompt: Option<String>,
#[serde(default)]
pub default: Option<String>,
}
#[derive(Clone, Debug, PartialEq, Eq)]
pub struct Question {
pub name: VarName,
pub prompt: String,
pub default: Option<String>,
}
impl VarName {
#[must_use]
pub fn as_str(&self) -> &str {
&self.0
}
}
impl TryFrom<String> for VarName {
type Error = VarError;
fn try_from(name: String) -> Result<Self, VarError> {
let mut chars = name.chars();
let valid = chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
&& chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
if valid { Ok(Self(name.into_boxed_str())) } else { Err(VarError::Name { name }) }
}
}
#[derive(Default)]
struct Declared<'a> {
prompt: Option<&'a str>,
default: Offer<'a>,
}
#[derive(Clone, Copy, Default, PartialEq, Eq)]
enum Offer<'a> {
#[default]
None,
Agreed(&'a str),
Disputed,
}
pub(crate) fn answers(layers: &[Layer], target: &Target) -> Result<BTreeMap<VarName, String>> {
let mut declared: BTreeMap<&VarName, Declared<'_>> = BTreeMap::new();
for (name, spec) in layers.iter().flat_map(Layer::vars) {
let entry = declared.entry(name).or_default();
entry.prompt = entry.prompt.or(spec.prompt.as_deref());
entry.default = match (entry.default, spec.default.as_deref()) {
(offer, None) => offer,
(Offer::None, Some(default)) => Offer::Agreed(default),
(Offer::Agreed(first), Some(default)) if first == default => Offer::Agreed(first),
(Offer::Agreed(_) | Offer::Disputed, Some(_)) => Offer::Disputed,
};
}
if let Some(name) = target.given().find(|name| !declared.contains_key(name)) {
let declared = declared.keys().map(|&name| name.clone()).collect();
return Err(VarError::Unknown { name: name.clone(), declared }.into());
}
let mut answers = BTreeMap::new();
let mut questions = Vec::new();
for (name, Declared { prompt, default }) in declared {
match target.answers().get(name) {
Some(answer) => {
answers.insert(name.clone(), answer.clone());
},
None => questions.push(Question {
name: name.clone(),
prompt: prompt.map_or_else(|| name.to_string(), str::to_owned),
default: match default {
Offer::Agreed(default) => Some(default.to_owned()),
Offer::None | Offer::Disputed => None,
},
}),
}
}
if questions.is_empty() { Ok(answers) } else { Err(VarError::Unanswered { questions }.into()) }
}
pub(crate) fn render(
mut layers: Vec<Layer>, answers: &BTreeMap<VarName, String>,
) -> Result<Vec<Layer>> {
let mut env = Environment::new();
env.set_undefined_behavior(UndefinedBehavior::Strict);
env.set_keep_trailing_newline(true);
env.set_auto_escape_callback(|_| AutoEscape::None);
let globals: HashSet<String> = env.globals().map(|(name, _)| name.to_owned()).collect();
for layer in &mut layers {
if !layer.has_templates() {
continue;
}
let mut rendered = Tree::default();
for (path, bytes) in layer.payload().iter() {
if !layer.is_template(path) {
rendered.put(path.clone(), bytes);
continue;
}
let source = str::from_utf8(bytes).map_err(|e| VarError::Template {
path: path.clone(),
reason: format!("it is not UTF-8: {e}"),
})?;
let file = layer.label(&format!("{PAYLOAD}/{path}"));
let failed = |e: &minijinja::Error| {
let message = e.detail().map_or_else(
|| e.kind().to_string(),
|detail| format!("{}: {detail}", e.kind()),
);
ParseError { file: file.clone(), text: source.to_owned(), span: e.range(), message }
};
let template =
env.template_from_named_str(path.as_str(), source).map_err(|e| failed(&e))?;
let undeclared = template
.undeclared_variables(false)
.into_iter()
.filter(|name| {
!globals.contains(name) && !answers.keys().any(|v| v.as_str() == name)
})
.min();
if let Some(name) = undeclared {
let declared = answers.keys().cloned().collect();
return Err(VarError::Undeclared { path: path.clone(), name, declared }.into());
}
let text = template.render(answers).map_err(|e| failed(&e))?;
rendered.put(path.clone(), text.as_bytes());
}
layer.set_payload(rendered);
}
Ok(layers)
}
#[cfg(test)]
mod tests {
use super::VarName;
#[test]
fn names_are_identifiers() {
for name in ["author", "_x", "target_cpu2"] {
assert!(VarName::try_from(name.to_owned()).is_ok(), "{name} is valid");
}
for name in ["", "2x", "a-b", "a.b", "é"] {
assert!(VarName::try_from(name.to_owned()).is_err(), "{name:?} is invalid");
}
}
}