use std::fmt;
use std::str::FromStr;
use anyhow::Context;
use regex_lite::Regex;
use serde::Serialize;
pub(crate) fn canonical_id(prefix: &str, id: u32) -> String {
format!("{prefix}-{id:03}")
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
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)]
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) 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) fn select_columns<'a, R>(
available: &'a [Column<R>],
default: &[&str],
requested: Option<&[String]>,
) -> anyhow::Result<Vec<&'a Column<R>>> {
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>]) -> String {
if rows.is_empty() {
return String::new();
}
let mut grid: Vec<Vec<String>> = Vec::with_capacity(rows.len() + 1);
grid.push(cols.iter().map(|c| c.header.to_string()).collect());
grid.extend(
rows.iter()
.map(|r| cols.iter().map(|c| (c.cell)(r)).collect()),
);
render_table(&grid)
}
const COL_GAP: &str = " ";
pub(crate) fn render_table(rows: &[Vec<String>]) -> String {
if rows.is_empty() {
return String::new();
}
let cols = rows.iter().map(Vec::len).max().unwrap_or(0);
let widths: Vec<usize> = (0..cols)
.map(|c| {
rows.iter()
.filter_map(|r| r.get(c))
.map(|cell| cell.chars().count())
.max()
.unwrap_or(0)
})
.collect();
let mut out = String::new();
for row in rows {
let last = row.len().saturating_sub(1);
for (c, cell) in row.iter().enumerate() {
if c > 0 {
out.push_str(COL_GAP);
}
out.push_str(cell);
if c != last {
let pad = widths
.get(c)
.copied()
.unwrap_or(0)
.saturating_sub(cell.chars().count());
out.extend(std::iter::repeat_n(' ', pad));
}
}
out.push('\n');
}
out
}
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)]
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(&[]), "");
}
#[test]
fn render_table_aligns_ragged_columns_and_leaves_last_unpadded() {
let out = render_table(&[
cells(&["a", "longvalue", "x"]),
cells(&["bb", "y", "trailing"]),
]);
assert_eq!(out, "a longvalue x\nbb y trailing\n");
}
#[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",
]),
]);
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"
);
}
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(),
},
Column {
name: "slug",
header: "slug",
cell: |r| r.slug.to_string(),
},
];
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]
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), "");
}
#[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);
assert_eq!(
out,
"id slug\nADR-001 module-layering\nADR-004 relations\n"
);
}
#[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);
}
}