use std::io::{self, Write};
use std::path::PathBuf;
use crate::entity::{Artifact, Fileset, Kind, ScaffoldCtx};
use crate::governance::{self, GovKind};
use crate::listing::{Format, ListArgs};
use crate::tomlfmt::toml_string;
const RFC_DIR: &str = ".doctrine/rfc";
pub(crate) const RFC_KIND: GovKind = GovKind {
kind: Kind {
dir: RFC_DIR,
prefix: crate::kinds::RFC,
scaffold: rfc_scaffold,
},
stem: "rfc",
statuses: RFC_STATUSES,
hidden: is_hidden,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub(crate) enum RfcStatus {
Open,
Resolved,
Withdrawn,
}
impl RfcStatus {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Open => "open",
Self::Resolved => "resolved",
Self::Withdrawn => "withdrawn",
}
}
}
pub(crate) const RFC_STATUSES: &[&str] = &["open", "resolved", "withdrawn"];
fn is_hidden(status: &str) -> bool {
matches!(status, "resolved" | "withdrawn")
}
fn render_rfc_toml(id: u32, slug: &str, title: &str, date: &str) -> anyhow::Result<String> {
Ok(crate::install::asset_text("templates/rfc.toml")?
.replace("{{id}}", &id.to_string())
.replace("{{slug}}", &toml_string(slug))
.replace("{{title}}", &toml_string(title))
.replace("{{date}}", date))
}
fn render_rfc_md(canonical_id: &str, title: &str) -> anyhow::Result<String> {
Ok(crate::install::asset_text("templates/rfc.md")?
.replace("{{ref}}", canonical_id)
.replace("{{title}}", title))
}
fn rfc_scaffold(ctx: &ScaffoldCtx<'_>) -> anyhow::Result<Fileset> {
let id = ctx.id;
let name = format!("{id:03}");
Ok(vec![
Artifact::File {
rel_path: PathBuf::from(format!("{name}/rfc-{name}.toml")),
body: render_rfc_toml(id, ctx.slug, ctx.title, ctx.date)?,
},
Artifact::File {
rel_path: PathBuf::from(format!("{name}/rfc-{name}.md")),
body: render_rfc_md(ctx.canonical, ctx.title)?,
},
])
}
pub(crate) fn run_new(
path: Option<PathBuf>,
title: Option<String>,
slug: Option<String>,
) -> anyhow::Result<()> {
governance::run_new(&RFC_KIND, path, title, slug)
}
pub(crate) fn run_list(path: Option<PathBuf>, args: ListArgs) -> anyhow::Result<()> {
governance::run_list(&RFC_KIND, path, args)
}
pub(crate) fn run_show(
path: Option<PathBuf>,
reference: &str,
format: Format,
) -> anyhow::Result<()> {
governance::run_show(&RFC_KIND, path, reference, format)
}
pub(crate) fn run_status(
path: Option<PathBuf>,
id: u32,
status: RfcStatus,
color: bool,
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
let gov_root = root.join(RFC_KIND.kind.dir);
governance::set_status(
&RFC_KIND,
&gov_root,
id,
status.as_str(),
&crate::clock::today(),
)?;
writeln!(
io::stdout(),
"RFC {id:03}: {}",
crate::listing::status_colored(status.as_str(), color)
)?;
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
use crate::meta::Meta;
use std::path::Path;
#[test]
fn render_rfc_toml_round_trips_to_metadata() {
let body = render_rfc_toml(1, "use-rust", "Use Rust?", "2026-06-04").unwrap();
let parsed: Meta = toml::from_str(&body).unwrap();
assert_eq!(
parsed,
Meta {
id: 1,
slug: "use-rust".to_string(),
title: "Use Rust?".to_string(),
status: "open".to_string(),
}
);
assert!(body.contains("created = \"2026-06-04\""));
assert!(!body.contains("{{"));
}
#[test]
fn render_rfc_toml_escapes_hostile_title_and_slug() {
let title = crate::tomlfmt::HOSTILE_TITLE;
let slug = crate::tomlfmt::HOSTILE_SLUG;
let body = render_rfc_toml(1, slug, title, "2026-06-04").unwrap();
let parsed: Meta = toml::from_str(&body).unwrap();
assert_eq!(parsed.slug, slug);
assert_eq!(parsed.title, title);
}
#[test]
fn render_rfc_md_substitutes_ref_and_title_without_frontmatter() {
let body = render_rfc_md("RFC-001", "Use Rust?").unwrap();
assert!(body.starts_with("# RFC-001: Use Rust?"));
assert!(!body.contains("{{ref}}"));
assert!(!body.contains("{{title}}"));
assert!(!body.starts_with("---"));
assert!(!body.contains("\n---\n"));
}
#[test]
fn rfc_scaffold_lays_out_two_files() {
let ctx = ScaffoldCtx {
id: 1,
canonical: "RFC-001",
slug: "use-rust",
title: "Use Rust?",
date: "2026-06-04",
};
let fileset = rfc_scaffold(&ctx).unwrap();
assert_eq!(fileset.len(), 2);
assert!(matches!(&fileset[0],
Artifact::File { rel_path, body }
if rel_path == Path::new("001/rfc-001.toml") && body.contains("2026-06-04")));
assert!(matches!(&fileset[1],
Artifact::File { rel_path, body }
if rel_path == Path::new("001/rfc-001.md") && body.contains("RFC-001: Use Rust?")));
}
#[test]
fn rfc_known_set_matches_variants() {
let variants = [RfcStatus::Open, RfcStatus::Resolved, RfcStatus::Withdrawn];
let from_variants: Vec<&str> = variants.iter().map(|v| v.as_str()).collect();
assert_eq!(from_variants, RFC_STATUSES.to_vec());
assert!(!RFC_STATUSES.contains(&"accepted"));
}
fn rfc_at(root: &Path, id: u32, status: &str, slug: &str, title: &str) {
let name = format!("{id:03}");
let dir = root.join(RFC_DIR).join(&name);
std::fs::create_dir_all(&dir).unwrap();
let toml = format!(
"schema = \"doctrine.rfc\"\nversion = 1\n\n\
id = {id}\nslug = \"{slug}\"\ntitle = \"{title}\"\n\
status = \"{status}\"\ncreated = \"2026-06-04\"\nupdated = \"2026-06-04\"\n"
);
std::fs::write(dir.join(format!("rfc-{name}.toml")), toml).unwrap();
}
fn rfc_root(root: &Path) -> PathBuf {
root.join(RFC_DIR)
}
#[test]
fn mint_show_round_trip_rfc_new_writes_both_tiers_rfc_show_renders_them() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
run_new(Some(root.to_path_buf()), Some("Use Rust?".into()), None).unwrap();
let rfc_dir = root.join(".doctrine/rfc");
assert!(rfc_dir.join("001/rfc-001.toml").is_file());
assert!(rfc_dir.join("001/rfc-001.md").is_file());
let out = governance::list_rows(&RFC_KIND, root, ListArgs::default()).unwrap();
assert!(out.contains("RFC-001"));
assert!(out.contains("open"));
assert!(out.contains("Use Rust?"));
}
#[test]
fn freshly_minted_rfc_reads_status_open_via_status_bearing_path() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
run_new(Some(root.to_path_buf()), Some("Use Rust?".into()), None).unwrap();
let rfc_root = root.join(RFC_KIND.kind.dir);
let toml_path = rfc_root.join("001/rfc-001.toml");
let text = std::fs::read_to_string(&toml_path).unwrap();
let parsed: crate::meta::Meta = toml::from_str(&text).unwrap();
assert_eq!(parsed.status, "open");
}
#[test]
fn status_transition_open_to_resolved_persists() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
rfc_at(root, 1, "open", "think-rust", "Think Rust?");
governance::set_status(
&RFC_KIND,
&rfc_root(root),
1,
RfcStatus::Resolved.as_str(),
"2099-01-01",
)
.unwrap();
let meta = crate::meta::read_meta(&rfc_root(root), "rfc", 1).unwrap();
assert_eq!(meta.status, "resolved");
let body = std::fs::read_to_string(rfc_root(root).join("001/rfc-001.toml")).unwrap();
assert!(body.contains("updated = \"2099-01-01\""));
assert!(body.contains("id = 1"));
assert!(body.contains("slug = \"think-rust\""));
}
#[test]
fn status_transition_open_to_withdrawn_persists() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
rfc_at(root, 3, "open", "too-early", "Too Early");
governance::set_status(
&RFC_KIND,
&rfc_root(root),
3,
RfcStatus::Withdrawn.as_str(),
"2099-01-01",
)
.unwrap();
let meta = crate::meta::read_meta(&rfc_root(root), "rfc", 3).unwrap();
assert_eq!(meta.status, "withdrawn");
}
#[test]
fn status_transition_all_three_known_statuses_accepted_by_set_status() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
for (id, target) in [
(1, RfcStatus::Open.as_str()),
(2, RfcStatus::Resolved.as_str()),
(3, RfcStatus::Withdrawn.as_str()),
] {
rfc_at(
root,
id,
"open",
&format!("slug-{id}"),
&format!("Title {id}"),
);
governance::set_status(&RFC_KIND, &rfc_root(root), id, target, "2099-01-01").unwrap();
let meta = crate::meta::read_meta(&rfc_root(root), "rfc", id).unwrap();
assert_eq!(meta.status, target, "id {id}");
}
}
#[test]
fn status_transition_set_status_on_a_missing_rfc_errors() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
rfc_at(root, 1, "open", "exists", "Exists");
let err = governance::set_status(
&RFC_KIND,
&rfc_root(root),
9,
RfcStatus::Resolved.as_str(),
"2099-01-01",
)
.unwrap_err();
assert!(
err.to_string().contains("not found"),
"missing RFC is a hard error: {err}"
);
}
#[test]
fn rfc_list_default_hides_resolved_and_withdrawn() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
rfc_at(root, 1, "open", "alpha", "Alpha");
rfc_at(root, 2, "resolved", "beta", "Beta");
rfc_at(root, 3, "withdrawn", "gamma", "Gamma");
let out = governance::list_rows(&RFC_KIND, root, ListArgs::default()).unwrap();
assert!(out.contains("RFC-001"), "default shows open: {out}");
assert!(!out.contains("RFC-002"), "default hides resolved: {out}");
assert!(!out.contains("RFC-003"), "default hides withdrawn: {out}");
}
#[test]
fn rfc_list_explicit_status_resolved_surfaces_it_and_filters_to_it() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
rfc_at(root, 1, "open", "alpha", "Alpha");
rfc_at(root, 2, "resolved", "beta", "Beta");
let out = governance::list_rows(
&RFC_KIND,
root,
ListArgs {
status: vec!["resolved".into()],
..ListArgs::default()
},
)
.unwrap();
assert!(out.contains("RFC-002"), "--status resolved surfaces: {out}");
assert!(
!out.contains("RFC-001"),
"and filters to resolved only: {out}"
);
}
#[test]
fn rfc_list_all_reveals_every_status() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
rfc_at(root, 1, "open", "alpha", "Alpha");
rfc_at(root, 2, "resolved", "beta", "Beta");
rfc_at(root, 3, "withdrawn", "gamma", "Gamma");
let out = governance::list_rows(
&RFC_KIND,
root,
ListArgs {
all: true,
..ListArgs::default()
},
)
.unwrap();
assert!(out.contains("RFC-001"), "--all reveals open: {out}");
assert!(out.contains("RFC-002"), "--all reveals resolved: {out}");
assert!(out.contains("RFC-003"), "--all reveals withdrawn: {out}");
}
#[test]
fn rfc_list_rejects_an_unknown_status_with_a_clear_error() {
let dir = tempfile::tempdir().unwrap();
let root = dir.path();
rfc_at(root, 1, "open", "alpha", "Alpha");
let err = governance::list_rows(
&RFC_KIND,
root,
ListArgs {
status: vec!["bogus".into()],
..ListArgs::default()
},
)
.unwrap_err()
.to_string();
assert!(err.contains("bogus"), "names the bad value: {err}");
assert!(err.contains("open"), "lists the known set: {err}");
assert!(
err.contains("resolved"),
"known set includes resolved: {err}"
);
assert!(
err.contains("withdrawn"),
"known set includes withdrawn: {err}"
);
}
#[test]
fn rfc_list_accepts_every_known_status_value() {
let dir = tempfile::tempdir().unwrap();
for s in RFC_STATUSES {
assert!(
governance::list_rows(
&RFC_KIND,
dir.path(),
ListArgs {
status: vec![(*s).to_string()],
..ListArgs::default()
},
)
.is_ok(),
"known status `{s}` accepted by --status"
);
}
}
}