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