1use crate::{
4 buffer::add_message,
5 core::{Filesystem, GrimoireCssError, ScrollDefinition},
6};
7use glob::glob;
8use serde::{Deserialize, Serialize};
9use std::{
10 collections::{HashMap, HashSet},
11 fs,
12 path::Path,
13};
14
15#[derive(Debug, Clone)]
17pub struct ConfigFs {
18 pub grimoire_css_version: Option<String>,
20 pub variables: Option<Vec<(String, String)>>,
21 pub scrolls: Option<HashMap<String, ScrollDefinition>>,
22 pub projects: Vec<ConfigFsProject>,
23 pub shared: Option<Vec<ConfigFsShared>>,
24 pub critical: Option<Vec<ConfigFsCritical>>,
25 pub shared_spells: HashSet<String>,
27 pub lock: Option<bool>,
28
29 pub custom_animations: HashMap<String, String>,
30}
31
32#[derive(Debug, Clone)]
34pub struct ConfigFsShared {
35 pub output_path: String,
36 pub styles: Option<Vec<String>>,
37 pub css_custom_properties: Option<Vec<ConfigFsCssCustomProperties>>,
38}
39
40#[derive(Debug, Clone)]
42pub struct ConfigFsCritical {
43 pub file_to_inline_paths: Vec<String>,
44 pub styles: Option<Vec<String>>,
45 pub css_custom_properties: Option<Vec<ConfigFsCssCustomProperties>>,
46}
47
48#[derive(Debug, Clone)]
50pub struct ConfigFsCssCustomProperties {
51 pub element: String,
52 pub data_param: String,
53 pub data_value: String,
54 pub css_variables: Vec<(String, String)>,
55}
56
57#[derive(Debug, Clone)]
59pub struct ConfigFsProject {
60 pub project_name: String,
61 pub input_paths: Vec<String>,
62 pub output_dir_path: Option<String>,
63 pub single_output_file_name: Option<String>,
64}
65
66#[derive(Serialize, Deserialize, Debug, Clone)]
72struct ConfigFsJSON {
73 #[serde(rename = "$schema")]
74 pub schema: Option<String>,
75 pub version: Option<String>,
76 pub variables: Option<HashMap<String, String>>,
78 pub scrolls: Option<Vec<ConfigFsScrollJSON>>,
80 pub projects: Vec<ConfigFsProjectJSON>,
82 pub shared: Option<Vec<ConfigFsSharedJSON>>,
83 pub critical: Option<Vec<ConfigFsCriticalJSON>>,
84 pub lock: Option<bool>,
85}
86
87#[derive(Serialize, Deserialize, Debug, Clone)]
89pub struct ConfigFsScrollJSON {
90 pub name: String,
91 pub spells: Vec<String>,
92 #[serde(rename = "spellsByArgs")]
93 pub spells_by_args: Option<HashMap<String, Vec<String>>>,
94 pub extends: Option<Vec<String>>,
95}
96
97#[derive(Serialize, Deserialize, Debug, Clone)]
99#[serde(rename_all = "camelCase")]
100struct ConfigFsProjectJSON {
101 pub project_name: String,
103 pub input_paths: Vec<String>,
105 pub output_dir_path: Option<String>,
107 pub single_output_file_name: Option<String>,
109}
110
111#[derive(Serialize, Deserialize, Debug, Clone)]
113#[serde(rename_all = "camelCase")]
114struct ConfigFsSharedJSON {
115 pub output_path: String,
116 pub styles: Option<Vec<String>>,
117 pub css_custom_properties: Option<Vec<ConfigFsCSSCustomPropertiesJSON>>,
118}
119
120#[derive(Serialize, Deserialize, Debug, Clone)]
122#[serde(rename_all = "camelCase")]
123struct ConfigFsCriticalJSON {
124 pub file_to_inline_paths: Vec<String>,
125 pub styles: Option<Vec<String>>,
126 pub css_custom_properties: Option<Vec<ConfigFsCSSCustomPropertiesJSON>>,
127}
128
129#[derive(Serialize, Deserialize, Debug, Clone)]
131#[serde(rename_all = "camelCase")]
132struct ConfigFsCSSCustomPropertiesJSON {
133 pub element: Option<String>,
135 pub data_param: String,
137 pub data_value: String,
139 pub css_variables: HashMap<String, String>,
141}
142
143impl Default for ConfigFs {
144 fn default() -> Self {
146 let projects = vec![ConfigFsProject {
147 project_name: "main".to_string(),
148 input_paths: Vec::new(),
149 output_dir_path: None,
150 single_output_file_name: None,
151 }];
152
153 Self {
154 grimoire_css_version: Some(env!("CARGO_PKG_VERSION").to_string()),
155 scrolls: None,
156 shared: None,
157 critical: None,
158 projects,
159 variables: None,
160 shared_spells: HashSet::new(),
161 custom_animations: HashMap::new(),
162 lock: None,
163 }
164 }
165}
166
167impl ConfigFs {
168 pub fn load(current_dir: &Path) -> Result<Self, GrimoireCssError> {
178 let config_path = Filesystem::get_config_path(current_dir)?;
179 let content = fs::read_to_string(&config_path)?;
180 let json_config: ConfigFsJSON = serde_json::from_str(&content)?;
181 let mut config = Self::from_json(json_config);
182
183 config.custom_animations = Self::find_custom_animations(current_dir)?;
185
186 config.scrolls = Self::load_external_scrolls(current_dir, config.scrolls)?;
188
189 config.variables = Self::load_external_variables(current_dir, config.variables)?;
191
192 Ok(config)
193 }
194
195 pub fn save(&self, current_dir: &Path) -> Result<(), GrimoireCssError> {
203 let config_path = Filesystem::get_config_path(current_dir)?;
204 let json_config = self.to_json();
205 let content = serde_json::to_string_pretty(&json_config)?;
206 fs::write(&config_path, content)?;
207
208 Ok(())
209 }
210
211 fn get_common_spells_set(config: &ConfigFsJSON) -> HashSet<String> {
221 let mut common_spells = HashSet::new();
222
223 if let Some(shared) = &config.shared {
224 for shared_item in shared {
225 if let Some(styles) = &shared_item.styles {
226 common_spells.extend(styles.iter().cloned());
227 }
228 }
229 }
230
231 if let Some(critical) = &config.critical {
232 for critical_item in critical {
233 if let Some(styles) = &critical_item.styles {
234 common_spells.extend(styles.iter().cloned());
235 }
236 }
237 }
238
239 common_spells
240 }
241
242 fn from_json(json_config: ConfigFsJSON) -> Self {
252 let shared_spells = Self::get_common_spells_set(&json_config);
253
254 let variables = json_config.variables.map(|vars| {
255 let mut sorted_vars: Vec<_> = vars.into_iter().collect();
256 sorted_vars.sort_by(|a, b| a.0.cmp(&b.0));
257 sorted_vars
258 });
259
260 let projects = Self::projects_from_json(json_config.projects);
261
262 let shared = Self::shared_from_json(json_config.shared);
264 let critical = Self::critical_from_json(json_config.critical);
265 let scrolls = Self::scrolls_from_json(json_config.scrolls);
266
267 ConfigFs {
268 grimoire_css_version: json_config.version,
269 variables,
270 scrolls,
271 projects,
272 shared,
273 critical,
274 shared_spells,
275 custom_animations: HashMap::new(),
276 lock: json_config.lock,
277 }
278 }
279
280 fn shared_from_json(shared: Option<Vec<ConfigFsSharedJSON>>) -> Option<Vec<ConfigFsShared>> {
282 shared.map(|shared_vec| {
283 shared_vec
284 .into_iter()
285 .map(|c| ConfigFsShared {
286 output_path: c.output_path,
287 styles: c.styles,
288 css_custom_properties: Self::convert_css_custom_properties_from_json(
289 c.css_custom_properties,
290 ),
291 })
292 .collect()
293 })
294 }
295
296 fn critical_from_json(
298 critical: Option<Vec<ConfigFsCriticalJSON>>,
299 ) -> Option<Vec<ConfigFsCritical>> {
300 critical.map(|critical_vec| {
301 critical_vec
302 .into_iter()
303 .map(|c| ConfigFsCritical {
304 file_to_inline_paths: Self::expand_glob_patterns(c.file_to_inline_paths),
305 styles: c.styles,
306 css_custom_properties: Self::convert_css_custom_properties_from_json(
307 c.css_custom_properties,
308 ),
309 })
310 .collect()
311 })
312 }
313
314 fn scrolls_from_json(
315 scrolls: Option<Vec<ConfigFsScrollJSON>>,
316 ) -> Option<HashMap<String, ScrollDefinition>> {
317 scrolls.map(|scrolls_vec| {
318 let mut scrolls_map = HashMap::new();
319
320 for scroll in &scrolls_vec {
321 let mut base_spells = Vec::new();
322 let mut spells_by_args: HashMap<String, Vec<String>> = HashMap::new();
323
324 Self::resolve_spells(scroll, &scrolls_vec, &mut base_spells);
326 Self::resolve_spells_by_args(scroll, &scrolls_vec, &mut spells_by_args);
327
328 base_spells.extend_from_slice(&scroll.spells);
330
331 if let Some(own) = &scroll.spells_by_args {
333 for (k, v) in own {
334 spells_by_args
335 .entry(k.clone())
336 .or_default()
337 .extend(v.iter().cloned());
338 }
339 }
340
341 let spells_by_args = if spells_by_args.is_empty() {
342 None
343 } else {
344 Some(spells_by_args)
345 };
346
347 scrolls_map.insert(
348 scroll.name.clone(),
349 ScrollDefinition {
350 spells: base_spells,
351 spells_by_args,
352 },
353 );
354 }
355
356 scrolls_map
357 })
358 }
359
360 fn resolve_spells(
362 scroll: &ConfigFsScrollJSON,
363 scrolls_vec: &[ConfigFsScrollJSON],
364 collected_spells: &mut Vec<String>,
365 ) {
366 if let Some(extends) = &scroll.extends {
367 for ext_name in extends {
368 if let Some(parent_scroll) = scrolls_vec.iter().find(|s| &s.name == ext_name) {
370 Self::resolve_spells(parent_scroll, scrolls_vec, collected_spells);
372
373 collected_spells.extend_from_slice(&parent_scroll.spells);
375 }
376 }
377 }
378 }
379
380 fn resolve_spells_by_args(
382 scroll: &ConfigFsScrollJSON,
383 scrolls_vec: &[ConfigFsScrollJSON],
384 collected: &mut HashMap<String, Vec<String>>,
385 ) {
386 if let Some(extends) = &scroll.extends {
387 for ext_name in extends {
388 if let Some(parent_scroll) = scrolls_vec.iter().find(|s| &s.name == ext_name) {
389 Self::resolve_spells_by_args(parent_scroll, scrolls_vec, collected);
390
391 if let Some(parent_map) = &parent_scroll.spells_by_args {
392 for (k, v) in parent_map {
393 collected
394 .entry(k.clone())
395 .or_default()
396 .extend(v.iter().cloned());
397 }
398 }
399 }
400 }
401 }
402 }
403
404 fn convert_css_custom_properties_from_json(
406 css_custom_properties_vec: Option<Vec<ConfigFsCSSCustomPropertiesJSON>>,
407 ) -> Option<Vec<ConfigFsCssCustomProperties>> {
408 css_custom_properties_vec.map(|items: Vec<ConfigFsCSSCustomPropertiesJSON>| {
409 items
410 .into_iter()
411 .map(|item| ConfigFsCssCustomProperties {
412 element: item.element.unwrap_or_else(|| String::from(":root")),
413 data_param: item.data_param,
414 data_value: item.data_value,
415 css_variables: {
416 let mut vars: Vec<_> = item.css_variables.into_iter().collect();
417 vars.sort_by(|a, b| a.0.cmp(&b.0));
418 vars
419 },
420 })
421 .collect()
422 })
423 }
424
425 fn projects_from_json(projects: Vec<ConfigFsProjectJSON>) -> Vec<ConfigFsProject> {
427 projects
428 .into_iter()
429 .map(|p| {
430 let input_paths = Self::expand_glob_patterns(p.input_paths);
431 ConfigFsProject {
432 project_name: p.project_name,
433 input_paths,
434 output_dir_path: p.output_dir_path,
435 single_output_file_name: p.single_output_file_name,
436 }
437 })
438 .collect()
439 }
440
441 fn to_json(&self) -> ConfigFsJSON {
443 let variables_hash_map = self.variables.as_ref().map(|vars| {
444 let mut sorted_vars: Vec<_> = vars.iter().collect();
445 sorted_vars.sort_by(|a, b| a.0.cmp(&b.0));
446 sorted_vars
447 .into_iter()
448 .map(|(key, value)| (key.clone(), value.clone()))
449 .collect()
450 });
451
452 ConfigFsJSON {
453 schema: Some("https://raw.githubusercontent.com/persevie/grimoire-css/main/src/core/config/config-schema.json".to_string()),
454 version: self.grimoire_css_version.clone(),
455 variables: variables_hash_map,
456 scrolls: Self::scrolls_to_json(self.scrolls.clone()),
457 projects: Self::projects_to_json(self.projects.clone()),
458 shared: Self::shared_to_json(self.shared.as_ref()),
459 critical: Self::critical_to_json(self.critical.as_ref()),
460 lock: self.lock,
461 }
462 }
463
464 pub fn update_config_version_only(
469 current_dir: &Path,
470 grimoire_css_version: &str,
471 ) -> Result<(), GrimoireCssError> {
472 let config_path = Filesystem::get_config_path(current_dir)?;
473 let content = fs::read_to_string(&config_path)?;
474 let mut json_value: serde_json::Value = serde_json::from_str(&content)?;
475
476 if let serde_json::Value::Object(map) = &mut json_value {
477 map.insert(
478 "version".to_string(),
479 serde_json::Value::String(grimoire_css_version.to_string()),
480 );
481
482 map.entry("$schema".to_string()).or_insert_with(|| {
484 serde_json::Value::String(
485 "https://raw.githubusercontent.com/persevie/grimoire-css/main/src/core/config/config-schema.json".to_string(),
486 )
487 });
488 }
489
490 let updated = serde_json::to_string_pretty(&json_value)?;
491 fs::write(&config_path, updated)?;
492 Ok(())
493 }
494
495 fn shared_to_json(shared: Option<&Vec<ConfigFsShared>>) -> Option<Vec<ConfigFsSharedJSON>> {
497 shared.map(|common_vec: &Vec<ConfigFsShared>| {
498 common_vec
499 .iter()
500 .map(|c| ConfigFsSharedJSON {
501 output_path: c.output_path.clone(),
502 styles: c.styles.clone(),
503 css_custom_properties: Self::css_custom_properties_to_json(
504 c.css_custom_properties.as_ref(),
505 ),
506 })
507 .collect()
508 })
509 }
510
511 fn critical_to_json(
513 critical: Option<&Vec<ConfigFsCritical>>,
514 ) -> Option<Vec<ConfigFsCriticalJSON>> {
515 critical.map(|common_vec| {
516 common_vec
517 .iter()
518 .map(|c| ConfigFsCriticalJSON {
519 file_to_inline_paths: c.file_to_inline_paths.clone(),
520 styles: c.styles.clone(),
521 css_custom_properties: Self::css_custom_properties_to_json(
522 c.css_custom_properties.as_ref(),
523 ),
524 })
525 .collect()
526 })
527 }
528
529 fn css_custom_properties_to_json(
531 css_custom_properties_vec: Option<&Vec<ConfigFsCssCustomProperties>>,
532 ) -> Option<Vec<ConfigFsCSSCustomPropertiesJSON>> {
533 css_custom_properties_vec.map(|items: &Vec<ConfigFsCssCustomProperties>| {
534 items
535 .iter()
536 .map(|item| ConfigFsCSSCustomPropertiesJSON {
537 element: Some(item.element.clone()),
538 data_param: item.data_param.clone(),
539 data_value: item.data_value.clone(),
540 css_variables: item.css_variables.clone().into_iter().collect(),
541 })
542 .collect()
543 })
544 }
545
546 fn scrolls_to_json(
547 config_scrolls: Option<HashMap<String, ScrollDefinition>>,
548 ) -> Option<Vec<ConfigFsScrollJSON>> {
549 config_scrolls.map(|scrolls| {
550 let mut scrolls_vec = Vec::new();
551 for (name, def) in scrolls {
552 scrolls_vec.push(ConfigFsScrollJSON {
553 name,
554 spells: def.spells,
555 spells_by_args: def.spells_by_args,
556 extends: None,
557 });
558 }
559 scrolls_vec
560 })
561 }
562
563 fn projects_to_json(projects: Vec<ConfigFsProject>) -> Vec<ConfigFsProjectJSON> {
565 projects
566 .into_iter()
567 .map(|p| ConfigFsProjectJSON {
568 project_name: p.project_name,
569 input_paths: p.input_paths,
570 output_dir_path: p.output_dir_path,
571 single_output_file_name: p.single_output_file_name,
572 })
573 .collect()
574 }
575
576 fn find_custom_animations(
600 current_dir: &Path,
601 ) -> Result<HashMap<String, String>, GrimoireCssError> {
602 let animations_dir =
603 Filesystem::get_or_create_grimoire_path(current_dir)?.join("animations");
604
605 if !animations_dir.exists() {
606 return Ok(HashMap::new());
607 }
608
609 let mut entries = animations_dir.read_dir()?.peekable();
610
611 if entries.peek().is_none() {
612 add_message("No custom animations were found in the 'animations' directory. Deleted unnecessary 'animations' directory".to_string());
613 fs::remove_dir(&animations_dir)?;
614 return Ok(HashMap::new());
615 }
616
617 let mut map = HashMap::new();
618
619 for entry in entries {
620 let entry = entry?;
621 let path = entry.path();
622
623 if path.is_file() {
624 if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
625 if ext == "css" {
626 if let Some(file_stem) = path.file_stem().and_then(|s| s.to_str()) {
627 let content = fs::read_to_string(&path)?;
628 map.insert(file_stem.to_owned(), content);
629 }
630 } else {
631 add_message(format!(
632 "Only CSS files are supported in the 'animations' directory. Skipping non-CSS file: {}.",
633 path.display()
634 ));
635 }
636 }
637 } else {
638 add_message(format!(
639 "Only files are supported in the 'animations' directory. Skipping directory: {}.",
640 path.display()
641 ));
642 }
643 }
644
645 Ok(map)
646 }
647
648 fn expand_glob_patterns(patterns: Vec<String>) -> Vec<String> {
649 let mut paths = Vec::new();
650 for pattern in patterns {
651 match glob(&pattern) {
652 Ok(glob_paths) => {
653 for path_result in glob_paths.flatten() {
654 if let Some(path_str) = path_result.to_str() {
655 paths.push(path_str.to_string());
656 }
657 }
658 }
659 Err(e) => {
660 add_message(format!("Failed to read glob pattern {pattern}: {e}"));
661 }
662 }
663 }
664 paths
665 }
666
667 fn load_external_scrolls(
684 current_dir: &Path,
685 existing_scrolls: Option<HashMap<String, ScrollDefinition>>,
686 ) -> Result<Option<HashMap<String, ScrollDefinition>>, GrimoireCssError> {
687 let config_dir = Filesystem::get_or_create_grimoire_path(current_dir)?.join("config");
689
690 let mut all_scrolls = existing_scrolls.unwrap_or_default();
692 let mut existing_scroll_names: HashSet<String> = all_scrolls.keys().cloned().collect();
693 let mut external_files_found = false;
694
695 let pattern = config_dir
697 .join("grimoire.*.scrolls.json")
698 .to_string_lossy()
699 .to_string();
700
701 match glob::glob(&pattern) {
702 Ok(entries) => {
703 for entry in entries.flatten() {
704 if let Some(file_name) = entry.file_name().and_then(|s| s.to_str()) {
705 match fs::read_to_string(&entry) {
707 Ok(content) => {
708 match serde_json::from_str::<serde_json::Value>(&content) {
709 Ok(json) => {
710 if let Some(scrolls) =
712 json.get("scrolls").and_then(|s| s.as_array())
713 {
714 external_files_found = true;
715
716 for scroll in scrolls {
718 if let (Some(name), Some(spells_arr)) = (
719 scroll.get("name").and_then(|n| n.as_str()),
720 scroll.get("spells").and_then(|s| s.as_array()),
721 ) {
722 if !existing_scroll_names.contains(name) {
724 let spells: Vec<String> = spells_arr
726 .iter()
727 .filter_map(|s| {
728 s.as_str().map(|s| s.to_string())
729 })
730 .collect();
731
732 let spells_by_args = scroll
734 .get("spellsByArgs")
735 .and_then(|s| s.as_object())
736 .and_then(|obj| {
737 let mut map: HashMap<String, Vec<String>> =
738 HashMap::new();
739 for (k, v) in obj {
740 if let Some(arr) = v.as_array() {
741 let spells: Vec<String> = arr
742 .iter()
743 .filter_map(|s| {
744 s.as_str().map(|s| s.to_string())
745 })
746 .collect();
747 map.insert(k.clone(), spells);
748 }
749 }
750 if map.is_empty() { None } else { Some(map) }
751 });
752
753 all_scrolls.insert(
755 name.to_string(),
756 ScrollDefinition {
757 spells,
758 spells_by_args,
759 },
760 );
761 existing_scroll_names
762 .insert(name.to_string());
763 }
764 }
766 }
767
768 add_message(format!(
769 "Loaded external scrolls from '{file_name}'"
770 ));
771 }
772 }
773 Err(err) => {
774 add_message(format!(
775 "Failed to parse external scroll file '{file_name}': {err}"
776 ));
777 }
778 }
779 }
780 Err(err) => {
781 add_message(format!(
782 "Failed to read external scroll file '{file_name}': {err}"
783 ));
784 }
785 }
786 }
787 }
788 }
789 Err(err) => {
790 add_message(format!("Failed to search for external scroll files: {err}"));
791 }
792 }
793
794 if all_scrolls.is_empty() {
796 Ok(None)
797 } else {
798 if external_files_found {
800 add_message("External scroll files were merged with configuration".to_string());
801 }
802 Ok(Some(all_scrolls))
803 }
804 }
805
806 fn load_external_variables(
822 current_dir: &Path,
823 existing_variables: Option<Vec<(String, String)>>,
824 ) -> Result<Option<Vec<(String, String)>>, GrimoireCssError> {
825 let config_dir = Filesystem::get_or_create_grimoire_path(current_dir)?.join("config");
827
828 let mut all_variables = existing_variables.unwrap_or_default();
830 let mut existing_keys: HashSet<String> =
831 all_variables.iter().map(|(key, _)| key.clone()).collect();
832 let mut external_files_found = false;
833
834 let pattern = config_dir
836 .join("grimoire.*.variables.json")
837 .to_string_lossy()
838 .to_string();
839
840 match glob::glob(&pattern) {
841 Ok(entries) => {
842 for entry in entries.flatten() {
843 if let Some(file_name) = entry.file_name().and_then(|s| s.to_str()) {
844 match fs::read_to_string(&entry) {
846 Ok(content) => {
847 match serde_json::from_str::<serde_json::Value>(&content) {
848 Ok(json) => {
849 if let Some(variables) =
851 json.get("variables").and_then(|v| v.as_object())
852 {
853 external_files_found = true;
854
855 for (key, value) in variables {
857 if let Some(value_str) = value.as_str() {
858 if !existing_keys.contains(key) {
860 all_variables.push((
861 key.clone(),
862 value_str.to_string(),
863 ));
864 existing_keys.insert(key.clone());
865 }
866 }
868 }
869
870 add_message(format!(
871 "Loaded external variables from '{file_name}'"
872 ));
873 }
874 }
875 Err(err) => {
876 add_message(format!(
877 "Failed to parse external variables file '{file_name}': {err}"
878 ));
879 }
880 }
881 }
882 Err(err) => {
883 add_message(format!(
884 "Failed to read external variables file '{file_name}': {err}"
885 ));
886 }
887 }
888 }
889 }
890 }
891 Err(err) => {
892 add_message(format!(
893 "Failed to search for external variables files: {err}"
894 ));
895 }
896 }
897
898 if !all_variables.is_empty() {
900 all_variables.sort_by(|a, b| a.0.cmp(&b.0));
901
902 if external_files_found {
904 add_message("External variable files were merged with configuration".to_string());
905 }
906 Ok(Some(all_variables))
907 } else {
908 Ok(None)
909 }
910 }
911}
912
913#[cfg(test)]
914mod tests {
915 use super::*;
916 use std::fs::File;
917 use std::io::Write;
918 use tempfile::tempdir;
919
920 #[test]
921 fn test_default_config() {
922 let config = ConfigFs::default();
923 assert!(config.variables.is_none());
924 assert!(config.scrolls.is_none());
925 assert!(config.shared.is_none());
926 assert!(config.critical.is_none());
927 assert_eq!(config.projects.len(), 1);
928 assert_eq!(config.projects[0].project_name, "main");
929 }
930
931 #[test]
932 fn test_load_nonexistent_config() {
933 let dir = tempdir().unwrap();
934 let result = ConfigFs::load(dir.path());
935 assert!(result.is_err());
936 }
937
938 #[test]
939 fn test_save_and_load_config() {
940 let dir = tempdir().unwrap();
941 let config = ConfigFs::default();
942 config.save(dir.path()).expect("Failed to save config");
943
944 let loaded_config = ConfigFs::load(dir.path()).expect("Failed to load config");
945 assert_eq!(
946 config.projects[0].project_name,
947 loaded_config.projects[0].project_name
948 );
949 }
950
951 #[test]
952 fn test_expand_glob_patterns() {
953 let dir = tempdir().unwrap();
954 let file_path = dir.path().join("test.txt");
955 File::create(&file_path).unwrap();
956
957 let patterns = vec![format!("{}/**/*.txt", dir.path().to_str().unwrap())];
958 let expanded = ConfigFs::expand_glob_patterns(patterns);
959 assert_eq!(expanded.len(), 1);
960 assert!(expanded[0].ends_with("test.txt"));
961 }
962
963 #[test]
964 fn test_find_custom_animations_empty() {
965 let dir = tempdir().unwrap();
966 let animations = ConfigFs::find_custom_animations(dir.path()).unwrap();
967 assert!(animations.is_empty());
968 }
969
970 #[test]
971 fn test_find_custom_animations_with_files() {
972 let dir = tempdir().unwrap();
973 let animations_dir = dir.path().join("grimoire").join("animations");
974 fs::create_dir_all(&animations_dir).unwrap();
975
976 let animation_file = animations_dir.join("fade_in.css");
977 let mut file = File::create(&animation_file).unwrap();
978 writeln!(
979 file,
980 "@keyframes fade_in {{ from {{ opacity: 0; }} to {{ opacity: 1; }} }}"
981 )
982 .unwrap();
983
984 let animations = ConfigFs::find_custom_animations(dir.path()).unwrap();
985 assert_eq!(animations.len(), 1);
986 assert!(animations.contains_key("fade_in"));
987 }
988
989 #[test]
990 fn test_get_common_spells_set() {
991 let json = ConfigFsJSON {
992 schema: None,
993 version: None,
994 variables: None,
995 scrolls: None,
996 projects: vec![],
997 shared: Some(vec![ConfigFsSharedJSON {
998 output_path: "styles.css".to_string(),
999 styles: Some(vec!["spell1".to_string(), "spell2".to_string()]),
1000 css_custom_properties: None,
1001 }]),
1002 critical: Some(vec![ConfigFsCriticalJSON {
1003 file_to_inline_paths: vec!["index.html".to_string()],
1004 styles: Some(vec!["spell3".to_string()]),
1005 css_custom_properties: None,
1006 }]),
1007 lock: None,
1008 };
1009
1010 let common_spells = ConfigFs::get_common_spells_set(&json);
1011 assert_eq!(common_spells.len(), 3);
1012 assert!(common_spells.contains("spell1"));
1013 assert!(common_spells.contains("spell2"));
1014 assert!(common_spells.contains("spell3"));
1015 }
1016
1017 #[test]
1018 fn test_load_external_scrolls_no_files() {
1019 let dir = tempdir().unwrap();
1020 let config_dir = dir.path().join("grimoire").join("config");
1021 fs::create_dir_all(&config_dir).unwrap();
1022
1023 let config_file = config_dir.join("grimoire.config.json");
1025 let config_content = r#"{
1026 "projects": [
1027 {
1028 "projectName": "main",
1029 "inputPaths": []
1030 }
1031 ]
1032 }"#;
1033 fs::write(&config_file, config_content).unwrap();
1034
1035 let result = ConfigFs::load_external_scrolls(dir.path(), None).unwrap();
1037 assert!(result.is_none());
1038 }
1039
1040 #[test]
1041 fn test_load_external_scrolls_single_file() {
1042 let dir = tempdir().unwrap();
1043 let config_dir = dir.path().join("grimoire").join("config");
1044 fs::create_dir_all(&config_dir).unwrap();
1045
1046 let config_file = config_dir.join("grimoire.config.json");
1048 let config_content = r#"{
1049 "projects": [
1050 {
1051 "projectName": "main",
1052 "inputPaths": []
1053 }
1054 ]
1055 }"#;
1056 fs::write(&config_file, config_content).unwrap();
1057
1058 let scrolls_file = config_dir.join("grimoire.tailwindcss.scrolls.json");
1060 let scrolls_content = r#"{
1061 "scrolls": [
1062 {
1063 "name": "tw-btn",
1064 "spells": [
1065 "p=4px",
1066 "bg=blue",
1067 "c=white",
1068 "br=4px"
1069 ]
1070 }
1071 ]
1072 }"#;
1073 fs::write(&scrolls_file, scrolls_content).unwrap();
1074
1075 let result = ConfigFs::load_external_scrolls(dir.path(), None).unwrap();
1077 assert!(result.is_some());
1078
1079 let scrolls = result.unwrap();
1080 assert_eq!(scrolls.len(), 1);
1081 assert!(scrolls.contains_key("tw-btn"));
1082 assert_eq!(scrolls.get("tw-btn").unwrap().spells.len(), 4);
1083 }
1084
1085 #[test]
1086 fn test_load_external_scrolls_multiple_files() {
1087 let dir = tempdir().unwrap();
1088 let config_dir = dir.path().join("grimoire").join("config");
1089 fs::create_dir_all(&config_dir).unwrap();
1090
1091 let config_file = config_dir.join("grimoire.config.json");
1093 let config_content = r#"{
1094 "projects": [
1095 {
1096 "projectName": "main",
1097 "inputPaths": []
1098 }
1099 ]
1100 }"#;
1101 fs::write(&config_file, config_content).unwrap();
1102
1103 let scrolls_file1 = config_dir.join("grimoire.tailwindcss.scrolls.json");
1105 let scrolls_content1 = r#"{
1106 "scrolls": [
1107 {
1108 "name": "tw-btn",
1109 "spells": [
1110 "p=4px",
1111 "bg=blue",
1112 "c=white",
1113 "br=4px"
1114 ]
1115 }
1116 ]
1117 }"#;
1118 fs::write(&scrolls_file1, scrolls_content1).unwrap();
1119
1120 let scrolls_file2 = config_dir.join("grimoire.bootstrap.scrolls.json");
1122 let scrolls_content2 = r#"{
1123 "scrolls": [
1124 {
1125 "name": "bs-card",
1126 "spells": [
1127 "border=1px_solid_#ccc",
1128 "br=8px",
1129 "shadow=0_2px_8px_rgba(0,0,0,0.1)"
1130 ]
1131 }
1132 ]
1133 }"#;
1134 fs::write(&scrolls_file2, scrolls_content2).unwrap();
1135
1136 let result = ConfigFs::load_external_scrolls(dir.path(), None).unwrap();
1138 assert!(result.is_some());
1139
1140 let scrolls = result.unwrap();
1141 assert_eq!(scrolls.len(), 2);
1142 assert!(scrolls.contains_key("tw-btn"));
1143 assert!(scrolls.contains_key("bs-card"));
1144 assert_eq!(scrolls.get("tw-btn").unwrap().spells.len(), 4);
1145 assert_eq!(scrolls.get("bs-card").unwrap().spells.len(), 3);
1146 }
1147
1148 #[test]
1149 fn test_merge_with_existing_scrolls() {
1150 let dir = tempdir().unwrap();
1151 let config_dir = dir.path().join("grimoire").join("config");
1152 fs::create_dir_all(&config_dir).unwrap();
1153
1154 let config_file = config_dir.join("grimoire.config.json");
1156 let config_content = r#"{
1157 "scrolls": [
1158 {
1159 "name": "main-btn",
1160 "spells": [
1161 "p=10px",
1162 "fw=bold",
1163 "c=black"
1164 ]
1165 }
1166 ],
1167 "projects": [
1168 {
1169 "projectName": "main",
1170 "inputPaths": []
1171 }
1172 ]
1173 }"#;
1174 fs::write(&config_file, config_content).unwrap();
1175
1176 let scrolls_file = config_dir.join("grimoire.extra.scrolls.json");
1178 let scrolls_content = r#"{
1179 "scrolls": [
1180 {
1181 "name": "main-btn",
1182 "spells": [
1183 "bg=green",
1184 "hover:bg=darkgreen"
1185 ]
1186 },
1187 {
1188 "name": "extra-btn",
1189 "spells": [
1190 "fs=16px",
1191 "m=10px"
1192 ]
1193 }
1194 ]
1195 }"#;
1196 fs::write(&scrolls_file, scrolls_content).unwrap();
1197
1198 let mut existing_scrolls = HashMap::new();
1200 existing_scrolls.insert(
1201 "main-btn".to_string(),
1202 ScrollDefinition {
1203 spells: vec![
1204 "p=10px".to_string(),
1205 "fw=bold".to_string(),
1206 "c=black".to_string(),
1207 ],
1208 spells_by_args: None,
1209 },
1210 );
1211
1212 let result = ConfigFs::load_external_scrolls(dir.path(), Some(existing_scrolls)).unwrap();
1214 assert!(result.is_some());
1215
1216 let scrolls = result.unwrap();
1217 assert_eq!(scrolls.len(), 2);
1218
1219 assert!(scrolls.contains_key("main-btn"));
1221 assert_eq!(scrolls.get("main-btn").unwrap().spells.len(), 3);
1222
1223 assert!(scrolls.contains_key("extra-btn"));
1225 assert_eq!(scrolls.get("extra-btn").unwrap().spells.len(), 2);
1226 }
1227
1228 #[test]
1229 fn test_full_config_with_external_scrolls() {
1230 let dir = tempdir().unwrap();
1231 let config_dir = dir.path().join("grimoire").join("config");
1232 fs::create_dir_all(&config_dir).unwrap();
1233
1234 let config_file = config_dir.join("grimoire.config.json");
1236 let config_content = r#"{
1237 "scrolls": [
1238 {
1239 "name": "base-btn",
1240 "spells": [
1241 "p=10px",
1242 "br=4px"
1243 ]
1244 }
1245 ],
1246 "projects": [
1247 {
1248 "projectName": "main",
1249 "inputPaths": []
1250 }
1251 ]
1252 }"#;
1253 fs::write(&config_file, config_content).unwrap();
1254
1255 let scrolls_file = config_dir.join("grimoire.theme.scrolls.json");
1257 let scrolls_content = r#"{
1258 "scrolls": [
1259 {
1260 "name": "theme-btn",
1261 "spells": [
1262 "bg=purple",
1263 "c=white"
1264 ]
1265 }
1266 ]
1267 }"#;
1268 fs::write(&scrolls_file, scrolls_content).unwrap();
1269
1270 let config = ConfigFs::load(dir.path()).expect("Failed to load config");
1272
1273 assert!(config.scrolls.is_some());
1275 let scrolls = config.scrolls.unwrap();
1276 assert_eq!(scrolls.len(), 2);
1277 assert!(scrolls.contains_key("base-btn"));
1278 assert!(scrolls.contains_key("theme-btn"));
1279 }
1280
1281 #[test]
1282 fn test_load_external_variables_no_files() {
1283 let dir = tempdir().unwrap();
1284 let config_dir = dir.path().join("grimoire").join("config");
1285 fs::create_dir_all(&config_dir).unwrap();
1286
1287 let config_file = config_dir.join("grimoire.config.json");
1289 let config_content = r#"{
1290 "projects": [
1291 {
1292 "projectName": "main",
1293 "inputPaths": []
1294 }
1295 ]
1296 }"#;
1297 fs::write(&config_file, config_content).unwrap();
1298
1299 let result = ConfigFs::load_external_variables(dir.path(), None).unwrap();
1301 assert!(result.is_none());
1302 }
1303
1304 #[test]
1305 fn test_load_external_variables_single_file() {
1306 let dir = tempdir().unwrap();
1307 let config_dir = dir.path().join("grimoire").join("config");
1308 fs::create_dir_all(&config_dir).unwrap();
1309
1310 let config_file = config_dir.join("grimoire.config.json");
1312 let config_content = r#"{
1313 "projects": [
1314 {
1315 "projectName": "main",
1316 "inputPaths": []
1317 }
1318 ]
1319 }"#;
1320 fs::write(&config_file, config_content).unwrap();
1321
1322 let vars_file = config_dir.join("grimoire.theme.variables.json");
1324 let vars_content = r##"{
1325 "variables": {
1326 "primary-color": "#3366ff",
1327 "secondary-color": "#ff6633",
1328 "font-size-base": "16px"
1329 }
1330 }"##;
1331 fs::write(&vars_file, vars_content).unwrap();
1332
1333 let result = ConfigFs::load_external_variables(dir.path(), None).unwrap();
1335 assert!(result.is_some());
1336
1337 let variables = result.unwrap();
1338 assert_eq!(variables.len(), 3);
1339
1340 assert_eq!(variables[0].0, "font-size-base");
1342 assert_eq!(variables[0].1, "16px");
1343 assert_eq!(variables[1].0, "primary-color");
1344 assert_eq!(variables[1].1, "#3366ff");
1345 assert_eq!(variables[2].0, "secondary-color");
1346 assert_eq!(variables[2].1, "#ff6633");
1347 }
1348
1349 #[test]
1350 fn test_load_external_variables_multiple_files() {
1351 let dir = tempdir().unwrap();
1352 let config_dir = dir.path().join("grimoire").join("config");
1353 fs::create_dir_all(&config_dir).unwrap();
1354
1355 let config_file = config_dir.join("grimoire.config.json");
1357 let config_content = r#"{
1358 "projects": [
1359 {
1360 "projectName": "main",
1361 "inputPaths": []
1362 }
1363 ]
1364 }"#;
1365 fs::write(&config_file, config_content).unwrap();
1366
1367 let vars_file1 = config_dir.join("grimoire.colors.variables.json");
1369 let vars_content1 = r##"{
1370 "variables": {
1371 "primary-color": "#3366ff",
1372 "secondary-color": "#ff6633"
1373 }
1374 }"##;
1375 fs::write(&vars_file1, vars_content1).unwrap();
1376
1377 let vars_file2 = config_dir.join("grimoire.typography.variables.json");
1379 let vars_content2 = r##"{
1380 "variables": {
1381 "font-size-base": "16px",
1382 "font-family-sans": "Arial, sans-serif"
1383 }
1384 }"##;
1385 fs::write(&vars_file2, vars_content2).unwrap();
1386
1387 let result = ConfigFs::load_external_variables(dir.path(), None).unwrap();
1389 assert!(result.is_some());
1390
1391 let variables = result.unwrap();
1392 assert_eq!(variables.len(), 4);
1393
1394 let var_map: HashMap<String, String> = variables.into_iter().collect();
1396 assert!(var_map.contains_key("primary-color"));
1397 assert!(var_map.contains_key("secondary-color"));
1398 assert!(var_map.contains_key("font-size-base"));
1399 assert!(var_map.contains_key("font-family-sans"));
1400
1401 assert_eq!(var_map.get("primary-color").unwrap(), "#3366ff");
1402 assert_eq!(
1403 var_map.get("font-family-sans").unwrap(),
1404 "Arial, sans-serif"
1405 );
1406 }
1407
1408 #[test]
1409 fn test_merge_with_existing_variables() {
1410 let dir = tempdir().unwrap();
1411 let config_dir = dir.path().join("grimoire").join("config");
1412 fs::create_dir_all(&config_dir).unwrap();
1413
1414 let config_file = config_dir.join("grimoire.config.json");
1416 let config_content = r##"{
1417 "variables": {
1418 "primary-color": "#3366ff",
1419 "font-size-base": "16px"
1420 },
1421 "projects": [
1422 {
1423 "projectName": "main",
1424 "inputPaths": []
1425 }
1426 ]
1427 }"##;
1428 fs::write(&config_file, config_content).unwrap();
1429
1430 let vars_file = config_dir.join("grimoire.extra.variables.json");
1432 let vars_content = r##"{
1433 "variables": {
1434 "secondary-color": "#ff6633",
1435 "primary-color": "#ff0000",
1436 "spacing-unit": "8px"
1437 }
1438 }"##;
1439 fs::write(&vars_file, vars_content).unwrap();
1440
1441 let existing_variables = vec![
1443 ("primary-color".to_string(), "#3366ff".to_string()),
1444 ("font-size-base".to_string(), "16px".to_string()),
1445 ];
1446
1447 let result =
1449 ConfigFs::load_external_variables(dir.path(), Some(existing_variables)).unwrap();
1450 assert!(result.is_some());
1451
1452 let variables = result.unwrap();
1453 assert_eq!(variables.len(), 4); let var_map: HashMap<String, String> = variables.into_iter().collect();
1457
1458 assert_eq!(var_map.get("primary-color").unwrap(), "#3366ff");
1460
1461 assert_eq!(var_map.get("secondary-color").unwrap(), "#ff6633");
1463 assert_eq!(var_map.get("spacing-unit").unwrap(), "8px");
1464
1465 assert_eq!(var_map.get("font-size-base").unwrap(), "16px");
1467 }
1468
1469 #[test]
1470 fn test_full_config_with_external_variables() {
1471 let dir = tempdir().unwrap();
1472 let config_dir = dir.path().join("grimoire").join("config");
1473 fs::create_dir_all(&config_dir).unwrap();
1474
1475 let config_file = config_dir.join("grimoire.config.json");
1477 let config_content = r##"{
1478 "variables": {
1479 "primary-color": "#3366ff"
1480 },
1481 "projects": [
1482 {
1483 "projectName": "main",
1484 "inputPaths": []
1485 }
1486 ]
1487 }"##;
1488 fs::write(&config_file, config_content).unwrap();
1489
1490 let vars_file = config_dir.join("grimoire.theme.variables.json");
1492 let vars_content = r##"{
1493 "variables": {
1494 "secondary-color": "#ff6633",
1495 "spacing-unit": "8px"
1496 }
1497 }"##;
1498 fs::write(&vars_file, vars_content).unwrap();
1499
1500 let config = ConfigFs::load(dir.path()).expect("Failed to load config");
1502
1503 assert!(config.variables.is_some());
1505 let variables = config.variables.unwrap();
1506 assert_eq!(variables.len(), 3);
1507
1508 assert_eq!(variables[0].0, "primary-color");
1510 assert_eq!(variables[1].0, "secondary-color");
1511 assert_eq!(variables[2].0, "spacing-unit");
1512 }
1513}