langcodec/formats/
csv.rs

1//! Support for CSV localization format.
2//!
3//! Only singular key-value pairs are supported; plurals will be dropped during conversion.
4//! Provides parsing, serialization, and conversion to/from the internal `Resource` model.
5//! Note: CSV format only supports singular translations; plurals will be dropped.
6use std::{collections::HashMap, io::BufRead};
7
8use serde::{Deserialize, Serialize};
9
10use crate::{
11    error::Error,
12    traits::Parser,
13    types::{Entry, EntryStatus, Metadata, Resource, Translation},
14};
15
16#[derive(Debug, Deserialize, Serialize, Clone, PartialEq, Eq)]
17pub struct CSVRecord {
18    pub key: String,
19    pub value: String,
20}
21
22impl Parser for Vec<CSVRecord> {
23    /// Parse from any reader.
24    fn from_reader<R: BufRead>(reader: R) -> Result<Self, Error> {
25        let mut rdr = csv::ReaderBuilder::new()
26            .has_headers(false)
27            .from_reader(reader);
28        let mut records = Vec::new();
29        for result in rdr.deserialize() {
30            records.push(result?);
31        }
32        Ok(records)
33    }
34
35    /// Write to any writer (file, memory, etc.).
36    fn to_writer<W: std::io::Write>(&self, writer: W) -> Result<(), Error> {
37        let mut wtr = csv::WriterBuilder::new().from_writer(writer);
38        for record in self {
39            wtr.serialize(record)?;
40        }
41        wtr.flush()?;
42        Ok(())
43    }
44}
45
46impl From<Vec<CSVRecord>> for Resource {
47    fn from(value: Vec<CSVRecord>) -> Self {
48        Resource {
49            metadata: Metadata {
50                language: String::from(""),
51                domain: String::from(""),
52                custom: HashMap::new(),
53            },
54            entries: value
55                .into_iter()
56                .map(|record| {
57                    Entry {
58                        id: record.key,
59                        value: Translation::Singular(record.value),
60                        comment: None,
61                        status: EntryStatus::Translated, // Default status
62                        custom: HashMap::new(),
63                    }
64                })
65                .collect(),
66        }
67    }
68}
69
70impl TryFrom<Resource> for Vec<CSVRecord> {
71    type Error = Error;
72
73    fn try_from(value: Resource) -> Result<Self, Self::Error> {
74        Ok(value
75            .entries
76            .into_iter()
77            .map(|entry| CSVRecord {
78                key: entry.id.clone(),
79                value: match entry.value {
80                    Translation::Singular(v) => v,
81                    Translation::Plural(_) => String::new(), // Plurals not supported in CSV
82                },
83            })
84            .collect())
85    }
86}
87
88#[cfg(test)]
89mod tests {
90    use super::*;
91    use crate::traits::Parser;
92    use crate::types::{Resource, Translation};
93    use std::io::Cursor;
94
95    #[test]
96    fn test_parse_simple_csv() {
97        let csv_content = "hello,Hello\nbye,Goodbye\n";
98        let records = Vec::<CSVRecord>::from_reader(Cursor::new(csv_content)).unwrap();
99        assert_eq!(records.len(), 2);
100        assert_eq!(records[0].key, "hello");
101        assert_eq!(records[0].value, "Hello");
102        assert_eq!(records[1].key, "bye");
103        assert_eq!(records[1].value, "Goodbye");
104    }
105
106    #[test]
107    fn test_round_trip_csv_resource_csv() {
108        let csv_content = "hello,Hello\nbye,Goodbye\n";
109        let records = Vec::<CSVRecord>::from_reader(Cursor::new(csv_content)).unwrap();
110        let resource = Resource::from(records.clone());
111        let serialized: Vec<CSVRecord> = TryFrom::try_from(resource).unwrap();
112        // Should be the same key-value pairs (order may not be guaranteed, but for this test, it is)
113        assert_eq!(records, serialized);
114    }
115
116    #[test]
117    fn test_csv_row_with_empty_value() {
118        let csv_content = "empty,\nhello,Hello\n";
119        let records = Vec::<CSVRecord>::from_reader(Cursor::new(csv_content)).unwrap();
120        assert_eq!(records.len(), 2);
121        assert_eq!(records[0].key, "empty");
122        assert_eq!(records[0].value, "");
123        let resource = Resource::from(records.clone());
124        assert_eq!(resource.entries.len(), 2);
125        // The entry with empty value should be present and its value should be empty
126        let entry = &resource.entries[0];
127        assert_eq!(entry.id, "empty");
128        assert_eq!(
129            match &entry.value {
130                Translation::Singular(s) => s,
131                _ => panic!("Expected singular translation"),
132            },
133            ""
134        );
135    }
136}