mod pretty;
mod sync;
use crate::db::{ActualUsageDay, BirdClient, DailyUsage, EndpointUsage, UsageSummary};
fn parse_since(since: Option<&str>) -> Result<i64, Box<dyn std::error::Error + Send + Sync>> {
match since {
Some(date_str) => {
let date = chrono::NaiveDate::parse_from_str(date_str, "%Y-%m-%d")
.map_err(|e| format!("invalid date '{}': {} (expected YYYY-MM-DD)", date_str, e))?;
Ok(date
.format("%Y%m%d")
.to_string()
.parse::<i64>()
.expect("invariant: chrono '%Y%m%d' format yields a valid i64"))
}
None => {
let now = chrono::Utc::now();
let thirty_days_ago = now - chrono::TimeDelta::days(30);
Ok(thirty_days_ago
.format("%Y%m%d")
.to_string()
.parse::<i64>()
.expect("invariant: chrono '%Y%m%d' format yields a valid i64"))
}
}
}
fn ymd_to_display(ymd: i64) -> String {
format!(
"{}-{:02}-{:02}",
ymd / 10000,
(ymd % 10000) / 100,
ymd % 100
)
}
pub fn run_usage(
client: &mut BirdClient,
cfg: &crate::output::OutputConfig,
stdout: &mut dyn std::io::Write,
stderr: &mut dyn std::io::Write,
since: Option<&str>,
local: bool,
pretty: bool,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
use std::io::Write;
let mut out = std::io::BufWriter::new(stdout);
let quiet = cfg.suppress_diag();
let since_ymd = parse_since(since)?;
if client.db().is_none() {
let msg = if client.db_disabled() {
"Usage tracking requires the store. Remove --no-cache to enable."
} else {
"Store database is unavailable. Run `bird cache clear` to reset."
};
if !quiet {
writeln!(stderr, "[usage] {}", msg).ok();
}
if !pretty {
writeln!(out, "{}", serde_json::to_string(&empty_report(since_ymd))?)?;
}
out.flush()?;
return Ok(());
}
let (summary, daily, top_endpoints) = {
let db = client
.db()
.expect("invariant: db().is_none() short-circuits above");
(
db.query_usage_summary(since_ymd)?,
db.query_daily_usage(since_ymd)?,
db.query_top_endpoints(since_ymd)?,
)
};
if summary.total_calls == 0 && local && !quiet {
writeln!(
stderr,
"[usage] No usage data recorded yet. Run some API commands first."
)
.ok();
}
let mut sync_status = if local { "skipped" } else { "failed" };
let (actuals, cap, per_app) = if !local {
let now = chrono::Utc::now().date_naive();
let since_date = chrono::NaiveDate::from_ymd_opt(
(since_ymd / 10000) as i32,
((since_ymd % 10000) / 100) as u32,
(since_ymd % 100) as u32,
);
if let Some(since_date) = since_date {
let days_back = (now - since_date).num_days();
if days_back > 90 && !quiet {
writeln!(
stderr,
"[usage] warning: X API only returns 90 days of history; --since may exceed that range"
)
.ok();
}
}
match sync::sync_actual_usage(client, stderr, quiet)? {
Some(sync_data) => {
sync_status = "success";
(Some(sync_data.daily), sync_data.cap, sync_data.per_app)
}
None => {
let fallback = client
.db()
.and_then(|db| db.query_actual_usage(since_ymd).ok())
.flatten();
(fallback, None, vec![])
}
}
} else {
let fallback = client
.db()
.and_then(|db| db.query_actual_usage(since_ymd).ok())
.flatten();
(fallback, None, vec![])
};
let since_display = since
.map(String::from)
.unwrap_or_else(|| ymd_to_display(since_ymd));
let until_display = chrono::Utc::now().format("%Y-%m-%d").to_string();
let report = UsageReport {
since: since_display,
until: until_display,
summary,
daily,
top_endpoints,
comparison: actuals,
cap,
per_app,
sync_status,
};
if pretty {
pretty::print_usage_pretty(&mut out, &report)?;
} else {
writeln!(out, "{}", serde_json::to_string(&report)?)?;
}
out.flush()?;
Ok(())
}
fn empty_report(since_ymd: i64) -> UsageReport {
let since_display = ymd_to_display(since_ymd);
let until_display = chrono::Utc::now().format("%Y-%m-%d").to_string();
UsageReport {
since: since_display,
until: until_display,
summary: UsageSummary {
total_cost: 0.0,
total_calls: 0,
cache_hits: 0,
estimated_savings: 0.0,
},
daily: vec![],
top_endpoints: vec![],
comparison: None,
cap: None,
per_app: vec![],
sync_status: "skipped",
}
}
#[derive(Debug, serde::Serialize)]
struct ProjectCap {
project_usage: u64,
project_cap: u64,
cap_reset_day: u32,
}
#[derive(Debug, serde::Serialize)]
struct AppDailyUsage {
client_app_id: String,
date: String,
tweet_count: u64,
}
#[derive(Debug)]
struct SyncData {
daily: Vec<ActualUsageDay>,
cap: Option<ProjectCap>,
per_app: Vec<AppDailyUsage>,
}
#[derive(serde::Serialize)]
struct UsageReport {
since: String,
until: String,
summary: UsageSummary,
daily: Vec<DailyUsage>,
top_endpoints: Vec<EndpointUsage>,
#[serde(skip_serializing_if = "Option::is_none")]
comparison: Option<Vec<ActualUsageDay>>,
#[serde(skip_serializing_if = "Option::is_none")]
cap: Option<ProjectCap>,
#[serde(skip_serializing_if = "Vec::is_empty")]
per_app: Vec<AppDailyUsage>,
sync_status: &'static str,
}
#[cfg(test)]
mod tests {
use super::*;
use crate::db::BirdClient;
use crate::db::store::in_memory_db;
use crate::output::{OutputConfig, OutputFormat};
use crate::transport::tests::MockTransport;
fn sync_client(responses: Vec<serde_json::Value>) -> BirdClient {
let mock = MockTransport::new(responses.into_iter().map(Ok).collect());
BirdClient::new_test(Box::new(mock), in_memory_db())
}
fn quiet_cfg() -> OutputConfig {
OutputConfig {
format: OutputFormat::Text,
use_color: false,
quiet: true,
raw: false,
}
}
#[test]
fn parse_since_valid_date() {
let ymd = parse_since(Some("2026-02-01")).expect("test");
assert_eq!(ymd, 20260201);
}
#[test]
fn parse_since_none_defaults_to_30_days_ago() {
let ymd = parse_since(None).expect("test");
assert!(ymd > 20200101);
assert!(ymd < 30000101);
}
#[test]
fn parse_since_invalid_date() {
let result = parse_since(Some("not-a-date"));
assert!(result.is_err());
}
#[test]
fn parse_since_invalid_format() {
let result = parse_since(Some("02/01/2026"));
assert!(result.is_err());
}
#[test]
fn ymd_to_display_format() {
assert_eq!(ymd_to_display(20260211), "2026-02-11");
assert_eq!(ymd_to_display(20260101), "2026-01-01");
}
#[test]
fn run_usage_local_skips_api() {
let mut client = sync_client(vec![]);
let cfg = quiet_cfg();
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
run_usage(
&mut client,
&cfg,
&mut stdout,
&mut stderr,
None,
true,
false,
)
.expect("test");
}
#[test]
fn run_usage_default_calls_api() {
let api_response = serde_json::json!({
"data": {
"daily_project_usage": {
"usage": [
{"date": "2026-03-25T00:00:00.000Z", "usage": "100"}
]
}
}
});
let mut client = sync_client(vec![api_response]);
let cfg = quiet_cfg();
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
run_usage(
&mut client,
&cfg,
&mut stdout,
&mut stderr,
None,
false,
false,
)
.expect("test");
}
#[test]
fn run_usage_default_with_empty_mock_errors() {
let mut client = sync_client(vec![]);
let cfg = quiet_cfg();
let mut stdout: Vec<u8> = Vec::new();
let mut stderr: Vec<u8> = Vec::new();
let result = run_usage(
&mut client,
&cfg,
&mut stdout,
&mut stderr,
None,
false,
false,
);
assert!(result.is_err(), "local=false should attempt API call");
}
}