Skip to main content

harn_modules/
host_capability_config.rs

1//! Project settings for declared and served host operations.
2
3use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use serde::Deserialize;
7
8use crate::host_capabilities::{parse_host_capability_document, HostCapabilitySurface};
9
10/// The host-operation fields under `[check]` in `harn.toml`.
11#[derive(Debug, Default, Clone, Deserialize)]
12pub struct HostCapabilityConfig {
13    #[serde(default)]
14    pub host_capabilities: HashMap<String, Vec<String>>,
15    #[serde(default, alias = "host_capabilities_file")]
16    pub host_capabilities_path: Option<String>,
17    /// JSON or TOML file that lists the operations the target host serves.
18    #[serde(default, alias = "host_served_capabilities_file")]
19    pub host_served_capabilities_path: Option<String>,
20    /// Exact declared operations whose handlers are added at runtime.
21    #[serde(default)]
22    pub runtime_installed_host_operations: Vec<String>,
23    /// Stop ACP startup when the connected host does not serve a declaration.
24    #[serde(default)]
25    pub require_declared_operations_served: bool,
26}
27
28impl HostCapabilityConfig {
29    #[must_use]
30    pub fn with_paths_from(mut self, manifest_dir: &Path) -> Self {
31        absolutize(&mut self.host_capabilities_path, manifest_dir);
32        absolutize(&mut self.host_served_capabilities_path, manifest_dir);
33        self
34    }
35}
36
37fn absolutize(path: &mut Option<String>, manifest_dir: &Path) {
38    let Some(value) = path.as_ref() else {
39        return;
40    };
41    let candidate = PathBuf::from(value);
42    if !candidate.is_absolute() {
43        *path = Some(manifest_dir.join(candidate).display().to_string());
44    }
45}
46
47#[derive(Default, Deserialize)]
48struct ProjectManifest {
49    #[serde(default)]
50    check: HostCapabilityConfig,
51}
52
53/// Load host-operation settings from `<project_root>/harn.toml`.
54pub fn load_host_capability_config(project_root: &Path) -> Result<HostCapabilityConfig, String> {
55    let path = project_root.join("harn.toml");
56    let content = match std::fs::read_to_string(&path) {
57        Ok(content) => content,
58        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
59            return Ok(HostCapabilityConfig::default());
60        }
61        Err(error) => return Err(format!("failed to read `{}`: {error}", path.display())),
62    };
63    let manifest = toml::from_str::<ProjectManifest>(&content)
64        .map_err(|error| format!("failed to parse `{}`: {error}", path.display()))?;
65    Ok(manifest.check.with_paths_from(project_root))
66}
67
68/// One resolved declaration snapshot shared by static and runtime checks.
69pub struct ResolvedHostCapabilityConfig {
70    pub declared: HostCapabilitySurface,
71    pub source_content: Option<String>,
72    pub source_value: Option<serde_json::Value>,
73    pub error: Option<String>,
74}
75
76pub fn resolve_host_capability_config(
77    config: &HostCapabilityConfig,
78) -> ResolvedHostCapabilityConfig {
79    let declared = HostCapabilitySurface::from_pairs(config.host_capabilities.iter().flat_map(
80        |(capability, operations)| {
81            operations
82                .iter()
83                .map(move |operation| (capability.as_str(), operation.as_str()))
84        },
85    ));
86    let mut resolved = ResolvedHostCapabilityConfig {
87        declared,
88        source_content: None,
89        source_value: None,
90        error: None,
91    };
92    let Some(path) = config.host_capabilities_path.as_deref() else {
93        return resolved;
94    };
95    let content = match std::fs::read_to_string(path) {
96        Ok(content) => content,
97        Err(error) => {
98            resolved.error = Some(format!(
99                "failed to read declared host operations from `{path}`: {error}"
100            ));
101            return resolved;
102        }
103    };
104    resolved.source_content = Some(content.clone());
105    match parse_host_capability_document(&content, path, "declared") {
106        Ok(value) => {
107            resolved
108                .declared
109                .extend(HostCapabilitySurface::from_value(&value));
110            resolved.source_value = Some(value);
111        }
112        Err(error) => resolved.error = Some(error),
113    }
114    resolved
115}
116
117#[cfg(test)]
118mod tests {
119    use super::*;
120
121    #[test]
122    fn project_loader_keeps_host_settings_in_one_shared_shape() {
123        let project = tempfile::tempdir().unwrap();
124        std::fs::write(
125            project.path().join("harn.toml"),
126            r#"
127[check]
128host_capabilities.workspace = ["read_text"]
129host_capabilities_path = "declared.json"
130host_served_capabilities_path = "served.json"
131runtime_installed_host_operations = ["runtime.prompt_content"]
132require_declared_operations_served = true
133"#,
134        )
135        .unwrap();
136
137        let config = load_host_capability_config(project.path()).unwrap();
138        assert_eq!(config.host_capabilities["workspace"], ["read_text"]);
139        assert!(config
140            .host_capabilities_path
141            .as_deref()
142            .unwrap()
143            .ends_with("declared.json"));
144        assert!(config
145            .host_served_capabilities_path
146            .as_deref()
147            .unwrap()
148            .ends_with("served.json"));
149        assert_eq!(
150            config.runtime_installed_host_operations,
151            ["runtime.prompt_content"]
152        );
153        assert!(config.require_declared_operations_served);
154    }
155
156    #[test]
157    fn malformed_declaration_keeps_source_for_cache_keys() {
158        let project = tempfile::tempdir().unwrap();
159        let path = project.path().join("declared.json");
160        std::fs::write(&path, "not JSON or TOML").unwrap();
161        let config = HostCapabilityConfig {
162            host_capabilities_path: Some(path.display().to_string()),
163            ..Default::default()
164        };
165
166        let resolved = resolve_host_capability_config(&config);
167
168        assert_eq!(resolved.source_content.as_deref(), Some("not JSON or TOML"));
169        assert!(resolved.source_value.is_none());
170        assert!(resolved.error.is_some());
171    }
172}