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