cartulary 0.3.0-alpha.1

The knowledge layer of your project — decisions, issues, docs, all in one place.
Documentation
//! Theme — centralised style decisions for all CLI output.
//!
//! All color and formatting choices live here. No ad-hoc color calls are
//! allowed outside this module.
//!
//! Design principles:
//! - Color carries semantics, not decoration.
//! - Never encode information by color alone — always pair with a symbol or
//!   label so output remains meaningful for colorblind users.
//! - Palette is colorblind-safe: cyan/grey/amber/magenta/blue avoids the
//!   red/green confusion that affects ~8% of men (deuteranopia/protanopia).
//! - No color when `NO_COLOR` is set or stdout is not a TTY.

use owo_colors::{OwoColorize as _, Stream::Stdout};

use crate::domain::model::status::StatusCategory;

// ── Color-enabled detection ───────────────────────────────────────────────────

/// Returns true if the current stdout supports color output.
/// Respects `NO_COLOR` env var and TTY detection automatically via owo-colors.
fn color_enabled() -> bool {
    supports_color::on(supports_color::Stream::Stdout).is_some()
}

// ── IDs ───────────────────────────────────────────────────────────────────────

/// Render a record/issue ID in bold.
pub fn id(s: &str) -> String {
    if color_enabled() {
        s.if_supports_color(Stdout, |t| t.bold()).to_string()
    } else {
        s.to_string()
    }
}

// ── Status ────────────────────────────────────────────────────────────────────

/// Symbol + colored label for a status, based on its category.
///
/// - `Queued`    → `◯ <label>` in grey   (pre-work)
/// - `Active`    → `● <label>` in cyan   (value-adding)
/// - `Stalled`   → `⏸ <label>` in yellow (started, waiting)
/// - `Resolved`  → `✓ <label>` in green  (completed)
/// - `Cancelled` → `✗ <label>` in dim red (abandoned)
/// - `Unknown`   → `- <label>` in grey   (no category configured)
pub fn status(label: impl AsRef<str>, category: StatusCategory) -> String {
    let label = label.as_ref();
    match category {
        StatusCategory::Queued => {
            let text = format!("{label}");
            if color_enabled() {
                text.if_supports_color(Stdout, |t| t.bright_black())
                    .to_string()
            } else {
                text
            }
        }
        StatusCategory::Active => {
            let text = format!("{label}");
            if color_enabled() {
                text.if_supports_color(Stdout, |t| t.cyan()).to_string()
            } else {
                text
            }
        }
        StatusCategory::Stalled => {
            let text = format!("{label}");
            if color_enabled() {
                text.if_supports_color(Stdout, |t| t.yellow()).to_string()
            } else {
                text
            }
        }
        StatusCategory::Resolved => {
            let text = format!("{label}");
            if color_enabled() {
                text.if_supports_color(Stdout, |t| t.green()).to_string()
            } else {
                text
            }
        }
        StatusCategory::Cancelled => {
            let text = format!("{label}");
            if color_enabled() {
                text.if_supports_color(Stdout, |t| t.red()).to_string()
            } else {
                text
            }
        }
        StatusCategory::Unknown => {
            let text = format!("- {label}");
            if color_enabled() {
                text.if_supports_color(Stdout, |t| t.bright_black())
                    .to_string()
            } else {
                text
            }
        }
    }
}

/// Render a `DrStatus` with the same icon/colour vocabulary as
/// [`status`]: proposed → queued, accepted → resolved (in force),
/// rejected/deprecated/superseded → cancelled (abandoned).
pub fn dr_status(s: crate::domain::model::decision_record::DrStatus) -> String {
    use crate::domain::model::decision_record::DrStatus;
    let category = match s {
        DrStatus::Proposed => StatusCategory::Queued,
        DrStatus::Accepted => StatusCategory::Resolved,
        DrStatus::Rejected | DrStatus::Deprecated | DrStatus::Superseded => {
            StatusCategory::Cancelled
        }
    };
    status(s.as_str(), category)
}

// ── Section headers ───────────────────────────────────────────────────────────

/// Bold section header (e.g. "Overview", "By Type").
pub fn section(s: &str) -> String {
    if color_enabled() {
        s.if_supports_color(Stdout, |t| t.bold()).to_string()
    } else {
        s.to_string()
    }
}

/// Dim separator line.
pub fn separator(width: usize) -> String {
    let line = "".repeat(width);
    if color_enabled() {
        line.if_supports_color(Stdout, |t| t.bright_black())
            .to_string()
    } else {
        line
    }
}

/// Dim field label (e.g. "Title", "Status").
pub fn label(s: &str) -> String {
    if color_enabled() {
        s.if_supports_color(Stdout, |t| t.bright_black())
            .to_string()
    } else {
        s.to_string()
    }
}

/// Highlighted numeric value.
pub fn number(s: &str) -> String {
    if color_enabled() {
        s.if_supports_color(Stdout, |t| t.bold()).to_string()
    } else {
        s.to_string()
    }
}

// ── Check output ──────────────────────────────────────────────────────────────

/// `✓` prefix for a passing check line (only shown with --verbose).
pub fn check_ok(path: &str) -> String {
    if color_enabled() {
        format!(
            "{}  {}",
            "".if_supports_color(Stdout, |t| t.bright_black()),
            path.if_supports_color(Stdout, |t| t.bright_black())
        )
    } else {
        format!("ok      {path}")
    }
}

/// `✗` prefix for an error check line. Symbol is the primary signal.
pub fn check_error(path: &str, message: &str) -> String {
    if color_enabled() {
        use owo_colors::OwoColorize as _;
        let symbol = "".red().to_string();
        let symbol = symbol.if_supports_color(Stdout, |t| t.bold()).to_string();
        let path_s = path.if_supports_color(Stdout, |t| t.bold()).to_string();
        format!("{symbol}  {path_s}: {message}")
    } else {
        format!("error   {path}: {message}")
    }
}

/// `⚠` prefix for a warning check line. Symbol is the primary signal.
pub fn check_warn(path: &str, message: &str) -> String {
    if color_enabled() {
        use owo_colors::OwoColorize as _;
        let symbol = "".yellow().to_string();
        let symbol = symbol.if_supports_color(Stdout, |t| t.bold()).to_string();
        format!("{symbol}  {path}: {message}")
    } else {
        format!("warning {path}: {message}")
    }
}

// ── Mutation feedback ─────────────────────────────────────────────────────────

/// Success confirmation (e.g. "Created …", "Updated …").
pub fn success(s: &str) -> String {
    if color_enabled() {
        s.if_supports_color(Stdout, |t| t.green()).to_string()
    } else {
        s.to_string()
    }
}

/// Dim / no-op feedback (e.g. "No change: …").
pub fn noop(s: &str) -> String {
    if color_enabled() {
        s.if_supports_color(Stdout, |t| t.bright_black())
            .to_string()
    } else {
        s.to_string()
    }
}