rustrails-support 0.1.1

Core utilities (ActiveSupport equivalent)
Documentation
use crate::error_reporter::{Severity, global_reporter};
use once_cell::sync::Lazy;
use parking_lot::Mutex;
use serde_json::Value;
use std::collections::HashMap;
use std::panic::Location;

/// A deprecation warning enriched with the callsite that emitted it.
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct DeprecationWarning {
    /// The deprecation message.
    pub message: String,
    /// The source location that triggered the warning.
    pub callsite: String,
}

/// The behavior used when a deprecation warning is emitted.
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum DeprecationBehavior {
    /// Raise an error immediately.
    Raise,
    /// Record and log the warning.
    Log,
    /// Ignore the warning.
    Silence,
    /// Forward the warning to the global error reporter.
    Report,
}

/// Errors returned while emitting deprecation warnings.
#[derive(Debug, thiserror::Error, Clone, PartialEq, Eq)]
pub enum DeprecationError {
    /// The current behavior raises deprecations as errors.
    #[error("deprecated API used: {0}")]
    Raised(String),
}

/// Emits deprecation warnings according to the configured global behavior.
pub struct Deprecation;

static DEPRECATION_BEHAVIOR: Lazy<Mutex<DeprecationBehavior>> =
    Lazy::new(|| Mutex::new(DeprecationBehavior::Log));
static LOGGED_WARNINGS: Lazy<Mutex<Vec<DeprecationWarning>>> = Lazy::new(|| Mutex::new(Vec::new()));

impl Deprecation {
    /// Sets the global deprecation behavior.
    pub fn set_behavior(behavior: DeprecationBehavior) {
        *DEPRECATION_BEHAVIOR.lock() = behavior;
    }

    /// Returns the global deprecation behavior.
    #[must_use]
    pub fn behavior() -> DeprecationBehavior {
        *DEPRECATION_BEHAVIOR.lock()
    }

    /// Emits a deprecation warning for `message`.
    #[track_caller]
    pub fn warn(message: impl Into<String>) -> Result<DeprecationWarning, DeprecationError> {
        let warning = DeprecationWarning {
            message: message.into(),
            callsite: Location::caller().to_string(),
        };

        match Self::behavior() {
            DeprecationBehavior::Raise => Err(DeprecationError::Raised(warning.message.clone())),
            DeprecationBehavior::Log => {
                tracing::warn!(message = %warning.message, callsite = %warning.callsite, "deprecation warning");
                LOGGED_WARNINGS.lock().push(warning.clone());
                Ok(warning)
            }
            DeprecationBehavior::Silence => Ok(warning),
            DeprecationBehavior::Report => {
                let mut context = HashMap::new();
                context.insert(
                    String::from("callsite"),
                    Value::String(warning.callsite.clone()),
                );
                global_reporter().report_with_severity(
                    warning.message.clone(),
                    true,
                    Severity::Warning,
                    context,
                );
                Ok(warning)
            }
        }
    }
}

/// Emits a deprecation warning using the global deprecation behavior.
#[track_caller]
pub fn deprecated(message: impl Into<String>) -> Result<DeprecationWarning, DeprecationError> {
    Deprecation::warn(message)
}

#[cfg(test)]
pub(crate) fn reset_logged_warnings() {
    LOGGED_WARNINGS.lock().clear();
    Deprecation::set_behavior(DeprecationBehavior::Log);
}

#[cfg(test)]
mod tests {
    use super::{
        Deprecation, DeprecationBehavior, DeprecationError, DeprecationWarning, deprecated,
        reset_logged_warnings,
    };
    use crate::error_reporter::{
        ErrorReport, ErrorSubscriber, global_reporter, reset_global_reporter, subscribe,
    };
    use parking_lot::Mutex;
    use std::sync::Arc;

    #[derive(Default)]
    struct RecordingSubscriber {
        reports: Arc<Mutex<Vec<ErrorReport>>>,
    }

    impl RecordingSubscriber {
        fn new() -> Self {
            Self::default()
        }

        fn handle(&self) -> Arc<Mutex<Vec<ErrorReport>>> {
            Arc::clone(&self.reports)
        }
    }

    impl ErrorSubscriber for RecordingSubscriber {
        fn report(&self, report: &ErrorReport) {
            self.reports.lock().push(report.clone());
        }
    }

    fn logged_warnings() -> Vec<DeprecationWarning> {
        super::LOGGED_WARNINGS.lock().clone()
    }

    #[test]
    fn default_behavior_is_log() {
        reset_logged_warnings();
        assert_eq!(Deprecation::behavior(), DeprecationBehavior::Log);
    }

    #[test]
    fn set_behavior_updates_the_global_behavior() {
        reset_logged_warnings();
        Deprecation::set_behavior(DeprecationBehavior::Silence);

        assert_eq!(Deprecation::behavior(), DeprecationBehavior::Silence);
    }

    #[test]
    fn warn_logs_by_default() {
        reset_logged_warnings();
        let warning = Deprecation::warn("old API").expect("log behavior should not fail");

        assert_eq!(warning.message, "old API");
        assert_eq!(logged_warnings().len(), 1);
    }

    #[test]
    fn warn_records_the_callsite() {
        reset_logged_warnings();
        let warning = Deprecation::warn("old API").expect("log behavior should not fail");

        assert!(warning.callsite.contains("deprecation.rs"));
    }

    #[test]
    fn silence_behavior_does_not_log() {
        reset_logged_warnings();
        Deprecation::set_behavior(DeprecationBehavior::Silence);

        let warning = Deprecation::warn("old API").expect("silenced warnings should still return");

        assert_eq!(warning.message, "old API");
        assert!(logged_warnings().is_empty());
    }

    #[test]
    fn raise_behavior_returns_a_typed_error() {
        reset_logged_warnings();
        Deprecation::set_behavior(DeprecationBehavior::Raise);

        let error = Deprecation::warn("old API").expect_err("raise behavior should error");

        assert_eq!(error, DeprecationError::Raised(String::from("old API")));
    }

    #[test]
    fn raise_behavior_does_not_log() {
        reset_logged_warnings();
        Deprecation::set_behavior(DeprecationBehavior::Raise);

        let _ = Deprecation::warn("old API");

        assert!(logged_warnings().is_empty());
    }

    #[test]
    fn report_behavior_forwards_to_the_global_error_reporter() {
        reset_logged_warnings();
        reset_global_reporter();
        Deprecation::set_behavior(DeprecationBehavior::Report);
        let subscriber = RecordingSubscriber::new();
        let reports = subscriber.handle();
        subscribe(subscriber);

        Deprecation::warn("old API").expect("report behavior should not fail");

        let reports = reports.lock();
        assert_eq!(reports.len(), 1);
        assert_eq!(reports[0].error, "old API");
        assert!(
            reports[0]
                .context
                .get("callsite")
                .and_then(|value| value.as_str())
                .is_some()
        );
    }

    #[test]
    fn report_behavior_does_not_add_logged_warnings() {
        reset_logged_warnings();
        reset_global_reporter();
        Deprecation::set_behavior(DeprecationBehavior::Report);

        Deprecation::warn("old API").expect("report behavior should not fail");

        assert!(logged_warnings().is_empty());
    }

    #[test]
    fn helper_function_uses_the_same_behavior() {
        reset_logged_warnings();
        let warning =
            deprecated("legacy helper").expect("helper should forward to deprecation warn");

        assert_eq!(warning.message, "legacy helper");
        assert_eq!(logged_warnings().len(), 1);
    }

    #[test]
    fn global_error_reporter_remains_accessible_after_reporting() {
        reset_logged_warnings();
        reset_global_reporter();
        Deprecation::set_behavior(DeprecationBehavior::Report);

        let reporter = global_reporter() as *const _;
        Deprecation::warn("old API").expect("report behavior should not fail");

        assert_eq!(reporter, global_reporter() as *const _);
    }
}