use std::collections::BTreeMap;
use std::fmt;
use serde::{Deserialize, Serialize};
use sha2::{Digest, Sha256};
use super::capability::{
AttrValue, Capability, CapabilityId, Provenance, valid_attr_name, valid_segment,
};
use super::requirement::{IdSelector, PredicateOp, Requirement};
pub const MAX_VOCAB_BYTES: usize = 256 * 1024;
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
#[serde(rename_all = "snake_case")]
pub enum AttrType {
String,
Int,
Number,
Bool,
List,
}
impl AttrType {
fn accepts(self, v: &AttrValue) -> bool {
matches!(
(self, v),
(AttrType::String, AttrValue::Str(_))
| (AttrType::Int, AttrValue::Int(_))
| (AttrType::Number, AttrValue::Int(_) | AttrValue::Float(_))
| (AttrType::Bool, AttrValue::Bool(_))
| (AttrType::List, AttrValue::List(_))
)
}
fn name(self) -> &'static str {
match self {
AttrType::String => "string",
AttrType::Int => "int",
AttrType::Number => "number",
AttrType::Bool => "bool",
AttrType::List => "list",
}
}
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct VocabularyMeta {
pub version: u32,
pub title: String,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct Family {
pub title: String,
pub order: u32,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
#[serde(deny_unknown_fields)]
pub struct IdEntry {
pub summary: String,
#[serde(default)]
pub attrs: BTreeMap<String, AttrType>,
#[serde(default)]
pub min_provenance: Option<Provenance>,
}
#[derive(Debug, Clone, Deserialize)]
#[serde(deny_unknown_fields)]
struct VocabularyFile {
meta: VocabularyMeta,
families: BTreeMap<String, Family>,
ids: BTreeMap<String, IdEntry>,
}
#[derive(Debug, Clone, PartialEq, thiserror::Error)]
pub enum VocabularyError {
#[error("vocabulary file is too large ({0} bytes)")]
TooLarge(usize),
#[error("vocabulary file does not parse: {0}")]
Parse(String),
#[error("vocabulary entry {entry:?} is invalid: {reason}")]
Invalid {
entry: String,
reason: String,
},
#[error("vocabulary digest {actual} is not the pinned digest {expected}")]
DigestMismatch {
expected: String,
actual: String,
},
#[error("vocabulary change must increase meta.version ({from} -> {to})")]
VersionNotIncreased {
from: u32,
to: u32,
},
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum VocabWarning {
UnknownId(String),
UnknownPrefix(String),
AttrType {
id: String,
attr: String,
expected: &'static str,
},
Provenance {
id: String,
expected: Provenance,
},
}
impl fmt::Display for VocabWarning {
fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
match self {
VocabWarning::UnknownId(id) => write!(
f,
"{id}: not in the well-known vocabulary (accepted; use `x.` for experimental ids)"
),
VocabWarning::UnknownPrefix(p) => {
write!(f, "{p}: prefix covers no well-known id (accepted)")
}
VocabWarning::AttrType { id, attr, expected } => {
write!(f, "{id}.{attr}: expected {expected}")
}
VocabWarning::Provenance { id, expected } => {
write!(f, "{id}: expected provenance {expected:?} or better")
}
}
}
}
#[derive(Debug, Clone, PartialEq)]
pub struct Vocabulary {
meta: VocabularyMeta,
families: BTreeMap<String, Family>,
ids: BTreeMap<String, IdEntry>,
digest: String,
}
fn is_placeholder(seg: &str) -> bool {
seg.len() > 2
&& seg.starts_with('<')
&& seg.ends_with('>')
&& valid_attr_name(&seg[1..seg.len() - 1])
}
pub fn digest_of(text: &str) -> String {
Sha256::digest(text.as_bytes())
.iter()
.map(|b| format!("{b:02x}"))
.collect()
}
impl Vocabulary {
pub fn from_toml_str(text: &str) -> Result<Self, VocabularyError> {
if text.len() > MAX_VOCAB_BYTES {
return Err(VocabularyError::TooLarge(text.len()));
}
let file: VocabularyFile =
toml::from_str(text).map_err(|e| VocabularyError::Parse(e.to_string()))?;
let invalid = |entry: &str, reason: &str| VocabularyError::Invalid {
entry: entry.to_string(),
reason: reason.to_string(),
};
for name in file.families.keys() {
if !valid_segment(name) || name == "x" {
return Err(invalid(name, "family must be one id segment and not `x`"));
}
}
for (id, entry) in &file.ids {
let segs: Vec<&str> = id.split('.').collect();
if segs.len() < 2 || segs.len() > super::capability::MAX_ID_SEGMENTS {
return Err(invalid(id, "id needs 2..=8 segments"));
}
if !segs.iter().all(|s| valid_segment(s) || is_placeholder(s)) {
return Err(invalid(
id,
"segments must be [a-z0-9][a-z0-9_-]* or <name>",
));
}
if !file.families.contains_key(segs[0]) {
return Err(invalid(id, "first segment is not a declared family"));
}
if let Some(a) = entry.attrs.keys().find(|a| !valid_attr_name(a)) {
return Err(invalid(
id,
&format!("attribute {a:?} must match [a-z][a-z0-9_]*"),
));
}
}
Ok(Self {
meta: file.meta,
families: file.families,
ids: file.ids,
digest: digest_of(text),
})
}
pub fn digest(&self) -> &str {
&self.digest
}
pub fn meta(&self) -> &VocabularyMeta {
&self.meta
}
pub fn families(&self) -> &BTreeMap<String, Family> {
&self.families
}
pub fn ids(&self) -> &BTreeMap<String, IdEntry> {
&self.ids
}
pub fn lookup(&self, id: &str) -> Option<&IdEntry> {
if let Some(e) = self.ids.get(id) {
return Some(e);
}
let segs: Vec<&str> = id.split('.').collect();
self.ids.iter().find_map(|(pat, e)| {
let p: Vec<&str> = pat.split('.').collect();
let hit = p.len() == segs.len()
&& p.iter()
.zip(&segs)
.all(|(a, b)| a == b || is_placeholder(a));
hit.then_some(e)
})
}
fn unknown_id(&self, id: &CapabilityId) -> Option<VocabWarning> {
(!id.is_experimental() && self.lookup(id.as_str()).is_none())
.then(|| VocabWarning::UnknownId(id.to_string()))
}
pub fn validate_capability(&self, cap: &Capability) -> Vec<VocabWarning> {
let mut out: Vec<VocabWarning> = self.unknown_id(&cap.id).into_iter().collect();
if let Some(entry) = self.lookup(cap.id.as_str()) {
for (attr, value) in &cap.attrs {
match entry.attrs.get(attr) {
Some(t) if !t.accepts(value) => out.push(VocabWarning::AttrType {
id: cap.id.to_string(),
attr: attr.clone(),
expected: t.name(),
}),
_ => {}
}
}
if let Some(min) = entry.min_provenance.filter(|&min| cap.provenance < min) {
out.push(VocabWarning::Provenance {
id: cap.id.to_string(),
expected: min,
});
}
}
out
}
pub fn validate_requirement(&self, req: &Requirement) -> Vec<VocabWarning> {
match &req.selector {
IdSelector::Exact(id) => {
let mut out: Vec<VocabWarning> = self.unknown_id(id).into_iter().collect();
if let Some(entry) = self.lookup(id.as_str()) {
for p in &req.where_ {
let Some(t) = entry.attrs.get(&p.attr) else {
continue;
};
let fits = match p.op {
PredicateOp::Gte | PredicateOp::Lte => {
matches!(t, AttrType::Int | AttrType::Number)
}
PredicateOp::Has => *t == AttrType::List,
PredicateOp::Eq => t.accepts(&p.value),
PredicateOp::In => *t != AttrType::List,
};
if !fits {
out.push(VocabWarning::AttrType {
id: id.to_string(),
attr: p.attr.clone(),
expected: t.name(),
});
}
}
}
out
}
IdSelector::Prefix(p) => {
let want: Vec<&str> = p.split('.').collect();
let known = want[0] == super::capability::EXPERIMENTAL_PREFIX
|| self.ids.keys().any(|k| {
let have: Vec<&str> = k.split('.').collect();
have.len() >= want.len()
&& want
.iter()
.zip(&have)
.all(|(w, h)| w == h || is_placeholder(h))
});
if known {
vec![]
} else {
vec![VocabWarning::UnknownPrefix(p.clone())]
}
}
}
}
pub fn to_markdown(&self) -> String {
let mut fams: Vec<(&String, &Family)> = self.families.iter().collect();
fams.sort_by_key(|(k, f)| (f.order, (*k).clone()));
let mut s = format!(
"# Capability vocabulary\n\n<!-- Generated from config/capabilities.toml by the clawft-types test \
`vocabulary_doc_is_current`. Do not edit by hand; regenerate with \
WEFTOS_REGEN_DOCS=1 scripts/build.sh test clawft-types -->\n\n\
{} (version {}). Advisory only: ids not listed here are accepted and matched; \
experimental ids use the `x.` prefix. Source: ADR-099 section 2.\n\n\
Vocabulary digest (SHA-256): `{}`\n",
self.meta.title, self.meta.version, self.digest
);
for (key, fam) in fams {
s.push_str(&format!("\n## {} (`{key}`)\n\n| Id | Summary | Attributes | Expected provenance |\n|---|---|---|---|\n", fam.title));
for (id, e) in self
.ids
.iter()
.filter(|(id, _)| id.split('.').next() == Some(key.as_str()))
{
let attrs = if e.attrs.is_empty() {
"-".to_string()
} else {
e.attrs
.iter()
.map(|(a, t)| format!("`{a}`: {}", t.name()))
.collect::<Vec<_>>()
.join(", ")
};
let prov = e
.min_provenance
.map(|p| format!("{p:?}").to_lowercase())
.unwrap_or_else(|| "-".into());
s.push_str(&format!(
"| `{id}` | {} | {attrs} | {prov} |\n",
e.summary.replace('|', "\\|")
));
}
}
s
}
}