use crate::adapters::edgar::filings::sections::html_to_text;
use crate::adapters::edgar::{accession_parts, build_client, submissions_for_symbol};
use crate::error::Result;
use crate::models::corporate::press_release::PressRelease;
use crate::models::filings::EdgarFiling;
use crate::models::filings::filing_index::EdgarFilingIndexItem;
pub async fn fetch_press_releases_response(symbol: &str, limit: u32) -> Result<Vec<PressRelease>> {
let subs = submissions_for_symbol(symbol).await?;
let eight_ks: Vec<EdgarFiling> = subs
.filings
.and_then(|f| f.recent)
.map(|r| r.to_filings())
.unwrap_or_default()
.into_iter()
.filter(|f| f.form == "8-K")
.take(limit as usize)
.collect();
let fetches = eight_ks
.into_iter()
.map(|filing| async move { fetch_one(symbol, filing).await });
Ok(futures::future::join_all(fetches)
.await
.into_iter()
.flatten()
.collect())
}
async fn fetch_one(symbol: &str, filing: EdgarFiling) -> Option<PressRelease> {
let client = build_client().ok()?;
let index = client.filing_index(&filing.accession_number).await.ok()?;
let exhibit = select_exhibit(&index.directory.item)?;
let (cik, accession_no_dashes) = accession_parts(&filing.accession_number).ok()?;
let url = format!(
"https://www.sec.gov/Archives/edgar/data/{cik}/{accession_no_dashes}/{}",
exhibit.name
);
let bytes = client.get_document(&url).await.ok()?;
let text = html_to_text(&String::from_utf8_lossy(&bytes));
let body = strip_leading_chrome(text.trim());
if body.is_empty() {
return None;
}
Some(PressRelease {
symbol: Some(symbol.to_string()),
date: Some(filing.filing_date),
title: extract_title(&body),
text: Some(body),
})
}
fn is_ex99_exhibit(name: &str) -> bool {
let name = name.to_ascii_lowercase();
name.contains("ex99") || name.contains("ex-99")
}
fn select_exhibit(items: &[EdgarFilingIndexItem]) -> Option<&EdgarFilingIndexItem> {
items
.iter()
.filter(|i| is_ex99_exhibit(&i.name))
.max_by_key(|i| i.size.unwrap_or(0))
}
fn is_viewer_chrome(line: &str) -> bool {
let lower = line.to_ascii_lowercase();
let caption_digits = lower
.strip_prefix("exhibit ")
.or_else(|| lower.strip_prefix("ex-"))
.unwrap_or_default();
line.chars().all(|c| c.is_ascii_digit())
|| lower.ends_with(".htm")
|| lower.ends_with(".html")
|| lower == "document"
|| (!caption_digits.is_empty()
&& caption_digits
.chars()
.all(|c| c.is_ascii_digit() || c == '.'))
}
fn extract_title(text: &str) -> Option<String> {
text.lines()
.map(str::trim)
.find(|l| !l.is_empty() && !is_viewer_chrome(l))
.map(str::to_string)
}
fn strip_leading_chrome(text: &str) -> String {
let lines: Vec<&str> = text.lines().collect();
let start = lines
.iter()
.position(|l| {
let trimmed = l.trim();
!trimmed.is_empty() && !is_viewer_chrome(trimmed)
})
.unwrap_or(lines.len());
lines[start..].join("\n")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn title_is_the_first_non_empty_line_of_the_exhibit_text() {
let text = "\n \nApple Reports Fourth Quarter Results\n\nCUPERTINO, Calif. ...";
assert_eq!(
extract_title(text),
Some("Apple Reports Fourth Quarter Results".to_string())
);
}
#[test]
fn blank_text_has_no_title() {
assert_eq!(extract_title(" \n\n "), None);
}
#[test]
fn title_skips_the_inline_viewers_own_chrome() {
let text = "EX-99.1\n2\na8-kex991q3202606272026.htm\nEX-99.1\n\nDocument\n\nExhibit 99.1\n\nApple reports third quarter results\n\nCUPERTINO, CALIFORNIA...";
assert_eq!(
extract_title(text),
Some("Apple reports third quarter results".to_string())
);
}
#[test]
fn strip_leading_chrome_drops_viewer_boilerplate_and_keeps_the_body() {
let text = "EX-99.1\n2\na8-kex991q3202606272026.htm\nEX-99.1\n\nDocument\n\nExhibit 99.1\n\nApple reports third quarter results\n\nCUPERTINO, CALIFORNIA...";
assert_eq!(
strip_leading_chrome(text),
"Apple reports third quarter results\n\nCUPERTINO, CALIFORNIA..."
);
}
#[test]
fn strip_leading_chrome_is_a_no_op_when_there_is_none() {
let text = "Apple reports third quarter results\n\nCUPERTINO, CALIFORNIA...";
assert_eq!(strip_leading_chrome(text), text);
}
#[test]
fn select_exhibit_picks_the_largest_when_multiple_ex99_entries_match() {
let item = |name: &str, size: u64| EdgarFilingIndexItem {
name: name.to_string(),
item_type: "text.gif".to_string(),
size: Some(size),
};
let items = vec![
item("aapl-ex99_2.htm", 1_200),
item("aapl-ex99_1.htm", 45_000),
item("aapl-ex99_1_g1.jpg", 8_000),
];
assert_eq!(
select_exhibit(&items).map(|i| &i.name),
Some(&"aapl-ex99_1.htm".to_string())
);
}
#[test]
fn ex99_exhibit_names_are_recognized_regardless_of_filer_convention() {
assert!(is_ex99_exhibit("a8-kex991q3202606272026.htm"));
assert!(is_ex99_exhibit("msft-ex99_1.htm"));
assert!(is_ex99_exhibit("EX-99.1.htm"));
assert!(!is_ex99_exhibit("aapl-20260730.htm"));
assert!(!is_ex99_exhibit("aapl-20260730_g1.jpg"));
}
#[tokio::test]
#[ignore = "requires network access"]
async fn test_live_press_releases() {
let _ = crate::adapters::edgar::init("test@example.com");
let releases = fetch_press_releases_response("AAPL", 10).await.unwrap();
assert!(!releases.is_empty());
assert!(releases[0].title.is_some());
let text = releases[0].text.as_deref().unwrap();
assert!(!text.starts_with("EX-99"));
assert_eq!(text.lines().next(), releases[0].title.as_deref());
}
}