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    if cli.spec_dir.is_some() && cli.spec.is_some() {
90        bail!("use either --spec-dir or --spec, not both");
91    }
92
93    let mut extra_paths = extra_paths_from_env();
94    extra_paths.extend(cli.extra_spec.iter().cloned());
95
96    let mut _stdin_temp: Option<NamedTempFile> = None;
97    if cli.stdin_spec {
98        let mut buf = String::new();
99        std::io::stdin()
100            .read_to_string(&mut buf)
101            .context("read stdin for --stdin-spec")?;
102        if buf.trim().is_empty() {
103            buf = "commands: {}\n".to_string();
104        }
105        let mut tmp = NamedTempFile::with_suffix(".yaml").context("temp file for --stdin-spec")?;
106        tmp.write_all(buf.as_bytes())?;
107        tmp.flush()?;
108        let p = tmp.path().to_path_buf();
109        _stdin_temp = Some(tmp);
110        extra_paths.push(p);
111    }
112
113    let (mut spec, spec_identity): (RootSpec, SpecRootIdentity) =
114        if let Some(dir) = cli.spec_dir.clone() {
115            let (path, id) =
116                resolve_spec_dir_entry(&dir, &cli.spec_root, &cwd).context("resolve --spec-dir")?;
117            (
118                load_spec(&path).with_context(|| format!("load {}", path.display()))?,
119                id,
120            )
121        } else if let Some(path) = resolve_spec_path(cli.spec, &cwd)? {
122            let id = spec_identity_for_spec_file(&path)?;
123            (
124                load_spec(&path).with_context(|| format!("load {}", path.display()))?,
125                id,
126            )
127        } else {
128            let (path, id) = resolve_well_known_spec(&cwd).context("resolve well-known spec")?;
129            (
130                load_spec(&path).with_context(|| format!("load {}", path.display()))?,
131                id,
132            )
133        };
134
135    if !extra_paths.is_empty() {
136        if cli.verbose {
137            eprintln!("jan: merging {} extra spec fragment(s)", extra_paths.len());
138            for p in &extra_paths {
139                eprintln!("jan:   {}", p.display());
140            }
141        }
142        for p in &extra_paths {
143            let overlay =
144                load_spec(p).with_context(|| format!("load extra spec {}", p.display()))?;
145            merge_specs_into(&mut spec, overlay)
146                .with_context(|| format!("merge {}", p.display()))?;
147        }
148        validate_spec(&spec).context("validate merged spec")?;
149    }
150
151    if cli.verbose {
152        eprintln!("jan: cwd={}", cwd.display());
153        eprintln!(
154            "jan: spec identity: {} / {}",
155            spec_identity.spec_dir, spec_identity.root_yaml
156        );
157    }
158
159    let branch = resolve_git_branch(&cwd, cli.branch.as_deref());
160    let db_path = if cli.no_log {
161        None
162    } else {
163        Some(cli.db.as_ref().cloned().unwrap_or_else(default_db_path))
164    };
165
166    let ctx = RunContext {
167        cwd: &cwd,
168        db_path: db_path.as_deref(),
169        branch,
170        no_log: cli.no_log,
171        spec_root: &spec_identity,
172    };
173
174    if cli.command.is_empty() {
175        let help = format_help(&spec, &[], None);
176        print!("{help}");
177        return Ok(0);
178    }
179
180    if let Some(first) = cli.command.first() {
181        let key = first.to_string_lossy();
182        if builtins::is_builtin_reserved(key.as_ref()) {
183            let tail: Vec<_> = cli.command[1..].to_vec();
184            return match key.as_ref() {
185                "bundle" => {
186                    builtins::bundle_spec_zip(&spec_identity, &extra_paths, &tail, cli.verbose)
187                }
188                "alias" => builtins::emit_shell_aliases(&spec, &spec_identity, &tail),
189                _ => Ok(0),
190            };
191        }
192    }
193
194    let m = match_commands(&spec, &cli.command);
195
196    if m.wants_help {
197        let help = format_help(&spec, &m.chain, m.node);
198        print!("{help}");
199        return Ok(0);
200    }
201
202    if m.trailing.iter().any(|a| a == "--help" || a == "-h") {
203        return Err(anyhow!(
204            "place --help immediately after the subcommand prefix you want help for"
205        ));
206    }
207
208    let node = match m.node {
209        Some(n) => n,
210        None => {
211            let key = cli.command[0].to_string_lossy();
212            return Err(anyhow!("unknown top-level command `{key}`"));
213        }
214    };
215
216    if !m.trailing.is_empty() && !node.is_leaf_exec() {
217        let t = m.trailing[0].to_string_lossy();
218        return Err(anyhow!("unknown subcommand `{t}`"));
219    }
220
221    if node.is_leaf_exec() {
222        return run_matched(&spec, &m.chain, node, &m.trailing, &ctx);
223    }
224
225    let help = format_help(&spec, &m.chain, Some(node));
226    print!("{help}");
227    Ok(0)
228}