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