Skip to main content

hyperdb_mcp/
ingest.rs

1// Copyright (c) 2026, Salesforce, Inc. All rights reserved.
2// SPDX-License-Identifier: Apache-2.0 OR MIT
3
4//! Ingest inline data (JSON strings, CSV strings) and CSV files into Hyper tables.
5//!
6//! JSON is inserted row-by-row via SQL `INSERT` statements. This is simple and
7//! correct but not the fastest path — for bulk data, prefer file-based ingest
8//! where Hyper's native `COPY FROM` is used.
9//!
10//! CSV ingest always uses `COPY FROM`: inline CSV is spilled to a temp file first,
11//! while file-based CSV is read directly by `hyperd`.
12//!
13//! # Atomicity
14//!
15//! Every ingest function wraps its `INSERT` / `COPY` work inside a single
16//! transaction via [`Engine::execute_in_transaction`]. If any row fails to
17//! insert, all prior inserts from the same call are rolled back, so a failed
18//! ingest leaves zero additional rows behind.
19//!
20//! Note that Hyper auto-commits DDL (`DROP TABLE`, `CREATE TABLE`) regardless
21//! of the surrounding transaction. In `replace` mode, this means the original
22//! table is already gone by the time inserts start — a mid-ingest failure
23//! leaves the new table empty, not the original intact. In `append` mode, no
24//! DDL runs (assuming the table already exists), so rollback is fully atomic.
25
26use crate::engine::Engine;
27use crate::error::{ErrorCode, McpError};
28use crate::schema::{
29    apply_schema_override, infer_csv_schema, infer_json_schema, json_type_name,
30    widen_csv_numeric_columns, ColumnSchema,
31};
32use crate::stats::{IngestStats, StatsTimer};
33use hyperdb_api::AsyncConnection;
34use std::path::{Path, PathBuf};
35
36/// Resolve a path to a form that's safe to embed in a SQL
37/// `COPY FROM` literal.
38///
39/// On macOS the temp dir lives under `/var`, which is a symlink to
40/// `/private/var`; Hyper's `COPY FROM` resolves the symlink and then
41/// opens the file by the resolved path, so passing the unresolved one
42/// would break inline-CSV ingest. `canonicalize()` fixes that.
43///
44/// On Windows two extra steps are needed:
45///
46/// 1. `canonicalize()` returns an extended-length prefix (`\\?\C:\...`)
47///    that Hyper's `COPY FROM` cannot parse — it returns SQLSTATE 55006
48///    "unable to read from external source". Strip the prefix so the
49///    path looks like a plain `C:\...`.
50///
51/// 2. Convert backslashes to forward slashes. `escape_string_literal`
52///    only escapes single quotes, leaving `\U`, `\t`, and other
53///    backslash sequences exposed to `PostgreSQL`'s legacy string-literal
54///    escape rules, which Hyper inherits. Forward slashes are accepted
55///    by the Win32 file APIs and sidestep the escape question entirely.
56///
57/// Any canonicalize failure falls back to the original path — the
58/// common case (absolute, existing file on any platform) still works.
59fn canonicalize_for_copy(path: &Path) -> PathBuf {
60    let canonical = path.canonicalize().unwrap_or_else(|_| path.to_path_buf());
61    #[cfg(windows)]
62    {
63        if let Some(s) = canonical.to_str() {
64            // `\\?\C:\foo\bar.csv` → `C:\foo\bar.csv`. Leave UNC-style
65            // `\\?\UNC\server\share\...` alone — those are only produced
66            // by canonicalize on UNC sources, which Hyper would reject
67            // either way, and stripping blindly would corrupt them.
68            let stripped = match s.strip_prefix(r"\\?\") {
69                Some(rest) if !rest.starts_with("UNC\\") => rest,
70                _ => s,
71            };
72            return PathBuf::from(stripped.replace('\\', "/"));
73        }
74    }
75    canonical
76}
77use serde_json::Value;
78
79/// Maximum bytes read from a CSV/text file for schema inference (64 MB).
80/// The full file is still loaded by `COPY FROM` — this limit only bounds the
81/// in-process memory used for type detection.
82const SCHEMA_INFERENCE_MAX_BYTES: u64 = 64 * 1024 * 1024;
83
84/// Reads at most `max_bytes` from a text file, returning a valid UTF-8 string.
85/// Truncates at the last newline within the byte budget to avoid splitting a row
86/// or multi-byte UTF-8 characters.
87fn read_text_sample(path: impl AsRef<Path>, max_bytes: u64) -> std::io::Result<String> {
88    use std::io::Read;
89    let file = std::fs::File::open(path.as_ref())?;
90    let file_len = file.metadata()?.len();
91    if file_len <= max_bytes {
92        return std::fs::read_to_string(path.as_ref());
93    }
94    let mut reader = std::io::BufReader::new(file);
95    let cap = usize::try_from(max_bytes).unwrap_or(usize::MAX);
96    let mut buf = vec![0u8; cap];
97    reader.read_exact(&mut buf)?;
98    // Truncate at last newline to avoid partial rows
99    if let Some(pos) = buf.iter().rposition(|&b| b == b'\n') {
100        buf.truncate(pos + 1);
101    }
102    // Handle UTF-8 boundary: if truncation split a multi-byte character,
103    // trim trailing incomplete bytes until we have valid UTF-8
104    match String::from_utf8(buf) {
105        Ok(s) => Ok(s),
106        Err(e) => {
107            let valid_up_to = e.utf8_error().valid_up_to();
108            let mut bytes = e.into_bytes();
109            bytes.truncate(valid_up_to);
110            // Truncate at last newline again to ensure we have complete rows
111            if let Some(pos) = bytes.iter().rposition(|&b| b == b'\n') {
112                bytes.truncate(pos + 1);
113            }
114            String::from_utf8(bytes)
115                .map_err(|e| std::io::Error::new(std::io::ErrorKind::InvalidData, e))
116        }
117    }
118}
119
120/// Controls how data is loaded into a target table.
121#[derive(Debug)]
122pub struct IngestOptions {
123    pub table: String,
124    /// `"replace"` drops the existing table first; `"append"` adds rows to
125    /// it; `"merge"` upserts rows by [`Self::merge_key`].
126    pub mode: String,
127    /// When set, bypasses schema inference and uses these exact column types.
128    pub schema_override: Option<serde_json::Map<String, Value>>,
129    /// When `mode == "merge"`, the column(s) to match on for upsert. Required
130    /// in merge mode; rejected in any other mode (the per-call site validates
131    /// up-front so the lower ingest paths can stay format-agnostic).
132    pub merge_key: Option<Vec<String>>,
133}
134
135/// Returned by every ingest function with the row count, resolved schema,
136/// and performance telemetry.
137#[derive(Debug)]
138pub struct IngestResult {
139    pub rows: u64,
140    pub schema: Vec<ColumnSchema>,
141    pub stats: IngestStats,
142}
143
144/// RAII guard that ensures a temp table is dropped on **every** scope
145/// exit — `Ok`, `Err`, *and* panic-unwind. Used by
146/// [`merge_via_temp_table`] so an orphan `__hyperdb_merge_*` table
147/// can't leak into the workspace, even if a per-format ingest path
148/// panics mid-load.
149///
150/// Disarm by calling [`TempTableGuard::disarm`] when the temp has
151/// been renamed away (so the guard isn't dropping a real
152/// user-facing table by name).
153struct TempTableGuard<'a> {
154    engine: &'a Engine,
155    name: String,
156    armed: bool,
157}
158
159impl<'a> TempTableGuard<'a> {
160    fn new(engine: &'a Engine, name: String) -> Self {
161        Self {
162            engine,
163            name,
164            armed: true,
165        }
166    }
167    fn disarm(&mut self) {
168        self.armed = false;
169    }
170}
171
172impl Drop for TempTableGuard<'_> {
173    fn drop(&mut self) {
174        if !self.armed {
175            return;
176        }
177        // Detect whether the guard is being dropped during normal
178        // unwinding vs. as part of a panic. Including this in the log
179        // lets ops distinguish "expected cleanup" from "we wouldn't
180        // have learned about the temp leak otherwise" — the latter is
181        // the load-bearing case for this guard's existence.
182        let panicking = std::thread::panicking();
183        let drop_sql = format!(
184            "DROP TABLE IF EXISTS \"{}\"",
185            self.name.replace('"', "\"\"")
186        );
187        match self.engine.execute_command(&drop_sql) {
188            Ok(_) => {
189                if panicking {
190                    // Successful cleanup during a panic-unwind is
191                    // exactly what the guard exists for. Note it at
192                    // info so it shows up in normal log review without
193                    // requiring debug-level capture.
194                    tracing::info!(
195                        tmp = %self.name,
196                        "merge_via_temp_table: dropped temp table during panic unwind"
197                    );
198                }
199            }
200            Err(e) => {
201                // Don't mask the original outcome's error with a
202                // cleanup error — log and continue. The temp table is
203                // benign; the user can drop it manually if it ever
204                // surfaces. `panicking` is included so ops can tell
205                // whether this was a normal-path cleanup failure or
206                // happened while another error was already
207                // propagating.
208                tracing::warn!(
209                    tmp = %self.name,
210                    panicking,
211                    error = %e,
212                    "merge_via_temp_table: failed to drop temp table on guard exit"
213                );
214            }
215        }
216    }
217}
218
219/// Implement `mode = "merge"` for any format by reusing that format's
220/// `replace`-mode load to populate a temp table, then upserting the
221/// target by `merge_key` columns.
222///
223/// `replace_load` is the format-specific entry point (e.g. `ingest_json`,
224/// `ingest_parquet_file`) called with a `replace`-mode [`IngestOptions`]
225/// pointing at a temp table. It must return the [`IngestResult`] for
226/// the temp-table load — caller stitches that into the final result.
227///
228/// Algorithm:
229///
230/// 1. Validate `merge_key` is set and non-empty.
231/// 2. Load incoming data into a unique temp table via `replace_load`.
232///    A `TempTableGuard` arms here and unwind-safely drops the temp
233///    on every exit (`Ok` / `Err` / panic). Verify the temp actually
234///    materialized — a no-op load would otherwise produce confusing
235///    downstream errors.
236/// 3. If target table doesn't exist, rename the temp table → target,
237///    disarm the guard (the temp is now the target, do **not** drop
238///    it), and return (degenerates to "create").
239/// 4. Read target + temp column metadata. Validate every merge key
240///    exists in both with `types_compatible` type. Reject any
241///    non-key shared column whose type differs.
242/// 5. Auto-`ALTER TABLE ADD COLUMN` for any column present in temp but
243///    not target (added as nullable). When this fires, set
244///    [`IngestStats::schema_changed`] so the caller can issue a
245///    resource-list-changed notification.
246/// 6. `DELETE FROM target USING temp WHERE <key match>` then
247///    `INSERT INTO target (<temp cols>) SELECT <temp cols> FROM temp`.
248///    Columns the target has but temp doesn't are deliberately omitted
249///    from the projection so they fall through to NULL — standard
250///    PostgreSQL semantics for partial inserts.
251/// 7. The `TempTableGuard` drops the temp on scope exit.
252///
253/// Atomicity: the DELETE+INSERT pair is **not** wrapped in a transaction.
254/// Hyper auto-commits DDL inside transactions (see module-level note),
255/// and the existing `replace` mode already accepts the same race window,
256/// so this matches precedent rather than introducing a new exposure.
257///
258/// # Errors
259///
260/// - [`ErrorCode::InvalidArgument`] when `merge_key` is missing/empty,
261///   or when a key column is missing from target or temp, or when a
262///   column type differs between target and temp on a shared column.
263/// - [`ErrorCode::InternalError`] if `replace_load` returns `Ok` but
264///   doesn't actually create the temp table (a contract violation).
265/// - Propagates errors from `replace_load`, [`Engine::column_metadata`],
266///   [`Engine::alter_table_add_columns`], or the underlying DELETE /
267///   INSERT statements. The temp table is dropped before the error
268///   propagates.
269pub fn merge_via_temp_table<F>(
270    engine: &Engine,
271    opts: &IngestOptions,
272    replace_load: F,
273) -> Result<IngestResult, McpError>
274where
275    F: FnOnce(&IngestOptions) -> Result<IngestResult, McpError>,
276{
277    // Belt-and-suspenders contract check. Every per-format ingest only
278    // calls this helper when `opts.mode == "merge"`; if a future
279    // refactor adds a third mode that also recurses, this debug-only
280    // assertion catches the mistake before it manifests as silent
281    // misbehavior. The closure also passes `mode = "replace"` to the
282    // recursed call, breaking infinite recursion.
283    debug_assert_eq!(
284        opts.mode, "merge",
285        "merge_via_temp_table called with non-merge mode `{}`",
286        opts.mode
287    );
288
289    // Step 1 — validate merge_key.
290    let keys = opts.merge_key.as_ref().ok_or_else(|| {
291        McpError::new(
292            ErrorCode::InvalidArgument,
293            "merge mode requires merge_key (a column name or list of column names)",
294        )
295    })?;
296    if keys.is_empty() || keys.iter().any(String::is_empty) {
297        return Err(McpError::new(
298            ErrorCode::InvalidArgument,
299            "merge_key must be a non-empty list of non-empty column names",
300        ));
301    }
302
303    // Step 2 — load incoming data into a per-call temp table. The name
304    // mixes PID + nanosecond timestamp + a process-local atomic counter.
305    // The counter is what makes this race-free: two parallel merges of
306    // the same target inside one process can land in the same nanosecond
307    // (sub-µs OS timer, or platform-error fallback to `0`), but the
308    // counter is monotonically unique per call. That's enough to keep
309    // each merge's temp table distinct so the `TempTableGuard`s don't
310    // cross-drop one another.
311    use std::sync::atomic::{AtomicU64, Ordering};
312    static MERGE_COUNTER: AtomicU64 = AtomicU64::new(0);
313    let counter = MERGE_COUNTER.fetch_add(1, Ordering::Relaxed);
314    let nanos = std::time::SystemTime::now()
315        .duration_since(std::time::UNIX_EPOCH)
316        .map_or(0, |d| d.as_nanos());
317    // Squash anything that isn't [A-Za-z0-9_] in the target name to `_`
318    // so the auto-generated identifier is always SQL-safe. The temp
319    // name is never user-visible after a successful merge.
320    let safe_target: String = opts
321        .table
322        .chars()
323        .map(|c| {
324            if c.is_ascii_alphanumeric() || c == '_' {
325                c
326            } else {
327                '_'
328            }
329        })
330        .collect();
331    let tmp = format!(
332        "__hyperdb_merge_{safe_target}_{}_{nanos}_{counter}",
333        std::process::id(),
334    );
335
336    let tmp_opts = IngestOptions {
337        table: tmp.clone(),
338        mode: "replace".into(),
339        schema_override: opts.schema_override.clone(),
340        merge_key: None,
341    };
342    let tmp_result = replace_load(&tmp_opts)?;
343
344    // Arm the cleanup guard immediately after the load so any later
345    // failure (or panic) drops the temp table on unwind.
346    let mut guard = TempTableGuard::new(engine, tmp.clone());
347
348    // Belt-and-suspenders check: a per-format `replace_load` that returns
349    // `Ok` without actually creating the table would surface as opaque
350    // "table not found" errors from the catalog read in step 4. Catch
351    // it here with a clear message so the contract violation is named
352    // outright.
353    if !engine.table_exists(&tmp)? {
354        return Err(McpError::new(
355            ErrorCode::InternalError,
356            format!(
357                "merge: temp table '{tmp}' was not produced by the format-specific \
358                 replace load — this is a contract violation in the per-format ingest path"
359            ),
360        ));
361    }
362
363    // Step 3 — if target doesn't exist, rename temp → target.
364    if !engine.table_exists(&opts.table)? {
365        let quoted_tmp = format!("\"{}\"", tmp.replace('"', "\"\""));
366        let quoted_tgt = format!("\"{}\"", opts.table.replace('"', "\"\""));
367        engine.execute_command(&format!("ALTER TABLE {quoted_tmp} RENAME TO {quoted_tgt}"))?;
368        // The temp is now the target; the guard must NOT try to drop it.
369        guard.disarm();
370        return Ok(IngestResult {
371            rows: tmp_result.rows,
372            schema: tmp_result.schema,
373            stats: IngestStats {
374                operation: tmp_result.stats.operation,
375                rows: tmp_result.stats.rows,
376                elapsed_ms: tmp_result.stats.elapsed_ms,
377                bytes_read: tmp_result.stats.bytes_read,
378                bytes_stored: tmp_result.stats.bytes_stored,
379                schema_inference_ms: tmp_result.stats.schema_inference_ms,
380                table: opts.table.clone(),
381                file_format: tmp_result.stats.file_format,
382                warning: tmp_result.stats.warning,
383                // Target was just created from scratch — by definition
384                // the resource list "changed" relative to its prior
385                // absence, so signal a notify.
386                schema_changed: true,
387            },
388        });
389    }
390
391    // Step 4 — schema reconciliation.
392    let target_cols = engine.column_metadata(&opts.table)?;
393    let tmp_cols = engine.column_metadata(&tmp)?;
394    // Every key must exist in both, with matching type.
395    for k in keys {
396        let in_target = target_cols.iter().find(|c| c.name == *k);
397        let in_tmp = tmp_cols.iter().find(|c| c.name == *k);
398        match (in_target, in_tmp) {
399            (None, _) => {
400                return Err(McpError::new(
401                    ErrorCode::InvalidArgument,
402                    format!(
403                        "merge_key column '{k}' is not in target table '{}'",
404                        opts.table
405                    ),
406                ));
407            }
408            (_, None) => {
409                return Err(McpError::new(
410                    ErrorCode::InvalidArgument,
411                    format!(
412                        "merge_key column '{k}' is not in incoming data \
413                         (column missing from the file)"
414                    ),
415                ));
416            }
417            (Some(t), Some(s)) if !types_compatible(&t.hyper_type, &s.hyper_type) => {
418                return Err(McpError::new(
419                    ErrorCode::InvalidArgument,
420                    format!(
421                        "merge_key column '{k}' type mismatch: target is {} but \
422                         incoming is {}. Use mode=replace or apply a schema override.",
423                        t.hyper_type, s.hyper_type
424                    ),
425                ));
426            }
427            _ => {}
428        }
429    }
430    // Reject type mismatches on any shared non-key column.
431    for tc in &tmp_cols {
432        if let Some(target_c) = target_cols.iter().find(|c| c.name == tc.name) {
433            if !types_compatible(&target_c.hyper_type, &tc.hyper_type) {
434                return Err(McpError::new(
435                    ErrorCode::InvalidArgument,
436                    format!(
437                        "Column '{}' type mismatch: target is {} but incoming is {}. \
438                         Use mode=replace or apply a schema override.",
439                        tc.name, target_c.hyper_type, tc.hyper_type
440                    ),
441                ));
442            }
443        }
444    }
445
446    // Step 5 — auto-ALTER for new columns (in temp, not in target).
447    // `alter_table_add_columns` handles the empty-input case by
448    // returning early without issuing SQL.
449    let new_cols: Vec<ColumnSchema> = tmp_cols
450        .iter()
451        .filter(|c| !target_cols.iter().any(|t| t.name == c.name))
452        .cloned()
453        // ALTER TABLE ADD COLUMN must be nullable; existing rows have no value.
454        .map(|mut c| {
455            c.nullable = true;
456            c
457        })
458        .collect();
459    let schema_changed = !new_cols.is_empty();
460    engine.alter_table_add_columns(&opts.table, &new_cols)?;
461
462    // Step 6 — DELETE matching rows by key, then INSERT all temp rows.
463    let quoted_tgt = format!("\"{}\"", opts.table.replace('"', "\"\""));
464    let quoted_tmp = format!("\"{}\"", tmp.replace('"', "\"\""));
465    let key_eq = keys
466        .iter()
467        .map(|k| {
468            let qk = k.replace('"', "\"\"");
469            format!("t.\"{qk}\" = s.\"{qk}\"")
470        })
471        .collect::<Vec<_>>()
472        .join(" AND ");
473    let delete_sql = format!("DELETE FROM {quoted_tgt} t USING {quoted_tmp} s WHERE {key_eq}");
474    engine.execute_command(&delete_sql)?;
475
476    // The INSERT projection is `tmp_cols` (not `target_cols`). Columns
477    // the target has but the incoming temp lacks are deliberately
478    // omitted — PostgreSQL fills them with NULL, which is the right
479    // widening semantic for an upsert that adds new rows for
480    // unmatched keys.
481    let cols_csv = tmp_cols
482        .iter()
483        .map(|c| format!("\"{}\"", c.name.replace('"', "\"\"")))
484        .collect::<Vec<_>>()
485        .join(", ");
486    let insert_sql =
487        format!("INSERT INTO {quoted_tgt} ({cols_csv}) SELECT {cols_csv} FROM {quoted_tmp}");
488    let inserted = engine.execute_command(&insert_sql)?;
489
490    // Re-read target schema for the result so callers see the post-merge shape.
491    let final_schema = engine.column_metadata(&opts.table)?;
492
493    // The guard drops the temp table when it falls out of scope here.
494    Ok(IngestResult {
495        rows: inserted,
496        schema: final_schema,
497        stats: IngestStats {
498            operation: tmp_result.stats.operation,
499            rows: inserted,
500            elapsed_ms: tmp_result.stats.elapsed_ms,
501            bytes_read: tmp_result.stats.bytes_read,
502            bytes_stored: tmp_result.stats.bytes_stored,
503            schema_inference_ms: tmp_result.stats.schema_inference_ms,
504            table: opts.table.clone(),
505            file_format: tmp_result.stats.file_format,
506            warning: tmp_result.stats.warning,
507            schema_changed,
508        },
509    })
510}
511
512/// Compare two Hyper type strings for merge compatibility.
513///
514/// Raw string equality is wrong: the catalog canonicalizes types
515/// (`INT` → `INTEGER`, `BYTES` → `BYTEA`, `NUMERIC(15,2)` → may
516/// differ in spacing) while the inferred-incoming side carries
517/// whatever the Rust inferrer emitted (`"INT"`, `"DOUBLE
518/// PRECISION"`, `"NUMERIC(15, 2)"`). Comparing through
519/// [`crate::schema::map_hyper_type`] collapses all known aliases
520/// into a single [`SqlType`].
521///
522/// If *either* side fails to parse (returns `None`), we err on the
523/// side of permissive: treat the pair as compatible and let Hyper
524/// itself enforce at INSERT time. This avoids false-rejecting
525/// types the Rust mapper doesn't know about (future Hyper types,
526/// custom Hyper extensions, anything `map_hyper_type` hasn't been
527/// taught about). Mismatches will still surface — just from
528/// `INSERT` rather than from our pre-flight check.
529fn types_compatible(a: &str, b: &str) -> bool {
530    if a == b {
531        return true;
532    }
533    match (
534        crate::schema::map_hyper_type(a),
535        crate::schema::map_hyper_type(b),
536    ) {
537        (Some(sa), Some(sb)) => sa == sb,
538        _ => true,
539    }
540}
541
542/// Ingest an inline JSON array of objects into a Hyper table.
543///
544/// Each object becomes one row. Missing keys are inserted as NULL.
545/// Emits a warning when the input exceeds 50 MB — the caller should
546/// prefer `load_file` with a Parquet or Arrow file for large datasets.
547///
548/// # Errors
549///
550/// - Returns [`ErrorCode::SchemaMismatch`] if `json_str` cannot be
551///   parsed as a JSON array of objects (via [`serde_json::from_str`]).
552/// - Returns [`ErrorCode::EmptyData`] if the parsed array is empty.
553/// - Propagates any schema-inference error from [`infer_json_schema`]
554///   or [`apply_schema_override`].
555/// - Propagates any [`McpError`] from the wrapping transaction —
556///   [`Engine::create_table`], the per-row `INSERT` statements, or
557///   transaction commit/rollback failures.
558pub fn ingest_json(
559    engine: &Engine,
560    json_str: &str,
561    opts: &IngestOptions,
562) -> Result<IngestResult, McpError> {
563    if opts.mode == "merge" {
564        return merge_via_temp_table(engine, opts, |tmp_opts| {
565            ingest_json(engine, json_str, tmp_opts)
566        });
567    }
568    let timer = StatsTimer::start();
569    let bytes_read = json_str.len() as u64;
570
571    // Schema
572    let schema_timer = StatsTimer::start();
573    let inferred = infer_json_schema(json_str)?;
574    let columns = match &opts.schema_override {
575        Some(s) => apply_schema_override(inferred, s)?,
576        None => inferred,
577    };
578    let schema_ms = schema_timer.elapsed_ms();
579
580    // Parse JSON
581    let array: Vec<serde_json::Map<String, Value>> = serde_json::from_str(json_str)
582        .map_err(|e| McpError::new(ErrorCode::SchemaMismatch, format!("Invalid JSON: {e}")))?;
583
584    if array.is_empty() {
585        return Err(McpError::new(ErrorCode::EmptyData, "JSON array is empty"));
586    }
587
588    // All mutations run inside a transaction so a partial failure leaves
589    // zero side effects.
590    let is_replace = opts.mode != "append";
591    let row_count = engine.execute_in_transaction(|engine| {
592        engine.create_table(&opts.table, &columns, is_replace)?;
593        let mut row_count = 0u64;
594        let col_names: Vec<String> = columns.iter().map(|c| format!("\"{}\"", c.name)).collect();
595        for obj in &array {
596            let values: Vec<String> = columns
597                .iter()
598                .map(|col| match obj.get(&col.name) {
599                    None | Some(Value::Null) => "NULL".to_string(),
600                    Some(Value::Bool(b)) => b.to_string(),
601                    Some(Value::Number(n)) => n.to_string(),
602                    Some(Value::String(s)) => format!("'{}'", s.replace('\'', "''")),
603                    Some(other) => format!("'{}'", other.to_string().replace('\'', "''")),
604                })
605                .collect();
606
607            let sql = format!(
608                "INSERT INTO \"{}\" ({}) VALUES ({})",
609                opts.table,
610                col_names.join(", "),
611                values.join(", ")
612            );
613            engine.execute_command(&sql)?;
614            row_count += 1;
615        }
616        Ok(row_count)
617    })?;
618
619    let elapsed = timer.elapsed_ms();
620    let stats = IngestStats {
621        operation: "load_data".into(),
622        rows: row_count,
623        elapsed_ms: elapsed,
624        bytes_read,
625        bytes_stored: 0,
626        schema_inference_ms: Some(schema_ms),
627        table: opts.table.clone(),
628        file_format: Some("json".into()),
629        warning: if bytes_read > 50_000_000 {
630            Some("Large inline data. Consider using load_file for better performance.".into())
631        } else {
632            None
633        },
634        schema_changed: false,
635    };
636
637    Ok(IngestResult {
638        rows: row_count,
639        schema: columns,
640        stats,
641    })
642}
643
644/// Ingest inline CSV text into a Hyper table.
645///
646/// The CSV is written to a temp file and loaded via `COPY FROM` so that Hyper's
647/// native CSV parser handles escaping, quoting, and bulk loading. The temp file
648/// is cleaned up after the COPY completes.
649///
650/// # Errors
651///
652/// - Returns [`ErrorCode::InternalError`] if the temp CSV file cannot
653///   be written to the system temp directory.
654/// - Propagates any error from [`infer_csv_schema`],
655///   [`widen_csv_numeric_columns`], or [`apply_schema_override`].
656/// - Propagates any transaction error from [`Engine::create_table`]
657///   or the `COPY FROM` statement (SQL errors, schema mismatches,
658///   connection loss).
659pub fn ingest_csv(
660    engine: &Engine,
661    csv_text: &str,
662    opts: &IngestOptions,
663) -> Result<IngestResult, McpError> {
664    if opts.mode == "merge" {
665        return merge_via_temp_table(engine, opts, |tmp_opts| {
666            ingest_csv(engine, csv_text, tmp_opts)
667        });
668    }
669    let timer = StatsTimer::start();
670    let bytes_read = csv_text.len() as u64;
671
672    // Schema: infer from the 1 000-row sample, widen numeric columns against
673    // the full CSV body to catch values hidden after the sample window, then
674    // overlay any user-provided partial override.
675    let schema_timer = StatsTimer::start();
676    let mut inferred = infer_csv_schema(csv_text, true)?;
677    widen_csv_numeric_columns(csv_text.as_bytes(), true, &mut inferred)?;
678    let columns = match &opts.schema_override {
679        Some(s) => apply_schema_override(inferred, s)?,
680        None => inferred,
681    };
682    let schema_ms = schema_timer.elapsed_ms();
683
684    // Write CSV to a temp file before starting the transaction (it's a pure
685    // filesystem operation and doesn't need to be atomic with the DB work).
686    //
687    // Uses `tempfile::NamedTempFile` for OS-guaranteed unique paths — the
688    // previous PID+nanosecond scheme could collide on macOS where timer
689    // resolution is coarser, causing parallel tests to race on the same file.
690    // `into_temp_path()` closes the file handle immediately while retaining
691    // the auto-delete-on-drop guarantee. This is required on Windows where
692    // `hyperd` cannot open a file held by another process.
693    let temp_path = tempfile::Builder::new()
694        .prefix("hyperdb_mcp_csv_")
695        .suffix(".csv")
696        .tempfile()
697        .map_err(|e| {
698            McpError::new(
699                ErrorCode::InternalError,
700                format!("Failed to create temp CSV file: {e}"),
701            )
702        })?
703        .into_temp_path();
704    std::fs::write(&temp_path, csv_text).map_err(|e| {
705        McpError::new(
706            ErrorCode::InternalError,
707            format!("Failed to write temp CSV: {e}"),
708        )
709    })?;
710
711    // `NULL ''` makes unquoted empty CSV cells load as SQL NULL, which is
712    // what users expect from the `,,` convention (and what `inspect_file`
713    // already reports in its `null_count` diagnostics). Without this,
714    // Hyper's CSV COPY treats empty cells as literal empty strings —
715    // breaking downstream `WHERE col IS NULL` and failing outright on
716    // numeric columns.
717    let canonical_temp = canonicalize_for_copy(&temp_path);
718    let copy_sql = format!(
719        "COPY \"{}\" FROM {} WITH (FORMAT csv, NULL '', DELIMITER ',', HEADER)",
720        opts.table,
721        hyperdb_api::escape_string_literal(canonical_temp.to_str().unwrap_or(""))
722    );
723
724    // Create table + COPY inside one transaction so that a COPY failure also
725    // unwinds the table creation.
726    let is_replace = opts.mode != "append";
727    let row_count = engine.execute_in_transaction(|engine| {
728        engine.create_table(&opts.table, &columns, is_replace)?;
729        engine.execute_command(&copy_sql)
730    });
731
732    // `temp_path` (TempPath) auto-deletes the file when dropped at end of scope.
733    drop(temp_path);
734    let row_count = row_count?;
735
736    let elapsed = timer.elapsed_ms();
737    let stats = IngestStats {
738        operation: "load_data".into(),
739        rows: row_count,
740        elapsed_ms: elapsed,
741        bytes_read,
742        bytes_stored: 0,
743        schema_inference_ms: Some(schema_ms),
744        table: opts.table.clone(),
745        file_format: Some("csv".into()),
746        warning: if bytes_read > 50_000_000 {
747            Some("Large inline data. Consider using load_file for better performance.".into())
748        } else {
749            None
750        },
751        schema_changed: false,
752    };
753
754    Ok(IngestResult {
755        rows: row_count,
756        schema: columns,
757        stats,
758    })
759}
760
761/// Ingest a CSV file from disk into a Hyper table.
762///
763/// The file is read once for schema inference (up to 1 000 rows sampled),
764/// then loaded in bulk via `COPY FROM` using the canonicalized path so
765/// `hyperd` can read it directly.
766///
767/// # Errors
768///
769/// - Returns [`ErrorCode::FileNotFound`] if `path` does not exist or
770///   cannot be read.
771/// - Propagates any error from [`infer_csv_schema`],
772///   [`widen_csv_numeric_columns`], or [`apply_schema_override`].
773/// - Propagates any transaction error from [`Engine::create_table`]
774///   or the `COPY FROM` statement.
775pub fn ingest_csv_file(
776    engine: &Engine,
777    path: &str,
778    opts: &IngestOptions,
779) -> Result<IngestResult, McpError> {
780    if opts.mode == "merge" {
781        return merge_via_temp_table(engine, opts, |tmp_opts| {
782            ingest_csv_file(engine, path, tmp_opts)
783        });
784    }
785    let timer = StatsTimer::start();
786
787    let abs_path = std::path::Path::new(path);
788    if !abs_path.exists() {
789        return Err(McpError::new(
790            ErrorCode::FileNotFound,
791            format!("File not found: {path}"),
792        ));
793    }
794
795    let file_size = std::fs::metadata(abs_path).map_or(0, |m| m.len());
796
797    // Read file for schema inference (capped at 64 MB to prevent OOM on huge files)
798    let schema_timer = StatsTimer::start();
799    let sample = read_text_sample(abs_path, SCHEMA_INFERENCE_MAX_BYTES)
800        .map_err(|e| McpError::new(ErrorCode::FileNotFound, format!("Cannot read file: {e}")))?;
801    let mut inferred = infer_csv_schema(&sample, true)?;
802    widen_csv_numeric_columns(sample.as_bytes(), true, &mut inferred)?;
803    let columns = match &opts.schema_override {
804        Some(s) => apply_schema_override(inferred, s)?,
805        None => inferred,
806    };
807    let schema_ms = schema_timer.elapsed_ms();
808
809    // COPY FROM the file directly, inside a transaction with CREATE TABLE.
810    let canonical = canonicalize_for_copy(abs_path);
811    // See `ingest_csv` above for the NULL-handling rationale: `NULL ''`
812    // makes unquoted empty cells load as SQL NULL.
813    let copy_sql = format!(
814        "COPY \"{}\" FROM {} WITH (FORMAT csv, NULL '', DELIMITER ',', HEADER)",
815        opts.table,
816        hyperdb_api::escape_string_literal(canonical.to_str().unwrap_or(""))
817    );
818
819    let is_replace = opts.mode != "append";
820    let row_count = engine.execute_in_transaction(|engine| {
821        engine.create_table(&opts.table, &columns, is_replace)?;
822        engine.execute_command(&copy_sql)
823    })?;
824
825    let elapsed = timer.elapsed_ms();
826    let stats = IngestStats {
827        operation: "load_file".into(),
828        rows: row_count,
829        elapsed_ms: elapsed,
830        bytes_read: file_size,
831        bytes_stored: 0,
832        schema_inference_ms: Some(schema_ms),
833        table: opts.table.clone(),
834        file_format: Some("csv".into()),
835        warning: None,
836        schema_changed: false,
837    };
838
839    Ok(IngestResult {
840        rows: row_count,
841        schema: columns,
842        stats,
843    })
844}
845
846/// Async twin of [`ingest_csv_file`]. Runs schema inference on the
847/// blocking pool, then issues `CREATE TABLE` + `COPY FROM` on the given
848/// async connection inside a single transaction.
849///
850/// # Errors
851///
852/// - Returns [`ErrorCode::FileNotFound`] if `path` does not exist or
853///   cannot be read.
854/// - Returns [`ErrorCode::InternalError`] if the schema-inference task
855///   panics on the blocking pool (surfaced as a join error).
856/// - Propagates any error from schema inference or override
857///   application.
858/// - Propagates any transaction, `CREATE TABLE`, or `COPY FROM` error
859///   from the async connection. A rollback failure after an inner
860///   error is logged but does not override the original error.
861pub async fn ingest_csv_file_async(
862    conn: &AsyncConnection,
863    path: &str,
864    opts: &IngestOptions,
865) -> Result<IngestResult, McpError> {
866    let timer = StatsTimer::start();
867
868    let abs_path = std::path::Path::new(path);
869    if !abs_path.exists() {
870        return Err(McpError::new(
871            ErrorCode::FileNotFound,
872            format!("File not found: {path}"),
873        ));
874    }
875    let file_size = std::fs::metadata(abs_path).map_or(0, |m| m.len());
876
877    // Inference is CPU-bound (parses the whole file to widen numerics)
878    // so it runs on the blocking pool rather than stalling a worker.
879    let schema_timer = StatsTimer::start();
880    let path_owned = path.to_string();
881    let override_owned = opts.schema_override.clone();
882    let columns: Vec<ColumnSchema> = tokio::task::spawn_blocking(move || -> Result<_, McpError> {
883        let sample = read_text_sample(&path_owned, SCHEMA_INFERENCE_MAX_BYTES).map_err(|e| {
884            McpError::new(ErrorCode::FileNotFound, format!("Cannot read file: {e}"))
885        })?;
886        let mut inferred = infer_csv_schema(&sample, true)?;
887        widen_csv_numeric_columns(sample.as_bytes(), true, &mut inferred)?;
888        let columns = match &override_owned {
889            Some(s) => apply_schema_override(inferred, s)?,
890            None => inferred,
891        };
892        Ok(columns)
893    })
894    .await
895    .map_err(|e| McpError::new(ErrorCode::InternalError, format!("Task join error: {e}")))??;
896    let schema_ms = schema_timer.elapsed_ms();
897
898    let canonical = canonicalize_for_copy(abs_path);
899    // `NULL ''` — unquoted empty cells load as SQL NULL (see sync twin).
900    let copy_sql = format!(
901        "COPY \"{}\" FROM {} WITH (FORMAT csv, NULL '', DELIMITER ',', HEADER)",
902        opts.table,
903        hyperdb_api::escape_string_literal(canonical.to_str().unwrap_or(""))
904    );
905
906    let is_replace = opts.mode != "append";
907
908    conn.begin_transaction().await.map_err(McpError::from)?;
909    let inner: Result<u64, McpError> = async {
910        create_table_async(conn, &opts.table, &columns, is_replace).await?;
911        conn.execute_command(&copy_sql)
912            .await
913            .map_err(McpError::from)
914    }
915    .await;
916    let row_count = match inner {
917        Ok(n) => {
918            conn.commit().await.map_err(McpError::from)?;
919            n
920        }
921        Err(e) => {
922            if let Err(rb) = conn.rollback().await {
923                tracing::warn!("rollback after error failed: {}", rb);
924            }
925            return Err(e);
926        }
927    };
928
929    let elapsed = timer.elapsed_ms();
930    let stats = IngestStats {
931        operation: "load_file".into(),
932        rows: row_count,
933        elapsed_ms: elapsed,
934        bytes_read: file_size,
935        bytes_stored: 0,
936        schema_inference_ms: Some(schema_ms),
937        table: opts.table.clone(),
938        file_format: Some("csv".into()),
939        warning: None,
940        schema_changed: false,
941    };
942
943    Ok(IngestResult {
944        rows: row_count,
945        schema: columns,
946        stats,
947    })
948}
949
950/// Ingest a JSON or JSON-Lines file from disk into a Hyper table.
951///
952/// Auto-detects the payload shape from the first non-whitespace byte:
953///
954/// * `[` → a single JSON array of objects, loaded verbatim via
955///   [`ingest_json`].
956/// * anything else → newline-delimited JSON (`.jsonl` convention): each
957///   non-empty line is parsed as one JSON object, collected into an
958///   array, then passed to [`ingest_json`].
959///
960/// The dispatch is content-based rather than extension-based so `.json`
961/// files that happen to contain JSONL (or vice-versa) still load; the
962/// extension `.jsonl` is accepted but not required. Blank lines and
963/// lines containing only whitespace are skipped.
964///
965/// # Errors
966///
967/// - Returns [`ErrorCode::FileNotFound`] if `path` does not exist or
968///   cannot be read.
969/// - Propagates errors from [`normalize_json_or_jsonl`] (malformed
970///   JSON / JSONL) and from [`ingest_json`] (schema inference,
971///   transaction failures, etc.).
972pub fn ingest_json_file(
973    engine: &Engine,
974    path: &str,
975    opts: &IngestOptions,
976) -> Result<IngestResult, McpError> {
977    let abs_path = std::path::Path::new(path);
978    if !abs_path.exists() {
979        return Err(McpError::new(
980            ErrorCode::FileNotFound,
981            format!("File not found: {path}"),
982        ));
983    }
984
985    let text = std::fs::read_to_string(abs_path)
986        .map_err(|e| McpError::new(ErrorCode::FileNotFound, format!("Cannot read file: {e}")))?;
987
988    let json_array_text = normalize_json_or_jsonl(&text)?;
989    let mut result = ingest_json(engine, &json_array_text, opts)?;
990
991    // Re-label the operation + file_format so telemetry reflects the
992    // file-based path; `ingest_json` defaults to inline-mode metadata.
993    result.stats.operation = "load_file".into();
994    // Preserve the actual on-disk size rather than the serialized array,
995    // which may differ slightly after normalization.
996    result.stats.bytes_read = std::fs::metadata(abs_path).map_or(0, |m| m.len());
997    // Report the effective format we dispatched on so LLMs can
998    // distinguish the JSONL path from the array path when debugging.
999    result.stats.file_format = Some(
1000        if text.trim_start().starts_with('[') {
1001            "json"
1002        } else {
1003            "jsonl"
1004        }
1005        .into(),
1006    );
1007
1008    Ok(result)
1009}
1010
1011/// Async twin of [`ingest_json`]: insert a JSON array of objects on the
1012/// given async connection. See [`ingest_json`] for the JSON-shape
1013/// contract (expects a top-level array; call [`normalize_json_or_jsonl`]
1014/// first if you have JSONL).
1015///
1016/// # Errors
1017///
1018/// - Returns [`ErrorCode::SchemaMismatch`] if `json_str` cannot be
1019///   parsed as a JSON array of objects.
1020/// - Returns [`ErrorCode::EmptyData`] if the parsed array is empty.
1021/// - Propagates any error from [`infer_json_schema`] /
1022///   [`apply_schema_override`].
1023/// - Propagates any transaction or `INSERT`-loop error from the async
1024///   connection. Rollback failures after an inner error are logged
1025///   but do not shadow the original error.
1026pub async fn ingest_json_async(
1027    conn: &AsyncConnection,
1028    json_str: &str,
1029    opts: &IngestOptions,
1030) -> Result<IngestResult, McpError> {
1031    let timer = StatsTimer::start();
1032    let bytes_read = json_str.len() as u64;
1033
1034    let schema_timer = StatsTimer::start();
1035    let inferred = infer_json_schema(json_str)?;
1036    let columns = match &opts.schema_override {
1037        Some(s) => apply_schema_override(inferred, s)?,
1038        None => inferred,
1039    };
1040    let schema_ms = schema_timer.elapsed_ms();
1041
1042    let array: Vec<serde_json::Map<String, Value>> = serde_json::from_str(json_str)
1043        .map_err(|e| McpError::new(ErrorCode::SchemaMismatch, format!("Invalid JSON: {e}")))?;
1044    if array.is_empty() {
1045        return Err(McpError::new(ErrorCode::EmptyData, "JSON array is empty"));
1046    }
1047
1048    let is_replace = opts.mode != "append";
1049
1050    conn.begin_transaction().await.map_err(McpError::from)?;
1051    let inner: Result<u64, McpError> = async {
1052        create_table_async(conn, &opts.table, &columns, is_replace).await?;
1053        let mut row_count = 0u64;
1054        let col_names: Vec<String> = columns.iter().map(|c| format!("\"{}\"", c.name)).collect();
1055        for obj in &array {
1056            let values: Vec<String> = columns
1057                .iter()
1058                .map(|col| match obj.get(&col.name) {
1059                    None | Some(Value::Null) => "NULL".to_string(),
1060                    Some(Value::Bool(b)) => b.to_string(),
1061                    Some(Value::Number(n)) => n.to_string(),
1062                    Some(Value::String(s)) => format!("'{}'", s.replace('\'', "''")),
1063                    Some(other) => format!("'{}'", other.to_string().replace('\'', "''")),
1064                })
1065                .collect();
1066
1067            let sql = format!(
1068                "INSERT INTO \"{}\" ({}) VALUES ({})",
1069                opts.table,
1070                col_names.join(", "),
1071                values.join(", ")
1072            );
1073            conn.execute_command(&sql).await.map_err(McpError::from)?;
1074            row_count += 1;
1075        }
1076        Ok(row_count)
1077    }
1078    .await;
1079
1080    let row_count = match inner {
1081        Ok(n) => {
1082            conn.commit().await.map_err(McpError::from)?;
1083            n
1084        }
1085        Err(e) => {
1086            if let Err(rb) = conn.rollback().await {
1087                tracing::warn!("rollback after error failed: {}", rb);
1088            }
1089            return Err(e);
1090        }
1091    };
1092
1093    let elapsed = timer.elapsed_ms();
1094    let stats = IngestStats {
1095        operation: "load_data".into(),
1096        rows: row_count,
1097        elapsed_ms: elapsed,
1098        bytes_read,
1099        bytes_stored: 0,
1100        schema_inference_ms: Some(schema_ms),
1101        table: opts.table.clone(),
1102        file_format: Some("json".into()),
1103        warning: if bytes_read > 50_000_000 {
1104            Some("Large inline data. Consider using load_file for better performance.".into())
1105        } else {
1106            None
1107        },
1108        schema_changed: false,
1109    };
1110
1111    Ok(IngestResult {
1112        rows: row_count,
1113        schema: columns,
1114        stats,
1115    })
1116}
1117
1118/// Async twin of [`ingest_json_file`].
1119///
1120/// # Errors
1121///
1122/// - Returns [`ErrorCode::FileNotFound`] if `path` does not exist or
1123///   cannot be read.
1124/// - Propagates errors from [`normalize_json_or_jsonl`] and from
1125///   [`ingest_json_async`].
1126pub async fn ingest_json_file_async(
1127    conn: &AsyncConnection,
1128    path: &str,
1129    opts: &IngestOptions,
1130) -> Result<IngestResult, McpError> {
1131    let abs_path = std::path::Path::new(path);
1132    if !abs_path.exists() {
1133        return Err(McpError::new(
1134            ErrorCode::FileNotFound,
1135            format!("File not found: {path}"),
1136        ));
1137    }
1138
1139    let text = std::fs::read_to_string(abs_path)
1140        .map_err(|e| McpError::new(ErrorCode::FileNotFound, format!("Cannot read file: {e}")))?;
1141
1142    let json_array_text = normalize_json_or_jsonl(&text)?;
1143    let mut result = ingest_json_async(conn, &json_array_text, opts).await?;
1144
1145    result.stats.operation = "load_file".into();
1146    result.stats.bytes_read = std::fs::metadata(abs_path).map_or(0, |m| m.len());
1147    result.stats.file_format = Some(
1148        if text.trim_start().starts_with('[') {
1149            "json"
1150        } else {
1151            "jsonl"
1152        }
1153        .into(),
1154    );
1155
1156    Ok(result)
1157}
1158
1159/// Shared helper: `CREATE TABLE` (optionally dropping first) on an async
1160/// connection. Mirrors [`Engine::create_table`] exactly so the async
1161/// ingest paths produce identical tables to the sync ones. Callers that
1162/// need atomicity should wrap this in `begin_transaction` / `commit`.
1163pub(crate) async fn create_table_async(
1164    conn: &AsyncConnection,
1165    table_name: &str,
1166    columns: &[ColumnSchema],
1167    replace: bool,
1168) -> Result<(), McpError> {
1169    if columns.is_empty() {
1170        return Err(McpError::new(
1171            ErrorCode::EmptyData,
1172            "No columns to create table from",
1173        ));
1174    }
1175    for col in columns {
1176        if crate::schema::map_hyper_type(&col.hyper_type).is_none() {
1177            return Err(McpError::new(
1178                ErrorCode::SchemaMismatch,
1179                format!(
1180                    "Unknown type '{}' for column '{}'",
1181                    col.hyper_type, col.name
1182                ),
1183            ));
1184        }
1185    }
1186
1187    let quoted_table = format!("\"{}\"", table_name.replace('"', "\"\""));
1188    if replace {
1189        conn.execute_command(&format!("DROP TABLE IF EXISTS {quoted_table}"))
1190            .await
1191            .map_err(McpError::from)?;
1192    }
1193
1194    let col_defs: Vec<String> = columns
1195        .iter()
1196        .map(|c| {
1197            let nullable = if c.nullable { "" } else { " NOT NULL" };
1198            format!(
1199                "\"{}\" {}{}",
1200                c.name.replace('"', "\"\""),
1201                c.hyper_type,
1202                nullable
1203            )
1204        })
1205        .collect();
1206    let create_sql = format!(
1207        "CREATE TABLE IF NOT EXISTS {} ({})",
1208        quoted_table,
1209        col_defs.join(", ")
1210    );
1211    conn.execute_command(&create_sql)
1212        .await
1213        .map_err(McpError::from)?;
1214    Ok(())
1215}
1216
1217/// Normalize a raw JSON / JSONL string to the "JSON array of objects"
1218/// representation [`ingest_json`] expects. Returns the original string
1219/// when it already parses as a top-level array; otherwise treats the
1220/// input as JSONL and re-serializes into a JSON array.
1221///
1222/// Public so [`crate::inspect`] can reuse the same JSON/JSONL
1223/// auto-detection for its dry-run inspector.
1224///
1225/// # Errors
1226///
1227/// - Returns [`ErrorCode::SchemaMismatch`] with the offending line
1228///   number when a JSONL line fails to parse as valid JSON.
1229/// - Returns [`ErrorCode::EmptyData`] if the input contains no
1230///   non-blank records.
1231/// - Returns [`ErrorCode::InternalError`] if the aggregated array
1232///   cannot be serialized back to a string (should not happen in
1233///   practice).
1234pub fn normalize_json_or_jsonl(text: &str) -> Result<String, McpError> {
1235    let trimmed = text.trim_start();
1236    if trimmed.starts_with('[') {
1237        return Ok(text.to_string());
1238    }
1239
1240    // JSONL path: one JSON object per non-empty line. We parse each line
1241    // eagerly rather than concatenating then parsing, so malformed lines
1242    // produce a useful per-line error pointing at the exact offender.
1243    let mut objects: Vec<Value> = Vec::new();
1244    for (idx, line) in text.lines().enumerate() {
1245        let line = line.trim();
1246        if line.is_empty() {
1247            continue;
1248        }
1249        let value: Value = serde_json::from_str(line).map_err(|e| {
1250            McpError::new(
1251                ErrorCode::SchemaMismatch,
1252                format!("Invalid JSON on line {}: {e}", idx + 1),
1253            )
1254        })?;
1255        objects.push(value);
1256    }
1257    if objects.is_empty() {
1258        return Err(McpError::new(
1259            ErrorCode::EmptyData,
1260            "JSON/JSONL file contained no records",
1261        ));
1262    }
1263    serde_json::to_string(&Value::Array(objects)).map_err(|e| {
1264        McpError::new(
1265            ErrorCode::InternalError,
1266            format!("Failed to serialize JSONL as array: {e}"),
1267        )
1268    })
1269}
1270
1271/// Navigate a dot-separated path into a JSON value, transparently parsing
1272/// stringified JSON at each step.
1273///
1274/// Path segments are dot-separated (e.g., `content.0.text.query_result.results`).
1275/// Numeric segments index into JSON arrays (`content.0` means `content[0]`).
1276/// When a segment resolves to a JSON string, the function automatically tries
1277/// to parse that string as JSON and continues navigating into the parsed
1278/// result. This handles the common MCP wrapper pattern where response
1279/// payloads are double-encoded as stringified JSON.
1280///
1281/// After all segments are consumed, if the final value is still a string,
1282/// one more parse attempt is made so that a terminal stringified array
1283/// (e.g., `"[{\"a\":1}]"`) is returned as the parsed array.
1284///
1285/// Returns the serialized JSON string of the value at the terminal path
1286/// segment.
1287///
1288/// # Errors
1289///
1290/// Returns [`ErrorCode::SchemaMismatch`] when:
1291/// - `raw_json` is not valid JSON.
1292/// - A stringified JSON value at a traversal point cannot be re-parsed.
1293/// - A numeric segment is out of range for the current array, or the
1294///   current value is not an array.
1295/// - A named segment is missing from the current object, or the current
1296///   value is not an object.
1297/// - The final result cannot be serialized back to a JSON string
1298///   (surfaces as [`ErrorCode::InternalError`]).
1299pub fn extract_json_path(raw_json: &str, path: &str) -> Result<String, McpError> {
1300    let mut current: Value = serde_json::from_str(raw_json).map_err(|e| {
1301        McpError::new(
1302            ErrorCode::SchemaMismatch,
1303            format!("json_extract_path: file is not valid JSON: {e}"),
1304        )
1305    })?;
1306
1307    let segments: Vec<&str> = path.split('.').collect();
1308    let mut traversed: Vec<&str> = Vec::new();
1309
1310    for segment in &segments {
1311        // If the current value is a string, try to parse it as JSON before
1312        // applying this segment. This handles stringified JSON wrappers.
1313        if let Value::String(s) = &current {
1314            current = serde_json::from_str(s).map_err(|_| {
1315                McpError::new(
1316                    ErrorCode::SchemaMismatch,
1317                    format!(
1318                        "json_extract_path '{}': at segment '{}' (after '{}'): \
1319                         value is a string but not valid JSON",
1320                        path,
1321                        segment,
1322                        traversed.join(".")
1323                    ),
1324                )
1325            })?;
1326        }
1327
1328        current = if let Ok(idx) = segment.parse::<usize>() {
1329            // Numeric segment: index into array.
1330            match current {
1331                Value::Array(mut arr) => {
1332                    if idx >= arr.len() {
1333                        return Err(McpError::new(
1334                            ErrorCode::SchemaMismatch,
1335                            format!(
1336                                "json_extract_path '{}': at segment '{}' (after '{}'): \
1337                                 array index {} out of bounds (length {})",
1338                                path,
1339                                segment,
1340                                traversed.join("."),
1341                                idx,
1342                                arr.len()
1343                            ),
1344                        ));
1345                    }
1346                    arr.swap_remove(idx)
1347                }
1348                other => {
1349                    return Err(McpError::new(
1350                        ErrorCode::SchemaMismatch,
1351                        format!(
1352                            "json_extract_path '{}': at segment '{}' (after '{}'): \
1353                             expected array, found {}",
1354                            path,
1355                            segment,
1356                            traversed.join("."),
1357                            json_type_name(&other)
1358                        ),
1359                    ));
1360                }
1361            }
1362        } else {
1363            // String segment: index into object by key.
1364            match current {
1365                Value::Object(mut map) => match map.remove(*segment) {
1366                    Some(v) => v,
1367                    None => {
1368                        return Err(McpError::new(
1369                            ErrorCode::SchemaMismatch,
1370                            format!(
1371                                "json_extract_path '{}': at segment '{}' (after '{}'): \
1372                                 key not found in object",
1373                                path,
1374                                segment,
1375                                traversed.join(".")
1376                            ),
1377                        ));
1378                    }
1379                },
1380                other => {
1381                    return Err(McpError::new(
1382                        ErrorCode::SchemaMismatch,
1383                        format!(
1384                            "json_extract_path '{}': at segment '{}' (after '{}'): \
1385                             expected object, found {}",
1386                            path,
1387                            segment,
1388                            traversed.join("."),
1389                            json_type_name(&other)
1390                        ),
1391                    ));
1392                }
1393            }
1394        };
1395
1396        traversed.push(segment);
1397    }
1398
1399    // Terminal auto-parse: if the final value is a string, try to parse it
1400    // as JSON so that e.g. a stringified array becomes the actual array.
1401    if let Value::String(s) = &current {
1402        if let Ok(parsed) = serde_json::from_str::<Value>(s) {
1403            current = parsed;
1404        }
1405    }
1406
1407    serde_json::to_string(&current).map_err(|e| {
1408        McpError::new(
1409            ErrorCode::InternalError,
1410            format!("json_extract_path: failed to serialize extracted value: {e}"),
1411        )
1412    })
1413}
1414
1415/// High-level file-format categories the ingest and inspect layers
1416/// dispatch on. Text-based formats (JSON, CSV) are distinguished by
1417/// peeking at the first non-whitespace byte when the extension is
1418/// unfamiliar, so log-like files with `.log`/`.txt`/no extension still
1419/// reach the right decoder without the caller having to rename them.
1420#[derive(Debug, Clone, Copy, PartialEq, Eq)]
1421pub enum InferredFileFormat {
1422    /// Columnar, binary. Matched by `.parquet` / `.pq` extensions.
1423    Parquet,
1424    /// Arrow IPC stream or file. Matched by `.arrow` / `.ipc` /
1425    /// `.feather` extensions.
1426    ArrowIpc,
1427    /// JSON — either a top-level array of objects or newline-delimited
1428    /// JSON. [`ingest_json_file`] auto-detects between the two shapes
1429    /// from the first non-whitespace byte.
1430    Json,
1431    /// Everything else: CSV / TSV / log text that the CSV COPY path
1432    /// can still make sense of.
1433    Csv,
1434}
1435
1436/// Decide how to dispatch an ingest / inspect call for `path`.
1437///
1438/// Extension first (zero-IO, cheap): binary formats (`.parquet`, `.pq`,
1439/// `.arrow`, `.ipc`, `.feather`) always win by extension because the
1440/// file is a binary container whose magic bytes we'd have to unpack
1441/// anyway. Known text extensions (`.json`, `.jsonl`, `.ndjson`) map
1442/// straight to JSON without needing to read the file.
1443///
1444/// Otherwise — for unknown, ambiguous, or missing extensions (`.log`,
1445/// `.txt`, no extension at all) — peek at the first 4 KiB and return
1446/// [`InferredFileFormat::Json`] if the first non-whitespace byte is
1447/// `[` or `{`, else [`InferredFileFormat::Csv`]. This is the path that
1448/// lets hyperd's raw `.log` files load without renaming, since JSONL
1449/// lines always begin with `{`.
1450///
1451/// Returns [`InferredFileFormat::Csv`] if the file can't be opened;
1452/// the subsequent CSV ingest will surface a clearer error than a
1453/// format-detection failure would.
1454#[must_use]
1455pub fn detect_file_format(path: &std::path::Path) -> InferredFileFormat {
1456    let ext = path
1457        .extension()
1458        .and_then(|e| e.to_str())
1459        .unwrap_or("")
1460        .to_lowercase();
1461    match ext.as_str() {
1462        "parquet" | "pq" => return InferredFileFormat::Parquet,
1463        "arrow" | "ipc" | "feather" => return InferredFileFormat::ArrowIpc,
1464        "json" | "jsonl" | "ndjson" => return InferredFileFormat::Json,
1465        _ => {}
1466    }
1467    // Content-sniff fallback for anything else. We only need the first
1468    // non-whitespace byte; 4 KiB comfortably covers any realistic
1469    // leading-whitespace padding or BOM noise.
1470    use std::io::Read;
1471    if let Ok(mut f) = std::fs::File::open(path) {
1472        let mut buf = [0u8; 4096];
1473        if let Ok(n) = f.read(&mut buf) {
1474            for b in &buf[..n] {
1475                match b {
1476                    b' ' | b'\t' | b'\n' | b'\r' | 0xEF | 0xBB | 0xBF => {}
1477                    b'[' | b'{' => return InferredFileFormat::Json,
1478                    _ => return InferredFileFormat::Csv,
1479                }
1480            }
1481        }
1482    }
1483    InferredFileFormat::Csv
1484}
1485
1486#[cfg(test)]
1487mod read_text_sample_tests {
1488    use super::read_text_sample;
1489    use std::io::Write;
1490
1491    fn write_temp(name: &str, contents: &[u8]) -> tempfile::TempPath {
1492        let mut tmp = tempfile::NamedTempFile::new().expect("temp file");
1493        tmp.write_all(contents).unwrap();
1494        tmp.flush().unwrap();
1495        // Use the path directly so the file persists for the read.
1496        let _ = name;
1497        tmp.into_temp_path()
1498    }
1499
1500    #[test]
1501    fn small_file_returns_full_contents() {
1502        let path = write_temp("small.csv", b"a,b,c\n1,2,3\n");
1503        let s = read_text_sample(&path, 1024 * 1024).unwrap();
1504        assert_eq!(s, "a,b,c\n1,2,3\n");
1505    }
1506
1507    #[test]
1508    fn truncates_at_last_newline_within_budget() {
1509        // 100 bytes total, cap at 30 — should truncate at last newline before byte 30.
1510        use std::fmt::Write;
1511        let mut data = String::new();
1512        for i in 0..20 {
1513            writeln!(&mut data, "row-{i}").unwrap();
1514        }
1515        let path = write_temp("rows.csv", data.as_bytes());
1516        let s = read_text_sample(&path, 30).unwrap();
1517        // Result should end with newline (no partial row).
1518        assert!(s.ends_with('\n'));
1519        // Should be at most 30 bytes.
1520        assert!(s.len() <= 30);
1521    }
1522
1523    #[test]
1524    fn handles_utf8_boundary_split() {
1525        // Construct a file where the byte budget falls in the middle of a
1526        // multi-byte UTF-8 character (4-byte emoji).
1527        let prefix = b"row1\n".to_vec();
1528        let mut data = prefix.clone();
1529        // 4-byte emoji: 🔥 = 0xF0 0x9F 0x94 0xA5
1530        data.extend_from_slice("🔥".as_bytes());
1531        // Total: 5 + 4 = 9 bytes
1532        let path = write_temp("emoji.csv", &data);
1533        // Cap at 8 bytes — splits the emoji at byte 8 (after F0 9F 94)
1534        let s = read_text_sample(&path, 8).unwrap();
1535        // Result must be valid UTF-8 (the emoji is dropped).
1536        // Should end with the prefix's newline, not partial bytes.
1537        assert!(s.ends_with('\n'));
1538        assert_eq!(s, "row1\n");
1539    }
1540
1541    #[test]
1542    fn returns_full_buffer_when_no_newline_under_budget() {
1543        // Single CSV row, no terminator, fits under cap.
1544        let path = write_temp("noeol.csv", b"a,b,c");
1545        let s = read_text_sample(&path, 1024).unwrap();
1546        assert_eq!(s, "a,b,c");
1547    }
1548
1549    #[test]
1550    fn handles_no_newline_in_first_max_bytes() {
1551        // File larger than cap, no newlines anywhere — degenerate case.
1552        // We accept this (returns the full max_bytes prefix, possibly truncated
1553        // at last UTF-8 boundary). Schema inference will likely fail downstream,
1554        // but read_text_sample itself must not panic or return garbage.
1555        let data = vec![b'a'; 2048];
1556        let path = write_temp("noeol-big.csv", &data);
1557        let s = read_text_sample(&path, 100).unwrap();
1558        // No newline → keeps all 100 bytes of ASCII 'a'.
1559        assert_eq!(s.len(), 100);
1560        assert!(s.chars().all(|c| c == 'a'));
1561    }
1562}