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}