1use 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#[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#[derive(Clone, Debug, Default, Deserialize, JsonSchema)]
43#[serde(deny_unknown_fields)]
44pub struct VarSpec {
45 #[serde(default)]
47 pub prompt: Option<String>,
48 #[serde(default)]
50 pub default: Option<String>,
51}
52
53#[derive(Clone, Debug, PartialEq, Eq)]
55pub struct Question {
56 pub name: VarName,
58 pub prompt: String,
60 pub default: Option<String>,
62}
63
64impl VarName {
65 #[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#[derive(Default)]
85struct Declared<'a> {
86 prompt: Option<&'a str>,
88 default: Offer<'a>,
90}
91
92#[derive(Clone, Copy, Default, PartialEq, Eq)]
94enum Offer<'a> {
95 #[default]
97 None,
98 Agreed(&'a str),
100 Disputed,
102}
103
104pub(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
145pub(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}