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, HashSet};
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, Clone, Default, PartialEq, Eq)]
195pub struct AliasesSpec {
196 pub names: Vec<String>,
198 pub shell: BTreeMap<String, String>,
200}
201
202impl AliasesSpec {
203 pub fn is_empty(&self) -> bool {
204 self.names.is_empty() && self.shell.is_empty()
205 }
206
207 pub fn merge_from(&mut self, other: Self) {
210 for n in other.names {
211 self.shell.remove(&n);
212 if !self.names.iter().any(|e| e == &n) {
213 self.names.push(n);
214 }
215 }
216 for (k, v) in other.shell {
217 self.names.retain(|n| n != &k);
218 self.shell.insert(k, v);
219 }
220 }
221
222 pub fn validate(&self, path: &str) -> Result<()> {
223 let mut seen = HashSet::new();
224 for name in &self.names {
225 if !is_safe_alias_name(name) {
226 bail!(
227 "command '{path}': alias name `{name}` must match [A-Za-z_][A-Za-z0-9_-]*"
228 );
229 }
230 if !seen.insert(name.clone()) {
231 bail!("command '{path}': duplicate alias name `{name}`");
232 }
233 }
234 for name in self.shell.keys() {
235 if !is_safe_alias_name(name) {
236 bail!(
237 "command '{path}': alias name `{name}` must match [A-Za-z_][A-Za-z0-9_-]*"
238 );
239 }
240 if !seen.insert(name.clone()) {
241 bail!(
242 "command '{path}': alias `{name}` is declared both as a jan name and a shell RHS"
243 );
244 }
245 }
246 Ok(())
247 }
248}
249
250impl<'de> Deserialize<'de> for AliasesSpec {
251 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
252 where
253 D: Deserializer<'de>,
254 {
255 struct AliasesVisitor;
256
257 impl<'de> Visitor<'de> for AliasesVisitor {
258 type Value = AliasesSpec;
259
260 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
261 formatter.write_str(
262 "a string, a list of names, or a map of alias name to shell RHS",
263 )
264 }
265
266 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
267 where
268 E: de::Error,
269 {
270 if value.trim().is_empty() {
271 Ok(AliasesSpec::default())
272 } else {
273 Ok(AliasesSpec {
274 names: vec![value.to_string()],
275 shell: BTreeMap::new(),
276 })
277 }
278 }
279
280 fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
281 where
282 E: de::Error,
283 {
284 self.visit_str(&value)
285 }
286
287 fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
288 where
289 A: de::SeqAccess<'de>,
290 {
291 let mut names = Vec::new();
292 while let Some(s) = seq.next_element::<String>()? {
293 if !s.trim().is_empty() {
294 names.push(s);
295 }
296 }
297 Ok(AliasesSpec {
298 names,
299 shell: BTreeMap::new(),
300 })
301 }
302
303 fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
304 where
305 A: de::MapAccess<'de>,
306 {
307 let mut spec = AliasesSpec::default();
308 while let Some(key) = map.next_key::<String>()? {
309 let val: Option<String> = map.next_value()?;
310 match val {
311 Some(s) if !s.trim().is_empty() => {
312 spec.shell.insert(key, s);
313 }
314 _ => spec.names.push(key),
315 }
316 }
317 Ok(spec)
318 }
319
320 fn visit_none<E>(self) -> Result<Self::Value, E>
321 where
322 E: de::Error,
323 {
324 Ok(AliasesSpec::default())
325 }
326
327 fn visit_unit<E>(self) -> Result<Self::Value, E>
328 where
329 E: de::Error,
330 {
331 Ok(AliasesSpec::default())
332 }
333 }
334
335 deserializer.deserialize_any(AliasesVisitor)
336 }
337}
338
339pub(crate) fn is_safe_alias_name(name: &str) -> bool {
343 let mut chars = name.chars();
344 matches!(chars.next(), Some(c) if c.is_ascii_alphabetic() || c == '_')
345 && chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '-')
346}
347
348#[derive(Debug, Deserialize, Default, Clone)]
349pub struct CommandNode {
350 #[serde(default)]
353 pub os: Vec<String>,
354 #[serde(default)]
355 pub about: String,
356 pub path: Option<String>,
358 #[serde(default)]
360 pub dependencies: Vec<String>,
361 #[serde(default)]
363 pub requires: Vec<String>,
364 #[serde(default)]
366 pub env: EnvSpec,
367 #[serde(default)]
369 pub inputs: BTreeMap<String, crate::inputs::InputDef>,
370 #[serde(default, deserialize_with = "deserialize_string_or_seq")]
373 pub cron: Vec<String>,
374 #[serde(default)]
376 pub packages: PackagesSpec,
377 #[serde(default)]
379 pub tests: BTreeMap<String, CommandTest>,
380 #[serde(default)]
382 pub aliases: AliasesSpec,
383 #[serde(default)]
384 pub commands: BTreeMap<String, CommandNode>,
385 pub exec: Option<ExecSpec>,
386 #[serde(skip)]
388 pub source: Option<IncludeLink>,
389}
390
391#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
398pub struct CommandTest {
399 #[serde(default)]
401 pub given: String,
402 #[serde(default)]
404 pub when: String,
405 #[serde(default)]
407 pub then: String,
408}
409
410impl CommandTest {
411 pub fn validate(&self, path: &str, name: &str) -> Result<()> {
412 if !gherkin_test_name(name) {
413 bail!(
414 "command '{path}': test `{name}` must follow the given_…_when_…_then_… naming pattern"
415 );
416 }
417 if self.then.trim().is_empty() {
418 bail!("command '{path}': test `{name}` needs a non-empty `then:` script");
419 }
420 Ok(())
421 }
422}
423
424pub fn gherkin_test_name(name: &str) -> bool {
426 let n: String = name
427 .trim()
428 .to_ascii_lowercase()
429 .chars()
430 .map(|c| {
431 if c == '-' || c.is_whitespace() {
432 '_'
433 } else {
434 c
435 }
436 })
437 .collect();
438 let n = n
439 .split('_')
440 .filter(|s| !s.is_empty())
441 .collect::<Vec<_>>()
442 .join("_");
443 let Some(rest) = n.strip_prefix("given_") else {
444 return false;
445 };
446 let Some((given_body, after_when)) = rest.split_once("_when_") else {
447 return false;
448 };
449 let Some((when_body, then_body)) = after_when.split_once("_then_") else {
450 return false;
451 };
452 !given_body.is_empty() && !when_body.is_empty() && !then_body.is_empty()
453}
454
455#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
460pub struct PackagesSpec {
461 #[serde(default)]
462 pub uv: Option<UvPackages>,
463 #[serde(default)]
464 pub pnpm: Option<PnpmPackages>,
465 #[serde(default)]
466 pub gradle: Option<GradlePackages>,
467}
468
469impl PackagesSpec {
470 pub fn is_empty(&self) -> bool {
471 self.uv.is_none() && self.pnpm.is_none() && self.gradle.is_none()
472 }
473
474 pub fn merge_from(&mut self, other: PackagesSpec) {
476 if other.uv.is_some() {
477 self.uv = other.uv;
478 }
479 if other.pnpm.is_some() {
480 self.pnpm = other.pnpm;
481 }
482 if other.gradle.is_some() {
483 self.gradle = other.gradle;
484 }
485 }
486
487 pub fn validate(&self, path: &str) -> Result<()> {
488 if let Some(uv) = &self.uv {
489 uv.validate(path)?;
490 }
491 if let Some(pnpm) = &self.pnpm {
492 pnpm.validate(path)?;
493 }
494 if let Some(gradle) = &self.gradle {
495 gradle.validate(path)?;
496 }
497 Ok(())
498 }
499}
500
501#[derive(Debug, Clone, PartialEq, Eq)]
504pub struct UvPackages {
505 pub deps: UvDeps,
506 pub python: Option<String>,
508}
509
510#[derive(Debug, Clone, PartialEq, Eq)]
511pub enum UvDeps {
512 List(Vec<String>),
513 Project(String),
514 Requirements(String),
515}
516
517impl UvPackages {
518 pub fn list(pkgs: Vec<String>) -> Self {
519 Self {
520 deps: UvDeps::List(pkgs),
521 python: None,
522 }
523 }
524
525 pub fn validate(&self, path: &str) -> Result<()> {
526 if let Some(py) = &self.python {
527 packages::parse_min_version_constraint(py)
528 .map_err(|e| anyhow::anyhow!("command '{path}': packages.uv.python: {e}"))?;
529 }
530 match &self.deps {
531 UvDeps::List(pkgs) => {
532 if pkgs.is_empty() {
533 bail!("command '{path}': packages.uv list must not be empty");
534 }
535 for p in pkgs {
536 if p.trim().is_empty() {
537 bail!("command '{path}': packages.uv entry must not be empty");
538 }
539 packages::check_pinned_requirement(p)
540 .map_err(|e| anyhow::anyhow!("command '{path}': {e}"))?;
541 }
542 }
543 UvDeps::Project(p) | UvDeps::Requirements(p) => {
544 if p.trim().is_empty() {
545 bail!("command '{path}': packages.uv path must not be empty");
546 }
547 }
548 }
549 Ok(())
550 }
551}
552
553impl<'de> Deserialize<'de> for UvPackages {
554 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
555 where
556 D: Deserializer<'de>,
557 {
558 #[derive(Deserialize)]
559 #[serde(deny_unknown_fields)]
560 struct MapForm {
561 #[serde(default)]
562 project: Option<String>,
563 #[serde(default)]
564 requirements: Option<String>,
565 #[serde(default, alias = "deps")]
566 packages: Option<Vec<String>>,
567 #[serde(default, deserialize_with = "deserialize_opt_stringish")]
568 python: Option<String>,
569 }
570
571 #[derive(Deserialize)]
572 #[serde(untagged)]
573 enum Helper {
574 List(Vec<String>),
575 Map(MapForm),
576 }
577
578 match Helper::deserialize(deserializer)? {
579 Helper::List(pkgs) => {
580 let pkgs: Vec<String> = pkgs
581 .into_iter()
582 .map(|s| s.trim().to_string())
583 .filter(|s| !s.is_empty())
584 .collect();
585 Ok(UvPackages {
586 deps: UvDeps::List(pkgs),
587 python: None,
588 })
589 }
590 Helper::Map(m) => {
591 let project = m
592 .project
593 .map(|s| s.trim().to_string())
594 .filter(|s| !s.is_empty());
595 let requirements = m
596 .requirements
597 .map(|s| s.trim().to_string())
598 .filter(|s| !s.is_empty());
599 let packages = m.packages.map(|pkgs| {
600 pkgs.into_iter()
601 .map(|s| s.trim().to_string())
602 .filter(|s| !s.is_empty())
603 .collect::<Vec<_>>()
604 });
605 let python = m
606 .python
607 .map(|s| s.trim().to_string())
608 .filter(|s| !s.is_empty());
609 let deps = match (project, requirements, packages) {
610 (Some(p), None, None) => UvDeps::Project(p),
611 (None, Some(r), None) => UvDeps::Requirements(r),
612 (None, None, Some(pkgs)) => UvDeps::List(pkgs),
613 _ => {
614 return Err(de::Error::custom(
615 "packages.uv map must set exactly one of `packages`, `project`, or `requirements`",
616 ));
617 }
618 };
619 Ok(UvPackages { deps, python })
620 }
621 }
622 }
623}
624
625#[derive(Debug, Clone, PartialEq, Eq)]
628pub struct PnpmPackages {
629 pub deps: PnpmDeps,
630 pub node: Option<String>,
632}
633
634#[derive(Debug, Clone, PartialEq, Eq)]
635pub enum PnpmDeps {
636 List(Vec<String>),
637 Project(String),
638}
639
640impl PnpmPackages {
641 pub fn list(pkgs: Vec<String>) -> Self {
642 Self {
643 deps: PnpmDeps::List(pkgs),
644 node: None,
645 }
646 }
647
648 pub fn validate(&self, path: &str) -> Result<()> {
649 if let Some(node) = &self.node {
650 packages::parse_min_version_constraint(node)
651 .map_err(|e| anyhow::anyhow!("command '{path}': packages.pnpm.node: {e}"))?;
652 }
653 match &self.deps {
654 PnpmDeps::List(pkgs) => {
655 if pkgs.is_empty() {
656 bail!("command '{path}': packages.pnpm list must not be empty");
657 }
658 for p in pkgs {
659 if p.trim().is_empty() {
660 bail!("command '{path}': packages.pnpm entry must not be empty");
661 }
662 packages::check_pinned_npm_spec(p)
663 .map_err(|e| anyhow::anyhow!("command '{path}': {e}"))?;
664 }
665 }
666 PnpmDeps::Project(p) => {
667 if p.trim().is_empty() {
668 bail!("command '{path}': packages.pnpm path must not be empty");
669 }
670 }
671 }
672 Ok(())
673 }
674}
675
676impl<'de> Deserialize<'de> for PnpmPackages {
677 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
678 where
679 D: Deserializer<'de>,
680 {
681 #[derive(Deserialize)]
682 #[serde(deny_unknown_fields)]
683 struct MapForm {
684 #[serde(default)]
685 project: Option<String>,
686 #[serde(default, alias = "deps")]
687 packages: Option<Vec<String>>,
688 #[serde(default, deserialize_with = "deserialize_opt_stringish")]
689 node: Option<String>,
690 }
691
692 #[derive(Deserialize)]
693 #[serde(untagged)]
694 enum Helper {
695 List(Vec<String>),
696 Map(MapForm),
697 }
698
699 match Helper::deserialize(deserializer)? {
700 Helper::List(pkgs) => {
701 let pkgs: Vec<String> = pkgs
702 .into_iter()
703 .map(|s| s.trim().to_string())
704 .filter(|s| !s.is_empty())
705 .collect();
706 Ok(PnpmPackages {
707 deps: PnpmDeps::List(pkgs),
708 node: None,
709 })
710 }
711 Helper::Map(m) => {
712 let project = m
713 .project
714 .map(|s| s.trim().to_string())
715 .filter(|s| !s.is_empty());
716 let packages = m.packages.map(|pkgs| {
717 pkgs.into_iter()
718 .map(|s| s.trim().to_string())
719 .filter(|s| !s.is_empty())
720 .collect::<Vec<_>>()
721 });
722 let node = m
723 .node
724 .map(|s| s.trim().to_string())
725 .filter(|s| !s.is_empty());
726 let deps = match (project, packages) {
727 (Some(p), None) => PnpmDeps::Project(p),
728 (None, Some(pkgs)) => PnpmDeps::List(pkgs),
729 _ => {
730 return Err(de::Error::custom(
731 "packages.pnpm map must set exactly one of `packages` or `project`",
732 ));
733 }
734 };
735 Ok(PnpmPackages { deps, node })
736 }
737 }
738 }
739}
740
741#[derive(Debug, Clone, PartialEq, Eq)]
744pub struct GradlePackages {
745 pub deps: GradleDeps,
746 pub java: Option<String>,
748}
749
750#[derive(Debug, Clone, PartialEq, Eq)]
751pub enum GradleDeps {
752 List(Vec<String>),
753 Project(String),
754}
755
756impl GradlePackages {
757 pub fn list(pkgs: Vec<String>) -> Self {
758 Self {
759 deps: GradleDeps::List(pkgs),
760 java: None,
761 }
762 }
763
764 pub fn validate(&self, path: &str) -> Result<()> {
765 if let Some(java) = &self.java {
766 packages::parse_min_version_constraint(java)
767 .map_err(|e| anyhow::anyhow!("command '{path}': packages.gradle.java: {e}"))?;
768 }
769 match &self.deps {
770 GradleDeps::List(pkgs) => {
771 if pkgs.is_empty() {
772 bail!("command '{path}': packages.gradle list must not be empty");
773 }
774 for p in pkgs {
775 if p.trim().is_empty() {
776 bail!("command '{path}': packages.gradle entry must not be empty");
777 }
778 packages::check_pinned_maven_coord(p)
779 .map_err(|e| anyhow::anyhow!("command '{path}': {e}"))?;
780 }
781 }
782 GradleDeps::Project(p) => {
783 if p.trim().is_empty() {
784 bail!("command '{path}': packages.gradle path must not be empty");
785 }
786 }
787 }
788 Ok(())
789 }
790}
791
792impl<'de> Deserialize<'de> for GradlePackages {
793 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
794 where
795 D: Deserializer<'de>,
796 {
797 #[derive(Deserialize)]
798 #[serde(deny_unknown_fields)]
799 struct MapForm {
800 #[serde(default)]
801 project: Option<String>,
802 #[serde(default, alias = "deps")]
803 packages: Option<Vec<String>>,
804 #[serde(default, alias = "jdk", deserialize_with = "deserialize_opt_stringish")]
805 java: Option<String>,
806 }
807
808 #[derive(Deserialize)]
809 #[serde(untagged)]
810 enum Helper {
811 List(Vec<String>),
812 Map(MapForm),
813 }
814
815 match Helper::deserialize(deserializer)? {
816 Helper::List(pkgs) => {
817 let pkgs: Vec<String> = pkgs
818 .into_iter()
819 .map(|s| s.trim().to_string())
820 .filter(|s| !s.is_empty())
821 .collect();
822 Ok(GradlePackages {
823 deps: GradleDeps::List(pkgs),
824 java: None,
825 })
826 }
827 Helper::Map(m) => {
828 let project = m
829 .project
830 .map(|s| s.trim().to_string())
831 .filter(|s| !s.is_empty());
832 let packages = m.packages.map(|pkgs| {
833 pkgs.into_iter()
834 .map(|s| s.trim().to_string())
835 .filter(|s| !s.is_empty())
836 .collect::<Vec<_>>()
837 });
838 let java = m
839 .java
840 .map(|s| s.trim().to_string())
841 .filter(|s| !s.is_empty());
842 let deps = match (project, packages) {
843 (Some(p), None) => GradleDeps::Project(p),
844 (None, Some(pkgs)) => GradleDeps::List(pkgs),
845 _ => {
846 return Err(de::Error::custom(
847 "packages.gradle map must set exactly one of `packages` or `project`",
848 ));
849 }
850 };
851 Ok(GradlePackages { deps, java })
852 }
853 }
854 }
855}
856
857pub(crate) fn deserialize_opt_stringish<'de, D>(deserializer: D) -> Result<Option<String>, D::Error>
858where
859 D: Deserializer<'de>,
860{
861 struct Stringish;
862
863 impl<'de> Visitor<'de> for Stringish {
864 type Value = Option<String>;
865
866 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
867 formatter.write_str("a string or number version constraint, or null")
868 }
869
870 fn visit_none<E>(self) -> Result<Self::Value, E>
871 where
872 E: de::Error,
873 {
874 Ok(None)
875 }
876
877 fn visit_unit<E>(self) -> Result<Self::Value, E>
878 where
879 E: de::Error,
880 {
881 Ok(None)
882 }
883
884 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
885 where
886 E: de::Error,
887 {
888 let t = value.trim();
889 if t.is_empty() {
890 Ok(None)
891 } else {
892 Ok(Some(t.to_string()))
893 }
894 }
895
896 fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
897 where
898 E: de::Error,
899 {
900 self.visit_str(&value)
901 }
902
903 fn visit_i64<E>(self, value: i64) -> Result<Self::Value, E>
904 where
905 E: de::Error,
906 {
907 Ok(Some(value.to_string()))
908 }
909
910 fn visit_u64<E>(self, value: u64) -> Result<Self::Value, E>
911 where
912 E: de::Error,
913 {
914 Ok(Some(value.to_string()))
915 }
916
917 fn visit_f64<E>(self, value: f64) -> Result<Self::Value, E>
918 where
919 E: de::Error,
920 {
921 let s = if (value.fract()).abs() < f64::EPSILON {
923 format!("{}", value as i64)
924 } else {
925 let s = format!("{value}");
927 s.trim_end_matches('0').trim_end_matches('.').to_string()
928 };
929 Ok(Some(s))
930 }
931 }
932
933 deserializer.deserialize_any(Stringish)
934}
935
936pub(crate) fn deserialize_string_or_seq<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
937where
938 D: Deserializer<'de>,
939{
940 struct StringOrSeq;
941
942 impl<'de> Visitor<'de> for StringOrSeq {
943 type Value = Vec<String>;
944
945 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
946 formatter.write_str("a string or a sequence of strings")
947 }
948
949 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
950 where
951 E: de::Error,
952 {
953 if value.trim().is_empty() {
954 Ok(Vec::new())
955 } else {
956 Ok(vec![value.to_string()])
957 }
958 }
959
960 fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
961 where
962 E: de::Error,
963 {
964 self.visit_str(&value)
965 }
966
967 fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
968 where
969 A: de::SeqAccess<'de>,
970 {
971 let mut out = Vec::new();
972 while let Some(s) = seq.next_element::<String>()? {
973 if !s.trim().is_empty() {
974 out.push(s);
975 }
976 }
977 Ok(out)
978 }
979
980 fn visit_none<E>(self) -> Result<Self::Value, E>
981 where
982 E: de::Error,
983 {
984 Ok(Vec::new())
985 }
986
987 fn visit_unit<E>(self) -> Result<Self::Value, E>
988 where
989 E: de::Error,
990 {
991 Ok(Vec::new())
992 }
993 }
994
995 deserializer.deserialize_any(StringOrSeq)
996}
997
998#[derive(Debug, Clone, PartialEq, Eq)]
1000pub struct LocalInclude {
1001 pub path: String,
1002 pub sha256: Option<String>,
1004 pub argv: Vec<String>,
1006 pub passthrough: bool,
1008}
1009
1010impl LocalInclude {
1011 pub fn from_path(path: impl Into<String>) -> Self {
1012 Self {
1013 path: path.into(),
1014 sha256: None,
1015 argv: Vec::new(),
1016 passthrough: false,
1017 }
1018 }
1019
1020 pub fn is_yaml(&self) -> bool {
1021 let lower = self.path.to_ascii_lowercase();
1022 lower.ends_with(".yaml") || lower.ends_with(".yml")
1023 }
1024}
1025
1026#[derive(Debug, Clone, PartialEq, Eq)]
1028pub enum IncludeRef {
1029 Local(LocalInclude),
1031 Remote(RemoteInclude),
1033}
1034
1035#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
1036pub struct RemoteInclude {
1037 pub url: String,
1038 pub sha256: String,
1039 #[serde(default)]
1040 pub ttl: Option<u64>,
1041}
1042
1043impl IncludeRef {
1044 pub fn is_remote(&self) -> bool {
1045 matches!(self, Self::Remote(_))
1046 }
1047
1048 pub fn local_path(&self) -> Option<&str> {
1049 match self {
1050 Self::Local(l) => Some(l.path.as_str()),
1051 Self::Remote(_) => None,
1052 }
1053 }
1054
1055 pub fn cycle_token(&self) -> String {
1056 match self {
1057 Self::Local(l) => match &l.sha256 {
1058 Some(h) => format!("{}#{}", l.path, h.to_ascii_lowercase()),
1059 None => l.path.clone(),
1060 },
1061 Self::Remote(r) => format!("{}#{}", r.url, r.sha256.to_ascii_lowercase()),
1062 }
1063 }
1064}
1065
1066impl<'de> Deserialize<'de> for IncludeRef {
1067 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
1068 where
1069 D: Deserializer<'de>,
1070 {
1071 #[derive(Deserialize)]
1072 #[serde(deny_unknown_fields)]
1073 struct LocalMap {
1074 path: String,
1075 #[serde(default)]
1076 sha256: Option<String>,
1077 #[serde(default)]
1078 argv: Vec<String>,
1079 #[serde(default)]
1080 passthrough: bool,
1081 }
1082
1083 #[derive(Deserialize)]
1084 #[serde(untagged)]
1085 enum Helper {
1086 Path(String),
1087 Local(LocalMap),
1088 Remote(RemoteInclude),
1089 }
1090
1091 match Helper::deserialize(deserializer)? {
1092 Helper::Path(path) => {
1093 let path = path.trim();
1094 if path.is_empty() {
1095 return Err(de::Error::custom("include path must not be empty"));
1096 }
1097 Ok(IncludeRef::Local(LocalInclude::from_path(path)))
1098 }
1099 Helper::Local(m) => {
1100 let path = m.path.trim();
1101 if path.is_empty() {
1102 return Err(de::Error::custom("include.path must not be empty"));
1103 }
1104 let sha256 = m
1105 .sha256
1106 .map(|s| s.trim().to_string())
1107 .filter(|s| !s.is_empty());
1108 Ok(IncludeRef::Local(LocalInclude {
1109 path: path.to_string(),
1110 sha256,
1111 argv: m.argv,
1112 passthrough: m.passthrough,
1113 }))
1114 }
1115 Helper::Remote(r) => {
1116 if r.url.trim().is_empty() {
1117 return Err(de::Error::custom("include.url must not be empty"));
1118 }
1119 if r.sha256.trim().is_empty() {
1120 return Err(de::Error::custom(
1121 "include.sha256 is required with include.url",
1122 ));
1123 }
1124 Ok(IncludeRef::Remote(r))
1125 }
1126 }
1127 }
1128}
1129
1130#[derive(Debug, Deserialize, Clone, Default)]
1131pub struct ExecSpec {
1132 #[serde(default)]
1136 pub argv: Vec<String>,
1137 #[serde(default)]
1139 pub passthrough: bool,
1140 #[serde(default)]
1142 pub url: Option<String>,
1143 #[serde(default)]
1145 pub file: Option<String>,
1146 #[serde(default)]
1151 pub kotlin: Option<String>,
1152 #[serde(default)]
1156 pub python: Option<String>,
1157 #[serde(default)]
1161 pub node: Option<String>,
1162 #[serde(default)]
1165 pub bash: Option<String>,
1166 #[serde(default)]
1168 pub sh: Option<String>,
1169 #[serde(default)]
1171 pub zsh: Option<String>,
1172 #[serde(default, alias = "cat")]
1174 pub text: Option<String>,
1175 #[serde(default)]
1177 pub sha256: Option<String>,
1178 #[serde(default)]
1180 pub ttl: Option<u64>,
1181}
1182
1183impl ExecSpec {
1184 pub fn is_remote(&self) -> bool {
1185 self.url
1186 .as_deref()
1187 .map(|u| !u.trim().is_empty())
1188 .unwrap_or(false)
1189 }
1190
1191 pub fn is_local_file(&self) -> bool {
1192 self.file
1193 .as_deref()
1194 .map(|u| !u.trim().is_empty())
1195 .unwrap_or(false)
1196 }
1197
1198 pub fn is_kotlin(&self) -> bool {
1199 self.kotlin
1200 .as_deref()
1201 .map(|u| !u.trim().is_empty())
1202 .unwrap_or(false)
1203 }
1204
1205 pub fn is_python(&self) -> bool {
1206 self.python
1207 .as_deref()
1208 .map(|u| !u.trim().is_empty())
1209 .unwrap_or(false)
1210 }
1211
1212 pub fn is_node(&self) -> bool {
1213 self.node
1214 .as_deref()
1215 .map(|u| !u.trim().is_empty())
1216 .unwrap_or(false)
1217 }
1218
1219 pub fn is_bash(&self) -> bool {
1220 self.bash
1221 .as_deref()
1222 .map(|u| !u.trim().is_empty())
1223 .unwrap_or(false)
1224 }
1225
1226 pub fn is_sh(&self) -> bool {
1227 self.sh
1228 .as_deref()
1229 .map(|u| !u.trim().is_empty())
1230 .unwrap_or(false)
1231 }
1232
1233 pub fn is_zsh(&self) -> bool {
1234 self.zsh
1235 .as_deref()
1236 .map(|u| !u.trim().is_empty())
1237 .unwrap_or(false)
1238 }
1239
1240 pub fn is_text(&self) -> bool {
1241 self.text
1242 .as_deref()
1243 .map(|u| !u.trim().is_empty())
1244 .unwrap_or(false)
1245 }
1246
1247 pub fn literal_text(&self) -> Option<&str> {
1249 self.text
1250 .as_deref()
1251 .map(str::trim)
1252 .filter(|s| !s.is_empty())
1253 }
1254
1255 pub fn is_language_source(&self) -> bool {
1257 self.is_kotlin()
1258 || self.is_python()
1259 || self.is_node()
1260 || self.is_bash()
1261 || self.is_sh()
1262 || self.is_zsh()
1263 }
1264
1265 pub fn kotlin_value_is_path(raw: &str) -> bool {
1267 Self::single_line_ext(raw, &[".kt", ".kts"])
1268 }
1269
1270 pub fn python_value_is_path(raw: &str) -> bool {
1271 Self::single_line_ext(raw, &[".py"])
1272 }
1273
1274 pub fn node_value_is_path(raw: &str) -> bool {
1275 Self::single_line_ext(raw, &[".js", ".mjs", ".cjs"])
1276 }
1277
1278 pub fn bash_value_is_path(raw: &str) -> bool {
1279 Self::single_line_ext(raw, &[".sh", ".bash"])
1280 }
1281
1282 pub fn sh_value_is_path(raw: &str) -> bool {
1283 Self::single_line_ext(raw, &[".sh"])
1284 }
1285
1286 pub fn zsh_value_is_path(raw: &str) -> bool {
1287 Self::single_line_ext(raw, &[".zsh", ".sh"])
1288 }
1289
1290 fn single_line_ext(raw: &str, exts: &[&str]) -> bool {
1291 let t = raw.trim();
1292 if t.is_empty() || t.lines().nth(1).is_some() {
1293 return false;
1294 }
1295 let lower = t.to_ascii_lowercase();
1296 exts.iter().any(|e| lower.ends_with(e))
1297 }
1298
1299 pub fn kotlin_is_path(&self) -> bool {
1301 self.kotlin
1302 .as_deref()
1303 .map(Self::kotlin_value_is_path)
1304 .unwrap_or(false)
1305 }
1306
1307 pub fn validate(&self, path: &str) -> Result<()> {
1308 let url = self.url.as_deref().map(str::trim).filter(|s| !s.is_empty());
1309 let file = self
1310 .file
1311 .as_deref()
1312 .map(str::trim)
1313 .filter(|s| !s.is_empty());
1314 let kotlin = self
1315 .kotlin
1316 .as_deref()
1317 .map(str::trim)
1318 .filter(|s| !s.is_empty());
1319 let python = self
1320 .python
1321 .as_deref()
1322 .map(str::trim)
1323 .filter(|s| !s.is_empty());
1324 let node = self
1325 .node
1326 .as_deref()
1327 .map(str::trim)
1328 .filter(|s| !s.is_empty());
1329 let bash = self
1330 .bash
1331 .as_deref()
1332 .map(str::trim)
1333 .filter(|s| !s.is_empty());
1334 let sh = self.sh.as_deref().map(str::trim).filter(|s| !s.is_empty());
1335 let zsh = self.zsh.as_deref().map(str::trim).filter(|s| !s.is_empty());
1336 let text = self
1337 .text
1338 .as_deref()
1339 .map(str::trim)
1340 .filter(|s| !s.is_empty());
1341 let hash = self
1342 .sha256
1343 .as_deref()
1344 .map(str::trim)
1345 .filter(|s| !s.is_empty());
1346 let exclusive = [
1347 ("url", url),
1348 ("file", file),
1349 ("kotlin", kotlin),
1350 ("python", python),
1351 ("node", node),
1352 ("bash", bash),
1353 ("sh", sh),
1354 ("zsh", zsh),
1355 ("text", text),
1356 ];
1357 let set: Vec<(&str, &str)> = exclusive
1358 .iter()
1359 .copied()
1360 .filter_map(|(n, v)| v.map(|s| (n, s)))
1361 .collect();
1362 if set.len() > 1 {
1363 bail!(
1364 "command '{path}': exec cannot combine `url`, `file`, `kotlin`, `python`, `node`, `bash`, `sh`, `zsh`, and `text`"
1365 );
1366 }
1367 const LANG: &[&str] = &["kotlin", "python", "node", "bash", "sh", "zsh"];
1368 if hash.is_some() && set.iter().any(|(n, _)| LANG.contains(n) || *n == "text") {
1369 bail!(
1370 "command '{path}': exec.sha256 is not supported with exec.kotlin / exec.python / exec.node / exec.bash / exec.sh / exec.zsh / exec.text"
1371 );
1372 }
1373 match set.first().copied() {
1374 Some(("url", _)) if hash.is_some() => Ok(()),
1375 Some(("url", _)) => {
1376 bail!("command '{path}': exec.sha256 is required with exec.url")
1377 }
1378 Some(("file", _)) => Ok(()),
1379 Some(("text", _)) => Ok(()),
1380 Some(("kotlin", k)) => {
1381 if Self::kotlin_value_is_path(k) {
1382 return Ok(());
1383 }
1384 if !k.contains("fun ") && !k.contains("fun\t") {
1385 bail!(
1386 "command '{path}': exec.kotlin inline source must contain a `fun` \
1387 (or set a single-line `.kt` / `.kts` path)"
1388 );
1389 }
1390 Ok(())
1391 }
1392 Some((label, src)) if LANG.contains(&label) => {
1393 let is_path = match label {
1394 "python" => Self::python_value_is_path(src),
1395 "node" => Self::node_value_is_path(src),
1396 "bash" => Self::bash_value_is_path(src),
1397 "sh" => Self::sh_value_is_path(src),
1398 "zsh" => Self::zsh_value_is_path(src),
1399 _ => false,
1400 };
1401 if is_path {
1402 return Ok(());
1403 }
1404 if src.len() < 2 {
1405 bail!("command '{path}': exec.{label} inline source is empty");
1406 }
1407 Ok(())
1408 }
1409 None if hash.is_some() => {
1410 bail!("command '{path}': exec.sha256 requires exec.url or exec.file")
1411 }
1412 None => {
1413 if self.argv.is_empty() {
1414 bail!(
1415 "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)"
1416 );
1417 }
1418 Ok(())
1419 }
1420 _ => unreachable!("modes > 1 checked above"),
1421 }
1422 }
1423}
1424
1425impl CommandNode {
1426 pub fn is_leaf_exec(&self) -> bool {
1427 self.exec.is_some()
1428 }
1429
1430 pub fn validate(&self, path: &str) -> Result<()> {
1431 if self.exec.is_some() && !self.commands.is_empty() {
1432 bail!("command '{path}' cannot define both `exec` and nested `commands`");
1433 }
1434 if let Some(ref e) = self.exec {
1435 e.validate(path)?;
1436 }
1437 self.aliases.validate(path)?;
1438 if !self.aliases.names.is_empty() {
1439 let is_jan_target = self
1440 .commands
1441 .get("run")
1442 .map(|r| r.exec.is_some())
1443 .unwrap_or(false)
1444 || (self.exec.is_some() && self.commands.is_empty());
1445 if !is_jan_target {
1446 bail!(
1447 "command '{path}': `aliases` names (not map RHS) require this node to be a jan alias target (`run` with exec, or a leaf `exec`)"
1448 );
1449 }
1450 }
1451 self.env.validate(path)?;
1452 self.packages.validate(path)?;
1453 for (name, t) in &self.tests {
1454 t.validate(path, name)?;
1455 }
1456 for (name, def) in &self.inputs {
1457 def.validate(name)
1458 .map_err(|e| anyhow::anyhow!("command '{path}': {e}"))?;
1459 }
1460 for (name, child) in &self.commands {
1461 let p = if path.is_empty() {
1462 name.clone()
1463 } else {
1464 format!("{path} {name}")
1465 };
1466 child.validate(&p)?;
1467 }
1468 Ok(())
1469 }
1470}
1471
1472pub fn merge_specs_into(base: &mut RootSpec, overlay: RootSpec) -> Result<()> {
1475 for (name, node) in overlay.commands {
1476 match base.commands.get_mut(&name) {
1477 Some(existing) => merge_command_node(existing, node)?,
1478 None => {
1479 base.commands.insert(name, node);
1480 }
1481 }
1482 }
1483 Ok(())
1484}
1485
1486fn merge_command_node(dst: &mut CommandNode, src: CommandNode) -> Result<()> {
1487 if src.exec.is_some() && !src.commands.is_empty() {
1488 bail!("merge overlay: command cannot define both `exec` and nested `commands`");
1489 }
1490 if !src.os.is_empty() {
1491 dst.os = src.os;
1492 }
1493 if !src.about.trim().is_empty() {
1494 dst.about = src.about;
1495 }
1496 if src.path.is_some() {
1497 dst.path = src.path;
1498 }
1499 if !src.dependencies.is_empty() {
1500 dst.dependencies = src.dependencies;
1501 }
1502 if !src.requires.is_empty() {
1503 dst.requires = src.requires;
1504 }
1505 if !src.cron.is_empty() {
1506 dst.cron = src.cron;
1507 }
1508 if !src.env.is_empty() {
1509 dst.env.merge_from(src.env);
1510 }
1511 for (k, v) in src.inputs {
1512 dst.inputs.insert(k, v);
1513 }
1514 for (k, v) in src.tests {
1515 dst.tests.insert(k, v);
1516 }
1517 dst.aliases.merge_from(src.aliases);
1518 if let Some(exec) = src.exec {
1519 dst.exec = Some(exec);
1520 dst.commands.clear();
1521 return Ok(());
1522 }
1523 if !src.commands.is_empty() {
1524 dst.exec = None;
1525 for (k, child) in src.commands {
1526 match dst.commands.get_mut(&k) {
1527 Some(existing) => merge_command_node(existing, child)?,
1528 None => {
1529 dst.commands.insert(k, child);
1530 }
1531 }
1532 }
1533 }
1534 Ok(())
1535}
1536
1537pub fn validate_spec(spec: &RootSpec) -> Result<()> {
1539 for (name, node) in &spec.commands {
1540 node.validate(name)?;
1541 }
1542 Ok(())
1543}
1544
1545pub fn load_spec_from_str(raw: &str, include_base: Option<&Path>) -> Result<RootSpec> {
1547 spec_load::load_spec_from_str(raw, include_base, HostPlatform::detect())
1548}
1549
1550pub fn load_spec(path: &Path) -> Result<RootSpec> {
1551 spec_load::load_spec_from_path(path, HostPlatform::detect())
1552}
1553
1554pub fn resolve_git_branch(cwd: &Path, override_branch: Option<&str>) -> String {
1555 if let Some(b) = override_branch {
1556 if !b.is_empty() {
1557 return b.to_string();
1558 }
1559 }
1560 if let Ok(v) = std::env::var("JAN_BRANCH") {
1561 if !v.is_empty() {
1562 return v;
1563 }
1564 }
1565 let output = Command::new("git")
1566 .args(["rev-parse", "--abbrev-ref", "HEAD"])
1567 .current_dir(cwd)
1568 .output();
1569 match output {
1570 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
1571 _ => "(no-git)".to_string(),
1572 }
1573}
1574
1575fn first_line(s: &str) -> String {
1576 s.lines().next().unwrap_or("").trim().to_string()
1577}
1578
1579fn is_help_leaf(name: &str, child: &CommandNode) -> bool {
1581 name == "help" && child.exec.is_some() && child.commands.is_empty()
1582}
1583
1584fn is_alias_bucket(child: &CommandNode) -> bool {
1587 !child.aliases.is_empty() && child.exec.is_none() && child.commands.is_empty()
1588}
1589
1590fn jan_invocation_for_node(bin: &str, chain: &[String], node: &CommandNode) -> String {
1591 let mut parts: Vec<String> = std::iter::once(bin.to_string())
1592 .chain(chain.iter().cloned())
1593 .collect();
1594 if node
1595 .commands
1596 .get("run")
1597 .map(|r| r.exec.is_some())
1598 .unwrap_or(false)
1599 {
1600 parts.push("run".into());
1601 }
1602 parts.join(" ")
1603}
1604
1605fn help_alias_lines(
1606 bin: &str,
1607 chain: &[String],
1608 node: &CommandNode,
1609) -> Vec<(String, String)> {
1610 let mut lines = BTreeMap::new();
1611 let target = jan_invocation_for_node(bin, chain, node);
1612 for name in &node.aliases.names {
1613 lines.insert(name.clone(), format!("same as `{target}`"));
1614 }
1615 for (name, rhs) in &node.aliases.shell {
1616 lines.insert(name.clone(), rhs.clone());
1617 }
1618 for child in node.commands.values() {
1619 if !is_alias_bucket(child) {
1620 continue;
1621 }
1622 for name in &child.aliases.names {
1623 lines
1624 .entry(name.clone())
1625 .or_insert_with(|| format!("same as `{target}`"));
1626 }
1627 for (name, rhs) in &child.aliases.shell {
1628 lines.entry(name.clone()).or_insert_with(|| rhs.clone());
1629 }
1630 }
1631 lines.into_iter().collect()
1632}
1633
1634fn append_help_aliases(
1635 out: &mut String,
1636 bin: &str,
1637 chain: &[String],
1638 node: Option<&CommandNode>,
1639) {
1640 let Some(n) = node else {
1641 return;
1642 };
1643 let lines = help_alias_lines(bin, chain, n);
1644 if lines.is_empty() {
1645 return;
1646 }
1647 out.push('\n');
1648 out.push_str("Aliases (`jan alias`):\n");
1649 for (name, rhs) in lines {
1650 out.push_str(&format!(" {name} — {}\n", first_line(&rhs)));
1651 }
1652}
1653
1654fn node_at_chain<'a>(spec: &'a RootSpec, chain: &[String]) -> Option<&'a CommandNode> {
1655 let mut map = &spec.commands;
1656 let mut node = None;
1657 for seg in chain {
1658 let next = map.get(seg)?;
1659 node = Some(next);
1660 map = &next.commands;
1661 }
1662 node
1663}
1664
1665fn command_help_text(
1668 spec: &RootSpec,
1669 chain: &[String],
1670 node: Option<&CommandNode>,
1671) -> Option<String> {
1672 let n = node?;
1673 if let Some(t) = n.exec.as_ref().and_then(ExecSpec::literal_text) {
1674 return Some(t.to_string());
1675 }
1676 if let Some(t) = n
1677 .commands
1678 .get("help")
1679 .and_then(|h| h.exec.as_ref())
1680 .and_then(ExecSpec::literal_text)
1681 {
1682 return Some(t.to_string());
1683 }
1684 if chain.last().map(String::as_str) == Some("run") && chain.len() >= 2 {
1685 let parent = node_at_chain(spec, &chain[..chain.len() - 1])?;
1686 return parent
1687 .commands
1688 .get("help")
1689 .and_then(|h| h.exec.as_ref())
1690 .and_then(ExecSpec::literal_text)
1691 .map(str::to_string);
1692 }
1693 None
1694}
1695
1696pub fn format_help(spec: &RootSpec, chain: &[String], node: Option<&CommandNode>) -> String {
1697 let mut out = String::new();
1698 let bin = spec
1699 .metadata
1700 .as_ref()
1701 .and_then(|m| m.name.as_deref())
1702 .unwrap_or("jan");
1703 let full_cmd = if chain.is_empty() {
1704 bin.to_string()
1705 } else {
1706 format!("{} {}", bin, chain.join(" "))
1707 };
1708
1709 let (about, children, exec) = match node {
1710 Some(n) => (n.about.as_str(), &n.commands, n.exec.as_ref()),
1711 None => ("", &spec.commands, None),
1712 };
1713
1714 if chain.is_empty() {
1715 if let Some(meta) = &spec.metadata {
1716 if let Some(desc) = &meta.description {
1717 out.push_str(desc.trim());
1718 out.push_str("\n\n");
1719 }
1720 }
1721 }
1722
1723 let help_doc = command_help_text(spec, chain, node);
1724 if let Some(doc) = &help_doc {
1725 out.push_str(doc);
1726 out.push_str("\n\n");
1727 } else if !about.is_empty() {
1728 out.push_str(about.trim());
1729 out.push_str("\n\n");
1730 }
1731
1732 let listed: Vec<(&String, &CommandNode)> = children
1733 .iter()
1734 .filter(|(name, child)| !is_help_leaf(name, child) && !is_alias_bucket(child))
1735 .collect();
1736 let has_aliases = node.map(|n| !help_alias_lines(bin, chain, n).is_empty()).unwrap_or(false);
1737
1738 if exec.is_some() && children.is_empty() {
1739 if help_doc.is_none() {
1740 out.push_str("This command runs an external program (see spec `exec.argv`).\n");
1741 }
1742 append_help_aliases(&mut out, bin, chain, node);
1743 append_help_inputs_and_tests(&mut out, spec, chain, node);
1744 return out;
1745 }
1746
1747 if !listed.is_empty() {
1748 out.push_str("Subcommands:\n");
1749 for (name, child) in &listed {
1750 let line = if child.about.is_empty() {
1751 format!(" {name}\n")
1752 } else {
1753 format!(" {name} — {}\n", first_line(&child.about))
1754 };
1755 out.push_str(&line);
1756 }
1757 out.push('\n');
1758 if listed.iter().any(|(n, _)| n.as_str() != "run") {
1759 out.push_str(&format!(
1760 "Use `{} --help` for more about a subcommand.\n",
1761 full_cmd
1762 ));
1763 }
1764 append_help_aliases(&mut out, bin, chain, node);
1765 append_help_inputs_and_tests(&mut out, spec, chain, node);
1766 } else if exec.is_none() && help_doc.is_none() && !has_aliases {
1767 out.push_str("(No subcommands defined.)\n");
1768 append_help_aliases(&mut out, bin, chain, node);
1769 append_help_inputs_and_tests(&mut out, spec, chain, node);
1770 } else {
1771 append_help_aliases(&mut out, bin, chain, node);
1772 append_help_inputs_and_tests(&mut out, spec, chain, node);
1773 }
1774 if chain.is_empty() && node.is_none() {
1775 out.push_str(
1776 "\nPlace `--help` or `-h` right after the subcommand prefix you want. Built-ins: `use`, `bundle`, `alias`, `list`, `search`, `show`, `validate`, `audit`, `cron`, `test`.\n",
1777 );
1778 }
1779 out
1780}
1781
1782fn append_help_inputs_and_tests(
1783 out: &mut String,
1784 spec: &RootSpec,
1785 chain: &[String],
1786 node: Option<&CommandNode>,
1787) {
1788 let defs = inputs::collect_chain_inputs(chain, spec);
1789 if !defs.is_empty() {
1790 out.push('\n');
1791 out.push_str(&inputs::format_inputs_help(&defs));
1792 }
1793 let n = match node {
1794 Some(n) => cmdtest::count_tests(n),
1795 None => spec.commands.values().map(cmdtest::count_tests).sum(),
1796 };
1797 if n > 0 {
1798 let hint = if chain.is_empty() {
1799 "jan test".to_string()
1800 } else {
1801 format!("jan test {}", chain.join(" "))
1802 };
1803 out.push_str(&format!("\n{n} test(s) — run with `{hint}`.\n"));
1804 }
1805}
1806
1807#[derive(Debug, Clone)]
1809pub struct SpecRootIdentity {
1810 pub spec_dir: String,
1812 pub root_yaml: String,
1814}
1815
1816pub fn resolve_preferred_spec() -> Result<(PathBuf, SpecRootIdentity)> {
1818 let cfg = config::load_user_config().context("load user config")?;
1819 let Some(dir_s) = cfg
1820 .jan_dir
1821 .as_ref()
1822 .map(|s| s.trim())
1823 .filter(|s| !s.is_empty())
1824 else {
1825 bail!(
1826 "no preferred jan directory configured\n\
1827 Run `jan use <DIR>` to save a YAML command tree (see docs/PORTABLE_SCRIPTS.md)"
1828 );
1829 };
1830 let dir = PathBuf::from(dir_s);
1831 if !dir.is_dir() {
1832 bail!(
1833 "preferred jan directory does not exist: {}\n\
1834 Fix the path or run `jan use <DIR>` again (config: {})",
1835 dir.display(),
1836 config::config_path().display()
1837 );
1838 }
1839 let root = cfg
1840 .spec_root
1841 .as_deref()
1842 .map(str::trim)
1843 .filter(|s| !s.is_empty())
1844 .unwrap_or("scripts.spec.yaml");
1845 resolve_spec_dir_entry(&dir, root)
1846}
1847
1848pub fn resolve_spec_dir_entry(
1850 spec_dir: &Path,
1851 root_yaml: &str,
1852) -> Result<(PathBuf, SpecRootIdentity)> {
1853 let rel = Path::new(root_yaml);
1854 if rel.is_absolute() {
1855 bail!("entry YAML must be a relative file name, not an absolute path");
1856 }
1857 if rel
1858 .components()
1859 .any(|c| matches!(c, std::path::Component::ParentDir))
1860 {
1861 bail!("entry YAML must not contain `..`");
1862 }
1863 let normal_only = rel
1864 .components()
1865 .all(|c| matches!(c, std::path::Component::Normal(_)));
1866 let n = rel
1867 .components()
1868 .filter(|c| matches!(c, std::path::Component::Normal(_)))
1869 .count();
1870 if !normal_only || n != 1 {
1871 bail!("entry YAML must be a single file name inside the jan directory");
1872 }
1873 let dir = spec_dir
1874 .canonicalize()
1875 .with_context(|| format!("canonicalize jan directory {}", spec_dir.display()))?;
1876 if !dir.is_dir() {
1877 bail!("not a directory: {}", dir.display());
1878 }
1879 let spec_path = dir.join(rel);
1880 if !spec_path.is_file() {
1881 bail!(
1882 "spec entry not found: {} (under {})\n\
1883 Expected a properly formatted jan directory (e.g. scripts.spec.yaml)",
1884 spec_path.display(),
1885 dir.display()
1886 );
1887 }
1888 let identity = SpecRootIdentity {
1889 spec_dir: dir.to_string_lossy().into_owned(),
1890 root_yaml: rel
1891 .file_name()
1892 .expect("relative root has file_name")
1893 .to_string_lossy()
1894 .into_owned(),
1895 };
1896 Ok((spec_path, identity))
1897}
1898
1899pub struct RunContext<'a> {
1900 pub cwd: &'a Path,
1901 pub db_path: Option<&'a Path>,
1902 pub branch: String,
1903 pub no_log: bool,
1904 pub spec_root: &'a SpecRootIdentity,
1905}
1906
1907fn shell_inline_c_needs_argv0(argv: &[String]) -> bool {
1914 if argv.len() != 3 {
1915 return false;
1916 }
1917 let prog = Path::new(&argv[0])
1918 .file_name()
1919 .and_then(|s| s.to_str())
1920 .unwrap_or(argv[0].as_str());
1921 let is_shell = matches!(
1922 prog,
1923 "bash" | "zsh" | "sh" | "dash" | "ksh" | "ash" | "busybox"
1924 );
1925 is_shell && matches!(argv[1].as_str(), "-c" | "-lc")
1926}
1927
1928fn shell_passthrough_argv0(chain: &[String]) -> String {
1929 chain
1930 .iter()
1931 .rev()
1932 .find(|s| s.as_str() != "run")
1933 .cloned()
1934 .or_else(|| chain.last().cloned())
1935 .unwrap_or_else(|| "jan".to_string())
1936}
1937
1938pub fn run_matched(
1939 spec: &RootSpec,
1940 chain: &[String],
1941 node: &CommandNode,
1942 trailing: &[OsString],
1943 ctx: &RunContext<'_>,
1944) -> Result<i32> {
1945 let exec = match &node.exec {
1946 Some(e) => e,
1947 None => {
1948 let help = format_help(spec, chain, Some(node));
1949 print!("{help}");
1950 bail!("missing subcommand");
1951 }
1952 };
1953 exec.validate(&chain.join(" "))?;
1954
1955 if exec.is_text() {
1956 let body = exec.literal_text().unwrap_or("");
1957 println!("{body}");
1958 return Ok(0);
1959 }
1960
1961 let input_defs = inputs::collect_chain_inputs(chain, spec);
1962 let (input_vals, rest) = inputs::resolve_inputs(&input_defs, trailing, Some(ctx.cwd))?;
1963
1964 let mut argv: Vec<String> = Vec::with_capacity(exec.argv.len() + 1);
1965 for a in &exec.argv {
1966 argv.push(inputs::interpolate(a, &input_vals)?);
1967 }
1968
1969 if exec.is_remote() {
1970 let url = exec.url.as_deref().unwrap().trim();
1971 let hash = exec.sha256.as_deref().unwrap().trim();
1972 let mut opts = remote::FetchOpts::new();
1973 if let Some(ttl) = exec.ttl {
1974 opts = opts.with_ttl(ttl);
1975 }
1976 let cached = remote::fetch_verified(url, hash, &opts, true)?;
1977 argv.push(cached.to_string_lossy().into_owned());
1978 } else if exec.is_local_file() {
1979 let rel = exec.file.as_deref().unwrap().trim();
1980 let use_root = Path::new(&ctx.spec_root.spec_dir);
1981 let resolved = spec_load::resolve_under_use_root(use_root, rel)?;
1982 if let Some(hash) = exec
1983 .sha256
1984 .as_deref()
1985 .map(str::trim)
1986 .filter(|s| !s.is_empty())
1987 {
1988 remote::verify_file_sha256(&resolved, hash)
1989 .with_context(|| format!("verify exec.file `{rel}`"))?;
1990 }
1991 argv.push(resolved.to_string_lossy().into_owned());
1992 } else if exec.is_language_source() {
1993 } else if argv.is_empty() {
1995 bail!("exec.argv must not be empty");
1996 }
1997
1998 if exec.passthrough {
1999 let mut rest = rest;
2000 if rest.first().is_some_and(|a| a == "--") {
2004 rest = rest[1..].to_vec();
2005 }
2006 if shell_inline_c_needs_argv0(&argv) {
2007 argv.push(shell_passthrough_argv0(chain));
2008 }
2009 for a in &rest {
2010 argv.push(a.to_string_lossy().into_owned());
2011 }
2012 } else if !rest.is_empty() {
2013 let preview = rest
2014 .iter()
2015 .take(3)
2016 .map(|s| s.to_string_lossy().into_owned())
2017 .collect::<Vec<_>>()
2018 .join(" ");
2019 bail!(
2020 "unexpected trailing arguments: {preview}{}",
2021 if rest.len() > 3 { "…" } else { "" }
2022 );
2023 }
2024
2025 let cmd_path = if chain.is_empty() {
2026 "(root)".to_string()
2027 } else {
2028 chain.join(" ")
2029 };
2030
2031 let (_, requires, _) = deps::collect_chain_metadata(chain, spec);
2032 deps::check_requires(&requires)?;
2033
2034 let pkgs = packages::collect_chain_packages(chain, spec);
2035 let pkg_envs = packages::ensure_packages(&pkgs, ctx)?;
2036
2037 if exec.is_kotlin() {
2038 let rel = exec.kotlin.as_deref().unwrap().trim();
2039 let use_root = Path::new(&ctx.spec_root.spec_dir);
2040 let main_args = std::mem::take(&mut argv);
2041 argv = packages::prepare_kotlin_argv(use_root, rel, &pkg_envs, &main_args)?;
2042 } else if exec.is_python() {
2043 let src = exec.python.as_deref().unwrap().trim();
2044 let use_root = Path::new(&ctx.spec_root.spec_dir);
2045 let main_args = std::mem::take(&mut argv);
2046 argv = packages::prepare_python_argv(use_root, src, &main_args)?;
2047 } else if exec.is_node() {
2048 let src = exec.node.as_deref().unwrap().trim();
2049 let use_root = Path::new(&ctx.spec_root.spec_dir);
2050 let main_args = std::mem::take(&mut argv);
2051 argv = packages::prepare_node_argv(use_root, src, &main_args)?;
2052 } else if exec.is_bash() || exec.is_sh() || exec.is_zsh() {
2053 let (kind, src) = if exec.is_bash() {
2054 (
2055 packages::ShellKind::Bash,
2056 exec.bash.as_deref().unwrap().trim(),
2057 )
2058 } else if exec.is_zsh() {
2059 (
2060 packages::ShellKind::Zsh,
2061 exec.zsh.as_deref().unwrap().trim(),
2062 )
2063 } else {
2064 (packages::ShellKind::Sh, exec.sh.as_deref().unwrap().trim())
2065 };
2066 let use_root = Path::new(&ctx.spec_root.spec_dir);
2067 let main_args = std::mem::take(&mut argv);
2068 let argv0 = shell_passthrough_argv0(chain);
2069 argv = packages::prepare_shell_argv(kind, use_root, src, &argv0, &main_args)?;
2070 } else {
2071 packages::inject_jvm_classpath(&mut argv, &pkg_envs);
2072 }
2073
2074 let path_dirs = deps::resolve_path_prefixes(spec, chain, ctx)?;
2075 let program = packages::resolve_program_with_envs(&argv[0], &pkg_envs, &path_dirs)?;
2076 let mut env_spec = deps::collect_chain_env(chain, spec);
2077 for value in env_spec.public.values_mut() {
2078 *value = inputs::interpolate(value, &input_vals)?;
2079 }
2080 deps::check_private_env(&env_spec.private)?;
2081 let mut path_override = if !path_dirs.is_empty() {
2082 Some(deps::prepend_path_env(&path_dirs)?)
2083 } else {
2084 None
2085 };
2086 if !pkg_envs.is_empty() {
2087 path_override = Some(packages::prepend_env_paths(&pkg_envs, path_override)?);
2088 }
2089 if let Some(node_path) = packages::node_path_for(&pkg_envs) {
2090 env_spec
2091 .public
2092 .entry("NODE_PATH".to_string())
2093 .or_insert(node_path);
2094 }
2095 if let Some(classpath) = packages::classpath_for(&pkg_envs) {
2096 env_spec
2097 .public
2098 .entry("CLASSPATH".to_string())
2099 .or_insert(classpath);
2100 }
2101
2102 let mut c = Command::new(&program);
2103 if argv.len() > 1 {
2104 c.args(&argv[1..]);
2105 }
2106 c.current_dir(ctx.cwd);
2107 deps::apply_process_env(&mut c, &env_spec, path_override)?;
2108
2109 let status = c.status().with_context(|| format!("spawn `{}`", argv[0]))?;
2110 let code = status.code().unwrap_or(255);
2111
2112 if !ctx.no_log {
2113 if let Some(db) = ctx.db_path {
2114 log_invocation(
2115 db,
2116 &ctx.branch,
2117 ctx.cwd,
2118 &cmd_path,
2119 &argv,
2120 code,
2121 ctx.spec_root,
2122 )?;
2123 }
2124 }
2125
2126 Ok(code)
2127}
2128
2129fn ensure_invocations_spec_root_column(conn: &Connection) -> Result<()> {
2130 let mut stmt = conn.prepare("PRAGMA table_info(invocations)")?;
2131 let cols: Vec<String> = stmt
2132 .query_map([], |row| row.get::<_, String>(1))?
2133 .collect::<std::result::Result<_, _>>()?;
2134 if !cols.iter().any(|c| c == "spec_root_id") {
2135 conn.execute(
2136 "ALTER TABLE invocations ADD COLUMN spec_root_id INTEGER",
2137 [],
2138 )?;
2139 }
2140 Ok(())
2141}
2142
2143fn upsert_spec_root(conn: &Connection, spec: &SpecRootIdentity) -> Result<i64> {
2144 let ts = unix_ts();
2145 conn.execute(
2146 r"INSERT INTO spec_roots (spec_dir, root_yaml, last_used_ts) VALUES (?1, ?2, ?3)
2147 ON CONFLICT(spec_dir, root_yaml) DO UPDATE SET last_used_ts = excluded.last_used_ts",
2148 rusqlite::params![&spec.spec_dir, &spec.root_yaml, &ts],
2149 )?;
2150 let id: i64 = conn.query_row(
2151 "SELECT id FROM spec_roots WHERE spec_dir = ?1 AND root_yaml = ?2",
2152 [&spec.spec_dir, &spec.root_yaml],
2153 |r| r.get(0),
2154 )?;
2155 Ok(id)
2156}
2157
2158fn log_invocation(
2159 db_path: &Path,
2160 branch: &str,
2161 cwd: &Path,
2162 command_path: &str,
2163 argv: &[String],
2164 exit_code: i32,
2165 spec_root: &SpecRootIdentity,
2166) -> Result<()> {
2167 if let Some(parent) = db_path.parent() {
2168 std::fs::create_dir_all(parent).ok();
2169 }
2170 let conn = Connection::open(db_path).with_context(|| format!("open {}", db_path.display()))?;
2171 conn.execute_batch(
2172 r"
2173 CREATE TABLE IF NOT EXISTS spec_roots (
2174 id INTEGER PRIMARY KEY AUTOINCREMENT,
2175 spec_dir TEXT NOT NULL,
2176 root_yaml TEXT NOT NULL,
2177 last_used_ts TEXT NOT NULL,
2178 UNIQUE(spec_dir, root_yaml)
2179 );
2180 CREATE TABLE IF NOT EXISTS invocations (
2181 id INTEGER PRIMARY KEY AUTOINCREMENT,
2182 ts TEXT NOT NULL,
2183 git_branch TEXT NOT NULL,
2184 cwd TEXT NOT NULL,
2185 command_path TEXT NOT NULL,
2186 argv_json TEXT NOT NULL,
2187 exit_code INTEGER NOT NULL,
2188 spec_root_id INTEGER
2189 );
2190 ",
2191 )?;
2192 ensure_invocations_spec_root_column(&conn)?;
2193 let spec_root_id = upsert_spec_root(&conn, spec_root)?;
2194 let ts = unix_ts();
2195 let argv_json = serde_json::to_string(argv).unwrap_or_else(|_| "[]".to_string());
2196 let cwd_s = cwd.to_string_lossy();
2197 conn.execute(
2198 "INSERT INTO invocations (ts, git_branch, cwd, command_path, argv_json, exit_code, spec_root_id)
2199 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
2200 rusqlite::params![
2201 ts,
2202 branch,
2203 cwd_s.as_ref(),
2204 command_path,
2205 argv_json,
2206 exit_code,
2207 spec_root_id
2208 ],
2209 )?;
2210 Ok(())
2211}
2212
2213fn unix_ts() -> String {
2214 use std::time::SystemTime;
2215 SystemTime::now()
2216 .duration_since(std::time::UNIX_EPOCH)
2217 .unwrap_or_default()
2218 .as_secs()
2219 .to_string()
2220}
2221
2222#[derive(Debug)]
2223pub struct MatchOutcome<'a> {
2224 pub chain: Vec<String>,
2225 pub node: Option<&'a CommandNode>,
2226 pub trailing: Vec<OsString>,
2227 pub wants_help: bool,
2228}
2229
2230pub fn match_commands<'a>(spec: &'a RootSpec, args: &[OsString]) -> MatchOutcome<'a> {
2231 let mut chain = Vec::new();
2232 let mut node: Option<&'a CommandNode> = None;
2233 let mut map: &BTreeMap<String, CommandNode> = &spec.commands;
2234 let mut i = 0usize;
2235 let len = args.len();
2236 while i < len {
2237 let raw = &args[i];
2238 if raw == "--help" || raw == "-h" {
2239 return MatchOutcome {
2240 chain,
2241 node,
2242 trailing: args[i + 1..].to_vec(),
2243 wants_help: true,
2244 };
2245 }
2246 let key = raw.to_string_lossy();
2247 if let Some(next) = map.get(key.as_ref()) {
2248 chain.push(key.into_owned());
2249 node = Some(next);
2250 map = &next.commands;
2251 i += 1;
2252 continue;
2253 }
2254 break;
2255 }
2256 MatchOutcome {
2257 chain,
2258 node,
2259 trailing: args[i..].to_vec(),
2260 wants_help: false,
2261 }
2262}
2263
2264#[cfg(test)]
2265mod tests {
2266 use super::*;
2267 use std::io::Write;
2268
2269 #[test]
2270 fn examples_default_spec_validates() {
2271 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/default.spec.yaml");
2272 load_spec(&path).unwrap();
2273 }
2274
2275 #[test]
2276 fn merge_specs_adds_and_replaces_leaves() {
2277 let mut base = load_spec_from_str(
2278 r"
2279commands:
2280 a:
2281 about: base
2282 commands:
2283 x:
2284 about: old
2285 exec:
2286 argv: [echo, old]
2287",
2288 None,
2289 )
2290 .unwrap();
2291 let overlay = load_spec_from_str(
2292 r"
2293commands:
2294 a:
2295 commands:
2296 x:
2297 about: new leaf
2298 exec:
2299 argv: [echo, new]
2300 b:
2301 about: added top
2302 exec:
2303 argv: [echo, b]
2304",
2305 None,
2306 )
2307 .unwrap();
2308 merge_specs_into(&mut base, overlay).unwrap();
2309 base.commands["a"].commands["x"].validate("a x").unwrap();
2310 assert_eq!(
2311 base.commands["a"].commands["x"].exec.as_ref().unwrap().argv,
2312 vec!["echo", "new"]
2313 );
2314 assert_eq!(
2315 base.commands["b"].exec.as_ref().unwrap().argv,
2316 vec!["echo", "b"]
2317 );
2318 }
2319
2320 #[test]
2321 fn validate_rejects_exec_with_children() {
2322 let mut tmp = tempfile::NamedTempFile::with_suffix(".yaml").unwrap();
2323 write!(
2324 tmp,
2325 r"
2326commands:
2327 x:
2328 exec:
2329 argv: [echo]
2330 commands:
2331 child:
2332 about: nested
2333"
2334 )
2335 .unwrap();
2336 let err = load_spec(tmp.path()).unwrap_err();
2337 assert!(err.to_string().contains("cannot define both"));
2338 }
2339
2340 #[test]
2341 fn shell_inline_c_needs_argv0_detects_bash_lc() {
2342 let argv = vec![
2343 "bash".into(),
2344 "-lc".into(),
2345 "case \"$1\" in create) ;; esac".into(),
2346 ];
2347 assert!(shell_inline_c_needs_argv0(&argv));
2348 let with_placeholder = vec!["zsh".into(), "-c".into(), "echo".into(), "issue".into()];
2349 assert!(!shell_inline_c_needs_argv0(&with_placeholder));
2350 assert!(!shell_inline_c_needs_argv0(&[
2351 "echo".into(),
2352 "start".into()
2353 ]));
2354 assert!(!shell_inline_c_needs_argv0(&[
2355 "python3".into(),
2356 "-c".into(),
2357 "print(1)".into()
2358 ]));
2359 }
2360
2361 #[test]
2362 fn shell_passthrough_argv0_skips_run_leaf() {
2363 assert_eq!(
2364 shell_passthrough_argv0(&[
2365 "scripts".into(),
2366 "misc".into(),
2367 "issue".into(),
2368 "run".into()
2369 ]),
2370 "issue"
2371 );
2372 assert_eq!(shell_passthrough_argv0(&["probe".into()]), "probe");
2373 }
2374
2375 #[test]
2376 fn language_exec_path_vs_inline_detection() {
2377 assert!(ExecSpec::python_value_is_path("scripts/x.py"));
2378 assert!(ExecSpec::python_value_is_path("X.PY"));
2379 assert!(!ExecSpec::python_value_is_path("print(1)\n"));
2380 assert!(!ExecSpec::python_value_is_path("import sys"));
2381 assert!(ExecSpec::node_value_is_path("a.js"));
2382 assert!(ExecSpec::node_value_is_path("a.mjs"));
2383 assert!(ExecSpec::node_value_is_path("a.cjs"));
2384 assert!(!ExecSpec::node_value_is_path("console.log(1)"));
2385 assert!(!ExecSpec::node_value_is_path("x.ts"));
2386 assert!(ExecSpec::bash_value_is_path("x.sh"));
2387 assert!(ExecSpec::bash_value_is_path("x.bash"));
2388 assert!(!ExecSpec::bash_value_is_path("echo hi"));
2389 assert!(ExecSpec::sh_value_is_path("x.sh"));
2390 assert!(ExecSpec::zsh_value_is_path("x.zsh"));
2391 let bash = ExecSpec {
2392 bash: Some("echo hi".into()),
2393 ..Default::default()
2394 };
2395 bash.validate("t").unwrap();
2396
2397 let python = ExecSpec {
2398 python: Some("print(1)".into()),
2399 ..Default::default()
2400 };
2401 python.validate("t").unwrap();
2402 let node = ExecSpec {
2403 node: Some("console.log(1)".into()),
2404 ..Default::default()
2405 };
2406 node.validate("t").unwrap();
2407 let both = ExecSpec {
2408 python: Some("x.py".into()),
2409 node: Some("x.js".into()),
2410 ..Default::default()
2411 };
2412 assert!(both.validate("t").is_err());
2413 let text = ExecSpec {
2414 text: Some("hello docs\n".into()),
2415 ..Default::default()
2416 };
2417 text.validate("t").unwrap();
2418 let cat: ExecSpec = serde_yaml::from_str("cat: |\n printed as-is\n").unwrap();
2419 assert_eq!(cat.literal_text(), Some("printed as-is"));
2420 }
2421
2422 #[test]
2423 fn format_help_inlines_help_child_and_hides_help_leaf() {
2424 let spec = load_spec_from_str(
2425 r#"
2426commands:
2427 backup:
2428 about: Backup a path
2429 inputs:
2430 path:
2431 required: true
2432 type: path
2433 commands:
2434 help:
2435 about: Describe this script.
2436 exec:
2437 text: |
2438 backup — copy files
2439 Example: jan backup run --path /data
2440 run:
2441 about: Run the backup
2442 exec:
2443 argv: [echo, ok]
2444"#,
2445 None,
2446 )
2447 .unwrap();
2448 let node = &spec.commands["backup"];
2449 let help = format_help(&spec, &["backup".into()], Some(node));
2450 assert!(help.contains("backup — copy files"));
2451 assert!(help.contains("jan backup run --path /data"));
2452 assert!(help.contains(" run — Run the backup"));
2453 assert!(!help.contains(" help —"));
2454 assert!(help.contains("--path"));
2455 let run = &node.commands["run"];
2456 let run_help = format_help(&spec, &["backup".into(), "run".into()], Some(run));
2457 assert!(run_help.contains("backup — copy files"));
2458 assert!(run_help.contains("--path"));
2459 }
2460
2461 #[test]
2462 fn format_help_lists_node_aliases_and_hides_alias_buckets() {
2463 let spec = load_spec_from_str(
2464 r#"
2465metadata:
2466 name: jan
2467commands:
2468 android:
2469 about: android utilities
2470 aliases:
2471 adbt: adb-triage
2472 android-reboot: adb reboot
2473 commands:
2474 dump:
2475 about: Dump device state
2476 exec:
2477 argv: [echo, ok]
2478 linux-shell:
2479 aliases:
2480 tulpn: netstat -tulpn
2481 last_branch:
2482 aliases: [lb]
2483 commands:
2484 run:
2485 exec:
2486 argv: [echo, branches]
2487"#,
2488 None,
2489 )
2490 .unwrap();
2491 let android = &spec.commands["android"];
2492 let help = format_help(&spec, &["android".into()], Some(android));
2493 assert!(help.contains("Aliases (`jan alias`):"), "{help}");
2494 assert!(help.contains(" adbt — adb-triage"), "{help}");
2495 assert!(help.contains(" android-reboot — adb reboot"), "{help}");
2496 assert!(help.contains(" dump — Dump device state"), "{help}");
2497 assert!(help.contains(" tulpn — netstat -tulpn"), "{help}");
2498 assert!(
2499 !help.contains(" linux-shell"),
2500 "alias-only children should not be listed as subcommands: {help}"
2501 );
2502
2503 let last = &spec.commands["last_branch"];
2504 let last_help = format_help(&spec, &["last_branch".into()], Some(last));
2505 assert!(
2506 last_help.contains(" lb — same as `jan last_branch run`"),
2507 "{last_help}"
2508 );
2509 }
2510
2511 #[test]
2512 fn gherkin_test_names() {
2513 assert!(gherkin_test_name(
2514 "given_a_csv_when_summarized_then_prints_shape"
2515 ));
2516 assert!(gherkin_test_name(
2517 "given a file when basename then prints name"
2518 ));
2519 assert!(gherkin_test_name(
2520 "given-a-name-when-run-then-mentions-birthday"
2521 ));
2522 assert!(!gherkin_test_name("prints_hello"));
2523 assert!(!gherkin_test_name("given_when_then"));
2524 assert!(!gherkin_test_name("given_x_when_y"));
2525 let t = CommandTest {
2526 when: "jan hello".into(),
2527 then: "test \"$JAN_STATUS\" -eq 0".into(),
2528 ..Default::default()
2529 };
2530 t.validate("hello", "given_no_args_when_run_then_ok")
2531 .unwrap();
2532 assert!(t.validate("hello", "not_gherkin").is_err());
2533 }
2534
2535 #[test]
2536 fn aliases_spec_deserializes_string_list_and_map() {
2537 let spec: AliasesSpec = serde_yaml::from_str("lb").unwrap();
2538 assert_eq!(spec.names, vec!["lb"]);
2539 assert!(spec.shell.is_empty());
2540
2541 let spec: AliasesSpec = serde_yaml::from_str("[lb, lbr]").unwrap();
2542 assert_eq!(spec.names, vec!["lb", "lbr"]);
2543
2544 let spec: AliasesSpec = serde_yaml::from_str("gs: git status\nlb:\ng: git\n").unwrap();
2545 assert_eq!(spec.names, vec!["lb"]);
2546 assert_eq!(spec.shell.get("gs").map(String::as_str), Some("git status"));
2547 assert_eq!(spec.shell.get("g").map(String::as_str), Some("git"));
2548 }
2549
2550 #[test]
2551 fn aliases_names_require_jan_target() {
2552 let spec = load_spec_from_str(
2553 r"
2554commands:
2555 git:
2556 aliases: [g]
2557 commands:
2558 status:
2559 exec:
2560 argv: [echo, ok]
2561",
2562 None,
2563 );
2564 let err = spec.unwrap_err().to_string();
2565 assert!(err.contains("jan alias target"), "{err}");
2566 }
2567
2568 #[test]
2569 fn aliases_reject_unsafe_names() {
2570 let spec = load_spec_from_str(
2571 r"
2572commands:
2573 leaf:
2574 aliases:
2575 'x;rm': echo pwn
2576 exec:
2577 argv: [echo, ok]
2578",
2579 None,
2580 );
2581 let err = spec.unwrap_err().to_string();
2582 assert!(err.contains("must match"), "{err}");
2583 }
2584}
2585
2586pub fn default_db_path() -> PathBuf {
2587 if let Ok(p) = std::env::var("JAN_DB") {
2588 return PathBuf::from(p);
2589 }
2590 dirs::data_local_dir()
2591 .unwrap_or_else(|| PathBuf::from("."))
2592 .join("jan-cli")
2593 .join("audit.db")
2594}