Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/edda

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}");
    }
}