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/inventory.rs

//! **The constellation inventory** — every declared build thing as one crisp
//! 3-col row, rendered from the DECLARATIONS and nowhere else.
//!
//! This is a *projection*, in the same family as [`crate::surface`] and
//! [`crate::spec`]: it holds no declaration logic of its own. Every fact in a
//! row comes back out of the one evaluator —
//!
//! | column fact | read from |
//! |---|---|
//! | the variant roster | [`BuildVariantSet::declarations`] |
//! | the typ | [`Declared::kind`] ([`ArtifactKind::as_str`]) |
//! | the features on / off | [`BuildVariant::features`] against `set.offers` |
//! | the settings **and their values** | [`crate::flags::flags_of`] over [`SettingsTree::of_set`] |
//! | the BUILT edges | the same walk's [`crate::flags::Flag::Build`] |
//!
//! — so the table cannot drift from what `--opt` / `--param` / `--build`
//! accept: it is *made of* those flags. That is the point of putting it here
//! rather than in a door: `dwarves buildthing inventory` and any file checked
//! into a `.nornir` are both RENDERS of this function, and a committed table
//! is an artifact of it, never a source.
//!
//! ## The permutation, not the defaults
//!
//! [`InventoryRow::settings`] is the emittable SPACE — `dhcp=on|off`,
//! `kernel.preset=nuked|slim|…`, `site=<value>` — with
//! [`InventoryRow::permutations`] the product of the value counts over the
//! one-of axes. A row that reads `perm=4` declares four buildable points, and
//! a row that reads `perm=1` declares exactly one. Defaults alone would answer
//! a different question ("what does it build if I say nothing"), and that
//! question already has a door (`--list`).
//!
//! ## The vocabulary
//!
//! variant → features → settings / sub-settings → typ. Never "cell".
//! [`cell_offences`] is the guard: it walks everything this module would
//! render and names any row whose own text carries the retired word, so a
//! declaration that reintroduces it fails at the table rather than in review.

use std::collections::{BTreeMap, BTreeSet};

use serde::{Deserialize, Serialize};

use crate::flags::{Flag, flags_of};
use crate::tree::SettingsTree;
use crate::variant::{ArtifactKind, BuildVariantSet};

/// The retired word. One spelling, one guard — see [`cell_offences`].
pub const RETIRED_WORD: &str = "cell";

/// **Which road the row's declaration came down** — the audit's own status
/// word, and the only judgement this module makes.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum Road {
    /// A `<repo>-build-thing` module answered; the code IS the source.
    Code,
    /// No code recipe: the committed toml is being read as the source. A
    /// finding, per the code-is-source ruling — not a failure.
    Toml,
    /// A code recipe answered AND disagreed with the committed toml. The
    /// sentence names the drift.
    Drift(String),
    /// The declaration could not be read at all; the sentence says why.
    Refused(String),
}

impl Road {
    /// The status word for column 2.
    pub fn word(&self) -> &'static str {
        match self {
            Road::Code => "kod",
            Road::Toml => "toml",
            Road::Drift(_) => "DRIFT",
            Road::Refused(_) => "VÄGRAD",
        }
    }
    /// Whether this road is a finding an audit must report.
    pub fn is_finding(&self) -> bool {
        !matches!(self, Road::Code)
    }
    /// The sentence behind a finding, when there is one.
    pub fn why(&self) -> Option<&str> {
        match self {
            Road::Drift(w) | Road::Refused(w) => Some(w.as_str()),
            _ => None,
        }
    }
}

/// One declared build thing: `<repo>/<variant>` and everything the evaluator
/// can say about it without building it.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct InventoryRow {
    /// The declaring checkout's name.
    pub repo: String,
    /// The declared handle — what `--variant` takes.
    pub variant: String,
    /// The OUTPUT TYPE ([`ArtifactKind::as_str`]): exe · lib · wasm ·
    /// container · vm · iso · installer · service · kernel · package.
    pub typ: String,
    /// The road this row's declaration came down.
    pub road: Road,
    /// The cargo features this variant turns ON.
    pub features_on: Vec<String>,
    /// The features the repo OFFERS that this variant leaves off. Empty when
    /// the repo declares no `offers` (an absence, not a zero).
    pub features_off: Vec<String>,
    /// The settings axis as `path=v1|v2|…` (a feature reads `on|off`, a
    /// setting reads `<value>`), in tree order. THE PERMUTATION, not the
    /// defaults.
    pub settings: Vec<String>,
    /// The BUILT edges as `path→repo/variant:typ`.
    pub edges: Vec<String>,
    /// The number of buildable points the one-of axes describe (the product of
    /// the value counts; free-text settings count as one, since their space is
    /// not enumerable).
    pub permutations: u64,
}

impl InventoryRow {
    /// `<repo>/<variant>` — column 1.
    pub fn handle(&self) -> String {
        format!("{}/{}", self.repo, self.variant)
    }

    /// **Column 3** — the technical detail, in one line and in the fixed order
    /// typ → features → settings → edges, capped so the table does not wrap.
    ///
    /// The cap truncates the LISTS (with `+N`), never the labels: a reader who
    /// sees `feat 3 …+2` knows a longer answer exists and which door gives it
    /// (`buildthing info <repo>`), where a silently short line would read as
    /// the whole truth.
    pub fn detail(&self, width: usize) -> String {
        let mut s = format!("typ {}", self.typ);
        if !self.features_on.is_empty() {
            s.push_str(&format!(" · feat {}", join_capped(&self.features_on, 3)));
        }
        if !self.settings.is_empty() {
            s.push_str(&format!(" · set {}", join_capped(&self.settings, 3)));
        }
        if !self.edges.is_empty() {
            s.push_str(&format!(" · edge {}", join_capped(&self.edges, 2)));
        }
        if self.permutations > 1 {
            s.push_str(&format!(" · perm {}", self.permutations));
        }
        if let Some(why) = self.road.why() {
            s.push_str(&format!(" · {why}"));
        }
        clip(&s, width)
    }
}

/// One repo's contribution to the inventory.
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct RepoInventory {
    /// The checkout name.
    pub repo: String,
    /// The road the repo's declaration came down (the rows repeat it).
    pub road: Road,
    /// The `<repo>-build-thing` package, when a code recipe answered.
    pub recipe: Option<String>,
    /// **Why this repo is not on the code road**, when a door measured it and
    /// was refused. Carried beside the rows rather than inside them: the reason
    /// is one fact about the repo, and repeating it on every variant would push
    /// the real detail out of column 3.
    pub note: Option<String>,
    /// Every declared row, in declaration order.
    pub rows: Vec<InventoryRow>,
}

impl RepoInventory {
    /// Build one repo's rows from its declared set. `road` is the caller's
    /// measurement (the door knows whether the code or the table answered);
    /// this function never guesses it.
    pub fn of_set(repo: &str, set: &BuildVariantSet, road: Road, recipe: Option<String>) -> Self {
        let rows = set
            .declarations()
            .iter()
            .map(|d| row_of(repo, set, d.name, d.kind, road.clone()))
            .collect();
        RepoInventory { repo: repo.to_string(), road, recipe, note: None, rows }
    }

    /// The same repo carrying the sentence that kept it off the code road.
    #[must_use]
    pub fn with_note(mut self, note: impl Into<String>) -> Self {
        self.note = Some(note.into());
        self
    }

    /// A repo that is checked out and declares nothing — a finding that must
    /// stay visible, never an absence.
    pub fn undeclared(repo: &str, why: impl Into<String>) -> Self {
        RepoInventory {
            repo: repo.to_string(),
            road: Road::Refused(why.into()),
            recipe: None,
            note: None,
            rows: Vec::new(),
        }
    }

    /// Every output type this repo declares, deduplicated, in kind order.
    pub fn typs(&self) -> Vec<&str> {
        let have: BTreeSet<&str> = self.rows.iter().map(|r| r.typ.as_str()).collect();
        ArtifactKind::ALL.iter().map(|k| k.as_str()).filter(|k| have.contains(k)).collect()
    }
}

/// One row, from the declaration and the settings tree only.
fn row_of(
    repo: &str,
    set: &BuildVariantSet,
    name: &str,
    kind: ArtifactKind,
    road: Road,
) -> InventoryRow {
    let features_on: Vec<String> =
        set.get(name).map(|v| v.features.clone()).unwrap_or_default();
    let on: BTreeSet<&str> = features_on.iter().map(String::as_str).collect();
    let features_off: Vec<String> = set
        .offers
        .iter()
        .filter(|f| !on.contains(f.as_str()))
        .cloned()
        .collect();

    // THE PERMUTATION — the flags the tree can produce, folded per path so a
    // feature's two states read as one axis (`dhcp=on|off`) rather than two
    // rows. Same walk `--list-options` and the ⚒ node menu use, so the three
    // cannot disagree about what is settable.
    let mut axes: BTreeMap<String, Vec<String>> = BTreeMap::new();
    let mut order: Vec<String> = Vec::new();
    let mut edges: Vec<String> = Vec::new();
    if let Some(tree) = SettingsTree::of_set(set, name) {
        for f in flags_of(&tree) {
            match &f {
                Flag::Opt { path, value } | Flag::Param { path, value } => {
                    let slot = axes.entry(path.clone()).or_insert_with(|| {
                        order.push(path.clone());
                        Vec::new()
                    });
                    if !slot.iter().any(|v| v == value) {
                        slot.push(value.clone());
                    }
                }
                // An edge is rendered by its PATH form only: the coordinate form
                // is the same edge spelled twice, and a table that printed both
                // would double every edge count.
                Flag::Build { target } => {
                    if !target.contains('/') {
                        let e = set
                            .edges_of(name)
                            .unwrap_or_default()
                            .into_iter()
                            .find(|(p, _)| p == target)
                            .map(|(p, e)| format!("{p}→{}:{}", e.coordinate(), e.typ.as_str()))
                            .unwrap_or_else(|| target.clone());
                        if !edges.contains(&e) {
                            edges.push(e);
                        }
                    }
                }
            }
        }
    }
    let mut permutations: u64 = 1;
    let settings: Vec<String> = order
        .iter()
        .map(|p| {
            let vals = &axes[p];
            // A free-text setting's space is not enumerable, so it multiplies by
            // one — counting `<value>` as a choice would claim a number nobody
            // can build through.
            if !(vals.len() == 1 && vals[0] == crate::flags::VALUE_PLACEHOLDER) {
                permutations = permutations.saturating_mul(vals.len() as u64);
            }
            format!("{p}={}", vals.join("|"))
        })
        .collect();

    InventoryRow {
        repo: repo.to_string(),
        variant: name.to_string(),
        typ: kind.as_str().to_string(),
        road,
        features_on,
        features_off,
        settings,
        edges,
        permutations,
    }
}

/// Column widths. Column 1 is the handle, column 2 the status word, and
/// column 3 takes the rest — the crisp shape the constellation reports in.
pub const COL1: usize = 34;
/// The status column.
pub const COL2: usize = 7;
/// Column 3's cap, chosen so `COL1 + COL2 + COL3` sits inside 120 columns and
/// the table never wraps.
pub const COL3: usize = 76;

/// **The table** — one header line, then one line per declared variant, then
/// the summary. Deterministic: repos in the order given, rows in declaration
/// order.
pub fn render(repos: &[RepoInventory]) -> Vec<String> {
    let mut out = Vec::new();
    out.push(row3("BUILD THING", "väg", "typ · features · settings (värderymd) · edges"));
    out.push(row3(&"─".repeat(12), &"─".repeat(3), &"─".repeat(44)));
    for r in repos {
        if r.rows.is_empty() {
            out.push(row3(
                &r.repo,
                r.road.word(),
                &clip(
                    &format!(
                        "deklarerar inga varianter{}",
                        r.road.why().map(|w| format!(" — {w}")).unwrap_or_default()
                    ),
                    COL3,
                ),
            ));
            continue;
        }
        for row in &r.rows {
            out.push(row3(&row.handle(), row.road.word(), &row.detail(COL3)));
        }
        // ONE line per repo for the reason it is on the table road — the audit's
        // whole subject, and the thing a per-row column has no width for.
        if let Some(n) = &r.note {
            out.push(row3(&format!("  ↳ {}", r.repo), "väg", &clip(n, COL3)));
        }
    }
    out.extend(summary(repos));
    out
}

/// The tail of [`render`] — the counts an auditor reads first.
pub fn summary(repos: &[RepoInventory]) -> Vec<String> {
    let variants: usize = repos.iter().map(|r| r.rows.len()).sum();
    let code = repos.iter().filter(|r| matches!(r.road, Road::Code)).count();
    let toml = repos.iter().filter(|r| matches!(r.road, Road::Toml)).count();
    let drift = repos.iter().filter(|r| matches!(r.road, Road::Drift(_))).count();
    let refused = repos.iter().filter(|r| matches!(r.road, Road::Refused(_))).count();
    let typs: BTreeSet<&str> = repos.iter().flat_map(|r| r.typs()).collect();
    let mut out = vec![row3(&"─".repeat(12), &"─".repeat(3), &"─".repeat(44))];
    out.push(row3(
        "SUMMA",
        if drift + refused == 0 { "ok" } else { "fynd" },
        &clip(
            &format!(
                "{} repo · {variants} varianter · kod {code} · toml {toml} · drift {drift} \
                 · vägrad {refused}",
                repos.len(),
            ),
            COL3,
        ),
    ));
    // The typ roster gets its OWN row rather than a tail on the counts: it is the
    // half of the answer that grows (ten kinds today), and a tail is the half that
    // gets clipped first.
    out.push(row3(
        "SUMMA typer",
        "—",
        &clip(&typs.into_iter().collect::<Vec<_>>().join(" · "), COL3),
    ));
    out
}

/// **The vocabulary guard.** Every retired word this table would render, named
/// with the row it rides on.
///
/// The check is on the RENDERED text, not on the file: a word that never
/// reaches a reader is a comment, and a word that does is the table teaching
/// it again. Case-insensitive, on whole words only — `excellent` is not a
/// finding.
pub fn cell_offences(repos: &[RepoInventory]) -> Vec<String> {
    let mut out = Vec::new();
    for r in repos {
        if says_retired(&r.repo) {
            out.push(format!("{}: repo name says '{RETIRED_WORD}'", r.repo));
        }
        if let Some(w) = r.road.why() {
            if says_retired(w) {
                out.push(format!("{}: the road's own sentence says '{RETIRED_WORD}': {w}", r.repo));
            }
        }
        for row in &r.rows {
            for (what, text) in [
                ("variant", row.variant.clone()),
                ("typ", row.typ.clone()),
                ("features", row.features_on.join(" ")),
                ("settings", row.settings.join(" ")),
                ("edges", row.edges.join(" ")),
            ] {
                if says_retired(&text) {
                    out.push(format!(
                        "{}: {what} says '{RETIRED_WORD}' — {text}",
                        row.handle()
                    ));
                }
            }
        }
    }
    out
}

/// Whole-word, case-insensitive `cell` (singular or plural).
fn says_retired(text: &str) -> bool {
    text.split(|c: char| !c.is_ascii_alphanumeric())
        .any(|w| w.eq_ignore_ascii_case(RETIRED_WORD) || w.eq_ignore_ascii_case("cells"))
}

/// **Every (repo, variant) named twice** — the uniqueness law the table rests
/// on. A duplicate handle means two declarations answer to one coordinate, and
/// every `repo/variant` resolve after that is a coin toss.
pub fn duplicate_handles(repos: &[RepoInventory]) -> Vec<String> {
    let mut seen: BTreeMap<String, usize> = BTreeMap::new();
    for r in repos {
        for row in &r.rows {
            *seen.entry(row.handle()).or_default() += 1;
        }
    }
    seen.into_iter().filter(|(_, n)| *n > 1).map(|(h, n)| format!("{h} × {n}")).collect()
}

fn row3(a: &str, b: &str, c: &str) -> String {
    format!("{:<COL1$} {:<COL2$} {}", clip(a, COL1), b, c)
}

fn clip(s: &str, width: usize) -> String {
    if s.chars().count() <= width {
        return s.to_string();
    }
    let keep: String = s.chars().take(width.saturating_sub(1)).collect();
    format!("{keep}…")
}

fn join_capped(items: &[String], max: usize) -> String {
    if items.len() <= max {
        return items.join(",");
    }
    format!("{}…+{}", items[..max].join(","), items.len() - max)
}

#[cfg(test)]
mod tests {
    use super::*;

    fn set_with_two() -> BuildVariantSet {
        BuildVariantSet::from_toml_str(
            r#"
            [build]
            offers = ["server", "client", "extra"]

            [[build.variant]]
            name = "thing-server"
            package = "thing"
            bin = "thing"
            features = ["server"]

            [[build.variant]]
            name = "thing-lib"
            package = "thing"
            kind = "lib"
            features = []
            "#,
        )
        .expect("fixture parses")
    }

    #[test]
    fn a_row_carries_the_typ_and_both_halves_of_the_feature_axis() {
        let set = set_with_two();
        let inv = RepoInventory::of_set("thing", &set, Road::Code, Some("thing-build-thing".into()));
        assert_eq!(inv.rows.len(), 2, "one row per declaration");
        let server = inv.rows.iter().find(|r| r.variant == "thing-server").expect("server row");
        assert_eq!(server.typ, "exe");
        assert_eq!(server.features_on, vec!["server".to_string()]);
        assert_eq!(server.features_off, vec!["client".to_string(), "extra".to_string()]);
        let lib = inv.rows.iter().find(|r| r.variant == "thing-lib").expect("lib row");
        assert_eq!(lib.typ, "lib");
        assert_eq!(inv.typs(), vec!["exe", "lib"]);
    }

    #[test]
    fn the_settings_column_is_the_value_space_not_the_default() {
        // An installer declaring one on/off option must read `dhcp=on|off` —
        // the SPACE — and count two permutations, not one.
        let set = BuildVariantSet::from_toml_str(
            r#"
            [[build.variant]]
            name = "thing-installer"
            package = "thing"
            kind = "installer"

            [[build.installer]]
            name = "thing-installer"
            variant = "thing-installer"

            [[build.installer.options]]
            name = "dhcp"
            label = "DHCP"
            default = true
            feature = "dhcp-client"
            "#,
        )
        .expect("installer fixture parses");
        let inv = RepoInventory::of_set("thing", &set, Road::Code, None);
        let row = &inv.rows[0];
        let dhcp = row
            .settings
            .iter()
            .find(|s| s.starts_with("dhcp="))
            .unwrap_or_else(|| panic!("no dhcp axis in {:?}", row.settings));
        assert_eq!(dhcp, "dhcp=on|off", "both states, not the default");
        assert!(row.permutations >= 2, "perm {} — the space, not one point", row.permutations);
    }

    #[test]
    fn the_status_word_says_which_road_and_a_drift_carries_its_sentence() {
        assert_eq!(Road::Code.word(), "kod");
        assert_eq!(Road::Toml.word(), "toml");
        let d = Road::Drift("tabellen namnger [a], koden [b]".into());
        assert_eq!(d.word(), "DRIFT");
        assert!(d.is_finding());
        assert_eq!(d.why(), Some("tabellen namnger [a], koden [b]"));
        assert!(!Road::Code.is_finding());
    }

    #[test]
    fn the_retired_word_is_named_wherever_it_rides() {
        let mut inv =
            RepoInventory::of_set("thing", &set_with_two(), Road::Code, None);
        assert!(cell_offences(&[inv.clone()]).is_empty(), "clean fixture is clean");
        inv.rows[0].variant = "thing-cell".to_string();
        let found = cell_offences(&[inv]);
        assert_eq!(found.len(), 1, "{found:?}");
        assert!(found[0].contains("variant says 'cell'"), "{}", found[0]);
        // A word that merely CONTAINS the letters is not a finding.
        assert!(!says_retired("excellent cellar-free"), "whole words only");
        assert!(says_retired("a Cell here"));
        assert!(says_retired("two cells"));
    }

    #[test]
    fn two_declarations_answering_to_one_coordinate_are_named() {
        let inv = RepoInventory::of_set("thing", &set_with_two(), Road::Code, None);
        assert!(duplicate_handles(&[inv.clone()]).is_empty());
        let dupe = duplicate_handles(&[inv.clone(), inv]);
        assert_eq!(dupe.len(), 2, "{dupe:?}");
        assert!(dupe[0].ends_with("× 2"), "{}", dupe[0]);
    }

    #[test]
    fn the_table_is_three_columns_and_does_not_wrap() {
        let inv = RepoInventory::of_set("thing", &set_with_two(), Road::Toml, None);
        let lines = render(&[inv]);
        for l in &lines {
            assert!(
                l.chars().count() <= COL1 + 1 + COL2 + 1 + COL3,
                "line wraps ({}): {l}",
                l.chars().count()
            );
        }
        assert!(lines.iter().any(|l| l.contains("thing/thing-server")), "{lines:#?}");
        let summa = lines
            .iter()
            .find(|l| l.starts_with("SUMMA") && l.contains("varianter"))
            .expect("a summary");
        assert!(summa.contains("2 varianter"), "{summa}");
        assert!(summa.contains("toml 1"), "{summa}");
        let typs = lines.iter().rev().find(|l| l.starts_with("SUMMA typer")).expect("a typ roster");
        assert!(typs.contains("exe · lib"), "{typs}");
    }

    #[test]
    fn a_checked_out_repo_that_declares_nothing_is_a_row_not_an_absence() {
        let lines = render(&[RepoInventory::undeclared("knut", "ingen build-variants.toml")]);
        assert!(
            lines.iter().any(|l| l.starts_with("knut") && l.contains("inga varianter")),
            "{lines:#?}"
        );
    }
}