metrale_bench/gate/
pr_taxonomy.rs1use std::collections::BTreeSet;
17use std::path::Path;
18
19use anyhow::{Context, Result, bail};
20
21const RESERVED: [&str; 2] = ["_doc", "_benches"];
23
24#[derive(Debug, Clone, PartialEq, Eq)]
27pub struct Node {
28 pub name: String,
29 pub benches: Vec<String>,
30 pub children: Vec<Node>,
31}
32
33impl Node {
34 pub fn is_leaf(&self) -> bool {
35 self.children.is_empty()
36 }
37}
38
39pub fn load(root: &Path) -> Result<Vec<Node>> {
41 let path = root.join(".github/pr-taxonomy.json");
42 let text =
43 std::fs::read_to_string(&path).with_context(|| format!("reading {}", path.display()))?;
44 let json: serde_json::Value =
45 serde_json::from_str(&text).with_context(|| format!("parsing {}", path.display()))?;
46 let roots = parse_children(&json)?;
47 validate(&roots)?;
48 Ok(roots)
49}
50
51fn parse_children(value: &serde_json::Value) -> Result<Vec<Node>> {
52 let Some(obj) = value.as_object() else {
53 bail!("every taxonomy node must be a JSON object");
54 };
55 let mut out = Vec::new();
56 for (key, child) in obj {
57 if RESERVED.contains(&key.as_str()) {
58 continue;
59 }
60 let benches = match child.get("_benches") {
63 None => Vec::new(),
64 Some(serde_json::Value::Array(items)) => {
65 let mut v = Vec::with_capacity(items.len());
66 for item in items {
67 match item.as_str() {
68 Some(s) => v.push(s.to_string()),
69 None => bail!(
70 "{key}: _benches contains a non-string entry ({item}). \
71 A silently-dropped entry removes a benchmark."
72 ),
73 }
74 }
75 v
76 }
77 Some(other) => bail!(
78 "{key}: _benches must be an ARRAY of benchmark ids, got {other}. \
79 A bare string parses as empty here while jq reads it, so the two \
80 halves would disagree — in the removing direction."
81 ),
82 };
83 out.push(Node {
84 name: key.clone(),
85 benches,
86 children: parse_children(child)?,
87 });
88 }
89 Ok(out)
90}
91
92fn validate(roots: &[Node]) -> Result<()> {
96 if roots.len() < 2 {
97 bail!("the taxonomy needs at least two roots; one root is not a choice");
98 }
99 let known: BTreeSet<&str> = super::coverage::REQUIRED.iter().map(|g| g.id).collect();
100 fn walk(nodes: &[Node], trail: &str, known: &BTreeSet<&str>) -> Result<()> {
101 for n in nodes {
102 let here = if trail.is_empty() {
103 n.name.clone()
104 } else {
105 format!("{trail}/{}", n.name)
106 };
107 if !n
108 .name
109 .chars()
110 .all(|c| c.is_ascii_lowercase() || c.is_ascii_digit() || c == '-')
111 || n.name.is_empty()
112 {
113 bail!("{here}: keys must be lowercase kebab-case so a path is a safe label");
114 }
115 for b in &n.benches {
116 if !known.contains(b.as_str()) {
117 bail!(
118 "{here}: _benches names {b:?}, which is not a required benchmark. \
119 A path that selects a benchmark nobody runs is a silent no-op."
120 );
121 }
122 }
123 if n.children.len() == 1 {
125 bail!(
126 "{here} has exactly one child ({}). Either give it a sibling or \
127 make {here} a leaf.",
128 n.children[0].name
129 );
130 }
131 walk(&n.children, &here, known)?;
132 }
133 Ok(())
134 }
135 walk(roots, "", &known)
136}
137
138pub fn options_at(roots: &[Node], path: &[String]) -> Option<Vec<String>> {
142 let node = walk_to(roots, path)?;
143 let kids: Vec<String> = node.iter().map(|n| n.name.clone()).collect();
144 (kids.len() > 1).then_some(kids)
145}
146
147fn walk_to<'a>(roots: &'a [Node], path: &[String]) -> Option<&'a [Node]> {
148 let mut level = roots;
149 for step in path {
150 level = &level.iter().find(|n| &n.name == step)?.children;
151 }
152 Some(level)
153}
154
155pub fn benches_for(roots: &[Node], path: &[String]) -> BTreeSet<String> {
159 benches_for_matched(roots, path).0
160}
161
162pub fn benches_for_matched(roots: &[Node], path: &[String]) -> (BTreeSet<String>, usize) {
166 let mut out = BTreeSet::new();
167 let mut level = roots;
168 let mut matched = 0usize;
169 for step in path {
170 let Some(node) = level.iter().find(|n| &n.name == step) else {
171 break;
172 };
173 out.extend(node.benches.iter().cloned());
174 level = &node.children;
175 matched += 1;
176 }
177 (out, matched)
178}
179
180pub fn resolve(roots: &[Node], path: &[String]) -> Vec<String> {
184 let mut out = path.to_vec();
185 loop {
186 let Some(level) = walk_to(roots, &out) else {
187 return out;
188 };
189 if level.len() == 1 {
190 out.push(level[0].name.clone());
191 } else {
192 return out;
193 }
194 }
195}
196
197pub fn is_complete(roots: &[Node], path: &[String]) -> bool {
199 walk_to(roots, path).is_some_and(<[Node]>::is_empty)
200}
201
202#[cfg(test)]
203#[path = "pr_taxonomy_tests.rs"]
204mod pr_taxonomy_tests;