use std::collections::BTreeMap;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use anyhow::Context;
use serde::{Deserialize, Serialize};
use crate::dtoml;
use crate::backlog_order::{BacklogOrder, ItemId, OrderInput, Override, OverrideReason};
use crate::tag::{self, normalize_tag};
use crate::dep_seq::{self, AfterEdge, RelEdit};
use crate::entity::{self, Artifact, Fileset, Inputs, Kind, MaterialiseRequest, ScaffoldCtx};
use crate::listing::{self, Format, ListArgs};
use crate::tomlfmt::toml_string;
use crate::risk;
use crate::risk::{RiskFacet, RiskLevel, exposure, validate_facet};
use clap::Subcommand;
#[derive(Subcommand)]
pub(crate) enum BacklogCommand {
New {
kind: ItemKind,
title: Option<String>,
#[arg(long)]
slug: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
List {
#[arg(long)]
kind: Option<ItemKind>,
#[arg(long = "by", value_enum, default_value_t = OrderBy::Sequence)]
by: OrderBy,
#[command(flatten)]
list: crate::CommonListArgs,
substr: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Show {
#[command(flatten)]
common: crate::CommonShowArgs,
},
Inspect {
#[command(flatten)]
common: crate::CommonShowArgs,
},
Edit {
id: String,
#[arg(long)]
status: Status,
#[arg(long)]
resolution: Option<Resolution>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Needs {
#[arg(value_name = "DEPENDENT")]
id: String,
#[arg(required = true, value_name = "PREREQUISITE")]
prereqs: Vec<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
After {
#[arg(value_name = "DEPENDENT")]
id: String,
#[arg(required_unless_present = "prune", value_name = "PREDECESSOR")]
to: Option<String>,
#[arg(long, default_value_t = 0)]
rank: i32,
#[arg(long, conflicts_with = "prune")]
remove: bool,
#[arg(long, conflicts_with = "remove")]
prune: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Tag {
id: String,
tags: Vec<String>,
#[arg(long = "remove", short = 'd')]
remove: Vec<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Paths {
refs: Vec<String>,
#[arg(short = 't', long)]
toml: bool,
#[arg(short = 'm', long)]
md: bool,
#[arg(short = 'e', long)]
entity: bool,
#[arg(short = 's', long)]
single: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
}
pub(crate) fn dispatch(cmd: BacklogCommand, color: bool) -> anyhow::Result<()> {
match cmd {
BacklogCommand::New {
kind,
title,
slug,
path,
} => run_new(path, kind, title, slug),
BacklogCommand::List {
kind,
by,
mut list,
substr,
path,
} => {
if list.filter.is_none() {
list.filter = substr;
}
run_list(path, kind, by, list.into_list_args(color))
}
BacklogCommand::Show { common } => {
let format = if common.json {
Format::Json
} else {
common.format
};
run_show(common.path, &common.id, format)
}
BacklogCommand::Inspect { common } => {
let format = if common.json {
Format::Json
} else {
common.format
};
run_inspect(common.path, &common.id, format)
}
BacklogCommand::Edit {
id,
status,
resolution,
path,
} => run_edit(path, &id, status, resolution),
BacklogCommand::Needs { id, prereqs, path } => run_needs(path, &id, &prereqs),
BacklogCommand::After {
id,
to,
rank,
remove,
prune,
path,
} => run_after(path, &id, to.as_deref(), rank, remove, prune),
BacklogCommand::Tag {
id,
tags,
remove,
path,
} => run_tag(path, &id, &tags, &remove),
BacklogCommand::Paths {
refs,
toml,
md,
entity,
single,
path,
} => run_paths(
path,
&refs,
&crate::paths::PathSelection {
toml,
md,
entity,
single,
},
),
}
}
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: crate::kinds::ISS,
stem: "backlog",
scaffold: |c| backlog_scaffold(ItemKind::Issue, c),
};
pub(crate) const IMPROVEMENT_KIND: Kind = Kind {
dir: ".doctrine/backlog/improvement",
prefix: crate::kinds::IMP,
stem: "backlog",
scaffold: |c| backlog_scaffold(ItemKind::Improvement, c),
};
pub(crate) const CHORE_KIND: Kind = Kind {
dir: ".doctrine/backlog/chore",
prefix: crate::kinds::CHR,
stem: "backlog",
scaffold: |c| backlog_scaffold(ItemKind::Chore, c),
};
pub(crate) const RISK_KIND: Kind = Kind {
dir: ".doctrine/backlog/risk",
prefix: crate::kinds::RSK,
stem: "backlog",
scaffold: |c| backlog_scaffold(ItemKind::Risk, c),
};
pub(crate) const IDEA_KIND: Kind = Kind {
dir: ".doctrine/backlog/idea",
prefix: crate::kinds::IDE,
stem: "backlog",
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 {
pub(crate) 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
}
pub(crate) 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 {
listing::canonical_id(self.prefix(), id)
}
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)
}
pub(crate) 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 {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Status::Open => "open",
Status::Triaged => "triaged",
Status::Started => "started",
Status::Resolved => "resolved",
Status::Closed => "closed",
}
}
pub(crate) const fn is_terminal(self) -> bool {
matches!(self, Status::Resolved | Status::Closed)
}
}
pub(crate) 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, 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<risk::RawRiskFacet>,
#[serde(default)]
relationships: Relationships,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct BacklogItem {
pub(crate) id: u32,
slug: String,
pub(crate) title: String,
pub(crate) kind: ItemKind,
pub(crate) status: Status,
resolution: Option<Resolution>,
created: String,
updated: String,
tags: Vec<String>,
facet: Option<RiskFacet>,
relationships: Relationships,
tier1: Vec<crate::relation::RelationEdge>,
pub(crate) body: String,
}
#[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)]
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 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,
tier1: Vec::new(),
body: String::new(),
})
}
#[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 trunk_ids = crate::git::trunk_entity_ids(&root, item_kind.kind().dir)?;
let (backend, mut reserved) = crate::reserve::backend(
&root,
item_kind.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(
item_kind.kind(),
&*backend,
&root,
&MaterialiseRequest::Fresh,
&Inputs {
slug: &slug,
title: &title,
date: &date,
},
&trunk_ids,
&mut reserved,
)?;
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 = dtoml::parse_entity_toml(&text, item_kind.prefix(), id)
.with_context(|| format!("Failed to parse {}", path.display()))?;
let mut item = validate(raw)?;
item.tier1 = crate::relation::tier1_edges(item_kind.kind(), &text)?;
let md_path = root
.join(item_kind.kind().dir)
.join(&name)
.join(format!("{BACKLOG_STEM}-{name}.md"));
item.body = std::fs::read_to_string(&md_path)
.with_context(|| format!("Failed to read {}", md_path.display()))?;
Ok(item)
}
pub(crate) fn kind_from_prefix(prefix: &str) -> Option<ItemKind> {
ItemKind::from_prefix(prefix)
}
pub(crate) fn relation_edges(
root: &Path,
item_kind: ItemKind,
id: u32,
) -> anyhow::Result<Vec<crate::relation::RelationEdge>> {
let item = read_item(root, item_kind, id)?;
Ok(item.tier1)
}
pub(crate) struct DepSeq {
pub(crate) needs: Vec<String>,
pub(crate) after: Vec<(String, i32)>,
pub(crate) promoted: bool,
}
pub(crate) fn dep_seq_for(root: &Path, item_kind: ItemKind, id: u32) -> anyhow::Result<DepSeq> {
let item = read_item(root, item_kind, id)?;
let after = item
.relationships
.after
.iter()
.map(|e| (e.to.clone(), e.rank))
.collect();
Ok(DepSeq {
needs: item.relationships.needs.clone(),
after,
promoted: item.resolution == Some(Resolution::Promoted),
})
}
pub(crate) 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,
tags: Vec<String>,
}
const BL_COLUMNS: [listing::Column<BacklogItem>; 6] = [
listing::Column {
name: "id",
header: "id",
cell: |i| i.kind.canonical_id(i.id),
paint: listing::ColumnPaint::Fixed(owo_colors::DynColors::Ansi(
owo_colors::AnsiColors::Cyan,
)),
},
listing::Column {
name: "kind",
header: "kind",
cell: |i| i.kind.as_str().to_string(),
paint: listing::ColumnPaint::ByValue(|i| listing::backlog_kind_hue(i.kind.as_str())),
},
listing::Column {
name: "status",
header: "status",
cell: |i| i.status.as_str().to_string(),
paint: listing::ColumnPaint::ByValue(|i| listing::status_hue(i.status.as_str())),
},
listing::Column {
name: "slug",
header: "slug",
cell: |i| i.slug.clone(),
paint: listing::ColumnPaint::None,
},
listing::Column {
name: "tags",
header: "tags",
cell: |i| i.tags.join(", "),
paint: listing::ColumnPaint::PerToken {
split: |i| i.tags.clone(),
render: listing::paint_tag,
},
},
listing::Column {
name: "title",
header: "title",
cell: |i| i.title.clone(),
paint: listing::ColumnPaint::Alternate([listing::TITLE_EVEN, listing::TITLE_ODD]),
},
];
const BL_DEFAULT: &[&str] = &["id", "kind", "status", "title"];
#[derive(Clone, Copy, Debug, Default, clap::ValueEnum)]
pub(crate) enum OrderBy {
#[default]
Sequence,
Id,
}
enum Ordering {
Composed {
pos: BTreeMap<ItemId, usize>,
footer: String,
},
Degraded { footer: String, warning: String },
}
fn compose(corpus: &[BacklogItem]) -> anyhow::Result<Ordering> {
let (inputs, absent) = project(corpus);
let order = BacklogOrder::build(&inputs)?;
let cmap: BTreeMap<ItemId, &BacklogItem> = corpus
.iter()
.map(|i| (ItemId::new(i.kind, i.id), i))
.collect();
let footer = render_overrides(&cmap, &absent, &order.overrides());
if let Some(cycle) = order.dep_cycles().first() {
return Ok(Ordering::Degraded {
footer,
warning: format!(
"backlog list: `needs` dependency cycle — {} — ordering by id (resolve, then re-run)",
name_cycle(cycle)
),
});
}
let pos = order
.ordered()
.iter()
.enumerate()
.map(|(i, id)| (*id, i))
.collect();
Ok(Ordering::Composed { pos, footer })
}
struct ListOutput {
stdout: String,
stderr: String,
}
fn list_rows(
root: &Path,
kind: Option<ItemKind>,
by: OrderBy,
mut args: ListArgs,
) -> anyhow::Result<ListOutput> {
validate_statuses(&args.status, BACKLOG_STATUSES)?;
let render = args.render;
let columns = args.columns.take();
let (filter, format) = listing::build(args)?;
let corpus = read_all(root)?;
let ordering = match by {
OrderBy::Sequence => Some(compose(&corpus)?),
OrderBy::Id => None,
};
let mut items = listing::retain(corpus, &filter, is_hidden, key);
items.retain(|i| kind.is_none_or(|k| i.kind == k));
let any_tagged = items.iter().any(|i| !i.tags.is_empty());
match &ordering {
Some(Ordering::Composed { pos, .. }) => items.sort_by_key(|i| {
(
pos.get(&ItemId::new(i.kind, i.id))
.copied()
.unwrap_or(usize::MAX),
i.kind.ordinal(),
i.id,
)
}),
_ => items.sort_by_key(|i| (i.kind.ordinal(), i.id)),
}
let (footer, warning) = match &ordering {
Some(Ordering::Composed { footer, .. }) => (footer.as_str(), ""),
Some(Ordering::Degraded { footer, warning }) => (footer.as_str(), warning.as_str()),
None => ("", ""),
};
match format {
Format::Table => {
let effective_default = listing::default_with_tags(BL_DEFAULT, any_tagged);
let sel = listing::select_columns(&BL_COLUMNS, &effective_default, columns.as_deref())?;
let table = listing::render_columns(&items, &sel, render);
Ok(ListOutput {
stdout: format!("{table}{footer}"),
stderr: warning.to_string(),
})
}
Format::Json => {
let envelope = listing::json_envelope("backlog", &json_rows(&items))?;
Ok(ListOutput {
stdout: envelope,
stderr: format!("{warning}{footer}"),
})
}
}
}
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(),
tags: i.tags.clone(),
})
.collect()
}
pub(crate) fn run_list(
path: Option<PathBuf>,
kind: Option<ItemKind>,
by: OrderBy,
args: ListArgs,
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let ListOutput { stdout, stderr } = list_rows(&root, kind, by, args)?;
write!(io::stdout(), "{stdout}")?;
if !stderr.is_empty() {
write!(io::stderr(), "{stderr}")?;
}
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_metadata(item: &BacklogItem) -> Vec<String> {
use crate::relation::{RelationLabel, Role, targets_for, targets_for_role};
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;
let slices = targets_for(&item.tier1, RelationLabel::Slices);
let drift = targets_for(&item.tier1, RelationLabel::Drift);
let ref_concerns = targets_for_role(&item.tier1, RelationLabel::References, Role::Concerns);
let ref_implements = targets_for_role(&item.tier1, RelationLabel::References, Role::Implements);
let ref_scoped_from =
targets_for_role(&item.tier1, RelationLabel::References, Role::ScopedFrom);
if !slices.is_empty()
|| !drift.is_empty()
|| !rel.needs.is_empty()
|| !rel.after.is_empty()
|| !rel.triggers.is_empty()
|| !ref_implements.is_empty()
|| !ref_scoped_from.is_empty()
|| !ref_concerns.is_empty()
{
parts.push("\nrelationships:\n".to_string());
for (label, refs) in [
("slices", &slices),
("drift", &drift),
("needs", &rel.needs),
("references(implements)", &ref_implements),
("references(scoped_from)", &ref_scoped_from),
("references(concerns)", &ref_concerns),
] {
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
}
fn format_show(item: &BacklogItem) -> String {
let mut parts = format_metadata(item);
parts.push(format!("\n{}", item.body));
parts.concat()
}
fn format_inspect(item: &BacklogItem) -> String {
format_metadata(item).concat()
}
fn run_show_inspect(
path: Option<PathBuf>,
reference: &str,
format: Format,
format_table: fn(&BacklogItem) -> String,
with_body: bool,
) -> 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_table(&item),
Format::Json => show_json(&item, with_body)?,
};
write!(io::stdout(), "{out}")?;
Ok(())
}
pub(crate) fn run_show(
path: Option<PathBuf>,
reference: &str,
format: Format,
) -> anyhow::Result<()> {
run_show_inspect(path, reference, format, format_show, true)
}
fn show_json(item: &BacklogItem, with_body: bool) -> anyhow::Result<String> {
use crate::relation::{RelationLabel, Role, targets_for, targets_for_role};
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 mut inner = serde_json::Map::new();
inner.insert(
"id".into(),
serde_json::json!(item.kind.canonical_id(item.id)),
);
inner.insert("kind".into(), serde_json::json!(item.kind.as_str()));
inner.insert("slug".into(), serde_json::json!(item.slug));
inner.insert("title".into(), serde_json::json!(item.title));
inner.insert("status".into(), serde_json::json!(item.status.as_str()));
inner.insert(
"resolution".into(),
serde_json::json!(item.resolution.map(Resolution::as_str)),
);
inner.insert("created".into(), serde_json::json!(item.created));
inner.insert("updated".into(), serde_json::json!(item.updated));
inner.insert("tags".into(), serde_json::json!(item.tags));
if with_body {
inner.insert("body".into(), serde_json::json!(item.body));
}
inner.insert("facet".into(), serde_json::json!(facet));
inner.insert("relationships".into(), serde_json::json!({
"slices": targets_for(&item.tier1, RelationLabel::Slices),
"drift": targets_for(&item.tier1, RelationLabel::Drift),
"needs": rel.needs,
"after": rel.after,
"triggers": rel.triggers,
"references": {
"implements": targets_for_role(&item.tier1, RelationLabel::References, Role::Implements),
"scoped_from": targets_for_role(&item.tier1, RelationLabel::References, Role::ScopedFrom),
"concerns": targets_for_role(&item.tier1, RelationLabel::References, Role::Concerns),
},
}));
let value = serde_json::json!({
"kind": "backlog",
"backlog": inner,
});
serde_json::to_string_pretty(&value).context("failed to serialize backlog show JSON")
}
pub(crate) fn run_inspect(
path: Option<PathBuf>,
reference: &str,
format: Format,
) -> anyhow::Result<()> {
run_show_inspect(path, reference, format, format_inspect, false)
}
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 hint = format!(
"malformed backlog item {name}: missing seeded `status`/`resolution`/`updated` — restore the missing keys and retry; the file is left untouched"
);
dep_seq::set_authored_status(
&path,
&[
("status", status.as_str()),
("resolution", resolution),
("updated", today),
],
&hint,
)?;
Ok(resolution)
}
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"));
dep_seq::append(&path, edit)
}
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 {
crate::integrity::ensure_ref_resolves(&root, prereq)
.with_context(|| format!("prerequisite `{prereq}` does not resolve"))?;
}
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: Option<&str>,
rank: i32,
remove: bool,
prune: bool,
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let target = require_item(&root, reference)?;
if prune {
let name = format!("{:03}", target.1);
let item_path = root
.join(target.0.kind().dir)
.join(&name)
.join(format!("{BACKLOG_STEM}-{name}.toml"));
let ds = dep_seq::read(&item_path)?;
let mut dropped: Vec<(String, i32, String)> = Vec::new();
let mut to_drop: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for edge in &ds.after {
let is_dangling = match crate::integrity::parse_canonical_ref(&edge.to) {
Ok((kref, tid)) => {
let target_path =
crate::entity::id_path(&root, kref.kind, tid, crate::entity::Ext::Toml);
if target_path.exists() {
let body = std::fs::read_to_string(&target_path).unwrap_or_default();
let val: toml::Value = match toml::from_str(&body) {
Ok(v) => v,
Err(_) => toml::Value::Table(toml::Table::new()),
};
let status = val.get("status").and_then(|s| s.as_str()).unwrap_or("");
status == "resolved" || status == "closed"
} else {
true
}
}
Err(_) => true,
};
if is_dangling {
let reason = match crate::integrity::parse_canonical_ref(&edge.to) {
Ok((kref2, tid2)) => {
let target_path = crate::entity::id_path(
&root,
kref2.kind,
tid2,
crate::entity::Ext::Toml,
);
if target_path.exists() {
let body = std::fs::read_to_string(&target_path).unwrap_or_default();
let val: toml::Value = match toml::from_str(&body) {
Ok(v) => v,
Err(_) => toml::Value::Table(toml::Table::new()),
};
let status = val.get("status").and_then(|s| s.as_str()).unwrap_or("");
let resolution =
val.get("resolution").and_then(|s| s.as_str()).unwrap_or("");
if resolution.is_empty() {
status.to_string()
} else {
format!("{status}/{resolution}")
}
} else {
"absent".to_string()
}
}
Err(_) => "(unparseable)".to_string(),
};
dropped.push((edge.to.clone(), edge.rank, reason));
to_drop.insert(edge.to.clone());
}
}
if dropped.is_empty() {
writeln!(
io::stdout(),
"{}: nothing to prune",
target.0.canonical_id(target.1)
)?;
return Ok(());
}
for target_id in &to_drop {
let _ = dep_seq::remove(&item_path, target_id, None)?;
}
for (target_id, r, reason) in &dropped {
writeln!(
io::stdout(),
"{} after {target_id} (rank {r}) dropped (dangling: {reason})",
target.0.canonical_id(target.1),
)?;
}
return Ok(());
}
if remove {
let to = to.ok_or_else(|| anyhow::anyhow!("--remove requires a target"))?;
require_item(&root, to)?;
let name = format!("{:03}", target.1);
let item_path = root
.join(target.0.kind().dir)
.join(&name)
.join(format!("{BACKLOG_STEM}-{name}.toml"));
let ceiling = if rank == 0 { None } else { Some(rank) };
let removed = dep_seq::remove(&item_path, to, ceiling)?;
if removed == 0 {
anyhow::bail!(
"{} has no after edge to {to}",
target.0.canonical_id(target.1)
);
}
writeln!(
io::stdout(),
"{} after {to} removed ({} edge{})",
target.0.canonical_id(target.1),
removed,
if removed == 1 { "" } else { "s" }
)?;
return Ok(());
}
let to = to.ok_or_else(|| anyhow::anyhow!("after requires a target"))?;
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(())
}
pub(crate) fn run_tag(
path: Option<PathBuf>,
reference: &str,
adds: &[String],
removes: &[String],
) -> anyhow::Result<()> {
if adds.is_empty() && removes.is_empty() {
anyhow::bail!("`backlog tag` needs at least one tag to add or remove (--remove/-d)");
}
let add_set: std::collections::BTreeSet<String> = adds
.iter()
.map(|t| normalize_tag(t))
.collect::<anyhow::Result<_>>()?;
let remove_set: std::collections::BTreeSet<String> = removes
.iter()
.map(|t| normalize_tag(t))
.collect::<anyhow::Result<_>>()?;
let overlap: Vec<&String> = add_set.intersection(&remove_set).collect();
if let Some(first) = overlap.first() {
anyhow::bail!("tag `{first}` is in both add and remove (pick one)");
}
let root = crate::root::find(path, &crate::root::default_markers())?;
let (item_kind, id) = require_item(&root, reference)?;
let name = format!("{id:03}");
let item_path = root
.join(item_kind.kind().dir)
.join(&name)
.join(format!("{BACKLOG_STEM}-{name}.toml"));
let text = std::fs::read_to_string(&item_path)
.with_context(|| format!("backlog item not found at {}", item_path.display()))?;
let mut doc = text
.parse::<toml_edit::DocumentMut>()
.with_context(|| format!("Failed to parse {}", item_path.display()))?;
let changed = tag::apply_tags_set(&mut doc, &add_set, &remove_set, &crate::clock::today())?;
if changed {
crate::fsutil::write_atomic(&item_path, doc.to_string().as_bytes())
.with_context(|| format!("Failed to write {}", item_path.display()))?;
}
let final_tags: Vec<String> = doc
.as_table()
.get("tags")
.and_then(toml_edit::Item::as_array)
.map(|a| {
a.iter()
.filter_map(|v| v.as_str().map(str::to_string))
.collect()
})
.unwrap_or_default();
let listed = if final_tags.is_empty() {
"(none)".to_string()
} else {
final_tags.join(", ")
};
writeln!(
io::stdout(),
"Tagged {}: {listed}",
item_kind.canonical_id(id),
)?;
Ok(())
}
fn run_paths(
path: Option<PathBuf>,
refs: &[String],
sel: &crate::paths::PathSelection,
) -> anyhow::Result<()> {
use std::io::Write;
let root = crate::root::find(path, &crate::root::default_markers())?;
let mut all_lines: Vec<String> = Vec::new();
for r in refs {
let (item_kind, id) = parse_ref(r)?;
let name = format!("{id:03}");
let entity_dir = root.join(item_kind.kind().dir).join(&name);
let toml_name = format!("{BACKLOG_STEM}-{name}.toml");
let md_name = format!("{BACKLOG_STEM}-{name}.md");
let set = crate::paths::scan_entity_dir(
&entity_dir,
&entity_dir.join(&toml_name),
Some(&entity_dir.join(&md_name)),
&root,
)?;
let lines = crate::paths::select_paths(&set, sel)?;
all_lines.extend(lines);
}
write!(io::stdout(), "{}", all_lines.join("\n"))?;
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 {
if ov.reason() == OverrideReason::Dangling
&& corpus
.get(&ov.from())
.is_some_and(|item| item.status.is_terminal())
{
continue;
}
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()
}
use crate::finding::{Category, Finding};
use crate::lifecycle::is_transition_terminal;
use crate::relation::{RelationLabel, targets_for};
pub(crate) fn lifecycle_findings(root: &Path) -> Vec<Finding> {
let Ok(items) = read_all(root) else {
return Vec::new();
};
let slice_metas: std::collections::BTreeMap<u32, String> =
crate::meta::read_metas(&root.join(".doctrine/slice"), "slice", "SL")
.unwrap_or_default()
.into_iter()
.map(|m| (m.id, m.status))
.collect();
let mut findings = Vec::new();
for item in &items {
if item.status.is_terminal() {
continue;
}
let slice_refs = targets_for(&item.tier1, RelationLabel::Slices);
if slice_refs.is_empty() {
continue;
}
let all_terminal = slice_refs.iter().all(|ref_str| {
if let Some(id_str) = ref_str.strip_prefix("SL-")
&& let Ok(id) = id_str.parse::<u32>()
{
return slice_metas
.get(&id)
.is_some_and(|s| is_transition_terminal(s));
}
false
});
if all_terminal {
findings.push(Finding {
category: Category::Lifecycle,
entity: Some(item.kind.canonical_id(item.id)),
message: format!("all linked slices terminal: {}", slice_refs.join(", ")),
});
}
}
findings
}
#[cfg(test)]
pub(crate) mod test_support {
use super::ItemKind;
use std::fs;
use std::path::Path;
pub(crate) struct Fixture<'a> {
pub(crate) kind: ItemKind,
pub(crate) id: u32,
pub(crate) slug: &'a str,
pub(crate) title: &'a str,
pub(crate) status: &'a str,
pub(crate) resolution: &'a str,
pub(crate) tags: &'a [&'a str],
pub(crate) facet: Option<FacetLit<'a>>,
pub(crate) rels: Option<RelLit<'a>>,
}
pub(crate) struct FacetLit<'a> {
pub(crate) likelihood: &'a str,
pub(crate) impact: &'a str,
pub(crate) origin: &'a str,
pub(crate) controls: &'a [&'a str],
}
pub(crate) struct RelLit<'a> {
pub(crate) slices: &'a [&'a str],
pub(crate) specs: &'a [&'a str],
pub(crate) needs: &'a [&'a str],
pub(crate) after: &'a [AfterLit<'a>],
pub(crate) triggers: &'a [TriggerLit<'a>],
}
pub(crate) struct AfterLit<'a> {
pub(crate) to: &'a str,
pub(crate) rank: i32,
}
pub(crate) struct TriggerLit<'a> {
pub(crate) globs: &'a [&'a str],
pub(crate) note: &'a str,
}
pub(crate) fn toml_list(xs: &[&str]) -> String {
xs.iter()
.map(|x| format!("\"{x}\""))
.collect::<Vec<_>>()
.join(", ")
}
pub(crate) fn toml_after(xs: &[AfterLit<'_>]) -> String {
xs.iter()
.map(|e| format!("{{ to = \"{}\", rank = {} }}", e.to, e.rank))
.collect::<Vec<_>>()
.join(", ")
}
pub(crate) fn toml_triggers(xs: &[TriggerLit<'_>]) -> String {
xs.iter()
.map(|t| {
format!(
"{{ globs = [{}], note = \"{}\" }}",
toml_list(t.globs),
t.note
)
})
.collect::<Vec<_>>()
.join(", ")
}
pub(crate) 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]\nneeds = [{}]\nafter = [{}]\ntriggers = [{}]\n",
toml_list(x.needs),
toml_after(x.after),
toml_triggers(x.triggers),
)
});
let mut relation_rows = String::new();
if let Some(x) = f.rels.as_ref() {
for s in x.slices {
relation_rows.push_str(&format!(
"\n[[relation]]\nlabel = \"slices\"\ntarget = \"{s}\"\n"
));
}
for s in x.specs {
relation_rows.push_str(&format!(
"\n[[relation]]\nlabel = \"references\"\nrole = \"concerns\"\ntarget = \"{s}\"\n"
));
}
}
format!("{head}{facet}{rels}{relation_rows}")
}
pub(crate) 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();
fs::write(
dir.join(format!("backlog-{name}.md")),
format!("# {}: {}\n", f.kind.canonical_id(f.id), f.title),
)
.unwrap();
}
}
#[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",
},
&[],
&mut entity::local_reserved(),
)
.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(),
tags: vec![],
}
);
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"]);
}
#[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_render_mirror_serde() {
assert_eq!(Resolution::WontDo.as_str(), "wont-do");
assert_eq!(Resolution::Promoted.as_str(), "promoted");
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)
);
}
use super::test_support::*;
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()
}
fn list_id(root: &Path, kind: Option<ItemKind>, args: ListArgs) -> anyhow::Result<String> {
list_rows(root, kind, OrderBy::Id, args).map(|o| o.stdout)
}
#[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_id(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_id(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_id(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_id(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_id(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_untagged_corpus_hides_tags_column() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_item(root, ItemKind::Issue, 1, "open", "", "a", "Alpha", &[]);
let out = list_id(root, None, list_args()).unwrap();
let header = out.lines().next().unwrap();
assert!(
!header.contains("tags"),
"untagged corpus omits the tags column: {header:?}"
);
}
#[test]
fn backlog_list_tagged_corpus_shows_tags_before_title() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_item(root, ItemKind::Issue, 1, "open", "", "a", "Alpha", &["cli"]);
let out = list_id(root, None, list_args()).unwrap();
let header = out.lines().next().unwrap();
assert_eq!(
header.split_whitespace().collect::<Vec<_>>(),
vec!["id", "│", "kind", "│", "status", "│", "tags", "│", "title"],
"tags spliced before title: {header:?}"
);
assert!(out.contains("cli"), "the tag value renders: {out}");
}
#[test]
fn backlog_list_columns_forces_tags_even_when_all_empty() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_item(root, ItemKind::Issue, 1, "open", "", "a", "Alpha", &[]);
let out = list_id(root, None, columns_args(&["id", "tags"])).unwrap();
let header = out.lines().next().unwrap();
assert_eq!(
header.split_whitespace().collect::<Vec<_>>(),
vec!["id", "│", "tags"],
"explicit --columns shows tags despite all-empty: {header:?}"
);
}
#[test]
fn backlog_list_columns_omitting_tags_hides_it_despite_tagged_rows() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_item(root, ItemKind::Issue, 1, "open", "", "a", "Alpha", &["cli"]);
let out = list_id(root, None, columns_args(&["id", "title"])).unwrap();
let header = out.lines().next().unwrap();
assert!(
!header.contains("tags"),
"explicit columns omitting tags hides it: {header:?}"
);
}
#[test]
fn backlog_list_tagged_row_filtered_by_kind_hides_tags_column() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_item(root, ItemKind::Issue, 1, "open", "", "a", "Alpha", &["cli"]);
write_item(
root,
ItemKind::Improvement,
1,
"open",
"",
"b",
"Bravo",
&[],
);
let out = list_id(root, Some(ItemKind::Improvement), list_args()).unwrap();
let header = out.lines().next().unwrap();
assert!(
!header.contains("tags"),
"no visible tagged row after --kind → no tags column: {header:?}"
);
}
#[test]
fn backlog_list_tags_column_colour_strips_to_plain() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_item(
root,
ItemKind::Issue,
1,
"open",
"",
"a",
"Alpha",
&["cli:command", "security"],
);
let plain = list_id(root, None, list_args()).unwrap();
let coloured = list_id(
root,
None,
ListArgs {
render: listing::RenderOpts {
color: true,
term_width: None,
},
..Default::default()
},
)
.unwrap();
assert!(
coloured.contains('\u{1b}'),
"the tagged cell carries ANSI under colour"
);
assert_eq!(
crate::listing::strip_ansi(&coloured),
plain,
"stripping the coloured backlog render reproduces the plain layout"
);
}
#[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_id(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_id(
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_id(
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_id(
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_id(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_id(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_id(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_id(
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_id(
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_id(
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, true).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_json_groups_references_by_role_and_keeps_legacy_keys() {
use crate::relation::{RelationEdge, RelationLabel, Role};
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
new_item(root, ItemKind::Improvement, "Token expiry");
let mut item = read_item(root, ItemKind::Improvement, 1).unwrap();
item.tier1 = vec![
RelationEdge::new(RelationLabel::Slices, "SL-020".into()),
RelationEdge::with_role(
RelationLabel::References,
Some(Role::Concerns),
"SPEC-018".into(),
),
];
let json = show_json(&item, true).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
let rel = &v["backlog"]["relationships"];
assert_eq!(
rel["references"]["concerns"],
serde_json::json!(["SPEC-018"])
);
assert_eq!(rel["references"]["implements"], serde_json::json!([]));
assert_eq!(rel["references"]["scoped_from"], serde_json::json!([]));
assert_eq!(rel["slices"], serde_json::json!(["SL-020"]));
}
#[test]
fn backlog_show_json_references_object_carries_concerns() {
use crate::relation::{RelationEdge, RelationLabel, Role};
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
new_item(root, ItemKind::Improvement, "Concerns only");
let mut item = read_item(root, ItemKind::Improvement, 1).unwrap();
item.tier1 = vec![
RelationEdge::new(RelationLabel::Slices, "SL-007".into()),
RelationEdge::with_role(
RelationLabel::References,
Some(Role::Concerns),
"SPEC-018".into(),
),
];
let json = show_json(&item, true).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
let rel = &v["backlog"]["relationships"];
assert_eq!(rel["slices"], serde_json::json!(["SL-007"]));
assert!(rel.get("specs").is_none(), "legacy specs key removed");
assert_eq!(rel["references"]["implements"], serde_json::json!([]));
assert_eq!(rel["references"]["scoped_from"], serde_json::json!([]));
assert_eq!(
rel["references"]["concerns"],
serde_json::json!(["SPEC-018"])
);
}
#[test]
fn backlog_inspect_json_omits_body() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_fixture(
root,
Fixture {
kind: ItemKind::Improvement,
id: 1,
slug: "token",
title: "Token expiry",
status: "open",
resolution: "",
tags: &[],
facet: None,
rels: None,
},
);
let item = read_item(root, ItemKind::Improvement, 1).unwrap();
let json = show_json(&item, false).unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert!(
v["backlog"].get("body").is_none(),
"inspect JSON must not include body"
);
}
#[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("references(concerns): PRD-009"),
"outbound spec ref shown as references(concerns)"
);
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, true).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(), "");
}
#[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_accepts_cross_kind_slice_prereq() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
new_item(root, ItemKind::Issue, "Auth"); let sl_dir = root.join(".doctrine/slice/001");
fs::create_dir_all(&sl_dir).unwrap();
run_needs(Some(root.to_path_buf()), "ISS-001", &["SL-001".to_string()]).unwrap();
let item = read_item(root, ItemKind::Issue, 1).unwrap();
assert_eq!(item.relationships.needs, vec!["SL-001"]);
}
#[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",
Some("ISS-002"),
0,
false,
false,
)
.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",
Some("ISS-001"),
5,
false,
false,
)
.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 item_path(root: &Path, kind: ItemKind, id: u32) -> PathBuf {
let name = format!("{id:03}");
root.join(kind.kind().dir)
.join(&name)
.join(format!("backlog-{name}.toml"))
}
fn s(xs: &[&str]) -> Vec<String> {
xs.iter().map(|x| (*x).to_string()).collect()
}
#[test]
fn run_tag_round_trips_add_filter_remove() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
new_item(root, ItemKind::Issue, "Auth");
run_tag(Some(root.to_path_buf()), "ISS-001", &s(&["a", "b"]), &[]).unwrap();
assert_eq!(
read_item(root, ItemKind::Issue, 1).unwrap().tags,
s(&["a", "b"])
);
let json = list_id(
root,
None,
ListArgs {
json: true,
tags: s(&["a"]),
..ListArgs::default()
},
)
.unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
assert_eq!(
v["rows"].as_array().unwrap().len(),
1,
"tag filter matches: {json}"
);
run_tag(Some(root.to_path_buf()), "ISS-001", &[], &s(&["a"])).unwrap();
assert_eq!(read_item(root, ItemKind::Issue, 1).unwrap().tags, s(&["b"]));
}
#[test]
fn run_tag_normalises_and_rejects_bad_charset() {
assert_eq!(normalize_tag("Security").unwrap(), "security");
assert_eq!(normalize_tag(" Area:Backlog ").unwrap(), "area:backlog");
assert_eq!(normalize_tag("a_b-1:c").unwrap(), "a_b-1:c");
for bad in ["a b", "a@b"] {
let err = normalize_tag(bad).unwrap_err().to_string();
assert!(
err.contains(bad),
"the reject names the offending token: {err}"
);
}
assert!(
normalize_tag(" ").is_err(),
"empty-after-trim is rejected"
);
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
new_item(root, ItemKind::Issue, "Auth");
let path = item_path(root, ItemKind::Issue, 1);
let before = fs::read_to_string(&path).unwrap();
assert!(run_tag(Some(root.to_path_buf()), "ISS-001", &s(&["a@b"]), &[]).is_err());
assert_eq!(
before,
fs::read_to_string(&path).unwrap(),
"rejected before any write"
);
run_tag(Some(root.to_path_buf()), "ISS-001", &s(&["Security"]), &[]).unwrap();
assert_eq!(
read_item(root, ItemKind::Issue, 1).unwrap().tags,
s(&["security"])
);
}
#[test]
fn run_tag_idempotent_no_op_holds_mtime_on_unsorted_store() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(item_path(root, ItemKind::Issue, 1).parent().unwrap()).unwrap();
let toml = "id = 1\nslug = \"a\"\ntitle = \"A\"\nkind = \"issue\"\n\
status = \"open\"\nresolution = \"\"\ncreated = \"2026-06-08\"\n\
updated = \"2026-06-08\"\ntags = [\"b\", \"a\"]\n";
let path = item_path(root, ItemKind::Issue, 1);
fs::write(&path, toml).unwrap();
fs::write(
path.parent().unwrap().join(format!("backlog-{:03}.md", 1)),
"# ISS-001: A\n",
)
.unwrap();
let before = fs::read_to_string(&path).unwrap();
let mtime0 = fs::metadata(&path).unwrap().modified().unwrap();
run_tag(Some(root.to_path_buf()), "ISS-001", &s(&["a"]), &[]).unwrap();
assert_eq!(
before,
fs::read_to_string(&path).unwrap(),
"no-op: content held"
);
assert_eq!(
mtime0,
fs::metadata(&path).unwrap().modified().unwrap(),
"mtime held"
);
run_tag(Some(root.to_path_buf()), "ISS-001", &[], &s(&["zzz"])).unwrap();
assert_eq!(
before,
fs::read_to_string(&path).unwrap(),
"remove-absent no-op"
);
let err = run_tag(Some(root.to_path_buf()), "ISS-001", &s(&["X"]), &s(&["x"]));
assert!(err.is_err(), "an add∩remove overlap is rejected");
assert_eq!(
before,
fs::read_to_string(&path).unwrap(),
"nothing written on reject"
);
}
#[test]
fn run_tag_requires_at_least_one_edit() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
new_item(root, ItemKind::Issue, "Auth");
assert!(run_tag(Some(root.to_path_buf()), "ISS-001", &[], &[]).is_err());
}
#[test]
fn run_tag_json_projects_tags_unconditionally() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_item(root, ItemKind::Issue, 1, "open", "", "a", "Alpha", &[]); write_item(
root,
ItemKind::Issue,
2,
"open",
"",
"b",
"Bravo",
&["security"],
);
let json = list_id(
root,
None,
ListArgs {
json: true,
..ListArgs::default()
},
)
.unwrap();
let v: serde_json::Value = serde_json::from_str(&json).unwrap();
let rows = v["rows"].as_array().unwrap();
let by_id = |id: &str| {
rows.iter()
.find(|r| r["id"] == id)
.unwrap_or_else(|| panic!("row {id}"))
};
assert_eq!(by_id("ISS-001")["tags"], serde_json::json!([]));
assert_eq!(by_id("ISS-002")["tags"], serde_json::json!(["security"]));
}
#[test]
fn run_tag_is_edit_preserving_and_refuses_missing_tags() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
fs::create_dir_all(item_path(root, ItemKind::Issue, 1).parent().unwrap()).unwrap();
let path = item_path(root, ItemKind::Issue, 1);
let toml = "# keep me\nid = 1\nslug = \"a\"\ntitle = \"A\"\nkind = \"issue\"\n\
status = \"open\"\nresolution = \"\"\ncreated = \"2026-06-08\"\n\
updated = \"2026-06-08\"\ntags = []\nunknown = \"survives\"\n\
\n[relationships]\nneeds = []\n";
fs::write(&path, toml).unwrap();
fs::write(
path.parent().unwrap().join(format!("backlog-{:03}.md", 1)),
"# ISS-001: A\n",
)
.unwrap();
run_tag(Some(root.to_path_buf()), "ISS-001", &s(&["security"]), &[]).unwrap();
let after = fs::read_to_string(&path).unwrap();
assert!(after.contains("# keep me"), "comment survives: {after}");
assert!(
after.contains("unknown = \"survives\""),
"unknown key survives"
);
assert!(after.contains("[relationships]"), "inert table survives");
assert!(
after.contains("tags = [\"security\"]"),
"tag written: {after}"
);
assert!(
!after.contains("updated = \"2026-06-08\""),
"updated stamped"
);
fs::create_dir_all(item_path(root, ItemKind::Issue, 2).parent().unwrap()).unwrap();
let path2 = item_path(root, ItemKind::Issue, 2);
let no_tags = "id = 2\nslug = \"b\"\ntitle = \"B\"\nkind = \"issue\"\n\
status = \"open\"\nresolution = \"\"\ncreated = \"2026-06-08\"\n\
updated = \"2026-06-08\"\n";
fs::write(&path2, no_tags).unwrap();
fs::write(
path2.parent().unwrap().join(format!("backlog-{:03}.md", 2)),
"# ISS-002: B\n",
)
.unwrap();
run_tag(Some(root.to_path_buf()), "ISS-002", &s(&["x"]), &[]).unwrap();
let after2 = fs::read_to_string(&path2).unwrap();
assert!(
after2.contains("tags = [\"x\"]"),
"self-heal seeds tags and writes: {after2}"
);
}
#[test]
fn filter_fold_is_lenient_and_distinct_from_write_normalise() {
assert_eq!(tag::fold_filter_tag(" Security "), "security");
assert_eq!(tag::fold_filter_tag("a b"), "a b");
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
write_item(
root,
ItemKind::Issue,
1,
"open",
"",
"a",
"Alpha",
&["security"],
);
let hit = list_id(
root,
None,
ListArgs {
json: true,
tags: s(&["Security"]),
..ListArgs::default()
},
)
.unwrap();
let v: serde_json::Value = serde_json::from_str(&hit).unwrap();
assert_eq!(
v["rows"].as_array().unwrap().len(),
1,
"case-folded filter hits"
);
let miss = list_id(
root,
None,
ListArgs {
json: true,
tags: s(&["nomatch at all"]),
..ListArgs::default()
},
)
.unwrap();
let v: serde_json::Value = serde_json::from_str(&miss).unwrap();
assert_eq!(
v["rows"].as_array().unwrap().len(),
0,
"no-match filter is silent"
);
}
fn list_seq(root: &Path, args: ListArgs) -> (String, String) {
let out = list_rows(root, None, OrderBy::Sequence, args).unwrap();
(out.stdout, out.stderr)
}
fn seq_ids(out: &str) -> Vec<String> {
let table = out.split("\noverrides:").next().unwrap_or(out);
ids(table)
}
#[test]
fn list_sequence_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, err) = list_seq(root, list_args());
assert_eq!(
seq_ids(&out),
vec!["ISS-002", "ISS-001"],
"B precedes A: {out}"
);
assert!(!out.contains("overrides:"), "no drops, no footer: {out}");
assert!(err.is_empty(), "no advisory on a clean compose: {err:?}");
}
#[test]
fn list_sequence_and_id_share_membership_differ_on_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", &[], &[]);
write_rel_item(root, ItemKind::Issue, 3, "open", &[], &[]);
let (seq, _) = list_seq(root, list_args());
let by_id = list_id(root, None, list_args()).unwrap();
let seq_order = seq_ids(&seq);
let pos = |id: &str| seq_order.iter().position(|x| x == id).unwrap();
assert!(
pos("ISS-002") < pos("ISS-001"),
"needs flips 002 ahead of its dependent 001: {seq}"
);
assert_eq!(
ids(&by_id),
vec!["ISS-001", "ISS-002", "ISS-003"],
"--by id is plain ascending: {by_id}"
);
let mut a = seq_ids(&seq);
let mut b = ids(&by_id);
a.sort();
b.sort();
assert_eq!(a, b, "sequence and id list the same items, reordered");
}
#[test]
fn compose_degrades_on_a_needs_cycle() {
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 corpus = read_all(root).unwrap();
let Ordering::Degraded { warning, .. } = compose(&corpus).unwrap() else {
panic!("a needs cycle degrades to Ordering::Degraded");
};
assert!(warning.contains("cycle"), "names the failure: {warning}");
assert!(
warning.contains("ISS-001") && warning.contains("ISS-002"),
"names members: {warning}"
);
let (out, err) = list_seq(root, list_args());
assert_eq!(
ids(&out),
vec!["ISS-001", "ISS-002"],
"degrade falls back to the id sort, never empty: {out}"
);
assert!(err.contains("cycle"), "the advisory is on stderr: {err}");
}
#[test]
fn list_sequence_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, _) = list_seq(root, list_args());
let mut shown = seq_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 list_sequence_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, _) = list_seq(root, list_args());
assert_eq!(
seq_ids(&out),
vec!["ISS-001"],
"the live node survives: {out}"
);
assert!(out.contains("overrides:"));
assert!(
!out.contains("CHR-001"),
"terminal dep suppressed by default: {out}"
);
assert!(
out.contains("ISS-099") && out.contains("absent"),
"absent ref named: {out}"
);
}
fn backlog_fixture(root: &Path, item_kind: ItemKind, id: u32, extra: &[&str]) {
let name = format!("{id:03}");
let dir = root.join(item_kind.kind().dir).join(&name);
fs::create_dir_all(&dir).unwrap();
fs::write(dir.join(format!("{BACKLOG_STEM}-{name}.toml")), "toml").unwrap();
fs::write(dir.join(format!("{BACKLOG_STEM}-{name}.md")), "md").unwrap();
for e in extra {
fs::write(dir.join(e), e).unwrap();
}
}
#[test]
fn paths_full_shows_toml_md_and_extras_in_canonical_order() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
backlog_fixture(root, ItemKind::Issue, 1, &["notes.md", "z.log"]);
let sel = crate::paths::PathSelection {
toml: false,
md: false,
entity: false,
single: false,
};
let entity_dir = root.join(ItemKind::Issue.kind().dir).join("001");
let identity_toml = entity_dir.join("backlog-001.toml");
let identity_md = entity_dir.join("backlog-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();
let output = lines.join("\n");
assert!(output.contains(".doctrine/backlog/issue/001/backlog-001.toml"));
assert!(output.contains(".doctrine/backlog/issue/001/backlog-001.md"));
assert!(output.contains(".doctrine/backlog/issue/001/notes.md"));
assert!(output.contains(".doctrine/backlog/issue/001/z.log"));
}
#[test]
fn paths_single_truncates_to_first() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
backlog_fixture(root, ItemKind::Issue, 1, &["notes.md"]);
let sel = crate::paths::PathSelection {
toml: false,
md: false,
entity: false,
single: true,
};
let entity_dir = root.join(ItemKind::Issue.kind().dir).join("001");
let identity_toml = entity_dir.join("backlog-001.toml");
let identity_md = entity_dir.join("backlog-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.len(), 1);
assert_eq!(lines[0], ".doctrine/backlog/issue/001/backlog-001.toml");
}
#[test]
fn paths_toml_only() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
backlog_fixture(root, ItemKind::Chore, 2, &["notes.md"]);
let sel = crate::paths::PathSelection {
toml: true,
md: false,
entity: false,
single: false,
};
let entity_dir = root.join(ItemKind::Chore.kind().dir).join("002");
let identity_toml = entity_dir.join("backlog-002.toml");
let identity_md = entity_dir.join("backlog-002.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, vec![".doctrine/backlog/chore/002/backlog-002.toml"]);
}
#[test]
fn paths_md_only() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
backlog_fixture(root, ItemKind::Risk, 3, &[]);
let sel = crate::paths::PathSelection {
toml: false,
md: true,
entity: false,
single: false,
};
let entity_dir = root.join(ItemKind::Risk.kind().dir).join("003");
let identity_toml = entity_dir.join("backlog-003.toml");
let identity_md = entity_dir.join("backlog-003.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, vec![".doctrine/backlog/risk/003/backlog-003.md"]);
}
#[test]
fn paths_entity_gives_toml_and_md() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
backlog_fixture(root, ItemKind::Idea, 4, &["extra.txt"]);
let sel = crate::paths::PathSelection {
toml: false,
md: false,
entity: true,
single: false,
};
let entity_dir = root.join(ItemKind::Idea.kind().dir).join("004");
let identity_toml = entity_dir.join("backlog-004.toml");
let identity_md = entity_dir.join("backlog-004.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,
vec![
".doctrine/backlog/idea/004/backlog-004.toml",
".doctrine/backlog/idea/004/backlog-004.md"
]
);
}
#[test]
fn paths_invalid_ref_errors() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
backlog_fixture(root, ItemKind::Issue, 1, &[]);
let result = parse_ref("ISS-99999");
assert!(result.is_ok()); let entity_dir = root.join(ItemKind::Issue.kind().dir).join("99999");
let identity_toml = entity_dir.join("backlog-99999.toml");
let identity_md = entity_dir.join("backlog-99999.md");
let scan =
crate::paths::scan_entity_dir(&entity_dir, &identity_toml, Some(&identity_md), root);
assert!(scan.is_err());
}
#[test]
fn paths_multi_ref_splat_preserves_order() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
backlog_fixture(root, ItemKind::Issue, 1, &[]);
backlog_fixture(root, ItemKind::Improvement, 1, &[]);
let sel = crate::paths::PathSelection {
toml: false,
md: false,
entity: false,
single: false,
};
let mut all_lines: Vec<String> = Vec::new();
for (kind, n) in [(ItemKind::Issue, "001"), (ItemKind::Improvement, "001")] {
let entity_dir = root.join(kind.kind().dir).join(n);
let toml_name = format!("{BACKLOG_STEM}-{n}.toml");
let md_name = format!("{BACKLOG_STEM}-{n}.md");
let set = crate::paths::scan_entity_dir(
&entity_dir,
&entity_dir.join(&toml_name),
Some(&entity_dir.join(&md_name)),
root,
)
.unwrap();
all_lines.extend(crate::paths::select_paths(&set, &sel).unwrap());
}
assert_eq!(all_lines.len(), 4);
assert!(all_lines[0].contains("issue/001/backlog-001.toml"));
assert!(all_lines[2].contains("improvement/001/backlog-001.toml"));
}
fn seed_backlog_item(root: &Path, kind: ItemKind, id: u32, status: Status, slices: &[&str]) {
let name = format!("{id:03}");
let dir = root.join(kind.kind().dir).join(&name);
std::fs::create_dir_all(&dir).unwrap();
let mut toml = format!(
"id = {id}\nslug = \"it{id:03}\"\ntitle = \"Item {id}\"\n\
kind = \"{}\"\nstatus = \"{}\"\n\
resolution = \"\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\ntags = []\n",
kind.as_str(),
status.as_str(),
);
for s in slices {
toml.push_str(&format!(
"[[relation]]\nlabel = \"slices\"\ntarget = \"{s}\"\n"
));
}
std::fs::write(dir.join(format!("backlog-{name}.toml")), toml).unwrap();
std::fs::write(
dir.join(format!("backlog-{name}.md")),
format!("# {}\n", kind.canonical_id(id)),
)
.unwrap();
}
fn seed_slice_entity(root: &Path, id: u32, status: &str) {
let name = format!("{id:03}");
let dir = root.join(".doctrine/slice").join(&name);
std::fs::create_dir_all(&dir).unwrap();
std::fs::write(
dir.join(format!("slice-{name}.toml")),
format!(
"id = {id}\nslug = \"s{id:03}\"\ntitle = \"Slice {id}\"\n\
status = \"{status}\"\ncreated = \"2026-01-01\"\nupdated = \"2026-01-01\"\n"
),
)
.unwrap();
std::fs::write(dir.join(format!("slice-{name}.md")), "scope\n").unwrap();
}
#[test]
fn lifecycle_done_only_slices_flag() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
seed_backlog_item(root, ItemKind::Issue, 1, Status::Open, &["SL-001"]);
seed_slice_entity(root, 1, "done");
let findings = lifecycle_findings(root);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].category, Category::Lifecycle);
assert!(findings[0].entity.as_deref() == Some("ISS-001"));
assert!(findings[0].message.contains("SL-001"));
}
#[test]
fn lifecycle_abandoned_only_slices_flag() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
seed_backlog_item(root, ItemKind::Issue, 1, Status::Open, &["SL-001"]);
seed_slice_entity(root, 1, "abandoned");
let findings = lifecycle_findings(root);
assert_eq!(findings.len(), 1);
assert_eq!(findings[0].category, Category::Lifecycle);
}
#[test]
fn lifecycle_mixed_terminal_flags() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
seed_backlog_item(
root,
ItemKind::Issue,
1,
Status::Open,
&["SL-001", "SL-002"],
);
seed_slice_entity(root, 1, "done");
seed_slice_entity(root, 2, "abandoned");
let findings = lifecycle_findings(root);
assert_eq!(findings.len(), 1, "all terminal → flag");
}
#[test]
fn lifecycle_live_slice_no_flag() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
seed_backlog_item(
root,
ItemKind::Issue,
1,
Status::Open,
&["SL-001", "SL-002"],
);
seed_slice_entity(root, 1, "done");
seed_slice_entity(root, 2, "started"); let findings = lifecycle_findings(root);
assert!(findings.is_empty(), "live slice → no flag");
}
#[test]
fn lifecycle_sliceless_open_item_no_flag() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
seed_backlog_item(root, ItemKind::Issue, 1, Status::Open, &[]);
let findings = lifecycle_findings(root);
assert!(
findings.is_empty(),
"≥1 guard: slice-less item must not flag"
);
}
#[test]
fn lifecycle_closed_item_no_flag() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
seed_backlog_item(root, ItemKind::Issue, 1, Status::Closed, &["SL-001"]);
seed_slice_entity(root, 1, "done");
let findings = lifecycle_findings(root);
assert!(
findings.is_empty(),
"closed item must not flag (open predicate)"
);
}
#[test]
fn lifecycle_resolved_item_no_flag() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
seed_backlog_item(root, ItemKind::Issue, 1, Status::Resolved, &["SL-001"]);
seed_slice_entity(root, 1, "done");
let findings = lifecycle_findings(root);
assert!(findings.is_empty(), "resolved item must not flag");
}
}