#![expect(
clippy::missing_panics_doc,
clippy::unwrap_used,
reason = "Pre-existing profile loader panic-family debt moved from hl7v2-prof; cleanup is separate from this behavior-preserving module collapse."
)]
use std::sync::Arc;
use std::time::Duration;
use async_lock::RwLock;
use lru::LruCache;
pub use super::ProfileLoadError;
use super::{Profile, load_profile};
const DEFAULT_CACHE_SIZE: usize = 100;
const DEFAULT_TIMEOUT_SECS: u64 = 30;
#[derive(Debug, Clone)]
struct CacheEntry {
profile: Profile,
etag: Option<String>,
#[expect(
dead_code,
reason = "Raw profile content is retained for compatibility with the existing loader cache shape."
)]
raw_content: String,
}
#[derive(Debug, Clone)]
pub struct LoadResult {
pub profile: Profile,
pub from_cache: bool,
pub etag: Option<String>,
}
pub struct ProfileLoaderBuilder {
cache_size: usize,
timeout: Duration,
user_agent: String,
}
impl Default for ProfileLoaderBuilder {
fn default() -> Self {
Self {
cache_size: DEFAULT_CACHE_SIZE,
timeout: Duration::from_secs(DEFAULT_TIMEOUT_SECS),
user_agent: format!("hl7v2-rs/{}", env!("CARGO_PKG_VERSION")),
}
}
}
impl ProfileLoaderBuilder {
pub fn new() -> Self {
Self::default()
}
pub fn cache_size(mut self, size: usize) -> Self {
self.cache_size = size;
self
}
pub fn timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self
}
pub fn user_agent(mut self, user_agent: impl Into<String>) -> Self {
self.user_agent = user_agent.into();
self
}
pub fn build(self) -> ProfileLoader {
let client = reqwest::Client::builder()
.timeout(self.timeout)
.user_agent(self.user_agent)
.build()
.unwrap_or_default();
ProfileLoader {
cache: Arc::new(RwLock::new(LruCache::new(
std::num::NonZeroUsize::new(self.cache_size)
.unwrap_or(std::num::NonZeroUsize::new(1).unwrap()),
))),
client,
timeout: self.timeout,
}
}
}
pub struct ProfileLoader {
cache: Arc<RwLock<LruCache<String, CacheEntry>>>,
client: reqwest::Client,
timeout: Duration,
}
impl Default for ProfileLoader {
fn default() -> Self {
Self::builder().build()
}
}
impl ProfileLoader {
pub fn new() -> Self {
Self::default()
}
pub fn builder() -> ProfileLoaderBuilder {
ProfileLoaderBuilder::new()
}
pub fn with_options(cache_size: usize, timeout: Duration) -> Self {
Self::builder()
.cache_size(cache_size)
.timeout(timeout)
.build()
}
pub fn with_timeout(mut self, timeout: Duration) -> Self {
self.timeout = timeout;
self.client = reqwest::Client::builder()
.timeout(timeout)
.build()
.unwrap_or_default();
self
}
pub fn with_cache_size(self, size: usize) -> Self {
let new_cache = LruCache::new(
std::num::NonZeroUsize::new(size).unwrap_or(std::num::NonZeroUsize::new(1).unwrap()),
);
Self {
cache: Arc::new(RwLock::new(new_cache)),
client: self.client,
timeout: self.timeout,
}
}
pub async fn load(&self, source: &str) -> Result<LoadResult, ProfileLoadError> {
if source.starts_with("http://") || source.starts_with("https://") {
self.load_from_url(source).await
} else {
let path = source.strip_prefix("file://").unwrap_or(source);
self.load_from_file(path).await
}
}
pub async fn load_from_url(&self, url: &str) -> Result<LoadResult, ProfileLoadError> {
let etag = {
let mut cache = self.cache.write().await;
cache.get(url).and_then(|e| e.etag.clone())
};
let mut request = self.client.get(url);
if let Some(etag_val) = etag {
request = request.header(reqwest::header::IF_NONE_MATCH, etag_val);
}
let response = request.send().await?;
if response.status() == reqwest::StatusCode::NOT_MODIFIED {
let mut cache = self.cache.write().await;
if let Some(entry) = cache.get(url) {
return Ok(LoadResult {
profile: entry.profile.clone(),
from_cache: true,
etag: entry.etag.clone(),
});
}
}
if !response.status().is_success() {
return Err(ProfileLoadError::Network(
response.error_for_status().unwrap_err(),
));
}
let new_etag = response
.headers()
.get(reqwest::header::ETAG)
.and_then(|h| h.to_str().ok())
.map(std::string::ToString::to_string);
let content = response.text().await?;
let profile = load_profile(&content)?;
{
let mut cache = self.cache.write().await;
cache.put(
url.to_string(),
CacheEntry {
profile: profile.clone(),
etag: new_etag.clone(),
raw_content: content,
},
);
}
Ok(LoadResult {
profile,
from_cache: false,
etag: new_etag,
})
}
pub async fn load_from_file(&self, path: &str) -> Result<LoadResult, ProfileLoadError> {
{
let mut cache = self.cache.write().await;
if let Some(entry) = cache.get(path) {
return Ok(LoadResult {
profile: entry.profile.clone(),
from_cache: true,
etag: None,
});
}
}
let content = tokio::fs::read_to_string(path)
.await
.map_err(|e| ProfileLoadError::Io(e.to_string()))?;
let profile = load_profile(&content)?;
{
let mut cache = self.cache.write().await;
cache.put(
path.to_string(),
CacheEntry {
profile: profile.clone(),
etag: None,
raw_content: content,
},
);
}
Ok(LoadResult {
profile,
from_cache: false,
etag: None,
})
}
pub fn load_file_sync(path: &str) -> Result<Profile, ProfileLoadError> {
let content =
std::fs::read_to_string(path).map_err(|e| ProfileLoadError::Io(e.to_string()))?;
load_profile(&content)
}
pub async fn is_cached(&self, source: &str) -> bool {
let cache = self.cache.read().await;
cache.contains(source)
}
pub async fn invalidate(&self, source: &str) -> bool {
let mut cache = self.cache.write().await;
cache.pop(source).is_some()
}
pub async fn clear_cache(&self) {
let mut cache = self.cache.write().await;
cache.clear();
}
pub async fn cache_size(&self) -> usize {
let cache = self.cache.read().await;
cache.len()
}
pub async fn prefetch(&self, source: &str) -> Result<(), ProfileLoadError> {
self.load(source).await?;
Ok(())
}
pub async fn prefetch_all<'a, I>(&self, sources: I) -> Vec<Result<(), ProfileLoadError>>
where
I: IntoIterator<Item = &'a str>,
{
let mut results = Vec::new();
for source in sources {
results.push(self.prefetch(source).await);
}
results
}
}
pub async fn load_from_url(url: &str) -> Result<Profile, ProfileLoadError> {
let loader = ProfileLoader::new();
let result = loader.load_from_url(url).await?;
Ok(result.profile)
}
pub fn load_from_file(path: &str) -> Result<Profile, ProfileLoadError> {
ProfileLoader::load_file_sync(path)
}
#[cfg(test)]
mod tests {
#![expect(
clippy::assertions_on_result_states,
clippy::panic,
reason = "Pre-existing profile loader test debt moved from hl7v2-prof; cleanup is separate from this behavior-preserving module collapse."
)]
use super::*;
use wiremock::matchers::{method, path};
use wiremock::{Mock, MockServer, ResponseTemplate};
#[tokio::test]
async fn test_load_from_url() {
let server = MockServer::start().await;
let profile_yaml = "message_structure: ADT_A01\nversion: '2.5'\nsegments: []";
Mock::given(method("GET"))
.and(path("/profile.yaml"))
.respond_with(ResponseTemplate::new(200).set_body_string(profile_yaml))
.mount(&server)
.await;
let loader = ProfileLoader::new();
let url = format!("{}/profile.yaml", server.uri());
let result = loader.load(&url).await.unwrap();
assert_eq!(result.profile.message_structure, "ADT_A01");
assert!(!result.from_cache);
}
#[tokio::test]
async fn test_cache_invalidation() {
let server = MockServer::start().await;
let profile_yaml = "message_structure: ADT_A01\nversion: '2.5'\nsegments: []";
Mock::given(method("GET"))
.and(path("/profile.yaml"))
.respond_with(
ResponseTemplate::new(200)
.set_body_string(profile_yaml)
.insert_header("ETag", "v1"),
)
.mount(&server)
.await;
let loader = ProfileLoader::new();
let url = format!("{}/profile.yaml", server.uri());
let _ = loader.load(&url).await.unwrap();
server.reset().await;
Mock::given(method("GET"))
.and(path("/profile.yaml"))
.respond_with(ResponseTemplate::new(304))
.mount(&server)
.await;
let result = loader.load(&url).await.unwrap();
assert!(result.from_cache);
}
#[tokio::test]
async fn test_load_local_file() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("profile.yaml");
let profile_yaml = "message_structure: ORU_R01\nversion: '2.5'\nsegments: []";
std::fs::write(&file_path, profile_yaml).unwrap();
let loader = ProfileLoader::new();
let path_str = file_path.to_str().unwrap();
let result = loader.load(path_str).await.unwrap();
assert_eq!(result.profile.message_structure, "ORU_R01");
}
#[tokio::test]
async fn test_invalid_url_scheme() {
let loader = ProfileLoader::new();
let result = loader.load("ftp://example.com/profile.yaml").await;
assert!(result.is_err());
}
#[tokio::test]
async fn test_file_not_found() {
let loader = ProfileLoader::new();
let result = loader.load("non_existent_file.yaml").await;
assert!(result.is_err());
assert!(matches!(result, Err(ProfileLoadError::Io(_))));
}
#[tokio::test]
async fn test_parse_error() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("invalid.yaml");
let invalid_yaml = "[: invalid yaml";
std::fs::write(&file_path, invalid_yaml).unwrap();
let loader = ProfileLoader::new();
let result = loader.load(file_path.to_str().unwrap()).await;
assert!(result.is_err());
if let Err(ProfileLoadError::YamlParse(_)) = result {
} else {
panic!("Expected YamlParse error, got {:?}", result);
}
}
#[tokio::test]
async fn test_lru_eviction() {
let loader = ProfileLoader::builder().cache_size(1).build();
let temp_dir = tempfile::tempdir().unwrap();
let p1 = temp_dir.path().join("p1.yaml");
let p2 = temp_dir.path().join("p2.yaml");
let yaml = "message_structure: ADT_A01\nversion: '2.5'\nsegments: []";
std::fs::write(&p1, yaml).unwrap();
std::fs::write(&p2, yaml).unwrap();
loader.load(p1.to_str().unwrap()).await.unwrap();
loader.load(p2.to_str().unwrap()).await.unwrap();
let result = loader.load(p1.to_str().unwrap()).await.unwrap();
assert!(!result.from_cache);
}
#[tokio::test]
async fn test_clear_cache() {
let temp_dir = tempfile::tempdir().unwrap();
let file_path = temp_dir.path().join("profile.yaml");
let yaml = "message_structure: ADT_A01\nversion: '2.5'\nsegments: []";
std::fs::write(&file_path, yaml).unwrap();
let loader = ProfileLoader::new();
let path = file_path.to_str().unwrap();
loader.load(path).await.unwrap();
loader.clear_cache().await;
let result = loader.load(path).await.unwrap();
assert!(!result.from_cache);
}
}