tailspin 6.1.0

A log file highlighter
Documentation
use crate::cli::{Arguments, KeywordColor};
use crate::highlighter_builder::builtins::get_builtin_keywords;
use std::collections::HashSet;
use tailspin::config::KeywordConfig;
use tailspin::style::{Color, Style};

pub fn collect_keywords(cli: &Arguments, theme_keywords: Vec<KeywordConfig>) -> Vec<KeywordConfig> {
    let builtin = get_builtin_keywords(cli.disable_builtin_keywords);
    let from_cli = get_keywords_from_cli(cli);

    dedupe_last_wins(builtin.into_iter().chain(theme_keywords).chain(from_cli).collect())
}

fn dedupe_last_wins(mut configs: Vec<KeywordConfig>) -> Vec<KeywordConfig> {
    let mut seen: HashSet<String> = HashSet::new();
    for config in configs.iter_mut().rev() {
        config.words.retain(|w| seen.insert(w.clone()));
    }
    configs.retain(|c| !c.words.is_empty());
    configs
}

fn get_keywords_from_cli(cli: &Arguments) -> Vec<KeywordConfig> {
    cli.color_word
        .iter()
        .flat_map(|(color, words)| {
            words.iter().map(move |word| KeywordConfig {
                style: Style::new().fg(Color::from(*color)),
                words: vec![word.clone()],
            })
        })
        .collect()
}

impl From<KeywordColor> for Color {
    fn from(value: KeywordColor) -> Self {
        match value {
            KeywordColor::Red => Self::Red,
            KeywordColor::Green => Self::Green,
            KeywordColor::Yellow => Self::Yellow,
            KeywordColor::Blue => Self::Blue,
            KeywordColor::Magenta => Self::Magenta,
            KeywordColor::Cyan => Self::Cyan,
        }
    }
}

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

    fn kw(words: &[&str], style: Style) -> KeywordConfig {
        KeywordConfig {
            words: words.iter().map(|s| s.to_string()).collect(),
            style,
        }
    }

    #[test]
    fn later_definition_overrides_earlier() {
        let builtin = kw(&["GET"], Style::new().fg(Color::Black).on(Color::Green));
        let user = kw(&["GET"], Style::new().fg(Color::Green).on(Color::Black));

        let result = dedupe_last_wins(vec![builtin, user.clone()]);

        assert_eq!(result, vec![user]);
    }

    #[test]
    fn unrelated_words_in_overridden_group_survive() {
        let builtin = kw(&["GET", "POST"], Style::new().fg(Color::Black).on(Color::Green));
        let user = kw(&["GET"], Style::new().fg(Color::Green));

        let result = dedupe_last_wins(vec![builtin, user.clone()]);

        assert_eq!(
            result,
            vec![kw(&["POST"], Style::new().fg(Color::Black).on(Color::Green)), user,]
        );
    }

    #[test]
    fn cli_overrides_theme_overrides_builtin() {
        let builtin = kw(&["GET"], Style::new().fg(Color::Red));
        let theme = kw(&["GET"], Style::new().fg(Color::Yellow));
        let cli = kw(&["GET"], Style::new().fg(Color::Green));

        let result = dedupe_last_wins(vec![builtin, theme, cli.clone()]);

        assert_eq!(result, vec![cli]);
    }

    #[test]
    fn theme_internal_duplicates_use_last_wins() {
        let first = kw(&["GET"], Style::new().fg(Color::Red));
        let second = kw(&["GET"], Style::new().fg(Color::Blue));

        let result = dedupe_last_wins(vec![first, second.clone()]);

        assert_eq!(result, vec![second]);
    }

    #[test]
    fn no_duplicates_passes_through_unchanged() {
        let configs = vec![
            kw(&["GET"], Style::new().fg(Color::Red)),
            kw(&["POST"], Style::new().fg(Color::Blue)),
        ];

        let result = dedupe_last_wins(configs.clone());

        assert_eq!(result, configs);
    }

    #[test]
    fn case_sensitive_dedup() {
        let lower = kw(&["get"], Style::new().fg(Color::Red));
        let upper = kw(&["GET"], Style::new().fg(Color::Blue));

        let result = dedupe_last_wins(vec![lower.clone(), upper.clone()]);

        assert_eq!(result, vec![lower, upper]);
    }

    #[test]
    fn empty_input_returns_empty() {
        let result = dedupe_last_wins(Vec::new());
        assert!(result.is_empty());
    }
}