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        Format::Parquet => {
78            let v = crate::from_parquet(&bytes)?;
79            Ok(vec![(stem, v)])
80        }
81        other => {
82            let text = std::str::from_utf8(&bytes)?;
83            let hub = decode_text(other, text)?;
84            if let CanonicalValue::Object(o) = &hub {
85                if other == Format::Sql && o.len() > 1 {
86                    let owned = hub.clone();
87                    let CanonicalValue::Object(inner) = owned else {
88                        unreachable!()
89                    };
90                    return Ok(inner
91                        .into_iter()
92                        .map(|(name, v)| (format!("{stem} ({name})"), v))
93                        .collect());
94                }
95            }
96            Ok(vec![(stem, hub)])
97        }
98    }
99}
100
101fn decode_text(fmt: Format, text: &str) -> Result<CanonicalValue, Box<dyn Error>> {
102    match fmt {
103        Format::Csv => CanonicalValue::from_csv(text),
104        Format::Tsv => CanonicalValue::from_tsv(text),
105        Format::Json => CanonicalValue::from_json(text),
106        Format::Ndjson => CanonicalValue::from_ndjson(text),
107        Format::Yaml => CanonicalValue::from_yaml(text),
108        Format::Xml => CanonicalValue::from_xml(text),
109        Format::Toml => CanonicalValue::from_toml(text),
110        Format::Sql => {
111            let sheets = CanonicalValue::from_sql_workbook(text, SqlDialect::Generic)?;
112            if sheets.len() == 1 {
113                let mut it = sheets.into_iter();
114                Ok(it.next().unwrap().1)
115            } else {
116                Ok(CanonicalValue::Object(sheets))
117            }
118        }
119        Format::Xlsx | Format::Ods | Format::Parquet => {
120            Err(format!("{fmt:?} requires binary decode").into())
121        }
122    }
123}
124
125/// Renders the loaded sources as formatted tables or as JSON.
126///
127/// # Examples
128///
129/// ```
130/// use sheeter_engine::view;
131/// use std::fs;
132///
133/// let dir = tempfile::tempdir().unwrap();
134/// fs::write(dir.path().join("a.csv"), "x\n1\n").unwrap();
135/// let expanded = view::expand(dir.path().join("a.csv").to_str().unwrap()).unwrap();
136/// let json = view::render(expanded, true).unwrap();
137/// assert_eq!(json.trim(), r#"[{"x":"1"}]"#);
138/// ```
139pub fn render(
140    sources: Vec<(String, CanonicalValue)>,
141    to_json: bool,
142) -> Result<String, Box<dyn Error>> {
143    let mut out = String::new();
144    if to_json {
145        if sources.len() == 1 {
146            out.push_str(&sources[0].1.to_json()?);
147            out.push('\n');
148        } else {
149            let mut map = serde_json::Map::new();
150            for (label, value) in &sources {
151                map.insert(label.clone(), value.to_json_value());
152            }
153            out.push_str(&serde_json::to_string(&map)?);
154            out.push('\n');
155        }
156        return Ok(out);
157    }
158    for (i, (label, value)) in sources.iter().enumerate() {
159        if i > 0 {
160            out.push('\n');
161        }
162        out.push_str(&format!("Output table No. {} — {}\n", i + 1, label));
163        out.push_str(&render_table(value)?);
164        out.push('\n');
165    }
166    Ok(out)
167}
168
169fn render_table(value: &CanonicalValue) -> Result<String, Box<dyn Error>> {
170    let rows: Vec<CanonicalValue> = match value {
171        CanonicalValue::Array(a) => a.clone(),
172        CanonicalValue::Object(o) => vec![CanonicalValue::Object(o.clone())],
173        other => {
174            let mut t = Table::new();
175            t.load_preset(presets::UTF8_FULL);
176            t.add_row(vec![cell_from(other)]);
177            return Ok(t.to_string());
178        }
179    };
180
181    let mut headers: Vec<String> = Vec::new();
182    for row in &rows {
183        if let CanonicalValue::Object(o) = row {
184            for (k, _) in o {
185                if !headers.contains(k) {
186                    headers.push(k.clone());
187                }
188            }
189        }
190    }
191
192    if headers.is_empty() {
193        return Ok(String::new());
194    }
195
196    let mut table = Table::new();
197    table.load_preset(presets::UTF8_FULL);
198    table.set_content_arrangement(ContentArrangement::Dynamic);
199    table.set_header(headers.iter().map(Cell::new));
200
201    for row in &rows {
202        if let CanonicalValue::Object(o) = row {
203            let cells: Vec<Cell> = headers
204                .iter()
205                .map(|h| match o.iter().find(|(k, _)| k == h).map(|(_, v)| v) {
206                    Some(v) => cell_from(v),
207                    None => Cell::new(""),
208                })
209                .collect();
210            table.add_row(cells);
211        }
212    }
213
214    Ok(table.to_string())
215}
216
217fn cell_from(v: &CanonicalValue) -> Cell {
218    match v {
219        CanonicalValue::Null => Cell::new(""),
220        CanonicalValue::Bool(b) => Cell::new(b.to_string()),
221        CanonicalValue::Number(n) => Cell::new(n.to_string()).set_alignment(CellAlignment::Right),
222        CanonicalValue::String(s) => Cell::new(s),
223        CanonicalValue::Bytes(b) => Cell::new(format!("<{} bytes>", b.len())),
224        CanonicalValue::Array(_) | CanonicalValue::Object(_) => Cell::new("<nested>"),
225    }
226}