use crate::error::{ErrorCode, McpError};
use crate::ingest::{detect_file_format, normalize_json_or_jsonl, InferredFileFormat};
use crate::ingest_arrow::arrow_schema_to_columns;
use crate::schema::{infer_csv_schema, infer_json_schema, widen_csv_numeric_columns, ColumnSchema};
use arrow::datatypes::Schema as ArrowSchema;
use serde_json::{json, Value};
use std::path::Path;
use std::sync::Arc;
pub const DEFAULT_SAMPLE_ROWS: usize = 5;
#[derive(Debug, Default, Clone)]
pub struct ColumnStats {
pub null_count: u64,
pub min_i128: Option<i128>,
pub max_i128: Option<i128>,
pub min_f64: Option<f64>,
pub max_f64: Option<f64>,
pub sample_values: Vec<String>,
}
#[derive(Debug, Clone)]
pub struct InspectReport {
pub columns: Vec<ColumnSchema>,
pub stats: Vec<ColumnStats>,
pub row_count: u64,
pub file_format: String,
pub file_size_bytes: u64,
pub sample_rows: Vec<Vec<String>>,
}
impl InspectReport {
#[must_use]
pub fn to_json(&self) -> Value {
let columns: Vec<Value> = self
.columns
.iter()
.zip(self.stats.iter())
.map(|(col, s)| {
let mut obj = json!({
"name": col.name,
"type": col.hyper_type,
"nullable": col.nullable,
"null_count": s.null_count,
"sample_values": s.sample_values,
});
if let Some(m) = s.min_i128 {
if let Ok(n) = i64::try_from(m) {
obj["min"] = json!(n);
} else {
obj["min"] = json!(m.to_string());
}
}
if let Some(m) = s.max_i128 {
if let Ok(n) = i64::try_from(m) {
obj["max"] = json!(n);
} else {
obj["max"] = json!(m.to_string());
}
}
if let Some(m) = s.min_f64 {
obj["min_f64"] = json!(m);
}
if let Some(m) = s.max_f64 {
obj["max_f64"] = json!(m);
}
obj
})
.collect();
json!({
"columns": columns,
"row_count": self.row_count,
"file_format": self.file_format,
"file_size_bytes": self.file_size_bytes,
"sample_rows": self.sample_rows.iter().map(|row| {
self.columns.iter().zip(row.iter()).map(|(c, v)| (c.name.clone(), Value::String(v.clone()))).collect::<serde_json::Map<_, _>>()
}).collect::<Vec<_>>(),
})
}
}
pub fn inspect_source(path: &str, sample_rows: usize) -> Result<InspectReport, McpError> {
let file_path = Path::new(path);
if !file_path.exists() {
return Err(McpError::new(
ErrorCode::FileNotFound,
format!("File not found: {path}"),
));
}
let file_size = std::fs::metadata(file_path).map_or(0, |m| m.len());
match detect_file_format(file_path) {
InferredFileFormat::Parquet => inspect_parquet(path, file_size),
InferredFileFormat::ArrowIpc => inspect_arrow_ipc(path, file_size),
InferredFileFormat::Json => inspect_json(path, file_size, sample_rows),
InferredFileFormat::Csv => inspect_csv(path, file_size, sample_rows),
}
}
fn inspect_json(path: &str, file_size: u64, sample_rows: usize) -> Result<InspectReport, McpError> {
let raw = std::fs::read_to_string(path)
.map_err(|e| McpError::new(ErrorCode::FileNotFound, format!("Cannot read file: {e}")))?;
inspect_json_from_text(&raw, file_size, sample_rows)
}
pub fn inspect_json_from_text(
raw: &str,
file_size: u64,
sample_rows: usize,
) -> Result<InspectReport, McpError> {
let is_array = raw.trim_start().starts_with('[');
let file_format = if is_array { "json" } else { "jsonl" };
let array_text = normalize_json_or_jsonl(raw)?;
let columns = infer_json_schema(&array_text)?;
let array: Vec<Value> = serde_json::from_str(&array_text)
.map_err(|e| McpError::new(ErrorCode::SchemaMismatch, format!("Invalid JSON: {e}")))?;
let sample_cap = sample_rows.max(1);
let mut stats: Vec<ColumnStats> = columns.iter().map(|_| ColumnStats::default()).collect();
let mut sample_rows_out: Vec<Vec<String>> = Vec::new();
let row_count = array.len() as u64;
for obj in &array {
let Some(object) = obj.as_object() else {
continue;
};
if sample_rows_out.len() < sample_cap {
let mut row_vals = Vec::with_capacity(columns.len());
for col in &columns {
row_vals.push(value_preview(object.get(&col.name)));
}
sample_rows_out.push(row_vals);
}
for (idx, col) in columns.iter().enumerate() {
let s = &mut stats[idx];
match object.get(&col.name) {
None | Some(Value::Null) => s.null_count += 1,
Some(v) => {
if s.sample_values.len() < sample_cap {
s.sample_values.push(value_preview(Some(v)));
}
}
}
}
}
Ok(InspectReport {
columns,
stats,
row_count,
file_format: file_format.into(),
file_size_bytes: file_size,
sample_rows: sample_rows_out,
})
}
fn value_preview(v: Option<&Value>) -> String {
const MAX_LEN: usize = 120;
let raw = match v {
None | Some(Value::Null) => String::new(),
Some(Value::String(s)) => s.clone(),
Some(Value::Number(n)) => n.to_string(),
Some(Value::Bool(b)) => b.to_string(),
Some(other) => other.to_string(),
};
if raw.len() > MAX_LEN {
let mut s = raw[..MAX_LEN].to_string();
s.push('…');
s
} else {
raw
}
}
fn inspect_csv(path: &str, file_size: u64, sample_rows: usize) -> Result<InspectReport, McpError> {
let text = std::fs::read_to_string(path)
.map_err(|e| McpError::new(ErrorCode::FileNotFound, format!("Cannot read file: {e}")))?;
let mut columns = infer_csv_schema(&text, true)?;
widen_csv_numeric_columns(text.as_bytes(), true, &mut columns)?;
let mut stats: Vec<ColumnStats> = columns.iter().map(|_| ColumnStats::default()).collect();
let mut sample_rows_out: Vec<Vec<String>> = Vec::new();
let sample_cap = sample_rows.max(1);
let mut rdr = csv::ReaderBuilder::new()
.has_headers(true)
.from_reader(text.as_bytes());
let mut row_count: u64 = 0;
for result in rdr.records() {
let record = result.map_err(|e| {
McpError::new(
ErrorCode::SchemaMismatch,
format!("CSV parse error at row {}: {e}", row_count + 1),
)
})?;
row_count += 1;
if sample_rows_out.len() < sample_cap {
sample_rows_out.push(
record
.iter()
.map(std::string::ToString::to_string)
.collect(),
);
}
for (col_idx, col) in columns.iter().enumerate() {
let s = &mut stats[col_idx];
let raw = record.get(col_idx).unwrap_or("");
let trimmed = raw.trim();
if trimmed.is_empty()
|| trimmed.eq_ignore_ascii_case("null")
|| trimmed.eq_ignore_ascii_case("na")
{
s.null_count += 1;
continue;
}
if s.sample_values.len() < sample_cap {
s.sample_values.push(trimmed.to_string());
}
if matches!(
col.hyper_type.as_str(),
"INT" | "INTEGER" | "BIGINT" | "NUMERIC(38,0)"
) {
if let Ok(n) = trimmed.parse::<i128>() {
s.min_i128 = Some(s.min_i128.map_or(n, |m| m.min(n)));
s.max_i128 = Some(s.max_i128.map_or(n, |m| m.max(n)));
}
} else if col.hyper_type == "DOUBLE PRECISION" {
if let Ok(n) = trimmed.parse::<f64>() {
s.min_f64 = Some(s.min_f64.map_or(n, |m| m.min(n)));
s.max_f64 = Some(s.max_f64.map_or(n, |m| m.max(n)));
}
}
}
}
Ok(InspectReport {
columns,
stats,
row_count,
file_format: "csv".into(),
file_size_bytes: file_size,
sample_rows: sample_rows_out,
})
}
fn inspect_parquet(path: &str, file_size: u64) -> Result<InspectReport, McpError> {
let file = std::fs::File::open(path)
.map_err(|e| McpError::new(ErrorCode::FileNotFound, format!("Cannot open file: {e}")))?;
let builder = parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder::try_new(file)
.map_err(|e| {
McpError::new(
ErrorCode::UnsupportedFormat,
format!("Invalid Parquet file: {e}"),
)
})?;
let arrow_schema: Arc<ArrowSchema> = Arc::clone(builder.schema());
let row_count = u64::try_from(builder.metadata().file_metadata().num_rows()).unwrap_or(0);
let columns = arrow_schema_to_columns(&arrow_schema);
let stats = vec![ColumnStats::default(); columns.len()];
Ok(InspectReport {
columns,
stats,
row_count,
file_format: "parquet".into(),
file_size_bytes: file_size,
sample_rows: Vec::new(),
})
}
fn inspect_arrow_ipc(path: &str, file_size: u64) -> Result<InspectReport, McpError> {
let file = std::fs::File::open(path)
.map_err(|e| McpError::new(ErrorCode::FileNotFound, format!("Cannot open file: {e}")))?;
let reader = arrow::ipc::reader::FileReader::try_new(file, None).map_err(|e| {
McpError::new(
ErrorCode::UnsupportedFormat,
format!("Invalid Arrow IPC file: {e}"),
)
})?;
let arrow_schema: Arc<ArrowSchema> = reader.schema();
let row_count: u64 = reader
.into_iter()
.map(|b| b.map_or(0, |rb| rb.num_rows() as u64))
.sum();
let columns = arrow_schema_to_columns(&arrow_schema);
let stats = vec![ColumnStats::default(); columns.len()];
Ok(InspectReport {
columns,
stats,
row_count,
file_format: "arrow_ipc".into(),
file_size_bytes: file_size,
sample_rows: Vec::new(),
})
}