Gunnar

mikronova/funnel-core

src/card.rs

//! **YouTrack / JIRA-style cards over funnel items** — a pure, additive
//! projection layer that turns the funnel's [`HistoryItem`]s (the Jira half of
//! "JIRA + Maven = nornir") into issue **cards**, groups them into a kanban
//! **board**, and filters them with a YouTrack-style **smart query**
//! (`type:bug is:open for:alice sort:newest`).
//!
//! This module is deliberately **read-only and event-free**: it never touches the
//! `funnel_events` table, the proto, or the Iceberg schema. It projects whatever
//! [`history`](super::history::history) already returns, so it works today against
//! the live funnel and stays migration-safe. A future `facett-board` viz / the
//! `Funnel.Board` RPC / the `funnel cards` CLI all bind to these pure types; the
//! richer persisted card fields (assignee, comments, workflow state) land later as
//! new optional `funnel_events` columns (see `.nornir/funnel-cards.md`).
//!
//! Everything here is deterministic (FC-7): given the same items + query string the
//! output is bit-identical, so the board is golden-testable with no warehouse, no
//! GPU, and no clock.

use serde::{Deserialize, Serialize};

use super::event::ItemKind;
use super::history::{HistoryItem, HistoryStatus};
use super::state::{Funnel, Plan, PlanNode};

/// The issue **type** badge on a card — the JIRA/YouTrack vocabulary for the
/// funnel's [`ItemKind`]: a feature request, a bug, or a test-idea.
///
/// **Open enum** (`#[non_exhaustive]`) — it maps one-for-one onto [`ItemKind`],
/// which is open, so it necessarily grows with it. See the open-enum contract on
/// [`Event`](crate::event::Event).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
#[non_exhaustive]
pub enum CardType {
    /// A feature / krav / prompt (funnel [`ItemKind::Idea`]).
    Feature,
    /// A bug / pasted error report (funnel [`ItemKind::Error`]).
    Bug,
    /// A desired/missing test (funnel [`ItemKind::Test`]).
    Test,
}

impl CardType {
    /// From the funnel item kind.
    #[must_use]
    pub fn from_item_kind(k: ItemKind) -> Self {
        match k {
            ItemKind::Idea => CardType::Feature,
            ItemKind::Error => CardType::Bug,
            ItemKind::Test => CardType::Test,
        }
    }
    /// The lower-case query/wire token (`feature` | `bug` | `test`).
    #[must_use]
    pub fn as_str(&self) -> &'static str {
        match self {
            CardType::Feature => "feature",
            CardType::Bug => "bug",
            CardType::Test => "test",
        }
    }
    /// Parse a query token, accepting both the JIRA/YouTrack word and the funnel
    /// `ItemKind` word (`idea`→feature, `error`→bug). Unknown → `None`.
    #[must_use]
    pub fn parse(s: &str) -> Option<Self> {
        match s.to_ascii_lowercase().as_str() {
            "feature" | "idea" | "story" => Some(CardType::Feature),
            "bug" | "error" | "defect" => Some(CardType::Bug),
            "test" => Some(CardType::Test),
            _ => None,
        }
    }
    /// From a decomposed **task node**'s free-verb `node_kind`
    /// (funnel-prompt-planner keystone): `test:*` → Test, `code:fix` → Bug,
    /// everything else (`code:write` and any other verb) → Feature.
    #[must_use]
    pub fn from_node_kind(kind: &str) -> Self {
        if kind.starts_with("test:") {
            CardType::Test
        } else if kind == "code:fix" {
            CardType::Bug
        } else {
            CardType::Feature
        }
    }
}

/// A kanban **lane** (board column) — the workflow state. Idea/error/test
/// cards derive theirs from the funnel's [`HistoryStatus`]; decomposed **task**
/// cards (funnel-prompt-planner keystone) derive theirs from the latest
/// [`super::event::Event::CardStateChanged`] instead (see
/// [`Funnel::card_lanes`]) — `Proposed` only ever applies to that side. The
/// fixed left→right order is the board layout.
///
/// Deliberately **closed** (no `#[non_exhaustive]`): the board is *the five fixed
/// lanes*, and a renderer must lay out a column for every one of them — a sixth
/// lane is a genuine semantic break that should stop a consumer's build until a
/// human decides where it goes. See the open/closed contract on
/// [`Event`](crate::event::Event).
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Lane {
    /// Submitted, not yet triaged (`Untriaged`).
    Inbox,
    /// Triaged `accept`, awaiting a plan (`Accepted`).
    Accepted,
    /// A Manual-mode draft task, staged for human review before it joins the
    /// live plan (funnel-prompt-planner §2) — NOT derived from
    /// `HistoryStatus`; only reachable via `Event::CardStateChanged`.
    Proposed,
    /// At least one plan refines it — work is tracked (`Planned`).
    Planned,
    /// Triaged `drop` (or a rejected `Proposed` task) — won't be worked
    /// (`Dropped`).
    Dropped,
}

impl Lane {
    /// From the funnel-derived history status.
    #[must_use]
    pub fn from_status(s: HistoryStatus) -> Self {
        match s {
            HistoryStatus::Untriaged => Lane::Inbox,
            HistoryStatus::Accepted => Lane::Accepted,
            HistoryStatus::Planned => Lane::Planned,
            HistoryStatus::Dropped => Lane::Dropped,
        }
    }
    /// The lower-case query/wire token.
    #[must_use]
    pub fn as_str(&self) -> &'static str {
        match self {
            Lane::Inbox => "inbox",
            Lane::Accepted => "accepted",
            Lane::Proposed => "proposed",
            Lane::Planned => "planned",
            Lane::Dropped => "dropped",
        }
    }
    /// The human column title.
    #[must_use]
    pub fn title(&self) -> &'static str {
        match self {
            Lane::Inbox => "Inbox",
            Lane::Accepted => "Accepted",
            Lane::Proposed => "Proposed",
            Lane::Planned => "Planned",
            Lane::Dropped => "Dropped",
        }
    }
    /// Parse a query token, accepting the lane word and the `HistoryStatus` word
    /// (`untriaged`→inbox). Unknown → `None`.
    #[must_use]
    pub fn parse(s: &str) -> Option<Self> {
        match s.to_ascii_lowercase().as_str() {
            "inbox" | "untriaged" | "open" => Some(Lane::Inbox),
            "accepted" => Some(Lane::Accepted),
            "proposed" | "staging" | "draft" | "drafts" => Some(Lane::Proposed),
            "planned" | "done" => Some(Lane::Planned),
            "dropped" => Some(Lane::Dropped),
            _ => None,
        }
    }
    /// The fixed board order (left→right).
    #[must_use]
    pub fn board_order() -> [Lane; 5] {
        [
            Lane::Inbox,
            Lane::Accepted,
            Lane::Proposed,
            Lane::Planned,
            Lane::Dropped,
        ]
    }
    /// "Open" lanes = actionable, not yet closed (not Planned/Dropped). Used by the
    /// `is:open` smart-query operator. `Proposed` counts as open — a draft
    /// still awaiting a human accept/reject decision is actionable.
    #[must_use]
    pub fn is_open(&self) -> bool {
        matches!(self, Lane::Inbox | Lane::Accepted | Lane::Proposed)
    }
}

/// A YouTrack/JIRA-style issue **card** projected from a funnel [`HistoryItem`].
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Card {
    /// Stable id (`i-NNN`), the card identity (FC-5).
    pub id: String,
    /// One-line summary (the first line of the item text, trimmed).
    pub title: String,
    /// Full item text (the description body).
    pub body: String,
    /// Issue type badge.
    pub card_type: CardType,
    /// Workflow lane / board column.
    pub lane: Lane,
    /// Who reported it (the funnel `source`, e.g. `human:alice`).
    pub reporter: String,
    /// RFC3339 creation time.
    pub created: String,
    /// Plan ids that refine this card (its lineage / linked work).
    pub plan_ids: Vec<String>,
    /// Labels: the type token, the lane token, and any `#hashtags` mined from the
    /// body — all deterministic, sorted, deduped (chip row on the card).
    pub labels: Vec<String>,
    /// The real person this card is assigned to (funnel-prompt-planner §3), from
    /// the latest [`super::event::Event::CardAssigned`]. `None` = unassigned
    /// (the pull model) — including every card projected before the keystone.
    #[serde(default)]
    pub assignee: Option<String>,
}

/// Extract `#hashtag` labels from free text (lower-cased, deduped, sorted). A tag
/// is `#` followed by `[A-Za-z0-9_-]+`.
#[must_use]
pub fn mine_hashtags(text: &str) -> Vec<String> {
    let mut tags: Vec<String> = Vec::new();
    let bytes = text.as_bytes();
    let mut i = 0;
    while i < bytes.len() {
        if bytes[i] == b'#' {
            let start = i + 1;
            let mut j = start;
            while j < bytes.len() {
                let c = bytes[j];
                if c.is_ascii_alphanumeric() || c == b'_' || c == b'-' {
                    j += 1;
                } else {
                    break;
                }
            }
            if j > start {
                tags.push(text[start..j].to_ascii_lowercase());
            }
            i = j;
        } else {
            i += 1;
        }
    }
    tags.sort();
    tags.dedup();
    tags
}

/// Project one funnel history item into a card.
#[must_use]
pub fn card_from_history(it: &HistoryItem) -> Card {
    let card_type = CardType::from_item_kind(it.item_kind);
    let lane = Lane::from_status(it.status);
    let title = it.text.lines().next().unwrap_or("").trim().to_string();
    let mut labels = vec![card_type.as_str().to_string(), lane.as_str().to_string()];
    labels.extend(mine_hashtags(&it.text));
    labels.sort();
    labels.dedup();
    Card {
        id: it.id.clone(),
        title,
        body: it.text.clone(),
        card_type,
        lane,
        reporter: it.source.clone(),
        created: it.submitted_at.clone(),
        plan_ids: it.plan_ids.clone(),
        labels,
        assignee: None,
    }
}

/// Project a whole history list into cards (order preserved from `history`,
/// which is newest-first).
#[must_use]
pub fn cards_from_history(items: &[HistoryItem]) -> Vec<Card> {
    items.iter().map(card_from_history).collect()
}

/// Project one decomposed **task node** (a child of a mother idea's
/// prompt-driven decomposition, funnel-prompt-planner §1) into a card. Unlike
/// [`card_from_history`], the lane comes from [`Funnel::card_lanes`] (defaults
/// to `Inbox` when no `CardStateChanged` was ever recorded for it — should not
/// happen for a node created via `nornir funnel prompt`, which always stamps
/// one immediately) rather than `HistoryStatus`, and the assignee/reporter come
/// straight from the funnel.
#[must_use]
pub fn card_from_node(plan: &Plan, node: &PlanNode, idea_source: &str, funnel: &Funnel) -> Card {
    let id = node.id.as_str().to_string();
    let raw_title = node
        .prompt_excerpt
        .clone()
        .or_else(|| {
            node.params
                .get("title")
                .and_then(|v| v.as_str())
                .map(str::to_string)
        })
        .unwrap_or_else(|| node.kind.clone());
    let title = raw_title.lines().next().unwrap_or("").trim().to_string();
    let body = node
        .params
        .get("acceptance")
        .and_then(|v| v.as_str())
        .map(str::to_string)
        .unwrap_or_else(|| raw_title.clone());
    let card_type = CardType::from_node_kind(&node.kind);
    let lane = funnel
        .card_lanes
        .get(&id)
        .and_then(|s| Lane::parse(s))
        .unwrap_or(Lane::Inbox);
    let assignee = funnel.assignments.get(&id).map(|a| a.member.clone());
    let mut labels = vec![
        card_type.as_str().to_string(),
        lane.as_str().to_string(),
        node.kind.clone(),
    ];
    labels.extend(mine_hashtags(&body));
    labels.sort();
    labels.dedup();
    Card {
        id,
        title,
        body,
        card_type,
        lane,
        reporter: idea_source.to_string(),
        created: node.created_at.to_rfc3339(),
        plan_ids: vec![plan.id.as_str().to_string()],
        labels,
        assignee,
    }
}

/// Project the WHOLE funnel into cards: one card per idea/error/test item
/// (as [`cards_from_history`], enriched with its `assignee` if any) PLUS one
/// card per decomposed task node across every plan (funnel-prompt-planner
/// keystone — "subtasks become assignable cards", §0). This is the superset
/// the `funnel cards`/`funnel board` surfaces and the `assignee:`/`for:me`
/// smart query bind to. Deterministic: sorted by id.
#[must_use]
pub fn cards_from_funnel(funnel: &Funnel) -> Vec<Card> {
    let hist = super::history::history(funnel, None, None);
    let mut out: Vec<Card> = hist
        .iter()
        .map(|it| {
            let mut c = card_from_history(it);
            c.assignee = funnel.assignments.get(&c.id).map(|a| a.member.clone());
            c
        })
        .collect();
    for plan in funnel.plans.values() {
        let idea_source = funnel
            .ideas
            .get(&plan.idea_id)
            .map(|i| i.source.clone())
            .unwrap_or_else(|| "funnel:decompose".to_string());
        for node in plan.nodes.values() {
            out.push(card_from_node(plan, node, &idea_source, funnel));
        }
    }
    out.sort_by(|a, b| a.id.cmp(&b.id));
    out
}

// ───────────────────────────── smart query ─────────────────────────────────

/// How a [`query_cards`] result is ordered.
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Sort {
    /// Newest created first (the default).
    #[default]
    Newest,
    /// Oldest created first.
    Oldest,
    /// By id ascending (stable, content-independent).
    Id,
}

impl Sort {
    fn parse(s: &str) -> Option<Self> {
        match s.to_ascii_lowercase().as_str() {
            "newest" | "updated" | "created" => Some(Sort::Newest),
            "oldest" => Some(Sort::Oldest),
            "id" => Some(Sort::Id),
            _ => None,
        }
    }
}

/// A parsed YouTrack-style smart query. Build it with [`parse_query`]; test a card
/// with [`Query::matches`]. Unknown operators degrade to free-text terms, so a
/// query is never an error — it just narrows.
///
/// Supported operators (case-insensitive keys):
/// - `type:` / `kind:` → feature | bug | test (also idea/error)
/// - `lane:` / `status:` → inbox | accepted | proposed | planned | dropped (also untriaged)
/// - `is:` → open | inbox | accepted | proposed | planned | dropped | done
/// - `reporter:` → substring of the reporter
/// - `assignee:` → substring of the assignee (`assignee:me` → contains [`query_me`])
/// - `for:` → substring of EITHER the reporter OR the assignee (`for:me` →
///   "cards for me" — either I filed it or it's assigned to me)
/// - `label:` → an exact label chip
/// - `has:plan` / `no:plan` → linked-plan presence
/// - `sort:` → newest | oldest | id
/// - any bare word → case-insensitive substring of title+body+id+reporter
#[derive(Debug, Clone, Default, PartialEq)]
pub struct Query {
    pub types: Vec<CardType>,
    pub lanes: Vec<Lane>,
    pub open_only: bool,
    pub reporter_terms: Vec<String>,
    /// `assignee:<term>` — matches the assignee only.
    pub assignee_terms: Vec<String>,
    /// `for:<term>` — matches the reporter OR the assignee.
    pub for_terms: Vec<String>,
    pub labels: Vec<String>,
    pub has_plan: Option<bool>,
    pub text_terms: Vec<String>,
    pub sort: Sort,
}

/// Who `me` means in a query (`reporter:me`, `assignee:me`, `for:me`): the
/// `FUNNEL_ME` environment variable, else `USER`, lower-cased.
#[must_use]
pub fn query_me() -> String {
    std::env::var("FUNNEL_ME")
        .or_else(|_| std::env::var("USER"))
        .unwrap_or_default()
        .to_ascii_lowercase()
}

/// Parse a smart-query string into a [`Query`]. Quoting is not needed — operators
/// are whitespace-delimited `key:value` pairs; everything else is free text.
/// `me` resolves to [`query_me`]; [`parse_query_as`] names it explicitly.
#[must_use]
pub fn parse_query(q: &str) -> Query {
    parse_query_as(q, &query_me())
}

/// [`parse_query`] with `me` given by the caller instead of the environment.
#[must_use]
pub fn parse_query_as(q: &str, me: &str) -> Query {
    let mut out = Query::default();
    for tok in q.split_whitespace() {
        if let Some((key, val)) = tok.split_once(':') {
            let val = val.trim();
            if val.is_empty() {
                continue;
            }
            match key.to_ascii_lowercase().as_str() {
                "type" | "kind" => {
                    if let Some(t) = CardType::parse(val) {
                        out.types.push(t);
                    }
                }
                "lane" | "status" => {
                    if let Some(l) = Lane::parse(val) {
                        out.lanes.push(l);
                    }
                }
                "is" => match val.to_ascii_lowercase().as_str() {
                    "open" => out.open_only = true,
                    other => {
                        if let Some(l) = Lane::parse(other) {
                            out.lanes.push(l);
                        }
                    }
                },
                "reporter" => {
                    let v = if val.eq_ignore_ascii_case("me") {
                        me.to_ascii_lowercase()
                    } else {
                        val.to_ascii_lowercase()
                    };
                    out.reporter_terms.push(v);
                }
                "assignee" => {
                    let v = if val.eq_ignore_ascii_case("me") {
                        me.to_ascii_lowercase()
                    } else {
                        val.to_ascii_lowercase()
                    };
                    out.assignee_terms.push(v);
                }
                "for" => {
                    let v = if val.eq_ignore_ascii_case("me") {
                        me.to_ascii_lowercase()
                    } else {
                        val.to_ascii_lowercase()
                    };
                    out.for_terms.push(v);
                }
                "label" | "tag" => out.labels.push(val.to_ascii_lowercase()),
                "has" => {
                    if val.eq_ignore_ascii_case("plan") {
                        out.has_plan = Some(true);
                    }
                }
                "no" => {
                    if val.eq_ignore_ascii_case("plan") {
                        out.has_plan = Some(false);
                    }
                }
                "sort" => {
                    if let Some(s) = Sort::parse(val) {
                        out.sort = s;
                    }
                }
                // Unknown operator → treat the whole token as a free-text term.
                _ => out.text_terms.push(tok.to_ascii_lowercase()),
            }
        } else {
            out.text_terms.push(tok.to_ascii_lowercase());
        }
    }
    out
}

impl Query {
    /// Does `card` satisfy every constraint in this query? Multiple values of the
    /// same facet are OR'd (any type matches); across facets they are AND'd; free
    /// text terms must all appear somewhere.
    #[must_use]
    pub fn matches(&self, card: &Card) -> bool {
        if !self.types.is_empty() && !self.types.contains(&card.card_type) {
            return false;
        }
        if !self.lanes.is_empty() && !self.lanes.contains(&card.lane) {
            return false;
        }
        if self.open_only && !card.lane.is_open() {
            return false;
        }
        if let Some(want) = self.has_plan {
            if card.plan_ids.is_empty() == want {
                return false;
            }
        }
        let reporter_lc = card.reporter.to_ascii_lowercase();
        let assignee_lc = card.assignee.as_deref().unwrap_or("").to_ascii_lowercase();
        for r in &self.reporter_terms {
            if !reporter_lc.contains(r) {
                return false;
            }
        }
        for a in &self.assignee_terms {
            if !assignee_lc.contains(a) {
                return false;
            }
        }
        for f in &self.for_terms {
            if !(reporter_lc.contains(f) || assignee_lc.contains(f)) {
                return false;
            }
        }
        for l in &self.labels {
            if !card.labels.iter().any(|c| c == l) {
                return false;
            }
        }
        if !self.text_terms.is_empty() {
            let hay = format!("{} {} {} {}", card.id, card.title, card.body, card.reporter)
                .to_ascii_lowercase();
            for t in &self.text_terms {
                if !hay.contains(t) {
                    return false;
                }
            }
        }
        true
    }
}

/// Filter `cards` by the smart query `q` and order by its `sort` (deterministic;
/// ties always break on id so the result is stable).
#[must_use]
pub fn query_cards(cards: &[Card], q: &str) -> Vec<Card> {
    query_cards_as(cards, q, &query_me())
}

/// [`query_cards`] with `me` given by the caller instead of the environment.
#[must_use]
pub fn query_cards_as(cards: &[Card], q: &str, me: &str) -> Vec<Card> {
    let query = parse_query_as(q, me);
    let mut out: Vec<Card> = cards.iter().filter(|c| query.matches(c)).cloned().collect();
    match query.sort {
        Sort::Newest => out.sort_by(|a, b| b.created.cmp(&a.created).then(a.id.cmp(&b.id))),
        Sort::Oldest => out.sort_by(|a, b| a.created.cmp(&b.created).then(a.id.cmp(&b.id))),
        Sort::Id => out.sort_by(|a, b| a.id.cmp(&b.id)),
    }
    out
}

// ───────────────────────────── board ───────────────────────────────────────

/// One board column: a lane + the cards in it (newest-first, id-stable).
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Column {
    pub lane: Lane,
    pub title: String,
    pub cards: Vec<Card>,
}

/// A kanban **board**: the four lanes in fixed left→right order, each with its
/// cards. The `facett-board` viz binds directly to this.
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Board {
    pub columns: Vec<Column>,
    /// Total cards across all columns.
    pub total: usize,
}

/// Group `cards` into the fixed-order kanban board. Within a column cards are
/// newest-first with an id tie-break (deterministic).
#[must_use]
pub fn board(cards: &[Card]) -> Board {
    let mut columns = Vec::with_capacity(4);
    for lane in Lane::board_order() {
        let mut col: Vec<Card> = cards.iter().filter(|c| c.lane == lane).cloned().collect();
        col.sort_by(|a, b| b.created.cmp(&a.created).then(a.id.cmp(&b.id)));
        columns.push(Column {
            lane,
            title: lane.title().to_string(),
            cards: col,
        });
    }
    Board {
        total: cards.len(),
        columns,
    }
}

/// The board as `state_json` (the headless robot-test + viz surface, LAW 6):
/// `{ total, columns: [{ lane, title, count, cards: [{id,title,type,reporter,labels,plans}] }] }`.
#[must_use]
pub fn board_to_json(b: &Board) -> serde_json::Value {
    serde_json::json!({
        "total": b.total,
        "columns": b.columns.iter().map(|c| serde_json::json!({
            "lane": c.lane.as_str(),
            "title": c.title,
            "count": c.cards.len(),
            "cards": c.cards.iter().map(|card| serde_json::json!({
                "id": card.id,
                "title": card.title,
                "type": card.card_type.as_str(),
                "reporter": card.reporter,
                "labels": card.labels,
                "plans": card.plan_ids.len(),
            })).collect::<Vec<_>>(),
        })).collect::<Vec<_>>(),
    })
}

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

    fn item(
        id: &str,
        kind: ItemKind,
        status: HistoryStatus,
        text: &str,
        source: &str,
        ts: &str,
        plans: &[&str],
    ) -> HistoryItem {
        HistoryItem {
            id: id.to_string(),
            item_kind: kind,
            text: text.to_string(),
            source: source.to_string(),
            submitted_at: ts.to_string(),
            status,
            plan_ids: plans.iter().map(|s| s.to_string()).collect(),
        }
    }

    fn sample() -> Vec<HistoryItem> {
        vec![
            item(
                "i-1",
                ItemKind::Idea,
                HistoryStatus::Untriaged,
                "Add #board view\nmulti-line body",
                "human:alice",
                "2026-06-30T10:00:00+00:00",
                &[],
            ),
            item(
                "i-2",
                ItemKind::Error,
                HistoryStatus::Planned,
                "panic on empty #funnel",
                "human:ada",
                "2026-06-30T11:00:00+00:00",
                &["p-1"],
            ),
            item(
                "i-3",
                ItemKind::Test,
                HistoryStatus::Accepted,
                "cover topo_ready",
                "agent:autonom",
                "2026-06-30T09:00:00+00:00",
                &[],
            ),
            item(
                "i-4",
                ItemKind::Idea,
                HistoryStatus::Dropped,
                "rewrite in COBOL",
                "human:alice",
                "2026-06-29T08:00:00+00:00",
                &[],
            ),
        ]
    }

    /// The projection maps every funnel facet into card vocabulary, and labels are
    /// deterministic (type + lane + mined hashtags, sorted/deduped).
    #[test]
    fn card_projection_maps_facets_and_labels() {
        let c = card_from_history(&sample()[0]);
        assert_eq!(c.id, "i-1");
        assert_eq!(c.card_type, CardType::Feature);
        assert_eq!(c.lane, Lane::Inbox);
        assert_eq!(
            c.title, "Add #board view",
            "title is the first line, trimmed"
        );
        assert_eq!(c.reporter, "human:alice");
        assert!(
            c.labels.contains(&"feature".to_string()) && c.labels.contains(&"inbox".to_string())
        );
        assert!(
            c.labels.contains(&"board".to_string()),
            "mined the #board hashtag"
        );
        // sorted + deduped
        let mut sorted = c.labels.clone();
        sorted.sort();
        sorted.dedup();
        assert_eq!(c.labels, sorted);

        let bug = card_from_history(&sample()[1]);
        assert_eq!(bug.card_type, CardType::Bug);
        assert_eq!(bug.lane, Lane::Planned);
        assert!(bug.labels.contains(&"funnel".to_string()));
    }

    /// Hashtag mining handles boundaries, case, dedupe.
    #[test]
    fn hashtag_mining() {
        assert_eq!(
            mine_hashtags("a #Foo, #foo and #bar-baz! #"),
            vec!["bar-baz", "foo"]
        );
        assert!(mine_hashtags("no tags here").is_empty());
    }

    /// Each smart-query operator parses and filters as specified.
    #[test]
    fn smart_query_operators() {
        let cards = cards_from_history(&sample());

        // type:bug → only the error card
        let r = query_cards(&cards, "type:bug");
        assert_eq!(r.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), ["i-2"]);
        // kind:idea alias + lane filter
        assert_eq!(
            query_cards(&cards, "kind:feature lane:dropped")
                .iter()
                .map(|c| c.id.clone())
                .collect::<Vec<_>>(),
            ["i-4"]
        );
        // is:open = inbox|accepted (i-1, i-3)
        let open: Vec<String> = query_cards(&cards, "is:open")
            .iter()
            .map(|c| c.id.clone())
            .collect();
        assert_eq!(open, ["i-1", "i-3"], "open = inbox+accepted, newest first");
        // for:me (me = alice) → reporter contains alice (i-1, i-4)
        let mine: Vec<String> = query_cards_as(&cards, "for:me", "alice")
            .iter()
            .map(|c| c.id.clone())
            .collect();
        assert_eq!(mine, ["i-1", "i-4"]);
        // has:plan / no:plan
        assert_eq!(
            query_cards(&cards, "has:plan")
                .iter()
                .map(|c| c.id.clone())
                .collect::<Vec<_>>(),
            ["i-2"]
        );
        assert_eq!(query_cards(&cards, "no:plan").len(), 3);
        // label chip
        assert_eq!(
            query_cards(&cards, "label:funnel")
                .iter()
                .map(|c| c.id.clone())
                .collect::<Vec<_>>(),
            ["i-2"]
        );
        // free text (case-insensitive, across title/body/id/reporter)
        assert_eq!(
            query_cards(&cards, "cobol")
                .iter()
                .map(|c| c.id.clone())
                .collect::<Vec<_>>(),
            ["i-4"]
        );
        // combined AND across facets
        assert_eq!(
            query_cards(&cards, "type:feature for:alice is:open")
                .iter()
                .map(|c| c.id.clone())
                .collect::<Vec<_>>(),
            ["i-1"]
        );
    }

    /// Sort operators order deterministically with an id tie-break.
    #[test]
    fn smart_query_sort() {
        let cards = cards_from_history(&sample());
        let newest: Vec<String> = query_cards(&cards, "sort:newest")
            .iter()
            .map(|c| c.id.clone())
            .collect();
        assert_eq!(newest, ["i-2", "i-1", "i-3", "i-4"]);
        let oldest: Vec<String> = query_cards(&cards, "sort:oldest")
            .iter()
            .map(|c| c.id.clone())
            .collect();
        assert_eq!(oldest, ["i-4", "i-3", "i-1", "i-2"]);
        let byid: Vec<String> = query_cards(&cards, "sort:id")
            .iter()
            .map(|c| c.id.clone())
            .collect();
        assert_eq!(byid, ["i-1", "i-2", "i-3", "i-4"]);
    }

    /// The board groups into the four fixed lanes with correct counts + order, and
    /// its json is deterministic.
    #[test]
    fn board_groups_into_fixed_lanes() {
        let cards = cards_from_history(&sample());
        let b = board(&cards);
        assert_eq!(b.total, 4);
        assert_eq!(
            b.columns.len(),
            5,
            "Inbox/Accepted/Proposed/Planned/Dropped"
        );
        let lanes: Vec<&str> = b.columns.iter().map(|c| c.lane.as_str()).collect();
        assert_eq!(
            lanes,
            ["inbox", "accepted", "proposed", "planned", "dropped"],
            "fixed left→right order"
        );
        let counts: Vec<usize> = b.columns.iter().map(|c| c.cards.len()).collect();
        assert_eq!(
            counts,
            [1, 1, 0, 1, 1],
            "no idea card ever lands in Proposed (task-node only)"
        );
        assert_eq!(b.columns[0].cards[0].id, "i-1");

        // Deterministic json (bit-identical on a re-projection).
        let j1 = board_to_json(&board(&cards_from_history(&sample())));
        let j2 = board_to_json(&board(&cards_from_history(&sample())));
        assert_eq!(j1.to_string(), j2.to_string());
        assert_eq!(
            j1["columns"][3]["cards"][0]["plans"], 1,
            "the planned bug shows 1 linked plan"
        );
    }

    /// An empty funnel projects to an empty-but-shaped board (RAGNARÖK: zero cards
    /// is honest empty, all five columns still exist).
    #[test]
    fn empty_funnel_is_honest_empty_board() {
        let b = board(&cards_from_history(&[]));
        assert_eq!(b.total, 0);
        assert_eq!(b.columns.len(), 5);
        assert!(b.columns.iter().all(|c| c.cards.is_empty()));
    }

    // ─── funnel-prompt-planner keystone: assignee + task cards + Proposed lane ──

    /// Build a funnel with one idea whose prompt-decompose created a 2-node child
    /// plan, and assign/lane-transition the nodes — the exact shape
    /// `nornir funnel prompt` produces. Shared by the tests below.
    fn funnel_with_tasks() -> (
        Funnel,
        crate::ids::PlanId,
        crate::ids::NodeId,
        crate::ids::NodeId,
    ) {
        use crate::event::{Event, PlanStatus};
        use crate::ids::{IdeaId, NodeId, PlanId};
        let mut f = Funnel::default();
        let idea = IdeaId::seq(1);
        let plan = PlanId::seq(1);
        let n1 = NodeId::seq(1);
        let n2 = NodeId::seq(2);
        let now = Utc::now();
        f.apply(&Event::IdeaSubmitted {
            id: idea.clone(),
            source: "human:alice".into(),
            text: "build the funnel prompt planner".into(),
            refs: vec![],
            item_kind: ItemKind::Idea,
            ts: now,
        })
        .unwrap();
        f.apply(&Event::IdeaModeSet {
            idea_id: idea.clone(),
            mode: "auto".into(),
            ts: now,
        })
        .unwrap();
        f.apply(&Event::PlanCreated {
            id: plan.clone(),
            idea_id: idea.clone(),
            summary: "decompose (idea) → 2 atomic subtask(s)".into(),
            planner: "decompose".into(),
            ts: now,
        })
        .unwrap();
        f.apply(&Event::PlanStatusChanged {
            plan_id: plan.clone(),
            status: PlanStatus::Active,
            why: None,
            ts: now,
        })
        .unwrap();
        for (nid, title, kind) in [
            (&n1, "implement `foo` in a.rs", "code:write"),
            (&n2, "implement `bar_test` in b.rs", "test:write"),
        ] {
            f.apply(&Event::NodeAdded {
                plan_id: plan.clone(),
                node_id: nid.clone(),
                kind: kind.into(),
                params: Default::default(),
                targets: vec![],
                prompt_excerpt: Some(title.into()),
                ts: now,
            })
            .unwrap();
        }
        (f, plan, n1, n2)
    }

    /// Task nodes project into cards distinct from idea cards: right id, type
    /// (from node_kind), reporter (the mother's source), and default `Inbox`
    /// lane/`None` assignee until a `CardStateChanged`/`CardAssigned` lands.
    #[test]
    fn cards_from_funnel_includes_task_nodes() {
        let (f, _plan, n1, n2) = funnel_with_tasks();
        let cards = cards_from_funnel(&f);
        // i-001 (the mother) + n-001 + n-002.
        assert_eq!(cards.len(), 3);
        let by_id = |id: &str| cards.iter().find(|c| c.id == id).unwrap();
        let mother = by_id("i-001");
        assert_eq!(mother.card_type, CardType::Feature);
        assert_eq!(mother.assignee, None);

        let t1 = by_id(n1.as_str());
        assert_eq!(t1.card_type, CardType::Feature, "code:write → Feature");
        assert_eq!(
            t1.reporter, "human:alice",
            "task card reporter = mother's source"
        );
        assert_eq!(
            t1.lane,
            Lane::Inbox,
            "no CardStateChanged yet → default Inbox"
        );
        assert_eq!(t1.assignee, None);
        assert_eq!(t1.plan_ids, vec!["p-001".to_string()]);

        let t2 = by_id(n2.as_str());
        assert_eq!(t2.card_type, CardType::Test, "test:write → Test");
    }

    /// `Event::CardStateChanged` moves a task card's lane (Proposed, the
    /// Manual-mode staging lane) and `Event::CardAssigned` sets its assignee —
    /// the exact mechanism `nornir funnel prompt --manual` / `assign` use.
    #[test]
    fn card_state_changed_and_assigned_are_reflected_in_the_projection() {
        use crate::event::Event;
        let (mut f, _plan, n1, _n2) = funnel_with_tasks();
        let now = Utc::now();
        f.apply(&Event::CardStateChanged {
            node: n1.as_str().to_string(),
            from_lane: None,
            to_lane: "proposed".into(),
            why: Some("manual prompt decompose".into()),
            ts: now,
        })
        .unwrap();
        f.apply(&Event::CardAssigned {
            node: n1.as_str().to_string(),
            member: "ada".into(),
            role: Some("coder".into()),
            why: Some("auto-assign: code:write → Coder".into()),
            ts: now,
        })
        .unwrap();

        let cards = cards_from_funnel(&f);
        let t1 = cards.iter().find(|c| c.id == n1.as_str()).unwrap();
        assert_eq!(t1.lane, Lane::Proposed);
        assert_eq!(t1.assignee, Some("ada".to_string()));

        // An unknown card id is rejected (validity check), never silently accepted.
        let err = f
            .apply(&Event::CardAssigned {
                node: "n-999".into(),
                member: "ada".into(),
                role: None,
                why: None,
                ts: now,
            })
            .unwrap_err();
        assert!(format!("{err:#}").contains("unknown card"));
    }

    /// `assignee:`/`for:` smart-query operators filter on the new assignee facet
    /// — `assignee:` is a pure assignee match, `for:` matches reporter OR
    /// assignee (existing `for:me`-on-reporter behaviour keeps working).
    #[test]
    fn smart_query_assignee_and_for() {
        let (mut f, _plan, n1, n2) = funnel_with_tasks();
        let now = Utc::now();
        f.apply(&crate::event::Event::CardAssigned {
            node: n1.as_str().to_string(),
            member: "ada".into(),
            role: Some("coder".into()),
            why: None,
            ts: now,
        })
        .unwrap();
        let cards = cards_from_funnel(&f);

        // assignee:ada → only n1.
        let r = query_cards(&cards, "assignee:ada");
        assert_eq!(
            r.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(),
            [n1.as_str()]
        );

        // for:ada → also only n1 (assigned, though reported by nobody named ada).
        let r = query_cards(&cards, "for:ada");
        assert_eq!(
            r.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(),
            [n1.as_str()]
        );

        // for:alice → every card reports back to the mother's source
        // "human:alice" (task cards inherit the mother's reporter) — proves
        // the reporter side of the OR still works.
        let r = query_cards(&cards, "for:alice");
        assert_eq!(r.len(), 3, "mother + both task cards all report to alice");

        // n2 has neither reporter "ada" nor assignee "ada".
        assert!(!query_cards(&cards, "assignee:ada")
            .iter()
            .any(|c| c.id == n2.as_str()));
        // for:ada is assignee-only for n2/mother (no "ada" in "human:alice"),
        // so only the explicitly-assigned n1 matches — proves for: also honours
        // the assignee side of the OR distinctly from the blanket reporter match.
        assert_eq!(query_cards(&cards, "for:ada").len(), 1);
    }
}