jan-cli 0.1.0

YAML-defined CLI trees with progressive help, optional exec aliases, merged extra specs, and SQLite audit logging keyed by git branch
Documentation
use std::ffi::OsString;
use std::io::{Read, Write};
use std::path::PathBuf;

use anyhow::{anyhow, bail, Context, Result};
use clap::Parser;
use tempfile::NamedTempFile;

use crate::{
    builtins, default_db_path, embedded_spec_identity, format_help, load_embedded_default_spec, load_spec,
    match_commands, merge_specs_into, resolve_git_branch, resolve_spec_dir_entry, resolve_spec_path,
    run_matched, spec_identity_for_spec_file, validate_spec, RootSpec, RunContext, SpecRootIdentity,
};

#[derive(Parser, Debug)]
#[command(name = "jan")]
#[command(
    about = "YAML-driven command tree: progressive --help, optional exec aliases, SQLite audit log keyed by git branch",
    version
)]
pub struct JanCli {
    /// Directory of top-level YAML fragments (linked via `include:`); entry file is `--spec-root`
    #[arg(long, value_name = "DIR", env = "JAN_SPEC_DIR", global = true)]
    pub spec_dir: Option<PathBuf>,

    /// YAML file name inside `--spec-dir` (default: jan.spec.yaml)
    #[arg(long, value_name = "NAME", default_value = "jan.spec.yaml", global = true)]
    pub spec_root: String,

    /// Path to the YAML command specification (root file; parent directory is recorded as the spec dir)
    #[arg(long, value_name = "FILE", env = "JAN_SPEC", global = true)]
    pub spec: Option<PathBuf>,

    /// SQLite database path for invocation audit log
    #[arg(long, value_name = "FILE", env = "JAN_DB", global = true)]
    pub db: Option<PathBuf>,

    /// Override git branch recorded in the audit log (default: `git rev-parse` or JAN_BRANCH)
    #[arg(long, global = true)]
    pub branch: Option<String>,

    /// Do not write to the SQLite audit log
    #[arg(long, global = true)]
    pub no_log: bool,

    /// Working directory for subprocesses and git branch detection
    #[arg(long, global = true, default_value = ".")]
    pub cwd: PathBuf,

    /// Merge another YAML fragment into the loaded command tree (repeatable). See also `JAN_EXTRA_SPEC` (comma-separated paths).
    #[arg(long = "extra-spec", value_name = "FILE", global = true)]
    pub extra_spec: Vec<PathBuf>,

    /// Read YAML from stdin and merge it like `--extra-spec` (written to a temporary file for parsing).
    #[arg(long, global = true)]
    pub stdin_spec: bool,

    /// Print how the spec was resolved and which extra fragments were merged (stderr).
    #[arg(short, long, global = true)]
    pub verbose: bool,

    /// Spec-defined subcommands, then optional passthrough args for `exec.passthrough`
    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
    pub command: Vec<OsString>,
}

fn extra_paths_from_env() -> Vec<PathBuf> {
    std::env::var("JAN_EXTRA_SPEC")
        .ok()
        .map(|s| {
            s.split(',')
                .map(|p| PathBuf::from(p.trim()))
                .filter(|p| !p.as_os_str().is_empty())
                .collect()
        })
        .unwrap_or_default()
}

pub fn run_jan() -> Result<i32> {
    let cli = JanCli::parse();
    let cwd = cli
        .cwd
        .canonicalize()
        .unwrap_or_else(|_| cli.cwd.clone());

    if cli.spec_dir.is_some() && cli.spec.is_some() {
        bail!("use either --spec-dir or --spec, not both");
    }

    let mut extra_paths = extra_paths_from_env();
    extra_paths.extend(cli.extra_spec.iter().cloned());

    let mut _stdin_temp: Option<NamedTempFile> = None;
    if cli.stdin_spec {
        let mut buf = String::new();
        std::io::stdin()
            .read_to_string(&mut buf)
            .context("read stdin for --stdin-spec")?;
        if buf.trim().is_empty() {
            buf = "commands: {}\n".to_string();
        }
        let mut tmp = NamedTempFile::with_suffix(".yaml").context("temp file for --stdin-spec")?;
        tmp.write_all(buf.as_bytes())?;
        tmp.flush()?;
        let p = tmp.path().to_path_buf();
        _stdin_temp = Some(tmp);
        extra_paths.push(p);
    }

    let (mut spec, spec_identity): (RootSpec, SpecRootIdentity) =
        if let Some(dir) = cli.spec_dir.clone() {
            let (path, id) =
                resolve_spec_dir_entry(&dir, &cli.spec_root, &cwd).context("resolve --spec-dir")?;
            (
                load_spec(&path).with_context(|| format!("load {}", path.display()))?,
                id,
            )
        } else if let Some(path) = resolve_spec_path(cli.spec, &cwd)? {
            let id = spec_identity_for_spec_file(&path)?;
            (
                load_spec(&path).with_context(|| format!("load {}", path.display()))?,
                id,
            )
        } else {
            (
                load_embedded_default_spec().context("load embedded default spec")?,
                embedded_spec_identity(),
            )
        };

    if !extra_paths.is_empty() {
        if cli.verbose {
            eprintln!("jan: merging {} extra spec fragment(s)", extra_paths.len());
            for p in &extra_paths {
                eprintln!("jan:   {}", p.display());
            }
        }
        for p in &extra_paths {
            let overlay =
                load_spec(p).with_context(|| format!("load extra spec {}", p.display()))?;
            merge_specs_into(&mut spec, overlay).with_context(|| format!("merge {}", p.display()))?;
        }
        validate_spec(&spec).context("validate merged spec")?;
    }

    if cli.verbose {
        eprintln!("jan: cwd={}", cwd.display());
        eprintln!(
            "jan: spec identity: {} / {}",
            spec_identity.spec_dir, spec_identity.root_yaml
        );
    }

    let branch = resolve_git_branch(&cwd, cli.branch.as_deref());
    let db_path = if cli.no_log {
        None
    } else {
        Some(cli.db.as_ref().cloned().unwrap_or_else(default_db_path))
    };

    let ctx = RunContext {
        cwd: &cwd,
        db_path: db_path.as_deref(),
        branch,
        no_log: cli.no_log,
        spec_root: &spec_identity,
    };

    if cli.command.is_empty() {
        let help = format_help(&spec, &[], None);
        print!("{help}");
        return Ok(0);
    }

    if let Some(first) = cli.command.first() {
        let key = first.to_string_lossy();
        if builtins::is_builtin_reserved(key.as_ref()) {
            let tail: Vec<_> = cli.command[1..].to_vec();
            return match key.as_ref() {
                "bundle" => builtins::bundle_spec_zip(&spec_identity, &extra_paths, &tail, cli.verbose),
                "alias" => builtins::emit_shell_aliases(&spec, &spec_identity, &tail),
                _ => Ok(0),
            };
        }
    }

    let m = match_commands(&spec, &cli.command);

    if m.wants_help {
        let help = format_help(&spec, &m.chain, m.node);
        print!("{help}");
        return Ok(0);
    }

    if m.trailing.iter().any(|a| a == "--help" || a == "-h") {
        return Err(anyhow!(
            "place --help immediately after the subcommand prefix you want help for"
        ));
    }

    let node = match m.node {
        Some(n) => n,
        None => {
            let key = cli.command[0].to_string_lossy();
            return Err(anyhow!("unknown top-level command `{key}`"));
        }
    };

    if !m.trailing.is_empty() && !node.is_leaf_exec() {
        let t = m.trailing[0].to_string_lossy();
        return Err(anyhow!("unknown subcommand `{t}`"));
    }

    if node.is_leaf_exec() {
        return run_matched(&spec, &m.chain, node, &m.trailing, &ctx);
    }

    let help = format_help(&spec, &m.chain, Some(node));
    print!("{help}");
    Ok(0)
}