Skip to main content

jan_cli/
lib.rs

1mod builtins;
2mod config;
3mod cron;
4mod deps;
5mod inputs;
6mod inspect;
7pub mod remote;
8mod runner;
9mod spec_load;
10mod yaml_closure;
11
12pub use config::{load_user_config, UserConfig};
13pub use runner::run_jan;
14pub use spec_load::HostPlatform;
15
16use std::collections::BTreeMap;
17use std::ffi::OsString;
18use std::path::{Path, PathBuf};
19use std::process::Command;
20
21use anyhow::{bail, Context, Result};
22use rusqlite::Connection;
23use serde::Deserialize;
24use serde::de::{self, Deserializer, Visitor};
25use std::fmt;
26
27#[derive(Debug, Deserialize)]
28pub struct RootSpec {
29    pub metadata: Option<Metadata>,
30    #[serde(default)]
31    pub commands: BTreeMap<String, CommandNode>,
32}
33
34#[derive(Debug, Deserialize)]
35pub struct Metadata {
36    pub name: Option<String>,
37    pub description: Option<String>,
38}
39
40/// Child-process environment declaration for a command node.
41///
42/// Two YAML shapes are accepted:
43///
44/// ```yaml
45/// # Legacy / shorthand — all keys are public assignments
46/// env:
47///   FOO: bar
48///
49/// # Explicit sections
50/// env:
51///   public:
52///     FOO: bar
53///   private:
54///     - GH_TOKEN
55///   pass:
56///     GH_TOKEN: github/pat
57/// ```
58///
59/// `public` values are taken from the YAML. `private` names must already exist in
60/// jan's own environment; their values are copied into the child and never stored
61/// in the spec. `pass` maps an environment variable name to a `pass` store id;
62/// jan runs `pass <id>` and sets only the first line of stdout as that variable
63/// in the child. When any section is non-empty, the child runs with a cleared
64/// environment containing only those variables plus a small essential allowlist
65/// (PATH, HOME, …).
66#[derive(Debug, Default, Clone, PartialEq, Eq)]
67pub struct EnvSpec {
68    pub public: BTreeMap<String, String>,
69    pub private: Vec<String>,
70    /// Env var name → `pass` store id (e.g. `GH_TOKEN` → `github/pat`).
71    pub pass: BTreeMap<String, String>,
72}
73
74impl EnvSpec {
75    pub fn is_empty(&self) -> bool {
76        self.public.is_empty() && self.private.is_empty() && self.pass.is_empty()
77    }
78
79    /// True when the child should not inherit the full parent environment.
80    pub fn restricts_child_env(&self) -> bool {
81        !self.is_empty()
82    }
83
84    pub fn merge_from(&mut self, other: EnvSpec) {
85        for (k, v) in other.public {
86            self.public.insert(k, v);
87        }
88        for name in other.private {
89            if !self.private.iter().any(|p| p == &name) {
90                self.private.push(name);
91            }
92        }
93        for (k, v) in other.pass {
94            self.pass.insert(k, v);
95        }
96    }
97
98    /// Reject overlapping private/pass names and empty keys/ids.
99    pub fn validate(&self, path: &str) -> Result<()> {
100        for name in &self.private {
101            if name.trim().is_empty() {
102                bail!("command '{path}': env.private entry must not be empty");
103            }
104        }
105        for (env_name, pass_id) in &self.pass {
106            if env_name.trim().is_empty() {
107                bail!("command '{path}': env.pass key must not be empty");
108            }
109            if pass_id.trim().is_empty() {
110                bail!("command '{path}': env.pass id for `{env_name}` must not be empty");
111            }
112            if self.private.iter().any(|p| p == env_name) {
113                bail!(
114                    "command '{path}': env var `{env_name}` cannot be both `env.private` and `env.pass`"
115                );
116            }
117        }
118        Ok(())
119    }
120}
121
122impl<'de> Deserialize<'de> for EnvSpec {
123    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
124    where
125        D: Deserializer<'de>,
126    {
127        #[derive(Deserialize)]
128        struct Structured {
129            #[serde(default)]
130            public: BTreeMap<String, String>,
131            #[serde(default, deserialize_with = "deserialize_string_or_seq")]
132            private: Vec<String>,
133            #[serde(default)]
134            pass: BTreeMap<String, String>,
135        }
136
137        #[derive(Deserialize)]
138        #[serde(untagged)]
139        enum EnvDe {
140            Flat(BTreeMap<String, String>),
141            Sections(Structured),
142        }
143
144        Ok(match EnvDe::deserialize(deserializer)? {
145            EnvDe::Flat(public) => Self {
146                public,
147                private: Vec::new(),
148                pass: BTreeMap::new(),
149            },
150            EnvDe::Sections(s) => Self {
151                public: s.public,
152                private: s.private,
153                pass: s.pass,
154            },
155        })
156    }
157}
158
159#[derive(Debug, Deserialize, Default, Clone)]
160pub struct CommandNode {
161    /// If non-empty, this command and its subtree are only offered on these
162    /// platforms (`linux`, `macos`, `windows`, …). `darwin` is accepted as an alias for `macos`.
163    #[serde(default)]
164    pub os: Vec<String>,
165    #[serde(default)]
166    pub about: String,
167    /// Directory prepended to PATH when this script (or a descendant leaf) runs.
168    pub path: Option<String>,
169    /// Other script names whose `path` directories are prepended before this one runs.
170    #[serde(default)]
171    pub dependencies: Vec<String>,
172    /// External binaries that must be on PATH (e.g. `fzf`, `jq`) before the leaf runs.
173    #[serde(default)]
174    pub requires: Vec<String>,
175    /// Public assignments and/or private names required from the host environment.
176    #[serde(default)]
177    pub env: EnvSpec,
178    /// Named CLI inputs (`--name value`) available as `${{ inputs.name }}` in env/argv.
179    #[serde(default)]
180    pub inputs: BTreeMap<String, crate::inputs::InputDef>,
181    /// Optional crontab schedule(s). When set, `jan cron` runs this script's `run`
182    /// leaf whenever the local time matches any expression.
183    #[serde(default, deserialize_with = "deserialize_string_or_seq")]
184    pub cron: Vec<String>,
185    #[serde(default)]
186    pub commands: BTreeMap<String, CommandNode>,
187    pub exec: Option<ExecSpec>,
188}
189
190pub(crate) fn deserialize_string_or_seq<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
191where
192    D: Deserializer<'de>,
193{
194    struct StringOrSeq;
195
196    impl<'de> Visitor<'de> for StringOrSeq {
197        type Value = Vec<String>;
198
199        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
200            formatter.write_str("a string or a sequence of strings")
201        }
202
203        fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
204        where
205            E: de::Error,
206        {
207            if value.trim().is_empty() {
208                Ok(Vec::new())
209            } else {
210                Ok(vec![value.to_string()])
211            }
212        }
213
214        fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
215        where
216            E: de::Error,
217        {
218            self.visit_str(&value)
219        }
220
221        fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
222        where
223            A: de::SeqAccess<'de>,
224        {
225            let mut out = Vec::new();
226            while let Some(s) = seq.next_element::<String>()? {
227                if !s.trim().is_empty() {
228                    out.push(s);
229                }
230            }
231            Ok(out)
232        }
233
234        fn visit_none<E>(self) -> Result<Self::Value, E>
235        where
236            E: de::Error,
237        {
238            Ok(Vec::new())
239        }
240
241        fn visit_unit<E>(self) -> Result<Self::Value, E>
242        where
243            E: de::Error,
244        {
245            Ok(Vec::new())
246        }
247    }
248
249    deserializer.deserialize_any(StringOrSeq)
250}
251
252/// Local path or remote HTTPS include target.
253#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
254#[serde(untagged)]
255pub enum IncludeRef {
256    /// Relative path under the preferred jan directory.
257    Local(String),
258    /// Remote YAML fetched with SHA256 verification.
259    Remote(RemoteInclude),
260}
261
262#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
263pub struct RemoteInclude {
264    pub url: String,
265    pub sha256: String,
266    #[serde(default)]
267    pub ttl: Option<u64>,
268}
269
270impl IncludeRef {
271    pub fn is_remote(&self) -> bool {
272        matches!(self, Self::Remote(_))
273    }
274
275    pub fn cycle_token(&self) -> String {
276        match self {
277            Self::Local(p) => p.clone(),
278            Self::Remote(r) => format!("{}#{}", r.url, r.sha256.to_ascii_lowercase()),
279        }
280    }
281}
282
283#[derive(Debug, Deserialize, Clone, Default)]
284pub struct ExecSpec {
285    /// Program argv. For remote `url` leaves this is an optional interpreter prefix
286    /// (e.g. `["python3"]`); the cached script path is appended automatically.
287    #[serde(default)]
288    pub argv: Vec<String>,
289    /// Append extra CLI arguments after those from `argv` / the remote script.
290    #[serde(default)]
291    pub passthrough: bool,
292    /// HTTPS URL of a remote script to download, verify, and run.
293    #[serde(default)]
294    pub url: Option<String>,
295    /// Required SHA256 when `url` is set.
296    #[serde(default)]
297    pub sha256: Option<String>,
298    /// Optional TTL override (seconds) for the remote script cache.
299    #[serde(default)]
300    pub ttl: Option<u64>,
301}
302
303impl ExecSpec {
304    pub fn is_remote(&self) -> bool {
305        self.url
306            .as_deref()
307            .map(|u| !u.trim().is_empty())
308            .unwrap_or(false)
309    }
310
311    pub fn validate(&self, path: &str) -> Result<()> {
312        let url = self
313            .url
314            .as_deref()
315            .map(str::trim)
316            .filter(|s| !s.is_empty());
317        let hash = self
318            .sha256
319            .as_deref()
320            .map(str::trim)
321            .filter(|s| !s.is_empty());
322        match (url, hash) {
323            (Some(_), Some(_)) => Ok(()),
324            (Some(_), None) => {
325                bail!("command '{path}': exec.sha256 is required with exec.url")
326            }
327            (None, Some(_)) => {
328                bail!("command '{path}': exec.sha256 requires exec.url")
329            }
330            (None, None) => {
331                if self.argv.is_empty() {
332                    bail!(
333                        "command '{path}': exec.argv must not be empty (or set exec.url + exec.sha256)"
334                    );
335                }
336                Ok(())
337            }
338        }
339    }
340}
341
342impl CommandNode {
343    pub fn is_leaf_exec(&self) -> bool {
344        self.exec.is_some()
345    }
346
347    pub fn validate(&self, path: &str) -> Result<()> {
348        if self.exec.is_some() && !self.commands.is_empty() {
349            bail!("command '{path}' cannot define both `exec` and nested `commands`");
350        }
351        if let Some(ref e) = self.exec {
352            e.validate(path)?;
353        }
354        self.env.validate(path)?;
355        for name in self.inputs.keys() {
356            inputs::InputDef::validate_name(name)
357                .map_err(|e| anyhow::anyhow!("command '{path}': {e}"))?;
358        }
359        for (name, child) in &self.commands {
360            let p = if path.is_empty() {
361                name.clone()
362            } else {
363                format!("{path} {name}")
364            };
365            child.validate(&p)?;
366        }
367        Ok(())
368    }
369}
370
371/// Deep-merge `overlay.commands` into `base`, letting included YAML fragments
372/// add or replace leaves and extend nested groups.
373pub fn merge_specs_into(base: &mut RootSpec, overlay: RootSpec) -> Result<()> {
374    for (name, node) in overlay.commands {
375        match base.commands.get_mut(&name) {
376            Some(existing) => merge_command_node(existing, node)?,
377            None => {
378                base.commands.insert(name, node);
379            }
380        }
381    }
382    Ok(())
383}
384
385fn merge_command_node(dst: &mut CommandNode, src: CommandNode) -> Result<()> {
386    if src.exec.is_some() && !src.commands.is_empty() {
387        bail!("merge overlay: command cannot define both `exec` and nested `commands`");
388    }
389    if !src.os.is_empty() {
390        dst.os = src.os;
391    }
392    if !src.about.trim().is_empty() {
393        dst.about = src.about;
394    }
395    if src.path.is_some() {
396        dst.path = src.path;
397    }
398    if !src.dependencies.is_empty() {
399        dst.dependencies = src.dependencies;
400    }
401    if !src.requires.is_empty() {
402        dst.requires = src.requires;
403    }
404    if !src.cron.is_empty() {
405        dst.cron = src.cron;
406    }
407    if !src.env.is_empty() {
408        dst.env.merge_from(src.env);
409    }
410    for (k, v) in src.inputs {
411        dst.inputs.insert(k, v);
412    }
413    if let Some(exec) = src.exec {
414        dst.exec = Some(exec);
415        dst.commands.clear();
416        return Ok(());
417    }
418    if !src.commands.is_empty() {
419        dst.exec = None;
420        for (k, child) in src.commands {
421            match dst.commands.get_mut(&k) {
422                Some(existing) => merge_command_node(existing, child)?,
423                None => {
424                    dst.commands.insert(k, child);
425                }
426            }
427        }
428    }
429    Ok(())
430}
431
432/// Validate every command in the tree (after merges or programmatic edits).
433pub fn validate_spec(spec: &RootSpec) -> Result<()> {
434    for (name, node) in &spec.commands {
435        node.validate(name)?;
436    }
437    Ok(())
438}
439
440/// Parse YAML from memory. Use `include_base` when the document uses `include:` (root or subtree).
441pub fn load_spec_from_str(raw: &str, include_base: Option<&Path>) -> Result<RootSpec> {
442    spec_load::load_spec_from_str(raw, include_base, HostPlatform::detect())
443}
444
445pub fn load_spec(path: &Path) -> Result<RootSpec> {
446    spec_load::load_spec_from_path(path, HostPlatform::detect())
447}
448
449pub fn resolve_git_branch(cwd: &Path, override_branch: Option<&str>) -> String {
450    if let Some(b) = override_branch {
451        if !b.is_empty() {
452            return b.to_string();
453        }
454    }
455    if let Ok(v) = std::env::var("JAN_BRANCH") {
456        if !v.is_empty() {
457            return v;
458        }
459    }
460    let output = Command::new("git")
461        .args(["rev-parse", "--abbrev-ref", "HEAD"])
462        .current_dir(cwd)
463        .output();
464    match output {
465        Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
466        _ => "(no-git)".to_string(),
467    }
468}
469
470fn first_line(s: &str) -> String {
471    s.lines().next().unwrap_or("").trim().to_string()
472}
473
474pub fn format_help(spec: &RootSpec, chain: &[String], node: Option<&CommandNode>) -> String {
475    let mut out = String::new();
476    let bin = spec
477        .metadata
478        .as_ref()
479        .and_then(|m| m.name.as_deref())
480        .unwrap_or("jan");
481    let full_cmd = if chain.is_empty() {
482        bin.to_string()
483    } else {
484        format!("{} {}", bin, chain.join(" "))
485    };
486
487    let (about, children, exec) = match node {
488        Some(n) => (n.about.as_str(), &n.commands, n.exec.as_ref()),
489        None => ("", &spec.commands, None),
490    };
491
492    if chain.is_empty() {
493        if let Some(meta) = &spec.metadata {
494            if let Some(desc) = &meta.description {
495                out.push_str(desc.trim());
496                out.push_str("\n\n");
497            }
498        }
499    }
500
501    if !about.is_empty() {
502        out.push_str(about.trim());
503        out.push_str("\n\n");
504    }
505
506    if exec.is_some() && children.is_empty() {
507        out.push_str("This command runs an external program (see spec `exec.argv`).\n");
508        let defs = inputs::collect_chain_inputs(chain, spec);
509        if !defs.is_empty() {
510            out.push('\n');
511            out.push_str(&inputs::format_inputs_help(&defs));
512        }
513        return out;
514    }
515
516    if !children.is_empty() {
517        out.push_str("Subcommands:\n");
518        for (name, child) in children {
519            let line = if child.about.is_empty() {
520                format!("  {name}\n")
521            } else {
522                format!("  {name} — {}\n", first_line(&child.about))
523            };
524            out.push_str(&line);
525        }
526        out.push('\n');
527        out.push_str(&format!(
528            "Use `{} --help` for more about a subcommand.\n",
529            full_cmd
530        ));
531        let defs = inputs::collect_chain_inputs(chain, spec);
532        if !defs.is_empty() {
533            out.push('\n');
534            out.push_str(&inputs::format_inputs_help(&defs));
535        }
536    } else if exec.is_none() {
537        out.push_str("(No subcommands defined.)\n");
538    }
539    if chain.is_empty() && node.is_none() {
540        out.push_str(
541            "\nPlace `--help` or `-h` right after the subcommand prefix you want. Built-ins: `use`, `bundle`, `alias`, `list`, `search`, `show`, `validate`, `audit`, `cron`.\n",
542        );
543    }
544    out
545}
546
547/// Stable identity for where a YAML spec tree is rooted (directory of linked fragments + entry file).
548#[derive(Debug, Clone)]
549pub struct SpecRootIdentity {
550    /// Canonical directory containing top-level YAML fragments.
551    pub spec_dir: String,
552    /// Entry YAML file name relative to `spec_dir`.
553    pub root_yaml: String,
554}
555
556/// Resolve the preferred jan directory saved by `jan use`.
557pub fn resolve_preferred_spec() -> Result<(PathBuf, SpecRootIdentity)> {
558    let cfg = config::load_user_config().context("load user config")?;
559    let Some(dir_s) = cfg
560        .jan_dir
561        .as_ref()
562        .map(|s| s.trim())
563        .filter(|s| !s.is_empty())
564    else {
565        bail!(
566            "no preferred jan directory configured\n\
567             Run `jan use <DIR>` to save a YAML command tree (see docs/PORTABLE_SCRIPTS.md)"
568        );
569    };
570    let dir = PathBuf::from(dir_s);
571    if !dir.is_dir() {
572        bail!(
573            "preferred jan directory does not exist: {}\n\
574             Fix the path or run `jan use <DIR>` again (config: {})",
575            dir.display(),
576            config::config_path().display()
577        );
578    }
579    let root = cfg
580        .spec_root
581        .as_deref()
582        .map(str::trim)
583        .filter(|s| !s.is_empty())
584        .unwrap_or("scripts.spec.yaml");
585    resolve_spec_dir_entry(&dir, root)
586}
587
588/// Resolve a jan directory + entry file name into an absolute spec path and identity.
589pub fn resolve_spec_dir_entry(
590    spec_dir: &Path,
591    root_yaml: &str,
592) -> Result<(PathBuf, SpecRootIdentity)> {
593    let rel = Path::new(root_yaml);
594    if rel.is_absolute() {
595        bail!("entry YAML must be a relative file name, not an absolute path");
596    }
597    if rel
598        .components()
599        .any(|c| matches!(c, std::path::Component::ParentDir))
600    {
601        bail!("entry YAML must not contain `..`");
602    }
603    let normal_only = rel
604        .components()
605        .all(|c| matches!(c, std::path::Component::Normal(_)));
606    let n = rel
607        .components()
608        .filter(|c| matches!(c, std::path::Component::Normal(_)))
609        .count();
610    if !normal_only || n != 1 {
611        bail!("entry YAML must be a single file name inside the jan directory");
612    }
613    let dir = spec_dir
614        .canonicalize()
615        .with_context(|| format!("canonicalize jan directory {}", spec_dir.display()))?;
616    if !dir.is_dir() {
617        bail!("not a directory: {}", dir.display());
618    }
619    let spec_path = dir.join(rel);
620    if !spec_path.is_file() {
621        bail!(
622            "spec entry not found: {} (under {})\n\
623             Expected a properly formatted jan directory (e.g. scripts.spec.yaml)",
624            spec_path.display(),
625            dir.display()
626        );
627    }
628    let identity = SpecRootIdentity {
629        spec_dir: dir.to_string_lossy().into_owned(),
630        root_yaml: rel
631            .file_name()
632            .expect("relative root has file_name")
633            .to_string_lossy()
634            .into_owned(),
635    };
636    Ok((spec_path, identity))
637}
638
639pub struct RunContext<'a> {
640    pub cwd: &'a Path,
641    pub db_path: Option<&'a Path>,
642    pub branch: String,
643    pub no_log: bool,
644    pub spec_root: &'a SpecRootIdentity,
645}
646
647/// True when `argv` is a POSIX-shell inline (`bash`/`zsh`/`sh`/… + `-c`/`-lc` + body)
648/// with no `$0` placeholder after the body yet.
649///
650/// For those interpreters the first word after the `-c` string becomes `$0`, not `$1`.
651/// Inlined jan scripts expect normal script semantics (`$1` / `"$@"` = user args), so
652/// passthrough must insert a `$0` before forwarding.
653fn shell_inline_c_needs_argv0(argv: &[String]) -> bool {
654    if argv.len() != 3 {
655        return false;
656    }
657    let prog = Path::new(&argv[0])
658        .file_name()
659        .and_then(|s| s.to_str())
660        .unwrap_or(argv[0].as_str());
661    let is_shell = matches!(
662        prog,
663        "bash" | "zsh" | "sh" | "dash" | "ksh" | "ash" | "busybox"
664    );
665    is_shell && matches!(argv[1].as_str(), "-c" | "-lc")
666}
667
668fn shell_passthrough_argv0(chain: &[String]) -> String {
669    chain
670        .iter()
671        .rev()
672        .find(|s| s.as_str() != "run")
673        .cloned()
674        .or_else(|| chain.last().cloned())
675        .unwrap_or_else(|| "jan".to_string())
676}
677
678pub fn run_matched(
679    spec: &RootSpec,
680    chain: &[String],
681    node: &CommandNode,
682    trailing: &[OsString],
683    ctx: &RunContext<'_>,
684) -> Result<i32> {
685    let exec = match &node.exec {
686        Some(e) => e,
687        None => {
688            let help = format_help(spec, chain, Some(node));
689            print!("{help}");
690            bail!("missing subcommand");
691        }
692    };
693    exec.validate(&chain.join(" "))?;
694
695    let input_defs = inputs::collect_chain_inputs(chain, spec);
696    let (input_vals, rest) = inputs::resolve_inputs(&input_defs, trailing)?;
697
698    let mut argv: Vec<String> = Vec::with_capacity(exec.argv.len() + 1);
699    for a in &exec.argv {
700        argv.push(inputs::interpolate(a, &input_vals)?);
701    }
702
703    if exec.is_remote() {
704        let url = exec.url.as_deref().unwrap().trim();
705        let hash = exec.sha256.as_deref().unwrap().trim();
706        let mut opts = remote::FetchOpts::new();
707        if let Some(ttl) = exec.ttl {
708            opts = opts.with_ttl(ttl);
709        }
710        let cached = remote::fetch_verified(url, hash, &opts, true)?;
711        argv.push(cached.to_string_lossy().into_owned());
712    } else if argv.is_empty() {
713        bail!("exec.argv must not be empty");
714    }
715
716    if exec.passthrough {
717        let mut rest = rest;
718        if shell_inline_c_needs_argv0(&argv) {
719            argv.push(shell_passthrough_argv0(chain));
720            // `--` is only needed to force args past `$0` when no placeholder is present;
721            // drop a single leading `--` so `run -- create` and `run create` match.
722            if rest.first().is_some_and(|a| a == "--") {
723                rest = rest[1..].to_vec();
724            }
725        }
726        for a in &rest {
727            argv.push(a.to_string_lossy().into_owned());
728        }
729    } else if !rest.is_empty() {
730        let preview = rest
731            .iter()
732            .take(3)
733            .map(|s| s.to_string_lossy().into_owned())
734            .collect::<Vec<_>>()
735            .join(" ");
736        bail!(
737            "unexpected trailing arguments: {preview}{}",
738            if rest.len() > 3 { "…" } else { "" }
739        );
740    }
741
742    let cmd_path = if chain.is_empty() {
743        "(root)".to_string()
744    } else {
745        chain.join(" ")
746    };
747
748    let (_, requires, _) = deps::collect_chain_metadata(chain, spec);
749    deps::check_requires(&requires)?;
750
751    let path_dirs = deps::resolve_path_prefixes(spec, chain, ctx)?;
752    let program = deps::resolve_program(&argv[0], &path_dirs)?;
753    let mut env_spec = deps::collect_chain_env(chain, spec);
754    for value in env_spec.public.values_mut() {
755        *value = inputs::interpolate(value, &input_vals)?;
756    }
757    deps::check_private_env(&env_spec.private)?;
758    let path_override = if !path_dirs.is_empty() {
759        Some(deps::prepend_path_env(&path_dirs)?)
760    } else {
761        None
762    };
763
764    let mut c = Command::new(&program);
765    if argv.len() > 1 {
766        c.args(&argv[1..]);
767    }
768    c.current_dir(ctx.cwd);
769    deps::apply_process_env(&mut c, &env_spec, path_override)?;
770
771    let status = c.status().with_context(|| format!("spawn `{}`", argv[0]))?;
772    let code = status.code().unwrap_or(255);
773
774    if !ctx.no_log {
775        if let Some(db) = ctx.db_path {
776            log_invocation(
777                db,
778                &ctx.branch,
779                ctx.cwd,
780                &cmd_path,
781                &argv,
782                code,
783                ctx.spec_root,
784            )?;
785        }
786    }
787
788    Ok(code)
789}
790
791fn ensure_invocations_spec_root_column(conn: &Connection) -> Result<()> {
792    let mut stmt = conn.prepare("PRAGMA table_info(invocations)")?;
793    let cols: Vec<String> = stmt
794        .query_map([], |row| row.get::<_, String>(1))?
795        .collect::<std::result::Result<_, _>>()?;
796    if !cols.iter().any(|c| c == "spec_root_id") {
797        conn.execute(
798            "ALTER TABLE invocations ADD COLUMN spec_root_id INTEGER",
799            [],
800        )?;
801    }
802    Ok(())
803}
804
805fn upsert_spec_root(conn: &Connection, spec: &SpecRootIdentity) -> Result<i64> {
806    let ts = unix_ts();
807    conn.execute(
808        r"INSERT INTO spec_roots (spec_dir, root_yaml, last_used_ts) VALUES (?1, ?2, ?3)
809          ON CONFLICT(spec_dir, root_yaml) DO UPDATE SET last_used_ts = excluded.last_used_ts",
810        rusqlite::params![&spec.spec_dir, &spec.root_yaml, &ts],
811    )?;
812    let id: i64 = conn.query_row(
813        "SELECT id FROM spec_roots WHERE spec_dir = ?1 AND root_yaml = ?2",
814        [&spec.spec_dir, &spec.root_yaml],
815        |r| r.get(0),
816    )?;
817    Ok(id)
818}
819
820fn log_invocation(
821    db_path: &Path,
822    branch: &str,
823    cwd: &Path,
824    command_path: &str,
825    argv: &[String],
826    exit_code: i32,
827    spec_root: &SpecRootIdentity,
828) -> Result<()> {
829    if let Some(parent) = db_path.parent() {
830        std::fs::create_dir_all(parent).ok();
831    }
832    let conn = Connection::open(db_path).with_context(|| format!("open {}", db_path.display()))?;
833    conn.execute_batch(
834        r"
835        CREATE TABLE IF NOT EXISTS spec_roots (
836            id INTEGER PRIMARY KEY AUTOINCREMENT,
837            spec_dir TEXT NOT NULL,
838            root_yaml TEXT NOT NULL,
839            last_used_ts TEXT NOT NULL,
840            UNIQUE(spec_dir, root_yaml)
841        );
842        CREATE TABLE IF NOT EXISTS invocations (
843            id INTEGER PRIMARY KEY AUTOINCREMENT,
844            ts TEXT NOT NULL,
845            git_branch TEXT NOT NULL,
846            cwd TEXT NOT NULL,
847            command_path TEXT NOT NULL,
848            argv_json TEXT NOT NULL,
849            exit_code INTEGER NOT NULL,
850            spec_root_id INTEGER
851        );
852        ",
853    )?;
854    ensure_invocations_spec_root_column(&conn)?;
855    let spec_root_id = upsert_spec_root(&conn, spec_root)?;
856    let ts = unix_ts();
857    let argv_json = serde_json::to_string(argv).unwrap_or_else(|_| "[]".to_string());
858    let cwd_s = cwd.to_string_lossy();
859    conn.execute(
860        "INSERT INTO invocations (ts, git_branch, cwd, command_path, argv_json, exit_code, spec_root_id)
861         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
862        rusqlite::params![
863            ts,
864            branch,
865            cwd_s.as_ref(),
866            command_path,
867            argv_json,
868            exit_code,
869            spec_root_id
870        ],
871    )?;
872    Ok(())
873}
874
875fn unix_ts() -> String {
876    use std::time::SystemTime;
877    SystemTime::now()
878        .duration_since(std::time::UNIX_EPOCH)
879        .unwrap_or_default()
880        .as_secs()
881        .to_string()
882}
883
884#[derive(Debug)]
885pub struct MatchOutcome<'a> {
886    pub chain: Vec<String>,
887    pub node: Option<&'a CommandNode>,
888    pub trailing: Vec<OsString>,
889    pub wants_help: bool,
890}
891
892pub fn match_commands<'a>(spec: &'a RootSpec, args: &[OsString]) -> MatchOutcome<'a> {
893    let mut chain = Vec::new();
894    let mut node: Option<&'a CommandNode> = None;
895    let mut map: &BTreeMap<String, CommandNode> = &spec.commands;
896    let mut i = 0usize;
897    let len = args.len();
898    while i < len {
899        let raw = &args[i];
900        if raw == "--help" || raw == "-h" {
901            return MatchOutcome {
902                chain,
903                node,
904                trailing: args[i + 1..].to_vec(),
905                wants_help: true,
906            };
907        }
908        let key = raw.to_string_lossy();
909        if let Some(next) = map.get(key.as_ref()) {
910            chain.push(key.into_owned());
911            node = Some(next);
912            map = &next.commands;
913            i += 1;
914            continue;
915        }
916        break;
917    }
918    MatchOutcome {
919        chain,
920        node,
921        trailing: args[i..].to_vec(),
922        wants_help: false,
923    }
924}
925
926#[cfg(test)]
927mod tests {
928    use super::*;
929    use std::io::Write;
930
931    #[test]
932    fn examples_default_spec_validates() {
933        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/default.spec.yaml");
934        load_spec(&path).unwrap();
935    }
936
937    #[test]
938    fn merge_specs_adds_and_replaces_leaves() {
939        let mut base = load_spec_from_str(
940            r"
941commands:
942  a:
943    about: base
944    commands:
945      x:
946        about: old
947        exec:
948          argv: [echo, old]
949",
950            None,
951        )
952        .unwrap();
953        let overlay = load_spec_from_str(
954            r"
955commands:
956  a:
957    commands:
958      x:
959        about: new leaf
960        exec:
961          argv: [echo, new]
962  b:
963    about: added top
964    exec:
965      argv: [echo, b]
966",
967            None,
968        )
969        .unwrap();
970        merge_specs_into(&mut base, overlay).unwrap();
971        base.commands["a"].commands["x"].validate("a x").unwrap();
972        assert_eq!(
973            base.commands["a"].commands["x"].exec.as_ref().unwrap().argv,
974            vec!["echo", "new"]
975        );
976        assert_eq!(
977            base.commands["b"].exec.as_ref().unwrap().argv,
978            vec!["echo", "b"]
979        );
980    }
981
982    #[test]
983    fn validate_rejects_exec_with_children() {
984        let mut tmp = tempfile::NamedTempFile::with_suffix(".yaml").unwrap();
985        write!(
986            tmp,
987            r"
988commands:
989  x:
990    exec:
991      argv: [echo]
992    commands:
993      child:
994        about: nested
995"
996        )
997        .unwrap();
998        let err = load_spec(tmp.path()).unwrap_err();
999        assert!(err.to_string().contains("cannot define both"));
1000    }
1001
1002    #[test]
1003    fn shell_inline_c_needs_argv0_detects_bash_lc() {
1004        let argv = vec![
1005            "bash".into(),
1006            "-lc".into(),
1007            "case \"$1\" in create) ;; esac".into(),
1008        ];
1009        assert!(shell_inline_c_needs_argv0(&argv));
1010        let with_placeholder = vec![
1011            "zsh".into(),
1012            "-c".into(),
1013            "echo".into(),
1014            "issue".into(),
1015        ];
1016        assert!(!shell_inline_c_needs_argv0(&with_placeholder));
1017        assert!(!shell_inline_c_needs_argv0(&[
1018            "echo".into(),
1019            "start".into()
1020        ]));
1021        assert!(!shell_inline_c_needs_argv0(&[
1022            "python3".into(),
1023            "-c".into(),
1024            "print(1)".into()
1025        ]));
1026    }
1027
1028    #[test]
1029    fn shell_passthrough_argv0_skips_run_leaf() {
1030        assert_eq!(
1031            shell_passthrough_argv0(&["scripts".into(), "misc".into(), "issue".into(), "run".into()]),
1032            "issue"
1033        );
1034        assert_eq!(shell_passthrough_argv0(&["probe".into()]), "probe");
1035    }
1036}
1037
1038pub fn default_db_path() -> PathBuf {
1039    if let Ok(p) = std::env::var("JAN_DB") {
1040        return PathBuf::from(p);
1041    }
1042    dirs::data_local_dir()
1043        .unwrap_or_else(|| PathBuf::from("."))
1044        .join("jan-cli")
1045        .join("audit.db")
1046}