Skip to main content

jan_cli/
lib.rs

1mod builtins;
2mod cmdtest;
3mod config;
4mod cron;
5mod deps;
6mod inputs;
7mod inspect;
8mod packages;
9pub mod remote;
10mod runner;
11mod spec_load;
12mod yaml_closure;
13
14pub use config::{load_user_config, UserConfig};
15pub use runner::run_jan;
16pub use spec_load::HostPlatform;
17
18use std::collections::{BTreeMap, HashSet};
19use std::ffi::OsString;
20use std::path::{Path, PathBuf};
21use std::process::Command;
22
23use anyhow::{bail, Context, Result};
24use rusqlite::Connection;
25use serde::de::{self, Deserializer, Visitor};
26use serde::Deserialize;
27use std::fmt;
28
29#[derive(Debug, Deserialize)]
30pub struct RootSpec {
31    pub metadata: Option<Metadata>,
32    #[serde(default)]
33    pub commands: BTreeMap<String, CommandNode>,
34}
35
36#[derive(Debug, Deserialize)]
37pub struct Metadata {
38    pub name: Option<String>,
39    pub description: Option<String>,
40}
41
42/// Child-process environment declaration for a command node.
43///
44/// Two YAML shapes are accepted:
45///
46/// ```yaml
47/// # Legacy / shorthand — all keys are public assignments
48/// env:
49///   FOO: bar
50///
51/// # Explicit sections
52/// env:
53///   public:
54///     FOO: bar
55///   private:
56///     - GH_TOKEN
57///   pass:
58///     GH_TOKEN: github/pat
59/// ```
60///
61/// `public` values are taken from the YAML. `private` names must already exist in
62/// jan's own environment; their values are copied into the child and never stored
63/// in the spec. `pass` maps an environment variable name to a `pass` store id;
64/// jan runs `pass <id>` and sets only the first line of stdout as that variable
65/// in the child. When any section is non-empty, the child runs with a cleared
66/// environment containing only those variables plus a small essential allowlist
67/// (PATH, HOME, …).
68#[derive(Debug, Default, Clone, PartialEq, Eq)]
69pub struct EnvSpec {
70    pub public: BTreeMap<String, String>,
71    pub private: Vec<String>,
72    /// Env var name → `pass` store id (e.g. `GH_TOKEN` → `github/pat`).
73    pub pass: BTreeMap<String, String>,
74}
75
76impl EnvSpec {
77    pub fn is_empty(&self) -> bool {
78        self.public.is_empty() && self.private.is_empty() && self.pass.is_empty()
79    }
80
81    /// True when the child should not inherit the full parent environment.
82    pub fn restricts_child_env(&self) -> bool {
83        !self.is_empty()
84    }
85
86    pub fn merge_from(&mut self, other: EnvSpec) {
87        for (k, v) in other.public {
88            self.public.insert(k, v);
89        }
90        for name in other.private {
91            if !self.private.iter().any(|p| p == &name) {
92                self.private.push(name);
93            }
94        }
95        for (k, v) in other.pass {
96            self.pass.insert(k, v);
97        }
98    }
99
100    /// Reject overlapping private/pass names and empty keys/ids.
101    pub fn validate(&self, path: &str) -> Result<()> {
102        for name in &self.private {
103            if name.trim().is_empty() {
104                bail!("command '{path}': env.private entry must not be empty");
105            }
106        }
107        for (env_name, pass_id) in &self.pass {
108            if env_name.trim().is_empty() {
109                bail!("command '{path}': env.pass key must not be empty");
110            }
111            if pass_id.trim().is_empty() {
112                bail!("command '{path}': env.pass id for `{env_name}` must not be empty");
113            }
114            if self.private.iter().any(|p| p == env_name) {
115                bail!(
116                    "command '{path}': env var `{env_name}` cannot be both `env.private` and `env.pass`"
117                );
118            }
119        }
120        Ok(())
121    }
122}
123
124impl<'de> Deserialize<'de> for EnvSpec {
125    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
126    where
127        D: Deserializer<'de>,
128    {
129        #[derive(Deserialize)]
130        struct Structured {
131            #[serde(default)]
132            public: BTreeMap<String, String>,
133            #[serde(default, deserialize_with = "deserialize_string_or_seq")]
134            private: Vec<String>,
135            #[serde(default)]
136            pass: BTreeMap<String, String>,
137        }
138
139        #[derive(Deserialize)]
140        #[serde(untagged)]
141        enum EnvDe {
142            Flat(BTreeMap<String, String>),
143            Sections(Structured),
144        }
145
146        Ok(match EnvDe::deserialize(deserializer)? {
147            EnvDe::Flat(public) => Self {
148                public,
149                private: Vec::new(),
150                pass: BTreeMap::new(),
151            },
152            EnvDe::Sections(s) => Self {
153                public: s.public,
154                private: s.private,
155                pass: s.pass,
156            },
157        })
158    }
159}
160
161/// Whether an include link pointed at YAML (subtree graft) or a script file (exec leaf).
162#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub enum IncludeLinkKind {
164    Yaml,
165    Script,
166}
167
168/// Retained include identity after load (not authored directly in YAML).
169#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct IncludeLink {
171    pub kind: IncludeLinkKind,
172    /// Relative path under the jan use root (local includes).
173    pub path: Option<String>,
174    /// Remote URL when the include was fetched over HTTPS.
175    pub url: Option<String>,
176    /// Declared SHA256 when present (required for remote; optional for local).
177    pub sha256: Option<String>,
178}
179
180/// Shell aliases declared on a command node (`jan alias` emits them).
181///
182/// Two YAML shapes:
183///
184/// ```yaml
185/// # Extra names for this jan command (same chain as `run` / leaf exec)
186/// aliases: [lb]
187/// aliases: lb
188///
189/// # Traditional shell aliases (`name` → RHS). An empty/null RHS is an extra jan name.
190/// aliases:
191///   gs: git status
192///   lb:
193/// ```
194#[derive(Debug, Clone, Default, PartialEq, Eq)]
195pub struct AliasesSpec {
196    /// Extra `jan alias` keys that invoke this command (or its `run` child).
197    pub names: Vec<String>,
198    /// Traditional shell aliases: name → unquoted RHS (quoted on emit).
199    pub shell: BTreeMap<String, String>,
200}
201
202impl AliasesSpec {
203    pub fn is_empty(&self) -> bool {
204        self.names.is_empty() && self.shell.is_empty()
205    }
206
207    /// Merge `other` onto `self`. Overlay keys win: a name removes a shell
208    /// alias of the same key, and a shell RHS removes a name.
209    pub fn merge_from(&mut self, other: Self) {
210        for n in other.names {
211            self.shell.remove(&n);
212            if !self.names.iter().any(|e| e == &n) {
213                self.names.push(n);
214            }
215        }
216        for (k, v) in other.shell {
217            self.names.retain(|n| n != &k);
218            self.shell.insert(k, v);
219        }
220    }
221
222    pub fn validate(&self, path: &str) -> Result<()> {
223        let mut seen = HashSet::new();
224        for name in &self.names {
225            if !is_safe_alias_name(name) {
226                bail!(
227                    "command '{path}': alias name `{name}` must match [A-Za-z_][A-Za-z0-9_-]*"
228                );
229            }
230            if !seen.insert(name.clone()) {
231                bail!("command '{path}': duplicate alias name `{name}`");
232            }
233        }
234        for name in self.shell.keys() {
235            if !is_safe_alias_name(name) {
236                bail!(
237                    "command '{path}': alias name `{name}` must match [A-Za-z_][A-Za-z0-9_-]*"
238                );
239            }
240            if !seen.insert(name.clone()) {
241                bail!(
242                    "command '{path}': alias `{name}` is declared both as a jan name and a shell RHS"
243                );
244            }
245        }
246        Ok(())
247    }
248}
249
250impl<'de> Deserialize<'de> for AliasesSpec {
251    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
252    where
253        D: Deserializer<'de>,
254    {
255        struct AliasesVisitor;
256
257        impl<'de> Visitor<'de> for AliasesVisitor {
258            type Value = AliasesSpec;
259
260            fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
261                formatter.write_str(
262                    "a string, a list of names, or a map of alias name to shell RHS",
263                )
264            }
265
266            fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
267            where
268                E: de::Error,
269            {
270                if value.trim().is_empty() {
271                    Ok(AliasesSpec::default())
272                } else {
273                    Ok(AliasesSpec {
274                        names: vec![value.to_string()],
275                        shell: BTreeMap::new(),
276                    })
277                }
278            }
279
280            fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
281            where
282                E: de::Error,
283            {
284                self.visit_str(&value)
285            }
286
287            fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
288            where
289                A: de::SeqAccess<'de>,
290            {
291                let mut names = Vec::new();
292                while let Some(s) = seq.next_element::<String>()? {
293                    if !s.trim().is_empty() {
294                        names.push(s);
295                    }
296                }
297                Ok(AliasesSpec {
298                    names,
299                    shell: BTreeMap::new(),
300                })
301            }
302
303            fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
304            where
305                A: de::MapAccess<'de>,
306            {
307                let mut spec = AliasesSpec::default();
308                while let Some(key) = map.next_key::<String>()? {
309                    let val: Option<String> = map.next_value()?;
310                    match val {
311                        Some(s) if !s.trim().is_empty() => {
312                            spec.shell.insert(key, s);
313                        }
314                        _ => spec.names.push(key),
315                    }
316                }
317                Ok(spec)
318            }
319
320            fn visit_none<E>(self) -> Result<Self::Value, E>
321            where
322                E: de::Error,
323            {
324                Ok(AliasesSpec::default())
325            }
326
327            fn visit_unit<E>(self) -> Result<Self::Value, E>
328            where
329                E: de::Error,
330            {
331                Ok(AliasesSpec::default())
332            }
333        }
334
335        deserializer.deserialize_any(AliasesVisitor)
336    }
337}
338
339/// Conservative identifier accepted by sh, bash, and zsh without parsing as
340/// syntax or an option. Hyphens are allowed after the first character because
341/// jan script names commonly contain them.
342pub(crate) fn is_safe_alias_name(name: &str) -> bool {
343    let mut chars = name.chars();
344    matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
345        && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
346}
347
348#[derive(Debug, Deserialize, Default, Clone)]
349pub struct CommandNode {
350    /// If non-empty, this command and its subtree are only offered on these
351    /// platforms (`linux`, `macos`, `windows`, …). `darwin` is accepted as an alias for `macos`.
352    #[serde(default)]
353    pub os: Vec<String>,
354    #[serde(default)]
355    pub about: String,
356    /// Directory prepended to PATH when this script (or a descendant leaf) runs.
357    pub path: Option<String>,
358    /// Other script names whose `path` directories are prepended before this one runs.
359    #[serde(default)]
360    pub dependencies: Vec<String>,
361    /// External binaries that must be on PATH (e.g. `fzf`, `jq`) before the leaf runs.
362    #[serde(default)]
363    pub requires: Vec<String>,
364    /// Public assignments and/or private names required from the host environment.
365    #[serde(default)]
366    pub env: EnvSpec,
367    /// Named CLI inputs (`--name value`) available as `${{ inputs.name }}` in env/argv.
368    #[serde(default)]
369    pub inputs: BTreeMap<String, crate::inputs::InputDef>,
370    /// Optional crontab schedule(s). When set, `jan cron` runs this script's `run`
371    /// leaf whenever the local time matches any expression.
372    #[serde(default, deserialize_with = "deserialize_string_or_seq")]
373    pub cron: Vec<String>,
374    /// Package-manager dependencies (uv now; pnpm reserved).
375    #[serde(default)]
376    pub packages: PackagesSpec,
377    /// Optional Given/When/Then shell tests (`jan test <path>`).
378    #[serde(default)]
379    pub tests: BTreeMap<String, CommandTest>,
380    /// Extra jan names and/or traditional shell aliases (`jan alias`).
381    #[serde(default)]
382    pub aliases: AliasesSpec,
383    #[serde(default)]
384    pub commands: BTreeMap<String, CommandNode>,
385    pub exec: Option<ExecSpec>,
386    /// Include link this node was loaded from, if any (filled by the loader).
387    #[serde(skip)]
388    pub source: Option<IncludeLink>,
389}
390
391/// One Given/When/Then shell test on a command node.
392///
393/// Names must follow `given_…_when_…_then_…` (spaces or hyphens are fine).
394/// `when` is extra argv for the command this test is declared on (shell-expanded);
395/// omit it to invoke that command with no extra args. stdout/stderr/status of that
396/// invocation are captured as `JAN_STATUS` / `JAN_STDOUT` / `JAN_STDERR` for `then`.
397#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
398pub struct CommandTest {
399    /// Setup (files, fixtures). Optional.
400    #[serde(default)]
401    pub given: String,
402    /// Extra argv after the command this test is declared on. Optional.
403    #[serde(default)]
404    pub when: String,
405    /// Assertions against the captured `when` result and any files from `given`.
406    #[serde(default)]
407    pub then: String,
408}
409
410impl CommandTest {
411    pub fn validate(&self, path: &str, name: &str) -> Result<()> {
412        if !gherkin_test_name(name) {
413            bail!(
414                "command '{path}': test `{name}` must follow the given_…_when_…_then_… naming pattern"
415            );
416        }
417        if self.then.trim().is_empty() {
418            bail!("command '{path}': test `{name}` needs a non-empty `then:` script");
419        }
420        Ok(())
421    }
422}
423
424/// True when `name` is `given_…_when_…_then_…` after normalizing spaces/hyphens.
425pub fn gherkin_test_name(name: &str) -> bool {
426    let n: String = name
427        .trim()
428        .to_ascii_lowercase()
429        .chars()
430        .map(|c| {
431            if c == '-' || c.is_whitespace() {
432                '_'
433            } else {
434                c
435            }
436        })
437        .collect();
438    let n = n
439        .split('_')
440        .filter(|s| !s.is_empty())
441        .collect::<Vec<_>>()
442        .join("_");
443    let Some(rest) = n.strip_prefix("given_") else {
444        return false;
445    };
446    let Some((given_body, after_when)) = rest.split_once("_when_") else {
447        return false;
448    };
449    let Some((when_body, then_body)) = after_when.split_once("_then_") else {
450        return false;
451    };
452    !given_body.is_empty() && !when_body.is_empty() && !then_body.is_empty()
453}
454
455/// Package-manager deps for a command node (`packages:` in YAML).
456///
457/// Distinct from `dependencies:`, which names other jan scripts whose `path`
458/// directories are prepended to PATH.
459#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
460pub struct PackagesSpec {
461    #[serde(default)]
462    pub uv: Option<UvPackages>,
463    #[serde(default)]
464    pub pnpm: Option<PnpmPackages>,
465    #[serde(default)]
466    pub gradle: Option<GradlePackages>,
467}
468
469impl PackagesSpec {
470    pub fn is_empty(&self) -> bool {
471        self.uv.is_none() && self.pnpm.is_none() && self.gradle.is_none()
472    }
473
474    /// Deeper node wins per manager (no list merge).
475    pub fn merge_from(&mut self, other: PackagesSpec) {
476        if other.uv.is_some() {
477            self.uv = other.uv;
478        }
479        if other.pnpm.is_some() {
480            self.pnpm = other.pnpm;
481        }
482        if other.gradle.is_some() {
483            self.gradle = other.gradle;
484        }
485    }
486
487    pub fn validate(&self, path: &str) -> Result<()> {
488        if let Some(uv) = &self.uv {
489            uv.validate(path)?;
490        }
491        if let Some(pnpm) = &self.pnpm {
492            pnpm.validate(path)?;
493        }
494        if let Some(gradle) = &self.gradle {
495            gradle.validate(path)?;
496        }
497        Ok(())
498    }
499}
500
501/// uv dependency declaration: inline list, project dir, or requirements file,
502/// with an optional minimum Python version (`python:`).
503#[derive(Debug, Clone, PartialEq, Eq)]
504pub struct UvPackages {
505    pub deps: UvDeps,
506    /// Minimum Python version, e.g. `3.11` or `>=3.11`.
507    pub python: Option<String>,
508}
509
510#[derive(Debug, Clone, PartialEq, Eq)]
511pub enum UvDeps {
512    List(Vec<String>),
513    Project(String),
514    Requirements(String),
515}
516
517impl UvPackages {
518    pub fn list(pkgs: Vec<String>) -> Self {
519        Self {
520            deps: UvDeps::List(pkgs),
521            python: None,
522        }
523    }
524
525    pub fn validate(&self, path: &str) -> Result<()> {
526        if let Some(py) = &self.python {
527            packages::parse_min_version_constraint(py)
528                .map_err(|e| anyhow::anyhow!("command '{path}': packages.uv.python: {e}"))?;
529        }
530        match &self.deps {
531            UvDeps::List(pkgs) => {
532                if pkgs.is_empty() {
533                    bail!("command '{path}': packages.uv list must not be empty");
534                }
535                for p in pkgs {
536                    if p.trim().is_empty() {
537                        bail!("command '{path}': packages.uv entry must not be empty");
538                    }
539                    packages::check_pinned_requirement(p)
540                        .map_err(|e| anyhow::anyhow!("command '{path}': {e}"))?;
541                }
542            }
543            UvDeps::Project(p) | UvDeps::Requirements(p) => {
544                if p.trim().is_empty() {
545                    bail!("command '{path}': packages.uv path must not be empty");
546                }
547            }
548        }
549        Ok(())
550    }
551}
552
553impl<'de> Deserialize<'de> for UvPackages {
554    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
555    where
556        D: Deserializer<'de>,
557    {
558        #[derive(Deserialize)]
559        #[serde(deny_unknown_fields)]
560        struct MapForm {
561            #[serde(default)]
562            project: Option<String>,
563            #[serde(default)]
564            requirements: Option<String>,
565            #[serde(default, alias = "deps")]
566            packages: Option<Vec<String>>,
567            #[serde(default, deserialize_with = "deserialize_opt_stringish")]
568            python: Option<String>,
569        }
570
571        #[derive(Deserialize)]
572        #[serde(untagged)]
573        enum Helper {
574            List(Vec<String>),
575            Map(MapForm),
576        }
577
578        match Helper::deserialize(deserializer)? {
579            Helper::List(pkgs) => {
580                let pkgs: Vec<String> = pkgs
581                    .into_iter()
582                    .map(|s| s.trim().to_string())
583                    .filter(|s| !s.is_empty())
584                    .collect();
585                Ok(UvPackages {
586                    deps: UvDeps::List(pkgs),
587                    python: None,
588                })
589            }
590            Helper::Map(m) => {
591                let project = m
592                    .project
593                    .map(|s| s.trim().to_string())
594                    .filter(|s| !s.is_empty());
595                let requirements = m
596                    .requirements
597                    .map(|s| s.trim().to_string())
598                    .filter(|s| !s.is_empty());
599                let packages = m.packages.map(|pkgs| {
600                    pkgs.into_iter()
601                        .map(|s| s.trim().to_string())
602                        .filter(|s| !s.is_empty())
603                        .collect::<Vec<_>>()
604                });
605                let python = m
606                    .python
607                    .map(|s| s.trim().to_string())
608                    .filter(|s| !s.is_empty());
609                let deps = match (project, requirements, packages) {
610                    (Some(p), None, None) => UvDeps::Project(p),
611                    (None, Some(r), None) => UvDeps::Requirements(r),
612                    (None, None, Some(pkgs)) => UvDeps::List(pkgs),
613                    _ => {
614                        return Err(de::Error::custom(
615                            "packages.uv map must set exactly one of `packages`, `project`, or `requirements`",
616                        ));
617                    }
618                };
619                Ok(UvPackages { deps, python })
620            }
621        }
622    }
623}
624
625/// pnpm dependency declaration: inline list or a project dir with a lockfile,
626/// with an optional minimum Node version (`node:`).
627#[derive(Debug, Clone, PartialEq, Eq)]
628pub struct PnpmPackages {
629    pub deps: PnpmDeps,
630    /// Minimum Node.js version, e.g. `18` or `>=18.0.0`.
631    pub node: Option<String>,
632}
633
634#[derive(Debug, Clone, PartialEq, Eq)]
635pub enum PnpmDeps {
636    List(Vec<String>),
637    Project(String),
638}
639
640impl PnpmPackages {
641    pub fn list(pkgs: Vec<String>) -> Self {
642        Self {
643            deps: PnpmDeps::List(pkgs),
644            node: None,
645        }
646    }
647
648    pub fn validate(&self, path: &str) -> Result<()> {
649        if let Some(node) = &self.node {
650            packages::parse_min_version_constraint(node)
651                .map_err(|e| anyhow::anyhow!("command '{path}': packages.pnpm.node: {e}"))?;
652        }
653        match &self.deps {
654            PnpmDeps::List(pkgs) => {
655                if pkgs.is_empty() {
656                    bail!("command '{path}': packages.pnpm list must not be empty");
657                }
658                for p in pkgs {
659                    if p.trim().is_empty() {
660                        bail!("command '{path}': packages.pnpm entry must not be empty");
661                    }
662                    packages::check_pinned_npm_spec(p)
663                        .map_err(|e| anyhow::anyhow!("command '{path}': {e}"))?;
664                }
665            }
666            PnpmDeps::Project(p) => {
667                if p.trim().is_empty() {
668                    bail!("command '{path}': packages.pnpm path must not be empty");
669                }
670            }
671        }
672        Ok(())
673    }
674}
675
676impl<'de> Deserialize<'de> for PnpmPackages {
677    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
678    where
679        D: Deserializer<'de>,
680    {
681        #[derive(Deserialize)]
682        #[serde(deny_unknown_fields)]
683        struct MapForm {
684            #[serde(default)]
685            project: Option<String>,
686            #[serde(default, alias = "deps")]
687            packages: Option<Vec<String>>,
688            #[serde(default, deserialize_with = "deserialize_opt_stringish")]
689            node: Option<String>,
690        }
691
692        #[derive(Deserialize)]
693        #[serde(untagged)]
694        enum Helper {
695            List(Vec<String>),
696            Map(MapForm),
697        }
698
699        match Helper::deserialize(deserializer)? {
700            Helper::List(pkgs) => {
701                let pkgs: Vec<String> = pkgs
702                    .into_iter()
703                    .map(|s| s.trim().to_string())
704                    .filter(|s| !s.is_empty())
705                    .collect();
706                Ok(PnpmPackages {
707                    deps: PnpmDeps::List(pkgs),
708                    node: None,
709                })
710            }
711            Helper::Map(m) => {
712                let project = m
713                    .project
714                    .map(|s| s.trim().to_string())
715                    .filter(|s| !s.is_empty());
716                let packages = m.packages.map(|pkgs| {
717                    pkgs.into_iter()
718                        .map(|s| s.trim().to_string())
719                        .filter(|s| !s.is_empty())
720                        .collect::<Vec<_>>()
721                });
722                let node = m
723                    .node
724                    .map(|s| s.trim().to_string())
725                    .filter(|s| !s.is_empty());
726                let deps = match (project, packages) {
727                    (Some(p), None) => PnpmDeps::Project(p),
728                    (None, Some(pkgs)) => PnpmDeps::List(pkgs),
729                    _ => {
730                        return Err(de::Error::custom(
731                            "packages.pnpm map must set exactly one of `packages` or `project`",
732                        ));
733                    }
734                };
735                Ok(PnpmPackages { deps, node })
736            }
737        }
738    }
739}
740
741/// Gradle dependency declaration: pinned Maven coordinates or a locked project,
742/// with an optional minimum JDK (`java:` / `jdk:`).
743#[derive(Debug, Clone, PartialEq, Eq)]
744pub struct GradlePackages {
745    pub deps: GradleDeps,
746    /// Minimum JDK/Java version, e.g. `21` or `>=21`.
747    pub java: Option<String>,
748}
749
750#[derive(Debug, Clone, PartialEq, Eq)]
751pub enum GradleDeps {
752    List(Vec<String>),
753    Project(String),
754}
755
756impl GradlePackages {
757    pub fn list(pkgs: Vec<String>) -> Self {
758        Self {
759            deps: GradleDeps::List(pkgs),
760            java: None,
761        }
762    }
763
764    pub fn validate(&self, path: &str) -> Result<()> {
765        if let Some(java) = &self.java {
766            packages::parse_min_version_constraint(java)
767                .map_err(|e| anyhow::anyhow!("command '{path}': packages.gradle.java: {e}"))?;
768        }
769        match &self.deps {
770            GradleDeps::List(pkgs) => {
771                if pkgs.is_empty() {
772                    bail!("command '{path}': packages.gradle list must not be empty");
773                }
774                for p in pkgs {
775                    if p.trim().is_empty() {
776                        bail!("command '{path}': packages.gradle entry must not be empty");
777                    }
778                    packages::check_pinned_maven_coord(p)
779                        .map_err(|e| anyhow::anyhow!("command '{path}': {e}"))?;
780                }
781            }
782            GradleDeps::Project(p) => {
783                if p.trim().is_empty() {
784                    bail!("command '{path}': packages.gradle path must not be empty");
785                }
786            }
787        }
788        Ok(())
789    }
790}
791
792impl<'de> Deserialize<'de> for GradlePackages {
793    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
794    where
795        D: Deserializer<'de>,
796    {
797        #[derive(Deserialize)]
798        #[serde(deny_unknown_fields)]
799        struct MapForm {
800            #[serde(default)]
801            project: Option<String>,
802            #[serde(default, alias = "deps")]
803            packages: Option<Vec<String>>,
804            #[serde(default, alias = "jdk", deserialize_with = "deserialize_opt_stringish")]
805            java: Option<String>,
806        }
807
808        #[derive(Deserialize)]
809        #[serde(untagged)]
810        enum Helper {
811            List(Vec<String>),
812            Map(MapForm),
813        }
814
815        match Helper::deserialize(deserializer)? {
816            Helper::List(pkgs) => {
817                let pkgs: Vec<String> = pkgs
818                    .into_iter()
819                    .map(|s| s.trim().to_string())
820                    .filter(|s| !s.is_empty())
821                    .collect();
822                Ok(GradlePackages {
823                    deps: GradleDeps::List(pkgs),
824                    java: None,
825                })
826            }
827            Helper::Map(m) => {
828                let project = m
829                    .project
830                    .map(|s| s.trim().to_string())
831                    .filter(|s| !s.is_empty());
832                let packages = m.packages.map(|pkgs| {
833                    pkgs.into_iter()
834                        .map(|s| s.trim().to_string())
835                        .filter(|s| !s.is_empty())
836                        .collect::<Vec<_>>()
837                });
838                let java = m
839                    .java
840                    .map(|s| s.trim().to_string())
841                    .filter(|s| !s.is_empty());
842                let deps = match (project, packages) {
843                    (Some(p), None) => GradleDeps::Project(p),
844                    (None, Some(pkgs)) => GradleDeps::List(pkgs),
845                    _ => {
846                        return Err(de::Error::custom(
847                            "packages.gradle map must set exactly one of `packages` or `project`",
848                        ));
849                    }
850                };
851                Ok(GradlePackages { deps, java })
852            }
853        }
854    }
855}
856
857pub(crate) fn deserialize_opt_stringish<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
858where
859    D: Deserializer<'de>,
860{
861    struct Stringish;
862
863    impl<'de> Visitor<'de> for Stringish {
864        type Value = Option<String>;
865
866        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
867            formatter.write_str("a string or number version constraint, or null")
868        }
869
870        fn visit_none<E>(self) -> Result<Self::Value, E>
871        where
872            E: de::Error,
873        {
874            Ok(None)
875        }
876
877        fn visit_unit<E>(self) -> Result<Self::Value, E>
878        where
879            E: de::Error,
880        {
881            Ok(None)
882        }
883
884        fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
885        where
886            E: de::Error,
887        {
888            let t = value.trim();
889            if t.is_empty() {
890                Ok(None)
891            } else {
892                Ok(Some(t.to_string()))
893            }
894        }
895
896        fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
897        where
898            E: de::Error,
899        {
900            self.visit_str(&value)
901        }
902
903        fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
904        where
905            E: de::Error,
906        {
907            Ok(Some(value.to_string()))
908        }
909
910        fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
911        where
912            E: de::Error,
913        {
914            Ok(Some(value.to_string()))
915        }
916
917        fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
918        where
919            E: de::Error,
920        {
921            // YAML may parse 3.11 as a float — keep a readable form.
922            let s = if (value.fract()).abs() < f64::EPSILON {
923                format!("{}", value as i64)
924            } else {
925                // Trim float noise: 3.110000 -> 3.11
926                let s = format!("{value}");
927                s.trim_end_matches('0').trim_end_matches('.').to_string()
928            };
929            Ok(Some(s))
930        }
931    }
932
933    deserializer.deserialize_any(Stringish)
934}
935
936pub(crate) fn deserialize_string_or_seq<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
937where
938    D: Deserializer<'de>,
939{
940    struct StringOrSeq;
941
942    impl<'de> Visitor<'de> for StringOrSeq {
943        type Value = Vec<String>;
944
945        fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
946            formatter.write_str("a string or a sequence of strings")
947        }
948
949        fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
950        where
951            E: de::Error,
952        {
953            if value.trim().is_empty() {
954                Ok(Vec::new())
955            } else {
956                Ok(vec![value.to_string()])
957            }
958        }
959
960        fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
961        where
962            E: de::Error,
963        {
964            self.visit_str(&value)
965        }
966
967        fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
968        where
969            A: de::SeqAccess<'de>,
970        {
971            let mut out = Vec::new();
972            while let Some(s) = seq.next_element::<String>()? {
973                if !s.trim().is_empty() {
974                    out.push(s);
975                }
976            }
977            Ok(out)
978        }
979
980        fn visit_none<E>(self) -> Result<Self::Value, E>
981        where
982            E: de::Error,
983        {
984            Ok(Vec::new())
985        }
986
987        fn visit_unit<E>(self) -> Result<Self::Value, E>
988        where
989            E: de::Error,
990        {
991            Ok(Vec::new())
992        }
993    }
994
995    deserializer.deserialize_any(StringOrSeq)
996}
997
998/// Local include under the preferred jan directory (YAML subtree or script file).
999#[derive(Debug, Clone, PartialEq, Eq)]
1000pub struct LocalInclude {
1001    pub path: String,
1002    /// Optional integrity pin; verified when present.
1003    pub sha256: Option<String>,
1004    /// Interpreter prefix for script includes only (e.g. `["bash"]`).
1005    pub argv: Vec<String>,
1006    /// Passthrough trailing CLI args for script includes only.
1007    pub passthrough: bool,
1008}
1009
1010impl LocalInclude {
1011    pub fn from_path(path: impl Into<String>) -> Self {
1012        Self {
1013            path: path.into(),
1014            sha256: None,
1015            argv: Vec::new(),
1016            passthrough: false,
1017        }
1018    }
1019
1020    pub fn is_yaml(&self) -> bool {
1021        let lower = self.path.to_ascii_lowercase();
1022        lower.ends_with(".yaml") || lower.ends_with(".yml")
1023    }
1024}
1025
1026/// Local path or remote HTTPS include target.
1027#[derive(Debug, Clone, PartialEq, Eq)]
1028pub enum IncludeRef {
1029    /// Relative path under the preferred jan directory (optional sha256 / script opts).
1030    Local(LocalInclude),
1031    /// Remote YAML fetched with SHA256 verification.
1032    Remote(RemoteInclude),
1033}
1034
1035#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
1036pub struct RemoteInclude {
1037    pub url: String,
1038    pub sha256: String,
1039    #[serde(default)]
1040    pub ttl: Option<u64>,
1041}
1042
1043impl IncludeRef {
1044    pub fn is_remote(&self) -> bool {
1045        matches!(self, Self::Remote(_))
1046    }
1047
1048    pub fn local_path(&self) -> Option<&str> {
1049        match self {
1050            Self::Local(l) => Some(l.path.as_str()),
1051            Self::Remote(_) => None,
1052        }
1053    }
1054
1055    pub fn cycle_token(&self) -> String {
1056        match self {
1057            Self::Local(l) => match &l.sha256 {
1058                Some(h) => format!("{}#{}", l.path, h.to_ascii_lowercase()),
1059                None => l.path.clone(),
1060            },
1061            Self::Remote(r) => format!("{}#{}", r.url, r.sha256.to_ascii_lowercase()),
1062        }
1063    }
1064}
1065
1066impl<'de> Deserialize<'de> for IncludeRef {
1067    fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1068    where
1069        D: Deserializer<'de>,
1070    {
1071        #[derive(Deserialize)]
1072        #[serde(deny_unknown_fields)]
1073        struct LocalMap {
1074            path: String,
1075            #[serde(default)]
1076            sha256: Option<String>,
1077            #[serde(default)]
1078            argv: Vec<String>,
1079            #[serde(default)]
1080            passthrough: bool,
1081        }
1082
1083        #[derive(Deserialize)]
1084        #[serde(untagged)]
1085        enum Helper {
1086            Path(String),
1087            Local(LocalMap),
1088            Remote(RemoteInclude),
1089        }
1090
1091        match Helper::deserialize(deserializer)? {
1092            Helper::Path(path) => {
1093                let path = path.trim();
1094                if path.is_empty() {
1095                    return Err(de::Error::custom("include path must not be empty"));
1096                }
1097                Ok(IncludeRef::Local(LocalInclude::from_path(path)))
1098            }
1099            Helper::Local(m) => {
1100                let path = m.path.trim();
1101                if path.is_empty() {
1102                    return Err(de::Error::custom("include.path must not be empty"));
1103                }
1104                let sha256 = m
1105                    .sha256
1106                    .map(|s| s.trim().to_string())
1107                    .filter(|s| !s.is_empty());
1108                Ok(IncludeRef::Local(LocalInclude {
1109                    path: path.to_string(),
1110                    sha256,
1111                    argv: m.argv,
1112                    passthrough: m.passthrough,
1113                }))
1114            }
1115            Helper::Remote(r) => {
1116                if r.url.trim().is_empty() {
1117                    return Err(de::Error::custom("include.url must not be empty"));
1118                }
1119                if r.sha256.trim().is_empty() {
1120                    return Err(de::Error::custom(
1121                        "include.sha256 is required with include.url",
1122                    ));
1123                }
1124                Ok(IncludeRef::Remote(r))
1125            }
1126        }
1127    }
1128}
1129
1130#[derive(Debug, Deserialize, Clone, Default)]
1131pub struct ExecSpec {
1132    /// Program argv. For remote `url` / local `file` leaves this is an optional
1133    /// interpreter prefix (e.g. `["python3"]`); the script path is appended automatically.
1134    /// For `kotlin:` leaves this is the argument list passed to `main`.
1135    #[serde(default)]
1136    pub argv: Vec<String>,
1137    /// Append extra CLI arguments after those from `argv` / the script path.
1138    #[serde(default)]
1139    pub passthrough: bool,
1140    /// HTTPS URL of a remote script to download, verify, and run.
1141    #[serde(default)]
1142    pub url: Option<String>,
1143    /// Local script path relative to the jan use root (optional `sha256` pin).
1144    #[serde(default)]
1145    pub file: Option<String>,
1146    /// Kotlin source relative to the jan use root (`.kt` / `.kts`), or an inline
1147    /// program (multiline YAML string). Paths are a single line ending in
1148    /// `.kt`/`.kts`; anything else is treated as inlined source and compile-once
1149    /// as `.kt`. `argv` is passed to `main`.
1150    #[serde(default)]
1151    pub kotlin: Option<String>,
1152    /// Python source relative to the jan use root (`.py`), or an inline program.
1153    /// Runs with the `packages.uv` venv interpreter when present. `argv` is
1154    /// forwarded after the script / `-c` body (`sys.argv`).
1155    #[serde(default)]
1156    pub python: Option<String>,
1157    /// JavaScript source relative to the jan use root (`.js` / `.mjs` / `.cjs`),
1158    /// or an inline program. Runs with `node` and `packages.pnpm` `NODE_PATH`
1159    /// when present. `argv` is forwarded on `process.argv`.
1160    #[serde(default)]
1161    pub node: Option<String>,
1162    /// Bash source relative to the jan use root (`.sh` / `.bash`), or an inline
1163    /// program (`bash -lc`). `argv` is `$1`… (`$0` is the command name).
1164    #[serde(default)]
1165    pub bash: Option<String>,
1166    /// POSIX `sh` source (`.sh`) or inline (`sh -c`). `argv` is `$1`….
1167    #[serde(default)]
1168    pub sh: Option<String>,
1169    /// Zsh source (`.zsh` / `.sh`) or inline (`zsh -c`). `argv` is `$1`….
1170    #[serde(default)]
1171    pub zsh: Option<String>,
1172    /// Literal text to print with no subprocess (`text:` or alias `cat:`).
1173    #[serde(default, alias = "cat")]
1174    pub text: Option<String>,
1175    /// SHA256 of the script: required with `url`, optional with `file`.
1176    #[serde(default)]
1177    pub sha256: Option<String>,
1178    /// Optional TTL override (seconds) for the remote script cache.
1179    #[serde(default)]
1180    pub ttl: Option<u64>,
1181}
1182
1183impl ExecSpec {
1184    pub fn is_remote(&self) -> bool {
1185        self.url
1186            .as_deref()
1187            .map(|u| !u.trim().is_empty())
1188            .unwrap_or(false)
1189    }
1190
1191    pub fn is_local_file(&self) -> bool {
1192        self.file
1193            .as_deref()
1194            .map(|u| !u.trim().is_empty())
1195            .unwrap_or(false)
1196    }
1197
1198    pub fn is_kotlin(&self) -> bool {
1199        self.kotlin
1200            .as_deref()
1201            .map(|u| !u.trim().is_empty())
1202            .unwrap_or(false)
1203    }
1204
1205    pub fn is_python(&self) -> bool {
1206        self.python
1207            .as_deref()
1208            .map(|u| !u.trim().is_empty())
1209            .unwrap_or(false)
1210    }
1211
1212    pub fn is_node(&self) -> bool {
1213        self.node
1214            .as_deref()
1215            .map(|u| !u.trim().is_empty())
1216            .unwrap_or(false)
1217    }
1218
1219    pub fn is_bash(&self) -> bool {
1220        self.bash
1221            .as_deref()
1222            .map(|u| !u.trim().is_empty())
1223            .unwrap_or(false)
1224    }
1225
1226    pub fn is_sh(&self) -> bool {
1227        self.sh
1228            .as_deref()
1229            .map(|u| !u.trim().is_empty())
1230            .unwrap_or(false)
1231    }
1232
1233    pub fn is_zsh(&self) -> bool {
1234        self.zsh
1235            .as_deref()
1236            .map(|u| !u.trim().is_empty())
1237            .unwrap_or(false)
1238    }
1239
1240    pub fn is_text(&self) -> bool {
1241        self.text
1242            .as_deref()
1243            .map(|u| !u.trim().is_empty())
1244            .unwrap_or(false)
1245    }
1246
1247    /// Body for `exec.text` / `exec.cat`, trimmed of surrounding whitespace.
1248    pub fn literal_text(&self) -> Option<&str> {
1249        self.text
1250            .as_deref()
1251            .map(str::trim)
1252            .filter(|s| !s.is_empty())
1253    }
1254
1255    /// True when a language `exec.*` field is set.
1256    pub fn is_language_source(&self) -> bool {
1257        self.is_kotlin()
1258            || self.is_python()
1259            || self.is_node()
1260            || self.is_bash()
1261            || self.is_sh()
1262            || self.is_zsh()
1263    }
1264
1265    /// True when `raw` is a single-line `.kt` / `.kts` path (not inline source).
1266    pub fn kotlin_value_is_path(raw: &str) -> bool {
1267        Self::single_line_ext(raw, &[".kt", ".kts"])
1268    }
1269
1270    pub fn python_value_is_path(raw: &str) -> bool {
1271        Self::single_line_ext(raw, &[".py"])
1272    }
1273
1274    pub fn node_value_is_path(raw: &str) -> bool {
1275        Self::single_line_ext(raw, &[".js", ".mjs", ".cjs"])
1276    }
1277
1278    pub fn bash_value_is_path(raw: &str) -> bool {
1279        Self::single_line_ext(raw, &[".sh", ".bash"])
1280    }
1281
1282    pub fn sh_value_is_path(raw: &str) -> bool {
1283        Self::single_line_ext(raw, &[".sh"])
1284    }
1285
1286    pub fn zsh_value_is_path(raw: &str) -> bool {
1287        Self::single_line_ext(raw, &[".zsh", ".sh"])
1288    }
1289
1290    fn single_line_ext(raw: &str, exts: &[&str]) -> bool {
1291        let t = raw.trim();
1292        if t.is_empty() || t.lines().nth(1).is_some() {
1293            return false;
1294        }
1295        let lower = t.to_ascii_lowercase();
1296        exts.iter().any(|e| lower.ends_with(e))
1297    }
1298
1299    /// True when `exec.kotlin` names a tree-relative `.kt` / `.kts` file.
1300    pub fn kotlin_is_path(&self) -> bool {
1301        self.kotlin
1302            .as_deref()
1303            .map(Self::kotlin_value_is_path)
1304            .unwrap_or(false)
1305    }
1306
1307    pub fn validate(&self, path: &str) -> Result<()> {
1308        let url = self.url.as_deref().map(str::trim).filter(|s| !s.is_empty());
1309        let file = self
1310            .file
1311            .as_deref()
1312            .map(str::trim)
1313            .filter(|s| !s.is_empty());
1314        let kotlin = self
1315            .kotlin
1316            .as_deref()
1317            .map(str::trim)
1318            .filter(|s| !s.is_empty());
1319        let python = self
1320            .python
1321            .as_deref()
1322            .map(str::trim)
1323            .filter(|s| !s.is_empty());
1324        let node = self
1325            .node
1326            .as_deref()
1327            .map(str::trim)
1328            .filter(|s| !s.is_empty());
1329        let bash = self
1330            .bash
1331            .as_deref()
1332            .map(str::trim)
1333            .filter(|s| !s.is_empty());
1334        let sh = self.sh.as_deref().map(str::trim).filter(|s| !s.is_empty());
1335        let zsh = self.zsh.as_deref().map(str::trim).filter(|s| !s.is_empty());
1336        let text = self
1337            .text
1338            .as_deref()
1339            .map(str::trim)
1340            .filter(|s| !s.is_empty());
1341        let hash = self
1342            .sha256
1343            .as_deref()
1344            .map(str::trim)
1345            .filter(|s| !s.is_empty());
1346        let exclusive = [
1347            ("url", url),
1348            ("file", file),
1349            ("kotlin", kotlin),
1350            ("python", python),
1351            ("node", node),
1352            ("bash", bash),
1353            ("sh", sh),
1354            ("zsh", zsh),
1355            ("text", text),
1356        ];
1357        let set: Vec<(&str, &str)> = exclusive
1358            .iter()
1359            .copied()
1360            .filter_map(|(n, v)| v.map(|s| (n, s)))
1361            .collect();
1362        if set.len() > 1 {
1363            bail!(
1364                "command '{path}': exec cannot combine `url`, `file`, `kotlin`, `python`, `node`, `bash`, `sh`, `zsh`, and `text`"
1365            );
1366        }
1367        const LANG: &[&str] = &["kotlin", "python", "node", "bash", "sh", "zsh"];
1368        if hash.is_some() && set.iter().any(|(n, _)| LANG.contains(n) || *n == "text") {
1369            bail!(
1370                "command '{path}': exec.sha256 is not supported with exec.kotlin / exec.python / exec.node / exec.bash / exec.sh / exec.zsh / exec.text"
1371            );
1372        }
1373        match set.first().copied() {
1374            Some(("url", _)) if hash.is_some() => Ok(()),
1375            Some(("url", _)) => {
1376                bail!("command '{path}': exec.sha256 is required with exec.url")
1377            }
1378            Some(("file", _)) => Ok(()),
1379            Some(("text", _)) => Ok(()),
1380            Some(("kotlin", k)) => {
1381                if Self::kotlin_value_is_path(k) {
1382                    return Ok(());
1383                }
1384                if !k.contains("fun ") && !k.contains("fun\t") {
1385                    bail!(
1386                        "command '{path}': exec.kotlin inline source must contain a `fun` \
1387                         (or set a single-line `.kt` / `.kts` path)"
1388                    );
1389                }
1390                Ok(())
1391            }
1392            Some((label, src)) if LANG.contains(&label) => {
1393                let is_path = match label {
1394                    "python" => Self::python_value_is_path(src),
1395                    "node" => Self::node_value_is_path(src),
1396                    "bash" => Self::bash_value_is_path(src),
1397                    "sh" => Self::sh_value_is_path(src),
1398                    "zsh" => Self::zsh_value_is_path(src),
1399                    _ => false,
1400                };
1401                if is_path {
1402                    return Ok(());
1403                }
1404                if src.len() < 2 {
1405                    bail!("command '{path}': exec.{label} inline source is empty");
1406                }
1407                Ok(())
1408            }
1409            None if hash.is_some() => {
1410                bail!("command '{path}': exec.sha256 requires exec.url or exec.file")
1411            }
1412            None => {
1413                if self.argv.is_empty() {
1414                    bail!(
1415                        "command '{path}': exec.argv must not be empty (or set exec.url / exec.file / exec.kotlin / exec.python / exec.node / exec.bash / exec.sh / exec.zsh / exec.text)"
1416                    );
1417                }
1418                Ok(())
1419            }
1420            _ => unreachable!("modes > 1 checked above"),
1421        }
1422    }
1423}
1424
1425impl CommandNode {
1426    pub fn is_leaf_exec(&self) -> bool {
1427        self.exec.is_some()
1428    }
1429
1430    pub fn validate(&self, path: &str) -> Result<()> {
1431        if self.exec.is_some() && !self.commands.is_empty() {
1432            bail!("command '{path}' cannot define both `exec` and nested `commands`");
1433        }
1434        if let Some(ref e) = self.exec {
1435            e.validate(path)?;
1436        }
1437        self.aliases.validate(path)?;
1438        if !self.aliases.names.is_empty() {
1439            let is_jan_target = self
1440                .commands
1441                .get("run")
1442                .map(|r| r.exec.is_some())
1443                .unwrap_or(false)
1444                || (self.exec.is_some() && self.commands.is_empty());
1445            if !is_jan_target {
1446                bail!(
1447                    "command '{path}': `aliases` names (not map RHS) require this node to be a jan alias target (`run` with exec, or a leaf `exec`)"
1448                );
1449            }
1450        }
1451        self.env.validate(path)?;
1452        self.packages.validate(path)?;
1453        for (name, t) in &self.tests {
1454            t.validate(path, name)?;
1455        }
1456        for (name, def) in &self.inputs {
1457            def.validate(name)
1458                .map_err(|e| anyhow::anyhow!("command '{path}': {e}"))?;
1459        }
1460        for (name, child) in &self.commands {
1461            let p = if path.is_empty() {
1462                name.clone()
1463            } else {
1464                format!("{path} {name}")
1465            };
1466            child.validate(&p)?;
1467        }
1468        Ok(())
1469    }
1470}
1471
1472/// Deep-merge `overlay.commands` into `base`, letting included YAML fragments
1473/// add or replace leaves and extend nested groups.
1474pub fn merge_specs_into(base: &mut RootSpec, overlay: RootSpec) -> Result<()> {
1475    for (name, node) in overlay.commands {
1476        match base.commands.get_mut(&name) {
1477            Some(existing) => merge_command_node(existing, node)?,
1478            None => {
1479                base.commands.insert(name, node);
1480            }
1481        }
1482    }
1483    Ok(())
1484}
1485
1486fn merge_command_node(dst: &mut CommandNode, src: CommandNode) -> Result<()> {
1487    if src.exec.is_some() && !src.commands.is_empty() {
1488        bail!("merge overlay: command cannot define both `exec` and nested `commands`");
1489    }
1490    if !src.os.is_empty() {
1491        dst.os = src.os;
1492    }
1493    if !src.about.trim().is_empty() {
1494        dst.about = src.about;
1495    }
1496    if src.path.is_some() {
1497        dst.path = src.path;
1498    }
1499    if !src.dependencies.is_empty() {
1500        dst.dependencies = src.dependencies;
1501    }
1502    if !src.requires.is_empty() {
1503        dst.requires = src.requires;
1504    }
1505    if !src.cron.is_empty() {
1506        dst.cron = src.cron;
1507    }
1508    if !src.env.is_empty() {
1509        dst.env.merge_from(src.env);
1510    }
1511    for (k, v) in src.inputs {
1512        dst.inputs.insert(k, v);
1513    }
1514    for (k, v) in src.tests {
1515        dst.tests.insert(k, v);
1516    }
1517    dst.aliases.merge_from(src.aliases);
1518    if let Some(exec) = src.exec {
1519        dst.exec = Some(exec);
1520        dst.commands.clear();
1521        return Ok(());
1522    }
1523    if !src.commands.is_empty() {
1524        dst.exec = None;
1525        for (k, child) in src.commands {
1526            match dst.commands.get_mut(&k) {
1527                Some(existing) => merge_command_node(existing, child)?,
1528                None => {
1529                    dst.commands.insert(k, child);
1530                }
1531            }
1532        }
1533    }
1534    Ok(())
1535}
1536
1537/// Validate every command in the tree (after merges or programmatic edits).
1538pub fn validate_spec(spec: &RootSpec) -> Result<()> {
1539    for (name, node) in &spec.commands {
1540        node.validate(name)?;
1541    }
1542    Ok(())
1543}
1544
1545/// Parse YAML from memory. Use `include_base` when the document uses `include:` (root or subtree).
1546pub fn load_spec_from_str(raw: &str, include_base: Option<&Path>) -> Result<RootSpec> {
1547    spec_load::load_spec_from_str(raw, include_base, HostPlatform::detect())
1548}
1549
1550pub fn load_spec(path: &Path) -> Result<RootSpec> {
1551    spec_load::load_spec_from_path(path, HostPlatform::detect())
1552}
1553
1554pub fn resolve_git_branch(cwd: &Path, override_branch: Option<&str>) -> String {
1555    if let Some(b) = override_branch {
1556        if !b.is_empty() {
1557            return b.to_string();
1558        }
1559    }
1560    if let Ok(v) = std::env::var("JAN_BRANCH") {
1561        if !v.is_empty() {
1562            return v;
1563        }
1564    }
1565    let output = Command::new("git")
1566        .args(["rev-parse", "--abbrev-ref", "HEAD"])
1567        .current_dir(cwd)
1568        .output();
1569    match output {
1570        Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
1571        _ => "(no-git)".to_string(),
1572    }
1573}
1574
1575fn first_line(s: &str) -> String {
1576    s.lines().next().unwrap_or("").trim().to_string()
1577}
1578
1579/// Conventional `commands.help` leaf: documentation inlined into `--help`.
1580fn is_help_leaf(name: &str, child: &CommandNode) -> bool {
1581    name == "help" && child.exec.is_some() && child.commands.is_empty()
1582}
1583
1584/// Node that only holds `aliases:` (no exec, no nested commands). Folded into
1585/// parent `--help` instead of listed as a subcommand.
1586fn is_alias_bucket(child: &CommandNode) -> bool {
1587    !child.aliases.is_empty() && child.exec.is_none() && child.commands.is_empty()
1588}
1589
1590fn jan_invocation_for_node(bin: &str, chain: &[String], node: &CommandNode) -> String {
1591    let mut parts: Vec<String> = std::iter::once(bin.to_string())
1592        .chain(chain.iter().cloned())
1593        .collect();
1594    if node
1595        .commands
1596        .get("run")
1597        .map(|r| r.exec.is_some())
1598        .unwrap_or(false)
1599    {
1600        parts.push("run".into());
1601    }
1602    parts.join(" ")
1603}
1604
1605fn help_alias_lines(
1606    bin: &str,
1607    chain: &[String],
1608    node: &CommandNode,
1609) -> Vec<(String, String)> {
1610    let mut lines = BTreeMap::new();
1611    let target = jan_invocation_for_node(bin, chain, node);
1612    for name in &node.aliases.names {
1613        lines.insert(name.clone(), format!("same as `{target}`"));
1614    }
1615    for (name, rhs) in &node.aliases.shell {
1616        lines.insert(name.clone(), rhs.clone());
1617    }
1618    for child in node.commands.values() {
1619        if !is_alias_bucket(child) {
1620            continue;
1621        }
1622        for name in &child.aliases.names {
1623            lines
1624                .entry(name.clone())
1625                .or_insert_with(|| format!("same as `{target}`"));
1626        }
1627        for (name, rhs) in &child.aliases.shell {
1628            lines.entry(name.clone()).or_insert_with(|| rhs.clone());
1629        }
1630    }
1631    lines.into_iter().collect()
1632}
1633
1634fn append_help_aliases(
1635    out: &mut String,
1636    bin: &str,
1637    chain: &[String],
1638    node: Option<&CommandNode>,
1639) {
1640    let Some(n) = node else {
1641        return;
1642    };
1643    let lines = help_alias_lines(bin, chain, n);
1644    if lines.is_empty() {
1645        return;
1646    }
1647    out.push('\n');
1648    out.push_str("Aliases (`jan alias`):\n");
1649    for (name, rhs) in lines {
1650        out.push_str(&format!("  {name} — {}\n", first_line(&rhs)));
1651    }
1652}
1653
1654fn node_at_chain<'a>(spec: &'a RootSpec, chain: &[String]) -> Option<&'a CommandNode> {
1655    let mut map = &spec.commands;
1656    let mut node = None;
1657    for seg in chain {
1658        let next = map.get(seg)?;
1659        node = Some(next);
1660        map = &next.commands;
1661    }
1662    node
1663}
1664
1665/// Documentation body for `--help`: `exec.text` on this node, else `commands.help`,
1666/// else the parent script's `help` when this node is the `run` leaf.
1667fn command_help_text(
1668    spec: &RootSpec,
1669    chain: &[String],
1670    node: Option<&CommandNode>,
1671) -> Option<String> {
1672    let n = node?;
1673    if let Some(t) = n.exec.as_ref().and_then(ExecSpec::literal_text) {
1674        return Some(t.to_string());
1675    }
1676    if let Some(t) = n
1677        .commands
1678        .get("help")
1679        .and_then(|h| h.exec.as_ref())
1680        .and_then(ExecSpec::literal_text)
1681    {
1682        return Some(t.to_string());
1683    }
1684    if chain.last().map(String::as_str) == Some("run") && chain.len() >= 2 {
1685        let parent = node_at_chain(spec, &chain[..chain.len() - 1])?;
1686        return parent
1687            .commands
1688            .get("help")
1689            .and_then(|h| h.exec.as_ref())
1690            .and_then(ExecSpec::literal_text)
1691            .map(str::to_string);
1692    }
1693    None
1694}
1695
1696pub fn format_help(spec: &RootSpec, chain: &[String], node: Option<&CommandNode>) -> String {
1697    let mut out = String::new();
1698    let bin = spec
1699        .metadata
1700        .as_ref()
1701        .and_then(|m| m.name.as_deref())
1702        .unwrap_or("jan");
1703    let full_cmd = if chain.is_empty() {
1704        bin.to_string()
1705    } else {
1706        format!("{} {}", bin, chain.join(" "))
1707    };
1708
1709    let (about, children, exec) = match node {
1710        Some(n) => (n.about.as_str(), &n.commands, n.exec.as_ref()),
1711        None => ("", &spec.commands, None),
1712    };
1713
1714    if chain.is_empty() {
1715        if let Some(meta) = &spec.metadata {
1716            if let Some(desc) = &meta.description {
1717                out.push_str(desc.trim());
1718                out.push_str("\n\n");
1719            }
1720        }
1721    }
1722
1723    let help_doc = command_help_text(spec, chain, node);
1724    if let Some(doc) = &help_doc {
1725        out.push_str(doc);
1726        out.push_str("\n\n");
1727    } else if !about.is_empty() {
1728        out.push_str(about.trim());
1729        out.push_str("\n\n");
1730    }
1731
1732    let listed: Vec<(&String, &CommandNode)> = children
1733        .iter()
1734        .filter(|(name, child)| !is_help_leaf(name, child) && !is_alias_bucket(child))
1735        .collect();
1736    let has_aliases = node.map(|n| !help_alias_lines(bin, chain, n).is_empty()).unwrap_or(false);
1737
1738    if exec.is_some() && children.is_empty() {
1739        if help_doc.is_none() {
1740            out.push_str("This command runs an external program (see spec `exec.argv`).\n");
1741        }
1742        append_help_aliases(&mut out, bin, chain, node);
1743        append_help_inputs_and_tests(&mut out, spec, chain, node);
1744        return out;
1745    }
1746
1747    if !listed.is_empty() {
1748        out.push_str("Subcommands:\n");
1749        for (name, child) in &listed {
1750            let line = if child.about.is_empty() {
1751                format!("  {name}\n")
1752            } else {
1753                format!("  {name} — {}\n", first_line(&child.about))
1754            };
1755            out.push_str(&line);
1756        }
1757        out.push('\n');
1758        if listed.iter().any(|(n, _)| n.as_str() != "run") {
1759            out.push_str(&format!(
1760                "Use `{} --help` for more about a subcommand.\n",
1761                full_cmd
1762            ));
1763        }
1764        append_help_aliases(&mut out, bin, chain, node);
1765        append_help_inputs_and_tests(&mut out, spec, chain, node);
1766    } else if exec.is_none() && help_doc.is_none() && !has_aliases {
1767        out.push_str("(No subcommands defined.)\n");
1768        append_help_aliases(&mut out, bin, chain, node);
1769        append_help_inputs_and_tests(&mut out, spec, chain, node);
1770    } else {
1771        append_help_aliases(&mut out, bin, chain, node);
1772        append_help_inputs_and_tests(&mut out, spec, chain, node);
1773    }
1774    if chain.is_empty() && node.is_none() {
1775        out.push_str(
1776            "\nPlace `--help` or `-h` right after the subcommand prefix you want. Built-ins: `use`, `bundle`, `alias`, `list`, `search`, `show`, `validate`, `audit`, `cron`, `test`.\n",
1777        );
1778    }
1779    out
1780}
1781
1782fn append_help_inputs_and_tests(
1783    out: &mut String,
1784    spec: &RootSpec,
1785    chain: &[String],
1786    node: Option<&CommandNode>,
1787) {
1788    let defs = inputs::collect_chain_inputs(chain, spec);
1789    if !defs.is_empty() {
1790        out.push('\n');
1791        out.push_str(&inputs::format_inputs_help(&defs));
1792    }
1793    let n = match node {
1794        Some(n) => cmdtest::count_tests(n),
1795        None => spec.commands.values().map(cmdtest::count_tests).sum(),
1796    };
1797    if n > 0 {
1798        let hint = if chain.is_empty() {
1799            "jan test".to_string()
1800        } else {
1801            format!("jan test {}", chain.join(" "))
1802        };
1803        out.push_str(&format!("\n{n} test(s) — run with `{hint}`.\n"));
1804    }
1805}
1806
1807/// Stable identity for where a YAML spec tree is rooted (directory of linked fragments + entry file).
1808#[derive(Debug, Clone)]
1809pub struct SpecRootIdentity {
1810    /// Canonical directory containing top-level YAML fragments.
1811    pub spec_dir: String,
1812    /// Entry YAML file name relative to `spec_dir`.
1813    pub root_yaml: String,
1814}
1815
1816/// Resolve the preferred jan directory saved by `jan use`.
1817pub fn resolve_preferred_spec() -> Result<(PathBuf, SpecRootIdentity)> {
1818    let cfg = config::load_user_config().context("load user config")?;
1819    let Some(dir_s) = cfg
1820        .jan_dir
1821        .as_ref()
1822        .map(|s| s.trim())
1823        .filter(|s| !s.is_empty())
1824    else {
1825        bail!(
1826            "no preferred jan directory configured\n\
1827             Run `jan use <DIR>` to save a YAML command tree (see docs/PORTABLE_SCRIPTS.md)"
1828        );
1829    };
1830    let dir = PathBuf::from(dir_s);
1831    if !dir.is_dir() {
1832        bail!(
1833            "preferred jan directory does not exist: {}\n\
1834             Fix the path or run `jan use <DIR>` again (config: {})",
1835            dir.display(),
1836            config::config_path().display()
1837        );
1838    }
1839    let root = cfg
1840        .spec_root
1841        .as_deref()
1842        .map(str::trim)
1843        .filter(|s| !s.is_empty())
1844        .unwrap_or("scripts.spec.yaml");
1845    resolve_spec_dir_entry(&dir, root)
1846}
1847
1848/// Resolve a jan directory + entry file name into an absolute spec path and identity.
1849pub fn resolve_spec_dir_entry(
1850    spec_dir: &Path,
1851    root_yaml: &str,
1852) -> Result<(PathBuf, SpecRootIdentity)> {
1853    let rel = Path::new(root_yaml);
1854    if rel.is_absolute() {
1855        bail!("entry YAML must be a relative file name, not an absolute path");
1856    }
1857    if rel
1858        .components()
1859        .any(|c| matches!(c, std::path::Component::ParentDir))
1860    {
1861        bail!("entry YAML must not contain `..`");
1862    }
1863    let normal_only = rel
1864        .components()
1865        .all(|c| matches!(c, std::path::Component::Normal(_)));
1866    let n = rel
1867        .components()
1868        .filter(|c| matches!(c, std::path::Component::Normal(_)))
1869        .count();
1870    if !normal_only || n != 1 {
1871        bail!("entry YAML must be a single file name inside the jan directory");
1872    }
1873    let dir = spec_dir
1874        .canonicalize()
1875        .with_context(|| format!("canonicalize jan directory {}", spec_dir.display()))?;
1876    if !dir.is_dir() {
1877        bail!("not a directory: {}", dir.display());
1878    }
1879    let spec_path = dir.join(rel);
1880    if !spec_path.is_file() {
1881        bail!(
1882            "spec entry not found: {} (under {})\n\
1883             Expected a properly formatted jan directory (e.g. scripts.spec.yaml)",
1884            spec_path.display(),
1885            dir.display()
1886        );
1887    }
1888    let identity = SpecRootIdentity {
1889        spec_dir: dir.to_string_lossy().into_owned(),
1890        root_yaml: rel
1891            .file_name()
1892            .expect("relative root has file_name")
1893            .to_string_lossy()
1894            .into_owned(),
1895    };
1896    Ok((spec_path, identity))
1897}
1898
1899pub struct RunContext<'a> {
1900    pub cwd: &'a Path,
1901    pub db_path: Option<&'a Path>,
1902    pub branch: String,
1903    pub no_log: bool,
1904    pub spec_root: &'a SpecRootIdentity,
1905}
1906
1907/// True when `argv` is a POSIX-shell inline (`bash`/`zsh`/`sh`/… + `-c`/`-lc` + body)
1908/// with no `$0` placeholder after the body yet.
1909///
1910/// For those interpreters the first word after the `-c` string becomes `$0`, not `$1`.
1911/// Inlined jan scripts expect normal script semantics (`$1` / `"$@"` = user args), so
1912/// passthrough must insert a `$0` before forwarding.
1913fn shell_inline_c_needs_argv0(argv: &[String]) -> bool {
1914    if argv.len() != 3 {
1915        return false;
1916    }
1917    let prog = Path::new(&argv[0])
1918        .file_name()
1919        .and_then(|s| s.to_str())
1920        .unwrap_or(argv[0].as_str());
1921    let is_shell = matches!(
1922        prog,
1923        "bash" | "zsh" | "sh" | "dash" | "ksh" | "ash" | "busybox"
1924    );
1925    is_shell && matches!(argv[1].as_str(), "-c" | "-lc")
1926}
1927
1928fn shell_passthrough_argv0(chain: &[String]) -> String {
1929    chain
1930        .iter()
1931        .rev()
1932        .find(|s| s.as_str() != "run")
1933        .cloned()
1934        .or_else(|| chain.last().cloned())
1935        .unwrap_or_else(|| "jan".to_string())
1936}
1937
1938pub fn run_matched(
1939    spec: &RootSpec,
1940    chain: &[String],
1941    node: &CommandNode,
1942    trailing: &[OsString],
1943    ctx: &RunContext<'_>,
1944) -> Result<i32> {
1945    let exec = match &node.exec {
1946        Some(e) => e,
1947        None => {
1948            let help = format_help(spec, chain, Some(node));
1949            print!("{help}");
1950            bail!("missing subcommand");
1951        }
1952    };
1953    exec.validate(&chain.join(" "))?;
1954
1955    if exec.is_text() {
1956        let body = exec.literal_text().unwrap_or("");
1957        println!("{body}");
1958        return Ok(0);
1959    }
1960
1961    let input_defs = inputs::collect_chain_inputs(chain, spec);
1962    let (input_vals, rest) = inputs::resolve_inputs(&input_defs, trailing, Some(ctx.cwd))?;
1963
1964    let mut argv: Vec<String> = Vec::with_capacity(exec.argv.len() + 1);
1965    for a in &exec.argv {
1966        argv.push(inputs::interpolate(a, &input_vals)?);
1967    }
1968
1969    if exec.is_remote() {
1970        let url = exec.url.as_deref().unwrap().trim();
1971        let hash = exec.sha256.as_deref().unwrap().trim();
1972        let mut opts = remote::FetchOpts::new();
1973        if let Some(ttl) = exec.ttl {
1974            opts = opts.with_ttl(ttl);
1975        }
1976        let cached = remote::fetch_verified(url, hash, &opts, true)?;
1977        argv.push(cached.to_string_lossy().into_owned());
1978    } else if exec.is_local_file() {
1979        let rel = exec.file.as_deref().unwrap().trim();
1980        let use_root = Path::new(&ctx.spec_root.spec_dir);
1981        let resolved = spec_load::resolve_under_use_root(use_root, rel)?;
1982        if let Some(hash) = exec
1983            .sha256
1984            .as_deref()
1985            .map(str::trim)
1986            .filter(|s| !s.is_empty())
1987        {
1988            remote::verify_file_sha256(&resolved, hash)
1989                .with_context(|| format!("verify exec.file `{rel}`"))?;
1990        }
1991        argv.push(resolved.to_string_lossy().into_owned());
1992    } else if exec.is_language_source() {
1993        // Args in `argv` are for the language entrypoint; the runner is built after packages.
1994    } else if argv.is_empty() {
1995        bail!("exec.argv must not be empty");
1996    }
1997
1998    if exec.passthrough {
1999        let mut rest = rest;
2000        // `--` after the leaf is the usual jan separator; drop one leading `--` so
2001        // `run -- arg` and `run arg` match for both inline shells and `exec.file` /
2002        // script includes. A literal first arg of `--` needs `run -- --`.
2003        if rest.first().is_some_and(|a| a == "--") {
2004            rest = rest[1..].to_vec();
2005        }
2006        if shell_inline_c_needs_argv0(&argv) {
2007            argv.push(shell_passthrough_argv0(chain));
2008        }
2009        for a in &rest {
2010            argv.push(a.to_string_lossy().into_owned());
2011        }
2012    } else if !rest.is_empty() {
2013        let preview = rest
2014            .iter()
2015            .take(3)
2016            .map(|s| s.to_string_lossy().into_owned())
2017            .collect::<Vec<_>>()
2018            .join(" ");
2019        bail!(
2020            "unexpected trailing arguments: {preview}{}",
2021            if rest.len() > 3 { "…" } else { "" }
2022        );
2023    }
2024
2025    let cmd_path = if chain.is_empty() {
2026        "(root)".to_string()
2027    } else {
2028        chain.join(" ")
2029    };
2030
2031    let (_, requires, _) = deps::collect_chain_metadata(chain, spec);
2032    deps::check_requires(&requires)?;
2033
2034    let pkgs = packages::collect_chain_packages(chain, spec);
2035    let pkg_envs = packages::ensure_packages(&pkgs, ctx)?;
2036
2037    if exec.is_kotlin() {
2038        let rel = exec.kotlin.as_deref().unwrap().trim();
2039        let use_root = Path::new(&ctx.spec_root.spec_dir);
2040        let main_args = std::mem::take(&mut argv);
2041        argv = packages::prepare_kotlin_argv(use_root, rel, &pkg_envs, &main_args)?;
2042    } else if exec.is_python() {
2043        let src = exec.python.as_deref().unwrap().trim();
2044        let use_root = Path::new(&ctx.spec_root.spec_dir);
2045        let main_args = std::mem::take(&mut argv);
2046        argv = packages::prepare_python_argv(use_root, src, &main_args)?;
2047    } else if exec.is_node() {
2048        let src = exec.node.as_deref().unwrap().trim();
2049        let use_root = Path::new(&ctx.spec_root.spec_dir);
2050        let main_args = std::mem::take(&mut argv);
2051        argv = packages::prepare_node_argv(use_root, src, &main_args)?;
2052    } else if exec.is_bash() || exec.is_sh() || exec.is_zsh() {
2053        let (kind, src) = if exec.is_bash() {
2054            (
2055                packages::ShellKind::Bash,
2056                exec.bash.as_deref().unwrap().trim(),
2057            )
2058        } else if exec.is_zsh() {
2059            (
2060                packages::ShellKind::Zsh,
2061                exec.zsh.as_deref().unwrap().trim(),
2062            )
2063        } else {
2064            (packages::ShellKind::Sh, exec.sh.as_deref().unwrap().trim())
2065        };
2066        let use_root = Path::new(&ctx.spec_root.spec_dir);
2067        let main_args = std::mem::take(&mut argv);
2068        let argv0 = shell_passthrough_argv0(chain);
2069        argv = packages::prepare_shell_argv(kind, use_root, src, &argv0, &main_args)?;
2070    } else {
2071        packages::inject_jvm_classpath(&mut argv, &pkg_envs);
2072    }
2073
2074    let path_dirs = deps::resolve_path_prefixes(spec, chain, ctx)?;
2075    let program = packages::resolve_program_with_envs(&argv[0], &pkg_envs, &path_dirs)?;
2076    let mut env_spec = deps::collect_chain_env(chain, spec);
2077    for value in env_spec.public.values_mut() {
2078        *value = inputs::interpolate(value, &input_vals)?;
2079    }
2080    deps::check_private_env(&env_spec.private)?;
2081    let mut path_override = if !path_dirs.is_empty() {
2082        Some(deps::prepend_path_env(&path_dirs)?)
2083    } else {
2084        None
2085    };
2086    if !pkg_envs.is_empty() {
2087        path_override = Some(packages::prepend_env_paths(&pkg_envs, path_override)?);
2088    }
2089    if let Some(node_path) = packages::node_path_for(&pkg_envs) {
2090        env_spec
2091            .public
2092            .entry("NODE_PATH".to_string())
2093            .or_insert(node_path);
2094    }
2095    if let Some(classpath) = packages::classpath_for(&pkg_envs) {
2096        env_spec
2097            .public
2098            .entry("CLASSPATH".to_string())
2099            .or_insert(classpath);
2100    }
2101
2102    let mut c = Command::new(&program);
2103    if argv.len() > 1 {
2104        c.args(&argv[1..]);
2105    }
2106    c.current_dir(ctx.cwd);
2107    deps::apply_process_env(&mut c, &env_spec, path_override)?;
2108
2109    let status = c.status().with_context(|| format!("spawn `{}`", argv[0]))?;
2110    let code = status.code().unwrap_or(255);
2111
2112    if !ctx.no_log {
2113        if let Some(db) = ctx.db_path {
2114            log_invocation(
2115                db,
2116                &ctx.branch,
2117                ctx.cwd,
2118                &cmd_path,
2119                &argv,
2120                code,
2121                ctx.spec_root,
2122            )?;
2123        }
2124    }
2125
2126    Ok(code)
2127}
2128
2129fn ensure_invocations_spec_root_column(conn: &Connection) -> Result<()> {
2130    let mut stmt = conn.prepare("PRAGMA table_info(invocations)")?;
2131    let cols: Vec<String> = stmt
2132        .query_map([], |row| row.get::<_, String>(1))?
2133        .collect::<std::result::Result<_, _>>()?;
2134    if !cols.iter().any(|c| c == "spec_root_id") {
2135        conn.execute(
2136            "ALTER TABLE invocations ADD COLUMN spec_root_id INTEGER",
2137            [],
2138        )?;
2139    }
2140    Ok(())
2141}
2142
2143fn upsert_spec_root(conn: &Connection, spec: &SpecRootIdentity) -> Result<i64> {
2144    let ts = unix_ts();
2145    conn.execute(
2146        r"INSERT INTO spec_roots (spec_dir, root_yaml, last_used_ts) VALUES (?1, ?2, ?3)
2147          ON CONFLICT(spec_dir, root_yaml) DO UPDATE SET last_used_ts = excluded.last_used_ts",
2148        rusqlite::params![&spec.spec_dir, &spec.root_yaml, &ts],
2149    )?;
2150    let id: i64 = conn.query_row(
2151        "SELECT id FROM spec_roots WHERE spec_dir = ?1 AND root_yaml = ?2",
2152        [&spec.spec_dir, &spec.root_yaml],
2153        |r| r.get(0),
2154    )?;
2155    Ok(id)
2156}
2157
2158fn log_invocation(
2159    db_path: &Path,
2160    branch: &str,
2161    cwd: &Path,
2162    command_path: &str,
2163    argv: &[String],
2164    exit_code: i32,
2165    spec_root: &SpecRootIdentity,
2166) -> Result<()> {
2167    if let Some(parent) = db_path.parent() {
2168        std::fs::create_dir_all(parent).ok();
2169    }
2170    let conn = Connection::open(db_path).with_context(|| format!("open {}", db_path.display()))?;
2171    conn.execute_batch(
2172        r"
2173        CREATE TABLE IF NOT EXISTS spec_roots (
2174            id INTEGER PRIMARY KEY AUTOINCREMENT,
2175            spec_dir TEXT NOT NULL,
2176            root_yaml TEXT NOT NULL,
2177            last_used_ts TEXT NOT NULL,
2178            UNIQUE(spec_dir, root_yaml)
2179        );
2180        CREATE TABLE IF NOT EXISTS invocations (
2181            id INTEGER PRIMARY KEY AUTOINCREMENT,
2182            ts TEXT NOT NULL,
2183            git_branch TEXT NOT NULL,
2184            cwd TEXT NOT NULL,
2185            command_path TEXT NOT NULL,
2186            argv_json TEXT NOT NULL,
2187            exit_code INTEGER NOT NULL,
2188            spec_root_id INTEGER
2189        );
2190        ",
2191    )?;
2192    ensure_invocations_spec_root_column(&conn)?;
2193    let spec_root_id = upsert_spec_root(&conn, spec_root)?;
2194    let ts = unix_ts();
2195    let argv_json = serde_json::to_string(argv).unwrap_or_else(|_| "[]".to_string());
2196    let cwd_s = cwd.to_string_lossy();
2197    conn.execute(
2198        "INSERT INTO invocations (ts, git_branch, cwd, command_path, argv_json, exit_code, spec_root_id)
2199         VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
2200        rusqlite::params![
2201            ts,
2202            branch,
2203            cwd_s.as_ref(),
2204            command_path,
2205            argv_json,
2206            exit_code,
2207            spec_root_id
2208        ],
2209    )?;
2210    Ok(())
2211}
2212
2213fn unix_ts() -> String {
2214    use std::time::SystemTime;
2215    SystemTime::now()
2216        .duration_since(std::time::UNIX_EPOCH)
2217        .unwrap_or_default()
2218        .as_secs()
2219        .to_string()
2220}
2221
2222#[derive(Debug)]
2223pub struct MatchOutcome<'a> {
2224    pub chain: Vec<String>,
2225    pub node: Option<&'a CommandNode>,
2226    pub trailing: Vec<OsString>,
2227    pub wants_help: bool,
2228}
2229
2230pub fn match_commands<'a>(spec: &'a RootSpec, args: &[OsString]) -> MatchOutcome<'a> {
2231    let mut chain = Vec::new();
2232    let mut node: Option<&'a CommandNode> = None;
2233    let mut map: &BTreeMap<String, CommandNode> = &spec.commands;
2234    let mut i = 0usize;
2235    let len = args.len();
2236    while i < len {
2237        let raw = &args[i];
2238        if raw == "--help" || raw == "-h" {
2239            return MatchOutcome {
2240                chain,
2241                node,
2242                trailing: args[i + 1..].to_vec(),
2243                wants_help: true,
2244            };
2245        }
2246        let key = raw.to_string_lossy();
2247        if let Some(next) = map.get(key.as_ref()) {
2248            chain.push(key.into_owned());
2249            node = Some(next);
2250            map = &next.commands;
2251            i += 1;
2252            continue;
2253        }
2254        break;
2255    }
2256    MatchOutcome {
2257        chain,
2258        node,
2259        trailing: args[i..].to_vec(),
2260        wants_help: false,
2261    }
2262}
2263
2264#[cfg(test)]
2265mod tests {
2266    use super::*;
2267    use std::io::Write;
2268
2269    #[test]
2270    fn examples_default_spec_validates() {
2271        let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/default.spec.yaml");
2272        load_spec(&path).unwrap();
2273    }
2274
2275    #[test]
2276    fn merge_specs_adds_and_replaces_leaves() {
2277        let mut base = load_spec_from_str(
2278            r"
2279commands:
2280  a:
2281    about: base
2282    commands:
2283      x:
2284        about: old
2285        exec:
2286          argv: [echo, old]
2287",
2288            None,
2289        )
2290        .unwrap();
2291        let overlay = load_spec_from_str(
2292            r"
2293commands:
2294  a:
2295    commands:
2296      x:
2297        about: new leaf
2298        exec:
2299          argv: [echo, new]
2300  b:
2301    about: added top
2302    exec:
2303      argv: [echo, b]
2304",
2305            None,
2306        )
2307        .unwrap();
2308        merge_specs_into(&mut base, overlay).unwrap();
2309        base.commands["a"].commands["x"].validate("a x").unwrap();
2310        assert_eq!(
2311            base.commands["a"].commands["x"].exec.as_ref().unwrap().argv,
2312            vec!["echo", "new"]
2313        );
2314        assert_eq!(
2315            base.commands["b"].exec.as_ref().unwrap().argv,
2316            vec!["echo", "b"]
2317        );
2318    }
2319
2320    #[test]
2321    fn validate_rejects_exec_with_children() {
2322        let mut tmp = tempfile::NamedTempFile::with_suffix(".yaml").unwrap();
2323        write!(
2324            tmp,
2325            r"
2326commands:
2327  x:
2328    exec:
2329      argv: [echo]
2330    commands:
2331      child:
2332        about: nested
2333"
2334        )
2335        .unwrap();
2336        let err = load_spec(tmp.path()).unwrap_err();
2337        assert!(err.to_string().contains("cannot define both"));
2338    }
2339
2340    #[test]
2341    fn shell_inline_c_needs_argv0_detects_bash_lc() {
2342        let argv = vec![
2343            "bash".into(),
2344            "-lc".into(),
2345            "case \"$1\" in create) ;; esac".into(),
2346        ];
2347        assert!(shell_inline_c_needs_argv0(&argv));
2348        let with_placeholder = vec!["zsh".into(), "-c".into(), "echo".into(), "issue".into()];
2349        assert!(!shell_inline_c_needs_argv0(&with_placeholder));
2350        assert!(!shell_inline_c_needs_argv0(&[
2351            "echo".into(),
2352            "start".into()
2353        ]));
2354        assert!(!shell_inline_c_needs_argv0(&[
2355            "python3".into(),
2356            "-c".into(),
2357            "print(1)".into()
2358        ]));
2359    }
2360
2361    #[test]
2362    fn shell_passthrough_argv0_skips_run_leaf() {
2363        assert_eq!(
2364            shell_passthrough_argv0(&[
2365                "scripts".into(),
2366                "misc".into(),
2367                "issue".into(),
2368                "run".into()
2369            ]),
2370            "issue"
2371        );
2372        assert_eq!(shell_passthrough_argv0(&["probe".into()]), "probe");
2373    }
2374
2375    #[test]
2376    fn language_exec_path_vs_inline_detection() {
2377        assert!(ExecSpec::python_value_is_path("scripts/x.py"));
2378        assert!(ExecSpec::python_value_is_path("X.PY"));
2379        assert!(!ExecSpec::python_value_is_path("print(1)\n"));
2380        assert!(!ExecSpec::python_value_is_path("import sys"));
2381        assert!(ExecSpec::node_value_is_path("a.js"));
2382        assert!(ExecSpec::node_value_is_path("a.mjs"));
2383        assert!(ExecSpec::node_value_is_path("a.cjs"));
2384        assert!(!ExecSpec::node_value_is_path("console.log(1)"));
2385        assert!(!ExecSpec::node_value_is_path("x.ts"));
2386        assert!(ExecSpec::bash_value_is_path("x.sh"));
2387        assert!(ExecSpec::bash_value_is_path("x.bash"));
2388        assert!(!ExecSpec::bash_value_is_path("echo hi"));
2389        assert!(ExecSpec::sh_value_is_path("x.sh"));
2390        assert!(ExecSpec::zsh_value_is_path("x.zsh"));
2391        let bash = ExecSpec {
2392            bash: Some("echo hi".into()),
2393            ..Default::default()
2394        };
2395        bash.validate("t").unwrap();
2396
2397        let python = ExecSpec {
2398            python: Some("print(1)".into()),
2399            ..Default::default()
2400        };
2401        python.validate("t").unwrap();
2402        let node = ExecSpec {
2403            node: Some("console.log(1)".into()),
2404            ..Default::default()
2405        };
2406        node.validate("t").unwrap();
2407        let both = ExecSpec {
2408            python: Some("x.py".into()),
2409            node: Some("x.js".into()),
2410            ..Default::default()
2411        };
2412        assert!(both.validate("t").is_err());
2413        let text = ExecSpec {
2414            text: Some("hello docs\n".into()),
2415            ..Default::default()
2416        };
2417        text.validate("t").unwrap();
2418        let cat: ExecSpec = serde_yaml::from_str("cat: |\n  printed as-is\n").unwrap();
2419        assert_eq!(cat.literal_text(), Some("printed as-is"));
2420    }
2421
2422    #[test]
2423    fn format_help_inlines_help_child_and_hides_help_leaf() {
2424        let spec = load_spec_from_str(
2425            r#"
2426commands:
2427  backup:
2428    about: Backup a path
2429    inputs:
2430      path:
2431        required: true
2432        type: path
2433    commands:
2434      help:
2435        about: Describe this script.
2436        exec:
2437          text: |
2438            backup — copy files
2439            Example: jan backup run --path /data
2440      run:
2441        about: Run the backup
2442        exec:
2443          argv: [echo, ok]
2444"#,
2445            None,
2446        )
2447        .unwrap();
2448        let node = &spec.commands["backup"];
2449        let help = format_help(&spec, &["backup".into()], Some(node));
2450        assert!(help.contains("backup — copy files"));
2451        assert!(help.contains("jan backup run --path /data"));
2452        assert!(help.contains("  run — Run the backup"));
2453        assert!(!help.contains("  help —"));
2454        assert!(help.contains("--path"));
2455        let run = &node.commands["run"];
2456        let run_help = format_help(&spec, &["backup".into(), "run".into()], Some(run));
2457        assert!(run_help.contains("backup — copy files"));
2458        assert!(run_help.contains("--path"));
2459    }
2460
2461    #[test]
2462    fn format_help_lists_node_aliases_and_hides_alias_buckets() {
2463        let spec = load_spec_from_str(
2464            r#"
2465metadata:
2466  name: jan
2467commands:
2468  android:
2469    about: android utilities
2470    aliases:
2471      adbt: adb-triage
2472      android-reboot: adb reboot
2473    commands:
2474      dump:
2475        about: Dump device state
2476        exec:
2477          argv: [echo, ok]
2478      linux-shell:
2479        aliases:
2480          tulpn: netstat -tulpn
2481  last_branch:
2482    aliases: [lb]
2483    commands:
2484      run:
2485        exec:
2486          argv: [echo, branches]
2487"#,
2488            None,
2489        )
2490        .unwrap();
2491        let android = &spec.commands["android"];
2492        let help = format_help(&spec, &["android".into()], Some(android));
2493        assert!(help.contains("Aliases (`jan alias`):"), "{help}");
2494        assert!(help.contains("  adbt — adb-triage"), "{help}");
2495        assert!(help.contains("  android-reboot — adb reboot"), "{help}");
2496        assert!(help.contains("  dump — Dump device state"), "{help}");
2497        assert!(help.contains("  tulpn — netstat -tulpn"), "{help}");
2498        assert!(
2499            !help.contains("  linux-shell"),
2500            "alias-only children should not be listed as subcommands: {help}"
2501        );
2502
2503        let last = &spec.commands["last_branch"];
2504        let last_help = format_help(&spec, &["last_branch".into()], Some(last));
2505        assert!(
2506            last_help.contains("  lb — same as `jan last_branch run`"),
2507            "{last_help}"
2508        );
2509    }
2510
2511    #[test]
2512    fn gherkin_test_names() {
2513        assert!(gherkin_test_name(
2514            "given_a_csv_when_summarized_then_prints_shape"
2515        ));
2516        assert!(gherkin_test_name(
2517            "given a file when basename then prints name"
2518        ));
2519        assert!(gherkin_test_name(
2520            "given-a-name-when-run-then-mentions-birthday"
2521        ));
2522        assert!(!gherkin_test_name("prints_hello"));
2523        assert!(!gherkin_test_name("given_when_then"));
2524        assert!(!gherkin_test_name("given_x_when_y"));
2525        let t = CommandTest {
2526            when: "jan hello".into(),
2527            then: "test \"$JAN_STATUS\" -eq 0".into(),
2528            ..Default::default()
2529        };
2530        t.validate("hello", "given_no_args_when_run_then_ok")
2531            .unwrap();
2532        assert!(t.validate("hello", "not_gherkin").is_err());
2533    }
2534
2535    #[test]
2536    fn aliases_spec_deserializes_string_list_and_map() {
2537        let spec: AliasesSpec = serde_yaml::from_str("lb").unwrap();
2538        assert_eq!(spec.names, vec!["lb"]);
2539        assert!(spec.shell.is_empty());
2540
2541        let spec: AliasesSpec = serde_yaml::from_str("[lb, lbr]").unwrap();
2542        assert_eq!(spec.names, vec!["lb", "lbr"]);
2543
2544        let spec: AliasesSpec = serde_yaml::from_str("gs: git status\nlb:\ng: git\n").unwrap();
2545        assert_eq!(spec.names, vec!["lb"]);
2546        assert_eq!(spec.shell.get("gs").map(String::as_str), Some("git status"));
2547        assert_eq!(spec.shell.get("g").map(String::as_str), Some("git"));
2548    }
2549
2550    #[test]
2551    fn aliases_names_require_jan_target() {
2552        let spec = load_spec_from_str(
2553            r"
2554commands:
2555  git:
2556    aliases: [g]
2557    commands:
2558      status:
2559        exec:
2560          argv: [echo, ok]
2561",
2562            None,
2563        );
2564        let err = spec.unwrap_err().to_string();
2565        assert!(err.contains("jan alias target"), "{err}");
2566    }
2567
2568    #[test]
2569    fn aliases_reject_unsafe_names() {
2570        let spec = load_spec_from_str(
2571            r"
2572commands:
2573  leaf:
2574    aliases:
2575      'x;rm': echo pwn
2576    exec:
2577      argv: [echo, ok]
2578",
2579            None,
2580        );
2581        let err = spec.unwrap_err().to_string();
2582        assert!(err.contains("must match"), "{err}");
2583    }
2584}
2585
2586pub fn default_db_path() -> PathBuf {
2587    if let Ok(p) = std::env::var("JAN_DB") {
2588        return PathBuf::from(p);
2589    }
2590    dirs::data_local_dir()
2591        .unwrap_or_else(|| PathBuf::from("."))
2592        .join("jan-cli")
2593        .join("audit.db")
2594}