1mod builtins;
2mod config;
3mod cron;
4mod deps;
5mod inputs;
6mod inspect;
7pub mod remote;
8mod runner;
9mod spec_load;
10mod yaml_closure;
11
12pub use config::{load_user_config, UserConfig};
13pub use runner::run_jan;
14pub use spec_load::HostPlatform;
15
16use std::collections::BTreeMap;
17use std::ffi::OsString;
18use std::path::{Path, PathBuf};
19use std::process::Command;
20
21use anyhow::{bail, Context, Result};
22use rusqlite::Connection;
23use serde::Deserialize;
24use serde::de::{self, Deserializer, Visitor};
25use std::fmt;
26
27#[derive(Debug, Deserialize)]
28pub struct RootSpec {
29 pub metadata: Option<Metadata>,
30 #[serde(default)]
31 pub commands: BTreeMap<String, CommandNode>,
32}
33
34#[derive(Debug, Deserialize)]
35pub struct Metadata {
36 pub name: Option<String>,
37 pub description: Option<String>,
38}
39
40#[derive(Debug, Default, Clone, PartialEq, Eq)]
67pub struct EnvSpec {
68 pub public: BTreeMap<String, String>,
69 pub private: Vec<String>,
70 pub pass: BTreeMap<String, String>,
72}
73
74impl EnvSpec {
75 pub fn is_empty(&self) -> bool {
76 self.public.is_empty() && self.private.is_empty() && self.pass.is_empty()
77 }
78
79 pub fn restricts_child_env(&self) -> bool {
81 !self.is_empty()
82 }
83
84 pub fn merge_from(&mut self, other: EnvSpec) {
85 for (k, v) in other.public {
86 self.public.insert(k, v);
87 }
88 for name in other.private {
89 if !self.private.iter().any(|p| p == &name) {
90 self.private.push(name);
91 }
92 }
93 for (k, v) in other.pass {
94 self.pass.insert(k, v);
95 }
96 }
97
98 pub fn validate(&self, path: &str) -> Result<()> {
100 for name in &self.private {
101 if name.trim().is_empty() {
102 bail!("command '{path}': env.private entry must not be empty");
103 }
104 }
105 for (env_name, pass_id) in &self.pass {
106 if env_name.trim().is_empty() {
107 bail!("command '{path}': env.pass key must not be empty");
108 }
109 if pass_id.trim().is_empty() {
110 bail!("command '{path}': env.pass id for `{env_name}` must not be empty");
111 }
112 if self.private.iter().any(|p| p == env_name) {
113 bail!(
114 "command '{path}': env var `{env_name}` cannot be both `env.private` and `env.pass`"
115 );
116 }
117 }
118 Ok(())
119 }
120}
121
122impl<'de> Deserialize<'de> for EnvSpec {
123 fn deserialize<D>(deserializer: D) -> Result<Self, D::Error>
124 where
125 D: Deserializer<'de>,
126 {
127 #[derive(Deserialize)]
128 struct Structured {
129 #[serde(default)]
130 public: BTreeMap<String, String>,
131 #[serde(default, deserialize_with = "deserialize_string_or_seq")]
132 private: Vec<String>,
133 #[serde(default)]
134 pass: BTreeMap<String, String>,
135 }
136
137 #[derive(Deserialize)]
138 #[serde(untagged)]
139 enum EnvDe {
140 Flat(BTreeMap<String, String>),
141 Sections(Structured),
142 }
143
144 Ok(match EnvDe::deserialize(deserializer)? {
145 EnvDe::Flat(public) => Self {
146 public,
147 private: Vec::new(),
148 pass: BTreeMap::new(),
149 },
150 EnvDe::Sections(s) => Self {
151 public: s.public,
152 private: s.private,
153 pass: s.pass,
154 },
155 })
156 }
157}
158
159#[derive(Debug, Deserialize, Default, Clone)]
160pub struct CommandNode {
161 #[serde(default)]
164 pub os: Vec<String>,
165 #[serde(default)]
166 pub about: String,
167 pub path: Option<String>,
169 #[serde(default)]
171 pub dependencies: Vec<String>,
172 #[serde(default)]
174 pub requires: Vec<String>,
175 #[serde(default)]
177 pub env: EnvSpec,
178 #[serde(default)]
180 pub inputs: BTreeMap<String, crate::inputs::InputDef>,
181 #[serde(default, deserialize_with = "deserialize_string_or_seq")]
184 pub cron: Vec<String>,
185 #[serde(default)]
186 pub commands: BTreeMap<String, CommandNode>,
187 pub exec: Option<ExecSpec>,
188}
189
190pub(crate) fn deserialize_string_or_seq<'de, D>(deserializer: D) -> Result<Vec<String>, D::Error>
191where
192 D: Deserializer<'de>,
193{
194 struct StringOrSeq;
195
196 impl<'de> Visitor<'de> for StringOrSeq {
197 type Value = Vec<String>;
198
199 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
200 formatter.write_str("a string or a sequence of strings")
201 }
202
203 fn visit_str<E>(self, value: &str) -> Result<Self::Value, E>
204 where
205 E: de::Error,
206 {
207 if value.trim().is_empty() {
208 Ok(Vec::new())
209 } else {
210 Ok(vec![value.to_string()])
211 }
212 }
213
214 fn visit_string<E>(self, value: String) -> Result<Self::Value, E>
215 where
216 E: de::Error,
217 {
218 self.visit_str(&value)
219 }
220
221 fn visit_seq<A>(self, mut seq: A) -> Result<Self::Value, A::Error>
222 where
223 A: de::SeqAccess<'de>,
224 {
225 let mut out = Vec::new();
226 while let Some(s) = seq.next_element::<String>()? {
227 if !s.trim().is_empty() {
228 out.push(s);
229 }
230 }
231 Ok(out)
232 }
233
234 fn visit_none<E>(self) -> Result<Self::Value, E>
235 where
236 E: de::Error,
237 {
238 Ok(Vec::new())
239 }
240
241 fn visit_unit<E>(self) -> Result<Self::Value, E>
242 where
243 E: de::Error,
244 {
245 Ok(Vec::new())
246 }
247 }
248
249 deserializer.deserialize_any(StringOrSeq)
250}
251
252#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
254#[serde(untagged)]
255pub enum IncludeRef {
256 Local(String),
258 Remote(RemoteInclude),
260}
261
262#[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
263pub struct RemoteInclude {
264 pub url: String,
265 pub sha256: String,
266 #[serde(default)]
267 pub ttl: Option<u64>,
268}
269
270impl IncludeRef {
271 pub fn is_remote(&self) -> bool {
272 matches!(self, Self::Remote(_))
273 }
274
275 pub fn cycle_token(&self) -> String {
276 match self {
277 Self::Local(p) => p.clone(),
278 Self::Remote(r) => format!("{}#{}", r.url, r.sha256.to_ascii_lowercase()),
279 }
280 }
281}
282
283#[derive(Debug, Deserialize, Clone, Default)]
284pub struct ExecSpec {
285 #[serde(default)]
288 pub argv: Vec<String>,
289 #[serde(default)]
291 pub passthrough: bool,
292 #[serde(default)]
294 pub url: Option<String>,
295 #[serde(default)]
297 pub sha256: Option<String>,
298 #[serde(default)]
300 pub ttl: Option<u64>,
301}
302
303impl ExecSpec {
304 pub fn is_remote(&self) -> bool {
305 self.url
306 .as_deref()
307 .map(|u| !u.trim().is_empty())
308 .unwrap_or(false)
309 }
310
311 pub fn validate(&self, path: &str) -> Result<()> {
312 let url = self
313 .url
314 .as_deref()
315 .map(str::trim)
316 .filter(|s| !s.is_empty());
317 let hash = self
318 .sha256
319 .as_deref()
320 .map(str::trim)
321 .filter(|s| !s.is_empty());
322 match (url, hash) {
323 (Some(_), Some(_)) => Ok(()),
324 (Some(_), None) => {
325 bail!("command '{path}': exec.sha256 is required with exec.url")
326 }
327 (None, Some(_)) => {
328 bail!("command '{path}': exec.sha256 requires exec.url")
329 }
330 (None, None) => {
331 if self.argv.is_empty() {
332 bail!(
333 "command '{path}': exec.argv must not be empty (or set exec.url + exec.sha256)"
334 );
335 }
336 Ok(())
337 }
338 }
339 }
340}
341
342impl CommandNode {
343 pub fn is_leaf_exec(&self) -> bool {
344 self.exec.is_some()
345 }
346
347 pub fn validate(&self, path: &str) -> Result<()> {
348 if self.exec.is_some() && !self.commands.is_empty() {
349 bail!("command '{path}' cannot define both `exec` and nested `commands`");
350 }
351 if let Some(ref e) = self.exec {
352 e.validate(path)?;
353 }
354 self.env.validate(path)?;
355 for name in self.inputs.keys() {
356 inputs::InputDef::validate_name(name)
357 .map_err(|e| anyhow::anyhow!("command '{path}': {e}"))?;
358 }
359 for (name, child) in &self.commands {
360 let p = if path.is_empty() {
361 name.clone()
362 } else {
363 format!("{path} {name}")
364 };
365 child.validate(&p)?;
366 }
367 Ok(())
368 }
369}
370
371pub fn merge_specs_into(base: &mut RootSpec, overlay: RootSpec) -> Result<()> {
374 for (name, node) in overlay.commands {
375 match base.commands.get_mut(&name) {
376 Some(existing) => merge_command_node(existing, node)?,
377 None => {
378 base.commands.insert(name, node);
379 }
380 }
381 }
382 Ok(())
383}
384
385fn merge_command_node(dst: &mut CommandNode, src: CommandNode) -> Result<()> {
386 if src.exec.is_some() && !src.commands.is_empty() {
387 bail!("merge overlay: command cannot define both `exec` and nested `commands`");
388 }
389 if !src.os.is_empty() {
390 dst.os = src.os;
391 }
392 if !src.about.trim().is_empty() {
393 dst.about = src.about;
394 }
395 if src.path.is_some() {
396 dst.path = src.path;
397 }
398 if !src.dependencies.is_empty() {
399 dst.dependencies = src.dependencies;
400 }
401 if !src.requires.is_empty() {
402 dst.requires = src.requires;
403 }
404 if !src.cron.is_empty() {
405 dst.cron = src.cron;
406 }
407 if !src.env.is_empty() {
408 dst.env.merge_from(src.env);
409 }
410 for (k, v) in src.inputs {
411 dst.inputs.insert(k, v);
412 }
413 if let Some(exec) = src.exec {
414 dst.exec = Some(exec);
415 dst.commands.clear();
416 return Ok(());
417 }
418 if !src.commands.is_empty() {
419 dst.exec = None;
420 for (k, child) in src.commands {
421 match dst.commands.get_mut(&k) {
422 Some(existing) => merge_command_node(existing, child)?,
423 None => {
424 dst.commands.insert(k, child);
425 }
426 }
427 }
428 }
429 Ok(())
430}
431
432pub fn validate_spec(spec: &RootSpec) -> Result<()> {
434 for (name, node) in &spec.commands {
435 node.validate(name)?;
436 }
437 Ok(())
438}
439
440pub fn load_spec_from_str(raw: &str, include_base: Option<&Path>) -> Result<RootSpec> {
442 spec_load::load_spec_from_str(raw, include_base, HostPlatform::detect())
443}
444
445pub fn load_spec(path: &Path) -> Result<RootSpec> {
446 spec_load::load_spec_from_path(path, HostPlatform::detect())
447}
448
449pub fn resolve_git_branch(cwd: &Path, override_branch: Option<&str>) -> String {
450 if let Some(b) = override_branch {
451 if !b.is_empty() {
452 return b.to_string();
453 }
454 }
455 if let Ok(v) = std::env::var("JAN_BRANCH") {
456 if !v.is_empty() {
457 return v;
458 }
459 }
460 let output = Command::new("git")
461 .args(["rev-parse", "--abbrev-ref", "HEAD"])
462 .current_dir(cwd)
463 .output();
464 match output {
465 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
466 _ => "(no-git)".to_string(),
467 }
468}
469
470fn first_line(s: &str) -> String {
471 s.lines().next().unwrap_or("").trim().to_string()
472}
473
474pub fn format_help(spec: &RootSpec, chain: &[String], node: Option<&CommandNode>) -> String {
475 let mut out = String::new();
476 let bin = spec
477 .metadata
478 .as_ref()
479 .and_then(|m| m.name.as_deref())
480 .unwrap_or("jan");
481 let full_cmd = if chain.is_empty() {
482 bin.to_string()
483 } else {
484 format!("{} {}", bin, chain.join(" "))
485 };
486
487 let (about, children, exec) = match node {
488 Some(n) => (n.about.as_str(), &n.commands, n.exec.as_ref()),
489 None => ("", &spec.commands, None),
490 };
491
492 if chain.is_empty() {
493 if let Some(meta) = &spec.metadata {
494 if let Some(desc) = &meta.description {
495 out.push_str(desc.trim());
496 out.push_str("\n\n");
497 }
498 }
499 }
500
501 if !about.is_empty() {
502 out.push_str(about.trim());
503 out.push_str("\n\n");
504 }
505
506 if exec.is_some() && children.is_empty() {
507 out.push_str("This command runs an external program (see spec `exec.argv`).\n");
508 let defs = inputs::collect_chain_inputs(chain, spec);
509 if !defs.is_empty() {
510 out.push('\n');
511 out.push_str(&inputs::format_inputs_help(&defs));
512 }
513 return out;
514 }
515
516 if !children.is_empty() {
517 out.push_str("Subcommands:\n");
518 for (name, child) in children {
519 let line = if child.about.is_empty() {
520 format!(" {name}\n")
521 } else {
522 format!(" {name} — {}\n", first_line(&child.about))
523 };
524 out.push_str(&line);
525 }
526 out.push('\n');
527 out.push_str(&format!(
528 "Use `{} --help` for more about a subcommand.\n",
529 full_cmd
530 ));
531 let defs = inputs::collect_chain_inputs(chain, spec);
532 if !defs.is_empty() {
533 out.push('\n');
534 out.push_str(&inputs::format_inputs_help(&defs));
535 }
536 } else if exec.is_none() {
537 out.push_str("(No subcommands defined.)\n");
538 }
539 if chain.is_empty() && node.is_none() {
540 out.push_str(
541 "\nPlace `--help` or `-h` right after the subcommand prefix you want. Built-ins: `use`, `bundle`, `alias`, `list`, `search`, `show`, `validate`, `audit`, `cron`.\n",
542 );
543 }
544 out
545}
546
547#[derive(Debug, Clone)]
549pub struct SpecRootIdentity {
550 pub spec_dir: String,
552 pub root_yaml: String,
554}
555
556pub fn resolve_preferred_spec() -> Result<(PathBuf, SpecRootIdentity)> {
558 let cfg = config::load_user_config().context("load user config")?;
559 let Some(dir_s) = cfg
560 .jan_dir
561 .as_ref()
562 .map(|s| s.trim())
563 .filter(|s| !s.is_empty())
564 else {
565 bail!(
566 "no preferred jan directory configured\n\
567 Run `jan use <DIR>` to save a YAML command tree (see docs/PORTABLE_SCRIPTS.md)"
568 );
569 };
570 let dir = PathBuf::from(dir_s);
571 if !dir.is_dir() {
572 bail!(
573 "preferred jan directory does not exist: {}\n\
574 Fix the path or run `jan use <DIR>` again (config: {})",
575 dir.display(),
576 config::config_path().display()
577 );
578 }
579 let root = cfg
580 .spec_root
581 .as_deref()
582 .map(str::trim)
583 .filter(|s| !s.is_empty())
584 .unwrap_or("scripts.spec.yaml");
585 resolve_spec_dir_entry(&dir, root)
586}
587
588pub fn resolve_spec_dir_entry(
590 spec_dir: &Path,
591 root_yaml: &str,
592) -> Result<(PathBuf, SpecRootIdentity)> {
593 let rel = Path::new(root_yaml);
594 if rel.is_absolute() {
595 bail!("entry YAML must be a relative file name, not an absolute path");
596 }
597 if rel
598 .components()
599 .any(|c| matches!(c, std::path::Component::ParentDir))
600 {
601 bail!("entry YAML must not contain `..`");
602 }
603 let normal_only = rel
604 .components()
605 .all(|c| matches!(c, std::path::Component::Normal(_)));
606 let n = rel
607 .components()
608 .filter(|c| matches!(c, std::path::Component::Normal(_)))
609 .count();
610 if !normal_only || n != 1 {
611 bail!("entry YAML must be a single file name inside the jan directory");
612 }
613 let dir = spec_dir
614 .canonicalize()
615 .with_context(|| format!("canonicalize jan directory {}", spec_dir.display()))?;
616 if !dir.is_dir() {
617 bail!("not a directory: {}", dir.display());
618 }
619 let spec_path = dir.join(rel);
620 if !spec_path.is_file() {
621 bail!(
622 "spec entry not found: {} (under {})\n\
623 Expected a properly formatted jan directory (e.g. scripts.spec.yaml)",
624 spec_path.display(),
625 dir.display()
626 );
627 }
628 let identity = SpecRootIdentity {
629 spec_dir: dir.to_string_lossy().into_owned(),
630 root_yaml: rel
631 .file_name()
632 .expect("relative root has file_name")
633 .to_string_lossy()
634 .into_owned(),
635 };
636 Ok((spec_path, identity))
637}
638
639pub struct RunContext<'a> {
640 pub cwd: &'a Path,
641 pub db_path: Option<&'a Path>,
642 pub branch: String,
643 pub no_log: bool,
644 pub spec_root: &'a SpecRootIdentity,
645}
646
647fn shell_inline_c_needs_argv0(argv: &[String]) -> bool {
654 if argv.len() != 3 {
655 return false;
656 }
657 let prog = Path::new(&argv[0])
658 .file_name()
659 .and_then(|s| s.to_str())
660 .unwrap_or(argv[0].as_str());
661 let is_shell = matches!(
662 prog,
663 "bash" | "zsh" | "sh" | "dash" | "ksh" | "ash" | "busybox"
664 );
665 is_shell && matches!(argv[1].as_str(), "-c" | "-lc")
666}
667
668fn shell_passthrough_argv0(chain: &[String]) -> String {
669 chain
670 .iter()
671 .rev()
672 .find(|s| s.as_str() != "run")
673 .cloned()
674 .or_else(|| chain.last().cloned())
675 .unwrap_or_else(|| "jan".to_string())
676}
677
678pub fn run_matched(
679 spec: &RootSpec,
680 chain: &[String],
681 node: &CommandNode,
682 trailing: &[OsString],
683 ctx: &RunContext<'_>,
684) -> Result<i32> {
685 let exec = match &node.exec {
686 Some(e) => e,
687 None => {
688 let help = format_help(spec, chain, Some(node));
689 print!("{help}");
690 bail!("missing subcommand");
691 }
692 };
693 exec.validate(&chain.join(" "))?;
694
695 let input_defs = inputs::collect_chain_inputs(chain, spec);
696 let (input_vals, rest) = inputs::resolve_inputs(&input_defs, trailing)?;
697
698 let mut argv: Vec<String> = Vec::with_capacity(exec.argv.len() + 1);
699 for a in &exec.argv {
700 argv.push(inputs::interpolate(a, &input_vals)?);
701 }
702
703 if exec.is_remote() {
704 let url = exec.url.as_deref().unwrap().trim();
705 let hash = exec.sha256.as_deref().unwrap().trim();
706 let mut opts = remote::FetchOpts::new();
707 if let Some(ttl) = exec.ttl {
708 opts = opts.with_ttl(ttl);
709 }
710 let cached = remote::fetch_verified(url, hash, &opts, true)?;
711 argv.push(cached.to_string_lossy().into_owned());
712 } else if argv.is_empty() {
713 bail!("exec.argv must not be empty");
714 }
715
716 if exec.passthrough {
717 let mut rest = rest;
718 if shell_inline_c_needs_argv0(&argv) {
719 argv.push(shell_passthrough_argv0(chain));
720 if rest.first().is_some_and(|a| a == "--") {
723 rest = rest[1..].to_vec();
724 }
725 }
726 for a in &rest {
727 argv.push(a.to_string_lossy().into_owned());
728 }
729 } else if !rest.is_empty() {
730 let preview = rest
731 .iter()
732 .take(3)
733 .map(|s| s.to_string_lossy().into_owned())
734 .collect::<Vec<_>>()
735 .join(" ");
736 bail!(
737 "unexpected trailing arguments: {preview}{}",
738 if rest.len() > 3 { "…" } else { "" }
739 );
740 }
741
742 let cmd_path = if chain.is_empty() {
743 "(root)".to_string()
744 } else {
745 chain.join(" ")
746 };
747
748 let (_, requires, _) = deps::collect_chain_metadata(chain, spec);
749 deps::check_requires(&requires)?;
750
751 let path_dirs = deps::resolve_path_prefixes(spec, chain, ctx)?;
752 let program = deps::resolve_program(&argv[0], &path_dirs)?;
753 let mut env_spec = deps::collect_chain_env(chain, spec);
754 for value in env_spec.public.values_mut() {
755 *value = inputs::interpolate(value, &input_vals)?;
756 }
757 deps::check_private_env(&env_spec.private)?;
758 let path_override = if !path_dirs.is_empty() {
759 Some(deps::prepend_path_env(&path_dirs)?)
760 } else {
761 None
762 };
763
764 let mut c = Command::new(&program);
765 if argv.len() > 1 {
766 c.args(&argv[1..]);
767 }
768 c.current_dir(ctx.cwd);
769 deps::apply_process_env(&mut c, &env_spec, path_override)?;
770
771 let status = c.status().with_context(|| format!("spawn `{}`", argv[0]))?;
772 let code = status.code().unwrap_or(255);
773
774 if !ctx.no_log {
775 if let Some(db) = ctx.db_path {
776 log_invocation(
777 db,
778 &ctx.branch,
779 ctx.cwd,
780 &cmd_path,
781 &argv,
782 code,
783 ctx.spec_root,
784 )?;
785 }
786 }
787
788 Ok(code)
789}
790
791fn ensure_invocations_spec_root_column(conn: &Connection) -> Result<()> {
792 let mut stmt = conn.prepare("PRAGMA table_info(invocations)")?;
793 let cols: Vec<String> = stmt
794 .query_map([], |row| row.get::<_, String>(1))?
795 .collect::<std::result::Result<_, _>>()?;
796 if !cols.iter().any(|c| c == "spec_root_id") {
797 conn.execute(
798 "ALTER TABLE invocations ADD COLUMN spec_root_id INTEGER",
799 [],
800 )?;
801 }
802 Ok(())
803}
804
805fn upsert_spec_root(conn: &Connection, spec: &SpecRootIdentity) -> Result<i64> {
806 let ts = unix_ts();
807 conn.execute(
808 r"INSERT INTO spec_roots (spec_dir, root_yaml, last_used_ts) VALUES (?1, ?2, ?3)
809 ON CONFLICT(spec_dir, root_yaml) DO UPDATE SET last_used_ts = excluded.last_used_ts",
810 rusqlite::params![&spec.spec_dir, &spec.root_yaml, &ts],
811 )?;
812 let id: i64 = conn.query_row(
813 "SELECT id FROM spec_roots WHERE spec_dir = ?1 AND root_yaml = ?2",
814 [&spec.spec_dir, &spec.root_yaml],
815 |r| r.get(0),
816 )?;
817 Ok(id)
818}
819
820fn log_invocation(
821 db_path: &Path,
822 branch: &str,
823 cwd: &Path,
824 command_path: &str,
825 argv: &[String],
826 exit_code: i32,
827 spec_root: &SpecRootIdentity,
828) -> Result<()> {
829 if let Some(parent) = db_path.parent() {
830 std::fs::create_dir_all(parent).ok();
831 }
832 let conn = Connection::open(db_path).with_context(|| format!("open {}", db_path.display()))?;
833 conn.execute_batch(
834 r"
835 CREATE TABLE IF NOT EXISTS spec_roots (
836 id INTEGER PRIMARY KEY AUTOINCREMENT,
837 spec_dir TEXT NOT NULL,
838 root_yaml TEXT NOT NULL,
839 last_used_ts TEXT NOT NULL,
840 UNIQUE(spec_dir, root_yaml)
841 );
842 CREATE TABLE IF NOT EXISTS invocations (
843 id INTEGER PRIMARY KEY AUTOINCREMENT,
844 ts TEXT NOT NULL,
845 git_branch TEXT NOT NULL,
846 cwd TEXT NOT NULL,
847 command_path TEXT NOT NULL,
848 argv_json TEXT NOT NULL,
849 exit_code INTEGER NOT NULL,
850 spec_root_id INTEGER
851 );
852 ",
853 )?;
854 ensure_invocations_spec_root_column(&conn)?;
855 let spec_root_id = upsert_spec_root(&conn, spec_root)?;
856 let ts = unix_ts();
857 let argv_json = serde_json::to_string(argv).unwrap_or_else(|_| "[]".to_string());
858 let cwd_s = cwd.to_string_lossy();
859 conn.execute(
860 "INSERT INTO invocations (ts, git_branch, cwd, command_path, argv_json, exit_code, spec_root_id)
861 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
862 rusqlite::params![
863 ts,
864 branch,
865 cwd_s.as_ref(),
866 command_path,
867 argv_json,
868 exit_code,
869 spec_root_id
870 ],
871 )?;
872 Ok(())
873}
874
875fn unix_ts() -> String {
876 use std::time::SystemTime;
877 SystemTime::now()
878 .duration_since(std::time::UNIX_EPOCH)
879 .unwrap_or_default()
880 .as_secs()
881 .to_string()
882}
883
884#[derive(Debug)]
885pub struct MatchOutcome<'a> {
886 pub chain: Vec<String>,
887 pub node: Option<&'a CommandNode>,
888 pub trailing: Vec<OsString>,
889 pub wants_help: bool,
890}
891
892pub fn match_commands<'a>(spec: &'a RootSpec, args: &[OsString]) -> MatchOutcome<'a> {
893 let mut chain = Vec::new();
894 let mut node: Option<&'a CommandNode> = None;
895 let mut map: &BTreeMap<String, CommandNode> = &spec.commands;
896 let mut i = 0usize;
897 let len = args.len();
898 while i < len {
899 let raw = &args[i];
900 if raw == "--help" || raw == "-h" {
901 return MatchOutcome {
902 chain,
903 node,
904 trailing: args[i + 1..].to_vec(),
905 wants_help: true,
906 };
907 }
908 let key = raw.to_string_lossy();
909 if let Some(next) = map.get(key.as_ref()) {
910 chain.push(key.into_owned());
911 node = Some(next);
912 map = &next.commands;
913 i += 1;
914 continue;
915 }
916 break;
917 }
918 MatchOutcome {
919 chain,
920 node,
921 trailing: args[i..].to_vec(),
922 wants_help: false,
923 }
924}
925
926#[cfg(test)]
927mod tests {
928 use super::*;
929 use std::io::Write;
930
931 #[test]
932 fn examples_default_spec_validates() {
933 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/default.spec.yaml");
934 load_spec(&path).unwrap();
935 }
936
937 #[test]
938 fn merge_specs_adds_and_replaces_leaves() {
939 let mut base = load_spec_from_str(
940 r"
941commands:
942 a:
943 about: base
944 commands:
945 x:
946 about: old
947 exec:
948 argv: [echo, old]
949",
950 None,
951 )
952 .unwrap();
953 let overlay = load_spec_from_str(
954 r"
955commands:
956 a:
957 commands:
958 x:
959 about: new leaf
960 exec:
961 argv: [echo, new]
962 b:
963 about: added top
964 exec:
965 argv: [echo, b]
966",
967 None,
968 )
969 .unwrap();
970 merge_specs_into(&mut base, overlay).unwrap();
971 base.commands["a"].commands["x"].validate("a x").unwrap();
972 assert_eq!(
973 base.commands["a"].commands["x"].exec.as_ref().unwrap().argv,
974 vec!["echo", "new"]
975 );
976 assert_eq!(
977 base.commands["b"].exec.as_ref().unwrap().argv,
978 vec!["echo", "b"]
979 );
980 }
981
982 #[test]
983 fn validate_rejects_exec_with_children() {
984 let mut tmp = tempfile::NamedTempFile::with_suffix(".yaml").unwrap();
985 write!(
986 tmp,
987 r"
988commands:
989 x:
990 exec:
991 argv: [echo]
992 commands:
993 child:
994 about: nested
995"
996 )
997 .unwrap();
998 let err = load_spec(tmp.path()).unwrap_err();
999 assert!(err.to_string().contains("cannot define both"));
1000 }
1001
1002 #[test]
1003 fn shell_inline_c_needs_argv0_detects_bash_lc() {
1004 let argv = vec![
1005 "bash".into(),
1006 "-lc".into(),
1007 "case \"$1\" in create) ;; esac".into(),
1008 ];
1009 assert!(shell_inline_c_needs_argv0(&argv));
1010 let with_placeholder = vec![
1011 "zsh".into(),
1012 "-c".into(),
1013 "echo".into(),
1014 "issue".into(),
1015 ];
1016 assert!(!shell_inline_c_needs_argv0(&with_placeholder));
1017 assert!(!shell_inline_c_needs_argv0(&[
1018 "echo".into(),
1019 "start".into()
1020 ]));
1021 assert!(!shell_inline_c_needs_argv0(&[
1022 "python3".into(),
1023 "-c".into(),
1024 "print(1)".into()
1025 ]));
1026 }
1027
1028 #[test]
1029 fn shell_passthrough_argv0_skips_run_leaf() {
1030 assert_eq!(
1031 shell_passthrough_argv0(&["scripts".into(), "misc".into(), "issue".into(), "run".into()]),
1032 "issue"
1033 );
1034 assert_eq!(shell_passthrough_argv0(&["probe".into()]), "probe");
1035 }
1036}
1037
1038pub fn default_db_path() -> PathBuf {
1039 if let Ok(p) = std::env::var("JAN_DB") {
1040 return PathBuf::from(p);
1041 }
1042 dirs::data_local_dir()
1043 .unwrap_or_else(|| PathBuf::from("."))
1044 .join("jan-cli")
1045 .join("audit.db")
1046}