use super::store::load_watchlist;
use crate::config::ResolvedConfig;
use crate::db::{BirdClient, RequestContext};
use crate::fields;
use crate::requirements::AuthType;
pub struct CheckOpts<'a> {
pub pretty: bool,
pub limit: u32,
pub cursor: Option<&'a str>,
}
pub fn run_watchlist_check(
client: &mut BirdClient,
config: &ResolvedConfig,
cfg: &crate::output::OutputConfig,
stdout: &mut dyn std::io::Write,
stderr: &mut dyn std::io::Write,
opts: CheckOpts<'_>,
auth_type: &AuthType,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use std::io::Write;
let quiet = cfg.suppress_diag();
let pretty = opts.pretty;
let config_path = config.config_dir.join("config.toml");
let entries = load_watchlist(&config_path)?;
if entries.is_empty() {
if !quiet {
writeln!(
stderr,
"Watchlist is empty. Add users with: bird watchlist add <username>"
)
.ok();
}
return Ok(());
}
let ctx = RequestContext {
auth_type,
username: None,
};
let mut writer = std::io::BufWriter::new(stdout);
let offset = opts
.cursor
.and_then(|s| s.parse::<usize>().ok())
.unwrap_or(0);
let cap = opts.limit as usize;
let bounded: Vec<(usize, &String)> = entries
.iter()
.enumerate()
.skip(offset)
.take(cap.max(1))
.collect();
let total = bounded.len();
for (i, (_, username)) in bounded.iter().enumerate() {
if !quiet {
writeln!(
stderr,
"[watchlist] checking @{} ({}/{})...",
username,
i + 1,
total
)
.ok();
}
let query = format!("from:{} -is:retweet", username);
let search_url = build_check_url(&query);
let activity = match execute_check(client, &ctx, &search_url, stderr, cfg) {
Ok((tweet_count, latest_tweet, cache_hit)) => AccountActivity {
username: (*username).clone(),
recent_tweets: tweet_count,
latest_tweet,
cache_hit,
},
Err(e) => {
if !quiet {
writeln!(stderr, "[watchlist] error checking @{}: {}", username, e).ok();
}
AccountActivity {
username: (*username).clone(),
recent_tweets: 0,
latest_tweet: None,
cache_hit: false,
}
}
};
if pretty {
serde_json::to_writer_pretty(&mut writer, &activity)?;
} else {
serde_json::to_writer(&mut writer, &activity)?;
}
writeln!(writer)?;
writer.flush()?;
}
writer.flush()?;
Ok(())
}
fn build_check_url(query: &str) -> String {
let mut url = url::Url::parse("https: .expect("invariant: constant search endpoint URL is well-formed");
{
let mut pairs = url.query_pairs_mut();
pairs.append_pair("query", query);
pairs.append_pair("max_results", "10");
for (key, value) in fields::tweet_query_params() {
pairs.append_pair(key, value);
}
}
url.to_string()
}
fn execute_check(
client: &mut BirdClient,
ctx: &RequestContext<'_>,
url: &str,
stderr: &mut dyn std::io::Write,
cfg: &crate::output::OutputConfig,
) -> Result<(u64, Option<LatestTweet>, bool), Box<dyn std::error::Error + Send + Sync>> {
let response = client.get(url, ctx)?;
if !response.is_success() {
return Err(format!(
"GET search {}: {}",
response.status,
crate::output::sanitize_for_stderr(&response.body(), 200)
)
.into());
}
let json = response.json.ok_or("invalid JSON from search")?;
let estimate = crate::cost::estimate_cost(&json, url, response.cache_hit);
crate::cost::display_cost(cfg, stderr, &estimate);
let tweet_count = json
.get("meta")
.and_then(|m| m.get("result_count"))
.and_then(|c| c.as_u64())
.unwrap_or(0);
let latest_tweet = extract_latest_tweet(&json);
Ok((tweet_count, latest_tweet, response.cache_hit))
}
fn extract_latest_tweet(body: &serde_json::Value) -> Option<LatestTweet> {
let data = body.get("data")?.as_array()?;
let tweet = data.first()?;
Some(LatestTweet {
id: tweet.get("id")?.as_str()?.to_string(),
text: tweet.get("text")?.as_str()?.to_string(),
created_at: tweet
.get("created_at")
.and_then(|c| c.as_str())
.unwrap_or("")
.to_string(),
likes: tweet
.get("public_metrics")
.and_then(|m| m.get("like_count"))
.and_then(|v| v.as_u64())
.unwrap_or(0),
retweets: tweet
.get("public_metrics")
.and_then(|m| m.get("retweet_count"))
.and_then(|v| v.as_u64())
.unwrap_or(0),
})
}
#[derive(serde::Serialize)]
struct AccountActivity {
username: String,
recent_tweets: u64,
#[serde(skip_serializing_if = "Option::is_none")]
latest_tweet: Option<LatestTweet>,
cache_hit: bool,
}
#[derive(serde::Serialize)]
struct LatestTweet {
id: String,
text: String,
created_at: String,
likes: u64,
retweets: u64,
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn extract_latest_tweet_empty_data() {
let body = serde_json::json!({"data": [], "meta": {"result_count": 0}});
assert!(extract_latest_tweet(&body).is_none());
}
#[test]
fn extract_latest_tweet_with_data() {
let body = serde_json::json!({
"data": [{
"id": "123",
"text": "hello world",
"created_at": "2026-02-11T10:00:00.000Z",
"public_metrics": {
"like_count": 42,
"retweet_count": 5
}
}]
});
let tweet = extract_latest_tweet(&body).expect("test");
assert_eq!(tweet.id, "123");
assert_eq!(tweet.text, "hello world");
assert_eq!(tweet.likes, 42);
assert_eq!(tweet.retweets, 5);
}
#[test]
fn extract_latest_tweet_no_data_key() {
let body = serde_json::json!({"meta": {"result_count": 0}});
assert!(extract_latest_tweet(&body).is_none());
}
#[test]
fn build_check_url_includes_query_and_limit() {
let url = build_check_url("from:alice -is:retweet");
assert!(url.starts_with("https://api.x.com/2/tweets/search/recent?"));
assert!(url.contains("max_results=10"));
assert!(url.contains("query=from%3Aalice"));
}
}