feat: add Google code retrieval across desktop CLI and MCP
Core CI / core (macos-latest) (push) Canceled after 0s
Core CI / core (windows-latest) (push) Canceled after 0s
Core CI / desktop (macos-latest) (push) Canceled after 0s
Core CI / desktop (ubuntu-latest) (push) Canceled after 0s
Core CI / desktop (windows-latest) (push) Canceled after 0s
Core CI / core (ubuntu-latest) (push) Canceled after 2m23s
Core CI / core (macos-latest) (push) Canceled after 0s
Core CI / core (windows-latest) (push) Canceled after 0s
Core CI / desktop (macos-latest) (push) Canceled after 0s
Core CI / desktop (ubuntu-latest) (push) Canceled after 0s
Core CI / desktop (windows-latest) (push) Canceled after 0s
Core CI / core (ubuntu-latest) (push) Canceled after 2m23s
This commit is contained in:
1 parent
8b9b079469
commit
81846b3e98
32 files changed
+1303
-59
No files matched your search
@@ -0,0 +1,52 @@
|
||||
//! Shared code-file classification for extraction and pre-ranking scope filters.
|
||||
use std::{path::Path, sync::OnceLock};
|
||||
|
||||
pub const CODE_EXTENSIONS: &[&str] = &[
|
||||
"rs", "py", "pyi", "js", "jsx", "mjs", "cjs", "ts", "tsx", "mts", "cts", "c", "h", "cpp", "cc",
|
||||
"cxx", "hpp", "hxx", "hh", "go", "java", "kt", "kts", "cs", "swift", "m", "mm", "rb", "php",
|
||||
"phtml", "scala", "sc", "sql", "sh", "bash", "zsh", "fish", "ps1", "psm1", "psd1", "bat",
|
||||
"cmd", "lua", "r", "pl", "pm", "ex", "exs", "erl", "hrl", "hs", "lhs", "clj", "cljs", "cljc",
|
||||
"dart", "vue", "svelte", "graphql", "gql", "tf", "tfvars", "proto", "zig", "vb", "vbs", "f",
|
||||
"for", "f90", "f95", "f03", "pas", "d", "cmake",
|
||||
];
|
||||
const CODE_FILENAMES: &[&str] = &[
|
||||
"makefile",
|
||||
"gnumakefile",
|
||||
"dockerfile",
|
||||
"cmakelists.txt",
|
||||
"justfile",
|
||||
];
|
||||
|
||||
pub fn is_code_path(path: &Path) -> bool {
|
||||
let extension = path
|
||||
.extension()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
let filename = path
|
||||
.file_name()
|
||||
.and_then(|value| value.to_str())
|
||||
.unwrap_or("")
|
||||
.to_ascii_lowercase();
|
||||
CODE_EXTENSIONS.contains(&extension.as_str()) || CODE_FILENAMES.contains(&filename.as_str())
|
||||
}
|
||||
|
||||
/// Static trusted SQL predicate for the shared internal `files f` alias. No
|
||||
/// caller-controlled query, path or extension is interpolated into this SQL.
|
||||
pub fn code_file_predicate() -> &'static str {
|
||||
static SQL: OnceLock<String> = OnceLock::new();
|
||||
SQL.get_or_init(|| {
|
||||
let literals = |values: &[&str]| {
|
||||
values
|
||||
.iter()
|
||||
.map(|value| format!("'{value}'"))
|
||||
.collect::<Vec<_>>()
|
||||
.join(",")
|
||||
};
|
||||
format!(
|
||||
"(f.extension IN ({}) OR lower(f.filename) IN ({}))",
|
||||
literals(CODE_EXTENSIONS),
|
||||
literals(CODE_FILENAMES)
|
||||
)
|
||||
})
|
||||
}
|
||||
@@ -33,27 +33,30 @@ pub(crate) fn extract(path: &Path) -> Result<Option<Vec<Passage>>> {
|
||||
docx(&bytes).map(Some)
|
||||
};
|
||||
}
|
||||
if !matches!(
|
||||
extension.as_str(),
|
||||
"txt"
|
||||
| "md"
|
||||
| "rs"
|
||||
| "py"
|
||||
| "js"
|
||||
| "ts"
|
||||
| "tsx"
|
||||
| "jsx"
|
||||
| "json"
|
||||
| "toml"
|
||||
| "yaml"
|
||||
| "yml"
|
||||
| "csv"
|
||||
| "html"
|
||||
| "css"
|
||||
| "xml"
|
||||
| "log"
|
||||
| "sh"
|
||||
) {
|
||||
let code = crate::is_code_path(path);
|
||||
if !code
|
||||
&& !matches!(
|
||||
extension.as_str(),
|
||||
"txt"
|
||||
| "md"
|
||||
| "rs"
|
||||
| "py"
|
||||
| "js"
|
||||
| "ts"
|
||||
| "tsx"
|
||||
| "jsx"
|
||||
| "json"
|
||||
| "toml"
|
||||
| "yaml"
|
||||
| "yml"
|
||||
| "csv"
|
||||
| "html"
|
||||
| "css"
|
||||
| "xml"
|
||||
| "log"
|
||||
| "sh"
|
||||
)
|
||||
{
|
||||
return Ok(None);
|
||||
}
|
||||
let file = File::open(path)?;
|
||||
@@ -69,6 +72,26 @@ pub(crate) fn extract(path: &Path) -> Result<Option<Vec<Passage>>> {
|
||||
return Ok(None);
|
||||
};
|
||||
let lines = text.split_inclusive('\n').collect::<Vec<_>>();
|
||||
if code {
|
||||
let mut passages = Vec::new();
|
||||
for start in (0..lines.len()).step_by(96) {
|
||||
if passages.len() >= MAX_PASSAGES {
|
||||
return Ok(None);
|
||||
}
|
||||
let end = (start + 128).min(lines.len());
|
||||
passages.push(Passage {
|
||||
text: lines[start..end].concat(),
|
||||
source: SourceLocation {
|
||||
kind: "line".into(),
|
||||
number: (start + 1) as u32,
|
||||
},
|
||||
});
|
||||
if end == lines.len() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
return Ok(Some(passages));
|
||||
}
|
||||
Ok(Some(
|
||||
lines
|
||||
.chunks(64)
|
||||
|
||||
@@ -3,7 +3,9 @@
|
||||
//! Reconciliation is atomic. Incomplete directory walks never delete records.
|
||||
//! Text and document extraction have byte limits, but still run in this process.
|
||||
//! Worker isolation remains a production milestone.
|
||||
mod code;
|
||||
mod extraction;
|
||||
pub use code::{code_file_predicate, is_code_path, CODE_EXTENSIONS};
|
||||
pub mod metrics;
|
||||
use anyhow::{bail, Context, Result};
|
||||
pub use metrics::{MetricsMode, SearchMetrics, SearchResponse, SearchSpan};
|
||||
@@ -19,7 +21,7 @@ use std::{
|
||||
};
|
||||
use walkdir::WalkDir;
|
||||
|
||||
const SCHEMA_VERSION: i64 = 10;
|
||||
const SCHEMA_VERSION: i64 = 11;
|
||||
const MAX_RESULTS: usize = 1000;
|
||||
|
||||
#[derive(Debug, Default, Serialize)]
|
||||
@@ -50,6 +52,8 @@ impl Default for IndexOptions {
|
||||
/// The folder is canonicalized and includes descendants, with a path-boundary check.
|
||||
#[derive(Debug, Default, Clone)]
|
||||
pub struct SearchOptions {
|
||||
/// Restrict every retrieval lane to source/build-script files before top-K.
|
||||
pub code_only: bool,
|
||||
pub folder: Option<PathBuf>,
|
||||
pub extension: Option<String>,
|
||||
pub min_size: Option<u64>,
|
||||
@@ -242,6 +246,16 @@ impl SearchIndex {
|
||||
CREATE TRIGGER IF NOT EXISTS files_media_delete AFTER DELETE ON files BEGIN DELETE FROM media_metadata WHERE path=old.path; END;
|
||||
CREATE TRIGGER IF NOT EXISTS files_media_update AFTER UPDATE ON files WHEN new.path!=old.path OR new.size!=old.size OR new.mtime!=old.mtime BEGIN DELETE FROM media_metadata WHERE path=old.path; END;
|
||||
PRAGMA user_version=10;")?;
|
||||
if version < 11 {
|
||||
initialization.execute(
|
||||
&format!(
|
||||
"UPDATE files AS f SET mtime=-1 WHERE {}",
|
||||
code_file_predicate()
|
||||
),
|
||||
[],
|
||||
)?;
|
||||
}
|
||||
initialization.execute_batch("PRAGMA user_version=11;")?;
|
||||
initialization.commit()?;
|
||||
}
|
||||
Ok(Self {
|
||||
@@ -620,12 +634,16 @@ impl SearchIndex {
|
||||
trace.end(normalization, Default::default());
|
||||
let planning = trace.begin("lexical.candidate_plan", parent);
|
||||
let limit = limit.min(MAX_RESULTS);
|
||||
let filters = " AND (?3 IS NULL OR substr(f.path,1,length(?3))=?3)
|
||||
let mut filters = String::from(" AND (?3 IS NULL OR substr(f.path,1,length(?3))=?3)
|
||||
AND (?4 IS NULL OR f.extension=?4)
|
||||
AND (?5 IS NULL OR f.size>=?5) AND (?6 IS NULL OR f.size<=?6)
|
||||
AND (?7 IS NULL OR f.mtime/1000000>=?7) AND (?8 IS NULL OR f.mtime/1000000<=?8)
|
||||
AND (?10 IS NULL OR EXISTS(SELECT 1 FROM media_metadata m WHERE m.file_id=f.id AND m.duration_ms>=?10))
|
||||
AND (?11 IS NULL OR EXISTS(SELECT 1 FROM media_metadata m WHERE m.file_id=f.id AND m.duration_ms<=?11))";
|
||||
AND (?11 IS NULL OR EXISTS(SELECT 1 FROM media_metadata m WHERE m.file_id=f.id AND m.duration_ms<=?11))");
|
||||
if options.code_only {
|
||||
filters.push_str(" AND ");
|
||||
filters.push_str(code_file_predicate());
|
||||
}
|
||||
let filter_values = [
|
||||
folder_prefix.map_or(Value::Null, Value::Text),
|
||||
extension.map_or(Value::Null, Value::Text),
|
||||
@@ -639,6 +657,7 @@ impl SearchIndex {
|
||||
.map_or(Value::Null, Value::Integer),
|
||||
];
|
||||
let has_filters = filter_values.iter().any(|value| *value != Value::Null)
|
||||
|| options.code_only
|
||||
|| options.min_duration_ms.is_some()
|
||||
|| options.max_duration_ms.is_some();
|
||||
let mut results = Vec::new();
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
use anyhow::Result;
|
||||
use searchi_core::{SearchIndex, SearchOptions, SourceLocation};
|
||||
use std::fs;
|
||||
|
||||
#[test]
|
||||
fn code_scope_filters_before_limit_and_extracts_build_files() -> Result<()> {
|
||||
let root = tempfile::tempdir()?;
|
||||
for number in 0..100 {
|
||||
fs::write(
|
||||
root.path().join(format!("early{number}.txt")),
|
||||
"refreshAccessToken",
|
||||
)?;
|
||||
}
|
||||
fs::write(
|
||||
root.path().join("last.go"),
|
||||
"func refreshAccessToken() {}\n",
|
||||
)?;
|
||||
fs::write(
|
||||
root.path().join("Makefile"),
|
||||
"refreshAccessToken:\n\techo ready\n",
|
||||
)?;
|
||||
fs::write(root.path().join("large.rs"), "\n".repeat(1_000_000))?;
|
||||
let mut index = SearchIndex::open(":memory:")?;
|
||||
index.index_folder(root.path())?;
|
||||
assert_eq!(index.search("large.rs", 1)?[0].filename, "large.rs");
|
||||
let options = SearchOptions {
|
||||
code_only: true,
|
||||
..Default::default()
|
||||
};
|
||||
let results = index.search_with_options("refreshAccessToken", 2, &options)?;
|
||||
assert_eq!(results.len(), 2);
|
||||
assert!(results
|
||||
.iter()
|
||||
.all(|r| r.filename == "last.go" || r.filename == "Makefile"));
|
||||
assert!(results
|
||||
.iter()
|
||||
.all(|r| r.source.is_some() && r.snippet.contains("refreshAccessToken")));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn overlapping_source_passages_preserve_routine_and_actual_lines() -> Result<()> {
|
||||
let root = tempfile::tempdir()?;
|
||||
let file = root.path().join("module.swift");
|
||||
let mut lines = vec!["// filler\n"; 166];
|
||||
lines[116] = "func importantRoutine() {\n";
|
||||
lines[136] = " return finalValue\n";
|
||||
lines[137] = "}\n";
|
||||
fs::write(&file, lines.concat())?;
|
||||
let mut index = SearchIndex::open(":memory:")?;
|
||||
index.index_folder(root.path())?;
|
||||
let path = fs::canonicalize(file)?.to_string_lossy().into_owned();
|
||||
let passage = index
|
||||
.source_passage(
|
||||
&path,
|
||||
&SourceLocation {
|
||||
kind: "line".into(),
|
||||
number: 97,
|
||||
},
|
||||
8192,
|
||||
)?
|
||||
.unwrap();
|
||||
assert_eq!(passage.text, lines[96..].concat());
|
||||
assert!(passage.text.contains("importantRoutine") && passage.text.contains("finalValue"));
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn old_code_index_reextracts_unchanged_source_on_upgrade() -> Result<()> {
|
||||
let root = tempfile::tempdir()?;
|
||||
let files = root.path().join("files");
|
||||
fs::create_dir(&files)?;
|
||||
fs::write(files.join("module.java"), "class Evidence {}")?;
|
||||
fs::write(files.join("notes.txt"), "ordinary evidence")?;
|
||||
let db = root.path().join("index.db");
|
||||
let mut index = SearchIndex::open(&db)?;
|
||||
index.index_folder(&files)?;
|
||||
drop(index);
|
||||
let connection = rusqlite::Connection::open(&db)?;
|
||||
connection.execute_batch("PRAGMA user_version=10;")?;
|
||||
drop(connection);
|
||||
let mut index = SearchIndex::open(&db)?;
|
||||
assert_eq!(index.search("module.java", 1)?[0].filename, "module.java");
|
||||
let report = index.index_folder(&files)?;
|
||||
assert_eq!(report.indexed, 1);
|
||||
assert_eq!(report.unchanged, 1);
|
||||
assert!(index
|
||||
.search("Evidence", 10)?
|
||||
.iter()
|
||||
.any(|r| r.filename == "module.java" && r.source.is_some()));
|
||||
Ok(())
|
||||
}
|
||||
Reference in new issue
Block a user