Skip to main content

grepdown_lib/
search.rs

1use crate::error::Result;
2use crate::frontmatter::{extract_tags, parse_frontmatter};
3use crate::project::MDDBProject;
4use rusqlite::{Connection, params};
5use serde::Serialize;
6use std::path::Path;
7
8/// Escape a query string so FTS5 treats it as a literal phrase.
9/// Wraps the input in double quotes and escapes any inner `"` as `""`.
10pub fn escape_fts5_query(query: &str) -> String {
11    format!("\"{}\"", query.replace('"', "\"\""))
12}
13
14#[derive(Debug, Clone, PartialEq, Serialize)]
15pub struct SearchResult {
16    pub path: String,
17    pub snippet: String,
18    pub score: f64,
19}
20
21#[derive(Debug, Clone, PartialEq, Serialize)]
22pub struct Link {
23    pub target: String,
24    pub raw_target: Option<String>,
25}
26
27#[derive(Debug, Clone, PartialEq, Serialize)]
28pub struct ReachableNode {
29    pub path: String,
30    pub depth: i64,
31}
32
33#[derive(Debug, Clone, PartialEq, Serialize)]
34pub struct DocumentContent {
35    pub path: String,
36    pub content: String,
37    pub tags: Vec<String>,
38}
39
40fn query_links(conn: &Connection, sql: &str, id: &str) -> Result<Vec<Link>> {
41    let mut stmt = conn.prepare_cached(sql)?;
42    stmt.query_map(params![id], |row| {
43        Ok(Link {
44            target: row.get(0)?,
45            raw_target: row.get(1)?,
46        })
47    })?
48    .map(|r| r.map_err(Into::into))
49    .collect()
50}
51
52impl MDDBProject {
53    /// Search the indexed documents using FTS5 full-text search.
54    ///
55    /// The query string supports FTS5 syntax (e.g., "word1 word2", "word1 OR word2",
56    /// "word1 NEAR word2", "prefix*"). Searches body content and tags.
57    ///
58    /// Results are ranked by BM25 relevance (lower score = better match).
59    pub fn search(
60        &self,
61        query: &str,
62        limit: usize,
63        path_filter: Option<&str>,
64        snippet_length: Option<i64>,
65    ) -> Result<Vec<SearchResult>> {
66        let conn = self.get_conn();
67        let path_like = match path_filter {
68            Some(prefix) => format!("{}%", prefix),
69            None => "%".to_string(),
70        };
71        let snippet_len = snippet_length.unwrap_or(32);
72        let limit_i64 = limit as i64;
73        let mut stmt = conn.prepare_cached(
74            "SELECT path, snippet, score
75             FROM (
76                 SELECT path, snippet, score,
77                        ROW_NUMBER() OVER (
78                            PARTITION BY path 
79                            ORDER BY score, source_priority
80                        ) as rn
81                 FROM (
82                     SELECT path,
83                            snippet(documents_fts, 1, '<b>', '</b>', ' ... ', ?4) as snippet,
84                            bm25(documents_fts) as score,
85                            1 as source_priority
86                     FROM documents_fts
87                     WHERE documents_fts MATCH ?1 AND path LIKE ?3
88                     UNION ALL
89                     SELECT path,
90                            headings as snippet,
91                            bm25(headings_fts, 20.0) as score,
92                            2 as source_priority
93                     FROM headings_fts
94                     WHERE headings_fts MATCH ?1 AND path LIKE ?3
95                     UNION ALL
96                     SELECT path,
97                            tags as snippet,
98                            bm25(tags_fts, 10.0) as score,
99                            3 as source_priority
100                     FROM tags_fts
101                     WHERE tags_fts MATCH ?1 AND path LIKE ?3
102                 )
103             )
104             WHERE rn = 1
105             ORDER BY score
106             LIMIT ?2",
107        )?;
108
109        stmt.query_map(params![query, limit_i64, path_like, snippet_len], |row| {
110            Ok(SearchResult {
111                path: row.get(0)?,
112                snippet: row.get(1)?,
113                score: row.get(2)?,
114            })
115        })?
116        .map(|r| r.map_err(Into::into))
117        .collect::<Result<Vec<_>>>()
118    }
119
120    /// Get all links from a document (forward traversal).
121    /// Returns cross-references to other documents.
122    pub fn get_links_from(&self, from_id: &str) -> Result<Vec<Link>> {
123        query_links(
124            self.get_conn(),
125            "SELECT to_id, raw_target FROM links WHERE from_id = ?1",
126            from_id,
127        )
128    }
129
130    /// Get all citations (external URLs) from a document.
131    pub fn get_citations_from(&self, from_id: &str) -> Result<Vec<String>> {
132        let conn = self.get_conn();
133        let mut stmt = conn.prepare_cached("SELECT url FROM citations WHERE from_id = ?1")?;
134        stmt.query_map(params![from_id], |row| row.get(0))?
135            .map(|r| r.map_err(Into::into))
136            .collect()
137    }
138
139    /// Get all links to a document (reverse traversal / backlinks).
140    pub fn get_links_to(&self, to_id: &str) -> Result<Vec<Link>> {
141        query_links(
142            self.get_conn(),
143            "SELECT from_id, raw_target FROM links WHERE to_id = ?1",
144            to_id,
145        )
146    }
147
148    /// BFS traversal: get all nodes reachable from a starting node up to max_depth hops.
149    /// Returns nodes with their minimum depth from the start.
150    pub fn get_reachable(&self, from_id: &str, max_depth: i64) -> Result<Vec<ReachableNode>> {
151        let conn = self.get_conn();
152        let mut stmt = conn.prepare_cached(
153            "WITH RECURSIVE bfs AS (
154                SELECT to_id AS node, 1 AS depth 
155                FROM links 
156                WHERE from_id = ?1
157                UNION ALL
158                SELECT l.to_id, bfs.depth + 1
159                FROM links l 
160                JOIN bfs ON l.from_id = bfs.node
161                WHERE bfs.depth < ?2
162            )
163            SELECT node, MIN(depth) AS depth 
164            FROM bfs 
165            GROUP BY node 
166            ORDER BY depth",
167        )?;
168
169        stmt.query_map(params![from_id, max_depth], |row| {
170            Ok(ReachableNode {
171                path: row.get(0)?,
172                depth: row.get(1)?,
173            })
174        })?
175        .map(|r| r.map_err(Into::into))
176        .collect::<Result<Vec<_>>>()
177    }
178
179    /// Read a document's content and extract its frontmatter tags.
180    /// The path should be relative to the project root.
181    pub fn read_document(&self, path: &str) -> Result<DocumentContent> {
182        let full_path = Path::new(self.get_root()).join(path);
183        let content = std::fs::read_to_string(&full_path)?;
184        let tags = parse_frontmatter(&content)
185            .map(|fm| extract_tags(&fm))
186            .unwrap_or_default();
187
188        Ok(DocumentContent {
189            path: path.to_string(),
190            content,
191            tags,
192        })
193    }
194}