use std::collections::BTreeMap;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use anyhow::Context;
use serde::{Deserialize, Serialize};
use crate::backlog_order::{BacklogOrder, ItemId, OrderInput, Override, OverrideReason};
use crate::entity::{
self, Artifact, Fileset, Inputs, Kind, LocalFs, MaterialiseRequest, ScaffoldCtx,
};
use crate::listing::{self, Format, ListArgs};
use crate::tomlfmt::toml_string;
const BACKLOG_STEM: &str = "backlog";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum ItemKind {
Issue,
Improvement,
Chore,
Risk,
Idea,
}
pub(crate) const ISSUE_KIND: Kind = Kind {
dir: ".doctrine/backlog/issue",
prefix: "ISS",
scaffold: |c| backlog_scaffold(ItemKind::Issue, c),
};
pub(crate) const IMPROVEMENT_KIND: Kind = Kind {
dir: ".doctrine/backlog/improvement",
prefix: "IMP",
scaffold: |c| backlog_scaffold(ItemKind::Improvement, c),
};
pub(crate) const CHORE_KIND: Kind = Kind {
dir: ".doctrine/backlog/chore",
prefix: "CHR",
scaffold: |c| backlog_scaffold(ItemKind::Chore, c),
};
pub(crate) const RISK_KIND: Kind = Kind {
dir: ".doctrine/backlog/risk",
prefix: "RSK",
scaffold: |c| backlog_scaffold(ItemKind::Risk, c),
};
pub(crate) const IDEA_KIND: Kind = Kind {
dir: ".doctrine/backlog/idea",
prefix: "IDE",
scaffold: |c| backlog_scaffold(ItemKind::Idea, c),
};
#[expect(
dead_code,
reason = "inert until the PRD-011 multi-kind resolver consumes it"
)]
const KIND_PRECEDENCE: [ItemKind; 5] = [
ItemKind::Risk,
ItemKind::Issue,
ItemKind::Improvement,
ItemKind::Chore,
ItemKind::Idea,
];
impl ItemKind {
const fn kind(self) -> &'static Kind {
match self {
ItemKind::Issue => &ISSUE_KIND,
ItemKind::Improvement => &IMPROVEMENT_KIND,
ItemKind::Chore => &CHORE_KIND,
ItemKind::Risk => &RISK_KIND,
ItemKind::Idea => &IDEA_KIND,
}
}
pub(crate) const fn prefix(self) -> &'static str {
self.kind().prefix
}
const fn as_str(self) -> &'static str {
match self {
ItemKind::Issue => "issue",
ItemKind::Improvement => "improvement",
ItemKind::Chore => "chore",
ItemKind::Risk => "risk",
ItemKind::Idea => "idea",
}
}
pub(crate) fn canonical_id(self, id: u32) -> String {
format!("{}-{id:03}", self.prefix())
}
fn from_prefix(prefix: &str) -> Option<Self> {
ItemKind::ALL.into_iter().find(|k| k.prefix() == prefix)
}
const fn has_facet(self) -> bool {
matches!(self, ItemKind::Risk)
}
const ALL: [ItemKind; 5] = [
ItemKind::Issue,
ItemKind::Improvement,
ItemKind::Chore,
ItemKind::Risk,
ItemKind::Idea,
];
const fn ordinal(self) -> usize {
match self {
ItemKind::Issue => 0,
ItemKind::Improvement => 1,
ItemKind::Chore => 2,
ItemKind::Risk => 3,
ItemKind::Idea => 4,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum Status {
Open,
Triaged,
Started,
Resolved,
Closed,
}
impl Status {
const fn as_str(self) -> &'static str {
match self {
Status::Open => "open",
Status::Triaged => "triaged",
Status::Started => "started",
Status::Resolved => "resolved",
Status::Closed => "closed",
}
}
const fn is_terminal(self) -> bool {
matches!(self, Status::Resolved | Status::Closed)
}
}
const BACKLOG_STATUSES: &[&str] = &["open", "triaged", "started", "resolved", "closed"];
fn is_hidden(status: &str) -> bool {
parse_enum::<Status>(status, "status").is_ok_and(Status::is_terminal)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum Resolution {
Fixed,
Done,
Mitigated,
Accepted,
Expired,
Duplicate,
WontDo,
Obsolete,
Promoted,
}
impl Resolution {
const fn as_str(self) -> &'static str {
match self {
Resolution::Fixed => "fixed",
Resolution::Done => "done",
Resolution::Mitigated => "mitigated",
Resolution::Accepted => "accepted",
Resolution::Expired => "expired",
Resolution::Duplicate => "duplicate",
Resolution::WontDo => "wont-do",
Resolution::Obsolete => "obsolete",
Resolution::Promoted => "promoted",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum RiskLevel {
Low,
Medium,
High,
Critical,
}
impl RiskLevel {
const fn as_str(self) -> &'static str {
match self {
RiskLevel::Low => "low",
RiskLevel::Medium => "medium",
RiskLevel::High => "high",
RiskLevel::Critical => "critical",
}
}
}
#[derive(Debug, Deserialize)]
struct RawBacklogToml {
id: u32,
slug: String,
title: String,
kind: ItemKind,
status: Status,
#[serde(default)]
resolution: String,
created: String,
updated: String,
#[serde(default)]
tags: Vec<String>,
#[serde(default)]
facet: Option<RawRiskFacet>,
#[serde(default)]
relationships: Relationships,
}
#[derive(Debug, Deserialize)]
struct RawRiskFacet {
#[serde(default)]
likelihood: String,
#[serde(default)]
impact: String,
#[serde(default)]
origin: String,
#[serde(default)]
controls: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct BacklogItem {
id: u32,
slug: String,
title: String,
kind: ItemKind,
status: Status,
resolution: Option<Resolution>,
created: String,
updated: String,
tags: Vec<String>,
facet: Option<RiskFacet>,
relationships: Relationships,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct RiskFacet {
likelihood: Option<RiskLevel>,
impact: Option<RiskLevel>,
origin: Option<String>,
controls: Vec<String>,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct AfterEdge {
to: String,
#[serde(default)]
rank: i32,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Serialize, Deserialize)]
struct Trigger {
#[serde(default)]
globs: Vec<String>,
#[serde(default)]
note: String,
}
#[derive(Debug, Default, Clone, PartialEq, Eq, Deserialize)]
struct Relationships {
#[serde(default)]
slices: Vec<String>,
#[serde(default)]
specs: Vec<String>,
#[serde(default)]
drift: Vec<String>,
#[serde(default)]
needs: Vec<String>,
#[serde(default)]
after: Vec<AfterEdge>,
#[serde(default)]
triggers: Vec<Trigger>,
}
fn parse_enum<T: serde::de::DeserializeOwned>(token: &str, what: &str) -> anyhow::Result<T> {
use serde::de::IntoDeserializer;
let de: serde::de::value::StrDeserializer<'_, serde::de::value::Error> =
token.into_deserializer();
T::deserialize(de).map_err(|e| anyhow::anyhow!("invalid {what} `{token}`: {e}"))
}
fn optional_enum<T: serde::de::DeserializeOwned>(
token: &str,
what: &str,
) -> anyhow::Result<Option<T>> {
if token.is_empty() {
Ok(None)
} else {
parse_enum(token, what).map(Some)
}
}
fn optional_text(text: String) -> Option<String> {
if text.is_empty() { None } else { Some(text) }
}
fn validate(raw: RawBacklogToml) -> anyhow::Result<BacklogItem> {
let resolution = optional_enum(&raw.resolution, "resolution")?;
let facet = match raw.facet {
Some(f) => Some(validate_facet(f)?),
None => None,
};
Ok(BacklogItem {
id: raw.id,
slug: raw.slug,
title: raw.title,
kind: raw.kind,
status: raw.status,
resolution,
created: raw.created,
updated: raw.updated,
tags: raw.tags,
facet,
relationships: raw.relationships,
})
}
fn validate_facet(raw: RawRiskFacet) -> anyhow::Result<RiskFacet> {
Ok(RiskFacet {
likelihood: optional_enum(&raw.likelihood, "likelihood")?,
impact: optional_enum(&raw.impact, "impact")?,
origin: optional_text(raw.origin),
controls: raw.controls,
})
}
pub(crate) fn exposure(facet: Option<&RiskFacet>) -> u8 {
const fn weight(level: RiskLevel) -> u8 {
match level {
RiskLevel::Low => 1,
RiskLevel::Medium => 2,
RiskLevel::High => 3,
RiskLevel::Critical => 4,
}
}
match facet.and_then(|f| f.likelihood.zip(f.impact)) {
Some((l, i)) => weight(l) * weight(i),
None => 0,
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct AbsentDrop {
from: ItemId,
reference: String,
}
impl AbsentDrop {
pub(crate) fn from(&self) -> ItemId {
self.from
}
pub(crate) fn reference(&self) -> &str {
&self.reference
}
}
fn project(items: &[BacklogItem]) -> (Vec<OrderInput>, Vec<AbsentDrop>) {
let mut inputs: BTreeMap<ItemId, OrderInput> = BTreeMap::new();
let mut absent: Vec<AbsentDrop> = Vec::new();
for item in items.iter().filter(|i| !i.status.is_terminal()) {
let from = ItemId::new(item.kind, item.id);
let mut resolve = |reference: &str| -> Option<ItemId> {
if let Ok((kind, id)) = parse_ref(reference) {
Some(ItemId::new(kind, id))
} else {
absent.push(AbsentDrop {
from,
reference: reference.to_string(),
});
None
}
};
let needs: Vec<ItemId> = item
.relationships
.needs
.iter()
.filter_map(|r| resolve(r))
.collect();
let after: Vec<(ItemId, i32)> = item
.relationships
.after
.iter()
.filter_map(|e| resolve(&e.to).map(|to| (to, e.rank)))
.collect();
inputs.insert(
from,
OrderInput::new(
from,
item.created.clone(),
exposure(item.facet.as_ref()),
needs,
after,
),
);
}
(inputs.into_values().collect(), absent)
}
fn render_backlog_toml(
item_kind: ItemKind,
id: u32,
slug: &str,
title: &str,
date: &str,
) -> anyhow::Result<String> {
let template = if item_kind.has_facet() {
"templates/backlog-risk.toml"
} else {
"templates/backlog.toml"
};
Ok(crate::install::asset_text(template)?
.replace("{{id}}", &id.to_string())
.replace("{{slug}}", &toml_string(slug))
.replace("{{title}}", &toml_string(title))
.replace("{{kind}}", item_kind.as_str())
.replace("{{date}}", date))
}
fn render_backlog_md(canonical_id: &str, title: &str) -> anyhow::Result<String> {
Ok(crate::install::asset_text("templates/backlog.md")?
.replace("{{ref}}", canonical_id)
.replace("{{title}}", title))
}
fn backlog_scaffold(item_kind: ItemKind, ctx: &ScaffoldCtx<'_>) -> anyhow::Result<Fileset> {
let id = ctx.id;
let name = format!("{id:03}");
Ok(vec![
Artifact::File {
rel_path: PathBuf::from(format!("{name}/{BACKLOG_STEM}-{name}.toml")),
body: render_backlog_toml(item_kind, id, ctx.slug, ctx.title, ctx.date)?,
},
Artifact::File {
rel_path: PathBuf::from(format!("{name}/{BACKLOG_STEM}-{name}.md")),
body: render_backlog_md(ctx.canonical, ctx.title)?,
},
Artifact::Symlink {
rel_path: PathBuf::from(format!("{name}-{}", ctx.slug)),
target: name,
},
])
}
pub(crate) fn run_new(
path: Option<PathBuf>,
item_kind: ItemKind,
title: Option<String>,
slug: Option<String>,
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let title = crate::input::resolve_title(title)?;
let slug = crate::input::resolve_slug(&title, slug)?;
let date = crate::clock::today();
let trunk_ids = crate::git::trunk_entity_ids(&root, item_kind.kind().dir)?;
let out = entity::materialise(
item_kind.kind(),
&LocalFs,
&root,
&MaterialiseRequest::Fresh,
&Inputs {
slug: &slug,
title: &title,
date: &date,
},
&trunk_ids,
)?;
let id = out
.eid
.numeric_id()
.context("backlog kind must yield a numeric id")?;
writeln!(
io::stdout(),
"Created {}: {}",
item_kind.canonical_id(id),
out.dir.display()
)?;
Ok(())
}
fn read_kind(root: &Path, item_kind: ItemKind) -> anyhow::Result<Vec<BacklogItem>> {
let tree = root.join(item_kind.kind().dir);
let mut items = Vec::new();
for id in entity::scan_ids(&tree)? {
items.push(read_item(root, item_kind, id)?);
}
Ok(items)
}
fn read_item(root: &Path, item_kind: ItemKind, id: u32) -> anyhow::Result<BacklogItem> {
let name = format!("{id:03}");
let path = root
.join(item_kind.kind().dir)
.join(&name)
.join(format!("{BACKLOG_STEM}-{name}.toml"));
let text = std::fs::read_to_string(&path)
.with_context(|| format!("backlog item not found at {}", path.display()))?;
let raw: RawBacklogToml =
toml::from_str(&text).with_context(|| format!("Failed to parse {}", path.display()))?;
validate(raw)
}
fn read_all(root: &Path) -> anyhow::Result<Vec<BacklogItem>> {
let mut items = Vec::new();
for item_kind in ItemKind::ALL {
items.extend(read_kind(root, item_kind)?);
}
Ok(items)
}
fn key(i: &BacklogItem) -> listing::FilterFields {
listing::FilterFields {
canonical: i.kind.canonical_id(i.id),
slug: i.slug.clone(),
title: i.title.clone(),
status: i.status.as_str().to_string(),
tags: i.tags.clone(),
}
}
fn validate_statuses(given: &[String], known: &[&str]) -> anyhow::Result<()> {
listing::validate_statuses(given, known)
}
#[derive(Debug, Serialize)]
struct BacklogRow {
id: String,
kind: &'static str,
status: &'static str,
resolution: Option<&'static str>,
slug: String,
title: String,
}
const BL_COLUMNS: [listing::Column<BacklogItem>; 5] = [
listing::Column {
name: "id",
header: "id",
cell: |i| i.kind.canonical_id(i.id),
},
listing::Column {
name: "kind",
header: "kind",
cell: |i| i.kind.as_str().to_string(),
},
listing::Column {
name: "status",
header: "status",
cell: |i| i.status.as_str().to_string(),
},
listing::Column {
name: "slug",
header: "slug",
cell: |i| i.slug.clone(),
},
listing::Column {
name: "title",
header: "title",
cell: |i| i.title.clone(),
},
];
const BL_DEFAULT: &[&str] = &["id", "kind", "status", "title"];
fn list_rows(root: &Path, kind: Option<ItemKind>, mut args: ListArgs) -> anyhow::Result<String> {
validate_statuses(&args.status, BACKLOG_STATUSES)?;
let columns = args.columns.take();
let (filter, format) = listing::build(args)?;
let mut items = listing::retain(read_all(root)?, &filter, is_hidden, key);
items.retain(|i| kind.is_none_or(|k| i.kind == k));
items.sort_by_key(|i| (i.kind.ordinal(), i.id));
match format {
Format::Table => {
let sel = listing::select_columns(&BL_COLUMNS, BL_DEFAULT, columns.as_deref())?;
Ok(listing::render_columns(&items, &sel))
}
Format::Json => listing::json_envelope("backlog", &json_rows(&items)),
}
}
fn json_rows(items: &[BacklogItem]) -> Vec<BacklogRow> {
items
.iter()
.map(|i| BacklogRow {
id: i.kind.canonical_id(i.id),
kind: i.kind.as_str(),
status: i.status.as_str(),
resolution: i.resolution.map(Resolution::as_str),
slug: i.slug.clone(),
title: i.title.clone(),
})
.collect()
}
pub(crate) fn run_list(
path: Option<PathBuf>,
kind: Option<ItemKind>,
args: ListArgs,
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
write!(io::stdout(), "{}", list_rows(&root, kind, args)?)?;
Ok(())
}
fn parse_ref(reference: &str) -> anyhow::Result<(ItemKind, u32)> {
let (prefix, tail) = reference.rsplit_once('-').with_context(|| {
format!("`{reference}` is not a canonical backlog ref (expected e.g. ISS-007)")
})?;
let kind = ItemKind::from_prefix(&prefix.to_uppercase()).with_context(|| {
format!("unknown backlog prefix `{prefix}` in `{reference}` (expected ISS/IMP/CHR/RSK/IDE)")
})?;
let id: u32 = tail
.parse()
.with_context(|| format!("`{tail}` is not a numeric id in `{reference}`"))?;
Ok((kind, id))
}
fn format_show(item: &BacklogItem) -> String {
let mut parts: Vec<String> = Vec::new();
parts.push(format!(
"{} — {}\n",
item.kind.canonical_id(item.id),
item.title
));
let resolution = match item.resolution {
Some(r) => format!(" · {}", r.as_str()),
None => String::new(),
};
parts.push(format!(
"{} · {} · {}{resolution}\n",
item.slug,
item.kind.as_str(),
item.status.as_str(),
));
parts.push(format!(
"created {} · updated {}\n",
item.created, item.updated
));
if !item.tags.is_empty() {
parts.push(format!("tags: {}\n", item.tags.join(", ")));
}
if let Some(facet) = &item.facet {
parts.push("\n[facet]\n".to_string());
if let Some(likelihood) = facet.likelihood {
parts.push(format!(" likelihood: {}\n", likelihood.as_str()));
}
if let Some(impact) = facet.impact {
parts.push(format!(" impact: {}\n", impact.as_str()));
}
if let Some(origin) = &facet.origin {
parts.push(format!(" origin: {origin}\n"));
}
if !facet.controls.is_empty() {
parts.push(format!(" controls: {}\n", facet.controls.join(", ")));
}
}
let rel = &item.relationships;
if !rel.slices.is_empty()
|| !rel.specs.is_empty()
|| !rel.drift.is_empty()
|| !rel.needs.is_empty()
|| !rel.after.is_empty()
|| !rel.triggers.is_empty()
{
parts.push("\nrelationships:\n".to_string());
for (label, refs) in [
("slices", &rel.slices),
("specs", &rel.specs),
("drift", &rel.drift),
("needs", &rel.needs),
] {
if !refs.is_empty() {
parts.push(format!(" {label}: {}\n", refs.join(", ")));
}
}
if !rel.after.is_empty() {
let rendered = rel
.after
.iter()
.map(|e| {
if e.rank == 0 {
e.to.clone()
} else {
format!("{} (rank {})", e.to, e.rank)
}
})
.collect::<Vec<_>>()
.join(", ");
parts.push(format!(" after: {rendered}\n"));
}
if !rel.triggers.is_empty() {
let rendered = rel
.triggers
.iter()
.map(|t| {
let globs = t.globs.join(", ");
if t.note.is_empty() {
format!("[{globs}]")
} else {
format!("[{globs}] {}", t.note)
}
})
.collect::<Vec<_>>()
.join("; ");
parts.push(format!(" triggers: {rendered}\n"));
}
}
parts.concat()
}
pub(crate) fn run_show(
path: Option<PathBuf>,
reference: &str,
format: Format,
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let (item_kind, id) = parse_ref(reference)?;
let item = read_item(&root, item_kind, id)?;
let out = match format {
Format::Table => format_show(&item),
Format::Json => show_json(&item)?,
};
write!(io::stdout(), "{out}")?;
Ok(())
}
fn show_json(item: &BacklogItem) -> anyhow::Result<String> {
let facet = item.facet.as_ref().map(|f| {
serde_json::json!({
"likelihood": f.likelihood.map(RiskLevel::as_str),
"impact": f.impact.map(RiskLevel::as_str),
"origin": f.origin,
"controls": f.controls,
})
});
let rel = &item.relationships;
let value = serde_json::json!({
"kind": "backlog",
"backlog": {
"id": item.kind.canonical_id(item.id),
"kind": item.kind.as_str(),
"slug": item.slug,
"title": item.title,
"status": item.status.as_str(),
"resolution": item.resolution.map(Resolution::as_str),
"created": item.created,
"updated": item.updated,
"tags": item.tags,
"facet": facet,
"relationships": {
"slices": rel.slices,
"specs": rel.specs,
"drift": rel.drift,
"needs": rel.needs,
"after": rel.after,
"triggers": rel.triggers,
},
},
});
serde_json::to_string_pretty(&value).context("failed to serialize backlog show JSON")
}
fn validate_transition(
status: Status,
resolution: Option<Resolution>,
) -> anyhow::Result<&'static str> {
match (status.is_terminal(), resolution) {
(true, Some(r)) => Ok(r.as_str()),
(true, None) => anyhow::bail!(
"a terminal status (`{}`) requires `--resolution`",
status.as_str()
),
(false, Some(r)) => anyhow::bail!(
"a non-terminal status (`{}`) takes no `--resolution` (got `{}`)",
status.as_str(),
r.as_str()
),
(false, None) => Ok(""),
}
}
fn set_backlog_status(
root: &Path,
item_kind: ItemKind,
id: u32,
status: Status,
resolution: Option<Resolution>,
today: &str,
) -> anyhow::Result<&'static str> {
let resolution = validate_transition(status, resolution)?;
let name = format!("{id:03}");
let path = root
.join(item_kind.kind().dir)
.join(&name)
.join(format!("{BACKLOG_STEM}-{name}.toml"));
let text = std::fs::read_to_string(&path)
.with_context(|| format!("backlog item not found at {}", path.display()))?;
let mut doc = text
.parse::<toml_edit::DocumentMut>()
.with_context(|| format!("Failed to parse {}", path.display()))?;
let unchanged = doc.get("status").and_then(toml_edit::Item::as_str) == Some(status.as_str())
&& doc.get("resolution").and_then(toml_edit::Item::as_str) == Some(resolution);
if unchanged {
return Ok(resolution);
}
let table = doc.as_table_mut();
if !table.contains_key("status")
|| !table.contains_key("resolution")
|| !table.contains_key("updated")
{
anyhow::bail!(
"malformed backlog item {name}: missing seeded `status`/`resolution`/`updated` (regenerate via `backlog new`)"
);
}
table.insert("status", toml_edit::value(status.as_str()));
table.insert("resolution", toml_edit::value(resolution));
table.insert("updated", toml_edit::value(today));
std::fs::write(&path, doc.to_string())
.with_context(|| format!("Failed to write {}", path.display()))?;
Ok(resolution)
}
enum RelEdit<'a> {
Needs(&'a [String]),
After { to: &'a str, rank: i32 },
}
fn append_relationship(
root: &Path,
item_kind: ItemKind,
id: u32,
edit: &RelEdit<'_>,
) -> anyhow::Result<()> {
let name = format!("{id:03}");
let path = root
.join(item_kind.kind().dir)
.join(&name)
.join(format!("{BACKLOG_STEM}-{name}.toml"));
let text = std::fs::read_to_string(&path)
.with_context(|| format!("backlog item not found at {}", path.display()))?;
let mut doc = text
.parse::<toml_edit::DocumentMut>()
.with_context(|| format!("Failed to parse {}", path.display()))?;
let axis = match edit {
RelEdit::Needs(_) => "needs",
RelEdit::After { .. } => "after",
};
let array = doc
.get_mut("relationships")
.and_then(toml_edit::Item::as_table_mut)
.and_then(|t| t.get_mut(axis))
.and_then(toml_edit::Item::as_array_mut)
.with_context(|| {
format!(
"malformed backlog item {name}: missing seeded `[relationships].{axis}` (regenerate via `backlog new`)"
)
})?;
let mut changed = false;
match edit {
RelEdit::Needs(refs) => {
for r in *refs {
if array.iter().any(|v| v.as_str() == Some(r.as_str())) {
continue;
}
array.push(r.as_str());
changed = true;
}
}
RelEdit::After { to, rank } => {
let present = array.iter().any(|v| {
v.as_inline_table().is_some_and(|t| {
t.get("to").and_then(toml_edit::Value::as_str) == Some(to)
&& t.get("rank").and_then(toml_edit::Value::as_integer)
== Some(i64::from(*rank))
})
});
if !present {
let mut edge = toml_edit::InlineTable::new();
edge.insert("to", (*to).into());
edge.insert("rank", i64::from(*rank).into());
array.push(edge);
changed = true;
}
}
}
if !changed {
return Ok(()); }
std::fs::write(&path, doc.to_string())
.with_context(|| format!("Failed to write {}", path.display()))?;
Ok(())
}
pub(crate) fn run_edit(
path: Option<PathBuf>,
reference: &str,
status: Status,
resolution: Option<Resolution>,
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let (item_kind, id) = parse_ref(reference)?;
let written = set_backlog_status(
&root,
item_kind,
id,
status,
resolution,
&crate::clock::today(),
)?;
let suffix = if written.is_empty() {
String::new()
} else {
format!(" · {written}")
};
writeln!(
io::stdout(),
"Edited {}: {}{suffix}",
item_kind.canonical_id(id),
status.as_str()
)?;
Ok(())
}
fn require_item(root: &Path, reference: &str) -> anyhow::Result<(ItemKind, u32)> {
let (kind, id) = parse_ref(reference)?;
read_item(root, kind, id)?;
Ok((kind, id))
}
fn name_cycle(members: &std::collections::BTreeSet<ItemId>) -> String {
members
.iter()
.map(|id| id.render())
.collect::<Vec<_>>()
.join(", ")
}
fn needs_would_cycle(
items: &[BacklogItem],
target: (ItemKind, u32),
new_needs: &[String],
) -> anyhow::Result<Vec<std::collections::BTreeSet<ItemId>>> {
let mut corpus: Vec<BacklogItem> = items.to_vec();
if let Some(item) = corpus
.iter_mut()
.find(|i| i.kind == target.0 && i.id == target.1)
{
item.relationships.needs.extend_from_slice(new_needs);
}
let (inputs, _) = project(&corpus);
Ok(BacklogOrder::build(&inputs)?.dep_cycles())
}
pub(crate) fn run_needs(
path: Option<PathBuf>,
reference: &str,
prereqs: &[String],
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let target = require_item(&root, reference)?;
for prereq in prereqs {
require_item(&root, prereq)?;
}
let items = read_all(&root)?;
let cycles = needs_would_cycle(&items, target, prereqs)?;
if let Some(cycle) = cycles.first() {
anyhow::bail!(
"`backlog needs` would close a dependency cycle: {} (nothing written)",
name_cycle(cycle)
);
}
append_relationship(&root, target.0, target.1, &RelEdit::Needs(prereqs))?;
writeln!(
io::stdout(),
"{} needs {}",
target.0.canonical_id(target.1),
prereqs.join(", ")
)?;
Ok(())
}
pub(crate) fn run_after(
path: Option<PathBuf>,
reference: &str,
to: &str,
rank: i32,
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let target = require_item(&root, reference)?;
require_item(&root, to)?;
append_relationship(&root, target.0, target.1, &RelEdit::After { to, rank })?;
let suffix = if rank == 0 {
String::new()
} else {
format!(" (rank {rank})")
};
writeln!(
io::stdout(),
"{} after {to}{suffix}",
target.0.canonical_id(target.1),
)?;
Ok(())
}
fn classify_dangling(corpus: &BTreeMap<ItemId, &BacklogItem>, endpoint: ItemId) -> String {
match corpus.get(&endpoint) {
Some(item) if item.status.is_terminal() => {
let resolution = item.resolution.map_or("?", Resolution::as_str);
format!("{}/{resolution}", item.status.as_str())
}
_ => "absent".to_string(),
}
}
fn render_overrides(
corpus: &BTreeMap<ItemId, &BacklogItem>,
absent: &[AbsentDrop],
overrides: &[Override],
) -> String {
let mut lines: Vec<String> = Vec::new();
for drop in absent {
lines.push(format!(
" {} → {} dropped (dangling: {} absent)\n",
drop.from().render(),
drop.reference(),
drop.reference(),
));
}
for ov in overrides {
let line = match ov.reason() {
OverrideReason::SoftCycleEvicted => format!(
" {} → {} dropped (soft cycle)\n",
ov.from().render(),
ov.to().render()
),
OverrideReason::Contradicted => format!(
" {} → {} dropped (contradicts a need)\n",
ov.from().render(),
ov.to().render()
),
OverrideReason::Dangling => format!(
" {} → {} dropped (dangling: {} {})\n",
ov.from().render(),
ov.to().render(),
ov.from().render(),
classify_dangling(corpus, ov.from()),
),
};
lines.push(line);
}
if lines.is_empty() {
return String::new();
}
let mut out = vec!["\noverrides:\n".to_string()];
out.extend(lines);
out.concat()
}
fn order_rows(root: &Path) -> anyhow::Result<String> {
let items = read_all(root)?;
let (inputs, absent) = project(&items);
let order = BacklogOrder::build(&inputs)?;
if let Some(cycle) = order.dep_cycles().first() {
anyhow::bail!(
"`backlog order` cannot compose: a `needs` dependency cycle — {} (resolve it, then re-run)",
name_cycle(cycle)
);
}
let corpus: BTreeMap<ItemId, &BacklogItem> = items
.iter()
.map(|i| (ItemId::new(i.kind, i.id), i))
.collect();
let ordered: Vec<BacklogItem> = order
.ordered()
.iter()
.filter_map(|id| corpus.get(id).map(|i| (*i).clone()))
.collect();
let sel = listing::select_columns(&BL_COLUMNS, BL_DEFAULT, None)?;
let table = listing::render_columns(&ordered, &sel);
let overrides = render_overrides(&corpus, &absent, &order.overrides());
Ok(format!("{table}{overrides}"))
}
pub(crate) fn run_order(path: Option<PathBuf>) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
write!(io::stdout(), "{}", order_rows(&root)?)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::entity::{self, Inputs, LocalFs, MaterialiseRequest};
use crate::meta::Meta;
use std::fs;
use std::path::Path;
fn ctx_for(item_kind: ItemKind) -> ScaffoldCtx<'static> {
let canonical: &'static str = match item_kind {
ItemKind::Issue => "ISS-003",
ItemKind::Improvement => "IMP-003",
ItemKind::Chore => "CHR-003",
ItemKind::Risk => "RSK-003",
ItemKind::Idea => "IDE-003",
};
ScaffoldCtx {
id: 3,
canonical,
slug: "token-expiry",
title: "Token expiry",
date: "2026-06-08",
}
}
fn fresh(root: &Path, item_kind: ItemKind, slug: &str, title: &str) -> entity::Materialised {
entity::materialise(
item_kind.kind(),
&LocalFs,
root,
&MaterialiseRequest::Fresh,
&Inputs {
slug,
title,
date: "2026-06-08",
},
&[],
)
.unwrap()
}
#[test]
fn backlog_scaffold_lays_out_toml_md_symlink() {
for kind in ItemKind::ALL {
let ctx = ctx_for(kind);
let fileset = backlog_scaffold(kind, &ctx).unwrap();
assert_eq!(fileset.len(), 3, "{kind:?}: toml + md + symlink");
assert!(
matches!(&fileset[0],
Artifact::File { rel_path, body }
if rel_path == Path::new("003/backlog-003.toml")
&& body.contains(&format!("kind = \"{}\"", kind.as_str()))),
"{kind:?}: toml at tree-relative path with the stored kind"
);
assert!(
matches!(&fileset[1],
Artifact::File { rel_path, body }
if rel_path == Path::new("003/backlog-003.md")
&& body.contains(&format!("{}: Token expiry", ctx.canonical))),
"{kind:?}: md carries the canonical ref"
);
assert!(
matches!(&fileset[2],
Artifact::Symlink { rel_path, target }
if rel_path == Path::new("003-token-expiry") && target == "003"),
"{kind:?}: NNN-slug alias last"
);
let toml_body = match &fileset[0] {
Artifact::File { body, .. } => body,
Artifact::Symlink { .. } => panic!("first artifact is the toml"),
};
assert_eq!(
toml_body.contains("[facet]"),
kind.has_facet(),
"{kind:?}: [facet] iff risk"
);
}
}
#[test]
fn all_five_kinds_seed_status_resolution_updated_tags() {
for kind in ItemKind::ALL {
let body = render_backlog_toml(kind, 1, "s", "T", "2026-06-08").unwrap();
assert!(
body.contains("status = \"open\""),
"{kind:?}: status seeded"
);
assert!(
body.contains("resolution = \"\""),
"{kind:?}: resolution seeded"
);
assert!(
body.contains("updated = \"2026-06-08\""),
"{kind:?}: updated seeded"
);
assert!(body.contains("tags = []"), "{kind:?}: tags seeded");
assert!(!body.contains("{{"), "{kind:?}: no token survives render");
}
}
#[test]
fn rendered_toml_round_trips_into_meta_and_backlog_item() {
let body = render_backlog_toml(ItemKind::Issue, 7, "fast-boot", "Fast boot", "2026-06-08")
.unwrap();
let meta: Meta = toml::from_str(&body).unwrap();
assert_eq!(
meta,
Meta {
id: 7,
slug: "fast-boot".to_string(),
title: "Fast boot".to_string(),
status: "open".to_string(),
}
);
let item = validate(toml::from_str::<RawBacklogToml>(&body).unwrap()).unwrap();
assert_eq!(item.kind, ItemKind::Issue);
assert_eq!(item.status, Status::Open);
assert_eq!(item.resolution, None);
assert!(item.facet.is_none(), "a plain kind has no facet");
assert_eq!(item.relationships, Relationships::default());
assert!(item.relationships.needs.is_empty());
assert!(item.relationships.after.is_empty());
assert!(item.relationships.triggers.is_empty());
}
#[test]
fn render_backlog_toml_escapes_hostile_title_and_slug() {
let title = crate::tomlfmt::HOSTILE_TITLE;
let slug = crate::tomlfmt::HOSTILE_SLUG;
let body = render_backlog_toml(ItemKind::Issue, 7, slug, title, "2026-06-08").unwrap();
let parsed: Meta = toml::from_str(&body).unwrap();
assert_eq!(parsed.slug, slug);
assert_eq!(parsed.title, title);
}
#[test]
fn risk_facet_levels_map_empty_to_none_and_parse_non_empty() {
let seeded = render_backlog_toml(ItemKind::Risk, 1, "r", "R", "2026-06-08").unwrap();
let item = validate(toml::from_str::<RawBacklogToml>(&seeded).unwrap()).unwrap();
let facet = item.facet.expect("risk carries a facet");
assert_eq!(facet.likelihood, None);
assert_eq!(facet.impact, None);
assert_eq!(facet.origin, None);
assert!(facet.controls.is_empty());
let assessed = "\
id = 1
slug = \"r\"
title = \"R\"
kind = \"risk\"
status = \"open\"
resolution = \"\"
created = \"2026-06-08\"
updated = \"2026-06-08\"
tags = []
[facet]
likelihood = \"high\"
impact = \"critical\"
origin = \"audit\"
controls = [\"rate-limit\"]
[relationships]
slices = [\"SL-020\"]
specs = []
drift = []
";
let item = validate(toml::from_str::<RawBacklogToml>(assessed).unwrap()).unwrap();
let facet = item.facet.unwrap();
assert_eq!(facet.likelihood, Some(RiskLevel::High));
assert_eq!(facet.impact, Some(RiskLevel::Critical));
assert_eq!(facet.origin.as_deref(), Some("audit"));
assert_eq!(facet.controls, vec!["rate-limit"]);
assert_eq!(item.relationships.slices, vec!["SL-020"]);
}
#[test]
fn validate_errors_on_an_unknown_enum_token() {
let body = "\
id = 1
slug = \"s\"
title = \"T\"
kind = \"issue\"
status = \"open\"
resolution = \"bogus\"
created = \"2026-06-08\"
updated = \"2026-06-08\"
tags = []
";
let raw: RawBacklogToml = toml::from_str(body).unwrap();
assert!(
validate(raw).is_err(),
"an unknown resolution token is rejected"
);
}
#[test]
fn status_is_terminal_is_backlog_local() {
assert!(Status::Resolved.is_terminal());
assert!(Status::Closed.is_terminal());
assert!(!Status::Open.is_terminal());
assert!(!Status::Triaged.is_terminal());
assert!(!Status::Started.is_terminal());
}
#[test]
fn item_kind_from_prefix_round_trips_each_kind() {
for kind in ItemKind::ALL {
assert_eq!(ItemKind::from_prefix(kind.prefix()), Some(kind));
}
assert_eq!(ItemKind::from_prefix("REQ"), None);
let prefixes: std::collections::BTreeSet<&str> =
ItemKind::ALL.iter().map(|k| k.prefix()).collect();
assert_eq!(prefixes.len(), 5);
}
#[test]
fn resolution_and_risk_level_render_mirror_serde() {
assert_eq!(Resolution::WontDo.as_str(), "wont-do");
assert_eq!(Resolution::Promoted.as_str(), "promoted");
assert_eq!(RiskLevel::Critical.as_str(), "critical");
assert_eq!(
parse_enum::<Resolution>("wont-do", "resolution").unwrap(),
Resolution::WontDo
);
}
#[test]
fn materialise_fresh_reserves_each_kind_in_its_own_namespace() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let i1 = fresh(root, ItemKind::Issue, "auth", "Auth");
let r1 = fresh(root, ItemKind::Risk, "expiry", "Expiry");
assert_eq!(i1.eid.numeric_id(), Some(1));
assert_eq!(r1.eid.numeric_id(), Some(1));
assert!(
root.join(".doctrine/backlog/issue/001/backlog-001.toml")
.is_file()
);
assert!(
root.join(".doctrine/backlog/issue/001/backlog-001.md")
.is_file()
);
assert_eq!(
fs::read_link(root.join(".doctrine/backlog/issue/001-auth")).unwrap(),
Path::new("001")
);
let risk_toml =
fs::read_to_string(root.join(".doctrine/backlog/risk/001/backlog-001.toml")).unwrap();
assert!(risk_toml.contains("[facet]"));
let issue_toml =
fs::read_to_string(root.join(".doctrine/backlog/issue/001/backlog-001.toml")).unwrap();
assert!(!issue_toml.contains("[facet]"));
let i2 = fresh(root, ItemKind::Issue, "login", "Login");
assert_eq!(i2.eid.numeric_id(), Some(2));
let r2 = fresh(root, ItemKind::Risk, "leak", "Leak");
assert_eq!(r2.eid.numeric_id(), Some(2));
let item = validate(toml::from_str::<RawBacklogToml>(&risk_toml).unwrap()).unwrap();
assert_eq!(item.kind, ItemKind::Risk);
assert_eq!(item.id, 1);
}
fn new_item(root: &Path, kind: ItemKind, title: &str) {
run_new(
Some(root.to_path_buf()),
kind,
Some(title.to_string()),
None,
)
.unwrap();
}
fn issue_dir(root: &Path, id: &str) -> PathBuf {
root.join(format!(".doctrine/backlog/issue/{id}"))
}
#[test]
fn backlog_new_reserves_monotonic_per_kind() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
new_item(root, ItemKind::Issue, "Auth");
new_item(root, ItemKind::Issue, "Login");
assert!(issue_dir(root, "001").join("backlog-001.toml").is_file());
assert!(issue_dir(root, "001").join("backlog-001.md").is_file());
assert_eq!(
fs::read_link(root.join(".doctrine/backlog/issue/001-auth")).unwrap(),
Path::new("001")
);
assert!(issue_dir(root, "002").join("backlog-002.toml").is_file());
}
#[test]
fn backlog_new_counters_isolated_across_kinds() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
new_item(root, ItemKind::Issue, "Auth");
new_item(root, ItemKind::Risk, "Expiry");
assert!(issue_dir(root, "001").join("backlog-001.toml").is_file());
assert!(
root.join(".doctrine/backlog/risk/001/backlog-001.toml")
.is_file()
);
new_item(root, ItemKind::Issue, "Login");
assert!(issue_dir(root, "002").join("backlog-002.toml").is_file());
assert!(
!root.join(".doctrine/backlog/risk/002").exists(),
"an issue create must not advance the risk counter"
);
}
#[test]
fn backlog_new_seeds_kind_template() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
new_item(root, ItemKind::Risk, "Token expiry");
new_item(root, ItemKind::Issue, "Token expiry");
let risk =
fs::read_to_string(root.join(".doctrine/backlog/risk/001/backlog-001.toml")).unwrap();
assert!(risk.contains("[facet]"), "risk seeds a facet");
assert!(risk.contains("status = \"open\""), "status defaults open");
let issue = fs::read_to_string(issue_dir(root, "001").join("backlog-001.toml")).unwrap();
assert!(!issue.contains("[facet]"), "a plain kind has no facet");
assert!(issue.contains("status = \"open\""));
let item = validate(toml::from_str::<RawBacklogToml>(&issue).unwrap()).unwrap();
assert_eq!(item.kind, ItemKind::Issue);
assert_eq!(item.id, 1);
assert_eq!(ItemKind::Issue.canonical_id(item.id), "ISS-001");
}
#[test]
fn created_backlog_item_is_git_addable() {
fn git(root: &Path, args: &[&str]) -> std::process::Output {
std::process::Command::new("git")
.arg("-C")
.arg(root)
.args(args)
.output()
.expect("spawn git")
}
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
assert!(git(root, &["init", "-b", "main"]).status.success());
fs::write(
root.join(".gitignore"),
".doctrine/*\n!.doctrine/backlog/\n",
)
.unwrap();
new_item(root, ItemKind::Issue, "Auth");
let item = ".doctrine/backlog/issue/001/backlog-001.toml";
assert_eq!(
git(root, &["check-ignore", "-q", item]).status.code(),
Some(1),
"the negation must un-ignore the backlog item"
);
let add = git(root, &["add", item]);
assert!(
add.status.success(),
"git add failed: {}",
String::from_utf8_lossy(&add.stderr)
);
}
struct Fixture<'a> {
kind: ItemKind,
id: u32,
slug: &'a str,
title: &'a str,
status: &'a str,
resolution: &'a str,
tags: &'a [&'a str],
facet: Option<FacetLit<'a>>,
rels: Option<RelLit<'a>>,
}
struct FacetLit<'a> {
likelihood: &'a str,
impact: &'a str,
origin: &'a str,
controls: &'a [&'a str],
}
struct RelLit<'a> {
slices: &'a [&'a str],
specs: &'a [&'a str],
needs: &'a [&'a str],
after: &'a [AfterLit<'a>],
triggers: &'a [TriggerLit<'a>],
}
struct AfterLit<'a> {
to: &'a str,
rank: i32,
}
struct TriggerLit<'a> {
globs: &'a [&'a str],
note: &'a str,
}
fn toml_list(xs: &[&str]) -> String {
xs.iter()
.map(|x| format!("\"{x}\""))
.collect::<Vec<_>>()
.join(", ")
}
fn toml_after(xs: &[AfterLit<'_>]) -> String {
xs.iter()
.map(|e| format!("{{ to = \"{}\", rank = {} }}", e.to, e.rank))
.collect::<Vec<_>>()
.join(", ")
}
fn toml_triggers(xs: &[TriggerLit<'_>]) -> String {
xs.iter()
.map(|t| {
format!(
"{{ globs = [{}], note = \"{}\" }}",
toml_list(t.globs),
t.note
)
})
.collect::<Vec<_>>()
.join(", ")
}
fn render_fixture_toml(f: &Fixture<'_>) -> String {
let head = format!(
"id = {}\nslug = \"{}\"\ntitle = \"{}\"\nkind = \"{}\"\n\
status = \"{}\"\nresolution = \"{}\"\n\
created = \"2026-06-08\"\nupdated = \"2026-06-08\"\ntags = [{}]\n",
f.id,
f.slug,
f.title,
f.kind.as_str(),
f.status,
f.resolution,
toml_list(f.tags),
);
let facet = f.facet.as_ref().map_or_else(String::new, |x| {
format!(
"\n[facet]\nlikelihood = \"{}\"\nimpact = \"{}\"\norigin = \"{}\"\ncontrols = [{}]\n",
x.likelihood,
x.impact,
x.origin,
toml_list(x.controls),
)
});
let rels = f.rels.as_ref().map_or_else(String::new, |x| {
format!(
"\n[relationships]\nslices = [{}]\nspecs = [{}]\ndrift = []\n\
needs = [{}]\nafter = [{}]\ntriggers = [{}]\n",
toml_list(x.slices),
toml_list(x.specs),
toml_list(x.needs),
toml_after(x.after),
toml_triggers(x.triggers),
)
});
format!("{head}{facet}{rels}")
}
fn write_fixture(root: &Path, f: Fixture<'_>) {
let name = format!("{:03}", f.id);
let dir = root.join(f.kind.kind().dir).join(&name);
fs::create_dir_all(&dir).unwrap();
fs::write(
dir.join(format!("backlog-{name}.toml")),
render_fixture_toml(&f),
)
.unwrap();
}
fn write_item(
root: &Path,
kind: ItemKind,
id: u32,
status: &str,
resolution: &str,
slug: &str,
title: &str,
tags: &[&str],
) {
write_fixture(
root,
Fixture {
kind,
id,
slug,
title,
status,
resolution,
tags,
facet: None,
rels: None,
},
);
}
fn ids(out: &str) -> Vec<String> {
out.lines()
.skip(1)
.map(|l| l.split_whitespace().next().unwrap().to_string())
.collect()
}
fn list_args() -> ListArgs {
ListArgs::default()
}
#[test]
fn backlog_list_emits_a_header_then_prefixed_ids() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_item(root, ItemKind::Issue, 1, "open", "", "a", "Alpha", &[]);
let out = list_rows(root, None, list_args()).unwrap();
let lines: Vec<&str> = out.lines().collect();
assert!(lines[0].starts_with("id"), "header row: {:?}", lines[0]);
assert!(
lines[0].contains("kind") && lines[0].contains("status"),
"header names columns: {:?}",
lines[0]
);
assert!(lines[1].starts_with("ISS-001"), "first data row prefixed");
}
#[test]
fn backlog_list_empty_suppresses_the_header() {
let dir = tempfile::tempdir().unwrap();
assert_eq!(list_rows(dir.path(), None, list_args()).unwrap(), "");
}
fn columns_args(cols: &[&str]) -> ListArgs {
ListArgs {
columns: Some(cols.iter().map(|s| (*s).to_string()).collect()),
..Default::default()
}
}
#[test]
fn backlog_list_default_omits_slug() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_item(
root,
ItemKind::Issue,
1,
"open",
"",
"token-expiry",
"Alpha",
&[],
);
let out = list_rows(root, None, list_args()).unwrap();
let header = out.lines().next().unwrap();
assert!(
!header.contains("slug"),
"default header omits slug: {header:?}"
);
assert!(
!out.contains("token-expiry"),
"slug value hidden by default: {out}"
);
assert!(
header.contains("kind") && header.contains("status") && header.contains("title"),
"default keeps id/kind/status/title: {header:?}"
);
}
#[test]
fn backlog_list_columns_reveals_and_orders_slug() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_item(
root,
ItemKind::Issue,
1,
"open",
"",
"token-expiry",
"Alpha",
&[],
);
let out = list_rows(root, None, columns_args(&["id", "slug", "title"])).unwrap();
let header = out.lines().next().unwrap();
assert_eq!(
header.split_whitespace().collect::<Vec<_>>(),
vec!["id", "slug", "title"]
);
assert!(
out.contains("token-expiry"),
"slug revealed by --columns: {out}"
);
}
#[test]
fn backlog_list_columns_unknown_errors_with_available_set() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_item(root, ItemKind::Issue, 1, "open", "", "a", "Alpha", &[]);
let err = list_rows(root, None, columns_args(&["bogus"]))
.err()
.map(|e| e.to_string())
.unwrap_or_default();
assert!(
err.contains("unknown column `bogus`"),
"uniform error: {err}"
);
assert!(
err.contains("id") && err.contains("slug"),
"lists available set: {err}"
);
}
#[test]
fn backlog_list_default_hides_terminal() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_item(root, ItemKind::Issue, 1, "open", "", "a", "Alpha", &[]);
write_item(root, ItemKind::Issue, 2, "triaged", "", "b", "Bravo", &[]);
write_item(root, ItemKind::Issue, 3, "started", "", "c", "Charlie", &[]);
write_item(
root,
ItemKind::Issue,
4,
"resolved",
"fixed",
"d",
"Delta",
&[],
);
write_item(root, ItemKind::Issue, 5, "closed", "done", "e", "Echo", &[]);
let out = list_rows(root, None, list_args()).unwrap();
assert_eq!(
ids(&out),
vec!["ISS-001", "ISS-002", "ISS-003"],
"default shows only the active states; resolved/closed hidden"
);
}
#[test]
fn backlog_list_all_and_explicit_status_reveal() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_item(root, ItemKind::Issue, 1, "open", "", "a", "Alpha", &[]);
write_item(
root,
ItemKind::Issue,
4,
"resolved",
"fixed",
"d",
"Delta",
&[],
);
write_item(root, ItemKind::Issue, 5, "closed", "done", "e", "Echo", &[]);
write_item(
root,
ItemKind::Issue,
6,
"resolved",
"promoted",
"f",
"Foxtrot",
&[],
);
let all = list_rows(
root,
None,
ListArgs {
all: true,
..ListArgs::default()
},
)
.unwrap();
assert_eq!(
ids(&all),
vec!["ISS-001", "ISS-004", "ISS-005", "ISS-006"],
"--all shows active + terminal + promoted"
);
let resolved = list_rows(
root,
None,
ListArgs {
status: vec!["resolved".into()],
..ListArgs::default()
},
)
.unwrap();
assert_eq!(
ids(&resolved),
vec!["ISS-004", "ISS-006"],
"--status resolved reveals the resolved (incl. promoted) items only"
);
}
#[test]
fn backlog_list_filters_and_together() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_item(
root,
ItemKind::Issue,
1,
"open",
"",
"auth-bug",
"Auth bug",
&["security"],
);
write_item(
root,
ItemKind::Issue,
2,
"open",
"",
"login",
"Login flow",
&["ui"],
);
write_item(
root,
ItemKind::Risk,
1,
"open",
"",
"auth-risk",
"Auth risk",
&["security"],
);
let out = list_rows(
root,
Some(ItemKind::Issue),
ListArgs {
tags: vec!["security".to_string()],
substr: Some("auth".to_string()),
..ListArgs::default()
},
)
.unwrap();
assert_eq!(
ids(&out),
vec!["ISS-001"],
"the axes intersect: ISS-002 lacks the tag/substr, RSK-001 is the wrong kind"
);
}
#[test]
fn backlog_list_kind_then_id_order() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_item(root, ItemKind::Risk, 1, "open", "", "r", "R", &[]);
write_item(root, ItemKind::Issue, 2, "open", "", "i2", "I2", &[]);
write_item(root, ItemKind::Issue, 1, "open", "", "i1", "I1", &[]);
write_item(root, ItemKind::Idea, 1, "open", "", "d", "D", &[]);
write_item(root, ItemKind::Chore, 1, "open", "", "c", "C", &[]);
let out = list_rows(root, None, list_args()).unwrap();
assert_eq!(
ids(&out),
vec!["ISS-001", "ISS-002", "CHR-001", "RSK-001", "IDE-001"],
"kind declaration order (issue/improvement/chore/risk/idea) then ascending id"
);
}
#[test]
fn backlog_list_missing_dir_is_empty_set() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_item(root, ItemKind::Issue, 1, "open", "", "a", "Alpha", &[]);
let out = list_rows(root, None, list_args()).unwrap();
assert_eq!(
ids(&out),
vec!["ISS-001"],
"an absent kind dir contributes the empty set, never an error"
);
}
#[test]
fn backlog_list_virgin_repo_empty_table() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let out = list_rows(root, None, list_args()).unwrap();
assert_eq!(
out, "",
"a virgin repo prints an empty table, never an error"
);
}
#[test]
fn backlog_list_regexp_matches_canonical_id_case_insensitive() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_item(root, ItemKind::Issue, 1, "open", "", "a", "Alpha", &[]);
write_item(root, ItemKind::Risk, 1, "open", "", "r", "Risky", &[]);
let out = list_rows(
root,
None,
ListArgs {
regexp: Some("iss-".into()),
case_insensitive: true,
..ListArgs::default()
},
)
.unwrap();
assert_eq!(
ids(&out),
vec!["ISS-001"],
"regexp on the prefixed id: {out}"
);
}
#[test]
fn backlog_list_json_is_one_envelope_with_prefixed_ids() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_item(root, ItemKind::Issue, 1, "open", "", "a", "Alpha", &["x"]);
write_item(
root,
ItemKind::Issue,
2,
"resolved",
"fixed",
"b",
"Bravo",
&[],
);
let json = list_rows(
root,
None,
ListArgs {
json: true,
..ListArgs::default()
},
)
.unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["kind"], "backlog");
let rows = v["rows"].as_array().expect("rows is an array");
assert_eq!(rows.len(), 1, "hide-set applies under json too: {json}");
let row = rows.first().expect("the open row");
assert_eq!(row["id"], "ISS-001");
assert_eq!(row["kind"], "issue");
assert_eq!(row["status"], "open");
assert_eq!(row["resolution"], serde_json::Value::Null);
}
#[test]
fn backlog_list_rejects_an_unknown_status_with_the_uniform_error() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_item(root, ItemKind::Issue, 1, "open", "", "a", "Alpha", &[]);
let err = list_rows(
root,
None,
ListArgs {
status: vec!["bogus".into()],
..ListArgs::default()
},
)
.unwrap_err();
assert!(
err.to_string().contains("bogus"),
"names the bad value: {err}"
);
}
#[test]
fn backlog_statuses_matches_the_variants() {
let from_variants: Vec<&str> = [
Status::Open,
Status::Triaged,
Status::Started,
Status::Resolved,
Status::Closed,
]
.iter()
.map(|s| s.as_str())
.collect();
assert_eq!(from_variants, BACKLOG_STATUSES.to_vec());
}
#[test]
fn is_hidden_reuses_status_is_terminal() {
assert!(is_hidden("resolved"));
assert!(is_hidden("closed"));
assert!(!is_hidden("open"));
assert!(!is_hidden("triaged"));
assert!(!is_hidden("started"));
assert!(!is_hidden("bogus"));
}
#[test]
fn backlog_show_json_is_faithful_item_state() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_fixture(
root,
Fixture {
kind: ItemKind::Risk,
id: 1,
slug: "leak",
title: "Token leak",
status: "resolved",
resolution: "mitigated",
tags: &["security"],
facet: Some(FacetLit {
likelihood: "high",
impact: "critical",
origin: "audit",
controls: &["rotate"],
}),
rels: Some(RelLit {
slices: &["SL-020"],
specs: &[],
needs: &[],
after: &[],
triggers: &[],
}),
},
);
let item = read_item(root, ItemKind::Risk, 1).unwrap();
let json = show_json(&item).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(v["kind"], "backlog");
let b = &v["backlog"];
assert_eq!(b["id"], "RSK-001");
assert_eq!(b["status"], "resolved");
assert_eq!(b["resolution"], "mitigated");
assert_eq!(b["tags"][0], "security");
assert_eq!(b["facet"]["likelihood"], "high");
assert_eq!(b["facet"]["impact"], "critical");
assert_eq!(b["relationships"]["slices"][0], "SL-020");
}
#[test]
fn backlog_show_id_parse_tolerance() {
assert_eq!(parse_ref("ISS-7").unwrap(), (ItemKind::Issue, 7));
assert_eq!(parse_ref("ISS-007").unwrap(), (ItemKind::Issue, 7));
assert_eq!(parse_ref("iss-7").unwrap(), (ItemKind::Issue, 7));
assert_eq!(parse_ref("RSK-001").unwrap(), (ItemKind::Risk, 1));
assert_eq!(parse_ref("IDE-12").unwrap(), (ItemKind::Idea, 12));
}
#[test]
fn backlog_show_unknown_prefix_errors() {
assert!(parse_ref("REQ-001").is_err(), "unknown prefix rejected");
assert!(parse_ref("ISS-abc").is_err(), "non-numeric tail rejected");
assert!(
parse_ref("nodash").is_err(),
"a ref with no `-` is rejected"
);
}
#[test]
fn backlog_show_auto_detects_kind_from_prefix() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
new_item(root, ItemKind::Issue, "Auth bug");
let issue = read_item(root, ItemKind::Issue, 1).unwrap();
let issue_out = format_show(&issue);
assert!(
issue_out.starts_with("ISS-001 — Auth bug\n"),
"identity line: {issue_out}"
);
assert!(
issue_out.contains("· issue · open"),
"flat field line carries kind + status: {issue_out}"
);
assert!(
!issue_out.contains("[facet]"),
"a plain kind shows no facet block: {issue_out}"
);
write_assessed_risk(root, 1);
let risk = read_item(root, ItemKind::Risk, 1).unwrap();
let risk_out = format_show(&risk);
assert!(risk_out.starts_with("RSK-001 — Token expiry\n"));
assert!(risk_out.contains("[facet]"), "risk shows the facet block");
assert!(risk_out.contains("likelihood: high"));
assert!(risk_out.contains("impact: critical"));
assert!(risk_out.contains("controls: rate-limit"));
}
#[test]
fn backlog_show_renders_outbound_only() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_related(root, ItemKind::Issue, 1, &["SL-020"], &["PRD-009"]);
write_related(root, ItemKind::Issue, 2, &[], &[]);
let out = format_show(&read_item(root, ItemKind::Issue, 1).unwrap());
assert!(out.contains("relationships:"), "the outbound seam renders");
assert!(out.contains("slices: SL-020"), "outbound slice ref shown");
assert!(out.contains("specs: PRD-009"), "outbound spec ref shown");
let bare = format_show(&read_item(root, ItemKind::Issue, 2).unwrap());
assert!(
!bare.contains("relationships:"),
"no outbound relations → no block (inbound never surfaced): {bare}"
);
}
#[test]
fn after_edge_round_trips_with_optional_rank() {
let rel: Relationships =
toml::from_str("after = [{ to = \"ISS-002\", rank = 2 }, { to = \"ISS-003\" }]\n")
.unwrap();
assert_eq!(
rel.after,
vec![
AfterEdge {
to: "ISS-002".to_string(),
rank: 2,
},
AfterEdge {
to: "ISS-003".to_string(),
rank: 0,
},
]
);
}
#[test]
fn trigger_round_trips_with_optional_note() {
let rel: Relationships = toml::from_str(
"triggers = [{ globs = [\"src/x/**\"], note = \"watch x\" }, \
{ globs = [\"src/y/**\"] }]\n",
)
.unwrap();
assert_eq!(
rel.triggers,
vec![
Trigger {
globs: vec!["src/x/**".to_string()],
note: "watch x".to_string(),
},
Trigger {
globs: vec!["src/y/**".to_string()],
note: String::new(),
},
]
);
}
#[test]
fn backlog_show_renders_all_three_item_axes() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_fixture(
root,
Fixture {
kind: ItemKind::Issue,
id: 1,
slug: "s",
title: "T",
status: "open",
resolution: "",
tags: &[],
facet: None,
rels: Some(RelLit {
slices: &[],
specs: &[],
needs: &["ISS-002"],
after: &[AfterLit {
to: "ISS-003",
rank: 2,
}],
triggers: &[TriggerLit {
globs: &["src/x/**"],
note: "watch x",
}],
}),
},
);
let item = read_item(root, ItemKind::Issue, 1).unwrap();
let out = format_show(&item);
assert!(out.contains("needs: ISS-002"), "hard prereq axis: {out}");
assert!(
out.contains("after: ISS-003 (rank 2)"),
"soft seq axis with rank: {out}"
);
assert!(
out.contains("triggers: [src/x/**] watch x"),
"triggers rider: {out}"
);
let json = show_json(&item).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
let rel = &v["backlog"]["relationships"];
assert_eq!(rel["needs"][0], "ISS-002");
assert_eq!(rel["after"][0]["to"], "ISS-003");
assert_eq!(rel["after"][0]["rank"], 2);
assert_eq!(rel["triggers"][0]["globs"][0], "src/x/**");
assert_eq!(rel["triggers"][0]["note"], "watch x");
}
fn write_assessed_risk(root: &Path, id: u32) {
write_fixture(
root,
Fixture {
kind: ItemKind::Risk,
id,
slug: "token-expiry",
title: "Token expiry",
status: "open",
resolution: "",
tags: &[],
facet: Some(FacetLit {
likelihood: "high",
impact: "critical",
origin: "audit",
controls: &["rate-limit"],
}),
rels: Some(RelLit {
slices: &[],
specs: &[],
needs: &[],
after: &[],
triggers: &[],
}),
},
);
}
fn write_related(root: &Path, kind: ItemKind, id: u32, slices: &[&str], specs: &[&str]) {
write_fixture(
root,
Fixture {
kind,
id,
slug: "s",
title: "T",
status: "open",
resolution: "",
tags: &[],
facet: None,
rels: Some(RelLit {
slices,
specs,
needs: &[],
after: &[],
triggers: &[],
}),
},
);
}
fn read_back(root: &Path, kind: ItemKind, id: u32) -> BacklogItem {
read_item(root, kind, id).unwrap()
}
#[test]
fn validate_transition_couples_both_directions_and_d9_clears() {
assert!(validate_transition(Status::Resolved, None).is_err());
assert!(validate_transition(Status::Closed, None).is_err());
assert_eq!(
validate_transition(Status::Resolved, Some(Resolution::Fixed)).unwrap(),
"fixed"
);
assert!(validate_transition(Status::Started, Some(Resolution::Fixed)).is_err());
assert!(validate_transition(Status::Open, Some(Resolution::Promoted)).is_err());
assert_eq!(validate_transition(Status::Open, None).unwrap(), "");
assert_eq!(validate_transition(Status::Triaged, None).unwrap(), "");
}
fn facet(likelihood: Option<RiskLevel>, impact: Option<RiskLevel>) -> RiskFacet {
RiskFacet {
likelihood,
impact,
origin: None,
controls: Vec::new(),
}
}
#[test]
fn exposure_scores_a_fully_assessed_risk() {
use RiskLevel::{Critical, High, Low};
assert_eq!(exposure(Some(&facet(Some(High), Some(Critical)))), 12);
assert_eq!(exposure(Some(&facet(Some(Low), Some(Low)))), 1);
assert_eq!(exposure(Some(&facet(Some(Critical), Some(Critical)))), 16);
}
#[test]
fn exposure_is_baseline_when_unassessed_or_non_risk() {
use RiskLevel::High;
assert_eq!(exposure(Some(&facet(Some(High), None))), 0);
assert_eq!(exposure(Some(&facet(None, Some(High)))), 0);
assert_eq!(exposure(Some(&facet(None, None))), 0);
assert_eq!(exposure(None), 0);
}
#[test]
fn backlog_edit_is_edit_preserving() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
new_item(root, ItemKind::Issue, "Auth"); let path = issue_dir(root, "001").join("backlog-001.toml");
let mut body = fs::read_to_string(&path).unwrap();
body.push_str("\n# hand note — keep me\n[custom]\nkeep = \"yes\"\n");
fs::write(&path, &body).unwrap();
set_backlog_status(
root,
ItemKind::Issue,
1,
Status::Resolved,
Some(Resolution::Fixed),
"2026-07-01",
)
.unwrap();
let after = fs::read_to_string(&path).unwrap();
assert!(after.contains("# hand note — keep me"), "comment survives");
assert!(after.contains("[custom]"), "inert table survives verbatim");
assert!(after.contains("keep = \"yes\""), "unknown key survives");
assert!(
after.contains("[relationships]"),
"seeded subtable survives"
);
assert!(after.contains("status = \"resolved\""));
assert!(after.contains("resolution = \"fixed\""));
assert!(after.contains("updated = \"2026-07-01\""), "updated bumps");
let item = read_back(root, ItemKind::Issue, 1);
assert_eq!(item.status, Status::Resolved);
assert_eq!(item.resolution, Some(Resolution::Fixed));
}
#[test]
fn backlog_edit_noop_writes_nothing() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
new_item(root, ItemKind::Issue, "Auth"); let path = issue_dir(root, "001").join("backlog-001.toml");
let before = fs::read_to_string(&path).unwrap();
let mtime_before = fs::metadata(&path).unwrap().modified().unwrap();
let written =
set_backlog_status(root, ItemKind::Issue, 1, Status::Open, None, "2026-07-01").unwrap();
assert_eq!(
written, "",
"the no-op still reports the resolved (empty) state"
);
assert_eq!(
before,
fs::read_to_string(&path).unwrap(),
"a no-op writes nothing — content byte-identical"
);
assert_eq!(
mtime_before,
fs::metadata(&path).unwrap().modified().unwrap(),
"a no-op leaves mtime untouched"
);
}
#[test]
fn backlog_edit_refuses_malformed_missing_seeded_key() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let d = root.join(ItemKind::Issue.kind().dir).join("001");
fs::create_dir_all(&d).unwrap();
let malformed = "id = 1\nslug = \"a\"\ntitle = \"A\"\nkind = \"issue\"\n\
status = \"open\"\ncreated = \"2026-06-08\"\nupdated = \"2026-06-08\"\ntags = []\n";
let path = d.join("backlog-001.toml");
fs::write(&path, malformed).unwrap();
let err = set_backlog_status(
root,
ItemKind::Issue,
1,
Status::Resolved,
Some(Resolution::Fixed),
"2026-07-01",
);
assert!(err.is_err(), "a missing seeded key is refused");
assert_eq!(
fs::read_to_string(&path).unwrap(),
malformed,
"the file is untouched — never tail-inserted into corruption"
);
}
#[test]
fn backlog_edit_missing_id_hard_errors() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let err = set_backlog_status(
root,
ItemKind::Issue,
99,
Status::Started,
None,
"2026-07-01",
);
assert!(err.is_err(), "editing a nonexistent id errors");
assert!(
!issue_dir(root, "099").exists(),
"the failed edit creates nothing"
);
}
#[test]
fn backlog_edit_reopen_auto_clears_resolution() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
new_item(root, ItemKind::Issue, "Auth");
set_backlog_status(
root,
ItemKind::Issue,
1,
Status::Resolved,
Some(Resolution::Fixed),
"2026-07-01",
)
.unwrap();
let resolved = read_back(root, ItemKind::Issue, 1);
assert_eq!(resolved.status, Status::Resolved);
assert_eq!(resolved.resolution, Some(Resolution::Fixed));
set_backlog_status(root, ItemKind::Issue, 1, Status::Open, None, "2026-07-02").unwrap();
let reopened = read_back(root, ItemKind::Issue, 1);
assert_eq!(reopened.status, Status::Open);
assert_eq!(reopened.resolution, None, "D9: re-open clears resolution");
set_backlog_status(
root,
ItemKind::Issue,
1,
Status::Closed,
Some(Resolution::Promoted),
"2026-07-03",
)
.unwrap();
set_backlog_status(root, ItemKind::Issue, 1, Status::Open, None, "2026-07-04").unwrap();
let after = read_back(root, ItemKind::Issue, 1);
assert_eq!(after.status, Status::Open);
assert_eq!(after.resolution, None, "a promoted item re-opens ungated");
}
#[test]
fn backlog_edit_rejects_noncanon_status_and_resolution() {
use clap::ValueEnum;
assert!(Status::from_str("bogus", false).is_err());
assert!(Resolution::from_str("nope", false).is_err());
assert_eq!(Status::from_str("started", false).unwrap(), Status::Started);
assert_eq!(
Resolution::from_str("wont-do", false).unwrap(),
Resolution::WontDo
);
}
#[test]
fn run_edit_drives_the_coupled_transition() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
new_item(root, ItemKind::Risk, "Token leak");
assert!(run_edit(Some(root.to_path_buf()), "RSK-001", Status::Resolved, None).is_err());
run_edit(
Some(root.to_path_buf()),
"RSK-001",
Status::Resolved,
Some(Resolution::Mitigated),
)
.unwrap();
let item = read_back(root, ItemKind::Risk, 1);
assert_eq!(item.status, Status::Resolved);
assert_eq!(item.resolution, Some(Resolution::Mitigated));
assert!(run_edit(Some(root.to_path_buf()), "RSK-099", Status::Started, None).is_err());
}
fn write_rel_item(
root: &Path,
kind: ItemKind,
id: u32,
status: &str,
needs: &[&str],
after: &[AfterLit<'_>],
) {
write_fixture(
root,
Fixture {
kind,
id,
slug: "s",
title: "T",
status,
resolution: if matches!(status, "resolved" | "closed") {
"done"
} else {
""
},
tags: &[],
facet: None,
rels: Some(RelLit {
slices: &[],
specs: &[],
needs,
after,
triggers: &[],
}),
},
);
}
fn ordered_ids(inputs: &[OrderInput]) -> Vec<String> {
BacklogOrder::build(inputs)
.unwrap()
.ordered()
.iter()
.map(|id| id.render())
.collect()
}
#[test]
fn project_keeps_non_terminal_nodes_only() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_rel_item(root, ItemKind::Issue, 1, "open", &[], &[]);
write_rel_item(root, ItemKind::Issue, 2, "resolved", &[], &[]);
write_rel_item(root, ItemKind::Issue, 3, "closed", &[], &[]);
write_rel_item(root, ItemKind::Issue, 4, "started", &[], &[]);
let (inputs, absent) = project(&read_all(root).unwrap());
assert!(absent.is_empty());
let mut ids = ordered_ids(&inputs);
ids.sort();
assert_eq!(ids, vec!["ISS-001", "ISS-004"]);
}
#[test]
fn project_wires_a_hard_needs_edge_into_the_order() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_rel_item(root, ItemKind::Issue, 1, "open", &["ISS-002"], &[]);
write_rel_item(root, ItemKind::Issue, 2, "open", &[], &[]);
let (inputs, _) = project(&read_all(root).unwrap());
assert_eq!(ordered_ids(&inputs), vec!["ISS-002", "ISS-001"]);
}
#[test]
fn project_honours_a_cross_kind_after_edge_with_rank() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_rel_item(
root,
ItemKind::Chore,
1,
"open",
&[],
&[AfterLit {
to: "RSK-001",
rank: 3,
}],
);
write_rel_item(root, ItemKind::Risk, 1, "open", &[], &[]);
let (inputs, absent) = project(&read_all(root).unwrap());
assert!(absent.is_empty(), "both endpoints are live nodes");
assert_eq!(ordered_ids(&inputs), vec!["RSK-001", "CHR-001"]);
}
#[test]
fn project_records_an_unparseable_ref_as_an_absent_drop() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_rel_item(root, ItemKind::Issue, 1, "open", &["NOPE-1"], &[]);
let (inputs, absent) = project(&read_all(root).unwrap());
assert_eq!(
absent.len(),
1,
"the unparseable ref is recorded, not silent"
);
assert_eq!(absent[0].from().render(), "ISS-001");
assert_eq!(absent[0].reference(), "NOPE-1");
assert_eq!(ordered_ids(&inputs), vec!["ISS-001"]);
}
#[test]
fn project_emits_distinct_item_ids_one_row_per_item() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_rel_item(root, ItemKind::Issue, 1, "open", &[], &[]);
write_rel_item(root, ItemKind::Risk, 1, "open", &[], &[]);
let (inputs, _) = project(&read_all(root).unwrap());
assert_eq!(ordered_ids(&inputs).len(), 2);
assert!(BacklogOrder::build(&inputs).is_ok());
}
#[test]
fn append_needs_preserves_comments_and_inert_tables() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
new_item(root, ItemKind::Issue, "Auth"); let path = issue_dir(root, "001").join("backlog-001.toml");
let mut body = fs::read_to_string(&path).unwrap();
body.push_str("\n# hand note — keep me\n[custom]\nkeep = \"yes\"\n");
fs::write(&path, &body).unwrap();
append_relationship(
root,
ItemKind::Issue,
1,
&RelEdit::Needs(&["ISS-002".to_string(), "RSK-001".to_string()]),
)
.unwrap();
let after = fs::read_to_string(&path).unwrap();
assert!(after.contains("# hand note — keep me"), "comment survives");
assert!(after.contains("[custom]"), "inert table survives");
assert!(after.contains("keep = \"yes\""), "unknown key survives");
let item = read_item(root, ItemKind::Issue, 1).unwrap();
assert_eq!(item.relationships.needs, vec!["ISS-002", "RSK-001"]);
}
#[test]
fn append_after_round_trips_to_and_rank() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
new_item(root, ItemKind::Issue, "Auth");
append_relationship(
root,
ItemKind::Issue,
1,
&RelEdit::After {
to: "ISS-002",
rank: 5,
},
)
.unwrap();
let item = read_item(root, ItemKind::Issue, 1).unwrap();
assert_eq!(
item.relationships.after,
vec![AfterEdge {
to: "ISS-002".to_string(),
rank: 5,
}]
);
}
#[test]
fn append_relationship_is_idempotent() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
new_item(root, ItemKind::Issue, "Auth");
let path = issue_dir(root, "001").join("backlog-001.toml");
append_relationship(
root,
ItemKind::Issue,
1,
&RelEdit::Needs(&["ISS-002".to_string()]),
)
.unwrap();
let once = fs::read_to_string(&path).unwrap();
append_relationship(
root,
ItemKind::Issue,
1,
&RelEdit::Needs(&["ISS-002".to_string()]),
)
.unwrap();
assert_eq!(
once,
fs::read_to_string(&path).unwrap(),
"idempotent append"
);
let item = read_item(root, ItemKind::Issue, 1).unwrap();
assert_eq!(item.relationships.needs, vec!["ISS-002"], "not duplicated");
}
#[test]
fn append_relationship_refuses_a_malformed_missing_array() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let d = root.join(ItemKind::Issue.kind().dir).join("001");
fs::create_dir_all(&d).unwrap();
let malformed = "id = 1\nslug = \"a\"\ntitle = \"A\"\nkind = \"issue\"\n\
status = \"open\"\nresolution = \"\"\ncreated = \"2026-06-08\"\n\
updated = \"2026-06-08\"\ntags = []\n\n[relationships]\nslices = []\n";
let path = d.join("backlog-001.toml");
fs::write(&path, malformed).unwrap();
let err = append_relationship(
root,
ItemKind::Issue,
1,
&RelEdit::Needs(&["ISS-002".to_string()]),
);
assert!(err.is_err(), "a missing seeded array is refused");
assert_eq!(
fs::read_to_string(&path).unwrap(),
malformed,
"the file is untouched on refuse"
);
}
#[test]
fn run_needs_appends_a_validated_prereq() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
new_item(root, ItemKind::Issue, "Auth"); new_item(root, ItemKind::Issue, "Login");
run_needs(
Some(root.to_path_buf()),
"ISS-001",
&["ISS-002".to_string()],
)
.unwrap();
let item = read_item(root, ItemKind::Issue, 1).unwrap();
assert_eq!(item.relationships.needs, vec!["ISS-002"]);
}
#[test]
fn run_needs_rejects_a_missing_prereq_ref() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
new_item(root, ItemKind::Issue, "Auth"); let path = issue_dir(root, "001").join("backlog-001.toml");
let before = fs::read_to_string(&path).unwrap();
let err = run_needs(
Some(root.to_path_buf()),
"ISS-001",
&["ISS-099".to_string()],
);
assert!(
err.is_err(),
"a missing prereq ref is rejected at author time"
);
assert_eq!(
before,
fs::read_to_string(&path).unwrap(),
"nothing written"
);
}
#[test]
fn run_needs_refuses_a_closing_cycle_naming_members_nothing_written() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_rel_item(root, ItemKind::Issue, 1, "open", &["ISS-002"], &[]); write_rel_item(root, ItemKind::Issue, 2, "open", &[], &[]); let path_b = issue_dir(root, "002").join("backlog-002.toml");
let before_b = fs::read_to_string(&path_b).unwrap();
let err = run_needs(
Some(root.to_path_buf()),
"ISS-002",
&["ISS-001".to_string()],
)
.unwrap_err();
let msg = err.to_string();
assert!(msg.contains("cycle"), "the refuse names the failure: {msg}");
assert!(
msg.contains("ISS-001") && msg.contains("ISS-002"),
"names members: {msg}"
);
assert_eq!(
before_b,
fs::read_to_string(&path_b).unwrap(),
"nothing written on refuse"
);
}
#[test]
fn run_after_appends_one_edge_with_default_rank_zero() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
new_item(root, ItemKind::Issue, "Auth"); new_item(root, ItemKind::Issue, "Login");
run_after(Some(root.to_path_buf()), "ISS-001", "ISS-002", 0).unwrap();
let item = read_item(root, ItemKind::Issue, 1).unwrap();
assert_eq!(
item.relationships.after,
vec![AfterEdge {
to: "ISS-002".to_string(),
rank: 0,
}]
);
}
#[test]
fn run_after_never_rejects_a_soft_cycle() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_rel_item(
root,
ItemKind::Issue,
1,
"open",
&[],
&[AfterLit {
to: "ISS-002",
rank: 1,
}],
);
write_rel_item(root, ItemKind::Issue, 2, "open", &[], &[]);
run_after(Some(root.to_path_buf()), "ISS-002", "ISS-001", 5).unwrap();
let item = read_item(root, ItemKind::Issue, 2).unwrap();
assert_eq!(
item.relationships.after,
vec![AfterEdge {
to: "ISS-001".to_string(),
rank: 5,
}]
);
}
fn order_ids(out: &str) -> Vec<String> {
let table = out.split("\noverrides:").next().unwrap_or(out);
ids(table)
}
#[test]
fn order_rows_composes_a_hard_needs_order() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_rel_item(root, ItemKind::Issue, 1, "open", &["ISS-002"], &[]);
write_rel_item(root, ItemKind::Issue, 2, "open", &[], &[]);
let out = order_rows(root).unwrap();
assert_eq!(
order_ids(&out),
vec!["ISS-002", "ISS-001"],
"B precedes A: {out}"
);
assert!(!out.contains("overrides:"), "no drops, no block: {out}");
}
#[test]
fn order_rows_hard_errors_on_a_needs_cycle_with_no_table() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_rel_item(root, ItemKind::Issue, 1, "open", &["ISS-002"], &[]);
write_rel_item(root, ItemKind::Issue, 2, "open", &["ISS-001"], &[]);
let err = order_rows(root).unwrap_err();
let msg = err.to_string();
assert!(msg.contains("cycle"), "names the failure: {msg}");
assert!(
msg.contains("ISS-001") && msg.contains("ISS-002"),
"names members: {msg}"
);
}
#[test]
fn order_rows_evicts_the_lower_rank_edge_of_a_soft_cycle() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_rel_item(
root,
ItemKind::Issue,
1,
"open",
&[],
&[AfterLit {
to: "ISS-002",
rank: 1,
}],
);
write_rel_item(
root,
ItemKind::Issue,
2,
"open",
&[],
&[AfterLit {
to: "ISS-001",
rank: 5,
}],
);
let out = order_rows(root).unwrap();
let mut shown = order_ids(&out);
shown.sort();
assert_eq!(shown, vec!["ISS-001", "ISS-002"]);
assert!(
out.contains("overrides:"),
"the eviction is recorded: {out}"
);
assert!(out.contains("soft cycle"), "named a soft-cycle drop: {out}");
}
#[test]
fn order_rows_records_terminal_and_absent_drops_with_status_and_resolution() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_rel_item(
root,
ItemKind::Issue,
1,
"open",
&["CHR-001", "ISS-099"],
&[],
);
write_item(
root,
ItemKind::Chore,
1,
"closed",
"wont-do",
"drop-me",
"Dropped chore",
&[],
);
let out = order_rows(root).unwrap();
assert_eq!(
order_ids(&out),
vec!["ISS-001"],
"the live node survives: {out}"
);
assert!(out.contains("overrides:"));
assert!(
out.contains("CHR-001") && out.contains("closed/wont-do"),
"terminal dep named status/resolution: {out}"
);
assert!(
out.contains("ISS-099") && out.contains("absent"),
"absent ref named: {out}"
);
}
}