1use std::io::Cursor;
16use std::path::Path;
17
18use anyhow::{Context, Result};
19
20use crate::calamine::{open_workbook, Data as CData, Reader, Xlsx};
21use crate::plan::{parse_template, parse_template_bytes};
22use crate::source::CalamineSourceReader;
23
24#[derive(Debug, Clone, PartialEq, Eq)]
28pub struct InputSpec {
29 pub name: String,
30 pub kind: InputKind,
31 pub required: bool,
32 pub default: Option<String>,
33 pub label: Option<String>,
34 pub description: Option<String>,
35 pub options: Vec<String>,
38}
39
40#[derive(Debug, Clone, Copy, PartialEq, Eq)]
41pub enum InputKind {
42 Text,
43 Number,
44 Date,
45 Select,
46 Other,
50}
51
52impl InputKind {
53 fn parse(s: &str) -> Self {
54 match s.trim().to_ascii_lowercase().as_str() {
55 "text" => InputKind::Text,
56 "number" => InputKind::Number,
57 "date" => InputKind::Date,
58 "select" => InputKind::Select,
59 _ => InputKind::Other,
60 }
61 }
62}
63
64#[derive(Debug, Clone)]
68pub struct PreviewFile {
69 pub filename: String,
70 pub sheets: Vec<PreviewSheet>,
71}
72
73#[derive(Debug, Clone)]
74pub struct PreviewSheet {
75 pub name: String,
76}
77
78#[derive(Debug, Clone)]
79pub struct PreviewResult {
80 pub files: Vec<PreviewFile>,
81 pub sources: Vec<PreviewSource>,
82}
83
84#[derive(Debug, Clone)]
85pub struct PreviewSource {
86 pub name: String,
87 pub headers: Vec<String>,
88 pub row_count: usize,
89}
90
91pub fn read_template_inputs(template: &Path) -> Result<Vec<InputSpec>> {
95 let wb: Xlsx<_> = open_workbook(template)
96 .with_context(|| format!("open template workbook at {}", template.display()))?;
97 read_template_inputs_inner(wb)
98}
99
100pub fn read_template_inputs_bytes(template_bytes: &[u8]) -> Result<Vec<InputSpec>> {
103 let cursor = Cursor::new(template_bytes.to_vec());
104 let wb: Xlsx<_> = Xlsx::new(cursor).context("open template workbook from bytes")?;
105 read_template_inputs_inner(wb)
106}
107
108fn read_template_inputs_inner<R: std::io::Read + std::io::Seek>(
109 mut wb: Xlsx<R>,
110) -> Result<Vec<InputSpec>> {
111 let names = wb.sheet_names();
112 if !names.iter().any(|n| n == "__inputs__") {
113 return Ok(Vec::new());
114 }
115 let range = wb
116 .worksheet_range("__inputs__")
117 .context("read __inputs__ sheet")?;
118 let (rows, cols) = range.get_size();
119 if rows < 2 || cols < 1 {
120 return Ok(Vec::new());
121 }
122 let mut headers: Vec<String> = Vec::with_capacity(cols);
123 for c in 0..cols {
124 headers.push(match range.get((0, c)) {
125 Some(CData::String(s)) => s.clone(),
126 _ => String::new(),
127 });
128 }
129 let col_of = |key: &str| -> Option<usize> {
130 headers
131 .iter()
132 .position(|h| h.eq_ignore_ascii_case(key))
133 };
134 let type_col = col_of("type");
135 let default_col = col_of("default");
136 let label_col = col_of("label");
137 let description_col = col_of("description");
138 let options_col = col_of("options");
139 let required_col = col_of("required");
140
141 let cell_to_string = |r: usize, c: Option<usize>| -> Option<String> {
142 let c = c?;
143 match range.get((r, c))? {
144 CData::String(s) if !s.is_empty() => Some(s.clone()),
145 CData::Float(f) => Some(format!("{f}")),
146 CData::Int(i) => Some(format!("{i}")),
147 CData::Bool(b) => Some(b.to_string()),
148 _ => None,
149 }
150 };
151
152 let mut out = Vec::new();
153 for r in 1..rows {
154 let name = match range.get((r, 0)) {
155 Some(CData::String(s)) if !s.is_empty() => s.clone(),
156 _ => continue,
157 };
158 let kind = type_col
159 .and_then(|c| cell_to_string(r, Some(c)))
160 .map(|s| InputKind::parse(&s))
161 .unwrap_or(InputKind::Other);
162 let default = default_col.and_then(|c| cell_to_string(r, Some(c)));
163 let label = label_col.and_then(|c| cell_to_string(r, Some(c)));
164 let description = description_col.and_then(|c| cell_to_string(r, Some(c)));
165 let options_raw = options_col.and_then(|c| cell_to_string(r, Some(c)));
166 let options: Vec<String> = options_raw
167 .as_deref()
168 .map(|s| {
169 s.split('|')
170 .map(|t| t.trim().to_string())
171 .filter(|t| !t.is_empty())
172 .collect()
173 })
174 .unwrap_or_default();
175 let required = required_col
176 .and_then(|c| cell_to_string(r, Some(c)))
177 .map(|s| matches!(s.trim().to_ascii_lowercase().as_str(), "true" | "yes" | "1"))
178 .unwrap_or(false);
179
180 out.push(InputSpec {
181 name,
182 kind,
183 required,
184 default,
185 label,
186 description,
187 options,
188 });
189 }
190 Ok(out)
191}
192
193pub fn preview(template: &Path, data: &Path) -> Result<PreviewResult> {
197 let plan = parse_template(template).context("parse template")?;
198 let source_reader = CalamineSourceReader::open(data).context("open source workbook")?;
199 preview_inner(plan, source_reader)
200}
201
202pub fn preview_bytes(template_bytes: &[u8], data_bytes: Vec<u8>) -> Result<PreviewResult> {
205 let plan = parse_template_bytes(template_bytes).context("parse template")?;
206 let source_reader =
207 CalamineSourceReader::open_bytes(data_bytes).context("open source workbook")?;
208 preview_inner(plan, source_reader)
209}
210
211fn preview_inner(
212 plan: crate::plan::WorkbookPlan,
213 mut source_reader: CalamineSourceReader,
214) -> Result<PreviewResult> {
215 let source_sheet = match plan.config.source_sheet() {
216 Some(pattern) => source_reader
217 .resolve_sheet_name(pattern)
218 .ok_or_else(|| {
219 anyhow::Error::from(crate::errors::XtlError::new(
220 crate::errors::code::SOURCE_SHEET_MISSING,
221 format!("Source sheet \"{pattern}\" was not found"),
222 ))
223 })?,
224 None => source_reader
225 .first_sheet()
226 .ok_or_else(|| {
227 anyhow::Error::from(crate::errors::XtlError::new(
228 crate::errors::code::SOURCE_SHEET_MISSING,
229 "Source workbook is empty",
230 ))
231 })?,
232 };
233 let source_table = plan.config.source_table();
234 let default_source = {
235 use crate::source::SourceReader;
236 source_reader.read(&source_sheet, &source_table)?
237 };
238
239 let mut sources = vec![PreviewSource {
240 name: default_source.name.clone(),
241 headers: default_source.headers.clone(),
242 row_count: default_source.rows.len(),
243 }];
244 for (name, decl) in &plan.named_sources {
245 use crate::source::SourceReader;
246 if let Ok(data) = source_reader.read(&decl.sheet, &decl.table) {
247 sources.push(PreviewSource {
248 name: name.clone(),
249 headers: data.headers,
250 row_count: data.rows.len(),
251 });
252 }
253 }
254
255 let filename = plan
256 .config
257 .output_file_pattern()
258 .map(str::to_string)
259 .unwrap_or_else(|| "output.xlsx".to_string());
260 let sheets: Vec<PreviewSheet> = plan
261 .sheets
262 .iter()
263 .map(|s| PreviewSheet {
264 name: s.name.clone(),
265 })
266 .collect();
267 Ok(PreviewResult {
268 files: vec![PreviewFile { filename, sheets }],
269 sources,
270 })
271}
272
273#[cfg(test)]
274mod tests {
275 use super::*;
276
277 fn fixture(name: &str) -> std::path::PathBuf {
278 std::path::PathBuf::from(format!(
279 "/Users/wefun/workspaces/playground/xl3/conformance/fixtures/{name}"
280 ))
281 }
282
283 #[test]
284 fn read_inputs_from_065() {
285 let dir = fixture("065-input-text-default-applied");
286 if !dir.exists() {
287 return; }
289 let specs = read_template_inputs(&dir.join("template.xlsx")).unwrap();
290 assert_eq!(specs.len(), 1);
291 let s = &specs[0];
292 assert_eq!(s.name, "month");
293 assert_eq!(s.kind, InputKind::Text);
294 assert_eq!(s.default.as_deref(), Some("2026-05"));
295 assert_eq!(s.label.as_deref(), Some("Report month"));
296 }
297
298 #[test]
299 fn read_inputs_select_from_068() {
300 let dir = fixture("068-input-select-host-supplied");
301 if !dir.exists() {
302 return;
303 }
304 let specs = read_template_inputs(&dir.join("template.xlsx")).unwrap();
305 assert_eq!(specs.len(), 1);
306 let s = &specs[0];
307 assert_eq!(s.name, "region");
308 assert_eq!(s.kind, InputKind::Select);
309 assert_eq!(
310 s.options,
311 vec!["Seoul".to_string(), "Busan".to_string(), "Daegu".to_string()]
312 );
313 }
314
315 #[test]
316 fn preview_001_single_file_single_sheet() {
317 let dir = fixture("001-bracket-substitution");
318 if !dir.exists() {
319 return;
320 }
321 let pv = preview(&dir.join("template.xlsx"), &dir.join("data.xlsx")).unwrap();
322 assert_eq!(pv.files.len(), 1);
323 assert_eq!(pv.files[0].filename, "output.xlsx");
324 assert_eq!(pv.files[0].sheets.len(), 1);
325 assert_eq!(pv.files[0].sheets[0].name, "Report");
326 assert_eq!(pv.sources.len(), 1);
327 assert_eq!(pv.sources[0].headers, vec!["Customer".to_string()]);
328 assert_eq!(pv.sources[0].row_count, 2);
329 }
330}
331