diff --git a/ssg-frontmatter/src/error.rs b/ssg-frontmatter/src/error.rs new file mode 100644 index 00000000..3dafbd29 --- /dev/null +++ b/ssg-frontmatter/src/error.rs @@ -0,0 +1,158 @@ +use serde_json::Error as JsonError; +use serde_yml::Error as YamlError; +use thiserror::Error; + +/// Represents errors that can occur during frontmatter parsing, conversion, and extraction. +/// +/// This enum uses the `thiserror` crate to provide clear and structured error messages, +/// making it easier to debug and handle issues that arise when processing frontmatter. +#[derive(Error, Debug)] +pub enum FrontmatterError { + /// Error occurred while parsing YAML. + #[error("Failed to parse YAML: {source}")] + YamlParseError { + /// The source error from the YAML parser. + source: YamlError, + }, + + /// Error occurred while parsing TOML. + #[error("Failed to parse TOML: {0}")] + TomlParseError(#[from] toml::de::Error), + + /// Error occurred while parsing JSON. + #[error("Failed to parse JSON: {0}")] + JsonParseError(#[from] JsonError), + + /// The frontmatter format is invalid or unsupported. + #[error("Invalid frontmatter format")] + InvalidFormat, + + /// Error occurred during conversion between formats. + #[error("Failed to convert frontmatter: {0}")] + ConversionError(String), + + /// Error occurred during extraction of frontmatter. + #[error("Failed to extract frontmatter: {0}")] + ExtractionError(String), + + /// Generic parse error. + #[error("Failed to parse frontmatter: {0}")] + ParseError(String), + + /// Error for unsupported or unknown frontmatter format. + #[error("Unsupported frontmatter format detected at line {line}")] + UnsupportedFormat { + /// The line number where the unsupported format was detected. + line: usize, + }, +} + +impl Clone for FrontmatterError { + fn clone(&self) -> Self { + match self { + FrontmatterError::YamlParseError { .. } => { + FrontmatterError::InvalidFormat + } // Custom fallback logic + FrontmatterError::TomlParseError(e) => { + FrontmatterError::TomlParseError(e.clone()) + } + FrontmatterError::JsonParseError(_) => { + FrontmatterError::InvalidFormat + } // Custom fallback logic + FrontmatterError::InvalidFormat => { + FrontmatterError::InvalidFormat + } + FrontmatterError::ConversionError(msg) => { + FrontmatterError::ConversionError(msg.clone()) + } + FrontmatterError::ExtractionError(msg) => { + FrontmatterError::ExtractionError(msg.clone()) + } + FrontmatterError::ParseError(msg) => { + FrontmatterError::ParseError(msg.clone()) + } + FrontmatterError::UnsupportedFormat { .. } => { + FrontmatterError::UnsupportedFormat { line: 0 } + } + } + } +} + +impl FrontmatterError { + /// Helper function to create a generic parse error with a custom message. + /// + /// # Arguments + /// + /// * `message` - A string slice containing the error message. + /// + /// # Example + /// + /// ```rust + /// use ssg_frontmatter::error::FrontmatterError; + /// let error = FrontmatterError::generic_parse_error("Failed to parse at line 10"); + /// ``` + pub fn generic_parse_error(message: &str) -> FrontmatterError { + FrontmatterError::ParseError(message.to_string()) + } + + /// Helper function to create an `UnsupportedFormat` error with a given line number. + /// + /// # Arguments + /// + /// * `line` - The line number where the unsupported format was detected. + /// + /// # Example + /// + /// ```rust + /// use ssg_frontmatter::error::FrontmatterError; + /// let error = FrontmatterError::unsupported_format(12); + /// ``` + pub fn unsupported_format(line: usize) -> FrontmatterError { + FrontmatterError::UnsupportedFormat { line } + } +} + +/// Example usage of the `FrontmatterError` enum. +/// +/// This function demonstrates how you might handle various errors during frontmatter parsing. +/// +/// # Returns +/// +/// Returns a `Result` demonstrating a parsing error. +pub fn example_usage() -> Result<(), FrontmatterError> { + let example_toml = "invalid toml content"; + + // Attempt to parse TOML and handle errors + match toml::from_str::(example_toml) { + Ok(_) => Ok(()), + Err(e) => Err(FrontmatterError::TomlParseError(e)), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generic_parse_error() { + let error = + FrontmatterError::generic_parse_error("Parsing failed"); + match error { + FrontmatterError::ParseError(msg) => { + assert_eq!(msg, "Parsing failed") + } + _ => panic!("Expected ParseError"), + } + } + + #[test] + fn test_unsupported_format_error() { + let error = FrontmatterError::unsupported_format(10); + match error { + FrontmatterError::UnsupportedFormat { line } => { + assert_eq!(line, 10) + } + _ => panic!("Expected UnsupportedFormat"), + } + } +} diff --git a/ssg-frontmatter/src/extractor.rs b/ssg-frontmatter/src/extractor.rs new file mode 100644 index 00000000..b7836e27 --- /dev/null +++ b/ssg-frontmatter/src/extractor.rs @@ -0,0 +1,248 @@ +use crate::error::FrontmatterError; +use crate::types::Format; + +/// Extracts raw frontmatter from the content, detecting YAML, TOML, or JSON formats. +/// +/// This function tries to extract frontmatter based on the common delimiters for +/// YAML (`---`), TOML (`+++`), and JSON (`{}`). If frontmatter is detected, it +/// returns the extracted frontmatter and the remaining content. +/// +/// # Arguments +/// +/// * `content` - The full content string that may contain frontmatter. +/// +/// # Returns +/// +/// A `Result` containing a tuple of two `&str` slices: the raw frontmatter and the remaining content. +/// If no valid frontmatter format is found, it returns `FrontmatterError::InvalidFormat`. +/// +/// # Errors +/// +/// - `FrontmatterError::InvalidFormat`: When the frontmatter format is not recognized. +/// - `FrontmatterError::ExtractionError`: When there is an issue extracting frontmatter. +/// +/// # Example +/// +/// ```rust +/// use ssg_frontmatter::extractor::{extract_delimited_frontmatter, extract_raw_frontmatter, extract_json_frontmatter}; +/// let content = "---\ntitle: Example\n---\nContent here"; +/// let result = extract_raw_frontmatter(content).unwrap(); +/// assert_eq!(result.0, "title: Example"); +/// assert_eq!(result.1, "Content here"); +/// ``` +pub fn extract_raw_frontmatter( + content: &str, +) -> Result<(&str, &str), FrontmatterError> { + // Try to extract YAML frontmatter. + if let Some(yaml) = + extract_delimited_frontmatter(content, "---\n", "\n---\n") + { + let remaining = content.split("\n---\n").nth(1).unwrap_or(""); + return Ok((yaml, remaining)); + } + // Try to extract TOML frontmatter. + if let Some(toml) = + extract_delimited_frontmatter(content, "+++\n", "\n+++\n") + { + let remaining = content.split("\n+++\n").nth(1).unwrap_or(""); + return Ok((toml, remaining)); + } + // Try to extract JSON frontmatter. + if let Some(json) = extract_json_frontmatter(content) { + let remaining = &content[json.len()..]; + return Ok((json, remaining.trim_start())); + } + // Return an error if no valid frontmatter format is found. + Err(FrontmatterError::InvalidFormat) +} + +/// Detects the format of the extracted frontmatter. +/// +/// This function analyzes the raw frontmatter and determines whether it is in YAML, +/// TOML, or JSON format by examining the structure of the data. +/// +/// # Arguments +/// +/// * `raw_frontmatter` - The extracted frontmatter as a string slice. +/// +/// # Returns +/// +/// A `Result` containing the detected `Format` (either `Json`, `Toml`, or `Yaml`). +/// +/// # Errors +/// +/// - `FrontmatterError::InvalidFormat`: If the format cannot be determined. +/// +/// # Example +/// +/// ```rust +/// use ssg_frontmatter::extractor::detect_format; +/// use ssg_frontmatter::Format; +/// let raw = "---\ntitle: Example\n---"; +/// let format = detect_format(raw).unwrap(); +/// assert_eq!(format, Format::Yaml); +/// ``` +pub fn detect_format( + raw_frontmatter: &str, +) -> Result { + let trimmed = raw_frontmatter.trim_start(); + + // Detect JSON format by checking for a leading '{' character. + if trimmed.starts_with('{') { + Ok(Format::Json) + } + // Detect TOML format by checking if the frontmatter contains '=' (key-value pairs). + else if trimmed.contains('=') { + Ok(Format::Toml) + } + // Default to YAML if no other format matches. + else { + Ok(Format::Yaml) + } +} + +/// Extracts frontmatter enclosed by the given start and end delimiters. +/// +/// This function checks for frontmatter enclosed by delimiters like `---` for YAML or `+++` for TOML. +/// It returns the extracted frontmatter if the delimiters are found. +/// +/// # Arguments +/// +/// * `content` - The full content string containing frontmatter. +/// * `start_delim` - The starting delimiter (e.g., `---\n` for YAML). +/// * `end_delim` - The ending delimiter (e.g., `\n---\n` for YAML). +/// +/// # Returns +/// +/// An `Option` containing the extracted frontmatter as a string slice. Returns `None` +/// if the delimiters are not found. +/// +/// # Example +/// +/// ```rust +/// use ssg_frontmatter::extractor::extract_delimited_frontmatter; +/// let content = "---\ntitle: Example\n---\nContent"; +/// let frontmatter = extract_delimited_frontmatter(content, "---\n", "\n---\n").unwrap(); +/// assert_eq!(frontmatter, "title: Example"); +/// ``` +pub fn extract_delimited_frontmatter<'a>( + content: &'a str, + start_delim: &str, + end_delim: &str, +) -> Option<&'a str> { + content.strip_prefix(start_delim)?.split(end_delim).next() +} + +/// Extracts JSON frontmatter from the content by detecting balanced curly braces (`{}`). +/// +/// This function attempts to locate a valid JSON object starting with `{` and checks for balanced +/// curly braces to identify the end of the frontmatter. If the JSON object is found, it returns +/// the frontmatter as a string slice. +/// +/// # Arguments +/// +/// * `content` - The full content string that may contain JSON frontmatter. +/// +/// # Returns +/// +/// An `Option` containing the extracted JSON frontmatter string. Returns `None` if no valid JSON frontmatter is detected. +/// +/// # Example +/// +/// ```rust +/// use ssg_frontmatter::extractor::extract_json_frontmatter; +/// let content = "{ \"title\": \"Example\" }\nContent"; +/// let frontmatter = extract_json_frontmatter(content).unwrap(); +/// assert_eq!(frontmatter, "{ \"title\": \"Example\" }"); +/// ``` +pub fn extract_json_frontmatter(content: &str) -> Option<&str> { + let trimmed = content.trim_start(); + + // If the content doesn't start with '{', it's not JSON frontmatter. + if !trimmed.starts_with('{') { + return None; + } + + let mut brace_count = 0; + + // Iterate over the characters in the trimmed content, looking for balanced braces. + for (idx, ch) in trimmed.char_indices() { + match ch { + '{' => brace_count += 1, + '}' => { + brace_count -= 1; + // Once braces are balanced (brace_count == 0), we've reached the end of the JSON object. + if brace_count == 0 { + return Some(&trimmed[..=idx]); + } + } + _ => {} + } + } + + // If no balanced braces are found, return None. + None +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_extract_raw_frontmatter_yaml() { + let content = "---\ntitle: Example\n---\nContent here"; + let result = extract_raw_frontmatter(content).unwrap(); + assert_eq!(result.0, "title: Example"); + assert_eq!(result.1, "Content here"); + } + + #[test] + fn test_extract_raw_frontmatter_toml() { + let content = "+++\ntitle = \"Example\"\n+++\nContent here"; + let result = extract_raw_frontmatter(content).unwrap(); + assert_eq!(result.0, "title = \"Example\""); + assert_eq!(result.1, "Content here"); + } + + #[test] + fn test_extract_raw_frontmatter_json() { + let content = "{ \"title\": \"Example\" }\nContent here"; + let result = extract_raw_frontmatter(content).unwrap(); + assert_eq!(result.0, "{ \"title\": \"Example\" }"); + assert_eq!(result.1, "Content here"); + } + + #[test] + fn test_extract_raw_frontmatter_invalid() { + let content = "Invalid frontmatter"; + let result = extract_raw_frontmatter(content); + assert!(matches!(result, Err(FrontmatterError::InvalidFormat))); + } + + #[test] + fn test_detect_format() { + let yaml = "title: Example"; + let toml = "title = \"Example\""; + let json = "{ \"title\": \"Example\" }"; + + assert_eq!(detect_format(yaml).unwrap(), Format::Yaml); + assert_eq!(detect_format(toml).unwrap(), Format::Toml); + assert_eq!(detect_format(json).unwrap(), Format::Json); + } + + #[test] + fn test_extract_delimited_frontmatter() { + let content = "---\ntitle: Example\n---\nContent here"; + let result = + extract_delimited_frontmatter(content, "---\n", "\n---\n") + .unwrap(); + assert_eq!(result, "title: Example"); + } + + #[test] + fn test_extract_json_frontmatter() { + let content = "{ \"title\": \"Example\" }\nContent here"; + let result = extract_json_frontmatter(content).unwrap(); + assert_eq!(result, "{ \"title\": \"Example\" }"); + } +} diff --git a/ssg-frontmatter/src/lib.rs b/ssg-frontmatter/src/lib.rs index de5950eb..5bf95f1d 100644 --- a/ssg-frontmatter/src/lib.rs +++ b/ssg-frontmatter/src/lib.rs @@ -1,33 +1,48 @@ //! # SSG Frontmatter //! -//! This module provides functionality to extract and parse frontmatter from various file formats -//! commonly used in static site generators. It supports YAML, TOML, and JSON frontmatter. - -use serde_json::{Map, Value as JsonValue}; -use serde_yml::Value as YamlValue; -use std::collections::HashMap; -use thiserror::Error; -use toml::Value as TomlValue; - -/// Errors that can occur during frontmatter parsing. -#[derive(Error, Debug)] -pub enum FrontmatterError { - /// Error occurred while parsing YAML. - #[error("Failed to parse YAML: {0}")] - YamlParseError(#[from] serde_yml::Error), - - /// Error occurred while parsing TOML. - #[error("Failed to parse TOML: {0}")] - TomlParseError(#[from] toml::de::Error), - - /// Error occurred while parsing JSON. - #[error("Failed to parse JSON: {0}")] - JsonParseError(#[from] serde_json::Error), - - /// The frontmatter format is invalid or unsupported. - #[error("Invalid frontmatter format")] - InvalidFormat, -} +//! `ssg-frontmatter` is a Rust library for parsing and serializing frontmatter in various formats, including YAML, TOML, and JSON. +//! Frontmatter is commonly used in static site generators (SSG) to store metadata at the beginning of content files. +//! +//! This library provides functions to extract, parse, and convert frontmatter between different formats, making it easy to work with frontmatter data in Rust applications. +//! +//! ## Features +//! - Extract frontmatter from content files. +//! - Parse frontmatter into a structured format. +//! - Convert frontmatter between YAML, TOML, and JSON formats. +//! +//! ## Example +//! ```rust +//! use ssg_frontmatter::{Format, Frontmatter, to_format}; +//! +//! let mut frontmatter = Frontmatter::new(); +//! frontmatter.insert("title".to_string(), "My Post".into()); +//! frontmatter.insert("date".to_string(), "2023-05-20".into()); +//! +//! let yaml = to_format(&frontmatter, Format::Yaml).unwrap(); +//! assert!(yaml.contains("title: My Post")); +//! assert!(yaml.contains("date: '2023-05-20'")); +//! ``` +//! +//! ## Modules +//! - `error`: Contains error types used throughout the library. +//! - `extractor`: Provides functions for extracting raw frontmatter. +//! - `parser`: Handles the parsing of frontmatter from raw strings. +//! - `types`: Defines the core types such as `Frontmatter`, `Value`, and `Format`. + +/// The `error` module contains error types related to the frontmatter parsing process. +pub mod error; +/// The `extractor` module contains functions for extracting raw frontmatter from content. +pub mod extractor; +/// The `parser` module contains functions for parsing frontmatter into a structured format. +pub mod parser; +/// The `types` module contains types related to the frontmatter parsing process. +pub mod types; + +use error::FrontmatterError; +use extractor::{detect_format, extract_raw_frontmatter}; +use parser::{parse, to_string}; +// Re-export types for external access +pub use types::{Format, Frontmatter, Value}; // Add `Frontmatter` and `Format` to the public interface /// Extracts frontmatter from a string of content. /// @@ -40,13 +55,13 @@ pub enum FrontmatterError { /// /// # Returns /// -/// * `Ok(HashMap)` - A hashmap of key-value pairs from the frontmatter. -/// * `Err(FrontmatterError)` - An error if parsing fails or the format is invalid. +/// * `Ok((Frontmatter, &str))` - A tuple containing the parsed frontmatter and the remaining content. +/// * `Err(FrontmatterError)` - An error if extraction or parsing fails. /// /// # Examples /// /// ``` -/// use ssg_frontmatter::extract; +/// use ssg_frontmatter::{extract, Frontmatter}; /// /// let yaml_content = r#"--- /// title: My Post @@ -54,197 +69,48 @@ pub enum FrontmatterError { /// --- /// Content here"#; /// -/// let frontmatter = extract(yaml_content).unwrap(); -/// assert_eq!(frontmatter.get("title"), Some(&"My Post".to_string())); +/// let (frontmatter, remaining_content) = extract(yaml_content).unwrap(); +/// assert_eq!(frontmatter.get("title").unwrap().as_str().unwrap(), "My Post"); +/// assert_eq!(remaining_content, "Content here"); /// ``` pub fn extract( content: &str, -) -> Result, FrontmatterError> { - if let Some(yaml) = - extract_delimited_frontmatter(content, "---\n", "\n---\n") - { - parse_yaml_frontmatter(yaml) - } else if let Some(toml) = - extract_delimited_frontmatter(content, "+++\n", "\n+++\n") - { - parse_toml_frontmatter(toml) - } else if let Some(json) = extract_json_frontmatter(content) { - parse_json_frontmatter(json) - } else { - Err(FrontmatterError::InvalidFormat) - } -} - -/// Extracts frontmatter enclosed by delimiters. -fn extract_delimited_frontmatter<'a>( - content: &'a str, - start_delim: &str, - end_delim: &str, -) -> Option<&'a str> { - content.strip_prefix(start_delim)?.split(end_delim).next() -} - -/// Extracts JSON frontmatter. -fn extract_json_frontmatter(content: &str) -> Option<&str> { - let trimmed = content.trim_start(); - if !trimmed.starts_with('{') { - return None; - } - - let mut brace_count = 0; - for (idx, ch) in trimmed.char_indices() { - match ch { - '{' => brace_count += 1, - '}' => { - brace_count -= 1; - if brace_count == 0 { - return Some(&trimmed[..=idx]); - } - } - _ => {} - } - } - None -} - -/// Parses YAML frontmatter. -fn parse_yaml_frontmatter( - yaml: &str, -) -> Result, FrontmatterError> { - let yaml_value: YamlValue = serde_yml::from_str(yaml)?; - Ok(parse_yaml_value(&yaml_value)) -} - -/// Parses TOML frontmatter. -fn parse_toml_frontmatter( - toml: &str, -) -> Result, FrontmatterError> { - let toml_value: TomlValue = toml.parse()?; - Ok(parse_toml_table( - toml_value - .as_table() - .ok_or(FrontmatterError::InvalidFormat)?, - )) -} - -/// Parses JSON frontmatter. -fn parse_json_frontmatter( - json: &str, -) -> Result, FrontmatterError> { - let json_value: JsonValue = serde_json::from_str(json)?; - parse_json_value(&json_value) -} - -/// Converts a YAML value to a HashMap. -fn parse_yaml_value(yaml_value: &YamlValue) -> HashMap { - let mut result = HashMap::new(); - if let YamlValue::Mapping(mapping) = yaml_value { - for (key, value) in mapping { - if let (YamlValue::String(k), YamlValue::String(v)) = - (key, value) - { - result.insert(k.clone(), v.clone()); - } - } - } - result -} - -/// Converts a TOML table to a HashMap. -fn parse_toml_table( - toml_table: &toml::Table, -) -> HashMap { - toml_table - .iter() - .filter_map(|(k, v)| { - v.as_str().map(|s| (k.to_string(), s.to_string())) - }) - .collect() -} - -/// Converts a JSON value to a HashMap. -fn parse_json_value( - json_value: &JsonValue, -) -> Result, FrontmatterError> { - match json_value { - JsonValue::Object(obj) => Ok(parse_json_object(obj)), - _ => Err(FrontmatterError::InvalidFormat), - } +) -> Result<(Frontmatter, &str), FrontmatterError> { + let (raw_frontmatter, remaining_content) = + extract_raw_frontmatter(content)?; + let format = detect_format(raw_frontmatter)?; + let frontmatter = parse(raw_frontmatter, format)?; + Ok((frontmatter, remaining_content)) } -/// Converts a JSON object to a HashMap. -fn parse_json_object( - json_object: &Map, -) -> HashMap { - json_object - .iter() - .filter_map(|(k, v)| { - Some(( - k.to_string(), - match v { - JsonValue::String(s) => s.to_string(), - JsonValue::Number(n) => n.to_string(), - JsonValue::Bool(b) => b.to_string(), - _ => return None, - }, - )) - }) - .collect() -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_extract_yaml_frontmatter() { - let content = r#"--- -title: Test Post -date: 2023-05-20 ---- -# Actual content here"#; - - let result = extract(content).unwrap(); - assert_eq!(result.get("title"), Some(&"Test Post".to_string())); - assert_eq!(result.get("date"), Some(&"2023-05-20".to_string())); - } - - #[test] - fn test_extract_toml_frontmatter() { - let content = r#"+++ -title = "Test Post" -date = "2023-05-20" -+++ -# Actual content here"#; - - let result = extract(content).unwrap(); - assert_eq!(result.get("title"), Some(&"Test Post".to_string())); - assert_eq!(result.get("date"), Some(&"2023-05-20".to_string())); - } - - #[test] - fn test_extract_json_frontmatter() { - let content = r#" -{ - "title": "Test Post", - "date": "2023-05-20", - "content": "Actual content here" -} -# Actual content here"#; - - let result = extract(content).unwrap(); - assert_eq!(result.get("title"), Some(&"Test Post".to_string())); - assert_eq!(result.get("date"), Some(&"2023-05-20".to_string())); - assert_eq!( - result.get("content"), - Some(&"Actual content here".to_string()) - ); - } - - #[test] - fn test_invalid_frontmatter() { - let content = "No frontmatter here"; - let result = extract(content); - assert!(matches!(result, Err(FrontmatterError::InvalidFormat))); - } +/// Converts frontmatter to a specific format. +/// +/// # Arguments +/// +/// * `frontmatter` - The Frontmatter to convert. +/// * `format` - The target Format to convert to. +/// +/// # Returns +/// +/// * `Ok(String)` - The frontmatter converted to the specified format. +/// * `Err(FrontmatterError)` - An error if conversion fails. +/// +/// # Examples +/// +/// ``` +/// use ssg_frontmatter::{Frontmatter, Format, to_format}; +/// +/// let mut frontmatter = Frontmatter::new(); +/// frontmatter.insert("title".to_string(), "My Post".into()); +/// frontmatter.insert("date".to_string(), "2023-05-20".into()); +/// +/// let yaml = to_format(&frontmatter, Format::Yaml).unwrap(); +/// assert!(yaml.contains("title: My Post")); +/// assert!(yaml.contains("date: '2023-05-20'")); +/// ``` +pub fn to_format( + frontmatter: &Frontmatter, + format: Format, +) -> Result { + to_string(frontmatter, format) } diff --git a/ssg-frontmatter/src/parser.rs b/ssg-frontmatter/src/parser.rs new file mode 100644 index 00000000..d80d8e1b --- /dev/null +++ b/ssg-frontmatter/src/parser.rs @@ -0,0 +1,463 @@ +//! This module provides functionality for parsing and serializing frontmatter in various formats. +//! It supports YAML, TOML, and JSON formats, allowing conversion between these formats and the internal `Frontmatter` representation. + +use crate::types::Frontmatter; +use crate::{error::FrontmatterError, Format, Value}; +use serde_json::Value as JsonValue; +use serde_yml::Value as YmlValue; +use toml::Value as TomlValue; + +/// Parses raw frontmatter string into a `Frontmatter` object based on the specified format. +/// +/// # Arguments +/// +/// * `raw_frontmatter` - A string slice containing the raw frontmatter content. +/// * `format` - The `Format` enum specifying the format of the frontmatter (YAML, TOML, or JSON). +/// +/// # Returns +/// +/// A `Result` containing the parsed `Frontmatter` object or a `FrontmatterError` if parsing fails. +/// +/// # Examples +/// +/// ``` +/// use ssg_frontmatter::{Format, Frontmatter, parser::parse}; +/// +/// let yaml_content = "title: My Post\ndate: 2023-05-20\n"; +/// let result = parse(yaml_content, Format::Yaml); +/// assert!(result.is_ok()); +/// ``` +pub fn parse( + raw_frontmatter: &str, + format: Format, +) -> Result { + match format { + Format::Yaml => parse_yaml(raw_frontmatter), + Format::Toml => parse_toml(raw_frontmatter), + Format::Json => parse_json(raw_frontmatter), + Format::Unsupported => Err(FrontmatterError::ConversionError( + "Unsupported format".to_string(), + )), + } +} + +/// Converts a `Frontmatter` object to a string representation in the specified format. +/// +/// # Arguments +/// +/// * `frontmatter` - A reference to the `Frontmatter` object to be converted. +/// * `format` - The `Format` enum specifying the target format (YAML, TOML, or JSON). +/// +/// # Returns +/// +/// A `Result` containing the serialized string or a `FrontmatterError` if serialization fails. +/// +/// # Examples +/// +/// ``` +/// use ssg_frontmatter::{Format, Frontmatter, Value, parser::to_string}; +/// +/// let mut frontmatter = Frontmatter::new(); +/// frontmatter.insert("title".to_string(), Value::String("My Post".to_string())); +/// let result = to_string(&frontmatter, Format::Yaml); +/// assert!(result.is_ok()); +/// ``` +pub fn to_string( + frontmatter: &Frontmatter, + format: Format, +) -> Result { + match format { + Format::Yaml => to_yaml(frontmatter), + Format::Toml => to_toml(frontmatter), + Format::Json => to_json(frontmatter), + Format::Unsupported => Err(FrontmatterError::ConversionError( + "Unsupported format".to_string(), + )), + } +} + +// YAML-specific functions + +fn parse_yaml(raw: &str) -> Result { + let yml_value: YmlValue = + serde_yml::from_str(raw).map_err(|e| { + FrontmatterError::YamlParseError { + source: e, // Assign the YamlError to source + } + })?; + Ok(parse_yml_value(&yml_value)) +} + +fn to_yaml( + frontmatter: &Frontmatter, +) -> Result { + serde_yml::to_string(&frontmatter.0) + .map_err(|e| FrontmatterError::ConversionError(e.to_string())) +} + +fn yml_to_value(yml: &YmlValue) -> Value { + match yml { + YmlValue::Null => Value::Null, + YmlValue::Bool(b) => Value::Boolean(*b), + YmlValue::Number(n) => { + if let Some(i) = n.as_i64() { + Value::Number(i as f64) + } else if let Some(f) = n.as_f64() { + Value::Number(f) + } else { + Value::Number(0.0) // Fallback, should not happen + } + } + YmlValue::String(s) => Value::String(s.clone()), + YmlValue::Sequence(seq) => { + Value::Array(seq.iter().map(yml_to_value).collect()) + } + YmlValue::Mapping(map) => { + let mut result = Frontmatter::new(); + for (k, v) in map { + if let YmlValue::String(key) = k { + let _ = result.insert(key.clone(), yml_to_value(v)); + } + } + Value::Object(Box::new(result)) + } + YmlValue::Tagged(tagged) => Value::Tagged( + tagged.tag.to_string(), + Box::new(yml_to_value(&tagged.value)), + ), + } +} + +fn parse_yml_value(yml_value: &YmlValue) -> Frontmatter { + let mut result = Frontmatter::new(); + if let YmlValue::Mapping(mapping) = yml_value { + for (key, value) in mapping { + if let YmlValue::String(k) = key { + let _ = result.insert(k.clone(), yml_to_value(value)); + } + } + } + result +} + +// TOML-specific functions + +fn parse_toml(raw: &str) -> Result { + let toml_value: TomlValue = + raw.parse().map_err(FrontmatterError::TomlParseError)?; + Ok(parse_toml_value(&toml_value)) +} + +fn to_toml( + frontmatter: &Frontmatter, +) -> Result { + toml::to_string(&frontmatter.0) + .map_err(|e| FrontmatterError::ConversionError(e.to_string())) +} + +fn toml_to_value(toml: &TomlValue) -> Value { + match toml { + TomlValue::String(s) => Value::String(s.clone()), + TomlValue::Integer(i) => Value::Number(*i as f64), + TomlValue::Float(f) => Value::Number(*f), + TomlValue::Boolean(b) => Value::Boolean(*b), + TomlValue::Array(arr) => { + Value::Array(arr.iter().map(toml_to_value).collect()) + } + TomlValue::Table(table) => { + let mut result = Frontmatter::new(); + for (k, v) in table { + let _ = result.insert(k.clone(), toml_to_value(v)); + } + Value::Object(Box::new(result)) + } + TomlValue::Datetime(dt) => Value::String(dt.to_string()), + } +} + +fn parse_toml_value(toml_value: &TomlValue) -> Frontmatter { + let mut result = Frontmatter::new(); + if let TomlValue::Table(table) = toml_value { + for (key, value) in table { + let _ = result.insert(key.clone(), toml_to_value(value)); + } + } + result +} + +// JSON-specific functions + +fn parse_json(raw: &str) -> Result { + let json_value: JsonValue = serde_json::from_str(raw) + .map_err(FrontmatterError::JsonParseError)?; + Ok(parse_json_value(&json_value)) +} + +fn to_json( + frontmatter: &Frontmatter, +) -> Result { + serde_json::to_string(&frontmatter.0) + .map_err(|e| FrontmatterError::ConversionError(e.to_string())) +} + +fn json_to_value(json: &JsonValue) -> Value { + match json { + JsonValue::Null => Value::Null, + JsonValue::Bool(b) => Value::Boolean(*b), + JsonValue::Number(n) => { + if let Some(i) = n.as_i64() { + Value::Number(i as f64) + } else if let Some(f) = n.as_f64() { + Value::Number(f) + } else { + Value::Number(0.0) // Fallback, should not happen + } + } + JsonValue::String(s) => Value::String(s.clone()), + JsonValue::Array(arr) => { + Value::Array(arr.iter().map(json_to_value).collect()) + } + JsonValue::Object(obj) => { + let mut result = Frontmatter::new(); + for (k, v) in obj { + let _ = result.insert(k.clone(), json_to_value(v)); + } + Value::Object(Box::new(result)) + } + } +} + +fn parse_json_value(json_value: &JsonValue) -> Frontmatter { + let mut result = Frontmatter::new(); + if let JsonValue::Object(obj) = json_value { + for (key, value) in obj { + let _ = result.insert(key.clone(), json_to_value(value)); + } + } + result +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_yaml() { + let yaml = "title: My Post\ndate: 2023-05-20\n"; + let result = parse(yaml, Format::Yaml); + assert!(result.is_ok()); + let frontmatter = result.unwrap(); + assert_eq!( + frontmatter.get("title").unwrap().as_str().unwrap(), + "My Post" + ); + } + + #[test] + fn test_parse_invalid_yaml() { + let invalid_yaml = + "title: My Post\ndate: 2023-05-20\ninvalid_entry"; + let result = parse(invalid_yaml, Format::Yaml); + assert!(result.is_err()); // Expecting an error + } + + #[test] + fn test_parse_toml() { + let toml = "title = \"My Post\"\ndate = 2023-05-20\n"; + let result = parse(toml, Format::Toml); + assert!(result.is_ok()); + let frontmatter = result.unwrap(); + assert_eq!( + frontmatter.get("title").unwrap().as_str().unwrap(), + "My Post" + ); + } + + #[test] + fn test_parse_invalid_toml() { + let toml = "title = \"My Post\"\ndate = invalid-date\n"; + let result = parse(toml, Format::Toml); + assert!(result.is_err()); + } + + #[test] + fn test_parse_json() { + let json = r#"{"title": "My Post", "date": "2023-05-20"}"#; + let result = parse(json, Format::Json); + assert!(result.is_ok()); + + // Work directly with the Frontmatter type + let frontmatter = result.unwrap(); + + // Assuming Frontmatter is a map-like structure, work with it directly + assert_eq!( + frontmatter.get("title").unwrap(), + &Value::String("My Post".to_string()) + ); + } + + #[test] + fn test_parse_invalid_json() { + let json = r#"{"title": "My Post", "date": invalid-date}"#; + let result = parse(json, Format::Json); + assert!(result.is_err()); // Expecting a JSON parsing error + } + + #[test] + fn test_to_yaml() { + let mut frontmatter = Frontmatter::new(); + let _ = frontmatter.insert( + "title".to_string(), + Value::String("My Post".to_string()), + ); + let result = to_string(&frontmatter, Format::Yaml); + assert!(result.is_ok()); + let yaml = result.unwrap(); + assert!(yaml.contains("title: My Post")); + } + + #[test] + fn test_to_toml() { + let mut frontmatter = Frontmatter::new(); + let _ = frontmatter.insert( + "title".to_string(), + Value::String("My Post".to_string()), + ); + let result = to_string(&frontmatter, Format::Toml); + assert!(result.is_ok()); + let toml = result.unwrap(); + assert!(toml.contains("title = \"My Post\"")); + } + + #[test] + fn test_to_json() { + let mut frontmatter = Frontmatter::new(); + let _ = frontmatter.insert( + "title".to_string(), + Value::String("My Post".to_string()), + ); + let result = to_string(&frontmatter, Format::Json); + assert!(result.is_ok()); + let json = result.unwrap(); + assert!(json.contains("\"title\":\"My Post\"")); + } + + #[test] + fn test_to_invalid_format() { + let mut frontmatter = Frontmatter::new(); + let _ = frontmatter.insert( + "title".to_string(), + Value::String("My Post".to_string()), + ); + + // Using the unsupported format variant + let result = to_string(&frontmatter, Format::Unsupported); + + // We expect this to fail with an error + assert!(result.is_err()); + } + + #[test] + fn test_parse_nested_yaml() { + let yaml = r#" + parent: + child1: value1 + child2: + subchild: value2 + array: + - item1 + - item2 + "#; + let result = parse(yaml, Format::Yaml); + assert!(result.is_ok()); + let frontmatter = result.unwrap(); + + let parent = + frontmatter.get("parent").unwrap().as_object().unwrap(); + let child1 = parent.get("child1").unwrap().as_str().unwrap(); + let subchild = parent + .get("child2") + .unwrap() + .as_object() + .unwrap() + .get("subchild") + .unwrap() + .as_str() + .unwrap(); + let array = parent.get("array").unwrap().as_array().unwrap(); + + assert_eq!(child1, "value1"); + assert_eq!(subchild, "value2"); + assert_eq!(array[0].as_str().unwrap(), "item1"); + assert_eq!(array[1].as_str().unwrap(), "item2"); + } + + #[test] + fn test_parse_nested_toml() { + let toml = r#" + [parent] + child1 = "value1" + child2 = { subchild = "value2" } + array = ["item1", "item2"] + "#; + let result = parse(toml, Format::Toml); + assert!(result.is_ok()); + let frontmatter = result.unwrap(); + + let parent = + frontmatter.get("parent").unwrap().as_object().unwrap(); + let child1 = parent.get("child1").unwrap().as_str().unwrap(); + let subchild = parent + .get("child2") + .unwrap() + .as_object() + .unwrap() + .get("subchild") + .unwrap() + .as_str() + .unwrap(); + let array = parent.get("array").unwrap().as_array().unwrap(); + + assert_eq!(child1, "value1"); + assert_eq!(subchild, "value2"); + assert_eq!(array[0].as_str().unwrap(), "item1"); + assert_eq!(array[1].as_str().unwrap(), "item2"); + } + + #[test] + fn test_parse_nested_json() { + let json = r#" + { + "parent": { + "child1": "value1", + "child2": { + "subchild": "value2" + }, + "array": ["item1", "item2"] + } + } + "#; + let result = parse(json, Format::Json); + assert!(result.is_ok()); + let frontmatter = result.unwrap(); + + let parent = + frontmatter.get("parent").unwrap().as_object().unwrap(); + let child1 = parent.get("child1").unwrap().as_str().unwrap(); + let subchild = parent + .get("child2") + .unwrap() + .as_object() + .unwrap() + .get("subchild") + .unwrap() + .as_str() + .unwrap(); + let array = parent.get("array").unwrap().as_array().unwrap(); + + assert_eq!(child1, "value1"); + assert_eq!(subchild, "value2"); + assert_eq!(array[0].as_str().unwrap(), "item1"); + assert_eq!(array[1].as_str().unwrap(), "item2"); + } +} diff --git a/ssg-frontmatter/src/types.rs b/ssg-frontmatter/src/types.rs new file mode 100644 index 00000000..0c0e43d8 --- /dev/null +++ b/ssg-frontmatter/src/types.rs @@ -0,0 +1,1287 @@ +use serde::{Deserialize, Serialize}; +use std::collections::{BTreeMap, HashMap}; +use std::fmt; +use std::str::FromStr; + +/// Format enum represents different formats that can be used for frontmatter serialization/deserialization. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Format { + /// YAML format. + Yaml, + /// TOML format. + Toml, + /// JSON format. + Json, + /// Unsupported format. + Unsupported, +} + +/// A flexible value type that can hold various types such as null, strings, +/// numbers, booleans, arrays, objects (in the form of frontmatter), and tagged values. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +#[serde(untagged)] +pub enum Value { + /// Represents a null value. + Null, + /// Represents a string value. + String(String), + /// Represents a numeric value. + Number(f64), + /// Represents a boolean value. + Boolean(bool), + /// Represents an array of values. + Array(Vec), + /// Represents an object (frontmatter). + Object(Box), + /// Represents a tagged value, containing a tag and a value. + Tagged(String, Box), +} + +/// Represents the frontmatter, a collection of key-value pairs where the value +/// is represented using the `Value` enum to support various data types. +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)] +pub struct Frontmatter(pub HashMap); + +impl Frontmatter { + /// Creates a new, empty frontmatter. + #[must_use] + pub fn new() -> Self { + Frontmatter(HashMap::new()) + } + + /// Inserts a key-value pair into the frontmatter. + /// + /// # Arguments + /// + /// * `key` - The key for the entry. + /// * `value` - The value associated with the key. + /// + /// # Returns + /// + /// An option containing the old value if it was replaced. + #[must_use] + pub fn insert( + &mut self, + key: String, + value: Value, + ) -> Option { + self.0.insert(key, value) + } + + /// Retrieves a reference to a value associated with a key. + #[must_use] + pub fn get(&self, key: &str) -> Option<&Value> { + self.0.get(key) + } + + /// Retrieves a mutable reference to a value associated with a key. + /// + /// # Arguments + /// + /// * `key` - The key for which to retrieve the mutable reference. + /// + /// # Returns + /// + /// An `Option` containing a mutable reference to the value, or `None` if the key is not present. + pub fn get_mut(&mut self, key: &str) -> Option<&mut Value> { + self.0.get_mut(key) + } + + /// Removes a key-value pair from the frontmatter. + #[must_use] + pub fn remove(&mut self, key: &str) -> Option { + self.0.remove(key) + } + + /// Checks if the frontmatter contains a given key. + pub fn contains_key(&self, key: &str) -> bool { + self.0.contains_key(key) + } + + /// Returns the number of entries in the frontmatter. + pub fn len(&self) -> usize { + self.0.len() + } + + /// Checks if the frontmatter is empty. + pub fn is_empty(&self) -> bool { + self.0.is_empty() + } + + /// Returns an iterator over the key-value pairs of the frontmatter. + pub fn iter( + &self, + ) -> std::collections::hash_map::Iter { + self.0.iter() + } + + /// Returns a mutable iterator over the key-value pairs of the frontmatter. + pub fn iter_mut( + &mut self, + ) -> std::collections::hash_map::IterMut { + self.0.iter_mut() + } + + /// Merges another frontmatter into this one. If a key exists, it will be overwritten. + pub fn merge(&mut self, other: Frontmatter) { + self.0.extend(other.0); + } + + /// Checks if a given key exists and its value is `null`. + pub fn is_null(&self, key: &str) -> bool { + matches!(self.get(key), Some(Value::Null)) + } +} + +impl Default for Frontmatter { + fn default() -> Self { + Self::new() + } +} + +/// Implement `IntoIterator` for `Frontmatter` to allow idiomatic iteration. +impl IntoIterator for Frontmatter { + type Item = (String, Value); + type IntoIter = std::collections::hash_map::IntoIter; + + fn into_iter(self) -> Self::IntoIter { + self.0.into_iter() + } +} + +/// Implement `FromIterator` for `Frontmatter` to create a frontmatter from an iterator. +impl FromIterator<(String, Value)> for Frontmatter { + /// Creates a `Frontmatter` from an iterator of key-value pairs. + /// + /// # Arguments + /// + /// * `iter` - An iterator of key-value pairs where the key is a `String` and the value is a `Value`. + /// + /// # Returns + /// + /// A `Frontmatter` containing the key-value pairs from the iterator. + fn from_iter>( + iter: I, + ) -> Self { + let mut fm = Frontmatter::new(); + for (key, value) in iter { + let _ = fm.insert(key, value); + } + fm + } +} + +/// Implement `Display` for `Frontmatter` to allow easy printing with escaped characters. +impl fmt::Display for Frontmatter { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{{")?; + + // Use a BTreeMap to ensure consistent key order (sorted by key) + let mut sorted_map = BTreeMap::new(); + for (key, value) in &self.0 { + sorted_map.insert(key, value); + } + + for (i, (key, value)) in sorted_map.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "\"{}\": {}", escape_str(key), value)?; + } + + write!(f, "}}") + } +} + +/// Implement `Display` for `Value` to allow easy printing with escaped characters. +impl fmt::Display for Value { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Value::Null => write!(f, "null"), + Value::String(s) => write!(f, "\"{}\"", escape_str(s)), + Value::Number(n) => { + if n.fract() == 0.0 { + write!(f, "{:.0}", n) + } else { + write!(f, "{}", n) + } + } + Value::Boolean(b) => write!(f, "{}", b), + Value::Array(arr) => { + write!(f, "[")?; + for (i, v) in arr.iter().enumerate() { + if i > 0 { + write!(f, ", ")?; + } + write!(f, "{}", v)?; + } + write!(f, "]") + } + Value::Object(obj) => write!(f, "{}", obj), + Value::Tagged(tag, val) => { + write!(f, "\"{}\": {}", escape_str(tag), val) + } + } + } +} + +/// Escapes special characters in a string (e.g., backslashes and quotes). +fn escape_str(s: &str) -> String { + s.replace('\\', "\\\\").replace('"', "\\\"") +} + +impl Value { + /// Returns the value as a string, if it is of type `String`. + pub fn as_str(&self) -> Option<&str> { + if let Value::String(ref s) = self { + Some(s) + } else { + None + } + } + + /// Returns the value as a float, if it is of type `Number`. + pub fn as_f64(&self) -> Option { + if let Value::Number(n) = self { + Some(*n) + } else { + None + } + } + + /// Returns the value as a boolean, if it is of type `Boolean`. + pub fn as_bool(&self) -> Option { + if let Value::Boolean(b) = self { + Some(*b) + } else { + None + } + } + + /// Returns the value as an array, if it is of type `Array`. + pub fn as_array(&self) -> Option<&Vec> { + if let Value::Array(ref arr) = self { + Some(arr) + } else { + None + } + } + + /// Returns the value as an object (frontmatter), if it is of type `Object`. + pub fn as_object(&self) -> Option<&Frontmatter> { + if let Value::Object(ref obj) = self { + Some(obj) + } else { + None + } + } + + /// Returns the value as a tagged value, if it is of type `Tagged`. + pub fn as_tagged(&self) -> Option<(&str, &Value)> { + if let Value::Tagged(ref tag, ref val) = self { + Some((tag.as_str(), val.as_ref())) + } else { + None + } + } + + /// Checks if the value is of type `Null`. + pub fn is_null(&self) -> bool { + matches!(self, Value::Null) + } + + /// Checks if the value is of type `String`. + /// + /// # Returns + /// + /// `true` if the value is a `String`, otherwise `false`. + pub fn is_string(&self) -> bool { + matches!(self, Value::String(_)) + } + + /// Checks if the value is of type `Number`. + /// + /// # Returns + /// + /// `true` if the value is a `Number`, otherwise `false`. + pub fn is_number(&self) -> bool { + matches!(self, Value::Number(_)) + } + + /// Checks if the value is of type `Boolean`. + /// + /// # Returns + /// + /// `true` if the value is a `Boolean`, otherwise `false`. + pub fn is_boolean(&self) -> bool { + matches!(self, Value::Boolean(_)) + } + + /// Checks if the value is of type `Array`. + /// + /// # Returns + /// + /// `true` if the value is an `Array`, otherwise `false`. + pub fn is_array(&self) -> bool { + matches!(self, Value::Array(_)) + } + + /// Checks if the value is of type `Object`. + /// + /// # Returns + /// + /// `true` if the value is an `Object`, otherwise `false`. + pub fn is_object(&self) -> bool { + matches!(self, Value::Object(_)) + } + + /// Checks if the value is of type `Tagged`. + /// + /// # Returns + /// + /// `true` if the value is `Tagged`, otherwise `false`. + pub fn is_tagged(&self) -> bool { + matches!(self, Value::Tagged(_, _)) + } + + /// Returns the length of the array if the value is an array, otherwise returns `None`. + pub fn array_len(&self) -> Option { + if let Value::Array(ref arr) = self { + Some(arr.len()) + } else { + None + } + } + + /// Attempts to convert the value into a `Frontmatter`. + pub fn to_object(self) -> Result { + if let Value::Object(obj) = self { + Ok(*obj) + } else { + Err("Value is not an object".into()) + } + } + + /// Converts the value to a string representation regardless of its type. + pub fn to_string_representation(&self) -> String { + format!("{}", self) + } + + /// Attempts to convert the value into a `String`. + /// + /// # Returns + /// + /// A `Result` containing the string value, or an error message if the value is not a string. + pub fn into_string(self) -> Result { + if let Value::String(s) = self { + Ok(s) + } else { + Err("Value is not a string".into()) + } + } + + /// Attempts to convert the value into an `f64`. + /// + /// # Returns + /// + /// A `Result` containing the float value, or an error message if the value is not a number. + pub fn into_f64(self) -> Result { + if let Value::Number(n) = self { + Ok(n) + } else { + Err("Value is not a number".into()) + } + } + + /// Attempts to convert the value into a `bool`. + /// + /// # Returns + /// + /// A `Result` containing the boolean value, or an error message if the value is not a boolean. + pub fn into_bool(self) -> Result { + if let Value::Boolean(b) = self { + Ok(b) + } else { + Err("Value is not a boolean".into()) + } + } + + /// Attempts to get a mutable reference to the array if the value is an array. + pub fn get_mut_array(&mut self) -> Option<&mut Vec> { + if let Value::Array(ref mut arr) = self { + Some(arr) + } else { + None + } + } +} + +impl From<&str> for Value { + fn from(s: &str) -> Self { + Value::String(s.to_string()) + } +} + +impl From for Value { + fn from(s: String) -> Self { + Value::String(s) + } +} + +impl From for Value { + fn from(n: f64) -> Self { + Value::Number(n) + } +} + +impl From for Value { + fn from(b: bool) -> Self { + Value::Boolean(b) + } +} + +impl FromIterator for Value { + fn from_iter>(iter: I) -> Self { + Value::Array(iter.into_iter().collect()) + } +} + +/// Implement the Default trait for `Value`, with the default being `Null`. +impl Default for Value { + fn default() -> Self { + Value::Null + } +} + +/// Implement the Default trait for `Format`, with the default being `Json`. +impl Default for Format { + fn default() -> Self { + Format::Json + } +} + +/// Implement `FromStr` for `Value` to allow parsing of simple types from strings. +impl FromStr for Value { + type Err = &'static str; + + fn from_str(s: &str) -> Result { + if s.eq_ignore_ascii_case("null") { + Ok(Value::Null) + } else if s.eq_ignore_ascii_case("true") { + Ok(Value::Boolean(true)) + } else if s.eq_ignore_ascii_case("false") { + Ok(Value::Boolean(false)) + } else if let Ok(n) = s.parse::() { + Ok(Value::Number(n)) + } else { + Ok(Value::String(s.to_string())) + } + } +} + +/// Implement conversion from `Value` to `serde_json::Value` for ease of use in JSON-related operations. +impl From for serde_json::Value { + fn from(value: Value) -> Self { + match value { + Value::Null => serde_json::Value::Null, + Value::String(s) => serde_json::Value::String(s), + Value::Number(n) => serde_json::Number::from_f64(n) + .map(serde_json::Value::Number) + .unwrap_or(serde_json::Value::Null), + Value::Boolean(b) => serde_json::Value::Bool(b), + Value::Array(arr) => serde_json::Value::Array( + arr.into_iter().map(serde_json::Value::from).collect(), + ), + Value::Object(obj) => serde_json::Value::Object( + obj.0 + .into_iter() + .map(|(k, v)| (k, serde_json::Value::from(v))) + .collect(), + ), + Value::Tagged(tag, v) => { + let mut map = serde_json::Map::new(); + map.insert(tag, serde_json::Value::from(*v)); + serde_json::Value::Object(map) + } + } + } +} + +/// Implement conversion from `Value` to `serde_yml::Value` for YAML-related operations. +impl From for serde_yml::Value { + fn from(value: Value) -> Self { + match value { + Value::Null => serde_yml::Value::Null, + Value::String(s) => serde_yml::Value::String(s), + Value::Number(n) => serde_yml::Value::Number(n.into()), + Value::Boolean(b) => serde_yml::Value::Bool(b), + Value::Array(arr) => serde_yml::Value::Sequence( + arr.into_iter().map(serde_yml::Value::from).collect(), + ), + Value::Object(obj) => { + let map = obj + .0 + .into_iter() + .map(|(k, v)| { + ( + serde_yml::Value::String(k), + serde_yml::Value::from(v), + ) + }) + .collect(); + serde_yml::Value::Mapping(map) + } + Value::Tagged(tag, v) => { + let mut map = serde_yml::Mapping::new(); + map.insert( + serde_yml::Value::String(tag), + serde_yml::Value::from(*v), + ); + serde_yml::Value::Mapping(map) + } + } + } +} + +/// Implement conversion from `Value` to `toml::Value` for TOML-related operations. +impl From for toml::Value { + fn from(value: Value) -> Self { + match value { + Value::Null => toml::Value::String(String::new()), // TOML has no explicit null, empty string as a placeholder. + Value::String(s) => toml::Value::String(s), + Value::Number(n) => toml::Value::Float(n), + Value::Boolean(b) => toml::Value::Boolean(b), + Value::Array(arr) => toml::Value::Array( + arr.into_iter().map(toml::Value::from).collect(), + ), + Value::Object(obj) => toml::Value::Table( + obj.0 + .into_iter() + .map(|(k, v)| (k, toml::Value::from(v))) + .collect(), + ), + Value::Tagged(tag, v) => { + let mut map = toml::map::Map::new(); + map.insert(tag, toml::Value::from(*v)); + toml::Value::Table(map) + } + } + } +} + +impl From for Value { + fn from(value: toml::Value) -> Self { + match value { + toml::Value::String(s) if s.is_empty() => Value::Null, // Treat empty strings as Null + toml::Value::String(s) => Value::String(s), + toml::Value::Float(n) => Value::Number(n), + toml::Value::Integer(n) => Value::Number(n as f64), + toml::Value::Boolean(b) => Value::Boolean(b), + toml::Value::Array(arr) => { + Value::Array(arr.into_iter().map(Value::from).collect()) + } + toml::Value::Table(obj) => { + Value::Object(Box::new(Frontmatter( + obj.into_iter() + .map(|(k, v)| (k, Value::from(v))) + .collect(), + ))) + } + _ => Value::Null, // Handle other TOML types as Null + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use serde_json; + use serde_yml; + use std::f64::consts::PI; + use toml; + + #[test] + fn test_frontmatter_new() { + let fm = Frontmatter::new(); + assert!(fm.is_empty()); + assert_eq!(fm.len(), 0); + } + + #[test] + fn test_frontmatter_insert_and_get() { + let mut fm = Frontmatter::new(); + let key = "title".to_string(); + let value = Value::String("Hello World".to_string()); + let _ = fm.insert(key.clone(), value.clone()); + + assert_eq!(fm.get(&key), Some(&value)); + } + + #[test] + fn test_frontmatter_remove() { + let mut fm = Frontmatter::new(); + let key = "title".to_string(); + let value = Value::String("Hello World".to_string()); + let _ = fm.insert(key.clone(), value.clone()); + + let removed = fm.remove(&key); + assert_eq!(removed, Some(value)); + assert!(fm.get(&key).is_none()); + } + + #[test] + fn test_frontmatter_contains_key() { + let mut fm = Frontmatter::new(); + let key = "title".to_string(); + let value = Value::String("Hello World".to_string()); + let _ = fm.insert(key.clone(), value.clone()); + + assert!(fm.contains_key(&key)); + let _ = fm.remove(&key); + assert!(!fm.contains_key(&key)); + } + + #[test] + fn test_frontmatter_len_and_is_empty() { + let mut fm = Frontmatter::new(); + assert_eq!(fm.len(), 0); + assert!(fm.is_empty()); + + let _ = fm.insert("key1".to_string(), Value::Null); + assert_eq!(fm.len(), 1); + assert!(!fm.is_empty()); + + let _ = fm.insert("key2".to_string(), Value::Boolean(true)); + assert_eq!(fm.len(), 2); + + let _ = fm.remove("key1"); + assert_eq!(fm.len(), 1); + + let _ = fm.remove("key2"); + assert_eq!(fm.len(), 0); + assert!(fm.is_empty()); + } + + #[test] + fn test_frontmatter_iter() { + let mut fm = Frontmatter::new(); + let _ = fm.insert( + "title".to_string(), + Value::String("Hello".to_string()), + ); + let _ = fm.insert("views".to_string(), Value::Number(100.0)); + + let mut keys = vec![]; + let mut values = vec![]; + + for (k, v) in fm.iter() { + keys.push(k.clone()); + values.push(v.clone()); + } + + keys.sort(); + values.sort_by(|a, b| { + format!("{:?}", a).cmp(&format!("{:?}", b)) + }); + + assert_eq!( + keys, + vec!["title".to_string(), "views".to_string()] + ); + assert_eq!( + values, + vec![ + Value::Number(100.0), + Value::String("Hello".to_string()) + ] + ); + } + + #[test] + fn test_frontmatter_iter_mut() { + let mut fm = Frontmatter::new(); + let _ = fm.insert("count".to_string(), Value::Number(1.0)); + + for (_, v) in fm.iter_mut() { + if let Value::Number(n) = v { + *n += 1.0; + } + } + + assert_eq!(fm.get("count"), Some(&Value::Number(2.0))); + } + + #[test] + fn test_value_as_str() { + let value = Value::String("Hello".to_string()); + assert_eq!(value.as_str(), Some("Hello")); + + let value = Value::Number(42.0); + assert_eq!(value.as_str(), None); + } + + #[test] + fn test_value_as_f64() { + let value = Value::Number(42.0); + assert_eq!(value.as_f64(), Some(42.0)); + + let value = Value::String("Not a number".to_string()); + assert_eq!(value.as_f64(), None); + } + + #[test] + fn test_value_as_bool() { + let value = Value::Boolean(true); + assert_eq!(value.as_bool(), Some(true)); + + let value = Value::String("Not a bool".to_string()); + assert_eq!(value.as_bool(), None); + } + + #[test] + fn test_value_as_array() { + let value = + Value::Array(vec![Value::Null, Value::Boolean(false)]); + assert!(value.as_array().is_some()); + let array = value.as_array().unwrap(); + assert_eq!(array.len(), 2); + assert_eq!(array[0], Value::Null); + assert_eq!(array[1], Value::Boolean(false)); + + let value = Value::String("Not an array".to_string()); + assert!(value.as_array().is_none()); + } + + #[test] + fn test_value_as_object() { + let mut fm = Frontmatter::new(); + let _ = fm.insert( + "key".to_string(), + Value::String("value".to_string()), + ); + let value = Value::Object(Box::new(fm.clone())); + assert!(value.as_object().is_some()); + assert_eq!(value.as_object().unwrap(), &fm); + + let value = Value::String("Not an object".to_string()); + assert!(value.as_object().is_none()); + } + + #[test] + fn test_value_as_tagged() { + let inner_value = Value::Boolean(true); + let value = Value::Tagged( + "isActive".to_string(), + Box::new(inner_value.clone()), + ); + assert!(value.as_tagged().is_some()); + let (tag, val) = value.as_tagged().unwrap(); + assert_eq!(tag, "isActive"); + assert_eq!(val, &inner_value); + + let value = Value::String("Not tagged".to_string()); + assert!(value.as_tagged().is_none()); + } + + #[test] + fn test_value_is_null() { + let value = Value::Null; + assert!(value.is_null()); + + let value = Value::String("Not null".to_string()); + assert!(!value.is_null()); + } + + #[test] + fn test_from_traits() { + let s: Value = "Hello".into(); + assert_eq!(s, Value::String("Hello".to_string())); + + let s: Value = "Hello".to_string().into(); + assert_eq!(s, Value::String("Hello".to_string())); + + let n: Value = Value::Number(PI); + assert_eq!(n, Value::Number(PI)); + + let b: Value = true.into(); + assert_eq!(b, Value::Boolean(true)); + } + + #[test] + fn test_default_traits() { + let default_value: Value = Default::default(); + assert_eq!(default_value, Value::Null); + + let default_format: Format = Default::default(); + assert_eq!(default_format, Format::Json); + } + + #[test] + fn test_value_conversion_to_serde_json() { + let mut fm = Frontmatter::new(); + let _ = fm.insert( + "title".to_string(), + Value::String("My Post".to_string()), + ); + let _ = fm.insert("views".to_string(), Value::Number(100.0)); + let _ = + fm.insert("published".to_string(), Value::Boolean(true)); + let _ = fm.insert( + "tags".to_string(), + Value::Array(vec![ + Value::String("rust".to_string()), + Value::String("serde".to_string()), + ]), + ); + + let value = Value::Object(Box::new(fm.clone())); + let json_value: serde_json::Value = value.into(); + + let expected = serde_json::json!({ + "title": "My Post", + "views": 100.0, + "published": true, + "tags": ["rust", "serde"] + }); + + assert_eq!(json_value, expected); + } + + #[test] + fn test_value_conversion_to_serde_yml() { + let mut fm = Frontmatter::new(); + let _ = fm.insert( + "title".to_string(), + Value::String("My Post".to_string()), + ); + let _ = fm.insert("views".to_string(), Value::Number(100.0)); + let _ = + fm.insert("published".to_string(), Value::Boolean(true)); + let _ = fm.insert( + "tags".to_string(), + Value::Array(vec![ + Value::String("rust".to_string()), + Value::String("serde".to_string()), + ]), + ); + + let value = Value::Object(Box::new(fm.clone())); + let yml_value: serde_yml::Value = value.into(); + + let mut expected_map = serde_yml::Mapping::new(); + expected_map.insert( + serde_yml::Value::String("title".to_string()), + serde_yml::Value::String("My Post".to_string()), + ); + expected_map.insert( + serde_yml::Value::String("views".to_string()), + serde_yml::Value::Number(100.0.into()), + ); + expected_map.insert( + serde_yml::Value::String("published".to_string()), + serde_yml::Value::Bool(true), + ); + expected_map.insert( + serde_yml::Value::String("tags".to_string()), + serde_yml::Value::Sequence(vec![ + serde_yml::Value::String("rust".to_string()), + serde_yml::Value::String("serde".to_string()), + ]), + ); + + let expected = serde_yml::Value::Mapping(expected_map); + assert_eq!(yml_value, expected); + } + + #[test] + fn test_value_conversion_to_toml() { + let mut fm = Frontmatter::new(); + let _ = fm.insert( + "title".to_string(), + Value::String("My Post".to_string()), + ); + let _ = fm.insert("views".to_string(), Value::Number(100.0)); + let _ = + fm.insert("published".to_string(), Value::Boolean(true)); + let _ = fm.insert( + "tags".to_string(), + Value::Array(vec![ + Value::String("rust".to_string()), + Value::String("serde".to_string()), + ]), + ); + + let value = Value::Object(Box::new(fm.clone())); + let toml_value: toml::Value = value.into(); + + let mut expected_table = toml::map::Map::new(); + expected_table.insert( + "title".to_string(), + toml::Value::String("My Post".to_string()), + ); + expected_table + .insert("views".to_string(), toml::Value::Float(100.0)); + expected_table.insert( + "published".to_string(), + toml::Value::Boolean(true), + ); + expected_table.insert( + "tags".to_string(), + toml::Value::Array(vec![ + toml::Value::String("rust".to_string()), + toml::Value::String("serde".to_string()), + ]), + ); + + let expected = toml::Value::Table(expected_table); + assert_eq!(toml_value, expected); + } + + #[test] + fn test_serialization_deserialization_json() { + let mut fm = Frontmatter::new(); + let _ = fm.insert( + "title".to_string(), + Value::String("JSON Test".to_string()), + ); + let _ = fm.insert("count".to_string(), Value::Number(10.0)); + + let value = Value::Object(Box::new(fm.clone())); + + // Serialize to JSON + let serialized = serde_json::to_string(&value).unwrap(); + + // Deserialize back + let deserialized: Value = + serde_json::from_str(&serialized).unwrap(); + + assert_eq!(deserialized, value); + } + + #[test] + fn test_serialization_deserialization_yaml() { + let mut fm = Frontmatter::new(); + let _ = fm.insert( + "title".to_string(), + Value::String("YAML Test".to_string()), + ); + let _ = fm.insert("active".to_string(), Value::Boolean(false)); + + let value = Value::Object(Box::new(fm.clone())); + + // Serialize to YAML + let serialized = serde_yml::to_string(&value).unwrap(); + + // Deserialize back + let deserialized: Value = + serde_yml::from_str(&serialized).unwrap(); + + assert_eq!(deserialized, value); + } + + #[test] + fn test_serialization_deserialization_toml() { + let mut fm = Frontmatter::new(); + let _ = fm.insert( + "title".to_string(), + Value::String("TOML Test".to_string()), + ); + let _ = fm.insert("score".to_string(), Value::Number(95.5)); + + let value = Value::Object(Box::new(fm.clone())); + + // Serialize to TOML + let serialized = toml::to_string(&value).unwrap(); + + // Deserialize back + // Note: Since TOML doesn't support all Value variants directly (e.g., Tagged), ensure your Value type can handle it or adjust accordingly. + let deserialized: toml::Value = + toml::from_str(&serialized).unwrap(); + let converted_back: Value = Value::from(deserialized); + + // Due to TOML's limitations, the deserialized structure might differ. + // Adjust the assertion based on how you handle TOML deserialization. + // Here, we check if essential fields are correctly deserialized. + if let Value::Object(obj) = converted_back { + assert_eq!( + obj.get("title"), + Some(&Value::String("TOML Test".to_string())) + ); + assert_eq!(obj.get("score"), Some(&Value::Number(95.5))); + } else { + panic!("Deserialized TOML value is not an object"); + } + } + + #[test] + fn test_tagged_value_conversion() { + let tagged_value = Value::Tagged( + "custom_tag".to_string(), + Box::new(Value::String("Tagged".to_string())), + ); + + // Convert to serde_json::Value + let json_value: serde_json::Value = tagged_value.clone().into(); + let expected_json = serde_json::json!({ + "custom_tag": "Tagged" + }); + assert_eq!(json_value, expected_json); + + // Convert to serde_yml::Value + let yaml_value: serde_yml::Value = tagged_value.clone().into(); + let mut expected_yaml_map = serde_yml::Mapping::new(); + expected_yaml_map.insert( + serde_yml::Value::String("custom_tag".to_string()), + serde_yml::Value::String("Tagged".to_string()), + ); + let expected_yaml = + serde_yml::Value::Mapping(expected_yaml_map); + assert_eq!(yaml_value, expected_yaml); + + // Convert to toml::Value + let toml_value: toml::Value = tagged_value.into(); + let mut expected_toml_map = toml::map::Map::new(); + expected_toml_map.insert( + "custom_tag".to_string(), + toml::Value::String("Tagged".to_string()), + ); + let expected_toml = toml::Value::Table(expected_toml_map); + assert_eq!(toml_value, expected_toml); + } + + #[test] + fn test_empty_array_and_object() { + let empty_array = Value::Array(vec![]); + assert!(empty_array.as_array().unwrap().is_empty()); + + let empty_object = Value::Object(Box::new(Frontmatter::new())); + assert!(empty_object.as_object().unwrap().is_empty()); + } + + #[test] + fn test_nested_frontmatter() { + let mut inner_fm = Frontmatter::new(); + let _ = inner_fm + .insert("inner_key".to_string(), Value::Boolean(true)); + + let mut outer_fm = Frontmatter::new(); + let _ = outer_fm.insert( + "outer_key".to_string(), + Value::Object(Box::new(inner_fm.clone())), + ); + + let value = Value::Object(Box::new(outer_fm.clone())); + let json_value: serde_json::Value = value.into(); + + let expected = serde_json::json!({ + "outer_key": { + "inner_key": true + } + }); + + assert_eq!(json_value, expected); + } + + #[test] + fn test_value_from_toml_null_placeholder() { + let toml_null = toml::Value::String(String::new()); + let value: Value = toml_null.into(); + assert_eq!(value, Value::Null); + } + + #[test] + fn test_frontmatter_merge() { + let mut fm1 = Frontmatter::new(); + let _ = fm1.insert( + "key1".to_string(), + Value::String("value1".to_string()), + ); + let _ = fm1.insert( + "key2".to_string(), + Value::String("value2".to_string()), + ); + + let mut fm2 = Frontmatter::new(); + let _ = fm2.insert( + "key2".to_string(), + Value::String("overwritten".to_string()), + ); + let _ = fm2.insert( + "key3".to_string(), + Value::String("value3".to_string()), + ); + + fm1.merge(fm2); + + assert_eq!( + fm1.get("key1"), + Some(&Value::String("value1".to_string())) + ); + assert_eq!( + fm1.get("key2"), + Some(&Value::String("overwritten".to_string())) + ); + assert_eq!( + fm1.get("key3"), + Some(&Value::String("value3".to_string())) + ); + } + + #[test] + fn test_frontmatter_is_null() { + let mut fm = Frontmatter::new(); + let _ = fm.insert("key1".to_string(), Value::Null); + let _ = fm.insert( + "key2".to_string(), + Value::String("value2".to_string()), + ); + + assert!(fm.is_null("key1")); + assert!(!fm.is_null("key2")); + } + + #[test] + fn test_value_array_len() { + let value = + Value::Array(vec![Value::Null, Value::Boolean(true)]); + assert_eq!(value.array_len(), Some(2)); + + let non_array_value = Value::String("Not an array".to_string()); + assert_eq!(non_array_value.array_len(), None); + } + + #[test] + fn test_value_to_object() { + let mut fm = Frontmatter::new(); + let _ = fm.insert( + "key".to_string(), + Value::String("value".to_string()), + ); + let value = Value::Object(Box::new(fm.clone())); + + assert_eq!(value.to_object().unwrap(), fm); + + let non_object_value = + Value::String("Not an object".to_string()); + assert!(non_object_value.to_object().is_err()); + } + + #[test] + fn test_value_to_string_representation() { + let value = Value::String("Hello World".to_string()); + assert_eq!(value.to_string_representation(), "\"Hello World\""); + + let value = Value::Null; + assert_eq!(value.to_string_representation(), "null"); + + let value = Value::Array(vec![ + Value::Boolean(true), + Value::Number(42.0), + ]); + assert_eq!(value.to_string_representation(), "[true, 42]"); + } + + #[test] + fn test_value_get_mut_array() { + let mut value = + Value::Array(vec![Value::Null, Value::Boolean(false)]); + let array = value.get_mut_array().unwrap(); + array.push(Value::String("new value".to_string())); + + assert_eq!( + value, + Value::Array(vec![ + Value::Null, + Value::Boolean(false), + Value::String("new value".to_string()) + ]) + ); + } + + #[test] + fn test_value_display() { + let value = Value::String("Hello World".to_string()); + assert_eq!(format!("{}", value), "\"Hello World\""); + + let value = Value::Number(42.0); + assert_eq!(format!("{}", value), "42"); + + let value = + Value::Array(vec![Value::Boolean(true), Value::Null]); + assert_eq!(format!("{}", value), "[true, null]"); + } + + #[test] + fn test_frontmatter_display() { + let mut fm = Frontmatter::new(); + let _ = fm.insert( + "key1".to_string(), + Value::String("value1".to_string()), + ); + let _ = fm.insert("key2".to_string(), Value::Number(42.0)); + + assert_eq!( + format!("{}", fm), + "{\"key1\": \"value1\", \"key2\": 42}" + ); + } + + #[test] + fn test_from_str_for_value() { + assert_eq!(Value::from_str("null").unwrap(), Value::Null); + assert_eq!( + Value::from_str("true").unwrap(), + Value::Boolean(true) + ); + assert_eq!( + Value::from_str("false").unwrap(), + Value::Boolean(false) + ); + assert_eq!( + Value::from_str("42.0").unwrap(), + Value::Number(42.0) + ); + assert_eq!( + Value::from_str("Hello World").unwrap(), + Value::String("Hello World".to_string()) + ); + } + + #[test] + fn test_escape_str() { + assert_eq!( + escape_str("Hello \"World\""), + "Hello \\\"World\\\"" + ); + assert_eq!(escape_str("Path\\to\\file"), "Path\\\\to\\\\file"); + } + + #[test] + fn test_display_for_value() { + let value = Value::String("Hello \"World\"".to_string()); + assert_eq!(format!("{}", value), "\"Hello \\\"World\\\"\""); + + let value = Value::Number(42.0); + assert_eq!(format!("{}", value), "42"); + + let value = + Value::Array(vec![Value::Boolean(true), Value::Null]); + assert_eq!(format!("{}", value), "[true, null]"); + } + + #[test] + fn test_display_for_frontmatter() { + let mut fm = Frontmatter::new(); + let _ = fm.insert( + "key1".to_string(), + Value::String("value1".to_string()), + ); + let _ = fm.insert("key2".to_string(), Value::Number(42.0)); + + let output = format!("{}", fm); + + // Check that the output contains both key-value pairs without enforcing the order + assert!(output.contains("\"key1\": \"value1\"")); + assert!(output.contains("\"key2\": 42")); + } +}