use std::collections::{BTreeMap, BTreeSet};
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub struct HostCapabilityOperation {
pub capability: String,
pub operation: String,
}
impl HostCapabilityOperation {
#[must_use]
pub fn qualified_name(&self) -> String {
format!("{}.{}", self.capability, self.operation)
}
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HostCapabilitySurface {
operations: BTreeMap<String, BTreeSet<String>>,
}
impl HostCapabilitySurface {
#[must_use]
pub fn from_pairs<I, C, O>(pairs: I) -> Self
where
I: IntoIterator<Item = (C, O)>,
C: Into<String>,
O: Into<String>,
{
let mut surface = Self::default();
for (capability, operation) in pairs {
surface
.operations
.entry(capability.into())
.or_default()
.insert(operation.into());
}
surface
}
#[must_use]
pub fn contains(&self, capability: &str, operation: &str) -> bool {
self.operations
.get(capability)
.is_some_and(|operations| operations.contains(operation))
}
#[must_use]
pub fn from_value(value: &serde_json::Value) -> Self {
let root = value.get("capabilities").unwrap_or(value);
let Some(capabilities) = root.as_object() else {
return Self::default();
};
let mut pairs = Vec::new();
for (capability, entry) in capabilities {
if let Some(operations) = entry.as_array() {
pairs.extend(
operations
.iter()
.filter_map(serde_json::Value::as_str)
.map(|operation| (capability.as_str(), operation)),
);
continue;
}
let Some(entry) = entry.as_object() else {
continue;
};
let operations = entry
.get("operations")
.or_else(|| entry.get("ops"))
.unwrap_or(&serde_json::Value::Null);
if let Some(list) = operations.as_array() {
pairs.extend(
list.iter()
.filter_map(serde_json::Value::as_str)
.map(|operation| (capability.as_str(), operation)),
);
continue;
}
let operation_map = operations.as_object().unwrap_or(entry);
pairs.extend(operation_map.iter().filter_map(|(operation, metadata)| {
metadata
.as_bool()
.unwrap_or(true)
.then_some((capability.as_str(), operation.as_str()))
}));
}
Self::from_pairs(pairs)
}
pub fn extend(&mut self, other: Self) {
for (capability, operations) in other.operations {
self.operations
.entry(capability)
.or_default()
.extend(operations);
}
}
pub fn operation_pairs(&self) -> impl Iterator<Item = (&str, &str)> {
self.operations.iter().flat_map(|(capability, operations)| {
operations
.iter()
.map(move |operation| (capability.as_str(), operation.as_str()))
})
}
#[must_use]
pub fn missing_from(
&self,
served: &Self,
runtime_installed: &HostCapabilityExemptions,
) -> Vec<HostCapabilityOperation> {
self.operation_pairs()
.filter(|(capability, operation)| {
!served.contains(capability, operation)
&& !runtime_installed.contains(capability, operation)
})
.map(|(capability, operation)| HostCapabilityOperation {
capability: capability.to_string(),
operation: operation.to_string(),
})
.collect()
}
}
pub fn parse_host_capability_document(
content: &str,
path: &str,
kind: &str,
) -> Result<serde_json::Value, String> {
serde_json::from_str::<serde_json::Value>(content)
.ok()
.or_else(|| {
toml::from_str::<toml::Value>(content)
.ok()
.and_then(|value| serde_json::to_value(value).ok())
})
.ok_or_else(|| {
format!("failed to parse {kind} host operations in `{path}` as JSON or TOML")
})
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct HostCapabilityExemptions(HostCapabilitySurface);
impl HostCapabilityExemptions {
pub fn parse<'a>(values: impl IntoIterator<Item = &'a str>) -> Result<Self, String> {
let mut operations = Vec::new();
for value in values {
let Some((capability, operation)) = value.split_once('.') else {
return Err(format!(
"runtime-installed host operation `{value}` must use `capability.operation`"
));
};
if capability.is_empty()
|| operation.is_empty()
|| operation.contains('.')
|| capability == "*"
|| operation == "*"
{
return Err(format!(
"runtime-installed host operation `{value}` must name one exact `capability.operation` pair"
));
}
operations.push((capability, operation));
}
Ok(Self(HostCapabilitySurface::from_pairs(operations)))
}
#[must_use]
pub fn contains(&self, capability: &str, operation: &str) -> bool {
self.0.contains(capability, operation)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn reconciliation_is_sorted_and_honors_exact_runtime_installations() {
let declared = HostCapabilitySurface::from_pairs([
("workspace", "write_text"),
("project", "runtime_only"),
("workspace", "read_text"),
]);
let served = HostCapabilitySurface::from_pairs([("workspace", "read_text")]);
let exemptions = HostCapabilityExemptions::parse(["project.runtime_only"]).unwrap();
assert_eq!(
declared
.missing_from(&served, &exemptions)
.into_iter()
.map(|operation| operation.qualified_name())
.collect::<Vec<_>>(),
["workspace.write_text"]
);
}
#[test]
fn runtime_installations_reject_wildcards_and_malformed_names() {
for value in ["workspace", "workspace.*", "*.read_text", "a.b.c"] {
assert!(HostCapabilityExemptions::parse([value]).is_err(), "{value}");
}
}
#[test]
fn document_shapes_project_to_one_surface() {
let value = parse_host_capability_document(
r#"{"capabilities":{"workspace":{"operations":{"read_text":true,"old":false}},"project":["scan"]}}"#,
"caps.json",
"declared",
)
.unwrap();
let surface = HostCapabilitySurface::from_value(&value);
assert!(surface.contains("workspace", "read_text"));
assert!(surface.contains("project", "scan"));
assert!(!surface.contains("workspace", "old"));
}
}