prompt refactor

This commit is contained in:
nvrl committed 2026-07-22 09:50:42 +02:00
1 parent 6cba77d3cb
commit 39ab4aae48
14 files changed
+1409 -111

No files matched your search

+53
View File
@@ -7,6 +7,7 @@ use super::Db;
use crate::Result;
use crate::localindex::LocalEntry;
use super::nodes::candidate_trigrams;
use super::utils::{TRIGRAM_MIN, like_escape};
/// One hit from [`Db::search_local`]: an indexed file on the machine itself, not
@@ -147,6 +148,58 @@ impl Db {
}
Ok(out)
}
/// Bounded candidates for the fuzzy scorer, including matches in the full
/// local path. ORed trigrams admit misspellings while the FTS index prevents
/// this from becoming an unbounded filesystem-table scan.
pub fn search_local_candidates(&self, query: &str, limit: usize) -> Result<Vec<LocalFileHit>> {
let query = query.trim();
if query.is_empty() || limit == 0 {
return Ok(Vec::new());
}
let conn = self.conn.lock();
let terms = candidate_trigrams(query);
let lane_limit = limit.div_ceil(2);
let mut rows: Vec<LocalFileHit> = if terms.is_empty() {
let pat = format!("%{}%", like_escape(query));
let mut stmt = conn.prepare(
"SELECT path, name, is_dir, size, mtime FROM local_files
WHERE name LIKE ?1 ESCAPE '\\' OR path LIKE ?1 ESCAPE '\\'
ORDER BY mtime DESC LIMIT ?2",
)?;
stmt.query_map(params![pat, limit as i64], local_hit)?
.collect::<std::result::Result<_, _>>()?
} else {
let mut stmt = conn.prepare(
"SELECT f.path, f.name, f.is_dir, f.size, f.mtime
FROM local_fts x JOIN local_files f ON f.id = x.rowid
WHERE local_fts MATCH ?1
ORDER BY x.rank LIMIT ?2",
)?;
stmt.query_map(params![terms.join(" OR "), lane_limit as i64], local_hit)?
.collect::<std::result::Result<_, _>>()?
};
if !terms.is_empty()
&& let Some(first) = query.chars().next()
{
let prefix = format!("{}%", like_escape(&first.to_string()));
let mut stmt = conn.prepare(
"SELECT path, name, is_dir, size, mtime FROM local_files
WHERE name COLLATE NOCASE LIKE ?1 ESCAPE '\\'
ORDER BY name COLLATE NOCASE LIMIT ?2",
)?;
let fallback = stmt
.query_map(params![prefix, lane_limit as i64], local_hit)?
.collect::<std::result::Result<Vec<_>, _>>()?;
for hit in fallback {
if !rows.iter().any(|existing| existing.path == hit.path) {
rows.push(hit);
}
}
rows.truncate(limit);
}
Ok(rows)
}
}
/// Row → [`LocalFileHit`]. Every local-search query selects the same
+38 -1
View File
@@ -9,7 +9,7 @@ use super::Db;
use crate::Result;
/// Current schema version. Bump on every forward migration added below.
pub(super) const SCHEMA_VERSION: i64 = 15;
pub(super) const SCHEMA_VERSION: i64 = 16;
impl Db {
pub(super) fn migrate(&self) -> Result<()> {
@@ -81,6 +81,9 @@ impl Db {
if current < 15 {
tx.execute_batch(MIGRATION_V15)?;
}
if current < 16 {
tx.execute_batch(MIGRATION_V16)?;
}
tx.execute(
"INSERT INTO sync_state (key, value) VALUES ('schema_version', ?1)
ON CONFLICT(key) DO UPDATE SET value = excluded.value",
@@ -369,3 +372,37 @@ CREATE VIRTUAL TABLE nodes_fts USING fts5(
INSERT INTO nodes_fts (rowid, name)
SELECT rowid, name FROM nodes WHERE trashed = 0;
";
/// Schema v16: fuzzy search needs a bounded candidate set that includes parent
/// paths. Keeping the path in the trigram indexes lets SQLite narrow both name
/// and path candidates without walking either base table for every keystroke.
/// Node paths are initially backfilled below and subsequently maintained by the
/// node write path (including descendants after a folder move/rename).
const MIGRATION_V16: &str = "
DROP TABLE IF EXISTS nodes_fts;
CREATE VIRTUAL TABLE nodes_fts USING fts5(
name, path, tokenize='trigram'
);
CREATE INDEX IF NOT EXISTS idx_nodes_name_nocase ON nodes(name COLLATE NOCASE);
INSERT INTO nodes_fts (rowid, name, path)
WITH RECURSIVE paths(rowid, uid, path) AS (
SELECT n.rowid, n.uid, '' FROM nodes n
WHERE n.parent_uid IS NULL
OR NOT EXISTS (SELECT 1 FROM nodes p WHERE p.uid = n.parent_uid)
UNION ALL
SELECT n.rowid, n.uid,
CASE WHEN paths.path = '' THEN n.name ELSE paths.path || '/' || n.name END
FROM nodes n JOIN paths ON n.parent_uid = paths.uid
)
SELECT n.rowid, n.name, paths.path
FROM nodes n JOIN paths ON paths.rowid = n.rowid
WHERE n.trashed = 0;
DROP TABLE IF EXISTS local_fts;
CREATE VIRTUAL TABLE local_fts USING fts5(
name, path, content='local_files', content_rowid='id', tokenize='trigram'
);
CREATE INDEX IF NOT EXISTS idx_local_files_name_nocase
ON local_files(name COLLATE NOCASE);
INSERT INTO local_fts(local_fts) VALUES('rebuild');
";
+114 -5
View File
@@ -81,12 +81,42 @@ impl Db {
params![uid],
|row| row.get(0),
)?;
tx.execute("DELETE FROM nodes_fts WHERE rowid = ?1", params![rowid])?;
if !node.trashed {
tx.execute(
"INSERT INTO nodes_fts (rowid, name) VALUES (?1, ?2)",
params![rowid, node.name],
// A folder rename/move changes every descendant's searchable path.
// Refresh the affected subtree, while retaining efficient rowid
// deletes for the overwhelmingly common one-file case.
let mut affected = vec![rowid];
if node.is_folder() {
let mut stmt = tx.prepare(
"WITH RECURSIVE descendants(rowid, uid) AS (
SELECT rowid, uid FROM nodes WHERE parent_uid = ?1
UNION ALL
SELECT n.rowid, n.uid FROM nodes n
JOIN descendants d ON n.parent_uid = d.uid
) SELECT rowid FROM descendants",
)?;
for descendant in stmt.query_map(params![uid], |row| row.get(0))? {
affected.push(descendant?);
}
}
for affected_rowid in affected {
tx.execute(
"DELETE FROM nodes_fts WHERE rowid = ?1",
params![affected_rowid],
)?;
let indexed: Option<(String, String)> = tx
.query_row(
"SELECT name, uid FROM nodes WHERE rowid = ?1 AND trashed = 0",
params![affected_rowid],
|row| Ok((row.get(0)?, row.get(1)?)),
)
.optional()?;
if let Some((name, indexed_uid)) = indexed {
let path = path_of(&tx, &indexed_uid)?;
tx.execute(
"INSERT INTO nodes_fts (rowid, name, path) VALUES (?1, ?2, ?3)",
params![affected_rowid, name, path],
)?;
}
}
}
tx.commit()?;
@@ -175,6 +205,68 @@ impl Db {
Ok(hits)
}
/// Return a bounded, deliberately broad candidate pool for fuzzy ranking.
/// Unlike [`search`](Self::search), trigram terms are ORed across both the
/// basename and parent path, so a typo can still share enough trigrams to
/// enter the pool. Final relevance ordering belongs to the caller.
pub fn search_candidates(&self, query: &str, limit: usize) -> Result<Vec<SearchHit>> {
let query = query.trim();
if query.is_empty() || limit == 0 {
return Ok(Vec::new());
}
let conn = self.conn.lock();
let terms = candidate_trigrams(query);
let lane_limit = limit.div_ceil(2);
let mut rows: Vec<(String, String)> = if terms.is_empty() {
let pat = format!("%{}%", like_escape(query));
let mut stmt = conn.prepare(
"SELECT node_json, uid FROM nodes
WHERE name LIKE ?1 ESCAPE '\\' AND trashed = 0 AND node_json IS NOT NULL
ORDER BY name LIMIT ?2",
)?;
collect_pairs(stmt.query_map(params![pat, limit as i64], pair)?)?
} else {
let expression = terms.join(" OR ");
let mut stmt = conn.prepare(
"SELECT n.node_json, n.uid
FROM nodes_fts f JOIN nodes n ON n.rowid = f.rowid
WHERE nodes_fts MATCH ?1 AND n.trashed = 0 AND n.node_json IS NOT NULL
ORDER BY f.rank LIMIT ?2",
)?;
collect_pairs(stmt.query_map(params![expression, lane_limit as i64], pair)?)?
};
// A short substitution can destroy every trigram (`vedio` vs `video`).
// Feed the scorer an additional indexed same-initial lane so it can
// recover those candidates without scanning the full node table.
if !terms.is_empty()
&& let Some(first) = query.chars().next()
{
let prefix = format!("{}%", like_escape(&first.to_string()));
let mut stmt = conn.prepare(
"SELECT node_json, uid FROM nodes
WHERE name COLLATE NOCASE LIKE ?1 ESCAPE '\\'
AND trashed = 0 AND node_json IS NOT NULL
ORDER BY name COLLATE NOCASE LIMIT ?2",
)?;
let fallback =
collect_pairs(stmt.query_map(params![prefix, lane_limit as i64], pair)?)?;
for row in fallback {
if !rows.iter().any(|(_, uid)| uid == &row.1) {
rows.push(row);
}
}
rows.truncate(limit);
}
rows.into_iter()
.map(|(json, uid)| {
Ok(SearchHit {
node: serde_json::from_str(&json)?,
path: path_of(&conn, &uid)?,
})
})
.collect()
}
/// Mark (or unmark) a folder's child listing as complete. A listed folder
/// rehydrates its `children` map on mount even when empty; an unlisted one
/// re-enumerates from the remote on next access.
@@ -261,6 +353,23 @@ impl Db {
// in sync on every store/read/evict, so it is authoritative for eviction.
}
/// Unique quoted character trigrams suitable for an FTS5 OR expression.
pub(super) fn candidate_trigrams(query: &str) -> Vec<String> {
let chars: Vec<char> = query.to_lowercase().chars().collect();
let mut terms = Vec::new();
for window in chars.windows(TRIGRAM_MIN) {
if window.iter().all(|c| c.is_whitespace()) {
continue;
}
let raw: String = window.iter().collect();
let quoted = format!("\"{}\"", raw.replace('"', "\"\""));
if !terms.contains(&quoted) {
terms.push(quoted);
}
}
terms
}
/// Effective plaintext size of a node for the indexed `size` column: the
/// claimed size when known, else the on-storage size; folders are 0.
pub(super) fn node_size(node: &Node) -> i64 {
+89
View File
@@ -542,6 +542,95 @@ fn search_short_query_like_fallback() {
assert_eq!(hits[0].node.name, "ab.txt");
}
#[test]
fn fuzzy_candidates_include_typo_and_parent_path_matches() {
let db = Db::open_in_memory().unwrap();
db.upsert_node(&folder("root", None, "My Files")).unwrap();
db.upsert_node(&folder("projects", Some("root"), "Projects"))
.unwrap();
db.upsert_node(&file("report", "projects", "quarterly-report.pdf", 1))
.unwrap();
db.upsert_node(&file("video", "projects", "video.mp4", 1))
.unwrap();
let typo = db.search_candidates("quartrly", 20).unwrap();
assert!(
typo.iter()
.any(|hit| hit.node.name == "quarterly-report.pdf")
);
assert!(
db.search_candidates("vedio", 20)
.unwrap()
.iter()
.any(|hit| hit.node.name == "video.mp4")
);
let path = db.search_candidates("projects", 20).unwrap();
assert!(
path.iter()
.any(|hit| hit.node.name == "quarterly-report.pdf")
);
}
#[test]
fn renaming_parent_refreshes_descendant_search_paths() {
let db = Db::open_in_memory().unwrap();
db.upsert_node(&folder("root", None, "My Files")).unwrap();
db.upsert_node(&folder("folder", Some("root"), "zzbeforezz"))
.unwrap();
db.upsert_node(&file("child", "folder", "plain.txt", 1))
.unwrap();
assert!(
db.search_candidates("zzbeforezz", 20)
.unwrap()
.iter()
.any(|h| h.node.name == "plain.txt")
);
db.upsert_node(&folder("folder", Some("root"), "yyafteryy"))
.unwrap();
assert!(
!db.search_candidates("zzbeforezz", 20)
.unwrap()
.iter()
.any(|h| h.node.name == "plain.txt")
);
assert!(
db.search_candidates("yyafteryy", 20)
.unwrap()
.iter()
.any(|h| h.node.name == "plain.txt")
);
}
#[test]
fn local_fuzzy_candidates_include_typo_and_parent_path() {
let db = Db::open_in_memory().unwrap();
let generation = db.local_begin_scan().unwrap();
db.local_upsert_batch(
generation,
&[
local(
"/home/u/Projects/quarterly-report.pdf",
"quarterly-report.pdf",
false,
),
local("/home/u/Videos/video.mp4", "video.mp4", false),
],
)
.unwrap();
db.local_finish_scan(generation, 1).unwrap();
assert_eq!(db.search_local_candidates("quartrly", 20).unwrap().len(), 1);
assert_eq!(db.search_local_candidates("projects", 20).unwrap().len(), 1);
assert!(
db.search_local_candidates("vedio", 20)
.unwrap()
.iter()
.any(|hit| hit.name == "video.mp4")
);
}
#[test]
fn search_drops_fts_row_on_delete_and_trash() {
let db = Db::open_in_memory().unwrap();