1use chamber_vault::{Item, ItemKind, NewItem};
2use color_eyre::Result;
3use color_eyre::eyre::Error;
4use color_eyre::eyre::eyre;
5use serde::{Deserialize, Serialize};
6use std::fs;
7use std::io::Write;
8use std::path::Path;
9use std::str::FromStr;
10use time::OffsetDateTime;
11#[derive(Debug, Clone, Serialize, Deserialize)]
12pub enum ExportFormat {
13 Json,
14 Csv,
15 ChamberBackup,
16}
17
18impl FromStr for ExportFormat {
19 type Err = Error;
20
21 fn from_str(s: &str) -> Result<Self, Self::Err> {
22 match s.to_lowercase().as_str() {
23 "json" => Ok(ExportFormat::Json),
24 "csv" => Ok(ExportFormat::Csv),
25 "backup" | "chamber" => Ok(ExportFormat::ChamberBackup),
26 _ => Err(eyre!("Unsupported format: {}. Supported formats: json, csv, backup", s)),
27 }
28 }
29}
30
31#[derive(Debug, Serialize, Deserialize)]
32pub struct ExportedItem {
33 pub name: String,
34 pub kind: String,
35 pub value: String,
36 pub created_at: String,
37 pub updated_at: String,
38 pub notes: Option<String>,
39}
40
41#[derive(Debug, Serialize, Deserialize)]
42pub struct ChamberBackup {
43 pub version: String,
44 pub exported_at: String,
45 pub item_count: usize,
46 pub items: Vec<ExportedItem>,
47}
48
49impl From<&Item> for ExportedItem {
50 fn from(item: &Item) -> Self {
51 Self {
52 name: item.name.clone(),
53 kind: item.kind.as_str().to_string(),
54 value: item.value.clone(),
55 created_at: item
56 .created_at
57 .format(&time::format_description::well_known::Rfc3339)
58 .unwrap_or_else(|_| "unknown".to_string()),
59 updated_at: item
60 .updated_at
61 .format(&time::format_description::well_known::Rfc3339)
62 .unwrap_or_else(|_| "unknown".to_string()),
63 notes: None,
64 }
65 }
66}
67
68pub fn export_items(items: &[Item], format: &ExportFormat, output_path: &Path) -> Result<()> {
94 match format {
95 ExportFormat::Json => export_json(items, output_path),
96 ExportFormat::Csv => export_csv(items, output_path),
97 ExportFormat::ChamberBackup => export_chamber_backup(items, output_path),
98 }
99}
100
101fn export_json(items: &[Item], output_path: &Path) -> Result<()> {
102 let exported_items: Vec<ExportedItem> = items.iter().map(ExportedItem::from).collect();
103 let json = serde_json::to_string_pretty(&exported_items)?;
104 fs::write(output_path, json)?;
105 Ok(())
106}
107
108fn export_csv(items: &[Item], output_path: &Path) -> Result<()> {
109 if let Some(parent) = output_path.parent() {
110 fs::create_dir_all(parent)?;
111 }
112
113 let mut file = fs::File::create(output_path)?;
114 writeln!(file, "name,kind,value,created_at,updated_at")?;
115
116 for item in items {
117 let exported = ExportedItem::from(item);
118 let name = escape_csv_field(&exported.name);
119 let kind = escape_csv_field(&exported.kind);
120 let value = escape_csv_field(&exported.value);
121 let created = escape_csv_field(&exported.created_at);
122 let updated = escape_csv_field(&exported.updated_at);
123
124 writeln!(file, "{name},{kind},{value},{created},{updated}")?;
125 }
126 Ok(())
127}
128
129fn export_chamber_backup(items: &[Item], output_path: &Path) -> Result<()> {
130 let backup = ChamberBackup {
131 version: "1.0".to_string(),
132 exported_at: OffsetDateTime::now_utc()
133 .format(&time::format_description::well_known::Rfc3339)
134 .unwrap_or_else(|_| "unknown".to_string()),
135 item_count: items.len(),
136 items: items.iter().map(ExportedItem::from).collect(),
137 };
138
139 let json = serde_json::to_string_pretty(&backup)?;
140 fs::write(output_path, json)?;
141 Ok(())
142}
143
144pub fn import_items(input_path: &Path, format: &ExportFormat) -> Result<Vec<NewItem>> {
174 match format {
175 ExportFormat::Json => import_json(input_path),
176 ExportFormat::Csv => import_csv(input_path),
177 ExportFormat::ChamberBackup => import_chamber_backup(input_path),
178 }
179}
180
181fn import_json(input_path: &Path) -> Result<Vec<NewItem>> {
182 let content = fs::read_to_string(input_path)?;
183 let exported_items: Vec<ExportedItem> =
184 serde_json::from_str(&content).map_err(|e| eyre!("JSON parse error: {e}"))?;
185
186 let mut items = Vec::new();
187 for exported in exported_items {
188 items.push(NewItem {
189 name: exported.name,
190 kind: ItemKind::from_str(&exported.kind)?,
191 value: exported.value,
192 });
193 }
194
195 Ok(items)
196}
197
198fn record_complete(line: &str) -> bool {
200 let mut in_quotes = false;
202 let mut chars = line.chars().peekable();
203 while let Some(ch) = chars.next() {
204 if ch == '"' {
205 if in_quotes {
206 if chars.peek() == Some(&'"') {
208 chars.next();
210 } else {
211 in_quotes = false;
213 }
214 } else {
215 in_quotes = true;
217 }
218 }
219 }
220 !in_quotes
221}
222
223fn import_csv(input_path: &Path) -> Result<Vec<NewItem>> {
224 let content = fs::read_to_string(input_path)?;
225 let mut lines = content.lines();
226
227 let Some(header) = lines.next() else {
229 return Ok(Vec::new());
230 };
231 if header.trim().is_empty() {
233 return Ok(Vec::new());
234 }
235
236 let mut items = Vec::new();
237 let mut buf = String::new();
238 let mut current_record_start_line: usize = 2;
241 let mut physical_line_index: usize = 1; for raw in lines {
244 physical_line_index += 1;
245 if buf.is_empty() {
246 buf.push_str(raw);
247 current_record_start_line = physical_line_index;
248 } else {
249 buf.push('\n');
250 buf.push_str(raw);
251 }
252
253 if !record_complete(&buf) {
254 continue;
256 }
257
258 if buf.trim().is_empty() {
259 buf.clear();
260 continue;
261 }
262
263 let fields = parse_csv_line(&buf);
264 if fields.len() < 3 {
265 return Err(eyre!(
266 "Invalid CSV format at line {}: expected at least 3 fields",
267 current_record_start_line
268 ));
269 }
270
271 items.push(NewItem {
272 name: fields[0].clone(),
273 kind: ItemKind::from_str(&fields[1])?,
274 value: fields[2].clone(),
275 });
276
277 buf.clear();
278 }
279
280 if !buf.is_empty() {
282 if !record_complete(&buf) {
283 return Err(eyre!(
284 "Invalid CSV format at line {}: unterminated quoted field",
285 current_record_start_line
286 ));
287 }
288 let fields = parse_csv_line(&buf);
289 if fields.len() < 3 {
290 return Err(eyre!(
291 "Invalid CSV format at line {}: expected at least 3 fields",
292 current_record_start_line
293 ));
294 }
295 items.push(NewItem {
296 name: fields[0].clone(),
297 kind: ItemKind::from_str(&fields[1])?,
298 value: fields[2].clone(),
299 });
300 }
301
302 Ok(items)
303}
304
305fn import_chamber_backup(input_path: &Path) -> Result<Vec<NewItem>> {
306 let content = fs::read_to_string(input_path)?;
307 let backup: ChamberBackup = serde_json::from_str(&content).map_err(|e| eyre!("JSON parse error: {e}"))?;
308
309 let mut items = Vec::new();
310 for exported in backup.items {
311 items.push(NewItem {
312 name: exported.name,
313 kind: ItemKind::from_str(&exported.kind)?,
314 value: exported.value,
315 });
316 }
317
318 Ok(items)
319}
320
321fn escape_csv_field(field: &str) -> String {
323 if field.contains(',') || field.contains('"') || field.contains('\n') {
324 format!("\"{}\"", field.replace('"', "\"\""))
325 } else {
326 field.to_string()
327 }
328}
329
330fn parse_csv_line(line: &str) -> Vec<String> {
331 let mut fields = Vec::new();
332 let mut current_field = String::new();
333 let mut in_quotes = false;
334 let mut chars = line.chars().peekable();
335
336 while let Some(ch) = chars.next() {
337 match ch {
338 '"' => {
339 if in_quotes {
340 if chars.peek() == Some(&'"') {
342 current_field.push('"');
343 chars.next(); } else {
345 in_quotes = false;
346 }
347 } else {
348 in_quotes = true;
349 }
350 }
351 ',' if !in_quotes => {
352 fields.push(current_field.trim().to_string());
353 current_field.clear();
354 }
355 _ => {
356 current_field.push(ch);
357 }
358 }
359 }
360
361 fields.push(current_field.trim().to_string());
363
364 fields
365}
366
367#[must_use]
369pub fn detect_format_from_extension(path: &Path) -> Option<ExportFormat> {
370 path.extension()
371 .and_then(|ext| ext.to_str())
372 .and_then(|ext| match ext.to_lowercase().as_str() {
373 "json" => {
374 if path
376 .file_name()
377 .and_then(|name| name.to_str())
378 .is_some_and(|name| name.contains("chamber") || name.contains("backup"))
379 {
380 Some(ExportFormat::ChamberBackup)
381 } else {
382 Some(ExportFormat::Json)
383 }
384 }
385 "csv" => Some(ExportFormat::Csv),
386 _ => None,
387 })
388}
389
390#[cfg(test)]
392mod tests {
393 #![allow(clippy::unwrap_used)]
394 #![allow(clippy::cast_possible_wrap)]
395 use super::*;
396 use chamber_vault::{Item, ItemKind};
397 use std::{fs, path::PathBuf};
398 use time::OffsetDateTime;
399
400 fn unique_path(ext: &str) -> PathBuf {
401 let now = OffsetDateTime::now_utc().unix_timestamp_nanos();
402 let pid = std::process::id();
403 std::env::temp_dir().join(format!("chamber_ie_test_{pid}_{now}.{ext}"))
404 }
405
406 fn mk_item(id: u64, name: &str, kind: ItemKind, value: &str) -> Item {
407 let now = OffsetDateTime::now_utc();
408 Item {
409 id,
410 name: name.to_string(),
411 kind,
412 value: value.to_string(),
413 created_at: now,
414 updated_at: now,
415 }
416 }
417
418 fn sample_items() -> Vec<Item> {
419 vec![
420 mk_item(1, "alpha", ItemKind::Password, "secret-α"),
421 mk_item(2, "beta", ItemKind::EnvVar, "VALUE=1,2,3"),
422 mk_item(3, "note,with,commas", ItemKind::Note, "Hello, \"World\""),
423 mk_item(
424 4,
425 "multiline",
426 ItemKind::ApiKey,
427 "line1\nline2\nline3 with , and \"quotes\"",
428 ),
429 ]
430 }
431
432 #[test]
433 fn test_exportformat_from_str() {
434 assert!(matches!(ExportFormat::from_str("json").unwrap(), ExportFormat::Json));
435 assert!(matches!(ExportFormat::from_str("csv").unwrap(), ExportFormat::Csv));
436 assert!(matches!(
437 ExportFormat::from_str("backup").unwrap(),
438 ExportFormat::ChamberBackup
439 ));
440 assert!(matches!(
441 ExportFormat::from_str("chamber").unwrap(),
442 ExportFormat::ChamberBackup
443 ));
444
445 let err = ExportFormat::from_str("unknown").unwrap_err().to_string();
446 assert!(err.contains("Unsupported format"));
447 assert!(err.contains("json"));
448 assert!(err.contains("csv"));
449 assert!(err.contains("backup"));
450 }
451
452 #[test]
453 fn test_detect_format_from_extension() {
454 let json = Path::new("data/export.json");
455 let csv = Path::new("data/export.CSV");
456 let backup1 = Path::new("backup/chamber_backup.json");
457 let backup2 = Path::new("backup/2024-12-01-backup.JSON");
458 let unknown = Path::new("data/file.txt");
459
460 assert!(matches!(detect_format_from_extension(json), Some(ExportFormat::Json)));
461 assert!(matches!(detect_format_from_extension(csv), Some(ExportFormat::Csv)));
462 assert!(matches!(
463 detect_format_from_extension(backup1),
464 Some(ExportFormat::ChamberBackup)
465 ));
466 assert!(matches!(
467 detect_format_from_extension(backup2),
468 Some(ExportFormat::ChamberBackup)
469 ));
470 assert!(detect_format_from_extension(unknown).is_none());
471 }
472
473 #[test]
474 fn test_json_round_trip() {
475 let items = sample_items();
476 let path = unique_path("json");
477
478 export_items(&items, &ExportFormat::Json, &path).unwrap();
479
480 let imported = import_items(&path, &ExportFormat::Json).unwrap();
481 fs::remove_file(&path).ok();
482
483 assert_eq!(imported.len(), items.len());
485 for (i, ni) in imported.iter().enumerate() {
486 assert_eq!(ni.name, items[i].name);
487 assert_eq!(ni.kind.as_str(), items[i].kind.as_str());
488 assert_eq!(ni.value, items[i].value);
489 }
490 }
491
492 #[test]
493 fn test_csv_round_trip_with_escaping() {
494 let items = sample_items();
495 let path = unique_path("csv");
496
497 export_items(&items, &ExportFormat::Csv, &path).unwrap();
498
499 let imported = import_items(&path, &ExportFormat::Csv).unwrap();
500 fs::remove_file(&path).ok();
501
502 assert_eq!(imported.len(), items.len());
504 for (i, ni) in imported.iter().enumerate() {
505 assert_eq!(ni.name, items[i].name);
506 assert_eq!(ni.kind.as_str(), items[i].kind.as_str());
507 assert_eq!(ni.value, items[i].value);
508 }
509 }
510
511 #[test]
512 fn test_chamber_backup_round_trip() {
513 let items = sample_items();
514 let path = unique_path("json"); export_items(&items, &ExportFormat::ChamberBackup, &path).unwrap();
517
518 let imported = import_items(&path, &ExportFormat::ChamberBackup).unwrap();
519 fs::remove_file(&path).ok();
520
521 assert_eq!(imported.len(), items.len());
522 for (i, ni) in imported.iter().enumerate() {
523 assert_eq!(ni.name, items[i].name);
524 assert_eq!(ni.kind.as_str(), items[i].kind.as_str());
525 assert_eq!(ni.value, items[i].value);
526 }
527 }
528
529 #[test]
530 fn test_import_csv_empty_file() {
531 let path = unique_path("csv");
532 fs::write(&path, "").unwrap();
533
534 let imported = import_items(&path, &ExportFormat::Csv).unwrap();
535 fs::remove_file(&path).ok();
536
537 assert!(imported.is_empty());
538 }
539
540 #[test]
541 fn test_import_csv_invalid_line() {
542 let path = unique_path("csv");
543 let content = "name,kind,value,created_at,updated_at\nbadline,password\n";
545 fs::write(&path, content).unwrap();
546
547 let err = import_items(&path, &ExportFormat::Csv).unwrap_err().to_string();
548 fs::remove_file(&path).ok();
549
550 assert!(err.contains("Invalid CSV format"));
551 assert!(err.contains("expected at least 3 fields"));
552 }
553
554 #[test]
555 fn test_import_json_invalid() {
556 let path = unique_path("json");
557 fs::write(&path, "{not valid json").unwrap();
558
559 let err = import_items(&path, &ExportFormat::Json).unwrap_err().to_string();
560 fs::remove_file(&path).ok();
561
562 assert!(err.to_lowercase().contains("json"));
564 }
565
566 #[test]
567 fn test_import_chamber_backup_invalid() {
568 let path = unique_path("json");
569 fs::write(&path, r#"{"foo":"bar"}"#).unwrap();
571
572 let err = import_items(&path, &ExportFormat::ChamberBackup)
573 .unwrap_err()
574 .to_string();
575 fs::remove_file(&path).ok();
576
577 assert!(err.to_lowercase().contains("json"));
578 }
579
580 #[test]
581 fn test_dispatch_import_items() {
582 let items = sample_items();
583
584 let path_json = unique_path("json");
586 export_items(&items, &ExportFormat::Json, &path_json).unwrap();
587 let j = import_items(&path_json, &ExportFormat::Json).unwrap();
588 assert_eq!(j.len(), items.len());
589 fs::remove_file(&path_json).ok();
590
591 let path_csv = unique_path("csv");
593 export_items(&items, &ExportFormat::Csv, &path_csv).unwrap();
594 let c = import_items(&path_csv, &ExportFormat::Csv).unwrap();
595 assert_eq!(c.len(), items.len());
596 fs::remove_file(&path_csv).ok();
597
598 let path_bak = unique_path("json");
600 export_items(&items, &ExportFormat::ChamberBackup, &path_bak).unwrap();
601 let b = import_items(&path_bak, &ExportFormat::ChamberBackup).unwrap();
602 assert_eq!(b.len(), items.len());
603 fs::remove_file(&path_bak).ok();
604 }
605
606 #[test]
607 fn test_export_creates_parent_directories_csv() {
608 let items = sample_items();
609 let mut dir = unique_path("dir");
610 dir.set_extension("");
612 let nested = dir.join("nested").join("export.csv");
613
614 export_items(&items, &ExportFormat::Csv, &nested).unwrap();
616 assert!(nested.exists());
617
618 fs::remove_file(&nested).ok();
620 if let Some(parent) = nested.parent() {
622 fs::remove_dir_all(parent.parent().unwrap_or(parent)).ok();
623 }
624 }
625
626 #[test]
627 fn test_csv_with_unterminated_quotes() {
628 let path = unique_path("csv");
629 let content = "name,kind,value\n\"unterminated,password,secret";
630 fs::write(&path, content).unwrap();
631
632 let err = import_items(&path, &ExportFormat::Csv).unwrap_err();
633 fs::remove_file(&path).ok();
634
635 assert!(err.to_string().contains("unterminated quoted field"));
636 }
637
638 #[test]
639 fn test_csv_multiline_records() {
640 let path = unique_path("csv");
641 let content = "name,kind,value\n\"multi\nline\nname\",password,\"multi\nline\nvalue\"";
642 fs::write(&path, content).unwrap();
643
644 let imported = import_items(&path, &ExportFormat::Csv).unwrap();
645 fs::remove_file(&path).ok();
646
647 assert_eq!(imported.len(), 1);
648 assert_eq!(imported[0].name, "multi\nline\nname");
649 assert_eq!(imported[0].value, "multi\nline\nvalue");
650 }
651
652 #[test]
653 fn test_csv_with_only_headers() {
654 let path = unique_path("csv");
655 fs::write(&path, "name,kind,value,created_at,updated_at\n").unwrap();
656
657 let imported = import_items(&path, &ExportFormat::Csv).unwrap();
658 fs::remove_file(&path).ok();
659
660 assert!(imported.is_empty());
661 }
662
663 #[test]
664 fn test_csv_with_extra_fields() {
665 let path = unique_path("csv");
666 let content = "name,kind,value,extra1,extra2,extra3\ntest,password,secret,field4,field5,field6";
667 fs::write(&path, content).unwrap();
668
669 let imported = import_items(&path, &ExportFormat::Csv).unwrap();
670 fs::remove_file(&path).ok();
671
672 assert_eq!(imported.len(), 1);
673 assert_eq!(imported[0].name, "test");
674 assert_eq!(imported[0].value, "secret");
675 }
676
677 #[test]
678 fn test_csv_with_empty_fields() {
679 let path = unique_path("csv");
680 let content = "name,kind,value\n,password,\nempty_name,note,";
681 fs::write(&path, content).unwrap();
682
683 let imported = import_items(&path, &ExportFormat::Csv).unwrap();
684 fs::remove_file(&path).ok();
685
686 assert_eq!(imported.len(), 2);
687 assert_eq!(imported[0].name, "");
688 assert_eq!(imported[0].value, "");
689 assert_eq!(imported[1].name, "empty_name");
690 assert_eq!(imported[1].value, "");
691 }
692
693 #[test]
694 fn test_csv_with_whitespace_handling() {
695 let path = unique_path("csv");
696 let content = "name,kind,value\n spaced name , password , spaced value ";
697 fs::write(&path, content).unwrap();
698
699 let imported = import_items(&path, &ExportFormat::Csv).unwrap();
700 fs::remove_file(&path).ok();
701
702 assert_eq!(imported.len(), 1);
703 assert_eq!(imported[0].name, "spaced name");
704 assert_eq!(imported[0].value, "spaced value");
705 }
706
707 #[test]
708 fn test_import_nonexistent_file() {
709 let nonexistent = {
711 let mut path = std::env::temp_dir();
712 path.push("chamber_test_nonexistent");
713 path.push("deeply");
714 path.push("nested");
715 path.push("path");
716 path.push("file.json");
717 path
718 };
719
720 assert!(!nonexistent.exists());
722
723 for format in [ExportFormat::Json, ExportFormat::Csv, ExportFormat::ChamberBackup] {
725 let result = import_items(&nonexistent, &format);
726 assert!(
727 result.is_err(),
728 "Import should fail for nonexistent file with format {format:?}"
729 );
730 }
731 }
732
733 #[test]
734 fn test_chamber_backup_with_zero_items() {
735 let path = unique_path("json");
736 export_items(&[], &ExportFormat::ChamberBackup, &path).unwrap();
737
738 let content = fs::read_to_string(&path).unwrap();
740 let backup: ChamberBackup = serde_json::from_str(&content).unwrap();
741
742 assert_eq!(backup.version, "1.0");
743 assert_eq!(backup.item_count, 0);
744 assert!(backup.items.is_empty());
745
746 let imported = import_items(&path, &ExportFormat::ChamberBackup).unwrap();
747 fs::remove_file(&path).ok();
748
749 assert!(imported.is_empty());
750 }
751
752 #[test]
753 fn test_csv_with_unicode_content() {
754 let items = vec![
755 mk_item(1, "🔒 密码", ItemKind::Password, "测试密码123"),
756 mk_item(2, "café", ItemKind::Note, "Héllö Wörld! 🌍"),
757 mk_item(3, "русский", ItemKind::EnvVar, "значение=тест"),
758 ];
759
760 let path = unique_path("csv");
761 export_items(&items, &ExportFormat::Csv, &path).unwrap();
762
763 let imported = import_items(&path, &ExportFormat::Csv).unwrap();
764 fs::remove_file(&path).ok();
765
766 assert_eq!(imported.len(), 3);
767 assert_eq!(imported[0].name, "🔒 密码");
768 assert_eq!(imported[0].value, "测试密码123");
769 assert_eq!(imported[1].name, "café");
770 assert_eq!(imported[1].value, "Héllö Wörld! 🌍");
771 assert_eq!(imported[2].name, "русский");
772 assert_eq!(imported[2].value, "значение=тест");
773 }
774
775 #[test]
776 fn test_export_to_readonly_directory() {
777 let temp_dir = std::env::temp_dir().join("readonly_test");
779 fs::create_dir_all(&temp_dir).ok();
780
781 let _ = temp_dir.join("test.json");
783
784 let dir_as_file = temp_dir.join("not_a_file");
787 fs::create_dir_all(&dir_as_file).ok();
788
789 let items = sample_items();
790 let result = export_items(&items, &ExportFormat::Json, &dir_as_file);
791
792 fs::remove_dir_all(&temp_dir).ok();
794
795 assert!(result.is_err());
797 }
798
799 #[test]
800 fn test_all_itemkind_variants_round_trip() {
801 let all_kinds = [
802 ItemKind::Password,
803 ItemKind::EnvVar,
804 ItemKind::Note,
805 ItemKind::ApiKey,
806 ItemKind::SshKey,
807 ItemKind::Certificate,
808 ItemKind::Database,
809 ];
810
811 let items: Vec<Item> = all_kinds
812 .iter()
813 .enumerate()
814 .map(|(i, &kind)| mk_item(i as u64 + 1, &format!("item_{}", kind.as_str()), kind, "test_value"))
815 .collect();
816
817 let json_path = unique_path("json");
819 export_items(&items, &ExportFormat::Json, &json_path).unwrap();
820 let json_imported = import_items(&json_path, &ExportFormat::Json).unwrap();
821 fs::remove_file(&json_path).ok();
822
823 let csv_path = unique_path("csv");
825 export_items(&items, &ExportFormat::Csv, &csv_path).unwrap();
826 let csv_imported = import_items(&csv_path, &ExportFormat::Csv).unwrap();
827 fs::remove_file(&csv_path).ok();
828
829 let backup_path = unique_path("json");
831 export_items(&items, &ExportFormat::ChamberBackup, &backup_path).unwrap();
832 let backup_imported = import_items(&backup_path, &ExportFormat::ChamberBackup).unwrap();
833 fs::remove_file(&backup_path).ok();
834
835 for imported in [&json_imported, &csv_imported, &backup_imported] {
837 assert_eq!(imported.len(), all_kinds.len());
838 for (i, item) in imported.iter().enumerate() {
839 assert_eq!(item.kind, all_kinds[i]);
840 }
841 }
842 }
843
844 #[test]
845 fn test_csv_with_embedded_newlines() {
846 let items = vec![
847 mk_item(1, "line1\nline2", ItemKind::Note, "value1\nvalue2\nvalue3"),
848 mk_item(2, "normal", ItemKind::Password, "no newlines"),
849 ];
850
851 let path = unique_path("csv");
852 export_items(&items, &ExportFormat::Csv, &path).unwrap();
853
854 let imported = import_items(&path, &ExportFormat::Csv).unwrap();
855 fs::remove_file(&path).ok();
856
857 assert_eq!(imported.len(), 2);
858 assert_eq!(imported[0].name, "line1\nline2");
859 assert_eq!(imported[0].value, "value1\nvalue2\nvalue3");
860 assert_eq!(imported[1].name, "normal");
861 assert_eq!(imported[1].value, "no newlines");
862 }
863
864 #[test]
865 fn test_chamber_backup_metadata_validation() {
866 let items = sample_items();
867 let path = unique_path("json");
868
869 export_items(&items, &ExportFormat::ChamberBackup, &path).unwrap();
870
871 let content = fs::read_to_string(&path).unwrap();
873 let backup: ChamberBackup = serde_json::from_str(&content).unwrap();
874
875 assert_eq!(backup.version, "1.0");
876 assert_eq!(backup.item_count, items.len());
877 assert_eq!(backup.items.len(), items.len());
878 assert!(!backup.exported_at.is_empty());
879
880 let _: OffsetDateTime =
882 OffsetDateTime::parse(&backup.exported_at, &time::format_description::well_known::Rfc3339)
883 .expect("exported_at should be valid RFC3339 timestamp");
884
885 fs::remove_file(&path).ok();
886 }
887
888 #[test]
889 fn test_format_detection_edge_cases() {
890 assert!(detect_format_from_extension(Path::new("noext")).is_none());
892
893 assert!(detect_format_from_extension(Path::new("file.")).is_none());
895
896 assert!(matches!(
898 detect_format_from_extension(Path::new("FILE.JSON")),
899 Some(ExportFormat::Json)
900 ));
901 assert!(matches!(
902 detect_format_from_extension(Path::new("file.Csv")),
903 Some(ExportFormat::Csv)
904 ));
905
906 assert!(matches!(
908 detect_format_from_extension(Path::new("chamber_export.json")),
909 Some(ExportFormat::ChamberBackup)
910 ));
911 assert!(matches!(
912 detect_format_from_extension(Path::new("my_backup_file.json")),
913 Some(ExportFormat::ChamberBackup)
914 ));
915 assert!(matches!(
916 detect_format_from_extension(Path::new("regular_data.json")),
917 Some(ExportFormat::Json)
918 ));
919
920 assert!(matches!(
922 detect_format_from_extension(Path::new("/path/to/chamber_backup_2024.json")),
923 Some(ExportFormat::ChamberBackup)
924 ));
925 }
926
927 #[test]
928 fn test_very_large_field_values() {
929 let large_value = "x".repeat(10_000); let large_name = "n".repeat(1000); let items = vec![
933 mk_item(1, &large_name, ItemKind::Note, &large_value),
934 mk_item(2, "normal", ItemKind::Password, "small"),
935 ];
936
937 for (format, ext) in [
939 (ExportFormat::Json, "json"),
940 (ExportFormat::Csv, "csv"),
941 (ExportFormat::ChamberBackup, "json"),
942 ] {
943 let path = unique_path(ext);
944 export_items(&items, &format, &path).unwrap();
945
946 let imported = import_items(&path, &format).unwrap();
947 fs::remove_file(&path).ok();
948
949 assert_eq!(imported.len(), 2);
950 assert_eq!(imported[0].name, large_name);
951 assert_eq!(imported[0].value, large_value);
952 assert_eq!(imported[1].name, "normal");
953 assert_eq!(imported[1].value, "small");
954 }
955 }
956
957 #[test]
958 fn test_exported_item_timestamp_fallback() {
959 let far_future = OffsetDateTime::from_unix_timestamp(253_402_300_799).unwrap_or(OffsetDateTime::now_utc()); let item = Item {
963 id: 1,
964 name: "test".to_string(),
965 kind: ItemKind::Password,
966 value: "value".to_string(),
967 created_at: far_future,
968 updated_at: far_future,
969 };
970
971 let exported = ExportedItem::from(&item);
972
973 assert!(!exported.created_at.is_empty());
975 assert!(!exported.updated_at.is_empty());
976 }
977
978 #[test]
979 fn test_csv_parsing_complex_quoting() {
980 let path = unique_path("csv");
981 let content = r#"name,kind,value
983"item ""with"" quotes",password,"value, with ""quotes"" and, commas"
984simple,note,no_quotes
985"trailing comma,",envvar,"value,"
986"#;
987 fs::write(&path, content).unwrap();
988
989 let imported = import_items(&path, &ExportFormat::Csv).unwrap();
990 fs::remove_file(&path).ok();
991
992 assert_eq!(imported.len(), 3);
993 assert_eq!(imported[0].name, r#"item "with" quotes"#);
994 assert_eq!(imported[0].value, r#"value, with "quotes" and, commas"#);
995 assert_eq!(imported[1].name, "simple");
996 assert_eq!(imported[2].name, "trailing comma,");
997 assert_eq!(imported[2].value, "value,");
998 }
999
1000 #[test]
1001 fn test_json_empty_array() {
1002 let path = unique_path("json");
1003 fs::write(&path, "[]").unwrap();
1004
1005 let imported = import_items(&path, &ExportFormat::Json).unwrap();
1006 fs::remove_file(&path).ok();
1007
1008 assert!(imported.is_empty());
1009 }
1010
1011 #[test]
1012 fn test_invalid_itemkind_conversion() {
1013 let path = unique_path("json");
1014 let invalid_json = r#"[{"name":"test","kind":"invalid_kind","value":"test","created_at":"2024-01-01T00:00:00Z","updated_at":"2024-01-01T00:00:00Z"}]"#;
1015 fs::write(&path, invalid_json).unwrap();
1016
1017 let imported = import_items(&path, &ExportFormat::Json);
1018 fs::remove_file(&path).ok();
1019
1020 assert!(imported.is_err());
1021 }
1022
1023 #[test]
1024 fn test_json_parse_error_missing_required_fields() {
1025 let path = unique_path("json");
1026 let invalid_json = r#"[{"kind":"password","created_at":"2024-01-01T00:00:00Z"}]"#;
1028 fs::write(&path, invalid_json).unwrap();
1029
1030 let err = import_items(&path, &ExportFormat::Json).unwrap_err();
1031 fs::remove_file(&path).ok();
1032
1033 let error_msg = err.to_string().to_lowercase();
1035 assert!(error_msg.contains("json") || error_msg.contains("missing") || error_msg.contains("error"));
1036 }
1037
1038 #[test]
1039 fn test_nested_directory_creation_all_formats() {
1040 let base_dir = std::env::temp_dir().join(format!("chamber_test_{}", std::process::id()));
1041 let items = vec![mk_item(1, "test", ItemKind::Password, "value")];
1042
1043 for (format, ext) in [
1044 (ExportFormat::Json, "json"),
1045 (ExportFormat::Csv, "csv"),
1046 (ExportFormat::ChamberBackup, "json"),
1047 ] {
1048 let nested_path = base_dir
1049 .join(format!("{format:?}"))
1050 .join("level1")
1051 .join("level2")
1052 .join(format!("export.{ext}"));
1053
1054 if let Some(parent) = nested_path.parent() {
1056 fs::create_dir_all(parent).unwrap();
1057 }
1058
1059 export_items(&items, &format, &nested_path).unwrap();
1060 assert!(nested_path.exists());
1061
1062 let imported = import_items(&nested_path, &format).unwrap();
1063 assert_eq!(imported.len(), 1);
1064 }
1065
1066 fs::remove_dir_all(&base_dir).ok();
1068 }
1069
1070 #[test]
1071 fn test_csv_escape_field_function() {
1072 let items = vec![
1074 mk_item(1, "no_escaping_needed", ItemKind::Password, "simple_value"),
1075 mk_item(2, "has,comma", ItemKind::Password, "has\"quote"),
1076 mk_item(3, "has\nnewline", ItemKind::Password, "normal"),
1077 mk_item(4, "has\"quote", ItemKind::Password, "has,comma"),
1078 ];
1079
1080 let path = unique_path("csv");
1081 export_items(&items, &ExportFormat::Csv, &path).unwrap();
1082
1083 let content = fs::read_to_string(&path).unwrap();
1084 fs::remove_file(&path).ok();
1085
1086 assert!(content.contains("\"has,comma\"")); assert!(content.contains("\"has\"\"quote\"")); assert!(content.contains("\"has\nnewline\"")); assert!(content.contains("no_escaping_needed")); }
1092
1093 #[test]
1094 fn test_performance_with_many_items() {
1095 let items: Vec<Item> = (0..100)
1097 .map(|i| mk_item(i, &format!("item_{i}"), ItemKind::Password, &format!("value_{i}")))
1098 .collect();
1099
1100 for format in [ExportFormat::Json, ExportFormat::Csv, ExportFormat::ChamberBackup] {
1101 let path = unique_path(match format {
1102 ExportFormat::Csv => "csv",
1103 _ => "json",
1104 });
1105
1106 let start = std::time::Instant::now();
1107 export_items(&items, &format, &path).unwrap();
1108 let export_duration = start.elapsed();
1109
1110 let start = std::time::Instant::now();
1111 let imported = import_items(&path, &format).unwrap();
1112 let import_duration = start.elapsed();
1113
1114 fs::remove_file(&path).ok();
1115
1116 assert_eq!(imported.len(), 100);
1117
1118 assert!(
1120 export_duration.as_secs() < 5,
1121 "Export took too long: {export_duration:?}"
1122 );
1123 assert!(
1124 import_duration.as_secs() < 5,
1125 "Import took too long: {import_duration:?}"
1126 );
1127 }
1128 }
1129}