Skip to main content

jan_cli/
lib.rs

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