Skip to main content

xl3_core/
introspect.rs

1//! Surface-area parity with xl3 (TS) / xl3-py introspection APIs.
2//!
3//! - `read_template_inputs(template) -> Vec<InputSpec>` — what the
4//!   template declares on `__inputs__` (xl3 ADR-0010).
5//! - `preview(template, data) -> PreviewResult` — the *shape* of what
6//!   `render` would produce: filenames + sheet names + source columns.
7//!   Implemented in terms of `parse_template` + the source reader; no
8//!   actual workbook bytes are produced.
9//!
10//! Both functions are pure introspection — they never touch
11//! `rust_xlsxwriter` or run the evaluator. Aimed at the same use-case
12//! as the TS/py siblings: hosts that need to render input forms or
13//! describe the output before committing to a full convert call.
14
15use 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/// Mirrors xl3 (TS)'s `InputSpec` and xl3-py's `InputSpec`. The shape
25/// is intentionally identical so a host can feed the result through a
26/// language-agnostic UI generator.
27#[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    /// `select`-only — the pipe-separated `options` column unpacked
36    /// into a list. Empty for other input kinds.
37    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    /// Unknown / unspecified — accept any string. Lets us not reject
47    /// templates that omit the `type` column or use a fixture-only
48    /// extension.
49    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/// One file the renderer would produce. Currently always exactly one
65/// (`output_file_pattern` template splitting is pending). Mirrors the
66/// PreviewFile shape from the sibling implementations.
67#[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
91/// Read just the `__inputs__` sheet, returning structured specs.
92/// Does not render or open the data workbook. Mirrors xl3 (TS)'s
93/// `readTemplateInputs` and xl3-py's `read_template_inputs`.
94pub 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
100/// In-memory variant for hosts that already have the template bytes
101/// (e.g. WASM `readTemplateInputs(buffer)`).
102pub 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
193/// Describe the rendered output without producing it. Reports the
194/// output filename(s), each sheet name, and a header / row-count
195/// summary of every source the template will consult.
196pub 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
202/// In-memory variant of [`preview`] for hosts that already have the
203/// template and data bytes (e.g. the WASM wrapper).
204pub 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; // skip when sibling repo isn't checked out
288        }
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