use std::sync::OnceLock;
use serde_json::Value;
const SPEC_JSON: &str = include_str!("../assets/spec/artifact-specs.json");
#[derive(Debug, Clone)]
pub struct ArtifactSpec {
pub name: String,
pub display: String,
pub required: Vec<String>,
pub recommended: Vec<String>,
pub optional: Vec<String>,
pub metadata: Vec<(String, Vec<String>)>,
pub retired_status: Vec<String>,
pub descriptions: Vec<(String, String)>,
pub guidance: Vec<(String, Vec<String>)>,
pub synonyms: Vec<(String, String)>,
pub id_field: Option<String>,
pub starter_bodies: Vec<(String, String)>,
}
impl ArtifactSpec {
pub fn expected(&self) -> Vec<String> {
let mut out = Vec::with_capacity(self.required.len() + self.recommended.len());
out.extend(self.required.iter().cloned());
out.extend(self.recommended.iter().cloned());
out
}
pub fn metadata_values(&self, field: &str) -> Option<&[String]> {
self.metadata
.iter()
.find(|(k, _)| k == field)
.map(|(_, v)| v.as_slice())
}
pub fn synonym(&self, heading: &str) -> Option<&str> {
self.synonyms
.iter()
.find(|(k, _)| k == heading)
.map(|(_, v)| v.as_str())
}
}
pub const RELATIONSHIP_SECTIONS: [(&str, &str); 9] = [
("related requirements", "related_requirements"),
("related decisions", "related_decisions"),
("related roadmaps", "related_roadmaps"),
("related prompts", "related_prompts"),
("related designs", "related_designs"),
("supersedes", "supersedes"),
("related tickets", "related_tickets"),
("verified by", "verified_by"),
("applies to", "applies_to"),
];
pub fn snake(section: &str) -> String {
section.replace(' ', "_")
}
pub fn canonical_value(candidate: &str, allowed: &[String]) -> String {
let folded = crate::pycompat::py_casefold(candidate);
for value in allowed {
if crate::pycompat::py_casefold(value) == folded {
return value.clone();
}
}
candidate.to_string()
}
fn as_str(v: &Value) -> String {
v.as_str().unwrap_or_default().to_string()
}
fn str_list(v: &Value) -> Vec<String> {
v.as_array()
.map(|a| a.iter().map(as_str).collect())
.unwrap_or_default()
}
fn str_map(v: &Value) -> Vec<(String, String)> {
v.as_object()
.map(|o| o.iter().map(|(k, val)| (k.clone(), as_str(val))).collect())
.unwrap_or_default()
}
fn list_map(v: &Value) -> Vec<(String, Vec<String>)> {
v.as_object()
.map(|o| o.iter().map(|(k, val)| (k.clone(), str_list(val))).collect())
.unwrap_or_default()
}
fn build_spec(v: &Value) -> ArtifactSpec {
ArtifactSpec {
name: as_str(&v["name"]),
display: as_str(&v["display"]),
required: str_list(&v["required"]),
recommended: str_list(&v["recommended"]),
optional: str_list(&v["optional"]),
metadata: list_map(&v["metadata"]),
retired_status: str_list(&v["retired_status"]),
descriptions: str_map(&v["descriptions"]),
guidance: list_map(&v["guidance"]),
synonyms: str_map(&v["synonyms"]),
id_field: v["id_field"].as_str().map(str::to_string),
starter_bodies: str_map(&v["starter_bodies"]),
}
}
struct SpecData {
specs: Vec<ArtifactSpec>,
relationship_descriptions: Vec<(String, String)>,
}
fn data() -> &'static SpecData {
static DATA: OnceLock<SpecData> = OnceLock::new();
DATA.get_or_init(|| {
let root: Value = serde_json::from_str(SPEC_JSON).expect("artifact-specs.json parses");
let specs = root["artifact_specs"]
.as_array()
.expect("artifact_specs is an array")
.iter()
.map(build_spec)
.collect();
let relationship_descriptions = str_map(&root["relationship_descriptions"]);
SpecData {
specs,
relationship_descriptions,
}
})
}
pub fn specs() -> &'static [ArtifactSpec] {
&data().specs
}
pub fn spec_for(name: &str) -> Option<&'static ArtifactSpec> {
data().specs.iter().find(|s| s.name == name)
}
pub fn available_schemas() -> Vec<&'static str> {
data().specs.iter().map(|s| s.name.as_str()).collect()
}
pub fn relationship_descriptions() -> &'static [(String, String)] {
&data().relationship_descriptions
}