feat: add desktop document search and verified native CPU embeddings
Core CI / core (ubuntu-latest) (push) Failing after 1m22s
Core CI / core (macos-latest) (push) Canceled after 0s
Core CI / core (windows-latest) (push) Canceled after 0s
Core CI / desktop (ubuntu-latest) (push) Successful in 3m46s
Core CI / desktop (macos-latest) (push) Canceled after 0s
Core CI / desktop (windows-latest) (push) Canceled after 0s

This commit is contained in:
karl committed 2026-10-12 16:07:53 +02:00
1 parent f77112db97
commit a9b6592330
46 files changed
+10794 -77

No files matched your search

+11 -3
View File
@@ -7,6 +7,8 @@ GPU setup. Index only folders you explicitly choose.
cargo run -p searchi-cli -- index --db ./searchi.db --root ./documents
cargo run -p searchi-cli -- search --db ./searchi.db "termination clause" --limit 10
cargo run -p searchi-cli -- search --db ./searchi.db "invoice" --json
cargo run -p searchi-cli -- search --db ./searchi.db "invoice" --extension pdf --min-size 1024 --json
cargo run -p searchi-cli -- index --db ./searchi.db --root ./documents --exclude private
cargo run -p searchi-cli -- stats --db ./searchi.db --json
```
@@ -23,6 +25,12 @@ snippet, ranking score, and match kind. An empty search emits `[]`. `stats --jso
emits an object with `files` and `content_files`. JSON is the scripting interface;
the human-readable output may change as preview support develops.
The foundation CLI searches filenames and extracted text. Semantic inference,
document formats beyond the core extractor, and desktop integration remain
tracked in the project roadmap; this interface does not fabricate embeddings.
The CLI searches filenames, bounded text, PDF text and DOCX paragraphs. Results
include actual matching source locations when available and extraction errors
for documents that could not be read. Semantic inference and media formats
remain tracked in the roadmap; this interface does not fabricate embeddings.
Filters apply before result limits: `--folder`, `--extension`, `--min-size`,
`--max-size`, `--modified-after-ms`, and `--modified-before-ms`. Dates are Unix
milliseconds and inclusive. Repeated `index --exclude NAME` flags add directory
names to the defaults (`.git`, `node_modules`, `target`).
+49 -6
View File
@@ -4,7 +4,7 @@ use std::path::PathBuf;
use anyhow::{bail, Context, Result};
use clap::{Parser, Subcommand};
use searchi_core::SearchIndex;
use searchi_core::{IndexOptions, SearchIndex, SearchOptions};
#[derive(Debug, Parser)]
#[command(name = "searchi", version, about = "Search your local files offline")]
@@ -23,6 +23,9 @@ enum Command {
/// Folder to index recursively.
#[arg(long)]
root: PathBuf,
/// Additional directory names to exclude; repeat this flag as needed.
#[arg(long)]
exclude: Vec<String>,
},
/// Search filenames and indexed document content.
Search {
@@ -36,6 +39,21 @@ enum Command {
/// Emit a JSON array for scripting.
#[arg(long)]
json: bool,
/// Restrict results to this folder and descendants.
#[arg(long)]
folder: Option<PathBuf>,
/// File extension, with or without the leading dot.
#[arg(long)]
extension: Option<String>,
#[arg(long)]
min_size: Option<u64>,
#[arg(long)]
max_size: Option<u64>,
/// Inclusive modification time in Unix milliseconds.
#[arg(long)]
modified_after_ms: Option<i64>,
#[arg(long)]
modified_before_ms: Option<i64>,
},
/// Show the indexed file and content counts.
Stats {
@@ -48,15 +66,17 @@ enum Command {
fn main() -> Result<()> {
match Cli::parse().command {
Command::Index { db, root } => {
Command::Index { db, root, exclude } => {
let mut options = IndexOptions::default();
options.excluded_directory_names.extend(exclude);
let mut index = SearchIndex::open(&db)
.with_context(|| format!("Cannot open index {}", db.display()))?;
let report = index
.index_folder(&root)
.index_folder_with_options(&root, &options)
.with_context(|| format!("Cannot index folder {}", root.display()))?;
println!(
"Discovered: {} Indexed: {} Removed: {} Skipped: {}",
report.discovered, report.indexed, report.removed, report.skipped
"Discovered: {} Indexed: {} Unchanged: {} Removed: {} Skipped: {} Extraction failures: {}",
report.discovered, report.indexed, report.unchanged, report.removed, report.skipped, report.failures
);
}
Command::Search {
@@ -64,6 +84,12 @@ fn main() -> Result<()> {
query,
limit,
json,
folder,
extension,
min_size,
max_size,
modified_after_ms,
modified_before_ms,
} => {
if query.trim().is_empty() {
bail!("Search text cannot be empty");
@@ -72,7 +98,18 @@ fn main() -> Result<()> {
let index = SearchIndex::open(&db)
.with_context(|| format!("Cannot open index {}", db.display()))?;
let results = index
.search(&query, usize::from(limit))
.search_with_options(
&query,
usize::from(limit),
&SearchOptions {
folder,
extension,
min_size,
max_size,
modified_after_unix_ms: modified_after_ms,
modified_before_unix_ms: modified_before_ms,
},
)
.context("Search failed")?;
if json {
println!("{}", serde_json::to_string_pretty(&results)?);
@@ -81,6 +118,12 @@ fn main() -> Result<()> {
} else {
for result in results {
println!("{} [{}]", result.path, result.match_kind);
if let Some(source) = result.source {
println!(" {} {}", source.kind, source.number);
}
if let Some(error) = result.extraction_error {
println!(" Text extraction unavailable: {error}");
}
if !result.snippet.is_empty() {
println!(" {}", result.snippet.replace(['\r', '\n'], " "));
}
+39
View File
@@ -88,3 +88,42 @@ fn errors_are_nonzero_and_missing_database_is_not_created() {
assert!(!bad_root.status.success());
assert!(String::from_utf8_lossy(&bad_root.stderr).contains("missing-folder"));
}
#[test]
fn exclusions_and_filters_change_real_results() {
let fixture = Fixture::new();
fs::create_dir_all(fixture.0.join("documents/private")).unwrap();
fs::write(fixture.0.join("documents/report.txt"), "quarterly invoice").unwrap();
fs::write(fixture.0.join("documents/report.md"), "quarterly invoice").unwrap();
fs::write(fixture.0.join("documents/private/secret.txt"), "invoice").unwrap();
let indexed = fixture.run(&[
"index",
"--db",
"index.db",
"--root",
"documents",
"--exclude",
"private",
]);
assert!(indexed.status.success());
let searched = fixture.run(&[
"search",
"--db",
"index.db",
"invoice",
"--extension",
"md",
"--json",
]);
assert!(
searched.status.success(),
"{}",
String::from_utf8_lossy(&searched.stderr)
);
let results: serde_json::Value = serde_json::from_slice(&searched.stdout).unwrap();
assert_eq!(results.as_array().unwrap().len(), 1);
assert_eq!(results[0]["filename"], "report.md");
let stats = fixture.run(&["stats", "--db", "index.db", "--json"]);
let stats: serde_json::Value = serde_json::from_slice(&stats.stdout).unwrap();
assert_eq!(stats["files"], 2);
}