Skip to main content

vaultdb_core/
render.rs

1//! Export rendering: serialize query results into CSV, JSON, YAML, or XLSX
2//! and atomically write them to a vault-scoped path.
3//!
4//! This module is the single source of truth for what an exported file
5//! looks like. The CLI's `--output` flag and the MCP read tools' `export`
6//! parameter both delegate here, which keeps the wire shape identical
7//! across consumers and lets the pyo3 / wasm bindings get the same
8//! renderers for free.
9//!
10//! The XLSX backend is feature-gated behind `xlsx` (default off) so
11//! consumers that don't need spreadsheet output don't pay for
12//! `rust_xlsxwriter` and its dependency tree.
13//!
14//! ## Path safety
15//!
16//! All exports are written **inside the vault root**. Absolute paths,
17//! `..` components, and symlink escapes are rejected. On filename
18//! collision, the renderer auto-suffixes `(1)`, `(2)`, ... — there is no
19//! overwrite mode, by design.
20
21use std::collections::BTreeSet;
22use std::path::{Component, Path, PathBuf};
23
24use crate::error::{Result, VaultdbError};
25use crate::links::LinkGraph;
26use crate::record::{Record, Value};
27
28// ── Format ─────────────────────────────────────────────────────────────
29
30/// Export output format. CSV carries the delimiter byte so callers can
31/// pick comma / semicolon / tab without a separate enum.
32#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Format {
34    /// Comma-separated values. The delimiter is configurable: `b','` for
35    /// RFC 4180, `b';'` for European-style CSV, `b'\t'` for TSV.
36    Csv { delimiter: u8 },
37    /// Excel 2007+ `.xlsx`. Available only when vaultdb-core is built
38    /// with the `xlsx` feature.
39    #[cfg(feature = "xlsx")]
40    Xlsx,
41    /// Pretty-printed JSON.
42    Json,
43    /// YAML.
44    Yaml,
45}
46
47impl Format {
48    /// Infer the format from a path's extension.
49    ///
50    /// - `.csv` → `Csv { delimiter: b',' }`
51    /// - `.tsv` → `Csv { delimiter: b'\t' }`
52    /// - `.json` → `Json`
53    /// - `.yaml` / `.yml` → `Yaml`
54    /// - `.xlsx` → `Xlsx` (requires `xlsx` feature)
55    /// - `.md` → refused (`.md` is the vault's note format, not an export format)
56    /// - everything else → error listing the supported extensions
57    pub fn from_path(path: &Path) -> Result<Self> {
58        let ext = path
59            .extension()
60            .and_then(|e| e.to_str())
61            .map(|s| s.to_ascii_lowercase());
62        match ext.as_deref() {
63            Some("csv") => Ok(Format::Csv { delimiter: b',' }),
64            Some("tsv") => Ok(Format::Csv { delimiter: b'\t' }),
65            Some("json") => Ok(Format::Json),
66            Some("yaml") | Some("yml") => Ok(Format::Yaml),
67            #[cfg(feature = "xlsx")]
68            Some("xlsx") => Ok(Format::Xlsx),
69            #[cfg(not(feature = "xlsx"))]
70            Some("xlsx") => Err(VaultdbError::SafetyRefused {
71                reason:
72                    "xlsx export unavailable: vaultdb-core was built without the 'xlsx' feature"
73                        .into(),
74            }),
75            Some("md") => Err(VaultdbError::SafetyRefused {
76                reason: format!(
77                    "refusing to export to '{}': .md is the vault's note format, not an export format",
78                    path.display()
79                ),
80            }),
81            Some(other) => Err(VaultdbError::SafetyRefused {
82                reason: format!(
83                    "unsupported export extension '.{}'; expected one of: csv, tsv, json, yaml, yml, xlsx",
84                    other
85                ),
86            }),
87            None => Err(VaultdbError::SafetyRefused {
88                reason: format!(
89                    "export path '{}' has no extension; expected one of: csv, tsv, json, yaml, yml, xlsx",
90                    path.display()
91                ),
92            }),
93        }
94    }
95
96    /// Override the CSV delimiter. No-op on non-CSV formats.
97    pub fn with_csv_delimiter(self, delimiter: u8) -> Self {
98        match self {
99            Format::Csv { .. } => Format::Csv { delimiter },
100            other => other,
101        }
102    }
103}
104
105// ── Path safety ────────────────────────────────────────────────────────
106
107/// Resolve a requested export path against the vault root, enforcing the
108/// vault sandbox and auto-suffixing on filename collision.
109///
110/// Rules:
111/// - Path must be vault-relative (not absolute).
112/// - No `..` components.
113/// - Cannot have a `.md` extension (vault's own format).
114/// - After joining + canonicalizing the parent, the path must stay under
115///   the canonical vault root (catches symlink escapes).
116/// - If a file at the target path already exists, the returned path is
117///   `<stem> (1).<ext>`, then `<stem> (2).<ext>`, etc. (macOS-style).
118///
119/// Returns the resolved absolute path where the export should be written.
120/// The parent directory is created as a side effect.
121pub fn resolve_export_path(vault_root: &Path, requested: &Path) -> Result<PathBuf> {
122    if requested.as_os_str().is_empty() {
123        return Err(VaultdbError::SafetyRefused {
124            reason: "export path is empty".into(),
125        });
126    }
127    if requested.is_absolute() {
128        return Err(VaultdbError::SafetyRefused {
129            reason: format!(
130                "export path must be vault-relative, got absolute path: {}",
131                requested.display()
132            ),
133        });
134    }
135    for component in requested.components() {
136        if matches!(component, Component::ParentDir) {
137            return Err(VaultdbError::SafetyRefused {
138                reason: format!("export path must not contain '..': {}", requested.display()),
139            });
140        }
141        if matches!(component, Component::Prefix(_) | Component::RootDir) {
142            return Err(VaultdbError::SafetyRefused {
143                reason: format!(
144                    "export path must be vault-relative: {}",
145                    requested.display()
146                ),
147            });
148        }
149    }
150    let ext_lower = requested
151        .extension()
152        .and_then(|e| e.to_str())
153        .map(|s| s.to_ascii_lowercase());
154    if ext_lower.as_deref() == Some("md") {
155        return Err(VaultdbError::SafetyRefused {
156            reason: format!(
157                "refusing to export to '{}': use a non-.md extension (csv, tsv, json, yaml, xlsx)",
158                requested.display()
159            ),
160        });
161    }
162
163    let candidate = vault_root.join(requested);
164    let parent = candidate
165        .parent()
166        .ok_or_else(|| VaultdbError::SafetyRefused {
167            reason: format!("export path has no parent: {}", requested.display()),
168        })?;
169    let file_name = candidate
170        .file_name()
171        .ok_or_else(|| VaultdbError::SafetyRefused {
172            reason: format!("export path has no filename: {}", requested.display()),
173        })?
174        .to_owned();
175
176    std::fs::create_dir_all(parent)?;
177
178    let canonical_vault = vault_root.canonicalize()?;
179    let canonical_parent = parent.canonicalize()?;
180    if !canonical_parent.starts_with(&canonical_vault) {
181        return Err(VaultdbError::SafetyRefused {
182            reason: format!(
183                "export path escapes vault root: {} resolved to {}",
184                requested.display(),
185                canonical_parent.display()
186            ),
187        });
188    }
189
190    let target = canonical_parent.join(&file_name);
191    if !target.exists() {
192        return Ok(target);
193    }
194
195    // Auto-suffix on collision: `Foo.csv` → `Foo (1).csv`, ...
196    let stem = candidate
197        .file_stem()
198        .and_then(|s| s.to_str())
199        .ok_or_else(|| VaultdbError::SafetyRefused {
200            reason: format!("export path stem is not utf-8: {}", requested.display()),
201        })?;
202    let ext = candidate.extension().and_then(|s| s.to_str());
203    for i in 1..=10_000 {
204        let suffixed = match ext {
205            Some(e) => canonical_parent.join(format!("{} ({}).{}", stem, i, e)),
206            None => canonical_parent.join(format!("{} ({})", stem, i)),
207        };
208        if !suffixed.exists() {
209            return Ok(suffixed);
210        }
211    }
212    Err(VaultdbError::SafetyRefused {
213        reason: format!(
214            "export path collision could not be resolved after 10000 attempts: {}",
215            target.display()
216        ),
217    })
218}
219
220// ── Public exporters ───────────────────────────────────────────────────
221
222/// Export a list of [`Record`]s to a file under the vault root.
223///
224/// If `select` is `Some(&[..])` and non-empty, those field names define
225/// the output columns; otherwise the column set is the union of all
226/// frontmatter keys across the records, always prefixed with `_name`.
227/// Virtual fields like `_backlink_count` are resolved via `link_index`
228/// when present.
229///
230/// Returns the actual absolute path written (after path resolution and
231/// any auto-suffix on filename collision).
232pub fn export_records(
233    vault_root: &Path,
234    requested_path: &Path,
235    format: Format,
236    records: &[Record],
237    select: Option<&[String]>,
238    link_index: Option<&LinkGraph>,
239) -> Result<PathBuf> {
240    let fields = match select {
241        Some(s) if !s.is_empty() => s.to_vec(),
242        _ => infer_fields(records),
243    };
244    let bytes = render_records(records, &fields, vault_root, link_index, format)?;
245    let resolved = resolve_export_path(vault_root, requested_path)?;
246    atomic_write_bytes(&resolved, &bytes)?;
247    Ok(resolved)
248}
249
250/// Export an arbitrary JSON-serializable value to a file under the vault
251/// root.
252///
253/// JSON and YAML accept any shape. CSV and XLSX only work when the value
254/// is tabular — an array of objects, an array of scalars, or a single
255/// object — and return an error for anything else.
256pub fn export_value(
257    vault_root: &Path,
258    requested_path: &Path,
259    format: Format,
260    value: &serde_json::Value,
261) -> Result<PathBuf> {
262    let bytes = render_value(value, format)?;
263    let resolved = resolve_export_path(vault_root, requested_path)?;
264    atomic_write_bytes(&resolved, &bytes)?;
265    Ok(resolved)
266}
267
268// ── Records → bytes ────────────────────────────────────────────────────
269
270/// Render records to the in-memory byte buffer for `format`. Public for
271/// consumers that want to stream/inspect bytes without writing to disk.
272pub fn render_records(
273    records: &[Record],
274    fields: &[String],
275    vault_root: &Path,
276    link_index: Option<&LinkGraph>,
277    format: Format,
278) -> Result<Vec<u8>> {
279    match format {
280        Format::Json => render_records_json(records, fields, vault_root, link_index),
281        Format::Yaml => render_records_yaml(records, fields, vault_root, link_index),
282        Format::Csv { delimiter } => {
283            render_records_csv(records, fields, vault_root, link_index, delimiter)
284        }
285        #[cfg(feature = "xlsx")]
286        Format::Xlsx => render_records_xlsx(records, fields, vault_root, link_index),
287    }
288}
289
290/// Field inference: union of all frontmatter keys across records,
291/// always prefixed with `_name` so callers can identify rows.
292fn infer_fields(records: &[Record]) -> Vec<String> {
293    let mut seen = BTreeSet::new();
294    for record in records {
295        for key in record.fields.keys() {
296            seen.insert(key.clone());
297        }
298    }
299    let mut fields = vec!["_name".to_string()];
300    for key in seen {
301        if key != "_name" {
302            fields.push(key);
303        }
304    }
305    fields
306}
307
308fn render_records_json(
309    records: &[Record],
310    fields: &[String],
311    vault_root: &Path,
312    link_index: Option<&LinkGraph>,
313) -> Result<Vec<u8>> {
314    let items: Vec<serde_json::Value> = records
315        .iter()
316        .map(|r| {
317            let mut map = serde_json::Map::new();
318            for f in fields {
319                let val = r
320                    .get_with_links(f, vault_root, link_index)
321                    .unwrap_or(Value::Null);
322                map.insert(f.clone(), value_to_json(&val));
323            }
324            serde_json::Value::Object(map)
325        })
326        .collect();
327    serde_json::to_vec_pretty(&items)
328        .map_err(|e| VaultdbError::Internal(format!("json serialize: {}", e)))
329}
330
331fn render_records_yaml(
332    records: &[Record],
333    fields: &[String],
334    vault_root: &Path,
335    link_index: Option<&LinkGraph>,
336) -> Result<Vec<u8>> {
337    let mut out = String::new();
338    for record in records {
339        out.push_str("---\n");
340        for f in fields {
341            let val = record
342                .get_with_links(f, vault_root, link_index)
343                .unwrap_or(Value::Null);
344            out.push_str(&format!("{}: {}\n", f, val.display_value()));
345        }
346    }
347    Ok(out.into_bytes())
348}
349
350fn render_records_csv(
351    records: &[Record],
352    fields: &[String],
353    vault_root: &Path,
354    link_index: Option<&LinkGraph>,
355    delimiter: u8,
356) -> Result<Vec<u8>> {
357    let mut buf = Vec::new();
358    {
359        let mut wtr = csv::WriterBuilder::new()
360            .delimiter(delimiter)
361            .from_writer(&mut buf);
362        wtr.write_record(fields)
363            .map_err(|e| VaultdbError::Internal(format!("csv header: {}", e)))?;
364        for record in records {
365            let row: Vec<String> = fields
366                .iter()
367                .map(|f| {
368                    record
369                        .get_with_links(f, vault_root, link_index)
370                        .map(|v| v.display_value())
371                        .unwrap_or_default()
372                })
373                .collect();
374            wtr.write_record(&row)
375                .map_err(|e| VaultdbError::Internal(format!("csv row: {}", e)))?;
376        }
377        wtr.flush()
378            .map_err(|e| VaultdbError::Internal(format!("csv flush: {}", e)))?;
379    }
380    Ok(buf)
381}
382
383#[cfg(feature = "xlsx")]
384fn render_records_xlsx(
385    records: &[Record],
386    fields: &[String],
387    vault_root: &Path,
388    link_index: Option<&LinkGraph>,
389) -> Result<Vec<u8>> {
390    use rust_xlsxwriter::Workbook;
391
392    let mut workbook = Workbook::new();
393    let worksheet = workbook.add_worksheet();
394
395    for (col, name) in fields.iter().enumerate() {
396        worksheet
397            .write(0, col as u16, name.as_str())
398            .map_err(|e| VaultdbError::Internal(format!("xlsx header: {}", e)))?;
399    }
400    for (row, record) in records.iter().enumerate() {
401        let row_idx = (row + 1) as u32;
402        for (col, field) in fields.iter().enumerate() {
403            let col_idx = col as u16;
404            let val = record
405                .get_with_links(field, vault_root, link_index)
406                .unwrap_or(Value::Null);
407            write_value_to_xlsx_cell(worksheet, row_idx, col_idx, &val)
408                .map_err(|e| VaultdbError::Internal(format!("xlsx cell: {}", e)))?;
409        }
410    }
411    workbook
412        .save_to_buffer()
413        .map_err(|e| VaultdbError::Internal(format!("xlsx save: {}", e)))
414}
415
416#[cfg(feature = "xlsx")]
417fn write_value_to_xlsx_cell(
418    worksheet: &mut rust_xlsxwriter::Worksheet,
419    row: u32,
420    col: u16,
421    value: &Value,
422) -> std::result::Result<(), rust_xlsxwriter::XlsxError> {
423    match value {
424        Value::Null => worksheet.write(row, col, "").map(|_| ()),
425        Value::String(s) => worksheet.write(row, col, s.as_str()).map(|_| ()),
426        Value::Integer(n) => worksheet.write(row, col, *n as f64).map(|_| ()),
427        Value::Float(f) => worksheet.write(row, col, *f).map(|_| ()),
428        Value::Bool(b) => worksheet.write(row, col, *b).map(|_| ()),
429        Value::List(_) | Value::Map(_) => worksheet
430            .write(row, col, value.display_value().as_str())
431            .map(|_| ()),
432    }
433}
434
435// ── Generic JSON value → bytes ─────────────────────────────────────────
436
437/// Render an arbitrary JSON value to the format's bytes. JSON / YAML
438/// accept any shape; CSV / XLSX only accept tabular shapes (array of
439/// objects, array of scalars, single object).
440pub fn render_value(value: &serde_json::Value, format: Format) -> Result<Vec<u8>> {
441    match format {
442        Format::Json => serde_json::to_vec_pretty(value)
443            .map_err(|e| VaultdbError::Internal(format!("json serialize: {}", e))),
444        Format::Yaml => serde_yaml::to_string(value)
445            .map(String::into_bytes)
446            .map_err(|e| VaultdbError::Internal(format!("yaml serialize: {}", e))),
447        Format::Csv { delimiter } => render_value_csv(value, delimiter),
448        #[cfg(feature = "xlsx")]
449        Format::Xlsx => render_value_xlsx(value),
450    }
451}
452
453/// Flatten a JSON value to a (header, rows) tabular shape, or `None` if
454/// the value isn't tabular. Used by both the CSV and XLSX backends.
455fn json_to_table(value: &serde_json::Value) -> Option<(Vec<String>, Vec<Vec<String>>)> {
456    use serde_json::Value as JV;
457    match value {
458        JV::Array(items) if items.is_empty() => Some((Vec::new(), Vec::new())),
459        JV::Array(items) if items.iter().all(|v| v.is_object()) => {
460            let mut keys = BTreeSet::new();
461            for item in items {
462                if let Some(obj) = item.as_object() {
463                    for k in obj.keys() {
464                        keys.insert(k.clone());
465                    }
466                }
467            }
468            let header: Vec<String> = keys.into_iter().collect();
469            let rows: Vec<Vec<String>> = items
470                .iter()
471                .map(|item| {
472                    let obj = item.as_object().unwrap();
473                    header
474                        .iter()
475                        .map(|k| obj.get(k).map(json_to_cell_string).unwrap_or_default())
476                        .collect()
477                })
478                .collect();
479            Some((header, rows))
480        }
481        JV::Array(items) if items.iter().all(|v| !v.is_array() && !v.is_object()) => {
482            let header = vec!["value".to_string()];
483            let rows = items.iter().map(|v| vec![json_to_cell_string(v)]).collect();
484            Some((header, rows))
485        }
486        JV::Object(_) => {
487            // Single object → 1-row table over its keys.
488            let array = JV::Array(vec![value.clone()]);
489            json_to_table(&array)
490        }
491        _ => None,
492    }
493}
494
495fn render_value_csv(value: &serde_json::Value, delimiter: u8) -> Result<Vec<u8>> {
496    let (header, rows) = json_to_table(value).ok_or_else(|| VaultdbError::SafetyRefused {
497        reason: "cannot render this shape as CSV; use json or yaml".into(),
498    })?;
499    let mut buf = Vec::new();
500    {
501        let mut wtr = csv::WriterBuilder::new()
502            .delimiter(delimiter)
503            .from_writer(&mut buf);
504        if !header.is_empty() {
505            wtr.write_record(&header)
506                .map_err(|e| VaultdbError::Internal(format!("csv header: {}", e)))?;
507        }
508        for row in rows {
509            wtr.write_record(&row)
510                .map_err(|e| VaultdbError::Internal(format!("csv row: {}", e)))?;
511        }
512        wtr.flush()
513            .map_err(|e| VaultdbError::Internal(format!("csv flush: {}", e)))?;
514    }
515    Ok(buf)
516}
517
518#[cfg(feature = "xlsx")]
519fn render_value_xlsx(value: &serde_json::Value) -> Result<Vec<u8>> {
520    use rust_xlsxwriter::Workbook;
521
522    let (header, rows) = json_to_table(value).ok_or_else(|| VaultdbError::SafetyRefused {
523        reason: "cannot render this shape as XLSX; use json or yaml".into(),
524    })?;
525
526    let mut workbook = Workbook::new();
527    let worksheet = workbook.add_worksheet();
528    for (col, name) in header.iter().enumerate() {
529        worksheet
530            .write(0, col as u16, name.as_str())
531            .map_err(|e| VaultdbError::Internal(format!("xlsx header: {}", e)))?;
532    }
533    for (row_idx, row) in rows.iter().enumerate() {
534        let row_num = (row_idx + 1) as u32;
535        for (col_idx, cell) in row.iter().enumerate() {
536            // Try to write numeric/bool when the cell parses as one; fall
537            // back to string. Keeps XLSX cells typed when the underlying
538            // JSON was typed, without us re-walking the original Value.
539            if let Ok(n) = cell.parse::<i64>() {
540                worksheet
541                    .write(row_num, col_idx as u16, n as f64)
542                    .map_err(|e| VaultdbError::Internal(format!("xlsx cell: {}", e)))?;
543            } else if let Ok(f) = cell.parse::<f64>() {
544                worksheet
545                    .write(row_num, col_idx as u16, f)
546                    .map_err(|e| VaultdbError::Internal(format!("xlsx cell: {}", e)))?;
547            } else if cell == "true" {
548                worksheet
549                    .write(row_num, col_idx as u16, true)
550                    .map_err(|e| VaultdbError::Internal(format!("xlsx cell: {}", e)))?;
551            } else if cell == "false" {
552                worksheet
553                    .write(row_num, col_idx as u16, false)
554                    .map_err(|e| VaultdbError::Internal(format!("xlsx cell: {}", e)))?;
555            } else {
556                worksheet
557                    .write(row_num, col_idx as u16, cell.as_str())
558                    .map_err(|e| VaultdbError::Internal(format!("xlsx cell: {}", e)))?;
559            }
560        }
561    }
562    workbook
563        .save_to_buffer()
564        .map_err(|e| VaultdbError::Internal(format!("xlsx save: {}", e)))
565}
566
567/// JSON scalar/array/object → flat cell string. Arrays join with `, `.
568fn json_to_cell_string(v: &serde_json::Value) -> String {
569    use serde_json::Value as JV;
570    match v {
571        JV::Null => String::new(),
572        JV::Bool(b) => b.to_string(),
573        JV::Number(n) => n.to_string(),
574        JV::String(s) => s.clone(),
575        JV::Array(items) => items
576            .iter()
577            .map(json_to_cell_string)
578            .collect::<Vec<_>>()
579            .join(", "),
580        JV::Object(_) => v.to_string(),
581    }
582}
583
584/// `Value` → `serde_json::Value`. Same conversion the CLI used inline;
585/// lifted here so the renderer is self-contained.
586fn value_to_json(val: &Value) -> serde_json::Value {
587    match val {
588        Value::Null => serde_json::Value::Null,
589        Value::String(s) => serde_json::Value::String(s.clone()),
590        Value::Integer(n) => serde_json::json!(n),
591        Value::Float(f) => serde_json::json!(f),
592        Value::Bool(b) => serde_json::Value::Bool(*b),
593        Value::List(items) => serde_json::Value::Array(items.iter().map(value_to_json).collect()),
594        Value::Map(m) => {
595            let obj: serde_json::Map<String, serde_json::Value> = m
596                .iter()
597                .map(|(k, v)| (k.clone(), value_to_json(v)))
598                .collect();
599            serde_json::Value::Object(obj)
600        }
601    }
602}
603
604// ── Atomic write ───────────────────────────────────────────────────────
605
606/// Atomic write: temp-file-in-same-dir + rename. Mirrors the shape of
607/// `writer::atomic_write` but takes bytes instead of `&str` (XLSX is
608/// binary).
609fn atomic_write_bytes(path: &Path, bytes: &[u8]) -> Result<()> {
610    let dir = path.parent().ok_or_else(|| {
611        VaultdbError::Internal(format!(
612            "atomic_write_bytes: no parent dir for {}",
613            path.display()
614        ))
615    })?;
616    let mut tmp = tempfile::NamedTempFile::new_in(dir)?;
617    use std::io::Write;
618    tmp.write_all(bytes)?;
619    tmp.flush()?;
620    tmp.persist(path).map_err(|e| VaultdbError::Io(e.error))?;
621    Ok(())
622}
623
624// ── Tests ──────────────────────────────────────────────────────────────
625
626#[cfg(test)]
627mod tests {
628    use super::*;
629    use std::collections::BTreeMap;
630    use std::path::PathBuf;
631    use tempfile::TempDir;
632
633    fn vault() -> TempDir {
634        TempDir::new().expect("tempdir")
635    }
636
637    fn rec(name: &str, dir: &Path, fields: &[(&str, Value)]) -> Record {
638        let path = dir.join(format!("{}.md", name));
639        std::fs::write(&path, format!("---\n_name: {}\n---\n", name)).unwrap();
640        let mut map = BTreeMap::new();
641        for (k, v) in fields {
642            map.insert(k.to_string(), v.clone());
643        }
644        Record {
645            path,
646            fields: map,
647            raw_content: None,
648        }
649    }
650
651    // Format extension inference
652
653    #[test]
654    fn format_csv_default_delimiter_is_comma() {
655        let f = Format::from_path(Path::new("foo.csv")).unwrap();
656        assert_eq!(f, Format::Csv { delimiter: b',' });
657    }
658
659    #[test]
660    fn format_tsv_uses_tab_delimiter() {
661        let f = Format::from_path(Path::new("foo.tsv")).unwrap();
662        assert_eq!(f, Format::Csv { delimiter: b'\t' });
663    }
664
665    #[test]
666    fn format_json_yaml() {
667        assert_eq!(
668            Format::from_path(Path::new("a.json")).unwrap(),
669            Format::Json
670        );
671        assert_eq!(
672            Format::from_path(Path::new("a.yaml")).unwrap(),
673            Format::Yaml
674        );
675        assert_eq!(Format::from_path(Path::new("a.yml")).unwrap(), Format::Yaml);
676    }
677
678    #[test]
679    fn format_uppercase_extension() {
680        assert_eq!(
681            Format::from_path(Path::new("FOO.CSV")).unwrap(),
682            Format::Csv { delimiter: b',' }
683        );
684    }
685
686    #[test]
687    fn format_md_extension_refused() {
688        let err = Format::from_path(Path::new("foo.md")).unwrap_err();
689        assert!(matches!(err, VaultdbError::SafetyRefused { .. }));
690    }
691
692    #[test]
693    fn format_unknown_extension_refused() {
694        let err = Format::from_path(Path::new("foo.pdf")).unwrap_err();
695        assert!(matches!(err, VaultdbError::SafetyRefused { .. }));
696    }
697
698    #[test]
699    fn format_missing_extension_refused() {
700        let err = Format::from_path(Path::new("nofile")).unwrap_err();
701        assert!(matches!(err, VaultdbError::SafetyRefused { .. }));
702    }
703
704    #[test]
705    fn with_csv_delimiter_overrides_only_csv() {
706        let csv = Format::Csv { delimiter: b',' }.with_csv_delimiter(b';');
707        assert_eq!(csv, Format::Csv { delimiter: b';' });
708        assert_eq!(Format::Json.with_csv_delimiter(b';'), Format::Json);
709    }
710
711    // Path resolution
712
713    #[test]
714    fn resolve_basic_path() {
715        let v = vault();
716        let p = resolve_export_path(v.path(), Path::new("foo.csv")).unwrap();
717        // Canonicalized vault path should contain the file.
718        assert_eq!(p.file_name().unwrap(), "foo.csv");
719        assert!(p.starts_with(v.path().canonicalize().unwrap()));
720    }
721
722    #[test]
723    fn resolve_creates_parent_dirs() {
724        let v = vault();
725        let p = resolve_export_path(v.path(), Path::new("a/b/c/foo.csv")).unwrap();
726        assert!(p.parent().unwrap().exists());
727    }
728
729    #[test]
730    fn resolve_rejects_absolute() {
731        let v = vault();
732        let err = resolve_export_path(v.path(), Path::new("/etc/passwd.csv")).unwrap_err();
733        assert!(matches!(err, VaultdbError::SafetyRefused { .. }));
734    }
735
736    #[test]
737    fn resolve_rejects_parent_dir_escape() {
738        let v = vault();
739        let err = resolve_export_path(v.path(), Path::new("../escape.csv")).unwrap_err();
740        assert!(matches!(err, VaultdbError::SafetyRefused { .. }));
741    }
742
743    #[test]
744    fn resolve_rejects_deep_parent_dir_escape() {
745        let v = vault();
746        let err = resolve_export_path(v.path(), Path::new("a/b/../../../escape.csv")).unwrap_err();
747        assert!(matches!(err, VaultdbError::SafetyRefused { .. }));
748    }
749
750    #[test]
751    fn resolve_rejects_md_extension() {
752        let v = vault();
753        let err = resolve_export_path(v.path(), Path::new("Note.md")).unwrap_err();
754        assert!(matches!(err, VaultdbError::SafetyRefused { .. }));
755    }
756
757    #[test]
758    fn resolve_rejects_empty_path() {
759        let v = vault();
760        let err = resolve_export_path(v.path(), Path::new("")).unwrap_err();
761        assert!(matches!(err, VaultdbError::SafetyRefused { .. }));
762    }
763
764    #[test]
765    fn resolve_autosuffix_on_collision() {
766        let v = vault();
767        let first = resolve_export_path(v.path(), Path::new("foo.csv")).unwrap();
768        std::fs::write(&first, b"already here").unwrap();
769
770        let second = resolve_export_path(v.path(), Path::new("foo.csv")).unwrap();
771        assert_eq!(second.file_name().unwrap(), "foo (1).csv");
772        std::fs::write(&second, b"and here").unwrap();
773
774        let third = resolve_export_path(v.path(), Path::new("foo.csv")).unwrap();
775        assert_eq!(third.file_name().unwrap(), "foo (2).csv");
776    }
777
778    // Record rendering
779
780    #[test]
781    fn export_records_json() {
782        let v = vault();
783        let dir = v.path().join("notes");
784        std::fs::create_dir_all(&dir).unwrap();
785        let records = vec![
786            rec("Alpha", &dir, &[("status", Value::String("active".into()))]),
787            rec("Beta", &dir, &[("status", Value::String("draft".into()))]),
788        ];
789        let path = export_records(
790            v.path(),
791            Path::new("out.json"),
792            Format::Json,
793            &records,
794            None,
795            None,
796        )
797        .unwrap();
798        let body = std::fs::read_to_string(&path).unwrap();
799        let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
800        let arr = parsed.as_array().unwrap();
801        assert_eq!(arr.len(), 2);
802        assert_eq!(arr[0]["_name"], "Alpha");
803        assert_eq!(arr[0]["status"], "active");
804    }
805
806    #[test]
807    fn export_records_csv_default_comma() {
808        let v = vault();
809        let dir = v.path().join("notes");
810        std::fs::create_dir_all(&dir).unwrap();
811        let records = vec![rec("Alpha", &dir, &[("rating", Value::Integer(5))])];
812        let path = export_records(
813            v.path(),
814            Path::new("out.csv"),
815            Format::Csv { delimiter: b',' },
816            &records,
817            None,
818            None,
819        )
820        .unwrap();
821        let body = std::fs::read_to_string(&path).unwrap();
822        let mut lines = body.lines();
823        let header = lines.next().unwrap();
824        assert!(header.contains("_name") && header.contains("rating"));
825        assert!(header.contains(','));
826        let row = lines.next().unwrap();
827        assert!(row.contains("Alpha"));
828        assert!(row.contains('5'));
829    }
830
831    #[test]
832    fn export_records_csv_semicolon_delimiter() {
833        let v = vault();
834        let dir = v.path().join("notes");
835        std::fs::create_dir_all(&dir).unwrap();
836        let records = vec![rec("Alpha", &dir, &[("x", Value::Integer(1))])];
837        let path = export_records(
838            v.path(),
839            Path::new("out.csv"),
840            Format::Csv { delimiter: b';' },
841            &records,
842            None,
843            None,
844        )
845        .unwrap();
846        let body = std::fs::read_to_string(&path).unwrap();
847        let header = body.lines().next().unwrap();
848        assert!(header.contains(';'));
849        assert!(!header.contains(','));
850    }
851
852    #[test]
853    fn export_records_tsv_via_extension() {
854        let v = vault();
855        let dir = v.path().join("notes");
856        std::fs::create_dir_all(&dir).unwrap();
857        let records = vec![rec("Alpha", &dir, &[("x", Value::Integer(1))])];
858        let fmt = Format::from_path(Path::new("out.tsv")).unwrap();
859        let path =
860            export_records(v.path(), Path::new("out.tsv"), fmt, &records, None, None).unwrap();
861        let body = std::fs::read_to_string(&path).unwrap();
862        let header = body.lines().next().unwrap();
863        assert!(header.contains('\t'));
864    }
865
866    #[test]
867    fn export_records_yaml() {
868        let v = vault();
869        let dir = v.path().join("notes");
870        std::fs::create_dir_all(&dir).unwrap();
871        let records = vec![rec(
872            "Alpha",
873            &dir,
874            &[("status", Value::String("active".into()))],
875        )];
876        let path = export_records(
877            v.path(),
878            Path::new("out.yaml"),
879            Format::Yaml,
880            &records,
881            None,
882            None,
883        )
884        .unwrap();
885        let body = std::fs::read_to_string(&path).unwrap();
886        assert!(body.contains("_name: Alpha"));
887        assert!(body.contains("status: active"));
888    }
889
890    #[test]
891    fn export_records_respects_select() {
892        let v = vault();
893        let dir = v.path().join("notes");
894        std::fs::create_dir_all(&dir).unwrap();
895        let records = vec![rec(
896            "Alpha",
897            &dir,
898            &[
899                ("status", Value::String("active".into())),
900                ("rating", Value::Integer(5)),
901            ],
902        )];
903        let select = vec!["_name".to_string(), "rating".to_string()];
904        let path = export_records(
905            v.path(),
906            Path::new("out.csv"),
907            Format::Csv { delimiter: b',' },
908            &records,
909            Some(&select),
910            None,
911        )
912        .unwrap();
913        let body = std::fs::read_to_string(&path).unwrap();
914        let header = body.lines().next().unwrap();
915        assert!(header.contains("_name"));
916        assert!(header.contains("rating"));
917        assert!(!header.contains("status"));
918    }
919
920    // JSON value rendering
921
922    #[test]
923    fn export_value_json_arbitrary_shape() {
924        let v = vault();
925        let value = serde_json::json!({
926            "path": "/foo/bar",
927            "nested": { "x": 1 }
928        });
929        let path = export_value(v.path(), Path::new("out.json"), Format::Json, &value).unwrap();
930        let body = std::fs::read_to_string(&path).unwrap();
931        let parsed: serde_json::Value = serde_json::from_str(&body).unwrap();
932        assert_eq!(parsed["nested"]["x"], 1);
933    }
934
935    #[test]
936    fn export_value_csv_array_of_objects() {
937        let v = vault();
938        let value = serde_json::json!([
939            { "name": "Alpha", "depth": 1 },
940            { "name": "Beta", "depth": 2 },
941        ]);
942        let path = export_value(
943            v.path(),
944            Path::new("hits.csv"),
945            Format::Csv { delimiter: b',' },
946            &value,
947        )
948        .unwrap();
949        let body = std::fs::read_to_string(&path).unwrap();
950        let header = body.lines().next().unwrap();
951        assert!(header.contains("name"));
952        assert!(header.contains("depth"));
953        assert_eq!(body.lines().count(), 3); // header + 2 rows
954    }
955
956    #[test]
957    fn export_value_csv_array_of_scalars() {
958        let v = vault();
959        let value = serde_json::json!(["a", "b", "c"]);
960        let path = export_value(
961            v.path(),
962            Path::new("scalars.csv"),
963            Format::Csv { delimiter: b',' },
964            &value,
965        )
966        .unwrap();
967        let body = std::fs::read_to_string(&path).unwrap();
968        let mut lines = body.lines();
969        assert_eq!(lines.next().unwrap(), "value");
970        assert_eq!(lines.next().unwrap(), "a");
971    }
972
973    #[test]
974    fn export_value_csv_rejects_non_tabular_shape() {
975        let v = vault();
976        let value = serde_json::json!("just a string");
977        let err = export_value(
978            v.path(),
979            Path::new("bad.csv"),
980            Format::Csv { delimiter: b',' },
981            &value,
982        )
983        .unwrap_err();
984        assert!(matches!(err, VaultdbError::SafetyRefused { .. }));
985    }
986
987    #[test]
988    fn export_value_yaml_arbitrary_shape() {
989        let v = vault();
990        let value = serde_json::json!({ "key": "value" });
991        let path = export_value(v.path(), Path::new("out.yaml"), Format::Yaml, &value).unwrap();
992        let body = std::fs::read_to_string(&path).unwrap();
993        assert!(body.contains("key: value"));
994    }
995
996    // XLSX (feature-gated)
997
998    #[cfg(feature = "xlsx")]
999    #[test]
1000    fn export_records_xlsx_writes_real_workbook() {
1001        let v = vault();
1002        let dir = v.path().join("notes");
1003        std::fs::create_dir_all(&dir).unwrap();
1004        let records = vec![rec(
1005            "Alpha",
1006            &dir,
1007            &[
1008                ("rating", Value::Integer(5)),
1009                ("status", Value::String("active".into())),
1010            ],
1011        )];
1012        let path = export_records(
1013            v.path(),
1014            Path::new("out.xlsx"),
1015            Format::Xlsx,
1016            &records,
1017            None,
1018            None,
1019        )
1020        .unwrap();
1021        let bytes = std::fs::read(&path).unwrap();
1022        // XLSX is a ZIP container — magic bytes are "PK\x03\x04".
1023        assert_eq!(&bytes[..4], b"PK\x03\x04");
1024    }
1025
1026    #[cfg(feature = "xlsx")]
1027    #[test]
1028    fn export_value_xlsx_array_of_objects() {
1029        let v = vault();
1030        let value = serde_json::json!([
1031            { "name": "Alpha", "depth": 1 },
1032            { "name": "Beta", "depth": 2 },
1033        ]);
1034        let path = export_value(v.path(), Path::new("hits.xlsx"), Format::Xlsx, &value).unwrap();
1035        let bytes = std::fs::read(&path).unwrap();
1036        assert_eq!(&bytes[..4], b"PK\x03\x04");
1037    }
1038
1039    // Atomic-write semantics
1040
1041    #[test]
1042    fn atomic_write_replaces_existing() {
1043        let v = vault();
1044        let target: PathBuf = v.path().join("foo.txt");
1045        std::fs::write(&target, b"old").unwrap();
1046        atomic_write_bytes(&target, b"new").unwrap();
1047        assert_eq!(std::fs::read(&target).unwrap(), b"new");
1048    }
1049}