Skip to main content

jan_cli/
lib.rs

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