1pub mod auto_discovery;
28mod svg_parser;
29
30use anyhow::{Context, Result, anyhow};
31use std::{
32 collections::HashSet,
33 fs,
34 path::{Path, PathBuf},
35};
36
37pub use svg_parser::{IconData, PathData, PathElement, SvgElem, SvgElement, SvgIcon};
38
39#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
41pub enum MdiStyle {
42 Filled,
44 Outline,
46}
47
48impl MdiStyle {
49 pub fn suffix(self) -> &'static str {
51 match self {
52 MdiStyle::Filled => "",
53 MdiStyle::Outline => "-outline",
54 }
55 }
56}
57
58#[derive(Clone, Debug)]
60pub enum IconSelection {
61 ByName(Vec<String>),
63 All,
65}
66
67impl IconSelection {
68 pub fn names<I, S>(names: I) -> Self
70 where
71 I: IntoIterator<Item = S>,
72 S: Into<String>,
73 {
74 IconSelection::ByName(names.into_iter().map(|s| s.into()).collect())
75 }
76}
77
78pub struct IconConfig {
80 pub selection: IconSelection,
82
83 pub styles: Vec<MdiStyle>,
85
86 pub output_file: PathBuf,
88}
89
90impl Default for IconConfig {
91 fn default() -> Self {
92 Self {
93 selection: IconSelection::ByName(vec![]),
94 styles: vec![MdiStyle::Filled, MdiStyle::Outline],
95 output_file: "src/generated/mdi_selected.rs".into(),
96 }
97 }
98}
99
100pub struct IconConfigBuilder {
102 config: IconConfig,
103}
104
105impl IconConfigBuilder {
106 pub fn new() -> Self {
108 Self {
109 config: IconConfig::default(),
110 }
111 }
112
113 pub fn selection(mut self, selection: IconSelection) -> Self {
115 self.config.selection = selection;
116 self
117 }
118
119 pub fn names<I, S>(self, names: I) -> Self
121 where
122 I: IntoIterator<Item = S>,
123 S: Into<String>,
124 {
125 self.selection(IconSelection::names(names))
126 }
127
128 pub fn styles(mut self, styles: Vec<MdiStyle>) -> Self {
130 self.config.styles = styles;
131 self
132 }
133
134 pub fn output(mut self, path: impl AsRef<Path>) -> Self {
136 self.config.output_file = path.as_ref().to_path_buf();
137 self
138 }
139
140 pub fn build(self) -> IconConfig {
142 self.config
143 }
144}
145
146impl Default for IconConfigBuilder {
147 fn default() -> Self {
148 Self::new()
149 }
150}
151
152pub fn build_selected_icons(config: &IconConfig) -> Result<()> {
160 println!("🎨 Building selected icons...");
161
162 let workspace_root = find_workspace_root()?;
164
165 let selected = match &config.selection {
167 IconSelection::ByName(names) => names.iter().cloned().collect::<HashSet<_>>(),
168 IconSelection::All => {
169 scan_available_icons(&workspace_root)?
171 }
172 };
173
174 if selected.is_empty() {
175 println!("⚠️ No icons selected!");
176 return Ok(());
177 }
178
179 println!(" Selected {} icons", selected.len());
180
181 let rust_code = generate_icon_module(&selected, &workspace_root)?;
183
184 let output_path = &config.output_file;
186 if let Some(parent) = output_path.parent() {
187 fs::create_dir_all(parent)?;
188 }
189
190 fs::write(output_path, rust_code)?;
191
192 if let Ok(metadata) = std::fs::metadata(output_path) {
194 println!(" Generated file size: {} bytes", metadata.len());
195 }
196
197 println!(" Output: {:?}", output_path);
198 println!("✅ Icon build complete!");
199
200 Ok(())
201}
202
203fn find_workspace_root() -> Result<PathBuf> {
205 let mut current = std::env::var("CARGO_MANIFEST_DIR")
206 .map(PathBuf::from)
207 .unwrap_or_else(|_| PathBuf::from("."));
208
209 loop {
210 let cargo_toml = current.join("Cargo.toml");
211 if cargo_toml.exists()
212 && let Ok(content) = fs::read_to_string(&cargo_toml)
213 && content.contains("[workspace]")
214 {
215 return Ok(current);
216 }
217
218 match current.parent() {
219 Some(parent) if parent != current => {
220 current = parent.to_path_buf();
221 }
222 _ => {
223 return Err(anyhow!("Workspace root not found"));
224 }
225 }
226 }
227}
228
229pub fn scan_available_icons(workspace_root: &Path) -> Result<HashSet<String>> {
231 let icons_dir = workspace_root.join("icons/mdi");
232 let mut icons = HashSet::new();
233
234 if !icons_dir.exists() {
235 return Err(anyhow!(
236 "Icons directory not found: {:?}. Run 'python scripts/icons/fetch_mdi_icons.py' first.",
237 icons_dir
238 ));
239 }
240
241 for entry in fs::read_dir(&icons_dir)
242 .with_context(|| format!("Failed to read icons directory: {:?}", icons_dir))?
243 {
244 let entry = entry?;
245 let path = entry.path();
246
247 if path.extension().and_then(|s| s.to_str()) == Some("svg")
248 && let Some(file_name) = path.file_stem().and_then(|s| s.to_str())
249 {
250 icons.insert(file_name.to_string());
251 }
252 }
253
254 Ok(icons)
255}
256
257fn read_svg_content(workspace_root: &Path, icon_name: &str) -> Result<String> {
259 let svg_path = workspace_root.join(format!("icons/mdi/{}.svg", icon_name));
260
261 if !svg_path.exists() {
262 return Err(anyhow!("SVG file not found: {:?}", svg_path));
263 }
264
265 fs::read_to_string(&svg_path).with_context(|| format!("Failed to read SVG: {:?}", svg_path))
266}
267
268fn generate_icon_module(selected_icons: &HashSet<String>, workspace_root: &Path) -> Result<String> {
270 let mut output = String::new();
271
272 output.push_str("// Auto-generated by hikari_builder::icons\n");
274 output.push_str("// DO NOT EDIT - This file is generated at build time\n");
275 output.push_str("//\n");
276 output.push_str("// Total selected icons: ");
277 output.push_str(&selected_icons.len().to_string());
278 output.push_str("\n\n");
279
280 output.push_str("/// Path data for generating Rust constants\n");
282 output.push_str("#[derive(Copy, Clone, Debug)]\n");
283 output.push_str("pub struct PathData {\n");
284 output.push_str(" pub d: Option<&'static str>,\n");
285 output.push_str(" pub fill: Option<&'static str>,\n");
286 output.push_str(" pub stroke: Option<&'static str>,\n");
287 output.push_str(" pub stroke_width: Option<&'static str>,\n");
288 output.push_str(" pub stroke_linecap: Option<&'static str>,\n");
289 output.push_str(" pub stroke_linejoin: Option<&'static str>,\n");
290 output.push_str(" pub transform: Option<&'static str>,\n");
291 output.push_str("}\n\n");
292
293 output.push_str("/// SVG element for generating Rust constants\n");
294 output.push_str("#[derive(Copy, Clone, Debug)]\n");
295 output.push_str("pub struct SvgElem {\n");
296 output.push_str(" pub tag: &'static str,\n");
297 output.push_str(" pub attributes: &'static [(&'static str, &'static str)],\n");
298 output.push_str("}\n\n");
299
300 output.push_str("/// Icon data for generating Rust constants\n");
301 output.push_str("#[derive(Copy, Clone, Debug)]\n");
302 output.push_str("pub struct IconData {\n");
303 output.push_str(" pub view_box: Option<&'static str>,\n");
304 output.push_str(" pub width: Option<&'static str>,\n");
305 output.push_str(" pub height: Option<&'static str>,\n");
306 output.push_str(" pub path: Option<&'static str>,\n");
307 output.push_str(" pub paths: &'static [PathData],\n");
308 output.push_str(" pub elements: &'static [SvgElem],\n");
309 output.push_str("}\n\n");
310
311 let mut sorted_icons: Vec<_> = selected_icons.iter().collect();
313 sorted_icons.sort();
314
315 let mut icon_data: Vec<(String, String, SvgIcon)> = Vec::new();
316 for icon_name in &sorted_icons {
317 match read_svg_content(workspace_root, icon_name) {
318 Ok(svg_content) => match svg_parser::parse_svg(&svg_content) {
319 Ok(icon) => {
320 let const_name = icon_name.replace('-', "_").to_uppercase();
321 icon_data.push((const_name, (**icon_name).clone(), icon));
322 }
323 Err(e) => {
324 eprintln!("⚠️ Failed to parse SVG for '{}': {}", icon_name, e);
325 }
326 },
327 Err(e) => {
328 eprintln!("⚠️ Failed to read SVG for '{}': {}", icon_name, e);
329 }
330 }
331 }
332
333 if icon_data.is_empty() {
334 eprintln!(
335 "⚠️ No icon data was generated! Selected {} icons but parsed 0.",
336 sorted_icons.len()
337 );
338 }
339
340 output.push_str("/// Structured icon data\n");
342 output.push_str("pub mod data {\n");
343 if !icon_data.is_empty() {
344 output.push_str(" use super::IconData;\n");
345 }
346 output.push('\n');
347
348 for (const_name, icon_name, icon) in &icon_data {
349 output.push_str(" /// Icon data for '");
350 output.push_str(icon_name);
351 output.push_str("'\n");
352 output.push_str(" pub const ");
353 output.push_str(const_name);
354 output.push_str(": IconData = IconData {\n");
355
356 output.push_str(" view_box: ");
358 if let Some(vb) = &icon.view_box {
359 output.push_str("Some(\"");
360 output.push_str(vb);
361 output.push_str("\"),\n");
362 } else {
363 output.push_str("None,\n");
364 }
365
366 output.push_str(" width: ");
368 if let Some(w) = &icon.width {
369 output.push_str("Some(\"");
370 output.push_str(w);
371 output.push_str("\"),\n");
372 } else {
373 output.push_str("None,\n");
374 }
375
376 output.push_str(" height: ");
378 if let Some(h) = &icon.height {
379 output.push_str("Some(\"");
380 output.push_str(h);
381 output.push_str("\"),\n");
382 } else {
383 output.push_str("None,\n");
384 }
385
386 output.push_str(" path: ");
388 if let Some(p) = &icon.path {
389 output.push_str("Some(\"");
390 output.push_str(p);
391 output.push_str("\"),\n");
392 } else {
393 output.push_str("None,\n");
394 }
395
396 output.push_str(" paths: &[");
398 for path in &icon.paths {
399 output.push_str("\n PathData {");
400 if let Some(d) = &path.d {
401 output.push_str(" d: Some(\"");
402 output.push_str(d);
403 output.push_str("\"),");
404 }
405 if let Some(f) = &path.fill {
406 output.push_str(" fill: Some(\"");
407 output.push_str(f);
408 output.push_str("\"),");
409 }
410 output.push_str(" },");
411 }
412 if icon.paths.is_empty() {
413 output.push_str("],\n");
414 } else {
415 output.push_str("\n ],\n");
416 }
417
418 output.push_str(" elements: &[");
420 if icon.elements.is_empty() {
421 output.push_str("],\n");
422 } else {
423 output.push_str("\n ],\n");
424 }
425
426 output.push_str(" };\n");
427 }
428
429 output.push_str("}\n\n");
430
431 output.push_str("/// Get icon data by name\n");
433 output.push_str("pub fn get(name: &str) -> Option<&'static IconData> {\n");
434 output.push_str(" match name {\n");
435
436 for (const_name, icon_name, _) in &icon_data {
437 output.push_str(" \"");
438 output.push_str(icon_name);
439 output.push_str("\" => Some(&data::");
440 output.push_str(const_name);
441 output.push_str("),\n");
442 }
443
444 output.push_str(" _ => None,\n");
445 output.push_str(" }\n");
446 output.push_str("}\n");
447
448 Ok(output)
449}
450
451pub fn build_icons() -> IconConfigBuilder {
453 IconConfigBuilder::new()
454}
455
456#[cfg(test)]
457mod tests {
458 use super::*;
459
460 #[test]
461 fn test_icon_selection_names() {
462 let selection = IconSelection::names(["moon", "sun"]);
463 assert!(matches!(selection, IconSelection::ByName(_)));
464 }
465
466 #[test]
467 fn test_mdi_style_suffix() {
468 assert_eq!(MdiStyle::Filled.suffix(), "");
469 assert_eq!(MdiStyle::Outline.suffix(), "-outline");
470 }
471}