ai-memory 0.6.1

AI-agnostic persistent memory system — MCP server, HTTP API, and CLI for any AI platform
// Copyright 2026 AlphaOne LLC
// SPDX-License-Identifier: Apache-2.0

//! ANSI color output for CLI — zero dependencies.

use std::io::IsTerminal;
use std::sync::atomic::{AtomicBool, Ordering};

static COLOR_ENABLED: AtomicBool = AtomicBool::new(true);

pub fn init() {
    COLOR_ENABLED.store(std::io::stdout().is_terminal(), Ordering::Relaxed);
}

fn enabled() -> bool {
    COLOR_ENABLED.load(Ordering::Relaxed)
}

fn wrap(code: &str, text: &str) -> String {
    if enabled() {
        format!("\x1b[{code}m{text}\x1b[0m")
    } else {
        text.to_string()
    }
}

// Tier colors
pub fn short(text: &str) -> String {
    wrap("91", text)
} // red
pub fn mid(text: &str) -> String {
    wrap("93", text)
} // yellow
pub fn long(text: &str) -> String {
    wrap("92", text)
} // green

// Semantic colors
pub fn dim(text: &str) -> String {
    wrap("2", text)
}
pub fn bold(text: &str) -> String {
    wrap("1", text)
}
pub fn cyan(text: &str) -> String {
    wrap("96", text)
}

pub fn tier_color(tier: &str, text: &str) -> String {
    match tier {
        "short" => short(text),
        "mid" => mid(text),
        "long" => long(text),
        _ => text.to_string(),
    }
}

/// Priority as a colored bar: ████░░░░░░
pub fn priority_bar(p: i32) -> String {
    let filled = usize::try_from(p.clamp(1, 10)).expect("i32 as usize");
    let empty = 10 - filled;
    let bar = format!("{}{}", "".repeat(filled), "".repeat(empty));
    if p >= 8 {
        wrap("92", &bar)
    } else if p >= 5 {
        wrap("93", &bar)
    } else {
        wrap("91", &bar)
    }
}

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

    use std::sync::Mutex;
    static TEST_LOCK: Mutex<()> = Mutex::new(());

    fn with_color_off<F: FnOnce()>(f: F) {
        let _guard = TEST_LOCK.lock().unwrap();
        COLOR_ENABLED.store(false, Ordering::Relaxed);
        f();
        COLOR_ENABLED.store(true, Ordering::Relaxed);
    }

    #[test]
    fn tier_colors_no_ansi() {
        with_color_off(|| {
            assert_eq!(short("test"), "test");
            assert_eq!(mid("test"), "test");
            assert_eq!(long("test"), "test");
        });
    }

    #[test]
    fn semantic_colors_no_ansi() {
        with_color_off(|| {
            assert_eq!(dim("test"), "test");
            assert_eq!(bold("test"), "test");
            assert_eq!(cyan("test"), "test");
        });
    }

    #[test]
    fn tier_color_dispatch() {
        with_color_off(|| {
            assert_eq!(tier_color("short", "x"), "x");
            assert_eq!(tier_color("mid", "x"), "x");
            assert_eq!(tier_color("long", "x"), "x");
            assert_eq!(tier_color("unknown", "x"), "x");
        });
    }

    #[test]
    fn priority_bar_length() {
        with_color_off(|| {
            let bar = priority_bar(5);
            // 5 filled + 5 empty = 10 chars (each is multi-byte unicode)
            assert!(bar.contains(""));
            assert!(bar.contains(""));
        });
    }

    #[test]
    fn priority_bar_clamps() {
        with_color_off(|| {
            let bar_min = priority_bar(0); // clamps to 1
            let bar_max = priority_bar(15); // clamps to 10
            assert!(bar_min.contains(""));
            assert!(!bar_max.contains("")); // all filled
        });
    }

    #[test]
    fn wrap_with_color_enabled() {
        let _guard = TEST_LOCK.lock().unwrap();
        COLOR_ENABLED.store(true, Ordering::Relaxed);
        let result = wrap("91", "red");
        assert!(result.contains("\x1b[91m"));
        assert!(result.contains("\x1b[0m"));
        assert!(result.contains("red"));
    }
}