use std::{
fs::File,
path::{Path, PathBuf},
};
use chrono::{TimeZone, Utc};
pub(crate) mod front_matter;
use bsky_sdk::{
api::{app::bsky::feed::post::RecordData, types::string::Datetime as BskyDatetime},
rich_text::RichText,
};
use link_bridge::Redirector;
use thiserror::Error;
use toml::value::Datetime;
use unicode_segmentation::UnicodeSegmentation;
use url::Url;
#[non_exhaustive]
#[derive(Error, Debug)]
pub(super) enum BlogPostError {
#[error("bluesky post for `{0}` contains too many characters: {1}")]
PostTooManyCharacters(String, usize),
#[error("bluesky post for `{0}` contains too many graphemes: {1}")]
PostTooManyGraphemes(String, usize),
#[error("bluesky post has not been constructed")]
BlueSkyPostNotConstructed,
#[error("post basename is not set")]
PostBasenameNotSet,
#[error("bsky_sdk error says: {0:?}")]
BskySdk(#[from] bsky_sdk::Error),
#[error("link-bridge error says: {0:?}")]
RedirectorError(#[from] link_bridge::RedirectorError),
#[error("io error says: {0:?}")]
Io(#[from] std::io::Error),
#[error("serde_json create_session error says: {0:?}")]
SerdeJsonError(#[from] serde_json::error::Error),
#[error("processing of draft posts is not allowed")]
DraftNotAllowed,
#[error("Post is older than allowed by minimum date setting {0}")]
PostTooOld(Datetime),
#[error("toml deserialization error says: {0:?}")]
Toml(#[from] toml::de::Error),
#[error("url error says: {0:?}")]
UrlParse(#[from] url::ParseError),
#[error("frontmatter write-back error: {0:?}")]
FmWrite(#[from] crate::frontmatter_writeback::FmWriteError),
}
#[derive(Debug, Clone)]
pub(super) struct BlogPost {
path: PathBuf,
frontmatter: front_matter::FrontMatter,
post_link: Url,
redirector: Redirector,
post_short_link: Option<Url>,
bluesky_count: u8,
}
impl BlogPost {
pub fn title(&self) -> &str {
self.frontmatter.title()
}
#[cfg(test)]
pub fn bluesky_count(&self) -> u8 {
self.bluesky_count
}
}
impl BlogPost {
pub fn new(
blog_path: &PathBuf,
min_date: Datetime,
allow_draft: bool,
base_url: &Url,
www_src_root: &Path,
) -> Result<BlogPost, BlogPostError> {
let blog_file = www_src_root.join(blog_path);
let frontmatter = match front_matter::FrontMatter::read(&blog_file, min_date, allow_draft) {
Ok(fm) => fm,
Err(e) => match e {
front_matter::FrontMatterError::DraftNotAllowed => {
return Err(BlogPostError::DraftNotAllowed)
}
front_matter::FrontMatterError::PostTooOld(md) => {
return Err(BlogPostError::PostTooOld(md))
}
front_matter::FrontMatterError::Io(e) => return Err(BlogPostError::Io(e)),
front_matter::FrontMatterError::Toml(e) => return Err(BlogPostError::Toml(e)),
},
};
let mut post_link = blog_path.clone();
post_link.set_extension("");
log::trace!("Post link with extension stripped: `{post_link:?}`");
let post_link = post_link.as_path().to_string_lossy().to_string();
log::trace!("Post link as string: `{post_link}`");
let post_link = post_link
.trim_start_matches(&www_src_root.to_string_lossy().to_string())
.trim_start_matches('/')
.trim_start_matches("content");
log::trace!("Post link as trimmed: `{post_link}`");
let link = base_url.join(post_link)?;
let redirector = Redirector::new(post_link)?;
Ok(BlogPost {
path: blog_file.clone(),
frontmatter,
post_link: link,
redirector,
post_short_link: None,
bluesky_count: 0,
})
}
pub async fn get_bluesky_record(
&self,
created_at_override: Option<BskyDatetime>,
) -> Result<RecordData, BlogPostError> {
log::info!("Blog post: {self:#?}");
log::debug!("Building post text");
let post_text = self.build_post_text()?;
log::trace!("Post text: {post_text}");
let rt = RichText::new_with_detect_facets(&post_text).await?;
log::trace!("Rich text: {rt:#?}");
let record_data = RecordData {
created_at: created_at_override.unwrap_or_else(BskyDatetime::now),
embed: None,
entities: None,
facets: rt.facets,
labels: None,
langs: None,
reply: None,
tags: None,
text: rt.text,
};
log::trace!("{record_data:?}");
Ok(record_data)
}
fn build_post_text(&self) -> Result<String, BlogPostError> {
log::debug!(
"Building post text with post dir: `{}`",
self.path.display()
);
if log::log_enabled!(log::Level::Debug) {
self.log_post_details();
}
let post_text = format!(
"{}\n\n{} {}\n\n{}",
self.frontmatter.title(),
self.frontmatter.bluesky_description(),
self.frontmatter.bluesky_tags().join(" "),
if let Some(sl) = self.post_short_link.as_ref() {
sl
} else {
&self.post_link
}
);
if post_text.len() > 300 {
return Err(BlogPostError::PostTooManyCharacters(
self.frontmatter.title().to_string(),
post_text.len(),
));
}
if post_text.graphemes(true).count() > 300 {
return Err(BlogPostError::PostTooManyGraphemes(
self.frontmatter.title().to_string(),
post_text.graphemes(true).count(),
));
}
Ok(post_text)
}
pub fn write_referrer_file_to(
&mut self,
store_dir: &Path,
base_url: &Url,
root: &Path,
) -> Result<(), BlogPostError> {
log::debug!("Building link with `{base_url}` as root of url",);
self.redirector.set_path(store_dir);
let short_link = self.redirector.write_redirect()?;
log::debug!("redirect written and short link returned: {short_link}");
self.post_short_link = Some(
base_url.join(
short_link
.trim_start_matches(&root.to_string_lossy().to_string())
.trim_start_matches("/")
.trim_start_matches("static/"),
)?,
);
log::debug!("Saved short post link {:#?}", self.post_short_link);
Ok(())
}
pub async fn write_bluesky_record_to(&mut self, store_dir: &Path) -> Result<(), BlogPostError> {
log::trace!("Store path to write to bluesky record: `{store_dir:#?}`");
log::trace!(
"Path for basename contains a filename: {:#?}",
self.path.is_file()
);
if self.frontmatter.bluesky_created().is_some() {
log::debug!(
"Skipping draft — [bluesky].created already set in `{}`",
self.path.display()
);
return Ok(());
}
let Some(filename) = self.path.as_path().file_name() else {
return Err(BlogPostError::PostBasenameNotSet);
};
let filename = filename.to_str().unwrap();
let today = super::today();
crate::frontmatter_writeback::write_bluesky_date_field(&self.path, "created", today)?;
log::debug!(
"Wrote [bluesky].created = {} to `{}`",
today,
self.path.display()
);
let created_at_bsky = toml_date_to_bsky_datetime(today);
let bluesky_post = match self.get_bluesky_record(Some(created_at_bsky)).await {
Ok(p) => p,
Err(e) => {
log::warn!(
"failed to create bluesky record for `{}` because `{e}`",
self.title()
);
return Err(BlogPostError::BlueSkyPostNotConstructed);
}
};
let postname = format!(
"{}{}{}",
base62::encode(self.post_link.path().encode_utf16().sum::<u16>()),
base62::encode(filename.encode_utf16().sum::<u16>()),
base62::encode(
self.post_link
.path()
.trim_end_matches(filename)
.encode_utf16()
.sum::<u16>()
)
);
log::trace!("Bluesky post: {bluesky_post:#?}");
let post_file = format!("{postname}.post");
let post_file = store_dir.to_path_buf().join(post_file);
log::debug!("Write filename: `{filename}` as `{postname}`");
log::debug!("Write file: `{}`", post_file.display());
if !should_write_post_file(&post_file) {
log::debug!(
"Skipping write — post file already exists: `{}`",
post_file.display()
);
return Ok(());
}
let file = File::create(post_file)?;
let post_file_record = crate::post::bsky_post::PostFile {
record: bluesky_post,
source_path: Some(self.path.clone()),
};
serde_json::to_writer_pretty(&file, &post_file_record)?;
file.sync_all()?;
self.bluesky_count += 1;
Ok(())
}
fn log_post_details(&self) {
log::debug!("Post link: {}", self.post_link);
log::debug!(
"Length of post link: {} characters and {} graphemes",
self.post_link.as_str().len(),
self.post_link.as_str().graphemes(true).count()
);
log::debug!(
"Length of post short link: {} characters and {} graphemes",
self.post_short_link
.as_ref()
.map_or(0, |link| link.as_str().len()),
self.post_short_link
.as_ref()
.map_or(0, |link| link.as_str().graphemes(true).count())
);
self.frontmatter.log_post_details();
}
}
fn toml_date_to_bsky_datetime(dt: toml::value::Datetime) -> BskyDatetime {
if let Some(d) = dt.date {
if let Some(naive_date) =
chrono::NaiveDate::from_ymd_opt(d.year as i32, d.month as u32, d.day as u32)
{
let naive_dt = naive_date.and_time(chrono::NaiveTime::MIN);
let utc_dt = Utc.from_utc_datetime(&naive_dt).fixed_offset();
return BskyDatetime::new(utc_dt);
}
}
BskyDatetime::now()
}
fn should_write_post_file(path: &Path) -> bool {
!path.exists()
}
#[cfg(test)]
mod tests {
use std::{fs, str::FromStr};
use log::LevelFilter;
use toml::value::Datetime;
use super::*;
use crate::util::test_utils;
fn create_test_blog_file(path: &Path, filename: &str, content: &str) -> PathBuf {
if !path.exists() {
fs::create_dir_all(path).unwrap();
}
let file_path = path.join(filename);
fs::write(&file_path, content).expect("Failed to write test file");
file_path
}
fn create_test_frontmatter_content() -> String {
r#"+++
title = "Test Blog Post"
date = 2024-01-15
description = "A test blog post for unit testing"
draft = false
[taxonomies]
tags = ["rust", "testing"]
+++"#
.to_string()
}
#[test]
fn test_blog_post_new_success() {
let (temp_dir, base_url) = test_utils::setup_test_environment(LevelFilter::Trace);
let content = format!(
"{}\n\nThis is the blog post content.",
create_test_frontmatter_content()
);
log::debug!("Blog post content: {content:#?}");
let blog_path = temp_dir.path().join("content").join("blog");
let blog_file = create_test_blog_file(&blog_path, "test-post.md", &content);
log::debug!("Path to blog_file: `{blog_file:?}`");
let min_date = Datetime::from_str("2024-01-01T00:00:00Z").unwrap();
log::debug!("Minimum date: {min_date:#?}");
let result = BlogPost::new(&blog_file, min_date, false, &base_url, temp_dir.path());
log::debug!("BlogPost::new result: {result:?}");
assert!(result.is_ok());
let post = result.unwrap();
assert_eq!(post.title(), "Test Blog Post");
assert_eq!(post.bluesky_count(), 0);
assert_eq!(
post.post_link.as_str(),
"https://www.example.com/blog/test-post"
);
}
#[test]
fn test_blog_post_new_with_directory_path() {
let (temp_dir, base_url) = test_utils::setup_test_environment(LevelFilter::Debug);
let content_dir = temp_dir.path().join("content").join("posts");
fs::create_dir_all(&content_dir).unwrap();
let blog_path = PathBuf::from("content/posts/");
let min_date = Datetime::from_str("2024-01-01T00:00:00Z").unwrap();
let result = BlogPost::new(&blog_path, min_date, false, &base_url, temp_dir.path());
if let Ok(post) = result {
assert_eq!(
post.post_link.as_str(),
"https://www.example.com/blog/posts/"
);
}
}
#[test]
fn test_blog_post_draft_not_allowed_error() {
let (temp_dir, base_url) = test_utils::setup_test_environment(LevelFilter::Debug);
let mut content = create_test_frontmatter_content();
content = content.replace("draft = false", "draft = true");
let blog_path = temp_dir.path().join("content").join("blog");
let blog_file = create_test_blog_file(&blog_path, "draft-post.md", &content);
let min_date = Datetime::from_str("2024-01-01T00:00:00Z").unwrap();
let result = BlogPost::new(
&blog_file,
min_date,
false, &base_url,
temp_dir.path(),
);
log::debug!("Result of new post generation:/n{result:#?}");
assert!(matches!(result, Err(BlogPostError::DraftNotAllowed)));
}
#[test]
fn test_blog_post_too_old_error() {
let (temp_dir, base_url) = test_utils::setup_test_environment(LevelFilter::Debug);
let content = create_test_frontmatter_content();
let blog_path = temp_dir.path().join("content").join("blog");
let blog_file = create_test_blog_file(&blog_path, "old-post.md", &content);
let min_date = Datetime::from_str("2024-12-01T00:00:00Z").unwrap();
let result = BlogPost::new(&blog_file, min_date, false, &base_url, temp_dir.path());
assert!(matches!(result, Err(BlogPostError::PostTooOld(_))));
}
#[tokio::test]
async fn test_get_bluesky_record_success() {
let (temp_dir, base_url) = test_utils::setup_test_environment(LevelFilter::Debug);
let content = format!(
"{}\n\nThis is the blog post content.",
create_test_frontmatter_content()
);
let blog_path = temp_dir.path().join("content").join("blog");
let blog_file = create_test_blog_file(&blog_path, "test-post.md", &content);
let min_date = Datetime::from_str("2024-01-01T00:00:00Z").unwrap();
let post = BlogPost::new(&blog_file, min_date, false, &base_url, temp_dir.path()).unwrap();
let result = post.get_bluesky_record(None).await;
assert!(result.is_ok());
let record = result.unwrap();
assert!(record.text.contains("Test Blog Post"));
assert!(record
.text
.contains("https://www.example.com/blog/test-post"));
assert!(record.text.len() <= 300);
}
#[tokio::test]
async fn test_build_post_text_format() {
let (temp_dir, base_url) = test_utils::setup_test_environment(LevelFilter::Trace);
let content = format!(
"{}\n\nThis is the blog post content.",
create_test_frontmatter_content()
);
log::debug!("Content of blog file: `{content}`");
let blog_path = temp_dir.path().join("content").join("blog");
let blog_file = create_test_blog_file(&blog_path, "format-test.md", &content);
let min_date = Datetime::from_str("2024-01-01T00:00:00Z").unwrap();
let post = BlogPost::new(&blog_file, min_date, false, &base_url, temp_dir.path()).unwrap();
let post_text = post.build_post_text().unwrap();
log::debug!("Generated post text:\n{post_text:#?}");
let lines: Vec<&str> = post_text.split("\n\n").collect();
assert_eq!(lines.len(), 3);
assert_eq!(lines[0], "Test Blog Post");
assert!(lines[1].contains("A test blog post for unit testing"));
assert!(lines[1].contains("#Rust #Testing"));
assert_eq!(lines[2], "https://www.example.com/blog/format-test");
}
#[tokio::test]
async fn test_post_text_too_many_characters() {
let (temp_dir, base_url) = test_utils::setup_test_environment(LevelFilter::Trace);
let long_description = "A".repeat(250);
let long_content = format!(
r#"+++
title = "Very Long Title That Will Cause Character Limit Issues"
date = 2024-01-15T10:30:00Z
description = "{long_description}"
tags = ["verylongtag1", "verylongtag2", "verylongtag3", "verylongtag4"]
draft = false
+++
Long content here."#
);
let blog_path = temp_dir.path().join("content").join("blog");
let blog_file = create_test_blog_file(&blog_path, "long-post.md", &long_content);
let min_date = Datetime::from_str("2024-01-01T00:00:00Z").unwrap();
let post_res = BlogPost::new(&blog_file, min_date, false, &base_url, temp_dir.path());
log::debug!("Post result: {post_res:#?}");
let post = post_res.unwrap();
let result = post.build_post_text();
log::debug!("Result: {result:?}");
assert!(matches!(
result,
Err(BlogPostError::PostTooManyCharacters(_, _))
));
}
#[test]
fn test_write_referrer_file_to() {
let (temp_dir, base_url) = test_utils::setup_test_environment(LevelFilter::Debug);
let content = format!("{}\n\nContent here.", create_test_frontmatter_content());
let blog_path = temp_dir.path().join("content").join("blog");
let blog_file = create_test_blog_file(&blog_path, "referrer-test.md", &content);
let min_date = Datetime::from_str("2024-01-01T00:00:00Z").unwrap();
let store_dir = temp_dir.path().join("static");
fs::create_dir_all(&store_dir).unwrap();
let mut post =
BlogPost::new(&blog_file, min_date, false, &base_url, temp_dir.path()).unwrap();
assert!(post.post_short_link.is_none());
let result = post.write_referrer_file_to(&store_dir, &base_url, temp_dir.path());
assert!(result.is_ok());
assert!(post.post_short_link.is_some());
let short_link = post.post_short_link.unwrap();
log::debug!("The short link is: `{:?}`", short_link.as_str());
assert!(short_link.as_str().starts_with("https://www.example.com/"));
}
#[tokio::test]
async fn test_write_bluesky_record_to() {
let (temp_dir, base_url) = test_utils::setup_test_environment(LevelFilter::Debug);
let content = format!("{}\n\nContent here.", create_test_frontmatter_content());
let blog_path = temp_dir.path().join("content").join("blog");
let blog_file = create_test_blog_file(&blog_path, "bluesky-test.md", &content);
let min_date = Datetime::from_str("2024-01-01T00:00:00Z").unwrap();
let store_dir = temp_dir.path().join("posts");
fs::create_dir_all(&store_dir).unwrap();
let mut post =
BlogPost::new(&blog_file, min_date, false, &base_url, temp_dir.path()).unwrap();
assert_eq!(post.bluesky_count(), 0);
let result = post.write_bluesky_record_to(&store_dir).await;
assert!(result.is_ok());
assert_eq!(post.bluesky_count(), 1);
let post_files: Vec<_> = fs::read_dir(&store_dir)
.unwrap()
.filter_map(|entry| {
let entry = entry.ok()?;
let path = entry.path();
if path.extension()? == "post" {
Some(path)
} else {
None
}
})
.collect();
assert_eq!(post_files.len(), 1);
let json_content = fs::read_to_string(&post_files[0]).unwrap();
let record_data: serde_json::Value = serde_json::from_str(&json_content).unwrap();
log::debug!("Record data: `{record_data}`");
assert!(record_data.get("text").is_some());
assert!(record_data.get("createdAt").is_some());
}
#[tokio::test]
async fn test_write_bluesky_record_multiple_times() {
let (temp_dir, base_url) = test_utils::setup_test_environment(LevelFilter::Debug);
let content = format!("{}\n\nContent here.", create_test_frontmatter_content());
let blog_path = temp_dir.path().join("content").join("blog");
let blog_file = create_test_blog_file(&blog_path, "multi-test.md", &content);
let min_date = Datetime::from_str("2024-01-01T00:00:00Z").unwrap();
let store_dir = temp_dir.path().join("posts");
fs::create_dir_all(&store_dir).unwrap();
let mut post =
BlogPost::new(&blog_file, min_date, false, &base_url, temp_dir.path()).unwrap();
post.write_bluesky_record_to(&store_dir).await.unwrap();
assert_eq!(post.bluesky_count(), 1, "first write should succeed");
post.write_bluesky_record_to(&store_dir).await.unwrap();
assert_eq!(post.bluesky_count(), 1, "count must not change on repeat");
post.write_bluesky_record_to(&store_dir).await.unwrap();
assert_eq!(post.bluesky_count(), 1, "count must not change on repeat");
}
#[test]
fn test_error_display_formatting() {
let error = BlogPostError::PostTooManyCharacters("Test Post".to_string(), 350);
assert!(format!("{error}").contains("Test Post"));
assert!(format!("{error}").contains("350"));
let error = BlogPostError::PostTooManyGraphemes("Another Post".to_string(), 400);
assert!(format!("{error}").contains("Another Post"));
assert!(format!("{error}").contains("400"));
let date = Datetime::from_str("2024-01-01T00:00:00Z").unwrap();
let error = BlogPostError::PostTooOld(date);
assert!(format!("{error}").contains("2024-01-01T00:00:00Z"));
}
#[test]
fn test_blog_post_accessors() {
let (temp_dir, base_url) = test_utils::setup_test_environment(LevelFilter::Debug);
let content = format!("{}\n\nContent here.", create_test_frontmatter_content());
let blog_path = temp_dir.path().join("content").join("blog");
let blog_file = create_test_blog_file(&blog_path, "accessor-test.md", &content);
let min_date = Datetime::from_str("2024-01-01T00:00:00Z").unwrap();
let post = BlogPost::new(&blog_file, min_date, false, &base_url, temp_dir.path()).unwrap();
assert_eq!(post.title(), "Test Blog Post");
assert_eq!(post.bluesky_count(), 0);
}
#[test]
fn test_unicode_grapheme_handling() {
let (temp_dir, base_url) = test_utils::setup_test_environment(LevelFilter::Debug);
let unicode_content = r##"+++
title = "Test 👋 Post 🦀"
date = 2024-01-15T10:30:00Z
description = "Testing unicode: 🚀 émojis and àccénts"
tags = ["#test", "#unicode"]
draft = false
+++
Content with unicode characters."##
.to_string();
let blog_path = temp_dir.path().join("content").join("blog");
let blog_file = create_test_blog_file(&blog_path, "unicode-test.md", &unicode_content);
let min_date = Datetime::from_str("2024-01-01T00:00:00Z").unwrap();
let post = BlogPost::new(&blog_file, min_date, false, &base_url, temp_dir.path()).unwrap();
let post_text = post.build_post_text().unwrap();
assert!(post_text.len() <= 300);
assert!(post_text.graphemes(true).count() <= 300);
assert!(post_text.contains("👋"));
assert!(post_text.contains("🦀"));
assert!(post_text.contains("🚀"));
assert!(post_text.contains("émojis"));
assert!(post_text.contains("àccénts"));
}
fn create_test_frontmatter_with_bluesky_created() -> String {
r#"+++
title = "Test Blog Post"
date = 2024-01-15
description = "A test blog post for unit testing"
draft = false
[taxonomies]
tags = ["rust", "testing"]
[bluesky]
description = "My bsky description"
created = 2026-04-03
+++"#
.to_string()
}
#[tokio::test]
async fn test_write_bluesky_record_skips_when_created_in_frontmatter() {
let (temp_dir, base_url) = test_utils::setup_test_environment(LevelFilter::Debug);
let content = format!(
"{}\n\nThis is the blog post content.",
create_test_frontmatter_with_bluesky_created()
);
let blog_path = temp_dir.path().join("content").join("blog");
let blog_file = create_test_blog_file(&blog_path, "drafted-post.md", &content);
let min_date = Datetime::from_str("2024-01-01T00:00:00Z").unwrap();
let store_dir = temp_dir.path().join("posts");
fs::create_dir_all(&store_dir).unwrap();
let mut post =
BlogPost::new(&blog_file, min_date, false, &base_url, temp_dir.path()).unwrap();
post.write_bluesky_record_to(&store_dir).await.unwrap();
assert_eq!(
post.bluesky_count(),
0,
"bluesky_count must stay 0 when frontmatter.bluesky.created is already set"
);
let post_files: Vec<_> = fs::read_dir(&store_dir)
.unwrap()
.filter_map(|e| {
let p = e.ok()?.path();
if p.extension()? == "post" {
Some(p)
} else {
None
}
})
.collect();
assert_eq!(
post_files.len(),
0,
"no .post files should be created when already drafted"
);
}
#[tokio::test]
async fn test_write_bluesky_record_sets_created_in_frontmatter() {
let (temp_dir, base_url) = test_utils::setup_test_environment(LevelFilter::Debug);
let content = format!(
"{}\n\nThis is the blog post content.",
create_test_frontmatter_content()
);
let blog_path = temp_dir.path().join("content").join("blog");
let blog_file = create_test_blog_file(&blog_path, "new-post.md", &content);
let min_date = Datetime::from_str("2024-01-01T00:00:00Z").unwrap();
let store_dir = temp_dir.path().join("posts");
fs::create_dir_all(&store_dir).unwrap();
let mut post =
BlogPost::new(&blog_file, min_date, false, &base_url, temp_dir.path()).unwrap();
post.write_bluesky_record_to(&store_dir).await.unwrap();
assert_eq!(post.bluesky_count(), 1);
let md_content = fs::read_to_string(&blog_file).unwrap();
assert!(
md_content.contains("created ="),
".md file should have created date: {md_content}"
);
}
#[tokio::test]
async fn test_write_bluesky_record_created_at_matches_frontmatter_created() {
let (temp_dir, base_url) = test_utils::setup_test_environment(LevelFilter::Debug);
let content = format!(
"{}\n\nThis is the blog post content.",
create_test_frontmatter_content()
);
let blog_path = temp_dir.path().join("content").join("blog");
let blog_file = create_test_blog_file(&blog_path, "ts-post.md", &content);
let min_date = Datetime::from_str("2024-01-01T00:00:00Z").unwrap();
let store_dir = temp_dir.path().join("posts");
fs::create_dir_all(&store_dir).unwrap();
let mut post =
BlogPost::new(&blog_file, min_date, false, &base_url, temp_dir.path()).unwrap();
post.write_bluesky_record_to(&store_dir).await.unwrap();
let post_files: Vec<_> = fs::read_dir(&store_dir)
.unwrap()
.filter_map(|e| {
let p = e.ok()?.path();
if p.extension()? == "post" {
Some(p)
} else {
None
}
})
.collect();
assert_eq!(post_files.len(), 1);
let json_content = fs::read_to_string(&post_files[0]).unwrap();
let record: serde_json::Value = serde_json::from_str(&json_content).unwrap();
let created_at = record.get("createdAt").unwrap().as_str().unwrap();
let md_content = fs::read_to_string(&blog_file).unwrap();
let created_line = md_content
.lines()
.find(|l| l.trim_start().starts_with("created ="))
.expect("created line should exist in .md file");
let date_str = created_line.split('=').nth(1).unwrap().trim();
assert!(
created_at.starts_with(date_str),
"createdAt `{created_at}` should start with frontmatter date `{date_str}`"
);
}
#[test]
fn test_should_write_post_file_when_absent() {
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("nonexistent.post");
assert!(
should_write_post_file(&path),
"should return true when file does not exist"
);
}
#[test]
fn test_should_not_write_post_file_when_exists() {
let temp_dir = tempfile::tempdir().unwrap();
let path = temp_dir.path().join("existing.post");
fs::write(&path, b"{}").unwrap();
assert!(
!should_write_post_file(&path),
"should return false when file already exists"
);
}
#[tokio::test]
async fn test_write_bluesky_record_to_is_idempotent() {
let (temp_dir, base_url) = test_utils::setup_test_environment(LevelFilter::Debug);
let content = format!("{}\n\nContent here.", create_test_frontmatter_content());
let blog_path = temp_dir.path().join("content").join("blog");
let blog_file = create_test_blog_file(&blog_path, "idempotent-test.md", &content);
let min_date = Datetime::from_str("2024-01-01T00:00:00Z").unwrap();
let store_dir = temp_dir.path().join("posts");
fs::create_dir_all(&store_dir).unwrap();
let mut post =
BlogPost::new(&blog_file, min_date, false, &base_url, temp_dir.path()).unwrap();
post.write_bluesky_record_to(&store_dir).await.unwrap();
assert_eq!(
post.bluesky_count(),
1,
"first write should increment count"
);
post.write_bluesky_record_to(&store_dir).await.unwrap();
assert_eq!(
post.bluesky_count(),
1,
"second write must skip — file already exists"
);
post.write_bluesky_record_to(&store_dir).await.unwrap();
assert_eq!(
post.bluesky_count(),
1,
"third write must skip — file already exists"
);
let post_files: Vec<_> = fs::read_dir(&store_dir)
.unwrap()
.filter_map(|e| {
let p = e.ok()?.path();
if p.extension()? == "post" {
Some(p)
} else {
None
}
})
.collect();
assert_eq!(post_files.len(), 1, "exactly one .post file should exist");
}
}