1use crate::{
4 buffer::add_message,
5 core::{Filesystem, GrimoireCssError},
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 variables: Option<Vec<(String, String)>>,
19 pub scrolls: Option<HashMap<String, Vec<String>>>,
20 pub projects: Vec<ConfigFsProject>,
21 pub shared: Option<Vec<ConfigFsShared>>,
22 pub critical: Option<Vec<ConfigFsCritical>>,
23 pub shared_spells: HashSet<String>,
25 pub lock: Option<bool>,
26
27 pub custom_animations: HashMap<String, String>,
28}
29
30#[derive(Debug, Clone)]
32pub struct ConfigFsShared {
33 pub output_path: String,
34 pub styles: Option<Vec<String>>,
35 pub css_custom_properties: Option<Vec<ConfigFsCssCustomProperties>>,
36}
37
38#[derive(Debug, Clone)]
40pub struct ConfigFsCritical {
41 pub file_to_inline_paths: Vec<String>,
42 pub styles: Option<Vec<String>>,
43 pub css_custom_properties: Option<Vec<ConfigFsCssCustomProperties>>,
44}
45
46#[derive(Debug, Clone)]
48pub struct ConfigFsCssCustomProperties {
49 pub element: String,
50 pub data_param: String,
51 pub data_value: String,
52 pub css_variables: Vec<(String, String)>,
53}
54
55#[derive(Debug, Clone)]
57pub struct ConfigFsProject {
58 pub project_name: String,
59 pub input_paths: Vec<String>,
60 pub output_dir_path: Option<String>,
61 pub single_output_file_name: Option<String>,
62}
63
64#[derive(Serialize, Deserialize, Debug, Clone)]
70struct ConfigFsJSON {
71 #[serde(rename = "$schema")]
72 pub schema: Option<String>,
73 pub variables: Option<HashMap<String, String>>,
75 pub scrolls: Option<Vec<ConfigFsScrollJSON>>,
77 pub projects: Vec<ConfigFsProjectJSON>,
79 pub shared: Option<Vec<ConfigFsSharedJSON>>,
80 pub critical: Option<Vec<ConfigFsCriticalJSON>>,
81 pub lock: Option<bool>,
82}
83
84#[derive(Serialize, Deserialize, Debug, Clone)]
86pub struct ConfigFsScrollJSON {
87 pub name: String,
88 pub spells: Vec<String>,
89 pub extends: Option<Vec<String>>,
90}
91
92#[derive(Serialize, Deserialize, Debug, Clone)]
94#[serde(rename_all = "camelCase")]
95struct ConfigFsProjectJSON {
96 pub project_name: String,
98 pub input_paths: Vec<String>,
100 pub output_dir_path: Option<String>,
102 pub single_output_file_name: Option<String>,
104}
105
106#[derive(Serialize, Deserialize, Debug, Clone)]
108#[serde(rename_all = "camelCase")]
109struct ConfigFsSharedJSON {
110 pub output_path: String,
111 pub styles: Option<Vec<String>>,
112 pub css_custom_properties: Option<Vec<ConfigFsCSSCustomPropertiesJSON>>,
113}
114
115#[derive(Serialize, Deserialize, Debug, Clone)]
117#[serde(rename_all = "camelCase")]
118struct ConfigFsCriticalJSON {
119 pub file_to_inline_paths: Vec<String>,
120 pub styles: Option<Vec<String>>,
121 pub css_custom_properties: Option<Vec<ConfigFsCSSCustomPropertiesJSON>>,
122}
123
124#[derive(Serialize, Deserialize, Debug, Clone)]
126#[serde(rename_all = "camelCase")]
127struct ConfigFsCSSCustomPropertiesJSON {
128 pub element: Option<String>,
130 pub data_param: String,
132 pub data_value: String,
134 pub css_variables: HashMap<String, String>,
136}
137
138impl Default for ConfigFs {
139 fn default() -> Self {
141 let projects = vec![ConfigFsProject {
142 project_name: "main".to_string(),
143 input_paths: Vec::new(),
144 output_dir_path: None,
145 single_output_file_name: None,
146 }];
147
148 Self {
149 scrolls: None,
150 shared: None,
151 critical: None,
152 projects,
153 variables: None,
154 shared_spells: HashSet::new(),
155 custom_animations: HashMap::new(),
156 lock: None,
157 }
158 }
159}
160
161impl ConfigFs {
162 pub fn load(current_dir: &Path) -> Result<Self, GrimoireCssError> {
172 let config_path = Filesystem::get_config_path(current_dir)?;
173 let content = fs::read_to_string(&config_path)?;
174 let json_config: ConfigFsJSON = serde_json::from_str(&content)?;
175 let mut config = Self::from_json(json_config);
176
177 config.custom_animations = Self::find_custom_animations(current_dir)?;
179
180 config.scrolls = Self::load_external_scrolls(current_dir, config.scrolls)?;
182
183 config.variables = Self::load_external_variables(current_dir, config.variables)?;
185
186 Ok(config)
187 }
188
189 pub fn save(&self, current_dir: &Path) -> Result<(), GrimoireCssError> {
197 let config_path = Filesystem::get_config_path(current_dir)?;
198 let json_config = self.to_json();
199 let content = serde_json::to_string_pretty(&json_config)?;
200 fs::write(&config_path, content)?;
201
202 Ok(())
203 }
204
205 fn get_common_spells_set(config: &ConfigFsJSON) -> HashSet<String> {
215 let mut common_spells = HashSet::new();
216
217 if let Some(shared) = &config.shared {
218 for shared_item in shared {
219 if let Some(styles) = &shared_item.styles {
220 common_spells.extend(styles.iter().cloned());
221 }
222 }
223 }
224
225 if let Some(critical) = &config.critical {
226 for critical_item in critical {
227 if let Some(styles) = &critical_item.styles {
228 common_spells.extend(styles.iter().cloned());
229 }
230 }
231 }
232
233 common_spells
234 }
235
236 fn from_json(json_config: ConfigFsJSON) -> Self {
246 let shared_spells = Self::get_common_spells_set(&json_config);
247
248 let variables = json_config.variables.map(|vars| {
249 let mut sorted_vars: Vec<_> = vars.into_iter().collect();
250 sorted_vars.sort_by(|a, b| a.0.cmp(&b.0));
251 sorted_vars
252 });
253
254 let projects = Self::projects_from_json(json_config.projects);
255
256 let shared = Self::shared_from_json(json_config.shared);
258 let critical = Self::critical_from_json(json_config.critical);
259 let scrolls = Self::scrolls_from_json(json_config.scrolls);
260
261 ConfigFs {
262 variables,
263 scrolls,
264 projects,
265 shared,
266 critical,
267 shared_spells,
268 custom_animations: HashMap::new(),
269 lock: json_config.lock,
270 }
271 }
272
273 fn shared_from_json(shared: Option<Vec<ConfigFsSharedJSON>>) -> Option<Vec<ConfigFsShared>> {
275 shared.map(|shared_vec| {
276 shared_vec
277 .into_iter()
278 .map(|c| ConfigFsShared {
279 output_path: c.output_path,
280 styles: c.styles,
281 css_custom_properties: Self::convert_css_custom_properties_from_json(
282 c.css_custom_properties,
283 ),
284 })
285 .collect()
286 })
287 }
288
289 fn critical_from_json(
291 critical: Option<Vec<ConfigFsCriticalJSON>>,
292 ) -> Option<Vec<ConfigFsCritical>> {
293 critical.map(|critical_vec| {
294 critical_vec
295 .into_iter()
296 .map(|c| ConfigFsCritical {
297 file_to_inline_paths: Self::expand_glob_patterns(c.file_to_inline_paths),
298 styles: c.styles,
299 css_custom_properties: Self::convert_css_custom_properties_from_json(
300 c.css_custom_properties,
301 ),
302 })
303 .collect()
304 })
305 }
306
307 fn scrolls_from_json(
308 scrolls: Option<Vec<ConfigFsScrollJSON>>,
309 ) -> Option<HashMap<String, Vec<String>>> {
310 scrolls.map(|scrolls_vec| {
311 let mut scrolls_map = HashMap::new();
312
313 for scroll in &scrolls_vec {
314 let mut scroll_spells = Vec::new();
315
316 Self::resolve_spells(scroll, &scrolls_vec, &mut scroll_spells);
318
319 scroll_spells.extend_from_slice(&scroll.spells);
321
322 scrolls_map.insert(scroll.name.clone(), scroll_spells);
324 }
325
326 scrolls_map
327 })
328 }
329
330 fn resolve_spells(
332 scroll: &ConfigFsScrollJSON,
333 scrolls_vec: &[ConfigFsScrollJSON],
334 collected_spells: &mut Vec<String>,
335 ) {
336 if let Some(extends) = &scroll.extends {
337 for ext_name in extends {
338 if let Some(parent_scroll) = scrolls_vec.iter().find(|s| &s.name == ext_name) {
340 Self::resolve_spells(parent_scroll, scrolls_vec, collected_spells);
342
343 collected_spells.extend_from_slice(&parent_scroll.spells);
345 }
346 }
347 }
348 }
349
350 fn convert_css_custom_properties_from_json(
352 css_custom_properties_vec: Option<Vec<ConfigFsCSSCustomPropertiesJSON>>,
353 ) -> Option<Vec<ConfigFsCssCustomProperties>> {
354 css_custom_properties_vec.map(|items: Vec<ConfigFsCSSCustomPropertiesJSON>| {
355 items
356 .into_iter()
357 .map(|item| ConfigFsCssCustomProperties {
358 element: item.element.unwrap_or_else(|| String::from(":root")),
359 data_param: item.data_param,
360 data_value: item.data_value,
361 css_variables: {
362 let mut vars: Vec<_> = item.css_variables.into_iter().collect();
363 vars.sort_by(|a, b| a.0.cmp(&b.0));
364 vars
365 },
366 })
367 .collect()
368 })
369 }
370
371 fn projects_from_json(projects: Vec<ConfigFsProjectJSON>) -> Vec<ConfigFsProject> {
373 projects
374 .into_iter()
375 .map(|p| {
376 let input_paths = Self::expand_glob_patterns(p.input_paths);
377 ConfigFsProject {
378 project_name: p.project_name,
379 input_paths,
380 output_dir_path: p.output_dir_path,
381 single_output_file_name: p.single_output_file_name,
382 }
383 })
384 .collect()
385 }
386
387 fn to_json(&self) -> ConfigFsJSON {
389 let variables_hash_map = self.variables.as_ref().map(|vars| {
390 let mut sorted_vars: Vec<_> = vars.iter().collect();
391 sorted_vars.sort_by(|a, b| a.0.cmp(&b.0));
392 sorted_vars
393 .into_iter()
394 .map(|(key, value)| (key.clone(), value.clone()))
395 .collect()
396 });
397
398 ConfigFsJSON {
399 schema: Some("https://raw.githubusercontent.com/persevie/grimoire-css/main/src/core/config/config-schema.json".to_string()),
400 variables: variables_hash_map,
401 scrolls: Self::scrolls_to_json(self.scrolls.clone()),
402 projects: Self::projects_to_json(self.projects.clone()),
403 shared: Self::shared_to_json(self.shared.as_ref()),
404 critical: Self::critical_to_json(self.critical.as_ref()),
405 lock: self.lock,
406 }
407 }
408
409 fn shared_to_json(shared: Option<&Vec<ConfigFsShared>>) -> Option<Vec<ConfigFsSharedJSON>> {
411 shared.map(|common_vec: &Vec<ConfigFsShared>| {
412 common_vec
413 .iter()
414 .map(|c| ConfigFsSharedJSON {
415 output_path: c.output_path.clone(),
416 styles: c.styles.clone(),
417 css_custom_properties: Self::css_custom_properties_to_json(
418 c.css_custom_properties.as_ref(),
419 ),
420 })
421 .collect()
422 })
423 }
424
425 fn critical_to_json(
427 critical: Option<&Vec<ConfigFsCritical>>,
428 ) -> Option<Vec<ConfigFsCriticalJSON>> {
429 critical.map(|common_vec| {
430 common_vec
431 .iter()
432 .map(|c| ConfigFsCriticalJSON {
433 file_to_inline_paths: c.file_to_inline_paths.clone(),
434 styles: c.styles.clone(),
435 css_custom_properties: Self::css_custom_properties_to_json(
436 c.css_custom_properties.as_ref(),
437 ),
438 })
439 .collect()
440 })
441 }
442
443 fn css_custom_properties_to_json(
445 css_custom_properties_vec: Option<&Vec<ConfigFsCssCustomProperties>>,
446 ) -> Option<Vec<ConfigFsCSSCustomPropertiesJSON>> {
447 css_custom_properties_vec.map(|items: &Vec<ConfigFsCssCustomProperties>| {
448 items
449 .iter()
450 .map(|item| ConfigFsCSSCustomPropertiesJSON {
451 element: Some(item.element.clone()),
452 data_param: item.data_param.clone(),
453 data_value: item.data_value.clone(),
454 css_variables: item.css_variables.clone().into_iter().collect(),
455 })
456 .collect()
457 })
458 }
459
460 fn scrolls_to_json(
461 config_scrolls: Option<HashMap<String, Vec<String>>>,
462 ) -> Option<Vec<ConfigFsScrollJSON>> {
463 config_scrolls.map(|scrolls| {
464 let mut scrolls_vec = Vec::new();
465 for (name, spells) in scrolls {
466 scrolls_vec.push(ConfigFsScrollJSON {
467 name,
468 spells,
469 extends: None,
470 });
471 }
472 scrolls_vec
473 })
474 }
475
476 fn projects_to_json(projects: Vec<ConfigFsProject>) -> Vec<ConfigFsProjectJSON> {
478 projects
479 .into_iter()
480 .map(|p| ConfigFsProjectJSON {
481 project_name: p.project_name,
482 input_paths: p.input_paths,
483 output_dir_path: p.output_dir_path,
484 single_output_file_name: p.single_output_file_name,
485 })
486 .collect()
487 }
488
489 fn find_custom_animations(
513 current_dir: &Path,
514 ) -> Result<HashMap<String, String>, GrimoireCssError> {
515 let animations_dir =
516 Filesystem::get_or_create_grimoire_path(current_dir)?.join("animations");
517
518 if !animations_dir.exists() {
519 return Ok(HashMap::new());
520 }
521
522 let mut entries = animations_dir.read_dir()?.peekable();
523
524 if entries.peek().is_none() {
525 add_message("No custom animations were found in the 'animations' directory. Deleted unnecessary 'animations' directory".to_string());
526 fs::remove_dir(&animations_dir)?;
527 return Ok(HashMap::new());
528 }
529
530 let mut map = HashMap::new();
531
532 for entry in entries {
533 let entry = entry?;
534 let path = entry.path();
535
536 if path.is_file() {
537 if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
538 if ext == "css" {
539 if let Some(file_stem) = path.file_stem().and_then(|s| s.to_str()) {
540 let content = fs::read_to_string(&path)?;
541 map.insert(file_stem.to_owned(), content);
542 }
543 } else {
544 add_message(format!(
545 "Only CSS files are supported in the 'animations' directory. Skipping non-CSS file: {}.",
546 path.display()
547 ));
548 }
549 }
550 } else {
551 add_message(format!(
552 "Only files are supported in the 'animations' directory. Skipping directory: {}.",
553 path.display()
554 ));
555 }
556 }
557
558 Ok(map)
559 }
560
561 fn expand_glob_patterns(patterns: Vec<String>) -> Vec<String> {
562 let mut paths = Vec::new();
563 for pattern in patterns {
564 match glob(&pattern) {
565 Ok(glob_paths) => {
566 for path_result in glob_paths.flatten() {
567 if let Some(path_str) = path_result.to_str() {
568 paths.push(path_str.to_string());
569 }
570 }
571 }
572 Err(e) => {
573 add_message(format!("Failed to read glob pattern {pattern}: {e}"));
574 }
575 }
576 }
577 paths
578 }
579
580 fn load_external_scrolls(
597 current_dir: &Path,
598 existing_scrolls: Option<HashMap<String, Vec<String>>>,
599 ) -> Result<Option<HashMap<String, Vec<String>>>, GrimoireCssError> {
600 let config_dir = Filesystem::get_or_create_grimoire_path(current_dir)?.join("config");
602
603 let mut all_scrolls = existing_scrolls.unwrap_or_default();
605 let mut existing_scroll_names: HashSet<String> = all_scrolls.keys().cloned().collect();
606 let mut external_files_found = false;
607
608 let pattern = config_dir
610 .join("grimoire.*.scrolls.json")
611 .to_string_lossy()
612 .to_string();
613
614 match glob::glob(&pattern) {
615 Ok(entries) => {
616 for entry in entries.flatten() {
617 if let Some(file_name) = entry.file_name().and_then(|s| s.to_str()) {
618 match fs::read_to_string(&entry) {
620 Ok(content) => {
621 match serde_json::from_str::<serde_json::Value>(&content) {
622 Ok(json) => {
623 if let Some(scrolls) =
625 json.get("scrolls").and_then(|s| s.as_array())
626 {
627 external_files_found = true;
628
629 for scroll in scrolls {
631 if let (Some(name), Some(spells_arr)) = (
632 scroll.get("name").and_then(|n| n.as_str()),
633 scroll.get("spells").and_then(|s| s.as_array()),
634 ) {
635 if !existing_scroll_names.contains(name) {
637 let spells: Vec<String> = spells_arr
639 .iter()
640 .filter_map(|s| {
641 s.as_str().map(|s| s.to_string())
642 })
643 .collect();
644
645 all_scrolls
647 .insert(name.to_string(), spells);
648 existing_scroll_names
649 .insert(name.to_string());
650 }
651 }
653 }
654
655 add_message(format!(
656 "Loaded external scrolls from '{file_name}'"
657 ));
658 }
659 }
660 Err(err) => {
661 add_message(format!(
662 "Failed to parse external scroll file '{file_name}': {err}"
663 ));
664 }
665 }
666 }
667 Err(err) => {
668 add_message(format!(
669 "Failed to read external scroll file '{file_name}': {err}"
670 ));
671 }
672 }
673 }
674 }
675 }
676 Err(err) => {
677 add_message(format!("Failed to search for external scroll files: {err}"));
678 }
679 }
680
681 if all_scrolls.is_empty() {
683 Ok(None)
684 } else {
685 if external_files_found {
687 add_message("External scroll files were merged with configuration".to_string());
688 }
689 Ok(Some(all_scrolls))
690 }
691 }
692
693 fn load_external_variables(
709 current_dir: &Path,
710 existing_variables: Option<Vec<(String, String)>>,
711 ) -> Result<Option<Vec<(String, String)>>, GrimoireCssError> {
712 let config_dir = Filesystem::get_or_create_grimoire_path(current_dir)?.join("config");
714
715 let mut all_variables = existing_variables.unwrap_or_default();
717 let mut existing_keys: HashSet<String> =
718 all_variables.iter().map(|(key, _)| key.clone()).collect();
719 let mut external_files_found = false;
720
721 let pattern = config_dir
723 .join("grimoire.*.variables.json")
724 .to_string_lossy()
725 .to_string();
726
727 match glob::glob(&pattern) {
728 Ok(entries) => {
729 for entry in entries.flatten() {
730 if let Some(file_name) = entry.file_name().and_then(|s| s.to_str()) {
731 match fs::read_to_string(&entry) {
733 Ok(content) => {
734 match serde_json::from_str::<serde_json::Value>(&content) {
735 Ok(json) => {
736 if let Some(variables) =
738 json.get("variables").and_then(|v| v.as_object())
739 {
740 external_files_found = true;
741
742 for (key, value) in variables {
744 if let Some(value_str) = value.as_str() {
745 if !existing_keys.contains(key) {
747 all_variables.push((
748 key.clone(),
749 value_str.to_string(),
750 ));
751 existing_keys.insert(key.clone());
752 }
753 }
755 }
756
757 add_message(format!(
758 "Loaded external variables from '{file_name}'"
759 ));
760 }
761 }
762 Err(err) => {
763 add_message(format!(
764 "Failed to parse external variables file '{file_name}': {err}"
765 ));
766 }
767 }
768 }
769 Err(err) => {
770 add_message(format!(
771 "Failed to read external variables file '{file_name}': {err}"
772 ));
773 }
774 }
775 }
776 }
777 }
778 Err(err) => {
779 add_message(format!(
780 "Failed to search for external variables files: {err}"
781 ));
782 }
783 }
784
785 if !all_variables.is_empty() {
787 all_variables.sort_by(|a, b| a.0.cmp(&b.0));
788
789 if external_files_found {
791 add_message("External variable files were merged with configuration".to_string());
792 }
793 Ok(Some(all_variables))
794 } else {
795 Ok(None)
796 }
797 }
798}
799
800#[cfg(test)]
801mod tests {
802 use super::*;
803 use std::fs::File;
804 use std::io::Write;
805 use tempfile::tempdir;
806
807 #[test]
808 fn test_default_config() {
809 let config = ConfigFs::default();
810 assert!(config.variables.is_none());
811 assert!(config.scrolls.is_none());
812 assert!(config.shared.is_none());
813 assert!(config.critical.is_none());
814 assert_eq!(config.projects.len(), 1);
815 assert_eq!(config.projects[0].project_name, "main");
816 }
817
818 #[test]
819 fn test_load_nonexistent_config() {
820 let dir = tempdir().unwrap();
821 let result = ConfigFs::load(dir.path());
822 assert!(result.is_err());
823 }
824
825 #[test]
826 fn test_save_and_load_config() {
827 let dir = tempdir().unwrap();
828 let config = ConfigFs::default();
829 config.save(dir.path()).expect("Failed to save config");
830
831 let loaded_config = ConfigFs::load(dir.path()).expect("Failed to load config");
832 assert_eq!(
833 config.projects[0].project_name,
834 loaded_config.projects[0].project_name
835 );
836 }
837
838 #[test]
839 fn test_expand_glob_patterns() {
840 let dir = tempdir().unwrap();
841 let file_path = dir.path().join("test.txt");
842 File::create(&file_path).unwrap();
843
844 let patterns = vec![format!("{}/**/*.txt", dir.path().to_str().unwrap())];
845 let expanded = ConfigFs::expand_glob_patterns(patterns);
846 assert_eq!(expanded.len(), 1);
847 assert!(expanded[0].ends_with("test.txt"));
848 }
849
850 #[test]
851 fn test_find_custom_animations_empty() {
852 let dir = tempdir().unwrap();
853 let animations = ConfigFs::find_custom_animations(dir.path()).unwrap();
854 assert!(animations.is_empty());
855 }
856
857 #[test]
858 fn test_find_custom_animations_with_files() {
859 let dir = tempdir().unwrap();
860 let animations_dir = dir.path().join("grimoire").join("animations");
861 fs::create_dir_all(&animations_dir).unwrap();
862
863 let animation_file = animations_dir.join("fade_in.css");
864 let mut file = File::create(&animation_file).unwrap();
865 writeln!(
866 file,
867 "@keyframes fade_in {{ from {{ opacity: 0; }} to {{ opacity: 1; }} }}"
868 )
869 .unwrap();
870
871 let animations = ConfigFs::find_custom_animations(dir.path()).unwrap();
872 assert_eq!(animations.len(), 1);
873 assert!(animations.contains_key("fade_in"));
874 }
875
876 #[test]
877 fn test_get_common_spells_set() {
878 let json = ConfigFsJSON {
879 schema: None,
880 variables: None,
881 scrolls: None,
882 projects: vec![],
883 shared: Some(vec![ConfigFsSharedJSON {
884 output_path: "styles.css".to_string(),
885 styles: Some(vec!["spell1".to_string(), "spell2".to_string()]),
886 css_custom_properties: None,
887 }]),
888 critical: Some(vec![ConfigFsCriticalJSON {
889 file_to_inline_paths: vec!["index.html".to_string()],
890 styles: Some(vec!["spell3".to_string()]),
891 css_custom_properties: None,
892 }]),
893 lock: None,
894 };
895
896 let common_spells = ConfigFs::get_common_spells_set(&json);
897 assert_eq!(common_spells.len(), 3);
898 assert!(common_spells.contains("spell1"));
899 assert!(common_spells.contains("spell2"));
900 assert!(common_spells.contains("spell3"));
901 }
902
903 #[test]
904 fn test_load_external_scrolls_no_files() {
905 let dir = tempdir().unwrap();
906 let config_dir = dir.path().join("grimoire").join("config");
907 fs::create_dir_all(&config_dir).unwrap();
908
909 let config_file = config_dir.join("grimoire.config.json");
911 let config_content = r#"{
912 "projects": [
913 {
914 "projectName": "main",
915 "inputPaths": []
916 }
917 ]
918 }"#;
919 fs::write(&config_file, config_content).unwrap();
920
921 let result = ConfigFs::load_external_scrolls(dir.path(), None).unwrap();
923 assert!(result.is_none());
924 }
925
926 #[test]
927 fn test_load_external_scrolls_single_file() {
928 let dir = tempdir().unwrap();
929 let config_dir = dir.path().join("grimoire").join("config");
930 fs::create_dir_all(&config_dir).unwrap();
931
932 let config_file = config_dir.join("grimoire.config.json");
934 let config_content = r#"{
935 "projects": [
936 {
937 "projectName": "main",
938 "inputPaths": []
939 }
940 ]
941 }"#;
942 fs::write(&config_file, config_content).unwrap();
943
944 let scrolls_file = config_dir.join("grimoire.tailwindcss.scrolls.json");
946 let scrolls_content = r#"{
947 "scrolls": [
948 {
949 "name": "tw-btn",
950 "spells": [
951 "p=4px",
952 "bg=blue",
953 "c=white",
954 "br=4px"
955 ]
956 }
957 ]
958 }"#;
959 fs::write(&scrolls_file, scrolls_content).unwrap();
960
961 let result = ConfigFs::load_external_scrolls(dir.path(), None).unwrap();
963 assert!(result.is_some());
964
965 let scrolls = result.unwrap();
966 assert_eq!(scrolls.len(), 1);
967 assert!(scrolls.contains_key("tw-btn"));
968 assert_eq!(scrolls.get("tw-btn").unwrap().len(), 4);
969 }
970
971 #[test]
972 fn test_load_external_scrolls_multiple_files() {
973 let dir = tempdir().unwrap();
974 let config_dir = dir.path().join("grimoire").join("config");
975 fs::create_dir_all(&config_dir).unwrap();
976
977 let config_file = config_dir.join("grimoire.config.json");
979 let config_content = r#"{
980 "projects": [
981 {
982 "projectName": "main",
983 "inputPaths": []
984 }
985 ]
986 }"#;
987 fs::write(&config_file, config_content).unwrap();
988
989 let scrolls_file1 = config_dir.join("grimoire.tailwindcss.scrolls.json");
991 let scrolls_content1 = r#"{
992 "scrolls": [
993 {
994 "name": "tw-btn",
995 "spells": [
996 "p=4px",
997 "bg=blue",
998 "c=white",
999 "br=4px"
1000 ]
1001 }
1002 ]
1003 }"#;
1004 fs::write(&scrolls_file1, scrolls_content1).unwrap();
1005
1006 let scrolls_file2 = config_dir.join("grimoire.bootstrap.scrolls.json");
1008 let scrolls_content2 = r#"{
1009 "scrolls": [
1010 {
1011 "name": "bs-card",
1012 "spells": [
1013 "border=1px_solid_#ccc",
1014 "br=8px",
1015 "shadow=0_2px_8px_rgba(0,0,0,0.1)"
1016 ]
1017 }
1018 ]
1019 }"#;
1020 fs::write(&scrolls_file2, scrolls_content2).unwrap();
1021
1022 let result = ConfigFs::load_external_scrolls(dir.path(), None).unwrap();
1024 assert!(result.is_some());
1025
1026 let scrolls = result.unwrap();
1027 assert_eq!(scrolls.len(), 2);
1028 assert!(scrolls.contains_key("tw-btn"));
1029 assert!(scrolls.contains_key("bs-card"));
1030 assert_eq!(scrolls.get("tw-btn").unwrap().len(), 4);
1031 assert_eq!(scrolls.get("bs-card").unwrap().len(), 3);
1032 }
1033
1034 #[test]
1035 fn test_merge_with_existing_scrolls() {
1036 let dir = tempdir().unwrap();
1037 let config_dir = dir.path().join("grimoire").join("config");
1038 fs::create_dir_all(&config_dir).unwrap();
1039
1040 let config_file = config_dir.join("grimoire.config.json");
1042 let config_content = r#"{
1043 "scrolls": [
1044 {
1045 "name": "main-btn",
1046 "spells": [
1047 "p=10px",
1048 "fw=bold",
1049 "c=black"
1050 ]
1051 }
1052 ],
1053 "projects": [
1054 {
1055 "projectName": "main",
1056 "inputPaths": []
1057 }
1058 ]
1059 }"#;
1060 fs::write(&config_file, config_content).unwrap();
1061
1062 let scrolls_file = config_dir.join("grimoire.extra.scrolls.json");
1064 let scrolls_content = r#"{
1065 "scrolls": [
1066 {
1067 "name": "main-btn",
1068 "spells": [
1069 "bg=green",
1070 "hover:bg=darkgreen"
1071 ]
1072 },
1073 {
1074 "name": "extra-btn",
1075 "spells": [
1076 "fs=16px",
1077 "m=10px"
1078 ]
1079 }
1080 ]
1081 }"#;
1082 fs::write(&scrolls_file, scrolls_content).unwrap();
1083
1084 let mut existing_scrolls = HashMap::new();
1086 existing_scrolls.insert(
1087 "main-btn".to_string(),
1088 vec![
1089 "p=10px".to_string(),
1090 "fw=bold".to_string(),
1091 "c=black".to_string(),
1092 ],
1093 );
1094
1095 let result = ConfigFs::load_external_scrolls(dir.path(), Some(existing_scrolls)).unwrap();
1097 assert!(result.is_some());
1098
1099 let scrolls = result.unwrap();
1100 assert_eq!(scrolls.len(), 2);
1101
1102 assert!(scrolls.contains_key("main-btn"));
1104 assert_eq!(scrolls.get("main-btn").unwrap().len(), 3);
1105
1106 assert!(scrolls.contains_key("extra-btn"));
1108 assert_eq!(scrolls.get("extra-btn").unwrap().len(), 2);
1109 }
1110
1111 #[test]
1112 fn test_full_config_with_external_scrolls() {
1113 let dir = tempdir().unwrap();
1114 let config_dir = dir.path().join("grimoire").join("config");
1115 fs::create_dir_all(&config_dir).unwrap();
1116
1117 let config_file = config_dir.join("grimoire.config.json");
1119 let config_content = r#"{
1120 "scrolls": [
1121 {
1122 "name": "base-btn",
1123 "spells": [
1124 "p=10px",
1125 "br=4px"
1126 ]
1127 }
1128 ],
1129 "projects": [
1130 {
1131 "projectName": "main",
1132 "inputPaths": []
1133 }
1134 ]
1135 }"#;
1136 fs::write(&config_file, config_content).unwrap();
1137
1138 let scrolls_file = config_dir.join("grimoire.theme.scrolls.json");
1140 let scrolls_content = r#"{
1141 "scrolls": [
1142 {
1143 "name": "theme-btn",
1144 "spells": [
1145 "bg=purple",
1146 "c=white"
1147 ]
1148 }
1149 ]
1150 }"#;
1151 fs::write(&scrolls_file, scrolls_content).unwrap();
1152
1153 let config = ConfigFs::load(dir.path()).expect("Failed to load config");
1155
1156 assert!(config.scrolls.is_some());
1158 let scrolls = config.scrolls.unwrap();
1159 assert_eq!(scrolls.len(), 2);
1160 assert!(scrolls.contains_key("base-btn"));
1161 assert!(scrolls.contains_key("theme-btn"));
1162 }
1163
1164 #[test]
1165 fn test_load_external_variables_no_files() {
1166 let dir = tempdir().unwrap();
1167 let config_dir = dir.path().join("grimoire").join("config");
1168 fs::create_dir_all(&config_dir).unwrap();
1169
1170 let config_file = config_dir.join("grimoire.config.json");
1172 let config_content = r#"{
1173 "projects": [
1174 {
1175 "projectName": "main",
1176 "inputPaths": []
1177 }
1178 ]
1179 }"#;
1180 fs::write(&config_file, config_content).unwrap();
1181
1182 let result = ConfigFs::load_external_variables(dir.path(), None).unwrap();
1184 assert!(result.is_none());
1185 }
1186
1187 #[test]
1188 fn test_load_external_variables_single_file() {
1189 let dir = tempdir().unwrap();
1190 let config_dir = dir.path().join("grimoire").join("config");
1191 fs::create_dir_all(&config_dir).unwrap();
1192
1193 let config_file = config_dir.join("grimoire.config.json");
1195 let config_content = r#"{
1196 "projects": [
1197 {
1198 "projectName": "main",
1199 "inputPaths": []
1200 }
1201 ]
1202 }"#;
1203 fs::write(&config_file, config_content).unwrap();
1204
1205 let vars_file = config_dir.join("grimoire.theme.variables.json");
1207 let vars_content = r##"{
1208 "variables": {
1209 "primary-color": "#3366ff",
1210 "secondary-color": "#ff6633",
1211 "font-size-base": "16px"
1212 }
1213 }"##;
1214 fs::write(&vars_file, vars_content).unwrap();
1215
1216 let result = ConfigFs::load_external_variables(dir.path(), None).unwrap();
1218 assert!(result.is_some());
1219
1220 let variables = result.unwrap();
1221 assert_eq!(variables.len(), 3);
1222
1223 assert_eq!(variables[0].0, "font-size-base");
1225 assert_eq!(variables[0].1, "16px");
1226 assert_eq!(variables[1].0, "primary-color");
1227 assert_eq!(variables[1].1, "#3366ff");
1228 assert_eq!(variables[2].0, "secondary-color");
1229 assert_eq!(variables[2].1, "#ff6633");
1230 }
1231
1232 #[test]
1233 fn test_load_external_variables_multiple_files() {
1234 let dir = tempdir().unwrap();
1235 let config_dir = dir.path().join("grimoire").join("config");
1236 fs::create_dir_all(&config_dir).unwrap();
1237
1238 let config_file = config_dir.join("grimoire.config.json");
1240 let config_content = r#"{
1241 "projects": [
1242 {
1243 "projectName": "main",
1244 "inputPaths": []
1245 }
1246 ]
1247 }"#;
1248 fs::write(&config_file, config_content).unwrap();
1249
1250 let vars_file1 = config_dir.join("grimoire.colors.variables.json");
1252 let vars_content1 = r##"{
1253 "variables": {
1254 "primary-color": "#3366ff",
1255 "secondary-color": "#ff6633"
1256 }
1257 }"##;
1258 fs::write(&vars_file1, vars_content1).unwrap();
1259
1260 let vars_file2 = config_dir.join("grimoire.typography.variables.json");
1262 let vars_content2 = r##"{
1263 "variables": {
1264 "font-size-base": "16px",
1265 "font-family-sans": "Arial, sans-serif"
1266 }
1267 }"##;
1268 fs::write(&vars_file2, vars_content2).unwrap();
1269
1270 let result = ConfigFs::load_external_variables(dir.path(), None).unwrap();
1272 assert!(result.is_some());
1273
1274 let variables = result.unwrap();
1275 assert_eq!(variables.len(), 4);
1276
1277 let var_map: HashMap<String, String> = variables.into_iter().collect();
1279 assert!(var_map.contains_key("primary-color"));
1280 assert!(var_map.contains_key("secondary-color"));
1281 assert!(var_map.contains_key("font-size-base"));
1282 assert!(var_map.contains_key("font-family-sans"));
1283
1284 assert_eq!(var_map.get("primary-color").unwrap(), "#3366ff");
1285 assert_eq!(
1286 var_map.get("font-family-sans").unwrap(),
1287 "Arial, sans-serif"
1288 );
1289 }
1290
1291 #[test]
1292 fn test_merge_with_existing_variables() {
1293 let dir = tempdir().unwrap();
1294 let config_dir = dir.path().join("grimoire").join("config");
1295 fs::create_dir_all(&config_dir).unwrap();
1296
1297 let config_file = config_dir.join("grimoire.config.json");
1299 let config_content = r##"{
1300 "variables": {
1301 "primary-color": "#3366ff",
1302 "font-size-base": "16px"
1303 },
1304 "projects": [
1305 {
1306 "projectName": "main",
1307 "inputPaths": []
1308 }
1309 ]
1310 }"##;
1311 fs::write(&config_file, config_content).unwrap();
1312
1313 let vars_file = config_dir.join("grimoire.extra.variables.json");
1315 let vars_content = r##"{
1316 "variables": {
1317 "secondary-color": "#ff6633",
1318 "primary-color": "#ff0000",
1319 "spacing-unit": "8px"
1320 }
1321 }"##;
1322 fs::write(&vars_file, vars_content).unwrap();
1323
1324 let existing_variables = vec![
1326 ("primary-color".to_string(), "#3366ff".to_string()),
1327 ("font-size-base".to_string(), "16px".to_string()),
1328 ];
1329
1330 let result =
1332 ConfigFs::load_external_variables(dir.path(), Some(existing_variables)).unwrap();
1333 assert!(result.is_some());
1334
1335 let variables = result.unwrap();
1336 assert_eq!(variables.len(), 4); let var_map: HashMap<String, String> = variables.into_iter().collect();
1340
1341 assert_eq!(var_map.get("primary-color").unwrap(), "#3366ff");
1343
1344 assert_eq!(var_map.get("secondary-color").unwrap(), "#ff6633");
1346 assert_eq!(var_map.get("spacing-unit").unwrap(), "8px");
1347
1348 assert_eq!(var_map.get("font-size-base").unwrap(), "16px");
1350 }
1351
1352 #[test]
1353 fn test_full_config_with_external_variables() {
1354 let dir = tempdir().unwrap();
1355 let config_dir = dir.path().join("grimoire").join("config");
1356 fs::create_dir_all(&config_dir).unwrap();
1357
1358 let config_file = config_dir.join("grimoire.config.json");
1360 let config_content = r##"{
1361 "variables": {
1362 "primary-color": "#3366ff"
1363 },
1364 "projects": [
1365 {
1366 "projectName": "main",
1367 "inputPaths": []
1368 }
1369 ]
1370 }"##;
1371 fs::write(&config_file, config_content).unwrap();
1372
1373 let vars_file = config_dir.join("grimoire.theme.variables.json");
1375 let vars_content = r##"{
1376 "variables": {
1377 "secondary-color": "#ff6633",
1378 "spacing-unit": "8px"
1379 }
1380 }"##;
1381 fs::write(&vars_file, vars_content).unwrap();
1382
1383 let config = ConfigFs::load(dir.path()).expect("Failed to load config");
1385
1386 assert!(config.variables.is_some());
1388 let variables = config.variables.unwrap();
1389 assert_eq!(variables.len(), 3);
1390
1391 assert_eq!(variables[0].0, "primary-color");
1393 assert_eq!(variables[1].0, "secondary-color");
1394 assert_eq!(variables[2].0, "spacing-unit");
1395 }
1396}