harn_modules/
host_capability_config.rs1use std::collections::HashMap;
4use std::path::{Path, PathBuf};
5
6use serde::Deserialize;
7
8use crate::host_capabilities::{parse_host_capability_document, HostCapabilitySurface};
9
10#[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 #[serde(default, alias = "host_served_capabilities_file")]
19 pub host_served_capabilities_path: Option<String>,
20 #[serde(default)]
22 pub runtime_installed_host_operations: Vec<String>,
23 #[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
53pub 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
68pub 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}