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