#![doc(hidden)]
use crate::cli::argv::{explicit_output_from_argv, output_from_argv};
use crate::cli::clap_errors::clap_error_to_bird;
use crate::cli::dispatch::{
GuardOutcome, ListFlags, clamp_limit, command_needs_xurl, require_confirmation,
};
use crate::cli::{Cli, Command, OutputFlags, SkillAction, WatchlistCommand};
use crate::config::{ArgOverrides, EnvOverrides, ResolvedConfig, ResolvedPaths};
use crate::error::BirdError;
use crate::output::{OutputConfig, OutputFormat};
use crate::{db, doctor, output, schema, schema_print, skill_install, transport, watchlist};
use clap::Parser;
use std::ffi::OsString;
use std::io::{IsTerminal, Write};
use std::process::ExitCode;
const TOP_LEVEL_EXAMPLES: &str = include_str!("../../examples/top-level.txt");
fn print_examples(out: &OutputConfig, stdout: &mut dyn Write) -> ExitCode {
if out.format.is_json() {
let qualified: Vec<String> = TOP_LEVEL_EXAMPLES
.lines()
.filter_map(|line| {
let trimmed = line.trim_start();
trimmed.strip_prefix("bird ").map(|rest| {
let cmd = rest.split('#').next().unwrap_or(rest).trim_end();
format!("bird {}", cmd)
})
})
.filter(|s| !s.is_empty())
.collect();
let data = serde_json::json!(qualified);
let meta = serde_json::json!({"count": qualified.len()});
match output::success_envelope_string(&data, &meta) {
Ok(line) => {
let _ = writeln!(stdout, "{}", line);
}
Err(_) => {
let _ = writeln!(stdout, "{}", TOP_LEVEL_EXAMPLES);
}
}
} else {
let _ = write!(stdout, "{}", TOP_LEVEL_EXAMPLES);
}
ExitCode::SUCCESS
}
pub fn run_argv() -> ExitCode {
let args: Vec<OsString> = std::env::args_os().collect();
let stdout = std::io::stdout();
let stderr = std::io::stderr();
let mut stdout_lock = stdout.lock();
let mut stderr_lock = stderr.lock();
run(args, &mut stdout_lock, &mut stderr_lock)
}
pub fn run<I, S>(args: I, stdout: &mut dyn Write, stderr: &mut dyn Write) -> ExitCode
where
I: IntoIterator<Item = S>,
S: Into<OsString> + Clone,
{
let paths = match ResolvedPaths::from_env() {
Ok(p) => p,
Err(e) => {
let err = BirdError::config(e);
err.print();
return ExitCode::from(err.exit_code());
}
};
let env = EnvOverrides::from_env();
run_with_paths(args, stdout, stderr, paths, env)
}
pub fn run_with_paths<I, S>(
args: I,
stdout: &mut dyn Write,
stderr: &mut dyn Write,
paths: ResolvedPaths,
env: EnvOverrides,
) -> ExitCode
where
I: IntoIterator<Item = S>,
S: Into<OsString> + Clone,
{
let args_os: Vec<OsString> = args.into_iter().map(|s| s.into()).collect();
let argv: Vec<String> = args_os
.iter()
.map(|s| s.to_string_lossy().into_owned())
.collect();
let argv_output = output_from_argv(&argv);
let explicit_output = explicit_output_from_argv(&argv);
if argv.iter().any(|a| a == "--examples") {
let fmt = argv_output;
let cfg = OutputConfig {
format: fmt,
use_color: output::use_color_auto() && !fmt.is_json(),
quiet: false,
raw: false,
};
return print_examples(&cfg, stdout);
}
let cli = match Cli::try_parse_from(args_os.iter()) {
Ok(c) => c,
Err(e) => match clap_error_to_bird(&e) {
None => {
let wrap_in_envelope = explicit_output.is_some_and(|f| f.is_json());
if wrap_in_envelope {
let body = e.to_string();
let kind = match e.kind() {
clap::error::ErrorKind::DisplayVersion => "version",
_ => "help",
};
let data = serde_json::json!({
kind: body.trim(),
});
let meta = serde_json::json!({"format": "text"});
match output::success_envelope_string(&data, &meta) {
Ok(line) => {
let _ = writeln!(stdout, "{}", line);
}
Err(_) => {
let _ = e.print();
}
}
} else {
let _ = e.print();
}
return ExitCode::SUCCESS;
}
Some(bird_err) => {
let fmt = argv_output;
let cfg = OutputConfig {
format: fmt,
use_color: output::use_color_auto() && !fmt.is_json(),
quiet: false,
raw: false,
};
let _ = cfg.print_error(stderr, &bird_err);
return ExitCode::from(bird_err.exit_code());
}
},
};
let color_mode = cli.effective_color();
let use_color = output::resolve_color(color_mode);
let raw = cli.raw;
let output_format = cli.effective_output().unwrap_or_else(|| {
if std::io::stderr().is_terminal() {
OutputFormat::Text
} else {
OutputFormat::Json
}
});
let out = OutputConfig {
format: output_format,
use_color,
quiet: cli.quiet,
raw,
};
let xurl_timeout = std::time::Duration::from_secs(cli.timeout);
if let Command::Completions { shell } = &cli.command {
use clap::CommandFactory;
clap_complete::generate(*shell, &mut Cli::command(), "bird", stdout);
return ExitCode::SUCCESS;
}
if let Command::Skill { action } = &cli.command {
let code = match *action {
SkillAction::Install { host, all, dry_run } => {
skill_install::run_install_multi(host, all, dry_run, &out, stdout)
}
SkillAction::Update { host, all, dry_run } => {
skill_install::run_update_multi(host, all, dry_run, &out, stdout)
}
};
return ExitCode::from((code.clamp(0, 255)) as u8);
}
if let Command::Schema { name, list } = &cli.command {
return match schema_print::run(name.as_deref(), *list, &out, stdout) {
Ok(()) => ExitCode::SUCCESS,
Err(err) => {
let _ = out.print_error(stderr, &err);
ExitCode::from(err.exit_code())
}
};
}
let cli_username = match cli.username {
Some(ref raw) => match schema::validate_username(raw) {
Ok(clean) => Some(clean.to_string()),
Err(e) => {
let err = BirdError::config(format!("--username: {}", e));
let _ = out.print_error(stderr, &err);
return ExitCode::from(err.exit_code());
}
},
None => None,
};
let env_username = env
.username
.clone()
.and_then(|u| match schema::validate_username(&u) {
Ok(s) => Some(s.to_string()),
Err(e) => {
if !out.suppress_diag() {
writeln!(
stderr,
"[config] warning: X_API_USERNAME invalid, ignoring: {}",
e
)
.ok();
}
None
}
});
let overrides = ArgOverrides {
username: cli_username,
env_username,
};
let config = match ResolvedConfig::load_with_paths(overrides, paths.clone(), env.clone()) {
Ok(c) => c,
Err(e) => {
let err = BirdError::config(e);
let _ = out.print_error(stderr, &err);
return ExitCode::from(err.exit_code());
}
};
let xurl_resolution = transport::resolve_xurl_path(&env);
let transport: Box<dyn transport::Transport> = match &xurl_resolution {
Ok(path) => Box::new(transport::XurlTransport::new(path.clone(), xurl_timeout)),
Err(e) => Box::new(transport::XurlTransport::from_error(
e.to_string(),
xurl_timeout,
)),
};
let cache_opts = db::CacheOpts {
no_store: cli.no_cache || !config.cache_enabled,
refresh: cli.refresh,
cache_only: cli.cache_only,
};
let client_stderr: std::sync::Arc<std::sync::Mutex<dyn std::io::Write + Send>> =
std::sync::Arc::new(std::sync::Mutex::new(std::io::stderr()));
let mut client = db::BirdClient::new(
transport,
&config.cache_path,
cache_opts,
config.cache_max_size_mb,
config.username.clone(),
out.suppress_diag(),
client_stderr,
);
if let Command::Doctor {
command,
common: OutputFlags { pretty },
} = &cli.command
{
let scope = command.as_deref();
let pretty = *pretty;
let use_emoji = use_color && pretty;
match doctor::run_doctor(&client, &out, stdout, stderr, pretty, scope, use_emoji) {
Ok(()) => return ExitCode::SUCCESS,
Err(e) => {
let err = BirdError::general("doctor", e);
let _ = out.print_error(stderr, &err);
return ExitCode::from(err.exit_code());
}
}
}
if let Command::Watchlist {
ref action,
common: OutputFlags { pretty },
} = cli.command
&& !matches!(action, WatchlistCommand::Fetch)
{
let result = match action {
WatchlistCommand::Add { username } => {
watchlist::run_watchlist_add(&config, &out, stderr, username)
.map_err(BirdError::config)
}
WatchlistCommand::Remove { username, guard } => {
let target = format!("watchlist:@{}", username);
match require_confirmation(
"remove",
"LOCAL",
&target,
None,
*guard,
&out,
cli.no_interactive,
stdout,
stderr,
None,
) {
Ok(GuardOutcome::DryRun) => Ok(()),
Ok(GuardOutcome::Proceed) => {
watchlist::run_watchlist_remove(&config, &out, stderr, username)
.map_err(BirdError::config)
}
Err(e) => Err(e),
}
}
WatchlistCommand::List => {
let (limit, _) = clamp_limit(cli.limit, 1000, 10_000);
watchlist::run_watchlist_list(
&config,
&out,
stdout,
stderr,
pretty,
Some(limit),
cli.cursor.as_deref(),
)
.map_err(|e| BirdError::from_source("watchlist", e))
}
WatchlistCommand::Fetch => unreachable!(),
};
return match result {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
let _ = out.print_error(stderr, &e);
ExitCode::from(e.exit_code())
}
};
}
let stdin_is_tty = std::io::stdin().is_terminal();
if command_needs_xurl(&cli.command, stdin_is_tty, cli.no_interactive)
&& !cli.cache_only
&& let Err(e) = &xurl_resolution
{
let err = BirdError::config(e.to_string());
let _ = out.print_error(stderr, &err);
return ExitCode::from(err.exit_code());
}
let list_flags = ListFlags {
limit: cli.limit,
cursor: cli.cursor.clone(),
};
match crate::cli::dispatch::run(
cli.command,
config,
&mut client,
&out,
stdout,
stderr,
cli.cache_only,
cli.no_interactive,
list_flags,
) {
Ok(()) => ExitCode::SUCCESS,
Err(e) => {
let _ = out.print_error(stderr, &e);
ExitCode::from(e.exit_code())
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::path::PathBuf;
fn test_paths() -> ResolvedPaths {
use std::time::{SystemTime, UNIX_EPOCH};
let nanos = SystemTime::now()
.duration_since(UNIX_EPOCH)
.unwrap()
.as_nanos();
let tmp: PathBuf =
std::env::temp_dir().join(format!("bird-runner-test-{}-{}", std::process::id(), nanos));
ResolvedPaths {
config_dir: tmp.clone(),
store_path: tmp,
}
}
fn exit_eq(actual: ExitCode, expected: ExitCode) -> bool {
format!("{:?}", actual) == format!("{:?}", expected)
}
#[test]
fn run_with_paths_help_returns_zero() {
let paths = test_paths();
let env = EnvOverrides::default();
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let exit = run_with_paths(["bird", "--help"], &mut stdout, &mut stderr, paths, env);
assert!(
exit_eq(exit, ExitCode::SUCCESS),
"--help should exit 0, got {:?}",
exit
);
}
#[test]
fn run_with_paths_bogus_flag_returns_two() {
let paths = test_paths();
let env = EnvOverrides::default();
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let exit = run_with_paths(["bird", "--bogus"], &mut stdout, &mut stderr, paths, env);
assert!(
exit_eq(exit, ExitCode::from(2)),
"bogus flag should exit 2, got {:?}",
exit
);
}
#[test]
fn run_with_paths_version_returns_zero() {
let paths = test_paths();
let env = EnvOverrides::default();
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let exit = run_with_paths(["bird", "--version"], &mut stdout, &mut stderr, paths, env);
assert!(
exit_eq(exit, ExitCode::SUCCESS),
"--version should exit 0, got {:?}",
exit
);
}
}