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(¤t_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}