Skip to main content

pimalaya_cli/
output.rs

1//! Output shapes shared by every binary.
2//!
3//! [`Bytes`] carries binary content under JSON, never written raw to
4//! stdout nor decoded lossily. [`Paginated`] carries a listing beside
5//! the cursor of its next page, which the `--page-token` argument of
6//! [`PageTokenArg`] takes back.
7//!
8//! [`PageTokenArg`]: crate::clap::args::PageTokenArg
9
10use std::{borrow::Cow, fmt};
11
12use base64_codec::codec::Base64Codec;
13use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
14use serde::{Serialize, Serializer, ser::SerializeStruct};
15
16/// Binary content, printed as `{data, size, mime}` with `data` in
17/// base64, encoded on the CPU token the binary installed (simd-cpu).
18#[derive(Clone, Debug, Eq, PartialEq)]
19pub struct Bytes {
20    /// The content.
21    pub data: Vec<u8>,
22    /// The media type of the content, `application/octet-stream` when
23    /// unknown.
24    pub mime: String,
25}
26
27impl fmt::Display for Bytes {
28    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
29        writeln!(f, "{} bytes of {}", self.data.len(), self.mime)
30    }
31}
32
33impl Serialize for Bytes {
34    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
35        let mut data = Vec::new();
36        Base64Codec::default().encode_vec(&self.data, &mut data);
37        // NOTE: the base64 alphabet is ASCII, so this never falls back.
38        let data = String::from_utf8(data).unwrap_or_default();
39
40        let mut bytes = serializer.serialize_struct("Bytes", 3)?;
41        bytes.serialize_field("data", &data)?;
42        bytes.serialize_field("size", &self.data.len())?;
43        bytes.serialize_field("mime", &self.mime)?;
44        bytes.end()
45    }
46}
47
48impl JsonSchema for Bytes {
49    fn schema_name() -> Cow<'static, str> {
50        "Bytes".into()
51    }
52
53    fn json_schema(_: &mut SchemaGenerator) -> Schema {
54        json_schema!({
55            "description": "Binary content.",
56            "type": "object",
57            "properties": {
58                "data": {
59                    "description": "The content, in base64.",
60                    "type": "string",
61                    "contentEncoding": "base64",
62                },
63                "size": {
64                    "description": "The size of the content, in bytes.",
65                    "type": "integer",
66                    "format": "uint64",
67                    "minimum": 0,
68                },
69                "mime": {
70                    "description": "The media type of the content.",
71                    "type": "string",
72                },
73            },
74            "required": ["data", "size", "mime"],
75        })
76    }
77}
78
79/// A listing plus the cursor of its next page.
80///
81/// The cursor is part of the output, a footer line in text and a
82/// `nextPage` field in JSON, `null` once the listing is complete, so a
83/// script reads it and gives it back through `--page-token`.
84#[derive(Serialize, JsonSchema)]
85#[serde(rename_all = "camelCase")]
86pub struct Paginated<T> {
87    /// The listing.
88    #[serde(flatten)]
89    pub inner: T,
90    /// The cursor of the next page, `null` once the listing is complete.
91    pub next_page: Option<String>,
92}
93
94impl<T: fmt::Display> fmt::Display for Paginated<T> {
95    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
96        write!(f, "{}", self.inner)?;
97
98        if let Some(cursor) = &self.next_page {
99            writeln!(f, "Next page: {cursor}")?;
100        }
101
102        Ok(())
103    }
104}
105
106#[cfg(test)]
107mod tests {
108    use serde_json::json;
109
110    use crate::{clap::commands::output_schema, output::*};
111
112    #[derive(Serialize, JsonSchema)]
113    struct Rows {
114        rows: Vec<u8>,
115    }
116
117    #[test]
118    fn bytes_print_as_base64_with_their_size() {
119        let bytes = Bytes {
120            data: b"\xff\x00a".to_vec(),
121            mime: "application/octet-stream".into(),
122        };
123
124        assert_eq!(
125            serde_json::to_value(bytes).unwrap(),
126            json!({ "data": "/wBh", "size": 3, "mime": "application/octet-stream" })
127        );
128    }
129
130    #[test]
131    fn a_complete_listing_prints_a_null_cursor() {
132        let page = Paginated {
133            inner: Rows { rows: vec![1] },
134            next_page: None,
135        };
136
137        assert_eq!(
138            serde_json::to_value(page).unwrap(),
139            json!({ "rows": [1], "nextPage": null })
140        );
141    }
142
143    #[test]
144    fn the_cursor_is_required_in_the_schema() {
145        let schema = output_schema::<Paginated<Rows>>();
146        let required = schema["required"].as_array().unwrap();
147
148        assert!(required.contains(&json!("nextPage")), "{schema}");
149        assert!(required.contains(&json!("rows")), "{schema}");
150    }
151}