1use std::fs::{self, File};
17use std::io::{self, BufWriter, Write};
18use std::path::Path;
19
20use serde::de::DeserializeOwned;
21use serde::Serialize;
22use serde_json::Value;
23
24use crate::error::{CliError, ErrorKind};
25
26pub const MAX_JSON_FILE_BYTES: u64 = 32 * 1024 * 1024;
28
29pub const MAX_NDJSON_LINE_BYTES: usize = 1024 * 1024;
31
32pub const MAX_CLI_JSON_PAYLOAD_BYTES: usize = 4 * 1024 * 1024;
34
35const UTF8_BOM: &[u8] = &[0xEF, 0xBB, 0xBF];
37
38#[inline]
43pub fn strip_utf8_bom(s: &str) -> &str {
44 s.strip_prefix('\u{FEFF}').unwrap_or(s)
45}
46
47#[inline]
49pub fn strip_utf8_bom_bytes(bytes: &[u8]) -> &[u8] {
50 bytes.strip_prefix(UTF8_BOM).unwrap_or(bytes)
51}
52
53pub fn from_str<T: DeserializeOwned>(s: &str) -> Result<T, serde_json::Error> {
55 serde_json::from_str(strip_utf8_bom(s.trim_start_matches('\u{FEFF}')))
56}
57
58pub fn from_slice<T: DeserializeOwned>(bytes: &[u8]) -> Result<T, serde_json::Error> {
60 serde_json::from_slice(strip_utf8_bom_bytes(bytes))
61}
62
63pub fn value_from_str(s: &str) -> Result<Value, serde_json::Error> {
65 from_str(s)
66}
67
68pub fn map_parse_err(ctx: &str, e: &serde_json::Error) -> CliError {
73 CliError::new(
74 ErrorKind::Data,
75 format!("{ctx}: invalid JSON (line {} column {}): {e}", e.line(), e.column()),
76 )
77}
78
79pub fn parse_cli_json_value(raw: &str, ctx: &str) -> Result<Value, CliError> {
81 if raw.len() > MAX_CLI_JSON_PAYLOAD_BYTES {
82 return Err(CliError::with_suggestion(
83 ErrorKind::Data,
84 format!(
85 "{ctx}: JSON payload too large ({} bytes > {MAX_CLI_JSON_PAYLOAD_BYTES})",
86 raw.len()
87 ),
88 "Pass a smaller payload or a file path when the command supports one",
89 ));
90 }
91 value_from_str(raw).map_err(|e| map_parse_err(ctx, &e))
92}
93
94pub fn read_text_file_limited(path: &Path, max_bytes: u64) -> Result<String, CliError> {
98 let meta = fs::metadata(path).map_err(|e| {
99 CliError::new(
100 ErrorKind::Io,
101 format!("stat {}: {e}", path.display()),
102 )
103 })?;
104 let len = meta.len();
105 if len > max_bytes {
106 return Err(CliError::with_suggestion(
107 ErrorKind::Data,
108 format!(
109 "file {} is too large ({} bytes > {max_bytes})",
110 path.display(),
111 len
112 ),
113 "Split the input or raise the product limit only after measuring need",
114 ));
115 }
116 let mut raw = String::new();
117 raw.try_reserve_exact(len as usize).map_err(|e| {
118 CliError::new(
119 ErrorKind::Software,
120 format!("reserve {} bytes for {}: {e}", len, path.display()),
121 )
122 })?;
123 let file = File::open(path).map_err(|e| {
124 CliError::new(
125 ErrorKind::Io,
126 format!("open {}: {e}", path.display()),
127 )
128 })?;
129 let mut reader = io::BufReader::new(file);
130 use std::io::Read;
131 reader.read_to_string(&mut raw).map_err(|e| {
132 CliError::new(
133 ErrorKind::Io,
134 format!("read {}: {e}", path.display()),
135 )
136 })?;
137 if raw.starts_with('\u{FEFF}') {
139 Ok(raw.trim_start_matches('\u{FEFF}').to_string())
140 } else {
141 Ok(raw)
142 }
143}
144
145pub fn read_json_file<T: DeserializeOwned>(path: &Path, max_bytes: u64) -> Result<T, CliError> {
147 let raw = read_text_file_limited(path, max_bytes)?;
148 from_str(&raw).map_err(|e| {
149 map_parse_err(&format!("parse {}", path.display()), &e)
150 })
151}
152
153pub fn read_json_value_file(path: &Path, max_bytes: u64) -> Result<Value, CliError> {
155 read_json_file(path, max_bytes)
156}
157
158pub fn check_ndjson_line_len(line: &str, lineno: usize) -> Result<(), CliError> {
160 if line.len() > MAX_NDJSON_LINE_BYTES {
161 return Err(CliError::with_suggestion(
162 ErrorKind::Data,
163 format!(
164 "NDJSON line {lineno}: line too large ({} bytes > {MAX_NDJSON_LINE_BYTES})",
165 line.len()
166 ),
167 "Split the record or use a whole-file JSON array for large steps",
168 ));
169 }
170 Ok(())
171}
172
173pub fn to_compact_string<T: Serialize>(value: &T) -> Result<String, CliError> {
175 serde_json::to_string(value).map_err(|e| {
176 CliError::new(ErrorKind::Software, format!("json encode: {e}"))
177 })
178}
179
180pub fn write_json_file_atomic<T: Serialize>(
185 path: &Path,
186 value: &T,
187 pretty: bool,
188) -> Result<(), CliError> {
189 if let Some(parent) = path.parent() {
190 if !parent.as_os_str().is_empty() {
191 fs::create_dir_all(parent).map_err(|e| {
192 CliError::new(
193 ErrorKind::Io,
194 format!("create parent {}: {e}", parent.display()),
195 )
196 })?;
197 }
198 }
199 let tmp = path.with_extension(format!(
200 "{}.tmp",
201 path.extension().and_then(|e| e.to_str()).unwrap_or("json")
202 ));
203 let tmp = if tmp == path {
205 path.with_extension("json.tmp")
206 } else {
207 tmp
208 };
209 {
210 let file = File::create(&tmp).map_err(|e| {
211 CliError::new(ErrorKind::Io, format!("create temp {}: {e}", tmp.display()))
212 })?;
213 let mut writer = BufWriter::new(file);
214 if pretty {
215 serde_json::to_writer_pretty(&mut writer, value).map_err(|e| {
216 CliError::new(ErrorKind::Software, format!("json encode: {e}"))
217 })?;
218 } else {
219 serde_json::to_writer(&mut writer, value).map_err(|e| {
220 CliError::new(ErrorKind::Software, format!("json encode: {e}"))
221 })?;
222 }
223 writer.write_all(b"\n").map_err(|e| {
224 CliError::new(ErrorKind::Io, format!("json trailing newline: {e}"))
225 })?;
226 writer.flush().map_err(|e| {
227 CliError::new(ErrorKind::Io, format!("json flush: {e}"))
228 })?;
229 writer
230 .into_inner()
231 .map_err(|e| CliError::new(ErrorKind::Io, format!("json into_inner: {e}")))?
232 .sync_all()
233 .map_err(|e| CliError::new(ErrorKind::Io, format!("json fsync: {e}")))?;
234 }
235 fs::rename(&tmp, path).map_err(|e| {
236 let _ = fs::remove_file(&tmp);
237 CliError::new(
238 ErrorKind::Io,
239 format!("rename {} → {}: {e}", tmp.display(), path.display()),
240 )
241 })?;
242 Ok(())
243}
244
245#[cfg(test)]
246mod tests {
247 use super::*;
248 use serde_json::json;
249 use std::io::Write;
250 use tempfile::NamedTempFile;
251
252 #[test]
253 fn strips_bom_from_str() {
254 let with_bom = "\u{FEFF}{\"ok\":true}";
255 let v: Value = from_str(with_bom).expect("bom parse");
256 assert_eq!(v, json!({"ok": true}));
257 }
258
259 #[test]
260 fn strips_bom_from_bytes() {
261 let mut bytes = UTF8_BOM.to_vec();
262 bytes.extend_from_slice(br#"{"n":1}"#);
263 let v: Value = from_slice(&bytes).expect("bom bytes");
264 assert_eq!(v["n"], 1);
265 }
266
267 #[test]
268 fn rejects_oversized_cli_payload() {
269 let huge = format!("{{\"x\":\"{}\"}}", "a".repeat(MAX_CLI_JSON_PAYLOAD_BYTES));
270 let err = parse_cli_json_value(&huge, "test").unwrap_err();
271 assert_eq!(err.kind(), ErrorKind::Data);
272 }
273
274 #[test]
275 fn read_file_limited_and_bom() {
276 let mut f = NamedTempFile::new().unwrap();
277 f.write_all(UTF8_BOM).unwrap();
278 f.write_all(br#"{"a":1}"#).unwrap();
279 f.flush().unwrap();
280 let v: Value = read_json_file(f.path(), MAX_JSON_FILE_BYTES).unwrap();
281 assert_eq!(v["a"], 1);
282 }
283
284 #[test]
285 fn ndjson_line_limit() {
286 let line = "x".repeat(MAX_NDJSON_LINE_BYTES + 1);
287 assert!(check_ndjson_line_len(&line, 1).is_err());
288 assert!(check_ndjson_line_len("{}", 1).is_ok());
289 }
290
291 #[test]
292 fn compact_roundtrip() {
293 let v = json!({"schema_version": 1, "ok": true});
294 let s = to_compact_string(&v).unwrap();
295 assert!(!s.contains('\n'));
296 assert!(!s.contains(" "));
297 let back: Value = from_str(&s).unwrap();
298 assert_eq!(back, v);
299 }
300}