Skip to main content

jan_cli/
lib.rs

1mod builtins;
2mod config;
3mod deps;
4mod runner;
5mod spec_load;
6mod yaml_closure;
7
8pub use config::{load_user_config, UserConfig};
9pub use runner::run_jan;
10pub use spec_load::HostPlatform;
11
12use std::collections::BTreeMap;
13use std::ffi::OsString;
14use std::path::{Path, PathBuf};
15use std::process::Command;
16
17use anyhow::{bail, Context, Result};
18use rusqlite::Connection;
19use serde::Deserialize;
20
21#[derive(Debug, Deserialize)]
22pub struct RootSpec {
23    pub metadata: Option<Metadata>,
24    #[serde(default)]
25    pub commands: BTreeMap<String, CommandNode>,
26}
27
28#[derive(Debug, Deserialize)]
29pub struct Metadata {
30    pub name: Option<String>,
31    pub description: Option<String>,
32}
33
34#[derive(Debug, Deserialize, Default, Clone)]
35pub struct CommandNode {
36    /// If non-empty, this command and its subtree are only offered on these
37    /// platforms (`linux`, `macos`, `windows`, …). `darwin` is accepted as an alias for `macos`.
38    #[serde(default)]
39    pub os: Vec<String>,
40    #[serde(default)]
41    pub about: String,
42    /// Directory prepended to PATH when this script (or a descendant leaf) runs.
43    pub path: Option<String>,
44    /// Other script names whose `path` directories are prepended before this one runs.
45    #[serde(default)]
46    pub dependencies: Vec<String>,
47    /// External binaries that must be on PATH (e.g. `fzf`, `jq`) before the leaf runs.
48    #[serde(default)]
49    pub requires: Vec<String>,
50    /// Environment variables set on the child process (later nodes in the chain override earlier keys).
51    #[serde(default)]
52    pub env: BTreeMap<String, String>,
53    #[serde(default)]
54    pub commands: BTreeMap<String, CommandNode>,
55    pub exec: Option<ExecSpec>,
56}
57
58#[derive(Debug, Deserialize, Clone)]
59pub struct ExecSpec {
60    /// Full argument vector; first element is the program.
61    pub argv: Vec<String>,
62    /// Append extra CLI arguments after those from `argv`.
63    #[serde(default)]
64    pub passthrough: bool,
65}
66
67impl CommandNode {
68    pub fn is_leaf_exec(&self) -> bool {
69        self.exec.is_some()
70    }
71
72    pub fn validate(&self, path: &str) -> Result<()> {
73        if self.exec.is_some() && !self.commands.is_empty() {
74            bail!("command '{path}' cannot define both `exec` and nested `commands`");
75        }
76        if let Some(ref e) = self.exec {
77            if e.argv.is_empty() {
78                bail!("command '{path}': exec.argv must not be empty");
79            }
80        }
81        for (name, child) in &self.commands {
82            let p = if path.is_empty() {
83                name.clone()
84            } else {
85                format!("{path} {name}")
86            };
87            child.validate(&p)?;
88        }
89        Ok(())
90    }
91}
92
93/// Deep-merge `overlay.commands` into `base`, letting overlays add or replace leaves and
94/// extend nested groups. Used for `--extra-spec` / `--stdin-spec` fragments.
95pub fn merge_specs_into(base: &mut RootSpec, overlay: RootSpec) -> Result<()> {
96    for (name, node) in overlay.commands {
97        match base.commands.get_mut(&name) {
98            Some(existing) => merge_command_node(existing, node)?,
99            None => {
100                base.commands.insert(name, node);
101            }
102        }
103    }
104    Ok(())
105}
106
107fn merge_command_node(dst: &mut CommandNode, src: CommandNode) -> Result<()> {
108    if src.exec.is_some() && !src.commands.is_empty() {
109        bail!("merge overlay: command cannot define both `exec` and nested `commands`");
110    }
111    if !src.os.is_empty() {
112        dst.os = src.os;
113    }
114    if !src.about.trim().is_empty() {
115        dst.about = src.about;
116    }
117    if src.path.is_some() {
118        dst.path = src.path;
119    }
120    if !src.dependencies.is_empty() {
121        dst.dependencies = src.dependencies;
122    }
123    if !src.requires.is_empty() {
124        dst.requires = src.requires;
125    }
126    for (k, v) in src.env {
127        dst.env.insert(k, v);
128    }
129    if let Some(exec) = src.exec {
130        dst.exec = Some(exec);
131        dst.commands.clear();
132        return Ok(());
133    }
134    if !src.commands.is_empty() {
135        dst.exec = None;
136        for (k, child) in src.commands {
137            match dst.commands.get_mut(&k) {
138                Some(existing) => merge_command_node(existing, child)?,
139                None => {
140                    dst.commands.insert(k, child);
141                }
142            }
143        }
144    }
145    Ok(())
146}
147
148/// Validate every command in the tree (after merges or programmatic edits).
149pub fn validate_spec(spec: &RootSpec) -> Result<()> {
150    for (name, node) in &spec.commands {
151        node.validate(name)?;
152    }
153    Ok(())
154}
155
156/// Parse YAML from memory. Use `include_base` when the document uses `include:` (root or subtree).
157pub fn load_spec_from_str(raw: &str, include_base: Option<&Path>) -> Result<RootSpec> {
158    spec_load::load_spec_from_str(raw, include_base, HostPlatform::detect())
159}
160
161pub fn load_spec(path: &Path) -> Result<RootSpec> {
162    spec_load::load_spec_from_path(path, HostPlatform::detect())
163}
164
165pub fn resolve_git_branch(cwd: &Path, override_branch: Option<&str>) -> String {
166    if let Some(b) = override_branch {
167        if !b.is_empty() {
168            return b.to_string();
169        }
170    }
171    if let Ok(v) = std::env::var("JAN_BRANCH") {
172        if !v.is_empty() {
173            return v;
174        }
175    }
176    let output = Command::new("git")
177        .args(["rev-parse", "--abbrev-ref", "HEAD"])
178        .current_dir(cwd)
179        .output();
180    match output {
181        Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
182        _ => "(no-git)".to_string(),
183    }
184}
185
186fn first_line(s: &str) -> String {
187    s.lines().next().unwrap_or("").trim().to_string()
188}
189
190pub fn format_help(spec: &RootSpec, chain: &[String], node: Option<&CommandNode>) -> String {
191    let mut out = String::new();
192    let bin = spec
193        .metadata
194        .as_ref()
195        .and_then(|m| m.name.as_deref())
196        .unwrap_or("jan");
197    let full_cmd = if chain.is_empty() {
198        bin.to_string()
199    } else {
200        format!("{} {}", bin, chain.join(" "))
201    };
202
203    let (about, children, exec) = match node {
204        Some(n) => (n.about.as_str(), &n.commands, n.exec.as_ref()),
205        None => ("", &spec.commands, None),
206    };
207
208    if chain.is_empty() {
209        if let Some(meta) = &spec.metadata {
210            if let Some(desc) = &meta.description {
211                out.push_str(desc.trim());
212                out.push_str("\n\n");
213            }
214        }
215    }
216
217    if !about.is_empty() {
218        out.push_str(about.trim());
219        out.push_str("\n\n");
220    }
221
222    if exec.is_some() && children.is_empty() {
223        out.push_str("This command runs an external program (see spec `exec.argv`).\n");
224        return out;
225    }
226
227    if !children.is_empty() {
228        out.push_str("Subcommands:\n");
229        for (name, child) in children {
230            let line = if child.about.is_empty() {
231                format!("  {name}\n")
232            } else {
233                format!("  {name} — {}\n", first_line(&child.about))
234            };
235            out.push_str(&line);
236        }
237        out.push('\n');
238        out.push_str(&format!(
239            "Use `{} --help` for more about a subcommand.\n",
240            full_cmd
241        ));
242    } else if exec.is_none() {
243        out.push_str("(No subcommands defined.)\n");
244    }
245    if chain.is_empty() && node.is_none() {
246        out.push_str(
247            "\nPlace `--help` or `-h` right after the subcommand prefix you want. Built-ins: `use`, `bundle`, `alias`.\n",
248        );
249    }
250    out
251}
252
253/// Stable identity for where a YAML spec tree is rooted (directory of linked fragments + entry file).
254#[derive(Debug, Clone)]
255pub struct SpecRootIdentity {
256    /// Canonical directory containing top-level YAML fragments.
257    pub spec_dir: String,
258    /// Entry YAML file name relative to `spec_dir`.
259    pub root_yaml: String,
260}
261
262/// Resolve the preferred jan directory saved by `jan use`.
263pub fn resolve_preferred_spec() -> Result<(PathBuf, SpecRootIdentity)> {
264    let cfg = config::load_user_config().context("load user config")?;
265    let Some(dir_s) = cfg.jan_dir.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) else {
266        bail!(
267            "no preferred jan directory configured\n\
268             Run `jan use <DIR>` to save a YAML command tree (see docs/PORTABLE_SCRIPTS.md)"
269        );
270    };
271    let dir = PathBuf::from(dir_s);
272    if !dir.is_dir() {
273        bail!(
274            "preferred jan directory does not exist: {}\n\
275             Fix the path or run `jan use <DIR>` again (config: {})",
276            dir.display(),
277            config::config_path().display()
278        );
279    }
280    let root = cfg
281        .spec_root
282        .as_deref()
283        .map(str::trim)
284        .filter(|s| !s.is_empty())
285        .unwrap_or("scripts.spec.yaml");
286    resolve_spec_dir_entry(&dir, root)
287}
288
289/// Resolve a jan directory + entry file name into an absolute spec path and identity.
290pub fn resolve_spec_dir_entry(
291    spec_dir: &Path,
292    root_yaml: &str,
293) -> Result<(PathBuf, SpecRootIdentity)> {
294    let rel = Path::new(root_yaml);
295    if rel.is_absolute() {
296        bail!("entry YAML must be a relative file name, not an absolute path");
297    }
298    if rel
299        .components()
300        .any(|c| matches!(c, std::path::Component::ParentDir))
301    {
302        bail!("entry YAML must not contain `..`");
303    }
304    let normal_only = rel
305        .components()
306        .all(|c| matches!(c, std::path::Component::Normal(_)));
307    let n = rel
308        .components()
309        .filter(|c| matches!(c, std::path::Component::Normal(_)))
310        .count();
311    if !normal_only || n != 1 {
312        bail!("entry YAML must be a single file name inside the jan directory");
313    }
314    let dir = spec_dir
315        .canonicalize()
316        .with_context(|| format!("canonicalize jan directory {}", spec_dir.display()))?;
317    if !dir.is_dir() {
318        bail!("not a directory: {}", dir.display());
319    }
320    let spec_path = dir.join(rel);
321    if !spec_path.is_file() {
322        bail!(
323            "spec entry not found: {} (under {})\n\
324             Expected a properly formatted jan directory (e.g. scripts.spec.yaml)",
325            spec_path.display(),
326            dir.display()
327        );
328    }
329    let identity = SpecRootIdentity {
330        spec_dir: dir.to_string_lossy().into_owned(),
331        root_yaml: rel
332            .file_name()
333            .expect("relative root has file_name")
334            .to_string_lossy()
335            .into_owned(),
336    };
337    Ok((spec_path, identity))
338}
339
340pub struct RunContext<'a> {
341    pub cwd: &'a Path,
342    pub db_path: Option<&'a Path>,
343    pub branch: String,
344    pub no_log: bool,
345    pub spec_root: &'a SpecRootIdentity,
346}
347
348pub fn run_matched(
349    spec: &RootSpec,
350    chain: &[String],
351    node: &CommandNode,
352    trailing: &[OsString],
353    ctx: &RunContext<'_>,
354) -> Result<i32> {
355    let exec = match &node.exec {
356        Some(e) => e,
357        None => {
358            let help = format_help(spec, chain, Some(node));
359            print!("{help}");
360            bail!("missing subcommand");
361        }
362    };
363    if exec.argv.is_empty() {
364        bail!("exec.argv must not be empty");
365    }
366    let mut argv: Vec<String> = exec.argv.clone();
367    if exec.passthrough {
368        for a in trailing {
369            argv.push(a.to_string_lossy().into_owned());
370        }
371    } else if !trailing.is_empty() {
372        bail!("unexpected trailing arguments (enable exec.passthrough in the spec)");
373    }
374
375    let cmd_path = if chain.is_empty() {
376        "(root)".to_string()
377    } else {
378        chain.join(" ")
379    };
380
381    let (_, requires, _) = deps::collect_chain_metadata(chain, spec);
382    deps::check_requires(&requires)?;
383
384    let path_dirs = deps::resolve_path_prefixes(spec, chain, ctx)?;
385    let mut run_env = deps::collect_chain_env(chain, spec);
386    if !path_dirs.is_empty() {
387        run_env.insert("PATH".into(), deps::prepend_path_env(&path_dirs)?);
388    }
389
390    let mut c = Command::new(&argv[0]);
391    if argv.len() > 1 {
392        c.args(&argv[1..]);
393    }
394    c.current_dir(ctx.cwd);
395    for (key, value) in run_env {
396        c.env(key, value);
397    }
398
399    let status = c.status().with_context(|| format!("spawn `{}`", argv[0]))?;
400    let code = status.code().unwrap_or(255);
401
402    if !ctx.no_log {
403        if let Some(db) = ctx.db_path {
404            log_invocation(
405                db,
406                &ctx.branch,
407                ctx.cwd,
408                &cmd_path,
409                &argv,
410                code,
411                ctx.spec_root,
412            )?;
413        }
414    }
415
416    Ok(code)
417}
418
419fn ensure_invocations_spec_root_column(conn: &Connection) -> Result<()> {
420    let mut stmt = conn.prepare("PRAGMA table_info(invocations)")?;
421    let cols: Vec<String> = stmt
422        .query_map([], |row| row.get::<_, String>(1))?
423        .collect::<std::result::Result<_, _>>()?;
424    if !cols.iter().any(|c| c == "spec_root_id") {
425        conn.execute(
426            "ALTER TABLE invocations ADD COLUMN spec_root_id INTEGER",
427            [],
428        )?;
429    }
430    Ok(())
431}
432
433fn upsert_spec_root(conn: &Connection, spec: &SpecRootIdentity) -> Result<i64> {
434    let ts = unix_ts();
435    conn.execute(
436        r"INSERT INTO spec_roots (spec_dir, root_yaml, last_used_ts) VALUES (?1, ?2, ?3)
437          ON CONFLICT(spec_dir, root_yaml) DO UPDATE SET last_used_ts = excluded.last_used_ts",
438        rusqlite::params![&spec.spec_dir, &spec.root_yaml, &ts],
439    )?;
440    let id: i64 = conn.query_row(
441        "SELECT id FROM spec_roots WHERE spec_dir = ?1 AND root_yaml = ?2",
442        [&spec.spec_dir, &spec.root_yaml],
443        |r| r.get(0),
444    )?;
445    Ok(id)
446}
447
448fn log_invocation(
449    db_path: &Path,
450    branch: &str,
451    cwd: &Path,
452    command_path: &str,
453    argv: &[String],
454    exit_code: i32,
455    spec_root: &SpecRootIdentity,
456) -> Result<()> {
457    if let Some(parent) = db_path.parent() {
458        std::fs::create_dir_all(parent).ok();
459    }
460    let conn = Connection::open(db_path).with_context(|| format!("open {}", db_path.display()))?;
461    conn.execute_batch(
462        r"
463        CREATE TABLE IF NOT EXISTS spec_roots (
464            id INTEGER PRIMARY KEY AUTOINCREMENT,
465            spec_dir TEXT NOT NULL,
466            root_yaml TEXT NOT NULL,
467            last_used_ts TEXT NOT NULL,
468            UNIQUE(spec_dir, root_yaml)
469        );
470        CREATE TABLE IF NOT EXISTS invocations (
471            id INTEGER PRIMARY KEY AUTOINCREMENT,
472            ts TEXT NOT NULL,
473            git_branch TEXT NOT NULL,
474            cwd TEXT NOT NULL,
475            command_path TEXT NOT NULL,
476            argv_json TEXT NOT NULL,
477            exit_code INTEGER NOT NULL,
478            spec_root_id INTEGER
479        );
480        ",
481    )?;
482    ensure_invocations_spec_root_column(&conn)?;
483    let spec_root_id = upsert_spec_root(&conn, spec_root)?;
484    let ts = unix_ts();
485    let argv_json = serde_json::to_string(argv).unwrap_or_else(|_| "[]".to_string());
486    let cwd_s = cwd.to_string_lossy();
487    conn.execute(
488        "INSERT INTO invocations (ts, git_branch, cwd, command_path, argv_json, exit_code, spec_root_id)
489         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
490        rusqlite::params![
491            ts,
492            branch,
493            cwd_s.as_ref(),
494            command_path,
495            argv_json,
496            exit_code,
497            spec_root_id
498        ],
499    )?;
500    Ok(())
501}
502
503fn unix_ts() -> String {
504    use std::time::SystemTime;
505    SystemTime::now()
506        .duration_since(std::time::UNIX_EPOCH)
507        .unwrap_or_default()
508        .as_secs()
509        .to_string()
510}
511
512#[derive(Debug)]
513pub struct MatchOutcome<'a> {
514    pub chain: Vec<String>,
515    pub node: Option<&'a CommandNode>,
516    pub trailing: Vec<OsString>,
517    pub wants_help: bool,
518}
519
520pub fn match_commands<'a>(spec: &'a RootSpec, args: &[OsString]) -> MatchOutcome<'a> {
521    let mut chain = Vec::new();
522    let mut node: Option<&'a CommandNode> = None;
523    let mut map: &BTreeMap<String, CommandNode> = &spec.commands;
524    let mut i = 0usize;
525    let len = args.len();
526    while i < len {
527        let raw = &args[i];
528        if raw == "--help" || raw == "-h" {
529            return MatchOutcome {
530                chain,
531                node,
532                trailing: args[i + 1..].to_vec(),
533                wants_help: true,
534            };
535        }
536        let key = raw.to_string_lossy();
537        if let Some(next) = map.get(key.as_ref()) {
538            chain.push(key.into_owned());
539            node = Some(next);
540            map = &next.commands;
541            i += 1;
542            continue;
543        }
544        break;
545    }
546    MatchOutcome {
547        chain,
548        node,
549        trailing: args[i..].to_vec(),
550        wants_help: false,
551    }
552}
553
554#[cfg(test)]
555mod tests {
556    use super::*;
557    use std::io::Write;
558
559    #[test]
560    fn examples_default_spec_validates() {
561        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/default.spec.yaml");
562        load_spec(&path).unwrap();
563    }
564
565    #[test]
566    fn merge_specs_adds_and_replaces_leaves() {
567        let mut base = load_spec_from_str(
568            r"
569commands:
570  a:
571    about: base
572    commands:
573      x:
574        about: old
575        exec:
576          argv: [echo, old]
577",
578            None,
579        )
580        .unwrap();
581        let overlay = load_spec_from_str(
582            r"
583commands:
584  a:
585    commands:
586      x:
587        about: new leaf
588        exec:
589          argv: [echo, new]
590  b:
591    about: added top
592    exec:
593      argv: [echo, b]
594",
595            None,
596        )
597        .unwrap();
598        merge_specs_into(&mut base, overlay).unwrap();
599        base.commands["a"].commands["x"].validate("a x").unwrap();
600        assert_eq!(
601            base.commands["a"].commands["x"].exec.as_ref().unwrap().argv,
602            vec!["echo", "new"]
603        );
604        assert_eq!(
605            base.commands["b"].exec.as_ref().unwrap().argv,
606            vec!["echo", "b"]
607        );
608    }
609
610    #[test]
611    fn validate_rejects_exec_with_children() {
612        let mut tmp = tempfile::NamedTempFile::with_suffix(".yaml").unwrap();
613        write!(
614            tmp,
615            r"
616commands:
617  x:
618    exec:
619      argv: [echo]
620    commands:
621      child:
622        about: nested
623"
624        )
625        .unwrap();
626        let err = load_spec(tmp.path()).unwrap_err();
627        assert!(err.to_string().contains("cannot define both"));
628    }
629}
630
631pub fn default_db_path() -> PathBuf {
632    if let Ok(p) = std::env::var("JAN_DB") {
633        return PathBuf::from(p);
634    }
635    dirs::data_local_dir()
636        .unwrap_or_else(|| PathBuf::from("."))
637        .join("jan-cli")
638        .join("audit.db")
639}
640