Skip to main content

jan_cli/
lib.rs

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