use std::process;
pub mod exit_code {
pub const ERROR: i32 = 1;
pub const USER_CANCELLED: i32 = 2;
}
#[derive(Debug, thiserror::Error)]
pub enum PawError {
#[error("Not a git repository. Run git-paw from inside a git project.")]
NotAGitRepo,
#[error(
"tmux is required but not installed. Install with: brew install tmux (macOS) or apt install tmux (Linux)"
)]
TmuxNotInstalled,
#[error(
"No AI CLIs found on PATH. Install one or use `git paw add-cli` to register a custom CLI."
)]
NoCLIsFound,
#[error("Worktree error: {0}")]
WorktreeError(String),
#[error("Session error: {0}")]
SessionError(String),
#[error("Config error: {0}")]
ConfigError(String),
#[error("Branch error: {0}")]
BranchError(String),
#[error("Cancelled.")]
UserCancelled,
#[error("Tmux error: {0}")]
TmuxError(String),
#[error("CLI '{0}' not found in config")]
CliNotFound(String),
#[error("Init error: {0}")]
InitError(String),
#[error("AGENTS.md error: {0}")]
AgentsMdError(String),
#[error("Spec error: {0}")]
SpecError(String),
#[error("Replay error: {0}")]
ReplayError(String),
#[error("Broker error: {0}")]
BrokerError(#[from] crate::broker::BrokerError),
#[error(transparent)]
SkillError(#[from] crate::skills::SkillError),
#[error("Dashboard error: {0}")]
DashboardError(String),
#[error("MCP error: {0}")]
McpError(String),
#[error("I/O error: {0}")]
IoError(#[from] std::io::Error),
}
impl PawError {
pub fn exit_code(&self) -> i32 {
match self {
Self::UserCancelled => exit_code::USER_CANCELLED,
_ => exit_code::ERROR,
}
}
pub fn exit(&self) -> ! {
eprintln!("error: {self}");
process::exit(self.exit_code());
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_not_a_git_repo_is_actionable() {
let msg = PawError::NotAGitRepo.to_string();
assert!(msg.contains("git repository"), "should explain the problem");
assert!(msg.contains("git-paw"), "should name the tool");
}
#[test]
fn test_tmux_not_installed_includes_install_instructions() {
let msg = PawError::TmuxNotInstalled.to_string();
assert!(msg.contains("tmux"), "should name the missing dependency");
assert!(
msg.contains("brew install"),
"should include macOS install hint"
);
assert!(
msg.contains("apt install"),
"should include Linux install hint"
);
}
#[test]
fn test_no_clis_found_suggests_add_cli() {
let msg = PawError::NoCLIsFound.to_string();
assert!(
msg.contains("add-cli"),
"should suggest the add-cli command"
);
}
#[test]
fn test_worktree_error_includes_detail() {
let msg = PawError::WorktreeError("failed to create".into()).to_string();
assert!(
msg.contains("failed to create"),
"should include the inner detail"
);
}
#[test]
fn test_session_error_includes_detail() {
let msg = PawError::SessionError("file corrupt".into()).to_string();
assert!(
msg.contains("file corrupt"),
"should include the inner detail"
);
}
#[test]
fn test_config_error_includes_detail() {
let msg = PawError::ConfigError("invalid toml".into()).to_string();
assert!(
msg.contains("invalid toml"),
"should include the inner detail"
);
}
#[test]
fn test_branch_error_includes_detail() {
let msg = PawError::BranchError("not found".into()).to_string();
assert!(msg.contains("not found"), "should include the inner detail");
}
#[test]
fn test_user_cancelled_is_not_empty() {
let msg = PawError::UserCancelled.to_string();
assert!(!msg.is_empty(), "should have a message");
}
#[test]
fn test_tmux_error_includes_detail() {
let msg = PawError::TmuxError("session failed".into()).to_string();
assert!(
msg.contains("session failed"),
"should include the inner detail"
);
}
#[test]
fn test_cli_not_found_includes_cli_name() {
let msg = PawError::CliNotFound("my-agent".into()).to_string();
assert!(
msg.contains("my-agent"),
"should include the missing CLI name"
);
}
#[test]
fn test_user_cancelled_exit_code() {
assert_eq!(
PawError::UserCancelled.exit_code(),
exit_code::USER_CANCELLED
);
}
#[test]
fn test_general_errors_exit_code() {
let errors: Vec<PawError> = vec![
PawError::NotAGitRepo,
PawError::TmuxNotInstalled,
PawError::NoCLIsFound,
PawError::WorktreeError("test".into()),
PawError::SessionError("test".into()),
PawError::ConfigError("test".into()),
PawError::BranchError("test".into()),
PawError::TmuxError("test".into()),
PawError::CliNotFound("test".into()),
PawError::SkillError(crate::skills::SkillError::UnknownSkill {
name: "test".into(),
}),
];
for err in errors {
assert_eq!(err.exit_code(), exit_code::ERROR, "failed for {err:?}");
}
}
#[test]
fn test_spec_error_includes_detail() {
let msg = PawError::SpecError("bad format".into()).to_string();
assert!(
msg.contains("bad format"),
"should include the inner detail"
);
assert!(
msg.contains("Spec error"),
"should have the Spec error prefix"
);
}
#[test]
fn test_spec_error_exit_code() {
assert_eq!(
PawError::SpecError("test".into()).exit_code(),
exit_code::ERROR
);
}
#[test]
fn test_agents_md_error_includes_detail() {
let msg = PawError::AgentsMdError("cannot write file".into()).to_string();
assert!(
msg.contains("AGENTS.md error"),
"should include AGENTS.md prefix"
);
assert!(
msg.contains("cannot write file"),
"should include the inner detail"
);
assert_eq!(
PawError::AgentsMdError("x".into()).exit_code(),
exit_code::ERROR,
"should use general exit code"
);
}
#[test]
fn test_skill_error_unknown_is_actionable() {
let inner = crate::skills::SkillError::UnknownSkill {
name: "nonexistent".into(),
};
let msg = inner.to_string();
assert!(msg.contains("nonexistent"), "should mention the skill name");
let paw = PawError::from(inner);
assert_eq!(paw.exit_code(), exit_code::ERROR);
}
#[test]
fn test_dashboard_error_includes_detail() {
let msg = PawError::DashboardError("not in tmux".into()).to_string();
assert!(
msg.contains("not in tmux"),
"should include the inner detail"
);
assert!(
msg.contains("Dashboard error"),
"should have the Dashboard error prefix"
);
assert_eq!(
PawError::DashboardError("test".into()).exit_code(),
exit_code::ERROR
);
}
#[test]
fn test_debug_derived() {
let err = PawError::NotAGitRepo;
let debug = format!("{err:?}");
assert!(debug.contains("NotAGitRepo"));
}
}