Skip to main content

devset_core/
vars.rs

1//! Template variables: declared by profiles, answered by the target, rendered into payload.
2
3use alloc::collections::BTreeMap;
4use core::str;
5use std::collections::HashSet;
6
7use derive_more::{AsRef, Display, Into};
8use minijinja::{AutoEscape, Environment, UndefinedBehavior};
9use schemars::JsonSchema;
10use serde::{Deserialize, Serialize};
11
12use crate::errors::{ParseError, Result, VarError};
13use crate::profile::PAYLOAD;
14use crate::resolve::Layer;
15use crate::target::Target;
16use crate::tree::Tree;
17
18/// A variable's name: an identifier, `[A-Za-z_][A-Za-z0-9_]*`.
19#[derive(
20    Clone,
21    PartialEq,
22    Eq,
23    PartialOrd,
24    Ord,
25    Hash,
26    AsRef,
27    Display,
28    derive_more::Debug,
29    Into,
30    Serialize,
31    Deserialize,
32    JsonSchema,
33)]
34#[as_ref(str)]
35#[debug("{_0:?}")]
36#[into(String)]
37#[serde(try_from = "String", into = "String")]
38#[schemars(inline)]
39pub struct VarName(Box<str>);
40
41/// A `[vars.name]` entry.
42#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
43#[serde(deny_unknown_fields)]
44pub struct VarSpec {
45    /// What to ask; the name if unset.
46    #[serde(default)]
47    pub prompt: Option<String>,
48    /// The answer offered.
49    #[serde(default)]
50    pub default: Option<String>,
51}
52
53/// A variable the target has not answered.
54#[derive(Clone, Debug, PartialEq, Eq)]
55pub struct Question {
56    /// The variable.
57    pub name: VarName,
58    /// What to ask.
59    pub prompt: String,
60    /// The answer offered: `None` when no layer offers one, or layers offer different ones.
61    pub default: Option<String>,
62}
63
64impl VarName {
65    /// The name as written.
66    #[must_use]
67    pub fn as_str(&self) -> &str {
68        &self.0
69    }
70}
71
72impl TryFrom<String> for VarName {
73    type Error = VarError;
74
75    fn try_from(name: String) -> Result<Self, VarError> {
76        let mut chars = name.chars();
77        let valid = chars.next().is_some_and(|c| c.is_ascii_alphabetic() || c == '_')
78            && chars.all(|c| c.is_ascii_alphanumeric() || c == '_');
79        if valid { Ok(Self(name.into_boxed_str())) } else { Err(VarError::Name { name }) }
80    }
81}
82
83/// What the layers say about one variable, merged.
84#[derive(Default)]
85struct Declared<'a> {
86    /// The first prompt given.
87    prompt: Option<&'a str>,
88    /// The default all layers agree on.
89    default: Offer<'a>,
90}
91
92/// A default, as the layers offer it.
93#[derive(Clone, Copy, Default, PartialEq, Eq)]
94enum Offer<'a> {
95    /// No layer offers one.
96    #[default]
97    None,
98    /// Every layer that offers one offers this.
99    Agreed(&'a str),
100    /// Layers disagree; the target must answer.
101    Disputed,
102}
103
104/// The target's answer to every variable the layers declare.
105///
106/// # Errors
107/// - [`VarError::Unknown`], the target answers a variable no layer declares.
108/// - [`VarError::Unanswered`], with every question, any is unanswered.
109pub(crate) fn answers(layers: &[Layer], target: &Target) -> Result<BTreeMap<VarName, String>> {
110    let mut declared: BTreeMap<&VarName, Declared<'_>> = BTreeMap::new();
111    for (name, spec) in layers.iter().flat_map(Layer::vars) {
112        let entry = declared.entry(name).or_default();
113        entry.prompt = entry.prompt.or(spec.prompt.as_deref());
114        entry.default = match (entry.default, spec.default.as_deref()) {
115            (offer, None) => offer,
116            (Offer::None, Some(default)) => Offer::Agreed(default),
117            (Offer::Agreed(first), Some(default)) if first == default => Offer::Agreed(first),
118            (Offer::Agreed(_) | Offer::Disputed, Some(_)) => Offer::Disputed,
119        };
120    }
121    if let Some(name) = target.given().find(|name| !declared.contains_key(name)) {
122        let declared = declared.keys().map(|&name| name.clone()).collect();
123        return Err(VarError::Unknown { name: name.clone(), declared }.into());
124    }
125    let mut answers = BTreeMap::new();
126    let mut questions = Vec::new();
127    for (name, Declared { prompt, default }) in declared {
128        match target.answers().get(name) {
129            Some(answer) => {
130                answers.insert(name.clone(), answer.clone());
131            },
132            None => questions.push(Question {
133                name: name.clone(),
134                prompt: prompt.map_or_else(|| name.to_string(), str::to_owned),
135                default: match default {
136                    Offer::Agreed(default) => Some(default.to_owned()),
137                    Offer::None | Offer::Disputed => None,
138                },
139            }),
140        }
141    }
142    if questions.is_empty() { Ok(answers) } else { Err(VarError::Unanswered { questions }.into()) }
143}
144
145/// `layers` with every template rendered with `answers`.
146///
147/// # Errors
148/// - [`VarError::Undeclared`], a template uses a variable no profile declares.
149/// - [`VarError::Template`], a template is not UTF-8.
150/// - [`Error::Parse`](crate::Error::Parse), a template does not parse or render, pointing at where.
151pub(crate) fn render(
152    mut layers: Vec<Layer>, answers: &BTreeMap<VarName, String>,
153) -> Result<Vec<Layer>> {
154    let mut env = Environment::new();
155    env.set_undefined_behavior(UndefinedBehavior::Strict);
156    env.set_keep_trailing_newline(true);
157    env.set_auto_escape_callback(|_| AutoEscape::None);
158    let globals: HashSet<String> = env.globals().map(|(name, _)| name.to_owned()).collect();
159    for layer in &mut layers {
160        if !layer.has_templates() {
161            continue;
162        }
163        let mut rendered = Tree::default();
164        for (path, bytes) in layer.payload().iter() {
165            if !layer.is_template(path) {
166                rendered.put(path.clone(), bytes);
167                continue;
168            }
169            let source = str::from_utf8(bytes).map_err(|e| VarError::Template {
170                path: path.clone(),
171                reason: format!("it is not UTF-8: {e}"),
172            })?;
173            let file = layer.label(&format!("{PAYLOAD}/{path}"));
174            let failed = |e: &minijinja::Error| {
175                let message = e.detail().map_or_else(
176                    || e.kind().to_string(),
177                    |detail| format!("{}: {detail}", e.kind()),
178                );
179                ParseError { file: file.clone(), text: source.to_owned(), span: e.range(), message }
180            };
181            let template =
182                env.template_from_named_str(path.as_str(), source).map_err(|e| failed(&e))?;
183            let undeclared = template
184                .undeclared_variables(false)
185                .into_iter()
186                .filter(|name| {
187                    !globals.contains(name) && !answers.keys().any(|v| v.as_str() == name)
188                })
189                .min();
190            if let Some(name) = undeclared {
191                let declared = answers.keys().cloned().collect();
192                return Err(VarError::Undeclared { path: path.clone(), name, declared }.into());
193            }
194            let text = template.render(answers).map_err(|e| failed(&e))?;
195            rendered.put(path.clone(), text.as_bytes());
196        }
197        layer.set_payload(rendered);
198    }
199    Ok(layers)
200}
201
202#[cfg(test)]
203mod tests {
204    use super::VarName;
205
206    #[test]
207    fn names_are_identifiers() {
208        for name in ["author", "_x", "target_cpu2"] {
209            assert!(VarName::try_from(name.to_owned()).is_ok(), "{name} is valid");
210        }
211        for name in ["", "2x", "a-b", "a.b", "é"] {
212            assert!(VarName::try_from(name.to_owned()).is_err(), "{name:?} is invalid");
213        }
214    }
215}