oc-session 0.1.0

Global OpenCode session browser and resume tool
use crate::session::Session;
use nucleo::{
    Matcher, Utf32Str,
    pattern::{CaseMatching, Normalization, Pattern},
};

pub fn filter(list: &[Session], query: &str, limit: usize) -> Vec<Session> {
    let query = query.trim().to_lowercase();
    if query.is_empty() {
        return list.iter().take(limit).cloned().collect();
    }
    let terms: Vec<_> = query.split_whitespace().collect();
    let pattern = Pattern::parse(&query, CaseMatching::Smart, Normalization::Smart);
    let mut matcher = Matcher::default();
    let mut scored: Vec<_> = list
        .iter()
        .filter_map(|session| {
            score(session, &terms, &pattern, &mut matcher).map(|score| (score, session.clone()))
        })
        .collect();
    scored.sort_by(|a, b| b.0.cmp(&a.0).then_with(|| b.1.updated.cmp(&a.1.updated)));
    scored
        .into_iter()
        .map(|(_, session)| session)
        .take(limit)
        .collect()
}

fn score(
    session: &Session,
    terms: &[&str],
    pattern: &Pattern,
    matcher: &mut Matcher,
) -> Option<i64> {
    let title = session.title.to_lowercase();
    let core = session.core();
    let paths = session.paths();
    let path_query = terms.iter().any(|term| {
        term.starts_with("path:") || term.contains('/') || term.contains('~') || term.contains('.')
    });
    let terms: Vec<_> = terms
        .iter()
        .map(|term| term.strip_prefix("path:").unwrap_or(term))
        .collect();
    // Keep default search session-centric. Otherwise directory names can match
    // unrelated sessions that only share a cwd prefix.
    let hay = if path_query {
        session.haystack()
    } else {
        core.clone()
    };
    let strict = terms.iter().all(|term| term.len() <= 3);
    if strict && !terms.iter().all(|term| hay.contains(term)) {
        return None;
    }
    if !strict && !terms.iter().any(|term| hay.contains(term)) {
        return None;
    }
    let mut buf = Vec::new();
    let mut title_buf = Vec::new();
    let score = if strict {
        0
    } else {
        pattern.score(Utf32Str::new(&hay, &mut buf), matcher)? as i64
    };
    let title_score = pattern
        .score(Utf32Str::new(&title, &mut title_buf), matcher)
        .unwrap_or_default() as i64;
    let exact = terms.iter().fold(0, |total, term| {
        total
            + if title.contains(term) { 200 } else { 0 }
            + if path_query && paths.contains(term) {
                120
            } else {
                0
            }
            + if session
                .preview
                .as_deref()
                .unwrap_or_default()
                .to_lowercase()
                .contains(term)
            {
                40
            } else {
                0
            }
    });
    Some(exact + score + title_score * 2 + session.updated / 1_000_000_000)
}

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

    #[test]
    fn searches_title_and_path() {
        let list = vec![Session {
            id: "ses_1".into(),
            title: "API recovery".into(),
            directory: PathBuf::from("/tmp/project"),
            path: None,
            agent: None,
            model: None,
            project: None,
            worktree: None,
            updated: 10,
            created: 1,
            db: PathBuf::from("db"),
            preview: None,
        }];

        assert_eq!(filter(&list, "api", 10).len(), 1);
        assert_eq!(filter(&list, "/tmp/project", 10).len(), 1);
        assert_eq!(filter(&list, "missing", 10).len(), 0);
    }

    #[test]
    fn default_search_does_not_match_path_only() {
        let list = vec![item("Unrelated", "/tmp/acme")];

        assert_eq!(filter(&list, "acme", 10).len(), 0);
        assert_eq!(filter(&list, "/tmp/acme", 10).len(), 1);
    }

    #[test]
    fn short_terms_are_not_loose_fuzzy_matches() {
        let list = vec![
            item("API planning", "/tmp/one"),
            item("another big goal", "/tmp/two"),
        ];

        let matches = filter(&list, "api", 10);

        assert_eq!(matches.len(), 1);
        assert_eq!(matches[0].title, "API planning");
    }

    fn item(title: &str, dir: &str) -> Session {
        Session {
            id: title.into(),
            title: title.into(),
            directory: PathBuf::from(dir),
            path: None,
            agent: None,
            model: None,
            project: None,
            worktree: None,
            updated: 10,
            created: 1,
            db: PathBuf::from("db"),
            preview: None,
        }
    }
}