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 node_at_chain<'a>(spec: &'a RootSpec, chain: &[String]) -> Option<&'a CommandNode> {
1585 let mut map = &spec.commands;
1586 let mut node = None;
1587 for seg in chain {
1588 let next = map.get(seg)?;
1589 node = Some(next);
1590 map = &next.commands;
1591 }
1592 node
1593}
1594
1595fn command_help_text(
1598 spec: &RootSpec,
1599 chain: &[String],
1600 node: Option<&CommandNode>,
1601) -> Option<String> {
1602 let n = node?;
1603 if let Some(t) = n.exec.as_ref().and_then(ExecSpec::literal_text) {
1604 return Some(t.to_string());
1605 }
1606 if let Some(t) = n
1607 .commands
1608 .get("help")
1609 .and_then(|h| h.exec.as_ref())
1610 .and_then(ExecSpec::literal_text)
1611 {
1612 return Some(t.to_string());
1613 }
1614 if chain.last().map(String::as_str) == Some("run") && chain.len() >= 2 {
1615 let parent = node_at_chain(spec, &chain[..chain.len() - 1])?;
1616 return parent
1617 .commands
1618 .get("help")
1619 .and_then(|h| h.exec.as_ref())
1620 .and_then(ExecSpec::literal_text)
1621 .map(str::to_string);
1622 }
1623 None
1624}
1625
1626pub fn format_help(spec: &RootSpec, chain: &[String], node: Option<&CommandNode>) -> String {
1627 let mut out = String::new();
1628 let bin = spec
1629 .metadata
1630 .as_ref()
1631 .and_then(|m| m.name.as_deref())
1632 .unwrap_or("jan");
1633 let full_cmd = if chain.is_empty() {
1634 bin.to_string()
1635 } else {
1636 format!("{} {}", bin, chain.join(" "))
1637 };
1638
1639 let (about, children, exec) = match node {
1640 Some(n) => (n.about.as_str(), &n.commands, n.exec.as_ref()),
1641 None => ("", &spec.commands, None),
1642 };
1643
1644 if chain.is_empty() {
1645 if let Some(meta) = &spec.metadata {
1646 if let Some(desc) = &meta.description {
1647 out.push_str(desc.trim());
1648 out.push_str("\n\n");
1649 }
1650 }
1651 }
1652
1653 let help_doc = command_help_text(spec, chain, node);
1654 if let Some(doc) = &help_doc {
1655 out.push_str(doc);
1656 out.push_str("\n\n");
1657 } else if !about.is_empty() {
1658 out.push_str(about.trim());
1659 out.push_str("\n\n");
1660 }
1661
1662 let listed: Vec<(&String, &CommandNode)> = children
1663 .iter()
1664 .filter(|(name, child)| !is_help_leaf(name, child))
1665 .collect();
1666
1667 if exec.is_some() && children.is_empty() {
1668 if help_doc.is_none() {
1669 out.push_str("This command runs an external program (see spec `exec.argv`).\n");
1670 }
1671 append_help_inputs_and_tests(&mut out, spec, chain, node);
1672 return out;
1673 }
1674
1675 if !listed.is_empty() {
1676 out.push_str("Subcommands:\n");
1677 for (name, child) in &listed {
1678 let line = if child.about.is_empty() {
1679 format!(" {name}\n")
1680 } else {
1681 format!(" {name} — {}\n", first_line(&child.about))
1682 };
1683 out.push_str(&line);
1684 }
1685 out.push('\n');
1686 if listed.iter().any(|(n, _)| n.as_str() != "run") {
1687 out.push_str(&format!(
1688 "Use `{} --help` for more about a subcommand.\n",
1689 full_cmd
1690 ));
1691 }
1692 append_help_inputs_and_tests(&mut out, spec, chain, node);
1693 } else if exec.is_none() && help_doc.is_none() {
1694 out.push_str("(No subcommands defined.)\n");
1695 append_help_inputs_and_tests(&mut out, spec, chain, node);
1696 } else {
1697 append_help_inputs_and_tests(&mut out, spec, chain, node);
1698 }
1699 if chain.is_empty() && node.is_none() {
1700 out.push_str(
1701 "\nPlace `--help` or `-h` right after the subcommand prefix you want. Built-ins: `use`, `bundle`, `alias`, `list`, `search`, `show`, `validate`, `audit`, `cron`, `test`.\n",
1702 );
1703 }
1704 out
1705}
1706
1707fn append_help_inputs_and_tests(
1708 out: &mut String,
1709 spec: &RootSpec,
1710 chain: &[String],
1711 node: Option<&CommandNode>,
1712) {
1713 let defs = inputs::collect_chain_inputs(chain, spec);
1714 if !defs.is_empty() {
1715 out.push('\n');
1716 out.push_str(&inputs::format_inputs_help(&defs));
1717 }
1718 let n = match node {
1719 Some(n) => cmdtest::count_tests(n),
1720 None => spec.commands.values().map(cmdtest::count_tests).sum(),
1721 };
1722 if n > 0 {
1723 let hint = if chain.is_empty() {
1724 "jan test".to_string()
1725 } else {
1726 format!("jan test {}", chain.join(" "))
1727 };
1728 out.push_str(&format!("\n{n} test(s) — run with `{hint}`.\n"));
1729 }
1730}
1731
1732#[derive(Debug, Clone)]
1734pub struct SpecRootIdentity {
1735 pub spec_dir: String,
1737 pub root_yaml: String,
1739}
1740
1741pub fn resolve_preferred_spec() -> Result<(PathBuf, SpecRootIdentity)> {
1743 let cfg = config::load_user_config().context("load user config")?;
1744 let Some(dir_s) = cfg
1745 .jan_dir
1746 .as_ref()
1747 .map(|s| s.trim())
1748 .filter(|s| !s.is_empty())
1749 else {
1750 bail!(
1751 "no preferred jan directory configured\n\
1752 Run `jan use <DIR>` to save a YAML command tree (see docs/PORTABLE_SCRIPTS.md)"
1753 );
1754 };
1755 let dir = PathBuf::from(dir_s);
1756 if !dir.is_dir() {
1757 bail!(
1758 "preferred jan directory does not exist: {}\n\
1759 Fix the path or run `jan use <DIR>` again (config: {})",
1760 dir.display(),
1761 config::config_path().display()
1762 );
1763 }
1764 let root = cfg
1765 .spec_root
1766 .as_deref()
1767 .map(str::trim)
1768 .filter(|s| !s.is_empty())
1769 .unwrap_or("scripts.spec.yaml");
1770 resolve_spec_dir_entry(&dir, root)
1771}
1772
1773pub fn resolve_spec_dir_entry(
1775 spec_dir: &Path,
1776 root_yaml: &str,
1777) -> Result<(PathBuf, SpecRootIdentity)> {
1778 let rel = Path::new(root_yaml);
1779 if rel.is_absolute() {
1780 bail!("entry YAML must be a relative file name, not an absolute path");
1781 }
1782 if rel
1783 .components()
1784 .any(|c| matches!(c, std::path::Component::ParentDir))
1785 {
1786 bail!("entry YAML must not contain `..`");
1787 }
1788 let normal_only = rel
1789 .components()
1790 .all(|c| matches!(c, std::path::Component::Normal(_)));
1791 let n = rel
1792 .components()
1793 .filter(|c| matches!(c, std::path::Component::Normal(_)))
1794 .count();
1795 if !normal_only || n != 1 {
1796 bail!("entry YAML must be a single file name inside the jan directory");
1797 }
1798 let dir = spec_dir
1799 .canonicalize()
1800 .with_context(|| format!("canonicalize jan directory {}", spec_dir.display()))?;
1801 if !dir.is_dir() {
1802 bail!("not a directory: {}", dir.display());
1803 }
1804 let spec_path = dir.join(rel);
1805 if !spec_path.is_file() {
1806 bail!(
1807 "spec entry not found: {} (under {})\n\
1808 Expected a properly formatted jan directory (e.g. scripts.spec.yaml)",
1809 spec_path.display(),
1810 dir.display()
1811 );
1812 }
1813 let identity = SpecRootIdentity {
1814 spec_dir: dir.to_string_lossy().into_owned(),
1815 root_yaml: rel
1816 .file_name()
1817 .expect("relative root has file_name")
1818 .to_string_lossy()
1819 .into_owned(),
1820 };
1821 Ok((spec_path, identity))
1822}
1823
1824pub struct RunContext<'a> {
1825 pub cwd: &'a Path,
1826 pub db_path: Option<&'a Path>,
1827 pub branch: String,
1828 pub no_log: bool,
1829 pub spec_root: &'a SpecRootIdentity,
1830}
1831
1832fn shell_inline_c_needs_argv0(argv: &[String]) -> bool {
1839 if argv.len() != 3 {
1840 return false;
1841 }
1842 let prog = Path::new(&argv[0])
1843 .file_name()
1844 .and_then(|s| s.to_str())
1845 .unwrap_or(argv[0].as_str());
1846 let is_shell = matches!(
1847 prog,
1848 "bash" | "zsh" | "sh" | "dash" | "ksh" | "ash" | "busybox"
1849 );
1850 is_shell && matches!(argv[1].as_str(), "-c" | "-lc")
1851}
1852
1853fn shell_passthrough_argv0(chain: &[String]) -> String {
1854 chain
1855 .iter()
1856 .rev()
1857 .find(|s| s.as_str() != "run")
1858 .cloned()
1859 .or_else(|| chain.last().cloned())
1860 .unwrap_or_else(|| "jan".to_string())
1861}
1862
1863pub fn run_matched(
1864 spec: &RootSpec,
1865 chain: &[String],
1866 node: &CommandNode,
1867 trailing: &[OsString],
1868 ctx: &RunContext<'_>,
1869) -> Result<i32> {
1870 let exec = match &node.exec {
1871 Some(e) => e,
1872 None => {
1873 let help = format_help(spec, chain, Some(node));
1874 print!("{help}");
1875 bail!("missing subcommand");
1876 }
1877 };
1878 exec.validate(&chain.join(" "))?;
1879
1880 if exec.is_text() {
1881 let body = exec.literal_text().unwrap_or("");
1882 println!("{body}");
1883 return Ok(0);
1884 }
1885
1886 let input_defs = inputs::collect_chain_inputs(chain, spec);
1887 let (input_vals, rest) = inputs::resolve_inputs(&input_defs, trailing, Some(ctx.cwd))?;
1888
1889 let mut argv: Vec<String> = Vec::with_capacity(exec.argv.len() + 1);
1890 for a in &exec.argv {
1891 argv.push(inputs::interpolate(a, &input_vals)?);
1892 }
1893
1894 if exec.is_remote() {
1895 let url = exec.url.as_deref().unwrap().trim();
1896 let hash = exec.sha256.as_deref().unwrap().trim();
1897 let mut opts = remote::FetchOpts::new();
1898 if let Some(ttl) = exec.ttl {
1899 opts = opts.with_ttl(ttl);
1900 }
1901 let cached = remote::fetch_verified(url, hash, &opts, true)?;
1902 argv.push(cached.to_string_lossy().into_owned());
1903 } else if exec.is_local_file() {
1904 let rel = exec.file.as_deref().unwrap().trim();
1905 let use_root = Path::new(&ctx.spec_root.spec_dir);
1906 let resolved = spec_load::resolve_under_use_root(use_root, rel)?;
1907 if let Some(hash) = exec
1908 .sha256
1909 .as_deref()
1910 .map(str::trim)
1911 .filter(|s| !s.is_empty())
1912 {
1913 remote::verify_file_sha256(&resolved, hash)
1914 .with_context(|| format!("verify exec.file `{rel}`"))?;
1915 }
1916 argv.push(resolved.to_string_lossy().into_owned());
1917 } else if exec.is_language_source() {
1918 } else if argv.is_empty() {
1920 bail!("exec.argv must not be empty");
1921 }
1922
1923 if exec.passthrough {
1924 let mut rest = rest;
1925 if rest.first().is_some_and(|a| a == "--") {
1929 rest = rest[1..].to_vec();
1930 }
1931 if shell_inline_c_needs_argv0(&argv) {
1932 argv.push(shell_passthrough_argv0(chain));
1933 }
1934 for a in &rest {
1935 argv.push(a.to_string_lossy().into_owned());
1936 }
1937 } else if !rest.is_empty() {
1938 let preview = rest
1939 .iter()
1940 .take(3)
1941 .map(|s| s.to_string_lossy().into_owned())
1942 .collect::<Vec<_>>()
1943 .join(" ");
1944 bail!(
1945 "unexpected trailing arguments: {preview}{}",
1946 if rest.len() > 3 { "…" } else { "" }
1947 );
1948 }
1949
1950 let cmd_path = if chain.is_empty() {
1951 "(root)".to_string()
1952 } else {
1953 chain.join(" ")
1954 };
1955
1956 let (_, requires, _) = deps::collect_chain_metadata(chain, spec);
1957 deps::check_requires(&requires)?;
1958
1959 let pkgs = packages::collect_chain_packages(chain, spec);
1960 let pkg_envs = packages::ensure_packages(&pkgs, ctx)?;
1961
1962 if exec.is_kotlin() {
1963 let rel = exec.kotlin.as_deref().unwrap().trim();
1964 let use_root = Path::new(&ctx.spec_root.spec_dir);
1965 let main_args = std::mem::take(&mut argv);
1966 argv = packages::prepare_kotlin_argv(use_root, rel, &pkg_envs, &main_args)?;
1967 } else if exec.is_python() {
1968 let src = exec.python.as_deref().unwrap().trim();
1969 let use_root = Path::new(&ctx.spec_root.spec_dir);
1970 let main_args = std::mem::take(&mut argv);
1971 argv = packages::prepare_python_argv(use_root, src, &main_args)?;
1972 } else if exec.is_node() {
1973 let src = exec.node.as_deref().unwrap().trim();
1974 let use_root = Path::new(&ctx.spec_root.spec_dir);
1975 let main_args = std::mem::take(&mut argv);
1976 argv = packages::prepare_node_argv(use_root, src, &main_args)?;
1977 } else if exec.is_bash() || exec.is_sh() || exec.is_zsh() {
1978 let (kind, src) = if exec.is_bash() {
1979 (
1980 packages::ShellKind::Bash,
1981 exec.bash.as_deref().unwrap().trim(),
1982 )
1983 } else if exec.is_zsh() {
1984 (
1985 packages::ShellKind::Zsh,
1986 exec.zsh.as_deref().unwrap().trim(),
1987 )
1988 } else {
1989 (packages::ShellKind::Sh, exec.sh.as_deref().unwrap().trim())
1990 };
1991 let use_root = Path::new(&ctx.spec_root.spec_dir);
1992 let main_args = std::mem::take(&mut argv);
1993 let argv0 = shell_passthrough_argv0(chain);
1994 argv = packages::prepare_shell_argv(kind, use_root, src, &argv0, &main_args)?;
1995 } else {
1996 packages::inject_jvm_classpath(&mut argv, &pkg_envs);
1997 }
1998
1999 let path_dirs = deps::resolve_path_prefixes(spec, chain, ctx)?;
2000 let program = packages::resolve_program_with_envs(&argv[0], &pkg_envs, &path_dirs)?;
2001 let mut env_spec = deps::collect_chain_env(chain, spec);
2002 for value in env_spec.public.values_mut() {
2003 *value = inputs::interpolate(value, &input_vals)?;
2004 }
2005 deps::check_private_env(&env_spec.private)?;
2006 let mut path_override = if !path_dirs.is_empty() {
2007 Some(deps::prepend_path_env(&path_dirs)?)
2008 } else {
2009 None
2010 };
2011 if !pkg_envs.is_empty() {
2012 path_override = Some(packages::prepend_env_paths(&pkg_envs, path_override)?);
2013 }
2014 if let Some(node_path) = packages::node_path_for(&pkg_envs) {
2015 env_spec
2016 .public
2017 .entry("NODE_PATH".to_string())
2018 .or_insert(node_path);
2019 }
2020 if let Some(classpath) = packages::classpath_for(&pkg_envs) {
2021 env_spec
2022 .public
2023 .entry("CLASSPATH".to_string())
2024 .or_insert(classpath);
2025 }
2026
2027 let mut c = Command::new(&program);
2028 if argv.len() > 1 {
2029 c.args(&argv[1..]);
2030 }
2031 c.current_dir(ctx.cwd);
2032 deps::apply_process_env(&mut c, &env_spec, path_override)?;
2033
2034 let status = c.status().with_context(|| format!("spawn `{}`", argv[0]))?;
2035 let code = status.code().unwrap_or(255);
2036
2037 if !ctx.no_log {
2038 if let Some(db) = ctx.db_path {
2039 log_invocation(
2040 db,
2041 &ctx.branch,
2042 ctx.cwd,
2043 &cmd_path,
2044 &argv,
2045 code,
2046 ctx.spec_root,
2047 )?;
2048 }
2049 }
2050
2051 Ok(code)
2052}
2053
2054fn ensure_invocations_spec_root_column(conn: &Connection) -> Result<()> {
2055 let mut stmt = conn.prepare("PRAGMA table_info(invocations)")?;
2056 let cols: Vec<String> = stmt
2057 .query_map([], |row| row.get::<_, String>(1))?
2058 .collect::<std::result::Result<_, _>>()?;
2059 if !cols.iter().any(|c| c == "spec_root_id") {
2060 conn.execute(
2061 "ALTER TABLE invocations ADD COLUMN spec_root_id INTEGER",
2062 [],
2063 )?;
2064 }
2065 Ok(())
2066}
2067
2068fn upsert_spec_root(conn: &Connection, spec: &SpecRootIdentity) -> Result<i64> {
2069 let ts = unix_ts();
2070 conn.execute(
2071 r"INSERT INTO spec_roots (spec_dir, root_yaml, last_used_ts) VALUES (?1, ?2, ?3)
2072 ON CONFLICT(spec_dir, root_yaml) DO UPDATE SET last_used_ts = excluded.last_used_ts",
2073 rusqlite::params![&spec.spec_dir, &spec.root_yaml, &ts],
2074 )?;
2075 let id: i64 = conn.query_row(
2076 "SELECT id FROM spec_roots WHERE spec_dir = ?1 AND root_yaml = ?2",
2077 [&spec.spec_dir, &spec.root_yaml],
2078 |r| r.get(0),
2079 )?;
2080 Ok(id)
2081}
2082
2083fn log_invocation(
2084 db_path: &Path,
2085 branch: &str,
2086 cwd: &Path,
2087 command_path: &str,
2088 argv: &[String],
2089 exit_code: i32,
2090 spec_root: &SpecRootIdentity,
2091) -> Result<()> {
2092 if let Some(parent) = db_path.parent() {
2093 std::fs::create_dir_all(parent).ok();
2094 }
2095 let conn = Connection::open(db_path).with_context(|| format!("open {}", db_path.display()))?;
2096 conn.execute_batch(
2097 r"
2098 CREATE TABLE IF NOT EXISTS spec_roots (
2099 id INTEGER PRIMARY KEY AUTOINCREMENT,
2100 spec_dir TEXT NOT NULL,
2101 root_yaml TEXT NOT NULL,
2102 last_used_ts TEXT NOT NULL,
2103 UNIQUE(spec_dir, root_yaml)
2104 );
2105 CREATE TABLE IF NOT EXISTS invocations (
2106 id INTEGER PRIMARY KEY AUTOINCREMENT,
2107 ts TEXT NOT NULL,
2108 git_branch TEXT NOT NULL,
2109 cwd TEXT NOT NULL,
2110 command_path TEXT NOT NULL,
2111 argv_json TEXT NOT NULL,
2112 exit_code INTEGER NOT NULL,
2113 spec_root_id INTEGER
2114 );
2115 ",
2116 )?;
2117 ensure_invocations_spec_root_column(&conn)?;
2118 let spec_root_id = upsert_spec_root(&conn, spec_root)?;
2119 let ts = unix_ts();
2120 let argv_json = serde_json::to_string(argv).unwrap_or_else(|_| "[]".to_string());
2121 let cwd_s = cwd.to_string_lossy();
2122 conn.execute(
2123 "INSERT INTO invocations (ts, git_branch, cwd, command_path, argv_json, exit_code, spec_root_id)
2124 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
2125 rusqlite::params![
2126 ts,
2127 branch,
2128 cwd_s.as_ref(),
2129 command_path,
2130 argv_json,
2131 exit_code,
2132 spec_root_id
2133 ],
2134 )?;
2135 Ok(())
2136}
2137
2138fn unix_ts() -> String {
2139 use std::time::SystemTime;
2140 SystemTime::now()
2141 .duration_since(std::time::UNIX_EPOCH)
2142 .unwrap_or_default()
2143 .as_secs()
2144 .to_string()
2145}
2146
2147#[derive(Debug)]
2148pub struct MatchOutcome<'a> {
2149 pub chain: Vec<String>,
2150 pub node: Option<&'a CommandNode>,
2151 pub trailing: Vec<OsString>,
2152 pub wants_help: bool,
2153}
2154
2155pub fn match_commands<'a>(spec: &'a RootSpec, args: &[OsString]) -> MatchOutcome<'a> {
2156 let mut chain = Vec::new();
2157 let mut node: Option<&'a CommandNode> = None;
2158 let mut map: &BTreeMap<String, CommandNode> = &spec.commands;
2159 let mut i = 0usize;
2160 let len = args.len();
2161 while i < len {
2162 let raw = &args[i];
2163 if raw == "--help" || raw == "-h" {
2164 return MatchOutcome {
2165 chain,
2166 node,
2167 trailing: args[i + 1..].to_vec(),
2168 wants_help: true,
2169 };
2170 }
2171 let key = raw.to_string_lossy();
2172 if let Some(next) = map.get(key.as_ref()) {
2173 chain.push(key.into_owned());
2174 node = Some(next);
2175 map = &next.commands;
2176 i += 1;
2177 continue;
2178 }
2179 break;
2180 }
2181 MatchOutcome {
2182 chain,
2183 node,
2184 trailing: args[i..].to_vec(),
2185 wants_help: false,
2186 }
2187}
2188
2189#[cfg(test)]
2190mod tests {
2191 use super::*;
2192 use std::io::Write;
2193
2194 #[test]
2195 fn examples_default_spec_validates() {
2196 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/default.spec.yaml");
2197 load_spec(&path).unwrap();
2198 }
2199
2200 #[test]
2201 fn merge_specs_adds_and_replaces_leaves() {
2202 let mut base = load_spec_from_str(
2203 r"
2204commands:
2205 a:
2206 about: base
2207 commands:
2208 x:
2209 about: old
2210 exec:
2211 argv: [echo, old]
2212",
2213 None,
2214 )
2215 .unwrap();
2216 let overlay = load_spec_from_str(
2217 r"
2218commands:
2219 a:
2220 commands:
2221 x:
2222 about: new leaf
2223 exec:
2224 argv: [echo, new]
2225 b:
2226 about: added top
2227 exec:
2228 argv: [echo, b]
2229",
2230 None,
2231 )
2232 .unwrap();
2233 merge_specs_into(&mut base, overlay).unwrap();
2234 base.commands["a"].commands["x"].validate("a x").unwrap();
2235 assert_eq!(
2236 base.commands["a"].commands["x"].exec.as_ref().unwrap().argv,
2237 vec!["echo", "new"]
2238 );
2239 assert_eq!(
2240 base.commands["b"].exec.as_ref().unwrap().argv,
2241 vec!["echo", "b"]
2242 );
2243 }
2244
2245 #[test]
2246 fn validate_rejects_exec_with_children() {
2247 let mut tmp = tempfile::NamedTempFile::with_suffix(".yaml").unwrap();
2248 write!(
2249 tmp,
2250 r"
2251commands:
2252 x:
2253 exec:
2254 argv: [echo]
2255 commands:
2256 child:
2257 about: nested
2258"
2259 )
2260 .unwrap();
2261 let err = load_spec(tmp.path()).unwrap_err();
2262 assert!(err.to_string().contains("cannot define both"));
2263 }
2264
2265 #[test]
2266 fn shell_inline_c_needs_argv0_detects_bash_lc() {
2267 let argv = vec![
2268 "bash".into(),
2269 "-lc".into(),
2270 "case \"$1\" in create) ;; esac".into(),
2271 ];
2272 assert!(shell_inline_c_needs_argv0(&argv));
2273 let with_placeholder = vec!["zsh".into(), "-c".into(), "echo".into(), "issue".into()];
2274 assert!(!shell_inline_c_needs_argv0(&with_placeholder));
2275 assert!(!shell_inline_c_needs_argv0(&[
2276 "echo".into(),
2277 "start".into()
2278 ]));
2279 assert!(!shell_inline_c_needs_argv0(&[
2280 "python3".into(),
2281 "-c".into(),
2282 "print(1)".into()
2283 ]));
2284 }
2285
2286 #[test]
2287 fn shell_passthrough_argv0_skips_run_leaf() {
2288 assert_eq!(
2289 shell_passthrough_argv0(&[
2290 "scripts".into(),
2291 "misc".into(),
2292 "issue".into(),
2293 "run".into()
2294 ]),
2295 "issue"
2296 );
2297 assert_eq!(shell_passthrough_argv0(&["probe".into()]), "probe");
2298 }
2299
2300 #[test]
2301 fn language_exec_path_vs_inline_detection() {
2302 assert!(ExecSpec::python_value_is_path("scripts/x.py"));
2303 assert!(ExecSpec::python_value_is_path("X.PY"));
2304 assert!(!ExecSpec::python_value_is_path("print(1)\n"));
2305 assert!(!ExecSpec::python_value_is_path("import sys"));
2306 assert!(ExecSpec::node_value_is_path("a.js"));
2307 assert!(ExecSpec::node_value_is_path("a.mjs"));
2308 assert!(ExecSpec::node_value_is_path("a.cjs"));
2309 assert!(!ExecSpec::node_value_is_path("console.log(1)"));
2310 assert!(!ExecSpec::node_value_is_path("x.ts"));
2311 assert!(ExecSpec::bash_value_is_path("x.sh"));
2312 assert!(ExecSpec::bash_value_is_path("x.bash"));
2313 assert!(!ExecSpec::bash_value_is_path("echo hi"));
2314 assert!(ExecSpec::sh_value_is_path("x.sh"));
2315 assert!(ExecSpec::zsh_value_is_path("x.zsh"));
2316 let bash = ExecSpec {
2317 bash: Some("echo hi".into()),
2318 ..Default::default()
2319 };
2320 bash.validate("t").unwrap();
2321
2322 let python = ExecSpec {
2323 python: Some("print(1)".into()),
2324 ..Default::default()
2325 };
2326 python.validate("t").unwrap();
2327 let node = ExecSpec {
2328 node: Some("console.log(1)".into()),
2329 ..Default::default()
2330 };
2331 node.validate("t").unwrap();
2332 let both = ExecSpec {
2333 python: Some("x.py".into()),
2334 node: Some("x.js".into()),
2335 ..Default::default()
2336 };
2337 assert!(both.validate("t").is_err());
2338 let text = ExecSpec {
2339 text: Some("hello docs\n".into()),
2340 ..Default::default()
2341 };
2342 text.validate("t").unwrap();
2343 let cat: ExecSpec = serde_yaml::from_str("cat: |\n printed as-is\n").unwrap();
2344 assert_eq!(cat.literal_text(), Some("printed as-is"));
2345 }
2346
2347 #[test]
2348 fn format_help_inlines_help_child_and_hides_help_leaf() {
2349 let spec = load_spec_from_str(
2350 r#"
2351commands:
2352 backup:
2353 about: Backup a path
2354 inputs:
2355 path:
2356 required: true
2357 type: path
2358 commands:
2359 help:
2360 about: Describe this script.
2361 exec:
2362 text: |
2363 backup — copy files
2364 Example: jan backup run --path /data
2365 run:
2366 about: Run the backup
2367 exec:
2368 argv: [echo, ok]
2369"#,
2370 None,
2371 )
2372 .unwrap();
2373 let node = &spec.commands["backup"];
2374 let help = format_help(&spec, &["backup".into()], Some(node));
2375 assert!(help.contains("backup — copy files"));
2376 assert!(help.contains("jan backup run --path /data"));
2377 assert!(help.contains(" run — Run the backup"));
2378 assert!(!help.contains(" help —"));
2379 assert!(help.contains("--path"));
2380 let run = &node.commands["run"];
2381 let run_help = format_help(&spec, &["backup".into(), "run".into()], Some(run));
2382 assert!(run_help.contains("backup — copy files"));
2383 assert!(run_help.contains("--path"));
2384 }
2385
2386 #[test]
2387 fn gherkin_test_names() {
2388 assert!(gherkin_test_name(
2389 "given_a_csv_when_summarized_then_prints_shape"
2390 ));
2391 assert!(gherkin_test_name(
2392 "given a file when basename then prints name"
2393 ));
2394 assert!(gherkin_test_name(
2395 "given-a-name-when-run-then-mentions-birthday"
2396 ));
2397 assert!(!gherkin_test_name("prints_hello"));
2398 assert!(!gherkin_test_name("given_when_then"));
2399 assert!(!gherkin_test_name("given_x_when_y"));
2400 let t = CommandTest {
2401 when: "jan hello".into(),
2402 then: "test \"$JAN_STATUS\" -eq 0".into(),
2403 ..Default::default()
2404 };
2405 t.validate("hello", "given_no_args_when_run_then_ok")
2406 .unwrap();
2407 assert!(t.validate("hello", "not_gherkin").is_err());
2408 }
2409
2410 #[test]
2411 fn aliases_spec_deserializes_string_list_and_map() {
2412 let spec: AliasesSpec = serde_yaml::from_str("lb").unwrap();
2413 assert_eq!(spec.names, vec!["lb"]);
2414 assert!(spec.shell.is_empty());
2415
2416 let spec: AliasesSpec = serde_yaml::from_str("[lb, lbr]").unwrap();
2417 assert_eq!(spec.names, vec!["lb", "lbr"]);
2418
2419 let spec: AliasesSpec = serde_yaml::from_str("gs: git status\nlb:\ng: git\n").unwrap();
2420 assert_eq!(spec.names, vec!["lb"]);
2421 assert_eq!(spec.shell.get("gs").map(String::as_str), Some("git status"));
2422 assert_eq!(spec.shell.get("g").map(String::as_str), Some("git"));
2423 }
2424
2425 #[test]
2426 fn aliases_names_require_jan_target() {
2427 let spec = load_spec_from_str(
2428 r"
2429commands:
2430 git:
2431 aliases: [g]
2432 commands:
2433 status:
2434 exec:
2435 argv: [echo, ok]
2436",
2437 None,
2438 );
2439 let err = spec.unwrap_err().to_string();
2440 assert!(err.contains("jan alias target"), "{err}");
2441 }
2442
2443 #[test]
2444 fn aliases_reject_unsafe_names() {
2445 let spec = load_spec_from_str(
2446 r"
2447commands:
2448 leaf:
2449 aliases:
2450 'x;rm': echo pwn
2451 exec:
2452 argv: [echo, ok]
2453",
2454 None,
2455 );
2456 let err = spec.unwrap_err().to_string();
2457 assert!(err.contains("must match"), "{err}");
2458 }
2459}
2460
2461pub fn default_db_path() -> PathBuf {
2462 if let Ok(p) = std::env::var("JAN_DB") {
2463 return PathBuf::from(p);
2464 }
2465 dirs::data_local_dir()
2466 .unwrap_or_else(|| PathBuf::from("."))
2467 .join("jan-cli")
2468 .join("audit.db")
2469}