track-core 0.1.0

Shared backend primitives and repositories for the track issue tracker.
Documentation
use std::env;
use std::io::{self, IsTerminal};

use owo_colors::OwoColorize;

#[derive(Clone, Copy)]
pub enum SummaryTone {
    Success,
    Info,
}

#[derive(Clone, Copy)]
pub enum ValueTone {
    Plain,
    Path,
    PriorityHigh,
    PriorityMedium,
    PriorityLow,
    StatusOpen,
    StatusClosed,
}

pub fn format_summary(
    title: &str,
    tone: SummaryTone,
    rows: &[(&str, String, ValueTone)],
) -> String {
    let mut lines = vec![style_title(title, tone)];
    let label_width = rows
        .iter()
        .map(|(label, _, _)| label.len())
        .max()
        .unwrap_or(0);

    for (label, value, value_tone) in rows {
        lines.push(format_summary_row(label, value, *value_tone, label_width));
    }

    lines.join("\n")
}

pub fn format_note(label: &str, value: &str) -> String {
    let raw_label = format!("{label:<8}");
    if should_color() {
        format!("  {}  {}", raw_label.bold().blue(), value.dimmed())
    } else {
        format!("  {raw_label}  {value}")
    }
}

pub fn format_prompt_label(label: &str, default_value: Option<&str>) -> String {
    match default_value.filter(|value| !value.trim().is_empty()) {
        Some(default_value) => format!("{label} [{default_value}]"),
        None => label.to_owned(),
    }
}

fn format_summary_row(
    label: &str,
    value: &str,
    value_tone: ValueTone,
    label_width: usize,
) -> String {
    let raw_label = format!("{label:<label_width$}", label_width = label_width);
    let rendered_label = if should_color() {
        raw_label.bold().blue().to_string()
    } else {
        raw_label
    };

    format!("  {rendered_label}  {}", style_value(value, value_tone))
}

fn style_title(title: &str, tone: SummaryTone) -> String {
    if !should_color() {
        return title.to_owned();
    }

    match tone {
        SummaryTone::Success => title.bold().green().to_string(),
        SummaryTone::Info => title.bold().cyan().to_string(),
    }
}

fn style_value(value: &str, tone: ValueTone) -> String {
    if !should_color() {
        return value.to_owned();
    }

    match tone {
        ValueTone::Plain => value.to_owned(),
        ValueTone::Path => value.cyan().to_string(),
        ValueTone::PriorityHigh => value.bold().red().to_string(),
        ValueTone::PriorityMedium => value.bold().yellow().to_string(),
        ValueTone::PriorityLow => value.bold().green().to_string(),
        ValueTone::StatusOpen => value.bold().green().to_string(),
        ValueTone::StatusClosed => value.bold().magenta().to_string(),
    }
}

fn should_color() -> bool {
    env::var_os("NO_COLOR").is_none() && io::stdout().is_terminal()
}

#[cfg(test)]
mod tests {
    use super::{format_note, format_prompt_label, format_summary, SummaryTone, ValueTone};

    #[test]
    fn renders_plain_prompt_labels_with_defaults() {
        assert_eq!(
            format_prompt_label("Model file", Some("~/.models/parser.gguf")),
            "Model file [~/.models/parser.gguf]"
        );
    }

    #[test]
    fn renders_plain_note_without_a_terminal() {
        assert_eq!(
            format_note("Enter", "keep current values"),
            "  Enter     keep current values"
        );
    }

    #[test]
    fn renders_plain_summary_without_a_terminal() {
        let rendered = format_summary(
            "Created task",
            SummaryTone::Success,
            &[
                ("Project", "project-x".to_owned(), ValueTone::Plain),
                (
                    "File",
                    "~/.track/issues/project-x/open/task.md".to_owned(),
                    ValueTone::Path,
                ),
            ],
        );

        assert_eq!(
            rendered,
            "Created task\n  Project  project-x\n  File     ~/.track/issues/project-x/open/task.md"
        );
    }
}