Skip to main content

jan_cli/
runner.rs

1use std::ffi::OsString;
2use std::io::{Read, Write};
3use std::path::PathBuf;
4
5use anyhow::{anyhow, bail, Context, Result};
6use clap::Parser;
7use tempfile::NamedTempFile;
8
9use crate::{
10    builtins, default_db_path, embedded_spec_identity, format_help, load_embedded_default_spec, load_spec,
11    match_commands, merge_specs_into, resolve_git_branch, resolve_spec_dir_entry, resolve_spec_path,
12    run_matched, spec_identity_for_spec_file, validate_spec, RootSpec, RunContext, SpecRootIdentity,
13};
14
15#[derive(Parser, Debug)]
16#[command(name = "jan")]
17#[command(
18    about = "YAML-driven command tree: progressive --help, optional exec aliases, SQLite audit log keyed by git branch",
19    version
20)]
21pub struct JanCli {
22    /// Directory of top-level YAML fragments (linked via `include:`); entry file is `--spec-root`
23    #[arg(long, value_name = "DIR", env = "JAN_SPEC_DIR", global = true)]
24    pub spec_dir: Option<PathBuf>,
25
26    /// YAML file name inside `--spec-dir` (default: jan.spec.yaml)
27    #[arg(long, value_name = "NAME", default_value = "jan.spec.yaml", global = true)]
28    pub spec_root: String,
29
30    /// Path to the YAML command specification (root file; parent directory is recorded as the spec dir)
31    #[arg(long, value_name = "FILE", env = "JAN_SPEC", global = true)]
32    pub spec: Option<PathBuf>,
33
34    /// SQLite database path for invocation audit log
35    #[arg(long, value_name = "FILE", env = "JAN_DB", global = true)]
36    pub db: Option<PathBuf>,
37
38    /// Override git branch recorded in the audit log (default: `git rev-parse` or JAN_BRANCH)
39    #[arg(long, global = true)]
40    pub branch: Option<String>,
41
42    /// Do not write to the SQLite audit log
43    #[arg(long, global = true)]
44    pub no_log: bool,
45
46    /// Working directory for subprocesses and git branch detection
47    #[arg(long, global = true, default_value = ".")]
48    pub cwd: PathBuf,
49
50    /// Merge another YAML fragment into the loaded command tree (repeatable). See also `JAN_EXTRA_SPEC` (comma-separated paths).
51    #[arg(long = "extra-spec", value_name = "FILE", global = true)]
52    pub extra_spec: Vec<PathBuf>,
53
54    /// Read YAML from stdin and merge it like `--extra-spec` (written to a temporary file for parsing).
55    #[arg(long, global = true)]
56    pub stdin_spec: bool,
57
58    /// Print how the spec was resolved and which extra fragments were merged (stderr).
59    #[arg(short, long, global = true)]
60    pub verbose: bool,
61
62    /// Spec-defined subcommands, then optional passthrough args for `exec.passthrough`
63    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
64    pub command: Vec<OsString>,
65}
66
67fn extra_paths_from_env() -> Vec<PathBuf> {
68    std::env::var("JAN_EXTRA_SPEC")
69        .ok()
70        .map(|s| {
71            s.split(',')
72                .map(|p| PathBuf::from(p.trim()))
73                .filter(|p| !p.as_os_str().is_empty())
74                .collect()
75        })
76        .unwrap_or_default()
77}
78
79pub fn run_jan() -> Result<i32> {
80    let cli = JanCli::parse();
81    let cwd = cli
82        .cwd
83        .canonicalize()
84        .unwrap_or_else(|_| cli.cwd.clone());
85
86    if cli.spec_dir.is_some() && cli.spec.is_some() {
87        bail!("use either --spec-dir or --spec, not both");
88    }
89
90    let mut extra_paths = extra_paths_from_env();
91    extra_paths.extend(cli.extra_spec.iter().cloned());
92
93    let mut _stdin_temp: Option<NamedTempFile> = None;
94    if cli.stdin_spec {
95        let mut buf = String::new();
96        std::io::stdin()
97            .read_to_string(&mut buf)
98            .context("read stdin for --stdin-spec")?;
99        if buf.trim().is_empty() {
100            buf = "commands: {}\n".to_string();
101        }
102        let mut tmp = NamedTempFile::with_suffix(".yaml").context("temp file for --stdin-spec")?;
103        tmp.write_all(buf.as_bytes())?;
104        tmp.flush()?;
105        let p = tmp.path().to_path_buf();
106        _stdin_temp = Some(tmp);
107        extra_paths.push(p);
108    }
109
110    let (mut spec, spec_identity): (RootSpec, SpecRootIdentity) =
111        if let Some(dir) = cli.spec_dir.clone() {
112            let (path, id) =
113                resolve_spec_dir_entry(&dir, &cli.spec_root, &cwd).context("resolve --spec-dir")?;
114            (
115                load_spec(&path).with_context(|| format!("load {}", path.display()))?,
116                id,
117            )
118        } else if let Some(path) = resolve_spec_path(cli.spec, &cwd)? {
119            let id = spec_identity_for_spec_file(&path)?;
120            (
121                load_spec(&path).with_context(|| format!("load {}", path.display()))?,
122                id,
123            )
124        } else {
125            (
126                load_embedded_default_spec().context("load embedded default spec")?,
127                embedded_spec_identity(),
128            )
129        };
130
131    if !extra_paths.is_empty() {
132        if cli.verbose {
133            eprintln!("jan: merging {} extra spec fragment(s)", extra_paths.len());
134            for p in &extra_paths {
135                eprintln!("jan:   {}", p.display());
136            }
137        }
138        for p in &extra_paths {
139            let overlay =
140                load_spec(p).with_context(|| format!("load extra spec {}", p.display()))?;
141            merge_specs_into(&mut spec, overlay).with_context(|| format!("merge {}", p.display()))?;
142        }
143        validate_spec(&spec).context("validate merged spec")?;
144    }
145
146    if cli.verbose {
147        eprintln!("jan: cwd={}", cwd.display());
148        eprintln!(
149            "jan: spec identity: {} / {}",
150            spec_identity.spec_dir, spec_identity.root_yaml
151        );
152    }
153
154    let branch = resolve_git_branch(&cwd, cli.branch.as_deref());
155    let db_path = if cli.no_log {
156        None
157    } else {
158        Some(cli.db.as_ref().cloned().unwrap_or_else(default_db_path))
159    };
160
161    let ctx = RunContext {
162        cwd: &cwd,
163        db_path: db_path.as_deref(),
164        branch,
165        no_log: cli.no_log,
166        spec_root: &spec_identity,
167    };
168
169    if cli.command.is_empty() {
170        let help = format_help(&spec, &[], None);
171        print!("{help}");
172        return Ok(0);
173    }
174
175    if let Some(first) = cli.command.first() {
176        let key = first.to_string_lossy();
177        if builtins::is_builtin_reserved(key.as_ref()) {
178            let tail: Vec<_> = cli.command[1..].to_vec();
179            return match key.as_ref() {
180                "bundle" => builtins::bundle_spec_zip(&spec_identity, &extra_paths, &tail, cli.verbose),
181                "alias" => builtins::emit_shell_aliases(&spec, &spec_identity, &tail),
182                _ => Ok(0),
183            };
184        }
185    }
186
187    let m = match_commands(&spec, &cli.command);
188
189    if m.wants_help {
190        let help = format_help(&spec, &m.chain, m.node);
191        print!("{help}");
192        return Ok(0);
193    }
194
195    if m.trailing.iter().any(|a| a == "--help" || a == "-h") {
196        return Err(anyhow!(
197            "place --help immediately after the subcommand prefix you want help for"
198        ));
199    }
200
201    let node = match m.node {
202        Some(n) => n,
203        None => {
204            let key = cli.command[0].to_string_lossy();
205            return Err(anyhow!("unknown top-level command `{key}`"));
206        }
207    };
208
209    if !m.trailing.is_empty() && !node.is_leaf_exec() {
210        let t = m.trailing[0].to_string_lossy();
211        return Err(anyhow!("unknown subcommand `{t}`"));
212    }
213
214    if node.is_leaf_exec() {
215        return run_matched(&spec, &m.chain, node, &m.trailing, &ctx);
216    }
217
218    let help = format_help(&spec, &m.chain, Some(node));
219    print!("{help}");
220    Ok(0)
221}