use crate::cmd::edit::rules as edit_rules;
use crate::config::Config;
use crate::diagnostic::{Diagnostic, DiagnosticCode};
use crate::load::find_clause_json;
use crate::model::{
AdrStatus, ClauseStatus, ProjectIndex, ReleasesFile, RfcIndex, RfcPhase, RfcStatus,
WorkItemStatus,
};
use crate::signature::{compute_rfc_signature, extract_signature};
use crate::write::read_clause;
use regex::Regex;
use std::collections::HashSet;
pub struct ValidationContext<'a> {
pub config: &'a Config,
pub artifact_id: &'a str,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[allow(dead_code)] pub enum ArtifactKind {
Rfc,
Clause,
Adr,
WorkItem,
}
impl ArtifactKind {
fn as_ssot_artifact(self) -> &'static str {
match self {
Self::Rfc => "rfc",
Self::Clause => "clause",
Self::Adr => "adr",
Self::WorkItem => "work",
}
}
}
#[derive(Debug, Clone)]
pub enum FieldValidation {
None,
Semver,
ClauseSupersededBy,
ArtifactRef,
EnumValue,
}
impl FieldValidation {
pub fn for_field(kind: ArtifactKind, field: &str) -> Self {
match edit_rules::field_validation_kind(kind.as_ssot_artifact(), field) {
Some(edit_rules::ValidationKind::Semver) => Self::Semver,
Some(edit_rules::ValidationKind::ClauseSupersededBy) => Self::ClauseSupersededBy,
Some(edit_rules::ValidationKind::ArtifactRef) => Self::ArtifactRef,
Some(edit_rules::ValidationKind::EnumValue) => Self::EnumValue,
None => Self::None,
}
}
pub fn validate(&self, ctx: &ValidationContext, value: &str) -> anyhow::Result<()> {
match self {
Self::None => Ok(()),
Self::EnumValue => Ok(()), Self::Semver => validate_semver(value),
Self::ClauseSupersededBy => validate_clause_superseded_by(ctx, value),
Self::ArtifactRef => validate_artifact_ref(ctx, value),
}
}
}
fn validate_semver(value: &str) -> anyhow::Result<()> {
semver::Version::parse(value).map_err(|_| {
Diagnostic::new(
DiagnosticCode::E0820InvalidFieldValue,
format!("Invalid semver: {value}"),
value,
)
})?;
Ok(())
}
fn validate_clause_superseded_by(ctx: &ValidationContext, target: &str) -> anyhow::Result<()> {
if target.is_empty() {
return Ok(());
}
let (source_rfc, source_clause) = ctx.artifact_id.split_once(':').ok_or_else(|| {
Diagnostic::new(
DiagnosticCode::E0210ClauseInvalidIdFormat,
format!("Invalid clause ID format: {}", ctx.artifact_id),
ctx.artifact_id,
)
})?;
if source_rfc.is_empty() || source_clause.is_empty() {
return Err(Diagnostic::new(
DiagnosticCode::E0210ClauseInvalidIdFormat,
format!("Invalid clause ID format: {}", ctx.artifact_id),
ctx.artifact_id,
)
.into());
}
let full_target = if target.contains(':') {
target.to_string()
} else {
format!("{source_rfc}:{target}")
};
let (target_rfc, target_clause) = full_target.split_once(':').ok_or_else(|| {
Diagnostic::new(
DiagnosticCode::E0210ClauseInvalidIdFormat,
format!("Invalid target clause ID: {target}"),
target,
)
})?;
if target_rfc.is_empty() || target_clause.is_empty() {
return Err(Diagnostic::new(
DiagnosticCode::E0210ClauseInvalidIdFormat,
format!("Invalid target clause ID: {target}"),
target,
)
.into());
}
if target_rfc != source_rfc {
return Err(Diagnostic::new(
DiagnosticCode::E0206ClauseSupersededByUnknown,
format!(
"superseded_by must reference a clause in the same RFC (got {target_rfc}, expected {source_rfc})"
),
target,
)
.into());
}
let target_path = find_clause_json(ctx.config, &full_target).ok_or_else(|| {
Diagnostic::new(
DiagnosticCode::E0202ClauseNotFound,
format!("Target clause not found: {full_target}"),
&full_target,
)
})?;
let target_clause = read_clause(ctx.config, &target_path)?;
match target_clause.status {
ClauseStatus::Active => Ok(()),
ClauseStatus::Superseded => Err(Diagnostic::new(
DiagnosticCode::E0207ClauseSupersededByNotActive,
format!("Cannot supersede by a superseded clause: {full_target}"),
&full_target,
)
.into()),
ClauseStatus::Deprecated => Err(Diagnostic::new(
DiagnosticCode::E0207ClauseSupersededByNotActive,
format!("Cannot supersede by a deprecated clause: {full_target}"),
&full_target,
)
.into()),
}
}
fn validate_artifact_ref(ctx: &ValidationContext, ref_id: &str) -> anyhow::Result<()> {
use crate::load::find_rfc_json;
use crate::parse::{load_adrs, load_work_items};
if ref_id.starts_with("RFC-") {
if find_rfc_json(ctx.config, ref_id).is_none() {
return Err(Diagnostic::new(
DiagnosticCode::E0102RfcNotFound,
format!("RFC not found: {ref_id}"),
ref_id,
)
.into());
}
} else if ref_id.starts_with("ADR-") {
let adrs = load_adrs(ctx.config)?;
if !adrs.iter().any(|a| a.spec.govctl.id == ref_id) {
return Err(Diagnostic::new(
DiagnosticCode::E0302AdrNotFound,
format!("ADR not found: {ref_id}"),
ref_id,
)
.into());
}
} else if ref_id.starts_with("WI-") {
let items = load_work_items(ctx.config)?;
if !items.iter().any(|w| w.spec.govctl.id == ref_id) {
return Err(Diagnostic::new(
DiagnosticCode::E0402WorkNotFound,
format!("Work item not found: {ref_id}"),
ref_id,
)
.into());
}
} else {
return Err(Diagnostic::new(
DiagnosticCode::E0813SupersedeNotSupported,
format!("Unknown artifact type: {ref_id}"),
ref_id,
)
.into());
}
check_ref_hierarchy(ctx.artifact_id, ref_id, ctx.artifact_id).map_err(|e| e.into())
}
fn check_ref_hierarchy(
artifact_id: &str,
ref_id: &str,
diagnostic_path: &str,
) -> Result<(), Diagnostic> {
let owner_is_rfc = artifact_id.starts_with("RFC-");
let owner_is_adr = artifact_id.starts_with("ADR-");
let owner_is_wi = artifact_id.starts_with("WI-");
if owner_is_wi {
return Ok(());
}
if owner_is_rfc && (ref_id.starts_with("ADR-") || ref_id.starts_with("WI-")) {
return Err(Diagnostic::new(
DiagnosticCode::E0112RfcReferenceHierarchy,
format!(
"RFC '{artifact_id}' references {ref_id}, but RFCs are higher authority than ADRs and Work Items — remove this reference (the ADR or Work Item should reference the RFC, not the other way around)"
),
diagnostic_path,
));
}
if owner_is_adr && ref_id.starts_with("WI-") {
return Err(Diagnostic::new(
DiagnosticCode::E0306AdrReferenceHierarchy,
format!(
"ADR '{artifact_id}' references {ref_id}, but ADRs are higher authority than Work Items — remove this reference (the Work Item should reference the ADR, not the other way around)"
),
diagnostic_path,
));
}
Ok(())
}
pub fn validate_field(
config: &Config,
artifact_id: &str,
kind: ArtifactKind,
field: &str,
value: &str,
) -> anyhow::Result<()> {
let ctx = ValidationContext {
config,
artifact_id,
};
let validation = FieldValidation::for_field(kind, field);
validation.validate(&ctx, value)
}
#[derive(Debug, Default)]
pub struct ValidationResult {
pub diagnostics: Vec<Diagnostic>,
pub rfc_count: usize,
pub clause_count: usize,
pub adr_count: usize,
pub work_count: usize,
}
impl ValidationResult {
#[allow(dead_code)]
pub fn has_errors(&self) -> bool {
self.diagnostics
.iter()
.any(|d| d.level == crate::diagnostic::DiagnosticLevel::Error)
}
}
pub fn validate_project(index: &ProjectIndex, config: &Config) -> ValidationResult {
let mut result = ValidationResult {
rfc_count: index.rfcs.len(),
clause_count: index.iter_clauses().count(),
adr_count: index.adrs.len(),
work_count: index.work_items.len(),
..Default::default()
};
for rfc in &index.rfcs {
validate_rfc(rfc, config, &mut result);
}
validate_rfc_signatures(index, config, &mut result);
validate_clause_references(index, config, &mut result);
for adr in &index.adrs {
let adr_path_display = config.display_path(&adr.path).display().to_string();
if adr.meta().refs.is_empty() {
result.diagnostics.push(Diagnostic::new(
DiagnosticCode::W0103AdrNoRefs,
format!(
"ADR has no artifact references (hint: `govctl adr add {} refs RFC-XXXX`)",
adr.meta().id
),
adr_path_display.clone(),
));
}
if adr.spec.content.context.contains("Describe the context") {
result.diagnostics.push(Diagnostic::new(
DiagnosticCode::W0103AdrNoRefs,
format!(
"ADR has placeholder context (hint: `govctl adr set {} context \"...\"`)",
adr.meta().id
),
adr_path_display,
));
}
}
validate_artifact_refs(index, config, &mut result);
validate_bracket_reference_hierarchy(index, config, &mut result);
validate_work_item_descriptions(index, config, &mut result);
result
}
fn validate_rfc_signatures(index: &ProjectIndex, config: &Config, result: &mut ValidationResult) {
let output_dir = config.rfc_output();
for rfc in &index.rfcs {
let md_path = output_dir.join(format!("{}.md", rfc.rfc.rfc_id));
if !md_path.exists() {
continue;
}
let md_content = match std::fs::read_to_string(&md_path) {
Ok(content) => content,
Err(e) => {
result.diagnostics.push(Diagnostic::new(
DiagnosticCode::W0106RenderedReadError,
format!(
"Could not read rendered markdown: {} (hint: run `govctl rfc render`)",
e
),
config.display_path(&md_path).display().to_string(),
));
continue;
}
};
let md_path_display = config.display_path(&md_path).display().to_string();
let Some(existing_sig) = extract_signature(&md_content) else {
result.diagnostics.push(Diagnostic::new(
DiagnosticCode::E0602SignatureMissing,
format!(
"Rendered markdown missing signature. Run 'govctl render' to regenerate: {}",
rfc.rfc.rfc_id
),
md_path_display.clone(),
));
continue;
};
let expected_sig = match compute_rfc_signature(rfc) {
Ok(sig) => sig,
Err(e) => {
result.diagnostics.push(Diagnostic::new(
DiagnosticCode::E0601SignatureMismatch,
format!("Failed to compute signature for {}: {}", rfc.rfc.rfc_id, e),
md_path_display.clone(),
));
continue;
}
};
if existing_sig != expected_sig {
result.diagnostics.push(Diagnostic::new(
DiagnosticCode::E0601SignatureMismatch,
format!(
"Signature mismatch: rendered markdown was edited directly or source changed. Run 'govctl render' to regenerate: {}",
rfc.rfc.rfc_id
),
md_path_display,
));
}
}
}
fn validate_rfc(rfc: &RfcIndex, config: &Config, result: &mut ValidationResult) {
let rfc_path_display = config.display_path(&rfc.path).display().to_string();
let dir_name = rfc
.path
.parent()
.and_then(|p| p.file_name())
.and_then(|n| n.to_str());
if let Some(name) = dir_name
&& name != rfc.rfc.rfc_id
{
result.diagnostics.push(Diagnostic::new(
DiagnosticCode::E0103RfcIdMismatch,
format!(
"RFC ID '{}' doesn't match directory '{}'",
rfc.rfc.rfc_id, name
),
rfc_path_display.clone(),
));
}
if rfc.rfc.changelog.is_empty() {
result.diagnostics.push(Diagnostic::new(
DiagnosticCode::W0101RfcNoChangelog,
"RFC has no changelog entries (hint: run `govctl rfc bump`)",
rfc_path_display.clone(),
));
}
validate_status_phase_constraints(rfc, config, result);
for clause in &rfc.clauses {
let clause_path_display = config.display_path(&clause.path).display().to_string();
if clause.spec.since.is_none() {
result.diagnostics.push(Diagnostic::new(
DiagnosticCode::W0102ClauseNoSince,
format!(
"Clause '{}' has no 'since' version (hint: it will be set automatically by `govctl rfc bump` or `govctl rfc finalize`)",
clause.spec.clause_id
),
clause_path_display.clone(),
));
}
let file_name = clause
.path
.file_stem()
.and_then(|n| n.to_str())
.unwrap_or("");
if file_name != clause.spec.clause_id {
result.diagnostics.push(Diagnostic::new(
DiagnosticCode::E0203ClauseIdMismatch,
format!(
"Clause ID '{}' doesn't match filename '{}'",
clause.spec.clause_id, file_name
),
clause_path_display,
));
}
}
}
fn validate_status_phase_constraints(
rfc: &RfcIndex,
config: &Config,
result: &mut ValidationResult,
) {
let status = rfc.rfc.status;
let phase = rfc.rfc.phase;
let path_display = config.display_path(&rfc.path).display().to_string();
if status == RfcStatus::Draft && phase == RfcPhase::Stable {
result.diagnostics.push(Diagnostic::new(
DiagnosticCode::E0104RfcInvalidTransition,
"Cannot have status=draft with phase=stable",
path_display.clone(),
));
}
if status == RfcStatus::Deprecated && (phase == RfcPhase::Impl || phase == RfcPhase::Test) {
result.diagnostics.push(Diagnostic::new(
DiagnosticCode::E0104RfcInvalidTransition,
format!(
"Cannot have status=deprecated with phase={}",
phase.as_ref()
),
path_display,
));
}
}
fn validate_clause_references(
index: &ProjectIndex,
config: &Config,
result: &mut ValidationResult,
) {
let active_clauses: HashSet<String> = index
.iter_clauses()
.filter(|(_, c)| c.spec.status == ClauseStatus::Active)
.map(|(rfc, c)| format!("{}:{}", rfc.rfc.rfc_id, c.spec.clause_id))
.collect();
for (rfc, clause) in index.iter_clauses() {
if let Some(ref superseded_by) = clause.spec.superseded_by {
let clause_path_display = config.display_path(&clause.path).display().to_string();
if clause.spec.status != ClauseStatus::Superseded {
result.diagnostics.push(Diagnostic::new(
DiagnosticCode::E0206ClauseSupersededByUnknown,
format!(
"Clause has superseded_by but status is not 'superseded': {}",
clause.spec.clause_id
),
clause_path_display.clone(),
));
}
let full_ref = if superseded_by.contains(':') {
superseded_by.clone()
} else {
format!("{}:{}", rfc.rfc.rfc_id, superseded_by)
};
if !active_clauses.contains(&full_ref) {
result.diagnostics.push(Diagnostic::new(
DiagnosticCode::E0207ClauseSupersededByNotActive,
format!(
"Clause '{}' superseded by '{}' which is not active",
clause.spec.clause_id, superseded_by
),
clause_path_display,
));
}
}
}
}
fn validate_bracket_reference_hierarchy(
index: &ProjectIndex,
config: &Config,
result: &mut ValidationResult,
) {
let re = match Regex::new(&config.source_scan.pattern) {
Ok(r) => r,
Err(e) => {
result.diagnostics.push(Diagnostic::new(
DiagnosticCode::E0501ConfigInvalid,
format!("Invalid source_scan.pattern for bracket reference scan: {e}"),
"gov/config.toml".to_string(),
));
return;
}
};
for rfc in &index.rfcs {
let rfc_path = config.display_path(&rfc.path).display().to_string();
let rid = rfc.rfc.rfc_id.as_str();
for clause in &rfc.clauses {
let clause_path = config.display_path(&clause.path).display().to_string();
scan_rfc_bracket_refs(&re, &clause.spec.text, rid, &clause_path, result);
}
for entry in &rfc.rfc.changelog {
if let Some(ref notes) = entry.notes {
scan_rfc_bracket_refs(&re, notes, rid, &rfc_path, result);
}
for line in entry
.added
.iter()
.chain(entry.changed.iter())
.chain(entry.deprecated.iter())
.chain(entry.removed.iter())
.chain(entry.fixed.iter())
.chain(entry.security.iter())
{
scan_rfc_bracket_refs(&re, line, rid, &rfc_path, result);
}
}
}
for adr in &index.adrs {
let adr_path = config.display_path(&adr.path).display().to_string();
let aid = adr.meta().id.as_str();
let c = &adr.spec.content;
scan_adr_bracket_refs(&re, &c.context, aid, &adr_path, result);
scan_adr_bracket_refs(&re, &c.decision, aid, &adr_path, result);
scan_adr_bracket_refs(&re, &c.consequences, aid, &adr_path, result);
for alt in &c.alternatives {
scan_adr_bracket_refs(&re, &alt.text, aid, &adr_path, result);
for p in &alt.pros {
scan_adr_bracket_refs(&re, p, aid, &adr_path, result);
}
for cons in &alt.cons {
scan_adr_bracket_refs(&re, cons, aid, &adr_path, result);
}
if let Some(ref rr) = alt.rejection_reason {
scan_adr_bracket_refs(&re, rr, aid, &adr_path, result);
}
}
}
}
fn scan_rfc_bracket_refs(
re: &Regex,
text: &str,
rfc_id: &str,
path: &str,
result: &mut ValidationResult,
) {
for caps in re.captures_iter(text) {
let Some(m) = caps.get(1) else {
continue;
};
let target = m.as_str();
if target.starts_with("ADR-") || target.starts_with("WI-") {
result.diagnostics.push(Diagnostic::new(
DiagnosticCode::E0112RfcReferenceHierarchy,
format!(
"RFC '{rfc_id}' links to [[{target}]], but RFCs are higher authority than ADRs and Work Items — remove this link (the ADR or Work Item should reference the RFC, not the other way around)"
),
path,
));
}
}
}
fn scan_adr_bracket_refs(
re: &Regex,
text: &str,
adr_id: &str,
path: &str,
result: &mut ValidationResult,
) {
for caps in re.captures_iter(text) {
let Some(m) = caps.get(1) else {
continue;
};
let target = m.as_str();
if target.starts_with("WI-") {
result.diagnostics.push(Diagnostic::new(
DiagnosticCode::E0306AdrReferenceHierarchy,
format!("ADR '{adr_id}' links to [[{target}]], but ADRs are higher authority than Work Items — remove this link (the Work Item should reference the ADR, not the other way around)"),
path,
));
}
}
}
fn validate_artifact_refs(index: &ProjectIndex, config: &Config, result: &mut ValidationResult) {
let mut known_ids: HashSet<String> = HashSet::new();
for rfc in &index.rfcs {
known_ids.insert(rfc.rfc.rfc_id.clone());
for clause in &rfc.clauses {
known_ids.insert(format!("{}:{}", rfc.rfc.rfc_id, clause.spec.clause_id));
}
}
for adr in &index.adrs {
known_ids.insert(adr.meta().id.clone());
}
for work in &index.work_items {
known_ids.insert(work.meta().id.clone());
}
for rfc in &index.rfcs {
let rfc_path_display = config.display_path(&rfc.path).display().to_string();
for ref_id in &rfc.rfc.refs {
if !known_ids.contains(ref_id) {
result.diagnostics.push(Diagnostic::new(
DiagnosticCode::E0105RfcRefNotFound,
format!(
"RFC '{}' references unknown artifact: {}",
rfc.rfc.rfc_id, ref_id
),
rfc_path_display.clone(),
));
} else if let Err(d) = check_ref_hierarchy(&rfc.rfc.rfc_id, ref_id, &rfc_path_display) {
result.diagnostics.push(d);
}
}
if let Some(ref supersedes) = rfc.rfc.supersedes {
if !known_ids.contains(supersedes) {
result.diagnostics.push(Diagnostic::new(
DiagnosticCode::E0106RfcSupersedesNotFound,
format!(
"RFC '{}' supersedes unknown RFC: {}",
rfc.rfc.rfc_id, supersedes
),
rfc_path_display.clone(),
));
} else if let Err(d) =
check_ref_hierarchy(&rfc.rfc.rfc_id, supersedes, &rfc_path_display)
{
result.diagnostics.push(d);
}
}
}
for adr in &index.adrs {
let adr_path_display = config.display_path(&adr.path).display().to_string();
for ref_id in &adr.meta().refs {
if !known_ids.contains(ref_id) {
result.diagnostics.push(Diagnostic::new(
DiagnosticCode::E0304AdrRefNotFound,
format!(
"ADR '{}' references unknown artifact: {}",
adr.meta().id,
ref_id
),
adr_path_display.clone(),
));
} else if let Err(d) = check_ref_hierarchy(&adr.meta().id, ref_id, &adr_path_display) {
result.diagnostics.push(d);
}
}
}
for work in &index.work_items {
let work_path_display = config.display_path(&work.path).display().to_string();
for ref_id in &work.meta().refs {
if !known_ids.contains(ref_id) {
result.diagnostics.push(Diagnostic::new(
DiagnosticCode::E0404WorkRefNotFound,
format!(
"Work item '{}' references unknown artifact: {}",
work.meta().id,
ref_id
),
work_path_display.clone(),
));
}
}
}
}
pub fn validate_releases(
releases: &ReleasesFile,
index: &ProjectIndex,
config: &Config,
) -> Vec<Diagnostic> {
let mut diagnostics = Vec::new();
let mut seen_versions = HashSet::new();
let known_work_ids: HashSet<&str> = index
.work_items
.iter()
.map(|work| work.meta().id.as_str())
.collect();
let releases_display = config
.display_path(&config.releases_path())
.display()
.to_string();
for release in &releases.releases {
if !seen_versions.insert(release.version.as_str()) {
diagnostics.push(Diagnostic::new(
DiagnosticCode::E0702ReleaseDuplicate,
format!("Duplicate release version: {}", release.version),
releases_display.clone(),
));
}
for work_id in &release.refs {
if !known_work_ids.contains(work_id.as_str()) {
diagnostics.push(Diagnostic::new(
DiagnosticCode::E0705ReleaseRefNotFound,
format!(
"Release '{}' references unknown work item: {}",
release.version, work_id
),
releases_display.clone(),
));
}
}
}
diagnostics
}
fn is_placeholder_description(desc: &str) -> bool {
let trimmed = desc.trim();
if trimmed.is_empty() {
return true;
}
if trimmed.contains("Describe the work to be done")
&& trimmed.contains("What is the goal?")
&& trimmed.contains("What are the acceptance criteria?")
{
return true;
}
let lower = trimmed.to_lowercase();
let placeholder_patterns = ["todo", "tbd", "fill in later", "placeholder", "fixme"];
placeholder_patterns
.iter()
.any(|p| lower == *p || lower == format!("[{}]", p) || lower == format!("<{}>", p))
}
fn validate_work_item_descriptions(
index: &ProjectIndex,
config: &Config,
result: &mut ValidationResult,
) {
for work in &index.work_items {
let desc = &work.spec.content.description;
if is_placeholder_description(desc) {
let path_display = config.display_path(&work.path).display().to_string();
result.diagnostics.push(Diagnostic::new(
DiagnosticCode::W0108WorkPlaceholderDescription,
format!(
"Work item has placeholder description (hint: `govctl work set {} description \"...\"`)",
work.meta().id
),
path_display,
));
}
}
}
pub fn is_valid_status_transition(from: RfcStatus, to: RfcStatus) -> bool {
matches!(
(from, to),
(RfcStatus::Draft, RfcStatus::Normative) | (RfcStatus::Normative, RfcStatus::Deprecated)
)
}
pub fn is_valid_phase_transition(from: RfcPhase, to: RfcPhase) -> bool {
matches!(
(from, to),
(RfcPhase::Spec, RfcPhase::Impl)
| (RfcPhase::Impl, RfcPhase::Test)
| (RfcPhase::Test, RfcPhase::Stable)
)
}
pub fn is_valid_adr_transition(from: AdrStatus, to: AdrStatus) -> bool {
matches!(
(from, to),
(AdrStatus::Proposed, AdrStatus::Accepted)
| (AdrStatus::Proposed, AdrStatus::Rejected)
| (AdrStatus::Accepted, AdrStatus::Superseded)
)
}
pub fn is_valid_work_transition(from: WorkItemStatus, to: WorkItemStatus) -> bool {
matches!(
(from, to),
(WorkItemStatus::Queue, WorkItemStatus::Active)
| (WorkItemStatus::Active, WorkItemStatus::Done)
| (WorkItemStatus::Queue, WorkItemStatus::Cancelled)
| (WorkItemStatus::Active, WorkItemStatus::Cancelled)
)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_rfc_status_draft_to_normative() {
assert!(is_valid_status_transition(
RfcStatus::Draft,
RfcStatus::Normative
));
}
#[test]
fn test_rfc_status_normative_to_deprecated() {
assert!(is_valid_status_transition(
RfcStatus::Normative,
RfcStatus::Deprecated
));
}
#[test]
fn test_rfc_status_invalid_draft_to_deprecated() {
assert!(!is_valid_status_transition(
RfcStatus::Draft,
RfcStatus::Deprecated
));
}
#[test]
fn test_rfc_status_invalid_normative_to_draft() {
assert!(!is_valid_status_transition(
RfcStatus::Normative,
RfcStatus::Draft
));
}
#[test]
fn test_rfc_status_invalid_deprecated_to_normative() {
assert!(!is_valid_status_transition(
RfcStatus::Deprecated,
RfcStatus::Normative
));
}
#[test]
fn test_rfc_status_same_state() {
assert!(!is_valid_status_transition(
RfcStatus::Draft,
RfcStatus::Draft
));
assert!(!is_valid_status_transition(
RfcStatus::Normative,
RfcStatus::Normative
));
}
#[test]
fn test_rfc_phase_spec_to_impl() {
assert!(is_valid_phase_transition(RfcPhase::Spec, RfcPhase::Impl));
}
#[test]
fn test_rfc_phase_impl_to_test() {
assert!(is_valid_phase_transition(RfcPhase::Impl, RfcPhase::Test));
}
#[test]
fn test_rfc_phase_test_to_stable() {
assert!(is_valid_phase_transition(RfcPhase::Test, RfcPhase::Stable));
}
#[test]
fn test_rfc_phase_invalid_skip() {
assert!(!is_valid_phase_transition(RfcPhase::Spec, RfcPhase::Test));
assert!(!is_valid_phase_transition(RfcPhase::Spec, RfcPhase::Stable));
assert!(!is_valid_phase_transition(RfcPhase::Impl, RfcPhase::Stable));
}
#[test]
fn test_rfc_phase_invalid_backward() {
assert!(!is_valid_phase_transition(RfcPhase::Stable, RfcPhase::Test));
assert!(!is_valid_phase_transition(RfcPhase::Test, RfcPhase::Impl));
assert!(!is_valid_phase_transition(RfcPhase::Impl, RfcPhase::Spec));
}
#[test]
fn test_adr_status_proposed_to_accepted() {
assert!(is_valid_adr_transition(
AdrStatus::Proposed,
AdrStatus::Accepted
));
}
#[test]
fn test_adr_status_accepted_to_superseded() {
assert!(is_valid_adr_transition(
AdrStatus::Accepted,
AdrStatus::Superseded
));
}
#[test]
fn test_adr_status_proposed_to_rejected() {
assert!(is_valid_adr_transition(
AdrStatus::Proposed,
AdrStatus::Rejected
));
}
#[test]
fn test_adr_status_invalid_proposed_to_superseded() {
assert!(!is_valid_adr_transition(
AdrStatus::Proposed,
AdrStatus::Superseded
));
}
#[test]
fn test_adr_status_invalid_rejected_transitions() {
assert!(!is_valid_adr_transition(
AdrStatus::Rejected,
AdrStatus::Accepted
));
assert!(!is_valid_adr_transition(
AdrStatus::Rejected,
AdrStatus::Proposed
));
}
#[test]
fn test_adr_status_invalid_backward() {
assert!(!is_valid_adr_transition(
AdrStatus::Accepted,
AdrStatus::Proposed
));
assert!(!is_valid_adr_transition(
AdrStatus::Superseded,
AdrStatus::Accepted
));
}
#[test]
fn test_work_status_queue_to_active() {
assert!(is_valid_work_transition(
WorkItemStatus::Queue,
WorkItemStatus::Active
));
}
#[test]
fn test_work_status_active_to_done() {
assert!(is_valid_work_transition(
WorkItemStatus::Active,
WorkItemStatus::Done
));
}
#[test]
fn test_work_status_queue_to_cancelled() {
assert!(is_valid_work_transition(
WorkItemStatus::Queue,
WorkItemStatus::Cancelled
));
}
#[test]
fn test_work_status_active_to_cancelled() {
assert!(is_valid_work_transition(
WorkItemStatus::Active,
WorkItemStatus::Cancelled
));
}
#[test]
fn test_work_status_invalid_queue_to_done() {
assert!(!is_valid_work_transition(
WorkItemStatus::Queue,
WorkItemStatus::Done
));
}
#[test]
fn test_work_status_invalid_done_transitions() {
assert!(!is_valid_work_transition(
WorkItemStatus::Done,
WorkItemStatus::Active
));
assert!(!is_valid_work_transition(
WorkItemStatus::Done,
WorkItemStatus::Queue
));
}
#[test]
fn test_work_status_invalid_cancelled_transitions() {
assert!(!is_valid_work_transition(
WorkItemStatus::Cancelled,
WorkItemStatus::Active
));
assert!(!is_valid_work_transition(
WorkItemStatus::Cancelled,
WorkItemStatus::Queue
));
}
#[test]
fn test_field_validation_clause_since() {
assert!(matches!(
FieldValidation::for_field(ArtifactKind::Clause, "since"),
FieldValidation::Semver
));
}
#[test]
fn test_field_validation_clause_superseded_by() {
assert!(matches!(
FieldValidation::for_field(ArtifactKind::Clause, "superseded_by"),
FieldValidation::ClauseSupersededBy
));
}
#[test]
fn test_field_validation_rfc_version() {
assert!(matches!(
FieldValidation::for_field(ArtifactKind::Rfc, "version"),
FieldValidation::Semver
));
}
#[test]
fn test_field_validation_unknown_field() {
assert!(matches!(
FieldValidation::for_field(ArtifactKind::Rfc, "unknown"),
FieldValidation::None
));
}
#[test]
fn test_ref_hierarchy_rfc_rejects_adr_and_wi() {
assert!(check_ref_hierarchy("RFC-0001", "ADR-0001", "f").is_err());
assert!(check_ref_hierarchy("RFC-0001", "WI-2026-01-01-001", "f").is_err());
}
#[test]
fn test_ref_hierarchy_rfc_allows_rfc_and_clause() {
assert!(check_ref_hierarchy("RFC-0001", "RFC-0002", "f").is_ok());
assert!(check_ref_hierarchy("RFC-0001", "RFC-0002:C-FOO", "f").is_ok());
}
#[test]
fn test_ref_hierarchy_adr_rejects_wi() {
assert!(check_ref_hierarchy("ADR-0001", "WI-2026-01-01-001", "f").is_err());
}
#[test]
fn test_ref_hierarchy_adr_allows_rfc_adr() {
assert!(check_ref_hierarchy("ADR-0001", "RFC-0000:C-RFC-DEF", "f").is_ok());
assert!(check_ref_hierarchy("ADR-0001", "ADR-0002", "f").is_ok());
}
#[test]
fn test_ref_hierarchy_work_allows_any() {
assert!(check_ref_hierarchy("WI-2026-01-01-001", "WI-2026-01-01-002", "f").is_ok());
assert!(check_ref_hierarchy("WI-2026-01-01-001", "ADR-0001", "f").is_ok());
}
}