use anyhow::{Result, bail};
use clap::{Parser, Subcommand, ValueEnum};
use fastembed::TextEmbedding;
use semlith::home;
use semlith::{Semlith, embed, embed::Model, filter::Filter, fleet::Fleet};
use std::io::{IsTerminal, Write};
use std::path::{Path, PathBuf};
use std::time::Instant;
#[derive(Copy, Clone, PartialEq, Eq, Debug, ValueEnum)]
enum Reconcile {
Drop,
Report,
Off,
}
#[derive(Parser)]
#[command(name = "semlith", version, about, long_about = None)]
struct Cli {
#[arg(long, short, global = true)]
store: Vec<PathBuf>,
#[command(subcommand)]
command: Command,
}
#[derive(Subcommand)]
enum Command {
Index {
paths: Vec<PathBuf>,
#[arg(long, short)]
model: Option<String>,
#[arg(long)]
name: Option<String>,
#[arg(long, short)]
quiet: bool,
#[arg(long)]
airgap: bool,
#[arg(long)]
include_secrets: bool,
#[arg(long)]
each: bool,
#[arg(long, value_name = "FOLDER")]
projects: Option<PathBuf>,
#[arg(long, value_name = "MODE", default_value = "drop")]
reconcile: Reconcile,
},
Start {
#[arg(long, conflicts_with = "no_service")]
service: bool,
#[arg(long)]
no_service: bool,
paths: Vec<PathBuf>,
#[arg(long)]
no_ledger: bool,
#[arg(long)]
port: Option<u16>,
#[arg(long, default_value_t = semlith::watch::DEBOUNCE.as_millis() as u64)]
debounce: u64,
#[arg(long)]
airgap: bool,
#[arg(long)]
no_mcp_http: bool,
},
Key {
#[command(subcommand)]
what: KeyCommand,
},
Adopt {
store_dir: PathBuf,
#[arg(long)]
root: Option<PathBuf>,
#[arg(long)]
name: Option<String>,
},
Trust {
store_dir: Option<PathBuf>,
#[arg(long)]
list: bool,
},
Watch {
paths: Vec<PathBuf>,
#[arg(long, default_value_t = semlith::watch::DEBOUNCE.as_millis() as u64)]
debounce: u64,
#[arg(long, short)]
quiet: bool,
},
Search {
query: String,
#[arg(long, short, default_value_t = 8)]
k: usize,
#[arg(long, short)]
path: Vec<String>,
#[arg(long, short)]
ext: Vec<String>,
#[arg(long, short)]
lang: Vec<String>,
#[arg(long, default_value = "any")]
prefer: String,
#[arg(long)]
json: bool,
},
Brief {
question: String,
#[arg(long, short, default_value_t = semlith::brief::DEFAULT_BUDGET)]
budget: i64,
#[arg(long, short)]
path: Vec<String>,
#[arg(long, short)]
ext: Vec<String>,
#[arg(long, short)]
lang: Vec<String>,
#[arg(long, default_value = "any")]
prefer: String,
#[arg(long)]
json: bool,
},
Read {
target: String,
#[arg(long, short)]
path: Vec<String>,
#[arg(long)]
json: bool,
},
Pattern {
query: String,
#[arg(long, short)]
lang: String,
#[arg(long, short)]
path: Vec<String>,
#[arg(long, default_value_t = 0)]
offset: usize,
#[arg(long)]
json: bool,
},
Stats,
Files,
Forget { path: PathBuf },
Scan {
#[arg(value_name = "STORE")]
name: Option<String>,
#[arg(long)]
forget: bool,
#[arg(long)]
json: bool,
},
Drop {
#[arg(value_name = "STORE")]
name: String,
#[arg(long)]
yes: bool,
},
Add {
url: String,
#[arg(long)]
name: Option<String>,
#[arg(long)]
airgap: bool,
},
Mcp,
Setup {
#[arg(long)]
no_service: bool,
#[arg(long, short)]
yes: bool,
#[arg(long)]
airgap: bool,
#[arg(long)]
register_all: bool,
},
Doctor {
#[arg(long)]
json: bool,
#[arg(long)]
fix: bool,
#[arg(long, conflicts_with_all = ["json", "fix"])]
brief: bool,
},
Upgrade {
#[arg(long)]
check: bool,
#[arg(long)]
version: Option<String>,
#[arg(long)]
airgap: bool,
},
Models,
Languages,
Ledger {
#[arg(long, default_value_t = 20)]
last: usize,
#[arg(long)]
verify: bool,
#[arg(long)]
json: bool,
},
Symbol {
name: String,
#[arg(long, short, default_value_t = 20)]
k: usize,
#[arg(long)]
history: bool,
#[arg(long)]
json: bool,
},
Neighbors {
name: String,
#[arg(long, short)]
kind: Vec<String>,
#[arg(long)]
all: bool,
#[arg(long)]
json: bool,
},
Path {
from: String,
to: String,
#[arg(long, short, default_value_t = 6)]
depth: u32,
#[arg(long)]
all_edges: bool,
#[arg(long)]
strict: bool,
#[arg(long)]
json: bool,
},
}
#[derive(Subcommand)]
enum KeyCommand {
Show {
#[arg(long)]
quiet: bool,
},
Rotate {
#[arg(long)]
now: bool,
},
}
fn main() -> Result<()> {
quiet_on_a_closed_pipe();
let worker = std::thread::Builder::new()
.name("semlith".into())
.stack_size(16 * 1024 * 1024)
.spawn(run)?;
match worker.join() {
Ok(result) => result,
Err(_) => std::process::exit(101),
}
}
fn run() -> Result<()> {
let cli = Cli::parse();
let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
match cli.command {
Command::Doctor { json, fix, brief } => {
let stores: Vec<(String, std::path::PathBuf)> = home::Registry::load()
.unwrap_or_default()
.stores
.into_keys()
.filter_map(|name| home::Registry::dir_of(&name).ok().map(|dir| (name, dir)))
.collect();
let applied = if fix {
semlith::doctor::apply_all(&stores)
} else {
Vec::new()
};
let report = if brief {
semlith::doctor::clients_report_read_only()
} else {
semlith::doctor::clients_report()
};
let rules = semlith::doctor::privacy_findings(&stores);
let proof = (!brief).then(semlith::doctor::proof);
if brief {
print_brief(&report, &rules);
let faults = report.iter().filter(|c| c.fault).count()
+ rules.iter().filter(|r| !r.ok).count();
if faults > 0 {
std::process::exit(1);
}
return Ok(());
}
if json {
let out = serde_json::json!({
"clients": report,
"rules": rules,
"service": semlith::service::status(),
"proof": proof,
"applied": applied
.iter()
.map(|r| match r {
Ok(a) => serde_json::json!(a),
Err(e) => serde_json::json!({ "error": e.to_string() }),
})
.collect::<Vec<_>>(),
});
println!("{}", serde_json::to_string_pretty(&out)?);
} else {
print_doctor(&report, &rules, &applied);
if let Some(proof) = &proof {
print_proof(proof);
}
}
let faults = report.iter().filter(|c| c.fault).count()
+ rules.iter().filter(|r| !r.ok).count()
+ usize::from(proof.as_ref().is_some_and(|p| p.failed.is_some()));
if faults > 0 {
std::process::exit(1);
}
}
Command::Setup {
yes,
airgap,
register_all,
no_service,
} => {
arm_airgap(airgap);
let no_service =
no_service || std::env::var(semlith::setup::NO_SERVICE_ENV).is_ok_and(|v| v == "1");
semlith::setup::run(yes, airgap, register_all, !no_service)?;
}
Command::Upgrade {
check,
version,
airgap,
} => {
arm_airgap(airgap);
if check {
if let Some(reason) = semlith::upgrade::offline() {
bail!("{reason}");
}
let found = semlith::upgrade::check()?;
println!("installed {}", found.installed);
println!("latest {}", found.latest.trim_start_matches('v'));
if let Some(reason) = &found.blocked {
println!("note: {reason}");
}
if found.available {
println!("run `semlith upgrade` to install it");
std::process::exit(semlith::upgrade::UPGRADE_AVAILABLE);
}
println!("already current");
} else {
semlith::upgrade::apply(version)?;
}
}
Command::Models => {
println!(
"{:<44} {:>5} dim default. IBM Granite R2 small, int8, English",
embed::GRANITE_NAME,
384
);
for info in TextEmbedding::list_supported_models() {
println!(
"{:<44} {:>5} dim {}",
info.model, info.dim, info.description
);
}
}
Command::Index {
paths,
model,
name,
quiet,
airgap,
include_secrets,
each,
projects,
reconcile,
} => {
arm_airgap(airgap);
let (roots, each) = match &projects {
Some(folder) => {
let (found, repositories) = home::projects_under(folder)?;
if found.is_empty() {
bail!(
"nothing under {} to index: no repository and no subfolder",
folder.display()
);
}
eprintln!(
"{} {} under {}",
found.len(),
if repositories {
"repositories"
} else {
"folders"
},
folder.display()
);
(found, true)
}
None if paths.is_empty() => (vec![PathBuf::from(".")], each),
None => (paths, each),
};
let (roots, unreadable) = semlith::check_roots(&roots);
for (path, why) in &unreadable {
eprintln!("cannot index {}: {why}", path.display());
}
if roots.is_empty() {
bail!(
"nothing to index: no path given could be read. Nothing was created \
and nothing was registered."
);
}
if each && name.is_some() {
bail!(
"--each names each store after its own path, so --name cannot apply to \
all of them. Run semlith index --name <name> <path> per folder instead."
);
}
let runs: Vec<Vec<PathBuf>> = if each {
roots.iter().cloned().map(|path| vec![path]).collect()
} else {
vec![roots.clone()]
};
for roots in &runs {
let first = roots[0].clone();
let choice = home::resolve(&cli.store, &first, name.as_deref())?;
if let Some(hint) = choice.hint() {
eprintln!("{hint}");
}
let dir = choice.one()?;
let model = model
.clone()
.map(|m| m.parse::<Model>().map_err(anyhow::Error::msg))
.transpose()?;
let mut store = Semlith::open(&dir, model)?;
store.quiet = quiet;
store.boundary = semlith::Boundary {
roots: None,
allow_secrets: include_secrets,
};
let started = Instant::now();
let mut spoke = Instant::now();
let report = store.index_paths(roots, |path, p| {
if quiet {
return;
}
if p.outcome == semlith::FileOutcome::Indexing {
eprintln!(" + {}", display(path));
}
if p.outcome == semlith::FileOutcome::Refused {
eprintln!(" - {}", display(path));
}
if p.outcome == semlith::FileOutcome::Failed {
eprintln!(
" ! {} — {}",
display(path),
p.why.as_deref().unwrap_or("failed")
);
}
if spoke.elapsed() >= PROGRESS_INTERVAL {
spoke = Instant::now();
eprintln!(" {}", predict(p, started.elapsed()));
}
})?;
let model_name = store.model().to_string();
home::record(&choice, roots, &model_name)?;
if reconcile != Reconcile::Off {
let boundary = home::index_roots(&dir);
let strays = store.out_of_root(&boundary)?;
if !strays.is_empty() {
if reconcile == Reconcile::Report {
eprintln!(
"{} file(s) in this store sit outside its roots. \
`--reconcile drop` removes them.",
strays.len()
);
} else {
let dropped = store.prune_out_of_root(&boundary)?;
eprintln!(
"reconciled: {dropped} file(s) dropped, held from outside \
this store's roots. The files on disk are untouched."
);
}
}
}
let (files, chunks, bytes) = store.stats()?;
let images = if report.images > 0 {
format!(", {} images", report.images)
} else {
String::new()
};
for (path, why) in &report.refused {
eprintln!("refused: {} — {why}", semlith::plain(path));
}
if !report.refused.is_empty() && !include_secrets {
eprintln!(" `--include-secrets` indexes these anyway, if you meant to.");
}
for (path, why) in &report.failed {
eprintln!("failed: {} — {why}", semlith::plain(path));
}
if report.secrets_indexed > 0 {
eprintln!(
"indexed {} file(s) the credential scan would have refused, because \
`--include-secrets` was given",
report.secrets_indexed
);
}
let by_reason = if report.skipped_reasons.is_empty() {
String::new()
} else {
let mut parts: Vec<(usize, &str)> = report
.skipped_reasons
.iter()
.map(|(kind, n)| (*n, kind.as_str()))
.collect();
parts.sort_by(|a, b| b.0.cmp(&a.0).then(a.1.cmp(b.1)));
format!(
" ({})",
parts
.iter()
.map(|(n, kind)| format!("{n} {kind}"))
.collect::<Vec<_>>()
.join(", ")
)
};
let failed = if report.failed.is_empty() {
String::new()
} else {
format!(", {} failed", report.failed.len())
};
eprintln!(
"indexed {} files ({} chunks{images}) in {:.1}s — {} already indexed, {} skipped{by_reason}, {} removed{failed}",
report.indexed,
report.chunks,
started.elapsed().as_secs_f32(),
report.unchanged,
report.skipped,
report.removed,
);
eprintln!(
"store: {files} files, {chunks} chunks, {} at {}",
semlith::human_bytes(bytes),
dir.display()
);
}
if !unreadable.is_empty() {
bail!(
"{} of {} paths could not be read, and were not indexed",
unreadable.len(),
unreadable.len() + roots.len()
);
}
}
Command::Watch {
paths,
debounce,
quiet,
} => {
let roots = if paths.is_empty() {
vec![PathBuf::from(".")]
} else {
paths
};
let choice = home::resolve(&cli.store, &roots[0], None)?;
if let Some(hint) = choice.hint() {
eprintln!("{hint}");
}
let dir = choice.one()?;
let mut store = Semlith::open(&dir, None)?;
store.quiet = quiet;
home::record(&choice, &roots, &store.model().to_string())?;
semlith::watch::stop_on_signal();
let shown: Vec<String> = roots.iter().map(|r| display(r)).collect();
semlith::watch::run(
&mut store,
&roots,
std::time::Duration::from_millis(debounce),
&semlith::watch::STOP,
|progress| {
use semlith::watch::Progress;
match progress {
Progress::Ready {
catch_up,
files,
chunks,
} => eprintln!(
"watching {} — {files} files, {chunks} chunks \
({} indexed at startup, {} unchanged)",
shown.join(", "),
catch_up.indexed,
catch_up.unchanged,
),
Progress::File(path) if !quiet => eprintln!(" ~ {}", display(path)),
Progress::Batch(report, elapsed) if !quiet => eprintln!(
" {} re-embedded, {} removed, {} chunks in {:.1}s",
report.indexed,
report.removed,
report.chunks,
elapsed.as_secs_f32(),
),
Progress::Error(e) => eprintln!("semlith: watch error: {e}"),
_ => {}
}
},
)?;
}
Command::Brief {
question,
budget,
path,
ext,
lang,
prefer,
json,
} => brief(
&cli.store, &cwd, question, budget, &path, &ext, &lang, &prefer, json,
)?,
Command::Search {
query,
k,
path,
ext,
lang,
prefer,
json,
} => {
let filter = Filter::new(&path, &ext, &lang)?;
let prefer = semlith::Prefer::parse(&prefer)?;
let mut fleet = read_fleet(&cli.store, &cwd, false)?;
fleet.quiet = json;
let selected = (!filter.is_empty())
.then(|| fleet.matching_files(&filter))
.transpose()?;
if selected == Some(0) {
if json {
println!("[]");
} else {
eprintln!(
"no files match the filter (store has {} chunks)",
fleet.chunks()
);
}
return Ok(());
}
let started = Instant::now();
let hits = fleet.search_preferring(None, &query, k, &filter, prefer)?;
let elapsed = started.elapsed();
semlith::ledger::search(&fleet, &CLI_LEDGER, &query, &hits, elapsed);
if json {
println!("{}", serde_json::to_string_pretty(&hits)?);
} else if hits.is_empty() {
eprintln!("no matches (store has {} chunks)", fleet.chunks());
} else {
let mut out = std::io::stdout().lock();
for (i, h) in hits.iter().enumerate() {
let from = match &h.store {
Some(label) => format!("[{label}] "),
None => String::new(),
};
let via: String = h
.lists
.iter()
.map(|l| match *l {
"vector" => 'v',
"keyword" => 'f',
"image" => 'i',
_ => 'g',
})
.collect();
let where_in = match h.image {
Some(px) => format!("{}x{} px", px.width, px.height),
None => format!("{}-{}", h.start_line, h.end_line),
};
writeln!(
out,
"{}{}. {:.3} {via:<3} {from}{}:{where_in}{}",
bold(),
i + 1,
h.score,
display(std::path::Path::new(&h.path)),
reset()
)?;
for line in h.text.lines() {
writeln!(out, " {line}")?;
}
writeln!(out)?;
}
let across = if fleet.len() > 1 {
let breakdown: Vec<String> = fleet
.labels()
.iter()
.map(|label| {
let n = hits
.iter()
.filter(|h| h.store.as_deref() == Some(*label))
.count();
format!("{label} {n}")
})
.collect();
format!(" across {} stores: {}", fleet.len(), breakdown.join(", "))
} else {
String::new()
};
match selected {
Some(n) => eprintln!(
"{} hits in {:?} (filter selected {n} of {} files){across}",
hits.len(),
elapsed,
fleet.files()?,
),
None => eprintln!("{} hits in {:?}{across}", hits.len(), elapsed),
}
let shape = semlith::shape_of(&query);
match prefer {
semlith::Prefer::Any => {
eprintln!("{} · {}", shape.as_str(), shape.weighting())
}
chosen => eprintln!(
"{} · {} · prefer {}",
shape.as_str(),
shape.weighting(),
chosen.as_str()
),
}
let evicted: u64 = fleet.each().map(|(_, s)| s.evictions()).sum();
if evicted > 0 {
eprintln!(
"semlith: put down {evicted} shard(s) to stay inside the {} MB index \
budget; raise {} to hold more",
semlith::index::budget_mb(),
semlith::index::INDEX_MEMORY_ENV,
);
}
}
}
Command::Ledger { last, verify, json } => {
let fleet = read_fleet(&cli.store, &cwd, false)?;
let many = fleet.len() > 1;
if verify {
let mut broken = false;
for (label, store) in fleet.each() {
match semlith::store::ledger_break(store.db())? {
Some(row) => {
broken = true;
println!("{label}: the chain does not verify from row {row} onwards");
}
None => println!("{label}: the chain is intact"),
}
}
if broken {
std::process::exit(1);
}
return Ok(());
}
let mut any = false;
for (label, store) in fleet.each() {
let rows = semlith::store::retrievals(store.db(), last)?;
if json {
println!("{}", serde_json::to_string_pretty(&rows)?);
any = any || !rows.is_empty();
continue;
}
if many {
println!("{}{label}{}", bold(), reset());
}
if rows.is_empty() {
continue;
}
any = true;
let mut out = std::io::stdout().lock();
for row in &rows {
writeln!(
out,
"{} {:<14} {:>3} hits {:>6} ms {}",
human_time(row.at),
row.client,
row.hits,
row.micros / 1000,
row.query,
)?;
}
if let Some(broken) = semlith::store::ledger_break(store.db())? {
writeln!(
out,
"
the chain does not verify from row {broken} onwards: \
these rows have been edited or removed"
)?;
}
}
if !any && !json {
eprintln!(
"nothing recorded yet. The daemon records by default from 0.15.0 and says \
so on every start; `--no-ledger` or SEMLITH_LEDGER=0 stops it. Nothing \
recorded ever leaves this machine."
);
}
}
Command::Symbol {
name,
k,
history,
json,
} => {
let fleet = read_fleet(&cli.store, &cwd, false)?;
if history {
let past = fleet.past_in(None, &name, k)?;
if json {
println!("{}", serde_json::to_string_pretty(&past)?);
} else if past.is_empty() {
eprintln!(
"no earlier definition of {name} is recorded \
(a store keeps history from its first index pass under 0.23.0)"
);
} else {
let mut out = std::io::stdout().lock();
for row in &past {
writeln!(
out,
"{}{} {}{} {}:{}-{} until {} hash {}",
bold(),
row.kind,
row.qualified,
reset(),
display(std::path::Path::new(&row.path)),
row.start_line,
row.end_line,
semlith::clock::local_stamp(row.retired_at),
&row.content_hash[..row.content_hash.len().min(12)],
)?;
}
}
return Ok(());
}
let found =
fleet.evidence_in(None, &name, &semlith::graph::dependency_kinds(), k, false)?;
if json {
println!("{}", serde_json::to_string_pretty(&found)?);
} else if found.definitions.is_empty() {
eprintln!("{}", nothing_known(&fleet, &name));
} else {
let mut out = std::io::stdout().lock();
writeln!(
out,
"{}",
found.render(bold(), reset(), &|p| display(std::path::Path::new(p)))
)?;
}
}
Command::Read { target, path, json } => {
let filter = Filter::new(&path, &[], &[])?;
let target = semlith::Target::parse(&target);
let fleet = read_fleet(&cli.store, &cwd, false)?;
let started = Instant::now();
let found = fleet.read_in(None, &target, &filter)?;
semlith::ledger::graph(
&fleet,
&CLI_LEDGER,
"read",
match &target {
semlith::Target::Span { path, .. } => path,
semlith::Target::Symbol(name) => name,
},
"",
found.is_some(),
started.elapsed(),
);
match found {
None => eprintln!(
"nothing indexed at that span or under that name (store has {} chunks)",
fleet.chunks()
),
Some(found) if json => println!("{}", serde_json::to_string_pretty(&found)?),
Some(semlith::Read::Choose(rows)) => {
let mut out = std::io::stdout().lock();
writeln!(out, "{} definitions of this name:", rows.len())?;
for row in &rows {
writeln!(
out,
" {} {} {}{}:{}-{}",
row.name,
row.kind,
store_prefix(&row.store),
display(std::path::Path::new(&row.path)),
row.start_line,
row.end_line,
)?;
}
}
Some(semlith::Read::One(span)) => {
let mut out = std::io::stdout().lock();
let named = match (&span.symbol, &span.symbol_kind) {
(Some(name), Some(kind)) => format!(" {name} {kind}"),
(Some(name), None) => format!(" {name}"),
_ => String::new(),
};
writeln!(
out,
"{}{}{}:{}-{}{named}{}{}",
bold(),
store_prefix(&span.store),
display(std::path::Path::new(&span.path)),
span.start_line,
span.end_line,
if span.fresh { "" } else { " · stale" },
reset(),
)?;
for line in span.text.lines() {
writeln!(out, "{line}")?;
}
}
}
}
Command::Pattern {
query,
lang,
path,
offset,
json,
} => {
let filter = Filter::new(&path, &[], &[])?;
let fleet = read_fleet(&cli.store, &cwd, false)?;
let started = Instant::now();
let found = fleet.pattern_in(None, &lang, &query, &filter, offset)?;
semlith::ledger::graph(
&fleet,
&CLI_LEDGER,
"pattern",
&query,
"",
!found.matches.is_empty(),
started.elapsed(),
);
if json {
println!("{}", serde_json::to_string_pretty(&found)?);
} else if found.matches.is_empty() {
if found.files == 0 {
eprintln!("no indexed file is {}", found.language);
} else {
eprintln!("no match in {} {} files", found.files, found.language);
}
} else {
let mut out = std::io::stdout().lock();
for found in &found.matches {
writeln!(
out,
"{}{}{}:{}-{}{} @{} {}",
bold(),
store_prefix(&found.store),
display(std::path::Path::new(&found.path)),
found.start_line,
found.end_line,
reset(),
found.capture,
found.text,
)?;
}
eprintln!(
"{} match(es) in {} {} files{}",
found.matches.len(),
found.files,
found.language,
if found.truncated {
format!(
" (truncated — --offset {} for the rest)",
offset + found.matches.len()
)
} else {
String::new()
},
);
}
}
Command::Neighbors {
name,
kind,
all,
json,
} => {
for k in &kind {
if !semlith::graph::KINDS.contains(&k.as_str()) {
anyhow::bail!(
"unknown edge kind {k:?}; the kinds are {}",
semlith::graph::KINDS.join(", ")
);
}
}
let fleet = read_fleet(&cli.store, &cwd, false)?;
let started = Instant::now();
let neighbours = fleet.neighbours_in(None, &name, &kind, all)?;
let found = !neighbours.callers.is_empty() || !neighbours.callees.is_empty();
semlith::ledger::graph(
&fleet,
&CLI_LEDGER,
"neighbors",
&name,
"",
found,
started.elapsed(),
);
if json {
println!("{}", serde_json::to_string_pretty(&neighbours)?);
} else if neighbours.callers.is_empty()
&& neighbours.callees.is_empty()
&& neighbours.hidden == 0
&& neighbours.unresolved.is_empty()
{
eprintln!("{}", nothing_known(&fleet, &name));
} else {
let mut out = std::io::stdout().lock();
print_ends(&mut out, "callers", &neighbours.callers)?;
print_ends(&mut out, "callees", &neighbours.callees)?;
if neighbours.hidden > 0 {
writeln!(
out,
"
{} target{} outside this store, not listed (--all)",
neighbours.hidden,
if neighbours.hidden == 1 { "" } else { "s" },
)?;
}
if !neighbours.unresolved.is_empty() {
writeln!(
out,
"
{}outside this store{}",
bold(),
reset()
)?;
for end in &neighbours.unresolved {
writeln!(out, " {} via {}", end.name, end.kind)?;
}
}
}
}
Command::Path {
from,
to,
depth,
all_edges,
strict,
json,
} => {
let all_edges = all_edges && !strict;
let fleet = read_fleet(&cli.store, &cwd, false)?;
let started = Instant::now();
let chain = fleet.path_in(None, &from, &to, depth, all_edges)?;
semlith::ledger::graph(
&fleet,
&CLI_LEDGER,
"path",
&from,
"",
chain.is_some(),
started.elapsed(),
);
if json {
println!("{}", serde_json::to_string_pretty(&chain)?);
} else {
match chain {
Some(chain) if chain.steps.is_empty() => {
println!("{from} is {to}");
}
Some(chain) => {
let mut out = std::io::stdout().lock();
write!(
out,
"{}",
chain.render(bold(), reset(), &|p| display(std::path::Path::new(p)))
)?;
}
None => eprintln!(
"{}",
semlith::graph::not_connected(&from, &to, depth, all_edges)
),
}
}
}
Command::Languages => {
for entry in semlith::filter::LANGUAGES {
let what = entry
.extensions
.iter()
.map(|e| format!(".{e}"))
.chain(entry.filenames.iter().map(|f| f.to_string()))
.collect::<Vec<_>>()
.join(" ");
let graph = if semlith::graph::has_graph(entry.name) {
"graph"
} else {
" "
};
println!("{:<12} {graph} {what}", entry.name);
}
}
Command::Stats => {
let fleet = read_fleet(&cli.store, &cwd, false)?;
let many = fleet.len() > 1;
let mut totals = (0, 0, 0);
for (label, store) in fleet.each() {
let (files, chunks, bytes) = store.stats()?;
totals = (totals.0 + files, totals.1 + chunks, totals.2 + bytes);
if many {
println!("{label}");
}
println!("store {}", store.dir().display());
if let Some(mode) = home::loose_mode(store.dir()) {
println!(
"warning the store directory is mode {mode:o}; other users on this \
machine can read what it indexed. Opening it with this semlith \
narrows it to 700."
);
}
println!("model {} ({} dim)", store.model(), store.dim());
println!("files {files}");
println!(
"chunks {chunks} (cut at {})",
semlith::store::chunking(store.db())?
);
let images = store.image_count()?;
if images > 0 {
println!("images {images}");
}
println!("vectors {}", store.len());
if let Some((shards, max)) = store.shards() {
println!(
"shards {shards}, up to {max} resident ({} MB budget, {})",
semlith::index::budget_mb(),
semlith::index::INDEX_MEMORY_ENV,
);
}
println!("indexed {}", semlith::human_bytes(bytes));
match store.exact_bytes() {
Some(exact) => println!(
"exact {} (full-precision rescoring)",
semlith::human_bytes(exact as i64)
),
None => println!("exact none (re-index to rescore in full precision)"),
}
println!("history {} retired definitions", store.retired_symbols()?);
let savings = semlith::store::ledger_savings(store.db())?;
if savings.total > 0 {
println!(
"saved {} tokens not read, over {} of {} retrievals \
({}% coverage, {})",
savings.net,
savings.credited,
savings.total,
savings.coverage(),
savings.tier(),
);
}
if many {
println!();
}
}
if many {
println!(
"total {} stores, {} files, {} chunks, {}",
fleet.len(),
totals.0,
totals.1,
semlith::human_bytes(totals.2),
);
}
}
Command::Files => {
let fleet = read_fleet(&cli.store, &cwd, false)?;
let many = fleet.len() > 1;
for (label, store) in fleet.each() {
if many {
println!("{label}");
}
for path in semlith::store::all_paths(store.db())? {
println!("{}", display(std::path::Path::new(&path)));
}
if many {
println!();
}
}
}
Command::Add { url, name, airgap } => {
arm_airgap(airgap);
let choice = home::resolve(&cli.store, &cwd, name.as_deref())?;
let choice = resolve_for_add(choice, &cli.store, name.as_deref())?;
if let Some(hint) = choice.hint() {
eprintln!("{hint}");
}
let dir = choice.one()?;
let spinner = cliclack::spinner();
spinner.start(format!("fetching {url}"));
let fetched = match semlith::add::fetch(&url, &dir) {
Ok(fetched) => {
spinner.stop(format!(
"fetched {}",
semlith::human_bytes(fetched.bytes as i64)
));
fetched
}
Err(e) => {
spinner.error("fetch failed");
return Err(e);
}
};
let mut store = Semlith::open(&dir, None)?;
store.quiet = true;
let report = store.index_paths(std::slice::from_ref(&fetched.path), |_, _| {})?;
let model_name = store.model().to_string();
home::record(&choice, &[semlith::add::downloads_dir(&dir)], &model_name)?;
eprintln!("{} -> {}", fetched.url, display(&fetched.path));
eprintln!("indexed {} chunks into {}", report.chunks, dir.display());
}
Command::Scan { name, forget, json } => {
let choice = home::resolve(&cli.store, &cwd, name.as_deref())?;
if let Some(hint) = choice.hint() {
eprintln!("{hint}");
}
if matches!(choice, home::Choice::New { .. }) {
anyhow::bail!("there is no store here to scan");
}
let mut store = Semlith::open(&choice.one()?, None)?;
let findings = store.scan()?;
if json {
println!("{}", serde_json::to_string_pretty(&findings)?);
} else {
for finding in &findings {
println!(
"{} — {}",
display(std::path::Path::new(&finding.path)),
finding.why
);
}
}
if findings.is_empty() {
if !json {
println!("nothing this store holds would be refused today.");
}
return Ok(());
}
if forget {
let gone = store.forget_findings(&findings)?;
if !json {
println!("removed {gone} file(s) from the store.");
}
return Ok(());
}
if !json {
println!(
"{} file(s) would be refused today. `semlith scan --forget` removes them.",
findings.len()
);
}
std::process::exit(1);
}
Command::Forget { path } => {
let anchor = if path.is_dir() {
path.clone()
} else {
match path.parent() {
Some(dir) if !dir.as_os_str().is_empty() => dir.to_path_buf(),
_ => cwd.clone(),
}
};
let choice = home::resolve(&cli.store, &anchor, None)?;
if let Some(hint) = choice.hint() {
eprintln!("{hint}");
}
if matches!(choice, home::Choice::New { .. }) {
anyhow::bail!(not_indexed(&path));
}
let mut store = Semlith::open(&choice.one()?, None)?;
let (chunks, images) = store.forget(&path)?;
if chunks == 0 && images == 0 {
anyhow::bail!(not_indexed(&path));
}
let what = match (chunks, images) {
(0, images) => format!("{images} image vector(s)"),
(chunks, 0) => format!("{chunks} chunks"),
(chunks, images) => format!("{chunks} chunks and {images} image vector(s)"),
};
eprintln!("removed {what} for {}", path.display());
}
Command::Drop { name: store, yes } => {
let dir = semlith::home::Registry::dir_of(&store)?;
if !semlith::home::Registry::load()?.stores.contains_key(&store) {
anyhow::bail!("no registered store called {store}");
}
if !yes {
let go = cliclack::confirm(format!(
"Delete {store}? Its vectors, chunks, graph and ledger go; the files it indexed are untouched."
))
.initial_value(false)
.interact()
.unwrap_or(false);
if !go {
eprintln!("nothing was deleted");
return Ok(());
}
}
let dirs = semlith::home::all_dirs(&cli.store, &cwd).unwrap_or_default();
let through_daemon = match semlith::proxy::find(&dirs) {
Some(upstream) => upstream.delete_store(&store).is_ok(),
None => false,
};
if !through_daemon {
semlith::home::delete_store(&store)?;
}
eprintln!("deleted {store} ({})", dir.display());
eprintln!("the files it indexed are untouched");
}
Command::Start {
service,
no_service,
paths,
no_ledger,
port,
debounce,
airgap,
no_mcp_http,
} => {
if no_service {
let removed = semlith::service::remove()?;
println!(
"{}",
if removed {
"semlith: login service removed — a daemon already running is untouched"
} else {
"semlith: no login service was installed"
}
);
return Ok(());
}
if service {
let installed = semlith::service::install(None, port)?;
print_service(&installed);
return Ok(());
}
arm_airgap(airgap);
let dirs = semlith::daemon::stores_to_open(&cli.store, &paths, &cwd)?;
if dirs.is_empty() {
eprintln!("semlith: no store registered yet — the portal will offer to make one");
}
semlith::daemon::run(
&dirs,
semlith::daemon::port_of(port),
std::time::Duration::from_millis(debounce),
semlith::embed::airgap(),
!no_ledger && semlith::ledger::enabled(),
!no_mcp_http,
|line| eprintln!("semlith: {line}"),
)?;
}
Command::Key { what } => match what {
KeyCommand::Show { quiet } => {
let key = semlith::home::agent_key()?;
if quiet {
println!("{key}");
} else {
let port = semlith::daemon::port_of(None);
println!("{}{key}{}", bold(), reset());
println!("{}", semlith::home::agent_key_path()?.display());
println!();
println!("It authenticates the MCP endpoint and nothing else, and it does not");
println!("change when the daemon restarts or the portal's token is rotated.");
for stanza in semlith::clients::http_stanzas(&key) {
println!();
println!("{}{}{}", bold(), stanza.format, reset());
print!("{}", stanza.text);
}
println!();
println!("Endpoint: http://127.0.0.1:{port}/mcp");
}
}
KeyCommand::Rotate { now } => {
let previous = semlith::home::agent_key().unwrap_or_default();
let fresh = semlith::home::rotate_agent_key()?;
let carried = semlith::setup::recarry_key(&previous, &fresh);
let dirs = semlith::home::all_dirs(&cli.store, &cwd).unwrap_or_default();
let reached = match semlith::proxy::find(&dirs) {
Some(upstream) => upstream.adopt_key(&fresh, now).is_ok(),
None => false,
};
println!("{}{fresh}{}", bold(), reset());
if reached {
if now {
println!("The running daemon took it up; the previous key is refused now.");
} else {
println!(
"The running daemon took it up. The previous key keeps working until \
that daemon exits, so a session already open finishes."
);
}
} else {
println!(
"No daemon is running here; the next `semlith start` reads the new key."
);
}
if carried.is_empty() {
println!(
"No configuration file on this machine carried the old key, and none \
needed to: a registration semlith wrote launches `semlith mcp`, which \
reads the key from the file you just rotated."
);
} else {
println!();
println!("Carried the new key into:");
for path in &carried {
println!(" {}", path.display());
}
}
println!();
println!("Any client configured somewhere else needs the stanza below pasted in.");
for stanza in semlith::clients::http_stanzas(&fresh) {
println!();
print!("{}", stanza.text);
}
}
},
Command::Trust { store_dir, list } => {
let mut registry = home::Registry::load()?;
if list || store_dir.is_none() {
if registry.trusted.is_empty() {
eprintln!(
"no store outside {} is trusted. Every store semlith made is \
opened without asking; a `.semlith` that arrived some other \
way needs `semlith trust <dir>` once.",
home::home_or_error()?.display()
);
} else {
for dir in ®istry.trusted {
let missing = if dir.join("store.db").exists() {
""
} else {
" (gone)"
};
println!("{}{missing}", dir.display());
}
}
return Ok(());
}
let dir = registry.trust(store_dir.as_deref().expect("checked above"))?;
eprintln!(
"{} is trusted. semlith will open it from this directory without \
--store; `semlith adopt` moves it into {} if you would rather it \
lived with the others.",
dir.display(),
home::stores_root()?.display()
);
}
Command::Adopt {
store_dir,
root,
name,
} => {
let registered = home::Registry::load()?
.name_of(&store_dir)
.map(str::to_string)
.or_else(|| {
name.as_deref()
.filter(|n| home::Registry::load().is_ok_and(|r| r.stores.contains_key(*n)))
.map(str::to_string)
});
match (registered, &root) {
(Some(name), Some(root)) => {
home::repoint(&name, root)?;
eprintln!("{name} now indexes {}", root.display());
}
_ => {
let (name, target) = home::adopt(&store_dir, root.as_deref(), name.as_deref())?;
let store = Semlith::open_existing(&target)?;
let (files, chunks, bytes) = store.stats()?;
eprintln!(
"adopted {} as {name} — {files} files, {chunks} chunks, {} at {}",
store_dir.display(),
semlith::human_bytes(bytes),
target.display(),
);
}
}
}
Command::Mcp => {
let dirs = semlith::home::all_dirs(&cli.store, &cwd)?;
if let Some(upstream) = semlith::proxy::find(&semlith::proxy::candidates(&dirs)) {
eprintln!(
"semlith {}: forwarding to the daemon on 127.0.0.1:{} (found via {})",
env!("CARGO_PKG_VERSION"),
upstream.port,
upstream.via.display(),
);
return semlith::proxy::serve(
&upstream,
std::io::stdin().lock(),
std::io::stdout().lock(),
);
}
let mut fleet = match read_fleet(&cli.store, &cwd, true) {
Ok(fleet) => fleet,
Err(e) => {
eprintln!("semlith: {e}");
Fleet::empty()
}
};
fleet.quiet = true;
let labels = fleet.labels().join(", ");
let stores = fleet.len();
match stores {
0 => eprintln!(
"semlith {}: serving no store — the tools are listed, and the first one that \
needs a corpus will say so",
env!("CARGO_PKG_VERSION"),
),
1 => eprintln!(
"semlith {}: serving 1 store on {labels}",
env!("CARGO_PKG_VERSION"),
),
n => eprintln!(
"semlith {}: serving {n} stores on {labels}",
env!("CARGO_PKG_VERSION"),
),
}
let fleet = std::sync::Arc::new(std::sync::Mutex::new(fleet));
if stores > 0 {
let warming = std::sync::Arc::clone(&fleet);
std::thread::spawn(move || {
let started = std::time::Instant::now();
let mut fleet = warming.lock().unwrap_or_else(|e| e.into_inner());
match fleet.warm() {
Ok(()) => eprintln!(
"semlith: embedding model ready in {}ms; searches are warm from here",
started.elapsed().as_millis(),
),
Err(e) => eprintln!("semlith: could not load the embedding model: {e}"),
}
});
eprintln!(
"semlith: loading the embedding model in the background — the first search \
waits for it, everything else does not"
);
}
semlith::mcp::serve(
&fleet,
&labels,
std::io::stdin().lock(),
std::io::stdout().lock(),
)?;
}
}
Ok(())
}
fn arm_airgap(on: bool) {
if on {
unsafe { std::env::set_var(semlith::embed::AIRGAP_ENV, "1") };
}
}
fn read_fleet(flags: &[PathBuf], cwd: &Path, all: bool) -> Result<Fleet> {
let dirs = if all {
home::all_dirs(flags, cwd)?
} else {
home::read_dirs(flags, cwd)?
};
if dirs.is_empty() {
bail!(
"no semlith store covers {} and none is registered — \
run `semlith index` in a directory to make one, \
or `semlith adopt ./.semlith` to move an existing one into {}",
cwd.display(),
semlith::home::stores_root()?.display(),
);
}
Fleet::open(&dirs)
}
const PROGRESS_INTERVAL: std::time::Duration = std::time::Duration::from_secs(2);
fn predict(p: semlith::IndexProgress, elapsed: std::time::Duration) -> String {
let secs = elapsed.as_secs_f32().max(0.001);
let rate = p.chunks as f32 / secs;
let left = p.total.saturating_sub(p.scanned);
let per_file = secs / p.scanned.max(1) as f32;
format!(
"{}/{} files, {} chunks, {rate:.0} chunks/s, ~{} left",
p.scanned,
p.total,
p.chunks,
human_duration(left as f32 * per_file),
)
}
fn human_duration(secs: f32) -> String {
if secs >= 3600.0 {
format!("{:.1}h", secs / 3600.0)
} else if secs >= 60.0 {
format!("{:.0}m", secs / 60.0)
} else {
format!("{secs:.0}s")
}
}
fn resolve_for_add(
choice: home::Choice,
flags: &[PathBuf],
name: Option<&str>,
) -> Result<home::Choice> {
if !matches!(choice, home::Choice::New { .. }) || !flags.is_empty() || name.is_some() {
return Ok(choice);
}
let registry = home::Registry::load()?;
let mut names = registry.stores.keys();
match (names.next(), names.next()) {
(Some(only), None) => {
let only = only.clone();
Ok(home::Choice::Registered {
dir: home::Registry::dir_of(&only)?,
name: only,
})
}
(None, _) => bail!(
"there is no store to add to yet — run `semlith index <path>` first, \
or give this one a name with `semlith add <url> --name <store>`"
),
_ => bail!(
"nothing indexes this directory, and there are {} stores to choose from: {}. \
Name one with `--store <dir>` or `--name <store>`, or run this from a \
directory one of them covers",
registry.stores.len(),
registry
.stores
.keys()
.cloned()
.collect::<Vec<_>>()
.join(", ")
),
}
}
const CLI_LEDGER: semlith::ledger::Who<'static> = semlith::ledger::Who {
client: "cli",
session: "cli",
};
fn quiet_on_a_closed_pipe() {
#[cfg(unix)]
{
unsafe {
libc::signal(libc::SIGPIPE, libc::SIG_DFL);
}
}
let inherited = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
let said = info
.payload()
.downcast_ref::<String>()
.map(String::as_str)
.or_else(|| info.payload().downcast_ref::<&str>().copied())
.unwrap_or_default();
let printing = said.starts_with("failed printing to std");
if printing && (said.contains("Broken pipe") || said.contains("pipe")) {
std::process::exit(0);
}
inherited(info);
}));
}
fn not_indexed(path: &std::path::Path) -> String {
format!("nothing to forget: {} is not indexed", path.display())
}
fn display(path: &std::path::Path) -> String {
let cwd = semlith::canonical(&std::env::current_dir().unwrap_or_default());
let real = semlith::canonical(path);
let shown = real.strip_prefix(&cwd).unwrap_or(&real);
semlith::plain(&shown.display().to_string())
}
fn bold() -> &'static str {
if std::io::stdout().is_terminal() {
"\x1b[1m"
} else {
""
}
}
fn reset() -> &'static str {
if std::io::stdout().is_terminal() {
"\x1b[0m"
} else {
""
}
}
fn nothing_known(fleet: &semlith::fleet::Fleet, name: &str) -> String {
let symbols: i64 = fleet
.each()
.map(|(_, store)| {
semlith::store::graph_stats(store.db())
.map(|(s, _)| s)
.unwrap_or(0)
})
.sum();
if symbols == 0 {
format!(
"no symbols in this store yet. The graph is built as files are indexed, \
so run `semlith index` once (or leave `semlith start` running) and \
{name} will be there if the corpus defines it."
)
} else {
format!("no symbol named {name} in the graph")
}
}
fn store_prefix(store: &Option<String>) -> String {
match store {
Some(label) => format!("[{label}] "),
None => String::new(),
}
}
fn print_ends(out: &mut impl Write, heading: &str, ends: &[semlith::store::EdgeEnd]) -> Result<()> {
writeln!(out, "{}{heading}{} ({})", bold(), reset(), ends.len())?;
if ends.is_empty() {
writeln!(out, " none")?;
return Ok(());
}
for end in ends {
if end.confidence == semlith::graph::AMBIGUOUS {
writeln!(
out,
" {} via {} ({}) · {} definitions{}",
end.symbol.name,
end.kind,
end.confidence,
end.definitions,
semlith::graph::call_site(end, &|p| display(std::path::Path::new(p))),
)?;
continue;
}
writeln!(
out,
" {} via {} ({}) {}{}:{}{}",
end.symbol.name,
end.kind,
end.confidence,
store_prefix(&end.symbol.store),
display(std::path::Path::new(&end.symbol.path)),
end.symbol.start_line,
semlith::graph::call_site(end, &|p| display(std::path::Path::new(p))),
)?;
}
Ok(())
}
fn human_time(at: i64) -> String {
semlith::clock::local_clock(at)
}
fn print_proof(proof: &semlith::doctor::Proof) {
println!();
println!("{}Proof{}", bold(), reset());
let service = semlith::service::status();
println!(
" {:<4} {:<20} {}",
if service.installed { "ok " } else { "-- " },
"login service",
if service.installed {
match semlith::service::last_started() {
Some(at) => format!(
"installed ({}), daemon last started {}",
service.mechanism,
human_time(at)
),
None => format!("installed ({}), no daemon running", service.mechanism),
}
} else {
"not installed — `semlith start --service`".to_string()
},
);
println!(
" {:<4} {:<20} {}",
"ok ", "a client would run", proof.command
);
println!(
" {:<4} {:<20} {}",
if proof.launched { "ok " } else { "FAIL" },
"launched",
if proof.launched {
"answered `initialize` with the environment a service manager gives a job"
} else {
"did not answer with a plain PATH"
},
);
match proof.tools {
Some(n) if n > 0 => println!(" {:<4} {:<20} {n} tools", "ok ", "listed the tools"),
_ => println!(" {:<4} {:<20} none", "FAIL", "listed the tools"),
}
match &proof.client {
Some(verdict) => println!(
" {:<4} {:<20} {}: {}",
if verdict.connected { "ok " } else { "FAIL" },
"client says",
verdict.name,
verdict.says,
),
None => println!(
" {:<4} {:<20} no client with a command semlith can ask is installed",
"n/a ", "client says",
),
}
if let Some(failed) = &proof.failed {
println!(" first failure: {failed}");
}
}
fn print_brief(clients: &[semlith::doctor::ClientReport], rules: &[semlith::doctor::Finding]) {
let service = semlith::service::status();
let broken = clients
.iter()
.find(|c| c.disabled_here)
.or_else(|| clients.iter().find(|c| c.fault));
if let Some(broken) = broken {
let why = match broken.explain.as_deref() {
Some(explain) => explain
.split_once(". ")
.map(|(head, _)| head)
.unwrap_or(explain)
.to_string(),
None => "is installed and cannot reach semlith".to_string(),
};
let fix = broken
.repair
.as_deref()
.map(|r| r.split_once(" #").map(|(cmd, _)| cmd).unwrap_or(r).trim())
.map(|cmd| format!(" — run: {cmd}"))
.unwrap_or_default();
println!("semlith: {} {why}{fix}", broken.name);
return;
}
if let Some(failed) = rules.iter().find(|r| !r.ok) {
println!("semlith: {}", failed.check);
return;
}
let stores = semlith::home::Registry::load()
.map(|r| r.stores.len())
.unwrap_or(0);
println!(
"semlith {} · {} store{} · {}",
env!("CARGO_PKG_VERSION"),
stores,
if stores == 1 { "" } else { "s" },
if service.installed {
format!("{} service", service.mechanism)
} else {
"no login service — `semlith start --service`".to_string()
},
);
}
fn print_service(status: &semlith::service::Status) {
if !status.installed {
println!(
"semlith: the {} definition was written but {} does not report it as installed",
status.mechanism, status.mechanism,
);
return;
}
if status.started_now {
println!(
"semlith: installed as a {} login service — running now, and from every login",
status.mechanism,
);
} else {
println!(
"semlith: installed as a {} login service — a daemon is already listening, so this one starts at the next login",
status.mechanism,
);
}
if let Some(definition) = &status.definition {
println!(" definition {}", definition.display());
}
if let Some(log) = &status.log {
println!(" log {}", log.display());
}
if !status.restarts {
println!(
" note this platform restarts a service that fails, \
but does not restart one that exits cleanly"
);
}
println!(" remove semlith start --no-service");
}
fn print_doctor(
clients: &[semlith::doctor::ClientReport],
rules: &[semlith::doctor::Finding],
applied: &[anyhow::Result<semlith::doctor::Applied>],
) {
println!("{}Clients{}", bold(), reset());
for client in clients {
let state = match (client.registered, &client.note, &client.command) {
(_, Some(_), _) => "cannot register".to_string(),
(true, _, _) if client.disabled_here => {
format!("registered ({}), OFF HERE", client.scope.unwrap_or("user"))
}
(true, _, _) if client.fault => {
format!(
"registered ({}), CANNOT LAUNCH",
client.scope.unwrap_or("user")
)
}
(true, _, _) => format!("registered ({})", client.scope.unwrap_or("user")),
(false, _, None) => "not registered".to_string(),
(false, _, Some(_)) if !client.present => "not installed".to_string(),
(false, _, Some(_)) => match client.scope {
Some("project") => "registered for one project only".to_string(),
_ => "installed, not registered".to_string(),
},
};
println!(" {:<28} {state}", client.name);
if let Some(note) = &client.note {
println!(" {note}");
}
if let Some(explain) = &client.explain {
println!(" {explain}");
}
for dir in client.disabled_in.iter().filter(|_| !client.disabled_here) {
println!(" switched off for {dir}");
}
if let Some(repair) = &client.repair {
println!(" run: {repair}");
}
}
println!();
println!("{}Rules{}", bold(), reset());
for rule in rules {
let mark = match (rule.applicable, rule.ok) {
(false, _) => "n/a ",
(true, true) => "ok ",
(true, false) => "FAIL",
};
println!(" {mark} {:<20} {}", rule.id, rule.check);
if let Some(manual) = &rule.manual {
println!(" run: {manual}");
}
}
if !applied.is_empty() {
println!();
println!("{}Applied{}", bold(), reset());
for result in applied {
match result {
Ok(a) => println!(
" {a}{}",
if a.rechecked_ok {
""
} else {
" (the rule still does not hold)"
}
),
Err(e) => println!(" refused: {e}"),
}
}
}
}
#[allow(clippy::too_many_arguments)]
fn brief(
store: &[PathBuf],
cwd: &std::path::Path,
question: String,
budget: i64,
path: &[String],
ext: &[String],
lang: &[String],
prefer: &str,
json: bool,
) -> Result<()> {
let filter = Filter::new(path, ext, lang)?;
let prefer = semlith::Prefer::parse(prefer)?;
if budget < 1 {
anyhow::bail!("--budget is a number of tokens, so it has to be at least 1");
}
let mut fleet = read_fleet(store, cwd, false)?;
fleet.quiet = json;
let started = Instant::now();
let brief = semlith::brief::brief(&mut fleet, None, &question, budget, &filter, prefer)?;
let elapsed = started.elapsed();
let rendered = serde_json::to_string(&brief)?;
semlith::ledger::reply(&fleet, &CLI_LEDGER, "brief", &question, &rendered, elapsed);
if json {
println!(
"{rendered_pretty}",
rendered_pretty = serde_json::to_string_pretty(&brief)?
);
return Ok(());
}
if brief.spans.is_empty() {
eprintln!("no matches (store has {} chunks)", fleet.chunks());
return Ok(());
}
let mut out = std::io::stdout().lock();
for (i, span) in brief.spans.iter().enumerate() {
let from = match &span.store {
Some(label) => format!("[{label}] "),
None => String::new(),
};
let via: String = span
.lists
.iter()
.map(|l| match *l {
"vector" => 'v',
"keyword" => 'f',
"image" => 'i',
_ => 'g',
})
.collect();
let what = match (&span.symbol, &span.symbol_kind) {
(Some(name), Some(kind)) => format!(" {kind} {name}"),
(Some(name), None) => format!(" {name}"),
_ => String::new(),
};
writeln!(
out,
"{}{}. {via:<3} {from}{}:{}-{}{what}{}",
bold(),
i + 1,
display(std::path::Path::new(&span.path)),
span.start_line,
span.end_line,
reset()
)?;
match &span.text {
Some(text) => {
for line in text.lines() {
writeln!(out, " {line}")?;
}
}
None => writeln!(out, " (text left out for the budget)")?,
}
writeln!(out)?;
}
for symbol in &brief.symbols {
writeln!(out, "{}{} (graph){}", bold(), symbol.name, reset())?;
for (label, edges) in [("called by", &symbol.callers), ("calls", &symbol.callees)] {
for edge in edges {
writeln!(
out,
" {label} {} {}:{}",
edge.symbol.name,
display(std::path::Path::new(&edge.symbol.path)),
edge.symbol.start_line
)?;
}
}
if symbol.hidden > 0 {
writeln!(out, " and {} more edges", symbol.hidden)?;
}
writeln!(out)?;
}
let mut tail = format!(
"{} tokens of {budget}, counted with {}",
brief.tokens, brief.counted_with
);
if !brief.cut.is_empty() {
let mut dropped = Vec::new();
if brief.cut.span_text > 0 {
dropped.push(format!("{} spans left without text", brief.cut.span_text));
}
if brief.cut.symbols > 0 {
dropped.push(format!("{} symbols' edges", brief.cut.symbols));
}
tail.push_str(&format!(" -- dropped: {}", dropped.join(", ")));
}
writeln!(out, "{tail}")?;
Ok(())
}