facett-brokkr/src/lib.rs
//! **facett-brokkr** — a brokkr CI/build-pipeline *builder* facet.
//!
//! A repo declares its CI/build pipeline as **data** in `brokkr.toml`: a name,
//! optional triggers, and an ordered list of **stages**, each a list of
//! DAG-ordered **steps** (a step `needs` earlier steps and `run`s a variant /
//! container / vm). This component lets a user *build* that pipeline two ways,
//! switchable in the UI, kept in sync:
//!
//! * **TEXT mode** — a multi-line editor holding the raw `brokkr.toml`. Every edit
//! is live-parsed; parse failures and structural problems (unknown `needs`,
//! duplicate step names, empty argv/image, a `cron` trigger with no expression,
//! an ungated webhook …) surface as validation errors — the same shape brokkr's
//! own descriptor validation rejects.
//! * **CLICK mode** — build it structurally: add/remove **stage**, add/remove
//! **step** (name, `needs`, and a **RunKind** = variant{name} / container{image,
//! argv} / vm{image}), add/remove **trigger** (poll / cron{expr} / webhook /
//! manual).
//! * **GRAPH mode** — Falsett draws the dependency DAG. Drag output ports to
//! connect steps, drag boxes to move them, or select an edge and press Delete.
//! Shared camera gestures and Undo/Redo keep graph editing inside Brokkr.
//!
//! A CLICK edit mutates the model → re-serialises the TEXT buffer; a TEXT edit that
//! parses rebuilds the model. So `pipeline_toml()` always yields a `brokkr.toml`
//! document, and [`PipelineBuilder::validate`] the structural verdict, whichever
//! mode authored it.
//!
//! This is a **pure, dependency-light editor**: it defines its OWN structs
//! mirroring brokkr's `brokkr.toml` schema and emits valid TOML *text*; it never
//! depends on `nornir`/`brokkr` and never runs anything — the host parses the
//! emitted text with brokkr's real parser and runs it. Mirrors the [`Facet`] shape
//! (`title` / `ui` / `state_json`) like the sibling `facett-sdp` editor.
//!
//! [`Facet`]: https://git.gunnar.rs/nordisk/facett
use std::collections::HashSet;
use egui::{Color32, Ui};
use serde::{Deserialize, Serialize};
// ─────────────────────────────────────────────────────────────────────────────
// The `brokkr.toml` serde shape — OUR OWN copy, structurally identical to
// `brokkr::model` (same `rename`/tagging), so what we emit round-trips through
// brokkr's real parser and what a user pastes deserialises here.
// ─────────────────────────────────────────────────────────────────────────────
/// The root of a `brokkr.toml` document: exactly one `[pipeline]` table.
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct PipelineFile {
/// The single pipeline this file declares.
#[serde(default)]
pub pipeline: Pipeline,
}
/// A complete CI/build pipeline declared as data.
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct Pipeline {
/// Human-facing pipeline name.
#[serde(default)]
pub name: String,
/// When this pipeline fires. Empty ⇒ manual-only.
#[serde(default, rename = "trigger", skip_serializing_if = "Vec::is_empty")]
pub triggers: Vec<Trigger>,
/// The ordered stages; steps within (and across, via `needs`) form the DAG.
#[serde(default, rename = "stage", skip_serializing_if = "Vec::is_empty")]
pub stages: Vec<Stage>,
}
/// A stage: a named group of steps.
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct Stage {
/// Stage name, unique within the pipeline.
pub name: String,
/// The steps in this stage.
#[serde(default, rename = "step", skip_serializing_if = "Vec::is_empty")]
pub steps: Vec<Step>,
}
/// A single unit of work: run *something* once its `needs` are done.
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct Step {
/// Step name, unique within the pipeline (the DAG node id).
pub name: String,
/// Names of steps that must complete first (DAG edges).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub needs: Vec<String>,
/// What to run.
pub run: RunKind,
/// Artifact paths/globs this step publishes.
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub artifacts: Vec<String>,
/// Test-report format to collect (`junit` …). `None` ⇒ no report.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub report: Option<String>,
}
/// What a step executes. Externally tagged so `brokkr.toml` reads naturally:
/// `run = { variant = "server" }`, `run = { container = { … } }`,
/// `run = { vm = "debian" }`.
#[derive(Clone, Debug, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum RunKind {
/// Build a co-located `BuildVariantSet` variant.
Variant(String),
/// Run an argv (zero-shell) inside a container image.
Container(ContainerRun),
/// Boot a prebuilt VM image and run inside it.
Vm(String),
}
impl Default for RunKind {
fn default() -> Self {
RunKind::Variant(String::new())
}
}
impl RunKind {
/// A short human tag for the run backend.
pub fn backend(&self) -> &'static str {
match self {
RunKind::Variant(_) => "variant",
RunKind::Container(_) => "container",
RunKind::Vm(_) => "vm",
}
}
}
/// A container step: an image plus a command vector (argv — NO shell string).
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct ContainerRun {
/// Image reference (`docker.io/library/rust:1.85`).
pub name: String,
/// The command as an argv vector (zero-shell).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub cmd: Vec<String>,
}
/// One trigger declaration: the *kind* plus the filter that gates it.
#[derive(Clone, Debug, Default, Serialize, Deserialize, PartialEq)]
pub struct Trigger {
/// Trigger transport.
pub kind: TriggerKind,
/// VCS events this trigger reacts to (`push`, `pull_request`, `tag`…).
#[serde(default, skip_serializing_if = "Vec::is_empty")]
pub events: Vec<String>,
/// Branch glob this trigger is scoped to. `None` ⇒ any.
#[serde(default, skip_serializing_if = "Option::is_none")]
pub branch: Option<String>,
/// Cron expression (only meaningful for [`TriggerKind::Cron`]).
#[serde(default, skip_serializing_if = "Option::is_none")]
pub cron: Option<String>,
}
/// How a pipeline learns it should run.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize, Default)]
#[serde(rename_all = "snake_case")]
pub enum TriggerKind {
/// An inbound webhook (push/PR from the forge).
#[default]
Webhook,
/// brokkr polls the remote and fires when HEAD moved.
Poll,
/// A cron schedule.
Cron,
/// Only ever started by an operator / API call.
Manual,
}
impl TriggerKind {
/// The `brokkr.toml` snake_case token.
pub fn as_str(self) -> &'static str {
match self {
TriggerKind::Webhook => "webhook",
TriggerKind::Poll => "poll",
TriggerKind::Cron => "cron",
TriggerKind::Manual => "manual",
}
}
/// All kinds, in UI/combo order.
pub const ALL: [TriggerKind; 4] = [
TriggerKind::Webhook,
TriggerKind::Poll,
TriggerKind::Cron,
TriggerKind::Manual,
];
}
/// Result of the structural validation pass — the same rejections as brokkr's
/// descriptor validation, accumulated (not fail-fast) so TEXT mode shows them all.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Validation {
/// True iff `errors` is empty.
pub valid: bool,
/// One message per structural problem.
pub errors: Vec<String>,
}
// ─────────────────────────────────────────────────────────────────────────────
// Editable rows — the CLICK-mode model. `needs`/argv/events are edited as one
// comma/space line each so a single `TextEdit` drives them; the clean vectors
// are re-derived on read. Kept private — hosts see the clean [`Pipeline`].
// ─────────────────────────────────────────────────────────────────────────────
/// The RunKind chooser (the combo the CLICK editor paints per step).
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
enum RunChoice {
Variant,
Container,
Vm,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
struct StepRow {
name: String,
needs_raw: String,
choice: RunChoice,
variant: String,
image: String,
cmd_raw: String,
vm: String,
artifacts_raw: String,
report: String,
}
impl Default for StepRow {
fn default() -> Self {
Self {
name: String::new(),
needs_raw: String::new(),
choice: RunChoice::Variant,
variant: String::new(),
image: String::new(),
cmd_raw: String::new(),
vm: String::new(),
artifacts_raw: String::new(),
report: String::new(),
}
}
}
impl StepRow {
fn from_step(s: &Step) -> Self {
let mut r = StepRow {
name: s.name.clone(),
needs_raw: s.needs.join(", "),
..Default::default()
};
match &s.run {
RunKind::Variant(v) => {
r.choice = RunChoice::Variant;
r.variant = v.clone();
}
RunKind::Container(c) => {
r.choice = RunChoice::Container;
r.image = c.name.clone();
r.cmd_raw = c.cmd.join(" ");
}
RunKind::Vm(v) => {
r.choice = RunChoice::Vm;
r.vm = v.clone();
}
}
r.artifacts_raw = s.artifacts.join(", ");
r.report = s.report.clone().unwrap_or_default();
r
}
fn run(&self) -> RunKind {
match self.choice {
RunChoice::Variant => RunKind::Variant(self.variant.trim().to_string()),
RunChoice::Container => RunKind::Container(ContainerRun {
name: self.image.trim().to_string(),
cmd: split_tokens(&self.cmd_raw),
}),
RunChoice::Vm => RunKind::Vm(self.vm.trim().to_string()),
}
}
fn to_step(&self) -> Step {
let report = self.report.trim();
Step {
name: self.name.trim().to_string(),
needs: parse_list(&self.needs_raw),
run: self.run(),
artifacts: parse_list(&self.artifacts_raw),
report: if report.is_empty() {
None
} else {
Some(report.to_string())
},
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
struct StageRow {
name: String,
steps: Vec<StepRow>,
}
impl StageRow {
fn from_stage(s: &Stage) -> Self {
Self {
name: s.name.clone(),
steps: s.steps.iter().map(StepRow::from_step).collect(),
}
}
fn to_stage(&self) -> Stage {
Stage {
name: self.name.trim().to_string(),
steps: self.steps.iter().map(StepRow::to_step).collect(),
}
}
}
#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)]
struct TriggerRow {
kind: TriggerKind,
events_raw: String,
branch: String,
cron: String,
}
impl TriggerRow {
fn from_trigger(t: &Trigger) -> Self {
Self {
kind: t.kind,
events_raw: t.events.join(", "),
branch: t.branch.clone().unwrap_or_default(),
cron: t.cron.clone().unwrap_or_default(),
}
}
fn to_trigger(&self) -> Trigger {
let branch = self.branch.trim();
let cron = self.cron.trim();
Trigger {
kind: self.kind,
events: parse_list(&self.events_raw),
branch: if branch.is_empty() {
None
} else {
Some(branch.to_string())
},
cron: if cron.is_empty() {
None
} else {
Some(cron.to_string())
},
}
}
}
/// Which authoring surface is active.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Mode {
/// The raw `brokkr.toml` text editor.
Text,
/// The structural add/remove-stage/step/trigger builder.
Build,
/// The interactive dependency graph editor.
Graph,
}
const ACCENT: Color32 = Color32::from_rgb(247, 198, 0); // Tillsynia yellow
const OK: Color32 = Color32::from_rgb(120, 190, 140);
const ERR: Color32 = Color32::from_rgb(230, 120, 120);
const TEXT_COL: Color32 = Color32::from_rgb(214, 219, 228);
/// Serializable editor state, including camera and session-only graph placement.
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
pub struct Model {
title: String,
mode: Mode,
name: String,
triggers: Vec<TriggerRow>,
stages: Vec<StageRow>,
text: String,
parse_error: Option<String>,
#[serde(default)]
pub nav: facett_core::nav::Navigable,
#[serde(default)]
pub fitted: bool,
#[serde(default)]
pub selected: Option<String>,
#[serde(default)]
pub selected_edge: Option<(String, String)>,
#[serde(default)]
pub layout_overrides: std::collections::BTreeMap<String, [f32; 2]>,
#[serde(default)]
pub last_refusal: Option<String>,
#[serde(default)]
undo: Vec<HistoryEntry>,
#[serde(default)]
redo: Vec<HistoryEntry>,
#[serde(default)]
drag: Option<DragState>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
struct EditState {
name: String,
triggers: Vec<TriggerRow>,
stages: Vec<StageRow>,
text: String,
parse_error: Option<String>,
layout_overrides: std::collections::BTreeMap<String, [f32; 2]>,
}
/// History carries executable edits in both directions. Ordinary gestures retain
/// only the affected fields, rows, text span and/or placement entry. Arbitrary
/// TOML replacement alone needs the previous document (including invalid text).
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
struct HistoryEntry {
forward: Vec<EditCommand>,
inverse: Vec<EditCommand>,
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
enum StepField {
Name(String),
Needs(String),
Choice(RunChoice),
Variant(String),
Image(String),
Cmd(String),
Vm(String),
Artifacts(String),
Report(String),
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
enum EditCommand {
Name(String),
Triggers {
at: usize,
remove: usize,
insert: Vec<TriggerRow>,
},
Stages {
at: usize,
remove: usize,
insert: Vec<StageRow>,
},
StageName {
stage: usize,
name: String,
},
Steps {
stage: usize,
at: usize,
remove: usize,
insert: Vec<StepRow>,
},
Step {
stage: usize,
step: usize,
field: StepField,
},
Text {
at: usize,
remove: usize,
insert: String,
},
ParseError(Option<String>),
Placement {
node: String,
position: Option<[f32; 2]>,
},
Document(Box<EditState>),
}
/// The minimal contiguous list replacement, used for inserting/removing rows.
fn row_splice<T: PartialEq + Clone>(before: &[T], after: &[T]) -> (usize, usize, Vec<T>) {
let prefix = before.iter().zip(after).take_while(|(a, b)| a == b).count();
let suffix = before[prefix..]
.iter()
.rev()
.zip(after[prefix..].iter().rev())
.take_while(|(a, b)| a == b)
.count();
(
prefix,
before.len() - prefix - suffix,
after[prefix..after.len() - suffix].to_vec(),
)
}
impl HistoryEntry {
fn between(before: &EditState, after: &EditState, document: bool) -> Self {
if document {
Self {
forward: vec![EditCommand::Document(Box::new(after.clone()))],
inverse: vec![EditCommand::Document(Box::new(before.clone()))],
}
} else {
Self {
forward: Self::commands(before, after),
inverse: Self::commands(after, before),
}
}
}
fn commands(before: &EditState, after: &EditState) -> Vec<EditCommand> {
let mut edits = Vec::new();
if before.name != after.name {
edits.push(EditCommand::Name(after.name.clone()));
}
if before.triggers != after.triggers {
let (at, remove, insert) = row_splice(&before.triggers, &after.triggers);
edits.push(EditCommand::Triggers { at, remove, insert });
}
if before.stages.len() != after.stages.len() {
let (at, remove, insert) = row_splice(&before.stages, &after.stages);
edits.push(EditCommand::Stages { at, remove, insert });
} else {
for (stage, (old, new)) in before.stages.iter().zip(&after.stages).enumerate() {
if old.name != new.name {
edits.push(EditCommand::StageName {
stage,
name: new.name.clone(),
});
}
if old.steps.len() != new.steps.len() {
let (at, remove, insert) = row_splice(&old.steps, &new.steps);
edits.push(EditCommand::Steps {
stage,
at,
remove,
insert,
});
} else {
for (step, (a, b)) in old.steps.iter().zip(&new.steps).enumerate() {
let mut fields = Vec::new();
if a.name != b.name {
fields.push(StepField::Name(b.name.clone()));
}
if a.needs_raw != b.needs_raw {
fields.push(StepField::Needs(b.needs_raw.clone()));
}
if a.choice != b.choice {
fields.push(StepField::Choice(b.choice));
}
if a.variant != b.variant {
fields.push(StepField::Variant(b.variant.clone()));
}
if a.image != b.image {
fields.push(StepField::Image(b.image.clone()));
}
if a.cmd_raw != b.cmd_raw {
fields.push(StepField::Cmd(b.cmd_raw.clone()));
}
if a.vm != b.vm {
fields.push(StepField::Vm(b.vm.clone()));
}
if a.artifacts_raw != b.artifacts_raw {
fields.push(StepField::Artifacts(b.artifacts_raw.clone()));
}
if a.report != b.report {
fields.push(StepField::Report(b.report.clone()));
}
edits.extend(fields.into_iter().map(|field| EditCommand::Step {
stage,
step,
field,
}));
}
}
}
}
if before.text != after.text {
// Preserve exact original TOML, including comments and whitespace,
// without retaining the entire text for every structural edit.
let mut prefix = before
.text
.bytes()
.zip(after.text.bytes())
.take_while(|(a, b)| a == b)
.count();
while !before.text.is_char_boundary(prefix) || !after.text.is_char_boundary(prefix) {
prefix -= 1;
}
let mut suffix = before.text.as_bytes()[prefix..]
.iter()
.rev()
.zip(after.text.as_bytes()[prefix..].iter().rev())
.take_while(|(a, b)| a == b)
.count();
while !before.text.is_char_boundary(before.text.len() - suffix)
|| !after.text.is_char_boundary(after.text.len() - suffix)
{
suffix -= 1;
}
edits.push(EditCommand::Text {
at: prefix,
remove: before.text.len() - prefix - suffix,
insert: after.text[prefix..after.text.len() - suffix].into(),
});
}
if before.parse_error != after.parse_error {
edits.push(EditCommand::ParseError(after.parse_error.clone()));
}
for node in before
.layout_overrides
.keys()
.chain(after.layout_overrides.keys())
.collect::<std::collections::BTreeSet<_>>()
{
if before.layout_overrides.get(node) != after.layout_overrides.get(node) {
edits.push(EditCommand::Placement {
node: node.clone(),
position: after.layout_overrides.get(node).copied(),
});
}
}
edits
}
}
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
enum DragState {
Connect {
from: String,
pointer: [f32; 2],
},
Move {
node: String,
origin: [f32; 2],
pointer: [f32; 2],
current: [f32; 2],
},
}
/// Every programmatic and graphical edit follows this reducer.
#[derive(Clone, Debug)]
#[non_exhaustive]
pub enum Msg {
SetMode(Mode),
SetName(String),
AddStage(String),
RemoveStage(usize),
AddStep {
stage: usize,
name: String,
run: RunKind,
},
RemoveStep {
stage: usize,
step: usize,
},
SetStepNeeds {
stage: usize,
step: usize,
needs: Vec<String>,
},
AddTrigger(Trigger),
RemoveTrigger(usize),
SetText(String),
/// The structural/text editor emits its edited draft through the same reducer.
Edit(Box<Model>),
Connect {
from: String,
to: String,
},
Disconnect {
from: String,
to: String,
},
MoveNode {
node: String,
position: [f32; 2],
},
SelectNode(String),
SelectEdge(String, String),
Pan(egui::Vec2),
Zoom {
factor: f32,
anchor: egui::Pos2,
},
Fit(egui::Rect),
RequestRefit,
Undo,
Redo,
/// Begin dragging a connection from an output port.
BeginConnect(String),
/// Begin moving a node, retaining the pointer offset inside its box.
BeginMove {
node: String,
pointer: [f32; 2],
},
DragPointer([f32; 2]),
EndDrag {
target: Option<String>,
},
CancelDrag,
}
#[derive(Clone, Debug)]
pub enum Effect {}
/// The brokkr pipeline builder facet.
#[derive(Clone)]
pub struct PipelineBuilder {
model: Model,
scene: std::sync::Arc<facett_flow::FlowScene>,
subtitles: std::sync::Arc<Vec<String>>,
}
impl PipelineBuilder {
/// An empty builder titled `title` (no stages, no triggers), in CLICK mode.
pub fn new(title: impl Into<String>) -> Self {
let mut b = Self {
model: Model {
title: title.into(),
mode: Mode::Build,
name: String::new(),
triggers: Vec::new(),
stages: Vec::new(),
text: String::new(),
parse_error: None,
nav: Default::default(),
fitted: false,
selected: None,
selected_edge: None,
layout_overrides: Default::default(),
last_refusal: None,
undo: Vec::new(),
redo: Vec::new(),
drag: None,
},
subtitles: Default::default(),
scene: std::sync::Arc::new(
facett_flow::FlowScene::new(facett_flow::Graph {
nodes: Vec::new(),
edges: Vec::new(),
})
.expect("empty graph"),
),
};
b.model_to_text();
b
}
/// A builder seeded from a `brokkr.toml` document (structure adopted into the
/// CLICK rows; the text buffer set verbatim). Falls back to an empty pipeline
/// if the text does not parse.
pub fn from_toml_str(title: impl Into<String>, toml_text: &str) -> Self {
let mut b = Self::new(title);
b.set_text(toml_text);
// Keep the exact source text if it parsed cleanly; else the model-derived text.
if b.model.parse_error.is_none() {
b.model.text = toml_text.to_string();
}
b
}
/// A ready-made demo pipeline (build → test → ship) so the surface is never
/// blank — the same shape the dwarves Brokkr tab ships.
pub fn demo(title: impl Into<String>) -> Self {
Self::from_pipeline(title, demo_pipeline())
}
/// A builder adopting an in-memory [`Pipeline`].
pub fn from_pipeline(title: impl Into<String>, p: Pipeline) -> Self {
let mut b = Self::new(title);
b.adopt(&p);
b.model_to_text();
b.rebuild_scene();
b
}
fn adopt(&mut self, p: &Pipeline) {
self.model.name = p.name.clone();
self.model.triggers = p.triggers.iter().map(TriggerRow::from_trigger).collect();
self.model.stages = p.stages.iter().map(StageRow::from_stage).collect();
}
// ── mode ──────────────────────────────────────────────────────────────────
/// The active authoring mode.
pub fn mode(&self) -> Mode {
self.model.mode
}
/// Switch the active authoring mode. Leaving TEXT re-reads the buffer so the
/// rows reflect the latest text.
fn set_mode_inner(&mut self, mode: Mode) {
if self.model.mode == Mode::Text && mode != Mode::Text {
self.sync_from_text();
}
self.model.mode = mode;
}
// ── programmatic edits (host + robot/test drive) ───────────────────────────
/// Set the pipeline name.
fn set_name_inner(&mut self, name: impl Into<String>) {
self.model.name = name.into();
self.model_to_text();
}
/// Append a stage; returns its index.
fn add_stage_inner(&mut self, name: impl Into<String>) -> usize {
self.model.stages.push(StageRow {
name: name.into(),
steps: Vec::new(),
});
self.model_to_text();
self.model.stages.len() - 1
}
/// Remove the stage at `index` (no-op if out of range).
fn remove_stage_inner(&mut self, index: usize) {
if index < self.model.stages.len() {
self.model.stages.remove(index);
self.model_to_text();
}
}
/// Append a step to stage `stage`; returns its index (no-op → `None` if the
/// stage is out of range).
fn add_step_inner(
&mut self,
stage: usize,
name: impl Into<String>,
run: RunKind,
) -> Option<usize> {
let row = self.model.stages.get_mut(stage)?;
let mut step = StepRow {
name: name.into(),
..Default::default()
};
match run {
RunKind::Variant(v) => {
step.choice = RunChoice::Variant;
step.variant = v;
}
RunKind::Container(c) => {
step.choice = RunChoice::Container;
step.image = c.name;
step.cmd_raw = c.cmd.join(" ");
}
RunKind::Vm(v) => {
step.choice = RunChoice::Vm;
step.vm = v;
}
}
row.steps.push(step);
let idx = row.steps.len() - 1;
self.model_to_text();
Some(idx)
}
/// Remove step `step` from stage `stage` (no-op if out of range).
fn remove_step_inner(&mut self, stage: usize, step: usize) {
if let Some(s) = self.model.stages.get_mut(stage)
&& step < s.steps.len()
{
s.steps.remove(step);
self.model_to_text();
}
}
/// Set the `needs` (dependency) list of step `step` in stage `stage`.
fn set_step_needs_inner(
&mut self,
stage: usize,
step: usize,
needs: impl IntoIterator<Item = impl Into<String>>,
) {
if let Some(s) = self
.model
.stages
.get_mut(stage)
.and_then(|st| st.steps.get_mut(step))
{
s.needs_raw = needs
.into_iter()
.map(Into::into)
.collect::<Vec<String>>()
.join(", ");
self.model_to_text();
}
}
/// Append a trigger; returns its index.
fn add_trigger_inner(&mut self, trigger: Trigger) -> usize {
self.model.triggers.push(TriggerRow::from_trigger(&trigger));
self.model_to_text();
self.model.triggers.len() - 1
}
/// Remove the trigger at `index` (no-op if out of range).
fn remove_trigger_inner(&mut self, index: usize) {
if index < self.model.triggers.len() {
self.model.triggers.remove(index);
self.model_to_text();
}
}
/// Replace the raw `brokkr.toml` text (the TEXT-mode edit). Re-parses: on
/// success the CLICK rows are rebuilt; on failure the rows are kept and
/// [`state_json`](Self::state_json)`["parse_error"]` names the problem.
fn set_text_inner(&mut self, text: impl Into<String>) {
self.model.text = text.into();
self.sync_from_text();
}
/// The current `brokkr.toml` text (the buffer in TEXT mode; the model-derived
/// serialisation otherwise). Empty stages/steps are still emitted so the
/// document is a faithful picture of the in-progress build.
pub fn text(&self) -> &str {
&self.model.text
}
// ── outputs (the host reads these) ─────────────────────────────────────────
/// The edited pipeline as a clean [`Pipeline`].
pub fn to_pipeline(&self) -> Pipeline {
Pipeline {
name: self.model.name.trim().to_string(),
triggers: self
.model
.triggers
.iter()
.map(TriggerRow::to_trigger)
.collect(),
stages: self.model.stages.iter().map(StageRow::to_stage).collect(),
}
}
/// The pipeline serialised as a `brokkr.toml` document — the string a host
/// hands to brokkr's real parser to `validate` / `run`.
pub fn pipeline_toml(&self) -> String {
if self.model.mode == Mode::Text {
self.model.text.clone()
} else {
pipeline_to_toml(&self.to_pipeline())
}
}
/// Structural validation — the same rejections brokkr's descriptor validation
/// makes (unknown `needs`, duplicate step names, empty argv/image, cron with no
/// expression, an ungated webhook/poll …), accumulated.
pub fn validate(&self) -> Validation {
validate_pipeline(&self.to_pipeline())
}
// ── facett Facet contract ──────────────────────────────────────────────────
/// The facet title.
pub fn title(&self) -> &str {
&self.model.title
}
/// The number of stages / steps / triggers currently modelled.
pub fn stage_count(&self) -> usize {
self.model.stages.len()
}
/// The total number of steps across all stages.
pub fn step_count(&self) -> usize {
self.model.stages.iter().map(|s| s.steps.len()).sum()
}
/// Observable state as JSON:
/// ```json
/// { "title": "..", "mode": "Build",
/// "name": "..", "stage_count": 3, "step_count": 4, "trigger_count": 1,
/// "pipeline": { "name": "..", "stage": [ … ], "trigger": [ … ] },
/// "toml": "…brokkr.toml…",
/// "valid": true, "errors": [], "parse_error": null }
/// ```
pub fn state_json(&self) -> serde_json::Value {
let v = self.validate();
serde_json::json!({
"title": self.model.title,
"mode": format!("{:?}", self.model.mode),
"name": self.to_pipeline().name,
"stage_count": self.stage_count(),
"step_count": self.step_count(),
"trigger_count": self.model.triggers.len(),
"pipeline": self.to_pipeline(),
"toml": self.pipeline_toml(),
"valid": v.valid,
"errors": v.errors,
"parse_error": self.model.parse_error,
"nav": self.model.nav, "fitted": self.model.fitted,
"selected": self.model.selected, "selected_edge": self.model.selected_edge,
"layout_overrides": self.model.layout_overrides,
"last_refusal": self.model.last_refusal,
"undo_count": self.model.undo.len(), "redo_count": self.model.redo.len(),
})
}
// ── internals ──────────────────────────────────────────────────────────────
/// Regenerate the text buffer from the current CLICK rows.
fn model_to_text(&mut self) {
self.model.text = pipeline_to_toml(&self.to_pipeline());
self.model.parse_error = None;
}
/// Parse the text buffer into the CLICK rows (TEXT-mode edit). On failure keep
/// the rows and record the parse error.
fn sync_from_text(&mut self) {
match toml::from_str::<PipelineFile>(&self.model.text) {
Ok(file) => {
self.adopt(&file.pipeline);
self.model.parse_error = None;
}
Err(e) => {
self.model.parse_error = Some(e.message().to_string());
}
}
}
// ── rendering ──────────────────────────────────────────────────────────────
/// Draw the builder (the facett `ui` contract).
fn draft_ui(&mut self, ui: &mut Ui) -> Vec<Msg> {
let mut messages = Vec::new();
// ── mode toggles + validity chip ────────────────────────────────────
ui.horizontal(|ui| {
let mut mode = self.model.mode;
ui.selectable_value(&mut mode, Mode::Text, "✎ Text");
ui.selectable_value(&mut mode, Mode::Build, "⚙ Build");
ui.selectable_value(&mut mode, Mode::Graph, "◈ Graph");
if mode != self.model.mode {
self.set_mode(mode);
}
ui.separator();
let v = self.validate();
if let Some(pe) = &self.model.parse_error {
ui.label(
egui::RichText::new(format!("✖ parse: {pe}"))
.small()
.color(ERR),
);
} else if v.valid {
ui.label(
egui::RichText::new(format!(
"✔ valid · {} stage(s) · {} step(s)",
self.stage_count(),
self.step_count()
))
.small()
.color(OK),
);
} else {
ui.label(
egui::RichText::new(format!("✖ {} error(s)", v.errors.len()))
.small()
.color(ERR),
);
}
});
ui.separator();
match self.model.mode {
Mode::Text => self.text_ui(ui),
Mode::Build => self.build_ui(ui),
Mode::Graph => messages.extend(self.graph_ui(ui)),
}
// Surface the accumulated validation errors under every mode.
let v = self.validate();
if !v.valid {
ui.add_space(4.0);
for e in &v.errors {
ui.label(egui::RichText::new(format!("• {e}")).small().color(ERR));
}
}
messages
}
fn text_ui(&mut self, ui: &mut Ui) {
ui.label(egui::RichText::new("brokkr.toml").strong().color(ACCENT));
let resp = egui::ScrollArea::vertical().show(ui, |ui| {
ui.add(
egui::TextEdit::multiline(&mut self.model.text)
.code_editor()
.desired_width(f32::INFINITY)
.desired_rows(16)
.hint_text("[pipeline]\nname = \"my-ci\"\n…"),
)
});
if resp.inner.changed() {
self.sync_from_text();
}
}
fn build_ui(&mut self, ui: &mut Ui) {
egui::ScrollArea::vertical().show(ui, |ui| {
// Pipeline name.
ui.horizontal(|ui| {
ui.label("pipeline");
let r = ui.add(
egui::TextEdit::singleline(&mut self.model.name)
.hint_text("pipeline name")
.desired_width(220.0),
);
if r.changed() {
self.model_to_text();
}
});
ui.add_space(4.0);
// ── Triggers ──────────────────────────────────────────────────
ui.label(egui::RichText::new("Triggers").strong());
let mut remove_trig: Option<usize> = None;
let mut changed = false;
for (i, t) in self.model.triggers.iter_mut().enumerate() {
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.horizontal(|ui| {
egui::ComboBox::from_id_salt(("trig_kind", i))
.selected_text(t.kind.as_str())
.show_ui(ui, |ui| {
for k in TriggerKind::ALL {
if ui.selectable_value(&mut t.kind, k, k.as_str()).changed() {
changed = true;
}
}
});
if ui.button("✕ Remove trigger").clicked() {
remove_trig = Some(i);
}
});
ui.horizontal(|ui| {
ui.label("events");
if ui
.add(
egui::TextEdit::singleline(&mut t.events_raw)
.hint_text("push, pull_request")
.desired_width(180.0),
)
.changed()
{
changed = true;
}
ui.label("branch");
if ui
.add(
egui::TextEdit::singleline(&mut t.branch)
.hint_text("main")
.desired_width(120.0),
)
.changed()
{
changed = true;
}
});
if t.kind == TriggerKind::Cron {
ui.horizontal(|ui| {
ui.label("cron");
if ui
.add(
egui::TextEdit::singleline(&mut t.cron)
.hint_text("0 3 * * *")
.desired_width(160.0),
)
.changed()
{
changed = true;
}
});
}
});
}
if ui.button("+ Add trigger").clicked() {
self.model.triggers.push(TriggerRow::default());
changed = true;
}
if let Some(i) = remove_trig {
self.model.triggers.remove(i);
changed = true;
}
ui.add_space(6.0);
ui.separator();
// ── Stages / steps ────────────────────────────────────────────
ui.label(egui::RichText::new("Stages").strong());
let mut remove_stage: Option<usize> = None;
let mut remove_step: Option<(usize, usize)> = None;
for (si, stage) in self.model.stages.iter_mut().enumerate() {
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.horizontal(|ui| {
ui.label(
egui::RichText::new(format!("▸ stage {}", si + 1))
.strong()
.color(ACCENT),
);
if ui
.add(
egui::TextEdit::singleline(&mut stage.name)
.hint_text("stage name")
.desired_width(180.0),
)
.changed()
{
changed = true;
}
if ui.button("✕ Remove stage").clicked() {
remove_stage = Some(si);
}
});
for (ti, step) in stage.steps.iter_mut().enumerate() {
egui::Frame::group(ui.style()).show(ui, |ui| {
ui.horizontal(|ui| {
ui.label("step");
if ui
.add(
egui::TextEdit::singleline(&mut step.name)
.hint_text("step name")
.desired_width(150.0),
)
.changed()
{
changed = true;
}
ui.label("needs");
if ui
.add(
egui::TextEdit::singleline(&mut step.needs_raw)
.hint_text("upstream steps")
.desired_width(180.0),
)
.changed()
{
changed = true;
}
if ui.button("✕ Remove step").clicked() {
remove_step = Some((si, ti));
}
});
ui.horizontal(|ui| {
ui.label("run");
egui::ComboBox::from_id_salt(("run_kind", si, ti))
.selected_text(match step.choice {
RunChoice::Variant => "variant",
RunChoice::Container => "container",
RunChoice::Vm => "vm",
})
.show_ui(ui, |ui| {
for (c, lbl) in [
(RunChoice::Variant, "variant"),
(RunChoice::Container, "container"),
(RunChoice::Vm, "vm"),
] {
if ui
.selectable_value(&mut step.choice, c, lbl)
.changed()
{
changed = true;
}
}
});
match step.choice {
RunChoice::Variant => {
if ui
.add(
egui::TextEdit::singleline(&mut step.variant)
.hint_text("variant name")
.desired_width(160.0),
)
.changed()
{
changed = true;
}
}
RunChoice::Container => {
if ui
.add(
egui::TextEdit::singleline(&mut step.image)
.hint_text("image")
.desired_width(140.0),
)
.changed()
{
changed = true;
}
if ui
.add(
egui::TextEdit::singleline(&mut step.cmd_raw)
.hint_text("argv, space-separated")
.desired_width(200.0),
)
.changed()
{
changed = true;
}
}
RunChoice::Vm => {
if ui
.add(
egui::TextEdit::singleline(&mut step.vm)
.hint_text("vm image")
.desired_width(160.0),
)
.changed()
{
changed = true;
}
}
}
});
});
}
if ui.button("+ Add step").clicked() {
stage.steps.push(StepRow::default());
changed = true;
}
});
}
if ui.button("+ Add stage").clicked() {
self.model.stages.push(StageRow::default());
changed = true;
}
if let Some(i) = remove_stage {
self.model.stages.remove(i);
changed = true;
}
if let Some((si, ti)) = remove_step {
if let Some(s) = self.model.stages.get_mut(si)
&& ti < s.steps.len()
{
s.steps.remove(ti);
}
changed = true;
}
if changed {
self.model_to_text();
}
});
}
/// The topo DAG preview: steps laid out in columns by longest-path depth over
/// the `needs` edges, with dependency edges drawn. Self-contained painter.
/// Cached graph generation: camera and drag preview updates leave it unchanged.
pub fn scene_generation(&self) -> u64 {
self.scene.generation()
}
pub fn graph_bounds(&self) -> egui::Rect {
self.scene.bounds()
}
/// Node rectangle in world coordinates, useful to hosts and input replay.
pub fn node_rect(&self, name: &str) -> Option<egui::Rect> {
let i = self
.scene
.graph()
.nodes
.iter()
.position(|n| n.label == name)?;
self.scene.node_rect(i)
}
fn rebuild_scene(&mut self) {
let p = self.to_pipeline();
let steps: Vec<_> = p
.stages
.iter()
.flat_map(|st| st.steps.iter().map(move |s| (st, s)))
.collect();
let names: std::collections::BTreeMap<_, _> = steps
.iter()
.enumerate()
.map(|(i, (_, s))| (s.name.as_str(), i as u32))
.collect();
let edges: Vec<_> = steps
.iter()
.enumerate()
.flat_map(|(i, (_, s))| {
s.needs
.iter()
.filter_map(|n| names.get(n.as_str()).map(|&a| (a, i as u32)))
.collect::<Vec<_>>()
})
.collect();
let layout = facett_core::layered::layered(steps.len(), &edges, Default::default());
let positions = facett_core::layered::place(&layout, [240.0, 90.0], [0.0, 0.0]);
let graph = facett_flow::Graph {
nodes: steps
.iter()
.enumerate()
.map(|(i, (st, s))| facett_flow::Node {
id: format!("step{i}"),
label: s.name.clone(),
position: self
.model
.layout_overrides
.get(&s.name)
.copied()
.unwrap_or(positions[i]),
group: if st.name.trim().is_empty() {
"steps".into()
} else {
st.name.clone()
},
kind: 1,
})
.collect(),
edges: edges
.iter()
.map(|(a, b)| facett_flow::Edge {
from: format!("step{a}"),
to: format!("step{b}"),
})
.collect(),
};
self.subtitles = std::sync::Arc::new(
steps
.iter()
.map(|(stage, step)| format!("{} · {}", stage.name, step.run.backend()))
.collect(),
);
let errors = validate_pipeline(&p).errors;
let scene = std::sync::Arc::make_mut(&mut self.scene);
scene.replace(graph).expect("bounded editor graph");
scene
.set_sizes(facett_flow::attr::Sizes {
nodes: vec![[170.0, 56.0]; steps.len()],
})
.expect("node sizes");
scene.set_arrows(true);
scene
.set_status(facett_flow::attr::StatusTable {
nodes: steps
.iter()
.map(|(_, s)| {
(s.name.is_empty()
|| errors.iter().any(|e| e.contains(&format!("'{}'", s.name))))
.then_some(facett_flow::attr::Status::Error)
})
.collect(),
edges: vec![None; edges.len()],
})
.expect("status ordering");
}
fn graph_ui(&self, ui: &mut Ui) -> Vec<Msg> {
use facett_core::a11y::{Semantics, node};
use facett_core::nav::NavGesture;
use facett_flow::Hit;
let mut msgs = Vec::new();
ui.horizontal(|ui| {
if ui.button("Fit").clicked() {
msgs.push(Msg::RequestRefit);
}
if ui
.add_enabled(!self.model.undo.is_empty(), egui::Button::new("Undo"))
.clicked()
{
msgs.push(Msg::Undo);
}
if ui
.add_enabled(!self.model.redo.is_empty(), egui::Button::new("Redo"))
.clicked()
{
msgs.push(Msg::Redo);
}
ui.weak(
"Drag output → input to connect; drag a box to move; select an edge and Delete.",
);
});
if let Some(ref refusal) = self.model.last_refusal {
ui.colored_label(ERR, refusal);
}
if self.scene.graph().nodes.is_empty() {
ui.weak("No steps yet — add a stage and a step in Build mode.");
return msgs;
}
let (rect, resp) = ui.allocate_exact_size(
egui::vec2(
ui.available_width().max(240.0),
ui.available_height().max(180.0),
),
egui::Sense::click_and_drag(),
);
let mut nav = self.model.nav;
if !self.model.fitted {
nav.fit(self.scene.bounds(), rect, 0.08);
msgs.push(Msg::Fit(rect));
}
let view = facett_flow::View::from_navigable(&nav, rect);
let painted = facett_flow::paint(
ui,
ui.id().with("brokkr-flow"),
rect,
&self.scene,
&view,
&facett_flow::PaintOpts {
animation: facett_flow::Animation::None,
cpu: facett_flow::CpuStyle::Full {
max_edges: usize::MAX,
},
..Default::default()
},
);
let painter = ui.painter_at(rect);
if let Some((from, to)) = &self.model.selected_edge {
let graph = self.scene.graph();
let source = graph.nodes.iter().find(|n| &n.label == from).map(|n| &n.id);
let target = graph.nodes.iter().find(|n| &n.label == to).map(|n| &n.id);
if let Some(i) = graph
.edges
.iter()
.position(|e| Some(&e.from) == source && Some(&e.to) == target)
{
let points = (0..=16)
.filter_map(|j| self.scene.edge_point(i, j as f32 / 16.0))
.map(|p| view.screen(p))
.collect();
painter.add(egui::Shape::line(points, egui::Stroke::new(2.5, ACCENT)));
}
}
let mut active = resp.hovered() || resp.has_focus();
for shown in &painted.visible_nodes {
let n = &self.scene.graph().nodes[shown.node];
let body = node(
ui,
ui.id().with("step"),
&n.label,
egui::Sense::click_and_drag(),
shown.rect.intersect(rect),
Semantics::button(format!("Step {}", n.label))
.selected(self.model.selected.as_deref() == Some(&n.label)),
);
active |= body.hovered() || body.has_focus();
if body.clicked() {
msgs.push(Msg::SelectNode(n.label.clone()));
}
if body.drag_started()
&& let Some(pointer) = ui.input(|i| i.pointer.press_origin())
{
msgs.push(Msg::BeginMove {
node: n.label.clone(),
pointer: view.world(pointer).into(),
});
}
for (output, point) in [
(false, shown.rect.left_center()),
(true, shown.rect.right_center()),
] {
let r = egui::Rect::from_center_size(point, egui::Vec2::splat(18.0));
if !r.intersects(rect) {
continue;
}
let port = node(
ui,
ui.id().with(if output { "output" } else { "input" }),
&n.label,
egui::Sense::click_and_drag(),
r.intersect(rect),
Semantics::button(format!(
"{} {}",
if output { "Output" } else { "Input" },
n.label
)),
);
active |= port.hovered() || port.has_focus();
painter.circle_filled(point, 4.5, ACCENT);
if output && port.drag_started() {
msgs.push(Msg::BeginConnect(n.label.clone()));
}
// Keyboard/AccessKit activation supports the same connection gesture.
if port.clicked() {
if output {
msgs.push(Msg::BeginConnect(n.label.clone()));
} else if matches!(self.model.drag, Some(DragState::Connect { .. })) {
msgs.push(Msg::EndDrag {
target: Some(n.label.clone()),
});
}
}
}
if self.model.selected.as_deref() == Some(&n.label) {
painter.rect_stroke(
shown.rect,
5.0,
egui::Stroke::new(2.0, ACCENT),
egui::StrokeKind::Outside,
);
}
}
for shown in &painted.on_screen {
let n = &self.scene.graph().nodes[shown.node];
let label_painter = painter.with_clip_rect(shown.rect.intersect(rect));
label_painter.text(
shown.rect.center() - egui::vec2(0.0, 7.0 * view.scale.min(1.5)),
egui::Align2::CENTER_CENTER,
&n.label,
egui::FontId::proportional((13.0 * view.scale).clamp(9.0, 18.0)),
TEXT_COL,
);
if shown.rect.height() >= 30.0 {
label_painter.text(
shown.rect.center() + egui::vec2(0.0, 10.0 * view.scale.min(1.5)),
egui::Align2::CENTER_CENTER,
&self.subtitles[shown.node],
egui::FontId::proportional((10.0 * view.scale).clamp(8.0, 13.0)),
facett_core::theme(ui).edge,
);
}
}
if resp.clicked()
&& let Some(Hit::Edge(i)) = painted.hover
{
let edge = &self.scene.graph().edges[i];
let label = |id: &str| {
self.scene
.graph()
.nodes
.iter()
.find(|n| n.id == id)
.unwrap()
.label
.clone()
};
msgs.push(Msg::SelectEdge(label(&edge.from), label(&edge.to)));
resp.request_focus();
}
for gesture in facett_core::nav::gestures(ui, rect, &resp) {
match gesture {
NavGesture::Pan(d)
if self.model.drag.is_none()
&& !msgs
.iter()
.any(|m| matches!(m, Msg::BeginConnect(_) | Msg::BeginMove { .. })) =>
{
msgs.push(Msg::Pan(d))
}
NavGesture::Zoom { factor, anchor } if self.model.drag.is_none() => {
msgs.push(Msg::Zoom { factor, anchor })
}
NavGesture::Fit => msgs.push(Msg::Fit(rect)),
_ => {}
}
}
if let Some(drag) = &self.model.drag
&& let Some(pointer) = ui.input(|i| i.pointer.interact_pos())
{
let world = view.world(pointer);
msgs.push(Msg::DragPointer(world.into()));
match drag {
DragState::Connect { from, .. } => {
if let Some(r) = self.node_rect(from) {
painter.line_segment(
[view.screen(r.right_center()), pointer],
egui::Stroke::new(2.0, ACCENT),
);
}
}
DragState::Move {
node,
origin,
pointer: offset,
..
} => {
if let Some(r) = self.node_rect(node) {
let center = world - egui::Vec2::from(*offset) + egui::Vec2::from(*origin);
let ghost = egui::Rect::from_center_size(
view.screen(center),
r.size() * view.scale,
);
painter.rect_stroke(
ghost,
5.0,
egui::Stroke::new(2.0, ACCENT),
egui::StrokeKind::Outside,
);
}
}
}
if ui.input(|i| i.pointer.any_released()) {
let target = self
.scene
.hit(world, 10.0 / view.scale)
.and_then(|h| match h {
Hit::Node(i) => Some(self.scene.graph().nodes[i].label.clone()),
_ => None,
});
msgs.push(Msg::EndDrag { target });
}
}
if active && !ui.ctx().text_edit_focused() {
let km = facett_core::look::keymap(ui);
if km.consume(facett_core::look::Action::Undo, ui) {
msgs.push(Msg::Undo);
}
if km.consume(facett_core::look::Action::Redo, ui) {
msgs.push(Msg::Redo);
}
if km.consume(facett_core::look::Action::Cancel, ui) {
msgs.push(Msg::CancelDrag);
}
if ui.input_mut(|i| i.consume_key(egui::Modifiers::NONE, egui::Key::Delete))
&& let Some((from, to)) = &self.model.selected_edge
{
msgs.push(Msg::Disconnect {
from: from.clone(),
to: to.clone(),
});
}
}
msgs
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Free functions: parsing, serialisation, validation, layout.
// ─────────────────────────────────────────────────────────────────────────────
/// Parse a comma/whitespace-separated line into unique, trimmed names.
fn parse_list(raw: &str) -> Vec<String> {
let mut out: Vec<String> = Vec::new();
for tok in raw.split(|c: char| c == ',' || c.is_whitespace()) {
let t = tok.trim();
if !t.is_empty() && !out.iter().any(|s| s == t) {
out.push(t.to_string());
}
}
out
}
/// Split an argv line on whitespace/commas, KEEPING duplicates (an argv can repeat
/// a token, unlike a dep set).
fn split_tokens(raw: &str) -> Vec<String> {
raw.split(|c: char| c == ',' || c.is_whitespace())
.map(str::trim)
.filter(|t| !t.is_empty())
.map(str::to_string)
.collect()
}
/// TOML basic-string escape.
fn esc(s: &str) -> String {
let mut o = String::with_capacity(s.len() + 2);
for c in s.chars() {
match c {
'\\' => o.push_str("\\\\"),
'"' => o.push_str("\\\""),
'\n' => o.push_str("\\n"),
'\t' => o.push_str("\\t"),
'\r' => o.push_str("\\r"),
_ => o.push(c),
}
}
o
}
fn q(s: &str) -> String {
format!("\"{}\"", esc(s))
}
fn arr(items: &[String]) -> String {
let inner: Vec<String> = items.iter().map(|s| q(s)).collect();
format!("[{}]", inner.join(", "))
}
fn run_inline(run: &RunKind) -> String {
match run {
RunKind::Variant(v) => format!("{{ variant = {} }}", q(v)),
RunKind::Vm(v) => format!("{{ vm = {} }}", q(v)),
RunKind::Container(c) => {
format!(
"{{ container = {{ name = {}, cmd = {} }} }}",
q(&c.name),
arr(&c.cmd)
)
}
}
}
/// Emit a `brokkr.toml` document — hand-written to match exactly the shape brokkr's
/// descriptor parser accepts (`run = { variant = … }` inline tables, `[[pipeline.
/// stage.step]]` array-of-tables), so a host round-trips it with brokkr's real
/// parser.
pub fn pipeline_to_toml(p: &Pipeline) -> String {
let mut s = String::new();
s.push_str("[pipeline]\n");
s.push_str(&format!("name = {}\n", q(&p.name)));
for t in &p.triggers {
s.push('\n');
s.push_str("[[pipeline.trigger]]\n");
s.push_str(&format!("kind = {}\n", q(t.kind.as_str())));
if !t.events.is_empty() {
s.push_str(&format!("events = {}\n", arr(&t.events)));
}
if let Some(b) = &t.branch {
s.push_str(&format!("branch = {}\n", q(b)));
}
if let Some(c) = &t.cron {
s.push_str(&format!("cron = {}\n", q(c)));
}
}
for stage in &p.stages {
s.push('\n');
s.push_str("[[pipeline.stage]]\n");
s.push_str(&format!("name = {}\n", q(&stage.name)));
for step in &stage.steps {
s.push_str("[[pipeline.stage.step]]\n");
s.push_str(&format!("name = {}\n", q(&step.name)));
if !step.needs.is_empty() {
s.push_str(&format!("needs = {}\n", arr(&step.needs)));
}
s.push_str(&format!("run = {}\n", run_inline(&step.run)));
if !step.artifacts.is_empty() {
s.push_str(&format!("artifacts = {}\n", arr(&step.artifacts)));
}
if let Some(r) = &step.report {
s.push_str(&format!("report = {}\n", q(r)));
}
}
}
s
}
/// Structural validation — the accumulated form of brokkr's descriptor validation.
pub fn validate_pipeline(p: &Pipeline) -> Validation {
let mut errors = Vec::new();
if p.name.trim().is_empty() {
errors.push("pipeline has no `name`".to_string());
}
// Collect + de-dup step names.
let mut names: HashSet<String> = HashSet::new();
let mut seen: HashSet<String> = HashSet::new();
let mut total = 0usize;
for stage in &p.stages {
if stage.name.trim().is_empty() {
errors.push("a stage has no `name`".to_string());
}
for step in &stage.steps {
total += 1;
if step.name.trim().is_empty() {
errors.push(format!("stage '{}' has a step with no `name`", stage.name));
} else if !seen.insert(step.name.clone()) {
errors.push(format!(
"duplicate step name '{}' (step names are the DAG node ids and must be unique)",
step.name
));
}
names.insert(step.name.clone());
match &step.run {
RunKind::Container(c) => {
if c.name.trim().is_empty() {
errors.push(format!(
"step '{}': container run has an empty image `name`",
step.name
));
}
if c.cmd.is_empty() {
errors.push(format!(
"step '{}': container run has an empty `cmd` argv (zero-shell needs an explicit argv)",
step.name
));
}
}
RunKind::Variant(v) => {
if v.trim().is_empty() {
errors.push(format!(
"step '{}': variant run names no variant",
step.name
));
}
}
RunKind::Vm(v) => {
if v.trim().is_empty() {
errors.push(format!("step '{}': vm run names no image", step.name));
}
}
}
}
}
if total == 0 {
errors.push(format!("pipeline '{}' declares no steps", p.name));
}
// Every `needs` must reference a declared step.
for stage in &p.stages {
for step in &stage.steps {
for need in &step.needs {
if need == &step.name {
errors.push(format!("step '{}' needs itself", step.name));
} else if !names.contains(need) {
errors.push(format!(
"step '{}' needs unknown step '{}'",
step.name, need
));
}
}
}
}
// Trigger sanity.
for t in &p.triggers {
match t.kind {
TriggerKind::Cron => {
if t.cron.as_deref().map(str::trim).unwrap_or("").is_empty() {
errors.push("a cron trigger has no `cron` expression".to_string());
}
}
TriggerKind::Webhook | TriggerKind::Poll => {
if t.events.is_empty() && t.branch.is_none() {
errors.push(format!(
"a {} trigger declares neither `events` nor a `branch` to gate on",
t.kind.as_str()
));
}
}
TriggerKind::Manual => {}
}
}
Validation {
valid: errors.is_empty(),
errors,
}
}
/// The shipped demo pipeline (build → test → ship) — the same shape the dwarves
/// Brokkr tab seeds, so the builder is never blank.
pub fn demo_pipeline() -> Pipeline {
Pipeline {
name: "nornir-ci".to_string(),
triggers: vec![Trigger {
kind: TriggerKind::Webhook,
events: vec!["push".to_string()],
branch: Some("main".to_string()),
cron: None,
}],
stages: vec![
Stage {
name: "build".to_string(),
steps: vec![Step {
name: "compile".to_string(),
needs: vec![],
run: RunKind::Variant("server".to_string()),
artifacts: vec!["target/release/nornir".to_string()],
report: None,
}],
},
Stage {
name: "test".to_string(),
steps: vec![
Step {
name: "unit".to_string(),
needs: vec!["compile".to_string()],
run: RunKind::Container(ContainerRun {
name: "rust:1.85".to_string(),
cmd: vec!["cargo".to_string(), "test".to_string()],
}),
artifacts: vec![],
report: Some("junit".to_string()),
},
Step {
name: "lint".to_string(),
needs: vec!["compile".to_string()],
run: RunKind::Container(ContainerRun {
name: "rust:1.85".to_string(),
cmd: vec!["cargo".to_string(), "clippy".to_string()],
}),
artifacts: vec![],
report: None,
},
],
},
Stage {
name: "ship".to_string(),
steps: vec![Step {
name: "release".to_string(),
needs: vec!["unit".to_string(), "lint".to_string()],
run: RunKind::Variant("server".to_string()),
artifacts: vec![],
report: None,
}],
},
],
}
}
impl PipelineBuilder {
pub fn state(&self) -> &Model {
&self.model
}
pub fn set_mode(&mut self, mode: Mode) {
self.update(Msg::SetMode(mode));
}
pub fn set_name(&mut self, name: impl Into<String>) {
self.update(Msg::SetName(name.into()));
}
pub fn add_stage(&mut self, name: impl Into<String>) -> usize {
let index = self.model.stages.len();
self.update(Msg::AddStage(name.into()));
index
}
pub fn remove_stage(&mut self, index: usize) {
self.update(Msg::RemoveStage(index));
}
pub fn add_step(
&mut self,
stage: usize,
name: impl Into<String>,
run: RunKind,
) -> Option<usize> {
let index = self.model.stages.get(stage)?.steps.len();
self.update(Msg::AddStep {
stage,
name: name.into(),
run,
});
Some(index)
}
pub fn remove_step(&mut self, stage: usize, step: usize) {
self.update(Msg::RemoveStep { stage, step });
}
pub fn set_step_needs(
&mut self,
stage: usize,
step: usize,
needs: impl IntoIterator<Item = impl Into<String>>,
) {
self.update(Msg::SetStepNeeds {
stage,
step,
needs: needs.into_iter().map(Into::into).collect(),
});
}
pub fn add_trigger(&mut self, trigger: Trigger) -> usize {
let index = self.model.triggers.len();
self.update(Msg::AddTrigger(trigger));
index
}
pub fn remove_trigger(&mut self, index: usize) {
self.update(Msg::RemoveTrigger(index));
}
pub fn set_text(&mut self, text: impl Into<String>) {
self.update(Msg::SetText(text.into()));
}
pub fn ui(&mut self, ui: &mut Ui) {
for msg in <Self as facett_core::Elm>::view(self, ui) {
self.update(msg);
}
}
fn edit_state(&self) -> EditState {
EditState {
name: self.model.name.clone(),
triggers: self.model.triggers.clone(),
stages: self.model.stages.clone(),
text: self.model.text.clone(),
parse_error: self.model.parse_error.clone(),
layout_overrides: self.model.layout_overrides.clone(),
}
}
fn restore_edit(&mut self, edit: EditState) {
self.model.name = edit.name;
self.model.triggers = edit.triggers;
self.model.stages = edit.stages;
self.model.text = edit.text;
self.model.parse_error = edit.parse_error;
self.model.layout_overrides = edit.layout_overrides;
self.model.selected_edge = None;
self.model.drag = None;
}
fn apply_commands(&mut self, commands: &[EditCommand]) {
for command in commands {
match command {
EditCommand::Name(name) => self.model.name.clone_from(name),
EditCommand::Triggers { at, remove, insert } => {
self.model
.triggers
.splice(*at..*at + *remove, insert.clone());
}
EditCommand::Stages { at, remove, insert } => {
self.model.stages.splice(*at..*at + *remove, insert.clone());
}
EditCommand::StageName { stage, name } => {
self.model.stages[*stage].name.clone_from(name)
}
EditCommand::Steps {
stage,
at,
remove,
insert,
} => {
self.model.stages[*stage]
.steps
.splice(*at..*at + *remove, insert.clone());
}
EditCommand::Step { stage, step, field } => {
let row = &mut self.model.stages[*stage].steps[*step];
match field {
StepField::Name(value) => row.name.clone_from(value),
StepField::Needs(value) => row.needs_raw.clone_from(value),
StepField::Choice(value) => row.choice = *value,
StepField::Variant(value) => row.variant.clone_from(value),
StepField::Image(value) => row.image.clone_from(value),
StepField::Cmd(value) => row.cmd_raw.clone_from(value),
StepField::Vm(value) => row.vm.clone_from(value),
StepField::Artifacts(value) => row.artifacts_raw.clone_from(value),
StepField::Report(value) => row.report.clone_from(value),
}
}
EditCommand::Text { at, remove, insert } => {
self.model.text.replace_range(*at..*at + *remove, insert)
}
EditCommand::ParseError(value) => self.model.parse_error.clone_from(value),
EditCommand::Placement { node, position } => {
if let Some(position) = position {
self.model.layout_overrides.insert(node.clone(), *position);
} else {
self.model.layout_overrides.remove(node);
}
}
EditCommand::Document(document) => self.restore_edit((**document).clone()),
}
}
self.model.selected_edge = None;
self.model.drag = None;
}
fn edit_connection(&mut self, from: String, to: String, connect: bool) {
let pipeline = self.to_pipeline();
let steps: Vec<_> = pipeline.stages.iter().flat_map(|s| &s.steps).collect();
let names: std::collections::BTreeMap<_, _> = steps
.iter()
.enumerate()
.map(|(i, s)| (s.name.as_str(), i as u32))
.collect();
if from == to
|| names.len() != steps.len()
|| !names.contains_key(from.as_str())
|| !names.contains_key(to.as_str())
{
self.model.last_refusal =
Some("Connections require two distinct, existing steps".into());
return;
}
let mut edges: Vec<_> = steps
.iter()
.enumerate()
.flat_map(|(i, s)| {
s.needs
.iter()
.filter_map(|need| names.get(need.as_str()).map(|&a| (a, i as u32)))
.collect::<Vec<_>>()
})
.collect();
if connect {
edges.push((names[from.as_str()], names[to.as_str()]));
if facett_core::layered::ranks(
steps.len(),
&edges,
facett_core::layered::Align::Sources,
)
.1
{
self.model.last_refusal = Some("Connection would create a dependency cycle".into());
return;
}
}
if let Some(step) = self
.model
.stages
.iter_mut()
.flat_map(|s| &mut s.steps)
.find(|s| s.name.trim() == to)
{
let mut needs = parse_list(&step.needs_raw);
if connect {
if !needs.contains(&from) {
needs.push(from);
}
} else {
needs.retain(|n| n != &from);
}
step.needs_raw = needs.join(", ");
self.model_to_text();
self.model.last_refusal = None;
}
}
pub fn update(&mut self, msg: Msg) -> Vec<Effect> {
let before = self.edit_state();
let history = !matches!(&msg, Msg::Undo | Msg::Redo);
let document = matches!(&msg, Msg::SetText(_))
|| matches!(&msg, Msg::Edit(draft) if draft.mode == Mode::Text && draft.text != self.model.text);
match msg {
Msg::SetMode(mode) => self.set_mode_inner(mode),
Msg::SetName(name) => self.set_name_inner(name),
Msg::AddStage(name) => {
self.add_stage_inner(name);
}
Msg::RemoveStage(index) => self.remove_stage_inner(index),
Msg::AddStep { stage, name, run } => {
self.add_step_inner(stage, name, run);
}
Msg::RemoveStep { stage, step } => self.remove_step_inner(stage, step),
Msg::SetStepNeeds { stage, step, needs } => {
self.set_step_needs_inner(stage, step, needs)
}
Msg::AddTrigger(trigger) => {
self.add_trigger_inner(trigger);
}
Msg::RemoveTrigger(index) => self.remove_trigger_inner(index),
Msg::SetText(text) => self.set_text_inner(text),
Msg::Edit(model) => {
// History belongs to this reducer; a draft cannot replace it.
let undo = std::mem::take(&mut self.model.undo);
let redo = std::mem::take(&mut self.model.redo);
self.model = *model;
self.model.undo = undo;
self.model.redo = redo;
}
Msg::Connect { from, to } => self.edit_connection(from, to, true),
Msg::Disconnect { from, to } => self.edit_connection(from, to, false),
Msg::MoveNode { node, position } => {
if position.iter().all(|v| v.is_finite() && v.abs() <= 1.0e7)
&& self
.model
.stages
.iter()
.flat_map(|s| &s.steps)
.any(|s| s.name.trim() == node)
{
self.model.layout_overrides.insert(node, position);
self.model.last_refusal = None;
} else {
self.model.last_refusal =
Some("Move requires an existing step and finite coordinates".into());
}
}
Msg::SelectNode(node) => {
if self
.model
.stages
.iter()
.flat_map(|s| &s.steps)
.any(|s| s.name.trim() == node)
{
self.model.selected = Some(node);
self.model.selected_edge = None;
}
}
Msg::SelectEdge(from, to) => {
self.model.selected_edge = Some((from, to));
self.model.selected = None;
}
Msg::Pan(delta) => {
if delta.is_finite() {
self.model.nav.pan(delta);
self.model.fitted = true;
}
}
Msg::Zoom { factor, anchor } => {
if factor.is_finite() && factor > 0.0 && anchor.is_finite() {
self.model.nav.zoom_to(factor, anchor);
self.model.fitted = true;
}
}
Msg::Fit(rect) => {
let bounds = self.graph_bounds();
self.model.nav.fit(bounds, rect, 0.08);
self.model.fitted = true;
}
Msg::RequestRefit => self.model.fitted = false,
Msg::BeginConnect(from) => {
self.model.drag = Some(DragState::Connect {
from,
pointer: [0.0; 2],
});
}
Msg::BeginMove { node, pointer } => {
if let Some(r) = self.node_rect(&node) {
self.model.drag = Some(DragState::Move {
node,
origin: r.center().into(),
pointer,
current: pointer,
});
}
}
Msg::DragPointer(pointer) => {
if let Some(DragState::Connect { pointer: p, .. }) = &mut self.model.drag {
*p = pointer;
}
if let Some(DragState::Move { current, .. }) = &mut self.model.drag {
*current = pointer;
}
}
Msg::EndDrag { target } => {
if let Some(drag) = self.model.drag.take() {
match drag {
DragState::Connect { from, .. } => {
if let Some(to) = target {
self.edit_connection(from, to, true);
}
}
DragState::Move {
node,
origin,
pointer,
current,
} => {
let position = [
origin[0] + current[0] - pointer[0],
origin[1] + current[1] - pointer[1],
];
if position.iter().all(|v| v.is_finite() && v.abs() <= 1.0e7) {
self.model.layout_overrides.insert(node, position);
}
}
}
}
}
Msg::CancelDrag => self.model.drag = None,
Msg::Undo => {
if let Some(edit) = self.model.undo.pop() {
self.apply_commands(&edit.inverse);
self.model.redo.push(edit);
}
}
Msg::Redo => {
if let Some(edit) = self.model.redo.pop() {
self.apply_commands(&edit.forward);
self.model.undo.push(edit);
}
}
}
if before.stages != self.model.stages
|| before.layout_overrides != self.model.layout_overrides
{
self.rebuild_scene();
}
if history && before != self.edit_state() {
self.model
.undo
.push(HistoryEntry::between(&before, &self.edit_state(), document));
if self.model.undo.len() > 200 {
self.model.undo.remove(0);
}
self.model.redo.clear();
}
Vec::new()
}
}
impl facett_core::Elm for PipelineBuilder {
type Model = Model;
type Msg = Msg;
type Effect = Effect;
fn title(&self) -> &str {
&self.model.title
}
fn state(&self) -> &Model {
&self.model
}
fn update(&mut self, msg: Msg) -> Vec<Effect> {
PipelineBuilder::update(self, msg)
}
fn view(&self, ui: &mut Ui) -> Vec<Msg> {
if self.model.mode == Mode::Graph {
let mut messages = Vec::new();
ui.horizontal(|ui| {
for (mode, label) in [
(Mode::Text, "✎ Text"),
(Mode::Build, "⚙ Build"),
(Mode::Graph, "◈ Graph"),
] {
if ui
.selectable_label(self.model.mode == mode, label)
.clicked()
{
messages.push(Msg::SetMode(mode));
}
}
ui.separator();
let validation = self.validate();
if validation.valid {
ui.colored_label(OK, "✔ valid");
} else {
ui.colored_label(ERR, format!("✖ {} error(s)", validation.errors.len()));
}
});
ui.separator();
messages.extend(self.graph_ui(ui));
for error in self.validate().errors {
ui.colored_label(ERR, error);
}
return messages;
}
// egui text/structural editors edit an isolated draft. The live model is
// changed only when the host applies the returned edit message.
let mut draft = self.clone();
let mut messages = draft.draft_ui(ui);
if draft.model != self.model {
messages.insert(0, Msg::Edit(Box::new(draft.model)));
}
messages
}
}
// ── The host-facing `Facet` contract ───────────────────────────────────────────
//
// The inherent `title`/`state_json`/`ui` trio above already IS the contract; this
// thin adapter makes `PipelineBuilder` an object-safe `dyn Facet` so it composes
// into a `FacetDeck` and can be handed to
// `facett_core::testmatrix::probe_and_emit` for its robot matrix cell in ONE call.
impl facett_core::Facet for PipelineBuilder {
fn title(&self) -> &str {
PipelineBuilder::title(self)
}
fn ui(&mut self, ui: &mut Ui) {
PipelineBuilder::ui(self, ui)
}
fn state_json(&self) -> serde_json::Value {
PipelineBuilder::state_json(self)
}
/// The **robot drive seam**: `"text"` / `"build"` / `"graph"` flip the
/// authoring surface, `{"add_stage":"name"}` / `{"remove_stage":<i>}` edit the
/// structure and `{"set_text":"…toml…"}` retypes the raw TOML (which re-parses
/// exactly as typing does, so `parse_error` is a real oracle).
fn update_json(&mut self, msg_json: &str) {
let Ok(v) = serde_json::from_str::<serde_json::Value>(msg_json) else {
return;
};
match v.as_str() {
Some("text") => return self.set_mode(Mode::Text),
Some("build") => return self.set_mode(Mode::Build),
Some("graph") => return self.set_mode(Mode::Graph),
Some("undo") => {
self.update(Msg::Undo);
return;
}
Some("redo") => {
self.update(Msg::Redo);
return;
}
Some("fit") => {
self.update(Msg::RequestRefit);
return;
}
_ => {}
}
for (key, connect) in [("connect", true), ("disconnect", false)] {
if let Some(pair) = v.get(key).and_then(|v| v.as_array())
&& pair.len() == 2
&& let (Some(from), Some(to)) = (pair[0].as_str(), pair[1].as_str())
{
self.update(if connect {
Msg::Connect {
from: from.into(),
to: to.into(),
}
} else {
Msg::Disconnect {
from: from.into(),
to: to.into(),
}
});
return;
}
}
if let Some(m) = v.get("move") {
let parsed = if let Some(a) = m.as_array() {
(
a.first().and_then(|v| v.as_str()),
a.get(1).and_then(|v| v.as_f64()),
a.get(2).and_then(|v| v.as_f64()),
)
} else {
(
m.get("node").and_then(|v| v.as_str()),
m.get("position")
.and_then(|v| v.get(0))
.and_then(|v| v.as_f64()),
m.get("position")
.and_then(|v| v.get(1))
.and_then(|v| v.as_f64()),
)
};
if let (Some(node), Some(x), Some(y)) = parsed {
self.update(Msg::MoveNode {
node: node.into(),
position: [x as f32, y as f32],
});
}
return;
}
if let Some(name) = v.get("add_stage").and_then(|s| s.as_str()) {
self.add_stage(name);
return;
}
if let Some(i) = v.get("remove_stage").and_then(|n| n.as_u64()) {
return self.remove_stage(i as usize);
}
if let Some(t) = v.get("set_text").and_then(|s| s.as_str()) {
self.set_text(t);
}
}
fn caps(&self) -> facett_core::FacetCaps {
facett_core::FacetCaps::NONE
.themeable()
.resizable()
.copyable()
.selectable()
.navigable()
}
fn selection_json(&self) -> serde_json::Value {
if let Some((from, to)) = &self.model.selected_edge {
serde_json::json!({"edge": [from,to]})
} else {
serde_json::to_value(&self.model.selected).unwrap_or_default()
}
}
fn copy(&mut self) -> Option<String> {
Some(self.pipeline_toml())
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn demo_round_trips_through_toml() {
let p = demo_pipeline();
let text = pipeline_to_toml(&p);
// Emitted shape matches the brokkr.toml the dwarves tab ships.
assert!(text.contains("[pipeline]"));
assert!(text.contains("[[pipeline.stage.step]]"));
assert!(text.contains("run = { variant = \"server\" }"));
assert!(text.contains(
"run = { container = { name = \"rust:1.85\", cmd = [\"cargo\", \"test\"] } }"
));
// Parse it back with the same serde shape → equal pipeline.
let back: PipelineFile = toml::from_str(&text).expect("emitted toml parses");
assert_eq!(back.pipeline, p, "toml round-trips losslessly");
}
#[test]
fn demo_is_valid() {
let v = validate_pipeline(&demo_pipeline());
assert!(v.valid, "errors: {:?}", v.errors);
}
#[test]
fn click_build_matches_text() {
// Build the demo structurally and prove the CLICK path emits the same TOML
// as adopting it wholesale (the two-mode sync invariant).
let mut b = PipelineBuilder::new("ci");
b.set_name("nornir-ci");
b.add_trigger(Trigger {
kind: TriggerKind::Webhook,
events: vec!["push".into()],
branch: Some("main".into()),
cron: None,
});
let build = b.add_stage("build");
b.add_step(build, "compile", RunKind::Variant("server".into()));
// artifacts aren't set via add_step; adopt the demo's compile artifact by
// comparing only structure we built:
let test = b.add_stage("test");
let unit = b.add_step(
test,
"unit",
RunKind::Container(ContainerRun {
name: "rust:1.85".into(),
cmd: vec!["cargo".into(), "test".into()],
}),
);
assert_eq!(unit, Some(0));
b.set_step_needs(test, 0, ["compile"]);
assert_eq!(b.stage_count(), 2);
assert_eq!(b.step_count(), 2);
let toml_text = b.pipeline_toml();
assert!(toml_text.contains("needs = [\"compile\"]"));
// Round-trips through the serde shape.
let back: PipelineFile = toml::from_str(&toml_text).unwrap();
assert_eq!(
back.pipeline.stages[1].steps[0].needs,
vec!["compile".to_string()]
);
}
#[test]
fn text_edit_syncs_the_model_and_flags_bad_needs() {
let mut b = PipelineBuilder::new("ci");
b.set_text(pipeline_to_toml(&demo_pipeline()));
assert!(b.model.parse_error.is_none());
assert_eq!(b.stage_count(), 3);
assert_eq!(b.step_count(), 4);
assert!(b.validate().valid);
// A step that needs a ghost is invalid, naming the unknown upstream.
let bad = r#"
[pipeline]
name = "x"
[[pipeline.stage]]
name = "s"
[[pipeline.stage.step]]
name = "a"
needs = ["ghost"]
run = { variant = "v" }
"#;
b.set_text(bad);
let st = b.state_json();
assert_eq!(st["valid"], false);
assert!(
st["errors"]
.as_array()
.unwrap()
.iter()
.any(|e| e.as_str().unwrap().contains("ghost"))
);
}
#[test]
fn malformed_text_records_a_parse_error_without_panicking() {
let mut b = PipelineBuilder::demo("ci");
b.set_text("this is = = not toml [[[");
assert!(b.model.parse_error.is_some(), "a parse failure is recorded");
// The rows are kept (still the demo), so the builder stays usable.
assert_eq!(b.stage_count(), 3);
assert!(b.state_json()["parse_error"].is_string());
}
#[test]
fn validation_flags_every_brokkr_rejection() {
// empty argv/image, empty variant, cron w/o expr, ungated webhook, dup name.
let p = Pipeline {
name: "p".into(),
triggers: vec![
Trigger {
kind: TriggerKind::Cron,
events: vec![],
branch: None,
cron: None,
},
Trigger {
kind: TriggerKind::Webhook,
events: vec![],
branch: None,
cron: None,
},
],
stages: vec![Stage {
name: "s".into(),
steps: vec![
Step {
name: "a".into(),
needs: vec![],
run: RunKind::Container(ContainerRun {
name: "".into(),
cmd: vec![],
}),
artifacts: vec![],
report: None,
},
Step {
name: "a".into(),
needs: vec!["nope".into()],
run: RunKind::Variant("".into()),
artifacts: vec![],
report: None,
},
],
}],
};
let v = validate_pipeline(&p);
assert!(!v.valid);
let joined = v.errors.join("\n");
assert!(joined.contains("empty image"));
assert!(joined.contains("empty `cmd`"));
assert!(joined.contains("names no variant"));
assert!(joined.contains("duplicate step name 'a'"));
assert!(joined.contains("needs unknown step 'nope'"));
assert!(joined.contains("cron trigger has no"));
assert!(joined.contains("webhook trigger declares neither"));
}
#[test]
fn state_json_shape() {
let b = PipelineBuilder::demo("ci");
let s = b.state_json();
assert_eq!(s["mode"], "Build");
assert_eq!(s["name"], "nornir-ci");
assert_eq!(s["stage_count"], 3);
assert_eq!(s["step_count"], 4);
assert_eq!(s["trigger_count"], 1);
assert_eq!(s["valid"], true);
assert!(s["toml"].as_str().unwrap().contains("[pipeline]"));
assert_eq!(s["pipeline"]["stage"][0]["step"][0]["name"], "compile");
}
#[test]
fn empty_builder_is_reported_stepless() {
let b = PipelineBuilder::new("empty");
let v = b.validate();
assert!(!v.valid, "an empty pipeline declares no steps");
assert!(v.errors.iter().any(|e| e.contains("declares no steps")));
}
}