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