use std::collections::HashSet; use std::fs; use std::path::Path; use crate::db::DbConnection as Connection; use walkdir::WalkDir; use crate::db::queries::embedding as qe; use crate::db::queries::media as qm; use crate::db::queries::media_translation as qmt; use crate::db::queries::post as qp; use crate::db::queries::post_translation as qt; use crate::db::queries::project as qproject; use crate::db::queries::script as qs; use crate::db::queries::template as qtpl; use crate::engine::{EngineError, EngineResult}; use crate::model::{Media, MediaTranslation, Post, PostTranslation, Script, Template}; use crate::util::frontmatter::{ ScriptFrontmatter, TemplateFrontmatter, read_post_file, read_script_file, read_template_file, read_translation_file, write_script_file, write_template_file, write_translation_file, }; use crate::util::sidecar::{ MediaSidecar, MediaTranslationSidecar, read_sidecar, read_translation_sidecar, }; use crate::util::{atomic_write_str, media_translation_sidecar_path}; /// A single field difference. #[derive(Debug, Clone)] pub struct DiffField { pub field_name: String, pub db_value: String, pub file_value: String, } /// Diff for a single entity. #[derive(Debug, Clone)] pub struct EntityDiff { pub entity_type: String, pub entity_id: String, pub file_path: String, pub fields: Vec, } /// An orphan file (exists on disk but not in DB, or vice versa). #[derive(Debug, Clone)] pub struct OrphanFile { pub file_path: String, pub reason: String, } /// Complete diff report. #[derive(Debug, Clone, Default)] pub struct DiffReport { pub diffs: Vec, pub orphans: Vec, pub errors: Vec, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum RepairDirection { FileToDatabase, DatabaseToFile, } /// Compare DB state vs filesystem files and report all differences. /// /// This function does NOT modify anything -- it only reports differences. pub fn compute_metadata_diff( conn: &Connection, data_dir: &Path, project_id: &str, ) -> EngineResult { let mut report = DiffReport::default(); if let Ok(project) = qproject::get_project_by_id(conn, project_id) { match diff_project(data_dir, &project) { Ok(Some(diff)) => report.diffs.push(diff), Ok(None) => {} Err(error) => report.errors.push(format!("project {project_id}: {error}")), } } // 1. Diff posts let posts = qp::list_posts_by_project(conn, project_id)?; for post in &posts { if post.file_path.is_empty() { continue; } match diff_post(data_dir, post) { Ok(Some(d)) => report.diffs.push(d), Ok(None) => {} Err(e) => report.errors.push(format!("post {}: {e}", post.id)), } } // 2. Diff translations for post in &posts { let translations = qt::list_post_translations_by_post(conn, &post.id)?; for t in &translations { if t.file_path.is_empty() { continue; } match diff_translation(data_dir, t) { Ok(Some(d)) => report.diffs.push(d), Ok(None) => {} Err(e) => report.errors.push(format!("translation {}: {e}", t.id)), } } } // 3. Diff media let media_items = qm::list_media_by_project(conn, project_id)?; for m in &media_items { if m.sidecar_path.is_empty() { continue; } let translations = qmt::list_media_translations_by_media(conn, &m.id)?; for translation in &translations { match diff_media_translation(data_dir, m, translation) { Ok(Some(diff)) => report.diffs.push(diff), Ok(None) => {} Err(error) => report .errors .push(format!("media translation {}: {error}", translation.id)), } } match diff_media(data_dir, m) { Ok(Some(d)) => report.diffs.push(d), Ok(None) => {} Err(e) => report.errors.push(format!("media {}: {e}", m.id)), } } // 4. Diff templates let templates = qtpl::list_templates_by_project(conn, project_id)?; for t in &templates { if t.file_path.is_empty() { continue; } match diff_template(data_dir, t) { Ok(Some(d)) => report.diffs.push(d), Ok(None) => {} Err(e) => report.errors.push(format!("template {}: {e}", t.id)), } } // 5. Diff scripts let scripts = qs::list_scripts_by_project(conn, project_id)?; for s in &scripts { if s.file_path.is_empty() { continue; } match diff_script(data_dir, s) { Ok(Some(d)) => report.diffs.push(d), Ok(None) => {} Err(e) => report.errors.push(format!("script {}: {e}", s.id)), } } // 6. Detect orphans if crate::engine::meta::read_project_json(data_dir) .is_ok_and(|metadata| metadata.semantic_similarity_enabled) { let service = crate::engine::embedding::EmbeddingService::production(conn, data_dir); let keys = qe::list_keys(conn, project_id)? .into_iter() .map(|key| (key.post_id.clone(), key)) .collect::>(); for post in &posts { let expected = service.content_hash_for_post(post)?; let key = keys.get(&post.id); let current_hash = key.map(|key| key.content_hash.as_str()).unwrap_or(""); let vector_status = key .filter(|key| crate::engine::embedding::decode_vector(&key.vector).is_ok()) .map(|_| "ready") .unwrap_or("missing"); if current_hash != expected || vector_status != "ready" { let mut fields = Vec::new(); if current_hash != expected { fields.push(DiffField { field_name: "content_hash".into(), db_value: current_hash.into(), file_value: expected, }); } if vector_status != "ready" { fields.push(DiffField { field_name: "embedding".into(), db_value: vector_status.into(), file_value: "ready".into(), }); } report.diffs.push(EntityDiff { entity_type: "embedding".into(), entity_id: post.id.clone(), file_path: format!("projects/{project_id}/embeddings.usearch"), fields, }); } } } // 7. Detect orphans let orphans = detect_orphan_files(conn, data_dir, project_id)?; report.orphans = orphans; Ok(report) } /// Resolve one reported difference in the selected direction. pub fn repair_metadata_diff_item( conn: &Connection, data_dir: &Path, project_id: &str, direction: RepairDirection, item: &EntityDiff, ) -> EngineResult<()> { match direction { RepairDirection::FileToDatabase => { let path = data_dir.join(&item.file_path); match item.entity_type.as_str() { "project" => sync_project_from_file(conn, data_dir, project_id)?, "post" => { crate::engine::post::rebuild_canonical_post(conn, data_dir, project_id, &path)?; } "post_translation" => { crate::engine::post::rebuild_translation(conn, data_dir, project_id, &path)?; } "media" => { crate::engine::media::rebuild_canonical_media( conn, data_dir, project_id, &path, )?; } "media_translation" => { crate::engine::media::rebuild_translation_sidecar( conn, data_dir, project_id, &path, )?; } "script" => { crate::engine::script_rebuild::rebuild_single_script( conn, data_dir, project_id, &path, )?; } "template" => { crate::engine::template_rebuild::rebuild_single_template( conn, data_dir, project_id, &path, )?; } "embedding" => { let post = qp::get_post_by_id(conn, &item.entity_id)?; crate::engine::embedding::EmbeddingService::production(conn, data_dir) .sync_post(&post)?; } other => return unsupported_repair(other), } } RepairDirection::DatabaseToFile => match item.entity_type.as_str() { "project" => rewrite_project_from_database(conn, data_dir, project_id)?, "post" => rewrite_post_from_database(conn, data_dir, &item.entity_id)?, "post_translation" => { rewrite_post_translation_from_database(conn, data_dir, &item.entity_id)? } "media" => rewrite_media_from_database(conn, data_dir, &item.entity_id)?, "media_translation" => rewrite_media_translation_from_database( conn, data_dir, project_id, &item.entity_id, )?, "script" => rewrite_script_from_database(conn, data_dir, &item.entity_id)?, "template" => rewrite_template_from_database(conn, data_dir, &item.entity_id)?, "embedding" => crate::engine::embedding::EmbeddingService::production(conn, data_dir) .flush_project(project_id)?, other => return unsupported_repair(other), }, } Ok(()) } /// Import one content file reported as a filesystem orphan by /// [`compute_metadata_diff`]. The normal per-entity rebuild paths remain the /// sole parsers and writers for these formats. pub fn import_orphan_file( conn: &Connection, data_dir: &Path, project_id: &str, orphan: &OrphanFile, ) -> EngineResult<()> { if orphan.reason != "file_without_db_entry" { return Err(EngineError::Validation(format!( "cannot import an orphan that is absent from the filesystem: {}", orphan.file_path ))); } let path = data_dir.join(&orphan.file_path); let canonical_data_dir = data_dir.canonicalize()?; let canonical_path = path.canonicalize()?; if !canonical_path.starts_with(&canonical_data_dir) { return Err(EngineError::Validation( "orphan path is outside the active project".into(), )); } if orphan.file_path.starts_with("posts/") && orphan.file_path.ends_with(".md") { let stem = path .file_stem() .and_then(|value| value.to_str()) .unwrap_or(""); if crate::engine::post::is_translation_filename(stem) { crate::engine::post::rebuild_translation(conn, data_dir, project_id, &path)?; } else { crate::engine::post::rebuild_canonical_post(conn, data_dir, project_id, &path)?; } } else if orphan.file_path.starts_with("media/") && orphan.file_path.ends_with(".meta") { let raw = fs::read_to_string(&path)?; if read_translation_sidecar(&raw).is_ok() { crate::engine::media::rebuild_translation_sidecar(conn, data_dir, project_id, &path)?; } else { crate::engine::media::rebuild_canonical_media(conn, data_dir, project_id, &path)?; } } else if orphan.file_path.starts_with("scripts/") && orphan.file_path.ends_with(".lua") { crate::engine::script_rebuild::rebuild_single_script(conn, data_dir, project_id, &path)?; } else if orphan.file_path.starts_with("templates/") && orphan.file_path.ends_with(".liquid") { crate::engine::template_rebuild::rebuild_single_template( conn, data_dir, project_id, &path, )?; } else { return Err(EngineError::Validation(format!( "unsupported orphan file: {}", orphan.file_path ))); } Ok(()) } fn diff_project( data_dir: &Path, project: &crate::model::Project, ) -> EngineResult> { let metadata = crate::engine::meta::read_project_json(data_dir)?; let mut fields = Vec::new(); compare_field(&mut fields, "name", &project.name, &metadata.name); compare_field( &mut fields, "description", project.description.as_deref().unwrap_or(""), metadata.description.as_deref().unwrap_or(""), ); Ok((!fields.is_empty()).then_some(EntityDiff { entity_type: "project".into(), entity_id: project.id.clone(), file_path: "meta/project.json".into(), fields, })) } fn sync_project_from_file( conn: &Connection, data_dir: &Path, project_id: &str, ) -> EngineResult<()> { crate::engine::meta::sync_project_from_file(conn, data_dir, project_id) } fn rewrite_project_from_database( conn: &Connection, data_dir: &Path, project_id: &str, ) -> EngineResult<()> { let project = qproject::get_project_by_id(conn, project_id)?; let mut metadata = crate::engine::meta::read_project_json(data_dir)?; metadata.name = project.name; metadata.description = project.description; crate::engine::meta::write_project_json(data_dir, &metadata) } fn unsupported_repair(entity_type: &str) -> EngineResult { Err(EngineError::Validation(format!( "unsupported metadata diff entity type: {entity_type}" ))) } fn rewrite_post_from_database( conn: &Connection, data_dir: &Path, entity_id: &str, ) -> EngineResult<()> { let post = qp::get_post_by_id(conn, entity_id)?; crate::engine::post::rewrite_post_file_from_database(data_dir, &post) } fn rewrite_post_translation_from_database( conn: &Connection, data_dir: &Path, entity_id: &str, ) -> EngineResult<()> { let translation = qt::get_post_translation_by_id(conn, entity_id)?; let path = data_dir.join(&translation.file_path); let body = fs::read_to_string(&path) .ok() .and_then(|content| read_translation_file(&content).ok().map(|(_, body)| body)) .unwrap_or_default(); atomic_write_str(&path, &write_translation_file(&translation, &body))?; Ok(()) } fn rewrite_media_from_database( conn: &Connection, data_dir: &Path, entity_id: &str, ) -> EngineResult<()> { let media = qm::get_media_by_id(conn, entity_id)?; let linked = crate::db::queries::post_media::list_post_media_by_media(conn, entity_id) .unwrap_or_default(); let post_ids = linked .into_iter() .map(|link| link.post_id) .collect::>(); atomic_write_str( &data_dir.join(&media.sidecar_path), &MediaSidecar::from_media(&media, &post_ids).to_string(), )?; Ok(()) } fn rewrite_media_translation_from_database( conn: &Connection, data_dir: &Path, project_id: &str, entity_id: &str, ) -> EngineResult<()> { let media_items = qm::list_media_by_project(conn, project_id)?; for media in media_items { if let Some(translation) = qmt::list_media_translations_by_media(conn, &media.id)? .into_iter() .find(|translation| translation.id == entity_id) { let sidecar = MediaTranslationSidecar { translation_for: translation.translation_for, language: translation.language.clone(), title: translation.title, alt: translation.alt, caption: translation.caption, }; let path = media_translation_sidecar_path(&media.file_path, &translation.language); atomic_write_str(&data_dir.join(path), &sidecar.to_string())?; return Ok(()); } } Err(EngineError::NotFound(format!( "media translation {entity_id}" ))) } fn rewrite_script_from_database( conn: &Connection, data_dir: &Path, entity_id: &str, ) -> EngineResult<()> { let script = qs::get_script_by_id(conn, entity_id)?; let path = data_dir.join(&script.file_path); let body = fs::read_to_string(&path) .ok() .and_then(|content| read_script_file(&content).ok().map(|(_, body)| body)) .or(script.content.clone()) .unwrap_or_default(); let frontmatter = ScriptFrontmatter { id: script.id, project_id: Some(script.project_id), slug: script.slug, title: script.title, kind: script.kind.as_str().to_owned(), entrypoint: script.entrypoint, enabled: script.enabled, version: script.version, created_at: script.created_at, updated_at: script.updated_at, }; atomic_write_str(&path, &write_script_file(&frontmatter, &body))?; Ok(()) } fn rewrite_template_from_database( conn: &Connection, data_dir: &Path, entity_id: &str, ) -> EngineResult<()> { let template = qtpl::get_template_by_id(conn, entity_id)?; let path = data_dir.join(&template.file_path); let body = fs::read_to_string(&path) .ok() .and_then(|content| read_template_file(&content).ok().map(|(_, body)| body)) .or(template.content.clone()) .unwrap_or_default(); let frontmatter = TemplateFrontmatter { id: template.id, project_id: Some(template.project_id), slug: template.slug, title: template.title, kind: template.kind.as_str().to_owned(), enabled: template.enabled, version: template.version, created_at: template.created_at, updated_at: template.updated_at, }; atomic_write_str(&path, &write_template_file(&frontmatter, &body))?; Ok(()) } // --- Internal helpers --- fn opt_to_str(opt: &Option) -> String { opt.clone().unwrap_or_default() } fn bool_to_str(b: bool) -> String { if b { "true".to_string() } else { "false".to_string() } } fn tags_to_json(tags: &[String]) -> String { serde_json::to_string(tags).unwrap_or_else(|_| "[]".to_string()) } fn compare_field(fields: &mut Vec, name: &str, db_val: &str, file_val: &str) { if db_val != file_val { fields.push(DiffField { field_name: name.to_string(), db_value: db_val.to_string(), file_value: file_val.to_string(), }); } } fn diff_post(data_dir: &Path, post: &Post) -> EngineResult> { let abs_path = data_dir.join(&post.file_path); if !abs_path.exists() { // Will be caught by orphan detection return Ok(None); } let content = fs::read_to_string(&abs_path)?; let (fm, _body) = read_post_file(&content).map_err(EngineError::Parse)?; let mut fields = Vec::new(); compare_field(&mut fields, "title", &post.title, &fm.title); compare_field(&mut fields, "slug", &post.slug, &fm.slug); compare_field(&mut fields, "status", post.status.as_str(), &fm.status); compare_field( &mut fields, "tags", &tags_to_json(&post.tags), &tags_to_json(&fm.tags), ); compare_field( &mut fields, "categories", &tags_to_json(&post.categories), &tags_to_json(&fm.categories), ); compare_field( &mut fields, "excerpt", &opt_to_str(&post.excerpt), &opt_to_str(&fm.excerpt), ); compare_field( &mut fields, "author", &opt_to_str(&post.author), &opt_to_str(&fm.author), ); compare_field( &mut fields, "language", &opt_to_str(&post.language), &opt_to_str(&fm.language), ); compare_field( &mut fields, "doNotTranslate", &bool_to_str(post.do_not_translate), &bool_to_str(fm.do_not_translate), ); compare_field( &mut fields, "templateSlug", &opt_to_str(&post.template_slug), &opt_to_str(&fm.template_slug), ); compare_field( &mut fields, "createdAt", &post.created_at.to_string(), &fm.created_at.to_string(), ); compare_field( &mut fields, "updatedAt", &post.updated_at.to_string(), &fm.updated_at.to_string(), ); compare_field( &mut fields, "publishedAt", &post.published_at.map(|v| v.to_string()).unwrap_or_default(), &fm.published_at.map(|v| v.to_string()).unwrap_or_default(), ); if fields.is_empty() { Ok(None) } else { Ok(Some(EntityDiff { entity_type: "post".to_string(), entity_id: post.id.clone(), file_path: post.file_path.clone(), fields, })) } } fn diff_translation(data_dir: &Path, t: &PostTranslation) -> EngineResult> { let abs_path = data_dir.join(&t.file_path); if !abs_path.exists() { return Ok(None); } let content = fs::read_to_string(&abs_path)?; let (fm, _body) = read_translation_file(&content).map_err(EngineError::Parse)?; let mut fields = Vec::new(); compare_field( &mut fields, "translationFor", &t.translation_for, &fm.translation_for, ); compare_field(&mut fields, "language", &t.language, &fm.language); compare_field(&mut fields, "title", &t.title, &fm.title); compare_field( &mut fields, "excerpt", &opt_to_str(&t.excerpt), &opt_to_str(&fm.excerpt), ); if fields.is_empty() { Ok(None) } else { Ok(Some(EntityDiff { entity_type: "post_translation".to_string(), entity_id: t.id.clone(), file_path: t.file_path.clone(), fields, })) } } fn diff_media(data_dir: &Path, media: &Media) -> EngineResult> { let abs_path = data_dir.join(&media.sidecar_path); if !abs_path.exists() { return Ok(None); } let content = fs::read_to_string(&abs_path)?; let sc = read_sidecar(&content).map_err(EngineError::Parse)?; let mut fields = Vec::new(); compare_field( &mut fields, "title", &opt_to_str(&media.title), &opt_to_str(&sc.title), ); compare_field( &mut fields, "alt", &opt_to_str(&media.alt), &opt_to_str(&sc.alt), ); compare_field( &mut fields, "caption", &opt_to_str(&media.caption), &opt_to_str(&sc.caption), ); compare_field( &mut fields, "author", &opt_to_str(&media.author), &opt_to_str(&sc.author), ); compare_field( &mut fields, "tags", &tags_to_json(&media.tags), &tags_to_json(&sc.tags), ); compare_field( &mut fields, "language", &opt_to_str(&media.language), &opt_to_str(&sc.language), ); if fields.is_empty() { Ok(None) } else { Ok(Some(EntityDiff { entity_type: "media".to_string(), entity_id: media.id.clone(), file_path: media.sidecar_path.clone(), fields, })) } } fn diff_media_translation( data_dir: &Path, media: &Media, translation: &MediaTranslation, ) -> EngineResult> { let relative_path = media_translation_sidecar_path(&media.file_path, &translation.language); let absolute_path = data_dir.join(&relative_path); if !absolute_path.is_file() { return Ok(None); } let sidecar = read_translation_sidecar(&fs::read_to_string(&absolute_path)?) .map_err(EngineError::Parse)?; let mut fields = Vec::new(); compare_field( &mut fields, "translationFor", &translation.translation_for, &sidecar.translation_for, ); compare_field( &mut fields, "language", &translation.language, &sidecar.language, ); compare_field( &mut fields, "title", &opt_to_str(&translation.title), &opt_to_str(&sidecar.title), ); compare_field( &mut fields, "alt", &opt_to_str(&translation.alt), &opt_to_str(&sidecar.alt), ); compare_field( &mut fields, "caption", &opt_to_str(&translation.caption), &opt_to_str(&sidecar.caption), ); Ok((!fields.is_empty()).then_some(EntityDiff { entity_type: "media_translation".into(), entity_id: translation.id.clone(), file_path: relative_path, fields, })) } fn diff_template(data_dir: &Path, tpl: &Template) -> EngineResult> { let abs_path = data_dir.join(&tpl.file_path); if !abs_path.exists() { return Ok(None); } let content = fs::read_to_string(&abs_path)?; let (fm, _body) = read_template_file(&content).map_err(EngineError::Parse)?; let mut fields = Vec::new(); compare_field(&mut fields, "title", &tpl.title, &fm.title); compare_field(&mut fields, "kind", tpl.kind.as_str(), &fm.kind); compare_field( &mut fields, "enabled", &bool_to_str(tpl.enabled), &bool_to_str(fm.enabled), ); compare_field( &mut fields, "version", &tpl.version.to_string(), &fm.version.to_string(), ); if fields.is_empty() { Ok(None) } else { Ok(Some(EntityDiff { entity_type: "template".to_string(), entity_id: tpl.id.clone(), file_path: tpl.file_path.clone(), fields, })) } } fn diff_script(data_dir: &Path, script: &Script) -> EngineResult> { let abs_path = data_dir.join(&script.file_path); if !abs_path.exists() { return Ok(None); } let content = fs::read_to_string(&abs_path)?; let (fm, _body) = read_script_file(&content).map_err(EngineError::Parse)?; let mut fields = Vec::new(); compare_field(&mut fields, "title", &script.title, &fm.title); compare_field(&mut fields, "kind", script.kind.as_str(), &fm.kind); compare_field( &mut fields, "entrypoint", &script.entrypoint, &fm.entrypoint, ); compare_field( &mut fields, "enabled", &bool_to_str(script.enabled), &bool_to_str(fm.enabled), ); compare_field( &mut fields, "version", &script.version.to_string(), &fm.version.to_string(), ); if fields.is_empty() { Ok(None) } else { Ok(Some(EntityDiff { entity_type: "script".to_string(), entity_id: script.id.clone(), file_path: script.file_path.clone(), fields, })) } } fn detect_orphan_files( conn: &Connection, data_dir: &Path, project_id: &str, ) -> EngineResult> { let mut orphans = Vec::new(); // Collect all known file paths from DB let mut db_file_paths: HashSet = HashSet::new(); let posts = qp::list_posts_by_project(conn, project_id)?; for post in &posts { if !post.file_path.is_empty() { db_file_paths.insert(post.file_path.clone()); } let translations = qt::list_post_translations_by_post(conn, &post.id)?; for t in &translations { if !t.file_path.is_empty() { db_file_paths.insert(t.file_path.clone()); } } } let media_items = qm::list_media_by_project(conn, project_id)?; for m in &media_items { if !m.file_path.is_empty() { db_file_paths.insert(m.file_path.clone()); } if !m.sidecar_path.is_empty() { db_file_paths.insert(m.sidecar_path.clone()); } for translation in qmt::list_media_translations_by_media(conn, &m.id)? { db_file_paths.insert(media_translation_sidecar_path( &m.file_path, &translation.language, )); } } let templates = qtpl::list_templates_by_project(conn, project_id)?; for t in &templates { if !t.file_path.is_empty() { db_file_paths.insert(t.file_path.clone()); } } let scripts = qs::list_scripts_by_project(conn, project_id)?; for s in &scripts { if !s.file_path.is_empty() { db_file_paths.insert(s.file_path.clone()); } } // Walk filesystem directories and find orphans let dirs_to_walk = ["posts", "media", "templates", "scripts"]; for dir_name in &dirs_to_walk { let dir_path = data_dir.join(dir_name); if !dir_path.exists() { continue; } for entry in WalkDir::new(&dir_path).into_iter().filter_map(|e| e.ok()) { let path = entry.path(); if !path.is_file() { continue; } // Compute relative path from data_dir let rel_path = path .strip_prefix(data_dir) .unwrap_or(path) .to_string_lossy() .to_string(); // Skip non-content files (thumbnails, etc.) let ext = path.extension().and_then(|e| e.to_str()).unwrap_or(""); let is_content_file = match *dir_name { "posts" => ext == "md", "media" => ext == "meta", "templates" => ext == "liquid", "scripts" => ext == "lua", _ => false, }; if !is_content_file { continue; } if !db_file_paths.contains(&rel_path) { orphans.push(OrphanFile { file_path: rel_path, reason: "file_without_db_entry".to_string(), }); } } } // Check DB entries whose file_path doesn't exist on disk for post in &posts { if !post.file_path.is_empty() { let abs = data_dir.join(&post.file_path); if !abs.exists() { orphans.push(OrphanFile { file_path: post.file_path.clone(), reason: "db_entry_without_file".to_string(), }); } } let translations = qt::list_post_translations_by_post(conn, &post.id)?; for t in &translations { if !t.file_path.is_empty() { let abs = data_dir.join(&t.file_path); if !abs.exists() { orphans.push(OrphanFile { file_path: t.file_path.clone(), reason: "db_entry_without_file".to_string(), }); } } } } for m in &media_items { if !m.sidecar_path.is_empty() { let abs = data_dir.join(&m.sidecar_path); if !abs.exists() { orphans.push(OrphanFile { file_path: m.sidecar_path.clone(), reason: "db_entry_without_file".to_string(), }); } } for translation in qmt::list_media_translations_by_media(conn, &m.id)? { let path = media_translation_sidecar_path(&m.file_path, &translation.language); if !data_dir.join(&path).is_file() { orphans.push(OrphanFile { file_path: path, reason: "db_entry_without_file".to_string(), }); } } } for t in &templates { if !t.file_path.is_empty() { let abs = data_dir.join(&t.file_path); if !abs.exists() { orphans.push(OrphanFile { file_path: t.file_path.clone(), reason: "db_entry_without_file".to_string(), }); } } } for s in &scripts { if !s.file_path.is_empty() { let abs = data_dir.join(&s.file_path); if !abs.exists() { orphans.push(OrphanFile { file_path: s.file_path.clone(), reason: "db_entry_without_file".to_string(), }); } } } Ok(orphans) } #[cfg(test)] mod tests { use super::*; use crate::db::Database; use crate::db::fts::ensure_fts_tables; use crate::db::queries::media::insert_media; use crate::db::queries::post::insert_post; use crate::db::queries::project::{insert_project, make_test_project}; use crate::db::queries::script::insert_script; use crate::db::queries::template::insert_template; use crate::engine::post::{archive_post, create_post, publish_post}; use crate::model::{ Media, Post, PostStatus, ProjectMetadata, Script, ScriptKind, ScriptStatus, Template, TemplateKind, TemplateStatus, }; use crate::util::frontmatter::{ ScriptFrontmatter, TemplateFrontmatter, write_script_file, write_template_file, }; use crate::util::sidecar::MediaSidecar; use tempfile::TempDir; fn setup() -> (Database, TempDir) { let 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 no_diffs_for_clean_state() { let (db, dir) = setup(); // Create and publish a post via the engine let post = create_post( db.conn(), dir.path(), "p1", "Clean Post", Some("body text"), vec!["rust".into()], vec!["tech".into()], Some("Alice"), Some("en"), None, ) .unwrap(); publish_post(db.conn(), dir.path(), &post.id).unwrap(); let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap(); // Published post: file was just written by publish, DB and file should match. // The only expected diff is updatedAt because publish sets it to now() in the DB // but the file was written with that same now(). They should match. // However, publish_post updates updatedAt multiple times, so the DB value may // differ from the file. Filter to only non-updatedAt diffs. let non_time_diffs: Vec<_> = report .diffs .iter() .filter(|d| d.fields.iter().any(|f| f.field_name != "updatedAt")) .collect(); assert!( non_time_diffs.is_empty(), "expected no non-timestamp diffs, got: {non_time_diffs:?}" ); assert!(report.orphans.is_empty(), "orphans: {:?}", report.orphans); } #[test] fn archived_published_post_reports_only_bds2_frontmatter_drift() { let (db, dir) = setup(); let post = create_post( db.conn(), dir.path(), "p1", "Archived Post", Some("body text"), vec!["rust".into()], vec!["tech".into()], Some("Alice"), Some("en"), None, ) .unwrap(); let published = publish_post(db.conn(), dir.path(), &post.id).unwrap(); let path = dir.path().join(&published.file_path); let original_file = fs::read(&path).unwrap(); archive_post(db.conn(), dir.path(), &post.id).unwrap(); let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap(); assert_eq!(fs::read(path).unwrap(), original_file); assert!( report .errors .iter() .all(|error| !error.starts_with("post ")), "post diff errors: {:?}", report.errors ); assert!(report.orphans.is_empty()); let diff = report .diffs .iter() .find(|diff| diff.entity_type == "post" && diff.entity_id == post.id) .expect("archived database status must differ from untouched published file"); assert!(diff.fields.iter().any(|field| { field.field_name == "status" && field.db_value == "archived" && field.file_value == "published" })); assert!( diff.fields .iter() .all(|field| matches!(field.field_name.as_str(), "status" | "updatedAt")), "unexpected archived-post drift: {:?}", diff.fields ); } #[test] fn detects_title_drift_in_post() { let (db, dir) = setup(); let post = create_post( db.conn(), dir.path(), "p1", "Original Title", Some("body"), vec!["rust".into()], vec!["tech".into()], None, None, None, ) .unwrap(); let published = publish_post(db.conn(), dir.path(), &post.id).unwrap(); // Manually edit the .md file to change the title let abs_path = dir.path().join(&published.file_path); let content = fs::read_to_string(&abs_path).unwrap(); let modified = content.replace("Original Title", "Tampered Title"); fs::write(&abs_path, modified).unwrap(); let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap(); // Should detect title drift let title_diffs: Vec<_> = report .diffs .iter() .filter(|d| d.entity_type == "post") .flat_map(|d| d.fields.iter()) .filter(|f| f.field_name == "title") .collect(); assert_eq!(title_diffs.len(), 1); assert_eq!(title_diffs[0].db_value, "Original Title"); assert_eq!(title_diffs[0].file_value, "Tampered Title"); } #[test] fn detects_missing_project_description() { let (db, dir) = setup(); crate::engine::meta::write_project_json( dir.path(), &ProjectMetadata { name: "Project p1".into(), description: None, public_url: None, main_language: None, default_author: None, max_posts_per_page: 50, image_import_concurrency: 4, blogmark_category: None, pico_theme: None, semantic_similarity_enabled: false, blog_languages: vec![], }, ) .unwrap(); let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap(); let project = report .diffs .iter() .find(|item| item.entity_type == "project") .unwrap(); assert!(project.fields.iter().any(|field| { field.field_name == "description" && field.db_value == "A test project" && field.file_value.is_empty() })); } #[test] fn ignores_python_scripts_during_orphan_scan() { let (db, dir) = setup(); let scripts = dir.path().join("scripts"); fs::create_dir_all(&scripts).unwrap(); fs::write(scripts.join("legacy.py"), "print('legacy')").unwrap(); let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap(); assert!( report .orphans .iter() .all(|orphan| !orphan.file_path.ends_with(".py")) ); } #[test] fn repairs_one_post_diff_from_file_to_database() { let (db, dir) = setup(); let database_target = create_post( db.conn(), dir.path(), "p1", "Database Target", None, vec![], vec![], None, None, None, ) .unwrap(); let filesystem_target = create_post( db.conn(), dir.path(), "p1", "Filesystem Target", None, vec![], vec![], None, None, None, ) .unwrap(); let post = create_post( db.conn(), dir.path(), "p1", "Database Title", Some("[database](/2024/01/01/database-target)"), vec![], vec![], None, Some("en"), None, ) .unwrap(); let published = publish_post(db.conn(), dir.path(), &post.id).unwrap(); let path = dir.path().join(&published.file_path); let content = fs::read_to_string(&path).unwrap(); fs::write( &path, content .replace("Database Title", "Filesystem Title") .replace( "[database](/2024/01/01/database-target)", "[filesystem](/2024/01/01/filesystem-target)", ), ) .unwrap(); let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap(); let item = report .diffs .iter() .find(|item| item.entity_type == "post") .unwrap(); repair_metadata_diff_item( db.conn(), dir.path(), "p1", RepairDirection::FileToDatabase, item, ) .unwrap(); assert_eq!( qp::get_post_by_id(db.conn(), &post.id).unwrap().title, "Filesystem Title" ); let links = crate::db::queries::post_link::list_links_by_source(db.conn(), &post.id).unwrap(); assert_eq!(links.len(), 1); assert_eq!(links[0].target_post_id, filesystem_target.id); assert_ne!(links[0].target_post_id, database_target.id); } #[test] fn repairs_one_post_diff_from_database_to_file_without_losing_body() { let (db, dir) = setup(); let post = create_post( db.conn(), dir.path(), "p1", "Database Title", Some("body that must survive"), vec![], vec![], None, Some("en"), None, ) .unwrap(); let published = publish_post(db.conn(), dir.path(), &post.id).unwrap(); let path = dir.path().join(&published.file_path); let content = fs::read_to_string(&path).unwrap(); fs::write(&path, content.replace("Database Title", "Filesystem Title")).unwrap(); let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap(); let item = report .diffs .iter() .find(|item| item.entity_type == "post") .unwrap(); repair_metadata_diff_item( db.conn(), dir.path(), "p1", RepairDirection::DatabaseToFile, item, ) .unwrap(); let repaired = fs::read_to_string(path).unwrap(); assert!(repaired.contains("title: Database Title")); assert!(repaired.contains("body that must survive")); } #[test] fn detects_media_sidecar_drift() { let (db, dir) = setup(); // Create media in DB with sidecar let media = Media { id: "m1".to_string(), project_id: "p1".to_string(), filename: "m1.jpg".to_string(), original_name: "photo.jpg".to_string(), mime_type: "image/jpeg".to_string(), size: 50000, width: Some(800), height: Some(600), title: Some("My Photo".to_string()), alt: Some("A nice photo".to_string()), caption: None, author: None, language: None, file_path: "media/2024/01/m1.jpg".to_string(), sidecar_path: "media/2024/01/m1.jpg.meta".to_string(), checksum: None, tags: vec![], created_at: 1000, updated_at: 2000, }; insert_media(db.conn(), &media).unwrap(); // Write sidecar with matching data initially, then change alt let sidecar = MediaSidecar { id: "m1".to_string(), original_name: "photo.jpg".to_string(), mime_type: "image/jpeg".to_string(), size: 50000, width: Some(800), height: Some(600), title: Some("My Photo".to_string()), alt: Some("TAMPERED ALT".to_string()), caption: None, author: None, language: None, created_at: 1000, updated_at: 2000, tags: vec![], linked_post_ids: vec![], }; let sidecar_dir = dir.path().join("media/2024/01"); fs::create_dir_all(&sidecar_dir).unwrap(); fs::write(sidecar_dir.join("m1.jpg.meta"), sidecar.to_string()).unwrap(); let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap(); let alt_diffs: Vec<_> = report .diffs .iter() .filter(|d| d.entity_type == "media") .flat_map(|d| d.fields.iter()) .filter(|f| f.field_name == "alt") .collect(); assert_eq!(alt_diffs.len(), 1); assert_eq!(alt_diffs[0].db_value, "A nice photo"); assert_eq!(alt_diffs[0].file_value, "TAMPERED ALT"); } #[test] fn detects_orphan_file() { let (db, dir) = setup(); // Create a .md file in posts/ that is not in the DB let posts_dir = dir.path().join("posts/2024/01"); fs::create_dir_all(&posts_dir).unwrap(); fs::write( posts_dir.join("orphan.md"), "---\ntitle: Orphan\n---\nBody\n", ) .unwrap(); let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap(); let orphan_files: Vec<_> = report .orphans .iter() .filter(|o| o.reason == "file_without_db_entry") .collect(); assert!( !orphan_files.is_empty(), "expected at least one orphan file" ); assert!( orphan_files .iter() .any(|o| o.file_path.contains("orphan.md")), "expected orphan.md in orphans, got: {orphan_files:?}" ); } #[test] fn detects_db_without_file() { let (db, dir) = setup(); // Insert post in DB with file_path pointing to a non-existent file let post = Post { id: "ghost-post".to_string(), project_id: "p1".to_string(), title: "Ghost".to_string(), slug: "ghost".to_string(), excerpt: None, content: None, status: PostStatus::Published, author: None, language: None, do_not_translate: false, template_slug: None, file_path: "posts/2024/01/ghost.md".to_string(), checksum: None, tags: vec![], categories: vec![], published_title: None, published_content: None, published_tags: None, published_categories: None, published_excerpt: None, created_at: 1000, updated_at: 2000, published_at: Some(3000), }; insert_post(db.conn(), &post).unwrap(); let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap(); let db_orphans: Vec<_> = report .orphans .iter() .filter(|o| o.reason == "db_entry_without_file") .collect(); assert!( !db_orphans.is_empty(), "expected at least one db_entry_without_file orphan" ); assert!( db_orphans.iter().any(|o| o.file_path.contains("ghost.md")), "expected ghost.md in orphans, got: {db_orphans:?}" ); } #[test] fn detects_template_drift() { let (db, dir) = setup(); // Insert template in DB let tpl = Template { id: "tpl1".to_string(), project_id: "p1".to_string(), slug: "my-template".to_string(), title: "My Template".to_string(), kind: TemplateKind::Post, enabled: true, version: 1, file_path: "templates/my-template.liquid".to_string(), status: TemplateStatus::Published, content: None, created_at: 1000, updated_at: 2000, }; insert_template(db.conn(), &tpl).unwrap(); // Write template file with different title let fm = TemplateFrontmatter { id: "tpl1".to_string(), project_id: Some("p1".to_string()), slug: "my-template".to_string(), title: "CHANGED Template Title".to_string(), kind: "post".to_string(), enabled: true, version: 1, created_at: 1000, updated_at: 2000, }; let file_content = write_template_file(&fm, "
body
"); let tpl_dir = dir.path().join("templates"); fs::create_dir_all(&tpl_dir).unwrap(); fs::write(tpl_dir.join("my-template.liquid"), file_content).unwrap(); let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap(); let title_diffs: Vec<_> = report .diffs .iter() .filter(|d| d.entity_type == "template") .flat_map(|d| d.fields.iter()) .filter(|f| f.field_name == "title") .collect(); assert_eq!(title_diffs.len(), 1); assert_eq!(title_diffs[0].db_value, "My Template"); assert_eq!(title_diffs[0].file_value, "CHANGED Template Title"); } #[test] fn detects_script_drift() { let (db, dir) = setup(); // Insert script in DB let script = Script { id: "s1".to_string(), project_id: "p1".to_string(), slug: "my-script".to_string(), title: "My Script".to_string(), kind: ScriptKind::Utility, entrypoint: "main".to_string(), enabled: true, version: 1, file_path: "scripts/my-script.lua".to_string(), status: ScriptStatus::Published, content: None, created_at: 1000, updated_at: 2000, }; insert_script(db.conn(), &script).unwrap(); // Write script file with different title and version let fm = ScriptFrontmatter { id: "s1".to_string(), project_id: Some("p1".to_string()), slug: "my-script".to_string(), title: "CHANGED Script Title".to_string(), kind: "utility".to_string(), entrypoint: "main".to_string(), enabled: true, version: 5, created_at: 1000, updated_at: 2000, }; let file_content = write_script_file(&fm, "-- lua code\nreturn 1"); let scripts_dir = dir.path().join("scripts"); fs::create_dir_all(&scripts_dir).unwrap(); fs::write(scripts_dir.join("my-script.lua"), file_content).unwrap(); let report = compute_metadata_diff(db.conn(), dir.path(), "p1").unwrap(); let script_diffs: Vec<_> = report .diffs .iter() .filter(|d| d.entity_type == "script") .collect(); assert!(!script_diffs.is_empty(), "expected script diffs"); let title_diffs: Vec<_> = script_diffs .iter() .flat_map(|d| d.fields.iter()) .filter(|f| f.field_name == "title") .collect(); assert_eq!(title_diffs.len(), 1); assert_eq!(title_diffs[0].db_value, "My Script"); assert_eq!(title_diffs[0].file_value, "CHANGED Script Title"); let version_diffs: Vec<_> = script_diffs .iter() .flat_map(|d| d.fields.iter()) .filter(|f| f.field_name == "version") .collect(); assert_eq!(version_diffs.len(), 1); assert_eq!(version_diffs[0].db_value, "1"); assert_eq!(version_diffs[0].file_value, "5"); } }