Skip to main content

cg_common/
output.rs

1//! Output writers for JSON and CSV formats
2
3use crate::error::Result;
4use serde::Serialize;
5use std::fs;
6use std::io::{self, Write};
7use std::path::{Path, PathBuf};
8use tracing::debug;
9
10/// JSON output writer
11#[derive(Debug, Clone)]
12pub struct JsonWriter {
13    output_path: Option<PathBuf>,
14    pretty: bool,
15}
16
17impl JsonWriter {
18    pub fn new(output_path: Option<PathBuf>, pretty: bool) -> Self {
19        Self {
20            output_path,
21            pretty,
22        }
23    }
24
25    /// Create a writer that outputs to stdout
26    pub fn stdout(pretty: bool) -> Self {
27        Self {
28            output_path: None,
29            pretty,
30        }
31    }
32
33    /// Create a writer that outputs to a file
34    pub fn file(path: impl Into<PathBuf>, pretty: bool) -> Self {
35        Self {
36            output_path: Some(path.into()),
37            pretty,
38        }
39    }
40
41    pub fn output_path(&self) -> Option<&Path> {
42        self.output_path.as_deref()
43    }
44
45    pub fn is_pretty(&self) -> bool {
46        self.pretty
47    }
48
49    /// Write data to output (file or stdout)
50    pub fn write<T: Serialize>(&self, data: &T) -> Result<()> {
51        let json = if self.pretty {
52            serde_json::to_string_pretty(data)?
53        } else {
54            serde_json::to_string(data)?
55        };
56
57        match &self.output_path {
58            Some(path) => {
59                // Ensure parent directory exists
60                if let Some(parent) = path.parent() {
61                    if !parent.exists() {
62                        fs::create_dir_all(parent)?;
63                    }
64                }
65                fs::write(path, &json)?;
66                debug!("Wrote JSON to: {:?}", path);
67            }
68            None => {
69                writeln!(io::stdout(), "{}", json)?;
70            }
71        }
72        Ok(())
73    }
74
75    /// Convert data to JSON string
76    pub fn to_string<T: Serialize>(&self, data: &T) -> Result<String> {
77        let json = if self.pretty {
78            serde_json::to_string_pretty(data)?
79        } else {
80            serde_json::to_string(data)?
81        };
82        Ok(json)
83    }
84}
85
86impl Default for JsonWriter {
87    fn default() -> Self {
88        Self::stdout(true)
89    }
90}
91
92/// CSV output writer (feature-gated)
93#[cfg(feature = "csv")]
94#[derive(Debug, Clone)]
95pub struct CsvWriter {
96    output_path: Option<PathBuf>,
97}
98
99#[cfg(feature = "csv")]
100impl CsvWriter {
101    pub fn new(output_path: Option<PathBuf>) -> Self {
102        Self { output_path }
103    }
104
105    pub fn stdout() -> Self {
106        Self { output_path: None }
107    }
108
109    pub fn file(path: impl Into<PathBuf>) -> Self {
110        Self {
111            output_path: Some(path.into()),
112        }
113    }
114
115    pub fn write<'a, I, R>(&self, headers: &[&str], records: I) -> Result<()>
116    where
117        I: IntoIterator<Item = R>,
118        R: AsRef<[&'a str]>,
119    {
120        let mut output = headers.join(",");
121        output.push('\n');
122        for record in records {
123            let fields: Vec<String> = record
124                .as_ref()
125                .iter()
126                .map(|f| escape_csv_field(f))
127                .collect();
128            output.push_str(&fields.join(","));
129            output.push('\n');
130        }
131
132        match &self.output_path {
133            Some(path) => {
134                if let Some(parent) = path.parent() {
135                    if !parent.exists() {
136                        fs::create_dir_all(parent)?;
137                    }
138                }
139                fs::write(path, &output)?;
140                debug!("Wrote CSV to: {:?}", path);
141            }
142            None => {
143                write!(io::stdout(), "{}", output)?;
144            }
145        }
146        Ok(())
147    }
148}
149
150#[cfg(feature = "csv")]
151impl Default for CsvWriter {
152    fn default() -> Self {
153        Self::stdout()
154    }
155}
156
157#[cfg(feature = "csv")]
158fn escape_csv_field(field: &str) -> String {
159    if field.contains(',') || field.contains('"') || field.contains('\n') {
160        format!("\"{}\"", field.replace('"', "\"\""))
161    } else {
162        field.to_string()
163    }
164}
165
166#[cfg(test)]
167mod tests {
168    use super::*;
169    use serde::{Deserialize, Serialize};
170    use tempfile::tempdir;
171
172    #[derive(Serialize, Deserialize, Debug, PartialEq)]
173    struct TestData {
174        name: String,
175        value: i32,
176    }
177
178    #[test]
179    fn test_json_writer_default() {
180        let writer = JsonWriter::default();
181        assert!(writer.output_path().is_none());
182        assert!(writer.is_pretty());
183    }
184
185    #[test]
186    fn test_json_writer_file() {
187        let dir = tempdir().unwrap();
188        let path = dir.path().join("test.json");
189        let writer = JsonWriter::file(&path, false);
190        let data = TestData {
191            name: "test".to_string(),
192            value: 42,
193        };
194        writer.write(&data).unwrap();
195        assert!(path.exists());
196        let content = fs::read_to_string(&path).unwrap();
197        let read_data: TestData = serde_json::from_str(&content).unwrap();
198        assert_eq!(read_data, data);
199    }
200
201    #[test]
202    fn test_json_writer_to_string() {
203        let writer = JsonWriter::stdout(false);
204        let data = TestData {
205            name: "test".to_string(),
206            value: 42,
207        };
208        let json = writer.to_string(&data).unwrap();
209        assert!(json.contains("\"name\":\"test\""));
210    }
211
212    #[test]
213    fn test_json_writer_creates_parent_dirs() {
214        let dir = tempdir().unwrap();
215        let nested = dir.path().join("a").join("b").join("test.json");
216        let writer = JsonWriter::file(&nested, false);
217        let data = TestData {
218            name: "test".to_string(),
219            value: 42,
220        };
221        writer.write(&data).unwrap();
222        assert!(nested.exists());
223    }
224}