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