use serde::Serialize;
use hwpforge::ops::{self, OpsWarning};
use crate::compat::{self, Tool};
use crate::output::{read_file_bytes, ToolErrorInfo};
#[derive(Debug, Serialize)]
#[non_exhaustive]
pub struct StructuralData {
pub output_path: String,
pub change: String,
#[serde(skip_serializing_if = "Vec::is_empty")]
pub warnings: Vec<String>,
}
fn render_warning(warning: &OpsWarning) -> String {
match warning {
OpsWarning::Structural(sw) => sw.to_string(),
other => {
let info = other.info();
format!("{}: {}", info.code, info.message)
}
}
}
fn structural_advisories(warnings: &[OpsWarning]) -> Vec<String> {
warnings
.iter()
.filter(|w| matches!(w, OpsWarning::Structural(_) | OpsWarning::Decode(_)))
.map(render_warning)
.collect()
}
pub fn run_delete_para(
file_path: &str,
section: usize,
indices: &[usize],
output_path: &str,
) -> Result<StructuralData, ToolErrorInfo> {
if indices.is_empty() {
return Err(ToolErrorInfo::new(
"DELETE_NO_TARGET",
"Pass at least one paragraph index",
"indices must be a non-empty list of top-level paragraph indices.",
));
}
let bytes = read_file_bytes(file_path)?;
let opts =
ops::DeleteParaOptions::default().with_section(section).with_indexes(indices.to_vec());
let out =
ops::delete_para(&bytes, &opts).map_err(|e| compat::tool_error(Tool::DeletePara, e))?;
let warnings = structural_advisories(&out.warnings);
write_bytes(&out.bytes, output_path)?;
Ok(StructuralData {
output_path: output_path.to_string(),
change: format!("deleted {} paragraph(s) from section {section}", indices.len()),
warnings,
})
}
pub fn run_insert_para(
file_path: &str,
section: usize,
anchor: usize,
before: bool,
text: Option<&str>,
texts: Option<&[String]>,
output_path: &str,
) -> Result<StructuralData, ToolErrorInfo> {
let block: Vec<String> = match (text, texts) {
(Some(t), None) => vec![t.to_string()],
(None, Some(ts)) if !ts.is_empty() => ts.to_vec(),
_ => {
return Err(ToolErrorInfo::new(
"INSERT_TEXT_REQUIRED",
"Provide exactly one of `text` or `texts` (non-empty)",
"Use `text` for a single paragraph or `texts` for a contiguous block.",
));
}
};
let bytes = read_file_bytes(file_path)?;
let opts = ops::InsertParaOptions::default()
.with_section(section)
.with_anchor(anchor)
.with_text(block.clone())
.with_before(before);
let out =
ops::insert_para(&bytes, &opts).map_err(|e| compat::tool_error(Tool::InsertPara, e))?;
let warnings = structural_advisories(&out.warnings);
write_bytes(&out.bytes, output_path)?;
let where_ = if before { "before" } else { "after" };
Ok(StructuralData {
output_path: output_path.to_string(),
change: format!(
"inserted {} paragraph(s) {where_} section {section} paragraph {anchor}",
block.len()
),
warnings,
})
}
fn write_bytes(bytes: &[u8], path: &str) -> Result<(), ToolErrorInfo> {
std::fs::write(path, bytes).map_err(|e| {
ToolErrorInfo::new(
"FILE_WRITE_FAILED",
format!("Cannot write '{path}': {e}"),
"Check the output path and permissions.",
)
})
}
#[cfg(test)]
mod tests {
use super::*;
fn base_doc(dir: &tempfile::TempDir) -> String {
let path = dir.path().join("base.hwpx");
crate::tools::convert::run_convert(
"첫째 문단.\n\n둘째 문단.\n\n셋째 문단.",
false,
path.to_str().unwrap(),
"default",
)
.unwrap();
path.to_str().unwrap().to_string()
}
fn warning_fixture(dir: &tempfile::TempDir) -> String {
let bytes = std::fs::read(concat!(
env!("CARGO_MANIFEST_DIR"),
"/../../tests/fixtures/layout/stale-line-cache.hwpx"
))
.expect("stale-line-cache.hwpx");
let path = dir.path().join("stale-line-cache.hwpx");
std::fs::write(&path, bytes).unwrap();
path.to_str().unwrap().to_string()
}
#[test]
fn insert_para_via_mcp_surface() {
let dir = tempfile::tempdir().unwrap();
let base = base_doc(&dir);
let out = dir.path().join("out.hwpx");
let data =
run_insert_para(&base, 0, 1, false, Some("삽입"), None, out.to_str().unwrap()).unwrap();
assert!(data.change.contains("inserted 1 paragraph"));
assert!(out.exists());
assert!(data.warnings.is_empty(), "a clean document must not warn: {:?}", data.warnings);
}
#[test]
fn insert_para_surfaces_the_decode_warning_from_a_stale_layout_cache() {
let dir = tempfile::tempdir().unwrap();
let base = warning_fixture(&dir);
let out = dir.path().join("out.hwpx");
let data =
run_insert_para(&base, 0, 2, false, Some("끼움"), None, out.to_str().unwrap()).unwrap();
let json = serde_json::to_value(&data).unwrap();
let warnings = json["warnings"].as_array().expect("warnings is an array");
assert_eq!(warnings.len(), 1, "{json}");
let first = warnings[0].as_str().expect("warning entries are strings");
assert!(first.contains("LAYOUT_CACHE_DROPPED"), "{json}");
}
#[test]
fn delete_para_surfaces_the_decode_warning_before_its_own_advisory() {
let dir = tempfile::tempdir().unwrap();
let base = warning_fixture(&dir);
let out = dir.path().join("out.hwpx");
let data = run_delete_para(&base, 0, &[2], out.to_str().unwrap()).unwrap();
let json = serde_json::to_value(&data).unwrap();
let warnings = json["warnings"].as_array().expect("warnings is an array");
assert_eq!(warnings.len(), 2, "{json}");
let first = warnings[0].as_str().expect("warning entries are strings");
let second = warnings[1].as_str().expect("warning entries are strings");
assert!(first.contains("LAYOUT_CACHE_DROPPED"), "{json}");
assert!(second.contains("index-mark"), "{json}");
}
#[test]
fn insert_para_batch_via_texts() {
let dir = tempfile::tempdir().unwrap();
let base = base_doc(&dir);
let out = dir.path().join("out.hwpx");
let block = vec!["하나.".to_string(), "둘.".to_string()];
let data =
run_insert_para(&base, 0, 1, false, None, Some(&block), out.to_str().unwrap()).unwrap();
assert!(data.change.contains("inserted 2 paragraph"));
assert!(out.exists());
}
#[test]
fn insert_para_requires_exactly_one_text_form() {
let dir = tempfile::tempdir().unwrap();
let base = base_doc(&dir);
let out = dir.path().join("out.hwpx");
let block = vec!["x".to_string()];
let err =
run_insert_para(&base, 0, 1, false, None, None, out.to_str().unwrap()).unwrap_err();
assert_eq!(err.code, "INSERT_TEXT_REQUIRED");
let err =
run_insert_para(&base, 0, 1, false, Some("x"), Some(&block), out.to_str().unwrap())
.unwrap_err();
assert_eq!(err.code, "INSERT_TEXT_REQUIRED");
let err = run_insert_para(&base, 0, 1, false, None, Some(&[]), out.to_str().unwrap())
.unwrap_err();
assert_eq!(err.code, "INSERT_TEXT_REQUIRED");
}
#[test]
fn delete_para_via_mcp_surface_and_secpr_rejection() {
let dir = tempfile::tempdir().unwrap();
let base = base_doc(&dir);
let out = dir.path().join("out.hwpx");
let data = run_delete_para(&base, 0, &[1], out.to_str().unwrap()).unwrap();
assert!(data.change.contains("deleted"));
assert!(data.warnings.is_empty(), "plain paragraph must not warn: {:?}", data.warnings);
let err = run_delete_para(&base, 0, &[0], out.to_str().unwrap()).unwrap_err();
assert_eq!(err.code, "SECTION_PROPERTIES_PARAGRAPH");
}
#[test]
fn delete_para_rejects_empty_targets() {
let dir = tempfile::tempdir().unwrap();
let base = base_doc(&dir);
let out = dir.path().join("out.hwpx");
let err = run_delete_para(&base, 0, &[], out.to_str().unwrap()).unwrap_err();
assert_eq!(err.code, "DELETE_NO_TARGET");
}
#[test]
fn error_code_mapping_covers_common_rejections() {
let dir = tempfile::tempdir().unwrap();
let base = base_doc(&dir);
let out = dir.path().join("out.hwpx");
let err = run_insert_para(&base, 0, 99, false, Some("x"), None, out.to_str().unwrap())
.unwrap_err();
assert_eq!(err.code, "INDEX_OUT_OF_RANGE");
let err = run_delete_para(&base, 9, &[0], out.to_str().unwrap()).unwrap_err();
assert_eq!(err.code, "INDEX_OUT_OF_RANGE");
let err = run_insert_para(&base, 0, 1, false, Some("a\nb"), None, out.to_str().unwrap())
.unwrap_err();
assert_eq!(err.code, "MULTI_PARAGRAPH_TEXT");
let err = run_delete_para(&base, 0, &[1, 1], out.to_str().unwrap()).unwrap_err();
assert_eq!(err.code, "DUPLICATE_TARGET");
let garbage = dir.path().join("g.hwpx");
std::fs::write(&garbage, b"not a zip").unwrap();
let err =
run_delete_para(garbage.to_str().unwrap(), 0, &[0], out.to_str().unwrap()).unwrap_err();
assert_eq!(err.code, "STRUCTURAL_CODEC");
}
fn fixture(rel: &str) -> String {
std::path::Path::new(env!("CARGO_MANIFEST_DIR"))
.join("../../tests/fixtures/structural")
.join(rel)
.to_str()
.unwrap()
.to_string()
}
#[test]
fn write_failure_is_reported() {
let dir = tempfile::tempdir().unwrap();
let base = base_doc(&dir);
let err =
run_insert_para(&base, 0, 1, false, Some("x"), None, "/nonexistent-dir-e4/o.hwpx")
.unwrap_err();
assert_eq!(err.code, "FILE_WRITE_FAILED");
let err = run_delete_para(&base, 0, &[1], "/nonexistent-dir-e4/o.hwpx").unwrap_err();
assert_eq!(err.code, "FILE_WRITE_FAILED");
}
#[test]
fn error_code_mapping_reference_and_roundtrip() {
let dir = tempfile::tempdir().unwrap();
let out = dir.path().join("out.hwpx");
let err = run_delete_para(&fixture("crossref_para.hwpx"), 0, &[0], out.to_str().unwrap())
.unwrap_err();
assert_eq!(err.code, "REFERENCE_STRANDED");
let err = run_delete_para(&fixture("page_break.hwpx"), 0, &[1], out.to_str().unwrap())
.unwrap_err();
assert_eq!(err.code, "HARD_BREAK_LOSS");
let err = run_delete_para(&fixture("plain_inserted.hwpx"), 0, &[1], out.to_str().unwrap())
.unwrap_err();
assert_eq!(err.code, "INPUT_NOT_ROUNDTRIP_SAFE");
}
}