pub mod auto_discovery;
mod svg_parser;
use anyhow::{Context, Result, anyhow};
use std::{
collections::HashSet,
fs,
path::{Path, PathBuf},
};
pub use svg_parser::{IconData, PathData, PathElement, SvgElem, SvgElement, SvgIcon};
#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)]
pub enum MdiStyle {
Filled,
Outline,
}
impl MdiStyle {
pub fn suffix(self) -> &'static str {
match self {
MdiStyle::Filled => "",
MdiStyle::Outline => "-outline",
}
}
}
#[derive(Clone, Debug)]
pub enum IconSelection {
ByName(Vec<String>),
All,
}
impl IconSelection {
pub fn names<I, S>(names: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
IconSelection::ByName(names.into_iter().map(|s| s.into()).collect())
}
}
pub struct IconConfig {
pub selection: IconSelection,
pub styles: Vec<MdiStyle>,
pub output_file: PathBuf,
}
impl Default for IconConfig {
fn default() -> Self {
Self {
selection: IconSelection::ByName(vec![]),
styles: vec![MdiStyle::Filled, MdiStyle::Outline],
output_file: "src/generated/mdi_selected.rs".into(),
}
}
}
pub struct IconConfigBuilder {
config: IconConfig,
}
impl IconConfigBuilder {
pub fn new() -> Self {
Self {
config: IconConfig::default(),
}
}
pub fn selection(mut self, selection: IconSelection) -> Self {
self.config.selection = selection;
self
}
pub fn names<I, S>(self, names: I) -> Self
where
I: IntoIterator<Item = S>,
S: Into<String>,
{
self.selection(IconSelection::names(names))
}
pub fn styles(mut self, styles: Vec<MdiStyle>) -> Self {
self.config.styles = styles;
self
}
pub fn output(mut self, path: impl AsRef<Path>) -> Self {
self.config.output_file = path.as_ref().to_path_buf();
self
}
pub fn build(self) -> IconConfig {
self.config
}
}
impl Default for IconConfigBuilder {
fn default() -> Self {
Self::new()
}
}
pub fn build_selected_icons(config: &IconConfig) -> Result<()> {
println!("🎨 Building selected icons...");
let workspace_root = find_workspace_root()?;
let selected = match &config.selection {
IconSelection::ByName(names) => names.iter().cloned().collect::<HashSet<_>>(),
IconSelection::All => {
scan_available_icons(&workspace_root)?
}
};
if selected.is_empty() {
println!("⚠️ No icons selected!");
return Ok(());
}
println!(" Selected {} icons", selected.len());
let rust_code = generate_icon_module(&selected, &workspace_root)?;
let output_path = &config.output_file;
if let Some(parent) = output_path.parent() {
fs::create_dir_all(parent)?;
}
fs::write(output_path, rust_code)?;
if let Ok(metadata) = std::fs::metadata(output_path) {
println!(" Generated file size: {} bytes", metadata.len());
}
println!(" Output: {:?}", output_path);
println!("✅ Icon build complete!");
Ok(())
}
fn find_workspace_root() -> Result<PathBuf> {
let mut current = std::env::var("CARGO_MANIFEST_DIR")
.map(PathBuf::from)
.unwrap_or_else(|_| PathBuf::from("."));
loop {
let cargo_toml = current.join("Cargo.toml");
if cargo_toml.exists()
&& let Ok(content) = fs::read_to_string(&cargo_toml)
&& content.contains("[workspace]")
{
return Ok(current);
}
match current.parent() {
Some(parent) if parent != current => {
current = parent.to_path_buf();
}
_ => {
return Err(anyhow!("Workspace root not found"));
}
}
}
}
pub fn scan_available_icons(workspace_root: &Path) -> Result<HashSet<String>> {
let icons_dir = workspace_root.join("icons/mdi");
let mut icons = HashSet::new();
if !icons_dir.exists() {
return Err(anyhow!(
"Icons directory not found: {:?}. Run 'python scripts/icons/fetch_mdi_icons.py' first.",
icons_dir
));
}
for entry in fs::read_dir(&icons_dir)
.with_context(|| format!("Failed to read icons directory: {:?}", icons_dir))?
{
let entry = entry?;
let path = entry.path();
if path.extension().and_then(|s| s.to_str()) == Some("svg")
&& let Some(file_name) = path.file_stem().and_then(|s| s.to_str())
{
icons.insert(file_name.to_string());
}
}
Ok(icons)
}
fn read_svg_content(workspace_root: &Path, icon_name: &str) -> Result<String> {
let svg_path = workspace_root.join(format!("icons/mdi/{}.svg", icon_name));
if !svg_path.exists() {
return Err(anyhow!("SVG file not found: {:?}", svg_path));
}
fs::read_to_string(&svg_path).with_context(|| format!("Failed to read SVG: {:?}", svg_path))
}
fn generate_icon_module(selected_icons: &HashSet<String>, workspace_root: &Path) -> Result<String> {
let mut output = String::new();
output.push_str("// Auto-generated by hikari_builder::icons\n");
output.push_str("// DO NOT EDIT - This file is generated at build time\n");
output.push_str("//\n");
output.push_str("// Total selected icons: ");
output.push_str(&selected_icons.len().to_string());
output.push_str("\n\n");
output.push_str("/// Path data for generating Rust constants\n");
output.push_str("#[derive(Copy, Clone, Debug)]\n");
output.push_str("pub struct PathData {\n");
output.push_str(" pub d: Option<&'static str>,\n");
output.push_str(" pub fill: Option<&'static str>,\n");
output.push_str(" pub stroke: Option<&'static str>,\n");
output.push_str(" pub stroke_width: Option<&'static str>,\n");
output.push_str(" pub stroke_linecap: Option<&'static str>,\n");
output.push_str(" pub stroke_linejoin: Option<&'static str>,\n");
output.push_str(" pub transform: Option<&'static str>,\n");
output.push_str("}\n\n");
output.push_str("/// SVG element for generating Rust constants\n");
output.push_str("#[derive(Copy, Clone, Debug)]\n");
output.push_str("pub struct SvgElem {\n");
output.push_str(" pub tag: &'static str,\n");
output.push_str(" pub attributes: &'static [(&'static str, &'static str)],\n");
output.push_str("}\n\n");
output.push_str("/// Icon data for generating Rust constants\n");
output.push_str("#[derive(Copy, Clone, Debug)]\n");
output.push_str("pub struct IconData {\n");
output.push_str(" pub view_box: Option<&'static str>,\n");
output.push_str(" pub width: Option<&'static str>,\n");
output.push_str(" pub height: Option<&'static str>,\n");
output.push_str(" pub path: Option<&'static str>,\n");
output.push_str(" pub paths: &'static [PathData],\n");
output.push_str(" pub elements: &'static [SvgElem],\n");
output.push_str("}\n\n");
let mut sorted_icons: Vec<_> = selected_icons.iter().collect();
sorted_icons.sort();
let mut icon_data: Vec<(String, String, SvgIcon)> = Vec::new();
for icon_name in &sorted_icons {
match read_svg_content(workspace_root, icon_name) {
Ok(svg_content) => match svg_parser::parse_svg(&svg_content) {
Ok(icon) => {
let const_name = icon_name.replace('-', "_").to_uppercase();
icon_data.push((const_name, (**icon_name).clone(), icon));
}
Err(e) => {
eprintln!("⚠️ Failed to parse SVG for '{}': {}", icon_name, e);
}
},
Err(e) => {
eprintln!("⚠️ Failed to read SVG for '{}': {}", icon_name, e);
}
}
}
if icon_data.is_empty() {
eprintln!(
"⚠️ No icon data was generated! Selected {} icons but parsed 0.",
sorted_icons.len()
);
}
output.push_str("/// Structured icon data\n");
output.push_str("pub mod data {\n");
if !icon_data.is_empty() {
output.push_str(" use super::IconData;\n");
}
output.push('\n');
for (const_name, icon_name, icon) in &icon_data {
output.push_str(" /// Icon data for '");
output.push_str(icon_name);
output.push_str("'\n");
output.push_str(" pub const ");
output.push_str(const_name);
output.push_str(": IconData = IconData {\n");
output.push_str(" view_box: ");
if let Some(vb) = &icon.view_box {
output.push_str("Some(\"");
output.push_str(vb);
output.push_str("\"),\n");
} else {
output.push_str("None,\n");
}
output.push_str(" width: ");
if let Some(w) = &icon.width {
output.push_str("Some(\"");
output.push_str(w);
output.push_str("\"),\n");
} else {
output.push_str("None,\n");
}
output.push_str(" height: ");
if let Some(h) = &icon.height {
output.push_str("Some(\"");
output.push_str(h);
output.push_str("\"),\n");
} else {
output.push_str("None,\n");
}
output.push_str(" path: ");
if let Some(p) = &icon.path {
output.push_str("Some(\"");
output.push_str(p);
output.push_str("\"),\n");
} else {
output.push_str("None,\n");
}
output.push_str(" paths: &[");
for path in &icon.paths {
output.push_str("\n PathData {");
if let Some(d) = &path.d {
output.push_str(" d: Some(\"");
output.push_str(d);
output.push_str("\"),");
}
if let Some(f) = &path.fill {
output.push_str(" fill: Some(\"");
output.push_str(f);
output.push_str("\"),");
}
output.push_str(" },");
}
if icon.paths.is_empty() {
output.push_str("],\n");
} else {
output.push_str("\n ],\n");
}
output.push_str(" elements: &[");
if icon.elements.is_empty() {
output.push_str("],\n");
} else {
output.push_str("\n ],\n");
}
output.push_str(" };\n");
}
output.push_str("}\n\n");
output.push_str("/// Get icon data by name\n");
output.push_str("pub fn get(name: &str) -> Option<&'static IconData> {\n");
output.push_str(" match name {\n");
for (const_name, icon_name, _) in &icon_data {
output.push_str(" \"");
output.push_str(icon_name);
output.push_str("\" => Some(&data::");
output.push_str(const_name);
output.push_str("),\n");
}
output.push_str(" _ => None,\n");
output.push_str(" }\n");
output.push_str("}\n");
Ok(output)
}
pub fn build_icons() -> IconConfigBuilder {
IconConfigBuilder::new()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_icon_selection_names() {
let selection = IconSelection::names(["moon", "sun"]);
assert!(matches!(selection, IconSelection::ByName(_)));
}
#[test]
fn test_mdi_style_suffix() {
assert_eq!(MdiStyle::Filled.suffix(), "");
assert_eq!(MdiStyle::Outline.suffix(), "-outline");
}
}