nornir 0.5.3

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! **Funnel Plan-DAG view** — the funnel's cards + decomposition/dependency edges
//! as a metric-bearing operator DAG, rendered by the shared facett component
//! [`facett_plandag::PlanDagView`].
//!
//! Thin adapter (mirrors [`super::funnel3d::Funnel3D`]): the DAG is assembled from
//! real funnel data by [`funnel_core::plan_dag::PlanDag`] (pure, tested there); this
//! module only maps that neutral graph model onto facett's [`PlanModel`] and lets
//! the Facet lay it out (toposort columns), paint the cost-heat, collapse plan
//! groups + select nodes, and expose the robot-observable `state_json`. The three
//! neutral node scalars map onto the plandag's Time/Memory/Rows cost-heat:
//! **staleness → Time** (oldest card = hottest), **weight → Memory**,
//! **fan-out → Rows**.

use funnel_core::plan_dag::{DagStatus, PlanDag};

use facett_plandag::{Metrics, Msg, OpStatus, PlanDagView, PlanEdge, PlanModel, PlanNode, PlanVersion};

/// Map the neutral DAG status onto the plandag's operator status (drives the node
/// border colour: done → point, running → accent, failed → red, idle → dim).
fn op_status(s: DagStatus) -> OpStatus {
    match s {
        DagStatus::Idle => OpStatus::Idle,
        DagStatus::Running => OpStatus::Running,
        DagStatus::Done => OpStatus::Done,
        DagStatus::Failed => OpStatus::Failed,
    }
}

/// The funnel Plan-DAG pane: a lazily-(re)built [`PlanDagView`] plus the scope it
/// was built for (so the tab only rebuilds on a plan-selection change).
#[derive(Default)]
pub struct FunnelPlanDag {
    view: Option<PlanDagView>,
    /// `Some(scope)` once built, where `scope` is the selected plan (`None` = whole
    /// funnel). `None` = never built.
    built_for: Option<Option<String>>,
    node_count: usize,
    edge_count: usize,
}

impl FunnelPlanDag {
    pub fn node_count(&self) -> usize {
        self.node_count
    }
    pub fn edge_count(&self) -> usize {
        self.edge_count
    }

    /// Whether the pane is already built for `sel_plan` (avoids a rebuild per frame).
    pub fn is_built_for(&self, sel_plan: Option<&str>) -> bool {
        self.built_for.as_ref().map(|b| b.as_deref()) == Some(sel_plan)
    }

    /// (Re)build the facet from an assembled [`PlanDag`]. `sel_plan` records the
    /// scope so [`is_built_for`](Self::is_built_for) can gate rebuilds.
    pub fn build(&mut self, dag: &PlanDag, sel_plan: Option<&str>) {
        // Stable id → node index (edges reference nodes by index in facett's model).
        let idx: std::collections::HashMap<&str, usize> =
            dag.nodes.iter().enumerate().map(|(i, n)| (n.id.as_str(), i)).collect();

        let nodes: Vec<PlanNode> = dag
            .nodes
            .iter()
            .map(|n| {
                let mut pn = PlanNode::new(n.id.clone(), n.title.clone())
                    .status(op_status(n.status))
                    .metrics(Metrics {
                        time_ms: n.staleness_hours,
                        peak_mem: n.weight_bytes,
                        rows: n.fanout,
                        spill: 0,
                    });
                if let Some(g) = &n.group {
                    pn = pn.group(g.clone());
                }
                if n.is_bug {
                    pn = pn.photon();
                }
                pn
            })
            .collect();

        let edges: Vec<PlanEdge> = dag
            .edges
            .iter()
            .filter_map(|e| {
                let (Some(&from), Some(&to)) = (idx.get(e.from.as_str()), idx.get(e.to.as_str())) else {
                    return None;
                };
                Some(PlanEdge { from, to })
            })
            .collect();

        self.node_count = nodes.len();
        self.edge_count = edges.len();
        let version = PlanVersion::new("funnel", nodes, edges);
        self.view = Some(PlanDagView::new("funnel-plandag", PlanModel::new(version)));
        self.built_for = Some(sel_plan.map(str::to_string));

        // Functional-status self-report: the DAG built ⇔ it has nodes (an empty
        // funnel is a legitimate empty state, not a failure — see funnel3d).
        #[cfg(feature = "testmatrix")]
        nornir_testmatrix::functional_status(
            "viz/funnel_plandag (facett-plandag)",
            "funnel_plandag_built",
            true,
            &format!(
                "nodes={} edges={} scope={}",
                self.node_count,
                self.edge_count,
                sel_plan.unwrap_or("<all>")
            ),
        );
    }

    /// Collapse / expand a plan group (the robot-driven ToggleGroup control).
    pub fn toggle_group(&mut self, group: &str) {
        if let Some(v) = &mut self.view {
            let _ = v.update(Msg::ToggleGroup(group.to_string()));
        }
    }

    /// Render the facet (or a placeholder when nothing is built yet).
    pub fn ui(&mut self, ui: &mut egui::Ui) {
        use facett_core::Facet;
        match &mut self.view {
            Some(v) => v.ui(ui),
            None => {
                ui.centered_and_justified(|ui| {
                    ui.weak("select a plan to render its Plan-DAG");
                });
            }
        }
    }

    /// Robot-observable state — folded under `state_json["funnel"]["plandag"]`. When
    /// nothing is built yet, a shaped empty blob (so the matrix key always exists).
    pub fn state_json(&self) -> serde_json::Value {
        use facett_core::Facet;
        match &self.view {
            Some(v) => {
                let mut j = v.state_json();
                if let Some(obj) = j.as_object_mut() {
                    obj.insert(
                        "built_for".into(),
                        serde_json::json!(self.built_for.clone().flatten()),
                    );
                }
                j
            }
            None => serde_json::json!({
                "node_count": 0,
                "edge_count": 0,
                "nodes": [],
                "built_for": null,
            }),
        }
    }
}

#[cfg(test)]
mod tests {
    use super::*;
    use funnel_core::event::{Event, ItemKind, PlanStatus};
    use funnel_core::ids::{IdeaId, NodeId, PlanId};
    use funnel_core::state::Funnel;

    fn funnel_with_plan() -> Funnel {
        let mut f = Funnel::default();
        let now = chrono::Utc::now();
        let idea = IdeaId::seq(1);
        let plan = PlanId::seq(1);
        f.apply(&Event::IdeaSubmitted {
            id: idea.clone(),
            source: "human:rickard".into(),
            text: "planner".into(),
            refs: vec![],
            item_kind: ItemKind::Idea,
            ts: now,
        })
        .unwrap();
        f.apply(&Event::PlanCreated {
            id: plan.clone(),
            idea_id: idea.clone(),
            summary: "decompose".into(),
            planner: "decompose".into(),
            ts: now,
        })
        .unwrap();
        f.apply(&Event::PlanStatusChanged { plan_id: plan.clone(), status: PlanStatus::Active, why: None, ts: now })
            .unwrap();
        for i in 1..=2u64 {
            f.apply(&Event::NodeAdded {
                plan_id: plan.clone(),
                node_id: NodeId::seq(i),
                kind: "code:write".into(),
                params: Default::default(),
                targets: vec![],
                prompt_excerpt: Some(format!("task {i}")),
                ts: now,
            })
            .unwrap();
        }
        f.apply(&Event::EdgeAdded { plan_id: plan.clone(), from_node: NodeId::seq(1), to_node: NodeId::seq(2), ts: now })
            .unwrap();
        f
    }

    #[test]
    fn build_maps_plan_dag_onto_facett_model() {
        let f = funnel_with_plan();
        let dag = PlanDag::from_funnel(&f, None);
        let mut pane = FunnelPlanDag::default();
        pane.build(&dag, None);
        // mother + 2 task nodes; 2 mother edges + 1 dep edge.
        assert_eq!(pane.node_count(), 3);
        assert_eq!(pane.edge_count(), 3);
        assert!(pane.is_built_for(None));
        assert!(!pane.is_built_for(Some("p-001")));

        let j = pane.state_json();
        assert_eq!(j["node_count"], 3);
        assert_eq!(j["edge_count"], 3);
        // cost-heat rides as data in [0,1] on every node.
        for n in j["nodes"].as_array().unwrap() {
            let h = n["heat"].as_f64().unwrap();
            assert!((0.0..=1.0).contains(&h), "heat in range: {h}");
        }
    }

    #[test]
    fn collapsing_a_plan_group_folds_its_task_nodes() {
        let f = funnel_with_plan();
        let dag = PlanDag::from_funnel(&f, None);
        let mut pane = FunnelPlanDag::default();
        pane.build(&dag, None);
        let before = pane.state_json()["node_count"].as_u64().unwrap();
        pane.toggle_group("p-001");
        let after = pane.state_json()["node_count"].as_u64().unwrap();
        assert!(after < before, "collapsing plan p-001 folds its task nodes into a super-node");
        let st = pane.state_json();
        assert_eq!(st["collapsed"], serde_json::json!(["p-001"]));
    }

    #[test]
    fn unbuilt_pane_has_a_shaped_empty_state() {
        let pane = FunnelPlanDag::default();
        let j = pane.state_json();
        assert_eq!(j["node_count"], 0);
        assert!(j["nodes"].as_array().unwrap().is_empty());
    }
}