//! **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);
}
}