Skip to main content

jan_cli/
lib.rs

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