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