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