Skip to main content

hwpforge_smithy_hwpx/
section_workflow.rs

1//! Shared section export/patch orchestration for bindings.
2//!
3//! This module centralizes section-level workflow decisions shared by the CLI
4//! and MCP bindings while leaving file I/O and presentation-layer rendering in
5//! the bindings themselves.
6
7use crate::error::HwpxError;
8use crate::{ExportedSection, HwpxDecoder, HwpxPatcher, PackageReader};
9
10/// Result of a section-only export intended for later patching.
11#[derive(Debug)]
12pub struct SectionExportOutcome {
13    /// Section export payload.
14    pub exported: ExportedSection,
15    /// Optional workflow warning that callers may surface to users.
16    pub warning: Option<SectionWorkflowWarning>,
17}
18
19/// Result of patching a single exported section back into a base HWPX package.
20#[derive(Debug)]
21pub struct SectionPatchOutcome {
22    /// Patched HWPX bytes.
23    pub bytes: Vec<u8>,
24    /// Which section was patched.
25    pub patched_section: usize,
26    /// Total section count in the output package.
27    pub sections: usize,
28}
29
30/// Non-fatal warning emitted while preparing a section export.
31#[derive(Debug, Clone, PartialEq, Eq)]
32#[non_exhaustive]
33pub enum SectionWorkflowWarning {
34    /// Preservation metadata could not be generated, so later preserving patch
35    /// is unavailable until the section is re-exported successfully.
36    PreservationMetadataUnavailable {
37        /// Underlying detail from the preserving metadata builder.
38        detail: String,
39    },
40}
41
42impl SectionWorkflowWarning {
43    /// Stable machine-readable warning code.
44    #[must_use]
45    pub fn code(&self) -> &'static str {
46        match self {
47            Self::PreservationMetadataUnavailable { .. } => "PRESERVATION_METADATA_UNAVAILABLE",
48        }
49    }
50
51    /// Human-readable warning message.
52    #[must_use]
53    pub fn message(&self) -> String {
54        match self {
55            Self::PreservationMetadataUnavailable { detail } => {
56                format!("Preserving patch metadata unavailable: {detail}")
57            }
58        }
59    }
60}
61
62/// Domain error for shared section export/patch workflows.
63#[derive(Debug, thiserror::Error)]
64#[non_exhaustive]
65pub enum SectionWorkflowError {
66    /// Base HWPX could not be decoded well enough to proceed.
67    #[error("HWPX decode failed: {detail}")]
68    Decode {
69        /// Underlying decode detail.
70        detail: String,
71    },
72    /// Requested section index is outside the document range.
73    #[error("Section {requested} does not exist (document has {sections} sections)")]
74    SectionOutOfRange {
75        /// Requested section index.
76        requested: usize,
77        /// Total section count in the document.
78        sections: usize,
79    },
80    /// Requested CLI/MCP section does not match the JSON payload section.
81    #[error("Requested section {requested} but JSON contains section {actual} data")]
82    SectionIndexMismatch {
83        /// Caller-requested section index.
84        requested: usize,
85        /// Section index found in the JSON payload.
86        actual: usize,
87    },
88    /// Preserve-first patch failed after orchestration-level validation.
89    #[error("Preserving patch failed: {0}")]
90    PreservingPatch(#[from] HwpxError),
91}
92
93impl HwpxPatcher {
94    /// Export a single section plus optional preservation metadata for editing.
95    pub fn export_section_for_edit(
96        base_bytes: &[u8],
97        section_idx: usize,
98        include_styles: bool,
99    ) -> Result<SectionExportOutcome, SectionWorkflowError> {
100        let hwpx_doc = HwpxDecoder::decode(base_bytes)
101            .map_err(|error| SectionWorkflowError::Decode { detail: error.to_string() })?;
102
103        let section = hwpx_doc.document.sections().get(section_idx).cloned().ok_or(
104            SectionWorkflowError::SectionOutOfRange {
105                requested: section_idx,
106                sections: hwpx_doc.document.sections().len(),
107            },
108        )?;
109
110        let preservation =
111            match HwpxPatcher::export_section_preservation(base_bytes, section_idx, &section) {
112                Ok(metadata) => (Some(metadata), None),
113                Err(error) => (
114                    None,
115                    Some(SectionWorkflowWarning::PreservationMetadataUnavailable {
116                        detail: error.to_string(),
117                    }),
118                ),
119            };
120
121        let exported = ExportedSection {
122            section_index: section_idx,
123            section,
124            styles: include_styles.then_some(hwpx_doc.style_store),
125            preservation: preservation.0,
126        };
127
128        Ok(SectionExportOutcome { exported, warning: preservation.1 })
129    }
130
131    /// Apply a section export back onto a base HWPX package.
132    pub fn patch_exported_section(
133        base_bytes: &[u8],
134        section_idx: usize,
135        exported: &ExportedSection,
136    ) -> Result<SectionPatchOutcome, SectionWorkflowError> {
137        let section_count = PackageReader::new(base_bytes)
138            .map_err(|error| SectionWorkflowError::Decode { detail: error.to_string() })?
139            .section_count();
140
141        if exported.section_index != section_idx {
142            return Err(SectionWorkflowError::SectionIndexMismatch {
143                requested: section_idx,
144                actual: exported.section_index,
145            });
146        }
147
148        if section_idx >= section_count {
149            return Err(SectionWorkflowError::SectionOutOfRange {
150                requested: section_idx,
151                sections: section_count,
152            });
153        }
154
155        let bytes = HwpxPatcher::patch_section_preserving(
156            base_bytes,
157            section_idx,
158            &exported.section,
159            exported.styles.as_ref(),
160            exported.preservation.as_ref(),
161        )?;
162
163        Ok(SectionPatchOutcome { bytes, patched_section: section_idx, sections: section_count })
164    }
165}
166
167#[cfg(test)]
168mod tests {
169    use super::*;
170    use hwpforge_core::document::Document;
171    use hwpforge_core::image::ImageStore;
172    use hwpforge_core::page::PageSettings;
173    use hwpforge_core::paragraph::Paragraph;
174    use hwpforge_core::run::Run;
175    use hwpforge_core::section::Section;
176    use hwpforge_core::Draft;
177    use hwpforge_foundation::{CharShapeIndex, ParaShapeIndex};
178
179    use crate::{HwpxCharShape, HwpxEncoder, HwpxParaShape, HwpxResult, HwpxStyleStore};
180
181    fn minimal_hwpx_bytes() -> HwpxResult<Vec<u8>> {
182        let mut doc: Document<Draft> = Document::new();
183        doc.add_section(Section::with_paragraphs(
184            vec![Paragraph::with_runs(
185                vec![Run::text("hello", CharShapeIndex::new(0))],
186                ParaShapeIndex::new(0),
187            )],
188            PageSettings::default(),
189        ));
190        let mut styles: HwpxStyleStore = HwpxStyleStore::with_default_fonts("함초롬돋움");
191        styles.push_char_shape(HwpxCharShape::default());
192        styles.push_para_shape(HwpxParaShape::default());
193        let validated = doc.validate()?;
194        HwpxEncoder::encode(&validated, &styles, &ImageStore::new())
195    }
196
197    #[test]
198    fn export_section_for_edit_embeds_preservation_when_available() {
199        let bytes = minimal_hwpx_bytes().unwrap();
200        let outcome = HwpxPatcher::export_section_for_edit(&bytes, 0, true).unwrap();
201        assert!(outcome.warning.is_none());
202        assert_eq!(outcome.exported.section_index, 0);
203        assert!(outcome.exported.styles.is_some());
204        assert!(outcome.exported.preservation.is_some());
205    }
206
207    #[test]
208    fn patch_exported_section_rejects_index_mismatch() {
209        let bytes = minimal_hwpx_bytes().unwrap();
210        let mut exported = HwpxPatcher::export_section_for_edit(&bytes, 0, true).unwrap().exported;
211        exported.section_index = 1;
212
213        let error = HwpxPatcher::patch_exported_section(&bytes, 0, &exported).unwrap_err();
214        assert!(matches!(
215            error,
216            SectionWorkflowError::SectionIndexMismatch { requested: 0, actual: 1 }
217        ));
218    }
219
220    #[test]
221    fn patch_exported_section_prefers_index_mismatch_over_out_of_range() {
222        let bytes = minimal_hwpx_bytes().unwrap();
223        let exported = HwpxPatcher::export_section_for_edit(&bytes, 0, true).unwrap().exported;
224
225        let error = HwpxPatcher::patch_exported_section(&bytes, 99, &exported).unwrap_err();
226        assert!(matches!(
227            error,
228            SectionWorkflowError::SectionIndexMismatch { requested: 99, actual: 0 }
229        ));
230    }
231}