1mod builtins;
2mod config;
3mod deps;
4mod runner;
5mod spec_load;
6mod yaml_closure;
7
8pub use config::{load_user_config, UserConfig};
9pub use runner::run_jan;
10pub use spec_load::HostPlatform;
11
12use std::collections::BTreeMap;
13use std::ffi::OsString;
14use std::path::{Path, PathBuf};
15use std::process::Command;
16
17use anyhow::{bail, Context, Result};
18use rusqlite::Connection;
19use serde::Deserialize;
20
21#[derive(Debug, Deserialize)]
22pub struct RootSpec {
23 pub metadata: Option<Metadata>,
24 #[serde(default)]
25 pub commands: BTreeMap<String, CommandNode>,
26}
27
28#[derive(Debug, Deserialize)]
29pub struct Metadata {
30 pub name: Option<String>,
31 pub description: Option<String>,
32}
33
34#[derive(Debug, Deserialize, Default, Clone)]
35pub struct CommandNode {
36 #[serde(default)]
39 pub os: Vec<String>,
40 #[serde(default)]
41 pub about: String,
42 pub path: Option<String>,
44 #[serde(default)]
46 pub dependencies: Vec<String>,
47 #[serde(default)]
49 pub requires: Vec<String>,
50 #[serde(default)]
52 pub env: BTreeMap<String, String>,
53 #[serde(default)]
54 pub commands: BTreeMap<String, CommandNode>,
55 pub exec: Option<ExecSpec>,
56}
57
58#[derive(Debug, Deserialize, Clone)]
59pub struct ExecSpec {
60 pub argv: Vec<String>,
62 #[serde(default)]
64 pub passthrough: bool,
65}
66
67impl CommandNode {
68 pub fn is_leaf_exec(&self) -> bool {
69 self.exec.is_some()
70 }
71
72 pub fn validate(&self, path: &str) -> Result<()> {
73 if self.exec.is_some() && !self.commands.is_empty() {
74 bail!("command '{path}' cannot define both `exec` and nested `commands`");
75 }
76 if let Some(ref e) = self.exec {
77 if e.argv.is_empty() {
78 bail!("command '{path}': exec.argv must not be empty");
79 }
80 }
81 for (name, child) in &self.commands {
82 let p = if path.is_empty() {
83 name.clone()
84 } else {
85 format!("{path} {name}")
86 };
87 child.validate(&p)?;
88 }
89 Ok(())
90 }
91}
92
93pub fn merge_specs_into(base: &mut RootSpec, overlay: RootSpec) -> Result<()> {
96 for (name, node) in overlay.commands {
97 match base.commands.get_mut(&name) {
98 Some(existing) => merge_command_node(existing, node)?,
99 None => {
100 base.commands.insert(name, node);
101 }
102 }
103 }
104 Ok(())
105}
106
107fn merge_command_node(dst: &mut CommandNode, src: CommandNode) -> Result<()> {
108 if src.exec.is_some() && !src.commands.is_empty() {
109 bail!("merge overlay: command cannot define both `exec` and nested `commands`");
110 }
111 if !src.os.is_empty() {
112 dst.os = src.os;
113 }
114 if !src.about.trim().is_empty() {
115 dst.about = src.about;
116 }
117 if src.path.is_some() {
118 dst.path = src.path;
119 }
120 if !src.dependencies.is_empty() {
121 dst.dependencies = src.dependencies;
122 }
123 if !src.requires.is_empty() {
124 dst.requires = src.requires;
125 }
126 for (k, v) in src.env {
127 dst.env.insert(k, v);
128 }
129 if let Some(exec) = src.exec {
130 dst.exec = Some(exec);
131 dst.commands.clear();
132 return Ok(());
133 }
134 if !src.commands.is_empty() {
135 dst.exec = None;
136 for (k, child) in src.commands {
137 match dst.commands.get_mut(&k) {
138 Some(existing) => merge_command_node(existing, child)?,
139 None => {
140 dst.commands.insert(k, child);
141 }
142 }
143 }
144 }
145 Ok(())
146}
147
148pub fn validate_spec(spec: &RootSpec) -> Result<()> {
150 for (name, node) in &spec.commands {
151 node.validate(name)?;
152 }
153 Ok(())
154}
155
156pub fn load_spec_from_str(raw: &str, include_base: Option<&Path>) -> Result<RootSpec> {
158 spec_load::load_spec_from_str(raw, include_base, HostPlatform::detect())
159}
160
161pub fn load_spec(path: &Path) -> Result<RootSpec> {
162 spec_load::load_spec_from_path(path, HostPlatform::detect())
163}
164
165pub fn resolve_git_branch(cwd: &Path, override_branch: Option<&str>) -> String {
166 if let Some(b) = override_branch {
167 if !b.is_empty() {
168 return b.to_string();
169 }
170 }
171 if let Ok(v) = std::env::var("JAN_BRANCH") {
172 if !v.is_empty() {
173 return v;
174 }
175 }
176 let output = Command::new("git")
177 .args(["rev-parse", "--abbrev-ref", "HEAD"])
178 .current_dir(cwd)
179 .output();
180 match output {
181 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
182 _ => "(no-git)".to_string(),
183 }
184}
185
186fn first_line(s: &str) -> String {
187 s.lines().next().unwrap_or("").trim().to_string()
188}
189
190pub fn format_help(spec: &RootSpec, chain: &[String], node: Option<&CommandNode>) -> String {
191 let mut out = String::new();
192 let bin = spec
193 .metadata
194 .as_ref()
195 .and_then(|m| m.name.as_deref())
196 .unwrap_or("jan");
197 let full_cmd = if chain.is_empty() {
198 bin.to_string()
199 } else {
200 format!("{} {}", bin, chain.join(" "))
201 };
202
203 let (about, children, exec) = match node {
204 Some(n) => (n.about.as_str(), &n.commands, n.exec.as_ref()),
205 None => (
206 spec.metadata
207 .as_ref()
208 .and_then(|m| m.description.as_deref())
209 .unwrap_or(""),
210 &spec.commands,
211 None,
212 ),
213 };
214
215 if chain.is_empty() {
216 if let Some(meta) = &spec.metadata {
217 if let Some(desc) = &meta.description {
218 out.push_str(desc.trim());
219 out.push_str("\n\n");
220 }
221 }
222 }
223
224 if !about.is_empty() {
225 out.push_str(about.trim());
226 out.push_str("\n\n");
227 }
228
229 if exec.is_some() && children.is_empty() {
230 out.push_str("This command runs an external program (see spec `exec.argv`).\n");
231 return out;
232 }
233
234 if !children.is_empty() {
235 out.push_str("Subcommands:\n");
236 for (name, child) in children {
237 let line = if child.about.is_empty() {
238 format!(" {name}\n")
239 } else {
240 format!(" {name} — {}\n", first_line(&child.about))
241 };
242 out.push_str(&line);
243 }
244 out.push('\n');
245 out.push_str(&format!(
246 "Use `{} --help` for more about a subcommand.\n",
247 full_cmd
248 ));
249 } else if exec.is_none() {
250 out.push_str("(No subcommands defined.)\n");
251 }
252 if chain.is_empty() && node.is_none() {
253 out.push_str(
254 "\nFramework: place `--help` or `-h` right after the subcommand prefix you want; run `jan --help` for global flags (`--verbose`, `--extra-spec`, `--stdin-spec`, …).\n",
255 );
256 }
257 out
258}
259
260#[derive(Debug, Clone)]
262pub struct SpecRootIdentity {
263 pub spec_dir: String,
265 pub root_yaml: String,
267}
268
269pub fn well_known_spec_dir() -> PathBuf {
271 if let Ok(p) = std::env::var("JAN_INSTALL_DIR") {
272 let p = p.trim();
273 if !p.is_empty() {
274 return PathBuf::from(p);
275 }
276 }
277 dirs::config_dir()
278 .unwrap_or_else(|| PathBuf::from("."))
279 .join("jan")
280 .join("scripts")
281}
282
283pub fn resolve_well_known_spec(cwd: &Path) -> Result<(PathBuf, SpecRootIdentity)> {
287 if let Some((dir, root)) = preferred_jan_dir_from_config() {
288 if dir.is_dir() {
289 match resolve_spec_dir_entry(&dir, &root, cwd) {
290 Ok(pair) => return Ok(pair),
291 Err(e) => {
292 eprintln!(
294 "jan: preferred jan-dir {} / {} unusable ({:#}); trying well-known install dir",
295 dir.display(),
296 root,
297 e
298 );
299 }
300 }
301 }
302 }
303
304 let dir = well_known_spec_dir();
305 let candidates = config::entry_candidates(None);
306
307 if !dir.is_dir() {
308 bail!(
309 "no spec found: no --spec/--spec-dir, no jan.yaml under {}, no preferred directory (`jan use`), \
310 and well-known directory {} does not exist\n\
311 Run `jan use <dir>` to save a preferred tree, install a bundle (see docs/PORTABLE_SCRIPTS.md), \
312 or pass --spec / --spec-dir / JAN_SPEC",
313 cwd.display(),
314 dir.display()
315 );
316 }
317 for name in &candidates {
318 let path = dir.join(name);
319 if path.is_file() {
320 return resolve_spec_dir_entry(&dir, name, cwd);
321 }
322 }
323 bail!(
324 "no spec entry file in {} (tried: {})\n\
325 Run `jan use <dir>`, install with jan-install.sh, or set JAN_SPEC_ROOT",
326 dir.display(),
327 candidates.join(", ")
328 );
329}
330
331fn preferred_jan_dir_from_config() -> Option<(PathBuf, String)> {
333 let cfg = config::load_user_config().ok()?;
334 let dir = cfg.jan_dir.as_ref()?.trim();
335 if dir.is_empty() {
336 return None;
337 }
338 let root = cfg
339 .spec_root
340 .as_deref()
341 .map(str::trim)
342 .filter(|s| !s.is_empty())
343 .unwrap_or("scripts.spec.yaml")
344 .to_string();
345 Some((PathBuf::from(dir), root))
346}
347
348pub fn spec_identity_for_spec_file(spec_file: &Path) -> Result<SpecRootIdentity> {
350 let spec_file = spec_file
351 .canonicalize()
352 .with_context(|| format!("canonicalize {}", spec_file.display()))?;
353 let parent = spec_file
354 .parent()
355 .ok_or_else(|| anyhow::anyhow!("spec file has no parent directory"))?;
356 let root_name = spec_file
357 .file_name()
358 .ok_or_else(|| anyhow::anyhow!("spec file has no file name"))?
359 .to_string_lossy()
360 .into_owned();
361 Ok(SpecRootIdentity {
362 spec_dir: parent.to_string_lossy().into_owned(),
363 root_yaml: root_name,
364 })
365}
366
367pub fn resolve_spec_dir_entry(
369 spec_dir: &Path,
370 root_yaml: &str,
371 cwd: &Path,
372) -> Result<(PathBuf, SpecRootIdentity)> {
373 let rel = Path::new(root_yaml);
374 if rel.is_absolute() {
375 bail!("--spec-root must be a relative file name, not an absolute path");
376 }
377 if rel
378 .components()
379 .any(|c| matches!(c, std::path::Component::ParentDir))
380 {
381 bail!("--spec-root must not contain `..`");
382 }
383 let normal_only = rel
384 .components()
385 .all(|c| matches!(c, std::path::Component::Normal(_)));
386 let n = rel
387 .components()
388 .filter(|c| matches!(c, std::path::Component::Normal(_)))
389 .count();
390 if !normal_only || n != 1 {
391 bail!("--spec-root must be a single file name inside the spec directory");
392 }
393 let dir = if spec_dir.is_absolute() {
394 spec_dir.to_path_buf()
395 } else {
396 cwd.join(spec_dir)
397 };
398 let dir = dir
399 .canonicalize()
400 .with_context(|| format!("canonicalize spec directory {}", dir.display()))?;
401 if !dir.is_dir() {
402 bail!("not a directory: {}", dir.display());
403 }
404 let spec_path = dir.join(rel);
405 if !spec_path.is_file() {
406 bail!(
407 "spec entry not found: {} (under {})",
408 spec_path.display(),
409 dir.display()
410 );
411 }
412 let identity = SpecRootIdentity {
413 spec_dir: dir.to_string_lossy().into_owned(),
414 root_yaml: rel
415 .file_name()
416 .expect("relative root has file_name")
417 .to_string_lossy()
418 .into_owned(),
419 };
420 Ok((spec_path, identity))
421}
422
423pub struct RunContext<'a> {
424 pub cwd: &'a Path,
425 pub db_path: Option<&'a Path>,
426 pub branch: String,
427 pub no_log: bool,
428 pub spec_root: &'a SpecRootIdentity,
429}
430
431pub fn run_matched(
432 spec: &RootSpec,
433 chain: &[String],
434 node: &CommandNode,
435 trailing: &[OsString],
436 ctx: &RunContext<'_>,
437) -> Result<i32> {
438 let exec = match &node.exec {
439 Some(e) => e,
440 None => {
441 let help = format_help(spec, chain, Some(node));
442 print!("{help}");
443 bail!("missing subcommand");
444 }
445 };
446 if exec.argv.is_empty() {
447 bail!("exec.argv must not be empty");
448 }
449 let mut argv: Vec<String> = exec.argv.clone();
450 if exec.passthrough {
451 for a in trailing {
452 argv.push(a.to_string_lossy().into_owned());
453 }
454 } else if !trailing.is_empty() {
455 bail!("unexpected trailing arguments (enable exec.passthrough in the spec)");
456 }
457
458 let cmd_path = if chain.is_empty() {
459 "(root)".to_string()
460 } else {
461 chain.join(" ")
462 };
463
464 let (_, requires, _) = deps::collect_chain_metadata(chain, spec);
465 deps::check_requires(&requires)?;
466
467 let path_dirs = deps::resolve_path_prefixes(spec, chain, ctx)?;
468 let mut run_env = deps::collect_chain_env(chain, spec);
469 if !path_dirs.is_empty() {
470 run_env.insert("PATH".into(), deps::prepend_path_env(&path_dirs)?);
471 }
472
473 let mut c = Command::new(&argv[0]);
474 if argv.len() > 1 {
475 c.args(&argv[1..]);
476 }
477 c.current_dir(ctx.cwd);
478 for (key, value) in run_env {
479 c.env(key, value);
480 }
481
482 let status = c.status().with_context(|| format!("spawn `{}`", argv[0]))?;
483 let code = status.code().unwrap_or(255);
484
485 if !ctx.no_log {
486 if let Some(db) = ctx.db_path {
487 log_invocation(
488 db,
489 &ctx.branch,
490 ctx.cwd,
491 &cmd_path,
492 &argv,
493 code,
494 ctx.spec_root,
495 )?;
496 }
497 }
498
499 Ok(code)
500}
501
502fn ensure_invocations_spec_root_column(conn: &Connection) -> Result<()> {
503 let mut stmt = conn.prepare("PRAGMA table_info(invocations)")?;
504 let cols: Vec<String> = stmt
505 .query_map([], |row| row.get::<_, String>(1))?
506 .collect::<std::result::Result<_, _>>()?;
507 if !cols.iter().any(|c| c == "spec_root_id") {
508 conn.execute(
509 "ALTER TABLE invocations ADD COLUMN spec_root_id INTEGER",
510 [],
511 )?;
512 }
513 Ok(())
514}
515
516fn upsert_spec_root(conn: &Connection, spec: &SpecRootIdentity) -> Result<i64> {
517 let ts = unix_ts();
518 conn.execute(
519 r"INSERT INTO spec_roots (spec_dir, root_yaml, last_used_ts) VALUES (?1, ?2, ?3)
520 ON CONFLICT(spec_dir, root_yaml) DO UPDATE SET last_used_ts = excluded.last_used_ts",
521 rusqlite::params![&spec.spec_dir, &spec.root_yaml, &ts],
522 )?;
523 let id: i64 = conn.query_row(
524 "SELECT id FROM spec_roots WHERE spec_dir = ?1 AND root_yaml = ?2",
525 [&spec.spec_dir, &spec.root_yaml],
526 |r| r.get(0),
527 )?;
528 Ok(id)
529}
530
531fn log_invocation(
532 db_path: &Path,
533 branch: &str,
534 cwd: &Path,
535 command_path: &str,
536 argv: &[String],
537 exit_code: i32,
538 spec_root: &SpecRootIdentity,
539) -> Result<()> {
540 if let Some(parent) = db_path.parent() {
541 std::fs::create_dir_all(parent).ok();
542 }
543 let conn = Connection::open(db_path).with_context(|| format!("open {}", db_path.display()))?;
544 conn.execute_batch(
545 r"
546 CREATE TABLE IF NOT EXISTS spec_roots (
547 id INTEGER PRIMARY KEY AUTOINCREMENT,
548 spec_dir TEXT NOT NULL,
549 root_yaml TEXT NOT NULL,
550 last_used_ts TEXT NOT NULL,
551 UNIQUE(spec_dir, root_yaml)
552 );
553 CREATE TABLE IF NOT EXISTS invocations (
554 id INTEGER PRIMARY KEY AUTOINCREMENT,
555 ts TEXT NOT NULL,
556 git_branch TEXT NOT NULL,
557 cwd TEXT NOT NULL,
558 command_path TEXT NOT NULL,
559 argv_json TEXT NOT NULL,
560 exit_code INTEGER NOT NULL,
561 spec_root_id INTEGER
562 );
563 ",
564 )?;
565 ensure_invocations_spec_root_column(&conn)?;
566 let spec_root_id = upsert_spec_root(&conn, spec_root)?;
567 let ts = unix_ts();
568 let argv_json = serde_json::to_string(argv).unwrap_or_else(|_| "[]".to_string());
569 let cwd_s = cwd.to_string_lossy();
570 conn.execute(
571 "INSERT INTO invocations (ts, git_branch, cwd, command_path, argv_json, exit_code, spec_root_id)
572 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
573 rusqlite::params![
574 ts,
575 branch,
576 cwd_s.as_ref(),
577 command_path,
578 argv_json,
579 exit_code,
580 spec_root_id
581 ],
582 )?;
583 Ok(())
584}
585
586fn unix_ts() -> String {
587 use std::time::SystemTime;
588 SystemTime::now()
589 .duration_since(std::time::UNIX_EPOCH)
590 .unwrap_or_default()
591 .as_secs()
592 .to_string()
593}
594
595#[derive(Debug)]
596pub struct MatchOutcome<'a> {
597 pub chain: Vec<String>,
598 pub node: Option<&'a CommandNode>,
599 pub trailing: Vec<OsString>,
600 pub wants_help: bool,
601}
602
603pub fn match_commands<'a>(spec: &'a RootSpec, args: &[OsString]) -> MatchOutcome<'a> {
604 let mut chain = Vec::new();
605 let mut node: Option<&'a CommandNode> = None;
606 let mut map: &BTreeMap<String, CommandNode> = &spec.commands;
607 let mut i = 0usize;
608 let len = args.len();
609 while i < len {
610 let raw = &args[i];
611 if raw == "--help" || raw == "-h" {
612 return MatchOutcome {
613 chain,
614 node,
615 trailing: args[i + 1..].to_vec(),
616 wants_help: true,
617 };
618 }
619 let key = raw.to_string_lossy();
620 if let Some(next) = map.get(key.as_ref()) {
621 chain.push(key.into_owned());
622 node = Some(next);
623 map = &next.commands;
624 i += 1;
625 continue;
626 }
627 break;
628 }
629 MatchOutcome {
630 chain,
631 node,
632 trailing: args[i..].to_vec(),
633 wants_help: false,
634 }
635}
636
637#[cfg(test)]
638mod tests {
639 use super::*;
640 use std::io::Write;
641
642 #[test]
643 fn examples_default_spec_validates() {
644 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/default.spec.yaml");
645 load_spec(&path).unwrap();
646 }
647
648 #[test]
649 fn merge_specs_adds_and_replaces_leaves() {
650 let mut base = load_spec_from_str(
651 r"
652commands:
653 a:
654 about: base
655 commands:
656 x:
657 about: old
658 exec:
659 argv: [echo, old]
660",
661 None,
662 )
663 .unwrap();
664 let overlay = load_spec_from_str(
665 r"
666commands:
667 a:
668 commands:
669 x:
670 about: new leaf
671 exec:
672 argv: [echo, new]
673 b:
674 about: added top
675 exec:
676 argv: [echo, b]
677",
678 None,
679 )
680 .unwrap();
681 merge_specs_into(&mut base, overlay).unwrap();
682 base.commands["a"].commands["x"].validate("a x").unwrap();
683 assert_eq!(
684 base.commands["a"].commands["x"].exec.as_ref().unwrap().argv,
685 vec!["echo", "new"]
686 );
687 assert_eq!(
688 base.commands["b"].exec.as_ref().unwrap().argv,
689 vec!["echo", "b"]
690 );
691 }
692
693 #[test]
694 fn validate_rejects_exec_with_children() {
695 let mut tmp = tempfile::NamedTempFile::with_suffix(".yaml").unwrap();
696 write!(
697 tmp,
698 r"
699commands:
700 x:
701 exec:
702 argv: [echo]
703 commands:
704 child:
705 about: nested
706"
707 )
708 .unwrap();
709 let err = load_spec(tmp.path()).unwrap_err();
710 assert!(err.to_string().contains("cannot define both"));
711 }
712}
713
714pub fn default_db_path() -> PathBuf {
715 if let Ok(p) = std::env::var("JAN_DB") {
716 return PathBuf::from(p);
717 }
718 dirs::data_local_dir()
719 .unwrap_or_else(|| PathBuf::from("."))
720 .join("jan-cli")
721 .join("audit.db")
722}
723
724pub fn resolve_spec_path(cli_spec: Option<PathBuf>, cwd: &Path) -> Result<Option<PathBuf>> {
726 if let Some(p) = cli_spec {
727 let full = if p.is_absolute() { p } else { cwd.join(p) };
728 if full.exists() {
729 return Ok(Some(full));
730 }
731 bail!("spec file not found: {}", full.display());
732 }
733 if let Ok(env) = std::env::var("JAN_SPEC") {
734 let p = PathBuf::from(&env);
735 let full = if p.is_absolute() { p } else { cwd.join(p) };
736 if full.exists() {
737 return Ok(Some(full));
738 }
739 bail!("JAN_SPEC points to missing file: {}", full.display());
740 }
741 let a = cwd.join("jan.yaml");
742 if a.exists() {
743 return Ok(Some(a));
744 }
745 let b = cwd.join("jan.spec.yaml");
746 if b.exists() {
747 return Ok(Some(b));
748 }
749 Ok(None)
750}