crates/nornir-build-thing/src/flags.rs
//! **Path ↔ flag** — law C generalized over the settings tree.
//!
//! A click in ⚒ and a flag on the prompt are the same statement about one
//! node of the tree ([`crate::tree`]):
//!
//! | click | flag |
//! |---|---|
//! | ☑ / ☐ a feature | `--opt <path>=on\|off` |
//! | pick a val | `--opt <path>=<choice>` |
//! | type a setting | `--param <path>=<value>` (`@path`, `@fixture` allowed) |
//! | [BYGG] an edge | `--build <path\|coordinate>` |
//!
//! [`Flag::render`] and [`Flag::parse`] are inverses: `parse(render(f)) == f`
//! for every flag the tree can produce ([`flags_of`]), which is what
//! [`crate::laws::law_path_flag`] checks over every impl. The path is
//! carried VERBATIM — resolving a short alias to its full path is the tree's
//! job ([`SettingsTree::resolve`](crate::tree::SettingsTree::resolve)), not
//! the parser's, so the flag a receipt prints is the flag the operator typed.
use anyhow::{Result, anyhow, bail};
use serde::{Deserialize, Serialize};
use crate::tree::{NodeKind, SettingsTree};
/// One flag — one statement about one node.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(tag = "flag", rename_all = "lowercase")]
pub enum Flag {
/// `--opt <path>=<value>`: `on|off` for a feature, a choice for a val.
Opt {
/// The node path (or an alias, verbatim).
path: String,
/// `on` / `off`, or the val's choice.
value: String,
},
/// `--param <path>=<value>`: a setting's value (`@path` / `@fixture` allowed).
Param {
/// The node path (or an alias, verbatim).
path: String,
/// The value, verbatim.
value: String,
},
/// `--build <path|coordinate>`: build the consumed artifact behind an edge.
Build {
/// A feature path carrying an edge, or the coordinate `repo/variant`.
target: String,
},
}
impl Flag {
/// `--opt <path>=on|off`.
pub fn opt(path: impl Into<String>, on: bool) -> Self {
Flag::Opt { path: path.into(), value: if on { "on" } else { "off" }.to_string() }
}
/// `--opt <path>=<choice>`.
pub fn choice(path: impl Into<String>, choice: impl Into<String>) -> Self {
Flag::Opt { path: path.into(), value: choice.into() }
}
/// `--param <path>=<value>`.
pub fn param(path: impl Into<String>, value: impl Into<String>) -> Self {
Flag::Param { path: path.into(), value: value.into() }
}
/// `--build <target>`.
pub fn build(target: impl Into<String>) -> Self {
Flag::Build { target: target.into() }
}
/// The path this flag speaks about.
pub fn path(&self) -> &str {
match self {
Flag::Opt { path, .. } | Flag::Param { path, .. } => path,
Flag::Build { target } => target,
}
}
/// **Render** — the exact spelling the prompt takes, as one token pair:
/// `--opt dhcp.static_ip=off`.
pub fn render(&self) -> String {
match self {
Flag::Opt { path, value } => format!("--opt {path}={value}"),
Flag::Param { path, value } => format!("--param {path}={value}"),
Flag::Build { target } => format!("--build {target}"),
}
}
/// **Parse** one rendered flag back. Refuses by name: an unknown flag word,
/// a missing `=`, an empty path, an empty value, a path with whitespace.
pub fn parse(s: &str) -> Result<Flag> {
let s = s.trim();
let (word, rest) = s
.split_once(char::is_whitespace)
.map(|(w, r)| (w, r.trim()))
.ok_or_else(|| anyhow!("'{s}': a flag is `--opt|--param|--build <…>`"))?;
match word {
"--opt" | "--param" => {
let (path, value) = rest
.split_once('=')
.ok_or_else(|| anyhow!("{word} takes <path>=<value>, got '{rest}'"))?;
let path = check_path(word, path)?;
if value.is_empty() {
bail!("{word} {path}=: an empty value");
}
if word == "--opt" {
Ok(Flag::Opt { path, value: value.to_string() })
} else {
Ok(Flag::Param { path, value: value.to_string() })
}
}
"--build" => Ok(Flag::Build { target: check_path(word, rest)? }),
other => bail!("'{other}' is not a flag this door takes (--opt, --param, --build)"),
}
}
/// Parse an argv tail: `["--opt", "dhcp=off", "--build", "kernel"]` or the
/// joined spellings `["--opt dhcp=off"]` — both forms are what a shell hands
/// over. Order is kept.
pub fn parse_argv<S: AsRef<str>>(argv: &[S]) -> Result<Vec<Flag>> {
let mut out = Vec::new();
let mut i = 0;
while i < argv.len() {
let a = argv[i].as_ref().trim();
if matches!(a, "--opt" | "--param" | "--build") {
let v = argv
.get(i + 1)
.map(|s| s.as_ref())
.ok_or_else(|| anyhow!("{a} at the end of the line takes an argument"))?;
out.push(Flag::parse(&format!("{a} {v}"))?);
i += 2;
} else {
out.push(Flag::parse(a)?);
i += 1;
}
}
Ok(out)
}
/// Is this an `--opt` whose value is a feature state (`on` / `off`)?
pub fn feature_state(&self) -> Option<bool> {
match self {
Flag::Opt { value, .. } => match value.to_ascii_lowercase().as_str() {
"on" | "true" | "yes" | "1" => Some(true),
"off" | "false" | "no" | "0" => Some(false),
_ => None,
},
_ => None,
}
}
}
fn check_path(word: &str, path: &str) -> Result<String> {
let path = path.trim();
if path.is_empty() {
bail!("{word} with an empty path");
}
if path.chars().any(char::is_whitespace) || path.contains('=') {
bail!("{word} {path}: a path carries no whitespace and no '='");
}
Ok(path.to_string())
}
impl std::fmt::Display for Flag {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
f.write_str(&self.render())
}
}
/// The placeholder a rendered `--param` carries when no value is known — the
/// listing form (`--param site=<value>`), which parses back like any value.
pub const VALUE_PLACEHOLDER: &str = "<value>";
/// **Every flag a tree can produce**, in tree order: both states of every
/// feature, every choice of every val, the placeholder form of every setting,
/// and `--build` for every edge (by path AND by coordinate).
pub fn flags_of(tree: &SettingsTree) -> Vec<Flag> {
let mut out = Vec::new();
for n in tree.walk() {
match &n.kind {
NodeKind::Feature(_) => {
out.push(Flag::opt(&n.path, true));
out.push(Flag::opt(&n.path, false));
}
NodeKind::Val(v) => {
for c in &v.choices {
out.push(Flag::choice(&n.path, c));
}
}
NodeKind::Setting(_) => out.push(Flag::param(&n.path, VALUE_PLACEHOLDER)),
}
if let Some(e) = &n.edge {
out.push(Flag::build(&n.path));
out.push(Flag::build(&e.coordinate));
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_flag_shape_round_trips_through_its_own_spelling() {
for f in [
Flag::opt("dhcp", false),
Flag::opt("kernel.unix", true),
Flag::choice("kernel.preset", "nuked"),
Flag::param("dhcp.static_ip", "10.0.2.15/24"),
Flag::param("wireguard.hub_conf", "@fixture"),
Flag::param("hub_conf", "@/tmp/hub.conf"),
Flag::param("site", VALUE_PLACEHOLDER),
Flag::build("kernel"),
Flag::build("tunnr/tunnr-kernel"),
] {
let s = f.render();
assert_eq!(Flag::parse(&s).unwrap(), f, "{s}");
assert_eq!(f.to_string(), s);
}
assert_eq!(Flag::opt("x", true).feature_state(), Some(true));
assert_eq!(Flag::choice("x", "nuked").feature_state(), None);
}
#[test]
fn argv_in_both_shell_shapes_parses_in_order() {
let f = Flag::parse_argv(&["--opt", "dhcp=off", "--param static_ip=1.2.3.4/24", "--build", "kernel"]).unwrap();
assert_eq!(
f,
vec![Flag::opt("dhcp", false), Flag::param("static_ip", "1.2.3.4/24"), Flag::build("kernel")]
);
}
#[test]
fn malformed_flags_are_refused_by_name() {
for (bad, why) in [
("--opt dhcp", "<path>=<value>"),
("--opt =off", "empty path"),
("--opt dhcp=", "empty value"),
("--opt wg hub=on", "no whitespace"),
("--build", "--opt|--param|--build"),
("--frob x=1", "not a flag"),
] {
let e = format!("{:#}", Flag::parse(bad).unwrap_err());
assert!(e.contains(why), "'{bad}': {e}");
}
let e = format!("{:#}", Flag::parse_argv(&["--opt"]).unwrap_err());
assert!(e.contains("takes an argument"), "{e}");
}
}