Skip to main content

chamber_import_export/
lib.rs

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
68/// Exports a list of items to a specified file format and writes the output to a given file path.
69///
70/// # Arguments
71///
72/// * `items` - A slice of `Item` objects to be exported.
73/// * `format` - The export format, represented as an `ExportFormat` enum. Possible formats include:
74///     * `ExportFormat::Json` - Exports the data in JSON format.
75///     * `ExportFormat::Csv` - Exports the data in CSV format.
76///     * `ExportFormat::ChamberBackup` - Exports the data in a custom chamber backup format.
77/// * `output_path` - The file path where the exported data will be saved.
78///
79/// # Returns
80///
81/// * `Result<()>` - Returns `Ok(())` if the export operation succeeds, or an `Err` if an error occurs.
82///
83/// # Errors
84///
85/// This function will return an error in any of the following cases:
86/// * The specified `output_path` is invalid or inaccessible.
87/// * An I/O error occurs while writing to the file.
88/// * Serialization to the chosen export format fails.
89///
90/// # Note
91///
92/// Ensure that the directory specified in `output_path` exists and has write permissions before calling this function.
93pub 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
144/// Imports items from a specified file path and format.
145///
146/// # Arguments
147///
148/// * `input_path` - A reference to a `Path` that specifies the location of the file to be imported.
149/// * `format` - The format of the file to be imported, which can be one of `ExportFormat::Json`,
150///   `ExportFormat::Csv`, or `ExportFormat::ChamberBackup`.
151///
152/// # Returns
153///
154/// This function returns a `Result` containing:
155/// * `Ok(Vec<NewItem>)` - A vector of `NewItem` if the import is successful.
156/// * `Err` - An error if there is an issue with importing the items.
157///
158/// # Behavior
159///
160/// The function determines the appropriate import operation to execute based on the provided file
161/// format:
162/// * `ExportFormat::Json` - Calls the `import_json` function to handle JSON files.
163/// * `ExportFormat::Csv` - Calls the `import_csv` function to handle CSV files.
164/// * `ExportFormat::ChamberBackup` - Calls the `import_chamber_backup` function to handle Chamber
165///   Backup files.
166///
167/// # Errors
168///
169/// This function will return an error if:
170/// * The file at `input_path` cannot be read.
171/// * The file format is invalid or corrupted for the specified `ExportFormat`.
172/// * Any other internal errors occur during the import process.
173pub 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
198// Rust
199fn record_complete(line: &str) -> bool {
200    // Returns true if the line ends outside of quotes (i.e., unescaped quotes are balanced)
201    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                // Escaped quote?
207                if chars.peek() == Some(&'"') {
208                    // consume the second quote and keep in quotes
209                    chars.next();
210                } else {
211                    // closing quote
212                    in_quotes = false;
213                }
214            } else {
215                // opening quote
216                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    // Handle header
228    let Some(header) = lines.next() else {
229        return Ok(Vec::new());
230    };
231    // Optional: validate header minimally (not strictly required)
232    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    // Track logical CSV line numbers for error reporting:
239    // header is line 1; data records start from logical line 2
240    let mut current_record_start_line: usize = 2;
241    let mut physical_line_index: usize = 1; // already consumed header
242
243    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            // Need more physical lines to complete a record
255            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 leftover buffer remains, ensure it's a complete record and parse it
281    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
321// Helper functions for CSV handling
322fn 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                    // Check if this is an escaped quote
341                    if chars.peek() == Some(&'"') {
342                        current_field.push('"');
343                        chars.next(); // consume the second quote
344                    } 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    // Add the last field
362    fields.push(current_field.trim().to_string());
363
364    fields
365}
366
367// Additional import format support
368#[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                // Try to detect if it's a chamber backup by checking the filename
375                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// Rust
391#[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        // Only name/kind/value are imported (timestamps are not round-tripped)
484        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        // CSV import uses only the first three fields (name, kind, value)
503        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"); // backup uses JSON extension
515
516        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        // Header + a malformed line with only two fields
544        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        // serde_json error should bubble up
563        assert!(err.to_lowercase().contains("json"));
564    }
565
566    #[test]
567    fn test_import_chamber_backup_invalid() {
568        let path = unique_path("json");
569        // Not a valid ChamberBackup shape
570        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        // JSON
585        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        // CSV
592        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        // Backup
599        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        // Ensure unique nested directory
611        dir.set_extension("");
612        let nested = dir.join("nested").join("export.csv");
613
614        // Export_csv should create a Parent
615        export_items(&items, &ExportFormat::Csv, &nested).unwrap();
616        assert!(nested.exists());
617
618        // Cleanup
619        fs::remove_file(&nested).ok();
620        // Remove dirs from deepest to top
621        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        // Generate a path that definitely doesn't exist
710        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        // Ensure the path doesn't exist
721        assert!(!nonexistent.exists());
722
723        // Test that import fails for all formats
724        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        // Verify the backup structure
739        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        // Create a temporary directory and make it read-only
778        let temp_dir = std::env::temp_dir().join("readonly_test");
779        fs::create_dir_all(&temp_dir).ok();
780
781        // Try to make the directory read-only (this might not work on all systems)
782        let _ = temp_dir.join("test.json");
783
784        // On Windows, we can't easily make directories read-only, so we'll
785        // test a different scenario - trying to write to a file that's a directory
786        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        // Cleanup
793        fs::remove_dir_all(&temp_dir).ok();
794
795        // Should fail because we're trying to write to a directory
796        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        // Test JSON round trip
818        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        // Test CSV round trip
824        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        // Test Chamber backup round trip
830        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        // Verify all formats preserved all kinds
836        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        // Read and verify the backup structure
872        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        // Verify timestamp format
881        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        // Test files without extensions
891        assert!(detect_format_from_extension(Path::new("noext")).is_none());
892
893        // Test empty extension
894        assert!(detect_format_from_extension(Path::new("file.")).is_none());
895
896        // Test case variations
897        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        // Test backup detection heuristics
907        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        // Test complex paths
921        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); // 10KB string
930        let large_name = "n".repeat(1000); // 1KB name
931
932        let items = vec![
933            mk_item(1, &large_name, ItemKind::Note, &large_value),
934            mk_item(2, "normal", ItemKind::Password, "small"),
935        ];
936
937        // Test with all formats
938        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        // Create an item with a timestamp that might cause formatting issues
960        let far_future = OffsetDateTime::from_unix_timestamp(253_402_300_799).unwrap_or(OffsetDateTime::now_utc()); // Year 9999
961
962        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        // Should either have a valid timestamp or fallback to "unknown"
974        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        // Complex CSV with nested quotes and commas
982        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        // Missing required fields like "name" or "value"
1027        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        // Should fail due to missing required fields during deserialization
1034        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            // Ensure the parent directory exists for formats that don't create it automatically
1055            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        // Cleanup
1067        fs::remove_dir_all(&base_dir).ok();
1068    }
1069
1070    #[test]
1071    fn test_csv_escape_field_function() {
1072        // Test the escape_csv_field function directly through export behavior
1073        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        // Verify escaping behavior in the CSV content
1087        assert!(content.contains("\"has,comma\"")); // Comma escaped
1088        assert!(content.contains("\"has\"\"quote\"")); // Quote escaped
1089        assert!(content.contains("\"has\nnewline\"")); // Newline escaped
1090        assert!(content.contains("no_escaping_needed")); // No escaping needed
1091    }
1092
1093    #[test]
1094    fn test_performance_with_many_items() {
1095        // Test with a reasonable number of items (not too many to slow down tests)
1096        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            // Should complete within reasonable time (adjust if needed)
1119            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}