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