harn_modules/
host_capabilities.rs1use std::collections::{BTreeMap, BTreeSet};
7
8#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
10pub struct HostCapabilityOperation {
11 pub capability: String,
12 pub operation: String,
13}
14
15impl HostCapabilityOperation {
16 #[must_use]
17 pub fn qualified_name(&self) -> String {
18 format!("{}.{}", self.capability, self.operation)
19 }
20}
21
22#[derive(Debug, Clone, Default, PartialEq, Eq)]
24pub struct HostCapabilitySurface {
25 operations: BTreeMap<String, BTreeSet<String>>,
26}
27
28impl HostCapabilitySurface {
29 #[must_use]
30 pub fn from_pairs<I, C, O>(pairs: I) -> Self
31 where
32 I: IntoIterator<Item = (C, O)>,
33 C: Into<String>,
34 O: Into<String>,
35 {
36 let mut surface = Self::default();
37 for (capability, operation) in pairs {
38 surface
39 .operations
40 .entry(capability.into())
41 .or_default()
42 .insert(operation.into());
43 }
44 surface
45 }
46
47 #[must_use]
48 pub fn contains(&self, capability: &str, operation: &str) -> bool {
49 self.operations
50 .get(capability)
51 .is_some_and(|operations| operations.contains(operation))
52 }
53
54 #[must_use]
56 pub fn from_value(value: &serde_json::Value) -> Self {
57 let root = value.get("capabilities").unwrap_or(value);
58 let Some(capabilities) = root.as_object() else {
59 return Self::default();
60 };
61 let mut pairs = Vec::new();
62 for (capability, entry) in capabilities {
63 if let Some(operations) = entry.as_array() {
64 pairs.extend(
65 operations
66 .iter()
67 .filter_map(serde_json::Value::as_str)
68 .map(|operation| (capability.as_str(), operation)),
69 );
70 continue;
71 }
72 let Some(entry) = entry.as_object() else {
73 continue;
74 };
75 let operations = entry
76 .get("operations")
77 .or_else(|| entry.get("ops"))
78 .unwrap_or(&serde_json::Value::Null);
79 if let Some(list) = operations.as_array() {
80 pairs.extend(
81 list.iter()
82 .filter_map(serde_json::Value::as_str)
83 .map(|operation| (capability.as_str(), operation)),
84 );
85 continue;
86 }
87 let operation_map = operations.as_object().unwrap_or(entry);
88 pairs.extend(operation_map.iter().filter_map(|(operation, metadata)| {
89 metadata
90 .as_bool()
91 .unwrap_or(true)
92 .then_some((capability.as_str(), operation.as_str()))
93 }));
94 }
95 Self::from_pairs(pairs)
96 }
97
98 pub fn extend(&mut self, other: Self) {
99 for (capability, operations) in other.operations {
100 self.operations
101 .entry(capability)
102 .or_default()
103 .extend(operations);
104 }
105 }
106
107 pub fn operation_pairs(&self) -> impl Iterator<Item = (&str, &str)> {
108 self.operations.iter().flat_map(|(capability, operations)| {
109 operations
110 .iter()
111 .map(move |operation| (capability.as_str(), operation.as_str()))
112 })
113 }
114
115 #[must_use]
118 pub fn missing_from(
119 &self,
120 served: &Self,
121 runtime_installed: &HostCapabilityExemptions,
122 ) -> Vec<HostCapabilityOperation> {
123 self.operation_pairs()
124 .filter(|(capability, operation)| {
125 !served.contains(capability, operation)
126 && !runtime_installed.contains(capability, operation)
127 })
128 .map(|(capability, operation)| HostCapabilityOperation {
129 capability: capability.to_string(),
130 operation: operation.to_string(),
131 })
132 .collect()
133 }
134}
135
136pub fn parse_host_capability_document(
138 content: &str,
139 path: &str,
140 kind: &str,
141) -> Result<serde_json::Value, String> {
142 serde_json::from_str::<serde_json::Value>(content)
143 .ok()
144 .or_else(|| {
145 toml::from_str::<toml::Value>(content)
146 .ok()
147 .and_then(|value| serde_json::to_value(value).ok())
148 })
149 .ok_or_else(|| {
150 format!("failed to parse {kind} host operations in `{path}` as JSON or TOML")
151 })
152}
153
154#[derive(Debug, Clone, Default, PartialEq, Eq)]
158pub struct HostCapabilityExemptions(HostCapabilitySurface);
159
160impl HostCapabilityExemptions {
161 pub fn parse<'a>(values: impl IntoIterator<Item = &'a str>) -> Result<Self, String> {
162 let mut operations = Vec::new();
163 for value in values {
164 let Some((capability, operation)) = value.split_once('.') else {
165 return Err(format!(
166 "runtime-installed host operation `{value}` must use `capability.operation`"
167 ));
168 };
169 if capability.is_empty()
170 || operation.is_empty()
171 || operation.contains('.')
172 || capability == "*"
173 || operation == "*"
174 {
175 return Err(format!(
176 "runtime-installed host operation `{value}` must name one exact `capability.operation` pair"
177 ));
178 }
179 operations.push((capability, operation));
180 }
181 Ok(Self(HostCapabilitySurface::from_pairs(operations)))
182 }
183
184 #[must_use]
185 pub fn contains(&self, capability: &str, operation: &str) -> bool {
186 self.0.contains(capability, operation)
187 }
188}
189
190#[cfg(test)]
191mod tests {
192 use super::*;
193
194 #[test]
195 fn reconciliation_is_sorted_and_honors_exact_runtime_installations() {
196 let declared = HostCapabilitySurface::from_pairs([
197 ("workspace", "write_text"),
198 ("project", "runtime_only"),
199 ("workspace", "read_text"),
200 ]);
201 let served = HostCapabilitySurface::from_pairs([("workspace", "read_text")]);
202 let exemptions = HostCapabilityExemptions::parse(["project.runtime_only"]).unwrap();
203
204 assert_eq!(
205 declared
206 .missing_from(&served, &exemptions)
207 .into_iter()
208 .map(|operation| operation.qualified_name())
209 .collect::<Vec<_>>(),
210 ["workspace.write_text"]
211 );
212 }
213
214 #[test]
215 fn runtime_installations_reject_wildcards_and_malformed_names() {
216 for value in ["workspace", "workspace.*", "*.read_text", "a.b.c"] {
217 assert!(HostCapabilityExemptions::parse([value]).is_err(), "{value}");
218 }
219 }
220
221 #[test]
222 fn document_shapes_project_to_one_surface() {
223 let value = parse_host_capability_document(
224 r#"{"capabilities":{"workspace":{"operations":{"read_text":true,"old":false}},"project":["scan"]}}"#,
225 "caps.json",
226 "declared",
227 )
228 .unwrap();
229 let surface = HostCapabilitySurface::from_value(&value);
230
231 assert!(surface.contains("workspace", "read_text"));
232 assert!(surface.contains("project", "scan"));
233 assert!(!surface.contains("workspace", "old"));
234 }
235}