use crate::error::{BlocksError, Result};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum BlockType {
Text,
Header { level: u8 },
List { list_type: ListType },
Code { language: Option<String> },
Quote,
Link { url: String, title: Option<String> },
Image {
url: String,
alt: String,
caption: Option<String>,
},
Table {
headers: Vec<String>,
rows: Vec<Vec<String>>,
has_header: bool,
},
Divider,
Embed {
embed_type: EmbedType,
url: String,
width: Option<u32>,
height: Option<u32>,
},
Button {
text: String,
url: String,
style: ButtonStyle,
},
Callout {
callout_type: CalloutType,
title: Option<String>,
},
Columns {
column_count: u8,
content: Vec<Vec<String>>,
},
Details { summary: String, is_open: bool },
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ListType {
Ordered,
Unordered,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum EmbedType {
YouTube,
Vimeo,
Iframe,
Audio,
Video,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum ButtonStyle {
Primary,
Secondary,
Danger,
Success,
Warning,
Info,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub enum CalloutType {
Info,
Warning,
Error,
Success,
Note,
Tip,
}
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct Block {
pub id: Uuid,
pub block_type: BlockType,
pub content: String,
pub metadata: std::collections::HashMap<String, String>,
pub created_at: u64,
pub updated_at: u64,
}
impl Block {
pub fn new(block_type: BlockType, content: String) -> Self {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
Self {
id: Uuid::new_v4(),
block_type,
content,
metadata: std::collections::HashMap::new(),
created_at: now,
updated_at: now,
}
}
pub fn with_id(id: Uuid, block_type: BlockType, content: String) -> Self {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
Self {
id,
block_type,
content,
metadata: std::collections::HashMap::new(),
created_at: now,
updated_at: now,
}
}
pub fn new_with_metadata(
block_type: BlockType,
content: String,
metadata: std::collections::HashMap<String, String>,
) -> Self {
let now = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
Self {
id: Uuid::new_v4(),
block_type,
content,
metadata,
created_at: now,
updated_at: now,
}
}
pub fn update_content(&mut self, content: String) {
self.content = content;
self.update_timestamp();
}
pub fn add_metadata(&mut self, key: String, value: String) {
self.metadata.insert(key, value);
self.update_timestamp();
}
pub fn get_metadata(&self, key: &str) -> Option<&String> {
self.metadata.get(key)
}
pub fn validate(&self) -> Result<()> {
match &self.block_type {
BlockType::Header { level } => {
if *level < 1 || *level > 6 {
return Err(BlocksError::InvalidHeaderLevel { level: *level });
}
if self.content.trim().is_empty() {
return Err(BlocksError::EmptyContent {
block_type: "Header".to_string(),
});
}
}
BlockType::Link { url, .. } => {
if url.trim().is_empty() {
return Err(BlocksError::InvalidUrl { url: url.clone() });
}
if !url.starts_with("http://")
&& !url.starts_with("https://")
&& !url.starts_with("/")
{
return Err(BlocksError::InvalidUrl { url: url.clone() });
}
}
BlockType::Image { url, alt, .. } => {
if url.trim().is_empty() {
return Err(BlocksError::InvalidImage {
reason: "URL cannot be empty".to_string(),
});
}
if alt.trim().is_empty() {
return Err(BlocksError::InvalidImage {
reason: "Alt text cannot be empty".to_string(),
});
}
}
BlockType::Table { headers, rows, .. } => {
if headers.is_empty() && !rows.is_empty() {
return Err(BlocksError::InvalidTable {
reason: "Table must have headers or be empty".to_string(),
});
}
if !headers.is_empty() {
for (i, row) in rows.iter().enumerate() {
if row.len() != headers.len() {
return Err(BlocksError::InvalidTable {
reason: format!(
"Row {} has {} columns but headers have {}",
i,
row.len(),
headers.len()
),
});
}
}
}
}
BlockType::Embed { url, .. } => {
if url.trim().is_empty() {
return Err(BlocksError::InvalidUrl { url: url.clone() });
}
}
BlockType::Button { url, text, .. } => {
if url.trim().is_empty() {
return Err(BlocksError::InvalidUrl { url: url.clone() });
}
if text.trim().is_empty() {
return Err(BlocksError::EmptyContent {
block_type: "Button".to_string(),
});
}
}
BlockType::Columns {
column_count,
content,
} => {
if *column_count == 0 || *column_count > 12 {
return Err(BlocksError::InvalidColumns {
reason: "Column count must be between 1 and 12".to_string(),
});
}
if content.len() != *column_count as usize {
return Err(BlocksError::InvalidColumns {
reason: format!(
"Expected {} columns but got {}",
column_count,
content.len()
),
});
}
}
BlockType::Details { summary, .. } => {
if summary.trim().is_empty() {
return Err(BlocksError::EmptyContent {
block_type: "Details".to_string(),
});
}
}
BlockType::Code { .. } => {
}
BlockType::Text
| BlockType::Quote
| BlockType::List { .. }
| BlockType::Divider
| BlockType::Callout { .. } => {
}
}
Ok(())
}
pub fn update_timestamp(&mut self) {
self.updated_at = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.unwrap()
.as_secs();
}
pub fn type_name(&self) -> &'static str {
match self.block_type {
BlockType::Text => "text",
BlockType::Header { .. } => "header",
BlockType::List { .. } => "list",
BlockType::Code { .. } => "code",
BlockType::Quote => "quote",
BlockType::Link { .. } => "link",
BlockType::Image { .. } => "image",
BlockType::Table { .. } => "table",
BlockType::Divider => "divider",
BlockType::Embed { .. } => "embed",
BlockType::Button { .. } => "button",
BlockType::Callout { .. } => "callout",
BlockType::Columns { .. } => "columns",
BlockType::Details { .. } => "details",
}
}
}
impl std::fmt::Display for Block {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
write!(f, "{}: {}", self.type_name(), self.content)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_block_creation() {
let block = Block::new(BlockType::Text, "Hello World".to_string());
assert_eq!(block.content, "Hello World");
assert_eq!(block.block_type, BlockType::Text);
assert!(block.metadata.is_empty());
}
#[test]
fn test_block_with_id() {
let id = Uuid::new_v4();
let block = Block::with_id(id, BlockType::Text, "Test".to_string());
assert_eq!(block.id, id);
}
#[test]
fn test_update_content() {
let mut block = Block::new(BlockType::Text, "Original".to_string());
let original_updated = block.updated_at;
std::thread::sleep(std::time::Duration::from_millis(10));
block.update_content("Updated".to_string());
assert_eq!(block.content, "Updated");
assert!(block.updated_at >= original_updated);
}
#[test]
fn test_metadata() {
let mut block = Block::new(BlockType::Text, "Test".to_string());
block.add_metadata("author".to_string(), "John Doe".to_string());
assert_eq!(block.get_metadata("author"), Some(&"John Doe".to_string()));
assert_eq!(block.get_metadata("nonexistent"), None);
}
#[test]
fn test_header_validation() {
let block = Block::new(BlockType::Header { level: 1 }, "Valid Header".to_string());
assert!(block.validate().is_ok());
let invalid_level = Block::new(BlockType::Header { level: 10 }, "Invalid".to_string());
assert!(invalid_level.validate().is_err());
let empty_header = Block::new(BlockType::Header { level: 1 }, "".to_string());
assert!(empty_header.validate().is_err());
}
#[test]
fn test_link_validation() {
let valid_link = Block::new(
BlockType::Link {
url: "https://example.com".to_string(),
title: None,
},
"Link text".to_string(),
);
assert!(valid_link.validate().is_ok());
let invalid_link = Block::new(
BlockType::Link {
url: "".to_string(),
title: None,
},
"Link text".to_string(),
);
assert!(invalid_link.validate().is_err());
}
#[test]
fn test_image_validation() {
let valid_image = Block::new(
BlockType::Image {
url: "https://example.com/image.jpg".to_string(),
alt: "Test image".to_string(),
caption: None,
},
"".to_string(),
);
assert!(valid_image.validate().is_ok());
let invalid_image = Block::new(
BlockType::Image {
url: "https://example.com/image.jpg".to_string(),
alt: "".to_string(),
caption: None,
},
"".to_string(),
);
assert!(invalid_image.validate().is_err());
}
#[test]
fn test_type_name() {
assert_eq!(
Block::new(BlockType::Text, "".to_string()).type_name(),
"text"
);
assert_eq!(
Block::new(BlockType::Header { level: 1 }, "".to_string()).type_name(),
"header"
);
assert_eq!(
Block::new(BlockType::Quote, "".to_string()).type_name(),
"quote"
);
}
#[test]
fn test_display() {
let block = Block::new(BlockType::Text, "Hello World".to_string());
assert_eq!(format!("{block}"), "text: Hello World");
}
#[test]
fn test_serialization() {
let block = Block::new(BlockType::Header { level: 2 }, "Test Header".to_string());
let json = serde_json::to_string(&block).unwrap();
let deserialized: Block = serde_json::from_str(&json).unwrap();
assert_eq!(block, deserialized);
}
}