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

nordisk/facett

facett-sdp/src/lib.rs

//! **facett-sdp** — a Spark Declarative Pipelines (SDP) editor facet.
//!
//! An SDP pipeline is a set of **flows**; each flow has a `name`, an optional list
//! of upstream `deps` (flow/dataset names it reads from), and a Spark **SQL**
//! `query` that defines it. This component lets a user edit those flows — the SQL
//! in a multi-line box, plus name/deps — add/remove flows, and see a tiny inline
//! DAG preview built from the deps. It does **static** validation only (does every
//! dep reference a known flow?); it never parses SQL and never talks to Spark — the
//! host owns submission. That keeps this a pure, dependency-light editor.
//!
//! It exposes its edited state via [`SdpEditor::state_json`] so a host (korp) can
//! read the flows back, map each to a knut `DataflowGraph` node, and submit to
//! Spark. Mirrors the [`Facet`] shape (title / ui / state_json) like the sibling
//! `facett-markdown` / `facett-search` facets; pure egui, no async, no network.
//!
//! [`Facet`]: https://git.gunnar.rs/nordisk/facett

use std::collections::{HashMap, HashSet};

use egui::{Color32, Ui};
use serde::{Deserialize, Serialize};

/// Editor view mode: edit the flows, or preview the dependency DAG.
#[derive(Clone, Copy, Debug, PartialEq, Eq, Serialize, Deserialize)]
pub enum Mode {
    Edit,
    Graph,
}

/// One flow in an SDP pipeline — the clean, portable representation a host reads
/// back from [`SdpEditor::flows`] / `state_json`. `deps` are the *names* of the
/// upstream flows/datasets this flow's SQL reads from; `query` is Spark SQL.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Flow {
    pub name: String,
    pub deps: Vec<String>,
    pub query: String,
}

impl Flow {
    pub fn new(name: impl Into<String>, deps: impl IntoIterator<Item = impl Into<String>>, query: impl Into<String>) -> Self {
        Self {
            name: name.into(),
            deps: deps.into_iter().map(Into::into).collect(),
            query: query.into(),
        }
    }
}

/// Result of the static (non-SQL) validation pass.
#[derive(Clone, Debug, Default, PartialEq, Eq, Serialize, Deserialize)]
pub struct Validation {
    pub valid: bool,
    pub errors: Vec<String>,
}

/// An editable flow row. `deps` are edited as one comma/space-separated line
/// (`deps_raw`) so a single [`egui::TextEdit`] drives them; the parsed list is
/// re-derived on read. Kept private — hosts see the clean [`Flow`].
#[derive(Clone, Debug, Default)]
struct Row {
    name: String,
    deps_raw: String,
    query: String,
}

impl Row {
    fn from_flow(f: &Flow) -> Self {
        Self { name: f.name.clone(), deps_raw: f.deps.join(", "), query: f.query.clone() }
    }
    fn to_flow(&self) -> Flow {
        Flow { name: self.name.trim().to_string(), deps: parse_deps(&self.deps_raw), query: self.query.clone() }
    }
}

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 NODE: Color32 = Color32::from_rgb(58, 62, 74);
const EDGE: Color32 = Color32::from_rgb(150, 156, 168);
const TEXT: Color32 = Color32::from_rgb(214, 219, 228);

/// The SDP pipeline editor facet.
pub struct SdpEditor {
    title: String,
    rows: Vec<Row>,
    mode: Mode,
}

impl SdpEditor {
    pub fn new(title: impl Into<String>) -> Self {
        Self { title: title.into(), rows: Vec::new(), mode: Mode::Edit }
    }

    /// Builder: seed with a flow.
    pub fn with_flow(mut self, flow: Flow) -> Self {
        self.rows.push(Row::from_flow(&flow));
        self
    }

    /// Builder: seed with many flows.
    pub fn with_flows(mut self, flows: impl IntoIterator<Item = Flow>) -> Self {
        self.rows = flows.into_iter().map(|f| Row::from_flow(&f)).collect();
        self
    }

    /// Append a flow.
    pub fn add_flow(&mut self, flow: Flow) {
        self.rows.push(Row::from_flow(&flow));
    }

    /// Append a blank flow (what the "+ Add flow" button does).
    pub fn add_empty_flow(&mut self) {
        self.rows.push(Row::default());
    }

    /// Remove the flow at `index` (no-op if out of range).
    pub fn remove_flow(&mut self, index: usize) {
        if index < self.rows.len() {
            self.rows.remove(index);
        }
    }

    pub fn len(&self) -> usize {
        self.rows.len()
    }
    pub fn is_empty(&self) -> bool {
        self.rows.is_empty()
    }

    pub fn set_mode(&mut self, mode: Mode) {
        self.mode = mode;
    }

    /// Set the SQL of the flow at `index` (no-op if out of range). Handy for hosts
    /// and tests that drive an edit programmatically.
    pub fn set_flow_query(&mut self, index: usize, query: impl Into<String>) {
        if let Some(r) = self.rows.get_mut(index) {
            r.query = query.into();
        }
    }

    /// Set the deps of the flow at `index` (no-op if out of range).
    pub fn set_flow_deps(&mut self, index: usize, deps: impl IntoIterator<Item = impl Into<String>>) {
        if let Some(r) = self.rows.get_mut(index) {
            r.deps_raw = deps.into_iter().map(Into::into).collect::<Vec<String>>().join(", ");
        }
    }

    /// The edited pipeline as clean [`Flow`]s — the host reads this to build its
    /// dataflow graph.
    pub fn flows(&self) -> Vec<Flow> {
        self.rows.iter().map(Row::to_flow).collect()
    }

    /// Static validation: every dep must reference a known flow; names must be
    /// present and unique; no self-loops. This is **not** SQL validation — Spark
    /// does that on submit.
    pub fn validate(&self) -> Validation {
        let flows = self.flows();
        let known: HashSet<&str> = flows.iter().map(|f| f.name.as_str()).filter(|n| !n.is_empty()).collect();
        let mut errors = Vec::new();
        let mut seen: HashSet<&str> = HashSet::new();
        for (i, f) in flows.iter().enumerate() {
            if f.name.is_empty() {
                errors.push(format!("flow #{} has an empty name", i + 1));
            } else if !seen.insert(f.name.as_str()) {
                errors.push(format!("duplicate flow name '{}'", f.name));
            }
            for d in &f.deps {
                if d == &f.name {
                    errors.push(format!("flow '{}' depends on itself", f.name));
                } else if !known.contains(d.as_str()) {
                    errors.push(format!("flow '{}' depends on unknown '{}'", f.name, d));
                }
            }
        }
        Validation { valid: errors.is_empty(), errors }
    }

    // ── facett Facet contract ────────────────────────────────────────────────

    /// The **shared demo fixture** — a two-flow ETL pipeline (a source with no
    /// deps and a downstream dependent) that validates clean, so both the Edit
    /// list and the Graph DAG preview paint real geometry. Consumed by the render
    /// and matrix tests (L5: one fixture, not one per test file).
    pub fn local() -> Self {
        Self::new("etl").with_flows([
            Flow::new("raw", Vec::<String>::new(), "SELECT * FROM read_files('/in')"),
            Flow::new("clean", ["raw"], "SELECT * FROM raw WHERE ok"),
        ])
    }

    pub fn title(&self) -> &str {
        &self.title
    }

    /// Observable state as JSON:
    /// ```json
    /// { "title": "..", "mode": "Edit",
    ///   "flows": [ { "name": "..", "deps": [".."], "query": ".." } ],
    ///   "valid": true, "errors": [] }
    /// ```
    pub fn state_json(&self) -> serde_json::Value {
        let v = self.validate();
        serde_json::json!({
            "title": self.title,
            "mode": format!("{:?}", self.mode),
            "flows": self.flows(),
            "valid": v.valid,
            "errors": v.errors,
        })
    }

    pub fn ui(&mut self, ui: &mut Ui) {
        ui.horizontal(|ui| {
            ui.selectable_value(&mut self.mode, Mode::Edit, "✎ Edit");
            ui.selectable_value(&mut self.mode, Mode::Graph, "◈ Graph");
            ui.separator();
            let v = self.validate();
            if v.valid {
                ui.label(egui::RichText::new(format!("✔ valid · {} flow(s)", self.rows.len())).small().color(OK));
            } else {
                ui.label(egui::RichText::new(format!("✖ {} error(s)", v.errors.len())).small().color(ERR));
            }
        });
        ui.separator();
        match self.mode {
            Mode::Edit => self.edit_ui(ui),
            Mode::Graph => self.graph_ui(ui),
        }
    }

    fn edit_ui(&mut self, ui: &mut Ui) {
        let mut remove: Option<usize> = None;
        egui::ScrollArea::vertical().show(ui, |ui| {
            for (i, row) in self.rows.iter_mut().enumerate() {
                egui::Frame::group(ui.style()).show(ui, |ui| {
                    ui.horizontal(|ui| {
                        let label = if row.name.trim().is_empty() { format!("flow #{}", i + 1) } else { row.name.trim().to_string() };
                        ui.label(egui::RichText::new(label).strong().color(ACCENT));
                        if ui.button("✕ Remove").clicked() {
                            remove = Some(i);
                        }
                    });
                    ui.horizontal(|ui| {
                        ui.label("name");
                        ui.add(egui::TextEdit::singleline(&mut row.name).hint_text("flow name").desired_width(180.0));
                        ui.label("deps");
                        ui.add(
                            egui::TextEdit::singleline(&mut row.deps_raw)
                                .hint_text("upstream, comma-separated")
                                .desired_width(f32::INFINITY),
                        );
                    });
                    ui.add(
                        egui::TextEdit::multiline(&mut row.query)
                            .code_editor()
                            .desired_width(f32::INFINITY)
                            .desired_rows(4)
                            .hint_text("SELECT … FROM …  (Spark SQL)"),
                    );
                });
                ui.add_space(4.0);
            }
            if ui.button("+ Add flow").clicked() {
                self.rows.push(Row::default());
            }
        });
        if let Some(i) = remove {
            self.rows.remove(i);
        }
    }

    /// A tiny inline DAG preview: nodes laid out in columns by dependency depth,
    /// edges drawn from each known dep. Self-contained (no facett-graph) — a
    /// pragmatic sketch, not a full graph viewer.
    fn graph_ui(&mut self, ui: &mut Ui) {
        let flows = self.flows();
        if flows.is_empty() {
            ui.weak("No flows yet — add one in Edit mode.");
            return;
        }
        let depth = depths(&flows);
        // Group indices by depth (column), preserving flow order within a column.
        let mut cols: HashMap<usize, Vec<usize>> = HashMap::new();
        for (i, f) in flows.iter().enumerate() {
            cols.entry(*depth.get(&f.name).unwrap_or(&0)).or_default().push(i);
        }
        let max_depth = depth.values().copied().max().unwrap_or(0);
        let max_rows = cols.values().map(|v| v.len()).max().unwrap_or(1);

        let col_w = 150.0;
        let row_h = 56.0;
        let node_r = 8.0;
        let width = (max_depth as f32 + 1.0) * col_w + 40.0;
        let height = (max_rows as f32).max(1.0) * row_h + 40.0;

        let (resp, painter) = ui.allocate_painter(egui::vec2(width.max(200.0), height.max(80.0)), egui::Sense::hover());
        let origin = resp.rect.min + egui::vec2(24.0, 24.0);

        // Position every flow by (column = depth, row = order within column).
        let mut pos: HashMap<String, egui::Pos2> = HashMap::new();
        for (d, idxs) in &cols {
            for (r, &i) in idxs.iter().enumerate() {
                let p = origin + egui::vec2(*d as f32 * col_w, r as f32 * row_h);
                pos.insert(flows[i].name.clone(), p);
            }
        }
        // Edges: dep → flow (only for deps that are known flows).
        for f in &flows {
            if let Some(&to) = pos.get(&f.name) {
                for dep in &f.deps {
                    if let Some(&from) = pos.get(dep) {
                        painter.line_segment([from + egui::vec2(node_r, 0.0), to - egui::vec2(node_r, 0.0)], egui::Stroke::new(1.5_f32, EDGE));
                    }
                }
            }
        }
        // Nodes.
        for f in &flows {
            if let Some(&p) = pos.get(&f.name) {
                painter.circle_filled(p, node_r, NODE);
                painter.circle_stroke(p, node_r, egui::Stroke::new(1.5_f32, ACCENT));
                painter.text(
                    p + egui::vec2(node_r + 4.0, 0.0),
                    egui::Align2::LEFT_CENTER,
                    &f.name,
                    egui::FontId::proportional(13.0),
                    TEXT,
                );
            }
        }
    }
}

/// Parse a comma/whitespace-separated deps line into unique, trimmed names
/// (first-seen order preserved).
fn parse_deps(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
}

/// Longest-path depth of each flow over the known-dep edges. Cycle-safe: the
/// relaxation is bounded to `flows.len()` passes, so a cycle just caps out instead
/// of looping forever.
fn depths(flows: &[Flow]) -> HashMap<String, usize> {
    let known: HashSet<&str> = flows.iter().map(|f| f.name.as_str()).collect();
    let mut depth: HashMap<String, usize> = flows.iter().map(|f| (f.name.clone(), 0usize)).collect();
    for _ in 0..flows.len() {
        let mut changed = false;
        for f in flows {
            let mut d = 0usize;
            for dep in &f.deps {
                if known.contains(dep.as_str()) && dep != &f.name {
                    d = d.max(depth.get(dep).copied().unwrap_or(0) + 1);
                }
            }
            if d > *depth.get(&f.name).unwrap_or(&0) {
                depth.insert(f.name.clone(), d);
                changed = true;
            }
        }
        if !changed {
            break;
        }
    }
    depth
}

// ── The host-facing `Facet` contract ───────────────────────────────────────────
impl facett_core::Facet for SdpEditor {
    fn title(&self) -> &str {
        SdpEditor::title(self)
    }
    fn ui(&mut self, ui: &mut Ui) {
        SdpEditor::ui(self, ui)
    }
    fn state_json(&self) -> serde_json::Value {
        SdpEditor::state_json(self)
    }
    /// The **robot drive seam**: `"graph"` / `"edit"` flip the mode toggle,
    /// `{"add_flow":{"name":..,"deps":[..],"query":".."}}` is the Add button,
    /// `{"remove_flow":<i>}` the Remove button, and
    /// `{"set_query":{"index":<i>,"query":".."}}` retypes a SQL box.
    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("graph") => return self.set_mode(Mode::Graph),
            Some("edit") => return self.set_mode(Mode::Edit),
            Some("add_empty_flow") => return self.add_empty_flow(),
            _ => {}
        }
        if let Some(i) = v.get("remove_flow").and_then(|n| n.as_u64()) {
            return self.remove_flow(i as usize);
        }
        if let Some(f) = v.get("add_flow") {
            let name = f.get("name").and_then(|s| s.as_str()).unwrap_or("");
            let query = f.get("query").and_then(|s| s.as_str()).unwrap_or("");
            let deps: Vec<String> = f
                .get("deps")
                .and_then(|d| d.as_array())
                .map(|a| a.iter().filter_map(|d| d.as_str().map(|s| s.to_string())).collect())
                .unwrap_or_default();
            return self.add_flow(Flow::new(name, deps, query));
        }
        if let Some(q) = v.get("set_query")
            && let (Some(i), Some(text)) =
                (q.get("index").and_then(|n| n.as_u64()), q.get("query").and_then(|s| s.as_str()))
        {
            self.set_flow_query(i as usize, text);
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;

    fn sample() -> SdpEditor {
        SdpEditor::new("etl").with_flows([
            Flow::new("raw", Vec::<String>::new(), "SELECT * FROM read_files('/in')"),
            Flow::new("clean", ["raw"], "SELECT * FROM raw WHERE ok"),
            Flow::new("marts", ["clean"], "SELECT k, count(*) FROM clean GROUP BY k"),
        ])
    }

    #[test]
    fn parse_deps_splits_and_dedups() {
        assert_eq!(parse_deps("a, b  c,,a"), vec!["a", "b", "c"]);
        assert!(parse_deps("   ").is_empty());
    }

    #[test]
    fn flows_roundtrip_through_rows() {
        let ed = sample();
        let f = ed.flows();
        assert_eq!(f.len(), 3);
        assert_eq!(f[1].name, "clean");
        assert_eq!(f[1].deps, vec!["raw"]);
        assert!(f[2].query.contains("GROUP BY"));
    }

    #[test]
    fn valid_pipeline_is_valid() {
        let v = sample().validate();
        assert!(v.valid, "errors: {:?}", v.errors);
        assert!(v.errors.is_empty());
    }

    #[test]
    fn unknown_dep_is_flagged_invalid() {
        let mut ed = sample();
        ed.set_flow_deps(1, ["nope"]);
        let v = ed.validate();
        assert!(!v.valid);
        assert!(v.errors.iter().any(|e| e.contains("unknown 'nope'")));
    }

    #[test]
    fn duplicate_name_and_self_dep_flagged() {
        let ed = SdpEditor::new("x").with_flows([
            Flow::new("a", ["a"], "SELECT 1"),
            Flow::new("a", Vec::<String>::new(), "SELECT 2"),
        ]);
        let v = ed.validate();
        assert!(!v.valid);
        assert!(v.errors.iter().any(|e| e.contains("depends on itself")));
        assert!(v.errors.iter().any(|e| e.contains("duplicate flow name 'a'")));
    }

    #[test]
    fn state_json_shape_and_reflects_edit() {
        let mut ed = sample();
        ed.set_flow_query(0, "SELECT 42 AS answer");
        let s = ed.state_json();
        assert_eq!(s["mode"], "Edit");
        assert_eq!(s["valid"], true);
        assert_eq!(s["flows"][0]["name"], "raw");
        assert_eq!(s["flows"][0]["query"], "SELECT 42 AS answer");
        assert_eq!(s["flows"][1]["deps"][0], "raw");
        assert!(s["errors"].as_array().unwrap().is_empty());
    }

    #[test]
    fn depths_are_layered_and_cycle_safe() {
        let d = depths(&sample().flows());
        assert_eq!(d["raw"], 0);
        assert_eq!(d["clean"], 1);
        assert_eq!(d["marts"], 2);
        // a 2-cycle must terminate (bounded to len passes) — reaching here proves it
        let cyc = vec![Flow::new("a", ["b"], ""), Flow::new("b", ["a"], "")];
        let dc = depths(&cyc);
        assert!(dc.contains_key("a") && dc.contains_key("b"));
    }
}