use serde::{Deserialize, Serialize};
use std::path::{Path, PathBuf};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
pub enum ImageFormat {
Png,
Jpg,
WebP,
Svg,
}
impl ImageFormat {
pub fn from_extension(ext: &str) -> Option<Self> {
match ext.to_lowercase().as_str() {
"png" => Some(Self::Png),
"jpg" | "jpeg" => Some(Self::Jpg),
"webp" => Some(Self::WebP),
"svg" => Some(Self::Svg),
_ => None,
}
}
pub fn extension(&self) -> &'static str {
match self {
Self::Png => "png",
Self::Jpg => "jpg",
Self::WebP => "webp",
Self::Svg => "svg",
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct HeroImageResult {
pub present: bool,
pub path: Option<PathBuf>,
pub format: Option<ImageFormat>,
pub dimensions: Option<(u32, u32)>,
pub file_size: Option<u64>,
pub valid: bool,
pub issues: Vec<String>,
}
impl HeroImageResult {
pub fn missing() -> Self {
Self {
present: false,
path: None,
format: None,
dimensions: None,
file_size: None,
valid: false,
issues: vec!["No hero image found".to_string()],
}
}
pub fn found(path: PathBuf, format: ImageFormat) -> Self {
Self {
present: true,
path: Some(path),
format: Some(format),
dimensions: None,
file_size: None,
valid: true,
issues: vec![],
}
}
pub fn detect(repo_path: &Path) -> Self {
for ext in &["svg", "png", "jpg", "jpeg", "webp"] {
let hero_path = repo_path.join(format!("docs/hero.{}", ext));
if hero_path.exists() {
if let Some(format) = ImageFormat::from_extension(ext) {
return Self::validate_at_path(&hero_path, format);
}
}
}
for ext in &["svg", "png", "jpg", "jpeg", "webp"] {
let hero_path = repo_path.join(format!("assets/hero.{}", ext));
if hero_path.exists() {
if let Some(format) = ImageFormat::from_extension(ext) {
return Self::validate_at_path(&hero_path, format);
}
}
}
let readme_path = repo_path.join("README.md");
if readme_path.exists() {
if let Some(img_ref) = Self::extract_first_image_from_readme(&readme_path) {
let full_path = repo_path.join(&img_ref);
if full_path.exists() {
if let Some(ext) = full_path.extension().and_then(|e| e.to_str()) {
if let Some(format) = ImageFormat::from_extension(ext) {
return Self::validate_at_path(&full_path, format);
}
}
}
}
}
Self::missing()
}
fn validate_at_path(path: &Path, format: ImageFormat) -> Self {
let mut result = Self::found(path.to_path_buf(), format);
let mut issues = Vec::new();
if let Ok(metadata) = std::fs::metadata(path) {
let size = metadata.len();
result.file_size = Some(size);
if size > 2 * 1024 * 1024 {
issues.push(format!("Image too large: {} bytes (max 2MB)", size));
}
}
if !issues.is_empty() {
result.valid = false;
result.issues = issues;
}
result
}
fn extract_first_image_from_readme(readme_path: &Path) -> Option<String> {
let content = std::fs::read_to_string(readme_path).ok()?;
if let Some(img_path) = Self::extract_markdown_image(&content) {
if !img_path.starts_with("http://") && !img_path.starts_with("https://") {
return Some(img_path);
}
}
if let Some(img_path) = Self::extract_html_image(&content) {
if !img_path.starts_with("http://") && !img_path.starts_with("https://") {
return Some(img_path);
}
}
None
}
fn extract_markdown_image(content: &str) -> Option<String> {
let start = content.find("![")?;
let after_bracket = &content[start + 2..];
let close_bracket = after_bracket.find("](")?;
let after_paren = &after_bracket[close_bracket + 2..];
let close_paren = after_paren.find(')')?;
Some(after_paren[..close_paren].to_string())
}
fn extract_html_image(content: &str) -> Option<String> {
let img_start = content.find("<img")?;
let after_img = &content[img_start..];
let tag_end = after_img.find('>')?;
let img_tag = &after_img[..tag_end];
for quote in ['"', '\''] {
let src_pattern = format!("src={}", quote);
if let Some(src_pos) = img_tag.find(&src_pattern) {
let after_src = &img_tag[src_pos + src_pattern.len()..];
if let Some(end_quote) = after_src.find(quote) {
return Some(after_src[..end_quote].to_string());
}
}
}
None
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_image_format_from_extension() {
assert_eq!(ImageFormat::from_extension("png"), Some(ImageFormat::Png));
assert_eq!(ImageFormat::from_extension("PNG"), Some(ImageFormat::Png));
assert_eq!(ImageFormat::from_extension("jpg"), Some(ImageFormat::Jpg));
assert_eq!(ImageFormat::from_extension("jpeg"), Some(ImageFormat::Jpg));
assert_eq!(ImageFormat::from_extension("svg"), Some(ImageFormat::Svg));
assert_eq!(ImageFormat::from_extension("webp"), Some(ImageFormat::WebP));
assert_eq!(ImageFormat::from_extension("gif"), None);
}
#[test]
fn test_hero_image_missing() {
let result = HeroImageResult::missing();
assert!(!result.present);
assert!(!result.valid);
assert!(result.path.is_none());
}
#[test]
fn test_hero_image_found() {
let result = HeroImageResult::found(PathBuf::from("hero.png"), ImageFormat::Png);
assert!(result.present);
assert!(result.valid);
assert_eq!(result.format, Some(ImageFormat::Png));
}
#[test]
fn test_extract_markdown_image() {
let content = "# Title\n\nMore text";
let path = HeroImageResult::extract_markdown_image(content);
assert_eq!(path, Some("docs/hero.svg".to_string()));
}
#[test]
fn test_extract_html_image() {
let content = r#"<img src="docs/hero.svg" alt="hero">"#;
let path = HeroImageResult::extract_html_image(content);
assert_eq!(path, Some("docs/hero.svg".to_string()));
}
#[test]
fn test_extract_html_image_single_quotes() {
let content = r#"<img src='docs/hero.svg' alt='hero'>"#;
let path = HeroImageResult::extract_html_image(content);
assert_eq!(path, Some("docs/hero.svg".to_string()));
}
#[test]
fn test_image_format_extension() {
assert_eq!(ImageFormat::Png.extension(), "png");
assert_eq!(ImageFormat::Jpg.extension(), "jpg");
assert_eq!(ImageFormat::WebP.extension(), "webp");
assert_eq!(ImageFormat::Svg.extension(), "svg");
}
#[test]
fn test_hero_image_result_fields() {
let mut result = HeroImageResult::found(PathBuf::from("test.png"), ImageFormat::Png);
result.dimensions = Some((800, 600));
result.file_size = Some(1024);
assert_eq!(result.dimensions, Some((800, 600)));
assert_eq!(result.file_size, Some(1024));
}
#[test]
fn test_hero_image_detect_nonexistent_repo() {
let result = HeroImageResult::detect(Path::new("/nonexistent/path"));
assert!(!result.present);
assert!(!result.valid);
}
#[test]
fn test_extract_markdown_image_no_image() {
let content = "# Just text\nNo images here";
let path = HeroImageResult::extract_markdown_image(content);
assert!(path.is_none());
}
#[test]
fn test_extract_html_image_no_image() {
let content = "Just text, no img tags";
let path = HeroImageResult::extract_html_image(content);
assert!(path.is_none());
}
#[test]
fn test_extract_html_image_no_src() {
let content = r#"<img alt="test">"#;
let path = HeroImageResult::extract_html_image(content);
assert!(path.is_none());
}
#[test]
fn test_image_format_equality() {
assert_eq!(ImageFormat::Png, ImageFormat::Png);
assert_ne!(ImageFormat::Png, ImageFormat::Jpg);
}
#[test]
fn test_hero_image_result_missing_issues() {
let result = HeroImageResult::missing();
assert!(!result.issues.is_empty());
assert!(result.issues[0].contains("No hero image"));
}
#[test]
fn test_hero_image_found_no_issues() {
let result = HeroImageResult::found(PathBuf::from("test.svg"), ImageFormat::Svg);
assert!(result.issues.is_empty());
}
}