use semtree_core::{Chunk, ChunkKind, Language};
use crate::RagError;
const OVERFETCH_FACTOR: usize = 10;
const OVERFETCH_FLOOR: usize = 50;
#[derive(Debug, Clone, Default)]
pub struct SearchFilters {
languages: Vec<Language>,
kinds: Vec<ChunkKind>,
path: Option<String>,
}
impl SearchFilters {
pub fn with_languages(mut self, languages: impl IntoIterator<Item = Language>) -> Self {
self.languages.extend(languages);
self
}
pub fn with_kinds(mut self, kinds: impl IntoIterator<Item = ChunkKind>) -> Self {
self.kinds.extend(kinds);
self
}
pub fn with_path(mut self, needle: impl Into<String>) -> Self {
self.path = Some(needle.into());
self
}
pub fn with_language_names<S: AsRef<str>>(
self,
names: impl IntoIterator<Item = S>,
) -> Result<Self, RagError> {
let languages = names
.into_iter()
.map(|name| {
Language::from_name(name.as_ref()).ok_or_else(|| {
RagError::Filter(format!(
"unknown language '{}' (expected one of: {})",
name.as_ref(),
join_names(Language::ALL)
))
})
})
.collect::<Result<Vec<_>, _>>()?;
Ok(self.with_languages(languages))
}
pub fn with_kind_names<S: AsRef<str>>(
self,
names: impl IntoIterator<Item = S>,
) -> Result<Self, RagError> {
let kinds = names
.into_iter()
.map(|name| {
ChunkKind::from_name(name.as_ref()).ok_or_else(|| {
RagError::Filter(format!(
"unknown kind '{}' (expected one of: {})",
name.as_ref(),
join_names(ChunkKind::ALL)
))
})
})
.collect::<Result<Vec<_>, _>>()?;
Ok(self.with_kinds(kinds))
}
pub fn is_empty(&self) -> bool {
self.languages.is_empty() && self.kinds.is_empty() && self.path.is_none()
}
pub fn matches(&self, chunk: &Chunk) -> bool {
if !self.languages.is_empty() && !self.languages.contains(&chunk.language) {
return false;
}
if !self.kinds.is_empty() && !self.kinds.contains(&chunk.kind) {
return false;
}
if let Some(needle) = &self.path
&& !chunk.path.to_string_lossy().contains(needle.as_str())
{
return false;
}
true
}
pub fn fetch_size(&self, top_k: usize) -> usize {
if self.is_empty() {
top_k
} else {
(top_k * OVERFETCH_FACTOR).max(OVERFETCH_FLOOR)
}
}
}
fn join_names<T: std::fmt::Display>(values: &[T]) -> String {
values
.iter()
.map(|v| v.to_string())
.collect::<Vec<_>>()
.join(", ")
}
#[cfg(test)]
mod tests {
use semtree_core::Span;
use super::*;
fn chunk(id: &str, language: Language, kind: ChunkKind, path: &str) -> Chunk {
Chunk {
id: id.to_string(),
path: path.into(),
language,
kind,
name: Some(id.to_string()),
content: String::new(),
span: Span::new(0, 0, 0, 0),
doc: None,
}
}
#[test]
fn empty_filters_match_everything() {
let filters = SearchFilters::default();
assert!(filters.is_empty());
assert!(filters.matches(&chunk("a", Language::Zig, ChunkKind::Struct, "src/a.zig")));
}
#[test]
fn filters_combine_as_and() {
let filters = SearchFilters::default()
.with_languages([Language::Rust])
.with_kinds([ChunkKind::Function])
.with_path("src/");
assert!(filters.matches(&chunk(
"ok",
Language::Rust,
ChunkKind::Function,
"src/a.rs"
)));
assert!(!filters.matches(&chunk(
"no",
Language::Rust,
ChunkKind::Function,
"tests/a.rs"
)));
assert!(!filters.matches(&chunk("no", Language::Go, ChunkKind::Function, "src/a.go")));
}
#[test]
fn unknown_names_are_rejected_rather_than_matching_nothing() {
let err = SearchFilters::default()
.with_language_names(["rust", "cobol"])
.unwrap_err();
assert!(err.to_string().contains("cobol"), "names the bad value");
assert!(err.to_string().contains("rust"), "lists valid values");
assert!(SearchFilters::default().with_kind_names(["gizmo"]).is_err());
}
#[test]
fn every_supported_language_is_accepted_by_name() {
for lang in Language::ALL {
assert!(
SearchFilters::default()
.with_language_names([lang.to_string()])
.is_ok(),
"{lang} is not accepted as a filter"
);
}
}
#[test]
fn overfetches_only_when_filtering() {
assert_eq!(SearchFilters::default().fetch_size(5), 5);
let filtered = SearchFilters::default().with_path("src/");
assert!(filtered.fetch_size(5) >= OVERFETCH_FLOOR);
assert!(filtered.fetch_size(100) >= 100 * OVERFETCH_FACTOR);
}
}