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