Skip to main content

stygian_graph/adapters/
output_format.rs

1//! Output format helpers — CSV, JSONL, JSON.
2//!
3//! Implements [`OutputFormatter`](crate::ports::storage::OutputFormatter) for the three formats defined in
4//! [`crate::ports::storage::OutputFormat`].
5
6use crate::domain::error::{Result, ServiceError, StygianError};
7use crate::ports::storage::{OutputFormat, OutputFormatter, StorageRecord};
8
9// ─────────────────────────────────────────────────────────────────────────────
10// JsonlFormatter
11// ─────────────────────────────────────────────────────────────────────────────
12
13/// Serialises records as newline-delimited JSON (one JSON object per line).
14///
15/// # Example
16///
17/// ```
18/// use stygian_graph::adapters::output_format::JsonlFormatter;
19/// use stygian_graph::ports::storage::{OutputFormatter, StorageRecord};
20/// use serde_json::json;
21///
22/// let formatter = JsonlFormatter;
23/// let records = vec![StorageRecord::new("p", "n", json!({"x": 1}))];
24/// let bytes = formatter.format(&records).unwrap();
25/// let text = String::from_utf8(bytes).unwrap();
26/// assert!(text.contains("\"x\":1") || text.contains("\"x\": 1"));
27/// assert!(text.ends_with('\n'));
28/// ```
29pub struct JsonlFormatter;
30
31impl OutputFormatter for JsonlFormatter {
32    fn format(&self, records: &[StorageRecord]) -> Result<Vec<u8>> {
33        let mut out = Vec::new();
34        for record in records {
35            let line = serde_json::to_string(record).map_err(|e| {
36                StygianError::Service(ServiceError::InvalidResponse(format!(
37                    "JSONL serialisation error: {e}"
38                )))
39            })?;
40            out.extend_from_slice(line.as_bytes());
41            out.push(b'\n');
42        }
43        Ok(out)
44    }
45
46    fn format_type(&self) -> OutputFormat {
47        OutputFormat::Jsonl
48    }
49}
50
51// ─────────────────────────────────────────────────────────────────────────────
52// JsonFormatter
53// ─────────────────────────────────────────────────────────────────────────────
54
55/// Serialises records as a pretty-printed JSON array.
56///
57/// # Example
58///
59/// ```
60/// use stygian_graph::adapters::output_format::JsonFormatter;
61/// use stygian_graph::ports::storage::{OutputFormatter, StorageRecord};
62/// use serde_json::json;
63///
64/// let formatter = JsonFormatter;
65/// let records = vec![StorageRecord::new("p", "n", json!({}))];
66/// let bytes = formatter.format(&records).unwrap();
67/// let text = String::from_utf8(bytes).unwrap();
68/// assert!(text.starts_with('['));
69/// assert!(text.ends_with("]\n"));
70/// ```
71pub struct JsonFormatter;
72
73impl OutputFormatter for JsonFormatter {
74    fn format(&self, records: &[StorageRecord]) -> Result<Vec<u8>> {
75        let mut out = serde_json::to_vec_pretty(records).map_err(|e| {
76            StygianError::Service(ServiceError::InvalidResponse(format!(
77                "JSON serialisation error: {e}"
78            )))
79        })?;
80        out.push(b'\n');
81        Ok(out)
82    }
83
84    fn format_type(&self) -> OutputFormat {
85        OutputFormat::Json
86    }
87}
88
89// ─────────────────────────────────────────────────────────────────────────────
90// CsvFormatter
91// ─────────────────────────────────────────────────────────────────────────────
92
93/// Serialises records as CSV.
94///
95/// Columns: `id`, `pipeline_id`, `node_name`, `timestamp_ms`, `data`.
96/// The `data` field is embedded as a compact JSON string (escaped per RFC 4180).
97///
98/// # Example
99///
100/// ```
101/// use stygian_graph::adapters::output_format::CsvFormatter;
102/// use stygian_graph::ports::storage::{OutputFormatter, StorageRecord};
103/// use serde_json::json;
104///
105/// let formatter = CsvFormatter;
106/// let records = vec![StorageRecord::new("p", "n", json!({"k": "v"}))];
107/// let bytes = formatter.format(&records).unwrap();
108/// let text = String::from_utf8(bytes).unwrap();
109/// assert!(text.starts_with("id,pipeline_id,node_name,timestamp_ms,data\n"));
110/// ```
111pub struct CsvFormatter;
112
113impl OutputFormatter for CsvFormatter {
114    fn format(&self, records: &[StorageRecord]) -> Result<Vec<u8>> {
115        let mut wtr = csv::WriterBuilder::new()
116            .has_headers(true)
117            .from_writer(Vec::new());
118
119        // Write header row
120        wtr.write_record(["id", "pipeline_id", "node_name", "timestamp_ms", "data"])
121            .map_err(|e| {
122                StygianError::Service(ServiceError::InvalidResponse(format!(
123                    "CSV header error: {e}"
124                )))
125            })?;
126
127        for record in records {
128            let data_str = serde_json::to_string(&record.data).map_err(|e| {
129                StygianError::Service(ServiceError::InvalidResponse(format!(
130                    "CSV data serialisation error: {e}"
131                )))
132            })?;
133            wtr.write_record([
134                &record.id,
135                &record.pipeline_id,
136                &record.node_name,
137                &record.timestamp_ms.to_string(),
138                &data_str,
139            ])
140            .map_err(|e| {
141                StygianError::Service(ServiceError::InvalidResponse(format!(
142                    "CSV write error: {e}"
143                )))
144            })?;
145        }
146
147        let bytes = wtr.into_inner().map_err(|e| {
148            StygianError::Service(ServiceError::InvalidResponse(format!(
149                "CSV finalisation error: {e}"
150            )))
151        })?;
152
153        Ok(bytes)
154    }
155
156    fn format_type(&self) -> OutputFormat {
157        OutputFormat::Csv
158    }
159}
160
161// ─────────────────────────────────────────────────────────────────────────────
162// Convenience constructor
163// ─────────────────────────────────────────────────────────────────────────────
164
165/// Return the appropriate [`OutputFormatter`] boxed for the given format.
166///
167/// # Example
168///
169/// ```
170/// use stygian_graph::adapters::output_format::{formatter_for, CsvFormatter};
171/// use stygian_graph::ports::storage::OutputFormat;
172///
173/// let f = formatter_for(OutputFormat::Csv);
174/// assert_eq!(f.format_type(), OutputFormat::Csv);
175/// ```
176#[must_use]
177pub fn formatter_for(format: OutputFormat) -> Box<dyn OutputFormatter> {
178    match format {
179        OutputFormat::Jsonl => Box::new(JsonlFormatter),
180        OutputFormat::Json => Box::new(JsonFormatter),
181        OutputFormat::Csv => Box::new(CsvFormatter),
182    }
183}
184
185// ─────────────────────────────────────────────────────────────────────────────
186// Tests
187// ─────────────────────────────────────────────────────────────────────────────
188
189#[cfg(test)]
190#[allow(clippy::unwrap_used, clippy::expect_used, clippy::indexing_slicing)]
191mod tests {
192    use super::*;
193    use serde_json::json;
194
195    #[test]
196    fn jsonl_produces_one_line_per_record() {
197        let records = vec![
198            StorageRecord::new("p", "n", json!({"a": 1})),
199            StorageRecord::new("p", "n", json!({"b": 2})),
200        ];
201        let bytes = JsonlFormatter.format(&records).unwrap();
202        let text = String::from_utf8(bytes).unwrap();
203        let lines: Vec<&str> = text.trim_end_matches('\n').split('\n').collect();
204        assert_eq!(lines.len(), 2);
205        for line in lines {
206            let _: StorageRecord = serde_json::from_str(line).expect("valid JSONL");
207        }
208    }
209
210    #[test]
211    fn json_produces_array() {
212        let records = vec![StorageRecord::new("p", "n", json!({"x": 42}))];
213        let bytes = JsonFormatter.format(&records).unwrap();
214        let text = String::from_utf8(bytes).unwrap();
215        assert!(text.starts_with('['), "should start with [");
216        let _: Vec<StorageRecord> = serde_json::from_str(text.trim()).expect("valid JSON array");
217    }
218
219    #[test]
220    fn csv_has_header_and_row() {
221        let records = vec![StorageRecord::new("pipe-1", "node-a", json!({"k": "v"}))];
222        let bytes = CsvFormatter.format(&records).unwrap();
223        let text = String::from_utf8(bytes).unwrap();
224        let mut lines = text.lines();
225        let header = lines.next().unwrap();
226        assert_eq!(header, "id,pipeline_id,node_name,timestamp_ms,data");
227        let data_line = lines.next().unwrap();
228        assert!(data_line.contains("pipe-1"));
229        assert!(data_line.contains("node-a"));
230    }
231
232    #[test]
233    fn csv_empty_records_only_header() {
234        let bytes = CsvFormatter.format(&[]).unwrap();
235        let text = String::from_utf8(bytes).unwrap();
236        let lines: Vec<&str> = text.lines().collect();
237        assert_eq!(lines.len(), 1);
238        assert_eq!(lines[0], "id,pipeline_id,node_name,timestamp_ms,data");
239    }
240
241    #[test]
242    fn formatter_for_selects_correct_type() {
243        assert_eq!(
244            formatter_for(OutputFormat::Jsonl).format_type(),
245            OutputFormat::Jsonl
246        );
247        assert_eq!(
248            formatter_for(OutputFormat::Json).format_type(),
249            OutputFormat::Json
250        );
251        assert_eq!(
252            formatter_for(OutputFormat::Csv).format_type(),
253            OutputFormat::Csv
254        );
255    }
256}