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