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