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