lean_ctx/core/ocla/adapters/
coverage.rs1use serde::{Deserialize, Serialize};
4
5pub const CAPABILITY_COVERAGE_SCHEMA_VERSION: u32 = 1;
7
8#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
10#[serde(rename_all = "snake_case")]
11pub enum CapabilityCoverageScenario {
12 NativeSuccess,
13 ExternalSuccess,
14 PolicyRejection,
15 ExternalTimeout,
16 ExternalDisabled,
17}
18
19impl CapabilityCoverageScenario {
20 const REQUIRED: [Self; 5] = [
21 Self::NativeSuccess,
22 Self::ExternalSuccess,
23 Self::PolicyRejection,
24 Self::ExternalTimeout,
25 Self::ExternalDisabled,
26 ];
27
28 const fn expected_result(self) -> CapabilityCoverageResult {
29 match self {
30 Self::NativeSuccess | Self::ExternalSuccess => CapabilityCoverageResult::Succeeded,
31 Self::PolicyRejection => CapabilityCoverageResult::RejectedByPolicy,
32 Self::ExternalTimeout => CapabilityCoverageResult::TimedOut,
33 Self::ExternalDisabled => CapabilityCoverageResult::Disabled,
34 }
35 }
36}
37
38#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
40#[serde(rename_all = "snake_case")]
41pub enum CapabilityCoverageResult {
42 Succeeded,
43 RejectedByPolicy,
44 TimedOut,
45 Disabled,
46}
47
48#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
50#[serde(deny_unknown_fields)]
51pub struct CapabilityCoverageCaseV1 {
52 pub scenario: CapabilityCoverageScenario,
53 pub capability_id: String,
54 pub capability_version: String,
55 pub observed: CapabilityCoverageResult,
56}
57
58impl CapabilityCoverageCaseV1 {
59 #[must_use]
61 pub fn new(
62 scenario: CapabilityCoverageScenario,
63 capability_id: impl Into<String>,
64 capability_version: impl Into<String>,
65 observed: CapabilityCoverageResult,
66 ) -> Self {
67 Self {
68 scenario,
69 capability_id: capability_id.into(),
70 capability_version: capability_version.into(),
71 observed,
72 }
73 }
74
75 #[must_use]
77 pub fn passed(&self) -> bool {
78 self.scenario.expected_result() == self.observed
79 }
80}
81
82#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
84#[serde(deny_unknown_fields)]
85pub struct CapabilityCoverageReportV1 {
86 pub schema_version: u32,
87 pub cases: Vec<CapabilityCoverageCaseV1>,
88}
89
90impl CapabilityCoverageReportV1 {
91 #[must_use]
93 pub fn new(cases: Vec<CapabilityCoverageCaseV1>) -> Self {
94 Self {
95 schema_version: CAPABILITY_COVERAGE_SCHEMA_VERSION,
96 cases,
97 }
98 }
99
100 #[must_use]
102 pub fn is_complete(&self) -> bool {
103 self.schema_version == CAPABILITY_COVERAGE_SCHEMA_VERSION
104 && self.cases.len() == CapabilityCoverageScenario::REQUIRED.len()
105 && self
106 .cases
107 .iter()
108 .zip(CapabilityCoverageScenario::REQUIRED)
109 .all(|(case, required)| case.scenario == required && case.passed())
110 }
111
112 pub fn to_json(&self) -> Result<String, serde_json::Error> {
114 serde_json::to_string(self)
115 }
116}
117
118#[cfg(test)]
119mod report_tests {
120 use super::*;
121
122 #[test]
123 fn canonical_report_requires_each_passing_scenario_once() {
124 let cases = CapabilityCoverageScenario::REQUIRED.map(|scenario| {
125 CapabilityCoverageCaseV1::new(
126 scenario,
127 "capability://example/test",
128 "1.0.0",
129 scenario.expected_result(),
130 )
131 });
132 let report = CapabilityCoverageReportV1::new(cases.into());
133
134 assert!(report.is_complete());
135 assert_eq!(
136 report.to_json().expect("serializable report"),
137 report.to_json().expect("deterministic report")
138 );
139
140 let incomplete = CapabilityCoverageReportV1::new(Vec::new());
141 assert!(!incomplete.is_complete());
142 }
143}
144
145#[cfg(all(test, unix))]
146mod tests {
147 use std::ffi::OsString;
148 use std::fs;
149 use std::sync::Arc;
150
151 use super::*;
152 use crate::core::ocla::adapters::external_process::ExternalProcessAdapter;
153 use crate::core::ocla::adapters::{AdapterRegistry, NativeContextAdapter};
154 use crate::core::ocla::invocation::{
155 CapabilityAdapter, CapabilityInput, CapabilityInvocation, PolicyConstraints,
156 };
157
158 const EXTERNAL_MANIFEST: &str = include_str!(
159 "../../../../../docs/contracts/ocla/capability-manifests/example/word-count-optimizer-v1.json"
160 );
161
162 fn native_invocation(adapter: &NativeContextAdapter) -> CapabilityInvocation {
163 CapabilityInvocation {
164 task_id: "capability-coverage/native-success".into(),
165 capability_id: adapter.manifest().capability_id.as_str().into(),
166 capability_version: adapter.manifest().version.clone(),
167 input: CapabilityInput::ContextRequest {
168 paths: vec!["fixture.rs".into()],
169 mode: "aggressive".into(),
170 budget_tokens: Some(100),
171 },
172 policy_constraints: PolicyConstraints::default(),
173 timeout_ms: 1_000,
174 }
175 }
176
177 fn external_invocation(timeout_ms: u64) -> CapabilityInvocation {
178 CapabilityInvocation {
179 task_id: "capability-coverage/external-success".into(),
180 capability_id: "capability://example/word-count-optimizer".into(),
181 capability_version: "1.0.0".into(),
182 input: CapabilityInput::ShellCommand {
183 command: "hello world foo".into(),
184 workdir: None,
185 },
186 policy_constraints: PolicyConstraints::default(),
187 timeout_ms,
188 }
189 }
190
191 fn write_manifest(temp: &tempfile::TempDir) -> std::path::PathBuf {
192 let path = temp.path().join("word-count-manifest.json");
193 fs::write(&path, EXTERNAL_MANIFEST).expect("external manifest fixture");
194 path
195 }
196
197 fn stdin_consuming_adapter(temp: &tempfile::TempDir) -> ExternalProcessAdapter {
198 ExternalProcessAdapter::discover(
201 write_manifest(temp),
202 "/bin/sh",
203 [
204 OsString::from("-c"),
205 OsString::from(
206 "while IFS= read -r _; do :; done; \
207 printf '%s' '{\"word_count\":3,\"char_count\":15,\"line_count\":1}'",
208 ),
209 ],
210 )
211 .expect("bounded external adapter")
212 }
213
214 #[test]
215 fn coverage_matrix_proves_registry_native_and_external_safety_behavior() {
216 let temp = tempfile::tempdir().expect("fixture directory");
217 fs::write(
218 temp.path().join("fixture.rs"),
219 "// fixture documentation\nfn main() { let value = 1; }\n",
220 )
221 .expect("native fixture");
222
223 let native = Arc::new(NativeContextAdapter::with_root(temp.path()));
224 let external = Arc::new(stdin_consuming_adapter(&temp));
225 let registry = AdapterRegistry::new();
226 registry
227 .register_arc(native.clone())
228 .expect("native registration");
229 registry
230 .register_arc(external.clone())
231 .expect("external registration");
232
233 let native_id = native.manifest().capability_id.as_str().to_owned();
234 let native_version = native.manifest().version.clone();
235 let external_id = external.manifest().capability_id.as_str().to_owned();
236 let external_version = external.manifest().version.clone();
237 let registered_native = registry
238 .lookup(&native_id, &native_version)
239 .expect("registered native adapter");
240 let registered_external = registry
241 .lookup(&external_id, &external_version)
242 .expect("registered external adapter");
243
244 assert!(registered_native.invoke(native_invocation(&native)).is_ok());
245 assert!(
246 registered_external
247 .invoke(external_invocation(1_000))
248 .is_ok()
249 );
250
251 let mut rejected_invocation = native_invocation(&native);
252 rejected_invocation.policy_constraints.allowed_paths = vec!["other.rs".into()];
253 let rejected = registered_native
254 .invoke(rejected_invocation)
255 .expect_err("native adapter must enforce path policy");
256 assert!(rejected.to_string().contains("outside policy allowlist"));
257
258 let timeout_adapter = ExternalProcessAdapter::discover(
259 write_manifest(&temp),
260 "/bin/sleep",
261 [OsString::from("1")],
262 )
263 .expect("bounded timeout adapter");
264 let timed_out = timeout_adapter
265 .invoke(external_invocation(1))
266 .expect_err("external process must honor timeout");
267 assert!(timed_out.to_string().contains("exceeded its timeout"));
268
269 external.disable();
270 let disabled = registered_external
271 .invoke(external_invocation(1_000))
272 .expect_err("disabled adapter must remain unavailable through registry");
273 assert!(disabled.to_string().contains("is disabled"));
274 assert_eq!(registry.len(), 2, "disable must not unregister the adapter");
275 assert_eq!(
276 registry
277 .health_check_all()
278 .expect("registry health")
279 .into_iter()
280 .filter(|health| !health.healthy)
281 .count(),
282 1,
283 "only the disabled external adapter is unhealthy"
284 );
285
286 let report = CapabilityCoverageReportV1::new(vec![
287 CapabilityCoverageCaseV1::new(
288 CapabilityCoverageScenario::NativeSuccess,
289 native_id.clone(),
290 native_version.clone(),
291 CapabilityCoverageResult::Succeeded,
292 ),
293 CapabilityCoverageCaseV1::new(
294 CapabilityCoverageScenario::ExternalSuccess,
295 external_id.clone(),
296 external_version.clone(),
297 CapabilityCoverageResult::Succeeded,
298 ),
299 CapabilityCoverageCaseV1::new(
300 CapabilityCoverageScenario::PolicyRejection,
301 native_id,
302 native_version,
303 CapabilityCoverageResult::RejectedByPolicy,
304 ),
305 CapabilityCoverageCaseV1::new(
306 CapabilityCoverageScenario::ExternalTimeout,
307 external_id.clone(),
308 external_version.clone(),
309 CapabilityCoverageResult::TimedOut,
310 ),
311 CapabilityCoverageCaseV1::new(
312 CapabilityCoverageScenario::ExternalDisabled,
313 external_id,
314 external_version,
315 CapabilityCoverageResult::Disabled,
316 ),
317 ]);
318
319 assert!(report.is_complete());
320 let json = report.to_json().expect("serializable coverage evidence");
321 assert_eq!(json, report.to_json().expect("deterministic report"));
322 assert_eq!(
323 serde_json::from_str::<CapabilityCoverageReportV1>(&json)
324 .expect("machine-readable report"),
325 report
326 );
327 }
328}