use std::ffi::OsString;
use std::io::{Read, Write};
use std::path::PathBuf;
use anyhow::{anyhow, bail, Context, Result};
use clap::Parser;
use tempfile::NamedTempFile;
use crate::{
builtins, default_db_path, format_help, load_spec, match_commands, merge_specs_into,
resolve_git_branch, resolve_spec_dir_entry, resolve_spec_path, resolve_well_known_spec,
run_matched, spec_identity_for_spec_file, validate_spec, RootSpec, RunContext,
SpecRootIdentity,
};
#[derive(Parser, Debug)]
#[command(name = "jan")]
#[command(
about = "YAML-driven command tree: progressive --help, optional exec aliases, SQLite audit log keyed by git branch",
version
)]
pub struct JanCli {
#[arg(long, value_name = "DIR", env = "JAN_SPEC_DIR", global = true)]
pub spec_dir: Option<PathBuf>,
#[arg(
long,
value_name = "NAME",
default_value = "jan.spec.yaml",
global = true
)]
pub spec_root: String,
#[arg(long, value_name = "FILE", env = "JAN_SPEC", global = true)]
pub spec: Option<PathBuf>,
#[arg(long, value_name = "FILE", env = "JAN_DB", global = true)]
pub db: Option<PathBuf>,
#[arg(long, global = true)]
pub branch: Option<String>,
#[arg(long, global = true)]
pub no_log: bool,
#[arg(long, global = true, default_value = ".")]
pub cwd: PathBuf,
#[arg(long = "extra-spec", value_name = "FILE", global = true)]
pub extra_spec: Vec<PathBuf>,
#[arg(long, global = true)]
pub stdin_spec: bool,
#[arg(short, long, global = true)]
pub verbose: bool,
#[arg(trailing_var_arg = true, allow_hyphen_values = true)]
pub command: Vec<OsString>,
}
fn extra_paths_from_env() -> Vec<PathBuf> {
std::env::var("JAN_EXTRA_SPEC")
.ok()
.map(|s| {
s.split(',')
.map(|p| PathBuf::from(p.trim()))
.filter(|p| !p.as_os_str().is_empty())
.collect()
})
.unwrap_or_default()
}
pub fn run_jan() -> Result<i32> {
let cli = JanCli::parse();
let cwd = cli.cwd.canonicalize().unwrap_or_else(|_| cli.cwd.clone());
if cli.spec_dir.is_some() && cli.spec.is_some() {
bail!("use either --spec-dir or --spec, not both");
}
let mut extra_paths = extra_paths_from_env();
extra_paths.extend(cli.extra_spec.iter().cloned());
let mut _stdin_temp: Option<NamedTempFile> = None;
if cli.stdin_spec {
let mut buf = String::new();
std::io::stdin()
.read_to_string(&mut buf)
.context("read stdin for --stdin-spec")?;
if buf.trim().is_empty() {
buf = "commands: {}\n".to_string();
}
let mut tmp = NamedTempFile::with_suffix(".yaml").context("temp file for --stdin-spec")?;
tmp.write_all(buf.as_bytes())?;
tmp.flush()?;
let p = tmp.path().to_path_buf();
_stdin_temp = Some(tmp);
extra_paths.push(p);
}
let (mut spec, spec_identity): (RootSpec, SpecRootIdentity) =
if let Some(dir) = cli.spec_dir.clone() {
let (path, id) =
resolve_spec_dir_entry(&dir, &cli.spec_root, &cwd).context("resolve --spec-dir")?;
(
load_spec(&path).with_context(|| format!("load {}", path.display()))?,
id,
)
} else if let Some(path) = resolve_spec_path(cli.spec, &cwd)? {
let id = spec_identity_for_spec_file(&path)?;
(
load_spec(&path).with_context(|| format!("load {}", path.display()))?,
id,
)
} else {
let (path, id) = resolve_well_known_spec(&cwd).context("resolve well-known spec")?;
(
load_spec(&path).with_context(|| format!("load {}", path.display()))?,
id,
)
};
if !extra_paths.is_empty() {
if cli.verbose {
eprintln!("jan: merging {} extra spec fragment(s)", extra_paths.len());
for p in &extra_paths {
eprintln!("jan: {}", p.display());
}
}
for p in &extra_paths {
let overlay =
load_spec(p).with_context(|| format!("load extra spec {}", p.display()))?;
merge_specs_into(&mut spec, overlay)
.with_context(|| format!("merge {}", p.display()))?;
}
validate_spec(&spec).context("validate merged spec")?;
}
if cli.verbose {
eprintln!("jan: cwd={}", cwd.display());
eprintln!(
"jan: spec identity: {} / {}",
spec_identity.spec_dir, spec_identity.root_yaml
);
}
let branch = resolve_git_branch(&cwd, cli.branch.as_deref());
let db_path = if cli.no_log {
None
} else {
Some(cli.db.as_ref().cloned().unwrap_or_else(default_db_path))
};
let ctx = RunContext {
cwd: &cwd,
db_path: db_path.as_deref(),
branch,
no_log: cli.no_log,
spec_root: &spec_identity,
};
if cli.command.is_empty() {
let help = format_help(&spec, &[], None);
print!("{help}");
return Ok(0);
}
if let Some(first) = cli.command.first() {
let key = first.to_string_lossy();
if builtins::is_builtin_reserved(key.as_ref()) {
let tail: Vec<_> = cli.command[1..].to_vec();
return match key.as_ref() {
"bundle" => {
builtins::bundle_spec_zip(&spec_identity, &extra_paths, &tail, cli.verbose)
}
"alias" => builtins::emit_shell_aliases(&spec, &spec_identity, &tail),
_ => Ok(0),
};
}
}
let m = match_commands(&spec, &cli.command);
if m.wants_help {
let help = format_help(&spec, &m.chain, m.node);
print!("{help}");
return Ok(0);
}
if m.trailing.iter().any(|a| a == "--help" || a == "-h") {
return Err(anyhow!(
"place --help immediately after the subcommand prefix you want help for"
));
}
let node = match m.node {
Some(n) => n,
None => {
let key = cli.command[0].to_string_lossy();
return Err(anyhow!("unknown top-level command `{key}`"));
}
};
if !m.trailing.is_empty() && !node.is_leaf_exec() {
let t = m.trailing[0].to_string_lossy();
return Err(anyhow!("unknown subcommand `{t}`"));
}
if node.is_leaf_exec() {
return run_matched(&spec, &m.chain, node, &m.trailing, &ctx);
}
let help = format_help(&spec, &m.chain, Some(node));
print!("{help}");
Ok(0)
}