agpm-cli 0.4.14

AGent Package Manager - A Git-based package manager for coding agents
Documentation
//! Atomic file write operations using temp-and-rename strategy.
//!
//! This module provides safe, atomic file writing that prevents corruption
//! from interrupted writes.

use crate::utils::fs::dirs::ensure_dir;
use anyhow::{Context, Result};
use std::fs;
use std::path::Path;

/// Safely writes a string to a file using atomic operations.
///
/// This is a convenience wrapper around [`atomic_write`] that handles string-to-bytes conversion.
/// The write is atomic, meaning the file either contains the new content or the old content,
/// never a partial write.
///
/// # Arguments
///
/// * `path` - The file path to write to
/// * `content` - The string content to write
///
/// # Returns
///
/// - `Ok(())` if the file was written successfully
/// - `Err` if the write operation fails
///
/// # Examples
///
/// ```rust,no_run
/// use agpm_cli::utils::fs::safe_write;
/// use std::path::Path;
///
/// # fn example() -> anyhow::Result<()> {
/// safe_write(Path::new("config.toml"), "[sources]\ncommunity = \"https://example.com\"")?;
/// # Ok(())
/// # }
/// ```
///
/// # See Also
///
/// - [`atomic_write`] for writing raw bytes
/// - [`atomic_write_multiple`] for batch writing multiple files
pub fn safe_write(path: &Path, content: &str) -> Result<()> {
    atomic_write(path, content.as_bytes())
}

/// Atomically writes bytes to a file using a write-then-rename strategy.
///
/// This function ensures atomic writes by:
/// 1. Writing content to a temporary file (`.tmp` extension)
/// 2. Syncing the temporary file to disk
/// 3. Atomically renaming the temporary file to the target path
///
/// This approach prevents data corruption from interrupted writes and ensures
/// readers never see partially written files.
///
/// # Arguments
///
/// * `path` - The target file path
/// * `content` - The raw bytes to write
///
/// # Returns
///
/// - `Ok(())` if the file was written atomically
/// - `Err` if any step of the atomic write fails
///
/// # Examples
///
/// ```rust,no_run
/// use agpm_cli::utils::fs::atomic_write;
/// use std::path::Path;
///
/// # fn example() -> anyhow::Result<()> {
/// let config_bytes = b"[sources]\ncommunity = \"https://example.com\"";
/// atomic_write(Path::new("agpm.toml"), config_bytes)?;
/// # Ok(())
/// # }
/// ```
///
/// # Platform Notes
///
/// - **Windows**: Handles long paths and provides specific error messages
/// - **Unix**: Preserves file permissions on existing files
/// - **All platforms**: Creates parent directories if they don't exist
///
/// # Guarantees
///
/// - **Atomicity**: File contents are never in a partial state
/// - **Durability**: Content is synced to disk before rename
/// - **Safety**: Parent directories are created automatically
pub fn atomic_write(path: &Path, content: &[u8]) -> Result<()> {
    use std::io::Write;

    // Handle Windows long paths
    let safe_path = crate::utils::platform::windows_long_path(path);

    // Create parent directory if needed
    if let Some(parent) = safe_path.parent() {
        ensure_dir(parent)?;
    }

    // Write to temporary file first
    let temp_path = safe_path.with_extension("tmp");

    {
        let mut file = fs::File::create(&temp_path).with_context(|| {
            let platform_help = if crate::utils::platform::is_windows() {
                "On Windows: Check file permissions, path length, and that directory exists"
            } else {
                "Check file permissions and that directory exists"
            };

            format!("Failed to create temp file: {}\n\n{}", temp_path.display(), platform_help)
        })?;

        file.write_all(content)
            .with_context(|| format!("Failed to write to temp file: {}", temp_path.display()))?;

        file.sync_all().with_context(|| "Failed to sync file to disk")?;
    }

    // Atomic rename
    fs::rename(&temp_path, &safe_path)
        .with_context(|| format!("Failed to rename temp file to: {}", safe_path.display()))?;

    Ok(())
}

/// Writes multiple files atomically in parallel.
///
/// This function performs multiple atomic write operations concurrently,
/// which can significantly improve performance when writing many files.
/// Each file is written atomically using the same write-then-rename strategy
/// as [`atomic_write`].
///
/// # Arguments
///
/// * `files` - A slice of (path, content) pairs to write
///
/// # Returns
///
/// - `Ok(())` if all files were written successfully
/// - `Err` if any write operation fails, with details about all failures
///
/// # Examples
///
/// ```rust,no_run
/// use agpm_cli::utils::fs::atomic_write_multiple;
/// use std::path::PathBuf;
///
/// # async fn example() -> anyhow::Result<()> {
/// let files = vec![
///     (PathBuf::from("config1.toml"), b"[sources]\ncommunity = \"url1\"".to_vec()),
///     (PathBuf::from("config2.toml"), b"[sources]\nprivate = \"url2\"".to_vec()),
///     (PathBuf::from("readme.md"), b"# Project Documentation".to_vec()),
/// ];
///
/// atomic_write_multiple(&files).await?;
/// println!("All configuration files written atomically!");
/// # Ok(())
/// # }
/// ```
///
/// # Atomicity Guarantees
///
/// - Each individual file is written atomically
/// - Either all files are written successfully or the operation fails
/// - No partially written files are left on disk
/// - Parent directories are created automatically
///
/// # Performance
///
/// This function uses parallel execution to improve performance:
/// - Multiple files written concurrently
/// - Scales with available CPU cores and I/O bandwidth
/// - Particularly effective for many small files
/// - Maintains atomicity guarantees for each file
///
/// # See Also
///
/// - [`atomic_write`] for single file atomic writes
/// - [`safe_write`] for string content convenience
/// - [`crate::utils::fs::copy_files_parallel`] for file copying operations
pub async fn atomic_write_multiple(files: &[(std::path::PathBuf, Vec<u8>)]) -> Result<()> {
    use futures::future::try_join_all;

    if files.is_empty() {
        return Ok(());
    }

    let mut tasks = Vec::new();

    for (path, content) in files {
        let path = path.clone();
        let content = content.clone();
        let task =
            tokio::task::spawn_blocking(move || atomic_write(&path, &content).map(|()| path));
        tasks.push(task);
    }

    let results = try_join_all(tasks).await.context("Failed to join atomic write tasks")?;

    let mut errors = Vec::new();

    for result in results {
        if let Err(e) = result {
            errors.push(e);
        }
    }

    if !errors.is_empty() {
        let error_msgs: Vec<String> =
            errors.into_iter().map(|error| format!("  {error}")).collect();
        return Err(anyhow::anyhow!(
            "Failed to write {} files:\n{}",
            error_msgs.len(),
            error_msgs.join("\n")
        ));
    }

    Ok(())
}

#[cfg(test)]
mod tests {
    use super::*;
    use tempfile::tempdir;

    #[test]
    fn test_safe_write() {
        let temp = tempdir().unwrap();
        let file_path = temp.path().join("test.txt");

        safe_write(&file_path, "test content").unwrap();

        let content = std::fs::read_to_string(&file_path).unwrap();
        assert_eq!(content, "test content");
    }

    #[test]
    fn test_safe_write_creates_parent_dirs() {
        let temp = tempdir().unwrap();
        let file_path = temp.path().join("subdir").join("test.txt");

        safe_write(&file_path, "test content").unwrap();

        assert!(file_path.exists());
        let content = std::fs::read_to_string(&file_path).unwrap();
        assert_eq!(content, "test content");
    }

    #[test]
    fn test_atomic_write_basic() {
        let temp = tempdir().unwrap();
        let file = temp.path().join("atomic.txt");

        atomic_write(&file, b"test content").unwrap();
        assert_eq!(std::fs::read_to_string(&file).unwrap(), "test content");
    }

    #[test]
    fn test_atomic_write_overwrites() {
        let temp = tempdir().unwrap();
        let file = temp.path().join("atomic.txt");

        // Write initial content
        atomic_write(&file, b"initial").unwrap();
        assert_eq!(std::fs::read_to_string(&file).unwrap(), "initial");

        // Overwrite
        atomic_write(&file, b"updated").unwrap();
        assert_eq!(std::fs::read_to_string(&file).unwrap(), "updated");
    }

    #[test]
    fn test_atomic_write_creates_parent() {
        let temp = tempdir().unwrap();
        let file = temp.path().join("deep").join("nested").join("atomic.txt");

        atomic_write(&file, b"nested content").unwrap();
        assert!(file.exists());
        assert_eq!(std::fs::read_to_string(&file).unwrap(), "nested content");
    }

    #[tokio::test]
    async fn test_atomic_write_multiple() {
        let temp = tempdir().unwrap();
        let file1 = temp.path().join("atomic1.txt");
        let file2 = temp.path().join("atomic2.txt");

        let files =
            vec![(file1.clone(), b"content1".to_vec()), (file2.clone(), b"content2".to_vec())];

        atomic_write_multiple(&files).await.unwrap();

        assert!(file1.exists());
        assert!(file2.exists());
        assert_eq!(std::fs::read_to_string(&file1).unwrap(), "content1");
        assert_eq!(std::fs::read_to_string(&file2).unwrap(), "content2");
    }

    #[tokio::test]
    async fn test_atomic_write_multiple_partial_failure() {
        // Test behavior when some writes might fail
        let temp = tempdir().unwrap();
        let valid_path = temp.path().join("valid.txt");

        // Use an invalid path that will cause write to fail
        // Create a file and try to use it as a directory
        let invalid_base = temp.path().join("not_a_directory.txt");
        std::fs::write(&invalid_base, "this is a file").unwrap();
        let invalid_path = invalid_base.join("impossible_file.txt");

        let files =
            vec![(valid_path.clone(), b"content".to_vec()), (invalid_path, b"fail".to_vec())];

        let result = atomic_write_multiple(&files).await;
        assert!(result.is_err());
    }

    #[test]
    fn test_safe_write_readonly_parent() {
        // This test verifies behavior when parent dir is readonly
        // We skip it in CI as it requires special permissions
        if std::env::var("CI").is_ok() {
            return;
        }

        let temp = tempdir().unwrap();
        let readonly_dir = temp.path().join("readonly");
        ensure_dir(&readonly_dir).unwrap();

        // Make directory readonly (Unix-specific)
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            let mut perms = std::fs::metadata(&readonly_dir).unwrap().permissions();
            perms.set_mode(0o555); // r-xr-xr-x
            std::fs::set_permissions(&readonly_dir, perms).unwrap();

            let file = readonly_dir.join("test.txt");
            let result = safe_write(&file, "test");
            assert!(result.is_err());

            // Restore permissions for cleanup
            let mut perms = std::fs::metadata(&readonly_dir).unwrap().permissions();
            perms.set_mode(0o755);
            std::fs::set_permissions(&readonly_dir, perms).unwrap();
        }
    }

    #[test]
    fn test_safe_copy_file() {
        let temp = tempdir().unwrap();
        let src = temp.path().join("source.txt");
        let dst = temp.path().join("dest.txt");

        std::fs::write(&src, "test content").unwrap();
        std::fs::copy(&src, &dst).unwrap();

        assert_eq!(std::fs::read_to_string(&dst).unwrap(), "test content");
    }

    #[test]
    fn test_copy_with_parent_creation() {
        let temp = tempdir().unwrap();
        let src = temp.path().join("source.txt");
        let dst = temp.path().join("subdir").join("dest.txt");

        std::fs::write(&src, "test content").unwrap();
        crate::utils::fs::ensure_parent_dir(&dst).unwrap();
        std::fs::copy(&src, &dst).unwrap();

        assert!(dst.exists());
        assert_eq!(std::fs::read_to_string(&dst).unwrap(), "test content");
    }

    #[test]
    fn test_copy_nonexistent_source() {
        let temp = tempdir().unwrap();
        let src = temp.path().join("nonexistent.txt");
        let dst = temp.path().join("dest.txt");

        let result = std::fs::copy(&src, &dst);
        assert!(result.is_err());
    }
}