1use crate::error::Result;
7use crate::fts::prepare_search_query;
8use crate::storage::Database;
9use crate::types::{Node, NodeType};
10use rusqlite::params;
11use serde::{Deserialize, Serialize};
12
13#[derive(Debug, Clone, Serialize, Deserialize)]
18pub struct RankedHit {
19 pub node: Node,
21 pub score: f32,
23 pub reasons: Vec<String>,
25}
26
27#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
29pub enum ScopeMainInclude {
30 Strict,
32 #[default]
37 HubsOnly,
38 AllMain,
40}
41
42#[derive(Debug, Clone)]
44pub struct QueryOptions {
45 pub limit: usize,
47 pub type_boosts: Vec<(NodeType, f32)>,
51 pub include_types: Vec<NodeType>,
53 pub exclude_types: Vec<NodeType>,
55 pub no_symbols: bool,
57 pub scope: Option<String>,
59 pub main_scope: String,
61 pub scope_main: ScopeMainInclude,
63}
64
65impl Default for QueryOptions {
66 fn default() -> Self {
67 Self {
68 limit: 50,
69 type_boosts: vec![
70 (NodeType::Goal, 1.15),
71 (NodeType::Changelog, 1.12),
72 (NodeType::Plan, 1.12),
73 (NodeType::Adr, 1.1),
74 (NodeType::EdgeCase, 1.1),
75 (NodeType::Analysis, 1.08),
76 (NodeType::Concept, 1.05),
77 (NodeType::Symbol, 0.95),
78 ],
79 include_types: Vec::new(),
80 exclude_types: Vec::new(),
81 no_symbols: false,
82 scope: None,
83 main_scope: crate::scopes::MAIN_SCOPE.to_string(),
84 scope_main: ScopeMainInclude::HubsOnly,
85 }
86 }
87}
88
89impl QueryOptions {
90 pub fn human() -> Self {
92 Self {
93 no_symbols: true,
94 ..Self::default()
95 }
96 }
97
98 pub fn with_scope(mut self, scope: impl Into<String>) -> Self {
100 self.scope = Some(scope.into());
101 self
102 }
103
104 fn allows(&self, ty: &NodeType) -> bool {
105 if self.no_symbols && *ty == NodeType::Symbol {
106 return false;
107 }
108 if !self.include_types.is_empty() && !self.include_types.contains(ty) {
109 return false;
110 }
111 if self.exclude_types.contains(ty) {
112 return false;
113 }
114 true
115 }
116
117 pub fn allows_scope(&self, node_scope: &str, node_id: &str) -> bool {
119 let Some(want) = self.scope.as_deref() else {
120 return true;
121 };
122 if node_scope == want {
123 return true;
124 }
125 match self.scope_main {
126 ScopeMainInclude::Strict => false,
127 ScopeMainInclude::HubsOnly => crate::hubs::is_hub_node_id(node_id),
128 ScopeMainInclude::AllMain => node_scope == self.main_scope,
129 }
130 }
131}
132
133fn map_ranked_row(row: &rusqlite::Row<'_>) -> rusqlite::Result<(Node, f32)> {
134 let type_str: String = row.get(1)?;
135 let node_type = NodeType::parse(&type_str).unwrap_or(NodeType::Concept);
136 let symbol_hash_raw: Option<i64> = row.get(4)?;
137 let scope: String = row
138 .get::<_, Option<String>>(7)?
139 .filter(|s| !s.is_empty())
140 .unwrap_or_else(|| crate::scopes::MAIN_SCOPE.to_string());
141 let bm25: f64 = row.get(10)?;
142 Ok((
143 Node {
144 id: row.get(0)?,
145 node_type,
146 title: row.get(2)?,
147 file_path: row.get(3)?,
148 symbol_hash: symbol_hash_raw.map(|h| h as u64),
149 summary: row.get(5)?,
150 content_hash: row.get(6)?,
151 scope,
152 created_at: row.get(8)?,
153 updated_at: row.get(9)?,
154 },
155 bm25 as f32,
156 ))
157}
158
159impl Database {
160 pub fn search_ranked(&self, query: &str, opts: &QueryOptions) -> Result<Vec<RankedHit>> {
174 let prepared = prepare_search_query(query)?;
175 let tokens = &prepared.tokens;
176
177 let sql_limit = (opts.limit as i64).saturating_mul(2).max(50);
179
180 let (sql, bind_scope): (String, Option<(String, ScopeMainInclude, String)>) =
181 if let Some(ref want) = opts.scope {
182 let clause = match opts.scope_main {
183 ScopeMainInclude::Strict => {
184 " AND n.scope = ?2 ".to_string()
185 }
186 ScopeMainInclude::HubsOnly => {
187 " AND (n.scope = ?2 OR n.id IN ('readme','changelog','roadmap','backlog')) "
188 .to_string()
189 }
190 ScopeMainInclude::AllMain => {
191 " AND (n.scope = ?2 OR n.scope = ?3) ".to_string()
192 }
193 };
194 (
195 format!(
196 "SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
197 n.content_hash, n.scope, n.created_at, n.updated_at,
198 bm25(node_fts) AS bm25_score
199 FROM node_fts f
200 JOIN nodes n ON n.id = f.node_id
201 WHERE node_fts MATCH ?1
202 {clause}
203 ORDER BY bm25_score
204 LIMIT ?{lim}",
205 lim = if matches!(opts.scope_main, ScopeMainInclude::AllMain) {
206 4
207 } else {
208 3
209 }
210 ),
211 Some((want.clone(), opts.scope_main, opts.main_scope.clone())),
212 )
213 } else {
214 (
215 "SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
216 n.content_hash, n.scope, n.created_at, n.updated_at,
217 bm25(node_fts) AS bm25_score
218 FROM node_fts f
219 JOIN nodes n ON n.id = f.node_id
220 WHERE node_fts MATCH ?1
221 ORDER BY bm25_score
222 LIMIT ?2"
223 .to_string(),
224 None,
225 )
226 };
227
228 let mut stmt = self.conn.prepare(&sql)?;
229 let rows = match &bind_scope {
230 None => stmt
231 .query_map(params![prepared.fts_match, sql_limit], map_ranked_row)?
232 .collect::<std::result::Result<Vec<_>, _>>()?,
233 Some((want, ScopeMainInclude::AllMain, main)) => stmt
234 .query_map(
235 params![prepared.fts_match, want.as_str(), main.as_str(), sql_limit],
236 map_ranked_row,
237 )?
238 .collect::<std::result::Result<Vec<_>, _>>()?,
239 Some((want, _, _)) => stmt
240 .query_map(params![prepared.fts_match, want.as_str(), sql_limit], map_ranked_row)?
241 .collect::<std::result::Result<Vec<_>, _>>()?,
242 };
243
244 let mut hits: Vec<RankedHit> = Vec::new();
245 for (node, bm25) in rows {
246 if !opts.allows(&node.node_type) {
247 continue;
248 }
249 if !opts.allows_scope(&node.scope, &node.id) {
250 continue;
251 }
252 let fts_score = (-bm25).max(0.01);
254 let mut score = fts_score;
255 let mut reasons = vec![format!("fts:{fts_score:.3}")];
256 if prepared.used_or {
257 reasons.push("fts:or".into());
258 }
259 if node.id == "readme" || node.file_path.as_deref() == Some("README.md") {
261 score *= 1.2;
262 reasons.push("hub:readme".into());
263 }
264 if node.id == crate::hubs::HUB_CHANGELOG
265 || node
266 .file_path
267 .as_deref()
268 .is_some_and(|p| p.eq_ignore_ascii_case("CHANGELOG.md"))
269 {
270 score *= 1.15;
271 reasons.push("hub:changelog".into());
272 }
273 if node.id == crate::hubs::HUB_ROADMAP || node.id == crate::hubs::HUB_BACKLOG {
274 score *= 1.1;
275 reasons.push(format!("hub:{}", node.id));
276 }
277
278 let title_l = node.title.to_lowercase();
280 let id_l = node.id.to_lowercase();
281 let fts_body = self
282 .get_fts_content(&node.id)
283 .ok()
284 .flatten()
285 .unwrap_or_default()
286 .to_lowercase();
287 let mut coverage = 0usize;
288 for t in tokens {
289 let mut hit_tok = false;
290 if title_l.split_whitespace().any(|w| w == t) || title_l.contains(t.as_str()) {
291 score += 2.0;
292 reasons.push(format!("title:{t}"));
293 hit_tok = true;
294 }
295 if id_l.rsplit('/').next() == Some(t.as_str()) || id_l.contains(t.as_str()) {
296 score += 1.5;
297 reasons.push(format!("id:{t}"));
298 hit_tok = true;
299 }
300 if !fts_body.is_empty() && fts_body.contains(t.as_str()) {
301 score += 0.75;
302 hit_tok = true;
303 }
304 if hit_tok {
305 coverage += 1;
306 }
307 }
308 if tokens.len() > 1 && coverage > 1 {
309 let bonus = 1.5 * (coverage as f32);
310 score += bonus;
311 reasons.push(format!("coverage:{coverage}/{}", tokens.len()));
312 }
313
314 let tags = self.get_tags_for(&node.id)?;
316 for tag in &tags {
317 let tag_l = tag.to_lowercase();
318 for t in tokens {
319 if tag_l == *t || tag_l.contains(t.as_str()) {
320 score += 3.0;
321 reasons.push(format!("tag:{tag}"));
322 }
323 }
324 }
325
326 let aliases = self.get_aliases_for(&node.id)?;
328 for alias in &aliases {
329 let a = alias.to_lowercase();
330 for t in tokens {
331 if a == *t || a.contains(t.as_str()) {
332 score += 2.5;
333 reasons.push(format!("alias:{alias}"));
334 }
335 }
336 }
337
338 for (ty, mult) in &opts.type_boosts {
340 if node.node_type == *ty {
341 score *= mult;
342 reasons.push(format!("type:{}×{mult}", ty.as_str()));
343 }
344 }
345
346 hits.push(RankedHit {
347 node,
348 score,
349 reasons,
350 });
351 }
352
353 self.augment_with_tag_alias_hits(tokens, &mut hits, opts)?;
355
356 hits.retain(|h| {
357 opts.allows(&h.node.node_type) && opts.allows_scope(&h.node.scope, &h.node.id)
358 });
359
360 hits.sort_by(|a, b| {
361 b.score
362 .partial_cmp(&a.score)
363 .unwrap_or(std::cmp::Ordering::Equal)
364 });
365 let mut seen = std::collections::HashSet::new();
367 hits.retain(|h| seen.insert(h.node.id.clone()));
368 hits.truncate(opts.limit);
369 Ok(hits)
370 }
371
372 pub fn get_tags_for(&self, node_id: &str) -> Result<Vec<String>> {
374 let mut stmt = self
375 .conn
376 .prepare("SELECT tag FROM node_tags WHERE node_id = ?1")?;
377 let rows = stmt.query_map([node_id], |row| row.get(0))?;
378 let mut v = Vec::new();
379 for r in rows {
380 v.push(r?);
381 }
382 Ok(v)
383 }
384
385 fn get_aliases_for(&self, node_id: &str) -> Result<Vec<String>> {
386 let mut stmt = self
387 .conn
388 .prepare("SELECT alias FROM node_aliases WHERE node_id = ?1")?;
389 let rows = stmt.query_map([node_id], |row| row.get(0))?;
390 let mut v = Vec::new();
391 for r in rows {
392 v.push(r?);
393 }
394 Ok(v)
395 }
396
397 fn augment_with_tag_alias_hits(
398 &self,
399 tokens: &[String],
400 hits: &mut Vec<RankedHit>,
401 opts: &QueryOptions,
402 ) -> Result<()> {
403 let existing: std::collections::HashSet<String> =
404 hits.iter().map(|h| h.node.id.clone()).collect();
405
406 for t in tokens {
407 let mut stmt = self.conn.prepare(
409 "SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
410 n.content_hash, n.scope, n.created_at, n.updated_at
411 FROM node_tags t
412 JOIN nodes n ON n.id = t.node_id
413 WHERE lower(t.tag) = ?1
414 LIMIT 20",
415 )?;
416 let rows = stmt.query_map([t.as_str()], Database::map_node_row)?;
417 for r in rows {
418 let node = r?;
419 if existing.contains(&node.id)
420 || !opts.allows(&node.node_type)
421 || !opts.allows_scope(&node.scope, &node.id)
422 {
423 continue;
424 }
425 hits.push(RankedHit {
426 node,
427 score: 3.5,
428 reasons: vec![format!("tag-exact:{t}")],
429 });
430 }
431
432 let mut stmt = self.conn.prepare(
434 "SELECT n.id, n.node_type, n.title, n.file_path, n.symbol_hash, n.summary,
435 n.content_hash, n.scope, n.created_at, n.updated_at
436 FROM node_aliases a
437 JOIN nodes n ON n.id = a.node_id
438 WHERE a.alias = ?1
439 LIMIT 20",
440 )?;
441 let rows = stmt.query_map([t.as_str()], Database::map_node_row)?;
442 for r in rows {
443 let node = r?;
444 if hits.iter().any(|h| h.node.id == node.id)
445 || !opts.allows(&node.node_type)
446 || !opts.allows_scope(&node.scope, &node.id)
447 {
448 continue;
449 }
450 hits.push(RankedHit {
451 node,
452 score: 3.0,
453 reasons: vec![format!("alias-exact:{t}")],
454 });
455 }
456 }
457 Ok(())
458 }
459
460 pub fn list_pending_links(&self) -> Result<Vec<PendingLink>> {
462 let mut stmt = self.conn.prepare(
463 "SELECT source_id, raw_target, relation_type, created_at FROM pending_links ORDER BY source_id, raw_target",
464 )?;
465 let rows = stmt.query_map([], |row| {
466 Ok(PendingLink {
467 source_id: row.get(0)?,
468 raw_target: row.get(1)?,
469 relation_type: row.get(2)?,
470 created_at: row.get(3)?,
471 })
472 })?;
473 let mut out = Vec::new();
474 for r in rows {
475 out.push(r?);
476 }
477 Ok(out)
478 }
479
480 pub fn count_nodes_by_type(&self) -> Result<Vec<(String, usize)>> {
482 let mut stmt = self
483 .conn
484 .prepare("SELECT node_type, COUNT(*) FROM nodes GROUP BY node_type ORDER BY COUNT(*) DESC")?;
485 let rows = stmt.query_map([], |row| {
486 Ok((row.get::<_, String>(0)?, row.get::<_, usize>(1)?))
487 })?;
488 let mut out = Vec::new();
489 for r in rows {
490 out.push(r?);
491 }
492 Ok(out)
493 }
494}
495
496#[derive(Debug, Clone, Serialize, Deserialize)]
498pub struct PendingLink {
499 pub source_id: String,
501 pub raw_target: String,
503 pub relation_type: String,
505 pub created_at: i64,
507}
508
509#[cfg(test)]
510mod tests {
511 use super::*;
512 use crate::types::NodeType;
513
514 #[test]
515 fn tag_boost_ranks_higher() {
516 let db = Database::open_in_memory().unwrap();
517 let n1 = Node {
518 id: "docs/a".into(),
519 node_type: NodeType::Concept,
520 title: "Something Else".into(),
521 file_path: None,
522 symbol_hash: None,
523 summary: Some("mentions raft in body".into()),
524 content_hash: None,
525 scope: crate::scopes::MAIN_SCOPE.to_string(),
526 created_at: 1,
527 updated_at: 1,
528 };
529 let n2 = Node {
530 id: "docs/b".into(),
531 node_type: NodeType::Concept,
532 title: "Protocol".into(),
533 file_path: None,
534 symbol_hash: None,
535 summary: Some("tagged note".into()),
536 content_hash: None,
537 scope: crate::scopes::MAIN_SCOPE.to_string(),
538 created_at: 1,
539 updated_at: 1,
540 };
541 db.insert_node(&n1).unwrap();
542 db.insert_node(&n2).unwrap();
543 db.index_fts("docs/a", "Something Else", "the raft algorithm", "").unwrap();
544 db.index_fts("docs/b", "Protocol", "tagged note about consensus", "raft").unwrap();
545 db.replace_node_tags("docs/b", &["raft".into()]).unwrap();
546
547 let hits = db
548 .search_ranked("raft", &QueryOptions::default())
549 .unwrap();
550 assert!(hits.len() >= 2);
551 assert!(hits.iter().any(|h| h.node.id == "docs/b"));
553 let b = hits.iter().find(|h| h.node.id == "docs/b").unwrap();
554 assert!(b.reasons.iter().any(|r| r.starts_with("tag")));
555 }
556}