mod client;
mod endpoints;
use crate::error::{FinanceError, Result};
use crate::models::filings::{
CompanyFacts, EdgarFilingIndex, EdgarSearchResults, EdgarSubmissions,
};
use crate::rate_limiter::RateLimiter;
use client::EdgarClientBuilder;
use std::collections::HashMap;
use std::sync::{Arc, OnceLock};
use std::time::Duration;
use tokio::sync::RwLock;
const EDGAR_RATE_PER_SEC: f64 = 10.0;
struct EdgarSingleton {
email: String,
app_name: String,
timeout: Duration,
rate_limiter: Arc<RateLimiter>,
cik_cache: Arc<RwLock<Option<HashMap<String, u64>>>>,
}
static EDGAR_SINGLETON: OnceLock<EdgarSingleton> = OnceLock::new();
pub fn init(email: impl Into<String>) -> Result<()> {
init_with_config(email, "finance-query", Duration::from_secs(30))
}
pub fn init_with_config(
email: impl Into<String>,
app_name: impl Into<String>,
timeout: Duration,
) -> Result<()> {
EDGAR_SINGLETON
.set(EdgarSingleton {
email: email.into(),
app_name: app_name.into(),
timeout,
rate_limiter: Arc::new(RateLimiter::new(EDGAR_RATE_PER_SEC)),
cik_cache: Arc::new(RwLock::new(None)),
})
.map_err(|_| FinanceError::InvalidParameter {
param: "edgar".to_string(),
reason: "EDGAR client already initialized".to_string(),
})
}
fn build_client() -> Result<client::EdgarClient> {
if EDGAR_SINGLETON.get().is_none()
&& let Ok(email) = std::env::var("EDGAR_EMAIL")
{
let _ = EDGAR_SINGLETON.set(EdgarSingleton {
email,
app_name: "finance-query".to_string(),
timeout: Duration::from_secs(30),
rate_limiter: Arc::new(RateLimiter::new(EDGAR_RATE_PER_SEC)),
cik_cache: Arc::new(RwLock::new(None)),
});
}
let s = EDGAR_SINGLETON
.get()
.ok_or_else(|| FinanceError::InvalidParameter {
param: "edgar".to_string(),
reason: "EDGAR_EMAIL not set. Call edgar::init(email) or set EDGAR_EMAIL env var."
.to_string(),
})?;
EdgarClientBuilder::new(&s.email)
.app_name(&s.app_name)
.timeout(s.timeout)
.build_with_shared_state(Arc::clone(&s.rate_limiter), Arc::clone(&s.cik_cache))
}
fn accession_parts(accession_number: &str) -> Result<(String, String)> {
let cik_part = accession_number
.split('-')
.next()
.unwrap_or("")
.trim_start_matches('0')
.to_string();
let accession_no_dashes = accession_number.replace('-', "");
if cik_part.is_empty() || accession_no_dashes.is_empty() {
return Err(FinanceError::InvalidParameter {
param: "accession_number".to_string(),
reason: "Invalid accession number format".to_string(),
});
}
Ok((cik_part, accession_no_dashes))
}
pub async fn resolve_cik(symbol: &str) -> Result<u64> {
build_client()?.resolve_cik(symbol).await
}
pub async fn submissions(cik: u64) -> Result<EdgarSubmissions> {
build_client()?.submissions(cik).await
}
pub async fn company_facts(cik: u64) -> Result<CompanyFacts> {
build_client()?.company_facts(cik).await
}
pub async fn filing_index(accession_number: &str) -> Result<EdgarFilingIndex> {
build_client()?.filing_index(accession_number).await
}
pub async fn search(
query: &str,
forms: Option<&[&str]>,
start_date: Option<&str>,
end_date: Option<&str>,
from: Option<usize>,
size: Option<usize>,
) -> Result<EdgarSearchResults> {
build_client()?
.search(query, forms, start_date, end_date, from, size)
.await
}
pub async fn fetch_filings_response(
symbol: &str,
) -> Result<crate::models::filings::ProviderFilings> {
use crate::models::filings::{ProviderFiling, ProviderFilings};
let cik_num = resolve_cik(symbol).await?;
let subs = submissions(cik_num).await?;
let cik = subs.cik.clone().unwrap_or_default();
let company_name = subs.name.clone();
let filings = subs
.filings
.and_then(|f| f.recent)
.map(|r| r.to_filings())
.unwrap_or_default()
.into_iter()
.map(|f| {
let accession_no_dashes = f.accession_number.replace('-', "");
let url = if !cik.is_empty()
&& !accession_no_dashes.is_empty()
&& !f.primary_document.is_empty()
{
Some(format!(
"https://www.sec.gov/Archives/edgar/data/{}/{}/{}",
cik.trim_start_matches('0'),
accession_no_dashes,
f.primary_document
))
} else {
None
};
ProviderFiling {
accession_number: Some(f.accession_number),
filing_date: Some(f.filing_date),
filing_type: Some(f.form),
filing_url: url,
company_name: company_name.clone(),
cik: Some(cik.clone()),
}
})
.collect();
Ok(ProviderFilings {
symbol: symbol.to_string(),
filings,
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_init_sets_singleton() {
let result = init("test@example.com");
assert!(result.is_ok() || result.is_err()); }
#[test]
fn test_double_init_fails() {
let _ = init("first@example.com");
let result = init("second@example.com");
assert!(matches!(result, Err(FinanceError::InvalidParameter { .. })));
}
#[test]
fn test_singleton_is_set_after_init() {
let _ = init("test@example.com");
assert!(EDGAR_SINGLETON.get().is_some());
}
}