use std::io::Write;
use std::sync::{Arc, Mutex};
use serde_json::{Map, Value};
use crate::cli::completion::print_completion;
use crate::ctx::Ctx;
use crate::errors;
use crate::registry::Registry;
use crate::spec::Entry;
use super::format::Format;
use super::tree::{CmdNode, build_tree};
type SharedWriter = Arc<Mutex<Box<dyn Write + Send>>>;
static CLI_VERSION: std::sync::OnceLock<&'static str> = std::sync::OnceLock::new();
pub fn set_cli_version(v: &'static str) {
let _ = CLI_VERSION.set(v);
}
fn cli_version() -> &'static str {
CLI_VERSION.get_or_init(crate::version::version)
}
pub struct App {
pub(crate) root: CmdNode,
pub(crate) out: SharedWriter,
pub(crate) err_out: SharedWriter,
pub(crate) mws: Vec<ExecFunc>,
pub(crate) default_format: String,
pub(crate) format_from_flag: bool,
pub(crate) format_interactive: String,
pub(crate) format_piped: String,
pub(crate) interactive_override: Option<bool>,
pub(crate) out_injected: bool,
}
#[derive(Default)]
pub struct Options {
pub out: Option<Box<dyn Write + Send>>,
pub err_out: Option<Box<dyn Write + Send>>,
pub format: Option<String>,
pub format_from_flag: bool,
pub format_interactive: Option<String>,
pub format_piped: Option<String>,
pub interactive: Option<bool>,
}
pub struct ExecContext {
pub path: String,
pub entry: Arc<Entry>,
pub format: Format,
pub json: bool,
pub out: SharedWriter,
}
pub type ExecFunc = Box<
dyn Fn(
&Ctx,
&ExecContext,
&mut Map<String, Value>,
&mut dyn FnMut(&mut Map<String, Value>) -> errors::Result<()>,
) -> errors::Result<()>
+ Send
+ Sync,
>;
impl App {
pub fn new(reg: &Registry) -> errors::Result<App> {
let root = build_tree(reg)?;
Ok(App {
root,
out: Arc::new(Mutex::new(Box::new(std::io::stdout()))),
err_out: Arc::new(Mutex::new(Box::new(std::io::stderr()))),
mws: Vec::new(),
default_format: String::new(),
format_from_flag: false,
format_interactive: String::new(),
format_piped: String::new(),
interactive_override: None,
out_injected: false,
})
}
pub fn new_with_options(reg: &Registry, opts: Options) -> errors::Result<App> {
for v in [
opts.format.as_deref().unwrap_or(""),
opts.format_interactive.as_deref().unwrap_or(""),
opts.format_piped.as_deref().unwrap_or(""),
] {
if Format::parse(v).is_none() {
return Err(errors::Error::new(
errors::Kind::Internal,
format!("cli: invalid format {v:?} (want auto|text|json|jsonl|markdown)"),
));
}
}
let mut a = App::new(reg)?;
if let Some(o) = opts.out {
a.out = Arc::new(Mutex::new(o));
a.out_injected = true;
}
if let Some(e) = opts.err_out {
a.err_out = Arc::new(Mutex::new(e));
}
if let Some(f) = opts.format {
a.default_format = f;
}
a.format_from_flag = opts.format_from_flag;
if let Some(v) = opts.format_interactive {
a.format_interactive = v;
}
if let Some(v) = opts.format_piped {
a.format_piped = v;
}
a.interactive_override = opts.interactive;
if a.out_injected {
}
Ok(a)
}
pub fn set_output(
&mut self,
out: Option<Box<dyn Write + Send>>,
err_out: Option<Box<dyn Write + Send>>,
) {
if let Some(o) = out {
self.out = Arc::new(Mutex::new(o));
self.out_injected = true;
}
if let Some(e) = err_out {
self.err_out = Arc::new(Mutex::new(e));
}
}
pub fn use_mw(&mut self, mw: ExecFunc) {
self.mws.push(mw);
}
pub fn run(&mut self, args: &[String]) -> i32 {
self.run_ctx(&Ctx::new(), args)
}
pub fn run_ctx(&mut self, ctx: &Ctx, args: &[String]) -> i32 {
if args.first().map(String::as_str) == Some("completion") {
let shell = args.get(1).map(String::as_str).unwrap_or("bash");
return print_completion(
&self.root_collect_top(),
&mut *self.out.lock().unwrap(),
&mut *self.err_out.lock().unwrap(),
&bin_name(),
shell,
);
}
let bin = bin_name();
for arg in args {
if arg == "--" {
break; }
if arg == "-v" || arg == "--version" {
let _ = writeln!(
self.out.lock().unwrap(),
"{} version {}",
bin,
cli_version()
);
return 0;
}
}
let target = self.resolve_target(args);
let format_conflict = target.map(|n| node_has_flag(n, "format")).unwrap_or(false);
let json_conflict = target.map(|n| node_has_flag(n, "json")).unwrap_or(false);
let mut bare: Option<String> = None;
let mut filtered: Vec<String> = Vec::with_capacity(args.len());
let mut past_double_dash = false;
let mut i = 0;
while i < args.len() {
let a = &args[i];
if past_double_dash {
filtered.push(a.clone());
i += 1;
continue;
}
match a.as_str() {
"--" => {
past_double_dash = true;
filtered.push(a.clone());
}
"--json" if !json_conflict => bare = Some("json".to_string()),
"--format" if !format_conflict => {
if i + 1 >= args.len() {
let _ = writeln!(
self.err_out.lock().unwrap(),
"{bin}: --format needs an argument (text|json|jsonl|markdown)"
);
return 2;
}
i += 1;
bare = Some(args[i].clone());
}
_ if a.starts_with("--format=") && !format_conflict => {
bare = Some(a["--format=".len()..].to_string());
}
_ => filtered.push(a.clone()),
}
i += 1;
}
if let Some(b) = &bare
&& Format::parse(b).is_none()
{
let _ = writeln!(
self.err_out.lock().unwrap(),
"{bin}: invalid output format {b:?} (want auto|text|json|jsonl|markdown)"
);
return 2;
}
let entry = target.and_then(|n| n.entry.clone());
let format = self.resolve_format(bare.as_deref(), entry.as_deref());
if let Err(e) = self.execute(ctx, self.root.clone(), &filtered, format, &bin) {
if errors::is_broken_pipe(&e) {
return 141;
}
self.render_error(&e, format);
return exit_code_of(&e);
}
0
}
pub(crate) fn interactive(&self) -> bool {
if let Some(v) = self.interactive_override {
return v;
}
if self.out_injected {
return false;
}
crate::termx::interactive()
}
pub(crate) fn resolve_format(&self, bare: Option<&str>, entry: Option<&Entry>) -> Format {
let iv = self.interactive();
let resolved = |s: &str| match Format::parse(s) {
Some(Format::Auto) => {
Format::Auto.resolve(iv, &self.format_interactive, &self.format_piped)
}
Some(f) => f,
None => Format::Text,
};
if let Some(b) = bare {
return resolved(b);
}
if self.format_from_flag {
return resolved(&self.default_format);
}
if let Some(e) = entry
&& !e.cli.format.is_empty()
{
return resolved(&e.cli.format);
}
if !self.default_format.is_empty() {
return resolved(&self.default_format);
}
Format::Auto.resolve(iv, &self.format_interactive, &self.format_piped)
}
fn resolve_target(&self, args: &[String]) -> Option<&CmdNode> {
let mut node: &CmdNode = &self.root;
let mut rest = args;
loop {
let Some(first) = rest.first() else {
return Some(node);
};
if first == "--" || first.starts_with('-') {
rest = &rest[1..];
continue;
}
if let Some(child) = node
.children
.iter()
.find(|c| c.segment == *first || (c.leaf && c.aliases.iter().any(|a| a == first)))
{
node = child;
rest = &rest[1..];
continue;
}
let default_seg = node.default_segment.clone();
if let Some(seg) = default_seg
&& let Some(child) = node.children.iter().find(|c| c.segment == seg)
{
node = child;
continue; }
return Some(node);
}
}
fn render_error(&self, e: &errors::Error, format: Format) {
let mut w = self.err_out.lock().unwrap();
match format {
Format::Json | Format::JsonL => {
let body = errors::error_body(e);
let s = if format == Format::Json {
serde_json::to_string_pretty(&body)
} else {
serde_json::to_string(&body)
}
.unwrap_or_else(|_| "{\"error\":\"\"}".to_string());
let _ = writeln!(*w, "{s}");
}
_ => {
let _ = writeln!(*w, "{e}");
}
}
}
fn root_collect_top(&self) -> Vec<String> {
self.root
.children
.iter()
.map(|c| c.segment.clone())
.collect()
}
pub fn execute(
&mut self,
ctx: &Ctx,
mut node: CmdNode,
args: &[String],
format: Format,
bin: &str,
) -> errors::Result<()> {
let mut rest = args;
while let Some(first) = rest.first() {
let hit = node
.children
.iter()
.find(|c| c.segment == *first || (c.leaf && c.aliases.iter().any(|a| a == first)));
match hit {
Some(child) => {
node = child.clone();
rest = &rest[1..];
}
None => break,
}
}
if !node.leaf
&& !rest.is_empty()
&& !rest[0].starts_with('-')
&& let Some(seg) = node.default_segment.clone()
&& let Some(child) = node.children.iter().find(|c| c.segment == seg)
{
node = child.clone();
}
for t in rest {
if t == "-h" || t == "--help" {
return self.print_help(&node, bin);
}
}
if !node.leaf {
return self.print_help(&node, bin);
}
let (fvals, pos) = super::parse::parse_flags(&node.defs, rest)?;
if pos.len() < node.min_pos || pos.len() > node.max_pos {
return Err(errors::Error::new(
errors::Kind::InvalidInput,
crate::lang::tf(
"cli.err_positional_count",
&[
&node.path.replace('.', " "),
&node.min_pos.to_string(),
&node.max_pos.to_string(),
&pos.len().to_string(),
],
),
));
}
let mut m = Map::new();
for (i, d) in node.defs.iter().enumerate() {
let fv = &fvals[i];
if fv.seen {
match d.kind {
super::parse::FlagKind::Bool => {
m.insert(d.field.json_name.clone(), Value::Bool(fv.boolean));
}
super::parse::FlagKind::Slice => {
m.insert(
d.field.json_name.clone(),
Value::Array(
fv.list.iter().map(|s| Value::String(s.clone())).collect(),
),
);
}
_ => {
m.insert(d.field.json_name.clone(), Value::String(fv.str.clone()));
}
}
continue;
}
if let Some(env) = &d.field.cli.env_var
&& let Ok(v) = std::env::var(env)
&& !v.is_empty()
{
m.insert(d.field.json_name.clone(), Value::String(v));
continue;
}
if let Some(def) = &d.field.cli.default {
m.insert(d.field.json_name.clone(), def.clone());
}
}
for f in &node.env_only {
if let Some(env) = &f.cli.env_var
&& let Ok(v) = std::env::var(env)
&& !v.is_empty()
{
m.insert(f.name.clone(), Value::String(v));
}
}
for (i, f) in node.pos_f.iter().enumerate() {
if i < pos.len() {
m.insert(f.json_name.clone(), Value::String(pos[i].clone()));
}
}
let entry = node
.entry
.as_ref()
.ok_or_else(|| {
errors::Error::new(errors::Kind::Internal, "leaf without entry".to_string())
})?
.clone();
let ec = ExecContext {
path: node.path.clone(),
entry,
format,
json: format.is_machine(),
out: Arc::clone(&self.out),
};
let terminal = |ctx: &Ctx, ec: &ExecContext, args: &mut Map<String, Value>| {
let out = (ec.entry.invoke)(ctx, args)?;
if ec.entry.cli.daemon {
return Ok(());
}
let mut w = ec.out.lock().unwrap();
match ec.format {
Format::Auto | Format::Text => super::render::render(&mut **w, &out)?,
Format::Json => {
let s = serde_json::to_string_pretty(&out).map_err(|e| {
errors::Error::new(
errors::Kind::Internal,
format!("result serialization: {e}"),
)
})?;
writeln!(*w, "{s}").map_err(io_err)?;
}
Format::JsonL => super::format::render_jsonl(&mut **w, &out)?,
Format::Markdown => super::format::render_markdown(&mut **w, &out)?,
}
Ok(())
};
let ec_ref = &ec;
#[allow(clippy::type_complexity)]
let mut chain: Box<dyn FnMut(&mut Map<String, Value>) -> errors::Result<()> + '_> =
Box::new(|m| terminal(ctx, ec_ref, m));
for i in (0..self.mws.len()).rev() {
let mw = &self.mws[i];
let inner = chain;
let cell = std::cell::RefCell::new(Some(inner));
chain = Box::new(move |m: &mut Map<String, Value>| {
let mut guard = cell.borrow_mut();
let mut inner_fn = guard.take().unwrap();
let res = {
let mut next_local = |mm: &mut Map<String, Value>| inner_fn(mm);
let mut next_trait: &mut dyn FnMut(
&mut Map<String, Value>,
) -> errors::Result<()> = &mut next_local;
(mw)(ctx, ec_ref, m, &mut next_trait)
};
*guard = Some(inner_fn);
res
});
}
let mut m = m;
chain(&mut m)
}
pub(crate) fn print_help(&mut self, node: &CmdNode, bin: &str) -> errors::Result<()> {
super::help::print_help(&mut *self.out.lock().unwrap(), node, bin)
}
}
fn io_err(e: std::io::Error) -> errors::Error {
errors::Error::new(errors::Kind::Internal, format!("write error: {e}"))
}
fn node_has_flag(node: &CmdNode, long: &str) -> bool {
node.leaf && node.defs.iter().any(|d| d.long == long)
}
pub(crate) fn exit_code_of(e: &errors::Error) -> i32 {
match errors::classify(e) {
Some(kind) => errors::exit_code(kind),
None => 2,
}
}
pub fn bin_name() -> String {
let arg0 = std::env::args().next().unwrap_or_default();
let base = std::path::Path::new(&arg0)
.file_name()
.and_then(|s| s.to_str())
.unwrap_or("app");
if base.is_empty() || base == "." || base == "/" {
"app".to_string()
} else {
base.to_string()
}
}
pub fn run(reg: &Registry, args: &[String]) -> i32 {
run_context(&Ctx::new(), reg, args, Options::default())
}
pub fn run_context(ctx: &Ctx, reg: &Registry, args: &[String], opts: Options) -> i32 {
let mut a = match App::new_with_options(reg, opts) {
Ok(a) => a,
Err(e) => {
eprintln!("{e}");
return 2;
}
};
a.run_ctx(ctx, args)
}