Skip to main content

rac_engine/
spec.rs

1//! Artifact specs, loaded from the vendored, language-neutral asdecided-spec registry
2//! embedded at build time. Field, section, and map order are preserved
3//! everywhere (PORT-CONTRACT.d/04 §1, PORT-CONTRACT.d/09, PORT-CONTRACT.d/05 §3.1).
4//!
5//! The Python registry `ARTIFACT_SPECS` is an ordered tuple of 5 specs:
6//! `requirement, decision, roadmap, prompt, design`. That order is
7//! load-bearing (classification tie-break, `available_schemas()`, registry
8//! iteration). All maps below preserve their JSON insertion order via
9//! `Vec<(K, V)>` so lookups and iteration match Python dict semantics.
10
11use std::sync::OnceLock;
12
13use serde_json::Value;
14
15/// Embedded spec data synced from `asdecided/spec`.
16const SPEC_JSON: &str = include_str!("../assets/spec/artifact-specs.json");
17
18/// One artifact type's schema. Field names/order mirror the Python dataclass.
19#[derive(Debug, Clone)]
20pub struct ArtifactSpec {
21    /// Canonical key, e.g. `"requirement"`.
22    pub name: String,
23    /// Human label, e.g. `"Requirement"`.
24    pub display: String,
25    /// Sections that define the type (scored at 1.0).
26    pub required: Vec<String>,
27    /// Expected-but-optional sections (scored at 0.5).
28    pub recommended: Vec<String>,
29    /// Recognized/extracted sections, never scored, never "missing".
30    pub optional: Vec<String>,
31    /// `{section -> allowed values}`, in declared order.
32    pub metadata: Vec<(String, Vec<String>)>,
33    /// Subset of `metadata["status"]` marking retirement.
34    pub retired_status: Vec<String>,
35    /// Schema-render description hints (`{section -> text}`), declared order.
36    pub descriptions: Vec<(String, String)>,
37    /// Improve/template guidance hints (`{section -> [lines]}`), declared order.
38    pub guidance: Vec<(String, Vec<String>)>,
39    /// Alt heading -> canonical section, applied before matching (per-spec).
40    pub synonyms: Vec<(String, String)>,
41    /// Canonical-id section; no spec sets it today (always `None`).
42    pub id_field: Option<String>,
43    /// Template starter bodies (`{section -> text}`), declared order.
44    pub starter_bodies: Vec<(String, String)>,
45}
46
47impl ArtifactSpec {
48    /// `expected` (Python property) = `required + recommended`, in that order.
49    pub fn expected(&self) -> Vec<String> {
50        let mut out = Vec::with_capacity(self.required.len() + self.recommended.len());
51        out.extend(self.required.iter().cloned());
52        out.extend(self.recommended.iter().cloned());
53        out
54    }
55
56    /// Allowed values for a metadata field, preserving declared order.
57    pub fn metadata_values(&self, field: &str) -> Option<&[String]> {
58        self.metadata
59            .iter()
60            .find(|(k, _)| k == field)
61            .map(|(_, v)| v.as_slice())
62    }
63
64    /// Canonical section a synonym maps to, if this spec declares one.
65    pub fn synonym(&self, heading: &str) -> Option<&str> {
66        self.synonyms
67            .iter()
68            .find(|(k, _)| k == heading)
69            .map(|(_, v)| v.as_str())
70    }
71}
72
73/// The canonical relationship-section vocabulary (`references.py`,
74/// PORT-CONTRACT.d/05 §3.1). Order is load-bearing: it is the canonical
75/// aggregation order for stats/relationship counts. Each entry is
76/// `(canonical space name, snake key)`.
77///
78/// ```text
79/// RELATED_SECTIONS  = related requirements, related decisions,
80///                     related roadmaps, related prompts, related designs
81/// EXTERNAL_SECTIONS = related tickets, verified by
82/// SCOPE_SECTIONS    = applies to
83/// RELATIONSHIP_SECTIONS = RELATED_SECTIONS + (supersedes,) + EXTERNAL + SCOPE
84/// ```
85pub const RELATIONSHIP_SECTIONS: [(&str, &str); 9] = [
86    ("related requirements", "related_requirements"),
87    ("related decisions", "related_decisions"),
88    ("related roadmaps", "related_roadmaps"),
89    ("related prompts", "related_prompts"),
90    ("related designs", "related_designs"),
91    ("supersedes", "supersedes"),
92    ("related tickets", "related_tickets"),
93    ("verified by", "verified_by"),
94    ("applies to", "applies_to"),
95];
96
97/// `_snake(section)` = `section.replace(" ", "_")` (spaces -> underscores only).
98pub fn snake(section: &str) -> String {
99    section.replace(' ', "_")
100}
101
102/// `canonical_value` tail: match `candidate` against the allowed vocabulary by
103/// casefold equality — the canonical allowed spelling wins, otherwise the
104/// candidate passes through. Callers supply their own first-line extraction.
105pub fn canonical_value(candidate: &str, allowed: &[String]) -> String {
106    let folded = crate::pycompat::py_casefold(candidate);
107    for value in allowed {
108        if crate::pycompat::py_casefold(value) == folded {
109            return value.clone();
110        }
111    }
112    candidate.to_string()
113}
114
115// --- JSON extraction helpers -------------------------------------------------
116
117fn as_str(v: &Value) -> String {
118    v.as_str().unwrap_or_default().to_string()
119}
120
121fn str_list(v: &Value) -> Vec<String> {
122    v.as_array()
123        .map(|a| a.iter().map(as_str).collect())
124        .unwrap_or_default()
125}
126
127/// Ordered `{key -> string}` map from a JSON object (insertion order preserved
128/// because serde_json is built with the `preserve_order` feature).
129fn str_map(v: &Value) -> Vec<(String, String)> {
130    v.as_object()
131        .map(|o| o.iter().map(|(k, val)| (k.clone(), as_str(val))).collect())
132        .unwrap_or_default()
133}
134
135/// Ordered `{key -> [string]}` map from a JSON object.
136fn list_map(v: &Value) -> Vec<(String, Vec<String>)> {
137    v.as_object()
138        .map(|o| o.iter().map(|(k, val)| (k.clone(), str_list(val))).collect())
139        .unwrap_or_default()
140}
141
142fn build_spec(v: &Value) -> ArtifactSpec {
143    ArtifactSpec {
144        name: as_str(&v["name"]),
145        display: as_str(&v["display"]),
146        required: str_list(&v["required"]),
147        recommended: str_list(&v["recommended"]),
148        optional: str_list(&v["optional"]),
149        metadata: list_map(&v["metadata"]),
150        retired_status: str_list(&v["retired_status"]),
151        descriptions: str_map(&v["descriptions"]),
152        guidance: list_map(&v["guidance"]),
153        synonyms: str_map(&v["synonyms"]),
154        id_field: v["id_field"].as_str().map(str::to_string),
155        starter_bodies: str_map(&v["starter_bodies"]),
156    }
157}
158
159struct SpecData {
160    specs: Vec<ArtifactSpec>,
161    relationship_descriptions: Vec<(String, String)>,
162}
163
164fn data() -> &'static SpecData {
165    static DATA: OnceLock<SpecData> = OnceLock::new();
166    DATA.get_or_init(|| {
167        let root: Value = serde_json::from_str(SPEC_JSON).expect("artifact-specs.json parses");
168        let specs = root["artifact_specs"]
169            .as_array()
170            .expect("artifact_specs is an array")
171            .iter()
172            .map(build_spec)
173            .collect();
174        let relationship_descriptions = str_map(&root["relationship_descriptions"]);
175        SpecData {
176            specs,
177            relationship_descriptions,
178        }
179    })
180}
181
182/// The ordered spec registry (`ARTIFACT_SPECS`): requirement, decision,
183/// roadmap, prompt, design — in that exact order.
184pub fn specs() -> &'static [ArtifactSpec] {
185    &data().specs
186}
187
188/// The spec for a canonical type name, or `None` for `"unknown"` / unregistered.
189pub fn spec_for(name: &str) -> Option<&'static ArtifactSpec> {
190    data().specs.iter().find(|s| s.name == name)
191}
192
193/// `available_schemas()` = the spec names in registry order.
194pub fn available_schemas() -> Vec<&'static str> {
195    data().specs.iter().map(|s| s.name.as_str()).collect()
196}
197
198/// Canonical relationship-section descriptions, in declared order
199/// (`relationship_descriptions` from the JSON; PORT-CONTRACT.d/05).
200pub fn relationship_descriptions() -> &'static [(String, String)] {
201    &data().relationship_descriptions
202}