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 => ("", &spec.commands, None),
206 };
207
208 if chain.is_empty() {
209 if let Some(meta) = &spec.metadata {
210 if let Some(desc) = &meta.description {
211 out.push_str(desc.trim());
212 out.push_str("\n\n");
213 }
214 }
215 }
216
217 if !about.is_empty() {
218 out.push_str(about.trim());
219 out.push_str("\n\n");
220 }
221
222 if exec.is_some() && children.is_empty() {
223 out.push_str("This command runs an external program (see spec `exec.argv`).\n");
224 return out;
225 }
226
227 if !children.is_empty() {
228 out.push_str("Subcommands:\n");
229 for (name, child) in children {
230 let line = if child.about.is_empty() {
231 format!(" {name}\n")
232 } else {
233 format!(" {name} — {}\n", first_line(&child.about))
234 };
235 out.push_str(&line);
236 }
237 out.push('\n');
238 out.push_str(&format!(
239 "Use `{} --help` for more about a subcommand.\n",
240 full_cmd
241 ));
242 } else if exec.is_none() {
243 out.push_str("(No subcommands defined.)\n");
244 }
245 if chain.is_empty() && node.is_none() {
246 out.push_str(
247 "\nPlace `--help` or `-h` right after the subcommand prefix you want. Built-ins: `use`, `bundle`, `alias`.\n",
248 );
249 }
250 out
251}
252
253#[derive(Debug, Clone)]
255pub struct SpecRootIdentity {
256 pub spec_dir: String,
258 pub root_yaml: String,
260}
261
262pub fn resolve_preferred_spec() -> Result<(PathBuf, SpecRootIdentity)> {
264 let cfg = config::load_user_config().context("load user config")?;
265 let Some(dir_s) = cfg.jan_dir.as_ref().map(|s| s.trim()).filter(|s| !s.is_empty()) else {
266 bail!(
267 "no preferred jan directory configured\n\
268 Run `jan use <DIR>` to save a YAML command tree (see docs/PORTABLE_SCRIPTS.md)"
269 );
270 };
271 let dir = PathBuf::from(dir_s);
272 if !dir.is_dir() {
273 bail!(
274 "preferred jan directory does not exist: {}\n\
275 Fix the path or run `jan use <DIR>` again (config: {})",
276 dir.display(),
277 config::config_path().display()
278 );
279 }
280 let root = cfg
281 .spec_root
282 .as_deref()
283 .map(str::trim)
284 .filter(|s| !s.is_empty())
285 .unwrap_or("scripts.spec.yaml");
286 resolve_spec_dir_entry(&dir, root)
287}
288
289pub fn resolve_spec_dir_entry(
291 spec_dir: &Path,
292 root_yaml: &str,
293) -> Result<(PathBuf, SpecRootIdentity)> {
294 let rel = Path::new(root_yaml);
295 if rel.is_absolute() {
296 bail!("entry YAML must be a relative file name, not an absolute path");
297 }
298 if rel
299 .components()
300 .any(|c| matches!(c, std::path::Component::ParentDir))
301 {
302 bail!("entry YAML must not contain `..`");
303 }
304 let normal_only = rel
305 .components()
306 .all(|c| matches!(c, std::path::Component::Normal(_)));
307 let n = rel
308 .components()
309 .filter(|c| matches!(c, std::path::Component::Normal(_)))
310 .count();
311 if !normal_only || n != 1 {
312 bail!("entry YAML must be a single file name inside the jan directory");
313 }
314 let dir = spec_dir
315 .canonicalize()
316 .with_context(|| format!("canonicalize jan directory {}", spec_dir.display()))?;
317 if !dir.is_dir() {
318 bail!("not a directory: {}", dir.display());
319 }
320 let spec_path = dir.join(rel);
321 if !spec_path.is_file() {
322 bail!(
323 "spec entry not found: {} (under {})\n\
324 Expected a properly formatted jan directory (e.g. scripts.spec.yaml)",
325 spec_path.display(),
326 dir.display()
327 );
328 }
329 let identity = SpecRootIdentity {
330 spec_dir: dir.to_string_lossy().into_owned(),
331 root_yaml: rel
332 .file_name()
333 .expect("relative root has file_name")
334 .to_string_lossy()
335 .into_owned(),
336 };
337 Ok((spec_path, identity))
338}
339
340pub struct RunContext<'a> {
341 pub cwd: &'a Path,
342 pub db_path: Option<&'a Path>,
343 pub branch: String,
344 pub no_log: bool,
345 pub spec_root: &'a SpecRootIdentity,
346}
347
348pub fn run_matched(
349 spec: &RootSpec,
350 chain: &[String],
351 node: &CommandNode,
352 trailing: &[OsString],
353 ctx: &RunContext<'_>,
354) -> Result<i32> {
355 let exec = match &node.exec {
356 Some(e) => e,
357 None => {
358 let help = format_help(spec, chain, Some(node));
359 print!("{help}");
360 bail!("missing subcommand");
361 }
362 };
363 if exec.argv.is_empty() {
364 bail!("exec.argv must not be empty");
365 }
366 let mut argv: Vec<String> = exec.argv.clone();
367 if exec.passthrough {
368 for a in trailing {
369 argv.push(a.to_string_lossy().into_owned());
370 }
371 } else if !trailing.is_empty() {
372 bail!("unexpected trailing arguments (enable exec.passthrough in the spec)");
373 }
374
375 let cmd_path = if chain.is_empty() {
376 "(root)".to_string()
377 } else {
378 chain.join(" ")
379 };
380
381 let (_, requires, _) = deps::collect_chain_metadata(chain, spec);
382 deps::check_requires(&requires)?;
383
384 let path_dirs = deps::resolve_path_prefixes(spec, chain, ctx)?;
385 let mut run_env = deps::collect_chain_env(chain, spec);
386 if !path_dirs.is_empty() {
387 run_env.insert("PATH".into(), deps::prepend_path_env(&path_dirs)?);
388 }
389
390 let mut c = Command::new(&argv[0]);
391 if argv.len() > 1 {
392 c.args(&argv[1..]);
393 }
394 c.current_dir(ctx.cwd);
395 for (key, value) in run_env {
396 c.env(key, value);
397 }
398
399 let status = c.status().with_context(|| format!("spawn `{}`", argv[0]))?;
400 let code = status.code().unwrap_or(255);
401
402 if !ctx.no_log {
403 if let Some(db) = ctx.db_path {
404 log_invocation(
405 db,
406 &ctx.branch,
407 ctx.cwd,
408 &cmd_path,
409 &argv,
410 code,
411 ctx.spec_root,
412 )?;
413 }
414 }
415
416 Ok(code)
417}
418
419fn ensure_invocations_spec_root_column(conn: &Connection) -> Result<()> {
420 let mut stmt = conn.prepare("PRAGMA table_info(invocations)")?;
421 let cols: Vec<String> = stmt
422 .query_map([], |row| row.get::<_, String>(1))?
423 .collect::<std::result::Result<_, _>>()?;
424 if !cols.iter().any(|c| c == "spec_root_id") {
425 conn.execute(
426 "ALTER TABLE invocations ADD COLUMN spec_root_id INTEGER",
427 [],
428 )?;
429 }
430 Ok(())
431}
432
433fn upsert_spec_root(conn: &Connection, spec: &SpecRootIdentity) -> Result<i64> {
434 let ts = unix_ts();
435 conn.execute(
436 r"INSERT INTO spec_roots (spec_dir, root_yaml, last_used_ts) VALUES (?1, ?2, ?3)
437 ON CONFLICT(spec_dir, root_yaml) DO UPDATE SET last_used_ts = excluded.last_used_ts",
438 rusqlite::params![&spec.spec_dir, &spec.root_yaml, &ts],
439 )?;
440 let id: i64 = conn.query_row(
441 "SELECT id FROM spec_roots WHERE spec_dir = ?1 AND root_yaml = ?2",
442 [&spec.spec_dir, &spec.root_yaml],
443 |r| r.get(0),
444 )?;
445 Ok(id)
446}
447
448fn log_invocation(
449 db_path: &Path,
450 branch: &str,
451 cwd: &Path,
452 command_path: &str,
453 argv: &[String],
454 exit_code: i32,
455 spec_root: &SpecRootIdentity,
456) -> Result<()> {
457 if let Some(parent) = db_path.parent() {
458 std::fs::create_dir_all(parent).ok();
459 }
460 let conn = Connection::open(db_path).with_context(|| format!("open {}", db_path.display()))?;
461 conn.execute_batch(
462 r"
463 CREATE TABLE IF NOT EXISTS spec_roots (
464 id INTEGER PRIMARY KEY AUTOINCREMENT,
465 spec_dir TEXT NOT NULL,
466 root_yaml TEXT NOT NULL,
467 last_used_ts TEXT NOT NULL,
468 UNIQUE(spec_dir, root_yaml)
469 );
470 CREATE TABLE IF NOT EXISTS invocations (
471 id INTEGER PRIMARY KEY AUTOINCREMENT,
472 ts TEXT NOT NULL,
473 git_branch TEXT NOT NULL,
474 cwd TEXT NOT NULL,
475 command_path TEXT NOT NULL,
476 argv_json TEXT NOT NULL,
477 exit_code INTEGER NOT NULL,
478 spec_root_id INTEGER
479 );
480 ",
481 )?;
482 ensure_invocations_spec_root_column(&conn)?;
483 let spec_root_id = upsert_spec_root(&conn, spec_root)?;
484 let ts = unix_ts();
485 let argv_json = serde_json::to_string(argv).unwrap_or_else(|_| "[]".to_string());
486 let cwd_s = cwd.to_string_lossy();
487 conn.execute(
488 "INSERT INTO invocations (ts, git_branch, cwd, command_path, argv_json, exit_code, spec_root_id)
489 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
490 rusqlite::params![
491 ts,
492 branch,
493 cwd_s.as_ref(),
494 command_path,
495 argv_json,
496 exit_code,
497 spec_root_id
498 ],
499 )?;
500 Ok(())
501}
502
503fn unix_ts() -> String {
504 use std::time::SystemTime;
505 SystemTime::now()
506 .duration_since(std::time::UNIX_EPOCH)
507 .unwrap_or_default()
508 .as_secs()
509 .to_string()
510}
511
512#[derive(Debug)]
513pub struct MatchOutcome<'a> {
514 pub chain: Vec<String>,
515 pub node: Option<&'a CommandNode>,
516 pub trailing: Vec<OsString>,
517 pub wants_help: bool,
518}
519
520pub fn match_commands<'a>(spec: &'a RootSpec, args: &[OsString]) -> MatchOutcome<'a> {
521 let mut chain = Vec::new();
522 let mut node: Option<&'a CommandNode> = None;
523 let mut map: &BTreeMap<String, CommandNode> = &spec.commands;
524 let mut i = 0usize;
525 let len = args.len();
526 while i < len {
527 let raw = &args[i];
528 if raw == "--help" || raw == "-h" {
529 return MatchOutcome {
530 chain,
531 node,
532 trailing: args[i + 1..].to_vec(),
533 wants_help: true,
534 };
535 }
536 let key = raw.to_string_lossy();
537 if let Some(next) = map.get(key.as_ref()) {
538 chain.push(key.into_owned());
539 node = Some(next);
540 map = &next.commands;
541 i += 1;
542 continue;
543 }
544 break;
545 }
546 MatchOutcome {
547 chain,
548 node,
549 trailing: args[i..].to_vec(),
550 wants_help: false,
551 }
552}
553
554#[cfg(test)]
555mod tests {
556 use super::*;
557 use std::io::Write;
558
559 #[test]
560 fn examples_default_spec_validates() {
561 let path = PathBuf::from(env!("CARGO_MANIFEST_DIR")).join("examples/default.spec.yaml");
562 load_spec(&path).unwrap();
563 }
564
565 #[test]
566 fn merge_specs_adds_and_replaces_leaves() {
567 let mut base = load_spec_from_str(
568 r"
569commands:
570 a:
571 about: base
572 commands:
573 x:
574 about: old
575 exec:
576 argv: [echo, old]
577",
578 None,
579 )
580 .unwrap();
581 let overlay = load_spec_from_str(
582 r"
583commands:
584 a:
585 commands:
586 x:
587 about: new leaf
588 exec:
589 argv: [echo, new]
590 b:
591 about: added top
592 exec:
593 argv: [echo, b]
594",
595 None,
596 )
597 .unwrap();
598 merge_specs_into(&mut base, overlay).unwrap();
599 base.commands["a"].commands["x"].validate("a x").unwrap();
600 assert_eq!(
601 base.commands["a"].commands["x"].exec.as_ref().unwrap().argv,
602 vec!["echo", "new"]
603 );
604 assert_eq!(
605 base.commands["b"].exec.as_ref().unwrap().argv,
606 vec!["echo", "b"]
607 );
608 }
609
610 #[test]
611 fn validate_rejects_exec_with_children() {
612 let mut tmp = tempfile::NamedTempFile::with_suffix(".yaml").unwrap();
613 write!(
614 tmp,
615 r"
616commands:
617 x:
618 exec:
619 argv: [echo]
620 commands:
621 child:
622 about: nested
623"
624 )
625 .unwrap();
626 let err = load_spec(tmp.path()).unwrap_err();
627 assert!(err.to_string().contains("cannot define both"));
628 }
629}
630
631pub fn default_db_path() -> PathBuf {
632 if let Ok(p) = std::env::var("JAN_DB") {
633 return PathBuf::from(p);
634 }
635 dirs::data_local_dir()
636 .unwrap_or_else(|| PathBuf::from("."))
637 .join("jan-cli")
638 .join("audit.db")
639}
640