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