use { super::{ config::{BlogCategory, BlogPost}, post::Post, }, crate::{ notion, response::success, route::track_mount, url::ROOT_GEMINI_URL, xml::{Item as XmlItem, Writer as XmlWriter}, }, std::sync::{ LazyLock, Mutex, RwLock, atomic::{AtomicBool, Ordering}, }, }; pub static POSTS: LazyLock>> = LazyLock::new(|| Mutex::new(Vec::new())); static BLOG_CATEGORIES: LazyLock>> = LazyLock::new(|| RwLock::new(Vec::new())); static BLOG_POSTS: LazyLock>> = LazyLock::new(|| RwLock::new(Vec::new())); static REFRESH_IN_PROGRESS: AtomicBool = AtomicBool::new(false); #[allow(clippy::too_many_lines)] pub fn module(router: &mut windmark::router::Router) { trigger_background_refresh("initial"); track_mount(router, "/blog", "Fuwn's blogs", |context| { if should_trigger_manual_refresh(context.url.query()) { trigger_manual_refresh(); } let categories = BLOG_CATEGORIES.read().unwrap(); let listing = categories .iter() .map(|category| { let permalink = canonical_blog_permalink(category); format!( "=> /blog/{} {}{}", permalink, category.title, category .description .as_ref() .map_or_else(String::new, |description_text| format!( " ― {description_text}" )) ) }) .collect::>() .join("\n"); success(&format!("# Blogs ({})\n\n{}", categories.len(), listing), &context) }); track_mount( router, "/blog/:blog_name", "-A blog on Fuwn's capsule", |context| { let raw_blog_name = context.parameters.get("blog_name").cloned().unwrap_or_default(); if let Some(blog_slug) = raw_blog_name.strip_suffix(".xml") { return build_rss_feed(blog_slug); } let blog_slug = raw_blog_name; let categories = BLOG_CATEGORIES.read().unwrap(); let posts = BLOG_POSTS.read().unwrap(); let matched_category = find_category_by_identifier(&categories, &blog_slug); let Some(category) = matched_category else { return windmark::response::Response::not_found( "This blog could not be found.", ); }; let category_posts = visible_posts_for_blog(&posts, &category.notion_id); let blog_permalink = canonical_blog_permalink(category); success( &render_blog_page(category, &blog_permalink, &category_posts), &context, ) }, ); track_mount( router, "/blog/:blog_name/:post_title", "-An entry to one of Fuwn's blogs", |context| { let blog_slug = context.parameters.get("blog_name").cloned().unwrap_or_default(); let post_slug = context.parameters.get("post_title").cloned().unwrap_or_default(); let categories = BLOG_CATEGORIES.read().unwrap(); let posts = BLOG_POSTS.read().unwrap(); let matched_category = find_category_by_identifier(&categories, &blog_slug); let Some(category) = matched_category else { return windmark::response::Response::not_found( "This blog could not be found.", ); }; let matched_post = find_visible_post_by_identifier( &posts, &category.notion_id, &post_slug, ); let Some(post) = matched_post else { return windmark::response::Response::not_found( "This post could not be found.", ); }; let header = construct_header(post); success(&format!("{}\n{}", header, post.content), &context) }, ); } fn should_trigger_manual_refresh(query: Option<&str>) -> bool { query.is_some_and(|query_string| { query_string.split('&').any(|query_part| { query_part.split_once('=').map_or(query_part, |(key, _)| key) == "__refresh" }) }) } fn begin_refresh() -> bool { REFRESH_IN_PROGRESS .compare_exchange(false, true, Ordering::AcqRel, Ordering::Acquire) .is_ok() } fn finish_refresh() { REFRESH_IN_PROGRESS.store(false, Ordering::Release); } fn refresh_once(refresh_origin: &str) { if !begin_refresh() { info!( "{refresh_origin} Notion refresh skipped because another refresh is already in progress" ); return; } match std::panic::catch_unwind(fetch_from_notion) { Ok(()) => info!("{refresh_origin} refreshed blog data from Notion"), Err(_) => warn!("{refresh_origin} Notion refresh failed"), } finish_refresh(); } fn trigger_background_refresh(refresh_origin: &'static str) { std::thread::spawn(move || refresh_once(refresh_origin)); } fn trigger_manual_refresh() { trigger_background_refresh("manual"); } fn construct_header(post: &BlogPost) -> String { let title_line = format!("# {}", post.title); let has_metadata = post.author.is_some() || post.created.is_some() || post.last_modified.is_some() || post.description.is_some(); if !has_metadata { return title_line; } let author_fragment = post.author.as_ref().map_or_else(String::new, |author_name| { format!("Written by {author_name}") }); let created_fragment = post .created .as_ref() .map_or_else(String::new, |created_date| format!(" on {created_date}")); let modified_fragment = post.last_modified.as_ref().map_or_else(String::new, |modified_date| { format!(" (last modified on {modified_date})\n") }); let description_fragment = post .description .as_ref() .filter(|description_text| !description_text.is_empty()) .map_or_else(String::new, |description_text| { format!("\n{description_text}\n") }); format!( "{title_line}\n\n{author_fragment}{created_fragment}{modified_fragment}{description_fragment}" ) } fn slugify(text: &str) -> String { text.replace(' ', "_").to_lowercase() } fn normalize_permalink_identifier(identifier: &str) -> String { identifier.replace('-', "").to_lowercase() } fn normalize_slug_identifier(identifier: &str) -> String { identifier.trim().to_lowercase() } fn notion_permalink_identifier(notion_id: &str) -> String { normalize_permalink_identifier(notion_id) } fn is_notion_identifier_match(identifier: &str, notion_id: &str) -> bool { normalize_permalink_identifier(identifier) == normalize_permalink_identifier(notion_id) } fn parse_slug_list(raw_slugs: &str) -> Vec { let mut parsed_slugs = Vec::new(); for raw_slug in raw_slugs.split(',') { let normalized_slug = normalize_slug_identifier(raw_slug); if normalized_slug.is_empty() || parsed_slugs.contains(&normalized_slug) { continue; } parsed_slugs.push(normalized_slug); } parsed_slugs } fn canonical_blog_permalink(category: &BlogCategory) -> String { category .slugs .first() .cloned() .unwrap_or_else(|| slugify(&category.title)) } fn canonical_post_permalink(post: &BlogPost) -> String { post .slugs .first() .cloned() .unwrap_or_else(|| slugify(&post.title)) } fn find_category_by_identifier<'a>( categories: &'a [BlogCategory], identifier: &str, ) -> Option<&'a BlogCategory> { let normalized_identifier = normalize_slug_identifier(identifier); categories.iter().find(|category| { slugify(&category.title) == normalized_identifier || category.slugs.iter().any(|slug| slug == &normalized_identifier) || is_notion_identifier_match(&normalized_identifier, &category.notion_id) }) } fn render_blog_page( category: &BlogCategory, blog_slug: &str, category_posts: &[&BlogPost], ) -> String { let post_listing = category_posts .iter() .map(|post| { let post_permalink = canonical_post_permalink(post); format!( "=> /blog/{}/{} {}{}", blog_slug, post_permalink, post.title, post .description .as_ref() .filter(|description_text| !description_text.is_empty()) .map_or_else(String::new, |description_text| format!( " ― {description_text}" )) ) }) .collect::>() .join("\n"); let description_block = category .description .as_deref() .filter(|description_text| !description_text.is_empty()) .map_or_else(String::new, |description_text| { format!("{description_text}\n\n") }); format!( "# {} ({})\n\n{}{post_listing}\n\n## Really Simple Syndication\n\nAccess \ {}'s RSS feed\n\n=> /blog/{}.xml here!", category.title, category_posts.len(), description_block, category.title, blog_slug, ) } fn parse_chronological_sort_key(raw_date: Option<&str>) -> i64 { raw_date .and_then(|date_text| { chrono::DateTime::parse_from_rfc3339(date_text) .ok() .map(|date_time| date_time.timestamp()) .or_else(|| { chrono::NaiveDate::parse_from_str(date_text, "%Y-%m-%d") .ok() .and_then(|date| date.and_hms_opt(0, 0, 0)) .map(|date_time| date_time.and_utc().timestamp()) }) }) .unwrap_or(i64::MIN) } fn sort_posts_by_created_desc(posts: &mut Vec<&BlogPost>) { posts.sort_by(|first_post, second_post| { parse_chronological_sort_key(second_post.created_raw.as_deref()) .cmp(&parse_chronological_sort_key(first_post.created_raw.as_deref())) .then_with(|| first_post.title.cmp(&second_post.title)) }); } fn visible_posts_for_blog<'a>( posts: &'a [BlogPost], blog_identifier: &str, ) -> Vec<&'a BlogPost> { let mut visible_posts: Vec<_> = posts .iter() .filter(|post| post.blog_id == blog_identifier && !post.hidden) .collect(); sort_posts_by_created_desc(&mut visible_posts); visible_posts } fn find_visible_post_by_identifier<'a>( posts: &'a [BlogPost], blog_identifier: &str, post_identifier: &str, ) -> Option<&'a BlogPost> { let normalized_identifier = normalize_slug_identifier(post_identifier); visible_posts_for_blog(posts, blog_identifier) .into_iter() .find(|post| { slugify(&post.title) == normalized_identifier || post.slugs.iter().any(|slug| slug == &normalized_identifier) || is_notion_identifier_match(&normalized_identifier, &post.notion_id) }) } fn build_rss_feed(blog_slug: &str) -> windmark::response::Response { let categories = BLOG_CATEGORIES.read().unwrap(); let posts = BLOG_POSTS.read().unwrap(); let matched_category = find_category_by_identifier(&categories, blog_slug); let Some(category) = matched_category else { return windmark::response::Response::not_found( "This blog could not be found.", ); }; let mut xml = XmlWriter::builder(); let blog_permalink = canonical_blog_permalink(category); xml.add_field("title", &category.title); xml.add_field("link", &format!("{ROOT_GEMINI_URL}/blog/{blog_permalink}")); if let Some(ref description_text) = category.description { xml.add_field("description", description_text); } xml.add_field("generator", "locus"); xml.add_field("lastBuildDate", &chrono::Local::now().to_rfc2822()); xml.add_link(&format!("{ROOT_GEMINI_URL}/blog/{blog_permalink}.xml")); for post in visible_posts_for_blog(&posts, &category.notion_id) { let post_permalink = canonical_post_permalink(post); xml.add_item(&{ let mut builder = XmlItem::builder(); builder.add_field( "link", &format!("{ROOT_GEMINI_URL}/blog/{blog_permalink}/{post_permalink}"), ); builder.add_field("description", &post.content); builder.add_field( "guid", &format!("{ROOT_GEMINI_URL}/blog/{blog_permalink}/{post_permalink}"), ); builder.add_field("title", &post.title); if let Some(ref created_date) = post.created { builder.add_field("pubDate", created_date); } builder }); } windmark::response::Response::success(xml.to_string()) .with_mime("text/rss+xml") .clone() } fn build_global_posts( categories: &[BlogCategory], fetched_posts: &[BlogPost], ) -> Vec { let mut visible_posts: Vec<_> = fetched_posts.iter().filter(|post| !post.hidden).collect(); sort_posts_by_created_desc(&mut visible_posts); visible_posts .into_iter() .map(|post| { let blog_title = categories .iter() .find(|category| category.notion_id == post.blog_id) .map(|category| category.title.clone()) .unwrap_or_default(); Post::new( format!("{}, {}", blog_title, post.title), categories .iter() .find(|category| category.notion_id == post.blog_id) .map_or_else( || { format!( "/blog/{}/{}", notion_permalink_identifier(&post.blog_id), canonical_post_permalink(post) ) }, |category| { format!( "/blog/{}/{}", canonical_blog_permalink(category), canonical_post_permalink(post) ) }, ), ) }) .collect() } fn fetch_from_notion() { let api_key = std::env::var("NOTION_API_KEY") .expect("NOTION_API_KEY environment variable is required"); let blogs_database_identifier = std::env::var("NOTION_BLOGS_DATABASE_ID") .expect("NOTION_BLOGS_DATABASE_ID environment variable is required"); let posts_database_identifier = std::env::var("NOTION_POSTS_DATABASE_ID") .expect("NOTION_POSTS_DATABASE_ID environment variable is required"); let http_client = reqwest::blocking::Client::new(); let blog_pages = notion::query_database(&http_client, &api_key, &blogs_database_identifier) .expect("failed to query Notion blogs database"); let mut categories: Vec = blog_pages .iter() .map(|page| { let description_text = notion::extract_rich_text(&page.properties, "Description"); let slugs_text = notion::extract_rich_text(&page.properties, "Slugs"); BlogCategory { title: notion::extract_title(&page.properties, "Title"), slugs: parse_slug_list(&slugs_text), description: if description_text.is_empty() { None } else { Some(description_text) }, priority: notion::extract_number(&page.properties, "Priority") .unwrap_or(0), notion_id: page.id.clone(), } }) .collect(); categories.sort_by(|first, second| second.priority.cmp(&first.priority)); let post_pages = notion::query_database(&http_client, &api_key, &posts_database_identifier) .expect("failed to query Notion posts database"); let fetched_posts: Vec = post_pages .iter() .map(|page| { let page_content = notion::fetch_page_content(&http_client, &api_key, &page.id) .unwrap_or_default(); let blog_relation_ids = notion::extract_relation_ids(&page.properties, "Blog"); let blog_identifier = blog_relation_ids.first().cloned().unwrap_or_default(); let created_raw = notion::extract_date(&page.properties, "Created"); let last_modified_raw = notion::extract_date(&page.properties, "Last Modified"); let description_text = notion::extract_rich_text(&page.properties, "Description"); let author_text = notion::extract_rich_text(&page.properties, "Author"); let slugs_text = notion::extract_rich_text(&page.properties, "Slugs"); BlogPost { title: notion::extract_title(&page.properties, "Title"), slugs: parse_slug_list(&slugs_text), notion_id: page.id.clone(), description: if description_text.is_empty() { None } else { Some(description_text) }, author: if author_text.is_empty() { None } else { Some(author_text) }, created_raw: if created_raw.is_empty() { None } else { Some(created_raw.clone()) }, created: if created_raw.is_empty() { None } else { Some(notion::format_notion_date(&created_raw)) }, last_modified: if last_modified_raw.is_empty() { None } else { Some(notion::format_notion_date(&last_modified_raw)) }, content: page_content, blog_id: blog_identifier, hidden: notion::extract_checkbox(&page.properties, "Hidden"), } }) .collect(); { let mut global_posts = POSTS.lock().unwrap(); *global_posts = build_global_posts(&categories, &fetched_posts); } *BLOG_CATEGORIES.write().unwrap() = categories; *BLOG_POSTS.write().unwrap() = fetched_posts; info!("fetched blog data from Notion"); } pub fn refresh_loop() { let refresh_interval_seconds = std::env::var("NOTION_REFRESH_INTERVAL") .ok() .and_then(|value| value.parse::().ok()) .unwrap_or(300); info!("spawned Notion blog refresh loop ({refresh_interval_seconds}s interval)"); loop { std::thread::sleep(std::time::Duration::from_secs(refresh_interval_seconds)); refresh_once("scheduled"); } } #[cfg(test)] mod tests { use super::{ begin_refresh, build_global_posts, build_rss_feed, find_category_by_identifier, find_visible_post_by_identifier, finish_refresh, render_blog_page, should_trigger_manual_refresh, slugify, visible_posts_for_blog, BLOG_CATEGORIES, BLOG_POSTS, }; use crate::modules::blog::config::{BlogCategory, BlogPost}; #[test] fn hidden_posts_are_excluded_from_rss_feed() { let original_categories = BLOG_CATEGORIES.read().unwrap().clone(); let original_posts = BLOG_POSTS.read().unwrap().clone(); let category = BlogCategory { title: "Test Blog".to_string(), slugs: vec![], description: None, priority: 1, notion_id: "blog-id".to_string(), }; let visible_post = BlogPost { title: "Visible".to_string(), slugs: vec![], notion_id: "post-id".to_string(), description: None, author: None, created_raw: None, created: None, last_modified: None, content: "visible content".to_string(), blog_id: "blog-id".to_string(), hidden: false, }; let hidden_post = BlogPost { title: "Hidden".to_string(), slugs: vec![], notion_id: "post-id".to_string(), description: None, author: None, created_raw: None, created: None, last_modified: None, content: "hidden content".to_string(), blog_id: "blog-id".to_string(), hidden: true, }; *BLOG_CATEGORIES.write().unwrap() = vec![category]; *BLOG_POSTS.write().unwrap() = vec![visible_post, hidden_post]; let feed = build_rss_feed(&slugify("Test Blog")); let feed_xml = feed.content; assert!(feed_xml.contains("Visible")); assert!(!feed_xml.contains("Hidden")); *BLOG_CATEGORIES.write().unwrap() = original_categories; *BLOG_POSTS.write().unwrap() = original_posts; } #[test] fn visible_posts_for_blog_excludes_hidden_posts() { let visible_post = BlogPost { title: "Visible".to_string(), slugs: vec![], notion_id: "post-id".to_string(), description: None, author: None, created_raw: None, created: None, last_modified: None, content: "visible content".to_string(), blog_id: "blog-id".to_string(), hidden: false, }; let hidden_post = BlogPost { title: "Hidden".to_string(), slugs: vec![], notion_id: "post-id".to_string(), description: None, author: None, created_raw: None, created: None, last_modified: None, content: "hidden content".to_string(), blog_id: "blog-id".to_string(), hidden: true, }; let posts = vec![visible_post, hidden_post]; let visible_titles = visible_posts_for_blog(&posts, "blog-id") .into_iter() .map(|post| post.title.clone()) .collect::>(); assert_eq!(visible_titles, vec!["Visible".to_string()]); } #[test] fn find_visible_post_by_identifier_skips_hidden_posts() { let visible_post = BlogPost { title: "Visible Post".to_string(), slugs: vec![], notion_id: "post-id".to_string(), description: None, author: None, created_raw: None, created: None, last_modified: None, content: "visible content".to_string(), blog_id: "blog-id".to_string(), hidden: false, }; let hidden_post = BlogPost { title: "Hidden Post".to_string(), slugs: vec![], notion_id: "post-id".to_string(), description: None, author: None, created_raw: None, created: None, last_modified: None, content: "hidden content".to_string(), blog_id: "blog-id".to_string(), hidden: true, }; let posts = vec![visible_post, hidden_post]; assert!( find_visible_post_by_identifier(&posts, "blog-id", "visible_post") .is_some() ); assert!( find_visible_post_by_identifier(&posts, "blog-id", "hidden_post") .is_none() ); } #[test] fn visible_posts_for_blog_are_sorted_chronologically_desc() { let oldest_post = BlogPost { title: "Oldest".to_string(), slugs: vec![], notion_id: "post-id".to_string(), description: None, author: None, created_raw: Some("2024-01-01".to_string()), created: Some("January 1, 2024".to_string()), last_modified: None, content: "oldest content".to_string(), blog_id: "blog-id".to_string(), hidden: false, }; let newest_post = BlogPost { title: "Newest".to_string(), slugs: vec![], notion_id: "post-id".to_string(), description: None, author: None, created_raw: Some("2026-03-10".to_string()), created: Some("March 10, 2026".to_string()), last_modified: None, content: "newest content".to_string(), blog_id: "blog-id".to_string(), hidden: false, }; let middle_post = BlogPost { title: "Middle".to_string(), slugs: vec![], notion_id: "post-id".to_string(), description: None, author: None, created_raw: Some("2025-02-02".to_string()), created: Some("February 2, 2025".to_string()), last_modified: None, content: "middle content".to_string(), blog_id: "blog-id".to_string(), hidden: false, }; let posts = vec![oldest_post, newest_post, middle_post]; let ordered_titles = visible_posts_for_blog(&posts, "blog-id") .iter() .map(|post| post.title.clone()) .collect::>(); assert_eq!( ordered_titles, vec![ "Newest".to_string(), "Middle".to_string(), "Oldest".to_string() ] ); } #[test] fn global_posts_are_sorted_chronologically_desc() { let categories = vec![BlogCategory { title: "Test Blog".to_string(), slugs: vec![], description: None, priority: 1, notion_id: "blog-id".to_string(), }]; let older_post = BlogPost { title: "Older".to_string(), slugs: vec![], notion_id: "post-id".to_string(), description: None, author: None, created_raw: Some("2025-01-01".to_string()), created: Some("January 1, 2025".to_string()), last_modified: None, content: "older content".to_string(), blog_id: "blog-id".to_string(), hidden: false, }; let newer_post = BlogPost { title: "Newer".to_string(), slugs: vec![], notion_id: "post-id".to_string(), description: None, author: None, created_raw: Some("2026-01-01".to_string()), created: Some("January 1, 2026".to_string()), last_modified: None, content: "newer content".to_string(), blog_id: "blog-id".to_string(), hidden: false, }; let hidden_newest_post = BlogPost { title: "Hidden".to_string(), slugs: vec![], notion_id: "post-id".to_string(), description: None, author: None, created_raw: Some("2027-01-01".to_string()), created: Some("January 1, 2027".to_string()), last_modified: None, content: "hidden content".to_string(), blog_id: "blog-id".to_string(), hidden: true, }; let posts = vec![older_post, hidden_newest_post, newer_post]; let global_posts = build_global_posts(&categories, &posts); let ordered_titles = global_posts .iter() .map(|post| post.title().clone()) .collect::>(); assert_eq!( ordered_titles, vec![ "Test Blog, Newer".to_string(), "Test Blog, Older".to_string() ] ); } #[test] fn render_blog_page_without_description_has_single_spacing_before_listing() { let category = BlogCategory { title: "The Daily".to_string(), slugs: vec![], description: None, priority: 1, notion_id: "blog-id".to_string(), }; let post = BlogPost { title: "Entry One".to_string(), slugs: vec!["entry-one".to_string(), "entry_one".to_string()], notion_id: "post-id".to_string(), description: None, author: None, created_raw: None, created: None, last_modified: None, content: "content".to_string(), blog_id: "blog-id".to_string(), hidden: false, }; let rendered = render_blog_page(&category, "the_daily", &[&post]); assert!(rendered.contains( "# The Daily (1)\n\n=> /blog/the_daily/entry-one Entry One" )); assert!(!rendered.contains("# The Daily (1)\n\n\n")); } #[test] fn render_blog_page_with_description_keeps_expected_spacing() { let category = BlogCategory { title: "The Daily".to_string(), slugs: vec![], description: Some("desc".to_string()), priority: 1, notion_id: "blog-id".to_string(), }; let post = BlogPost { title: "Entry One".to_string(), slugs: vec!["entry-one".to_string()], notion_id: "post-id".to_string(), description: None, author: None, created_raw: None, created: None, last_modified: None, content: "content".to_string(), blog_id: "blog-id".to_string(), hidden: false, }; let rendered = render_blog_page(&category, "the_daily", &[&post]); assert!( rendered .contains("# The Daily (1)\n\ndesc\n\n=> /blog/the_daily/entry-one Entry One") ); } #[test] fn should_trigger_manual_refresh_when_hidden_query_is_present() { assert!(should_trigger_manual_refresh(Some("__refresh=1"))); assert!(should_trigger_manual_refresh(Some("foo=bar&__refresh=1"))); assert!(should_trigger_manual_refresh(Some("__refresh&foo=bar"))); assert!(!should_trigger_manual_refresh(Some("refresh=1"))); assert!(!should_trigger_manual_refresh(Some("foo=bar"))); assert!(!should_trigger_manual_refresh(None)); } #[test] fn manual_refresh_guard_prevents_parallel_refreshes() { finish_refresh(); assert!(begin_refresh()); assert!(!begin_refresh()); finish_refresh(); assert!(begin_refresh()); finish_refresh(); } #[test] fn find_category_by_identifier_supports_slug_aliases() { let categories = vec![ BlogCategory { title: "Alpha".to_string(), slugs: vec!["alpha".to_string(), "a".to_string()], description: None, priority: 0, notion_id: "11111111-1111-1111-1111-111111111111".to_string(), }, BlogCategory { title: "Beta".to_string(), slugs: vec![ "the-daily".to_string(), "thedaily".to_string(), "daily".to_string(), ], description: None, priority: 0, notion_id: "22222222-2222-2222-2222-222222222222".to_string(), }, ]; assert_eq!( find_category_by_identifier(&categories, "thedaily") .map(|c| c.notion_id.clone()), Some("22222222-2222-2222-2222-222222222222".to_string()) ); assert_eq!( find_category_by_identifier(&categories, "daily").map(|c| c.notion_id.clone()), Some("22222222-2222-2222-2222-222222222222".to_string()) ); assert_eq!( find_category_by_identifier(&categories, "alpha") .map(|c| c.notion_id.clone()), Some("11111111-1111-1111-1111-111111111111".to_string()) ); } #[test] fn find_visible_post_by_identifier_supports_slug_aliases() { let posts = vec![ BlogPost { title: "First".to_string(), slugs: vec!["first".to_string()], notion_id: "aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaaa".to_string(), description: None, author: None, created_raw: None, created: None, last_modified: None, content: "first".to_string(), blog_id: "blog-id".to_string(), hidden: false, }, BlogPost { title: "Second".to_string(), slugs: vec![ "post-two".to_string(), "post2".to_string(), "second".to_string(), ], notion_id: "bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbbb".to_string(), description: None, author: None, created_raw: None, created: None, last_modified: None, content: "second".to_string(), blog_id: "blog-id".to_string(), hidden: false, }, ]; assert_eq!( find_visible_post_by_identifier(&posts, "blog-id", "post2") .map(|post| post.title.clone()), Some("Second".to_string()) ); assert_eq!( find_visible_post_by_identifier(&posts, "blog-id", "first") .map(|post| post.title.clone()), Some("First".to_string()) ); } }