1use std::process;
7
8pub mod exit_code {
10 pub const ERROR: i32 = 1;
12 pub const USER_CANCELLED: i32 = 2;
14}
15
16#[derive(Debug, thiserror::Error)]
18pub enum PawError {
19 #[error("Not a git repository. Run git-paw from inside a git project.")]
21 NotAGitRepo,
22
23 #[error(
25 "tmux is required but not installed. Install with: brew install tmux (macOS) or apt install tmux (Linux)"
26 )]
27 TmuxNotInstalled,
28
29 #[error(
31 "No AI CLIs found on PATH. Install one or use `git paw add-cli` to register a custom CLI."
32 )]
33 NoCLIsFound,
34
35 #[error("Worktree error: {0}")]
37 WorktreeError(String),
38
39 #[error("Session error: {0}")]
41 SessionError(String),
42
43 #[error("Config error: {0}")]
45 ConfigError(String),
46
47 #[error("Branch error: {0}")]
49 BranchError(String),
50
51 #[error("Cancelled.")]
53 UserCancelled,
54
55 #[error("Tmux error: {0}")]
57 TmuxError(String),
58
59 #[error("CLI '{0}' not found in config")]
61 CliNotFound(String),
62
63 #[error("Init error: {0}")]
65 InitError(String),
66
67 #[error("AGENTS.md error: {0}")]
69 AgentsMdError(String),
70
71 #[error("Spec error: {0}")]
73 SpecError(String),
74
75 #[error("Replay error: {0}")]
77 ReplayError(String),
78
79 #[error("Broker error: {0}")]
81 BrokerError(#[from] crate::broker::BrokerError),
82
83 #[error(transparent)]
85 SkillError(#[from] crate::skills::SkillError),
86
87 #[error("Dashboard error: {0}")]
89 DashboardError(String),
90}
91
92impl PawError {
93 pub fn exit_code(&self) -> i32 {
95 match self {
96 Self::UserCancelled => exit_code::USER_CANCELLED,
97 _ => exit_code::ERROR,
98 }
99 }
100
101 pub fn exit(&self) -> ! {
103 eprintln!("error: {self}");
104 process::exit(self.exit_code());
105 }
106}
107
108#[cfg(test)]
109mod tests {
110 use super::*;
111
112 #[test]
113 fn test_not_a_git_repo_is_actionable() {
114 let msg = PawError::NotAGitRepo.to_string();
115 assert!(msg.contains("git repository"), "should explain the problem");
116 assert!(msg.contains("git-paw"), "should name the tool");
117 }
118
119 #[test]
120 fn test_tmux_not_installed_includes_install_instructions() {
121 let msg = PawError::TmuxNotInstalled.to_string();
122 assert!(msg.contains("tmux"), "should name the missing dependency");
123 assert!(
124 msg.contains("brew install"),
125 "should include macOS install hint"
126 );
127 assert!(
128 msg.contains("apt install"),
129 "should include Linux install hint"
130 );
131 }
132
133 #[test]
134 fn test_no_clis_found_suggests_add_cli() {
135 let msg = PawError::NoCLIsFound.to_string();
136 assert!(
137 msg.contains("add-cli"),
138 "should suggest the add-cli command"
139 );
140 }
141
142 #[test]
143 fn test_worktree_error_includes_detail() {
144 let msg = PawError::WorktreeError("failed to create".into()).to_string();
145 assert!(
146 msg.contains("failed to create"),
147 "should include the inner detail"
148 );
149 }
150
151 #[test]
152 fn test_session_error_includes_detail() {
153 let msg = PawError::SessionError("file corrupt".into()).to_string();
154 assert!(
155 msg.contains("file corrupt"),
156 "should include the inner detail"
157 );
158 }
159
160 #[test]
161 fn test_config_error_includes_detail() {
162 let msg = PawError::ConfigError("invalid toml".into()).to_string();
163 assert!(
164 msg.contains("invalid toml"),
165 "should include the inner detail"
166 );
167 }
168
169 #[test]
170 fn test_branch_error_includes_detail() {
171 let msg = PawError::BranchError("not found".into()).to_string();
172 assert!(msg.contains("not found"), "should include the inner detail");
173 }
174
175 #[test]
176 fn test_user_cancelled_is_not_empty() {
177 let msg = PawError::UserCancelled.to_string();
178 assert!(!msg.is_empty(), "should have a message");
179 }
180
181 #[test]
182 fn test_tmux_error_includes_detail() {
183 let msg = PawError::TmuxError("session failed".into()).to_string();
184 assert!(
185 msg.contains("session failed"),
186 "should include the inner detail"
187 );
188 }
189
190 #[test]
191 fn test_cli_not_found_includes_cli_name() {
192 let msg = PawError::CliNotFound("my-agent".into()).to_string();
193 assert!(
194 msg.contains("my-agent"),
195 "should include the missing CLI name"
196 );
197 }
198
199 #[test]
200 fn test_user_cancelled_exit_code() {
201 assert_eq!(
202 PawError::UserCancelled.exit_code(),
203 exit_code::USER_CANCELLED
204 );
205 }
206
207 #[test]
208 fn test_general_errors_exit_code() {
209 let errors: Vec<PawError> = vec![
210 PawError::NotAGitRepo,
211 PawError::TmuxNotInstalled,
212 PawError::NoCLIsFound,
213 PawError::WorktreeError("test".into()),
214 PawError::SessionError("test".into()),
215 PawError::ConfigError("test".into()),
216 PawError::BranchError("test".into()),
217 PawError::TmuxError("test".into()),
218 PawError::CliNotFound("test".into()),
219 PawError::SkillError(crate::skills::SkillError::UnknownSkill {
220 name: "test".into(),
221 }),
222 ];
223 for err in errors {
224 assert_eq!(err.exit_code(), exit_code::ERROR, "failed for {err:?}");
225 }
226 }
227
228 #[test]
229 fn test_spec_error_includes_detail() {
230 let msg = PawError::SpecError("bad format".into()).to_string();
231 assert!(
232 msg.contains("bad format"),
233 "should include the inner detail"
234 );
235 assert!(
236 msg.contains("Spec error"),
237 "should have the Spec error prefix"
238 );
239 }
240
241 #[test]
242 fn test_spec_error_exit_code() {
243 assert_eq!(
244 PawError::SpecError("test".into()).exit_code(),
245 exit_code::ERROR
246 );
247 }
248
249 #[test]
250 fn test_agents_md_error_includes_detail() {
251 let msg = PawError::AgentsMdError("cannot write file".into()).to_string();
252 assert!(
253 msg.contains("AGENTS.md error"),
254 "should include AGENTS.md prefix"
255 );
256 assert!(
257 msg.contains("cannot write file"),
258 "should include the inner detail"
259 );
260 assert_eq!(
261 PawError::AgentsMdError("x".into()).exit_code(),
262 exit_code::ERROR,
263 "should use general exit code"
264 );
265 }
266
267 #[test]
268 fn test_skill_error_unknown_is_actionable() {
269 let inner = crate::skills::SkillError::UnknownSkill {
270 name: "nonexistent".into(),
271 };
272 let msg = inner.to_string();
273 assert!(msg.contains("nonexistent"), "should mention the skill name");
274 let paw = PawError::from(inner);
275 assert_eq!(paw.exit_code(), exit_code::ERROR);
276 }
277
278 #[test]
279 fn test_skill_error_user_override_read_is_actionable() {
280 let inner = crate::skills::SkillError::UserOverrideRead {
281 path: std::path::PathBuf::from(
282 "/home/user/.config/git-paw/agent-skills/coordination.md",
283 ),
284 source: std::io::Error::new(std::io::ErrorKind::PermissionDenied, "permission denied"),
285 };
286 let msg = inner.to_string();
287 assert!(
288 msg.contains("coordination.md"),
289 "should include the file path"
290 );
291 assert!(
292 msg.contains("permission"),
293 "should suggest checking permissions"
294 );
295 let paw = PawError::from(inner);
296 assert_eq!(paw.exit_code(), exit_code::ERROR);
297 }
298
299 #[test]
300 fn test_dashboard_error_includes_detail() {
301 let msg = PawError::DashboardError("not in tmux".into()).to_string();
302 assert!(
303 msg.contains("not in tmux"),
304 "should include the inner detail"
305 );
306 assert!(
307 msg.contains("Dashboard error"),
308 "should have the Dashboard error prefix"
309 );
310 assert_eq!(
311 PawError::DashboardError("test".into()).exit_code(),
312 exit_code::ERROR
313 );
314 }
315
316 #[test]
317 fn test_debug_derived() {
318 let err = PawError::NotAGitRepo;
319 let debug = format!("{err:?}");
320 assert!(debug.contains("NotAGitRepo"));
321 }
322}