use std::fmt;
use serde::Deserialize;
pub const STRICT_TRACEABILITY_STATUSES: &[&str] = &[
"TYPED",
"WIRED",
"VALIDATED",
"PROVISIONAL",
"MISSING",
"DEFERRED",
"STUBBED",
];
const REQUIRED_GOVERNANCE_STATE: &str = "realignment_in_progress";
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum GovernanceValidationError {
MissingGovernanceState { file: String },
InvalidGovernanceState { file: String, found: String },
NoTraceabilityRows,
InvalidTraceabilityStatus { line: usize, status: String },
MissingTraceabilityEvidence { line: usize },
InvalidTraceabilityRow { line: usize },
}
impl fmt::Display for GovernanceValidationError {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
Self::MissingGovernanceState { file } => {
write!(f, "missing governance_state in {file}")
}
Self::InvalidGovernanceState { file, found } => write!(
f,
"invalid governance_state in {file}: expected {REQUIRED_GOVERNANCE_STATE}, found {found}"
),
Self::NoTraceabilityRows => write!(f, "no traceability rows found"),
Self::InvalidTraceabilityStatus { line, status } => {
write!(f, "invalid traceability status at line {line}: {status}")
}
Self::MissingTraceabilityEvidence { line } => {
write!(f, "missing traceability evidence/link at line {line}")
}
Self::InvalidTraceabilityRow { line } => {
write!(f, "invalid traceability row format at line {line}")
}
}
}
}
impl std::error::Error for GovernanceValidationError {}
#[derive(Debug, Deserialize)]
struct TraceabilityDocument {
#[serde(default)]
rows: Vec<TraceabilityRow>,
}
#[derive(Debug, Deserialize)]
struct TraceabilityRow {
status: String,
#[serde(default)]
evidence: Vec<String>,
}
pub fn validate_governance_state(
file_name: &str,
content: &str,
) -> Result<(), GovernanceValidationError> {
let line = content
.lines()
.find(|line| line.trim_start().starts_with("governance_state:"))
.ok_or_else(|| GovernanceValidationError::MissingGovernanceState {
file: file_name.to_string(),
})?;
let found = line
.split_once(':')
.map(|(_, value)| value.trim())
.unwrap_or_default()
.to_string();
if found != REQUIRED_GOVERNANCE_STATE {
return Err(GovernanceValidationError::InvalidGovernanceState {
file: file_name.to_string(),
found,
});
}
Ok(())
}
pub fn validate_traceability_statuses(markdown: &str) -> Result<(), GovernanceValidationError> {
if looks_like_markdown_table(markdown) {
return validate_markdown_traceability_statuses(markdown);
}
validate_yaml_traceability_statuses(markdown)
}
fn validate_markdown_traceability_statuses(
markdown: &str,
) -> Result<(), GovernanceValidationError> {
let mut seen_rows = false;
let mut status_column = None;
let mut evidence_or_link_column = None;
let mut header_line = None;
for (idx, line) in markdown.lines().enumerate() {
let trimmed = line.trim();
if !trimmed.starts_with('|') {
continue;
}
let columns = trimmed
.trim_start_matches('|')
.trim_end_matches('|')
.split('|')
.map(str::trim)
.collect::<Vec<_>>();
if columns.is_empty()
|| columns
.iter()
.all(|column| !column.is_empty() && column.chars().all(|ch| ch == '-' || ch == ':'))
{
continue;
}
if status_column.is_none() {
header_line = Some(idx + 1);
status_column = columns
.iter()
.position(|column| column.eq_ignore_ascii_case("Status"));
evidence_or_link_column = columns
.iter()
.position(|column| is_evidence_or_link_column(column));
continue;
}
let Some(status_column) = status_column else {
continue;
};
let Some(evidence_or_link_column) = evidence_or_link_column else {
return Err(GovernanceValidationError::InvalidTraceabilityRow {
line: header_line.unwrap_or(idx + 1),
});
};
if columns.len() <= status_column || columns.len() <= evidence_or_link_column {
return Err(GovernanceValidationError::InvalidTraceabilityRow { line: idx + 1 });
}
seen_rows = true;
let status = columns[status_column].trim_matches('`');
if !STRICT_TRACEABILITY_STATUSES.contains(&status) {
return Err(GovernanceValidationError::InvalidTraceabilityStatus {
line: idx + 1,
status: status.to_string(),
});
}
let evidence = columns[evidence_or_link_column].trim_matches('`').trim();
if status_requires_evidence(status) && is_missing_evidence(evidence) {
return Err(GovernanceValidationError::MissingTraceabilityEvidence { line: idx + 1 });
}
}
if !seen_rows {
return Err(GovernanceValidationError::NoTraceabilityRows);
}
Ok(())
}
fn validate_yaml_traceability_statuses(yaml: &str) -> Result<(), GovernanceValidationError> {
let document: TraceabilityDocument = serde_yaml::from_str(yaml)
.map_err(|_| GovernanceValidationError::InvalidTraceabilityRow { line: 1 })?;
if document.rows.is_empty() {
return Err(GovernanceValidationError::NoTraceabilityRows);
}
for (index, row) in document.rows.iter().enumerate() {
if !STRICT_TRACEABILITY_STATUSES.contains(&row.status.as_str()) {
return Err(GovernanceValidationError::InvalidTraceabilityStatus {
line: index + 1,
status: row.status.clone(),
});
}
if status_requires_evidence(&row.status)
&& row.evidence.iter().all(|entry| is_missing_evidence(entry))
{
return Err(GovernanceValidationError::MissingTraceabilityEvidence { line: index + 1 });
}
}
Ok(())
}
fn looks_like_markdown_table(content: &str) -> bool {
content
.lines()
.any(|line| line.trim_start().starts_with('|'))
}
fn is_evidence_or_link_column(column: &str) -> bool {
let normalized = column
.chars()
.filter(char::is_ascii_alphanumeric)
.collect::<String>()
.to_ascii_lowercase();
normalized.contains("evidence") || normalized.contains("link")
}
fn is_missing_evidence(value: &str) -> bool {
if value.is_empty() {
return true;
}
let normalized = value.to_ascii_lowercase();
matches!(
normalized.as_str(),
"-" | "--" | "n/a" | "na" | "none" | "null" | "todo" | "tbd" | "placeholder"
)
}
fn status_requires_evidence(status: &str) -> bool {
!matches!(status, "MISSING" | "DEFERRED" | "STUBBED")
}
pub fn validate_governance_contracts(
spec_yaml: &str,
plan_yaml: &str,
checklist_yaml: &str,
traceability_markdown: &str,
) -> Result<(), GovernanceValidationError> {
validate_governance_state(".rct/spec.yaml", spec_yaml)?;
validate_governance_state(".rct/plan.yaml", plan_yaml)?;
validate_governance_state(".rct/checklist.yaml", checklist_yaml)?;
validate_traceability_statuses(traceability_markdown)?;
Ok(())
}
#[deprecated(since = "0.4.11", note = "renamed to validate_governance_contracts")]
pub fn validate_phase0_governance_contracts(
spec_yaml: &str,
plan_yaml: &str,
checklist_yaml: &str,
traceability_markdown: &str,
) -> Result<(), GovernanceValidationError> {
validate_governance_contracts(spec_yaml, plan_yaml, checklist_yaml, traceability_markdown)
}