use std::fmt;
use std::str::FromStr;
use anyhow::Context;
use comfy_table::{ContentArrangement, Table, TableComponent};
use regex_lite::Regex;
use serde::{Deserialize, Serialize};
pub(crate) fn canonical_id(prefix: &str, id: u32) -> String {
format!("{prefix}-{id:03}")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Deserialize)]
pub(crate) enum Format {
#[default]
Table,
Json,
}
impl fmt::Display for Format {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.write_str(match self {
Format::Table => "table",
Format::Json => "json",
})
}
}
impl FromStr for Format {
type Err = anyhow::Error;
fn from_str(s: &str) -> Result<Self, Self::Err> {
match s {
"table" => Ok(Format::Table),
"json" => Ok(Format::Json),
other => Err(anyhow::anyhow!(
"unknown format `{other}` (expected `table` or `json`)"
)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct FilterFields {
pub(crate) canonical: String,
pub(crate) slug: String,
pub(crate) title: String,
pub(crate) status: String,
pub(crate) tags: Vec<String>,
}
pub(crate) struct Filter {
pub(crate) substr: Option<String>,
pub(crate) regex: Option<Regex>,
pub(crate) status: Vec<String>,
pub(crate) tags: Vec<String>,
pub(crate) all: bool,
}
impl fmt::Debug for Filter {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
f.debug_struct("Filter")
.field("substr", &self.substr)
.field("regex", &self.regex.as_ref().map(Regex::as_str))
.field("status", &self.status)
.field("tags", &self.tags)
.field("all", &self.all)
.finish()
}
}
#[derive(Debug, Default, Deserialize)]
#[serde(default)]
pub(crate) struct ListArgs {
pub(crate) substr: Option<String>,
pub(crate) regexp: Option<String>,
pub(crate) case_insensitive: bool,
pub(crate) status: Vec<String>,
pub(crate) tags: Vec<String>,
pub(crate) all: bool,
pub(crate) format: Format,
pub(crate) json: bool,
pub(crate) columns: Option<Vec<String>>,
pub(crate) render: RenderOpts,
}
#[derive(Debug, Clone, Copy, Default, Deserialize)]
pub(crate) struct RenderOpts {
pub(crate) color: bool,
pub(crate) term_width: Option<u16>,
}
pub(crate) fn build(args: ListArgs) -> anyhow::Result<(Filter, Format)> {
let regex = match args.regexp {
None => None,
Some(pattern) => {
let pattern = if args.case_insensitive {
format!("(?i){pattern}")
} else {
pattern
};
let compiled =
Regex::new(&pattern).with_context(|| format!("invalid regex `{pattern}`"))?;
Some(compiled)
}
};
let filter = Filter {
substr: args.substr.map(|s| s.to_lowercase()),
regex,
status: args.status,
tags: args.tags,
all: args.all,
};
let resolved = if args.json { Format::Json } else { args.format };
Ok((filter, resolved))
}
pub(crate) fn retain<R>(
rows: Vec<R>,
f: &Filter,
is_hidden: impl Fn(&str) -> bool,
key: impl Fn(&R) -> FilterFields,
) -> Vec<R> {
let reveal_hidden = f.all || !f.status.is_empty();
rows.into_iter()
.filter(|row| {
let fields = key(row);
if !reveal_hidden && is_hidden(&fields.status) {
return false;
}
substr_admits(f, &fields)
&& regex_admits(f, &fields)
&& status_admits(f, &fields)
&& tags_admit(f, &fields)
})
.collect()
}
fn substr_admits(f: &Filter, fields: &FilterFields) -> bool {
match &f.substr {
None => true,
Some(needle) => {
fields.slug.to_lowercase().contains(needle)
|| fields.title.to_lowercase().contains(needle)
}
}
}
fn regex_admits(f: &Filter, fields: &FilterFields) -> bool {
match &f.regex {
None => true,
Some(re) => {
re.is_match(&fields.canonical)
|| re.is_match(&fields.slug)
|| re.is_match(&fields.title)
}
}
}
fn status_admits(f: &Filter, fields: &FilterFields) -> bool {
f.status.is_empty() || f.status.contains(&fields.status)
}
fn tags_admit(f: &Filter, fields: &FilterFields) -> bool {
f.tags.is_empty() || f.tags.iter().any(|t| fields.tags.contains(t))
}
pub(crate) fn validate_statuses(given: &[String], known: &[&str]) -> anyhow::Result<()> {
if let Some(bad) = given.iter().find(|s| !known.contains(&s.as_str())) {
let known = known.join(", ");
anyhow::bail!("unknown status `{bad}` (known: {known})");
}
Ok(())
}
pub(crate) struct Column<R> {
pub(crate) name: &'static str,
pub(crate) header: &'static str,
pub(crate) cell: fn(&R) -> String,
pub(crate) paint: ColumnPaint<R>,
}
pub(crate) enum ColumnPaint<R> {
None,
Fixed(owo_colors::DynColors),
ByValue(fn(&R) -> Option<owo_colors::DynColors>),
PerToken {
split: fn(&R) -> Vec<String>,
render: fn(&str) -> String,
},
Alternate([owo_colors::DynColors; 2]),
}
pub(crate) fn status_hue(s: &str) -> Option<owo_colors::DynColors> {
use owo_colors::{
AnsiColors::{Green, Red, Yellow},
DynColors,
};
match s {
"done" | "active" | "accepted" | "required" => Some(DynColors::Ansi(Green)),
"design" | "plan" | "ready" | "started" | "audit" | "reconcile" => {
Some(DynColors::Ansi(Yellow))
}
"blocked" | "abandoned" | "contested" => Some(DynColors::Ansi(Red)),
_ => None,
}
}
pub(crate) fn status_colored(status: &str, color: bool) -> String {
use owo_colors::OwoColorize;
if !color {
return status.to_string();
}
match status_hue(status) {
Some(hue) => status.color(hue).to_string(),
None => status.to_string(),
}
}
pub(crate) fn backlog_kind_hue(kind: &str) -> Option<owo_colors::DynColors> {
use owo_colors::{
AnsiColors::{Blue, Green, Magenta, Red, Yellow},
DynColors,
};
match kind {
"issue" => Some(DynColors::Ansi(Red)),
"improvement" => Some(DynColors::Ansi(Green)),
"chore" => Some(DynColors::Ansi(Yellow)),
"risk" => Some(DynColors::Ansi(Magenta)),
"idea" => Some(DynColors::Ansi(Blue)),
_ => None,
}
}
pub(crate) fn memory_type_hue(kind: &str) -> Option<owo_colors::DynColors> {
use owo_colors::{
AnsiColors::{Blue, Cyan, Green, Magenta, Red, Yellow},
DynColors,
};
match kind {
"concept" => Some(DynColors::Ansi(Cyan)),
"fact" => Some(DynColors::Ansi(Green)),
"pattern" => Some(DynColors::Ansi(Magenta)),
"signpost" => Some(DynColors::Ansi(Blue)),
"system" => Some(DynColors::Ansi(Yellow)),
"thread" => Some(DynColors::Ansi(Red)),
_ => None,
}
}
pub(crate) fn trust_hue(trust: &str) -> Option<owo_colors::DynColors> {
use owo_colors::{
AnsiColors::{Green, Red, Yellow},
DynColors,
};
match trust {
"high" => Some(DynColors::Ansi(Green)),
"medium" => Some(DynColors::Ansi(Yellow)),
"low" => Some(DynColors::Ansi(Red)),
_ => None,
}
}
const TAG_PALETTE: [owo_colors::Rgb; 12] = [
owo_colors::Rgb(204, 36, 29), owo_colors::Rgb(152, 151, 26), owo_colors::Rgb(215, 153, 33), owo_colors::Rgb(69, 133, 136), owo_colors::Rgb(177, 98, 134), owo_colors::Rgb(104, 157, 106), owo_colors::Rgb(214, 93, 14), owo_colors::Rgb(250, 189, 47), owo_colors::Rgb(131, 165, 152), owo_colors::Rgb(211, 134, 155), owo_colors::Rgb(142, 192, 124), owo_colors::Rgb(254, 128, 25), ];
pub(crate) const TITLE_EVEN: owo_colors::DynColors = owo_colors::DynColors::Rgb(255, 230, 195); pub(crate) const TITLE_ODD: owo_colors::DynColors = owo_colors::DynColors::Rgb(213, 196, 161);
fn stable_hash(seg: &str) -> u32 {
let mut hash: u32 = 0x811c_9dc5;
for byte in seg.bytes() {
hash ^= u32::from(byte);
hash = hash.wrapping_mul(0x0100_0193);
}
hash
}
fn segment_hue(seg: &str) -> Option<owo_colors::DynColors> {
if seg.is_empty() {
return None;
}
let len = u32::try_from(TAG_PALETTE.len()).unwrap_or(1);
let index = usize::try_from(stable_hash(seg) % len).unwrap_or(0);
TAG_PALETTE
.get(index)
.map(|c| owo_colors::DynColors::Rgb(c.0, c.1, c.2))
}
pub(crate) fn paint_tag(tag: &str) -> String {
use owo_colors::{AnsiColors::White, DynColors, OwoColorize};
let white = DynColors::Ansi(White);
let mut out = String::with_capacity(tag.len());
for (index, seg) in tag.split(':').enumerate() {
if index != 0 {
out.push_str(&":".color(white).to_string());
}
match segment_hue(seg) {
Some(hue) => out.push_str(&seg.color(hue).to_string()),
None => out.push_str(seg),
}
}
out
}
pub(crate) fn select_columns<'a, R>(
available: &'a [Column<R>],
default: &[&str],
requested: Option<&[String]>,
) -> anyhow::Result<Vec<&'a Column<R>>> {
debug_assert!(
requested.is_some()
|| default
.iter()
.all(|d: &&str| available.iter().any(|c| c.name == *d)),
"default column `{}` not in available set [{}]",
default
.iter()
.find(|d| !available.iter().any(|c| c.name == **d))
.unwrap_or(&"?"),
available
.iter()
.map(|c| c.name)
.collect::<Vec<_>>()
.join(", ")
);
let pick = |name: &str| {
available.iter().find(|c| c.name == name).ok_or_else(|| {
let known = available
.iter()
.map(|c| c.name)
.collect::<Vec<_>>()
.join(", ");
anyhow::anyhow!("unknown column `{name}` (available: {known})")
})
};
match requested {
None => default.iter().map(|n| pick(n)).collect(), Some(names) => names.iter().map(|n| pick(n)).collect(),
}
}
pub(crate) fn render_columns<R>(rows: &[R], cols: &[&Column<R>], opts: RenderOpts) -> String {
if rows.is_empty() {
return String::new();
}
let color = opts.color;
let mut grid: Vec<Vec<String>> = Vec::with_capacity(rows.len() + 1);
grid.push(cols.iter().map(|c| paint_header(c.header, color)).collect());
grid.extend(rows.iter().enumerate().map(|(i, r)| {
cols.iter()
.map(|c| paint_cell(&(c.cell)(r), &c.paint, r, color, i))
.collect()
}));
render_table(&grid, opts.term_width)
}
fn paint_header(header: &str, color: bool) -> String {
use owo_colors::OwoColorize;
if color {
header.bold().to_string()
} else {
header.to_string()
}
}
fn paint_cell<R>(
cell: &str,
paint: &ColumnPaint<R>,
row: &R,
color: bool,
row_index: usize,
) -> String {
use owo_colors::OwoColorize;
if !color {
return cell.to_string();
}
if let ColumnPaint::PerToken { split, render } = paint {
return split(row)
.iter()
.map(|t| render(t.as_str()))
.collect::<Vec<_>>()
.join(", ");
}
if let ColumnPaint::Alternate([even, odd]) = paint {
let hue = if row_index.is_multiple_of(2) {
*even
} else {
*odd
};
return cell.color(hue).to_string();
}
let hue = match paint {
ColumnPaint::Fixed(c) => Some(*c),
ColumnPaint::ByValue(f) => f(row),
ColumnPaint::None | ColumnPaint::PerToken { .. } | ColumnPaint::Alternate(_) => None,
};
match hue {
Some(c) => cell.color(c).to_string(),
None => cell.to_string(),
}
}
const COLUMN_SEPARATOR: char = '│';
pub(crate) fn render_table(rows: &[Vec<String>], term_width: Option<u16>) -> String {
if rows.is_empty() {
return String::new();
}
let width = rows.first().map_or(0, Vec::len);
debug_assert!(
rows.iter().all(|r| r.len() == width),
"render_table requires a rectangular grid; got ragged rows"
);
let mut table = Table::new();
let fits = |w: u16| usize::from(w) >= grid_min_width(width);
match term_width {
Some(w) if fits(w) => {
table.set_content_arrangement(ContentArrangement::Dynamic);
table.set_width(w);
}
_ => {
table.set_content_arrangement(ContentArrangement::Disabled);
}
}
table.force_no_tty();
for component in TableComponent::iter() {
table.remove_style(component);
}
table.set_style(TableComponent::VerticalLines, COLUMN_SEPARATOR);
for row in rows {
table.add_row(row.clone());
}
let last = width.saturating_sub(1);
for (index, column) in table.column_iter_mut().enumerate() {
let left = u16::from(index != 0);
let right = u16::from(index != last);
column.set_padding((left, right));
}
let rendered = table.to_string();
let mut out = String::with_capacity(rendered.len() + 1);
for line in rendered.lines() {
out.push_str(line.trim_end());
out.push('\n');
}
out
}
fn grid_min_width(cols: usize) -> usize {
(cols * 4).saturating_sub(3)
}
pub(crate) fn json_envelope<T: Serialize>(kind: &str, rows: &[T]) -> anyhow::Result<String> {
let envelope = serde_json::json!({ "kind": kind, "rows": rows });
serde_json::to_string_pretty(&envelope).context("failed to serialize list JSON envelope")
}
#[cfg(test)]
pub(crate) fn strip_ansi(s: &str) -> String {
let mut out = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(c) = chars.next() {
if c == '\u{1b}' {
for inner in chars.by_ref() {
if inner == 'm' {
break;
}
}
} else {
out.push(c);
}
}
out
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn canonical_id_prefixes_and_zero_pads_to_three() {
assert_eq!(canonical_id("SL", 25), "SL-025");
assert_eq!(canonical_id("ADR", 1), "ADR-001");
assert_eq!(canonical_id("PRD", 123), "PRD-123");
assert_eq!(canonical_id("REQ", 1234), "REQ-1234");
}
#[test]
fn format_parses_table_and_json() {
assert_eq!(Format::from_str("table").unwrap(), Format::Table);
assert_eq!(Format::from_str("json").unwrap(), Format::Json);
}
#[test]
fn format_rejects_unknown_value() {
let err = Format::from_str("yaml").unwrap_err().to_string();
assert!(err.contains("yaml"), "error names the bad value: {err}");
}
#[test]
fn format_display_round_trips_from_str() {
for f in [Format::Table, Format::Json] {
assert_eq!(Format::from_str(&f.to_string()).unwrap(), f);
}
}
fn no_filter() -> (Filter, Format) {
build(ListArgs::default()).unwrap()
}
#[test]
fn build_json_flag_forces_json_over_format_table() {
let (_f, fmt) = build(ListArgs {
format: Format::Table,
json: true,
..Default::default()
})
.unwrap();
assert_eq!(fmt, Format::Json);
}
#[test]
fn build_without_json_flag_honours_format() {
let (_f, fmt) = build(ListArgs {
format: Format::Json,
..Default::default()
})
.unwrap();
assert_eq!(fmt, Format::Json);
}
#[test]
fn build_lowercases_the_substring_once() {
let (f, _) = build(ListArgs {
substr: Some("HeLLo".into()),
..Default::default()
})
.unwrap();
assert_eq!(f.substr.as_deref(), Some("hello"));
}
#[test]
fn build_compiles_a_valid_regex() {
let (f, _) = build(ListArgs {
regexp: Some("^SL-".into()),
..Default::default()
})
.unwrap();
assert!(f.regex.is_some());
}
#[test]
fn build_invalid_regex_is_a_clean_error_not_a_panic() {
let err = build(ListArgs {
regexp: Some("(unclosed".into()),
..Default::default()
})
.unwrap_err()
.to_string();
assert!(err.contains("invalid regex"), "got: {err}");
}
#[test]
fn build_case_insensitive_bakes_the_flag_into_the_pattern() {
let (f, _) = build(ListArgs {
regexp: Some("sl".into()),
case_insensitive: true,
..Default::default()
})
.unwrap();
let re = f.regex.unwrap();
assert!(
re.is_match("SL-025"),
"case-insensitive should match uppercase"
);
}
#[derive(Debug, Clone, PartialEq, Eq)]
struct Row {
canonical: &'static str,
slug: &'static str,
title: &'static str,
status: &'static str,
tags: Vec<&'static str>,
}
fn row(
canonical: &'static str,
slug: &'static str,
title: &'static str,
status: &'static str,
tags: &[&'static str],
) -> Row {
Row {
canonical,
slug,
title,
status,
tags: tags.to_vec(),
}
}
fn fields(r: &Row) -> FilterFields {
FilterFields {
canonical: r.canonical.to_string(),
slug: r.slug.to_string(),
title: r.title.to_string(),
status: r.status.to_string(),
tags: r.tags.iter().map(|t| (*t).to_string()).collect(),
}
}
fn hidden(status: &str) -> bool {
matches!(status, "done" | "abandoned")
}
fn never_hidden(_: &str) -> bool {
false
}
fn sample() -> Vec<Row> {
vec![
row("SL-001", "alpha-thing", "Alpha Thing", "proposed", &["x"]),
row("SL-002", "beta-widget", "Beta Widget", "done", &["y"]),
row(
"SL-003",
"gamma-gadget",
"Gamma Gadget",
"started",
&["x", "z"],
),
row(
"SL-004",
"delta-doohickey",
"Delta Doohickey",
"abandoned",
&["y"],
),
]
}
fn canonicals(rows: &[Row]) -> Vec<&str> {
rows.iter().map(|r| r.canonical).collect()
}
#[test]
fn retain_default_hides_the_hide_set() {
let (f, _) = no_filter();
let kept = retain(sample(), &f, hidden, fields);
assert_eq!(canonicals(&kept), vec!["SL-001", "SL-003"]);
}
#[test]
fn retain_all_reveals_the_hide_set() {
let (f, _) = build(ListArgs {
all: true,
..Default::default()
})
.unwrap();
let kept = retain(sample(), &f, hidden, fields);
assert_eq!(
canonicals(&kept),
vec!["SL-001", "SL-002", "SL-003", "SL-004"]
);
}
#[test]
fn retain_explicit_status_reveals_the_hide_set() {
let (f, _) = build(ListArgs {
status: vec!["done".into()],
..Default::default()
})
.unwrap();
let kept = retain(sample(), &f, hidden, fields);
assert_eq!(canonicals(&kept), vec!["SL-002"]);
}
#[test]
fn retain_multi_status_is_or_within_the_axis() {
let (f, _) = build(ListArgs {
status: vec!["proposed".into(), "started".into()],
..Default::default()
})
.unwrap();
let kept = retain(sample(), &f, hidden, fields);
assert_eq!(canonicals(&kept), vec!["SL-001", "SL-003"]);
}
#[test]
fn retain_substr_matches_slug_or_title_case_insensitively() {
let (f, _) = build(ListArgs {
substr: Some("WIDGET".into()),
all: true,
..Default::default()
})
.unwrap();
let kept = retain(sample(), &f, hidden, fields);
assert_eq!(canonicals(&kept), vec!["SL-002"]);
}
#[test]
fn retain_tag_is_or_and_ands_with_other_axes() {
let (f, _) = build(ListArgs {
tags: vec!["x".into()],
..Default::default()
})
.unwrap();
let kept = retain(sample(), &f, hidden, fields);
assert_eq!(canonicals(&kept), vec!["SL-001", "SL-003"]);
}
#[test]
fn retain_axes_and_across_each_other() {
let (f, _) = build(ListArgs {
substr: Some("a".into()),
tags: vec!["y".into()],
..Default::default()
})
.unwrap();
let kept = retain(sample(), &f, hidden, fields);
assert!(
kept.is_empty(),
"hide-set still drops done+abandoned: {:?}",
canonicals(&kept)
);
}
#[test]
fn retain_regex_over_canonical() {
let (f, _) = build(ListArgs {
regexp: Some("SL-00[13]".into()),
all: true,
..Default::default()
})
.unwrap();
let kept = retain(sample(), &f, hidden, fields);
assert_eq!(canonicals(&kept), vec!["SL-001", "SL-003"]);
}
#[test]
fn retain_preserves_input_order_never_sorts() {
let rows = vec![
row("SL-003", "c", "C", "proposed", &[]),
row("SL-001", "a", "A", "proposed", &[]),
row("SL-002", "b", "B", "proposed", &[]),
];
let (f, _) = no_filter();
let kept = retain(rows, &f, never_hidden, fields);
assert_eq!(canonicals(&kept), vec!["SL-003", "SL-001", "SL-002"]);
}
#[test]
fn retain_regex_matches_canonical_only_keeps_the_row() {
let rows = vec![row(
"SL-042",
"nomatch-slug",
"Nomatch Title",
"proposed",
&[],
)];
let (f, _) = build(ListArgs {
regexp: Some("SL-042".into()),
all: true,
..Default::default()
})
.unwrap();
let kept = retain(rows, &f, never_hidden, fields);
assert_eq!(canonicals(&kept), vec!["SL-042"]);
}
#[test]
fn retain_substr_does_not_search_canonical() {
let rows = vec![row(
"SL-042",
"nomatch-slug",
"Nomatch Title",
"proposed",
&[],
)];
let (f, _) = build(ListArgs {
substr: Some("042".into()),
all: true,
..Default::default()
})
.unwrap();
let kept = retain(rows, &f, never_hidden, fields);
assert!(
kept.is_empty(),
"substr must not see canonical: {:?}",
canonicals(&kept)
);
}
#[test]
fn retain_substr_only_match_is_kept() {
let rows = vec![row("SL-042", "special-slug", "Title", "proposed", &[])];
let (f, _) = build(ListArgs {
substr: Some("special".into()),
all: true,
..Default::default()
})
.unwrap();
let kept = retain(rows, &f, never_hidden, fields);
assert_eq!(canonicals(&kept), vec!["SL-042"]);
}
#[test]
fn validate_statuses_accepts_known_values() {
let known = ["proposed", "ready", "done"];
assert!(validate_statuses(&["proposed".into(), "done".into()], &known).is_ok());
assert!(validate_statuses(&[], &known).is_ok());
}
#[test]
fn validate_statuses_rejects_an_unknown_value_naming_it_and_the_set() {
let known = ["proposed", "ready", "done"];
let err = validate_statuses(&["bogus".into()], &known)
.unwrap_err()
.to_string();
assert!(err.contains("bogus"), "names the bad value: {err}");
assert!(err.contains("proposed"), "lists the known set: {err}");
}
fn cells(cells: &[&str]) -> Vec<String> {
cells.iter().map(|s| (*s).to_string()).collect()
}
#[test]
fn render_table_empty_is_empty_string() {
assert_eq!(render_table(&[], None), "");
}
#[test]
fn render_table_is_deterministic_and_carries_no_ansi() {
let grid = [
cells(&["id", "kind", "status", "title"]),
cells(&["SL-001", "slice", "proposed", "Alpha"]),
];
let first = render_table(&grid, None);
let second = render_table(&grid, None);
assert_eq!(first, second, "render_table must be byte-stable");
assert!(
!first.contains('\u{1b}'),
"force_no_tty must suppress ANSI styling: {first:?}"
);
}
#[test]
fn render_table_line_shape_minimalist_vertical_separators() {
let out = render_table(
&[
cells(&["id", "kind", "status", "title"]),
cells(&["SL-001", "slice", "proposed", "Alpha Thing"]),
],
None,
);
assert_eq!(
out,
"id │ kind │ status │ title\n\
SL-001 │ slice │ proposed │ Alpha Thing\n"
);
for line in out.lines() {
assert!(
!line.starts_with(' '),
"no leading space on a line: {line:?}"
);
assert_eq!(
line.trim_end(),
line,
"no trailing whitespace on a line: {line:?}"
);
assert!(
line.contains(" │ "),
"interior separator is ` │ `: {line:?}"
);
}
}
#[test]
fn render_table_aligns_a_middle_column_the_slice_case() {
let out = render_table(
&[
cells(&["001", "done", "4/6", "memory-entity-v1", "Memory entity v1"]),
cells(&[
"009",
"proposed",
"—",
"slice-status-rollup",
"Slice status rollup",
]),
],
None,
);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(
lines[0],
"001 │ done │ 4/6 │ memory-entity-v1 │ Memory entity v1"
);
assert_eq!(
lines[1],
"009 │ proposed │ — │ slice-status-rollup │ Slice status rollup"
);
}
#[test]
fn grid_min_width_is_the_derived_comfy_accounting() {
assert_eq!(grid_min_width(1), 1, "1 col: (0 borders)+(0 pad)+(1 char)");
assert_eq!(grid_min_width(2), 5, "2 col: 1+2+2");
assert_eq!(grid_min_width(4), 13, "4 col: 3+6+4");
assert_eq!(grid_min_width(6), 21, "6 col: 5+10+6");
}
#[test]
fn render_table_some_width_wraps_a_wide_cell_keeping_the_separator() {
let budget = 40u16;
let out = render_table(
&[
cells(&["id", "description"]),
cells(&[
"SL-001",
"a deliberately long description that cannot fit inside forty columns and must wrap",
]),
],
Some(budget),
);
let lines: Vec<&str> = out.lines().collect();
assert!(
lines.len() > 2,
"the wide cell must wrap to extra lines, got {lines:?}"
);
for line in &lines {
assert!(
line.contains('\u{2502}'),
"every wrapped line keeps the `│` separator: {line:?}"
);
assert!(
line.chars().count() <= usize::from(budget),
"no line exceeds the budget after trim_end: {line:?}"
);
}
}
#[test]
fn render_table_grid_floor_falls_back_below_and_wraps_at_or_above() {
let grid = [
cells(&["a", "b", "c", "d", "e", "f"]),
cells(&[
"1",
"2",
"3",
"4",
"5",
"a wide trailing cell that would wrap given any real budget",
]),
];
let floor = grid_min_width(6);
assert_eq!(floor, 21, "the 6-col floor is the derived 4·6-3");
let disabled = render_table(&grid, None);
let below = render_table(&grid, Some(20));
assert_eq!(
below, disabled,
"Some(w) below the grid floor must equal the Disabled output"
);
assert!(
u16::try_from(floor).is_ok_and(|f| f > 20),
"20 is genuinely below the floor"
);
let at_floor = render_table(&grid, Some(u16::try_from(floor).expect("floor fits u16")));
assert!(
at_floor.lines().count() > disabled.lines().count(),
"at the floor the wide cell wraps: {at_floor:?}"
);
}
#[test]
fn render_columns_byvalue_wide_cell_wraps_and_colour_strips_to_plain() {
struct WRow {
status: &'static str,
note: &'static str,
}
let columns: [Column<WRow>; 2] = [
Column {
name: "status",
header: "status",
cell: |r| r.status.to_string(),
paint: ColumnPaint::ByValue(|r| status_hue(r.status)),
},
Column {
name: "note",
header: "note",
cell: |r| r.note.to_string(),
paint: ColumnPaint::None,
},
];
let rows = [WRow {
status: "blocked",
note: "a long trailing note that overflows a narrow budget and must wrap onto several lines",
}];
let sel = select_columns(&columns, &["status", "note"], None).unwrap();
let width = Some(40u16);
let plain = render_columns(
&rows,
&sel,
RenderOpts {
color: false,
term_width: width,
},
);
let coloured = render_columns(
&rows,
&sel,
RenderOpts {
color: true,
term_width: width,
},
);
assert!(
plain.lines().count() > 2,
"the wide note wraps under the budget: {plain:?}"
);
assert!(
coloured.contains('\u{1b}'),
"the ByValue-painted status cell carries ANSI under wrapping"
);
assert_eq!(
strip_ansi(&coloured),
plain,
"stripping ANSI from the wrapped coloured render reproduces the plain layout"
);
}
#[test]
fn render_table_non_empty_ends_in_exactly_one_newline() {
let out = render_table(&[cells(&["a", "b"]), cells(&["c", "d"])], None);
assert!(out.ends_with('\n'), "ends in a newline: {out:?}");
assert!(
!out.ends_with("\n\n"),
"exactly one trailing newline: {out:?}"
);
}
#[test]
#[should_panic(expected = "rectangular")]
fn render_table_ragged_grid_panics_loudly() {
let _ = render_table(&[cells(&["a", "b", "c"]), cells(&["short", "row"])], None);
}
struct CRow {
id: &'static str,
slug: &'static str,
}
const CROW_COLUMNS: [Column<CRow>; 2] = [
Column {
name: "id",
header: "id",
cell: |r| r.id.to_string(),
paint: ColumnPaint::Fixed(owo_colors::DynColors::Ansi(owo_colors::AnsiColors::Cyan)),
},
Column {
name: "slug",
header: "slug",
cell: |r| r.slug.to_string(),
paint: ColumnPaint::None,
},
];
fn names<R>(sel: &[&Column<R>]) -> Vec<&'static str> {
sel.iter().map(|c| c.name).collect()
}
fn req(names: &[&str]) -> Vec<String> {
names.iter().map(|s| (*s).to_string()).collect()
}
#[test]
fn select_columns_none_takes_the_default_verbatim() {
let sel = select_columns(&CROW_COLUMNS, &["id"], None).unwrap();
assert_eq!(names(&sel), ["id"]);
}
#[test]
fn select_columns_requested_subset_and_order_win() {
let sel = select_columns(&CROW_COLUMNS, &["id", "slug"], Some(&req(&["slug"]))).unwrap();
assert_eq!(names(&sel), ["slug"]);
let sel =
select_columns(&CROW_COLUMNS, &["id", "slug"], Some(&req(&["slug", "id"]))).unwrap();
assert_eq!(names(&sel), ["slug", "id"]);
}
#[test]
fn select_columns_duplicates_are_permitted() {
let sel = select_columns(&CROW_COLUMNS, &["id"], Some(&req(&["id", "id"]))).unwrap();
assert_eq!(names(&sel), ["id", "id"]);
}
#[test]
fn select_columns_unknown_name_is_one_uniform_error_listing_available() {
let err = select_columns(&CROW_COLUMNS, &["id"], Some(&req(&["bogus"])))
.err()
.map(|e| e.to_string())
.unwrap();
assert!(err.contains("unknown column `bogus`"), "names it: {err}");
assert!(err.contains("id, slug"), "lists the available set: {err}");
}
#[test]
fn select_columns_empty_available_rejects_any_request() {
let none: [Column<CRow>; 0] = [];
let err = select_columns(&none, &[], Some(&req(&["id"])))
.err()
.map(|e| e.to_string())
.unwrap();
assert!(err.contains("unknown column `id`"), "got: {err}");
assert!(select_columns(&none, &[], None).unwrap().is_empty());
}
#[test]
#[cfg(debug_assertions)]
#[should_panic(expected = "default column")]
fn select_columns_panics_on_invalid_default_in_debug() {
let _ = select_columns(&CROW_COLUMNS, &["bogus"], None);
}
#[test]
fn select_columns_valid_defaults_pass() {
let result = select_columns(&CROW_COLUMNS, &["id"], None);
assert!(result.is_ok());
}
#[test]
fn render_opts_default_is_plain_unwrapped() {
let opts = RenderOpts::default();
assert!(!opts.color, "default is colourless");
assert_eq!(
opts.term_width, None,
"default is unwrapped (no term width)"
);
}
#[test]
fn render_columns_empty_rows_is_empty_string_header_suppressed() {
let sel = select_columns(&CROW_COLUMNS, &["id", "slug"], None).unwrap();
assert_eq!(render_columns::<CRow>(&[], &sel, RenderOpts::default()), "");
}
#[test]
fn render_columns_emits_header_row_then_cells_via_render_table() {
let rows = [
CRow {
id: "ADR-001",
slug: "module-layering",
},
CRow {
id: "ADR-004",
slug: "relations",
},
];
let sel = select_columns(&CROW_COLUMNS, &["id", "slug"], None).unwrap();
let out = render_columns(&rows, &sel, RenderOpts::default());
assert_eq!(
out,
"id │ slug\nADR-001 │ module-layering\nADR-004 │ relations\n"
);
}
#[test]
fn render_columns_colour_emits_ansi_only_when_enabled() {
let rows = [
CRow {
id: "ADR-001",
slug: "module-layering",
},
CRow {
id: "ADR-004",
slug: "relations",
},
];
let sel = select_columns(&CROW_COLUMNS, &["id", "slug"], None).unwrap();
let plain = render_columns(&rows, &sel, RenderOpts::default());
assert!(
!plain.contains('\u{1b}'),
"color=false must emit zero ANSI: {plain:?}"
);
let coloured = render_columns(
&rows,
&sel,
RenderOpts {
color: true,
..Default::default()
},
);
assert!(
coloured.contains('\u{1b}'),
"color=true must emit ANSI for the painted id column + bold header"
);
assert!(
coloured.contains("\u{1b}[1m"),
"color=true bolds the header: {coloured:?}"
);
}
#[test]
fn render_columns_colour_keeps_separators_aligned() {
let rows = [
CRow {
id: "ADR-001",
slug: "module-layering",
},
CRow {
id: "ADR-004",
slug: "relations",
},
];
let sel = select_columns(&CROW_COLUMNS, &["id", "slug"], None).unwrap();
let plain = render_columns(&rows, &sel, RenderOpts::default());
let coloured = render_columns(
&rows,
&sel,
RenderOpts {
color: true,
..Default::default()
},
);
assert_eq!(
strip_ansi(&coloured),
plain,
"stripping ANSI from the coloured render must reproduce the plain layout"
);
}
#[test]
fn status_hue_maps_each_class_and_leaves_the_rest_uncoloured() {
use owo_colors::{
AnsiColors::{Green, Red, Yellow},
DynColors,
};
for green in ["done", "active", "accepted", "required"] {
assert_eq!(
status_hue(green),
Some(DynColors::Ansi(Green)),
"{green} is settled/green"
);
}
for yellow in ["design", "plan", "ready", "started", "audit", "reconcile"] {
assert_eq!(
status_hue(yellow),
Some(DynColors::Ansi(Yellow)),
"{yellow} is in-flight/yellow"
);
}
for red in ["blocked", "abandoned", "contested"] {
assert_eq!(
status_hue(red),
Some(DynColors::Ansi(Red)),
"{red} is stopped/red"
);
}
for none in [
"proposed",
"open",
"draft",
"resolved",
"closed",
"superseded",
] {
assert_eq!(status_hue(none), None, "{none} falls through uncoloured");
}
assert_eq!(
status_hue("in_progress"),
None,
"in_progress is a phase status, not a painted status cell"
);
}
#[test]
fn status_colored_mapped_status_with_color_produces_ansi() {
let s = status_colored("accepted", true);
assert!(s.contains('\u{1b}'), "accepted+color → ANSI present: {s:?}");
assert!(
s.contains("accepted"),
"the status word is still in there: {s:?}"
);
}
#[test]
fn status_colored_unmapped_status_produces_plain() {
let s = status_colored("bogus", true);
assert_eq!(s, "bogus");
assert!(!s.contains('\u{1b}'));
}
#[test]
fn status_colored_without_color_produces_plain() {
assert_eq!(status_colored("accepted", false), "accepted");
assert_eq!(status_colored("bogus", false), "bogus");
assert_eq!(status_colored("", false), "");
}
#[test]
fn status_colored_every_mapped_token_produces_ansi() {
for green in ["done", "active", "accepted", "required"] {
let s = status_colored(green, true);
assert!(s.contains('\u{1b}'), "{green} + color → ANSI");
}
for yellow in ["design", "plan", "ready", "started", "audit", "reconcile"] {
let s = status_colored(yellow, true);
assert!(s.contains('\u{1b}'), "{yellow} + color → ANSI");
}
for red in ["blocked", "abandoned", "contested"] {
let s = status_colored(red, true);
assert!(s.contains('\u{1b}'), "{red} + color → ANSI");
}
}
#[test]
fn paint_cell_byvalue_reads_row_and_respects_none() {
let row = CRow {
id: "SL-001",
slug: "done",
};
let by_status: ColumnPaint<CRow> = ColumnPaint::ByValue(|r| status_hue(r.slug));
let painted = paint_cell("done", &by_status, &row, true, 0);
assert!(
painted.contains('\u{1b}'),
"ByValue Some hue emits ANSI: {painted:?}"
);
assert_eq!(
strip_ansi(&painted),
"done",
"stripped ANSI is the raw cell"
);
let by_none: ColumnPaint<CRow> = ColumnPaint::ByValue(|_| None);
assert_eq!(paint_cell("done", &by_none, &row, true, 0), "done");
assert_eq!(paint_cell("done", &by_status, &row, false, 0), "done");
}
struct TRow {
tags: Vec<&'static str>,
}
fn trow(tags: &[&'static str]) -> TRow {
TRow {
tags: tags.iter().copied().collect(),
}
}
fn tags_column() -> Column<TRow> {
Column {
name: "tags",
header: "tags",
cell: |r| r.tags.join(", "),
paint: ColumnPaint::PerToken {
split: |r| r.tags.iter().map(|t| (*t).to_string()).collect(),
render: paint_tag,
},
}
}
#[test]
fn segment_hue_is_deterministic_and_palette_excludes_reserved() {
for seg in ["cli", "command", "security", "a", "longer-segment"] {
assert_eq!(segment_hue(seg), segment_hue(seg), "{seg} is deterministic");
}
assert_eq!(segment_hue(""), None, "empty segment is uncoloured");
for seg in ["x", "alpha", "cli", "命"] {
let hue = segment_hue(seg).expect("non-empty segment is coloured");
assert!(
matches!(hue, owo_colors::DynColors::Rgb(..)),
"{seg} hue {hue:?} is a truecolour palette entry"
);
}
assert!(TAG_PALETTE.len() >= 8, "palette is sufficiently large");
}
#[test]
fn paint_tag_colon_segments_hued_separators_white() {
let white_colon = {
use owo_colors::{AnsiColors::White, DynColors, OwoColorize};
":".color(DynColors::Ansi(White)).to_string()
};
let single = paint_tag("security");
assert!(single.contains('\u{1b}'), "single segment is coloured");
assert!(
!single.contains(&white_colon),
"no colon in a single segment"
);
assert_eq!(strip_ansi(&single), "security", "stripped is the raw tag");
let chip = paint_tag("cli:command");
assert_eq!(strip_ansi(&chip), "cli:command", "stripped is the raw tag");
assert!(chip.contains(&white_colon), "the colon is painted white");
assert_ne!(
segment_hue("cli"),
segment_hue("command"),
"the fixture's two segments differ in hue (distinct chips)"
);
}
#[test]
fn paint_tag_is_stable_across_runs() {
for tag in ["cli:command", "security", "a::b", ":leading"] {
assert_eq!(paint_tag(tag), paint_tag(tag), "{tag} renders identically");
}
}
#[test]
fn paint_tag_empty_segments_keep_the_colon() {
for tag in [":x", "a::b", "trailing:"] {
assert_eq!(strip_ansi(&paint_tag(tag)), tag, "{tag} stripped is raw");
}
let chip = paint_tag("a::b");
let colons = chip.matches('b').count(); assert_eq!(colons, 1, "the painted segment text survives: {chip:?}");
}
#[test]
fn pertoken_byte_clean_coupling_strip_equals_plain_equals_cell() {
let col = tags_column();
let rows = [
trow(&["cli:command", "security"]),
trow(&["a::b", ":lead", "trail:"]),
trow(&[]),
trow(&["solo"]),
];
for r in &rows {
let plain = paint_cell(&(col.cell)(r), &col.paint, r, false, 0);
let coloured = paint_cell(&(col.cell)(r), &col.paint, r, true, 0);
let raw_cell = (col.cell)(r);
assert_eq!(plain, raw_cell, "color=false is the raw cell extractor");
assert_eq!(
strip_ansi(&coloured),
plain,
"stripping the coloured PerToken cell reproduces the plain cell"
);
}
}
#[test]
fn pertoken_color_false_emits_zero_ansi() {
let col = tags_column();
let r = trow(&["cli:command", "security"]);
let out = paint_cell(&(col.cell)(&r), &col.paint, &r, false, 0);
assert!(
!out.contains('\u{1b}'),
"color=false PerToken is byte-clean: {out:?}"
);
assert_eq!(out, "cli:command, security", "joined by `, ` unchanged");
}
#[test]
fn pertoken_multi_sgr_keeps_alignment_and_spares_the_reset() {
let columns: [Column<TRow>; 2] = [
Column {
name: "id",
header: "id",
cell: |_| "ITEM".to_string(),
paint: ColumnPaint::None,
},
tags_column(),
];
let rows = [trow(&["cli:command", "security"]), trow(&["x"])];
let sel = select_columns(&columns, &["id", "tags"], None).unwrap();
let plain = render_columns(&rows, &sel, RenderOpts::default());
let coloured = render_columns(
&rows,
&sel,
RenderOpts {
color: true,
..Default::default()
},
);
assert!(
coloured.matches('\u{1b}').count() > 2,
"the tags cell emits multiple SGR sequences"
);
assert_eq!(
strip_ansi(&coloured),
plain,
"multi-SGR tags cell stays column-aligned (display-width measured)"
);
assert!(
coloured.contains('\u{1b}'),
"the chip ANSI survives the last-column trim_end"
);
for line in coloured.lines() {
assert_eq!(line.trim_end(), line, "no trailing whitespace: {line:?}");
}
}
#[derive(Serialize)]
struct JsonRow {
id: &'static str,
status: &'static str,
}
#[test]
fn json_envelope_wraps_rows_under_kind() {
let rows = [
JsonRow {
id: "SL-001",
status: "proposed",
},
JsonRow {
id: "SL-002",
status: "done",
},
];
let out = json_envelope("slice", &rows).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(parsed["kind"], "slice");
assert_eq!(parsed["rows"].as_array().unwrap().len(), 2);
assert_eq!(parsed["rows"][0]["id"], "SL-001");
assert_eq!(parsed["rows"][1]["status"], "done");
}
#[test]
fn json_envelope_empty_rows_is_an_empty_array() {
let rows: [JsonRow; 0] = [];
let out = json_envelope("adr", &rows).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(parsed["kind"], "adr");
assert_eq!(parsed["rows"].as_array().unwrap().len(), 0);
}
#[test]
fn paint_cell_alternate_even_odd_on_row_index() {
use owo_colors::{
AnsiColors::{Green, Yellow},
DynColors,
};
let alt: ColumnPaint<CRow> =
ColumnPaint::Alternate([DynColors::Ansi(Green), DynColors::Ansi(Yellow)]);
let row = CRow { id: "X", slug: "x" };
let even = paint_cell("title", &alt, &row, true, 0);
let odd = paint_cell("title", &alt, &row, true, 1);
assert!(even.contains('\u{1b}'), "even row 0 carries ANSI: {even:?}");
assert!(odd.contains('\u{1b}'), "odd row 1 carries ANSI: {odd:?}");
assert_ne!(even, odd, "even and odd hue differ");
assert_eq!(strip_ansi(&even), "title", "strip even → raw cell");
assert_eq!(strip_ansi(&odd), "title", "strip odd → raw cell");
}
#[test]
fn paint_cell_alternate_color_false_is_plain() {
use owo_colors::{AnsiColors::Green, DynColors};
let alt: ColumnPaint<CRow> =
ColumnPaint::Alternate([DynColors::Ansi(Green), DynColors::Ansi(Green)]);
let row = CRow { id: "X", slug: "x" };
let out = paint_cell("title", &alt, &row, false, 0);
assert_eq!(out, "title", "color=false Alternate is raw");
assert!(!out.contains('\u{1b}'), "zero ANSI: {out:?}");
}
#[test]
fn backlog_kind_hue_maps_all_known_and_none_for_unknown() {
use owo_colors::{
AnsiColors::{Blue, Green, Magenta, Red, Yellow},
DynColors,
};
let known: &[(&str, DynColors)] = &[
("issue", DynColors::Ansi(Red)),
("improvement", DynColors::Ansi(Green)),
("chore", DynColors::Ansi(Yellow)),
("risk", DynColors::Ansi(Magenta)),
("idea", DynColors::Ansi(Blue)),
];
for &(kind, expected) in known {
assert_eq!(
backlog_kind_hue(kind),
Some(expected),
"{kind} hue mismatch"
);
}
let hues: Vec<_> = known.iter().map(|(_, h)| h).collect();
for i in 0..hues.len() {
for j in (i + 1)..hues.len() {
assert_ne!(hues[i], hues[j], "kinds must have distinct hues");
}
}
assert!(backlog_kind_hue("bogus").is_none(), "unknown kind → None");
}
#[test]
fn memory_type_hue_maps_all_known_and_none_for_unknown() {
use owo_colors::{
AnsiColors::{Blue, Cyan, Green, Magenta, Red, Yellow},
DynColors,
};
let known: &[(&str, DynColors)] = &[
("concept", DynColors::Ansi(Cyan)),
("fact", DynColors::Ansi(Green)),
("pattern", DynColors::Ansi(Magenta)),
("signpost", DynColors::Ansi(Blue)),
("system", DynColors::Ansi(Yellow)),
("thread", DynColors::Ansi(Red)),
];
for &(kind, expected) in known {
assert_eq!(memory_type_hue(kind), Some(expected), "{kind} hue mismatch");
}
let hues: Vec<_> = known.iter().map(|(_, h)| h).collect();
for i in 0..hues.len() {
for j in (i + 1)..hues.len() {
assert_ne!(hues[i], hues[j], "memory types must have distinct hues");
}
}
assert!(memory_type_hue("bogus").is_none(), "unknown type → None");
}
#[test]
fn trust_hue_maps_all_known_and_none_for_unknown() {
use owo_colors::{
AnsiColors::{Green, Red, Yellow},
DynColors,
};
assert_eq!(trust_hue("high"), Some(DynColors::Ansi(Green)));
assert_eq!(trust_hue("medium"), Some(DynColors::Ansi(Yellow)));
assert_eq!(trust_hue("low"), Some(DynColors::Ansi(Red)));
assert!(trust_hue("bogus").is_none(), "unknown trust → None");
}
#[test]
fn render_columns_alternate_zebra_pattern_on_data_rows_header_excluded() {
use owo_colors::{
AnsiColors::{Green, Red},
DynColors,
};
struct ZRow {
title: &'static str,
}
let columns: [Column<ZRow>; 1] = [Column {
name: "title",
header: "title",
cell: |r| r.title.to_string(),
paint: ColumnPaint::Alternate([DynColors::Ansi(Green), DynColors::Ansi(Red)]),
}];
let rows = [
ZRow { title: "Row0" },
ZRow { title: "Row1" },
ZRow { title: "Row2" },
];
let sel = select_columns(&columns, &["title"], None).unwrap();
let out = render_columns(
&rows,
&sel,
RenderOpts {
color: true,
..Default::default()
},
);
let lines: Vec<&str> = out.lines().collect();
assert_eq!(lines.len(), 4, "header + 3 data rows");
let header = lines[0];
assert!(header.contains("\u{1b}[1m"), "header is bold");
assert!(
!header.contains("\u{1b}[32m"),
"header must not carry the Alternate even-hue (row 0) — header is excluded"
);
assert!(
lines[1].contains("\u{1b}[32m"),
"data row 0 carries even (Green) hue"
);
assert!(
lines[2].contains("\u{1b}[31m"),
"data row 1 carries odd (Red) hue"
);
assert!(
lines[3].contains("\u{1b}[32m"),
"data row 2 carries even (Green) hue — wraps back"
);
}
#[test]
fn render_columns_alternate_and_fixed_colours_strip_to_plain() {
use owo_colors::{AnsiColors::Cyan, DynColors};
struct ARow {
id: &'static str,
title: &'static str,
}
let columns: [Column<ARow>; 2] = [
Column {
name: "id",
header: "id",
cell: |r| r.id.to_string(),
paint: ColumnPaint::Fixed(DynColors::Ansi(Cyan)),
},
Column {
name: "title",
header: "title",
cell: |r| r.title.to_string(),
paint: ColumnPaint::Alternate([TITLE_EVEN, TITLE_ODD]),
},
];
let rows = [
ARow {
id: "001",
title: "First",
},
ARow {
id: "002",
title: "Second",
},
];
let sel = select_columns(&columns, &["id", "title"], None).unwrap();
let plain = render_columns(&rows, &sel, RenderOpts::default());
let coloured = render_columns(
&rows,
&sel,
RenderOpts {
color: true,
..Default::default()
},
);
assert!(coloured.contains('\u{1b}'), "coloured output carries ANSI");
assert_eq!(
strip_ansi(&coloured),
plain,
"stripping ANSI from coloured render reproduces the plain layout"
);
}
}