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}