use std::collections::hash_map::Entry;
use std::collections::{BTreeMap, HashMap};
use std::ffi::OsString;
use std::fs::File;
use std::io::{Read, Write};
use std::path::{Path, PathBuf};
use std::process::Command;
use std::time::{SystemTime, UNIX_EPOCH};
use anyhow::{bail, Context, Result};
use serde_json::json;
use zip::write::FileOptions;
use zip::CompressionMethod;
use crate::yaml_closure::ordered_yaml_closure;
use crate::{CommandNode, RootSpec, SpecRootIdentity};
pub fn is_builtin_reserved(token: &str) -> bool {
matches!(token, "bundle" | "alias" | "use")
}
pub fn is_pre_spec_builtin(token: &str) -> bool {
matches!(token, "use")
}
#[derive(Debug, Default)]
struct BundleCli {
output: PathBuf,
dry_run: bool,
include_extra: bool,
}
fn parse_bundle_args(args: &[OsString]) -> Result<BundleCli> {
let mut out = BundleCli {
output: PathBuf::from("jan-spec-bundle.zip"),
dry_run: false,
include_extra: false,
};
let mut i = 0usize;
while i < args.len() {
let s = args[i].to_string_lossy();
match s.as_ref() {
"--dry-run" => {
out.dry_run = true;
}
"--include-extra" => {
out.include_extra = true;
}
"-o" | "--output" => {
let next = args
.get(i + 1)
.ok_or_else(|| anyhow::anyhow!("missing path after `{}`", s))?;
out.output = PathBuf::from(next);
i += 1;
}
"--help" | "-h" => {
print_bundle_help();
return Err(anyhow::anyhow!("help"));
}
other if other.starts_with('-') && other != "-" => {
bail!("unknown bundle flag `{}`", other);
}
_ => {
bail!(
"unexpected bundle argument `{}` (try `jan bundle --help`)",
s
);
}
}
i += 1;
}
Ok(out)
}
fn parse_alias_args(args: &[OsString]) -> Result<AliasCli> {
let mut out = AliasCli::default();
let mut i = 0usize;
while i < args.len() {
let s = args[i].to_string_lossy();
match s.as_ref() {
"-o" | "--output" => {
let next = args
.get(i + 1)
.ok_or_else(|| anyhow::anyhow!("missing path after `{}`", s))?;
out.output = Some(PathBuf::from(next));
i += 1;
}
"--jan-bin" => {
let next = args
.get(i + 1)
.ok_or_else(|| anyhow::anyhow!("missing path after `--jan-bin`"))?;
out.jan_bin = next.to_string_lossy().into_owned();
i += 1;
}
"--spec-dir" => {
let next = args
.get(i + 1)
.ok_or_else(|| anyhow::anyhow!("missing path after `--spec-dir`"))?;
out.spec_dir = Some(next.to_string_lossy().into_owned());
i += 1;
}
"--spec-root" => {
let next = args
.get(i + 1)
.ok_or_else(|| anyhow::anyhow!("missing path after `--spec-root`"))?;
out.spec_root = Some(next.to_string_lossy().into_owned());
i += 1;
}
"--omit-spec-flags" => {
out.omit_spec_flags = true;
}
"--shell" => {
let next = args
.get(i + 1)
.ok_or_else(|| anyhow::anyhow!("missing path after `--shell`"))?;
let sh = next.to_string_lossy().into_owned().to_ascii_lowercase();
if sh != "sh" && sh != "zsh" && sh != "bash" {
bail!("--shell expects sh | bash | zsh");
}
out.shell = sh;
i += 1;
}
"--help" | "-h" => {
print_alias_help();
return Err(anyhow::anyhow!("help"));
}
other if other.starts_with('-') && other != "-" => {
bail!("unknown alias flag `{}`", other);
}
_ => {
bail!("unexpected alias argument `{}` (try `jan alias --help`)", s);
}
}
i += 1;
}
Ok(out)
}
#[derive(Debug)]
struct AliasCli {
output: Option<PathBuf>,
jan_bin: String,
shell: String,
spec_dir: Option<String>,
spec_root: Option<String>,
omit_spec_flags: bool,
}
impl Default for AliasCli {
fn default() -> Self {
Self {
output: None,
jan_bin: "jan".into(),
shell: "sh".into(),
spec_dir: None,
spec_root: None,
omit_spec_flags: false,
}
}
}
fn print_bundle_help() {
print!(
"\
bundle — pack all reachable YAML specs into a ZIP under the anchored spec directory
USAGE:
jan bundle [OPTIONS]
OPTIONS:
--dry-run List files instead of creating an archive
-o, --output <FILE> Output zip path (default: jan-spec-bundle.zip)
--include-extra Also archive merged `--extra-spec` files if they reside under anchor
-h, --help Prints help
DESCRIPTION:
Validates that every transitive `include:` target canonicalizes beneath the resolved
spec directory for the primary entry file, then creates a ZIP with paths relative to
that directory. Also writes `env.sh` and `manifest.json` into the archive.
"
);
}
fn print_alias_help() {
print!(
"\
alias — emit shell aliases for executable leaves in the merged spec
USAGE:
jan alias [OPTIONS]
OPTIONS:
--jan-bin <NAME> Program name/path used on the RHS (default: jan)
--spec-dir <DIR> Embed `--spec-dir` on each alias (default: loaded absolute spec dir)
--spec-root <NAME> Embed `--spec-root` on each alias (default: loaded root yaml)
--omit-spec-flags Emit bare `jan <chain>` (relies on `jan use` / well-known dir)
--shell <sh|bash|zsh> Shell dialect for the header (default: sh)
-o, --output <FILE> Write to FILE instead of stdout
-h, --help Prints help
DESCRIPTION:
For each script `run` leaf, prints `alias <name>='jan … scripts <cat> <name> run'`.
Spec flags default to the absolute path of the currently loaded tree so aliases work
without sourcing `env.sh`. Use `--omit-spec-flags` after `jan use` for shorter aliases.
"
);
}
fn shell_single_quote(s: &str) -> String {
format!("'{}'", s.replace('\'', "'\"'\"'"))
}
fn unix_ts() -> String {
SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap_or_default()
.as_secs()
.to_string()
}
fn git_sha_for_dir(dir: &Path) -> Option<String> {
let out = Command::new("git")
.args(["-C", dir.to_str()?, "rev-parse", "HEAD"])
.output()
.ok()?;
if !out.status.success() {
return None;
}
let s = String::from_utf8_lossy(&out.stdout).trim().to_string();
if s.is_empty() {
None
} else {
Some(s)
}
}
fn hex_encode(bytes: &[u8]) -> String {
bytes.iter().map(|b| format!("{b:02x}")).collect()
}
fn sha256_impl(data: &[u8]) -> [u8; 32] {
let mut h: [u32; 8] = [
0x6a09e667, 0xbb67ae85, 0x3c6ef372, 0xa54ff53a, 0x510e527f, 0x9b05688c, 0x1f83d9ab,
0x5be0cd19,
];
let k: [u32; 64] = [
0x428a2f98, 0x71374491, 0xb5c0fbcf, 0xe9b5dba5, 0x3956c25b, 0x59f111f1, 0x923f82a4,
0xab1c5ed5, 0xd807aa98, 0x12835b01, 0x243185be, 0x550c7dc3, 0x72be5d74, 0x80deb1fe,
0x9bdc06a7, 0xc19bf174, 0xe49b69c1, 0xefbe4786, 0x0fc19dc6, 0x240ca1cc, 0x2de92c6f,
0x4a7484aa, 0x5cb0a9dc, 0x76f988da, 0x983e5152, 0xa831c66d, 0xb00327c8, 0xbf597fc7,
0xc6e00bf3, 0xd5a79147, 0x06ca6351, 0x14292967, 0x27b70a85, 0x2e1b2138, 0x4d2c6dfc,
0x53380d13, 0x650a7354, 0x766a0abb, 0x81c2c92e, 0x92722c85, 0xa2bfe8a1, 0xa81a664b,
0xc24b8b70, 0xc76c51a3, 0xd192e819, 0xd6990624, 0xf40e3585, 0x106aa070, 0x19a4c116,
0x1e376c08, 0x2748774c, 0x34b0bcb5, 0x391c0cb3, 0x4ed8aa4a, 0x5b9cca4f, 0x682e6ff3,
0x748f82ee, 0x78a5636f, 0x84c87814, 0x8cc70208, 0x90befffa, 0xa4506ceb, 0xbef9a3f7,
0xc67178f2,
];
let bit_len = (data.len() as u64) * 8;
let mut msg = data.to_vec();
msg.push(0x80);
while (msg.len() % 64) != 56 {
msg.push(0);
}
msg.extend_from_slice(&bit_len.to_be_bytes());
for chunk in msg.chunks(64) {
let mut w = [0u32; 64];
for (i, word) in chunk.chunks(4).enumerate().take(16) {
let mut b = [0u8; 4];
b.copy_from_slice(word);
w[i] = u32::from_be_bytes(b);
}
for i in 16..64 {
let s0 = w[i - 15].rotate_right(7) ^ w[i - 15].rotate_right(18) ^ (w[i - 15] >> 3);
let s1 = w[i - 2].rotate_right(17) ^ w[i - 2].rotate_right(19) ^ (w[i - 2] >> 10);
w[i] = w[i - 16]
.wrapping_add(s0)
.wrapping_add(w[i - 7])
.wrapping_add(s1)
.wrapping_add(w[i - 2]);
}
let (mut a, mut b_, mut c, mut d, mut e, mut f, mut g, mut hh) =
(h[0], h[1], h[2], h[3], h[4], h[5], h[6], h[7]);
for i in 0..64 {
let s1 = e.rotate_right(6) ^ e.rotate_right(11) ^ e.rotate_right(25);
let ch = (e & f) ^ ((!e) & g);
let t1 = hh
.wrapping_add(s1)
.wrapping_add(ch)
.wrapping_add(k[i])
.wrapping_add(w[i]);
let s0 = a.rotate_right(2) ^ a.rotate_right(13) ^ a.rotate_right(22);
let maj = (a & b_) ^ (a & c) ^ (b_ & c);
let t2 = s0.wrapping_add(maj);
hh = g;
g = f;
f = e;
e = d.wrapping_add(t1);
d = c;
c = b_;
b_ = a;
a = t1.wrapping_add(t2);
}
h[0] = h[0].wrapping_add(a);
h[1] = h[1].wrapping_add(b_);
h[2] = h[2].wrapping_add(c);
h[3] = h[3].wrapping_add(d);
h[4] = h[4].wrapping_add(e);
h[5] = h[5].wrapping_add(f);
h[6] = h[6].wrapping_add(g);
h[7] = h[7].wrapping_add(hh);
}
let mut out = [0u8; 32];
for (i, word) in h.iter().enumerate() {
out[i * 4..(i + 1) * 4].copy_from_slice(&word.to_be_bytes());
}
out
}
fn hash_file_sha256(path: &Path) -> Result<String> {
let mut f = File::open(path)?;
let mut buf = Vec::new();
f.read_to_end(&mut buf)?;
Ok(hex_encode(&sha256_impl(&buf)))
}
fn write_zip_entry(
zip: &mut zip::ZipWriter<File>,
name: &str,
content: &[u8],
opts: FileOptions<'_, ()>,
) -> Result<()> {
zip.start_file(name.replace('\\', "/"), opts)?;
zip.write_all(content)?;
Ok(())
}
pub fn bundle_spec_zip(
spec_identity: &SpecRootIdentity,
extra_specs: &[PathBuf],
args: &[OsString],
verbose: bool,
) -> Result<i32> {
let cli = match parse_bundle_args(args) {
Ok(c) => c,
Err(e) => {
if e.to_string() == "help" {
return Ok(0);
}
return Err(e);
}
};
let anchor = PathBuf::from(&spec_identity.spec_dir)
.canonicalize()
.with_context(|| format!("canonicalize spec dir {}", spec_identity.spec_dir))?;
let entry_yaml = anchor.join(&spec_identity.root_yaml);
if verbose {
eprintln!("jan bundle: anchor={}", anchor.display());
eprintln!("jan bundle: entry={}", entry_yaml.display());
}
let mut merged = ordered_yaml_closure(&entry_yaml, &anchor)?;
if cli.include_extra {
for extra in extra_specs {
match extra.canonicalize() {
Ok(p) if p.starts_with(&anchor) => {
if !merged.iter().any(|x| x == &p) {
merged.push(p);
}
}
Ok(p) => {
bail!(
"--include-extra: extra spec outside anchor: {} (anchor {})",
p.display(),
anchor.display()
);
}
Err(e) => {
bail!(
"--include-extra: cannot canonicalize {}: {e}",
extra.display()
);
}
}
}
}
let gen_manifest = anchor.join("generated/scripts/manifest.json");
if gen_manifest.is_file() && !merged.iter().any(|p| p == &gen_manifest) {
merged.push(gen_manifest);
}
merged.sort();
let yaml_count = merged.len();
if cli.dry_run {
for p in &merged {
match p.strip_prefix(&anchor) {
Ok(rel) => println!("{}", rel.display()),
Err(_) => {
unreachable!("paths were forced under anchor when collected");
}
}
}
println!("env.sh");
println!("manifest.json");
return Ok(0);
}
let opts = FileOptions::<'_, ()>::default().compression_method(CompressionMethod::Deflated);
let file =
File::create(&cli.output).with_context(|| format!("create {}", cli.output.display()))?;
let mut zip = zip::ZipWriter::new(file);
let mut manifest_files = serde_json::Map::new();
for p in &merged {
let rel = p.strip_prefix(&anchor).with_context(|| {
format!("strip prefix `{}` from `{}`", anchor.display(), p.display())
})?;
let arc_name = rel.to_string_lossy().replace('\\', "/");
zip.start_file(arc_name.clone(), opts)?;
std::io::copy(&mut File::open(p)?, &mut zip)?;
let size = p.metadata().map(|m| m.len()).unwrap_or(0);
let sha256 = hash_file_sha256(p)?;
manifest_files.insert(arc_name.clone(), json!({ "sha256": sha256, "size": size }));
}
let env_sh = format!(
"# Generated by `jan bundle` — source after unzip.\n\
export JAN_SPEC_DIR=\"$(cd \"$(dirname \"${{BASH_SOURCE[0]:-$0}}\")\" && pwd)\"\n\
export JAN_SPEC_ROOT=\"{}\"\n",
spec_identity.root_yaml
);
write_zip_entry(&mut zip, "env.sh", env_sh.as_bytes(), opts)?;
let bundle_manifest = json!({
"jan_cli_version": env!("CARGO_PKG_VERSION"),
"bundled_at": unix_ts(),
"root_yaml": spec_identity.root_yaml,
"git_sha": git_sha_for_dir(&anchor),
"files": manifest_files,
});
let manifest_bytes = serde_json::to_vec_pretty(&bundle_manifest)?;
write_zip_entry(&mut zip, "manifest.json", &manifest_bytes, opts)?;
zip.finish()?;
if verbose {
eprintln!(
"jan bundle: wrote {} with {} YAML file(s), env.sh, manifest.json",
cli.output.display(),
yaml_count,
);
}
Ok(0)
}
fn collect_script_alias_chains(spec: &RootSpec) -> Vec<Vec<String>> {
let mut out = Vec::new();
fn walk(prefix: &[String], map: &BTreeMap<String, CommandNode>, out: &mut Vec<Vec<String>>) {
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 mut run_chain = chain.clone();
run_chain.push("run".into());
out.push(run_chain);
continue;
}
}
if node.exec.is_some() && node.commands.is_empty() {
out.push(chain);
} else if !node.commands.is_empty() {
walk(&chain, &node.commands, out);
}
}
}
walk(&[], &spec.commands, &mut out);
out
}
fn alias_key_for_chain(chain: &[String]) -> Option<String> {
if chain.is_empty() {
return None;
}
if chain.last().map(|s| s.as_str()) == Some("run") && chain.len() >= 2 {
return Some(chain[chain.len() - 2].clone());
}
chain.last().cloned()
}
fn jan_invocation_prefix(cli: &AliasCli, spec_identity: &SpecRootIdentity) -> Vec<String> {
let mut parts = vec![cli.jan_bin.clone()];
if cli.omit_spec_flags {
return parts;
}
parts.push("--spec-dir".into());
parts.push(
cli.spec_dir
.clone()
.unwrap_or_else(|| spec_identity.spec_dir.clone()),
);
parts.push("--spec-root".into());
parts.push(
cli.spec_root
.clone()
.unwrap_or_else(|| spec_identity.root_yaml.clone()),
);
parts
}
pub fn emit_shell_aliases(
spec: &RootSpec,
spec_identity: &SpecRootIdentity,
args: &[OsString],
) -> Result<i32> {
let cli = match parse_alias_args(args) {
Ok(c) => c,
Err(e) => {
if e.to_string() == "help" {
return Ok(0);
}
return Err(e);
}
};
let mut winners: HashMap<String, Vec<String>> = HashMap::new();
let chains = collect_script_alias_chains(spec);
let mut collisions: Vec<(String, usize)> = Vec::new();
for chain in chains {
let Some(key) = alias_key_for_chain(&chain) else {
continue;
};
match winners.entry(key.clone()) {
Entry::Vacant(v) => {
v.insert(chain);
}
Entry::Occupied(mut o) => {
let old_len = o.get().len();
if chain.len() > old_len {
collisions.push((key.clone(), old_len));
o.insert(chain);
} else if chain.len() == old_len && chain != *o.get() {
collisions.push((key.clone(), old_len));
}
}
}
}
let mut names: Vec<String> = winners.keys().cloned().collect();
names.sort();
let mut body = String::new();
let header = match cli.shell.as_str() {
"zsh" => "# generated by `jan alias` (zsh)\n# source env.sh (from bundle) before these aliases\n",
"bash" => "# generated by `jan alias` (bash)\n# source env.sh (from bundle) before these aliases\n",
_ => "# generated by `jan alias` (POSIX sh)\n# source env.sh (from bundle) before these aliases\n",
};
body.push_str(header);
let prefix = jan_invocation_prefix(&cli, spec_identity);
for name in &names {
let chain = winners.get(name).expect("key");
let mut rhs_parts = prefix.clone();
rhs_parts.extend(chain.iter().cloned());
let rhs = rhs_parts.join(" ");
body.push_str(&format!("alias {}={}\n", name, shell_single_quote(&rhs)));
}
if !collisions.is_empty() {
body.push('\n');
body.push_str(
"# duplicate leaf names resolved by preferring the longest subcommand chain\n",
);
for (n, len) in collisions {
body.push_str(&format!("# noted collision on `{n}` (tied length {len})\n"));
}
}
if let Some(path) = &cli.output {
let mut f = File::create(path).with_context(|| format!("create {}", path.display()))?;
f.write_all(body.as_bytes())?;
} else {
print!("{body}");
}
Ok(0)
}
fn print_use_help() {
print!(
"\
use — set, show, or clear the preferred jan directory (XDG config)
USAGE:
jan use <DIR> [--root <NAME>]
jan use --show
jan use --clear
jan use --help
OPTIONS:
--root <NAME> Entry YAML file name inside DIR (auto-detected if omitted)
--show Print the saved preference and config path
--clear Remove the saved preference
-h, --help Prints help
DESCRIPTION:
Saves DIR to `$XDG_CONFIG_HOME/jan-cli/config.json` (typically
`~/.config/jan-cli/config.json`). Later `jan` invocations use that tree
without `--spec-dir` / `JAN_SPEC_DIR`, unless overridden by flags, env, or a
project-local `jan.yaml`.
DIR must contain `scripts.spec.yaml`, `jan.spec.yaml`, or `jan.yaml`
(or the file named by `--root`).
EXAMPLES:
jan use ~/.config/jan/scripts
jan use /path/to/jan-cli --root scripts.spec.yaml
jan use --show
jan use --clear
"
);
}
pub fn run_use(args: &[OsString]) -> Result<i32> {
let mut show = false;
let mut clear = false;
let mut root: Option<String> = None;
let mut dir: Option<PathBuf> = None;
let mut i = 0usize;
while i < args.len() {
let s = args[i].to_string_lossy();
match s.as_ref() {
"--show" => show = true,
"--clear" => clear = true,
"--root" => {
let next = args
.get(i + 1)
.ok_or_else(|| anyhow::anyhow!("missing value after `--root`"))?;
root = Some(next.to_string_lossy().into_owned());
i += 1;
}
"--help" | "-h" => {
print_use_help();
return Ok(0);
}
other if other.starts_with('-') => {
bail!("unknown use flag `{other}` (try `jan use --help`)");
}
_ => {
if dir.is_some() {
bail!("unexpected argument `{s}` (try `jan use --help`)");
}
dir = Some(PathBuf::from(args[i].clone()));
}
}
i += 1;
}
if show && clear {
bail!("use either --show or --clear, not both");
}
if (show || clear) && dir.is_some() {
bail!("do not pass a directory with --show or --clear");
}
if show {
let cfg = crate::config::load_user_config()?;
let path = crate::config::config_path();
println!("config: {}", path.display());
match (&cfg.jan_dir, &cfg.spec_root) {
(Some(d), Some(r)) => {
println!("jan-dir: {d}");
println!("spec-root: {r}");
}
(Some(d), None) => {
println!("jan-dir: {d}");
println!("spec-root: (auto)");
}
_ => println!("(no preferred jan directory set)"),
}
return Ok(0);
}
if clear {
crate::config::clear_user_config()?;
println!(
"cleared preferred jan directory ({})",
crate::config::config_path().display()
);
return Ok(0);
}
let Some(dir) = dir else {
print_use_help();
bail!("missing directory (try `jan use <DIR>` or `jan use --show`)");
};
let cfg = crate::config::set_preferred_jan_dir(&dir, root.as_deref())?;
println!(
"preferred jan directory saved:\n jan-dir: {}\n spec-root: {}\n config: {}",
cfg.jan_dir.as_deref().unwrap_or("?"),
cfg.spec_root.as_deref().unwrap_or("?"),
crate::config::config_path().display()
);
Ok(0)
}