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 => (
206            spec.metadata
207                .as_ref()
208                .and_then(|m| m.description.as_deref())
209                .unwrap_or(""),
210            &spec.commands,
211            None,
212        ),
213    };
214
215    if chain.is_empty() {
216        if let Some(meta) = &spec.metadata {
217            if let Some(desc) = &meta.description {
218                out.push_str(desc.trim());
219                out.push_str("\n\n");
220            }
221        }
222    }
223
224    if !about.is_empty() {
225        out.push_str(about.trim());
226        out.push_str("\n\n");
227    }
228
229    if exec.is_some() && children.is_empty() {
230        out.push_str("This command runs an external program (see spec `exec.argv`).\n");
231        return out;
232    }
233
234    if !children.is_empty() {
235        out.push_str("Subcommands:\n");
236        for (name, child) in children {
237            let line = if child.about.is_empty() {
238                format!("  {name}\n")
239            } else {
240                format!("  {name} — {}\n", first_line(&child.about))
241            };
242            out.push_str(&line);
243        }
244        out.push('\n');
245        out.push_str(&format!(
246            "Use `{} --help` for more about a subcommand.\n",
247            full_cmd
248        ));
249    } else if exec.is_none() {
250        out.push_str("(No subcommands defined.)\n");
251    }
252    if chain.is_empty() && node.is_none() {
253        out.push_str(
254            "\nFramework: place `--help` or `-h` right after the subcommand prefix you want; run `jan --help` for global flags (`--verbose`, `--extra-spec`, `--stdin-spec`, …).\n",
255        );
256    }
257    out
258}
259
260/// Stable identity for where a YAML spec tree is rooted (directory of linked fragments + entry file).
261#[derive(Debug, Clone)]
262pub struct SpecRootIdentity {
263    /// Canonical directory containing top-level YAML fragments.
264    pub spec_dir: String,
265    /// Entry YAML file name relative to `spec_dir`.
266    pub root_yaml: String,
267}
268
269/// Default install directory for a spec tree (`~/.config/jan/scripts`, overridable via `JAN_INSTALL_DIR`).
270pub fn well_known_spec_dir() -> PathBuf {
271    if let Ok(p) = std::env::var("JAN_INSTALL_DIR") {
272        let p = p.trim();
273        if !p.is_empty() {
274            return PathBuf::from(p);
275        }
276    }
277    dirs::config_dir()
278        .unwrap_or_else(|| PathBuf::from("."))
279        .join("jan")
280        .join("scripts")
281}
282
283/// Resolve a preferred or well-known spec directory when no explicit flags / cwd `jan.yaml` is set.
284///
285/// Order: saved user preference (`jan use`) → `JAN_INSTALL_DIR` / `~/.config/jan/scripts`.
286pub fn resolve_well_known_spec(cwd: &Path) -> Result<(PathBuf, SpecRootIdentity)> {
287    if let Some((dir, root)) = preferred_jan_dir_from_config() {
288        if dir.is_dir() {
289            match resolve_spec_dir_entry(&dir, &root, cwd) {
290                Ok(pair) => return Ok(pair),
291                Err(e) => {
292                    // Fall through to default well-known if preference is stale.
293                    eprintln!(
294                        "jan: preferred jan-dir {} / {} unusable ({:#}); trying well-known install dir",
295                        dir.display(),
296                        root,
297                        e
298                    );
299                }
300            }
301        }
302    }
303
304    let dir = well_known_spec_dir();
305    let candidates = config::entry_candidates(None);
306
307    if !dir.is_dir() {
308        bail!(
309            "no spec found: no --spec/--spec-dir, no jan.yaml under {}, no preferred directory (`jan use`), \
310             and well-known directory {} does not exist\n\
311             Run `jan use <dir>` to save a preferred tree, install a bundle (see docs/PORTABLE_SCRIPTS.md), \
312             or pass --spec / --spec-dir / JAN_SPEC",
313            cwd.display(),
314            dir.display()
315        );
316    }
317    for name in &candidates {
318        let path = dir.join(name);
319        if path.is_file() {
320            return resolve_spec_dir_entry(&dir, name, cwd);
321        }
322    }
323    bail!(
324        "no spec entry file in {} (tried: {})\n\
325         Run `jan use <dir>`, install with jan-install.sh, or set JAN_SPEC_ROOT",
326        dir.display(),
327        candidates.join(", ")
328    );
329}
330
331/// Absolute preferred jan directory + entry file from XDG user config, if set and valid-looking.
332fn preferred_jan_dir_from_config() -> Option<(PathBuf, String)> {
333    let cfg = config::load_user_config().ok()?;
334    let dir = cfg.jan_dir.as_ref()?.trim();
335    if dir.is_empty() {
336        return None;
337    }
338    let root = cfg
339        .spec_root
340        .as_deref()
341        .map(str::trim)
342        .filter(|s| !s.is_empty())
343        .unwrap_or("scripts.spec.yaml")
344        .to_string();
345    Some((PathBuf::from(dir), root))
346}
347
348/// Build [`SpecRootIdentity`] for a concrete spec file on disk (`include` resolves from its parent).
349pub fn spec_identity_for_spec_file(spec_file: &Path) -> Result<SpecRootIdentity> {
350    let spec_file = spec_file
351        .canonicalize()
352        .with_context(|| format!("canonicalize {}", spec_file.display()))?;
353    let parent = spec_file
354        .parent()
355        .ok_or_else(|| anyhow::anyhow!("spec file has no parent directory"))?;
356    let root_name = spec_file
357        .file_name()
358        .ok_or_else(|| anyhow::anyhow!("spec file has no file name"))?
359        .to_string_lossy()
360        .into_owned();
361    Ok(SpecRootIdentity {
362        spec_dir: parent.to_string_lossy().into_owned(),
363        root_yaml: root_name,
364    })
365}
366
367/// Resolve `--spec-dir` + root file name into an absolute spec path and identity.
368pub fn resolve_spec_dir_entry(
369    spec_dir: &Path,
370    root_yaml: &str,
371    cwd: &Path,
372) -> Result<(PathBuf, SpecRootIdentity)> {
373    let rel = Path::new(root_yaml);
374    if rel.is_absolute() {
375        bail!("--spec-root must be a relative file name, not an absolute path");
376    }
377    if rel
378        .components()
379        .any(|c| matches!(c, std::path::Component::ParentDir))
380    {
381        bail!("--spec-root must not contain `..`");
382    }
383    let normal_only = rel
384        .components()
385        .all(|c| matches!(c, std::path::Component::Normal(_)));
386    let n = rel
387        .components()
388        .filter(|c| matches!(c, std::path::Component::Normal(_)))
389        .count();
390    if !normal_only || n != 1 {
391        bail!("--spec-root must be a single file name inside the spec directory");
392    }
393    let dir = if spec_dir.is_absolute() {
394        spec_dir.to_path_buf()
395    } else {
396        cwd.join(spec_dir)
397    };
398    let dir = dir
399        .canonicalize()
400        .with_context(|| format!("canonicalize spec directory {}", dir.display()))?;
401    if !dir.is_dir() {
402        bail!("not a directory: {}", dir.display());
403    }
404    let spec_path = dir.join(rel);
405    if !spec_path.is_file() {
406        bail!(
407            "spec entry not found: {} (under {})",
408            spec_path.display(),
409            dir.display()
410        );
411    }
412    let identity = SpecRootIdentity {
413        spec_dir: dir.to_string_lossy().into_owned(),
414        root_yaml: rel
415            .file_name()
416            .expect("relative root has file_name")
417            .to_string_lossy()
418            .into_owned(),
419    };
420    Ok((spec_path, identity))
421}
422
423pub struct RunContext<'a> {
424    pub cwd: &'a Path,
425    pub db_path: Option<&'a Path>,
426    pub branch: String,
427    pub no_log: bool,
428    pub spec_root: &'a SpecRootIdentity,
429}
430
431pub fn run_matched(
432    spec: &RootSpec,
433    chain: &[String],
434    node: &CommandNode,
435    trailing: &[OsString],
436    ctx: &RunContext<'_>,
437) -> Result<i32> {
438    let exec = match &node.exec {
439        Some(e) => e,
440        None => {
441            let help = format_help(spec, chain, Some(node));
442            print!("{help}");
443            bail!("missing subcommand");
444        }
445    };
446    if exec.argv.is_empty() {
447        bail!("exec.argv must not be empty");
448    }
449    let mut argv: Vec<String> = exec.argv.clone();
450    if exec.passthrough {
451        for a in trailing {
452            argv.push(a.to_string_lossy().into_owned());
453        }
454    } else if !trailing.is_empty() {
455        bail!("unexpected trailing arguments (enable exec.passthrough in the spec)");
456    }
457
458    let cmd_path = if chain.is_empty() {
459        "(root)".to_string()
460    } else {
461        chain.join(" ")
462    };
463
464    let (_, requires, _) = deps::collect_chain_metadata(chain, spec);
465    deps::check_requires(&requires)?;
466
467    let path_dirs = deps::resolve_path_prefixes(spec, chain, ctx)?;
468    let mut run_env = deps::collect_chain_env(chain, spec);
469    if !path_dirs.is_empty() {
470        run_env.insert("PATH".into(), deps::prepend_path_env(&path_dirs)?);
471    }
472
473    let mut c = Command::new(&argv[0]);
474    if argv.len() > 1 {
475        c.args(&argv[1..]);
476    }
477    c.current_dir(ctx.cwd);
478    for (key, value) in run_env {
479        c.env(key, value);
480    }
481
482    let status = c.status().with_context(|| format!("spawn `{}`", argv[0]))?;
483    let code = status.code().unwrap_or(255);
484
485    if !ctx.no_log {
486        if let Some(db) = ctx.db_path {
487            log_invocation(
488                db,
489                &ctx.branch,
490                ctx.cwd,
491                &cmd_path,
492                &argv,
493                code,
494                ctx.spec_root,
495            )?;
496        }
497    }
498
499    Ok(code)
500}
501
502fn ensure_invocations_spec_root_column(conn: &Connection) -> Result<()> {
503    let mut stmt = conn.prepare("PRAGMA table_info(invocations)")?;
504    let cols: Vec<String> = stmt
505        .query_map([], |row| row.get::<_, String>(1))?
506        .collect::<std::result::Result<_, _>>()?;
507    if !cols.iter().any(|c| c == "spec_root_id") {
508        conn.execute(
509            "ALTER TABLE invocations ADD COLUMN spec_root_id INTEGER",
510            [],
511        )?;
512    }
513    Ok(())
514}
515
516fn upsert_spec_root(conn: &Connection, spec: &SpecRootIdentity) -> Result<i64> {
517    let ts = unix_ts();
518    conn.execute(
519        r"INSERT INTO spec_roots (spec_dir, root_yaml, last_used_ts) VALUES (?1, ?2, ?3)
520          ON CONFLICT(spec_dir, root_yaml) DO UPDATE SET last_used_ts = excluded.last_used_ts",
521        rusqlite::params![&spec.spec_dir, &spec.root_yaml, &ts],
522    )?;
523    let id: i64 = conn.query_row(
524        "SELECT id FROM spec_roots WHERE spec_dir = ?1 AND root_yaml = ?2",
525        [&spec.spec_dir, &spec.root_yaml],
526        |r| r.get(0),
527    )?;
528    Ok(id)
529}
530
531fn log_invocation(
532    db_path: &Path,
533    branch: &str,
534    cwd: &Path,
535    command_path: &str,
536    argv: &[String],
537    exit_code: i32,
538    spec_root: &SpecRootIdentity,
539) -> Result<()> {
540    if let Some(parent) = db_path.parent() {
541        std::fs::create_dir_all(parent).ok();
542    }
543    let conn = Connection::open(db_path).with_context(|| format!("open {}", db_path.display()))?;
544    conn.execute_batch(
545        r"
546        CREATE TABLE IF NOT EXISTS spec_roots (
547            id INTEGER PRIMARY KEY AUTOINCREMENT,
548            spec_dir TEXT NOT NULL,
549            root_yaml TEXT NOT NULL,
550            last_used_ts TEXT NOT NULL,
551            UNIQUE(spec_dir, root_yaml)
552        );
553        CREATE TABLE IF NOT EXISTS invocations (
554            id INTEGER PRIMARY KEY AUTOINCREMENT,
555            ts TEXT NOT NULL,
556            git_branch TEXT NOT NULL,
557            cwd TEXT NOT NULL,
558            command_path TEXT NOT NULL,
559            argv_json TEXT NOT NULL,
560            exit_code INTEGER NOT NULL,
561            spec_root_id INTEGER
562        );
563        ",
564    )?;
565    ensure_invocations_spec_root_column(&conn)?;
566    let spec_root_id = upsert_spec_root(&conn, spec_root)?;
567    let ts = unix_ts();
568    let argv_json = serde_json::to_string(argv).unwrap_or_else(|_| "[]".to_string());
569    let cwd_s = cwd.to_string_lossy();
570    conn.execute(
571        "INSERT INTO invocations (ts, git_branch, cwd, command_path, argv_json, exit_code, spec_root_id)
572         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
573        rusqlite::params![
574            ts,
575            branch,
576            cwd_s.as_ref(),
577            command_path,
578            argv_json,
579            exit_code,
580            spec_root_id
581        ],
582    )?;
583    Ok(())
584}
585
586fn unix_ts() -> String {
587    use std::time::SystemTime;
588    SystemTime::now()
589        .duration_since(std::time::UNIX_EPOCH)
590        .unwrap_or_default()
591        .as_secs()
592        .to_string()
593}
594
595#[derive(Debug)]
596pub struct MatchOutcome<'a> {
597    pub chain: Vec<String>,
598    pub node: Option<&'a CommandNode>,
599    pub trailing: Vec<OsString>,
600    pub wants_help: bool,
601}
602
603pub fn match_commands<'a>(spec: &'a RootSpec, args: &[OsString]) -> MatchOutcome<'a> {
604    let mut chain = Vec::new();
605    let mut node: Option<&'a CommandNode> = None;
606    let mut map: &BTreeMap<String, CommandNode> = &spec.commands;
607    let mut i = 0usize;
608    let len = args.len();
609    while i < len {
610        let raw = &args[i];
611        if raw == "--help" || raw == "-h" {
612            return MatchOutcome {
613                chain,
614                node,
615                trailing: args[i + 1..].to_vec(),
616                wants_help: true,
617            };
618        }
619        let key = raw.to_string_lossy();
620        if let Some(next) = map.get(key.as_ref()) {
621            chain.push(key.into_owned());
622            node = Some(next);
623            map = &next.commands;
624            i += 1;
625            continue;
626        }
627        break;
628    }
629    MatchOutcome {
630        chain,
631        node,
632        trailing: args[i..].to_vec(),
633        wants_help: false,
634    }
635}
636
637#[cfg(test)]
638mod tests {
639    use super::*;
640    use std::io::Write;
641
642    #[test]
643    fn examples_default_spec_validates() {
644        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/default.spec.yaml");
645        load_spec(&path).unwrap();
646    }
647
648    #[test]
649    fn merge_specs_adds_and_replaces_leaves() {
650        let mut base = load_spec_from_str(
651            r"
652commands:
653  a:
654    about: base
655    commands:
656      x:
657        about: old
658        exec:
659          argv: [echo, old]
660",
661            None,
662        )
663        .unwrap();
664        let overlay = load_spec_from_str(
665            r"
666commands:
667  a:
668    commands:
669      x:
670        about: new leaf
671        exec:
672          argv: [echo, new]
673  b:
674    about: added top
675    exec:
676      argv: [echo, b]
677",
678            None,
679        )
680        .unwrap();
681        merge_specs_into(&mut base, overlay).unwrap();
682        base.commands["a"].commands["x"].validate("a x").unwrap();
683        assert_eq!(
684            base.commands["a"].commands["x"].exec.as_ref().unwrap().argv,
685            vec!["echo", "new"]
686        );
687        assert_eq!(
688            base.commands["b"].exec.as_ref().unwrap().argv,
689            vec!["echo", "b"]
690        );
691    }
692
693    #[test]
694    fn validate_rejects_exec_with_children() {
695        let mut tmp = tempfile::NamedTempFile::with_suffix(".yaml").unwrap();
696        write!(
697            tmp,
698            r"
699commands:
700  x:
701    exec:
702      argv: [echo]
703    commands:
704      child:
705        about: nested
706"
707        )
708        .unwrap();
709        let err = load_spec(tmp.path()).unwrap_err();
710        assert!(err.to_string().contains("cannot define both"));
711    }
712}
713
714pub fn default_db_path() -> PathBuf {
715    if let Ok(p) = std::env::var("JAN_DB") {
716        return PathBuf::from(p);
717    }
718    dirs::data_local_dir()
719        .unwrap_or_else(|| PathBuf::from("."))
720        .join("jan-cli")
721        .join("audit.db")
722}
723
724/// Resolve a YAML spec file from explicit flags, env, or cwd-local `jan.yaml` / `jan.spec.yaml`.
725pub fn resolve_spec_path(cli_spec: Option<PathBuf>, cwd: &Path) -> Result<Option<PathBuf>> {
726    if let Some(p) = cli_spec {
727        let full = if p.is_absolute() { p } else { cwd.join(p) };
728        if full.exists() {
729            return Ok(Some(full));
730        }
731        bail!("spec file not found: {}", full.display());
732    }
733    if let Ok(env) = std::env::var("JAN_SPEC") {
734        let p = PathBuf::from(&env);
735        let full = if p.is_absolute() { p } else { cwd.join(p) };
736        if full.exists() {
737            return Ok(Some(full));
738        }
739        bail!("JAN_SPEC points to missing file: {}", full.display());
740    }
741    let a = cwd.join("jan.yaml");
742    if a.exists() {
743        return Ok(Some(a));
744    }
745    let b = cwd.join("jan.spec.yaml");
746    if b.exists() {
747        return Ok(Some(b));
748    }
749    Ok(None)
750}