1use 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#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33pub enum Format {
34 Csv { delimiter: u8 },
37 #[cfg(feature = "xlsx")]
40 Xlsx,
41 Json,
43 Yaml,
45}
46
47impl Format {
48 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 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
105pub 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 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
220pub 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
250pub 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
268pub 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
290fn 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
435pub 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
453fn 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 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 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
567fn 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
584fn 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
604fn 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#[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 #[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 #[test]
714 fn resolve_basic_path() {
715 let v = vault();
716 let p = resolve_export_path(v.path(), Path::new("foo.csv")).unwrap();
717 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 #[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 #[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); }
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 #[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 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 #[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}