Skip to main content

git_paw/
error.rs

1//! Error types for git-paw.
2//!
3//! Defines [`PawError`], the central error enum used across all modules.
4//! Each variant carries an actionable, user-facing message.
5
6use std::process;
7
8/// Exit codes for git-paw.
9pub mod exit_code {
10    /// General error.
11    pub const ERROR: i32 = 1;
12    /// User cancelled (Ctrl+C or empty selection).
13    pub const USER_CANCELLED: i32 = 2;
14}
15
16/// Central error type for git-paw operations.
17#[derive(Debug, thiserror::Error)]
18pub enum PawError {
19    /// Not inside a git repository.
20    #[error("Not a git repository. Run git-paw from inside a git project.")]
21    NotAGitRepo,
22
23    /// tmux is not installed.
24    #[error(
25        "tmux is required but not installed. Install with: brew install tmux (macOS) or apt install tmux (Linux)"
26    )]
27    TmuxNotInstalled,
28
29    /// No AI CLIs found on PATH or in config.
30    #[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    /// Git worktree operation failed.
36    #[error("Worktree error: {0}")]
37    WorktreeError(String),
38
39    /// Session state read/write failed.
40    #[error("Session error: {0}")]
41    SessionError(String),
42
43    /// Config file parsing failed.
44    #[error("Config error: {0}")]
45    ConfigError(String),
46
47    /// Branch operation failed.
48    #[error("Branch error: {0}")]
49    BranchError(String),
50
51    /// User cancelled via Ctrl+C or empty selection.
52    #[error("Cancelled.")]
53    UserCancelled,
54
55    /// tmux operation failed.
56    #[error("Tmux error: {0}")]
57    TmuxError(String),
58
59    /// Custom CLI not found in config.
60    #[error("CLI '{0}' not found in config")]
61    CliNotFound(String),
62
63    /// Init operation failed.
64    #[error("Init error: {0}")]
65    InitError(String),
66
67    /// AGENTS.md operation failed.
68    #[error("AGENTS.md error: {0}")]
69    AgentsMdError(String),
70
71    /// Spec scanning failed.
72    #[error("Spec error: {0}")]
73    SpecError(String),
74
75    /// Replay operation failed.
76    #[error("Replay error: {0}")]
77    ReplayError(String),
78
79    /// Broker operation failed.
80    #[error("Broker error: {0}")]
81    BrokerError(#[from] crate::broker::BrokerError),
82
83    /// Skill template loading failed.
84    #[error(transparent)]
85    SkillError(#[from] crate::skills::SkillError),
86
87    /// Dashboard TUI operation failed.
88    #[error("Dashboard error: {0}")]
89    DashboardError(String),
90}
91
92impl PawError {
93    /// Returns the process exit code for this error.
94    pub fn exit_code(&self) -> i32 {
95        match self {
96            Self::UserCancelled => exit_code::USER_CANCELLED,
97            _ => exit_code::ERROR,
98        }
99    }
100
101    /// Prints the error message to stderr and exits with the appropriate code.
102    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}