1use std::sync::OnceLock;
12
13use serde_json::Value;
14
15const SPEC_JSON: &str = include_str!("../assets/spec/artifact-specs.json");
17
18#[derive(Debug, Clone)]
20pub struct ArtifactSpec {
21 pub name: String,
23 pub display: String,
25 pub required: Vec<String>,
27 pub recommended: Vec<String>,
29 pub optional: Vec<String>,
31 pub metadata: Vec<(String, Vec<String>)>,
33 pub retired_status: Vec<String>,
35 pub descriptions: Vec<(String, String)>,
37 pub guidance: Vec<(String, Vec<String>)>,
39 pub synonyms: Vec<(String, String)>,
41 pub id_field: Option<String>,
43 pub starter_bodies: Vec<(String, String)>,
45}
46
47impl ArtifactSpec {
48 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 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 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
73pub 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
97pub fn snake(section: &str) -> String {
99 section.replace(' ', "_")
100}
101
102pub 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
115fn 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
127fn 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
135fn 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
182pub fn specs() -> &'static [ArtifactSpec] {
185 &data().specs
186}
187
188pub fn spec_for(name: &str) -> Option<&'static ArtifactSpec> {
190 data().specs.iter().find(|s| s.name == name)
191}
192
193pub fn available_schemas() -> Vec<&'static str> {
195 data().specs.iter().map(|s| s.name.as_str()).collect()
196}
197
198pub fn relationship_descriptions() -> &'static [(String, String)] {
201 &data().relationship_descriptions
202}