mod builtins;
mod deps;
mod runner;
mod spec_load;
mod yaml_closure;
pub use runner::run_jan;
pub use spec_load::HostPlatform;
use std::collections::BTreeMap;
use std::ffi::OsString;
use std::path::{Path, PathBuf};
use std::process::Command;
use anyhow::{bail, Context, Result};
use rusqlite::Connection;
use serde::Deserialize;
#[derive(Debug, Deserialize)]
pub struct RootSpec {
pub metadata: Option<Metadata>,
#[serde(default)]
pub commands: BTreeMap<String, CommandNode>,
}
#[derive(Debug, Deserialize)]
pub struct Metadata {
pub name: Option<String>,
pub description: Option<String>,
}
#[derive(Debug, Deserialize, Default, Clone)]
pub struct CommandNode {
#[serde(default)]
pub os: Vec<String>,
#[serde(default)]
pub about: String,
pub path: Option<String>,
#[serde(default)]
pub dependencies: Vec<String>,
#[serde(default)]
pub requires: Vec<String>,
#[serde(default)]
pub env: BTreeMap<String, String>,
#[serde(default)]
pub commands: BTreeMap<String, CommandNode>,
pub exec: Option<ExecSpec>,
}
#[derive(Debug, Deserialize, Clone)]
pub struct ExecSpec {
pub argv: Vec<String>,
#[serde(default)]
pub passthrough: bool,
}
impl CommandNode {
pub fn is_leaf_exec(&self) -> bool {
self.exec.is_some()
}
pub fn validate(&self, path: &str) -> Result<()> {
if self.exec.is_some() && !self.commands.is_empty() {
bail!("command '{path}' cannot define both `exec` and nested `commands`");
}
if let Some(ref e) = self.exec {
if e.argv.is_empty() {
bail!("command '{path}': exec.argv must not be empty");
}
}
for (name, child) in &self.commands {
let p = if path.is_empty() {
name.clone()
} else {
format!("{path} {name}")
};
child.validate(&p)?;
}
Ok(())
}
}
pub fn merge_specs_into(base: &mut RootSpec, overlay: RootSpec) -> Result<()> {
for (name, node) in overlay.commands {
match base.commands.get_mut(&name) {
Some(existing) => merge_command_node(existing, node)?,
None => {
base.commands.insert(name, node);
}
}
}
Ok(())
}
fn merge_command_node(dst: &mut CommandNode, src: CommandNode) -> Result<()> {
if src.exec.is_some() && !src.commands.is_empty() {
bail!("merge overlay: command cannot define both `exec` and nested `commands`");
}
if !src.os.is_empty() {
dst.os = src.os;
}
if !src.about.trim().is_empty() {
dst.about = src.about;
}
if src.path.is_some() {
dst.path = src.path;
}
if !src.dependencies.is_empty() {
dst.dependencies = src.dependencies;
}
if !src.requires.is_empty() {
dst.requires = src.requires;
}
for (k, v) in src.env {
dst.env.insert(k, v);
}
if let Some(exec) = src.exec {
dst.exec = Some(exec);
dst.commands.clear();
return Ok(());
}
if !src.commands.is_empty() {
dst.exec = None;
for (k, child) in src.commands {
match dst.commands.get_mut(&k) {
Some(existing) => merge_command_node(existing, child)?,
None => {
dst.commands.insert(k, child);
}
}
}
}
Ok(())
}
pub fn validate_spec(spec: &RootSpec) -> Result<()> {
for (name, node) in &spec.commands {
node.validate(name)?;
}
Ok(())
}
pub const EMBEDDED_DEFAULT_FILES: &[(&str, &str)] = &[
("default/git.yaml", include_str!("../default/git.yaml")),
("default/disk.yaml", include_str!("../default/disk.yaml")),
("default/net.yaml", include_str!("../default/net.yaml")),
("default/proc.yaml", include_str!("../default/proc.yaml")),
("default/gradle.yaml", include_str!("../default/gradle.yaml")),
("default/rust.yaml", include_str!("../default/rust.yaml")),
("default/android.yaml", include_str!("../default/android.yaml")),
("default/docker.yaml", include_str!("../default/docker.yaml")),
("default/sys.yaml", include_str!("../default/sys.yaml")),
];
pub fn load_embedded_default_spec() -> Result<RootSpec> {
spec_load::load_spec_from_str_with_embedded(
include_str!("../default.spec.yaml"),
"",
EMBEDDED_DEFAULT_FILES,
HostPlatform::detect(),
)
}
pub fn load_spec_from_str(raw: &str, include_base: Option<&Path>) -> Result<RootSpec> {
spec_load::load_spec_from_str(raw, include_base, HostPlatform::detect())
}
pub fn load_spec(path: &Path) -> Result<RootSpec> {
spec_load::load_spec_from_path(path, HostPlatform::detect())
}
pub fn resolve_git_branch(cwd: &Path, override_branch: Option<&str>) -> String {
if let Some(b) = override_branch {
if !b.is_empty() {
return b.to_string();
}
}
if let Ok(v) = std::env::var("JAN_BRANCH") {
if !v.is_empty() {
return v;
}
}
let output = Command::new("git")
.args(["rev-parse", "--abbrev-ref", "HEAD"])
.current_dir(cwd)
.output();
match output {
Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
_ => "(no-git)".to_string(),
}
}
fn first_line(s: &str) -> String {
s.lines().next().unwrap_or("").trim().to_string()
}
pub fn format_help(spec: &RootSpec, chain: &[String], node: Option<&CommandNode>) -> String {
let mut out = String::new();
let bin = spec
.metadata
.as_ref()
.and_then(|m| m.name.as_deref())
.unwrap_or("jan");
let full_cmd = if chain.is_empty() {
bin.to_string()
} else {
format!("{} {}", bin, chain.join(" "))
};
let (about, children, exec) = match node {
Some(n) => (n.about.as_str(), &n.commands, n.exec.as_ref()),
None => (
spec.metadata
.as_ref()
.and_then(|m| m.description.as_deref())
.unwrap_or(""),
&spec.commands,
None,
),
};
if chain.is_empty() {
if let Some(meta) = &spec.metadata {
if let Some(desc) = &meta.description {
out.push_str(desc.trim());
out.push_str("\n\n");
}
}
}
if !about.is_empty() {
out.push_str(about.trim());
out.push_str("\n\n");
}
if exec.is_some() && children.is_empty() {
out.push_str("This command runs an external program (see spec `exec.argv`).\n");
return out;
}
if !children.is_empty() {
out.push_str("Subcommands:\n");
for (name, child) in children {
let line = if child.about.is_empty() {
format!(" {name}\n")
} else {
format!(" {name} — {}\n", first_line(&child.about))
};
out.push_str(&line);
}
out.push('\n');
out.push_str(&format!(
"Use `{} --help` for more about a subcommand.\n",
full_cmd
));
} else if exec.is_none() {
out.push_str("(No subcommands defined.)\n");
}
if chain.is_empty() && node.is_none() {
out.push_str(
"\nFramework: place `--help` or `-h` right after the subcommand prefix you want; run `jan --help` for global flags (`--verbose`, `--extra-spec`, `--stdin-spec`, …).\n",
);
}
out
}
#[derive(Debug, Clone)]
pub struct SpecRootIdentity {
pub spec_dir: String,
pub root_yaml: String,
}
pub const EMBEDDED_SPEC_DIR: &str = "jan://embedded";
pub const EMBEDDED_ROOT_YAML: &str = "default.spec.yaml";
pub fn embedded_spec_identity() -> SpecRootIdentity {
SpecRootIdentity {
spec_dir: EMBEDDED_SPEC_DIR.to_string(),
root_yaml: EMBEDDED_ROOT_YAML.to_string(),
}
}
pub fn spec_identity_for_spec_file(spec_file: &Path) -> Result<SpecRootIdentity> {
let spec_file = spec_file
.canonicalize()
.with_context(|| format!("canonicalize {}", spec_file.display()))?;
let parent = spec_file
.parent()
.ok_or_else(|| anyhow::anyhow!("spec file has no parent directory"))?;
let root_name = spec_file
.file_name()
.ok_or_else(|| anyhow::anyhow!("spec file has no file name"))?
.to_string_lossy()
.into_owned();
Ok(SpecRootIdentity {
spec_dir: parent.to_string_lossy().into_owned(),
root_yaml: root_name,
})
}
pub fn resolve_spec_dir_entry(spec_dir: &Path, root_yaml: &str, cwd: &Path) -> Result<(PathBuf, SpecRootIdentity)> {
let rel = Path::new(root_yaml);
if rel.is_absolute() {
bail!("--spec-root must be a relative file name, not an absolute path");
}
if rel.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
bail!("--spec-root must not contain `..`");
}
let normal_only = rel
.components()
.all(|c| matches!(c, std::path::Component::Normal(_)));
let n = rel
.components()
.filter(|c| matches!(c, std::path::Component::Normal(_)))
.count();
if !normal_only || n != 1 {
bail!("--spec-root must be a single file name inside the spec directory");
}
let dir = if spec_dir.is_absolute() {
spec_dir.to_path_buf()
} else {
cwd.join(spec_dir)
};
let dir = dir
.canonicalize()
.with_context(|| format!("canonicalize spec directory {}", dir.display()))?;
if !dir.is_dir() {
bail!("not a directory: {}", dir.display());
}
let spec_path = dir.join(rel);
if !spec_path.is_file() {
bail!(
"spec entry not found: {} (under {})",
spec_path.display(),
dir.display()
);
}
let identity = SpecRootIdentity {
spec_dir: dir.to_string_lossy().into_owned(),
root_yaml: rel
.file_name()
.expect("relative root has file_name")
.to_string_lossy()
.into_owned(),
};
Ok((spec_path, identity))
}
pub struct RunContext<'a> {
pub cwd: &'a Path,
pub db_path: Option<&'a Path>,
pub branch: String,
pub no_log: bool,
pub spec_root: &'a SpecRootIdentity,
}
pub fn run_matched(
spec: &RootSpec,
chain: &[String],
node: &CommandNode,
trailing: &[OsString],
ctx: &RunContext<'_>,
) -> Result<i32> {
let exec = match &node.exec {
Some(e) => e,
None => {
let help = format_help(spec, chain, Some(node));
print!("{help}");
bail!("missing subcommand");
}
};
if exec.argv.is_empty() {
bail!("exec.argv must not be empty");
}
let mut argv: Vec<String> = exec.argv.clone();
if exec.passthrough {
for a in trailing {
argv.push(a.to_string_lossy().into_owned());
}
} else if !trailing.is_empty() {
bail!("unexpected trailing arguments (enable exec.passthrough in the spec)");
}
let cmd_path = if chain.is_empty() {
"(root)".to_string()
} else {
chain.join(" ")
};
let (_, requires, _) = deps::collect_chain_metadata(chain, spec);
deps::check_requires(&requires)?;
let path_dirs = deps::resolve_path_prefixes(spec, chain, ctx)?;
let mut run_env = deps::collect_chain_env(chain, spec);
if !path_dirs.is_empty() {
run_env.insert("PATH".into(), deps::prepend_path_env(&path_dirs)?);
}
let mut c = Command::new(&argv[0]);
if argv.len() > 1 {
c.args(&argv[1..]);
}
c.current_dir(ctx.cwd);
for (key, value) in run_env {
c.env(key, value);
}
let status = c.status().with_context(|| format!("spawn `{}`", argv[0]))?;
let code = status.code().unwrap_or(255);
if !ctx.no_log {
if let Some(db) = ctx.db_path {
log_invocation(
db,
&ctx.branch,
ctx.cwd,
&cmd_path,
&argv,
code,
ctx.spec_root,
)?;
}
}
Ok(code)
}
fn ensure_invocations_spec_root_column(conn: &Connection) -> Result<()> {
let mut stmt = conn.prepare("PRAGMA table_info(invocations)")?;
let cols: Vec<String> = stmt
.query_map([], |row| row.get::<_, String>(1))?
.collect::<std::result::Result<_, _>>()?;
if !cols.iter().any(|c| c == "spec_root_id") {
conn.execute(
"ALTER TABLE invocations ADD COLUMN spec_root_id INTEGER",
[],
)?;
}
Ok(())
}
fn upsert_spec_root(conn: &Connection, spec: &SpecRootIdentity) -> Result<i64> {
let ts = unix_ts();
conn.execute(
r"INSERT INTO spec_roots (spec_dir, root_yaml, last_used_ts) VALUES (?1, ?2, ?3)
ON CONFLICT(spec_dir, root_yaml) DO UPDATE SET last_used_ts = excluded.last_used_ts",
rusqlite::params![&spec.spec_dir, &spec.root_yaml, &ts],
)?;
let id: i64 = conn.query_row(
"SELECT id FROM spec_roots WHERE spec_dir = ?1 AND root_yaml = ?2",
[&spec.spec_dir, &spec.root_yaml],
|r| r.get(0),
)?;
Ok(id)
}
fn log_invocation(
db_path: &Path,
branch: &str,
cwd: &Path,
command_path: &str,
argv: &[String],
exit_code: i32,
spec_root: &SpecRootIdentity,
) -> Result<()> {
if let Some(parent) = db_path.parent() {
std::fs::create_dir_all(parent).ok();
}
let conn = Connection::open(db_path).with_context(|| format!("open {}", db_path.display()))?;
conn.execute_batch(
r"
CREATE TABLE IF NOT EXISTS spec_roots (
id INTEGER PRIMARY KEY AUTOINCREMENT,
spec_dir TEXT NOT NULL,
root_yaml TEXT NOT NULL,
last_used_ts TEXT NOT NULL,
UNIQUE(spec_dir, root_yaml)
);
CREATE TABLE IF NOT EXISTS invocations (
id INTEGER PRIMARY KEY AUTOINCREMENT,
ts TEXT NOT NULL,
git_branch TEXT NOT NULL,
cwd TEXT NOT NULL,
command_path TEXT NOT NULL,
argv_json TEXT NOT NULL,
exit_code INTEGER NOT NULL,
spec_root_id INTEGER
);
",
)?;
ensure_invocations_spec_root_column(&conn)?;
let spec_root_id = upsert_spec_root(&conn, spec_root)?;
let ts = unix_ts();
let argv_json = serde_json::to_string(argv).unwrap_or_else(|_| "[]".to_string());
let cwd_s = cwd.to_string_lossy();
conn.execute(
"INSERT INTO invocations (ts, git_branch, cwd, command_path, argv_json, exit_code, spec_root_id)
VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
rusqlite::params![
ts,
branch,
cwd_s.as_ref(),
command_path,
argv_json,
exit_code,
spec_root_id
],
)?;
Ok(())
}
fn unix_ts() -> String {
use std::time::SystemTime;
SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
.to_string()
}
#[derive(Debug)]
pub struct MatchOutcome<'a> {
pub chain: Vec<String>,
pub node: Option<&'a CommandNode>,
pub trailing: Vec<OsString>,
pub wants_help: bool,
}
pub fn match_commands<'a>(spec: &'a RootSpec, args: &[OsString]) -> MatchOutcome<'a> {
let mut chain = Vec::new();
let mut node: Option<&'a CommandNode> = None;
let mut map: &BTreeMap<String, CommandNode> = &spec.commands;
let mut i = 0usize;
let len = args.len();
while i < len {
let raw = &args[i];
if raw == "--help" || raw == "-h" {
return MatchOutcome {
chain,
node,
trailing: args[i + 1..].to_vec(),
wants_help: true,
};
}
let key = raw.to_string_lossy();
if let Some(next) = map.get(key.as_ref()) {
chain.push(key.into_owned());
node = Some(next);
map = &next.commands;
i += 1;
continue;
}
break;
}
MatchOutcome {
chain,
node,
trailing: args[i..].to_vec(),
wants_help: false,
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::io::Write;
#[test]
fn embedded_default_spec_validates() {
load_embedded_default_spec().unwrap();
}
#[test]
fn merge_specs_adds_and_replaces_leaves() {
let mut base = load_spec_from_str(
r"
commands:
a:
about: base
commands:
x:
about: old
exec:
argv: [echo, old]
",
None,
)
.unwrap();
let overlay = load_spec_from_str(
r"
commands:
a:
commands:
x:
about: new leaf
exec:
argv: [echo, new]
b:
about: added top
exec:
argv: [echo, b]
",
None,
)
.unwrap();
merge_specs_into(&mut base, overlay).unwrap();
base.commands["a"].commands["x"].validate("a x").unwrap();
assert_eq!(
base.commands["a"].commands["x"].exec.as_ref().unwrap().argv,
vec!["echo", "new"]
);
assert_eq!(base.commands["b"].exec.as_ref().unwrap().argv, vec!["echo", "b"]);
}
#[test]
fn validate_rejects_exec_with_children() {
let mut tmp = tempfile::NamedTempFile::with_suffix(".yaml").unwrap();
write!(
tmp,
r"
commands:
x:
exec:
argv: [echo]
commands:
child:
about: nested
"
)
.unwrap();
let err = load_spec(tmp.path()).unwrap_err();
assert!(err.to_string().contains("cannot define both"));
}
}
pub fn default_db_path() -> PathBuf {
if let Ok(p) = std::env::var("JAN_DB") {
return PathBuf::from(p);
}
dirs::data_local_dir()
.unwrap_or_else(|| PathBuf::from("."))
.join("jan-cli")
.join("audit.db")
}
pub fn resolve_spec_path(cli_spec: Option<PathBuf>, cwd: &Path) -> Result<Option<PathBuf>> {
if let Some(p) = cli_spec {
let full = if p.is_absolute() {
p
} else {
cwd.join(p)
};
if full.exists() {
return Ok(Some(full));
}
bail!("spec file not found: {}", full.display());
}
if let Ok(env) = std::env::var("JAN_SPEC") {
let p = PathBuf::from(&env);
let full = if p.is_absolute() {
p
} else {
cwd.join(p)
};
if full.exists() {
return Ok(Some(full));
}
bail!("JAN_SPEC points to missing file: {}", full.display());
}
let a = cwd.join("jan.yaml");
if a.exists() {
return Ok(Some(a));
}
let b = cwd.join("jan.spec.yaml");
if b.exists() {
return Ok(Some(b));
}
Ok(None)
}