use anyhow::{Context, Result};
use clap::Parser;
use jevr::{
bm25, candidates,
config::Config,
expansion,
jev::client::JevClient,
output::{self, Mode, RankedFile},
rerank, verify, walk,
};
use std::collections::BTreeSet;
use std::path::PathBuf;
use std::process::ExitCode;
const AFTER_HELP: &str = "\
Examples:
jevr \"where is the websocket reconnect logic?\" search current dir
jevr \"exchange fee calculation\" src/arbitrage search a subtree
jevr \"how do I configure retry backoff?\" docs/ search documents
jevr \"crash recovery\" --snippets show matching lines
Works on source code and plain-text documents alike (markdown, rst, txt,
html, org, tex, ...); PATH may be a directory or a single file. Code and
document results are gated and ranked separately — code keeps score >= 0.90,
documents >= 0.60 (prose scores run lower than code scores for equally
relevant content) — and document results print after code results.
Output lines are `path:start-end score` — the line range of the best-matching
content and a 0..1 relevance score. Results are ordered by relevance; plain
natural-language questions rank best (keyword lists are unnecessary).
Exit codes follow grep: 0 = matches, 1 = none above threshold (the best few
below-threshold guesses may still print, flagged on stderr), 2 = error.
Requires TYPESAFE_API_KEY; results come from a network AI judge, so a cold
query takes a few seconds (repeats are cached and instant).";
#[derive(Parser, Debug)]
#[command(
version,
about = "Semantic search: find the code or document files matching a concept, described in plain language",
after_help = AFTER_HELP
)]
struct Args {
#[arg(value_name = "QUERY")]
query: String,
#[arg(value_name = "PATH", conflicts_with = "path")]
path_pos: Option<PathBuf>,
#[arg(short, long, default_value = ".")]
path: PathBuf,
#[arg(short, long = "keywords")]
keywords: Vec<String>,
#[arg(long)]
candidates: Option<PathBuf>,
#[arg(long)]
threshold: Option<f64>,
#[arg(long)]
top_k: Option<usize>,
#[arg(long)]
model: Option<String>,
#[arg(long)]
config: Option<PathBuf>,
#[arg(long)]
no_grep_union: bool,
#[arg(long)]
no_cache: bool,
#[arg(long)]
hidden: bool,
#[arg(long)]
concurrency: Option<usize>,
#[arg(long, group = "mode")]
paths_only: bool,
#[arg(long, group = "mode")]
snippets: bool,
#[arg(long, group = "mode")]
json: bool,
}
fn main() -> ExitCode {
let args = Args::parse();
match run(args) {
Ok(found) => {
if found {
ExitCode::SUCCESS
} else {
ExitCode::from(1)
}
}
Err(e) => {
eprintln!("jevr: {e:#}");
ExitCode::from(2)
}
}
}
fn run(args: Args) -> Result<bool> {
if args.query.trim().is_empty() {
anyhow::bail!("query is empty; describe what to look for in natural language");
}
let given = args.path_pos.clone().unwrap_or_else(|| args.path.clone());
if !given.exists() {
anyhow::bail!("{}: no such file or directory", given.display());
}
let (root, single_file) = if given.is_file() {
let file = given
.file_name()
.map(|f| f.to_string_lossy().into_owned())
.context("path has no file name")?;
let parent = match given.parent() {
Some(p) if !p.as_os_str().is_empty() => p.to_path_buf(),
_ => PathBuf::from("."),
};
(parent, Some(file))
} else {
(given, None)
};
let mut config = Config::load(&root, args.config.as_deref())?;
if let Some(threshold) = args.threshold {
config.threshold = threshold;
config.doc_threshold = threshold;
}
if let Some(top_k) = args.top_k {
config.top_k = top_k;
}
if let Some(model) = args.model.clone() {
config.model = model;
}
if let Some(concurrency) = args.concurrency {
config.concurrency = concurrency;
}
if args.no_grep_union {
config.git_grep_union = false;
}
if args.no_cache {
config.cache = false;
}
if config.bm25_k1 <= 0.0 || !(0.0..=1.0).contains(&config.bm25_b) {
anyhow::bail!("bm25_k1 must be > 0 and bm25_b between 0 and 1");
}
if !(0.0..=1.0).contains(&config.threshold) || !(0.0..=1.0).contains(&config.doc_threshold) {
anyhow::bail!("threshold must be between 0 and 1");
}
if !config.doc_rank_weight.is_finite() || config.doc_rank_weight < 0.0 {
anyhow::bail!("doc_rank_weight must be a finite value >= 0");
}
if config.concurrency == 0 {
anyhow::bail!("concurrency must be at least 1");
}
if config.candidate_cap == 0 || config.top_k == 0 || config.snippet_lines == 0 {
anyhow::bail!("candidate_cap, top_k and snippet_lines must be ≥ 1");
}
let key = std::env::var("TYPESAFE_API_KEY")
.context("TYPESAFE_API_KEY is required (export it before running)")?;
let mode = if args.snippets {
Mode::Snippets
} else if args.json {
Mode::Json
} else {
Mode::PathsOnly
};
let candidate_files: Vec<String> = if let Some(file) = single_file {
vec![file]
} else if let Some(list) = &args.candidates {
std::fs::read_to_string(list)
.with_context(|| format!("read candidates file {}", list.display()))?
.lines()
.map(str::trim)
.filter(|l| !l.is_empty())
.map(String::from)
.collect()
} else {
let walked = walk::searchable_files(&root, args.hidden)?;
if walked.is_empty() {
eprintln!(
"jevr: no searchable files under {} (code or text documents)",
root.display()
);
return Ok(false);
}
let variants = expansion::variants(&args.query, &args.keywords);
let mut variant_lists: Vec<Vec<String>> = Vec::new();
for kind in [walk::FileKind::Code, walk::FileKind::Doc] {
let lane: Vec<PathBuf> = walked
.iter()
.filter(|p| walk::kind_of(p) == kind)
.cloned()
.collect();
if lane.is_empty() {
continue;
}
let index =
bm25::Bm25Index::build_from_files(&root, &lane, config.bm25_k1, config.bm25_b);
variant_lists.extend(
variants
.iter()
.map(|variant| index.top(variant, config.candidate_cap)),
);
}
let walked_rel: BTreeSet<String> = walked
.iter()
.map(|p| {
p.strip_prefix(&root)
.unwrap_or(p)
.to_string_lossy()
.into_owned()
})
.collect();
let grep_hits = if config.git_grep_union {
let grep_keywords: Vec<String> = variants
.iter()
.flat_map(|v| v.split(','))
.map(|k| k.trim().to_owned())
.filter(|k| !k.is_empty())
.collect();
candidates::git_grep_hits(&root, &grep_keywords, &walked_rel)
} else {
Vec::new()
};
candidates::union_candidates(&variant_lists, &grep_hits)
};
if candidate_files.is_empty() {
return Ok(false);
}
let runtime = tokio::runtime::Builder::new_current_thread()
.enable_all()
.build()?;
let cache_dir = if config.cache {
std::env::var_os("XDG_CACHE_HOME")
.map(PathBuf::from)
.or_else(|| std::env::var_os("HOME").map(|h| PathBuf::from(h).join(".cache")))
.map(|base| base.join("jevr"))
} else {
None
};
let client = JevClient::new(key, cache_dir);
let mut batches = Vec::new();
let mut head_by_file: std::collections::BTreeMap<String, String> = Default::default();
for rel in &candidate_files {
let abs = root.join(rel);
let Ok(text) = std::fs::read_to_string(&abs) else {
eprintln!("jevr: warning: skipping unreadable {rel}");
continue;
};
head_by_file.insert(
rel.clone(),
text.lines().take(25).collect::<Vec<_>>().join("\n"),
);
batches.extend(verify::build_batches(
rel,
walk::kind_of(std::path::Path::new(rel)),
&text,
));
}
let verdicts = runtime.block_on(verify::verify_files(
&client,
&config.model,
&args.query,
batches,
config.concurrency,
));
let (code_lane, doc_lane): (Vec<_>, Vec<_>) = verdicts
.into_iter()
.filter(|v| v.error.is_none())
.partition(|v| walk::kind_of(std::path::Path::new(&v.file)) == walk::FileKind::Code);
let mut ranked: Vec<RankedFile> = Vec::new();
let mut any_real_match = false;
let (mut code_shown, mut doc_shown) = (0usize, 0usize);
for (lane, gate, any_doc, label) in [
(code_lane, config.threshold, false, "code file"),
(doc_lane, config.doc_threshold, true, "document"),
] {
if lane.is_empty() {
continue;
}
let (mut lane_kept, lane_rest): (Vec<_>, Vec<_>) =
lane.into_iter().partition(|v| v.score >= gate);
let lane_fallback = lane_kept.is_empty() && config.empty_fallback > 0;
if lane_fallback {
lane_kept = lane_rest;
lane_kept.sort_by(|a, b| b.score.total_cmp(&a.score));
lane_kept.truncate(config.empty_fallback);
lane_kept.retain(|v| v.score > 0.0);
if !lane_kept.is_empty() {
eprintln!(
"jevr: no {label} scored >= {gate:.2}; showing the best {} below-threshold guesses",
lane_kept.len()
);
}
}
lane_kept.sort_by(|a, b| b.score.total_cmp(&a.score));
if lane_kept.is_empty() {
continue;
}
if !lane_fallback {
any_real_match = true;
}
let lane = lane_kept;
let fallback = lane_fallback;
let heads: Vec<(String, String)> = lane
.iter()
.map(|v| {
let head = head_by_file.get(&v.file).cloned().unwrap_or_default();
(v.file.clone(), head)
})
.collect();
let rank_probabilities = match runtime.block_on(rerank::rank_kept(
&client,
&config.model,
&args.query,
&heads,
any_doc,
)) {
Ok(lane_ranked) => Some(
lane_ranked
.into_iter()
.collect::<std::collections::BTreeMap<_, _>>(),
),
Err(e) => {
eprintln!("jevr: warning: rerank failed, ordering by score: {e}");
None
}
};
let mut lane_ranked: Vec<RankedFile> = lane
.into_iter()
.map(|verdict| RankedFile {
rank_probability: rank_probabilities
.as_ref()
.and_then(|m| m.get(&verdict.file).copied()),
fallback,
verdict,
})
.collect();
if any_doc {
let w = config.doc_rank_weight;
let key = |r: &RankedFile| r.verdict.score + w * r.rank_probability.unwrap_or(0.0);
lane_ranked.sort_by(|a, b| key(b).total_cmp(&key(a)));
} else if rank_probabilities.is_some() {
lane_ranked.sort_by(|a, b| {
b.rank_probability
.unwrap_or(0.0)
.total_cmp(&a.rank_probability.unwrap_or(0.0))
});
}
lane_ranked.truncate(config.top_k);
if !fallback {
if any_doc {
doc_shown = lane_ranked.len();
} else {
code_shown = lane_ranked.len();
}
}
ranked.extend(lane_ranked);
}
if doc_shown > 0 {
let docs = format!(
"{doc_shown} document result(s) kept at score >= {:.2}",
config.doc_threshold
);
if code_shown > 0 {
eprintln!(
"jevr: {code_shown} code result(s) kept at score >= {:.2}, then {docs}",
config.threshold
);
} else {
eprintln!("jevr: {docs}");
}
}
if ranked.is_empty() {
return Ok(false);
}
if root.as_os_str() != "." {
for r in &mut ranked {
r.verdict.file = root.join(&r.verdict.file).to_string_lossy().into_owned();
}
}
let shown = ranked.len();
output::render(mode, &ranked, shown, config.snippet_lines)?;
Ok(any_real_match)
}