use std::collections::BTreeMap;
use std::ffi::OsString;
use std::path::Path;
use anyhow::{bail, Context, Result};
use rusqlite::Connection;
use crate::deps::{check_requires, collect_chain_metadata};
use crate::{default_db_path, CommandNode, RootSpec};
#[derive(Debug, Clone)]
pub struct ScriptEntry {
pub name: String,
pub chain: Vec<String>,
pub about: String,
pub category: String,
pub dependencies: Vec<String>,
pub requires: Vec<String>,
pub env: BTreeMap<String, String>,
}
impl ScriptEntry {
pub fn chain_str(&self) -> String {
self.chain.join(" ")
}
pub fn run_chain_str(&self) -> String {
let mut c = self.chain.clone();
c.push("run".into());
c.join(" ")
}
}
pub fn collect_scripts(spec: &RootSpec) -> Vec<ScriptEntry> {
let mut out = Vec::new();
walk_scripts(&[], &spec.commands, &mut out);
out.sort_by(|a, b| a.name.to_ascii_lowercase().cmp(&b.name.to_ascii_lowercase()));
out
}
fn walk_scripts(
prefix: &[String],
map: &BTreeMap<String, CommandNode>,
out: &mut Vec<ScriptEntry>,
) {
for (name, node) in map {
let mut chain = prefix.to_vec();
chain.push(name.clone());
if let Some(run) = node.commands.get("run") {
if run.exec.is_some() {
let category = if chain.len() >= 2 {
chain[chain.len() - 2].clone()
} else {
String::new()
};
let mut deps = node.dependencies.clone();
deps.extend(run.dependencies.iter().cloned());
deps.sort();
deps.dedup();
let mut requires = node.requires.clone();
requires.extend(run.requires.iter().cloned());
requires.sort();
requires.dedup();
let mut env = node.env.clone();
for (k, v) in &run.env {
env.insert(k.clone(), v.clone());
}
let about = if !node.about.trim().is_empty() {
node.about.clone()
} else {
run.about.clone()
};
out.push(ScriptEntry {
name: name.clone(),
chain: chain.clone(),
about,
category,
dependencies: deps,
requires,
env,
});
continue;
}
}
walk_scripts(&chain, &node.commands, out);
}
}
pub fn dispatch_inspect(
token: &str,
args: &[OsString],
spec: &RootSpec,
db_path: Option<&Path>,
) -> Result<i32> {
match token {
"list" => run_list(spec, args),
"search" => run_search(spec, args),
"show" => run_show(spec, args),
"validate" => run_validate(spec, args),
"audit" => run_audit(args, db_path),
_ => bail!("unknown inspect builtin `{token}`"),
}
}
fn print_list_help() {
print!(
"\
list — list script leaves in the preferred tree
USAGE:
jan list [--category <NAME>] [--format table|names]
OPTIONS:
--category <NAME> Only scripts under this category segment
--format <FMT> `table` (default) or `names`
-h, --help Prints help
"
);
}
fn run_list(spec: &RootSpec, args: &[OsString]) -> Result<i32> {
let mut category: Option<String> = None;
let mut format = "table".to_string();
let mut i = 0usize;
while i < args.len() {
let s = args[i].to_string_lossy();
match s.as_ref() {
"--help" | "-h" => {
print_list_help();
return Ok(0);
}
"--category" => {
let next = args
.get(i + 1)
.ok_or_else(|| anyhow::anyhow!("missing value after `--category`"))?;
category = Some(next.to_string_lossy().into_owned());
i += 1;
}
"--format" => {
let next = args
.get(i + 1)
.ok_or_else(|| anyhow::anyhow!("missing value after `--format`"))?;
format = next.to_string_lossy().into_owned();
i += 1;
}
other if other.starts_with('-') => bail!("unknown list flag `{other}`"),
_ => bail!("unexpected list argument `{s}`"),
}
i += 1;
}
let mut scripts = collect_scripts(spec);
if let Some(cat) = &category {
scripts.retain(|s| s.category.eq_ignore_ascii_case(cat));
}
print_scripts(&scripts, &format)?;
Ok(0)
}
fn print_search_help() {
print!(
"\
search — find script leaves by name, about text, or category
USAGE:
jan search [--name <PAT>] [--about <PAT>] [--category <NAME>] [--keyword <PAT>]
At least one filter is required. Patterns are case-insensitive substrings.
OPTIONS:
--name <PAT> Match script name
--about <PAT> Match about / description
--category <NAME> Exact category segment (case-insensitive)
--keyword <PAT> Match name or about
-h, --help Prints help
"
);
}
fn run_search(spec: &RootSpec, args: &[OsString]) -> Result<i32> {
let mut name_pat: Option<String> = None;
let mut about_pat: Option<String> = None;
let mut category: Option<String> = None;
let mut keyword: Option<String> = None;
let mut i = 0usize;
while i < args.len() {
let s = args[i].to_string_lossy();
match s.as_ref() {
"--help" | "-h" => {
print_search_help();
return Ok(0);
}
"--name" => {
name_pat = Some(require_value(args, &mut i, "--name")?);
}
"--about" => {
about_pat = Some(require_value(args, &mut i, "--about")?);
}
"--category" => {
category = Some(require_value(args, &mut i, "--category")?);
}
"--keyword" => {
keyword = Some(require_value(args, &mut i, "--keyword")?);
}
other if other.starts_with('-') => bail!("unknown search flag `{other}`"),
_ => bail!("unexpected search argument `{s}`"),
}
i += 1;
}
if name_pat.is_none() && about_pat.is_none() && category.is_none() && keyword.is_none() {
print_search_help();
bail!("at least one search filter is required");
}
let scripts: Vec<_> = collect_scripts(spec)
.into_iter()
.filter(|s| {
if let Some(cat) = &category {
if !s.category.eq_ignore_ascii_case(cat) {
return false;
}
}
if let Some(p) = &name_pat {
if !contains_ci(&s.name, p) {
return false;
}
}
if let Some(p) = &about_pat {
if !contains_ci(&s.about, p) {
return false;
}
}
if let Some(p) = &keyword {
if !(contains_ci(&s.name, p) || contains_ci(&s.about, p)) {
return false;
}
}
true
})
.collect();
print_scripts(&scripts, "table")?;
Ok(0)
}
fn print_show_help() {
print!(
"\
show — print details for a script leaf
USAGE:
jan show <NAME>
OPTIONS:
-h, --help Prints help
"
);
}
fn run_show(spec: &RootSpec, args: &[OsString]) -> Result<i32> {
let mut name: Option<String> = None;
let mut i = 0usize;
while i < args.len() {
let s = args[i].to_string_lossy();
match s.as_ref() {
"--help" | "-h" => {
print_show_help();
return Ok(0);
}
other if other.starts_with('-') => bail!("unknown show flag `{other}`"),
_ => {
if name.is_some() {
bail!("unexpected argument `{s}`");
}
name = Some(s.into_owned());
}
}
i += 1;
}
let Some(name) = name else {
print_show_help();
bail!("missing script name");
};
let scripts = collect_scripts(spec);
let matches: Vec<_> = scripts
.iter()
.filter(|s| s.name == name || s.name.eq_ignore_ascii_case(&name))
.collect();
if matches.is_empty() {
let partial: Vec<_> = scripts
.iter()
.filter(|s| contains_ci(&s.name, &name))
.take(8)
.map(|s| s.name.as_str())
.collect();
if partial.is_empty() {
bail!("script `{name}` not found");
}
bail!(
"script `{name}` not found; similar: {}",
partial.join(", ")
);
}
if matches.len() > 1 {
let names: Vec<_> = matches.iter().map(|s| s.chain_str()).collect();
bail!(
"multiple scripts named `{name}`:\n {}",
names.join("\n ")
);
}
let s = matches[0];
println!("name: {}", s.name);
println!("chain: {}", s.chain_str());
println!("run: jan {}", s.run_chain_str());
println!("category: {}", s.category);
println!("about: {}", s.about.trim());
if !s.dependencies.is_empty() {
println!("dependencies: {}", s.dependencies.join(", "));
}
if !s.requires.is_empty() {
println!("requires: {}", s.requires.join(", "));
}
if !s.env.is_empty() {
println!("env:");
for (k, v) in &s.env {
println!(" {k}={v}");
}
}
Ok(0)
}
fn print_validate_help() {
print!(
"\
validate — load-time checks for the preferred tree
USAGE:
jan validate [--requires]
OPTIONS:
--requires Also report missing host binaries listed in `requires`
-h, --help Prints help
"
);
}
fn run_validate(spec: &RootSpec, args: &[OsString]) -> Result<i32> {
let mut check_req = false;
let mut i = 0usize;
while i < args.len() {
let s = args[i].to_string_lossy();
match s.as_ref() {
"--help" | "-h" => {
print_validate_help();
return Ok(0);
}
"--requires" => check_req = true,
other if other.starts_with('-') => bail!("unknown validate flag `{other}`"),
_ => bail!("unexpected validate argument `{s}`"),
}
i += 1;
}
let mut issues: Vec<String> = Vec::new();
if let Err(e) = validate_tree(&spec.commands, "") {
issues.push(e.to_string());
}
for script in collect_scripts(spec) {
if script.about.trim().is_empty() {
issues.push(format!("{}: missing about/description", script.chain_str()));
}
if check_req && !script.requires.is_empty() {
if let Err(e) = check_requires(&script.requires) {
issues.push(format!("{}: {e:#}", script.chain_str()));
}
}
let _ = collect_chain_metadata(&script.chain, spec);
}
if issues.is_empty() {
println!("ok: {} script leaf/leaves", collect_scripts(spec).len());
return Ok(0);
}
println!("issues ({}):", issues.len());
for issue in &issues {
println!(" - {issue}");
}
Ok(1)
}
fn validate_tree(map: &BTreeMap<String, CommandNode>, path: &str) -> Result<()> {
for (name, node) in map {
let p = if path.is_empty() {
name.clone()
} else {
format!("{path} {name}")
};
node.validate(&p)?;
validate_tree(&node.commands, &p)?;
}
Ok(())
}
fn print_audit_help() {
print!(
"\
audit — query the SQLite invocation log
USAGE:
jan audit recent [--limit N]
jan audit stats [<command-path-prefix>]
jan audit cleanup --days N
OPTIONS:
-h, --help Prints help
Uses `--db` / `JAN_DB` / the default XDG audit database.
"
);
}
fn run_audit(args: &[OsString], db_path: Option<&Path>) -> Result<i32> {
if args.is_empty() {
print_audit_help();
bail!("missing audit subcommand (recent | stats | cleanup)");
}
let sub = args[0].to_string_lossy();
if matches!(sub.as_ref(), "--help" | "-h") {
print_audit_help();
return Ok(0);
}
let db = match db_path {
Some(p) => p.to_path_buf(),
None => default_db_path(),
};
match sub.as_ref() {
"recent" => audit_recent(&db, &args[1..]),
"stats" => audit_stats(&db, &args[1..]),
"cleanup" => audit_cleanup(&db, &args[1..]),
other => bail!("unknown audit subcommand `{other}` (try `jan audit --help`)"),
}
}
fn open_audit_db(db: &Path) -> Result<Connection> {
if !db.is_file() {
bail!("audit database not found: {}", db.display());
}
Connection::open(db).with_context(|| format!("open {}", db.display()))
}
fn audit_recent(db: &Path, args: &[OsString]) -> Result<i32> {
let mut limit: i64 = 20;
let mut i = 0usize;
while i < args.len() {
let s = args[i].to_string_lossy();
match s.as_ref() {
"--help" | "-h" => {
print_audit_help();
return Ok(0);
}
"--limit" => {
let v = require_value(args, &mut i, "--limit")?;
limit = v
.parse()
.with_context(|| format!("invalid --limit `{v}`"))?;
}
other if other.starts_with('-') => bail!("unknown audit recent flag `{other}`"),
_ => bail!("unexpected argument `{s}`"),
}
i += 1;
}
let conn = open_audit_db(db)?;
let mut stmt = conn.prepare(
"SELECT ts, git_branch, command_path, exit_code FROM invocations ORDER BY id DESC LIMIT ?1",
)?;
let rows = stmt.query_map([limit], |r| {
Ok((
r.get::<_, String>(0)?,
r.get::<_, String>(1)?,
r.get::<_, String>(2)?,
r.get::<_, i32>(3)?,
))
})?;
let mut count = 0;
println!("{:<12} {:<16} {:<40} {}", "ts", "branch", "command", "exit");
for row in rows {
let (ts, branch, cmd, exit) = row?;
println!("{ts:<12} {branch:<16} {cmd:<40} {exit}");
count += 1;
}
if count == 0 {
println!("(no invocations recorded)");
}
Ok(0)
}
fn audit_stats(db: &Path, args: &[OsString]) -> Result<i32> {
let mut prefix: Option<String> = None;
let mut i = 0usize;
while i < args.len() {
let s = args[i].to_string_lossy();
match s.as_ref() {
"--help" | "-h" => {
print_audit_help();
return Ok(0);
}
other if other.starts_with('-') => bail!("unknown audit stats flag `{other}`"),
_ => {
if prefix.is_some() {
bail!("unexpected argument `{s}`");
}
prefix = Some(s.into_owned());
}
}
i += 1;
}
let conn = open_audit_db(db)?;
let (total, ok): (i64, i64) = if let Some(p) = &prefix {
let like = format!("{p}%");
conn.query_row(
"SELECT COUNT(*), SUM(CASE WHEN exit_code = 0 THEN 1 ELSE 0 END)
FROM invocations WHERE command_path LIKE ?1",
[&like],
|r| Ok((r.get(0)?, r.get::<_, Option<i64>>(1)?.unwrap_or(0))),
)?
} else {
conn.query_row(
"SELECT COUNT(*), SUM(CASE WHEN exit_code = 0 THEN 1 ELSE 0 END) FROM invocations",
[],
|r| Ok((r.get(0)?, r.get::<_, Option<i64>>(1)?.unwrap_or(0))),
)?
};
println!("database: {}", db.display());
if let Some(p) = &prefix {
println!("filter: command_path LIKE '{p}%'");
}
println!("total: {total}");
println!("success: {ok}");
if total > 0 {
println!("success_rate: {:.1}%", (ok as f64 / total as f64) * 100.0);
}
Ok(0)
}
fn audit_cleanup(db: &Path, args: &[OsString]) -> Result<i32> {
let mut days: Option<i64> = None;
let mut i = 0usize;
while i < args.len() {
let s = args[i].to_string_lossy();
match s.as_ref() {
"--help" | "-h" => {
print_audit_help();
return Ok(0);
}
"--days" => {
let v = require_value(args, &mut i, "--days")?;
days = Some(
v.parse()
.with_context(|| format!("invalid --days `{v}`"))?,
);
}
other if other.starts_with('-') => bail!("unknown audit cleanup flag `{other}`"),
_ => bail!("unexpected argument `{s}`"),
}
i += 1;
}
let Some(days) = days else {
bail!("audit cleanup requires `--days N`");
};
if days < 0 {
bail!("--days must be >= 0");
}
let conn = open_audit_db(db)?;
let cutoff = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs() as i64
- days * 24 * 60 * 60;
let deleted = conn.execute(
"DELETE FROM invocations WHERE CAST(ts AS INTEGER) < ?1",
[cutoff],
)?;
println!("deleted {deleted} invocation(s) older than {days} day(s)");
Ok(0)
}
fn require_value(args: &[OsString], i: &mut usize, flag: &str) -> Result<String> {
let next = args
.get(*i + 1)
.ok_or_else(|| anyhow::anyhow!("missing value after `{flag}`"))?;
*i += 1;
Ok(next.to_string_lossy().into_owned())
}
fn contains_ci(hay: &str, needle: &str) -> bool {
hay.to_ascii_lowercase()
.contains(&needle.to_ascii_lowercase())
}
fn print_scripts(scripts: &[ScriptEntry], format: &str) -> Result<()> {
match format {
"names" => {
for s in scripts {
println!("{}", s.name);
}
}
"table" => {
println!("{:<24} {:<12} {}", "name", "category", "about");
for s in scripts {
let about = first_line(&s.about);
let about = if about.len() > 60 {
format!("{}…", &about[..59])
} else {
about
};
println!("{:<24} {:<12} {}", s.name, s.category, about);
}
println!("({} script(s))", scripts.len());
}
other => bail!("unknown --format `{other}` (expected table|names)"),
}
Ok(())
}
fn first_line(s: &str) -> String {
s.lines()
.next()
.unwrap_or("")
.trim()
.to_string()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::load_spec_from_str;
fn sample_spec() -> RootSpec {
load_spec_from_str(
r#"
commands:
scripts:
commands:
misc:
commands:
sum:
about: Sum numbers
requires: [python3]
commands:
run:
exec:
argv: [echo, sum]
help:
exec:
argv: [echo, help]
"#,
None,
)
.unwrap()
}
#[test]
fn collects_run_leaves() {
let scripts = collect_scripts(&sample_spec());
assert_eq!(scripts.len(), 1);
assert_eq!(scripts[0].name, "sum");
assert_eq!(scripts[0].category, "misc");
assert_eq!(scripts[0].chain, vec!["scripts", "misc", "sum"]);
assert_eq!(scripts[0].requires, vec!["python3"]);
}
}