1use std::ffi::OsString;
2use std::io::{Read, Write};
3use std::path::PathBuf;
4
5use anyhow::{anyhow, bail, Context, Result};
6use clap::Parser;
7use tempfile::NamedTempFile;
8
9use crate::{
10 builtins, default_db_path, embedded_spec_identity, format_help, load_embedded_default_spec, load_spec,
11 match_commands, merge_specs_into, resolve_git_branch, resolve_spec_dir_entry, resolve_spec_path,
12 run_matched, spec_identity_for_spec_file, validate_spec, RootSpec, RunContext, SpecRootIdentity,
13};
14
15#[derive(Parser, Debug)]
16#[command(name = "jan")]
17#[command(
18 about = "YAML-driven command tree: progressive --help, optional exec aliases, SQLite audit log keyed by git branch",
19 version
20)]
21pub struct JanCli {
22 #[arg(long, value_name = "DIR", env = "JAN_SPEC_DIR", global = true)]
24 pub spec_dir: Option<PathBuf>,
25
26 #[arg(long, value_name = "NAME", default_value = "jan.spec.yaml", global = true)]
28 pub spec_root: String,
29
30 #[arg(long, value_name = "FILE", env = "JAN_SPEC", global = true)]
32 pub spec: Option<PathBuf>,
33
34 #[arg(long, value_name = "FILE", env = "JAN_DB", global = true)]
36 pub db: Option<PathBuf>,
37
38 #[arg(long, global = true)]
40 pub branch: Option<String>,
41
42 #[arg(long, global = true)]
44 pub no_log: bool,
45
46 #[arg(long, global = true, default_value = ".")]
48 pub cwd: PathBuf,
49
50 #[arg(long = "extra-spec", value_name = "FILE", global = true)]
52 pub extra_spec: Vec<PathBuf>,
53
54 #[arg(long, global = true)]
56 pub stdin_spec: bool,
57
58 #[arg(short, long, global = true)]
60 pub verbose: bool,
61
62 #[arg(trailing_var_arg = true, allow_hyphen_values = true)]
64 pub command: Vec<OsString>,
65}
66
67fn extra_paths_from_env() -> Vec<PathBuf> {
68 std::env::var("JAN_EXTRA_SPEC")
69 .ok()
70 .map(|s| {
71 s.split(',')
72 .map(|p| PathBuf::from(p.trim()))
73 .filter(|p| !p.as_os_str().is_empty())
74 .collect()
75 })
76 .unwrap_or_default()
77}
78
79pub fn run_jan() -> Result<i32> {
80 let cli = JanCli::parse();
81 let cwd = cli
82 .cwd
83 .canonicalize()
84 .unwrap_or_else(|_| cli.cwd.clone());
85
86 if cli.spec_dir.is_some() && cli.spec.is_some() {
87 bail!("use either --spec-dir or --spec, not both");
88 }
89
90 let mut extra_paths = extra_paths_from_env();
91 extra_paths.extend(cli.extra_spec.iter().cloned());
92
93 let mut _stdin_temp: Option<NamedTempFile> = None;
94 if cli.stdin_spec {
95 let mut buf = String::new();
96 std::io::stdin()
97 .read_to_string(&mut buf)
98 .context("read stdin for --stdin-spec")?;
99 if buf.trim().is_empty() {
100 buf = "commands: {}\n".to_string();
101 }
102 let mut tmp = NamedTempFile::with_suffix(".yaml").context("temp file for --stdin-spec")?;
103 tmp.write_all(buf.as_bytes())?;
104 tmp.flush()?;
105 let p = tmp.path().to_path_buf();
106 _stdin_temp = Some(tmp);
107 extra_paths.push(p);
108 }
109
110 let (mut spec, spec_identity): (RootSpec, SpecRootIdentity) =
111 if let Some(dir) = cli.spec_dir.clone() {
112 let (path, id) =
113 resolve_spec_dir_entry(&dir, &cli.spec_root, &cwd).context("resolve --spec-dir")?;
114 (
115 load_spec(&path).with_context(|| format!("load {}", path.display()))?,
116 id,
117 )
118 } else if let Some(path) = resolve_spec_path(cli.spec, &cwd)? {
119 let id = spec_identity_for_spec_file(&path)?;
120 (
121 load_spec(&path).with_context(|| format!("load {}", path.display()))?,
122 id,
123 )
124 } else {
125 (
126 load_embedded_default_spec().context("load embedded default spec")?,
127 embedded_spec_identity(),
128 )
129 };
130
131 if !extra_paths.is_empty() {
132 if cli.verbose {
133 eprintln!("jan: merging {} extra spec fragment(s)", extra_paths.len());
134 for p in &extra_paths {
135 eprintln!("jan: {}", p.display());
136 }
137 }
138 for p in &extra_paths {
139 let overlay =
140 load_spec(p).with_context(|| format!("load extra spec {}", p.display()))?;
141 merge_specs_into(&mut spec, overlay).with_context(|| format!("merge {}", p.display()))?;
142 }
143 validate_spec(&spec).context("validate merged spec")?;
144 }
145
146 if cli.verbose {
147 eprintln!("jan: cwd={}", cwd.display());
148 eprintln!(
149 "jan: spec identity: {} / {}",
150 spec_identity.spec_dir, spec_identity.root_yaml
151 );
152 }
153
154 let branch = resolve_git_branch(&cwd, cli.branch.as_deref());
155 let db_path = if cli.no_log {
156 None
157 } else {
158 Some(cli.db.as_ref().cloned().unwrap_or_else(default_db_path))
159 };
160
161 let ctx = RunContext {
162 cwd: &cwd,
163 db_path: db_path.as_deref(),
164 branch,
165 no_log: cli.no_log,
166 spec_root: &spec_identity,
167 };
168
169 if cli.command.is_empty() {
170 let help = format_help(&spec, &[], None);
171 print!("{help}");
172 return Ok(0);
173 }
174
175 if let Some(first) = cli.command.first() {
176 let key = first.to_string_lossy();
177 if builtins::is_builtin_reserved(key.as_ref()) {
178 let tail: Vec<_> = cli.command[1..].to_vec();
179 return match key.as_ref() {
180 "bundle" => builtins::bundle_spec_zip(&spec_identity, &extra_paths, &tail, cli.verbose),
181 "alias" => builtins::emit_shell_aliases(&spec, &spec_identity, &tail),
182 _ => Ok(0),
183 };
184 }
185 }
186
187 let m = match_commands(&spec, &cli.command);
188
189 if m.wants_help {
190 let help = format_help(&spec, &m.chain, m.node);
191 print!("{help}");
192 return Ok(0);
193 }
194
195 if m.trailing.iter().any(|a| a == "--help" || a == "-h") {
196 return Err(anyhow!(
197 "place --help immediately after the subcommand prefix you want help for"
198 ));
199 }
200
201 let node = match m.node {
202 Some(n) => n,
203 None => {
204 let key = cli.command[0].to_string_lossy();
205 return Err(anyhow!("unknown top-level command `{key}`"));
206 }
207 };
208
209 if !m.trailing.is_empty() && !node.is_leaf_exec() {
210 let t = m.trailing[0].to_string_lossy();
211 return Err(anyhow!("unknown subcommand `{t}`"));
212 }
213
214 if node.is_leaf_exec() {
215 return run_matched(&spec, &m.chain, node, &m.trailing, &ctx);
216 }
217
218 let help = format_help(&spec, &m.chain, Some(node));
219 print!("{help}");
220 Ok(0)
221}