use anyhow::{anyhow, bail, Context, Result};
use serde::Deserialize;
use std::collections::{BTreeMap, BTreeSet};
use std::sync::OnceLock;
const CONTRACT_YAML: &str = include_str!("../schemas/sdd-contract.yaml");
static CONTRACT: OnceLock<SddContract> = OnceLock::new();
#[derive(Debug, Deserialize)]
pub struct SddContract {
pub version: u32,
pub orchestration: OrchestrationContract,
pub states: ContractStates,
#[serde(default)]
pub validations: ValidationCatalog,
pub artifacts: BTreeMap<String, ArtifactContract>,
pub stages: Vec<StageContract>,
}
#[derive(Debug, Deserialize)]
pub struct OrchestrationContract {
pub canonical_command: String,
#[serde(default)]
pub legacy_aliases: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct ContractStates {
pub orchestration: Vec<String>,
pub artifact: Vec<String>,
pub tui_status: Vec<String>,
}
#[derive(Debug, Default, Deserialize)]
pub struct ValidationCatalog {
#[serde(default)]
pub diagrams: DiagramValidation,
#[serde(default)]
pub prompts_agent: PromptValidation,
}
#[derive(Debug, Default, Deserialize)]
pub struct DiagramValidation {
#[serde(default)]
pub allowed_markers: Vec<String>,
}
#[derive(Debug, Default, Deserialize)]
pub struct PromptValidation {
#[serde(default)]
pub task_id_patterns: Vec<String>,
}
#[derive(Debug, Deserialize)]
pub struct ArtifactContract {
#[serde(default = "default_true")]
pub gate: bool,
#[serde(default)]
pub aliases: Vec<String>,
#[serde(default)]
pub required_sections: Vec<String>,
#[serde(default)]
pub semantic_validations: Vec<String>,
#[serde(default)]
pub quality_rubric: Vec<String>,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum StagePhase {
Pre,
Core,
Post,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BoardLane {
Guided,
Recorded,
}
#[derive(Debug, Deserialize)]
pub struct StageContract {
pub key: String,
pub command: String,
pub label: String,
pub order: u32,
pub phase: StagePhase,
pub filename: String,
#[serde(default)]
pub optional: bool,
#[serde(default)]
pub guided: bool,
#[serde(default)]
pub board_visible: bool,
pub board_lane: BoardLane,
#[serde(default)]
pub requires_human_checkpoint: bool,
pub initial_state: String,
pub artifact_type: Option<String>,
#[serde(default)]
pub aliases: Vec<String>,
#[serde(default)]
pub surfaces: Vec<String>,
#[serde(default)]
pub traceability_from: Vec<String>,
}
fn default_true() -> bool {
true
}
pub fn contract() -> &'static SddContract {
CONTRACT.get_or_init(|| {
parse_contract(CONTRACT_YAML).expect("embedded sdd contract must stay valid")
})
}
pub fn embedded_contract_yaml() -> &'static str {
CONTRACT_YAML
}
pub fn parse_contract(text: &str) -> Result<SddContract> {
let contract: SddContract =
serde_yaml::from_str(text).context("parsing schemas/sdd-contract.yaml")?;
validate_contract(&contract)?;
Ok(contract)
}
pub fn validate_contract(contract: &SddContract) -> Result<()> {
if contract.version == 0 {
bail!("contract version must be positive");
}
if contract.orchestration.canonical_command != "orchestration" {
bail!("canonical command must be `orchestration`");
}
if !contract
.orchestration
.legacy_aliases
.iter()
.any(|alias| alias == "orchestrator")
{
bail!("legacy alias `orchestrator` must be preserved");
}
if contract.stages.is_empty() {
bail!("contract must declare at least one stage");
}
if contract.states.orchestration.is_empty()
|| contract.states.artifact.is_empty()
|| contract.states.tui_status.is_empty()
{
bail!("contract states must declare orchestration, artifact and tui_status values");
}
let artifact_states: BTreeSet<_> = contract
.states
.artifact
.iter()
.map(String::as_str)
.collect();
let mut stage_keys = BTreeSet::new();
let mut stage_commands = BTreeSet::new();
let mut stage_filenames = BTreeSet::new();
let mut stage_orders = BTreeSet::new();
let mut artifact_aliases = BTreeSet::new();
for (artifact_name, artifact) in &contract.artifacts {
if artifact.required_sections.is_empty() {
bail!("artifact `{artifact_name}` must declare required_sections");
}
if artifact.quality_rubric.is_empty() {
bail!("artifact `{artifact_name}` must declare quality_rubric");
}
for alias in &artifact.aliases {
if !artifact_aliases.insert(alias.as_str()) {
bail!("duplicate artifact alias `{alias}` in contract");
}
}
}
for stage in &contract.stages {
if !stage_keys.insert(stage.key.as_str()) {
bail!("duplicate stage key `{}` in contract", stage.key);
}
if !stage_commands.insert(stage.command.as_str()) {
bail!("duplicate stage command `{}` in contract", stage.command);
}
if !stage_filenames.insert(stage.filename.as_str()) {
bail!("duplicate stage filename `{}` in contract", stage.filename);
}
if !stage_orders.insert(stage.order) {
bail!("duplicate stage order `{}` in contract", stage.order);
}
if stage.surfaces.is_empty() {
bail!("stage `{}` must declare at least one surface", stage.key);
}
if stage.guided && stage.board_lane != BoardLane::Guided {
bail!(
"guided stage `{}` must use the guided board lane",
stage.key
);
}
if stage.requires_human_checkpoint && stage.artifact_type.is_none() {
bail!(
"stage `{}` requires a human checkpoint but has no artifact_type",
stage.key
);
}
if !artifact_states.contains(stage.initial_state.as_str()) {
bail!(
"stage `{}` uses unknown initial_state `{}`",
stage.key,
stage.initial_state
);
}
if let Some(artifact_type) = &stage.artifact_type {
if !contract.artifacts.contains_key(artifact_type) {
bail!(
"stage `{}` references unknown artifact_type `{artifact_type}`",
stage.key
);
}
}
for dependency in &stage.traceability_from {
if !contract
.stages
.iter()
.any(|candidate| &candidate.key == dependency)
{
bail!(
"stage `{}` references unknown traceability dependency `{dependency}`",
stage.key
);
}
}
}
Ok(())
}
pub fn stages() -> &'static [StageContract] {
contract().stages.as_slice()
}
pub fn stage_by_key(key: &str) -> Option<&'static StageContract> {
stages().iter().find(|stage| stage.key == key).or_else(|| {
stages()
.iter()
.find(|stage| stage.aliases.iter().any(|alias| alias == key))
})
}
pub fn stage_by_command(command: &str) -> Option<&'static StageContract> {
stages().iter().find(|stage| stage.command == command)
}
pub fn stage_file(key: &str) -> Option<&'static str> {
stage_by_key(key).map(|stage| stage.filename.as_str())
}
pub fn stage_label(key: &str) -> Option<&'static str> {
stage_by_key(key).map(|stage| stage.label.as_str())
}
pub fn stage_title_from_command(command: &str) -> Option<&'static str> {
stage_by_command(command)
.map(|stage| stage.label.as_str())
.or_else(|| utility_command_label(command))
}
fn utility_commands() -> &'static [(&'static str, &'static str)] {
&[("adr", "ADR")]
}
fn utility_command_label(command: &str) -> Option<&'static str> {
utility_commands()
.iter()
.find(|(cmd, _)| *cmd == command)
.map(|(_, label)| *label)
}
pub fn command_names() -> Vec<&'static str> {
stages()
.iter()
.map(|stage| stage.command.as_str())
.chain(utility_commands().iter().map(|(cmd, _)| *cmd))
.collect()
}
pub fn guided_stages() -> Vec<&'static StageContract> {
stages().iter().filter(|stage| stage.guided).collect()
}
pub fn board_stages(lane: BoardLane) -> Vec<&'static StageContract> {
stages()
.iter()
.filter(|stage| stage.board_visible && stage.board_lane == lane)
.collect()
}
pub fn artifact_spec(name: &str) -> Option<&'static ArtifactContract> {
contract().artifacts.get(name)
}
pub fn artifact_required_section_map() -> BTreeMap<&'static str, Vec<&'static str>> {
contract()
.artifacts
.iter()
.map(|(name, artifact)| {
(
name.as_str(),
artifact
.required_sections
.iter()
.map(String::as_str)
.collect::<Vec<_>>(),
)
})
.collect()
}
pub fn artifact_semantic_validations(name: &str) -> Vec<&'static str> {
artifact_spec(name)
.map(|artifact| {
artifact
.semantic_validations
.iter()
.map(String::as_str)
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
pub fn artifact_quality_rubric(name: &str) -> Vec<&'static str> {
artifact_spec(name)
.map(|artifact| artifact.quality_rubric.iter().map(String::as_str).collect())
.unwrap_or_default()
}
pub fn artifact_allowed_diagram_markers() -> Vec<&'static str> {
contract()
.validations
.diagrams
.allowed_markers
.iter()
.map(String::as_str)
.collect()
}
pub fn artifact_prompt_id_patterns() -> Vec<&'static str> {
contract()
.validations
.prompts_agent
.task_id_patterns
.iter()
.map(String::as_str)
.collect()
}
pub fn stage_traceability_from(key: &str) -> Vec<&'static StageContract> {
stage_by_key(key)
.map(|stage| {
stage
.traceability_from
.iter()
.filter_map(|dependency| stage_by_key(dependency))
.collect::<Vec<_>>()
})
.unwrap_or_default()
}
pub fn canonical_artifact_for_stem(stem: &str) -> Option<Option<&'static str>> {
stage_by_filename_stem(stem).map(|stage| {
stage.artifact_type.as_deref().and_then(|artifact_type| {
artifact_spec(artifact_type).and_then(|artifact| artifact.gate.then_some(artifact_type))
})
})
}
pub fn stage_by_filename_stem(stem: &str) -> Option<&'static StageContract> {
stages()
.iter()
.find(|stage| stage.filename.strip_suffix(".md") == Some(stem))
}
pub fn inference_hints() -> Vec<(&'static str, Vec<&'static str>)> {
let mut hints = Vec::new();
for stage in stages() {
let Some(artifact_type) = stage.artifact_type.as_deref() else {
continue;
};
let Some(artifact) = artifact_spec(artifact_type) else {
continue;
};
if !artifact.gate {
continue;
}
let mut aliases = Vec::new();
aliases.extend(artifact.aliases.iter().map(String::as_str));
aliases.extend(stage.aliases.iter().map(String::as_str));
aliases.push(stage.key.as_str());
if stage.command != stage.key {
aliases.push(stage.command.as_str());
}
aliases.sort_unstable();
aliases.dedup();
hints.push((artifact_type, aliases));
}
hints
}
pub fn template_stage_pairs() -> Vec<(String, String)> {
stages()
.iter()
.map(|stage| (stage.key.clone(), stage.filename.clone()))
.collect()
}
pub fn parse_template_stage_pairs(yaml: &str) -> Result<Vec<(String, String)>> {
let value: serde_yaml::Value = serde_yaml::from_str(yaml).context("parsing template yaml")?;
let artifacts = value
.get("artifacts")
.and_then(serde_yaml::Value::as_mapping)
.ok_or_else(|| anyhow!("template yaml missing `artifacts` mapping"))?;
let mut pairs = Vec::new();
for stage in stages() {
let key = serde_yaml::Value::String(stage.key.clone());
let file = artifacts
.get(&key)
.and_then(|item| item.get("file"))
.and_then(serde_yaml::Value::as_str)
.ok_or_else(|| anyhow!("template yaml missing file for stage `{}`", stage.key))?;
pairs.push((stage.key.clone(), file.to_string()));
}
Ok(pairs)
}
pub fn parse_artifact_sections_json(text: &str) -> Result<BTreeMap<String, Vec<String>>> {
let value: serde_json::Value =
serde_json::from_str(text).context("parsing artifact sections schema json")?;
let object = value
.as_object()
.ok_or_else(|| anyhow!("artifact sections schema must be a JSON object"))?;
let mut out = BTreeMap::new();
for (artifact_name, artifact_value) in object {
let required = artifact_value
.get("required_sections")
.and_then(serde_json::Value::as_array)
.ok_or_else(|| {
anyhow!("artifact sections schema missing required_sections for `{artifact_name}`")
})?;
out.insert(
artifact_name.clone(),
required
.iter()
.filter_map(serde_json::Value::as_str)
.map(ToString::to_string)
.collect(),
);
}
Ok(out)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn embedded_contract_is_valid() {
let parsed = parse_contract(CONTRACT_YAML).unwrap();
assert_eq!(parsed.orchestration.canonical_command, "orchestration");
assert!(parsed
.stages
.iter()
.any(|stage| stage.key == "project-discovery"));
}
#[test]
fn adr_is_a_utility_command_not_a_pipeline_stage() {
assert!(stage_by_key("adr").is_none());
assert!(command_names().contains(&"adr"));
assert!(artifact_spec("adr").is_some());
}
#[test]
fn risk_is_a_validated_artifact_contract() {
let risk = artifact_spec("risk").unwrap();
assert!(risk.gate);
assert_eq!(
risk.required_sections,
[
"Classificação de risco",
"Fatores",
"Checkpoints",
"Evidências mínimas",
"Rastreabilidade",
]
);
assert_eq!(
canonical_artifact_for_stem("00-risk-classification"),
Some(Some("risk"))
);
}
#[test]
fn artifact_sections_json_mirrors_contract() {
let json = include_str!("../schemas/artifact-sections.json");
let parsed = parse_artifact_sections_json(json).unwrap();
let expected = contract()
.artifacts
.iter()
.map(|(name, artifact)| (name.clone(), artifact.required_sections.clone()))
.collect::<BTreeMap<_, _>>();
assert_eq!(parsed, expected);
}
#[test]
fn creation_templates_mirror_contract_stage_files() {
let expected = template_stage_pairs();
for template in [
include_str!("../templates/traceability-map.yaml"),
include_str!("../templates/artifact-index.yaml"),
] {
assert_eq!(parse_template_stage_pairs(template).unwrap(), expected);
}
}
}