use hwpforge::ops::{OpsError, OpsWarning};
use hwpforge_foundation::diagnostics::OpsCode;
use hwpforge_smithy_hwpx::stamp::{CellStampError, StampError, StamperError};
use hwpforge_smithy_hwpx::{FillError, SectionWorkflowError};
use crate::output::{ToolErrorInfo, ToolWarningInfo};
pub(crate) const READ_TARGET_REQUIRED_MESSAGE: &str = "Pass exactly one of section, table, field";
pub(crate) const READ_PARAS_WITHOUT_SECTION_MESSAGE: &str = "paras requires section";
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
pub enum Tool {
ConvertMd,
Inspect,
Outline,
Read,
Fields,
ToJson,
FromJson,
Patch,
Diff,
Fill,
SetCell,
InsertPara,
DeletePara,
StampPlan,
Stamp,
Templates,
Restyle,
Validate,
ToMd,
}
struct Row {
tool: Tool,
code: OpsCode,
legacy: &'static str,
hint: Option<&'static str>,
}
macro_rules! row {
($tool:ident, $code:ident, $legacy:literal) => {
Row { tool: Tool::$tool, code: OpsCode::$code, legacy: $legacy, hint: None }
};
($tool:ident, $code:ident, $legacy:literal, $hint:literal) => {
Row { tool: Tool::$tool, code: OpsCode::$code, legacy: $legacy, hint: Some($hint) }
};
}
#[rustfmt::skip]
const TABLE: &[Row] = &[
row!(ConvertMd, PresetNotFound, "PRESET_NOT_FOUND", "Use hwpforge_templates to see available presets."),
row!(ConvertMd, MdDecodeFailed, "MD_DECODE_ERROR", "Check Markdown syntax. Use GFM (GitHub Flavored Markdown)."),
row!(ConvertMd, StyleStoreFailed, "STYLE_STORE_ERROR", "Check paragraph list references in the resolved style registry."),
row!(ConvertMd, StyleRebindFailed, "STYLE_REBIND_ERROR", "Document style indices do not match the generated HWPX style store."),
row!(ConvertMd, ValidationFailed, "VALIDATION_ERROR", "Check document structure."),
row!(ConvertMd, EncodeFailed, "ENCODE_ERROR", "This may be a bug. Please report at https://github.com/ai-screams/HwpForge/issues"),
row!(Inspect, DecodeFailed, "DECODE_ERROR", "Check that the file is a valid HWPX document."),
row!(Outline, DecodeFailed, "DECODE_ERROR", "Check that the file is valid HWPX. This server reads HWPX only — convert an HWP5 (.hwp) file first with the CLI: hwpforge convert-hwp5 <in.hwp> -o <out.hwpx>."),
row!(Read, DecodeFailed, "DECODE_ERROR", "Check that the file is valid HWPX. This server reads HWPX only — convert an HWP5 (.hwp) file first with the CLI: hwpforge convert-hwp5 <in.hwp> -o <out.hwpx>."),
row!(Read, ReadTargetRequired, "READ_TARGET_REQUIRED", "section reads a paragraph range; table reads a grid text matrix; field reads a named click-here field."),
row!(Read, ReadParasWithoutSection, "READ_PARAS_WITHOUT_SECTION", "Pass section together with paras."),
row!(Read, ReadParasInvalid, "READ_PARAS_INVALID", "Use \"A..B\" (inclusive) or a single \"N\"."),
row!(Read, ReadSectionOutOfRange, "READ_SECTION_OUT_OF_RANGE", "Use hwpforge_outline to see section indexes."),
row!(Read, ReadParaRangeInvalid, "READ_PARA_RANGE_INVALID", "Use hwpforge_outline to see paragraph counts per section."),
row!(Read, ReadTableOutOfRange, "READ_TABLE_OUT_OF_RANGE", "Use hwpforge_outline to see table ordinals."),
row!(Read, TableGridInvalid, "TABLE_GRID_INVALID", "This table's strict grid cannot be derived; use hwpforge_to_json."),
row!(Read, ReadFieldNotFound, "READ_FIELD_NOT_FOUND", "Use hwpforge_fields to list available field names."),
row!(Fields, DecodeFailed, "DECODE_ERROR", "Check that the file is valid HWPX. This server reads HWPX only — convert an HWP5 (.hwp) file first with the CLI: hwpforge convert-hwp5 <in.hwp> -o <out.hwpx>."),
row!(ToJson, DecodeFailed, "DECODE_ERROR", "Check that the file is a valid HWPX document."),
row!(ToJson, JsonSerializeFailed, "SERIALIZE_ERROR", "This may be a bug."),
row!(ToJson, GridAddrProjectionFailed, "GRID_ADDR_PROJECTION_FAILED", "This may be a bug."),
row!(ToJson, PatchFailed, "PATCH_ERROR", "Re-export the target section with the current hwpforge_to_json tool so preservation metadata is embedded. Structural/style changes still require a broader rebuild workflow."),
row!(FromJson, JsonParseFailed, "JSON_PARSE_ERROR", "Ensure JSON matches the ExportedDocument schema from hwpforge_to_json output."),
row!(FromJson, GridAddrInvalid, "GRID_ADDR_INVALID", "Grid addresses come from hwpforge_to_json output; after structural edits, drop the stale addr fields (or re-export) and retry."),
row!(FromJson, ValidationFailed, "VALIDATION_ERROR", "Check document structure."),
row!(FromJson, EncodeFailed, "ENCODE_ERROR", "This may be a bug. Please report at https://github.com/ai-screams/HwpForge/issues"),
row!(Patch, JsonParseFailed, "JSON_PARSE_ERROR", "Ensure the JSON matches the ExportedSection schema from hwpforge_to_json output."),
row!(Patch, GridAddrInvalid, "GRID_ADDR_INVALID", "Grid addresses come from hwpforge_to_json output; after structural edits, drop the stale addr fields (or re-export) and retry."),
row!(Patch, DecodeFailed, "DECODE_ERROR", "Check that the base file is valid HWPX."),
row!(Patch, PatchFailed, "PATCH_ERROR", "Re-export the target section with the current hwpforge_to_json tool so preservation metadata is embedded. Structural/style changes still require a broader rebuild workflow."),
row!(Diff, DecodeFailed, "DECODE_ERROR", "Both inputs must be valid HWPX. This server reads HWPX only — convert an HWP5 (.hwp) file first with the CLI: hwpforge convert-hwp5 <in.hwp> -o <out.hwpx>."),
row!(Fill, NoValues, "NO_VALUES", "Pass at least one name\u{2192}value pair. Use hwpforge_fields to discover names."),
row!(Fill, EmptyFieldValue, "EMPTY_FIELD_VALUE", "빈 값 채우기는 미지원 — 값을 지우려면 한컴에서 편집하세요."),
row!(Fill, FieldNameAmbiguous, "FIELD_NAME_AMBIGUOUS", "같은 이름의 누름틀이 여러 개라 대상이 모호합니다 — 문서에서 이름을 유일하게 하세요."),
row!(Fill, FieldNotFillable, "FIELD_NOT_FILLABLE", "병합-run 모호 필드 또는 빈 본문(patch 슬롯 없음)이라 이 필드는 채우기 표면이 아닙니다 — 한컴에서 열어 다시 저장한 뒤 재시도하세요. 서버 안에서 끝내려면 hwpforge_to_json(section 없이 전체 문서) → 편집 → hwpforge_from_json 재구성이 가능하지만 이미지 바이너리는 유지되지 않습니다 (이미지를 지키는 재구성은 CLI hwpforge from-json --base)."),
row!(Fill, FillFailed, "FILL_ERROR", "Check that the file is valid HWPX."),
row!(SetCell, InvalidSetCellMap, "INVALID_SET_CELL_MAP", "Pass at least one CellSpec: {table, at|right_of|below, text}."),
row!(SetCell, TableNotFound, "TABLE_NOT_FOUND", "표 서수는 hwpforge_to_json export 의 문서 순서 0-base 입니다."),
row!(SetCell, TableGridInvalid, "TABLE_GRID_INVALID", "이 표는 셀 span 이 well-formed 격자를 이루지 않아 주소 지정이 불가합니다."),
row!(SetCell, CellNotFound, "CELL_NOT_FOUND", "좌표는 병합 전 논리 격자 0-base — export 의 addr 값을 쓰세요."),
row!(SetCell, CellLabelAmbiguous, "CELL_LABEL_AMBIGUOUS", "라벨이 여러 셀과 일치합니다 — at 좌표로 직접 지정하세요."),
row!(SetCell, CellHasNonTextContent, "CELL_HAS_NON_TEXT_CONTENT", "표/이미지/컨트롤이 든 셀은 파괴 방지를 위해 교체를 거부합니다."),
row!(SetCell, CellTargetDuplicate, "CELL_TARGET_DUPLICATE", "두 편집이 같은 앵커 셀로 resolve 됐습니다."),
row!(SetCell, CellTargetConflict, "CELL_TARGET_CONFLICT", "바깥 셀 교체가 다른 편집이 노리는 중첩 표를 파괴합니다."),
row!(SetCell, InputNotRoundtripSafe, "INPUT_NOT_ROUNDTRIP_SAFE", "이 입력은 무손실 재인코드가 증명되지 않아 편집을 거부합니다 (fail-closed). 텍스트만 바꾸려면 hwpforge_to_json(section) → hwpforge_patch, 누름틀은 hwpforge_fill 을 쓰세요 — 둘 다 원본 ZIP 엔트리를 보존합니다."),
row!(SetCell, InputEntriesNotCarried, "INPUT_ENTRIES_NOT_CARRIED", "인코더가 carry 하지 않는 ZIP entry 가 있어 편집을 거부합니다 (fail-closed) — 한컴이 저장한 문서(Preview/*·META-INF/container.rdf)는 현재 이 도구의 대상이 아닙니다. 텍스트만 바꾸려면 hwpforge_to_json(section) → hwpforge_patch, 누름틀은 hwpforge_fill 을 쓰세요."),
row!(SetCell, SetCellCodecFailed, "SET_CELL_CODEC_FAILED", "Report this as a bug."),
row!(SetCell, UpstreamUnmapped, "SET_CELL_FAILED", "Report this as a bug."),
row!(InsertPara, InsertTextRequired, "INSERT_TEXT_REQUIRED", "Use `text` for a single paragraph or `texts` for a contiguous block."),
row!(DeletePara, DeleteNoTarget, "DELETE_NO_TARGET", "indices must be a non-empty list of top-level paragraph indices."),
row!(InsertPara, InputNotRoundtripSafe, "INPUT_NOT_ROUNDTRIP_SAFE", "Structural edits require a round-trip-safe input; this document has a codec fidelity gap. To change text instead, export the section with hwpforge_to_json and apply it with hwpforge_patch; to fill a click-here field, use hwpforge_fill — both preserve the original ZIP entries."),
row!(DeletePara, InputNotRoundtripSafe, "INPUT_NOT_ROUNDTRIP_SAFE", "Structural edits require a round-trip-safe input; this document has a codec fidelity gap. To change text instead, export the section with hwpforge_to_json and apply it with hwpforge_patch; to fill a click-here field, use hwpforge_fill — both preserve the original ZIP entries."),
row!(DeletePara, ReferenceStranded, "REFERENCE_STRANDED", "This paragraph carries a bookmark/cross-ref/footnote; deleting it could strand a reference."),
row!(DeletePara, HardBreakLoss, "HARD_BREAK_LOSS", "This paragraph carries a hard page/column break."),
row!(InsertPara, SectionPropertiesParagraph, "SECTION_PROPERTIES_PARAGRAPH", "The section's first paragraph holds page setup; it cannot be deleted or displaced."),
row!(DeletePara, SectionPropertiesParagraph, "SECTION_PROPERTIES_PARAGRAPH", "The section's first paragraph holds page setup; it cannot be deleted or displaced."),
row!(DeletePara, EmptySection, "EMPTY_SECTION", "A section must keep at least one paragraph."),
row!(InsertPara, MultiParagraphText, "MULTI_PARAGRAPH_TEXT", "Insert one paragraph per call; text may not contain line breaks."),
row!(InsertPara, ParagraphOutOfRange, "INDEX_OUT_OF_RANGE", "Use hwpforge_outline to see section and paragraph counts."),
row!(DeletePara, ParagraphOutOfRange, "INDEX_OUT_OF_RANGE", "Use hwpforge_outline to see section and paragraph counts."),
row!(InsertPara, SectionOutOfRange, "INDEX_OUT_OF_RANGE", "Use hwpforge_outline to see section and paragraph counts."),
row!(DeletePara, SectionOutOfRange, "INDEX_OUT_OF_RANGE", "Use hwpforge_outline to see section and paragraph counts."),
row!(DeletePara, DuplicateTarget, "DUPLICATE_TARGET", "Each paragraph index may appear once per batch."),
row!(InsertPara, SelfVerifyFailed, "SELF_VERIFY_FAILED", "The edit did not verify; no output was written."),
row!(DeletePara, SelfVerifyFailed, "SELF_VERIFY_FAILED", "The edit did not verify; no output was written."),
row!(InsertPara, StructuralCodec, "STRUCTURAL_CODEC", "Check that the file is valid HWPX."),
row!(DeletePara, StructuralCodec, "STRUCTURAL_CODEC", "Check that the file is valid HWPX."),
row!(InsertPara, InsertBeforeSectionProperties, "SECTION_PROPERTIES_PARAGRAPH", "The section's first paragraph holds page setup; it cannot be deleted or displaced."),
row!(InsertPara, SpanCountMismatch, "STRUCTURAL_EDIT_FAILED", "The structural edit was refused."),
row!(DeletePara, SpanCountMismatch, "STRUCTURAL_EDIT_FAILED", "The structural edit was refused."),
row!(InsertPara, InputEntriesNotCarried, "STRUCTURAL_EDIT_FAILED", "The structural edit was refused: the encoder does not carry every ZIP entry of this document, so a document saved by Hancom (Preview/*, META-INF/container.rdf) is out of scope for this tool today. To change text instead, export the section with hwpforge_to_json and apply it with hwpforge_patch; to fill a click-here field, use hwpforge_fill — both preserve the original ZIP entries."),
row!(DeletePara, InputEntriesNotCarried, "STRUCTURAL_EDIT_FAILED", "The structural edit was refused: the encoder does not carry every ZIP entry of this document, so a document saved by Hancom (Preview/*, META-INF/container.rdf) is out of scope for this tool today. To change text instead, export the section with hwpforge_to_json and apply it with hwpforge_patch; to fill a click-here field, use hwpforge_fill — both preserve the original ZIP entries."),
row!(InsertPara, UpstreamUnmapped, "STRUCTURAL_EDIT_FAILED", "The structural edit was refused."),
row!(DeletePara, UpstreamUnmapped, "STRUCTURAL_EDIT_FAILED", "The structural edit was refused."),
row!(StampPlan, StampCodecFailed, "STAMP_CODEC_FAILED", "Check that the file is valid HWPX."),
row!(Stamp, InputNotRoundtripSafe, "INPUT_NOT_ROUNDTRIP_SAFE", "이 입력은 무손실 재인코드가 증명되지 않아 거부됩니다 (fail-closed). 코덱 갭 수정 전까지 스탬핑 불가 — 텍스트만 바꾸려면 hwpforge_to_json(section) → hwpforge_patch, 누름틀은 hwpforge_fill 을 쓰세요 (둘 다 원본 ZIP 엔트리를 보존합니다)."),
row!(Stamp, InputEntriesNotCarried, "INPUT_ENTRIES_NOT_CARRIED", "재인코드 시 유실될 ZIP 엔트리가 있어 거부됩니다 (fail-closed) — 한컴이 저장한 문서(Preview/*·META-INF/container.rdf)는 현재 이 도구의 대상이 아닙니다. 텍스트만 바꾸려면 hwpforge_to_json(section) → hwpforge_patch, 누름틀은 hwpforge_fill 을 쓰세요."),
row!(Stamp, StampManifestInvariant, "STAMP_MANIFEST_INVARIANT", "Report this as a bug — the output inventory violated an invariant."),
row!(Stamp, StampCodecFailed, "STAMP_CODEC_FAILED", "Check that the file is valid HWPX."),
row!(Stamp, StampSourceHashMismatch, "STAMP_SOURCE_HASH_MISMATCH", "문서가 변경됐습니다 — hwpforge_stamp_plan 을 다시 실행해 source_sha256 을 갱신하세요."),
row!(Stamp, StampDeltaMismatch, "STAMP_DELTA_MISMATCH", "산출물 검증 실패 — 코덱 버그 가능성이 있어 무출력으로 거부했습니다."),
row!(Stamp, TableNotFound, "TABLE_NOT_FOUND", "hwpforge_stamp_plan 의 cells[].table 서수를 사용하세요."),
row!(Stamp, TableGridInvalid, "TABLE_GRID_INVALID", "이 표는 논리 격자를 만들 수 없어 셀 스탬핑 대상이 아닙니다."),
row!(Stamp, StampCellNotEmpty, "STAMP_CELL_NOT_EMPTY", "클래스-B 대상은 whitespace-only 빈 셀이어야 합니다."),
row!(Stamp, StampLabelDrift, "STAMP_LABEL_DRIFT", "문서가 변경됐습니다 — hwpforge_stamp_plan 을 다시 실행하세요."),
row!(Stamp, StampCellNotCandidate, "STAMP_CELL_NOT_CANDIDATE", "ignore 는 live 후보에만 가능합니다."),
row!(Stamp, StampCellTargetDuplicate, "STAMP_CELL_TARGET_DUPLICATE", "같은 셀을 두 번 분류했습니다."),
row!(Stamp, StampNameEmpty, "STAMP_NAME_EMPTY", "빈 이름은 허용되지 않습니다."),
row!(Stamp, StampHintBlank, "STAMP_HINT_BLANK", "빈 셀엔 마커가 없어 hint 가 필수입니다 — plan 의 suggested_hint 를 참고하세요."),
row!(Stamp, StampSpecStale, "STAMP_SPEC_STALE", "문서가 변경됐거나 span 이 어긋났습니다 — hwpforge_stamp_plan 을 다시 실행하세요."),
row!(Stamp, StampMarkerMismatch, "STAMP_MARKER_MISMATCH", "spec 의 marker 는 문서의 현재 텍스트와 일치해야 합니다."),
row!(Stamp, StampSpecDuplicate, "STAMP_SPEC_DUPLICATE", "같은 후보를 두 번 분류했습니다."),
row!(Stamp, UpstreamUnmapped, "STAMP_FAILED", "Unexpected failure."),
row!(Templates, PresetNotFound, "PRESET_NOT_FOUND", "Available presets: default, modern, classic, latest. Use hwpforge_templates without a name to list all."),
row!(Restyle, PresetNotFound, "PRESET_NOT_FOUND", "Use hwpforge_templates to see available presets."),
row!(Restyle, DecodeFailed, "DECODE_ERROR", "Check that the file is a valid HWPX document."),
row!(Restyle, NoFonts, "NO_FONTS", "The HWPX file may be malformed. Use hwpforge_validate to check."),
row!(Restyle, ValidationFailed, "VALIDATION_ERROR", "Check document structure."),
row!(Restyle, EncodeFailed, "ENCODE_ERROR", "This may be a bug. Please report at https://github.com/ai-screams/HwpForge/issues"),
row!(ToMd, DecodeFailed, "DECODE_ERROR", "Check that the file is a valid HWPX document."),
row!(ToMd, ValidationFailed, "VALIDATION_ERROR", "The HWPX document structure is invalid."),
];
#[must_use]
pub fn tool_error(tool: Tool, err: OpsError) -> ToolErrorInfo {
if let OpsError::EncodeSemanticLoss { warnings, .. } = &err {
if let Some(info) = semantic_loss_error(tool, warnings) {
return info;
}
}
if tool == Tool::Validate {
if let OpsError::Decode(_) = &err {
return ToolErrorInfo::new(
err.code().as_str(),
err.to_string(),
"Check that the file is valid HWPX. This server reads HWPX only — convert an HWP5 (.hwp) file first with the CLI: hwpforge convert-hwp5 <in.hwp> -o <out.hwpx>.",
);
}
}
match &err {
OpsError::Fill(FillError::UnknownField { available, .. }) => {
return ToolErrorInfo::new(
"FIELD_NOT_FOUND",
err.to_string(),
format!(
"Available fields: [{}]. Use hwpforge_fields to list them.",
available.join(", ")
),
);
}
OpsError::SectionWorkflow(SectionWorkflowError::SectionOutOfRange { sections, .. }) => {
return ToolErrorInfo::new(
"SECTION_OUT_OF_RANGE",
err.to_string(),
format!("Valid range: 0..={}", sections.saturating_sub(1)),
);
}
OpsError::SectionWorkflow(SectionWorkflowError::SectionIndexMismatch {
requested,
actual,
}) => {
return ToolErrorInfo::new(
"SECTION_INDEX_MISMATCH",
err.to_string(),
format!(
"Use section: {actual} to match the JSON, or re-export section {requested} with hwpforge_to_json."
),
);
}
OpsError::SectionWorkflow(SectionWorkflowError::Decode { .. })
| OpsError::SectionWorkflow(SectionWorkflowError::PreservingPatch(_)) => {}
OpsError::SectionWorkflow(_) => {
return ToolErrorInfo::new(
"SECTION_WORKFLOW_ERROR",
err.to_string(),
"Update hwpforge so this MCP binding understands the newer section workflow error.",
);
}
OpsError::Stamper(StamperError::CellStamp(CellStampError::NotAnAnchor {
anchor, ..
})) => {
let hint = match anchor {
Some(a) => {
format!("병합 피복 위치입니다 — anchor ({},{}) 를 지정하세요.", a.row, a.col)
}
None => "격자 범위 밖 좌표입니다.".to_string(),
};
return ToolErrorInfo::new("STAMP_CELL_NOT_ANCHOR", err.to_string(), hint);
}
OpsError::Stamper(StamperError::Stamp(StampError::DuplicateName { .. })) => {
return ToolErrorInfo::new(
"STAMP_NAME_DUPLICATE",
err.to_string(),
"필드 이름은 spec 전체에서 유일해야 합니다.",
);
}
OpsError::Stamper(StamperError::CellStamp(CellStampError::DuplicateName { .. })) => {
return ToolErrorInfo::new(
"STAMP_NAME_DUPLICATE",
err.to_string(),
"필드 이름은 text+cells 전체에서 유일해야 합니다.",
);
}
OpsError::Stamper(StamperError::Stamp(StampError::NameCollision { .. })) => {
return ToolErrorInfo::new(
"STAMP_NAME_COLLISION",
err.to_string(),
"기존 누름틀과 이름이 겹칩니다 — hwpforge_fields 로 기존 이름을 확인하세요.",
);
}
OpsError::Stamper(StamperError::CellStamp(CellStampError::NameCollision { .. })) => {
return ToolErrorInfo::new(
"STAMP_NAME_COLLISION",
err.to_string(),
"기존 누름틀과 이름이 겹칩니다 — hwpforge_fields 로 확인하세요.",
);
}
OpsError::Stamper(StamperError::Stamp(StampError::UncoveredCandidate { .. })) => {
return ToolErrorInfo::new(
"STAMP_CANDIDATE_UNCOVERED",
err.to_string(),
"모든 무가드 후보는 이름 또는 ignore 로 분류해야 합니다 — hwpforge_stamp_plan 출력을 빠짐없이 사용하세요.",
);
}
OpsError::Stamper(StamperError::CellStamp(CellStampError::UncoveredCandidate {
..
})) => {
return ToolErrorInfo::new(
"STAMP_CANDIDATE_UNCOVERED",
err.to_string(),
"모든 무가드 셀 후보는 이름 또는 ignore 로 분류해야 합니다.",
);
}
OpsError::Rejected { code: OpsCode::ReadTargetRequired, .. } => {
return ToolErrorInfo::new(
"READ_TARGET_REQUIRED",
READ_TARGET_REQUIRED_MESSAGE,
"section reads a paragraph range; table reads a grid text matrix; field reads a named click-here field.",
);
}
OpsError::Rejected { code: OpsCode::ReadParasWithoutSection, .. } => {
return ToolErrorInfo::new(
"READ_PARAS_WITHOUT_SECTION",
READ_PARAS_WITHOUT_SECTION_MESSAGE,
"Pass section together with paras.",
);
}
_ => {}
}
let code = err.code();
let message = err.to_string();
match TABLE.iter().find(|row| row.tool == tool && row.code == code) {
Some(row) => ToolErrorInfo::new(
row.legacy,
message,
row.hint.or_else(|| err.hint()).unwrap_or_default(),
),
None => ToolErrorInfo::new(code.as_str(), message, err.hint().unwrap_or_default()),
}
}
fn semantic_loss_error(
tool: Tool,
warnings: &[hwpforge_foundation::diagnostics::WarningInfo],
) -> Option<ToolErrorInfo> {
match tool {
Tool::SetCell => {
let message = match warnings.first() {
Some(w) => format!(
"codec failure: encode produced a semantic-loss warning (fail-closed): {}",
w.message
),
None => "codec failure: encode produced a semantic-loss warning (fail-closed)"
.to_string(),
};
Some(ToolErrorInfo::new("SET_CELL_CODEC_FAILED", message, "Report this as a bug."))
}
Tool::Stamp => {
let message = match warnings.first() {
Some(w) => {
format!("encode produced a semantic-loss warning (fail-closed): {}", w.message)
}
None => "encode produced a semantic-loss warning (fail-closed)".to_string(),
};
Some(ToolErrorInfo::new(
"STAMP_CODEC_FAILED",
message,
"Check that the file is valid HWPX.",
))
}
Tool::Restyle => {
let message =
warnings.iter().map(|w| w.message.as_str()).collect::<Vec<_>>().join("; ");
Some(ToolErrorInfo::new(
"ENCODE_SEMANTIC_LOSS",
message,
"The restyled document would lose footnote/endnote numbering or TOC marks; fix the \
source document or restyle a document without those constructs.",
))
}
_ => None,
}
}
#[must_use]
pub fn warning(w: &OpsWarning) -> ToolWarningInfo {
w.info().into()
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use hwpforge_core::table::grid::GridCoord;
use hwpforge_smithy_hwpx::grid_addr::GridAddrError;
use hwpforge_smithy_hwpx::stamp::StampMapError;
use hwpforge_smithy_hwpx::{CellEditError, HwpxError, ReadError, StructuralEditError};
use super::*;
const SNAPSHOT: &str =
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/legacy_codes.txt"));
const SNAPSHOT_NEW: &str =
include_str!(concat!(env!("CARGO_MANIFEST_DIR"), "/tests/data/new_codes.txt"));
fn snapshot_pairs() -> BTreeSet<(&'static str, &'static str)> {
[SNAPSHOT, SNAPSHOT_NEW]
.into_iter()
.flat_map(str::lines)
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(|line| {
let mut parts = line.split('\t');
let tool = parts.next().expect("tool column");
let code = parts.next().expect("code column");
assert!(parts.next().is_none(), "unexpected third column: {line:?}");
(tool, code)
})
.collect()
}
#[test]
fn legacy_and_new_snapshots_stay_disjoint() {
fn pairs(text: &str) -> BTreeSet<(&str, &str)> {
text.lines()
.map(str::trim)
.filter(|line| !line.is_empty() && !line.starts_with('#'))
.map(|line| {
let mut parts = line.split('\t');
(parts.next().expect("tool column"), parts.next().expect("code column"))
})
.collect()
}
let legacy = pairs(SNAPSHOT);
let new = pairs(SNAPSHOT_NEW);
for pair in &new {
assert!(
!legacy.contains(pair),
"{pair:?} is audited in both legacy_codes.txt and new_codes.txt"
);
}
}
fn tool_name(tool: Tool) -> &'static str {
match tool {
Tool::ConvertMd => "convert",
Tool::Inspect => "inspect",
Tool::Outline => "outline",
Tool::Read => "read",
Tool::Fields => "fields",
Tool::ToJson => "to_json",
Tool::FromJson => "from_json",
Tool::Patch => "patch",
Tool::Diff => "diff",
Tool::Fill => "fill",
Tool::SetCell => "set_cell",
Tool::InsertPara => "insert_para",
Tool::DeletePara => "delete_para",
Tool::StampPlan => "stamp_plan",
Tool::Stamp => "stamp",
Tool::Templates => "templates",
Tool::Restyle => "restyle",
Tool::Validate => "validate",
Tool::ToMd => "to_md",
}
}
#[test]
fn every_table_row_is_in_the_audited_snapshot() {
let snapshot = snapshot_pairs();
for row in TABLE {
let name = tool_name(row.tool);
assert!(
snapshot.contains(&(name, row.legacy)),
"TABLE has ({name}, {}) but tests/data/legacy_codes.txt does not \u{2014} \
either the row is wrong or the snapshot needs re-auditing",
row.legacy
);
}
}
const SUPERSEDED_LEGACY_HINTS: &[(Tool, OpsCode, &str, &str)] = &[
(
Tool::Outline,
OpsCode::DecodeFailed,
"Check that the file is valid HWPX. For .hwp files, convert with hwpforge_convert first.",
"Check that the file is valid HWPX. This server reads HWPX only — convert an HWP5 (.hwp) file first with the CLI: hwpforge convert-hwp5 <in.hwp> -o <out.hwpx>.",
),
(
Tool::Read,
OpsCode::DecodeFailed,
"Check that the file is valid HWPX. For .hwp files, convert with hwpforge_convert first.",
"Check that the file is valid HWPX. This server reads HWPX only — convert an HWP5 (.hwp) file first with the CLI: hwpforge convert-hwp5 <in.hwp> -o <out.hwpx>.",
),
(
Tool::Fields,
OpsCode::DecodeFailed,
"Check that the file is valid HWPX. For .hwp files, convert with hwpforge_convert first.",
"Check that the file is valid HWPX. This server reads HWPX only — convert an HWP5 (.hwp) file first with the CLI: hwpforge convert-hwp5 <in.hwp> -o <out.hwpx>.",
),
(
Tool::Diff,
OpsCode::DecodeFailed,
"Both inputs must be valid HWPX. For .hwp files, convert with hwpforge_convert first.",
"Both inputs must be valid HWPX. This server reads HWPX only — convert an HWP5 (.hwp) file first with the CLI: hwpforge convert-hwp5 <in.hwp> -o <out.hwpx>.",
),
(
Tool::Validate,
OpsCode::DecodeFailed,
"Check that the file is valid HWPX. For .hwp files, convert with hwpforge_convert first.",
"Check that the file is valid HWPX. This server reads HWPX only — convert an HWP5 (.hwp) file first with the CLI: hwpforge convert-hwp5 <in.hwp> -o <out.hwpx>.",
),
(
Tool::Fill,
OpsCode::FieldNotFillable,
"병합-run 모호 필드 또는 빈 본문 — 한컴 재저장 또는 from-json --base 재생성이 필요합니다.",
"병합-run 모호 필드 또는 빈 본문(patch 슬롯 없음)이라 이 필드는 채우기 표면이 아닙니다 — 한컴에서 열어 다시 저장한 뒤 재시도하세요. 서버 안에서 끝내려면 hwpforge_to_json(section 없이 전체 문서) → 편집 → hwpforge_from_json 재구성이 가능하지만 이미지 바이너리는 유지되지 않습니다 (이미지를 지키는 재구성은 CLI hwpforge from-json --base).",
),
(
Tool::SetCell,
OpsCode::InputNotRoundtripSafe,
"이 입력은 무손실 재인코드가 증명되지 않아 편집을 거부합니다 (fail-closed).",
"이 입력은 무손실 재인코드가 증명되지 않아 편집을 거부합니다 (fail-closed). 텍스트만 바꾸려면 hwpforge_to_json(section) → hwpforge_patch, 누름틀은 hwpforge_fill 을 쓰세요 — 둘 다 원본 ZIP 엔트리를 보존합니다.",
),
(
Tool::SetCell,
OpsCode::InputEntriesNotCarried,
"인코더가 carry 하지 않는 ZIP entry 가 있어 편집을 거부합니다 (fail-closed).",
"인코더가 carry 하지 않는 ZIP entry 가 있어 편집을 거부합니다 (fail-closed) — 한컴이 저장한 문서(Preview/*·META-INF/container.rdf)는 현재 이 도구의 대상이 아닙니다. 텍스트만 바꾸려면 hwpforge_to_json(section) → hwpforge_patch, 누름틀은 hwpforge_fill 을 쓰세요.",
),
(
Tool::InsertPara,
OpsCode::InputNotRoundtripSafe,
"Structural edits require a round-trip-safe input; this document has a codec fidelity gap.",
"Structural edits require a round-trip-safe input; this document has a codec fidelity gap. To change text instead, export the section with hwpforge_to_json and apply it with hwpforge_patch; to fill a click-here field, use hwpforge_fill — both preserve the original ZIP entries.",
),
(
Tool::DeletePara,
OpsCode::InputNotRoundtripSafe,
"Structural edits require a round-trip-safe input; this document has a codec fidelity gap.",
"Structural edits require a round-trip-safe input; this document has a codec fidelity gap. To change text instead, export the section with hwpforge_to_json and apply it with hwpforge_patch; to fill a click-here field, use hwpforge_fill — both preserve the original ZIP entries.",
),
(
Tool::InsertPara,
OpsCode::InputEntriesNotCarried,
"The structural edit was refused.",
"The structural edit was refused: the encoder does not carry every ZIP entry of this document, so a document saved by Hancom (Preview/*, META-INF/container.rdf) is out of scope for this tool today. To change text instead, export the section with hwpforge_to_json and apply it with hwpforge_patch; to fill a click-here field, use hwpforge_fill — both preserve the original ZIP entries.",
),
(
Tool::DeletePara,
OpsCode::InputEntriesNotCarried,
"The structural edit was refused.",
"The structural edit was refused: the encoder does not carry every ZIP entry of this document, so a document saved by Hancom (Preview/*, META-INF/container.rdf) is out of scope for this tool today. To change text instead, export the section with hwpforge_to_json and apply it with hwpforge_patch; to fill a click-here field, use hwpforge_fill — both preserve the original ZIP entries.",
),
(
Tool::Stamp,
OpsCode::InputNotRoundtripSafe,
"이 입력은 무손실 재인코드가 증명되지 않아 거부됩니다 (fail-closed). 코덱 갭 수정 전까지 스탬핑 불가.",
"이 입력은 무손실 재인코드가 증명되지 않아 거부됩니다 (fail-closed). 코덱 갭 수정 전까지 스탬핑 불가 — 텍스트만 바꾸려면 hwpforge_to_json(section) → hwpforge_patch, 누름틀은 hwpforge_fill 을 쓰세요 (둘 다 원본 ZIP 엔트리를 보존합니다).",
),
(
Tool::Stamp,
OpsCode::InputEntriesNotCarried,
"재인코드 시 유실될 ZIP 엔트리가 있어 거부됩니다 (fail-closed).",
"재인코드 시 유실될 ZIP 엔트리가 있어 거부됩니다 (fail-closed) — 한컴이 저장한 문서(Preview/*·META-INF/container.rdf)는 현재 이 도구의 대상이 아닙니다. 텍스트만 바꾸려면 hwpforge_to_json(section) → hwpforge_patch, 누름틀은 hwpforge_fill 을 쓰세요.",
),
];
#[test]
fn superseded_hints_match_the_recorded_replacement() {
for &(tool, code, old, new) in SUPERSEDED_LEGACY_HINTS {
let current = match TABLE.iter().find(|r| r.tool == tool && r.code == code) {
Some(row) => row
.hint
.unwrap_or_else(|| {
panic!(
"{tool:?}/{code:?} is listed as superseded but its TABLE row asks \
`ops` for the hint — list `ops::hint_for`'s wording there instead"
)
})
.to_string(),
None => {
assert!(
tool == Tool::Validate && code == OpsCode::DecodeFailed,
"{tool:?}/{code:?} is listed as superseded but nothing produces it any \
more — drop the entry instead of leaving a dead exemption"
);
tool_error(tool, OpsError::decode(HwpxError::Zip("not a zip file".into()))).hint
}
};
assert_ne!(
old, new,
"{tool:?}/{code:?} records the same text as both old and new wording"
);
assert_eq!(
current, new,
"{tool:?}/{code:?} does not emit the wording this list records as its \
replacement — update the entry in the same commit as the hint"
);
}
}
const DYNAMIC: &[(&str, &str)] = &[
("fill", "FIELD_NOT_FOUND"),
("to_json", "SECTION_OUT_OF_RANGE"),
("to_json", "SECTION_INDEX_MISMATCH"),
("patch", "SECTION_OUT_OF_RANGE"),
("patch", "SECTION_INDEX_MISMATCH"),
("stamp", "STAMP_CELL_NOT_ANCHOR"),
("stamp", "STAMP_NAME_DUPLICATE"),
("stamp", "STAMP_NAME_COLLISION"),
("stamp", "STAMP_CANDIDATE_UNCOVERED"),
("set_cell", "SET_CELL_CODEC_FAILED"),
("stamp", "STAMP_CODEC_FAILED"),
("restyle", "ENCODE_SEMANTIC_LOSS"),
("read", "READ_TARGET_REQUIRED"),
("read", "READ_PARAS_WITHOUT_SECTION"),
("to_json", "SECTION_WORKFLOW_ERROR"),
("patch", "SECTION_WORKFLOW_ERROR"),
("validate", "DECODE_FAILED"),
];
#[test]
fn every_snapshot_row_is_mcp_local_or_in_the_table() {
const MCP_LOCAL: &[(&str, &str)] = &[
("output", "FILE_NOT_FOUND"),
("output", "INPUT_TOO_LARGE"),
("output", "METADATA_ERROR"),
("output", "READ_ERROR"),
("output", "WRITE_ERROR"),
("convert", "INPUT_TOO_LARGE"),
("convert", "INVALID_EXTENSION"),
("diff", "FILE_WRITE_FAILED"),
("diff", "OUTPUT_TOO_LARGE"),
("diff", "SERIALIZE_ERROR"),
("fill", "INVALID_EXTENSION"),
("from_json", "INPUT_TOO_LARGE"),
("from_json", "INVALID_EXTENSION"),
("insert_para", "FILE_WRITE_FAILED"),
("delete_para", "FILE_WRITE_FAILED"),
("outline", "OUTPUT_TOO_LARGE"),
("patch", "INVALID_EXTENSION"),
("restyle", "INVALID_EXTENSION"),
("set_cell", "INVALID_EXTENSION"),
("stamp", "INVALID_EXTENSION"),
("stamp", "MANIFEST_PATH_CONFLICT"),
("stamp", "MISSING_SOURCE_SHA256"),
("stamp", "STAMP_MANIFEST_SERIALIZE"),
("stamp", "WRITE_ERROR"),
("to_json", "INVALID_EXTENSION"),
("to_json", "OUTPUT_TOO_LARGE"),
("to_md", "DIR_CREATE_ERROR"),
("to_md", "INVALID_INPUT"),
];
const RETIRED_BY_MIGRATION: &[(&str, &str)] = &[
("from_json", "INTERNAL_ERROR"),
("convert", "PRESET_ERROR"),
];
let mut covered: BTreeSet<(&str, &str)> =
TABLE.iter().map(|row| (tool_name(row.tool), row.legacy)).collect();
covered.extend(DYNAMIC.iter().copied());
let mcp_local: BTreeSet<(&str, &str)> = MCP_LOCAL.iter().copied().collect();
let retired: BTreeSet<(&str, &str)> = RETIRED_BY_MIGRATION.iter().copied().collect();
for pair in snapshot_pairs() {
assert!(
covered.contains(&pair) || mcp_local.contains(&pair) || retired.contains(&pair),
"{pair:?} is in the snapshot but neither TABLE/DYNAMIC, MCP_LOCAL, nor \
RETIRED_BY_MIGRATION accounts for it"
);
}
for pair in &mcp_local {
assert!(!covered.contains(pair), "{pair:?} is both MCP_LOCAL and in TABLE");
}
for pair in &retired {
assert!(!covered.contains(pair), "{pair:?} is both RETIRED_BY_MIGRATION and in TABLE");
assert!(
!mcp_local.contains(pair),
"{pair:?} is both RETIRED_BY_MIGRATION and MCP_LOCAL"
);
}
}
fn expected_ops_originated(tool: Tool) -> &'static [OpsCode] {
use OpsCode::*;
match tool {
Tool::ConvertMd => &[
PresetNotFound,
MdDecodeFailed,
StyleStoreFailed,
StyleRebindFailed,
ValidationFailed,
EncodeFailed,
],
Tool::Inspect | Tool::Fields => &[DecodeFailed],
Tool::Outline => &[DecodeFailed],
Tool::Read => &[
DecodeFailed,
ReadTargetRequired,
ReadParasWithoutSection,
ReadParasInvalid,
ReadSectionOutOfRange,
ReadParaRangeInvalid,
ReadTableOutOfRange,
TableGridInvalid,
ReadFieldNotFound,
],
Tool::ToJson => &[
DecodeFailed,
JsonSerializeFailed,
GridAddrProjectionFailed,
SectionOutOfRange,
SectionIndexMismatch,
PatchFailed,
UpstreamUnmapped,
],
Tool::FromJson => &[JsonParseFailed, GridAddrInvalid, ValidationFailed, EncodeFailed],
Tool::Patch => &[
JsonParseFailed,
GridAddrInvalid,
DecodeFailed,
SectionOutOfRange,
SectionIndexMismatch,
PatchFailed,
UpstreamUnmapped,
],
Tool::Diff => &[DecodeFailed],
Tool::Fill => &[
NoValues,
EmptyFieldValue,
FieldNotFound,
FieldNameAmbiguous,
FieldNotFillable,
FillFailed,
],
Tool::SetCell => &[
InvalidSetCellMap,
TableNotFound,
TableGridInvalid,
CellNotFound,
CellLabelAmbiguous,
CellHasNonTextContent,
CellTargetDuplicate,
CellTargetConflict,
InputNotRoundtripSafe,
InputEntriesNotCarried,
SetCellCodecFailed,
EncodeSemanticLoss,
UpstreamUnmapped,
],
Tool::InsertPara => &[
InsertTextRequired,
InputNotRoundtripSafe,
InputEntriesNotCarried,
SectionPropertiesParagraph,
MultiParagraphText,
ParagraphOutOfRange,
SectionOutOfRange,
SelfVerifyFailed,
StructuralCodec,
SpanCountMismatch,
InsertBeforeSectionProperties,
UpstreamUnmapped,
],
Tool::DeletePara => &[
DeleteNoTarget,
InputNotRoundtripSafe,
InputEntriesNotCarried,
ReferenceStranded,
HardBreakLoss,
SectionPropertiesParagraph,
EmptySection,
ParagraphOutOfRange,
SectionOutOfRange,
DuplicateTarget,
SelfVerifyFailed,
StructuralCodec,
SpanCountMismatch,
UpstreamUnmapped,
],
Tool::StampPlan => &[StampCodecFailed],
Tool::Stamp => &[
InputNotRoundtripSafe,
InputEntriesNotCarried,
StampManifestInvariant,
StampCodecFailed,
StampSourceHashMismatch,
StampDeltaMismatch,
TableNotFound,
TableGridInvalid,
StampCellNotAnchor,
StampCellNotEmpty,
StampLabelDrift,
StampCellNotCandidate,
StampCellTargetDuplicate,
StampNameEmpty,
StampHintBlank,
StampSpecStale,
StampMarkerMismatch,
StampSpecDuplicate,
StampNameDuplicate,
StampNameCollision,
StampCandidateUncovered,
EncodeSemanticLoss,
UpstreamUnmapped,
],
Tool::Templates => &[PresetNotFound],
Tool::Restyle => &[
PresetNotFound,
DecodeFailed,
NoFonts,
ValidationFailed,
EncodeFailed,
EncodeSemanticLoss,
],
Tool::Validate => &[DecodeFailed],
Tool::ToMd => &[DecodeFailed, ValidationFailed],
}
}
#[test]
fn every_expected_code_is_covered() {
const DYNAMIC: &[(Tool, OpsCode)] = &[
(Tool::Validate, OpsCode::DecodeFailed),
(Tool::Fill, OpsCode::FieldNotFound),
(Tool::ToJson, OpsCode::SectionOutOfRange),
(Tool::ToJson, OpsCode::SectionIndexMismatch),
(Tool::Patch, OpsCode::SectionOutOfRange),
(Tool::Patch, OpsCode::SectionIndexMismatch),
(Tool::Stamp, OpsCode::StampCellNotAnchor),
(Tool::Stamp, OpsCode::StampNameDuplicate),
(Tool::Stamp, OpsCode::StampNameCollision),
(Tool::Stamp, OpsCode::StampCandidateUncovered),
(Tool::SetCell, OpsCode::EncodeSemanticLoss),
(Tool::Stamp, OpsCode::EncodeSemanticLoss),
(Tool::Restyle, OpsCode::EncodeSemanticLoss),
(Tool::ToJson, OpsCode::UpstreamUnmapped),
(Tool::Patch, OpsCode::UpstreamUnmapped),
];
for &tool in &[
Tool::ConvertMd,
Tool::Inspect,
Tool::Outline,
Tool::Read,
Tool::Fields,
Tool::ToJson,
Tool::FromJson,
Tool::Patch,
Tool::Diff,
Tool::Fill,
Tool::SetCell,
Tool::InsertPara,
Tool::DeletePara,
Tool::StampPlan,
Tool::Stamp,
Tool::Templates,
Tool::Restyle,
Tool::Validate,
Tool::ToMd,
] {
for &code in expected_ops_originated(tool) {
let has_row = TABLE.iter().any(|r| r.tool == tool && r.code == code);
let is_dynamic = DYNAMIC.contains(&(tool, code));
assert!(
has_row || is_dynamic,
"{tool:?} is documented to reach {code:?} but TABLE has no row and no \
dynamic arm covers it"
);
}
}
}
fn assert_code(tool: Tool, err: OpsError, expected: &str) {
let got = tool_error(tool, err);
assert_eq!(got.code, expected, "{tool:?}: {got:?}");
}
#[test]
fn validate_decode_failure_matches_the_new_codes_snapshot() {
let err = OpsError::decode(HwpxError::Zip("not a zip file".into()));
let got = tool_error(Tool::Validate, err);
assert_eq!(got.code, "DECODE_FAILED");
let shared = TABLE
.iter()
.find(|r| r.tool == Tool::Outline && r.code == OpsCode::DecodeFailed)
.and_then(|r| r.hint)
.expect("outline's DecodeFailed row carries the shared decode hint");
assert_eq!(got.hint, shared);
assert!(got.hint.contains("hwpforge convert-hwp5"), "{got:?}");
}
#[test]
fn fill_errors_round_trip() {
assert_code(
Tool::Fill,
OpsError::Rejected { code: OpsCode::NoValues, reason: "no field values given".into() },
"NO_VALUES",
);
assert_code(
Tool::Fill,
OpsError::Fill(FillError::EmptyValue { name: "x".into() }),
"EMPTY_FIELD_VALUE",
);
let err = OpsError::Fill(FillError::UnknownField {
name: "x".into(),
available: vec!["a".into(), "b".into()],
});
let info = tool_error(Tool::Fill, err);
assert_eq!(info.code, "FIELD_NOT_FOUND");
assert!(info.hint.contains("[a, b]"), "{info:?}");
assert_code(
Tool::Fill,
OpsError::Fill(FillError::DuplicateFieldName { name: "x".into(), count: 2 }),
"FIELD_NAME_AMBIGUOUS",
);
assert_code(
Tool::Fill,
OpsError::Fill(FillError::UnfillableField { name: "x".into(), section: 0 }),
"FIELD_NOT_FILLABLE",
);
}
#[test]
fn read_shape_rejections_keep_the_mcp_wording_whoever_produced_them() {
let from_ops = OpsError::Rejected {
code: OpsCode::ReadTargetRequired,
reason: "a producer's own wording for the target rule".into(),
};
assert_eq!(tool_error(Tool::Read, from_ops).message, READ_TARGET_REQUIRED_MESSAGE);
let from_ops = OpsError::Rejected {
code: OpsCode::ReadParasWithoutSection,
reason: "a producer's own wording for the paras rule".into(),
};
assert_eq!(tool_error(Tool::Read, from_ops).message, READ_PARAS_WITHOUT_SECTION_MESSAGE);
}
#[test]
fn read_errors_round_trip() {
assert_code(
Tool::Read,
OpsError::Rejected { code: OpsCode::ReadTargetRequired, reason: "x".into() },
"READ_TARGET_REQUIRED",
);
assert_code(
Tool::Read,
OpsError::Rejected { code: OpsCode::ReadParasWithoutSection, reason: "x".into() },
"READ_PARAS_WITHOUT_SECTION",
);
assert_code(
Tool::Read,
OpsError::Rejected { code: OpsCode::ReadParasInvalid, reason: "x".into() },
"READ_PARAS_INVALID",
);
assert_code(
Tool::Read,
OpsError::Read(ReadError::SectionOutOfRange { requested: 9, available: 1 }),
"READ_SECTION_OUT_OF_RANGE",
);
assert_code(
Tool::Read,
OpsError::Read(ReadError::FieldNotFound { name: "x".into(), available: vec![] }),
"READ_FIELD_NOT_FOUND",
);
}
#[test]
fn section_workflow_errors_round_trip_for_patch_and_to_json() {
for tool in [Tool::Patch, Tool::ToJson] {
let err = OpsError::SectionWorkflow(SectionWorkflowError::SectionOutOfRange {
requested: 5,
sections: 2,
});
let info = tool_error(tool, err);
assert_eq!(info.code, "SECTION_OUT_OF_RANGE");
assert_eq!(info.hint, "Valid range: 0..=1");
let err = OpsError::SectionWorkflow(SectionWorkflowError::SectionIndexMismatch {
requested: 0,
actual: 1,
});
let info = tool_error(tool, err);
assert_eq!(info.code, "SECTION_INDEX_MISMATCH");
assert!(info.message.contains("Requested section 0 but JSON contains section 1 data"));
assert!(info.hint.contains("Use section: 1"));
assert_code(
tool,
OpsError::SectionWorkflow(SectionWorkflowError::PreservingPatch(
HwpxError::InvalidStructure { detail: "preservation metadata version".into() },
)),
"PATCH_ERROR",
);
assert_code(
tool,
OpsError::SectionWorkflow(SectionWorkflowError::Decode { detail: "x".into() }),
"DECODE_ERROR",
);
}
for err in [
OpsError::SectionWorkflow(SectionWorkflowError::Decode { detail: "x".into() }),
OpsError::SectionWorkflow(SectionWorkflowError::SectionOutOfRange {
requested: 1,
sections: 1,
}),
OpsError::SectionWorkflow(SectionWorkflowError::SectionIndexMismatch {
requested: 0,
actual: 1,
}),
OpsError::SectionWorkflow(SectionWorkflowError::PreservingPatch(
HwpxError::InvalidStructure { detail: "x".into() },
)),
] {
assert_ne!(err.code(), OpsCode::UpstreamUnmapped, "{err}");
}
}
#[test]
fn structural_errors_route_shared_codes_to_index_out_of_range() {
for (tool, err) in [
(
Tool::InsertPara,
OpsError::StructuralEdit(StructuralEditError::SectionOutOfRange {
section: 9,
available: 1,
}),
),
(
Tool::DeletePara,
OpsError::StructuralEdit(StructuralEditError::ParagraphOutOfRange {
section: 0,
index: 9,
available: 1,
}),
),
] {
assert_code(tool, err, "INDEX_OUT_OF_RANGE");
}
assert_code(
Tool::DeletePara,
OpsError::StructuralEdit(StructuralEditError::DuplicateTarget { section: 0, index: 1 }),
"DUPLICATE_TARGET",
);
assert_code(
Tool::InsertPara,
OpsError::Rejected {
code: OpsCode::InsertTextRequired,
reason: "insert_para needs at least one paragraph text".into(),
},
"INSERT_TEXT_REQUIRED",
);
}
#[test]
fn insert_before_section_properties_matches_the_legacy_combined_arm() {
let info = tool_error(
Tool::InsertPara,
OpsError::StructuralEdit(StructuralEditError::InsertBeforeSectionProperties {
section: 0,
index: 0,
}),
);
assert_eq!(info.code, "SECTION_PROPERTIES_PARAGRAPH");
assert_eq!(
info.hint,
"The section's first paragraph holds page setup; it cannot be deleted or displaced."
);
}
#[test]
fn structural_uncarried_zip_entries_keeps_the_legacy_wildcard_code() {
for tool in [Tool::InsertPara, Tool::DeletePara] {
let info = tool_error(
tool,
OpsError::StructuralEdit(StructuralEditError::UncarriedZipEntries {
entries: vec!["Contents/section0.xml".into()],
}),
);
assert_eq!(info.code, "STRUCTURAL_EDIT_FAILED", "{tool:?}");
assert!(info.hint.contains("hwpforge_patch"), "{tool:?}: {info:?}");
assert!(info.hint.contains("hwpforge_fill"), "{tool:?}: {info:?}");
let generic = tool_error(
tool,
OpsError::StructuralEdit(StructuralEditError::SpanCountMismatch {
section: 0,
decoded: 2,
wire: 1,
}),
);
assert_eq!(generic.code, "STRUCTURAL_EDIT_FAILED", "{tool:?}");
assert_eq!(generic.hint, "The structural edit was refused.", "{tool:?}");
}
}
#[test]
fn set_cell_and_stamp_semantic_loss_reproduce_the_legacy_codec_message() {
use hwpforge_smithy_hwpx::{EncodeWarning, ParagraphPath, PathSeg};
let note_head = EncodeWarning::NoteHeadSkipped {
path: ParagraphPath(vec![PathSeg::Section(0), PathSeg::BodyParagraph(1)]),
reason: "titleMark first run".into(),
};
let title_mark = EncodeWarning::TitleMarkSkipped {
path: ParagraphPath(vec![PathSeg::Section(0), PathSeg::BodyParagraph(1)]),
reason: "second reason".into(),
};
let cache_dropped = EncodeWarning::LayoutCacheDropped {
path: ParagraphPath(vec![PathSeg::Section(0)]),
reason: "ledger".into(),
};
let clone_err = |e: &OpsError| {
let OpsError::EncodeSemanticLoss { warnings, others } = e else { unreachable!() };
OpsError::EncodeSemanticLoss { warnings: warnings.clone(), others: others.clone() }
};
struct Case {
name: &'static str,
warnings: Vec<EncodeWarning>,
others: Vec<EncodeWarning>,
set_cell: &'static str,
stamp: &'static str,
restyle: &'static str,
}
let cases = [
Case {
name: "empty warnings",
warnings: vec![],
others: vec![],
set_cell: "codec failure: encode produced a semantic-loss warning (fail-closed)",
stamp: "encode produced a semantic-loss warning (fail-closed)",
restyle: "",
},
Case {
name: "single warning",
warnings: vec![note_head.clone()],
others: vec![],
set_cell: "codec failure: encode produced a semantic-loss warning (fail-closed): \
note number head skipped at section[0].para[1]: titleMark first run",
stamp: "encode produced a semantic-loss warning (fail-closed): note number head \
skipped at section[0].para[1]: titleMark first run",
restyle: "note number head skipped at section[0].para[1]: titleMark first run",
},
Case {
name: "multiple warnings — set_cell/stamp use only the first",
warnings: vec![note_head.clone(), title_mark.clone()],
others: vec![],
set_cell: "codec failure: encode produced a semantic-loss warning (fail-closed): \
note number head skipped at section[0].para[1]: titleMark first run",
stamp: "encode produced a semantic-loss warning (fail-closed): note number head \
skipped at section[0].para[1]: titleMark first run",
restyle: "note number head skipped at section[0].para[1]: titleMark first run; \
titleMark skipped at section[0].para[1]: second reason",
},
Case {
name: "non-empty others is ignored",
warnings: vec![note_head.clone()],
others: vec![cache_dropped.clone()],
set_cell: "codec failure: encode produced a semantic-loss warning (fail-closed): \
note number head skipped at section[0].para[1]: titleMark first run",
stamp: "encode produced a semantic-loss warning (fail-closed): note number head \
skipped at section[0].para[1]: titleMark first run",
restyle: "note number head skipped at section[0].para[1]: titleMark first run",
},
];
for case in cases {
let err = OpsError::semantic_loss(case.warnings, case.others);
let set_cell = tool_error(Tool::SetCell, clone_err(&err));
assert_eq!(set_cell.code, "SET_CELL_CODEC_FAILED", "{}", case.name);
assert_eq!(set_cell.message, case.set_cell, "{}", case.name);
assert_eq!(set_cell.hint, "Report this as a bug.", "{}", case.name);
let stamp = tool_error(Tool::Stamp, clone_err(&err));
assert_eq!(stamp.code, "STAMP_CODEC_FAILED", "{}", case.name);
assert_eq!(stamp.message, case.stamp, "{}", case.name);
assert_eq!(stamp.hint, "Check that the file is valid HWPX.", "{}", case.name);
let restyle = tool_error(Tool::Restyle, err);
assert_eq!(restyle.code, "ENCODE_SEMANTIC_LOSS", "{}", case.name);
assert_eq!(restyle.message, case.restyle, "{}", case.name);
}
}
#[test]
fn stamp_cell_not_anchor_reconstructs_the_dynamic_hint() {
let err = OpsError::Stamper(StamperError::CellStamp(CellStampError::NotAnAnchor {
table: 0,
requested: GridCoord::new(0, 1),
anchor: Some(GridCoord::new(0, 0)),
}));
let info = tool_error(Tool::Stamp, err);
assert_eq!(info.code, "STAMP_CELL_NOT_ANCHOR");
assert!(info.hint.contains("(0,0)"), "{info:?}");
let err = OpsError::Stamper(StamperError::CellStamp(CellStampError::NotAnAnchor {
table: 0,
requested: GridCoord::new(9, 9),
anchor: None,
}));
let info = tool_error(Tool::Stamp, err);
assert!(info.hint.contains("격자 범위"), "{info:?}");
}
#[test]
fn stamp_dual_source_name_codes_pick_the_matching_wording() {
let text = tool_error(
Tool::Stamp,
OpsError::Stamper(StamperError::Stamp(StampError::DuplicateName { name: "x".into() })),
);
let cell = tool_error(
Tool::Stamp,
OpsError::Stamper(StamperError::CellStamp(CellStampError::DuplicateName {
name: "x".into(),
})),
);
assert_eq!(text.code, "STAMP_NAME_DUPLICATE");
assert_eq!(cell.code, "STAMP_NAME_DUPLICATE");
assert_ne!(text.hint, cell.hint, "text-spec and cell-spec wording must stay distinct");
}
#[test]
fn upstream_unmapped_falls_back_to_each_tool_s_legacy_catch_all() {
assert_code(
Tool::SetCell,
OpsError::CellEdit(CellEditError::Codec("x".into())),
"SET_CELL_CODEC_FAILED",
);
assert_code(
Tool::SetCell,
OpsError::CellEdit(CellEditError::NotRoundTripSafe {
component: "x".into(),
diff_path: "y".into(),
}),
"INPUT_NOT_ROUNDTRIP_SAFE",
);
}
#[test]
fn grid_addr_and_stamp_map_pass_through_by_design() {
let err = OpsError::GridAddrProjection(GridAddrError::ShapeMismatch { path: "x".into() });
assert_eq!(err.code(), OpsCode::GridAddrProjectionFailed);
let err2 = OpsError::StampMap(StampMapError::Parse("x".into()));
assert_eq!(err2.code(), OpsCode::InvalidStampMap);
assert_eq!(tool_error(Tool::ToJson, err).code, "GRID_ADDR_PROJECTION_FAILED");
let _ = err2; }
#[test]
fn warning_copies_the_ops_warning_info_verbatim() {
use hwpforge_smithy_hwpx::{EncodeWarning, ParagraphPath, PathSeg};
let w = OpsWarning::Encode(EncodeWarning::LayoutCacheDropped {
path: ParagraphPath(vec![PathSeg::Section(0)]),
reason: "ledger".into(),
});
let info = warning(&w);
assert_eq!(info.code, "LAYOUT_CACHE_DROPPED");
assert!(info.message.contains("ledger"));
assert!(info.hint.is_none());
}
}