use std::collections::BTreeMap;
use std::ffi::OsString;
use std::path::Path;
use anyhow::{bail, Context, Result};
use rusqlite::Connection;
use crate::cron::{any_match, parse_at, CivilTime, CronExpr};
use crate::deps::{check_requires, collect_chain_metadata};
use crate::inputs::InputDef;
use crate::{default_db_path, run_matched, CommandNode, EnvSpec, RootSpec, RunContext};
#[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: EnvSpec,
pub inputs: BTreeMap<String, InputDef>,
pub cron: Vec<String>,
pub source: Option<crate::IncludeLink>,
pub exec_file: Option<String>,
pub exec_sha256: Option<String>,
pub packages: crate::PackagesSpec,
}
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_key(|a| a.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();
env.merge_from(run.env.clone());
let mut inputs_map = node.inputs.clone();
for (k, v) in &run.inputs {
inputs_map.insert(k.clone(), v.clone());
}
let about = if !node.about.trim().is_empty() {
node.about.clone()
} else {
run.about.clone()
};
let mut cron = node.cron.clone();
cron.extend(run.cron.iter().cloned());
cron.sort();
cron.dedup();
let source = run.source.clone().or_else(|| node.source.clone());
let (exec_file, exec_sha256) = match &run.exec {
Some(e) => (e.file.clone(), e.sha256.clone()),
None => (None, None),
};
let mut packages = node.packages.clone();
packages.merge_from(run.packages.clone());
out.push(ScriptEntry {
name: name.clone(),
chain: chain.clone(),
about,
category,
dependencies: deps,
requires,
env,
inputs: inputs_map,
cron,
source,
exec_file,
exec_sha256,
packages,
});
continue;
}
}
walk_scripts(&chain, &node.commands, out);
}
}
pub fn dispatch_inspect(
token: &str,
args: &[OsString],
spec: &RootSpec,
db_path: Option<&Path>,
use_root: 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, use_root),
"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.public.is_empty() {
println!("env.public:");
for (k, v) in &s.env.public {
println!(" {k}={v}");
}
}
if !s.env.private.is_empty() {
println!("env.private:");
for name in &s.env.private {
println!(" {name}");
}
}
if !s.inputs.is_empty() {
println!("inputs:");
for (name, def) in &s.inputs {
let mut bits = Vec::new();
if def.required && def.default.is_none() {
bits.push("required".to_string());
}
if let Some(d) = &def.default {
bits.push(format!("default={d}"));
}
if !def.description.trim().is_empty() {
bits.push(def.description.trim().to_string());
}
if bits.is_empty() {
println!(" --{name}");
} else {
println!(" --{name} ({})", bits.join("; "));
}
}
}
if !s.cron.is_empty() {
println!("cron:");
for c in &s.cron {
println!(" {c}");
}
}
if let Some(src) = &s.source {
let kind = match src.kind {
crate::IncludeLinkKind::Yaml => "yaml",
crate::IncludeLinkKind::Script => "script",
};
print!("source: kind={kind}");
if let Some(p) = &src.path {
print!(" path={p}");
}
if let Some(u) = &src.url {
print!(" url={u}");
}
if let Some(h) = &src.sha256 {
print!(" sha256={h}");
}
println!();
}
if let Some(f) = &s.exec_file {
print!("exec.file: {f}");
if let Some(h) = &s.exec_sha256 {
print!(" sha256={h}");
}
println!();
}
if !s.packages.is_empty() {
println!("packages:");
if let Some(uv) = &s.packages.uv {
match uv {
crate::UvPackages::List(pkgs) => {
println!(" uv: [{}]", pkgs.join(", "));
}
crate::UvPackages::Project(p) => {
println!(" uv.project: {p}");
}
crate::UvPackages::Requirements(r) => {
println!(" uv.requirements: {r}");
}
}
}
if s.packages.pnpm.is_some() {
println!(" pnpm: (declared; not implemented)");
}
}
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 in `requires` and unset `env.private` vars
-h, --help Prints help
"
);
}
fn run_validate(spec: &RootSpec, args: &[OsString], use_root: Option<&Path>) -> 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());
}
if let Some(root) = use_root {
verify_linked_hashes(&spec.commands, "", root, &mut issues);
verify_package_paths(&spec.commands, "", root, &mut issues);
}
for script in collect_scripts(spec) {
if script.about.trim().is_empty() {
issues.push(format!("{}: missing about/description", script.chain_str()));
}
if let Err(e) = script.packages.validate(&script.chain_str()) {
issues.push(e.to_string());
}
if check_req && !script.requires.is_empty() {
if let Err(e) = check_requires(&script.requires) {
issues.push(format!("{}: {e:#}", script.chain_str()));
}
}
if check_req && !script.env.private.is_empty() {
if let Err(e) = crate::deps::check_private_env(&script.env.private) {
issues.push(format!("{}: {e:#}", script.chain_str()));
}
}
for expr in &script.cron {
if let Err(e) = CronExpr::parse(expr) {
issues.push(format!("{}: invalid cron `{expr}`: {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 verify_linked_hashes(
map: &BTreeMap<String, CommandNode>,
path: &str,
use_root: &Path,
issues: &mut Vec<String>,
) {
for (name, node) in map {
let p = if path.is_empty() {
name.clone()
} else {
format!("{path} {name}")
};
if let Some(src) = &node.source {
if let (Some(rel), Some(hash)) = (&src.path, &src.sha256) {
match crate::spec_load::resolve_under_use_root(use_root, rel) {
Ok(resolved) => {
if let Err(e) = crate::remote::verify_file_sha256(&resolved, hash) {
issues.push(format!("{p}: {e:#}"));
}
}
Err(e) => issues.push(format!("{p}: source path `{rel}`: {e:#}")),
}
}
}
if let Some(exec) = &node.exec {
if let (Some(rel), Some(hash)) = (
exec.file.as_deref().map(str::trim).filter(|s| !s.is_empty()),
exec.sha256.as_deref().map(str::trim).filter(|s| !s.is_empty()),
) {
match crate::spec_load::resolve_under_use_root(use_root, rel) {
Ok(resolved) => {
if let Err(e) = crate::remote::verify_file_sha256(&resolved, hash) {
issues.push(format!("{p}: {e:#}"));
}
}
Err(e) => issues.push(format!("{p}: exec.file `{rel}`: {e:#}")),
}
}
}
verify_linked_hashes(&node.commands, &p, use_root, issues);
}
}
fn verify_package_paths(
map: &BTreeMap<String, CommandNode>,
path: &str,
use_root: &Path,
issues: &mut Vec<String>,
) {
for (name, node) in map {
let p = if path.is_empty() {
name.clone()
} else {
format!("{path} {name}")
};
if let Some(uv) = &node.packages.uv {
match uv {
crate::UvPackages::Project(rel) => {
match crate::spec_load::resolve_under_use_root_any(use_root, rel) {
Ok(dir) if dir.is_dir() => {
if !dir.join("pyproject.toml").is_file() {
issues.push(format!(
"{p}: packages.uv.project missing pyproject.toml ({})",
dir.display()
));
}
}
Ok(other) => issues.push(format!(
"{p}: packages.uv.project is not a directory: {}",
other.display()
)),
Err(e) => issues.push(format!("{p}: packages.uv.project `{rel}`: {e:#}")),
}
}
crate::UvPackages::Requirements(rel) => {
if let Err(e) = crate::spec_load::resolve_under_use_root(use_root, rel) {
issues.push(format!("{p}: packages.uv.requirements `{rel}`: {e:#}"));
}
}
crate::UvPackages::List(_) => {}
}
}
verify_package_paths(&node.commands, &p, use_root, issues);
}
}
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} exit", "ts", "branch", "command");
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 print_cron_help() {
print!(
"\
cron — run script leaves whose `cron:` schedule matches the current time
USAGE:
jan cron [--dry-run] [--list] [--at <WHEN>] [-v]
Add a five-field crontab expression (or list) on a script node or its `run` child:
my-job:
cron: \"30 10 * * *\"
commands:
run:
exec:
argv: [echo, hello]
Nicknames: @yearly @monthly @weekly @daily @hourly
OPTIONS:
--dry-run, -n Print matching scripts without running them
--list List all scripts that declare a cron schedule
--at <WHEN> Match against local civil time YYYY-MM-DD HH:MM instead of now
-v, --verbose Print schedule evaluation details
-h, --help Prints help
Typical host crontab entry (every minute):
* * * * * jan cron
"
);
}
fn resolve_chain_node<'a>(spec: &'a RootSpec, chain: &[String]) -> Option<&'a CommandNode> {
let mut map = &spec.commands;
let mut cur = None;
for name in chain {
let n = map.get(name)?;
cur = Some(n);
map = &n.commands;
}
cur
}
pub fn run_cron(spec: &RootSpec, args: &[OsString], ctx: &RunContext<'_>) -> Result<i32> {
let mut dry_run = false;
let mut list_only = false;
let mut verbose = false;
let mut at: 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_cron_help();
return Ok(0);
}
"--dry-run" | "-n" => dry_run = true,
"--list" => list_only = true,
"-v" | "--verbose" => verbose = true,
"--at" => at = Some(require_value(args, &mut i, "--at")?),
other if other.starts_with('-') => bail!("unknown cron flag `{other}`"),
_ => bail!("unexpected cron argument `{s}`"),
}
i += 1;
}
let scripts = collect_scripts(spec);
let scheduled: Vec<&ScriptEntry> = scripts.iter().filter(|s| !s.cron.is_empty()).collect();
if list_only {
if scheduled.is_empty() {
println!("(no scripts with cron schedules)");
return Ok(0);
}
for s in &scheduled {
println!("{}:", s.chain_str());
for c in &s.cron {
println!(" {c}");
}
}
println!("({} script(s) with cron)", scheduled.len());
return Ok(0);
}
let when = match &at {
Some(s) => parse_at(s)?,
None => CivilTime::now_local(),
};
if verbose {
eprintln!(
"jan cron: matching minute={} hour={} day={} month={} dow={}",
when.minute, when.hour, when.day, when.month, when.dow
);
}
let mut matched: Vec<&ScriptEntry> = Vec::new();
for s in &scheduled {
match any_match(&s.cron, &when) {
Ok(true) => matched.push(s),
Ok(false) => {
if verbose {
eprintln!("jan cron: skip {} ({})", s.chain_str(), s.cron.join("; "));
}
}
Err(e) => bail!("{}: {e:#}", s.chain_str()),
}
}
if matched.is_empty() {
if verbose || dry_run {
println!("(no scripts match)");
}
return Ok(0);
}
let mut worst = 0i32;
for s in matched {
let mut run_chain = s.chain.clone();
run_chain.push("run".into());
if dry_run || verbose {
println!("match: {} ({})", s.chain_str(), s.cron.join("; "));
}
if dry_run {
continue;
}
let Some(node) = resolve_chain_node(spec, &run_chain) else {
bail!("missing run leaf for `{}`", s.chain_str());
};
if verbose {
eprintln!("jan cron: running `jan {}`", run_chain.join(" "));
}
let code = run_matched(spec, &run_chain, node, &[], ctx)?;
if code != 0 {
eprintln!(
"jan cron: `jan {}` exited {code}",
run_chain.join(" ")
);
if code > worst {
worst = code;
}
}
}
Ok(worst)
}
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} about", "name", "category");
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"]);
}
}