1mod builtins;
2mod cmdtest;
3mod config;
4mod cron;
5mod deps;
6mod inputs;
7mod inspect;
8mod packages;
9pub mod remote;
10mod runner;
11mod spec_load;
12mod yaml_closure;
13
14pub use config::{load_user_config, UserConfig};
15pub use runner::run_jan;
16pub use spec_load::HostPlatform;
17
18use std::collections::BTreeMap;
19use std::ffi::OsString;
20use std::path::{Path, PathBuf};
21use std::process::Command;
22
23use anyhow::{bail, Context, Result};
24use rusqlite::Connection;
25use serde::de::{self, Deserializer, Visitor};
26use serde::Deserialize;
27use std::fmt;
28
29#[derive(Debug, Deserialize)]
30pub struct RootSpec {
31 pub metadata: Option<Metadata>,
32 #[serde(default)]
33 pub commands: BTreeMap<String, CommandNode>,
34}
35
36#[derive(Debug, Deserialize)]
37pub struct Metadata {
38 pub name: Option<String>,
39 pub description: Option<String>,
40}
41
42#[derive(Debug, Default, Clone, PartialEq, Eq)]
69pub struct EnvSpec {
70 pub public: BTreeMap<String, String>,
71 pub private: Vec<String>,
72 pub pass: BTreeMap<String, String>,
74}
75
76impl EnvSpec {
77 pub fn is_empty(&self) -> bool {
78 self.public.is_empty() && self.private.is_empty() && self.pass.is_empty()
79 }
80
81 pub fn restricts_child_env(&self) -> bool {
83 !self.is_empty()
84 }
85
86 pub fn merge_from(&mut self, other: EnvSpec) {
87 for (k, v) in other.public {
88 self.public.insert(k, v);
89 }
90 for name in other.private {
91 if !self.private.iter().any(|p| p == &name) {
92 self.private.push(name);
93 }
94 }
95 for (k, v) in other.pass {
96 self.pass.insert(k, v);
97 }
98 }
99
100 pub fn validate(&self, path: &str) -> Result<()> {
102 for name in &self.private {
103 if name.trim().is_empty() {
104 bail!("command '{path}': env.private entry must not be empty");
105 }
106 }
107 for (env_name, pass_id) in &self.pass {
108 if env_name.trim().is_empty() {
109 bail!("command '{path}': env.pass key must not be empty");
110 }
111 if pass_id.trim().is_empty() {
112 bail!("command '{path}': env.pass id for `{env_name}` must not be empty");
113 }
114 if self.private.iter().any(|p| p == env_name) {
115 bail!(
116 "command '{path}': env var `{env_name}` cannot be both `env.private` and `env.pass`"
117 );
118 }
119 }
120 Ok(())
121 }
122}
123
124impl<'de> Deserialize<'de> for EnvSpec {
125 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
126 where
127 D: Deserializer<'de>,
128 {
129 #[derive(Deserialize)]
130 struct Structured {
131 #[serde(default)]
132 public: BTreeMap<String, String>,
133 #[serde(default, deserialize_with = "deserialize_string_or_seq")]
134 private: Vec<String>,
135 #[serde(default)]
136 pass: BTreeMap<String, String>,
137 }
138
139 #[derive(Deserialize)]
140 #[serde(untagged)]
141 enum EnvDe {
142 Flat(BTreeMap<String, String>),
143 Sections(Structured),
144 }
145
146 Ok(match EnvDe::deserialize(deserializer)? {
147 EnvDe::Flat(public) => Self {
148 public,
149 private: Vec::new(),
150 pass: BTreeMap::new(),
151 },
152 EnvDe::Sections(s) => Self {
153 public: s.public,
154 private: s.private,
155 pass: s.pass,
156 },
157 })
158 }
159}
160
161#[derive(Debug, Clone, Copy, PartialEq, Eq)]
163pub enum IncludeLinkKind {
164 Yaml,
165 Script,
166}
167
168#[derive(Debug, Clone, PartialEq, Eq)]
170pub struct IncludeLink {
171 pub kind: IncludeLinkKind,
172 pub path: Option<String>,
174 pub url: Option<String>,
176 pub sha256: Option<String>,
178}
179
180#[derive(Debug, Deserialize, Default, Clone)]
181pub struct CommandNode {
182 #[serde(default)]
185 pub os: Vec<String>,
186 #[serde(default)]
187 pub about: String,
188 pub path: Option<String>,
190 #[serde(default)]
192 pub dependencies: Vec<String>,
193 #[serde(default)]
195 pub requires: Vec<String>,
196 #[serde(default)]
198 pub env: EnvSpec,
199 #[serde(default)]
201 pub inputs: BTreeMap<String, crate::inputs::InputDef>,
202 #[serde(default, deserialize_with = "deserialize_string_or_seq")]
205 pub cron: Vec<String>,
206 #[serde(default)]
208 pub packages: PackagesSpec,
209 #[serde(default)]
211 pub tests: BTreeMap<String, CommandTest>,
212 #[serde(default)]
213 pub commands: BTreeMap<String, CommandNode>,
214 pub exec: Option<ExecSpec>,
215 #[serde(skip)]
217 pub source: Option<IncludeLink>,
218}
219
220#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
227pub struct CommandTest {
228 #[serde(default)]
230 pub given: String,
231 #[serde(default)]
233 pub when: String,
234 #[serde(default)]
236 pub then: String,
237}
238
239impl CommandTest {
240 pub fn validate(&self, path: &str, name: &str) -> Result<()> {
241 if !gherkin_test_name(name) {
242 bail!(
243 "command '{path}': test `{name}` must follow the given_…_when_…_then_… naming pattern"
244 );
245 }
246 if self.then.trim().is_empty() {
247 bail!("command '{path}': test `{name}` needs a non-empty `then:` script");
248 }
249 Ok(())
250 }
251}
252
253pub fn gherkin_test_name(name: &str) -> bool {
255 let n: String = name
256 .trim()
257 .to_ascii_lowercase()
258 .chars()
259 .map(|c| {
260 if c == '-' || c.is_whitespace() {
261 '_'
262 } else {
263 c
264 }
265 })
266 .collect();
267 let n = n
268 .split('_')
269 .filter(|s| !s.is_empty())
270 .collect::<Vec<_>>()
271 .join("_");
272 let Some(rest) = n.strip_prefix("given_") else {
273 return false;
274 };
275 let Some((given_body, after_when)) = rest.split_once("_when_") else {
276 return false;
277 };
278 let Some((when_body, then_body)) = after_when.split_once("_then_") else {
279 return false;
280 };
281 !given_body.is_empty() && !when_body.is_empty() && !then_body.is_empty()
282}
283
284#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
289pub struct PackagesSpec {
290 #[serde(default)]
291 pub uv: Option<UvPackages>,
292 #[serde(default)]
293 pub pnpm: Option<PnpmPackages>,
294 #[serde(default)]
295 pub gradle: Option<GradlePackages>,
296}
297
298impl PackagesSpec {
299 pub fn is_empty(&self) -> bool {
300 self.uv.is_none() && self.pnpm.is_none() && self.gradle.is_none()
301 }
302
303 pub fn merge_from(&mut self, other: PackagesSpec) {
305 if other.uv.is_some() {
306 self.uv = other.uv;
307 }
308 if other.pnpm.is_some() {
309 self.pnpm = other.pnpm;
310 }
311 if other.gradle.is_some() {
312 self.gradle = other.gradle;
313 }
314 }
315
316 pub fn validate(&self, path: &str) -> Result<()> {
317 if let Some(uv) = &self.uv {
318 uv.validate(path)?;
319 }
320 if let Some(pnpm) = &self.pnpm {
321 pnpm.validate(path)?;
322 }
323 if let Some(gradle) = &self.gradle {
324 gradle.validate(path)?;
325 }
326 Ok(())
327 }
328}
329
330#[derive(Debug, Clone, PartialEq, Eq)]
333pub struct UvPackages {
334 pub deps: UvDeps,
335 pub python: Option<String>,
337}
338
339#[derive(Debug, Clone, PartialEq, Eq)]
340pub enum UvDeps {
341 List(Vec<String>),
342 Project(String),
343 Requirements(String),
344}
345
346impl UvPackages {
347 pub fn list(pkgs: Vec<String>) -> Self {
348 Self {
349 deps: UvDeps::List(pkgs),
350 python: None,
351 }
352 }
353
354 pub fn validate(&self, path: &str) -> Result<()> {
355 if let Some(py) = &self.python {
356 packages::parse_min_version_constraint(py)
357 .map_err(|e| anyhow::anyhow!("command '{path}': packages.uv.python: {e}"))?;
358 }
359 match &self.deps {
360 UvDeps::List(pkgs) => {
361 if pkgs.is_empty() {
362 bail!("command '{path}': packages.uv list must not be empty");
363 }
364 for p in pkgs {
365 if p.trim().is_empty() {
366 bail!("command '{path}': packages.uv entry must not be empty");
367 }
368 packages::check_pinned_requirement(p)
369 .map_err(|e| anyhow::anyhow!("command '{path}': {e}"))?;
370 }
371 }
372 UvDeps::Project(p) | UvDeps::Requirements(p) => {
373 if p.trim().is_empty() {
374 bail!("command '{path}': packages.uv path must not be empty");
375 }
376 }
377 }
378 Ok(())
379 }
380}
381
382impl<'de> Deserialize<'de> for UvPackages {
383 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
384 where
385 D: Deserializer<'de>,
386 {
387 #[derive(Deserialize)]
388 #[serde(deny_unknown_fields)]
389 struct MapForm {
390 #[serde(default)]
391 project: Option<String>,
392 #[serde(default)]
393 requirements: Option<String>,
394 #[serde(default, alias = "deps")]
395 packages: Option<Vec<String>>,
396 #[serde(default, deserialize_with = "deserialize_opt_stringish")]
397 python: Option<String>,
398 }
399
400 #[derive(Deserialize)]
401 #[serde(untagged)]
402 enum Helper {
403 List(Vec<String>),
404 Map(MapForm),
405 }
406
407 match Helper::deserialize(deserializer)? {
408 Helper::List(pkgs) => {
409 let pkgs: Vec<String> = pkgs
410 .into_iter()
411 .map(|s| s.trim().to_string())
412 .filter(|s| !s.is_empty())
413 .collect();
414 Ok(UvPackages {
415 deps: UvDeps::List(pkgs),
416 python: None,
417 })
418 }
419 Helper::Map(m) => {
420 let project = m
421 .project
422 .map(|s| s.trim().to_string())
423 .filter(|s| !s.is_empty());
424 let requirements = m
425 .requirements
426 .map(|s| s.trim().to_string())
427 .filter(|s| !s.is_empty());
428 let packages = m.packages.map(|pkgs| {
429 pkgs.into_iter()
430 .map(|s| s.trim().to_string())
431 .filter(|s| !s.is_empty())
432 .collect::<Vec<_>>()
433 });
434 let python = m
435 .python
436 .map(|s| s.trim().to_string())
437 .filter(|s| !s.is_empty());
438 let deps = match (project, requirements, packages) {
439 (Some(p), None, None) => UvDeps::Project(p),
440 (None, Some(r), None) => UvDeps::Requirements(r),
441 (None, None, Some(pkgs)) => UvDeps::List(pkgs),
442 _ => {
443 return Err(de::Error::custom(
444 "packages.uv map must set exactly one of `packages`, `project`, or `requirements`",
445 ));
446 }
447 };
448 Ok(UvPackages { deps, python })
449 }
450 }
451 }
452}
453
454#[derive(Debug, Clone, PartialEq, Eq)]
457pub struct PnpmPackages {
458 pub deps: PnpmDeps,
459 pub node: Option<String>,
461}
462
463#[derive(Debug, Clone, PartialEq, Eq)]
464pub enum PnpmDeps {
465 List(Vec<String>),
466 Project(String),
467}
468
469impl PnpmPackages {
470 pub fn list(pkgs: Vec<String>) -> Self {
471 Self {
472 deps: PnpmDeps::List(pkgs),
473 node: None,
474 }
475 }
476
477 pub fn validate(&self, path: &str) -> Result<()> {
478 if let Some(node) = &self.node {
479 packages::parse_min_version_constraint(node)
480 .map_err(|e| anyhow::anyhow!("command '{path}': packages.pnpm.node: {e}"))?;
481 }
482 match &self.deps {
483 PnpmDeps::List(pkgs) => {
484 if pkgs.is_empty() {
485 bail!("command '{path}': packages.pnpm list must not be empty");
486 }
487 for p in pkgs {
488 if p.trim().is_empty() {
489 bail!("command '{path}': packages.pnpm entry must not be empty");
490 }
491 packages::check_pinned_npm_spec(p)
492 .map_err(|e| anyhow::anyhow!("command '{path}': {e}"))?;
493 }
494 }
495 PnpmDeps::Project(p) => {
496 if p.trim().is_empty() {
497 bail!("command '{path}': packages.pnpm path must not be empty");
498 }
499 }
500 }
501 Ok(())
502 }
503}
504
505impl<'de> Deserialize<'de> for PnpmPackages {
506 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
507 where
508 D: Deserializer<'de>,
509 {
510 #[derive(Deserialize)]
511 #[serde(deny_unknown_fields)]
512 struct MapForm {
513 #[serde(default)]
514 project: Option<String>,
515 #[serde(default, alias = "deps")]
516 packages: Option<Vec<String>>,
517 #[serde(default, deserialize_with = "deserialize_opt_stringish")]
518 node: Option<String>,
519 }
520
521 #[derive(Deserialize)]
522 #[serde(untagged)]
523 enum Helper {
524 List(Vec<String>),
525 Map(MapForm),
526 }
527
528 match Helper::deserialize(deserializer)? {
529 Helper::List(pkgs) => {
530 let pkgs: Vec<String> = pkgs
531 .into_iter()
532 .map(|s| s.trim().to_string())
533 .filter(|s| !s.is_empty())
534 .collect();
535 Ok(PnpmPackages {
536 deps: PnpmDeps::List(pkgs),
537 node: None,
538 })
539 }
540 Helper::Map(m) => {
541 let project = m
542 .project
543 .map(|s| s.trim().to_string())
544 .filter(|s| !s.is_empty());
545 let packages = m.packages.map(|pkgs| {
546 pkgs.into_iter()
547 .map(|s| s.trim().to_string())
548 .filter(|s| !s.is_empty())
549 .collect::<Vec<_>>()
550 });
551 let node = m
552 .node
553 .map(|s| s.trim().to_string())
554 .filter(|s| !s.is_empty());
555 let deps = match (project, packages) {
556 (Some(p), None) => PnpmDeps::Project(p),
557 (None, Some(pkgs)) => PnpmDeps::List(pkgs),
558 _ => {
559 return Err(de::Error::custom(
560 "packages.pnpm map must set exactly one of `packages` or `project`",
561 ));
562 }
563 };
564 Ok(PnpmPackages { deps, node })
565 }
566 }
567 }
568}
569
570#[derive(Debug, Clone, PartialEq, Eq)]
573pub struct GradlePackages {
574 pub deps: GradleDeps,
575 pub java: Option<String>,
577}
578
579#[derive(Debug, Clone, PartialEq, Eq)]
580pub enum GradleDeps {
581 List(Vec<String>),
582 Project(String),
583}
584
585impl GradlePackages {
586 pub fn list(pkgs: Vec<String>) -> Self {
587 Self {
588 deps: GradleDeps::List(pkgs),
589 java: None,
590 }
591 }
592
593 pub fn validate(&self, path: &str) -> Result<()> {
594 if let Some(java) = &self.java {
595 packages::parse_min_version_constraint(java)
596 .map_err(|e| anyhow::anyhow!("command '{path}': packages.gradle.java: {e}"))?;
597 }
598 match &self.deps {
599 GradleDeps::List(pkgs) => {
600 if pkgs.is_empty() {
601 bail!("command '{path}': packages.gradle list must not be empty");
602 }
603 for p in pkgs {
604 if p.trim().is_empty() {
605 bail!("command '{path}': packages.gradle entry must not be empty");
606 }
607 packages::check_pinned_maven_coord(p)
608 .map_err(|e| anyhow::anyhow!("command '{path}': {e}"))?;
609 }
610 }
611 GradleDeps::Project(p) => {
612 if p.trim().is_empty() {
613 bail!("command '{path}': packages.gradle path must not be empty");
614 }
615 }
616 }
617 Ok(())
618 }
619}
620
621impl<'de> Deserialize<'de> for GradlePackages {
622 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
623 where
624 D: Deserializer<'de>,
625 {
626 #[derive(Deserialize)]
627 #[serde(deny_unknown_fields)]
628 struct MapForm {
629 #[serde(default)]
630 project: Option<String>,
631 #[serde(default, alias = "deps")]
632 packages: Option<Vec<String>>,
633 #[serde(default, alias = "jdk", deserialize_with = "deserialize_opt_stringish")]
634 java: Option<String>,
635 }
636
637 #[derive(Deserialize)]
638 #[serde(untagged)]
639 enum Helper {
640 List(Vec<String>),
641 Map(MapForm),
642 }
643
644 match Helper::deserialize(deserializer)? {
645 Helper::List(pkgs) => {
646 let pkgs: Vec<String> = pkgs
647 .into_iter()
648 .map(|s| s.trim().to_string())
649 .filter(|s| !s.is_empty())
650 .collect();
651 Ok(GradlePackages {
652 deps: GradleDeps::List(pkgs),
653 java: None,
654 })
655 }
656 Helper::Map(m) => {
657 let project = m
658 .project
659 .map(|s| s.trim().to_string())
660 .filter(|s| !s.is_empty());
661 let packages = m.packages.map(|pkgs| {
662 pkgs.into_iter()
663 .map(|s| s.trim().to_string())
664 .filter(|s| !s.is_empty())
665 .collect::<Vec<_>>()
666 });
667 let java = m
668 .java
669 .map(|s| s.trim().to_string())
670 .filter(|s| !s.is_empty());
671 let deps = match (project, packages) {
672 (Some(p), None) => GradleDeps::Project(p),
673 (None, Some(pkgs)) => GradleDeps::List(pkgs),
674 _ => {
675 return Err(de::Error::custom(
676 "packages.gradle map must set exactly one of `packages` or `project`",
677 ));
678 }
679 };
680 Ok(GradlePackages { deps, java })
681 }
682 }
683 }
684}
685
686pub(crate) fn deserialize_opt_stringish<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
687where
688 D: Deserializer<'de>,
689{
690 struct Stringish;
691
692 impl<'de> Visitor<'de> for Stringish {
693 type Value = Option<String>;
694
695 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
696 formatter.write_str("a string or number version constraint, or null")
697 }
698
699 fn visit_none<E>(self) -> Result<Self::Value, E>
700 where
701 E: de::Error,
702 {
703 Ok(None)
704 }
705
706 fn visit_unit<E>(self) -> Result<Self::Value, E>
707 where
708 E: de::Error,
709 {
710 Ok(None)
711 }
712
713 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
714 where
715 E: de::Error,
716 {
717 let t = value.trim();
718 if t.is_empty() {
719 Ok(None)
720 } else {
721 Ok(Some(t.to_string()))
722 }
723 }
724
725 fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
726 where
727 E: de::Error,
728 {
729 self.visit_str(&value)
730 }
731
732 fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
733 where
734 E: de::Error,
735 {
736 Ok(Some(value.to_string()))
737 }
738
739 fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
740 where
741 E: de::Error,
742 {
743 Ok(Some(value.to_string()))
744 }
745
746 fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
747 where
748 E: de::Error,
749 {
750 let s = if (value.fract()).abs() < f64::EPSILON {
752 format!("{}", value as i64)
753 } else {
754 let s = format!("{value}");
756 s.trim_end_matches('0').trim_end_matches('.').to_string()
757 };
758 Ok(Some(s))
759 }
760 }
761
762 deserializer.deserialize_any(Stringish)
763}
764
765pub(crate) fn deserialize_string_or_seq<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
766where
767 D: Deserializer<'de>,
768{
769 struct StringOrSeq;
770
771 impl<'de> Visitor<'de> for StringOrSeq {
772 type Value = Vec<String>;
773
774 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
775 formatter.write_str("a string or a sequence of strings")
776 }
777
778 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
779 where
780 E: de::Error,
781 {
782 if value.trim().is_empty() {
783 Ok(Vec::new())
784 } else {
785 Ok(vec![value.to_string()])
786 }
787 }
788
789 fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
790 where
791 E: de::Error,
792 {
793 self.visit_str(&value)
794 }
795
796 fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
797 where
798 A: de::SeqAccess<'de>,
799 {
800 let mut out = Vec::new();
801 while let Some(s) = seq.next_element::<String>()? {
802 if !s.trim().is_empty() {
803 out.push(s);
804 }
805 }
806 Ok(out)
807 }
808
809 fn visit_none<E>(self) -> Result<Self::Value, E>
810 where
811 E: de::Error,
812 {
813 Ok(Vec::new())
814 }
815
816 fn visit_unit<E>(self) -> Result<Self::Value, E>
817 where
818 E: de::Error,
819 {
820 Ok(Vec::new())
821 }
822 }
823
824 deserializer.deserialize_any(StringOrSeq)
825}
826
827#[derive(Debug, Clone, PartialEq, Eq)]
829pub struct LocalInclude {
830 pub path: String,
831 pub sha256: Option<String>,
833 pub argv: Vec<String>,
835 pub passthrough: bool,
837}
838
839impl LocalInclude {
840 pub fn from_path(path: impl Into<String>) -> Self {
841 Self {
842 path: path.into(),
843 sha256: None,
844 argv: Vec::new(),
845 passthrough: false,
846 }
847 }
848
849 pub fn is_yaml(&self) -> bool {
850 let lower = self.path.to_ascii_lowercase();
851 lower.ends_with(".yaml") || lower.ends_with(".yml")
852 }
853}
854
855#[derive(Debug, Clone, PartialEq, Eq)]
857pub enum IncludeRef {
858 Local(LocalInclude),
860 Remote(RemoteInclude),
862}
863
864#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
865pub struct RemoteInclude {
866 pub url: String,
867 pub sha256: String,
868 #[serde(default)]
869 pub ttl: Option<u64>,
870}
871
872impl IncludeRef {
873 pub fn is_remote(&self) -> bool {
874 matches!(self, Self::Remote(_))
875 }
876
877 pub fn local_path(&self) -> Option<&str> {
878 match self {
879 Self::Local(l) => Some(l.path.as_str()),
880 Self::Remote(_) => None,
881 }
882 }
883
884 pub fn cycle_token(&self) -> String {
885 match self {
886 Self::Local(l) => match &l.sha256 {
887 Some(h) => format!("{}#{}", l.path, h.to_ascii_lowercase()),
888 None => l.path.clone(),
889 },
890 Self::Remote(r) => format!("{}#{}", r.url, r.sha256.to_ascii_lowercase()),
891 }
892 }
893}
894
895impl<'de> Deserialize<'de> for IncludeRef {
896 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
897 where
898 D: Deserializer<'de>,
899 {
900 #[derive(Deserialize)]
901 #[serde(deny_unknown_fields)]
902 struct LocalMap {
903 path: String,
904 #[serde(default)]
905 sha256: Option<String>,
906 #[serde(default)]
907 argv: Vec<String>,
908 #[serde(default)]
909 passthrough: bool,
910 }
911
912 #[derive(Deserialize)]
913 #[serde(untagged)]
914 enum Helper {
915 Path(String),
916 Local(LocalMap),
917 Remote(RemoteInclude),
918 }
919
920 match Helper::deserialize(deserializer)? {
921 Helper::Path(path) => {
922 let path = path.trim();
923 if path.is_empty() {
924 return Err(de::Error::custom("include path must not be empty"));
925 }
926 Ok(IncludeRef::Local(LocalInclude::from_path(path)))
927 }
928 Helper::Local(m) => {
929 let path = m.path.trim();
930 if path.is_empty() {
931 return Err(de::Error::custom("include.path must not be empty"));
932 }
933 let sha256 = m
934 .sha256
935 .map(|s| s.trim().to_string())
936 .filter(|s| !s.is_empty());
937 Ok(IncludeRef::Local(LocalInclude {
938 path: path.to_string(),
939 sha256,
940 argv: m.argv,
941 passthrough: m.passthrough,
942 }))
943 }
944 Helper::Remote(r) => {
945 if r.url.trim().is_empty() {
946 return Err(de::Error::custom("include.url must not be empty"));
947 }
948 if r.sha256.trim().is_empty() {
949 return Err(de::Error::custom(
950 "include.sha256 is required with include.url",
951 ));
952 }
953 Ok(IncludeRef::Remote(r))
954 }
955 }
956 }
957}
958
959#[derive(Debug, Deserialize, Clone, Default)]
960pub struct ExecSpec {
961 #[serde(default)]
965 pub argv: Vec<String>,
966 #[serde(default)]
968 pub passthrough: bool,
969 #[serde(default)]
971 pub url: Option<String>,
972 #[serde(default)]
974 pub file: Option<String>,
975 #[serde(default)]
980 pub kotlin: Option<String>,
981 #[serde(default)]
985 pub python: Option<String>,
986 #[serde(default)]
990 pub node: Option<String>,
991 #[serde(default)]
994 pub bash: Option<String>,
995 #[serde(default)]
997 pub sh: Option<String>,
998 #[serde(default)]
1000 pub zsh: Option<String>,
1001 #[serde(default, alias = "cat")]
1003 pub text: Option<String>,
1004 #[serde(default)]
1006 pub sha256: Option<String>,
1007 #[serde(default)]
1009 pub ttl: Option<u64>,
1010}
1011
1012impl ExecSpec {
1013 pub fn is_remote(&self) -> bool {
1014 self.url
1015 .as_deref()
1016 .map(|u| !u.trim().is_empty())
1017 .unwrap_or(false)
1018 }
1019
1020 pub fn is_local_file(&self) -> bool {
1021 self.file
1022 .as_deref()
1023 .map(|u| !u.trim().is_empty())
1024 .unwrap_or(false)
1025 }
1026
1027 pub fn is_kotlin(&self) -> bool {
1028 self.kotlin
1029 .as_deref()
1030 .map(|u| !u.trim().is_empty())
1031 .unwrap_or(false)
1032 }
1033
1034 pub fn is_python(&self) -> bool {
1035 self.python
1036 .as_deref()
1037 .map(|u| !u.trim().is_empty())
1038 .unwrap_or(false)
1039 }
1040
1041 pub fn is_node(&self) -> bool {
1042 self.node
1043 .as_deref()
1044 .map(|u| !u.trim().is_empty())
1045 .unwrap_or(false)
1046 }
1047
1048 pub fn is_bash(&self) -> bool {
1049 self.bash
1050 .as_deref()
1051 .map(|u| !u.trim().is_empty())
1052 .unwrap_or(false)
1053 }
1054
1055 pub fn is_sh(&self) -> bool {
1056 self.sh
1057 .as_deref()
1058 .map(|u| !u.trim().is_empty())
1059 .unwrap_or(false)
1060 }
1061
1062 pub fn is_zsh(&self) -> bool {
1063 self.zsh
1064 .as_deref()
1065 .map(|u| !u.trim().is_empty())
1066 .unwrap_or(false)
1067 }
1068
1069 pub fn is_text(&self) -> bool {
1070 self.text
1071 .as_deref()
1072 .map(|u| !u.trim().is_empty())
1073 .unwrap_or(false)
1074 }
1075
1076 pub fn literal_text(&self) -> Option<&str> {
1078 self.text
1079 .as_deref()
1080 .map(str::trim)
1081 .filter(|s| !s.is_empty())
1082 }
1083
1084 pub fn is_language_source(&self) -> bool {
1086 self.is_kotlin()
1087 || self.is_python()
1088 || self.is_node()
1089 || self.is_bash()
1090 || self.is_sh()
1091 || self.is_zsh()
1092 }
1093
1094 pub fn kotlin_value_is_path(raw: &str) -> bool {
1096 Self::single_line_ext(raw, &[".kt", ".kts"])
1097 }
1098
1099 pub fn python_value_is_path(raw: &str) -> bool {
1100 Self::single_line_ext(raw, &[".py"])
1101 }
1102
1103 pub fn node_value_is_path(raw: &str) -> bool {
1104 Self::single_line_ext(raw, &[".js", ".mjs", ".cjs"])
1105 }
1106
1107 pub fn bash_value_is_path(raw: &str) -> bool {
1108 Self::single_line_ext(raw, &[".sh", ".bash"])
1109 }
1110
1111 pub fn sh_value_is_path(raw: &str) -> bool {
1112 Self::single_line_ext(raw, &[".sh"])
1113 }
1114
1115 pub fn zsh_value_is_path(raw: &str) -> bool {
1116 Self::single_line_ext(raw, &[".zsh", ".sh"])
1117 }
1118
1119 fn single_line_ext(raw: &str, exts: &[&str]) -> bool {
1120 let t = raw.trim();
1121 if t.is_empty() || t.lines().nth(1).is_some() {
1122 return false;
1123 }
1124 let lower = t.to_ascii_lowercase();
1125 exts.iter().any(|e| lower.ends_with(e))
1126 }
1127
1128 pub fn kotlin_is_path(&self) -> bool {
1130 self.kotlin
1131 .as_deref()
1132 .map(Self::kotlin_value_is_path)
1133 .unwrap_or(false)
1134 }
1135
1136 pub fn validate(&self, path: &str) -> Result<()> {
1137 let url = self.url.as_deref().map(str::trim).filter(|s| !s.is_empty());
1138 let file = self
1139 .file
1140 .as_deref()
1141 .map(str::trim)
1142 .filter(|s| !s.is_empty());
1143 let kotlin = self
1144 .kotlin
1145 .as_deref()
1146 .map(str::trim)
1147 .filter(|s| !s.is_empty());
1148 let python = self
1149 .python
1150 .as_deref()
1151 .map(str::trim)
1152 .filter(|s| !s.is_empty());
1153 let node = self
1154 .node
1155 .as_deref()
1156 .map(str::trim)
1157 .filter(|s| !s.is_empty());
1158 let bash = self
1159 .bash
1160 .as_deref()
1161 .map(str::trim)
1162 .filter(|s| !s.is_empty());
1163 let sh = self.sh.as_deref().map(str::trim).filter(|s| !s.is_empty());
1164 let zsh = self.zsh.as_deref().map(str::trim).filter(|s| !s.is_empty());
1165 let text = self
1166 .text
1167 .as_deref()
1168 .map(str::trim)
1169 .filter(|s| !s.is_empty());
1170 let hash = self
1171 .sha256
1172 .as_deref()
1173 .map(str::trim)
1174 .filter(|s| !s.is_empty());
1175 let exclusive = [
1176 ("url", url),
1177 ("file", file),
1178 ("kotlin", kotlin),
1179 ("python", python),
1180 ("node", node),
1181 ("bash", bash),
1182 ("sh", sh),
1183 ("zsh", zsh),
1184 ("text", text),
1185 ];
1186 let set: Vec<(&str, &str)> = exclusive
1187 .iter()
1188 .copied()
1189 .filter_map(|(n, v)| v.map(|s| (n, s)))
1190 .collect();
1191 if set.len() > 1 {
1192 bail!(
1193 "command '{path}': exec cannot combine `url`, `file`, `kotlin`, `python`, `node`, `bash`, `sh`, `zsh`, and `text`"
1194 );
1195 }
1196 const LANG: &[&str] = &["kotlin", "python", "node", "bash", "sh", "zsh"];
1197 if hash.is_some() && set.iter().any(|(n, _)| LANG.contains(n) || *n == "text") {
1198 bail!(
1199 "command '{path}': exec.sha256 is not supported with exec.kotlin / exec.python / exec.node / exec.bash / exec.sh / exec.zsh / exec.text"
1200 );
1201 }
1202 match set.first().copied() {
1203 Some(("url", _)) if hash.is_some() => Ok(()),
1204 Some(("url", _)) => {
1205 bail!("command '{path}': exec.sha256 is required with exec.url")
1206 }
1207 Some(("file", _)) => Ok(()),
1208 Some(("text", _)) => Ok(()),
1209 Some(("kotlin", k)) => {
1210 if Self::kotlin_value_is_path(k) {
1211 return Ok(());
1212 }
1213 if !k.contains("fun ") && !k.contains("fun\t") {
1214 bail!(
1215 "command '{path}': exec.kotlin inline source must contain a `fun` \
1216 (or set a single-line `.kt` / `.kts` path)"
1217 );
1218 }
1219 Ok(())
1220 }
1221 Some((label, src)) if LANG.contains(&label) => {
1222 let is_path = match label {
1223 "python" => Self::python_value_is_path(src),
1224 "node" => Self::node_value_is_path(src),
1225 "bash" => Self::bash_value_is_path(src),
1226 "sh" => Self::sh_value_is_path(src),
1227 "zsh" => Self::zsh_value_is_path(src),
1228 _ => false,
1229 };
1230 if is_path {
1231 return Ok(());
1232 }
1233 if src.len() < 2 {
1234 bail!("command '{path}': exec.{label} inline source is empty");
1235 }
1236 Ok(())
1237 }
1238 None if hash.is_some() => {
1239 bail!("command '{path}': exec.sha256 requires exec.url or exec.file")
1240 }
1241 None => {
1242 if self.argv.is_empty() {
1243 bail!(
1244 "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)"
1245 );
1246 }
1247 Ok(())
1248 }
1249 _ => unreachable!("modes > 1 checked above"),
1250 }
1251 }
1252}
1253
1254impl CommandNode {
1255 pub fn is_leaf_exec(&self) -> bool {
1256 self.exec.is_some()
1257 }
1258
1259 pub fn validate(&self, path: &str) -> Result<()> {
1260 if self.exec.is_some() && !self.commands.is_empty() {
1261 bail!("command '{path}' cannot define both `exec` and nested `commands`");
1262 }
1263 if let Some(ref e) = self.exec {
1264 e.validate(path)?;
1265 }
1266 self.env.validate(path)?;
1267 self.packages.validate(path)?;
1268 for (name, t) in &self.tests {
1269 t.validate(path, name)?;
1270 }
1271 for (name, def) in &self.inputs {
1272 def.validate(name)
1273 .map_err(|e| anyhow::anyhow!("command '{path}': {e}"))?;
1274 }
1275 for (name, child) in &self.commands {
1276 let p = if path.is_empty() {
1277 name.clone()
1278 } else {
1279 format!("{path} {name}")
1280 };
1281 child.validate(&p)?;
1282 }
1283 Ok(())
1284 }
1285}
1286
1287pub fn merge_specs_into(base: &mut RootSpec, overlay: RootSpec) -> Result<()> {
1290 for (name, node) in overlay.commands {
1291 match base.commands.get_mut(&name) {
1292 Some(existing) => merge_command_node(existing, node)?,
1293 None => {
1294 base.commands.insert(name, node);
1295 }
1296 }
1297 }
1298 Ok(())
1299}
1300
1301fn merge_command_node(dst: &mut CommandNode, src: CommandNode) -> Result<()> {
1302 if src.exec.is_some() && !src.commands.is_empty() {
1303 bail!("merge overlay: command cannot define both `exec` and nested `commands`");
1304 }
1305 if !src.os.is_empty() {
1306 dst.os = src.os;
1307 }
1308 if !src.about.trim().is_empty() {
1309 dst.about = src.about;
1310 }
1311 if src.path.is_some() {
1312 dst.path = src.path;
1313 }
1314 if !src.dependencies.is_empty() {
1315 dst.dependencies = src.dependencies;
1316 }
1317 if !src.requires.is_empty() {
1318 dst.requires = src.requires;
1319 }
1320 if !src.cron.is_empty() {
1321 dst.cron = src.cron;
1322 }
1323 if !src.env.is_empty() {
1324 dst.env.merge_from(src.env);
1325 }
1326 for (k, v) in src.inputs {
1327 dst.inputs.insert(k, v);
1328 }
1329 for (k, v) in src.tests {
1330 dst.tests.insert(k, v);
1331 }
1332 if let Some(exec) = src.exec {
1333 dst.exec = Some(exec);
1334 dst.commands.clear();
1335 return Ok(());
1336 }
1337 if !src.commands.is_empty() {
1338 dst.exec = None;
1339 for (k, child) in src.commands {
1340 match dst.commands.get_mut(&k) {
1341 Some(existing) => merge_command_node(existing, child)?,
1342 None => {
1343 dst.commands.insert(k, child);
1344 }
1345 }
1346 }
1347 }
1348 Ok(())
1349}
1350
1351pub fn validate_spec(spec: &RootSpec) -> Result<()> {
1353 for (name, node) in &spec.commands {
1354 node.validate(name)?;
1355 }
1356 Ok(())
1357}
1358
1359pub fn load_spec_from_str(raw: &str, include_base: Option<&Path>) -> Result<RootSpec> {
1361 spec_load::load_spec_from_str(raw, include_base, HostPlatform::detect())
1362}
1363
1364pub fn load_spec(path: &Path) -> Result<RootSpec> {
1365 spec_load::load_spec_from_path(path, HostPlatform::detect())
1366}
1367
1368pub fn resolve_git_branch(cwd: &Path, override_branch: Option<&str>) -> String {
1369 if let Some(b) = override_branch {
1370 if !b.is_empty() {
1371 return b.to_string();
1372 }
1373 }
1374 if let Ok(v) = std::env::var("JAN_BRANCH") {
1375 if !v.is_empty() {
1376 return v;
1377 }
1378 }
1379 let output = Command::new("git")
1380 .args(["rev-parse", "--abbrev-ref", "HEAD"])
1381 .current_dir(cwd)
1382 .output();
1383 match output {
1384 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
1385 _ => "(no-git)".to_string(),
1386 }
1387}
1388
1389fn first_line(s: &str) -> String {
1390 s.lines().next().unwrap_or("").trim().to_string()
1391}
1392
1393fn is_help_leaf(name: &str, child: &CommandNode) -> bool {
1395 name == "help" && child.exec.is_some() && child.commands.is_empty()
1396}
1397
1398fn node_at_chain<'a>(spec: &'a RootSpec, chain: &[String]) -> Option<&'a CommandNode> {
1399 let mut map = &spec.commands;
1400 let mut node = None;
1401 for seg in chain {
1402 let next = map.get(seg)?;
1403 node = Some(next);
1404 map = &next.commands;
1405 }
1406 node
1407}
1408
1409fn command_help_text(
1412 spec: &RootSpec,
1413 chain: &[String],
1414 node: Option<&CommandNode>,
1415) -> Option<String> {
1416 let n = node?;
1417 if let Some(t) = n.exec.as_ref().and_then(ExecSpec::literal_text) {
1418 return Some(t.to_string());
1419 }
1420 if let Some(t) = n
1421 .commands
1422 .get("help")
1423 .and_then(|h| h.exec.as_ref())
1424 .and_then(ExecSpec::literal_text)
1425 {
1426 return Some(t.to_string());
1427 }
1428 if chain.last().map(String::as_str) == Some("run") && chain.len() >= 2 {
1429 let parent = node_at_chain(spec, &chain[..chain.len() - 1])?;
1430 return parent
1431 .commands
1432 .get("help")
1433 .and_then(|h| h.exec.as_ref())
1434 .and_then(ExecSpec::literal_text)
1435 .map(str::to_string);
1436 }
1437 None
1438}
1439
1440pub fn format_help(spec: &RootSpec, chain: &[String], node: Option<&CommandNode>) -> String {
1441 let mut out = String::new();
1442 let bin = spec
1443 .metadata
1444 .as_ref()
1445 .and_then(|m| m.name.as_deref())
1446 .unwrap_or("jan");
1447 let full_cmd = if chain.is_empty() {
1448 bin.to_string()
1449 } else {
1450 format!("{} {}", bin, chain.join(" "))
1451 };
1452
1453 let (about, children, exec) = match node {
1454 Some(n) => (n.about.as_str(), &n.commands, n.exec.as_ref()),
1455 None => ("", &spec.commands, None),
1456 };
1457
1458 if chain.is_empty() {
1459 if let Some(meta) = &spec.metadata {
1460 if let Some(desc) = &meta.description {
1461 out.push_str(desc.trim());
1462 out.push_str("\n\n");
1463 }
1464 }
1465 }
1466
1467 let help_doc = command_help_text(spec, chain, node);
1468 if let Some(doc) = &help_doc {
1469 out.push_str(doc);
1470 out.push_str("\n\n");
1471 } else if !about.is_empty() {
1472 out.push_str(about.trim());
1473 out.push_str("\n\n");
1474 }
1475
1476 let listed: Vec<(&String, &CommandNode)> = children
1477 .iter()
1478 .filter(|(name, child)| !is_help_leaf(name, child))
1479 .collect();
1480
1481 if exec.is_some() && children.is_empty() {
1482 if help_doc.is_none() {
1483 out.push_str("This command runs an external program (see spec `exec.argv`).\n");
1484 }
1485 append_help_inputs_and_tests(&mut out, spec, chain, node);
1486 return out;
1487 }
1488
1489 if !listed.is_empty() {
1490 out.push_str("Subcommands:\n");
1491 for (name, child) in &listed {
1492 let line = if child.about.is_empty() {
1493 format!(" {name}\n")
1494 } else {
1495 format!(" {name} — {}\n", first_line(&child.about))
1496 };
1497 out.push_str(&line);
1498 }
1499 out.push('\n');
1500 if listed.iter().any(|(n, _)| n.as_str() != "run") {
1501 out.push_str(&format!(
1502 "Use `{} --help` for more about a subcommand.\n",
1503 full_cmd
1504 ));
1505 }
1506 append_help_inputs_and_tests(&mut out, spec, chain, node);
1507 } else if exec.is_none() && help_doc.is_none() {
1508 out.push_str("(No subcommands defined.)\n");
1509 append_help_inputs_and_tests(&mut out, spec, chain, node);
1510 } else {
1511 append_help_inputs_and_tests(&mut out, spec, chain, node);
1512 }
1513 if chain.is_empty() && node.is_none() {
1514 out.push_str(
1515 "\nPlace `--help` or `-h` right after the subcommand prefix you want. Built-ins: `use`, `bundle`, `alias`, `list`, `search`, `show`, `validate`, `audit`, `cron`, `test`.\n",
1516 );
1517 }
1518 out
1519}
1520
1521fn append_help_inputs_and_tests(
1522 out: &mut String,
1523 spec: &RootSpec,
1524 chain: &[String],
1525 node: Option<&CommandNode>,
1526) {
1527 let defs = inputs::collect_chain_inputs(chain, spec);
1528 if !defs.is_empty() {
1529 out.push('\n');
1530 out.push_str(&inputs::format_inputs_help(&defs));
1531 }
1532 let n = match node {
1533 Some(n) => cmdtest::count_tests(n),
1534 None => spec.commands.values().map(cmdtest::count_tests).sum(),
1535 };
1536 if n > 0 {
1537 let hint = if chain.is_empty() {
1538 "jan test".to_string()
1539 } else {
1540 format!("jan test {}", chain.join(" "))
1541 };
1542 out.push_str(&format!("\n{n} test(s) — run with `{hint}`.\n"));
1543 }
1544}
1545
1546#[derive(Debug, Clone)]
1548pub struct SpecRootIdentity {
1549 pub spec_dir: String,
1551 pub root_yaml: String,
1553}
1554
1555pub fn resolve_preferred_spec() -> Result<(PathBuf, SpecRootIdentity)> {
1557 let cfg = config::load_user_config().context("load user config")?;
1558 let Some(dir_s) = cfg
1559 .jan_dir
1560 .as_ref()
1561 .map(|s| s.trim())
1562 .filter(|s| !s.is_empty())
1563 else {
1564 bail!(
1565 "no preferred jan directory configured\n\
1566 Run `jan use <DIR>` to save a YAML command tree (see docs/PORTABLE_SCRIPTS.md)"
1567 );
1568 };
1569 let dir = PathBuf::from(dir_s);
1570 if !dir.is_dir() {
1571 bail!(
1572 "preferred jan directory does not exist: {}\n\
1573 Fix the path or run `jan use <DIR>` again (config: {})",
1574 dir.display(),
1575 config::config_path().display()
1576 );
1577 }
1578 let root = cfg
1579 .spec_root
1580 .as_deref()
1581 .map(str::trim)
1582 .filter(|s| !s.is_empty())
1583 .unwrap_or("scripts.spec.yaml");
1584 resolve_spec_dir_entry(&dir, root)
1585}
1586
1587pub fn resolve_spec_dir_entry(
1589 spec_dir: &Path,
1590 root_yaml: &str,
1591) -> Result<(PathBuf, SpecRootIdentity)> {
1592 let rel = Path::new(root_yaml);
1593 if rel.is_absolute() {
1594 bail!("entry YAML must be a relative file name, not an absolute path");
1595 }
1596 if rel
1597 .components()
1598 .any(|c| matches!(c, std::path::Component::ParentDir))
1599 {
1600 bail!("entry YAML must not contain `..`");
1601 }
1602 let normal_only = rel
1603 .components()
1604 .all(|c| matches!(c, std::path::Component::Normal(_)));
1605 let n = rel
1606 .components()
1607 .filter(|c| matches!(c, std::path::Component::Normal(_)))
1608 .count();
1609 if !normal_only || n != 1 {
1610 bail!("entry YAML must be a single file name inside the jan directory");
1611 }
1612 let dir = spec_dir
1613 .canonicalize()
1614 .with_context(|| format!("canonicalize jan directory {}", spec_dir.display()))?;
1615 if !dir.is_dir() {
1616 bail!("not a directory: {}", dir.display());
1617 }
1618 let spec_path = dir.join(rel);
1619 if !spec_path.is_file() {
1620 bail!(
1621 "spec entry not found: {} (under {})\n\
1622 Expected a properly formatted jan directory (e.g. scripts.spec.yaml)",
1623 spec_path.display(),
1624 dir.display()
1625 );
1626 }
1627 let identity = SpecRootIdentity {
1628 spec_dir: dir.to_string_lossy().into_owned(),
1629 root_yaml: rel
1630 .file_name()
1631 .expect("relative root has file_name")
1632 .to_string_lossy()
1633 .into_owned(),
1634 };
1635 Ok((spec_path, identity))
1636}
1637
1638pub struct RunContext<'a> {
1639 pub cwd: &'a Path,
1640 pub db_path: Option<&'a Path>,
1641 pub branch: String,
1642 pub no_log: bool,
1643 pub spec_root: &'a SpecRootIdentity,
1644}
1645
1646fn shell_inline_c_needs_argv0(argv: &[String]) -> bool {
1653 if argv.len() != 3 {
1654 return false;
1655 }
1656 let prog = Path::new(&argv[0])
1657 .file_name()
1658 .and_then(|s| s.to_str())
1659 .unwrap_or(argv[0].as_str());
1660 let is_shell = matches!(
1661 prog,
1662 "bash" | "zsh" | "sh" | "dash" | "ksh" | "ash" | "busybox"
1663 );
1664 is_shell && matches!(argv[1].as_str(), "-c" | "-lc")
1665}
1666
1667fn shell_passthrough_argv0(chain: &[String]) -> String {
1668 chain
1669 .iter()
1670 .rev()
1671 .find(|s| s.as_str() != "run")
1672 .cloned()
1673 .or_else(|| chain.last().cloned())
1674 .unwrap_or_else(|| "jan".to_string())
1675}
1676
1677pub fn run_matched(
1678 spec: &RootSpec,
1679 chain: &[String],
1680 node: &CommandNode,
1681 trailing: &[OsString],
1682 ctx: &RunContext<'_>,
1683) -> Result<i32> {
1684 let exec = match &node.exec {
1685 Some(e) => e,
1686 None => {
1687 let help = format_help(spec, chain, Some(node));
1688 print!("{help}");
1689 bail!("missing subcommand");
1690 }
1691 };
1692 exec.validate(&chain.join(" "))?;
1693
1694 if exec.is_text() {
1695 let body = exec.literal_text().unwrap_or("");
1696 println!("{body}");
1697 return Ok(0);
1698 }
1699
1700 let input_defs = inputs::collect_chain_inputs(chain, spec);
1701 let (input_vals, rest) = inputs::resolve_inputs(&input_defs, trailing, Some(ctx.cwd))?;
1702
1703 let mut argv: Vec<String> = Vec::with_capacity(exec.argv.len() + 1);
1704 for a in &exec.argv {
1705 argv.push(inputs::interpolate(a, &input_vals)?);
1706 }
1707
1708 if exec.is_remote() {
1709 let url = exec.url.as_deref().unwrap().trim();
1710 let hash = exec.sha256.as_deref().unwrap().trim();
1711 let mut opts = remote::FetchOpts::new();
1712 if let Some(ttl) = exec.ttl {
1713 opts = opts.with_ttl(ttl);
1714 }
1715 let cached = remote::fetch_verified(url, hash, &opts, true)?;
1716 argv.push(cached.to_string_lossy().into_owned());
1717 } else if exec.is_local_file() {
1718 let rel = exec.file.as_deref().unwrap().trim();
1719 let use_root = Path::new(&ctx.spec_root.spec_dir);
1720 let resolved = spec_load::resolve_under_use_root(use_root, rel)?;
1721 if let Some(hash) = exec
1722 .sha256
1723 .as_deref()
1724 .map(str::trim)
1725 .filter(|s| !s.is_empty())
1726 {
1727 remote::verify_file_sha256(&resolved, hash)
1728 .with_context(|| format!("verify exec.file `{rel}`"))?;
1729 }
1730 argv.push(resolved.to_string_lossy().into_owned());
1731 } else if exec.is_language_source() {
1732 } else if argv.is_empty() {
1734 bail!("exec.argv must not be empty");
1735 }
1736
1737 if exec.passthrough {
1738 let mut rest = rest;
1739 if rest.first().is_some_and(|a| a == "--") {
1743 rest = rest[1..].to_vec();
1744 }
1745 if shell_inline_c_needs_argv0(&argv) {
1746 argv.push(shell_passthrough_argv0(chain));
1747 }
1748 for a in &rest {
1749 argv.push(a.to_string_lossy().into_owned());
1750 }
1751 } else if !rest.is_empty() {
1752 let preview = rest
1753 .iter()
1754 .take(3)
1755 .map(|s| s.to_string_lossy().into_owned())
1756 .collect::<Vec<_>>()
1757 .join(" ");
1758 bail!(
1759 "unexpected trailing arguments: {preview}{}",
1760 if rest.len() > 3 { "…" } else { "" }
1761 );
1762 }
1763
1764 let cmd_path = if chain.is_empty() {
1765 "(root)".to_string()
1766 } else {
1767 chain.join(" ")
1768 };
1769
1770 let (_, requires, _) = deps::collect_chain_metadata(chain, spec);
1771 deps::check_requires(&requires)?;
1772
1773 let pkgs = packages::collect_chain_packages(chain, spec);
1774 let pkg_envs = packages::ensure_packages(&pkgs, ctx)?;
1775
1776 if exec.is_kotlin() {
1777 let rel = exec.kotlin.as_deref().unwrap().trim();
1778 let use_root = Path::new(&ctx.spec_root.spec_dir);
1779 let main_args = std::mem::take(&mut argv);
1780 argv = packages::prepare_kotlin_argv(use_root, rel, &pkg_envs, &main_args)?;
1781 } else if exec.is_python() {
1782 let src = exec.python.as_deref().unwrap().trim();
1783 let use_root = Path::new(&ctx.spec_root.spec_dir);
1784 let main_args = std::mem::take(&mut argv);
1785 argv = packages::prepare_python_argv(use_root, src, &main_args)?;
1786 } else if exec.is_node() {
1787 let src = exec.node.as_deref().unwrap().trim();
1788 let use_root = Path::new(&ctx.spec_root.spec_dir);
1789 let main_args = std::mem::take(&mut argv);
1790 argv = packages::prepare_node_argv(use_root, src, &main_args)?;
1791 } else if exec.is_bash() || exec.is_sh() || exec.is_zsh() {
1792 let (kind, src) = if exec.is_bash() {
1793 (
1794 packages::ShellKind::Bash,
1795 exec.bash.as_deref().unwrap().trim(),
1796 )
1797 } else if exec.is_zsh() {
1798 (
1799 packages::ShellKind::Zsh,
1800 exec.zsh.as_deref().unwrap().trim(),
1801 )
1802 } else {
1803 (packages::ShellKind::Sh, exec.sh.as_deref().unwrap().trim())
1804 };
1805 let use_root = Path::new(&ctx.spec_root.spec_dir);
1806 let main_args = std::mem::take(&mut argv);
1807 let argv0 = shell_passthrough_argv0(chain);
1808 argv = packages::prepare_shell_argv(kind, use_root, src, &argv0, &main_args)?;
1809 } else {
1810 packages::inject_jvm_classpath(&mut argv, &pkg_envs);
1811 }
1812
1813 let path_dirs = deps::resolve_path_prefixes(spec, chain, ctx)?;
1814 let program = packages::resolve_program_with_envs(&argv[0], &pkg_envs, &path_dirs)?;
1815 let mut env_spec = deps::collect_chain_env(chain, spec);
1816 for value in env_spec.public.values_mut() {
1817 *value = inputs::interpolate(value, &input_vals)?;
1818 }
1819 deps::check_private_env(&env_spec.private)?;
1820 let mut path_override = if !path_dirs.is_empty() {
1821 Some(deps::prepend_path_env(&path_dirs)?)
1822 } else {
1823 None
1824 };
1825 if !pkg_envs.is_empty() {
1826 path_override = Some(packages::prepend_env_paths(&pkg_envs, path_override)?);
1827 }
1828 if let Some(node_path) = packages::node_path_for(&pkg_envs) {
1829 env_spec
1830 .public
1831 .entry("NODE_PATH".to_string())
1832 .or_insert(node_path);
1833 }
1834 if let Some(classpath) = packages::classpath_for(&pkg_envs) {
1835 env_spec
1836 .public
1837 .entry("CLASSPATH".to_string())
1838 .or_insert(classpath);
1839 }
1840
1841 let mut c = Command::new(&program);
1842 if argv.len() > 1 {
1843 c.args(&argv[1..]);
1844 }
1845 c.current_dir(ctx.cwd);
1846 deps::apply_process_env(&mut c, &env_spec, path_override)?;
1847
1848 let status = c.status().with_context(|| format!("spawn `{}`", argv[0]))?;
1849 let code = status.code().unwrap_or(255);
1850
1851 if !ctx.no_log {
1852 if let Some(db) = ctx.db_path {
1853 log_invocation(
1854 db,
1855 &ctx.branch,
1856 ctx.cwd,
1857 &cmd_path,
1858 &argv,
1859 code,
1860 ctx.spec_root,
1861 )?;
1862 }
1863 }
1864
1865 Ok(code)
1866}
1867
1868fn ensure_invocations_spec_root_column(conn: &Connection) -> Result<()> {
1869 let mut stmt = conn.prepare("PRAGMA table_info(invocations)")?;
1870 let cols: Vec<String> = stmt
1871 .query_map([], |row| row.get::<_, String>(1))?
1872 .collect::<std::result::Result<_, _>>()?;
1873 if !cols.iter().any(|c| c == "spec_root_id") {
1874 conn.execute(
1875 "ALTER TABLE invocations ADD COLUMN spec_root_id INTEGER",
1876 [],
1877 )?;
1878 }
1879 Ok(())
1880}
1881
1882fn upsert_spec_root(conn: &Connection, spec: &SpecRootIdentity) -> Result<i64> {
1883 let ts = unix_ts();
1884 conn.execute(
1885 r"INSERT INTO spec_roots (spec_dir, root_yaml, last_used_ts) VALUES (?1, ?2, ?3)
1886 ON CONFLICT(spec_dir, root_yaml) DO UPDATE SET last_used_ts = excluded.last_used_ts",
1887 rusqlite::params![&spec.spec_dir, &spec.root_yaml, &ts],
1888 )?;
1889 let id: i64 = conn.query_row(
1890 "SELECT id FROM spec_roots WHERE spec_dir = ?1 AND root_yaml = ?2",
1891 [&spec.spec_dir, &spec.root_yaml],
1892 |r| r.get(0),
1893 )?;
1894 Ok(id)
1895}
1896
1897fn log_invocation(
1898 db_path: &Path,
1899 branch: &str,
1900 cwd: &Path,
1901 command_path: &str,
1902 argv: &[String],
1903 exit_code: i32,
1904 spec_root: &SpecRootIdentity,
1905) -> Result<()> {
1906 if let Some(parent) = db_path.parent() {
1907 std::fs::create_dir_all(parent).ok();
1908 }
1909 let conn = Connection::open(db_path).with_context(|| format!("open {}", db_path.display()))?;
1910 conn.execute_batch(
1911 r"
1912 CREATE TABLE IF NOT EXISTS spec_roots (
1913 id INTEGER PRIMARY KEY AUTOINCREMENT,
1914 spec_dir TEXT NOT NULL,
1915 root_yaml TEXT NOT NULL,
1916 last_used_ts TEXT NOT NULL,
1917 UNIQUE(spec_dir, root_yaml)
1918 );
1919 CREATE TABLE IF NOT EXISTS invocations (
1920 id INTEGER PRIMARY KEY AUTOINCREMENT,
1921 ts TEXT NOT NULL,
1922 git_branch TEXT NOT NULL,
1923 cwd TEXT NOT NULL,
1924 command_path TEXT NOT NULL,
1925 argv_json TEXT NOT NULL,
1926 exit_code INTEGER NOT NULL,
1927 spec_root_id INTEGER
1928 );
1929 ",
1930 )?;
1931 ensure_invocations_spec_root_column(&conn)?;
1932 let spec_root_id = upsert_spec_root(&conn, spec_root)?;
1933 let ts = unix_ts();
1934 let argv_json = serde_json::to_string(argv).unwrap_or_else(|_| "[]".to_string());
1935 let cwd_s = cwd.to_string_lossy();
1936 conn.execute(
1937 "INSERT INTO invocations (ts, git_branch, cwd, command_path, argv_json, exit_code, spec_root_id)
1938 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1939 rusqlite::params![
1940 ts,
1941 branch,
1942 cwd_s.as_ref(),
1943 command_path,
1944 argv_json,
1945 exit_code,
1946 spec_root_id
1947 ],
1948 )?;
1949 Ok(())
1950}
1951
1952fn unix_ts() -> String {
1953 use std::time::SystemTime;
1954 SystemTime::now()
1955 .duration_since(std::time::UNIX_EPOCH)
1956 .unwrap_or_default()
1957 .as_secs()
1958 .to_string()
1959}
1960
1961#[derive(Debug)]
1962pub struct MatchOutcome<'a> {
1963 pub chain: Vec<String>,
1964 pub node: Option<&'a CommandNode>,
1965 pub trailing: Vec<OsString>,
1966 pub wants_help: bool,
1967}
1968
1969pub fn match_commands<'a>(spec: &'a RootSpec, args: &[OsString]) -> MatchOutcome<'a> {
1970 let mut chain = Vec::new();
1971 let mut node: Option<&'a CommandNode> = None;
1972 let mut map: &BTreeMap<String, CommandNode> = &spec.commands;
1973 let mut i = 0usize;
1974 let len = args.len();
1975 while i < len {
1976 let raw = &args[i];
1977 if raw == "--help" || raw == "-h" {
1978 return MatchOutcome {
1979 chain,
1980 node,
1981 trailing: args[i + 1..].to_vec(),
1982 wants_help: true,
1983 };
1984 }
1985 let key = raw.to_string_lossy();
1986 if let Some(next) = map.get(key.as_ref()) {
1987 chain.push(key.into_owned());
1988 node = Some(next);
1989 map = &next.commands;
1990 i += 1;
1991 continue;
1992 }
1993 break;
1994 }
1995 MatchOutcome {
1996 chain,
1997 node,
1998 trailing: args[i..].to_vec(),
1999 wants_help: false,
2000 }
2001}
2002
2003#[cfg(test)]
2004mod tests {
2005 use super::*;
2006 use std::io::Write;
2007
2008 #[test]
2009 fn examples_default_spec_validates() {
2010 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/default.spec.yaml");
2011 load_spec(&path).unwrap();
2012 }
2013
2014 #[test]
2015 fn merge_specs_adds_and_replaces_leaves() {
2016 let mut base = load_spec_from_str(
2017 r"
2018commands:
2019 a:
2020 about: base
2021 commands:
2022 x:
2023 about: old
2024 exec:
2025 argv: [echo, old]
2026",
2027 None,
2028 )
2029 .unwrap();
2030 let overlay = load_spec_from_str(
2031 r"
2032commands:
2033 a:
2034 commands:
2035 x:
2036 about: new leaf
2037 exec:
2038 argv: [echo, new]
2039 b:
2040 about: added top
2041 exec:
2042 argv: [echo, b]
2043",
2044 None,
2045 )
2046 .unwrap();
2047 merge_specs_into(&mut base, overlay).unwrap();
2048 base.commands["a"].commands["x"].validate("a x").unwrap();
2049 assert_eq!(
2050 base.commands["a"].commands["x"].exec.as_ref().unwrap().argv,
2051 vec!["echo", "new"]
2052 );
2053 assert_eq!(
2054 base.commands["b"].exec.as_ref().unwrap().argv,
2055 vec!["echo", "b"]
2056 );
2057 }
2058
2059 #[test]
2060 fn validate_rejects_exec_with_children() {
2061 let mut tmp = tempfile::NamedTempFile::with_suffix(".yaml").unwrap();
2062 write!(
2063 tmp,
2064 r"
2065commands:
2066 x:
2067 exec:
2068 argv: [echo]
2069 commands:
2070 child:
2071 about: nested
2072"
2073 )
2074 .unwrap();
2075 let err = load_spec(tmp.path()).unwrap_err();
2076 assert!(err.to_string().contains("cannot define both"));
2077 }
2078
2079 #[test]
2080 fn shell_inline_c_needs_argv0_detects_bash_lc() {
2081 let argv = vec![
2082 "bash".into(),
2083 "-lc".into(),
2084 "case \"$1\" in create) ;; esac".into(),
2085 ];
2086 assert!(shell_inline_c_needs_argv0(&argv));
2087 let with_placeholder = vec!["zsh".into(), "-c".into(), "echo".into(), "issue".into()];
2088 assert!(!shell_inline_c_needs_argv0(&with_placeholder));
2089 assert!(!shell_inline_c_needs_argv0(&[
2090 "echo".into(),
2091 "start".into()
2092 ]));
2093 assert!(!shell_inline_c_needs_argv0(&[
2094 "python3".into(),
2095 "-c".into(),
2096 "print(1)".into()
2097 ]));
2098 }
2099
2100 #[test]
2101 fn shell_passthrough_argv0_skips_run_leaf() {
2102 assert_eq!(
2103 shell_passthrough_argv0(&[
2104 "scripts".into(),
2105 "misc".into(),
2106 "issue".into(),
2107 "run".into()
2108 ]),
2109 "issue"
2110 );
2111 assert_eq!(shell_passthrough_argv0(&["probe".into()]), "probe");
2112 }
2113
2114 #[test]
2115 fn language_exec_path_vs_inline_detection() {
2116 assert!(ExecSpec::python_value_is_path("scripts/x.py"));
2117 assert!(ExecSpec::python_value_is_path("X.PY"));
2118 assert!(!ExecSpec::python_value_is_path("print(1)\n"));
2119 assert!(!ExecSpec::python_value_is_path("import sys"));
2120 assert!(ExecSpec::node_value_is_path("a.js"));
2121 assert!(ExecSpec::node_value_is_path("a.mjs"));
2122 assert!(ExecSpec::node_value_is_path("a.cjs"));
2123 assert!(!ExecSpec::node_value_is_path("console.log(1)"));
2124 assert!(!ExecSpec::node_value_is_path("x.ts"));
2125 assert!(ExecSpec::bash_value_is_path("x.sh"));
2126 assert!(ExecSpec::bash_value_is_path("x.bash"));
2127 assert!(!ExecSpec::bash_value_is_path("echo hi"));
2128 assert!(ExecSpec::sh_value_is_path("x.sh"));
2129 assert!(ExecSpec::zsh_value_is_path("x.zsh"));
2130 let bash = ExecSpec {
2131 bash: Some("echo hi".into()),
2132 ..Default::default()
2133 };
2134 bash.validate("t").unwrap();
2135
2136 let python = ExecSpec {
2137 python: Some("print(1)".into()),
2138 ..Default::default()
2139 };
2140 python.validate("t").unwrap();
2141 let node = ExecSpec {
2142 node: Some("console.log(1)".into()),
2143 ..Default::default()
2144 };
2145 node.validate("t").unwrap();
2146 let both = ExecSpec {
2147 python: Some("x.py".into()),
2148 node: Some("x.js".into()),
2149 ..Default::default()
2150 };
2151 assert!(both.validate("t").is_err());
2152 let text = ExecSpec {
2153 text: Some("hello docs\n".into()),
2154 ..Default::default()
2155 };
2156 text.validate("t").unwrap();
2157 let cat: ExecSpec = serde_yaml::from_str("cat: |\n printed as-is\n").unwrap();
2158 assert_eq!(cat.literal_text(), Some("printed as-is"));
2159 }
2160
2161 #[test]
2162 fn format_help_inlines_help_child_and_hides_help_leaf() {
2163 let spec = load_spec_from_str(
2164 r#"
2165commands:
2166 backup:
2167 about: Backup a path
2168 inputs:
2169 path:
2170 required: true
2171 type: path
2172 commands:
2173 help:
2174 about: Describe this script.
2175 exec:
2176 text: |
2177 backup — copy files
2178 Example: jan backup run --path /data
2179 run:
2180 about: Run the backup
2181 exec:
2182 argv: [echo, ok]
2183"#,
2184 None,
2185 )
2186 .unwrap();
2187 let node = &spec.commands["backup"];
2188 let help = format_help(&spec, &["backup".into()], Some(node));
2189 assert!(help.contains("backup — copy files"));
2190 assert!(help.contains("jan backup run --path /data"));
2191 assert!(help.contains(" run — Run the backup"));
2192 assert!(!help.contains(" help —"));
2193 assert!(help.contains("--path"));
2194 let run = &node.commands["run"];
2195 let run_help = format_help(&spec, &["backup".into(), "run".into()], Some(run));
2196 assert!(run_help.contains("backup — copy files"));
2197 assert!(run_help.contains("--path"));
2198 }
2199
2200 #[test]
2201 fn gherkin_test_names() {
2202 assert!(gherkin_test_name(
2203 "given_a_csv_when_summarized_then_prints_shape"
2204 ));
2205 assert!(gherkin_test_name(
2206 "given a file when basename then prints name"
2207 ));
2208 assert!(gherkin_test_name(
2209 "given-a-name-when-run-then-mentions-birthday"
2210 ));
2211 assert!(!gherkin_test_name("prints_hello"));
2212 assert!(!gherkin_test_name("given_when_then"));
2213 assert!(!gherkin_test_name("given_x_when_y"));
2214 let t = CommandTest {
2215 when: "jan hello".into(),
2216 then: "test \"$JAN_STATUS\" -eq 0".into(),
2217 ..Default::default()
2218 };
2219 t.validate("hello", "given_no_args_when_run_then_ok")
2220 .unwrap();
2221 assert!(t.validate("hello", "not_gherkin").is_err());
2222 }
2223}
2224
2225pub fn default_db_path() -> PathBuf {
2226 if let Ok(p) = std::env::var("JAN_DB") {
2227 return PathBuf::from(p);
2228 }
2229 dirs::data_local_dir()
2230 .unwrap_or_else(|| PathBuf::from("."))
2231 .join("jan-cli")
2232 .join("audit.db")
2233}