1#![warn(missing_docs)]
126#![forbid(unsafe_code)]
127
128mod codegen;
129mod error;
130mod schema;
131mod validate;
132
133use std::collections::BTreeMap;
134use std::path::{Path, PathBuf};
135
136use heck::ToSnakeCase;
137
138pub use error::{BuildError, BuildErrors};
139use schema::{MasterConfig, ThemeMapping};
140
141fn validate_rust_path(path: &str) -> Option<String> {
147 if path.is_empty() {
148 return Some("must be non-empty".to_string());
149 }
150 let segments: Vec<&str> = path.split("::").collect();
151 for segment in &segments {
152 if segment.is_empty() {
153 return Some(
154 "contains empty segment (leading, trailing, or consecutive `::`)".to_string(),
155 );
156 }
157 let mut chars = segment.chars();
158 if let Some(first) = chars.next()
159 && !first.is_ascii_alphabetic()
160 && first != '_'
161 {
162 return Some(format!(
163 "segment \"{segment}\" must start with a letter or underscore"
164 ));
165 }
166 for c in chars {
167 if !c.is_ascii_alphanumeric() && c != '_' {
168 return Some(format!(
169 "segment \"{segment}\" contains invalid character '{c}'"
170 ));
171 }
172 }
173 }
174 None
175}
176
177#[cfg(test)]
178use schema::{MappingValue, THEME_TABLE};
179
180#[derive(Debug, Clone)]
187#[must_use = "call .emit_cargo_directives() to write the file and emit cargo directives"]
188pub struct GenerateOutput {
189 pub output_path: PathBuf,
191 pub warnings: Vec<String>,
193 pub role_count: usize,
195 pub bundled_theme_count: usize,
197 pub svg_count: usize,
199 pub total_svg_bytes: u64,
201 rerun_paths: Vec<PathBuf>,
203 pub code: String,
205}
206
207impl GenerateOutput {
208 pub fn rerun_paths(&self) -> &[PathBuf] {
210 &self.rerun_paths
211 }
212
213 pub fn emit_cargo_directives(&self) -> Result<(), std::io::Error> {
224 for path in &self.rerun_paths {
225 println!("cargo::rerun-if-changed={}", path.display());
226 }
227 std::fs::write(&self.output_path, &self.code)?;
228 for w in &self.warnings {
229 println!("cargo::warning={w}");
230 }
231 Ok(())
232 }
233}
234
235pub trait UnwrapOrExit<T> {
252 fn unwrap_or_exit(self) -> T;
254}
255
256impl UnwrapOrExit<GenerateOutput> for Result<GenerateOutput, BuildErrors> {
257 fn unwrap_or_exit(self) -> GenerateOutput {
258 match self {
259 Ok(output) => output,
260 Err(errors) => {
261 errors.emit_cargo_errors();
265 std::process::exit(1);
266 }
267 }
268 }
269}
270
271#[must_use = "this returns the generated output; call .emit_cargo_directives() to complete the build"]
293pub fn generate_icons(toml_path: impl AsRef<Path>) -> Result<GenerateOutput, BuildErrors> {
294 let toml_path = toml_path.as_ref();
295 let manifest_dir = PathBuf::from(
296 std::env::var("CARGO_MANIFEST_DIR")
297 .map_err(|e| BuildErrors::io_env("CARGO_MANIFEST_DIR", e.to_string()))?,
298 );
299 let out_dir = PathBuf::from(
300 std::env::var("OUT_DIR").map_err(|e| BuildErrors::io_env("OUT_DIR", e.to_string()))?,
301 );
302 let resolved = manifest_dir.join(toml_path);
303
304 let content = std::fs::read_to_string(&resolved)
305 .map_err(|e| BuildErrors::io_read(resolved.display().to_string(), e.to_string()))?;
306 let config: MasterConfig = toml::from_str(&content)
307 .map_err(|e| BuildErrors::io_parse(resolved.display().to_string(), e.to_string()))?;
308
309 let base_dir = resolved
310 .parent()
311 .ok_or_else(|| {
312 BuildErrors::io_other(format!("{} has no parent directory", resolved.display()))
313 })?
314 .to_path_buf();
315 let file_path_str = resolved.to_string_lossy().to_string();
316
317 let result = run_pipeline(
318 &[(file_path_str, config)],
319 &[base_dir],
320 None,
321 Some(&manifest_dir),
322 None,
323 &[],
324 );
325
326 pipeline_result_to_output(result, &out_dir)
327}
328
329#[derive(Debug)]
331#[must_use = "a configured builder does nothing until .generate() is called"]
332pub struct IconGenerator {
333 sources: Vec<PathBuf>,
334 enum_name_override: Option<String>,
335 base_dir: Option<PathBuf>,
336 crate_path: Option<String>,
337 extra_derives: Vec<String>,
338 output_dir: Option<PathBuf>,
339}
340
341impl Default for IconGenerator {
342 fn default() -> Self {
343 Self::new()
344 }
345}
346
347impl IconGenerator {
348 pub fn new() -> Self {
350 Self {
351 sources: Vec::new(),
352 enum_name_override: None,
353 base_dir: None,
354 crate_path: None,
355 extra_derives: Vec::new(),
356 output_dir: None,
357 }
358 }
359
360 pub fn source(mut self, path: impl AsRef<Path>) -> Self {
362 self.sources.push(path.as_ref().to_path_buf());
363 self
364 }
365
366 pub fn enum_name(mut self, name: &str) -> Self {
368 self.enum_name_override = Some(name.to_string());
369 self
370 }
371
372 pub fn base_dir(mut self, path: impl AsRef<Path>) -> Self {
381 self.base_dir = Some(path.as_ref().to_path_buf());
382 self
383 }
384
385 pub fn crate_path(mut self, path: &str) -> Self {
393 self.crate_path = Some(path.to_string());
395 self
396 }
397
398 pub fn derive(mut self, name: &str) -> Self {
414 self.extra_derives.push(name.to_string());
416 self
417 }
418
419 pub fn output_dir(mut self, path: impl AsRef<Path>) -> Self {
425 self.output_dir = Some(path.as_ref().to_path_buf());
426 self
427 }
428
429 pub fn generate(self) -> Result<GenerateOutput, BuildErrors> {
445 if self.sources.is_empty() {
446 return Err(BuildErrors::io_other(
447 "no source files added to IconGenerator (call .source() before .generate())",
448 ));
449 }
450
451 if let Some(ref path) = self.crate_path
453 && let Some(reason) = validate_rust_path(path)
454 {
455 return Err(BuildErrors::new(vec![BuildError::InvalidCratePath {
456 path: path.clone(),
457 reason,
458 }]));
459 }
460
461 {
463 let mut errors = Vec::new();
464 for name in &self.extra_derives {
465 if let Some(reason) = validate_rust_path(name) {
466 errors.push(BuildError::InvalidDerive {
467 name: name.clone(),
468 reason,
469 });
470 }
471 }
472 if !errors.is_empty() {
473 return Err(BuildErrors::new(errors));
474 }
475 }
476
477 let needs_manifest_dir = self.sources.iter().any(|s| !s.is_absolute())
478 || self.base_dir.as_ref().is_some_and(|b| !b.is_absolute());
479 let manifest_dir = if needs_manifest_dir {
480 Some(PathBuf::from(std::env::var("CARGO_MANIFEST_DIR").map_err(
481 |e| BuildErrors::io_env("CARGO_MANIFEST_DIR", e.to_string()),
482 )?))
483 } else {
484 std::env::var("CARGO_MANIFEST_DIR").ok().map(PathBuf::from)
485 };
486
487 let out_dir = match self.output_dir {
488 Some(dir) => dir,
489 None => PathBuf::from(
490 std::env::var("OUT_DIR")
491 .map_err(|e| BuildErrors::io_env("OUT_DIR", e.to_string()))?,
492 ),
493 };
494
495 let mut configs = Vec::new();
496 let mut base_dirs = Vec::new();
497
498 for source in &self.sources {
499 let resolved = if source.is_absolute() {
500 source.clone()
501 } else {
502 manifest_dir
503 .as_ref()
504 .ok_or_else(|| {
505 BuildErrors::io_env(
506 "CARGO_MANIFEST_DIR",
507 format!("required for relative path {}", source.display()),
508 )
509 })?
510 .join(source)
511 };
512 if resolved.is_dir() {
514 return Err(BuildErrors::io_other(format!(
515 "source path '{}' is a directory; expected a TOML file",
516 resolved.display()
517 )));
518 }
519
520 let content = std::fs::read_to_string(&resolved)
521 .map_err(|e| BuildErrors::io_read(resolved.display().to_string(), e.to_string()))?;
522 let config: MasterConfig = toml::from_str(&content).map_err(|e| {
523 BuildErrors::io_parse(resolved.display().to_string(), e.to_string())
524 })?;
525
526 let file_path_str = resolved.to_string_lossy().to_string();
527
528 if let Some(ref explicit_base) = self.base_dir {
529 let base = if explicit_base.is_absolute() {
530 explicit_base.clone()
531 } else {
532 manifest_dir
533 .as_ref()
534 .ok_or_else(|| {
535 BuildErrors::io_env(
536 "CARGO_MANIFEST_DIR",
537 format!(
538 "required for relative base_dir {}",
539 explicit_base.display()
540 ),
541 )
542 })?
543 .join(explicit_base)
544 };
545 base_dirs.push(base);
546 } else {
547 let parent = resolved
548 .parent()
549 .ok_or_else(|| {
550 BuildErrors::io_other(format!(
551 "{} has no parent directory",
552 resolved.display()
553 ))
554 })?
555 .to_path_buf();
556 base_dirs.push(parent);
557 }
558
559 configs.push((file_path_str, config));
560 }
561
562 if self.base_dir.is_none() && base_dirs.len() > 1 {
564 let first = &base_dirs[0];
565 let divergent = base_dirs.iter().any(|d| d != first);
566 if divergent {
567 return Err(BuildErrors::io_other(
568 "multiple source files have different parent directories; \
569 use .base_dir() to specify a common base directory for theme resolution",
570 ));
571 }
572 }
573
574 let result = run_pipeline(
575 &configs,
576 &base_dirs,
577 self.enum_name_override.as_deref(),
578 manifest_dir.as_deref(),
579 self.crate_path.as_deref(),
580 &self.extra_derives,
581 );
582
583 pipeline_result_to_output(result, &out_dir)
584 }
585}
586
587struct PipelineResult {
593 pub code: String,
595 pub errors: Vec<BuildError>,
597 pub warnings: Vec<String>,
599 pub rerun_paths: Vec<PathBuf>,
601 pub size_report: Option<SizeReport>,
603 pub output_filename: String,
605}
606
607struct SizeReport {
609 pub role_count: usize,
611 pub bundled_theme_count: usize,
613 pub total_svg_bytes: u64,
615 pub svg_count: usize,
617}
618
619fn run_pipeline(
632 configs: &[(String, MasterConfig)],
633 base_dirs: &[PathBuf],
634 enum_name_override: Option<&str>,
635 manifest_dir: Option<&Path>,
636 crate_path: Option<&str>,
637 extra_derives: &[String],
638) -> PipelineResult {
639 if configs.is_empty() {
640 return PipelineResult {
641 code: String::new(),
642 errors: vec![BuildError::IoOther {
643 message: "no icon configs provided".into(),
644 }],
645 warnings: Vec::new(),
646 rerun_paths: Vec::new(),
647 size_report: None,
648 output_filename: String::new(),
649 };
650 }
651
652 debug_assert_eq!(configs.len(), base_dirs.len());
653
654 let mut errors: Vec<BuildError> = Vec::new();
655 let mut warnings: Vec<String> = Vec::new();
656 let mut rerun_paths: Vec<PathBuf> = Vec::new();
657 let mut all_mappings: BTreeMap<String, ThemeMapping> = BTreeMap::new();
658 let mut svg_paths: Vec<PathBuf> = Vec::new();
659
660 for (file_path, config) in configs {
664 if config.roles.is_empty() {
666 warnings.push(format!(
667 "{file_path}: roles list is empty; generated enum will have no variants"
668 ));
669 }
670
671 let dup_in_file_errors = validate::validate_no_duplicate_roles_in_file(config, file_path);
673 errors.extend(dup_in_file_errors);
674
675 let overlap_errors = validate::validate_theme_overlap(config);
677 errors.extend(overlap_errors);
678
679 let dup_theme_errors = validate::validate_no_duplicate_themes(config);
681 errors.extend(dup_theme_errors);
682
683 let file_id_errors = validate::validate_identifiers(config, Some(file_path));
685 errors.extend(file_id_errors);
686
687 let file_theme_errors = validate::validate_themes(config, Some(file_path));
689 errors.extend(file_theme_errors);
690 }
691
692 if configs.len() > 1 {
694 let dup_errors = validate::validate_no_duplicate_roles(configs);
695 errors.extend(dup_errors);
696 }
697
698 let merged = merge_configs(configs, enum_name_override, &mut warnings);
700
701 let overlap_errors = validate::validate_theme_overlap(&merged);
703 errors.extend(overlap_errors);
704
705 if merged.bundled_themes.is_empty() && merged.system_themes.is_empty() {
707 warnings.push(
708 "no bundled-themes or system-themes configured; \
709 generated IconProvider will always return None"
710 .to_string(),
711 );
712 }
713
714 let output_filename = format!("{}.rs", merged.name.to_snake_case());
716
717 if output_filename == ".rs" {
719 errors.push(BuildError::InvalidIdentifier {
720 name: merged.name.clone(),
721 reason: "snake_case conversion produces an empty filename".to_string(),
722 });
723 }
724
725 {
727 let pascal = heck::ToUpperCamelCase::to_upper_camel_case(merged.name.as_str());
728 if !pascal.is_empty() && pascal != merged.name {
729 warnings.push(format!(
730 "name \"{}\" will be used as \"{}\" (PascalCase normalization)",
731 merged.name, pascal
732 ));
733 }
734 }
735
736 {
738 let enum_pascal = heck::ToUpperCamelCase::to_upper_camel_case(merged.name.as_str());
739 for role in &merged.roles {
740 let role_pascal = heck::ToUpperCamelCase::to_upper_camel_case(role.as_str());
741 if role_pascal == enum_pascal && !role_pascal.is_empty() {
742 warnings.push(format!(
743 "role \"{role}\" produces the same PascalCase name \"{role_pascal}\" \
744 as the enum; this creates `enum {enum_pascal} {{ {role_pascal}, ... }}` \
745 which may be confusing"
746 ));
747 }
748 }
749 }
750
751 let id_errors = validate::validate_identifiers(&merged, None);
755 errors.extend(id_errors);
756
757 for (file_path, _config) in configs {
759 rerun_paths.push(PathBuf::from(file_path));
760 }
761
762 let theme_errors = validate::validate_themes(&merged, None);
766 errors.extend(theme_errors);
767
768 let base_dir = &base_dirs[0];
771
772 for theme_name in &merged.bundled_themes {
774 let theme_dir = base_dir.join(theme_name);
775
776 if !theme_dir.exists() {
778 errors.push(BuildError::IoRead {
779 path: theme_dir.display().to_string(),
780 reason: format!(
781 "theme directory not found (expected for bundled theme \"{theme_name}\")"
782 ),
783 });
784 continue;
785 }
786
787 let mapping_path = theme_dir.join("mapping.toml");
788 let mapping_path_str = mapping_path.to_string_lossy().to_string();
789
790 rerun_paths.push(mapping_path.clone());
792 rerun_paths.push(theme_dir.clone());
793
794 match std::fs::read_to_string(&mapping_path) {
795 Ok(content) => match toml::from_str::<ThemeMapping>(&content) {
796 Ok(mapping) => {
797 let map_errors =
799 validate::validate_mapping(&merged.roles, &mapping, &mapping_path_str);
800 errors.extend(map_errors);
801
802 let name_errors =
804 validate::validate_mapping_values(&mapping, &mapping_path_str);
805 errors.extend(name_errors);
806
807 let svg_errors = validate::validate_svgs(&mapping, &theme_dir, &merged.roles);
809 errors.extend(svg_errors);
810
811 let de_warnings = validate::validate_de_keys(&mapping, &mapping_path_str);
813 warnings.extend(de_warnings);
814
815 for (role_name, value) in &mapping {
819 if matches!(value, schema::MappingValue::DeAware(_)) {
820 errors.push(BuildError::BundledDeAware {
821 theme: theme_name.clone(),
822 role: role_name.clone(),
823 });
824 }
825 }
826
827 let orphan_warnings = check_orphan_svgs_and_collect_paths(
829 &mapping,
830 &theme_dir,
831 theme_name,
832 &mut svg_paths,
833 &mut rerun_paths,
834 );
835 warnings.extend(orphan_warnings);
836
837 all_mappings.insert(theme_name.clone(), mapping);
838 }
839 Err(e) => {
840 errors.push(BuildError::IoParse {
841 path: mapping_path_str,
842 reason: e.to_string(),
843 });
844 }
845 },
846 Err(e) => {
847 errors.push(BuildError::IoRead {
848 path: mapping_path_str,
849 reason: e.to_string(),
850 });
851 }
852 }
853 }
854
855 for theme_name in &merged.system_themes {
857 let theme_dir = base_dir.join(theme_name);
858
859 if !theme_dir.exists() {
861 errors.push(BuildError::IoRead {
862 path: theme_dir.display().to_string(),
863 reason: format!(
864 "theme directory not found (expected for system theme \"{theme_name}\")"
865 ),
866 });
867 continue;
868 }
869
870 let mapping_path = theme_dir.join("mapping.toml");
871 let mapping_path_str = mapping_path.to_string_lossy().to_string();
872
873 rerun_paths.push(mapping_path.clone());
875
876 match std::fs::read_to_string(&mapping_path) {
877 Ok(content) => match toml::from_str::<ThemeMapping>(&content) {
878 Ok(mapping) => {
879 let map_errors =
880 validate::validate_mapping(&merged.roles, &mapping, &mapping_path_str);
881 errors.extend(map_errors);
882
883 let name_errors =
885 validate::validate_mapping_values(&mapping, &mapping_path_str);
886 errors.extend(name_errors);
887
888 let de_warnings = validate::validate_de_keys(&mapping, &mapping_path_str);
890 warnings.extend(de_warnings);
891
892 all_mappings.insert(theme_name.clone(), mapping);
893 }
894 Err(e) => {
895 errors.push(BuildError::IoParse {
896 path: mapping_path_str,
897 reason: e.to_string(),
898 });
899 }
900 },
901 Err(e) => {
902 errors.push(BuildError::IoRead {
903 path: mapping_path_str,
904 reason: e.to_string(),
905 });
906 }
907 }
908 }
909
910 if !errors.is_empty() {
912 return PipelineResult {
913 code: String::new(),
914 errors,
915 warnings,
916 rerun_paths,
917 size_report: None,
918 output_filename,
919 };
920 }
921
922 let base_dir_str = if let Some(mdir) = manifest_dir {
928 base_dir
929 .strip_prefix(mdir)
930 .unwrap_or(base_dir)
931 .to_string_lossy()
932 .replace('\\', "/")
933 } else {
934 base_dir.to_string_lossy().replace('\\', "/")
935 };
936
937 let effective_crate_path = crate_path.unwrap_or("native_theme");
939 let code = codegen::generate_code(
940 &merged,
941 &all_mappings,
942 &base_dir_str,
943 effective_crate_path,
944 extra_derives,
945 );
946
947 let total_svg_bytes: u64 = svg_paths
949 .iter()
950 .filter_map(|p| std::fs::metadata(p).ok())
951 .map(|m| m.len())
952 .sum();
953
954 let size_report = Some(SizeReport {
955 role_count: merged.roles.len(),
956 bundled_theme_count: merged.bundled_themes.len(),
957 total_svg_bytes,
958 svg_count: svg_paths.len(),
959 });
960
961 PipelineResult {
962 code,
963 errors,
964 warnings,
965 rerun_paths,
966 size_report,
967 output_filename,
968 }
969}
970
971fn check_orphan_svgs_and_collect_paths(
976 mapping: &ThemeMapping,
977 theme_dir: &Path,
978 theme_name: &str,
979 svg_paths: &mut Vec<PathBuf>,
980 rerun_paths: &mut Vec<PathBuf>,
981) -> Vec<String> {
982 for value in mapping.values() {
984 let names = value.all_names();
985 for name in names {
986 let svg_path = theme_dir.join(format!("{name}.svg"));
987 if svg_path.exists() {
988 rerun_paths.push(svg_path.clone());
989 svg_paths.push(svg_path);
990 }
991 }
992 }
993
994 validate::check_orphan_svgs(mapping, theme_dir, theme_name)
995}
996
997fn merge_configs(
1001 configs: &[(String, MasterConfig)],
1002 enum_name_override: Option<&str>,
1003 warnings: &mut Vec<String>,
1004) -> MasterConfig {
1005 let name = enum_name_override
1006 .map(|s| s.to_string())
1007 .unwrap_or_else(|| configs[0].1.name.clone());
1008
1009 if enum_name_override.is_none() && configs.len() > 1 {
1013 let first_name = &configs[0].1.name;
1014 for (path, config) in &configs[1..] {
1015 if !config.name.is_empty() && config.name != *first_name {
1016 warnings.push(format!(
1017 "config \"{path}\" has name \"{}\" which differs from \
1018 the first config's name \"{first_name}\"; using \"{first_name}\" \
1019 (set .enum_name() to override)",
1020 config.name
1021 ));
1022 }
1023 }
1024 }
1025
1026 let mut roles = Vec::new();
1027 let mut bundled_themes = Vec::new();
1028 let mut system_themes = Vec::new();
1029 let mut seen_roles = std::collections::BTreeSet::new();
1030 let mut seen_bundled = std::collections::BTreeSet::new();
1031 let mut seen_system = std::collections::BTreeSet::new();
1032
1033 for (_path, config) in configs {
1034 for role in &config.roles {
1035 if seen_roles.insert(role.clone()) {
1036 roles.push(role.clone());
1037 }
1038 }
1039
1040 for t in &config.bundled_themes {
1041 if !seen_bundled.insert(t.clone()) {
1042 warnings.push(format!(
1044 "bundled theme \"{t}\" appears in multiple source files; using first occurrence"
1045 ));
1046 } else {
1047 bundled_themes.push(t.clone());
1048 }
1049 }
1050 for t in &config.system_themes {
1051 if !seen_system.insert(t.clone()) {
1052 warnings.push(format!(
1054 "system theme \"{t}\" appears in multiple source files; using first occurrence"
1055 ));
1056 } else {
1057 system_themes.push(t.clone());
1058 }
1059 }
1060 }
1061
1062 MasterConfig {
1063 name,
1064 roles,
1065 bundled_themes,
1066 system_themes,
1067 }
1068}
1069
1070fn pipeline_result_to_output(
1072 result: PipelineResult,
1073 out_dir: &Path,
1074) -> Result<GenerateOutput, BuildErrors> {
1075 if !result.errors.is_empty() {
1076 return Err(BuildErrors::with_rerun_paths(
1079 result.errors,
1080 result.rerun_paths,
1081 ));
1082 }
1083
1084 let output_path = out_dir.join(&result.output_filename);
1085
1086 let (role_count, bundled_theme_count, svg_count, total_svg_bytes) = match &result.size_report {
1087 Some(report) => (
1088 report.role_count,
1089 report.bundled_theme_count,
1090 report.svg_count,
1091 report.total_svg_bytes,
1092 ),
1093 None => (0, 0, 0, 0),
1094 };
1095
1096 Ok(GenerateOutput {
1097 output_path,
1098 warnings: result.warnings,
1099 role_count,
1100 bundled_theme_count,
1101 svg_count,
1102 total_svg_bytes,
1103 rerun_paths: result.rerun_paths,
1104 code: result.code,
1105 })
1106}
1107
1108#[cfg(test)]
1109mod tests {
1110 use super::*;
1111 use std::collections::BTreeMap;
1112 use std::fs;
1113
1114 #[test]
1117 fn master_config_deserializes_full() {
1118 let toml_str = r#"
1119name = "app-icon"
1120roles = ["play-pause", "skip-forward"]
1121bundled-themes = ["material"]
1122system-themes = ["sf-symbols"]
1123"#;
1124 let config: MasterConfig = toml::from_str(toml_str).unwrap();
1125 assert_eq!(config.name, "app-icon");
1126 assert_eq!(config.roles, vec!["play-pause", "skip-forward"]);
1127 assert_eq!(config.bundled_themes, vec!["material"]);
1128 assert_eq!(config.system_themes, vec!["sf-symbols"]);
1129 }
1130
1131 #[test]
1132 fn master_config_empty_optional_fields() {
1133 let toml_str = r#"
1134name = "x"
1135roles = ["a"]
1136"#;
1137 let config: MasterConfig = toml::from_str(toml_str).unwrap();
1138 assert_eq!(config.name, "x");
1139 assert_eq!(config.roles, vec!["a"]);
1140 assert!(config.bundled_themes.is_empty());
1141 assert!(config.system_themes.is_empty());
1142 }
1143
1144 #[test]
1145 fn master_config_rejects_unknown_fields() {
1146 let toml_str = r#"
1147name = "x"
1148roles = ["a"]
1149bogus = "nope"
1150"#;
1151 let result = toml::from_str::<MasterConfig>(toml_str);
1152 assert!(result.is_err());
1153 }
1154
1155 #[test]
1158 fn mapping_value_simple() {
1159 let toml_str = r#"play-pause = "play_pause""#;
1160 let mapping: BTreeMap<String, MappingValue> = toml::from_str(toml_str).unwrap();
1161 match &mapping["play-pause"] {
1162 MappingValue::Simple(s) => assert_eq!(s, "play_pause"),
1163 _ => panic!("expected Simple variant"),
1164 }
1165 }
1166
1167 #[test]
1168 fn mapping_value_de_aware() {
1169 let toml_str = r#"play-pause = { kde = "media-playback-start", default = "play" }"#;
1170 let mapping: BTreeMap<String, MappingValue> = toml::from_str(toml_str).unwrap();
1171 match &mapping["play-pause"] {
1172 MappingValue::DeAware(m) => {
1173 assert_eq!(m["kde"], "media-playback-start");
1174 assert_eq!(m["default"], "play");
1175 }
1176 _ => panic!("expected DeAware variant"),
1177 }
1178 }
1179
1180 #[test]
1181 fn theme_mapping_mixed_values() {
1182 let toml_str = r#"
1183play-pause = "play_pause"
1184bluetooth = { kde = "preferences-system-bluetooth", default = "bluetooth" }
1185skip-forward = "skip_next"
1186"#;
1187 let mapping: ThemeMapping = toml::from_str(toml_str).unwrap();
1188 assert_eq!(mapping.len(), 3);
1189 assert!(matches!(&mapping["play-pause"], MappingValue::Simple(_)));
1190 assert!(matches!(&mapping["bluetooth"], MappingValue::DeAware(_)));
1191 assert!(matches!(&mapping["skip-forward"], MappingValue::Simple(_)));
1192 }
1193
1194 #[test]
1197 fn mapping_value_default_name_simple() {
1198 let val = MappingValue::Simple("play_pause".to_string());
1199 assert_eq!(val.default_name(), Some("play_pause"));
1200 }
1201
1202 #[test]
1203 fn mapping_value_default_name_de_aware() {
1204 let mut m = BTreeMap::new();
1205 m.insert("kde".to_string(), "media-playback-start".to_string());
1206 m.insert("default".to_string(), "play".to_string());
1207 let val = MappingValue::DeAware(m);
1208 assert_eq!(val.default_name(), Some("play"));
1209 }
1210
1211 #[test]
1212 fn mapping_value_default_name_de_aware_missing_default() {
1213 let mut m = BTreeMap::new();
1214 m.insert("kde".to_string(), "media-playback-start".to_string());
1215 let val = MappingValue::DeAware(m);
1216 assert_eq!(val.default_name(), None);
1217 }
1218
1219 #[test]
1222 fn build_error_missing_role_format() {
1223 let err = BuildError::MissingRole {
1224 role: "play-pause".into(),
1225 mapping_file: "icons/material/mapping.toml".into(),
1226 };
1227 let msg = err.to_string();
1228 assert!(msg.contains("play-pause"), "should contain role name");
1229 assert!(
1230 msg.contains("icons/material/mapping.toml"),
1231 "should contain file path"
1232 );
1233 }
1234
1235 #[test]
1236 fn build_error_missing_svg_format() {
1237 let err = BuildError::MissingSvg {
1238 path: "icons/material/play.svg".into(),
1239 };
1240 let msg = err.to_string();
1241 assert!(
1242 msg.contains("icons/material/play.svg"),
1243 "should contain SVG path"
1244 );
1245 }
1246
1247 #[test]
1248 fn build_error_unknown_role_format() {
1249 let err = BuildError::UnknownRole {
1250 role: "bogus".into(),
1251 mapping_file: "icons/material/mapping.toml".into(),
1252 };
1253 let msg = err.to_string();
1254 assert!(msg.contains("bogus"), "should contain role name");
1255 assert!(
1256 msg.contains("icons/material/mapping.toml"),
1257 "should contain file path"
1258 );
1259 }
1260
1261 #[test]
1262 fn build_error_unknown_theme_format() {
1263 let err = BuildError::UnknownTheme {
1264 theme: "nonexistent".into(),
1265 source_file: None,
1266 };
1267 let msg = err.to_string();
1268 assert!(msg.contains("nonexistent"), "should contain theme name");
1269 }
1270
1271 #[test]
1272 fn build_error_missing_default_format() {
1273 let err = BuildError::MissingDefault {
1274 role: "bluetooth".into(),
1275 mapping_file: "icons/freedesktop/mapping.toml".into(),
1276 };
1277 let msg = err.to_string();
1278 assert!(msg.contains("bluetooth"), "should contain role name");
1279 assert!(
1280 msg.contains("icons/freedesktop/mapping.toml"),
1281 "should contain file path"
1282 );
1283 }
1284
1285 #[test]
1286 fn build_error_duplicate_role_format() {
1287 let err = BuildError::DuplicateRole {
1288 role: "play-pause".into(),
1289 file_a: "icons/a.toml".into(),
1290 file_b: "icons/b.toml".into(),
1291 };
1292 let msg = err.to_string();
1293 assert!(msg.contains("play-pause"), "should contain role name");
1294 assert!(
1295 msg.contains("icons/a.toml"),
1296 "should contain first file path"
1297 );
1298 assert!(
1299 msg.contains("icons/b.toml"),
1300 "should contain second file path"
1301 );
1302 }
1303
1304 #[test]
1307 fn theme_table_has_all_five() {
1308 assert_eq!(THEME_TABLE.len(), 5);
1309 let names: Vec<&str> = THEME_TABLE.iter().map(|(k, _)| *k).collect();
1310 assert!(names.contains(&"sf-symbols"));
1311 assert!(names.contains(&"segoe-fluent"));
1312 assert!(names.contains(&"freedesktop"));
1313 assert!(names.contains(&"material"));
1314 assert!(names.contains(&"lucide"));
1315 }
1316
1317 fn create_fixture_dir(suffix: &str) -> PathBuf {
1320 let dir = std::env::temp_dir().join(format!(
1321 "native_theme_test_pipeline_{suffix}_{}",
1322 std::process::id()
1323 ));
1324 let _ = fs::remove_dir_all(&dir);
1325 fs::create_dir_all(&dir).unwrap();
1326 dir
1327 }
1328
1329 fn write_fixture(dir: &Path, path: &str, content: &str) {
1330 let full_path = dir.join(path);
1331 if let Some(parent) = full_path.parent() {
1332 fs::create_dir_all(parent).unwrap();
1333 }
1334 fs::write(full_path, content).unwrap();
1335 }
1336
1337 const SVG_STUB: &str = r#"<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24"></svg>"#;
1338
1339 #[test]
1342 fn pipeline_happy_path_generates_code() {
1343 let dir = create_fixture_dir("happy");
1344 write_fixture(
1345 &dir,
1346 "material/mapping.toml",
1347 r#"
1348play-pause = "play_pause"
1349skip-forward = "skip_next"
1350"#,
1351 );
1352 write_fixture(
1353 &dir,
1354 "sf-symbols/mapping.toml",
1355 r#"
1356play-pause = "play.fill"
1357skip-forward = "forward.fill"
1358"#,
1359 );
1360 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1361 write_fixture(&dir, "material/skip_next.svg", SVG_STUB);
1362
1363 let config: MasterConfig = toml::from_str(
1364 r#"
1365name = "sample-icon"
1366roles = ["play-pause", "skip-forward"]
1367bundled-themes = ["material"]
1368system-themes = ["sf-symbols"]
1369"#,
1370 )
1371 .unwrap();
1372
1373 let result = run_pipeline(
1374 &[("sample-icons.toml".to_string(), config)],
1375 std::slice::from_ref(&dir),
1376 None,
1377 None,
1378 None,
1379 &[],
1380 );
1381
1382 assert!(
1383 result.errors.is_empty(),
1384 "expected no errors: {:?}",
1385 result.errors
1386 );
1387 assert!(!result.code.is_empty(), "expected generated code");
1388 assert!(result.code.contains("pub enum SampleIcon"));
1389 assert!(result.code.contains("PlayPause"));
1390 assert!(result.code.contains("SkipForward"));
1391
1392 let _ = fs::remove_dir_all(&dir);
1393 }
1394
1395 #[test]
1396 fn pipeline_output_filename_uses_snake_case() {
1397 let dir = create_fixture_dir("filename");
1398 write_fixture(
1399 &dir,
1400 "material/mapping.toml",
1401 "play-pause = \"play_pause\"\n",
1402 );
1403 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1404
1405 let config: MasterConfig = toml::from_str(
1406 r#"
1407name = "app-icon"
1408roles = ["play-pause"]
1409bundled-themes = ["material"]
1410"#,
1411 )
1412 .unwrap();
1413
1414 let result = run_pipeline(
1415 &[("app.toml".to_string(), config)],
1416 std::slice::from_ref(&dir),
1417 None,
1418 None,
1419 None,
1420 &[],
1421 );
1422
1423 assert_eq!(result.output_filename, "app_icon.rs");
1424
1425 let _ = fs::remove_dir_all(&dir);
1426 }
1427
1428 #[test]
1429 fn pipeline_collects_rerun_paths() {
1430 let dir = create_fixture_dir("rerun");
1431 write_fixture(
1432 &dir,
1433 "material/mapping.toml",
1434 r#"
1435play-pause = "play_pause"
1436"#,
1437 );
1438 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1439
1440 let config: MasterConfig = toml::from_str(
1441 r#"
1442name = "test"
1443roles = ["play-pause"]
1444bundled-themes = ["material"]
1445"#,
1446 )
1447 .unwrap();
1448
1449 let result = run_pipeline(
1450 &[("test.toml".to_string(), config)],
1451 std::slice::from_ref(&dir),
1452 None,
1453 None,
1454 None,
1455 &[],
1456 );
1457
1458 assert!(result.errors.is_empty());
1459 let path_strs: Vec<String> = result
1461 .rerun_paths
1462 .iter()
1463 .map(|p| p.to_string_lossy().to_string())
1464 .collect();
1465 assert!(
1466 path_strs.iter().any(|p| p.contains("test.toml")),
1467 "should track master TOML"
1468 );
1469 assert!(
1470 path_strs.iter().any(|p| p.contains("mapping.toml")),
1471 "should track mapping TOML"
1472 );
1473 assert!(
1474 path_strs.iter().any(|p| p.contains("play_pause.svg")),
1475 "should track SVG files"
1476 );
1477
1478 let _ = fs::remove_dir_all(&dir);
1479 }
1480
1481 #[test]
1482 fn pipeline_emits_size_report() {
1483 let dir = create_fixture_dir("size");
1484 write_fixture(
1485 &dir,
1486 "material/mapping.toml",
1487 "play-pause = \"play_pause\"\n",
1488 );
1489 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1490
1491 let config: MasterConfig = toml::from_str(
1492 r#"
1493name = "test"
1494roles = ["play-pause"]
1495bundled-themes = ["material"]
1496"#,
1497 )
1498 .unwrap();
1499
1500 let result = run_pipeline(
1501 &[("test.toml".to_string(), config)],
1502 std::slice::from_ref(&dir),
1503 None,
1504 None,
1505 None,
1506 &[],
1507 );
1508
1509 assert!(result.errors.is_empty());
1510 let report = result
1511 .size_report
1512 .as_ref()
1513 .expect("should have size report");
1514 assert_eq!(report.role_count, 1);
1515 assert_eq!(report.bundled_theme_count, 1);
1516 assert_eq!(report.svg_count, 1);
1517 assert!(report.total_svg_bytes > 0, "SVGs should have nonzero size");
1518
1519 let _ = fs::remove_dir_all(&dir);
1520 }
1521
1522 #[test]
1523 fn pipeline_returns_errors_on_missing_role() {
1524 let dir = create_fixture_dir("missing_role");
1525 write_fixture(
1527 &dir,
1528 "material/mapping.toml",
1529 "play-pause = \"play_pause\"\n",
1530 );
1531 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1532
1533 let config: MasterConfig = toml::from_str(
1534 r#"
1535name = "test"
1536roles = ["play-pause", "skip-forward"]
1537bundled-themes = ["material"]
1538"#,
1539 )
1540 .unwrap();
1541
1542 let result = run_pipeline(
1543 &[("test.toml".to_string(), config)],
1544 std::slice::from_ref(&dir),
1545 None,
1546 None,
1547 None,
1548 &[],
1549 );
1550
1551 assert!(!result.errors.is_empty(), "should have errors");
1552 assert!(
1553 result
1554 .errors
1555 .iter()
1556 .any(|e| e.to_string().contains("skip-forward")),
1557 "should mention missing role"
1558 );
1559 assert!(result.code.is_empty(), "no code on errors");
1560
1561 let _ = fs::remove_dir_all(&dir);
1562 }
1563
1564 #[test]
1565 fn pipeline_returns_errors_on_missing_svg() {
1566 let dir = create_fixture_dir("missing_svg");
1567 write_fixture(
1568 &dir,
1569 "material/mapping.toml",
1570 r#"
1571play-pause = "play_pause"
1572skip-forward = "skip_next"
1573"#,
1574 );
1575 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1577
1578 let config: MasterConfig = toml::from_str(
1579 r#"
1580name = "test"
1581roles = ["play-pause", "skip-forward"]
1582bundled-themes = ["material"]
1583"#,
1584 )
1585 .unwrap();
1586
1587 let result = run_pipeline(
1588 &[("test.toml".to_string(), config)],
1589 std::slice::from_ref(&dir),
1590 None,
1591 None,
1592 None,
1593 &[],
1594 );
1595
1596 assert!(!result.errors.is_empty(), "should have errors");
1597 assert!(
1598 result
1599 .errors
1600 .iter()
1601 .any(|e| e.to_string().contains("skip_next.svg")),
1602 "should mention missing SVG"
1603 );
1604
1605 let _ = fs::remove_dir_all(&dir);
1606 }
1607
1608 #[test]
1609 fn pipeline_orphan_svgs_are_warnings() {
1610 let dir = create_fixture_dir("orphan_warn");
1611 write_fixture(
1612 &dir,
1613 "material/mapping.toml",
1614 "play-pause = \"play_pause\"\n",
1615 );
1616 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1617 write_fixture(&dir, "material/unused.svg", SVG_STUB);
1618
1619 let config: MasterConfig = toml::from_str(
1620 r#"
1621name = "test"
1622roles = ["play-pause"]
1623bundled-themes = ["material"]
1624"#,
1625 )
1626 .unwrap();
1627
1628 let result = run_pipeline(
1629 &[("test.toml".to_string(), config)],
1630 std::slice::from_ref(&dir),
1631 None,
1632 None,
1633 None,
1634 &[],
1635 );
1636
1637 assert!(result.errors.is_empty(), "orphans are not errors");
1638 assert!(!result.warnings.is_empty(), "should have orphan warning");
1639 assert!(result.warnings.iter().any(|w| w.contains("unused.svg")));
1640
1641 let _ = fs::remove_dir_all(&dir);
1642 }
1643
1644 #[test]
1647 fn merge_configs_combines_roles() {
1648 let config_a: MasterConfig = toml::from_str(
1649 r#"
1650name = "a"
1651roles = ["play-pause"]
1652bundled-themes = ["material"]
1653"#,
1654 )
1655 .unwrap();
1656 let config_b: MasterConfig = toml::from_str(
1657 r#"
1658name = "b"
1659roles = ["skip-forward"]
1660bundled-themes = ["material"]
1661system-themes = ["sf-symbols"]
1662"#,
1663 )
1664 .unwrap();
1665
1666 let configs = vec![
1667 ("a.toml".to_string(), config_a),
1668 ("b.toml".to_string(), config_b),
1669 ];
1670 let mut warnings = Vec::new();
1671 let merged = merge_configs(&configs, None, &mut warnings);
1672
1673 assert_eq!(merged.name, "a"); assert_eq!(merged.roles, vec!["play-pause", "skip-forward"]);
1675 assert_eq!(merged.bundled_themes, vec!["material"]); assert_eq!(merged.system_themes, vec!["sf-symbols"]);
1677 }
1678
1679 #[test]
1680 fn merge_configs_uses_enum_name_override() {
1681 let config: MasterConfig = toml::from_str(
1682 r#"
1683name = "original"
1684roles = ["x"]
1685"#,
1686 )
1687 .unwrap();
1688
1689 let configs = vec![("a.toml".to_string(), config)];
1690 let mut warnings = Vec::new();
1691 let merged = merge_configs(&configs, Some("MyIcons"), &mut warnings);
1692
1693 assert_eq!(merged.name, "MyIcons");
1694 }
1695
1696 #[test]
1699 fn pipeline_builder_merges_two_files() {
1700 let dir = create_fixture_dir("builder_merge");
1701 write_fixture(
1702 &dir,
1703 "material/mapping.toml",
1704 r#"
1705play-pause = "play_pause"
1706skip-forward = "skip_next"
1707"#,
1708 );
1709 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1710 write_fixture(&dir, "material/skip_next.svg", SVG_STUB);
1711
1712 let config_a: MasterConfig = toml::from_str(
1713 r#"
1714name = "icons-a"
1715roles = ["play-pause"]
1716bundled-themes = ["material"]
1717"#,
1718 )
1719 .unwrap();
1720 let config_b: MasterConfig = toml::from_str(
1721 r#"
1722name = "icons-b"
1723roles = ["skip-forward"]
1724bundled-themes = ["material"]
1725"#,
1726 )
1727 .unwrap();
1728
1729 let result = run_pipeline(
1730 &[
1731 ("a.toml".to_string(), config_a),
1732 ("b.toml".to_string(), config_b),
1733 ],
1734 &[dir.clone(), dir.clone()],
1735 Some("AllIcons"),
1736 None,
1737 None,
1738 &[],
1739 );
1740
1741 assert!(
1742 result.errors.is_empty(),
1743 "expected no errors: {:?}",
1744 result.errors
1745 );
1746 assert!(
1747 result.code.contains("pub enum AllIcons"),
1748 "should use override name"
1749 );
1750 assert!(result.code.contains("PlayPause"));
1751 assert!(result.code.contains("SkipForward"));
1752 assert_eq!(result.output_filename, "all_icons.rs");
1753
1754 let _ = fs::remove_dir_all(&dir);
1755 }
1756
1757 #[test]
1758 fn pipeline_builder_detects_duplicate_roles() {
1759 let dir = create_fixture_dir("builder_dup");
1760 write_fixture(
1761 &dir,
1762 "material/mapping.toml",
1763 "play-pause = \"play_pause\"\n",
1764 );
1765 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1766
1767 let config_a: MasterConfig = toml::from_str(
1768 r#"
1769name = "a"
1770roles = ["play-pause"]
1771bundled-themes = ["material"]
1772"#,
1773 )
1774 .unwrap();
1775 let config_b: MasterConfig = toml::from_str(
1776 r#"
1777name = "b"
1778roles = ["play-pause"]
1779bundled-themes = ["material"]
1780"#,
1781 )
1782 .unwrap();
1783
1784 let result = run_pipeline(
1785 &[
1786 ("a.toml".to_string(), config_a),
1787 ("b.toml".to_string(), config_b),
1788 ],
1789 &[dir.clone(), dir.clone()],
1790 None,
1791 None,
1792 None,
1793 &[],
1794 );
1795
1796 assert!(!result.errors.is_empty(), "should detect duplicate roles");
1797 assert!(
1798 result
1799 .errors
1800 .iter()
1801 .any(|e| e.to_string().contains("play-pause"))
1802 );
1803
1804 let _ = fs::remove_dir_all(&dir);
1805 }
1806
1807 #[test]
1808 fn pipeline_generates_relative_include_bytes_paths() {
1809 let tmpdir = create_fixture_dir("rel_paths");
1814 write_fixture(
1815 &tmpdir,
1816 "icons/material/mapping.toml",
1817 "play-pause = \"play_pause\"\n",
1818 );
1819 write_fixture(&tmpdir, "icons/material/play_pause.svg", SVG_STUB);
1820
1821 let config: MasterConfig = toml::from_str(
1822 r#"
1823name = "test"
1824roles = ["play-pause"]
1825bundled-themes = ["material"]
1826"#,
1827 )
1828 .unwrap();
1829
1830 let abs_base_dir = tmpdir.join("icons");
1832
1833 let result = run_pipeline(
1834 &[("icons/icons.toml".to_string(), config)],
1835 &[abs_base_dir],
1836 None,
1837 Some(&tmpdir), None,
1839 &[],
1840 );
1841
1842 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
1843 assert!(
1845 result.code.contains("\"/icons/material/play_pause.svg\""),
1846 "include_bytes path should use relative base_dir 'icons'. code:\n{}",
1847 result.code,
1848 );
1849 let tmpdir_str = tmpdir.to_string_lossy();
1851 assert!(
1852 !result.code.contains(&*tmpdir_str),
1853 "include_bytes path should NOT contain absolute tmpdir path",
1854 );
1855
1856 let _ = fs::remove_dir_all(&tmpdir);
1857 }
1858
1859 #[test]
1860 fn pipeline_no_system_svg_check() {
1861 let dir = create_fixture_dir("no_sys_svg");
1863 write_fixture(
1865 &dir,
1866 "sf-symbols/mapping.toml",
1867 r#"
1868play-pause = "play.fill"
1869"#,
1870 );
1871
1872 let config: MasterConfig = toml::from_str(
1873 r#"
1874name = "test"
1875roles = ["play-pause"]
1876system-themes = ["sf-symbols"]
1877"#,
1878 )
1879 .unwrap();
1880
1881 let result = run_pipeline(
1882 &[("test.toml".to_string(), config)],
1883 std::slice::from_ref(&dir),
1884 None,
1885 None,
1886 None,
1887 &[],
1888 );
1889
1890 assert!(
1891 result.errors.is_empty(),
1892 "system themes should not require SVGs: {:?}",
1893 result.errors
1894 );
1895
1896 let _ = fs::remove_dir_all(&dir);
1897 }
1898
1899 #[test]
1902 fn build_errors_display_format() {
1903 let errors = BuildErrors::new(vec![
1904 BuildError::MissingRole {
1905 role: "play-pause".into(),
1906 mapping_file: "mapping.toml".into(),
1907 },
1908 BuildError::MissingSvg {
1909 path: "play.svg".into(),
1910 },
1911 ]);
1912 let msg = errors.to_string();
1913 assert!(msg.contains("2 build error(s):"));
1914 assert!(msg.contains("play-pause"));
1915 assert!(msg.contains("play.svg"));
1916 }
1917
1918 #[test]
1921 fn build_error_invalid_identifier_format() {
1922 let err = BuildError::InvalidIdentifier {
1923 name: "---".into(),
1924 reason: "PascalCase conversion produces an empty string".into(),
1925 };
1926 let msg = err.to_string();
1927 assert!(msg.contains("---"), "should contain the name");
1928 assert!(msg.contains("empty"), "should contain the reason");
1929 }
1930
1931 #[test]
1932 fn build_error_identifier_collision_format() {
1933 let err = BuildError::IdentifierCollision {
1934 role_a: "play_pause".into(),
1935 role_b: "play-pause".into(),
1936 pascal: "PlayPause".into(),
1937 source_file: None,
1938 };
1939 let msg = err.to_string();
1940 assert!(msg.contains("play_pause"), "should mention first role");
1941 assert!(msg.contains("play-pause"), "should mention second role");
1942 assert!(msg.contains("PlayPause"), "should mention PascalCase");
1943 }
1944
1945 #[test]
1946 fn build_error_theme_overlap_format() {
1947 let err = BuildError::ThemeOverlap {
1948 theme: "material".into(),
1949 };
1950 let msg = err.to_string();
1951 assert!(msg.contains("material"), "should mention theme");
1952 assert!(msg.contains("bundled"), "should mention bundled");
1953 assert!(msg.contains("system"), "should mention system");
1954 }
1955
1956 #[test]
1957 fn build_error_duplicate_role_in_file_format() {
1958 let err = BuildError::DuplicateRoleInFile {
1959 role: "play-pause".into(),
1960 file: "icons.toml".into(),
1961 };
1962 let msg = err.to_string();
1963 assert!(msg.contains("play-pause"), "should mention role");
1964 assert!(msg.contains("icons.toml"), "should mention file");
1965 }
1966
1967 #[test]
1970 fn pipeline_detects_theme_overlap() {
1971 let dir = create_fixture_dir("theme_overlap");
1972 write_fixture(
1973 &dir,
1974 "material/mapping.toml",
1975 "play-pause = \"play_pause\"\n",
1976 );
1977 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
1978
1979 let config: MasterConfig = toml::from_str(
1980 r#"
1981name = "test"
1982roles = ["play-pause"]
1983bundled-themes = ["material"]
1984system-themes = ["material"]
1985"#,
1986 )
1987 .unwrap();
1988
1989 let result = run_pipeline(
1990 &[("test.toml".to_string(), config)],
1991 std::slice::from_ref(&dir),
1992 None,
1993 None,
1994 None,
1995 &[],
1996 );
1997
1998 assert!(!result.errors.is_empty(), "should detect theme overlap");
1999 assert!(
2000 result.errors.iter().any(|e| matches!(
2001 e,
2002 BuildError::ThemeOverlap { theme } if theme == "material"
2003 )),
2004 "should have ThemeOverlap error for 'material': {:?}",
2005 result.errors
2006 );
2007
2008 let _ = fs::remove_dir_all(&dir);
2009 }
2010
2011 #[test]
2012 fn pipeline_detects_identifier_collision() {
2013 let dir = create_fixture_dir("id_collision");
2014 write_fixture(
2015 &dir,
2016 "material/mapping.toml",
2017 "play_pause = \"pp\"\nplay-pause = \"pp2\"\n",
2018 );
2019 write_fixture(&dir, "material/pp.svg", SVG_STUB);
2020
2021 let config: MasterConfig = toml::from_str(
2022 r#"
2023name = "test"
2024roles = ["play_pause", "play-pause"]
2025bundled-themes = ["material"]
2026"#,
2027 )
2028 .unwrap();
2029
2030 let result = run_pipeline(
2031 &[("test.toml".to_string(), config)],
2032 std::slice::from_ref(&dir),
2033 None,
2034 None,
2035 None,
2036 &[],
2037 );
2038
2039 assert!(
2040 result.errors.iter().any(|e| matches!(
2041 e,
2042 BuildError::IdentifierCollision { pascal, .. } if pascal == "PlayPause"
2043 )),
2044 "should detect PascalCase collision: {:?}",
2045 result.errors
2046 );
2047
2048 let _ = fs::remove_dir_all(&dir);
2049 }
2050
2051 #[test]
2052 fn pipeline_detects_invalid_identifier() {
2053 let dir = create_fixture_dir("id_invalid");
2054 write_fixture(&dir, "material/mapping.toml", "self = \"self_icon\"\n");
2055 write_fixture(&dir, "material/self_icon.svg", SVG_STUB);
2056
2057 let config: MasterConfig = toml::from_str(
2058 r#"
2059name = "test"
2060roles = ["self"]
2061bundled-themes = ["material"]
2062"#,
2063 )
2064 .unwrap();
2065
2066 let result = run_pipeline(
2067 &[("test.toml".to_string(), config)],
2068 std::slice::from_ref(&dir),
2069 None,
2070 None,
2071 None,
2072 &[],
2073 );
2074
2075 assert!(
2076 result.errors.iter().any(|e| matches!(
2077 e,
2078 BuildError::InvalidIdentifier { name, .. } if name == "self"
2079 )),
2080 "should detect keyword identifier: {:?}",
2081 result.errors
2082 );
2083
2084 let _ = fs::remove_dir_all(&dir);
2085 }
2086
2087 #[test]
2088 fn pipeline_detects_duplicate_role_in_file() {
2089 let dir = create_fixture_dir("dup_in_file");
2090 write_fixture(
2091 &dir,
2092 "material/mapping.toml",
2093 "play-pause = \"play_pause\"\n",
2094 );
2095 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
2096
2097 let config = MasterConfig {
2100 name: "test".to_string(),
2101 roles: vec!["play-pause".to_string(), "play-pause".to_string()],
2102 bundled_themes: vec!["material".to_string()],
2103 system_themes: Vec::new(),
2104 };
2105
2106 let result = run_pipeline(
2107 &[("test.toml".to_string(), config)],
2108 std::slice::from_ref(&dir),
2109 None,
2110 None,
2111 None,
2112 &[],
2113 );
2114
2115 assert!(
2116 result.errors.iter().any(|e| matches!(
2117 e,
2118 BuildError::DuplicateRoleInFile { role, file }
2119 if role == "play-pause" && file == "test.toml"
2120 )),
2121 "should detect duplicate role in file: {:?}",
2122 result.errors
2123 );
2124
2125 let _ = fs::remove_dir_all(&dir);
2126 }
2127
2128 #[test]
2131 fn pipeline_bundled_de_aware_produces_warning() {
2132 let dir = create_fixture_dir("bundled_de_aware");
2133 write_fixture(
2135 &dir,
2136 "material/mapping.toml",
2137 r#"play-pause = { kde = "media-playback-start", default = "play_pause" }"#,
2138 );
2139 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
2140
2141 let config: MasterConfig = toml::from_str(
2142 r#"
2143name = "test-icon"
2144roles = ["play-pause"]
2145bundled-themes = ["material"]
2146"#,
2147 )
2148 .unwrap();
2149
2150 let result = run_pipeline(
2151 &[("test.toml".to_string(), config)],
2152 std::slice::from_ref(&dir),
2153 None,
2154 None,
2155 None,
2156 &[],
2157 );
2158
2159 assert!(
2161 !result.errors.is_empty(),
2162 "bundled DE-aware should be an error"
2163 );
2164 assert!(
2165 result.errors.iter().any(|e| matches!(
2166 e,
2167 BuildError::BundledDeAware { theme, role }
2168 if theme == "material" && role == "play-pause"
2169 )),
2170 "should have BundledDeAware error for material/play-pause: {:?}",
2171 result.errors
2172 );
2173
2174 let _ = fs::remove_dir_all(&dir);
2175 }
2176
2177 #[test]
2178 fn pipeline_system_de_aware_no_bundled_warning() {
2179 let dir = create_fixture_dir("system_de_aware");
2180 write_fixture(
2182 &dir,
2183 "freedesktop/mapping.toml",
2184 r#"play-pause = { kde = "media-playback-start", default = "play" }"#,
2185 );
2186
2187 let config: MasterConfig = toml::from_str(
2188 r#"
2189name = "test-icon"
2190roles = ["play-pause"]
2191system-themes = ["freedesktop"]
2192"#,
2193 )
2194 .unwrap();
2195
2196 let result = run_pipeline(
2197 &[("test.toml".to_string(), config)],
2198 std::slice::from_ref(&dir),
2199 None,
2200 None,
2201 None,
2202 &[],
2203 );
2204
2205 assert!(
2206 result.errors.is_empty(),
2207 "system DE-aware should not be an error: {:?}",
2208 result.errors
2209 );
2210 assert!(
2211 !result
2212 .warnings
2213 .iter()
2214 .any(|w| w.contains("only the default SVG will be embedded")),
2215 "system themes should NOT produce bundled DE-aware warning. warnings: {:?}",
2216 result.warnings
2217 );
2218
2219 let _ = fs::remove_dir_all(&dir);
2220 }
2221
2222 #[test]
2225 fn pipeline_custom_crate_path() {
2226 let dir = create_fixture_dir("crate_path");
2227 write_fixture(
2228 &dir,
2229 "material/mapping.toml",
2230 "play-pause = \"play_pause\"\n",
2231 );
2232 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
2233
2234 let config: MasterConfig = toml::from_str(
2235 r#"
2236name = "test-icon"
2237roles = ["play-pause"]
2238bundled-themes = ["material"]
2239"#,
2240 )
2241 .unwrap();
2242
2243 let result = run_pipeline(
2244 &[("test.toml".to_string(), config)],
2245 std::slice::from_ref(&dir),
2246 None,
2247 None,
2248 Some("my_crate::native_theme"),
2249 &[],
2250 );
2251
2252 assert!(
2253 result.errors.is_empty(),
2254 "custom crate path should not cause errors: {:?}",
2255 result.errors
2256 );
2257 assert!(
2258 result
2259 .code
2260 .contains("impl my_crate::native_theme::IconProvider"),
2261 "should use custom crate path in impl. code:\n{}",
2262 result.code
2263 );
2264 assert!(
2265 !result.code.contains("extern crate"),
2266 "custom crate path should not emit extern crate. code:\n{}",
2267 result.code
2268 );
2269
2270 let _ = fs::remove_dir_all(&dir);
2271 }
2272
2273 #[test]
2274 fn pipeline_default_crate_path_emits_extern_crate() {
2275 let dir = create_fixture_dir("default_crate_path");
2276 write_fixture(
2277 &dir,
2278 "material/mapping.toml",
2279 "play-pause = \"play_pause\"\n",
2280 );
2281 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
2282
2283 let config: MasterConfig = toml::from_str(
2284 r#"
2285name = "test-icon"
2286roles = ["play-pause"]
2287bundled-themes = ["material"]
2288"#,
2289 )
2290 .unwrap();
2291
2292 let result = run_pipeline(
2293 &[("test.toml".to_string(), config)],
2294 std::slice::from_ref(&dir),
2295 None,
2296 None,
2297 None,
2298 &[],
2299 );
2300
2301 assert!(
2302 result.errors.is_empty(),
2303 "default crate path should not cause errors: {:?}",
2304 result.errors
2305 );
2306 assert!(
2307 result.code.contains("extern crate native_theme;"),
2308 "default crate path should emit extern crate. code:\n{}",
2309 result.code
2310 );
2311
2312 let _ = fs::remove_dir_all(&dir);
2313 }
2314
2315 fn generate_with_dummy_source(builder: IconGenerator) -> Result<GenerateOutput, BuildErrors> {
2321 builder
2323 .source("/nonexistent/icons.toml")
2324 .output_dir("/tmp/native_theme_test_dummy")
2325 .generate()
2326 }
2327
2328 #[test]
2329 fn derive_rejects_empty_string() {
2330 let result = generate_with_dummy_source(IconGenerator::new().derive(""));
2331 let errors = result.unwrap_err();
2332 assert!(
2333 errors.errors().iter().any(|e| matches!(
2334 e,
2335 BuildError::InvalidDerive { name, .. } if name.is_empty()
2336 )),
2337 "should reject empty derive: {errors:?}"
2338 );
2339 }
2340
2341 #[test]
2342 fn derive_rejects_whitespace() {
2343 let result = generate_with_dummy_source(IconGenerator::new().derive("Ord PartialOrd"));
2344 let errors = result.unwrap_err();
2345 assert!(
2346 errors.errors().iter().any(|e| matches!(
2347 e,
2348 BuildError::InvalidDerive { name, .. } if name == "Ord PartialOrd"
2349 )),
2350 "should reject whitespace derive: {errors:?}"
2351 );
2352 }
2353
2354 #[test]
2355 fn derive_rejects_tab() {
2356 let result = generate_with_dummy_source(IconGenerator::new().derive("Ord\t"));
2357 let errors = result.unwrap_err();
2358 assert!(
2359 errors
2360 .errors()
2361 .iter()
2362 .any(|e| matches!(e, BuildError::InvalidDerive { .. })),
2363 "should reject tab derive: {errors:?}"
2364 );
2365 }
2366
2367 #[test]
2368 fn derive_accepts_valid_name() {
2369 let r1 = generate_with_dummy_source(IconGenerator::new().derive("Ord"));
2371 if let Err(ref e) = r1 {
2372 assert!(
2373 !e.errors()
2374 .iter()
2375 .any(|e| matches!(e, BuildError::InvalidDerive { .. })),
2376 "Ord should be valid: {e:?}"
2377 );
2378 }
2379 let r2 = generate_with_dummy_source(IconGenerator::new().derive("serde::Serialize"));
2380 if let Err(ref e) = r2 {
2381 assert!(
2382 !e.errors()
2383 .iter()
2384 .any(|e| matches!(e, BuildError::InvalidDerive { .. })),
2385 "serde::Serialize should be valid: {e:?}"
2386 );
2387 }
2388 }
2389
2390 #[test]
2391 fn crate_path_rejects_empty_string() {
2392 let result = generate_with_dummy_source(IconGenerator::new().crate_path(""));
2393 let errors = result.unwrap_err();
2394 assert!(
2395 errors.errors().iter().any(|e| matches!(
2396 e,
2397 BuildError::InvalidCratePath { path, .. } if path.is_empty()
2398 )),
2399 "should reject empty crate_path: {errors:?}"
2400 );
2401 }
2402
2403 #[test]
2404 fn crate_path_rejects_spaces() {
2405 let result = generate_with_dummy_source(IconGenerator::new().crate_path("foo bar"));
2406 let errors = result.unwrap_err();
2407 assert!(
2408 errors.errors().iter().any(|e| matches!(
2409 e,
2410 BuildError::InvalidCratePath { path, .. } if path == "foo bar"
2411 )),
2412 "should reject spaces in crate_path: {errors:?}"
2413 );
2414 }
2415
2416 #[test]
2417 fn crate_path_accepts_valid_path() {
2418 let r1 = generate_with_dummy_source(IconGenerator::new().crate_path("native_theme"));
2419 if let Err(ref e) = r1 {
2420 assert!(
2421 !e.errors()
2422 .iter()
2423 .any(|e| matches!(e, BuildError::InvalidCratePath { .. })),
2424 "native_theme should be valid: {e:?}"
2425 );
2426 }
2427 let r2 =
2428 generate_with_dummy_source(IconGenerator::new().crate_path("my_crate::native_theme"));
2429 if let Err(ref e) = r2 {
2430 assert!(
2431 !e.errors()
2432 .iter()
2433 .any(|e| matches!(e, BuildError::InvalidCratePath { .. })),
2434 "my_crate::native_theme should be valid: {e:?}"
2435 );
2436 }
2437 }
2438
2439 #[test]
2442 fn validate_rust_path_valid() {
2443 assert!(validate_rust_path("native_theme").is_none());
2444 assert!(validate_rust_path("my_crate::native_theme").is_none());
2445 assert!(validate_rust_path("a::b::c").is_none());
2446 assert!(validate_rust_path("_private").is_none());
2447 }
2448
2449 #[test]
2450 fn validate_rust_path_rejects_empty() {
2451 assert!(validate_rust_path("").is_some());
2452 }
2453
2454 #[test]
2455 fn validate_rust_path_rejects_empty_segment() {
2456 assert!(validate_rust_path("::foo").is_some());
2457 assert!(validate_rust_path("foo::").is_some());
2458 assert!(validate_rust_path("foo::::bar").is_some());
2459 }
2460
2461 #[test]
2462 fn validate_rust_path_rejects_digit_start() {
2463 assert!(validate_rust_path("3crate").is_some());
2464 assert!(validate_rust_path("foo::3bar").is_some());
2465 }
2466
2467 #[test]
2468 fn validate_rust_path_rejects_special_chars() {
2469 assert!(validate_rust_path("foo bar").is_some());
2470 assert!(validate_rust_path("foo-bar").is_some());
2471 assert!(validate_rust_path("foo.bar").is_some());
2472 }
2473
2474 #[test]
2477 fn build_error_duplicate_theme_format() {
2478 let err = BuildError::DuplicateTheme {
2479 theme: "material".into(),
2480 list: "bundled-themes".into(),
2481 };
2482 let msg = err.to_string();
2483 assert!(msg.contains("material"), "should contain theme name");
2484 assert!(msg.contains("bundled-themes"), "should contain list name");
2485 }
2486
2487 #[test]
2488 fn build_error_invalid_icon_name_format() {
2489 let err = BuildError::InvalidIconName {
2490 name: "bad\x00name".into(),
2491 role: "play-pause".into(),
2492 mapping_file: "mapping.toml".into(),
2493 offending: Some('\x00'),
2494 };
2495 let msg = err.to_string();
2496 assert!(msg.contains("play-pause"), "should contain role name");
2497 assert!(msg.contains("mapping.toml"), "should contain file path");
2498 assert!(
2499 msg.contains("\\u{0000}"),
2500 "should show offending character: {msg}"
2501 );
2502 }
2503
2504 #[test]
2507 fn build_error_bundled_de_aware_format() {
2508 let err = BuildError::BundledDeAware {
2509 theme: "material".into(),
2510 role: "play-pause".into(),
2511 };
2512 let msg = err.to_string();
2513 assert!(msg.contains("material"), "should contain theme name");
2514 assert!(msg.contains("play-pause"), "should contain role name");
2515 assert!(
2516 msg.contains("system theme"),
2517 "should suggest using system theme"
2518 );
2519 }
2520
2521 #[test]
2522 fn build_error_invalid_crate_path_format() {
2523 let err = BuildError::InvalidCratePath {
2524 path: "foo bar".into(),
2525 reason: "contains space".into(),
2526 };
2527 let msg = err.to_string();
2528 assert!(msg.contains("foo bar"), "should contain the path");
2529 assert!(msg.contains("contains space"), "should contain reason");
2530 }
2531
2532 #[test]
2533 fn build_error_invalid_derive_format() {
2534 let err = BuildError::InvalidDerive {
2535 name: "".into(),
2536 reason: "must be non-empty".into(),
2537 };
2538 let msg = err.to_string();
2539 assert!(msg.contains("must be non-empty"), "should contain reason");
2540 }
2541
2542 #[test]
2545 fn pipeline_empty_roles_list() {
2546 let dir = create_fixture_dir("empty_roles");
2547 write_fixture(&dir, "material/mapping.toml", "");
2548
2549 let config: MasterConfig = toml::from_str(
2550 r#"
2551name = "test"
2552roles = []
2553bundled-themes = ["material"]
2554"#,
2555 )
2556 .unwrap();
2557
2558 let result = run_pipeline(
2559 &[("test.toml".to_string(), config)],
2560 std::slice::from_ref(&dir),
2561 None,
2562 None,
2563 None,
2564 &[],
2565 );
2566
2567 assert!(
2568 result.errors.is_empty(),
2569 "empty roles should not produce errors: {:?}",
2570 result.errors
2571 );
2572 assert!(
2573 result
2574 .warnings
2575 .iter()
2576 .any(|w| w.contains("roles list is empty")),
2577 "should warn about empty roles: {:?}",
2578 result.warnings
2579 );
2580 assert!(result.code.contains("pub enum Test {"));
2582 assert!(result.code.contains("#[non_exhaustive]"));
2583
2584 let _ = fs::remove_dir_all(&dir);
2585 }
2586
2587 #[test]
2590 fn pipeline_multiple_de_overrides() {
2591 let dir = create_fixture_dir("multi_de");
2592 write_fixture(
2593 &dir,
2594 "freedesktop/mapping.toml",
2595 r#"reveal = { kde = "view-kde", gnome = "view-gnome", xfce = "view-xfce", default = "view-default" }"#,
2596 );
2597
2598 let config: MasterConfig = toml::from_str(
2599 r#"
2600name = "test"
2601roles = ["reveal"]
2602system-themes = ["freedesktop"]
2603"#,
2604 )
2605 .unwrap();
2606
2607 let result = run_pipeline(
2608 &[("test.toml".to_string(), config)],
2609 std::slice::from_ref(&dir),
2610 None,
2611 None,
2612 None,
2613 &[],
2614 );
2615
2616 assert!(
2617 result.errors.is_empty(),
2618 "multiple DE overrides should not produce errors: {:?}",
2619 result.errors
2620 );
2621 assert!(
2623 result
2624 .code
2625 .contains("LinuxDesktop::Kde => Some(\"view-kde\")"),
2626 "should have KDE arm. code:\n{}",
2627 result.code
2628 );
2629 assert!(
2630 result
2631 .code
2632 .contains("LinuxDesktop::Gnome => Some(\"view-gnome\")"),
2633 "should have GNOME arm. code:\n{}",
2634 result.code
2635 );
2636 assert!(
2637 result
2638 .code
2639 .contains("LinuxDesktop::Xfce => Some(\"view-xfce\")"),
2640 "should have XFCE arm. code:\n{}",
2641 result.code
2642 );
2643 assert!(
2644 result.code.contains("_ => Some(\"view-default\")"),
2645 "should have default arm. code:\n{}",
2646 result.code
2647 );
2648
2649 let _ = fs::remove_dir_all(&dir);
2650 }
2651
2652 #[test]
2655 fn pipeline_empty_themes_warning() {
2656 let config: MasterConfig = toml::from_str(
2657 r#"
2658name = "test"
2659roles = ["play-pause"]
2660"#,
2661 )
2662 .unwrap();
2663
2664 let dir = create_fixture_dir("empty_themes");
2665
2666 let result = run_pipeline(
2667 &[("test.toml".to_string(), config)],
2668 std::slice::from_ref(&dir),
2669 None,
2670 None,
2671 None,
2672 &[],
2673 );
2674
2675 assert!(
2676 result.errors.is_empty(),
2677 "empty themes should not be an error: {:?}",
2678 result.errors
2679 );
2680 assert!(
2681 result
2682 .warnings
2683 .iter()
2684 .any(|w| w.contains("no bundled-themes or system-themes")),
2685 "should warn about no themes. warnings: {:?}",
2686 result.warnings
2687 );
2688
2689 let _ = fs::remove_dir_all(&dir);
2690 }
2691
2692 #[test]
2695 fn pipeline_de_specific_svgs_not_required() {
2696 let dir = create_fixture_dir("de_svgs_not_required");
2697 write_fixture(
2700 &dir,
2701 "freedesktop/mapping.toml",
2702 r#"play-pause = { kde = "media-playback-start", default = "play" }"#,
2703 );
2704
2705 let config: MasterConfig = toml::from_str(
2706 r#"
2707name = "test"
2708roles = ["play-pause"]
2709system-themes = ["freedesktop"]
2710"#,
2711 )
2712 .unwrap();
2713
2714 let result = run_pipeline(
2715 &[("test.toml".to_string(), config)],
2716 std::slice::from_ref(&dir),
2717 None,
2718 None,
2719 None,
2720 &[],
2721 );
2722
2723 assert!(
2725 !result
2726 .errors
2727 .iter()
2728 .any(|e| matches!(e, BuildError::MissingSvg { .. })),
2729 "should not require SVGs for system theme DE-specific names: {:?}",
2730 result.errors
2731 );
2732
2733 let _ = fs::remove_dir_all(&dir);
2734 }
2735
2736 #[test]
2739 fn pipeline_backslash_path_normalized() {
2740 let dir = create_fixture_dir("backslash_path");
2741 write_fixture(
2742 &dir,
2743 "material/mapping.toml",
2744 "play-pause = \"play_pause\"\n",
2745 );
2746 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
2747
2748 let config: MasterConfig = toml::from_str(
2749 r#"
2750name = "test"
2751roles = ["play-pause"]
2752bundled-themes = ["material"]
2753"#,
2754 )
2755 .unwrap();
2756
2757 let base_with_backslash = PathBuf::from(dir.to_string_lossy().replace('/', "\\"));
2759
2760 let result = run_pipeline(
2761 &[("test.toml".to_string(), config)],
2762 std::slice::from_ref(&dir),
2764 None,
2765 None,
2766 None,
2767 &[],
2768 );
2769
2770 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
2771 let include_bytes_paths: Vec<&str> = result
2773 .code
2774 .lines()
2775 .filter(|l| l.contains("include_bytes!"))
2776 .collect();
2777 for path_line in &include_bytes_paths {
2778 assert!(
2781 !path_line.contains("\\\\"),
2782 "include_bytes path should use forward slashes: {path_line}"
2783 );
2784 }
2785
2786 let _ = fs::remove_dir_all(&base_with_backslash);
2787 let _ = fs::remove_dir_all(&dir);
2788 }
2789
2790 #[test]
2793 fn pipeline_enum_name_override_normalized() {
2794 let dir = create_fixture_dir("enum_name_norm");
2795 write_fixture(
2796 &dir,
2797 "material/mapping.toml",
2798 "play-pause = \"play_pause\"\n",
2799 );
2800 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
2801
2802 let config: MasterConfig = toml::from_str(
2803 r#"
2804name = "original"
2805roles = ["play-pause"]
2806bundled-themes = ["material"]
2807"#,
2808 )
2809 .unwrap();
2810
2811 let result = run_pipeline(
2812 &[("test.toml".to_string(), config)],
2813 std::slice::from_ref(&dir),
2814 Some("my-custom-icons"),
2815 None,
2816 None,
2817 &[],
2818 );
2819
2820 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
2821 assert!(
2822 result.code.contains("pub enum MyCustomIcons"),
2823 "enum_name should be PascalCase of 'my-custom-icons'. code:\n{}",
2824 result.code
2825 );
2826 assert_eq!(
2827 result.output_filename, "my_custom_icons.rs",
2828 "output filename should be snake_case"
2829 );
2830
2831 let _ = fs::remove_dir_all(&dir);
2832 }
2833
2834 #[test]
2837 fn pipeline_rejects_path_traversal_in_icon_names() {
2838 let dir = create_fixture_dir("path_traversal");
2839 write_fixture(
2840 &dir,
2841 "material/mapping.toml",
2842 "play-pause = \"../../etc/passwd\"\n",
2843 );
2844
2845 let config: MasterConfig = toml::from_str(
2846 r#"
2847name = "test"
2848roles = ["play-pause"]
2849bundled-themes = ["material"]
2850"#,
2851 )
2852 .unwrap();
2853
2854 let result = run_pipeline(
2855 &[("test.toml".to_string(), config)],
2856 std::slice::from_ref(&dir),
2857 None,
2858 None,
2859 None,
2860 &[],
2861 );
2862
2863 assert!(
2864 result.errors.iter().any(|e| matches!(
2865 e,
2866 BuildError::InvalidIconName { name, .. } if name.contains("..")
2867 )),
2868 "should reject path traversal in icon names: {:?}",
2869 result.errors
2870 );
2871
2872 let _ = fs::remove_dir_all(&dir);
2873 }
2874
2875 #[test]
2876 fn pipeline_rejects_slash_in_icon_names() {
2877 let dir = create_fixture_dir("slash_icon");
2878 write_fixture(&dir, "material/mapping.toml", "play-pause = \"sub/dir\"\n");
2879
2880 let config: MasterConfig = toml::from_str(
2881 r#"
2882name = "test"
2883roles = ["play-pause"]
2884bundled-themes = ["material"]
2885"#,
2886 )
2887 .unwrap();
2888
2889 let result = run_pipeline(
2890 &[("test.toml".to_string(), config)],
2891 std::slice::from_ref(&dir),
2892 None,
2893 None,
2894 None,
2895 &[],
2896 );
2897
2898 assert!(
2899 result.errors.iter().any(|e| matches!(
2900 e,
2901 BuildError::InvalidIconName { name, .. } if name == "sub/dir"
2902 )),
2903 "should reject slash in icon names: {:?}",
2904 result.errors
2905 );
2906
2907 let _ = fs::remove_dir_all(&dir);
2908 }
2909
2910 #[test]
2913 fn pipeline_collapses_redundant_de_aware() {
2914 let dir = create_fixture_dir("collapse_de");
2915 write_fixture(
2916 &dir,
2917 "freedesktop/mapping.toml",
2918 r#"play-pause = { kde = "play", gnome = "play", default = "play" }"#,
2919 );
2920
2921 let config: MasterConfig = toml::from_str(
2922 r#"
2923name = "test"
2924roles = ["play-pause"]
2925system-themes = ["freedesktop"]
2926"#,
2927 )
2928 .unwrap();
2929
2930 let result = run_pipeline(
2931 &[("test.toml".to_string(), config)],
2932 std::slice::from_ref(&dir),
2933 None,
2934 None,
2935 None,
2936 &[],
2937 );
2938
2939 assert!(result.errors.is_empty(), "errors: {:?}", result.errors);
2940 assert!(
2942 !result.code.contains("detect_linux_de"),
2943 "all-same DE-aware should collapse to simple arm. code:\n{}",
2944 result.code
2945 );
2946 assert!(
2947 result.code.contains("Some(\"play\")"),
2948 "should contain simple play arm. code:\n{}",
2949 result.code
2950 );
2951
2952 let _ = fs::remove_dir_all(&dir);
2953 }
2954
2955 #[test]
2958 fn pipeline_rejects_invisible_unicode_in_icon_names() {
2959 let dir = create_fixture_dir("invisible_unicode");
2960 write_fixture(
2961 &dir,
2962 "material/mapping.toml",
2963 "play-pause = \"play\u{200B}pause\"\n",
2964 );
2965
2966 let config: MasterConfig = toml::from_str(
2967 r#"
2968name = "test"
2969roles = ["play-pause"]
2970bundled-themes = ["material"]
2971"#,
2972 )
2973 .unwrap();
2974
2975 let result = run_pipeline(
2976 &[("test.toml".to_string(), config)],
2977 std::slice::from_ref(&dir),
2978 None,
2979 None,
2980 None,
2981 &[],
2982 );
2983
2984 assert!(
2985 result
2986 .errors
2987 .iter()
2988 .any(|e| matches!(e, BuildError::InvalidIconName { .. })),
2989 "should reject invisible Unicode in icon names: {:?}",
2990 result.errors
2991 );
2992
2993 let _ = fs::remove_dir_all(&dir);
2994 }
2995
2996 #[test]
2999 fn pipeline_cross_file_theme_overlap() {
3000 let dir = create_fixture_dir("cross_overlap");
3001 write_fixture(
3002 &dir,
3003 "material/mapping.toml",
3004 "play-pause = \"play_pause\"\nskip-forward = \"skip_next\"\n",
3005 );
3006 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
3007 write_fixture(&dir, "material/skip_next.svg", SVG_STUB);
3008
3009 let config_a: MasterConfig = toml::from_str(
3010 r#"
3011name = "a"
3012roles = ["play-pause"]
3013bundled-themes = ["material"]
3014"#,
3015 )
3016 .unwrap();
3017 let config_b: MasterConfig = toml::from_str(
3018 r#"
3019name = "b"
3020roles = ["skip-forward"]
3021system-themes = ["material"]
3022"#,
3023 )
3024 .unwrap();
3025
3026 let result = run_pipeline(
3027 &[
3028 ("a.toml".to_string(), config_a),
3029 ("b.toml".to_string(), config_b),
3030 ],
3031 &[dir.clone(), dir.clone()],
3032 Some("AllIcons"),
3033 None,
3034 None,
3035 &[],
3036 );
3037
3038 assert!(
3039 result.errors.iter().any(|e| matches!(
3040 e,
3041 BuildError::ThemeOverlap { theme } if theme == "material"
3042 )),
3043 "should detect cross-file theme overlap: {:?}",
3044 result.errors
3045 );
3046
3047 let _ = fs::remove_dir_all(&dir);
3048 }
3049
3050 #[test]
3053 fn pipeline_warns_variant_vs_enum_name_collision() {
3054 let dir = create_fixture_dir("variant_enum_collision");
3055 write_fixture(
3056 &dir,
3057 "material/mapping.toml",
3058 "play-pause = \"play_pause\"\n",
3059 );
3060 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
3061
3062 let config: MasterConfig = toml::from_str(
3063 r#"
3064name = "play-pause"
3065roles = ["play-pause"]
3066bundled-themes = ["material"]
3067"#,
3068 )
3069 .unwrap();
3070
3071 let result = run_pipeline(
3072 &[("test.toml".to_string(), config)],
3073 std::slice::from_ref(&dir),
3074 None,
3075 None,
3076 None,
3077 &[],
3078 );
3079
3080 assert!(
3081 result
3082 .warnings
3083 .iter()
3084 .any(|w| w.contains("same PascalCase name")),
3085 "should warn about variant/enum name collision. warnings: {:?}",
3086 result.warnings
3087 );
3088
3089 let _ = fs::remove_dir_all(&dir);
3090 }
3091
3092 #[test]
3095 fn pipeline_warns_on_name_normalization() {
3096 let dir = create_fixture_dir("name_norm");
3097 write_fixture(
3098 &dir,
3099 "material/mapping.toml",
3100 "play-pause = \"play_pause\"\n",
3101 );
3102 write_fixture(&dir, "material/play_pause.svg", SVG_STUB);
3103
3104 let config: MasterConfig = toml::from_str(
3105 r#"
3106name = "my-app-icon"
3107roles = ["play-pause"]
3108bundled-themes = ["material"]
3109"#,
3110 )
3111 .unwrap();
3112
3113 let result = run_pipeline(
3114 &[("test.toml".to_string(), config)],
3115 std::slice::from_ref(&dir),
3116 None,
3117 None,
3118 None,
3119 &[],
3120 );
3121
3122 assert!(
3123 result
3124 .warnings
3125 .iter()
3126 .any(|w| { w.contains("my-app-icon") && w.contains("MyAppIcon") }),
3127 "should warn about name normalization. warnings: {:?}",
3128 result.warnings
3129 );
3130
3131 let _ = fs::remove_dir_all(&dir);
3132 }
3133
3134 #[test]
3137 fn pipeline_rejects_empty_output_filename() {
3138 let dir = create_fixture_dir("empty_filename");
3139
3140 let config: MasterConfig = toml::from_str(
3141 r#"
3142name = "---"
3143roles = ["play-pause"]
3144"#,
3145 )
3146 .unwrap();
3147
3148 let result = run_pipeline(
3149 &[("test.toml".to_string(), config)],
3150 std::slice::from_ref(&dir),
3151 None,
3152 None,
3153 None,
3154 &[],
3155 );
3156
3157 assert!(
3158 result.errors.iter().any(|e| matches!(
3159 e,
3160 BuildError::InvalidIdentifier { name, reason }
3161 if name == "---" && reason.contains("empty")
3162 )),
3163 "should reject name that produces empty filename: {:?}",
3164 result.errors
3165 );
3166
3167 let _ = fs::remove_dir_all(&dir);
3168 }
3169
3170 #[test]
3173 fn pipeline_missing_theme_directory() {
3174 let dir = create_fixture_dir("missing_theme_dir");
3175 let config: MasterConfig = toml::from_str(
3178 r#"
3179name = "test"
3180roles = ["play-pause"]
3181bundled-themes = ["material"]
3182"#,
3183 )
3184 .unwrap();
3185
3186 let result = run_pipeline(
3187 &[("test.toml".to_string(), config)],
3188 std::slice::from_ref(&dir),
3189 None,
3190 None,
3191 None,
3192 &[],
3193 );
3194
3195 assert!(
3196 result.errors.iter().any(|e| {
3197 matches!(e, BuildError::IoRead { reason, .. } if reason.contains("theme directory not found"))
3198 }),
3199 "should report missing theme directory: {:?}",
3200 result.errors
3201 );
3202
3203 let _ = fs::remove_dir_all(&dir);
3204 }
3205}