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