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