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: 0,
176 }
177 }
178
179 fn external_invocation(timeout_ms: u64) -> CapabilityInvocation {
180 CapabilityInvocation {
181 task_id: "capability-coverage/external-success".into(),
182 capability_id: "capability://example/word-count-optimizer".into(),
183 capability_version: "1.0.0".into(),
184 input: CapabilityInput::ShellCommand {
185 command: "hello world foo".into(),
186 workdir: None,
187 },
188 policy_constraints: PolicyConstraints::default(),
189 timeout_ms,
190 }
191 }
192
193 fn write_manifest(temp: &tempfile::TempDir) -> std::path::PathBuf {
194 let path = temp.path().join("word-count-manifest.json");
195 fs::write(&path, EXTERNAL_MANIFEST).expect("external manifest fixture");
196 path
197 }
198
199 fn stdin_consuming_adapter(temp: &tempfile::TempDir) -> ExternalProcessAdapter {
200 ExternalProcessAdapter::discover(
203 write_manifest(temp),
204 "/bin/sh",
205 [
206 OsString::from("-c"),
207 OsString::from(
208 "while IFS= read -r _; do :; done; \
209 printf '%s' '{\"word_count\":3,\"char_count\":15,\"line_count\":1}'",
210 ),
211 ],
212 )
213 .expect("bounded external adapter")
214 }
215
216 #[test]
217 fn coverage_matrix_proves_registry_native_and_external_safety_behavior() {
218 let temp = tempfile::tempdir().expect("fixture directory");
219 fs::write(
220 temp.path().join("fixture.rs"),
221 "// fixture documentation\nfn main() { let value = 1; }\n",
222 )
223 .expect("native fixture");
224
225 let native = Arc::new(NativeContextAdapter::with_root(temp.path()));
226 let external = Arc::new(stdin_consuming_adapter(&temp));
227 let registry = AdapterRegistry::new();
228 registry
229 .register_arc(native.clone())
230 .expect("native registration");
231 registry
232 .register_arc(external.clone())
233 .expect("external registration");
234
235 let native_id = native.manifest().capability_id.as_str().to_owned();
236 let native_version = native.manifest().version.clone();
237 let external_id = external.manifest().capability_id.as_str().to_owned();
238 let external_version = external.manifest().version.clone();
239 let registered_native = registry
240 .lookup(&native_id, &native_version)
241 .expect("registered native adapter");
242 let registered_external = registry
243 .lookup(&external_id, &external_version)
244 .expect("registered external adapter");
245
246 assert!(registered_native.invoke(native_invocation(&native)).is_ok());
247 assert!(
250 registered_external
251 .invoke(external_invocation(30_000))
252 .is_ok()
253 );
254
255 let mut rejected_invocation = native_invocation(&native);
256 rejected_invocation.policy_constraints.allowed_paths = vec!["other.rs".into()];
257 let rejected = registered_native
258 .invoke(rejected_invocation)
259 .expect_err("native adapter must enforce path policy");
260 assert!(rejected.to_string().contains("outside policy allowlist"));
261
262 let timeout_adapter = ExternalProcessAdapter::discover(
263 write_manifest(&temp),
264 "/bin/sleep",
265 [OsString::from("1")],
266 )
267 .expect("bounded timeout adapter");
268 let timed_out = timeout_adapter
269 .invoke(external_invocation(1))
270 .expect_err("external process must honor timeout");
271 assert!(timed_out.to_string().contains("exceeded its timeout"));
272
273 external.disable();
274 let disabled = registered_external
275 .invoke(external_invocation(1_000))
276 .expect_err("disabled adapter must remain unavailable through registry");
277 assert!(disabled.to_string().contains("is disabled"));
278 assert_eq!(registry.len(), 2, "disable must not unregister the adapter");
279 assert_eq!(
280 registry
281 .health_check_all()
282 .expect("registry health")
283 .into_iter()
284 .filter(|health| !health.healthy)
285 .count(),
286 1,
287 "only the disabled external adapter is unhealthy"
288 );
289
290 let report = CapabilityCoverageReportV1::new(vec![
291 CapabilityCoverageCaseV1::new(
292 CapabilityCoverageScenario::NativeSuccess,
293 native_id.clone(),
294 native_version.clone(),
295 CapabilityCoverageResult::Succeeded,
296 ),
297 CapabilityCoverageCaseV1::new(
298 CapabilityCoverageScenario::ExternalSuccess,
299 external_id.clone(),
300 external_version.clone(),
301 CapabilityCoverageResult::Succeeded,
302 ),
303 CapabilityCoverageCaseV1::new(
304 CapabilityCoverageScenario::PolicyRejection,
305 native_id,
306 native_version,
307 CapabilityCoverageResult::RejectedByPolicy,
308 ),
309 CapabilityCoverageCaseV1::new(
310 CapabilityCoverageScenario::ExternalTimeout,
311 external_id.clone(),
312 external_version.clone(),
313 CapabilityCoverageResult::TimedOut,
314 ),
315 CapabilityCoverageCaseV1::new(
316 CapabilityCoverageScenario::ExternalDisabled,
317 external_id,
318 external_version,
319 CapabilityCoverageResult::Disabled,
320 ),
321 ]);
322
323 assert!(report.is_complete());
324 let json = report.to_json().expect("serializable coverage evidence");
325 assert_eq!(json, report.to_json().expect("deterministic report"));
326 assert_eq!(
327 serde_json::from_str::<CapabilityCoverageReportV1>(&json)
328 .expect("machine-readable report"),
329 report
330 );
331 }
332}