use crate::error::{Error, Result};
use crate::types::catalogs::files::CatalogFile;
use crate::types::scenario::storyboard::OpenScenario;
use markup_fmt::{config::FormatOptions, format_text, Language};
use std::fs;
use std::path::Path;
const MAX_FILE_SIZE: u64 = 100 * 1024 * 1024;
fn remove_bom(content: &str) -> &str {
if content.starts_with('\u{FEFF}') {
&content['\u{FEFF}'.len_utf8()..]
} else {
content
}
}
fn parse_from_file_internal<P: AsRef<Path>>(path: P, validate_xml: bool) -> Result<OpenScenario> {
let metadata = fs::metadata(&path).map_err(Error::from).map_err(|e| {
e.with_context(&format!(
"Failed to read file metadata: {}",
path.as_ref().display()
))
})?;
if metadata.len() > MAX_FILE_SIZE {
return Err(Error::out_of_range(
"file_size",
&metadata.len().to_string(),
"0",
&MAX_FILE_SIZE.to_string(),
));
}
let xml_content = fs::read_to_string(&path)
.map_err(Error::from)
.map_err(|e| {
e.with_context(&format!("Failed to read file: {}", path.as_ref().display()))
})?;
let cleaned_content = remove_bom(&xml_content);
if validate_xml {
validate_xml_structure(cleaned_content).map_err(|e| {
e.with_context(&format!(
"XML validation failed for file: {}",
path.as_ref().display()
))
})?;
}
parse_from_str(cleaned_content).map_err(|e| {
e.with_context(&format!(
"Failed to parse file: {}",
path.as_ref().display()
))
})
}
fn parse_catalog_from_file_internal<P: AsRef<Path>>(
path: P,
validate_xml: bool,
) -> Result<CatalogFile> {
let metadata = fs::metadata(&path).map_err(Error::from).map_err(|e| {
e.with_context(&format!(
"Failed to read catalog file metadata: {}",
path.as_ref().display()
))
})?;
if metadata.len() > MAX_FILE_SIZE {
return Err(Error::out_of_range(
"file_size",
&metadata.len().to_string(),
"0",
&MAX_FILE_SIZE.to_string(),
));
}
let xml_content = fs::read_to_string(&path)
.map_err(Error::from)
.map_err(|e| {
e.with_context(&format!(
"Failed to read catalog file: {}",
path.as_ref().display()
))
})?;
let cleaned_content = remove_bom(&xml_content);
if validate_xml {
validate_catalog_xml_structure(cleaned_content).map_err(|e| {
e.with_context(&format!(
"XML validation failed for catalog file: {}",
path.as_ref().display()
))
})?;
}
parse_catalog_from_str(cleaned_content).map_err(|e| {
e.with_context(&format!(
"Failed to parse catalog file: {}",
path.as_ref().display()
))
})
}
#[must_use = "parsing result should be handled"]
pub fn parse_from_str(xml: &str) -> Result<OpenScenario> {
quick_xml::de::from_str(xml)
.map_err(Error::from)
.map_err(|e| e.with_context("Failed to parse OpenSCENARIO XML"))
}
#[must_use = "parsing result should be handled"]
pub fn parse_from_file<P: AsRef<Path>>(path: P) -> Result<OpenScenario> {
parse_from_file_internal(path, false)
}
#[must_use = "serialization result should be handled"]
pub fn serialize_to_string(scenario: &OpenScenario) -> Result<String> {
let mut xml = String::from(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
xml.push('\n');
let serialized = quick_xml::se::to_string(scenario)
.map_err(Error::XmlSerializeError)
.map_err(|e| e.with_context("Failed to serialize OpenSCENARIO to XML"))?;
let s = format_text(
&serialized,
Language::Xml,
&FormatOptions::default(),
|serialized, _| Ok::<_, std::convert::Infallible>(serialized.into()),
)
.unwrap();
xml.push_str(&s);
Ok(xml)
}
#[must_use = "serialization result should be handled"]
pub fn serialize_to_file<P: AsRef<Path>>(scenario: &OpenScenario, path: P) -> Result<()> {
let xml = serialize_to_string(scenario)?;
fs::write(&path, xml).map_err(Error::from).map_err(|e| {
e.with_context(&format!(
"Failed to write file: {}",
path.as_ref().display()
))
})
}
pub fn validate_xml_structure(xml: &str) -> Result<()> {
let trimmed = xml.trim();
if trimmed.is_empty() {
return Err(Error::invalid_xml("XML document is empty"));
}
if !trimmed.starts_with("<?xml") && !trimmed.starts_with('<') {
return Err(Error::invalid_xml(
"XML document must start with XML declaration or root element",
));
}
if !trimmed.contains("OpenSCENARIO") {
return Err(Error::invalid_xml(
"Document does not appear to contain OpenSCENARIO root element",
));
}
Ok(())
}
#[must_use = "parsing result should be handled"]
pub fn parse_from_str_validated(xml: &str) -> Result<OpenScenario> {
validate_xml_structure(xml)?;
parse_from_str(xml)
}
#[must_use = "parsing result should be handled"]
pub fn parse_from_file_validated<P: AsRef<Path>>(path: P) -> Result<OpenScenario> {
parse_from_file_internal(path, true)
}
#[must_use = "parsing result should be handled"]
pub fn parse_catalog_from_str(xml: &str) -> Result<CatalogFile> {
quick_xml::de::from_str(xml)
.map_err(Error::from)
.map_err(|e| e.with_context("Failed to parse catalog XML"))
}
#[must_use = "parsing result should be handled"]
pub fn parse_catalog_from_file<P: AsRef<Path>>(path: P) -> Result<CatalogFile> {
parse_catalog_from_file_internal(path, false)
}
pub fn validate_catalog_xml_structure(xml: &str) -> Result<()> {
let trimmed = xml.trim();
if trimmed.is_empty() {
return Err(Error::invalid_xml("Catalog XML document is empty"));
}
if !trimmed.starts_with("<?xml") && !trimmed.starts_with('<') {
return Err(Error::invalid_xml(
"Catalog XML document must start with XML declaration or root element",
));
}
if !trimmed.contains("OpenSCENARIO") {
return Err(Error::invalid_xml(
"Document does not appear to contain OpenSCENARIO root element",
));
}
if !trimmed.contains("Catalog") {
return Err(Error::invalid_xml(
"Document does not appear to contain Catalog element",
));
}
Ok(())
}
#[must_use = "parsing result should be handled"]
pub fn parse_catalog_from_str_validated(xml: &str) -> Result<CatalogFile> {
validate_catalog_xml_structure(xml)?;
parse_catalog_from_str(xml)
}
#[must_use = "parsing result should be handled"]
pub fn parse_catalog_from_file_validated<P: AsRef<Path>>(path: P) -> Result<CatalogFile> {
parse_catalog_from_file_internal(path, true)
}
pub fn serialize_catalog_to_string(catalog: &CatalogFile) -> Result<String> {
let mut xml = String::from(r#"<?xml version="1.0" encoding="UTF-8"?>"#);
xml.push('\n');
let serialized = quick_xml::se::to_string(catalog)
.map_err(Error::XmlSerializeError)
.map_err(|e| e.with_context("Failed to serialize catalog to XML"))?;
xml.push_str(&serialized);
Ok(xml)
}
pub fn serialize_catalog_to_file<P: AsRef<Path>>(catalog: &CatalogFile, path: P) -> Result<()> {
let xml = serialize_catalog_to_string(catalog)?;
fs::write(&path, xml).map_err(Error::from).map_err(|e| {
e.with_context(&format!(
"Failed to write catalog file: {}",
path.as_ref().display()
))
})
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_validate_xml_structure() {
assert!(
validate_xml_structure(r#"<?xml version="1.0"?><OpenSCENARIO></OpenSCENARIO>"#).is_ok()
);
assert!(validate_xml_structure(r#"<OpenSCENARIO></OpenSCENARIO>"#).is_ok());
assert!(validate_xml_structure("").is_err());
assert!(validate_xml_structure(" ").is_err());
assert!(validate_xml_structure("This is not XML").is_err());
assert!(validate_xml_structure(r#"<SomeOtherRoot></SomeOtherRoot>"#).is_err());
}
#[test]
fn test_validate_catalog_xml_structure() {
let valid_xml = r#"<?xml version="1.0"?>
<OpenSCENARIO>
<FileHeader revMajor="1" revMinor="3" date="2024-01-01T00:00:00" author="Test" description="Test"/>
<Catalog name="test">
</Catalog>
</OpenSCENARIO>"#;
assert!(validate_catalog_xml_structure(valid_xml).is_ok());
let invalid_xml = r#"<?xml version="1.0"?><OpenSCENARIO><FileHeader/></OpenSCENARIO>"#;
assert!(validate_catalog_xml_structure(invalid_xml).is_err());
assert!(validate_catalog_xml_structure("").is_err());
}
#[test]
fn test_catalog_serialization_roundtrip() {
let catalog = CatalogFile::default();
let xml = serialize_catalog_to_string(&catalog).unwrap();
assert!(xml.contains("<?xml version=\"1.0\" encoding=\"UTF-8\"?>"));
assert!(xml.contains("OpenSCENARIO"));
assert!(xml.contains("Catalog"));
}
}