cargo-mend 0.2.0

Opinionated visibility auditing for Rust crates and workspaces
use std::fmt;
use std::process::ExitCode;

use anyhow::Error;

use crate::diagnostics::Report;
use crate::run_mode::OperationIntent;

#[derive(Debug)]
pub struct ExecutionOutcome {
    pub report: Report,
    pub notice: Option<ExecutionNotice>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ExecutionNotice {
    kinds: Vec<NoticeKind>,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum NoticeKind {
    ImportFixes(FixNotice),
    PubUseFixes(PubUseNotice),
    ImportCleanupSuggested,
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum FixNotice {
    NoneAvailable,
    PreviewApplied(usize),
    Applied(usize),
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub enum PubUseNotice {
    NoneAvailable {
        skipped_unsupported: usize,
    },
    PreviewApplied {
        applied:             usize,
        skipped_unsupported: usize,
    },
    Applied {
        applied:             usize,
        skipped_unsupported: usize,
    },
}

#[derive(Debug)]
pub enum MendFailure {
    Analysis(AnalysisFailure),
    FixValidation(FixValidationFailure),
    Unexpected(Error),
}

#[derive(Debug)]
pub enum CompilerFailureCause {
    CargoCheck,
    CargoRustcRefresh { package: String },
    DriverSetup(Error),
    DriverExecution(Error),
    Unexpected(Error),
}

#[derive(Debug)]
pub struct AnalysisFailure {
    pub cause: CompilerFailureCause,
}

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum RollbackStatus {
    Restored,
    RestoreFailed,
}

#[derive(Debug)]
pub struct FixValidationFailure {
    pub rollback: RollbackStatus,
    pub cause:    CompilerFailureCause,
}

impl MendFailure {
    pub fn exit_code() -> ExitCode { ExitCode::from(2) }
}

impl fmt::Display for MendFailure {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match self {
            Self::Analysis(failure) => write!(f, "{failure}"),
            Self::FixValidation(failure) => write!(f, "{failure}"),
            Self::Unexpected(error) => write!(f, "{error:#}"),
        }
    }
}

impl fmt::Display for AnalysisFailure {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        match &self.cause {
            CompilerFailureCause::CargoCheck => {
                write!(f, "compiler failed while validating this crate")
            },
            CompilerFailureCause::CargoRustcRefresh { package } => {
                write!(
                    f,
                    "compiler refresh failed while validating package `{package}`"
                )
            },
            CompilerFailureCause::DriverSetup(error)
            | CompilerFailureCause::DriverExecution(error)
            | CompilerFailureCause::Unexpected(error) => write!(f, "{error:#}"),
        }
    }
}

impl fmt::Display for FixValidationFailure {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        let source = match &self.cause {
            CompilerFailureCause::CargoCheck => {
                "compiler failed after applying mend fixes".to_string()
            },
            CompilerFailureCause::CargoRustcRefresh { package } => {
                format!("compiler refresh failed after applying mend fixes for package `{package}`")
            },
            CompilerFailureCause::DriverSetup(error)
            | CompilerFailureCause::DriverExecution(error)
            | CompilerFailureCause::Unexpected(error) => format!("{error:#}"),
        };
        match self.rollback {
            RollbackStatus::Restored => write!(
                f,
                "compiler failed after applying mend fixes; changes were rolled back\n\n{source:#}"
            ),
            RollbackStatus::RestoreFailed => write!(
                f,
                "compiler failed after applying mend fixes, and rollback also failed\n\n{source:#}"
            ),
        }
    }
}

impl From<Error> for MendFailure {
    fn from(value: Error) -> Self { Self::Unexpected(value) }
}

impl ExecutionNotice {
    pub fn from_kind(kind: NoticeKind) -> Self { Self { kinds: vec![kind] } }

    pub const fn from_kinds(kinds: Vec<NoticeKind>) -> Self { Self { kinds } }

    pub fn render(&self) -> String {
        let parts = self
            .kinds
            .iter()
            .map(NoticeKind::render_part)
            .collect::<Vec<_>>();
        format!("mend: {}", parts.join("; "))
    }
}

impl NoticeKind {
    fn render_part(&self) -> String {
        match self {
            Self::ImportFixes(notice) => notice.render(),
            Self::PubUseFixes(notice) => notice.render(),
            Self::ImportCleanupSuggested => {
                "some imports may now be unused; consider running cargo fix or cleaning them up manually"
                    .to_string()
            },
        }
    }
}

impl FixNotice {
    fn render(&self) -> String {
        match self {
            Self::NoneAvailable => "no import fixes available".to_string(),
            Self::PreviewApplied(count) => format!("would apply {count} import fix(es) in dry run"),
            Self::Applied(count) => format!("applied {count} import fix(es)"),
        }
    }

    pub const fn from_intent(intent: OperationIntent, count: usize) -> Self {
        match intent {
            OperationIntent::ReadOnly => Self::NoneAvailable,
            OperationIntent::DryRun => {
                if count == 0 {
                    Self::NoneAvailable
                } else {
                    Self::PreviewApplied(count)
                }
            },
            OperationIntent::Apply => {
                if count == 0 {
                    Self::NoneAvailable
                } else {
                    Self::Applied(count)
                }
            },
        }
    }
}

impl PubUseNotice {
    fn render(&self) -> String {
        match self {
            Self::NoneAvailable {
                skipped_unsupported: 0,
            } => "no `pub use` fixes available".to_string(),
            Self::NoneAvailable {
                skipped_unsupported,
            } => format!(
                "no `pub use` fixes available; skipped {skipped_unsupported} unsupported `pub use` candidate(s)"
            ),
            Self::PreviewApplied {
                applied,
                skipped_unsupported: 0,
            } => format!("would apply {applied} `pub use` fix(es) in dry run"),
            Self::PreviewApplied {
                applied,
                skipped_unsupported,
            } => format!(
                "would apply {applied} `pub use` fix(es) in dry run; skipped {skipped_unsupported} unsupported `pub use` candidate(s)"
            ),
            Self::Applied {
                applied,
                skipped_unsupported: 0,
            } => format!("applied {applied} `pub use` fix(es)"),
            Self::Applied {
                applied,
                skipped_unsupported,
            } => format!(
                "applied {applied} `pub use` fix(es); skipped {skipped_unsupported} unsupported `pub use` candidate(s)"
            ),
        }
    }

    pub const fn from_intent(
        intent: OperationIntent,
        applied: usize,
        skipped_unsupported: usize,
    ) -> Self {
        match intent {
            OperationIntent::ReadOnly => Self::NoneAvailable {
                skipped_unsupported,
            },
            OperationIntent::DryRun => {
                if applied == 0 {
                    Self::NoneAvailable {
                        skipped_unsupported,
                    }
                } else {
                    Self::PreviewApplied {
                        applied,
                        skipped_unsupported,
                    }
                }
            },
            OperationIntent::Apply => {
                if applied == 0 {
                    Self::NoneAvailable {
                        skipped_unsupported,
                    }
                } else {
                    Self::Applied {
                        applied,
                        skipped_unsupported,
                    }
                }
            },
        }
    }
}

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

    use super::AnalysisFailure;
    use super::CompilerFailureCause;
    use super::ExecutionNotice;
    use super::FixNotice;
    use super::FixValidationFailure;
    use super::NoticeKind;
    use super::PubUseNotice;
    use super::RollbackStatus;
    use crate::run_mode::OperationIntent;

    #[test]
    fn analysis_failure_message_uses_typed_collection_wording() {
        let failure = AnalysisFailure {
            cause: CompilerFailureCause::CargoCheck,
        };
        assert_eq!(
            failure.to_string(),
            "compiler failed while validating this crate"
        );
    }

    #[test]
    fn fix_validation_failure_reports_rollback_success() {
        let failure = FixValidationFailure {
            rollback: RollbackStatus::Restored,
            cause:    CompilerFailureCause::Unexpected(anyhow!("boom")),
        };
        assert!(
            failure
                .to_string()
                .contains("compiler failed after applying mend fixes; changes were rolled back")
        );
    }

    #[test]
    fn fix_validation_failure_reports_rollback_failure() {
        let failure = FixValidationFailure {
            rollback: RollbackStatus::RestoreFailed,
            cause:    CompilerFailureCause::Unexpected(anyhow!("boom")),
        };
        assert!(
            failure
                .to_string()
                .contains("compiler failed after applying mend fixes, and rollback also failed")
        );
    }

    #[test]
    fn import_fix_notice_respects_operation_intent() {
        let preview = FixNotice::from_intent(OperationIntent::DryRun, 2);
        assert_eq!(preview.render(), "would apply 2 import fix(es) in dry run");
    }

    #[test]
    fn combined_notice_renders_all_parts() {
        let notice = ExecutionNotice::from_kinds(vec![
            NoticeKind::ImportFixes(FixNotice::Applied(2)),
            NoticeKind::PubUseFixes(PubUseNotice::Applied {
                applied:             1,
                skipped_unsupported: 0,
            }),
        ]);
        assert_eq!(
            notice.render(),
            "mend: applied 2 import fix(es); applied 1 `pub use` fix(es)"
        );
    }
}