hwpforge_foundation/diagnostics.rs
1//! Cross-frontend diagnostics: stable operation codes and the shared warning payload.
2//!
3//! This module is a deliberate, documented exception to Foundation's
4//! "primitives only" role. The shared `ops` layer that CLI, MCP and the
5//! Python bindings call needs one code table that every crate can see
6//! without a reverse dependency, and the workspace does not add crates for
7//! this purpose, so the table lives here.
8//!
9//! Isolation rules (keep them when extending):
10//!
11//! - nothing in this module depends on any other Foundation module;
12//! - it does not extend the numeric [`crate::ErrorCode`] scheme — codes here
13//! are stable **strings** (the CLI contract), never numbers;
14//! - no frontend response DTO lives here; [`WarningInfo`] is the only
15//! serialisable payload, and it is the wire shape of one warning.
16//!
17//! Naming follows the CLI contract: `<AREA>_<NOUN>` and `_FAILED` for
18//! failures, in SCREAMING_SNAKE_CASE. Where the CLI historically used two
19//! spellings for one meaning, this table holds the canonical one and the
20//! frontends keep their legacy spelling through a compatibility table.
21
22use std::fmt;
23
24use serde::{Deserialize, Deserializer, Serialize, Serializer};
25
26/// Declares [`OpsCode`] once: variant, wire string and doc line.
27///
28/// Generates the enum, `as_str`, `ALL` and `from_str` from a single list so
29/// the three can never drift apart.
30macro_rules! ops_codes {
31 ($( $(#[$doc:meta])* $variant:ident => $wire:literal ),+ $(,)?) => {
32 /// Stable, machine-readable code for an operation failure class.
33 ///
34 /// The string form ([`OpsCode::as_str`]) is the public contract that
35 /// CLI JSON errors, MCP tool errors and Python `HwpForgeError.code`
36 /// expose. Variants are additive; the enum is `#[non_exhaustive]`, so
37 /// match on it with a wildcard arm outside this crate.
38 #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
39 #[non_exhaustive]
40 pub enum OpsCode {
41 $( $(#[$doc])* $variant, )+
42 }
43
44 impl OpsCode {
45 /// Every code, in declaration order (for inventories and snapshots).
46 pub const ALL: &'static [OpsCode] = &[ $( OpsCode::$variant, )+ ];
47
48 /// The stable wire string, e.g. `"DECODE_FAILED"`.
49 pub const fn as_str(self) -> &'static str {
50 match self {
51 $( OpsCode::$variant => $wire, )+
52 }
53 }
54
55 /// Looks a wire string up (exact match only).
56 pub fn lookup(wire: &str) -> Option<OpsCode> {
57 match wire {
58 $( $wire => Some(OpsCode::$variant), )+
59 _ => None,
60 }
61 }
62 }
63 };
64}
65
66ops_codes! {
67 // ── generic ────────────────────────────────────────────────────
68 /// Arguments were syntactically valid but semantically unusable (mutually exclusive targets, empty lists, …).
69 InvalidInput => "INVALID_INPUT",
70 /// An upstream error variant that this table has no mapping for yet; the message keeps the original type name.
71 UpstreamUnmapped => "UPSTREAM_UNMAPPED",
72 /// A library invariant was violated — a bug, not a user error.
73 InternalInvariant => "INTERNAL_INVARIANT",
74 /// JSON input could not be parsed.
75 JsonParseFailed => "JSON_PARSE_FAILED",
76 /// A result could not be serialised to JSON.
77 JsonSerializeFailed => "JSON_SERIALIZE_FAILED",
78 /// Requested JSON schema kind does not exist.
79 UnknownSchemaType => "UNKNOWN_SCHEMA_TYPE",
80 /// Named style preset does not exist.
81 PresetNotFound => "PRESET_NOT_FOUND",
82 /// Input exceeds the accepted size limit.
83 InputTooLarge => "INPUT_TOO_LARGE",
84 /// Provisioned assets do not line up with the document's asset plan (missing, extra, duplicate or reordered occurrence).
85 AssetPlanMismatch => "ASSET_PLAN_MISMATCH",
86 /// Two provisioned assets claim the same identity with different bytes.
87 AssetIdentityConflict => "ASSET_IDENTITY_CONFLICT",
88 // ── codec ──────────────────────────────────────────────────────
89 /// HWPX package or XML could not be decoded.
90 DecodeFailed => "DECODE_FAILED",
91 /// HWPX (or Markdown) output could not be encoded.
92 EncodeFailed => "ENCODE_FAILED",
93 /// A regenerating edit refused to emit bytes because encoding reported semantic-loss warnings (fail-closed).
94 EncodeSemanticLoss => "ENCODE_SEMANTIC_LOSS",
95 /// Core document validation failed.
96 ValidationFailed => "VALIDATION_FAILED",
97 /// Markdown input could not be decoded.
98 MdDecodeFailed => "MD_DECODE_FAILED",
99 /// Style store could not be built from the preset.
100 StyleStoreFailed => "STYLE_STORE_FAILED",
101 /// The document has no fonts, so a restyle has nothing to rebind.
102 NoFonts => "NO_FONTS",
103 /// Style references could not be rebound to the preset.
104 StyleRebindFailed => "STYLE_REBIND_FAILED",
105 /// Document analysis (inspect) failed.
106 AnalysisFailed => "ANALYSIS_FAILED",
107 /// A table cell grid address is invalid.
108 GridAddrInvalid => "GRID_ADDR_INVALID",
109 /// A table grid could not be projected for addressing.
110 GridAddrProjectionFailed => "GRID_ADDR_PROJECTION_FAILED",
111 // ── fill ───────────────────────────────────────────────────────
112 /// A field value was empty (clearing a field is unsupported).
113 EmptyFieldValue => "EMPTY_FIELD_VALUE",
114 /// Named field does not exist in the document.
115 FieldNotFound => "FIELD_NOT_FOUND",
116 /// Several fields share the requested name.
117 FieldNameAmbiguous => "FIELD_NAME_AMBIGUOUS",
118 /// The field exists but is not a fillable click-here field.
119 FieldNotFillable => "FIELD_NOT_FILLABLE",
120 /// Fill workflow failed for another reason.
121 FillFailed => "FILL_FAILED",
122 /// No field values were supplied.
123 NoValues => "NO_VALUES",
124 // ── section workflow ───────────────────────────────────────────
125 /// Requested section index is outside the document.
126 SectionOutOfRange => "SECTION_OUT_OF_RANGE",
127 /// Section index in the payload does not match the requested one.
128 SectionIndexMismatch => "SECTION_INDEX_MISMATCH",
129 /// Preserving patch could not be applied.
130 PatchFailed => "PATCH_FAILED",
131 /// Section export/patch workflow failed for another reason.
132 SectionWorkflowFailed => "SECTION_WORKFLOW_FAILED",
133 // ── read ───────────────────────────────────────────────────────
134 /// `read` needs exactly one target (section, paragraphs, table or field).
135 ReadTargetRequired => "READ_TARGET_REQUIRED",
136 /// Paragraph range expression could not be parsed.
137 ReadParasInvalid => "READ_PARAS_INVALID",
138 /// Paragraph range given without a section.
139 ReadParasWithoutSection => "READ_PARAS_WITHOUT_SECTION",
140 /// `read` section index is outside the document.
141 ReadSectionOutOfRange => "READ_SECTION_OUT_OF_RANGE",
142 /// `read` paragraph range is outside the section.
143 ReadParaRangeInvalid => "READ_PARA_RANGE_INVALID",
144 /// `read` table ordinal is outside the document.
145 ReadTableOutOfRange => "READ_TABLE_OUT_OF_RANGE",
146 /// `read` field name does not exist.
147 ReadFieldNotFound => "READ_FIELD_NOT_FOUND",
148 // ── table / cell ───────────────────────────────────────────────
149 /// Table ordinal does not exist.
150 TableNotFound => "TABLE_NOT_FOUND",
151 /// Table grid is malformed.
152 TableGridInvalid => "TABLE_GRID_INVALID",
153 /// Table grid cannot be addressed by label.
154 TableGridUnaddressable => "TABLE_GRID_UNADDRESSABLE",
155 /// Addressed cell does not exist.
156 CellNotFound => "CELL_NOT_FOUND",
157 /// Cell label matches more than one cell.
158 CellLabelAmbiguous => "CELL_LABEL_AMBIGUOUS",
159 /// Target cell contains non-text content that an edit would destroy.
160 CellHasNonTextContent => "CELL_HAS_NON_TEXT_CONTENT",
161 /// The same cell was targeted twice.
162 CellTargetDuplicate => "CELL_TARGET_DUPLICATE",
163 /// Two cell specs resolve to conflicting targets.
164 CellTargetConflict => "CELL_TARGET_CONFLICT",
165 /// Input document is not round-trip safe, so a regenerating edit was refused.
166 InputNotRoundtripSafe => "INPUT_NOT_ROUNDTRIP_SAFE",
167 /// ZIP entries of the input would be lost by re-encoding, so the edit was refused.
168 InputEntriesNotCarried => "INPUT_ENTRIES_NOT_CARRIED",
169 /// Cell edit failed inside the codec.
170 SetCellCodecFailed => "SET_CELL_CODEC_FAILED",
171 /// Cell edit failed for another reason.
172 SetCellFailed => "SET_CELL_FAILED",
173 /// Cell edit arguments are inconsistent (single target and spec list mixed, …).
174 InvalidSetCellArgs => "INVALID_SET_CELL_ARGS",
175 /// Cell edit spec list is malformed.
176 InvalidSetCellMap => "INVALID_SET_CELL_MAP",
177 // ── stamp ──────────────────────────────────────────────────────
178 /// Stamping failed for another reason.
179 StampFailed => "STAMP_FAILED",
180 /// Stamping failed inside the codec.
181 StampCodecFailed => "STAMP_CODEC_FAILED",
182 /// Stamp manifest violated an invariant.
183 StampManifestInvariant => "STAMP_MANIFEST_INVARIANT",
184 /// Stamp request was made for a different source document (hash mismatch).
185 StampSourceHashMismatch => "STAMP_SOURCE_HASH_MISMATCH",
186 /// Stamp output failed self-verification.
187 StampDeltaMismatch => "STAMP_DELTA_MISMATCH",
188 /// Cell stamp target is not the anchor of its merged region.
189 StampCellNotAnchor => "STAMP_CELL_NOT_ANCHOR",
190 /// Cell stamp target is not an empty cell.
191 StampCellNotEmpty => "STAMP_CELL_NOT_EMPTY",
192 /// Cell label drifted since the plan was made.
193 StampLabelDrift => "STAMP_LABEL_DRIFT",
194 /// Cell stamp target is not a plan candidate.
195 StampCellNotCandidate => "STAMP_CELL_NOT_CANDIDATE",
196 /// The same cell was stamped twice.
197 StampCellTargetDuplicate => "STAMP_CELL_TARGET_DUPLICATE",
198 /// Stamp field name is empty.
199 StampNameEmpty => "STAMP_NAME_EMPTY",
200 /// Cell stamp hint is blank.
201 StampHintBlank => "STAMP_HINT_BLANK",
202 /// Stamp field name is used twice in the request.
203 StampNameDuplicate => "STAMP_NAME_DUPLICATE",
204 /// Stamp field name collides with an existing field.
205 StampNameCollision => "STAMP_NAME_COLLISION",
206 /// A plan candidate was neither named nor ignored.
207 StampCandidateUncovered => "STAMP_CANDIDATE_UNCOVERED",
208 /// Stamp spec no longer matches the document.
209 StampSpecStale => "STAMP_SPEC_STALE",
210 /// Stamp marker text differs from the plan.
211 StampMarkerMismatch => "STAMP_MARKER_MISMATCH",
212 /// The same span was specified twice.
213 StampSpecDuplicate => "STAMP_SPEC_DUPLICATE",
214 /// Stamp request payload is malformed.
215 InvalidStampMap => "INVALID_STAMP_MAP",
216 /// A v2 stamp request lacks `source_sha256`.
217 MissingSourceSha256 => "MISSING_SOURCE_SHA256",
218 // ── structural edit ────────────────────────────────────────────
219 /// Structural edit failed for another reason.
220 StructuralEditFailed => "STRUCTURAL_EDIT_FAILED",
221 /// Structural edit failed inside the codec.
222 StructuralCodec => "STRUCTURAL_CODEC",
223 /// Paragraph index is outside the section.
224 ParagraphOutOfRange => "PARAGRAPH_OUT_OF_RANGE",
225 /// The same paragraph was targeted twice.
226 DuplicateTarget => "DUPLICATE_TARGET",
227 /// Deleting would strand a reference (note, bookmark, …).
228 ReferenceStranded => "REFERENCE_STRANDED",
229 /// Edit would lose a hard break.
230 HardBreakLoss => "HARD_BREAK_LOSS",
231 /// Edit would leave a section empty.
232 EmptySection => "EMPTY_SECTION",
233 /// The section-properties paragraph cannot be edited.
234 SectionPropertiesParagraph => "SECTION_PROPERTIES_PARAGRAPH",
235 /// Span count changed unexpectedly.
236 SpanCountMismatch => "SPAN_COUNT_MISMATCH",
237 /// Structural edit output failed self-verification.
238 SelfVerifyFailed => "SELF_VERIFY_FAILED",
239 /// Inserted text spans several paragraphs where one is required.
240 MultiParagraphText => "MULTI_PARAGRAPH_TEXT",
241 /// Insertion before the section-properties paragraph is not allowed.
242 InsertBeforeSectionProperties => "INSERT_BEFORE_SECTION_PROPERTIES",
243 /// `delete_para` was given no target.
244 DeleteNoTarget => "DELETE_NO_TARGET",
245 /// `insert_para` was given no text.
246 InsertTextRequired => "INSERT_TEXT_REQUIRED",
247 // ── conversion (HWP5 · PDF) ────────────────────────────────────
248 /// HWP5 input could not be decoded.
249 Hwp5DecodeFailed => "HWP5_DECODE_FAILED",
250 /// HWP5 → HWPX conversion failed.
251 Hwp5ConvertFailed => "HWP5_CONVERT_FAILED",
252 /// Input bytes are neither HWP5 nor HWPX.
253 UnrecognizedFormat => "UNRECOGNIZED_FORMAT",
254 /// PDF rendering failed (the renderer's own code is carried as the cause).
255 PdfRenderFailed => "PDF_RENDER_FAILED",
256 /// Font discovery mode is not one of the accepted values.
257 InvalidDiscovery => "INVALID_DISCOVERY",
258}
259
260impl fmt::Display for OpsCode {
261 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
262 f.write_str(self.as_str())
263 }
264}
265
266/// A wire string that is not an [`OpsCode`].
267#[derive(Debug, Clone, PartialEq, Eq)]
268pub struct UnknownOpsCode(pub String);
269
270impl fmt::Display for UnknownOpsCode {
271 fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result {
272 write!(f, "unknown ops code `{}`", self.0)
273 }
274}
275
276impl std::error::Error for UnknownOpsCode {}
277
278impl std::str::FromStr for OpsCode {
279 type Err = UnknownOpsCode;
280
281 fn from_str(wire: &str) -> Result<Self, Self::Err> {
282 OpsCode::lookup(wire).ok_or_else(|| UnknownOpsCode(wire.to_owned()))
283 }
284}
285
286impl Serialize for OpsCode {
287 fn serialize<S: Serializer>(&self, serializer: S) -> Result<S::Ok, S::Error> {
288 serializer.serialize_str(self.as_str())
289 }
290}
291
292impl<'de> Deserialize<'de> for OpsCode {
293 fn deserialize<D: Deserializer<'de>>(deserializer: D) -> Result<Self, D::Error> {
294 let wire = String::deserialize(deserializer)?;
295 wire.parse().map_err(serde::de::Error::custom)
296 }
297}
298
299/// One non-fatal warning as every frontend reports it.
300///
301/// `code` is a stable SCREAMING_SNAKE string (an [`OpsCode`] wire string or a
302/// crate-specific warning code such as `LAYOUT_CACHE_DROPPED`), `message` is
303/// human-readable, and `hint` is an optional recovery suggestion. The struct
304/// is `#[non_exhaustive]`; build it with [`WarningInfo::new`].
305#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize, schemars::JsonSchema)]
306#[non_exhaustive]
307pub struct WarningInfo {
308 /// Stable warning code.
309 pub code: String,
310 /// Human-readable description.
311 pub message: String,
312 /// Optional recovery hint.
313 #[serde(default, skip_serializing_if = "Option::is_none")]
314 pub hint: Option<String>,
315}
316
317impl WarningInfo {
318 /// Creates a warning with `code` and `message` and no hint.
319 pub fn new(code: impl Into<String>, message: impl Into<String>) -> Self {
320 Self { code: code.into(), message: message.into(), hint: None }
321 }
322
323 /// Creates a warning whose code is an [`OpsCode`].
324 pub fn coded(code: OpsCode, message: impl Into<String>) -> Self {
325 Self::new(code.as_str(), message)
326 }
327
328 /// Attaches a recovery hint.
329 #[must_use]
330 pub fn with_hint(mut self, hint: impl Into<String>) -> Self {
331 self.hint = Some(hint.into());
332 self
333 }
334}
335
336#[cfg(test)]
337mod tests {
338 use super::*;
339 use std::collections::HashSet;
340
341 #[test]
342 fn every_code_round_trips_through_its_wire_string() {
343 for code in OpsCode::ALL {
344 assert_eq!(code.as_str().parse::<OpsCode>(), Ok(*code), "{code:?}");
345 assert_eq!(OpsCode::lookup(code.as_str()), Some(*code));
346 }
347 }
348
349 #[test]
350 fn wire_strings_are_unique_screaming_snake() {
351 let mut seen = HashSet::new();
352 for code in OpsCode::ALL {
353 let s = code.as_str();
354 assert!(seen.insert(s), "duplicate wire string {s}");
355 assert!(
356 s.bytes().all(|b| b.is_ascii_uppercase() || b.is_ascii_digit() || b == b'_'),
357 "{s} is not SCREAMING_SNAKE"
358 );
359 assert!(!s.starts_with('_') && !s.ends_with('_'), "{s}");
360 }
361 }
362
363 #[test]
364 fn serde_uses_the_wire_string() {
365 let json = serde_json::to_string(&OpsCode::EncodeSemanticLoss).unwrap();
366 assert_eq!(json, "\"ENCODE_SEMANTIC_LOSS\"");
367 let back: OpsCode = serde_json::from_str(&json).unwrap();
368 assert_eq!(back, OpsCode::EncodeSemanticLoss);
369 assert!(serde_json::from_str::<OpsCode>("\"NOPE\"").is_err());
370 }
371
372 #[test]
373 fn unknown_wire_string_is_none() {
374 assert_eq!(OpsCode::lookup("decode_failed"), None);
375 assert_eq!("".parse::<OpsCode>(), Err(UnknownOpsCode(String::new())));
376 assert_eq!(UnknownOpsCode("X".into()).to_string(), "unknown ops code `X`");
377 }
378
379 #[test]
380 fn warning_info_serialises_without_null_hint() {
381 let w = WarningInfo::coded(OpsCode::EncodeSemanticLoss, "note head skipped");
382 assert_eq!(
383 serde_json::to_string(&w).unwrap(),
384 r#"{"code":"ENCODE_SEMANTIC_LOSS","message":"note head skipped"}"#
385 );
386 let w = w.with_hint("re-export the section");
387 let v: serde_json::Value = serde_json::to_value(&w).unwrap();
388 assert_eq!(v["hint"], "re-export the section");
389 let back: WarningInfo = serde_json::from_value(v).unwrap();
390 assert_eq!(back, w);
391 }
392
393 #[test]
394 fn display_is_the_wire_string() {
395 assert_eq!(OpsCode::PresetNotFound.to_string(), "PRESET_NOT_FOUND");
396 }
397}