1use std::collections::{BTreeMap, BTreeSet};
7
8fn operation_entries(entry: &serde_json::Value) -> &serde_json::Value {
9 entry
10 .get("operations")
11 .or_else(|| entry.get("ops"))
12 .unwrap_or(entry)
13}
14
15#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
17pub struct HostCapabilityOperation {
18 pub capability: String,
19 pub operation: String,
20}
21
22impl HostCapabilityOperation {
23 #[must_use]
24 pub fn qualified_name(&self) -> String {
25 format!("{}.{}", self.capability, self.operation)
26 }
27}
28
29#[derive(Debug, Clone, Default, PartialEq, Eq)]
31pub struct HostCapabilitySurface {
32 operations: BTreeMap<String, BTreeSet<String>>,
33 optional_operations: BTreeSet<HostCapabilityOperation>,
34}
35
36impl HostCapabilitySurface {
37 #[must_use]
38 pub fn from_pairs<I, C, O>(pairs: I) -> Self
39 where
40 I: IntoIterator<Item = (C, O)>,
41 C: Into<String>,
42 O: Into<String>,
43 {
44 let mut surface = Self::default();
45 for (capability, operation) in pairs {
46 surface
47 .operations
48 .entry(capability.into())
49 .or_default()
50 .insert(operation.into());
51 }
52 surface
53 }
54
55 #[must_use]
56 pub fn contains(&self, capability: &str, operation: &str) -> bool {
57 self.operations
58 .get(capability)
59 .is_some_and(|operations| operations.contains(operation))
60 }
61
62 #[must_use]
64 pub fn from_value(value: &serde_json::Value) -> Self {
65 let root = value.get("capabilities").unwrap_or(value);
66 let Some(capabilities) = root.as_object() else {
67 return Self::default();
68 };
69 let mut pairs = Vec::new();
70 for (capability, entry) in capabilities {
71 let operations = operation_entries(entry);
72 if let Some(operations) = operations.as_array() {
73 pairs.extend(
74 operations
75 .iter()
76 .filter_map(serde_json::Value::as_str)
77 .map(|operation| (capability.as_str(), operation)),
78 );
79 continue;
80 }
81 let Some(operation_map) = operations.as_object() else {
82 continue;
83 };
84 pairs.extend(operation_map.iter().filter_map(|(operation, metadata)| {
85 metadata
86 .as_bool()
87 .unwrap_or(true)
88 .then_some((capability.as_str(), operation.as_str()))
89 }));
90 }
91 let mut surface = Self::from_pairs(pairs);
92 for (capability, entry) in capabilities {
93 let operations = operation_entries(entry);
94 if let Some(operations) = operations.as_object() {
95 for (operation, metadata) in operations {
96 if metadata
97 .get("optional")
98 .and_then(serde_json::Value::as_bool)
99 == Some(true)
100 {
101 surface.optional_operations.insert(HostCapabilityOperation {
102 capability: capability.clone(),
103 operation: operation.clone(),
104 });
105 }
106 }
107 }
108 }
109 surface
110 }
111
112 pub fn extend(&mut self, other: Self) {
113 self.optional_operations.extend(other.optional_operations);
114 for (capability, operations) in other.operations {
115 self.operations
116 .entry(capability)
117 .or_default()
118 .extend(operations);
119 }
120 }
121
122 pub fn operation_pairs(&self) -> impl Iterator<Item = (&str, &str)> {
123 self.operations.iter().flat_map(|(capability, operations)| {
124 operations
125 .iter()
126 .map(move |operation| (capability.as_str(), operation.as_str()))
127 })
128 }
129
130 #[must_use]
133 pub fn missing_from(
134 &self,
135 served: &Self,
136 runtime_installed: &HostCapabilityExemptions,
137 ) -> Vec<HostCapabilityOperation> {
138 self.operation_pairs()
139 .filter(|(capability, operation)| {
140 !served.contains(capability, operation)
141 && !runtime_installed.contains(capability, operation)
142 && !self.optional_operations.contains(&HostCapabilityOperation {
143 capability: capability.to_string(),
144 operation: operation.to_string(),
145 })
146 })
147 .map(|(capability, operation)| HostCapabilityOperation {
148 capability: capability.to_string(),
149 operation: operation.to_string(),
150 })
151 .collect()
152 }
153}
154
155pub fn parse_host_capability_document(
157 content: &str,
158 path: &str,
159 kind: &str,
160) -> Result<serde_json::Value, String> {
161 let value = serde_json::from_str::<serde_json::Value>(content)
162 .ok()
163 .or_else(|| {
164 toml::from_str::<toml::Value>(content)
165 .ok()
166 .and_then(|value| serde_json::to_value(value).ok())
167 })
168 .ok_or_else(|| {
169 format!("failed to parse {kind} host operations in `{path}` as JSON or TOML")
170 })?;
171 if let Some(capabilities) = value.get("capabilities").unwrap_or(&value).as_object() {
172 for (capability, entry) in capabilities {
173 let operations = operation_entries(entry);
174 if let Some(operations) = operations.as_object() {
175 for (operation, metadata) in operations {
176 if metadata
177 .get("optional")
178 .is_some_and(|optional| !optional.is_boolean())
179 {
180 return Err(format!(
181 "{kind} host operation `{capability}.{operation}` in `{path}` must declare `optional` as a boolean"
182 ));
183 }
184 }
185 }
186 }
187 }
188 Ok(value)
189}
190
191#[derive(Debug, Clone, Default, PartialEq, Eq)]
195pub struct HostCapabilityExemptions(HostCapabilitySurface);
196
197impl HostCapabilityExemptions {
198 pub fn parse<'a>(values: impl IntoIterator<Item = &'a str>) -> Result<Self, String> {
199 let mut operations = Vec::new();
200 for value in values {
201 let Some((capability, operation)) = value.split_once('.') else {
202 return Err(format!(
203 "runtime-installed host operation `{value}` must use `capability.operation`"
204 ));
205 };
206 if capability.is_empty()
207 || operation.is_empty()
208 || operation.contains('.')
209 || capability == "*"
210 || operation == "*"
211 {
212 return Err(format!(
213 "runtime-installed host operation `{value}` must name one exact `capability.operation` pair"
214 ));
215 }
216 operations.push((capability, operation));
217 }
218 Ok(Self(HostCapabilitySurface::from_pairs(operations)))
219 }
220
221 #[must_use]
222 pub fn contains(&self, capability: &str, operation: &str) -> bool {
223 self.0.contains(capability, operation)
224 }
225}
226
227#[cfg(test)]
228mod tests {
229 use super::*;
230
231 #[test]
232 fn optional_operations_remain_known_without_becoming_required_or_served() {
233 let value = parse_host_capability_document(
234 r#"{"workspace":{"operations":{"read_text":{},"report":{"optional":true}}}}"#,
235 "declared.json",
236 "declared",
237 )
238 .unwrap();
239 let declared = HostCapabilitySurface::from_value(&value);
240 let served = HostCapabilitySurface::default();
241 assert!(declared.contains("workspace", "report"));
242 assert!(!served.contains("workspace", "report"));
243 assert_eq!(
244 declared.missing_from(&served, &HostCapabilityExemptions::default()),
245 [HostCapabilityOperation {
246 capability: "workspace".into(),
247 operation: "read_text".into()
248 }]
249 );
250 }
251
252 #[test]
253 fn malformed_optional_requirement_cannot_silence_missing_operations() {
254 assert!(parse_host_capability_document(
255 r#"{"workspace":{"operations":{"read_text":{"optional":"true"}}}}"#,
256 "declared.json",
257 "declared",
258 )
259 .is_err());
260 assert!(parse_host_capability_document(
261 r#"{"workspace":{"read_text":{"optional":"true"}}}"#,
262 "declared.json",
263 "declared",
264 )
265 .is_err());
266 }
267
268 #[test]
269 fn direct_operation_metadata_has_the_same_optional_contract() {
270 let value = parse_host_capability_document(
271 r#"{"workspace":{"read_text":{},"report":{"optional":true}}}"#,
272 "declared.json",
273 "declared",
274 )
275 .unwrap();
276 let declared = HostCapabilitySurface::from_value(&value);
277 let served = HostCapabilitySurface::default();
278 assert!(declared.contains("workspace", "report"));
279 assert_eq!(
280 declared.missing_from(&served, &HostCapabilityExemptions::default()),
281 [HostCapabilityOperation {
282 capability: "workspace".into(),
283 operation: "read_text".into()
284 }]
285 );
286 }
287
288 #[test]
289 fn reconciliation_is_sorted_and_honors_exact_runtime_installations() {
290 let declared = HostCapabilitySurface::from_pairs([
291 ("workspace", "write_text"),
292 ("project", "runtime_only"),
293 ("workspace", "read_text"),
294 ]);
295 let served = HostCapabilitySurface::from_pairs([("workspace", "read_text")]);
296 let exemptions = HostCapabilityExemptions::parse(["project.runtime_only"]).unwrap();
297
298 assert_eq!(
299 declared
300 .missing_from(&served, &exemptions)
301 .into_iter()
302 .map(|operation| operation.qualified_name())
303 .collect::<Vec<_>>(),
304 ["workspace.write_text"]
305 );
306 }
307
308 #[test]
309 fn runtime_installations_reject_wildcards_and_malformed_names() {
310 for value in ["workspace", "workspace.*", "*.read_text", "a.b.c"] {
311 assert!(HostCapabilityExemptions::parse([value]).is_err(), "{value}");
312 }
313 }
314
315 #[test]
316 fn document_shapes_project_to_one_surface() {
317 let value = parse_host_capability_document(
318 r#"{"capabilities":{"workspace":{"operations":{"read_text":true,"old":false}},"project":["scan"]}}"#,
319 "caps.json",
320 "declared",
321 )
322 .unwrap();
323 let surface = HostCapabilitySurface::from_value(&value);
324
325 assert!(surface.contains("workspace", "read_text"));
326 assert!(surface.contains("project", "scan"));
327 assert!(!surface.contains("workspace", "old"));
328 }
329}