balls 0.3.5

Git-native task tracker for parallel agent workflows
Documentation
use std::fmt;
use std::io;
use std::path::PathBuf;

#[derive(Debug)]
pub enum BallError {
    Io(io::Error),
    Json(serde_json::Error),
    Git(String),
    TaskNotFound(String),
    InvalidTask(String),
    NotInitialized,
    NotARepo,
    AlreadyClaimed(String),
    DepsUnmet(String),
    NotClaimable(String),
    Cycle(String),
    WorktreeExists(PathBuf),
    Conflict(String),
    Other(String),
}

impl fmt::Display for BallError {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            BallError::Io(e) => write!(f, "io error: {e}"),
            BallError::Json(e) => write!(f, "json error: {e}"),
            BallError::Git(s) => write!(f, "git error: {s}"),
            BallError::TaskNotFound(id) => write!(f, "task not found: {id}"),
            BallError::InvalidTask(s) => write!(f, "invalid task: {s}"),
            BallError::NotInitialized => {
                write!(f, "not initialized. Run `bl init`")
            }
            BallError::NotARepo => write!(f, "not a git repository"),
            BallError::AlreadyClaimed(id) => write!(f, "task {id} is already claimed"),
            BallError::DepsUnmet(id) => write!(f, "task {id} has unmet dependencies"),
            BallError::NotClaimable(id) => write!(f, "task {id} is not claimable"),
            BallError::Cycle(s) => write!(f, "dependency cycle: {s}"),
            BallError::WorktreeExists(p) => {
                write!(f, "worktree already exists: {} (try `bl drop`)", p.display())
            }
            BallError::Conflict(s) => write!(f, "conflict: {s}"),
            BallError::Other(s) => write!(f, "{s}"),
        }
    }
}

impl std::error::Error for BallError {}

impl From<io::Error> for BallError {
    fn from(e: io::Error) -> Self {
        BallError::Io(e)
    }
}

impl From<serde_json::Error> for BallError {
    fn from(e: serde_json::Error) -> Self {
        BallError::Json(e)
    }
}

pub type Result<T> = std::result::Result<T, BallError>;

#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn display_all_variants() {
        let cases: Vec<BallError> = vec![
            BallError::Io(io::Error::other("boom")),
            BallError::Json(serde_json::from_str::<i32>("x").unwrap_err()),
            BallError::Git("fatal".into()),
            BallError::TaskNotFound("bl-x".into()),
            BallError::InvalidTask("bad".into()),
            BallError::NotInitialized,
            BallError::NotARepo,
            BallError::AlreadyClaimed("bl-x".into()),
            BallError::DepsUnmet("bl-x".into()),
            BallError::NotClaimable("bl-x".into()),
            BallError::Cycle("loop".into()),
            BallError::WorktreeExists(PathBuf::from("/tmp/x")),
            BallError::Conflict("merge".into()),
            BallError::Other("misc".into()),
        ];
        for e in &cases {
            let s = format!("{e}");
            assert!(!s.is_empty());
        }
    }

    #[test]
    fn from_io_error() {
        let e: BallError = io::Error::other("x").into();
        assert!(matches!(e, BallError::Io(_)));
    }

    #[test]
    fn from_json_error() {
        let e: BallError = serde_json::from_str::<i32>("oops").unwrap_err().into();
        assert!(matches!(e, BallError::Json(_)));
    }

    #[test]
    fn error_is_std_error() {
        let e = BallError::NotARepo;
        let s: &dyn std::error::Error = &e;
        assert!(!s.to_string().is_empty());
    }
}