Skip to main content

harn_modules/
host_capabilities.rs

1//! Shared checks for declared and served host operations.
2//!
3//! The CLI and runtime adapters both need to find declared operations that a
4//! host does not serve. This module owns that set check.
5
6use 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/// One namespaced host operation.
16#[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/// A deterministic set of host capability operations.
30#[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    /// Read the declaration shapes accepted by Harn's host-operation files.
63    #[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    /// Return each declared operation the host does not serve, except for
131    /// explicitly optional operations and handlers added at runtime.
132    #[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
155/// Parse a JSON or TOML host-operation document once for all consumers.
156pub 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/// Exact operations whose handlers are added at runtime.
192///
193/// Wildcards are not allowed. Each exemption must name one operation.
194#[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}