use chrono::{DateTime, NaiveDateTime, Utc};
use crate::error::Result;
use crate::models::corporate::news::News;
use super::models::GdeltArticle;
const MAX_RECORDS: u32 = 50;
const TIMESPAN: &str = "2w";
const MIN_PHRASE_LEN: usize = 5;
pub(crate) fn build_query(symbol: &str) -> Result<String> {
let symbol = symbol.trim();
if symbol.len() < MIN_PHRASE_LEN {
return Err(crate::error::FinanceError::InvalidParameter {
param: "symbol".to_string(),
reason: format!(
"GDELT rejects phrase queries shorter than {MIN_PHRASE_LEN} characters, so it \
cannot serve news for {symbol:?}; route CORPORATE to another provider for \
short tickers"
),
});
}
Ok(format!("\"{symbol}\""))
}
pub async fn fetch_news_response(symbol: &str) -> Result<Vec<News>> {
let query = build_query(symbol)?;
let response = super::client()?
.article_search(&query, TIMESPAN, MAX_RECORDS)
.await?;
let now = Utc::now();
Ok(response
.articles
.into_iter()
.map(|a| to_news_at(a, now))
.collect())
}
pub(crate) fn to_news_at(article: GdeltArticle, now: DateTime<Utc>) -> News {
News {
title: article.title.unwrap_or_default(),
link: article.url,
source: article.domain.unwrap_or_default(),
img: article.socialimage.unwrap_or_default(),
time: article
.seendate
.as_deref()
.map(|d| relative_time_at(d, now))
.unwrap_or_default(),
provider_id: Some(crate::Provider::Gdelt),
#[cfg(feature = "sentiment")]
sentiment: None,
}
}
fn relative_time_at(seendate: &str, now: DateTime<Utc>) -> String {
let Some(seen) = parse_seendate(seendate) else {
return seendate.to_string();
};
let minutes = now.signed_duration_since(seen).num_minutes();
if minutes < 1 {
"just now".to_string()
} else if minutes < 60 {
format!("{minutes} minute{} ago", plural(minutes))
} else if minutes < 60 * 24 {
let hours = minutes / 60;
format!("{hours} hour{} ago", plural(hours))
} else {
let days = minutes / (60 * 24);
format!("{days} day{} ago", plural(days))
}
}
fn plural(n: i64) -> &'static str {
if n == 1 { "" } else { "s" }
}
fn parse_seendate(seendate: &str) -> Option<DateTime<Utc>> {
NaiveDateTime::parse_from_str(seendate, "%Y%m%dT%H%M%SZ")
.or_else(|_| NaiveDateTime::parse_from_str(seendate, "%Y%m%d%H%M%S"))
.ok()
.map(|naive| naive.and_utc())
}
#[cfg(test)]
mod tests {
use super::*;
fn fixed_now() -> DateTime<Utc> {
"2026-08-05T12:00:00Z".parse().unwrap()
}
#[test]
fn query_wraps_the_symbol_in_quotes_for_an_exact_match() {
assert_eq!(build_query("GOOGL").unwrap(), "\"GOOGL\"");
assert_eq!(build_query(" BRK.B ").unwrap(), "\"BRK.B\"");
}
#[test]
fn a_symbol_below_gdelts_phrase_minimum_is_rejected_up_front() {
for symbol in ["AAPL", "TSLA", "AMD", "F"] {
assert!(
matches!(
build_query(symbol),
Err(crate::error::FinanceError::InvalidParameter { .. })
),
"{symbol} should be rejected"
);
}
}
#[test]
fn iso_basic_seendate_parses() {
assert_eq!(
parse_seendate("20260805T113000Z"),
Some("2026-08-05T11:30:00Z".parse().unwrap())
);
}
#[test]
fn bare_seendate_without_separators_also_parses() {
assert_eq!(
parse_seendate("20260805113000"),
Some("2026-08-05T11:30:00Z".parse().unwrap())
);
}
#[test]
fn unparseable_seendate_returns_none() {
assert_eq!(parse_seendate("not-a-date"), None);
}
#[test]
fn recent_article_reads_minutes_ago() {
assert_eq!(
relative_time_at("20260805T113000Z", fixed_now()),
"30 minutes ago"
);
}
#[test]
fn single_minute_is_not_pluralised() {
assert_eq!(
relative_time_at("20260805T115900Z", fixed_now()),
"1 minute ago"
);
}
#[test]
fn same_minute_reads_just_now() {
assert_eq!(
relative_time_at("20260805T120000Z", fixed_now()),
"just now"
);
}
#[test]
fn hours_old_article_reads_hours_ago() {
assert_eq!(
relative_time_at("20260805T090000Z", fixed_now()),
"3 hours ago"
);
}
#[test]
fn days_old_article_reads_days_ago() {
assert_eq!(
relative_time_at("20260803T120000Z", fixed_now()),
"2 days ago"
);
}
#[test]
fn unparseable_seendate_survives_as_the_raw_string() {
assert_eq!(relative_time_at("garbage", fixed_now()), "garbage");
}
#[test]
fn article_without_a_title_maps_to_an_empty_string_not_a_panic() {
let article = GdeltArticle {
url: "https://example.com/a".to_string(),
title: None,
seendate: None,
domain: None,
socialimage: None,
};
let news = to_news_at(article, fixed_now());
assert_eq!(news.title, "");
assert_eq!(news.link, "https://example.com/a");
assert_eq!(news.source, "");
assert_eq!(news.img, "");
assert_eq!(news.time, "");
assert_eq!(news.provider_id, Some(crate::Provider::Gdelt));
}
}