Skip to main content

hikari_builder/
icons.rs

1//! # Hikari Icon Builder
2//!
3//! Build-time icon selection and packaging system.
4//!
5//! This module provides on-demand icon compilation, allowing projects to select
6//! specific icons to include in the final binary, reducing WASM size and compilation time.
7//!
8//! ## Usage in build.rs
9//!
10//! ```rust,ignore
11//! use hikari_builder::icons::{build_selected_icons, IconSelection};
12//!
13//! fn main() {
14//!     let selection = IconSelection::ByName(vec![
15//!         "moon-waning-crescent".into(),
16//!         "sun".into(),
17//!     ]);
18//!
19//!     build_selected_icons(&IconConfig {
20//!         selection,
21//!         output_file: "src/generated/icons.rs".into(),
22//!         ..Default::default()
23//!     }).expect("Failed to build icons");
24//! }
25//! ```
26
27pub 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/// Icon style variant for Material Design Icons
40#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
41pub enum MdiStyle {
42    /// Filled style (default, no suffix)
43    Filled,
44    /// Outline style (has -outline suffix)
45    Outline,
46}
47
48impl MdiStyle {
49    /// Get the file suffix for this style
50    pub fn suffix(self) -> &'static str {
51        match self {
52            MdiStyle::Filled => "",
53            MdiStyle::Outline => "-outline",
54        }
55    }
56}
57
58/// Icon selection strategy
59#[derive(Clone, Debug)]
60pub enum IconSelection {
61    /// Select specific icons by name
62    ByName(Vec<String>),
63    /// Select all icons (not recommended due to size)
64    All,
65}
66
67impl IconSelection {
68    /// Create a selection by specific icon names
69    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
78/// Icon build configuration
79pub struct IconConfig {
80    /// Which icons to include
81    pub selection: IconSelection,
82
83    /// Which styles to include (for MDI icons that have both)
84    pub styles: Vec<MdiStyle>,
85
86    /// Output file path for generated Rust code
87    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
100/// Builder for IconConfig
101pub struct IconConfigBuilder {
102    config: IconConfig,
103}
104
105impl IconConfigBuilder {
106    /// Create a new builder with default configuration
107    pub fn new() -> Self {
108        Self {
109            config: IconConfig::default(),
110        }
111    }
112
113    /// Set the icon selection
114    pub fn selection(mut self, selection: IconSelection) -> Self {
115        self.config.selection = selection;
116        self
117    }
118
119    /// Select specific icons by name
120    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    /// Set which styles to include
129    pub fn styles(mut self, styles: Vec<MdiStyle>) -> Self {
130        self.config.styles = styles;
131        self
132    }
133
134    /// Set the output file path
135    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    /// Build the configuration
141    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
152/// Build selected icons into a Rust module
153///
154/// This function:
155/// 1. Resolves the selection strategy to a set of icon names
156/// 2. Reads the SVG content for each selected icon
157/// 3. Generates a Rust module with icon data
158/// 4. Writes the output to the configured file
159pub fn build_selected_icons(config: &IconConfig) -> Result<()> {
160    println!("🎨 Building selected icons...");
161
162    // Find workspace root
163    let workspace_root = find_workspace_root()?;
164
165    // Resolve selection
166    let selected = match &config.selection {
167        IconSelection::ByName(names) => names.iter().cloned().collect::<HashSet<_>>(),
168        IconSelection::All => {
169            // Scan all available icons
170            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    // Generate Rust module
182    let rust_code = generate_icon_module(&selected, &workspace_root)?;
183
184    // Write output
185    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    // Debug: Show generated file size
193    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
203/// Find workspace root by looking for Cargo.toml with [workspace]
204fn 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
229/// Scan for available icons in icons/mdi/ directory
230pub 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
257/// Read and parse SVG content from file
258fn 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
268/// Generate Rust code for selected icons
269fn generate_icon_module(selected_icons: &HashSet<String>, workspace_root: &Path) -> Result<String> {
270    let mut output = String::new();
271
272    // Header
273    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    // Type definitions
281    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    // Collect icon data
312    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    // Generate structured data
341    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        // view_box
357        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        // width
367        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        // height
377        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        // path
387        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        // paths
397        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        // elements
419        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    // Get function
432    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
451/// Convenience function for building icons with a builder pattern
452pub 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}