dwarves-gui/src/actions.rs
//! **The verbs a typ bears** — gap #7 of `.nornir/build-thing-model-design.md`, as
//! DATA (the owner's ruling, 2026-08-26): after BYGG the selected node's menu shows, by
//! typ only, (a) always a BUILDINFO (the receipt, readable), (b) the verbs, (c) the run
//! SURFACES the console leaf draws.
//!
//! | typ | verbs | where (a `val`) | surfaces |
//! |---|---|---|---|
//! | lib · wasm | none — RUN must not appear | — | — |
//! | exe | RUN | `run.where` = container \| vm (a host process is not an option — the containers rule) | log + exit code |
//! | service | RUN | container \| vm | log + exit code |
//! | container | RUN (draupnir) | — | log + exit code |
//! | kernel | BOOT (vm) | — | serial console + framebuffer |
//! | vm · iso · installer | BOOT (vm) | — | serial console + framebuffer |
//! | znippy | UNPACK | — | listing — *not an `ArtifactKind` today; the row waits for the typ* |
//!
//! This table is the LOCAL twin of edda's `actions(kind)` (stage 2 of the model, not
//! yet on the wasm ABI): the typ is read off the BUILD receipt, the answer is looked
//! up here, and the pane renders the answer — no if-chains on a kind in the pane.
//! When edda's `actions(kind)` lands over wasm, [`actions`] is what it replaces, and
//! nothing that reads an [`Actions`] changes.
//!
//! **Two receipts, never one** (law E): the BUILD receipt carries the typ
//! (`nornir_build_thing::contract::Receipt`, in the artifact's section); the RUN
//! receipt ([`RunReceipt`]) carries the outcome — exit code, booted-to-login, service
//! up — and is what a re-reader of the ledger gets for "what happened when it ran".
use nornir_build_thing::contract::PaneKind;
use nornir_build_thing::variant::ArtifactKind;
use serde_json::{json, Value};
/// A verb the menu offers.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Verb {
/// Run the artifact (an exe in a container or a vm; a container through draupnir).
Run,
/// Boot the medium (a vm, an iso, an installer, a kernel) — serial + framebuffer.
Boot,
/// Unpack an archive and list it.
Unpack,
}
impl Verb {
/// The word on the button — and the word a robot addresses.
#[must_use]
pub fn label(self) -> &'static str {
match self {
Verb::Run => "RUN",
Verb::Boot => "BOOT",
Verb::Unpack => "UNPACK",
}
}
}
/// **The `where` val an exe's RUN offers** — the only two places a built program may
/// run from ⚒. A host process is deliberately not in the list.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Where {
/// The val's path, in the settings vocabulary: `run.where`.
pub path: &'static str,
/// The vocabulary.
pub choices: Vec<&'static str>,
/// The default.
pub default: &'static str,
}
/// A run surface the console leaf draws.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Surface {
/// stdout / the container log.
Log,
/// The process's or container's exit code.
ExitCode,
/// The guest's serial console.
Serial,
/// The guest's framebuffer.
Framebuffer,
/// An archive listing.
Listing,
}
impl Surface {
/// The word a robot reads.
#[must_use]
pub fn as_str(self) -> &'static str {
match self {
Surface::Log => "log",
Surface::ExitCode => "exit_code",
Surface::Serial => "serial",
Surface::Framebuffer => "framebuffer",
Surface::Listing => "listing",
}
}
}
/// **What a typ bears**: its verbs, the `where` val when a verb has one, and the
/// surfaces the console draws while the verb runs.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Actions {
/// The typ the answer is about.
pub typ: ArtifactKind,
/// The verbs, in menu order. Empty for a lib or a wasm.
pub verbs: Vec<Verb>,
/// The `where` val, when RUN offers one.
pub where_: Option<Where>,
/// The surfaces the console draws.
pub surfaces: Vec<Surface>,
/// The console pane the surfaces live in — the SAME `PaneKind` the console leaf
/// already dispatches on, so the surfaces and the pane cannot disagree.
pub pane: PaneKind,
}
impl Actions {
/// Whether RUN is among the verbs.
#[must_use]
pub fn offers(&self, v: Verb) -> bool {
self.verbs.contains(&v)
}
/// The answer, as data.
#[must_use]
pub fn state_json(&self) -> Value {
json!({
"typ": self.typ.as_str(),
"verbs": self.verbs.iter().map(|v| v.label()).collect::<Vec<_>>(),
"where": self.where_.as_ref().map(|w| json!({
"path": w.path,
"choices": w.choices,
"default": w.default,
})),
"surfaces": self.surfaces.iter().map(|s| s.as_str()).collect::<Vec<_>>(),
})
}
}
impl From<nornir_build_thing::verbs::Action> for Verb {
fn from(a: nornir_build_thing::verbs::Action) -> Self {
match a {
nornir_build_thing::verbs::Action::Run => Verb::Run,
nornir_build_thing::verbs::Action::Boot => Verb::Boot,
nornir_build_thing::verbs::Action::Unpack => Verb::Unpack,
}
}
}
/// **The table.** One answer per typ; nothing in the pane branches on a kind. The
/// verbs are edda's (`nornir_build_thing::verbs::actions_for` — the same table the
/// build thing answers over wasm); the `where` val, the surfaces and the pane are the
/// local half edda's `Action` does not carry yet.
#[must_use]
pub fn actions(typ: ArtifactKind) -> Actions {
with_verbs(
typ,
nornir_build_thing::verbs::actions_for(typ)
.into_iter()
.map(Verb::from)
.collect(),
)
}
/// [`actions`] with the verbs ANSWERED — by the loaded build thing over wasm, or by
/// edda's table — and the where / surfaces / pane from the local half.
#[must_use]
pub fn with_verbs(typ: ArtifactKind, verbs: Vec<Verb>) -> Actions {
let mut a = local(typ);
a.verbs = verbs;
a
}
fn local(typ: ArtifactKind) -> Actions {
let where_exe = Some(Where {
path: "run.where",
choices: vec!["container", "vm"],
default: "container",
});
match typ {
// A produced FILE SET has no verb and no surface of its own: `pip install`
// is what consumes it, on a machine this forge does not own. Same row as
// lib/wasm, and edda's `verbs::VERBS` says the same thing — this half is
// the pane's, and it must not disagree with it.
ArtifactKind::Lib | ArtifactKind::Wasm | ArtifactKind::Package => Actions {
typ,
verbs: Vec::new(),
where_: None,
surfaces: Vec::new(),
pane: PaneKind::None,
},
ArtifactKind::Exe | ArtifactKind::Service => Actions {
typ,
verbs: vec![Verb::Run],
where_: where_exe,
surfaces: vec![Surface::Log, Surface::ExitCode],
pane: PaneKind::Exec,
},
ArtifactKind::Container => Actions {
typ,
verbs: vec![Verb::Run],
where_: None,
surfaces: vec![Surface::Log, Surface::ExitCode],
pane: PaneKind::Log,
},
ArtifactKind::Kernel | ArtifactKind::Vm | ArtifactKind::Iso | ArtifactKind::Installer => {
Actions {
typ,
verbs: vec![Verb::Boot],
where_: None,
surfaces: vec![Surface::Serial, Surface::Framebuffer],
pane: PaneKind::Console,
}
}
}
}
/// The typ a build receipt names, parsed by the model crate's own reader — kept out of the
/// scene so the scene dispatches on no kind.
#[must_use]
pub fn typ_of(target: &str) -> ArtifactKind {
ArtifactKind::from(target.to_string())
}
/// The verbs the model crate's table bears for a typ.
#[must_use]
pub fn verbs_for(kind: ArtifactKind) -> Vec<nornir_build_thing::verbs::Action> {
nornir_build_thing::verbs::actions_for(kind)
}
/// A refusing verdict, in the model crate's type.
#[must_use]
pub fn refuse(why: impl Into<String>) -> nornir_build_thing::plugin::Verdict {
nornir_build_thing::plugin::Verdict::refuse(why)
}
/// **The RUN receipt** — the second receipt (law E): what happened when the built
/// artifact ran. Lives beside the build receipt, never inside it.
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct RunReceipt {
/// The typ, off the build receipt.
pub typ: String,
/// The verb that ran.
pub verb: String,
/// Where it ran (`container` / `vm`), when the verb offered a choice.
pub where_: Option<String>,
/// The artifact's sha, off the build outcome, so the two receipts pair.
pub artifact_sha: Option<String>,
/// The outcome, in words the console measured: `exit 0`, `booted-to-login`,
/// `service up`, or `running` while it is.
pub outcome: String,
}
impl RunReceipt {
/// As data.
#[must_use]
pub fn state_json(&self) -> Value {
json!({
"typ": self.typ,
"verb": self.verb,
"where": self.where_,
"artifact_sha": self.artifact_sha,
"outcome": self.outcome,
})
}
}
#[cfg(test)]
mod tests {
use super::*;
/// **By typ only.** A lib bears no verb; an exe bears RUN with container|vm and
/// nothing else; a container bears RUN; media boot; and every answer names the
/// console pane the surfaces live in.
#[test]
fn the_table_answers_by_typ_only() {
let lib = actions(ArtifactKind::Lib);
assert!(lib.verbs.is_empty() && lib.where_.is_none() && lib.surfaces.is_empty());
assert_eq!(lib.pane, PaneKind::None);
let wasm = actions(ArtifactKind::Wasm);
assert!(!wasm.offers(Verb::Run));
let exe = actions(ArtifactKind::Exe);
assert_eq!(exe.verbs, vec![Verb::Run]);
assert_eq!(
exe.where_.as_ref().map(|w| w.choices.clone()),
Some(vec!["container", "vm"])
);
assert_eq!(exe.pane, PaneKind::Exec);
assert_eq!(exe.surfaces, vec![Surface::Log, Surface::ExitCode]);
let c = actions(ArtifactKind::Container);
assert_eq!(c.verbs, vec![Verb::Run]);
assert!(c.where_.is_none());
for medium in [
ArtifactKind::Kernel,
ArtifactKind::Iso,
ArtifactKind::Installer,
ArtifactKind::Vm,
] {
let a = actions(medium);
assert_eq!(a.verbs, vec![Verb::Boot], "{medium:?}");
assert_eq!(a.surfaces, vec![Surface::Serial, Surface::Framebuffer]);
assert_eq!(a.pane, PaneKind::Console);
}
let j = exe.state_json();
assert_eq!(j["verbs"], json!(["RUN"]));
assert_eq!(j["where"]["choices"], json!(["container", "vm"]));
}
}