luff 0.2.1

Print files with formatting
Documentation
//! ANSI color utilities for terminal output

use std::env;
use std::fmt;
use std::io::IsTerminal;
use std::path::Path;
use std::sync::OnceLock;

/// ANSI color codes for file types with state for color support detection
#[derive(Debug)]
pub struct Colors {
    /// ANSI escape sequence for directory coloring (bright blue)
    directory: &'static str,
    /// ANSI escape sequence for executable file coloring (bright red)
    executable: &'static str,
    /// ANSI escape sequence to reset all formatting
    reset: &'static str,
    /// Whether color output is enabled based on terminal and environment
    enabled: bool,
}

/// Helper struct for zero-copy colorized output
///
/// This struct implements `Display` to write color codes and the path directly
/// to the formatter without allocating intermediate strings.
pub struct ColoredPath<'a> {
    /// The color configuration to use for formatting
    colors: &'a Colors,
    /// The display name of the file (to be printed)
    name: &'a str,
    /// The full path to the file (used for metadata lookup to determine color)
    path: &'a Path,
}

impl fmt::Display for ColoredPath<'_> {
    fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
        if !self.colors.enabled {
            return f.write_str(self.name);
        }

        // Try to get metadata to determine file type
        // If metadata fails, fallback to plain text
        let Ok(metadata) = std::fs::metadata(self.path) else {
            return f.write_str(self.name);
        };

        // Check if it's a directory
        if metadata.is_dir() {
            return write!(
                f,
                "{}{}{}",
                self.colors.directory, self.name, self.colors.reset
            );
        }

        // Check if it's executable (Unix-specific)
        #[cfg(unix)]
        {
            use std::os::unix::fs::PermissionsExt;
            if metadata.permissions().mode() & 0o111 != 0 {
                return write!(
                    f,
                    "{}{}{}",
                    self.colors.executable, self.name, self.colors.reset
                );
            }
        }

        // Windows executable detection
        #[cfg(windows)]
        {
            if let Some(ext) = self.path.extension() {
                if let Some(ext_str) = ext.to_str() {
                    let ext_lower = ext_str.to_lowercase();
                    if matches!(ext_lower.as_str(), "exe" | "bat" | "cmd" | "com" | "ps1") {
                        return write!(
                            f,
                            "{}{}{}",
                            self.colors.executable, self.name, self.colors.reset
                        );
                    }
                }
            }
        }

        // Regular file - no color
        f.write_str(self.name)
    }
}

/// Global cached Colors instance for performance
///
/// This ensures we only check environment variables and TTY status once,
/// rather than on every `Colors::new()` call. This is a significant
/// performance optimization for hot paths.
static COLORS: OnceLock<Colors> = OnceLock::new();

impl Colors {
    /// Get the global Colors instance (cached)
    ///
    /// This method returns a cached Colors instance that is initialized
    /// once on first access. Subsequent calls return the same instance
    /// without re-checking environment variables or TTY status.
    ///
    /// # Performance
    ///
    /// - First call: ~50µs (checks env vars + TTY)
    /// - Subsequent calls: ~1ns (pointer deref)
    ///
    /// # Thread Safety
    ///
    /// This method is thread-safe. Multiple threads calling it concurrently
    /// will result in only one initialization, with all threads receiving
    /// the same instance.
    ///
    /// # Examples
    ///
    /// ```
    /// use luff::printer::Colors;
    ///
    /// let colors = Colors::get();
    /// let colored = colors.colorize("test.txt", std::path::Path::new("/tmp/test.txt"));
    /// println!("{}", colored);
    /// ```
    #[must_use]
    pub fn get() -> &'static Self {
        COLORS.get_or_init(Self::new_impl)
    }

    /// Create a new Colors instance (internal implementation)
    ///
    /// This is the actual initialization logic, called by `get()` via
    /// `OnceLock`. It's private to ensure all code goes through the
    /// cached `get()` method.
    fn new_impl() -> Self {
        let enabled = Self::should_enable_colors();

        if enabled {
            Self {
                directory: "\x1b[1;34m",  // Bright blue
                executable: "\x1b[1;31m", // Bright red
                reset: "\x1b[0m",
                enabled: true,
            }
        } else {
            Self {
                directory: "",
                executable: "",
                reset: "",
                enabled: false,
            }
        }
    }

    /// Create a new Colors instance, respecting environment variables
    ///
    /// **Deprecated**: Use `Colors::get()` instead for better performance.
    /// This method is kept for backward compatibility in tests.
    ///
    /// Colors are disabled if:
    /// - Output is not a TTY (piped to file/process)
    /// - `NO_COLOR` environment variable is set (to any value)
    /// - `CLICOLOR=0` is set
    #[must_use]
    pub fn new() -> Self {
        Self::new_impl()
    }

    /// Determine if colors should be enabled based on environment and terminal
    ///
    /// Checks `NO_COLOR`, `CLICOLOR`, and TTY status to make a determination.
    /// This is executed once during global initialization.
    fn should_enable_colors() -> bool {
        // First check: is stdout a terminal?
        // This prevents ANSI codes when piping to files or other processes
        if !std::io::stdout().is_terminal() {
            return false;
        }

        // Respect NO_COLOR standard (https://no-color.org/)
        if env::var("NO_COLOR").is_ok() {
            return false;
        }

        // Respect CLICOLOR=0 standard
        if let Ok(val) = env::var("CLICOLOR") {
            if val == "0" {
                return false;
            }
        }

        true
    }

    /// Colorize a filename based on its file type
    ///
    /// Returns a `ColoredPath` struct that implements `Display`. This allows
    /// zero-copy formatting directly to the output stream, avoiding intermediate
    /// string allocations.
    ///
    /// # Arguments
    ///
    /// * `name` - The filename to colorize
    /// * `path` - Full path to the file (for metadata lookup)
    ///
    /// # Performance
    ///
    /// This method performs no allocations. The `Display` implementation
    /// writes directly to the formatter.
    #[must_use]
    pub const fn colorize<'a>(&'a self, name: &'a str, path: &'a Path) -> ColoredPath<'a> {
        ColoredPath {
            colors: self,
            name,
            path,
        }
    }

    /// Get directory color prefix ANSI sequence
    #[must_use]
    pub const fn directory(&self) -> &str {
        self.directory
    }

    /// Get executable file color prefix ANSI sequence
    #[must_use]
    pub const fn executable(&self) -> &str {
        self.executable
    }

    /// Get reset ANSI sequence to clear formatting
    #[must_use]
    pub const fn reset(&self) -> &str {
        self.reset
    }

    /// Check if color output is enabled
    #[must_use]
    pub const fn enabled(&self) -> bool {
        self.enabled
    }
}

impl Default for Colors {
    fn default() -> Self {
        Self::new()
    }
}

// CLICOLOR/NO_COLOR tests in tests/colors_env_test.rs
#[cfg(test)]
mod tests {
    use super::*;

    #[test]
    fn test_colors_new() {
        let colors = Colors::new();
        // Just verify it constructs without panic
        let _ = colors.enabled();
    }

    #[test]
    fn test_colors_get_is_cached() {
        // Verify that Colors::get() returns the same instance
        let colors1 = Colors::get();
        let colors2 = Colors::get();

        // Same memory address means same instance
        assert!(std::ptr::eq(colors1, colors2));
    }

    #[test]
    fn test_colors_accessors() {
        let colors = Colors::get();
        // Verify accessors work
        let _ = colors.directory();
        let _ = colors.executable();
        let _ = colors.reset();
    }

    #[test]
    fn test_colors_disabled_when_not_tty() {
        // When running under `cargo test`, stdout is typically not a TTY
        // This test verifies that colors are correctly disabled in that case
        let colors = Colors::get();

        // If we're not in a TTY (which is the case for most test runs),
        // colors should be disabled
        if !std::io::stdout().is_terminal() {
            assert!(
                !colors.enabled(),
                "Colors should be disabled when stdout is not a TTY"
            );
        }
    }

    #[test]
    fn test_display_formatting() {
        // Verify ColoredPath works with format!
        let colors = Colors::get();
        let result = colors.colorize("test", Path::new("/tmp/test"));

        // Can use with format!
        let _formatted = format!("File: {result}");

        // Can use Display trait
        assert_eq!(result.to_string(), "test");
    }
}