Skip to main content

sheeter_engine/
view.rs

1//! Table rendering and multi-source loading for `sheeter view`.
2//!
3//! [`expand`] reads a file, detects its format, decodes it into the canonical
4//! hub, and returns one `(label, value)` entry per logical table. For
5//! single-table formats (CSV, TSV, JSON, NDJSON, YAML, XML, TOML, single-table
6//! SQL) the label is the filename stem. For XLSX and ODS, every sheet becomes
7//! its own entry, labelled `<stem> (<sheet>)`.
8//!
9//! [`render`] produces either a sequence of formatted tables (one per entry)
10//! or a JSON document. With `to_json = false`, each entry is prefixed with
11//! `Output table No. N — <label>` and rendered as a UTF-8 box table. With
12//! `to_json = true` and exactly one entry, the raw hub JSON is emitted; with
13//! multiple entries, a JSON object keyed by label is emitted.
14//!
15//! # Examples
16//!
17//! ```
18//! use sheeter_engine::view;
19//! use std::fs;
20//!
21//! let dir = tempfile::tempdir().unwrap();
22//! fs::write(dir.path().join("a.csv"), "name,age\nAlice,30\n").unwrap();
23//! let expanded = view::expand(dir.path().join("a.csv").to_str().unwrap()).unwrap();
24//! assert_eq!(expanded.len(), 1);
25//! assert_eq!(expanded[0].0, "a");
26//! let rendered = view::render(expanded, false).unwrap();
27//! assert!(rendered.contains("Output table No. 1"));
28//! assert!(rendered.contains("Alice"));
29//! ```
30
31use crate::{detect_content, detect_extension, CanonicalValue, Format, SqlDialect};
32use comfy_table::{presets, Cell, CellAlignment, ContentArrangement, Table};
33use std::error::Error;
34use std::path::Path;
35
36/// Loads a file and returns one `(label, value)` pair per logical table.
37///
38/// Multi-sheet XLSX and ODS inputs produce multiple entries, one per sheet.
39/// All other formats produce exactly one entry. The label is the filename
40/// stem for single-table inputs, and `<stem> (<sheet>)` for multi-sheet
41/// inputs.
42///
43/// # Errors
44///
45/// Returns an error if the file cannot be read, the format cannot be
46/// detected, or the format parser rejects the contents.
47pub fn expand(path: &str) -> Result<Vec<(String, CanonicalValue)>, Box<dyn Error>> {
48    let bytes = std::fs::read(path)?;
49    let fmt = match detect_extension(Path::new(path)) {
50        Some(f) => f,
51        None => detect_content(&bytes)?,
52    };
53    let stem = Path::new(path)
54        .file_stem()
55        .and_then(|s| s.to_str())
56        .unwrap_or(path)
57        .to_string();
58
59    match fmt {
60        Format::Xlsx => {
61            let sheets = CanonicalValue::from_xlsx_workbook(&bytes)?;
62            Ok(sheets
63                .into_iter()
64                .map(|(name, v)| (format!("{stem} ({name})"), v))
65                .collect())
66        }
67        Format::Ods => {
68            let v = CanonicalValue::from_ods(&bytes)?;
69            match v {
70                CanonicalValue::Object(o) if o.len() > 1 => Ok(o
71                    .into_iter()
72                    .map(|(name, v)| (format!("{stem} ({name})"), v))
73                    .collect()),
74                other => Ok(vec![(stem, other)]),
75            }
76        }
77        other => {
78            let text = std::str::from_utf8(&bytes)?;
79            let hub = decode_text(other, text)?;
80            if let CanonicalValue::Object(o) = &hub {
81                if other == Format::Sql && o.len() > 1 {
82                    let owned = hub.clone();
83                    let CanonicalValue::Object(inner) = owned else {
84                        unreachable!()
85                    };
86                    return Ok(inner
87                        .into_iter()
88                        .map(|(name, v)| (format!("{stem} ({name})"), v))
89                        .collect());
90                }
91            }
92            Ok(vec![(stem, hub)])
93        }
94    }
95}
96
97fn decode_text(fmt: Format, text: &str) -> Result<CanonicalValue, Box<dyn Error>> {
98    match fmt {
99        Format::Csv => CanonicalValue::from_csv(text),
100        Format::Tsv => CanonicalValue::from_tsv(text),
101        Format::Json => CanonicalValue::from_json(text),
102        Format::Ndjson => CanonicalValue::from_ndjson(text),
103        Format::Yaml => CanonicalValue::from_yaml(text),
104        Format::Xml => CanonicalValue::from_xml(text),
105        Format::Toml => CanonicalValue::from_toml(text),
106        Format::Sql => {
107            let sheets = CanonicalValue::from_sql_workbook(text, SqlDialect::Generic)?;
108            if sheets.len() == 1 {
109                let mut it = sheets.into_iter();
110                Ok(it.next().unwrap().1)
111            } else {
112                Ok(CanonicalValue::Object(sheets))
113            }
114        }
115        Format::Xlsx | Format::Ods => Err(format!("{fmt:?} requires binary decode").into()),
116    }
117}
118
119/// Renders the loaded sources as formatted tables or as JSON.
120///
121/// # Examples
122///
123/// ```
124/// use sheeter_engine::view;
125/// use std::fs;
126///
127/// let dir = tempfile::tempdir().unwrap();
128/// fs::write(dir.path().join("a.csv"), "x\n1\n").unwrap();
129/// let expanded = view::expand(dir.path().join("a.csv").to_str().unwrap()).unwrap();
130/// let json = view::render(expanded, true).unwrap();
131/// assert_eq!(json.trim(), r#"[{"x":"1"}]"#);
132/// ```
133pub fn render(
134    sources: Vec<(String, CanonicalValue)>,
135    to_json: bool,
136) -> Result<String, Box<dyn Error>> {
137    let mut out = String::new();
138    if to_json {
139        if sources.len() == 1 {
140            out.push_str(&sources[0].1.to_json()?);
141            out.push('\n');
142        } else {
143            let mut map = serde_json::Map::new();
144            for (label, value) in &sources {
145                map.insert(label.clone(), value.to_json_value());
146            }
147            out.push_str(&serde_json::to_string(&map)?);
148            out.push('\n');
149        }
150        return Ok(out);
151    }
152    for (i, (label, value)) in sources.iter().enumerate() {
153        if i > 0 {
154            out.push('\n');
155        }
156        out.push_str(&format!("Output table No. {} — {}\n", i + 1, label));
157        out.push_str(&render_table(value)?);
158        out.push('\n');
159    }
160    Ok(out)
161}
162
163fn render_table(value: &CanonicalValue) -> Result<String, Box<dyn Error>> {
164    let rows: Vec<CanonicalValue> = match value {
165        CanonicalValue::Array(a) => a.clone(),
166        CanonicalValue::Object(o) => vec![CanonicalValue::Object(o.clone())],
167        other => {
168            let mut t = Table::new();
169            t.load_preset(presets::UTF8_FULL);
170            t.add_row(vec![cell_from(other)]);
171            return Ok(t.to_string());
172        }
173    };
174
175    let mut headers: Vec<String> = Vec::new();
176    for row in &rows {
177        if let CanonicalValue::Object(o) = row {
178            for (k, _) in o {
179                if !headers.contains(k) {
180                    headers.push(k.clone());
181                }
182            }
183        }
184    }
185
186    if headers.is_empty() {
187        return Ok(String::new());
188    }
189
190    let mut table = Table::new();
191    table.load_preset(presets::UTF8_FULL);
192    table.set_content_arrangement(ContentArrangement::Dynamic);
193    table.set_header(headers.iter().map(Cell::new));
194
195    for row in &rows {
196        if let CanonicalValue::Object(o) = row {
197            let cells: Vec<Cell> = headers
198                .iter()
199                .map(|h| match o.iter().find(|(k, _)| k == h).map(|(_, v)| v) {
200                    Some(v) => cell_from(v),
201                    None => Cell::new(""),
202                })
203                .collect();
204            table.add_row(cells);
205        }
206    }
207
208    Ok(table.to_string())
209}
210
211fn cell_from(v: &CanonicalValue) -> Cell {
212    match v {
213        CanonicalValue::Null => Cell::new(""),
214        CanonicalValue::Bool(b) => Cell::new(b.to_string()),
215        CanonicalValue::Number(n) => Cell::new(n.to_string()).set_alignment(CellAlignment::Right),
216        CanonicalValue::String(s) => Cell::new(s),
217        CanonicalValue::Bytes(b) => Cell::new(format!("<{} bytes>", b.len())),
218        CanonicalValue::Array(_) | CanonicalValue::Object(_) => Cell::new("<nested>"),
219    }
220}