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, 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 system: Option<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, None, &mut out);
out.sort_by_key(|a| a.name.to_ascii_lowercase());
out
}
pub fn collect_systems(spec: &RootSpec) -> Vec<String> {
let mut names: Vec<String> = collect_scripts(spec)
.into_iter()
.filter_map(|s| s.system)
.collect();
names.sort();
names.dedup();
names
}
fn walk_scripts(
prefix: &[String],
map: &BTreeMap<String, CommandNode>,
inherited_system: Option<&str>,
out: &mut Vec<ScriptEntry>,
) {
for (name, node) in map {
let mut chain = prefix.to_vec();
chain.push(name.clone());
let system = match node.system.as_deref().map(str::trim) {
Some("") => None,
Some(s) => Some(s.to_string()),
None => inherited_system.map(str::to_string),
};
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());
let leaf_system = match run.system.as_deref().map(str::trim) {
Some("") => None,
Some(s) => Some(s.to_string()),
None => system.clone(),
};
out.push(ScriptEntry {
name: name.clone(),
chain: chain.clone(),
about,
category,
system: leaf_system,
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, system.as_deref(), 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);
if let Some(sys) = &s.system {
println!("system: {sys}");
}
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] [--no-systemd]
jan cron stop [--dry-run] [--no-systemd]
jan cron status
jan cron status --compact
jan cron status --json
jan cron watch [--interval <MS>]
jan cron wakeups
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 start --no-systemd
Start a background daemon only (never touch systemd).
Also implied when `$JAN_CONFIG_DIR` is set — so `jan lab`
never hijacks your daily user unit.
jan cron stop Stop the daemon and disable the systemd user service
jan cron stop --no-systemd
Stop the daemon only (leave the user unit alone).
Also implied when `$JAN_CONFIG_DIR` is set.
jan cron status Show pid/uptime one-liner plus running, deferred, and
recent spawn history (leaf, trigger, exit codes)
jan cron status --compact
One-liner only (script-friendly)
jan cron status --json
Machine-readable summary + detail
jan cron watch Poll status every second (Ctrl-C to stop)
jan cron wakeups List event-addressable script leaves (cron + mailbox/event)
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)
JAN_CRON_SPAN Outer unifier log span around each spawn:
1/true = always; 0/false = never; unset = when
events.sock is connected (best-effort)
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 (one line each; see below)
jan cron --list --at <WHEN> Same, with next fire relative to WHEN
jan cron wakeups List every script leaf Jan can wake (including
mailbox/event-only leaves with no cron:)
--list prints one schedule per line as `chain|cron|next|status` so it can be
filtered with grep/cut (`cut -d'|' -f1`). Summary counts go to stderr.
status is `enabled` or `disabled`.
wakeups prints `leaf|chain|cron|status` or `leaf|chain|event-only|status`.
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
--no-systemd On `start`: background spawn only (skip user unit)
-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.
To try a tree in isolation (second daemon + private preferred dir), see
`jan lab --help`.
"
);
}
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, no_systemd: bool) -> Result<i32> {
let skip_systemd = no_systemd || std::env::var_os("JAN_CONFIG_DIR").is_some();
if dry_run {
if skip_systemd {
println!("# dry-run: would start jan cron daemon (background; no systemd)");
} else {
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 !skip_systemd && cron_daemon::install_systemd(false).is_ok() {
println!("started jan cron daemon (systemd user service)");
return Ok(0);
}
cron_daemon::start_daemon_background(false)?;
if skip_systemd && no_systemd {
println!("started jan cron daemon (no systemd)");
} else if skip_systemd {
println!("started jan cron daemon (no systemd; JAN_CONFIG_DIR is set)");
} else {
println!("started jan cron daemon");
}
Ok(0)
}
fn cron_stop(dry_run: bool, no_systemd: bool) -> Result<i32> {
let skip_systemd = no_systemd || std::env::var_os("JAN_CONFIG_DIR").is_some();
if dry_run {
if !skip_systemd {
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();
if !skip_systemd {
cron_daemon::uninstall_systemd(false).ok();
}
crate::runtime_daemon::stop_daemon().ok();
if skip_systemd && no_systemd {
println!("stopped jan cron daemon (no systemd)");
} else if skip_systemd {
println!("stopped jan cron daemon (no systemd; JAN_CONFIG_DIR is set)");
} else {
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) {
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() {
eprintln!("(no scripts with cron schedules)");
return Ok(0);
}
let from = parse_list_from(at)?;
for s in scheduled {
let status = if s.disabled { "disabled" } else { "enabled" };
let chain = s.chain_str();
for c in &s.cron {
let next = if s.disabled {
"(disabled)".to_string()
} else {
match CronExpr::parse(c) {
Ok(expr)
if matches!(
expr.granularity(),
crate::cron::CronGranularity::Second
| crate::cron::CronGranularity::Decisecond
) =>
{
match expr.next_after_tick(from) {
Some(next) => format!(
"{} ({})",
format_absolute_tick(&next, from),
format_relative_tick(from, &next)
),
None => "(none within 1 year)".to_string(),
}
}
Ok(expr) => match expr.next_after(from) {
Some(next) => {
format!("{} ({})", format_absolute(next), format_relative(from, next))
}
None => "(none within 1 year)".to_string(),
},
Err(e) => format!("(invalid: {e:#})"),
}
};
println!("{chain}|{c}|{next}|{status}");
}
}
eprintln!("({} 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;
let mut no_systemd = 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,
"--no-systemd" => no_systemd = true,
other => bail!("unknown cron start flag `{other}`"),
}
}
return cron_start(dry_run, no_systemd);
}
"stop" => {
let mut dry_run = false;
let mut no_systemd = 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,
"--no-systemd" => no_systemd = true,
other => bail!("unknown cron stop flag `{other}`"),
}
}
return cron_stop(dry_run, no_systemd);
}
"status" => {
let mut opts = cron_daemon::StatusOpts::default();
for a in &args[1..] {
let s = a.to_string_lossy();
match s.as_ref() {
"--help" | "-h" => {
print_cron_help();
return Ok(0);
}
"--compact" => opts.compact = true,
"--json" => opts.json = true,
other => bail!("unknown cron status flag `{other}`"),
}
}
return cron_daemon::daemon_status_opts(opts);
}
"watch" => {
let mut interval_ms: u64 = 1000;
let mut i = 1usize;
while i < args.len() {
let s = args[i].to_string_lossy();
match s.as_ref() {
"--help" | "-h" => {
print_cron_help();
return Ok(0);
}
"--interval" => {
i += 1;
let raw = args
.get(i)
.map(|a| a.to_string_lossy().into_owned())
.context("--interval requires a millisecond value")?;
interval_ms = raw
.parse()
.with_context(|| format!("invalid --interval `{raw}`"))?;
}
other => bail!("unknown cron watch flag `{other}`"),
}
i += 1;
}
return cron_daemon::watch_status(interval_ms);
}
"wakeups" => {
if args_have_help(&args[1..]) {
print_cron_help();
return Ok(0);
}
return cron_daemon::list_wakeup_leaves();
}
"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"]);
assert!(scripts[0].system.is_none());
}
#[test]
fn system_field_inherits_to_leaves() {
let spec = load_spec_from_str(
r#"
commands:
gifts:
system: gifts
commands:
gift-sync:
about: sync
cron: "0 7 * * *"
commands:
run:
exec:
argv: [echo, sync]
gift-curator:
about: curator
commands:
run:
exec:
argv: [echo, curator]
"#,
None,
)
.unwrap();
let scripts = collect_scripts(&spec);
assert_eq!(scripts.len(), 2);
assert!(scripts.iter().all(|s| s.system.as_deref() == Some("gifts")));
let systems = collect_systems(&spec);
assert_eq!(systems, vec!["gifts".to_string()]);
}
#[test]
fn system_empty_clears_inheritance() {
let spec = load_spec_from_str(
r#"
commands:
gifts:
system: gifts
commands:
gift-sync:
commands:
run:
exec:
argv: [echo, sync]
status:
system: ""
commands:
run:
exec:
argv: [echo, status]
"#,
None,
)
.unwrap();
let scripts = collect_scripts(&spec);
let sync = scripts.iter().find(|s| s.name == "gift-sync").unwrap();
let status = scripts.iter().find(|s| s.name == "status").unwrap();
assert_eq!(sync.system.as_deref(), Some("gifts"));
assert!(status.system.is_none());
}
#[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");
}
}