use assert_cmd::Command;
use predicates::prelude::*;
use rusqlite::Connection;
use std::fs;
use std::io::Write;
use std::path::{Path, PathBuf};
use std::process::Command as SysCommand;
use tempfile::tempdir;
use zip::write::FileOptions;
fn jan() -> Command {
Command::cargo_bin("jan").unwrap()
}
fn jan_cli_manifest_dir() -> PathBuf {
PathBuf::from(env!("CARGO_MANIFEST_DIR"))
}
fn portable_scripts_fixture() -> PathBuf {
jan_cli_manifest_dir().join("tests/fixtures/portable-scripts")
}
fn prefer_tree(tree: &Path, cfg: &Path) {
jan()
.env("JAN_CONFIG_DIR", cfg)
.args(["use", tree.to_str().unwrap()])
.assert()
.success();
}
fn write_scripts_spec(dir: &Path, body: &str) {
fs::write(dir.join("scripts.spec.yaml"), body).unwrap();
}
fn write_test_zip(path: &Path, entries: &[(&str, &[u8])]) {
let file = fs::File::create(path).unwrap();
let mut zip = zip::ZipWriter::new(file);
let options = FileOptions::<'_, ()>::default();
for (name, content) in entries {
zip.start_file(*name, options).unwrap();
zip.write_all(content).unwrap();
}
zip.finish().unwrap();
}
#[test]
fn help_root_lists_android() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: demo
description: Root description line.
commands:
android:
about: Android tools
commands:
list:
about: List
exec:
argv: ["echo", "ok"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("android"))
.stdout(predicate::str::contains("Root description"))
.stdout(predicate::str::contains("Built-in commands"));
}
#[test]
fn nested_exec_runs() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: demo
commands:
a:
about: A
commands:
b:
about: B
commands:
c:
about: C
exec:
argv: ["echo", "leaf"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "a", "b", "c"])
.assert()
.success()
.stdout(predicate::str::contains("leaf"));
}
#[test]
fn passthrough_forwards_args() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: demo
commands:
run:
about: Run
exec:
argv: ["echo", "start"]
passthrough: true
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "run", "extra", "tokens"])
.assert()
.success()
.stdout(predicate::str::contains("start extra tokens"));
}
#[test]
fn shell_c_passthrough_puts_args_in_dollar1() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: demo
commands:
issue:
about: note app
commands:
run:
about: run
exec:
argv:
- bash
- -lc
- |
case "$1" in
create) echo "got-create argc=$#" ;;
*) echo "miss arg1=${1-} argc=$#" ;;
esac
passthrough: true
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "issue", "run", "create"])
.assert()
.success()
.stdout(predicate::str::contains("got-create argc=1"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "issue", "run", "--", "create"])
.assert()
.success()
.stdout(predicate::str::contains("got-create argc=1"));
}
#[test]
fn preferred_spec_help_lists_commands() {
let tree = tempdir().unwrap();
write_scripts_spec(
tree.path(),
r#"
metadata:
name: demo
commands:
alpha:
about: Alpha tool
exec:
argv: ["echo", "alpha"]
beta:
about: Beta tool
exec:
argv: ["echo", "beta"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(tree.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_OS", "linux")
.args(["--no-log", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("alpha"))
.stdout(predicate::str::contains("beta"));
}
#[test]
fn examples_default_git_r_passthrough() {
let dir = tempdir().unwrap();
let examples = jan_cli_manifest_dir().join("examples");
let cfg = tempdir().unwrap();
SysCommand::new("git")
.args(["init"])
.current_dir(dir.path())
.status()
.expect("git init");
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args([
"use",
examples.to_str().unwrap(),
"--root",
"default.spec.yaml",
])
.assert()
.success();
jan()
.current_dir(dir.path())
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "git", "r", "rev-parse", "--is-inside-work-tree"])
.assert()
.success()
.stdout(predicate::str::contains("true"));
}
#[test]
fn spec_include_resolves_subtree_from_disk() {
let dir = tempdir().unwrap();
let sub = dir.path().join("sub");
fs::create_dir_all(&sub).unwrap();
fs::write(
sub.join("leaf.yaml"),
r#"
about: From include
exec:
argv: ["echo", "included"]
"#,
)
.unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: inc-demo
commands:
outer:
include: sub/leaf.yaml
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "outer"])
.assert()
.success()
.stdout(predicate::str::contains("included"));
}
#[test]
fn script_include_runs_with_passthrough() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("scripts")).unwrap();
fs::write(
dir.path().join("scripts/greet.sh"),
"#!/bin/sh\nprintf 'hi:%s\\n' \"$1\"\n",
)
.unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: script-inc
commands:
greet:
about: Greet
include:
path: scripts/greet.sh
argv: [bash]
passthrough: true
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "greet", "world"])
.assert()
.success()
.stdout(predicate::str::contains("hi:world"));
}
#[test]
fn local_include_sha256_mismatch_fails_load() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("sub")).unwrap();
fs::write(
dir.path().join("sub/leaf.yaml"),
"about: x\nexec:\n argv: [echo, ok]\n",
)
.unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: pin-demo
commands:
outer:
include:
path: sub/leaf.yaml
sha256: 0000000000000000000000000000000000000000000000000000000000000000
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "outer"])
.assert()
.failure()
.stderr(predicate::str::contains("SHA256 mismatch"));
}
#[test]
fn local_include_optional_sha256_ok() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("sub")).unwrap();
let body = "about: pinned\nexec:\n argv: [echo, pinned-ok]\n";
fs::write(dir.path().join("sub/leaf.yaml"), body).unwrap();
let digest = "bea2991fe347b4490607fccecc8fa838af970c69797303ad0299f4862aa21a22";
write_scripts_spec(
dir.path(),
&format!(
r#"
metadata:
name: pin-ok
commands:
outer:
include:
path: sub/leaf.yaml
sha256: {digest}
"#
),
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "outer"])
.assert()
.success()
.stdout(predicate::str::contains("pinned-ok"));
}
#[test]
fn exec_file_leaf_runs() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("bin")).unwrap();
fs::write(
dir.path().join("bin/tool.sh"),
"#!/bin/sh\necho from-file\n",
)
.unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: file-exec
commands:
tool:
about: file leaf
exec:
file: bin/tool.sh
argv: [bash]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "tool"])
.assert()
.success()
.stdout(predicate::str::contains("from-file"));
}
#[test]
fn bundle_includes_script_files() {
let dir = tempdir().unwrap();
fs::create_dir_all(dir.path().join("scripts")).unwrap();
fs::write(dir.path().join("scripts/hi.sh"), "echo hi\n").unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: bundle-script
commands:
hi:
include:
path: scripts/hi.sh
argv: [bash]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
let out = dir.path().join("out.zip");
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["bundle", "--dry-run"])
.assert()
.success()
.stdout(predicate::str::contains("scripts/hi.sh"));
let _ = out;
}
#[test]
fn os_filter_hides_command_on_mismatch() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: os-demo
commands:
only_win:
os: [windows]
about: Hidden on Linux
exec:
argv: ["echo", "win"]
always:
about: Shown
exec:
argv: ["echo", "ok"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
let assert = jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_OS", "linux")
.args(["--no-log", "--help"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
assert!(stdout.contains("always"));
assert!(!stdout.contains("only_win"));
}
#[test]
fn sqlite_log_written() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: demo
commands:
leaf:
about: Leaf
exec:
argv: ["echo", "x"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
let db = dir.path().join("audit.db");
SysCommand::new("git")
.args(["init"])
.current_dir(dir.path())
.status()
.expect("git init");
jan()
.current_dir(dir.path())
.env("JAN_CONFIG_DIR", cfg.path())
.args([
"--db",
db.to_str().unwrap(),
"--branch",
"test-branch-2120",
"leaf",
])
.assert()
.success();
let conn = Connection::open(&db).unwrap();
let n: i64 = conn
.query_row(
"SELECT COUNT(*) FROM invocations WHERE git_branch = ?1",
["test-branch-2120"],
|r| r.get(0),
)
.unwrap();
assert!(n >= 1);
let spec_dir: String = conn
.query_row(
"SELECT s.spec_dir FROM invocations i JOIN spec_roots s ON i.spec_root_id = s.id \
WHERE i.git_branch = ?1 ORDER BY i.id DESC LIMIT 1",
["test-branch-2120"],
|r| r.get(0),
)
.unwrap();
let expected_dir = dir
.path()
.canonicalize()
.unwrap()
.to_string_lossy()
.to_string();
assert_eq!(spec_dir, expected_dir);
}
#[test]
fn preferred_dir_loads_includes() {
let dir = tempdir().unwrap();
let sub = dir.path().join("sub");
fs::create_dir_all(&sub).unwrap();
fs::write(
sub.join("leaf.yaml"),
r#"
about: From include
exec:
argv: ["echo", "from-preferred-dir"]
"#,
)
.unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: dir-demo
commands:
outer:
include: sub/leaf.yaml
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "outer"])
.assert()
.success()
.stdout(predicate::str::contains("from-preferred-dir"));
}
#[test]
fn script_dependencies_prepended_to_path() {
let dir = tempdir().unwrap();
let helper = dir.path().join("helper-bin");
fs::create_dir_all(&helper).unwrap();
let helper_script = helper.join("helper-cmd");
fs::write(&helper_script, "#!/bin/sh\nprintf 'from-helper'\n").unwrap();
#[cfg(unix)]
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&helper_script).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&helper_script, perms).unwrap();
}
let consumer = dir.path().join("consumer");
fs::create_dir_all(&consumer).unwrap();
let helper_s = helper.to_string_lossy();
let consumer_s = consumer.to_string_lossy();
write_scripts_spec(
dir.path(),
&format!(
r#"
metadata:
name: demo
commands:
helper:
path: {helper_s}
commands:
run:
exec:
argv: ["echo", "unused"]
consumer:
path: {consumer_s}
dependencies: [helper]
commands:
run:
exec:
argv: ["bash", "-lc", "helper-cmd"]
"#
),
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "consumer", "run"])
.assert()
.success()
.stdout(predicate::str::contains("from-helper"));
}
#[cfg(unix)]
#[test]
fn spec_path_cannot_shadow_system_interpreter() {
let dir = tempdir().unwrap();
let evil = dir.path().join("evil-bin");
fs::create_dir_all(&evil).unwrap();
let fake_bash = evil.join("bash");
fs::write(&fake_bash, "#!/bin/sh\nprintf 'shadowed'\n").unwrap();
{
use std::os::unix::fs::PermissionsExt;
let mut perms = fs::metadata(&fake_bash).unwrap().permissions();
perms.set_mode(0o755);
fs::set_permissions(&fake_bash, perms).unwrap();
}
let evil_s = evil.to_string_lossy();
write_scripts_spec(
dir.path(),
&format!(
r#"
metadata:
name: demo
commands:
victim:
path: {evil_s}
commands:
run:
exec:
argv: ["bash", "-lc", "printf real"]
"#
),
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "victim", "run"])
.assert()
.success()
.stdout(predicate::str::contains("real"))
.stdout(predicate::str::contains("shadowed").not());
}
#[test]
fn env_vars_passed_to_child() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: demo
commands:
outer:
env:
OUTER: outer-value
SHARED: from-outer
commands:
inner:
env:
INNER: inner-value
SHARED: from-inner
exec:
argv: ["bash", "-lc", "printf '%s %s %s' \"$OUTER\" \"$INNER\" \"$SHARED\""]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "outer", "inner"])
.assert()
.success()
.stdout(predicate::str::contains(
"outer-value inner-value from-inner",
));
}
#[test]
fn env_private_required_and_restricts_leakage() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: demo
commands:
secure:
env:
public:
VISIBLE: from-yaml
private:
- JAN_TEST_PRIVATE_TOKEN
exec:
argv:
- bash
- -lc
- |
printf 'vis=%s tok=%s leak=%s' "$VISIBLE" "$JAN_TEST_PRIVATE_TOKEN" "${JAN_TEST_LEAK_MARKER:-unset}"
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env_remove("JAN_TEST_PRIVATE_TOKEN")
.args(["--no-log", "secure"])
.assert()
.failure()
.stderr(predicate::str::contains(
"missing required environment variables",
));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_TEST_PRIVATE_TOKEN", "s3cret")
.env("JAN_TEST_LEAK_MARKER", "should-not-pass")
.args(["--no-log", "secure"])
.assert()
.success()
.stdout(predicate::str::contains(
"vis=from-yaml tok=s3cret leak=unset",
));
}
#[test]
fn typed_inputs_flags_and_interpolation() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: demo
commands:
backup:
about: Backup demo
inputs:
path:
description: Source directory
required: true
dest:
default: ~/Backups
env:
public:
SRC: ${{ inputs.path }}
commands:
run:
exec:
argv:
- bash
- -lc
- printf 'src=%s dest=%s' "$SRC" "${{ inputs.dest }}"
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "backup", "run"])
.assert()
.failure()
.stderr(predicate::str::contains("missing required input"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "backup", "run", "--path", "/tmp/vault"])
.assert()
.success()
.stdout(predicate::str::contains("src=/tmp/vault dest=~/Backups"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args([
"--no-log",
"backup",
"run",
"--path=/data",
"--dest",
"/mnt/b",
])
.assert()
.success()
.stdout(predicate::str::contains("src=/data dest=/mnt/b"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "backup", "run", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("Inputs:"))
.stdout(predicate::str::contains("--path"))
.stdout(predicate::str::contains("--dest"));
}
#[test]
fn typed_inputs_type_and_choices_checked_before_script() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: demo
commands:
fetch:
about: Fetch demo
inputs:
url:
description: Absolute HTTPS URL to request
required: true
type: url
port:
type: port
default: "443"
proto:
default: tcp
choices: [tcp, udp]
commands:
run:
exec:
argv:
- bash
- -lc
- printf 'ran'
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "fetch", "run", "--url", "not-a-url"])
.assert()
.failure()
.stderr(predicate::str::contains("http or https URL"))
.stdout(predicate::str::contains("ran").not());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "fetch", "run", "--url", "ftp://example.com"])
.assert()
.failure()
.stderr(predicate::str::contains("http or https URL"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args([
"--no-log",
"fetch",
"run",
"--url",
"https://example.com/x",
"--port",
"99999",
])
.assert()
.failure()
.stderr(predicate::str::contains("port"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args([
"--no-log",
"fetch",
"run",
"--url",
"https://example.com/x",
"--proto",
"sctp",
])
.assert()
.failure()
.stderr(predicate::str::contains("must be one of"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args([
"--no-log",
"fetch",
"run",
"--url",
"https://example.com/x",
"--port",
"443",
"--proto",
"udp",
])
.assert()
.success()
.stdout(predicate::str::contains("ran"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "fetch", "run", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--url <URL>"))
.stdout(predicate::str::contains("type: url"))
.stdout(predicate::str::contains("--proto <tcp|udp>"));
}
#[test]
fn help_child_inlined_on_flag_and_text_mode_prints() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: demo
commands:
backup:
about: Backup demo
inputs:
path:
description: Source
required: true
type: path
commands:
help:
about: Describe this script.
exec:
text: |
backup — copy files somewhere
Example: jan backup run --path /data
run:
about: Run backup
exec:
argv:
- bash
- -lc
- printf 'ran'
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "backup", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("backup — copy files somewhere"))
.stdout(predicate::str::contains("Example: jan backup run"))
.stdout(predicate::str::contains(" run — Run backup"))
.stdout(predicate::str::contains(" help —").not());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "backup", "help"])
.assert()
.success()
.stdout(predicate::str::contains("backup — copy files somewhere"))
.stdout(predicate::str::contains("missing required input").not());
}
#[test]
fn exec_text_and_cat_alias_print_literal() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
commands:
docs:
exec:
text: |
hello from text
also:
exec:
cat: |
hello from cat
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "docs"])
.assert()
.success()
.stdout(predicate::str::contains("hello from text"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "also"])
.assert()
.success()
.stdout(predicate::str::contains("hello from cat"));
}
#[test]
fn typed_inputs_unknown_type_fails_at_load() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: demo
commands:
bad:
inputs:
x:
type: notatype
commands:
run:
exec:
argv: [true]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "bad", "run", "--x", "1"])
.assert()
.failure()
.stderr(predicate::str::contains("unknown input type"));
}
#[test]
fn requires_blocks_when_utility_missing() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: demo
commands:
needs-fzf:
about: Needs fzf
requires: [definitely-not-a-real-binary-xyz-jan-test]
exec:
argv: ["echo", "should-not-run"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "needs-fzf"])
.assert()
.failure()
.stderr(predicate::str::contains("missing required utilities"));
}
#[test]
fn preferred_spec_logs_spec_root() {
let tree = tempdir().unwrap();
write_scripts_spec(
tree.path(),
r#"
commands:
leaf:
exec:
argv: ["echo", "logged"]
"#,
);
let cfg = tempdir().unwrap();
let cwd = tempdir().unwrap();
prefer_tree(tree.path(), cfg.path());
let db = cwd.path().join("audit.db");
jan()
.current_dir(cwd.path())
.env("JAN_CONFIG_DIR", cfg.path())
.args([
"--db",
db.to_str().unwrap(),
"--branch",
"pref-branch",
"leaf",
])
.assert()
.success();
let conn = Connection::open(&db).unwrap();
let (spec_dir, root_yaml): (String, String) = conn
.query_row(
"SELECT s.spec_dir, s.root_yaml FROM invocations i JOIN spec_roots s ON i.spec_root_id = s.id \
WHERE i.git_branch = ?1 ORDER BY i.id DESC LIMIT 1",
["pref-branch"],
|r| Ok((r.get::<_, String>(0)?, r.get::<_, String>(1)?)),
)
.unwrap();
assert_eq!(
spec_dir,
tree.path().canonicalize().unwrap().to_string_lossy()
);
assert_eq!(root_yaml, "scripts.spec.yaml");
}
#[test]
fn scripts_bundle_dry_run_lists_portable_artifacts() {
let root = portable_scripts_fixture();
let cfg = tempdir().unwrap();
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["use", root.to_str().unwrap(), "--root", "scripts.spec.yaml"])
.assert()
.success();
let assert = jan()
.current_dir(&root)
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "bundle", "--dry-run"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
assert!(stdout.contains("scripts.spec.yaml"));
assert!(stdout.contains("scripts.yaml"));
assert!(stdout.contains("env.sh"));
assert!(stdout.contains("manifest.json"));
}
#[test]
fn inspect_list_and_show_script_leaves() {
let root = portable_scripts_fixture();
let cfg = tempdir().unwrap();
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["use", root.to_str().unwrap(), "--root", "scripts.spec.yaml"])
.assert()
.success();
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "list"])
.assert()
.success()
.stdout(predicate::str::contains("sum"))
.stdout(predicate::str::contains("basename"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "show", "sum"])
.assert()
.success()
.stdout(predicate::str::contains("scripts misc sum"))
.stdout(predicate::str::contains("category: misc"))
.stdout(predicate::str::contains("cron:"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "search", "--keyword", "comma"])
.assert()
.success()
.stdout(predicate::str::contains("sum"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "validate"])
.assert()
.success()
.stdout(predicate::str::contains("ok:"));
}
#[test]
fn cron_lists_and_runs_matching_scripts() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: cron-demo
commands:
scripts:
commands:
misc:
commands:
tick:
about: Runs on the half hour
cron: "30 10 * * *"
commands:
run:
exec:
argv: ["echo", "tick-ok"]
silent:
about: Never matches this test clock
cron: "0 0 1 1 *"
commands:
run:
exec:
argv: ["echo", "silent"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "cron", "--list", "--at", "2026-08-05 09:00"])
.assert()
.success()
.stdout(predicate::str::contains("scripts misc tick"))
.stdout(predicate::str::contains("schedule: 30 10 * * *"))
.stdout(predicate::str::contains("next: 2026-08-05 10:30 (in 1h 30m)"))
.stdout(predicate::str::contains("scripts misc silent"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "cron", "install", "--dry-run"])
.assert()
.success()
.stdout(predicate::str::contains("BEGIN JAN CRON"))
.stdout(predicate::str::contains("--no-log cron"))
.stdout(predicate::str::contains("END JAN CRON"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "cron", "--dry-run", "--at", "2026-08-05 10:30"])
.assert()
.success()
.stdout(predicate::str::contains("match: scripts misc tick"))
.stdout(predicate::str::contains("silent").not());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "cron", "--at", "2026-08-05 10:30"])
.assert()
.success()
.stdout(predicate::str::contains("tick-ok"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "cron", "--at", "2026-08-05 10:31", "-v"])
.assert()
.success()
.stdout(predicate::str::contains("(no scripts match)"));
}
#[test]
fn scripts_alias_emits_bare_jan_and_sum() {
let root = portable_scripts_fixture();
let cfg = tempdir().unwrap();
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["use", root.to_str().unwrap(), "--root", "scripts.spec.yaml"])
.assert()
.success();
let assert = jan()
.current_dir(&root)
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "alias"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
assert!(!stdout.contains("--spec-dir"));
assert!(!stdout.contains("--spec-root"));
assert!(stdout.contains("alias sum="));
assert!(stdout.contains("scripts"));
assert!(stdout.contains("misc"));
assert!(stdout.contains("sum"));
assert!(stdout.contains("run"));
}
#[test]
fn alias_rejects_shell_metacharacters_in_alias_name() {
let tree = tempdir().unwrap();
write_scripts_spec(
tree.path(),
r#"
commands:
"safe-parent":
commands:
"x; touch /tmp/jan-alias-injection":
exec:
argv: ["echo", "never"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(tree.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "alias"])
.assert()
.failure()
.stderr(predicate::str::contains("refusing unsafe alias name"));
}
#[test]
fn alias_emits_declared_names_and_shell_rhs() {
let tree = tempdir().unwrap();
write_scripts_spec(
tree.path(),
r#"
commands:
scripts:
aliases:
g: git
gs: git status
sum: echo should-skip
commands:
misc:
commands:
last_branch:
aliases: [lb]
commands:
run:
exec:
argv: ["echo", "branches"]
sum:
commands:
run:
exec:
argv: ["echo", "1"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(tree.path(), cfg.path());
let assert = jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "alias"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
assert!(stdout.contains("alias last_branch="), "{stdout}");
assert!(stdout.contains("alias lb="), "{stdout}");
assert!(stdout.contains("alias sum="), "{stdout}");
assert!(stdout.contains("alias g='git'"), "{stdout}");
assert!(stdout.contains("alias gs='git status'"), "{stdout}");
assert!(stdout.contains("# shell aliases from spec"), "{stdout}");
assert!(
stdout.contains("# skipped `sum`"),
"shell alias colliding with jan name should be skipped: {stdout}"
);
assert!(
!stdout.contains("echo should-skip"),
"colliding shell RHS must not be emitted: {stdout}"
);
let lb = stdout
.lines()
.find(|l| l.starts_with("alias lb="))
.expect("lb alias");
let last_branch = stdout
.lines()
.find(|l| l.starts_with("alias last_branch="))
.expect("last_branch alias");
assert_eq!(
lb.split_once('=').unwrap().1,
last_branch.split_once('=').unwrap().1,
"extra jan name must share the last_branch chain"
);
}
#[test]
fn config_emit_link_apply_smoke() {
let tree = tempdir().unwrap();
let frag = tree.path().join("config");
fs::create_dir_all(&frag).unwrap();
fs::write(frag.join("zsh.zsh"), "export JAN_CONFIG_SMOKE=1\n").unwrap();
write_scripts_spec(
tree.path(),
r#"
commands:
config:
about: host configuration
commands:
zsh:
config:
shell:
path: config/zsh.zsh
emacs:
config:
link:
~/.config/jan-config-smoke/init.el: |
(message "hi")
git:
config:
apply:
- [true]
shell: |
# inline git note
:
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(tree.path(), cfg.path());
let emit = jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "config", "emit", "--shell", "zsh"])
.assert()
.success();
let out = String::from_utf8_lossy(&emit.get_output().stdout);
assert!(out.contains("JAN_CONFIG_SMOKE"), "{out}");
assert!(
out.contains("from `config zsh`") || out.contains("config/zsh.zsh"),
"{out}"
);
assert!(out.contains("inline git note"), "{out}");
let home = tempdir().unwrap();
let link = jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("HOME", home.path())
.args(["--no-log", "config", "link", "--dry-run"])
.assert()
.success();
let link_out = String::from_utf8_lossy(&link.get_output().stdout);
assert!(link_out.contains("dry-run"), "{link_out}");
assert!(link_out.contains("write") && link_out.contains("init.el"), "{link_out}");
assert!(
link_out.contains("=== config link (dry-run) summary ==="),
"expected link summary, got {link_out}"
);
let linked = jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("HOME", home.path())
.args(["--no-log", "config", "link"])
.assert()
.success();
let linked_out = String::from_utf8_lossy(&linked.get_output().stdout);
assert!(
linked_out.contains("=== config link summary ==="),
"{linked_out}"
);
let dest = home.path().join(".config/jan-config-smoke/init.el");
let body = fs::read_to_string(&dest).unwrap_or_default();
assert!(
body.contains("(message \"hi\")"),
"inline link should write file contents, got {body:?} at {}",
dest.display()
);
let again = jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("HOME", home.path())
.args(["--no-log", "config", "link"])
.assert()
.success();
let again_out = String::from_utf8_lossy(&again.get_output().stdout);
assert!(again_out.contains("already ok:"), "{again_out}");
let unlinked = jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("HOME", home.path())
.args(["--no-log", "config", "unlink"])
.assert()
.success();
let unlink_out = String::from_utf8_lossy(&unlinked.get_output().stdout);
assert!(
unlink_out.contains("=== config unlink summary ===") && unlink_out.contains("removed:"),
"{unlink_out}"
);
assert!(
!dest.exists(),
"unlink should remove managed file {}",
dest.display()
);
let home2 = tempdir().unwrap();
let occupied = home2.path().join(".config/jan-config-smoke/init.el");
fs::create_dir_all(occupied.parent().unwrap()).unwrap();
fs::write(&occupied, "keep me\n").unwrap();
let skip = jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("HOME", home2.path())
.args(["--no-log", "config", "link"])
.assert()
.success();
let skip_err = String::from_utf8_lossy(&skip.get_output().stderr);
assert!(
skip_err.contains("destination already exists") && skip_err.contains("skipping"),
"expected skip warning, got stderr={skip_err}"
);
assert_eq!(
fs::read_to_string(&occupied).unwrap(),
"keep me\n",
"existing file must not be overwritten without --force"
);
let foreign = jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("HOME", home2.path())
.args(["--no-log", "config", "unlink"])
.assert()
.success();
let foreign_err = String::from_utf8_lossy(&foreign.get_output().stderr);
assert!(
foreign_err.contains("does not match managed content")
|| String::from_utf8_lossy(&foreign.get_output().stdout).contains("skipped (foreign)"),
"expected foreign skip, stderr={foreign_err}"
);
assert_eq!(fs::read_to_string(&occupied).unwrap(), "keep me\n");
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "config", "apply"])
.assert()
.success();
}
#[test]
fn config_deps_reports_missing_and_present() {
let tree = tempdir().unwrap();
write_scripts_spec(
tree.path(),
r#"
commands:
config:
commands:
required:
about: required for full use of scripts
config:
deps:
"true": ""
definitely-not-a-real-binary-xyz-jan-deps: some-pkg
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(tree.path(), cfg.path());
let out = jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "config", "deps"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&out.get_output().stdout);
assert!(
stdout.contains("Missing required for full use of scripts:")
|| stdout.contains("definitely-not-a-real-binary-xyz-jan-deps"),
"{stdout}"
);
assert!(
stdout.contains("definitely-not-a-real-binary-xyz-jan-deps (some-pkg)"),
"{stdout}"
);
assert!(
!stdout.contains("\n true\n") && !stdout.contains(" true ("),
"present tool `true` should not be listed as missing: {stdout}"
);
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "config", "deps", "--strict"])
.assert()
.failure();
}
#[test]
fn scripts_inlined_basename_run() {
let root = portable_scripts_fixture();
let cfg = tempdir().unwrap();
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["use", root.to_str().unwrap(), "--root", "scripts.spec.yaml"])
.assert()
.success();
jan()
.current_dir(&root)
.env("JAN_CONFIG_DIR", cfg.path())
.args([
"--no-log",
"scripts",
"files",
"basename",
"run",
"--",
"/tmp/foo/bar.txt",
])
.assert()
.success()
.stdout(predicate::str::contains("bar.txt"));
}
#[test]
fn scripts_path_demo_help_lists_dependency() {
let root = portable_scripts_fixture();
let cfg = tempdir().unwrap();
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["use", root.to_str().unwrap(), "--root", "scripts.spec.yaml"])
.assert()
.success();
let assert = jan()
.current_dir(&root)
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "scripts", "system", "path-demo", "help"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
assert!(stdout.contains("path-demo-impl"));
}
#[test]
fn packages_uv_list_installs_and_imports() {
let dir = tempdir().unwrap();
let cache = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: uv-demo
commands:
check:
about: Import pyyaml from packages.uv
packages:
uv: [pyyaml==6.0.2]
exec:
argv: ["python3", "-c", "import yaml; print('yaml-ok')"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "check"])
.assert()
.success()
.stdout(predicate::str::contains("yaml-ok"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.env("PATH", "/usr/bin:/bin")
.args(["--no-log", "check"])
.assert()
.success()
.stdout(predicate::str::contains("yaml-ok"));
}
#[test]
fn packages_uv_requirements_file() {
let dir = tempdir().unwrap();
let cache = tempdir().unwrap();
fs::create_dir_all(dir.path().join("deps")).unwrap();
fs::write(dir.path().join("deps/req.txt"), "pyyaml==6.0.2\n").unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: uv-req
commands:
check:
about: Import via requirements
packages:
uv:
requirements: deps/req.txt
exec:
argv: ["python3", "-c", "import yaml; print('req-ok')"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "check"])
.assert()
.success()
.stdout(predicate::str::contains("req-ok"));
}
#[test]
fn packages_pnpm_unpinned_list_rejected() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: pnpm-unpinned
commands:
check:
about: Unpinned npm dep
packages:
pnpm: [lodash, "zx@^8"]
exec:
argv: ["echo", "nope"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "check"])
.assert()
.failure()
.stderr(predicate::str::contains("must pin a version"));
}
#[test]
fn packages_pnpm_project_requires_lock() {
let dir = tempdir().unwrap();
let cache = tempdir().unwrap();
fs::create_dir_all(dir.path().join("deps/web")).unwrap();
fs::write(
dir.path().join("deps/web/package.json"),
"{\n \"name\": \"web\",\n \"private\": true\n}\n",
)
.unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: pnpm-unlocked
commands:
check:
about: Project without pnpm-lock.yaml
packages:
pnpm:
project: deps/web
exec:
argv: ["echo", "nope"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "check"])
.assert()
.failure()
.stderr(predicate::str::contains("no pnpm-lock.yaml"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "validate"])
.assert()
.failure()
.stdout(predicate::str::contains("no pnpm-lock.yaml"));
}
#[test]
fn packages_pnpm_missing_binary_errors() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: no-pnpm
commands:
check:
about: needs pnpm
packages:
pnpm: [lodash@4.17.21]
exec:
argv: ["node", "-e", "console.log(1)"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("PATH", "/usr/bin:/bin") .args(["--no-log", "check"])
.assert()
.failure()
.stderr(predicate::str::contains("pnpm"));
}
fn pnpm_available() -> bool {
std::process::Command::new("pnpm")
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[test]
fn packages_pnpm_list_installs_and_requires() {
if !pnpm_available() {
eprintln!("skipping: pnpm not on PATH");
return;
}
let dir = tempdir().unwrap();
let cache = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: pnpm-demo
commands:
check:
about: Require lodash from packages.pnpm
packages:
pnpm: [lodash@4.17.21]
exec:
argv: ["node", "-e", "console.log(require('lodash').VERSION)"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "check"])
.assert()
.success()
.stdout(predicate::str::contains("4.17.21"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.env("PATH", "/usr/bin:/bin")
.args(["--no-log", "check"])
.assert()
.success()
.stdout(predicate::str::contains("4.17.21"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "packages", "show", "check"])
.assert()
.success()
.stdout(predicate::str::contains("manager: pnpm"))
.stdout(predicate::str::contains("lodash@4.17.21"));
let assert = jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "packages", "path", "check", "--modules"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
assert!(stdout.contains("packages/pnpm/"));
assert!(stdout.trim().ends_with("node_modules"));
}
#[test]
fn packages_uv_missing_binary_errors() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: no-uv
commands:
check:
about: needs uv
packages:
uv: [pyyaml==6.0.2]
exec:
argv: ["python3", "-c", "print(1)"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("PATH", "/usr/bin:/bin") .args(["--no-log", "check"])
.assert()
.failure()
.stderr(predicate::str::contains("uv"));
}
#[test]
fn packages_uv_unpinned_list_rejected() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: unpinned
commands:
check:
about: Unpinned dep
packages:
uv: [pyyaml, "requests>=2"]
exec:
argv: ["python3", "-c", "print(1)"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "check"])
.assert()
.failure()
.stderr(predicate::str::contains("must pin a version"));
}
#[test]
fn packages_uv_unpinned_requirements_rejected() {
let dir = tempdir().unwrap();
let cache = tempdir().unwrap();
fs::create_dir_all(dir.path().join("deps")).unwrap();
fs::write(
dir.path().join("deps/req.txt"),
"# deps\npyyaml==6.0.2\nrequests\n",
)
.unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: unpinned-req
commands:
check:
about: Unpinned requirements file
packages:
uv:
requirements: deps/req.txt
exec:
argv: ["python3", "-c", "print(1)"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "check"])
.assert()
.failure()
.stderr(predicate::str::contains("req.txt:3"))
.stderr(predicate::str::contains("must pin a version"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "validate"])
.assert()
.failure()
.stdout(predicate::str::contains("must pin a version"));
}
#[test]
fn packages_uv_project_requires_lock() {
let dir = tempdir().unwrap();
let cache = tempdir().unwrap();
fs::create_dir_all(dir.path().join("deps/ml")).unwrap();
fs::write(
dir.path().join("deps/ml/pyproject.toml"),
"[project]\nname = \"ml\"\nversion = \"0.1.0\"\ndependencies = []\n",
)
.unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: unlocked-project
commands:
check:
about: Project without uv.lock
packages:
uv:
project: deps/ml
exec:
argv: ["python3", "-c", "print(1)"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "check"])
.assert()
.failure()
.stderr(predicate::str::contains("no uv.lock"));
}
#[test]
fn packages_gradle_unpinned_list_rejected() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: gradle-unpinned
commands:
check:
about: Unpinned maven coord
packages:
gradle: ["com.google.guava:guava:31.+"]
exec:
argv: ["echo", "nope"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "check"])
.assert()
.failure()
.stderr(predicate::str::contains("exact version"));
}
#[test]
fn packages_gradle_project_requires_lock() {
let dir = tempdir().unwrap();
let cache = tempdir().unwrap();
fs::create_dir_all(dir.path().join("deps/jvm")).unwrap();
fs::write(
dir.path().join("deps/jvm/build.gradle"),
"plugins { id 'java-library' }\nrepositories { mavenCentral() }\n",
)
.unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: gradle-unlocked
commands:
check:
about: Project without gradle.lockfile
packages:
gradle:
project: deps/jvm
exec:
argv: ["echo", "nope"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "check"])
.assert()
.failure()
.stderr(predicate::str::contains("no gradle.lockfile"));
}
fn gradle_available() -> bool {
std::process::Command::new("gradle")
.arg("--version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false)
}
#[test]
fn packages_gradle_list_resolves_classpath() {
if !gradle_available() {
eprintln!("skipping: gradle not on PATH");
return;
}
let dir = tempdir().unwrap();
let cache = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: gradle-demo
commands:
check:
about: Guava on CLASSPATH from packages.gradle
packages:
gradle: [com.google.guava:guava:33.3.1-jre]
exec:
argv:
- bash
- -lc
- |
case "$CLASSPATH" in
*guava*) echo classpath-ok ;;
*) echo "missing guava in CLASSPATH=$CLASSPATH" >&2; exit 1 ;;
esac
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "check"])
.assert()
.success()
.stdout(predicate::str::contains("classpath-ok"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.env("PATH", "/usr/bin:/bin")
.args(["--no-log", "check"])
.assert()
.success()
.stdout(predicate::str::contains("classpath-ok"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "packages", "show", "check", "--gradle"])
.assert()
.success()
.stdout(predicate::str::contains("manager: gradle"))
.stdout(predicate::str::contains("guava"));
}
fn kotlin_toolchain_available() -> bool {
let kotlinc = std::process::Command::new("kotlinc")
.arg("-version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
let kotlin = std::process::Command::new("kotlin")
.arg("-version")
.stdout(std::process::Stdio::null())
.stderr(std::process::Stdio::null())
.status()
.map(|s| s.success())
.unwrap_or(false);
kotlinc && kotlin
}
#[test]
fn exec_kotlin_compiles_once_and_runs() {
if !kotlin_toolchain_available() {
eprintln!("skipping: kotlinc/kotlin not on PATH");
return;
}
let dir = tempdir().unwrap();
let cache = tempdir().unwrap();
std::fs::write(
dir.path().join("hello.kt"),
r#"
fun main(args: Array<String>) {
println("hello-" + args.joinToString("-"))
}
"#,
)
.unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: kotlin-demo
commands:
greet:
about: exec.kotlin compile-once
exec:
kotlin: hello.kt
argv: ["Ada", "Lovelace"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "greet"])
.assert()
.success()
.stdout(predicate::str::contains("hello-Ada-Lovelace"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.env(
"PATH",
std::env::var("PATH").unwrap_or_default(),
)
.args(["--no-log", "greet"])
.assert()
.success()
.stdout(predicate::str::contains("hello-Ada-Lovelace"));
}
#[test]
fn exec_python_inline_and_path_run() {
let dir = tempdir().unwrap();
let cache = tempdir().unwrap();
std::fs::write(
dir.path().join("hello.py"),
"import sys\nprint('path-' + '-'.join(sys.argv[1:]))\n",
)
.unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: python-exec-demo
commands:
inline:
about: exec.python inline
exec:
python: |
import sys
print("inline-" + "-".join(sys.argv[1:]))
argv: ["a", "b"]
path:
about: exec.python path
exec:
python: hello.py
argv: ["c", "d"]
eval:
about: classic python3 -c still works
exec:
argv: ["python3", "-c", "import sys; print('eval-' + '-'.join(sys.argv[1:]))", "e", "f"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "inline"])
.assert()
.success()
.stdout(predicate::str::contains("inline-a-b"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "path"])
.assert()
.success()
.stdout(predicate::str::contains("path-c-d"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "eval"])
.assert()
.success()
.stdout(predicate::str::contains("eval-e-f"));
}
#[test]
fn exec_node_inline_and_path_run() {
let dir = tempdir().unwrap();
let cache = tempdir().unwrap();
std::fs::write(
dir.path().join("hello.js"),
"console.log('path-' + process.argv.slice(2).join('-'));\n",
)
.unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: node-exec-demo
commands:
inline:
about: exec.node inline
exec:
node: |
console.log("inline-" + process.argv.slice(1).join("-"));
argv: ["a", "b"]
path:
about: exec.node path
exec:
node: hello.js
argv: ["c", "d"]
eval:
about: classic node -e still works
exec:
argv: ["node", "-e", "console.log('eval-' + process.argv.slice(1).join('-'))", "e", "f"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "inline"])
.assert()
.success()
.stdout(predicate::str::contains("inline-a-b"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "path"])
.assert()
.success()
.stdout(predicate::str::contains("path-c-d"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "eval"])
.assert()
.success()
.stdout(predicate::str::contains("eval-e-f"));
}
#[test]
fn exec_bash_inline_passthrough_is_dollar_one() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
commands:
greet:
about: exec.bash inline
exec:
bash: |
printf '%s\n' "$1"
passthrough: true
helpish:
about: exec.bash help style
exec:
bash: |
printf 'ok\n'
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "greet", "Ada"])
.assert()
.success()
.stdout(predicate::str::contains("Ada"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "helpish"])
.assert()
.success()
.stdout(predicate::str::contains("ok"));
}
#[test]
fn packages_list_reports_declared_but_unbuilt() {
let dir = tempdir().unwrap();
let cache = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: uv-list
commands:
check:
about: Import pyyaml from packages.uv
packages:
uv: [pyyaml==6.0.2]
exec:
argv: ["python3", "-c", "import yaml"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "packages", "list"])
.assert()
.success()
.stdout(predicate::str::contains("declared but not built yet"))
.stdout(predicate::str::contains("check"));
}
#[test]
fn packages_list_show_and_path_after_build() {
let dir = tempdir().unwrap();
let cache = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: uv-inspect
commands:
check:
about: Import pyyaml from packages.uv
packages:
uv: [pyyaml==6.0.2]
exec:
argv: ["python3", "-c", "import yaml; print('yaml-ok')"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "check"])
.assert()
.success();
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "packages", "list"])
.assert()
.success()
.stdout(predicate::str::contains("USED BY"))
.stdout(predicate::str::contains("check"))
.stdout(predicate::str::contains("list"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "packages", "show", "check"])
.assert()
.success()
.stdout(predicate::str::contains("declaration: pyyaml==6.0.2"))
.stdout(predicate::str::contains("ready: yes"))
.stdout(predicate::str::contains("PyYAML==").or(predicate::str::contains("pyyaml==")));
let assert = jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "packages", "path", "check", "--python"])
.assert()
.success();
let stdout = String::from_utf8_lossy(&assert.get_output().stdout);
assert!(stdout.trim().ends_with("python3") || stdout.trim().ends_with("python"));
assert!(stdout.contains("packages/uv/"));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args(["--no-log", "packages", "list", "--orphans"])
.assert()
.success()
.stdout(predicate::str::contains("no orphaned envs"));
}
#[test]
fn packages_prune_removes_old_orphans() {
let dir = tempdir().unwrap();
let cache = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: prune-demo
commands:
hello:
about: no packages
exec:
argv: ["echo", "ok"]
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
let uv_root = cache.path().join("packages/uv");
let big = uv_root.join("deadbeefdeadbeef");
let tiny = uv_root.join("cafebabecafebabe");
fs::create_dir_all(&big).unwrap();
fs::create_dir_all(&tiny).unwrap();
fs::write(big.join(".jan-ready"), "list:\nold-pkg==1.0.0\n").unwrap();
fs::write(big.join(".jan-used"), "").unwrap();
fs::write(big.join("blob.bin"), vec![0u8; 64 * 1024]).unwrap();
fs::write(tiny.join(".jan-ready"), "list:\ntiny==1.0.0\n").unwrap();
fs::write(tiny.join("blob.bin"), b"x").unwrap();
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args([
"--no-log",
"packages",
"prune",
"--older-than",
"0",
"--min-size",
"32K",
])
.assert()
.success()
.stdout(predicate::str::contains("would remove"))
.stdout(predicate::str::contains("deadbeefdeadbeef"))
.stdout(predicate::str::contains("dry-run only"));
assert!(big.is_dir());
assert!(tiny.is_dir());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_CACHE_DIR", cache.path())
.args([
"--no-log",
"packages",
"prune",
"--older-than",
"0",
"--min-size",
"32K",
"--yes",
])
.assert()
.success()
.stdout(predicate::str::contains("removing"));
assert!(!big.exists());
assert!(tiny.is_dir(), "tiny env below --min-size should remain");
}
#[test]
fn use_saves_preference_and_loads_without_flags() {
let tree = tempdir().unwrap();
write_scripts_spec(
tree.path(),
r#"
metadata:
name: demo
commands:
hello:
about: Hello
exec:
argv: ["echo", "from-preferred"]
"#,
);
let cfg = tempdir().unwrap();
let cwd = tempdir().unwrap();
jan()
.current_dir(cwd.path())
.env("JAN_CONFIG_DIR", cfg.path())
.args(["use", tree.path().to_str().unwrap()])
.assert()
.success()
.stdout(predicate::str::contains("preferred jan directory saved"));
jan()
.current_dir(cwd.path())
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "hello"])
.assert()
.success()
.stdout(predicate::str::contains("from-preferred"));
jan()
.current_dir(cwd.path())
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--help"])
.assert()
.success()
.stdout(predicate::str::contains("hello"));
jan()
.current_dir(cwd.path())
.env("JAN_CONFIG_DIR", cfg.path())
.args(["use", "--show"])
.assert()
.success()
.stdout(predicate::str::contains("scripts.spec.yaml"));
jan()
.current_dir(cwd.path())
.env("JAN_CONFIG_DIR", cfg.path())
.args(["use", "--clear"])
.assert()
.success();
jan()
.current_dir(cwd.path())
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "hello"])
.assert()
.failure()
.stderr(predicate::str::contains("jan use"));
}
#[test]
fn use_works_before_any_spec_exists() {
let cfg = tempdir().unwrap();
let cwd = tempdir().unwrap();
jan()
.current_dir(cwd.path())
.env("JAN_CONFIG_DIR", cfg.path())
.args(["use", "--show"])
.assert()
.success()
.stdout(predicate::str::contains("no preferred jan directory"));
jan()
.current_dir(cwd.path())
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--help"])
.assert()
.success()
.stdout(predicate::str::contains("No preferred jan directory"))
.stdout(predicate::str::contains("Built-in commands"));
}
#[test]
fn use_help_documents_remote_bundle() {
jan()
.args(["use", "--help"])
.assert()
.success()
.stdout(predicate::str::contains("--sha256"))
.stdout(predicate::str::contains("HTTPS_URL"))
.stdout(predicate::str::contains("--allow-http"));
}
#[test]
fn remote_use_requires_sha256() {
let cfg = tempdir().unwrap();
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["use", "https://example.com/tree.zip"])
.assert()
.failure()
.stderr(predicate::str::contains("--sha256"));
}
#[test]
fn installer_rejects_zip_slip() {
let tmp = tempdir().unwrap();
let archive = tmp.path().join("malicious.zip");
let install = tmp.path().join("install");
let escaped = tmp.path().join("escaped.yaml");
write_test_zip(
&archive,
&[
("../escaped.yaml", b"commands: {}\n"),
("manifest.json", br#"{"files":{}}"#),
("env.sh", b""),
],
);
let status = SysCommand::new("bash")
.arg(jan_cli_manifest_dir().join("scripts/jan-install.sh"))
.arg(&archive)
.arg(&install)
.status()
.expect("run installer");
assert!(!status.success(), "zip-slip archive must be rejected");
assert!(!escaped.exists());
assert!(!install.exists());
}
#[test]
fn installer_rejects_manifest_hash_mismatch() {
let tmp = tempdir().unwrap();
let archive = tmp.path().join("tampered.zip");
let install = tmp.path().join("install");
let manifest = br#"{
"root_yaml": "scripts.spec.yaml",
"files": {
"scripts.spec.yaml": {
"sha256": "0000000000000000000000000000000000000000000000000000000000000000",
"size": 13
}
}
}"#;
write_test_zip(
&archive,
&[
("scripts.spec.yaml", b"commands: {}\n"),
("manifest.json", manifest),
("env.sh", b""),
],
);
let status = SysCommand::new("bash")
.arg(jan_cli_manifest_dir().join("scripts/jan-install.sh"))
.arg(&archive)
.arg(&install)
.status()
.expect("run installer");
assert!(!status.success());
assert!(!install.exists());
}
#[test]
fn installer_verifies_bundle_without_changing_jan_use() {
let root = portable_scripts_fixture();
let tmp = tempdir().unwrap();
let cfg = tmp.path().join("config");
let archive = tmp.path().join("bundle.zip");
let install = tmp.path().join("install");
let install_sh = jan_cli_manifest_dir().join("scripts/jan-install.sh");
jan()
.env("JAN_CONFIG_DIR", &cfg)
.args(["use", root.to_str().unwrap(), "--root", "scripts.spec.yaml"])
.assert()
.success();
let config_before = fs::read(cfg.join("config.json")).unwrap();
jan()
.current_dir(&root)
.env("JAN_CONFIG_DIR", &cfg)
.args(["--no-log", "bundle", "-o", archive.to_str().unwrap()])
.assert()
.success();
let output = SysCommand::new("bash")
.arg(&install_sh)
.arg(&archive)
.arg(&install)
.env("JAN_CONFIG_DIR", &cfg)
.output()
.expect("run installer");
assert!(
output.status.success(),
"{}",
String::from_utf8_lossy(&output.stderr)
);
assert!(install.join("scripts.spec.yaml").is_file());
assert!(install.join("manifest.json").is_file());
assert_eq!(fs::read(cfg.join("config.json")).unwrap(), config_before);
assert!(String::from_utf8_lossy(&output.stdout).contains("Run: jan use"));
}
#[test]
fn command_tests_run_nested_and_observe_cli() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
metadata:
name: test-demo
commands:
hello:
about: Say hello
exec:
argv: ["echo", "hello"]
tests:
given_no_args_when_run_then_prints_hello:
then: |
test "$JAN_STATUS" -eq 0
printf '%s\n' "$JAN_STDOUT" | grep -qx hello
group:
about: Nested
commands:
inner:
about: Inner echo
exec:
argv: ["echo", "inner"]
tests:
given_no_args_when_run_then_prints_inner:
then: |
test "$JAN_STATUS" -eq 0
grep -qx inner "$JAN_STDOUT_FILE"
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "test", "hello"])
.assert()
.success()
.stderr(predicate::str::contains(
"hello given_no_args_when_run_then_prints_hello",
));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "test", "group"])
.assert()
.success()
.stderr(predicate::str::contains(
"group inner given_no_args_when_run_then_prints_inner",
));
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "test"])
.assert()
.success()
.stderr(predicate::str::contains("2 passed"));
}
#[test]
fn command_tests_do_not_write_audit_log() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
commands:
hello:
exec:
argv: ["echo", "hello"]
tests:
given_no_args_when_run_then_prints_hello:
then: |
test "$JAN_STATUS" -eq 0
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
let db = dir.path().join("audit.db");
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.env("JAN_DB", &db)
.args(["test", "hello"])
.assert()
.success();
assert!(
!db.exists(),
"jan test should not create an audit db even without --no-log"
);
}
#[test]
fn command_tests_fail_then_exits_one() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
commands:
hello:
exec:
argv: ["echo", "hello"]
tests:
given_no_args_when_run_then_prints_goodbye:
then: grep -qx goodbye "$JAN_STDOUT_FILE"
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "test", "hello"])
.assert()
.failure()
.code(1)
.stderr(predicate::str::contains("not ok 1"))
.stderr(predicate::str::contains("1 failed"));
}
#[test]
fn command_tests_reject_non_gherkin_names() {
let dir = tempdir().unwrap();
write_scripts_spec(
dir.path(),
r#"
commands:
hello:
exec:
argv: ["echo", "hello"]
tests:
prints_hello:
when: jan hello
then: true
"#,
);
let cfg = tempdir().unwrap();
prefer_tree(dir.path(), cfg.path());
jan()
.env("JAN_CONFIG_DIR", cfg.path())
.args(["--no-log", "--help"])
.assert()
.failure()
.stderr(predicate::str::contains("given_…_when_…_then_…"));
}