Skip to main content

mabi_cli/
runner_contract.rs

1//! Machine-readable CLI contracts for local Forge and Trials runners.
2
3use std::io;
4
5use chrono::{SecondsFormat, Utc};
6use serde::Serialize;
7
8use mabi_runtime::{
9    ProtocolCatalogEntry, RUNTIME_CONTRACT_VERSION, RUN_EVIDENCE_SCHEMA_VERSION,
10    SNAPSHOT_METADATA_VERSION, TRIAL_ARTIFACT_CONTRACT_VERSION,
11};
12
13use crate::output::{OutputFormat, OutputWriter};
14
15pub const LOCAL_RUNNER_CONTRACT_VERSION: &str = "local-runner-contract-v1";
16pub const CLI_OUTPUT_ENVELOPE_VERSION: &str = "cli-output-envelope-v1";
17pub const UNIFIED_READINESS_CONTRACT_VERSION: &str = "unified-readiness-contract-v1";
18pub const VERSION_METADATA_CONTRACT_VERSION: &str = "version-metadata-contract-v1";
19pub const COMPATIBILITY_MATRIX_VERSION: &str = "compatibility-matrix-v1";
20pub const PROTOCOL_READINESS_MATRIX_VERSION: &str = "protocol-readiness-matrix-v1";
21pub const RELEASE_CHANNEL: &str = "source-build";
22pub const COMPATIBLE_TRIAL_SUITE_RANGE: &str = ">=0.1.0 <1.0.0";
23pub const COMPATIBILITY_DECISION_OWNER: &str = "mabinogion-trials-or-forge";
24pub const COMPATIBILITY_MATRIX_DOCUMENT: &str = "docs/release/compatibility-matrix.yaml";
25pub const RELEASE_POLICY_DOCUMENT: &str = "docs/release/release-checklist.md";
26pub const CHANGELOG_POLICY_DOCUMENT: &str = "docs/release/changelog-policy.md";
27
28#[derive(Debug, Clone, Serialize)]
29pub struct CliOutputEnvelope<'a, T: Serialize> {
30    pub contract_version: &'static str,
31    pub envelope_version: &'static str,
32    pub command: &'a str,
33    pub status: &'static str,
34    pub exit_code: i32,
35    pub exit_category: CliExitCategory,
36    pub generated_at: String,
37    pub engine_version: &'static str,
38    pub data: &'a T,
39    pub warnings: Vec<String>,
40    pub errors: Vec<CliErrorPayload>,
41}
42
43impl<'a, T: Serialize> CliOutputEnvelope<'a, T> {
44    pub fn success(command: &'a str, data: &'a T) -> Self {
45        Self::new(command, 0, data, Vec::new(), Vec::new())
46    }
47
48    pub fn failure(
49        command: &'a str,
50        exit_code: i32,
51        data: &'a T,
52        errors: Vec<CliErrorPayload>,
53    ) -> Self {
54        Self::new(command, exit_code, data, Vec::new(), errors)
55    }
56
57    pub fn new(
58        command: &'a str,
59        exit_code: i32,
60        data: &'a T,
61        warnings: Vec<String>,
62        errors: Vec<CliErrorPayload>,
63    ) -> Self {
64        Self {
65            contract_version: LOCAL_RUNNER_CONTRACT_VERSION,
66            envelope_version: CLI_OUTPUT_ENVELOPE_VERSION,
67            command,
68            status: if exit_code == 0 { "success" } else { "failure" },
69            exit_code,
70            exit_category: CliExitCategory::from_exit_code(exit_code),
71            generated_at: Utc::now().to_rfc3339_opts(SecondsFormat::Secs, true),
72            engine_version: mabi_core::RELEASE_VERSION,
73            data,
74            warnings,
75            errors,
76        }
77    }
78}
79
80#[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
81#[serde(rename_all = "snake_case")]
82pub enum CliExitCategory {
83    Success,
84    InputContractError,
85    ValidationFailure,
86    RuntimeFailure,
87    Timeout,
88    Interrupted,
89    InternalFailure,
90}
91
92impl CliExitCategory {
93    pub fn from_exit_code(exit_code: i32) -> Self {
94        match exit_code {
95            0 => Self::Success,
96            2 | 3 | 4 | 5 | 7 | 8 => Self::InputContractError,
97            6 => Self::ValidationFailure,
98            9 => Self::RuntimeFailure,
99            124 => Self::Timeout,
100            130 => Self::Interrupted,
101            _ => Self::InternalFailure,
102        }
103    }
104}
105
106#[derive(Debug, Clone, Serialize)]
107pub struct CliErrorPayload {
108    pub category: CliExitCategory,
109    pub code: String,
110    pub message: String,
111    #[serde(skip_serializing_if = "Option::is_none")]
112    pub path: Option<String>,
113}
114
115impl CliErrorPayload {
116    pub fn new(exit_code: i32, code: impl Into<String>, message: impl Into<String>) -> Self {
117        Self {
118            category: CliExitCategory::from_exit_code(exit_code),
119            code: code.into(),
120            message: message.into(),
121            path: None,
122        }
123    }
124
125    pub fn with_path(mut self, path: impl Into<String>) -> Self {
126        self.path = Some(path.into());
127        self
128    }
129}
130
131#[derive(Debug, Clone, Serialize)]
132pub struct VersionReport {
133    pub engine_version: &'static str,
134    pub rust_version: &'static str,
135    pub registered_protocols: Vec<VersionProtocol>,
136    pub contract_versions: VersionContracts,
137    pub feature_flags: Vec<String>,
138    pub release_metadata: ReleaseMetadata,
139    pub trial_compatible_metadata: TrialCompatibleMetadata,
140}
141
142#[derive(Debug, Clone, Serialize)]
143pub struct VersionProtocol {
144    pub key: &'static str,
145    pub protocol_key: &'static str,
146    pub display_name: &'static str,
147    pub default_port: u16,
148    pub features: Vec<&'static str>,
149    #[serde(flatten)]
150    pub capability: ProtocolCapabilityVersion,
151}
152
153#[derive(Debug, Clone, Serialize)]
154pub struct ProtocolCapabilityVersion {
155    pub capability_version: &'static str,
156    pub readiness_matrix_version: &'static str,
157    pub readiness_contract_version: &'static str,
158    pub trial_profile_ids: Vec<&'static str>,
159    pub breaking_change_scope: Vec<&'static str>,
160}
161
162#[derive(Debug, Clone, Serialize)]
163pub struct VersionContracts {
164    pub local_runner_contract: &'static str,
165    pub cli_output_envelope: &'static str,
166    pub runtime_contract: &'static str,
167    pub snapshot_metadata_contract: &'static str,
168    pub unified_readiness_contract: &'static str,
169    pub run_evidence_schema: &'static str,
170    pub trial_artifact_contract: &'static str,
171    pub version_metadata_contract: &'static str,
172}
173
174#[derive(Debug, Clone, Serialize)]
175pub struct ReleaseMetadata {
176    pub engine_version: &'static str,
177    pub workspace_rust_version: &'static str,
178    pub release_channel: &'static str,
179    pub compatibility_matrix_version: &'static str,
180    pub compatibility_matrix_document: &'static str,
181    pub release_policy_document: &'static str,
182    pub changelog_policy_document: &'static str,
183}
184
185#[derive(Debug, Clone, Serialize)]
186pub struct TrialCompatibleMetadata {
187    pub trial_definition_owner: &'static str,
188    pub scoring_owner: &'static str,
189    pub supported_machine_formats: Vec<&'static str>,
190    pub runner_facing_commands: Vec<&'static str>,
191    pub future_trial_run_contract_documented: bool,
192    #[serde(flatten)]
193    pub runner_compatibility: RunnerCompatibilityPolicy,
194}
195
196#[derive(Debug, Clone, Serialize)]
197pub struct RunnerCompatibilityPolicy {
198    pub compatible_trial_suite_range: &'static str,
199    pub compatibility_decision_owner: &'static str,
200    pub runner_policy_document: &'static str,
201}
202
203pub fn is_machine_format(format: OutputFormat) -> bool {
204    matches!(
205        format,
206        OutputFormat::Json | OutputFormat::Yaml | OutputFormat::Compact
207    )
208}
209
210pub fn write_success<T: Serialize>(
211    output: &OutputWriter,
212    command: &str,
213    data: &T,
214) -> io::Result<()> {
215    output.write(&CliOutputEnvelope::success(command, data))
216}
217
218pub fn write_failure<T: Serialize>(
219    output: &OutputWriter,
220    command: &str,
221    exit_code: i32,
222    data: &T,
223    errors: Vec<CliErrorPayload>,
224) -> io::Result<()> {
225    output.write(&CliOutputEnvelope::failure(
226        command, exit_code, data, errors,
227    ))
228}
229
230pub fn version_report(
231    rust_version: &'static str,
232    protocols: &[ProtocolCatalogEntry],
233) -> VersionReport {
234    VersionReport {
235        engine_version: mabi_core::RELEASE_VERSION,
236        rust_version,
237        registered_protocols: protocols
238            .iter()
239            .map(|entry| VersionProtocol {
240                key: entry.descriptor.key,
241                protocol_key: entry.descriptor.key,
242                display_name: entry.descriptor.display_name,
243                default_port: entry.descriptor.default_port,
244                features: entry.features.clone(),
245                capability: protocol_capability_version(entry.descriptor.key),
246            })
247            .collect(),
248        contract_versions: VersionContracts {
249            local_runner_contract: LOCAL_RUNNER_CONTRACT_VERSION,
250            cli_output_envelope: CLI_OUTPUT_ENVELOPE_VERSION,
251            runtime_contract: RUNTIME_CONTRACT_VERSION,
252            snapshot_metadata_contract: SNAPSHOT_METADATA_VERSION,
253            unified_readiness_contract: UNIFIED_READINESS_CONTRACT_VERSION,
254            run_evidence_schema: RUN_EVIDENCE_SCHEMA_VERSION,
255            trial_artifact_contract: TRIAL_ARTIFACT_CONTRACT_VERSION,
256            version_metadata_contract: VERSION_METADATA_CONTRACT_VERSION,
257        },
258        feature_flags: feature_flags(),
259        release_metadata: ReleaseMetadata {
260            engine_version: mabi_core::RELEASE_VERSION,
261            workspace_rust_version: rust_version,
262            release_channel: RELEASE_CHANNEL,
263            compatibility_matrix_version: COMPATIBILITY_MATRIX_VERSION,
264            compatibility_matrix_document: COMPATIBILITY_MATRIX_DOCUMENT,
265            release_policy_document: RELEASE_POLICY_DOCUMENT,
266            changelog_policy_document: CHANGELOG_POLICY_DOCUMENT,
267        },
268        trial_compatible_metadata: TrialCompatibleMetadata {
269            trial_definition_owner: "mabinogion-trials",
270            scoring_owner: "mabinogion-trials",
271            supported_machine_formats: vec!["json", "yaml", "compact"],
272            runner_facing_commands: vec!["doctor", "inspect", "validate", "version"],
273            future_trial_run_contract_documented: true,
274            runner_compatibility: RunnerCompatibilityPolicy {
275                compatible_trial_suite_range: COMPATIBLE_TRIAL_SUITE_RANGE,
276                compatibility_decision_owner: COMPATIBILITY_DECISION_OWNER,
277                runner_policy_document: RELEASE_POLICY_DOCUMENT,
278            },
279        },
280    }
281}
282
283pub fn protocol_capability_version(protocol_key: &str) -> ProtocolCapabilityVersion {
284    match protocol_key {
285        "modbus" => ProtocolCapabilityVersion {
286            capability_version: "modbus-capabilities-v1",
287            readiness_matrix_version: PROTOCOL_READINESS_MATRIX_VERSION,
288            readiness_contract_version: UNIFIED_READINESS_CONTRACT_VERSION,
289            trial_profile_ids: vec![
290                "modbus.l1.function_code",
291                "modbus.l1.register_map",
292                "modbus.l1.exception_response",
293                "modbus.l2.multi_unit",
294                "modbus.l2.timeout",
295                "modbus.l2.partial_response",
296                "modbus.l2.slow_device",
297            ],
298            breaking_change_scope: version_breaking_change_scope(),
299        },
300        "opcua" => ProtocolCapabilityVersion {
301            capability_version: "opcua-capabilities-v1",
302            readiness_matrix_version: PROTOCOL_READINESS_MATRIX_VERSION,
303            readiness_contract_version: UNIFIED_READINESS_CONTRACT_VERSION,
304            trial_profile_ids: vec![
305                "opcua.l1.session_lifecycle",
306                "opcua.l2.secure_channel_renewal",
307                "opcua.l3.reconnect",
308                "opcua.l2.subscription",
309                "opcua.l3.timeout",
310                "opcua.l3.malformed_service_response",
311                "opcua.l2.operation_limit",
312            ],
313            breaking_change_scope: version_breaking_change_scope(),
314        },
315        "bacnet" => ProtocolCapabilityVersion {
316            capability_version: "bacnet-capabilities-v1",
317            readiness_matrix_version: PROTOCOL_READINESS_MATRIX_VERSION,
318            readiness_contract_version: UNIFIED_READINESS_CONTRACT_VERSION,
319            trial_profile_ids: vec![
320                "bacnet.l1.discovery",
321                "bacnet.l1.property_io",
322                "bacnet.l2.cov",
323                "bacnet.l3.segmentation",
324                "bacnet.l3.bbmd_fdr",
325                "bacnet.l3.duplicate_handling",
326            ],
327            breaking_change_scope: version_breaking_change_scope(),
328        },
329        "knx" => ProtocolCapabilityVersion {
330            capability_version: "knx-capabilities-v1",
331            readiness_matrix_version: PROTOCOL_READINESS_MATRIX_VERSION,
332            readiness_contract_version: UNIFIED_READINESS_CONTRACT_VERSION,
333            trial_profile_ids: vec![
334                "knx.l1.discovery",
335                "knx.l1.tunneling_lifecycle",
336                "knx.l1.group_value_io",
337                "knx.l2.dpt_codec",
338                "knx.l2.sequence_validation",
339                "knx.l2.heartbeat_connection_state",
340            ],
341            breaking_change_scope: version_breaking_change_scope(),
342        },
343        _ => ProtocolCapabilityVersion {
344            capability_version: "unknown-capabilities-v1",
345            readiness_matrix_version: PROTOCOL_READINESS_MATRIX_VERSION,
346            readiness_contract_version: UNIFIED_READINESS_CONTRACT_VERSION,
347            trial_profile_ids: Vec::new(),
348            breaking_change_scope: version_breaking_change_scope(),
349        },
350    }
351}
352
353fn version_breaking_change_scope() -> Vec<&'static str> {
354    vec![
355        "config",
356        "runtime_contract",
357        "readiness_contract",
358        "run_evidence_schema",
359        "version_metadata_contract",
360    ]
361}
362
363fn feature_flags() -> Vec<String> {
364    let mut flags = Vec::new();
365    if cfg!(feature = "opcua-https") {
366        flags.push("opcua-https".to_string());
367    } else {
368        flags.push("opcua-https-disabled".to_string());
369    }
370    flags
371}