1use crate::error::{ErrorCode, McpError};
25use crate::ingest::{detect_file_format, normalize_json_or_jsonl, InferredFileFormat};
26use crate::ingest_arrow::arrow_schema_to_columns;
27use crate::schema::{infer_csv_schema, infer_json_schema, widen_csv_numeric_columns, ColumnSchema};
28use arrow::datatypes::Schema as ArrowSchema;
29use serde_json::{json, Value};
30use std::path::Path;
31use std::sync::Arc;
32
33pub const DEFAULT_SAMPLE_ROWS: usize = 5;
37
38#[derive(Debug, Default, Clone)]
40pub struct ColumnStats {
41 pub null_count: u64,
42 pub min_i128: Option<i128>,
46 pub max_i128: Option<i128>,
47 pub min_f64: Option<f64>,
50 pub max_f64: Option<f64>,
51 pub sample_values: Vec<String>,
53}
54
55#[derive(Debug, Clone)]
57pub struct InspectReport {
58 pub columns: Vec<ColumnSchema>,
59 pub stats: Vec<ColumnStats>,
60 pub row_count: u64,
61 pub file_format: String,
62 pub file_size_bytes: u64,
63 pub sample_rows: Vec<Vec<String>>,
66}
67
68impl InspectReport {
69 #[must_use]
71 pub fn to_json(&self) -> Value {
72 let columns: Vec<Value> = self
73 .columns
74 .iter()
75 .zip(self.stats.iter())
76 .map(|(col, s)| {
77 let mut obj = json!({
78 "name": col.name,
79 "type": col.hyper_type,
80 "nullable": col.nullable,
81 "null_count": s.null_count,
82 "sample_values": s.sample_values,
83 });
84 if let Some(m) = s.min_i128 {
85 if let Ok(n) = i64::try_from(m) {
86 obj["min"] = json!(n);
87 } else {
88 obj["min"] = json!(m.to_string());
89 }
90 }
91 if let Some(m) = s.max_i128 {
92 if let Ok(n) = i64::try_from(m) {
93 obj["max"] = json!(n);
94 } else {
95 obj["max"] = json!(m.to_string());
96 }
97 }
98 if let Some(m) = s.min_f64 {
99 obj["min_f64"] = json!(m);
100 }
101 if let Some(m) = s.max_f64 {
102 obj["max_f64"] = json!(m);
103 }
104 obj
105 })
106 .collect();
107 json!({
108 "columns": columns,
109 "row_count": self.row_count,
110 "file_format": self.file_format,
111 "file_size_bytes": self.file_size_bytes,
112 "sample_rows": self.sample_rows.iter().map(|row| {
113 self.columns.iter().zip(row.iter()).map(|(c, v)| (c.name.clone(), Value::String(v.clone()))).collect::<serde_json::Map<_, _>>()
114 }).collect::<Vec<_>>(),
115 })
116 }
117}
118
119pub fn inspect_source(path: &str, sample_rows: usize) -> Result<InspectReport, McpError> {
133 let file_path = Path::new(path);
134 if !file_path.exists() {
135 return Err(McpError::new(
136 ErrorCode::FileNotFound,
137 format!("File not found: {path}"),
138 ));
139 }
140 let file_size = std::fs::metadata(file_path).map_or(0, |m| m.len());
141 match detect_file_format(file_path) {
142 InferredFileFormat::Parquet => inspect_parquet(path, file_size),
143 InferredFileFormat::ArrowIpc => inspect_arrow_ipc(path, file_size),
144 InferredFileFormat::Json => inspect_json(path, file_size, sample_rows),
145 InferredFileFormat::Csv => inspect_csv(path, file_size, sample_rows),
146 }
147}
148
149fn inspect_json(path: &str, file_size: u64, sample_rows: usize) -> Result<InspectReport, McpError> {
167 let raw = std::fs::read_to_string(path)
168 .map_err(|e| McpError::new(ErrorCode::FileNotFound, format!("Cannot read file: {e}")))?;
169 inspect_json_from_text(&raw, file_size, sample_rows)
170}
171
172pub fn inspect_json_from_text(
185 raw: &str,
186 file_size: u64,
187 sample_rows: usize,
188) -> Result<InspectReport, McpError> {
189 let is_array = raw.trim_start().starts_with('[');
193 let file_format = if is_array { "json" } else { "jsonl" };
194
195 let array_text = normalize_json_or_jsonl(raw)?;
196 let columns = infer_json_schema(&array_text)?;
197
198 let array: Vec<Value> = serde_json::from_str(&array_text)
201 .map_err(|e| McpError::new(ErrorCode::SchemaMismatch, format!("Invalid JSON: {e}")))?;
202
203 let sample_cap = sample_rows.max(1);
204 let mut stats: Vec<ColumnStats> = columns.iter().map(|_| ColumnStats::default()).collect();
205 let mut sample_rows_out: Vec<Vec<String>> = Vec::new();
206 let row_count = array.len() as u64;
207
208 for obj in &array {
209 let Some(object) = obj.as_object() else {
210 continue;
211 };
212 if sample_rows_out.len() < sample_cap {
213 let mut row_vals = Vec::with_capacity(columns.len());
214 for col in &columns {
215 row_vals.push(value_preview(object.get(&col.name)));
216 }
217 sample_rows_out.push(row_vals);
218 }
219 for (idx, col) in columns.iter().enumerate() {
220 let s = &mut stats[idx];
221 match object.get(&col.name) {
222 None | Some(Value::Null) => s.null_count += 1,
223 Some(v) => {
224 if s.sample_values.len() < sample_cap {
225 s.sample_values.push(value_preview(Some(v)));
226 }
227 }
228 }
229 }
230 }
231
232 Ok(InspectReport {
233 columns,
234 stats,
235 row_count,
236 file_format: file_format.into(),
237 file_size_bytes: file_size,
238 sample_rows: sample_rows_out,
239 })
240}
241
242fn value_preview(v: Option<&Value>) -> String {
248 const MAX_LEN: usize = 120;
249 let raw = match v {
250 None | Some(Value::Null) => String::new(),
251 Some(Value::String(s)) => s.clone(),
252 Some(Value::Number(n)) => n.to_string(),
253 Some(Value::Bool(b)) => b.to_string(),
254 Some(other) => other.to_string(),
255 };
256 if raw.len() > MAX_LEN {
257 let mut s = raw[..MAX_LEN].to_string();
258 s.push('…');
259 s
260 } else {
261 raw
262 }
263}
264
265fn inspect_csv(path: &str, file_size: u64, sample_rows: usize) -> Result<InspectReport, McpError> {
270 let text = std::fs::read_to_string(path)
271 .map_err(|e| McpError::new(ErrorCode::FileNotFound, format!("Cannot read file: {e}")))?;
272
273 let mut columns = infer_csv_schema(&text, true)?;
274 widen_csv_numeric_columns(text.as_bytes(), true, &mut columns)?;
275
276 let mut stats: Vec<ColumnStats> = columns.iter().map(|_| ColumnStats::default()).collect();
277 let mut sample_rows_out: Vec<Vec<String>> = Vec::new();
278 let sample_cap = sample_rows.max(1);
279
280 let mut rdr = csv::ReaderBuilder::new()
281 .has_headers(true)
282 .from_reader(text.as_bytes());
283 let mut row_count: u64 = 0;
284 for result in rdr.records() {
285 let record = result.map_err(|e| {
286 McpError::new(
287 ErrorCode::SchemaMismatch,
288 format!("CSV parse error at row {}: {e}", row_count + 1),
289 )
290 })?;
291 row_count += 1;
292 if sample_rows_out.len() < sample_cap {
294 sample_rows_out.push(
295 record
296 .iter()
297 .map(std::string::ToString::to_string)
298 .collect(),
299 );
300 }
301 for (col_idx, col) in columns.iter().enumerate() {
303 let s = &mut stats[col_idx];
304 let raw = record.get(col_idx).unwrap_or("");
305 let trimmed = raw.trim();
306 if trimmed.is_empty()
307 || trimmed.eq_ignore_ascii_case("null")
308 || trimmed.eq_ignore_ascii_case("na")
309 {
310 s.null_count += 1;
311 continue;
312 }
313 if s.sample_values.len() < sample_cap {
314 s.sample_values.push(trimmed.to_string());
315 }
316 if matches!(
318 col.hyper_type.as_str(),
319 "INT" | "INTEGER" | "BIGINT" | "NUMERIC(38,0)"
320 ) {
321 if let Ok(n) = trimmed.parse::<i128>() {
322 s.min_i128 = Some(s.min_i128.map_or(n, |m| m.min(n)));
323 s.max_i128 = Some(s.max_i128.map_or(n, |m| m.max(n)));
324 }
325 } else if col.hyper_type == "DOUBLE PRECISION" {
326 if let Ok(n) = trimmed.parse::<f64>() {
327 s.min_f64 = Some(s.min_f64.map_or(n, |m| m.min(n)));
328 s.max_f64 = Some(s.max_f64.map_or(n, |m| m.max(n)));
329 }
330 }
331 }
332 }
333
334 Ok(InspectReport {
335 columns,
336 stats,
337 row_count,
338 file_format: "csv".into(),
339 file_size_bytes: file_size,
340 sample_rows: sample_rows_out,
341 })
342}
343
344fn inspect_parquet(path: &str, file_size: u64) -> Result<InspectReport, McpError> {
349 let file = std::fs::File::open(path)
350 .map_err(|e| McpError::new(ErrorCode::FileNotFound, format!("Cannot open file: {e}")))?;
351 let builder = parquet::arrow::arrow_reader::ParquetRecordBatchReaderBuilder::try_new(file)
352 .map_err(|e| {
353 McpError::new(
354 ErrorCode::UnsupportedFormat,
355 format!("Invalid Parquet file: {e}"),
356 )
357 })?;
358 let arrow_schema: Arc<ArrowSchema> = Arc::clone(builder.schema());
359 let row_count = u64::try_from(builder.metadata().file_metadata().num_rows()).unwrap_or(0);
362 let columns = arrow_schema_to_columns(&arrow_schema);
363 let stats = vec![ColumnStats::default(); columns.len()];
364 Ok(InspectReport {
365 columns,
366 stats,
367 row_count,
368 file_format: "parquet".into(),
369 file_size_bytes: file_size,
370 sample_rows: Vec::new(),
371 })
372}
373
374fn inspect_arrow_ipc(path: &str, file_size: u64) -> Result<InspectReport, McpError> {
376 let file = std::fs::File::open(path)
377 .map_err(|e| McpError::new(ErrorCode::FileNotFound, format!("Cannot open file: {e}")))?;
378 let reader = arrow::ipc::reader::FileReader::try_new(file, None).map_err(|e| {
379 McpError::new(
380 ErrorCode::UnsupportedFormat,
381 format!("Invalid Arrow IPC file: {e}"),
382 )
383 })?;
384 let arrow_schema: Arc<ArrowSchema> = reader.schema();
385 let row_count: u64 = reader
386 .into_iter()
387 .map(|b| b.map_or(0, |rb| rb.num_rows() as u64))
388 .sum();
389 let columns = arrow_schema_to_columns(&arrow_schema);
390 let stats = vec![ColumnStats::default(); columns.len()];
391 Ok(InspectReport {
392 columns,
393 stats,
394 row_count,
395 file_format: "arrow_ipc".into(),
396 file_size_bytes: file_size,
397 sample_rows: Vec::new(),
398 })
399}