use serde::{Deserialize, Serialize};
use super::event::ItemKind;
use super::history::{HistoryItem, HistoryStatus};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum CardType {
Feature,
Bug,
Test,
}
impl CardType {
#[must_use]
pub fn from_item_kind(k: ItemKind) -> Self {
match k {
ItemKind::Idea => CardType::Feature,
ItemKind::Error => CardType::Bug,
ItemKind::Test => CardType::Test,
}
}
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
CardType::Feature => "feature",
CardType::Bug => "bug",
CardType::Test => "test",
}
}
#[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,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Lane {
Inbox,
Accepted,
Planned,
Dropped,
}
impl Lane {
#[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,
}
}
#[must_use]
pub fn as_str(&self) -> &'static str {
match self {
Lane::Inbox => "inbox",
Lane::Accepted => "accepted",
Lane::Planned => "planned",
Lane::Dropped => "dropped",
}
}
#[must_use]
pub fn title(&self) -> &'static str {
match self {
Lane::Inbox => "Inbox",
Lane::Accepted => "Accepted",
Lane::Planned => "Planned",
Lane::Dropped => "Dropped",
}
}
#[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),
"planned" | "done" => Some(Lane::Planned),
"dropped" => Some(Lane::Dropped),
_ => None,
}
}
#[must_use]
pub fn board_order() -> [Lane; 4] {
[Lane::Inbox, Lane::Accepted, Lane::Planned, Lane::Dropped]
}
#[must_use]
pub fn is_open(&self) -> bool {
matches!(self, Lane::Inbox | Lane::Accepted)
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Card {
pub id: String,
pub title: String,
pub body: String,
pub card_type: CardType,
pub lane: Lane,
pub reporter: String,
pub created: String,
pub plan_ids: Vec<String>,
pub labels: Vec<String>,
}
#[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
}
#[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,
}
}
#[must_use]
pub fn cards_from_history(items: &[HistoryItem]) -> Vec<Card> {
items.iter().map(card_from_history).collect()
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum Sort {
#[default]
Newest,
Oldest,
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,
}
}
}
#[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>,
pub labels: Vec<String>,
pub has_plan: Option<bool>,
pub text_terms: Vec<String>,
pub sort: Sort,
}
#[must_use]
pub fn parse_query(q: &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" | "for" | "assignee" => {
let v = if val.eq_ignore_ascii_case("me") { "rickard".to_string() } else { val.to_ascii_lowercase() };
out.reporter_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;
}
}
_ => out.text_terms.push(tok.to_ascii_lowercase()),
}
} else {
out.text_terms.push(tok.to_ascii_lowercase());
}
}
out
}
impl Query {
#[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();
for r in &self.reporter_terms {
if !reporter_lc.contains(r) {
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
}
}
#[must_use]
pub fn query_cards(cards: &[Card], q: &str) -> Vec<Card> {
let query = parse_query(q);
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
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Column {
pub lane: Lane,
pub title: String,
pub cards: Vec<Card>,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Board {
pub columns: Vec<Column>,
pub total: usize,
}
#[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 }
}
#[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::*;
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:rickard", "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:rickard", "2026-06-29T08:00:00+00:00", &[]),
]
}
#[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:rickard");
assert!(c.labels.contains(&"feature".to_string()) && c.labels.contains(&"inbox".to_string()));
assert!(c.labels.contains(&"board".to_string()), "mined the #board hashtag");
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()));
}
#[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());
}
#[test]
fn smart_query_operators() {
let cards = cards_from_history(&sample());
let r = query_cards(&cards, "type:bug");
assert_eq!(r.iter().map(|c| c.id.as_str()).collect::<Vec<_>>(), ["i-2"]);
assert_eq!(query_cards(&cards, "kind:feature lane:dropped").iter().map(|c| c.id.clone()).collect::<Vec<_>>(), ["i-4"]);
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");
let mine: Vec<String> = query_cards(&cards, "for:me").iter().map(|c| c.id.clone()).collect();
assert_eq!(mine, ["i-1", "i-4"]);
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);
assert_eq!(query_cards(&cards, "label:funnel").iter().map(|c| c.id.clone()).collect::<Vec<_>>(), ["i-2"]);
assert_eq!(query_cards(&cards, "cobol").iter().map(|c| c.id.clone()).collect::<Vec<_>>(), ["i-4"]);
assert_eq!(query_cards(&cards, "type:feature for:rickard is:open").iter().map(|c| c.id.clone()).collect::<Vec<_>>(), ["i-1"]);
}
#[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"]);
}
#[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(), 4);
let lanes: Vec<&str> = b.columns.iter().map(|c| c.lane.as_str()).collect();
assert_eq!(lanes, ["inbox", "accepted", "planned", "dropped"], "fixed left→right order");
let counts: Vec<usize> = b.columns.iter().map(|c| c.cards.len()).collect();
assert_eq!(counts, [1, 1, 1, 1]);
assert_eq!(b.columns[0].cards[0].id, "i-1");
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"][2]["cards"][0]["plans"], 1, "the planned bug shows 1 linked plan");
#[cfg(feature = "testmatrix")]
{
let ok = b.total == 4 && lanes == ["inbox", "accepted", "planned", "dropped"];
crate::selftest::emit(
"nornir::funnel::card",
"board_projection_groups_lanes",
ok,
&format!("total={} lanes={:?} counts={:?}", b.total, lanes, counts),
);
}
}
#[test]
fn empty_funnel_is_honest_empty_board() {
let b = board(&cards_from_history(&[]));
assert_eq!(b.total, 0);
assert_eq!(b.columns.len(), 4);
assert!(b.columns.iter().all(|c| c.cards.is_empty()));
}
}