Skip to main content

isoloom_spec/syntax/
placeholder.rs

1//! The one placeholder grammar of the whole spec: `{{ path }}`, in the message, in an app's
2//! templates and in a catalogue entry's recipe and files. Isoloom fills every placeholder in when
3//! it generates (never Jinja2 or envsubst), so every CM tool installs the same bytes.
4//!
5//! | Path | Where |
6//! |---|---|
7//! | `machine.name`, `machine.addresses.<network>` | templates (this machine's) |
8//! | `machines.<m>.addresses.<network>`, `machines.<m>.ports.<name>.port` | templates and the message |
9//! | `machines.<m>.ports.<name>.published`, `.url` | the message only: run-time values |
10//! | `cloud.outputs.<name>` | the message |
11//! | `version`, `os.family`, `os.version`, `os.codename`, `vars.<x>`, `with.<setting>`, `with.<setting>.path` | a catalogue entry |
12//! | `item.<field>`, `item.<field>.path` | a catalogue entry's step repeated with `for_each` |
13//!
14//! Templates are logic-free: no conditionals, no loops. A secret's or an input's value is never
15//! a placeholder; an entry reaches such a setting's file by its path (`with.<setting>.path`).
16
17use std::fmt;
18use std::str::FromStr;
19
20use super::names::PortName;
21use crate::Error;
22use crate::placeholders::{self, Piece};
23
24/// What a placeholder names.
25#[derive(Debug, Clone, PartialEq, Eq, Hash)]
26#[non_exhaustive]
27pub enum Reference {
28    /// `machine.name`: the machine the template is for.
29    MachineName,
30    /// `machine.addresses.<network>`: its address on that network.
31    OwnAddress {
32        /// The network.
33        network: String,
34    },
35    /// `machines.<m>.addresses.<network>`: another machine's address on a network.
36    Address {
37        /// The machine.
38        machine: String,
39        /// The network.
40        network: String,
41    },
42    /// `machines.<m>.ports.<name>.port|published|url`: a machine's port.
43    Port {
44        /// The machine.
45        machine: String,
46        /// The port, by name.
47        port: PortName,
48        /// What of it.
49        field: PortField,
50    },
51    /// `cloud.outputs.<name>`: an output of the cloud module.
52    CloudOutput(String),
53    /// `version`: a catalogue entry's resolved release.
54    Version,
55    /// `os.family`, `os.version`, `os.codename`: the machine's OS.
56    Os(OsField),
57    /// `vars.<x>`: a variable of the entry's release.
58    Var(String),
59    /// `with.<setting>`, or `with.<setting>.path` for a password given as a secret or an input.
60    Setting {
61        /// The setting.
62        name: String,
63        /// Its value, or its value file's path.
64        field: SettingField,
65    },
66    /// `item.<field>`: a field of the list item a `for_each` step is repeated for.
67    Item(String),
68    /// `item.<field>.path`: the value file of a list item's password field.
69    ItemPath(String),
70}
71
72/// What of a port a placeholder names.
73#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
74#[non_exhaustive]
75pub enum PortField {
76    /// Its number in the machine.
77    Port,
78    /// The host port it is published on: known only once the lab runs.
79    Published,
80    /// Its URL (`http` and `https` ports): known only once the lab runs.
81    Url,
82}
83
84/// What of the machine's OS a placeholder names.
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
86#[non_exhaustive]
87pub enum OsField {
88    /// The family (`debian`).
89    Family,
90    /// The version (`12`).
91    Version,
92    /// The release's codename (`bookworm`).
93    Codename,
94}
95
96/// What of a setting a placeholder names.
97#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
98#[non_exhaustive]
99pub enum SettingField {
100    /// Its value.
101    Value,
102    /// Its value file's path: a password given as a secret or an input.
103    Path,
104}
105
106/// Where a text with placeholders is written: each place has its own paths.
107#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
108#[non_exhaustive]
109pub enum Place {
110    /// The spec's `message`, shown once the lab is up.
111    Message,
112    /// An app's `template` step's file.
113    Template,
114    /// A catalogue entry's recipe or files.
115    Entry,
116    /// A catalogue entry's step repeated with `for_each`.
117    EntryItem,
118}
119
120impl Reference {
121    /// Whether its value is only known once the lab runs (a published port, a URL).
122    #[must_use]
123    pub fn is_run_time(&self) -> bool {
124        matches!(
125            self,
126            Reference::Port {
127                field: PortField::Published | PortField::Url,
128                ..
129            }
130        )
131    }
132
133    /// Whether `place` may name it, else why not.
134    ///
135    /// # Errors
136    ///
137    /// Why the place can't name it: a run-time value in a file, an entry's path outside an entry.
138    pub fn allowed_in(&self, place: Place) -> Result<(), &'static str> {
139        let entry = matches!(place, Place::Entry | Place::EntryItem);
140        match self {
141            Reference::MachineName | Reference::OwnAddress { .. } if place == Place::Message => Err("the message names machines by `machines.<m>`"),
142            Reference::Port { .. } if self.is_run_time() && place != Place::Message => {
143                Err("a published port and a URL are known only once the lab runs; a file gets `port`")
144            }
145            Reference::CloudOutput(_) if place != Place::Message => Err("a cloud output is the message's only"),
146            Reference::Version | Reference::Os(_) | Reference::Var(_) | Reference::Setting { .. } if !entry => Err("only a catalogue entry has it"),
147            Reference::Item(_) | Reference::ItemPath(_) if place != Place::EntryItem => Err("only a step repeated with `for_each` has an `item`"),
148            _ => Ok(()),
149        }
150    }
151}
152
153impl FromStr for Reference {
154    type Err = Error;
155
156    /// The reference a placeholder's path names (`machines.web.ports.site.port`).
157    ///
158    /// # Errors
159    ///
160    /// [`Error::Placeholder`] for a path the grammar doesn't have, with the paths it has there.
161    fn from_str(path: &str) -> Result<Reference, Error> {
162        let unknown = |why| Error::Placeholder { path: path.to_owned(), why };
163        let parts: Vec<&str> = path.split('.').collect();
164        match parts.as_slice() {
165            ["machine", "name"] => Ok(Reference::MachineName),
166            ["machine", "addresses", n] => Ok(Reference::OwnAddress { network: (*n).to_owned() }),
167            ["machine", ..] => Err(unknown("this machine's paths are `machine.name` and `machine.addresses.<network>`")),
168            ["machines", m, rest @ ..] => {
169                machine(m, rest).ok_or_else(|| unknown("a machine's paths are `ports.<name>.port`, `.published`, `.url` and `addresses.<network>`"))
170            }
171            ["cloud", "outputs", name] => Ok(Reference::CloudOutput((*name).to_owned())),
172            ["version"] => Ok(Reference::Version),
173            ["os", "family"] => Ok(Reference::Os(OsField::Family)),
174            ["os", "version"] => Ok(Reference::Os(OsField::Version)),
175            ["os", "codename"] => Ok(Reference::Os(OsField::Codename)),
176            ["os", ..] => Err(unknown("the OS's paths are `os.family`, `os.version` and `os.codename`")),
177            ["vars", x] => Ok(Reference::Var((*x).to_owned())),
178            ["with", s] => Ok(Reference::Setting {
179                name: (*s).to_owned(),
180                field: SettingField::Value,
181            }),
182            ["with", s, "path"] => Ok(Reference::Setting {
183                name: (*s).to_owned(),
184                field: SettingField::Path,
185            }),
186            ["item", field] => Ok(Reference::Item((*field).to_owned())),
187            ["item", field, "path"] => Ok(Reference::ItemPath((*field).to_owned())),
188            _ => Err(unknown(
189                "the paths start with `machine.`, `machines.<m>.`, `cloud.outputs.`, `version`, `os.`, `vars.`, `with.` or `item.`",
190            )),
191        }
192    }
193}
194
195/// `machines.<m>.<rest>`, when `rest` is one of a machine's paths.
196fn machine(m: &str, rest: &[&str]) -> Option<Reference> {
197    let machine = m.to_owned();
198    match rest {
199        ["addresses", n] => Some(Reference::Address {
200            machine,
201            network: (*n).to_owned(),
202        }),
203        ["ports", p, field] => {
204            let field = match *field {
205                "port" => PortField::Port,
206                "published" => PortField::Published,
207                "url" => PortField::Url,
208                _ => return None,
209            };
210            Some(Reference::Port {
211                machine,
212                port: p.parse().ok()?,
213                field,
214            })
215        }
216        _ => None,
217    }
218}
219
220impl fmt::Display for Reference {
221    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
222        match self {
223            Reference::MachineName => f.write_str("machine.name"),
224            Reference::OwnAddress { network } => write!(f, "machine.addresses.{network}"),
225            Reference::Address { machine, network } => write!(f, "machines.{machine}.addresses.{network}"),
226            Reference::Port { machine, port, field } => {
227                let field = match field {
228                    PortField::Port => "port",
229                    PortField::Published => "published",
230                    PortField::Url => "url",
231                };
232                write!(f, "machines.{machine}.ports.{port}.{field}")
233            }
234            Reference::CloudOutput(name) => write!(f, "cloud.outputs.{name}"),
235            Reference::Version => f.write_str("version"),
236            Reference::Os(OsField::Family) => f.write_str("os.family"),
237            Reference::Os(OsField::Version) => f.write_str("os.version"),
238            Reference::Os(OsField::Codename) => f.write_str("os.codename"),
239            Reference::Var(x) => write!(f, "vars.{x}"),
240            Reference::Setting {
241                name,
242                field: SettingField::Value,
243            } => write!(f, "with.{name}"),
244            Reference::Setting {
245                name,
246                field: SettingField::Path,
247            } => write!(f, "with.{name}.path"),
248            Reference::Item(field) => write!(f, "item.{field}"),
249            Reference::ItemPath(field) => write!(f, "item.{field}.path"),
250        }
251    }
252}
253
254/// A piece of a text with placeholders, read.
255#[derive(Debug, Clone, PartialEq, Eq)]
256#[non_exhaustive]
257pub enum Part {
258    /// Text, as written.
259    Text(String),
260    /// A placeholder.
261    Placeholder(Reference),
262}
263
264/// The pieces of `text`, written at `place`: its text and its placeholders, in order.
265///
266/// # Errors
267///
268/// Every [`Error`] placeholder, in order: an unclosed one ends the text.
269pub fn read(text: &str, place: Place) -> Result<Vec<Part>, Vec<Error>> {
270    let mut parts = Vec::new();
271    let mut wrong = Vec::new();
272    for piece in placeholders::pieces(text) {
273        match piece {
274            Piece::Text(t) => parts.push(Part::Text(t.to_owned())),
275            Piece::Placeholder(inner) => match reference(inner, place) {
276                Ok(r) => parts.push(Part::Placeholder(r)),
277                Err(e) => wrong.push(e),
278            },
279            Piece::Unclosed(_) => wrong.push(Error::Unclosed),
280        }
281    }
282    if wrong.is_empty() { Ok(parts) } else { Err(wrong) }
283}
284
285/// `parts` joined back into a text, each placeholder replaced by what `value` gives it.
286///
287/// # Errors
288///
289/// The first error `value` gives.
290pub fn join<E>(parts: Vec<Part>, mut value: impl FnMut(&Reference) -> Result<String, E>) -> Result<String, E> {
291    let mut out = String::new();
292    for part in parts {
293        match part {
294            Part::Text(t) => out.push_str(&t),
295            Part::Placeholder(r) => out.push_str(&value(&r)?),
296        }
297    }
298    Ok(out)
299}
300
301/// Each placeholder of `text`, written at `place`, in order: what it names, or why it can't be
302/// read there.
303#[must_use]
304pub fn placeholders(text: &str, place: Place) -> Vec<Result<Reference, Error>> {
305    placeholders::pieces(text)
306        .into_iter()
307        .filter_map(|piece| match piece {
308            Piece::Text(_) => None,
309            Piece::Placeholder(inner) => Some(reference(inner, place)),
310            Piece::Unclosed(_) => Some(Err(Error::Unclosed)),
311        })
312        .collect()
313}
314
315/// What placeholder `inner` (between its braces) names, when `place` may name it.
316fn reference(inner: &str, place: Place) -> Result<Reference, Error> {
317    let r: Reference = inner.trim().parse()?;
318    match r.allowed_in(place) {
319        Ok(()) => Ok(r),
320        Err(why) => Err(Error::NotHere { reference: r, why }),
321    }
322}
323
324#[cfg(test)]
325mod tests;