Skip to main content

gravityfile_core/
error.rs

1//! Error types for scanning operations.
2
3use std::fmt;
4use std::path::PathBuf;
5
6use serde::{Deserialize, Serialize};
7use thiserror::Error;
8
9/// Errors that can occur during scanning.
10#[derive(Debug, Error, Serialize)]
11pub enum ScanError {
12    /// Permission denied for a path.
13    #[error("Permission denied: {}: {}", crate::escape_filename(.path.as_os_str()), serialize_io_error_display(source))]
14    PermissionDenied {
15        #[serde(serialize_with = "crate::path_serde::serialize")]
16        path: PathBuf,
17        #[source]
18        #[serde(serialize_with = "serialize_io_error")]
19        source: std::io::Error,
20    },
21
22    /// Path not found.
23    #[error("Path not found: {}: {}", crate::escape_filename(.path.as_os_str()), serialize_io_error_display(source))]
24    NotFound {
25        #[serde(serialize_with = "crate::path_serde::serialize")]
26        path: PathBuf,
27        #[source]
28        #[serde(serialize_with = "serialize_io_error")]
29        source: std::io::Error,
30    },
31
32    /// Generic I/O error.
33    #[error("I/O error at {}: {}", crate::escape_filename(.path.as_os_str()), serialize_io_error_display(source))]
34    Io {
35        #[serde(serialize_with = "crate::path_serde::serialize")]
36        path: PathBuf,
37        #[source]
38        #[serde(serialize_with = "serialize_io_error")]
39        source: std::io::Error,
40    },
41
42    /// Operation was interrupted.
43    #[error("Operation interrupted")]
44    Interrupted,
45
46    /// Too many errors occurred.
47    #[error("Too many errors ({count}), aborting")]
48    TooManyErrors { count: usize },
49
50    /// The configured descendant-entry budget was exhausted.
51    #[error("Scan entry limit exceeded ({limit} descendant entries)")]
52    EntryLimitExceeded { limit: u64 },
53
54    /// Invalid configuration.
55    #[error("Invalid configuration: {}", crate::escape_terminal_text(message))]
56    InvalidConfig { message: String },
57
58    /// Root path is not a directory.
59    #[error("Root path is not a directory: {}", crate::escape_filename(.path.as_os_str()))]
60    NotADirectory {
61        #[serde(serialize_with = "crate::path_serde::serialize")]
62        path: PathBuf,
63    },
64
65    /// Other error.
66    #[error("{}", crate::escape_terminal_text(message))]
67    Other { message: String },
68}
69
70fn serialize_io_error<S: serde::Serializer>(
71    error: &std::io::Error,
72    s: S,
73) -> Result<S::Ok, S::Error> {
74    s.serialize_str(&error.to_string())
75}
76
77fn serialize_io_error_display(error: &std::io::Error) -> String {
78    crate::escape_terminal_text(&error.to_string()).into_owned()
79}
80
81impl ScanError {
82    /// Create an I/O error with path context. Preserves the original `io::Error` as the source.
83    pub fn io(path: impl Into<PathBuf>, source: std::io::Error) -> Self {
84        let path = path.into();
85        match source.kind() {
86            std::io::ErrorKind::PermissionDenied => Self::PermissionDenied { path, source },
87            std::io::ErrorKind::NotFound => Self::NotFound { path, source },
88            _ => Self::Io { path, source },
89        }
90    }
91}
92
93/// Kind of scan warning.
94#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Serialize, Deserialize)]
95pub enum WarningKind {
96    /// Permission was denied.
97    PermissionDenied,
98    /// Symbolic link target does not exist.
99    BrokenSymlink,
100    /// Error reading file/directory.
101    ReadError,
102    /// Error reading metadata.
103    MetadataError,
104    /// Filesystem boundary crossed (when not allowed).
105    CrossFilesystem,
106    /// Warning details omitted by a bounded scanner accumulator.
107    Omitted { count: u64 },
108}
109
110/// Non-fatal warning encountered during scan.
111#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize, Deserialize)]
112pub struct ScanWarning {
113    /// Path where the warning occurred.
114    #[serde(with = "crate::path_serde")]
115    pub path: PathBuf,
116    /// Human-readable message.
117    pub message: String,
118    /// Kind of warning.
119    pub kind: WarningKind,
120}
121
122impl fmt::Display for ScanWarning {
123    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
124        write!(f, "{}", crate::escape_terminal_text(&self.message))
125    }
126}
127
128impl ScanWarning {
129    /// Number of observed warnings represented by this record.
130    pub fn represented_count(&self) -> u64 {
131        match self.kind {
132            WarningKind::Omitted { count } => count.max(1),
133            _ => 1,
134        }
135    }
136
137    /// Create a new scan warning.
138    pub fn new(path: impl Into<PathBuf>, kind: WarningKind, message: impl Into<String>) -> Self {
139        Self {
140            path: path.into(),
141            message: message.into(),
142            kind,
143        }
144    }
145
146    /// Create a permission denied warning.
147    pub fn permission_denied(path: impl Into<PathBuf>) -> Self {
148        let path = path.into();
149        Self {
150            message: format!(
151                "Permission denied: {}",
152                crate::escape_filename(path.as_os_str())
153            ),
154            path,
155            kind: WarningKind::PermissionDenied,
156        }
157    }
158
159    /// Create a broken symlink warning.
160    pub fn broken_symlink(path: impl Into<PathBuf>, target: &str) -> Self {
161        let path = path.into();
162        Self {
163            message: format!(
164                "Broken symlink: {} -> {}",
165                crate::escape_filename(path.as_os_str()),
166                crate::escape_filename(std::ffi::OsStr::new(target))
167            ),
168            path,
169            kind: WarningKind::BrokenSymlink,
170        }
171    }
172
173    /// Create a read error warning.
174    pub fn read_error(path: impl Into<PathBuf>, error: &std::io::Error) -> Self {
175        let path = path.into();
176        Self {
177            message: format!("Read error: {error}"),
178            path,
179            kind: WarningKind::ReadError,
180        }
181    }
182}
183
184#[cfg(test)]
185mod tests {
186    use super::*;
187
188    #[test]
189    fn displayed_errors_escape_paths_and_messages_but_preserve_sources() {
190        let path = PathBuf::from("line\n\u{1b}\\n");
191        for kind in [
192            std::io::ErrorKind::PermissionDenied,
193            std::io::ErrorKind::NotFound,
194            std::io::ErrorKind::Other,
195        ] {
196            let error = ScanError::io(&path, std::io::Error::new(kind, "bad\n\u{1b}source"));
197            let displayed = error.to_string();
198            // On Windows the backslash is a separator and is not escaped.
199            let expected = if cfg!(windows) {
200                r"line\n\u{1b}\n"
201            } else {
202                r"line\n\u{1b}\\n"
203            };
204            assert!(displayed.contains(expected), "{displayed}");
205            assert!(displayed.contains(r"bad\n\u{1b}source"));
206            assert!(!displayed.contains(['\n', '\u{1b}']));
207            assert_eq!(
208                std::error::Error::source(&error).unwrap().to_string(),
209                "bad\n\u{1b}source"
210            );
211        }
212        for error in [
213            ScanError::NotADirectory { path },
214            ScanError::InvalidConfig {
215                message: "bad\nconfig".into(),
216            },
217            ScanError::Other {
218                message: "bad\nother".into(),
219            },
220        ] {
221            assert!(!error.to_string().contains(['\n', '\u{1b}']));
222        }
223        let warning = ScanWarning::new("root", WarningKind::ReadError, "bad\nwarning");
224        assert_eq!(warning.to_string(), r"bad\nwarning");
225        assert_eq!(warning.message, "bad\nwarning");
226    }
227
228    #[test]
229    fn test_scan_error_io() {
230        let err = ScanError::io(
231            "/test/path",
232            std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied"),
233        );
234        assert!(matches!(err, ScanError::PermissionDenied { .. }));
235    }
236
237    #[test]
238    fn test_scan_error_preserves_source() {
239        let err = ScanError::io(
240            "/test/path",
241            std::io::Error::new(std::io::ErrorKind::NotFound, "not found"),
242        );
243        match err {
244            ScanError::NotFound { source, .. } => {
245                assert_eq!(source.kind(), std::io::ErrorKind::NotFound);
246            }
247            _ => panic!("Expected NotFound variant"),
248        }
249    }
250
251    #[test]
252    fn test_scan_warning_creation() {
253        let warning = ScanWarning::permission_denied("/test/path");
254        assert_eq!(warning.kind, WarningKind::PermissionDenied);
255        assert!(warning.message.contains("Permission denied"));
256        assert_eq!(format!("{warning}"), warning.message);
257    }
258}