1#![warn(missing_docs)]
122#![forbid(unsafe_code)]
123
124mod codegen;
125mod error;
126mod schema;
127mod validate;
128
129use std::collections::BTreeMap;
130use std::path::{Path, PathBuf};
131
132use heck::ToSnakeCase;
133
134pub use error::{BuildError, BuildErrors};
135use schema::{MasterConfig, ThemeMapping};
136
137#[cfg(test)]
138use schema::{MappingValue, THEME_TABLE};
139
140#[derive(Debug, Clone)]
147#[must_use = "call .emit_cargo_directives() to write the file and emit cargo directives"]
148pub struct GenerateOutput {
149 pub output_path: PathBuf,
151 pub warnings: Vec<String>,
153 pub role_count: usize,
155 pub bundled_theme_count: usize,
157 pub svg_count: usize,
159 pub total_svg_bytes: u64,
161 rerun_paths: Vec<PathBuf>,
163 pub code: String,
165}
166
167impl GenerateOutput {
168 pub fn rerun_paths(&self) -> &[PathBuf] {
170 &self.rerun_paths
171 }
172
173 pub fn emit_cargo_directives(&self) {
181 for path in &self.rerun_paths {
182 println!("cargo::rerun-if-changed={}", path.display());
183 }
184 if let Err(e) = std::fs::write(&self.output_path, &self.code) {
185 println!(
186 "cargo::error=failed to write {}: {e}",
187 self.output_path.display()
188 );
189 std::process::exit(1);
190 }
191 for w in &self.warnings {
192 println!("cargo::warning={w}");
193 }
194 }
195}
196
197pub trait UnwrapOrExit<T> {
213 fn unwrap_or_exit(self) -> T;
215}
216
217impl UnwrapOrExit<GenerateOutput> for Result<GenerateOutput, BuildErrors> {
218 fn unwrap_or_exit(self) -> GenerateOutput {
219 match self {
220 Ok(output) => output,
221 Err(errors) => {
222 errors.emit_cargo_errors();
226 std::process::exit(1);
227 }
228 }
229 }
230}
231
232#[must_use = "this returns the generated output; call .emit_cargo_directives() to complete the build"]
247pub fn generate_icons(toml_path: impl AsRef<Path>) -> Result<GenerateOutput, BuildErrors> {
248 let toml_path = toml_path.as_ref();
249 let manifest_dir = PathBuf::from(
250 std::env::var("CARGO_MANIFEST_DIR")
251 .map_err(|e| BuildErrors::io(format!("CARGO_MANIFEST_DIR not set: {e}")))?,
252 );
253 let out_dir = PathBuf::from(
254 std::env::var("OUT_DIR").map_err(|e| BuildErrors::io(format!("OUT_DIR not set: {e}")))?,
255 );
256 let resolved = manifest_dir.join(toml_path);
257
258 let content = std::fs::read_to_string(&resolved)
259 .map_err(|e| BuildErrors::io(format!("failed to read {}: {e}", resolved.display())))?;
260 let config: MasterConfig = toml::from_str(&content)
261 .map_err(|e| BuildErrors::io(format!("failed to parse {}: {e}", resolved.display())))?;
262
263 let base_dir = resolved
264 .parent()
265 .ok_or_else(|| BuildErrors::io(format!("{} has no parent directory", resolved.display())))?
266 .to_path_buf();
267 let file_path_str = resolved.to_string_lossy().to_string();
268
269 let result = run_pipeline(
270 &[(file_path_str, config)],
271 &[base_dir],
272 None,
273 Some(&manifest_dir),
274 None,
275 &[],
276 );
277
278 pipeline_result_to_output(result, &out_dir)
279}
280
281#[derive(Debug)]
283#[must_use = "a configured builder does nothing until .generate() is called"]
284pub struct IconGenerator {
285 sources: Vec<PathBuf>,
286 enum_name_override: Option<String>,
287 base_dir: Option<PathBuf>,
288 crate_path: Option<String>,
289 extra_derives: Vec<String>,
290 output_dir: Option<PathBuf>,
291}
292
293impl Default for IconGenerator {
294 fn default() -> Self {
295 Self::new()
296 }
297}
298
299impl IconGenerator {
300 pub fn new() -> Self {
302 Self {
303 sources: Vec::new(),
304 enum_name_override: None,
305 base_dir: None,
306 crate_path: None,
307 extra_derives: Vec::new(),
308 output_dir: None,
309 }
310 }
311
312 pub fn source(mut self, path: impl AsRef<Path>) -> Self {
314 self.sources.push(path.as_ref().to_path_buf());
315 self
316 }
317
318 pub fn enum_name(mut self, name: &str) -> Self {
320 self.enum_name_override = Some(name.to_string());
321 self
322 }
323
324 pub fn base_dir(mut self, path: impl AsRef<Path>) -> Self {
333 self.base_dir = Some(path.as_ref().to_path_buf());
334 self
335 }
336
337 pub fn crate_path(mut self, path: &str) -> Self {
345 assert!(
346 !path.is_empty() && !path.contains(' '),
347 "crate_path must be non-empty and contain no spaces: {path:?}"
348 );
349 self.crate_path = Some(path.to_string());
350 self
351 }
352
353 pub fn derive(mut self, name: &str) -> Self {
367 assert!(
368 !name.is_empty() && !name.contains(char::is_whitespace),
369 "derive name must be non-empty and contain no whitespace: {name:?}"
370 );
371 self.extra_derives.push(name.to_string());
372 self
373 }
374
375 pub fn output_dir(mut self, path: impl AsRef<Path>) -> Self {
381 self.output_dir = Some(path.as_ref().to_path_buf());
382 self
383 }
384
385 pub fn generate(self) -> Result<GenerateOutput, BuildErrors> {
401 if self.sources.is_empty() {
402 return Err(BuildErrors::io(
403 "no source files added to IconGenerator (call .source() before .generate())",
404 ));
405 }
406
407 let needs_manifest_dir = self.sources.iter().any(|s| !s.is_absolute())
408 || self.base_dir.as_ref().is_some_and(|b| !b.is_absolute());
409 let manifest_dir = if needs_manifest_dir {
410 Some(PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").map_err(
411 |e| BuildErrors::io(format!("CARGO_MANIFEST_DIR not set: {e}")),
412 )?))
413 } else {
414 std::env::var("CARGO_MANIFEST_DIR").ok().map(PathBuf::from)
415 };
416
417 let out_dir = match self.output_dir {
418 Some(dir) => dir,
419 None => PathBuf::from(
420 std::env::var("OUT_DIR")
421 .map_err(|e| BuildErrors::io(format!("OUT_DIR not set: {e}")))?,
422 ),
423 };
424
425 let mut configs = Vec::new();
426 let mut base_dirs = Vec::new();
427
428 for source in &self.sources {
429 let resolved = if source.is_absolute() {
430 source.clone()
431 } else {
432 manifest_dir
433 .as_ref()
434 .ok_or_else(|| {
435 BuildErrors::io(format!(
436 "CARGO_MANIFEST_DIR required for relative path {}",
437 source.display()
438 ))
439 })?
440 .join(source)
441 };
442 let content = std::fs::read_to_string(&resolved).map_err(|e| {
443 BuildErrors::io(format!("failed to read {}: {e}", resolved.display()))
444 })?;
445 let config: MasterConfig = toml::from_str(&content).map_err(|e| {
446 BuildErrors::io(format!("failed to parse {}: {e}", resolved.display()))
447 })?;
448
449 let file_path_str = resolved.to_string_lossy().to_string();
450
451 if let Some(ref explicit_base) = self.base_dir {
452 let base = if explicit_base.is_absolute() {
453 explicit_base.clone()
454 } else {
455 manifest_dir
456 .as_ref()
457 .ok_or_else(|| {
458 BuildErrors::io(format!(
459 "CARGO_MANIFEST_DIR required for relative base_dir {}",
460 explicit_base.display()
461 ))
462 })?
463 .join(explicit_base)
464 };
465 base_dirs.push(base);
466 } else {
467 let parent = resolved
468 .parent()
469 .ok_or_else(|| {
470 BuildErrors::io(format!("{} has no parent directory", resolved.display()))
471 })?
472 .to_path_buf();
473 base_dirs.push(parent);
474 }
475
476 configs.push((file_path_str, config));
477 }
478
479 if self.base_dir.is_none() && base_dirs.len() > 1 {
481 let first = &base_dirs[0];
482 let divergent = base_dirs.iter().any(|d| d != first);
483 if divergent {
484 return Err(BuildErrors::io(
485 "multiple source files have different parent directories; \
486 use .base_dir() to specify a common base directory for theme resolution",
487 ));
488 }
489 }
490
491 let result = run_pipeline(
492 &configs,
493 &base_dirs,
494 self.enum_name_override.as_deref(),
495 manifest_dir.as_deref(),
496 self.crate_path.as_deref(),
497 &self.extra_derives,
498 );
499
500 pipeline_result_to_output(result, &out_dir)
501 }
502}
503
504struct PipelineResult {
510 pub code: String,
512 pub errors: Vec<BuildError>,
514 pub warnings: Vec<String>,
516 pub rerun_paths: Vec<PathBuf>,
518 pub size_report: Option<SizeReport>,
520 pub output_filename: String,
522}
523
524struct SizeReport {
526 pub role_count: usize,
528 pub bundled_theme_count: usize,
530 pub total_svg_bytes: u64,
532 pub svg_count: usize,
534}
535
536fn run_pipeline(
549 configs: &[(String, MasterConfig)],
550 base_dirs: &[PathBuf],
551 enum_name_override: Option<&str>,
552 manifest_dir: Option<&Path>,
553 crate_path: Option<&str>,
554 extra_derives: &[String],
555) -> PipelineResult {
556 if configs.is_empty() {
557 return PipelineResult {
558 code: String::new(),
559 errors: vec![BuildError::Io {
560 message: "no icon configs provided".into(),
561 }],
562 warnings: Vec::new(),
563 rerun_paths: Vec::new(),
564 size_report: None,
565 output_filename: String::new(),
566 };
567 }
568
569 assert_eq!(configs.len(), base_dirs.len());
570
571 let mut errors: Vec<BuildError> = Vec::new();
572 let mut warnings: Vec<String> = Vec::new();
573 let mut rerun_paths: Vec<PathBuf> = Vec::new();
574 let mut all_mappings: BTreeMap<String, ThemeMapping> = BTreeMap::new();
575 let mut svg_paths: Vec<PathBuf> = Vec::new();
576
577 let first_name = enum_name_override
579 .map(|s| s.to_string())
580 .unwrap_or_else(|| configs[0].1.name.clone());
581 let output_filename = format!("{}.rs", first_name.to_snake_case());
582
583 for (file_path, config) in configs {
585 if config.roles.is_empty() {
587 warnings.push(format!(
588 "{file_path}: roles list is empty; generated enum will have no variants"
589 ));
590 }
591
592 let dup_in_file_errors = validate::validate_no_duplicate_roles_in_file(config, file_path);
594 errors.extend(dup_in_file_errors);
595
596 let overlap_errors = validate::validate_theme_overlap(config);
598 errors.extend(overlap_errors);
599
600 let dup_theme_errors = validate::validate_no_duplicate_themes(config);
602 errors.extend(dup_theme_errors);
603 }
604
605 if configs.len() > 1 {
607 let dup_errors = validate::validate_no_duplicate_roles(configs);
608 errors.extend(dup_errors);
609 }
610
611 let merged = merge_configs(configs, enum_name_override);
613
614 if merged.bundled_themes.is_empty() && merged.system_themes.is_empty() {
616 warnings.push(
617 "no bundled-themes or system-themes configured; \
618 generated IconProvider will always return None"
619 .to_string(),
620 );
621 }
622
623 let id_errors = validate::validate_identifiers(&merged);
625 errors.extend(id_errors);
626
627 for (file_path, _config) in configs {
629 rerun_paths.push(PathBuf::from(file_path));
630 }
631
632 let theme_errors = validate::validate_themes(&merged);
634 errors.extend(theme_errors);
635
636 let base_dir = &base_dirs[0];
639
640 for theme_name in &merged.bundled_themes {
642 let theme_dir = base_dir.join(theme_name);
643 let mapping_path = theme_dir.join("mapping.toml");
644 let mapping_path_str = mapping_path.to_string_lossy().to_string();
645
646 rerun_paths.push(mapping_path.clone());
648 rerun_paths.push(theme_dir.clone());
649
650 match std::fs::read_to_string(&mapping_path) {
651 Ok(content) => match toml::from_str::<ThemeMapping>(&content) {
652 Ok(mapping) => {
653 let map_errors =
655 validate::validate_mapping(&merged.roles, &mapping, &mapping_path_str);
656 errors.extend(map_errors);
657
658 let name_errors =
660 validate::validate_mapping_values(&mapping, &mapping_path_str);
661 errors.extend(name_errors);
662
663 let svg_errors = validate::validate_svgs(&mapping, &theme_dir, &merged.roles);
665 errors.extend(svg_errors);
666
667 let de_warnings = validate::validate_de_keys(&mapping, &mapping_path_str);
669 warnings.extend(de_warnings);
670
671 for (role_name, value) in &mapping {
674 if matches!(value, schema::MappingValue::DeAware(_)) {
675 warnings.push(format!(
676 "bundled theme \"{}\" has DE-aware mapping for \"{}\": \
677 only the default SVG will be embedded",
678 theme_name, role_name
679 ));
680 }
681 }
682
683 let orphan_warnings = check_orphan_svgs_and_collect_paths(
685 &mapping,
686 &theme_dir,
687 theme_name,
688 &mut svg_paths,
689 &mut rerun_paths,
690 );
691 warnings.extend(orphan_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 for theme_name in &merged.system_themes {
711 let theme_dir = base_dir.join(theme_name);
712 let mapping_path = theme_dir.join("mapping.toml");
713 let mapping_path_str = mapping_path.to_string_lossy().to_string();
714
715 rerun_paths.push(mapping_path.clone());
717
718 match std::fs::read_to_string(&mapping_path) {
719 Ok(content) => match toml::from_str::<ThemeMapping>(&content) {
720 Ok(mapping) => {
721 let map_errors =
722 validate::validate_mapping(&merged.roles, &mapping, &mapping_path_str);
723 errors.extend(map_errors);
724
725 let name_errors =
727 validate::validate_mapping_values(&mapping, &mapping_path_str);
728 errors.extend(name_errors);
729
730 let de_warnings = validate::validate_de_keys(&mapping, &mapping_path_str);
732 warnings.extend(de_warnings);
733
734 all_mappings.insert(theme_name.clone(), mapping);
735 }
736 Err(e) => {
737 errors.push(BuildError::Io {
738 message: format!("failed to parse {mapping_path_str}: {e}"),
739 });
740 }
741 },
742 Err(e) => {
743 errors.push(BuildError::Io {
744 message: format!("failed to read {mapping_path_str}: {e}"),
745 });
746 }
747 }
748 }
749
750 if !errors.is_empty() {
752 return PipelineResult {
753 code: String::new(),
754 errors,
755 warnings,
756 rerun_paths,
757 size_report: None,
758 output_filename,
759 };
760 }
761
762 let base_dir_str = if let Some(mdir) = manifest_dir {
768 base_dir
769 .strip_prefix(mdir)
770 .unwrap_or(base_dir)
771 .to_string_lossy()
772 .replace('\\', "/")
773 } else {
774 base_dir.to_string_lossy().replace('\\', "/")
775 };
776
777 let effective_crate_path = crate_path.unwrap_or("native_theme");
779 let code = codegen::generate_code(
780 &merged,
781 &all_mappings,
782 &base_dir_str,
783 effective_crate_path,
784 extra_derives,
785 );
786
787 let total_svg_bytes: u64 = svg_paths
789 .iter()
790 .filter_map(|p| std::fs::metadata(p).ok())
791 .map(|m| m.len())
792 .sum();
793
794 let size_report = Some(SizeReport {
795 role_count: merged.roles.len(),
796 bundled_theme_count: merged.bundled_themes.len(),
797 total_svg_bytes,
798 svg_count: svg_paths.len(),
799 });
800
801 PipelineResult {
802 code,
803 errors,
804 warnings,
805 rerun_paths,
806 size_report,
807 output_filename,
808 }
809}
810
811fn check_orphan_svgs_and_collect_paths(
813 mapping: &ThemeMapping,
814 theme_dir: &Path,
815 theme_name: &str,
816 svg_paths: &mut Vec<PathBuf>,
817 rerun_paths: &mut Vec<PathBuf>,
818) -> Vec<String> {
819 for value in mapping.values() {
821 if let Some(name) = value.default_name() {
822 let svg_path = theme_dir.join(format!("{name}.svg"));
823 if svg_path.exists() {
824 rerun_paths.push(svg_path.clone());
825 svg_paths.push(svg_path);
826 }
827 }
828 }
829
830 validate::check_orphan_svgs(mapping, theme_dir, theme_name)
831}
832
833fn merge_configs(
835 configs: &[(String, MasterConfig)],
836 enum_name_override: Option<&str>,
837) -> MasterConfig {
838 let name = enum_name_override
839 .map(|s| s.to_string())
840 .unwrap_or_else(|| configs[0].1.name.clone());
841
842 let mut roles = Vec::new();
843 let mut bundled_themes = Vec::new();
844 let mut system_themes = Vec::new();
845 let mut seen_roles = std::collections::BTreeSet::new();
846 let mut seen_bundled = std::collections::BTreeSet::new();
847 let mut seen_system = std::collections::BTreeSet::new();
848
849 for (_path, config) in configs {
850 for role in &config.roles {
851 if seen_roles.insert(role.clone()) {
852 roles.push(role.clone());
853 }
854 }
855
856 for t in &config.bundled_themes {
857 if seen_bundled.insert(t.clone()) {
858 bundled_themes.push(t.clone());
859 }
860 }
861 for t in &config.system_themes {
862 if seen_system.insert(t.clone()) {
863 system_themes.push(t.clone());
864 }
865 }
866 }
867
868 MasterConfig {
869 name,
870 roles,
871 bundled_themes,
872 system_themes,
873 }
874}
875
876fn pipeline_result_to_output(
878 result: PipelineResult,
879 out_dir: &Path,
880) -> Result<GenerateOutput, BuildErrors> {
881 if !result.errors.is_empty() {
882 for path in &result.rerun_paths {
885 println!("cargo::rerun-if-changed={}", path.display());
886 }
887 return Err(BuildErrors::new(result.errors));
888 }
889
890 let output_path = out_dir.join(&result.output_filename);
891
892 let (role_count, bundled_theme_count, svg_count, total_svg_bytes) = match &result.size_report {
893 Some(report) => (
894 report.role_count,
895 report.bundled_theme_count,
896 report.svg_count,
897 report.total_svg_bytes,
898 ),
899 None => (0, 0, 0, 0),
900 };
901
902 Ok(GenerateOutput {
903 output_path,
904 warnings: result.warnings,
905 role_count,
906 bundled_theme_count,
907 svg_count,
908 total_svg_bytes,
909 rerun_paths: result.rerun_paths,
910 code: result.code,
911 })
912}
913
914#[cfg(test)]
915mod tests {
916 use super::*;
917 use std::collections::BTreeMap;
918 use std::fs;
919
920 #[test]
923 fn master_config_deserializes_full() {
924 let toml_str = r#"
925name = "app-icon"
926roles = ["play-pause", "skip-forward"]
927bundled-themes = ["material"]
928system-themes = ["sf-symbols"]
929"#;
930 let config: MasterConfig = toml::from_str(toml_str).unwrap();
931 assert_eq!(config.name, "app-icon");
932 assert_eq!(config.roles, vec!["play-pause", "skip-forward"]);
933 assert_eq!(config.bundled_themes, vec!["material"]);
934 assert_eq!(config.system_themes, vec!["sf-symbols"]);
935 }
936
937 #[test]
938 fn master_config_empty_optional_fields() {
939 let toml_str = r#"
940name = "x"
941roles = ["a"]
942"#;
943 let config: MasterConfig = toml::from_str(toml_str).unwrap();
944 assert_eq!(config.name, "x");
945 assert_eq!(config.roles, vec!["a"]);
946 assert!(config.bundled_themes.is_empty());
947 assert!(config.system_themes.is_empty());
948 }
949
950 #[test]
951 fn master_config_rejects_unknown_fields() {
952 let toml_str = r#"
953name = "x"
954roles = ["a"]
955bogus = "nope"
956"#;
957 let result = toml::from_str::<MasterConfig>(toml_str);
958 assert!(result.is_err());
959 }
960
961 #[test]
964 fn mapping_value_simple() {
965 let toml_str = r#"play-pause = "play_pause""#;
966 let mapping: BTreeMap<String, MappingValue> = toml::from_str(toml_str).unwrap();
967 match &mapping["play-pause"] {
968 MappingValue::Simple(s) => assert_eq!(s, "play_pause"),
969 _ => panic!("expected Simple variant"),
970 }
971 }
972
973 #[test]
974 fn mapping_value_de_aware() {
975 let toml_str = r#"play-pause = { kde = "media-playback-start", default = "play" }"#;
976 let mapping: BTreeMap<String, MappingValue> = toml::from_str(toml_str).unwrap();
977 match &mapping["play-pause"] {
978 MappingValue::DeAware(m) => {
979 assert_eq!(m["kde"], "media-playback-start");
980 assert_eq!(m["default"], "play");
981 }
982 _ => panic!("expected DeAware variant"),
983 }
984 }
985
986 #[test]
987 fn theme_mapping_mixed_values() {
988 let toml_str = r#"
989play-pause = "play_pause"
990bluetooth = { kde = "preferences-system-bluetooth", default = "bluetooth" }
991skip-forward = "skip_next"
992"#;
993 let mapping: ThemeMapping = toml::from_str(toml_str).unwrap();
994 assert_eq!(mapping.len(), 3);
995 assert!(matches!(&mapping["play-pause"], MappingValue::Simple(_)));
996 assert!(matches!(&mapping["bluetooth"], MappingValue::DeAware(_)));
997 assert!(matches!(&mapping["skip-forward"], MappingValue::Simple(_)));
998 }
999
1000 #[test]
1003 fn mapping_value_default_name_simple() {
1004 let val = MappingValue::Simple("play_pause".to_string());
1005 assert_eq!(val.default_name(), Some("play_pause"));
1006 }
1007
1008 #[test]
1009 fn mapping_value_default_name_de_aware() {
1010 let mut m = BTreeMap::new();
1011 m.insert("kde".to_string(), "media-playback-start".to_string());
1012 m.insert("default".to_string(), "play".to_string());
1013 let val = MappingValue::DeAware(m);
1014 assert_eq!(val.default_name(), Some("play"));
1015 }
1016
1017 #[test]
1018 fn mapping_value_default_name_de_aware_missing_default() {
1019 let mut m = BTreeMap::new();
1020 m.insert("kde".to_string(), "media-playback-start".to_string());
1021 let val = MappingValue::DeAware(m);
1022 assert_eq!(val.default_name(), None);
1023 }
1024
1025 #[test]
1028 fn build_error_missing_role_format() {
1029 let err = BuildError::MissingRole {
1030 role: "play-pause".into(),
1031 mapping_file: "icons/material/mapping.toml".into(),
1032 };
1033 let msg = err.to_string();
1034 assert!(msg.contains("play-pause"), "should contain role name");
1035 assert!(
1036 msg.contains("icons/material/mapping.toml"),
1037 "should contain file path"
1038 );
1039 }
1040
1041 #[test]
1042 fn build_error_missing_svg_format() {
1043 let err = BuildError::MissingSvg {
1044 path: "icons/material/play.svg".into(),
1045 };
1046 let msg = err.to_string();
1047 assert!(
1048 msg.contains("icons/material/play.svg"),
1049 "should contain SVG path"
1050 );
1051 }
1052
1053 #[test]
1054 fn build_error_unknown_role_format() {
1055 let err = BuildError::UnknownRole {
1056 role: "bogus".into(),
1057 mapping_file: "icons/material/mapping.toml".into(),
1058 };
1059 let msg = err.to_string();
1060 assert!(msg.contains("bogus"), "should contain role name");
1061 assert!(
1062 msg.contains("icons/material/mapping.toml"),
1063 "should contain file path"
1064 );
1065 }
1066
1067 #[test]
1068 fn build_error_unknown_theme_format() {
1069 let err = BuildError::UnknownTheme {
1070 theme: "nonexistent".into(),
1071 };
1072 let msg = err.to_string();
1073 assert!(msg.contains("nonexistent"), "should contain theme name");
1074 }
1075
1076 #[test]
1077 fn build_error_missing_default_format() {
1078 let err = BuildError::MissingDefault {
1079 role: "bluetooth".into(),
1080 mapping_file: "icons/freedesktop/mapping.toml".into(),
1081 };
1082 let msg = err.to_string();
1083 assert!(msg.contains("bluetooth"), "should contain role name");
1084 assert!(
1085 msg.contains("icons/freedesktop/mapping.toml"),
1086 "should contain file path"
1087 );
1088 }
1089
1090 #[test]
1091 fn build_error_duplicate_role_format() {
1092 let err = BuildError::DuplicateRole {
1093 role: "play-pause".into(),
1094 file_a: "icons/a.toml".into(),
1095 file_b: "icons/b.toml".into(),
1096 };
1097 let msg = err.to_string();
1098 assert!(msg.contains("play-pause"), "should contain role name");
1099 assert!(
1100 msg.contains("icons/a.toml"),
1101 "should contain first file path"
1102 );
1103 assert!(
1104 msg.contains("icons/b.toml"),
1105 "should contain second file path"
1106 );
1107 }
1108
1109 #[test]
1112 fn theme_table_has_all_five() {
1113 assert_eq!(THEME_TABLE.len(), 5);
1114 let names: Vec<&str> = THEME_TABLE.iter().map(|(k, _)| *k).collect();
1115 assert!(names.contains(&"sf-symbols"));
1116 assert!(names.contains(&"segoe-fluent"));
1117 assert!(names.contains(&"freedesktop"));
1118 assert!(names.contains(&"material"));
1119 assert!(names.contains(&"lucide"));
1120 }
1121
1122 fn create_fixture_dir(suffix: &str) -> PathBuf {
1125 let dir = std::env::temp_dir().join(format!("native_theme_test_pipeline_{suffix}"));
1126 let _ = fs::remove_dir_all(&dir);
1127 fs::create_dir_all(&dir).unwrap();
1128 dir
1129 }
1130
1131 fn write_fixture(dir: &Path, path: &str, content: &str) {
1132 let full_path = dir.join(path);
1133 if let Some(parent) = full_path.parent() {
1134 fs::create_dir_all(parent).unwrap();
1135 }
1136 fs::write(full_path, content).unwrap();
1137 }
1138
1139 const SVG_STUB: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"></svg>"#;
1140
1141 #[test]
1144 fn pipeline_happy_path_generates_code() {
1145 let dir = create_fixture_dir("happy");
1146 write_fixture(
1147 &dir,
1148 "material/mapping.toml",
1149 r#"
1150play-pause = "play_pause"
1151skip-forward = "skip_next"
1152"#,
1153 );
1154 write_fixture(
1155 &dir,
1156 "sf-symbols/mapping.toml",
1157 r#"
1158play-pause = "play.fill"
1159skip-forward = "forward.fill"
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: MasterConfig = toml::from_str(
1166 r#"
1167name = "sample-icon"
1168roles = ["play-pause", "skip-forward"]
1169bundled-themes = ["material"]
1170system-themes = ["sf-symbols"]
1171"#,
1172 )
1173 .unwrap();
1174
1175 let result = run_pipeline(
1176 &[("sample-icons.toml".to_string(), config)],
1177 std::slice::from_ref(&dir),
1178 None,
1179 None,
1180 None,
1181 &[],
1182 );
1183
1184 assert!(
1185 result.errors.is_empty(),
1186 "expected no errors: {:?}",
1187 result.errors
1188 );
1189 assert!(!result.code.is_empty(), "expected generated code");
1190 assert!(result.code.contains("pub enum SampleIcon"));
1191 assert!(result.code.contains("PlayPause"));
1192 assert!(result.code.contains("SkipForward"));
1193
1194 let _ = fs::remove_dir_all(&dir);
1195 }
1196
1197 #[test]
1198 fn pipeline_output_filename_uses_snake_case() {
1199 let dir = create_fixture_dir("filename");
1200 write_fixture(
1201 &dir,
1202 "material/mapping.toml",
1203 "play-pause = \"play_pause\"\n",
1204 );
1205 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1206
1207 let config: MasterConfig = toml::from_str(
1208 r#"
1209name = "app-icon"
1210roles = ["play-pause"]
1211bundled-themes = ["material"]
1212"#,
1213 )
1214 .unwrap();
1215
1216 let result = run_pipeline(
1217 &[("app.toml".to_string(), config)],
1218 std::slice::from_ref(&dir),
1219 None,
1220 None,
1221 None,
1222 &[],
1223 );
1224
1225 assert_eq!(result.output_filename, "app_icon.rs");
1226
1227 let _ = fs::remove_dir_all(&dir);
1228 }
1229
1230 #[test]
1231 fn pipeline_collects_rerun_paths() {
1232 let dir = create_fixture_dir("rerun");
1233 write_fixture(
1234 &dir,
1235 "material/mapping.toml",
1236 r#"
1237play-pause = "play_pause"
1238"#,
1239 );
1240 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1241
1242 let config: MasterConfig = toml::from_str(
1243 r#"
1244name = "test"
1245roles = ["play-pause"]
1246bundled-themes = ["material"]
1247"#,
1248 )
1249 .unwrap();
1250
1251 let result = run_pipeline(
1252 &[("test.toml".to_string(), config)],
1253 std::slice::from_ref(&dir),
1254 None,
1255 None,
1256 None,
1257 &[],
1258 );
1259
1260 assert!(result.errors.is_empty());
1261 let path_strs: Vec<String> = result
1263 .rerun_paths
1264 .iter()
1265 .map(|p| p.to_string_lossy().to_string())
1266 .collect();
1267 assert!(
1268 path_strs.iter().any(|p| p.contains("test.toml")),
1269 "should track master TOML"
1270 );
1271 assert!(
1272 path_strs.iter().any(|p| p.contains("mapping.toml")),
1273 "should track mapping TOML"
1274 );
1275 assert!(
1276 path_strs.iter().any(|p| p.contains("play_pause.svg")),
1277 "should track SVG files"
1278 );
1279
1280 let _ = fs::remove_dir_all(&dir);
1281 }
1282
1283 #[test]
1284 fn pipeline_emits_size_report() {
1285 let dir = create_fixture_dir("size");
1286 write_fixture(
1287 &dir,
1288 "material/mapping.toml",
1289 "play-pause = \"play_pause\"\n",
1290 );
1291 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1292
1293 let config: MasterConfig = toml::from_str(
1294 r#"
1295name = "test"
1296roles = ["play-pause"]
1297bundled-themes = ["material"]
1298"#,
1299 )
1300 .unwrap();
1301
1302 let result = run_pipeline(
1303 &[("test.toml".to_string(), config)],
1304 std::slice::from_ref(&dir),
1305 None,
1306 None,
1307 None,
1308 &[],
1309 );
1310
1311 assert!(result.errors.is_empty());
1312 let report = result
1313 .size_report
1314 .as_ref()
1315 .expect("should have size report");
1316 assert_eq!(report.role_count, 1);
1317 assert_eq!(report.bundled_theme_count, 1);
1318 assert_eq!(report.svg_count, 1);
1319 assert!(report.total_svg_bytes > 0, "SVGs should have nonzero size");
1320
1321 let _ = fs::remove_dir_all(&dir);
1322 }
1323
1324 #[test]
1325 fn pipeline_returns_errors_on_missing_role() {
1326 let dir = create_fixture_dir("missing_role");
1327 write_fixture(
1329 &dir,
1330 "material/mapping.toml",
1331 "play-pause = \"play_pause\"\n",
1332 );
1333 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1334
1335 let config: MasterConfig = toml::from_str(
1336 r#"
1337name = "test"
1338roles = ["play-pause", "skip-forward"]
1339bundled-themes = ["material"]
1340"#,
1341 )
1342 .unwrap();
1343
1344 let result = run_pipeline(
1345 &[("test.toml".to_string(), config)],
1346 std::slice::from_ref(&dir),
1347 None,
1348 None,
1349 None,
1350 &[],
1351 );
1352
1353 assert!(!result.errors.is_empty(), "should have errors");
1354 assert!(
1355 result
1356 .errors
1357 .iter()
1358 .any(|e| e.to_string().contains("skip-forward")),
1359 "should mention missing role"
1360 );
1361 assert!(result.code.is_empty(), "no code on errors");
1362
1363 let _ = fs::remove_dir_all(&dir);
1364 }
1365
1366 #[test]
1367 fn pipeline_returns_errors_on_missing_svg() {
1368 let dir = create_fixture_dir("missing_svg");
1369 write_fixture(
1370 &dir,
1371 "material/mapping.toml",
1372 r#"
1373play-pause = "play_pause"
1374skip-forward = "skip_next"
1375"#,
1376 );
1377 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1379
1380 let config: MasterConfig = toml::from_str(
1381 r#"
1382name = "test"
1383roles = ["play-pause", "skip-forward"]
1384bundled-themes = ["material"]
1385"#,
1386 )
1387 .unwrap();
1388
1389 let result = run_pipeline(
1390 &[("test.toml".to_string(), config)],
1391 std::slice::from_ref(&dir),
1392 None,
1393 None,
1394 None,
1395 &[],
1396 );
1397
1398 assert!(!result.errors.is_empty(), "should have errors");
1399 assert!(
1400 result
1401 .errors
1402 .iter()
1403 .any(|e| e.to_string().contains("skip_next.svg")),
1404 "should mention missing SVG"
1405 );
1406
1407 let _ = fs::remove_dir_all(&dir);
1408 }
1409
1410 #[test]
1411 fn pipeline_orphan_svgs_are_warnings() {
1412 let dir = create_fixture_dir("orphan_warn");
1413 write_fixture(
1414 &dir,
1415 "material/mapping.toml",
1416 "play-pause = \"play_pause\"\n",
1417 );
1418 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1419 write_fixture(&dir, "material/unused.svg", SVG_STUB);
1420
1421 let config: MasterConfig = toml::from_str(
1422 r#"
1423name = "test"
1424roles = ["play-pause"]
1425bundled-themes = ["material"]
1426"#,
1427 )
1428 .unwrap();
1429
1430 let result = run_pipeline(
1431 &[("test.toml".to_string(), config)],
1432 std::slice::from_ref(&dir),
1433 None,
1434 None,
1435 None,
1436 &[],
1437 );
1438
1439 assert!(result.errors.is_empty(), "orphans are not errors");
1440 assert!(!result.warnings.is_empty(), "should have orphan warning");
1441 assert!(result.warnings.iter().any(|w| w.contains("unused.svg")));
1442
1443 let _ = fs::remove_dir_all(&dir);
1444 }
1445
1446 #[test]
1449 fn merge_configs_combines_roles() {
1450 let config_a: MasterConfig = toml::from_str(
1451 r#"
1452name = "a"
1453roles = ["play-pause"]
1454bundled-themes = ["material"]
1455"#,
1456 )
1457 .unwrap();
1458 let config_b: MasterConfig = toml::from_str(
1459 r#"
1460name = "b"
1461roles = ["skip-forward"]
1462bundled-themes = ["material"]
1463system-themes = ["sf-symbols"]
1464"#,
1465 )
1466 .unwrap();
1467
1468 let configs = vec![
1469 ("a.toml".to_string(), config_a),
1470 ("b.toml".to_string(), config_b),
1471 ];
1472 let merged = merge_configs(&configs, None);
1473
1474 assert_eq!(merged.name, "a"); assert_eq!(merged.roles, vec!["play-pause", "skip-forward"]);
1476 assert_eq!(merged.bundled_themes, vec!["material"]); assert_eq!(merged.system_themes, vec!["sf-symbols"]);
1478 }
1479
1480 #[test]
1481 fn merge_configs_uses_enum_name_override() {
1482 let config: MasterConfig = toml::from_str(
1483 r#"
1484name = "original"
1485roles = ["x"]
1486"#,
1487 )
1488 .unwrap();
1489
1490 let configs = vec![("a.toml".to_string(), config)];
1491 let merged = merge_configs(&configs, Some("MyIcons"));
1492
1493 assert_eq!(merged.name, "MyIcons");
1494 }
1495
1496 #[test]
1499 fn pipeline_builder_merges_two_files() {
1500 let dir = create_fixture_dir("builder_merge");
1501 write_fixture(
1502 &dir,
1503 "material/mapping.toml",
1504 r#"
1505play-pause = "play_pause"
1506skip-forward = "skip_next"
1507"#,
1508 );
1509 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1510 write_fixture(&dir, "material/skip_next.svg", SVG_STUB);
1511
1512 let config_a: MasterConfig = toml::from_str(
1513 r#"
1514name = "icons-a"
1515roles = ["play-pause"]
1516bundled-themes = ["material"]
1517"#,
1518 )
1519 .unwrap();
1520 let config_b: MasterConfig = toml::from_str(
1521 r#"
1522name = "icons-b"
1523roles = ["skip-forward"]
1524bundled-themes = ["material"]
1525"#,
1526 )
1527 .unwrap();
1528
1529 let result = run_pipeline(
1530 &[
1531 ("a.toml".to_string(), config_a),
1532 ("b.toml".to_string(), config_b),
1533 ],
1534 &[dir.clone(), dir.clone()],
1535 Some("AllIcons"),
1536 None,
1537 None,
1538 &[],
1539 );
1540
1541 assert!(
1542 result.errors.is_empty(),
1543 "expected no errors: {:?}",
1544 result.errors
1545 );
1546 assert!(
1547 result.code.contains("pub enum AllIcons"),
1548 "should use override name"
1549 );
1550 assert!(result.code.contains("PlayPause"));
1551 assert!(result.code.contains("SkipForward"));
1552 assert_eq!(result.output_filename, "all_icons.rs");
1553
1554 let _ = fs::remove_dir_all(&dir);
1555 }
1556
1557 #[test]
1558 fn pipeline_builder_detects_duplicate_roles() {
1559 let dir = create_fixture_dir("builder_dup");
1560 write_fixture(
1561 &dir,
1562 "material/mapping.toml",
1563 "play-pause = \"play_pause\"\n",
1564 );
1565 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1566
1567 let config_a: MasterConfig = toml::from_str(
1568 r#"
1569name = "a"
1570roles = ["play-pause"]
1571bundled-themes = ["material"]
1572"#,
1573 )
1574 .unwrap();
1575 let config_b: MasterConfig = toml::from_str(
1576 r#"
1577name = "b"
1578roles = ["play-pause"]
1579bundled-themes = ["material"]
1580"#,
1581 )
1582 .unwrap();
1583
1584 let result = run_pipeline(
1585 &[
1586 ("a.toml".to_string(), config_a),
1587 ("b.toml".to_string(), config_b),
1588 ],
1589 &[dir.clone(), dir.clone()],
1590 None,
1591 None,
1592 None,
1593 &[],
1594 );
1595
1596 assert!(!result.errors.is_empty(), "should detect duplicate roles");
1597 assert!(
1598 result
1599 .errors
1600 .iter()
1601 .any(|e| e.to_string().contains("play-pause"))
1602 );
1603
1604 let _ = fs::remove_dir_all(&dir);
1605 }
1606
1607 #[test]
1608 fn pipeline_generates_relative_include_bytes_paths() {
1609 let tmpdir = create_fixture_dir("rel_paths");
1614 write_fixture(
1615 &tmpdir,
1616 "icons/material/mapping.toml",
1617 "play-pause = \"play_pause\"\n",
1618 );
1619 write_fixture(&tmpdir, "icons/material/play_pause.svg", SVG_STUB);
1620
1621 let config: MasterConfig = toml::from_str(
1622 r#"
1623name = "test"
1624roles = ["play-pause"]
1625bundled-themes = ["material"]
1626"#,
1627 )
1628 .unwrap();
1629
1630 let abs_base_dir = tmpdir.join("icons");
1632
1633 let result = run_pipeline(
1634 &[("icons/icons.toml".to_string(), config)],
1635 &[abs_base_dir],
1636 None,
1637 Some(&tmpdir), None,
1639 &[],
1640 );
1641
1642 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
1643 assert!(
1645 result.code.contains("\"/icons/material/play_pause.svg\""),
1646 "include_bytes path should use relative base_dir 'icons'. code:\n{}",
1647 result.code,
1648 );
1649 let tmpdir_str = tmpdir.to_string_lossy();
1651 assert!(
1652 !result.code.contains(&*tmpdir_str),
1653 "include_bytes path should NOT contain absolute tmpdir path",
1654 );
1655
1656 let _ = fs::remove_dir_all(&tmpdir);
1657 }
1658
1659 #[test]
1660 fn pipeline_no_system_svg_check() {
1661 let dir = create_fixture_dir("no_sys_svg");
1663 write_fixture(
1665 &dir,
1666 "sf-symbols/mapping.toml",
1667 r#"
1668play-pause = "play.fill"
1669"#,
1670 );
1671
1672 let config: MasterConfig = toml::from_str(
1673 r#"
1674name = "test"
1675roles = ["play-pause"]
1676system-themes = ["sf-symbols"]
1677"#,
1678 )
1679 .unwrap();
1680
1681 let result = run_pipeline(
1682 &[("test.toml".to_string(), config)],
1683 std::slice::from_ref(&dir),
1684 None,
1685 None,
1686 None,
1687 &[],
1688 );
1689
1690 assert!(
1691 result.errors.is_empty(),
1692 "system themes should not require SVGs: {:?}",
1693 result.errors
1694 );
1695
1696 let _ = fs::remove_dir_all(&dir);
1697 }
1698
1699 #[test]
1702 fn build_errors_display_format() {
1703 let errors = BuildErrors::new(vec![
1704 BuildError::MissingRole {
1705 role: "play-pause".into(),
1706 mapping_file: "mapping.toml".into(),
1707 },
1708 BuildError::MissingSvg {
1709 path: "play.svg".into(),
1710 },
1711 ]);
1712 let msg = errors.to_string();
1713 assert!(msg.contains("2 build error(s):"));
1714 assert!(msg.contains("play-pause"));
1715 assert!(msg.contains("play.svg"));
1716 }
1717
1718 #[test]
1721 fn build_error_invalid_identifier_format() {
1722 let err = BuildError::InvalidIdentifier {
1723 name: "---".into(),
1724 reason: "PascalCase conversion produces an empty string".into(),
1725 };
1726 let msg = err.to_string();
1727 assert!(msg.contains("---"), "should contain the name");
1728 assert!(msg.contains("empty"), "should contain the reason");
1729 }
1730
1731 #[test]
1732 fn build_error_identifier_collision_format() {
1733 let err = BuildError::IdentifierCollision {
1734 role_a: "play_pause".into(),
1735 role_b: "play-pause".into(),
1736 pascal: "PlayPause".into(),
1737 };
1738 let msg = err.to_string();
1739 assert!(msg.contains("play_pause"), "should mention first role");
1740 assert!(msg.contains("play-pause"), "should mention second role");
1741 assert!(msg.contains("PlayPause"), "should mention PascalCase");
1742 }
1743
1744 #[test]
1745 fn build_error_theme_overlap_format() {
1746 let err = BuildError::ThemeOverlap {
1747 theme: "material".into(),
1748 };
1749 let msg = err.to_string();
1750 assert!(msg.contains("material"), "should mention theme");
1751 assert!(msg.contains("bundled"), "should mention bundled");
1752 assert!(msg.contains("system"), "should mention system");
1753 }
1754
1755 #[test]
1756 fn build_error_duplicate_role_in_file_format() {
1757 let err = BuildError::DuplicateRoleInFile {
1758 role: "play-pause".into(),
1759 file: "icons.toml".into(),
1760 };
1761 let msg = err.to_string();
1762 assert!(msg.contains("play-pause"), "should mention role");
1763 assert!(msg.contains("icons.toml"), "should mention file");
1764 }
1765
1766 #[test]
1769 fn pipeline_detects_theme_overlap() {
1770 let dir = create_fixture_dir("theme_overlap");
1771 write_fixture(
1772 &dir,
1773 "material/mapping.toml",
1774 "play-pause = \"play_pause\"\n",
1775 );
1776 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1777
1778 let config: MasterConfig = toml::from_str(
1779 r#"
1780name = "test"
1781roles = ["play-pause"]
1782bundled-themes = ["material"]
1783system-themes = ["material"]
1784"#,
1785 )
1786 .unwrap();
1787
1788 let result = run_pipeline(
1789 &[("test.toml".to_string(), config)],
1790 std::slice::from_ref(&dir),
1791 None,
1792 None,
1793 None,
1794 &[],
1795 );
1796
1797 assert!(!result.errors.is_empty(), "should detect theme overlap");
1798 assert!(
1799 result.errors.iter().any(|e| matches!(
1800 e,
1801 BuildError::ThemeOverlap { theme } if theme == "material"
1802 )),
1803 "should have ThemeOverlap error for 'material': {:?}",
1804 result.errors
1805 );
1806
1807 let _ = fs::remove_dir_all(&dir);
1808 }
1809
1810 #[test]
1811 fn pipeline_detects_identifier_collision() {
1812 let dir = create_fixture_dir("id_collision");
1813 write_fixture(
1814 &dir,
1815 "material/mapping.toml",
1816 "play_pause = \"pp\"\nplay-pause = \"pp2\"\n",
1817 );
1818 write_fixture(&dir, "material/pp.svg", SVG_STUB);
1819
1820 let config: MasterConfig = toml::from_str(
1821 r#"
1822name = "test"
1823roles = ["play_pause", "play-pause"]
1824bundled-themes = ["material"]
1825"#,
1826 )
1827 .unwrap();
1828
1829 let result = run_pipeline(
1830 &[("test.toml".to_string(), config)],
1831 std::slice::from_ref(&dir),
1832 None,
1833 None,
1834 None,
1835 &[],
1836 );
1837
1838 assert!(
1839 result.errors.iter().any(|e| matches!(
1840 e,
1841 BuildError::IdentifierCollision { pascal, .. } if pascal == "PlayPause"
1842 )),
1843 "should detect PascalCase collision: {:?}",
1844 result.errors
1845 );
1846
1847 let _ = fs::remove_dir_all(&dir);
1848 }
1849
1850 #[test]
1851 fn pipeline_detects_invalid_identifier() {
1852 let dir = create_fixture_dir("id_invalid");
1853 write_fixture(&dir, "material/mapping.toml", "self = \"self_icon\"\n");
1854 write_fixture(&dir, "material/self_icon.svg", SVG_STUB);
1855
1856 let config: MasterConfig = toml::from_str(
1857 r#"
1858name = "test"
1859roles = ["self"]
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.iter().any(|e| matches!(
1876 e,
1877 BuildError::InvalidIdentifier { name, .. } if name == "self"
1878 )),
1879 "should detect keyword identifier: {:?}",
1880 result.errors
1881 );
1882
1883 let _ = fs::remove_dir_all(&dir);
1884 }
1885
1886 #[test]
1887 fn pipeline_detects_duplicate_role_in_file() {
1888 let dir = create_fixture_dir("dup_in_file");
1889 write_fixture(
1890 &dir,
1891 "material/mapping.toml",
1892 "play-pause = \"play_pause\"\n",
1893 );
1894 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1895
1896 let config = MasterConfig {
1899 name: "test".to_string(),
1900 roles: vec!["play-pause".to_string(), "play-pause".to_string()],
1901 bundled_themes: vec!["material".to_string()],
1902 system_themes: Vec::new(),
1903 };
1904
1905 let result = run_pipeline(
1906 &[("test.toml".to_string(), config)],
1907 std::slice::from_ref(&dir),
1908 None,
1909 None,
1910 None,
1911 &[],
1912 );
1913
1914 assert!(
1915 result.errors.iter().any(|e| matches!(
1916 e,
1917 BuildError::DuplicateRoleInFile { role, file }
1918 if role == "play-pause" && file == "test.toml"
1919 )),
1920 "should detect duplicate role in file: {:?}",
1921 result.errors
1922 );
1923
1924 let _ = fs::remove_dir_all(&dir);
1925 }
1926
1927 #[test]
1930 fn pipeline_bundled_de_aware_produces_warning() {
1931 let dir = create_fixture_dir("bundled_de_aware");
1932 write_fixture(
1934 &dir,
1935 "material/mapping.toml",
1936 r#"play-pause = { kde = "media-playback-start", default = "play_pause" }"#,
1937 );
1938 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1939
1940 let config: MasterConfig = toml::from_str(
1941 r#"
1942name = "test-icon"
1943roles = ["play-pause"]
1944bundled-themes = ["material"]
1945"#,
1946 )
1947 .unwrap();
1948
1949 let result = run_pipeline(
1950 &[("test.toml".to_string(), config)],
1951 std::slice::from_ref(&dir),
1952 None,
1953 None,
1954 None,
1955 &[],
1956 );
1957
1958 assert!(
1959 result.errors.is_empty(),
1960 "bundled DE-aware should not be an error: {:?}",
1961 result.errors
1962 );
1963 assert!(
1964 result.warnings.iter().any(|w| {
1965 w.contains("bundled theme \"material\"")
1966 && w.contains("play-pause")
1967 && w.contains("only the default SVG will be embedded")
1968 }),
1969 "should warn about bundled DE-aware mapping. warnings: {:?}",
1970 result.warnings
1971 );
1972
1973 let _ = fs::remove_dir_all(&dir);
1974 }
1975
1976 #[test]
1977 fn pipeline_system_de_aware_no_bundled_warning() {
1978 let dir = create_fixture_dir("system_de_aware");
1979 write_fixture(
1981 &dir,
1982 "freedesktop/mapping.toml",
1983 r#"play-pause = { kde = "media-playback-start", default = "play" }"#,
1984 );
1985
1986 let config: MasterConfig = toml::from_str(
1987 r#"
1988name = "test-icon"
1989roles = ["play-pause"]
1990system-themes = ["freedesktop"]
1991"#,
1992 )
1993 .unwrap();
1994
1995 let result = run_pipeline(
1996 &[("test.toml".to_string(), config)],
1997 std::slice::from_ref(&dir),
1998 None,
1999 None,
2000 None,
2001 &[],
2002 );
2003
2004 assert!(
2005 result.errors.is_empty(),
2006 "system DE-aware should not be an error: {:?}",
2007 result.errors
2008 );
2009 assert!(
2010 !result
2011 .warnings
2012 .iter()
2013 .any(|w| w.contains("only the default SVG will be embedded")),
2014 "system themes should NOT produce bundled DE-aware warning. warnings: {:?}",
2015 result.warnings
2016 );
2017
2018 let _ = fs::remove_dir_all(&dir);
2019 }
2020
2021 #[test]
2024 fn pipeline_custom_crate_path() {
2025 let dir = create_fixture_dir("crate_path");
2026 write_fixture(
2027 &dir,
2028 "material/mapping.toml",
2029 "play-pause = \"play_pause\"\n",
2030 );
2031 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
2032
2033 let config: MasterConfig = toml::from_str(
2034 r#"
2035name = "test-icon"
2036roles = ["play-pause"]
2037bundled-themes = ["material"]
2038"#,
2039 )
2040 .unwrap();
2041
2042 let result = run_pipeline(
2043 &[("test.toml".to_string(), config)],
2044 std::slice::from_ref(&dir),
2045 None,
2046 None,
2047 Some("my_crate::native_theme"),
2048 &[],
2049 );
2050
2051 assert!(
2052 result.errors.is_empty(),
2053 "custom crate path should not cause errors: {:?}",
2054 result.errors
2055 );
2056 assert!(
2057 result
2058 .code
2059 .contains("impl my_crate::native_theme::IconProvider"),
2060 "should use custom crate path in impl. code:\n{}",
2061 result.code
2062 );
2063 assert!(
2064 !result.code.contains("extern crate"),
2065 "custom crate path should not emit extern crate. code:\n{}",
2066 result.code
2067 );
2068
2069 let _ = fs::remove_dir_all(&dir);
2070 }
2071
2072 #[test]
2073 fn pipeline_default_crate_path_emits_extern_crate() {
2074 let dir = create_fixture_dir("default_crate_path");
2075 write_fixture(
2076 &dir,
2077 "material/mapping.toml",
2078 "play-pause = \"play_pause\"\n",
2079 );
2080 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
2081
2082 let config: MasterConfig = toml::from_str(
2083 r#"
2084name = "test-icon"
2085roles = ["play-pause"]
2086bundled-themes = ["material"]
2087"#,
2088 )
2089 .unwrap();
2090
2091 let result = run_pipeline(
2092 &[("test.toml".to_string(), config)],
2093 std::slice::from_ref(&dir),
2094 None,
2095 None,
2096 None,
2097 &[],
2098 );
2099
2100 assert!(
2101 result.errors.is_empty(),
2102 "default crate path should not cause errors: {:?}",
2103 result.errors
2104 );
2105 assert!(
2106 result.code.contains("extern crate native_theme;"),
2107 "default crate path should emit extern crate. code:\n{}",
2108 result.code
2109 );
2110
2111 let _ = fs::remove_dir_all(&dir);
2112 }
2113
2114 #[test]
2117 #[should_panic(expected = "derive name must be non-empty")]
2118 fn derive_rejects_empty_string() {
2119 let _ = IconGenerator::new().derive("");
2120 }
2121
2122 #[test]
2123 #[should_panic(expected = "derive name must be non-empty and contain no whitespace")]
2124 fn derive_rejects_whitespace() {
2125 let _ = IconGenerator::new().derive("Ord PartialOrd");
2126 }
2127
2128 #[test]
2129 #[should_panic(expected = "derive name must be non-empty and contain no whitespace")]
2130 fn derive_rejects_tab() {
2131 let _ = IconGenerator::new().derive("Ord\t");
2132 }
2133
2134 #[test]
2135 fn derive_accepts_valid_name() {
2136 let _ = IconGenerator::new().derive("Ord");
2138 let _ = IconGenerator::new().derive("serde::Serialize");
2139 }
2140
2141 #[test]
2142 #[should_panic(expected = "crate_path must be non-empty")]
2143 fn crate_path_rejects_empty_string() {
2144 let _ = IconGenerator::new().crate_path("");
2145 }
2146
2147 #[test]
2148 #[should_panic(expected = "crate_path must be non-empty and contain no spaces")]
2149 fn crate_path_rejects_spaces() {
2150 let _ = IconGenerator::new().crate_path("foo bar");
2151 }
2152
2153 #[test]
2154 fn crate_path_accepts_valid_path() {
2155 let _ = IconGenerator::new().crate_path("native_theme");
2157 let _ = IconGenerator::new().crate_path("my_crate::native_theme");
2158 }
2159}