use crate::search::FuzzyQuery;
pub const MAX_PROMPT_HISTORY_ROWS: usize = 200;
#[must_use]
pub fn rank_prompt_history(query: &str, candidates: &[&str], limit: usize) -> Vec<usize> {
if limit == 0 {
return Vec::new();
}
let query = query.trim();
let mut fuzzy = (!query.is_empty()).then(|| FuzzyQuery::new(query));
let mut scored = Vec::with_capacity(candidates.len());
for (index, candidate) in candidates.iter().enumerate() {
if candidate.trim().is_empty() {
continue;
}
let score = match fuzzy.as_mut() {
Some(fuzzy) => match fuzzy.score(candidate) {
Some(score) => score,
None => continue,
},
None => 0,
};
scored.push((index, score));
}
scored.sort_by_key(|&(_, score)| std::cmp::Reverse(score));
let mut seen = hashbrown::HashSet::with_capacity(scored.len());
let mut ranked = Vec::with_capacity(scored.len().min(limit));
for (index, _) in scored {
let Some(content) = candidates.get(index) else {
continue;
};
if !seen.insert(*content) {
continue;
}
ranked.push(index);
if ranked.len() == limit {
break;
}
}
ranked
}
#[must_use]
pub fn prompt_history_row_label(content: &str, time_label: &str) -> String {
let mut lines = content.lines();
let first = lines.next().unwrap_or("");
let extra_lines = lines.count();
let sanitized: String = first.chars().map(|ch| if ch.is_control() { ' ' } else { ch }).collect();
let first = if sanitized.is_empty() && extra_lines > 0 {
"…"
} else {
sanitized.as_str()
};
let mut parts = vec![first.to_owned()];
if !time_label.is_empty() {
parts.push(time_label.to_owned());
}
if extra_lines > 0 {
if extra_lines == 1 {
parts.push("+1 line".to_owned());
} else {
parts.push(format!("+{extra_lines} lines"));
}
}
parts.join(" \u{b7} ")
}
#[cfg(test)]
mod tests {
use super::*;
fn candidates<'a>() -> Vec<&'a str> {
vec![
"Implement current changes",
"Review stale diffs",
"git status",
"git diff",
]
}
fn ranked_contents(query: &str, limit: usize) -> Vec<String> {
let candidates = candidates();
rank_prompt_history(query, &candidates, limit)
.into_iter()
.map(|index| candidates[index].to_owned())
.collect()
}
#[test]
fn multi_word_query_matches_non_contiguous_text() {
assert_eq!(ranked_contents("impl chg", MAX_PROMPT_HISTORY_ROWS), vec!["Implement current changes"]);
assert_eq!(ranked_contents("stff", MAX_PROMPT_HISTORY_ROWS), vec!["Review stale diffs"]);
}
#[test]
fn empty_query_preserves_newest_first_order() {
assert_eq!(ranked_contents(" ", MAX_PROMPT_HISTORY_ROWS), candidates());
}
#[test]
fn empty_query_order_is_asymmetric() {
let newest_first = ["newer prompt", "older prompt"];
let oldest_first = ["older prompt", "newer prompt"];
assert_eq!(rank_prompt_history("", &newest_first, MAX_PROMPT_HISTORY_ROWS), vec![0, 1]);
assert_eq!(rank_prompt_history("", &oldest_first, MAX_PROMPT_HISTORY_ROWS), vec![0, 1]);
}
#[test]
fn blank_and_whitespace_prompts_never_match() {
let candidates = ["", " ", "real prompt"];
assert_eq!(rank_prompt_history("", &candidates, MAX_PROMPT_HISTORY_ROWS), vec![2]);
}
#[test]
fn duplicate_content_collapses_to_newest_occurrence() {
let candidates = ["cargo test", "unrelated", "cargo test"];
let ranked = rank_prompt_history("", &candidates, MAX_PROMPT_HISTORY_ROWS);
assert_eq!(ranked, vec![0, 1], "dedup keeps the first (newest) copy");
}
#[test]
fn query_ranking_beats_arrival_order() {
let candidates = ["git", "git status"];
let ranked = rank_prompt_history("git status", &candidates, MAX_PROMPT_HISTORY_ROWS);
assert_eq!(ranked.first().copied().map(|index| candidates[index]), Some("git status"));
}
#[test]
fn cap_applies_to_the_merged_result() {
let owned: Vec<String> = (0..250).map(|index| format!("prompt {index:03}")).collect();
let candidates: Vec<&str> = owned.iter().map(String::as_str).collect();
assert_eq!(rank_prompt_history("", &candidates, MAX_PROMPT_HISTORY_ROWS).len(), MAX_PROMPT_HISTORY_ROWS);
assert_eq!(rank_prompt_history("", &candidates, 0).len(), 0);
}
#[test]
fn no_match_yields_no_rows() {
assert!(ranked_contents("zzzznomatch", MAX_PROMPT_HISTORY_ROWS).is_empty());
}
#[test]
fn row_label_reports_hidden_lines() {
assert_eq!(prompt_history_row_label("cargo test", "3h ago"), "cargo test \u{b7} 3h ago");
assert_eq!(prompt_history_row_label("cargo test", ""), "cargo test");
assert_eq!(prompt_history_row_label("one\ntwo", "just now"), "one \u{b7} just now \u{b7} +1 line");
assert_eq!(prompt_history_row_label("one\ntwo\nthree", ""), "one \u{b7} +2 lines");
}
#[test]
fn row_label_sanitizes_control_characters() {
let label = prompt_history_row_label("safe\u{1b}[31mevil", "");
assert!(!label.contains('\u{1b}'), "escape must not survive: {label:?}");
assert!(label.starts_with("safe [31mevil"));
}
#[test]
fn row_label_uses_ellipsis_for_continuation_only_prompts() {
assert_eq!(prompt_history_row_label("\nsecond line", ""), "… \u{b7} +1 line");
}
}