use std::collections::BTreeMap;
use std::ffi::OsString;
use std::path::Path;
use anyhow::{bail, Context, Result};
use chrono::{Local, TimeZone};
use rusqlite::Connection;
use crate::cron::{
any_match, any_match_tick, format_absolute, format_absolute_tick, format_relative,
format_relative_tick, next_any, next_any_tick, parse_at, parse_at_tick, CivilTime, CronExpr,
};
use crate::cron_daemon;
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 exec_kotlin: Option<String>,
pub exec_python: Option<String>,
pub exec_node: Option<String>,
pub exec_bash: Option<String>,
pub exec_sh: Option<String>,
pub exec_zsh: 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,
exec_kotlin,
exec_python,
exec_node,
exec_bash,
exec_sh,
exec_zsh,
) = match &run.exec {
Some(e) => (
e.file.clone(),
e.sha256.clone(),
e.kotlin.clone(),
e.python.clone(),
e.node.clone(),
e.bash.clone(),
e.sh.clone(),
e.zsh.clone(),
),
None => (None, None, None, None, None, 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,
exec_kotlin,
exec_python,
exec_node,
exec_bash,
exec_sh,
exec_zsh,
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.kind != crate::inputs::InputType::String {
bits.push(format!("type={}", def.kind.as_str()));
}
if !def.choices.is_empty() {
bits.push(format!("choices={}", def.choices.join("|")));
}
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 let Some(k) = &s.exec_kotlin {
let preview = if k.lines().nth(1).is_some() {
"(inline)".to_string()
} else {
k.clone()
};
println!("exec.kotlin: {preview}");
}
if let Some(u) = &s.exec_python {
let preview = if crate::ExecSpec::python_value_is_path(u) {
u.clone()
} else {
"(inline)".to_string()
};
println!("exec.python: {preview}");
}
if let Some(n) = &s.exec_node {
let preview = if crate::ExecSpec::node_value_is_path(n) {
n.clone()
} else {
"(inline)".to_string()
};
println!("exec.node: {preview}");
}
for (label, val, is_path) in [
(
"exec.bash",
s.exec_bash.as_deref(),
crate::ExecSpec::bash_value_is_path as fn(&str) -> bool,
),
(
"exec.sh",
s.exec_sh.as_deref(),
crate::ExecSpec::sh_value_is_path as fn(&str) -> bool,
),
(
"exec.zsh",
s.exec_zsh.as_deref(),
crate::ExecSpec::zsh_value_is_path as fn(&str) -> bool,
),
] {
if let Some(v) = val {
let preview = if is_path(v) {
v.to_string()
} else {
"(inline)".to_string()
};
println!("{label}: {preview}");
}
}
if !s.packages.is_empty() {
println!("packages:");
if let Some(uv) = &s.packages.uv {
if let Some(py) = &uv.python {
println!(" uv.python: {py}");
}
match &uv.deps {
crate::UvDeps::List(pkgs) => {
println!(" uv: [{}]", pkgs.join(", "));
}
crate::UvDeps::Project(p) => {
println!(" uv.project: {p}");
}
crate::UvDeps::Requirements(r) => {
println!(" uv.requirements: {r}");
}
}
}
if let Some(pnpm) = &s.packages.pnpm {
if let Some(node) = &pnpm.node {
println!(" pnpm.node: {node}");
}
match &pnpm.deps {
crate::PnpmDeps::List(pkgs) => {
println!(" pnpm: [{}]", pkgs.join(", "));
}
crate::PnpmDeps::Project(p) => {
println!(" pnpm.project: {p}");
}
}
}
if let Some(gradle) = &s.packages.gradle {
if let Some(java) = &gradle.java {
println!(" gradle.java: {java}");
}
match &gradle.deps {
crate::GradleDeps::List(pkgs) => {
println!(" gradle: [{}]", pkgs.join(", "));
}
crate::GradleDeps::Project(p) => {
println!(" gradle.project: {p}");
}
}
}
}
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:#}")),
}
}
if let Some(rel) = exec
.kotlin
.as_deref()
.map(str::trim)
.filter(|s| !s.is_empty())
{
if crate::ExecSpec::kotlin_value_is_path(rel) {
match crate::spec_load::resolve_under_use_root(use_root, rel) {
Ok(resolved) => {
if !resolved.is_file() {
issues.push(format!(
"{p}: exec.kotlin `{rel}` is not a file ({})",
resolved.display()
));
}
}
Err(e) => issues.push(format!("{p}: exec.kotlin `{rel}`: {e:#}")),
}
}
}
for (label, is_path, field) in [
(
"exec.python",
crate::ExecSpec::python_value_is_path as fn(&str) -> bool,
exec.python.as_deref(),
),
(
"exec.node",
crate::ExecSpec::node_value_is_path as fn(&str) -> bool,
exec.node.as_deref(),
),
(
"exec.bash",
crate::ExecSpec::bash_value_is_path as fn(&str) -> bool,
exec.bash.as_deref(),
),
(
"exec.sh",
crate::ExecSpec::sh_value_is_path as fn(&str) -> bool,
exec.sh.as_deref(),
),
(
"exec.zsh",
crate::ExecSpec::zsh_value_is_path as fn(&str) -> bool,
exec.zsh.as_deref(),
),
] {
if let Some(rel) = field.map(str::trim).filter(|s| !s.is_empty()) {
if is_path(rel) {
match crate::spec_load::resolve_under_use_root(use_root, rel) {
Ok(resolved) => {
if !resolved.is_file() {
issues.push(format!(
"{p}: {label} `{rel}` is not a file ({})",
resolved.display()
));
}
}
Err(e) => issues.push(format!("{p}: {label} `{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}")
};
for manager in [
crate::packages::Manager::Uv,
crate::packages::Manager::Pnpm,
crate::packages::Manager::Gradle,
] {
if let Some(Err(e)) = crate::packages::canonical_for(manager, &node.packages, use_root)
{
issues.push(format!("{p}: {e:#}"));
}
}
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 format_audit_ts(ts: &str) -> String {
let Ok(secs) = ts.parse::<i64>() else {
return ts.to_string();
};
match Local.timestamp_opt(secs, 0) {
chrono::LocalResult::Single(dt) => dt.format("%Y.%m.%d.%H.%M.%S").to_string(),
_ => ts.to_string(),
}
}
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!("{:<19} {:<16} {:<40} exit", "ts", "branch", "command");
for row in rows {
let (ts, branch, cmd, exit) = row?;
let ts = format_audit_ts(&ts);
println!("{ts:<19} {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 — daemon-backed scheduler for `cron:` script leaves
USAGE:
jan cron start [--dry-run]
jan cron stop [--dry-run]
jan cron status
jan cron refresh
jan cron disable <agent>
jan cron enable <agent>
jan cron disabled
jan cron --list [--at <WHEN>]
jan cron --dry-run --at <WHEN> [-v]
jan cron --at <WHEN> [-v]
Lifecycle (Gradle-style front end to the cron daemon):
jan cron start Start the daemon (systemd user service when available)
jan cron stop Stop the daemon and disable the systemd user service
jan cron status Show pid, uptime, schedules, events/wakeup metrics,
running/deferred job counts, and cache age
jan cron refresh Reload the schedule cache from the preferred YAML tree
(alias: reload)
Concurrency (daemon env):
JAN_CRON_MAX_CONCURRENT Max simultaneous agent processes (default 32)
JAN_CRON_MAX_DEFERRED Max queued jobs waiting for a slot (default 256)
JAN_CRON_ALLOW_OVERLAP If 1/true, allow the same leaf to run twice at once
(default: deny same-leaf overlap)
When at the concurrent cap, new cron/event jobs are deferred. When a leaf
is already running and overlap is denied, the new run is skipped
(counted as overlap_skips in `jan cron status`).
Disable / enable agents:
jan cron disable <agent> Stop cron ticks and Unifier event wakeups for
a script leaf. <agent> is the leaf name
(pong-agent) or chain (scripts agents pong-agent).
Persisted in $JAN_CONFIG_DIR/cron-disabled.json.
jan cron enable <agent> Re-enable a disabled agent
jan cron disabled List disabled agent leaf names
Schedule cache:
On start (and on refresh), the daemon walks the preferred tree once and
stores every script with a `cron:` field. The tick loop and `jan cron --list`
use that cache only — they do not re-read YAML until you refresh.
jan cron --list List cached entries + next fire times
jan cron --list --at <WHEN> Same, with next fire relative to WHEN
Unifier event wakeups:
The daemon also connects to Unifier's `.daemon/events.sock` (under
`$UNIFIER_HOME` or `~/.local/unifier`). Mailbox notices wake the script
leaf whose name equals the notice `to` field on the next 100 ms tick:
unifier message --from ping-agent pong-agent '{{\"hello\":1}}'
# → jan scripts … pong-agent run --message-id <uuid>
# with JAN_UNIFIER_MESSAGE_ID / FROM / TO / KIND in the env
Named event notices (`unifier event '{{\"name\":\"my-agent\",…}}'`) wake
the leaf named in `name` with `--event-id` / `JAN_UNIFIER_EVENT_*`.
Tick-phase notices (`kind: tick` from Unifier `tick.sock` lifecycles) are
counted in status (`tick_notices=`) but do not spawn agents.
Start Unifier's daemon first (`unifier daemon start`) so events.sock
exists; Jan reconnects automatically while it is down. Status reports
`events=connected|disconnected`, wakeup queue depth, overflow `drops=`,
and `reconnects=`.
Disabled agents are skipped for both cron schedules and event wakeups.
One-shot testing (no daemon; loads the preferred tree in-process):
jan cron --dry-run --at <WHEN> Print matches without running them
jan cron --at <WHEN> Run matches once for WHEN
WHEN formats: YYYY-MM-DD HH:MM[, :SS[, .d]] (local civil time)
YAML `cron:` field counts:
5 — minute hour dom month dow
6 — second minute hour dom month dow
7 — decisecond second minute hour dom month dow (100 ms ticks)
my-job:
cron: \"30 10 * * *\" # minute
# cron: \"* * * * * *\" # every second
# cron: \"*/5 * * * * * *\" # every 500 ms
commands:
run:
exec:
argv: [echo, hello]
Nicknames: @yearly @monthly @weekly @daily @hourly @every_second @every_100ms
OPTIONS:
--list List cached cron entries (requires a running daemon)
--dry-run, -n Print matches without running (in-process; no daemon)
--at <WHEN> Civil time for --list next-fire or one-shot matching
-v, --verbose Print schedule evaluation details
-h, --help Prints this help
Requires a preferred tree (`jan use`). After editing `cron:` in YAML, run
`jan cron refresh` so the daemon and `--list` see the changes.
"
);
}
const CRON_BEGIN: &str = "# BEGIN JAN CRON (managed by `jan cron install`; do not edit by hand)";
const CRON_END: &str = "# END JAN CRON";
fn read_user_crontab() -> Result<String> {
let out = std::process::Command::new("crontab")
.arg("-l")
.output()
.context("spawn `crontab -l` (is cronie/vixie-cron installed?)")?;
if out.status.success() {
return Ok(String::from_utf8_lossy(&out.stdout).into_owned());
}
let stderr = String::from_utf8_lossy(&out.stderr);
if stderr.to_ascii_lowercase().contains("no crontab")
|| out.stdout.is_empty() && !stderr.is_empty()
{
return Ok(String::new());
}
bail!("`crontab -l` failed ({}): {}", out.status, stderr.trim());
}
fn write_user_crontab(body: &str) -> Result<()> {
use std::io::Write;
let mut child = std::process::Command::new("crontab")
.arg("-")
.stdin(std::process::Stdio::piped())
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::piped())
.spawn()
.context("spawn `crontab -`")?;
{
let mut stdin = child.stdin.take().context("open crontab stdin")?;
stdin
.write_all(body.as_bytes())
.context("write crontab body")?;
if !body.ends_with('\n') {
stdin.write_all(b"\n").ok();
}
}
let out = child.wait_with_output().context("wait for crontab -")?;
if !out.status.success() {
bail!(
"`crontab -` failed ({}): {}",
out.status,
String::from_utf8_lossy(&out.stderr).trim()
);
}
Ok(())
}
fn strip_jan_cron_block(existing: &str) -> String {
let mut out = String::new();
let mut in_block = false;
for line in existing.lines() {
if line.trim() == CRON_BEGIN {
in_block = true;
continue;
}
if line.trim() == CRON_END {
in_block = false;
continue;
}
if !in_block {
out.push_str(line);
out.push('\n');
}
}
out
}
fn cron_start(dry_run: bool) -> Result<i32> {
if dry_run {
cron_daemon::install_systemd(true)?;
println!("# dry-run: would start jan cron daemon");
println!("# dry-run: would start jan runtime daemon");
cron_uninstall_legacy(true).ok();
return Ok(0);
}
cron_uninstall_legacy(false).ok();
crate::runtime_daemon::start_daemon_background().ok();
if cron_daemon::install_systemd(false).is_ok() {
println!("started jan cron daemon (systemd user service)");
return Ok(0);
}
cron_daemon::start_daemon_background(false)?;
println!("started jan cron daemon");
Ok(0)
}
fn cron_stop(dry_run: bool) -> Result<i32> {
if dry_run {
cron_daemon::uninstall_systemd(true)?;
println!("# dry-run: would stop jan cron daemon");
println!("# dry-run: would stop jan runtime daemon");
return Ok(0);
}
cron_daemon::stop_daemon().ok();
cron_daemon::uninstall_systemd(false).ok();
crate::runtime_daemon::stop_daemon().ok();
println!("stopped jan cron daemon");
Ok(0)
}
pub fn cron_uninstall_legacy(dry_run: bool) -> Result<i32> {
let existing = read_user_crontab()?;
if !existing.lines().any(|l| l.trim() == CRON_BEGIN) {
println!("(no managed JAN CRON block in user crontab)");
return Ok(0);
}
let next = strip_jan_cron_block(&existing);
if dry_run {
println!("# dry-run: would remove managed JAN CRON block from user crontab");
return Ok(0);
}
if next.trim().is_empty() {
write_user_crontab("")?;
} else {
write_user_crontab(&next)?;
}
println!("removed managed JAN CRON block from user crontab");
Ok(0)
}
fn parse_list_from(at: &Option<String>) -> Result<chrono::DateTime<Local>> {
match at {
None => Ok(Local::now()),
Some(s) => {
let naive = chrono::NaiveDateTime::parse_from_str(s.trim(), "%Y-%m-%d %H:%M")
.or_else(|_| chrono::NaiveDateTime::parse_from_str(s.trim(), "%Y-%m-%dT%H:%M"))
.with_context(|| format!("invalid --at `{s}` (expected YYYY-MM-DD HH:MM)"))?;
Local
.from_local_datetime(&naive)
.single()
.with_context(|| format!("ambiguous/invalid local time for --at `{s}`"))
}
}
}
fn list_scheduled_scripts(
scheduled: &[cron_daemon::CachedCronEntry],
at: &Option<String>,
) -> Result<i32> {
if scheduled.is_empty() {
println!("(no scripts with cron schedules)");
return Ok(0);
}
let from = parse_list_from(at)?;
for s in scheduled {
if s.disabled {
println!("{}: [disabled]", s.chain_str());
} else {
println!("{}:", s.chain_str());
}
for c in &s.cron {
println!(" schedule: {c}");
if s.disabled {
println!(" next: (disabled)");
continue;
}
let parsed = CronExpr::parse(c);
match parsed {
Ok(expr)
if matches!(
expr.granularity(),
crate::cron::CronGranularity::Second
| crate::cron::CronGranularity::Decisecond
) =>
{
if let Some(next) = expr.next_after_tick(from) {
println!(
" next: {} ({})",
format_absolute_tick(&next, from),
format_relative_tick(from, &next)
);
} else {
println!(" next: (none within 1 year)");
}
}
Ok(expr) => match expr.next_after(from) {
Some(next) => {
println!(
" next: {} ({})",
format_absolute(next),
format_relative(from, next)
);
}
None => println!(" next: (none within 1 year)"),
},
Err(e) => println!(" next: (invalid: {e:#})"),
}
}
if s.disabled {
continue;
}
if s.cron.len() > 1 {
if let Ok(Some(next)) = next_any_tick(&s.cron, from) {
println!(
" soonest: {} ({})",
format_absolute_tick(&next, from),
format_relative_tick(from, &next)
);
} else if let Ok(Some(next)) = next_any(&s.cron, from) {
println!(
" soonest: {} ({})",
format_absolute(next),
format_relative(from, next)
);
}
}
}
println!("({} script(s) with cron)", scheduled.len());
Ok(0)
}
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> {
if let Some(first) = args.first() {
let sub = first.to_string_lossy();
match sub.as_ref() {
"start" => {
let mut dry_run = false;
for a in &args[1..] {
let s = a.to_string_lossy();
match s.as_ref() {
"--help" | "-h" => {
print_cron_help();
return Ok(0);
}
"--dry-run" | "-n" => dry_run = true,
other => bail!("unknown cron start flag `{other}`"),
}
}
return cron_start(dry_run);
}
"stop" => {
let mut dry_run = false;
for a in &args[1..] {
let s = a.to_string_lossy();
match s.as_ref() {
"--help" | "-h" => {
print_cron_help();
return Ok(0);
}
"--dry-run" | "-n" => dry_run = true,
other => bail!("unknown cron stop flag `{other}`"),
}
}
return cron_stop(dry_run);
}
"status" => return cron_daemon::daemon_status(),
"refresh" | "reload" => {
cron_daemon::reload_daemon()?;
return Ok(0);
}
"disable" => {
if args_have_help(&args[1..]) {
print_cron_help();
return Ok(0);
}
let target = join_cron_agent_args(&args[1..])?;
cron_daemon::disable_agent(&target)?;
return Ok(0);
}
"enable" => {
if args_have_help(&args[1..]) {
print_cron_help();
return Ok(0);
}
let target = join_cron_agent_args(&args[1..])?;
cron_daemon::enable_agent(&target)?;
return Ok(0);
}
"disabled" => {
if let Some(a) = args.get(1) {
let s = a.to_string_lossy();
match s.as_ref() {
"--help" | "-h" => {
print_cron_help();
return Ok(0);
}
other => bail!("unknown cron disabled flag `{other}`"),
}
}
let names = cron_daemon::list_disabled_agents()?;
if names.is_empty() {
println!("(no disabled agents)");
} else {
for name in &names {
println!("{name}");
}
println!("({} disabled)", names.len());
}
return Ok(0);
}
"install" => {
bail!("`jan cron install` was removed; use `jan cron start`");
}
"uninstall" => {
bail!("`jan cron uninstall` was removed; use `jan cron stop`");
}
"daemon" => {
let mut verbose = false;
for a in &args[1..] {
let s = a.to_string_lossy();
match s.as_ref() {
"--help" | "-h" => {
print_cron_help();
return Ok(0);
}
"--foreground" | "-f" => {}
"-v" | "--verbose" => verbose = true,
other => bail!("unknown cron daemon flag `{other}`"),
}
}
return cron_daemon::run_foreground(verbose);
}
_ => {}
}
}
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}`"),
other => bail!("unexpected cron argument `{other}`"),
}
i += 1;
}
if list_only {
let entries = cron_daemon::fetch_cached_entries()?;
return list_scheduled_scripts(&entries, &at);
}
let scripts = collect_scripts(spec);
let scheduled: Vec<&ScriptEntry> = scripts.iter().filter(|s| !s.cron.is_empty()).collect();
if at.is_none() && !dry_run {
bail!(
"jan cron requires a subcommand or flag; try `jan cron start`, `jan cron --list`, or `jan cron --help`"
);
}
let use_tick = at.as_deref().is_some_and(at_has_subminute);
if use_tick {
return run_cron_at_tick(
spec,
&scheduled,
at.as_deref().unwrap(),
dry_run,
verbose,
ctx,
);
}
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);
}
run_matched_scripts(spec, matched, dry_run, verbose, ctx)
}
fn run_cron_at_tick(
spec: &RootSpec,
scheduled: &[&ScriptEntry],
at: &str,
dry_run: bool,
verbose: bool,
ctx: &RunContext<'_>,
) -> Result<i32> {
let when = parse_at_tick(at)?;
if verbose {
eprintln!(
"jan cron: matching ds={} second={} minute={} hour={} day={} month={} dow={}",
when.decisecond, when.second, when.minute, when.hour, when.day, when.month, when.dow
);
}
let mut matched: Vec<&ScriptEntry> = Vec::new();
for s in scheduled {
match any_match_tick(&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);
}
run_matched_scripts(spec, matched, dry_run, verbose, ctx)
}
fn run_matched_scripts(
spec: &RootSpec,
matched: Vec<&ScriptEntry>,
dry_run: bool,
verbose: bool,
ctx: &RunContext<'_>,
) -> Result<i32> {
let mut worst = 0i32;
for s in matched {
if cron_daemon::is_disabled_chain(&s.chain) {
if dry_run || verbose {
println!("disabled: {} ({})", s.chain_str(), s.cron.join("; "));
}
continue;
}
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 join_cron_agent_args(args: &[OsString]) -> Result<String> {
let mut parts = Vec::new();
for a in args {
let s = a.to_string_lossy();
if s.starts_with('-') {
bail!("unknown flag `{s}`");
}
parts.push(s.into_owned());
}
if parts.is_empty() {
bail!("agent name required (e.g. `jan cron disable ping-agent`)");
}
Ok(parts.join(" "))
}
fn args_have_help(args: &[OsString]) -> bool {
args.iter()
.any(|a| matches!(a.to_string_lossy().as_ref(), "--help" | "-h"))
}
fn at_has_subminute(s: &str) -> bool {
let time = s.rsplit_once(' ').map(|(_, t)| t).unwrap_or(s);
time.matches(':').count() >= 2 || time.contains('.')
}
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"]);
}
#[test]
fn format_audit_ts_converts_unix_seconds() {
let expected = Local
.timestamp_opt(1_700_000_000, 0)
.single()
.unwrap()
.format("%Y.%m.%d.%H.%M.%S")
.to_string();
assert_eq!(format_audit_ts("1700000000"), expected);
assert_eq!(format_audit_ts("not-a-number"), "not-a-number");
}
#[test]
fn strip_jan_cron_block_preserves_other_lines() {
let existing = "MAILTO=me\n# BEGIN JAN CRON (managed by `jan cron install`; do not edit by hand)\n* * * * * /tmp/jan --no-log cron\n# END JAN CRON\n0 0 * * * /usr/bin/backup\n";
let stripped = strip_jan_cron_block(existing);
assert_eq!(stripped, "MAILTO=me\n0 0 * * * /usr/bin/backup\n");
assert_eq!(strip_jan_cron_block("just a line\n"), "just a line\n");
}
}