Skip to main content

obsidian_cli_inspector/query/
search.rs

1use rusqlite::{Connection, Result};
2
3#[derive(Debug, Clone)]
4pub struct SearchResult {
5    pub chunk_id: i64,
6    pub note_id: i64,
7    pub note_path: String,
8    pub note_title: String,
9    pub heading_path: Option<String>,
10    pub chunk_text: String,
11    pub rank: f32,
12}
13
14/// Search chunks using FTS5 full-text search with BM25 ranking
15pub fn search_chunks(conn: &Connection, query: &str, limit: usize) -> Result<Vec<SearchResult>> {
16    let mut stmt = conn.prepare(
17        "SELECT 
18            c.id,
19            n.id,
20            n.path,
21            n.title,
22            c.heading_path,
23            c.text,
24            rank
25         FROM fts_chunks fc
26         JOIN chunks c ON fc.rowid = c.id
27         JOIN notes n ON c.note_id = n.id
28         WHERE fts_chunks MATCH ?1
29            ORDER BY rank, n.path COLLATE NOCASE, c.byte_offset, c.id
30         LIMIT ?2",
31    )?;
32
33    let results = stmt.query_map([query, &limit.to_string()], |row| {
34        Ok(SearchResult {
35            chunk_id: row.get(0)?,
36            note_id: row.get(1)?,
37            note_path: row.get(2)?,
38            note_title: row.get(3)?,
39            heading_path: row.get(4)?,
40            chunk_text: row.get(5)?,
41            rank: row.get(6)?,
42        })
43    })?;
44
45    let mut search_results = Vec::new();
46    for result in results {
47        search_results.push(result?);
48    }
49
50    Ok(search_results)
51}
52
53#[cfg(test)]
54mod tests {
55    use super::*;
56
57    #[test]
58    fn test_search_result_creation() {
59        let result = SearchResult {
60            chunk_id: 1,
61            note_id: 1,
62            note_path: "test.md".to_string(),
63            note_title: "Test".to_string(),
64            heading_path: Some("Heading".to_string()),
65            chunk_text: "Test content".to_string(),
66            rank: 1.0,
67        };
68
69        assert_eq!(result.chunk_id, 1);
70        assert!(result.heading_path.is_some());
71    }
72
73    #[test]
74    fn test_search_result_no_heading() {
75        let result = SearchResult {
76            chunk_id: 1,
77            note_id: 1,
78            note_path: "test.md".to_string(),
79            note_title: "Test".to_string(),
80            heading_path: None,
81            chunk_text: "Test content".to_string(),
82            rank: 1.0,
83        };
84
85        assert!(result.heading_path.is_none());
86    }
87}