Skip to main content

lean_ctx/core/ocla/adapters/
coverage.rs

1//! Deterministic coverage evidence for the OSS capability adapter boundary.
2
3use serde::{Deserialize, Serialize};
4
5/// Schema version for [`CapabilityCoverageReportV1`].
6pub const CAPABILITY_COVERAGE_SCHEMA_VERSION: u32 = 1;
7
8/// Required behavior covered by the Phase-C adapter matrix.
9#[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/// Payload-free outcome observed for one capability coverage scenario.
39#[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/// One deterministic coverage assertion for a versioned capability.
49#[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    /// Construct one coverage case without retaining invocation payloads.
60    #[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    /// Whether the observed result satisfies this scenario's required behavior.
76    #[must_use]
77    pub fn passed(&self) -> bool {
78        self.scenario.expected_result() == self.observed
79    }
80}
81
82/// Machine-readable, deterministic Phase-C coverage evidence.
83#[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    /// Build a report in its caller-defined, deterministic case order.
92    #[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    /// True only when every required scenario appears once, in canonical order, and passes.
101    #[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    /// Serialize a payload-free report for reproducible evidence bundles.
113    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        // Keep the child alive until the host closes stdin; an output-only
199        // fixture can exit before `write_all` and race into EPIPE on Linux.
200        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}