From 857e92233e0c13f345e979df82c60db83b025167 Mon Sep 17 00:00:00 2001 From: Oleksandr Ostrovskyi Date: Wed, 25 Mar 2026 00:06:21 +0200 Subject: [PATCH] =?UTF-8?q?fix:=20context=20engine=20cleanup=20=E2=80=94?= =?UTF-8?q?=20resolve=20N+1=20queries,=20consolidate=20resolution?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add find_file_by_basename store method (SQL instead of get_all_files) - Add edge_counts_for_files batch method (single query instead of N+1) - Consolidate file resolution: context_who, context_project, graph CLI all delegate to resolve_file/find_file_by_basename - Fix ProjectContext.total_chars to include children and tasks - Rename char_count to byte_count for accuracy Co-Authored-By: Claude Opus 4.6 (1M context) --- src/context.rs | 103 ++++++++++++++++++++++------------------------ src/main.rs | 16 +------- src/store.rs | 108 +++++++++++++++++++++++++++++++++++++++++++++++++ 3 files changed, 158 insertions(+), 69 deletions(-) diff --git a/src/context.rs b/src/context.rs index 7f5952e..23d0446 100644 --- a/src/context.rs +++ b/src/context.rs @@ -26,7 +26,7 @@ pub struct NoteContent { pub incoming_links: Vec, pub mentions_people: Vec, pub mentioned_by: Vec, - pub char_count: usize, + pub byte_count: usize, } #[derive(Debug, Serialize)] @@ -110,18 +110,8 @@ fn resolve_file( return Ok(Some(f)); } - // Basename fallback: append .md if needed, then case-insensitive suffix match - let target = if file_or_docid.ends_with(".md") { - file_or_docid.to_string() - } else { - format!("{}.md", file_or_docid) - }; - let target_lower = target.to_lowercase(); - let all = params.store.get_all_files()?; - Ok(all.into_iter().find(|f| { - let p = f.path.to_lowercase(); - p == target_lower || p.ends_with(&format!("/{}", target_lower)) - })) + // Basename fallback via SQL + params.store.find_file_by_basename(file_or_docid) } /// Split content into (frontmatter YAML, body) parts. @@ -190,7 +180,7 @@ pub fn context_read(params: &ContextParams, file_or_docid: &str) -> Result Result Result> { let files = params.store.list_files(folder, tags, limit)?; + let file_ids: Vec = files.iter().map(|f| f.id).collect(); + let edge_counts = params + .store + .edge_counts_for_files(&file_ids) + .unwrap_or_default(); let mut items = Vec::new(); for f in files { - let edge_count = params.store.edge_count_for_file(f.id).unwrap_or(0); + let edge_count = edge_counts.get(&f.id).copied().unwrap_or(0); items.push(NoteListItem { path: f.path, docid: f.docid, @@ -270,15 +265,7 @@ pub fn vault_map(params: &ContextParams) -> Result { /// Build a person context bundle: note content, mentions, wikilink connections. pub fn context_who(params: &ContextParams, name: &str) -> Result { - let name_md = format!("{}.md", name); - let name_lower = name_md.to_lowercase(); - let all_files = params.store.get_all_files()?; - let person_file = all_files.iter().find(|f| { - let basename = f.path.rsplit('/').next().unwrap_or(&f.path).to_lowercase(); - basename == name_lower - }); - - let (note, person_id) = if let Some(pf) = person_file { + let (note, person_id) = if let Some(pf) = resolve_file(params, name)? { let n = context_read(params, &pf.path)?; (Some(n), Some(pf.id)) } else { @@ -323,7 +310,7 @@ pub fn context_who(params: &ContextParams, name: &str) -> Result } } - let total_chars = note.as_ref().map(|n| n.char_count).unwrap_or(0) + let total_chars = note.as_ref().map(|n| n.byte_count).unwrap_or(0) + mentioned_in.iter().map(|m| m.snippet.len()).sum::(); Ok(PersonContext { @@ -364,38 +351,23 @@ fn get_mention_snippet(params: &ContextParams, file_id: i64, name: &str) -> Stri /// Build a project context bundle: note, child notes, tasks, team, recent mentions. pub fn context_project(params: &ContextParams, name: &str) -> Result { - let name_md = format!("{}.md", name); - let name_lower = name_md.to_lowercase(); - let all_files = params.store.get_all_files()?; - let project_file = all_files.iter().find(|f| { - let basename = f.path.rsplit('/').next().unwrap_or(&f.path).to_lowercase(); - basename == name_lower - }); - - let (note, project_id, project_folder) = if let Some(pf) = project_file { - let n = context_read(params, &pf.path)?; + let (note, project_id, project_folder) = if let Some(pf) = resolve_file(params, name)? { let folder = pf.path.rsplit_once('/').map(|(f, _)| f.to_string()); + let n = context_read(params, &pf.path)?; (Some(n), Some(pf.id), folder) } else { (None, None, None) }; let mut child_ids = HashSet::new(); - let mut child_notes = Vec::new(); + let mut child_records: Vec = Vec::new(); // Files in same folder if let Some(folder) = &project_folder { let folder_files = params.store.list_files(Some(folder), &[], 50)?; for f in folder_files { if Some(f.id) != project_id && child_ids.insert(f.id) { - let ec = params.store.edge_count_for_file(f.id).unwrap_or(0); - child_notes.push(NoteListItem { - path: f.path, - docid: f.docid, - tags: f.tags, - indexed_at: f.indexed_at, - edge_count: ec, - }); + child_records.push(f); } } } @@ -407,18 +379,31 @@ pub fn context_project(params: &ContextParams, name: &str) -> Result = child_records.iter().map(|f| f.id).collect(); + let edge_counts = params + .store + .edge_counts_for_files(&child_file_ids) + .unwrap_or_default(); + let child_notes: Vec = child_records + .into_iter() + .map(|f| { + let ec = edge_counts.get(&f.id).copied().unwrap_or(0); + NoteListItem { + path: f.path, + docid: f.docid, + tags: f.tags, + indexed_at: f.indexed_at, + edge_count: ec, + } + }) + .collect(); + // Active tasks let mut active_tasks = Vec::new(); let scan_tasks = |path: &str, tasks: &mut Vec| { @@ -483,7 +468,15 @@ pub fn context_project(params: &ContextParams, name: &str) -> Result() + + active_tasks.iter().map(|t| t.text.len()).sum::(); Ok(ProjectContext { name: name.to_string(), @@ -700,7 +693,7 @@ mod tests { assert!(note.tags.contains(&"rust".to_string())); assert_eq!(note.outgoing_links.len(), 1); assert_eq!(note.incoming_links.len(), 1); - assert!(note.char_count > 0); + assert!(note.byte_count > 0); } #[test] diff --git a/src/main.rs b/src/main.rs index 360a71d..d5ee073 100644 --- a/src/main.rs +++ b/src/main.rs @@ -396,19 +396,7 @@ fn main() -> Result<()> { } else if let Some(f) = store.get_file(&file)? { Some(f) } else { - // Basename match (case-insensitive) - let target = if file.ends_with(".md") { - file.clone() - } else { - format!("{}.md", file) - }; - let all = store.get_all_files()?; - let target_lower = target.to_lowercase(); - all.into_iter().find(|f| { - let path_lower = f.path.to_lowercase(); - path_lower == target_lower - || path_lower.ends_with(&format!("/{}", target_lower)) - }) + store.find_file_by_basename(&file)? }; let record = match record { @@ -543,7 +531,7 @@ fn main() -> Result<()> { println!("Tags: {}", note.tags.join(", ")); println!("Outgoing links: {}", note.outgoing_links.len()); println!("Incoming links: {}", note.incoming_links.len()); - println!("Chars: {}\n", note.char_count); + println!("Bytes: {}\n", note.byte_count); println!("{}", note.body); } } diff --git a/src/store.rs b/src/store.rs index 85860c9..f62e6bd 100644 --- a/src/store.rs +++ b/src/store.rs @@ -903,6 +903,75 @@ impl Store { )?; Ok(count as usize) } + + /// Get edge counts for multiple files in a single query. + pub fn edge_counts_for_files( + &self, + file_ids: &[i64], + ) -> Result> { + use std::collections::HashMap; + if file_ids.is_empty() { + return Ok(HashMap::new()); + } + let placeholders: Vec = file_ids.iter().map(|_| "?".to_string()).collect(); + let ph = placeholders.join(","); + let sql = format!( + "SELECT fid, COUNT(*) FROM ( + SELECT from_file AS fid FROM edges WHERE from_file IN ({ph}) + UNION ALL + SELECT to_file AS fid FROM edges WHERE to_file IN ({ph}) + ) GROUP BY fid" + ); + let mut stmt = self.conn.prepare(&sql)?; + let params: Vec> = file_ids + .iter() + .chain(file_ids.iter()) + .map(|id| Box::new(*id) as Box) + .collect(); + let rows = stmt.query_map(rusqlite::params_from_iter(params.iter()), |row| { + Ok((row.get::<_, i64>(0)?, row.get::<_, i64>(1)? as usize)) + })?; + let mut map = HashMap::new(); + for row in rows { + let (id, count) = row?; + map.insert(id, count); + } + Ok(map) + } + + /// Find a file by case-insensitive basename match. Returns first match (shortest path). + pub fn find_file_by_basename(&self, basename: &str) -> Result> { + let target = if basename.ends_with(".md") { + basename.to_string() + } else { + format!("{}.md", basename) + }; + // Try exact path first + if let Some(f) = self.get_file(&target)? { + return Ok(Some(f)); + } + // Basename match via SQL + let mut stmt = self.conn.prepare( + "SELECT id, path, content_hash, mtime, tags, indexed_at, docid FROM files + WHERE lower(path) LIKE '%/' || lower(?1) OR lower(path) = lower(?1) + ORDER BY length(path) ASC LIMIT 1", + )?; + let mut rows = stmt.query_map(params![target], |row| { + Ok(FileRecord { + id: row.get(0)?, + path: row.get(1)?, + content_hash: row.get(2)?, + mtime: row.get(3)?, + tags: parse_tags(&row.get::<_, String>(4)?), + indexed_at: row.get(5)?, + docid: row.get(6)?, + }) + })?; + match rows.next() { + Some(r) => Ok(Some(r?)), + None => Ok(None), + } + } } fn parse_tags(json: &str) -> Vec { @@ -1527,4 +1596,43 @@ mod tests { assert_eq!(store.edge_count_for_file(f1).unwrap(), 2); assert_eq!(store.edge_count_for_file(f2).unwrap(), 2); } + + #[test] + fn test_find_file_by_basename() { + let store = Store::open_memory().unwrap(); + store + .insert_file("01-Projects/Work/note.md", "h1", 100, &[], "aaa111") + .unwrap(); + store + .insert_file("root.md", "h2", 100, &[], "bbb222") + .unwrap(); + + let found = store.find_file_by_basename("note").unwrap(); + assert!(found.is_some()); + assert_eq!(found.unwrap().path, "01-Projects/Work/note.md"); + + let found = store.find_file_by_basename("note.md").unwrap(); + assert!(found.is_some()); + + let found = store.find_file_by_basename("nonexistent").unwrap(); + assert!(found.is_none()); + } + + #[test] + fn test_edge_counts_for_files() { + let store = Store::open_memory().unwrap(); + let f1 = store.insert_file("a.md", "h1", 100, &[], "a1").unwrap(); + let f2 = store.insert_file("b.md", "h2", 100, &[], "b2").unwrap(); + let f3 = store.insert_file("c.md", "h3", 100, &[], "c3").unwrap(); + store.insert_edge(f1, f2, "wikilink").unwrap(); + store.insert_edge(f2, f1, "wikilink").unwrap(); + store.insert_edge(f1, f3, "wikilink").unwrap(); + let counts = store.edge_counts_for_files(&[f1, f2, f3]).unwrap(); + assert_eq!(*counts.get(&f1).unwrap(), 3); + assert_eq!(*counts.get(&f2).unwrap(), 2); + assert_eq!(*counts.get(&f3).unwrap(), 1); + // Empty input returns empty map + let empty = store.edge_counts_for_files(&[]).unwrap(); + assert!(empty.is_empty()); + } }