agpm_cli/utils/fs/
dirs.rs

1//! Directory operations for creating, copying, and removing directories.
2//!
3//! This module provides cross-platform directory operations with proper
4//! error handling and Windows long path support.
5
6use anyhow::{Context, Result};
7use std::fs;
8use std::path::Path;
9
10/// Ensures a directory exists, creating it and all parent directories if necessary.
11///
12/// This function is cross-platform and handles:
13/// - Windows long paths (>260 characters) automatically
14/// - Permission errors with helpful error messages
15/// - Existing files at the target path (returns error)
16///
17/// # Arguments
18///
19/// * `path` - The directory path to create
20///
21/// # Returns
22///
23/// - `Ok(())` if the directory exists or was successfully created
24/// - `Err` if the path exists but is not a directory, or creation fails
25///
26/// # Examples
27///
28/// ```rust,no_run
29/// use agpm_cli::utils::fs::ensure_dir;
30/// use std::path::Path;
31///
32/// # fn example() -> anyhow::Result<()> {
33/// // Create nested directories
34/// ensure_dir(Path::new("output/agents/subdir"))?;
35/// # Ok(())
36/// # }
37/// ```
38///
39/// # Platform Notes
40///
41/// - **Windows**: Automatically handles long paths and provides specific error guidance
42/// - **Unix**: Respects umask for directory permissions
43/// - **All platforms**: Creates parent directories recursively
44pub fn ensure_dir(path: &Path) -> Result<()> {
45    // Handle Windows long paths
46    let safe_path = crate::utils::platform::windows_long_path(path);
47
48    if !safe_path.exists() {
49        fs::create_dir_all(&safe_path).with_context(|| {
50            let platform_help = if crate::utils::platform::is_windows() {
51                "On Windows: Check that the path length is < 260 chars or that long path support is enabled"
52            } else {
53                "Check directory permissions and path validity"
54            };
55
56            format!("Failed to create directory: {}\n\n{}", path.display(), platform_help)
57        })?;
58    } else if !safe_path.is_dir() {
59        return Err(anyhow::anyhow!("Path exists but is not a directory: {}", path.display()));
60    }
61    Ok(())
62}
63
64/// Ensures that the parent directory of a file path exists.
65///
66/// This is a convenience function for creating the directory structure needed
67/// for a file before writing to it. It extracts the parent directory from the
68/// file path and ensures it exists.
69///
70/// # Arguments
71///
72/// * `path` - The file path whose parent directory should exist
73///
74/// # Returns
75///
76/// - `Ok(())` if the parent directory exists or was created successfully
77/// - `Err` if directory creation fails
78/// - `Ok(())` if the path has no parent (e.g., root level files)
79///
80/// # Examples
81///
82/// ```rust,no_run
83/// use agpm_cli::utils::fs::ensure_parent_dir;
84/// use std::path::Path;
85///
86/// # fn example() -> anyhow::Result<()> {
87/// // Ensure directory structure exists before writing file
88/// ensure_parent_dir(Path::new("output/agents/example.md"))?;
89/// std::fs::write("output/agents/example.md", "# Example Agent")?;
90/// # Ok(())
91/// # }
92/// ```
93///
94/// # Use Cases
95///
96/// - Preparing directory structure before file operations
97/// - Ensuring atomic writes have proper directory structure
98/// - Setting up output paths in batch processing
99///
100/// # See Also
101///
102/// - [`ensure_dir`] for creating a specific directory
103/// - [`crate::utils::fs::atomic_write`] which calls this internally
104pub fn ensure_parent_dir(path: &Path) -> Result<()> {
105    if let Some(parent) = path.parent() {
106        ensure_dir(parent)?;
107    }
108    Ok(())
109}
110
111/// Alias for `ensure_dir` for consistency
112pub fn ensure_dir_exists(path: &Path) -> Result<()> {
113    ensure_dir(path)
114}
115
116/// Recursively copies a directory and all its contents to a new location.
117///
118/// This function performs a deep copy of all files and subdirectories from the source
119/// to the destination. It creates the destination directory if it doesn't exist and
120/// preserves the directory structure.
121///
122/// # Arguments
123///
124/// * `src` - The source directory to copy from
125/// * `dst` - The destination directory to copy to
126///
127/// # Returns
128///
129/// - `Ok(())` if the directory was copied successfully
130/// - `Err` if the copy operation fails for any file or directory
131///
132/// # Examples
133///
134/// ```rust,no_run
135/// use agpm_cli::utils::fs::copy_dir;
136/// use std::path::Path;
137///
138/// # fn example() -> anyhow::Result<()> {
139/// // Copy entire agent directory
140/// copy_dir(Path::new("cache/agents"), Path::new("output/agents"))?;
141/// # Ok(())
142/// # }
143/// ```
144///
145/// # Behavior
146///
147/// - Creates destination directory if it doesn't exist
148/// - Recursively copies all subdirectories
149/// - Copies only regular files (skips symlinks and special files)
150/// - Overwrites existing files in the destination
151///
152/// # Platform Notes
153///
154/// - **Windows**: Handles long paths and preserves attributes
155/// - **Unix**: Preserves file permissions during copy
156/// - **All platforms**: Does not follow symbolic links
157///
158/// # See Also
159///
160/// - [`crate::utils::fs::copy_dirs_parallel`] for copying multiple directories concurrently
161/// - [`crate::utils::fs::copy_files_parallel`] for batch file copying
162pub fn copy_dir(src: &Path, dst: &Path) -> Result<()> {
163    ensure_dir(dst)?;
164
165    for entry in
166        fs::read_dir(src).with_context(|| format!("Failed to read directory: {}", src.display()))?
167    {
168        let entry = entry?;
169        let file_type = entry.file_type()?;
170        let src_path = entry.path();
171        let dst_path = dst.join(entry.file_name());
172
173        if file_type.is_dir() {
174            copy_dir(&src_path, &dst_path)?;
175        } else if file_type.is_file() {
176            fs::copy(&src_path, &dst_path).with_context(|| {
177                format!("Failed to copy file from {} to {}", src_path.display(), dst_path.display())
178            })?;
179        }
180        // Skip symlinks and other file types
181    }
182
183    Ok(())
184}
185
186/// Copy a directory recursively (alias for consistency)
187pub fn copy_dir_all(src: &Path, dst: &Path) -> Result<()> {
188    copy_dir(src, dst)
189}
190
191/// Recursively removes a directory and all its contents.
192///
193/// This function safely removes a directory tree, handling the case where the
194/// directory doesn't exist (no error). It's designed to be safe for cleanup
195/// operations where the directory may or may not exist.
196///
197/// # Arguments
198///
199/// * `path` - The directory to remove
200///
201/// # Returns
202///
203/// - `Ok(())` if the directory was removed or didn't exist
204/// - `Err` if the removal failed due to permissions or other filesystem errors
205///
206/// # Examples
207///
208/// ```rust,no_run
209/// use agpm_cli::utils::fs::remove_dir_all;
210/// use std::path::Path;
211///
212/// # fn example() -> anyhow::Result<()> {
213/// // Safe cleanup - won't error if directory doesn't exist
214/// remove_dir_all(Path::new("temp/cache"))?;
215/// # Ok(())
216/// # }
217/// ```
218///
219/// # Safety
220///
221/// - Does not follow symbolic links outside the directory tree
222/// - Handles permission errors with descriptive messages
223/// - Safe to call on non-existent directories
224///
225/// # Platform Notes
226///
227/// - **Windows**: Handles long paths and readonly files
228/// - **Unix**: Respects file permissions
229/// - **All platforms**: Atomic operation where supported by filesystem
230pub fn remove_dir_all(path: &Path) -> Result<()> {
231    if path.exists() {
232        fs::remove_dir_all(path)
233            .with_context(|| format!("Failed to remove directory: {}", path.display()))?;
234    }
235    Ok(())
236}
237
238#[cfg(test)]
239mod tests {
240    use super::*;
241    use tempfile::tempdir;
242
243    #[test]
244    fn test_ensure_dir() {
245        let temp = tempdir().unwrap();
246        let test_dir = temp.path().join("test_dir");
247
248        assert!(!test_dir.exists());
249        ensure_dir(&test_dir).unwrap();
250        assert!(test_dir.exists());
251        assert!(test_dir.is_dir());
252    }
253
254    #[test]
255    fn test_ensure_dir_on_file() {
256        let temp = tempdir().unwrap();
257        let file_path = temp.path().join("file.txt");
258        std::fs::write(&file_path, "content").unwrap();
259
260        let result = ensure_dir(&file_path);
261        assert!(result.is_err());
262    }
263
264    #[test]
265    fn test_ensure_parent_dir() {
266        let temp = tempdir().unwrap();
267        let file_path = temp.path().join("parent").join("child").join("file.txt");
268
269        ensure_parent_dir(&file_path).unwrap();
270        assert!(file_path.parent().unwrap().exists());
271    }
272
273    #[test]
274    fn test_ensure_parent_dir_edge_cases() {
275        use std::path::PathBuf;
276
277        let temp = tempdir().unwrap();
278
279        // File at root (no parent)
280        let root_file = if cfg!(windows) {
281            PathBuf::from("C:\\file.txt")
282        } else {
283            PathBuf::from("/file.txt")
284        };
285        ensure_parent_dir(&root_file).unwrap(); // Should not panic
286
287        // Current directory file
288        let current_file = PathBuf::from("file.txt");
289        ensure_parent_dir(&current_file).unwrap();
290
291        // Already existing parent
292        let existing = temp.path().join("file.txt");
293        ensure_parent_dir(&existing).unwrap();
294        ensure_parent_dir(&existing).unwrap(); // Second call should be ok
295    }
296
297    #[test]
298    fn test_ensure_dir_exists() {
299        let temp = tempdir().unwrap();
300        let test_dir = temp.path().join("test_dir_alias");
301
302        assert!(!test_dir.exists());
303        ensure_dir_exists(&test_dir).unwrap();
304        assert!(test_dir.exists());
305    }
306
307    #[test]
308    fn test_copy_dir() {
309        let temp = tempdir().unwrap();
310        let src = temp.path().join("src");
311        let dst = temp.path().join("dst");
312
313        // Create source structure
314        ensure_dir(&src).unwrap();
315        ensure_dir(&src.join("subdir")).unwrap();
316        std::fs::write(src.join("file1.txt"), "content1").unwrap();
317        std::fs::write(src.join("subdir/file2.txt"), "content2").unwrap();
318
319        // Copy directory
320        copy_dir(&src, &dst).unwrap();
321
322        // Verify copy
323        assert!(dst.join("file1.txt").exists());
324        assert!(dst.join("subdir/file2.txt").exists());
325
326        let content1 = std::fs::read_to_string(dst.join("file1.txt")).unwrap();
327        assert_eq!(content1, "content1");
328
329        let content2 = std::fs::read_to_string(dst.join("subdir/file2.txt")).unwrap();
330        assert_eq!(content2, "content2");
331    }
332
333    #[test]
334    fn test_copy_dir_all() {
335        let temp = tempdir().unwrap();
336        let src = temp.path().join("src_alias");
337        let dst = temp.path().join("dst_alias");
338
339        ensure_dir(&src).unwrap();
340        std::fs::write(src.join("file.txt"), "content").unwrap();
341
342        copy_dir_all(&src, &dst).unwrap();
343        assert!(dst.join("file.txt").exists());
344    }
345
346    #[test]
347    fn test_copy_dir_with_permissions() {
348        let temp = tempdir().unwrap();
349        let src = temp.path().join("src");
350        let dst = temp.path().join("dst");
351
352        ensure_dir(&src).unwrap();
353        std::fs::write(src.join("file.txt"), "content").unwrap();
354
355        // Set specific permissions on Unix
356        #[cfg(unix)]
357        {
358            use std::os::unix::fs::PermissionsExt;
359            let mut perms = std::fs::metadata(src.join("file.txt")).unwrap().permissions();
360            perms.set_mode(0o644);
361            std::fs::set_permissions(src.join("file.txt"), perms).unwrap();
362        }
363
364        copy_dir(&src, &dst).unwrap();
365
366        assert!(dst.join("file.txt").exists());
367
368        // Verify permissions were preserved on Unix
369        #[cfg(unix)]
370        {
371            use std::os::unix::fs::PermissionsExt;
372            let perms = std::fs::metadata(dst.join("file.txt")).unwrap().permissions();
373            assert_eq!(perms.mode() & 0o777, 0o644);
374        }
375    }
376
377    #[test]
378    fn test_remove_dir_all() {
379        let temp = tempdir().unwrap();
380        let dir = temp.path().join("to_remove");
381
382        ensure_dir(&dir).unwrap();
383        std::fs::write(dir.join("file.txt"), "content").unwrap();
384
385        assert!(dir.exists());
386        remove_dir_all(&dir).unwrap();
387        assert!(!dir.exists());
388    }
389
390    #[test]
391    fn test_remove_dir_all_nonexistent() {
392        let temp = tempdir().unwrap();
393        let dir = temp.path().join("nonexistent");
394
395        // Should not error on non-existent directory
396        remove_dir_all(&dir).unwrap();
397    }
398
399    #[test]
400    #[cfg(unix)]
401    fn test_remove_dir_all_symlink() {
402        // Test that remove_dir_all doesn't follow symlinks
403        let temp = tempdir().unwrap();
404        let target = temp.path().join("target");
405        let link = temp.path().join("link");
406
407        ensure_dir(&target).unwrap();
408        std::fs::write(target.join("important.txt"), "data").unwrap();
409
410        std::os::unix::fs::symlink(&target, &link).unwrap();
411        remove_dir_all(&link).unwrap();
412
413        // Target should still exist
414        assert!(target.exists());
415        assert!(target.join("important.txt").exists());
416    }
417}