use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use anyhow::Context;
use serde::{Deserialize, Serialize};
use crate::CommonListArgs;
use crate::entity::{self, Kind, Materialised};
use crate::listing::{self, Column, Format, ListArgs};
use crate::requirement::ReqStatus;
use crate::tomlfmt::toml_string;
use std::str::FromStr;
use clap::Subcommand;
#[derive(Subcommand)]
pub(crate) enum RevisionChangeCommand {
Add {
reference: String,
#[arg(long)]
action: ChangeAction,
#[arg(long)]
target: Option<String>,
#[arg(long = "to-status")]
to_status: Option<String>,
#[arg(long = "new-label")]
new_label: Option<String>,
#[arg(long = "member-of")]
member_of: Option<String>,
#[arg(long = "new-statement")]
new_statement: Option<String>,
#[arg(long)]
primary: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
}
#[derive(Subcommand)]
pub(crate) enum RevisionCommand {
New {
title: Option<String>,
#[arg(long)]
slug: Option<String>,
#[arg(long)]
originates_from: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Show {
reference: String,
#[arg(long, value_parser = Format::from_str, default_value_t = Format::Table)]
format: Format,
#[arg(long)]
json: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Status {
reference: String,
state: RevStatus,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Change {
#[command(subcommand)]
command: RevisionChangeCommand,
},
Approve {
reference: String,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Apply {
reference: 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>,
},
List {
#[command(flatten)]
list: CommonListArgs,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
}
pub(crate) fn dispatch(cmd: RevisionCommand, color: bool) -> anyhow::Result<()> {
match cmd {
RevisionCommand::New {
title,
slug,
path,
originates_from,
} => run_new(path, title, slug, originates_from.as_deref()),
RevisionCommand::Show {
reference,
format,
json,
path,
} => run_show(path, &reference, if json { Format::Json } else { format }),
RevisionCommand::Status {
reference,
state,
path,
} => run_status(path, &reference, state, color),
RevisionCommand::Change { command } => match command {
RevisionChangeCommand::Add {
reference,
action,
target,
to_status,
new_label,
member_of,
new_statement,
primary,
path,
} => run_change_add(
path,
&reference,
&ChangeAddArgs {
action,
target,
to_status,
new_label,
member_of,
new_statement,
primary,
},
),
},
RevisionCommand::Approve { reference, path } => run_approve(path, &reference),
RevisionCommand::Apply { reference, path } => run_apply(path, &reference),
RevisionCommand::List { list, path } => run_list(path, list.into_list_args(color)),
RevisionCommand::Paths {
refs,
toml,
md,
entity,
single,
path,
} => {
use std::io::Write;
let root = crate::root::find(path, &crate::root::default_markers())?;
let rev_root = root.join(REV_DIR);
let sel = crate::paths::PathSelection {
toml,
md,
entity,
single,
};
let mut all_lines: Vec<String> = Vec::new();
for r in &refs {
let id = parse_ref(r)?;
let name = format!("{id:03}");
let entity_dir = rev_root.join(&name);
let toml_name = format!("revision-{name}.toml");
let md_name = format!("revision-{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!(std::io::stdout(), "{}", all_lines.join("\n"))?;
Ok(())
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum RevStatus {
Proposed,
Started,
Done,
Abandoned,
}
impl RevStatus {
pub(crate) const fn as_str(self) -> &'static str {
match self {
RevStatus::Proposed => "proposed",
RevStatus::Started => "started",
RevStatus::Done => "done",
RevStatus::Abandoned => "abandoned",
}
}
const fn is_terminal(self) -> bool {
matches!(self, RevStatus::Done | RevStatus::Abandoned)
}
}
pub(crate) const REV_STATUSES: &[&str] = &["proposed", "started", "done", "abandoned"];
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum Approval {
None,
Requested,
Approved,
Rejected,
}
impl Approval {
const fn as_str(self) -> &'static str {
match self {
Approval::None => "none",
Approval::Requested => "requested",
Approval::Approved => "approved",
Approval::Rejected => "rejected",
}
}
}
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "SL-066 PHASE-02: approval vocab SSoT — consumed only by the drift canary now; the PHASE-05 approve verb reads it in non-test builds"
)
)]
const APPROVALS: &[&str] = &["none", "requested", "approved", "rejected"];
const REV_HIDDEN: &[&str] = &["done", "abandoned"];
fn validate_transition(from: RevStatus, to: RevStatus) -> anyhow::Result<()> {
if from == to {
return Ok(()); }
if from.is_terminal() {
anyhow::bail!(
"revision is terminal (`{}`) — no transition out of a terminal status",
from.as_str()
);
}
let legal = matches!(
(from, to),
(RevStatus::Proposed, RevStatus::Started)
| (RevStatus::Started, RevStatus::Done)
| (
RevStatus::Proposed | RevStatus::Started,
RevStatus::Abandoned
)
);
if legal {
Ok(())
} else {
anyhow::bail!(
"illegal revision transition `{}` → `{}` (legal: proposed→started, \
started→done, proposed/started→abandoned)",
from.as_str(),
to.as_str()
)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub(crate) struct RevDoc {
pub(crate) id: u32,
pub(crate) slug: String,
pub(crate) title: String,
pub(crate) status: RevStatus,
pub(crate) approval: Approval,
#[serde(default)]
pub(crate) tags: Vec<String>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::estimate::deserialize_lenient"
)]
pub(crate) estimate: Option<crate::estimate::EstimateFacet>,
#[serde(
default,
skip_serializing_if = "Option::is_none",
deserialize_with = "crate::value::deserialize_lenient"
)]
pub(crate) value: Option<crate::value::ValueFacet>,
}
pub(crate) const REV_DIR: &str = ".doctrine/revision";
pub(crate) const REV_KIND: Kind = Kind {
dir: REV_DIR,
prefix: crate::kinds::REV,
stem: "revision",
scaffold: rev_scaffold_unused,
};
fn rev_scaffold_unused(_ctx: &entity::ScaffoldCtx<'_>) -> anyhow::Result<entity::Fileset> {
anyhow::bail!("revision materialises eagerly, not via Kind.scaffold")
}
fn render_revision_toml(
id: u32,
slug: &str,
title: &str,
date: &str,
originates_from: Option<&str>,
) -> anyhow::Result<String> {
let mut toml = crate::install::asset_text("templates/revision.toml")?
.replace("{{id}}", &id.to_string())
.replace("{{slug}}", &toml_string(slug))
.replace("{{title}}", &toml_string(title))
.replace("{{date}}", date);
if let Some(rfcref) = originates_from {
use std::fmt::Write;
writeln!(toml, "\n[[relation]]")?;
writeln!(toml, "label = \"originates_from\"")?;
writeln!(toml, "target = \"{rfcref}\"")?;
}
Ok(toml)
}
fn render_revision_md(canonical: &str, title: &str) -> anyhow::Result<String> {
Ok(crate::install::asset_text("templates/revision.md")?
.replace("{{ref}}", canonical)
.replace("{{title}}", title))
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize, clap::ValueEnum)]
#[serde(rename_all = "kebab-case")]
pub(crate) enum ChangeAction {
Modify,
Retire,
Move,
Status,
Introduce,
Create,
}
impl ChangeAction {
const fn as_str(self) -> &'static str {
match self {
ChangeAction::Modify => "modify",
ChangeAction::Retire => "retire",
ChangeAction::Move => "move",
ChangeAction::Status => "status",
ChangeAction::Introduce => "introduce",
ChangeAction::Create => "create",
}
}
const fn is_creation(self) -> bool {
matches!(self, ChangeAction::Introduce | ChangeAction::Create)
}
}
#[cfg_attr(
not(test),
expect(
dead_code,
reason = "SL-066 PHASE-03: ChangeAction vocab SSoT — consumed only by the drift canary; clap binds the enum directly"
)
)]
const CHANGE_ACTIONS: &[&str] = &["modify", "retire", "move", "status", "introduce", "create"];
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub(crate) struct ChangeRow {
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) target: Option<String>,
pub(crate) action: ChangeAction,
#[serde(default)]
pub(crate) primary: bool,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) from: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) to_status: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) new_label: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) member_of: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) new_statement: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) allocated: Option<String>,
}
#[derive(Debug, Default, Deserialize)]
struct ChangeDoc {
#[serde(default)]
change: Vec<ChangeRow>,
}
impl ChangeRow {
fn edge_target(&self) -> Option<&str> {
self.target.as_deref().or(self.member_of.as_deref())
}
}
pub(crate) fn relation_edges(
root: &Path,
id: u32,
) -> anyhow::Result<Vec<crate::relation::RelationEdge>> {
let name = format!("{id:03}");
let path = root
.join(REV_DIR)
.join(&name)
.join(format!("revision-{name}.toml"));
let text = fs::read_to_string(&path)
.with_context(|| format!("revision {name} not found at {}", path.display()))?;
let doc: ChangeDoc =
toml::from_str(&text).with_context(|| format!("Failed to parse {}", path.display()))?;
Ok(doc
.change
.iter()
.filter_map(|row| {
row.edge_target().map(|t| {
crate::relation::RelationEdge::new(
crate::relation::RelationLabel::Revises,
t.to_owned(),
)
})
})
.collect())
}
pub(crate) fn run_new(
path: Option<PathBuf>,
title: Option<String>,
slug: Option<String>,
originates_from: Option<&str>,
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let trunk_ids = crate::git::trunk_entity_ids(&root, REV_DIR)?;
let (backend, mut reserved) =
crate::reserve::backend(&root, REV_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 rfcref: Option<String> = match originates_from {
Some(raw) => {
use crate::integrity;
let (kref, id) = integrity::parse_canonical_ref(raw)
.map_err(|e| anyhow::anyhow!("invalid --originates-from `{raw}`: {e}"))?;
anyhow::ensure!(
kref.kind.prefix == "RFC",
"--originates-from must be an RFC reference, got `{raw}` (prefix `{}`)",
kref.kind.prefix
);
let canonical = crate::listing::canonical_id(kref.kind.prefix, id);
integrity::ensure_ref_resolves(&root, &canonical).map_err(|e| {
anyhow::anyhow!("--originates-from `{canonical}` does not resolve: {e}")
})?;
Some(canonical)
}
None => None,
};
let out: Materialised = entity::materialise_fresh_prebuilt(
&*backend,
&root,
REV_DIR,
REV_KIND.prefix,
&trunk_ids,
&mut reserved,
|id, canonical| {
let name = format!("{id:03}");
Ok(vec![
entity::Artifact::File {
rel_path: PathBuf::from(format!("{name}/revision-{name}.toml")),
body: render_revision_toml(id, &slug, &title, &date, rfcref.as_deref())?,
},
entity::Artifact::File {
rel_path: PathBuf::from(format!("{name}/revision-{name}.md")),
body: render_revision_md(canonical, &title)?,
},
entity::Artifact::Symlink {
rel_path: PathBuf::from(format!("{name}-{slug}")),
target: name,
},
])
},
)?;
let id = out
.eid
.numeric_id()
.context("revision kind must yield a numeric id")?;
writeln!(
io::stdout(),
"Created revision {id:03}: {}",
out.dir.display()
)?;
Ok(())
}
fn canonical_id(id: u32) -> String {
listing::canonical_id(REV_KIND.prefix, id)
}
fn parse_ref(reference: &str) -> anyhow::Result<u32> {
let digits = reference
.strip_prefix("REV-")
.or_else(|| reference.strip_prefix("revision-"))
.unwrap_or(reference);
digits.parse::<u32>().with_context(|| {
format!("not a revision reference: `{reference}` (expected `REV-007` or `7`)")
})
}
fn read_revision(rev_root: &Path, id: u32) -> anyhow::Result<RevDoc> {
let name = format!("{id:03}");
let path = rev_root.join(&name).join(format!("revision-{name}.toml"));
let text = fs::read_to_string(&path)
.with_context(|| format!("revision {name} not found at {}", path.display()))?;
toml::from_str(&text).with_context(|| format!("Failed to parse {}", path.display()))
}
fn read_rationale(rev_root: &Path, id: u32) -> anyhow::Result<String> {
let name = format!("{id:03}");
let path = rev_root.join(&name).join(format!("revision-{name}.md"));
fs::read_to_string(&path).with_context(|| format!("Failed to read {}", path.display()))
}
pub(crate) fn run_show(
path: Option<PathBuf>,
reference: &str,
format: Format,
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let rev_root = root.join(REV_DIR);
let id = parse_ref(reference)?;
let doc = read_revision(&rev_root, id)?;
let body = read_rationale(&rev_root, id)?;
let out = match format {
Format::Table => {
let cfg = crate::dtoml::load_doctrine_toml(&root)?;
let estimation_unit = crate::estimate::resolve_unit(&cfg.estimation);
let value_unit = crate::value::resolve_unit(&cfg.value);
let (lower_pct, upper_pct) = crate::estimate::resolve_confidence(&cfg.estimation)?;
format_show(
&doc,
&body,
&estimation_unit,
&value_unit,
lower_pct,
upper_pct,
)
}
Format::Json => show_json(&doc, &body)?,
};
write!(io::stdout(), "{out}")?;
Ok(())
}
fn format_show(
doc: &RevDoc,
body: &str,
estimation_unit: &str,
value_unit: &str,
lower_pct: f64,
upper_pct: f64,
) -> String {
let mut parts: Vec<String> = Vec::new();
parts.push(format!("{} — {}\n", canonical_id(doc.id), doc.title));
parts.push(format!(
"status={} · approval={}\n",
doc.status.as_str(),
doc.approval.as_str()
));
if let Some(ref est) = doc.estimate {
parts.push(format!(
"{}\n",
crate::estimate::display::format_estimate_confidence(
est,
lower_pct,
upper_pct,
estimation_unit,
)
));
}
if let Some(ref val) = doc.value {
parts.push(format!(
"{}\n",
crate::value::format_value_normal(val, value_unit)
));
}
parts.push(format!("\n{body}"));
parts.concat()
}
fn show_json(doc: &RevDoc, body: &str) -> anyhow::Result<String> {
let mut value = serde_json::json!({
"kind": "revision",
"revision": {
"id": canonical_id(doc.id),
"slug": doc.slug,
"title": doc.title,
"status": doc.status.as_str(),
"approval": doc.approval.as_str(),
"tags": doc.tags,
},
"body": body,
});
if let Some(ref est) = doc.estimate
&& let Some(obj) = value.get_mut("revision").and_then(|v| v.as_object_mut())
{
obj.insert(
"estimate".to_string(),
serde_json::json!({
"lower": est.lower,
"upper": est.upper,
}),
);
}
if let Some(ref val) = doc.value
&& let Some(obj) = value.get_mut("revision").and_then(|v| v.as_object_mut())
{
obj.insert(
"value".to_string(),
serde_json::json!({
"value": val.value,
}),
);
}
serde_json::to_string_pretty(&value).context("failed to serialize revision show JSON")
}
pub(crate) fn run_status(
path: Option<PathBuf>,
reference: &str,
state: RevStatus,
color: bool,
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let rev_root = root.join(REV_DIR);
let id = parse_ref(reference)?;
let from = read_revision(&rev_root, id)?.status;
validate_transition(from, state)?;
set_revision_status(&rev_root, id, state, &crate::clock::today())?;
writeln!(
io::stdout(),
"{} → {}",
crate::listing::status_colored(from.as_str(), color),
crate::listing::status_colored(state.as_str(), color)
)?;
Ok(())
}
fn set_revision_status(
rev_root: &Path,
id: u32,
state: RevStatus,
today: &str,
) -> anyhow::Result<()> {
let name = format!("{id:03}");
let path = rev_root.join(&name).join(format!("revision-{name}.toml"));
let hint = format!(
"malformed revision {name}: missing seeded `status`/`updated` (regenerate via `revision new`)"
);
crate::dep_seq::set_authored_status(
&path,
&[("status", state.as_str()), ("updated", today)],
&hint,
)?;
Ok(())
}
pub(crate) struct ChangeAddArgs {
pub(crate) action: ChangeAction,
pub(crate) target: Option<String>,
pub(crate) to_status: Option<String>,
pub(crate) new_label: Option<String>,
pub(crate) member_of: Option<String>,
pub(crate) new_statement: Option<String>,
pub(crate) primary: bool,
}
pub(crate) fn run_change_add(
path: Option<PathBuf>,
reference: &str,
args: &ChangeAddArgs,
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let rev_root = root.join(REV_DIR);
let id = parse_ref(reference)?;
read_revision(&rev_root, id)?;
let existing = read_change_rows(&rev_root, id)?;
let row = build_row(&root, args, &existing)?;
append_change_row(&rev_root, id, &row)?;
let subject = row
.target
.clone()
.or_else(|| row.member_of.clone())
.unwrap_or_default();
writeln!(
io::stdout(),
"{}: change {} {}",
canonical_id(id),
row.action.as_str(),
subject
)?;
Ok(())
}
fn build_row(
root: &Path,
args: &ChangeAddArgs,
existing: &[ChangeRow],
) -> anyhow::Result<ChangeRow> {
if args.primary && existing.iter().any(|r| r.primary) {
anyhow::bail!(
"this revision already has a primary change row — `primary` is at most one (F1)"
);
}
if args.action.is_creation() {
build_creation_row(root, args)
} else {
build_existing_target_row(root, args, existing)
}
}
fn build_creation_row(root: &Path, args: &ChangeAddArgs) -> anyhow::Result<ChangeRow> {
anyhow::ensure!(
args.target.is_none(),
"`--target` is not valid for a creation op (`{}`) — it keys on no pre-existing FK; use `--member-of`",
args.action.as_str()
);
anyhow::ensure!(
args.to_status.is_none(),
"`--to-status` is only valid for a `status` row"
);
let new_label = args.new_label.clone().with_context(|| {
format!(
"a creation op (`{}`) requires `--new-label` (frozen at change add, E4)",
args.action.as_str()
)
})?;
let member_of = args.member_of.clone().with_context(|| {
format!(
"a creation op (`{}`) requires `--member-of` naming a live SPEC-NNN",
args.action.as_str()
)
})?;
let (kref, _) = crate::integrity::parse_canonical_ref(&member_of)?;
anyhow::ensure!(
kref.kind.prefix == "SPEC",
"`--member-of` must name a SPEC, got a {}",
kref.kind.prefix
);
crate::integrity::ensure_ref_resolves(root, &member_of)?;
Ok(ChangeRow {
target: None,
action: args.action,
primary: args.primary,
from: None,
to_status: None,
new_label: Some(new_label),
member_of: Some(member_of),
new_statement: args.new_statement.clone(),
allocated: None,
})
}
fn build_existing_target_row(
root: &Path,
args: &ChangeAddArgs,
existing: &[ChangeRow],
) -> anyhow::Result<ChangeRow> {
anyhow::ensure!(
args.new_label.is_none() && args.member_of.is_none(),
"`--new-label` / `--member-of` are only valid for a creation op (`introduce`/`create`)"
);
let target = args.target.clone().with_context(|| {
format!(
"an existing-target op (`{}`) requires `--target` naming a live peer FK",
args.action.as_str()
)
})?;
let (kref, _) = crate::integrity::parse_canonical_ref(&target)?;
let rule = crate::relation::lookup(&REV_KIND, crate::relation::RelationLabel::Revises, None)
.context("internal: missing `revises` rule row")?;
crate::relation::check_target_kind(rule, &REV_KIND, kref.kind.prefix)?;
crate::integrity::ensure_ref_resolves(root, &target)?;
anyhow::ensure!(
!existing
.iter()
.any(|r| r.action == args.action && r.target.as_deref() == Some(target.as_str())),
"this revision already carries a `{}` change for {target} (OQ-1: a change is named once)",
args.action.as_str()
);
let (from, to_status) = if args.action == ChangeAction::Status {
anyhow::ensure!(
kref.kind.prefix == "REQ",
"a `status` change targets a requirement (REQ), got a {}",
kref.kind.prefix
);
let to = args
.to_status
.clone()
.context("a `status` change requires `--to-status`")?;
let current = crate::requirement::load(root, &target)?.status;
(Some(current.as_str().to_owned()), Some(to))
} else {
anyhow::ensure!(
args.to_status.is_none(),
"`--to-status` is only valid for a `status` row"
);
(None, None)
};
Ok(ChangeRow {
target: Some(target),
action: args.action,
primary: args.primary,
from,
to_status,
new_label: None,
member_of: None,
new_statement: args.new_statement.clone(),
allocated: None,
})
}
fn read_change_rows(rev_root: &Path, id: u32) -> anyhow::Result<Vec<ChangeRow>> {
let name = format!("{id:03}");
let path = rev_root.join(&name).join(format!("revision-{name}.toml"));
let text = fs::read_to_string(&path)
.with_context(|| format!("revision {name} not found at {}", path.display()))?;
let doc: ChangeDoc =
toml::from_str(&text).with_context(|| format!("Failed to parse {}", path.display()))?;
Ok(doc.change)
}
fn append_change_row(rev_root: &Path, id: u32, row: &ChangeRow) -> anyhow::Result<()> {
let name = format!("{id:03}");
let path = rev_root.join(&name).join(format!("revision-{name}.toml"));
let text = fs::read_to_string(&path)
.with_context(|| format!("revision {name} not found at {}", path.display()))?;
let mut doc = text
.parse::<toml_edit::DocumentMut>()
.with_context(|| format!("Failed to parse {}", path.display()))?;
let array = doc
.entry("change")
.or_insert_with(|| toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new()))
.as_array_of_tables_mut()
.context("malformed revision: `change` is present but is not an array-of-tables")?;
let mut table = toml_edit::Table::new();
if let Some(t) = &row.target {
table.insert("target", toml_edit::value(t.as_str()));
}
table.insert("action", toml_edit::value(row.action.as_str()));
table.insert("primary", toml_edit::value(row.primary));
if let Some(f) = &row.from {
table.insert("from", toml_edit::value(f.as_str()));
}
if let Some(to) = &row.to_status {
table.insert("to_status", toml_edit::value(to.as_str()));
}
if let Some(l) = &row.new_label {
table.insert("new_label", toml_edit::value(l.as_str()));
}
if let Some(m) = &row.member_of {
table.insert("member_of", toml_edit::value(m.as_str()));
}
if let Some(s) = &row.new_statement {
table.insert("new_statement", toml_edit::value(s.as_str()));
}
array.push(table);
crate::fsutil::write_atomic(&path, doc.to_string().as_bytes())
.with_context(|| format!("Failed to write {}", path.display()))
}
pub(crate) fn run_approve(path: Option<PathBuf>, reference: &str) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let rev_root = root.join(REV_DIR);
let id = parse_ref(reference)?;
let doc = read_revision(&rev_root, id)?;
set_revision_approval(&rev_root, id, Approval::Approved, &crate::clock::today())?;
writeln!(
io::stdout(),
"{} approval: {} → approved",
canonical_id(id),
doc.approval.as_str()
)?;
Ok(())
}
fn set_revision_approval(
rev_root: &Path,
id: u32,
approval: Approval,
today: &str,
) -> anyhow::Result<()> {
let name = format!("{id:03}");
let path = rev_root.join(&name).join(format!("revision-{name}.toml"));
let hint = format!(
"malformed revision {name}: missing seeded `approval`/`updated` (regenerate via `revision new`)"
);
crate::dep_seq::set_authored_status(
&path,
&[("approval", approval.as_str()), ("updated", today)],
&hint,
)?;
Ok(())
}
struct PlannedStatus {
fk: String,
req_id: u32,
to: ReqStatus,
}
struct StaleFinding {
fk: String,
expected_from: String,
actual: String,
}
fn parse_req_status(s: &str) -> anyhow::Result<ReqStatus> {
serde_json::from_value::<ReqStatus>(serde_json::Value::String(s.to_owned()))
.with_context(|| format!("not a known requirement status: `{s}`"))
}
fn partition_change_rows(rows: &[ChangeRow]) -> (Vec<&ChangeRow>, Vec<&ChangeRow>) {
rows.iter().partition(|r| r.action == ChangeAction::Status)
}
pub(crate) fn run_apply(path: Option<PathBuf>, reference: &str) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let rev_root = root.join(REV_DIR);
let id = parse_ref(reference)?;
let doc = read_revision(&rev_root, id)?;
anyhow::ensure!(
doc.approval == Approval::Approved,
"{} is not approved (approval = `{}`) — run `doctrine revision approve {}` before apply",
canonical_id(id),
doc.approval.as_str(),
canonical_id(id)
);
let rows = read_change_rows(&rev_root, id)?;
let (status_rows, manual_rows) = partition_change_rows(&rows);
let mut planned: Vec<PlannedStatus> = Vec::new();
let mut stale: Vec<StaleFinding> = Vec::new();
for row in &status_rows {
let fk = row
.target
.clone()
.context("internal: a `status` change row carries no target")?;
let expected_from = row
.from
.clone()
.context("internal: a `status` change row carries no captured `from`")?;
let to_str = row
.to_status
.clone()
.context("internal: a `status` change row carries no `to_status`")?;
let to = parse_req_status(&to_str)?;
match crate::requirement::load(&root, &fk) {
Ok(req) => {
let actual = req.status.as_str().to_owned();
if actual == expected_from {
let req_id = crate::requirement::id_from_fk(&fk)?;
planned.push(PlannedStatus { fk, req_id, to });
} else {
stale.push(StaleFinding {
fk: fk.clone(),
expected_from,
actual,
});
}
}
Err(_) => stale.push(StaleFinding {
fk: fk.clone(),
expected_from,
actual: "missing".to_owned(),
}),
}
}
if !stale.is_empty() {
use std::fmt::Write as _;
let mut msg = String::from(
"apply aborted — the requirement status drifted since the change was drafted (all-or-nothing; nothing written):",
);
for f in &stale {
write!(
msg,
"\n {} expected from `{}`, found `{}` — re-draft the status change",
f.fk, f.expected_from, f.actual
)?;
}
anyhow::bail!(msg);
}
let mut out = io::stdout();
for p in &planned {
let prior = crate::requirement::load(&root, &p.fk)?.status;
let rec = compose_apply_rec(&p.fk, prior, p.to);
let rec_id = crate::rec::materialise_populated(&root, &rec)?; crate::requirement::set_status(&root, p.req_id, p.to)?;
writeln!(
out,
"{}: status {} → {} (rec {rec_id:03})",
p.fk,
prior.as_str(),
p.to.as_str()
)?;
}
if !manual_rows.is_empty() {
writeln!(
out,
"\nsurfaced for manual handling ({} row(s) — land by operator hand-edit):",
manual_rows.len()
)?;
for row in &manual_rows {
let subject = row
.target
.clone()
.or_else(|| row.member_of.clone())
.unwrap_or_default();
writeln!(out, " {} {}", row.action.as_str(), subject)?;
}
}
let target = if manual_rows.is_empty() {
RevStatus::Done
} else {
RevStatus::Started
};
settle_disposition(&rev_root, id, doc.status, target)?;
writeln!(out, "{} → {}", canonical_id(id), target.as_str())?;
Ok(())
}
fn compose_apply_rec(req: &str, prior: ReqStatus, written: ReqStatus) -> crate::rec::RecDoc {
crate::rec::RecDoc {
id: 0,
slug: format!("apply-{}", req.to_lowercase()),
title: format!("apply {req}"),
rec: crate::rec::RecMeta {
r#move: crate::rec::RecMove::Revise.as_str().to_owned(),
owning_slice: None,
decision_ref: None,
},
status_delta: vec![crate::rec::StatusDelta {
requirement: req.to_owned(),
from: prior.as_str().to_owned(),
to: written.as_str().to_owned(),
}],
evidence_ref: Vec::new(),
tags: Vec::new(),
estimate: None,
value: None,
}
}
fn settle_disposition(
rev_root: &Path,
id: u32,
current: RevStatus,
target: RevStatus,
) -> anyhow::Result<()> {
let today = crate::clock::today();
if current.is_terminal() {
return Ok(());
}
let steps: &[RevStatus] = match target {
RevStatus::Started => &[RevStatus::Started],
RevStatus::Done => &[RevStatus::Started, RevStatus::Done],
_ => &[],
};
let mut from = current;
for &step in steps {
if from == step {
continue;
}
validate_transition(from, step)?;
set_revision_status(rev_root, id, step, &today)?;
from = step;
}
Ok(())
}
fn list_rows(root: &Path, args: ListArgs) -> anyhow::Result<String> {
listing::validate_statuses(&args.status, REV_STATUSES)?;
let render = args.render;
let columns = args.columns.clone();
let (filter, format) = listing::build(args)?;
let rev_root = root.join(REV_DIR);
if !rev_root.is_dir() {
return match format {
Format::Table => Ok(listing::render_columns::<RevDoc>(
&[],
&listing::select_columns(&REV_COLUMNS, REV_DEFAULT, columns.as_deref())?,
render,
)),
Format::Json => listing::json_envelope::<ListRow>("revision", &[]),
};
}
let mut docs = listing::retain(
read_revs(&rev_root)?,
&filter,
|s| REV_HIDDEN.contains(&s),
key,
);
docs.sort_by_key(|d| d.id);
match format {
Format::Table => {
let sel = listing::select_columns(&REV_COLUMNS, REV_DEFAULT, columns.as_deref())?;
Ok(listing::render_columns(&docs, &sel, render))
}
Format::Json => listing::json_envelope("revision", &json_rows(&docs)),
}
}
pub(crate) fn run_list(path: Option<PathBuf>, args: ListArgs) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let mut out = io::stdout();
write!(out, "{}", list_rows(&root, args)?)?;
Ok(())
}
fn read_rev(rev_root: &Path, id: u32) -> anyhow::Result<RevDoc> {
let name = format!("{id:03}");
let path = rev_root.join(&name).join(format!("revision-{name}.toml"));
let text = fs::read_to_string(&path)
.with_context(|| format!("revision {name} not found at {}", path.display()))?;
toml::from_str(&text).with_context(|| format!("Failed to parse {}", path.display()))
}
fn read_revs(rev_root: &Path) -> anyhow::Result<Vec<RevDoc>> {
let mut docs = Vec::new();
for id in entity::scan_ids(rev_root)? {
docs.push(read_rev(rev_root, id)?);
}
Ok(docs)
}
fn key(d: &RevDoc) -> listing::FilterFields {
listing::FilterFields {
canonical: canonical_id(d.id),
slug: d.slug.clone(),
title: d.title.clone(),
status: d.status.as_str().to_owned(),
tags: d.tags.clone(),
}
}
#[derive(Debug, Serialize)]
struct ListRow {
id: String,
status: String,
approval: String,
tags: Vec<String>,
title: String,
}
fn json_rows(docs: &[RevDoc]) -> Vec<ListRow> {
docs.iter()
.map(|d| ListRow {
id: canonical_id(d.id),
status: d.status.as_str().to_owned(),
approval: d.approval.as_str().to_owned(),
tags: d.tags.clone(),
title: d.title.clone(),
})
.collect()
}
const REV_COLUMNS: [Column<RevDoc>; 5] = [
Column {
name: "id",
header: "id",
cell: |d| canonical_id(d.id),
paint: listing::ColumnPaint::Fixed(owo_colors::DynColors::Ansi(
owo_colors::AnsiColors::Cyan,
)),
},
Column {
name: "status",
header: "status",
cell: |d| d.status.as_str().to_owned(),
paint: listing::ColumnPaint::ByValue(|d| listing::status_hue(d.status.as_str())),
},
Column {
name: "approval",
header: "approval",
cell: |d| d.approval.as_str().to_owned(),
paint: listing::ColumnPaint::None,
},
Column {
name: "tags",
header: "tags",
cell: |d| d.tags.join(", "),
paint: listing::ColumnPaint::None,
},
Column {
name: "title",
header: "title",
cell: |d| d.title.clone(),
paint: listing::ColumnPaint::Alternate([listing::TITLE_EVEN, listing::TITLE_ODD]),
},
];
const REV_DEFAULT: &[&str] = &["id", "status", "approval", "title"];
#[cfg(test)]
mod tests {
use super::*;
use std::fs;
#[test]
fn rev_statuses_matches_the_variants() {
let variants = [
RevStatus::Proposed,
RevStatus::Started,
RevStatus::Done,
RevStatus::Abandoned,
];
let from_variants: Vec<&str> = variants.iter().map(|s| s.as_str()).collect();
assert_eq!(
from_variants, REV_STATUSES,
"REV_STATUSES drifted from the RevStatus variants"
);
}
#[test]
fn change_actions_matches_the_variants() {
let variants = [
ChangeAction::Modify,
ChangeAction::Retire,
ChangeAction::Move,
ChangeAction::Status,
ChangeAction::Introduce,
ChangeAction::Create,
];
let from_variants: Vec<&str> = variants.iter().map(|a| a.as_str()).collect();
assert_eq!(
from_variants, CHANGE_ACTIONS,
"CHANGE_ACTIONS drifted from the ChangeAction variants"
);
}
#[test]
fn is_creation_partitions_the_actions() {
assert!(ChangeAction::Introduce.is_creation());
assert!(ChangeAction::Create.is_creation());
for a in [
ChangeAction::Modify,
ChangeAction::Retire,
ChangeAction::Move,
ChangeAction::Status,
] {
assert!(!a.is_creation(), "{a:?} is an existing-target op");
}
}
#[test]
fn change_row_edge_target_picks_fk_then_member_of() {
let existing = ChangeRow {
target: Some("ADR-006".to_owned()),
action: ChangeAction::Modify,
primary: false,
from: None,
to_status: None,
new_label: None,
member_of: None,
new_statement: None,
allocated: None,
};
assert_eq!(existing.edge_target(), Some("ADR-006"));
let creation = ChangeRow {
target: None,
action: ChangeAction::Introduce,
primary: false,
from: None,
to_status: None,
new_label: Some("FR-007".to_owned()),
member_of: Some("SPEC-018".to_owned()),
new_statement: None,
allocated: None,
};
assert_eq!(creation.edge_target(), Some("SPEC-018"));
}
#[test]
fn change_doc_parses_both_row_shapes() {
let text = r#"
id = 1
status = "started"
[[change]]
target = "REQ-201"
action = "status"
primary = false
from = "active"
to_status = "retired"
[[change]]
action = "introduce"
member_of = "SPEC-018"
new_label = "FR-007"
primary = true
"#;
let doc: ChangeDoc = toml::from_str(text).unwrap();
assert_eq!(doc.change.len(), 2);
assert_eq!(doc.change[0].action, ChangeAction::Status);
assert_eq!(doc.change[0].from.as_deref(), Some("active"));
assert_eq!(doc.change[1].action, ChangeAction::Introduce);
assert!(doc.change[1].primary);
assert_eq!(doc.change[1].new_label.as_deref(), Some("FR-007"));
}
#[test]
fn approvals_matches_the_variants() {
let variants = [
Approval::None,
Approval::Requested,
Approval::Approved,
Approval::Rejected,
];
let from_variants: Vec<&str> = variants.iter().map(|a| a.as_str()).collect();
assert_eq!(
from_variants, APPROVALS,
"APPROVALS drifted from the Approval variants"
);
}
#[test]
fn schema_round_trips_through_serde() {
let doc = RevDoc {
id: 7,
slug: "revise-adr-006".to_owned(),
title: "revise ADR-006".to_owned(),
status: RevStatus::Started,
approval: Approval::None,
tags: vec![],
estimate: None,
value: None,
};
let text = toml::to_string(&doc).unwrap();
assert!(
text.contains("status = \"started\""),
"kebab status: {text}"
);
assert!(
text.contains("approval = \"none\""),
"kebab approval: {text}"
);
let back: RevDoc = toml::from_str(&text).unwrap();
assert_eq!(back, doc);
}
#[test]
fn fsm_advances_proposed_started_done() {
assert!(validate_transition(RevStatus::Proposed, RevStatus::Started).is_ok());
assert!(validate_transition(RevStatus::Started, RevStatus::Done).is_ok());
}
#[test]
fn fsm_abandons_from_any_non_terminal() {
assert!(validate_transition(RevStatus::Proposed, RevStatus::Abandoned).is_ok());
assert!(validate_transition(RevStatus::Started, RevStatus::Abandoned).is_ok());
}
#[test]
fn fsm_refuses_leaving_a_terminal_source() {
assert!(validate_transition(RevStatus::Done, RevStatus::Started).is_err());
assert!(validate_transition(RevStatus::Abandoned, RevStatus::Started).is_err());
assert!(validate_transition(RevStatus::Done, RevStatus::Abandoned).is_err());
}
#[test]
fn fsm_refuses_a_skip() {
let err =
validate_transition(RevStatus::Proposed, RevStatus::Done).expect_err("skip refused");
assert!(
format!("{err}").contains("illegal"),
"names the illegal move: {err}"
);
}
#[test]
fn fsm_allows_idempotent_no_op() {
assert!(validate_transition(RevStatus::Started, RevStatus::Started).is_ok());
assert!(validate_transition(RevStatus::Done, RevStatus::Done).is_ok());
}
#[test]
fn is_terminal_marks_done_and_abandoned() {
assert!(RevStatus::Done.is_terminal());
assert!(RevStatus::Abandoned.is_terminal());
assert!(!RevStatus::Proposed.is_terminal());
assert!(!RevStatus::Started.is_terminal());
}
#[test]
fn render_escapes_a_hostile_title() {
let text = render_revision_toml(1, "s", "T\"\ninjected = \"x", "2026-06-14", None).unwrap();
let back: RevDoc = toml::from_str(&text).unwrap();
assert_eq!(back.title, "T\"\ninjected = \"x");
assert_eq!(back.status, RevStatus::Proposed, "seeded proposed");
assert_eq!(back.approval, Approval::None, "seeded none");
}
#[test]
fn parse_req_status_round_trips_and_rejects_unknown() {
for s in crate::requirement::REQ_STATUSES {
let parsed = parse_req_status(s).expect("known status parses");
assert_eq!(parsed.as_str(), *s, "round-trips `{s}`");
}
assert!(
parse_req_status("not-a-status").is_err(),
"an unknown status token is refused"
);
}
#[test]
fn partition_splits_status_from_surfaced_for_manual() {
let row = |action: ChangeAction, target: &str| ChangeRow {
target: Some(target.to_owned()),
action,
primary: false,
from: None,
to_status: None,
new_label: None,
member_of: None,
new_statement: None,
allocated: None,
};
let rows = vec![
row(ChangeAction::Status, "REQ-201"),
row(ChangeAction::Modify, "ADR-006"),
row(ChangeAction::Move, "REQ-202"),
row(ChangeAction::Status, "REQ-203"),
];
let (status, manual) = partition_change_rows(&rows);
assert_eq!(status.len(), 2, "only the two status rows auto-land");
assert!(status.iter().all(|r| r.action == ChangeAction::Status));
assert_eq!(manual.len(), 2, "modify + move are surfaced-for-manual");
assert!(manual.iter().all(|r| r.action != ChangeAction::Status));
}
#[test]
fn compose_apply_rec_is_standalone_with_one_delta_no_evidence() {
let rec = compose_apply_rec("REQ-201", ReqStatus::Active, ReqStatus::Retired);
assert_eq!(
rec.rec.owning_slice, None,
"standalone: owning_slice = None"
);
assert_eq!(rec.status_delta.len(), 1, "one status_delta");
let d = &rec.status_delta[0];
assert_eq!(
(d.requirement.as_str(), d.from.as_str(), d.to.as_str()),
("REQ-201", "active", "retired")
);
assert!(
rec.evidence_ref.is_empty(),
"apply rests on the approved REV, not coverage — no evidence"
);
}
fn seed_rev(root: &Path, id: u32, status: &str, title: &str, tags: &[&str]) {
let name = format!("{id:03}");
let dir = root.join(REV_DIR).join(&name);
fs::create_dir_all(&dir).unwrap();
let tags_toml = if tags.is_empty() {
String::from("tags = []")
} else {
format!(
"tags = [{}]",
tags.iter()
.map(|t| format!("\"{t}\""))
.collect::<Vec<_>>()
.join(", ")
)
};
let toml = format!(
"id = {id}\nslug = \"s-{id}\"\ntitle = \"{title}\"\nstatus = \"{status}\"\napproval = \"none\"\n{tags_toml}\n"
);
fs::write(dir.join(format!("revision-{name}.toml")), toml).unwrap();
fs::write(dir.join(format!("revision-{name}.md")), "# body\n").unwrap();
}
#[test]
fn rev_hidden_covers_terminal_statuses() {
assert_eq!(REV_HIDDEN, &["done", "abandoned"]);
}
#[test]
fn list_rows_empty_tree_is_empty() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let out = list_rows(root, ListArgs::default()).unwrap();
assert!(out.is_empty(), "empty tree → empty string: {out:?}");
}
#[test]
fn list_rows_hides_done_and_abandoned() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
seed_rev(root, 1, "proposed", "Active rev", &[]);
seed_rev(root, 2, "done", "Done rev", &[]);
seed_rev(root, 3, "abandoned", "Abandoned rev", &[]);
let out = list_rows(root, ListArgs::default()).unwrap();
assert!(out.contains("REV-001"), "proposed visible: {out}");
assert!(!out.contains("REV-002"), "done hidden: {out}");
assert!(!out.contains("REV-003"), "abandoned hidden: {out}");
}
#[test]
fn list_rows_all_reveals_hidden() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
seed_rev(root, 1, "done", "Done rev", &[]);
let mut args = ListArgs::default();
args.all = true;
let out = list_rows(root, args).unwrap();
assert!(out.contains("REV-001"), "--all reveals done: {out}");
}
#[test]
fn list_rows_explicit_status_reveals_hidden() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
seed_rev(root, 1, "done", "Done rev", &[]);
let mut args = ListArgs::default();
args.status = vec!["done".to_owned()];
let out = list_rows(root, args).unwrap();
assert!(
out.contains("REV-001"),
"explicit --status done reveals: {out}"
);
}
#[test]
fn list_rows_filter_matches_slug_and_title() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
seed_rev(root, 1, "proposed", "Alpha", &[]);
seed_rev(root, 2, "proposed", "Beta", &[]);
let mut args = ListArgs::default();
args.substr = Some("Alpha".to_owned());
let out = list_rows(root, args).unwrap();
assert!(out.contains("REV-001"), "title match: {out}");
assert!(!out.contains("REV-002"), "no match: {out}");
}
#[test]
fn list_rows_tag_filter_matches() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
seed_rev(root, 1, "proposed", "Tagged", &["cli"]);
seed_rev(root, 2, "proposed", "Untagged", &[]);
let mut args = ListArgs::default();
args.tags = vec!["cli".to_owned()];
let out = list_rows(root, args).unwrap();
assert!(out.contains("REV-001"), "tagged match: {out}");
assert!(!out.contains("REV-002"), "untagged excluded: {out}");
}
#[test]
fn list_rows_unknown_status_errors() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let mut args = ListArgs::default();
args.status = vec!["bogus".to_owned()];
let err = list_rows(root, args).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("bogus"), "names the bad token: {msg}");
assert!(msg.contains("proposed"), "names known set: {msg}");
}
#[test]
fn list_rows_unknown_column_errors() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let mut args = ListArgs::default();
args.columns = Some(vec!["bogus".to_owned()]);
let err = list_rows(root, args).unwrap_err();
let msg = format!("{err}");
assert!(msg.contains("bogus"), "names the bad column: {msg}");
assert!(msg.contains("id"), "names available set: {msg}");
}
#[test]
fn list_rows_json_is_faithful_envelope() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
seed_rev(root, 1, "proposed", "Test", &["cli"]);
let mut args = ListArgs::default();
args.format = Format::Json;
let out = list_rows(root, args).unwrap();
assert!(out.contains("\"id\": \"REV-001\""), "prefixed id: {out}");
assert!(
out.contains("\"tags\"") && out.contains("\"cli\""),
"tags present: {out}"
);
assert!(out.contains("\"revision\""), "json envelope: {out}");
}
#[test]
fn list_rows_columns_selects_and_orders() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
seed_rev(root, 1, "proposed", "Test", &["cli"]);
let mut args = ListArgs::default();
args.columns = Some(vec![
"id".to_owned(),
"tags".to_owned(),
"status".to_owned(),
]);
let out = list_rows(root, args).unwrap();
let header = out.lines().next().unwrap();
assert!(header.contains("id"), "id column: {header}");
assert!(header.contains("tags"), "tags column: {header}");
assert!(header.contains("status"), "status column: {header}");
assert!(!header.contains("title"), "title excluded: {header}");
}
#[test]
fn render_revision_toml_includes_tags() {
let text = render_revision_toml(1, "s", "T", "2026-06-14", None).unwrap();
assert!(
text.contains("tags") && text.contains("[]"),
"tags scaffolded: {text}"
);
}
#[test]
fn tagless_revision_round_trips() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
let rev_dir = root.join(REV_DIR).join("001");
fs::create_dir_all(&rev_dir).unwrap();
let tagless_toml = concat!(
"id = 1\n",
"slug = \"s\"\n",
"title = \"Tagless Old Rev\"\n",
"status = \"proposed\"\n",
"approval = \"none\"\n",
);
fs::write(rev_dir.join("revision-001.toml"), tagless_toml).unwrap();
let rev_root = root.join(REV_DIR);
let doc = read_rev(&rev_root, 1).unwrap();
assert_eq!(doc.id, 1);
assert_eq!(doc.slug, "s");
assert_eq!(doc.title, "Tagless Old Rev");
assert!(
doc.tags.is_empty(),
"tags default to empty vec: {:?}",
doc.tags
);
let text = render_revision_toml(doc.id, &doc.slug, &doc.title, "2026-01-01", None).unwrap();
assert!(
text.contains("tags"),
"re-rendered text contains tags field: {text}"
);
let back: RevDoc = toml::from_str(&text).unwrap();
assert_eq!(back.id, 1);
assert_eq!(back.slug, "s");
assert_eq!(back.title, "Tagless Old Rev");
}
}