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