1#[derive(Debug, Clone, PartialEq, Eq, Default)]
12pub struct Payload {
13 pub encoding: String,
16 pub type_hint: Option<String>,
19 pub data: Vec<u8>,
20}
21
22#[derive(Debug, Clone, PartialEq, Eq)]
24pub enum Rendered {
25 Null,
27 Text(String),
29 Opaque { bytes: usize, encoding: String },
32 Encrypted { bytes: usize, encoding: String },
37}
38
39impl Payload {
40 pub fn new(encoding: impl Into<String>, data: Vec<u8>) -> Self {
41 Self {
42 encoding: encoding.into(),
43 type_hint: None,
44 data: data.to_vec(),
45 }
46 }
47
48 const NOT_CODEC: [&'static str; 6] = [
51 "json/plain",
52 "json/protobuf",
53 "text/plain",
54 "binary/null",
55 "binary/plain",
58 "binary/protobuf",
59 ];
60
61 pub fn needs_codec(&self) -> bool {
75 !self.encoding.is_empty() && !Self::NOT_CODEC.contains(&self.encoding.as_str())
76 }
77
78 pub fn render(&self) -> Rendered {
84 match self.encoding.as_str() {
85 "binary/null" => Rendered::Null,
86 "json/plain" | "json/protobuf" => match std::str::from_utf8(&self.data) {
88 Ok(text) => Rendered::Text(pretty_json(text)),
89 Err(_) => self.opaque(),
92 },
93 "text/plain" => match std::str::from_utf8(&self.data) {
94 Ok(text) => Rendered::Text(text.to_string()),
95 Err(_) => self.opaque(),
96 },
97 _ if self.needs_codec() => Rendered::Encrypted {
98 bytes: self.data.len(),
99 encoding: self.encoding.clone(),
100 },
101 _ => self.opaque(),
102 }
103 }
104
105 fn opaque(&self) -> Rendered {
106 Rendered::Opaque {
107 bytes: self.data.len(),
108 encoding: if self.encoding.is_empty() {
109 "unknown".to_string()
110 } else {
111 self.encoding.clone()
112 },
113 }
114 }
115
116 pub fn summary(&self, width: usize) -> String {
118 match self.render() {
119 Rendered::Null => "null".into(),
120 Rendered::Encrypted { bytes, encoding } => {
121 format!("🔒 {encoding}, {bytes} bytes")
122 }
123 Rendered::Opaque { bytes, encoding } => format!("{encoding}, {bytes} bytes"),
124 Rendered::Text(t) => {
125 let flat = t.split_whitespace().collect::<Vec<_>>().join(" ");
128 if flat.chars().count() <= width {
129 flat
130 } else {
131 let keep: String = flat.chars().take(width.saturating_sub(1)).collect();
132 format!("{keep}…")
133 }
134 }
135 }
136 }
137
138 pub fn pipeable(&self) -> Option<&[u8]> {
143 match self.encoding.as_str() {
144 "json/plain" | "json/protobuf" | "text/plain" => Some(&self.data),
145 _ => None,
146 }
147 }
148}
149
150pub fn payloads_as_json(payloads: &[(String, Payload)]) -> (Option<String>, Vec<String>) {
165 let mut obj = serde_json::Map::new();
166 let mut skipped = Vec::new();
167
168 for (label, p) in payloads {
169 match p.pipeable() {
170 None => skipped.push(label.clone()),
171 Some(bytes) => {
172 let Ok(text) = std::str::from_utf8(bytes) else {
173 skipped.push(label.clone());
174 continue;
175 };
176 let value = match p.encoding.as_str() {
177 "json/plain" | "json/protobuf" => serde_json::from_str(text)
178 .unwrap_or_else(|_| serde_json::Value::String(text.to_string())),
179 _ => serde_json::Value::String(text.to_string()),
180 };
181 obj.insert(label.clone(), value);
182 }
183 }
184 }
185
186 let json = if obj.is_empty() {
187 None
188 } else {
189 serde_json::to_string_pretty(&serde_json::Value::Object(obj)).ok()
190 };
191 (json, skipped)
192}
193
194pub fn unwrap_single(json: &str) -> Option<String> {
200 let value: serde_json::Value = serde_json::from_str(json).ok()?;
201 let obj = value.as_object()?;
202 if obj.len() != 1 {
203 return None;
204 }
205 match obj.values().next()? {
206 serde_json::Value::String(s) => Some(s.clone()),
207 other => serde_json::to_string_pretty(other).ok(),
208 }
209}
210
211pub fn pretty_json(text: &str) -> String {
217 match serde_json::from_str::<serde_json::Value>(text) {
218 Ok(v) => serde_json::to_string_pretty(&v).unwrap_or_else(|_| text.to_string()),
219 Err(_) => text.to_string(),
220 }
221}
222
223#[cfg(test)]
224mod tests {
225 use super::*;
226
227 fn json(body: &str) -> Payload {
228 Payload::new("json/plain", body.as_bytes().to_vec())
229 }
230
231 #[test]
232 fn a_json_payload_is_pretty_printed() {
233 let p = json(r#"{"amount":100,"currency":"GBP"}"#);
234 let Rendered::Text(t) = p.render() else {
235 panic!("expected text, got {:?}", p.render())
236 };
237 assert!(t.contains("\n"), "should be pretty-printed:\n{t}");
238 assert!(t.contains("\"amount\": 100"), "got:\n{t}");
239 }
240
241 #[test]
242 fn a_scalar_json_payload_survives_intact() {
243 assert_eq!(json("100").render(), Rendered::Text("100".into()));
245 assert_eq!(
246 json("\"Sleep\"").render(),
247 Rendered::Text("\"Sleep\"".into())
248 );
249 }
250
251 #[test]
252 fn json_that_does_not_parse_is_shown_raw_rather_than_rejected() {
253 let p = json("{not json");
256 assert_eq!(p.render(), Rendered::Text("{not json".into()));
257 }
258
259 #[test]
260 fn a_null_payload_is_not_an_empty_string() {
261 let p = Payload::new("binary/null", Vec::new());
262 assert_eq!(p.render(), Rendered::Null);
263 assert_eq!(p.summary(40), "null");
264 assert_eq!(json("\"\"").render(), Rendered::Text("\"\"".into()));
266 }
267
268 #[test]
269 fn encrypted_payloads_announce_themselves_rather_than_showing_ciphertext() {
270 let p = Payload::new("binary/encrypted", vec![0u8; 64]);
271 assert!(p.needs_codec());
272 assert_eq!(
273 p.render(),
274 Rendered::Encrypted {
275 bytes: 64,
276 encoding: "binary/encrypted".into()
277 }
278 );
279 assert!(p.summary(40).contains("encrypted"));
280 assert_eq!(p.pipeable(), None, "ciphertext is not worth piping to jq");
281 }
282
283 #[test]
284 fn unknown_and_binary_encodings_stay_opaque() {
285 for enc in ["binary/plain", "binary/protobuf", ""] {
289 let p = Payload::new(enc, vec![0xff, 0xfe, 0x00, 0x01]);
290 match p.render() {
291 Rendered::Opaque { bytes, .. } => assert_eq!(bytes, 4),
292 other => panic!("{enc} should be opaque, got {other:?}"),
293 }
294 assert_eq!(p.pipeable(), None);
295 assert!(!p.needs_codec(), "{enc} must not be sent to a codec");
296 }
297 for enc in ["binary/deflate", "application/x-thrift"] {
300 let p = Payload::new(enc, vec![0xff, 0xfe]);
301 assert!(p.needs_codec(), "{enc} should be offered to a codec");
302 assert_eq!(p.pipeable(), None);
303 }
304 assert!(
305 Payload::new("", vec![1]).summary(40).contains("unknown"),
306 "a missing encoding should say so"
307 );
308 }
309
310 #[test]
311 fn json_that_is_not_valid_utf8_is_not_trusted() {
312 let p = Payload::new("json/plain", vec![0xff, 0xff]);
315 assert!(matches!(p.render(), Rendered::Opaque { .. }));
316 }
317
318 #[test]
319 fn a_summary_is_one_line_and_fits() {
320 let p = json(r#"{"a":1,"b":2,"c":"a rather long string value here"}"#);
321 let s = p.summary(30);
322 assert!(!s.contains('\n'), "a summary must be one line: {s:?}");
323 assert!(
324 s.chars().count() <= 30,
325 "{} chars: {s:?}",
326 s.chars().count()
327 );
328 assert!(s.ends_with('…'));
329
330 assert_eq!(json("42").summary(30), "42");
332 }
333
334 #[test]
335 fn payloads_pipe_as_one_object_keyed_by_label() {
336 let payloads = vec![
339 ("input".to_string(), json(r#"{"amount":100}"#)),
340 ("result".to_string(), json(r#""charged""#)),
341 ];
342 let (out, skipped) = payloads_as_json(&payloads);
343 let out = out.expect("something to pipe");
344 assert!(skipped.is_empty());
345
346 let v: serde_json::Value = serde_json::from_str(&out).unwrap();
347 assert_eq!(v["input"]["amount"], 100);
349 assert_eq!(v["result"], "charged");
350 }
351
352 #[test]
353 fn indexed_arguments_keep_their_labels() {
354 let payloads = vec![
355 ("input[0]".to_string(), json("1")),
356 ("input[1]".to_string(), json(r#""two""#)),
357 ];
358 let (out, _) = payloads_as_json(&payloads);
359 let v: serde_json::Value = serde_json::from_str(&out.unwrap()).unwrap();
360 assert_eq!(v["input[0]"], 1);
361 assert_eq!(v["input[1]"], "two");
362 }
363
364 #[test]
365 fn unpipeable_payloads_are_reported_rather_than_breaking_the_object() {
366 let payloads = vec![
367 ("input".to_string(), json("42")),
368 (
369 "result".to_string(),
370 Payload::new("binary/encrypted", vec![0u8; 8]),
371 ),
372 ];
373 let (out, skipped) = payloads_as_json(&payloads);
374 let v: serde_json::Value = serde_json::from_str(&out.unwrap()).unwrap();
375 assert_eq!(v["input"], 42);
376 assert!(v.get("result").is_none(), "ciphertext must not be embedded");
377 assert_eq!(skipped, ["result"], "and the reader is told which");
378 }
379
380 #[test]
381 fn a_row_with_nothing_pipeable_yields_no_json() {
382 let payloads = vec![(
383 "input".to_string(),
384 Payload::new("binary/encrypted", vec![0u8; 8]),
385 )];
386 let (out, skipped) = payloads_as_json(&payloads);
387 assert_eq!(out, None, "an empty object is not worth piping");
388 assert_eq!(skipped, ["input"]);
389 assert_eq!(payloads_as_json(&[]), (None, Vec::new()));
390 }
391
392 #[test]
393 fn a_broken_json_payload_is_embedded_as_a_string_rather_than_lost() {
394 let payloads = vec![
396 ("input".to_string(), json("{not json")),
397 ("result".to_string(), json("1")),
398 ];
399 let (out, skipped) = payloads_as_json(&payloads);
400 let v: serde_json::Value = serde_json::from_str(&out.unwrap()).unwrap();
401 assert_eq!(v["input"], "{not json");
402 assert_eq!(v["result"], 1);
403 assert!(skipped.is_empty());
404 }
405
406 #[test]
407 fn only_textual_payloads_are_pipeable() {
408 assert_eq!(json("{}").pipeable(), Some(&b"{}"[..]));
409 assert_eq!(
410 Payload::new("text/plain", b"hello".to_vec()).pipeable(),
411 Some(&b"hello"[..])
412 );
413 assert_eq!(Payload::new("binary/null", vec![]).pipeable(), None);
414 }
415
416 #[test]
417 fn a_single_payload_is_unwrapped_for_pasting() {
418 let (json, _) = payloads_as_json(&[(
420 "result".into(),
421 Payload::new("json/plain", br#"{"total":42}"#.to_vec()),
422 )]);
423 let out = unwrap_single(&json.unwrap()).unwrap();
424 assert!(out.contains(r#""total": 42"#), "{out}");
425 assert!(
426 !out.contains("result"),
427 "the wrapper key should be gone: {out}"
428 );
429 }
430
431 #[test]
432 fn a_single_string_payload_is_not_requoted() {
433 let (json, _) = payloads_as_json(&[(
434 "result".into(),
435 Payload::new("text/plain", b"already text".to_vec()),
436 )]);
437 assert_eq!(unwrap_single(&json.unwrap()).unwrap(), "already text");
438 }
439
440 #[test]
441 fn several_payloads_keep_their_keys() {
442 let (json, _) = payloads_as_json(&[
444 ("input[0]".into(), Payload::new("json/plain", b"1".to_vec())),
445 ("input[1]".into(), Payload::new("json/plain", b"2".to_vec())),
446 ]);
447 assert_eq!(unwrap_single(&json.unwrap()), None, "must not unwrap");
448 }
449
450 #[test]
451 fn a_codec_may_name_its_own_encoding() {
452 let p = Payload::new("binary/aes_comp", vec![0; 10232]);
456 assert!(
457 p.needs_codec(),
458 "a custom codec encoding still needs a codec"
459 );
460 assert_eq!(
461 p.render(),
462 Rendered::Encrypted {
463 bytes: 10232,
464 encoding: "binary/aes_comp".into()
465 },
466 "and the badge names the encoding rather than guessing at `encrypted`"
467 );
468 }
469
470 #[test]
471 fn readable_encodings_never_ask_for_a_codec() {
472 for e in ["json/plain", "json/protobuf", "text/plain", "binary/null"] {
473 assert!(
474 !Payload::new(e, b"{}".to_vec()).needs_codec(),
475 "{e} is readable as it stands"
476 );
477 }
478 }
479
480 #[test]
481 fn a_malformed_payload_is_not_sent_to_a_codec() {
482 assert!(!Payload::new("", vec![1, 2, 3]).needs_codec());
485 }
486}