Skip to main content

jan_cli/
lib.rs

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