use std::path::{Path, PathBuf};
use crate::{guard, resolve, setup, symbol, vectors};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum State {
Here,
NotHere,
NotApplicable,
}
impl State {
pub fn word(self) -> &'static str {
match self {
State::Here => "here",
State::NotHere => "not here",
State::NotApplicable => "n/a",
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct Row {
pub agent: &'static str,
pub state: State,
pub detail: String,
}
fn row_for(target: &setup::Target, detected: bool, looked_at: &str, root: &Path) -> Row {
if !detected {
return Row {
agent: target.agent,
state: State::NotHere,
detail: format!("no {looked_at}"),
};
}
let path = show_path(&target.path, root, target.form == setup::Form::Skill);
match setup::inspect(target) {
setup::Presence::Current => Row {
agent: target.agent,
state: State::Here,
detail: path,
},
setup::Presence::Stale => Row {
agent: target.agent,
state: State::Here,
detail: format!("{path} — from another release, re-run setup"),
},
setup::Presence::Absent => Row {
agent: target.agent,
state: State::NotHere,
detail: format!("{looked_at} is here, brief is not"),
},
setup::Presence::Blocked(why) => Row {
agent: target.agent,
state: State::NotHere,
detail: format!("{path} {why} — setup would refuse"),
},
}
}
pub fn project_rows(root: &Path) -> Vec<Row> {
setup::project_candidates(root)
.into_iter()
.map(|c| row_for(&c.target, c.detected(), c.target.marker, root))
.collect()
}
pub fn user_rows(home: &Path) -> Vec<Row> {
user_rows_in(home, &setup::vibe_home(home), &setup::opencode_config(home))
}
pub fn user_rows_in(home: &Path, vibe: &Path, opencode: &Path) -> Vec<Row> {
let mut rows: Vec<Row> = setup::user_candidates(home, vibe, opencode)
.into_iter()
.map(|c| {
let dir = show_path(&c.dir, home, true);
row_for(&c.target, c.detected(), &dir, home)
})
.collect();
rows.extend(setup::USER_NOT_APPLICABLE.iter().map(|(agent, why)| Row {
agent,
state: State::NotApplicable,
detail: (*why).to_string(),
}));
rows
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum Encoder {
Unavailable,
Cached { dir: PathBuf, bytes: u64 },
Missing { dir: PathBuf },
}
impl Encoder {
pub fn state(&self) -> State {
match self {
Encoder::Unavailable => State::NotApplicable,
Encoder::Cached { .. } => State::Here,
Encoder::Missing { .. } => State::NotHere,
}
}
}
pub fn encoder_status() -> Encoder {
match vectors::encoder_cache_dir() {
None => Encoder::Unavailable,
Some(dir) if vectors::encoder_is_cached() => {
let bytes = dir_size(&dir);
Encoder::Cached { dir, bytes }
}
Some(dir) => Encoder::Missing { dir },
}
}
fn dir_size(dir: &Path) -> u64 {
let Ok(entries) = std::fs::read_dir(dir) else {
return 0;
};
entries
.flatten()
.map(|e| match e.file_type() {
Ok(t) if t.is_dir() => dir_size(&e.path()),
Ok(t) if t.is_file() => e.metadata().map(|m| m.len()).unwrap_or(0),
_ => 0,
})
.sum()
}
fn mib(bytes: u64) -> String {
format!("{:.1} MB", bytes as f64 / 1_048_576.0)
}
fn show(path: &Path, root: &Path) -> String {
escape(&match path.strip_prefix(root) {
Ok(rest) => rest.display().to_string(),
Err(_) => path.display().to_string(),
})
}
fn show_safe(path: &Path) -> String {
escape(&path.display().to_string())
}
fn escape(shown: &str) -> String {
shown
.chars()
.map(|c| match c {
'\n' => "\\n".to_string(),
'\r' => "\\r".to_string(),
'\t' => "\\t".to_string(),
c if c.is_control() => format!("\\u{{{:04x}}}", c as u32),
c => c.to_string(),
})
.collect()
}
fn show_path(path: &Path, root: &Path, directory: bool) -> String {
let shown = show(path, root);
if directory {
format!("{shown}/")
} else {
shown
}
}
const AGENT_W: usize = 19;
const STATE_W: usize = 10;
pub fn print_rows(rows: &[Row]) {
for row in rows {
println!(
" {:<AGENT_W$}{:<STATE_W$}{}",
row.agent,
row.state.word(),
row.detail
);
}
}
pub fn report(dir: &Path) {
println!("cargo-teksilo {}", crate::guard::TOOL_VERSION);
match setup::find_project_root(dir) {
Some(root) => {
match resolve::resolve_locked(&root) {
Ok(r) => {
println!("app resolved teksilo {}", r.version);
match guard::check(&r.version) {
guard::Verdict::Ok => {}
guard::Verdict::Degraded { app, tool } => {
println!("{}", guard::degraded_text(&app, &tool))
}
guard::Verdict::Refuse { app, tool } => println!(
"\n{}",
guard::refusal_text(
&app,
&tool,
"symbol and search",
guard::readable_sources(&r).as_deref(),
)
),
}
}
Err(_) => println!(
"app resolved teksilo — unknown: no up-to-date Cargo.lock here, and\n\
writing one is the thing this command will not do"
),
}
println!("\nProject {}", show_safe(&root));
print_rows(&project_rows(&root));
println!(
"\n Mistral Vibe, opencode, Grok Build and Cline all read a project's\n \
AGENTS.md, so the AGENTS.md row above serves them too — Cline has a\n \
row of its own because it also reads a directory this can write."
);
}
None => {
println!(
"\nProject none — no Cargo.toml in {} or above it",
dir.display()
);
}
}
match setup::home_dir() {
Ok(home) => {
println!("\nUser {}", show_safe(&home));
print_rows(&user_rows(&home));
}
Err(e) => println!("\nUser unavailable — {e}"),
}
let encoder = encoder_status();
println!(
"\nSearch encoder {} ({} dimensions)",
vectors::ENCODER_ID,
vectors::ENCODER_DIM
);
let (state, detail) = match &encoder {
Encoder::Cached { dir, bytes } => (
encoder.state(),
format!("{} ({} on disk)", show_safe(dir), mib(*bytes)),
),
Encoder::Missing { dir } => (
encoder.state(),
format!(
"{} — not downloaded (~{} MB)",
show_safe(dir),
vectors::ENCODER_DOWNLOAD_MB,
),
),
Encoder::Unavailable => (
encoder.state(),
"this build has no encoder (compiled with `--no-default-features`);\n \
search uses BM25 only, which is a supported mode and not a fault"
.to_string(),
),
};
println!(
" {:<AGENT_W$}{:<STATE_W$}{}",
"model",
state.word(),
detail
);
if matches!(encoder, Encoder::Missing { .. }) {
println!(" `cargo teksilo setup` fetches it, or the next `search` will.");
println!(" Until then `search` uses BM25 only.");
}
println!("\nSymbol extractor Python 3");
let (state, detail) = match symbol::find_python() {
Some(p) => (State::Here, show_safe(&p)),
None => (
State::NotHere,
"not on PATH — `symbol` needs it; every other subcommand works without it".to_string(),
),
};
println!(
" {:<AGENT_W$}{:<STATE_W$}{}",
"interpreter",
state.word(),
detail
);
println!("\n{}", setup::ACTIVATION_CAVEAT);
}
#[cfg(test)]
mod tests {
use super::*;
fn temp() -> tempfile::TempDir {
tempfile::tempdir().unwrap()
}
fn find<'a>(rows: &'a [Row], agent: &str) -> &'a Row {
rows.iter()
.find(|r| r.agent == agent)
.unwrap_or_else(|| panic!("no row for {agent}"))
}
#[test]
fn an_agent_nobody_uses_here_is_not_here() {
let t = temp();
let rows = project_rows(t.path());
assert!(rows.iter().all(|r| r.state == State::NotHere));
assert!(rows.iter().all(|r| r.detail.starts_with("no ")));
}
#[test]
fn an_agent_that_is_here_without_a_brief_is_distinguishable() {
let t = temp();
std::fs::create_dir_all(t.path().join(".cursor")).unwrap();
let rows = project_rows(t.path());
let row = find(&rows, "Cursor");
assert_eq!(row.state, State::NotHere);
assert!(
row.detail.contains(".cursor/") && row.detail.contains("brief is not"),
"must say the agent is here and the brief is not: {}",
row.detail
);
}
#[test]
fn a_fresh_install_reports_here_with_its_path() {
let t = temp();
std::fs::create_dir_all(t.path().join(".cursor")).unwrap();
let targets = setup::project_targets(t.path());
setup::apply(&targets[0]).unwrap();
let rows = project_rows(t.path());
let row = find(&rows, "Cursor");
assert_eq!(row.state, State::Here);
assert_eq!(row.detail, ".cursor/rules/teksilo.mdc");
}
#[test]
fn an_older_releases_brief_is_still_here_but_says_so() {
let t = temp();
std::fs::create_dir_all(t.path().join(".cursor/rules")).unwrap();
std::fs::write(
t.path().join(".cursor/rules/teksilo.mdc"),
"---\ndescription: from 0.9\n---\nolder brief\n",
)
.unwrap();
let rows = project_rows(t.path());
let row = find(&rows, "Cursor");
assert_eq!(
row.state,
State::Here,
"an outdated brief is being read right now — it is here"
);
assert!(row.detail.contains("another release"), "{}", row.detail);
}
#[test]
fn project_rows_never_look_at_home() {
let t = temp();
let home = temp();
std::fs::create_dir_all(home.path().join(".claude/skills/teksilo")).unwrap();
let rows = project_rows(t.path());
let row = find(&rows, "Claude Code");
assert_eq!(row.state, State::NotHere);
}
#[test]
fn user_scope_lists_the_writable_three_and_the_rest_as_na() {
let home = temp();
let vibe = home.path().join(".vibe");
let opencode = home.path().join(".config/opencode");
std::fs::create_dir_all(&vibe).unwrap();
let rows = user_rows_in(home.path(), &vibe, &opencode);
assert_eq!(find(&rows, "Mistral Vibe").state, State::NotHere);
assert_eq!(find(&rows, "opencode").state, State::NotHere);
assert_eq!(find(&rows, "Claude Code").state, State::NotHere);
for (agent, _) in setup::USER_NOT_APPLICABLE {
assert_eq!(
find(&rows, agent).state,
State::NotApplicable,
"{agent} has no user-level file this tool writes"
);
}
}
#[test]
fn an_na_row_always_says_why() {
let home = temp();
let rows = user_rows_in(
home.path(),
&home.path().join(".vibe"),
&home.path().join(".config/opencode"),
);
for row in rows.iter().filter(|r| r.state == State::NotApplicable) {
assert!(!row.detail.trim().is_empty(), "{} has no reason", row.agent);
}
assert!(find(&rows, "Windsurf").detail.contains("global_rules.md"));
}
#[test]
fn a_relocated_user_directory_is_shown_absolute() {
let home = temp();
let elsewhere = temp();
let vibe = elsewhere.path().join("vibe-state");
std::fs::create_dir_all(&vibe).unwrap();
let rows = user_rows_in(home.path(), &vibe, &home.path().join(".config/opencode"));
let detail = &find(&rows, "Mistral Vibe").detail;
assert!(
detail.contains("vibe-state"),
"a relocated directory must be shown in full, not as a bare tail: {detail}"
);
}
#[test]
fn the_encoder_state_matches_the_build() {
let e = encoder_status();
if cfg!(feature = "semantic") {
assert_ne!(
e.state(),
State::NotApplicable,
"a semantic build always has a cache directory to report on"
);
} else {
assert_eq!(e, Encoder::Unavailable);
assert_eq!(e.state(), State::NotApplicable);
}
}
#[test]
fn dir_size_sums_a_tree_and_survives_a_missing_one() {
let t = temp();
std::fs::write(t.path().join("a"), vec![0u8; 100]).unwrap();
std::fs::create_dir(t.path().join("sub")).unwrap();
std::fs::write(t.path().join("sub/b"), vec![0u8; 28]).unwrap();
assert_eq!(dir_size(t.path()), 128);
assert_eq!(dir_size(&t.path().join("nope")), 0);
}
#[test]
fn status_writes_nothing() {
let t = temp();
let listing = |d: &Path| {
let mut names: Vec<_> = std::fs::read_dir(d)
.unwrap()
.flatten()
.map(|e| e.file_name())
.collect();
names.sort();
names
};
let before = listing(t.path());
let _ = project_rows(t.path());
let _ = user_rows_in(
t.path(),
&t.path().join(".vibe"),
&t.path().join(".config/opencode"),
);
let _ = encoder_status();
assert_eq!(before, listing(t.path()));
assert!(!t.path().join(".vibe").exists());
assert!(!t.path().join(".claude").exists());
assert!(!t.path().join(".config").exists());
}
}