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    /// MCP server startup / repository-resolution failure.
92    #[error("MCP error: {0}")]
93    McpError(String),
94
95    /// I/O operation failed.
96    #[error("I/O error: {0}")]
97    IoError(#[from] std::io::Error),
98}
99
100impl PawError {
101    /// Returns the process exit code for this error.
102    pub fn exit_code(&self) -> i32 {
103        match self {
104            Self::UserCancelled => exit_code::USER_CANCELLED,
105            _ => exit_code::ERROR,
106        }
107    }
108
109    /// Prints the error message to stderr and exits with the appropriate code.
110    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}