use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use anyhow::Context;
use serde::Serialize;
use crate::dtoml;
use crate::entity::{self, Inputs, Kind, MaterialiseRequest};
use crate::listing::{self, Format, ListArgs};
use crate::meta::{self, Meta};
pub(crate) struct GovKind {
pub kind: Kind,
pub statuses: &'static [&'static str],
pub hidden: fn(&str) -> bool,
}
#[derive(Debug, Serialize)]
struct GovRow {
id: String,
status: String,
slug: String,
title: String,
tags: Vec<String>,
}
pub(crate) fn list_rows(g: &GovKind, root: &Path, mut args: ListArgs) -> anyhow::Result<String> {
listing::validate_statuses(&args.status, g.statuses)?;
let render = args.render;
let columns = args.columns.take();
let (filter, format) = listing::build(args)?;
let gov_root = root.join(g.kind.dir);
let mut metas = listing::retain(
meta::read_metas(&gov_root, g.kind.stem, g.kind.prefix)?,
&filter,
g.hidden,
|m| key(g, m),
);
metas.sort_by_key(|m| m.id);
let rows = gov_rows(g, &metas);
let any_tagged = rows.iter().any(|r| !r.tags.is_empty());
match format {
Format::Table => {
let effective_default = listing::default_with_tags(GOV_DEFAULT, any_tagged);
let sel =
listing::select_columns(&GOV_COLUMNS, &effective_default, columns.as_deref())?;
Ok(listing::render_columns(&rows, &sel, render))
}
Format::Json => listing::json_envelope(g.kind.stem, &rows),
}
}
fn key(g: &GovKind, m: &Meta) -> listing::FilterFields {
listing::FilterFields {
canonical: listing::canonical_id(g.kind.prefix, m.id),
slug: m.slug.clone(),
title: m.title.clone(),
status: m.status.clone(),
tags: m.tags.clone(),
}
}
const GOV_COLUMNS: [listing::Column<GovRow>; 5] = [
listing::Column {
name: "id",
header: "id",
cell: |r| r.id.clone(),
paint: listing::ColumnPaint::Fixed(owo_colors::DynColors::Ansi(
owo_colors::AnsiColors::Cyan,
)),
},
listing::Column {
name: "status",
header: "status",
cell: |r| r.status.clone(),
paint: listing::ColumnPaint::ByValue(|r| listing::status_hue(&r.status)),
},
listing::Column {
name: "tags",
header: "tags",
cell: |r| r.tags.join(", "),
paint: listing::ColumnPaint::PerToken {
split: |r| r.tags.clone(),
render: listing::paint_tag,
},
},
listing::Column {
name: "slug",
header: "slug",
cell: |r| r.slug.clone(),
paint: listing::ColumnPaint::None,
},
listing::Column {
name: "title",
header: "title",
cell: |r| r.title.clone(),
paint: listing::ColumnPaint::Alternate([listing::TITLE_EVEN, listing::TITLE_ODD]),
},
];
const GOV_DEFAULT: &[&str] = &["id", "status", "title"];
fn gov_rows(g: &GovKind, metas: &[Meta]) -> Vec<GovRow> {
metas
.iter()
.map(|m| GovRow {
id: listing::canonical_id(g.kind.prefix, m.id),
status: m.status.clone(),
slug: m.slug.clone(),
title: m.title.clone(),
tags: m.tags.clone(),
})
.collect()
}
#[derive(Debug, Default, Clone, PartialEq, Eq, serde::Deserialize, Serialize)]
struct Relationships {
#[serde(default)]
superseded_by: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, serde::Deserialize, Serialize)]
struct Doc {
id: u32,
slug: String,
title: String,
status: String,
created: String,
updated: String,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
relationships: Relationships,
}
fn parse_ref(g: &GovKind, reference: &str) -> anyhow::Result<u32> {
let label = format!("an {}", g.kind.prefix);
parse_entity_ref(g.kind.prefix, &label, reference)
}
pub(crate) fn parse_entity_ref(
prefix: &str,
kind_label: &str,
reference: &str,
) -> anyhow::Result<u32> {
let upper = format!("{prefix}-");
let lower = format!("{prefix}-").to_lowercase();
let digits = reference
.strip_prefix(&upper)
.or_else(|| reference.strip_prefix(&lower))
.unwrap_or(reference);
digits.parse::<u32>().with_context(|| {
format!("not {kind_label} reference: `{reference}` (expected `{prefix}-007` or `7`)")
})
}
fn read_doc(g: &GovKind, root: &Path, id: u32) -> anyhow::Result<(Doc, String, String)> {
let name = format!("{id:03}");
let toml_path = entity::id_path(root, &g.kind, id, entity::Ext::Toml);
let text = fs::read_to_string(&toml_path).with_context(|| {
format!(
"{} {name} not found at {}",
g.kind.stem,
toml_path.display()
)
})?;
let doc: Doc = dtoml::parse_entity_toml(&text, g.kind.prefix, id)
.with_context(|| format!("Failed to parse {}", toml_path.display()))?;
let md_path = entity::id_path(root, &g.kind, id, entity::Ext::Md);
let body = fs::read_to_string(&md_path)
.with_context(|| format!("Failed to read {}", md_path.display()))?;
Ok((doc, text, body))
}
pub(crate) fn supersession_pair(
g: &GovKind,
root: &Path,
id: u32,
) -> anyhow::Result<(Vec<String>, Vec<String>)> {
use crate::relation::{RelationDoc, RelationLabel, read_block};
let (doc, toml_text, _body) = read_doc(g, root, id)?;
let relation_doc: RelationDoc = toml::from_str(&toml_text)
.with_context(|| format!("failed to parse relations for {} {id}", g.kind.stem))?;
let (edges, _illegal) = read_block(&g.kind, &relation_doc);
let supersedes = edges
.into_iter()
.filter(|e| e.label == RelationLabel::Supersedes)
.map(|e| e.target)
.collect();
Ok((supersedes, doc.relationships.superseded_by))
}
pub(crate) fn relation_edges(
g: &GovKind,
root: &Path,
id: u32,
) -> anyhow::Result<Vec<crate::relation::RelationEdge>> {
use crate::relation::tier1_edges;
let (_doc, toml_text, _body) = read_doc(g, root, id)?;
tier1_edges(&g.kind, &toml_text)
}
fn format_show(
g: &GovKind,
doc: &Doc,
supersedes: &[String],
related: &[String],
body: &str,
) -> String {
let mut parts: Vec<String> = Vec::new();
parts.push(format!(
"{} — {}\n",
listing::canonical_id(g.kind.prefix, doc.id),
doc.title
));
parts.push(format!("{} · {}\n", doc.slug, doc.status));
parts.push(format!(
"created {} · updated {}\n",
doc.created, doc.updated
));
let rel = &doc.relationships;
if !supersedes.is_empty()
|| !rel.superseded_by.is_empty()
|| !related.is_empty()
|| !doc.tags.is_empty()
{
parts.push("\nrelationships:\n".to_string());
for (label, refs) in [
("supersedes", &supersedes.to_vec()),
("superseded_by", &rel.superseded_by),
("related", &related.to_vec()),
("tags", &doc.tags),
] {
if !refs.is_empty() {
parts.push(format!(" {label}: {}\n", refs.join(", ")));
}
}
}
parts.push(format!("\n{body}"));
parts.concat()
}
fn show_json(
g: &GovKind,
doc: &Doc,
supersedes: &[String],
related: &[String],
body: &str,
) -> anyhow::Result<String> {
let mut map = serde_json::Map::new();
map.insert(
"kind".to_string(),
serde_json::Value::String(g.kind.stem.to_string()),
);
let mut doc_value = serde_json::to_value(doc)
.with_context(|| format!("failed to serialize {}", g.kind.stem))?;
if let Some(rel) = doc_value
.get_mut("relationships")
.and_then(serde_json::Value::as_object_mut)
{
rel.insert("supersedes".to_string(), serde_json::json!(supersedes));
rel.insert("related".to_string(), serde_json::json!(related));
}
map.insert(g.kind.stem.to_string(), doc_value);
map.insert(
"body".to_string(),
serde_json::Value::String(body.to_string()),
);
serde_json::to_string_pretty(&serde_json::Value::Object(map))
.with_context(|| format!("failed to serialize {} show JSON", g.kind.stem))
}
pub(crate) fn set_status(
g: &GovKind,
root: &Path,
id: u32,
status: &str,
today: &str,
) -> anyhow::Result<()> {
let name = format!("{id:03}");
let path = entity::id_path(root, &g.kind, id, entity::Ext::Toml);
let hint = format!(
"malformed {stem} {name}: missing seeded `status`/`updated` — restore the seeded keys before the transition; the file is left untouched",
stem = g.kind.stem
);
crate::dep_seq::set_authored_status(&path, &[("status", status), ("updated", today)], &hint)?;
Ok(())
}
pub(crate) fn run_new(
g: &GovKind,
path: Option<PathBuf>,
title: Option<String>,
slug: Option<String>,
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let trunk_ids = crate::git::trunk_entity_ids(&root, g.kind.dir)?;
let (backend, mut reserved) =
crate::reserve::backend(&root, g.kind.prefix, crate::install::prompt_confirm)?;
let title = crate::input::resolve_title(title)?;
let slug = crate::input::resolve_slug(&title, slug)?;
let date = crate::clock::today();
let out = entity::materialise(
&g.kind,
&*backend,
&root,
&MaterialiseRequest::Fresh,
&Inputs {
slug: &slug,
title: &title,
date: &date,
},
&trunk_ids,
&mut reserved,
)?;
let id = out
.eid
.numeric_id()
.with_context(|| format!("{} kind must yield a numeric id", g.kind.stem))?;
writeln!(
io::stdout(),
"Created {} {id:03}: {}",
g.kind.prefix,
out.dir.display()
)?;
Ok(())
}
pub(crate) fn run_list(g: &GovKind, path: Option<PathBuf>, args: ListArgs) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let mut out = io::stdout();
write!(out, "{}", list_rows(g, &root, args)?)?;
Ok(())
}
pub(crate) fn run_show(
g: &GovKind,
path: Option<PathBuf>,
reference: &str,
format: Format,
) -> anyhow::Result<()> {
use crate::relation::RelationLabel;
let root = crate::root::find(path, &crate::root::default_markers())?;
let id = parse_ref(g, reference)?;
let (doc, toml_text, body) = read_doc(g, &root, id)?;
let edges = crate::relation::tier1_edges(&g.kind, &toml_text)?;
let supersedes: Vec<String> = edges
.iter()
.filter(|e| e.label == RelationLabel::Supersedes)
.map(|e| e.target.clone())
.collect();
let related: Vec<String> = edges
.iter()
.filter(|e| e.label == RelationLabel::Related)
.map(|e| e.target.clone())
.collect();
let out = match format {
Format::Table => format_show(g, &doc, &supersedes, &related, &body),
Format::Json => show_json(g, &doc, &supersedes, &related, &body)?,
};
write!(io::stdout(), "{out}")?;
Ok(())
}
pub(crate) fn run_paths(
g: &GovKind,
path: Option<PathBuf>,
refs: &[String],
sel: &crate::paths::PathSelection,
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let gov_root = root.join(g.kind.dir);
let mut all_lines: Vec<String> = Vec::new();
for r in refs {
let id = parse_ref(g, r)?;
let name = format!("{id:03}");
let entity_dir = gov_root.join(&name);
let toml_name = format!("{}-{name}.toml", g.kind.stem);
let md_name = format!("{}-{name}.md", g.kind.stem);
let identity_toml = entity_dir.join(&toml_name);
let identity_md = entity_dir.join(&md_name);
let set =
crate::paths::scan_entity_dir(&entity_dir, &identity_toml, Some(&identity_md), &root)?;
let lines = crate::paths::select_paths(&set, sel)?;
all_lines.extend(lines);
}
write!(io::stdout(), "{}", all_lines.join("\n"))?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::adr::{ADR_KIND, AdrStatus};
use crate::test_support::SCHEMA_ADR;
fn adr_root(root: &Path) -> PathBuf {
root.join(ADR_KIND.kind.dir)
}
fn args() -> ListArgs {
ListArgs::default()
}
fn two_adrs(root: &Path, first_status: AdrStatus) {
run_new(
&ADR_KIND,
Some(root.to_path_buf()),
Some("Use Rust".into()),
None,
)
.unwrap();
run_new(
&ADR_KIND,
Some(root.to_path_buf()),
Some("Adopt CI".into()),
None,
)
.unwrap();
set_status(
&ADR_KIND,
root,
1,
first_status.as_str(),
&crate::clock::today(),
)
.unwrap();
}
#[test]
fn run_new_writes_the_adr_tree_and_allocates_monotonically() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
run_new(
&ADR_KIND,
Some(root.to_path_buf()),
Some("Use Rust".into()),
None,
)
.unwrap();
run_new(
&ADR_KIND,
Some(root.to_path_buf()),
Some("Adopt CI".into()),
None,
)
.unwrap();
let adr = adr_root(root);
assert!(adr.join("001/adr-001.toml").is_file());
assert!(adr.join("001/adr-001.md").is_file());
assert_eq!(
fs::read_link(adr.join("001-use-rust")).unwrap(),
Path::new("001")
);
assert!(adr.join("002/adr-002.toml").is_file());
assert_eq!(
fs::read_link(adr.join("002-adopt-ci")).unwrap(),
Path::new("002")
);
}
#[test]
fn end_to_end_new_x2_list_status_accept_then_filtered_list() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path().to_path_buf();
run_new(&ADR_KIND, Some(root.clone()), Some("Use Rust".into()), None).unwrap();
run_new(&ADR_KIND, Some(root.clone()), Some("Adopt CI".into()), None).unwrap();
let all = list_rows(
&ADR_KIND,
&root,
ListArgs {
all: true,
..ListArgs::default()
},
)
.unwrap();
assert!(all.contains("ADR-001"));
assert!(all.contains("ADR-002"));
set_status(
&ADR_KIND,
&root,
1,
AdrStatus::Accepted.as_str(),
&crate::clock::today(),
)
.unwrap();
let accepted = list_rows(
&ADR_KIND,
&root,
ListArgs {
status: vec!["accepted".into()],
..ListArgs::default()
},
)
.unwrap();
assert!(accepted.contains("ADR-001"));
assert!(!accepted.contains("ADR-002"));
}
#[test]
fn list_rows_emits_prefixed_ids_and_a_header() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
two_adrs(root, AdrStatus::Accepted);
let out = list_rows(&ADR_KIND, root, args()).unwrap();
let lines: Vec<&str> = out.lines().collect();
assert!(lines[0].starts_with("id"), "header row: {:?}", lines[0]);
assert!(lines[0].contains("status"), "header names columns");
assert!(out.contains("ADR-001 │ accepted"), "prefixed id: {out}");
assert!(out.contains("ADR-002"), "second ADR present: {out}");
assert!(!out.contains("\n001 "), "no bare numeric id: {out}");
}
#[test]
fn list_rows_hide_set_drops_rejected_superseded_deprecated_by_default() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
run_new(
&ADR_KIND,
Some(root.to_path_buf()),
Some("Keep".into()),
None,
)
.unwrap();
run_new(
&ADR_KIND,
Some(root.to_path_buf()),
Some("Gone".into()),
None,
)
.unwrap();
set_status(
&ADR_KIND,
root,
2,
AdrStatus::Superseded.as_str(),
"2099-01-01",
)
.unwrap();
let out = list_rows(&ADR_KIND, root, args()).unwrap();
assert!(out.contains("ADR-001"), "non-hidden ADR kept: {out}");
assert!(
!out.contains("ADR-002"),
"superseded hidden by default: {out}"
);
}
#[test]
fn list_rows_all_and_explicit_status_reveal_the_hide_set() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
run_new(
&ADR_KIND,
Some(root.to_path_buf()),
Some("Keep".into()),
None,
)
.unwrap();
run_new(
&ADR_KIND,
Some(root.to_path_buf()),
Some("Gone".into()),
None,
)
.unwrap();
set_status(
&ADR_KIND,
root,
2,
AdrStatus::Superseded.as_str(),
"2099-01-01",
)
.unwrap();
let all = list_rows(
&ADR_KIND,
root,
ListArgs {
all: true,
..Default::default()
},
)
.unwrap();
assert!(all.contains("ADR-002"), "--all reveals superseded: {all}");
let by_status = list_rows(
&ADR_KIND,
root,
ListArgs {
status: vec!["superseded".into()],
..Default::default()
},
)
.unwrap();
assert!(
by_status.contains("ADR-002"),
"explicit status reveals: {by_status}"
);
assert!(
!by_status.contains("ADR-001"),
"and filters to it: {by_status}"
);
}
#[test]
fn list_rows_filter_matches_slug_and_title() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
two_adrs(root, AdrStatus::Accepted);
let out = list_rows(
&ADR_KIND,
root,
ListArgs {
substr: Some("adopt".into()),
all: true,
..Default::default()
},
)
.unwrap();
assert!(out.contains("ADR-002"), "substr matches adopt-ci: {out}");
assert!(!out.contains("ADR-001"), "use-rust filtered out: {out}");
}
#[test]
fn list_rows_regexp_matches_canonical_id() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
two_adrs(root, AdrStatus::Accepted);
let out = list_rows(
&ADR_KIND,
root,
ListArgs {
regexp: Some("ADR-002".into()),
all: true,
..Default::default()
},
)
.unwrap();
assert!(out.contains("ADR-002"), "regex matches canonical: {out}");
assert!(!out.contains("ADR-001"), "non-matching dropped: {out}");
}
#[test]
fn list_rows_json_is_the_shared_envelope_with_prefixed_ids() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
two_adrs(root, AdrStatus::Accepted);
let out = list_rows(
&ADR_KIND,
root,
ListArgs {
json: true,
all: true,
..Default::default()
},
)
.unwrap();
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(parsed["kind"], "adr");
let rows = parsed["rows"].as_array().unwrap();
assert_eq!(rows.len(), 2);
assert_eq!(rows[0]["id"], "ADR-001");
assert_eq!(rows[0]["status"], "accepted");
assert_eq!(rows[0]["slug"], "use-rust");
}
#[test]
fn list_rows_default_table_omits_slug() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
two_adrs(root, AdrStatus::Accepted);
let out = list_rows(&ADR_KIND, root, args()).unwrap();
let lines: Vec<&str> = out.lines().collect();
assert_eq!(
lines[0].split_whitespace().collect::<Vec<_>>(),
["id", "│", "status", "│", "title"],
"default header is slug-free: {out}"
);
assert!(!out.contains("use-rust"), "slug cell hidden: {out}");
assert!(out.contains("Use Rust"), "title cell present: {out}");
}
#[test]
fn list_rows_columns_selects_orders_and_reveals_slug() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
two_adrs(root, AdrStatus::Accepted);
let out = list_rows(
&ADR_KIND,
root,
ListArgs {
columns: Some(vec!["slug".into(), "id".into()]),
..Default::default()
},
)
.unwrap();
let lines: Vec<&str> = out.lines().collect();
assert_eq!(
lines[0].split_whitespace().collect::<Vec<_>>(),
["slug", "│", "id"],
"requested order wins: {out}"
);
assert!(out.contains("use-rust"), "slug revealed: {out}");
assert!(!out.contains("accepted"), "unselected status hidden: {out}");
}
#[test]
fn list_rows_unknown_column_is_the_uniform_error_listing_available() {
let dir = tempfile::tempdir().unwrap();
let err = list_rows(
&ADR_KIND,
dir.path(),
ListArgs {
columns: Some(vec!["bogus".into()]),
..Default::default()
},
)
.unwrap_err()
.to_string();
assert!(err.contains("unknown column `bogus`"), "names it: {err}");
assert!(
err.contains("id, status, tags, slug, title"),
"lists the available set: {err}"
);
}
#[test]
fn list_rows_json_ignores_columns_and_keeps_slug() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
two_adrs(root, AdrStatus::Accepted);
let plain = list_rows(
&ADR_KIND,
root,
ListArgs {
json: true,
all: true,
..Default::default()
},
)
.unwrap();
let projected = list_rows(
&ADR_KIND,
root,
ListArgs {
json: true,
all: true,
columns: Some(vec!["id".into()]),
..Default::default()
},
)
.unwrap();
assert_eq!(plain, projected, "--columns is a no-op under --json");
let parsed: serde_json::Value = serde_json::from_str(&projected).unwrap();
assert_eq!(parsed["rows"][0]["slug"], "use-rust");
}
#[test]
fn list_rows_empty_tree_is_the_empty_string() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(list_rows(&ADR_KIND, dir.path(), args()).unwrap(), "");
}
#[test]
fn list_rows_rejects_an_unknown_status_with_the_uniform_error() {
let dir = tempfile::tempdir().unwrap();
let err = list_rows(
&ADR_KIND,
dir.path(),
ListArgs {
status: vec!["bogus".into()],
..Default::default()
},
)
.unwrap_err()
.to_string();
assert!(err.contains("bogus"), "names the bad value: {err}");
assert!(err.contains("accepted"), "lists the known set: {err}");
}
#[test]
fn list_rows_accepts_every_known_status() {
let dir = tempfile::tempdir().unwrap();
for s in ADR_KIND.statuses {
assert!(
list_rows(
&ADR_KIND,
dir.path(),
ListArgs {
status: vec![(*s).to_string()],
..Default::default()
},
)
.is_ok(),
"known status `{s}` accepted"
);
}
}
fn adr_at(root: &Path, id: u32, status: &str, slug: &str, title: &str) {
let name = format!("{id:03}");
let dir = adr_root(root).join(&name);
fs::create_dir_all(&dir).unwrap();
let toml = format!(
"schema = \"{SCHEMA_ADR}\"\nversion = 1\n\nid = {id}\nslug = \"{slug}\"\ntitle = \"{title}\"\nstatus = \"{status}\"\ncreated = \"2026-06-04\"\nupdated = \"2026-06-04\"\n"
);
fs::write(dir.join(format!("adr-{name}.toml")), toml).unwrap();
}
fn id_order(out: &str, ids: &[&str]) -> Vec<usize> {
ids.iter()
.map(|id| {
out.find(id)
.unwrap_or_else(|| panic!("{id} present: {out}"))
})
.collect()
}
#[test]
fn list_rows_orders_by_id_ascending_regardless_of_creation_order() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
adr_at(root, 3, "accepted", "gamma", "Gamma");
adr_at(root, 1, "accepted", "alpha", "Alpha");
adr_at(root, 2, "accepted", "beta", "Beta");
let out = list_rows(&ADR_KIND, root, args()).unwrap();
let offsets = id_order(&out, &["ADR-001", "ADR-002", "ADR-003"]);
assert!(
offsets[0] < offsets[1] && offsets[1] < offsets[2],
"ADR rows must render in ascending id order (sort, not read order): {out}"
);
}
#[test]
fn parse_ref_accepts_prefixed_padded_and_bare_ids() {
assert_eq!(parse_ref(&ADR_KIND, "ADR-007").unwrap(), 7);
assert_eq!(parse_ref(&ADR_KIND, "adr-7").unwrap(), 7);
assert_eq!(parse_ref(&ADR_KIND, "7").unwrap(), 7);
assert_eq!(parse_ref(&ADR_KIND, "042").unwrap(), 42);
assert!(parse_ref(&ADR_KIND, "nope").is_err());
assert!(parse_ref(&ADR_KIND, "AdR-7").is_err());
}
#[test]
fn parse_entity_ref_delegates_correctly_for_each_prefix() {
assert_eq!(parse_entity_ref("ADR", "an ADR", "ADR-007").unwrap(), 7);
assert_eq!(parse_entity_ref("ADR", "an ADR", "7").unwrap(), 7);
let err = parse_entity_ref("ADR", "an ADR", "nope")
.unwrap_err()
.to_string();
assert!(err.contains("an ADR"), "error names the kind label: {err}");
assert!(err.contains("ADR-007"), "error shows expected form: {err}");
assert_eq!(parse_entity_ref("POL", "a policy", "POL-001").unwrap(), 1);
let err = parse_entity_ref("POL", "a policy", "bad")
.unwrap_err()
.to_string();
assert!(err.contains("a policy"), "{err}");
assert_eq!(parse_entity_ref("SL", "a slice", "SL-025").unwrap(), 25);
assert_eq!(parse_entity_ref("RFC", "an RFC", "011").unwrap(), 11);
assert!(parse_entity_ref("ADR", "an ADR", "AdR-7").is_err());
}
#[test]
fn read_doc_reassembles_toml_as_data_and_md_body() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
run_new(
&ADR_KIND,
Some(root.to_path_buf()),
Some("Use Rust".into()),
None,
)
.unwrap();
let (doc, _toml_text, body) = read_doc(&ADR_KIND, root, 1).unwrap();
assert_eq!(doc.id, 1);
assert_eq!(doc.slug, "use-rust");
assert_eq!(doc.status, "proposed");
assert!(doc.relationships.superseded_by.is_empty());
assert!(body.contains("ADR-001: Use Rust"));
assert!(body.contains("## Context"));
}
#[test]
fn format_show_renders_identity_relationships_and_body() {
let doc = Doc {
id: 7,
slug: "use-rust".into(),
title: "Use Rust".into(),
status: "accepted".into(),
created: "2026-06-01".into(),
updated: "2026-06-08".into(),
tags: vec!["lang".into()],
relationships: Relationships {
superseded_by: vec![],
},
};
let supersedes = vec!["ADR-003".to_string()];
let related = vec!["ADR-004".to_string()];
let out = format_show(
&ADR_KIND,
&doc,
&supersedes,
&related,
"# ADR-007: Use Rust\n\nbody.\n",
);
assert!(out.contains("ADR-007 — Use Rust"), "identity: {out}");
assert!(out.contains("use-rust · accepted"), "flat fields: {out}");
assert!(out.contains("created 2026-06-01 · updated 2026-06-08"));
assert!(out.contains("supersedes: ADR-003"), "relationships: {out}");
assert!(out.contains("related: ADR-004"), "related axis: {out}");
assert!(out.contains("tags: lang"), "tags axis: {out}");
assert!(
out.contains("# ADR-007: Use Rust"),
"prose body appended: {out}"
);
}
#[test]
fn show_json_is_faithful_toml_as_data_plus_body() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
run_new(
&ADR_KIND,
Some(root.to_path_buf()),
Some("Use Rust".into()),
None,
)
.unwrap();
let (doc, _toml_text, body) = read_doc(&ADR_KIND, root, 1).unwrap();
let out = show_json(&ADR_KIND, &doc, &[], &[], &body).unwrap();
let parsed: serde_json::Value = serde_json::from_str(&out).unwrap();
assert_eq!(parsed["kind"], "adr");
assert_eq!(parsed["adr"]["id"], 1);
assert_eq!(parsed["adr"]["slug"], "use-rust");
assert_eq!(parsed["adr"]["status"], "proposed");
assert!(parsed["adr"]["relationships"]["supersedes"].is_array());
assert!(parsed["adr"]["relationships"]["related"].is_array());
assert!(
parsed["body"].as_str().unwrap().contains("## Context"),
"body carried in json"
);
}
#[test]
fn run_show_on_a_missing_adr_errors() {
let dir = tempfile::tempdir().unwrap();
let err = run_show(
&ADR_KIND,
Some(dir.path().to_path_buf()),
"ADR-009",
Format::Table,
)
.unwrap_err();
assert!(err.to_string().contains("not found"), "got: {err}");
}
#[test]
fn read_metas_round_trips_created_adrs_and_filters_by_status() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
run_new(
&ADR_KIND,
Some(root.to_path_buf()),
Some("Use Rust".into()),
None,
)
.unwrap();
run_new(
&ADR_KIND,
Some(root.to_path_buf()),
Some("Adopt CI".into()),
None,
)
.unwrap();
let adr = adr_root(root);
let p = adr.join("002/adr-002.toml");
let flipped = fs::read_to_string(&p)
.unwrap()
.replace("status = \"proposed\"", "status = \"accepted\"");
fs::write(&p, flipped).unwrap();
let mut all = meta::read_metas(&adr, "adr", "ADR").unwrap();
all.sort_by_key(|m| m.id);
assert_eq!(all.iter().map(|m| m.id).collect::<Vec<_>>(), vec![1, 2]);
assert_eq!(
all.first(),
Some(&Meta {
id: 1,
slug: "use-rust".into(),
title: "Use Rust".into(),
status: "proposed".into(),
tags: vec![],
})
);
let accepted = list_rows(
&ADR_KIND,
root,
ListArgs {
status: vec!["accepted".into()],
..ListArgs::default()
},
)
.unwrap();
assert!(accepted.contains("ADR-002"));
assert!(!accepted.contains("ADR-001"));
}
#[test]
fn set_status_flips_status_bumps_updated_and_preserves_the_rest() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
run_new(
&ADR_KIND,
Some(root.to_path_buf()),
Some("Use Rust".into()),
None,
)
.unwrap();
let adr = adr_root(root);
set_status(
&ADR_KIND,
root,
1,
AdrStatus::Accepted.as_str(),
"2099-01-01",
)
.unwrap();
assert_eq!(
meta::read_meta(&adr, "adr", 1, "ADR").unwrap().status,
"accepted"
);
let body = fs::read_to_string(adr.join("001/adr-001.toml")).unwrap();
assert!(body.contains("updated = \"2099-01-01\""));
assert!(!body.contains("created = \"2099-01-01\""));
assert!(body.contains("[relationships]"));
assert!(body.contains("`doctrine supersede") || body.contains("doctrine link ADR"));
assert!(!body.contains("supersedes = []"));
}
#[test]
fn set_status_to_the_current_value_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
run_new(
&ADR_KIND,
Some(root.to_path_buf()),
Some("Use Rust".into()),
None,
)
.unwrap();
let p = adr_root(root).join("001/adr-001.toml");
let before = fs::read_to_string(&p).unwrap();
set_status(
&ADR_KIND,
root,
1,
AdrStatus::Proposed.as_str(),
"2099-01-01",
)
.unwrap();
assert_eq!(fs::read_to_string(&p).unwrap(), before);
}
#[test]
fn set_status_on_a_missing_id_among_existing_adrs_errors() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
run_new(
&ADR_KIND,
Some(root.to_path_buf()),
Some("Use Rust".into()),
None,
)
.unwrap();
let err = set_status(
&ADR_KIND,
root,
9,
AdrStatus::Accepted.as_str(),
"2099-01-01",
)
.unwrap_err();
assert!(err.to_string().contains("not found"));
}
#[test]
fn set_status_on_an_adr_missing_updated_errors() {
let dir = tempfile::tempdir().unwrap();
let p = adr_root(dir.path()).join("003/adr-003.toml");
fs::create_dir_all(p.parent().unwrap()).unwrap();
fs::write(
&p,
"status = \"proposed\"\n\n[relationships]\nsupersedes = []\n",
)
.unwrap();
let err = set_status(
&ADR_KIND,
dir.path(),
3,
AdrStatus::Accepted.as_str(),
"2099-01-01",
)
.unwrap_err();
let msg = err.to_string().to_lowercase();
assert!(msg.contains("malformed"), "{msg}");
assert!(
!msg.contains("regenerate") && !msg.contains(" new`") && !msg.contains("scaffold"),
"F-1 refuse must be non-destructive: {msg}"
);
}
fn adr_fixture(id: u32, extra: &[&str]) -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
let name = format!("{id:03}");
let dir = adr_root(tmp.path()).join(&name);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join(format!("adr-{name}.toml")), "toml").unwrap();
fs::write(dir.join(format!("adr-{name}.md")), "md").unwrap();
for e in extra {
fs::write(dir.join(e), e).unwrap();
}
tmp
}
#[test]
fn paths_full_output_shows_toml_md_and_extras_in_canonical_order() {
let tmp = adr_fixture(1, &["notes.md", "z.log"]);
let root = tmp.path();
let sel = crate::paths::PathSelection {
toml: false,
md: false,
entity: false,
single: false,
};
let gov_root = root.join(ADR_KIND.kind.dir);
let entity_dir = gov_root.join("001");
let identity_toml = entity_dir.join("adr-001.toml");
let identity_md = entity_dir.join("adr-001.md");
let set =
crate::paths::scan_entity_dir(&entity_dir, &identity_toml, Some(&identity_md), root)
.unwrap();
let lines = crate::paths::select_paths(&set, &sel).unwrap();
assert_eq!(
lines.join("\n"),
".doctrine/adr/001/adr-001.toml\n.doctrine/adr/001/adr-001.md\n.doctrine/adr/001/notes.md\n.doctrine/adr/001/z.log"
);
}
#[test]
fn paths_single_truncates_to_first() {
let tmp = adr_fixture(1, &["notes.md"]);
let root = tmp.path();
let sel = crate::paths::PathSelection {
toml: false,
md: false,
entity: false,
single: true,
};
let gov_root = root.join(ADR_KIND.kind.dir);
let entity_dir = gov_root.join("001");
let set = crate::paths::scan_entity_dir(
&entity_dir,
&entity_dir.join("adr-001.toml"),
Some(&entity_dir.join("adr-001.md")),
&root,
)
.unwrap();
let lines = crate::paths::select_paths(&set, &sel).unwrap();
assert_eq!(lines.len(), 1);
assert_eq!(lines[0], ".doctrine/adr/001/adr-001.toml");
}
#[test]
fn paths_toml_only() {
let tmp = adr_fixture(1, &["notes.md"]);
let root = tmp.path();
let sel = crate::paths::PathSelection {
toml: true,
md: false,
entity: false,
single: false,
};
let gov_root = root.join(ADR_KIND.kind.dir);
let entity_dir = gov_root.join("001");
let set = crate::paths::scan_entity_dir(
&entity_dir,
&entity_dir.join("adr-001.toml"),
Some(&entity_dir.join("adr-001.md")),
root,
)
.unwrap();
let lines = crate::paths::select_paths(&set, &sel).unwrap();
assert_eq!(lines, vec![".doctrine/adr/001/adr-001.toml"]);
}
#[test]
fn paths_md_only() {
let tmp = adr_fixture(1, &[]);
let root = tmp.path();
let sel = crate::paths::PathSelection {
toml: false,
md: true,
entity: false,
single: false,
};
let gov_root = root.join(ADR_KIND.kind.dir);
let entity_dir = gov_root.join("001");
let set = crate::paths::scan_entity_dir(
&entity_dir,
&entity_dir.join("adr-001.toml"),
Some(&entity_dir.join("adr-001.md")),
root,
)
.unwrap();
let lines = crate::paths::select_paths(&set, &sel).unwrap();
assert_eq!(lines, vec![".doctrine/adr/001/adr-001.md"]);
}
#[test]
fn paths_entity_gives_toml_and_md() {
let tmp = adr_fixture(1, &["extra.txt"]);
let root = tmp.path();
let sel = crate::paths::PathSelection {
toml: false,
md: false,
entity: true,
single: false,
};
let gov_root = root.join(ADR_KIND.kind.dir);
let entity_dir = gov_root.join("001");
let set = crate::paths::scan_entity_dir(
&entity_dir,
&entity_dir.join("adr-001.toml"),
Some(&entity_dir.join("adr-001.md")),
root,
)
.unwrap();
let lines = crate::paths::select_paths(&set, &sel).unwrap();
assert_eq!(
lines,
vec![
".doctrine/adr/001/adr-001.toml",
".doctrine/adr/001/adr-001.md"
]
);
}
#[test]
fn paths_multi_ref_splat_preserves_order() {
let tmp = adr_fixture(1, &[]);
let root = tmp.path();
{
let dir = adr_root(root).join("002");
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join("adr-002.toml"), "toml").unwrap();
fs::write(dir.join("adr-002.md"), "md").unwrap();
}
let sel = crate::paths::PathSelection {
toml: false,
md: false,
entity: false,
single: false,
};
let gov_root = root.join(ADR_KIND.kind.dir);
let mut all_lines: Vec<String> = Vec::new();
for n in ["001", "002"] {
let entity_dir = gov_root.join(n);
let set = crate::paths::scan_entity_dir(
&entity_dir,
&entity_dir.join(format!("adr-{n}.toml")),
Some(&entity_dir.join(format!("adr-{n}.md"))),
root,
)
.unwrap();
all_lines.extend(crate::paths::select_paths(&set, &sel).unwrap());
}
assert_eq!(all_lines.len(), 4);
assert!(all_lines[0].contains("001/adr-001.toml"));
assert!(all_lines[2].contains("002/adr-002.toml"));
}
#[test]
fn paths_invalid_ref_errors() {
let tmp = adr_fixture(1, &[]);
let root = tmp.path();
let _id = parse_ref(&ADR_KIND, "ADR-99999").unwrap();
let gov_root = root.join(ADR_KIND.kind.dir);
let entity_dir = gov_root.join("99999");
let result = crate::paths::scan_entity_dir(
&entity_dir,
&entity_dir.join("adr-99999.toml"),
Some(&entity_dir.join("adr-99999.md")),
root,
);
assert!(result.is_err());
}
}