pub mod spec;
use crate::cli::WriteGuard;
use crate::cli::dispatch::{GuardOutcome, require_confirmation, xurl_write, xurl_write_call};
use crate::db;
use crate::error::BirdError;
use crate::output::OutputConfig;
use spec::WriteSpec;
use std::io::Write;
#[allow(clippy::too_many_arguments)]
pub fn execute(
spec: WriteSpec,
client: &db::BirdClient,
out: &OutputConfig,
stdout: &mut dyn Write,
guard: WriteGuard,
cache_only: bool,
no_interactive: bool,
username: Option<&str>,
) -> Result<(), BirdError> {
let outcome = require_confirmation(
spec.verb,
spec.method,
&spec.url_for_prompt,
spec.body.as_ref(),
guard,
out,
no_interactive,
stdout,
&mut std::io::stderr().lock(),
None,
)?;
if matches!(outcome, GuardOutcome::DryRun) {
return Ok(());
}
let xurl_args = spec.xurl_args;
xurl_write(cache_only, spec.verb, || {
let args: Vec<&str> = xurl_args.iter().map(String::as_str).collect();
xurl_write_call(client, stdout, &args, username)
})
}
#[allow(clippy::too_many_arguments)]
pub fn run_tweet(
client: &db::BirdClient,
out: &OutputConfig,
stdout: &mut dyn Write,
text: String,
media_id: Option<String>,
guard: WriteGuard,
cache_only: bool,
no_interactive: bool,
config_username: Option<&str>,
) -> Result<(), BirdError> {
let body = serde_json::json!({"text": text, "media_id": media_id});
let mut xurl_args: Vec<String> = vec!["post".into(), text.clone()];
if let Some(id) = media_id.as_ref() {
xurl_args.extend(["--media-id".into(), id.clone()]);
}
let spec = WriteSpec {
verb: "tweet",
method: "POST",
url_for_prompt: "https://api.x.com/2/tweets".into(),
body: Some(body),
xurl_args,
};
execute(
spec,
client,
out,
stdout,
guard,
cache_only,
no_interactive,
config_username,
)
}
#[allow(clippy::too_many_arguments)]
pub fn run_reply(
client: &db::BirdClient,
out: &OutputConfig,
stdout: &mut dyn Write,
tweet_id: String,
text: String,
guard: WriteGuard,
cache_only: bool,
no_interactive: bool,
config_username: Option<&str>,
) -> Result<(), BirdError> {
let spec = WriteSpec {
verb: "reply",
method: "POST",
url_for_prompt: format!("https://api.x.com/2/tweets (reply to {})", tweet_id),
body: Some(serde_json::json!({"text": text, "reply_to": tweet_id})),
xurl_args: vec!["reply".into(), tweet_id, text],
};
execute(
spec,
client,
out,
stdout,
guard,
cache_only,
no_interactive,
config_username,
)
}
#[allow(clippy::too_many_arguments)]
pub fn run_like(
client: &db::BirdClient,
out: &OutputConfig,
stdout: &mut dyn Write,
tweet_id: String,
guard: WriteGuard,
cache_only: bool,
no_interactive: bool,
config_username: Option<&str>,
) -> Result<(), BirdError> {
let spec = WriteSpec {
verb: "like",
method: "POST",
url_for_prompt: format!("https://api.x.com/2/users/me/likes/{}", tweet_id),
body: None,
xurl_args: vec!["like".into(), tweet_id],
};
execute(
spec,
client,
out,
stdout,
guard,
cache_only,
no_interactive,
config_username,
)
}
#[allow(clippy::too_many_arguments)]
pub fn run_unlike(
client: &db::BirdClient,
out: &OutputConfig,
stdout: &mut dyn Write,
tweet_id: String,
guard: WriteGuard,
cache_only: bool,
no_interactive: bool,
config_username: Option<&str>,
) -> Result<(), BirdError> {
let spec = WriteSpec {
verb: "unlike",
method: "DELETE",
url_for_prompt: format!("https://api.x.com/2/users/me/likes/{}", tweet_id),
body: None,
xurl_args: vec!["unlike".into(), tweet_id],
};
execute(
spec,
client,
out,
stdout,
guard,
cache_only,
no_interactive,
config_username,
)
}
#[allow(clippy::too_many_arguments)]
pub fn run_repost(
client: &db::BirdClient,
out: &OutputConfig,
stdout: &mut dyn Write,
tweet_id: String,
guard: WriteGuard,
cache_only: bool,
no_interactive: bool,
config_username: Option<&str>,
) -> Result<(), BirdError> {
let spec = WriteSpec {
verb: "repost",
method: "POST",
url_for_prompt: format!("https://api.x.com/2/users/me/retweets/{}", tweet_id),
body: None,
xurl_args: vec!["repost".into(), tweet_id],
};
execute(
spec,
client,
out,
stdout,
guard,
cache_only,
no_interactive,
config_username,
)
}
#[allow(clippy::too_many_arguments)]
pub fn run_unrepost(
client: &db::BirdClient,
out: &OutputConfig,
stdout: &mut dyn Write,
tweet_id: String,
guard: WriteGuard,
cache_only: bool,
no_interactive: bool,
config_username: Option<&str>,
) -> Result<(), BirdError> {
let spec = WriteSpec {
verb: "unrepost",
method: "DELETE",
url_for_prompt: format!("https://api.x.com/2/users/me/retweets/{}", tweet_id),
body: None,
xurl_args: vec!["unrepost".into(), tweet_id],
};
execute(
spec,
client,
out,
stdout,
guard,
cache_only,
no_interactive,
config_username,
)
}
#[allow(clippy::too_many_arguments)]
pub fn run_follow(
client: &db::BirdClient,
out: &OutputConfig,
stdout: &mut dyn Write,
target: String,
guard: WriteGuard,
cache_only: bool,
no_interactive: bool,
config_username: Option<&str>,
) -> Result<(), BirdError> {
let spec = WriteSpec {
verb: "follow",
method: "POST",
url_for_prompt: format!(
"https://api.x.com/2/users/me/following (target=@{})",
target
),
body: None,
xurl_args: vec!["follow".into(), target],
};
execute(
spec,
client,
out,
stdout,
guard,
cache_only,
no_interactive,
config_username,
)
}
#[allow(clippy::too_many_arguments)]
pub fn run_unfollow(
client: &db::BirdClient,
out: &OutputConfig,
stdout: &mut dyn Write,
target: String,
guard: WriteGuard,
cache_only: bool,
no_interactive: bool,
config_username: Option<&str>,
) -> Result<(), BirdError> {
let spec = WriteSpec {
verb: "unfollow",
method: "DELETE",
url_for_prompt: format!(
"https://api.x.com/2/users/me/following (target=@{})",
target
),
body: None,
xurl_args: vec!["unfollow".into(), target],
};
execute(
spec,
client,
out,
stdout,
guard,
cache_only,
no_interactive,
config_username,
)
}
#[allow(clippy::too_many_arguments)]
pub fn run_dm(
client: &db::BirdClient,
out: &OutputConfig,
stdout: &mut dyn Write,
target: String,
text: String,
guard: WriteGuard,
cache_only: bool,
no_interactive: bool,
config_username: Option<&str>,
) -> Result<(), BirdError> {
let spec = WriteSpec {
verb: "dm",
method: "POST",
url_for_prompt: format!(
"https://api.x.com/2/dm_conversations/with/@{}/messages",
target
),
body: Some(serde_json::json!({"to": target, "text": text})),
xurl_args: vec!["dm".into(), target, text],
};
execute(
spec,
client,
out,
stdout,
guard,
cache_only,
no_interactive,
config_username,
)
}
#[allow(clippy::too_many_arguments)]
pub fn run_block(
client: &db::BirdClient,
out: &OutputConfig,
stdout: &mut dyn Write,
target: String,
guard: WriteGuard,
cache_only: bool,
no_interactive: bool,
config_username: Option<&str>,
) -> Result<(), BirdError> {
let spec = WriteSpec {
verb: "block",
method: "POST",
url_for_prompt: format!("https://api.x.com/2/users/me/blocking (target=@{})", target),
body: None,
xurl_args: vec!["block".into(), target],
};
execute(
spec,
client,
out,
stdout,
guard,
cache_only,
no_interactive,
config_username,
)
}
#[allow(clippy::too_many_arguments)]
pub fn run_unblock(
client: &db::BirdClient,
out: &OutputConfig,
stdout: &mut dyn Write,
target: String,
guard: WriteGuard,
cache_only: bool,
no_interactive: bool,
config_username: Option<&str>,
) -> Result<(), BirdError> {
let spec = WriteSpec {
verb: "unblock",
method: "DELETE",
url_for_prompt: format!("https://api.x.com/2/users/me/blocking (target=@{})", target),
body: None,
xurl_args: vec!["unblock".into(), target],
};
execute(
spec,
client,
out,
stdout,
guard,
cache_only,
no_interactive,
config_username,
)
}
#[allow(clippy::too_many_arguments)]
pub fn run_mute(
client: &db::BirdClient,
out: &OutputConfig,
stdout: &mut dyn Write,
target: String,
guard: WriteGuard,
cache_only: bool,
no_interactive: bool,
config_username: Option<&str>,
) -> Result<(), BirdError> {
let spec = WriteSpec {
verb: "mute",
method: "POST",
url_for_prompt: format!("https://api.x.com/2/users/me/muting (target=@{})", target),
body: None,
xurl_args: vec!["mute".into(), target],
};
execute(
spec,
client,
out,
stdout,
guard,
cache_only,
no_interactive,
config_username,
)
}
#[allow(clippy::too_many_arguments)]
pub fn run_unmute(
client: &db::BirdClient,
out: &OutputConfig,
stdout: &mut dyn Write,
target: String,
guard: WriteGuard,
cache_only: bool,
no_interactive: bool,
config_username: Option<&str>,
) -> Result<(), BirdError> {
let spec = WriteSpec {
verb: "unmute",
method: "DELETE",
url_for_prompt: format!("https://api.x.com/2/users/me/muting (target=@{})", target),
body: None,
xurl_args: vec!["unmute".into(), target],
};
execute(
spec,
client,
out,
stdout,
guard,
cache_only,
no_interactive,
config_username,
)
}
#[cfg(test)]
mod tests {
use super::*;
use crate::output::OutputFormat;
use crate::transport::tests::MockTransport;
fn quiet_out() -> OutputConfig {
OutputConfig {
format: OutputFormat::Text,
use_color: false,
quiet: true,
raw: false,
}
}
fn test_client() -> db::BirdClient {
let transport = Box::new(MockTransport::new(vec![]));
let db_obj = crate::db::store::in_memory_db();
db::BirdClient::new_test(transport, db_obj)
}
#[test]
fn execute_dry_run_short_circuits() {
let client = test_client();
let out = quiet_out();
let spec = WriteSpec {
verb: "like",
method: "POST",
url_for_prompt: "https://api.x.com/2/users/me/likes/1".into(),
body: None,
xurl_args: vec!["like".into(), "1".into()],
};
let guard = WriteGuard {
force: false,
dry_run: true,
};
let mut stdout: Vec<u8> = Vec::new();
assert!(execute(spec, &client, &out, &mut stdout, guard, false, false, None).is_ok());
}
#[test]
fn execute_cache_only_rejects_write() {
let client = test_client();
let out = quiet_out();
let spec = WriteSpec {
verb: "like",
method: "POST",
url_for_prompt: "https://api.x.com/2/users/me/likes/1".into(),
body: None,
xurl_args: vec!["like".into(), "1".into()],
};
let guard = WriteGuard {
force: true,
dry_run: false,
};
let mut stdout: Vec<u8> = Vec::new();
match execute(spec, &client, &out, &mut stdout, guard, true, false, None) {
Err(BirdError::General {
command, message, ..
}) => {
assert_eq!(command, Some("like"));
assert!(
message.contains("--cache-only"),
"expected cache-only refusal, got: {message}"
);
}
Err(_) => panic!("expected General error with cache-only refusal"),
Ok(()) => panic!("expected cache-only refusal, got Ok"),
}
}
#[test]
fn execute_no_interactive_without_force_errors() {
let client = test_client();
let out = quiet_out();
let spec = WriteSpec {
verb: "like",
method: "POST",
url_for_prompt: "https://api.x.com/2/users/me/likes/1".into(),
body: None,
xurl_args: vec!["like".into(), "1".into()],
};
let guard = WriteGuard {
force: false,
dry_run: false,
};
let mut stdout: Vec<u8> = Vec::new();
match execute(spec, &client, &out, &mut stdout, guard, false, true, None) {
Err(BirdError::Usage { error_id, .. }) => {
assert_eq!(error_id, "requires-confirmation");
}
Err(_) => panic!("expected requires-confirmation usage error"),
Ok(()) => panic!("expected requires-confirmation error, got Ok"),
}
}
#[test]
fn tweet_builder_dry_run_envelope_with_media() {
let client = test_client();
let out = quiet_out();
let mut stdout: Vec<u8> = Vec::new();
let res = run_tweet(
&client,
&out,
&mut stdout,
"hello world".into(),
Some("media-123".into()),
WriteGuard {
force: false,
dry_run: true,
},
false,
false,
None,
);
assert!(res.is_ok(), "tweet dry-run should succeed");
}
#[test]
fn follow_builder_dry_run_envelope() {
let client = test_client();
let out = quiet_out();
let mut stdout: Vec<u8> = Vec::new();
let res = run_follow(
&client,
&out,
&mut stdout,
"someuser".into(),
WriteGuard {
force: false,
dry_run: true,
},
false,
false,
None,
);
assert!(res.is_ok(), "follow dry-run should succeed");
}
#[test]
fn dm_builder_dry_run_envelope() {
let client = test_client();
let out = quiet_out();
let mut stdout: Vec<u8> = Vec::new();
let res = run_dm(
&client,
&out,
&mut stdout,
"9876543210".into(),
"hi".into(),
WriteGuard {
force: false,
dry_run: true,
},
false,
false,
None,
);
assert!(res.is_ok(), "dm dry-run should succeed");
}
}