use std::fs; use std::path::Path; use rusqlite::{params, Connection}; use uuid::Uuid; use walkdir::WalkDir; use crate::db::fts; use crate::db::queries::post as qp; use crate::db::queries::post_link as ql; use crate::db::queries::post_translation as qt; use crate::engine::{EngineError, EngineResult}; use crate::model::{Post, PostLink, PostStatus, PostTranslation}; use crate::util::frontmatter::{ read_post_file, read_translation_file, write_post_file, write_translation_file, }; use crate::util::{ atomic_write_str, content_hash, ensure_unique, now_unix_ms, post_file_path, slugify, translation_file_path, }; /// Report returned by `rebuild_posts_from_filesystem`. #[derive(Debug, Default)] pub struct RebuildReport { pub posts_created: usize, pub posts_updated: usize, pub translations_created: usize, pub translations_updated: usize, pub errors: Vec, } /// Create a new draft post. pub fn create_post( conn: &Connection, _data_dir: &Path, project_id: &str, title: &str, content: Option<&str>, tags: Vec, categories: Vec, author: Option<&str>, language: Option<&str>, template_slug: Option<&str>, ) -> EngineResult { let id = Uuid::new_v4().to_string(); let slug_source = if title.is_empty() { "untitled" } else { title }; let base_slug = slugify(slug_source); let base_slug = if base_slug.is_empty() { "untitled".to_string() } else { base_slug }; let slug = ensure_unique(&base_slug, |candidate| { qp::get_post_by_project_and_slug(conn, project_id, candidate).is_ok() }); let now = now_unix_ms(); let post = Post { id, project_id: project_id.to_string(), title: title.to_string(), slug, excerpt: None, content: content.map(|s| s.to_string()), status: PostStatus::Draft, author: author.map(|s| s.to_string()), language: language.map(|s| s.to_string()), do_not_translate: false, template_slug: template_slug.map(|s| s.to_string()), file_path: String::new(), checksum: None, tags, categories, published_title: None, published_content: None, published_tags: None, published_categories: None, published_excerpt: None, created_at: now, updated_at: now, published_at: None, }; qp::insert_post(conn, &post)?; // Index for FTS fts_index_post(conn, &post)?; Ok(post) } /// Update a post's fields. pub fn update_post( conn: &Connection, _data_dir: &Path, post_id: &str, title: Option<&str>, slug: Option<&str>, excerpt: Option>, content: Option<&str>, tags: Option>, categories: Option>, author: Option>, language: Option>, template_slug: Option>, do_not_translate: Option, ) -> EngineResult { let mut post = qp::get_post_by_id(conn, post_id)?; // Slug frozen after first publish if slug.is_some() && post.published_at.is_some() { return Err(EngineError::Conflict( "slug cannot be changed after publishing".to_string(), )); } // Slug uniqueness check if let Some(new_slug) = slug { if new_slug != post.slug { if qp::get_post_by_project_and_slug(conn, &post.project_id, new_slug).is_ok() { return Err(EngineError::Conflict(format!( "slug '{new_slug}' already exists in this project" ))); } } } if let Some(t) = title { post.title = t.to_string(); } if let Some(s) = slug { post.slug = s.to_string(); } if let Some(exc) = excerpt { post.excerpt = exc.map(|s| s.to_string()); } if let Some(c) = content { post.content = Some(c.to_string()); } if let Some(t) = tags { post.tags = t; } if let Some(c) = categories { post.categories = c; } if let Some(a) = author { post.author = a.map(|s| s.to_string()); } if let Some(l) = language { post.language = l.map(|s| s.to_string()); } if let Some(ts) = template_slug { post.template_slug = ts.map(|s| s.to_string()); } if let Some(dnt) = do_not_translate { post.do_not_translate = dnt; } // Auto-transition published or archived post back to draft on content/metadata change if post.status == PostStatus::Published || post.status == PostStatus::Archived { // Reload content from filesystem if content field is NULL (published state) if post.content.is_none() && !post.file_path.is_empty() { let abs_path = _data_dir.join(&post.file_path); if abs_path.exists() { if let Ok(file_content) = fs::read_to_string(&abs_path) { if let Ok((_fm, body)) = read_post_file(&file_content) { post.content = Some(body); } } } } post.status = PostStatus::Draft; } post.updated_at = now_unix_ms(); qp::update_post(conn, &post)?; // Re-index FTS fts_index_post(conn, &post)?; Ok(post) } /// Publish a post: write file, clear content, set published_at. pub fn publish_post( conn: &Connection, data_dir: &Path, post_id: &str, ) -> EngineResult { let mut post = qp::get_post_by_id(conn, post_id)?; // Require Draft or Archived status match post.status { PostStatus::Draft | PostStatus::Archived => {} PostStatus::Published => { return Err(EngineError::Conflict( "post is already published".to_string(), )); } } // Compute file_path from created_at + slug // Use a savepoint for atomicity // Note: savepoint auto-rolls-back if not released (on error propagation) conn.execute_batch("SAVEPOINT publish_post")?; let rel_path = post_file_path(post.created_at, &post.slug); let abs_path = data_dir.join(&rel_path); // Get body: from post.content (draft) or read from existing file (re-publish after archive) let body = if let Some(ref c) = post.content { c.clone() } else if abs_path.exists() { let file_content = fs::read_to_string(&abs_path)?; let (_fm, body) = read_post_file(&file_content) .map_err(|e| EngineError::Parse(e))?; body } else { String::new() }; // Write frontmatter+body to filesystem let now = now_unix_ms(); let published_at = post.published_at.unwrap_or(now); post.published_at = Some(published_at); post.status = PostStatus::Published; post.file_path = rel_path.clone(); post.updated_at = now; let file_content = write_post_file(&post, &body); atomic_write_str(&abs_path, &file_content)?; // Compute checksum let hash = content_hash(file_content.as_bytes()); post.checksum = Some(hash); // Set published snapshot fields let tags_json = serde_json::to_string(&post.tags).unwrap_or_else(|_| "[]".into()); let cats_json = serde_json::to_string(&post.categories).unwrap_or_else(|_| "[]".into()); post.published_title = Some(post.title.clone()); post.published_content = Some(body.clone()); post.published_tags = Some(tags_json.clone()); post.published_categories = Some(cats_json.clone()); post.published_excerpt = post.excerpt.clone(); qp::set_published_snapshot( conn, post_id, &post.title, &body, &tags_json, &cats_json, post.excerpt.as_deref(), published_at, now, )?; // Set file_path and checksum in DB qp::set_post_file_path(conn, post_id, &post.file_path, now)?; conn.execute( "UPDATE posts SET checksum = ?1 WHERE id = ?2", params![post.checksum, post_id], )?; // Clear content in DB qp::clear_post_content(conn, post_id, now)?; post.content = None; // Set status = Published qp::update_post_status(conn, post_id, &PostStatus::Published, now)?; // Publish all translations let translations = qt::list_post_translations_by_post(conn, post_id)?; for mut t in translations { publish_translation(conn, data_dir, &mut t, &post)?; } // Parse inter-post links and update link graph ql::delete_links_by_source(conn, post_id)?; let link_body = if let Some(ref pc) = post.published_content { pc.as_str() } else { "" }; let parsed_links = parse_post_links(link_body); for (target_slug, link_text) in &parsed_links { if let Ok(target) = qp::get_post_by_project_and_slug(conn, &post.project_id, target_slug) { let link = PostLink { id: Uuid::new_v4().to_string(), source_post_id: post_id.to_string(), target_post_id: target.id.clone(), link_text: Some(link_text.clone()), created_at: now, }; let _ = ql::insert_post_link(conn, &link); } } // Re-index FTS fts_index_post(conn, &post)?; conn.execute_batch("RELEASE publish_post")?; Ok(post) } /// Archive a post. pub fn archive_post(conn: &Connection, data_dir: &Path, post_id: &str) -> EngineResult<()> { let mut post = qp::get_post_by_id(conn, post_id)?; if post.status == PostStatus::Archived { return Err(EngineError::Conflict( "post is already archived".to_string(), )); } // Reload content from filesystem if transitioning from published (content is NULL) if post.status == PostStatus::Published && post.content.is_none() && !post.file_path.is_empty() { let abs_path = data_dir.join(&post.file_path); if abs_path.exists() { if let Ok(file_content) = fs::read_to_string(&abs_path) { if let Ok((_fm, body)) = read_post_file(&file_content) { post.content = Some(body); qp::update_post(conn, &post)?; } } } } let now = now_unix_ms(); qp::update_post_status(conn, post_id, &PostStatus::Archived, now)?; Ok(()) } /// Delete a post and all related data. pub fn delete_post( conn: &Connection, data_dir: &Path, post_id: &str, ) -> EngineResult<()> { let post = qp::get_post_by_id(conn, post_id)?; // Delete .md file if exists if !post.file_path.is_empty() { let abs_path = data_dir.join(&post.file_path); if abs_path.exists() { fs::remove_file(&abs_path)?; } } // Delete all translation files let translations = qt::list_post_translations_by_post(conn, post_id)?; for t in &translations { if !t.file_path.is_empty() { let abs_path = data_dir.join(&t.file_path); if abs_path.exists() { fs::remove_file(&abs_path)?; } } } // Delete all translations from DB qt::delete_all_translations_for_post(conn, post_id)?; // Delete post links (source and target) ql::delete_links_by_source(conn, post_id)?; conn.execute( "DELETE FROM post_links WHERE target_post_id = ?1", params![post_id], )?; // Delete post-media associations conn.execute( "DELETE FROM post_media WHERE post_id = ?1", params![post_id], )?; // Remove from FTS fts::remove_post_from_index(conn, post_id)?; // Delete post from DB qp::delete_post(conn, post_id)?; Ok(()) } /// Compute the canonical URL for a post: /{YYYY}/{MM}/{DD}/{slug} pub fn canonical_url(created_at_ms: i64, slug: &str) -> String { let (y, m, d) = crate::util::timestamp::year_month_day_from_unix_ms(created_at_ms); format!("/{y}/{m}/{d}/{slug}") } /// Upsert a translation for a post. pub fn upsert_translation( conn: &Connection, _data_dir: &Path, post_id: &str, language: &str, title: &str, excerpt: Option<&str>, content: Option<&str>, ) -> EngineResult { let post = qp::get_post_by_id(conn, post_id)?; if post.do_not_translate { return Err(EngineError::Validation( "cannot create translation for a do-not-translate post".to_string(), )); } let now = now_unix_ms(); // Check if translation already exists let existing = qt::get_post_translation_by_post_and_language(conn, post_id, language); match existing { Ok(mut t) => { // Update existing t.title = title.to_string(); t.excerpt = excerpt.map(|s| s.to_string()); if let Some(c) = content { t.content = Some(c.to_string()); } t.updated_at = now; qt::update_post_translation(conn, &t)?; // Re-index FTS for parent post fts_index_post(conn, &post)?; Ok(t) } Err(_) => { // Create new let id = Uuid::new_v4().to_string(); let t = PostTranslation { id, project_id: post.project_id.clone(), translation_for: post_id.to_string(), language: language.to_string(), title: title.to_string(), excerpt: excerpt.map(|s| s.to_string()), content: content.map(|s| s.to_string()), status: PostStatus::Draft, file_path: String::new(), checksum: None, created_at: now, updated_at: now, published_at: None, }; qt::insert_post_translation(conn, &t)?; // Re-index FTS for parent post fts_index_post(conn, &post)?; Ok(t) } } } /// Delete a translation. pub fn delete_translation( conn: &Connection, data_dir: &Path, translation_id: &str, ) -> EngineResult<()> { let t = qt::get_post_translation_by_id(conn, translation_id)?; // Delete file if exists if !t.file_path.is_empty() { let abs_path = data_dir.join(&t.file_path); if abs_path.exists() { fs::remove_file(&abs_path)?; } } qt::delete_post_translation(conn, translation_id)?; // Re-index FTS for parent post if let Ok(post) = qp::get_post_by_id(conn, &t.translation_for) { fts_index_post(conn, &post)?; } Ok(()) } /// Rebuild posts from filesystem. Walk posts/ dir, parse .md files, upsert into DB. pub fn rebuild_posts_from_filesystem( conn: &Connection, data_dir: &Path, project_id: &str, ) -> EngineResult { rebuild_posts_from_filesystem_with_progress(conn, data_dir, project_id, None) } /// Per-item progress callback: (current_item, total_items, item_description). pub type ItemProgressFn = Box; /// Like `rebuild_posts_from_filesystem` but with optional per-item progress. pub fn rebuild_posts_from_filesystem_with_progress( conn: &Connection, data_dir: &Path, project_id: &str, on_item: Option, ) -> EngineResult { let mut report = RebuildReport::default(); let posts_dir = data_dir.join("posts"); if !posts_dir.exists() { return Ok(report); } // Collect all .md files let mut canonical_files = Vec::new(); let mut translation_files = Vec::new(); for entry in WalkDir::new(&posts_dir) .into_iter() .filter_map(|e| e.ok()) { let path = entry.path(); if !path.is_file() { continue; } let ext = path.extension().and_then(|e| e.to_str()); if ext != Some("md") { continue; } let stem = path.file_stem().and_then(|s| s.to_str()).unwrap_or(""); if is_translation_filename(stem) { translation_files.push(path.to_path_buf()); } else { canonical_files.push(path.to_path_buf()); } } let total = canonical_files.len() + translation_files.len(); // Process canonical posts first for (i, path) in canonical_files.iter().enumerate() { if let Some(ref cb) = on_item { let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("?"); cb(i + 1, total, name); } match rebuild_canonical_post(conn, data_dir, project_id, path) { Ok(created) => { if created { report.posts_created += 1; } else { report.posts_updated += 1; } } Err(e) => { report.errors.push(format!("{}: {e}", path.display())); } } } // Process translations let offset = canonical_files.len(); for (i, path) in translation_files.iter().enumerate() { if let Some(ref cb) = on_item { let name = path.file_stem().and_then(|s| s.to_str()).unwrap_or("?"); cb(offset + i + 1, total, name); } match rebuild_translation(conn, data_dir, project_id, path) { Ok(created) => { if created { report.translations_created += 1; } else { report.translations_updated += 1; } } Err(e) => { report.errors.push(format!("{}: {e}", path.display())); } } } // Re-index FTS for all posts in this project let posts = qp::list_posts_by_project(conn, project_id)?; for post in &posts { fts_index_post(conn, post)?; } Ok(report) } // --- Internal helpers --- /// Parse inter-post links from markdown content. /// Looks for markdown links that reference canonical post URLs: [text](/YYYY/MM/DD/slug) fn parse_post_links(content: &str) -> Vec<(String, String)> { let mut links = Vec::new(); // Match markdown links: [text](/YYYY/MM/DD/slug) or [text](/YYYY/MM/DD/slug/) // Simple manual parsing since we don't have regex crate // Look for patterns like [...](...) where the URL matches /YYYY/MM/DD/slug for line in content.lines() { let mut pos = 0; while pos < line.len() { if let Some(bracket_start) = line[pos..].find('[') { let abs_start = pos + bracket_start; if let Some(bracket_end) = line[abs_start..].find("](") { let text_end = abs_start + bracket_end; let link_text = &line[abs_start + 1..text_end]; let url_start = text_end + 2; if let Some(paren_end) = line[url_start..].find(')') { let url = &line[url_start..url_start + paren_end]; // Check if URL matches /YYYY/MM/DD/slug pattern let parts: Vec<&str> = url.trim_end_matches('/').split('/').collect(); if parts.len() == 5 && parts[0].is_empty() && parts[1].len() == 4 && parts[1].chars().all(|c| c.is_ascii_digit()) && parts[2].len() == 2 && parts[2].chars().all(|c| c.is_ascii_digit()) && parts[3].len() == 2 && parts[3].chars().all(|c| c.is_ascii_digit()) { links.push((parts[4].to_string(), link_text.to_string())); } pos = url_start + paren_end + 1; continue; } } pos = abs_start + 1; } else { break; } } } links } /// Publish a single translation: write file, clear content, set status. fn publish_translation( conn: &Connection, data_dir: &Path, t: &mut PostTranslation, post: &Post, ) -> EngineResult<()> { let rel_path = translation_file_path(post.created_at, &post.slug, &t.language); let abs_path = data_dir.join(&rel_path); // Get body let body = if let Some(ref c) = t.content { c.clone() } else if abs_path.exists() { let file_content = fs::read_to_string(&abs_path)?; let (_fm, body) = read_translation_file(&file_content).map_err(|e| EngineError::Parse(e))?; body } else { String::new() }; let now = now_unix_ms(); t.status = PostStatus::Published; t.file_path = rel_path; t.published_at = Some(t.published_at.unwrap_or(now)); t.updated_at = now; let file_content = write_translation_file(t, &body); atomic_write_str(&abs_path, &file_content)?; let hash = content_hash(file_content.as_bytes()); t.checksum = Some(hash); t.content = None; qt::update_post_translation(conn, t)?; // Clear content after update (the update already set content=None via the struct) // but we also do an explicit clear to be safe conn.execute( "UPDATE post_translations SET content = NULL WHERE id = ?1", params![t.id], )?; Ok(()) } /// Index a post in FTS, gathering translation texts. fn fts_index_post(conn: &Connection, post: &Post) -> EngineResult<()> { let translations = qt::list_post_translations_by_post(conn, &post.id).unwrap_or_default(); let translation_data: Vec = translations .iter() .map(|t| fts::PostTranslationFts { title: t.title.clone(), excerpt: t.excerpt.clone(), content: t.content.clone(), language: t.language.clone(), }) .collect(); let lang = post.language.as_deref().unwrap_or("en"); fts::index_post( conn, &post.id, &post.title, post.excerpt.as_deref(), post.content.as_deref(), &post.tags, &post.categories, &translation_data, lang, )?; Ok(()) } /// Check if a file stem looks like a translation filename: `{slug}.{lang}` /// where lang is a 2-letter code. We look for a dot followed by exactly 2 lowercase letters. fn is_translation_filename(stem: &str) -> bool { if let Some(dot_pos) = stem.rfind('.') { let suffix = &stem[dot_pos + 1..]; suffix.len() == 2 && suffix.chars().all(|c| c.is_ascii_lowercase()) } else { false } } /// Rebuild a canonical post from a .md file. Returns true if created, false if updated. fn rebuild_canonical_post( conn: &Connection, data_dir: &Path, project_id: &str, path: &Path, ) -> EngineResult { let content = fs::read_to_string(path)?; let (fm, body) = read_post_file(&content).map_err(|e| EngineError::Parse(e))?; let rel_path = path .strip_prefix(data_dir) .unwrap_or(path) .to_string_lossy() .to_string(); let hash = content_hash(content.as_bytes()); let now = now_unix_ms(); let status = match fm.status.as_str() { "published" => PostStatus::Published, "archived" => PostStatus::Archived, _ => PostStatus::Draft, }; // Check if post exists in DB by id let existing = qp::get_post_by_id(conn, &fm.id); match existing { Ok(mut post) => { // Update existing post post.title = fm.title; post.slug = fm.slug; post.excerpt = fm.excerpt; post.content = if status == PostStatus::Published { None } else { Some(body) }; post.status = status; post.author = fm.author; post.language = fm.language; post.do_not_translate = fm.do_not_translate; post.template_slug = fm.template_slug; post.file_path = rel_path; post.checksum = Some(hash); post.tags = fm.tags; post.categories = fm.categories; post.created_at = fm.created_at; post.updated_at = now; post.published_at = fm.published_at; qp::update_post(conn, &post)?; Ok(false) } Err(_) => { // Insert new post let post = Post { id: fm.id, project_id: project_id.to_string(), title: fm.title, slug: fm.slug, excerpt: fm.excerpt, content: if status == PostStatus::Published { None } else { Some(body) }, status, author: fm.author, language: fm.language, do_not_translate: fm.do_not_translate, template_slug: fm.template_slug, file_path: rel_path, checksum: Some(hash), tags: fm.tags, categories: fm.categories, published_title: None, published_content: None, published_tags: None, published_categories: None, published_excerpt: None, created_at: fm.created_at, updated_at: now, published_at: fm.published_at, }; qp::insert_post(conn, &post)?; Ok(true) } } } /// Rebuild a translation from a .{lang}.md file. Returns true if created, false if updated. fn rebuild_translation( conn: &Connection, data_dir: &Path, project_id: &str, path: &Path, ) -> EngineResult { let content = fs::read_to_string(path)?; let (fm, body) = read_translation_file(&content).map_err(|e| EngineError::Parse(e))?; let rel_path = path .strip_prefix(data_dir) .unwrap_or(path) .to_string_lossy() .to_string(); let hash = content_hash(content.as_bytes()); let now = now_unix_ms(); // Check if parent post exists let parent = qp::get_post_by_id(conn, &fm.translation_for); if parent.is_err() { return Err(EngineError::NotFound(format!( "parent post '{}' not found for translation", fm.translation_for ))); } let parent = parent.unwrap(); // Determine status from parent let status = if parent.status == PostStatus::Published { PostStatus::Published } else { PostStatus::Draft }; // Check if translation exists let existing = qt::get_post_translation_by_post_and_language(conn, &fm.translation_for, &fm.language); match existing { Ok(mut t) => { t.title = fm.title; t.excerpt = fm.excerpt; t.content = if status == PostStatus::Published { None } else { Some(body) }; t.status = status; t.file_path = rel_path; t.checksum = Some(hash); t.updated_at = now; qt::update_post_translation(conn, &t)?; Ok(false) } Err(_) => { let t = PostTranslation { id: Uuid::new_v4().to_string(), project_id: project_id.to_string(), translation_for: fm.translation_for, language: fm.language, title: fm.title, excerpt: fm.excerpt, content: if status == PostStatus::Published { None } else { Some(body) }, status, file_path: rel_path, checksum: Some(hash), created_at: now, updated_at: now, published_at: if parent.published_at.is_some() { Some(now) } else { None }, }; qt::insert_post_translation(conn, &t)?; Ok(true) } } } #[cfg(test)] mod tests { use super::*; use crate::db::fts::ensure_fts_tables; use crate::db::queries::project::{insert_project, make_test_project}; use crate::db::Database; use tempfile::TempDir; fn setup() -> (Database, TempDir) { let mut db = Database::open_in_memory().unwrap(); db.migrate().unwrap(); ensure_fts_tables(db.conn()).unwrap(); insert_project(db.conn(), &make_test_project("p1", "blog")).unwrap(); let dir = TempDir::new().unwrap(); (db, dir) } #[test] fn create_post_generates_slug_and_draft() { let (db, dir) = setup(); let post = create_post( db.conn(), dir.path(), "p1", "Hello World", Some("body text"), vec!["rust".into()], vec!["tech".into()], Some("Alice"), Some("en"), None, ) .unwrap(); assert_eq!(post.slug, "hello-world"); assert_eq!(post.status, PostStatus::Draft); assert_eq!(post.title, "Hello World"); assert_eq!(post.tags, vec!["rust"]); assert_eq!(post.categories, vec!["tech"]); assert_eq!(post.content.as_deref(), Some("body text")); assert!(post.file_path.is_empty()); assert!(post.published_at.is_none()); // Verify it's in DB let fetched = qp::get_post_by_id(db.conn(), &post.id).unwrap(); assert_eq!(fetched.slug, "hello-world"); assert_eq!(fetched.status, PostStatus::Draft); } #[test] fn create_post_empty_title_uses_untitled() { let (db, dir) = setup(); let post = create_post( db.conn(), dir.path(), "p1", "", None, vec![], vec![], None, None, None, ) .unwrap(); assert_eq!(post.slug, "untitled"); } #[test] fn create_post_unique_slugs() { let (db, dir) = setup(); let p1 = create_post( db.conn(), dir.path(), "p1", "Dupe", None, vec![], vec![], None, None, None, ) .unwrap(); let p2 = create_post( db.conn(), dir.path(), "p1", "Dupe", None, vec![], vec![], None, None, None, ) .unwrap(); assert_eq!(p1.slug, "dupe"); assert_eq!(p2.slug, "dupe-2"); } #[test] fn update_post_changes_fields() { let (db, dir) = setup(); let post = create_post( db.conn(), dir.path(), "p1", "Original", Some("body"), vec![], vec![], None, None, None, ) .unwrap(); let updated = update_post( db.conn(), dir.path(), &post.id, Some("Updated Title"), Some("new-slug"), Some(Some("new excerpt")), Some("new body"), Some(vec!["tag1".into()]), Some(vec!["cat1".into()]), Some(Some("Bob")), Some(Some("de")), None, Some(true), ) .unwrap(); assert_eq!(updated.title, "Updated Title"); assert_eq!(updated.slug, "new-slug"); assert_eq!(updated.excerpt.as_deref(), Some("new excerpt")); assert_eq!(updated.content.as_deref(), Some("new body")); assert_eq!(updated.tags, vec!["tag1"]); assert_eq!(updated.categories, vec!["cat1"]); assert_eq!(updated.author.as_deref(), Some("Bob")); assert_eq!(updated.language.as_deref(), Some("de")); assert!(updated.do_not_translate); } #[test] fn update_post_slug_frozen_after_publish() { let (db, dir) = setup(); let post = create_post( db.conn(), dir.path(), "p1", "Frozen Slug", Some("body"), vec![], vec![], None, None, None, ) .unwrap(); // Publish the post publish_post(db.conn(), dir.path(), &post.id).unwrap(); // Archive so we can try updating archive_post(db.conn(), dir.path(), &post.id).unwrap(); // Try to change slug - should fail let result = update_post( db.conn(), dir.path(), &post.id, None, Some("different-slug"), None, None, None, None, None, None, None, None, ); assert!(result.is_err()); match result.unwrap_err() { EngineError::Conflict(_) => {} // expected other => panic!("expected Conflict, got: {other}"), } } #[test] fn publish_post_writes_file_and_clears_content() { let (db, dir) = setup(); let post = create_post( db.conn(), dir.path(), "p1", "Publish Me", Some("my body content"), vec!["rust".into()], vec!["tech".into()], None, None, None, ) .unwrap(); let published = publish_post(db.conn(), dir.path(), &post.id).unwrap(); // Status should be Published assert_eq!(published.status, PostStatus::Published); // Content should be None in DB let from_db = qp::get_post_by_id(db.conn(), &post.id).unwrap(); assert!(from_db.content.is_none()); // file_path should be set assert!(!from_db.file_path.is_empty()); // published_at should be set assert!(from_db.published_at.is_some()); // Published snapshot fields should be set assert_eq!(from_db.published_title.as_deref(), Some("Publish Me")); assert!(from_db.published_content.is_some()); // File should exist on disk let abs_path = dir.path().join(&from_db.file_path); assert!(abs_path.exists()); // File should contain frontmatter and body let file_content = fs::read_to_string(&abs_path).unwrap(); assert!(file_content.contains("my body content")); assert!(file_content.contains("Publish Me")); } #[test] fn publish_preserves_published_at_on_republish() { let (db, dir) = setup(); let post = create_post( db.conn(), dir.path(), "p1", "Republish", Some("body"), vec![], vec![], None, None, None, ) .unwrap(); let published = publish_post(db.conn(), dir.path(), &post.id).unwrap(); let original_published_at = published.published_at.unwrap(); // Archive then re-publish archive_post(db.conn(), dir.path(), &post.id).unwrap(); // Brief delay to ensure now() is different let republished = publish_post(db.conn(), dir.path(), &post.id).unwrap(); assert_eq!( republished.published_at.unwrap(), original_published_at, "published_at should be preserved on re-publish" ); } #[test] fn delete_post_removes_everything() { let (db, dir) = setup(); let post = create_post( db.conn(), dir.path(), "p1", "Delete Me", Some("body"), vec![], vec![], None, None, None, ) .unwrap(); // Add a translation upsert_translation( db.conn(), dir.path(), &post.id, "de", "German Title", None, Some("German body"), ) .unwrap(); // Publish to create files publish_post(db.conn(), dir.path(), &post.id).unwrap(); // Verify files exist let from_db = qp::get_post_by_id(db.conn(), &post.id).unwrap(); let post_file = dir.path().join(&from_db.file_path); assert!(post_file.exists()); // Delete delete_post(db.conn(), dir.path(), &post.id).unwrap(); // Post should be gone from DB assert!(qp::get_post_by_id(db.conn(), &post.id).is_err()); // Translations should be gone let trans = qt::list_post_translations_by_post(db.conn(), &post.id).unwrap(); assert!(trans.is_empty()); // Post file should be gone assert!(!post_file.exists()); } #[test] fn upsert_translation_create_and_update() { let (db, dir) = setup(); let post = create_post( db.conn(), dir.path(), "p1", "Parent", Some("body"), vec![], vec![], None, None, None, ) .unwrap(); // Create translation let t = upsert_translation( db.conn(), dir.path(), &post.id, "de", "German Title", Some("excerpt"), Some("Inhalt"), ) .unwrap(); assert_eq!(t.language, "de"); assert_eq!(t.title, "German Title"); // Update same translation let t2 = upsert_translation( db.conn(), dir.path(), &post.id, "de", "Neuer Titel", None, Some("Neuer Inhalt"), ) .unwrap(); assert_eq!(t2.id, t.id); assert_eq!(t2.title, "Neuer Titel"); } #[test] fn delete_translation_removes_file_and_db() { let (db, dir) = setup(); let post = create_post( db.conn(), dir.path(), "p1", "Parent", Some("body"), vec![], vec![], None, None, None, ) .unwrap(); let t = upsert_translation( db.conn(), dir.path(), &post.id, "de", "German", None, Some("Inhalt"), ) .unwrap(); // Publish to create files publish_post(db.conn(), dir.path(), &post.id).unwrap(); // Translation should have a file now let t_from_db = qt::get_post_translation_by_id(db.conn(), &t.id).unwrap(); assert!(!t_from_db.file_path.is_empty()); let t_file = dir.path().join(&t_from_db.file_path); assert!(t_file.exists()); // Delete translation delete_translation(db.conn(), dir.path(), &t.id).unwrap(); // Should be gone from DB assert!(qt::get_post_translation_by_id(db.conn(), &t.id).is_err()); // File should be gone assert!(!t_file.exists()); } #[test] fn rebuild_from_filesystem() { let (db, dir) = setup(); // Create fixture files let posts_dir = dir.path().join("posts").join("2024").join("01"); fs::create_dir_all(&posts_dir).unwrap(); // Write a canonical post let post_content = "---\n\ id: rebuild-post-1\n\ title: Rebuilt Post\n\ slug: rebuilt-post\n\ status: published\n\ createdAt: '2024-01-15T12:00:00.000Z'\n\ updatedAt: '2024-01-15T12:00:00.000Z'\n\ tags:\n - test\n\ categories: []\n\ publishedAt: '2024-01-15T12:00:00.000Z'\n\ ---\nHello from rebuild!\n"; fs::write(posts_dir.join("rebuilt-post.md"), post_content).unwrap(); // Write a translation let trans_content = "---\n\ translationFor: rebuild-post-1\n\ language: de\n\ title: Wiederhergestellter Beitrag\n\ ---\nHallo vom Rebuild!\n"; fs::write(posts_dir.join("rebuilt-post.de.md"), trans_content).unwrap(); // Run rebuild let report = rebuild_posts_from_filesystem(db.conn(), dir.path(), "p1").unwrap(); assert_eq!(report.posts_created, 1); assert_eq!(report.translations_created, 1); assert!(report.errors.is_empty(), "errors: {:?}", report.errors); // Verify post in DB let post = qp::get_post_by_id(db.conn(), "rebuild-post-1").unwrap(); assert_eq!(post.title, "Rebuilt Post"); assert_eq!(post.slug, "rebuilt-post"); assert_eq!(post.tags, vec!["test"]); // Verify translation in DB let trans = qt::get_post_translation_by_post_and_language( db.conn(), "rebuild-post-1", "de", ) .unwrap(); assert_eq!(trans.title, "Wiederhergestellter Beitrag"); // Run rebuild again - should update, not create let report2 = rebuild_posts_from_filesystem(db.conn(), dir.path(), "p1").unwrap(); assert_eq!(report2.posts_created, 0); assert_eq!(report2.posts_updated, 1); assert_eq!(report2.translations_created, 0); assert_eq!(report2.translations_updated, 1); } #[test] fn is_translation_filename_works() { assert!(is_translation_filename("hello.de")); assert!(is_translation_filename("hello.en")); assert!(is_translation_filename("my-post.fr")); assert!(!is_translation_filename("hello")); // Note: "md" is 2 lowercase letters so is_translation_filename("hello.md") = true. // In practice this never arises because file_stem() of "hello.md" is "hello", // not "hello.md". Only "hello.md.md" would produce stem "hello.md". assert!(!is_translation_filename("hello.123")); assert!(!is_translation_filename("hello.D")); } #[test] fn do_not_translate_guard_rejects_translation() { let (db, dir) = setup(); let post = create_post( db.conn(), dir.path(), "p1", "No Translate", Some("body"), vec![], vec![], None, None, None, ).unwrap(); // Set do_not_translate update_post( db.conn(), dir.path(), &post.id, None, None, None, None, None, None, None, None, None, Some(true), ).unwrap(); let result = upsert_translation( db.conn(), dir.path(), &post.id, "de", "German", None, Some("Inhalt"), ); assert!(result.is_err()); match result.unwrap_err() { EngineError::Validation(_) => {} other => panic!("expected Validation, got: {other}"), } } #[test] fn update_post_slug_uniqueness_enforced() { let (db, dir) = setup(); create_post( db.conn(), dir.path(), "p1", "First", Some("body"), vec![], vec![], None, None, None, ).unwrap(); let second = create_post( db.conn(), dir.path(), "p1", "Second", Some("body"), vec![], vec![], None, None, None, ).unwrap(); let result = update_post( db.conn(), dir.path(), &second.id, None, Some("first"), None, None, None, None, None, None, None, None, ); assert!(result.is_err()); } #[test] fn update_published_post_transitions_to_draft() { let (db, dir) = setup(); let post = create_post( db.conn(), dir.path(), "p1", "Published", Some("body"), vec![], vec![], None, None, None, ).unwrap(); publish_post(db.conn(), dir.path(), &post.id).unwrap(); // Archive then update to test auto-draft archive_post(db.conn(), dir.path(), &post.id).unwrap(); // Re-publish let _ = publish_post(db.conn(), dir.path(), &post.id).unwrap(); // Now update the published post let updated = update_post( db.conn(), dir.path(), &post.id, Some("New Title"), None, None, Some("new body"), None, None, None, None, None, None, ).unwrap(); assert_eq!(updated.status, PostStatus::Draft); } #[test] fn canonical_url_format() { // 2005-11-13T12:00:00.000Z = 1131883200000 let url = canonical_url(1131883200000, "esmeralda"); assert_eq!(url, "/2005/11/13/esmeralda"); } #[test] fn publish_post_already_published_rejected() { let (db, dir) = setup(); let post = create_post( db.conn(), dir.path(), "p1", "Double Pub", Some("body"), vec![], vec![], None, None, None, ).unwrap(); publish_post(db.conn(), dir.path(), &post.id).unwrap(); let result = publish_post(db.conn(), dir.path(), &post.id); assert!(result.is_err()); match result.unwrap_err() { EngineError::Conflict(_) => {} other => panic!("expected Conflict, got: {other}"), } } #[test] fn publish_post_updates_link_graph() { let (db, dir) = setup(); // Create target post first let target = create_post( db.conn(), dir.path(), "p1", "Target Post", Some("target body"), vec![], vec![], None, None, None, ).unwrap(); publish_post(db.conn(), dir.path(), &target.id).unwrap(); // Create source post with a link to target let target_url = canonical_url(target.created_at, &target.slug); let body = format!("Check out [this post]({target_url}) for more."); let source = create_post( db.conn(), dir.path(), "p1", "Source Post", Some(&body), vec![], vec![], None, None, None, ).unwrap(); publish_post(db.conn(), dir.path(), &source.id).unwrap(); // Verify link was created let links = ql::list_links_by_source(db.conn(), &source.id).unwrap(); assert_eq!(links.len(), 1); assert_eq!(links[0].target_post_id, target.id); } #[test] fn archive_from_published_status() { let (db, dir) = setup(); let post = create_post( db.conn(), dir.path(), "p1", "To Archive", Some("body"), vec![], vec![], None, None, None, ).unwrap(); publish_post(db.conn(), dir.path(), &post.id).unwrap(); archive_post(db.conn(), dir.path(), &post.id).unwrap(); let from_db = qp::get_post_by_id(db.conn(), &post.id).unwrap(); assert_eq!(from_db.status, PostStatus::Archived); } #[test] fn parse_post_links_extracts_canonical_urls() { let content = "See [my post](/2024/01/15/hello-world) and [another](/2024/02/01/test-post/) for more."; let links = parse_post_links(content); assert_eq!(links.len(), 2); assert_eq!(links[0].0, "hello-world"); assert_eq!(links[0].1, "my post"); assert_eq!(links[1].0, "test-post"); } }