use std::{collections::BTreeMap, path::Path}; use anyhow::Context; use memofs::{IoResultExt, Vfs}; use rbx_dom_weak::ustr; use serde::Serialize; use crate::snapshot::{InstanceContext, InstanceMetadata, InstanceSnapshot}; use super::{ dir::{dir_meta, snapshot_dir_no_meta}, meta_file::AdjacentMetadata, }; pub fn snapshot_csv( _context: &InstanceContext, vfs: &Vfs, path: &Path, name: &str, ) -> anyhow::Result> { let meta_path = path.with_file_name(format!("{}.meta.json", name)); let contents = vfs.read(path)?; let table_contents = convert_localization_csv(&contents).with_context(|| { format!( "File was not a valid LocalizationTable CSV file: {}", path.display() ) })?; let mut snapshot = InstanceSnapshot::new() .name(name) .class_name("LocalizationTable") .property(ustr("Contents"), table_contents) .metadata( InstanceMetadata::new() .instigating_source(path) .relevant_paths(vec![path.to_path_buf(), meta_path.clone()]), ); if let Some(meta_contents) = vfs.read(&meta_path).with_not_found()? { let mut metadata = AdjacentMetadata::from_slice(&meta_contents, meta_path)?; metadata.apply_all(&mut snapshot)?; } Ok(Some(snapshot)) } /// Attempts to snapshot an 'init' csv contained inside of a folder with /// the given name. /// /// csv named `init.csv` /// their parents, which acts similarly to `__init__.py` from the Python world. pub fn snapshot_csv_init( context: &InstanceContext, vfs: &Vfs, init_path: &Path, ) -> anyhow::Result> { let folder_path = init_path.parent().unwrap(); let dir_snapshot = snapshot_dir_no_meta(context, vfs, folder_path)?.unwrap(); if dir_snapshot.class_name != "Folder" { anyhow::bail!( "init.csv can only be used if the instance produced by \ the containing directory would be a Folder.\n\ \n\ The directory {} turned into an instance of class {}.", folder_path.display(), dir_snapshot.class_name ); } let mut init_snapshot = snapshot_csv(context, vfs, init_path, &dir_snapshot.name)?.unwrap(); init_snapshot.children = dir_snapshot.children; init_snapshot.metadata = dir_snapshot.metadata; if let Some(mut meta) = dir_meta(vfs, folder_path)? { meta.apply_all(&mut init_snapshot)?; } Ok(Some(init_snapshot)) } /// Struct that holds any valid row from a Roblox CSV translation table. /// /// We manually deserialize into this table from CSV, but let serde_json handle /// serialization. #[derive(Debug, Default, Serialize)] #[serde(rename_all = "camelCase")] struct LocalizationEntry<'a> { #[serde(skip_serializing_if = "Option::is_none")] key: Option<&'a str>, #[serde(skip_serializing_if = "Option::is_none")] context: Option<&'a str>, #[serde(skip_serializing_if = "Option::is_none")] example: Option<&'a str>, #[serde(skip_serializing_if = "Option::is_none")] source: Option<&'a str>, // We use a BTreeMap here to get deterministic output order. values: BTreeMap<&'a str, &'a str>, } /// Normally, we'd be able to let the csv crate construct our struct for us. /// /// However, because of a limitation with Serde's 'flatten' feature, it's not /// possible presently to losslessly collect extra string values while using /// csv+Serde. /// /// https://github.com/BurntSushi/rust-csv/issues/151 /// /// This function operates in one step in order to minimize data-copying. fn convert_localization_csv(contents: &[u8]) -> Result { let mut reader = csv::Reader::from_reader(contents); let headers = reader.headers()?.clone(); let mut records = Vec::new(); for record in reader.into_records() { records.push(record?); } let mut entries = Vec::new(); for record in &records { let mut entry = LocalizationEntry::default(); for (header, value) in headers.iter().zip(record.into_iter()) { if header.is_empty() || value.is_empty() { continue; } match header { "Key" => entry.key = Some(value), "Source" => entry.source = Some(value), "Context" => entry.context = Some(value), "Example" => entry.example = Some(value), _ => { entry.values.insert(header, value); } } } if entry.key.is_none() && entry.source.is_none() { continue; } entries.push(entry); } let encoded = serde_json::to_string(&entries).expect("Could not encode JSON for localization table"); Ok(encoded) } #[cfg(test)] mod test { use super::*; use memofs::{InMemoryFs, VfsSnapshot}; #[test] fn csv_from_vfs() { let mut imfs = InMemoryFs::new(); imfs.load_snapshot( "/foo.csv", VfsSnapshot::file( r#" Key,Source,Context,Example,es Ack,Ack!,,An exclamation of despair,¡Ay!"#, ), ) .unwrap(); let vfs = Vfs::new(imfs); let instance_snapshot = snapshot_csv( &InstanceContext::default(), &vfs, Path::new("/foo.csv"), "foo", ) .unwrap() .unwrap(); insta::assert_yaml_snapshot!(instance_snapshot); } #[test] fn csv_with_meta() { let mut imfs = InMemoryFs::new(); imfs.load_snapshot( "/foo.csv", VfsSnapshot::file( r#" Key,Source,Context,Example,es Ack,Ack!,,An exclamation of despair,¡Ay!"#, ), ) .unwrap(); imfs.load_snapshot( "/foo.meta.json", VfsSnapshot::file(r#"{ "ignoreUnknownInstances": true }"#), ) .unwrap(); let vfs = Vfs::new(imfs); let instance_snapshot = snapshot_csv( &InstanceContext::default(), &vfs, Path::new("/foo.csv"), "foo", ) .unwrap() .unwrap(); insta::assert_yaml_snapshot!(instance_snapshot); } }