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 const EMBEDDED_DEFAULT_FILES: &[(&str, &str)] = &[
156 ("default/git.yaml", include_str!("../default/git.yaml")),
157 ("default/disk.yaml", include_str!("../default/disk.yaml")),
158 ("default/net.yaml", include_str!("../default/net.yaml")),
159 ("default/proc.yaml", include_str!("../default/proc.yaml")),
160 ("default/gradle.yaml", include_str!("../default/gradle.yaml")),
161 ("default/rust.yaml", include_str!("../default/rust.yaml")),
162 ("default/android.yaml", include_str!("../default/android.yaml")),
163 ("default/docker.yaml", include_str!("../default/docker.yaml")),
164 ("default/sys.yaml", include_str!("../default/sys.yaml")),
165];
166
167pub fn load_embedded_default_spec() -> Result<RootSpec> {
169 spec_load::load_spec_from_str_with_embedded(
170 include_str!("../default.spec.yaml"),
171 "",
172 EMBEDDED_DEFAULT_FILES,
173 HostPlatform::detect(),
174 )
175}
176
177pub fn load_spec_from_str(raw: &str, include_base: Option<&Path>) -> Result<RootSpec> {
179 spec_load::load_spec_from_str(raw, include_base, HostPlatform::detect())
180}
181
182pub fn load_spec(path: &Path) -> Result<RootSpec> {
183 spec_load::load_spec_from_path(path, HostPlatform::detect())
184}
185
186pub fn resolve_git_branch(cwd: &Path, override_branch: Option<&str>) -> String {
187 if let Some(b) = override_branch {
188 if !b.is_empty() {
189 return b.to_string();
190 }
191 }
192 if let Ok(v) = std::env::var("JAN_BRANCH") {
193 if !v.is_empty() {
194 return v;
195 }
196 }
197 let output = Command::new("git")
198 .args(["rev-parse", "--abbrev-ref", "HEAD"])
199 .current_dir(cwd)
200 .output();
201 match output {
202 Ok(o) if o.status.success() => String::from_utf8_lossy(&o.stdout).trim().to_string(),
203 _ => "(no-git)".to_string(),
204 }
205}
206
207fn first_line(s: &str) -> String {
208 s.lines().next().unwrap_or("").trim().to_string()
209}
210
211pub fn format_help(spec: &RootSpec, chain: &[String], node: Option<&CommandNode>) -> String {
212 let mut out = String::new();
213 let bin = spec
214 .metadata
215 .as_ref()
216 .and_then(|m| m.name.as_deref())
217 .unwrap_or("jan");
218 let full_cmd = if chain.is_empty() {
219 bin.to_string()
220 } else {
221 format!("{} {}", bin, chain.join(" "))
222 };
223
224 let (about, children, exec) = match node {
225 Some(n) => (n.about.as_str(), &n.commands, n.exec.as_ref()),
226 None => (
227 spec.metadata
228 .as_ref()
229 .and_then(|m| m.description.as_deref())
230 .unwrap_or(""),
231 &spec.commands,
232 None,
233 ),
234 };
235
236 if chain.is_empty() {
237 if let Some(meta) = &spec.metadata {
238 if let Some(desc) = &meta.description {
239 out.push_str(desc.trim());
240 out.push_str("\n\n");
241 }
242 }
243 }
244
245 if !about.is_empty() {
246 out.push_str(about.trim());
247 out.push_str("\n\n");
248 }
249
250 if exec.is_some() && children.is_empty() {
251 out.push_str("This command runs an external program (see spec `exec.argv`).\n");
252 return out;
253 }
254
255 if !children.is_empty() {
256 out.push_str("Subcommands:\n");
257 for (name, child) in children {
258 let line = if child.about.is_empty() {
259 format!(" {name}\n")
260 } else {
261 format!(" {name} — {}\n", first_line(&child.about))
262 };
263 out.push_str(&line);
264 }
265 out.push('\n');
266 out.push_str(&format!(
267 "Use `{} --help` for more about a subcommand.\n",
268 full_cmd
269 ));
270 } else if exec.is_none() {
271 out.push_str("(No subcommands defined.)\n");
272 }
273 if chain.is_empty() && node.is_none() {
274 out.push_str(
275 "\nFramework: place `--help` or `-h` right after the subcommand prefix you want; run `jan --help` for global flags (`--verbose`, `--extra-spec`, `--stdin-spec`, …).\n",
276 );
277 }
278 out
279}
280
281#[derive(Debug, Clone)]
283pub struct SpecRootIdentity {
284 pub spec_dir: String,
286 pub root_yaml: String,
288}
289
290pub const EMBEDDED_SPEC_DIR: &str = "jan://embedded";
291pub const EMBEDDED_ROOT_YAML: &str = "default.spec.yaml";
292
293pub fn embedded_spec_identity() -> SpecRootIdentity {
294 SpecRootIdentity {
295 spec_dir: EMBEDDED_SPEC_DIR.to_string(),
296 root_yaml: EMBEDDED_ROOT_YAML.to_string(),
297 }
298}
299
300pub fn spec_identity_for_spec_file(spec_file: &Path) -> Result<SpecRootIdentity> {
302 let spec_file = spec_file
303 .canonicalize()
304 .with_context(|| format!("canonicalize {}", spec_file.display()))?;
305 let parent = spec_file
306 .parent()
307 .ok_or_else(|| anyhow::anyhow!("spec file has no parent directory"))?;
308 let root_name = spec_file
309 .file_name()
310 .ok_or_else(|| anyhow::anyhow!("spec file has no file name"))?
311 .to_string_lossy()
312 .into_owned();
313 Ok(SpecRootIdentity {
314 spec_dir: parent.to_string_lossy().into_owned(),
315 root_yaml: root_name,
316 })
317}
318
319pub fn resolve_spec_dir_entry(spec_dir: &Path, root_yaml: &str, cwd: &Path) -> Result<(PathBuf, SpecRootIdentity)> {
321 let rel = Path::new(root_yaml);
322 if rel.is_absolute() {
323 bail!("--spec-root must be a relative file name, not an absolute path");
324 }
325 if rel.components().any(|c| matches!(c, std::path::Component::ParentDir)) {
326 bail!("--spec-root must not contain `..`");
327 }
328 let normal_only = rel
329 .components()
330 .all(|c| matches!(c, std::path::Component::Normal(_)));
331 let n = rel
332 .components()
333 .filter(|c| matches!(c, std::path::Component::Normal(_)))
334 .count();
335 if !normal_only || n != 1 {
336 bail!("--spec-root must be a single file name inside the spec directory");
337 }
338 let dir = if spec_dir.is_absolute() {
339 spec_dir.to_path_buf()
340 } else {
341 cwd.join(spec_dir)
342 };
343 let dir = dir
344 .canonicalize()
345 .with_context(|| format!("canonicalize spec directory {}", dir.display()))?;
346 if !dir.is_dir() {
347 bail!("not a directory: {}", dir.display());
348 }
349 let spec_path = dir.join(rel);
350 if !spec_path.is_file() {
351 bail!(
352 "spec entry not found: {} (under {})",
353 spec_path.display(),
354 dir.display()
355 );
356 }
357 let identity = SpecRootIdentity {
358 spec_dir: dir.to_string_lossy().into_owned(),
359 root_yaml: rel
360 .file_name()
361 .expect("relative root has file_name")
362 .to_string_lossy()
363 .into_owned(),
364 };
365 Ok((spec_path, identity))
366}
367
368pub struct RunContext<'a> {
369 pub cwd: &'a Path,
370 pub db_path: Option<&'a Path>,
371 pub branch: String,
372 pub no_log: bool,
373 pub spec_root: &'a SpecRootIdentity,
374}
375
376pub fn run_matched(
377 spec: &RootSpec,
378 chain: &[String],
379 node: &CommandNode,
380 trailing: &[OsString],
381 ctx: &RunContext<'_>,
382) -> Result<i32> {
383 let exec = match &node.exec {
384 Some(e) => e,
385 None => {
386 let help = format_help(spec, chain, Some(node));
387 print!("{help}");
388 bail!("missing subcommand");
389 }
390 };
391 if exec.argv.is_empty() {
392 bail!("exec.argv must not be empty");
393 }
394 let mut argv: Vec<String> = exec.argv.clone();
395 if exec.passthrough {
396 for a in trailing {
397 argv.push(a.to_string_lossy().into_owned());
398 }
399 } else if !trailing.is_empty() {
400 bail!("unexpected trailing arguments (enable exec.passthrough in the spec)");
401 }
402
403 let cmd_path = if chain.is_empty() {
404 "(root)".to_string()
405 } else {
406 chain.join(" ")
407 };
408
409 let (_, requires, _) = deps::collect_chain_metadata(chain, spec);
410 deps::check_requires(&requires)?;
411
412 let path_dirs = deps::resolve_path_prefixes(spec, chain, ctx)?;
413 let mut run_env = deps::collect_chain_env(chain, spec);
414 if !path_dirs.is_empty() {
415 run_env.insert("PATH".into(), deps::prepend_path_env(&path_dirs)?);
416 }
417
418 let mut c = Command::new(&argv[0]);
419 if argv.len() > 1 {
420 c.args(&argv[1..]);
421 }
422 c.current_dir(ctx.cwd);
423 for (key, value) in run_env {
424 c.env(key, value);
425 }
426
427 let status = c.status().with_context(|| format!("spawn `{}`", argv[0]))?;
428 let code = status.code().unwrap_or(255);
429
430 if !ctx.no_log {
431 if let Some(db) = ctx.db_path {
432 log_invocation(
433 db,
434 &ctx.branch,
435 ctx.cwd,
436 &cmd_path,
437 &argv,
438 code,
439 ctx.spec_root,
440 )?;
441 }
442 }
443
444 Ok(code)
445}
446
447fn ensure_invocations_spec_root_column(conn: &Connection) -> Result<()> {
448 let mut stmt = conn.prepare("PRAGMA table_info(invocations)")?;
449 let cols: Vec<String> = stmt
450 .query_map([], |row| row.get::<_, String>(1))?
451 .collect::<std::result::Result<_, _>>()?;
452 if !cols.iter().any(|c| c == "spec_root_id") {
453 conn.execute(
454 "ALTER TABLE invocations ADD COLUMN spec_root_id INTEGER",
455 [],
456 )?;
457 }
458 Ok(())
459}
460
461fn upsert_spec_root(conn: &Connection, spec: &SpecRootIdentity) -> Result<i64> {
462 let ts = unix_ts();
463 conn.execute(
464 r"INSERT INTO spec_roots (spec_dir, root_yaml, last_used_ts) VALUES (?1, ?2, ?3)
465 ON CONFLICT(spec_dir, root_yaml) DO UPDATE SET last_used_ts = excluded.last_used_ts",
466 rusqlite::params![&spec.spec_dir, &spec.root_yaml, &ts],
467 )?;
468 let id: i64 = conn.query_row(
469 "SELECT id FROM spec_roots WHERE spec_dir = ?1 AND root_yaml = ?2",
470 [&spec.spec_dir, &spec.root_yaml],
471 |r| r.get(0),
472 )?;
473 Ok(id)
474}
475
476fn log_invocation(
477 db_path: &Path,
478 branch: &str,
479 cwd: &Path,
480 command_path: &str,
481 argv: &[String],
482 exit_code: i32,
483 spec_root: &SpecRootIdentity,
484) -> Result<()> {
485 if let Some(parent) = db_path.parent() {
486 std::fs::create_dir_all(parent).ok();
487 }
488 let conn = Connection::open(db_path).with_context(|| format!("open {}", db_path.display()))?;
489 conn.execute_batch(
490 r"
491 CREATE TABLE IF NOT EXISTS spec_roots (
492 id INTEGER PRIMARY KEY AUTOINCREMENT,
493 spec_dir TEXT NOT NULL,
494 root_yaml TEXT NOT NULL,
495 last_used_ts TEXT NOT NULL,
496 UNIQUE(spec_dir, root_yaml)
497 );
498 CREATE TABLE IF NOT EXISTS invocations (
499 id INTEGER PRIMARY KEY AUTOINCREMENT,
500 ts TEXT NOT NULL,
501 git_branch TEXT NOT NULL,
502 cwd TEXT NOT NULL,
503 command_path TEXT NOT NULL,
504 argv_json TEXT NOT NULL,
505 exit_code INTEGER NOT NULL,
506 spec_root_id INTEGER
507 );
508 ",
509 )?;
510 ensure_invocations_spec_root_column(&conn)?;
511 let spec_root_id = upsert_spec_root(&conn, spec_root)?;
512 let ts = unix_ts();
513 let argv_json = serde_json::to_string(argv).unwrap_or_else(|_| "[]".to_string());
514 let cwd_s = cwd.to_string_lossy();
515 conn.execute(
516 "INSERT INTO invocations (ts, git_branch, cwd, command_path, argv_json, exit_code, spec_root_id)
517 VALUES (?1, ?2, ?3, ?4, ?5, ?6, ?7)",
518 rusqlite::params![
519 ts,
520 branch,
521 cwd_s.as_ref(),
522 command_path,
523 argv_json,
524 exit_code,
525 spec_root_id
526 ],
527 )?;
528 Ok(())
529}
530
531fn unix_ts() -> String {
532 use std::time::SystemTime;
533 SystemTime::now()
534 .duration_since(std::time::UNIX_EPOCH)
535 .unwrap_or_default()
536 .as_secs()
537 .to_string()
538}
539
540#[derive(Debug)]
541pub struct MatchOutcome<'a> {
542 pub chain: Vec<String>,
543 pub node: Option<&'a CommandNode>,
544 pub trailing: Vec<OsString>,
545 pub wants_help: bool,
546}
547
548pub fn match_commands<'a>(spec: &'a RootSpec, args: &[OsString]) -> MatchOutcome<'a> {
549 let mut chain = Vec::new();
550 let mut node: Option<&'a CommandNode> = None;
551 let mut map: &BTreeMap<String, CommandNode> = &spec.commands;
552 let mut i = 0usize;
553 let len = args.len();
554 while i < len {
555 let raw = &args[i];
556 if raw == "--help" || raw == "-h" {
557 return MatchOutcome {
558 chain,
559 node,
560 trailing: args[i + 1..].to_vec(),
561 wants_help: true,
562 };
563 }
564 let key = raw.to_string_lossy();
565 if let Some(next) = map.get(key.as_ref()) {
566 chain.push(key.into_owned());
567 node = Some(next);
568 map = &next.commands;
569 i += 1;
570 continue;
571 }
572 break;
573 }
574 MatchOutcome {
575 chain,
576 node,
577 trailing: args[i..].to_vec(),
578 wants_help: false,
579 }
580}
581
582#[cfg(test)]
583mod tests {
584 use super::*;
585 use std::io::Write;
586
587 #[test]
588 fn embedded_default_spec_validates() {
589 load_embedded_default_spec().unwrap();
590 }
591
592 #[test]
593 fn merge_specs_adds_and_replaces_leaves() {
594 let mut base = load_spec_from_str(
595 r"
596commands:
597 a:
598 about: base
599 commands:
600 x:
601 about: old
602 exec:
603 argv: [echo, old]
604",
605 None,
606 )
607 .unwrap();
608 let overlay = load_spec_from_str(
609 r"
610commands:
611 a:
612 commands:
613 x:
614 about: new leaf
615 exec:
616 argv: [echo, new]
617 b:
618 about: added top
619 exec:
620 argv: [echo, b]
621",
622 None,
623 )
624 .unwrap();
625 merge_specs_into(&mut base, overlay).unwrap();
626 base.commands["a"].commands["x"].validate("a x").unwrap();
627 assert_eq!(
628 base.commands["a"].commands["x"].exec.as_ref().unwrap().argv,
629 vec!["echo", "new"]
630 );
631 assert_eq!(base.commands["b"].exec.as_ref().unwrap().argv, vec!["echo", "b"]);
632 }
633
634 #[test]
635 fn validate_rejects_exec_with_children() {
636 let mut tmp = tempfile::NamedTempFile::with_suffix(".yaml").unwrap();
637 write!(
638 tmp,
639 r"
640commands:
641 x:
642 exec:
643 argv: [echo]
644 commands:
645 child:
646 about: nested
647"
648 )
649 .unwrap();
650 let err = load_spec(tmp.path()).unwrap_err();
651 assert!(err.to_string().contains("cannot define both"));
652 }
653}
654
655pub fn default_db_path() -> PathBuf {
656 if let Ok(p) = std::env::var("JAN_DB") {
657 return PathBuf::from(p);
658 }
659 dirs::data_local_dir()
660 .unwrap_or_else(|| PathBuf::from("."))
661 .join("jan-cli")
662 .join("audit.db")
663}
664
665pub fn resolve_spec_path(cli_spec: Option<PathBuf>, cwd: &Path) -> Result<Option<PathBuf>> {
667 if let Some(p) = cli_spec {
668 let full = if p.is_absolute() {
669 p
670 } else {
671 cwd.join(p)
672 };
673 if full.exists() {
674 return Ok(Some(full));
675 }
676 bail!("spec file not found: {}", full.display());
677 }
678 if let Ok(env) = std::env::var("JAN_SPEC") {
679 let p = PathBuf::from(&env);
680 let full = if p.is_absolute() {
681 p
682 } else {
683 cwd.join(p)
684 };
685 if full.exists() {
686 return Ok(Some(full));
687 }
688 bail!("JAN_SPEC points to missing file: {}", full.display());
689 }
690 let a = cwd.join("jan.yaml");
691 if a.exists() {
692 return Ok(Some(a));
693 }
694 let b = cwd.join("jan.spec.yaml");
695 if b.exists() {
696 return Ok(Some(b));
697 }
698 Ok(None)
699}