use crate::app::Snapshot;
mod corpus;
mod excerpt;
#[cfg(test)]
mod tests;
pub use excerpt::excerpt;
pub const MAX: usize = 50;
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub enum Field {
Name,
Summary,
Text,
}
impl Field {
pub(crate) fn token(self) -> &'static str {
match self {
Self::Name => "name",
Self::Summary => "summary",
Self::Text => "text",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord)]
pub enum Address {
Ball { project: String, id: String },
Workspace { name: String },
Conversation { workspace: String, agent: String },
}
impl Address {
pub(crate) fn token(&self) -> &'static str {
match self {
Self::Ball { .. } => "ball",
Self::Workspace { .. } => "workspace",
Self::Conversation { .. } => "conversation",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Hit {
pub at: Address,
pub field: Field,
pub offset: usize,
pub excerpt: String,
}
#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Found {
pub needle: String,
pub hits: Vec<Hit>,
pub unreadable: Vec<String>,
}
impl Found {
pub fn is_empty(&self) -> bool {
self.hits.is_empty() && self.unreadable.is_empty()
}
pub fn asked(&self) -> bool {
!self.needle.is_empty()
}
}
pub fn no_matches(needle: &str) -> String {
format!("no matches for `{needle}`")
}
pub const SEARCHED_EVERYTHING: &str = "every ball, workspace and conversation in this world was read. Ctrl+F asks \
another needle; `/search` with no text closes this tab.";
pub fn run(snap: &Snapshot, text: &str, wanted: &dyn Fn() -> bool) -> Found {
let needle = text.trim().to_ascii_lowercase();
if needle.is_empty() {
return Found::default();
}
let mut found = Found {
needle: text.trim().to_owned(),
..Found::default()
};
found.unreadable = corpus::unreadable(snap);
for (at, fields) in corpus::from_snapshot(snap) {
push(&mut found.hits, &needle, at, &fields);
}
for (workspace, agent, mut fields) in corpus::conversations(snap) {
if !wanted() {
break;
}
fields.extend(corpus::read_conversation(
&workspace,
&agent,
&mut found.unreadable,
));
let at = Address::Conversation {
workspace: snap.ws_name(&workspace),
agent,
};
push(&mut found.hits, &needle, at, &fields);
}
found.unreadable.sort();
found.unreadable.dedup();
rank(&mut found.hits);
found
}
fn push(hits: &mut Vec<Hit>, needle: &str, at: Address, fields: &[(Field, String)]) {
let best = fields
.iter()
.filter_map(|(field, text)| {
let offset = text.to_ascii_lowercase().find(needle)?;
Some((*field, offset, excerpt(text, offset)))
})
.min_by_key(|(field, offset, _)| (*field, *offset));
if let Some((field, offset, excerpt)) = best {
hits.push(Hit {
at,
field,
offset,
excerpt,
});
}
}
pub fn label(hit: &Hit) -> String {
let at = match &hit.at {
Address::Ball { id, .. } => format!("ball {id}"),
Address::Workspace { name } => format!("workspace {name}"),
Address::Conversation { workspace, agent } => format!("{workspace}/{agent}"),
};
format!("{at} — {}", hit.excerpt)
}
fn rank(hits: &mut Vec<Hit>) {
hits.sort_by(|a, b| {
(a.field, a.offset)
.cmp(&(b.field, b.offset))
.then_with(|| a.at.cmp(&b.at))
});
hits.truncate(MAX);
}