devbrain 0.2.0

Local-first CLI to capture, search, and recall developer workflow (commands, errors, and fixes)
use crate::{models::Entry, utils};

const CONTENT_MAX_LEN: usize = 120;
const RELATED_MAX_LEN: usize = 72;

#[derive(Clone, Copy)]
pub struct DisplayOptions {
    pub color: bool,
}

pub fn print_entry(
    entry: &Entry,
    terms: Option<&[String]>,
    related: &[&Entry],
    options: DisplayOptions,
) {
    println!("{}", entry_header(entry, options));
    println!("{}", format_content(&entry.content, terms, CONTENT_MAX_LEN));

    if !entry.tags.is_empty() {
        println!("Tags: {}", entry.tags.join(", "));
    }

    if !related.is_empty() {
        println!("Related:");

        for related_entry in related {
            println!(
                "- {}: {}",
                related_label(related_entry, options),
                format_content(&related_entry.content, None, RELATED_MAX_LEN)
            );
        }
    }

    println!();
}

fn entry_header(entry: &Entry, options: DisplayOptions) -> String {
    let timestamp = utils::format_timestamp(&entry.timestamp);
    let status = match (entry.entry_type.as_str(), entry.success) {
        ("command", Some(true)) => colorize("[SUCCESS]", "32", options.color),
        ("command", Some(false)) => colorize("[FAILED ]", "31", options.color),
        _ => "         ".to_string(),
    };
    let label = colorize(
        &format!("[{:<7}]", entry_type_label(entry)),
        entry_type_color(entry),
        options.color,
    );

    format!("{}{} {} {}", label, status, entry.project, timestamp)
}

fn highlight_terms(text: &str, terms: Option<&[String]>) -> String {
    let Some(terms) = terms.filter(|terms| !terms.is_empty()) else {
        return text.to_string();
    };

    let lower = text.to_lowercase();
    let mut matches: Vec<(usize, usize)> = Vec::new();

    for term in terms {
        if term.is_empty() {
            continue;
        }

        let mut start = 0;
        while let Some(found) = lower[start..].find(term) {
            let match_start = start + found;
            let match_end = match_start + term.len();
            matches.push((match_start, match_end));
            start = match_end;
        }
    }

    if matches.is_empty() {
        return text.to_string();
    }

    matches.sort_unstable_by(|a, b| a.0.cmp(&b.0).then_with(|| a.1.cmp(&b.1)));

    let mut merged: Vec<(usize, usize)> = Vec::new();
    for (start, end) in matches {
        if let Some((_, last_end)) = merged.last_mut() {
            if start <= *last_end {
                *last_end = (*last_end).max(end);
                continue;
            }
        }

        merged.push((start, end));
    }

    let mut result = String::new();
    let mut last = 0;

    for (start, end) in merged {
        result.push_str(&text[last..start]);
        result.push_str("**");
        result.push_str(&text[start..end]);
        result.push_str("**");
        last = end;
    }

    result.push_str(&text[last..]);
    result
}

fn related_label(entry: &Entry, options: DisplayOptions) -> String {
    let label = colorize(
        &format!("[{:<7}]", entry_type_label(entry)),
        entry_type_color(entry),
        options.color,
    );
    format!("{} {}", label, utils::format_timestamp(&entry.timestamp))
}

fn entry_type_label(entry: &Entry) -> &'static str {
    match entry.entry_type.as_str() {
        "command" => "COMMAND",
        "log" => "LOG",
        "error" => "ERROR",
        _ => "ENTRY",
    }
}

fn entry_type_color(entry: &Entry) -> &'static str {
    match entry.entry_type.as_str() {
        "command" => "36",
        "error" => "33",
        _ => "0",
    }
}

fn format_content(content: &str, terms: Option<&[String]>, max_len: usize) -> String {
    let content = truncate_content(content, max_len);
    highlight_terms(&content, terms)
}

fn truncate_content(content: &str, max_len: usize) -> String {
    if content.chars().count() <= max_len {
        return content.to_string();
    }

    let truncated: String = content.chars().take(max_len - 3).collect();
    format!("{}...", truncated)
}

fn colorize(text: &str, code: &str, enabled: bool) -> String {
    if !enabled || code == "0" {
        return text.to_string();
    }

    format!("\x1b[{}m{}\x1b[0m", code, text)
}