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