Skip to main content

seshat_cli/
error.rs

1//! CLI error types.
2
3/// Errors originating from CLI commands and TUI.
4#[derive(Debug, thiserror::Error)]
5pub enum CliError {
6    /// A command received invalid arguments.
7    #[error("invalid argument: {0}")]
8    InvalidArgument(String),
9
10    /// The specified path does not exist or is not a directory.
11    #[error("invalid path '{path}': {reason}")]
12    InvalidPath {
13        /// The path that was invalid.
14        path: String,
15        /// Why the path is invalid.
16        reason: String,
17    },
18
19    /// A subcommand failed.
20    #[error("{command} failed: {reason}")]
21    CommandFailed {
22        /// Which command failed.
23        command: String,
24        /// Why it failed.
25        reason: String,
26    },
27
28    /// TUI rendering error.
29    #[error("TUI error: {0}")]
30    TuiError(String),
31
32    /// IO error with path context.
33    #[error("{message} (path: {path})")]
34    IoWithPath {
35        /// Human-readable description of the operation that failed.
36        message: String,
37        /// The file or directory involved.
38        path: std::path::PathBuf,
39    },
40
41    /// IO error.
42    #[error("IO error: {0}")]
43    Io(#[from] std::io::Error),
44
45    /// `serve` was invoked from a path on the dangerous-cwd denylist with no
46    /// nearby git repository (e.g. `$HOME`, `/`, or a drive root). Auto-scan
47    /// is refused because recursive watching from such a location can consume
48    /// tens of GB of memory.
49    #[error(
50        "refusing to auto-scan from a dangerous location: {path}\n\
51         \n\
52         This directory is on the per-OS dangerous-cwd denylist (e.g. $HOME, ~/Library, /, \
53         drive roots) and is not inside a git repository. Auto-scanning here would recursively \
54         walk a huge tree and consume excessive memory.\n\
55         \n\
56         {hint}"
57    )]
58    DangerousCwd {
59        /// The offending cwd that triggered the refusal.
60        path: std::path::PathBuf,
61        /// Multi-line, user-facing suggestions for how to proceed.
62        hint: String,
63    },
64}
65
66impl CliError {
67    /// Shorthand for `CommandFailed { command: "scan", reason }`.
68    pub fn scan(reason: impl std::fmt::Display) -> Self {
69        Self::CommandFailed {
70            command: "scan".to_owned(),
71            reason: reason.to_string(),
72        }
73    }
74}
75
76#[cfg(test)]
77mod tests {
78    use super::*;
79
80    #[test]
81    fn invalid_argument_display() {
82        let err = CliError::InvalidArgument("missing path".to_owned());
83        assert_eq!(err.to_string(), "invalid argument: missing path");
84    }
85
86    #[test]
87    fn invalid_path_display() {
88        let err = CliError::InvalidPath {
89            path: "/tmp/nope".to_owned(),
90            reason: "not a directory".to_owned(),
91        };
92        assert_eq!(err.to_string(), "invalid path '/tmp/nope': not a directory");
93    }
94
95    #[test]
96    fn command_failed_display() {
97        let err = CliError::CommandFailed {
98            command: "scan".to_owned(),
99            reason: "disk full".to_owned(),
100        };
101        assert_eq!(err.to_string(), "scan failed: disk full");
102    }
103
104    #[test]
105    fn tui_error_display() {
106        let err = CliError::TuiError("buffer overflow".to_owned());
107        assert_eq!(err.to_string(), "TUI error: buffer overflow");
108    }
109
110    #[test]
111    fn io_with_path_display() {
112        let err = CliError::IoWithPath {
113            message: "failed to read".to_owned(),
114            path: std::path::PathBuf::from("/tmp/file.txt"),
115        };
116        assert!(err.to_string().contains("failed to read"));
117        assert!(err.to_string().contains("/tmp/file.txt"));
118    }
119
120    #[test]
121    fn io_display() {
122        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "not found");
123        let err = CliError::Io(io_err);
124        assert!(err.to_string().contains("IO error"));
125        assert!(err.to_string().contains("not found"));
126    }
127
128    #[test]
129    fn io_from_conversion() {
130        let io_err = std::io::Error::new(std::io::ErrorKind::PermissionDenied, "denied");
131        let cli_err: CliError = io_err.into();
132        assert!(cli_err.to_string().contains("denied"));
133    }
134
135    #[test]
136    fn scan_constructor() {
137        let err = CliError::scan("no disk space");
138        assert_eq!(err.to_string(), "scan failed: no disk space");
139    }
140
141    #[test]
142    fn scan_constructor_with_number() {
143        let err = CliError::scan(42);
144        assert_eq!(err.to_string(), "scan failed: 42");
145    }
146
147    #[test]
148    fn error_is_std_error() {
149        fn takes_error(_: &dyn std::error::Error) {}
150        let err = CliError::InvalidArgument("x".to_owned());
151        takes_error(&err);
152    }
153
154    #[test]
155    fn dangerous_cwd_display_includes_path_explanation_and_hint() {
156        let err = CliError::DangerousCwd {
157            path: std::path::PathBuf::from("/Users/foo"),
158            hint: "Suggestions:\n  • cd into a real project\n  • run `seshat scan <path>`\n  \
159                   • pass an explicit `<repo>` path"
160                .to_owned(),
161        };
162        let msg = err.to_string();
163        assert!(msg.contains("/Users/foo"), "missing offending path: {msg}");
164        assert!(
165            msg.contains("dangerous-cwd denylist"),
166            "missing explanation: {msg}"
167        );
168        assert!(msg.contains("git repository"), "missing git context: {msg}");
169        assert!(
170            msg.contains("cd into a real project"),
171            "missing first hint: {msg}"
172        );
173        assert!(msg.contains("seshat scan"), "missing scan hint: {msg}");
174        assert!(msg.contains("repo"), "missing repo hint: {msg}");
175        assert!(
176            msg.lines().count() >= 3,
177            "expected multi-line message: {msg}"
178        );
179    }
180}