use serde::{Deserialize, Serialize};
use serde_yaml::{Mapping, Value};
use crate::error::{ContractError, Severity, Violation};
pub const SCHEMA_PREFIX: &str = "ont.paiml.dev/external-corpora/";
pub const SUPPORTED_VERSIONS: &[&str] = &["v1alpha1"];
const TOP_KEYS: &[&str] = &["schema", "corpora"];
const ENTRY_KEYS: &[&str] = &[
"name",
"repo",
"ref",
"head",
"n_files",
"mark",
"counted_by",
"note",
];
const ENTRY_REQUIRED: &[&str] = &["name", "repo", "ref", "head"];
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub struct ExternalCorpus {
pub name: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub repo: Option<String>,
#[serde(default, rename = "ref", skip_serializing_if = "Option::is_none")]
pub git_ref: Option<String>,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub head: Option<String>,
pub n_files: usize,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub mark: Option<String>,
pub counted_by: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub note: Option<String>,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ExternalCorpora {
#[serde(default)]
pub schema: Option<String>,
#[serde(default)]
pub corpora: Vec<ExternalCorpus>,
}
pub fn parse_external_corpora_str(yaml: &str) -> Result<ExternalCorpora, ContractError> {
Ok(serde_yaml::from_str(yaml)?)
}
#[must_use]
pub fn is_external_corpora_schema(schema: &str) -> bool {
schema.starts_with(SCHEMA_PREFIX)
}
fn violation(rule: &str, message: String, location: &str) -> Violation {
Violation {
severity: Severity::Error,
rule: rule.to_string(),
message,
location: Some(location.to_string()),
}
}
#[must_use]
pub fn validate_external_corpora(yaml: &str) -> Vec<Violation> {
let doc: Value = match serde_yaml::from_str(yaml) {
Ok(doc) => doc,
Err(e) => {
return vec![violation(
"EXT-CORPORA-001",
format!("external-corpora declaration is not YAML: {e}"),
"",
)]
}
};
let Some(top) = doc.as_mapping() else {
return vec![violation(
"EXT-CORPORA-001",
"external-corpora declaration is not a YAML mapping".to_string(),
"",
)];
};
let mut violations = Vec::new();
check_schema_version(top, &mut violations);
check_unknown_keys(top, TOP_KEYS, "", &mut violations);
check_corpora(top, &mut violations);
check_census_readable(yaml, &mut violations);
violations
}
fn check_schema_version(top: &Mapping, violations: &mut Vec<Violation>) {
let Some(Value::String(schema)) = top.get("schema") else {
violations.push(violation(
"EXT-CORPORA-001",
format!("`schema` is missing or not a string — expected {SCHEMA_PREFIX}<version>"),
"schema",
));
return;
};
let version = schema.trim_start_matches(SCHEMA_PREFIX);
if !SUPPORTED_VERSIONS.contains(&version) {
violations.push(violation(
"EXT-CORPORA-001",
format!(
"`schema` {schema:?} is version {version:?}, which these rules were not \
written for — accepted versions are {SUPPORTED_VERSIONS:?}. A newer \
declaration must be read before it is validated, not validated by \
rules that predate it"
),
"schema",
));
}
}
fn check_unknown_keys(
map: &Mapping,
allowed: &[&str],
prefix: &str,
violations: &mut Vec<Violation>,
) {
for key in map.keys() {
let Some(key) = key.as_str() else {
violations.push(violation(
"EXT-CORPORA-007",
format!("{prefix}key {key:?} is not a string"),
prefix,
));
continue;
};
if !allowed.contains(&key) {
violations.push(violation(
"EXT-CORPORA-007",
format!(
"unknown key `{prefix}{key}` — an external-corpora declaration \
carries only {allowed:?}, and a key nothing reads is a figure \
nobody is keeping honest"
),
&format!("{prefix}{key}"),
));
}
}
}
fn check_corpora(top: &Mapping, violations: &mut Vec<Violation>) {
let Some(Value::Sequence(entries)) = top.get("corpora") else {
violations.push(violation(
"EXT-CORPORA-002",
"`corpora` is missing or not a list".to_string(),
"corpora",
));
return;
};
if entries.is_empty() {
violations.push(violation(
"EXT-CORPORA-002",
"`corpora` is empty — a declaration that declares nothing is a file, not a \
declaration; delete it instead"
.to_string(),
"corpora",
));
return;
}
let mut seen: Vec<String> = Vec::new();
for (i, entry) in entries.iter().enumerate() {
check_entry(entry, i, &mut seen, violations);
}
}
fn check_entry(entry: &Value, i: usize, seen: &mut Vec<String>, violations: &mut Vec<Violation>) {
let prefix = format!("corpora[{i}].");
let Some(map) = entry.as_mapping() else {
violations.push(violation(
"EXT-CORPORA-003",
format!("corpora[{i}] is not a mapping"),
&prefix,
));
return;
};
check_unknown_keys(map, ENTRY_KEYS, &prefix, violations);
check_entry_required(map, &prefix, violations);
check_repo(map, &prefix, violations);
check_head(map, &prefix, violations);
check_entry_optional(map, &prefix, violations);
check_duplicate_name(map, &prefix, seen, violations);
}
fn check_entry_required(map: &Mapping, prefix: &str, violations: &mut Vec<Violation>) {
for field in ENTRY_REQUIRED {
let why = match map.get(*field) {
None => "missing",
Some(Value::Null) => "null",
Some(Value::String(s)) if s.trim().is_empty() => "an empty string",
Some(Value::String(_)) => continue,
Some(_) => "not a string",
};
violations.push(violation(
"EXT-CORPORA-003",
format!(
"required field `{prefix}{field}` is {why} — without it the corpus \
cannot be re-counted, and ONT-001 R-10 declares a figure so that it \
can be re-measured rather than believed"
),
&format!("{prefix}{field}"),
));
}
}
fn check_repo(map: &Mapping, prefix: &str, violations: &mut Vec<Violation>) {
let Some(Value::String(repo)) = map.get("repo") else {
return;
};
let parts: Vec<&str> = repo.split('/').collect();
if parts.len() == 2 && parts.iter().all(|p| !p.trim().is_empty()) {
return;
}
violations.push(violation(
"EXT-CORPORA-004",
format!(
"`{prefix}repo` {repo:?} is not owner/name — it is what `gh api repos/<repo>` \
takes, and any other spelling names no repository"
),
&format!("{prefix}repo"),
));
}
fn check_head(map: &Mapping, prefix: &str, violations: &mut Vec<Violation>) {
let Some(Value::String(head)) = map.get("head") else {
return;
};
let ok = (7..=40).contains(&head.len()) && head.chars().all(|c| c.is_ascii_hexdigit());
if !ok {
violations.push(violation(
"EXT-CORPORA-005",
format!(
"`{prefix}head` {head:?} is not a 7-40 character hex commit id — a corpus \
pinned to anything that can move cannot be re-counted to the same number"
),
&format!("{prefix}head"),
));
}
}
fn check_entry_optional(map: &Mapping, prefix: &str, violations: &mut Vec<Violation>) {
if let Some(value) = map.get("n_files") {
if value.as_u64().is_none() {
violations.push(violation(
"EXT-CORPORA-006",
format!(
"`{prefix}n_files` must be an integer >= 0, got {value:?} — it is a \
file count, and the census copies it verbatim"
),
&format!("{prefix}n_files"),
));
}
}
for field in ["counted_by", "mark", "note"] {
match map.get(field) {
None | Some(Value::String(_)) => {}
Some(value) => violations.push(violation(
"EXT-CORPORA-006",
format!("`{prefix}{field}` must be a string, got {value:?}"),
&format!("{prefix}{field}"),
)),
}
}
}
fn check_duplicate_name(
map: &Mapping,
prefix: &str,
seen: &mut Vec<String>,
violations: &mut Vec<Violation>,
) {
let Some(Value::String(name)) = map.get("name") else {
return;
};
if seen.iter().any(|s| s == name) {
violations.push(violation(
"EXT-CORPORA-008",
format!(
"duplicate corpus name {name:?} — the census sorts and reports by name, \
so two entries sharing one make the figure unattributable"
),
&format!("{prefix}name"),
));
} else {
seen.push(name.clone());
}
}
fn check_census_readable(yaml: &str, violations: &mut Vec<Violation>) {
if let Err(e) = parse_external_corpora_str(yaml) {
violations.push(violation(
"EXT-CORPORA-009",
format!(
"the declaration does not deserialize into the struct `pv census` reads: \
{e} — pv validate would be passing a file the census cannot count"
),
"",
));
}
}
#[cfg(test)]
mod tests {
include!("external_corpora_tests.rs");
}