cargo-changeset 0.0.2

A cargo subcommand for managing changesets
use std::path::PathBuf;

use thiserror::Error;

#[derive(Debug, Error)]
pub enum CliError {
    #[error(transparent)]
    Core(#[from] changeset_core::ChangesetError),

    #[error(transparent)]
    Git(#[from] changeset_git::GitError),

    #[error("project error")]
    Project(#[from] changeset_project::ProjectError),

    #[error("failed to determine current directory")]
    CurrentDir(#[source] std::io::Error),

    #[error("IO error")]
    Io(#[from] std::io::Error),

    #[error("operation error")]
    Operation(#[from] changeset_operations::OperationError),

    #[error("interactive mode requires a terminal")]
    NotATty,

    #[error("invalid --package-bump format '{input}' (expected 'package-name:bump-type')")]
    InvalidPackageBumpFormat { input: String },

    #[error("invalid bump type '{input}' (expected major, minor, or patch)")]
    InvalidBumpType { input: String },

    #[error("editor command failed")]
    EditorFailed {
        #[source]
        source: std::io::Error,
    },

    #[error("{uncovered_count} package(s) have changes without changeset coverage")]
    VerificationFailed { uncovered_count: usize },

    #[error(
        "changeset files were deleted in this branch (use --allow-deleted-changesets to bypass)"
    )]
    ChangesetDeleted { paths: Vec<PathBuf> },

    #[error("invalid prerelease tag '{tag}'")]
    InvalidPrereleaseTag { tag: String },

    #[error("invalid pre-release format '{input}' (expected 'crate:tag')")]
    InvalidPrereleaseFormat { input: String },

    #[error("package '{name}' not found in workspace")]
    PackageNotFound { name: String },

    #[error("cannot graduate package '{package}' with prerelease version '{version}'")]
    CannotGraduatePrerelease { package: String, version: String },

    #[error("cannot graduate package '{package}' with stable version '{version}' (>= 1.0.0)")]
    CannotGraduateStable { package: String, version: String },
}

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

#[cfg(test)]
mod tests {
    use std::path::PathBuf;

    use super::CliError;

    #[test]
    fn io_error_converts_via_from() {
        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "test");

        let cli_err: CliError = io_err.into();

        assert!(matches!(cli_err, CliError::Io(_)));
    }

    #[test]
    fn project_error_converts_via_from() {
        let project_err = changeset_project::ProjectError::NotFound {
            start_dir: PathBuf::from("/test"),
        };

        let cli_err: CliError = project_err.into();

        assert!(matches!(cli_err, CliError::Project(_)));
    }

    #[test]
    fn project_error_has_source_chain() {
        let project_err = changeset_project::ProjectError::NotFound {
            start_dir: PathBuf::from("/test"),
        };
        let cli_err: CliError = project_err.into();

        let source = std::error::Error::source(&cli_err);

        assert!(source.is_some());
    }

    #[test]
    fn not_a_tty_error_message() {
        let err = CliError::NotATty;

        assert!(err.to_string().contains("terminal"));
    }

    #[test]
    fn invalid_package_bump_format_error_includes_input() {
        let err = CliError::InvalidPackageBumpFormat {
            input: "bad-format".to_string(),
        };

        let msg = err.to_string();

        assert!(msg.contains("bad-format"));
        assert!(msg.contains("package-name:bump-type"));
    }

    #[test]
    fn invalid_bump_type_error_includes_input() {
        let err = CliError::InvalidBumpType {
            input: "huge".to_string(),
        };

        let msg = err.to_string();

        assert!(msg.contains("huge"));
        assert!(msg.contains("major"));
    }

    #[test]
    fn editor_failed_error_has_source() {
        let io_err = std::io::Error::new(std::io::ErrorKind::NotFound, "editor not found");
        let err = CliError::EditorFailed { source: io_err };

        let source = std::error::Error::source(&err);

        assert!(source.is_some());
    }

    #[test]
    fn operation_error_converts_via_from() {
        let op_err = changeset_operations::OperationError::Cancelled;

        let cli_err: CliError = op_err.into();

        assert!(matches!(cli_err, CliError::Operation(_)));
    }

    #[test]
    fn invalid_prerelease_format_error_includes_input() {
        let err = CliError::InvalidPrereleaseFormat {
            input: "no-colon".to_string(),
        };

        let msg = err.to_string();

        assert!(msg.contains("no-colon"));
        assert!(msg.contains("crate:tag"));
    }

    #[test]
    fn package_not_found_error_includes_name() {
        let err = CliError::PackageNotFound {
            name: "missing-crate".to_string(),
        };

        let msg = err.to_string();

        assert!(msg.contains("missing-crate"));
        assert!(msg.contains("not found"));
    }

    #[test]
    fn cannot_graduate_prerelease_error_includes_package_and_version() {
        let err = CliError::CannotGraduatePrerelease {
            package: "my-crate".to_string(),
            version: "0.1.0-alpha.1".to_string(),
        };

        let msg = err.to_string();

        assert!(msg.contains("my-crate"));
        assert!(msg.contains("0.1.0-alpha.1"));
        assert!(msg.contains("prerelease"));
    }

    #[test]
    fn cannot_graduate_stable_error_includes_package_and_version() {
        let err = CliError::CannotGraduateStable {
            package: "stable-crate".to_string(),
            version: "1.2.3".to_string(),
        };

        let msg = err.to_string();

        assert!(msg.contains("stable-crate"));
        assert!(msg.contains("1.2.3"));
        assert!(msg.contains("stable"));
    }
}