nornir 0.4.30

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! 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 submitted intake item (idea or error), newest-first, with its derived
/// triage→plan status + lineage — the unit the FI2 history list renders. Both
/// the embedded path ([`FunnelView::history`]) and the remote `Funnel.History`
/// RPC collapse into this.
#[derive(Clone, Debug, PartialEq)]
pub struct HistoryItemView {
    pub id: String,
    /// `idea` | `error`.
    pub item_kind: String,
    pub text: String,
    pub source: String,
    pub submitted_at: String,
    /// `untriaged` | `accepted` | `dropped` | `planned`.
    pub status: String,
    pub plan_ids: Vec<String>,
}

impl HistoryItemView {
    /// True when this item is an error report (the red-tinted rows).
    pub fn is_error(&self) -> bool {
        self.item_kind == "error"
    }
}

/// 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,
        }
    }

    /// Palette-aware node / pin tint — routed through the active facett palette so
    /// a theme switch re-skins the funnel (C8). Greens = progressing, amber =
    /// stuck, red = failed: 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 }
    }

    /// FI2 — the intake history (newest-first), built from a locally-replayed
    /// funnel via the shared [`crate::funnel::history`] projection so it derives
    /// the exact same kind/status/lineage the CLI + server emit.
    pub fn history(f: &funnel::Funnel) -> Vec<HistoryItemView> {
        funnel::history(f, None, None)
            .into_iter()
            .map(|it| HistoryItemView {
                id: it.id,
                item_kind: it.item_kind.as_str().to_string(),
                text: it.text,
                source: it.source,
                submitted_at: it.submitted_at,
                status: it.status.as_str().to_string(),
                plan_ids: it.plan_ids,
            })
            .collect()
    }
}