1mod builtins;
2mod config;
3mod cron;
4mod deps;
5mod inputs;
6mod inspect;
7mod packages;
8pub mod remote;
9mod runner;
10mod spec_load;
11mod yaml_closure;
12
13pub use config::{load_user_config, UserConfig};
14pub use runner::run_jan;
15pub use spec_load::HostPlatform;
16
17use std::collections::BTreeMap;
18use std::ffi::OsString;
19use std::path::{Path, PathBuf};
20use std::process::Command;
21
22use anyhow::{bail, Context, Result};
23use rusqlite::Connection;
24use serde::de::{self, Deserializer, Visitor};
25use serde::Deserialize;
26use std::fmt;
27
28#[derive(Debug, Deserialize)]
29pub struct RootSpec {
30 pub metadata: Option<Metadata>,
31 #[serde(default)]
32 pub commands: BTreeMap<String, CommandNode>,
33}
34
35#[derive(Debug, Deserialize)]
36pub struct Metadata {
37 pub name: Option<String>,
38 pub description: Option<String>,
39}
40
41#[derive(Debug, Default, Clone, PartialEq, Eq)]
68pub struct EnvSpec {
69 pub public: BTreeMap<String, String>,
70 pub private: Vec<String>,
71 pub pass: BTreeMap<String, String>,
73}
74
75impl EnvSpec {
76 pub fn is_empty(&self) -> bool {
77 self.public.is_empty() && self.private.is_empty() && self.pass.is_empty()
78 }
79
80 pub fn restricts_child_env(&self) -> bool {
82 !self.is_empty()
83 }
84
85 pub fn merge_from(&mut self, other: EnvSpec) {
86 for (k, v) in other.public {
87 self.public.insert(k, v);
88 }
89 for name in other.private {
90 if !self.private.iter().any(|p| p == &name) {
91 self.private.push(name);
92 }
93 }
94 for (k, v) in other.pass {
95 self.pass.insert(k, v);
96 }
97 }
98
99 pub fn validate(&self, path: &str) -> Result<()> {
101 for name in &self.private {
102 if name.trim().is_empty() {
103 bail!("command '{path}': env.private entry must not be empty");
104 }
105 }
106 for (env_name, pass_id) in &self.pass {
107 if env_name.trim().is_empty() {
108 bail!("command '{path}': env.pass key must not be empty");
109 }
110 if pass_id.trim().is_empty() {
111 bail!("command '{path}': env.pass id for `{env_name}` must not be empty");
112 }
113 if self.private.iter().any(|p| p == env_name) {
114 bail!(
115 "command '{path}': env var `{env_name}` cannot be both `env.private` and `env.pass`"
116 );
117 }
118 }
119 Ok(())
120 }
121}
122
123impl<'de> Deserialize<'de> for EnvSpec {
124 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
125 where
126 D: Deserializer<'de>,
127 {
128 #[derive(Deserialize)]
129 struct Structured {
130 #[serde(default)]
131 public: BTreeMap<String, String>,
132 #[serde(default, deserialize_with = "deserialize_string_or_seq")]
133 private: Vec<String>,
134 #[serde(default)]
135 pass: BTreeMap<String, String>,
136 }
137
138 #[derive(Deserialize)]
139 #[serde(untagged)]
140 enum EnvDe {
141 Flat(BTreeMap<String, String>),
142 Sections(Structured),
143 }
144
145 Ok(match EnvDe::deserialize(deserializer)? {
146 EnvDe::Flat(public) => Self {
147 public,
148 private: Vec::new(),
149 pass: BTreeMap::new(),
150 },
151 EnvDe::Sections(s) => Self {
152 public: s.public,
153 private: s.private,
154 pass: s.pass,
155 },
156 })
157 }
158}
159
160#[derive(Debug, Clone, Copy, PartialEq, Eq)]
162pub enum IncludeLinkKind {
163 Yaml,
164 Script,
165}
166
167#[derive(Debug, Clone, PartialEq, Eq)]
169pub struct IncludeLink {
170 pub kind: IncludeLinkKind,
171 pub path: Option<String>,
173 pub url: Option<String>,
175 pub sha256: Option<String>,
177}
178
179#[derive(Debug, Deserialize, Default, Clone)]
180pub struct CommandNode {
181 #[serde(default)]
184 pub os: Vec<String>,
185 #[serde(default)]
186 pub about: String,
187 pub path: Option<String>,
189 #[serde(default)]
191 pub dependencies: Vec<String>,
192 #[serde(default)]
194 pub requires: Vec<String>,
195 #[serde(default)]
197 pub env: EnvSpec,
198 #[serde(default)]
200 pub inputs: BTreeMap<String, crate::inputs::InputDef>,
201 #[serde(default, deserialize_with = "deserialize_string_or_seq")]
204 pub cron: Vec<String>,
205 #[serde(default)]
207 pub packages: PackagesSpec,
208 #[serde(default)]
209 pub commands: BTreeMap<String, CommandNode>,
210 pub exec: Option<ExecSpec>,
211 #[serde(skip)]
213 pub source: Option<IncludeLink>,
214}
215
216#[derive(Debug, Clone, Default, PartialEq, Eq, Deserialize)]
221pub struct PackagesSpec {
222 #[serde(default)]
223 pub uv: Option<UvPackages>,
224 #[serde(default)]
225 pub pnpm: Option<PnpmPackages>,
226 #[serde(default)]
227 pub gradle: Option<GradlePackages>,
228}
229
230impl PackagesSpec {
231 pub fn is_empty(&self) -> bool {
232 self.uv.is_none() && self.pnpm.is_none() && self.gradle.is_none()
233 }
234
235 pub fn merge_from(&mut self, other: PackagesSpec) {
237 if other.uv.is_some() {
238 self.uv = other.uv;
239 }
240 if other.pnpm.is_some() {
241 self.pnpm = other.pnpm;
242 }
243 if other.gradle.is_some() {
244 self.gradle = other.gradle;
245 }
246 }
247
248 pub fn validate(&self, path: &str) -> Result<()> {
249 if let Some(uv) = &self.uv {
250 uv.validate(path)?;
251 }
252 if let Some(pnpm) = &self.pnpm {
253 pnpm.validate(path)?;
254 }
255 if let Some(gradle) = &self.gradle {
256 gradle.validate(path)?;
257 }
258 Ok(())
259 }
260}
261
262#[derive(Debug, Clone, PartialEq, Eq)]
264pub enum UvPackages {
265 List(Vec<String>),
266 Project(String),
267 Requirements(String),
268}
269
270impl UvPackages {
271 pub fn validate(&self, path: &str) -> Result<()> {
272 match self {
273 Self::List(pkgs) => {
274 if pkgs.is_empty() {
275 bail!("command '{path}': packages.uv list must not be empty");
276 }
277 for p in pkgs {
278 if p.trim().is_empty() {
279 bail!("command '{path}': packages.uv entry must not be empty");
280 }
281 packages::check_pinned_requirement(p)
282 .map_err(|e| anyhow::anyhow!("command '{path}': {e}"))?;
283 }
284 }
285 Self::Project(p) | Self::Requirements(p) => {
286 if p.trim().is_empty() {
287 bail!("command '{path}': packages.uv path must not be empty");
288 }
289 }
290 }
291 Ok(())
292 }
293}
294
295impl<'de> Deserialize<'de> for UvPackages {
296 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
297 where
298 D: Deserializer<'de>,
299 {
300 #[derive(Deserialize)]
301 #[serde(deny_unknown_fields)]
302 struct MapForm {
303 #[serde(default)]
304 project: Option<String>,
305 #[serde(default)]
306 requirements: Option<String>,
307 }
308
309 #[derive(Deserialize)]
310 #[serde(untagged)]
311 enum Helper {
312 List(Vec<String>),
313 Map(MapForm),
314 }
315
316 match Helper::deserialize(deserializer)? {
317 Helper::List(pkgs) => {
318 let pkgs: Vec<String> = pkgs
319 .into_iter()
320 .map(|s| s.trim().to_string())
321 .filter(|s| !s.is_empty())
322 .collect();
323 Ok(UvPackages::List(pkgs))
324 }
325 Helper::Map(m) => {
326 let project = m
327 .project
328 .map(|s| s.trim().to_string())
329 .filter(|s| !s.is_empty());
330 let requirements = m
331 .requirements
332 .map(|s| s.trim().to_string())
333 .filter(|s| !s.is_empty());
334 match (project, requirements) {
335 (Some(p), None) => Ok(UvPackages::Project(p)),
336 (None, Some(r)) => Ok(UvPackages::Requirements(r)),
337 (None, None) => Err(de::Error::custom(
338 "packages.uv map must set exactly one of `project` or `requirements`",
339 )),
340 (Some(_), Some(_)) => Err(de::Error::custom(
341 "packages.uv map must set exactly one of `project` or `requirements`, not both",
342 )),
343 }
344 }
345 }
346 }
347}
348
349#[derive(Debug, Clone, PartialEq, Eq)]
351pub enum PnpmPackages {
352 List(Vec<String>),
353 Project(String),
354}
355
356impl PnpmPackages {
357 pub fn validate(&self, path: &str) -> Result<()> {
358 match self {
359 Self::List(pkgs) => {
360 if pkgs.is_empty() {
361 bail!("command '{path}': packages.pnpm list must not be empty");
362 }
363 for p in pkgs {
364 if p.trim().is_empty() {
365 bail!("command '{path}': packages.pnpm entry must not be empty");
366 }
367 packages::check_pinned_npm_spec(p)
368 .map_err(|e| anyhow::anyhow!("command '{path}': {e}"))?;
369 }
370 }
371 Self::Project(p) => {
372 if p.trim().is_empty() {
373 bail!("command '{path}': packages.pnpm path must not be empty");
374 }
375 }
376 }
377 Ok(())
378 }
379}
380
381impl<'de> Deserialize<'de> for PnpmPackages {
382 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
383 where
384 D: Deserializer<'de>,
385 {
386 #[derive(Deserialize)]
387 #[serde(deny_unknown_fields)]
388 struct MapForm {
389 #[serde(default)]
390 project: Option<String>,
391 }
392
393 #[derive(Deserialize)]
394 #[serde(untagged)]
395 enum Helper {
396 List(Vec<String>),
397 Map(MapForm),
398 }
399
400 match Helper::deserialize(deserializer)? {
401 Helper::List(pkgs) => {
402 let pkgs: Vec<String> = pkgs
403 .into_iter()
404 .map(|s| s.trim().to_string())
405 .filter(|s| !s.is_empty())
406 .collect();
407 Ok(PnpmPackages::List(pkgs))
408 }
409 Helper::Map(m) => {
410 let project = m
411 .project
412 .map(|s| s.trim().to_string())
413 .filter(|s| !s.is_empty());
414 match project {
415 Some(p) => Ok(PnpmPackages::Project(p)),
416 None => Err(de::Error::custom(
417 "packages.pnpm map must set `project` (a directory with package.json and pnpm-lock.yaml)",
418 )),
419 }
420 }
421 }
422 }
423}
424
425#[derive(Debug, Clone, PartialEq, Eq)]
427pub enum GradlePackages {
428 List(Vec<String>),
429 Project(String),
430}
431
432impl GradlePackages {
433 pub fn validate(&self, path: &str) -> Result<()> {
434 match self {
435 Self::List(pkgs) => {
436 if pkgs.is_empty() {
437 bail!("command '{path}': packages.gradle list must not be empty");
438 }
439 for p in pkgs {
440 if p.trim().is_empty() {
441 bail!("command '{path}': packages.gradle entry must not be empty");
442 }
443 packages::check_pinned_maven_coord(p)
444 .map_err(|e| anyhow::anyhow!("command '{path}': {e}"))?;
445 }
446 }
447 Self::Project(p) => {
448 if p.trim().is_empty() {
449 bail!("command '{path}': packages.gradle path must not be empty");
450 }
451 }
452 }
453 Ok(())
454 }
455}
456
457impl<'de> Deserialize<'de> for GradlePackages {
458 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
459 where
460 D: Deserializer<'de>,
461 {
462 #[derive(Deserialize)]
463 #[serde(deny_unknown_fields)]
464 struct MapForm {
465 #[serde(default)]
466 project: Option<String>,
467 }
468
469 #[derive(Deserialize)]
470 #[serde(untagged)]
471 enum Helper {
472 List(Vec<String>),
473 Map(MapForm),
474 }
475
476 match Helper::deserialize(deserializer)? {
477 Helper::List(pkgs) => {
478 let pkgs: Vec<String> = pkgs
479 .into_iter()
480 .map(|s| s.trim().to_string())
481 .filter(|s| !s.is_empty())
482 .collect();
483 Ok(GradlePackages::List(pkgs))
484 }
485 Helper::Map(m) => {
486 let project = m
487 .project
488 .map(|s| s.trim().to_string())
489 .filter(|s| !s.is_empty());
490 match project {
491 Some(p) => Ok(GradlePackages::Project(p)),
492 None => Err(de::Error::custom(
493 "packages.gradle map must set `project` (a directory with a Gradle build and gradle.lockfile)",
494 )),
495 }
496 }
497 }
498 }
499}
500
501pub(crate) fn deserialize_string_or_seq<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
502where
503 D: Deserializer<'de>,
504{
505 struct StringOrSeq;
506
507 impl<'de> Visitor<'de> for StringOrSeq {
508 type Value = Vec<String>;
509
510 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
511 formatter.write_str("a string or a sequence of strings")
512 }
513
514 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
515 where
516 E: de::Error,
517 {
518 if value.trim().is_empty() {
519 Ok(Vec::new())
520 } else {
521 Ok(vec![value.to_string()])
522 }
523 }
524
525 fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
526 where
527 E: de::Error,
528 {
529 self.visit_str(&value)
530 }
531
532 fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
533 where
534 A: de::SeqAccess<'de>,
535 {
536 let mut out = Vec::new();
537 while let Some(s) = seq.next_element::<String>()? {
538 if !s.trim().is_empty() {
539 out.push(s);
540 }
541 }
542 Ok(out)
543 }
544
545 fn visit_none<E>(self) -> Result<Self::Value, E>
546 where
547 E: de::Error,
548 {
549 Ok(Vec::new())
550 }
551
552 fn visit_unit<E>(self) -> Result<Self::Value, E>
553 where
554 E: de::Error,
555 {
556 Ok(Vec::new())
557 }
558 }
559
560 deserializer.deserialize_any(StringOrSeq)
561}
562
563#[derive(Debug, Clone, PartialEq, Eq)]
565pub struct LocalInclude {
566 pub path: String,
567 pub sha256: Option<String>,
569 pub argv: Vec<String>,
571 pub passthrough: bool,
573}
574
575impl LocalInclude {
576 pub fn from_path(path: impl Into<String>) -> Self {
577 Self {
578 path: path.into(),
579 sha256: None,
580 argv: Vec::new(),
581 passthrough: false,
582 }
583 }
584
585 pub fn is_yaml(&self) -> bool {
586 let lower = self.path.to_ascii_lowercase();
587 lower.ends_with(".yaml") || lower.ends_with(".yml")
588 }
589}
590
591#[derive(Debug, Clone, PartialEq, Eq)]
593pub enum IncludeRef {
594 Local(LocalInclude),
596 Remote(RemoteInclude),
598}
599
600#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
601pub struct RemoteInclude {
602 pub url: String,
603 pub sha256: String,
604 #[serde(default)]
605 pub ttl: Option<u64>,
606}
607
608impl IncludeRef {
609 pub fn is_remote(&self) -> bool {
610 matches!(self, Self::Remote(_))
611 }
612
613 pub fn local_path(&self) -> Option<&str> {
614 match self {
615 Self::Local(l) => Some(l.path.as_str()),
616 Self::Remote(_) => None,
617 }
618 }
619
620 pub fn cycle_token(&self) -> String {
621 match self {
622 Self::Local(l) => match &l.sha256 {
623 Some(h) => format!("{}#{}", l.path, h.to_ascii_lowercase()),
624 None => l.path.clone(),
625 },
626 Self::Remote(r) => format!("{}#{}", r.url, r.sha256.to_ascii_lowercase()),
627 }
628 }
629}
630
631impl<'de> Deserialize<'de> for IncludeRef {
632 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
633 where
634 D: Deserializer<'de>,
635 {
636 #[derive(Deserialize)]
637 #[serde(deny_unknown_fields)]
638 struct LocalMap {
639 path: String,
640 #[serde(default)]
641 sha256: Option<String>,
642 #[serde(default)]
643 argv: Vec<String>,
644 #[serde(default)]
645 passthrough: bool,
646 }
647
648 #[derive(Deserialize)]
649 #[serde(untagged)]
650 enum Helper {
651 Path(String),
652 Local(LocalMap),
653 Remote(RemoteInclude),
654 }
655
656 match Helper::deserialize(deserializer)? {
657 Helper::Path(path) => {
658 let path = path.trim();
659 if path.is_empty() {
660 return Err(de::Error::custom("include path must not be empty"));
661 }
662 Ok(IncludeRef::Local(LocalInclude::from_path(path)))
663 }
664 Helper::Local(m) => {
665 let path = m.path.trim();
666 if path.is_empty() {
667 return Err(de::Error::custom("include.path must not be empty"));
668 }
669 let sha256 = m
670 .sha256
671 .map(|s| s.trim().to_string())
672 .filter(|s| !s.is_empty());
673 Ok(IncludeRef::Local(LocalInclude {
674 path: path.to_string(),
675 sha256,
676 argv: m.argv,
677 passthrough: m.passthrough,
678 }))
679 }
680 Helper::Remote(r) => {
681 if r.url.trim().is_empty() {
682 return Err(de::Error::custom("include.url must not be empty"));
683 }
684 if r.sha256.trim().is_empty() {
685 return Err(de::Error::custom(
686 "include.sha256 is required with include.url",
687 ));
688 }
689 Ok(IncludeRef::Remote(r))
690 }
691 }
692 }
693}
694
695#[derive(Debug, Deserialize, Clone, Default)]
696pub struct ExecSpec {
697 #[serde(default)]
700 pub argv: Vec<String>,
701 #[serde(default)]
703 pub passthrough: bool,
704 #[serde(default)]
706 pub url: Option<String>,
707 #[serde(default)]
709 pub file: Option<String>,
710 #[serde(default)]
712 pub sha256: Option<String>,
713 #[serde(default)]
715 pub ttl: Option<u64>,
716}
717
718impl ExecSpec {
719 pub fn is_remote(&self) -> bool {
720 self.url
721 .as_deref()
722 .map(|u| !u.trim().is_empty())
723 .unwrap_or(false)
724 }
725
726 pub fn is_local_file(&self) -> bool {
727 self.file
728 .as_deref()
729 .map(|u| !u.trim().is_empty())
730 .unwrap_or(false)
731 }
732
733 pub fn validate(&self, path: &str) -> Result<()> {
734 let url = self.url.as_deref().map(str::trim).filter(|s| !s.is_empty());
735 let file = self
736 .file
737 .as_deref()
738 .map(str::trim)
739 .filter(|s| !s.is_empty());
740 let hash = self
741 .sha256
742 .as_deref()
743 .map(str::trim)
744 .filter(|s| !s.is_empty());
745 if url.is_some() && file.is_some() {
746 bail!("command '{path}': exec cannot set both `url` and `file`");
747 }
748 match (url, file, hash) {
749 (Some(_), None, Some(_)) => Ok(()),
750 (Some(_), None, None) => {
751 bail!("command '{path}': exec.sha256 is required with exec.url")
752 }
753 (None, Some(_), _) => Ok(()),
754 (None, None, Some(_)) => {
755 bail!("command '{path}': exec.sha256 requires exec.url or exec.file")
756 }
757 (None, None, None) => {
758 if self.argv.is_empty() {
759 bail!(
760 "command '{path}': exec.argv must not be empty (or set exec.url / exec.file)"
761 );
762 }
763 Ok(())
764 }
765 (Some(_), Some(_), _) => unreachable!("checked above"),
766 }
767 }
768}
769
770impl CommandNode {
771 pub fn is_leaf_exec(&self) -> bool {
772 self.exec.is_some()
773 }
774
775 pub fn validate(&self, path: &str) -> Result<()> {
776 if self.exec.is_some() && !self.commands.is_empty() {
777 bail!("command '{path}' cannot define both `exec` and nested `commands`");
778 }
779 if let Some(ref e) = self.exec {
780 e.validate(path)?;
781 }
782 self.env.validate(path)?;
783 self.packages.validate(path)?;
784 for name in self.inputs.keys() {
785 inputs::InputDef::validate_name(name)
786 .map_err(|e| anyhow::anyhow!("command '{path}': {e}"))?;
787 }
788 for (name, child) in &self.commands {
789 let p = if path.is_empty() {
790 name.clone()
791 } else {
792 format!("{path} {name}")
793 };
794 child.validate(&p)?;
795 }
796 Ok(())
797 }
798}
799
800pub fn merge_specs_into(base: &mut RootSpec, overlay: RootSpec) -> Result<()> {
803 for (name, node) in overlay.commands {
804 match base.commands.get_mut(&name) {
805 Some(existing) => merge_command_node(existing, node)?,
806 None => {
807 base.commands.insert(name, node);
808 }
809 }
810 }
811 Ok(())
812}
813
814fn merge_command_node(dst: &mut CommandNode, src: CommandNode) -> Result<()> {
815 if src.exec.is_some() && !src.commands.is_empty() {
816 bail!("merge overlay: command cannot define both `exec` and nested `commands`");
817 }
818 if !src.os.is_empty() {
819 dst.os = src.os;
820 }
821 if !src.about.trim().is_empty() {
822 dst.about = src.about;
823 }
824 if src.path.is_some() {
825 dst.path = src.path;
826 }
827 if !src.dependencies.is_empty() {
828 dst.dependencies = src.dependencies;
829 }
830 if !src.requires.is_empty() {
831 dst.requires = src.requires;
832 }
833 if !src.cron.is_empty() {
834 dst.cron = src.cron;
835 }
836 if !src.env.is_empty() {
837 dst.env.merge_from(src.env);
838 }
839 for (k, v) in src.inputs {
840 dst.inputs.insert(k, v);
841 }
842 if let Some(exec) = src.exec {
843 dst.exec = Some(exec);
844 dst.commands.clear();
845 return Ok(());
846 }
847 if !src.commands.is_empty() {
848 dst.exec = None;
849 for (k, child) in src.commands {
850 match dst.commands.get_mut(&k) {
851 Some(existing) => merge_command_node(existing, child)?,
852 None => {
853 dst.commands.insert(k, child);
854 }
855 }
856 }
857 }
858 Ok(())
859}
860
861pub fn validate_spec(spec: &RootSpec) -> Result<()> {
863 for (name, node) in &spec.commands {
864 node.validate(name)?;
865 }
866 Ok(())
867}
868
869pub fn load_spec_from_str(raw: &str, include_base: Option<&Path>) -> Result<RootSpec> {
871 spec_load::load_spec_from_str(raw, include_base, HostPlatform::detect())
872}
873
874pub fn load_spec(path: &Path) -> Result<RootSpec> {
875 spec_load::load_spec_from_path(path, HostPlatform::detect())
876}
877
878pub fn resolve_git_branch(cwd: &Path, override_branch: Option<&str>) -> String {
879 if let Some(b) = override_branch {
880 if !b.is_empty() {
881 return b.to_string();
882 }
883 }
884 if let Ok(v) = std::env::var("JAN_BRANCH") {
885 if !v.is_empty() {
886 return v;
887 }
888 }
889 let output = Command::new("git")
890 .args(["rev-parse", "--abbrev-ref", "HEAD"])
891 .current_dir(cwd)
892 .output();
893 match output {
894 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
895 _ => "(no-git)".to_string(),
896 }
897}
898
899fn first_line(s: &str) -> String {
900 s.lines().next().unwrap_or("").trim().to_string()
901}
902
903pub fn format_help(spec: &RootSpec, chain: &[String], node: Option<&CommandNode>) -> String {
904 let mut out = String::new();
905 let bin = spec
906 .metadata
907 .as_ref()
908 .and_then(|m| m.name.as_deref())
909 .unwrap_or("jan");
910 let full_cmd = if chain.is_empty() {
911 bin.to_string()
912 } else {
913 format!("{} {}", bin, chain.join(" "))
914 };
915
916 let (about, children, exec) = match node {
917 Some(n) => (n.about.as_str(), &n.commands, n.exec.as_ref()),
918 None => ("", &spec.commands, None),
919 };
920
921 if chain.is_empty() {
922 if let Some(meta) = &spec.metadata {
923 if let Some(desc) = &meta.description {
924 out.push_str(desc.trim());
925 out.push_str("\n\n");
926 }
927 }
928 }
929
930 if !about.is_empty() {
931 out.push_str(about.trim());
932 out.push_str("\n\n");
933 }
934
935 if exec.is_some() && children.is_empty() {
936 out.push_str("This command runs an external program (see spec `exec.argv`).\n");
937 let defs = inputs::collect_chain_inputs(chain, spec);
938 if !defs.is_empty() {
939 out.push('\n');
940 out.push_str(&inputs::format_inputs_help(&defs));
941 }
942 return out;
943 }
944
945 if !children.is_empty() {
946 out.push_str("Subcommands:\n");
947 for (name, child) in children {
948 let line = if child.about.is_empty() {
949 format!(" {name}\n")
950 } else {
951 format!(" {name} — {}\n", first_line(&child.about))
952 };
953 out.push_str(&line);
954 }
955 out.push('\n');
956 out.push_str(&format!(
957 "Use `{} --help` for more about a subcommand.\n",
958 full_cmd
959 ));
960 let defs = inputs::collect_chain_inputs(chain, spec);
961 if !defs.is_empty() {
962 out.push('\n');
963 out.push_str(&inputs::format_inputs_help(&defs));
964 }
965 } else if exec.is_none() {
966 out.push_str("(No subcommands defined.)\n");
967 }
968 if chain.is_empty() && node.is_none() {
969 out.push_str(
970 "\nPlace `--help` or `-h` right after the subcommand prefix you want. Built-ins: `use`, `bundle`, `alias`, `list`, `search`, `show`, `validate`, `audit`, `cron`.\n",
971 );
972 }
973 out
974}
975
976#[derive(Debug, Clone)]
978pub struct SpecRootIdentity {
979 pub spec_dir: String,
981 pub root_yaml: String,
983}
984
985pub fn resolve_preferred_spec() -> Result<(PathBuf, SpecRootIdentity)> {
987 let cfg = config::load_user_config().context("load user config")?;
988 let Some(dir_s) = cfg
989 .jan_dir
990 .as_ref()
991 .map(|s| s.trim())
992 .filter(|s| !s.is_empty())
993 else {
994 bail!(
995 "no preferred jan directory configured\n\
996 Run `jan use <DIR>` to save a YAML command tree (see docs/PORTABLE_SCRIPTS.md)"
997 );
998 };
999 let dir = PathBuf::from(dir_s);
1000 if !dir.is_dir() {
1001 bail!(
1002 "preferred jan directory does not exist: {}\n\
1003 Fix the path or run `jan use <DIR>` again (config: {})",
1004 dir.display(),
1005 config::config_path().display()
1006 );
1007 }
1008 let root = cfg
1009 .spec_root
1010 .as_deref()
1011 .map(str::trim)
1012 .filter(|s| !s.is_empty())
1013 .unwrap_or("scripts.spec.yaml");
1014 resolve_spec_dir_entry(&dir, root)
1015}
1016
1017pub fn resolve_spec_dir_entry(
1019 spec_dir: &Path,
1020 root_yaml: &str,
1021) -> Result<(PathBuf, SpecRootIdentity)> {
1022 let rel = Path::new(root_yaml);
1023 if rel.is_absolute() {
1024 bail!("entry YAML must be a relative file name, not an absolute path");
1025 }
1026 if rel
1027 .components()
1028 .any(|c| matches!(c, std::path::Component::ParentDir))
1029 {
1030 bail!("entry YAML must not contain `..`");
1031 }
1032 let normal_only = rel
1033 .components()
1034 .all(|c| matches!(c, std::path::Component::Normal(_)));
1035 let n = rel
1036 .components()
1037 .filter(|c| matches!(c, std::path::Component::Normal(_)))
1038 .count();
1039 if !normal_only || n != 1 {
1040 bail!("entry YAML must be a single file name inside the jan directory");
1041 }
1042 let dir = spec_dir
1043 .canonicalize()
1044 .with_context(|| format!("canonicalize jan directory {}", spec_dir.display()))?;
1045 if !dir.is_dir() {
1046 bail!("not a directory: {}", dir.display());
1047 }
1048 let spec_path = dir.join(rel);
1049 if !spec_path.is_file() {
1050 bail!(
1051 "spec entry not found: {} (under {})\n\
1052 Expected a properly formatted jan directory (e.g. scripts.spec.yaml)",
1053 spec_path.display(),
1054 dir.display()
1055 );
1056 }
1057 let identity = SpecRootIdentity {
1058 spec_dir: dir.to_string_lossy().into_owned(),
1059 root_yaml: rel
1060 .file_name()
1061 .expect("relative root has file_name")
1062 .to_string_lossy()
1063 .into_owned(),
1064 };
1065 Ok((spec_path, identity))
1066}
1067
1068pub struct RunContext<'a> {
1069 pub cwd: &'a Path,
1070 pub db_path: Option<&'a Path>,
1071 pub branch: String,
1072 pub no_log: bool,
1073 pub spec_root: &'a SpecRootIdentity,
1074}
1075
1076fn shell_inline_c_needs_argv0(argv: &[String]) -> bool {
1083 if argv.len() != 3 {
1084 return false;
1085 }
1086 let prog = Path::new(&argv[0])
1087 .file_name()
1088 .and_then(|s| s.to_str())
1089 .unwrap_or(argv[0].as_str());
1090 let is_shell = matches!(
1091 prog,
1092 "bash" | "zsh" | "sh" | "dash" | "ksh" | "ash" | "busybox"
1093 );
1094 is_shell && matches!(argv[1].as_str(), "-c" | "-lc")
1095}
1096
1097fn shell_passthrough_argv0(chain: &[String]) -> String {
1098 chain
1099 .iter()
1100 .rev()
1101 .find(|s| s.as_str() != "run")
1102 .cloned()
1103 .or_else(|| chain.last().cloned())
1104 .unwrap_or_else(|| "jan".to_string())
1105}
1106
1107pub fn run_matched(
1108 spec: &RootSpec,
1109 chain: &[String],
1110 node: &CommandNode,
1111 trailing: &[OsString],
1112 ctx: &RunContext<'_>,
1113) -> Result<i32> {
1114 let exec = match &node.exec {
1115 Some(e) => e,
1116 None => {
1117 let help = format_help(spec, chain, Some(node));
1118 print!("{help}");
1119 bail!("missing subcommand");
1120 }
1121 };
1122 exec.validate(&chain.join(" "))?;
1123
1124 let input_defs = inputs::collect_chain_inputs(chain, spec);
1125 let (input_vals, rest) = inputs::resolve_inputs(&input_defs, trailing)?;
1126
1127 let mut argv: Vec<String> = Vec::with_capacity(exec.argv.len() + 1);
1128 for a in &exec.argv {
1129 argv.push(inputs::interpolate(a, &input_vals)?);
1130 }
1131
1132 if exec.is_remote() {
1133 let url = exec.url.as_deref().unwrap().trim();
1134 let hash = exec.sha256.as_deref().unwrap().trim();
1135 let mut opts = remote::FetchOpts::new();
1136 if let Some(ttl) = exec.ttl {
1137 opts = opts.with_ttl(ttl);
1138 }
1139 let cached = remote::fetch_verified(url, hash, &opts, true)?;
1140 argv.push(cached.to_string_lossy().into_owned());
1141 } else if exec.is_local_file() {
1142 let rel = exec.file.as_deref().unwrap().trim();
1143 let use_root = Path::new(&ctx.spec_root.spec_dir);
1144 let resolved = spec_load::resolve_under_use_root(use_root, rel)?;
1145 if let Some(hash) = exec
1146 .sha256
1147 .as_deref()
1148 .map(str::trim)
1149 .filter(|s| !s.is_empty())
1150 {
1151 remote::verify_file_sha256(&resolved, hash)
1152 .with_context(|| format!("verify exec.file `{rel}`"))?;
1153 }
1154 argv.push(resolved.to_string_lossy().into_owned());
1155 } else if argv.is_empty() {
1156 bail!("exec.argv must not be empty");
1157 }
1158
1159 if exec.passthrough {
1160 let mut rest = rest;
1161 if rest.first().is_some_and(|a| a == "--") {
1165 rest = rest[1..].to_vec();
1166 }
1167 if shell_inline_c_needs_argv0(&argv) {
1168 argv.push(shell_passthrough_argv0(chain));
1169 }
1170 for a in &rest {
1171 argv.push(a.to_string_lossy().into_owned());
1172 }
1173 } else if !rest.is_empty() {
1174 let preview = rest
1175 .iter()
1176 .take(3)
1177 .map(|s| s.to_string_lossy().into_owned())
1178 .collect::<Vec<_>>()
1179 .join(" ");
1180 bail!(
1181 "unexpected trailing arguments: {preview}{}",
1182 if rest.len() > 3 { "…" } else { "" }
1183 );
1184 }
1185
1186 let cmd_path = if chain.is_empty() {
1187 "(root)".to_string()
1188 } else {
1189 chain.join(" ")
1190 };
1191
1192 let (_, requires, _) = deps::collect_chain_metadata(chain, spec);
1193 deps::check_requires(&requires)?;
1194
1195 let pkgs = packages::collect_chain_packages(chain, spec);
1196 let pkg_envs = packages::ensure_packages(&pkgs, ctx)?;
1197
1198 let path_dirs = deps::resolve_path_prefixes(spec, chain, ctx)?;
1199 let program = packages::resolve_program_with_envs(&argv[0], &pkg_envs, &path_dirs)?;
1200 let mut env_spec = deps::collect_chain_env(chain, spec);
1201 for value in env_spec.public.values_mut() {
1202 *value = inputs::interpolate(value, &input_vals)?;
1203 }
1204 deps::check_private_env(&env_spec.private)?;
1205 let mut path_override = if !path_dirs.is_empty() {
1206 Some(deps::prepend_path_env(&path_dirs)?)
1207 } else {
1208 None
1209 };
1210 if !pkg_envs.is_empty() {
1211 path_override = Some(packages::prepend_env_paths(&pkg_envs, path_override)?);
1212 }
1213 if let Some(node_path) = packages::node_path_for(&pkg_envs) {
1214 env_spec
1215 .public
1216 .entry("NODE_PATH".to_string())
1217 .or_insert(node_path);
1218 }
1219 if let Some(classpath) = packages::classpath_for(&pkg_envs) {
1220 env_spec
1221 .public
1222 .entry("CLASSPATH".to_string())
1223 .or_insert(classpath);
1224 }
1225
1226 let mut c = Command::new(&program);
1227 if argv.len() > 1 {
1228 c.args(&argv[1..]);
1229 }
1230 c.current_dir(ctx.cwd);
1231 deps::apply_process_env(&mut c, &env_spec, path_override)?;
1232
1233 let status = c.status().with_context(|| format!("spawn `{}`", argv[0]))?;
1234 let code = status.code().unwrap_or(255);
1235
1236 if !ctx.no_log {
1237 if let Some(db) = ctx.db_path {
1238 log_invocation(
1239 db,
1240 &ctx.branch,
1241 ctx.cwd,
1242 &cmd_path,
1243 &argv,
1244 code,
1245 ctx.spec_root,
1246 )?;
1247 }
1248 }
1249
1250 Ok(code)
1251}
1252
1253fn ensure_invocations_spec_root_column(conn: &Connection) -> Result<()> {
1254 let mut stmt = conn.prepare("PRAGMA table_info(invocations)")?;
1255 let cols: Vec<String> = stmt
1256 .query_map([], |row| row.get::<_, String>(1))?
1257 .collect::<std::result::Result<_, _>>()?;
1258 if !cols.iter().any(|c| c == "spec_root_id") {
1259 conn.execute(
1260 "ALTER TABLE invocations ADD COLUMN spec_root_id INTEGER",
1261 [],
1262 )?;
1263 }
1264 Ok(())
1265}
1266
1267fn upsert_spec_root(conn: &Connection, spec: &SpecRootIdentity) -> Result<i64> {
1268 let ts = unix_ts();
1269 conn.execute(
1270 r"INSERT INTO spec_roots (spec_dir, root_yaml, last_used_ts) VALUES (?1, ?2, ?3)
1271 ON CONFLICT(spec_dir, root_yaml) DO UPDATE SET last_used_ts = excluded.last_used_ts",
1272 rusqlite::params![&spec.spec_dir, &spec.root_yaml, &ts],
1273 )?;
1274 let id: i64 = conn.query_row(
1275 "SELECT id FROM spec_roots WHERE spec_dir = ?1 AND root_yaml = ?2",
1276 [&spec.spec_dir, &spec.root_yaml],
1277 |r| r.get(0),
1278 )?;
1279 Ok(id)
1280}
1281
1282fn log_invocation(
1283 db_path: &Path,
1284 branch: &str,
1285 cwd: &Path,
1286 command_path: &str,
1287 argv: &[String],
1288 exit_code: i32,
1289 spec_root: &SpecRootIdentity,
1290) -> Result<()> {
1291 if let Some(parent) = db_path.parent() {
1292 std::fs::create_dir_all(parent).ok();
1293 }
1294 let conn = Connection::open(db_path).with_context(|| format!("open {}", db_path.display()))?;
1295 conn.execute_batch(
1296 r"
1297 CREATE TABLE IF NOT EXISTS spec_roots (
1298 id INTEGER PRIMARY KEY AUTOINCREMENT,
1299 spec_dir TEXT NOT NULL,
1300 root_yaml TEXT NOT NULL,
1301 last_used_ts TEXT NOT NULL,
1302 UNIQUE(spec_dir, root_yaml)
1303 );
1304 CREATE TABLE IF NOT EXISTS invocations (
1305 id INTEGER PRIMARY KEY AUTOINCREMENT,
1306 ts TEXT NOT NULL,
1307 git_branch TEXT NOT NULL,
1308 cwd TEXT NOT NULL,
1309 command_path TEXT NOT NULL,
1310 argv_json TEXT NOT NULL,
1311 exit_code INTEGER NOT NULL,
1312 spec_root_id INTEGER
1313 );
1314 ",
1315 )?;
1316 ensure_invocations_spec_root_column(&conn)?;
1317 let spec_root_id = upsert_spec_root(&conn, spec_root)?;
1318 let ts = unix_ts();
1319 let argv_json = serde_json::to_string(argv).unwrap_or_else(|_| "[]".to_string());
1320 let cwd_s = cwd.to_string_lossy();
1321 conn.execute(
1322 "INSERT INTO invocations (ts, git_branch, cwd, command_path, argv_json, exit_code, spec_root_id)
1323 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
1324 rusqlite::params![
1325 ts,
1326 branch,
1327 cwd_s.as_ref(),
1328 command_path,
1329 argv_json,
1330 exit_code,
1331 spec_root_id
1332 ],
1333 )?;
1334 Ok(())
1335}
1336
1337fn unix_ts() -> String {
1338 use std::time::SystemTime;
1339 SystemTime::now()
1340 .duration_since(std::time::UNIX_EPOCH)
1341 .unwrap_or_default()
1342 .as_secs()
1343 .to_string()
1344}
1345
1346#[derive(Debug)]
1347pub struct MatchOutcome<'a> {
1348 pub chain: Vec<String>,
1349 pub node: Option<&'a CommandNode>,
1350 pub trailing: Vec<OsString>,
1351 pub wants_help: bool,
1352}
1353
1354pub fn match_commands<'a>(spec: &'a RootSpec, args: &[OsString]) -> MatchOutcome<'a> {
1355 let mut chain = Vec::new();
1356 let mut node: Option<&'a CommandNode> = None;
1357 let mut map: &BTreeMap<String, CommandNode> = &spec.commands;
1358 let mut i = 0usize;
1359 let len = args.len();
1360 while i < len {
1361 let raw = &args[i];
1362 if raw == "--help" || raw == "-h" {
1363 return MatchOutcome {
1364 chain,
1365 node,
1366 trailing: args[i + 1..].to_vec(),
1367 wants_help: true,
1368 };
1369 }
1370 let key = raw.to_string_lossy();
1371 if let Some(next) = map.get(key.as_ref()) {
1372 chain.push(key.into_owned());
1373 node = Some(next);
1374 map = &next.commands;
1375 i += 1;
1376 continue;
1377 }
1378 break;
1379 }
1380 MatchOutcome {
1381 chain,
1382 node,
1383 trailing: args[i..].to_vec(),
1384 wants_help: false,
1385 }
1386}
1387
1388#[cfg(test)]
1389mod tests {
1390 use super::*;
1391 use std::io::Write;
1392
1393 #[test]
1394 fn examples_default_spec_validates() {
1395 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/default.spec.yaml");
1396 load_spec(&path).unwrap();
1397 }
1398
1399 #[test]
1400 fn merge_specs_adds_and_replaces_leaves() {
1401 let mut base = load_spec_from_str(
1402 r"
1403commands:
1404 a:
1405 about: base
1406 commands:
1407 x:
1408 about: old
1409 exec:
1410 argv: [echo, old]
1411",
1412 None,
1413 )
1414 .unwrap();
1415 let overlay = load_spec_from_str(
1416 r"
1417commands:
1418 a:
1419 commands:
1420 x:
1421 about: new leaf
1422 exec:
1423 argv: [echo, new]
1424 b:
1425 about: added top
1426 exec:
1427 argv: [echo, b]
1428",
1429 None,
1430 )
1431 .unwrap();
1432 merge_specs_into(&mut base, overlay).unwrap();
1433 base.commands["a"].commands["x"].validate("a x").unwrap();
1434 assert_eq!(
1435 base.commands["a"].commands["x"].exec.as_ref().unwrap().argv,
1436 vec!["echo", "new"]
1437 );
1438 assert_eq!(
1439 base.commands["b"].exec.as_ref().unwrap().argv,
1440 vec!["echo", "b"]
1441 );
1442 }
1443
1444 #[test]
1445 fn validate_rejects_exec_with_children() {
1446 let mut tmp = tempfile::NamedTempFile::with_suffix(".yaml").unwrap();
1447 write!(
1448 tmp,
1449 r"
1450commands:
1451 x:
1452 exec:
1453 argv: [echo]
1454 commands:
1455 child:
1456 about: nested
1457"
1458 )
1459 .unwrap();
1460 let err = load_spec(tmp.path()).unwrap_err();
1461 assert!(err.to_string().contains("cannot define both"));
1462 }
1463
1464 #[test]
1465 fn shell_inline_c_needs_argv0_detects_bash_lc() {
1466 let argv = vec![
1467 "bash".into(),
1468 "-lc".into(),
1469 "case \"$1\" in create) ;; esac".into(),
1470 ];
1471 assert!(shell_inline_c_needs_argv0(&argv));
1472 let with_placeholder = vec!["zsh".into(), "-c".into(), "echo".into(), "issue".into()];
1473 assert!(!shell_inline_c_needs_argv0(&with_placeholder));
1474 assert!(!shell_inline_c_needs_argv0(&[
1475 "echo".into(),
1476 "start".into()
1477 ]));
1478 assert!(!shell_inline_c_needs_argv0(&[
1479 "python3".into(),
1480 "-c".into(),
1481 "print(1)".into()
1482 ]));
1483 }
1484
1485 #[test]
1486 fn shell_passthrough_argv0_skips_run_leaf() {
1487 assert_eq!(
1488 shell_passthrough_argv0(&[
1489 "scripts".into(),
1490 "misc".into(),
1491 "issue".into(),
1492 "run".into()
1493 ]),
1494 "issue"
1495 );
1496 assert_eq!(shell_passthrough_argv0(&["probe".into()]), "probe");
1497 }
1498}
1499
1500pub fn default_db_path() -> PathBuf {
1501 if let Ok(p) = std::env::var("JAN_DB") {
1502 return PathBuf::from(p);
1503 }
1504 dirs::data_local_dir()
1505 .unwrap_or_else(|| PathBuf::from("."))
1506 .join("jan-cli")
1507 .join("audit.db")
1508}