#![allow(missing_docs)]
#![allow(dead_code)]
#![allow(clippy::missing_docs_in_private_items)]
use serde::{Deserialize, Serialize};
use std::fs::{self, File};
use std::io::BufReader;
use std::path::{Path, PathBuf};
use std::sync::Mutex;
use std::time::Instant;
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum TestSource {
Cli,
Core,
Node,
Python,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum TestCategory {
Conversion,
Orientation,
Fonts,
Images,
Forms,
Batch,
Watermark,
Bookmarks,
Annotations,
Url,
Metadata,
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum TestStatus {
Pass,
Fail,
Skip,
Error,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TestInput {
#[serde(rename = "type")]
pub input_type: Option<String>,
pub path: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct TestOutput {
pub path: Option<String>,
pub size_bytes: Option<u64>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ExpectedProperties {
pub orientation: Option<String>,
pub page_count: Option<u32>,
pub page_size: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ActualProperties {
pub orientation: Option<String>,
pub page_count: Option<u32>,
pub width_pt: Option<f64>,
pub height_pt: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestResult {
pub name: String,
pub category: TestCategory,
#[serde(skip_serializing_if = "Option::is_none")]
pub description: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub input: Option<TestInput>,
#[serde(skip_serializing_if = "Option::is_none")]
pub output: Option<TestOutput>,
#[serde(skip_serializing_if = "Option::is_none")]
pub expected: Option<ExpectedProperties>,
#[serde(skip_serializing_if = "Option::is_none")]
pub actual: Option<ActualProperties>,
pub status: TestStatus,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub duration_ms: Option<f64>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TestManifest {
pub source: TestSource,
pub timestamp: String,
#[serde(skip_serializing_if = "Option::is_none")]
pub version: Option<String>,
pub tests: Vec<TestResult>,
}
impl TestManifest {
pub fn new(source: TestSource) -> Self {
Self {
source,
timestamp: chrono_now(),
version: option_env!("CARGO_PKG_VERSION").map(|s| s.to_string()),
tests: Vec::new(),
}
}
pub fn load_or_create(path: &Path, source: TestSource) -> Self {
if path.exists() {
match File::open(path) {
Ok(file) => {
let reader = BufReader::new(file);
match serde_json::from_reader(reader) {
Ok(manifest) => return manifest,
Err(e) => eprintln!("Warning: Failed to parse manifest: {}", e),
}
}
Err(e) => eprintln!("Warning: Failed to open manifest: {}", e),
}
}
Self::new(source)
}
pub fn add_result(&mut self, result: TestResult) {
self.tests.retain(|t| t.name != result.name);
self.tests.push(result);
}
pub fn write(&self, path: &Path) -> std::io::Result<()> {
if let Some(parent) = path.parent() {
fs::create_dir_all(parent)?;
}
let file = File::create(path)?;
serde_json::to_writer_pretty(file, self)?;
Ok(())
}
}
pub struct TestResultBuilder {
name: String,
category: TestCategory,
description: Option<String>,
input: Option<TestInput>,
output: Option<TestOutput>,
expected: Option<ExpectedProperties>,
actual: Option<ActualProperties>,
status: TestStatus,
error: Option<String>,
start_time: Option<Instant>,
duration_ms: Option<f64>,
}
impl TestResultBuilder {
pub fn new(name: &str, category: TestCategory) -> Self {
Self {
name: name.to_string(),
category,
description: None,
input: None,
output: None,
expected: None,
actual: None,
status: TestStatus::Pass,
error: None,
start_time: None,
duration_ms: None,
}
}
pub fn description(mut self, desc: &str) -> Self {
self.description = Some(desc.to_string());
self
}
pub fn input_html_file(mut self, path: &str) -> Self {
self.input = Some(TestInput {
input_type: Some("html_file".to_string()),
path: Some(path.to_string()),
});
self
}
pub fn input_url(mut self, url: &str) -> Self {
self.input = Some(TestInput {
input_type: Some("url".to_string()),
path: Some(url.to_string()),
});
self
}
pub fn input_pdf_file(mut self, path: &str) -> Self {
self.input = Some(TestInput {
input_type: Some("pdf_file".to_string()),
path: Some(path.to_string()),
});
self
}
pub fn output_path(mut self, path: &str) -> Self {
let size = fs::metadata(path).map(|m| m.len()).ok();
self.output = Some(TestOutput {
path: Some(path.to_string()),
size_bytes: size,
});
self
}
pub fn expect_orientation(mut self, orientation: &str) -> Self {
let mut expected = self.expected.unwrap_or_default();
expected.orientation = Some(orientation.to_string());
self.expected = Some(expected);
self
}
pub fn expect_page_count(mut self, count: u32) -> Self {
let mut expected = self.expected.unwrap_or_default();
expected.page_count = Some(count);
self.expected = Some(expected);
self
}
pub fn actual_properties(
mut self,
orientation: &str,
page_count: u32,
width: f64,
height: f64,
) -> Self {
self.actual = Some(ActualProperties {
orientation: Some(orientation.to_string()),
page_count: Some(page_count),
width_pt: Some(width),
height_pt: Some(height),
});
self
}
pub fn pass(mut self) -> Self {
self.status = TestStatus::Pass;
self.error = None;
self
}
pub fn fail(mut self, error: &str) -> Self {
self.status = TestStatus::Fail;
self.error = Some(error.to_string());
self
}
pub fn skip(mut self, reason: &str) -> Self {
self.status = TestStatus::Skip;
self.error = Some(reason.to_string());
self
}
pub fn start_timer(mut self) -> Self {
self.start_time = Some(Instant::now());
self
}
pub fn stop_timer(mut self) -> Self {
if let Some(start) = self.start_time {
self.duration_ms = Some(start.elapsed().as_secs_f64() * 1000.0);
}
self
}
pub fn build(self) -> TestResult {
TestResult {
name: self.name,
category: self.category,
description: self.description,
input: self.input,
output: self.output,
expected: self.expected,
actual: self.actual,
status: self.status,
error: self.error,
duration_ms: self.duration_ms,
}
}
}
static MANIFEST: Mutex<Option<TestManifest>> = Mutex::new(None);
static MANIFEST_PATH: Mutex<Option<PathBuf>> = Mutex::new(None);
pub fn init_manifest(output_dir: &Path, source: TestSource) {
let manifest_path = output_dir.join("manifest.json");
let manifest = TestManifest::load_or_create(&manifest_path, source);
*MANIFEST.lock().unwrap() = Some(manifest);
*MANIFEST_PATH.lock().unwrap() = Some(manifest_path);
}
pub fn record_test(result: TestResult) {
if let Some(ref mut manifest) = *MANIFEST.lock().unwrap() {
manifest.add_result(result);
}
}
pub fn save_manifest() {
let manifest = MANIFEST.lock().unwrap();
let path = MANIFEST_PATH.lock().unwrap();
if let (Some(m), Some(p)) = (manifest.as_ref(), path.as_ref()) {
if let Err(e) = m.write(p) {
eprintln!("Warning: Failed to write manifest: {}", e);
}
}
}
fn chrono_now() -> String {
use std::time::SystemTime;
let now = SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
let days_since_epoch = now / 86400;
let seconds_today = now % 86400;
let hours = seconds_today / 3600;
let minutes = (seconds_today % 3600) / 60;
let seconds = seconds_today % 60;
let mut year = 1970i64;
let mut remaining_days = days_since_epoch as i64;
loop {
let days_in_year = if is_leap_year(year) { 366 } else { 365 };
if remaining_days < days_in_year {
break;
}
remaining_days -= days_in_year;
year += 1;
}
let months = [
31,
28 + if is_leap_year(year) { 1 } else { 0 },
31,
30,
31,
30,
31,
31,
30,
31,
30,
31,
];
let mut month = 1;
for days_in_month in months {
if remaining_days < days_in_month {
break;
}
remaining_days -= days_in_month;
month += 1;
}
let day = remaining_days + 1;
format!(
"{:04}-{:02}-{:02}T{:02}:{:02}:{:02}Z",
year, month, day, hours, minutes, seconds
)
}
fn is_leap_year(year: i64) -> bool {
(year % 4 == 0 && year % 100 != 0) || (year % 400 == 0)
}
pub fn extract_pdf_metadata(pdf_path: &Path) -> Option<ActualProperties> {
let content = fs::read(pdf_path).ok()?;
let content_str = String::from_utf8_lossy(&content);
let page_count = content_str.matches("/Type /Page").count().max(1) as u32;
let (width, height) = if let Some(pos) = content_str.find("/MediaBox") {
parse_media_box(&content_str[pos..]).unwrap_or((612.0, 792.0))
} else {
(612.0, 792.0)
};
let orientation = if width > height {
"landscape"
} else {
"portrait"
};
Some(ActualProperties {
orientation: Some(orientation.to_string()),
page_count: Some(page_count),
width_pt: Some(width),
height_pt: Some(height),
})
}
fn parse_media_box(content: &str) -> Option<(f64, f64)> {
let start = content.find('[')? + 1;
let end = content.find(']')?;
let box_content = &content[start..end];
let parts: Vec<f64> = box_content
.split_whitespace()
.filter_map(|s| s.parse().ok())
.collect();
if parts.len() >= 4 {
Some((parts[2], parts[3]))
} else {
None
}
}