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