1#[derive(Debug, thiserror::Error)]
5pub enum CliError {
6 #[error("invalid argument: {0}")]
8 InvalidArgument(String),
9
10 #[error("invalid path '{path}': {reason}")]
12 InvalidPath {
13 path: String,
15 reason: String,
17 },
18
19 #[error("{command} failed: {reason}")]
21 CommandFailed {
22 command: String,
24 reason: String,
26 },
27
28 #[error("TUI error: {0}")]
30 TuiError(String),
31
32 #[error("{message} (path: {path})")]
34 IoWithPath {
35 message: String,
37 path: std::path::PathBuf,
39 },
40
41 #[error("IO error: {0}")]
43 Io(#[from] std::io::Error),
44
45 #[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 path: std::path::PathBuf,
61 hint: String,
63 },
64}
65
66impl CliError {
67 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}