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