provable_contracts/schema/
external_corpora.rs1use serde::{Deserialize, Serialize};
35use serde_yaml::{Mapping, Value};
36
37use crate::error::{ContractError, Severity, Violation};
38
39pub const SCHEMA_PREFIX: &str = "ont.paiml.dev/external-corpora/";
44
45pub const SUPPORTED_VERSIONS: &[&str] = &["v1alpha1"];
48
49const TOP_KEYS: &[&str] = &["schema", "corpora"];
51
52const ENTRY_KEYS: &[&str] = &[
56 "name",
57 "repo",
58 "ref",
59 "head",
60 "n_files",
61 "mark",
62 "counted_by",
63 "note",
64];
65
66const ENTRY_REQUIRED: &[&str] = &["name", "repo", "ref", "head"];
69
70#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
72pub struct ExternalCorpus {
73 pub name: String,
74 #[serde(default, skip_serializing_if = "Option::is_none")]
75 pub repo: Option<String>,
76 #[serde(default, rename = "ref", skip_serializing_if = "Option::is_none")]
77 pub git_ref: Option<String>,
78 #[serde(default, skip_serializing_if = "Option::is_none")]
79 pub head: Option<String>,
80 pub n_files: usize,
81 #[serde(default, skip_serializing_if = "Option::is_none")]
82 pub mark: Option<String>,
83 pub counted_by: String,
87 #[serde(default, skip_serializing_if = "Option::is_none")]
88 pub note: Option<String>,
89}
90
91#[derive(Debug, Clone, Deserialize)]
93pub struct ExternalCorpora {
94 #[serde(default)]
95 pub schema: Option<String>,
96 #[serde(default)]
97 pub corpora: Vec<ExternalCorpus>,
98}
99
100pub fn parse_external_corpora_str(yaml: &str) -> Result<ExternalCorpora, ContractError> {
106 Ok(serde_yaml::from_str(yaml)?)
107}
108
109#[must_use]
111pub fn is_external_corpora_schema(schema: &str) -> bool {
112 schema.starts_with(SCHEMA_PREFIX)
113}
114
115fn violation(rule: &str, message: String, location: &str) -> Violation {
116 Violation {
117 severity: Severity::Error,
118 rule: rule.to_string(),
119 message,
120 location: Some(location.to_string()),
121 }
122}
123
124#[must_use]
126pub fn validate_external_corpora(yaml: &str) -> Vec<Violation> {
127 let doc: Value = match serde_yaml::from_str(yaml) {
128 Ok(doc) => doc,
129 Err(e) => {
130 return vec![violation(
131 "EXT-CORPORA-001",
132 format!("external-corpora declaration is not YAML: {e}"),
133 "",
134 )]
135 }
136 };
137 let Some(top) = doc.as_mapping() else {
138 return vec![violation(
139 "EXT-CORPORA-001",
140 "external-corpora declaration is not a YAML mapping".to_string(),
141 "",
142 )];
143 };
144 let mut violations = Vec::new();
145 check_schema_version(top, &mut violations);
146 check_unknown_keys(top, TOP_KEYS, "", &mut violations);
147 check_corpora(top, &mut violations);
148 check_census_readable(yaml, &mut violations);
149 violations
150}
151
152fn check_schema_version(top: &Mapping, violations: &mut Vec<Violation>) {
157 let Some(Value::String(schema)) = top.get("schema") else {
158 violations.push(violation(
159 "EXT-CORPORA-001",
160 format!("`schema` is missing or not a string — expected {SCHEMA_PREFIX}<version>"),
161 "schema",
162 ));
163 return;
164 };
165 let version = schema.trim_start_matches(SCHEMA_PREFIX);
166 if !SUPPORTED_VERSIONS.contains(&version) {
167 violations.push(violation(
168 "EXT-CORPORA-001",
169 format!(
170 "`schema` {schema:?} is version {version:?}, which these rules were not \
171 written for — accepted versions are {SUPPORTED_VERSIONS:?}. A newer \
172 declaration must be read before it is validated, not validated by \
173 rules that predate it"
174 ),
175 "schema",
176 ));
177 }
178}
179
180fn check_unknown_keys(
184 map: &Mapping,
185 allowed: &[&str],
186 prefix: &str,
187 violations: &mut Vec<Violation>,
188) {
189 for key in map.keys() {
190 let Some(key) = key.as_str() else {
191 violations.push(violation(
192 "EXT-CORPORA-007",
193 format!("{prefix}key {key:?} is not a string"),
194 prefix,
195 ));
196 continue;
197 };
198 if !allowed.contains(&key) {
199 violations.push(violation(
200 "EXT-CORPORA-007",
201 format!(
202 "unknown key `{prefix}{key}` — an external-corpora declaration \
203 carries only {allowed:?}, and a key nothing reads is a figure \
204 nobody is keeping honest"
205 ),
206 &format!("{prefix}{key}"),
207 ));
208 }
209 }
210}
211
212fn check_corpora(top: &Mapping, violations: &mut Vec<Violation>) {
215 let Some(Value::Sequence(entries)) = top.get("corpora") else {
216 violations.push(violation(
217 "EXT-CORPORA-002",
218 "`corpora` is missing or not a list".to_string(),
219 "corpora",
220 ));
221 return;
222 };
223 if entries.is_empty() {
224 violations.push(violation(
225 "EXT-CORPORA-002",
226 "`corpora` is empty — a declaration that declares nothing is a file, not a \
227 declaration; delete it instead"
228 .to_string(),
229 "corpora",
230 ));
231 return;
232 }
233 let mut seen: Vec<String> = Vec::new();
234 for (i, entry) in entries.iter().enumerate() {
235 check_entry(entry, i, &mut seen, violations);
236 }
237}
238
239fn check_entry(entry: &Value, i: usize, seen: &mut Vec<String>, violations: &mut Vec<Violation>) {
240 let prefix = format!("corpora[{i}].");
241 let Some(map) = entry.as_mapping() else {
242 violations.push(violation(
243 "EXT-CORPORA-003",
244 format!("corpora[{i}] is not a mapping"),
245 &prefix,
246 ));
247 return;
248 };
249 check_unknown_keys(map, ENTRY_KEYS, &prefix, violations);
250 check_entry_required(map, &prefix, violations);
251 check_repo(map, &prefix, violations);
252 check_head(map, &prefix, violations);
253 check_entry_optional(map, &prefix, violations);
254 check_duplicate_name(map, &prefix, seen, violations);
255}
256
257fn check_entry_required(map: &Mapping, prefix: &str, violations: &mut Vec<Violation>) {
259 for field in ENTRY_REQUIRED {
260 let why = match map.get(*field) {
261 None => "missing",
262 Some(Value::Null) => "null",
263 Some(Value::String(s)) if s.trim().is_empty() => "an empty string",
264 Some(Value::String(_)) => continue,
265 Some(_) => "not a string",
266 };
267 violations.push(violation(
268 "EXT-CORPORA-003",
269 format!(
270 "required field `{prefix}{field}` is {why} — without it the corpus \
271 cannot be re-counted, and ONT-001 R-10 declares a figure so that it \
272 can be re-measured rather than believed"
273 ),
274 &format!("{prefix}{field}"),
275 ));
276 }
277}
278
279fn check_repo(map: &Mapping, prefix: &str, violations: &mut Vec<Violation>) {
281 let Some(Value::String(repo)) = map.get("repo") else {
282 return;
283 };
284 let parts: Vec<&str> = repo.split('/').collect();
285 if parts.len() == 2 && parts.iter().all(|p| !p.trim().is_empty()) {
286 return;
287 }
288 violations.push(violation(
289 "EXT-CORPORA-004",
290 format!(
291 "`{prefix}repo` {repo:?} is not owner/name — it is what `gh api repos/<repo>` \
292 takes, and any other spelling names no repository"
293 ),
294 &format!("{prefix}repo"),
295 ));
296}
297
298fn check_head(map: &Mapping, prefix: &str, violations: &mut Vec<Violation>) {
301 let Some(Value::String(head)) = map.get("head") else {
302 return;
303 };
304 let ok = (7..=40).contains(&head.len()) && head.chars().all(|c| c.is_ascii_hexdigit());
305 if !ok {
306 violations.push(violation(
307 "EXT-CORPORA-005",
308 format!(
309 "`{prefix}head` {head:?} is not a 7-40 character hex commit id — a corpus \
310 pinned to anything that can move cannot be re-counted to the same number"
311 ),
312 &format!("{prefix}head"),
313 ));
314 }
315}
316
317fn check_entry_optional(map: &Mapping, prefix: &str, violations: &mut Vec<Violation>) {
320 if let Some(value) = map.get("n_files") {
321 if value.as_u64().is_none() {
322 violations.push(violation(
323 "EXT-CORPORA-006",
324 format!(
325 "`{prefix}n_files` must be an integer >= 0, got {value:?} — it is a \
326 file count, and the census copies it verbatim"
327 ),
328 &format!("{prefix}n_files"),
329 ));
330 }
331 }
332 for field in ["counted_by", "mark", "note"] {
333 match map.get(field) {
334 None | Some(Value::String(_)) => {}
335 Some(value) => violations.push(violation(
336 "EXT-CORPORA-006",
337 format!("`{prefix}{field}` must be a string, got {value:?}"),
338 &format!("{prefix}{field}"),
339 )),
340 }
341 }
342}
343
344fn check_duplicate_name(
347 map: &Mapping,
348 prefix: &str,
349 seen: &mut Vec<String>,
350 violations: &mut Vec<Violation>,
351) {
352 let Some(Value::String(name)) = map.get("name") else {
353 return;
354 };
355 if seen.iter().any(|s| s == name) {
356 violations.push(violation(
357 "EXT-CORPORA-008",
358 format!(
359 "duplicate corpus name {name:?} — the census sorts and reports by name, \
360 so two entries sharing one make the figure unattributable"
361 ),
362 &format!("{prefix}name"),
363 ));
364 } else {
365 seen.push(name.clone());
366 }
367}
368
369fn check_census_readable(yaml: &str, violations: &mut Vec<Violation>) {
377 if let Err(e) = parse_external_corpora_str(yaml) {
378 violations.push(violation(
379 "EXT-CORPORA-009",
380 format!(
381 "the declaration does not deserialize into the struct `pv census` reads: \
382 {e} — pv validate would be passing a file the census cannot count"
383 ),
384 "",
385 ));
386 }
387}
388
389#[cfg(test)]
390mod tests {
391 include!("external_corpora_tests.rs");
392}