Skip to main content

jan_cli/
lib.rs

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