ci_group 0.1.0

RAII log groups for GitHub Actions and Azure Pipelines. Fixes swallowed logs.
Documentation
//! A lightweight RAII library for log groups in GitHub Actions and Azure Pipelines.
//!
//! Fixes "swallowed logs" by closing groups automatically when dropped, preserving output even on panic.
//!
//! # Usage
//!
//! Guard style (group lasts until end of scope):
//!
//! ```rust
//! let _g = ci_group::open("Build");
//! println!("Building...");
//! // group closes here when _g is dropped
//! ```
//!
//! Macro style (group wraps a block):
//!
//! ```rust
//! ci_group::group!("Test", {
//!     println!("Running tests...");
//! });
//! ```
//!
//! # Caveats
//!
//! - `std::process::exit()` skips destructors. Groups won't close. Use normal returns instead.
//! - Don't hold `StdoutLock` across a scope where a `Group` drops (potential deadlock).

use std::io::Write;

/// Represents a CI/CD provider (GitHub Actions, Azure DevOps, etc.).
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum Provider {
    GitHub,
    Azure,
    None,
}
    
impl Provider {
    /// Detects the CI/CD provider from the environment variables.
    fn detect() -> Self {
        if std::env::var("GITHUB_ACTIONS")
            .map(|v| v.eq_ignore_ascii_case("true"))
            .unwrap_or(false)
        {
            Provider::GitHub
        } else if std::env::var("TF_BUILD")
            .map(|v| v.eq_ignore_ascii_case("true"))
            .unwrap_or(false)
        {
            Provider::Azure
        } else {
            Provider::None
        }
    }

    /// Returns true if the provider is active.
    fn is_active(&self) -> bool {
        !matches!(self, Provider::None)
    }

}


/// A collapsible log group. Closes automatically when dropped.
#[must_use = "group closes immediately when dropped. Bind it: let _g = open(...)"]
pub struct Group {
    provider: Provider,
}

impl Group {
    /// Creates a new group with the given title.
    pub fn new(title: &str) -> Self {
        let provider = Provider::detect();

        if provider.is_active() {
            let mut stdout = std::io::stdout().lock();
            let _ = match provider {
                Provider::GitHub => writeln!(stdout, "\n::group::{title}"),
                Provider::Azure => writeln!(stdout, "\n##[group]{title}"),
                Provider::None => Ok(()),
            };
            let _ = stdout.flush();
        }

        Group { provider }
    }
}

impl Drop for Group {
    fn drop(&mut self) {
        if self.provider.is_active() {
            let mut stdout = std::io::stdout().lock();
            let _ = match self.provider {
                Provider::GitHub => writeln!(stdout, "\n::endgroup::"),
                Provider::Azure => writeln!(stdout, "\n##[endgroup]"),
                Provider::None => Ok(()),
            };
            let _ = stdout.flush();
        }
    }
}

/// Opens a new log group. Alias for [`Group::new`].
pub fn open(title: &str) -> Group {
    Group::new(title)
}

/// Opens a log group for the duration of a block.
///
/// ```rust
/// ci_group::group!("Build", {
///     println!("Building...");
/// });
/// ```
#[macro_export]
macro_rules! group {
    ($title:expr, $body:block) => {{
        let _guard = $crate::open($title);
        $body
    }};
}


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

    #[test]
    fn detects_github() {
        temp_env::with_var("GITHUB_ACTIONS", Some("true"), || {
            assert_eq!(Provider::detect(), Provider::GitHub);
        });
        temp_env::with_var_unset("GITHUB_ACTIONS", || {
            assert_eq!(Provider::detect(), Provider::None);
        });
    }

    #[test]
    fn detects_github_case_insensitive() {
        temp_env::with_var("GITHUB_ACTIONS", Some("TRUE"), || {
            assert_eq!(Provider::detect(), Provider::GitHub);
        });
        temp_env::with_var("GITHUB_ACTIONS", Some("True"), || {
            assert_eq!(Provider::detect(), Provider::GitHub);
        });
    }

    #[test]
    fn rejects_non_true_values() {
        temp_env::with_var("GITHUB_ACTIONS", Some("false"), || {
            assert_eq!(Provider::detect(), Provider::None);
        });
        temp_env::with_var("GITHUB_ACTIONS", Some(""), || {
            assert_eq!(Provider::detect(), Provider::None);
        });
        temp_env::with_var("GITHUB_ACTIONS", Some("1"), || {
            assert_eq!(Provider::detect(), Provider::None);
        });
    }

    #[test]
    fn detects_azure() {
        // Must unset GITHUB_ACTIONS since this test runs in GitHub Actions
        temp_env::with_vars(
            [("TF_BUILD", Some("True")), ("GITHUB_ACTIONS", None)],
            || {
                assert_eq!(Provider::detect(), Provider::Azure);
            },
        );
    }

    #[test]
    fn is_active_works() {
        assert!(Provider::GitHub.is_active());
        assert!(Provider::Azure.is_active());
        assert!(!Provider::None.is_active());
    }

}