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