agpm_cli/core/
file_error.rs

1//! Structured file system error handling for AGPM
2//!
3//! This module provides better error handling for file operations by capturing
4//! context at the operation site rather than parsing error messages.
5
6use std::path::{Path, PathBuf};
7use thiserror::Error;
8
9/// Large file size for testing and production use (1MB in bytes)
10pub const LARGE_FILE_SIZE: usize = 1024 * 1024;
11
12/// Detailed file operation context for better error messages
13#[derive(Debug, Clone)]
14pub struct FileOperationContext {
15    /// The type of operation being performed
16    pub operation: FileOperation,
17    /// The file path being accessed
18    pub file_path: PathBuf,
19    /// Additional context about why the file is being accessed
20    pub purpose: String,
21    /// The resource/caller that initiated the operation
22    pub caller: String,
23    /// Optional related paths (e.g., project directory)
24    pub related_paths: Vec<PathBuf>,
25}
26
27/// Types of file operations
28#[derive(Debug, Clone, PartialEq)]
29pub enum FileOperation {
30    /// Reading a file completely
31    Read,
32    /// Writing a file
33    Write,
34    /// Checking if a file exists
35    Exists,
36    /// Getting file metadata
37    Metadata,
38    /// Canonicalizing a path
39    Canonicalize,
40    /// Creating a directory
41    CreateDir,
42    /// Validating a file path (security checks)
43    Validate,
44}
45
46impl std::fmt::Display for FileOperation {
47    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
48        match self {
49            FileOperation::Read => write!(f, "reading"),
50            FileOperation::Write => write!(f, "writing"),
51            FileOperation::Exists => write!(f, "checking if file exists"),
52            FileOperation::Metadata => write!(f, "getting file metadata"),
53            FileOperation::Canonicalize => write!(f, "resolving path"),
54            FileOperation::CreateDir => write!(f, "creating directory"),
55            FileOperation::Validate => write!(f, "validating file path"),
56        }
57    }
58}
59
60impl FileOperationContext {
61    /// Create a new file operation context
62    pub fn new(
63        operation: FileOperation,
64        file_path: impl Into<PathBuf>,
65        purpose: impl Into<String>,
66        caller: impl Into<String>,
67    ) -> Self {
68        Self {
69            operation,
70            file_path: file_path.into(),
71            purpose: purpose.into(),
72            caller: caller.into(),
73            related_paths: Vec::new(),
74        }
75    }
76
77    /// Add a related path for context
78    pub fn with_related_path(mut self, path: impl Into<PathBuf>) -> Self {
79        self.related_paths.push(path.into());
80        self
81    }
82
83    /// Add multiple related paths
84    pub fn with_related_paths<I>(mut self, paths: I) -> Self
85    where
86        I: IntoIterator,
87        I::Item: Into<PathBuf>,
88    {
89        for path in paths {
90            self.related_paths.push(path.into());
91        }
92        self
93    }
94}
95
96/// Enhanced file operation error with full context
97#[derive(Error, Debug)]
98#[error("File operation failed: {operation} on {file_path}")]
99pub struct FileOperationError {
100    /// The type of operation that failed
101    pub operation: FileOperation,
102    /// The file path that was being accessed
103    pub file_path: PathBuf,
104    /// Why the file was being accessed
105    pub purpose: String,
106    /// What code initiated the operation
107    pub caller: String,
108    /// The underlying IO error
109    #[source]
110    pub source: std::io::Error,
111    /// Related paths for additional context
112    pub related_paths: Vec<PathBuf>,
113}
114
115impl FileOperationError {
116    /// Create a new file operation error from context and IO error
117    pub fn new(context: FileOperationContext, source: std::io::Error) -> Self {
118        Self {
119            operation: context.operation,
120            file_path: context.file_path,
121            purpose: context.purpose,
122            caller: context.caller,
123            source,
124            related_paths: context.related_paths,
125        }
126    }
127
128    /// Get a user-friendly error message with context
129    pub fn user_message(&self) -> String {
130        let operation_name = match self.operation {
131            FileOperation::Read => "reading",
132            FileOperation::Write => "writing",
133            FileOperation::Exists => "checking if file exists",
134            FileOperation::Metadata => "getting file metadata",
135            FileOperation::Canonicalize => "resolving path",
136            FileOperation::CreateDir => "creating directory",
137            FileOperation::Validate => "validating file path",
138        };
139
140        let mut message = format!(
141            "Failed {} file '{}' for {} ({})",
142            operation_name,
143            self.file_path.display(),
144            self.purpose,
145            self.caller
146        );
147
148        // Add specific error details
149        match self.source.kind() {
150            std::io::ErrorKind::NotFound => {
151                message.push_str("\n\nThe file does not exist at the specified path.");
152
153                // Add helpful suggestions based on file type and purpose
154                if self.file_path.extension().and_then(|s| s.to_str()) == Some("md") {
155                    message.push_str("\n\nFor markdown files, check:");
156                    message.push_str("\n- The file exists in the expected location");
157                    message.push_str("\n- The filename is spelled correctly (case-sensitive)");
158                    message.push_str(&format!(
159                        "\n- The file should be relative to: {}",
160                        self.related_paths
161                            .first()
162                            .map(|p| p.display().to_string())
163                            .unwrap_or_else(|| "project root".to_string())
164                    ));
165                }
166
167                if self.purpose.contains("template") || self.purpose.contains("render") {
168                    message.push_str("\n\nFor template errors, ensure:");
169                    message.push_str("\n- All referenced files exist");
170                    message.push_str("\n- File paths in templates are correct");
171                    message.push_str("\n- Dependencies are properly declared in frontmatter");
172                }
173            }
174            std::io::ErrorKind::PermissionDenied => {
175                message.push_str(&format!(
176                    "\n\nPermission denied. Check file/directory permissions for: {}",
177                    self.file_path.display()
178                ));
179            }
180            std::io::ErrorKind::InvalidData => {
181                message.push_str("\n\nThe file contains invalid data or encoding.");
182                if self.purpose.contains("UTF-8") || self.purpose.contains("read") {
183                    message.push_str("\nEnsure the file contains valid UTF-8 text.");
184                }
185            }
186            _ => {
187                message.push_str(&format!("\n\nError details: {}", self.source));
188            }
189        }
190
191        // Add related paths context
192        if !self.related_paths.is_empty() {
193            message.push_str("\n\nRelated paths:");
194            for path in &self.related_paths {
195                message.push_str(&format!("\n  - {}", path.display()));
196            }
197        }
198
199        message
200    }
201}
202
203/// Extension trait for Result types to add file operation context
204pub trait FileResultExt<T> {
205    /// Add file operation context to a Result
206    fn with_file_context(
207        self,
208        operation: FileOperation,
209        file_path: impl Into<PathBuf>,
210        purpose: impl Into<String>,
211        caller: impl Into<String>,
212    ) -> Result<T, FileOperationError>;
213}
214
215impl<T> FileResultExt<T> for Result<T, std::io::Error> {
216    fn with_file_context(
217        self,
218        operation: FileOperation,
219        file_path: impl Into<PathBuf>,
220        purpose: impl Into<String>,
221        caller: impl Into<String>,
222    ) -> Result<T, FileOperationError> {
223        self.map_err(|io_error| {
224            let context = FileOperationContext::new(operation, file_path, purpose, caller);
225            FileOperationError::new(context, io_error)
226        })
227    }
228}
229
230/// Convenience functions for common file operations with context
231pub struct FileOps;
232
233impl FileOps {
234    /// Read a file with full context
235    pub async fn read_with_context(
236        path: &Path,
237        purpose: &str,
238        caller: &str,
239    ) -> Result<String, FileOperationError> {
240        tokio::fs::read_to_string(path).await.with_file_context(
241            FileOperation::Read,
242            path,
243            purpose,
244            caller,
245        )
246    }
247
248    /// Check if a file exists with context
249    pub async fn exists_with_context(
250        path: &Path,
251        purpose: &str,
252        caller: &str,
253    ) -> Result<bool, FileOperationError> {
254        tokio::fs::metadata(path)
255            .await
256            .map(|_| true)
257            .or_else(|e| {
258                if e.kind() == std::io::ErrorKind::NotFound {
259                    Ok(false)
260                } else {
261                    Err(e)
262                }
263            })
264            .with_file_context(FileOperation::Exists, path, purpose, caller)
265    }
266
267    /// Get file metadata with context
268    pub async fn metadata_with_context(
269        path: &Path,
270        purpose: &str,
271        caller: &str,
272    ) -> Result<std::fs::Metadata, FileOperationError> {
273        tokio::fs::metadata(path).await.with_file_context(
274            FileOperation::Metadata,
275            path,
276            purpose,
277            caller,
278        )
279    }
280}
281
282#[cfg(test)]
283mod tests {
284    use super::*;
285    use std::io::{Error, ErrorKind};
286
287    #[test]
288    fn test_file_operation_context_creation() {
289        let context = FileOperationContext::new(
290            FileOperation::Read,
291            "/path/to/file.md",
292            "template rendering",
293            "content_filter",
294        );
295
296        assert_eq!(context.operation, FileOperation::Read);
297        assert_eq!(context.file_path, PathBuf::from("/path/to/file.md"));
298        assert_eq!(context.purpose, "template rendering");
299        assert_eq!(context.caller, "content_filter");
300    }
301
302    #[test]
303    fn test_file_operation_error_user_message() {
304        let io_error = Error::new(ErrorKind::NotFound, "file not found");
305        let context = FileOperationContext::new(
306            FileOperation::Read,
307            "docs/styleguide.md",
308            "template rendering",
309            "content_filter",
310        )
311        .with_related_path("/project/root");
312
313        let file_error = FileOperationError::new(context, io_error);
314        let message = file_error.user_message();
315
316        assert!(message.contains("Failed reading file"));
317        assert!(message.contains("docs/styleguide.md"));
318        assert!(message.contains("template rendering"));
319        assert!(message.contains("content_filter"));
320        assert!(message.contains("does not exist"));
321        assert!(message.contains("Related paths"));
322    }
323
324    #[test]
325    fn test_file_result_ext() {
326        let io_error = Error::new(ErrorKind::PermissionDenied, "access denied");
327        let result: Result<String, std::io::Error> = Err(io_error);
328
329        let enhanced_result = result.with_file_context(
330            FileOperation::Write,
331            "/tmp/test.txt",
332            "saving configuration",
333            "config_module",
334        );
335
336        assert!(enhanced_result.is_err());
337        let error = enhanced_result.unwrap_err();
338        assert_eq!(error.operation, FileOperation::Write);
339        assert_eq!(error.purpose, "saving configuration");
340        assert_eq!(error.caller, "config_module");
341    }
342
343    #[tokio::test]
344    async fn test_exists_with_context_success() {
345        let temp_dir = tempfile::tempdir().unwrap();
346        let test_file = temp_dir.path().join("test.txt");
347        std::fs::write(&test_file, "test content").unwrap();
348
349        let result =
350            FileOps::exists_with_context(&test_file, "checking if file exists", "test_module")
351                .await;
352
353        assert!(result.is_ok());
354        assert!(result.unwrap());
355    }
356
357    #[tokio::test]
358    async fn test_exists_with_context_not_found() {
359        let temp_dir = tempfile::tempdir().unwrap();
360        let nonexistent_file = temp_dir.path().join("nonexistent.txt");
361
362        let result = FileOps::exists_with_context(
363            &nonexistent_file,
364            "checking if file exists",
365            "test_module",
366        )
367        .await;
368
369        // exists_with_context returns Ok(false) for not found, not an error
370        assert!(result.is_ok());
371        assert!(!result.unwrap());
372    }
373
374    #[tokio::test]
375    async fn test_metadata_with_context_success() {
376        let temp_dir = tempfile::tempdir().unwrap();
377        let test_file = temp_dir.path().join("test.txt");
378        std::fs::write(&test_file, "test content").unwrap();
379
380        let result =
381            FileOps::metadata_with_context(&test_file, "getting file metadata", "test_module")
382                .await;
383
384        assert!(result.is_ok());
385        let metadata = result.unwrap();
386        assert!(metadata.is_file());
387    }
388
389    #[tokio::test]
390    async fn test_metadata_with_context_not_found() {
391        let temp_dir = tempfile::tempdir().unwrap();
392        let nonexistent_file = temp_dir.path().join("nonexistent.txt");
393
394        let result = FileOps::metadata_with_context(
395            &nonexistent_file,
396            "getting file metadata",
397            "test_module",
398        )
399        .await;
400
401        assert!(result.is_err());
402        let error = result.unwrap_err();
403        assert_eq!(error.operation, FileOperation::Metadata);
404        assert_eq!(error.purpose, "getting file metadata");
405        assert_eq!(error.caller, "test_module");
406    }
407
408    #[tokio::test]
409    async fn test_with_related_paths() {
410        let temp_dir = tempfile::tempdir().unwrap();
411        let main_file = temp_dir.path().join("main.md");
412
413        std::fs::write(&main_file, "# Main file").unwrap();
414
415        let result =
416            FileOps::read_with_context(&main_file, "reading main file", "test_module").await;
417
418        assert!(result.is_ok());
419        let content = result.unwrap();
420        assert_eq!(content, "# Main file");
421    }
422
423    #[test]
424    fn test_permission_denied_error() {
425        // This test simulates a permission denied error
426        let io_error =
427            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "Permission denied");
428        let context = FileOperationContext::new(
429            FileOperation::Read,
430            "/root/secret.txt",
431            "reading secret file",
432            "security_module",
433        );
434
435        let file_error = FileOperationError::new(context, io_error);
436
437        assert!(matches!(file_error.source.kind(), std::io::ErrorKind::PermissionDenied));
438        assert_eq!(file_error.operation, FileOperation::Read);
439        assert_eq!(file_error.file_path, PathBuf::from("/root/secret.txt"));
440        assert_eq!(file_error.purpose, "reading secret file");
441        assert_eq!(file_error.caller, "security_module");
442    }
443
444    #[tokio::test]
445    async fn test_invalid_utf8_handling() {
446        let temp_dir = tempfile::tempdir().unwrap();
447        let test_file = temp_dir.path().join("invalid_utf8.txt");
448
449        // Create a file with invalid UTF-8 bytes
450        let invalid_bytes = &[0xFF, 0xFE, 0xFD];
451        std::fs::write(&test_file, invalid_bytes).unwrap();
452
453        let result =
454            FileOps::read_with_context(&test_file, "reading file as string", "test_module").await;
455
456        assert!(result.is_err());
457        let error = result.unwrap_err();
458        assert_eq!(error.operation, FileOperation::Read);
459        assert_eq!(error.purpose, "reading file as string");
460        // The underlying error should be an InvalidData error from UTF-8 decoding
461        assert!(matches!(error.source.kind(), std::io::ErrorKind::InvalidData));
462    }
463
464    #[tokio::test]
465    async fn test_read_with_context_large_file() {
466        let temp_dir = tempfile::tempdir().unwrap();
467        let test_file = temp_dir.path().join("large.txt");
468
469        // Create a large file (1MB)
470        let large_content = "x".repeat(LARGE_FILE_SIZE);
471        std::fs::write(&test_file, &large_content).unwrap();
472
473        let result =
474            FileOps::read_with_context(&test_file, "reading large file", "test_module").await;
475
476        assert!(result.is_ok());
477        let read_content = result.unwrap();
478        assert_eq!(read_content.len(), large_content.len());
479    }
480
481    // Note: write_with_context does not exist in FileOps
482    // Test removed - FileOps only provides read, exists, and metadata operations
483}