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