1mod codegen;
113mod error;
114mod schema;
115mod validate;
116
117use std::collections::BTreeMap;
118use std::path::{Path, PathBuf};
119
120use heck::ToSnakeCase;
121
122use schema::{MasterConfig, ThemeMapping};
123
124#[cfg(test)]
126use error::BuildError;
127#[cfg(test)]
128use schema::{KNOWN_THEMES, MappingValue};
129
130#[doc(hidden)]
132pub fn __run_pipeline_on_files(
133 toml_paths: &[&Path],
134 enum_name_override: Option<&str>,
135) -> PipelineResult {
136 let mut configs = Vec::new();
137 let mut base_dirs = Vec::new();
138
139 for path in toml_paths {
140 let content = std::fs::read_to_string(path)
141 .unwrap_or_else(|e| panic!("failed to read {}: {e}", path.display()));
142 let config: MasterConfig = toml::from_str(&content)
143 .unwrap_or_else(|e| panic!("failed to parse {}: {e}", path.display()));
144 let base_dir = path
145 .parent()
146 .expect("TOML path has no parent")
147 .to_path_buf();
148 configs.push((path.to_string_lossy().to_string(), config));
149 base_dirs.push(base_dir);
150 }
151
152 run_pipeline(&configs, &base_dirs, enum_name_override, None)
153}
154
155#[doc(hidden)]
161pub struct PipelineResult {
162 pub code: String,
164 pub errors: Vec<String>,
166 pub warnings: Vec<String>,
168 pub rerun_paths: Vec<PathBuf>,
170 pub size_report: Option<SizeReport>,
172 pub output_filename: String,
174}
175
176#[doc(hidden)]
178pub struct SizeReport {
179 pub role_count: usize,
180 pub bundled_theme_count: usize,
181 pub total_svg_bytes: u64,
182 pub svg_count: usize,
183}
184
185#[doc(hidden)]
195pub fn run_pipeline(
196 configs: &[(String, MasterConfig)],
197 base_dirs: &[PathBuf],
198 enum_name_override: Option<&str>,
199 manifest_dir: Option<&Path>,
200) -> PipelineResult {
201 assert_eq!(configs.len(), base_dirs.len());
202
203 let mut errors: Vec<String> = Vec::new();
204 let mut warnings: Vec<String> = Vec::new();
205 let mut rerun_paths: Vec<PathBuf> = Vec::new();
206 let mut all_mappings: BTreeMap<String, ThemeMapping> = BTreeMap::new();
207 let mut svg_paths: Vec<PathBuf> = Vec::new();
208
209 let first_name = enum_name_override
211 .map(|s| s.to_string())
212 .unwrap_or_else(|| configs[0].1.name.clone());
213 let output_filename = format!("{}.rs", first_name.to_snake_case());
214
215 if configs.len() > 1 {
217 let dup_errors = validate::validate_no_duplicate_roles(configs);
218 for e in dup_errors {
219 errors.push(e.to_string());
220 }
221 }
222
223 let merged = merge_configs(configs, enum_name_override);
225
226 for (file_path, _config) in configs {
228 rerun_paths.push(PathBuf::from(file_path));
229 }
230
231 let theme_errors = validate::validate_themes(&merged);
233 for e in theme_errors {
234 errors.push(e.to_string());
235 }
236
237 let base_dir = &base_dirs[0];
240
241 for theme_name in &merged.bundled_themes {
243 let theme_dir = base_dir.join(theme_name);
244 let mapping_path = theme_dir.join("mapping.toml");
245 let mapping_path_str = mapping_path.to_string_lossy().to_string();
246
247 rerun_paths.push(mapping_path.clone());
249 rerun_paths.push(theme_dir.clone());
250
251 match std::fs::read_to_string(&mapping_path) {
252 Ok(content) => match toml::from_str::<ThemeMapping>(&content) {
253 Ok(mapping) => {
254 let map_errors =
256 validate::validate_mapping(&merged.roles, &mapping, &mapping_path_str);
257 for e in map_errors {
258 errors.push(e.to_string());
259 }
260
261 let svg_errors =
263 validate::validate_svgs(&mapping, &theme_dir, &mapping_path_str);
264 for e in svg_errors {
265 errors.push(e.to_string());
266 }
267
268 let de_warnings = validate::validate_de_keys(&mapping, &mapping_path_str);
270 warnings.extend(de_warnings);
271
272 let orphan_warnings = check_orphan_svgs_and_collect_paths(
274 &mapping,
275 &theme_dir,
276 theme_name,
277 &mut svg_paths,
278 &mut rerun_paths,
279 );
280 warnings.extend(orphan_warnings);
281
282 all_mappings.insert(theme_name.clone(), mapping);
283 }
284 Err(e) => {
285 errors.push(format!("failed to parse {mapping_path_str}: {e}"));
286 }
287 },
288 Err(e) => {
289 errors.push(format!("failed to read {mapping_path_str}: {e}"));
290 }
291 }
292 }
293
294 for theme_name in &merged.system_themes {
296 let theme_dir = base_dir.join(theme_name);
297 let mapping_path = theme_dir.join("mapping.toml");
298 let mapping_path_str = mapping_path.to_string_lossy().to_string();
299
300 rerun_paths.push(mapping_path.clone());
302
303 match std::fs::read_to_string(&mapping_path) {
304 Ok(content) => match toml::from_str::<ThemeMapping>(&content) {
305 Ok(mapping) => {
306 let map_errors =
307 validate::validate_mapping(&merged.roles, &mapping, &mapping_path_str);
308 for e in map_errors {
309 errors.push(e.to_string());
310 }
311
312 let de_warnings = validate::validate_de_keys(&mapping, &mapping_path_str);
314 warnings.extend(de_warnings);
315
316 all_mappings.insert(theme_name.clone(), mapping);
317 }
318 Err(e) => {
319 errors.push(format!("failed to parse {mapping_path_str}: {e}"));
320 }
321 },
322 Err(e) => {
323 errors.push(format!("failed to read {mapping_path_str}: {e}"));
324 }
325 }
326 }
327
328 if !errors.is_empty() {
330 return PipelineResult {
331 code: String::new(),
332 errors,
333 warnings,
334 rerun_paths,
335 size_report: None,
336 output_filename,
337 };
338 }
339
340 let base_dir_str = if let Some(mdir) = manifest_dir {
344 base_dir
345 .strip_prefix(mdir)
346 .unwrap_or(base_dir)
347 .to_string_lossy()
348 .to_string()
349 } else {
350 base_dir.to_string_lossy().to_string()
351 };
352
353 let code = codegen::generate_code(&merged, &all_mappings, &base_dir_str);
355
356 let total_svg_bytes: u64 = svg_paths
358 .iter()
359 .filter_map(|p| std::fs::metadata(p).ok())
360 .map(|m| m.len())
361 .sum();
362
363 let size_report = Some(SizeReport {
364 role_count: merged.roles.len(),
365 bundled_theme_count: merged.bundled_themes.len(),
366 total_svg_bytes,
367 svg_count: svg_paths.len(),
368 });
369
370 PipelineResult {
371 code,
372 errors,
373 warnings,
374 rerun_paths,
375 size_report,
376 output_filename,
377 }
378}
379
380fn check_orphan_svgs_and_collect_paths(
382 mapping: &ThemeMapping,
383 theme_dir: &Path,
384 theme_name: &str,
385 svg_paths: &mut Vec<PathBuf>,
386 rerun_paths: &mut Vec<PathBuf>,
387) -> Vec<String> {
388 for value in mapping.values() {
390 if let Some(name) = value.default_name() {
391 let svg_path = theme_dir.join(format!("{name}.svg"));
392 if svg_path.exists() {
393 rerun_paths.push(svg_path.clone());
394 svg_paths.push(svg_path);
395 }
396 }
397 }
398
399 validate::check_orphan_svgs(mapping, theme_dir, theme_name)
400}
401
402fn merge_configs(
404 configs: &[(String, MasterConfig)],
405 enum_name_override: Option<&str>,
406) -> MasterConfig {
407 let name = enum_name_override
408 .map(|s| s.to_string())
409 .unwrap_or_else(|| configs[0].1.name.clone());
410
411 let mut roles = Vec::new();
412 let mut bundled_themes = Vec::new();
413 let mut system_themes = Vec::new();
414 let mut seen_bundled = std::collections::BTreeSet::new();
415 let mut seen_system = std::collections::BTreeSet::new();
416
417 for (_path, config) in configs {
418 roles.extend(config.roles.iter().cloned());
419
420 for t in &config.bundled_themes {
421 if seen_bundled.insert(t.clone()) {
422 bundled_themes.push(t.clone());
423 }
424 }
425 for t in &config.system_themes {
426 if seen_system.insert(t.clone()) {
427 system_themes.push(t.clone());
428 }
429 }
430 }
431
432 MasterConfig {
433 name,
434 roles,
435 bundled_themes,
436 system_themes,
437 }
438}
439
440pub fn generate_icons(toml_path: impl AsRef<Path>) {
449 let toml_path = toml_path.as_ref();
450 let manifest_dir =
451 PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"));
452 let resolved = manifest_dir.join(toml_path);
453
454 let content = std::fs::read_to_string(&resolved)
455 .unwrap_or_else(|e| panic!("failed to read {}: {e}", resolved.display()));
456 let config: MasterConfig = toml::from_str(&content)
457 .unwrap_or_else(|e| panic!("failed to parse {}: {e}", resolved.display()));
458
459 let base_dir = resolved
460 .parent()
461 .expect("TOML path has no parent")
462 .to_path_buf();
463 let file_path_str = resolved.to_string_lossy().to_string();
464
465 let result = run_pipeline(
466 &[(file_path_str, config)],
467 &[base_dir],
468 None,
469 Some(&manifest_dir),
470 );
471
472 emit_result(result);
473}
474
475pub struct IconGenerator {
477 sources: Vec<PathBuf>,
478 enum_name_override: Option<String>,
479}
480
481impl Default for IconGenerator {
482 fn default() -> Self {
483 Self::new()
484 }
485}
486
487impl IconGenerator {
488 pub fn new() -> Self {
490 Self {
491 sources: Vec::new(),
492 enum_name_override: None,
493 }
494 }
495
496 #[allow(clippy::should_implement_trait)]
498 pub fn add(mut self, path: impl AsRef<Path>) -> Self {
499 self.sources.push(path.as_ref().to_path_buf());
500 self
501 }
502
503 pub fn enum_name(mut self, name: &str) -> Self {
505 self.enum_name_override = Some(name.to_string());
506 self
507 }
508
509 pub fn generate(self) {
515 let manifest_dir =
516 PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"));
517
518 let mut configs = Vec::new();
519 let mut base_dirs = Vec::new();
520
521 for source in &self.sources {
522 let resolved = manifest_dir.join(source);
523 let content = std::fs::read_to_string(&resolved)
524 .unwrap_or_else(|e| panic!("failed to read {}: {e}", resolved.display()));
525 let config: MasterConfig = toml::from_str(&content)
526 .unwrap_or_else(|e| panic!("failed to parse {}: {e}", resolved.display()));
527
528 let base_dir = resolved
529 .parent()
530 .expect("TOML path has no parent")
531 .to_path_buf();
532 let file_path_str = resolved.to_string_lossy().to_string();
533
534 configs.push((file_path_str, config));
535 base_dirs.push(base_dir);
536 }
537
538 let result = run_pipeline(
539 &configs,
540 &base_dirs,
541 self.enum_name_override.as_deref(),
542 Some(&manifest_dir),
543 );
544
545 emit_result(result);
546 }
547}
548
549fn emit_result(result: PipelineResult) {
551 for path in &result.rerun_paths {
553 println!("cargo::rerun-if-changed={}", path.display());
554 }
555
556 if !result.errors.is_empty() {
558 for e in &result.errors {
559 println!("cargo::error={e}");
560 }
561 std::process::exit(1);
562 }
563
564 for w in &result.warnings {
566 println!("cargo::warning={w}");
567 }
568
569 let out_dir = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR not set"));
571 let out_path = out_dir.join(&result.output_filename);
572 std::fs::write(&out_path, &result.code)
573 .unwrap_or_else(|e| panic!("failed to write {}: {e}", out_path.display()));
574
575 if let Some(report) = &result.size_report {
577 let kb = report.total_svg_bytes as f64 / 1024.0;
578 println!(
579 "cargo::warning={} roles x {} bundled themes = {} SVGs, {:.1} KB total",
580 report.role_count, report.bundled_theme_count, report.svg_count, kb
581 );
582 }
583}
584
585#[cfg(test)]
586mod tests {
587 use super::*;
588 use std::collections::BTreeMap;
589 use std::fs;
590
591 #[test]
594 fn master_config_deserializes_full() {
595 let toml_str = r#"
596name = "app-icon"
597roles = ["play-pause", "skip-forward"]
598bundled-themes = ["material"]
599system-themes = ["sf-symbols"]
600"#;
601 let config: MasterConfig = toml::from_str(toml_str).unwrap();
602 assert_eq!(config.name, "app-icon");
603 assert_eq!(config.roles, vec!["play-pause", "skip-forward"]);
604 assert_eq!(config.bundled_themes, vec!["material"]);
605 assert_eq!(config.system_themes, vec!["sf-symbols"]);
606 }
607
608 #[test]
609 fn master_config_empty_optional_fields() {
610 let toml_str = r#"
611name = "x"
612roles = ["a"]
613"#;
614 let config: MasterConfig = toml::from_str(toml_str).unwrap();
615 assert_eq!(config.name, "x");
616 assert_eq!(config.roles, vec!["a"]);
617 assert!(config.bundled_themes.is_empty());
618 assert!(config.system_themes.is_empty());
619 }
620
621 #[test]
622 fn master_config_rejects_unknown_fields() {
623 let toml_str = r#"
624name = "x"
625roles = ["a"]
626bogus = "nope"
627"#;
628 let result = toml::from_str::<MasterConfig>(toml_str);
629 assert!(result.is_err());
630 }
631
632 #[test]
635 fn mapping_value_simple() {
636 let toml_str = r#"play-pause = "play_pause""#;
637 let mapping: BTreeMap<String, MappingValue> = toml::from_str(toml_str).unwrap();
638 match &mapping["play-pause"] {
639 MappingValue::Simple(s) => assert_eq!(s, "play_pause"),
640 _ => panic!("expected Simple variant"),
641 }
642 }
643
644 #[test]
645 fn mapping_value_de_aware() {
646 let toml_str = r#"play-pause = { kde = "media-playback-start", default = "play" }"#;
647 let mapping: BTreeMap<String, MappingValue> = toml::from_str(toml_str).unwrap();
648 match &mapping["play-pause"] {
649 MappingValue::DeAware(m) => {
650 assert_eq!(m["kde"], "media-playback-start");
651 assert_eq!(m["default"], "play");
652 }
653 _ => panic!("expected DeAware variant"),
654 }
655 }
656
657 #[test]
658 fn theme_mapping_mixed_values() {
659 let toml_str = r#"
660play-pause = "play_pause"
661bluetooth = { kde = "preferences-system-bluetooth", default = "bluetooth" }
662skip-forward = "skip_next"
663"#;
664 let mapping: ThemeMapping = toml::from_str(toml_str).unwrap();
665 assert_eq!(mapping.len(), 3);
666 assert!(matches!(&mapping["play-pause"], MappingValue::Simple(_)));
667 assert!(matches!(&mapping["bluetooth"], MappingValue::DeAware(_)));
668 assert!(matches!(&mapping["skip-forward"], MappingValue::Simple(_)));
669 }
670
671 #[test]
674 fn mapping_value_default_name_simple() {
675 let val = MappingValue::Simple("play_pause".to_string());
676 assert_eq!(val.default_name(), Some("play_pause"));
677 }
678
679 #[test]
680 fn mapping_value_default_name_de_aware() {
681 let mut m = BTreeMap::new();
682 m.insert("kde".to_string(), "media-playback-start".to_string());
683 m.insert("default".to_string(), "play".to_string());
684 let val = MappingValue::DeAware(m);
685 assert_eq!(val.default_name(), Some("play"));
686 }
687
688 #[test]
689 fn mapping_value_default_name_de_aware_missing_default() {
690 let mut m = BTreeMap::new();
691 m.insert("kde".to_string(), "media-playback-start".to_string());
692 let val = MappingValue::DeAware(m);
693 assert_eq!(val.default_name(), None);
694 }
695
696 #[test]
699 fn build_error_missing_role_format() {
700 let err = BuildError::MissingRole {
701 role: "play-pause".into(),
702 mapping_file: "icons/material/mapping.toml".into(),
703 };
704 let msg = err.to_string();
705 assert!(msg.contains("play-pause"), "should contain role name");
706 assert!(
707 msg.contains("icons/material/mapping.toml"),
708 "should contain file path"
709 );
710 }
711
712 #[test]
713 fn build_error_missing_svg_format() {
714 let err = BuildError::MissingSvg {
715 path: "icons/material/play.svg".into(),
716 };
717 let msg = err.to_string();
718 assert!(
719 msg.contains("icons/material/play.svg"),
720 "should contain SVG path"
721 );
722 }
723
724 #[test]
725 fn build_error_unknown_role_format() {
726 let err = BuildError::UnknownRole {
727 role: "bogus".into(),
728 mapping_file: "icons/material/mapping.toml".into(),
729 };
730 let msg = err.to_string();
731 assert!(msg.contains("bogus"), "should contain role name");
732 assert!(
733 msg.contains("icons/material/mapping.toml"),
734 "should contain file path"
735 );
736 }
737
738 #[test]
739 fn build_error_unknown_theme_format() {
740 let err = BuildError::UnknownTheme {
741 theme: "nonexistent".into(),
742 };
743 let msg = err.to_string();
744 assert!(msg.contains("nonexistent"), "should contain theme name");
745 }
746
747 #[test]
748 fn build_error_missing_default_format() {
749 let err = BuildError::MissingDefault {
750 role: "bluetooth".into(),
751 mapping_file: "icons/freedesktop/mapping.toml".into(),
752 };
753 let msg = err.to_string();
754 assert!(msg.contains("bluetooth"), "should contain role name");
755 assert!(
756 msg.contains("icons/freedesktop/mapping.toml"),
757 "should contain file path"
758 );
759 }
760
761 #[test]
762 fn build_error_duplicate_role_format() {
763 let err = BuildError::DuplicateRole {
764 role: "play-pause".into(),
765 file_a: "icons/a.toml".into(),
766 file_b: "icons/b.toml".into(),
767 };
768 let msg = err.to_string();
769 assert!(msg.contains("play-pause"), "should contain role name");
770 assert!(
771 msg.contains("icons/a.toml"),
772 "should contain first file path"
773 );
774 assert!(
775 msg.contains("icons/b.toml"),
776 "should contain second file path"
777 );
778 }
779
780 #[test]
783 fn known_themes_has_all_five() {
784 assert_eq!(KNOWN_THEMES.len(), 5);
785 assert!(KNOWN_THEMES.contains(&"sf-symbols"));
786 assert!(KNOWN_THEMES.contains(&"segoe-fluent"));
787 assert!(KNOWN_THEMES.contains(&"freedesktop"));
788 assert!(KNOWN_THEMES.contains(&"material"));
789 assert!(KNOWN_THEMES.contains(&"lucide"));
790 }
791
792 fn create_fixture_dir(suffix: &str) -> PathBuf {
795 let dir = std::env::temp_dir().join(format!("native_theme_test_pipeline_{suffix}"));
796 let _ = fs::remove_dir_all(&dir);
797 fs::create_dir_all(&dir).unwrap();
798 dir
799 }
800
801 fn write_fixture(dir: &Path, path: &str, content: &str) {
802 let full_path = dir.join(path);
803 if let Some(parent) = full_path.parent() {
804 fs::create_dir_all(parent).unwrap();
805 }
806 fs::write(full_path, content).unwrap();
807 }
808
809 const SVG_STUB: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"></svg>"#;
810
811 #[test]
814 fn pipeline_happy_path_generates_code() {
815 let dir = create_fixture_dir("happy");
816 write_fixture(
817 &dir,
818 "material/mapping.toml",
819 r#"
820play-pause = "play_pause"
821skip-forward = "skip_next"
822"#,
823 );
824 write_fixture(
825 &dir,
826 "sf-symbols/mapping.toml",
827 r#"
828play-pause = "play.fill"
829skip-forward = "forward.fill"
830"#,
831 );
832 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
833 write_fixture(&dir, "material/skip_next.svg", SVG_STUB);
834
835 let config: MasterConfig = toml::from_str(
836 r#"
837name = "sample-icon"
838roles = ["play-pause", "skip-forward"]
839bundled-themes = ["material"]
840system-themes = ["sf-symbols"]
841"#,
842 )
843 .unwrap();
844
845 let result = run_pipeline(
846 &[("sample-icons.toml".to_string(), config)],
847 std::slice::from_ref(&dir),
848 None,
849 None,
850 );
851
852 assert!(
853 result.errors.is_empty(),
854 "expected no errors: {:?}",
855 result.errors
856 );
857 assert!(!result.code.is_empty(), "expected generated code");
858 assert!(result.code.contains("pub enum SampleIcon"));
859 assert!(result.code.contains("PlayPause"));
860 assert!(result.code.contains("SkipForward"));
861
862 let _ = fs::remove_dir_all(&dir);
863 }
864
865 #[test]
866 fn pipeline_output_filename_uses_snake_case() {
867 let dir = create_fixture_dir("filename");
868 write_fixture(
869 &dir,
870 "material/mapping.toml",
871 "play-pause = \"play_pause\"\n",
872 );
873 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
874
875 let config: MasterConfig = toml::from_str(
876 r#"
877name = "app-icon"
878roles = ["play-pause"]
879bundled-themes = ["material"]
880"#,
881 )
882 .unwrap();
883
884 let result = run_pipeline(
885 &[("app.toml".to_string(), config)],
886 std::slice::from_ref(&dir),
887 None,
888 None,
889 );
890
891 assert_eq!(result.output_filename, "app_icon.rs");
892
893 let _ = fs::remove_dir_all(&dir);
894 }
895
896 #[test]
897 fn pipeline_collects_rerun_paths() {
898 let dir = create_fixture_dir("rerun");
899 write_fixture(
900 &dir,
901 "material/mapping.toml",
902 r#"
903play-pause = "play_pause"
904"#,
905 );
906 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
907
908 let config: MasterConfig = toml::from_str(
909 r#"
910name = "test"
911roles = ["play-pause"]
912bundled-themes = ["material"]
913"#,
914 )
915 .unwrap();
916
917 let result = run_pipeline(
918 &[("test.toml".to_string(), config)],
919 std::slice::from_ref(&dir),
920 None,
921 None,
922 );
923
924 assert!(result.errors.is_empty());
925 let path_strs: Vec<String> = result
927 .rerun_paths
928 .iter()
929 .map(|p| p.to_string_lossy().to_string())
930 .collect();
931 assert!(
932 path_strs.iter().any(|p| p.contains("test.toml")),
933 "should track master TOML"
934 );
935 assert!(
936 path_strs.iter().any(|p| p.contains("mapping.toml")),
937 "should track mapping TOML"
938 );
939 assert!(
940 path_strs.iter().any(|p| p.contains("play_pause.svg")),
941 "should track SVG files"
942 );
943
944 let _ = fs::remove_dir_all(&dir);
945 }
946
947 #[test]
948 fn pipeline_emits_size_report() {
949 let dir = create_fixture_dir("size");
950 write_fixture(
951 &dir,
952 "material/mapping.toml",
953 "play-pause = \"play_pause\"\n",
954 );
955 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
956
957 let config: MasterConfig = toml::from_str(
958 r#"
959name = "test"
960roles = ["play-pause"]
961bundled-themes = ["material"]
962"#,
963 )
964 .unwrap();
965
966 let result = run_pipeline(
967 &[("test.toml".to_string(), config)],
968 std::slice::from_ref(&dir),
969 None,
970 None,
971 );
972
973 assert!(result.errors.is_empty());
974 let report = result
975 .size_report
976 .as_ref()
977 .expect("should have size report");
978 assert_eq!(report.role_count, 1);
979 assert_eq!(report.bundled_theme_count, 1);
980 assert_eq!(report.svg_count, 1);
981 assert!(report.total_svg_bytes > 0, "SVGs should have nonzero size");
982
983 let _ = fs::remove_dir_all(&dir);
984 }
985
986 #[test]
987 fn pipeline_returns_errors_on_missing_role() {
988 let dir = create_fixture_dir("missing_role");
989 write_fixture(
991 &dir,
992 "material/mapping.toml",
993 "play-pause = \"play_pause\"\n",
994 );
995 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
996
997 let config: MasterConfig = toml::from_str(
998 r#"
999name = "test"
1000roles = ["play-pause", "skip-forward"]
1001bundled-themes = ["material"]
1002"#,
1003 )
1004 .unwrap();
1005
1006 let result = run_pipeline(
1007 &[("test.toml".to_string(), config)],
1008 std::slice::from_ref(&dir),
1009 None,
1010 None,
1011 );
1012
1013 assert!(!result.errors.is_empty(), "should have errors");
1014 assert!(
1015 result.errors.iter().any(|e| e.contains("skip-forward")),
1016 "should mention missing role"
1017 );
1018 assert!(result.code.is_empty(), "no code on errors");
1019
1020 let _ = fs::remove_dir_all(&dir);
1021 }
1022
1023 #[test]
1024 fn pipeline_returns_errors_on_missing_svg() {
1025 let dir = create_fixture_dir("missing_svg");
1026 write_fixture(
1027 &dir,
1028 "material/mapping.toml",
1029 r#"
1030play-pause = "play_pause"
1031skip-forward = "skip_next"
1032"#,
1033 );
1034 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1036
1037 let config: MasterConfig = toml::from_str(
1038 r#"
1039name = "test"
1040roles = ["play-pause", "skip-forward"]
1041bundled-themes = ["material"]
1042"#,
1043 )
1044 .unwrap();
1045
1046 let result = run_pipeline(
1047 &[("test.toml".to_string(), config)],
1048 std::slice::from_ref(&dir),
1049 None,
1050 None,
1051 );
1052
1053 assert!(!result.errors.is_empty(), "should have errors");
1054 assert!(
1055 result.errors.iter().any(|e| e.contains("skip_next.svg")),
1056 "should mention missing SVG"
1057 );
1058
1059 let _ = fs::remove_dir_all(&dir);
1060 }
1061
1062 #[test]
1063 fn pipeline_orphan_svgs_are_warnings() {
1064 let dir = create_fixture_dir("orphan_warn");
1065 write_fixture(
1066 &dir,
1067 "material/mapping.toml",
1068 "play-pause = \"play_pause\"\n",
1069 );
1070 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1071 write_fixture(&dir, "material/unused.svg", SVG_STUB);
1072
1073 let config: MasterConfig = toml::from_str(
1074 r#"
1075name = "test"
1076roles = ["play-pause"]
1077bundled-themes = ["material"]
1078"#,
1079 )
1080 .unwrap();
1081
1082 let result = run_pipeline(
1083 &[("test.toml".to_string(), config)],
1084 std::slice::from_ref(&dir),
1085 None,
1086 None,
1087 );
1088
1089 assert!(result.errors.is_empty(), "orphans are not errors");
1090 assert!(!result.warnings.is_empty(), "should have orphan warning");
1091 assert!(result.warnings.iter().any(|w| w.contains("unused.svg")));
1092
1093 let _ = fs::remove_dir_all(&dir);
1094 }
1095
1096 #[test]
1099 fn merge_configs_combines_roles() {
1100 let config_a: MasterConfig = toml::from_str(
1101 r#"
1102name = "a"
1103roles = ["play-pause"]
1104bundled-themes = ["material"]
1105"#,
1106 )
1107 .unwrap();
1108 let config_b: MasterConfig = toml::from_str(
1109 r#"
1110name = "b"
1111roles = ["skip-forward"]
1112bundled-themes = ["material"]
1113system-themes = ["sf-symbols"]
1114"#,
1115 )
1116 .unwrap();
1117
1118 let configs = vec![
1119 ("a.toml".to_string(), config_a),
1120 ("b.toml".to_string(), config_b),
1121 ];
1122 let merged = merge_configs(&configs, None);
1123
1124 assert_eq!(merged.name, "a"); assert_eq!(merged.roles, vec!["play-pause", "skip-forward"]);
1126 assert_eq!(merged.bundled_themes, vec!["material"]); assert_eq!(merged.system_themes, vec!["sf-symbols"]);
1128 }
1129
1130 #[test]
1131 fn merge_configs_uses_enum_name_override() {
1132 let config: MasterConfig = toml::from_str(
1133 r#"
1134name = "original"
1135roles = ["x"]
1136"#,
1137 )
1138 .unwrap();
1139
1140 let configs = vec![("a.toml".to_string(), config)];
1141 let merged = merge_configs(&configs, Some("MyIcons"));
1142
1143 assert_eq!(merged.name, "MyIcons");
1144 }
1145
1146 #[test]
1149 fn pipeline_builder_merges_two_files() {
1150 let dir = create_fixture_dir("builder_merge");
1151 write_fixture(
1152 &dir,
1153 "material/mapping.toml",
1154 r#"
1155play-pause = "play_pause"
1156skip-forward = "skip_next"
1157"#,
1158 );
1159 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1160 write_fixture(&dir, "material/skip_next.svg", SVG_STUB);
1161
1162 let config_a: MasterConfig = toml::from_str(
1163 r#"
1164name = "icons-a"
1165roles = ["play-pause"]
1166bundled-themes = ["material"]
1167"#,
1168 )
1169 .unwrap();
1170 let config_b: MasterConfig = toml::from_str(
1171 r#"
1172name = "icons-b"
1173roles = ["skip-forward"]
1174bundled-themes = ["material"]
1175"#,
1176 )
1177 .unwrap();
1178
1179 let result = run_pipeline(
1180 &[
1181 ("a.toml".to_string(), config_a),
1182 ("b.toml".to_string(), config_b),
1183 ],
1184 &[dir.clone(), dir.clone()],
1185 Some("AllIcons"),
1186 None,
1187 );
1188
1189 assert!(
1190 result.errors.is_empty(),
1191 "expected no errors: {:?}",
1192 result.errors
1193 );
1194 assert!(
1195 result.code.contains("pub enum AllIcons"),
1196 "should use override name"
1197 );
1198 assert!(result.code.contains("PlayPause"));
1199 assert!(result.code.contains("SkipForward"));
1200 assert_eq!(result.output_filename, "all_icons.rs");
1201
1202 let _ = fs::remove_dir_all(&dir);
1203 }
1204
1205 #[test]
1206 fn pipeline_builder_detects_duplicate_roles() {
1207 let dir = create_fixture_dir("builder_dup");
1208 write_fixture(
1209 &dir,
1210 "material/mapping.toml",
1211 "play-pause = \"play_pause\"\n",
1212 );
1213 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1214
1215 let config_a: MasterConfig = toml::from_str(
1216 r#"
1217name = "a"
1218roles = ["play-pause"]
1219bundled-themes = ["material"]
1220"#,
1221 )
1222 .unwrap();
1223 let config_b: MasterConfig = toml::from_str(
1224 r#"
1225name = "b"
1226roles = ["play-pause"]
1227bundled-themes = ["material"]
1228"#,
1229 )
1230 .unwrap();
1231
1232 let result = run_pipeline(
1233 &[
1234 ("a.toml".to_string(), config_a),
1235 ("b.toml".to_string(), config_b),
1236 ],
1237 &[dir.clone(), dir.clone()],
1238 None,
1239 None,
1240 );
1241
1242 assert!(!result.errors.is_empty(), "should detect duplicate roles");
1243 assert!(result.errors.iter().any(|e| e.contains("play-pause")));
1244
1245 let _ = fs::remove_dir_all(&dir);
1246 }
1247
1248 #[test]
1249 fn pipeline_generates_relative_include_bytes_paths() {
1250 let tmpdir = create_fixture_dir("rel_paths");
1255 write_fixture(
1256 &tmpdir,
1257 "icons/material/mapping.toml",
1258 "play-pause = \"play_pause\"\n",
1259 );
1260 write_fixture(&tmpdir, "icons/material/play_pause.svg", SVG_STUB);
1261
1262 let config: MasterConfig = toml::from_str(
1263 r#"
1264name = "test"
1265roles = ["play-pause"]
1266bundled-themes = ["material"]
1267"#,
1268 )
1269 .unwrap();
1270
1271 let abs_base_dir = tmpdir.join("icons");
1273
1274 let result = run_pipeline(
1275 &[("icons/icons.toml".to_string(), config)],
1276 &[abs_base_dir],
1277 None,
1278 Some(&tmpdir), );
1280
1281 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
1282 assert!(
1284 result.code.contains("\"/icons/material/play_pause.svg\""),
1285 "include_bytes path should use relative base_dir 'icons'. code:\n{}",
1286 result.code,
1287 );
1288 let tmpdir_str = tmpdir.to_string_lossy();
1290 assert!(
1291 !result.code.contains(&*tmpdir_str),
1292 "include_bytes path should NOT contain absolute tmpdir path",
1293 );
1294
1295 let _ = fs::remove_dir_all(&tmpdir);
1296 }
1297
1298 #[test]
1299 fn pipeline_no_system_svg_check() {
1300 let dir = create_fixture_dir("no_sys_svg");
1302 write_fixture(
1304 &dir,
1305 "sf-symbols/mapping.toml",
1306 r#"
1307play-pause = "play.fill"
1308"#,
1309 );
1310
1311 let config: MasterConfig = toml::from_str(
1312 r#"
1313name = "test"
1314roles = ["play-pause"]
1315system-themes = ["sf-symbols"]
1316"#,
1317 )
1318 .unwrap();
1319
1320 let result = run_pipeline(
1321 &[("test.toml".to_string(), config)],
1322 std::slice::from_ref(&dir),
1323 None,
1324 None,
1325 );
1326
1327 assert!(
1328 result.errors.is_empty(),
1329 "system themes should not require SVGs: {:?}",
1330 result.errors
1331 );
1332
1333 let _ = fs::remove_dir_all(&dir);
1334 }
1335}