Your browser sucks, JavaScript disabled! Gunnar's console needs JavaScript and WebAssembly. Git over SSH on port 2222 does not.

nordisk/facett

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