1use 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}