1#![warn(missing_docs)]
120#![forbid(unsafe_code)]
121
122mod codegen;
123mod error;
124mod schema;
125mod validate;
126
127use std::collections::BTreeMap;
128use std::path::{Path, PathBuf};
129
130use heck::ToSnakeCase;
131
132pub use error::{BuildError, BuildErrors};
133use schema::{MasterConfig, ThemeMapping};
134
135#[cfg(test)]
136use schema::{MappingValue, THEME_TABLE};
137
138#[derive(Debug)]
145pub struct GenerateOutput {
146 pub output_path: PathBuf,
148 pub warnings: Vec<String>,
150 pub role_count: usize,
152 pub bundled_theme_count: usize,
154 pub svg_count: usize,
156 pub total_svg_bytes: u64,
158 rerun_paths: Vec<PathBuf>,
160 pub code: String,
162}
163
164impl GenerateOutput {
165 pub fn emit_cargo_directives(&self) {
174 for path in &self.rerun_paths {
175 println!("cargo::rerun-if-changed={}", path.display());
176 }
177 std::fs::write(&self.output_path, &self.code)
178 .unwrap_or_else(|e| panic!("failed to write {}: {e}", self.output_path.display()));
179 for w in &self.warnings {
180 println!("cargo::warning={w}");
181 }
182 }
183}
184
185pub trait UnwrapOrExit<T> {
201 fn unwrap_or_exit(self) -> T;
203}
204
205impl UnwrapOrExit<GenerateOutput> for Result<GenerateOutput, BuildErrors> {
206 fn unwrap_or_exit(self) -> GenerateOutput {
207 match self {
208 Ok(output) => output,
209 Err(errors) => {
210 errors.emit_cargo_errors();
214 std::process::exit(1);
215 }
216 }
217 }
218}
219
220pub fn generate_icons(toml_path: impl AsRef<Path>) -> Result<GenerateOutput, BuildErrors> {
234 let toml_path = toml_path.as_ref();
235 let manifest_dir =
236 PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"));
237 let out_dir = PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR not set"));
238 let resolved = manifest_dir.join(toml_path);
239
240 let content = std::fs::read_to_string(&resolved)
241 .unwrap_or_else(|e| panic!("failed to read {}: {e}", resolved.display()));
242 let config: MasterConfig = toml::from_str(&content)
243 .unwrap_or_else(|e| panic!("failed to parse {}: {e}", resolved.display()));
244
245 let base_dir = resolved
246 .parent()
247 .expect("TOML path has no parent")
248 .to_path_buf();
249 let file_path_str = resolved.to_string_lossy().to_string();
250
251 let result = run_pipeline(
252 &[(file_path_str, config)],
253 &[base_dir],
254 None,
255 Some(&manifest_dir),
256 None,
257 &[],
258 );
259
260 pipeline_result_to_output(result, &out_dir)
261}
262
263pub struct IconGenerator {
265 sources: Vec<PathBuf>,
266 enum_name_override: Option<String>,
267 base_dir: Option<PathBuf>,
268 crate_path: Option<String>,
269 extra_derives: Vec<String>,
270 output_dir: Option<PathBuf>,
271}
272
273impl Default for IconGenerator {
274 fn default() -> Self {
275 Self::new()
276 }
277}
278
279impl IconGenerator {
280 pub fn new() -> Self {
282 Self {
283 sources: Vec::new(),
284 enum_name_override: None,
285 base_dir: None,
286 crate_path: None,
287 extra_derives: Vec::new(),
288 output_dir: None,
289 }
290 }
291
292 pub fn source(mut self, path: impl AsRef<Path>) -> Self {
294 self.sources.push(path.as_ref().to_path_buf());
295 self
296 }
297
298 pub fn enum_name(mut self, name: &str) -> Self {
300 self.enum_name_override = Some(name.to_string());
301 self
302 }
303
304 pub fn base_dir(mut self, path: impl AsRef<Path>) -> Self {
313 self.base_dir = Some(path.as_ref().to_path_buf());
314 self
315 }
316
317 pub fn crate_path(mut self, path: &str) -> Self {
325 self.crate_path = Some(path.to_string());
326 self
327 }
328
329 pub fn derive(mut self, name: &str) -> Self {
343 self.extra_derives.push(name.to_string());
344 self
345 }
346
347 pub fn output_dir(mut self, path: impl AsRef<Path>) -> Self {
353 self.output_dir = Some(path.as_ref().to_path_buf());
354 self
355 }
356
357 pub fn generate(self) -> Result<GenerateOutput, BuildErrors> {
373 if self.sources.is_empty() {
374 return Err(BuildErrors(vec![BuildError::Io {
375 message:
376 "no source files added to IconGenerator (call .source() before .generate())"
377 .into(),
378 }]));
379 }
380
381 let needs_manifest_dir = self.sources.iter().any(|s| !s.is_absolute())
382 || self.base_dir.as_ref().is_some_and(|b| !b.is_absolute());
383 let manifest_dir = if needs_manifest_dir {
384 Some(PathBuf::from(
385 std::env::var("CARGO_MANIFEST_DIR").expect("CARGO_MANIFEST_DIR not set"),
386 ))
387 } else {
388 std::env::var("CARGO_MANIFEST_DIR").ok().map(PathBuf::from)
389 };
390
391 let out_dir = self
392 .output_dir
393 .unwrap_or_else(|| PathBuf::from(std::env::var("OUT_DIR").expect("OUT_DIR not set")));
394
395 let mut configs = Vec::new();
396 let mut base_dirs = Vec::new();
397
398 for source in &self.sources {
399 let resolved = if source.is_absolute() {
400 source.clone()
401 } else {
402 manifest_dir.as_ref().unwrap().join(source)
403 };
404 let content = std::fs::read_to_string(&resolved)
405 .unwrap_or_else(|e| panic!("failed to read {}: {e}", resolved.display()));
406 let config: MasterConfig = toml::from_str(&content)
407 .unwrap_or_else(|e| panic!("failed to parse {}: {e}", resolved.display()));
408
409 let file_path_str = resolved.to_string_lossy().to_string();
410
411 if let Some(ref explicit_base) = self.base_dir {
412 let base = if explicit_base.is_absolute() {
413 explicit_base.clone()
414 } else {
415 manifest_dir.as_ref().unwrap().join(explicit_base)
416 };
417 base_dirs.push(base);
418 } else {
419 let parent = resolved
420 .parent()
421 .expect("TOML path has no parent")
422 .to_path_buf();
423 base_dirs.push(parent);
424 }
425
426 configs.push((file_path_str, config));
427 }
428
429 if self.base_dir.is_none() && base_dirs.len() > 1 {
431 let first = &base_dirs[0];
432 let divergent = base_dirs.iter().any(|d| d != first);
433 if divergent {
434 return Err(BuildErrors(vec![BuildError::Io {
435 message: "multiple source files have different parent directories; \
436 use .base_dir() to specify a common base directory for theme resolution"
437 .into(),
438 }]));
439 }
440 }
441
442 let result = run_pipeline(
443 &configs,
444 &base_dirs,
445 self.enum_name_override.as_deref(),
446 manifest_dir.as_deref(),
447 self.crate_path.as_deref(),
448 &self.extra_derives,
449 );
450
451 pipeline_result_to_output(result, &out_dir)
452 }
453}
454
455struct PipelineResult {
461 pub code: String,
463 pub errors: Vec<BuildError>,
465 pub warnings: Vec<String>,
467 pub rerun_paths: Vec<PathBuf>,
469 pub size_report: Option<SizeReport>,
471 pub output_filename: String,
473}
474
475struct SizeReport {
477 pub role_count: usize,
479 pub bundled_theme_count: usize,
481 pub total_svg_bytes: u64,
483 pub svg_count: usize,
485}
486
487fn run_pipeline(
500 configs: &[(String, MasterConfig)],
501 base_dirs: &[PathBuf],
502 enum_name_override: Option<&str>,
503 manifest_dir: Option<&Path>,
504 crate_path: Option<&str>,
505 extra_derives: &[String],
506) -> PipelineResult {
507 assert_eq!(configs.len(), base_dirs.len());
508
509 let mut errors: Vec<BuildError> = Vec::new();
510 let mut warnings: Vec<String> = Vec::new();
511 let mut rerun_paths: Vec<PathBuf> = Vec::new();
512 let mut all_mappings: BTreeMap<String, ThemeMapping> = BTreeMap::new();
513 let mut svg_paths: Vec<PathBuf> = Vec::new();
514
515 let first_name = enum_name_override
517 .map(|s| s.to_string())
518 .unwrap_or_else(|| configs[0].1.name.clone());
519 let output_filename = format!("{}.rs", first_name.to_snake_case());
520
521 for (file_path, config) in configs {
523 let dup_in_file_errors = validate::validate_no_duplicate_roles_in_file(config, file_path);
525 errors.extend(dup_in_file_errors);
526
527 let overlap_errors = validate::validate_theme_overlap(config);
529 errors.extend(overlap_errors);
530
531 let dup_theme_errors = validate::validate_no_duplicate_themes(config);
533 errors.extend(dup_theme_errors);
534 }
535
536 if configs.len() > 1 {
538 let dup_errors = validate::validate_no_duplicate_roles(configs);
539 errors.extend(dup_errors);
540 }
541
542 let merged = merge_configs(configs, enum_name_override);
544
545 let id_errors = validate::validate_identifiers(&merged);
547 errors.extend(id_errors);
548
549 for (file_path, _config) in configs {
551 rerun_paths.push(PathBuf::from(file_path));
552 }
553
554 let theme_errors = validate::validate_themes(&merged);
556 errors.extend(theme_errors);
557
558 let base_dir = &base_dirs[0];
561
562 for theme_name in &merged.bundled_themes {
564 let theme_dir = base_dir.join(theme_name);
565 let mapping_path = theme_dir.join("mapping.toml");
566 let mapping_path_str = mapping_path.to_string_lossy().to_string();
567
568 rerun_paths.push(mapping_path.clone());
570 rerun_paths.push(theme_dir.clone());
571
572 match std::fs::read_to_string(&mapping_path) {
573 Ok(content) => match toml::from_str::<ThemeMapping>(&content) {
574 Ok(mapping) => {
575 let map_errors =
577 validate::validate_mapping(&merged.roles, &mapping, &mapping_path_str);
578 errors.extend(map_errors);
579
580 let name_errors =
582 validate::validate_mapping_values(&mapping, &mapping_path_str);
583 errors.extend(name_errors);
584
585 let svg_errors =
587 validate::validate_svgs(&mapping, &theme_dir, &mapping_path_str);
588 errors.extend(svg_errors);
589
590 let de_warnings = validate::validate_de_keys(&mapping, &mapping_path_str);
592 warnings.extend(de_warnings);
593
594 for (role_name, value) in &mapping {
597 if matches!(value, schema::MappingValue::DeAware(_)) {
598 warnings.push(format!(
599 "bundled theme \"{}\" has DE-aware mapping for \"{}\": \
600 only the default SVG will be embedded",
601 theme_name, role_name
602 ));
603 }
604 }
605
606 let orphan_warnings = check_orphan_svgs_and_collect_paths(
608 &mapping,
609 &theme_dir,
610 theme_name,
611 &mut svg_paths,
612 &mut rerun_paths,
613 );
614 warnings.extend(orphan_warnings);
615
616 all_mappings.insert(theme_name.clone(), mapping);
617 }
618 Err(e) => {
619 errors.push(BuildError::Io {
620 message: format!("failed to parse {mapping_path_str}: {e}"),
621 });
622 }
623 },
624 Err(e) => {
625 errors.push(BuildError::Io {
626 message: format!("failed to read {mapping_path_str}: {e}"),
627 });
628 }
629 }
630 }
631
632 for theme_name in &merged.system_themes {
634 let theme_dir = base_dir.join(theme_name);
635 let mapping_path = theme_dir.join("mapping.toml");
636 let mapping_path_str = mapping_path.to_string_lossy().to_string();
637
638 rerun_paths.push(mapping_path.clone());
640
641 match std::fs::read_to_string(&mapping_path) {
642 Ok(content) => match toml::from_str::<ThemeMapping>(&content) {
643 Ok(mapping) => {
644 let map_errors =
645 validate::validate_mapping(&merged.roles, &mapping, &mapping_path_str);
646 errors.extend(map_errors);
647
648 let name_errors =
650 validate::validate_mapping_values(&mapping, &mapping_path_str);
651 errors.extend(name_errors);
652
653 let de_warnings = validate::validate_de_keys(&mapping, &mapping_path_str);
655 warnings.extend(de_warnings);
656
657 all_mappings.insert(theme_name.clone(), mapping);
658 }
659 Err(e) => {
660 errors.push(BuildError::Io {
661 message: format!("failed to parse {mapping_path_str}: {e}"),
662 });
663 }
664 },
665 Err(e) => {
666 errors.push(BuildError::Io {
667 message: format!("failed to read {mapping_path_str}: {e}"),
668 });
669 }
670 }
671 }
672
673 if !errors.is_empty() {
675 return PipelineResult {
676 code: String::new(),
677 errors,
678 warnings,
679 rerun_paths,
680 size_report: None,
681 output_filename,
682 };
683 }
684
685 let base_dir_str = if let Some(mdir) = manifest_dir {
689 base_dir
690 .strip_prefix(mdir)
691 .unwrap_or(base_dir)
692 .to_string_lossy()
693 .to_string()
694 } else {
695 base_dir.to_string_lossy().to_string()
696 };
697
698 let effective_crate_path = crate_path.unwrap_or("native_theme");
700 let code = codegen::generate_code(
701 &merged,
702 &all_mappings,
703 &base_dir_str,
704 effective_crate_path,
705 extra_derives,
706 );
707
708 let total_svg_bytes: u64 = svg_paths
710 .iter()
711 .filter_map(|p| std::fs::metadata(p).ok())
712 .map(|m| m.len())
713 .sum();
714
715 let size_report = Some(SizeReport {
716 role_count: merged.roles.len(),
717 bundled_theme_count: merged.bundled_themes.len(),
718 total_svg_bytes,
719 svg_count: svg_paths.len(),
720 });
721
722 PipelineResult {
723 code,
724 errors,
725 warnings,
726 rerun_paths,
727 size_report,
728 output_filename,
729 }
730}
731
732fn check_orphan_svgs_and_collect_paths(
734 mapping: &ThemeMapping,
735 theme_dir: &Path,
736 theme_name: &str,
737 svg_paths: &mut Vec<PathBuf>,
738 rerun_paths: &mut Vec<PathBuf>,
739) -> Vec<String> {
740 for value in mapping.values() {
742 if let Some(name) = value.default_name() {
743 let svg_path = theme_dir.join(format!("{name}.svg"));
744 if svg_path.exists() {
745 rerun_paths.push(svg_path.clone());
746 svg_paths.push(svg_path);
747 }
748 }
749 }
750
751 validate::check_orphan_svgs(mapping, theme_dir, theme_name)
752}
753
754fn merge_configs(
756 configs: &[(String, MasterConfig)],
757 enum_name_override: Option<&str>,
758) -> MasterConfig {
759 let name = enum_name_override
760 .map(|s| s.to_string())
761 .unwrap_or_else(|| configs[0].1.name.clone());
762
763 let mut roles = Vec::new();
764 let mut bundled_themes = Vec::new();
765 let mut system_themes = Vec::new();
766 let mut seen_bundled = std::collections::BTreeSet::new();
767 let mut seen_system = std::collections::BTreeSet::new();
768
769 for (_path, config) in configs {
770 roles.extend(config.roles.iter().cloned());
771
772 for t in &config.bundled_themes {
773 if seen_bundled.insert(t.clone()) {
774 bundled_themes.push(t.clone());
775 }
776 }
777 for t in &config.system_themes {
778 if seen_system.insert(t.clone()) {
779 system_themes.push(t.clone());
780 }
781 }
782 }
783
784 MasterConfig {
785 name,
786 roles,
787 bundled_themes,
788 system_themes,
789 }
790}
791
792fn pipeline_result_to_output(
794 result: PipelineResult,
795 out_dir: &Path,
796) -> Result<GenerateOutput, BuildErrors> {
797 if !result.errors.is_empty() {
798 for path in &result.rerun_paths {
801 println!("cargo::rerun-if-changed={}", path.display());
802 }
803 return Err(BuildErrors(result.errors));
804 }
805
806 let output_path = out_dir.join(&result.output_filename);
807
808 let (role_count, bundled_theme_count, svg_count, total_svg_bytes) = match &result.size_report {
809 Some(report) => (
810 report.role_count,
811 report.bundled_theme_count,
812 report.svg_count,
813 report.total_svg_bytes,
814 ),
815 None => (0, 0, 0, 0),
816 };
817
818 Ok(GenerateOutput {
819 output_path,
820 warnings: result.warnings,
821 role_count,
822 bundled_theme_count,
823 svg_count,
824 total_svg_bytes,
825 rerun_paths: result.rerun_paths,
826 code: result.code,
827 })
828}
829
830#[cfg(test)]
831mod tests {
832 use super::*;
833 use std::collections::BTreeMap;
834 use std::fs;
835
836 #[test]
839 fn master_config_deserializes_full() {
840 let toml_str = r#"
841name = "app-icon"
842roles = ["play-pause", "skip-forward"]
843bundled-themes = ["material"]
844system-themes = ["sf-symbols"]
845"#;
846 let config: MasterConfig = toml::from_str(toml_str).unwrap();
847 assert_eq!(config.name, "app-icon");
848 assert_eq!(config.roles, vec!["play-pause", "skip-forward"]);
849 assert_eq!(config.bundled_themes, vec!["material"]);
850 assert_eq!(config.system_themes, vec!["sf-symbols"]);
851 }
852
853 #[test]
854 fn master_config_empty_optional_fields() {
855 let toml_str = r#"
856name = "x"
857roles = ["a"]
858"#;
859 let config: MasterConfig = toml::from_str(toml_str).unwrap();
860 assert_eq!(config.name, "x");
861 assert_eq!(config.roles, vec!["a"]);
862 assert!(config.bundled_themes.is_empty());
863 assert!(config.system_themes.is_empty());
864 }
865
866 #[test]
867 fn master_config_rejects_unknown_fields() {
868 let toml_str = r#"
869name = "x"
870roles = ["a"]
871bogus = "nope"
872"#;
873 let result = toml::from_str::<MasterConfig>(toml_str);
874 assert!(result.is_err());
875 }
876
877 #[test]
880 fn mapping_value_simple() {
881 let toml_str = r#"play-pause = "play_pause""#;
882 let mapping: BTreeMap<String, MappingValue> = toml::from_str(toml_str).unwrap();
883 match &mapping["play-pause"] {
884 MappingValue::Simple(s) => assert_eq!(s, "play_pause"),
885 _ => panic!("expected Simple variant"),
886 }
887 }
888
889 #[test]
890 fn mapping_value_de_aware() {
891 let toml_str = r#"play-pause = { kde = "media-playback-start", default = "play" }"#;
892 let mapping: BTreeMap<String, MappingValue> = toml::from_str(toml_str).unwrap();
893 match &mapping["play-pause"] {
894 MappingValue::DeAware(m) => {
895 assert_eq!(m["kde"], "media-playback-start");
896 assert_eq!(m["default"], "play");
897 }
898 _ => panic!("expected DeAware variant"),
899 }
900 }
901
902 #[test]
903 fn theme_mapping_mixed_values() {
904 let toml_str = r#"
905play-pause = "play_pause"
906bluetooth = { kde = "preferences-system-bluetooth", default = "bluetooth" }
907skip-forward = "skip_next"
908"#;
909 let mapping: ThemeMapping = toml::from_str(toml_str).unwrap();
910 assert_eq!(mapping.len(), 3);
911 assert!(matches!(&mapping["play-pause"], MappingValue::Simple(_)));
912 assert!(matches!(&mapping["bluetooth"], MappingValue::DeAware(_)));
913 assert!(matches!(&mapping["skip-forward"], MappingValue::Simple(_)));
914 }
915
916 #[test]
919 fn mapping_value_default_name_simple() {
920 let val = MappingValue::Simple("play_pause".to_string());
921 assert_eq!(val.default_name(), Some("play_pause"));
922 }
923
924 #[test]
925 fn mapping_value_default_name_de_aware() {
926 let mut m = BTreeMap::new();
927 m.insert("kde".to_string(), "media-playback-start".to_string());
928 m.insert("default".to_string(), "play".to_string());
929 let val = MappingValue::DeAware(m);
930 assert_eq!(val.default_name(), Some("play"));
931 }
932
933 #[test]
934 fn mapping_value_default_name_de_aware_missing_default() {
935 let mut m = BTreeMap::new();
936 m.insert("kde".to_string(), "media-playback-start".to_string());
937 let val = MappingValue::DeAware(m);
938 assert_eq!(val.default_name(), None);
939 }
940
941 #[test]
944 fn build_error_missing_role_format() {
945 let err = BuildError::MissingRole {
946 role: "play-pause".into(),
947 mapping_file: "icons/material/mapping.toml".into(),
948 };
949 let msg = err.to_string();
950 assert!(msg.contains("play-pause"), "should contain role name");
951 assert!(
952 msg.contains("icons/material/mapping.toml"),
953 "should contain file path"
954 );
955 }
956
957 #[test]
958 fn build_error_missing_svg_format() {
959 let err = BuildError::MissingSvg {
960 path: "icons/material/play.svg".into(),
961 };
962 let msg = err.to_string();
963 assert!(
964 msg.contains("icons/material/play.svg"),
965 "should contain SVG path"
966 );
967 }
968
969 #[test]
970 fn build_error_unknown_role_format() {
971 let err = BuildError::UnknownRole {
972 role: "bogus".into(),
973 mapping_file: "icons/material/mapping.toml".into(),
974 };
975 let msg = err.to_string();
976 assert!(msg.contains("bogus"), "should contain role name");
977 assert!(
978 msg.contains("icons/material/mapping.toml"),
979 "should contain file path"
980 );
981 }
982
983 #[test]
984 fn build_error_unknown_theme_format() {
985 let err = BuildError::UnknownTheme {
986 theme: "nonexistent".into(),
987 };
988 let msg = err.to_string();
989 assert!(msg.contains("nonexistent"), "should contain theme name");
990 }
991
992 #[test]
993 fn build_error_missing_default_format() {
994 let err = BuildError::MissingDefault {
995 role: "bluetooth".into(),
996 mapping_file: "icons/freedesktop/mapping.toml".into(),
997 };
998 let msg = err.to_string();
999 assert!(msg.contains("bluetooth"), "should contain role name");
1000 assert!(
1001 msg.contains("icons/freedesktop/mapping.toml"),
1002 "should contain file path"
1003 );
1004 }
1005
1006 #[test]
1007 fn build_error_duplicate_role_format() {
1008 let err = BuildError::DuplicateRole {
1009 role: "play-pause".into(),
1010 file_a: "icons/a.toml".into(),
1011 file_b: "icons/b.toml".into(),
1012 };
1013 let msg = err.to_string();
1014 assert!(msg.contains("play-pause"), "should contain role name");
1015 assert!(
1016 msg.contains("icons/a.toml"),
1017 "should contain first file path"
1018 );
1019 assert!(
1020 msg.contains("icons/b.toml"),
1021 "should contain second file path"
1022 );
1023 }
1024
1025 #[test]
1028 fn theme_table_has_all_five() {
1029 assert_eq!(THEME_TABLE.len(), 5);
1030 let names: Vec<&str> = THEME_TABLE.iter().map(|(k, _)| *k).collect();
1031 assert!(names.contains(&"sf-symbols"));
1032 assert!(names.contains(&"segoe-fluent"));
1033 assert!(names.contains(&"freedesktop"));
1034 assert!(names.contains(&"material"));
1035 assert!(names.contains(&"lucide"));
1036 }
1037
1038 fn create_fixture_dir(suffix: &str) -> PathBuf {
1041 let dir = std::env::temp_dir().join(format!("native_theme_test_pipeline_{suffix}"));
1042 let _ = fs::remove_dir_all(&dir);
1043 fs::create_dir_all(&dir).unwrap();
1044 dir
1045 }
1046
1047 fn write_fixture(dir: &Path, path: &str, content: &str) {
1048 let full_path = dir.join(path);
1049 if let Some(parent) = full_path.parent() {
1050 fs::create_dir_all(parent).unwrap();
1051 }
1052 fs::write(full_path, content).unwrap();
1053 }
1054
1055 const SVG_STUB: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"></svg>"#;
1056
1057 #[test]
1060 fn pipeline_happy_path_generates_code() {
1061 let dir = create_fixture_dir("happy");
1062 write_fixture(
1063 &dir,
1064 "material/mapping.toml",
1065 r#"
1066play-pause = "play_pause"
1067skip-forward = "skip_next"
1068"#,
1069 );
1070 write_fixture(
1071 &dir,
1072 "sf-symbols/mapping.toml",
1073 r#"
1074play-pause = "play.fill"
1075skip-forward = "forward.fill"
1076"#,
1077 );
1078 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1079 write_fixture(&dir, "material/skip_next.svg", SVG_STUB);
1080
1081 let config: MasterConfig = toml::from_str(
1082 r#"
1083name = "sample-icon"
1084roles = ["play-pause", "skip-forward"]
1085bundled-themes = ["material"]
1086system-themes = ["sf-symbols"]
1087"#,
1088 )
1089 .unwrap();
1090
1091 let result = run_pipeline(
1092 &[("sample-icons.toml".to_string(), config)],
1093 std::slice::from_ref(&dir),
1094 None,
1095 None,
1096 None,
1097 &[],
1098 );
1099
1100 assert!(
1101 result.errors.is_empty(),
1102 "expected no errors: {:?}",
1103 result.errors
1104 );
1105 assert!(!result.code.is_empty(), "expected generated code");
1106 assert!(result.code.contains("pub enum SampleIcon"));
1107 assert!(result.code.contains("PlayPause"));
1108 assert!(result.code.contains("SkipForward"));
1109
1110 let _ = fs::remove_dir_all(&dir);
1111 }
1112
1113 #[test]
1114 fn pipeline_output_filename_uses_snake_case() {
1115 let dir = create_fixture_dir("filename");
1116 write_fixture(
1117 &dir,
1118 "material/mapping.toml",
1119 "play-pause = \"play_pause\"\n",
1120 );
1121 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1122
1123 let config: MasterConfig = toml::from_str(
1124 r#"
1125name = "app-icon"
1126roles = ["play-pause"]
1127bundled-themes = ["material"]
1128"#,
1129 )
1130 .unwrap();
1131
1132 let result = run_pipeline(
1133 &[("app.toml".to_string(), config)],
1134 std::slice::from_ref(&dir),
1135 None,
1136 None,
1137 None,
1138 &[],
1139 );
1140
1141 assert_eq!(result.output_filename, "app_icon.rs");
1142
1143 let _ = fs::remove_dir_all(&dir);
1144 }
1145
1146 #[test]
1147 fn pipeline_collects_rerun_paths() {
1148 let dir = create_fixture_dir("rerun");
1149 write_fixture(
1150 &dir,
1151 "material/mapping.toml",
1152 r#"
1153play-pause = "play_pause"
1154"#,
1155 );
1156 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1157
1158 let config: MasterConfig = toml::from_str(
1159 r#"
1160name = "test"
1161roles = ["play-pause"]
1162bundled-themes = ["material"]
1163"#,
1164 )
1165 .unwrap();
1166
1167 let result = run_pipeline(
1168 &[("test.toml".to_string(), config)],
1169 std::slice::from_ref(&dir),
1170 None,
1171 None,
1172 None,
1173 &[],
1174 );
1175
1176 assert!(result.errors.is_empty());
1177 let path_strs: Vec<String> = result
1179 .rerun_paths
1180 .iter()
1181 .map(|p| p.to_string_lossy().to_string())
1182 .collect();
1183 assert!(
1184 path_strs.iter().any(|p| p.contains("test.toml")),
1185 "should track master TOML"
1186 );
1187 assert!(
1188 path_strs.iter().any(|p| p.contains("mapping.toml")),
1189 "should track mapping TOML"
1190 );
1191 assert!(
1192 path_strs.iter().any(|p| p.contains("play_pause.svg")),
1193 "should track SVG files"
1194 );
1195
1196 let _ = fs::remove_dir_all(&dir);
1197 }
1198
1199 #[test]
1200 fn pipeline_emits_size_report() {
1201 let dir = create_fixture_dir("size");
1202 write_fixture(
1203 &dir,
1204 "material/mapping.toml",
1205 "play-pause = \"play_pause\"\n",
1206 );
1207 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1208
1209 let config: MasterConfig = toml::from_str(
1210 r#"
1211name = "test"
1212roles = ["play-pause"]
1213bundled-themes = ["material"]
1214"#,
1215 )
1216 .unwrap();
1217
1218 let result = run_pipeline(
1219 &[("test.toml".to_string(), config)],
1220 std::slice::from_ref(&dir),
1221 None,
1222 None,
1223 None,
1224 &[],
1225 );
1226
1227 assert!(result.errors.is_empty());
1228 let report = result
1229 .size_report
1230 .as_ref()
1231 .expect("should have size report");
1232 assert_eq!(report.role_count, 1);
1233 assert_eq!(report.bundled_theme_count, 1);
1234 assert_eq!(report.svg_count, 1);
1235 assert!(report.total_svg_bytes > 0, "SVGs should have nonzero size");
1236
1237 let _ = fs::remove_dir_all(&dir);
1238 }
1239
1240 #[test]
1241 fn pipeline_returns_errors_on_missing_role() {
1242 let dir = create_fixture_dir("missing_role");
1243 write_fixture(
1245 &dir,
1246 "material/mapping.toml",
1247 "play-pause = \"play_pause\"\n",
1248 );
1249 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1250
1251 let config: MasterConfig = toml::from_str(
1252 r#"
1253name = "test"
1254roles = ["play-pause", "skip-forward"]
1255bundled-themes = ["material"]
1256"#,
1257 )
1258 .unwrap();
1259
1260 let result = run_pipeline(
1261 &[("test.toml".to_string(), config)],
1262 std::slice::from_ref(&dir),
1263 None,
1264 None,
1265 None,
1266 &[],
1267 );
1268
1269 assert!(!result.errors.is_empty(), "should have errors");
1270 assert!(
1271 result
1272 .errors
1273 .iter()
1274 .any(|e| e.to_string().contains("skip-forward")),
1275 "should mention missing role"
1276 );
1277 assert!(result.code.is_empty(), "no code on errors");
1278
1279 let _ = fs::remove_dir_all(&dir);
1280 }
1281
1282 #[test]
1283 fn pipeline_returns_errors_on_missing_svg() {
1284 let dir = create_fixture_dir("missing_svg");
1285 write_fixture(
1286 &dir,
1287 "material/mapping.toml",
1288 r#"
1289play-pause = "play_pause"
1290skip-forward = "skip_next"
1291"#,
1292 );
1293 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1295
1296 let config: MasterConfig = toml::from_str(
1297 r#"
1298name = "test"
1299roles = ["play-pause", "skip-forward"]
1300bundled-themes = ["material"]
1301"#,
1302 )
1303 .unwrap();
1304
1305 let result = run_pipeline(
1306 &[("test.toml".to_string(), config)],
1307 std::slice::from_ref(&dir),
1308 None,
1309 None,
1310 None,
1311 &[],
1312 );
1313
1314 assert!(!result.errors.is_empty(), "should have errors");
1315 assert!(
1316 result
1317 .errors
1318 .iter()
1319 .any(|e| e.to_string().contains("skip_next.svg")),
1320 "should mention missing SVG"
1321 );
1322
1323 let _ = fs::remove_dir_all(&dir);
1324 }
1325
1326 #[test]
1327 fn pipeline_orphan_svgs_are_warnings() {
1328 let dir = create_fixture_dir("orphan_warn");
1329 write_fixture(
1330 &dir,
1331 "material/mapping.toml",
1332 "play-pause = \"play_pause\"\n",
1333 );
1334 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1335 write_fixture(&dir, "material/unused.svg", SVG_STUB);
1336
1337 let config: MasterConfig = toml::from_str(
1338 r#"
1339name = "test"
1340roles = ["play-pause"]
1341bundled-themes = ["material"]
1342"#,
1343 )
1344 .unwrap();
1345
1346 let result = run_pipeline(
1347 &[("test.toml".to_string(), config)],
1348 std::slice::from_ref(&dir),
1349 None,
1350 None,
1351 None,
1352 &[],
1353 );
1354
1355 assert!(result.errors.is_empty(), "orphans are not errors");
1356 assert!(!result.warnings.is_empty(), "should have orphan warning");
1357 assert!(result.warnings.iter().any(|w| w.contains("unused.svg")));
1358
1359 let _ = fs::remove_dir_all(&dir);
1360 }
1361
1362 #[test]
1365 fn merge_configs_combines_roles() {
1366 let config_a: MasterConfig = toml::from_str(
1367 r#"
1368name = "a"
1369roles = ["play-pause"]
1370bundled-themes = ["material"]
1371"#,
1372 )
1373 .unwrap();
1374 let config_b: MasterConfig = toml::from_str(
1375 r#"
1376name = "b"
1377roles = ["skip-forward"]
1378bundled-themes = ["material"]
1379system-themes = ["sf-symbols"]
1380"#,
1381 )
1382 .unwrap();
1383
1384 let configs = vec![
1385 ("a.toml".to_string(), config_a),
1386 ("b.toml".to_string(), config_b),
1387 ];
1388 let merged = merge_configs(&configs, None);
1389
1390 assert_eq!(merged.name, "a"); assert_eq!(merged.roles, vec!["play-pause", "skip-forward"]);
1392 assert_eq!(merged.bundled_themes, vec!["material"]); assert_eq!(merged.system_themes, vec!["sf-symbols"]);
1394 }
1395
1396 #[test]
1397 fn merge_configs_uses_enum_name_override() {
1398 let config: MasterConfig = toml::from_str(
1399 r#"
1400name = "original"
1401roles = ["x"]
1402"#,
1403 )
1404 .unwrap();
1405
1406 let configs = vec![("a.toml".to_string(), config)];
1407 let merged = merge_configs(&configs, Some("MyIcons"));
1408
1409 assert_eq!(merged.name, "MyIcons");
1410 }
1411
1412 #[test]
1415 fn pipeline_builder_merges_two_files() {
1416 let dir = create_fixture_dir("builder_merge");
1417 write_fixture(
1418 &dir,
1419 "material/mapping.toml",
1420 r#"
1421play-pause = "play_pause"
1422skip-forward = "skip_next"
1423"#,
1424 );
1425 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1426 write_fixture(&dir, "material/skip_next.svg", SVG_STUB);
1427
1428 let config_a: MasterConfig = toml::from_str(
1429 r#"
1430name = "icons-a"
1431roles = ["play-pause"]
1432bundled-themes = ["material"]
1433"#,
1434 )
1435 .unwrap();
1436 let config_b: MasterConfig = toml::from_str(
1437 r#"
1438name = "icons-b"
1439roles = ["skip-forward"]
1440bundled-themes = ["material"]
1441"#,
1442 )
1443 .unwrap();
1444
1445 let result = run_pipeline(
1446 &[
1447 ("a.toml".to_string(), config_a),
1448 ("b.toml".to_string(), config_b),
1449 ],
1450 &[dir.clone(), dir.clone()],
1451 Some("AllIcons"),
1452 None,
1453 None,
1454 &[],
1455 );
1456
1457 assert!(
1458 result.errors.is_empty(),
1459 "expected no errors: {:?}",
1460 result.errors
1461 );
1462 assert!(
1463 result.code.contains("pub enum AllIcons"),
1464 "should use override name"
1465 );
1466 assert!(result.code.contains("PlayPause"));
1467 assert!(result.code.contains("SkipForward"));
1468 assert_eq!(result.output_filename, "all_icons.rs");
1469
1470 let _ = fs::remove_dir_all(&dir);
1471 }
1472
1473 #[test]
1474 fn pipeline_builder_detects_duplicate_roles() {
1475 let dir = create_fixture_dir("builder_dup");
1476 write_fixture(
1477 &dir,
1478 "material/mapping.toml",
1479 "play-pause = \"play_pause\"\n",
1480 );
1481 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1482
1483 let config_a: MasterConfig = toml::from_str(
1484 r#"
1485name = "a"
1486roles = ["play-pause"]
1487bundled-themes = ["material"]
1488"#,
1489 )
1490 .unwrap();
1491 let config_b: MasterConfig = toml::from_str(
1492 r#"
1493name = "b"
1494roles = ["play-pause"]
1495bundled-themes = ["material"]
1496"#,
1497 )
1498 .unwrap();
1499
1500 let result = run_pipeline(
1501 &[
1502 ("a.toml".to_string(), config_a),
1503 ("b.toml".to_string(), config_b),
1504 ],
1505 &[dir.clone(), dir.clone()],
1506 None,
1507 None,
1508 None,
1509 &[],
1510 );
1511
1512 assert!(!result.errors.is_empty(), "should detect duplicate roles");
1513 assert!(
1514 result
1515 .errors
1516 .iter()
1517 .any(|e| e.to_string().contains("play-pause"))
1518 );
1519
1520 let _ = fs::remove_dir_all(&dir);
1521 }
1522
1523 #[test]
1524 fn pipeline_generates_relative_include_bytes_paths() {
1525 let tmpdir = create_fixture_dir("rel_paths");
1530 write_fixture(
1531 &tmpdir,
1532 "icons/material/mapping.toml",
1533 "play-pause = \"play_pause\"\n",
1534 );
1535 write_fixture(&tmpdir, "icons/material/play_pause.svg", SVG_STUB);
1536
1537 let config: MasterConfig = toml::from_str(
1538 r#"
1539name = "test"
1540roles = ["play-pause"]
1541bundled-themes = ["material"]
1542"#,
1543 )
1544 .unwrap();
1545
1546 let abs_base_dir = tmpdir.join("icons");
1548
1549 let result = run_pipeline(
1550 &[("icons/icons.toml".to_string(), config)],
1551 &[abs_base_dir],
1552 None,
1553 Some(&tmpdir), None,
1555 &[],
1556 );
1557
1558 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
1559 assert!(
1561 result.code.contains("\"/icons/material/play_pause.svg\""),
1562 "include_bytes path should use relative base_dir 'icons'. code:\n{}",
1563 result.code,
1564 );
1565 let tmpdir_str = tmpdir.to_string_lossy();
1567 assert!(
1568 !result.code.contains(&*tmpdir_str),
1569 "include_bytes path should NOT contain absolute tmpdir path",
1570 );
1571
1572 let _ = fs::remove_dir_all(&tmpdir);
1573 }
1574
1575 #[test]
1576 fn pipeline_no_system_svg_check() {
1577 let dir = create_fixture_dir("no_sys_svg");
1579 write_fixture(
1581 &dir,
1582 "sf-symbols/mapping.toml",
1583 r#"
1584play-pause = "play.fill"
1585"#,
1586 );
1587
1588 let config: MasterConfig = toml::from_str(
1589 r#"
1590name = "test"
1591roles = ["play-pause"]
1592system-themes = ["sf-symbols"]
1593"#,
1594 )
1595 .unwrap();
1596
1597 let result = run_pipeline(
1598 &[("test.toml".to_string(), config)],
1599 std::slice::from_ref(&dir),
1600 None,
1601 None,
1602 None,
1603 &[],
1604 );
1605
1606 assert!(
1607 result.errors.is_empty(),
1608 "system themes should not require SVGs: {:?}",
1609 result.errors
1610 );
1611
1612 let _ = fs::remove_dir_all(&dir);
1613 }
1614
1615 #[test]
1618 fn build_errors_display_format() {
1619 let errors = BuildErrors(vec![
1620 BuildError::MissingRole {
1621 role: "play-pause".into(),
1622 mapping_file: "mapping.toml".into(),
1623 },
1624 BuildError::MissingSvg {
1625 path: "play.svg".into(),
1626 },
1627 ]);
1628 let msg = errors.to_string();
1629 assert!(msg.contains("2 build error(s):"));
1630 assert!(msg.contains("play-pause"));
1631 assert!(msg.contains("play.svg"));
1632 }
1633
1634 #[test]
1637 fn build_error_invalid_identifier_format() {
1638 let err = BuildError::InvalidIdentifier {
1639 name: "---".into(),
1640 reason: "PascalCase conversion produces an empty string".into(),
1641 };
1642 let msg = err.to_string();
1643 assert!(msg.contains("---"), "should contain the name");
1644 assert!(msg.contains("empty"), "should contain the reason");
1645 }
1646
1647 #[test]
1648 fn build_error_identifier_collision_format() {
1649 let err = BuildError::IdentifierCollision {
1650 role_a: "play_pause".into(),
1651 role_b: "play-pause".into(),
1652 pascal: "PlayPause".into(),
1653 };
1654 let msg = err.to_string();
1655 assert!(msg.contains("play_pause"), "should mention first role");
1656 assert!(msg.contains("play-pause"), "should mention second role");
1657 assert!(msg.contains("PlayPause"), "should mention PascalCase");
1658 }
1659
1660 #[test]
1661 fn build_error_theme_overlap_format() {
1662 let err = BuildError::ThemeOverlap {
1663 theme: "material".into(),
1664 };
1665 let msg = err.to_string();
1666 assert!(msg.contains("material"), "should mention theme");
1667 assert!(msg.contains("bundled"), "should mention bundled");
1668 assert!(msg.contains("system"), "should mention system");
1669 }
1670
1671 #[test]
1672 fn build_error_duplicate_role_in_file_format() {
1673 let err = BuildError::DuplicateRoleInFile {
1674 role: "play-pause".into(),
1675 file: "icons.toml".into(),
1676 };
1677 let msg = err.to_string();
1678 assert!(msg.contains("play-pause"), "should mention role");
1679 assert!(msg.contains("icons.toml"), "should mention file");
1680 }
1681
1682 #[test]
1685 fn pipeline_detects_theme_overlap() {
1686 let dir = create_fixture_dir("theme_overlap");
1687 write_fixture(
1688 &dir,
1689 "material/mapping.toml",
1690 "play-pause = \"play_pause\"\n",
1691 );
1692 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1693
1694 let config: MasterConfig = toml::from_str(
1695 r#"
1696name = "test"
1697roles = ["play-pause"]
1698bundled-themes = ["material"]
1699system-themes = ["material"]
1700"#,
1701 )
1702 .unwrap();
1703
1704 let result = run_pipeline(
1705 &[("test.toml".to_string(), config)],
1706 std::slice::from_ref(&dir),
1707 None,
1708 None,
1709 None,
1710 &[],
1711 );
1712
1713 assert!(!result.errors.is_empty(), "should detect theme overlap");
1714 assert!(
1715 result.errors.iter().any(|e| matches!(
1716 e,
1717 BuildError::ThemeOverlap { theme } if theme == "material"
1718 )),
1719 "should have ThemeOverlap error for 'material': {:?}",
1720 result.errors
1721 );
1722
1723 let _ = fs::remove_dir_all(&dir);
1724 }
1725
1726 #[test]
1727 fn pipeline_detects_identifier_collision() {
1728 let dir = create_fixture_dir("id_collision");
1729 write_fixture(
1730 &dir,
1731 "material/mapping.toml",
1732 "play_pause = \"pp\"\nplay-pause = \"pp2\"\n",
1733 );
1734 write_fixture(&dir, "material/pp.svg", SVG_STUB);
1735
1736 let config: MasterConfig = toml::from_str(
1737 r#"
1738name = "test"
1739roles = ["play_pause", "play-pause"]
1740bundled-themes = ["material"]
1741"#,
1742 )
1743 .unwrap();
1744
1745 let result = run_pipeline(
1746 &[("test.toml".to_string(), config)],
1747 std::slice::from_ref(&dir),
1748 None,
1749 None,
1750 None,
1751 &[],
1752 );
1753
1754 assert!(
1755 result.errors.iter().any(|e| matches!(
1756 e,
1757 BuildError::IdentifierCollision { pascal, .. } if pascal == "PlayPause"
1758 )),
1759 "should detect PascalCase collision: {:?}",
1760 result.errors
1761 );
1762
1763 let _ = fs::remove_dir_all(&dir);
1764 }
1765
1766 #[test]
1767 fn pipeline_detects_invalid_identifier() {
1768 let dir = create_fixture_dir("id_invalid");
1769 write_fixture(&dir, "material/mapping.toml", "self = \"self_icon\"\n");
1770 write_fixture(&dir, "material/self_icon.svg", SVG_STUB);
1771
1772 let config: MasterConfig = toml::from_str(
1773 r#"
1774name = "test"
1775roles = ["self"]
1776bundled-themes = ["material"]
1777"#,
1778 )
1779 .unwrap();
1780
1781 let result = run_pipeline(
1782 &[("test.toml".to_string(), config)],
1783 std::slice::from_ref(&dir),
1784 None,
1785 None,
1786 None,
1787 &[],
1788 );
1789
1790 assert!(
1791 result.errors.iter().any(|e| matches!(
1792 e,
1793 BuildError::InvalidIdentifier { name, .. } if name == "self"
1794 )),
1795 "should detect keyword identifier: {:?}",
1796 result.errors
1797 );
1798
1799 let _ = fs::remove_dir_all(&dir);
1800 }
1801
1802 #[test]
1803 fn pipeline_detects_duplicate_role_in_file() {
1804 let dir = create_fixture_dir("dup_in_file");
1805 write_fixture(
1806 &dir,
1807 "material/mapping.toml",
1808 "play-pause = \"play_pause\"\n",
1809 );
1810 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1811
1812 let config = MasterConfig {
1815 name: "test".to_string(),
1816 roles: vec!["play-pause".to_string(), "play-pause".to_string()],
1817 bundled_themes: vec!["material".to_string()],
1818 system_themes: Vec::new(),
1819 };
1820
1821 let result = run_pipeline(
1822 &[("test.toml".to_string(), config)],
1823 std::slice::from_ref(&dir),
1824 None,
1825 None,
1826 None,
1827 &[],
1828 );
1829
1830 assert!(
1831 result.errors.iter().any(|e| matches!(
1832 e,
1833 BuildError::DuplicateRoleInFile { role, file }
1834 if role == "play-pause" && file == "test.toml"
1835 )),
1836 "should detect duplicate role in file: {:?}",
1837 result.errors
1838 );
1839
1840 let _ = fs::remove_dir_all(&dir);
1841 }
1842
1843 #[test]
1846 fn pipeline_bundled_de_aware_produces_warning() {
1847 let dir = create_fixture_dir("bundled_de_aware");
1848 write_fixture(
1850 &dir,
1851 "material/mapping.toml",
1852 r#"play-pause = { kde = "media-playback-start", default = "play_pause" }"#,
1853 );
1854 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1855
1856 let config: MasterConfig = toml::from_str(
1857 r#"
1858name = "test-icon"
1859roles = ["play-pause"]
1860bundled-themes = ["material"]
1861"#,
1862 )
1863 .unwrap();
1864
1865 let result = run_pipeline(
1866 &[("test.toml".to_string(), config)],
1867 std::slice::from_ref(&dir),
1868 None,
1869 None,
1870 None,
1871 &[],
1872 );
1873
1874 assert!(
1875 result.errors.is_empty(),
1876 "bundled DE-aware should not be an error: {:?}",
1877 result.errors
1878 );
1879 assert!(
1880 result.warnings.iter().any(|w| {
1881 w.contains("bundled theme \"material\"")
1882 && w.contains("play-pause")
1883 && w.contains("only the default SVG will be embedded")
1884 }),
1885 "should warn about bundled DE-aware mapping. warnings: {:?}",
1886 result.warnings
1887 );
1888
1889 let _ = fs::remove_dir_all(&dir);
1890 }
1891
1892 #[test]
1893 fn pipeline_system_de_aware_no_bundled_warning() {
1894 let dir = create_fixture_dir("system_de_aware");
1895 write_fixture(
1897 &dir,
1898 "freedesktop/mapping.toml",
1899 r#"play-pause = { kde = "media-playback-start", default = "play" }"#,
1900 );
1901
1902 let config: MasterConfig = toml::from_str(
1903 r#"
1904name = "test-icon"
1905roles = ["play-pause"]
1906system-themes = ["freedesktop"]
1907"#,
1908 )
1909 .unwrap();
1910
1911 let result = run_pipeline(
1912 &[("test.toml".to_string(), config)],
1913 std::slice::from_ref(&dir),
1914 None,
1915 None,
1916 None,
1917 &[],
1918 );
1919
1920 assert!(
1921 result.errors.is_empty(),
1922 "system DE-aware should not be an error: {:?}",
1923 result.errors
1924 );
1925 assert!(
1926 !result
1927 .warnings
1928 .iter()
1929 .any(|w| w.contains("only the default SVG will be embedded")),
1930 "system themes should NOT produce bundled DE-aware warning. warnings: {:?}",
1931 result.warnings
1932 );
1933
1934 let _ = fs::remove_dir_all(&dir);
1935 }
1936
1937 #[test]
1940 fn pipeline_custom_crate_path() {
1941 let dir = create_fixture_dir("crate_path");
1942 write_fixture(
1943 &dir,
1944 "material/mapping.toml",
1945 "play-pause = \"play_pause\"\n",
1946 );
1947 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1948
1949 let config: MasterConfig = toml::from_str(
1950 r#"
1951name = "test-icon"
1952roles = ["play-pause"]
1953bundled-themes = ["material"]
1954"#,
1955 )
1956 .unwrap();
1957
1958 let result = run_pipeline(
1959 &[("test.toml".to_string(), config)],
1960 std::slice::from_ref(&dir),
1961 None,
1962 None,
1963 Some("my_crate::native_theme"),
1964 &[],
1965 );
1966
1967 assert!(
1968 result.errors.is_empty(),
1969 "custom crate path should not cause errors: {:?}",
1970 result.errors
1971 );
1972 assert!(
1973 result
1974 .code
1975 .contains("impl my_crate::native_theme::IconProvider"),
1976 "should use custom crate path in impl. code:\n{}",
1977 result.code
1978 );
1979 assert!(
1980 !result.code.contains("extern crate"),
1981 "custom crate path should not emit extern crate. code:\n{}",
1982 result.code
1983 );
1984
1985 let _ = fs::remove_dir_all(&dir);
1986 }
1987
1988 #[test]
1989 fn pipeline_default_crate_path_emits_extern_crate() {
1990 let dir = create_fixture_dir("default_crate_path");
1991 write_fixture(
1992 &dir,
1993 "material/mapping.toml",
1994 "play-pause = \"play_pause\"\n",
1995 );
1996 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1997
1998 let config: MasterConfig = toml::from_str(
1999 r#"
2000name = "test-icon"
2001roles = ["play-pause"]
2002bundled-themes = ["material"]
2003"#,
2004 )
2005 .unwrap();
2006
2007 let result = run_pipeline(
2008 &[("test.toml".to_string(), config)],
2009 std::slice::from_ref(&dir),
2010 None,
2011 None,
2012 None,
2013 &[],
2014 );
2015
2016 assert!(
2017 result.errors.is_empty(),
2018 "default crate path should not cause errors: {:?}",
2019 result.errors
2020 );
2021 assert!(
2022 result.code.contains("extern crate native_theme;"),
2023 "default crate path should emit extern crate. code:\n{}",
2024 result.code
2025 );
2026
2027 let _ = fs::remove_dir_all(&dir);
2028 }
2029}