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 cli.spec_dir.is_some() && cli.spec.is_some() {
90 bail!("use either --spec-dir or --spec, not both");
91 }
92
93 let mut extra_paths = extra_paths_from_env();
94 extra_paths.extend(cli.extra_spec.iter().cloned());
95
96 let mut _stdin_temp: Option<NamedTempFile> = None;
97 if cli.stdin_spec {
98 let mut buf = String::new();
99 std::io::stdin()
100 .read_to_string(&mut buf)
101 .context("read stdin for --stdin-spec")?;
102 if buf.trim().is_empty() {
103 buf = "commands: {}\n".to_string();
104 }
105 let mut tmp = NamedTempFile::with_suffix(".yaml").context("temp file for --stdin-spec")?;
106 tmp.write_all(buf.as_bytes())?;
107 tmp.flush()?;
108 let p = tmp.path().to_path_buf();
109 _stdin_temp = Some(tmp);
110 extra_paths.push(p);
111 }
112
113 let (mut spec, spec_identity): (RootSpec, SpecRootIdentity) =
114 if let Some(dir) = cli.spec_dir.clone() {
115 let (path, id) =
116 resolve_spec_dir_entry(&dir, &cli.spec_root, &cwd).context("resolve --spec-dir")?;
117 (
118 load_spec(&path).with_context(|| format!("load {}", path.display()))?,
119 id,
120 )
121 } else if let Some(path) = resolve_spec_path(cli.spec, &cwd)? {
122 let id = spec_identity_for_spec_file(&path)?;
123 (
124 load_spec(&path).with_context(|| format!("load {}", path.display()))?,
125 id,
126 )
127 } else {
128 let (path, id) = resolve_well_known_spec(&cwd).context("resolve well-known spec")?;
129 (
130 load_spec(&path).with_context(|| format!("load {}", path.display()))?,
131 id,
132 )
133 };
134
135 if !extra_paths.is_empty() {
136 if cli.verbose {
137 eprintln!("jan: merging {} extra spec fragment(s)", extra_paths.len());
138 for p in &extra_paths {
139 eprintln!("jan: {}", p.display());
140 }
141 }
142 for p in &extra_paths {
143 let overlay =
144 load_spec(p).with_context(|| format!("load extra spec {}", p.display()))?;
145 merge_specs_into(&mut spec, overlay)
146 .with_context(|| format!("merge {}", p.display()))?;
147 }
148 validate_spec(&spec).context("validate merged spec")?;
149 }
150
151 if cli.verbose {
152 eprintln!("jan: cwd={}", cwd.display());
153 eprintln!(
154 "jan: spec identity: {} / {}",
155 spec_identity.spec_dir, spec_identity.root_yaml
156 );
157 }
158
159 let branch = resolve_git_branch(&cwd, cli.branch.as_deref());
160 let db_path = if cli.no_log {
161 None
162 } else {
163 Some(cli.db.as_ref().cloned().unwrap_or_else(default_db_path))
164 };
165
166 let ctx = RunContext {
167 cwd: &cwd,
168 db_path: db_path.as_deref(),
169 branch,
170 no_log: cli.no_log,
171 spec_root: &spec_identity,
172 };
173
174 if cli.command.is_empty() {
175 let help = format_help(&spec, &[], None);
176 print!("{help}");
177 return Ok(0);
178 }
179
180 if let Some(first) = cli.command.first() {
181 let key = first.to_string_lossy();
182 if builtins::is_builtin_reserved(key.as_ref()) {
183 let tail: Vec<_> = cli.command[1..].to_vec();
184 return match key.as_ref() {
185 "bundle" => {
186 builtins::bundle_spec_zip(&spec_identity, &extra_paths, &tail, cli.verbose)
187 }
188 "alias" => builtins::emit_shell_aliases(&spec, &spec_identity, &tail),
189 _ => Ok(0),
190 };
191 }
192 }
193
194 let m = match_commands(&spec, &cli.command);
195
196 if m.wants_help {
197 let help = format_help(&spec, &m.chain, m.node);
198 print!("{help}");
199 return Ok(0);
200 }
201
202 if m.trailing.iter().any(|a| a == "--help" || a == "-h") {
203 return Err(anyhow!(
204 "place --help immediately after the subcommand prefix you want help for"
205 ));
206 }
207
208 let node = match m.node {
209 Some(n) => n,
210 None => {
211 let key = cli.command[0].to_string_lossy();
212 return Err(anyhow!("unknown top-level command `{key}`"));
213 }
214 };
215
216 if !m.trailing.is_empty() && !node.is_leaf_exec() {
217 let t = m.trailing[0].to_string_lossy();
218 return Err(anyhow!("unknown subcommand `{t}`"));
219 }
220
221 if node.is_leaf_exec() {
222 return run_matched(&spec, &m.chain, node, &m.trailing, &ctx);
223 }
224
225 let help = format_help(&spec, &m.chain, Some(node));
226 print!("{help}");
227 Ok(0)
228}