nornir 0.4.33

Companion to cargo: dependency tracking, release gating, deploy, benchmarks, and documentation assembly. Project-agnostic.
//! Funnel **history** view (EPIC FUNNEL-INTAKE / FI2).
//!
//! A pure projection over a materialised [`Funnel`]: every submitted item
//! (idea OR error), newest-first, with its **derived status** (untriaged →
//! accepted/dropped → planned) and the plan ids that refine it (its triage→plan
//! lineage). The CLI (`nornir funnel history`), the server `Funnel.History` RPC,
//! and the viz Funnel-tab history list all render this same shape, so they never
//! disagree — and a test asserts the derivation on injected events.

use serde::{Deserialize, Serialize};

use super::event::{ItemKind, TriageDecision};
use super::state::Funnel;

/// Where a submitted item sits in the funnel pipeline. Derived, never stored —
/// it's a function of the item's triage + whether a plan was created for it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum HistoryStatus {
    /// Submitted, not yet triaged.
    Untriaged,
    /// Triaged `accept` but no plan yet.
    Accepted,
    /// Triaged `drop` — won't be planned.
    Dropped,
    /// At least one plan refines it (the loop closed: intake → tracked work).
    Planned,
}

impl HistoryStatus {
    /// The lower-case wire/CLI string.
    pub fn as_str(&self) -> &'static str {
        match self {
            HistoryStatus::Untriaged => "untriaged",
            HistoryStatus::Accepted => "accepted",
            HistoryStatus::Dropped => "dropped",
            HistoryStatus::Planned => "planned",
        }
    }

    /// Parse a CLI/wire status filter; unknown/empty → `None` (no filter).
    pub fn parse(s: &str) -> Option<Self> {
        match s {
            "untriaged" => Some(HistoryStatus::Untriaged),
            "accepted" => Some(HistoryStatus::Accepted),
            "dropped" => Some(HistoryStatus::Dropped),
            "planned" => Some(HistoryStatus::Planned),
            _ => None,
        }
    }
}

/// One submitted funnel item with its derived lineage — the unit the history
/// list (viz + CLI) renders.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct HistoryItem {
    pub id: String,
    pub item_kind: ItemKind,
    pub text: String,
    pub source: String,
    /// RFC3339 submission time.
    pub submitted_at: String,
    pub status: HistoryStatus,
    /// Plan ids refining this item (its triage→plan lineage), sorted.
    pub plan_ids: Vec<String>,
}

/// Build the history list from a funnel, **newest submission first**, optionally
/// filtered by `kind` and/or `status`. `None` filters keep everything.
///
/// Status derivation: a plan exists for the item ⇒ `Planned`; else its triage
/// decides — `accept` ⇒ `Accepted`, `drop` ⇒ `Dropped`, no triage ⇒ `Untriaged`.
pub fn history(
    funnel: &Funnel,
    kind: Option<ItemKind>,
    status: Option<HistoryStatus>,
) -> Vec<HistoryItem> {
    let mut items: Vec<HistoryItem> = funnel
        .ideas
        .values()
        .map(|idea| {
            let mut plan_ids: Vec<String> = funnel
                .plans
                .values()
                .filter(|p| p.idea_id == idea.id)
                .map(|p| p.id.as_str().to_string())
                .collect();
            plan_ids.sort();
            let st = if !plan_ids.is_empty() {
                HistoryStatus::Planned
            } else {
                match idea.triage.as_ref().map(|(d, _, _)| *d) {
                    Some(TriageDecision::Accept) => HistoryStatus::Accepted,
                    Some(TriageDecision::Drop) => HistoryStatus::Dropped,
                    None => HistoryStatus::Untriaged,
                }
            };
            HistoryItem {
                id: idea.id.as_str().to_string(),
                item_kind: idea.item_kind,
                text: idea.text.clone(),
                source: idea.source.clone(),
                submitted_at: idea.submitted_at.to_rfc3339(),
                status: st,
                plan_ids,
            }
        })
        .filter(|it| kind.map(|k| it.item_kind == k).unwrap_or(true))
        .filter(|it| status.map(|s| it.status == s).unwrap_or(true))
        .collect();
    // Newest first by submission time; tie-break on id for a stable order.
    items.sort_by(|a, b| {
        b.submitted_at.cmp(&a.submitted_at).then(a.id.cmp(&b.id))
    });
    items
}

/// Serialise a history list to a pretty JSON array (the CLI's `history` output
/// and the viz history surface's `state_json` share this exact shape).
pub fn history_to_json(items: &[HistoryItem]) -> String {
    serde_json::to_string_pretty(items).unwrap_or_else(|_| "[]".to_string())
}

#[cfg(test)]
mod tests {
    use super::*;
    use crate::funnel::event::{Event, PlanStatus};
    use crate::funnel::ids::{IdeaId, PlanId};
    use chrono::Utc;

    fn funnel_from(events: &[Event]) -> Funnel {
        let mut f = Funnel::default();
        for e in events {
            f.apply(e).unwrap();
        }
        f
    }

    /// Inject an idea, an error, and an idea-with-plan; assert the history
    /// derives the right kind + status for each, newest-first, and that the
    /// kind/status filters select the expected subset.
    #[test]
    fn history_derives_kind_status_and_filters() {
        let base = Utc::now();
        let at = |i: i64| base + chrono::Duration::seconds(i);
        let events = vec![
            // i-001: a plain idea, untriaged.
            Event::IdeaSubmitted {
                id: IdeaId::seq(1),
                source: "cli".into(),
                text: "add a dark theme".into(),
                refs: vec![],
                item_kind: ItemKind::Idea,
                ts: at(0),
            },
            // i-002: an error report, untriaged (newest so far).
            Event::IdeaSubmitted {
                id: IdeaId::seq(2),
                source: "cli".into(),
                text: "panic: index out of bounds in foo.rs:42".into(),
                refs: vec![],
                item_kind: ItemKind::Error,
                ts: at(1),
            },
            // i-003: an idea that got a plan → Planned (newest).
            Event::IdeaSubmitted {
                id: IdeaId::seq(3),
                source: "cli".into(),
                text: "ship the funnel history".into(),
                refs: vec![],
                item_kind: ItemKind::Idea,
                ts: at(2),
            },
            Event::PlanCreated {
                id: PlanId::seq(1),
                idea_id: IdeaId::seq(3),
                summary: "FI2".into(),
                planner: "cli".into(),
                ts: at(3),
            },
            Event::PlanStatusChanged {
                plan_id: PlanId::seq(1),
                status: PlanStatus::Active,
                why: None,
                ts: at(4),
            },
        ];
        let f = funnel_from(&events);

        // Full history: newest-first → i-003, i-002, i-001.
        let all = history(&f, None, None);
        assert_eq!(all.len(), 3);
        assert_eq!(all[0].id, "i-003");
        assert_eq!(all[0].status, HistoryStatus::Planned);
        assert_eq!(all[0].plan_ids, vec!["p-001".to_string()]);
        assert_eq!(all[1].id, "i-002");
        assert_eq!(all[1].item_kind, ItemKind::Error);
        assert_eq!(all[1].status, HistoryStatus::Untriaged);
        assert_eq!(all[2].id, "i-001");
        assert_eq!(all[2].item_kind, ItemKind::Idea);

        // Kind filter: only the error.
        let errs = history(&f, Some(ItemKind::Error), None);
        assert_eq!(errs.len(), 1);
        assert_eq!(errs[0].id, "i-002");
        assert!(errs[0].text.contains("panic"));

        // Status filter: only the planned item.
        let planned = history(&f, None, Some(HistoryStatus::Planned));
        assert_eq!(planned.len(), 1);
        assert_eq!(planned[0].id, "i-003");

        // Combined filter that matches nothing (error + planned) → empty.
        assert!(history(&f, Some(ItemKind::Error), Some(HistoryStatus::Planned)).is_empty());

        // JSON is a well-formed array carrying the kind + status.
        let json = history_to_json(&all);
        let parsed: serde_json::Value = serde_json::from_str(&json).unwrap();
        assert_eq!(parsed.as_array().unwrap().len(), 3);
        assert_eq!(parsed[0]["item_kind"], "idea");
        assert_eq!(parsed[1]["item_kind"], "error");
    }
}