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
80impl PawError {
81    /// Returns the process exit code for this error.
82    pub fn exit_code(&self) -> i32 {
83        match self {
84            Self::UserCancelled => exit_code::USER_CANCELLED,
85            _ => exit_code::ERROR,
86        }
87    }
88
89    /// Prints the error message to stderr and exits with the appropriate code.
90    pub fn exit(&self) -> ! {
91        eprintln!("error: {self}");
92        process::exit(self.exit_code());
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99
100    #[test]
101    fn test_not_a_git_repo_is_actionable() {
102        let msg = PawError::NotAGitRepo.to_string();
103        assert!(msg.contains("git repository"), "should explain the problem");
104        assert!(msg.contains("git-paw"), "should name the tool");
105    }
106
107    #[test]
108    fn test_tmux_not_installed_includes_install_instructions() {
109        let msg = PawError::TmuxNotInstalled.to_string();
110        assert!(msg.contains("tmux"), "should name the missing dependency");
111        assert!(
112            msg.contains("brew install"),
113            "should include macOS install hint"
114        );
115        assert!(
116            msg.contains("apt install"),
117            "should include Linux install hint"
118        );
119    }
120
121    #[test]
122    fn test_no_clis_found_suggests_add_cli() {
123        let msg = PawError::NoCLIsFound.to_string();
124        assert!(
125            msg.contains("add-cli"),
126            "should suggest the add-cli command"
127        );
128    }
129
130    #[test]
131    fn test_worktree_error_includes_detail() {
132        let msg = PawError::WorktreeError("failed to create".into()).to_string();
133        assert!(
134            msg.contains("failed to create"),
135            "should include the inner detail"
136        );
137    }
138
139    #[test]
140    fn test_session_error_includes_detail() {
141        let msg = PawError::SessionError("file corrupt".into()).to_string();
142        assert!(
143            msg.contains("file corrupt"),
144            "should include the inner detail"
145        );
146    }
147
148    #[test]
149    fn test_config_error_includes_detail() {
150        let msg = PawError::ConfigError("invalid toml".into()).to_string();
151        assert!(
152            msg.contains("invalid toml"),
153            "should include the inner detail"
154        );
155    }
156
157    #[test]
158    fn test_branch_error_includes_detail() {
159        let msg = PawError::BranchError("not found".into()).to_string();
160        assert!(msg.contains("not found"), "should include the inner detail");
161    }
162
163    #[test]
164    fn test_user_cancelled_is_not_empty() {
165        let msg = PawError::UserCancelled.to_string();
166        assert!(!msg.is_empty(), "should have a message");
167    }
168
169    #[test]
170    fn test_tmux_error_includes_detail() {
171        let msg = PawError::TmuxError("session failed".into()).to_string();
172        assert!(
173            msg.contains("session failed"),
174            "should include the inner detail"
175        );
176    }
177
178    #[test]
179    fn test_cli_not_found_includes_cli_name() {
180        let msg = PawError::CliNotFound("my-agent".into()).to_string();
181        assert!(
182            msg.contains("my-agent"),
183            "should include the missing CLI name"
184        );
185    }
186
187    #[test]
188    fn test_user_cancelled_exit_code() {
189        assert_eq!(
190            PawError::UserCancelled.exit_code(),
191            exit_code::USER_CANCELLED
192        );
193    }
194
195    #[test]
196    fn test_general_errors_exit_code() {
197        let errors: Vec<PawError> = vec![
198            PawError::NotAGitRepo,
199            PawError::TmuxNotInstalled,
200            PawError::NoCLIsFound,
201            PawError::WorktreeError("test".into()),
202            PawError::SessionError("test".into()),
203            PawError::ConfigError("test".into()),
204            PawError::BranchError("test".into()),
205            PawError::TmuxError("test".into()),
206            PawError::CliNotFound("test".into()),
207        ];
208        for err in errors {
209            assert_eq!(err.exit_code(), exit_code::ERROR, "failed for {err:?}");
210        }
211    }
212
213    #[test]
214    fn test_spec_error_includes_detail() {
215        let msg = PawError::SpecError("bad format".into()).to_string();
216        assert!(
217            msg.contains("bad format"),
218            "should include the inner detail"
219        );
220        assert!(
221            msg.contains("Spec error"),
222            "should have the Spec error prefix"
223        );
224    }
225
226    #[test]
227    fn test_spec_error_exit_code() {
228        assert_eq!(
229            PawError::SpecError("test".into()).exit_code(),
230            exit_code::ERROR
231        );
232    }
233
234    #[test]
235    fn test_agents_md_error_includes_detail() {
236        let msg = PawError::AgentsMdError("cannot write file".into()).to_string();
237        assert!(
238            msg.contains("AGENTS.md error"),
239            "should include AGENTS.md prefix"
240        );
241        assert!(
242            msg.contains("cannot write file"),
243            "should include the inner detail"
244        );
245        assert_eq!(
246            PawError::AgentsMdError("x".into()).exit_code(),
247            exit_code::ERROR,
248            "should use general exit code"
249        );
250    }
251
252    #[test]
253    fn test_debug_derived() {
254        let err = PawError::NotAGitRepo;
255        let debug = format!("{err:?}");
256        assert!(debug.contains("NotAGitRepo"));
257    }
258}