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
8/// One namespaced host operation.
9#[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/// A deterministic set of host capability operations.
23#[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    /// Read the declaration shapes accepted by Harn's host-operation files.
55    #[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    /// Return each declared operation the host does not serve, except for
116    /// operations whose handlers are added at runtime.
117    #[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
136/// Parse a JSON or TOML host-operation document once for all consumers.
137pub 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/// Exact operations whose handlers are added at runtime.
155///
156/// Wildcards are not allowed. Each exemption must name one operation.
157#[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}