use crate::error::{Error, Result};
use crate::models::Frontmatter;
use regex::Regex;
use std::path::Path;
pub fn parse_markdown(content: &str) -> Result<(Frontmatter, String)> {
let re = Regex::new(r"(?s)^---\n(.*?)\n---\n(.*)$")?;
if let Some(captures) = re.captures(content) {
let yaml_str = captures.get(1).unwrap().as_str();
let body = captures.get(2).unwrap().as_str();
let frontmatter: Frontmatter = serde_yaml::from_str(yaml_str)?;
frontmatter.validate()?;
Ok((frontmatter, body.to_string()))
} else {
let frontmatter = Frontmatter::default();
Ok((frontmatter, content.to_string()))
}
}
pub async fn parse_markdown_file<P: AsRef<Path>>(path: P) -> Result<(Frontmatter, String)> {
let path = path.as_ref();
let content = tokio::fs::read_to_string(path).await?;
parse_markdown(&content).map_err(|e| match e {
Error::Yaml(_) => Error::markdown_parse(path, "Failed to parse YAML frontmatter"),
Error::Regex(_) => Error::markdown_parse(path, "Failed to parse markdown structure"),
other => other,
})
}
pub fn format_markdown(frontmatter: &Frontmatter, body: &str) -> Result<String> {
let yaml = serde_yaml::to_string(frontmatter)?;
Ok(format!("---\n{yaml}---\n{body}"))
}
pub async fn write_markdown_file<P: AsRef<Path>>(
path: P,
frontmatter: &Frontmatter,
body: &str,
) -> Result<()> {
let content = format_markdown(frontmatter, body)?;
let path = path.as_ref();
tokio::fs::write(path, content).await.map_err(Error::from)
}
pub async fn update_frontmatter<P: AsRef<Path>, F>(path: P, updater: F) -> Result<()>
where
F: FnOnce(&mut Frontmatter) -> Result<()>,
{
let path = path.as_ref();
let (mut frontmatter, body) = parse_markdown_file(path).await?;
updater(&mut frontmatter)?;
write_markdown_file(path, &frontmatter, &body).await
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::Frontmatter;
use tempfile::TempDir;
#[test]
fn test_parse_markdown_with_frontmatter() {
let content = r#"---
title: "Test Article"
published: "draft"
author: "John Doe"
---
# Hello World
This is the content of the article.
"#;
let (frontmatter, body) = parse_markdown(content).unwrap();
assert_eq!(frontmatter.title, Some("Test Article".to_string()));
assert_eq!(frontmatter.published, Some("draft".to_string()));
assert_eq!(
body,
"# Hello World\n\nThis is the content of the article.\n"
);
if let serde_yaml::Value::Mapping(map) = &frontmatter.other {
assert!(map.contains_key(serde_yaml::Value::String("author".to_string())));
} else {
panic!("Expected mapping for other fields");
}
}
#[test]
fn test_parse_markdown_without_frontmatter() {
let content = "# Just a Title\n\nSome content without frontmatter.";
let (frontmatter, body) = parse_markdown(content).unwrap();
assert_eq!(frontmatter.title, None);
assert_eq!(frontmatter.published, None);
assert_eq!(body, content);
}
#[test]
fn test_parse_markdown_empty_frontmatter() {
let content = r#"---
---
# Content Only
Just the body.
"#;
let (frontmatter, body) = parse_markdown(content).unwrap();
assert_eq!(frontmatter.title, None);
assert_eq!(frontmatter.published, None);
assert_eq!(body, "# Content Only\n\nJust the body.\n");
}
#[test]
fn test_parse_markdown_minimal_frontmatter() {
let content = r#"---
published: "true"
---
Content here.
"#;
let (frontmatter, body) = parse_markdown(content).unwrap();
assert_eq!(frontmatter.title, None);
assert_eq!(frontmatter.published, Some("true".to_string()));
assert_eq!(body, "Content here.\n");
}
#[test]
fn test_parse_markdown_multiline_content() {
let content = r#"---
title: "Multi-line Test"
---
# First Line
Second line
Third line with **markdown**.
```rust
fn test() {
println!("Hello");
}
```
"#;
let (frontmatter, body) = parse_markdown(content).unwrap();
assert_eq!(frontmatter.title, Some("Multi-line Test".to_string()));
assert!(body.contains("First Line"));
assert!(body.contains("```rust"));
assert!(body.contains("println!"));
}
#[test]
fn test_format_markdown_basic() {
let mut frontmatter = Frontmatter::default();
frontmatter.set_title("Test Title");
frontmatter.set_published("draft");
let body = "# Content\n\nSome text.";
let result = format_markdown(&frontmatter, body).unwrap();
assert!(result.starts_with("---\n"));
assert!(result.contains("title: Test Title"));
assert!(result.contains("published: draft"));
assert!(result.contains("---\n# Content"));
assert!(result.ends_with("Some text."));
}
#[test]
fn test_format_markdown_empty_frontmatter() {
let frontmatter = Frontmatter::default();
let body = "Just content.";
let result = format_markdown(&frontmatter, body).unwrap();
assert!(result.starts_with("---\n"));
assert!(result.ends_with("---\nJust content."));
assert!(result.contains("null\n") || result.contains("{}\n"));
}
#[test]
fn test_format_markdown_preserves_body_formatting() {
let mut frontmatter = Frontmatter::default();
frontmatter.set_title("Formatting Test");
let body = r#"# Title
## Subtitle
- List item 1
- List item 2
```rust
fn main() {
println!("Hello");
}
```
End of content."#;
let result = format_markdown(&frontmatter, body).unwrap();
assert!(result.contains("# Title"));
assert!(result.contains("## Subtitle"));
assert!(result.contains("- List item 1"));
assert!(result.contains("```rust"));
assert!(result.contains("fn main()"));
assert!(result.ends_with("End of content."));
}
#[test]
fn test_roundtrip_parse_and_format() {
let original_content = r#"---
title: "Roundtrip Test"
published: "draft"
author: "Test Author"
tags:
- rust
- testing
---
# Test Article
This is a test article with various content.
## Section
More content here.
"#;
let (frontmatter, body) = parse_markdown(original_content).unwrap();
let formatted = format_markdown(&frontmatter, &body).unwrap();
let (parsed_frontmatter, parsed_body) = parse_markdown(&formatted).unwrap();
assert_eq!(frontmatter.title, parsed_frontmatter.title);
assert_eq!(frontmatter.published, parsed_frontmatter.published);
assert_eq!(body, parsed_body);
}
#[test]
fn test_parse_markdown_invalid_yaml() {
let content = r#"---
title: "Test"
invalid: [unclosed bracket
---
Content
"#;
let result = parse_markdown(content);
assert!(result.is_err());
let error_message = result.unwrap_err().to_string();
assert!(error_message.contains("YAML error"));
}
#[test]
fn test_frontmatter_with_cover() {
let content = r#"---
title: "Article with Cover"
published: "draft"
cover: "my-cover.png"
---
# Article Content
"#;
let (frontmatter, body) = parse_markdown(content).unwrap();
assert_eq!(frontmatter.title, Some("Article with Cover".to_string()));
assert_eq!(frontmatter.published, Some("draft".to_string()));
assert_eq!(frontmatter.cover, Some("my-cover.png".to_string()));
assert!(body.contains("Article Content"));
}
#[test]
fn test_frontmatter_without_cover() {
let content = r#"---
title: "Article without Cover"
published: "draft"
---
# Article Content
"#;
let (frontmatter, _) = parse_markdown(content).unwrap();
assert_eq!(frontmatter.title, Some("Article without Cover".to_string()));
assert_eq!(frontmatter.published, Some("draft".to_string()));
assert_eq!(frontmatter.cover, None);
}
#[test]
fn test_format_markdown_with_cover() {
let mut frontmatter = Frontmatter::default();
frontmatter.set_title("Test Article");
frontmatter.set_published("draft");
frontmatter.set_cover("test-cover.png");
let body = "# Test Content";
let result = format_markdown(&frontmatter, body).unwrap();
assert!(result.contains("title: Test Article"));
assert!(result.contains("published: draft"));
assert!(result.contains("cover: test-cover.png"));
assert!(result.contains("# Test Content"));
}
#[tokio::test]
async fn test_file_operations() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.md");
let mut frontmatter = Frontmatter::default();
frontmatter.set_title("File Test");
frontmatter.set_published("draft");
let body = "# Test Content\n\nThis is a test.";
write_markdown_file(&file_path, &frontmatter, body)
.await
.unwrap();
let (read_frontmatter, read_body) = parse_markdown_file(&file_path).await.unwrap();
assert_eq!(frontmatter.title, read_frontmatter.title);
assert_eq!(frontmatter.published, read_frontmatter.published);
assert_eq!(body, read_body);
}
#[tokio::test]
async fn test_update_frontmatter() {
let temp_dir = TempDir::new().unwrap();
let file_path = temp_dir.path().join("test.md");
let mut frontmatter = Frontmatter::default();
frontmatter.set_title("Original Title");
let body = "# Content";
write_markdown_file(&file_path, &frontmatter, body)
.await
.unwrap();
update_frontmatter(&file_path, |fm| {
fm.set_title("Updated Title");
fm.set_published("draft");
Ok(())
})
.await
.unwrap();
let (updated_frontmatter, _) = parse_markdown_file(&file_path).await.unwrap();
assert_eq!(updated_frontmatter.title, Some("Updated Title".to_string()));
assert_eq!(updated_frontmatter.published, Some("draft".to_string()));
}
}