Skip to main content

ctl_core/
model.rs

1//! Schema-first wire types. Views render these; they do not own data.
2
3/// Bump when the envelope shape changes.
4pub const SCHEMA_VERSION: u32 = 1;
5
6/// Hosts `JsonSchema`. schemars expands `concat!`; this module is the allow.
7mod data {
8    #![expect(
9        clippy::disallowed_macros,
10        reason = "schemars' JsonSchema derive expands concat!"
11    )]
12
13    /// Machine envelope. Pretty views ignore this and render `data` / `error`.
14    #[derive(Clone, Debug, Eq, PartialEq)]
15    #[cfg_attr(feature = "json", derive(serde::Deserialize, serde::Serialize))]
16    #[cfg_attr(feature = "json", serde(tag = "status", rename_all = "snake_case"))]
17    #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
18    pub enum Envelope<T> {
19        /// Successful payload.
20        Ok {
21            /// Envelope schema version.
22            schema_version: u32,
23            /// Command result.
24            data: T,
25        },
26        /// Failed payload.
27        Err {
28            /// Envelope schema version.
29            schema_version: u32,
30            /// Error body.
31            error: ErrorBody,
32        },
33    }
34
35    /// Human and machine error payload.
36    #[derive(Clone, Debug, Eq, PartialEq)]
37    #[cfg_attr(feature = "json", derive(serde::Deserialize, serde::Serialize))]
38    #[cfg_attr(feature = "schema", derive(schemars::JsonSchema))]
39    pub struct ErrorBody {
40        /// Binary name (`qctl`, `verctl`, …).
41        pub bin: String,
42        /// Display message. Use `{error:#}` when the source is `anyhow`.
43        pub message: String,
44    }
45}
46
47pub use data::{Envelope, ErrorBody};
48
49impl<T> Envelope<T> {
50    /// Wrap `data` in a current-version success envelope.
51    #[must_use]
52    pub fn ok(data: T) -> Self {
53        Self::Ok {
54            schema_version: SCHEMA_VERSION,
55            data,
56        }
57    }
58
59    /// Wrap `error` in a current-version failure envelope.
60    #[must_use]
61    pub fn err(error: ErrorBody) -> Self {
62        Self::Err {
63            schema_version: SCHEMA_VERSION,
64            error,
65        }
66    }
67}
68
69impl ErrorBody {
70    /// Build an error from a binary name and message.
71    #[must_use]
72    pub fn new(bin: impl Into<String>, message: impl Into<String>) -> Self {
73        Self {
74            bin: bin.into(),
75            message: message.into(),
76        }
77    }
78}
79
80#[cfg(all(test, feature = "json"))]
81mod tests {
82    use indoc::indoc;
83
84    use super::{Envelope, ErrorBody, SCHEMA_VERSION};
85
86    #[test]
87    fn ok_envelope_roundtrip() {
88        let env = Envelope::ok("demo");
89        let json = serde_json::to_string(&env).unwrap();
90        assert!(json.contains("\"schema_version\":1"));
91        let back: Envelope<String> = serde_json::from_str(&json).unwrap();
92        assert_eq!(back, Envelope::ok("demo".to_owned()));
93        assert_eq!(SCHEMA_VERSION, 1);
94    }
95
96    #[test]
97    fn err_envelope_shape() {
98        let env = Envelope::<()>::err(ErrorBody::new("toy", "missing token"));
99        let json = serde_json::to_string_pretty(&env).unwrap();
100        let expected = indoc! {r#"
101            {
102              "status": "err",
103              "schema_version": 1,
104              "error": {
105                "bin": "toy",
106                "message": "missing token"
107              }
108            }
109        "#};
110        let value: serde_json::Value = serde_json::from_str(&json).unwrap();
111        let expect: serde_json::Value = serde_json::from_str(expected).unwrap();
112        assert_eq!(value, expect);
113    }
114}