use rusqlite::{types::Value, Connection};
use crate::error::Result;
use crate::model::{Item, ItemType};
use crate::store::{fill_tags, row_to_item};
#[derive(Debug, Clone, Default)]
pub struct Filter {
pub query: Option<String>,
pub category: Option<i64>,
pub tags: Vec<String>,
pub item_type: Option<ItemType>,
pub favorite_only: bool,
}
fn sanitize_fts(raw: &str) -> String {
raw.split_whitespace()
.map(|tok| {
let escaped = tok.replace('"', "\"\"");
format!("\"{escaped}\"")
})
.collect::<Vec<_>>()
.join(" ")
}
pub fn search(conn: &Connection, f: &Filter) -> Result<Vec<Item>> {
let mut sql = String::new();
let mut where_clauses: Vec<String> = Vec::new();
let mut bind_params: Vec<Value> = Vec::new();
let query_trimmed = f
.query
.as_ref()
.map(|s| s.trim())
.filter(|s| !s.is_empty());
if let Some(q) = query_trimmed {
sql.push_str(
"SELECT i.id, i.type, i.title, i.category_id, i.data, i.favorite, i.search_text, i.created_at, i.updated_at
FROM items_fts f JOIN items i ON i.id = f.rowid",
);
where_clauses.push("f.items_fts MATCH ?1".to_string());
bind_params.push(Value::from(sanitize_fts(q)));
} else {
sql.push_str(
"SELECT i.id, i.type, i.title, i.category_id, i.data, i.favorite, i.search_text, i.created_at, i.updated_at
FROM items i",
);
}
if let Some(cat) = f.category {
where_clauses.push("i.category_id = ?".to_string());
bind_params.push(Value::from(cat));
}
if let Some(ty) = f.item_type {
where_clauses.push("i.type = ?".to_string());
bind_params.push(Value::from(ty.as_str().to_string()));
}
if f.favorite_only {
where_clauses.push("i.favorite = 1".to_string());
}
if !f.tags.is_empty() {
let placeholders: Vec<String> = (0..f.tags.len())
.map(|_| "?".to_string())
.collect();
let sub = format!(
"EXISTS(SELECT 1 FROM item_tags it JOIN tags t ON t.id = it.tag_id
WHERE it.item_id = i.id AND t.name IN ({}))",
placeholders.join(", ")
);
where_clauses.push(sub);
for t in &f.tags {
bind_params.push(Value::from(t.clone()));
}
}
if !where_clauses.is_empty() {
sql.push_str(" WHERE ");
sql.push_str(&where_clauses.join(" AND "));
}
sql.push_str(" ORDER BY i.updated_at DESC");
let mut stmt = conn.prepare(&sql)?;
let binds: Vec<&dyn rusqlite::ToSql> = bind_params
.iter()
.map(|v| v as &dyn rusqlite::ToSql)
.collect();
let mut items: Vec<Item> = stmt
.query_map(binds.as_slice(), row_to_item)?
.filter_map(|r| r.ok())
.collect();
for it in items.iter_mut() {
fill_tags(conn, it)?;
}
Ok(items)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::Database;
use crate::model::{Item, ItemData, ItemType};
use crate::store;
fn make_password(title: &str, body: &str, tags: Vec<&str>, favorite: bool) -> Item {
Item {
id: None,
item_type: ItemType::Password,
title: title.into(),
category_id: None,
data: ItemData::Password {
username: "u".into(),
password: "p".into(),
url: "https://x".into(),
totp_secret: body.into(),
notes: body.into(),
},
favorite,
tags: tags.into_iter().map(String::from).collect(),
created_at: 0,
updated_at: 0,
}
}
fn seed() -> Database {
let db = Database::open_in_memory().unwrap();
let conn = db.conn();
let mut cat = crate::model::Category {
id: None,
name: "Work".into(),
parent_id: None,
sort_order: 0,
};
let cid = store::insert_category(conn, &mut cat).unwrap();
let mut a = make_password("GitHub Login", "github myuser", vec!["work", "vip"], true);
a.category_id = Some(cid);
store::insert_item(conn, &mut a).unwrap();
let mut b = make_password("GitLab Notes", "gitlab secret", vec!["work"], false);
b.category_id = Some(cid);
store::insert_item(conn, &mut b).unwrap();
let mut c = Item {
id: None,
item_type: ItemType::Note,
title: "Personal Diary".into(),
category_id: None,
data: ItemData::Note {
format: "markdown".into(),
content: "today was a good day".into(),
},
favorite: false,
tags: vec!["personal".into()],
created_at: 0,
updated_at: 0,
};
store::insert_item(conn, &mut c).unwrap();
db
}
#[test]
fn query_hit_and_miss() {
let db = seed();
let conn = db.conn();
let hits = search(
conn,
&Filter {
query: Some("github".into()),
..Default::default()
},
)
.unwrap();
assert_eq!(hits.len(), 1, "should match only GitHub item");
assert_eq!(hits[0].title, "GitHub Login");
let miss = search(
conn,
&Filter {
query: Some("zzznomatch".into()),
..Default::default()
},
)
.unwrap();
assert!(miss.is_empty());
}
#[test]
fn query_empty_returns_all() {
let db = seed();
let conn = db.conn();
let all = search(conn, &Filter::default()).unwrap();
assert_eq!(all.len(), 3);
}
#[test]
fn filter_by_category() {
let db = seed();
let conn = db.conn();
let cats = store::list_categories(conn).unwrap();
let cid = cats[0].id.unwrap();
let res = search(
conn,
&Filter {
category: Some(cid),
..Default::default()
},
)
.unwrap();
assert_eq!(res.len(), 2);
for it in &res {
assert_eq!(it.category_id, Some(cid));
}
}
#[test]
fn filter_by_tags() {
let db = seed();
let conn = db.conn();
let vip = search(
conn,
&Filter {
tags: vec!["vip".into()],
..Default::default()
},
)
.unwrap();
assert_eq!(vip.len(), 1);
assert_eq!(vip[0].title, "GitHub Login");
let work = search(
conn,
&Filter {
tags: vec!["work".into()],
..Default::default()
},
)
.unwrap();
assert_eq!(work.len(), 2);
}
#[test]
fn filter_by_item_type() {
let db = seed();
let conn = db.conn();
let notes = search(
conn,
&Filter {
item_type: Some(ItemType::Note),
..Default::default()
},
)
.unwrap();
assert_eq!(notes.len(), 1);
assert_eq!(notes[0].item_type, ItemType::Note);
}
#[test]
fn filter_favorite_only() {
let db = seed();
let conn = db.conn();
let fav = search(
conn,
&Filter {
favorite_only: true,
..Default::default()
},
)
.unwrap();
assert_eq!(fav.len(), 1);
assert!(fav[0].favorite);
}
#[test]
fn combined_filter() {
let db = seed();
let conn = db.conn();
let cats = store::list_categories(conn).unwrap();
let cid = cats[0].id.unwrap();
let res = search(
conn,
&Filter {
category: Some(cid),
tags: vec!["vip".into()],
..Default::default()
},
)
.unwrap();
assert_eq!(res.len(), 1);
assert_eq!(res[0].title, "GitHub Login");
}
#[test]
fn sanitize_fts_quotes_tokens() {
assert_eq!(sanitize_fts("hello world"), "\"hello\" \"world\"");
assert_eq!(sanitize_fts("a\"b"), "\"a\"\"b\"");
}
}