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