librojo/snapshot_middleware/
csv.rs

1use std::{collections::BTreeMap, path::Path};
2
3use anyhow::Context;
4use memofs::{IoResultExt, Vfs};
5use rbx_dom_weak::ustr;
6use serde::Serialize;
7
8use crate::snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot};
9
10use super::{
11    dir::{dir_meta, snapshot_dir_no_meta},
12    meta_file::AdjacentMetadata,
13};
14
15pub fn snapshot_csv(
16    _context: &InstanceContext,
17    vfs: &Vfs,
18    path: &Path,
19    name: &str,
20) -> anyhow::Result<Option<InstanceSnapshot>> {
21    let meta_path = path.with_file_name(format!("{}.meta.json", name));
22    let contents = vfs.read(path)?;
23
24    let table_contents = convert_localization_csv(&contents).with_context(|| {
25        format!(
26            "File was not a valid LocalizationTable CSV file: {}",
27            path.display()
28        )
29    })?;
30
31    let mut snapshot = InstanceSnapshot::new()
32        .name(name)
33        .class_name("LocalizationTable")
34        .property(ustr("Contents"), table_contents)
35        .metadata(
36            InstanceMetadata::new()
37                .instigating_source(path)
38                .relevant_paths(vec![path.to_path_buf(), meta_path.clone()]),
39        );
40
41    if let Some(meta_contents) = vfs.read(&meta_path).with_not_found()? {
42        let mut metadata = AdjacentMetadata::from_slice(&meta_contents, meta_path)?;
43        metadata.apply_all(&mut snapshot)?;
44    }
45
46    Ok(Some(snapshot))
47}
48
49/// Attempts to snapshot an 'init' csv contained inside of a folder with
50/// the given name.
51///
52/// csv named `init.csv`
53/// their parents, which acts similarly to `__init__.py` from the Python world.
54pub fn snapshot_csv_init(
55    context: &InstanceContext,
56    vfs: &Vfs,
57    init_path: &Path,
58) -> anyhow::Result<Option<InstanceSnapshot>> {
59    let folder_path = init_path.parent().unwrap();
60    let dir_snapshot = snapshot_dir_no_meta(context, vfs, folder_path)?.unwrap();
61
62    if dir_snapshot.class_name != "Folder" {
63        anyhow::bail!(
64            "init.csv can only be used if the instance produced by \
65             the containing directory would be a Folder.\n\
66             \n\
67             The directory {} turned into an instance of class {}.",
68            folder_path.display(),
69            dir_snapshot.class_name
70        );
71    }
72
73    let mut init_snapshot = snapshot_csv(context, vfs, init_path, &dir_snapshot.name)?.unwrap();
74
75    init_snapshot.children = dir_snapshot.children;
76    init_snapshot.metadata = dir_snapshot.metadata;
77
78    if let Some(mut meta) = dir_meta(vfs, folder_path)? {
79        meta.apply_all(&mut init_snapshot)?;
80    }
81
82    Ok(Some(init_snapshot))
83}
84
85/// Struct that holds any valid row from a Roblox CSV translation table.
86///
87/// We manually deserialize into this table from CSV, but let serde_json handle
88/// serialization.
89#[derive(Debug, Default, Serialize)]
90#[serde(rename_all = "camelCase")]
91struct LocalizationEntry<'a> {
92    #[serde(skip_serializing_if = "Option::is_none")]
93    key: Option<&'a str>,
94
95    #[serde(skip_serializing_if = "Option::is_none")]
96    context: Option<&'a str>,
97
98    #[serde(skip_serializing_if = "Option::is_none")]
99    example: Option<&'a str>,
100
101    #[serde(skip_serializing_if = "Option::is_none")]
102    source: Option<&'a str>,
103
104    // We use a BTreeMap here to get deterministic output order.
105    values: BTreeMap<&'a str, &'a str>,
106}
107
108/// Normally, we'd be able to let the csv crate construct our struct for us.
109///
110/// However, because of a limitation with Serde's 'flatten' feature, it's not
111/// possible presently to losslessly collect extra string values while using
112/// csv+Serde.
113///
114/// https://github.com/BurntSushi/rust-csv/issues/151
115///
116/// This function operates in one step in order to minimize data-copying.
117fn convert_localization_csv(contents: &[u8]) -> Result<String, csv::Error> {
118    let mut reader = csv::Reader::from_reader(contents);
119
120    let headers = reader.headers()?.clone();
121
122    let mut records = Vec::new();
123
124    for record in reader.into_records() {
125        records.push(record?);
126    }
127
128    let mut entries = Vec::new();
129
130    for record in &records {
131        let mut entry = LocalizationEntry::default();
132
133        for (header, value) in headers.iter().zip(record.into_iter()) {
134            if header.is_empty() || value.is_empty() {
135                continue;
136            }
137
138            match header {
139                "Key" => entry.key = Some(value),
140                "Source" => entry.source = Some(value),
141                "Context" => entry.context = Some(value),
142                "Example" => entry.example = Some(value),
143                _ => {
144                    entry.values.insert(header, value);
145                }
146            }
147        }
148
149        if entry.key.is_none() && entry.source.is_none() {
150            continue;
151        }
152
153        entries.push(entry);
154    }
155
156    let encoded =
157        serde_json::to_string(&entries).expect("Could not encode JSON for localization table");
158
159    Ok(encoded)
160}
161
162#[cfg(test)]
163mod test {
164    use super::*;
165
166    use memofs::{InMemoryFs, VfsSnapshot};
167
168    #[test]
169    fn csv_from_vfs() {
170        let mut imfs = InMemoryFs::new();
171        imfs.load_snapshot(
172            "/foo.csv",
173            VfsSnapshot::file(
174                r#"
175Key,Source,Context,Example,es
176Ack,Ack!,,An exclamation of despair,¡Ay!"#,
177            ),
178        )
179        .unwrap();
180
181        let vfs = Vfs::new(imfs);
182
183        let instance_snapshot = snapshot_csv(
184            &InstanceContext::default(),
185            &vfs,
186            Path::new("/foo.csv"),
187            "foo",
188        )
189        .unwrap()
190        .unwrap();
191
192        insta::assert_yaml_snapshot!(instance_snapshot);
193    }
194
195    #[test]
196    fn csv_with_meta() {
197        let mut imfs = InMemoryFs::new();
198        imfs.load_snapshot(
199            "/foo.csv",
200            VfsSnapshot::file(
201                r#"
202Key,Source,Context,Example,es
203Ack,Ack!,,An exclamation of despair,¡Ay!"#,
204            ),
205        )
206        .unwrap();
207        imfs.load_snapshot(
208            "/foo.meta.json",
209            VfsSnapshot::file(r#"{ "ignoreUnknownInstances": true }"#),
210        )
211        .unwrap();
212
213        let vfs = Vfs::new(imfs);
214
215        let instance_snapshot = snapshot_csv(
216            &InstanceContext::default(),
217            &vfs,
218            Path::new("/foo.csv"),
219            "foo",
220        )
221        .unwrap()
222        .unwrap();
223
224        insta::assert_yaml_snapshot!(instance_snapshot);
225    }
226}