diff --git a/README.md b/README.md index 21d13c18..e8e51e70 100644 --- a/README.md +++ b/README.md @@ -11,7 +11,7 @@ Currently complete: - [x] Library versioning - [ ] Setting a logging callback - [x] Parsing metadata from YAML -- [ ] Serialising metadata to YAML +- [x] Serialising metadata to YAML - [x] Game-related functionality - [x] Plugin-related functionality - [x] Archive-related functionality diff --git a/src/metadata/error.rs b/src/metadata/error.rs index f015190a..871cf381 100644 --- a/src/metadata/error.rs +++ b/src/metadata/error.rs @@ -363,7 +363,7 @@ impl std::fmt::Display for YamlMergeKeyError { impl std::error::Error for YamlMergeKeyError {} /// Represents an error that occurred while trying to write metadata to a file. -#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[derive(Debug)] pub struct WriteMetadataError { path: PathBuf, reason: WriteMetadataErrorReason, @@ -386,14 +386,29 @@ impl std::fmt::Display for WriteMetadataError { WriteMetadataErrorReason::PathAlreadyExists => { write!(f, "the path \"{}\" already exists", self.path.display()) } + WriteMetadataErrorReason::IoError(_) => write!(f, "an I/O error occurred"), } } } -impl std::error::Error for WriteMetadataError {} +impl std::error::Error for WriteMetadataError { + fn source(&self) -> Option<&(dyn std::error::Error + 'static)> { + match &self.reason { + WriteMetadataErrorReason::IoError(e) => Some(e), + _ => None, + } + } +} -#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +#[derive(Debug)] pub(crate) enum WriteMetadataErrorReason { ParentDirectoryNotFound, PathAlreadyExists, + IoError(std::io::Error), +} + +impl From for WriteMetadataErrorReason { + fn from(value: std::io::Error) -> Self { + WriteMetadataErrorReason::IoError(value) + } } diff --git a/src/metadata/file.rs b/src/metadata/file.rs index 6dce45e8..dea1bd84 100644 --- a/src/metadata/file.rs +++ b/src/metadata/file.rs @@ -2,13 +2,16 @@ use saphyr::{MarkedYaml, YamlData}; use unicase::UniCase; use super::{ - error::ExpectedType, - error::{MultilingualMessageContentsError, ParseMetadataError}, - message::{MessageContent, parse_message_contents_yaml, validate_message_contents}, + error::{ExpectedType, MultilingualMessageContentsError, ParseMetadataError}, + message::{ + MessageContent, emit_message_contents, parse_message_contents_yaml, + validate_message_contents, + }, yaml::{ YamlObjectType, as_string_node, get_required_string_value, get_string_value, parse_condition, }, + yaml_emit::{EmitYaml, YamlEmitter}, }; /// Represents a file in a game's Data folder, including files in @@ -155,3 +158,162 @@ impl TryFrom<&MarkedYaml> for File { } } } + +impl EmitYaml for File { + fn is_scalar(&self) -> bool { + self.condition.is_none() && self.detail.is_empty() && self.display_name.is_none() + } + + fn emit_yaml(&self, emitter: &mut YamlEmitter) { + if self.is_scalar() { + emitter.single_quoted_str(self.name.as_str()); + } else { + emitter.begin_map(); + + emitter.map_key("name"); + emitter.single_quoted_str(self.name.as_str()); + + if let Some(display_name) = &self.display_name { + emitter.map_key("display"); + emitter.single_quoted_str(display_name); + } + + if let Some(condition) = &self.condition { + emitter.map_key("condition"); + emitter.single_quoted_str(condition); + } + + emit_message_contents(&self.detail, emitter, "detail"); + + emitter.end_map(); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod emit_yaml { + use crate::metadata::emit; + + use super::*; + + #[test] + fn should_emit_only_name_scalar_if_other_fields_are_empty() { + let file = File::new("filename".into()); + let yaml = emit(&file); + + assert_eq!(format!("'{}'", file.name.as_str()), yaml); + } + + #[test] + fn should_emit_map_with_display_if_display_name_is_not_empty() { + let file = File::new("filename".into()).with_display_name("display1".into()); + let yaml = emit(&file); + + assert_eq!( + format!( + "name: '{}'\ndisplay: '{}'", + file.name.as_str(), + file.display_name.unwrap() + ), + yaml + ); + } + + #[test] + fn should_emit_map_with_condition_if_it_is_not_empty() { + let file = File::new("filename".into()).with_condition("condition1".into()); + let yaml = emit(&file); + + assert_eq!( + format!( + "name: '{}'\ncondition: '{}'", + file.name.as_str(), + file.condition.unwrap() + ), + yaml + ); + } + + #[test] + fn should_emit_map_with_a_detail_string_if_detail_is_monolingual() { + let file = File::new("filename".into()) + .with_detail(vec![MessageContent::new("message".into())]) + .unwrap(); + let yaml = emit(&file); + + assert_eq!( + format!( + "name: '{}'\ndetail: '{}'", + file.name.as_str(), + file.detail[0].text() + ), + yaml + ); + } + + #[test] + fn should_emit_map_with_a_detail_array_if_detail_is_multilingual() { + let file = File::new("filename".into()) + .with_detail(vec![ + MessageContent::new("english".into()).with_language("en".into()), + MessageContent::new("french".into()).with_language("fr".into()), + ]) + .unwrap(); + let yaml = emit(&file); + + assert_eq!( + format!( + "name: '{}' +detail: + - lang: {} + text: '{}' + - lang: {} + text: '{}'", + file.name.as_str(), + file.detail[0].language(), + file.detail[0].text(), + file.detail[1].language(), + file.detail[1].text() + ), + yaml + ); + } + + #[test] + fn should_emit_map_with_all_fields_set() { + let file = File::new("filename".into()) + .with_display_name("display1".into()) + .with_condition("condition1".into()) + .with_detail(vec![ + MessageContent::new("english".into()).with_language("en".into()), + MessageContent::new("french".into()).with_language("fr".into()), + ]) + .unwrap(); + let yaml = emit(&file); + + assert_eq!( + format!( + "name: '{}' +display: '{}' +condition: '{}' +detail: + - lang: {} + text: '{}' + - lang: {} + text: '{}'", + file.name.as_str(), + file.display_name.unwrap(), + file.condition.unwrap(), + file.detail[0].language(), + file.detail[0].text(), + file.detail[1].language(), + file.detail[1].text() + ), + yaml + ); + } + } +} diff --git a/src/metadata/group.rs b/src/metadata/group.rs index 61cb9972..e5124676 100644 --- a/src/metadata/group.rs +++ b/src/metadata/group.rs @@ -4,6 +4,7 @@ use super::error::ParseMetadataError; use super::yaml::{ YamlObjectType, get_as_hash, get_required_string_value, get_string_value, get_strings_vec_value, }; +use super::yaml_emit::{EmitYaml, YamlEmitter}; /// Represents a group to which plugin metadata objects can belong. #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] @@ -89,3 +90,103 @@ impl TryFrom<&MarkedYaml> for Group { }) } } + +impl EmitYaml for Group { + fn is_scalar(&self) -> bool { + false + } + + fn emit_yaml(&self, emitter: &mut YamlEmitter) { + emitter.begin_map(); + + emitter.map_key("name"); + emitter.single_quoted_str(&self.name); + + if let Some(description) = &self.description { + emitter.map_key("description"); + emitter.single_quoted_str(description); + } + + if !self.after_groups.is_empty() { + emitter.map_key("after"); + emitter.begin_array(); + + for after in &self.after_groups { + emitter.unquoted_str(after); + } + + emitter.end_array(); + } + + emitter.end_map(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod emit_yaml { + use super::*; + use crate::metadata::emit; + + #[test] + fn should_omit_description_and_after_keys_if_their_fields_are_empty() { + let group = Group::new("name".into()); + let yaml = emit(&group); + + assert_eq!(format!("name: '{}'", group.name), yaml); + } + + #[test] + fn should_include_description_key_if_a_description_is_set() { + let group = Group::new("name".into()).with_description("desc".into()); + let yaml = emit(&group); + + assert_eq!( + format!( + "name: '{}'\ndescription: '{}'", + group.name, + group.description.unwrap() + ), + yaml + ); + } + + #[test] + fn should_include_after_key_if_after_groups_is_not_empty() { + let group = + Group::new("name".into()).with_after_groups(vec!["after1".into(), "after2".into()]); + + let yaml = emit(&group); + + assert_eq!( + format!( + "name: '{}'\nafter:\n - {}\n - {}", + group.name, group.after_groups[0], group.after_groups[1] + ), + yaml + ); + } + + #[test] + fn should_emit_map_with_all_fields_set() { + let group = Group::new("name".into()) + .with_description("desc".into()) + .with_after_groups(vec!["after1".into(), "after2".into()]); + + let yaml = emit(&group); + + assert_eq!( + format!( + "name: '{}'\ndescription: '{}'\nafter:\n - {}\n - {}", + group.name, + group.description.unwrap(), + group.after_groups[0], + group.after_groups[1] + ), + yaml + ); + } + } +} diff --git a/src/metadata/location.rs b/src/metadata/location.rs index b4507a08..bb79fc0b 100644 --- a/src/metadata/location.rs +++ b/src/metadata/location.rs @@ -4,6 +4,8 @@ use super::error::ExpectedType; use super::error::ParseMetadataError; use super::yaml::{YamlObjectType, get_required_string_value}; +use super::yaml_emit::EmitYaml; +use super::yaml_emit::YamlEmitter; /// Represents a URL at which the parent plugin can be found. #[derive(Clone, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] @@ -76,3 +78,60 @@ impl TryFrom<&MarkedYaml> for Location { } } } + +impl EmitYaml for Location { + fn is_scalar(&self) -> bool { + self.name.is_none() + } + + fn emit_yaml(&self, emitter: &mut YamlEmitter) { + if let Some(name) = &self.name { + emitter.begin_map(); + + emitter.map_key("link"); + emitter.single_quoted_str(&self.url); + + emitter.map_key("name"); + emitter.single_quoted_str(name); + + emitter.end_map(); + } else { + emitter.single_quoted_str(&self.url); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod emit_yaml { + use crate::metadata::emit; + + use super::*; + + #[test] + fn should_emit_url_only_if_there_is_no_name() { + let location = Location::new("http://www.example.com".into()); + let yaml = emit(&location); + + assert_eq!(format!("'{}'", location.url), yaml); + } + + #[test] + fn should_emit_map_if_there_is_a_name() { + let location = + Location::new("http://www.example.com".into()).with_name("example".into()); + let yaml = emit(&location); + + assert_eq!( + format!( + "link: '{}'\nname: '{}'", + location.url, + location.name.unwrap() + ), + yaml + ); + } + } +} diff --git a/src/metadata/message.rs b/src/metadata/message.rs index 87a8386b..ee9ebf91 100644 --- a/src/metadata/message.rs +++ b/src/metadata/message.rs @@ -9,6 +9,7 @@ use super::{ YamlObjectType, as_string_node, get_as_hash, get_required_string_value, get_strings_vec_value, parse_condition, }, + yaml_emit::{EmitYaml, YamlEmitter}, }; /// Codes used to indicate the type of a message. @@ -25,6 +26,16 @@ pub enum MessageType { Error, } +impl std::fmt::Display for MessageType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + MessageType::Say => write!(f, "say"), + MessageType::Warn => write!(f, "warn"), + MessageType::Error => write!(f, "error"), + } + } +} + /// Represents a message's localised text content. #[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] pub struct MessageContent { @@ -322,8 +333,69 @@ impl TryFrom<&MarkedYaml> for Message { } } +impl EmitYaml for MessageContent { + fn is_scalar(&self) -> bool { + false + } + + fn emit_yaml(&self, emitter: &mut YamlEmitter) { + emitter.begin_map(); + + emitter.map_key("lang"); + emitter.unquoted_str(&self.language); + + emitter.map_key("text"); + emitter.single_quoted_str(&self.text); + + emitter.end_map(); + } +} + +pub(super) fn emit_message_contents( + slice: &[MessageContent], + emitter: &mut YamlEmitter, + key: &'static str, +) { + match slice { + [] => {} + [detail] => { + emitter.map_key(key); + emitter.single_quoted_str(detail.text()); + } + details => { + emitter.map_key(key); + + details.emit_yaml(emitter); + } + } +} + +impl EmitYaml for Message { + fn is_scalar(&self) -> bool { + false + } + + fn emit_yaml(&self, emitter: &mut YamlEmitter) { + emitter.begin_map(); + + emitter.map_key("type"); + emitter.unquoted_str(&self.message_type.to_string()); + + emit_message_contents(&self.content, emitter, "content"); + + if let Some(condition) = &self.condition { + emitter.map_key("condition"); + emitter.single_quoted_str(condition); + } + + emitter.end_map(); + } +} + #[cfg(test)] mod tests { + use crate::metadata::emit; + use super::*; mod select_message_content { @@ -344,4 +416,109 @@ mod tests { assert_eq!(&slice[0], content.unwrap()); } } + + mod message_content { + use super::*; + mod emit_yaml { + use super::*; + + #[test] + fn should_emit_map() { + let content = MessageContent::new("message".into()).with_language("fr".into()); + let yaml = emit(&content); + + assert_eq!( + format!("lang: {}\ntext: '{}'", content.language, content.text), + yaml + ); + } + } + } + + mod message { + use super::*; + mod emit_yaml { + use super::*; + + #[test] + fn should_emit_say_message_type_correctly() { + let message = Message::new(MessageType::Say, "message".into()); + let yaml = emit(&message); + + assert_eq!( + format!("type: say\ncontent: '{}'", message.content[0].text), + yaml + ); + } + + #[test] + fn should_emit_warn_message_type_correctly() { + let message = Message::new(MessageType::Warn, "message".into()); + let yaml = emit(&message); + + assert_eq!( + format!("type: warn\ncontent: '{}'", message.content[0].text), + yaml + ); + } + + #[test] + fn should_emit_error_message_type_correctly() { + let message = Message::new(MessageType::Error, "message".into()); + let yaml = emit(&message); + + assert_eq!( + format!("type: error\ncontent: '{}'", message.content[0].text), + yaml + ); + } + + #[test] + fn should_emit_condition_if_it_is_not_empty() { + let message = Message::new(MessageType::Say, "message".into()) + .with_condition("condition1".into()); + let yaml = emit(&message); + + assert_eq!( + format!( + "type: {}\ncontent: '{}'\ncondition: '{}'", + message.message_type, + message.content[0].text, + message.condition.unwrap() + ), + yaml + ); + } + + #[test] + fn should_emit_a_content_array_if_content_is_multilingual() { + let message = Message::multilingual( + MessageType::Say, + vec![ + MessageContent::new("english".into()).with_language("en".into()), + MessageContent::new("french".into()).with_language("fr".into()), + ], + ) + .unwrap(); + let yaml = emit(&message); + + assert_eq!( + format!( + "type: {} +content: + - lang: {} + text: '{}' + - lang: {} + text: '{}'", + message.message_type, + message.content[0].language(), + message.content[0].text(), + message.content[1].language(), + message.content[1].text() + ), + yaml + ); + } + } + } } diff --git a/src/metadata/metadata_document.rs b/src/metadata/metadata_document.rs index 16c855a4..6611cc35 100644 --- a/src/metadata/metadata_document.rs +++ b/src/metadata/metadata_document.rs @@ -6,6 +6,8 @@ use std::{ use saphyr::{MarkedYaml, YamlData}; +use crate::logging; + use super::{ error::{ ExpectedType, LoadMetadataError, MetadataDocumentParsingError, ParseMetadataError, @@ -16,6 +18,7 @@ use super::{ message::Message, plugin_metadata::PluginMetadata, yaml::{YamlObjectType, as_string_node, get_as_slice}, + yaml_emit::{EmitYaml, YamlEmitter}, }; static MERGE_KEY: LazyLock = LazyLock::new(|| as_string_node("<<")); @@ -203,25 +206,48 @@ impl MetadataDocument { } pub fn save(&self, file_path: &Path) -> Result<(), WriteMetadataError> { - // let mut hash = saphyr::Hash::new(); + logging::trace!("Saving metadata list to: {}", file_path.display()); - // hash.insert( - // Yaml::String("bash_tags".into()), - // Yaml::Array( - // self.bash_tags - // .iter() - // .map(|b| Yaml::String(b.to_string())) - // .collect(), - // ), - // ); + let mut emitter = YamlEmitter::new(); - // let mut yaml = Yaml::Hash(hash); + if !self.bash_tags.is_empty() { + emitter.map_key("bash_tags"); - // let mut output = String::new(); - // FIXME: Can't handle the error because it's a type that's not exported by saphyr. - // let emitter = YamlEmitter::new(&mut output).dump(&yaml)?; + emitter.begin_array(); - todo!() + for tag in &self.bash_tags { + emitter.unquoted_str(tag); + } + + emitter.end_array(); + } + + if !self.groups.is_empty() { + emitter.map_key("groups"); + self.groups.emit_yaml(&mut emitter); + } + + if !self.messages.is_empty() { + emitter.map_key("globals"); + self.messages.emit_yaml(&mut emitter); + } + + if !self.plugins.is_empty() || !self.regex_plugins.is_empty() { + emitter.map_key("plugins"); + + emitter.begin_array(); + + for plugin in self.plugins() { + plugin.emit_yaml(&mut emitter); + } + + emitter.end_array(); + } + + std::fs::write(file_path, emitter.into_string()) + .map_err(|e| WriteMetadataError::new(file_path.into(), e.into()))?; + + Ok(()) } pub fn bash_tags(&self) -> &[String] { @@ -497,4 +523,23 @@ plugins: let mut metadata_list = MetadataDocument::default(); metadata_list.load(&path).unwrap(); } + + #[test] + fn save_should_write_the_loaded_metadata() { + let tmp_dir = tempdir().unwrap(); + + let path = tmp_dir.path().join("masterlist.yaml"); + std::fs::write(&path, METADATA_LIST_YAML).unwrap(); + + let mut metadata = MetadataDocument::default(); + metadata.load(&path).unwrap(); + + let other_path = tmp_dir.path().join("other.yaml"); + metadata.save(&other_path).unwrap(); + + let mut other_metadata = MetadataDocument::default(); + other_metadata.load(&other_path).unwrap(); + + assert_eq!(metadata, other_metadata); + } } diff --git a/src/metadata/mod.rs b/src/metadata/mod.rs index 98a2e02e..a0245cd0 100644 --- a/src/metadata/mod.rs +++ b/src/metadata/mod.rs @@ -8,7 +8,8 @@ pub(crate) mod metadata_document; mod plugin_cleaning_data; pub(crate) mod plugin_metadata; mod tag; -pub(crate) mod yaml; +mod yaml; +mod yaml_emit; pub use file::{File, Filename}; pub use group::Group; @@ -17,3 +18,11 @@ pub use message::{Message, MessageContent, MessageType, select_message_content}; pub use plugin_cleaning_data::PluginCleaningData; pub use plugin_metadata::PluginMetadata; pub use tag::{Tag, TagSuggestion}; + +#[cfg(test)] +fn emit(metadata: &T) -> String { + let mut emitter = yaml_emit::YamlEmitter::new(); + metadata.emit_yaml(&mut emitter); + + emitter.into_string() +} diff --git a/src/metadata/plugin_cleaning_data.rs b/src/metadata/plugin_cleaning_data.rs index ac96e13e..caae3508 100644 --- a/src/metadata/plugin_cleaning_data.rs +++ b/src/metadata/plugin_cleaning_data.rs @@ -2,8 +2,12 @@ use saphyr::MarkedYaml; use super::{ error::{MultilingualMessageContentsError, ParseMetadataError}, - message::{MessageContent, parse_message_contents_yaml, validate_message_contents}, + message::{ + MessageContent, emit_message_contents, parse_message_contents_yaml, + validate_message_contents, + }, yaml::{YamlObjectType, as_string_node, get_as_hash, get_required_string_value, get_u32_value}, + yaml_emit::{EmitYaml, YamlEmitter}, }; /// Represents data identifying the plugin under which it is stored as dirty or @@ -146,3 +150,153 @@ impl TryFrom<&MarkedYaml> for PluginCleaningData { }) } } + +impl EmitYaml for PluginCleaningData { + fn is_scalar(&self) -> bool { + false + } + + fn emit_yaml(&self, emitter: &mut YamlEmitter) { + emitter.begin_map(); + + emitter.map_key("crc"); + emitter.unquoted_str(&format!("0x{:08X}", self.crc)); + + emitter.map_key("util"); + emitter.single_quoted_str(&self.cleaning_utility); + + if self.itm_count > 0 { + emitter.map_key("itm"); + emitter.u32(self.itm_count); + } + + if self.deleted_reference_count > 0 { + emitter.map_key("udr"); + emitter.u32(self.deleted_reference_count); + } + + if self.deleted_navmesh_count > 0 { + emitter.map_key("nav"); + emitter.u32(self.deleted_navmesh_count); + } + + emit_message_contents(&self.detail, emitter, "detail"); + + emitter.end_map(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod emit_yaml { + use crate::metadata::emit; + + use super::*; + + #[test] + fn should_omit_zero_counts() { + let data = PluginCleaningData::new(0xDEADBEEF, "TES5Edit".into()); + let yaml = emit(&data); + + assert_eq!("crc: 0xDEADBEEF\nutil: 'TES5Edit'", yaml); + } + + #[test] + fn should_emit_non_zero_counts() { + let data = PluginCleaningData::new(0xDEADBEEF, "TES5Edit".into()) + .with_itm_count(1) + .with_deleted_reference_count(2) + .with_deleted_navmesh_count(3); + let yaml = emit(&data); + + assert_eq!( + "crc: 0xDEADBEEF\nutil: 'TES5Edit'\nitm: 1\nudr: 2\nnav: 3", + yaml + ); + } + + #[test] + fn should_emit_map_with_a_detail_string_if_detail_is_monolingual() { + let data = PluginCleaningData::new(0xDEADBEEF, "TES5Edit".into()) + .with_detail(vec![MessageContent::new("message".into())]) + .unwrap(); + let yaml = emit(&data); + + assert_eq!( + format!( + "crc: 0xDEADBEEF\nutil: 'TES5Edit'\ndetail: '{}'", + data.detail[0].text() + ), + yaml + ); + } + + #[test] + fn should_emit_map_with_a_detail_array_if_detail_is_multilingual() { + let data = PluginCleaningData::new(0xDEADBEEF, "TES5Edit".into()) + .with_detail(vec![ + MessageContent::new("english".into()).with_language("en".into()), + MessageContent::new("french".into()).with_language("fr".into()), + ]) + .unwrap(); + let yaml = emit(&data); + + assert_eq!( + format!( + "crc: 0xDEADBEEF +util: 'TES5Edit' +detail: + - lang: {} + text: '{}' + - lang: {} + text: '{}'", + data.detail[0].language(), + data.detail[0].text(), + data.detail[1].language(), + data.detail[1].text() + ), + yaml + ); + } + + #[test] + fn should_emit_map_with_all_fields_set() { + let data = PluginCleaningData::new(0xDEADBEEF, "TES5Edit".into()) + .with_itm_count(1) + .with_deleted_reference_count(2) + .with_deleted_navmesh_count(3) + .with_detail(vec![ + MessageContent::new("english".into()).with_language("en".into()), + MessageContent::new("french".into()).with_language("fr".into()), + ]) + .unwrap(); + let yaml = emit(&data); + + assert_eq!( + format!( + "crc: 0xDEADBEEF +util: '{}' +itm: {} +udr: {} +nav: {} +detail: + - lang: {} + text: '{}' + - lang: {} + text: '{}'", + data.cleaning_utility, + data.itm_count, + data.deleted_reference_count, + data.deleted_navmesh_count, + data.detail[0].language(), + data.detail[0].text(), + data.detail[1].language(), + data.detail[1].text() + ), + yaml + ); + } + } +} diff --git a/src/metadata/plugin_metadata.rs b/src/metadata/plugin_metadata.rs index bc7eb83b..7fe9a9a2 100644 --- a/src/metadata/plugin_metadata.rs +++ b/src/metadata/plugin_metadata.rs @@ -13,6 +13,7 @@ use super::{ yaml::{ YamlObjectType, get_as_hash, get_as_slice, get_required_string_value, get_string_value, }, + yaml_emit::{EmitYaml, YamlEmitter}, }; pub(crate) const GHOST_FILE_EXTENSION: &str = ".ghost"; @@ -209,7 +210,9 @@ impl PluginMetadata { /// Serialises the plugin metadata as YAML. pub fn as_yaml(&self) -> String { - todo!() + let mut emitter = YamlEmitter::new(); + self.emit_yaml(&mut emitter); + emitter.into_string() } } @@ -353,3 +356,292 @@ fn get_vec<'a, T: TryFrom<&'a MarkedYaml, Error = impl Into> .map(|e| T::try_from(e).map_err(Into::into)) .collect::, _>>() } + +impl EmitYaml for PluginMetadata { + fn is_scalar(&self) -> bool { + false + } + + fn emit_yaml(&self, emitter: &mut YamlEmitter) { + emitter.begin_map(); + + emitter.map_key("name"); + emitter.single_quoted_str(self.name()); + + if !self.locations.is_empty() { + emitter.map_key("url"); + self.locations.emit_yaml(emitter); + } + + if let Some(group) = &self.group { + emitter.map_key("group"); + emitter.single_quoted_str(group); + } + + if !self.load_after.is_empty() { + emitter.map_key("after"); + self.load_after.emit_yaml(emitter); + } + + if !self.requirements.is_empty() { + emitter.map_key("req"); + self.requirements.emit_yaml(emitter); + } + + if !self.incompatibilities.is_empty() { + emitter.map_key("inc"); + self.incompatibilities.emit_yaml(emitter); + } + + if !self.messages.is_empty() { + emitter.map_key("msg"); + self.messages.emit_yaml(emitter); + } + + if !self.tags.is_empty() { + emitter.map_key("tag"); + self.tags.emit_yaml(emitter); + } + + if !self.dirty_info.is_empty() { + emitter.map_key("dirty"); + self.dirty_info.emit_yaml(emitter); + } + + if !self.clean_info.is_empty() { + emitter.map_key("clean"); + self.clean_info.emit_yaml(emitter); + } + + emitter.end_map(); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod as_yaml { + use super::*; + + #[test] + fn should_return_a_yaml_string_representation() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_load_after_files(vec![File::new("other.esp".into())]); + let yaml = plugin.as_yaml(); + + assert_eq!( + format!( + "name: '{}'\nafter: ['{}']", + plugin.name.string, + plugin.load_after[0].name() + ), + yaml + ); + } + } + + mod emit_yaml { + use super::*; + use crate::metadata::{MessageType, TagSuggestion, emit}; + + #[test] + fn should_omit_group_if_not_set() { + let plugin = PluginMetadata::new("test.esp").unwrap(); + let yaml = emit(&plugin); + + assert_eq!(format!("name: '{}'", plugin.name.string), yaml); + } + + #[test] + fn should_emit_group_if_set() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_group("group1"); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\ngroup: '{}'", + plugin.name.string, + plugin.group.unwrap() + ), + yaml + ); + } + + #[test] + fn should_emit_a_single_scalar_load_after_file_in_flow_style() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_load_after_files(vec![File::new("other.esp".into())]); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\nafter: ['{}']", + plugin.name.string, + plugin.load_after[0].name() + ), + yaml + ); + } + + #[test] + fn should_emit_a_single_non_scalar_load_after_file_in_block_style() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_load_after_files(vec![ + File::new("other.esp".into()).with_condition("condition1".into()), + ]); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\nafter:\n - name: '{}'\n condition: '{}'", + plugin.name.string, + plugin.load_after[0].name(), + plugin.load_after[0].condition().unwrap(), + ), + yaml + ); + } + + #[test] + fn should_emit_multiple_load_after_files_in_block_style() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_load_after_files(vec![ + File::new("other1.esp".into()), + File::new("other2.esp".into()), + ]); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\nafter:\n - '{}'\n - '{}'", + plugin.name.string, + plugin.load_after[0].name(), + plugin.load_after[1].name(), + ), + yaml + ); + } + + #[test] + fn should_emit_a_single_scalar_requirements_in_flow_style() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_requirements(vec![File::new("other.esp".into())]); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\nreq: ['{}']", + plugin.name.string, + plugin.requirements[0].name() + ), + yaml + ); + } + + #[test] + fn should_emit_a_single_scalar_incompatibility_in_flow_style() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_incompatibilities(vec![File::new("other.esp".into())]); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\ninc: ['{}']", + plugin.name.string, + plugin.incompatibilities[0].name() + ), + yaml + ); + } + + #[test] + fn should_emit_messages() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_messages(vec![ + Message::new(MessageType::Say, "content1".into()), + Message::new(MessageType::Say, "content2".into()), + ]); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\nmsg:\n - type: {}\n content: '{}'\n - type: {}\n content: '{}'", + plugin.name.string, + plugin.messages[0].message_type(), + plugin.messages[0].content()[0].text(), + plugin.messages[1].message_type(), + plugin.messages[1].content()[0].text(), + ), + yaml + ); + } + + #[test] + fn should_emit_a_single_scalar_tag_in_flow_style() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_tags(vec![Tag::new("Relev".into(), TagSuggestion::Addition)]); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\ntag: [{}]", + plugin.name.string, + plugin.tags[0].name() + ), + yaml + ); + } + + #[test] + fn should_emit_dirty_info() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_dirty_info(vec![PluginCleaningData::new(0xDEADBEEF, "utility".into())]); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\ndirty:\n - crc: 0x{:8X}\n util: '{}'", + plugin.name(), + plugin.dirty_info[0].crc(), + plugin.dirty_info[0].cleaning_utility() + ), + yaml + ); + } + + #[test] + fn should_emit_clean_info() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_clean_info(vec![PluginCleaningData::new(0xDEADBEEF, "utility".into())]); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\nclean:\n - crc: 0x{:8X}\n util: '{}'", + plugin.name(), + plugin.clean_info[0].crc(), + plugin.clean_info[0].cleaning_utility() + ), + yaml + ); + } + + #[test] + fn should_emit_a_single_scalar_location_in_flow_style() { + let mut plugin = PluginMetadata::new("test.esp").unwrap(); + plugin.set_locations(vec![Location::new("https://www.example.com".into())]); + let yaml = emit(&plugin); + + assert_eq!( + format!( + "name: '{}'\nurl: ['{}']", + plugin.name(), + plugin.locations[0].url() + ), + yaml + ); + } + } +} diff --git a/src/metadata/tag.rs b/src/metadata/tag.rs index 99d21d70..a689337d 100644 --- a/src/metadata/tag.rs +++ b/src/metadata/tag.rs @@ -3,6 +3,8 @@ use saphyr::YamlData; use super::error::ExpectedType; use super::error::ParseMetadataError; use super::yaml::{YamlObjectType, get_required_string_value, parse_condition}; +use super::yaml_emit::EmitYaml; +use super::yaml_emit::YamlEmitter; /// Represents whether a Bash Tag suggestion is for addition or removal. #[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Ord, PartialOrd, Hash)] @@ -96,3 +98,59 @@ fn name_and_suggestion(value: &str) -> (String, TagSuggestion) { (value.to_string(), TagSuggestion::Addition) } } + +impl EmitYaml for Tag { + fn is_scalar(&self) -> bool { + self.condition.is_none() + } + + fn emit_yaml(&self, emitter: &mut YamlEmitter) { + if let Some(condition) = &self.condition { + emitter.begin_map(); + + emitter.map_key("name"); + if self.is_addition() { + emitter.unquoted_str(&self.name); + } else { + emitter.unquoted_str(&format!("-{}", self.name)); + } + + emitter.map_key("condition"); + emitter.single_quoted_str(condition); + + emitter.end_map(); + } else if self.is_addition() { + emitter.unquoted_str(&self.name); + } else { + emitter.unquoted_str(&format!("-{}", self.name)); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + mod emit_yaml { + use crate::metadata::emit; + + use super::*; + + #[test] + fn should_emit_name_only_if_unconditional_addition() { + let tag = Tag::new("name1".into(), TagSuggestion::Addition); + let yaml = emit(&tag); + + assert_eq!(tag.name(), yaml); + } + + #[test] + fn should_emit_map_if_there_is_a_condition() { + let tag = + Tag::new("name1".into(), TagSuggestion::Removal).with_condition("condition".into()); + let yaml = emit(&tag); + + assert_eq!("name: -name1\ncondition: 'condition'", yaml); + } + } +} diff --git a/src/metadata/yaml_emit.rs b/src/metadata/yaml_emit.rs new file mode 100644 index 00000000..8f8b2f41 --- /dev/null +++ b/src/metadata/yaml_emit.rs @@ -0,0 +1,315 @@ +pub trait EmitYaml { + fn is_scalar(&self) -> bool; + + fn emit_yaml(&self, emitter: &mut YamlEmitter); +} + +#[derive(Clone, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +pub struct YamlEmitter { + buffer: String, + scope: Vec, + style: YamlStyle, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +enum YamlBlock { + Array, + Map, +} + +#[derive(Clone, Copy, Debug, Eq, PartialEq, Ord, PartialOrd, Hash)] +enum YamlStyle { + Flow, + Block, +} + +impl YamlEmitter { + const INDENT_UNIT: &str = " "; + const ARRAY_ELEMENT_PREFIX: &str = "- "; + + pub fn new() -> Self { + Self { + buffer: String::new(), + scope: vec![], + style: YamlStyle::Block, + } + } + + pub fn into_string(self) -> String { + self.buffer + } + + pub fn unquoted_str(&mut self, value: &str) { + if self.style == YamlStyle::Block { + self.write_prefix(); + } + + if can_emit_unquoted(value, self.style) { + self.write(value); + } else if can_single_quote(value) { + self.write(&single_quote(value)); + } else { + self.write(&double_quote(value)); + } + } + + pub fn single_quoted_str(&mut self, value: &str) { + if self.style == YamlStyle::Block { + self.write_prefix(); + } + + if can_single_quote(value) { + self.write(&single_quote(value)); + } else { + self.write(&double_quote(value)); + } + } + + pub fn u32(&mut self, value: u32) { + if self.style == YamlStyle::Block { + self.write_prefix(); + } + + self.write(&value.to_string()); + } + + pub fn begin_map(&mut self) { + if self.scope.last() == Some(&YamlBlock::Array) { + self.end_line(); + self.write_indent(); + self.write(Self::ARRAY_ELEMENT_PREFIX); + } + } + + pub fn end_map(&mut self) { + if self.scope.last() == Some(&YamlBlock::Map) { + self.scope.pop(); + } + } + + /// This assumes that the given key is valid to be written as an unquoted string, and expects a string literal so that it's obvious that a given value is valid. + pub fn map_key(&mut self, key: &'static str) { + match self.scope.last() { + Some(&YamlBlock::Map) => { + self.end_line(); + self.write_indent(); + } + _ => self.scope.push(YamlBlock::Map), + } + + self.write(&format!("{}:", key)); + } + + pub fn begin_array(&mut self) { + if self.style == YamlStyle::Flow { + if self.scope.last() == Some(&YamlBlock::Map) { + self.write(" "); + } + self.write("["); + } + + self.scope.push(YamlBlock::Array); + } + + pub fn end_array(&mut self) { + if self.scope.last() == Some(&YamlBlock::Array) { + self.scope.pop(); + } + + if self.style == YamlStyle::Flow { + self.write("]"); + } + } + + pub fn set_flow_style(&mut self) { + self.style = YamlStyle::Flow; + } + + pub fn set_block_style(&mut self) { + self.style = YamlStyle::Block; + } + + fn end_line(&mut self) { + self.write("\n"); + } + + fn write_indent(&mut self) { + // If in a map, no indent is needed, but an array needs an indent, and a map in an array needs an indent. + if !self.scope.is_empty() { + for _ in 0..self.scope.len() - 1 { + self.write(Self::INDENT_UNIT); + } + } + } + + fn write_prefix(&mut self) { + match self.scope.last() { + Some(&YamlBlock::Array) => { + self.end_line(); + self.write_indent(); + self.write(Self::ARRAY_ELEMENT_PREFIX); + } + Some(&YamlBlock::Map) => self.write(" "), + _ => self.write_indent(), + } + } + + fn write(&mut self, value: &str) { + self.buffer += value; + } +} + +fn can_single_quote(value: &str) -> bool { + // Single-quoted strings are restricted to printable characters + // + value.chars().all(is_printable) +} + +fn is_printable(c: char) -> bool { + // + matches!(c, + '\x09' | '\x0A' | '\x0D' | '\x20'..='\x7E' | '\u{0085}' | '\u{00A0}'..='\u{D7FF}' | '\u{E000}'..='\u{FFFD}' | '\u{010000}'..='\u{10FFFF}' + ) +} + +fn is_yaml_whitespace(c: char) -> bool { + c == ' ' || c == '\t' +} + +fn is_flow_indicator(c: char) -> bool { + matches!(c, '[' | ']' | '{' | '}' | ',') +} + +fn can_emit_unquoted(value: &str, style: YamlStyle) -> bool { + // + if value.is_empty() + || value.starts_with(is_yaml_whitespace) + || value.ends_with(is_yaml_whitespace) + { + return false; + } + + if value.starts_with(|c| { + matches!( + c, + ',' | '[' + | ']' + | '{' + | '}' + | '#' + | '&' + | '*' + | '!' + | '|' + | '>' + | '\'' + | '"' + | '%' + | '@' + | '`' + ) + }) { + return false; + } + + if value.starts_with("? ") + || value.starts_with("?\t") + || value.starts_with("- ") + || value.starts_with("-\t") + { + return false; + } + + if value.contains(": ") + || value.contains(":\t") + || value.contains(" #") + || value.contains("\t#") + { + return false; + } + + if style == YamlStyle::Flow { + !value.contains(is_flow_indicator) + } else { + true + } +} + +fn single_quote(value: &str) -> String { + // Single-quoted strings need single quotes escaped by repeating them. + // + format!("'{}'", value.replace('\'', "''")) +} + +fn double_quote(value: &str) -> String { + // + value + .chars() + .map(|c| { + if is_printable(c) { + c.to_string() + } else { + match c { + '\x00' => "\\0".to_string(), + '\x07' => "\\a".to_string(), + '\x08' => "\\b".to_string(), + '\x09' => "\\t".to_string(), + '\x0A' => "\\n".to_string(), + '\x0B' => "\\v".to_string(), + '\x0C' => "\\f".to_string(), + '\x0D' => "\\r".to_string(), + '\x1B' => "\\e".to_string(), + '\x20' => "\\x20".to_string(), + '"' => "\\\"".to_string(), + '/' => "\\/".to_string(), + '\\' => "\\\\".to_string(), + '\u{0085}' => "\\N".to_string(), + '\u{00A0}' => "\\_".to_string(), + '\u{2028}' => "\\L".to_string(), + '\u{2029}' => "\\P".to_string(), + '\u{00}'..='\u{FF}' => format!("\\x{:2X}", u32::from(c)), + '\u{0100}'..='\u{FFFF}' => format!("\\u{:4X}", u32::from(c)), + c => format!("\\U{:8X}", u32::from(c)), + } + } + }) + .collect() +} + +impl EmitYaml for &[T] { + fn is_scalar(&self) -> bool { + false + } + + fn emit_yaml(&self, emitter: &mut YamlEmitter) { + match self { + [] => {} + [element] if element.is_scalar() => { + emitter.set_flow_style(); + emitter.begin_array(); + element.emit_yaml(emitter); + emitter.end_array(); + emitter.set_block_style(); + } + elements => { + emitter.begin_array(); + + for element in elements.iter() { + element.emit_yaml(emitter); + } + + emitter.end_array(); + } + } + } +} + +impl EmitYaml for Vec { + fn is_scalar(&self) -> bool { + false + } + + fn emit_yaml(&self, emitter: &mut YamlEmitter) { + self.as_slice().emit_yaml(emitter); + } +}