Skip to main content

nxs/convert/
mod.rs

1//! Converter suite — JSON/CSV/XML ⇄ .nxb.
2//!
3//! Spec: ../../context/data/2026-04-30-converter-suite-spec.yaml
4//! Plan: ../../context/plans/2026-04-30-converter-suite.md
5//!
6//! Module map:
7//!   infer     — two-pass streaming sigil inference shared by all import sources
8//!   json_in   — JSON → NxsWriter
9//!   csv_in    — CSV  → NxsWriter
10//!   xml_in    — XML  → NxsWriter
11//!   json_out  — .nxb → JSON (streaming, optional ndjson/pretty)
12//!   csv_out   — .nxb → CSV
13//!   inspect   — .nxb → human/JSON report
14//!
15//! All public entry points below are stubs returning `Unimplemented` until the
16//! implementation steps in the plan are executed in TDD order.
17
18// No panics on adversarial input — enforced mechanically in this module.
19#![deny(
20    clippy::unwrap_used,
21    clippy::expect_used,
22    clippy::panic,
23    clippy::indexing_slicing
24)]
25
26use crate::error::Result;
27
28pub mod csv_in;
29pub mod csv_out;
30pub mod infer;
31pub mod inspect;
32pub mod json_in;
33pub mod json_out;
34pub mod xml_in;
35
36// ── Verify policy (--verify) ────────────────────────────────────────────────
37
38/// `--verify <auto|force|off>` — post-write roundtrip decode control.
39#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
40pub enum VerifyPolicy {
41    /// Verify when output is under 100 MB; skip otherwise with a warning.
42    #[default]
43    Auto,
44    /// Always verify, regardless of output size.
45    Force,
46    /// Skip verify entirely.
47    Off,
48}
49
50// ── Binary encoding (--binary) ───────────────────────────────────────────────
51
52/// `--binary <base64|hex|skip>` — how to render `<` binary values on export.
53#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
54pub enum BinaryEncoding {
55    #[default]
56    Base64,
57    Hex,
58    Skip,
59}
60
61// ── XML attribute handling (--xml-attrs) ────────────────────────────────────
62
63/// `--xml-attrs <as-fields|prefix>`.
64#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
65pub enum XmlAttrsMode {
66    /// `<u id="1"/>` → `{id: =1}`.
67    #[default]
68    AsFields,
69    /// `<u id="1"/>` → `{@id: =1}`.
70    Prefix,
71}
72
73// ── Conflict policy (--on-conflict) ─────────────────────────────────────────
74
75/// `--on-conflict <error|coerce-string|first-wins>`.
76#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
77pub enum ConflictPolicy {
78    /// Exit 4 on the first conflict.
79    #[default]
80    Error,
81    /// Widen conflicting keys to string.
82    CoerceString,
83    /// First-seen sigil wins; later mismatches are errors.
84    FirstWins,
85}
86
87// ── Inferred schema types ────────────────────────────────────────────────────
88
89/// Return type of the inference pass: each key's chosen sigil plus whether it
90/// is optional (absent in ≥1 record).
91///
92/// During pass 1, `key_states` and `total_records` are populated alongside
93/// `keys`. After `infer::finalize`, `key_states` may be dropped.
94#[derive(Debug, Default)]
95pub struct InferredSchema {
96    pub keys: Vec<InferredKey>,
97    /// Parallel to `keys` — accumulates raw observations during pass 1.
98    pub key_states: Vec<crate::convert::infer::KeyState>,
99    /// Total records seen during pass 1 (used to detect optional keys).
100    pub total_records: usize,
101}
102
103#[derive(Debug)]
104pub struct InferredKey {
105    pub name: String,
106    /// NXS sigil byte: `=` `~` `?` `"` `@` `<` `^`
107    pub sigil: u8,
108    pub optional: bool,
109    /// When `Some(s)`, the key is an NXS list whose elements have sigil `s`.
110    pub list_of: Option<u8>,
111}
112
113// ── Common options ────────────────────────────────────────────────────────────
114
115/// Options shared across all three binaries (I/O paths).
116#[derive(Debug, Default, Clone)]
117pub struct CommonOpts {
118    /// `None` → stdin.
119    pub input_path: Option<std::path::PathBuf>,
120    /// `None` → stdout.
121    pub output_path: Option<std::path::PathBuf>,
122}
123
124// ── Import ────────────────────────────────────────────────────────────────────
125
126/// All CLI flags for `nxs-import`. One field per spec flag.
127#[derive(Debug)]
128pub struct ImportArgs {
129    pub common: CommonOpts,
130    /// `--from <json|csv|xml>` — required.
131    pub from: ImportFormat,
132    /// `--schema <file.yaml>` — skip inference; single-pass.
133    pub schema_hint: Option<std::path::PathBuf>,
134    /// `--on-conflict`
135    pub conflict: ConflictPolicy,
136    /// `--root <jsonpath>` — default `$` (JSON only).
137    pub root: Option<String>,
138    /// `--csv-delimiter <char>` — default `,`.
139    pub csv_delimiter: Option<char>,
140    /// `--csv-no-header` — generate positional keys `col_0`, `col_1`, …
141    pub csv_no_header: bool,
142    /// `--xml-record-tag <name>` — required for XML.
143    pub xml_record_tag: Option<String>,
144    /// `--xml-attrs <as-fields|prefix>` — default `as-fields`.
145    pub xml_attrs: XmlAttrsMode,
146    /// `--buffer-records <N>` — default 4096.
147    pub buffer_records: usize,
148    /// `--max-depth <N>` — default 64; applies to JSON and XML.
149    pub max_depth: usize,
150    /// `--xml-max-depth <N>` — default 64; effective = min(max_depth, xml_max_depth).
151    pub xml_max_depth: usize,
152    /// `--tail-index-spill` — allow tail-index to exceed 512 MB by spilling to disk.
153    pub tail_index_spill: bool,
154    /// `--verify <auto|force|off>` — default `auto`.
155    pub verify: VerifyPolicy,
156}
157
158impl Default for ImportArgs {
159    fn default() -> Self {
160        Self {
161            common: CommonOpts::default(),
162            from: ImportFormat::default(),
163            schema_hint: None,
164            conflict: ConflictPolicy::default(),
165            root: None,
166            csv_delimiter: None,
167            csv_no_header: false,
168            xml_record_tag: None,
169            xml_attrs: XmlAttrsMode::default(),
170            buffer_records: 4096,
171            max_depth: 64,
172            xml_max_depth: 64,
173            tail_index_spill: false,
174            verify: VerifyPolicy::default(),
175        }
176    }
177}
178
179/// `--from` source format.
180#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
181pub enum ImportFormat {
182    #[default]
183    Json,
184    Csv,
185    Xml,
186}
187
188/// Result returned after a successful import.
189#[derive(Debug, Default)]
190pub struct ImportReport {
191    pub records_written: usize,
192    pub output_bytes: usize,
193}
194
195// ── Export ────────────────────────────────────────────────────────────────────
196
197/// All CLI flags for `nxs-export`. One field per spec flag.
198#[derive(Debug, Default)]
199pub struct ExportArgs {
200    pub common: CommonOpts,
201    /// `--to <json|csv>` — required.
202    pub to: ExportFormat,
203    /// `--pretty` — (JSON only) indent 2 spaces.
204    pub pretty: bool,
205    /// `--ndjson` — (JSON only) newline-delimited JSON.
206    pub ndjson: bool,
207    /// `--columns <a,b,c>` — (CSV only) explicit column order.
208    pub columns: Option<Vec<String>>,
209    /// `--csv-delimiter <char>` — default `,`.
210    pub csv_delimiter: Option<char>,
211    /// `--binary <base64|hex|skip>` — default `base64`.
212    pub binary: BinaryEncoding,
213    /// `--csv-safe` — prefix injection-prone cells with `'`.
214    pub csv_safe: bool,
215}
216
217/// `--to` export format.
218#[derive(Debug, Default, Clone, Copy, PartialEq, Eq)]
219pub enum ExportFormat {
220    #[default]
221    Json,
222    Csv,
223}
224
225/// Result returned after a successful export.
226#[derive(Debug, Default)]
227pub struct ExportReport {
228    pub records_read: usize,
229    pub output_bytes: usize,
230}
231
232// ── Inspect ───────────────────────────────────────────────────────────────────
233
234/// All CLI flags for `nxs-inspect`. One field per spec flag.
235#[derive(Debug, Default)]
236pub struct InspectArgs {
237    pub common: CommonOpts,
238    /// `--json` — emit structured JSON instead of text.
239    pub json_output: bool,
240    /// `--records <N|all>` — how many records to summarize. `None` = all.
241    pub records_to_show: Option<usize>,
242    /// `--record-index <N>` — decode exactly one record by zero-based index (O(1) via tail-index).
243    /// Mutually exclusive with `--records`.
244    pub record_index: Option<usize>,
245    /// `--verify-hash` — recompute DictHash and compare to preamble.
246    pub verify_hash: bool,
247}
248
249/// Result returned after a successful inspect.
250#[derive(Debug, Default)]
251pub struct InspectReport {
252    /// `Some(true/false)` only when `--verify-hash` was supplied.
253    pub dict_hash_ok: Option<bool>,
254    pub record_count: usize,
255}
256
257// ── Schema hint YAML loader ───────────────────────────────────────────────────
258
259/// YAML schema hint file format (from spec `schema_hints_format`).
260/// `keys:` maps key names to `{ sigil, optional?, list_of? }`.
261#[derive(Debug, serde::Deserialize)]
262struct SchemaHintFile {
263    keys: std::collections::HashMap<String, SchemaHintKey>,
264}
265
266#[derive(Debug, serde::Deserialize)]
267struct SchemaHintKey {
268    sigil: String,
269    #[serde(default)]
270    optional: bool,
271    list_of: Option<String>,
272}
273
274/// Load an `InferredSchema` from a `--schema <file.yaml>` hint file.
275/// The caller uses this instead of running inference pass 1.
276pub fn load_schema_hint(path: &std::path::Path) -> Result<InferredSchema> {
277    let text = std::fs::read_to_string(path)
278        .map_err(|e| crate::error::NxsError::IoError(format!("{}: {e}", path.display())))?;
279    let hint: SchemaHintFile = serde_yaml2::de::from_str(&text).map_err(|e| {
280        crate::error::NxsError::ConvertParseError {
281            offset: 0,
282            msg: format!("schema hint YAML parse error: {e}"),
283        }
284    })?;
285
286    let keys = hint
287        .keys
288        .into_iter()
289        .map(|(name, k)| {
290            let sigil = k.sigil.bytes().next().unwrap_or(b'"');
291            let list_of = k.list_of.as_deref().and_then(|s| s.bytes().next());
292            InferredKey {
293                name,
294                sigil,
295                optional: k.optional,
296                list_of,
297            }
298        })
299        .collect();
300
301    Ok(InferredSchema {
302        keys,
303        key_states: vec![],
304        total_records: 0,
305    })
306}
307
308// ── Exit code mapping ─────────────────────────────────────────────────────────
309
310/// Map an `NxsError` to the documented exit code for the converter binaries.
311///
312/// Exit codes (from spec):
313///   0 — success
314///   1 — generic error
315///   2 — usage error (bad/missing flags)
316///   3 — input format error
317///   4 — schema conflict
318///   5 — IO error
319pub fn exit_code_for(err: &crate::error::NxsError) -> i32 {
320    use crate::error::NxsError;
321    match err {
322        NxsError::ConvertSchemaConflict(_) => 4,
323        NxsError::ConvertParseError { .. }
324        | NxsError::ConvertEntityExpansion
325        | NxsError::ConvertDepthExceeded
326        | NxsError::BadMagic
327        | NxsError::OutOfBounds
328        | NxsError::RecursionLimit => 3,
329        NxsError::IoError(_) => 5,
330        _ => 1,
331    }
332}
333
334// ── Entry points (stubs) ──────────────────────────────────────────────────────
335
336/// Top-level driver for nxs-import (dispatched on `--from`).
337pub fn run_import(args: &ImportArgs) -> Result<ImportReport> {
338    use crate::convert::json_in;
339    use std::io::BufReader;
340
341    let input_path = args.common.input_path.as_deref();
342    let output_path = args.common.output_path.as_deref();
343
344    match args.from {
345        ImportFormat::Json => {
346            // Two-pass: open file twice (pass 1 + pass 2), or spill stdin.
347            match input_path {
348                Some(path) => {
349                    // Pass 1: infer schema (or load from hint)
350                    let schema = if let Some(hint_path) = &args.schema_hint {
351                        load_schema_hint(hint_path)?
352                    } else {
353                        let f1 = std::fs::File::open(path).map_err(|e| {
354                            crate::error::NxsError::IoError(format!("{}: {e}", path.display()))
355                        })?;
356                        json_in::infer_schema(BufReader::new(f1), args)?
357                    };
358
359                    // Pass 2: emit
360                    let f2 = std::fs::File::open(path).map_err(|e| {
361                        crate::error::NxsError::IoError(format!("{}: {e}", path.display()))
362                    })?;
363
364                    match output_path {
365                        Some(out_path) => {
366                            let out = std::fs::File::create(out_path).map_err(|e| {
367                                crate::error::NxsError::IoError(format!(
368                                    "{}: {e}",
369                                    out_path.display()
370                                ))
371                            })?;
372                            json_in::emit(BufReader::new(f2), out, &schema, args)
373                        }
374                        None => json_in::emit(BufReader::new(f2), std::io::stdout(), &schema, args),
375                    }
376                }
377                None => {
378                    // stdin: spill to tempfile, then two passes over the tempfile.
379                    // With --schema, skip pass 1 (no spill needed — single pass).
380                    let mut spill = tempfile::NamedTempFile::new()
381                        .map_err(|e| crate::error::NxsError::IoError(e.to_string()))?;
382                    std::io::copy(&mut std::io::stdin(), &mut spill)
383                        .map_err(|e| crate::error::NxsError::IoError(e.to_string()))?;
384                    let spill_path = spill.path().to_path_buf();
385
386                    let schema = if let Some(hint_path) = &args.schema_hint {
387                        load_schema_hint(hint_path)?
388                    } else {
389                        let f1 = std::fs::File::open(&spill_path)
390                            .map_err(|e| crate::error::NxsError::IoError(e.to_string()))?;
391                        json_in::infer_schema(BufReader::new(f1), args)?
392                    };
393
394                    let f2 = std::fs::File::open(&spill_path)
395                        .map_err(|e| crate::error::NxsError::IoError(e.to_string()))?;
396                    match output_path {
397                        Some(out_path) => {
398                            let out = std::fs::File::create(out_path).map_err(|e| {
399                                crate::error::NxsError::IoError(format!(
400                                    "{}: {e}",
401                                    out_path.display()
402                                ))
403                            })?;
404                            json_in::emit(BufReader::new(f2), out, &schema, args)
405                        }
406                        None => json_in::emit(BufReader::new(f2), std::io::stdout(), &schema, args),
407                    }
408                    // spill NamedTempFile is dropped here → removed from disk
409                }
410            }
411        }
412        ImportFormat::Csv => {
413            use crate::convert::csv_in;
414            match input_path {
415                Some(path) => {
416                    let schema = if let Some(hint_path) = &args.schema_hint {
417                        load_schema_hint(hint_path)?
418                    } else {
419                        let f1 = std::fs::File::open(path).map_err(|e| {
420                            crate::error::NxsError::IoError(format!("{}: {e}", path.display()))
421                        })?;
422                        csv_in::infer_schema(BufReader::new(f1), args)?
423                    };
424                    let f2 = std::fs::File::open(path).map_err(|e| {
425                        crate::error::NxsError::IoError(format!("{}: {e}", path.display()))
426                    })?;
427                    match output_path {
428                        Some(out_path) => {
429                            let out = std::fs::File::create(out_path).map_err(|e| {
430                                crate::error::NxsError::IoError(format!(
431                                    "{}: {e}",
432                                    out_path.display()
433                                ))
434                            })?;
435                            csv_in::emit(BufReader::new(f2), out, &schema, args)
436                        }
437                        None => csv_in::emit(BufReader::new(f2), std::io::stdout(), &schema, args),
438                    }
439                }
440                None => {
441                    let mut spill = tempfile::NamedTempFile::new()
442                        .map_err(|e| crate::error::NxsError::IoError(e.to_string()))?;
443                    std::io::copy(&mut std::io::stdin(), &mut spill)
444                        .map_err(|e| crate::error::NxsError::IoError(e.to_string()))?;
445                    let spill_path = spill.path().to_path_buf();
446                    let schema = if let Some(hint_path) = &args.schema_hint {
447                        load_schema_hint(hint_path)?
448                    } else {
449                        let f1 = std::fs::File::open(&spill_path)
450                            .map_err(|e| crate::error::NxsError::IoError(e.to_string()))?;
451                        csv_in::infer_schema(BufReader::new(f1), args)?
452                    };
453                    let f2 = std::fs::File::open(&spill_path)
454                        .map_err(|e| crate::error::NxsError::IoError(e.to_string()))?;
455                    match output_path {
456                        Some(out_path) => {
457                            let out = std::fs::File::create(out_path).map_err(|e| {
458                                crate::error::NxsError::IoError(format!(
459                                    "{}: {e}",
460                                    out_path.display()
461                                ))
462                            })?;
463                            csv_in::emit(BufReader::new(f2), out, &schema, args)
464                        }
465                        None => csv_in::emit(BufReader::new(f2), std::io::stdout(), &schema, args),
466                    }
467                }
468            }
469        }
470        ImportFormat::Xml => {
471            use crate::convert::xml_in;
472            match input_path {
473                Some(path) => {
474                    let schema = if let Some(hint_path) = &args.schema_hint {
475                        load_schema_hint(hint_path)?
476                    } else {
477                        let f1 = std::fs::File::open(path).map_err(|e| {
478                            crate::error::NxsError::IoError(format!("{}: {e}", path.display()))
479                        })?;
480                        xml_in::infer_schema(BufReader::new(f1), args)?
481                    };
482                    let f2 = std::fs::File::open(path).map_err(|e| {
483                        crate::error::NxsError::IoError(format!("{}: {e}", path.display()))
484                    })?;
485                    match output_path {
486                        Some(out_path) => {
487                            let out = std::fs::File::create(out_path).map_err(|e| {
488                                crate::error::NxsError::IoError(format!(
489                                    "{}: {e}",
490                                    out_path.display()
491                                ))
492                            })?;
493                            xml_in::emit(BufReader::new(f2), out, &schema, args)
494                        }
495                        None => xml_in::emit(BufReader::new(f2), std::io::stdout(), &schema, args),
496                    }
497                }
498                None => {
499                    let mut spill = tempfile::NamedTempFile::new()
500                        .map_err(|e| crate::error::NxsError::IoError(e.to_string()))?;
501                    std::io::copy(&mut std::io::stdin(), &mut spill)
502                        .map_err(|e| crate::error::NxsError::IoError(e.to_string()))?;
503                    let spill_path = spill.path().to_path_buf();
504                    let schema = if let Some(hint_path) = &args.schema_hint {
505                        load_schema_hint(hint_path)?
506                    } else {
507                        let f1 = std::fs::File::open(&spill_path)
508                            .map_err(|e| crate::error::NxsError::IoError(e.to_string()))?;
509                        xml_in::infer_schema(BufReader::new(f1), args)?
510                    };
511                    let f2 = std::fs::File::open(&spill_path)
512                        .map_err(|e| crate::error::NxsError::IoError(e.to_string()))?;
513                    match output_path {
514                        Some(out_path) => {
515                            let out = std::fs::File::create(out_path).map_err(|e| {
516                                crate::error::NxsError::IoError(format!(
517                                    "{}: {e}",
518                                    out_path.display()
519                                ))
520                            })?;
521                            xml_in::emit(BufReader::new(f2), out, &schema, args)
522                        }
523                        None => xml_in::emit(BufReader::new(f2), std::io::stdout(), &schema, args),
524                    }
525                }
526            }
527        }
528    }
529}
530
531/// Top-level driver for nxs-export (dispatched on `--to`).
532pub fn run_export(args: &ExportArgs) -> Result<ExportReport> {
533    use crate::convert::json_out;
534
535    let input_path = args.common.input_path.as_deref();
536    let output_path = args.common.output_path.as_deref();
537
538    // Helper: open input as a reader (file or stdin).
539    // Export is single-pass (reads .nxb bytes fully), so no stdin spill needed.
540    macro_rules! open_input {
541        ($path:expr) => {
542            std::fs::File::open($path)
543                .map_err(|e| crate::error::NxsError::IoError(format!("{}: {e}", $path.display())))
544        };
545    }
546
547    macro_rules! open_output {
548        ($path:expr) => {
549            std::fs::File::create($path)
550                .map_err(|e| crate::error::NxsError::IoError(format!("{}: {e}", $path.display())))
551        };
552    }
553
554    match args.to {
555        ExportFormat::Json => match (input_path, output_path) {
556            (Some(inp), Some(out)) => json_out::run(open_input!(inp)?, open_output!(out)?, args),
557            (Some(inp), None) => json_out::run(open_input!(inp)?, std::io::stdout(), args),
558            (None, Some(out)) => json_out::run(std::io::stdin(), open_output!(out)?, args),
559            (None, None) => json_out::run(std::io::stdin(), std::io::stdout(), args),
560        },
561        ExportFormat::Csv => {
562            use crate::convert::csv_out;
563            match (input_path, output_path) {
564                (Some(inp), Some(out)) => csv_out::run(open_input!(inp)?, open_output!(out)?, args),
565                (Some(inp), None) => csv_out::run(open_input!(inp)?, std::io::stdout(), args),
566                (None, Some(out)) => csv_out::run(std::io::stdin(), open_output!(out)?, args),
567                (None, None) => csv_out::run(std::io::stdin(), std::io::stdout(), args),
568            }
569        }
570    }
571}
572
573/// Top-level driver for nxs-inspect.
574pub fn run_inspect(args: &InspectArgs) -> Result<InspectReport> {
575    use crate::convert::inspect;
576    if args.json_output {
577        inspect::render_json(std::io::stdout(), args)
578    } else {
579        inspect::render_text(std::io::stdout(), args)
580    }
581}
582
583// ── Tests ─────────────────────────────────────────────────────────────────────
584
585#[cfg(test)]
586mod tests {
587    use super::*;
588
589    /// Every flag that the spec defines for nxs-import must have a corresponding
590    /// field in `ImportArgs`. Update this list whenever the spec changes.
591    #[test]
592    fn import_args_maps_every_spec_flag() {
593        // Hand-written mirror of spec nxs_import.optional_flags (plus required).
594        // This test fails at compile time if a field is removed; it fails at
595        // runtime if someone forgets to add a new spec flag to the list below.
596        let spec_fields: &[&str] = &[
597            "from",
598            "schema_hint",
599            "conflict",
600            "root",
601            "csv_delimiter",
602            "csv_no_header",
603            "xml_record_tag",
604            "xml_attrs",
605            "buffer_records",
606            "max_depth",
607            "xml_max_depth",
608            "tail_index_spill",
609            "verify",
610        ];
611        // Build the struct and access every field so the compiler catches removals.
612        let a = ImportArgs::default();
613        let _ = &a.from;
614        let _ = &a.schema_hint;
615        let _ = &a.conflict;
616        let _ = &a.root;
617        let _ = &a.csv_delimiter;
618        let _ = &a.csv_no_header;
619        let _ = &a.xml_record_tag;
620        let _ = &a.xml_attrs;
621        let _ = &a.buffer_records;
622        let _ = &a.max_depth;
623        let _ = &a.xml_max_depth;
624        let _ = &a.tail_index_spill;
625        let _ = &a.verify;
626        assert_eq!(spec_fields.len(), 13, "spec has 13 import flags");
627    }
628
629    #[test]
630    fn export_args_maps_every_spec_flag() {
631        let spec_fields: &[&str] = &[
632            "to",
633            "pretty",
634            "ndjson",
635            "columns",
636            "csv_delimiter",
637            "binary",
638            "csv_safe",
639        ];
640        let a = ExportArgs::default();
641        let _ = &a.to;
642        let _ = &a.pretty;
643        let _ = &a.ndjson;
644        let _ = &a.columns;
645        let _ = &a.csv_delimiter;
646        let _ = &a.binary;
647        let _ = &a.csv_safe;
648        assert_eq!(spec_fields.len(), 7, "spec has 7 export flags");
649    }
650
651    #[test]
652    fn inspect_args_maps_every_spec_flag() {
653        let spec_fields: &[&str] = &["json_output", "records_to_show", "verify_hash"];
654        let a = InspectArgs::default();
655        let _ = &a.json_output;
656        let _ = &a.records_to_show;
657        let _ = &a.verify_hash;
658        assert_eq!(spec_fields.len(), 3, "spec has 3 inspect flags");
659    }
660
661    /// Each new NxsError convert variant maps to the exit code in the spec.
662    #[test]
663    fn convert_errors_map_to_documented_exit_codes() {
664        use crate::error::NxsError;
665        assert_eq!(
666            exit_code_for(&NxsError::ConvertSchemaConflict("x".into())),
667            4
668        );
669        assert_eq!(
670            exit_code_for(&NxsError::ConvertParseError {
671                offset: 0,
672                msg: "bad".into()
673            }),
674            3
675        );
676        assert_eq!(exit_code_for(&NxsError::ConvertEntityExpansion), 3);
677        assert_eq!(exit_code_for(&NxsError::ConvertDepthExceeded), 3);
678        assert_eq!(exit_code_for(&NxsError::IoError("disk full".into())), 5);
679        assert_eq!(exit_code_for(&NxsError::BadMagic), 3);
680    }
681
682    /// Output path derivation uses only `Path::file_name()` — never traverses `..`.
683    #[test]
684    fn import_output_path_derivation_does_not_traverse() {
685        let cases = &[
686            ("../foo.json", "foo.nxb"),
687            ("/tmp/foo.json", "foo.nxb"),
688            ("foo.json", "foo.nxb"),
689            ("./bar/baz.csv", "baz.nxb"),
690        ];
691        for (input, expected) in cases {
692            let p = std::path::Path::new(input);
693            let stem = p
694                .file_name()
695                .and_then(|n| std::path::Path::new(n).file_stem())
696                .expect("no file stem");
697            let derived = std::path::PathBuf::from(stem).with_extension("nxb");
698            assert_eq!(derived.to_str().unwrap_or(""), *expected, "input={input}");
699            // Must not contain `..`
700            assert!(
701                !derived
702                    .components()
703                    .any(|c| c == std::path::Component::ParentDir),
704                "traversal in derived path for input={input}"
705            );
706        }
707    }
708}