Skip to main content

three_dcf_core/
serializer.rs

1use std::fs::File;
2use std::io::Write;
3use std::path::Path;
4
5use crate::document::{CellType, Document};
6use crate::error::Result;
7
8#[derive(Debug, Clone, Copy, PartialEq, Eq)]
9pub enum TableMode {
10    Auto,
11    Csv,
12    Dims,
13}
14
15impl Default for TableMode {
16    fn default() -> Self {
17        TableMode::Auto
18    }
19}
20
21#[derive(Debug, Clone)]
22pub struct TextSerializerConfig {
23    pub include_header: bool,
24    pub include_grammar: bool,
25    pub max_preview_chars: usize,
26    pub table_mode: TableMode,
27    pub preset_label: Option<String>,
28    pub budget_label: Option<String>,
29}
30
31impl Default for TextSerializerConfig {
32    fn default() -> Self {
33        Self {
34            include_header: true,
35            include_grammar: true,
36            max_preview_chars: 64,
37            table_mode: TableMode::Auto,
38            preset_label: None,
39            budget_label: None,
40        }
41    }
42}
43
44#[derive(Debug, Clone)]
45pub struct TextSerializer {
46    config: TextSerializerConfig,
47}
48
49impl TextSerializer {
50    pub fn new() -> Self {
51        Self {
52            config: TextSerializerConfig::default(),
53        }
54    }
55
56    pub fn with_config(config: TextSerializerConfig) -> Self {
57        Self { config }
58    }
59
60    pub fn to_string(&self, document: &Document) -> Result<String> {
61        let mut out = String::new();
62        if self.config.include_header {
63            let preset = self
64                .config
65                .preset_label
66                .clone()
67                .unwrap_or_else(|| "unknown".to_string());
68            let budget = self
69                .config
70                .budget_label
71                .clone()
72                .unwrap_or_else(|| "auto".to_string());
73            out.push_str(&format!(
74                "<ctx3d grid={} codeset={} preset={} budget={}>\n",
75                document.header.grid, document.header.codeset, preset, budget
76            ));
77        }
78        for cell in document.ordered_cells() {
79            let code_hex = hex::encode(cell.code_id);
80            let preview = document
81                .payload_for(&cell.code_id)
82                .map(|payload| match cell.cell_type {
83                    CellType::Table => render_table_preview(payload, &self.config),
84                    _ => preview(payload, self.config.max_preview_chars),
85                })
86                .unwrap_or_else(|| "<missing>".to_string());
87            let code_short = &code_hex[..16];
88            let preview_escaped = escape_preview(&preview);
89            out.push_str(&format!(
90                "(z={z},x={x},y={y},w={w},h={h},code={code},rle={rle},imp={imp},type={typ}) \"{preview}\"\n",
91                z = cell.z,
92                x = cell.x,
93                y = cell.y,
94                w = cell.w,
95                h = cell.h,
96                code = code_short,
97                rle = cell.rle,
98                imp = cell.importance,
99                typ = format!("{:?}", cell.cell_type).to_uppercase(),
100                preview = preview_escaped
101            ));
102        }
103        if self.config.include_grammar {
104            out.push_str("\ngrammar: --select \"z=0,x=0..1024,y=0..4096\"\n");
105        }
106        if self.config.include_header {
107            out.push_str("</ctx3d>\n");
108        }
109        Ok(out)
110    }
111
112    pub fn write_textual<P: AsRef<Path>>(&self, document: &Document, path: P) -> Result<()> {
113        let txt = self.to_string(document)?;
114        let mut file = File::create(path)?;
115        file.write_all(txt.as_bytes())?;
116        Ok(())
117    }
118}
119
120fn preview(payload: &str, limit: usize) -> String {
121    if payload.len() <= limit {
122        return payload.to_string();
123    }
124    let mut truncated = payload.chars().take(limit).collect::<String>();
125    truncated.push_str("...");
126    truncated
127}
128
129fn estimate_table_columns(payload: &str) -> usize {
130    if payload.contains('|') {
131        payload
132            .split('|')
133            .filter(|c| !c.trim().is_empty())
134            .count()
135            .max(1)
136    } else {
137        payload.split_whitespace().count().max(1)
138    }
139}
140
141fn render_table_preview(payload: &str, config: &TextSerializerConfig) -> String {
142    match config.table_mode {
143        TableMode::Csv => csv_preview(payload, config.max_preview_chars),
144        TableMode::Dims => dims_preview(payload),
145        TableMode::Auto => {
146            if payload.len() <= config.max_preview_chars * 2 {
147                csv_preview(payload, config.max_preview_chars)
148            } else {
149                dims_preview(payload)
150            }
151        }
152    }
153}
154
155fn dims_preview(payload: &str) -> String {
156    let rows = payload
157        .lines()
158        .filter(|l| !l.trim().is_empty())
159        .count()
160        .max(1);
161    let cols = estimate_table_columns(payload);
162    format!("[table rows={rows} cols={cols}]")
163}
164
165fn csv_preview(payload: &str, limit: usize) -> String {
166    let mut rows = Vec::new();
167    for line in payload.lines().filter(|l| !l.trim().is_empty()) {
168        let normalized = line
169            .replace('|', ",")
170            .replace('\t', ",")
171            .split(',')
172            .map(|cell| cell.trim())
173            .filter(|cell| !cell.is_empty())
174            .collect::<Vec<_>>()
175            .join(", ");
176        if normalized.is_empty() {
177            continue;
178        }
179        rows.push(normalized);
180        if rows.len() >= 4 {
181            break;
182        }
183    }
184    let mut combined = rows.join(" | ");
185    if combined.len() > limit {
186        combined.truncate(limit);
187        combined.push_str("...");
188    }
189    if combined.is_empty() {
190        dims_preview(payload)
191    } else {
192        format!("[csv {combined}]")
193    }
194}
195
196fn escape_preview(payload: &str) -> String {
197    payload.replace('\"', "\\\"")
198}
199
200#[cfg(test)]
201mod tests {
202    use super::*;
203    use crate::document::{CellRecord, CellType, Document, Header, PageInfo};
204
205    fn sample_document() -> Document {
206        let mut doc = Document::new(Header::default());
207        doc.add_page(PageInfo {
208            z: 0,
209            width_px: 800,
210            height_px: 1000,
211        });
212        let text_code = [1u8; 32];
213        doc.dict.insert(text_code, "Hello world".to_string());
214        doc.cells.push(CellRecord {
215            z: 0,
216            x: 10,
217            y: 20,
218            w: 700,
219            h: 20,
220            code_id: text_code,
221            rle: 0,
222            cell_type: CellType::Text,
223            importance: 100,
224        });
225
226        let table_code = [2u8; 32];
227        doc.dict.insert(
228            table_code,
229            "Quarter | Revenue | Cost\nQ1 | 10 | 5\nQ2 | 12 | 6".to_string(),
230        );
231        doc.cells.push(CellRecord {
232            z: 0,
233            x: 10,
234            y: 60,
235            w: 700,
236            h: 40,
237            code_id: table_code,
238            rle: 0,
239            cell_type: CellType::Table,
240            importance: 120,
241        });
242
243        doc
244    }
245
246    #[test]
247    fn snapshot_serialization() {
248        let doc = sample_document();
249        let serializer = TextSerializer::new();
250        let rendered = serializer.to_string(&doc).unwrap();
251        insta::assert_snapshot!(rendered);
252    }
253
254    #[test]
255    fn snapshot_serialization_csv_mode() {
256        let doc = sample_document();
257        let serializer = TextSerializer::with_config(TextSerializerConfig {
258            table_mode: TableMode::Csv,
259            ..Default::default()
260        });
261        let rendered = serializer.to_string(&doc).unwrap();
262        insta::assert_snapshot!(rendered);
263    }
264
265    #[test]
266    fn snapshot_serialization_dims_mode() {
267        let doc = sample_document();
268        let serializer = TextSerializer::with_config(TextSerializerConfig {
269            table_mode: TableMode::Dims,
270            ..Default::default()
271        });
272        let rendered = serializer.to_string(&doc).unwrap();
273        insta::assert_snapshot!(rendered);
274    }
275}