facett-cicd/src/lib.rs
//! **facett-cicd** — a **CI/CD forge** view: the read-side monitoring panel for a
//! build/pipeline forge (the sibling of [`facett_brokkr`]'s *authoring* editor).
//! It shows three things, top to bottom:
//!
//! * a **pipeline roster** — one row per pipeline (name · trigger · last status),
//! click-selectable;
//! * for the selected pipeline, its **run history** (a run id · status · trigger ·
//! duration list), click-selectable; and
//! * for the selected run (the latest by default), its **per-stage breakdown**
//! (stage name · status ∈ queued/running/passed/failed · duration) plus a
//! **live-run detail** (the steps and a log tail).
//!
//! [`facett_brokkr`]: https://git.gunnar.rs/nordisk/facett
//!
//! ## Backend-agnostic by construction
//! This crate knows nothing about dwarves / nornir / holger. The host builds a
//! neutral [`CicdModel`] — a `Vec<`[`Pipeline`]`>` each carrying its [`Run`]s
//! (stages, steps, log tail) — and hands it to [`CicdPanel::new`]. The dwarves forge
//! populates it from its real run store; nornir's viz populates it from the test
//! matrix; the panel renders whichever it is handed. No adapter, no backend dep.
//!
//! ## FC contract (the canonical Elm split, FC-2 / FC-9)
//! Built on [`facett_core::Elm`]: the complete observable state lives in one
//! serializable [`CicdModel`] ([`state`](facett_core::Elm::state)); every input is a
//! [`Msg`] applied through the single mutation path [`update`](CicdPanel::update)
//! (FC-2); and [`view`](CicdPanel::view) is a **pure** function of `&self` that
//! paints and *returns* the [`Msg`]s the frame produced (FC-9). Selection keys on the
//! stable pipeline / run **ids** (FC-5), never list indices, so the roster can stream
//! and reorder without the selection drifting.
//!
//! The status colours reuse [`facett_stat`]'s shared verdict chip
//! ([`facett_stat::Verdict`] / [`facett_stat::status_verdict`]) rather than inventing
//! a private palette.
use egui::{RichText, Ui};
use facett_core::{Semantics, a11y_node, theme};
use serde::{Deserialize, Serialize};
// ─────────────────────────────────────────────────────────────────────────────
// The neutral, backend-agnostic input model (FC-1 / FC-3). The dwarves forge or
// nornir populates this; nothing here depends on either.
// ─────────────────────────────────────────────────────────────────────────────
/// Neutral run status of a pipeline run / a single stage / a single step —
/// exactly the four the forge distinguishes. The host maps its own status onto
/// this; the panel colours each chip by it (via [`facett_stat`]).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum RunStatus {
/// Accepted, not started yet.
#[default]
Queued,
/// Currently executing.
Running,
/// Finished successfully.
Passed,
/// Finished with a failure.
Failed,
}
impl RunStatus {
/// The stable lowercase tag (matches `state_json` + a host's status string).
pub fn as_str(self) -> &'static str {
match self {
RunStatus::Queued => "queued",
RunStatus::Running => "running",
RunStatus::Passed => "passed",
RunStatus::Failed => "failed",
}
}
/// Parse a free-text host status; unknown ⇒ [`RunStatus::Queued`].
pub fn parse(s: &str) -> Self {
match s.to_ascii_lowercase().as_str() {
"running" | "in_progress" => RunStatus::Running,
"passed" | "pass" | "ok" | "succeeded" | "done" => RunStatus::Passed,
"failed" | "fail" | "error" => RunStatus::Failed,
_ => RunStatus::Queued,
}
}
/// A short uppercase chip glyph for the UI.
pub fn tag(self) -> &'static str {
match self {
RunStatus::Queued => "QUEUED",
RunStatus::Running => "RUNNING",
RunStatus::Passed => "PASSED",
RunStatus::Failed => "FAILED",
}
}
/// The chip colour on the active theme — **reusing** [`facett_stat`]'s shared
/// verdict palette (`passed`→ok, `failed`→red, `running`→amber); `queued`
/// (which the verdict vocabulary has no word for) paints dim.
pub fn color(self, th: &facett_core::theme::Theme) -> egui::Color32 {
match self {
RunStatus::Queued => th.text_dim,
other => facett_stat::Verdict::parse(other.as_str())
.map(|v| v.color(th))
.unwrap_or(th.text_dim),
}
}
}
/// One stage in a run's per-stage breakdown: a name, a [`RunStatus`], and how long
/// it took (seconds; `0.0` ⇒ not-yet / unknown, rendered `—`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct StageRun {
/// Stage display name (e.g. `build`, `test`, `ship`).
pub name: String,
/// This stage's status.
pub status: RunStatus,
/// Wall-clock duration in seconds.
pub duration_s: f32,
}
impl StageRun {
pub fn new(name: impl Into<String>, status: RunStatus, duration_s: f32) -> Self {
Self { name: name.into(), status, duration_s }
}
}
/// One step in the **live-run detail** — a finer-grained unit than a stage (e.g.
/// `cargo build`, `cargo test`). Carries just a name + status; the running one is
/// where the [`Run::log_tail`] is coming from.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct RunStep {
/// Step display name.
pub name: String,
/// This step's status.
pub status: RunStatus,
}
impl RunStep {
pub fn new(name: impl Into<String>, status: RunStatus) -> Self {
Self { name: name.into(), status }
}
}
/// One run in a pipeline's history: a stable id, its overall status, what triggered
/// it, a commit summary, its stages, its live-run steps, and a tail of log lines.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Run {
/// Stable run id (e.g. `"#128"`), unique within its pipeline. Drives selection.
pub id: String,
/// The run's overall status.
pub status: RunStatus,
/// What triggered this run (e.g. `"push · main"`, `"manual"`, `"cron"`).
pub trigger: String,
/// A short commit / cause summary (sha + subject).
pub commit: String,
/// Total wall-clock seconds.
pub duration_s: f32,
/// The per-stage breakdown, in pipeline order.
pub stages: Vec<StageRun>,
/// The live-run steps (finer than stages).
pub steps: Vec<RunStep>,
/// A tail of recent log lines (newest last).
pub log_tail: Vec<String>,
}
impl Run {
/// A bare run (no stages/steps/log yet).
pub fn new(id: impl Into<String>, status: RunStatus, trigger: impl Into<String>) -> Self {
Self {
id: id.into(),
status,
trigger: trigger.into(),
commit: String::new(),
duration_s: 0.0,
stages: Vec::new(),
steps: Vec::new(),
log_tail: Vec::new(),
}
}
pub fn with_commit(mut self, commit: impl Into<String>) -> Self {
self.commit = commit.into();
self
}
pub fn with_duration(mut self, secs: f32) -> Self {
self.duration_s = secs;
self
}
pub fn with_stages(mut self, stages: Vec<StageRun>) -> Self {
self.stages = stages;
self
}
pub fn with_steps(mut self, steps: Vec<RunStep>) -> Self {
self.steps = steps;
self
}
pub fn with_log(mut self, lines: impl IntoIterator<Item = impl Into<String>>) -> Self {
self.log_tail = lines.into_iter().map(Into::into).collect();
self
}
}
/// One pipeline in the roster: a stable id, a display name, its trigger, its last
/// status, and its run history (newest first, by convention).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Pipeline {
/// Stable pipeline id, unique in the roster. Drives selection.
pub id: String,
/// Human display name.
pub name: String,
/// How this pipeline is triggered (e.g. `"push · main"`, `"cron 0 3 * * *"`).
pub trigger: String,
/// The status of its most recent run — shown in the roster row.
pub last_status: RunStatus,
/// The run history, newest first (index 0 is the latest run).
pub runs: Vec<Run>,
}
impl Pipeline {
/// A pipeline with a run history; `last_status` defaults to the newest run's
/// status (or [`RunStatus::Queued`] if there are no runs).
pub fn new(id: impl Into<String>, name: impl Into<String>, trigger: impl Into<String>, runs: Vec<Run>) -> Self {
let last_status = runs.first().map(|r| r.status).unwrap_or_default();
Self { id: id.into(), name: name.into(), trigger: trigger.into(), last_status, runs }
}
/// Override the roster's last-status chip (when it differs from `runs[0]`).
pub fn with_last_status(mut self, status: RunStatus) -> Self {
self.last_status = status;
self
}
/// The latest run (index 0), if any.
pub fn latest(&self) -> Option<&Run> {
self.runs.first()
}
/// The run with this id, if present.
pub fn run(&self, id: &str) -> Option<&Run> {
self.runs.iter().find(|r| r.id == id)
}
}
/// **The complete observable state (FC-1 / FC-3)** of a [`CicdPanel`]: the pipeline
/// roster plus the current selection (a stable pipeline id, and within it an
/// optional run id — `None` ⇒ "the latest run"). Round-trips through serde; the
/// headless [`facett_core::harness`] snapshots it.
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
pub struct CicdModel {
/// The pipeline roster.
pub pipelines: Vec<Pipeline>,
/// Stable id of the selected pipeline (drives the run-history + detail panels),
/// or `None` when nothing is selected.
pub selected_pipeline: Option<String>,
/// Stable id of the selected run **within** the selected pipeline. `None` ⇒ the
/// pipeline's latest run is shown.
pub selected_run: Option<String>,
}
/// Aggregate roster counts — the neutral "forge health" header a host can read.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct CicdSummary {
/// Total pipelines in the roster.
pub pipelines: usize,
/// Pipelines whose last run is [`RunStatus::Running`].
pub running: usize,
/// Pipelines whose last run [`RunStatus::Passed`].
pub passed: usize,
/// Pipelines whose last run [`RunStatus::Failed`].
pub failed: usize,
}
// ─────────────────────────────────────────────────────────────────────────────
// Msg / Effect — the single mutation path (FC-2 / FC-8).
// ─────────────────────────────────────────────────────────────────────────────
/// A robot-/CLI-addressable control message — the named boundary a headless driver
/// (or a host toolbar) drives the panel through, the same effect the roster/run
/// clicks produce. Applied by [`CicdPanel::update`]; the pure
/// [`view`](CicdPanel::view) *returns* these (it never mutates `self`).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Msg {
/// **Toggle**-select a pipeline by stable id (re-selecting clears it). Selecting
/// a different pipeline resets the run selection to "latest". Unknown id no-ops.
SelectPipeline(String),
/// **Toggle**-select a run by stable id **within the selected pipeline**
/// (re-selecting reverts to "latest"). Unknown id no-ops.
SelectRun(String),
/// Clear both the pipeline and run selection.
ClearSelection,
}
/// Side work as data (FC-8). The forge panel does no I/O — every [`Msg`] mutates
/// only the in-memory [`CicdModel`] — so this is uninhabited on purpose.
#[derive(Clone, Debug, PartialEq)]
pub enum Effect {}
// ─────────────────────────────────────────────────────────────────────────────
// The Facet.
// ─────────────────────────────────────────────────────────────────────────────
/// The CI/CD forge [`Facet`](facett_core::Facet): a tab title plus the complete
/// observable [`CicdModel`]. The FC-9 pure [`view`](Self::view) reads it; the FC-2
/// [`update`](Self::update) is the sole path that mutates it.
pub struct CicdPanel {
/// Tab/panel title.
pub title: String,
/// All observable state (FC-3).
pub state: CicdModel,
}
impl CicdPanel {
/// A panel over a roster (nothing selected yet).
pub fn new(title: impl Into<String>, pipelines: Vec<Pipeline>) -> Self {
Self {
title: title.into(),
state: CicdModel { pipelines, selected_pipeline: None, selected_run: None },
}
}
/// A self-contained **demo forge** — three pipelines (a green `nornir-ci`, a
/// live-running `facett-pages`, a red `skidbladnir-release`) with run histories,
/// per-stage breakdowns, live steps, and a log tail. The fixture the `cicd_panel`
/// demo (and the Pages site) mounts, so the surface is never blank.
#[must_use]
pub fn demo() -> Self {
Self::new("CI/CD Forge", demo_pipelines())
}
// ── read-only views of the model ────────────────────────────────────────────
/// The roster (read-only).
pub fn pipelines(&self) -> &[Pipeline] {
&self.state.pipelines
}
/// The selected pipeline's id, if any.
pub fn selected_pipeline(&self) -> Option<&str> {
self.state.selected_pipeline.as_deref()
}
/// The selected run's id, if any (the *explicit* selection, not the latest fallback).
pub fn selected_run(&self) -> Option<&str> {
self.state.selected_run.as_deref()
}
fn pipeline_of(&self, id: &str) -> Option<&Pipeline> {
self.state.pipelines.iter().find(|p| p.id == id)
}
/// The currently-selected [`Pipeline`], if one is selected.
pub fn current_pipeline(&self) -> Option<&Pipeline> {
self.state.selected_pipeline.as_deref().and_then(|id| self.pipeline_of(id))
}
/// The run whose detail is shown: the explicitly-selected run if set, else the
/// selected pipeline's latest run. `None` when no pipeline is selected / it has
/// no runs.
pub fn resolved_run(&self) -> Option<&Run> {
let p = self.current_pipeline()?;
match self.state.selected_run.as_deref() {
Some(rid) => p.run(rid).or_else(|| p.latest()),
None => p.latest(),
}
}
/// The roster as flat rows `(id, name, trigger, status_tag)` — the model→rows
/// projection a host/test asserts without a painter.
pub fn roster_rows(&self) -> Vec<(String, String, String, &'static str)> {
self.state
.pipelines
.iter()
.map(|p| (p.id.clone(), p.name.clone(), p.trigger.clone(), p.last_status.tag()))
.collect()
}
/// Aggregate roster counts (total / running / passed / failed by last status).
pub fn summary(&self) -> CicdSummary {
let mut s = CicdSummary { pipelines: self.state.pipelines.len(), ..Default::default() };
for p in &self.state.pipelines {
match p.last_status {
RunStatus::Running => s.running += 1,
RunStatus::Passed => s.passed += 1,
RunStatus::Failed => s.failed += 1,
RunStatus::Queued => {}
}
}
s
}
// ── FC-2 / FC-8 mutation path ────────────────────────────────────────────────
/// **The single mutation path.** Apply one [`Msg`]; returns the (always empty)
/// [`Effect`]s. The roster/run clicks route their [`Msg`] here too, so live ==
/// headless.
pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
match msg {
Msg::SelectPipeline(id) => {
if self.pipeline_of(&id).is_some() {
if self.state.selected_pipeline.as_deref() == Some(id.as_str()) {
// re-select ⇒ clear
self.state.selected_pipeline = None;
} else {
self.state.selected_pipeline = Some(id);
}
// Any pipeline (de)selection resets the run selection to "latest".
self.state.selected_run = None;
}
}
Msg::SelectRun(rid) => {
// Only meaningful within the selected pipeline.
if let Some(p) = self.current_pipeline() && p.run(&rid).is_some() {
self.state.selected_run = if self.state.selected_run.as_deref() == Some(rid.as_str()) {
None // re-select ⇒ back to "latest"
} else {
Some(rid)
};
}
}
Msg::ClearSelection => {
self.state.selected_pipeline = None;
self.state.selected_run = None;
}
}
Vec::new()
}
/// Select a pipeline by id (headless-test + host entry point). Thin wrapper over
/// [`update`](Self::update)`(`[`Msg::SelectPipeline`]`)`.
pub fn select_pipeline(&mut self, id: &str) {
let _ = self.update(Msg::SelectPipeline(id.to_string()));
}
/// Select a run by id within the selected pipeline. Thin wrapper over
/// [`update`](Self::update)`(`[`Msg::SelectRun`]`)`.
pub fn select_run(&mut self, id: &str) {
let _ = self.update(Msg::SelectRun(id.to_string()));
}
/// Clear the selection ([`Msg::ClearSelection`]).
pub fn clear_selection(&mut self) {
let _ = self.update(Msg::ClearSelection);
}
// ── typed copy (§16): the resolved run's detail, or the roster as TSV ─────────
/// The copyable text: the resolved run's per-stage breakdown when a pipeline is
/// selected, else the whole roster as a TSV rectangle (`id \t name \t trigger \t
/// last_status`).
pub fn copy_text(&self) -> Option<String> {
if self.state.pipelines.is_empty() {
return None;
}
if let (Some(p), Some(r)) = (self.current_pipeline(), self.resolved_run()) {
let mut s = format!("{} · {} · {}", p.name, r.id, r.status.tag());
for st in &r.stages {
s.push('\n');
s.push_str(&format!("{}\t{}\t{}", st.name, st.status.as_str(), fmt_dur(st.duration_s)));
}
return Some(s);
}
let mut out = String::from("id\tname\ttrigger\tlast_status");
for p in &self.state.pipelines {
out.push('\n');
out.push_str(&format!("{}\t{}\t{}\t{}", p.id, p.name, p.trigger, p.last_status.as_str()));
}
Some(out)
}
}
/// Duration in seconds → a short human string (`—` for none, `45s`, `1m 03s`).
fn fmt_dur(secs: f32) -> String {
if secs <= 0.0 {
return "—".to_string();
}
let s = secs.round() as u64;
if s < 60 {
format!("{s}s")
} else {
format!("{}m {:02}s", s / 60, s % 60)
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Rendering — the FC-9 pure view.
// ─────────────────────────────────────────────────────────────────────────────
impl CicdPanel {
/// A themed status chip: `RichText` tag coloured by the shared [`facett_stat`]
/// verdict palette. Reused for every status the panel paints.
fn status_chip(ui: &mut Ui, th: &facett_core::theme::Theme, status: RunStatus) {
ui.label(RichText::new(status.tag()).small().strong().color(status.color(th)));
}
/// **FC-9 pure view** — a function of `&self`: it paints the forge and *returns*
/// the [`Msg`]s the frame produced (a roster/run click). It does **not** mutate
/// the model; the [`impl_facet_via_elm!`](facett_core::impl_facet_via_elm) bridge
/// applies the returned messages through [`update`](Self::update).
pub fn view(&self, ui: &mut Ui) -> Vec<Msg> {
facett_core::navgraph::declare_own_draw_order(ui); // atoms follow the data order (navgraph §9)
let mut msgs: Vec<Msg> = Vec::new();
let th = theme(ui);
let base = ui.id().with("cicd");
// ── header summary ───────────────────────────────────────────────────
let sum = self.summary();
ui.horizontal(|ui| {
ui.strong("Pipelines");
ui.weak(format!(
"· {} total · {} running · {} passed · {} failed",
sum.pipelines, sum.running, sum.passed, sum.failed
));
});
ui.separator();
// ── pipeline roster ──────────────────────────────────────────────────
if self.state.pipelines.is_empty() {
ui.weak("No pipelines in this forge yet.");
return msgs;
}
let sel_pipe = self.state.selected_pipeline.clone();
for p in &self.state.pipelines {
let selected = sel_pipe.as_deref() == Some(p.id.as_str());
let row = ui.horizontal(|ui| {
Self::status_chip(ui, &th, p.last_status);
let r = ui.selectable_label(selected, &p.name);
ui.weak(format!("· {}", p.trigger));
r
});
// AccessKit hit-test over the whole row (FC-4), keyed on the stable
// pipeline id (FC-5): role=Button, label = "{name} · {trigger} · {tag}".
let label = format!("{} · {} · {}", p.name, p.trigger, p.last_status.tag());
let hit = a11y_node(
ui,
base,
("pipe", &p.id),
egui::Sense::click(),
row.response.rect,
Semantics::button(label).selected(selected),
);
if row.inner.clicked() || hit.clicked() {
msgs.push(Msg::SelectPipeline(p.id.clone()));
}
}
// ── selected pipeline: run history + detail ──────────────────────────
ui.separator();
let Some(p) = self.current_pipeline() else {
ui.weak("Select a pipeline to see its run history.");
return msgs;
};
ui.horizontal(|ui| {
ui.strong(format!("Runs · {}", p.name));
ui.weak(format!("({})", p.runs.len()));
});
if p.runs.is_empty() {
ui.weak("This pipeline has no runs yet.");
return msgs;
}
let resolved_id = self.resolved_run().map(|r| r.id.clone());
for r in &p.runs {
let selected = resolved_id.as_deref() == Some(r.id.as_str());
let row = ui.horizontal(|ui| {
Self::status_chip(ui, &th, r.status);
let resp = ui.selectable_label(selected, &r.id);
ui.weak(format!("· {} · {}", r.trigger, fmt_dur(r.duration_s)));
resp
});
let label = format!("run {} · {} · {}", r.id, r.trigger, r.status.tag());
let hit = a11y_node(
ui,
base,
("run", &p.id, &r.id),
egui::Sense::click(),
row.response.rect,
Semantics::button(label).selected(selected),
);
if row.inner.clicked() || hit.clicked() {
msgs.push(Msg::SelectRun(r.id.clone()));
}
}
// ── resolved run: per-stage breakdown + live-run detail ──────────────
if let Some(run) = self.resolved_run() {
ui.separator();
ui.horizontal(|ui| {
ui.strong(format!("Run {}", run.id));
Self::status_chip(ui, &th, run.status);
if !run.commit.is_empty() {
ui.weak(format!("· {}", run.commit));
}
});
// Per-stage breakdown.
ui.label(RichText::new("Stages").underline());
if run.stages.is_empty() {
ui.weak("(no stages)");
} else {
for st in &run.stages {
ui.horizontal(|ui| {
Self::status_chip(ui, &th, st.status);
ui.monospace(&st.name);
ui.weak(format!("· {}", fmt_dur(st.duration_s)));
});
}
}
// Live-run detail: steps + log tail.
ui.label(RichText::new("Live run").underline());
if run.steps.is_empty() {
ui.weak("(no steps)");
} else {
for step in &run.steps {
ui.horizontal(|ui| {
Self::status_chip(ui, &th, step.status);
ui.monospace(&step.name);
});
}
}
if !run.log_tail.is_empty() {
ui.label(RichText::new("log tail").small().color(th.text_dim));
for line in &run.log_tail {
ui.monospace(RichText::new(line).small());
}
}
}
// ── render-lane emit: this view path RAN ─────────────────────────────
#[cfg(feature = "testmatrix")]
facett_core::testmatrix::emit(
"facett-cicd::CicdPanel::view",
"ui_render",
!self.state.pipelines.is_empty(),
&format!("pipelines={} selected={:?}", self.state.pipelines.len(), self.selected_pipeline()),
);
msgs
}
/// The panel's `state_json` (also the deck/matrix introspection surface): the
/// roster flattened (id/name/trigger/last_status/run_count), the summary counts,
/// the selection, and the resolved run's detail (stages/steps/log tail).
fn state_json_impl(&self) -> serde_json::Value {
let sum = self.summary();
serde_json::json!({
"pipelines": self.state.pipelines.iter().map(|p| serde_json::json!({
"id": p.id,
"name": p.name,
"trigger": p.trigger,
"last_status": p.last_status.as_str(),
"run_count": p.runs.len(),
})).collect::<Vec<_>>(),
"summary": {
"pipelines": sum.pipelines,
"running": sum.running,
"passed": sum.passed,
"failed": sum.failed,
},
"selected_pipeline": self.selected_pipeline(),
"selected_run": self.resolved_run().map(|r| r.id.as_str()),
"run": self.resolved_run().map(|r| serde_json::json!({
"id": r.id,
"status": r.status.as_str(),
"trigger": r.trigger,
"commit": r.commit,
"duration_s": r.duration_s,
"stages": r.stages.iter().map(|s| serde_json::json!({
"name": s.name, "status": s.status.as_str(), "duration_s": s.duration_s,
})).collect::<Vec<_>>(),
"steps": r.steps.iter().map(|s| serde_json::json!({
"name": s.name, "status": s.status.as_str(),
})).collect::<Vec<_>>(),
"log_tail": r.log_tail,
})),
})
}
}
// ── typed copy (§16) — read-only: resolved-run detail or roster rows ─────────────
impl facett_core::clip::CopySource for CicdPanel {
fn copy_kinds(&self) -> &[facett_core::clip::ClipKind] {
&[facett_core::clip::ClipKind::Text]
}
fn copy_payload(&self) -> Option<facett_core::clip::ClipPayload> {
self.copy_text().map(facett_core::clip::ClipPayload::Text)
}
}
// ── the Elm contract (FC-2 / FC-9) ──────────────────────────────────────────────
impl facett_core::Elm for CicdPanel {
type Model = CicdModel;
type Msg = Msg;
type Effect = Effect;
fn title(&self) -> &str {
&self.title
}
fn state(&self) -> &CicdModel {
&self.state
}
fn update(&mut self, msg: Msg) -> Vec<Effect> {
CicdPanel::update(self, msg)
}
fn view(&self, ui: &mut Ui) -> Vec<Msg> {
CicdPanel::view(self, ui)
}
}
// The bridge macro writes `impl Facet for CicdPanel` from the `Elm` impl. **Form 3**
// (`custom_state_json`) because the panel publishes a RICHER `state_json` than plain
// `serde(state())` — the flattened roster + summary + resolved-run detail the demo's
// matrix asserts.
facett_core::impl_facet_via_elm!(CicdPanel, custom_state_json, {
fn state_json(&self) -> serde_json::Value {
self.state_json_impl()
}
/// The robot / headless-drive seam (FC-2): parse one serde-tagged [`Msg`] and
/// apply it through the single [`Elm::update`](facett_core::Elm::update)
/// mutation path. Malformed / unknown JSON is ignored (never a panic), so a
/// headless robot or the shared `probe_and_emit` harness drives the
/// pipeline/run selection without the concrete `Msg` type at the call site.
fn update_json(&mut self, msg_json: &str) {
if let Ok(msg) = serde_json::from_str::<Msg>(msg_json) {
let _ = <Self as facett_core::Elm>::update(self, msg);
}
}
fn selection_json(&self) -> serde_json::Value {
match self.selected_pipeline() {
Some(id) => serde_json::json!({ "pipeline": id, "run": self.resolved_run().map(|r| r.id.as_str()) }),
None => serde_json::Value::Null,
}
}
fn caps(&self) -> facett_core::FacetCaps {
facett_core::FacetCaps::NONE.themeable().resizable().selectable().copyable()
}
fn copy(&mut self) -> Option<String> {
use facett_core::clip::CopySource;
self.copy_payload().map(|p| p.as_text())
}
fn as_any_mut(&mut self) -> Option<&mut dyn std::any::Any> {
Some(self)
}
});
// ─────────────────────────────────────────────────────────────────────────────
// The shipped demo forge fixture.
// ─────────────────────────────────────────────────────────────────────────────
/// Three demo pipelines — the fixture the `cicd_panel` demo + the Pages site mount.
pub fn demo_pipelines() -> Vec<Pipeline> {
use RunStatus::*;
let nornir = Pipeline::new(
"nornir-ci",
"nornir-ci",
"push · main",
vec![
Run::new("#128", Passed, "push · main")
.with_commit("a1b2c3d fix: retry index.lock")
.with_duration(214.0)
.with_stages(vec![
StageRun::new("build", Passed, 96.0),
StageRun::new("test", Passed, 88.0),
StageRun::new("ship", Passed, 30.0),
])
.with_steps(vec![
RunStep::new("cargo build --release", Passed),
RunStep::new("cargo test", Passed),
RunStep::new("cargo clippy", Passed),
RunStep::new("publish", Passed),
])
.with_log([" Compiling nornir v0.9.0", " Finished release", "test result: ok. 412 passed", "published nornir@0.9.0"]),
Run::new("#127", Failed, "push · main")
.with_commit("9f8e7d6 refactor edda")
.with_duration(142.0)
.with_stages(vec![
StageRun::new("build", Passed, 95.0),
StageRun::new("test", Failed, 47.0),
StageRun::new("ship", Queued, 0.0),
]),
],
);
let pages = Pipeline::new(
"facett-pages",
"facett-pages",
"cron · 0 3 * * *",
vec![Run::new("#54", Running, "cron")
.with_commit("c0ffee0 demo: add cicd panel")
.with_duration(38.0)
.with_stages(vec![
StageRun::new("build-wasm", Passed, 31.0),
StageRun::new("deploy-pages", Running, 7.0),
])
.with_steps(vec![
RunStep::new("cargo xtask demo-wasm", Passed),
RunStep::new("wasm-bindgen --target web", Passed),
RunStep::new("force-push pages", Running),
])
.with_log([" bundling dist/…", " wrote index.html", " pushing to pages…"])],
);
let release = Pipeline::new(
"skidbladnir-release",
"skidbladnir-release",
"manual",
vec![Run::new("#12", Failed, "manual")
.with_commit("deadbee release 0.3.1")
.with_duration(63.0)
.with_stages(vec![
StageRun::new("stage", Passed, 40.0),
StageRun::new("publish", Failed, 23.0),
])
.with_steps(vec![
RunStep::new("cargo publish --dry-run", Passed),
RunStep::new("cargo publish", Failed),
])
.with_log([" Packaging skidbladnir…", "error: crate version 0.3.1 already exists"])],
);
vec![nornir, pages, release]
}
#[cfg(test)]
mod tests {
use super::*;
use facett_core::{Elm, Facet, harness};
fn sample() -> CicdPanel {
CicdPanel::demo()
}
#[test]
fn model_to_roster_rows_and_summary() {
let c = sample();
let rows = c.roster_rows();
assert_eq!(rows.len(), 3);
// (id, name, trigger, status_tag) for the first pipeline.
assert_eq!(rows[0].0, "nornir-ci");
assert_eq!(rows[0].3, "PASSED");
let s = c.summary();
assert_eq!(s.pipelines, 3);
assert_eq!(s.running, 1, "facett-pages is running");
assert_eq!(s.passed, 1, "nornir-ci passed");
assert_eq!(s.failed, 1, "skidbladnir-release failed");
}
#[test]
fn select_pipeline_toggles_and_resolves_latest_run() {
let mut c = sample();
assert!(c.current_pipeline().is_none());
c.select_pipeline("nornir-ci");
assert_eq!(c.selected_pipeline(), Some("nornir-ci"));
// No explicit run ⇒ the latest (#128) is resolved.
assert_eq!(c.resolved_run().map(|r| r.id.as_str()), Some("#128"));
// Toggle off.
c.select_pipeline("nornir-ci");
assert_eq!(c.selected_pipeline(), None);
// Unknown id is a no-op.
c.select_pipeline("ghost");
assert_eq!(c.selected_pipeline(), None);
}
#[test]
fn select_run_overrides_latest_and_reverts_on_reselect() {
let mut c = sample();
c.select_pipeline("nornir-ci");
// Pick the older run.
c.select_run("#127");
assert_eq!(c.selected_run(), Some("#127"));
assert_eq!(c.resolved_run().map(|r| r.id.as_str()), Some("#127"));
// Re-select ⇒ back to "latest".
c.select_run("#127");
assert_eq!(c.selected_run(), None);
assert_eq!(c.resolved_run().map(|r| r.id.as_str()), Some("#128"));
// A run id not in this pipeline is a no-op.
c.select_run("#999");
assert_eq!(c.selected_run(), None);
}
#[test]
fn switching_pipeline_resets_the_run_selection() {
let mut c = sample();
c.select_pipeline("nornir-ci");
c.select_run("#127");
assert_eq!(c.selected_run(), Some("#127"));
c.select_pipeline("facett-pages");
assert_eq!(c.selected_run(), None, "a new pipeline shows its latest run");
assert_eq!(c.resolved_run().map(|r| r.id.as_str()), Some("#54"));
}
#[test]
fn state_json_carries_roster_summary_and_resolved_run() {
let mut c = sample();
c.select_pipeline("skidbladnir-release");
let j = Facet::state_json(&c);
assert_eq!(j["pipelines"].as_array().unwrap().len(), 3);
assert_eq!(j["summary"]["failed"], 1);
assert_eq!(j["selected_pipeline"], "skidbladnir-release");
assert_eq!(j["selected_run"], "#12");
// The resolved run's per-stage breakdown is surfaced.
let stages = j["run"]["stages"].as_array().unwrap();
assert_eq!(stages.len(), 2);
assert_eq!(stages[1]["name"], "publish");
assert_eq!(stages[1]["status"], "failed");
assert!(j["run"]["log_tail"].as_array().unwrap().iter().any(|l| l.as_str().unwrap().contains("already exists")));
}
#[test]
fn headless_render_draws_roster_and_detail() {
// Inject a selection, render offscreen, assert it drew pixels and reported
// the resolved run — the inject-input/assert-output law, no display.
let mut c = sample();
c.select_pipeline("nornir-ci");
let r = harness::headless_render(&mut c);
assert_eq!(r.title, "CI/CD Forge");
assert!(r.drew(), "a 3-pipeline forge with a detail panel tessellates to vertices");
assert_eq!(r.state["selected_pipeline"], "nornir-ci");
assert_eq!(r.state["run"]["id"], "#128");
}
#[test]
fn empty_forge_renders_without_panicking() {
let mut c = CicdPanel::new("empty", vec![]);
let r = harness::headless_render(&mut c);
assert!(r.drew(), "the empty hint still tessellates text");
assert_eq!(r.state["summary"]["pipelines"], 0);
}
#[test]
fn model_serde_round_trips() {
let mut c = sample();
c.select_pipeline("nornir-ci");
c.select_run("#127");
let json = serde_json::to_value(c.state()).unwrap();
let back: CicdModel = serde_json::from_value(json).unwrap();
assert_eq!(&back, c.state(), "the Model round-trips through serde (FC-3)");
}
#[test]
fn typed_copy_is_run_detail_or_roster_rows() {
use facett_core::clip::{ClipKind, CopySource};
let mut c = sample();
// No selection ⇒ the roster as a TSV rectangle.
let p = c.copy_payload().unwrap();
assert_eq!(p.kind(), ClipKind::Text);
assert!(p.as_text().starts_with("id\tname\ttrigger\tlast_status"), "{}", p.as_text());
assert!(p.as_text().contains("\nnornir-ci\tnornir-ci\tpush · main\tpassed"));
// Selecting narrows to the resolved run's per-stage breakdown.
c.select_pipeline("nornir-ci");
let t = c.copy_payload().unwrap().as_text();
assert!(t.starts_with("nornir-ci · #128 · PASSED"), "{t}");
assert!(t.contains("build\tpassed"));
// An empty forge has nothing to copy.
assert!(CicdPanel::new("e", vec![]).copy_payload().is_none());
}
// ── FC-2 / FC-9 harness properties: drive `Msg`s, snapshot `state()`, no GPU ──
#[test]
fn drive_select_pipeline_and_run_via_msg_no_effects() {
let mut c = sample();
let fx = harness::drive(
&mut c,
[Msg::SelectPipeline("nornir-ci".into()), Msg::SelectRun("#127".into())],
);
assert!(fx.is_empty(), "the forge issues no Effects (uninhabited)");
assert_eq!(c.state().selected_pipeline.as_deref(), Some("nornir-ci"));
assert_eq!(c.state().selected_run.as_deref(), Some("#127"));
}
#[test]
fn drive_clear_selection_via_msg() {
let mut c = sample();
let snap = harness::snapshot(
&mut c,
[Msg::SelectPipeline("nornir-ci".into()), Msg::SelectRun("#127".into()), Msg::ClearSelection],
);
assert_eq!(snap.selected_pipeline, None);
assert_eq!(snap.selected_run, None);
}
#[test]
fn run_status_reuses_facett_stat_verdict_colours() {
// The status chip colour comes from the shared facett-stat verdict palette,
// not a private one: passed==ok(point), failed==red, running==amber.
let th = facett_core::theme::Theme::default();
assert_eq!(RunStatus::Passed.color(&th), facett_stat::Verdict::Pass.color(&th));
assert_eq!(RunStatus::Failed.color(&th), facett_stat::Verdict::Fail.color(&th));
assert_eq!(RunStatus::Running.color(&th), facett_stat::Verdict::Running.color(&th));
// queued has no verdict word ⇒ paints dim.
assert_eq!(RunStatus::Queued.color(&th), th.text_dim);
}
#[test]
fn demo_is_a_three_pipeline_forge_that_renders() {
let mut c = CicdPanel::demo();
assert_eq!(c.pipelines().len(), 3);
let r = harness::headless_render(&mut c);
assert_eq!(r.title, "CI/CD Forge");
assert!(r.drew());
assert_eq!(r.state["pipelines"].as_array().unwrap().len(), 3);
}
}