Skip to main content

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}