devbrain 0.2.0

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

pub struct EntryFilters<'a> {
    pub query: Option<&'a str>,
    pub project: Option<&'a str>,
    pub entry_type: Option<&'a EntryType>,
    pub session_id: Option<&'a str>,
    pub success: Option<bool>,
    pub since: Option<&'a str>,
    pub offset: Option<usize>,
    pub limit: Option<usize>,
}

impl<'a> EntryFilters<'a> {
    fn matches(&self, entry: &Entry) -> bool {
        self.project.is_none_or(|project| entry.project == project)
            && self
                .entry_type
                .is_none_or(|entry_type| entry.entry_type == *entry_type)
            && self
                .session_id
                .is_none_or(|session_id| entry.session_id.as_deref() == Some(session_id))
            && self
                .success
                .is_none_or(|success| entry.success == Some(success))
            && self
                .since
                .is_none_or(|since| entry.timestamp.as_str() >= since)
    }
}

pub fn process_entries<'a>(entries: &'a [Entry], filters: &EntryFilters<'_>) -> Vec<&'a Entry> {
    match filters.query.and_then(parse_terms) {
        Some(terms) => process_search_results(entries, filters, &terms),
        None => process_recent_entries(entries, filters),
    }
}

pub fn find_last_entry<'a>(entries: &'a [Entry], filters: &EntryFilters<'_>) -> Option<&'a Entry> {
    entries.iter().rev().find(|entry| filters.matches(entry))
}

pub fn fuzzy_match(text: &str, query: &str) -> i32 {
    if query.is_empty() {
        return 0;
    }

    if equals_ignore_ascii_case(text, query) {
        return 3;
    }

    if contains_ignore_ascii_case(text, query) {
        return 2;
    }

    if chars_in_order_ignore_ascii_case(text, query) {
        return 1;
    }

    0
}

fn process_recent_entries<'a>(entries: &'a [Entry], filters: &EntryFilters<'_>) -> Vec<&'a Entry> {
    let offset = filters.offset.unwrap_or(0);
    let limit = filters.limit.unwrap_or(usize::MAX);
    let capacity = entries.len().saturating_sub(offset).min(limit);
    let mut results = Vec::with_capacity(capacity);

    for entry in entries
        .iter()
        .rev()
        .filter(|entry| filters.matches(entry))
        .skip(offset)
    {
        if results.len() == limit {
            break;
        }

        results.push(entry);
    }

    results
}

fn process_search_results<'a>(
    entries: &'a [Entry],
    filters: &EntryFilters<'_>,
    terms: &[String],
) -> Vec<&'a Entry> {
    let mut scored_entries: Vec<(&Entry, i32)> =
        Vec::with_capacity(entries.len().min(filters.limit.unwrap_or(entries.len())));

    for entry in entries.iter().filter(|entry| filters.matches(entry)) {
        let mut score = 0;

        for term in terms {
            score += fuzzy_match(&entry.content, term);
            score += fuzzy_match(&entry.project, term);

            for tag in &entry.tags {
                score += fuzzy_match(tag, term);
            }

            score += fuzzy_match(entry.entry_type.as_str(), term);
        }

        if score > 0 {
            scored_entries.push((entry, score));
        }
    }

    scored_entries.sort_by(|a, b| {
        b.1.cmp(&a.1)
            .then_with(|| b.0.timestamp.cmp(&a.0.timestamp))
    });

    let offset = filters.offset.unwrap_or(0);
    let limit = filters.limit.unwrap_or(usize::MAX);

    scored_entries
        .into_iter()
        .skip(offset)
        .take(limit)
        .map(|(entry, _score)| entry)
        .collect()
}

pub fn parse_terms(query: &str) -> Option<Vec<String>> {
    let terms: Vec<String> = query
        .to_lowercase()
        .split_whitespace()
        .map(str::to_string)
        .collect();

    (!terms.is_empty()).then_some(terms)
}

fn equals_ignore_ascii_case(text: &str, query: &str) -> bool {
    text.len() == query.len() && text.eq_ignore_ascii_case(query)
}

fn contains_ignore_ascii_case(text: &str, query: &str) -> bool {
    let query_len = query.len();
    if query_len == 0 {
        return true;
    }

    let text_bytes = text.as_bytes();
    let query_bytes = query.as_bytes();

    if query_len > text_bytes.len() {
        return false;
    }

    text_bytes.windows(query_len).any(|window| {
        window
            .iter()
            .zip(query_bytes.iter())
            .all(|(left, right)| left.eq_ignore_ascii_case(right))
    })
}

fn chars_in_order_ignore_ascii_case(text: &str, query: &str) -> bool {
    let mut query_bytes = query.as_bytes().iter();
    let mut next = query_bytes.next();

    for byte in text.as_bytes() {
        if next.is_some_and(|target| byte.eq_ignore_ascii_case(target)) {
            next = query_bytes.next();

            if next.is_none() {
                return true;
            }
        }
    }

    false
}