Skip to main content

rsomics_common/
json.rs

1use std::io::{self, Write};
2
3use serde::Serialize;
4
5use crate::error::{Result, RsomicsError};
6use crate::exit::ExitCode;
7
8pub(crate) const SCHEMA_VERSION: &str = "1.0";
9
10#[derive(Debug, Clone, Copy)]
11pub struct ToolMeta {
12    pub name: &'static str,
13    pub version: &'static str,
14}
15
16#[derive(Serialize)]
17struct OkEnvelope<'a, T: Serialize> {
18    schema_version: &'static str,
19    tool: &'a str,
20    tool_version: &'a str,
21    status: &'static str,
22    result: &'a T,
23}
24
25#[derive(Serialize)]
26struct ErrorEnvelope<'a> {
27    schema_version: &'static str,
28    tool: &'a str,
29    tool_version: &'a str,
30    status: &'static str,
31    error: ErrorBody<'a>,
32    exit_code: u8,
33}
34
35#[derive(Serialize)]
36struct ValidationEnvelope<'a, T: Serialize> {
37    schema_version: &'static str,
38    tool: &'a str,
39    tool_version: &'a str,
40    status: &'static str,
41    error: ErrorBody<'a>,
42    exit_code: u8,
43    report: &'a T,
44}
45
46#[derive(Serialize)]
47struct ErrorBody<'a> {
48    kind: &'static str,
49    message: &'a str,
50}
51
52pub(crate) fn try_emit_ok<T: Serialize>(meta: &ToolMeta, result: &T) -> Result<()> {
53    let stdout = io::stdout();
54    write_ok_to(stdout.lock(), meta, result)
55}
56
57pub(crate) fn try_emit_error(meta: &ToolMeta, err: &RsomicsError) -> Result<()> {
58    let stderr = io::stderr();
59    write_error_to(stderr.lock(), meta, err)
60}
61
62pub(crate) fn try_emit_invalid<T: Serialize>(
63    meta: &ToolMeta,
64    message: &str,
65    report: &T,
66) -> Result<()> {
67    let stderr = io::stderr();
68    write_invalid_to(stderr.lock(), meta, message, report)
69}
70
71fn write_ok_to<W: Write, T: Serialize>(mut writer: W, meta: &ToolMeta, result: &T) -> Result<()> {
72    let env = OkEnvelope {
73        schema_version: SCHEMA_VERSION,
74        tool: meta.name,
75        tool_version: meta.version,
76        status: "ok",
77        result,
78    };
79    write_json_line(&mut writer, &env)
80}
81
82fn write_error_to<W: Write>(mut writer: W, meta: &ToolMeta, err: &RsomicsError) -> Result<()> {
83    let kind = match err {
84        RsomicsError::Io(_) => "Io",
85        RsomicsError::InvalidInput(_) => "InvalidInput",
86        RsomicsError::ConfigError(_) => "ConfigError",
87        RsomicsError::UpstreamError(_) => "UpstreamError",
88    };
89    let message = err.to_string();
90    let exit_code = ExitCode::from(err) as u8;
91    let env = ErrorEnvelope {
92        schema_version: SCHEMA_VERSION,
93        tool: meta.name,
94        tool_version: meta.version,
95        status: "error",
96        error: ErrorBody {
97            kind,
98            message: &message,
99        },
100        exit_code,
101    };
102    write_json_line(&mut writer, &env)
103}
104
105fn write_invalid_to<W: Write, T: Serialize>(
106    mut writer: W,
107    meta: &ToolMeta,
108    message: &str,
109    report: &T,
110) -> Result<()> {
111    let env = ValidationEnvelope {
112        schema_version: SCHEMA_VERSION,
113        tool: meta.name,
114        tool_version: meta.version,
115        status: "error",
116        error: ErrorBody {
117            kind: "InvalidInput",
118            message,
119        },
120        exit_code: ExitCode::InvalidInput as u8,
121        report,
122    };
123    write_json_line(&mut writer, &env)
124}
125
126fn write_json_line<W: Write, T: Serialize>(writer: &mut W, value: &T) -> Result<()> {
127    serde_json::to_writer(&mut *writer, value).map_err(json_error)?;
128    writer.write_all(b"\n").map_err(RsomicsError::Io)?;
129    writer.flush().map_err(RsomicsError::Io)
130}
131
132fn json_error(error: serde_json::Error) -> RsomicsError {
133    let kind = error.io_error_kind().unwrap_or(io::ErrorKind::InvalidData);
134    RsomicsError::Io(io::Error::new(kind, error))
135}
136
137#[cfg(test)]
138mod tests {
139    use super::*;
140    use serde::ser::Error as _;
141
142    #[derive(Serialize)]
143    struct Summary {
144        total: u64,
145        passed: u64,
146    }
147
148    struct FailingSerialize;
149
150    impl Serialize for FailingSerialize {
151        fn serialize<S>(&self, _serializer: S) -> std::result::Result<S::Ok, S::Error>
152        where
153            S: serde::Serializer,
154        {
155            Err(S::Error::custom("intentional serialization failure"))
156        }
157    }
158
159    #[derive(Default)]
160    struct ControlledWriter {
161        bytes: Vec<u8>,
162        fail_json: bool,
163        fail_newline: bool,
164        fail_flush: bool,
165    }
166
167    impl Write for ControlledWriter {
168        fn write(&mut self, buf: &[u8]) -> io::Result<usize> {
169            if self.fail_json || (self.fail_newline && buf == b"\n") {
170                return Err(io::Error::new(io::ErrorKind::BrokenPipe, "intentional"));
171            }
172            self.bytes.extend_from_slice(buf);
173            Ok(buf.len())
174        }
175
176        fn flush(&mut self) -> io::Result<()> {
177            if self.fail_flush {
178                Err(io::Error::new(io::ErrorKind::BrokenPipe, "intentional"))
179            } else {
180                Ok(())
181            }
182        }
183    }
184
185    fn meta() -> ToolMeta {
186        ToolMeta {
187            name: "rsomics-test",
188            version: "0.0.0",
189        }
190    }
191
192    #[test]
193    fn ok_envelope_serializes_with_expected_keys() {
194        let meta = ToolMeta {
195            name: "rsomics-test",
196            version: "0.0.0",
197        };
198        let s = Summary {
199            total: 100,
200            passed: 90,
201        };
202        let env = OkEnvelope {
203            schema_version: SCHEMA_VERSION,
204            tool: meta.name,
205            tool_version: meta.version,
206            status: "ok",
207            result: &s,
208        };
209        let v: serde_json::Value =
210            serde_json::from_str(&serde_json::to_string(&env).expect("ser")).expect("de");
211        assert_eq!(v["schema_version"], "1.0");
212        assert_eq!(v["tool"], "rsomics-test");
213        assert_eq!(v["status"], "ok");
214        assert_eq!(v["result"]["total"], 100);
215        assert_eq!(v["result"]["passed"], 90);
216    }
217
218    #[test]
219    fn error_envelope_has_kind_message_and_exit_code() {
220        let meta = meta();
221        let err = RsomicsError::InvalidInput("bad header".into());
222        let exit = ExitCode::from(&err) as u8;
223        let body = ErrorEnvelope {
224            schema_version: SCHEMA_VERSION,
225            tool: meta.name,
226            tool_version: meta.version,
227            status: "error",
228            error: ErrorBody {
229                kind: "InvalidInput",
230                message: "invalid input: bad header",
231            },
232            exit_code: exit,
233        };
234        let v: serde_json::Value =
235            serde_json::from_str(&serde_json::to_string(&body).expect("ser")).expect("de");
236        assert_eq!(v["status"], "error");
237        assert_eq!(v["error"]["kind"], "InvalidInput");
238        assert!(
239            v["error"]["message"]
240                .as_str()
241                .unwrap()
242                .contains("bad header")
243        );
244        assert_eq!(v["exit_code"], exit);
245    }
246
247    #[test]
248    fn validation_envelope_keeps_the_structured_report() {
249        let mut writer = Vec::new();
250        write_invalid_to(
251            &mut writer,
252            &meta(),
253            "2 records are invalid",
254            &Summary {
255                total: 10,
256                passed: 8,
257            },
258        )
259        .unwrap();
260        let value: serde_json::Value = serde_json::from_slice(&writer).unwrap();
261        assert_eq!(value["status"], "error");
262        assert_eq!(value["error"]["kind"], "InvalidInput");
263        assert_eq!(value["exit_code"], ExitCode::InvalidInput as u8);
264        assert_eq!(value["report"]["total"], 10);
265        assert_eq!(value["report"]["passed"], 8);
266    }
267
268    #[test]
269    fn successful_emission_writes_one_flushed_json_line() {
270        let mut writer = ControlledWriter::default();
271        write_ok_to(
272            &mut writer,
273            &meta(),
274            &Summary {
275                total: 1,
276                passed: 1,
277            },
278        )
279        .expect("write");
280        assert!(writer.bytes.ends_with(b"\n"));
281        let value: serde_json::Value =
282            serde_json::from_slice(&writer.bytes).expect("valid JSON line");
283        assert_eq!(value["status"], "ok");
284    }
285
286    #[test]
287    fn serialization_failure_is_propagated() {
288        let error = write_ok_to(Vec::new(), &meta(), &FailingSerialize).unwrap_err();
289        let RsomicsError::Io(ref io_error) = error else {
290            panic!("expected I/O-compatible serialization error");
291        };
292        assert_eq!(io_error.kind(), io::ErrorKind::InvalidData);
293        assert!(
294            error
295                .to_string()
296                .contains("intentional serialization failure")
297        );
298    }
299
300    #[test]
301    fn json_writer_failure_is_propagated() {
302        let writer = ControlledWriter {
303            fail_json: true,
304            ..ControlledWriter::default()
305        };
306        let error = write_ok_to(
307            writer,
308            &meta(),
309            &Summary {
310                total: 1,
311                passed: 1,
312            },
313        )
314        .unwrap_err();
315        assert!(matches!(error, RsomicsError::Io(_)));
316    }
317
318    #[test]
319    fn newline_failure_is_propagated() {
320        let writer = ControlledWriter {
321            fail_newline: true,
322            ..ControlledWriter::default()
323        };
324        let error = write_ok_to(
325            writer,
326            &meta(),
327            &Summary {
328                total: 1,
329                passed: 1,
330            },
331        )
332        .unwrap_err();
333        assert!(matches!(error, RsomicsError::Io(_)));
334    }
335
336    #[test]
337    fn flush_failure_is_propagated() {
338        let writer = ControlledWriter {
339            fail_flush: true,
340            ..ControlledWriter::default()
341        };
342        let error = write_ok_to(
343            writer,
344            &meta(),
345            &Summary {
346                total: 1,
347                passed: 1,
348            },
349        )
350        .unwrap_err();
351        assert!(matches!(error, RsomicsError::Io(_)));
352    }
353
354    #[test]
355    fn error_emission_propagates_writer_failure() {
356        let writer = ControlledWriter {
357            fail_json: true,
358            ..ControlledWriter::default()
359        };
360        let error =
361            write_error_to(writer, &meta(), &RsomicsError::InvalidInput("bad".into())).unwrap_err();
362        assert!(matches!(error, RsomicsError::Io(_)));
363    }
364}