use std::fs;
use std::io::{self, Write};
use std::path::{Path, PathBuf};
use anyhow::Context;
use serde::{Deserialize, Serialize};
use crate::entity::{self, Kind, LocalFs, Materialised};
use crate::listing::{self, Column, Format, ListArgs};
use crate::tomlfmt::toml_string;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum RecMove {
Accept,
Revise,
Redesign,
}
impl RecMove {
pub(crate) const fn as_str(self) -> &'static str {
match self {
Self::Accept => "accept",
Self::Revise => "revise",
Self::Redesign => "redesign",
}
}
pub(crate) fn parse(s: &str) -> Result<Self, String> {
match s {
"accept" => Ok(Self::Accept),
"revise" => Ok(Self::Revise),
"redesign" => Ok(Self::Redesign),
other => Err(format!(
"unknown move `{other}` (known: {})",
MOVES.join(", ")
)),
}
}
}
const MOVES: &[&str] = &["accept", "revise", "redesign"];
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub(crate) struct StatusDelta {
pub(crate) requirement: String,
pub(crate) from: String,
pub(crate) to: String,
}
use crate::coverage::CoverageKey as EvidenceRef;
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub(crate) struct RecMeta {
#[serde(rename = "move")]
pub(crate) r#move: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) owning_slice: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) decision_ref: Option<String>,
}
#[derive(Debug, Clone, PartialEq, Eq, Deserialize, Serialize)]
pub(crate) struct RecDoc {
pub(crate) id: u32,
pub(crate) slug: String,
pub(crate) title: String,
pub(crate) rec: RecMeta,
#[serde(default)]
pub(crate) status_delta: Vec<StatusDelta>,
#[serde(default)]
pub(crate) evidence_ref: Vec<EvidenceRef>,
}
pub(crate) const REC_DIR: &str = ".doctrine/rec";
pub(crate) const REC_KIND: Kind = Kind {
dir: REC_DIR,
prefix: crate::kinds::REC,
scaffold: rec_scaffold_unused,
};
fn rec_scaffold_unused(_ctx: &entity::ScaffoldCtx<'_>) -> anyhow::Result<entity::Fileset> {
anyhow::bail!("rec materialises eagerly, not via Kind.scaffold")
}
fn render_rec_toml(id: u32, slug: &str, title: &str, meta: &RecMeta) -> anyhow::Result<String> {
Ok(crate::install::asset_text("templates/rec.toml")?
.replace("{{id}}", &id.to_string())
.replace("{{slug}}", &toml_string(slug))
.replace("{{title}}", &toml_string(title))
.replace("{{move}}", &toml_string(&meta.r#move))
.replace(
"{{owning_slice}}",
&optional_line("owning_slice", meta.owning_slice.as_deref()),
)
.replace(
"{{decision_ref}}",
&optional_line("decision_ref", meta.decision_ref.as_deref()),
))
}
fn optional_line(key: &str, value: Option<&str>) -> String {
match value {
Some(v) => {
let mut line = String::from(key);
line.push_str(" = ");
line.push_str(&toml_string(v));
line.push('\n');
line
}
None => String::new(),
}
}
fn render_rec_md(canonical: &str, r#move: &str) -> anyhow::Result<String> {
Ok(crate::install::asset_text("templates/rec.md")?
.replace("{{ref}}", canonical)
.replace("{{move}}", r#move))
}
fn render_rec_toml_populated(doc: &RecDoc) -> anyhow::Result<String> {
let mut out = render_rec_toml(doc.id, &doc.slug, &doc.title, &doc.rec)?;
for d in &doc.status_delta {
out.push_str(&status_delta_table(d));
}
for e in &doc.evidence_ref {
out.push_str(&evidence_ref_table(e));
}
Ok(out)
}
fn status_delta_table(d: &StatusDelta) -> String {
[
"\n[[status_delta]]\n".to_owned(),
format!("requirement = {}\n", toml_string(&d.requirement)),
format!("from = {}\n", toml_string(&d.from)),
format!("to = {}\n", toml_string(&d.to)),
]
.concat()
}
fn evidence_ref_table(e: &EvidenceRef) -> String {
[
"\n[[evidence_ref]]\n".to_owned(),
format!("slice = {}\n", toml_string(&e.slice)),
format!("requirement = {}\n", toml_string(&e.requirement)),
format!(
"contributing_change = {}\n",
toml_string(&e.contributing_change)
),
format!("mode = {}\n", toml_string(&e.mode)),
]
.concat()
}
pub(crate) fn materialise_populated(root: &Path, doc: &RecDoc) -> anyhow::Result<u32> {
let trunk_ids = crate::git::trunk_entity_ids(root, REC_DIR)?;
let out: Materialised = entity::materialise_fresh_prebuilt(
&LocalFs,
root,
REC_DIR,
REC_KIND.prefix,
&trunk_ids,
|id, canonical| {
let name = format!("{id:03}");
let placed = RecDoc { id, ..doc.clone() };
Ok(vec![
entity::Artifact::File {
rel_path: PathBuf::from(format!("{name}/rec-{name}.toml")),
body: render_rec_toml_populated(&placed)?,
},
entity::Artifact::File {
rel_path: PathBuf::from(format!("{name}/rec-{name}.md")),
body: render_rec_md(canonical, &doc.rec.r#move)?,
},
entity::Artifact::Symlink {
rel_path: PathBuf::from(format!("{name}-{}", doc.slug)),
target: name,
},
])
},
)?;
out.eid
.numeric_id()
.context("rec kind must yield a numeric id")
}
pub(crate) struct NewArgs {
pub(crate) r#move: RecMove,
pub(crate) owning_slice: Option<String>,
pub(crate) decision_ref: Option<String>,
pub(crate) title: Option<String>,
}
pub(crate) fn run_new(path: Option<PathBuf>, args: &NewArgs) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
if let Some(owning) = &args.owning_slice {
crate::integrity::ensure_ref_resolves(&root, owning)?;
}
let title = args
.title
.clone()
.unwrap_or_else(|| format!("{} reconciliation", args.r#move.as_str()));
let slug = crate::input::resolve_slug(&title, None)?;
let meta = RecMeta {
r#move: args.r#move.as_str().to_owned(),
owning_slice: args.owning_slice.clone(),
decision_ref: args.decision_ref.clone(),
};
let trunk_ids = crate::git::trunk_entity_ids(&root, REC_DIR)?;
let out: Materialised = entity::materialise_fresh_prebuilt(
&LocalFs,
&root,
REC_DIR,
REC_KIND.prefix,
&trunk_ids,
|id, canonical| {
let name = format!("{id:03}");
Ok(vec![
entity::Artifact::File {
rel_path: PathBuf::from(format!("{name}/rec-{name}.toml")),
body: render_rec_toml(id, &slug, &title, &meta)?,
},
entity::Artifact::File {
rel_path: PathBuf::from(format!("{name}/rec-{name}.md")),
body: render_rec_md(canonical, &meta.r#move)?,
},
entity::Artifact::Symlink {
rel_path: PathBuf::from(format!("{name}-{slug}")),
target: name,
},
])
},
)?;
let id = out
.eid
.numeric_id()
.context("rec kind must yield a numeric id")?;
writeln!(io::stdout(), "Created rec {id:03}: {}", out.dir.display())?;
Ok(())
}
fn canonical_id(id: u32) -> String {
listing::canonical_id(REC_KIND.prefix, id)
}
fn parse_ref(reference: &str) -> anyhow::Result<u32> {
let digits = reference
.strip_prefix("REC-")
.or_else(|| reference.strip_prefix("rec-"))
.unwrap_or(reference);
digits
.parse::<u32>()
.with_context(|| format!("not a rec reference: `{reference}` (expected `REC-007` or `7`)"))
}
fn read_rec(rec_root: &Path, id: u32) -> anyhow::Result<RecDoc> {
let name = format!("{id:03}");
let path = rec_root.join(&name).join(format!("rec-{name}.toml"));
let text = fs::read_to_string(&path)
.with_context(|| format!("rec {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_rec(&root.join(REC_DIR), id)?;
let mut edges = Vec::new();
if let Some(s) = &doc.rec.owning_slice {
edges.push(RelationEdge::new(RelationLabel::OwningSlice, s.clone()));
}
if let Some(d) = &doc.rec.decision_ref {
edges.push(RelationEdge::new(RelationLabel::DecisionRef, d.clone()));
}
Ok(edges)
}
fn read_recs(rec_root: &Path) -> anyhow::Result<Vec<RecDoc>> {
let mut docs = Vec::new();
for id in entity::scan_ids(rec_root)? {
docs.push(read_rec(rec_root, id)?);
}
Ok(docs)
}
pub(crate) fn recs_owned_by(root: &Path, slice: &str) -> anyhow::Result<Vec<RecDoc>> {
let rec_root = root.join(REC_DIR);
if !rec_root.is_dir() {
return Ok(Vec::new());
}
Ok(read_recs(&rec_root)?
.into_iter()
.filter(|doc| doc.rec.owning_slice.as_deref() == Some(slice))
.collect())
}
fn owning_label(doc: &RecDoc) -> String {
doc.rec
.owning_slice
.clone()
.unwrap_or_else(|| "—".to_owned())
}
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 rec_root = root.join(REC_DIR);
let id = parse_ref(reference)?;
let doc = read_rec(&rec_root, id)?;
let body = read_rationale(&rec_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_rationale(rec_root: &Path, id: u32) -> anyhow::Result<String> {
let name = format!("{id:03}");
let path = rec_root.join(&name).join(format!("rec-{name}.md"));
fs::read_to_string(&path).with_context(|| format!("Failed to read {}", path.display()))
}
fn format_show(doc: &RecDoc, body: &str) -> String {
let mut parts: Vec<String> = Vec::new();
parts.push(format!("{} — {}\n", canonical_id(doc.id), doc.title));
parts.push(format!(
"move={} · owning={}\n",
doc.rec.r#move,
owning_label(doc)
));
if let Some(decision) = &doc.rec.decision_ref {
parts.push(format!("decision: {decision}\n"));
}
parts.push(format!(
"deltas: {} · evidence: {}\n",
doc.status_delta.len(),
doc.evidence_ref.len()
));
parts.push(format!("\n{body}"));
parts.concat()
}
#[derive(Debug, Serialize)]
struct ShowJson<'a> {
#[serde(flatten)]
doc: &'a RecDoc,
}
fn show_json(doc: &RecDoc, body: &str) -> anyhow::Result<String> {
let row = ShowJson { doc };
let value = serde_json::json!({ "kind": "rec", "rec": row, "body": body });
serde_json::to_string_pretty(&value).context("failed to serialize rec show JSON")
}
const REC_COLUMNS: [Column<RecDoc>; 4] = [
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: "move",
header: "move",
cell: |d| d.rec.r#move.clone(),
paint: listing::ColumnPaint::None,
},
Column {
name: "owning",
header: "owning",
cell: owning_label,
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 REC_DEFAULT: &[&str] = &["id", "move", "owning", "title"];
fn key(d: &RecDoc) -> listing::FilterFields {
listing::FilterFields {
canonical: canonical_id(d.id),
slug: d.slug.clone(),
title: d.title.clone(),
status: String::new(),
tags: Vec::new(),
}
}
fn list_rows(root: &Path, mut args: ListArgs) -> anyhow::Result<String> {
listing::validate_statuses(&args.status, &[])?;
let render = args.render;
let columns = args.columns.take();
let (filter, format) = listing::build(args)?;
let rec_root = root.join(REC_DIR);
if !rec_root.is_dir() {
return match format {
Format::Table => Ok(listing::render_columns::<RecDoc>(
&[],
&listing::select_columns(&REC_COLUMNS, REC_DEFAULT, columns.as_deref())?,
render,
)),
Format::Json => listing::json_envelope::<ListRow>("rec", &[]),
};
}
let mut docs = listing::retain(read_recs(&rec_root)?, &filter, |_| false, key);
docs.sort_by_key(|d| d.id);
match format {
Format::Table => {
let sel = listing::select_columns(&REC_COLUMNS, REC_DEFAULT, columns.as_deref())?;
Ok(listing::render_columns(&docs, &sel, render))
}
Format::Json => listing::json_envelope("rec", &json_rows(&docs)),
}
}
#[derive(Debug, Serialize)]
struct ListRow {
id: String,
r#move: String,
owning: String,
title: String,
}
fn json_rows(docs: &[RecDoc]) -> Vec<ListRow> {
docs.iter()
.map(|d| ListRow {
id: canonical_id(d.id),
r#move: d.rec.r#move.clone(),
owning: owning_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(())
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn move_as_str_round_trips_through_parse() {
for m in [RecMove::Accept, RecMove::Revise, RecMove::Redesign] {
assert_eq!(RecMove::parse(m.as_str()), Ok(m));
}
}
#[test]
fn move_parse_rejects_unknown_naming_the_known_set() {
let err = RecMove::parse("supersede").unwrap_err();
assert!(err.contains("supersede"), "names the bad token: {err}");
assert!(
err.contains("accept, revise, redesign"),
"names the set: {err}"
);
}
#[test]
fn move_known_set_matches_variants() {
let variants = [RecMove::Accept, RecMove::Revise, RecMove::Redesign];
let from_variants: Vec<&str> = variants.iter().map(|m| m.as_str()).collect();
assert_eq!(
from_variants, MOVES,
"MOVES drifted from the RecMove variants"
);
}
#[test]
fn schema_round_trips_a_populated_rec() {
let doc = RecDoc {
id: 7,
slug: "accept-req-108".to_owned(),
title: "accept REQ-108".to_owned(),
rec: RecMeta {
r#move: "accept".to_owned(),
owning_slice: Some("SL-042".to_owned()),
decision_ref: Some("DEC-005-C".to_owned()),
},
status_delta: vec![StatusDelta {
requirement: "REQ-108".to_owned(),
from: "pending".to_owned(),
to: "active".to_owned(),
}],
evidence_ref: vec![EvidenceRef {
slice: "SL-042".to_owned(),
requirement: "REQ-108".to_owned(),
contributing_change: "SL-042".to_owned(),
mode: "VT".to_owned(),
}],
};
let text = toml::to_string(&doc).unwrap();
let back: RecDoc = toml::from_str(&text).unwrap();
assert_eq!(back, doc);
}
#[test]
fn schema_admits_an_empty_delta_list() {
let doc = RecDoc {
id: 1,
slug: "redesign-escalation".to_owned(),
title: "redesign escalation".to_owned(),
rec: RecMeta {
r#move: "redesign".to_owned(),
owning_slice: None,
decision_ref: None,
},
status_delta: Vec::new(),
evidence_ref: Vec::new(),
};
let text = toml::to_string(&doc).unwrap();
let back: RecDoc = toml::from_str(&text).unwrap();
assert!(back.status_delta.is_empty(), "empty deltas admitted (F7)");
assert_eq!(back, doc);
}
#[test]
fn populated_render_round_trips_deltas_and_evidence() {
let doc = RecDoc {
id: 3,
slug: "accept-req-110".to_owned(),
title: "accept REQ-110".to_owned(),
rec: RecMeta {
r#move: "accept".to_owned(),
owning_slice: Some("SL-044".to_owned()),
decision_ref: None,
},
status_delta: vec![StatusDelta {
requirement: "REQ-110".to_owned(),
from: "pending".to_owned(),
to: "active".to_owned(),
}],
evidence_ref: vec![EvidenceRef {
slice: "SL-044".to_owned(),
requirement: "REQ-110".to_owned(),
contributing_change: "SL-040".to_owned(),
mode: "VT".to_owned(),
}],
};
let text = render_rec_toml_populated(&doc).unwrap();
assert!(
text.lines().any(|l| l == "[[status_delta]]"),
"real delta table: {text}"
);
assert!(
text.lines().any(|l| l == "[[evidence_ref]]"),
"real evidence table: {text}"
);
let back: RecDoc = toml::from_str(&text).unwrap();
assert_eq!(back.status_delta, doc.status_delta);
assert_eq!(back.evidence_ref, doc.evidence_ref);
assert_eq!(back.rec.r#move, "accept");
assert_eq!(back.rec.owning_slice.as_deref(), Some("SL-044"));
}
#[test]
fn populated_render_emits_no_delta_table_when_empty() {
let doc = RecDoc {
id: 1,
slug: "redesign".to_owned(),
title: "redesign".to_owned(),
rec: RecMeta {
r#move: "redesign".to_owned(),
owning_slice: Some("SL-044".to_owned()),
decision_ref: None,
},
status_delta: Vec::new(),
evidence_ref: Vec::new(),
};
let text = render_rec_toml_populated(&doc).unwrap();
assert!(
!text.lines().any(|l| l == "[[status_delta]]"),
"no real delta table (F7): {text}"
);
let back: RecDoc = toml::from_str(&text).unwrap();
assert!(back.status_delta.is_empty());
}
#[test]
fn populated_render_escapes_hostile_delta_values() {
let doc = RecDoc {
id: 1,
slug: "s".to_owned(),
title: "t".to_owned(),
rec: RecMeta {
r#move: "accept".to_owned(),
owning_slice: None,
decision_ref: None,
},
status_delta: vec![StatusDelta {
requirement: "REQ-1\"\ninjected = \"x".to_owned(),
from: "pending".to_owned(),
to: "active".to_owned(),
}],
evidence_ref: Vec::new(),
};
let text = render_rec_toml_populated(&doc).unwrap();
let back: RecDoc = toml::from_str(&text).unwrap();
assert_eq!(
back.status_delta.first().unwrap().requirement,
"REQ-1\"\ninjected = \"x"
);
}
#[test]
fn move_field_renders_as_bare_move_key() {
let doc = RecDoc {
id: 1,
slug: "s".to_owned(),
title: "t".to_owned(),
rec: RecMeta {
r#move: "accept".to_owned(),
owning_slice: None,
decision_ref: None,
},
status_delta: Vec::new(),
evidence_ref: Vec::new(),
};
let text = toml::to_string(&doc).unwrap();
assert!(text.contains("move = \"accept\""), "bare move key: {text}");
assert!(!text.contains("r#move"), "no raw-ident leak: {text}");
}
}