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