Skip to main content

batuta/stack/
hero_image.rs

1//! Hero Image Detection and Validation
2//!
3//! Detects and validates hero images in PAIML stack repositories.
4//! Supports SVG, PNG, JPG, and WebP formats.
5
6use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8
9/// Supported image formats for hero images
10#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11pub enum ImageFormat {
12    Png,
13    Jpg,
14    WebP,
15    Svg,
16}
17
18impl ImageFormat {
19    /// Detect format from file extension
20    pub fn from_extension(ext: &str) -> Option<Self> {
21        match ext.to_lowercase().as_str() {
22            "png" => Some(Self::Png),
23            "jpg" | "jpeg" => Some(Self::Jpg),
24            "webp" => Some(Self::WebP),
25            "svg" => Some(Self::Svg),
26            _ => None,
27        }
28    }
29
30    /// Get file extension for format
31    pub fn extension(&self) -> &'static str {
32        match self {
33            Self::Png => "png",
34            Self::Jpg => "jpg",
35            Self::WebP => "webp",
36            Self::Svg => "svg",
37        }
38    }
39}
40
41/// Hero image detection result
42#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct HeroImageResult {
44    /// Whether a hero image was found
45    pub present: bool,
46    /// Path to the hero image (if found)
47    pub path: Option<PathBuf>,
48    /// Detected format
49    pub format: Option<ImageFormat>,
50    /// Image dimensions (width, height) if detectable
51    pub dimensions: Option<(u32, u32)>,
52    /// File size in bytes
53    pub file_size: Option<u64>,
54    /// Whether the image passes validation
55    pub valid: bool,
56    /// Issues found during validation
57    pub issues: Vec<String>,
58}
59
60impl HeroImageResult {
61    /// Create result for missing hero image
62    pub fn missing() -> Self {
63        Self {
64            present: false,
65            path: None,
66            format: None,
67            dimensions: None,
68            file_size: None,
69            valid: false,
70            issues: vec!["No hero image found".to_string()],
71        }
72    }
73
74    /// Create result for found hero image
75    pub fn found(path: PathBuf, format: ImageFormat) -> Self {
76        Self {
77            present: true,
78            path: Some(path),
79            format: Some(format),
80            dimensions: None,
81            file_size: None,
82            valid: true,
83            issues: vec![],
84        }
85    }
86
87    /// Detect hero image in repository
88    pub fn detect(repo_path: &Path) -> Self {
89        // Priority 1: Check docs/hero.* files (SVG preferred)
90        for ext in &["svg", "png", "jpg", "jpeg", "webp"] {
91            let hero_path = repo_path.join(format!("docs/hero.{}", ext));
92            if hero_path.exists() {
93                if let Some(format) = ImageFormat::from_extension(ext) {
94                    return Self::validate_at_path(&hero_path, format);
95                }
96            }
97        }
98
99        // Priority 2: Check assets/hero.* files (SVG preferred)
100        for ext in &["svg", "png", "jpg", "jpeg", "webp"] {
101            let hero_path = repo_path.join(format!("assets/hero.{}", ext));
102            if hero_path.exists() {
103                if let Some(format) = ImageFormat::from_extension(ext) {
104                    return Self::validate_at_path(&hero_path, format);
105                }
106            }
107        }
108
109        // Priority 3: Parse README.md for first image
110        let readme_path = repo_path.join("README.md");
111        if readme_path.exists() {
112            if let Some(img_ref) = Self::extract_first_image_from_readme(&readme_path) {
113                let full_path = repo_path.join(&img_ref);
114                if full_path.exists() {
115                    if let Some(ext) = full_path.extension().and_then(|e| e.to_str()) {
116                        if let Some(format) = ImageFormat::from_extension(ext) {
117                            return Self::validate_at_path(&full_path, format);
118                        }
119                    }
120                }
121            }
122        }
123
124        Self::missing()
125    }
126
127    /// Validate image at path
128    fn validate_at_path(path: &Path, format: ImageFormat) -> Self {
129        let mut result = Self::found(path.to_path_buf(), format);
130        let mut issues = Vec::new();
131
132        // Check file size
133        if let Ok(metadata) = std::fs::metadata(path) {
134            let size = metadata.len();
135            result.file_size = Some(size);
136
137            // Max 2MB
138            if size > 2 * 1024 * 1024 {
139                issues.push(format!("Image too large: {} bytes (max 2MB)", size));
140            }
141        }
142
143        // Note: Dimension checking would require image crate
144        // For now, we skip dimension validation
145
146        if !issues.is_empty() {
147            result.valid = false;
148            result.issues = issues;
149        }
150
151        result
152    }
153
154    /// Extract first image reference from README.md
155    /// Replaced regex-lite with string parsing (DEP-REDUCE)
156    fn extract_first_image_from_readme(readme_path: &Path) -> Option<String> {
157        let content = std::fs::read_to_string(readme_path).ok()?;
158
159        // Match markdown image syntax: ![alt](path)
160        if let Some(img_path) = Self::extract_markdown_image(&content) {
161            if !img_path.starts_with("http://") && !img_path.starts_with("https://") {
162                return Some(img_path);
163            }
164        }
165
166        // Match HTML img syntax: <img src="path"
167        if let Some(img_path) = Self::extract_html_image(&content) {
168            if !img_path.starts_with("http://") && !img_path.starts_with("https://") {
169                return Some(img_path);
170            }
171        }
172
173        None
174    }
175
176    /// Extract image path from markdown syntax ![alt](path)
177    fn extract_markdown_image(content: &str) -> Option<String> {
178        // Find ![
179        let start = content.find("![")?;
180        let after_bracket = &content[start + 2..];
181        // Find ]( after ![
182        let close_bracket = after_bracket.find("](")?;
183        let after_paren = &after_bracket[close_bracket + 2..];
184        // Find closing )
185        let close_paren = after_paren.find(')')?;
186        Some(after_paren[..close_paren].to_string())
187    }
188
189    /// Extract image path from HTML syntax <img src="path">
190    fn extract_html_image(content: &str) -> Option<String> {
191        // Find <img
192        let img_start = content.find("<img")?;
193        let after_img = &content[img_start..];
194        // Find closing >
195        let tag_end = after_img.find('>')?;
196        let img_tag = &after_img[..tag_end];
197
198        // Find src=" or src='
199        for quote in ['"', '\''] {
200            let src_pattern = format!("src={}", quote);
201            if let Some(src_pos) = img_tag.find(&src_pattern) {
202                let after_src = &img_tag[src_pos + src_pattern.len()..];
203                if let Some(end_quote) = after_src.find(quote) {
204                    return Some(after_src[..end_quote].to_string());
205                }
206            }
207        }
208        None
209    }
210}
211
212#[cfg(test)]
213mod tests {
214    use super::*;
215
216    #[test]
217    fn test_image_format_from_extension() {
218        assert_eq!(ImageFormat::from_extension("png"), Some(ImageFormat::Png));
219        assert_eq!(ImageFormat::from_extension("PNG"), Some(ImageFormat::Png));
220        assert_eq!(ImageFormat::from_extension("jpg"), Some(ImageFormat::Jpg));
221        assert_eq!(ImageFormat::from_extension("jpeg"), Some(ImageFormat::Jpg));
222        assert_eq!(ImageFormat::from_extension("svg"), Some(ImageFormat::Svg));
223        assert_eq!(ImageFormat::from_extension("webp"), Some(ImageFormat::WebP));
224        assert_eq!(ImageFormat::from_extension("gif"), None);
225    }
226
227    #[test]
228    fn test_hero_image_missing() {
229        let result = HeroImageResult::missing();
230        assert!(!result.present);
231        assert!(!result.valid);
232        assert!(result.path.is_none());
233    }
234
235    #[test]
236    fn test_hero_image_found() {
237        let result = HeroImageResult::found(PathBuf::from("hero.png"), ImageFormat::Png);
238        assert!(result.present);
239        assert!(result.valid);
240        assert_eq!(result.format, Some(ImageFormat::Png));
241    }
242
243    #[test]
244    fn test_extract_markdown_image() {
245        let content = "# Title\n![Hero](docs/hero.svg)\nMore text";
246        let path = HeroImageResult::extract_markdown_image(content);
247        assert_eq!(path, Some("docs/hero.svg".to_string()));
248    }
249
250    #[test]
251    fn test_extract_html_image() {
252        let content = r#"<img src="docs/hero.svg" alt="hero">"#;
253        let path = HeroImageResult::extract_html_image(content);
254        assert_eq!(path, Some("docs/hero.svg".to_string()));
255    }
256
257    #[test]
258    fn test_extract_html_image_single_quotes() {
259        let content = r#"<img src='docs/hero.svg' alt='hero'>"#;
260        let path = HeroImageResult::extract_html_image(content);
261        assert_eq!(path, Some("docs/hero.svg".to_string()));
262    }
263
264    #[test]
265    fn test_image_format_extension() {
266        assert_eq!(ImageFormat::Png.extension(), "png");
267        assert_eq!(ImageFormat::Jpg.extension(), "jpg");
268        assert_eq!(ImageFormat::WebP.extension(), "webp");
269        assert_eq!(ImageFormat::Svg.extension(), "svg");
270    }
271
272    #[test]
273    fn test_hero_image_result_fields() {
274        let mut result = HeroImageResult::found(PathBuf::from("test.png"), ImageFormat::Png);
275        result.dimensions = Some((800, 600));
276        result.file_size = Some(1024);
277        assert_eq!(result.dimensions, Some((800, 600)));
278        assert_eq!(result.file_size, Some(1024));
279    }
280
281    #[test]
282    fn test_hero_image_detect_nonexistent_repo() {
283        let result = HeroImageResult::detect(Path::new("/nonexistent/path"));
284        assert!(!result.present);
285        assert!(!result.valid);
286    }
287
288    #[test]
289    fn test_extract_markdown_image_no_image() {
290        let content = "# Just text\nNo images here";
291        let path = HeroImageResult::extract_markdown_image(content);
292        assert!(path.is_none());
293    }
294
295    #[test]
296    fn test_extract_html_image_no_image() {
297        let content = "Just text, no img tags";
298        let path = HeroImageResult::extract_html_image(content);
299        assert!(path.is_none());
300    }
301
302    #[test]
303    fn test_extract_html_image_no_src() {
304        let content = r#"<img alt="test">"#;
305        let path = HeroImageResult::extract_html_image(content);
306        assert!(path.is_none());
307    }
308
309    #[test]
310    fn test_image_format_equality() {
311        assert_eq!(ImageFormat::Png, ImageFormat::Png);
312        assert_ne!(ImageFormat::Png, ImageFormat::Jpg);
313    }
314
315    #[test]
316    fn test_hero_image_result_missing_issues() {
317        let result = HeroImageResult::missing();
318        assert!(!result.issues.is_empty());
319        assert!(result.issues[0].contains("No hero image"));
320    }
321
322    #[test]
323    fn test_hero_image_found_no_issues() {
324        let result = HeroImageResult::found(PathBuf::from("test.svg"), ImageFormat::Svg);
325        assert!(result.issues.is_empty());
326    }
327}