#[cfg_attr(doc, aquamarine::aquamarine)]
use std::{fmt::Display, fs, io::BufReader, path::Path};
const TESTING_FLAG: &str = "TESTING";
pub(crate) mod bsky_post;
use bsky_post::{BskyPost, BskyPostState};
use bsky_sdk::{agent::config::Config as BskyConfig, BskyAgent};
use thiserror::Error;
#[non_exhaustive]
#[derive(Debug, Error)]
pub enum PostError {
#[error("No bluesky identifier provided")]
NoBlueskyIdentifier,
#[error("No bluesky password provided")]
NoBlueskyPassword,
#[error("bsky_sdk create_session error says: {0:?}")]
BlueskyLoginError(String),
#[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("bsky_sdk error says: {0:?}")]
BskySdk(#[from] bsky_sdk::Error),
#[error("frontmatter write-back error: {0:?}")]
FmWrite(#[from] crate::frontmatter_writeback::FmWriteError),
}
#[derive(Default, Debug, Clone)]
pub struct Post {
bsky_posts: Vec<BskyPost>,
id: String,
pwd: String,
}
impl Post {
pub fn new(id: &str, password: &str) -> Result<Self, PostError> {
if id.is_empty() {
return Err(PostError::NoBlueskyIdentifier);
};
if password.is_empty() {
return Err(PostError::NoBlueskyPassword);
};
Ok(Post {
id: id.to_string(),
pwd: password.to_string(),
..Default::default()
})
}
pub fn load<P>(&mut self, directory: P) -> Result<&mut Self, PostError>
where
P: AsRef<Path> + Display,
{
let files = fs::read_dir(&directory)?;
let mut bsky_posts = Vec::new();
for file in files {
let file = file?;
let file_name = file.file_name().into_string().unwrap();
if file_name.ends_with(".post") {
let file_path = file.path();
let post = fs::File::open(&file_path)?;
let reader = BufReader::new(post);
let post_file: bsky_post::PostFile = serde_json::from_reader(reader)?;
let bsky_post = if let Some(src) = post_file.source_path {
BskyPost::new_with_source(post_file.record, file_path, src)
} else {
BskyPost::new(post_file.record, file_path)
};
bsky_posts.push(bsky_post);
}
}
self.bsky_posts.extend(bsky_posts);
Ok(self)
}
pub async fn post_to_bluesky(&mut self) -> Result<&mut Self, PostError> {
let bsky_config = BskyConfig::default();
let agent = BskyAgent::builder().config(bsky_config).build().await?;
agent
.login(&self.id, &self.pwd)
.await
.map_err(|e| PostError::BlueskyLoginError(e.to_string()))?;
let preferences = agent.get_preferences(true).await?;
agent.configure_labelers_from_preferences(&preferences);
log::info!("Bluesky login successful!");
let testing = std::env::var(TESTING_FLAG).is_ok();
if testing {
log::info!("No posts will be made to bluesky as this is a test.");
}
for bsky_post in &mut self
.bsky_posts
.iter_mut()
.filter(|p| p.state() == &BskyPostState::Read)
{
if let Some(src) = bsky_post.source_path() {
if crate::frontmatter_writeback::read_bluesky_date_field(src, "published").is_some()
{
log::debug!("Skipping post — [bluesky].published already set in {src:?}");
continue;
}
}
log::debug!("Post: {}", bsky_post.post().text.clone());
if testing {
log::info!(
"Successfully posted `{}` to Bluesky",
bsky_post.file_path().to_string_lossy()
);
} else {
let result = agent.create_record(bsky_post.post().clone()).await;
let Ok(output) = result else {
log::warn!("Error posting to Bluesky: {}", result.err().unwrap());
continue;
};
log::debug!("Post validation: `{:?}`", output.validation_status.as_ref());
log::info!(
"Successfully posted `{}` to Bluesky",
bsky_post
.post()
.text
.split_terminator('\n')
.collect::<Vec<&str>>()[0],
);
bsky_post.set_state(BskyPostState::Posted);
};
}
Ok(self)
}
pub fn delete_posted_posts(&mut self) -> Result<&mut Self, PostError> {
for bsky_post in &mut self
.bsky_posts
.iter_mut()
.filter(|p| p.state() == &BskyPostState::Posted)
{
if let Some(src) = bsky_post.source_path() {
let today = crate::draft::today();
match crate::frontmatter_writeback::write_bluesky_date_field(
src,
"published",
today,
) {
Ok(()) => log::debug!(
"Wrote [bluesky].published = {} to `{}`",
today,
src.display()
),
Err(e) => log::warn!(
"Failed to write [bluesky].published to `{}`: {e}",
src.display()
),
}
}
log::debug!("Deleting related file: {:?}", bsky_post.file_path());
fs::remove_file(bsky_post.file_path())?;
log::info!(
"Successfully deleted `{}` bluesky post file",
bsky_post.file_path().to_string_lossy()
);
bsky_post.set_state(BskyPostState::Deleted);
}
Ok(self)
}
pub fn count_deleted(&self) -> usize {
self.bsky_posts
.iter()
.filter(|b| b.state() == &BskyPostState::Deleted)
.count()
}
}
#[cfg(test)]
mod tests {
use super::*;
use std::env;
use std::fs;
use std::io::Write;
use tempfile::TempDir;
fn create_test_post() -> Result<Post, PostError> {
Post::new("test.user.bsky.social", "test-password")
}
fn create_test_posts_directory() -> Result<TempDir, std::io::Error> {
let temp_dir = tempfile::tempdir()?;
let post_content = r#"{
"text": "Test post content",
"createdAt": "2024-01-01T00:00:00.000Z",
"langs": ["en"]
}"#;
let post_path = temp_dir.path().join("test1.post");
let mut post_file = fs::File::create(post_path)?;
post_file.write_all(post_content.as_bytes())?;
let post_content2 = r#"{
"text": "Another test post",
"createdAt": "2024-01-02T00:00:00.000Z",
"langs": ["en", "es"]
}"#;
let post_path2 = temp_dir.path().join("test2.post");
let mut post_file2 = fs::File::create(post_path2)?;
post_file2.write_all(post_content2.as_bytes())?;
let other_file = temp_dir.path().join("readme.txt");
let mut other = fs::File::create(other_file)?;
other.write_all(b"This is not a post file")?;
Ok(temp_dir)
}
fn create_invalid_post_file(temp_dir: &TempDir) -> Result<(), std::io::Error> {
let invalid_content = r#"{
"text": "Invalid JSON",
"createdAt": "2024-01-01T00:00:00.000Z"
// Missing closing brace and comma
"#;
let invalid_path = temp_dir.path().join("invalid.post");
let mut invalid_file = fs::File::create(invalid_path)?;
invalid_file.write_all(invalid_content.as_bytes())?;
Ok(())
}
#[test]
fn test_post_new_success() {
let result = Post::new("test.user", "password123");
assert!(result.is_ok());
let post = result.unwrap();
assert_eq!(post.id, "test.user");
assert_eq!(post.pwd, "password123");
assert_eq!(post.bsky_posts.len(), 0);
}
#[test]
fn test_post_new_empty_identifier() {
let result = Post::new("", "password");
assert!(matches!(result, Err(PostError::NoBlueskyIdentifier)));
}
#[test]
fn test_post_new_empty_password() {
let result = Post::new("user", "");
assert!(matches!(result, Err(PostError::NoBlueskyPassword)));
}
#[test]
fn test_post_new_both_empty() {
let result = Post::new("", "");
assert!(matches!(result, Err(PostError::NoBlueskyIdentifier)));
}
#[test]
fn test_post_new_whitespace_identifier() {
let result = Post::new(" ", "password");
assert!(result.is_ok());
}
#[test]
fn test_load_success() -> Result<(), Box<dyn std::error::Error>> {
let mut post = create_test_post()?;
let temp_dir = create_test_posts_directory()?;
let result = post.load(temp_dir.path().to_string_lossy().to_string());
assert!(result.is_ok());
assert_eq!(post.bsky_posts.len(), 2);
for bsky_post in &post.bsky_posts {
assert_eq!(bsky_post.state(), &BskyPostState::Read);
}
let post_texts: Vec<&str> = post
.bsky_posts
.iter()
.map(|p| p.post().text.as_str())
.collect();
assert!(post_texts.contains(&"Test post content"));
assert!(post_texts.contains(&"Another test post"));
Ok(())
}
#[test]
fn test_load_nonexistent_directory() {
let mut post = create_test_post().unwrap();
let result = post.load("/nonexistent/directory");
assert!(matches!(result, Err(PostError::Io(_))));
}
#[test]
fn test_load_invalid_json() -> Result<(), Box<dyn std::error::Error>> {
let mut post = create_test_post()?;
let temp_dir = create_test_posts_directory()?;
create_invalid_post_file(&temp_dir)?;
let result = post.load(temp_dir.path().to_string_lossy().to_string());
assert!(matches!(result, Err(PostError::SerdeJsonError(_))));
Ok(())
}
#[test]
fn test_load_empty_directory() -> Result<(), Box<dyn std::error::Error>> {
let mut post = create_test_post()?;
let temp_dir = tempfile::tempdir()?;
let result = post.load(temp_dir.path().to_string_lossy().to_string());
assert!(result.is_ok());
assert_eq!(post.bsky_posts.len(), 0);
Ok(())
}
#[test]
fn test_load_no_post_files() -> Result<(), Box<dyn std::error::Error>> {
let mut post = create_test_post()?;
let temp_dir = tempfile::tempdir()?;
let txt_file = temp_dir.path().join("readme.txt");
fs::write(txt_file, "Not a post file")?;
let json_file = temp_dir.path().join("config.json");
fs::write(json_file, "{\"config\": \"value\"}")?;
let result = post.load(temp_dir.path().to_string_lossy().to_string());
assert!(result.is_ok());
assert_eq!(post.bsky_posts.len(), 0);
Ok(())
}
#[test]
fn test_load_accumulative() -> Result<(), Box<dyn std::error::Error>> {
let mut post = create_test_post()?;
let temp_dir1 = create_test_posts_directory()?;
post.load(temp_dir1.path().to_string_lossy().to_string())?;
assert_eq!(post.bsky_posts.len(), 2);
let temp_dir2 = tempfile::tempdir()?;
let post_content = r#"{
"text": "Third post",
"createdAt": "2024-01-03T00:00:00.000Z",
"langs": ["fr"]
}"#;
let post_path = temp_dir2.path().join("test3.post");
fs::write(post_path, post_content)?;
post.load(temp_dir2.path().to_string_lossy().to_string())?;
assert_eq!(post.bsky_posts.len(), 3);
Ok(())
}
#[tokio::test]
async fn test_post_to_bluesky_testing_mode() -> Result<(), Box<dyn std::error::Error>> {
unsafe { env::set_var("TESTING", "1") };
let mut post = create_test_post()?;
let temp_dir = create_test_posts_directory()?;
post.load(temp_dir.path().to_string_lossy().to_string())?;
let result = post.post_to_bluesky().await;
unsafe { env::remove_var("TESTING") };
match result {
Err(PostError::BlueskyLoginError(_)) => {
}
Ok(_) => {
}
Err(e) => {
println!("Unexpected error: {e:?}");
}
}
Ok(())
}
#[test]
fn test_delete_posted_posts_no_posted() -> Result<(), Box<dyn std::error::Error>> {
let mut post = create_test_post()?;
let temp_dir = create_test_posts_directory()?;
post.load(temp_dir.path().to_string_lossy().to_string())?;
let result = post.delete_posted_posts();
assert!(result.is_ok());
assert!(temp_dir.path().join("test1.post").exists());
assert!(temp_dir.path().join("test2.post").exists());
Ok(())
}
#[test]
fn test_delete_posted_posts_with_posted() -> Result<(), Box<dyn std::error::Error>> {
let mut post = create_test_post()?;
let temp_dir = create_test_posts_directory()?;
post.load(temp_dir.path().to_string_lossy().to_string())?;
let first_post_file_path = post.bsky_posts[0].file_path().clone();
post.bsky_posts[0].set_state(BskyPostState::Posted);
let result = post.delete_posted_posts();
assert!(result.is_ok());
assert!(!first_post_file_path.exists());
let remaining_files: Vec<_> = ["test1.post", "test2.post"]
.iter()
.filter(|filename| temp_dir.path().join(filename).exists())
.collect();
assert_eq!(remaining_files.len(), 1);
assert_eq!(post.bsky_posts[0].state(), &BskyPostState::Deleted);
assert_eq!(post.bsky_posts[1].state(), &BskyPostState::Read);
Ok(())
}
#[test]
fn test_delete_posted_posts_file_not_found() -> Result<(), Box<dyn std::error::Error>> {
let mut post = create_test_post()?;
let temp_dir = create_test_posts_directory()?;
post.load(temp_dir.path().to_string_lossy().to_string())?;
let test1_post_index = post
.bsky_posts
.iter()
.position(|p| p.file_path().file_name().unwrap().to_str().unwrap() == "test1.post")
.expect("Should find test1.post");
fs::remove_file(post.bsky_posts[test1_post_index].file_path())?;
post.bsky_posts[test1_post_index].set_state(BskyPostState::Posted);
let result = post.delete_posted_posts();
assert!(matches!(result, Err(PostError::Io(_))));
Ok(())
}
#[test]
fn test_count_deleted_none() {
let post = create_test_post().unwrap();
assert_eq!(post.count_deleted(), 0);
}
#[test]
fn test_count_deleted_with_deleted_posts() -> Result<(), Box<dyn std::error::Error>> {
let mut post = create_test_post()?;
let temp_dir = create_test_posts_directory()?;
post.load(temp_dir.path().to_string_lossy().to_string())?;
post.bsky_posts[0].set_state(BskyPostState::Deleted);
post.bsky_posts[1].set_state(BskyPostState::Posted);
assert_eq!(post.count_deleted(), 1);
Ok(())
}
#[test]
fn test_error_display() {
let errors = vec![
PostError::NoBlueskyIdentifier,
PostError::NoBlueskyPassword,
PostError::BlueskyLoginError("Login failed".to_string()),
PostError::Io(std::io::Error::new(
std::io::ErrorKind::NotFound,
"File not found",
)),
];
for error in errors {
let error_string = format!("{error}");
assert!(!error_string.is_empty());
}
}
#[test]
fn test_post_clone() -> Result<(), Box<dyn std::error::Error>> {
let mut original = create_test_post()?;
let temp_dir = create_test_posts_directory()?;
original.load(temp_dir.path().to_string_lossy().to_string())?;
let cloned = original.clone();
assert_eq!(original.id, cloned.id);
assert_eq!(original.pwd, cloned.pwd);
assert_eq!(original.bsky_posts.len(), cloned.bsky_posts.len());
Ok(())
}
#[test]
fn test_post_debug() {
let post = create_test_post().unwrap();
let debug_str = format!("{post:?}");
assert!(debug_str.contains("Post"));
}
#[test]
fn test_post_default() {
let post = Post::default();
assert!(post.id.is_empty());
assert!(post.pwd.is_empty());
assert_eq!(post.bsky_posts.len(), 0);
}
#[test]
fn test_fluent_interface() -> Result<(), Box<dyn std::error::Error>> {
let mut post = create_test_post()?;
let temp_dir1 = create_test_posts_directory()?;
let temp_dir2 = tempfile::tempdir()?;
let post_content = r#"{
"text": "Fluent test",
"createdAt": "2024-01-04T00:00:00.000Z"
}"#;
fs::write(temp_dir2.path().join("fluent.post"), post_content)?;
let result = post
.load(temp_dir1.path().to_string_lossy().to_string())?
.load(temp_dir2.path().to_string_lossy().to_string());
assert!(result.is_ok());
assert_eq!(post.bsky_posts.len(), 3);
Ok(())
}
fn create_post_file_with_source_path(
dir: &std::path::Path,
filename: &str,
source_path: &str,
) -> std::path::PathBuf {
let content = format!(
r#"{{"text":"Test","createdAt":"2026-04-03T00:00:00.000000Z","langs":null,"reply":null,"embed":null,"facets":null,"tags":null,"entities":null,"labels":null,"source_path":"{source_path}"}}"#
);
let path = dir.join(filename);
fs::write(&path, content).unwrap();
path
}
fn create_md_with_bluesky_created(dir: &std::path::Path, filename: &str) -> std::path::PathBuf {
let content = "+++\ntitle = \"Test\"\ndescription = \"test\"\ndate = 2026-04-03\n\n[bluesky]\ndescription = \"bsky desc\"\ncreated = 2026-04-03\n+++\n\nBody.";
let path = dir.join(filename);
fs::write(&path, content).unwrap();
path
}
#[test]
fn test_load_reads_source_path_from_post_file() -> Result<(), Box<dyn std::error::Error>> {
let mut post = create_test_post()?;
let temp_dir = tempfile::tempdir()?;
let md_path = temp_dir.path().join("my-post.md");
create_post_file_with_source_path(temp_dir.path(), "abc.post", &md_path.to_string_lossy());
post.load(temp_dir.path().to_string_lossy().to_string())?;
assert_eq!(post.bsky_posts.len(), 1);
assert_eq!(
post.bsky_posts[0].source_path(),
Some(&md_path),
"source_path should be loaded from .post file"
);
Ok(())
}
#[test]
fn test_delete_posted_posts_writes_published_to_frontmatter() {
let mut post = create_test_post().unwrap();
let temp_dir = tempfile::tempdir().unwrap();
let md_path = create_md_with_bluesky_created(temp_dir.path(), "my-post.md");
create_post_file_with_source_path(temp_dir.path(), "abc.post", &md_path.to_string_lossy());
post.load(temp_dir.path().to_string_lossy().to_string())
.unwrap();
assert_eq!(post.bsky_posts.len(), 1);
post.bsky_posts[0].set_state(BskyPostState::Posted);
post.delete_posted_posts().unwrap();
let md_content = fs::read_to_string(&md_path).unwrap();
assert!(
md_content.contains("published ="),
".md file should have published date after delete_posted_posts: {md_content}"
);
}
#[test]
fn test_large_number_of_posts() -> Result<(), Box<dyn std::error::Error>> {
let mut post = create_test_post()?;
let temp_dir = tempfile::tempdir()?;
for i in 0..100 {
let post_content = format!(
r#"{{
"text": "Post number {i}",
"createdAt": "2024-01-01T00:00:00.000Z"
}}"#
);
let post_path = temp_dir.path().join(format!("post{i}.post"));
fs::write(post_path, post_content)?;
}
let result = post.load(temp_dir.path().to_string_lossy().to_string());
assert!(result.is_ok());
assert_eq!(post.bsky_posts.len(), 100);
for i in 0..30 {
post.bsky_posts[i].set_state(BskyPostState::Posted);
}
for i in 30..50 {
post.bsky_posts[i].set_state(BskyPostState::Deleted);
}
assert_eq!(post.count_deleted(), 20);
Ok(())
}
}