use cliban_core::contexts::issues::{self, ListOpts};
use cliban_core::contexts::milestones;
use cliban_core::schema::Issue;
use cliban_core::Store;
use crate::errors::{CliError, CliResult};
const FIRST_CHAR_MATCH_BONUS: i64 = 10;
const MATCH_FOLLOWING_SEPARATOR_BONUS: i64 = 20;
const CAMEL_CASE_MATCH_BONUS: i64 = 20;
const ADJACENT_MATCH_BONUS: i64 = 5;
const UNMATCHED_LEADING_CHAR_PENALTY: i64 = -5;
const MAX_UNMATCHED_LEADING_CHAR_PENALTY: i64 = -15;
const SEPARATORS: &[char] = &['/', '-', '_', ' ', '.', '\\'];
fn is_separator(c: char) -> bool {
SEPARATORS.contains(&c)
}
fn equal_fold(tr: char, sr: char) -> bool {
if tr == sr {
return true;
}
let (tr, sr) = if tr < sr { (sr, tr) } else { (tr, sr) };
if (tr as u32) < 0x80 {
if sr.is_ascii_uppercase() && tr == ((sr as u8) + b'a' - b'A') as char {
return true;
}
return false;
}
tr.to_lowercase().eq(sr.to_lowercase())
}
fn adjacent_char_bonus(i: usize, last_match: usize, current_bonus: i64) -> i64 {
if last_match == i {
current_bonus * 2 + ADJACENT_MATCH_BONUS
} else {
0
}
}
pub fn fuzzy_find(pattern: &str, text: &str) -> Option<(i64, Vec<usize>)> {
if pattern.is_empty() {
return None;
}
let runes: Vec<char> = pattern.chars().collect();
let clean: &str = match text.find('\0') {
Some(i) => &text[..i],
None => text,
};
let chars: Vec<(usize, char)> = clean.char_indices().collect();
let n = chars.len();
let mut matched_indexes: Vec<usize> = Vec::with_capacity(runes.len());
let mut total_score: i64 = 0;
let mut score: i64;
let mut pattern_index = 0usize;
let mut best_score: i64 = -1;
let mut matched_index: i64 = -1;
let mut curr_adjacent_match_bonus: i64 = 0;
let mut last: char = '\0';
let mut have_last = false;
let mut last_index: usize = 0;
for idx in 0..n {
let (j, candidate) = chars[idx];
if equal_fold(candidate, runes[pattern_index]) {
score = 0;
if j == 0 {
score += FIRST_CHAR_MATCH_BONUS;
}
if have_last && last.is_lowercase() && candidate.is_uppercase() {
score += CAMEL_CASE_MATCH_BONUS;
}
if j != 0 && have_last && is_separator(last) {
score += MATCH_FOLLOWING_SEPARATOR_BONUS;
}
if let Some(&last_match) = matched_indexes.last() {
let bonus = adjacent_char_bonus(last_index, last_match, curr_adjacent_match_bonus);
score += bonus;
curr_adjacent_match_bonus += bonus;
}
if score > best_score {
best_score = score;
matched_index = j as i64;
}
}
let nextp: char = if pattern_index < runes.len() - 1 {
runes[pattern_index + 1]
} else {
'\0'
};
let nextc: char = if idx + 1 < n { chars[idx + 1].1 } else { '\0' };
if (equal_fold(nextp, nextc) || nextc == '\0') && matched_index > -1 {
if matched_indexes.is_empty() {
let penalty = matched_index * UNMATCHED_LEADING_CHAR_PENALTY;
best_score += penalty.max(MAX_UNMATCHED_LEADING_CHAR_PENALTY);
}
total_score += best_score;
matched_indexes.push(matched_index as usize);
best_score = -1;
matched_index = -1;
pattern_index += 1;
if pattern_index >= runes.len() {
}
}
last_index = j;
last = candidate;
have_last = true;
if pattern_index >= runes.len() {
break;
}
}
let penalty = matched_indexes.len() as i64 - n as i64;
total_score += penalty;
if matched_indexes.len() == runes.len() {
Some((total_score, matched_indexes))
} else {
None
}
}
const MAX_DESC_BYTES: usize = 4096;
pub fn strip_description(s: &str) -> String {
use std::sync::OnceLock;
static RE_FENCE: OnceLock<regex::Regex> = OnceLock::new();
static RE_HEADING: OnceLock<regex::Regex> = OnceLock::new();
static RE_LINK: OnceLock<regex::Regex> = OnceLock::new();
let re_fence = RE_FENCE.get_or_init(|| regex::Regex::new(r"(?s)```.*?```").unwrap());
let re_heading = RE_HEADING.get_or_init(|| regex::Regex::new(r"(?m)^#+\s*").unwrap());
let re_link = RE_LINK.get_or_init(|| regex::Regex::new(r"\[([^\]]+)\]\([^)]+\)").unwrap());
let s = re_fence.replace_all(s, " ");
let s = re_heading.replace_all(&s, "");
let s = re_link.replace_all(&s, "$1");
let mut s = s.into_owned();
if s.len() > MAX_DESC_BYTES {
let mut cut = MAX_DESC_BYTES;
while cut > 0 && !s.is_char_boundary(cut) {
cut -= 1;
}
s.truncate(cut);
}
s
}
const WEIGHT_TITLE: i64 = 30;
const WEIGHT_KEY: i64 = 25;
const WEIGHT_LABEL: i64 = 20;
const WEIGHT_DESC: i64 = 10;
pub struct Match {
pub issue: Issue,
pub score: i64,
}
pub struct Options {
pub query: String,
pub project: Option<String>,
pub label: Vec<String>,
pub milestone: Option<String>,
pub status: Option<String>,
pub priority: Option<String>,
pub parent: Option<String>,
pub include_archived: bool,
pub exclude_subs: bool,
pub limit: i64,
}
pub async fn search(store: &Store, opts: Options) -> CliResult<Vec<Match>> {
let project = opts.project.clone().filter(|p| !p.is_empty());
let status = opts.status.clone().filter(|s| !s.is_empty());
let milestone = opts.milestone.clone().filter(|m| !m.is_empty());
let priority = opts.priority.clone().filter(|p| !p.is_empty());
let parent_key = opts.parent.clone().filter(|p| !p.is_empty());
let list_project = project.clone();
let list_status = status.clone();
let list_milestone = milestone.clone();
let include_archived = opts.include_archived;
let mut issues = store
.call(move |conn| {
let mut out = issues::list(
conn,
ListOpts {
project: list_project.as_deref(),
status: list_status.as_deref(),
milestone: list_milestone.as_deref(),
archived: false,
},
)?;
if include_archived {
let archived = issues::list(
conn,
ListOpts {
project: list_project.as_deref(),
status: list_status.as_deref(),
milestone: list_milestone.as_deref(),
archived: true,
},
)?;
out.extend(archived);
}
Ok(out)
})
.await?;
if let Some(pr) = &priority {
issues.retain(|i| &i.priority == pr);
}
if let Some(pk) = &parent_key {
let parsed = crate::cmd::issue::parse_issue_key_pub(pk)
.map_err(|e| CliError::other(format!("parent key {pk:?}: {}", e.message())))?;
let lookup = parsed.clone();
let parent_id = store
.call(move |conn| issues::get_by_key(conn, &lookup).map(|o| o.map(|i| i.id)))
.await?;
match parent_id {
Some(pid) => issues.retain(|i| i.parent_id == Some(pid)),
None => issues.clear(),
}
}
if opts.exclude_subs {
issues.retain(|i| i.parent_id.is_none());
}
if !opts.label.is_empty() {
let want = opts.label.clone();
let mut kept = Vec::with_capacity(issues.len());
for i in issues.into_iter() {
let id = i.id;
let names = store
.call(move |conn| issues::label_names(conn, id))
.await?;
if want.iter().all(|w| names.iter().any(|n| n == w)) {
kept.push(i);
}
}
issues = kept;
}
crate::cmd::issue::base_order(&mut issues);
let q = opts.query.trim();
let mut matches: Vec<Match>;
if q.is_empty() {
matches = issues
.into_iter()
.map(|issue| Match { issue, score: 0 })
.collect();
matches.sort_by_key(|m| std::cmp::Reverse(m.issue.updated_at));
} else {
matches = Vec::with_capacity(issues.len());
for issue in issues.into_iter() {
let id = issue.id;
let labels = store
.call(move |conn| issues::label_names(conn, id))
.await?;
let label_str = labels.join(" ");
let desc = strip_description(&issue.description);
let fields: [(&str, i64); 4] = [
(issue.title.as_str(), WEIGHT_TITLE),
(issue.key.as_str(), WEIGHT_KEY),
(label_str.as_str(), WEIGHT_LABEL),
(desc.as_str(), WEIGHT_DESC),
];
let mut total = 0i64;
let mut matched = false;
for (text, weight) in fields {
if text.is_empty() {
continue;
}
if let Some((s, _idx)) = fuzzy_find(q, text) {
total += s * weight;
matched = true;
}
}
if matched {
matches.push(Match {
issue,
score: total,
});
}
}
matches.sort_by(|a, b| {
b.score
.cmp(&a.score)
.then_with(|| b.issue.updated_at.cmp(&a.issue.updated_at))
});
}
if opts.limit > 0 && matches.len() > opts.limit as usize {
matches.truncate(opts.limit as usize);
}
Ok(matches)
}
pub async fn resolve_refs(store: &Store, issue: &Issue) -> CliResult<(String, String)> {
let milestone_id = issue.milestone_id;
let parent_id = issue.parent_id;
let pair = store
.call(move |conn| {
let milestone = match milestone_id {
Some(mid) => milestones::get_by_id(conn, mid)?.map(|m| m.name),
None => None,
};
let parent = match parent_id {
Some(pid) => issues::get_by_id(conn, pid)?.map(|i| i.key),
None => None,
};
Ok((milestone.unwrap_or_default(), parent.unwrap_or_default()))
})
.await?;
Ok(pair)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn fuzzy_no_match_returns_none() {
assert!(fuzzy_find("xyz", "abc").is_none());
assert!(fuzzy_find("abcd", "abc").is_none()); assert!(fuzzy_find("", "abc").is_none());
}
#[test]
fn fuzzy_exhaustive_picks_best_k() {
let (score, idx) = fuzzy_find("tk", "The Black Knight").unwrap();
assert_eq!(idx[0], 0);
assert_eq!(idx[1], 10);
assert!(score > 0, "score {score}");
}
#[test]
fn fuzzy_first_char_and_full_word() {
let (full, _) = fuzzy_find("search", "search").unwrap();
let (partial, _) = fuzzy_find("srch", "search").unwrap();
assert!(full > partial, "full {full} partial {partial}");
}
#[test]
fn strip_collapses_fences_headings_links() {
let s = strip_description("## Spec\nhello [text](http://x)\n```\ncode\n```\nbye");
assert!(!s.contains("##"));
assert!(!s.contains("```"));
assert!(!s.contains("http"));
assert!(s.contains("text"));
assert!(s.contains("hello") && s.contains("bye"));
}
#[test]
fn strip_caps_at_4096_bytes() {
let big = "a".repeat(5000);
assert_eq!(strip_description(&big).len(), 4096);
}
}