agpm_cli/utils/fs/
formats.rs

1//! File format operations for reading and writing structured data files.
2//!
3//! This module provides convenience functions for working with common file formats:
4//! - Plain text files
5//! - JSON (with pretty printing option)
6//! - TOML (always pretty printed)
7//! - YAML
8//!
9//! All write operations use atomic writes via [`super::atomic::safe_write`] to ensure
10//! data integrity.
11//!
12//! # Examples
13//!
14//! ```rust,no_run
15//! use agpm_cli::utils::fs::formats::{read_json_file, write_json_file};
16//! use serde::{Deserialize, Serialize};
17//! use std::path::Path;
18//!
19//! #[derive(Serialize, Deserialize)]
20//! struct Config {
21//!     name: String,
22//!     version: String,
23//! }
24//!
25//! # fn example() -> anyhow::Result<()> {
26//! let config = Config {
27//!     name: "agpm".to_string(),
28//!     version: "1.0.0".to_string(),
29//! };
30//!
31//! // Write with pretty formatting
32//! write_json_file(Path::new("config.json"), &config, true)?;
33//!
34//! // Read back
35//! let loaded: Config = read_json_file(Path::new("config.json"))?;
36//! # Ok(())
37//! # }
38//! ```
39
40use crate::core::file_error::{FileOperation, FileResultExt};
41use anyhow::{Context, Result};
42use std::fs;
43use std::path::Path;
44use tokio_retry::Retry;
45use tokio_retry::strategy::ExponentialBackoff;
46
47/// Reads a text file with proper error handling and context.
48///
49/// # Arguments
50/// * `path` - The path to the file to read
51///
52/// # Returns
53/// The contents of the file as a String
54///
55/// # Errors
56/// Returns an error with context if the file cannot be read
57pub fn read_text_file(path: &Path) -> Result<String> {
58    Ok(fs::read_to_string(path).with_file_context(
59        FileOperation::Read,
60        path,
61        "reading text file",
62        "utils::fs::formats::read_text_file",
63    )?)
64}
65
66/// Reads a text file asynchronously with retry for filesystem coherency delays.
67///
68/// Git worktrees can have brief visibility delays after creation, especially
69/// under high parallel I/O load. This function uses `tokio-retry` with
70/// exponential backoff to handle transient `NotFound` errors.
71///
72/// # Arguments
73/// * `path` - The path to the file to read
74///
75/// # Returns
76/// The contents of the file as a String
77///
78/// # Errors
79/// Returns an error with context if the file cannot be read after all retries
80///
81/// # Retry Strategy
82/// - Initial delay: 10ms
83/// - Max delay: 200ms (capped)
84/// - Max attempts: 5
85/// - Only retries on `NotFound` errors; other errors fail immediately
86pub async fn read_text_file_with_retry(path: &Path) -> Result<String> {
87    let strategy = ExponentialBackoff::from_millis(10)
88        .max_delay(std::time::Duration::from_millis(200))
89        .take(5);
90
91    let path_buf = path.to_path_buf();
92    let path_for_error = path.to_path_buf();
93
94    Retry::spawn(strategy, || {
95        let path = path_buf.clone();
96        async move {
97            match tokio::fs::read_to_string(&path).await {
98                Ok(content) => Ok(content),
99                Err(e) if e.kind() == std::io::ErrorKind::NotFound => {
100                    tracing::debug!(
101                        target: "fs::retry",
102                        "File not found at {}, will retry",
103                        path.display()
104                    );
105                    Err(e)
106                }
107                // Don't retry other errors (permission denied, etc.)
108                Err(e) => {
109                    tracing::warn!(
110                        target: "fs::retry",
111                        "Non-retryable error reading {}: {:?} (kind: {:?})",
112                        path.display(),
113                        e,
114                        e.kind()
115                    );
116                    Ok(Err(e)?)
117                }
118            }
119        }
120    })
121    .await
122    .map_err(|e| {
123        tracing::warn!(
124            target: "fs::retry",
125            "All retries exhausted for {}: {:?} (kind: {:?})",
126            path_for_error.display(),
127            e,
128            e.kind()
129        );
130        let file_error = crate::core::file_error::FileOperationError::new(
131            crate::core::file_error::FileOperationContext::new(
132                FileOperation::Read,
133                &path_for_error,
134                "reading text file with retry".to_string(),
135                "utils::fs::formats::read_text_file_with_retry",
136            ),
137            e,
138        );
139        anyhow::Error::from(file_error)
140    })
141}
142
143/// Writes a text file atomically with proper error handling.
144///
145/// # Arguments
146/// * `path` - The path to write to
147/// * `content` - The text content to write
148///
149/// # Returns
150/// Ok(()) on success
151///
152/// # Errors
153/// Returns an error with context if the file cannot be written
154pub fn write_text_file(path: &Path, content: &str) -> Result<()> {
155    super::atomic::safe_write(path, content)
156        .with_context(|| format!("Failed to write file: {}", path.display()))
157}
158
159/// Reads and parses a JSON file.
160///
161/// # Arguments
162/// * `path` - The path to the JSON file
163///
164/// # Type Parameters
165/// * `T` - The type to deserialize into (must implement `DeserializeOwned`)
166///
167/// # Returns
168/// The parsed JSON data
169///
170/// # Errors
171/// Returns an error if the file cannot be read or parsed
172pub fn read_json_file<T>(path: &Path) -> Result<T>
173where
174    T: serde::de::DeserializeOwned,
175{
176    let content = read_text_file(path)?;
177    serde_json::from_str(&content)
178        .with_context(|| format!("Failed to parse JSON from file: {}", path.display()))
179}
180
181/// Writes data as JSON to a file atomically.
182///
183/// # Arguments
184/// * `path` - The path to write to
185/// * `data` - The data to serialize
186/// * `pretty` - Whether to use pretty formatting
187///
188/// # Type Parameters
189/// * `T` - The type to serialize (must implement Serialize)
190///
191/// # Returns
192/// Ok(()) on success
193///
194/// # Errors
195/// Returns an error if serialization fails or the file cannot be written
196pub fn write_json_file<T>(path: &Path, data: &T, pretty: bool) -> Result<()>
197where
198    T: serde::Serialize,
199{
200    let json = if pretty {
201        serde_json::to_string_pretty(data)?
202    } else {
203        serde_json::to_string(data)?
204    };
205
206    write_text_file(path, &json)
207        .with_context(|| format!("Failed to write JSON file: {}", path.display()))
208}
209
210/// Reads and parses a TOML file.
211///
212/// # Arguments
213/// * `path` - The path to the TOML file
214///
215/// # Type Parameters
216/// * `T` - The type to deserialize into (must implement `DeserializeOwned`)
217///
218/// # Returns
219/// The parsed TOML data
220///
221/// # Errors
222/// Returns an error if the file cannot be read or parsed
223pub fn read_toml_file<T>(path: &Path) -> Result<T>
224where
225    T: serde::de::DeserializeOwned,
226{
227    let content = read_text_file(path)?;
228    toml::from_str(&content)
229        .with_context(|| format!("Failed to parse TOML from file: {}", path.display()))
230}
231
232/// Writes data as TOML to a file atomically.
233///
234/// # Arguments
235/// * `path` - The path to write to
236/// * `data` - The data to serialize
237///
238/// # Type Parameters
239/// * `T` - The type to serialize (must implement Serialize)
240///
241/// # Returns
242/// Ok(()) on success
243///
244/// # Errors
245/// Returns an error if serialization fails or the file cannot be written
246///
247/// # Note
248/// TOML is always pretty-printed for readability
249pub fn write_toml_file<T>(path: &Path, data: &T) -> Result<()>
250where
251    T: serde::Serialize,
252{
253    let toml = toml::to_string_pretty(data)
254        .with_context(|| format!("Failed to serialize data to TOML for: {}", path.display()))?;
255
256    write_text_file(path, &toml)
257        .with_context(|| format!("Failed to write TOML file: {}", path.display()))
258}
259
260/// Reads and parses a YAML file.
261///
262/// # Arguments
263/// * `path` - The path to the YAML file
264///
265/// # Type Parameters
266/// * `T` - The type to deserialize into (must implement `DeserializeOwned`)
267///
268/// # Returns
269/// The parsed YAML data
270///
271/// # Errors
272/// Returns an error if the file cannot be read or parsed
273pub fn read_yaml_file<T>(path: &Path) -> Result<T>
274where
275    T: serde::de::DeserializeOwned,
276{
277    let content = read_text_file(path)?;
278    serde_yaml::from_str(&content)
279        .with_context(|| format!("Failed to parse YAML from file: {}", path.display()))
280}
281
282/// Writes data as YAML to a file atomically.
283///
284/// # Arguments
285/// * `path` - The path to write to
286/// * `data` - The data to serialize
287///
288/// # Type Parameters
289/// * `T` - The type to serialize (must implement Serialize)
290///
291/// # Returns
292/// Ok(()) on success
293///
294/// # Errors
295/// Returns an error if serialization fails or the file cannot be written
296pub fn write_yaml_file<T>(path: &Path, data: &T) -> Result<()>
297where
298    T: serde::Serialize,
299{
300    let yaml = serde_yaml::to_string(data)
301        .with_context(|| format!("Failed to serialize data to YAML for: {}", path.display()))?;
302
303    write_text_file(path, &yaml)
304        .with_context(|| format!("Failed to write YAML file: {}", path.display()))
305}
306
307/// Creates a temporary file with content for testing.
308///
309/// # Arguments
310/// * `prefix` - The prefix for the temp file name
311/// * `content` - The content to write to the file
312///
313/// # Returns
314/// A `TempPath` that will delete the file when dropped
315///
316/// # Errors
317/// Returns an error if the temp file cannot be created
318pub fn create_temp_file(prefix: &str, content: &str) -> Result<tempfile::TempPath> {
319    let temp_file = tempfile::Builder::new().prefix(prefix).suffix(".tmp").tempfile()?;
320
321    let path = temp_file.into_temp_path();
322    write_text_file(&path, content)?;
323
324    Ok(path)
325}
326
327#[cfg(test)]
328mod tests {
329    use super::*;
330    use serde::{Deserialize, Serialize};
331    use tempfile::tempdir;
332
333    #[derive(Debug, Serialize, Deserialize, PartialEq)]
334    struct TestData {
335        name: String,
336        value: i32,
337    }
338
339    #[test]
340    fn test_read_write_text_file() {
341        let temp = tempdir().unwrap();
342        let path = temp.path().join("test.txt");
343
344        write_text_file(&path, "test content").unwrap();
345        let content = read_text_file(&path).unwrap();
346        assert_eq!(content, "test content");
347    }
348
349    #[test]
350    fn test_read_write_json_file() {
351        let temp = tempdir().unwrap();
352        let path = temp.path().join("test.json");
353
354        let data = TestData {
355            name: "test".to_string(),
356            value: 42,
357        };
358
359        write_json_file(&path, &data, true).unwrap();
360        let loaded: TestData = read_json_file(&path).unwrap();
361        assert_eq!(loaded, data);
362    }
363
364    #[test]
365    fn test_read_write_json_file_compact() {
366        let temp = tempdir().unwrap();
367        let path = temp.path().join("test.json");
368
369        let data = TestData {
370            name: "test".to_string(),
371            value: 42,
372        };
373
374        write_json_file(&path, &data, false).unwrap();
375        let content = read_text_file(&path).unwrap();
376        assert!(!content.contains('\n')); // Compact format
377        let loaded: TestData = read_json_file(&path).unwrap();
378        assert_eq!(loaded, data);
379    }
380
381    #[test]
382    fn test_read_write_toml_file() {
383        let temp = tempdir().unwrap();
384        let path = temp.path().join("test.toml");
385
386        let data = TestData {
387            name: "test".to_string(),
388            value: 42,
389        };
390
391        write_toml_file(&path, &data).unwrap();
392        let loaded: TestData = read_toml_file(&path).unwrap();
393        assert_eq!(loaded, data);
394    }
395
396    #[test]
397    fn test_read_write_yaml_file() {
398        let temp = tempdir().unwrap();
399        let path = temp.path().join("test.yaml");
400
401        let data = TestData {
402            name: "test".to_string(),
403            value: 42,
404        };
405
406        write_yaml_file(&path, &data).unwrap();
407        let loaded: TestData = read_yaml_file(&path).unwrap();
408        assert_eq!(loaded, data);
409    }
410
411    #[test]
412    fn test_create_temp_file() {
413        let temp_file = create_temp_file("test", "content").unwrap();
414        assert!(temp_file.exists());
415
416        let content = read_text_file(&temp_file).unwrap();
417        assert_eq!(content, "content");
418
419        let path = temp_file.to_path_buf();
420        drop(temp_file);
421        assert!(!path.exists()); // Cleaned up after drop
422    }
423
424    #[test]
425    fn test_read_nonexistent_file() {
426        let result = read_text_file(Path::new("/nonexistent/file.txt"));
427        assert!(result.is_err());
428    }
429
430    #[test]
431    fn test_write_creates_parent_directories() {
432        let temp = tempdir().unwrap();
433        let path = temp.path().join("nested").join("dirs").join("file.txt");
434
435        write_text_file(&path, "content").unwrap();
436        assert!(path.exists());
437        assert_eq!(read_text_file(&path).unwrap(), "content");
438    }
439
440    #[test]
441    fn test_json_parse_error() {
442        let temp = tempdir().unwrap();
443        let path = temp.path().join("invalid.json");
444
445        write_text_file(&path, "not valid json").unwrap();
446        let result: Result<TestData> = read_json_file(&path);
447        assert!(result.is_err());
448    }
449
450    #[test]
451    fn test_toml_parse_error() {
452        let temp = tempdir().unwrap();
453        let path = temp.path().join("invalid.toml");
454
455        write_text_file(&path, "not = valid = toml").unwrap();
456        let result: Result<TestData> = read_toml_file(&path);
457        assert!(result.is_err());
458    }
459
460    #[test]
461    fn test_yaml_parse_error() {
462        let temp = tempdir().unwrap();
463        let path = temp.path().join("invalid.yaml");
464
465        write_text_file(&path, "not: valid: yaml: [").unwrap();
466        let result: Result<TestData> = read_yaml_file(&path);
467        assert!(result.is_err());
468    }
469}