Skip to main content

vct_core/utils/
file.rs

1use crate::constants::buffer;
2use anyhow::{Context, Result};
3use serde_json::Value;
4use std::fs::File;
5use std::io::{BufRead, BufReader, Read, Write};
6use std::path::Path;
7
8/// Reads a JSONL file and returns one [`Value`] per non-empty line.
9///
10/// Blank and whitespace-only lines are skipped. The result `Vec` is
11/// pre-sized from the file length (via [`buffer::AVG_JSONL_LINE_SIZE`]) and
12/// shrunk to fit afterwards to avoid both repeated reallocation and retained
13/// slack.
14///
15/// # Errors
16///
17/// Returns an error if the file cannot be opened, if reading any line fails
18/// (e.g. invalid UTF-8 or an I/O error), or if any non-empty line is not
19/// valid JSON. The error context names the offending line number.
20pub fn read_jsonl<P: AsRef<Path>>(path: P) -> Result<Vec<Value>> {
21    let file = File::open(path.as_ref())
22        .with_context(|| format!("Failed to open file: {}", path.as_ref().display()))?;
23
24    // Pre-allocate Vec capacity based on estimated line count
25    // This reduces allocations and improves performance significantly
26    let file_size = file.metadata().ok().map(|m| m.len() as usize).unwrap_or(0);
27    let estimated_lines = if file_size > 0 {
28        // Use centralized constant for average line size estimation
29        file_size / buffer::AVG_JSONL_LINE_SIZE
30    } else {
31        10 // Default minimum capacity
32    };
33    let mut results = Vec::with_capacity(estimated_lines);
34
35    // Use centralized buffer size constant for optimal I/O performance
36    let reader = BufReader::with_capacity(buffer::FILE_READ_BUFFER, file);
37
38    for (index, line) in reader.lines().enumerate() {
39        let line = line.with_context(|| format!("Failed to read line {}", index + 1))?;
40
41        if line.trim().is_empty() {
42            continue;
43        }
44
45        let obj: Value = serde_json::from_str(&line)
46            .with_context(|| format!("Failed to parse JSON at line {}", index + 1))?;
47
48        results.push(obj);
49    }
50
51    // Shrink capacity to actual size to free excess memory
52    results.shrink_to_fit();
53
54    Ok(results)
55}
56
57/// Serializes `value` as compact JSON and writes it to `path` atomically.
58///
59/// Writes to a temporary file in the same directory, fsyncs it, then renames
60/// it over `path`, so a concurrent reader never observes a partially written
61/// file. Used by the quota-cache writers (the background quota workers each
62/// persist their last-known-good snapshot).
63///
64/// # Errors
65///
66/// Returns an error if the parent directory cannot be created, the temp file
67/// cannot be written, or the final rename fails.
68pub fn write_json_atomic<T, P>(path: P, value: &T) -> Result<()>
69where
70    T: serde::Serialize,
71    P: AsRef<Path>,
72{
73    write_json_atomic_inner(path.as_ref(), value, false)
74}
75
76/// Like [`write_json_atomic`] but pretty-prints the JSON.
77///
78/// Used for credential write-back so a refreshed token file keeps the
79/// human-readable 2-space layout the provider CLIs write, rather than being
80/// collapsed onto one line.
81///
82/// # Errors
83///
84/// Returns an error if the parent directory cannot be created, the temp file
85/// cannot be written, or the final rename fails.
86pub fn write_json_atomic_pretty<T, P>(path: P, value: &T) -> Result<()>
87where
88    T: serde::Serialize,
89    P: AsRef<Path>,
90{
91    write_json_atomic_inner(path.as_ref(), value, true)
92}
93
94/// Shared atomic-write body: temp file in the same dir, fsync, rename.
95fn write_json_atomic_inner<T>(path: &Path, value: &T, pretty: bool) -> Result<()>
96where
97    T: serde::Serialize,
98{
99    persist_atomic(path, |tmp| {
100        if pretty {
101            serde_json::to_writer_pretty(tmp, value).context("Failed to serialize JSON")
102        } else {
103            serde_json::to_writer(tmp, value).context("Failed to serialize JSON")
104        }
105    })
106}
107
108/// Writes `contents` to `path` atomically (temp file in the same dir, fsync,
109/// rename), so a concurrent reader never observes a partial file.
110///
111/// The string counterpart of [`write_json_atomic`], used to persist non-JSON
112/// text such as the `~/.vct/config.toml` settings file.
113///
114/// # Errors
115///
116/// Returns an error if the parent directory cannot be created, the temp file
117/// cannot be written, or the final rename fails.
118pub fn write_string_atomic<P: AsRef<Path>>(path: P, contents: &str) -> Result<()> {
119    persist_atomic(path.as_ref(), |tmp| {
120        tmp.write_all(contents.as_bytes())
121            .context("Failed to write file contents")
122    })
123}
124
125/// Runs `write` against a temp file in `path`'s directory, fsyncs it, then
126/// atomically renames it over `path`.
127fn persist_atomic(
128    path: &Path,
129    write: impl FnOnce(&mut tempfile::NamedTempFile) -> Result<()>,
130) -> Result<()> {
131    let dir = path.parent().unwrap_or_else(|| Path::new("."));
132    std::fs::create_dir_all(dir)
133        .with_context(|| format!("Failed to create directory: {}", dir.display()))?;
134    let mut tmp = tempfile::NamedTempFile::new_in(dir)
135        .with_context(|| format!("Failed to create temp file in: {}", dir.display()))?;
136    write(&mut tmp)?;
137    tmp.as_file().sync_all().ok();
138    tmp.persist(path)
139        .with_context(|| format!("Failed to persist file: {}", path.display()))?;
140    Ok(())
141}
142
143/// Reads a single JSON document and returns it wrapped in a one-element `Vec`.
144///
145/// The wrapping keeps the return type identical to [`read_jsonl`] so callers
146/// can treat both file shapes uniformly. Whatever top-level JSON the file
147/// contains (object, array, scalar) becomes the sole element.
148///
149/// # Errors
150///
151/// Returns an error if the file cannot be opened, if it cannot be read to a
152/// string, or if its contents are not valid JSON.
153pub fn read_json<P: AsRef<Path>>(path: P) -> Result<Vec<Value>> {
154    let file = File::open(path.as_ref())
155        .with_context(|| format!("Failed to open file: {}", path.as_ref().display()))?;
156
157    // Pre-allocate String capacity based on file size to reduce allocations
158    let file_size = file.metadata().ok().map(|m| m.len() as usize).unwrap_or(0);
159    let mut contents = String::with_capacity(file_size);
160
161    // Use centralized buffer size constant for optimal I/O performance
162    let mut reader = BufReader::with_capacity(buffer::FILE_READ_BUFFER, file);
163    reader
164        .read_to_string(&mut contents)
165        .with_context(|| format!("Failed to read file: {}", path.as_ref().display()))?;
166
167    let obj: Value = serde_json::from_str(&contents).with_context(|| {
168        format!(
169            "Failed to parse JSON from file: {}",
170            path.as_ref().display()
171        )
172    })?;
173
174    Ok(vec![obj])
175}
176
177/// Counts the lines in `text`.
178///
179/// A line is a `\n`-terminated run; a trailing partial line (text not ending
180/// in `\n`) counts as one more. The empty string is zero lines. Newline
181/// counting uses the SIMD-accelerated `bytecount` crate rather than iterating
182/// chars.
183///
184/// # Examples
185///
186/// ```
187/// use vct_core::utils::count_lines;
188///
189/// assert_eq!(count_lines(""), 0);
190/// assert_eq!(count_lines("one line"), 1);
191/// assert_eq!(count_lines("a\nb\nc"), 3);
192/// assert_eq!(count_lines("a\nb\n"), 2);
193/// ```
194pub fn count_lines(text: &str) -> usize {
195    if text.is_empty() {
196        return 0;
197    }
198    // Use bytecount for much faster line counting (SIMD-accelerated)
199    // Count newlines and add 1 if text doesn't end with newline
200    let newline_count = bytecount::count(text.as_bytes(), b'\n');
201    if text.ends_with('\n') {
202        newline_count
203    } else {
204        newline_count + 1
205    }
206}
207
208/// Serializes `value` as pretty-printed JSON and writes it to `path`.
209///
210/// Any existing file at `path` is overwritten.
211///
212/// # Errors
213///
214/// Returns an error if `value` cannot be serialized to JSON or if the file
215/// cannot be written.
216pub fn save_json_pretty<P: AsRef<Path>>(path: P, value: &Value) -> Result<()> {
217    let json_str = serde_json::to_string_pretty(value).context("Failed to serialize JSON")?;
218
219    std::fs::write(path.as_ref(), json_str)
220        .with_context(|| format!("Failed to write file: {}", path.as_ref().display()))?;
221
222    Ok(())
223}
224
225#[cfg(test)]
226mod tests {
227    use super::*;
228    use serde_json::json;
229    use std::fs::File;
230    use std::io::Write;
231    use tempfile::tempdir;
232
233    #[test]
234    fn test_count_lines_empty() {
235        // Test counting lines in empty string
236        assert_eq!(count_lines(""), 0);
237    }
238
239    #[test]
240    fn test_count_lines_single_line_no_newline() {
241        // Test single line without trailing newline
242        assert_eq!(count_lines("hello"), 1);
243    }
244
245    #[test]
246    fn test_count_lines_single_line_with_newline() {
247        // Test single line with trailing newline
248        assert_eq!(count_lines("hello\n"), 1);
249    }
250
251    #[test]
252    fn test_count_lines_multiple_lines() {
253        // Test multiple lines without trailing newline
254        assert_eq!(count_lines("line1\nline2\nline3"), 3);
255    }
256
257    #[test]
258    fn test_count_lines_multiple_lines_with_newline() {
259        // Test multiple lines with trailing newline
260        assert_eq!(count_lines("line1\nline2\nline3\n"), 3);
261    }
262
263    #[test]
264    fn test_count_lines_empty_lines() {
265        // Test with empty lines in between
266        assert_eq!(count_lines("line1\n\nline3"), 3);
267        assert_eq!(count_lines("\n\n\n"), 3);
268    }
269
270    #[test]
271    fn test_read_jsonl_valid() {
272        // Test reading valid JSONL file
273        let dir = tempdir().unwrap();
274        let file_path = dir.path().join("test.jsonl");
275
276        let mut file = File::create(&file_path).unwrap();
277        writeln!(file, r#"{{"key1": "value1"}}"#).unwrap();
278        writeln!(file, r#"{{"key2": "value2"}}"#).unwrap();
279        writeln!(file, r#"{{"key3": "value3"}}"#).unwrap();
280
281        let result = read_jsonl(&file_path).unwrap();
282        assert_eq!(result.len(), 3);
283        assert_eq!(result[0]["key1"], "value1");
284        assert_eq!(result[1]["key2"], "value2");
285        assert_eq!(result[2]["key3"], "value3");
286    }
287
288    #[test]
289    fn test_read_jsonl_with_empty_lines() {
290        // Test reading JSONL with empty lines (should skip them)
291        let dir = tempdir().unwrap();
292        let file_path = dir.path().join("test.jsonl");
293
294        let mut file = File::create(&file_path).unwrap();
295        writeln!(file, r#"{{"key1": "value1"}}"#).unwrap();
296        writeln!(file).unwrap();
297        writeln!(file, r#"{{"key2": "value2"}}"#).unwrap();
298        writeln!(file, "   ").unwrap();
299        writeln!(file, r#"{{"key3": "value3"}}"#).unwrap();
300
301        let result = read_jsonl(&file_path).unwrap();
302        assert_eq!(result.len(), 3);
303    }
304
305    #[test]
306    fn test_read_jsonl_empty_file() {
307        // Test reading empty JSONL file
308        let dir = tempdir().unwrap();
309        let file_path = dir.path().join("empty.jsonl");
310
311        File::create(&file_path).unwrap();
312
313        let result = read_jsonl(&file_path).unwrap();
314        assert_eq!(result.len(), 0);
315    }
316
317    #[test]
318    fn test_read_jsonl_invalid_json() {
319        // Test reading JSONL with invalid JSON
320        let dir = tempdir().unwrap();
321        let file_path = dir.path().join("invalid.jsonl");
322
323        let mut file = File::create(&file_path).unwrap();
324        writeln!(file, "not valid json").unwrap();
325
326        let result = read_jsonl(&file_path);
327        assert!(result.is_err());
328    }
329
330    #[test]
331    fn test_read_jsonl_nonexistent_file() {
332        // Test reading non-existent file
333        let result = read_jsonl("/nonexistent/path/file.jsonl");
334        assert!(result.is_err());
335    }
336
337    #[test]
338    fn test_read_json_valid() {
339        // Test reading valid JSON file
340        let dir = tempdir().unwrap();
341        let file_path = dir.path().join("test.json");
342
343        let mut file = File::create(&file_path).unwrap();
344        write!(file, r#"{{"key": "value", "number": 42}}"#).unwrap();
345
346        let result = read_json(&file_path).unwrap();
347        assert_eq!(result.len(), 1);
348        assert_eq!(result[0]["key"], "value");
349        assert_eq!(result[0]["number"], 42);
350    }
351
352    #[test]
353    fn test_read_json_array() {
354        // Test reading JSON array (wrapped in single-element vector)
355        let dir = tempdir().unwrap();
356        let file_path = dir.path().join("array.json");
357
358        let mut file = File::create(&file_path).unwrap();
359        write!(file, r#"[1, 2, 3, 4, 5]"#).unwrap();
360
361        let result = read_json(&file_path).unwrap();
362        assert_eq!(result.len(), 1);
363        assert!(result[0].is_array());
364        assert_eq!(result[0].as_array().unwrap().len(), 5);
365    }
366
367    #[test]
368    fn test_read_json_invalid() {
369        // Test reading invalid JSON
370        let dir = tempdir().unwrap();
371        let file_path = dir.path().join("invalid.json");
372
373        let mut file = File::create(&file_path).unwrap();
374        write!(file, "not valid json").unwrap();
375
376        let result = read_json(&file_path);
377        assert!(result.is_err());
378    }
379
380    #[test]
381    fn test_read_json_nonexistent_file() {
382        // Test reading non-existent file
383        let result = read_json("/nonexistent/path/file.json");
384        assert!(result.is_err());
385    }
386
387    #[test]
388    fn test_count_lines_unicode() {
389        // Test counting lines with unicode characters
390        assert_eq!(count_lines("こんにちは"), 1);
391        assert_eq!(count_lines("line1 😀\nline2 🎉\nline3 🚀"), 3);
392    }
393
394    #[test]
395    fn test_count_lines_windows_line_endings() {
396        // Test with Windows-style line endings (\r\n)
397        // Note: count_lines counts \n, so \r\n will work correctly
398        assert_eq!(count_lines("line1\r\nline2\r\nline3"), 3);
399    }
400
401    #[test]
402    fn test_read_jsonl_large_objects() {
403        // Test reading JSONL with larger objects
404        let dir = tempdir().unwrap();
405        let file_path = dir.path().join("large.jsonl");
406
407        let mut file = File::create(&file_path).unwrap();
408        let large_obj = json!({
409            "field1": "value1",
410            "field2": "value2",
411            "nested": {
412                "a": 1,
413                "b": 2,
414                "c": [1, 2, 3, 4, 5]
415            }
416        });
417        writeln!(file, "{}", serde_json::to_string(&large_obj).unwrap()).unwrap();
418
419        let result = read_jsonl(&file_path).unwrap();
420        assert_eq!(result.len(), 1);
421        assert_eq!(result[0]["field1"], "value1");
422        assert_eq!(result[0]["nested"]["c"].as_array().unwrap().len(), 5);
423    }
424
425    #[test]
426    fn test_read_json_nested_structure() {
427        // Test reading JSON with deeply nested structure
428        let dir = tempdir().unwrap();
429        let file_path = dir.path().join("nested.json");
430
431        let nested = json!({
432            "level1": {
433                "level2": {
434                    "level3": {
435                        "value": "deep"
436                    }
437                }
438            }
439        });
440
441        let mut file = File::create(&file_path).unwrap();
442        write!(file, "{}", serde_json::to_string(&nested).unwrap()).unwrap();
443
444        let result = read_json(&file_path).unwrap();
445        assert_eq!(result.len(), 1);
446        assert_eq!(result[0]["level1"]["level2"]["level3"]["value"], "deep");
447    }
448
449    #[test]
450    fn test_count_lines_only_newlines() {
451        // Test string with only newlines
452        assert_eq!(count_lines("\n"), 1);
453        assert_eq!(count_lines("\n\n"), 2);
454        assert_eq!(count_lines("\n\n\n"), 3);
455    }
456
457    #[test]
458    fn test_count_lines_mixed_content() {
459        // Test mixed content with spaces and newlines
460        assert_eq!(count_lines("a\n  \nc"), 3);
461        assert_eq!(count_lines("  hello  \n  world  "), 2);
462    }
463}