#![cfg_attr(
not(test),
expect(
dead_code,
reason = "pure-core vocabulary + predicates stood up in SL-040 PHASE-01; first non-test consumers (engine row, verb handlers) land in PHASE-02/03 — self-clearing"
)
)]
use crate::tomlfmt::toml_string;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Facet {
Scope,
Design,
Plan,
PhasePlan,
Implementation,
CodeReview,
Reconciliation,
}
impl Facet {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Scope => "scope",
Self::Design => "design",
Self::Plan => "plan",
Self::PhasePlan => "phase-plan",
Self::Implementation => "implementation",
Self::CodeReview => "code-review",
Self::Reconciliation => "reconciliation",
}
}
}
const FACETS: &[&str] = &[
"scope",
"design",
"plan",
"phase-plan",
"implementation",
"code-review",
"reconciliation",
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum FindingStatus {
Open,
Answered,
Contested,
Verified,
Withdrawn,
}
impl FindingStatus {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Open => "open",
Self::Answered => "answered",
Self::Contested => "contested",
Self::Verified => "verified",
Self::Withdrawn => "withdrawn",
}
}
pub(crate) const fn is_terminal(self) -> bool {
matches!(self, Self::Verified | Self::Withdrawn)
}
}
const FINDING_STATUSES: &[&str] = &["open", "answered", "contested", "verified", "withdrawn"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Severity {
Blocker,
Major,
Minor,
Nit,
}
impl Severity {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Blocker => "blocker",
Self::Major => "major",
Self::Minor => "minor",
Self::Nit => "nit",
}
}
pub(crate) fn parse(s: &str) -> Result<Self, String> {
match s {
"blocker" => Ok(Self::Blocker),
"major" => Ok(Self::Major),
"minor" => Ok(Self::Minor),
"nit" => Ok(Self::Nit),
other => Err(format!(
"unknown severity `{other}` (known: {})",
SEVERITIES.join(", ")
)),
}
}
}
const SEVERITIES: &[&str] = &["blocker", "major", "minor", "nit"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Role {
Raiser,
Responder,
}
impl Role {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Raiser => "raiser",
Self::Responder => "responder",
}
}
}
const ROLES: &[&str] = &["raiser", "responder"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ReviewStatus {
Active,
Done,
}
impl ReviewStatus {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Active => "active",
Self::Done => "done",
}
}
}
pub(crate) const REVIEW_STATUSES: &[&str] = &["active", "done"];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Await {
Raiser,
Responder,
None,
}
impl Await {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Raiser => "raiser",
Self::Responder => "responder",
Self::None => "none",
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum Verb {
Raise,
Dispose,
Verify,
Contest,
Withdraw,
}
impl Verb {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Raise => "raise",
Self::Dispose => "dispose",
Self::Verify => "verify",
Self::Contest => "contest",
Self::Withdraw => "withdraw",
}
}
pub(crate) const fn required_role(self) -> Role {
match self {
Self::Raise | Self::Verify | Self::Contest | Self::Withdraw => Role::Raiser,
Self::Dispose => Role::Responder,
}
}
}
pub(crate) const fn can(verb: Verb, from: Option<FindingStatus>, role: Role) -> bool {
if !role_eq(role, verb.required_role()) {
return false;
}
matches!(
(verb, from),
(Verb::Raise, None)
| (
Verb::Dispose,
Some(FindingStatus::Open | FindingStatus::Contested)
)
| (Verb::Verify | Verb::Contest, Some(FindingStatus::Answered))
| (
Verb::Withdraw,
Some(FindingStatus::Open | FindingStatus::Answered)
)
)
}
const fn role_eq(a: Role, b: Role) -> bool {
matches!(
(a, b),
(Role::Raiser, Role::Raiser) | (Role::Responder, Role::Responder)
)
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct FindingState {
pub(crate) status: FindingStatus,
}
pub(crate) fn derived_status(findings: &[FindingState]) -> (ReviewStatus, Await) {
if findings.is_empty() {
return (ReviewStatus::Active, Await::Raiser);
}
if findings
.iter()
.any(|f| matches!(f.status, FindingStatus::Open | FindingStatus::Contested))
{
return (ReviewStatus::Active, Await::Responder);
}
if findings.iter().any(|f| f.status == FindingStatus::Answered) {
return (ReviewStatus::Active, Await::Raiser);
}
(ReviewStatus::Done, Await::None)
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct Finding {
pub(crate) id: String,
pub(crate) status: FindingStatus,
pub(crate) severity: Severity,
pub(crate) title: String,
pub(crate) detail: String,
pub(crate) disposition: Option<String>,
pub(crate) response: Option<String>,
}
pub(crate) fn render_finding(finding: &Finding) -> String {
let mut out = String::new();
out.push_str("[[finding]]\n");
push_line(&mut out, "id", &toml_string(&finding.id));
push_line(&mut out, "status", &toml_string(finding.status.as_str()));
push_line(
&mut out,
"severity",
&toml_string(finding.severity.as_str()),
);
push_line(&mut out, "title", &toml_string(&finding.title));
push_line(&mut out, "detail", &toml_string(&finding.detail));
if let Some(disposition) = &finding.disposition {
push_line(&mut out, "disposition", &toml_string(disposition));
}
if let Some(response) = &finding.response {
push_line(&mut out, "response", &toml_string(response));
}
out
}
fn push_line(out: &mut String, key: &str, value: &str) {
out.push_str(key);
out.push_str(" = ");
out.push_str(value);
out.push('\n');
}
use std::collections::BTreeMap;
use std::fs;
use std::io::{self, Read as _, Write};
use std::path::{Path, PathBuf};
use anyhow::Context;
use serde::{Deserialize, Serialize};
use crate::contentset::{self, ContentSet};
use crate::entity::{self, Kind, LocalFs, Materialised};
use crate::listing::{self, Column, Format, ListArgs};
pub(crate) const REVIEW_DIR: &str = ".doctrine/review";
pub(crate) const REVIEW_KIND: Kind = Kind {
dir: REVIEW_DIR,
prefix: "RV",
scaffold: review_scaffold_unused,
};
fn review_scaffold_unused(_ctx: &entity::ScaffoldCtx<'_>) -> anyhow::Result<entity::Fileset> {
anyhow::bail!("review materialises eagerly, not via Kind.scaffold")
}
impl Facet {
pub(crate) fn parse(s: &str) -> Result<Self, String> {
match s {
"scope" => Ok(Self::Scope),
"design" => Ok(Self::Design),
"plan" => Ok(Self::Plan),
"phase-plan" => Ok(Self::PhasePlan),
"implementation" => Ok(Self::Implementation),
"code-review" => Ok(Self::CodeReview),
"reconciliation" => Ok(Self::Reconciliation),
other => Err(format!(
"unknown facet `{other}` (known: {})",
FACETS.join(", ")
)),
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
struct Target {
#[serde(rename = "ref")]
reference: String,
#[serde(default)]
phase: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
struct ReviewMeta {
facet: String,
raiser: String,
responder: String,
}
fn render_review_toml(
id: u32,
slug: &str,
title: &str,
review: &ReviewMeta,
target: &Target,
) -> anyhow::Result<String> {
let phase_line = match &target.phase {
Some(p) => {
let mut line = String::from("phase = ");
line.push_str(&toml_string(p));
line
}
None => String::new(),
};
Ok(crate::install::asset_text("templates/review.toml")?
.replace("{{id}}", &id.to_string())
.replace("{{slug}}", &toml_string(slug))
.replace("{{title}}", &toml_string(title))
.replace("{{facet}}", &toml_string(&review.facet))
.replace("{{raiser}}", &toml_string(&review.raiser))
.replace("{{responder}}", &toml_string(&review.responder))
.replace("{{target_ref}}", &toml_string(&target.reference))
.replace("{{target_phase}}", &phase_line))
}
fn render_review_md(canonical: &str, facet: &str, target_ref: &str) -> anyhow::Result<String> {
Ok(crate::install::asset_text("templates/review.md")?
.replace("{{ref}}", canonical)
.replace("{{facet}}", facet)
.replace("{{target}}", target_ref))
}
pub(crate) struct NewArgs {
pub(crate) facet: Facet,
pub(crate) target: String,
pub(crate) phase: Option<String>,
pub(crate) title: Option<String>,
pub(crate) raiser: Option<String>,
pub(crate) responder: Option<String>,
}
pub(crate) fn run_new(path: Option<PathBuf>, args: &NewArgs) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
crate::integrity::ensure_ref_resolves(&root, &args.target)?;
let title = args
.title
.clone()
.unwrap_or_else(|| format!("{} review of {}", args.facet.as_str(), args.target));
let slug = crate::input::resolve_slug(&title, None)?;
let review = ReviewMeta {
facet: args.facet.as_str().to_owned(),
raiser: args.raiser.clone().unwrap_or_else(|| "raiser".to_owned()),
responder: args
.responder
.clone()
.unwrap_or_else(|| "responder".to_owned()),
};
let target = Target {
reference: args.target.clone(),
phase: args.phase.clone(),
};
let trunk_ids = crate::git::trunk_entity_ids(&root, REVIEW_DIR)?;
let out: Materialised = entity::materialise_fresh_prebuilt(
&LocalFs,
&root,
REVIEW_DIR,
REVIEW_KIND.prefix,
&trunk_ids,
|id, canonical| {
let name = format!("{id:03}");
Ok(vec![
entity::Artifact::File {
rel_path: PathBuf::from(format!("{name}/review-{name}.toml")),
body: render_review_toml(id, &slug, &title, &review, &target)?,
},
entity::Artifact::File {
rel_path: PathBuf::from(format!("{name}/review-{name}.md")),
body: render_review_md(canonical, &review.facet, &target.reference)?,
},
entity::Artifact::Symlink {
rel_path: PathBuf::from(format!("{name}-{slug}")),
target: name,
},
])
},
)?;
let id = out
.eid
.numeric_id()
.context("review kind must yield a numeric id")?;
writeln!(
io::stdout(),
"Created review {id:03}: {}",
out.dir.display()
)?;
Ok(())
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
struct FindingRow {
id: String,
status: String,
severity: String,
title: String,
detail: String,
#[serde(default)]
disposition: Option<String>,
#[serde(default)]
response: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
struct ReviewDoc {
id: u32,
slug: String,
title: String,
review: ReviewMeta,
target: Target,
#[serde(default)]
finding: Vec<FindingRow>,
}
impl ReviewDoc {
fn finding_states(&self) -> Vec<FindingState> {
self.finding
.iter()
.map(|f| FindingState {
status: parse_finding_status(&f.status),
})
.collect()
}
fn derived(&self) -> (ReviewStatus, Await) {
derived_status(&self.finding_states())
}
}
fn parse_finding_status(s: &str) -> FindingStatus {
match s {
"answered" => FindingStatus::Answered,
"contested" => FindingStatus::Contested,
"verified" => FindingStatus::Verified,
"withdrawn" => FindingStatus::Withdrawn,
_ => FindingStatus::Open,
}
}
fn read_review(review_root: &Path, id: u32) -> anyhow::Result<ReviewDoc> {
let name = format!("{id:03}");
let path = review_root.join(&name).join(format!("review-{name}.toml"));
let text = fs::read_to_string(&path)
.with_context(|| format!("review {name} not found at {}", path.display()))?;
toml::from_str(&text).with_context(|| format!("Failed to parse {}", path.display()))
}
pub(crate) fn relation_edges(
root: &Path,
id: u32,
) -> anyhow::Result<Vec<crate::relation::RelationEdge>> {
use crate::relation::{RelationEdge, RelationLabel};
let doc = read_review(&root.join(REVIEW_DIR), id)?;
Ok(vec![RelationEdge::new(
RelationLabel::Reviews,
doc.target.reference,
)])
}
pub(crate) fn derived_status_string(root: &Path, id: u32) -> anyhow::Result<String> {
let doc = read_review(&root.join(REVIEW_DIR), id)?;
let (status, _await) = doc.derived();
Ok(status.as_str().to_string())
}
fn read_reviews(review_root: &Path) -> anyhow::Result<Vec<ReviewDoc>> {
let mut docs = Vec::new();
for id in entity::scan_ids(review_root)? {
docs.push(read_review(review_root, id)?);
}
Ok(docs)
}
fn canonical_id(id: u32) -> String {
listing::canonical_id(REVIEW_KIND.prefix, id)
}
fn edge_label(doc: &ReviewDoc) -> String {
match &doc.target.phase {
Some(p) => {
let mut s = doc.target.reference.clone();
s.push('@');
s.push_str(p);
s
}
None => doc.target.reference.clone(),
}
}
fn parse_ref(reference: &str) -> anyhow::Result<u32> {
let digits = reference
.strip_prefix("RV-")
.or_else(|| reference.strip_prefix("rv-"))
.unwrap_or(reference);
digits.parse::<u32>().with_context(|| {
format!("not a review reference: `{reference}` (expected `RV-007` or `7`)")
})
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) struct BlockerRef {
pub(crate) rv: String,
pub(crate) finding: String,
}
fn doc_unresolved_blockers(doc: &ReviewDoc) -> Vec<BlockerRef> {
if doc.derived().0 != ReviewStatus::Active {
return Vec::new();
}
doc.finding
.iter()
.filter(|f| Severity::parse(&f.severity) == Ok(Severity::Blocker))
.filter(|f| !parse_finding_status(&f.status).is_terminal())
.map(|f| BlockerRef {
rv: canonical_id(doc.id),
finding: f.id.clone(),
})
.collect()
}
pub(crate) fn unresolved_blockers_for(
root: &Path,
subject_ref: &str,
) -> anyhow::Result<Vec<BlockerRef>> {
let review_root = root.join(REVIEW_DIR);
if !review_root.is_dir() {
return Ok(Vec::new());
}
let mut blockers = Vec::new();
for doc in read_reviews(&review_root)? {
if doc.target.reference == subject_ref {
blockers.extend(doc_unresolved_blockers(&doc));
}
}
Ok(blockers)
}
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 review_root = root.join(REVIEW_DIR);
let id = parse_ref(reference)?;
let doc = read_review(&review_root, id)?;
let body = read_brief(&review_root, id)?;
let out = match format {
Format::Table => format_show(&doc, &body),
Format::Json => show_json(&doc, &body)?,
};
write!(io::stdout(), "{out}")?;
Ok(())
}
fn read_brief(review_root: &Path, id: u32) -> anyhow::Result<String> {
let name = format!("{id:03}");
let path = review_root.join(&name).join(format!("review-{name}.md"));
fs::read_to_string(&path).with_context(|| format!("Failed to read {}", path.display()))
}
fn format_show(doc: &ReviewDoc, body: &str) -> String {
let (status, awaited) = doc.derived();
let mut parts: Vec<String> = Vec::new();
parts.push(format!("{} — {}\n", canonical_id(doc.id), doc.title));
parts.push(format!(
"{} · {} · await={}\n",
doc.review.facet,
status.as_str(),
awaited.as_str()
));
parts.push(format!(
"{} ──reviews──▶ {}\n",
canonical_id(doc.id),
edge_label(doc)
));
parts.push(format!(
"findings: {} (raiser {} · responder {})\n",
doc.finding.len(),
doc.review.raiser,
doc.review.responder
));
parts.push(format!("\n{body}"));
parts.concat()
}
#[derive(Debug, Serialize)]
struct ShowJson<'a> {
#[serde(flatten)]
doc: &'a ReviewDoc,
status: &'a str,
awaiting: &'a str,
}
fn show_json(doc: &ReviewDoc, body: &str) -> anyhow::Result<String> {
let (status, awaited) = doc.derived();
let row = ShowJson {
doc,
status: status.as_str(),
awaiting: awaited.as_str(),
};
let value = serde_json::json!({ "kind": "review", "review": row, "body": body });
serde_json::to_string_pretty(&value).context("failed to serialize review show JSON")
}
type ReviewRow = (ReviewDoc, ReviewStatus, Await);
const REVIEW_COLUMNS: [Column<ReviewRow>; 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: |(_, s, a)| {
let mut cell = s.as_str().to_owned();
cell.push_str(" (await ");
cell.push_str(a.as_str());
cell.push(')');
cell
},
paint: listing::ColumnPaint::ByValue(|(_, s, _)| listing::status_hue(s.as_str())),
},
Column {
name: "facet",
header: "facet",
cell: |(d, _, _)| d.review.facet.clone(),
paint: listing::ColumnPaint::None,
},
Column {
name: "target",
header: "target",
cell: |(d, _, _)| edge_label(d),
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 REVIEW_DEFAULT: &[&str] = &["id", "status", "facet", "target", "title"];
fn key(d: &ReviewDoc) -> listing::FilterFields {
let (status, _) = d.derived();
listing::FilterFields {
canonical: canonical_id(d.id),
slug: d.slug.clone(),
title: d.title.clone(),
status: status.as_str().to_owned(),
tags: Vec::new(),
}
}
fn list_rows(root: &Path, mut args: ListArgs) -> anyhow::Result<String> {
listing::validate_statuses(&args.status, REVIEW_STATUSES)?;
let render = args.render;
let columns = args.columns.take();
let (filter, format) = listing::build(args)?;
let review_root = root.join(REVIEW_DIR);
let mut docs = listing::retain(read_reviews(&review_root)?, &filter, |_| false, key);
docs.sort_by_key(|d| d.id);
let rows: Vec<ReviewRow> = docs
.into_iter()
.map(|d| {
let (status, awaited) = d.derived();
(d, status, awaited)
})
.collect();
match format {
Format::Table => {
let sel = listing::select_columns(&REVIEW_COLUMNS, REVIEW_DEFAULT, columns.as_deref())?;
Ok(listing::render_columns(&rows, &sel, render))
}
Format::Json => listing::json_envelope("review", &json_rows(&rows)),
}
}
#[derive(Debug, Serialize)]
struct ListRow {
id: String,
status: String,
awaiting: String,
facet: String,
target: String,
title: String,
}
fn json_rows(rows: &[ReviewRow]) -> Vec<ListRow> {
rows.iter()
.map(|(d, status, awaited)| ListRow {
id: canonical_id(d.id),
status: status.as_str().to_owned(),
awaiting: awaited.as_str().to_owned(),
facet: d.review.facet.clone(),
target: edge_label(d),
title: d.title.clone(),
})
.collect()
}
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(())
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)]
struct Baton {
#[serde(default)]
awaiting: String,
#[serde(default)]
authored_hash: String,
#[serde(default)]
rounds: u32,
#[serde(default)]
contests: u32,
#[serde(default)]
handoff: Vec<String>,
}
fn state_dir(root: &Path, id: u32) -> PathBuf {
root.join(".doctrine/state/review").join(format!("{id:03}"))
}
fn baton_path(root: &Path, id: u32) -> PathBuf {
state_dir(root, id).join("baton.toml")
}
fn lock_path(root: &Path, id: u32) -> PathBuf {
state_dir(root, id).join("lock")
}
fn read_baton(root: &Path, id: u32) -> anyhow::Result<Option<Baton>> {
let path = baton_path(root, id);
match fs::read_to_string(&path) {
Ok(text) => Ok(Some(toml::from_str(&text).with_context(|| {
format!("Failed to parse baton {}", path.display())
})?)),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e).with_context(|| format!("Failed to read baton {}", path.display())),
}
}
fn write_baton(root: &Path, id: u32, baton: &Baton) -> anyhow::Result<()> {
let dir = state_dir(root, id);
fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
let body = toml::to_string(baton).context("serialize baton")?;
crate::fsutil::write_atomic(&baton_path(root, id), body.as_bytes())
}
fn reconcile_baton_fields(findings: &[FindingState], hash: &str) -> (String, String) {
let (_, awaited) = derived_status(findings);
(awaited.as_str().to_owned(), hash.to_owned())
}
struct LockGuard {
path: PathBuf,
}
impl LockGuard {
fn acquire(root: &Path, id: u32) -> anyhow::Result<Self> {
let dir = state_dir(root, id);
fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
let path = lock_path(root, id);
match crate::fsutil::create_new_file(&path) {
Ok(mut file) => {
let stamp = crate::clock::now_timestamp().unwrap_or_default();
let body = format!("pid = {}\nacquired = \"{stamp}\"\n", std::process::id());
file.write_all(body.as_bytes())
.with_context(|| format!("write lock body {}", path.display()))?;
Ok(Self { path })
}
Err(e) if e.kind() == io::ErrorKind::AlreadyExists => {
anyhow::bail!(
"{} busy (another `review` invocation holds the lock); re-run \
(a stale lock from a hard kill clears with `review unlock`)",
canonical_id(id)
)
}
Err(e) => Err(e).with_context(|| format!("acquire lock {}", path.display())),
}
}
}
impl Drop for LockGuard {
fn drop(&mut self) {
let _ignored = fs::remove_file(&self.path);
}
}
fn finding_states_of(doc: &ReviewDoc) -> Vec<FindingState> {
doc.finding
.iter()
.map(|f| FindingState {
status: parse_finding_status(&f.status),
})
.collect()
}
fn read_authored(root: &Path, id: u32) -> anyhow::Result<(String, ReviewDoc)> {
let name = format!("{id:03}");
let path = root
.join(REVIEW_DIR)
.join(&name)
.join(format!("review-{name}.toml"));
let text = fs::read_to_string(&path)
.with_context(|| format!("review {name} not found at {}", path.display()))?;
let doc: ReviewDoc =
toml::from_str(&text).with_context(|| format!("Failed to parse {}", path.display()))?;
Ok((text, doc))
}
fn authored_path(root: &Path, id: u32) -> PathBuf {
let name = format!("{id:03}");
root.join(REVIEW_DIR)
.join(&name)
.join(format!("review-{name}.toml"))
}
fn resolve_review_root(path: Option<PathBuf>) -> anyhow::Result<PathBuf> {
let root = crate::root::find(path, &crate::root::default_markers())?;
if crate::worktree::is_linked_worktree(&root).unwrap_or(false) {
anyhow::bail!(
"review verbs are not supported on a worktree fork (IMP-024): the turn \
baton lives in the parent tree's gitignored state, which a fork cannot \
co-write. Run `review` from the parent tree."
);
}
Ok(root)
}
type MidTurnHook<'a> = &'a dyn Fn();
fn with_turn<F>(root: &Path, id: u32, verb: Verb, role: Role, f: F) -> anyhow::Result<()>
where
F: FnOnce(&mut toml_edit::DocumentMut, &[FindingRow]) -> anyhow::Result<()>,
{
with_turn_hooked(root, id, verb, role, &|| {}, f)
}
fn with_turn_hooked<F>(
root: &Path,
id: u32,
verb: Verb,
role: Role,
mid_turn: MidTurnHook<'_>,
f: F,
) -> anyhow::Result<()>
where
F: FnOnce(&mut toml_edit::DocumentMut, &[FindingRow]) -> anyhow::Result<()>,
{
let _lock = LockGuard::acquire(root, id)?;
let (snapshot, doc) = read_authored(root, id)?;
let snapshot_hash = crate::git::sha256(snapshot.as_bytes());
if let Some(baton) = read_baton(root, id)?.filter(|b| b.authored_hash != snapshot_hash) {
let (awaiting, hash) = reconcile_baton_fields(&finding_states_of(&doc), &snapshot_hash);
let healed = Baton {
awaiting,
authored_hash: hash,
..baton
};
write_baton(root, id, &healed)?;
anyhow::bail!(
"{} ledger changed underneath the baton — re-run (the baton has been \
refreshed from the authored ledger)",
canonical_id(id)
);
}
if role != verb.required_role() {
anyhow::bail!(
"`{}` is the {}'s verb; --as {} cannot assert it",
verb.as_str(),
verb.required_role().as_str(),
role.as_str()
);
}
let mut document = snapshot
.parse::<toml_edit::DocumentMut>()
.with_context(|| format!("Failed to parse {}", authored_path(root, id).display()))?;
f(&mut document, &doc.finding)?;
mid_turn();
let current = fs::read_to_string(authored_path(root, id))
.with_context(|| format!("re-read {}", authored_path(root, id).display()))?;
if crate::git::sha256(current.as_bytes()) != snapshot_hash {
anyhow::bail!(
"{} ledger changed underneath this turn — re-run (a hand-edit landed \
mid-turn; nothing was written, no clobber)",
canonical_id(id)
);
}
let new_body = document.to_string();
crate::fsutil::write_atomic(&authored_path(root, id), new_body.as_bytes())?;
let new_hash = crate::git::sha256(new_body.as_bytes());
let new_doc: ReviewDoc = toml::from_str(&new_body)
.with_context(|| format!("re-parse {}", authored_path(root, id).display()))?;
let (awaiting, hash) = reconcile_baton_fields(&finding_states_of(&new_doc), &new_hash);
let prior = read_baton(root, id)?.unwrap_or_default();
let contests = prior.contests + u32::from(verb == Verb::Contest);
let baton = Baton {
awaiting,
authored_hash: hash,
rounds: prior.rounds + 1,
contests,
handoff: prior.handoff,
};
write_baton(root, id, &baton)?;
Ok(())
}
fn finding_table_mut<'a>(
doc: &'a mut toml_edit::DocumentMut,
finding_id: &str,
) -> anyhow::Result<&'a mut toml_edit::Table> {
let array = doc
.get_mut("finding")
.and_then(toml_edit::Item::as_array_of_tables_mut)
.ok_or_else(|| anyhow::anyhow!("ledger has no findings"))?;
array
.iter_mut()
.find(|t| t.get("id").and_then(toml_edit::Item::as_str) == Some(finding_id))
.ok_or_else(|| anyhow::anyhow!("no finding `{finding_id}` in the ledger"))
}
fn apply_transition(
table: &mut toml_edit::Table,
new_status: FindingStatus,
disposition: Option<&str>,
response: Option<&str>,
) {
table.insert("status", toml_edit::value(new_status.as_str()));
if let Some(d) = disposition {
table.insert("disposition", toml_edit::value(d));
}
if let Some(r) = response {
table.insert("response", toml_edit::value(r));
}
}
fn append_finding(
doc: &mut toml_edit::DocumentMut,
existing: &[FindingRow],
severity: Severity,
title: &str,
detail: &str,
) -> String {
let next = next_finding_id(existing);
let mut row = toml_edit::Table::new();
row.insert("id", toml_edit::value(&next));
row.insert("status", toml_edit::value(FindingStatus::Open.as_str()));
row.insert("severity", toml_edit::value(severity.as_str()));
row.insert("title", toml_edit::value(title));
row.insert("detail", toml_edit::value(detail));
if let Some(array) = doc
.entry("finding")
.or_insert_with(|| toml_edit::Item::ArrayOfTables(toml_edit::ArrayOfTables::new()))
.as_array_of_tables_mut()
{
array.push(row);
}
next
}
fn next_finding_id(existing: &[FindingRow]) -> String {
let max = existing
.iter()
.filter_map(|f| f.id.strip_prefix("F-"))
.filter_map(|n| n.parse::<u32>().ok())
.max()
.unwrap_or(0);
format!("F-{}", max + 1)
}
fn finding_status_of(existing: &[FindingRow], finding_id: &str) -> anyhow::Result<FindingStatus> {
let row = existing
.iter()
.find(|f| f.id == finding_id)
.ok_or_else(|| anyhow::anyhow!("no finding `{finding_id}` in the ledger"))?;
Ok(parse_finding_status(&row.status))
}
pub(crate) struct RaiseArgs {
pub(crate) reference: String,
pub(crate) severity: Severity,
pub(crate) title: String,
pub(crate) detail: String,
}
pub(crate) fn run_raise(path: Option<PathBuf>, args: &RaiseArgs, role: Role) -> anyhow::Result<()> {
let root = resolve_review_root(path)?;
let id = parse_ref(&args.reference)?;
with_turn(&root, id, Verb::Raise, role, |doc, existing| {
if !can(Verb::Raise, None, role) {
anyhow::bail!("raise is the raiser's verb (--as raiser)");
}
let new_id = append_finding(doc, existing, args.severity, &args.title, &args.detail);
writeln!(io::stdout(), "Raised {} on {}", new_id, canonical_id(id))?;
Ok(())
})
}
pub(crate) struct DisposeArgs {
pub(crate) reference: String,
pub(crate) finding: String,
pub(crate) disposition: String,
pub(crate) response: String,
}
pub(crate) fn run_dispose(
path: Option<PathBuf>,
args: &DisposeArgs,
role: Role,
) -> anyhow::Result<()> {
let root = resolve_review_root(path)?;
let id = parse_ref(&args.reference)?;
with_turn(&root, id, Verb::Dispose, role, |doc, existing| {
let from = finding_status_of(existing, &args.finding)?;
gate(Verb::Dispose, from, role, &args.finding)?;
let table = finding_table_mut(doc, &args.finding)?;
apply_transition(
table,
FindingStatus::Answered,
Some(&args.disposition),
Some(&args.response),
);
writeln!(
io::stdout(),
"Disposed {} on {} (answered)",
args.finding,
canonical_id(id)
)?;
Ok(())
})
}
pub(crate) fn run_verify(
path: Option<PathBuf>,
reference: &str,
finding: &str,
note: Option<&str>,
role: Role,
) -> anyhow::Result<()> {
let root = resolve_review_root(path)?;
let id = parse_ref(reference)?;
run_raiser_transition(
&root,
id,
Verb::Verify,
FindingStatus::Verified,
finding,
note,
role,
)
}
pub(crate) fn run_contest(
path: Option<PathBuf>,
reference: &str,
finding: &str,
note: Option<&str>,
role: Role,
) -> anyhow::Result<()> {
let root = resolve_review_root(path)?;
let id = parse_ref(reference)?;
run_raiser_transition(
&root,
id,
Verb::Contest,
FindingStatus::Contested,
finding,
note,
role,
)
}
pub(crate) fn run_withdraw(
path: Option<PathBuf>,
reference: &str,
finding: &str,
role: Role,
) -> anyhow::Result<()> {
let root = resolve_review_root(path)?;
let id = parse_ref(reference)?;
run_raiser_transition(
&root,
id,
Verb::Withdraw,
FindingStatus::Withdrawn,
finding,
None,
role,
)
}
fn run_raiser_transition(
root: &Path,
id: u32,
verb: Verb,
to: FindingStatus,
finding: &str,
note: Option<&str>,
role: Role,
) -> anyhow::Result<()> {
with_turn(root, id, verb, role, |doc, existing| {
let from = finding_status_of(existing, finding)?;
gate(verb, from, role, finding)?;
let table = finding_table_mut(doc, finding)?;
apply_transition(table, to, None, None);
writeln!(
io::stdout(),
"{} {} on {} ({})",
verb_past(verb),
finding,
canonical_id(id),
to.as_str()
)?;
Ok(())
})?;
if let (Some(n), Some(mut baton)) = (note, read_baton(root, id)?) {
baton.handoff.push(format!("{}: {n}", verb.as_str()));
write_baton(root, id, &baton)?;
}
Ok(())
}
fn gate(verb: Verb, from: FindingStatus, role: Role, finding: &str) -> anyhow::Result<()> {
if !can(verb, Some(from), role) {
anyhow::bail!(
"`{}` cannot fire on {} (status `{}`, --as {}): out of turn (design §5)",
verb.as_str(),
finding,
from.as_str(),
role.as_str()
);
}
Ok(())
}
fn verb_past(verb: Verb) -> &'static str {
match verb {
Verb::Raise => "Raised",
Verb::Dispose => "Disposed",
Verb::Verify => "Verified",
Verb::Contest => "Contested",
Verb::Withdraw => "Withdrew",
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)]
struct CacheArea {
name: String,
#[serde(default)]
purpose: String,
#[serde(default)]
paths: Vec<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)]
struct CacheNote {
text: String,
}
#[derive(Debug, Clone, PartialEq, Eq, Default, Deserialize, Serialize)]
struct Cache {
#[serde(default, rename = "area")]
areas: Vec<CacheArea>,
#[serde(default, rename = "invariant")]
invariants: Vec<CacheNote>,
#[serde(default, rename = "risk")]
risks: Vec<CacheNote>,
#[serde(default)]
hashes: BTreeMap<String, String>,
}
impl Cache {
fn tracked_paths(&self) -> Vec<String> {
let mut set: std::collections::BTreeSet<String> = std::collections::BTreeSet::new();
for area in &self.areas {
for path in &area.paths {
set.insert(path.clone());
}
}
set.into_iter().collect()
}
fn baseline(&self) -> ContentSet {
ContentSet::from_hashes(self.hashes.clone())
}
}
fn cache_path(root: &Path, id: u32) -> PathBuf {
state_dir(root, id).join("cache.toml")
}
fn read_cache(root: &Path, id: u32) -> anyhow::Result<Option<Cache>> {
let path = cache_path(root, id);
match fs::read_to_string(&path) {
Ok(text) => Ok(Some(toml::from_str(&text).with_context(|| {
format!("Failed to parse warm-cache {}", path.display())
})?)),
Err(e) if e.kind() == io::ErrorKind::NotFound => Ok(None),
Err(e) => Err(e).with_context(|| format!("Failed to read {}", path.display())),
}
}
fn write_cache(root: &Path, id: u32, cache: &Cache) -> anyhow::Result<()> {
let dir = state_dir(root, id);
fs::create_dir_all(&dir).with_context(|| format!("create {}", dir.display()))?;
let body = toml::to_string(cache).context("serialize warm-cache")?;
crate::fsutil::write_atomic(&cache_path(root, id), body.as_bytes())
}
fn cache_staleness(root: &Path, cache: &Cache) -> anyhow::Result<CacheVerdict> {
let live = contentset::compute(root, &cache.tracked_paths())
.context("hash the warm-cache's tracked paths")?;
let drift = cache.baseline().diff(&live);
let mut drifted: Vec<String> = Vec::new();
drifted.extend(drift.changed);
drifted.extend(drift.removed);
drifted.extend(drift.added);
if drifted.is_empty() {
Ok(CacheVerdict::Current)
} else {
drifted.sort();
drifted.dedup();
Ok(CacheVerdict::Stale(drifted))
}
}
enum CacheVerdict {
Current,
Stale(Vec<String>),
}
pub(crate) struct PrimeArgs {
pub(crate) reference: String,
pub(crate) seed: bool,
pub(crate) from: Option<PathBuf>,
}
pub(crate) fn run_prime(path: Option<PathBuf>, args: &PrimeArgs) -> anyhow::Result<()> {
let root = resolve_review_root(path)?;
let id = parse_ref(&args.reference)?;
let _ = read_authored(&root, id)?;
if args.seed {
return emit_seed_candidates(&root, id);
}
let supplied = if let Some(file) = &args.from {
fs::read_to_string(file).with_context(|| format!("read domain_map {}", file.display()))?
} else {
let mut buf = String::new();
io::stdin()
.read_to_string(&mut buf)
.context("read domain_map from stdin")?;
buf
};
let mut cache: Cache = toml::from_str(&supplied)
.context("parse the supplied domain_map (expected [[area]]/[[invariant]]/[[risk]] TOML)")?;
validate_domain_map(&cache)?;
let _lock = LockGuard::acquire(&root, id)?;
let baseline = contentset::compute(&root, &cache.tracked_paths())
.context("hash the curated domain_map paths")?;
cache.hashes = baseline.hashes().clone();
write_cache(&root, id, &cache)?;
writeln!(
io::stdout(),
"{} primed — {} area(s), {} tracked path(s), {} invariant(s), {} risk(s)",
canonical_id(id),
cache.areas.len(),
cache.tracked_paths().len(),
cache.invariants.len(),
cache.risks.len(),
)?;
Ok(())
}
fn validate_domain_map(cache: &Cache) -> anyhow::Result<()> {
if cache.areas.is_empty() {
anyhow::bail!(
"domain_map has no [[area]] — a primed cache needs at least one curated area"
);
}
for area in &cache.areas {
if area.name.trim().is_empty() {
anyhow::bail!("an [[area]] is missing a `name`");
}
if area.paths.is_empty() {
anyhow::bail!(
"area `{}` has no `paths` — every area must track at least one path",
area.name
);
}
for path in &area.paths {
if Path::new(path).is_absolute() || path.contains("..") {
anyhow::bail!(
"area `{}` path `{path}` is not root-relative (no absolute paths or `..`)",
area.name
);
}
}
}
Ok(())
}
fn emit_seed_candidates(root: &Path, id: u32) -> anyhow::Result<()> {
let porcelain = crate::git::git_text(root, &["status", "--porcelain", "--untracked-files=all"])
.context("git status for prime --seed candidates")?;
let mut paths: Vec<String> = Vec::new();
for line in porcelain.lines() {
let rest = line.get(2..).unwrap_or("").trim();
let candidate = rest.rsplit(" -> ").next().unwrap_or(rest);
if !candidate.is_empty() {
paths.push(candidate.to_owned());
}
}
paths.sort();
paths.dedup();
let mut out = io::stdout();
writeln!(
out,
"# {} prime --seed: {} git-changed candidate(s) — curate into a domain_map (not authority)",
canonical_id(id),
paths.len()
)?;
for path in &paths {
writeln!(out, "{path}")?;
}
Ok(())
}
pub(crate) fn run_status(path: Option<PathBuf>, reference: &str) -> anyhow::Result<()> {
let root = resolve_review_root(path)?;
let id = parse_ref(reference)?;
let _lock = LockGuard::acquire(&root, id)?;
let (text, doc) = read_authored(&root, id)?;
let hash = crate::git::sha256(text.as_bytes());
let states = finding_states_of(&doc);
let (status, awaited) = derived_status(&states);
let (awaiting, authored_hash) = reconcile_baton_fields(&states, &hash);
let prior = read_baton(&root, id)?.unwrap_or_default();
let rebuilt = Baton {
awaiting,
authored_hash,
..prior
};
write_baton(&root, id, &rebuilt)?;
let mut out = io::stdout();
writeln!(
out,
"{} — {} · await={} · findings {} · rounds {}",
canonical_id(id),
status.as_str(),
awaited.as_str(),
doc.finding.len(),
rebuilt.rounds
)?;
if let Some(cache) = read_cache(&root, id)? {
match cache_staleness(&root, &cache)? {
CacheVerdict::Current => writeln!(out, "cache: current")?,
CacheVerdict::Stale(paths) => {
writeln!(out, "cache: stale ({})", paths.join(", "))?;
}
}
}
Ok(())
}
pub(crate) fn run_unlock(path: Option<PathBuf>, reference: &str) -> anyhow::Result<()> {
let root = resolve_review_root(path)?;
let id = parse_ref(reference)?;
let lock = lock_path(&root, id);
match fs::read_to_string(&lock) {
Ok(body) => {
writeln!(
io::stdout(),
"Removing stale lock for {}:",
canonical_id(id)
)?;
for line in body.lines() {
writeln!(io::stdout(), " {line}")?;
}
fs::remove_file(&lock).with_context(|| format!("remove lock {}", lock.display()))?;
Ok(())
}
Err(e) if e.kind() == io::ErrorKind::NotFound => {
writeln!(io::stdout(), "{} is not locked", canonical_id(id))?;
Ok(())
}
Err(e) => Err(e).with_context(|| format!("read lock {}", lock.display())),
}
}
pub(crate) fn parse_role(token: Option<&str>, default: Role) -> anyhow::Result<Role> {
match token {
None => Ok(default),
Some("raiser") => Ok(Role::Raiser),
Some("responder") => Ok(Role::Responder),
Some(other) => anyhow::bail!("unknown --as role `{other}` (known: raiser, responder)"),
}
}
#[cfg(test)]
mod tests {
use super::*;
fn states(statuses: &[FindingStatus]) -> Vec<FindingState> {
statuses
.iter()
.map(|&status| FindingState { status })
.collect()
}
#[test]
fn derived_status_empty_is_active_raiser() {
assert_eq!(derived_status(&[]), (ReviewStatus::Active, Await::Raiser));
}
#[test]
fn derived_status_any_open_or_contested_is_active_responder() {
assert_eq!(
derived_status(&states(&[FindingStatus::Open])),
(ReviewStatus::Active, Await::Responder)
);
assert_eq!(
derived_status(&states(&[FindingStatus::Contested])),
(ReviewStatus::Active, Await::Responder)
);
assert_eq!(
derived_status(&states(&[FindingStatus::Answered, FindingStatus::Open])),
(ReviewStatus::Active, Await::Responder)
);
}
#[test]
fn derived_status_answered_and_none_open_is_active_raiser() {
assert_eq!(
derived_status(&states(&[FindingStatus::Answered])),
(ReviewStatus::Active, Await::Raiser)
);
assert_eq!(
derived_status(&states(&[FindingStatus::Answered, FindingStatus::Verified])),
(ReviewStatus::Active, Await::Raiser)
);
}
#[test]
fn derived_status_all_terminal_is_done_none() {
assert_eq!(
derived_status(&states(&[
FindingStatus::Verified,
FindingStatus::Withdrawn
])),
(ReviewStatus::Done, Await::None)
);
assert_eq!(
derived_status(&states(&[FindingStatus::Verified])),
(ReviewStatus::Done, Await::None)
);
assert_eq!(
derived_status(&states(&[FindingStatus::Withdrawn])),
(ReviewStatus::Done, Await::None)
);
}
#[test]
fn derived_status_total_over_enum() {
let all = [
FindingStatus::Open,
FindingStatus::Answered,
FindingStatus::Contested,
FindingStatus::Verified,
FindingStatus::Withdrawn,
];
for &a in &all {
let _single = derived_status(&states(&[a]));
for &b in &all {
let (status, awaited) = derived_status(&states(&[a, b]));
assert_eq!(
status == ReviewStatus::Done,
awaited == Await::None,
"Done iff await=None for [{}, {}]",
a.as_str(),
b.as_str()
);
}
}
}
#[test]
fn can_valid_single_owner_edges_pass() {
use FindingStatus::{Answered, Contested, Open};
assert!(can(Verb::Raise, None, Role::Raiser));
assert!(can(Verb::Dispose, Some(Open), Role::Responder));
assert!(can(Verb::Dispose, Some(Contested), Role::Responder));
assert!(can(Verb::Verify, Some(Answered), Role::Raiser));
assert!(can(Verb::Contest, Some(Answered), Role::Raiser));
assert!(can(Verb::Withdraw, Some(Open), Role::Raiser));
assert!(can(Verb::Withdraw, Some(Answered), Role::Raiser));
}
#[test]
fn can_wrong_role_refused() {
use FindingStatus::{Answered, Open};
assert!(!can(Verb::Dispose, Some(Open), Role::Raiser));
assert!(!can(Verb::Verify, Some(Answered), Role::Responder));
assert!(!can(Verb::Raise, None, Role::Responder));
}
#[test]
fn can_wrong_from_state_refused() {
use FindingStatus::{Answered, Open, Verified, Withdrawn};
assert!(!can(Verb::Dispose, Some(Answered), Role::Responder));
assert!(!can(Verb::Verify, Some(Open), Role::Raiser));
assert!(!can(Verb::Contest, Some(Open), Role::Raiser));
assert!(!can(
Verb::Withdraw,
Some(FindingStatus::Contested),
Role::Raiser
));
assert!(!can(Verb::Verify, Some(Verified), Role::Raiser));
assert!(!can(Verb::Dispose, Some(Withdrawn), Role::Responder));
assert!(!can(Verb::Raise, Some(Open), Role::Raiser));
}
#[test]
fn facet_known_set_matches_variants() {
let from_variants: Vec<&str> = [
Facet::Scope,
Facet::Design,
Facet::Plan,
Facet::PhasePlan,
Facet::Implementation,
Facet::CodeReview,
Facet::Reconciliation,
]
.iter()
.map(|f| f.as_str())
.collect();
assert_eq!(from_variants, FACETS.to_vec());
assert!(!FACETS.contains(&"drift"));
assert_eq!(FACETS.len(), 7);
}
#[test]
fn finding_status_known_set_matches_variants() {
let from_variants: Vec<&str> = [
FindingStatus::Open,
FindingStatus::Answered,
FindingStatus::Contested,
FindingStatus::Verified,
FindingStatus::Withdrawn,
]
.iter()
.map(|s| s.as_str())
.collect();
assert_eq!(from_variants, FINDING_STATUSES.to_vec());
}
#[test]
fn severity_known_set_matches_variants() {
let from_variants: Vec<&str> = [
Severity::Blocker,
Severity::Major,
Severity::Minor,
Severity::Nit,
]
.iter()
.map(|s| s.as_str())
.collect();
assert_eq!(from_variants, SEVERITIES.to_vec());
}
#[test]
fn role_known_set_matches_variants() {
let from_variants: Vec<&str> = [Role::Raiser, Role::Responder]
.iter()
.map(|r| r.as_str())
.collect();
assert_eq!(from_variants, ROLES.to_vec());
}
#[test]
fn review_status_known_set_matches_variants() {
let from_variants: Vec<&str> = [ReviewStatus::Active, ReviewStatus::Done]
.iter()
.map(|s| s.as_str())
.collect();
assert_eq!(from_variants, REVIEW_STATUSES.to_vec());
}
#[test]
fn await_str_forms() {
assert_eq!(Await::Raiser.as_str(), "raiser");
assert_eq!(Await::Responder.as_str(), "responder");
assert_eq!(Await::None.as_str(), "none");
}
#[test]
fn verb_str_and_required_role() {
assert_eq!(Verb::Raise.as_str(), "raise");
assert_eq!(Verb::Dispose.as_str(), "dispose");
assert_eq!(Verb::Verify.as_str(), "verify");
assert_eq!(Verb::Contest.as_str(), "contest");
assert_eq!(Verb::Withdraw.as_str(), "withdraw");
assert_eq!(Verb::Raise.required_role(), Role::Raiser);
assert_eq!(Verb::Verify.required_role(), Role::Raiser);
assert_eq!(Verb::Contest.required_role(), Role::Raiser);
assert_eq!(Verb::Withdraw.required_role(), Role::Raiser);
assert_eq!(Verb::Dispose.required_role(), Role::Responder);
}
#[test]
fn finding_status_terminal_set() {
assert!(FindingStatus::Verified.is_terminal());
assert!(FindingStatus::Withdrawn.is_terminal());
assert!(!FindingStatus::Open.is_terminal());
assert!(!FindingStatus::Answered.is_terminal());
assert!(!FindingStatus::Contested.is_terminal());
}
#[test]
fn render_finding_escapes_hostile_free_text() {
let finding = Finding {
id: "F-1".to_owned(),
status: FindingStatus::Open,
severity: Severity::Major,
title: "a\"b\\c\nd]e".to_owned(),
detail: "plain".to_owned(),
disposition: None,
response: None,
};
let rendered = render_finding(&finding);
let parsed: toml::Value = toml::from_str(&rendered).unwrap();
let finding_tbl = parsed["finding"].as_array().unwrap()[0].as_table().unwrap();
assert_eq!(finding_tbl["title"].as_str().unwrap(), "a\"b\\c\nd]e");
assert_eq!(finding_tbl["id"].as_str().unwrap(), "F-1");
assert_eq!(finding_tbl["status"].as_str().unwrap(), "open");
assert_eq!(finding_tbl["severity"].as_str().unwrap(), "major");
}
#[test]
fn render_finding_emits_responder_fields_when_present() {
let finding = Finding {
id: "F-2".to_owned(),
status: FindingStatus::Answered,
severity: Severity::Nit,
title: "t".to_owned(),
detail: "d".to_owned(),
disposition: Some("fixed".to_owned()),
response: Some("done in r\"123".to_owned()),
};
let rendered = render_finding(&finding);
let parsed: toml::Value = toml::from_str(&rendered).unwrap();
let tbl = parsed["finding"].as_array().unwrap()[0].as_table().unwrap();
assert_eq!(tbl["disposition"].as_str().unwrap(), "fixed");
assert_eq!(tbl["response"].as_str().unwrap(), "done in r\"123");
}
use std::path::Path;
fn plant_slice_target(root: &Path, id: u32) {
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 = \"t\"\ntitle = \"T\"\nstatus = \"proposed\"\n"),
)
.unwrap();
}
fn meta(facet: &str) -> ReviewMeta {
ReviewMeta {
facet: facet.to_owned(),
raiser: "rev".to_owned(),
responder: "auth".to_owned(),
}
}
#[test]
fn render_review_toml_round_trips_without_a_stored_status() {
let target = Target {
reference: "SL-024".to_owned(),
phase: Some("PHASE-03".to_owned()),
};
let body = render_review_toml(
7,
"design-review",
"Design review",
&meta("design"),
&target,
)
.unwrap();
let value: toml::Value = toml::from_str(&body).unwrap();
assert!(
value.get("status").is_none(),
"ledger stores no status: {body}"
);
let doc: ReviewDoc = toml::from_str(&body).unwrap();
assert_eq!(doc.id, 7);
assert_eq!(doc.review.facet, "design");
assert_eq!(doc.target.reference, "SL-024");
assert_eq!(doc.target.phase.as_deref(), Some("PHASE-03"));
assert!(doc.finding.is_empty(), "fresh ledger has no findings");
}
#[test]
fn render_review_toml_escapes_a_hostile_title() {
let target = Target {
reference: "SL-001".to_owned(),
phase: None,
};
let hostile = "a\"b\\c\nd]e";
let body = render_review_toml(1, "s", hostile, &meta("scope"), &target).unwrap();
let doc: ReviewDoc = toml::from_str(&body).unwrap();
assert_eq!(doc.title, hostile);
assert!(doc.target.phase.is_none());
}
#[test]
fn show_renders_empty_ledger_active_raiser_and_the_edge() {
let doc = ReviewDoc {
id: 3,
slug: "s".to_owned(),
title: "Design review of SL-024".to_owned(),
review: meta("design"),
target: Target {
reference: "SL-024".to_owned(),
phase: None,
},
finding: Vec::new(),
};
let out = format_show(&doc, "## Brief\n");
assert!(out.contains("RV-003 — Design review of SL-024"), "{out}");
assert!(out.contains("active · await=raiser"), "{out}");
assert!(out.contains("RV-003 ──reviews──▶ SL-024"), "edge: {out}");
assert!(out.contains("findings: 0"), "{out}");
}
#[test]
fn list_renders_derived_status_facet_and_edge() {
let doc = ReviewDoc {
id: 5,
slug: "s".to_owned(),
title: "Plan review".to_owned(),
review: meta("plan"),
target: Target {
reference: "SL-009".to_owned(),
phase: Some("PHASE-02".to_owned()),
},
finding: Vec::new(),
};
let (status, awaited) = doc.derived();
let rows = vec![(doc, status, awaited)];
let sel = listing::select_columns(&REVIEW_COLUMNS, REVIEW_DEFAULT, None).unwrap();
let out = listing::render_columns(&rows, &sel, listing::RenderOpts::default());
let lines: Vec<&str> = out.lines().collect();
assert!(lines[0].starts_with("id"), "header: {:?}", lines[0]);
assert!(lines[1].starts_with("RV-005"), "{:?}", lines[1]);
assert!(lines[1].contains("active (await raiser)"), "{:?}", lines[1]);
assert!(lines[1].contains("plan"), "{:?}", lines[1]);
assert!(lines[1].contains("SL-009@PHASE-02"), "{:?}", lines[1]);
}
#[test]
fn derived_status_reads_findings_not_a_stored_status() {
let mut doc = ReviewDoc {
id: 1,
slug: "s".to_owned(),
title: "t".to_owned(),
review: meta("design"),
target: Target {
reference: "SL-001".to_owned(),
phase: None,
},
finding: vec![FindingRow {
id: "F-1".to_owned(),
status: "open".to_owned(),
severity: "major".to_owned(),
title: "t".to_owned(),
detail: "d".to_owned(),
disposition: None,
response: None,
}],
};
assert_eq!(doc.derived(), (ReviewStatus::Active, Await::Responder));
doc.finding[0].status = "verified".to_owned();
assert_eq!(doc.derived(), (ReviewStatus::Done, Await::None));
}
fn new_args(facet: Facet, target: &str) -> NewArgs {
NewArgs {
facet,
target: target.to_owned(),
phase: None,
title: None,
raiser: None,
responder: None,
}
}
#[test]
fn run_new_creates_an_empty_ledger_rv_against_a_real_target() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
plant_slice_target(root, 24);
run_new(Some(root.to_path_buf()), &new_args(Facet::Design, "SL-024")).unwrap();
let review_root = root.join(REVIEW_DIR);
let doc = read_review(&review_root, 1).unwrap();
assert_eq!(doc.id, 1);
assert_eq!(doc.target.reference, "SL-024");
assert!(doc.finding.is_empty());
assert_eq!(doc.derived(), (ReviewStatus::Active, Await::Raiser));
let brief = read_brief(&review_root, 1).unwrap();
assert!(brief.contains("## Brief"), "brief seeded: {brief}");
assert!(
std::fs::symlink_metadata(review_root.join("001-design-review-of-sl-024"))
.map(|m| m.file_type().is_symlink())
.unwrap_or(false),
"alias symlink planted"
);
}
#[test]
fn run_new_refuses_a_dangling_target_and_mints_nothing() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let err =
run_new(Some(root.to_path_buf()), &new_args(Facet::Design, "SL-099")).unwrap_err();
assert!(
err.to_string().contains("does not resolve"),
"dangling ref refused: {err}"
);
assert!(
entity::scan_ids(&root.join(REVIEW_DIR)).unwrap().is_empty(),
"no RV minted on a refused target"
);
}
#[test]
fn run_new_refuses_an_unknown_prefix_target() {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
let err = run_new(Some(root.to_path_buf()), &new_args(Facet::Scope, "ZZ-001")).unwrap_err();
assert!(
err.to_string().contains("unknown kind prefix"),
"unknown prefix refused: {err}"
);
}
#[test]
fn facet_parse_accepts_the_seven_and_rejects_drift() {
assert_eq!(Facet::parse("phase-plan").unwrap(), Facet::PhasePlan);
assert_eq!(Facet::parse("code-review").unwrap(), Facet::CodeReview);
assert!(
Facet::parse("drift").is_err(),
"drift is not a facet (D-C11)"
);
let err = Facet::parse("bogus").unwrap_err();
assert!(err.contains("unknown facet"), "{err}");
}
fn fixture_rv() -> tempfile::TempDir {
let tmp = tempfile::tempdir().unwrap();
let root = tmp.path();
plant_slice_target(root, 1);
run_new(Some(root.to_path_buf()), &new_args(Facet::Design, "SL-001")).unwrap();
tmp
}
fn raise_args(reference: &str, sev: Severity, title: &str) -> RaiseArgs {
RaiseArgs {
reference: reference.to_owned(),
severity: sev,
title: title.to_owned(),
detail: "d".to_owned(),
}
}
fn dispose_args(reference: &str, finding: &str) -> DisposeArgs {
DisposeArgs {
reference: reference.to_owned(),
finding: finding.to_owned(),
disposition: "fixed".to_owned(),
response: "done".to_owned(),
}
}
fn read_doc(root: &Path, id: u32) -> ReviewDoc {
read_review(&root.join(REVIEW_DIR), id).unwrap()
}
#[test]
fn lifecycle_raise_dispose_verify() {
let tmp = fixture_rv();
let root = tmp.path();
run_raise(
Some(root.to_path_buf()),
&raise_args("RV-001", Severity::Major, "t"),
Role::Raiser,
)
.unwrap();
let doc = read_doc(root, 1);
assert_eq!(doc.finding.len(), 1);
assert_eq!(doc.finding[0].id, "F-1");
assert_eq!(doc.finding[0].status, "open");
assert_eq!(read_baton(root, 1).unwrap().unwrap().awaiting, "responder");
run_dispose(
Some(root.to_path_buf()),
&dispose_args("RV-001", "F-1"),
Role::Responder,
)
.unwrap();
let doc = read_doc(root, 1);
assert_eq!(doc.finding[0].status, "answered");
assert_eq!(doc.finding[0].disposition.as_deref(), Some("fixed"));
assert_eq!(doc.finding[0].response.as_deref(), Some("done"));
assert_eq!(read_baton(root, 1).unwrap().unwrap().awaiting, "raiser");
run_verify(
Some(root.to_path_buf()),
"RV-001",
"F-1",
None,
Role::Raiser,
)
.unwrap();
let doc = read_doc(root, 1);
assert_eq!(doc.finding[0].status, "verified");
assert_eq!(read_baton(root, 1).unwrap().unwrap().awaiting, "none");
}
#[test]
fn vt1_raiser_fields_immutable_responder_fields_mutable() {
let tmp = fixture_rv();
let root = tmp.path();
run_raise(
Some(root.to_path_buf()),
&RaiseArgs {
reference: "RV-001".to_owned(),
severity: Severity::Blocker,
title: "orig-title".to_owned(),
detail: "orig-detail".to_owned(),
},
Role::Raiser,
)
.unwrap();
run_dispose(
Some(root.to_path_buf()),
&dispose_args("RV-001", "F-1"),
Role::Responder,
)
.unwrap();
let f = &read_doc(root, 1).finding[0];
assert_eq!(f.id, "F-1");
assert_eq!(f.title, "orig-title");
assert_eq!(f.detail, "orig-detail");
assert_eq!(f.severity, "blocker");
assert_eq!(f.disposition.as_deref(), Some("fixed"));
assert_eq!(f.response.as_deref(), Some("done"));
assert_eq!(f.status, "answered");
}
#[test]
fn vt2_finding_ids_are_append_only() {
let tmp = fixture_rv();
let root = tmp.path();
for n in ["a", "b", "c"] {
run_raise(
Some(root.to_path_buf()),
&raise_args("RV-001", Severity::Minor, n),
Role::Raiser,
)
.unwrap();
}
let ids: Vec<String> = read_doc(root, 1)
.finding
.iter()
.map(|f| f.id.clone())
.collect();
assert_eq!(ids, ["F-1", "F-2", "F-3"]);
let rows = vec![FindingRow {
id: "F-7".to_owned(),
status: "open".to_owned(),
severity: "nit".to_owned(),
title: "t".to_owned(),
detail: "d".to_owned(),
disposition: None,
response: None,
}];
assert_eq!(next_finding_id(&rows), "F-8");
assert_eq!(next_finding_id(&[]), "F-1");
}
#[test]
fn vt3_transitions_are_edit_preserving() {
let tmp = fixture_rv();
let root = tmp.path();
run_raise(
Some(root.to_path_buf()),
&raise_args("RV-001", Severity::Major, "t"),
Role::Raiser,
)
.unwrap();
let path = authored_path(root, 1);
let mut text = fs::read_to_string(&path).unwrap();
text.push_str("\n# a hand comment\nunknown_key = \"keepme\"\n");
fs::write(&path, &text).unwrap();
run_status(Some(root.to_path_buf()), "RV-001").unwrap();
run_dispose(
Some(root.to_path_buf()),
&dispose_args("RV-001", "F-1"),
Role::Responder,
)
.unwrap();
let after = fs::read_to_string(&path).unwrap();
assert!(
after.contains("# a hand comment"),
"comment survived: {after}"
);
assert!(
after.contains("unknown_key = \"keepme\""),
"unknown key survived: {after}"
);
assert_eq!(read_doc(root, 1).finding[0].status, "answered");
}
#[test]
fn vt4_hostile_free_text_round_trips() {
let tmp = fixture_rv();
let root = tmp.path();
let hostile = "a\"b\\c\nd]e";
run_raise(
Some(root.to_path_buf()),
&RaiseArgs {
reference: "RV-001".to_owned(),
severity: Severity::Major,
title: hostile.to_owned(),
detail: hostile.to_owned(),
},
Role::Raiser,
)
.unwrap();
let f = &read_doc(root, 1).finding[0];
assert_eq!(f.title, hostile);
assert_eq!(f.detail, hostile);
}
#[test]
fn vt5a_lock_serializes_loser_bails_then_re_runs() {
let tmp = fixture_rv();
let root = tmp.path();
let held = LockGuard::acquire(root, 1).unwrap();
let err = run_raise(
Some(root.to_path_buf()),
&raise_args("RV-001", Severity::Major, "t"),
Role::Raiser,
)
.unwrap_err();
assert!(err.to_string().contains("busy"), "loser bailed busy: {err}");
assert!(
read_doc(root, 1).finding.is_empty(),
"no clobber on a lost lock"
);
drop(held);
run_raise(
Some(root.to_path_buf()),
&raise_args("RV-001", Severity::Major, "first"),
Role::Raiser,
)
.unwrap();
assert_eq!(read_baton(root, 1).unwrap().unwrap().awaiting, "responder");
run_raise(
Some(root.to_path_buf()),
&raise_args("RV-001", Severity::Major, "second"),
Role::Raiser,
)
.unwrap();
let ids: Vec<String> = read_doc(root, 1)
.finding
.iter()
.map(|f| f.id.clone())
.collect();
assert_eq!(ids, ["F-1", "F-2"]);
}
#[test]
fn vt5b_entry_cas_self_heals_a_crash_between_writes() {
let tmp = fixture_rv();
let root = tmp.path();
run_raise(
Some(root.to_path_buf()),
&raise_args("RV-001", Severity::Major, "t"),
Role::Raiser,
)
.unwrap();
let baton_before = read_baton(root, 1).unwrap().unwrap();
let path = authored_path(root, 1);
let mut text = fs::read_to_string(&path).unwrap();
text = text.replace("status = \"open\"", "status = \"answered\"");
fs::write(&path, &text).unwrap();
let err = run_dispose(
Some(root.to_path_buf()),
&dispose_args("RV-001", "F-1"),
Role::Responder,
)
.unwrap_err();
assert!(
err.to_string().contains("changed underneath"),
"entry CAS bailed: {err}"
);
let healed = read_baton(root, 1).unwrap().unwrap();
assert_ne!(
healed.authored_hash, baton_before.authored_hash,
"hash refreshed"
);
assert_eq!(
healed.authored_hash,
crate::git::sha256(fs::read_to_string(&path).unwrap().as_bytes())
);
assert_eq!(
healed.awaiting, "raiser",
"await recomputed from authored truth"
);
assert_eq!(read_doc(root, 1).finding[0].status, "answered");
}
#[test]
fn vt5c_pre_write_cas_aborts_a_mid_turn_edit() {
let tmp = fixture_rv();
let root = tmp.path();
run_raise(
Some(root.to_path_buf()),
&raise_args("RV-001", Severity::Major, "t"),
Role::Raiser,
)
.unwrap();
let path = authored_path(root, 1);
let hook = || {
let mut text = fs::read_to_string(&path).unwrap();
text.push_str(
"\n[[finding]]\nid = \"F-2\"\nstatus = \"open\"\nseverity = \"nit\"\n\
title = \"injected\"\ndetail = \"by hand\"\n",
);
fs::write(&path, &text).unwrap();
};
let err = with_turn_hooked(
root,
1,
Verb::Dispose,
Role::Responder,
&hook,
|doc, existing| {
let from = finding_status_of(existing, "F-1")?;
gate(Verb::Dispose, from, Role::Responder, "F-1")?;
let table = finding_table_mut(doc, "F-1")?;
apply_transition(table, FindingStatus::Answered, Some("fixed"), Some("done"));
Ok(())
},
)
.unwrap_err();
assert!(
err.to_string().contains("changed underneath this turn"),
"pre-write CAS aborted: {err}"
);
let doc = read_doc(root, 1);
assert_eq!(doc.finding.len(), 2, "injected finding survived");
assert_eq!(
doc.finding[0].status, "open",
"F-1 not clobbered to answered"
);
assert_eq!(doc.finding[1].id, "F-2");
}
#[test]
fn vt5d_same_finding_contest_racing_verify() {
let tmp = fixture_rv();
let root = tmp.path();
run_raise(
Some(root.to_path_buf()),
&raise_args("RV-001", Severity::Major, "t"),
Role::Raiser,
)
.unwrap();
run_dispose(
Some(root.to_path_buf()),
&dispose_args("RV-001", "F-1"),
Role::Responder,
)
.unwrap();
run_verify(
Some(root.to_path_buf()),
"RV-001",
"F-1",
None,
Role::Raiser,
)
.unwrap();
assert_eq!(read_doc(root, 1).finding[0].status, "verified");
let err = run_contest(
Some(root.to_path_buf()),
"RV-001",
"F-1",
None,
Role::Raiser,
)
.unwrap_err();
assert!(
err.to_string().contains("out of turn"),
"contest gated: {err}"
);
assert_eq!(
read_doc(root, 1).finding[0].status,
"verified",
"winner stands"
);
}
#[test]
fn vt8_out_of_turn_refused() {
let tmp = fixture_rv();
let root = tmp.path();
run_raise(
Some(root.to_path_buf()),
&raise_args("RV-001", Severity::Major, "t"),
Role::Raiser,
)
.unwrap();
let err = run_dispose(
Some(root.to_path_buf()),
&dispose_args("RV-001", "F-1"),
Role::Raiser, )
.unwrap_err();
assert!(
err.to_string().contains("responder's verb"),
"static role: {err}"
);
let err = run_verify(
Some(root.to_path_buf()),
"RV-001",
"F-1",
None,
Role::Raiser,
)
.unwrap_err();
assert!(
err.to_string().contains("out of turn"),
"per-finding gate: {err}"
);
assert_eq!(read_doc(root, 1).finding[0].status, "open");
}
#[test]
fn vt9_status_rebuilds_the_baton() {
let tmp = fixture_rv();
let root = tmp.path();
run_raise(
Some(root.to_path_buf()),
&raise_args("RV-001", Severity::Major, "t"),
Role::Raiser,
)
.unwrap();
fs::remove_file(baton_path(root, 1)).unwrap();
run_status(Some(root.to_path_buf()), "RV-001").unwrap();
let baton = read_baton(root, 1).unwrap().unwrap();
let doc = read_doc(root, 1);
let (_, awaited) = derived_status(&finding_states_of(&doc));
assert_eq!(baton.awaiting, awaited.as_str(), "cache == recompute");
assert_eq!(
baton.authored_hash,
crate::git::sha256(
fs::read_to_string(authored_path(root, 1))
.unwrap()
.as_bytes()
)
);
}
#[test]
fn vt10_fork_root_refused_and_baton_in_parent_state() {
use std::process::Command;
let tmp = tempfile::tempdir().unwrap();
let main = tmp.path().join("main");
std::fs::create_dir_all(&main).unwrap();
let git = |dir: &Path, args: &[&str]| {
let ok = Command::new("git")
.arg("-C")
.arg(dir)
.args(args)
.env("GIT_AUTHOR_DATE", "2026-01-01T00:00:00 +0000")
.env("GIT_COMMITTER_DATE", "2026-01-01T00:00:00 +0000")
.output()
.unwrap();
assert!(
ok.status.success(),
"git {args:?}: {}",
String::from_utf8_lossy(&ok.stderr)
);
};
git(&main, &["init", "-b", "main"]);
git(&main, &["config", "user.name", "T"]);
git(&main, &["config", "user.email", "t@t.invalid"]);
plant_slice_target(&main, 1);
run_new(Some(main.clone()), &new_args(Facet::Design, "SL-001")).unwrap();
std::fs::write(main.join("seed"), "x").unwrap();
git(&main, &["add", "."]);
git(&main, &["commit", "-m", "seed"]);
let fork = tmp.path().join("fork");
git(&main, &["worktree", "add", fork.to_str().unwrap()]);
let err = run_raise(
Some(fork.clone()),
&raise_args("RV-001", Severity::Major, "t"),
Role::Raiser,
)
.unwrap_err();
assert!(
err.to_string().contains("worktree fork"),
"fork guard: {err}"
);
run_raise(
Some(main.clone()),
&raise_args("RV-001", Severity::Major, "t"),
Role::Raiser,
)
.unwrap();
assert!(
main.join(".doctrine/state/review/001/baton.toml").is_file(),
"baton in parent state"
);
assert!(
!fork.join(".doctrine/state/review/001/baton.toml").exists(),
"no baton in the fork"
);
}
#[test]
fn parse_role_defaults_and_validates() {
assert_eq!(parse_role(None, Role::Responder).unwrap(), Role::Responder);
assert_eq!(
parse_role(Some("raiser"), Role::Responder).unwrap(),
Role::Raiser
);
assert!(parse_role(Some("bogus"), Role::Raiser).is_err());
}
#[test]
fn note_is_handoff_chatter_in_the_baton_not_the_ledger() {
let tmp = fixture_rv();
let root = tmp.path();
run_raise(
Some(root.to_path_buf()),
&raise_args("RV-001", Severity::Major, "t"),
Role::Raiser,
)
.unwrap();
run_dispose(
Some(root.to_path_buf()),
&dispose_args("RV-001", "F-1"),
Role::Responder,
)
.unwrap();
run_contest(
Some(root.to_path_buf()),
"RV-001",
"F-1",
Some("please address the edge case"),
Role::Raiser,
)
.unwrap();
let baton = read_baton(root, 1).unwrap().unwrap();
assert!(
baton
.handoff
.iter()
.any(|h| h.contains("please address the edge case")),
"note in baton handoff log: {:?}",
baton.handoff
);
assert_eq!(baton.contests, 1, "contest counter bumped");
let text = fs::read_to_string(authored_path(root, 1)).unwrap();
assert!(
!text.contains("please address the edge case"),
"note not durable: {text}"
);
}
#[test]
fn vt3_scan_reports_an_unresolved_blocker_on_an_active_rv() {
let tmp = fixture_rv(); let root = tmp.path();
run_raise(
Some(root.to_path_buf()),
&raise_args("RV-001", Severity::Blocker, "must fix"),
Role::Raiser,
)
.unwrap();
assert_eq!(read_doc(root, 1).derived().0, ReviewStatus::Active);
let blockers = unresolved_blockers_for(root, "SL-001").unwrap();
assert_eq!(
blockers,
vec![BlockerRef {
rv: "RV-001".to_owned(),
finding: "F-1".to_owned(),
}]
);
}
#[test]
fn vt3_scan_ignores_a_non_matching_target() {
let tmp = fixture_rv();
let root = tmp.path();
run_raise(
Some(root.to_path_buf()),
&raise_args("RV-001", Severity::Blocker, "must fix"),
Role::Raiser,
)
.unwrap();
assert!(unresolved_blockers_for(root, "SL-999").unwrap().is_empty());
}
#[test]
fn vt3_scan_ignores_a_non_blocker_finding() {
let tmp = fixture_rv();
let root = tmp.path();
run_raise(
Some(root.to_path_buf()),
&raise_args("RV-001", Severity::Major, "nice to have"),
Role::Raiser,
)
.unwrap();
assert!(unresolved_blockers_for(root, "SL-001").unwrap().is_empty());
}
#[test]
fn vt1_verified_blocker_is_terminal_and_not_reported() {
let tmp = fixture_rv();
let root = tmp.path();
run_raise(
Some(root.to_path_buf()),
&raise_args("RV-001", Severity::Blocker, "must fix"),
Role::Raiser,
)
.unwrap();
run_dispose(
Some(root.to_path_buf()),
&dispose_args("RV-001", "F-1"),
Role::Responder,
)
.unwrap();
run_verify(
Some(root.to_path_buf()),
"RV-001",
"F-1",
None,
Role::Raiser,
)
.unwrap();
assert_eq!(read_doc(root, 1).derived().0, ReviewStatus::Done);
assert!(unresolved_blockers_for(root, "SL-001").unwrap().is_empty());
}
#[test]
fn vt1_withdrawn_blocker_is_terminal_and_not_reported() {
let tmp = fixture_rv();
let root = tmp.path();
run_raise(
Some(root.to_path_buf()),
&raise_args("RV-001", Severity::Blocker, "must fix"),
Role::Raiser,
)
.unwrap();
run_withdraw(Some(root.to_path_buf()), "RV-001", "F-1", Role::Raiser).unwrap();
assert_eq!(read_doc(root, 1).derived().0, ReviewStatus::Done);
assert!(unresolved_blockers_for(root, "SL-001").unwrap().is_empty());
}
#[test]
fn vt1_answered_blocker_keeps_the_review_active_and_gating() {
let tmp = fixture_rv();
let root = tmp.path();
run_raise(
Some(root.to_path_buf()),
&raise_args("RV-001", Severity::Blocker, "must fix"),
Role::Raiser,
)
.unwrap();
run_dispose(
Some(root.to_path_buf()),
&dispose_args("RV-001", "F-1"),
Role::Responder,
)
.unwrap();
assert_eq!(read_doc(root, 1).derived().0, ReviewStatus::Active);
let blockers = unresolved_blockers_for(root, "SL-001").unwrap();
assert_eq!(blockers.len(), 1);
assert_eq!(blockers[0].finding, "F-1");
}
#[test]
fn vt3_scan_with_no_review_tree_is_empty() {
let tmp = tempfile::tempdir().unwrap();
assert!(
unresolved_blockers_for(tmp.path(), "SL-001")
.unwrap()
.is_empty()
);
}
#[test]
fn unlock_clears_a_stale_lock() {
let tmp = fixture_rv();
let root = tmp.path();
let lock = lock_path(root, 1);
fs::create_dir_all(lock.parent().unwrap()).unwrap();
fs::write(&lock, "pid = 99999\nacquired = \"stale\"\n").unwrap();
run_unlock(Some(root.to_path_buf()), "RV-001").unwrap();
assert!(!lock.exists(), "stale lock removed");
run_unlock(Some(root.to_path_buf()), "RV-001").unwrap();
}
fn plant_tracked(root: &Path, rel: &str, body: &str) {
let path = root.join(rel);
fs::create_dir_all(path.parent().unwrap()).unwrap();
fs::write(&path, body).unwrap();
}
fn sample_domain_map() -> &'static str {
r#"
[[area]]
name = "turn protocol"
purpose = "baton/lock/CAS serialize turns"
paths = ["src/review.rs", "src/state.rs"]
[[invariant]]
text = "await derived, never stored (D-C8)"
[[risk]]
text = "stale baton after out-of-band edit"
"#
}
#[test]
fn vt1_prime_persists_domain_map_and_hashes_then_current() {
let tmp = fixture_rv();
let root = tmp.path();
plant_tracked(root, "src/review.rs", "fn review() {}\n");
plant_tracked(root, "src/state.rs", "fn state() {}\n");
let map = root.join("map.toml");
fs::write(&map, sample_domain_map()).unwrap();
run_prime(
Some(root.to_path_buf()),
&PrimeArgs {
reference: "RV-001".to_owned(),
seed: false,
from: Some(map),
},
)
.unwrap();
let cache = read_cache(root, 1).unwrap().expect("cache primed");
assert_eq!(cache.areas.len(), 1);
assert_eq!(cache.areas[0].name, "turn protocol");
assert_eq!(cache.invariants.len(), 1);
assert_eq!(cache.risks.len(), 1);
assert_eq!(
cache.hashes.keys().cloned().collect::<Vec<_>>(),
vec!["src/review.rs".to_owned(), "src/state.rs".to_owned()]
);
let expected = contentset::compute(
root,
&["src/review.rs".to_owned(), "src/state.rs".to_owned()],
)
.unwrap();
assert_eq!(&cache.hashes, expected.hashes());
assert!(matches!(
cache_staleness(root, &cache).unwrap(),
CacheVerdict::Current
));
}
#[test]
fn vt2_status_reports_current_then_stale_on_drift_and_absence() {
let tmp = fixture_rv();
let root = tmp.path();
plant_tracked(root, "src/review.rs", "original\n");
plant_tracked(root, "src/state.rs", "state\n");
let map = root.join("map.toml");
fs::write(&map, sample_domain_map()).unwrap();
run_prime(
Some(root.to_path_buf()),
&PrimeArgs {
reference: "RV-001".to_owned(),
seed: false,
from: Some(map),
},
)
.unwrap();
let cache = read_cache(root, 1).unwrap().unwrap();
assert!(matches!(
cache_staleness(root, &cache).unwrap(),
CacheVerdict::Current
));
fs::write(root.join("src/review.rs"), "MUTATED\n").unwrap();
match cache_staleness(root, &cache).unwrap() {
CacheVerdict::Stale(paths) => {
assert_eq!(paths, vec!["src/review.rs".to_owned()]);
}
CacheVerdict::Current => panic!("expected stale after a content drift"),
}
fs::write(root.join("src/review.rs"), "original\n").unwrap();
fs::remove_file(root.join("src/state.rs")).unwrap();
match cache_staleness(root, &cache).unwrap() {
CacheVerdict::Stale(paths) => {
assert_eq!(paths, vec!["src/state.rs".to_owned()]);
}
CacheVerdict::Current => panic!("absent tracked path must be stale (R1)"),
}
}
#[test]
fn prime_ignores_supplied_hashes_and_recomputes() {
let tmp = fixture_rv();
let root = tmp.path();
plant_tracked(root, "a.txt", "real content\n");
let map = root.join("map.toml");
fs::write(
&map,
"[[area]]\nname = \"a\"\npaths = [\"a.txt\"]\n[hashes]\n\"a.txt\" = \"deadbeef\"\n",
)
.unwrap();
run_prime(
Some(root.to_path_buf()),
&PrimeArgs {
reference: "RV-001".to_owned(),
seed: false,
from: Some(map),
},
)
.unwrap();
let cache = read_cache(root, 1).unwrap().unwrap();
assert_ne!(
cache.hashes.get("a.txt").map(String::as_str),
Some("deadbeef")
);
assert!(matches!(
cache_staleness(root, &cache).unwrap(),
CacheVerdict::Current
));
}
#[test]
fn prime_refuses_an_empty_or_malformed_domain_map() {
let tmp = fixture_rv();
let root = tmp.path();
let map = root.join("map.toml");
fs::write(&map, "[[invariant]]\ntext = \"x\"\n").unwrap();
assert!(
run_prime(
Some(root.to_path_buf()),
&PrimeArgs {
reference: "RV-001".to_owned(),
seed: false,
from: Some(map.clone()),
},
)
.is_err()
);
fs::write(&map, "[[area]]\nname = \"a\"\npaths = []\n").unwrap();
assert!(
run_prime(
Some(root.to_path_buf()),
&PrimeArgs {
reference: "RV-001".to_owned(),
seed: false,
from: Some(map.clone()),
},
)
.is_err()
);
fs::write(
&map,
"[[area]]\nname = \"a\"\npaths = [\"../etc/passwd\"]\n",
)
.unwrap();
assert!(
run_prime(
Some(root.to_path_buf()),
&PrimeArgs {
reference: "RV-001".to_owned(),
seed: false,
from: Some(map),
},
)
.is_err()
);
assert!(read_cache(root, 1).unwrap().is_none());
}
#[test]
fn prime_serializes_via_the_per_review_lock() {
let tmp = fixture_rv();
let root = tmp.path();
plant_tracked(root, "a.txt", "x\n");
let map = root.join("map.toml");
fs::write(&map, "[[area]]\nname = \"a\"\npaths = [\"a.txt\"]\n").unwrap();
let held = LockGuard::acquire(root, 1).unwrap();
let err = run_prime(
Some(root.to_path_buf()),
&PrimeArgs {
reference: "RV-001".to_owned(),
seed: false,
from: Some(map.clone()),
},
)
.unwrap_err();
assert!(format!("{err}").contains("busy"), "lock contention: {err}");
assert!(
read_cache(root, 1).unwrap().is_none(),
"no cache written under contention"
);
drop(held);
run_prime(
Some(root.to_path_buf()),
&PrimeArgs {
reference: "RV-001".to_owned(),
seed: false,
from: Some(map),
},
)
.unwrap();
assert!(read_cache(root, 1).unwrap().is_some());
}
#[test]
fn status_is_silent_about_an_unprimed_cache() {
let tmp = fixture_rv();
let root = tmp.path();
assert!(read_cache(root, 1).unwrap().is_none());
run_status(Some(root.to_path_buf()), "RV-001").unwrap();
}
}