use std::collections::BTreeMap;
use std::path::Path;
use teksilo_corpus::Index;
use crate::guard::{self, Verdict};
use crate::resolve;
const MAX_SUGGESTIONS: usize = 6;
#[derive(Debug, thiserror::Error)]
pub enum ShowError {
#[error(transparent)]
Resolve(#[from] resolve::ResolveError),
#[error("{0}")]
Refused(String),
#[error("{0}")]
Usage(String),
#[error("{0}")]
NotFound(String),
#[error("the embedded corpus could not be read: {0}")]
Corpus(#[from] teksilo_corpus::CorpusError),
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct ShowRequest {
pub path: Option<String>,
pub lines: Option<String>,
pub list: bool,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub struct LineRange {
pub first: usize,
pub last: usize,
}
pub fn parse_line_range(spec: &str) -> Result<LineRange, ShowError> {
let spec = spec.trim();
let (first_text, last_text) = match spec.split_once('-') {
Some((a, b)) => (a.trim(), b.trim()),
None => (spec, spec),
};
let parse_one = |text: &str| -> Result<usize, ShowError> {
match text.parse::<usize>() {
Ok(0) => Err(ShowError::Usage(format!(
"--lines counts from 1, not 0 (got `{spec}`). \
`--lines 1-1` is the first line."
))),
Ok(n) => Ok(n),
Err(_) => Err(ShowError::Usage(format!(
"--lines wants `A-B` or `A`, got `{spec}`"
))),
}
};
let first = parse_one(first_text)?;
let last = parse_one(last_text)?;
if first > last {
return Err(ShowError::Usage(format!(
"--lines {first}-{last} runs backwards"
)));
}
Ok(LineRange { first, last })
}
pub fn document_lines(index: &Index, path: &str) -> Option<Vec<String>> {
let mut chunks: Vec<_> = index.chunks.iter().filter(|c| c.path == path).collect();
if chunks.is_empty() {
return None;
}
chunks.sort_by_key(|c| (c.line_start, c.line_end));
let last_line = chunks.iter().map(|c| c.line_end).max()?;
let mut lines: Vec<String> = vec![String::new(); last_line + 1];
for chunk in chunks {
for (offset, text) in chunk.text().split('\n').enumerate() {
let at = chunk.line_start + offset;
if at >= lines.len() {
lines.resize(at + 1, String::new());
}
lines[at] = text.to_string();
}
}
Some(lines)
}
pub fn slice(lines: &[String], range: LineRange) -> Result<&[String], ShowError> {
if range.first > lines.len() {
return Err(ShowError::Usage(format!(
"--lines {}-{} starts past the end: this document has {} lines",
range.first,
range.last,
lines.len()
)));
}
let start = range.first - 1; let end = range.last.min(lines.len());
Ok(&lines[start..end])
}
pub fn documents(index: &Index) -> Vec<(&str, &str)> {
let mut seen: BTreeMap<&str, &str> = BTreeMap::new();
for chunk in &index.chunks {
seen.entry(chunk.path.as_str()).or_insert(&chunk.kind);
}
let mut out: Vec<(&str, &str)> = seen.into_iter().map(|(p, k)| (k, p)).collect();
out.sort_by_key(|(kind, path)| (*kind != "guide", *path));
out
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Resolved {
Exact(String),
Rewritten(String),
Unknown(String),
}
fn normalize(raw: &str) -> String {
let mut s = raw.trim().replace('\\', "/");
while let Some(rest) = s.strip_prefix("./") {
s = rest.to_string();
}
s.trim_matches('/').to_string()
}
pub fn resolve_path(index: &Index, raw: &str) -> Resolved {
let wanted = normalize(raw);
let paths: Vec<&str> = documents(index).into_iter().map(|(_, p)| p).collect();
if paths.contains(&wanted.as_str()) {
return Resolved::Exact(wanted);
}
let lower = wanted.to_lowercase();
let unambiguous: Vec<&str> = paths
.iter()
.copied()
.filter(|candidate| {
let c = candidate.to_lowercase();
c == lower || basename(&c) == lower || c.ends_with(&format!("/{lower}"))
})
.collect();
match unambiguous.as_slice() {
[one] => return Resolved::Rewritten((*one).to_string()),
[] => {}
many => {
let mut out = format!(
"`{raw}` matches {} documents in the teksilo {} corpus. Name one:\n\n",
many.len(),
index.teksilo_version,
);
for p in many.iter().take(MAX_SUGGESTIONS) {
out.push_str(&format!(" {p}\n"));
}
if many.len() > MAX_SUGGESTIONS {
out.push_str(&format!(
" … and {} more (`cargo teksilo show --list`)\n",
many.len() - MAX_SUGGESTIONS
));
}
return Resolved::Unknown(out);
}
}
Resolved::Unknown(not_found_text(index, raw, &wanted, &paths))
}
fn not_found_text(index: &Index, raw: &str, wanted: &str, paths: &[&str]) -> String {
let mut out = format!(
"no document at `{raw}` in the teksilo {} corpus.\n",
index.teksilo_version
);
let prefix = format!("{wanted}/");
let under: Vec<&str> = paths
.iter()
.copied()
.filter(|p| p.starts_with(&prefix))
.collect();
if !under.is_empty() {
out.push_str(&format!(
"\n`{wanted}` is a directory. It holds {} documents:\n",
under.len()
));
for p in under.iter().take(MAX_SUGGESTIONS) {
out.push_str(&format!(" {p}\n"));
}
if under.len() > MAX_SUGGESTIONS {
out.push_str(&format!(
" … and {} more (`cargo teksilo show --list`)\n",
under.len() - MAX_SUGGESTIONS
));
}
return out;
}
let near = suggestions(paths, wanted);
if near.is_empty() {
out.push_str("\nNo path looks close to it.\n");
} else {
out.push_str("\nDid you mean:\n");
for p in &near {
out.push_str(&format!(" {p}\n"));
}
}
out.push_str(
"\n`cargo teksilo search \"<question>\"` finds paths by content; \
`cargo teksilo show --list` prints every one.\n",
);
out
}
pub fn suggestions(paths: &[&str], wanted: &str) -> Vec<String> {
let wanted = wanted.to_lowercase();
let wanted_base = basename(&wanted);
let budget = |len: usize| (len / 3).clamp(2, 6);
let mut scored: Vec<(usize, &str)> = paths
.iter()
.filter_map(|candidate| {
let lower = candidate.to_lowercase();
let base = basename(&lower);
let whole = levenshtein(&wanted, &lower);
let by_base = levenshtein(wanted_base, base);
let best = whole.min(by_base);
let tolerance = budget(wanted_base.len().max(2));
let contains = base.contains(wanted_base) || wanted_base.contains(base);
(best <= tolerance || contains)
.then_some((if contains { best.min(1) } else { best }, *candidate))
})
.collect();
scored.sort_by_key(|(score, path)| (*score, *path));
scored
.into_iter()
.take(MAX_SUGGESTIONS)
.map(|(_, p)| p.to_string())
.collect()
}
fn basename(path: &str) -> &str {
path.rsplit('/').next().unwrap_or(path)
}
fn levenshtein(a: &str, b: &str) -> usize {
let a: Vec<char> = a.chars().collect();
let b: Vec<char> = b.chars().collect();
if a.is_empty() {
return b.len();
}
let mut prev: Vec<usize> = (0..=b.len()).collect();
let mut cur = vec![0usize; b.len() + 1];
for (i, ca) in a.iter().enumerate() {
cur[0] = i + 1;
for (j, cb) in b.iter().enumerate() {
let cost = usize::from(ca != cb);
cur[j + 1] = (prev[j] + cost).min(prev[j + 1] + 1).min(cur[j] + 1);
}
std::mem::swap(&mut prev, &mut cur);
}
prev[b.len()]
}
pub fn run(dir: &Path, request: &ShowRequest) -> Result<i32, ShowError> {
let resolution = resolve::resolve(dir)?;
let verdict = guard::check(&resolution.version);
if !verdict.may_answer() {
let Verdict::Refuse { app, tool } = &verdict else {
unreachable!()
};
return Err(ShowError::Refused(guard::refusal_text(
app,
tool,
"corpus documents",
guard::readable_sources(&resolution).as_deref(),
)));
}
if let Some(note) = verdict.note() {
crate::output::note(note);
}
let index = teksilo_corpus::index()?;
if request.list {
return print_list(index);
}
let Some(raw) = request.path.as_deref() else {
return Err(ShowError::Usage(
"cargo teksilo show <PATH> the whole document\n\
cargo teksilo show <PATH> --lines 166-172\n\
cargo teksilo show --list every path in the corpus"
.to_string(),
));
};
let path = match resolve_path(index, raw) {
Resolved::Exact(p) => p,
Resolved::Rewritten(p) => {
if crate::output::verbose() {
eprintln!("Resolved: {raw} → {p}");
}
p
}
Resolved::Unknown(help) => return Err(ShowError::NotFound(help)),
};
let lines = document_lines(index, &path).expect("resolve_path only returns corpus paths");
let total = lines.len();
let shown = match request.lines.as_deref() {
None => {
if crate::output::verbose() {
eprintln!(
"teksilo {} corpus · {path} ({total} lines)",
index.teksilo_version
);
}
&lines[..]
}
Some(spec) => {
let range = parse_line_range(spec)?;
let shown = slice(&lines, range)?;
if crate::output::verbose() {
eprintln!(
"teksilo {} corpus · {path} lines {}-{} of {total}",
index.teksilo_version,
range.first,
range.first + shown.len().saturating_sub(1),
);
}
shown
}
};
for line in shown {
println!("{line}");
}
Ok(0)
}
fn print_list(index: &Index) -> Result<i32, ShowError> {
let docs = documents(index);
let guides = docs.iter().filter(|(kind, _)| *kind == "guide").count();
if crate::output::verbose() {
eprintln!(
"teksilo {} corpus · {} documents ({guides} guides, then {} example sources)",
index.teksilo_version,
docs.len(),
docs.len() - guides,
);
}
for (_, path) in docs {
println!("{path}");
}
Ok(0)
}
#[cfg(test)]
mod tests {
use super::*;
use std::collections::BTreeMap;
use teksilo_corpus::Chunk;
fn chunk(id: usize, kind: &str, path: &str, start: usize, text: &str) -> Chunk {
let line_count = text.split('\n').count();
Chunk {
id,
kind: kind.to_string(),
path: path.to_string(),
heading_path: vec![],
text: text.to_string(),
line_start: start,
line_end: start + line_count - 1,
crate_name: None,
tokens: BTreeMap::new(),
len: 0,
embedding: None,
}
}
fn toy(chunks: Vec<Chunk>) -> Index {
Index {
schema: 2,
teksilo_version: "0.0.0".to_string(),
encoder: None,
encoder_dim: None,
chunk_count: chunks.len(),
avgdl: 1.0,
terms: vec![],
df: BTreeMap::new(),
chunks,
}
}
fn repo_root() -> Option<std::path::PathBuf> {
let root = std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.parent()?
.parent()?
.to_path_buf();
(root.join("docs").is_dir() && root.join("examples").is_dir()).then_some(root)
}
#[test]
fn a_line_range_is_one_based_and_inclusive() {
assert_eq!(
parse_line_range("166-172").unwrap(),
LineRange {
first: 166,
last: 172
}
);
assert_eq!(
parse_line_range("7").unwrap(),
LineRange { first: 7, last: 7 }
);
assert_eq!(
parse_line_range(" 1 - 1 ").unwrap(),
LineRange { first: 1, last: 1 }
);
}
#[test]
fn line_zero_is_rejected_rather_than_read_as_the_first_line() {
let err = parse_line_range("0-4").unwrap_err();
assert!(matches!(err, ShowError::Usage(_)));
assert!(err.to_string().contains("counts from 1"));
}
#[test]
fn a_backwards_or_unparseable_range_is_rejected() {
for bad in ["9-2", "a-b", "", "3-", "-3"] {
assert!(
matches!(parse_line_range(bad), Err(ShowError::Usage(_))),
"{bad:?} should not parse"
);
}
}
#[test]
fn lines_one_to_one_is_the_first_line() {
let doc: Vec<String> = ["first", "second", "third"]
.iter()
.map(|s| s.to_string())
.collect();
assert_eq!(
slice(&doc, parse_line_range("1-1").unwrap()).unwrap(),
["first".to_string()]
);
assert_eq!(
slice(&doc, parse_line_range("2-3").unwrap()).unwrap(),
["second".to_string(), "third".to_string()]
);
assert_eq!(
slice(&doc, parse_line_range("3").unwrap()).unwrap(),
["third".to_string()]
);
}
#[test]
fn a_range_past_the_end_clamps_but_a_range_starting_past_it_is_an_error() {
let doc: Vec<String> = (0..3).map(|i| i.to_string()).collect();
assert_eq!(
slice(
&doc,
LineRange {
first: 2,
last: 900
}
)
.unwrap()
.len(),
2
);
let err = slice(&doc, LineRange { first: 9, last: 9 }).unwrap_err();
assert!(err.to_string().contains("3 lines"), "{err}");
}
#[test]
fn a_gap_between_chunks_comes_back_as_a_blank_line() {
let index = toy(vec![
chunk(0, "guide", "docs/a.md", 0, "# Title\nintro"),
chunk(1, "guide", "docs/a.md", 3, "## Next\nbody"),
]);
assert_eq!(
document_lines(&index, "docs/a.md").unwrap(),
["# Title", "intro", "", "## Next", "body"]
);
}
#[test]
fn an_unknown_path_reassembles_to_nothing() {
let index = toy(vec![chunk(0, "guide", "docs/a.md", 0, "x")]);
assert!(document_lines(&index, "docs/b.md").is_none());
}
#[test]
fn chunks_of_other_documents_do_not_bleed_in() {
let index = toy(vec![
chunk(0, "guide", "docs/a.md", 0, "a0"),
chunk(1, "guide", "docs/b.md", 1, "b1"),
]);
assert_eq!(document_lines(&index, "docs/a.md").unwrap(), ["a0"]);
assert_eq!(document_lines(&index, "docs/b.md").unwrap(), ["", "b1"]);
}
#[test]
fn every_corpus_path_reassembles() {
let index = teksilo_corpus::index().unwrap();
let docs = documents(index);
assert!(
docs.len() > 100,
"corpus shrank unexpectedly: {}",
docs.len()
);
for (_, path) in &docs {
let lines = document_lines(index, path)
.unwrap_or_else(|| panic!("{path} is listed but does not reassemble"));
assert!(!lines.is_empty(), "{path} reassembled empty");
}
}
#[test]
fn no_two_chunks_disagree_about_a_line() {
let index = teksilo_corpus::index().unwrap();
let mut seen: BTreeMap<(&str, usize), &str> = BTreeMap::new();
for c in &index.chunks {
for (offset, text) in c.text().split('\n').enumerate() {
let key = (c.path.as_str(), c.line_start + offset);
if let Some(prev) = seen.insert(key, text) {
assert_eq!(prev, text, "chunks disagree about {}:{}", key.0, key.1 + 1);
}
}
}
}
#[test]
fn a_chunks_text_matches_the_line_range_it_claims() {
let index = teksilo_corpus::index().unwrap();
for c in &index.chunks {
assert_eq!(
c.text().split('\n').count(),
c.line_end - c.line_start + 1,
"chunk {} of {} claims lines {}-{}",
c.id,
c.path,
c.line_start,
c.line_end
);
}
}
#[test]
fn every_document_reconstructs_byte_exactly() {
let Some(root) = repo_root() else {
return;
};
let index = teksilo_corpus::index().unwrap();
for (_, path) in documents(index) {
let on_disk =
std::fs::read_to_string(root.join(path)).unwrap_or_else(|e| panic!("{path}: {e}"));
let original = on_disk.replace("\r\n", "\n");
let rebuilt = document_lines(index, path).unwrap().join("\n") + "\n";
assert_eq!(rebuilt, original, "{path} did not reconstruct byte-exactly");
}
}
#[test]
fn search_line_numbers_address_the_same_lines_show_prints() {
let index = teksilo_corpus::index().unwrap();
let chunk = index
.chunks
.iter()
.find(|c| c.line_start > 5 && c.line_end > c.line_start)
.expect("some chunk starts past line 5");
let lines = document_lines(index, &chunk.path).unwrap();
let printed = LineRange {
first: chunk.line_start + 1,
last: chunk.line_end + 1,
};
let shown = slice(&lines, printed).unwrap().join("\n");
assert_eq!(shown, *chunk.text());
}
#[test]
fn an_exact_path_resolves_to_itself() {
let index = teksilo_corpus::index().unwrap();
assert_eq!(
resolve_path(index, "docs/scroll-area.md"),
Resolved::Exact("docs/scroll-area.md".to_string())
);
}
#[test]
fn the_spellings_that_are_the_same_path_are_accepted() {
let index = teksilo_corpus::index().unwrap();
for spelling in [
"./docs/scroll-area.md",
"docs\\scroll-area.md",
"/docs/scroll-area.md",
" docs/scroll-area.md ",
] {
assert_eq!(
resolve_path(index, spelling),
Resolved::Exact("docs/scroll-area.md".to_string()),
"{spelling}"
);
}
}
#[test]
fn an_unambiguous_abbreviation_is_rewritten_not_refused() {
let index = teksilo_corpus::index().unwrap();
assert_eq!(
resolve_path(index, "scroll-area.md"),
Resolved::Rewritten("docs/scroll-area.md".to_string())
);
assert_eq!(
resolve_path(index, "DOCS/Scroll-Area.MD"),
Resolved::Rewritten("docs/scroll-area.md".to_string())
);
assert_eq!(
resolve_path(index, "simple_button/src/main.rs"),
Resolved::Rewritten("examples/simple_button/src/main.rs".to_string())
);
}
#[test]
fn an_ambiguous_basename_lists_the_candidates_instead_of_picking_one() {
let index = teksilo_corpus::index().unwrap();
let Resolved::Unknown(help) = resolve_path(index, "main.rs") else {
panic!("`main.rs` must not resolve to one document");
};
assert!(help.contains("matches"), "{help}");
assert!(help.contains("examples/"), "{help}");
}
#[test]
fn a_typo_suggests_the_real_path_rather_than_saying_not_found() {
let index = teksilo_corpus::index().unwrap();
let Resolved::Unknown(help) = resolve_path(index, "docs/scroll_area.md") else {
panic!("a typo must not silently resolve");
};
assert!(
help.contains("docs/scroll-area.md"),
"no suggestion in:\n{help}"
);
assert!(help.contains("cargo teksilo search"), "{help}");
}
#[test]
fn a_directory_lists_what_is_under_it() {
let index = teksilo_corpus::index().unwrap();
let Resolved::Unknown(help) = resolve_path(index, "examples/simple_button") else {
panic!("a directory is not a document");
};
assert!(help.contains("is a directory"), "{help}");
assert!(
help.contains("examples/simple_button/src/main.rs"),
"{help}"
);
}
#[test]
fn a_path_that_resembles_nothing_still_points_somewhere() {
let index = teksilo_corpus::index().unwrap();
let Resolved::Unknown(help) = resolve_path(index, "docs/qzxwvblargh.md") else {
panic!("nonsense must not resolve");
};
assert!(help.contains("cargo teksilo show --list"), "{help}");
}
#[test]
fn suggestions_are_bounded_and_best_first() {
let paths = [
"docs/scroll-area.md",
"docs/scene-ink.md",
"docs/settings.md",
"docs/shortcut-intent-action.md",
"docs/styling-system.md",
"docs/table-view.md",
"docs/telemetry.md",
];
let got = suggestions(&paths, "docs/scroll-are.md");
assert_eq!(got.first().map(String::as_str), Some("docs/scroll-area.md"));
assert!(got.len() <= MAX_SUGGESTIONS);
}
#[test]
fn levenshtein_is_a_metric_on_the_cases_that_matter() {
assert_eq!(levenshtein("", ""), 0);
assert_eq!(levenshtein("", "abc"), 3);
assert_eq!(levenshtein("abc", ""), 3);
assert_eq!(levenshtein("kitten", "sitting"), 3);
assert_eq!(levenshtein("scroll-area", "scroll_area"), 1);
}
#[test]
fn the_listing_puts_guides_first_and_names_every_document_once() {
let index = teksilo_corpus::index().unwrap();
let docs = documents(index);
let mut paths: Vec<&str> = docs.iter().map(|(_, p)| *p).collect();
let before = paths.len();
paths.sort_unstable();
paths.dedup();
assert_eq!(before, paths.len(), "a path was listed twice");
let first_example = docs.iter().position(|(kind, _)| *kind == "example");
let last_guide = docs.iter().rposition(|(kind, _)| *kind == "guide");
assert!(
matches!((first_example, last_guide), (Some(e), Some(g)) if g < e),
"guides and examples are interleaved"
);
}
}