use serde::Serialize;
use hwpforge::ops;
use crate::compat::{self, Tool};
use crate::output::{read_file_bytes, ToolErrorInfo, ToolWarningInfo};
#[derive(Debug, Serialize)]
pub struct ValidateData {
pub valid: bool,
pub sections: usize,
pub paragraphs: usize,
pub issues: Vec<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<ToolWarningInfo>,
}
pub fn run_validate(file_path: &str) -> Result<ValidateData, ToolErrorInfo> {
let bytes = read_file_bytes(file_path)?;
match ops::validate(&bytes) {
Ok(out) if out.ok => Ok(ValidateData {
valid: true,
sections: out.sections,
paragraphs: out.paragraphs,
issues: vec![],
warnings: out.warnings.iter().map(compat::warning).collect(),
}),
Ok(out) => {
let detail = out.errors.first().map(|e| e.message.as_str()).unwrap_or_default();
Ok(ValidateData {
valid: false,
sections: out.sections,
paragraphs: out.paragraphs,
issues: vec![format!("Validation error: {detail}")],
warnings: out.warnings.iter().map(compat::warning).collect(),
})
}
Err(err) => Err(compat::tool_error(Tool::Validate, err)),
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn validate_missing_file() {
let err = run_validate("/nonexistent/file.hwpx").unwrap_err();
assert_eq!(err.code, "FILE_NOT_FOUND");
}
#[test]
fn validate_valid_document() {
let dir = tempfile::tempdir().unwrap();
let hwpx_path = dir.path().join("valid.hwpx");
crate::tools::convert::run_convert(
"# Test\n\nParagraph.",
false,
hwpx_path.to_str().unwrap(),
"default",
)
.unwrap();
let data = run_validate(hwpx_path.to_str().unwrap()).unwrap();
assert!(data.valid);
assert!(data.sections >= 1);
assert!(data.paragraphs >= 1);
assert!(data.issues.is_empty());
assert!(data.warnings.is_empty(), "a clean document must not warn: {:?}", data.warnings);
}
#[test]
fn validate_reports_decode_failure_as_an_error_not_as_invalid() {
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("bad.hwpx");
std::fs::write(&path, b"not a zip file").unwrap();
let err = run_validate(path.to_str().unwrap())
.expect_err("an undecodable package must be an error, not a valid:false payload");
assert_eq!(err.code, "DECODE_FAILED");
assert!(
err.hint.contains("hwpforge convert-hwp5"),
"hint must name the CLI command that converts an HWP5 file: {:?}",
err.hint
);
let hwp_path = fixture("tables/table_01_basic_2x2.hwp");
let err = run_validate(&hwp_path)
.expect_err("a real .hwp file must be an error, not a valid:false payload");
assert_eq!(err.code, "DECODE_FAILED");
assert!(
err.hint.contains("hwpforge convert-hwp5"),
"hint must name the CLI command that converts an HWP5 file: {:?}",
err.hint
);
}
fn fixture(rel: &str) -> String {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/fixtures")
.join(rel)
.to_str()
.unwrap()
.to_string()
}
#[test]
fn validate_surfaces_decode_warnings_on_a_valid_document() {
let path = fixture("layout/stale-line-cache.hwpx");
let data = run_validate(&path).unwrap();
assert!(data.valid, "{:?}", data.issues);
assert!(
data.warnings.iter().any(|w| w.code == "LAYOUT_CACHE_DROPPED"),
"validate must surface the decode warning of a successful decode: {:?}",
data.warnings
);
let value = serde_json::to_value(&data).unwrap();
assert_eq!(value["warnings"][0]["code"], "LAYOUT_CACHE_DROPPED");
assert!(!value["warnings"][0]["message"].as_str().unwrap_or_default().is_empty());
}
#[test]
fn validate_reports_decode_warnings_and_a_real_validation_failure_together() {
use std::io::{Cursor, Write};
let bytes = std::fs::read(fixture("layout/stale-line-cache.hwpx")).expect("read fixture");
let mut archive = zip::ZipArchive::new(Cursor::new(bytes)).expect("open zip");
let mut writer = zip::ZipWriter::new(Cursor::new(Vec::new()));
for i in 0..archive.len() {
let entry = archive.by_index_raw(i).expect("entry");
writer.raw_copy_file(entry).expect("copy");
}
writer
.start_file("Contents/section1.xml", zip::write::SimpleFileOptions::default())
.expect("start section1");
writer
.write_all(
br#"<?xml version="1.0" encoding="UTF-8" standalone="yes" ?><hs:sec xmlns:hp="http://www.hancom.co.kr/hwpml/2011/paragraph" xmlns:hs="http://www.hancom.co.kr/hwpml/2011/section"></hs:sec>"#,
)
.expect("write section1");
let tampered = writer.finish().expect("finish").into_inner();
let dir = tempfile::tempdir().unwrap();
let path = dir.path().join("two-section-one-empty.hwpx");
std::fs::write(&path, &tampered).expect("write tampered fixture");
let data = run_validate(path.to_str().unwrap()).unwrap();
assert!(!data.valid, "the second, empty section must fail Document::validate");
assert_eq!(data.sections, 2, "the decode itself sees both sections");
assert!(
data.issues.iter().any(|issue| issue.contains("Section 1 has no paragraphs")),
"the validation error must name the rule it tripped: {:?}",
data.issues
);
let value = serde_json::to_value(&data).unwrap();
assert_eq!(value["warnings"][0]["code"], "LAYOUT_CACHE_DROPPED");
assert!(!value["warnings"][0]["message"].as_str().unwrap_or_default().is_empty());
}
}