use crate::cli::commands;
use crate::cli::{CacheAction, Command, OutputFlags, WatchlistCommand, WriteGuard};
use crate::config::ResolvedConfig;
use crate::db;
use crate::error::BirdError;
use crate::output::{self, OutputConfig};
use crate::requirements;
use crate::schema;
use std::collections::HashMap;
use std::io::{IsTerminal, Write};
#[allow(clippy::too_many_arguments)]
pub fn run(
command: Command,
config: ResolvedConfig,
client: &mut db::BirdClient,
out: &OutputConfig,
stdout: &mut dyn Write,
stderr: &mut dyn Write,
cache_only: bool,
no_interactive: bool,
list_flags: ListFlags,
) -> Result<(), BirdError> {
match command {
Command::Login { headless } => commands::login::run(
client,
out,
stdout,
stderr,
headless,
config.username.as_deref(),
),
Command::Me {
common: OutputFlags { pretty },
} => commands::reads::run_me(client, out, stdout, stderr, pretty),
Command::Get {
path,
param,
query,
common: OutputFlags { pretty },
} => commands::reads::run_get(
client,
out,
stdout,
stderr,
path,
param,
query,
pretty,
&list_flags,
),
Command::Bookmarks {
common: OutputFlags { pretty },
} => commands::bookmarks::run(client, out, stdout, stderr, pretty, &list_flags),
Command::Profile {
username,
common: OutputFlags { pretty },
} => commands::profile::run(client, out, stdout, stderr, username, pretty),
Command::Search {
query,
common: OutputFlags { pretty },
sort,
min_likes,
max_results,
pages,
} => commands::search::run(
client,
out,
stdout,
stderr,
query,
pretty,
sort,
min_likes,
max_results,
pages,
&list_flags,
),
Command::Thread {
tweet_id,
common: OutputFlags { pretty },
max_pages,
} => commands::thread::run(client, out, stdout, stderr, tweet_id, pretty, max_pages),
Command::Post {
path,
param,
query,
body,
common: OutputFlags { pretty },
guard,
} => commands::raw_write::run_post(
client,
out,
stdout,
stderr,
path,
param,
query,
body,
pretty,
guard,
no_interactive,
),
Command::Put {
path,
param,
query,
body,
common: OutputFlags { pretty },
guard,
} => commands::raw_write::run_put(
client,
out,
stdout,
stderr,
path,
param,
query,
body,
pretty,
guard,
no_interactive,
),
Command::Delete {
path,
param,
query,
common: OutputFlags { pretty },
guard,
} => commands::raw_write::run_delete(
client,
out,
stdout,
stderr,
path,
param,
query,
pretty,
guard,
no_interactive,
),
Command::Watchlist {
action,
common: OutputFlags { pretty },
} => match action {
WatchlistCommand::Fetch => commands::watchlist::run_fetch(
client,
out,
stdout,
stderr,
&config,
pretty,
&list_flags,
),
WatchlistCommand::Add { .. }
| WatchlistCommand::Remove { .. }
| WatchlistCommand::List => Ok(()),
},
Command::Usage {
since,
local,
common: OutputFlags { pretty },
} => commands::usage::run(client, out, stdout, stderr, since, local, pretty),
Command::Tweet {
text,
media_id,
guard,
} => commands::writes::run_tweet(
client,
out,
stdout,
text,
media_id,
guard,
cache_only,
no_interactive,
config.username.as_deref(),
),
Command::Reply {
tweet_id,
text,
guard,
} => commands::writes::run_reply(
client,
out,
stdout,
tweet_id,
text,
guard,
cache_only,
no_interactive,
config.username.as_deref(),
),
Command::Like { tweet_id, guard } => commands::writes::run_like(
client,
out,
stdout,
tweet_id,
guard,
cache_only,
no_interactive,
config.username.as_deref(),
),
Command::Unlike { tweet_id, guard } => commands::writes::run_unlike(
client,
out,
stdout,
tweet_id,
guard,
cache_only,
no_interactive,
config.username.as_deref(),
),
Command::Repost { tweet_id, guard } => commands::writes::run_repost(
client,
out,
stdout,
tweet_id,
guard,
cache_only,
no_interactive,
config.username.as_deref(),
),
Command::Unrepost { tweet_id, guard } => commands::writes::run_unrepost(
client,
out,
stdout,
tweet_id,
guard,
cache_only,
no_interactive,
config.username.as_deref(),
),
Command::Follow {
username: target,
guard,
} => commands::writes::run_follow(
client,
out,
stdout,
target,
guard,
cache_only,
no_interactive,
config.username.as_deref(),
),
Command::Unfollow {
username: target,
guard,
} => commands::writes::run_unfollow(
client,
out,
stdout,
target,
guard,
cache_only,
no_interactive,
config.username.as_deref(),
),
Command::Dm {
username: target,
text,
guard,
} => commands::writes::run_dm(
client,
out,
stdout,
target,
text,
guard,
cache_only,
no_interactive,
config.username.as_deref(),
),
Command::Block {
username: target,
guard,
} => commands::writes::run_block(
client,
out,
stdout,
target,
guard,
cache_only,
no_interactive,
config.username.as_deref(),
),
Command::Unblock {
username: target,
guard,
} => commands::writes::run_unblock(
client,
out,
stdout,
target,
guard,
cache_only,
no_interactive,
config.username.as_deref(),
),
Command::Mute {
username: target,
guard,
} => commands::writes::run_mute(
client,
out,
stdout,
target,
guard,
cache_only,
no_interactive,
config.username.as_deref(),
),
Command::Unmute {
username: target,
guard,
} => commands::writes::run_unmute(
client,
out,
stdout,
target,
guard,
cache_only,
no_interactive,
config.username.as_deref(),
),
Command::Cache { action } => {
commands::cache::run(client, out, stdout, stderr, action, no_interactive)
}
Command::Doctor { .. }
| Command::Completions { .. }
| Command::Skill { .. }
| Command::Schema { .. } => Ok(()),
}
}
pub fn parse_param_vec(param: &[String]) -> HashMap<String, String> {
let mut m = HashMap::new();
for p in param {
if let Some((k, v)) = p.split_once('=') {
m.insert(k.to_string(), v.to_string());
}
}
m
}
pub fn command_needs_xurl(cmd: &Command, stdin_is_tty: bool, no_interactive: bool) -> bool {
let guard_proceeds = |g: &WriteGuard| -> bool {
if g.dry_run {
return false;
}
if g.force {
return true;
}
stdin_is_tty && !no_interactive
};
match cmd {
Command::Cache { action } => match action {
CacheAction::Clear { guard } => guard_proceeds(guard),
CacheAction::Stats { .. } => false,
},
Command::Watchlist { action, .. } => matches!(action, WatchlistCommand::Fetch),
Command::Login { .. } => true,
Command::Completions { .. } | Command::Schema { .. } | Command::Skill { .. } => false,
Command::Doctor { .. } => false,
Command::Delete { guard, .. }
| Command::Post { guard, .. }
| Command::Put { guard, .. }
| Command::Tweet { guard, .. }
| Command::Reply { guard, .. }
| Command::Like { guard, .. }
| Command::Unlike { guard, .. }
| Command::Repost { guard, .. }
| Command::Unrepost { guard, .. }
| Command::Follow { guard, .. }
| Command::Unfollow { guard, .. }
| Command::Dm { guard, .. }
| Command::Block { guard, .. }
| Command::Unblock { guard, .. }
| Command::Mute { guard, .. }
| Command::Unmute { guard, .. } => guard_proceeds(guard),
Command::Me { .. }
| Command::Get { .. }
| Command::Bookmarks { .. }
| Command::Profile { .. }
| Command::Search { .. }
| Command::Thread { .. }
| Command::Usage { .. } => true,
}
}
pub fn default_auth_type(command_name: &str) -> requirements::AuthType {
requirements::requirements_for_command(command_name)
.and_then(|r| r.accepted.first().copied())
.unwrap_or(requirements::AuthType::OAuth2User)
}
pub enum GuardOutcome {
DryRun,
Proceed,
}
#[allow(clippy::too_many_arguments)]
pub fn require_confirmation(
verb: &str,
method: &str,
target: &str,
body: Option<&serde_json::Value>,
guard: WriteGuard,
out: &OutputConfig,
no_interactive: bool,
stdout: &mut dyn Write,
prompt_writer: &mut dyn Write,
answer_reader: Option<Box<dyn FnOnce() -> std::io::Result<String>>>,
) -> Result<GuardOutcome, BirdError> {
if guard.dry_run {
emit_dry_run(verb, method, target, body, out, stdout).map_err(|e| {
BirdError::general(
"dry-run",
Box::<dyn std::error::Error + Send + Sync>::from(e),
)
})?;
return Ok(GuardOutcome::DryRun);
}
if guard.force {
return Ok(GuardOutcome::Proceed);
}
if answer_reader.is_none() {
let stdin_tty = std::io::stdin().is_terminal();
if !stdin_tty || no_interactive {
return Err(BirdError::usage(
"requires-confirmation",
format!(
"destructive operation '{}' requires --force, --yes, or interactive confirmation",
verb
),
));
}
} else if no_interactive {
return Err(BirdError::usage(
"requires-confirmation",
format!(
"destructive operation '{}' requires --force, --yes, or interactive confirmation",
verb
),
));
}
let _ = write!(
prompt_writer,
"About to {} {} {}. Proceed? [y/N] ",
verb, method, target
);
let _ = prompt_writer.flush();
let read_result = match answer_reader {
Some(f) => f(),
None => {
let mut line = String::new();
std::io::stdin().read_line(&mut line).map(|_| line)
}
};
let line = match read_result {
Ok(s) => s,
Err(_) => {
return Err(BirdError::usage(
"requires-confirmation",
"failed to read confirmation from stdin".to_string(),
));
}
};
let trimmed = line.trim();
if trimmed.eq_ignore_ascii_case("y") || trimmed.eq_ignore_ascii_case("yes") {
Ok(GuardOutcome::Proceed)
} else {
Err(BirdError::usage(
"user-aborted",
"aborted by user (confirmation declined)".to_string(),
))
}
}
pub fn emit_dry_run(
verb: &str,
method: &str,
target: &str,
body: Option<&serde_json::Value>,
out: &OutputConfig,
stdout: &mut dyn Write,
) -> std::io::Result<()> {
if out.format.is_json() {
let mut would = serde_json::json!({
"method": method,
"url": target,
});
if let Some(b) = body {
would["body"] = b.clone();
}
let data = serde_json::json!({"dry_run": true, "would": would, "verb": verb});
let meta = serde_json::json!({});
if let Ok(line) = output::success_envelope_string(&data, &meta) {
return writeln!(stdout, "{}", line);
}
}
writeln!(stdout, "Would {}: {} {}", verb, method, target)?;
if let Some(b) = body {
writeln!(stdout, "Body: {}", b)?;
}
writeln!(stdout, "(--dry-run; no request sent)")
}
pub fn build_dry_run_url(
path: &str,
params: &HashMap<String, String>,
query: &[String],
) -> Option<String> {
let resolved = schema::resolve_path(path, params).ok()?;
let mut url = url::Url::parse(&format!("https://api.x.com{}", resolved)).ok()?;
for q in query {
if let Some((k, v)) = q.split_once('=') {
url.query_pairs_mut().append_pair(k, v);
}
}
Some(url.to_string())
}
pub fn clamp_limit(requested: Option<u32>, default: u32, ceiling: u32) -> (u32, bool) {
match requested {
Some(n) if n > ceiling => (ceiling, true),
Some(0) => (default, false),
Some(n) => (n, false),
None => (default, false),
}
}
pub fn xurl_write_call(
client: &crate::db::BirdClient,
stdout: &mut dyn Write,
args: &[&str],
username: Option<&str>,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let mut full_args: Vec<String> = Vec::new();
if let Some(u) = username {
full_args.push("-u".into());
full_args.push(u.into());
}
full_args.extend(args.iter().map(|s| (*s).to_string()));
let json = client.transport_request(&full_args)?;
writeln!(stdout, "{}", serde_json::to_string(&json)?)?;
Ok(())
}
pub fn xurl_write(
cache_only: bool,
name: &'static str,
f: impl FnOnce() -> Result<(), Box<dyn std::error::Error + Send + Sync>>,
) -> Result<(), BirdError> {
if cache_only {
return Err(BirdError::general(
name,
"write commands require network access; remove --cache-only".into(),
));
}
f().map_err(|e| BirdError::from_source(name, e))
}
#[derive(Clone, Debug, Default)]
pub struct ListFlags {
pub limit: Option<u32>,
pub cursor: Option<String>,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::cli::{CacheAction, Command, OutputFlags, WriteGuard};
fn cfg() -> OutputConfig {
OutputConfig {
format: crate::output::OutputFormat::Text,
use_color: false,
quiet: true,
raw: false,
}
}
#[test]
fn command_needs_xurl_cache_stats_returns_false() {
let cmd = Command::Cache {
action: CacheAction::Stats {
common: OutputFlags::default(),
},
};
assert!(!command_needs_xurl(&cmd, true, false));
}
#[test]
fn command_needs_xurl_tweet_respects_dry_run() {
let dry = Command::Tweet {
text: "hi".to_string(),
media_id: None,
guard: WriteGuard {
force: false,
dry_run: true,
},
};
assert!(!command_needs_xurl(&dry, true, false));
let go = Command::Tweet {
text: "hi".to_string(),
media_id: None,
guard: WriteGuard {
force: true,
dry_run: false,
},
};
assert!(command_needs_xurl(&go, true, false));
}
#[test]
fn require_confirmation_yes_returns_proceed() {
let out = cfg();
let guard = WriteGuard {
force: false,
dry_run: false,
};
let mut stdout: Vec<u8> = Vec::new();
let mut buf: Vec<u8> = Vec::new();
let res = require_confirmation(
"like",
"POST",
"https://api.x.com/2/users/me/likes/1",
None,
guard,
&out,
false,
&mut stdout,
&mut buf,
Some(Box::new(|| Ok("yes\n".to_string()))),
);
assert!(matches!(res, Ok(GuardOutcome::Proceed)));
let prompt = String::from_utf8(buf).expect("prompt is utf-8");
assert!(
prompt.contains("About to like POST https://api.x.com/2/users/me/likes/1"),
"prompt should describe the action, got {prompt:?}"
);
}
#[test]
fn require_confirmation_no_returns_user_aborted() {
let out = cfg();
let guard = WriteGuard {
force: false,
dry_run: false,
};
let mut stdout: Vec<u8> = Vec::new();
let mut buf: Vec<u8> = Vec::new();
let res = require_confirmation(
"like",
"POST",
"https://api.x.com/2/users/me/likes/1",
None,
guard,
&out,
false,
&mut stdout,
&mut buf,
Some(Box::new(|| Ok("n\n".to_string()))),
);
match res {
Err(BirdError::Usage { error_id, .. }) => {
assert_eq!(error_id, "user-aborted");
}
_ => panic!("expected user-aborted usage error"),
}
}
#[test]
fn require_confirmation_dry_run_returns_dry_run_without_reader() {
let out = cfg();
let guard = WriteGuard {
force: false,
dry_run: true,
};
let mut stdout: Vec<u8> = Vec::new();
let mut buf: Vec<u8> = Vec::new();
let panic_reader: Box<dyn FnOnce() -> std::io::Result<String>> =
Box::new(|| panic!("reader must not be invoked for --dry-run"));
let res = require_confirmation(
"like",
"POST",
"https://api.x.com/2/users/me/likes/1",
None,
guard,
&out,
false,
&mut stdout,
&mut buf,
Some(panic_reader),
);
assert!(matches!(res, Ok(GuardOutcome::DryRun)));
}
#[test]
fn require_confirmation_no_interactive_errors() {
let out = cfg();
let guard = WriteGuard {
force: false,
dry_run: false,
};
let mut stdout: Vec<u8> = Vec::new();
let mut buf: Vec<u8> = Vec::new();
let res = require_confirmation(
"like",
"POST",
"https://api.x.com/2/users/me/likes/1",
None,
guard,
&out,
true,
&mut stdout,
&mut buf,
None,
);
match res {
Err(BirdError::Usage { error_id, .. }) => {
assert_eq!(error_id, "requires-confirmation");
}
_ => panic!("expected requires-confirmation usage error"),
}
}
#[test]
fn build_dry_run_url_assembles_absolute_url() {
let params: HashMap<String, String> = HashMap::new();
let query: Vec<String> = Vec::new();
let url = build_dry_run_url("/2/users/me", ¶ms, &query);
assert_eq!(url.as_deref(), Some("https://api.x.com/2/users/me"));
}
#[test]
fn clamp_limit_enforces_ceiling_and_default() {
assert_eq!(clamp_limit(Some(50_000), 1000, 10_000), (10_000, true));
assert_eq!(clamp_limit(None, 1000, 10_000), (1000, false));
assert_eq!(clamp_limit(Some(500), 1000, 10_000), (500, false));
assert_eq!(clamp_limit(Some(0), 1000, 10_000), (1000, false));
}
}