nornir 0.4.18

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
Documentation
//! Uniform, render-ready view of the idea→plan funnel (DAG 2).
//!
//! Both data paths collapse into [`FunnelView`]:
//!   * **embedded** — [`FunnelView::from_funnel`] over a replayed
//!     [`nornir::funnel::Funnel`] (the viz opened the funnel `Store` locally),
//!   * **remote** — built from the `Funnel.Show` gRPC `FunnelDump` (see
//!     [`super::remote::funnel_show`]); that RPC already carries per-node
//!     `deps`, so the funnel tab needs **no** proto/server change.
//!
//! The [`funnel_tab`](super::funnel_tab) renderer consumes only this model, so
//! it is identical local vs. remote.

use eframe::egui::Color32;

use crate::funnel::{self, NodeStatus};

/// Whole-funnel snapshot: every plan and its node DAG.
#[derive(Clone, Debug, Default)]
pub struct FunnelView {
    pub plans: Vec<PlanView>,
}

/// One plan = one DAG (nodes + dependency edges).
#[derive(Clone, Debug)]
pub struct PlanView {
    pub id: String,
    pub summary: String,
    /// `PlanStatus` rendered lower-case (`draft|active|done|abandoned`).
    pub status: String,
    /// The originating idea's text (context header); empty when unknown.
    pub idea_text: String,
    pub nodes: Vec<NodeView>,
}

/// A single plan node. `deps` are the node ids this node depends on
/// (i.e. edges `dep -> self`), the wiring the topo layout flows along.
#[derive(Clone, Debug)]
pub struct NodeView {
    pub id: String,
    pub kind: String,
    pub title: String,
    pub status: NodeStat,
    pub targets: Vec<String>,
    pub deps: Vec<String>,
}

/// Lifecycle, mirrored from [`funnel::NodeStatus`] plus an `Unknown` for
/// statuses arriving as a free string over the wire.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum NodeStat {
    Pending,
    Ready,
    InProgress,
    Done,
    Blocked,
    Failed,
    Unknown,
}

impl NodeStat {
    /// Lenient parse of the wire/Debug spelling (`Funnel.Show` ships the
    /// snake_case serde repr; the CLI accepts a few synonyms).
    pub fn parse(s: &str) -> Self {
        match s.trim().to_ascii_lowercase().as_str() {
            "pending" => Self::Pending,
            "ready" => Self::Ready,
            "inprogress" | "in_progress" | "active" => Self::InProgress,
            "done" => Self::Done,
            "blocked" => Self::Blocked,
            "failed" | "abandoned" => Self::Failed,
            _ => Self::Unknown,
        }
    }

    pub fn from_node(s: NodeStatus) -> Self {
        match s {
            NodeStatus::Pending => Self::Pending,
            NodeStatus::Ready => Self::Ready,
            NodeStatus::InProgress => Self::InProgress,
            NodeStatus::Done => Self::Done,
            NodeStatus::Blocked => Self::Blocked,
            NodeStatus::Failed => Self::Failed,
        }
    }

    /// Node / pin tint. Greens = progressing, amber = stuck, red = failed.
    pub fn color(self) -> Color32 {
        match self {
            Self::Done => Color32::from_rgb(90, 200, 120),
            Self::InProgress => Color32::from_rgb(80, 165, 235),
            Self::Ready => Color32::from_rgb(90, 205, 200),
            Self::Pending => Color32::from_rgb(140, 142, 152),
            Self::Blocked => Color32::from_rgb(235, 175, 60),
            Self::Failed => Color32::from_rgb(225, 85, 85),
            Self::Unknown => Color32::from_rgb(120, 120, 120),
        }
    }

    /// Palette-aware node / pin tint — the same status semantics as [`color`],
    /// but routed through the active facett palette so a theme switch re-skins
    /// the funnel: Done→GREEN, InProgress→accent, Ready→point, Pending→dim,
    /// Blocked→AMBER, Failed→RED, Unknown→dim.
    pub fn color_themed(&self, theme: &super::facett_theme::Theme) -> Color32 {
        use super::facett_theme::{AMBER, GREEN, RED};
        match self {
            Self::Done => GREEN,
            Self::InProgress => theme.accent,
            Self::Ready => theme.point,
            Self::Pending => theme.text_dim,
            Self::Blocked => AMBER,
            Self::Failed => RED,
            Self::Unknown => theme.text_dim,
        }
    }

    /// Compact status glyph for the node header.
    pub fn glyph(self) -> &'static str {
        match self {
            Self::Done => "",
            Self::InProgress => "",
            Self::Ready => "",
            Self::Pending => "",
            Self::Blocked => "",
            Self::Failed => "",
            Self::Unknown => "?",
        }
    }

    pub fn label(self) -> &'static str {
        match self {
            Self::Done => "done",
            Self::InProgress => "in progress",
            Self::Ready => "ready",
            Self::Pending => "pending",
            Self::Blocked => "blocked",
            Self::Failed => "failed",
            Self::Unknown => "unknown",
        }
    }
}

impl FunnelView {
    /// Build from a locally-replayed funnel (embedded path).
    pub fn from_funnel(f: &funnel::Funnel) -> Self {
        let mut plans = Vec::new();
        for plan in f.plans.values() {
            let idea_text = f.ideas.get(&plan.idea_id).map(|i| i.text.clone()).unwrap_or_default();
            let mut nodes = Vec::with_capacity(plan.nodes.len());
            for node in plan.nodes.values() {
                // deps of `node` = every `from` with edge (from -> node).
                let deps = plan
                    .edges
                    .iter()
                    .filter(|(_, to)| to == &node.id)
                    .map(|(from, _)| from.to_string())
                    .collect();
                let title = node
                    .prompt_excerpt
                    .clone()
                    .filter(|s| !s.is_empty())
                    .unwrap_or_default();
                nodes.push(NodeView {
                    id: node.id.to_string(),
                    kind: node.kind.clone(),
                    title,
                    status: NodeStat::from_node(node.status),
                    targets: node.targets.clone(),
                    deps,
                });
            }
            plans.push(PlanView {
                id: plan.id.to_string(),
                summary: plan.summary.clone(),
                status: format!("{:?}", plan.status).to_ascii_lowercase(),
                idea_text,
                nodes,
            });
        }
        // Stable, human order: by plan id.
        plans.sort_by(|a, b| a.id.cmp(&b.id));
        Self { plans }
    }
}