dioxus_iconify/
generator.rs

1use anyhow::{Context, Result};
2use indoc::{formatdoc, indoc};
3use std::collections::{BTreeMap, HashMap};
4use std::fs;
5use std::path::{Path, PathBuf};
6
7use crate::api::{IconifyCollectionInfo, IconifyIcon};
8use crate::naming::IconIdentifier;
9
10const MOD_RS_TEMPLATE: &str = indoc! {r#"// Auto-generated by dioxus-iconify - DO NOT EDIT
11    use dioxus::prelude::*;
12
13    #[derive(Clone, Copy, PartialEq)]
14    pub struct IconData {
15        pub name: &'static str,
16        pub body: &'static str,
17        pub view_box: &'static str,
18        pub width: &'static str,
19        pub height: &'static str,
20    }
21
22    #[component]
23    pub fn Icon(
24        data: IconData,
25        /// Optional size to set both width and height
26        #[props(default, into)]
27        size: String,
28        /// Additional attributes to extend the svg element
29        #[props(extends = GlobalAttributes)]
30        attributes: Vec<Attribute>,
31    ) -> Element {
32        let (width, height) = if size.is_empty() {
33            (data.width, data.height)
34        } else {
35            (size.as_str(), size.as_str())
36        };
37
38        rsx! {
39            svg {
40                view_box: "{data.view_box}",
41                width: "{width}",
42                height: "{height}",
43                dangerous_inner_html: "{data.body}",
44                ..attributes,
45            }
46        }
47    }
48    "#};
49
50/// Represents a generated icon constant
51#[derive(Debug, Clone)]
52struct IconConst {
53    name: String,
54    full_icon_name: String,
55    body: String,
56    view_box: String,
57    width: String,
58    height: String,
59}
60
61impl IconConst {
62    fn from_api_icon(identifier: &IconIdentifier, icon: &IconifyIcon) -> Self {
63        Self {
64            name: identifier.to_const_name(),
65            full_icon_name: identifier.full_name.clone(),
66            body: icon.body.clone(),
67            view_box: icon
68                .view_box
69                .clone()
70                .unwrap_or_else(|| "0 0 24 24".to_string()),
71            width: icon.width.unwrap_or(24).to_string(),
72            height: icon.height.unwrap_or(24).to_string(),
73        }
74    }
75
76    fn to_rust_code(&self) -> String {
77        // we use non upper case to be able to switch/wrap to struct or enum i the future
78        formatdoc! { "
79
80            #[allow(non_upper_case_globals)]
81            pub const {}: IconData = IconData {{
82                name: \"{}\",
83                body: r#\"{}\"#,
84                view_box: \"{}\",
85                width: \"{}\",
86                height: \"{}\",
87            }};
88            ",
89            self.name,
90            self.full_icon_name,
91            self.body,
92            self.view_box,
93            self.width,
94            self.height
95        }
96    }
97}
98
99/// Icon code generator
100pub struct Generator {
101    icons_dir: PathBuf,
102}
103
104impl Generator {
105    /// Create a new generator with the specified icons directory
106    pub fn new(icons_dir: PathBuf) -> Self {
107        Self { icons_dir }
108    }
109
110    /// List all generated icons grouped by collection
111    pub fn list_icons(&self) -> Result<BTreeMap<String, Vec<String>>> {
112        let mut icons_by_collection: BTreeMap<String, Vec<String>> = BTreeMap::new();
113
114        // Check if icons directory exists
115        if !self.icons_dir.exists() {
116            return Ok(icons_by_collection);
117        }
118
119        // Read all .rs files in the icons directory (except mod.rs)
120        let entries = fs::read_dir(&self.icons_dir).context("Failed to read icons directory")?;
121
122        for entry in entries {
123            let entry = entry.context("Failed to read directory entry")?;
124            let path = entry.path();
125
126            // Skip if not a file or if it's mod.rs
127            if !path.is_file() || path.file_name() == Some("mod.rs".as_ref()) {
128                continue;
129            }
130
131            // Skip if not a .rs file
132            if path.extension() != Some("rs".as_ref()) {
133                continue;
134            }
135
136            // Parse the collection file
137            let icons = self.parse_collection_file(&path)?;
138
139            // Get collection name from file name
140            if let Some(collection_name) = path.file_stem().and_then(|s| s.to_str()) {
141                let icon_names: Vec<String> = icons
142                    .values()
143                    .map(|icon| icon.full_icon_name.clone())
144                    .collect();
145
146                if !icon_names.is_empty() {
147                    icons_by_collection.insert(collection_name.to_string(), icon_names);
148                }
149            }
150        }
151
152        Ok(icons_by_collection)
153    }
154
155    /// Get all icon identifiers from generated files
156    pub fn get_all_icon_identifiers(&self) -> Result<Vec<String>> {
157        let icons_by_collection = self.list_icons()?;
158        let mut all_icons = Vec::new();
159
160        for icon_names in icons_by_collection.values() {
161            all_icons.extend(icon_names.clone());
162        }
163
164        Ok(all_icons)
165    }
166
167    /// Initialize the icons directory with mod.rs if it doesn't exist
168    pub fn init(&self) -> Result<()> {
169        // Create icons directory if it doesn't exist
170        if !self.icons_dir.exists() {
171            fs::create_dir_all(&self.icons_dir).context("Failed to create icons directory")?;
172        }
173
174        // Create mod.rs if it doesn't exist
175        let mod_rs_path = self.icons_dir.join("mod.rs");
176        if !mod_rs_path.exists() {
177            fs::write(&mod_rs_path, MOD_RS_TEMPLATE).context("Failed to create mod.rs")?;
178        }
179
180        Ok(())
181    }
182
183    /// Add icons to the generated code
184    pub fn add_icons(
185        &self,
186        icons: &[(IconIdentifier, IconifyIcon)],
187        collection_info: &HashMap<String, IconifyCollectionInfo>,
188    ) -> Result<()> {
189        // Ensure icons directory and mod.rs exist
190        self.init()?;
191
192        // Group icons by collection
193        let mut icons_by_collection: HashMap<String, Vec<(IconIdentifier, IconifyIcon)>> =
194            HashMap::new();
195
196        for (identifier, icon) in icons {
197            icons_by_collection
198                .entry(identifier.collection.clone())
199                .or_default()
200                .push((identifier.clone(), icon.clone()));
201        }
202
203        // Generate/update each collection file
204        for (collection, collection_icons) in &icons_by_collection {
205            let info = collection_info.get(collection);
206            self.update_collection_file(collection, collection_icons, info)?;
207        }
208
209        // Update mod.rs with module declarations
210        self.update_mod_rs(&icons_by_collection.keys().cloned().collect::<Vec<_>>())?;
211
212        Ok(())
213    }
214
215    /// Regenerate mod.rs with the latest template
216    /// This is useful for updating the Icon component definition after CLI updates
217    pub fn regenerate_mod_rs(&self) -> Result<()> {
218        let mod_rs_path = self.icons_dir.join("mod.rs");
219
220        // If mod.rs doesn't exist, just use init
221        if !mod_rs_path.exists() {
222            return self.init();
223        }
224
225        // Read existing mod.rs to extract module declarations
226        let content = fs::read_to_string(&mod_rs_path).context("Failed to read mod.rs")?;
227
228        // Extract existing module declarations with their visibility
229        let existing_modules = extract_module_declarations(&content);
230
231        // Regenerate mod.rs with latest template
232        let mut new_content = MOD_RS_TEMPLATE.to_string();
233        new_content.push('\n');
234
235        // Add module declarations in alphabetical order
236        let mut sorted_modules: Vec<_> = existing_modules.iter().collect();
237        sorted_modules.sort_by_key(|(name, _)| *name);
238
239        for (module, visibility) in sorted_modules {
240            new_content.push_str(&format!("{}mod {};\n", visibility, module));
241        }
242
243        fs::write(&mod_rs_path, new_content).context("Failed to update mod.rs")?;
244
245        Ok(())
246    }
247
248    /// Update a collection file (e.g., mdi.rs) with new icons
249    fn update_collection_file(
250        &self,
251        collection: &str,
252        new_icons: &[(IconIdentifier, IconifyIcon)],
253        collection_info: Option<&IconifyCollectionInfo>,
254    ) -> Result<()> {
255        let module_name = collection.replace('-', "_");
256        let file_path = self.icons_dir.join(format!("{}.rs", module_name));
257
258        // Read existing icons if file exists
259        let mut existing_icons: BTreeMap<String, IconConst> = BTreeMap::new();
260        if file_path.exists() {
261            existing_icons = self.parse_collection_file(&file_path)?;
262        }
263
264        // Add/update new icons
265        for (identifier, icon) in new_icons {
266            let icon_const = IconConst::from_api_icon(identifier, icon);
267            existing_icons.insert(icon_const.name.clone(), icon_const);
268        }
269
270        // Generate file content
271        let content =
272            self.generate_collection_file(collection, &existing_icons, collection_info)?;
273
274        // Write to file
275        fs::write(&file_path, content)
276            .context(format!("Failed to write collection file {:?}", file_path))?;
277
278        println!(
279            "✓ Updated {}.rs with {} icon(s)",
280            module_name,
281            new_icons.len()
282        );
283
284        Ok(())
285    }
286
287    /// Parse existing icons from a collection file
288    fn parse_collection_file(&self, path: &Path) -> Result<BTreeMap<String, IconConst>> {
289        let content =
290            fs::read_to_string(path).context(format!("Failed to read file {:?}", path))?;
291
292        let mut icons = BTreeMap::new();
293
294        // Simple regex-free parsing: look for "pub const NAME: IconData = IconData {"
295        // and extract the data between braces
296        let lines: Vec<&str> = content.lines().collect();
297        let mut i = 0;
298
299        while i < lines.len() {
300            let line = lines[i].trim();
301
302            // Look for "pub const NAME: IconData"
303            if line.starts_with("pub const ")
304                && line.contains(": IconData")
305                && let Some(name_end) = line.find(':')
306            {
307                let name = line[10..name_end].trim().to_string();
308
309                // Parse the IconData struct (next several lines)
310                if let Some(icon_const) = self.parse_icon_data(&lines, &mut i, &name) {
311                    icons.insert(name, icon_const);
312                }
313            }
314
315            i += 1;
316        }
317
318        Ok(icons)
319    }
320
321    /// Parse IconData struct from lines
322    fn parse_icon_data(
323        &self,
324        lines: &[&str],
325        index: &mut usize,
326        const_name: &str,
327    ) -> Option<IconConst> {
328        let mut full_icon_name = String::new();
329        let mut body = String::new();
330        let mut view_box = String::new();
331        let mut width = String::new();
332        let mut height = String::new();
333
334        // Look ahead to find all fields
335        let mut j = *index;
336        while j < lines.len() {
337            let line = lines[j].trim();
338
339            if line.contains("name:") {
340                full_icon_name = extract_string_value(line);
341            } else if line.contains("body:") {
342                // Body might span multiple lines in raw string
343                body = extract_raw_string_value(lines, &mut j);
344            } else if line.contains("view_box:") {
345                view_box = extract_string_value(line);
346            } else if line.contains("width:") {
347                width = extract_string_value(line);
348            } else if line.contains("height:") {
349                height = extract_string_value(line);
350            }
351
352            // End of struct
353            if line.contains("};") {
354                break;
355            }
356
357            j += 1;
358        }
359
360        *index = j;
361
362        if !full_icon_name.is_empty() && !body.is_empty() {
363            Some(IconConst {
364                name: const_name.to_string(),
365                full_icon_name,
366                body,
367                view_box,
368                width,
369                height,
370            })
371        } else {
372            None
373        }
374    }
375
376    /// Generate content for a collection file
377    fn generate_collection_file(
378        &self,
379        collection: &str,
380        icons: &BTreeMap<String, IconConst>,
381        collection_info: Option<&IconifyCollectionInfo>,
382    ) -> Result<String> {
383        let mut content = String::from("/// Auto-generated by dioxus-iconify - DO NOT EDIT\n");
384
385        // Add generation timestamp
386        let now = chrono::Utc::now();
387        content.push_str(&format!("/// Generated: {}\n", now.to_rfc3339()));
388
389        content.push_str(&format!("/// Collection: {}\n", collection));
390        content.push_str("/// This is a partial import from Iconify\n");
391        content.push_str(&format!(
392            "/// Browse icons: https://icon-sets.iconify.design/{}/\n",
393            collection
394        ));
395
396        // Add collection info if available
397        if let Some(info) = collection_info {
398            content.push_str("///\n");
399            content.push_str(&format_collection_info_comment(info));
400        }
401
402        content.push_str("use super::IconData;\n");
403
404        // Add each icon const in alphabetical order (BTreeMap maintains order)
405        for icon_const in icons.values() {
406            content.push_str(&icon_const.to_rust_code());
407        }
408
409        Ok(content)
410    }
411
412    /// Update mod.rs with module declarations
413    fn update_mod_rs(&self, collections: &[String]) -> Result<()> {
414        let mod_rs_path = self.icons_dir.join("mod.rs");
415
416        // Read existing mod.rs
417        let content = fs::read_to_string(&mod_rs_path).context("Failed to read mod.rs")?;
418
419        // Extract existing module declarations with their visibility
420        let mut existing_modules = extract_module_declarations(&content);
421
422        // Add new modules (with pub visibility by default)
423        let mut needs_update = false;
424        for collection in collections {
425            let module_name = collection.replace('-', "_");
426            if let std::collections::hash_map::Entry::Vacant(e) =
427                existing_modules.entry(module_name)
428            {
429                e.insert("pub ".to_string());
430                needs_update = true;
431            }
432        }
433
434        // Regenerate mod.rs if we have new modules
435        if needs_update {
436            let mut new_content = MOD_RS_TEMPLATE.to_string();
437            new_content.push('\n');
438
439            // Add module declarations in alphabetical order
440            let mut sorted_modules: Vec<_> = existing_modules.iter().collect();
441            sorted_modules.sort_by_key(|(name, _)| *name);
442
443            for (module, visibility) in sorted_modules {
444                new_content.push_str(&format!("{}mod {};\n", visibility, module));
445            }
446
447            fs::write(&mod_rs_path, new_content).context("Failed to update mod.rs")?;
448        }
449
450        Ok(())
451    }
452}
453
454/// Extract module declarations from mod.rs content, preserving their visibility modifiers
455/// Returns a HashMap where keys are module names and values are visibility prefixes
456/// (e.g., "pub ", "pub(crate) ", "" for private modules)
457fn extract_module_declarations(content: &str) -> HashMap<String, String> {
458    let mut modules = HashMap::new();
459
460    for line in content.lines() {
461        let trimmed = line.trim();
462
463        // Skip lines that don't end with semicolon
464        if !trimmed.ends_with(';') {
465            continue;
466        }
467
468        // Remove the trailing semicolon
469        let without_semi = &trimmed[..trimmed.len() - 1];
470
471        // Look for "mod " pattern
472        if let Some(mod_idx) = without_semi.find("mod ") {
473            // Everything before "mod " is the visibility (if any)
474            let visibility = if mod_idx == 0 {
475                // Line starts with "mod " - no visibility modifier
476                String::new()
477            } else {
478                // There's something before "mod " - that's the visibility
479                // The space before "mod" is already there, so just take everything before mod_idx
480                let vis = without_semi[..mod_idx].trim();
481                if vis.is_empty() {
482                    String::new()
483                } else {
484                    vis.to_string() + " "
485                }
486            };
487
488            // Everything after "mod " is the module name
489            let module_name = without_semi[mod_idx + 4..].trim();
490
491            // Only add if we have a valid module name
492            if !module_name.is_empty() {
493                modules.insert(module_name.to_string(), visibility);
494            }
495        }
496    }
497
498    modules
499}
500
501/// Format collection info as a YAML comment block
502fn format_collection_info_comment(info: &IconifyCollectionInfo) -> String {
503    use crate::api::{IconifyAuthor, IconifyLicense};
504
505    let mut lines = Vec::new();
506    lines.push("/// ```yaml".to_string());
507
508    if let Some(name) = &info.name {
509        lines.push(format!("/// name: {}", name));
510    }
511
512    if let Some(author) = &info.author {
513        match author {
514            IconifyAuthor::Simple(s) => {
515                lines.push(format!("/// author: {}", s));
516            }
517            IconifyAuthor::Detailed { name, url } => {
518                lines.push("/// author:".to_string());
519                if let Some(n) = name {
520                    lines.push(format!("///   name: {}", n));
521                }
522                if let Some(u) = url {
523                    lines.push(format!("///   url: {}", u));
524                }
525            }
526        }
527    }
528
529    if let Some(license) = &info.license {
530        match license {
531            IconifyLicense::Simple(s) => {
532                lines.push(format!("/// license: {}", s));
533            }
534            IconifyLicense::Detailed { title, spdx, url } => {
535                lines.push("/// license:".to_string());
536                if let Some(t) = title {
537                    lines.push(format!("///   title: {}", t));
538                }
539                if let Some(s) = spdx {
540                    lines.push(format!("///   spdx: {}", s));
541                }
542                if let Some(u) = url {
543                    lines.push(format!("///   url: {}", u));
544                }
545            }
546        }
547    }
548
549    if let Some(total) = info.total {
550        lines.push(format!("/// total: {}", total));
551    }
552
553    if let Some(category) = &info.category {
554        lines.push(format!("/// category: {}", category));
555    }
556
557    if let Some(palette) = info.palette {
558        lines.push(format!("/// palette: {}", palette));
559    }
560
561    if let Some(height) = info.height {
562        lines.push(format!("/// height: {}", height));
563    }
564
565    lines.push("/// ```".to_string());
566
567    lines.join("\n") + "\n"
568}
569
570/// Extract a string value from a line like `name: "value",`
571fn extract_string_value(line: &str) -> String {
572    if let Some(start) = line.find('"')
573        && let Some(end) = line.rfind('"')
574        && end > start
575    {
576        return line[start + 1..end].to_string();
577    }
578    String::new()
579}
580
581/// Extract a raw string value that might span multiple lines
582fn extract_raw_string_value(lines: &[&str], index: &mut usize) -> String {
583    let line = lines[*index];
584
585    // Look for r#"..."#
586    if let Some(start) = line.find("r#\"") {
587        let start_pos = start + 3;
588
589        // Check if it ends on the same line
590        if let Some(end) = line[start_pos..].find("\"#") {
591            return line[start_pos..start_pos + end].to_string();
592        }
593
594        // Multi-line: collect until we find "#
595        let mut result = line[start_pos..].to_string();
596        *index += 1;
597
598        while *index < lines.len() {
599            let next_line = lines[*index];
600            if let Some(end) = next_line.find("\"#") {
601                result.push_str(&next_line[..end]);
602                break;
603            }
604            result.push_str(next_line);
605            result.push('\n');
606            *index += 1;
607        }
608
609        result
610    } else {
611        String::new()
612    }
613}
614
615#[cfg(test)]
616mod tests {
617    use super::*;
618    use crate::api::IconifyIcon;
619    use tempfile::TempDir;
620
621    #[test]
622    fn test_list_icons_empty_directory() -> Result<()> {
623        let temp_dir = TempDir::new()?;
624        let generator = Generator::new(temp_dir.path().join("icons"));
625
626        let icons = generator.list_icons()?;
627        assert!(
628            icons.is_empty(),
629            "Should return empty map for non-existent directory"
630        );
631
632        Ok(())
633    }
634
635    #[test]
636    fn test_list_icons_with_generated_icons() -> Result<()> {
637        let temp_dir = TempDir::new()?;
638        let icons_dir = temp_dir.path().join("icons");
639        let generator = Generator::new(icons_dir.clone());
640
641        // Add some test icons
642        let test_icon1 = IconifyIcon {
643            body: r#"<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>"#.to_string(),
644            width: Some(24),
645            height: Some(24),
646            view_box: Some("0 0 24 24".to_string()),
647        };
648
649        let test_icon2 = IconifyIcon {
650            body: r#"<circle cx="12" cy="12" r="10"/>"#.to_string(),
651            width: Some(24),
652            height: Some(24),
653            view_box: Some("0 0 24 24".to_string()),
654        };
655
656        let identifier1 = IconIdentifier::parse("mdi:home")?;
657        let identifier2 = IconIdentifier::parse("mdi:settings")?;
658        let identifier3 = IconIdentifier::parse("heroicons:arrow-left")?;
659
660        generator.add_icons(
661            &[
662                (identifier1, test_icon1.clone()),
663                (identifier2, test_icon2.clone()),
664                (identifier3, test_icon1.clone()),
665            ],
666            &HashMap::new(),
667        )?;
668
669        // List the icons
670        let icons = generator.list_icons()?;
671
672        // Should have 2 collections
673        assert_eq!(icons.len(), 2, "Should have 2 collections");
674        assert!(icons.contains_key("mdi"), "Should have mdi collection");
675        assert!(
676            icons.contains_key("heroicons"),
677            "Should have heroicons collection"
678        );
679
680        // Check mdi collection has 2 icons
681        let mdi_icons = icons.get("mdi").unwrap();
682        assert_eq!(mdi_icons.len(), 2, "mdi should have 2 icons");
683        assert!(mdi_icons.contains(&"mdi:home".to_string()));
684        assert!(mdi_icons.contains(&"mdi:settings".to_string()));
685
686        // Check heroicons collection has 1 icon
687        let heroicons_icons = icons.get("heroicons").unwrap();
688        assert_eq!(heroicons_icons.len(), 1, "heroicons should have 1 icon");
689        assert!(heroicons_icons.contains(&"heroicons:arrow-left".to_string()));
690
691        Ok(())
692    }
693
694    #[test]
695    fn test_get_all_icon_identifiers() -> Result<()> {
696        let temp_dir = TempDir::new()?;
697        let icons_dir = temp_dir.path().join("icons");
698        let generator = Generator::new(icons_dir.clone());
699
700        // Test with no icons
701        let empty_icons = generator.get_all_icon_identifiers()?;
702        assert!(
703            empty_icons.is_empty(),
704            "Should return empty vec for no icons"
705        );
706
707        // Add some test icons
708        let test_icon = IconifyIcon {
709            body: r#"<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>"#.to_string(),
710            width: Some(24),
711            height: Some(24),
712            view_box: Some("0 0 24 24".to_string()),
713        };
714
715        let identifier1 = IconIdentifier::parse("mdi:home")?;
716        let identifier2 = IconIdentifier::parse("mdi:settings")?;
717        let identifier3 = IconIdentifier::parse("heroicons:arrow-left")?;
718
719        generator.add_icons(
720            &[
721                (identifier1, test_icon.clone()),
722                (identifier2, test_icon.clone()),
723                (identifier3, test_icon.clone()),
724            ],
725            &HashMap::new(),
726        )?;
727
728        // Get all identifiers
729        let all_icons = generator.get_all_icon_identifiers()?;
730
731        // Should have 3 icons
732        assert_eq!(all_icons.len(), 3, "Should have 3 icons");
733        assert!(
734            all_icons.contains(&"mdi:home".to_string()),
735            "Should contain mdi:home"
736        );
737        assert!(
738            all_icons.contains(&"mdi:settings".to_string()),
739            "Should contain mdi:settings"
740        );
741        assert!(
742            all_icons.contains(&"heroicons:arrow-left".to_string()),
743            "Should contain heroicons:arrow-left"
744        );
745
746        Ok(())
747    }
748
749    #[test]
750    fn test_regenerate_mod_rs_updates_template() -> Result<()> {
751        let temp_dir = TempDir::new()?;
752        let icons_dir = temp_dir.path().join("icons");
753        let generator = Generator::new(icons_dir.clone());
754
755        // Add some test icons
756        let test_icon = IconifyIcon {
757            body: r#"<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>"#.to_string(),
758            width: Some(24),
759            height: Some(24),
760            view_box: Some("0 0 24 24".to_string()),
761        };
762
763        let identifier1 = IconIdentifier::parse("mdi:home")?;
764        let identifier2 = IconIdentifier::parse("heroicons:arrow-left")?;
765
766        generator.add_icons(
767            &[
768                (identifier1, test_icon.clone()),
769                (identifier2, test_icon.clone()),
770            ],
771            &HashMap::new(),
772        )?;
773
774        let mod_rs_path = icons_dir.join("mod.rs");
775        assert!(mod_rs_path.exists(), "mod.rs should exist");
776
777        // Simulate an old version by writing outdated content
778        let old_content = r#"// Auto-generated by dioxus-iconify - DO NOT EDIT
779use dioxus::prelude::*;
780
781// OLD VERSION WITHOUT SIZE PARAMETER
782#[component]
783pub fn Icon(data: IconData) -> Element {
784    rsx! { svg {} }
785}
786
787pub mod heroicons;
788pub mod mdi;
789"#;
790        fs::write(&mod_rs_path, old_content)?;
791
792        // Verify the old content is there
793        let content_before = fs::read_to_string(&mod_rs_path)?;
794        assert!(
795            content_before.contains("OLD VERSION"),
796            "Should have old version marker"
797        );
798        assert!(
799            !content_before.contains("size: Option<String>"),
800            "Should not have size parameter yet"
801        );
802
803        // Regenerate mod.rs
804        generator.regenerate_mod_rs()?;
805
806        // Verify the new content has the latest template
807        let content_after = fs::read_to_string(&mod_rs_path)?;
808        assert!(
809            !content_after.contains("OLD VERSION"),
810            "Should not have old version marker"
811        );
812        assert!(
813            content_after.contains("size: String"),
814            "Should have size parameter from latest template"
815        );
816        assert!(
817            content_after.contains("pub mod heroicons;"),
818            "Should preserve heroicons module"
819        );
820        assert!(
821            content_after.contains("pub mod mdi;"),
822            "Should preserve mdi module"
823        );
824
825        Ok(())
826    }
827
828    #[test]
829    fn test_collection_info_in_generated_file() -> Result<()> {
830        use crate::api::{IconifyAuthor, IconifyCollectionInfo, IconifyLicense};
831
832        let temp_dir = TempDir::new()?;
833        let icons_dir = temp_dir.path().join("icons");
834        let generator = Generator::new(icons_dir.clone());
835
836        // Create test icon
837        let test_icon = IconifyIcon {
838            body: r#"<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>"#.to_string(),
839            width: Some(24),
840            height: Some(24),
841            view_box: Some("0 0 24 24".to_string()),
842        };
843
844        let identifier = IconIdentifier::parse("mdi:home")?;
845
846        // Create collection info
847        let mut collection_info = HashMap::new();
848        collection_info.insert(
849            "mdi".to_string(),
850            IconifyCollectionInfo {
851                name: Some("Material Design Icons".to_string()),
852                author: Some(IconifyAuthor::Detailed {
853                    name: Some("Pictogrammers".to_string()),
854                    url: Some("https://pictogrammers.com".to_string()),
855                }),
856                license: Some(IconifyLicense::Detailed {
857                    title: Some("Apache 2.0".to_string()),
858                    spdx: Some("Apache-2.0".to_string()),
859                    url: Some("https://www.apache.org/licenses/LICENSE-2.0".to_string()),
860                }),
861                total: Some(7000),
862                category: Some("General".to_string()),
863                palette: Some(false),
864                height: Some(24),
865            },
866        );
867
868        // Generate the icon with collection info
869        generator.add_icons(&[(identifier, test_icon)], &collection_info)?;
870
871        // Read the generated file
872        let generated_file = icons_dir.join("mdi.rs");
873        let content = fs::read_to_string(&generated_file)?;
874
875        // Verify generation metadata
876        assert!(
877            content.contains("/// Generated:"),
878            "Should include generation timestamp"
879        );
880        assert!(
881            content.contains("/// Collection: mdi"),
882            "Should include collection name"
883        );
884        assert!(
885            content.contains("/// This is a partial import from Iconify"),
886            "Should indicate partial import"
887        );
888        assert!(
889            content.contains("/// Browse icons: https://icon-sets.iconify.design/mdi/"),
890            "Should include browse URL"
891        );
892
893        // Verify collection info is included
894        assert!(
895            content.contains("/// ```yaml"),
896            "Should include YAML code block"
897        );
898        assert!(
899            content.contains("name: Material Design Icons"),
900            "Should include collection name"
901        );
902        assert!(content.contains("author:"), "Should include author section");
903        assert!(
904            content.contains("name: Pictogrammers"),
905            "Should include author name"
906        );
907        assert!(
908            content.contains("url: https://pictogrammers.com"),
909            "Should include author URL"
910        );
911        assert!(
912            content.contains("license:"),
913            "Should include license section"
914        );
915        assert!(
916            content.contains("title: Apache 2.0"),
917            "Should include license title"
918        );
919        assert!(
920            content.contains("spdx: Apache-2.0"),
921            "Should include SPDX identifier"
922        );
923        assert!(
924            content.contains("total: 7000"),
925            "Should include total count"
926        );
927        assert!(
928            content.contains("category: General"),
929            "Should include category"
930        );
931        assert!(content.contains("palette: false"), "Should include palette");
932        assert!(content.contains("height: 24"), "Should include height");
933
934        Ok(())
935    }
936
937    #[test]
938    fn test_module_visibility_preservation() -> Result<()> {
939        let temp_dir = TempDir::new()?;
940        let icons_dir = temp_dir.path().join("icons");
941        let generator = Generator::new(icons_dir.clone());
942
943        // Add initial icons
944        let test_icon = IconifyIcon {
945            body: r#"<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>"#.to_string(),
946            width: Some(24),
947            height: Some(24),
948            view_box: Some("0 0 24 24".to_string()),
949        };
950
951        let identifier1 = IconIdentifier::parse("mdi:home")?;
952        let identifier2 = IconIdentifier::parse("heroicons:arrow-left")?;
953
954        generator.add_icons(
955            &[
956                (identifier1, test_icon.clone()),
957                (identifier2, test_icon.clone()),
958            ],
959            &HashMap::new(),
960        )?;
961
962        let mod_rs_path = icons_dir.join("mod.rs");
963
964        // Modify mod.rs to have different visibility modifiers
965        let modified_content = r#"// Auto-generated by dioxus-iconify - DO NOT EDIT
966use dioxus::prelude::*;
967
968#[derive(Clone, Copy, PartialEq)]
969pub struct IconData {
970    pub name: &'static str,
971    pub body: &'static str,
972    pub view_box: &'static str,
973    pub width: &'static str,
974    pub height: &'static str,
975}
976
977#[component]
978pub fn Icon(
979    data: IconData,
980    /// Optional size to set both width and height
981    #[props(default, into)]
982    size: String,
983    /// Additional attributes to extend the svg element
984    #[props(extends = GlobalAttributes)]
985    attributes: Vec<Attribute>,
986) -> Element {
987    let (width, height) = if size.is_empty() {
988        (data.width, data.height)
989    } else {
990        (size.as_str(), size.as_str())
991    };
992
993    rsx! {
994        svg {
995            view_box: "{data.view_box}",
996            width: "{width}",
997            height: "{height}",
998            dangerous_inner_html: "{data.body}",
999            ..attributes,
1000        }
1001    }
1002}
1003
1004mod heroicons;
1005pub mod mdi;
1006"#;
1007        fs::write(&mod_rs_path, modified_content)?;
1008
1009        // Add another icon from a new collection
1010        let identifier3 = IconIdentifier::parse("lucide:star")?;
1011        generator.add_icons(&[(identifier3, test_icon.clone())], &HashMap::new())?;
1012
1013        // Read the updated mod.rs
1014        let content_after = fs::read_to_string(&mod_rs_path)?;
1015
1016        // Verify that visibility is preserved
1017        assert!(
1018            content_after.contains("mod heroicons;"),
1019            "Should preserve 'mod heroicons;' without pub"
1020        );
1021        assert!(
1022            content_after.contains("pub mod mdi;"),
1023            "Should preserve 'pub mod mdi;'"
1024        );
1025        assert!(
1026            content_after.contains("pub mod lucide;"),
1027            "New modules should be added with 'pub mod'"
1028        );
1029
1030        // Verify that all three modules are present
1031        let has_heroicons = content_after.lines().any(|l| l.trim() == "mod heroicons;");
1032        let has_mdi = content_after.lines().any(|l| l.trim() == "pub mod mdi;");
1033        let has_lucide = content_after.lines().any(|l| l.trim() == "pub mod lucide;");
1034
1035        assert!(has_heroicons, "Should have mod heroicons;");
1036        assert!(has_mdi, "Should have pub mod mdi;");
1037        assert!(has_lucide, "Should have pub mod lucide;");
1038
1039        Ok(())
1040    }
1041
1042    #[test]
1043    fn test_extract_module_declarations() {
1044        let content = r#"
1045// Some comment
1046pub mod mdi;
1047mod heroicons;
1048pub(crate) mod feather;
1049pub mod simple_icons;
1050"#;
1051
1052        let modules = extract_module_declarations(content);
1053
1054        assert_eq!(modules.len(), 4);
1055        assert_eq!(modules.get("mdi"), Some(&"pub ".to_string()));
1056        assert_eq!(modules.get("heroicons"), Some(&"".to_string()));
1057        assert_eq!(modules.get("feather"), Some(&"pub(crate) ".to_string()));
1058        assert_eq!(modules.get("simple_icons"), Some(&"pub ".to_string()));
1059    }
1060
1061    #[test]
1062    fn test_custom_user_module_preservation() -> Result<()> {
1063        let temp_dir = TempDir::new()?;
1064        let icons_dir = temp_dir.path().join("icons");
1065        let generator = Generator::new(icons_dir.clone());
1066
1067        // Create custom user module with custom IconData
1068        fs::create_dir_all(&icons_dir)?;
1069        let app_rs_path = icons_dir.join("app.rs");
1070        let custom_icon_content = r##"/// Custom user-defined icons
1071use super::IconData;
1072
1073#[allow(non_upper_case_globals)]
1074pub const CustomLogo: IconData = IconData {
1075    name: "app:custom-logo",
1076    body: r#"<rect width="100" height="100" fill="blue"/>"#,
1077    view_box: "0 0 100 100",
1078    width: "100",
1079    height: "100",
1080};
1081
1082#[allow(non_upper_case_globals)]
1083pub const CustomBrand: IconData = IconData {
1084    name: "app:custom-brand",
1085    body: r#"<circle cx="50" cy="50" r="40" fill="red"/>"#,
1086    view_box: "0 0 100 100",
1087    width: "100",
1088    height: "100",
1089};
1090"##;
1091        fs::write(&app_rs_path, custom_icon_content)?;
1092
1093        // Initialize with mod.rs and add the custom module with pub(crate) visibility
1094        generator.init()?;
1095        let mod_rs_path = icons_dir.join("mod.rs");
1096        let initial_mod_content = format!(
1097            "{}
1098pub(crate) mod app;
1099",
1100            MOD_RS_TEMPLATE
1101        );
1102        fs::write(&mod_rs_path, initial_mod_content)?;
1103
1104        // Verify custom module file exists
1105        assert!(app_rs_path.exists(), "Custom app.rs should exist");
1106        let custom_content_before = fs::read_to_string(&app_rs_path)?;
1107        assert!(
1108            custom_content_before.contains("CustomLogo"),
1109            "Custom icon should be defined"
1110        );
1111
1112        // Now add some generated icons
1113        let test_icon = IconifyIcon {
1114            body: r#"<path d="M10 20v-6h4v6h5v-8h3L12 3 2 12h3v8z"/>"#.to_string(),
1115            width: Some(24),
1116            height: Some(24),
1117            view_box: Some("0 0 24 24".to_string()),
1118        };
1119
1120        let identifier1 = IconIdentifier::parse("mdi:home")?;
1121        let identifier2 = IconIdentifier::parse("heroicons:star")?;
1122
1123        generator.add_icons(
1124            &[
1125                (identifier1, test_icon.clone()),
1126                (identifier2, test_icon.clone()),
1127            ],
1128            &HashMap::new(),
1129        )?;
1130
1131        // Verify custom module file still exists and is unchanged
1132        assert!(
1133            app_rs_path.exists(),
1134            "Custom app.rs should still exist after adding generated icons"
1135        );
1136        let custom_content_after = fs::read_to_string(&app_rs_path)?;
1137        assert_eq!(
1138            custom_content_before, custom_content_after,
1139            "Custom module content should not be modified"
1140        );
1141
1142        // Read updated mod.rs
1143        let mod_rs_content = fs::read_to_string(&mod_rs_path)?;
1144
1145        // Verify that custom module is still present with original visibility
1146        assert!(
1147            mod_rs_content.contains("pub(crate) mod app;"),
1148            "Custom module should be preserved with pub(crate) visibility"
1149        );
1150
1151        // Verify that generated modules were added
1152        assert!(
1153            mod_rs_content.contains("pub mod mdi;"),
1154            "Generated mdi module should be added"
1155        );
1156        assert!(
1157            mod_rs_content.contains("pub mod heroicons;"),
1158            "Generated heroicons module should be added"
1159        );
1160
1161        // Count module declarations to ensure nothing was removed
1162        let module_count = mod_rs_content
1163            .lines()
1164            .filter(|line| line.trim().contains("mod ") && line.trim().ends_with(';'))
1165            .count();
1166        assert_eq!(
1167            module_count, 3,
1168            "Should have exactly 3 modules: app, heroicons, and mdi"
1169        );
1170
1171        // Verify modules are in alphabetical order
1172        let mod_lines: Vec<&str> = mod_rs_content
1173            .lines()
1174            .filter(|line| line.trim().contains("mod ") && line.trim().ends_with(';'))
1175            .collect();
1176        assert_eq!(mod_lines.len(), 3, "Should have 3 module lines");
1177
1178        // Check that they appear in alphabetical order
1179        let mut module_names: Vec<String> = Vec::new();
1180        for line in &mod_lines {
1181            if let Some(name_start) = line.find("mod ") {
1182                let name_part = &line[name_start + 4..];
1183                if let Some(name_end) = name_part.find(';') {
1184                    module_names.push(name_part[..name_end].trim().to_string());
1185                }
1186            }
1187        }
1188        assert_eq!(module_names, vec!["app", "heroicons", "mdi"]);
1189
1190        Ok(())
1191    }
1192}