agpm_cli/utils/fs.rs
1//! File system utilities for cross-platform file operations
2//!
3//! This module provides safe, atomic file operations designed to work consistently
4//! across Windows, macOS, and Linux. All functions handle platform-specific
5//! differences such as path lengths, permissions, and separators.
6//!
7//! # Key Features
8//!
9//! - **Atomic operations**: Files are written atomically to prevent corruption
10//! - **Cross-platform**: Handles Windows long paths, Unix permissions, and path separators
11//! - **Parallel operations**: Async versions for processing multiple files concurrently
12//! - **Safety**: Path traversal prevention and safe path handling
13//! - **Checksum validation**: SHA-256 checksums for data integrity
14//!
15//! # Examples
16//!
17//! ```rust,no_run
18//! use agpm_cli::utils::fs::{ensure_dir, safe_write, calculate_checksum};
19//! use std::path::Path;
20//!
21//! # fn example() -> anyhow::Result<()> {
22//! // Create directory structure
23//! ensure_dir(Path::new("output/agents"))?;
24//!
25//! // Write file atomically
26//! safe_write(Path::new("output/config.toml"), "[sources]")?;
27//!
28//! // Verify file integrity
29//! let checksum = calculate_checksum(Path::new("output/config.toml"))?;
30//! println!("File checksum: {}", checksum);
31//! # Ok(())
32//! # }
33//! ```
34//!
35//! # Platform Considerations
36//!
37//! ## Windows
38//! - Supports long paths (>260 characters) using UNC prefixes
39//! - Handles case-insensitive file systems
40//! - Manages file permissions and attributes correctly
41//!
42//! ## Unix/Linux
43//! - Preserves file permissions during copy operations
44//! - Handles case-sensitive file systems
45//! - Supports symbolic links appropriately
46//!
47//! ## macOS
48//! - Handles HFS+ case-insensitive by default
49//! - Supports extended attributes
50//! - Works with case-sensitive APFS volumes
51
52use crate::core::file_error::{FileOperation, FileResultExt};
53use anyhow::{Context, Result};
54use futures::future::try_join_all;
55use sha2::{Digest, Sha256};
56use std::fs;
57use std::path::{Path, PathBuf};
58
59/// Ensures a directory exists, creating it and all parent directories if necessary.
60///
61/// This function is cross-platform and handles:
62/// - Windows long paths (>260 characters) automatically
63/// - Permission errors with helpful error messages
64/// - Existing files at the target path (returns error)
65///
66/// # Arguments
67///
68/// * `path` - The directory path to create
69///
70/// # Returns
71///
72/// - `Ok(())` if the directory exists or was successfully created
73/// - `Err` if the path exists but is not a directory, or creation fails
74///
75/// # Examples
76///
77/// ```rust,no_run
78/// use agpm_cli::utils::fs::ensure_dir;
79/// use std::path::Path;
80///
81/// # fn example() -> anyhow::Result<()> {
82/// // Create nested directories
83/// ensure_dir(Path::new("output/agents/subdir"))?;
84/// # Ok(())
85/// # }
86/// ```
87///
88/// # Platform Notes
89///
90/// - **Windows**: Automatically handles long paths and provides specific error guidance
91/// - **Unix**: Respects umask for directory permissions
92/// - **All platforms**: Creates parent directories recursively
93pub fn ensure_dir(path: &Path) -> Result<()> {
94 // Handle Windows long paths
95 let safe_path = crate::utils::platform::windows_long_path(path);
96
97 if !safe_path.exists() {
98 fs::create_dir_all(&safe_path).with_context(|| {
99 let platform_help = if crate::utils::platform::is_windows() {
100 "On Windows: Check that the path length is < 260 chars or that long path support is enabled"
101 } else {
102 "Check directory permissions and path validity"
103 };
104
105 format!("Failed to create directory: {}\\n\\n{}", path.display(), platform_help)
106 })?;
107 } else if !safe_path.is_dir() {
108 return Err(anyhow::anyhow!("Path exists but is not a directory: {}", path.display()));
109 }
110 Ok(())
111}
112
113/// Safely writes a string to a file using atomic operations.
114///
115/// This is a convenience wrapper around [`atomic_write`] that handles string-to-bytes conversion.
116/// The write is atomic, meaning the file either contains the new content or the old content,
117/// never a partial write.
118///
119/// # Arguments
120///
121/// * `path` - The file path to write to
122/// * `content` - The string content to write
123///
124/// # Returns
125///
126/// - `Ok(())` if the file was written successfully
127/// - `Err` if the write operation fails
128///
129/// # Examples
130///
131/// ```rust,no_run
132/// use agpm_cli::utils::fs::safe_write;
133/// use std::path::Path;
134///
135/// # fn example() -> anyhow::Result<()> {
136/// safe_write(Path::new("config.toml"), "[sources]\ncommunity = \"https://example.com\"")?;
137/// # Ok(())
138/// # }
139/// ```
140///
141/// # See Also
142///
143/// - [`atomic_write`] for writing raw bytes
144/// - [`atomic_write_multiple`] for batch writing multiple files
145pub fn safe_write(path: &Path, content: &str) -> Result<()> {
146 atomic_write(path, content.as_bytes())
147}
148
149/// Atomically writes bytes to a file using a write-then-rename strategy.
150///
151/// This function ensures atomic writes by:
152/// 1. Writing content to a temporary file (`.tmp` extension)
153/// 2. Syncing the temporary file to disk
154/// 3. Atomically renaming the temporary file to the target path
155///
156/// This approach prevents data corruption from interrupted writes and ensures
157/// readers never see partially written files.
158///
159/// # Arguments
160///
161/// * `path` - The target file path
162/// * `content` - The raw bytes to write
163///
164/// # Returns
165///
166/// - `Ok(())` if the file was written atomically
167/// - `Err` if any step of the atomic write fails
168///
169/// # Examples
170///
171/// ```rust,no_run
172/// use agpm_cli::utils::fs::atomic_write;
173/// use std::path::Path;
174///
175/// # fn example() -> anyhow::Result<()> {
176/// let config_bytes = b"[sources]\ncommunity = \"https://example.com\"";
177/// atomic_write(Path::new("agpm.toml"), config_bytes)?;
178/// # Ok(())
179/// # }
180/// ```
181///
182/// # Platform Notes
183///
184/// - **Windows**: Handles long paths and provides specific error messages
185/// - **Unix**: Preserves file permissions on existing files
186/// - **All platforms**: Creates parent directories if they don't exist
187///
188/// # Guarantees
189///
190/// - **Atomicity**: File contents are never in a partial state
191/// - **Durability**: Content is synced to disk before rename
192/// - **Safety**: Parent directories are created automatically
193pub fn atomic_write(path: &Path, content: &[u8]) -> Result<()> {
194 use std::io::Write;
195
196 // Handle Windows long paths
197 let safe_path = crate::utils::platform::windows_long_path(path);
198
199 // Create parent directory if needed
200 if let Some(parent) = safe_path.parent() {
201 ensure_dir(parent)?;
202 }
203
204 // Write to temporary file first
205 let temp_path = safe_path.with_extension("tmp");
206
207 {
208 let mut file = fs::File::create(&temp_path).with_context(|| {
209 let platform_help = if crate::utils::platform::is_windows() {
210 "On Windows: Check file permissions, path length, and that directory exists"
211 } else {
212 "Check file permissions and that directory exists"
213 };
214
215 format!("Failed to create temp file: {}\\n\\n{}", temp_path.display(), platform_help)
216 })?;
217
218 file.write_all(content)
219 .with_context(|| format!("Failed to write to temp file: {}", temp_path.display()))?;
220
221 file.sync_all().with_context(|| "Failed to sync file to disk")?;
222 }
223
224 // Atomic rename
225 fs::rename(&temp_path, &safe_path)
226 .with_context(|| format!("Failed to rename temp file to: {}", safe_path.display()))?;
227
228 Ok(())
229}
230
231/// Recursively copies a directory and all its contents to a new location.
232///
233/// This function performs a deep copy of all files and subdirectories from the source
234/// to the destination. It creates the destination directory if it doesn't exist and
235/// preserves the directory structure.
236///
237/// # Arguments
238///
239/// * `src` - The source directory to copy from
240/// * `dst` - The destination directory to copy to
241///
242/// # Returns
243///
244/// - `Ok(())` if the directory was copied successfully
245/// - `Err` if the copy operation fails for any file or directory
246///
247/// # Examples
248///
249/// ```rust,no_run
250/// use agpm_cli::utils::fs::copy_dir;
251/// use std::path::Path;
252///
253/// # fn example() -> anyhow::Result<()> {
254/// // Copy entire agent directory
255/// copy_dir(Path::new("cache/agents"), Path::new("output/agents"))?;
256/// # Ok(())
257/// # }
258/// ```
259///
260/// # Behavior
261///
262/// - Creates destination directory if it doesn't exist
263/// - Recursively copies all subdirectories
264/// - Copies only regular files (skips symlinks and special files)
265/// - Overwrites existing files in the destination
266///
267/// # Platform Notes
268///
269/// - **Windows**: Handles long paths and preserves attributes
270/// - **Unix**: Preserves file permissions during copy
271/// - **All platforms**: Does not follow symbolic links
272///
273/// # See Also
274///
275/// - [`copy_dirs_parallel`] for copying multiple directories concurrently
276/// - [`copy_files_parallel`] for batch file copying
277pub fn copy_dir(src: &Path, dst: &Path) -> Result<()> {
278 ensure_dir(dst)?;
279
280 for entry in
281 fs::read_dir(src).with_context(|| format!("Failed to read directory: {}", src.display()))?
282 {
283 let entry = entry?;
284 let file_type = entry.file_type()?;
285 let src_path = entry.path();
286 let dst_path = dst.join(entry.file_name());
287
288 if file_type.is_dir() {
289 copy_dir(&src_path, &dst_path)?;
290 } else if file_type.is_file() {
291 fs::copy(&src_path, &dst_path).with_context(|| {
292 format!("Failed to copy file from {} to {}", src_path.display(), dst_path.display())
293 })?;
294 }
295 // Skip symlinks and other file types
296 }
297
298 Ok(())
299}
300
301/// Recursively removes a directory and all its contents.
302///
303/// This function safely removes a directory tree, handling the case where the
304/// directory doesn't exist (no error). It's designed to be safe for cleanup
305/// operations where the directory may or may not exist.
306///
307/// # Arguments
308///
309/// * `path` - The directory to remove
310///
311/// # Returns
312///
313/// - `Ok(())` if the directory was removed or didn't exist
314/// - `Err` if the removal failed due to permissions or other filesystem errors
315///
316/// # Examples
317///
318/// ```rust,no_run
319/// use agpm_cli::utils::fs::remove_dir_all;
320/// use std::path::Path;
321///
322/// # fn example() -> anyhow::Result<()> {
323/// // Safe cleanup - won't error if directory doesn't exist
324/// remove_dir_all(Path::new("temp/cache"))?;
325/// # Ok(())
326/// # }
327/// ```
328///
329/// # Safety
330///
331/// - Does not follow symbolic links outside the directory tree
332/// - Handles permission errors with descriptive messages
333/// - Safe to call on non-existent directories
334///
335/// # Platform Notes
336///
337/// - **Windows**: Handles long paths and readonly files
338/// - **Unix**: Respects file permissions
339/// - **All platforms**: Atomic operation where supported by filesystem
340pub fn remove_dir_all(path: &Path) -> Result<()> {
341 if path.exists() {
342 fs::remove_dir_all(path)
343 .with_context(|| format!("Failed to remove directory: {}", path.display()))?;
344 }
345 Ok(())
346}
347
348/// Normalizes a path by resolving `.` and `..` components.
349///
350/// This function cleans up path components by:
351/// - Removing `.` (current directory) components
352/// - Resolving `..` (parent directory) components
353/// - Maintaining the path's absolute or relative nature
354///
355/// Note: This function performs logical path resolution without accessing the filesystem.
356/// It does not resolve symbolic links or verify that the path exists.
357///
358/// # Arguments
359///
360/// * `path` - The path to normalize
361///
362/// # Returns
363///
364/// A normalized [`PathBuf`] with `.` and `..` components resolved
365///
366/// # Examples
367///
368/// ```rust,no_run
369/// use agpm_cli::utils::fs::normalize_path;
370/// use std::path::{Path, PathBuf};
371///
372/// let path = Path::new("/foo/./bar/../baz");
373/// let normalized = normalize_path(path);
374/// assert_eq!(normalized, PathBuf::from("/foo/baz"));
375///
376/// let relative = Path::new("../src/./lib.rs");
377/// let normalized_rel = normalize_path(relative);
378/// assert_eq!(normalized_rel, PathBuf::from("../src/lib.rs"));
379/// ```
380///
381/// # Use Cases
382///
383/// - Cleaning user input paths
384/// - Path comparison and deduplication
385/// - Security checks for path traversal
386/// - Canonical path representation
387///
388/// # See Also
389///
390/// - `is_safe_path` for security validation using this normalization
391/// - `safe_canonicalize` for filesystem-aware path resolution
392#[must_use]
393pub fn normalize_path(path: &Path) -> PathBuf {
394 let mut components = Vec::new();
395
396 for component in path.components() {
397 match component {
398 std::path::Component::CurDir => {} // Skip .
399 std::path::Component::ParentDir => {
400 components.pop(); // Remove previous component for ..
401 }
402 c => components.push(c),
403 }
404 }
405
406 components.iter().collect()
407}
408
409/// Checks if a path is safe and doesn't escape the base directory.
410///
411/// This function prevents directory traversal attacks by ensuring that the resolved
412/// path remains within the base directory. It handles both absolute and relative paths,
413/// normalizing them before comparison.
414///
415/// # Arguments
416///
417/// * `base` - The base directory that should contain the path
418/// * `path` - The path to validate (can be absolute or relative)
419///
420/// # Returns
421///
422/// - `true` if the path is safe and stays within the base directory
423/// - `false` if the path would escape the base directory
424///
425/// # Examples
426///
427/// ```rust,no_run
428/// use agpm_cli::utils::fs::is_safe_path;
429/// use std::path::Path;
430///
431/// let base = Path::new("/home/user/project");
432///
433/// // Safe paths
434/// assert!(is_safe_path(base, Path::new("src/main.rs")));
435/// assert!(is_safe_path(base, Path::new("./config/settings.toml")));
436///
437/// // Unsafe paths (directory traversal)
438/// assert!(!is_safe_path(base, Path::new("../../../etc/passwd")));
439/// assert!(!is_safe_path(base, Path::new("/etc/passwd")));
440/// ```
441///
442/// # Security
443///
444/// This function is essential for preventing directory traversal vulnerabilities
445/// when processing user-provided paths. It should be used whenever:
446/// - Extracting archives or packages
447/// - Processing configuration files with path references
448/// - Handling user input that specifies file locations
449///
450/// # Implementation
451///
452/// The function normalizes both paths using `normalize_path` before comparison,
453/// ensuring that path traversal attempts using `../` are properly detected.
454#[must_use]
455pub fn is_safe_path(base: &Path, path: &Path) -> bool {
456 let normalized_base = normalize_path(base);
457 let normalized_path = if path.is_absolute() {
458 normalize_path(path)
459 } else {
460 normalize_path(&base.join(path))
461 };
462
463 normalized_path.starts_with(normalized_base)
464}
465
466/// Recursively finds files matching a pattern in a directory tree.
467///
468/// This function performs a recursive search through the directory tree,
469/// matching files whose names contain the specified pattern. The search
470/// is case-sensitive and uses simple string matching (not regex).
471///
472/// # Arguments
473///
474/// * `dir` - The directory to search in
475/// * `pattern` - The pattern to match in filenames (substring match)
476///
477/// # Returns
478///
479/// A vector of [`PathBuf`]s for all matching files, or an error if the directory
480/// cannot be read.
481///
482/// # Examples
483///
484/// ```rust,no_run
485/// use agpm_cli::utils::fs::find_files;
486/// use std::path::Path;
487///
488/// # fn example() -> anyhow::Result<()> {
489/// // Find all Rust source files
490/// let rust_files = find_files(Path::new("src"), ".rs")?;
491///
492/// // Find all markdown files
493/// let md_files = find_files(Path::new("docs"), ".md")?;
494///
495/// // Find files with "test" in the name
496/// let test_files = find_files(Path::new("."), "test")?;
497/// # Ok(())
498/// # }
499/// ```
500///
501/// # Behavior
502///
503/// - Searches recursively through all subdirectories
504/// - Only returns regular files (not directories or symlinks)
505/// - Uses substring matching (case-sensitive)
506/// - Returns empty vector if no matches found
507/// - Continues searching even if some subdirectories are inaccessible
508///
509/// # Performance
510///
511/// For large directory trees or when searching for many different patterns,
512/// consider using external tools like `fd` or implementing caching for repeated searches.
513pub fn find_files(dir: &Path, pattern: &str) -> Result<Vec<PathBuf>> {
514 let mut files = Vec::new();
515 find_files_recursive(dir, pattern, &mut files)?;
516 Ok(files)
517}
518
519fn find_files_recursive(dir: &Path, pattern: &str, files: &mut Vec<PathBuf>) -> Result<()> {
520 if !dir.is_dir() {
521 return Ok(());
522 }
523
524 for entry in fs::read_dir(dir)? {
525 let entry = entry?;
526 let path = entry.path();
527
528 if path.is_dir() {
529 find_files_recursive(&path, pattern, files)?;
530 } else if path.is_file()
531 && let Some(name) = path.file_name()
532 && name.to_string_lossy().contains(pattern)
533 {
534 files.push(path);
535 }
536 }
537
538 Ok(())
539}
540
541/// Calculates the total size of a directory and all its contents recursively.
542///
543/// This function traverses the directory tree and sums the sizes of all regular files.
544/// It handles nested directories and provides the total disk usage for the directory tree.
545///
546/// # Arguments
547///
548/// * `path` - The directory to calculate size for
549///
550/// # Returns
551///
552/// The total size in bytes, or an error if the directory cannot be read
553///
554/// # Examples
555///
556/// ```rust,no_run
557/// use agpm_cli::utils::fs::dir_size;
558/// use std::path::Path;
559///
560/// # fn example() -> anyhow::Result<()> {
561/// let cache_size = dir_size(Path::new("~/.agpm/cache"))?;
562/// println!("Cache size: {} bytes ({:.2} MB)", cache_size, cache_size as f64 / 1024.0 / 1024.0);
563/// # Ok(())
564/// # }
565/// ```
566///
567/// # Behavior
568///
569/// - Recursively traverses all subdirectories
570/// - Includes only regular files in size calculation
571/// - Does not follow symbolic links
572/// - Returns 0 for empty directories
573/// - Accumulates sizes using 64-bit integers (supports very large directories)
574///
575/// # Performance
576///
577/// This is a synchronous operation that may take time for large directory trees.
578/// For better performance with large directories, use [`get_directory_size`] which
579/// runs the calculation on a separate thread.
580///
581/// # See Also
582///
583/// - [`get_directory_size`] for async version
584/// - Platform-specific tools may be faster for very large directories
585pub fn dir_size(path: &Path) -> Result<u64> {
586 let mut size = 0;
587
588 for entry in fs::read_dir(path)? {
589 let entry = entry?;
590 let metadata = entry.metadata()?;
591
592 if metadata.is_dir() {
593 size += dir_size(&entry.path())?;
594 } else {
595 size += metadata.len();
596 }
597 }
598
599 Ok(size)
600}
601
602/// Asynchronously calculates the total size of a directory and all its contents.
603///
604/// This is the async version of [`dir_size`] that runs the calculation on a separate
605/// thread to avoid blocking the async runtime. Use this when calculating directory
606/// sizes as part of async operations.
607///
608/// # Arguments
609///
610/// * `path` - The directory to calculate size for
611///
612/// # Returns
613///
614/// The total size in bytes, or an error if the operation fails
615///
616/// # Examples
617///
618/// ```rust,no_run
619/// use agpm_cli::utils::fs::get_directory_size;
620/// use std::path::Path;
621///
622/// # async fn example() -> anyhow::Result<()> {
623/// let cache_size = get_directory_size(Path::new("~/.agpm/cache")).await?;
624/// println!("Cache size: {} bytes", cache_size);
625/// # Ok(())
626/// # }
627/// ```
628///
629/// # Performance
630///
631/// This function uses `tokio::task::spawn_blocking` to run the directory traversal
632/// on a thread pool, preventing it from blocking other async tasks. This is particularly
633/// useful when:
634/// - Calculating sizes for multiple directories concurrently
635/// - Integrating with async workflows
636/// - Avoiding blocking in async web servers or CLI applications
637///
638/// # See Also
639///
640/// - [`dir_size`] for synchronous version
641pub async fn get_directory_size(path: &Path) -> Result<u64> {
642 let path = path.to_path_buf();
643 tokio::task::spawn_blocking(move || dir_size(&path))
644 .await
645 .context("Failed to join directory size calculation task")?
646}
647
648/// Ensures that the parent directory of a file path exists.
649///
650/// This is a convenience function for creating the directory structure needed
651/// for a file before writing to it. It extracts the parent directory from the
652/// file path and ensures it exists.
653///
654/// # Arguments
655///
656/// * `path` - The file path whose parent directory should exist
657///
658/// # Returns
659///
660/// - `Ok(())` if the parent directory exists or was created successfully
661/// - `Err` if directory creation fails
662/// - `Ok(())` if the path has no parent (e.g., root level files)
663///
664/// # Examples
665///
666/// ```rust,no_run
667/// use agpm_cli::utils::fs::ensure_parent_dir;
668/// use std::path::Path;
669///
670/// # fn example() -> anyhow::Result<()> {
671/// // Ensure directory structure exists before writing file
672/// ensure_parent_dir(Path::new("output/agents/example.md"))?;
673/// std::fs::write("output/agents/example.md", "# Example Agent")?;
674/// # Ok(())
675/// # }
676/// ```
677///
678/// # Use Cases
679///
680/// - Preparing directory structure before file operations
681/// - Ensuring atomic writes have proper directory structure
682/// - Setting up output paths in batch processing
683///
684/// # See Also
685///
686/// - [`ensure_dir`] for creating a specific directory
687/// - [`atomic_write`] which calls this internally
688pub fn ensure_parent_dir(path: &Path) -> Result<()> {
689 if let Some(parent) = path.parent() {
690 ensure_dir(parent)?;
691 }
692 Ok(())
693}
694
695/// Alias for `ensure_dir` for consistency
696pub fn ensure_dir_exists(path: &Path) -> Result<()> {
697 ensure_dir(path)
698}
699
700/// Copy a directory recursively (alias for consistency)
701pub fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
702 copy_dir(src, dst)
703}
704
705/// Finds the AGPM project root by searching for `agpm.toml` in the directory hierarchy.
706///
707/// This function starts from the given directory and walks up the directory tree
708/// looking for a `agpm.toml` file, which indicates the root of a AGPM project.
709/// This is similar to how Git finds the repository root by looking for `.git`.
710///
711/// # Arguments
712///
713/// * `start` - The directory to start searching from (typically current directory)
714///
715/// # Returns
716///
717/// The path to the directory containing `agpm.toml`, or an error if not found
718///
719/// # Examples
720///
721/// ```rust,no_run
722/// use agpm_cli::utils::fs::find_project_root;
723/// use std::env;
724///
725/// # fn example() -> anyhow::Result<()> {
726/// // Find project root from current directory
727/// let current_dir = env::current_dir()?;
728/// let project_root = find_project_root(¤t_dir)?;
729/// println!("Project root: {}", project_root.display());
730/// # Ok(())
731/// # }
732/// ```
733///
734/// # Behavior
735///
736/// - Starts from the given directory and searches upward
737/// - Returns the first directory containing `agpm.toml`
738/// - Canonicalizes the starting path to handle symlinks
739/// - Stops at filesystem root if no `agpm.toml` is found
740///
741/// # Error Cases
742///
743/// - No `agpm.toml` found in the directory hierarchy
744/// - Permission denied accessing parent directories
745/// - Invalid or inaccessible starting path
746///
747/// # Use Cases
748///
749/// - CLI commands that need to operate on the current project
750/// - Finding configuration files relative to project root
751/// - Validating that commands are run within a AGPM project
752pub fn find_project_root(start: &Path) -> Result<PathBuf> {
753 let mut current = start.canonicalize().unwrap_or_else(|_| start.to_path_buf());
754
755 loop {
756 if current.join("agpm.toml").exists() {
757 return Ok(current);
758 }
759
760 if !current.pop() {
761 return Err(anyhow::anyhow!(
762 "No agpm.toml found in current directory or any parent directory"
763 ));
764 }
765 }
766}
767
768/// Returns the path to the global AGPM configuration file.
769///
770/// This function constructs the path to the global configuration file following
771/// platform conventions. The global config contains user-specific settings like
772/// authentication tokens and private repository URLs.
773///
774/// # Returns
775///
776/// The path to `~/.config/agpm/config.toml`, or an error if the home directory
777/// cannot be determined
778///
779/// # Examples
780///
781/// ```rust,no_run
782/// use agpm_cli::utils::fs::get_global_config_path;
783///
784/// # fn example() -> anyhow::Result<()> {
785/// let config_path = get_global_config_path()?;
786/// println!("Global config at: {}", config_path.display());
787///
788/// // Check if global config exists
789/// if config_path.exists() {
790/// let config = std::fs::read_to_string(&config_path)?;
791/// println!("Config contents: {}", config);
792/// }
793/// # Ok(())
794/// # }
795/// ```
796///
797/// # Platform Paths
798///
799/// - **Linux**: `~/.config/agpm/config.toml`
800/// - **macOS**: `~/.config/agpm/config.toml`
801/// - **Windows**: `%USERPROFILE%\.config\agpm\config.toml`
802///
803/// # Use Cases
804///
805/// - Loading global user configuration
806/// - Storing authentication tokens securely
807/// - Sharing settings across multiple projects
808///
809/// # Security Note
810///
811/// This file may contain sensitive information like API tokens. It should never
812/// be committed to version control or shared publicly.
813pub fn get_global_config_path() -> Result<PathBuf> {
814 let home = crate::utils::platform::get_home_dir()?;
815 Ok(home.join(".config").join("agpm").join("config.toml"))
816}
817
818/// A temporary directory that automatically cleans up when dropped.
819///
820/// This struct provides RAII (Resource Acquisition Is Initialization) semantics
821/// for temporary directories. The directory is created when the struct is created
822/// and automatically removed when the struct is dropped, even if the program panics.
823///
824/// # Examples
825///
826/// ```rust,no_run
827/// use agpm_cli::utils::fs::TempDir;
828///
829/// # fn example() -> anyhow::Result<()> {
830/// {
831/// let temp = TempDir::new("test")?;
832/// let temp_path = temp.path();
833///
834/// // Use the temporary directory
835/// std::fs::write(temp_path.join("file.txt"), "temporary data")?;
836///
837/// // Directory exists here
838/// assert!(temp_path.exists());
839/// } // TempDir is dropped here, directory is automatically cleaned up
840/// # Ok(())
841/// # }
842/// ```
843///
844/// # Thread Safety
845///
846/// Each `TempDir` instance creates a unique directory using UUID generation,
847/// making it safe to use across multiple threads without naming conflicts.
848///
849/// # Cleanup Behavior
850///
851/// - Directory is removed recursively when dropped
852/// - Cleanup happens even if the program panics
853/// - If cleanup fails (rare), the error is silently ignored
854/// - Uses the system temporary directory as the parent
855///
856/// # Use Cases
857///
858/// - Unit testing with temporary files
859/// - Staging areas for atomic operations
860/// - Scratch space for temporary processing
861pub struct TempDir {
862 path: PathBuf,
863}
864
865impl TempDir {
866 /// Creates a new temporary directory with the given prefix.
867 ///
868 /// The directory is created immediately and will have a name like
869 /// `agpm_{prefix}_{uuid}` in the system temporary directory.
870 ///
871 /// # Arguments
872 ///
873 /// * `prefix` - A prefix for the directory name (for identification)
874 ///
875 /// # Returns
876 ///
877 /// A new `TempDir` instance, or an error if directory creation fails
878 ///
879 /// # Examples
880 ///
881 /// ```rust,no_run
882 /// use agpm_cli::utils::fs::TempDir;
883 ///
884 /// # fn example() -> anyhow::Result<()> {
885 /// let temp = TempDir::new("cache")?;
886 /// println!("Temporary directory: {}", temp.path().display());
887 /// # Ok(())
888 /// # }
889 /// ```
890 pub fn new(prefix: &str) -> Result<Self> {
891 let temp_dir = std::env::temp_dir();
892 let unique_name = format!("agpm_{}_{}", prefix, uuid::Uuid::new_v4());
893 let path = temp_dir.join(unique_name);
894
895 ensure_dir(&path)?;
896
897 Ok(Self {
898 path,
899 })
900 }
901
902 /// Returns the path to the temporary directory.
903 ///
904 /// The directory is guaranteed to exist as long as this `TempDir` instance exists.
905 ///
906 /// # Returns
907 ///
908 /// A reference to the temporary directory path
909 #[must_use]
910 pub fn path(&self) -> &Path {
911 &self.path
912 }
913}
914
915impl Drop for TempDir {
916 fn drop(&mut self) {
917 let _ = remove_dir_all(&self.path);
918 }
919}
920
921/// Calculates the SHA-256 checksum of a file.
922///
923/// This function reads the entire file into memory and computes its SHA-256 hash,
924/// returning it as a lowercase hexadecimal string. This is useful for verifying
925/// file integrity and detecting changes.
926///
927/// # Arguments
928///
929/// * `path` - The path to the file to checksum
930///
931/// # Returns
932///
933/// A 64-character lowercase hexadecimal string representing the SHA-256 hash,
934/// or an error if the file cannot be read
935///
936/// # Examples
937///
938/// ```rust,no_run
939/// use agpm_cli::utils::fs::calculate_checksum;
940/// use std::path::Path;
941///
942/// # fn example() -> anyhow::Result<()> {
943/// let checksum = calculate_checksum(Path::new("important-file.txt"))?;
944/// println!("File checksum: {}", checksum);
945///
946/// // Verify against expected checksum
947/// let expected = "d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2d2";
948/// if checksum == expected {
949/// println!("File integrity verified!");
950/// }
951/// # Ok(())
952/// # }
953/// ```
954///
955/// # Performance
956///
957/// This function reads the entire file into memory, so it may not be suitable
958/// for very large files. For processing multiple files, consider using
959/// [`calculate_checksums_parallel`] for better performance.
960///
961/// # Security
962///
963/// SHA-256 is cryptographically secure and suitable for:
964/// - Integrity verification
965/// - Change detection
966/// - Digital signatures
967/// - Blockchain applications
968///
969/// # See Also
970///
971/// - [`calculate_checksums_parallel`] for batch processing
972/// - [`hex`] crate for hexadecimal encoding
973pub fn calculate_checksum(path: &Path) -> Result<String> {
974 let content = fs::read(path)
975 .with_context(|| format!("Failed to read file for checksum: {}", path.display()))?;
976
977 let mut hasher = Sha256::new();
978 hasher.update(&content);
979 let result = hasher.finalize();
980
981 Ok(hex::encode(result))
982}
983
984/// Calculates SHA-256 checksums for multiple files concurrently.
985///
986/// This function processes multiple files in parallel using Tokio's thread pool,
987/// which can significantly improve performance when processing many files or
988/// large files on systems with multiple CPU cores.
989///
990/// # Arguments
991///
992/// * `paths` - A slice of file paths to process
993///
994/// # Returns
995///
996/// A vector of tuples containing each file path and its corresponding checksum,
997/// in the same order as the input paths. Returns an error if any file fails
998/// to be processed.
999///
1000/// # Examples
1001///
1002/// ```rust,no_run
1003/// use agpm_cli::utils::fs::calculate_checksums_parallel;
1004/// use std::path::PathBuf;
1005///
1006/// # async fn example() -> anyhow::Result<()> {
1007/// let files = vec![
1008/// PathBuf::from("file1.txt"),
1009/// PathBuf::from("file2.txt"),
1010/// PathBuf::from("file3.txt"),
1011/// ];
1012///
1013/// let results = calculate_checksums_parallel(&files).await?;
1014/// for (path, checksum) in results {
1015/// println!("{}: {}", path.display(), checksum);
1016/// }
1017/// # Ok(())
1018/// # }
1019/// ```
1020///
1021/// # Performance
1022///
1023/// This function uses `tokio::task::spawn_blocking` to run checksum calculations
1024/// on separate threads, allowing for true parallelism. Benefits:
1025/// - CPU-bound work doesn't block the async runtime
1026/// - Multiple files processed simultaneously
1027/// - Scales with available CPU cores
1028/// - Maintains order of results
1029///
1030/// # Error Handling
1031///
1032/// If any file fails to be processed, the entire operation fails and returns
1033/// an error with details about all failures. This "all-or-nothing" approach
1034/// ensures data consistency.
1035///
1036/// # See Also
1037///
1038/// - [`calculate_checksum`] for single file processing
1039/// - [`read_files_parallel`] for concurrent file reading
1040pub async fn calculate_checksums_parallel(paths: &[PathBuf]) -> Result<Vec<(PathBuf, String)>> {
1041 if paths.is_empty() {
1042 return Ok(Vec::new());
1043 }
1044
1045 let mut tasks = Vec::new();
1046
1047 for (index, path) in paths.iter().enumerate() {
1048 let path = path.clone();
1049 let task = tokio::task::spawn_blocking(move || {
1050 calculate_checksum(&path).map(|checksum| (index, path, checksum))
1051 });
1052 tasks.push(task);
1053 }
1054
1055 let results = try_join_all(tasks).await.context("Failed to join checksum calculation tasks")?;
1056
1057 let mut successes = Vec::new();
1058 let mut errors = Vec::new();
1059
1060 for result in results {
1061 match result {
1062 Ok((index, path, checksum)) => successes.push((index, path, checksum)),
1063 Err(e) => errors.push(e),
1064 }
1065 }
1066
1067 if !errors.is_empty() {
1068 let error_msgs: Vec<String> =
1069 errors.into_iter().map(|error| format!(" {error}")).collect();
1070 return Err(anyhow::anyhow!(
1071 "Failed to calculate checksums for {} files:\n{}",
1072 error_msgs.len(),
1073 error_msgs.join("\n")
1074 ));
1075 }
1076
1077 // Sort results by original index to maintain order
1078 successes.sort_by_key(|(index, _, _)| *index);
1079 let ordered_results: Vec<(PathBuf, String)> =
1080 successes.into_iter().map(|(_, path, checksum)| (path, checksum)).collect();
1081
1082 Ok(ordered_results)
1083}
1084
1085/// Copies multiple files concurrently using parallel processing.
1086///
1087/// This function performs multiple file copy operations in parallel, which can
1088/// significantly improve performance when copying many files, especially on
1089/// systems with fast storage and multiple CPU cores.
1090///
1091/// # Arguments
1092///
1093/// * `sources_and_destinations` - A slice of (source, destination) path pairs
1094///
1095/// # Returns
1096///
1097/// - `Ok(())` if all files were copied successfully
1098/// - `Err` if any copy operation fails, with details about all failures
1099///
1100/// # Examples
1101///
1102/// ```rust,no_run
1103/// use agpm_cli::utils::fs::copy_files_parallel;
1104/// use std::path::PathBuf;
1105///
1106/// # async fn example() -> anyhow::Result<()> {
1107/// let copy_operations = vec![
1108/// (PathBuf::from("src/agent1.md"), PathBuf::from("output/agent1.md")),
1109/// (PathBuf::from("src/agent2.md"), PathBuf::from("output/agent2.md")),
1110/// (PathBuf::from("src/snippet.md"), PathBuf::from("output/snippet.md")),
1111/// ];
1112///
1113/// copy_files_parallel(©_operations).await?;
1114/// println!("All files copied successfully!");
1115/// # Ok(())
1116/// # }
1117/// ```
1118///
1119/// # Features
1120///
1121/// - **Parallel execution**: Uses thread pool for concurrent operations
1122/// - **Automatic directory creation**: Creates destination directories as needed
1123/// - **Atomic behavior**: Either all files copy successfully or none do
1124/// - **Progress tracking**: Can be combined with progress bars for user feedback
1125///
1126/// # Performance Characteristics
1127///
1128/// - Best for many small to medium files
1129/// - Scales with available CPU cores and I/O bandwidth
1130/// - May not improve performance for very large files (I/O bound)
1131/// - Respects filesystem limits on concurrent operations
1132///
1133/// # Error Handling
1134///
1135/// All copy operations must succeed for the function to return `Ok(())`. If any
1136/// operation fails, detailed error information is provided for troubleshooting.
1137///
1138/// # See Also
1139///
1140/// - [`copy_dirs_parallel`] for directory copying
1141/// - [`atomic_write_multiple`] for writing multiple files
1142pub async fn copy_files_parallel(sources_and_destinations: &[(PathBuf, PathBuf)]) -> Result<()> {
1143 if sources_and_destinations.is_empty() {
1144 return Ok(());
1145 }
1146
1147 let mut tasks = Vec::new();
1148
1149 for (src, dst) in sources_and_destinations {
1150 let src = src.clone();
1151 let dst = dst.clone();
1152 let task = tokio::task::spawn_blocking(move || {
1153 // Ensure destination directory exists
1154 if let Some(parent) = dst.parent() {
1155 ensure_dir(parent)?;
1156 }
1157
1158 // Copy file
1159 fs::copy(&src, &dst).with_context(|| {
1160 format!("Failed to copy file from {} to {}", src.display(), dst.display())
1161 })?;
1162
1163 Ok::<_, anyhow::Error>((src, dst))
1164 });
1165 tasks.push(task);
1166 }
1167
1168 let results = try_join_all(tasks).await.context("Failed to join file copy tasks")?;
1169
1170 let mut errors = Vec::new();
1171
1172 for result in results {
1173 if let Err(e) = result {
1174 errors.push(e);
1175 }
1176 }
1177
1178 if !errors.is_empty() {
1179 let error_msgs: Vec<String> =
1180 errors.into_iter().map(|error| format!(" {error}")).collect();
1181 return Err(anyhow::anyhow!(
1182 "Failed to copy {} files:\n{}",
1183 error_msgs.len(),
1184 error_msgs.join("\n")
1185 ));
1186 }
1187
1188 Ok(())
1189}
1190
1191/// Writes multiple files atomically in parallel.
1192///
1193/// This function performs multiple atomic write operations concurrently,
1194/// which can significantly improve performance when writing many files.
1195/// Each file is written atomically using the same write-then-rename strategy
1196/// as [`atomic_write`].
1197///
1198/// # Arguments
1199///
1200/// * `files` - A slice of (path, content) pairs to write
1201///
1202/// # Returns
1203///
1204/// - `Ok(())` if all files were written successfully
1205/// - `Err` if any write operation fails, with details about all failures
1206///
1207/// # Examples
1208///
1209/// ```rust,no_run
1210/// use agpm_cli::utils::fs::atomic_write_multiple;
1211/// use std::path::PathBuf;
1212///
1213/// # async fn example() -> anyhow::Result<()> {
1214/// let files = vec![
1215/// (PathBuf::from("config1.toml"), b"[sources]\ncommunity = \"url1\"".to_vec()),
1216/// (PathBuf::from("config2.toml"), b"[sources]\nprivate = \"url2\"".to_vec()),
1217/// (PathBuf::from("readme.md"), b"# Project Documentation".to_vec()),
1218/// ];
1219///
1220/// atomic_write_multiple(&files).await?;
1221/// println!("All configuration files written atomically!");
1222/// # Ok(())
1223/// # }
1224/// ```
1225///
1226/// # Atomicity Guarantees
1227///
1228/// - Each individual file is written atomically
1229/// - Either all files are written successfully or the operation fails
1230/// - No partially written files are left on disk
1231/// - Parent directories are created automatically
1232///
1233/// # Performance
1234///
1235/// This function uses parallel execution to improve performance:
1236/// - Multiple files written concurrently
1237/// - Scales with available CPU cores and I/O bandwidth
1238/// - Particularly effective for many small files
1239/// - Maintains atomicity guarantees for each file
1240///
1241/// # See Also
1242///
1243/// - [`atomic_write`] for single file atomic writes
1244/// - [`safe_write`] for string content convenience
1245/// - [`copy_files_parallel`] for file copying operations
1246pub async fn atomic_write_multiple(files: &[(PathBuf, Vec<u8>)]) -> Result<()> {
1247 if files.is_empty() {
1248 return Ok(());
1249 }
1250
1251 let mut tasks = Vec::new();
1252
1253 for (path, content) in files {
1254 let path = path.clone();
1255 let content = content.clone();
1256 let task =
1257 tokio::task::spawn_blocking(move || atomic_write(&path, &content).map(|()| path));
1258 tasks.push(task);
1259 }
1260
1261 let results = try_join_all(tasks).await.context("Failed to join atomic write tasks")?;
1262
1263 let mut errors = Vec::new();
1264
1265 for result in results {
1266 if let Err(e) = result {
1267 errors.push(e);
1268 }
1269 }
1270
1271 if !errors.is_empty() {
1272 let error_msgs: Vec<String> =
1273 errors.into_iter().map(|error| format!(" {error}")).collect();
1274 return Err(anyhow::anyhow!(
1275 "Failed to write {} files:\n{}",
1276 error_msgs.len(),
1277 error_msgs.join("\n")
1278 ));
1279 }
1280
1281 Ok(())
1282}
1283
1284/// Copies multiple directories concurrently.
1285///
1286/// This function performs multiple directory copy operations in parallel,
1287/// which can improve performance when copying several separate directory trees.
1288/// Each directory is copied recursively using the same logic as [`copy_dir`].
1289///
1290/// # Arguments
1291///
1292/// * `sources_and_destinations` - A slice of (source, destination) directory pairs
1293///
1294/// # Returns
1295///
1296/// - `Ok(())` if all directories were copied successfully
1297/// - `Err` if any copy operation fails, with details about all failures
1298///
1299/// # Examples
1300///
1301/// ```rust,no_run
1302/// use agpm_cli::utils::fs::copy_dirs_parallel;
1303/// use std::path::PathBuf;
1304///
1305/// # async fn example() -> anyhow::Result<()> {
1306/// let copy_operations = vec![
1307/// (PathBuf::from("cache/agents"), PathBuf::from("output/agents")),
1308/// (PathBuf::from("cache/snippets"), PathBuf::from("output/snippets")),
1309/// (PathBuf::from("cache/templates"), PathBuf::from("output/templates")),
1310/// ];
1311///
1312/// copy_dirs_parallel(©_operations).await?;
1313/// println!("All directories copied successfully!");
1314/// # Ok(())
1315/// # }
1316/// ```
1317///
1318/// # Features
1319///
1320/// - **Recursive copying**: Each directory is copied with all subdirectories
1321/// - **Parallel execution**: Multiple directory trees copied concurrently
1322/// - **Atomic behavior**: Either all directories copy successfully or operation fails
1323/// - **Automatic creation**: Destination directories are created as needed
1324///
1325/// # Use Cases
1326///
1327/// - Copying multiple resource categories simultaneously
1328/// - Batch operations on directory structures
1329/// - Backup operations for multiple directories
1330/// - Installation processes involving multiple components
1331///
1332/// # Performance Considerations
1333///
1334/// - Best performance with multiple separate directory trees
1335/// - May not improve performance if directories share the same disk
1336/// - Memory usage scales with number of directories and their sizes
1337/// - Respects filesystem concurrent operation limits
1338///
1339/// # See Also
1340///
1341/// - [`copy_dir`] for single directory copying
1342/// - [`copy_files_parallel`] for individual file copying
1343pub async fn copy_dirs_parallel(sources_and_destinations: &[(PathBuf, PathBuf)]) -> Result<()> {
1344 if sources_and_destinations.is_empty() {
1345 return Ok(());
1346 }
1347
1348 let mut tasks = Vec::new();
1349
1350 for (src, dst) in sources_and_destinations {
1351 let src = src.clone();
1352 let dst = dst.clone();
1353 let task = tokio::task::spawn_blocking(move || copy_dir(&src, &dst).map(|()| (src, dst)));
1354 tasks.push(task);
1355 }
1356
1357 let results = try_join_all(tasks).await.context("Failed to join directory copy tasks")?;
1358
1359 let mut errors = Vec::new();
1360
1361 for result in results {
1362 if let Err(e) = result {
1363 errors.push(e);
1364 }
1365 }
1366
1367 if !errors.is_empty() {
1368 let error_msgs: Vec<String> =
1369 errors.into_iter().map(|error| format!(" {error}")).collect();
1370 return Err(anyhow::anyhow!(
1371 "Failed to copy {} directories:\n{}",
1372 error_msgs.len(),
1373 error_msgs.join("\n")
1374 ));
1375 }
1376
1377 Ok(())
1378}
1379
1380/// Reads multiple files concurrently and returns their contents.
1381///
1382/// This function reads multiple text files in parallel, which can improve
1383/// performance when processing many files, especially on systems with
1384/// fast storage and multiple CPU cores.
1385///
1386/// # Arguments
1387///
1388/// * `paths` - A slice of file paths to read
1389///
1390/// # Returns
1391///
1392/// A vector of tuples containing each file path and its content as a UTF-8 string,
1393/// in the same order as the input paths. Returns an error if any file fails
1394/// to be read or contains invalid UTF-8.
1395///
1396/// # Examples
1397///
1398/// ```rust,no_run
1399/// use agpm_cli::utils::fs::read_files_parallel;
1400/// use std::path::PathBuf;
1401///
1402/// # async fn example() -> anyhow::Result<()> {
1403/// let config_files = vec![
1404/// PathBuf::from("agpm.toml"),
1405/// PathBuf::from("agents/agent1.md"),
1406/// PathBuf::from("snippets/snippet1.md"),
1407/// ];
1408///
1409/// let results = read_files_parallel(&config_files).await?;
1410/// for (path, content) in results {
1411/// println!("{}: {} characters", path.display(), content.len());
1412/// }
1413/// # Ok(())
1414/// # }
1415/// ```
1416///
1417/// # Performance
1418///
1419/// This function uses `tokio::task::spawn_blocking` to perform file I/O
1420/// on separate threads:
1421/// - Multiple files read simultaneously
1422/// - Non-blocking for the async runtime
1423/// - Scales with available CPU cores and I/O bandwidth
1424/// - Maintains result ordering
1425///
1426/// # UTF-8 Handling
1427///
1428/// All files must contain valid UTF-8 text. If any file contains invalid
1429/// UTF-8 bytes, the operation will fail with a descriptive error.
1430///
1431/// # Error Handling
1432///
1433/// If any file fails to be read (due to permissions, missing file, or
1434/// invalid UTF-8), the entire operation fails. This ensures consistency
1435/// when processing related files that should all be available.
1436///
1437/// # See Also
1438///
1439/// - [`calculate_checksums_parallel`] for file integrity verification
1440/// - [`atomic_write_multiple`] for batch writing operations
1441pub async fn read_files_parallel(paths: &[PathBuf]) -> Result<Vec<(PathBuf, String)>> {
1442 if paths.is_empty() {
1443 return Ok(Vec::new());
1444 }
1445
1446 let mut tasks = Vec::new();
1447
1448 for (index, path) in paths.iter().enumerate() {
1449 let path = path.clone();
1450 let task = tokio::task::spawn_blocking(move || {
1451 fs::read_to_string(&path).map(|content| (index, path, content))
1452 });
1453 tasks.push(task);
1454 }
1455
1456 let results = try_join_all(tasks).await.context("Failed to join file read tasks")?;
1457
1458 let mut successes = Vec::new();
1459 let mut errors = Vec::new();
1460
1461 for result in results {
1462 match result {
1463 Ok((index, path, content)) => successes.push((index, path, content)),
1464 Err(e) => errors.push(e),
1465 }
1466 }
1467
1468 if !errors.is_empty() {
1469 let error_msgs: Vec<String> =
1470 errors.into_iter().map(|error| format!(" {error}")).collect();
1471 return Err(anyhow::anyhow!(
1472 "Failed to read {} files:\n{}",
1473 error_msgs.len(),
1474 error_msgs.join("\n")
1475 ));
1476 }
1477
1478 // Sort results by original index to maintain order
1479 successes.sort_by_key(|(index, _, _)| *index);
1480 let ordered_results: Vec<(PathBuf, String)> =
1481 successes.into_iter().map(|(_, path, content)| (path, content)).collect();
1482
1483 Ok(ordered_results)
1484}
1485
1486// ============================================================================
1487// Unified File I/O Operations
1488// ============================================================================
1489
1490/// Reads a text file with proper error handling and context.
1491///
1492/// # Arguments
1493/// * `path` - The path to the file to read
1494///
1495/// # Returns
1496/// The contents of the file as a String
1497///
1498/// # Errors
1499/// Returns an error with context if the file cannot be read
1500pub fn read_text_file(path: &Path) -> Result<String> {
1501 Ok(fs::read_to_string(path).with_file_context(
1502 FileOperation::Read,
1503 path,
1504 "reading text file",
1505 "utils::fs::read_text_file",
1506 )?)
1507}
1508
1509/// Writes a text file atomically with proper error handling.
1510///
1511/// # Arguments
1512/// * `path` - The path to write to
1513/// * `content` - The text content to write
1514///
1515/// # Returns
1516/// Ok(()) on success
1517///
1518/// # Errors
1519/// Returns an error with context if the file cannot be written
1520pub fn write_text_file(path: &Path, content: &str) -> Result<()> {
1521 safe_write(path, content).with_context(|| format!("Failed to write file: {}", path.display()))
1522}
1523
1524/// Reads and parses a JSON file.
1525///
1526/// # Arguments
1527/// * `path` - The path to the JSON file
1528///
1529/// # Type Parameters
1530/// * `T` - The type to deserialize into (must implement `DeserializeOwned`)
1531///
1532/// # Returns
1533/// The parsed JSON data
1534///
1535/// # Errors
1536/// Returns an error if the file cannot be read or parsed
1537pub fn read_json_file<T>(path: &Path) -> Result<T>
1538where
1539 T: serde::de::DeserializeOwned,
1540{
1541 let content = read_text_file(path)?;
1542 serde_json::from_str(&content)
1543 .with_context(|| format!("Failed to parse JSON from file: {}", path.display()))
1544}
1545
1546/// Writes data as JSON to a file atomically.
1547///
1548/// # Arguments
1549/// * `path` - The path to write to
1550/// * `data` - The data to serialize
1551/// * `pretty` - Whether to use pretty formatting
1552///
1553/// # Type Parameters
1554/// * `T` - The type to serialize (must implement Serialize)
1555///
1556/// # Returns
1557/// Ok(()) on success
1558///
1559/// # Errors
1560/// Returns an error if serialization fails or the file cannot be written
1561pub fn write_json_file<T>(path: &Path, data: &T, pretty: bool) -> Result<()>
1562where
1563 T: serde::Serialize,
1564{
1565 let json = if pretty {
1566 serde_json::to_string_pretty(data)?
1567 } else {
1568 serde_json::to_string(data)?
1569 };
1570
1571 write_text_file(path, &json)
1572 .with_context(|| format!("Failed to write JSON file: {}", path.display()))
1573}
1574
1575/// Reads and parses a TOML file.
1576///
1577/// # Arguments
1578/// * `path` - The path to the TOML file
1579///
1580/// # Type Parameters
1581/// * `T` - The type to deserialize into (must implement `DeserializeOwned`)
1582///
1583/// # Returns
1584/// The parsed TOML data
1585///
1586/// # Errors
1587/// Returns an error if the file cannot be read or parsed
1588pub fn read_toml_file<T>(path: &Path) -> Result<T>
1589where
1590 T: serde::de::DeserializeOwned,
1591{
1592 let content = read_text_file(path)?;
1593 toml::from_str(&content)
1594 .with_context(|| format!("Failed to parse TOML from file: {}", path.display()))
1595}
1596
1597/// Writes data as TOML to a file atomically.
1598///
1599/// # Arguments
1600/// * `path` - The path to write to
1601/// * `data` - The data to serialize
1602/// * `pretty` - Whether to use pretty formatting (always true for TOML)
1603///
1604/// # Type Parameters
1605/// * `T` - The type to serialize (must implement Serialize)
1606///
1607/// # Returns
1608/// Ok(()) on success
1609///
1610/// # Errors
1611/// Returns an error if serialization fails or the file cannot be written
1612pub fn write_toml_file<T>(path: &Path, data: &T) -> Result<()>
1613where
1614 T: serde::Serialize,
1615{
1616 let toml = toml::to_string_pretty(data)
1617 .with_context(|| format!("Failed to serialize data to TOML for: {}", path.display()))?;
1618
1619 write_text_file(path, &toml)
1620 .with_context(|| format!("Failed to write TOML file: {}", path.display()))
1621}
1622
1623/// Reads and parses a YAML file.
1624///
1625/// # Arguments
1626/// * `path` - The path to the YAML file
1627///
1628/// # Type Parameters
1629/// * `T` - The type to deserialize into (must implement `DeserializeOwned`)
1630///
1631/// # Returns
1632/// The parsed YAML data
1633///
1634/// # Errors
1635/// Returns an error if the file cannot be read or parsed
1636pub fn read_yaml_file<T>(path: &Path) -> Result<T>
1637where
1638 T: serde::de::DeserializeOwned,
1639{
1640 let content = read_text_file(path)?;
1641 serde_yaml::from_str(&content)
1642 .with_context(|| format!("Failed to parse YAML from file: {}", path.display()))
1643}
1644
1645/// Writes data as YAML to a file atomically.
1646///
1647/// # Arguments
1648/// * `path` - The path to write to
1649/// * `data` - The data to serialize
1650///
1651/// # Type Parameters
1652/// * `T` - The type to serialize (must implement Serialize)
1653///
1654/// # Returns
1655/// Ok(()) on success
1656///
1657/// # Errors
1658/// Returns an error if serialization fails or the file cannot be written
1659pub fn write_yaml_file<T>(path: &Path, data: &T) -> Result<()>
1660where
1661 T: serde::Serialize,
1662{
1663 let yaml = serde_yaml::to_string(data)
1664 .with_context(|| format!("Failed to serialize data to YAML for: {}", path.display()))?;
1665
1666 write_text_file(path, &yaml)
1667 .with_context(|| format!("Failed to write YAML file: {}", path.display()))
1668}
1669
1670/// Creates a temporary file with content for testing.
1671///
1672/// # Arguments
1673/// * `prefix` - The prefix for the temp file name
1674/// * `content` - The content to write to the file
1675///
1676/// # Returns
1677/// A `TempPath` that will delete the file when dropped
1678///
1679/// # Errors
1680/// Returns an error if the temp file cannot be created
1681pub fn create_temp_file(prefix: &str, content: &str) -> Result<tempfile::TempPath> {
1682 let temp_file = tempfile::Builder::new().prefix(prefix).suffix(".tmp").tempfile()?;
1683
1684 let path = temp_file.into_temp_path();
1685 write_text_file(&path, content)?;
1686
1687 Ok(path)
1688}
1689
1690/// Checks if a file exists and is readable.
1691///
1692/// # Arguments
1693/// * `path` - The path to check
1694///
1695/// # Returns
1696/// true if the file exists and is readable, false otherwise
1697pub fn file_exists_and_readable(path: &Path) -> bool {
1698 path.exists() && path.is_file() && fs::metadata(path).is_ok()
1699}
1700
1701/// Gets the modification time of a file.
1702///
1703/// # Arguments
1704/// * `path` - The path to the file
1705///
1706/// # Returns
1707/// The modification time as a `SystemTime`
1708///
1709/// # Errors
1710/// Returns an error if the file metadata cannot be read
1711pub fn get_modified_time(path: &Path) -> Result<std::time::SystemTime> {
1712 let metadata = fs::metadata(path)
1713 .with_context(|| format!("Failed to get metadata for: {}", path.display()))?;
1714
1715 metadata
1716 .modified()
1717 .with_context(|| format!("Failed to get modification time for: {}", path.display()))
1718}
1719
1720/// Compares the modification times of two files.
1721///
1722/// # Arguments
1723/// * `path1` - The first file path
1724/// * `path2` - The second file path
1725///
1726/// # Returns
1727/// - `Ok(Ordering::Less)` if path1 is older than path2
1728/// - `Ok(Ordering::Greater)` if path1 is newer than path2
1729/// - `Ok(Ordering::Equal)` if they have the same modification time
1730///
1731/// # Errors
1732/// Returns an error if either file's metadata cannot be read
1733pub fn compare_file_times(path1: &Path, path2: &Path) -> Result<std::cmp::Ordering> {
1734 let time1 = get_modified_time(path1)?;
1735 let time2 = get_modified_time(path2)?;
1736
1737 Ok(time1.cmp(&time2))
1738}
1739
1740#[cfg(test)]
1741mod tests {
1742 use super::*;
1743 use tempfile::tempdir;
1744
1745 #[test]
1746 fn test_ensure_dir() {
1747 let temp = tempdir().unwrap();
1748 let test_dir = temp.path().join("test_dir");
1749
1750 assert!(!test_dir.exists());
1751 ensure_dir(&test_dir).unwrap();
1752 assert!(test_dir.exists());
1753 assert!(test_dir.is_dir());
1754 }
1755
1756 #[test]
1757 fn test_normalize_path() {
1758 let path = Path::new("/foo/./bar/../baz");
1759 let normalized = normalize_path(path);
1760 assert_eq!(normalized, PathBuf::from("/foo/baz"));
1761 }
1762
1763 #[test]
1764 fn test_is_safe_path() {
1765 let base = Path::new("/home/user/project");
1766
1767 assert!(is_safe_path(base, Path::new("subdir/file.txt")));
1768 assert!(is_safe_path(base, Path::new("./subdir/file.txt")));
1769 assert!(!is_safe_path(base, Path::new("../other/file.txt")));
1770 assert!(!is_safe_path(base, Path::new("/etc/passwd")));
1771 }
1772
1773 #[test]
1774 fn test_safe_write() {
1775 let temp = tempdir().unwrap();
1776 let file_path = temp.path().join("test.txt");
1777
1778 safe_write(&file_path, "test content").unwrap();
1779
1780 let content = std::fs::read_to_string(&file_path).unwrap();
1781 assert_eq!(content, "test content");
1782 }
1783
1784 #[test]
1785 fn test_safe_write_creates_parent_dirs() {
1786 let temp = tempdir().unwrap();
1787 let file_path = temp.path().join("subdir").join("test.txt");
1788
1789 safe_write(&file_path, "test content").unwrap();
1790
1791 assert!(file_path.exists());
1792 let content = std::fs::read_to_string(&file_path).unwrap();
1793 assert_eq!(content, "test content");
1794 }
1795
1796 #[test]
1797 fn test_copy_dir() {
1798 let temp = tempdir().unwrap();
1799 let src = temp.path().join("src");
1800 let dst = temp.path().join("dst");
1801
1802 // Create source structure
1803 ensure_dir(&src).unwrap();
1804 ensure_dir(&src.join("subdir")).unwrap();
1805 std::fs::write(src.join("file1.txt"), "content1").unwrap();
1806 std::fs::write(src.join("subdir/file2.txt"), "content2").unwrap();
1807
1808 // Copy directory
1809 copy_dir(&src, &dst).unwrap();
1810
1811 // Verify copy
1812 assert!(dst.join("file1.txt").exists());
1813 assert!(dst.join("subdir/file2.txt").exists());
1814
1815 let content1 = std::fs::read_to_string(dst.join("file1.txt")).unwrap();
1816 assert_eq!(content1, "content1");
1817
1818 let content2 = std::fs::read_to_string(dst.join("subdir/file2.txt")).unwrap();
1819 assert_eq!(content2, "content2");
1820 }
1821
1822 #[test]
1823 fn test_remove_dir_all() {
1824 let temp = tempdir().unwrap();
1825 let dir = temp.path().join("to_remove");
1826
1827 ensure_dir(&dir).unwrap();
1828 std::fs::write(dir.join("file.txt"), "content").unwrap();
1829
1830 assert!(dir.exists());
1831 remove_dir_all(&dir).unwrap();
1832 assert!(!dir.exists());
1833 }
1834
1835 #[test]
1836 fn test_remove_dir_all_nonexistent() {
1837 let temp = tempdir().unwrap();
1838 let dir = temp.path().join("nonexistent");
1839
1840 // Should not error on non-existent directory
1841 remove_dir_all(&dir).unwrap();
1842 }
1843
1844 #[test]
1845 fn test_find_files() {
1846 let temp = tempdir().unwrap();
1847 let root = temp.path();
1848
1849 // Create test files
1850 std::fs::write(root.join("test.rs"), "").unwrap();
1851 std::fs::write(root.join("main.rs"), "").unwrap();
1852 ensure_dir(&root.join("src")).unwrap();
1853 std::fs::write(root.join("src/lib.rs"), "").unwrap();
1854 std::fs::write(root.join("src/test.txt"), "").unwrap();
1855
1856 let files = find_files(root, ".rs").unwrap();
1857 assert_eq!(files.len(), 3);
1858
1859 let files = find_files(root, "test").unwrap();
1860 assert_eq!(files.len(), 2);
1861 }
1862
1863 #[test]
1864 fn test_dir_size() {
1865 let temp = tempdir().unwrap();
1866 let dir = temp.path();
1867
1868 std::fs::write(dir.join("file1.txt"), "12345").unwrap();
1869 std::fs::write(dir.join("file2.txt"), "123456789").unwrap();
1870 ensure_dir(&dir.join("subdir")).unwrap();
1871 std::fs::write(dir.join("subdir/file3.txt"), "abc").unwrap();
1872
1873 let size = dir_size(dir).unwrap();
1874 assert_eq!(size, 17); // 5 + 9 + 3
1875 }
1876
1877 #[test]
1878 fn test_temp_dir() {
1879 let temp_dir = TempDir::new("test").unwrap();
1880 let path = temp_dir.path().to_path_buf();
1881
1882 assert!(path.exists());
1883 assert!(path.is_dir());
1884
1885 // Write a file to verify it's a real directory
1886 std::fs::write(path.join("test.txt"), "test").unwrap();
1887 assert!(path.join("test.txt").exists());
1888
1889 drop(temp_dir);
1890 // Directory should be cleaned up
1891 assert!(!path.exists());
1892 }
1893
1894 #[test]
1895 fn test_ensure_parent_dir() {
1896 let temp = tempdir().unwrap();
1897 let file_path = temp.path().join("parent").join("child").join("file.txt");
1898
1899 ensure_parent_dir(&file_path).unwrap();
1900 assert!(file_path.parent().unwrap().exists());
1901 }
1902
1903 #[test]
1904 fn test_ensure_dir_exists() {
1905 let temp = tempdir().unwrap();
1906 let test_dir = temp.path().join("test_dir_alias");
1907
1908 assert!(!test_dir.exists());
1909 ensure_dir_exists(&test_dir).unwrap();
1910 assert!(test_dir.exists());
1911 }
1912
1913 #[test]
1914 fn test_copy_dir_all() {
1915 let temp = tempdir().unwrap();
1916 let src = temp.path().join("src_alias");
1917 let dst = temp.path().join("dst_alias");
1918
1919 ensure_dir(&src).unwrap();
1920 std::fs::write(src.join("file.txt"), "content").unwrap();
1921
1922 copy_dir_all(&src, &dst).unwrap();
1923 assert!(dst.join("file.txt").exists());
1924 }
1925
1926 #[test]
1927 fn test_find_project_root() {
1928 let temp = tempdir().unwrap();
1929 let project = temp.path().join("project");
1930 let subdir = project.join("src").join("subdir");
1931
1932 ensure_dir(&subdir).unwrap();
1933 std::fs::write(project.join("agpm.toml"), "[sources]").unwrap();
1934
1935 let root = find_project_root(&subdir).unwrap();
1936 assert_eq!(root.canonicalize().unwrap(), project.canonicalize().unwrap());
1937 }
1938
1939 #[test]
1940 fn test_find_project_root_not_found() {
1941 let temp = tempdir().unwrap();
1942 let result = find_project_root(temp.path());
1943 assert!(result.is_err());
1944 }
1945
1946 #[test]
1947 fn test_get_global_config_path() {
1948 let config_path = get_global_config_path().unwrap();
1949 assert!(config_path.to_string_lossy().contains(".config"));
1950 assert!(config_path.to_string_lossy().contains("agpm"));
1951 }
1952
1953 #[test]
1954 fn test_calculate_checksum() {
1955 let temp = tempdir().unwrap();
1956 let file = temp.path().join("checksum_test.txt");
1957 std::fs::write(&file, "test content").unwrap();
1958
1959 let checksum = calculate_checksum(&file).unwrap();
1960 assert!(!checksum.is_empty());
1961 assert_eq!(checksum.len(), 64); // SHA256 produces 64 hex chars
1962 }
1963
1964 #[tokio::test]
1965 async fn test_calculate_checksums_parallel() {
1966 let temp = tempdir().unwrap();
1967 let file1 = temp.path().join("file1.txt");
1968 let file2 = temp.path().join("file2.txt");
1969
1970 std::fs::write(&file1, "content1").unwrap();
1971 std::fs::write(&file2, "content2").unwrap();
1972
1973 let paths = vec![file1.clone(), file2.clone()];
1974 let results = calculate_checksums_parallel(&paths).await.unwrap();
1975
1976 assert_eq!(results.len(), 2);
1977 assert_eq!(results[0].0, file1);
1978 assert_eq!(results[1].0, file2);
1979 assert!(!results[0].1.is_empty());
1980 assert!(!results[1].1.is_empty());
1981 }
1982
1983 #[tokio::test]
1984 async fn test_calculate_checksums_parallel_empty() {
1985 let results = calculate_checksums_parallel(&[]).await.unwrap();
1986 assert!(results.is_empty());
1987 }
1988
1989 #[tokio::test]
1990 async fn test_copy_files_parallel() {
1991 let temp = tempdir().unwrap();
1992 let src1 = temp.path().join("src1.txt");
1993 let src2 = temp.path().join("src2.txt");
1994 let dst1 = temp.path().join("dst").join("dst1.txt");
1995 let dst2 = temp.path().join("dst").join("dst2.txt");
1996
1997 std::fs::write(&src1, "content1").unwrap();
1998 std::fs::write(&src2, "content2").unwrap();
1999
2000 let pairs = vec![(src1.clone(), dst1.clone()), (src2.clone(), dst2.clone())];
2001 copy_files_parallel(&pairs).await.unwrap();
2002
2003 assert!(dst1.exists());
2004 assert!(dst2.exists());
2005 assert_eq!(std::fs::read_to_string(&dst1).unwrap(), "content1");
2006 assert_eq!(std::fs::read_to_string(&dst2).unwrap(), "content2");
2007 }
2008
2009 #[tokio::test]
2010 async fn test_atomic_write_multiple() {
2011 let temp = tempdir().unwrap();
2012 let file1 = temp.path().join("atomic1.txt");
2013 let file2 = temp.path().join("atomic2.txt");
2014
2015 let files =
2016 vec![(file1.clone(), b"content1".to_vec()), (file2.clone(), b"content2".to_vec())];
2017
2018 atomic_write_multiple(&files).await.unwrap();
2019
2020 assert!(file1.exists());
2021 assert!(file2.exists());
2022 assert_eq!(std::fs::read_to_string(&file1).unwrap(), "content1");
2023 assert_eq!(std::fs::read_to_string(&file2).unwrap(), "content2");
2024 }
2025
2026 #[tokio::test]
2027 async fn test_copy_dirs_parallel() {
2028 let temp = tempdir().unwrap();
2029 let src1 = temp.path().join("src1");
2030 let src2 = temp.path().join("src2");
2031 let dst1 = temp.path().join("dst1");
2032 let dst2 = temp.path().join("dst2");
2033
2034 ensure_dir(&src1).unwrap();
2035 ensure_dir(&src2).unwrap();
2036 std::fs::write(src1.join("file1.txt"), "content1").unwrap();
2037 std::fs::write(src2.join("file2.txt"), "content2").unwrap();
2038
2039 let pairs = vec![(src1.clone(), dst1.clone()), (src2.clone(), dst2.clone())];
2040 copy_dirs_parallel(&pairs).await.unwrap();
2041
2042 assert!(dst1.join("file1.txt").exists());
2043 assert!(dst2.join("file2.txt").exists());
2044 }
2045
2046 #[tokio::test]
2047 async fn test_read_files_parallel() {
2048 let temp = tempdir().unwrap();
2049 let file1 = temp.path().join("read1.txt");
2050 let file2 = temp.path().join("read2.txt");
2051
2052 std::fs::write(&file1, "content1").unwrap();
2053 std::fs::write(&file2, "content2").unwrap();
2054
2055 let paths = vec![file1.clone(), file2.clone()];
2056 let results = read_files_parallel(&paths).await.unwrap();
2057
2058 assert_eq!(results.len(), 2);
2059 assert_eq!(results[0].0, file1);
2060 assert_eq!(results[0].1, "content1");
2061 assert_eq!(results[1].0, file2);
2062 assert_eq!(results[1].1, "content2");
2063 }
2064
2065 #[test]
2066 fn test_ensure_dir_on_file() {
2067 let temp = tempdir().unwrap();
2068 let file_path = temp.path().join("file.txt");
2069 std::fs::write(&file_path, "content").unwrap();
2070
2071 let result = ensure_dir(&file_path);
2072 assert!(result.is_err());
2073 }
2074
2075 #[tokio::test]
2076 async fn test_parallel_operations_empty() {
2077 // Test parallel operations with empty inputs
2078 let result = calculate_checksums_parallel(&[]).await;
2079 assert!(result.is_ok());
2080 assert!(result.unwrap().is_empty());
2081
2082 let result = copy_files_parallel(&[]).await;
2083 assert!(result.is_ok());
2084
2085 let result = atomic_write_multiple(&[]).await;
2086 assert!(result.is_ok());
2087
2088 let result = copy_dirs_parallel(&[]).await;
2089 assert!(result.is_ok());
2090
2091 let result = read_files_parallel(&[]).await;
2092 assert!(result.is_ok());
2093 assert!(result.unwrap().is_empty());
2094 }
2095
2096 #[test]
2097 fn test_atomic_write_basic() {
2098 let temp = tempdir().unwrap();
2099 let file = temp.path().join("atomic.txt");
2100
2101 atomic_write(&file, b"test content").unwrap();
2102 assert_eq!(std::fs::read_to_string(&file).unwrap(), "test content");
2103 }
2104
2105 #[test]
2106 fn test_atomic_write_overwrites() {
2107 let temp = tempdir().unwrap();
2108 let file = temp.path().join("atomic.txt");
2109
2110 // Write initial content
2111 atomic_write(&file, b"initial").unwrap();
2112 assert_eq!(std::fs::read_to_string(&file).unwrap(), "initial");
2113
2114 // Overwrite
2115 atomic_write(&file, b"updated").unwrap();
2116 assert_eq!(std::fs::read_to_string(&file).unwrap(), "updated");
2117 }
2118
2119 #[test]
2120 fn test_atomic_write_creates_parent() {
2121 let temp = tempdir().unwrap();
2122 let file = temp.path().join("deep").join("nested").join("atomic.txt");
2123
2124 atomic_write(&file, b"nested content").unwrap();
2125 assert!(file.exists());
2126 assert_eq!(std::fs::read_to_string(&file).unwrap(), "nested content");
2127 }
2128
2129 #[test]
2130 fn test_safe_copy_file() {
2131 let temp = tempdir().unwrap();
2132 let src = temp.path().join("source.txt");
2133 let dst = temp.path().join("dest.txt");
2134
2135 std::fs::write(&src, "test content").unwrap();
2136 std::fs::copy(&src, &dst).unwrap();
2137
2138 assert_eq!(std::fs::read_to_string(&dst).unwrap(), "test content");
2139 }
2140
2141 #[test]
2142 fn test_copy_with_parent_creation() {
2143 let temp = tempdir().unwrap();
2144 let src = temp.path().join("source.txt");
2145 let dst = temp.path().join("subdir").join("dest.txt");
2146
2147 std::fs::write(&src, "test content").unwrap();
2148 ensure_parent_dir(&dst).unwrap();
2149 std::fs::copy(&src, &dst).unwrap();
2150
2151 assert!(dst.exists());
2152 assert_eq!(std::fs::read_to_string(&dst).unwrap(), "test content");
2153 }
2154
2155 #[test]
2156 fn test_copy_nonexistent_source() {
2157 let temp = tempdir().unwrap();
2158 let src = temp.path().join("nonexistent.txt");
2159 let dst = temp.path().join("dest.txt");
2160
2161 let result = std::fs::copy(&src, &dst);
2162 assert!(result.is_err());
2163 }
2164
2165 #[test]
2166 fn test_normalize_path_complex() {
2167 // Test various path normalization scenarios
2168 assert_eq!(normalize_path(Path::new("/")), PathBuf::from("/"));
2169 assert_eq!(normalize_path(Path::new("/foo/bar")), PathBuf::from("/foo/bar"));
2170 assert_eq!(normalize_path(Path::new("/foo/./bar")), PathBuf::from("/foo/bar"));
2171 assert_eq!(normalize_path(Path::new("/foo/../bar")), PathBuf::from("/bar"));
2172 assert_eq!(normalize_path(Path::new("/foo/bar/..")), PathBuf::from("/foo"));
2173 assert_eq!(normalize_path(Path::new("foo/./bar")), PathBuf::from("foo/bar"));
2174 assert_eq!(normalize_path(Path::new("./foo/bar")), PathBuf::from("foo/bar"));
2175 }
2176
2177 #[test]
2178 fn test_is_safe_path_edge_cases() {
2179 let base = Path::new("/home/user/project");
2180
2181 // Safe paths
2182 assert!(is_safe_path(base, Path::new("")));
2183 assert!(is_safe_path(base, Path::new(".")));
2184 assert!(is_safe_path(base, Path::new("./nested/./path")));
2185
2186 // Unsafe paths
2187 assert!(!is_safe_path(base, Path::new("..")));
2188 assert!(!is_safe_path(base, Path::new("../../etc")));
2189 assert!(!is_safe_path(base, Path::new("/absolute/path")));
2190
2191 // Windows-style paths (on Unix these are relative)
2192 if cfg!(windows) {
2193 assert!(!is_safe_path(base, Path::new("C:\\Windows")));
2194 }
2195 }
2196
2197 #[test]
2198 fn test_safe_write_readonly_parent() {
2199 // This test verifies behavior when parent dir is readonly
2200 // We skip it in CI as it requires special permissions
2201 if std::env::var("CI").is_ok() {
2202 return;
2203 }
2204
2205 let temp = tempdir().unwrap();
2206 let readonly_dir = temp.path().join("readonly");
2207 ensure_dir(&readonly_dir).unwrap();
2208
2209 // Make directory readonly (Unix-specific)
2210 #[cfg(unix)]
2211 {
2212 use std::os::unix::fs::PermissionsExt;
2213 let mut perms = std::fs::metadata(&readonly_dir).unwrap().permissions();
2214 perms.set_mode(0o555); // r-xr-xr-x
2215 std::fs::set_permissions(&readonly_dir, perms).unwrap();
2216
2217 let file = readonly_dir.join("test.txt");
2218 let result = safe_write(&file, "test");
2219 assert!(result.is_err());
2220
2221 // Restore permissions for cleanup
2222 let mut perms = std::fs::metadata(&readonly_dir).unwrap().permissions();
2223 perms.set_mode(0o755);
2224 std::fs::set_permissions(&readonly_dir, perms).unwrap();
2225 }
2226 }
2227
2228 #[test]
2229 #[cfg(unix)]
2230 fn test_remove_dir_all_symlink() {
2231 // Test that remove_dir_all doesn't follow symlinks
2232 let temp = tempdir().unwrap();
2233 let target = temp.path().join("target");
2234 let link = temp.path().join("link");
2235
2236 ensure_dir(&target).unwrap();
2237 std::fs::write(target.join("important.txt"), "data").unwrap();
2238
2239 std::os::unix::fs::symlink(&target, &link).unwrap();
2240 remove_dir_all(&link).unwrap();
2241
2242 // Target should still exist
2243 assert!(target.exists());
2244 assert!(target.join("important.txt").exists());
2245 }
2246
2247 #[test]
2248 fn test_find_files_with_patterns() {
2249 let temp = tempdir().unwrap();
2250 let root = temp.path();
2251
2252 // Create test files
2253 std::fs::write(root.join("README.md"), "").unwrap();
2254 std::fs::write(root.join("test.MD"), "").unwrap(); // Different case
2255 std::fs::write(root.join("file.txt"), "").unwrap();
2256 ensure_dir(&root.join("hidden")).unwrap();
2257 std::fs::write(root.join("hidden/.secret.md"), "").unwrap();
2258
2259 // Pattern matching
2260 let files = find_files(root, ".md").unwrap();
2261 assert_eq!(files.len(), 2); // README.md and .secret.md
2262
2263 let files = find_files(root, ".MD").unwrap();
2264 assert_eq!(files.len(), 1); // test.MD
2265
2266 // Substring matching
2267 let files = find_files(root, "test").unwrap();
2268 assert_eq!(files.len(), 1);
2269
2270 let files = find_files(root, "secret").unwrap();
2271 assert_eq!(files.len(), 1);
2272 }
2273
2274 #[test]
2275 fn test_dir_size_edge_cases() {
2276 let temp = tempdir().unwrap();
2277
2278 // Empty directory
2279 let empty_dir = temp.path().join("empty");
2280 ensure_dir(&empty_dir).unwrap();
2281 assert_eq!(dir_size(&empty_dir).unwrap(), 0);
2282
2283 // Non-existent directory
2284 let nonexistent = temp.path().join("nonexistent");
2285 let result = dir_size(&nonexistent);
2286 assert!(result.is_err());
2287
2288 // Directory with symlinks
2289 #[cfg(unix)]
2290 {
2291 let dir = temp.path().join("with_symlink");
2292 ensure_dir(&dir).unwrap();
2293 std::fs::write(dir.join("file.txt"), "12345").unwrap();
2294
2295 let target = temp.path().join("target");
2296 std::fs::write(&target, "123456789").unwrap();
2297 std::os::unix::fs::symlink(&target, dir.join("link")).unwrap();
2298
2299 // The dir_size function behavior with symlinks depends on the implementation
2300 // Just verify it doesn't crash and returns a reasonable size
2301 let size = dir_size(&dir).unwrap();
2302 // We should have at least the size of the real file
2303 assert!(size >= 5);
2304 // The size should be reasonable (not gigabytes)
2305 assert!(size < 1_000_000);
2306 }
2307 }
2308
2309 #[test]
2310 fn test_temp_dir_custom_prefix() {
2311 let temp1 = TempDir::new("prefix1").unwrap();
2312 let temp2 = TempDir::new("prefix2").unwrap();
2313
2314 assert!(temp1.path().to_string_lossy().contains("prefix1"));
2315 assert!(temp2.path().to_string_lossy().contains("prefix2"));
2316
2317 let path1 = temp1.path().to_path_buf();
2318 let path2 = temp2.path().to_path_buf();
2319
2320 assert_ne!(path1, path2);
2321 assert!(path1.exists());
2322 assert!(path2.exists());
2323 }
2324
2325 #[test]
2326 fn test_ensure_parent_dir_edge_cases() {
2327 let temp = tempdir().unwrap();
2328
2329 // File at root (no parent)
2330 let root_file = if cfg!(windows) {
2331 PathBuf::from("C:\\file.txt")
2332 } else {
2333 PathBuf::from("/file.txt")
2334 };
2335 ensure_parent_dir(&root_file).unwrap(); // Should not panic
2336
2337 // Current directory file
2338 let current_file = PathBuf::from("file.txt");
2339 ensure_parent_dir(¤t_file).unwrap();
2340
2341 // Already existing parent
2342 let existing = temp.path().join("file.txt");
2343 ensure_parent_dir(&existing).unwrap();
2344 ensure_parent_dir(&existing).unwrap(); // Second call should be ok
2345 }
2346
2347 #[test]
2348 fn test_calculate_checksum_edge_cases() {
2349 let temp = tempdir().unwrap();
2350
2351 // Empty file
2352 let empty = temp.path().join("empty.txt");
2353 std::fs::write(&empty, "").unwrap();
2354 let checksum = calculate_checksum(&empty).unwrap();
2355 assert_eq!(checksum.len(), 64);
2356 // SHA256 of empty string is well-known
2357 assert_eq!(checksum, "e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855");
2358
2359 // Non-existent file
2360 let nonexistent = temp.path().join("nonexistent.txt");
2361 let result = calculate_checksum(&nonexistent);
2362 assert!(result.is_err());
2363
2364 // Large file (1MB)
2365 let large = temp.path().join("large.txt");
2366 let large_content = vec![b'a'; 1024 * 1024];
2367 std::fs::write(&large, &large_content).unwrap();
2368 let checksum = calculate_checksum(&large).unwrap();
2369 assert_eq!(checksum.len(), 64);
2370 }
2371
2372 #[tokio::test]
2373 async fn test_calculate_checksums_parallel_errors() {
2374 let temp = tempdir().unwrap();
2375 let valid = temp.path().join("valid.txt");
2376 let invalid = temp.path().join("invalid.txt");
2377
2378 std::fs::write(&valid, "content").unwrap();
2379
2380 let paths = vec![valid.clone(), invalid.clone()];
2381 let result = calculate_checksums_parallel(&paths).await;
2382
2383 // Should fail if any file is invalid
2384 assert!(result.is_err());
2385 }
2386
2387 #[tokio::test]
2388 async fn test_copy_files_parallel_errors() {
2389 let temp = tempdir().unwrap();
2390 let src = temp.path().join("nonexistent.txt");
2391 let dst = temp.path().join("dest.txt");
2392
2393 let pairs = vec![(src, dst)];
2394 let result = copy_files_parallel(&pairs).await;
2395
2396 assert!(result.is_err());
2397 }
2398
2399 #[tokio::test]
2400 async fn test_atomic_write_multiple_partial_failure() {
2401 // Test behavior when some writes might fail
2402 let temp = tempdir().unwrap();
2403 let valid_path = temp.path().join("valid.txt");
2404
2405 // Use an invalid path that will cause write to fail
2406 // Create a file and try to use it as a directory
2407 let invalid_base = temp.path().join("not_a_directory.txt");
2408 std::fs::write(&invalid_base, "this is a file").unwrap();
2409 let invalid_path = invalid_base.join("impossible_file.txt");
2410
2411 let files =
2412 vec![(valid_path.clone(), b"content".to_vec()), (invalid_path, b"fail".to_vec())];
2413
2414 let result = atomic_write_multiple(&files).await;
2415 assert!(result.is_err());
2416 }
2417
2418 #[tokio::test]
2419 async fn test_read_files_parallel_mixed() {
2420 let temp = tempdir().unwrap();
2421 let valid = temp.path().join("valid.txt");
2422 let invalid = temp.path().join("invalid.txt");
2423
2424 std::fs::write(&valid, "content").unwrap();
2425
2426 let paths = vec![valid, invalid];
2427 let result = read_files_parallel(&paths).await;
2428
2429 // Should fail if any file cannot be read
2430 assert!(result.is_err());
2431 }
2432
2433 #[tokio::test]
2434 async fn test_copy_dirs_parallel_errors() {
2435 let temp = tempdir().unwrap();
2436 let src = temp.path().join("nonexistent");
2437 let dst = temp.path().join("dest");
2438
2439 let pairs = vec![(src, dst)];
2440 let result = copy_dirs_parallel(&pairs).await;
2441
2442 assert!(result.is_err());
2443 }
2444
2445 #[test]
2446 fn test_copy_dir_with_permissions() {
2447 let temp = tempdir().unwrap();
2448 let src = temp.path().join("src");
2449 let dst = temp.path().join("dst");
2450
2451 ensure_dir(&src).unwrap();
2452 std::fs::write(src.join("file.txt"), "content").unwrap();
2453
2454 // Set specific permissions on Unix
2455 #[cfg(unix)]
2456 {
2457 use std::os::unix::fs::PermissionsExt;
2458 let mut perms = std::fs::metadata(src.join("file.txt")).unwrap().permissions();
2459 perms.set_mode(0o644);
2460 std::fs::set_permissions(src.join("file.txt"), perms).unwrap();
2461 }
2462
2463 copy_dir(&src, &dst).unwrap();
2464
2465 assert!(dst.join("file.txt").exists());
2466
2467 // Verify permissions were preserved on Unix
2468 #[cfg(unix)]
2469 {
2470 use std::os::unix::fs::PermissionsExt;
2471 let perms = std::fs::metadata(dst.join("file.txt")).unwrap().permissions();
2472 assert_eq!(perms.mode() & 0o777, 0o644);
2473 }
2474 }
2475
2476 #[test]
2477 fn test_find_project_root_multiple_markers() {
2478 let temp = tempdir().unwrap();
2479 let root = temp.path().join("project");
2480 let subproject = root.join("subproject");
2481 let deep = subproject.join("src");
2482
2483 ensure_dir(&deep).unwrap();
2484 std::fs::write(root.join("agpm.toml"), "[sources]").unwrap();
2485 std::fs::write(subproject.join("agpm.toml"), "[sources]").unwrap();
2486
2487 // Should find the closest agpm.toml
2488 let found = find_project_root(&deep).unwrap();
2489 assert_eq!(found.canonicalize().unwrap(), subproject.canonicalize().unwrap());
2490 }
2491
2492 #[test]
2493 fn test_get_cache_dir_from_config() {
2494 // Test that we can get a cache directory (using the config module)
2495 // Without setting env vars (to avoid race conditions in parallel tests)
2496 let cache_dir = crate::config::get_cache_dir().unwrap();
2497
2498 // The cache directory should be a valid non-empty path
2499 assert!(!cache_dir.as_os_str().is_empty());
2500
2501 // When no env var is set, it should contain "agpm" in its path
2502 // (unless AGPM_CACHE_DIR is already set in the test environment)
2503 if std::env::var("AGPM_CACHE_DIR").is_err() {
2504 assert!(cache_dir.to_string_lossy().contains("agpm"));
2505 }
2506 }
2507}