use serde::Serialize;
use hwpforge::ops::{self, ExportSectionOptions, OpsWarning, ToJsonOptions};
use hwpforge_smithy_hwpx::SectionWorkflowWarning;
use crate::compat::{self, Tool};
use crate::output::{
read_file_bytes, write_output_file, ToolErrorInfo, ToolWarningInfo, MAX_INLINE_RESPONSE,
};
#[derive(Debug, Serialize)]
pub struct ToJsonData {
pub output_path: Option<String>,
pub size_bytes: u64,
pub section_only: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub json_content: Option<String>,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<ToolWarningInfo>,
}
pub fn run_to_json(
file_path: &str,
section_idx: Option<usize>,
output_path: Option<&str>,
) -> Result<ToJsonData, ToolErrorInfo> {
if let Some(out_path) = output_path {
if !out_path.ends_with(".json") {
return Err(ToolErrorInfo::new(
"INVALID_EXTENSION",
format!("Output path must end with .json: {out_path}"),
"Use a .json output_path, or omit output_path to receive the JSON inline.",
));
}
}
let bytes = read_file_bytes(file_path)?;
let mut warnings: Vec<ToolWarningInfo> = Vec::new();
let json_string = if let Some(idx) = section_idx {
let out = ops::export_section(&bytes, &ExportSectionOptions::default().with_section(idx))
.map_err(|e| compat::tool_error(Tool::ToJson, e))?;
for w in &out.warnings {
match w {
OpsWarning::SectionWorkflow(sw) => {
warnings.push(map_section_workflow_warning_for_to_json(sw.clone()));
}
other => warnings.push(compat::warning(other)),
}
}
render_pretty_value(&out.section)?
} else {
let out = ops::to_json(&bytes, &ToJsonOptions::default())
.map_err(|e| compat::tool_error(Tool::ToJson, e))?;
for w in &out.warnings {
warnings.push(compat::warning(w));
}
render_pretty_value(&out.document)?
};
let size_bytes = json_string.len() as u64;
if let Some(out_path) = output_path {
write_output_file(out_path, json_string.as_bytes())?;
Ok(ToJsonData {
output_path: Some(out_path.to_string()),
size_bytes,
section_only: section_idx.is_some(),
json_content: None,
warnings,
})
} else {
build_to_json_data(json_string, section_idx.is_some(), warnings)
}
}
fn build_to_json_data(
json_string: String,
section_only: bool,
warnings: Vec<ToolWarningInfo>,
) -> Result<ToJsonData, ToolErrorInfo> {
if json_string.len() > MAX_INLINE_RESPONSE {
return Err(output_too_large_error(json_string.len()));
}
let size_bytes = json_string.len() as u64;
let data = ToJsonData {
output_path: None,
size_bytes,
section_only,
json_content: Some(json_string),
warnings,
};
let mut counter = CountingWriter::default();
let inline_size = match serde_json::to_writer(&mut counter, &data) {
Ok(()) => counter.0,
Err(_) => usize::MAX,
};
if inline_size > MAX_INLINE_RESPONSE {
return Err(output_too_large_error(inline_size));
}
Ok(data)
}
fn output_too_large_error(inline_size: usize) -> ToolErrorInfo {
ToolErrorInfo::new(
"OUTPUT_TOO_LARGE",
format!("JSON response is {inline_size} bytes (limit {MAX_INLINE_RESPONSE})"),
"Use output_path to write to a file, or use section parameter to export a single section.",
)
}
#[derive(Default)]
struct CountingWriter(usize);
impl std::io::Write for CountingWriter {
fn write(&mut self, buf: &[u8]) -> std::io::Result<usize> {
self.0 += buf.len();
Ok(buf.len())
}
fn flush(&mut self) -> std::io::Result<()> {
Ok(())
}
}
fn render_pretty_value(value: &serde_json::Value) -> Result<String, ToolErrorInfo> {
serde_json::to_string_pretty(value).map_err(|e| {
ToolErrorInfo::new(
"SERIALIZE_ERROR",
format!("Failed to serialize export: {e}"),
"This may be a bug.",
)
})
}
fn map_section_workflow_warning_for_to_json(warning: SectionWorkflowWarning) -> ToolWarningInfo {
let mapped = compat::warning(&OpsWarning::SectionWorkflow(warning));
if mapped.code == "PRESERVATION_METADATA_UNAVAILABLE" {
mapped.with_hint(
"This JSON export may inspect correctly, but later hwpforge_patch can fail until preservation metadata is available. Re-export with the current tool after simplifying unsupported mixed-content edits.",
)
} else {
mapped
}
}
#[cfg(test)]
mod tests {
use super::*;
use hwpforge_smithy_hwpx::ExportedSection;
use hwpforge_smithy_hwpx::SECTION_PRESERVATION_VERSION;
#[test]
fn to_json_section_embeds_preservation_metadata() {
let dir = tempfile::tempdir().unwrap();
let hwpx_path = dir.path().join("source.hwpx");
crate::tools::convert::run_convert(
"# 제목\n\n본문 문단입니다.",
false,
hwpx_path.to_str().unwrap(),
"default",
)
.unwrap();
let data = run_to_json(hwpx_path.to_str().unwrap(), Some(0), None).unwrap();
assert!(data.section_only);
let json = data.json_content.expect("inline section json expected");
let exported: ExportedSection = serde_json::from_str(&json).unwrap();
assert_eq!(exported.section_index, 0);
assert!(exported.preservation.is_some(), "section export must embed preservation metadata");
let preservation = exported.preservation.unwrap();
assert_eq!(preservation.preservation_version, SECTION_PRESERVATION_VERSION);
assert!(!preservation.text_slots.is_empty());
assert!(data.warnings.is_empty());
}
#[test]
fn to_json_maps_preservation_warning_for_machine_consumers() {
let warning = SectionWorkflowWarning::PreservationMetadataUnavailable {
detail: "raw/semantic mismatch".to_string(),
};
let mapped = map_section_workflow_warning_for_to_json(warning);
assert_eq!(mapped.code, "PRESERVATION_METADATA_UNAVAILABLE");
assert!(mapped.message.contains("raw/semantic mismatch"));
assert!(mapped.hint.as_deref().unwrap().contains("hwpforge_patch"));
}
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 to_json_surfaces_decode_warnings_for_both_export_shapes() {
let path = fixture("layout/stale-line-cache.hwpx");
let full = run_to_json(&path, None, None).unwrap();
assert!(
full.warnings.iter().any(|w| w.code == "LAYOUT_CACHE_DROPPED"),
"full-document export must surface the decode warning: {:?}",
full.warnings
);
let section = run_to_json(&path, Some(0), None).unwrap();
assert!(
section.warnings.iter().any(|w| w.code == "LAYOUT_CACHE_DROPPED"),
"section export must surface the decode warning: {:?}",
section.warnings
);
}
#[test]
fn to_json_rejects_non_json_output_path() {
let err = run_to_json("ignored.hwpx", None, Some("out.txt"))
.expect_err("non-.json output_path must be rejected");
assert_eq!(err.code, "INVALID_EXTENSION");
assert!(err.message.contains(".json"), "message should mention the .json requirement");
}
#[test]
fn to_json_inline_mode_skips_extension_guard() {
let err = run_to_json("/nonexistent/file.hwpx", None, None)
.expect_err("missing input must still error");
assert_ne!(err.code, "INVALID_EXTENSION", "inline mode must not hit the extension guard");
}
#[test]
fn to_json_oversized_warnings_alone_reports_output_too_large() {
let huge =
vec![ToolWarningInfo::new("STUB_OVERSIZED", "x".repeat(MAX_INLINE_RESPONSE + 1))];
let err = build_to_json_data("{}".to_string(), false, huge).unwrap_err();
assert_eq!(err.code, "OUTPUT_TOO_LARGE");
}
#[test]
fn to_json_content_alone_over_limit_is_rejected_before_building_the_response() {
let huge = "x".repeat(MAX_INLINE_RESPONSE + 1);
let len = huge.len();
let err = build_to_json_data(huge, false, Vec::new()).unwrap_err();
assert_eq!(err.code, "OUTPUT_TOO_LARGE");
assert!(
err.message.contains(&format!("{len} bytes")),
"expected the early-return branch to report exactly {len} bytes: {}",
err.message
);
}
}