use anyhow::{Context, Result};
use indoc::{formatdoc, indoc};
use std::collections::{BTreeMap, HashMap};
use std::fs;
use std::path::{Path, PathBuf};
use crate::api::{IconifyCollectionInfo, IconifyIcon};
use crate::naming::IconIdentifier;
const MOD_RS_TEMPLATE: &str = indoc! {r#"// Auto-generated by dioxus-iconify - DO NOT EDIT
use dioxus::prelude::*;
#[derive(Clone, Copy, PartialEq)]
pub struct IconData {
pub name: &'static str,
pub body: &'static str,
pub view_box: &'static str,
pub width: &'static str,
pub height: &'static str,
}
#[component]
pub fn Icon(
data: IconData,
/// Optional size to set both width and height
#[props(default, into)]
size: String,
/// Additional attributes to extend the svg element
#[props(extends = SvgAttributes)]
attributes: Vec<Attribute>,
) -> Element {
let (width, height) = if size.is_empty() {
(data.width, data.height)
} else {
(size.as_str(), size.as_str())
};
rsx! {
svg {
view_box: "{data.view_box}",
width: "{width}",
height: "{height}",
dangerous_inner_html: "{data.body}",
..attributes,
}
}
}
"#};
#[derive(Debug, Clone)]
struct IconConst {
name: String,
full_icon_name: String,
body: String,
view_box: String,
width: String,
height: String,
}
impl IconConst {
fn from_api_icon(identifier: &IconIdentifier, icon: &IconifyIcon) -> Self {
Self {
name: identifier.to_const_name(),
full_icon_name: identifier.full_name.clone(),
body: icon.body.clone(),
view_box: icon
.view_box
.clone()
.unwrap_or_else(|| "0 0 24 24".to_string()),
width: icon.width.unwrap_or(24).to_string(),
height: icon.height.unwrap_or(24).to_string(),
}
}
fn to_rust_code(&self) -> String {
formatdoc! { "
#[allow(non_upper_case_globals)]
pub const {}: IconData = IconData {{
name: \"{}\",
body: r#\"{}\"#,
view_box: \"{}\",
width: \"{}\",
height: \"{}\",
}};
",
self.name,
self.full_icon_name,
self.body,
self.view_box,
self.width,
self.height
}
}
}
pub struct Generator {
icons_dir: PathBuf,
}
impl Generator {
pub fn new(icons_dir: PathBuf) -> Self {
Self { icons_dir }
}
pub fn list_icons(&self) -> Result<BTreeMap<String, Vec<String>>> {
let mut icons_by_collection: BTreeMap<String, Vec<String>> = BTreeMap::new();
if !self.icons_dir.exists() {
return Ok(icons_by_collection);
}
let entries = fs::read_dir(&self.icons_dir).context("Failed to read icons directory")?;
for entry in entries {
let entry = entry.context("Failed to read directory entry")?;
let path = entry.path();
if !path.is_file() || path.file_name() == Some("mod.rs".as_ref()) {
continue;
}
if path.extension() != Some("rs".as_ref()) {
continue;
}
let icons = self.parse_collection_file(&path)?;
if let Some(collection_name) = path.file_stem().and_then(|s| s.to_str()) {
let icon_names: Vec<String> = icons
.values()
.map(|icon| icon.full_icon_name.clone())
.collect();
if !icon_names.is_empty() {
icons_by_collection.insert(collection_name.to_string(), icon_names);
}
}
}
Ok(icons_by_collection)
}
pub fn get_all_icon_identifiers(&self) -> Result<Vec<String>> {
let icons_by_collection = self.list_icons()?;
let mut all_icons = Vec::new();
for icon_names in icons_by_collection.values() {
all_icons.extend(icon_names.clone());
}
Ok(all_icons)
}
pub fn init(&self) -> Result<()> {
if !self.icons_dir.exists() {
fs::create_dir_all(&self.icons_dir).context("Failed to create icons directory")?;
}
let mod_rs_path = self.icons_dir.join("mod.rs");
if !mod_rs_path.exists() {
fs::write(&mod_rs_path, MOD_RS_TEMPLATE).context("Failed to create mod.rs")?;
}
Ok(())
}
pub fn add_icons(
&self,
icons: &[(IconIdentifier, IconifyIcon)],
collection_info: &HashMap<String, IconifyCollectionInfo>,
) -> Result<()> {
self.init()?;
let mut icons_by_collection: HashMap<String, Vec<(IconIdentifier, IconifyIcon)>> =
HashMap::new();
for (identifier, icon) in icons {
icons_by_collection
.entry(identifier.collection.clone())
.or_default()
.push((identifier.clone(), icon.clone()));
}
for (collection, collection_icons) in &icons_by_collection {
let info = collection_info.get(collection);
self.update_collection_file(collection, collection_icons, info)?;
}
self.update_mod_rs(&icons_by_collection.keys().cloned().collect::<Vec<_>>())?;
Ok(())
}
pub fn regenerate_mod_rs(&self) -> Result<()> {
let mod_rs_path = self.icons_dir.join("mod.rs");
if !mod_rs_path.exists() {
return self.init();
}
let content = fs::read_to_string(&mod_rs_path).context("Failed to read mod.rs")?;
let existing_modules = extract_module_declarations(&content);
let mut new_content = MOD_RS_TEMPLATE.to_string();
new_content.push('\n');
let mut sorted_modules: Vec<_> = existing_modules.iter().collect();
sorted_modules.sort_by_key(|(name, _)| *name);
for (module, visibility) in sorted_modules {
new_content.push_str(&format!("{}mod {};\n", visibility, module));
}
fs::write(&mod_rs_path, new_content).context("Failed to update mod.rs")?;
Ok(())
}
fn update_collection_file(
&self,
collection: &str,
new_icons: &[(IconIdentifier, IconifyIcon)],
collection_info: Option<&IconifyCollectionInfo>,
) -> Result<()> {
let module_name = collection.replace('-', "_");
let file_path = self.icons_dir.join(format!("{}.rs", module_name));
let mut existing_icons: BTreeMap<String, IconConst> = BTreeMap::new();
if file_path.exists() {
existing_icons = self.parse_collection_file(&file_path)?;
}
for (identifier, icon) in new_icons {
let icon_const = IconConst::from_api_icon(identifier, icon);
existing_icons.insert(icon_const.name.clone(), icon_const);
}
let content =
self.generate_collection_file(collection, &existing_icons, collection_info)?;
fs::write(&file_path, content)
.context(format!("Failed to write collection file {:?}", file_path))?;
println!(
"✓ Updated {}.rs with {} icon(s)",
module_name,
new_icons.len()
);
Ok(())
}
fn parse_collection_file(&self, path: &Path) -> Result<BTreeMap<String, IconConst>> {
let content =
fs::read_to_string(path).context(format!("Failed to read file {:?}", path))?;
let mut icons = BTreeMap::new();
let lines: Vec<&str> = content.lines().collect();
let mut i = 0;
while i < lines.len() {
let line = lines[i].trim();
if line.starts_with("pub const ")
&& line.contains(": IconData")
&& let Some(name_end) = line.find(':')
{
let name = line[10..name_end].trim().to_string();
if let Some(icon_const) = self.parse_icon_data(&lines, &mut i, &name) {
icons.insert(name, icon_const);
}
}
i += 1;
}
Ok(icons)
}
fn parse_icon_data(
&self,
lines: &[&str],
index: &mut usize,
const_name: &str,
) -> Option<IconConst> {
let mut full_icon_name = String::new();
let mut body = String::new();
let mut view_box = String::new();
let mut width = String::new();
let mut height = String::new();
let mut j = *index;
while j < lines.len() {
let line = lines[j].trim();
if line.contains("name:") {
full_icon_name = extract_string_value(line);
} else if line.contains("body:") {
body = extract_raw_string_value(lines, &mut j);
} else if line.contains("view_box:") {
view_box = extract_string_value(line);
} else if line.contains("width:") {
width = extract_string_value(line);
} else if line.contains("height:") {
height = extract_string_value(line);
}
if line.contains("};") {
break;
}
j += 1;
}
*index = j;
if !full_icon_name.is_empty() && !body.is_empty() {
Some(IconConst {
name: const_name.to_string(),
full_icon_name,
body,
view_box,
width,
height,
})
} else {
None
}
}
fn generate_collection_file(
&self,
collection: &str,
icons: &BTreeMap<String, IconConst>,
collection_info: Option<&IconifyCollectionInfo>,
) -> Result<String> {
let mut content = String::from("/// Auto-generated by dioxus-iconify - DO NOT EDIT\n");
let now = chrono::Utc::now();
content.push_str(&format!("/// Generated: {}\n", now.to_rfc3339()));
content.push_str(&format!("/// Collection: {}\n", collection));
content.push_str("/// This is a partial import from Iconify\n");
content.push_str(&format!(
"/// Browse icons: https://icon-sets.iconify.design/{}/\n",
collection
));
if let Some(info) = collection_info {
content.push_str("///\n");
content.push_str(&format_collection_info_comment(info));
}
content.push_str("use super::IconData;\n");
for icon_const in icons.values() {
content.push_str(&icon_const.to_rust_code());
}
Ok(content)
}
fn update_mod_rs(&self, collections: &[String]) -> Result<()> {
let mod_rs_path = self.icons_dir.join("mod.rs");
let content = fs::read_to_string(&mod_rs_path).context("Failed to read mod.rs")?;
let mut existing_modules = extract_module_declarations(&content);
let mut needs_update = false;
for collection in collections {
let module_name = collection.replace('-', "_");
if let std::collections::hash_map::Entry::Vacant(e) =
existing_modules.entry(module_name)
{
e.insert("pub ".to_string());
needs_update = true;
}
}
if needs_update {
let mut new_content = MOD_RS_TEMPLATE.to_string();
new_content.push('\n');
let mut sorted_modules: Vec<_> = existing_modules.iter().collect();
sorted_modules.sort_by_key(|(name, _)| *name);
for (module, visibility) in sorted_modules {
new_content.push_str(&format!("{}mod {};\n", visibility, module));
}
fs::write(&mod_rs_path, new_content).context("Failed to update mod.rs")?;
}
Ok(())
}
}
fn extract_module_declarations(content: &str) -> HashMap<String, String> {
let mut modules = HashMap::new();
for line in content.lines() {
let trimmed = line.trim();
if !trimmed.ends_with(';') {
continue;
}
let without_semi = &trimmed[..trimmed.len() - 1];
if let Some(mod_idx) = without_semi.find("mod ") {
let visibility = if mod_idx == 0 {
String::new()
} else {
let vis = without_semi[..mod_idx].trim();
if vis.is_empty() {
String::new()
} else {
vis.to_string() + " "
}
};
let module_name = without_semi[mod_idx + 4..].trim();
if !module_name.is_empty() {
modules.insert(module_name.to_string(), visibility);
}
}
}
modules
}
fn format_collection_info_comment(info: &IconifyCollectionInfo) -> String {
use crate::api::{IconifyAuthor, IconifyLicense};
let mut lines = Vec::new();
lines.push("/// ```yaml".to_string());
if let Some(name) = &info.name {
lines.push(format!("/// name: {}", name));
}
if let Some(author) = &info.author {
match author {
IconifyAuthor::Simple(s) => {
lines.push(format!("/// author: {}", s));
}
IconifyAuthor::Detailed { name, url } => {
lines.push("/// author:".to_string());
if let Some(n) = name {
lines.push(format!("/// name: {}", n));
}
if let Some(u) = url {
lines.push(format!("/// url: {}", u));
}
}
}
}
if let Some(license) = &info.license {
match license {
IconifyLicense::Simple(s) => {
lines.push(format!("/// license: {}", s));
}
IconifyLicense::Detailed { title, spdx, url } => {
lines.push("/// license:".to_string());
if let Some(t) = title {
lines.push(format!("/// title: {}", t));
}
if let Some(s) = spdx {
lines.push(format!("/// spdx: {}", s));
}
if let Some(u) = url {
lines.push(format!("/// url: {}", u));
}
}
}
}
if let Some(total) = info.total {
lines.push(format!("/// total: {}", total));
}
if let Some(category) = &info.category {
lines.push(format!("/// category: {}", category));
}
if let Some(palette) = info.palette {
lines.push(format!("/// palette: {}", palette));
}
if let Some(height) = info.height {
lines.push(format!("/// height: {}", height));
}
lines.push("/// ```".to_string());
lines.join("\n") + "\n"
}
fn extract_string_value(line: &str) -> String {
if let Some(start) = line.find('"')
&& let Some(end) = line.rfind('"')
&& end > start
{
return line[start + 1..end].to_string();
}
String::new()
}
fn extract_raw_string_value(lines: &[&str], index: &mut usize) -> String {
let line = lines[*index];
if let Some(start) = line.find("r#\"") {
let start_pos = start + 3;
if let Some(end) = line[start_pos..].find("\"#") {
return line[start_pos..start_pos + end].to_string();
}
let mut result = line[start_pos..].to_string();
*index += 1;
while *index < lines.len() {
let next_line = lines[*index];
if let Some(end) = next_line.find("\"#") {
result.push_str(&next_line[..end]);
break;
}
result.push_str(next_line);
result.push('\n');
*index += 1;
}
result
} else {
String::new()
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::api::IconifyIcon;
use tempfile::TempDir;
#[test]
fn test_list_icons_empty_directory() -> Result<()> {
let temp_dir = TempDir::new()?;
let generator = Generator::new(temp_dir.path().join("icons"));
let icons = generator.list_icons()?;
assert!(
icons.is_empty(),
"Should return empty map for non-existent directory"
);
Ok(())
}
#[test]
fn test_list_icons_with_generated_icons() -> Result<()> {
let temp_dir = TempDir::new()?;
let icons_dir = temp_dir.path().join("icons");
let generator = Generator::new(icons_dir.clone());
let test_icon1 = IconifyIcon {
body: r#"<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>"#.to_string(),
width: Some(24),
height: Some(24),
view_box: Some("0 0 24 24".to_string()),
};
let test_icon2 = IconifyIcon {
body: r#"<circle cx="12" cy="12" r="10"/>"#.to_string(),
width: Some(24),
height: Some(24),
view_box: Some("0 0 24 24".to_string()),
};
let identifier1 = IconIdentifier::parse("mdi:home")?;
let identifier2 = IconIdentifier::parse("mdi:settings")?;
let identifier3 = IconIdentifier::parse("heroicons:arrow-left")?;
generator.add_icons(
&[
(identifier1, test_icon1.clone()),
(identifier2, test_icon2.clone()),
(identifier3, test_icon1.clone()),
],
&HashMap::new(),
)?;
let icons = generator.list_icons()?;
assert_eq!(icons.len(), 2, "Should have 2 collections");
assert!(icons.contains_key("mdi"), "Should have mdi collection");
assert!(
icons.contains_key("heroicons"),
"Should have heroicons collection"
);
let mdi_icons = icons.get("mdi").unwrap();
assert_eq!(mdi_icons.len(), 2, "mdi should have 2 icons");
assert!(mdi_icons.contains(&"mdi:home".to_string()));
assert!(mdi_icons.contains(&"mdi:settings".to_string()));
let heroicons_icons = icons.get("heroicons").unwrap();
assert_eq!(heroicons_icons.len(), 1, "heroicons should have 1 icon");
assert!(heroicons_icons.contains(&"heroicons:arrow-left".to_string()));
Ok(())
}
#[test]
fn test_get_all_icon_identifiers() -> Result<()> {
let temp_dir = TempDir::new()?;
let icons_dir = temp_dir.path().join("icons");
let generator = Generator::new(icons_dir.clone());
let empty_icons = generator.get_all_icon_identifiers()?;
assert!(
empty_icons.is_empty(),
"Should return empty vec for no icons"
);
let test_icon = IconifyIcon {
body: r#"<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>"#.to_string(),
width: Some(24),
height: Some(24),
view_box: Some("0 0 24 24".to_string()),
};
let identifier1 = IconIdentifier::parse("mdi:home")?;
let identifier2 = IconIdentifier::parse("mdi:settings")?;
let identifier3 = IconIdentifier::parse("heroicons:arrow-left")?;
generator.add_icons(
&[
(identifier1, test_icon.clone()),
(identifier2, test_icon.clone()),
(identifier3, test_icon.clone()),
],
&HashMap::new(),
)?;
let all_icons = generator.get_all_icon_identifiers()?;
assert_eq!(all_icons.len(), 3, "Should have 3 icons");
assert!(
all_icons.contains(&"mdi:home".to_string()),
"Should contain mdi:home"
);
assert!(
all_icons.contains(&"mdi:settings".to_string()),
"Should contain mdi:settings"
);
assert!(
all_icons.contains(&"heroicons:arrow-left".to_string()),
"Should contain heroicons:arrow-left"
);
Ok(())
}
#[test]
fn test_regenerate_mod_rs_updates_template() -> Result<()> {
let temp_dir = TempDir::new()?;
let icons_dir = temp_dir.path().join("icons");
let generator = Generator::new(icons_dir.clone());
let test_icon = IconifyIcon {
body: r#"<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>"#.to_string(),
width: Some(24),
height: Some(24),
view_box: Some("0 0 24 24".to_string()),
};
let identifier1 = IconIdentifier::parse("mdi:home")?;
let identifier2 = IconIdentifier::parse("heroicons:arrow-left")?;
generator.add_icons(
&[
(identifier1, test_icon.clone()),
(identifier2, test_icon.clone()),
],
&HashMap::new(),
)?;
let mod_rs_path = icons_dir.join("mod.rs");
assert!(mod_rs_path.exists(), "mod.rs should exist");
let old_content = r#"// Auto-generated by dioxus-iconify - DO NOT EDIT
use dioxus::prelude::*;
// OLD VERSION WITHOUT SIZE PARAMETER
#[component]
pub fn Icon(data: IconData) -> Element {
rsx! { svg {} }
}
pub mod heroicons;
pub mod mdi;
"#;
fs::write(&mod_rs_path, old_content)?;
let content_before = fs::read_to_string(&mod_rs_path)?;
assert!(
content_before.contains("OLD VERSION"),
"Should have old version marker"
);
assert!(
!content_before.contains("size: Option<String>"),
"Should not have size parameter yet"
);
generator.regenerate_mod_rs()?;
let content_after = fs::read_to_string(&mod_rs_path)?;
assert!(
!content_after.contains("OLD VERSION"),
"Should not have old version marker"
);
assert!(
content_after.contains("size: String"),
"Should have size parameter from latest template"
);
assert!(
content_after.contains("pub mod heroicons;"),
"Should preserve heroicons module"
);
assert!(
content_after.contains("pub mod mdi;"),
"Should preserve mdi module"
);
Ok(())
}
#[test]
fn test_collection_info_in_generated_file() -> Result<()> {
use crate::api::{IconifyAuthor, IconifyCollectionInfo, IconifyLicense};
let temp_dir = TempDir::new()?;
let icons_dir = temp_dir.path().join("icons");
let generator = Generator::new(icons_dir.clone());
let test_icon = IconifyIcon {
body: r#"<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>"#.to_string(),
width: Some(24),
height: Some(24),
view_box: Some("0 0 24 24".to_string()),
};
let identifier = IconIdentifier::parse("mdi:home")?;
let mut collection_info = HashMap::new();
collection_info.insert(
"mdi".to_string(),
IconifyCollectionInfo {
name: Some("Material Design Icons".to_string()),
author: Some(IconifyAuthor::Detailed {
name: Some("Pictogrammers".to_string()),
url: Some("https://pictogrammers.com".to_string()),
}),
license: Some(IconifyLicense::Detailed {
title: Some("Apache 2.0".to_string()),
spdx: Some("Apache-2.0".to_string()),
url: Some("https://www.apache.org/licenses/LICENSE-2.0".to_string()),
}),
total: Some(7000),
category: Some("General".to_string()),
palette: Some(false),
height: Some(24),
},
);
generator.add_icons(&[(identifier, test_icon)], &collection_info)?;
let generated_file = icons_dir.join("mdi.rs");
let content = fs::read_to_string(&generated_file)?;
assert!(
content.contains("/// Generated:"),
"Should include generation timestamp"
);
assert!(
content.contains("/// Collection: mdi"),
"Should include collection name"
);
assert!(
content.contains("/// This is a partial import from Iconify"),
"Should indicate partial import"
);
assert!(
content.contains("/// Browse icons: https://icon-sets.iconify.design/mdi/"),
"Should include browse URL"
);
assert!(
content.contains("/// ```yaml"),
"Should include YAML code block"
);
assert!(
content.contains("name: Material Design Icons"),
"Should include collection name"
);
assert!(content.contains("author:"), "Should include author section");
assert!(
content.contains("name: Pictogrammers"),
"Should include author name"
);
assert!(
content.contains("url: https://pictogrammers.com"),
"Should include author URL"
);
assert!(
content.contains("license:"),
"Should include license section"
);
assert!(
content.contains("title: Apache 2.0"),
"Should include license title"
);
assert!(
content.contains("spdx: Apache-2.0"),
"Should include SPDX identifier"
);
assert!(
content.contains("total: 7000"),
"Should include total count"
);
assert!(
content.contains("category: General"),
"Should include category"
);
assert!(content.contains("palette: false"), "Should include palette");
assert!(content.contains("height: 24"), "Should include height");
Ok(())
}
#[test]
fn test_module_visibility_preservation() -> Result<()> {
let temp_dir = TempDir::new()?;
let icons_dir = temp_dir.path().join("icons");
let generator = Generator::new(icons_dir.clone());
let test_icon = IconifyIcon {
body: r#"<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>"#.to_string(),
width: Some(24),
height: Some(24),
view_box: Some("0 0 24 24".to_string()),
};
let identifier1 = IconIdentifier::parse("mdi:home")?;
let identifier2 = IconIdentifier::parse("heroicons:arrow-left")?;
generator.add_icons(
&[
(identifier1, test_icon.clone()),
(identifier2, test_icon.clone()),
],
&HashMap::new(),
)?;
let mod_rs_path = icons_dir.join("mod.rs");
let modified_content = r#"// Auto-generated by dioxus-iconify - DO NOT EDIT
use dioxus::prelude::*;
#[derive(Clone, Copy, PartialEq)]
pub struct IconData {
pub name: &'static str,
pub body: &'static str,
pub view_box: &'static str,
pub width: &'static str,
pub height: &'static str,
}
#[component]
pub fn Icon(
data: IconData,
/// Optional size to set both width and height
#[props(default, into)]
size: String,
/// Additional attributes to extend the svg element
#[props(extends = GlobalAttributes)]
attributes: Vec<Attribute>,
) -> Element {
let (width, height) = if size.is_empty() {
(data.width, data.height)
} else {
(size.as_str(), size.as_str())
};
rsx! {
svg {
view_box: "{data.view_box}",
width: "{width}",
height: "{height}",
dangerous_inner_html: "{data.body}",
..attributes,
}
}
}
mod heroicons;
pub mod mdi;
"#;
fs::write(&mod_rs_path, modified_content)?;
let identifier3 = IconIdentifier::parse("lucide:star")?;
generator.add_icons(&[(identifier3, test_icon.clone())], &HashMap::new())?;
let content_after = fs::read_to_string(&mod_rs_path)?;
assert!(
content_after.contains("mod heroicons;"),
"Should preserve 'mod heroicons;' without pub"
);
assert!(
content_after.contains("pub mod mdi;"),
"Should preserve 'pub mod mdi;'"
);
assert!(
content_after.contains("pub mod lucide;"),
"New modules should be added with 'pub mod'"
);
let has_heroicons = content_after.lines().any(|l| l.trim() == "mod heroicons;");
let has_mdi = content_after.lines().any(|l| l.trim() == "pub mod mdi;");
let has_lucide = content_after.lines().any(|l| l.trim() == "pub mod lucide;");
assert!(has_heroicons, "Should have mod heroicons;");
assert!(has_mdi, "Should have pub mod mdi;");
assert!(has_lucide, "Should have pub mod lucide;");
Ok(())
}
#[test]
fn test_extract_module_declarations() {
let content = r#"
// Some comment
pub mod mdi;
mod heroicons;
pub(crate) mod feather;
pub mod simple_icons;
"#;
let modules = extract_module_declarations(content);
assert_eq!(modules.len(), 4);
assert_eq!(modules.get("mdi"), Some(&"pub ".to_string()));
assert_eq!(modules.get("heroicons"), Some(&"".to_string()));
assert_eq!(modules.get("feather"), Some(&"pub(crate) ".to_string()));
assert_eq!(modules.get("simple_icons"), Some(&"pub ".to_string()));
}
#[test]
fn test_custom_user_module_preservation() -> Result<()> {
let temp_dir = TempDir::new()?;
let icons_dir = temp_dir.path().join("icons");
let generator = Generator::new(icons_dir.clone());
fs::create_dir_all(&icons_dir)?;
let app_rs_path = icons_dir.join("app.rs");
let custom_icon_content = r##"/// Custom user-defined icons
use super::IconData;
#[allow(non_upper_case_globals)]
pub const CustomLogo: IconData = IconData {
name: "app:custom-logo",
body: r#"<rect width="100" height="100" fill="blue"/>"#,
view_box: "0 0 100 100",
width: "100",
height: "100",
};
#[allow(non_upper_case_globals)]
pub const CustomBrand: IconData = IconData {
name: "app:custom-brand",
body: r#"<circle cx="50" cy="50" r="40" fill="red"/>"#,
view_box: "0 0 100 100",
width: "100",
height: "100",
};
"##;
fs::write(&app_rs_path, custom_icon_content)?;
generator.init()?;
let mod_rs_path = icons_dir.join("mod.rs");
let initial_mod_content = format!(
"{}
pub(crate) mod app;
",
MOD_RS_TEMPLATE
);
fs::write(&mod_rs_path, initial_mod_content)?;
assert!(app_rs_path.exists(), "Custom app.rs should exist");
let custom_content_before = fs::read_to_string(&app_rs_path)?;
assert!(
custom_content_before.contains("CustomLogo"),
"Custom icon should be defined"
);
let test_icon = IconifyIcon {
body: r#"<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>"#.to_string(),
width: Some(24),
height: Some(24),
view_box: Some("0 0 24 24".to_string()),
};
let identifier1 = IconIdentifier::parse("mdi:home")?;
let identifier2 = IconIdentifier::parse("heroicons:star")?;
generator.add_icons(
&[
(identifier1, test_icon.clone()),
(identifier2, test_icon.clone()),
],
&HashMap::new(),
)?;
assert!(
app_rs_path.exists(),
"Custom app.rs should still exist after adding generated icons"
);
let custom_content_after = fs::read_to_string(&app_rs_path)?;
assert_eq!(
custom_content_before, custom_content_after,
"Custom module content should not be modified"
);
let mod_rs_content = fs::read_to_string(&mod_rs_path)?;
assert!(
mod_rs_content.contains("pub(crate) mod app;"),
"Custom module should be preserved with pub(crate) visibility"
);
assert!(
mod_rs_content.contains("pub mod mdi;"),
"Generated mdi module should be added"
);
assert!(
mod_rs_content.contains("pub mod heroicons;"),
"Generated heroicons module should be added"
);
let module_count = mod_rs_content
.lines()
.filter(|line| line.trim().contains("mod ") && line.trim().ends_with(';'))
.count();
assert_eq!(
module_count, 3,
"Should have exactly 3 modules: app, heroicons, and mdi"
);
let mod_lines: Vec<&str> = mod_rs_content
.lines()
.filter(|line| line.trim().contains("mod ") && line.trim().ends_with(';'))
.collect();
assert_eq!(mod_lines.len(), 3, "Should have 3 module lines");
let mut module_names: Vec<String> = Vec::new();
for line in &mod_lines {
if let Some(name_start) = line.find("mod ") {
let name_part = &line[name_start + 4..];
if let Some(name_end) = name_part.find(';') {
module_names.push(name_part[..name_end].trim().to_string());
}
}
}
assert_eq!(module_names, vec!["app", "heroicons", "mdi"]);
Ok(())
}
}