1use serde::{Deserialize, Serialize};
7use std::path::{Path, PathBuf};
8
9#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
11pub enum ImageFormat {
12 Png,
13 Jpg,
14 WebP,
15 Svg,
16}
17
18impl ImageFormat {
19 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 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#[derive(Debug, Clone, Serialize, Deserialize)]
43pub struct HeroImageResult {
44 pub present: bool,
46 pub path: Option<PathBuf>,
48 pub format: Option<ImageFormat>,
50 pub dimensions: Option<(u32, u32)>,
52 pub file_size: Option<u64>,
54 pub valid: bool,
56 pub issues: Vec<String>,
58}
59
60impl HeroImageResult {
61 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 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 pub fn detect(repo_path: &Path) -> Self {
89 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 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 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 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 if let Ok(metadata) = std::fs::metadata(path) {
134 let size = metadata.len();
135 result.file_size = Some(size);
136
137 if size > 2 * 1024 * 1024 {
139 issues.push(format!("Image too large: {} bytes (max 2MB)", size));
140 }
141 }
142
143 if !issues.is_empty() {
147 result.valid = false;
148 result.issues = issues;
149 }
150
151 result
152 }
153
154 fn extract_first_image_from_readme(readme_path: &Path) -> Option<String> {
157 let content = std::fs::read_to_string(readme_path).ok()?;
158
159 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 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 fn extract_markdown_image(content: &str) -> Option<String> {
178 let start = content.find("![")?;
180 let after_bracket = &content[start + 2..];
181 let close_bracket = after_bracket.find("](")?;
183 let after_paren = &after_bracket[close_bracket + 2..];
184 let close_paren = after_paren.find(')')?;
186 Some(after_paren[..close_paren].to_string())
187 }
188
189 fn extract_html_image(content: &str) -> Option<String> {
191 let img_start = content.find("<img")?;
193 let after_img = &content[img_start..];
194 let tag_end = after_img.find('>')?;
196 let img_tag = &after_img[..tag_end];
197
198 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\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}