use std::collections::HashMap;
use std::fmt;
use serde::Deserialize;
use crate::model::badges::Badged;
use crate::model::join::AgentRef;
use crate::model::snapshot::{Counts, Node};
use crate::model::types::Status;
use crate::view::fitted;
use crate::view::phrase;
pub const AGENT: char = '◍';
pub const WARNING: char = '⚠';
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct Progress {
pub closed: usize,
pub total: usize,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Row {
pub status: Status,
pub glyph: char,
pub id: String,
pub title: String,
pub badges: Vec<Badged>,
pub progress: Option<Progress>,
pub agent: Option<String>,
pub agent_briefly: Option<String>,
pub anomalies: Vec<String>,
pub shut_over: Option<Counts>,
pub notes: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
pub enum Cell {
Glyph,
Id,
Title,
Badges,
Progress,
Agent,
Anomalies,
Badge(String),
}
const BUILT_IN: [(&str, Cell); 7] = [
("glyph", Cell::Glyph),
("id", Cell::Id),
("title", Cell::Title),
("badges", Cell::Badges),
("progress", Cell::Progress),
("agent", Cell::Agent),
("anomalies", Cell::Anomalies),
];
const ONE_BADGE: &str = "badge.";
impl fmt::Display for Cell {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Cell::Badge(key) => write!(f, "{ONE_BADGE}{key}"),
built_in => {
let (name, _) = BUILT_IN
.iter()
.find(|(_, cell)| cell == built_in)
.expect("every built-in cell has a name a config writes");
f.write_str(name)
}
}
}
}
impl<'de> Deserialize<'de> for Cell {
fn deserialize<D: serde::Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
let written = String::deserialize(deserializer)?;
if let Some((_, cell)) = BUILT_IN.iter().find(|(name, _)| *name == written) {
return Ok(cell.clone());
}
match written
.strip_prefix(ONE_BADGE)
.filter(|key| !key.is_empty())
{
Some(key) => Ok(Cell::Badge(key.to_string())),
None => Err(serde::de::Error::custom(format!(
"{written:?} is no cell of a row; bdi draws {}, or one badge as {ONE_BADGE}<key>",
BUILT_IN
.iter()
.map(|(name, _)| *name)
.collect::<Vec<_>>()
.join(", ")
))),
}
}
}
#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
#[serde(default, deny_unknown_fields)]
pub struct Layout {
pub identity: Vec<Cell>,
pub title: Vec<Cell>,
pub state: Vec<Cell>,
}
impl Default for Layout {
fn default() -> Self {
Self {
identity: vec![Cell::Glyph, Cell::Id],
title: vec![Cell::Title, Cell::Badges],
state: vec![Cell::Progress, Cell::Agent, Cell::Anomalies],
}
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Widths(HashMap<Cell, usize>);
impl Widths {
pub fn of(&self, cell: &Cell) -> usize {
self.0.get(cell).copied().unwrap_or(0)
}
pub fn widen(&mut self, cell: &Cell, width: usize) {
let entry = self.0.entry(cell.clone()).or_default();
*entry = (*entry).max(width);
}
pub fn merge(&mut self, other: &Widths) {
for (cell, width) in &other.0 {
self.widen(cell, *width);
}
}
}
impl FromIterator<(Cell, usize)> for Widths {
fn from_iter<I: IntoIterator<Item = (Cell, usize)>>(cells: I) -> Self {
let mut widths = Widths::default();
for (cell, width) in cells {
widths.widen(&cell, width);
}
widths
}
}
impl<const N: usize> From<[(Cell, usize); N]> for Widths {
fn from(cells: [(Cell, usize); N]) -> Self {
cells.into_iter().collect()
}
}
impl Layout {
pub(crate) fn block(&self, block: fitted::Block) -> &[Cell] {
match block {
fitted::Block::Identity => &self.identity,
fitted::Block::Title => &self.title,
fitted::Block::State => &self.state,
}
}
pub fn names(&self, key: &str) -> bool {
self.cells()
.any(|cell| matches!(cell, Cell::Badge(named) if named == key))
}
pub fn cells(&self) -> impl Iterator<Item = &Cell> {
[&self.identity, &self.title, &self.state]
.into_iter()
.flatten()
}
}
pub fn says_the_same_about_its_link(badge: &Badged, said: &str) -> bool {
badge
.link
.as_deref()
.is_none_or(|to| fitted::openable(said, to) == fitted::openable(&badge.text, to))
}
pub fn cells(
node: &Node,
above: Option<&str>,
progress: Option<Progress>,
shut_over: Option<Counts>,
) -> Row {
let mut notes = Vec::new();
notes.extend(
shut_over
.as_ref()
.filter(|_| node.status.is_closed())
.map(Counts::unfinished)
.filter(|unfinished| *unfinished > 0)
.map(phrase::unfinished_beneath),
);
notes.extend(phrase::unrecognised_status(&node.status));
notes.extend(node.undrawn.iter().map(phrase::undrawn));
notes.extend(
node.badges
.iter()
.filter(|badge| {
badge
.link
.as_deref()
.is_some_and(|to| !fitted::openable(&badge.text, to))
})
.map(|badge| phrase::unopenable_link(&badge.key)),
);
notes.extend(
node.badges
.iter()
.filter(|badge| {
badge
.short
.as_deref()
.is_some_and(|said| !says_the_same_about_its_link(badge, said))
})
.map(|badge| phrase::unopenable_short(&badge.key)),
);
Row {
status: node.status.clone(),
glyph: status_glyph(&node.status),
id: abbreviate(&node.id, above).to_string(),
title: node.title.clone(),
badges: node.badges.clone(),
progress,
agent: node.agent.as_ref().map(agent_marker),
agent_briefly: node.agent.as_ref().map(agent_briefly),
anomalies: node.anomalies.iter().map(phrase::anomaly).collect(),
shut_over,
notes,
}
}
pub fn status_glyph(status: &Status) -> char {
match status {
Status::Open => '○',
Status::InProgress => '◐',
Status::Blocked => '●',
Status::Closed => '✓',
Status::Deferred => '❄',
Status::Other(_) => '?',
}
}
pub fn abbreviate<'a>(id: &'a str, above: Option<&str>) -> &'a str {
above
.and_then(|above| id.strip_prefix(above))
.filter(|rest| rest.starts_with('.'))
.unwrap_or(id)
}
pub fn agent_marker(agent: &AgentRef) -> String {
named(agent, agent.title.as_deref().unwrap_or(&agent.pane.id))
}
pub fn agent_briefly(agent: &AgentRef) -> String {
named(agent, &agent.pane.id)
}
fn named(agent: &AgentRef, doing: &str) -> String {
let mut said = vec![
format!("{AGENT} {doing}"),
phrase::pane_state(&agent.pane_status),
];
said.extend(phrase::join_caveat(agent.source).map(str::to_string));
said.join(" · ")
}
pub fn anomaly_marker(said: &[String]) -> Option<String> {
(!said.is_empty()).then(|| format!("{WARNING} {}", said.join(" · ")))
}
pub fn anomaly_alone(said: &str) -> String {
format!("{WARNING} {said}")
}
#[cfg(test)]
mod tests {
use super::*;
use crate::model::anomaly::Anomaly;
use crate::model::badges::{Badged, Undrawn};
use crate::model::join::JoinSource;
use crate::model::types::testing::key;
use crate::model::types::PaneStatus;
use pretty_assertions::assert_eq;
const ROOT: &str = "smt-4kd3p";
fn node(id: &str, status: Status) -> Node {
Node {
id: id.into(),
title: "wallpaper timer calls dms".into(),
status,
issue_type: "task".into(),
priority: 2,
ready: false,
blocked_by: Vec::new(),
started_at: None,
closed_at: None,
badges: Vec::new(),
undrawn: Vec::new(),
agent: None,
anomalies: Vec::new(),
description: String::new(),
notes: String::new(),
created_by: None,
assignee: None,
labels: Vec::new(),
created_at: None,
updated_at: None,
parent: None,
depends_on: Vec::new(),
blocks: Vec::new(),
}
}
fn agent(source: JoinSource) -> AgentRef {
AgentRef {
pane: key("wCM:p9"),
pane_status: PaneStatus::Working,
title: Some("shell selector".into()),
source,
}
}
fn unlabelled(source: JoinSource) -> AgentRef {
AgentRef {
title: None,
..agent(source)
}
}
#[test]
fn every_glyph_is_the_one_bd_lists_beside_that_status_in_its_own_legend() {
assert_eq!(status_glyph(&Status::Open), '○');
assert_eq!(status_glyph(&Status::InProgress), '◐');
assert_eq!(status_glyph(&Status::Blocked), '●');
assert_eq!(status_glyph(&Status::Closed), '✓');
assert_eq!(status_glyph(&Status::Deferred), '❄');
}
#[test]
fn the_glyph_is_the_beads_own_status_and_no_two_statuses_share_one() {
let statuses = [
Status::InProgress,
Status::Blocked,
Status::Open,
Status::Deferred,
Status::Closed,
Status::Other("triage".into()),
];
let mut glyphs: Vec<char> = statuses.iter().map(status_glyph).collect();
let drawn = glyphs.len();
glyphs.sort_unstable();
glyphs.dedup();
assert_eq!(glyphs.len(), drawn);
}
#[test]
fn the_glyph_does_not_move_when_an_agent_arrives() {
let mut staffed = node("smt-4kd3p.20", Status::InProgress);
staffed.agent = Some(agent(JoinSource::AgentPane));
assert_eq!(
cells(&staffed, Some(ROOT), None, None).glyph,
cells(
&node("smt-4kd3p.20", Status::InProgress),
Some(ROOT),
None,
None
)
.glyph
);
}
#[test]
fn a_node_shows_only_what_it_adds_to_the_id_above_it() {
assert_eq!(abbreviate("smt-4kd3p.20", Some(ROOT)), ".20");
assert_eq!(abbreviate("smt-4kd3p.1.4", Some("smt-4kd3p.1")), ".4");
}
#[test]
fn a_node_that_does_not_descend_from_the_id_above_it_keeps_its_whole_id() {
assert_eq!(abbreviate("mdw-6qzt4.3", Some(ROOT)), "mdw-6qzt4.3");
assert_eq!(abbreviate("smt-4kd3pX.3", Some(ROOT)), "smt-4kd3pX.3");
}
#[test]
fn a_node_with_nothing_above_it_keeps_its_whole_id() {
assert_eq!(abbreviate(ROOT, None), ROOT);
}
#[test]
fn a_live_agent_is_marked_with_what_it_is_doing_and_not_with_its_pane() {
let said = agent_marker(&agent(JoinSource::AgentPane));
assert_eq!(said, "◍ shell selector · working");
}
#[test]
fn a_pane_that_says_what_it_is_doing_never_shows_its_id() {
let said = agent_marker(&agent(JoinSource::DisplayAgent));
assert!(!said.contains("wCM:p9"), "{said}");
}
#[test]
fn a_pane_that_has_said_nothing_falls_back_to_the_id_it_cannot_lose() {
let said = agent_marker(&unlabelled(JoinSource::AgentPane));
assert_eq!(said, "◍ wCM:p9 · working");
}
#[test]
fn an_agent_the_bead_never_named_is_marked_as_inferred() {
let said = agent_marker(&agent(JoinSource::DisplayAgent));
assert!(said.contains("inferred, not confirmed"), "{said}");
}
#[test]
fn the_join_caveat_follows_the_state_rather_than_the_caption() {
let said = agent_marker(&agent(JoinSource::DisplayAgent));
assert_eq!(said, "◍ shell selector · working · inferred, not confirmed");
}
#[test]
fn a_caption_is_kept_apart_from_the_state_that_follows_it() {
let waiting = AgentRef {
pane_status: PaneStatus::Blocked,
..agent(JoinSource::AgentPane)
};
assert_eq!(
agent_marker(&waiting),
"◍ shell selector · waiting at a prompt"
);
}
#[test]
fn a_row_carries_every_anomaly_that_fired_rather_than_the_first() {
let mut odd = node("smt-4kd3p.20", Status::InProgress);
odd.anomalies = vec![
Anomaly::OrphanClaim { refused: None },
Anomaly::StaleClaim { days: 58 },
];
let row = cells(&odd, Some(ROOT), None, None);
let said = anomaly_marker(&row.anomalies).expect("two rules fired");
assert_eq!(row.anomalies.len(), 2, "{:?}", row.anomalies);
assert!(said.contains("no pane"), "{said}");
assert!(said.contains("58"), "{said}");
}
#[test]
fn a_bead_with_nothing_wrong_carries_no_marker_at_all() {
let row = cells(&node("smt-4kd3p.20", Status::Open), Some(ROOT), None, None);
assert_eq!(row.anomalies, Vec::<String>::new());
assert_eq!(row.agent, None);
assert_eq!(row.notes, Vec::<String>::new());
}
const HOSTILE: &str = "\u{1b}]0;owned\u{7}";
const SOMEWHERE: &str = "https://forge.invalid/dunwich/arkham/pull/12";
fn badged(text: &str, link: Option<&str>) -> Node {
let mut node = node("smt-4kd3p.20", Status::Open);
node.badges = vec![Badged {
key: "delivery_pr".into(),
text: text.into(),
link: link.map(str::to_string),
short: None,
colour: None,
}];
node
}
#[test]
fn a_link_the_value_could_not_fill_leaves_its_key_on_the_row() {
let mut unfilled = node("smt-4kd3p.20", Status::Open);
unfilled.undrawn = vec![Undrawn::Link {
key: "delivery_pr".into(),
}];
let row = cells(&unfilled, Some(ROOT), None, None);
assert!(
row.notes.iter().any(|note| note.contains("delivery_pr")),
"{row:?}"
);
}
#[test]
fn a_link_holding_a_control_character_leaves_its_key_on_the_row() {
let hostile = badged(
"⇢ #12",
Some(&format!("https://forge.invalid/dunwich{HOSTILE}/pull/12")),
);
let row = cells(&hostile, Some(ROOT), None, None);
assert!(
row.notes.iter().any(|note| note.contains("delivery_pr")),
"{row:?}"
);
}
#[test]
fn a_badges_own_words_holding_a_control_character_lose_the_link_too() {
let hostile = badged(
&format!("⇢ #12{HOSTILE}"),
Some("https://forge.invalid/dunwich/arkham/pull/12"),
);
let row = cells(&hostile, Some(ROOT), None, None);
assert!(
row.notes.iter().any(|note| note.contains("delivery_pr")),
"{row:?}"
);
}
#[test]
fn a_short_form_holding_a_control_character_leaves_its_key_on_the_row() {
let mut hostile = badged("⇢ arkham #12", Some(SOMEWHERE));
hostile.badges[0].short = Some(format!("⇢ #12{HOSTILE}"));
let row = cells(&hostile, Some(ROOT), None, None);
assert!(
row.notes
.iter()
.any(|note| note.contains("short form") && note.contains("delivery_pr")),
"{row:?}"
);
}
#[test]
fn a_short_form_on_a_badge_with_no_link_is_held_to_no_such_rule() {
let mut unlinked = badged("⏸ waiting", None);
unlinked.badges[0].short = Some(format!("⏸{HOSTILE}"));
let row = cells(&unlinked, Some(ROOT), None, None);
assert_eq!(row.notes, Vec::<String>::new());
}
#[test]
fn a_badge_that_draws_its_link_leaves_nothing_on_the_row() {
let linked = badged("⇢ #12", Some(SOMEWHERE));
let row = cells(&linked, Some(ROOT), None, None);
assert_eq!(row.notes, Vec::<String>::new());
}
#[test]
fn a_badge_that_draws_both_its_forms_leaves_nothing_on_the_row() {
let mut both = badged("⇢ arkham #12", Some(SOMEWHERE));
both.badges[0].short = Some("⇢ #12".into());
let row = cells(&both, Some(ROOT), None, None);
assert_eq!(row.notes, Vec::<String>::new());
}
#[test]
fn a_badge_with_no_link_leaves_nothing_on_the_row() {
let row = cells(&badged("⏸ waiting", None), Some(ROOT), None, None);
assert_eq!(row.notes, Vec::<String>::new());
}
#[test]
fn a_status_outside_bds_own_set_leaves_the_word_bd_used_on_the_row() {
let odd = node("smt-4kd3p.20", Status::Other("triage".into()));
let row = cells(&odd, Some(ROOT), None, None);
assert_eq!(row.glyph, '?');
assert!(
row.notes.iter().any(|note| note.contains("triage")),
"{row:?}"
);
}
#[test]
fn badges_are_drawn_in_the_order_they_were_configured() {
let mut badged = node("smt-4kd3p.20", Status::Blocked);
badged.badges = vec![
Badged {
key: "delivery_pr".into(),
text: "⇢ #12".into(),
link: None,
short: None,
colour: None,
},
Badged {
key: "blocked_on".into(),
text: "⏸ waiting".into(),
link: None,
short: None,
colour: None,
},
];
let row = cells(&badged, Some(ROOT), None, None);
let drawn: Vec<&str> = row.badges.iter().map(|b| b.text.as_str()).collect();
assert_eq!(drawn, vec!["⇢ #12", "⏸ waiting"]);
}
#[test]
fn a_badge_carries_its_link_to_the_row_beside_the_text_it_draws() {
let mut badged = node("smt-4kd3p.20", Status::Blocked);
badged.badges = vec![
Badged {
key: "delivery_pr".into(),
text: "⇢ #12".into(),
link: Some("https://forge.invalid/dunwich/arkham/pull/12".into()),
short: None,
colour: None,
},
Badged {
key: "blocked_on".into(),
text: "⏸ waiting".into(),
link: None,
short: None,
colour: None,
},
];
let row = cells(&badged, Some(ROOT), None, None);
assert_eq!(
row.badges[0].link.as_deref(),
Some("https://forge.invalid/dunwich/arkham/pull/12")
);
assert_eq!(row.badges[1].link, None);
assert!(
!row.badges[0].text.contains("forge.invalid"),
"the URL is in the text the row draws: {:?}",
row.badges[0].text
);
}
#[test]
fn a_row_says_what_the_bead_says() {
let row = cells(
&node("smt-4kd3p.20", Status::Blocked),
Some(ROOT),
None,
None,
);
assert_eq!(row.status, Status::Blocked);
assert_eq!(row.glyph, '●');
assert_eq!(row.id, ".20");
assert_eq!(row.title, "wallpaper timer calls dms");
}
}