fix: even more closing of M0/M1/M2 gaps against the spec

This commit is contained in:
2026-04-05 14:26:26 +02:00
parent 6e34f5de1c
commit 0cf59da467
24 changed files with 1979 additions and 29 deletions

View File

@@ -0,0 +1,405 @@
use std::fs;
use std::path::Path;
use crate::engine::EngineResult;
use crate::util::atomic_write_str;
/// A navigation menu item per menu.allium.
#[derive(Debug, Clone, PartialEq)]
pub struct MenuItem {
pub kind: MenuItemKind,
pub label: String,
pub slug: Option<String>,
pub children: Vec<MenuItem>,
}
#[derive(Debug, Clone, PartialEq)]
pub enum MenuItemKind {
Home,
Page,
Submenu,
CategoryArchive,
}
impl MenuItemKind {
fn as_str(&self) -> &'static str {
match self {
Self::Home => "home",
Self::Page => "page",
Self::Submenu => "submenu",
Self::CategoryArchive => "category_archive",
}
}
fn from_str(s: &str) -> Self {
match s {
"home" => Self::Home,
"submenu" => Self::Submenu,
"category_archive" => Self::CategoryArchive,
_ => Self::Page,
}
}
}
/// Read the navigation menu from meta/menu.opml.
pub fn read_menu(data_dir: &Path) -> EngineResult<Vec<MenuItem>> {
let path = data_dir.join("meta").join("menu.opml");
if !path.exists() {
return Ok(vec![MenuItem {
kind: MenuItemKind::Home,
label: "Home".to_string(),
slug: None,
children: Vec::new(),
}]);
}
let content = fs::read_to_string(&path)?;
Ok(parse_opml(&content))
}
/// Write the navigation menu to meta/menu.opml.
/// Per menu.allium UpdateMenu rule: Home entry is always extracted and prepended.
pub fn write_menu(data_dir: &Path, items: &[MenuItem]) -> EngineResult<()> {
let normalized = normalize_menu(items);
let opml = serialize_opml(&normalized);
let path = data_dir.join("meta").join("menu.opml");
atomic_write_str(&path, &opml)?;
Ok(())
}
/// Return the default menu OPML for new projects.
pub fn default_menu_opml() -> String {
let items = vec![MenuItem {
kind: MenuItemKind::Home,
label: "Home".to_string(),
slug: None,
children: Vec::new(),
}];
serialize_opml(&items)
}
/// Per menu.allium HomeAlwaysPresent: ensure Home is always first.
fn normalize_menu(items: &[MenuItem]) -> Vec<MenuItem> {
let without_home: Vec<MenuItem> = items
.iter()
.filter(|i| i.kind != MenuItemKind::Home)
.cloned()
.collect();
let mut result = vec![MenuItem {
kind: MenuItemKind::Home,
label: "Home".to_string(),
slug: None,
children: Vec::new(),
}];
result.extend(without_home);
result
}
/// Parse OPML 2.0 XML into menu items.
fn parse_opml(content: &str) -> Vec<MenuItem> {
// Simple XML parsing using quick-xml-style manual parsing.
// OPML structure: <opml><body><outline .../></body></opml>
let mut items = Vec::new();
parse_outlines(content, &mut items);
// Ensure HomeAlwaysPresent
if items.is_empty() || items[0].kind != MenuItemKind::Home {
let without_home: Vec<MenuItem> = items
.into_iter()
.filter(|i| i.kind != MenuItemKind::Home)
.collect();
let mut normalized = vec![MenuItem {
kind: MenuItemKind::Home,
label: "Home".to_string(),
slug: None,
children: Vec::new(),
}];
normalized.extend(without_home);
return normalized;
}
items
}
/// Simple OPML outline parser.
/// Parses <outline text="..." type="..." htmlUrl="..."> elements.
fn parse_outlines(xml: &str, items: &mut Vec<MenuItem>) {
// Find all outline elements at the body level
let body_start = xml.find("<body>");
let body_end = xml.find("</body>");
if body_start.is_none() || body_end.is_none() {
return;
}
let body = &xml[body_start.unwrap() + 6..body_end.unwrap()];
parse_outline_children(body, items);
}
fn parse_outline_children(content: &str, items: &mut Vec<MenuItem>) {
let mut pos = 0;
while pos < content.len() {
// Find next <outline
let Some(start) = content[pos..].find("<outline") else {
break;
};
let abs_start = pos + start;
let tag_start = abs_start;
// Find the end of the opening tag
let rest = &content[tag_start..];
let is_self_closing;
let tag_end;
if let Some(sc) = rest.find("/>") {
if let Some(gt) = rest.find('>') {
if sc < gt {
is_self_closing = true;
tag_end = tag_start + sc + 2;
} else {
is_self_closing = false;
tag_end = tag_start + gt + 1;
}
} else {
is_self_closing = true;
tag_end = tag_start + sc + 2;
}
} else if let Some(gt) = rest.find('>') {
is_self_closing = false;
tag_end = tag_start + gt + 1;
} else {
break;
}
let tag_content = &content[tag_start..tag_end];
// Extract attributes
let label = extract_attr(tag_content, "text")
.unwrap_or_else(|| "Untitled".to_string());
let kind_str = extract_attr(tag_content, "type")
.unwrap_or_else(|| "page".to_string());
let slug = extract_attr(tag_content, "htmlUrl");
let kind = MenuItemKind::from_str(&kind_str);
let mut children = Vec::new();
let after_tag;
if is_self_closing {
after_tag = tag_end;
} else {
// Find matching </outline>
let inner = &content[tag_end..];
if let Some(close_idx) = find_closing_outline(inner) {
let inner_content = &inner[..close_idx];
parse_outline_children(inner_content, &mut children);
after_tag = tag_end + close_idx + "</outline>".len();
} else {
after_tag = tag_end;
}
}
items.push(MenuItem {
kind,
label,
slug,
children,
});
pos = after_tag;
}
}
fn find_closing_outline(content: &str) -> Option<usize> {
let mut depth = 0i32;
let mut pos = 0;
while pos < content.len() {
if content[pos..].starts_with("<outline") {
if let Some(sc) = content[pos..].find("/>") {
if let Some(gt) = content[pos..].find('>') {
if sc < gt {
pos += sc + 2;
continue;
}
}
}
depth += 1;
pos += 8; // skip past "<outline"
} else if content[pos..].starts_with("</outline>") {
if depth == 0 {
return Some(pos);
}
depth -= 1;
pos += 10;
} else {
pos += 1;
}
}
None
}
fn extract_attr(tag: &str, name: &str) -> Option<String> {
let pattern = format!("{name}=\"");
let start = tag.find(&pattern)?;
let value_start = start + pattern.len();
let rest = &tag[value_start..];
let end = rest.find('"')?;
let value = &rest[..end];
// Unescape XML entities
Some(
value
.replace("&amp;", "&")
.replace("&lt;", "<")
.replace("&gt;", ">")
.replace("&quot;", "\"")
.replace("&apos;", "'"),
)
}
/// Serialize menu items to OPML 2.0 format.
fn serialize_opml(items: &[MenuItem]) -> String {
let mut out = String::new();
out.push_str("<?xml version=\"1.0\" encoding=\"UTF-8\"?>\n");
out.push_str("<opml version=\"2.0\">\n");
out.push_str(" <head><title>Blog Menu</title></head>\n");
out.push_str(" <body>\n");
for item in items {
serialize_outline(&mut out, item, 2);
}
out.push_str(" </body>\n");
out.push_str("</opml>\n");
out
}
fn serialize_outline(out: &mut String, item: &MenuItem, indent: usize) {
let pad = " ".repeat(indent);
out.push_str(&pad);
out.push_str("<outline");
out.push_str(&format!(
" text=\"{}\"",
xml_escape(&item.label)
));
out.push_str(&format!(
" type=\"{}\"",
item.kind.as_str()
));
if let Some(ref slug) = item.slug {
out.push_str(&format!(
" htmlUrl=\"{}\"",
xml_escape(slug)
));
}
if item.children.is_empty() {
out.push_str("/>\n");
} else {
out.push_str(">\n");
for child in &item.children {
serialize_outline(out, child, indent + 1);
}
out.push_str(&pad);
out.push_str("</outline>\n");
}
}
fn xml_escape(s: &str) -> String {
s.replace('&', "&amp;")
.replace('<', "&lt;")
.replace('>', "&gt;")
.replace('"', "&quot;")
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn default_opml_has_home() {
let opml = default_menu_opml();
assert!(opml.contains("type=\"home\""));
assert!(opml.contains("text=\"Home\""));
}
#[test]
fn roundtrip_menu() {
let items = vec![
MenuItem {
kind: MenuItemKind::Home,
label: "Home".into(),
slug: None,
children: Vec::new(),
},
MenuItem {
kind: MenuItemKind::Page,
label: "About".into(),
slug: Some("/about".into()),
children: Vec::new(),
},
MenuItem {
kind: MenuItemKind::Submenu,
label: "Categories".into(),
slug: None,
children: vec![
MenuItem {
kind: MenuItemKind::CategoryArchive,
label: "Tech".into(),
slug: Some("/category/tech/".into()),
children: Vec::new(),
},
],
},
];
let opml = serialize_opml(&items);
let parsed = parse_opml(&opml);
assert_eq!(parsed.len(), 3);
assert_eq!(parsed[0].kind, MenuItemKind::Home);
assert_eq!(parsed[1].label, "About");
assert_eq!(parsed[1].slug.as_deref(), Some("/about"));
assert_eq!(parsed[2].children.len(), 1);
assert_eq!(parsed[2].children[0].label, "Tech");
}
#[test]
fn normalize_prepends_home() {
let items = vec![
MenuItem {
kind: MenuItemKind::Page,
label: "About".into(),
slug: Some("/about".into()),
children: Vec::new(),
},
];
let normalized = normalize_menu(&items);
assert_eq!(normalized.len(), 2);
assert_eq!(normalized[0].kind, MenuItemKind::Home);
assert_eq!(normalized[1].label, "About");
}
#[test]
fn read_menu_missing_file_returns_home() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join("meta")).unwrap();
let items = read_menu(dir.path()).unwrap();
assert_eq!(items.len(), 1);
assert_eq!(items[0].kind, MenuItemKind::Home);
}
#[test]
fn write_and_read_menu() {
let dir = tempfile::TempDir::new().unwrap();
std::fs::create_dir_all(dir.path().join("meta")).unwrap();
let items = vec![
MenuItem {
kind: MenuItemKind::Page,
label: "Blog".into(),
slug: Some("/blog".into()),
children: Vec::new(),
},
];
write_menu(dir.path(), &items).unwrap();
let read = read_menu(dir.path()).unwrap();
// Home is always prepended
assert_eq!(read.len(), 2);
assert_eq!(read[0].kind, MenuItemKind::Home);
assert_eq!(read[1].label, "Blog");
}
#[test]
fn xml_escape_special_chars() {
let escaped = xml_escape("Tom & Jerry <3 \"quotes\"");
assert_eq!(escaped, "Tom &amp; Jerry &lt;3 &quot;quotes&quot;");
}
}

View File

@@ -149,6 +149,123 @@ pub fn remove_category(data_dir: &Path, category: &str) -> EngineResult<()> {
Ok(())
}
// ── update helpers ──────────────────────────────────────────────────
/// Update the blog_languages list in project.json.
/// Per metadata.allium UpdateProjectMetadata: writes ProjectJsonWritten.
pub fn update_blog_languages(data_dir: &Path, languages: Vec<String>) -> EngineResult<()> {
let mut meta = read_project_json(data_dir)?;
meta.blog_languages = languages;
write_project_json(data_dir, &meta)?;
Ok(())
}
/// Update arbitrary fields of project metadata.
/// Per metadata.allium UpdateProjectMetadata rule.
pub fn update_project_metadata(
data_dir: &Path,
changes: &serde_json::Value,
) -> EngineResult<()> {
let mut meta = read_project_json(data_dir)?;
if let Some(name) = changes.get("name").and_then(|v| v.as_str()) {
meta.name = name.to_string();
}
if let Some(desc) = changes.get("description") {
meta.description = desc.as_str().map(|s| s.to_string());
}
if let Some(url) = changes.get("publicUrl") {
meta.public_url = url.as_str().map(|s| s.to_string());
}
if let Some(lang) = changes.get("mainLanguage") {
meta.main_language = lang.as_str().map(|s| s.to_string());
}
if let Some(author) = changes.get("defaultAuthor") {
meta.default_author = author.as_str().map(|s| s.to_string());
}
if let Some(max) = changes.get("maxPostsPerPage").and_then(|v| v.as_i64()) {
meta.max_posts_per_page = max as i32;
}
if let Some(cat) = changes.get("blogmarkCategory") {
meta.blogmark_category = cat.as_str().map(|s| s.to_string());
}
if let Some(theme) = changes.get("picoTheme") {
meta.pico_theme = theme.as_str().map(|s| s.to_string());
}
if let Some(enabled) = changes.get("semanticSimilarityEnabled").and_then(|v| v.as_bool()) {
meta.semantic_similarity_enabled = enabled;
}
if let Some(langs) = changes.get("blogLanguages").and_then(|v| v.as_array()) {
meta.blog_languages = langs
.iter()
.filter_map(|v| v.as_str().map(|s| s.to_string()))
.collect();
}
meta.validate().map_err(|e| crate::engine::EngineError::Validation(e))?;
write_project_json(data_dir, &meta)?;
Ok(())
}
// ── startup sync ────────────────────────────────────────────────────
/// Per metadata.allium StartupSync: loads metadata from filesystem, creating
/// default files if missing. Called on project activation.
pub fn startup_sync(data_dir: &Path) -> EngineResult<()> {
let meta_dir = data_dir.join("meta");
fs::create_dir_all(&meta_dir)?;
// Ensure project.json exists
if !meta_dir.join("project.json").exists() {
let default_meta = ProjectMetadata {
name: "My Blog".to_string(),
description: None,
public_url: None,
main_language: None,
default_author: None,
max_posts_per_page: 50,
blogmark_category: None,
pico_theme: None,
semantic_similarity_enabled: false,
blog_languages: Vec::new(),
};
write_project_json(data_dir, &default_meta)?;
}
// Ensure categories.json exists with defaults
if !meta_dir.join("categories.json").exists() {
let defaults = vec![
"article".to_string(),
"aside".to_string(),
"page".to_string(),
"picture".to_string(),
];
write_categories_json(data_dir, &defaults)?;
}
// Ensure category-meta.json exists
if !meta_dir.join("category-meta.json").exists() {
let empty: HashMap<String, CategorySettings> = HashMap::new();
write_category_meta_json(data_dir, &empty)?;
}
// Ensure publishing.json exists
if !meta_dir.join("publishing.json").exists() {
atomic_write_str(&meta_dir.join("publishing.json"), "{}")?;
}
// Ensure tags.json exists
if !meta_dir.join("tags.json").exists() {
write_tags_json(data_dir, &[])?;
}
// Ensure menu.opml exists
if !meta_dir.join("menu.opml").exists() {
let opml = crate::engine::menu::default_menu_opml();
atomic_write_str(&meta_dir.join("menu.opml"), &opml)?;
}
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;

View File

@@ -14,6 +14,9 @@ pub mod rebuild;
pub mod search;
pub mod calendar;
pub mod validate_translations;
pub mod validate_media;
pub mod validate_content;
pub mod menu;
pub use error::{EngineError, EngineResult};
pub use context::EngineContext;

View File

@@ -194,6 +194,48 @@ fn write_default_meta_files(data_dir: &Path, project_name: &str) -> EngineResult
// tags.json — empty array
atomic_write_str(&meta_dir.join("tags.json"), "[]")?;
// menu.opml — default empty menu per menu.allium HomeAlwaysPresent
let default_opml = crate::engine::menu::default_menu_opml();
atomic_write_str(&meta_dir.join("menu.opml"), &default_opml)?;
// Starter templates — per project.allium StarterTemplatesCopied
copy_starter_templates(data_dir)?;
Ok(())
}
/// Copy bundled starter templates into the project templates directory.
/// Per project.allium: "Bundled starter templates are copied into the new project."
fn copy_starter_templates(data_dir: &Path) -> EngineResult<()> {
let templates_dir = data_dir.join("templates");
let partials_dir = templates_dir.join("partials");
fs::create_dir_all(&partials_dir)?;
// Starter templates embedded at compile time from assets/starter-templates/
let templates: &[(&str, &str)] = &[
("single-post.liquid", include_str!("../../../../assets/starter-templates/single-post.liquid")),
("post-list.liquid", include_str!("../../../../assets/starter-templates/post-list.liquid")),
("not-found.liquid", include_str!("../../../../assets/starter-templates/not-found.liquid")),
];
let partials: &[(&str, &str)] = &[
("head.liquid", include_str!("../../../../assets/starter-templates/partials/head.liquid")),
("menu.liquid", include_str!("../../../../assets/starter-templates/partials/menu.liquid")),
("menu-items.liquid", include_str!("../../../../assets/starter-templates/partials/menu-items.liquid")),
("language-switcher.liquid", include_str!("../../../../assets/starter-templates/partials/language-switcher.liquid")),
];
for (name, content) in templates {
let path = templates_dir.join(name);
if !path.exists() {
atomic_write_str(&path, content)?;
}
}
for (name, content) in partials {
let path = partials_dir.join(name);
if !path.exists() {
atomic_write_str(&path, content)?;
}
}
Ok(())
}

View File

@@ -0,0 +1,218 @@
/// Validation functions for template (Liquid) and script (Lua/Python) content.
/// Per template.allium and script.allium, these are pre-publish gates.
///
/// Current implementation: basic structural checks.
/// When a full Liquid parser crate is added, upgrade `validate_liquid` to
/// attempt a real parse. Similarly for `validate_script` with a Lua parser.
/// Result of a validation check.
#[derive(Debug, Clone)]
pub struct ValidationResult {
pub valid: bool,
pub errors: Vec<String>,
}
impl ValidationResult {
pub fn ok() -> Self {
Self {
valid: true,
errors: Vec::new(),
}
}
pub fn fail(errors: Vec<String>) -> Self {
Self {
valid: false,
errors,
}
}
}
/// Validate Liquid template content.
/// Per template.allium: "LiquidJS parser must accept the template".
/// Currently performs structural tag-matching. Upgrade to full liquid crate parse later.
pub fn validate_liquid(content: &str) -> ValidationResult {
let mut errors = Vec::new();
// Check for unmatched Liquid block tags
let block_tags = ["if", "unless", "for", "case", "capture", "comment",
"raw", "paginate", "tablerow", "block", "schema"];
for tag in &block_tags {
let open_pattern = format!("{{% {tag}");
let close_pattern = format!("{{% end{tag}");
let opens = content.matches(&open_pattern).count();
let closes = content.matches(&close_pattern).count();
if opens != closes {
errors.push(format!(
"Unmatched {{% {tag} %}}: {opens} opens, {closes} closes"
));
}
}
// Check for unclosed {{ }}
let double_opens = content.matches("{{").count();
let double_closes = content.matches("}}").count();
if double_opens != double_closes {
errors.push(format!(
"Unmatched {{}}: {double_opens} opens, {double_closes} closes"
));
}
// Check for unclosed {% %}
let tag_opens = content.matches("{%").count();
let tag_closes = content.matches("%}").count();
if tag_opens != tag_closes {
errors.push(format!(
"Unmatched {{% %}}: {tag_opens} opens, {tag_closes} closes"
));
}
if errors.is_empty() {
ValidationResult::ok()
} else {
ValidationResult::fail(errors)
}
}
/// Validate script content (Lua syntax).
/// Per script.allium: "AST parsing must succeed".
/// Currently performs basic bracket-matching. Upgrade to full Lua parser later.
pub fn validate_script(content: &str) -> ValidationResult {
let mut errors = Vec::new();
// Basic bracket matching
let mut paren_depth: i32 = 0;
let mut brace_depth: i32 = 0;
let mut bracket_depth: i32 = 0;
for (line_num, line) in content.lines().enumerate() {
// Skip comment lines
let trimmed = line.trim();
if trimmed.starts_with("--") {
continue;
}
for ch in line.chars() {
match ch {
'(' => paren_depth += 1,
')' => paren_depth -= 1,
'{' => brace_depth += 1,
'}' => brace_depth -= 1,
'[' => bracket_depth += 1,
']' => bracket_depth -= 1,
_ => {}
}
if paren_depth < 0 {
errors.push(format!("Unexpected ')' at line {}", line_num + 1));
paren_depth = 0;
}
if brace_depth < 0 {
errors.push(format!("Unexpected '}}' at line {}", line_num + 1));
brace_depth = 0;
}
if bracket_depth < 0 {
errors.push(format!("Unexpected ']' at line {}", line_num + 1));
bracket_depth = 0;
}
}
}
if paren_depth != 0 {
errors.push(format!("Unclosed parentheses: depth {paren_depth}"));
}
if brace_depth != 0 {
errors.push(format!("Unclosed braces: depth {brace_depth}"));
}
if bracket_depth != 0 {
errors.push(format!("Unclosed brackets: depth {bracket_depth}"));
}
// Check for unmatched Lua block keywords.
// In Lua, `for ... do ... end` and `while ... do ... end` each form a
// single block, so `do` on such lines must NOT be counted as a separate
// opener.
let mut block_opens: usize = 0;
let mut block_closes: usize = 0;
for line in content.lines() {
let trimmed = line.trim();
if trimmed.starts_with("--") {
continue;
}
let words: Vec<&str> = trimmed.split_whitespace().collect();
let has_for_or_while = words.iter().any(|w| matches!(*w, "for" | "while"));
for w in &words {
match *w {
"function" | "if" | "for" | "while" | "repeat" => block_opens += 1,
"do" if !has_for_or_while => block_opens += 1,
"end" | "until" => block_closes += 1,
_ => {}
}
}
}
if block_opens > block_closes {
errors.push(format!(
"Possible unclosed blocks: {block_opens} openers, {block_closes} closers"
));
}
if errors.is_empty() {
ValidationResult::ok()
} else {
ValidationResult::fail(errors)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn valid_liquid_template() {
let content = r#"
<html>
{% if user %}
<p>{{ user.name }}</p>
{% endif %}
</html>"#;
let result = validate_liquid(content);
assert!(result.valid, "Errors: {:?}", result.errors);
}
#[test]
fn unmatched_liquid_if() {
let content = "{% if true %}<p>Hello</p>";
let result = validate_liquid(content);
assert!(!result.valid);
assert!(result.errors.iter().any(|e| e.contains("if")));
}
#[test]
fn unmatched_liquid_output() {
let content = "{{ user.name";
let result = validate_liquid(content);
assert!(!result.valid);
}
#[test]
fn valid_lua_script() {
let content = r#"
function render(ctx)
local result = {}
for i = 1, 10 do
result[i] = i * 2
end
return result
end
"#;
let result = validate_script(content);
assert!(result.valid, "Errors: {:?}", result.errors);
}
#[test]
fn unmatched_lua_parens() {
let content = "function test(\n local x = 1\nend";
let result = validate_script(content);
assert!(!result.valid);
}
}

View File

@@ -0,0 +1,191 @@
use std::fs;
use std::path::Path;
use rusqlite::Connection;
use crate::db::queries::media as mq;
use crate::db::queries::post_media as pmq;
use crate::engine::EngineResult;
use crate::model::Media;
use crate::util::{media_sidecar_path, thumbnail_path};
/// Thumbnail sizes per media_processing.allium.
const THUMBNAIL_SIZES: &[&str] = &["small", "medium", "large", "ai"];
/// Types of media validation issues.
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MediaIssueKind {
MissingBinary,
MissingSidecar,
MissingThumbnail { size: String },
Corrupted,
Orphan,
}
/// A single media validation issue.
#[derive(Debug, Clone)]
pub struct MediaIssue {
pub media_id: String,
pub original_name: String,
pub kind: MediaIssueKind,
pub detail: String,
}
/// Full validation report.
#[derive(Debug, Clone, Default)]
pub struct MediaValidationReport {
pub issues: Vec<MediaIssue>,
pub total_checked: usize,
}
/// Progress callback type for media validation.
pub type ProgressFn = Box<dyn Fn(usize, usize, &str) + Send>;
/// Validate all media for a project per media_processing.allium ValidateMedia rule.
///
/// Checks for:
/// - Missing binary files
/// - Missing sidecar files
/// - Missing thumbnails (all 4 sizes)
/// - Corrupted image files (basic header check)
/// - Orphan media (not linked to any post)
pub fn validate_media(
conn: &Connection,
data_dir: &Path,
project_id: &str,
on_progress: Option<ProgressFn>,
) -> EngineResult<MediaValidationReport> {
let all_media = mq::list_media_by_project(conn, project_id)?;
let total = all_media.len();
let mut report = MediaValidationReport {
issues: Vec::new(),
total_checked: total,
};
for (idx, media) in all_media.iter().enumerate() {
if let Some(ref cb) = on_progress {
cb(idx + 1, total, &media.original_name);
}
check_media_item(conn, data_dir, media, &mut report.issues)?;
}
Ok(report)
}
fn check_media_item(
conn: &Connection,
data_dir: &Path,
media: &Media,
issues: &mut Vec<MediaIssue>,
) -> EngineResult<()> {
let binary_path = data_dir.join(&media.file_path);
// 1. Missing binary
if !binary_path.exists() {
issues.push(MediaIssue {
media_id: media.id.clone(),
original_name: media.original_name.clone(),
kind: MediaIssueKind::MissingBinary,
detail: format!("Binary file not found: {}", media.file_path),
});
} else {
// 3. Corrupted check — basic: file is empty or not readable
match fs::metadata(&binary_path) {
Ok(meta) if meta.len() == 0 => {
issues.push(MediaIssue {
media_id: media.id.clone(),
original_name: media.original_name.clone(),
kind: MediaIssueKind::Corrupted,
detail: "Binary file is empty (0 bytes)".to_string(),
});
}
Err(e) => {
issues.push(MediaIssue {
media_id: media.id.clone(),
original_name: media.original_name.clone(),
kind: MediaIssueKind::Corrupted,
detail: format!("Cannot read binary: {e}"),
});
}
_ => {}
}
}
// 2. Missing sidecar
let sidecar_rel = media_sidecar_path(&media.file_path);
let sidecar_path = data_dir.join(&sidecar_rel);
if !sidecar_path.exists() {
issues.push(MediaIssue {
media_id: media.id.clone(),
original_name: media.original_name.clone(),
kind: MediaIssueKind::MissingSidecar,
detail: format!("Sidecar not found: {sidecar_rel}"),
});
}
// 3. Missing thumbnails — only for image types
if is_image_mime(&media.mime_type) {
let ext = thumbnail_extension(&media.mime_type);
for size in THUMBNAIL_SIZES {
let thumb_rel = thumbnail_path(&media.id, size, ext);
let thumb_path = data_dir.join(&thumb_rel);
if !thumb_path.exists() {
issues.push(MediaIssue {
media_id: media.id.clone(),
original_name: media.original_name.clone(),
kind: MediaIssueKind::MissingThumbnail {
size: size.to_string(),
},
detail: format!("Thumbnail missing: {thumb_rel}"),
});
}
}
}
// 4. Orphan check — media not linked to any post
let links = pmq::list_post_media_by_media(conn, &media.id)?;
if links.is_empty() {
issues.push(MediaIssue {
media_id: media.id.clone(),
original_name: media.original_name.clone(),
kind: MediaIssueKind::Orphan,
detail: "Media is not linked to any post".to_string(),
});
}
Ok(())
}
fn is_image_mime(mime: &str) -> bool {
mime.starts_with("image/")
}
fn thumbnail_extension(mime: &str) -> &str {
match mime {
"image/png" => "png",
"image/gif" => "gif",
"image/webp" => "webp",
_ => "jpg",
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn is_image_recognizes_types() {
assert!(is_image_mime("image/jpeg"));
assert!(is_image_mime("image/png"));
assert!(!is_image_mime("application/pdf"));
assert!(!is_image_mime("video/mp4"));
}
#[test]
fn thumbnail_ext_defaults_to_jpg() {
assert_eq!(thumbnail_extension("image/jpeg"), "jpg");
assert_eq!(thumbnail_extension("image/png"), "png");
assert_eq!(thumbnail_extension("image/webp"), "webp");
assert_eq!(thumbnail_extension("image/tiff"), "jpg"); // fallback
}
}