Skip to main content

jan_cli/
runner.rs

1use std::ffi::OsString;
2use std::path::PathBuf;
3
4use anyhow::{anyhow, Context, Result};
5use clap::{ArgAction, Parser};
6
7use crate::{
8    builtins, default_db_path, format_help, load_spec, match_commands, resolve_git_branch,
9    resolve_preferred_spec, run_matched, RootSpec, RunContext,
10};
11
12#[derive(Parser, Debug)]
13#[command(name = "jan")]
14#[command(
15    about = "YAML-driven command tree loaded from the preferred directory (`jan use`)",
16    version,
17    disable_help_flag = true
18)]
19pub struct JanCli {
20    /// Print help for the loaded command tree (or framework help if none is configured)
21    #[arg(long, short = 'h', action = ArgAction::SetTrue, global = true)]
22    pub help: bool,
23
24    /// SQLite database path for invocation audit log
25    #[arg(long, value_name = "FILE", env = "JAN_DB", global = true)]
26    pub db: Option<PathBuf>,
27
28    /// Override git branch recorded in the audit log (default: `git rev-parse` or JAN_BRANCH)
29    #[arg(long, global = true)]
30    pub branch: Option<String>,
31
32    /// Do not write to the SQLite audit log
33    #[arg(long, global = true)]
34    pub no_log: bool,
35
36    /// Working directory for subprocesses and git branch detection
37    #[arg(long, global = true, default_value = ".")]
38    pub cwd: PathBuf,
39
40    /// Print how the preferred spec was resolved (stderr)
41    #[arg(short, long, global = true)]
42    pub verbose: bool,
43
44    /// Spec-defined subcommands, then optional passthrough args for `exec.passthrough`
45    #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
46    pub command: Vec<OsString>,
47}
48
49fn framework_builtins_help() -> String {
50    "\
51Built-in commands:
52  use — set, show, or clear the preferred jan directory
53  bundle — pack the preferred YAML tree into a ZIP
54  alias — emit shell aliases for executable leaves
55  list — list script leaves in the preferred tree
56  search — find scripts by name/about/category
57  show — print details for a script leaf
58  validate — structural checks for the preferred tree
59  audit — query the SQLite invocation log
60  cron — run scripts whose `cron:` schedule matches now
61
62Global options:
63  -h, --help       Show this help (lists live subcommands when a preferred dir is set)
64  -V, --version    Print version
65  -v, --verbose    Explain how the preferred directory was resolved
66      --cwd <DIR>  Working directory for subprocesses (default: .)
67      --db <FILE>  SQLite audit log path
68      --branch <B> Override git branch recorded in the audit log
69      --no-log     Do not write to the audit log
70
71Configure a command tree with `jan use <DIR>`, then re-run `jan --help` to list live subcommands.
72"
73    .to_string()
74}
75
76fn print_root_help(spec: Option<&RootSpec>) {
77    match spec {
78        Some(spec) => {
79            print!("{}", format_help(spec, &[], None));
80        }
81        None => {
82            println!("jan — YAML-driven command tree\n");
83            println!("No preferred jan directory is configured yet.\n");
84            println!("  jan use <DIR>     Save a directory containing scripts.spec.yaml");
85            println!("  jan use --show    Show the saved preference\n");
86        }
87    }
88    print!("{}", framework_builtins_help());
89}
90
91pub fn run_jan() -> Result<i32> {
92    let cli = JanCli::parse();
93    let cwd = cli.cwd.canonicalize().unwrap_or_else(|_| cli.cwd.clone());
94
95    // Bootstrap builtins that must work without a loaded YAML tree.
96    if let Some(first) = cli.command.first() {
97        let key = first.to_string_lossy();
98        if builtins::is_pre_spec_builtin(key.as_ref()) {
99            let tail: Vec<_> = cli.command[1..].to_vec();
100            return match key.as_ref() {
101                "use" => builtins::run_use(&tail),
102                _ => Ok(0),
103            };
104        }
105    }
106
107    let loaded = match resolve_preferred_spec() {
108        Ok(pair) => Some(pair),
109        Err(e) => {
110            if cli.help || cli.command.is_empty() {
111                print_root_help(None);
112                if cli.verbose {
113                    eprintln!("jan: no preferred spec ({e:#})");
114                }
115                return Ok(0);
116            }
117            return Err(e);
118        }
119    };
120
121    let (spec_path, spec_identity) = loaded.expect("Ok branch always sets Some");
122    let spec = load_spec(&spec_path).with_context(|| format!("load {}", spec_path.display()))?;
123
124    if cli.verbose {
125        eprintln!("jan: cwd={}", cwd.display());
126        eprintln!(
127            "jan: preferred directory: {} / {}",
128            spec_identity.spec_dir, spec_identity.root_yaml
129        );
130    }
131
132    let branch = resolve_git_branch(&cwd, cli.branch.as_deref());
133    let audit_db = cli.db.as_ref().cloned().unwrap_or_else(default_db_path);
134    let db_path = if cli.no_log {
135        None
136    } else {
137        Some(audit_db.clone())
138    };
139
140    let ctx = RunContext {
141        cwd: &cwd,
142        db_path: db_path.as_deref(),
143        branch,
144        no_log: cli.no_log,
145        spec_root: &spec_identity,
146    };
147
148    if cli.command.is_empty() {
149        print_root_help(Some(&spec));
150        return Ok(0);
151    }
152
153    if let Some(first) = cli.command.first() {
154        let key = first.to_string_lossy();
155        if builtins::is_builtin_reserved(key.as_ref()) {
156            let tail: Vec<_> = cli.command[1..].to_vec();
157            return match key.as_ref() {
158                "bundle" => builtins::bundle_spec_zip(&spec_identity, &tail, cli.verbose),
159                "alias" => builtins::emit_shell_aliases(&spec, &tail),
160                "use" => builtins::run_use(&tail),
161                "list" | "search" | "show" | "validate" => {
162                    let root = PathBuf::from(&spec_identity.spec_dir);
163                    crate::inspect::dispatch_inspect(
164                        key.as_ref(),
165                        &tail,
166                        &spec,
167                        None,
168                        Some(&root),
169                    )
170                }
171                "audit" => {
172                    crate::inspect::dispatch_inspect(
173                        key.as_ref(),
174                        &tail,
175                        &spec,
176                        Some(&audit_db),
177                        None,
178                    )
179                }
180                "cron" => crate::inspect::run_cron(&spec, &tail, &ctx),
181                _ => Ok(0),
182            };
183        }
184    }
185
186    let m = match_commands(&spec, &cli.command);
187
188    if cli.help || m.wants_help {
189        let help = format_help(&spec, &m.chain, m.node);
190        print!("{help}");
191        return Ok(0);
192    }
193
194    if m.trailing.iter().any(|a| a == "--help" || a == "-h") {
195        return Err(anyhow!(
196            "place --help immediately after the subcommand prefix you want help for"
197        ));
198    }
199
200    let node = match m.node {
201        Some(n) => n,
202        None => {
203            let key = cli.command[0].to_string_lossy();
204            return Err(anyhow!("unknown top-level command `{key}`"));
205        }
206    };
207
208    if !m.trailing.is_empty() && !node.is_leaf_exec() {
209        let t = m.trailing[0].to_string_lossy();
210        return Err(anyhow!("unknown subcommand `{t}`"));
211    }
212
213    if node.is_leaf_exec() {
214        return run_matched(&spec, &m.chain, node, &m.trailing, &ctx);
215    }
216
217    let help = format_help(&spec, &m.chain, Some(node));
218    print!("{help}");
219    Ok(0)
220}