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 #[error("MCP error: {0}")]
93 McpError(String),
94
95 #[error("I/O error: {0}")]
97 IoError(#[from] std::io::Error),
98}
99
100impl PawError {
101 pub fn exit_code(&self) -> i32 {
103 match self {
104 Self::UserCancelled => exit_code::USER_CANCELLED,
105 _ => exit_code::ERROR,
106 }
107 }
108
109 pub fn exit(&self) -> ! {
111 eprintln!("error: {self}");
112 process::exit(self.exit_code());
113 }
114}
115
116#[cfg(test)]
117mod tests {
118 use super::*;
119
120 #[test]
121 fn test_not_a_git_repo_is_actionable() {
122 let msg = PawError::NotAGitRepo.to_string();
123 assert!(msg.contains("git repository"), "should explain the problem");
124 assert!(msg.contains("git-paw"), "should name the tool");
125 }
126
127 #[test]
128 fn test_tmux_not_installed_includes_install_instructions() {
129 let msg = PawError::TmuxNotInstalled.to_string();
130 assert!(msg.contains("tmux"), "should name the missing dependency");
131 assert!(
132 msg.contains("brew install"),
133 "should include macOS install hint"
134 );
135 assert!(
136 msg.contains("apt install"),
137 "should include Linux install hint"
138 );
139 }
140
141 #[test]
142 fn test_no_clis_found_suggests_add_cli() {
143 let msg = PawError::NoCLIsFound.to_string();
144 assert!(
145 msg.contains("add-cli"),
146 "should suggest the add-cli command"
147 );
148 }
149
150 #[test]
151 fn test_worktree_error_includes_detail() {
152 let msg = PawError::WorktreeError("failed to create".into()).to_string();
153 assert!(
154 msg.contains("failed to create"),
155 "should include the inner detail"
156 );
157 }
158
159 #[test]
160 fn test_session_error_includes_detail() {
161 let msg = PawError::SessionError("file corrupt".into()).to_string();
162 assert!(
163 msg.contains("file corrupt"),
164 "should include the inner detail"
165 );
166 }
167
168 #[test]
169 fn test_config_error_includes_detail() {
170 let msg = PawError::ConfigError("invalid toml".into()).to_string();
171 assert!(
172 msg.contains("invalid toml"),
173 "should include the inner detail"
174 );
175 }
176
177 #[test]
178 fn test_branch_error_includes_detail() {
179 let msg = PawError::BranchError("not found".into()).to_string();
180 assert!(msg.contains("not found"), "should include the inner detail");
181 }
182
183 #[test]
184 fn test_user_cancelled_is_not_empty() {
185 let msg = PawError::UserCancelled.to_string();
186 assert!(!msg.is_empty(), "should have a message");
187 }
188
189 #[test]
190 fn test_tmux_error_includes_detail() {
191 let msg = PawError::TmuxError("session failed".into()).to_string();
192 assert!(
193 msg.contains("session failed"),
194 "should include the inner detail"
195 );
196 }
197
198 #[test]
199 fn test_cli_not_found_includes_cli_name() {
200 let msg = PawError::CliNotFound("my-agent".into()).to_string();
201 assert!(
202 msg.contains("my-agent"),
203 "should include the missing CLI name"
204 );
205 }
206
207 #[test]
208 fn test_user_cancelled_exit_code() {
209 assert_eq!(
210 PawError::UserCancelled.exit_code(),
211 exit_code::USER_CANCELLED
212 );
213 }
214
215 #[test]
216 fn test_general_errors_exit_code() {
217 let errors: Vec<PawError> = vec![
218 PawError::NotAGitRepo,
219 PawError::TmuxNotInstalled,
220 PawError::NoCLIsFound,
221 PawError::WorktreeError("test".into()),
222 PawError::SessionError("test".into()),
223 PawError::ConfigError("test".into()),
224 PawError::BranchError("test".into()),
225 PawError::TmuxError("test".into()),
226 PawError::CliNotFound("test".into()),
227 PawError::SkillError(crate::skills::SkillError::UnknownSkill {
228 name: "test".into(),
229 }),
230 ];
231 for err in errors {
232 assert_eq!(err.exit_code(), exit_code::ERROR, "failed for {err:?}");
233 }
234 }
235
236 #[test]
237 fn test_spec_error_includes_detail() {
238 let msg = PawError::SpecError("bad format".into()).to_string();
239 assert!(
240 msg.contains("bad format"),
241 "should include the inner detail"
242 );
243 assert!(
244 msg.contains("Spec error"),
245 "should have the Spec error prefix"
246 );
247 }
248
249 #[test]
250 fn test_spec_error_exit_code() {
251 assert_eq!(
252 PawError::SpecError("test".into()).exit_code(),
253 exit_code::ERROR
254 );
255 }
256
257 #[test]
258 fn test_agents_md_error_includes_detail() {
259 let msg = PawError::AgentsMdError("cannot write file".into()).to_string();
260 assert!(
261 msg.contains("AGENTS.md error"),
262 "should include AGENTS.md prefix"
263 );
264 assert!(
265 msg.contains("cannot write file"),
266 "should include the inner detail"
267 );
268 assert_eq!(
269 PawError::AgentsMdError("x".into()).exit_code(),
270 exit_code::ERROR,
271 "should use general exit code"
272 );
273 }
274
275 #[test]
276 fn test_skill_error_unknown_is_actionable() {
277 let inner = crate::skills::SkillError::UnknownSkill {
278 name: "nonexistent".into(),
279 };
280 let msg = inner.to_string();
281 assert!(msg.contains("nonexistent"), "should mention the skill name");
282 let paw = PawError::from(inner);
283 assert_eq!(paw.exit_code(), exit_code::ERROR);
284 }
285
286 #[test]
287 fn test_dashboard_error_includes_detail() {
288 let msg = PawError::DashboardError("not in tmux".into()).to_string();
289 assert!(
290 msg.contains("not in tmux"),
291 "should include the inner detail"
292 );
293 assert!(
294 msg.contains("Dashboard error"),
295 "should have the Dashboard error prefix"
296 );
297 assert_eq!(
298 PawError::DashboardError("test".into()).exit_code(),
299 exit_code::ERROR
300 );
301 }
302
303 #[test]
304 fn test_debug_derived() {
305 let err = PawError::NotAGitRepo;
306 let debug = format!("{err:?}");
307 assert!(debug.contains("NotAGitRepo"));
308 }
309}