use std::io::{self, Write};
use std::path::PathBuf;
use crate::entity::{self, Artifact, Fileset, Kind, ScaffoldCtx};
use crate::governance::{self, GovKind};
use crate::listing::{Format, ListArgs};
use crate::tomlfmt::toml_string;
const STANDARD_DIR: &str = ".doctrine/standard";
pub(crate) const STANDARD_KIND: GovKind = GovKind {
kind: Kind {
dir: STANDARD_DIR,
prefix: crate::kinds::STD,
stem: "standard",
scaffold: standard_scaffold,
},
statuses: STANDARD_STATUSES,
hidden: is_hidden,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, clap::ValueEnum)]
pub(crate) enum StandardStatus {
Draft,
Default,
Required,
Superseded,
Deprecated,
Retired,
}
impl StandardStatus {
pub(crate) fn as_str(self) -> &'static str {
match self {
Self::Draft => "draft",
Self::Default => "default",
Self::Required => "required",
Self::Superseded => "superseded",
Self::Deprecated => "deprecated",
Self::Retired => "retired",
}
}
}
pub(crate) const STANDARD_STATUSES: &[&str] = &[
"draft",
"default",
"required",
"superseded",
"deprecated",
"retired",
];
fn is_hidden(status: &str) -> bool {
matches!(status, "superseded" | "deprecated" | "retired")
}
fn render_standard_toml(id: u32, slug: &str, title: &str, date: &str) -> anyhow::Result<String> {
Ok(crate::install::asset_text("templates/standard.toml")?
.replace("{{id}}", &id.to_string())
.replace("{{slug}}", &toml_string(slug))
.replace("{{title}}", &toml_string(title))
.replace("{{date}}", date))
}
fn render_standard_md(canonical_id: &str, title: &str) -> anyhow::Result<String> {
Ok(crate::install::asset_text("templates/standard.md")?
.replace("{{ref}}", canonical_id)
.replace("{{title}}", title))
}
fn standard_scaffold(ctx: &ScaffoldCtx<'_>) -> anyhow::Result<Fileset> {
let id = ctx.id;
let name = format!("{id:03}");
Ok(vec![
Artifact::File {
rel_path: entity::rel_path(&STANDARD_KIND.kind, id, entity::Ext::Toml),
body: render_standard_toml(id, ctx.slug, ctx.title, ctx.date)?,
},
Artifact::File {
rel_path: entity::rel_path(&STANDARD_KIND.kind, id, entity::Ext::Md),
body: render_standard_md(ctx.canonical, ctx.title)?,
},
Artifact::Symlink {
rel_path: PathBuf::from(format!("{name}-{}", ctx.slug)),
target: name,
},
])
}
pub(crate) fn run_new(
path: Option<PathBuf>,
title: Option<String>,
slug: Option<String>,
) -> anyhow::Result<()> {
governance::run_new(&STANDARD_KIND, path, title, slug)
}
pub(crate) fn run_list(path: Option<PathBuf>, args: ListArgs) -> anyhow::Result<()> {
governance::run_list(&STANDARD_KIND, path, args)
}
pub(crate) fn run_show(
path: Option<PathBuf>,
reference: &str,
format: Format,
) -> anyhow::Result<()> {
governance::run_show(&STANDARD_KIND, path, reference, format)
}
pub(crate) fn parse_ref(reference: &str) -> anyhow::Result<u32> {
governance::parse_entity_ref("STD", "a standard", reference)
}
fn parse_cli_id(s: &str) -> Result<u32, String> {
parse_ref(s).map_err(|e| format!("{e:#}"))
}
pub(crate) fn run_status(
path: Option<PathBuf>,
id: u32,
status: StandardStatus,
color: bool,
) -> anyhow::Result<()> {
let root = crate::root::find(path, &crate::root::default_markers())?;
governance::set_status(
&STANDARD_KIND,
&root,
id,
status.as_str(),
&crate::clock::today(),
)?;
writeln!(
io::stdout(),
"STD {id:03}: {}",
crate::listing::status_colored(status.as_str(), color)
)?;
Ok(())
}
use std::str::FromStr;
use crate::CommonListArgs;
use clap::Subcommand;
#[derive(Subcommand)]
pub(crate) enum StandardCommand {
New {
title: Option<String>,
#[arg(long)]
slug: Option<String>,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
List {
#[command(flatten)]
list: CommonListArgs,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Show {
reference: String,
#[arg(long, value_parser = Format::from_str, default_value_t = Format::Table)]
format: Format,
#[arg(long)]
json: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Status {
#[arg(value_parser = parse_cli_id)]
id: u32,
#[arg(long)]
status: StandardStatus,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
Paths {
refs: Vec<String>,
#[arg(short = 't', long)]
toml: bool,
#[arg(short = 'm', long)]
md: bool,
#[arg(short = 'e', long)]
entity: bool,
#[arg(short = 's', long)]
single: bool,
#[arg(short = 'p', long)]
path: Option<PathBuf>,
},
}
pub(crate) fn dispatch(cmd: StandardCommand, color: bool) -> anyhow::Result<()> {
match cmd {
StandardCommand::New { title, slug, path } => run_new(path, title, slug),
StandardCommand::List { list, path } => run_list(path, list.into_list_args(color)),
StandardCommand::Show {
reference,
format,
json,
path,
} => run_show(path, &reference, if json { Format::Json } else { format }),
StandardCommand::Status { id, status, path } => run_status(path, id, status, color),
StandardCommand::Paths {
refs,
toml,
md,
entity,
single,
path,
} => governance::run_paths(
&STANDARD_KIND,
path,
&refs,
&crate::paths::PathSelection {
toml,
md,
entity,
single,
},
),
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::meta::Meta;
use std::path::Path;
#[test]
fn render_standard_toml_round_trips_to_metadata() {
let body =
render_standard_toml(7, "two-space-indent", "Two-space indent", "2026-06-04").unwrap();
let parsed: Meta = toml::from_str(&body).unwrap();
assert_eq!(
parsed,
Meta {
id: 7,
slug: "two-space-indent".to_string(),
title: "Two-space indent".to_string(),
status: "draft".to_string(),
tags: vec![],
}
);
assert!(body.contains("created = \"2026-06-04\""));
assert!(!body.contains("{{"));
}
#[test]
fn render_standard_toml_escapes_hostile_title_and_slug() {
let title = crate::tomlfmt::HOSTILE_TITLE;
let slug = crate::tomlfmt::HOSTILE_SLUG;
let body = render_standard_toml(7, 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_standard_toml_relationships_are_preserved_and_ignored_by_meta() {
let body = render_standard_toml(1, "s", "T", "2026-06-04").unwrap();
let doc: toml::Value = toml::from_str(&body).unwrap();
for axis in ["superseded_by"] {
assert!(
doc["relationships"][axis].as_array().unwrap().is_empty(),
"{axis} should seed empty"
);
}
assert!(toml::from_str::<Meta>(&body).is_ok());
}
#[test]
fn render_standard_md_substitutes_ref_and_title_without_frontmatter() {
let body = render_standard_md("STD-007", "Two-space indent").unwrap();
assert!(body.starts_with("# STD-007: Two-space indent"));
assert!(!body.contains("{{ref}}"));
assert!(!body.contains("{{title}}"));
assert!(!body.starts_with("---"));
assert!(!body.contains("\n---\n"));
assert!(body.contains("status \"default\""));
}
#[test]
fn standard_scaffold_lays_out_two_files_and_a_symlink() {
let ctx = ScaffoldCtx {
id: 7,
canonical: "STD-007",
slug: "two-space-indent",
title: "Two-space indent",
date: "2026-06-04",
};
let fileset = standard_scaffold(&ctx).unwrap();
assert_eq!(fileset.len(), 3);
assert!(matches!(&fileset[0],
Artifact::File { rel_path, body }
if rel_path == Path::new("007/standard-007.toml") && body.contains("2026-06-04")));
assert!(matches!(&fileset[1],
Artifact::File { rel_path, body }
if rel_path == Path::new("007/standard-007.md") && body.contains("STD-007: Two-space indent")));
assert!(matches!(&fileset[2],
Artifact::Symlink { rel_path, target }
if rel_path == Path::new("007-two-space-indent") && target == "007"));
}
#[test]
fn standard_known_set_matches_variants() {
let variants = [
StandardStatus::Draft,
StandardStatus::Default,
StandardStatus::Required,
StandardStatus::Superseded,
StandardStatus::Deprecated,
StandardStatus::Retired,
];
let from_variants: Vec<&str> = variants.iter().map(|v| v.as_str()).collect();
assert_eq!(from_variants, STANDARD_STATUSES.to_vec());
}
#[test]
fn standard_hide_set_is_a_subset_of_the_known_set() {
for s in STANDARD_STATUSES {
let _ = is_hidden(s);
}
assert!(is_hidden("superseded"));
assert!(is_hidden("deprecated"));
assert!(is_hidden("retired"));
assert!(!is_hidden("draft"));
assert!(!is_hidden("default"));
assert!(!is_hidden("required"));
}
#[test]
fn run_new_bails_for_a_slug_on_a_symbol_only_title() {
let dir = tempfile::tempdir().unwrap();
let err = run_new(Some(dir.path().to_path_buf()), Some("!!!".into()), None).unwrap_err();
assert!(err.to_string().contains("pass --slug"));
}
#[test]
fn parse_ref_accepts_prefixed_padded_and_bare_ids() {
assert_eq!(parse_ref("STD-007").unwrap(), 7);
assert_eq!(parse_ref("std-7").unwrap(), 7);
assert_eq!(parse_ref("7").unwrap(), 7);
assert_eq!(parse_ref("007").unwrap(), 7);
let err = parse_ref("nope").unwrap_err().to_string();
assert!(
err.contains("standard"),
"error should name standard kind: {err}"
);
}
}