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, and [`SavedOutput`] names the file a
5//! command wrote it to instead. [`Paginated`] carries a listing beside
6//! the cursor of its next page, which the `--page-token` argument of
7//! [`PageTokenArg`] takes back.
8//!
9//! [`PageTokenArg`]: crate::clap::args::PageTokenArg
10
11use std::{borrow::Cow, fmt};
12
13use base64_codec::codec::Base64Codec;
14use schemars::{JsonSchema, Schema, SchemaGenerator, json_schema};
15use serde::{Serialize, Serializer, ser::SerializeStruct};
16
17/// Binary content, printed as `{data, size, mime}` with `data` in
18/// base64, encoded on the CPU token the binary installed (simd-cpu).
19#[derive(Clone, Debug, Eq, PartialEq)]
20pub struct Bytes {
21    /// The content.
22    pub data: Vec<u8>,
23    /// The media type of the content, `application/octet-stream` when
24    /// unknown.
25    pub mime: String,
26}
27
28impl fmt::Display for Bytes {
29    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
30        writeln!(f, "{} bytes of {}", self.data.len(), self.mime)
31    }
32}
33
34impl Serialize for Bytes {
35    fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
36        let mut data = Vec::new();
37        Base64Codec::default().encode_vec(&self.data, &mut data);
38        // NOTE: the base64 alphabet is ASCII, so this never falls back.
39        let data = String::from_utf8(data).unwrap_or_default();
40
41        let mut bytes = serializer.serialize_struct("Bytes", 3)?;
42        bytes.serialize_field("data", &data)?;
43        bytes.serialize_field("size", &self.data.len())?;
44        bytes.serialize_field("mime", &self.mime)?;
45        bytes.end()
46    }
47}
48
49impl JsonSchema for Bytes {
50    fn schema_name() -> Cow<'static, str> {
51        "Bytes".into()
52    }
53
54    fn json_schema(_: &mut SchemaGenerator) -> Schema {
55        json_schema!({
56            "description": "Binary content.",
57            "type": "object",
58            "properties": {
59                "data": {
60                    "description": "The content, in base64.",
61                    "type": "string",
62                    "contentEncoding": "base64",
63                },
64                "size": {
65                    "description": "The size of the content, in bytes.",
66                    "type": "integer",
67                    "format": "uint64",
68                    "minimum": 0,
69                },
70                "mime": {
71                    "description": "The media type of the content.",
72                    "type": "string",
73                },
74            },
75            "required": ["data", "size", "mime"],
76        })
77    }
78}
79
80/// What a command saving bytes to a file prints.
81#[derive(Clone, Debug, Serialize, JsonSchema)]
82#[serde(rename_all = "camelCase")]
83pub struct SavedOutput {
84    /// The file written.
85    pub path: String,
86    /// How many bytes were written.
87    pub size: usize,
88}
89
90impl fmt::Display for SavedOutput {
91    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
92        writeln!(f, "Saved {} bytes to {}", self.size, self.path)
93    }
94}
95
96/// A listing plus the cursor of its next page.
97///
98/// The cursor is part of the output, a footer line in text and a
99/// `nextPage` field in JSON, `null` once the listing is complete, so a
100/// script reads it and gives it back through `--page-token`.
101#[derive(Serialize, JsonSchema)]
102#[serde(rename_all = "camelCase")]
103pub struct Paginated<T> {
104    /// The listing.
105    #[serde(flatten)]
106    pub inner: T,
107    /// The cursor of the next page, `null` once the listing is complete.
108    pub next_page: Option<String>,
109}
110
111impl<T: fmt::Display> fmt::Display for Paginated<T> {
112    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
113        let inner = self.inner.to_string();
114        f.write_str(&inner)?;
115
116        if let Some(cursor) = &self.next_page {
117            if !inner.is_empty() {
118                writeln!(f)?;
119            }
120
121            writeln!(f, "Next page: {cursor}")?;
122        }
123
124        Ok(())
125    }
126}
127
128#[cfg(test)]
129mod tests {
130    use serde_json::json;
131
132    use crate::{clap::commands::json_schema::output_schema, output::*};
133
134    #[derive(Serialize, JsonSchema)]
135    struct Rows {
136        rows: Vec<u8>,
137    }
138
139    #[test]
140    fn bytes_print_as_base64_with_their_size() {
141        let bytes = Bytes {
142            data: b"\xff\x00a".to_vec(),
143            mime: "application/octet-stream".into(),
144        };
145
146        assert_eq!(
147            serde_json::to_value(bytes).unwrap(),
148            json!({ "data": "/wBh", "size": 3, "mime": "application/octet-stream" })
149        );
150    }
151
152    #[test]
153    fn a_saved_file_prints_its_path_and_size() {
154        let saved = SavedOutput {
155            path: String::from("/tmp/a.pdf"),
156            size: 42,
157        };
158
159        assert_eq!(saved.to_string(), "Saved 42 bytes to /tmp/a.pdf\n");
160        assert_eq!(
161            serde_json::to_string(&saved).unwrap(),
162            r#"{"path":"/tmp/a.pdf","size":42}"#
163        );
164    }
165
166    #[test]
167    fn a_complete_listing_prints_a_null_cursor() {
168        let page = Paginated {
169            inner: Rows { rows: vec![1] },
170            next_page: None,
171        };
172
173        assert_eq!(
174            serde_json::to_value(page).unwrap(),
175            json!({ "rows": [1], "nextPage": null })
176        );
177    }
178
179    #[test]
180    fn the_text_cursor_follows_a_blank_line() {
181        let page = Paginated {
182            inner: "42\n",
183            next_page: Some(String::from("2")),
184        };
185        assert_eq!(page.to_string(), "42\n\nNext page: 2\n");
186
187        let page = Paginated {
188            inner: "",
189            next_page: Some(String::from("2")),
190        };
191        assert_eq!(page.to_string(), "Next page: 2\n");
192    }
193
194    #[test]
195    fn the_cursor_is_required_in_the_schema() {
196        let schema = output_schema::<Paginated<Rows>>();
197        let required = schema["required"].as_array().unwrap();
198
199        assert!(required.contains(&json!("nextPage")), "{schema}");
200        assert!(required.contains(&json!("rows")), "{schema}");
201    }
202}