use std::path::PathBuf;
use bsky_sdk::api::app::bsky::feed::post::RecordData;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
pub(crate) struct PostFile {
#[serde(flatten)]
pub(crate) record: RecordData,
#[serde(default, skip_serializing_if = "Option::is_none")]
pub(crate) source_path: Option<PathBuf>,
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub(crate) enum BskyPostState {
Read,
Posted,
Deleted,
}
#[derive(Debug, Clone)]
pub(crate) struct BskyPost {
post: RecordData,
file_path: PathBuf,
source_path: Option<PathBuf>,
state: BskyPostState,
}
impl BskyPost {
pub(crate) fn new(post: RecordData, file_path: PathBuf) -> Self {
BskyPost {
post,
file_path,
source_path: None,
state: BskyPostState::Read,
}
}
pub(crate) fn new_with_source(
post: RecordData,
file_path: PathBuf,
source_path: PathBuf,
) -> Self {
BskyPost {
post,
file_path,
source_path: Some(source_path),
state: BskyPostState::Read,
}
}
pub(crate) fn post(&self) -> &RecordData {
&self.post
}
pub(crate) fn file_path(&self) -> &PathBuf {
&self.file_path
}
pub(crate) fn state(&self) -> &BskyPostState {
&self.state
}
pub(crate) fn set_state(&mut self, new_state: BskyPostState) {
self.state = new_state
}
pub(crate) fn source_path(&self) -> Option<&PathBuf> {
self.source_path.as_ref()
}
}
#[cfg(test)]
mod tests {
use super::*;
use bsky_sdk::api::app::bsky::feed::post::RecordData;
use std::path::PathBuf;
fn create_test_record_data(text: &str) -> RecordData {
use bsky_sdk::api::types::string::Datetime;
use bsky_sdk::api::types::string::Language;
RecordData {
text: text.to_string(),
created_at: Datetime::now(),
langs: Some(vec![Language::new("en".to_string()).unwrap()]),
reply: None,
embed: None,
facets: None,
tags: None,
entities: None,
labels: None,
}
}
fn create_test_bsky_post() -> BskyPost {
let record = create_test_record_data("Test post content");
let path = PathBuf::from("/test/path/post.post");
BskyPost::new(record, path)
}
#[test]
fn test_bsky_post_state_equality() {
assert_eq!(BskyPostState::Read, BskyPostState::Read);
assert_eq!(BskyPostState::Posted, BskyPostState::Posted);
assert_eq!(BskyPostState::Deleted, BskyPostState::Deleted);
assert_ne!(BskyPostState::Read, BskyPostState::Posted);
assert_ne!(BskyPostState::Posted, BskyPostState::Deleted);
assert_ne!(BskyPostState::Read, BskyPostState::Deleted);
}
#[test]
fn test_bsky_post_state_debug() {
let read_debug = format!("{:?}", BskyPostState::Read);
let posted_debug = format!("{:?}", BskyPostState::Posted);
let deleted_debug = format!("{:?}", BskyPostState::Deleted);
assert_eq!(read_debug, "Read");
assert_eq!(posted_debug, "Posted");
assert_eq!(deleted_debug, "Deleted");
}
#[test]
fn test_bsky_post_state_clone() {
let original = BskyPostState::Posted;
let cloned = original.clone();
assert_eq!(original, cloned);
let _modified_clone = BskyPostState::Deleted;
assert_eq!(original, BskyPostState::Posted);
}
#[test]
fn test_bsky_post_state_filtering() {
let states = [
BskyPostState::Read,
BskyPostState::Posted,
BskyPostState::Read,
BskyPostState::Deleted,
BskyPostState::Posted,
];
let read_count = states.iter().filter(|&s| s == &BskyPostState::Read).count();
let posted_count = states
.iter()
.filter(|&s| s == &BskyPostState::Posted)
.count();
let deleted_count = states
.iter()
.filter(|&s| s == &BskyPostState::Deleted)
.count();
assert_eq!(read_count, 2);
assert_eq!(posted_count, 2);
assert_eq!(deleted_count, 1);
}
#[test]
fn test_bsky_post_new() {
let record = create_test_record_data("Test content");
let path = PathBuf::from("/test/example.post");
let bsky_post = BskyPost::new(record.clone(), path.clone());
assert_eq!(bsky_post.post().text, "Test content");
assert_eq!(bsky_post.file_path(), &path);
assert_eq!(bsky_post.state(), &BskyPostState::Read);
}
#[test]
fn test_bsky_post_new_initial_state() {
let bsky_post = create_test_bsky_post();
assert_eq!(bsky_post.state(), &BskyPostState::Read);
}
#[test]
fn test_bsky_post_new_with_different_content() {
let texts = vec![
"Short",
"A much longer post with more content",
"",
"🦀 Rust with emoji",
];
for text in texts {
let record = create_test_record_data(text);
let path = PathBuf::from(format!("/test/{}.post", text.len()));
let bsky_post = BskyPost::new(record, path);
assert_eq!(bsky_post.post().text, text);
assert_eq!(bsky_post.state(), &BskyPostState::Read);
}
}
#[test]
fn test_bsky_post_post_accessor() {
let original_text = "Test post for accessor";
let record = create_test_record_data(original_text);
let bsky_post = BskyPost::new(record, PathBuf::from("/test.post"));
let retrieved_post = bsky_post.post();
assert_eq!(retrieved_post.text, original_text);
let ptr1 = retrieved_post as *const RecordData;
let ptr2 = bsky_post.post() as *const RecordData;
assert_eq!(ptr1, ptr2);
}
#[test]
fn test_bsky_post_file_path_accessor() {
let original_path = PathBuf::from("/some/test/path.post");
let bsky_post = BskyPost::new(create_test_record_data("test"), original_path.clone());
let retrieved_path = bsky_post.file_path();
assert_eq!(retrieved_path, &original_path);
let ptr1 = retrieved_path as *const PathBuf;
let ptr2 = bsky_post.file_path() as *const PathBuf;
assert_eq!(ptr1, ptr2);
}
#[test]
fn test_bsky_post_state_accessor() {
let mut bsky_post = create_test_bsky_post();
assert_eq!(bsky_post.state(), &BskyPostState::Read);
bsky_post.set_state(BskyPostState::Posted);
assert_eq!(bsky_post.state(), &BskyPostState::Posted);
bsky_post.set_state(BskyPostState::Deleted);
assert_eq!(bsky_post.state(), &BskyPostState::Deleted);
}
#[test]
fn test_bsky_post_set_state() {
let mut bsky_post = create_test_bsky_post();
assert_eq!(bsky_post.state(), &BskyPostState::Read);
bsky_post.set_state(BskyPostState::Posted);
assert_eq!(bsky_post.state(), &BskyPostState::Posted);
bsky_post.set_state(BskyPostState::Deleted);
assert_eq!(bsky_post.state(), &BskyPostState::Deleted);
}
#[test]
fn test_bsky_post_set_state_non_linear_transitions() {
let mut bsky_post = create_test_bsky_post();
bsky_post.set_state(BskyPostState::Deleted);
assert_eq!(bsky_post.state(), &BskyPostState::Deleted);
bsky_post.set_state(BskyPostState::Read);
assert_eq!(bsky_post.state(), &BskyPostState::Read);
bsky_post.set_state(BskyPostState::Posted);
assert_eq!(bsky_post.state(), &BskyPostState::Posted);
}
#[test]
fn test_bsky_post_set_state_same_state() {
let mut bsky_post = create_test_bsky_post();
assert_eq!(bsky_post.state(), &BskyPostState::Read);
bsky_post.set_state(BskyPostState::Read);
assert_eq!(bsky_post.state(), &BskyPostState::Read);
bsky_post.set_state(BskyPostState::Posted);
bsky_post.set_state(BskyPostState::Posted);
assert_eq!(bsky_post.state(), &BskyPostState::Posted);
}
#[test]
fn test_bsky_post_clone() {
let original = create_test_bsky_post();
let cloned = original.clone();
assert_eq!(original.post().text, cloned.post().text);
assert_eq!(original.file_path(), cloned.file_path());
assert_eq!(original.state(), cloned.state());
let mut cloned_modified = cloned.clone();
cloned_modified.set_state(BskyPostState::Posted);
assert_eq!(original.state(), &BskyPostState::Read);
assert_eq!(cloned_modified.state(), &BskyPostState::Posted);
}
#[test]
fn test_bsky_post_debug() {
let bsky_post = create_test_bsky_post();
let debug_str = format!("{bsky_post:?}");
assert!(debug_str.contains("BskyPost"));
assert!(!debug_str.is_empty());
}
#[test]
fn test_bsky_post_with_empty_text() {
let record = create_test_record_data("");
let bsky_post = BskyPost::new(record, PathBuf::from("/empty.post"));
assert_eq!(bsky_post.post().text, "");
assert_eq!(bsky_post.state(), &BskyPostState::Read);
}
#[test]
fn test_bsky_post_with_unicode_content() {
let unicode_text = "Hello 世界 🌍 Здравствуй мир";
let record = create_test_record_data(unicode_text);
let bsky_post = BskyPost::new(record, PathBuf::from("/unicode.post"));
assert_eq!(bsky_post.post().text, unicode_text);
}
#[test]
fn test_bsky_post_with_long_path() {
let long_path = PathBuf::from("/very/deep/directory/structure/with/many/levels/post.post");
let bsky_post = BskyPost::new(create_test_record_data("test"), long_path.clone());
assert_eq!(bsky_post.file_path(), &long_path);
}
#[test]
fn test_bsky_post_with_relative_path() {
let relative_path = PathBuf::from("./posts/relative.post");
let bsky_post = BskyPost::new(create_test_record_data("test"), relative_path.clone());
assert_eq!(bsky_post.file_path(), &relative_path);
}
#[test]
fn test_multiple_posts_state_filtering() {
let mut posts = [
create_test_bsky_post(),
create_test_bsky_post(),
create_test_bsky_post(),
create_test_bsky_post(),
];
posts[1].set_state(BskyPostState::Posted);
posts[2].set_state(BskyPostState::Deleted);
posts[3].set_state(BskyPostState::Posted);
let read_posts: Vec<_> = posts
.iter()
.filter(|p| p.state() == &BskyPostState::Read)
.collect();
let posted_posts: Vec<_> = posts
.iter()
.filter(|p| p.state() == &BskyPostState::Posted)
.collect();
let deleted_posts: Vec<_> = posts
.iter()
.filter(|p| p.state() == &BskyPostState::Deleted)
.collect();
assert_eq!(read_posts.len(), 1);
assert_eq!(posted_posts.len(), 2);
assert_eq!(deleted_posts.len(), 1);
}
#[test]
fn test_state_transition_workflow() {
let mut bsky_post = create_test_bsky_post();
assert_eq!(bsky_post.state(), &BskyPostState::Read);
bsky_post.set_state(BskyPostState::Posted);
assert_eq!(bsky_post.state(), &BskyPostState::Posted);
bsky_post.set_state(BskyPostState::Deleted);
assert_eq!(bsky_post.state(), &BskyPostState::Deleted);
}
#[test]
fn test_bsky_post_source_path_is_none_by_default() {
let bsky_post = create_test_bsky_post();
assert!(bsky_post.source_path().is_none());
}
#[test]
fn test_bsky_post_new_with_source_path_stores_it() {
let record = create_test_record_data("Test content");
let file_path = PathBuf::from("/posts/abc.post");
let source_path = PathBuf::from("/content/blog/my-post.md");
let bsky_post = BskyPost::new_with_source(record, file_path, source_path.clone());
assert_eq!(bsky_post.source_path(), Some(&source_path));
}
#[test]
fn test_post_file_deserializes_without_source_path() {
let json = r#"{"text":"Hello","createdAt":"2026-04-03T00:00:00.000000Z","langs":null,"reply":null,"embed":null,"facets":null,"tags":null,"entities":null,"labels":null}"#;
let pf: PostFile = serde_json::from_str(json).unwrap();
assert_eq!(pf.record.text, "Hello");
assert!(pf.source_path.is_none());
}
#[test]
fn test_post_file_roundtrips_with_source_path() {
use bsky_sdk::api::types::string::Datetime;
use bsky_sdk::api::types::string::Language;
let record = RecordData {
text: "Test".to_string(),
created_at: Datetime::now(),
langs: Some(vec![Language::new("en".to_string()).unwrap()]),
reply: None,
embed: None,
facets: None,
tags: None,
entities: None,
labels: None,
};
let pf = PostFile {
record,
source_path: Some(PathBuf::from("/blog/my-post.md")),
};
let json = serde_json::to_string(&pf).unwrap();
assert!(
json.contains("source_path"),
"source_path should appear in JSON: {json}"
);
let pf2: PostFile = serde_json::from_str(&json).unwrap();
assert_eq!(pf2.source_path, Some(PathBuf::from("/blog/my-post.md")));
assert_eq!(pf2.record.text, "Test");
}
#[test]
fn test_many_posts_creation() {
let posts: Vec<BskyPost> = (0..1000)
.map(|i| {
let record = create_test_record_data(&format!("Post {i}"));
let path = PathBuf::from(format!("/test/post{i}.post"));
BskyPost::new(record, path)
})
.collect();
assert_eq!(posts.len(), 1000);
let read_count = posts
.iter()
.filter(|p| p.state() == &BskyPostState::Read)
.count();
assert_eq!(read_count, 1000);
assert_eq!(posts[0].post().text, "Post 0");
assert_eq!(posts[999].post().text, "Post 999");
}
#[test]
fn test_state_changes_performance() {
let mut posts: Vec<BskyPost> = (0..100)
.map(|i| {
let record = create_test_record_data(&format!("Post {i}"));
let path = PathBuf::from(format!("/test/post{i}.post"));
BskyPost::new(record, path)
})
.collect();
for post in posts.iter_mut().take(30) {
post.set_state(BskyPostState::Posted);
}
for post in posts.iter_mut().skip(30).take(20) {
post.set_state(BskyPostState::Deleted);
}
let read_count = posts
.iter()
.filter(|p| p.state() == &BskyPostState::Read)
.count();
let posted_count = posts
.iter()
.filter(|p| p.state() == &BskyPostState::Posted)
.count();
let deleted_count = posts
.iter()
.filter(|p| p.state() == &BskyPostState::Deleted)
.count();
assert_eq!(read_count, 50);
assert_eq!(posted_count, 30);
assert_eq!(deleted_count, 20);
}
}