use anyhow::Result;
use crate::cli::{InspectCommand, InspectionFormat, SchemaCommand, TablesCommand};
use crate::input_spec::parse_input_specs;
pub(crate) fn run_tables_command(cmd: TablesCommand) -> Result<()> {
let input_specs = parse_input_specs(&cmd.input, cmd.sheet.as_deref())?;
let workbook_inputs = build_workbook_inputs(&input_specs);
let summaries = query_forge::load_table_summaries(&workbook_inputs, 0, false)?;
let output = match cmd.format {
InspectionFormat::Text => render_tables_text(&summaries),
InspectionFormat::Json => render_tables_json(&summaries),
InspectionFormat::Markdown => render_tables_markdown(&summaries),
};
println!("{output}");
Ok(())
}
pub(crate) fn run_schema_command(cmd: SchemaCommand) -> Result<()> {
let input_specs = parse_input_specs(&cmd.input, cmd.sheet.as_deref())?;
let workbook_inputs = build_workbook_inputs(&input_specs);
let summaries = query_forge::load_table_summaries(&workbook_inputs, 0, false)?;
let output = match cmd.format {
InspectionFormat::Text => render_schema_text(&summaries),
InspectionFormat::Json => render_schema_json(&summaries),
InspectionFormat::Markdown => render_schema_markdown(&summaries),
};
println!("{output}");
Ok(())
}
pub(crate) fn run_inspect_command(cmd: InspectCommand) -> Result<()> {
let input_specs = parse_input_specs(&cmd.input, cmd.sheet.as_deref())?;
let workbook_inputs = build_workbook_inputs(&input_specs);
let summaries = query_forge::load_table_summaries(&workbook_inputs, cmd.sample, cmd.stats)?;
let output = match cmd.format {
InspectionFormat::Text => render_inspect_text(&summaries, cmd.stats),
InspectionFormat::Json => render_inspect_json(&summaries, cmd.stats),
InspectionFormat::Markdown => render_inspect_markdown(&summaries, cmd.stats),
};
println!("{output}");
Ok(())
}
fn build_workbook_inputs<'a>(
specs: &'a [crate::input_spec::InputSpec],
) -> Vec<query_forge::WorkbookInput<'a>> {
specs
.iter()
.map(|s| query_forge::WorkbookInput {
path: s.path.as_path(),
sheet_name: s.sheet_name.as_deref(),
table_name: s.table_name.as_deref(),
})
.collect()
}
pub(crate) fn render_tables_text(summaries: &[query_forge::TableSummary]) -> String {
if summaries.is_empty() {
return String::new();
}
let col_table = "table";
let col_source = "source";
let col_selector = "selector";
let col_rows = "rows";
let col_columns = "columns";
let w_table = summaries
.iter()
.map(|s| s.table_name.len())
.max()
.unwrap_or(0)
.max(col_table.len());
let w_source = summaries
.iter()
.map(|s| s.source_path.len())
.max()
.unwrap_or(0)
.max(col_source.len());
let w_selector = summaries
.iter()
.map(|s| s.source_selector.as_deref().unwrap_or("").len())
.max()
.unwrap_or(0)
.max(col_selector.len());
let w_rows = summaries
.iter()
.map(|s| s.row_count.to_string().len())
.max()
.unwrap_or(0)
.max(col_rows.len());
let w_columns = summaries
.iter()
.map(|s| s.columns.len().to_string().len())
.max()
.unwrap_or(0)
.max(col_columns.len());
let mut out = String::new();
out.push_str(&format!(
"{:<w_table$} {:<w_source$} {:<w_selector$} {:>w_rows$} {:>w_columns$}",
col_table, col_source, col_selector, col_rows, col_columns,
));
out.push('\n');
out.push_str(&format!(
"{} {} {} {} {}",
"-".repeat(w_table),
"-".repeat(w_source),
"-".repeat(w_selector),
"-".repeat(w_rows),
"-".repeat(w_columns),
));
for s in summaries {
out.push('\n');
out.push_str(&format!(
"{:<w_table$} {:<w_source$} {:<w_selector$} {:>w_rows$} {:>w_columns$}",
s.table_name,
s.source_path,
s.source_selector.as_deref().unwrap_or(""),
s.row_count,
s.columns.len(),
));
}
out
}
pub(crate) fn render_tables_json(summaries: &[query_forge::TableSummary]) -> String {
let mut out = String::from("[\n");
for (i, s) in summaries.iter().enumerate() {
if i > 0 {
out.push_str(",\n");
}
let selector = s
.source_selector
.as_deref()
.map(json_string)
.unwrap_or_else(|| "null".to_owned());
out.push_str(&format!(
" {{\"table\":{},\"source\":{},\"selector\":{},\"rows\":{},\"columns\":{}}}",
json_string(&s.table_name),
json_string(&s.source_path),
selector,
s.row_count,
s.columns.len(),
));
}
out.push_str("\n]");
out
}
pub(crate) fn render_tables_markdown(summaries: &[query_forge::TableSummary]) -> String {
let mut out = String::from("| table | source | selector | rows | columns |\n");
out.push_str("| --- | --- | --- | --- | --- |\n");
for s in summaries {
out.push_str(&format!(
"| {} | {} | {} | {} | {} |\n",
md_cell(&s.table_name),
md_cell(&s.source_path),
md_cell(s.source_selector.as_deref().unwrap_or("")),
s.row_count,
s.columns.len(),
));
}
out.trim_end_matches('\n').to_owned()
}
pub(crate) fn render_schema_text(summaries: &[query_forge::TableSummary]) -> String {
if summaries.is_empty() {
return String::new();
}
let col_table = "table";
let col_column = "column";
let col_type = "type";
let col_nullable = "nullable";
let all_cols: Vec<&query_forge::ColumnInfo> =
summaries.iter().flat_map(|s| &s.columns).collect();
let w_table = all_cols
.iter()
.map(|c| c.table_name.len())
.max()
.unwrap_or(0)
.max(col_table.len());
let w_column = all_cols
.iter()
.map(|c| c.column_name.len())
.max()
.unwrap_or(0)
.max(col_column.len());
let w_type = all_cols
.iter()
.map(|c| c.inferred_type.as_str().len())
.max()
.unwrap_or(0)
.max(col_type.len());
let w_nullable = col_nullable.len();
let mut out = String::new();
out.push_str(&format!(
"{:<w_table$} {:<w_column$} {:<w_type$} {:<w_nullable$}",
col_table, col_column, col_type, col_nullable,
));
out.push('\n');
out.push_str(&format!(
"{} {} {} {}",
"-".repeat(w_table),
"-".repeat(w_column),
"-".repeat(w_type),
"-".repeat(w_nullable),
));
for c in all_cols {
out.push('\n');
out.push_str(&format!(
"{:<w_table$} {:<w_column$} {:<w_type$} {:<w_nullable$}",
c.table_name,
c.column_name,
c.inferred_type.as_str(),
if c.nullable { "true" } else { "false" },
));
}
out
}
pub(crate) fn render_schema_json(summaries: &[query_forge::TableSummary]) -> String {
let mut out = String::from("[\n");
let mut first = true;
for s in summaries {
for c in &s.columns {
if !first {
out.push_str(",\n");
}
first = false;
out.push_str(&format!(
" {{\"table\":{},\"column\":{},\"type\":{},\"nullable\":{}}}",
json_string(&c.table_name),
json_string(&c.column_name),
json_string(c.inferred_type.as_str()),
c.nullable,
));
}
}
out.push_str("\n]");
out
}
pub(crate) fn render_schema_markdown(summaries: &[query_forge::TableSummary]) -> String {
let mut out = String::from("| table | column | type | nullable |\n");
out.push_str("| --- | --- | --- | --- |\n");
for s in summaries {
for c in &s.columns {
out.push_str(&format!(
"| {} | {} | {} | {} |\n",
md_cell(&c.table_name),
md_cell(&c.column_name),
c.inferred_type.as_str(),
c.nullable,
));
}
}
out.trim_end_matches('\n').to_owned()
}
pub(crate) fn render_inspect_text(
summaries: &[query_forge::TableSummary],
include_stats: bool,
) -> String {
let mut out = String::new();
for (idx, s) in summaries.iter().enumerate() {
if idx > 0 {
out.push('\n');
}
let selector_part = s
.source_selector
.as_deref()
.map(|sel| format!(":{sel}"))
.unwrap_or_default();
out.push_str(&format!(
"=== {} ({}{}) ===\n",
s.table_name, s.source_path, selector_part
));
out.push_str(&format!(
"Rows: {} Columns: {}\n",
s.row_count,
s.columns.len()
));
if !s.columns.is_empty() {
out.push('\n');
out.push_str("Schema:\n");
let w_name = s
.columns
.iter()
.map(|c| c.column_name.len())
.max()
.unwrap_or(0);
let w_type = s
.columns
.iter()
.map(|c| c.inferred_type.as_str().len())
.max()
.unwrap_or(0);
for (i, c) in s.columns.iter().enumerate() {
let nullability = if c.nullable { "nullable" } else { "not null" };
let stats_part = if include_stats {
s.column_stats
.as_ref()
.and_then(|cs| cs.get(i))
.map(|cs| {
let mut parts = vec![format!("distinct: {}", cs.distinct_count)];
if let Some(min) = &cs.min_value {
parts.push(format!("min: {min}"));
}
if let Some(max) = &cs.max_value {
parts.push(format!("max: {max}"));
}
format!(" ({})", parts.join(", "))
})
.unwrap_or_default()
} else {
String::new()
};
out.push_str(&format!(
" {:<w_name$} {:<w_type$} {}{}",
c.column_name,
c.inferred_type.as_str(),
nullability,
stats_part,
));
out.push('\n');
}
}
if !s.sample_rows.is_empty() {
out.push('\n');
out.push_str(&format!("Sample ({} rows):\n", s.sample_rows.len()));
let col_names: Vec<&str> = s.columns.iter().map(|c| c.column_name.as_str()).collect();
let col_count = col_names.len();
let mut widths: Vec<usize> = col_names.iter().map(|h| h.len()).collect();
for row in &s.sample_rows {
for (j, val) in row.iter().enumerate() {
if j < widths.len() {
widths[j] = widths[j].max(display_query_value(val).len());
}
}
}
let header_line: String = col_names
.iter()
.enumerate()
.map(|(j, h)| format!("{:w$}", h, w = widths[j]))
.collect::<Vec<_>>()
.join(" | ");
out.push_str(&format!(" {header_line}\n"));
let sep_line: String = widths
.iter()
.map(|w| "-".repeat(*w))
.collect::<Vec<_>>()
.join("-+-");
out.push_str(&format!(" {sep_line}\n"));
for row in &s.sample_rows {
let row_line: String = (0..col_count)
.map(|j| {
let val = row.get(j).map_or(String::new(), display_query_value);
format!("{:w$}", val, w = widths[j])
})
.collect::<Vec<_>>()
.join(" | ");
out.push_str(&format!(" {row_line}\n"));
}
}
if !s.warnings.is_empty() {
out.push('\n');
out.push_str("Warnings:\n");
for w in &s.warnings {
out.push_str(&format!(" ! {w}\n"));
}
}
}
out.trim_end_matches('\n').to_owned()
}
pub(crate) fn render_inspect_json(
summaries: &[query_forge::TableSummary],
include_stats: bool,
) -> String {
let mut out = String::from("[\n");
for (i, s) in summaries.iter().enumerate() {
if i > 0 {
out.push_str(",\n");
}
let selector = s
.source_selector
.as_deref()
.map(json_string)
.unwrap_or_else(|| "null".to_owned());
out.push_str(" {\n");
out.push_str(&format!(" \"table\":{},\n", json_string(&s.table_name)));
out.push_str(&format!(
" \"source\":{},\n",
json_string(&s.source_path)
));
out.push_str(&format!(" \"selector\":{selector},\n"));
out.push_str(&format!(" \"rows\":{},\n", s.row_count));
out.push_str(&format!(" \"columns\":{},\n", s.columns.len()));
out.push_str(" \"schema\":[\n");
for (j, c) in s.columns.iter().enumerate() {
if j > 0 {
out.push_str(",\n");
}
if include_stats {
let stats_part = s
.column_stats
.as_ref()
.and_then(|cs| cs.get(j))
.map(|cs| {
let min = cs
.min_value
.as_deref()
.map(json_string)
.unwrap_or_else(|| "null".to_owned());
let max = cs
.max_value
.as_deref()
.map(json_string)
.unwrap_or_else(|| "null".to_owned());
format!(
",\"distinct\":{},\"min\":{min},\"max\":{max}",
cs.distinct_count
)
})
.unwrap_or_default();
out.push_str(&format!(
" {{\"column\":{},\"type\":{},\"nullable\":{}{}}}",
json_string(&c.column_name),
json_string(c.inferred_type.as_str()),
c.nullable,
stats_part,
));
} else {
out.push_str(&format!(
" {{\"column\":{},\"type\":{},\"nullable\":{}}}",
json_string(&c.column_name),
json_string(c.inferred_type.as_str()),
c.nullable,
));
}
}
out.push_str("\n ],\n");
out.push_str(" \"sample\":[\n");
for (r, row) in s.sample_rows.iter().enumerate() {
if r > 0 {
out.push_str(",\n");
}
out.push_str(" {");
for (j, c) in s.columns.iter().enumerate() {
if j > 0 {
out.push(',');
}
let val = row.get(j);
let json_val = match val {
Some(query_forge::QueryValue::Null) | None => "null".to_owned(),
Some(query_forge::QueryValue::Integer(n)) => n.to_string(),
Some(query_forge::QueryValue::Real(n)) => n.to_string(),
Some(query_forge::QueryValue::Text(t)) => json_string(t),
};
out.push_str(&format!("{}:{json_val}", json_string(&c.column_name)));
}
out.push('}');
}
out.push_str("\n ],\n");
out.push_str(" \"warnings\":[\n");
for (w, warning) in s.warnings.iter().enumerate() {
if w > 0 {
out.push_str(",\n");
}
out.push_str(&format!(" {}", json_string(warning)));
}
out.push_str("\n ]\n");
out.push_str(" }");
}
out.push_str("\n]");
out
}
pub(crate) fn render_inspect_markdown(
summaries: &[query_forge::TableSummary],
include_stats: bool,
) -> String {
let mut out = String::new();
for (idx, s) in summaries.iter().enumerate() {
if idx > 0 {
out.push_str("\n\n");
}
let selector_part = s
.source_selector
.as_deref()
.map(|sel| format!(":{sel}"))
.unwrap_or_default();
out.push_str(&format!(
"### {} (`{}{}`)\n\n",
s.table_name, s.source_path, selector_part
));
out.push_str(&format!(
"Rows: {} | Columns: {}\n\n",
s.row_count,
s.columns.len()
));
if !s.columns.is_empty() {
if include_stats {
out.push_str("| column | type | nullable | distinct | min | max |\n");
out.push_str("| --- | --- | --- | --- | --- | --- |\n");
for (j, c) in s.columns.iter().enumerate() {
let cs = s.column_stats.as_ref().and_then(|cs| cs.get(j));
let distinct = cs.map_or("-".to_owned(), |cs| cs.distinct_count.to_string());
let min = cs.and_then(|cs| cs.min_value.as_deref()).unwrap_or("-");
let max = cs.and_then(|cs| cs.max_value.as_deref()).unwrap_or("-");
out.push_str(&format!(
"| {} | {} | {} | {} | {} | {} |\n",
md_cell(&c.column_name),
c.inferred_type.as_str(),
c.nullable,
distinct,
md_cell(min),
md_cell(max),
));
}
} else {
out.push_str("| column | type | nullable |\n");
out.push_str("| --- | --- | --- |\n");
for c in &s.columns {
out.push_str(&format!(
"| {} | {} | {} |\n",
md_cell(&c.column_name),
c.inferred_type.as_str(),
c.nullable,
));
}
}
out.push('\n');
}
if !s.sample_rows.is_empty() {
let headers: Vec<String> = s.columns.iter().map(|c| md_cell(&c.column_name)).collect();
out.push_str(&format!("| {} |\n", headers.join(" | ")));
out.push_str(&format!(
"| {} |\n",
s.columns
.iter()
.map(|_| "---")
.collect::<Vec<_>>()
.join(" | ")
));
for row in &s.sample_rows {
let cells: Vec<String> = (0..s.columns.len())
.map(|j| md_cell(&row.get(j).map_or(String::new(), display_query_value)))
.collect();
out.push_str(&format!("| {} |\n", cells.join(" | ")));
}
out.push('\n');
}
if !s.warnings.is_empty() {
out.push_str("**Warnings:**\n\n");
for w in &s.warnings {
out.push_str(&format!("- ⚠️ {w}\n"));
}
}
}
out.trim_end_matches('\n').to_owned()
}
fn display_query_value(val: &query_forge::QueryValue) -> String {
match val {
query_forge::QueryValue::Null => String::new(),
query_forge::QueryValue::Integer(n) => n.to_string(),
query_forge::QueryValue::Real(n) => n.to_string(),
query_forge::QueryValue::Text(t) => t.clone(),
}
}
fn json_string(s: &str) -> String {
let mut out = String::from('"');
for c in s.chars() {
match c {
'"' => out.push_str("\\\""),
'\\' => out.push_str("\\\\"),
'\n' => out.push_str("\\n"),
'\r' => out.push_str("\\r"),
'\t' => out.push_str("\\t"),
c if c <= '\u{1F}' => {
let _ = std::fmt::Write::write_fmt(&mut out, format_args!("\\u{:04x}", c as u32));
}
c => out.push(c),
}
}
out.push('"');
out
}
fn md_cell(s: &str) -> String {
s.replace('|', "\\|").replace('\n', "<br>")
}
#[cfg(test)]
mod tests {
use super::*;
use query_forge::{ColumnInfo, ColumnStats, InferredType, QueryValue, TableSummary};
fn make_summary(name: &str, src: &str, selector: Option<&str>) -> TableSummary {
TableSummary {
table_name: name.to_owned(),
source_path: src.to_owned(),
source_selector: selector.map(str::to_owned),
row_count: 2,
columns: vec![
ColumnInfo {
table_name: name.to_owned(),
column_name: "id".to_owned(),
inferred_type: InferredType::Integer,
nullable: false,
},
ColumnInfo {
table_name: name.to_owned(),
column_name: "name".to_owned(),
inferred_type: InferredType::Text,
nullable: false,
},
ColumnInfo {
table_name: name.to_owned(),
column_name: "price".to_owned(),
inferred_type: InferredType::Real,
nullable: true,
},
],
sample_rows: vec![
vec![
QueryValue::Integer(1),
QueryValue::Text("Keyboard".to_owned()),
QueryValue::Real(12.5),
],
vec![
QueryValue::Integer(2),
QueryValue::Text("Cable".to_owned()),
QueryValue::Null,
],
],
warnings: vec![],
column_stats: None,
}
}
#[test]
fn tables_text_contains_header_and_rows() {
let s = make_summary("sales", "./sales.xlsx", Some("Q1"));
let out = render_tables_text(&[s]);
assert!(out.contains("table"), "should contain 'table' header");
assert!(out.contains("sales"), "should contain table name");
assert!(out.contains("./sales.xlsx"), "should contain source path");
assert!(out.contains("Q1"), "should contain selector");
assert!(out.contains("2"), "should contain row count");
}
#[test]
fn tables_json_is_valid_structure() {
let s = make_summary("sales", "./sales.xlsx", Some("Q1"));
let out = render_tables_json(&[s]);
assert!(out.starts_with('['), "should be JSON array");
assert!(out.contains("\"table\":\"sales\""));
assert!(out.contains("\"selector\":\"Q1\""));
assert!(out.contains("\"rows\":2"));
}
#[test]
fn tables_json_null_selector_when_none() {
let s = make_summary("costs", "./costs.csv", None);
let out = render_tables_json(&[s]);
assert!(out.contains("\"selector\":null"));
}
#[test]
fn tables_markdown_has_pipe_rows() {
let s = make_summary("sales", "./sales.xlsx", Some("Q1"));
let out = render_tables_markdown(&[s]);
assert!(out.starts_with("| table |"), "should have header row");
assert!(out.contains("| sales |"));
}
#[test]
fn schema_text_contains_all_columns() {
let s = make_summary("sales", "./sales.xlsx", Some("Q1"));
let out = render_schema_text(&[s]);
assert!(out.contains("id"), "should list id column");
assert!(out.contains("name"), "should list name column");
assert!(out.contains("price"), "should list price column");
assert!(out.contains("INTEGER"), "should show INTEGER type");
assert!(out.contains("TEXT"), "should show TEXT type");
assert!(out.contains("REAL"), "should show REAL type");
assert!(out.contains("true"), "should show nullable true for price");
assert!(out.contains("false"), "should show nullable false for id");
}
#[test]
fn schema_json_lists_all_columns() {
let s = make_summary("sales", "./sales.xlsx", Some("Q1"));
let out = render_schema_json(&[s]);
assert!(out.contains("\"column\":\"id\""));
assert!(out.contains("\"type\":\"INTEGER\""));
assert!(out.contains("\"nullable\":false"));
assert!(out.contains("\"nullable\":true"));
}
#[test]
fn schema_markdown_has_pipe_rows() {
let s = make_summary("sales", "./sales.xlsx", Some("Q1"));
let out = render_schema_markdown(&[s]);
assert!(out.starts_with("| table |"), "should have table header");
assert!(out.contains("| sales | id | INTEGER | false |"));
}
#[test]
fn inspect_text_has_section_headers() {
let s = make_summary("sales", "./sales.xlsx", Some("Q1"));
let out = render_inspect_text(&[s], false);
assert!(out.contains("=== sales"), "should have section header");
assert!(out.contains("Rows: 2"), "should show row count");
assert!(out.contains("Schema:"), "should have schema section");
assert!(out.contains("Sample ("), "should have sample section");
assert!(out.contains("Keyboard"), "should show sample data");
}
#[test]
fn inspect_text_with_stats_shows_extra_info() {
let mut s = make_summary("sales", "./sales.xlsx", Some("Q1"));
s.column_stats = Some(vec![
ColumnStats {
distinct_count: 2,
min_value: Some("1".to_owned()),
max_value: Some("2".to_owned()),
},
ColumnStats {
distinct_count: 2,
min_value: None,
max_value: None,
},
ColumnStats {
distinct_count: 1,
min_value: Some("12.5".to_owned()),
max_value: Some("12.5".to_owned()),
},
]);
let out = render_inspect_text(&[s], true);
assert!(out.contains("distinct: 2"), "should show distinct count");
assert!(out.contains("min: 1"), "should show min value");
assert!(out.contains("max: 2"), "should show max value");
}
#[test]
fn inspect_json_has_all_sections() {
let s = make_summary("sales", "./sales.xlsx", Some("Q1"));
let out = render_inspect_json(&[s], false);
assert!(out.contains("\"schema\":["), "should have schema section");
assert!(out.contains("\"sample\":["), "should have sample section");
assert!(
out.contains("\"warnings\":["),
"should have warnings section"
);
}
#[test]
fn inspect_markdown_has_headings() {
let s = make_summary("sales", "./sales.xlsx", Some("Q1"));
let out = render_inspect_markdown(&[s], false);
assert!(out.contains("### sales"), "should have markdown heading");
assert!(out.contains("Rows: 2"), "should show row count");
}
#[test]
fn inspect_text_shows_warnings() {
let mut s = make_summary("sales", "./sales.xlsx", None);
s.warnings.push("column 'price' has mixed types".to_owned());
let out = render_inspect_text(&[s], false);
assert!(out.contains("Warnings:"), "should have warnings section");
assert!(out.contains("mixed types"), "should show warning text");
}
#[test]
fn multi_table_inspect_json_is_array() {
let s1 = make_summary("sales", "./sales.xlsx", Some("Q1"));
let s2 = make_summary("costs", "./costs.csv", None);
let out = render_inspect_json(&[s1, s2], false);
assert!(out.starts_with('['));
assert!(out.contains("\"table\":\"sales\""));
assert!(out.contains("\"table\":\"costs\""));
}
}