jan-cli 0.17.0

YAML-defined CLI trees with progressive help, optional exec aliases, merged extra specs, and SQLite audit logging keyed by git branch
Documentation
//! Given/When/Then shell tests declared on command nodes (`tests:` in YAML).

use std::collections::BTreeMap;
use std::ffi::OsString;
use std::fmt::Write as _;
use std::fs;
use std::os::unix::fs::PermissionsExt;
use std::path::Path;
use std::process::Command;

use anyhow::{bail, Context, Result};

use crate::{match_commands, CommandNode, CommandTest, RootSpec, RunContext};

#[derive(Debug)]
struct CollectedTest<'a> {
    chain: Vec<String>,
    name: String,
    spec: &'a CommandTest,
}

impl CollectedTest<'_> {
    fn command_path(&self) -> String {
        if self.chain.is_empty() {
            "(root)".to_string()
        } else {
            self.chain.join(" ")
        }
    }

    fn label(&self) -> String {
        format!("{}  {}", self.command_path(), self.name)
    }
}

pub fn count_tests(node: &CommandNode) -> usize {
    node.tests.len() + node.commands.values().map(count_tests).sum::<usize>()
}

fn collect_from_node<'a>(
    chain: &[String],
    node: &'a CommandNode,
    out: &mut Vec<CollectedTest<'a>>,
) {
    for (name, spec) in &node.tests {
        out.push(CollectedTest {
            chain: chain.to_vec(),
            name: name.clone(),
            spec,
        });
    }
    for (child_name, child) in &node.commands {
        let mut next = chain.to_vec();
        next.push(child_name.clone());
        collect_from_node(&next, child, out);
    }
}

fn collect_from_map<'a>(
    prefix: &[String],
    map: &'a BTreeMap<String, CommandNode>,
    out: &mut Vec<CollectedTest<'a>>,
) {
    for (name, node) in map {
        let mut chain = prefix.to_vec();
        chain.push(name.clone());
        collect_from_node(&chain, node, out);
    }
}

fn print_test_help() {
    print!(
        "\
test — run Given/When/Then shell tests

    jan test
    jan test <command path...>

With no path, every test in the preferred tree runs. With a path, tests on that
command and all nested descendants run.

`when:` is extra argv for the command the test is declared on (do not repeat the
script path). Omit `when` to invoke that command with no extra args. Tests never
write the audit log.

Examples:

    jan test
    jan test scripts files
    jan test scripts files csv-summary
"
    );
}

/// `jan test [path...]`
pub fn dispatch_test(args: &[OsString], spec: &RootSpec, ctx: &RunContext<'_>) -> Result<i32> {
    let mut path: Vec<OsString> = Vec::new();
    for a in args {
        let s = a.to_string_lossy();
        match s.as_ref() {
            "--help" | "-h" => {
                print_test_help();
                return Ok(0);
            }
            other if other.starts_with('-') && other != "-" => {
                bail!("unknown test flag `{other}`");
            }
            _ => path.push(a.clone()),
        }
    }
    if path.is_empty() {
        return run_tests(spec, &[], None, ctx);
    }
    let m = match_commands(spec, &path);
    if !m.trailing.is_empty() {
        let t = m.trailing[0].to_string_lossy();
        if m.chain.is_empty() {
            bail!("unknown top-level command `{t}`");
        }
        bail!("unknown subcommand `{t}` under `{}`", m.chain.join(" "));
    }
    let node = match m.node {
        Some(n) => n,
        None => {
            let key = path[0].to_string_lossy();
            bail!("unknown top-level command `{key}`");
        }
    };
    run_tests(spec, &m.chain, Some(node), ctx)
}

/// Run tests for `node` and every nested command. `node == None` at the spec root
/// collects the whole tree.
pub fn run_tests(
    spec: &RootSpec,
    chain: &[String],
    node: Option<&CommandNode>,
    ctx: &RunContext<'_>,
) -> Result<i32> {
    let mut tests = Vec::new();
    match node {
        Some(n) => collect_from_node(chain, n, &mut tests),
        None => collect_from_map(&[], &spec.commands, &mut tests),
    }

    if tests.is_empty() {
        let where_ = if chain.is_empty() {
            "this tree".to_string()
        } else {
            format!("`{}`", chain.join(" "))
        };
        eprintln!("no tests defined under {where_}");
        return Ok(0);
    }

    let jan_bin = std::env::current_exe().context("resolve jan executable for tests")?;
    let mut passed = 0usize;
    let mut failed = 0usize;

    for (i, t) in tests.iter().enumerate() {
        match run_one(t, &jan_bin, ctx) {
            Ok(()) => {
                eprintln!("ok {}  {}", i + 1, t.label());
                passed += 1;
            }
            Err(e) => {
                eprintln!("not ok {}  {}", i + 1, t.label());
                eprintln!("{e:#}");
                failed += 1;
            }
        }
    }

    eprintln!("# {passed} passed, {failed} failed, {} total", tests.len());
    if failed > 0 {
        Ok(1)
    } else {
        Ok(0)
    }
}

fn run_one(t: &CollectedTest<'_>, jan_bin: &Path, ctx: &RunContext<'_>) -> Result<()> {
    let tmp = tempfile::tempdir().context("create jan test temp dir")?;
    write_harness(t, jan_bin, tmp.path(), ctx)?;

    let output = Command::new("sh")
        .arg(tmp.path().join("test.sh"))
        .current_dir(tmp.path())
        .output()
        .context("spawn `sh` for command test")?;

    if output.status.success() {
        return Ok(());
    }
    let stdout = String::from_utf8_lossy(&output.stdout);
    let stderr = String::from_utf8_lossy(&output.stderr);
    let code = output.status.code().unwrap_or(255);
    let mut msg = format!("test `{}` failed (sh exit {code})", t.label());
    if !stdout.trim().is_empty() {
        msg.push_str("\n--- stdout ---\n");
        msg.push_str(stdout.trim_end());
    }
    if !stderr.trim().is_empty() {
        msg.push_str("\n--- stderr ---\n");
        msg.push_str(stderr.trim_end());
    }
    bail!("{msg}");
}

fn write_harness(
    t: &CollectedTest<'_>,
    jan_bin: &Path,
    tmp: &Path,
    ctx: &RunContext<'_>,
) -> Result<()> {
    fs::write(tmp.join(".jan-given.sh"), t.spec.given.trim_start())?;
    fs::write(tmp.join(".jan-then.sh"), t.spec.then.trim_start())?;

    let cmd = tmp.join("cmd");
    let mut wrap = String::from("#!/bin/sh\n");
    write!(
        wrap,
        "exec {} --no-log",
        sh_single_quote(&jan_bin.to_string_lossy())
    )
    .unwrap();
    for part in &t.chain {
        wrap.push(' ');
        wrap.push_str(&sh_single_quote(part));
    }
    wrap.push_str(" \"$@\"\n");
    fs::write(&cmd, wrap)?;
    let mut perms = fs::metadata(&cmd)?.permissions();
    perms.set_mode(0o755);
    fs::set_permissions(&cmd, perms)?;

    // Any explicit `jan` in given/then also skips the audit log.
    let jan_shim = tmp.join("jan");
    let mut shim = String::from("#!/bin/sh\n");
    writeln!(
        shim,
        "exec {} --no-log \"$@\"",
        sh_single_quote(&jan_bin.to_string_lossy())
    )
    .unwrap();
    fs::write(&jan_shim, shim)?;
    let mut shim_perms = fs::metadata(&jan_shim)?.permissions();
    shim_perms.set_mode(0o755);
    fs::set_permissions(&jan_shim, shim_perms)?;

    // `when` is extra argv for the command this test lives on (shell-expanded).
    let mut when_run = String::from("#!/bin/sh\n");
    when_run.push_str("exec ");
    when_run.push_str(&sh_single_quote(&cmd.to_string_lossy()));
    let when = t.spec.when.trim();
    if !when.is_empty() {
        when_run.push(' ');
        when_run.push_str(when);
    }
    when_run.push('\n');
    fs::write(tmp.join(".jan-when-run.sh"), when_run)?;

    let mut buf = String::new();
    buf.push_str("#!/bin/sh\n");
    buf.push_str("# jan Given/When/Then harness — do not run by hand\n");
    buf.push_str("set -u\n");
    writeln!(
        buf,
        "export JAN_BIN={}",
        sh_single_quote(&jan_bin.to_string_lossy())
    )
    .unwrap();
    writeln!(
        buf,
        "export JAN_TEST_TMP={}",
        sh_single_quote(&tmp.to_string_lossy())
    )
    .unwrap();
    writeln!(
        buf,
        "export JAN_CWD={}",
        sh_single_quote(&ctx.cwd.to_string_lossy())
    )
    .unwrap();
    writeln!(buf, "export JAN_CMD={}", sh_single_quote(&t.command_path())).unwrap();
    buf.push_str("export JAN_NO_LOG=1\n");
    buf.push_str("cd \"$JAN_TEST_TMP\" || exit 1\n");
    buf.push_str("PATH=\"$JAN_TEST_TMP:$(dirname \"$JAN_BIN\"):$PATH\"\n");
    buf.push_str("export PATH\n\n");

    buf.push_str("# --- given ---\n");
    buf.push_str("if [ -s \"$JAN_TEST_TMP/.jan-given.sh\" ]; then\n");
    buf.push_str("  set -a\n");
    buf.push_str("  set -e\n");
    buf.push_str("  . \"$JAN_TEST_TMP/.jan-given.sh\"\n");
    buf.push_str("  set +e\n");
    buf.push_str("  set +a\n");
    buf.push_str("fi\n\n");

    buf.push_str("# --- when ---\n");
    buf.push_str("JAN_STDOUT_FILE=\"$JAN_TEST_TMP/.jan-stdout\"\n");
    buf.push_str("JAN_STDERR_FILE=\"$JAN_TEST_TMP/.jan-stderr\"\n");
    buf.push_str("set +e\n");
    buf.push_str(
        "sh \"$JAN_TEST_TMP/.jan-when-run.sh\" >\"$JAN_STDOUT_FILE\" 2>\"$JAN_STDERR_FILE\"\n",
    );
    buf.push_str("JAN_STATUS=$?\n");
    buf.push_str("JAN_STDOUT=$(cat \"$JAN_STDOUT_FILE\"; printf x)\n");
    buf.push_str("JAN_STDOUT=${JAN_STDOUT%x}\n");
    buf.push_str("JAN_STDERR=$(cat \"$JAN_STDERR_FILE\"; printf x)\n");
    buf.push_str("JAN_STDERR=${JAN_STDERR%x}\n");
    buf.push_str("export JAN_STATUS JAN_STDOUT JAN_STDERR JAN_STDOUT_FILE JAN_STDERR_FILE\n\n");

    buf.push_str("# --- then ---\n");
    buf.push_str("set -e\n");
    buf.push_str(". \"$JAN_TEST_TMP/.jan-then.sh\"\n");

    fs::write(tmp.join("test.sh"), buf)?;
    Ok(())
}

fn sh_single_quote(s: &str) -> String {
    let mut out = String::from("'");
    for c in s.chars() {
        if c == '\'' {
            out.push_str("'\\''");
        } else {
            out.push(c);
        }
    }
    out.push('\'');
    out
}

#[cfg(test)]
mod tests {
    use super::sh_single_quote;

    #[test]
    fn quotes_apostrophes() {
        assert_eq!(sh_single_quote("a'b"), "'a'\\''b'");
        assert_eq!(sh_single_quote("plain"), "'plain'");
    }
}