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 '{}'",
657 file_name
658 ));
659 }
660 }
661 Err(err) => {
662 add_message(format!(
663 "Failed to parse external scroll file '{}': {}",
664 file_name, err
665 ));
666 }
667 }
668 }
669 Err(err) => {
670 add_message(format!(
671 "Failed to read external scroll file '{}': {}",
672 file_name, err
673 ));
674 }
675 }
676 }
677 }
678 }
679 Err(err) => {
680 add_message(format!(
681 "Failed to search for external scroll files: {}",
682 err
683 ));
684 }
685 }
686
687 if all_scrolls.is_empty() {
689 Ok(None)
690 } else {
691 if external_files_found {
693 add_message("External scroll files were merged with configuration".to_string());
694 }
695 Ok(Some(all_scrolls))
696 }
697 }
698
699 fn load_external_variables(
715 current_dir: &Path,
716 existing_variables: Option<Vec<(String, String)>>,
717 ) -> Result<Option<Vec<(String, String)>>, GrimoireCssError> {
718 let config_dir = Filesystem::get_or_create_grimoire_path(current_dir)?.join("config");
720
721 let mut all_variables = existing_variables.unwrap_or_default();
723 let mut existing_keys: HashSet<String> =
724 all_variables.iter().map(|(key, _)| key.clone()).collect();
725 let mut external_files_found = false;
726
727 let pattern = config_dir
729 .join("grimoire.*.variables.json")
730 .to_string_lossy()
731 .to_string();
732
733 match glob::glob(&pattern) {
734 Ok(entries) => {
735 for entry in entries.flatten() {
736 if let Some(file_name) = entry.file_name().and_then(|s| s.to_str()) {
737 match fs::read_to_string(&entry) {
739 Ok(content) => {
740 match serde_json::from_str::<serde_json::Value>(&content) {
741 Ok(json) => {
742 if let Some(variables) =
744 json.get("variables").and_then(|v| v.as_object())
745 {
746 external_files_found = true;
747
748 for (key, value) in variables {
750 if let Some(value_str) = value.as_str() {
751 if !existing_keys.contains(key) {
753 all_variables.push((
754 key.clone(),
755 value_str.to_string(),
756 ));
757 existing_keys.insert(key.clone());
758 }
759 }
761 }
762
763 add_message(format!(
764 "Loaded external variables from '{}'",
765 file_name
766 ));
767 }
768 }
769 Err(err) => {
770 add_message(format!(
771 "Failed to parse external variables file '{}': {}",
772 file_name, err
773 ));
774 }
775 }
776 }
777 Err(err) => {
778 add_message(format!(
779 "Failed to read external variables file '{}': {}",
780 file_name, err
781 ));
782 }
783 }
784 }
785 }
786 }
787 Err(err) => {
788 add_message(format!(
789 "Failed to search for external variables files: {}",
790 err
791 ));
792 }
793 }
794
795 if !all_variables.is_empty() {
797 all_variables.sort_by(|a, b| a.0.cmp(&b.0));
798
799 if external_files_found {
801 add_message("External variable files were merged with configuration".to_string());
802 }
803 Ok(Some(all_variables))
804 } else {
805 Ok(None)
806 }
807 }
808}
809
810#[cfg(test)]
811mod tests {
812 use super::*;
813 use std::fs::File;
814 use std::io::Write;
815 use tempfile::tempdir;
816
817 #[test]
818 fn test_default_config() {
819 let config = ConfigFs::default();
820 assert!(config.variables.is_none());
821 assert!(config.scrolls.is_none());
822 assert!(config.shared.is_none());
823 assert!(config.critical.is_none());
824 assert_eq!(config.projects.len(), 1);
825 assert_eq!(config.projects[0].project_name, "main");
826 }
827
828 #[test]
829 fn test_load_nonexistent_config() {
830 let dir = tempdir().unwrap();
831 let result = ConfigFs::load(dir.path());
832 assert!(result.is_err());
833 }
834
835 #[test]
836 fn test_save_and_load_config() {
837 let dir = tempdir().unwrap();
838 let config = ConfigFs::default();
839 config.save(dir.path()).expect("Failed to save config");
840
841 let loaded_config = ConfigFs::load(dir.path()).expect("Failed to load config");
842 assert_eq!(
843 config.projects[0].project_name,
844 loaded_config.projects[0].project_name
845 );
846 }
847
848 #[test]
849 fn test_expand_glob_patterns() {
850 let dir = tempdir().unwrap();
851 let file_path = dir.path().join("test.txt");
852 File::create(&file_path).unwrap();
853
854 let patterns = vec![format!("{}/**/*.txt", dir.path().to_str().unwrap())];
855 let expanded = ConfigFs::expand_glob_patterns(patterns);
856 assert_eq!(expanded.len(), 1);
857 assert!(expanded[0].ends_with("test.txt"));
858 }
859
860 #[test]
861 fn test_find_custom_animations_empty() {
862 let dir = tempdir().unwrap();
863 let animations = ConfigFs::find_custom_animations(dir.path()).unwrap();
864 assert!(animations.is_empty());
865 }
866
867 #[test]
868 fn test_find_custom_animations_with_files() {
869 let dir = tempdir().unwrap();
870 let animations_dir = dir.path().join("grimoire").join("animations");
871 fs::create_dir_all(&animations_dir).unwrap();
872
873 let animation_file = animations_dir.join("fade_in.css");
874 let mut file = File::create(&animation_file).unwrap();
875 writeln!(
876 file,
877 "@keyframes fade_in {{ from {{ opacity: 0; }} to {{ opacity: 1; }} }}"
878 )
879 .unwrap();
880
881 let animations = ConfigFs::find_custom_animations(dir.path()).unwrap();
882 assert_eq!(animations.len(), 1);
883 assert!(animations.contains_key("fade_in"));
884 }
885
886 #[test]
887 fn test_get_common_spells_set() {
888 let json = ConfigFsJSON {
889 schema: None,
890 variables: None,
891 scrolls: None,
892 projects: vec![],
893 shared: Some(vec![ConfigFsSharedJSON {
894 output_path: "styles.css".to_string(),
895 styles: Some(vec!["spell1".to_string(), "spell2".to_string()]),
896 css_custom_properties: None,
897 }]),
898 critical: Some(vec![ConfigFsCriticalJSON {
899 file_to_inline_paths: vec!["index.html".to_string()],
900 styles: Some(vec!["spell3".to_string()]),
901 css_custom_properties: None,
902 }]),
903 lock: None,
904 };
905
906 let common_spells = ConfigFs::get_common_spells_set(&json);
907 assert_eq!(common_spells.len(), 3);
908 assert!(common_spells.contains("spell1"));
909 assert!(common_spells.contains("spell2"));
910 assert!(common_spells.contains("spell3"));
911 }
912
913 #[test]
914 fn test_load_external_scrolls_no_files() {
915 let dir = tempdir().unwrap();
916 let config_dir = dir.path().join("grimoire").join("config");
917 fs::create_dir_all(&config_dir).unwrap();
918
919 let config_file = config_dir.join("grimoire.config.json");
921 let config_content = r#"{
922 "projects": [
923 {
924 "projectName": "main",
925 "inputPaths": []
926 }
927 ]
928 }"#;
929 fs::write(&config_file, config_content).unwrap();
930
931 let result = ConfigFs::load_external_scrolls(dir.path(), None).unwrap();
933 assert!(result.is_none());
934 }
935
936 #[test]
937 fn test_load_external_scrolls_single_file() {
938 let dir = tempdir().unwrap();
939 let config_dir = dir.path().join("grimoire").join("config");
940 fs::create_dir_all(&config_dir).unwrap();
941
942 let config_file = config_dir.join("grimoire.config.json");
944 let config_content = r#"{
945 "projects": [
946 {
947 "projectName": "main",
948 "inputPaths": []
949 }
950 ]
951 }"#;
952 fs::write(&config_file, config_content).unwrap();
953
954 let scrolls_file = config_dir.join("grimoire.tailwindcss.scrolls.json");
956 let scrolls_content = r#"{
957 "scrolls": [
958 {
959 "name": "tw-btn",
960 "spells": [
961 "p=4px",
962 "bg=blue",
963 "c=white",
964 "br=4px"
965 ]
966 }
967 ]
968 }"#;
969 fs::write(&scrolls_file, scrolls_content).unwrap();
970
971 let result = ConfigFs::load_external_scrolls(dir.path(), None).unwrap();
973 assert!(result.is_some());
974
975 let scrolls = result.unwrap();
976 assert_eq!(scrolls.len(), 1);
977 assert!(scrolls.contains_key("tw-btn"));
978 assert_eq!(scrolls.get("tw-btn").unwrap().len(), 4);
979 }
980
981 #[test]
982 fn test_load_external_scrolls_multiple_files() {
983 let dir = tempdir().unwrap();
984 let config_dir = dir.path().join("grimoire").join("config");
985 fs::create_dir_all(&config_dir).unwrap();
986
987 let config_file = config_dir.join("grimoire.config.json");
989 let config_content = r#"{
990 "projects": [
991 {
992 "projectName": "main",
993 "inputPaths": []
994 }
995 ]
996 }"#;
997 fs::write(&config_file, config_content).unwrap();
998
999 let scrolls_file1 = config_dir.join("grimoire.tailwindcss.scrolls.json");
1001 let scrolls_content1 = r#"{
1002 "scrolls": [
1003 {
1004 "name": "tw-btn",
1005 "spells": [
1006 "p=4px",
1007 "bg=blue",
1008 "c=white",
1009 "br=4px"
1010 ]
1011 }
1012 ]
1013 }"#;
1014 fs::write(&scrolls_file1, scrolls_content1).unwrap();
1015
1016 let scrolls_file2 = config_dir.join("grimoire.bootstrap.scrolls.json");
1018 let scrolls_content2 = r#"{
1019 "scrolls": [
1020 {
1021 "name": "bs-card",
1022 "spells": [
1023 "border=1px_solid_#ccc",
1024 "br=8px",
1025 "shadow=0_2px_8px_rgba(0,0,0,0.1)"
1026 ]
1027 }
1028 ]
1029 }"#;
1030 fs::write(&scrolls_file2, scrolls_content2).unwrap();
1031
1032 let result = ConfigFs::load_external_scrolls(dir.path(), None).unwrap();
1034 assert!(result.is_some());
1035
1036 let scrolls = result.unwrap();
1037 assert_eq!(scrolls.len(), 2);
1038 assert!(scrolls.contains_key("tw-btn"));
1039 assert!(scrolls.contains_key("bs-card"));
1040 assert_eq!(scrolls.get("tw-btn").unwrap().len(), 4);
1041 assert_eq!(scrolls.get("bs-card").unwrap().len(), 3);
1042 }
1043
1044 #[test]
1045 fn test_merge_with_existing_scrolls() {
1046 let dir = tempdir().unwrap();
1047 let config_dir = dir.path().join("grimoire").join("config");
1048 fs::create_dir_all(&config_dir).unwrap();
1049
1050 let config_file = config_dir.join("grimoire.config.json");
1052 let config_content = r#"{
1053 "scrolls": [
1054 {
1055 "name": "main-btn",
1056 "spells": [
1057 "p=10px",
1058 "fw=bold",
1059 "c=black"
1060 ]
1061 }
1062 ],
1063 "projects": [
1064 {
1065 "projectName": "main",
1066 "inputPaths": []
1067 }
1068 ]
1069 }"#;
1070 fs::write(&config_file, config_content).unwrap();
1071
1072 let scrolls_file = config_dir.join("grimoire.extra.scrolls.json");
1074 let scrolls_content = r#"{
1075 "scrolls": [
1076 {
1077 "name": "main-btn",
1078 "spells": [
1079 "bg=green",
1080 "hover:bg=darkgreen"
1081 ]
1082 },
1083 {
1084 "name": "extra-btn",
1085 "spells": [
1086 "fs=16px",
1087 "m=10px"
1088 ]
1089 }
1090 ]
1091 }"#;
1092 fs::write(&scrolls_file, scrolls_content).unwrap();
1093
1094 let mut existing_scrolls = HashMap::new();
1096 existing_scrolls.insert(
1097 "main-btn".to_string(),
1098 vec![
1099 "p=10px".to_string(),
1100 "fw=bold".to_string(),
1101 "c=black".to_string(),
1102 ],
1103 );
1104
1105 let result = ConfigFs::load_external_scrolls(dir.path(), Some(existing_scrolls)).unwrap();
1107 assert!(result.is_some());
1108
1109 let scrolls = result.unwrap();
1110 assert_eq!(scrolls.len(), 2);
1111
1112 assert!(scrolls.contains_key("main-btn"));
1114 assert_eq!(scrolls.get("main-btn").unwrap().len(), 3);
1115
1116 assert!(scrolls.contains_key("extra-btn"));
1118 assert_eq!(scrolls.get("extra-btn").unwrap().len(), 2);
1119 }
1120
1121 #[test]
1122 fn test_full_config_with_external_scrolls() {
1123 let dir = tempdir().unwrap();
1124 let config_dir = dir.path().join("grimoire").join("config");
1125 fs::create_dir_all(&config_dir).unwrap();
1126
1127 let config_file = config_dir.join("grimoire.config.json");
1129 let config_content = r#"{
1130 "scrolls": [
1131 {
1132 "name": "base-btn",
1133 "spells": [
1134 "p=10px",
1135 "br=4px"
1136 ]
1137 }
1138 ],
1139 "projects": [
1140 {
1141 "projectName": "main",
1142 "inputPaths": []
1143 }
1144 ]
1145 }"#;
1146 fs::write(&config_file, config_content).unwrap();
1147
1148 let scrolls_file = config_dir.join("grimoire.theme.scrolls.json");
1150 let scrolls_content = r#"{
1151 "scrolls": [
1152 {
1153 "name": "theme-btn",
1154 "spells": [
1155 "bg=purple",
1156 "c=white"
1157 ]
1158 }
1159 ]
1160 }"#;
1161 fs::write(&scrolls_file, scrolls_content).unwrap();
1162
1163 let config = ConfigFs::load(dir.path()).expect("Failed to load config");
1165
1166 assert!(config.scrolls.is_some());
1168 let scrolls = config.scrolls.unwrap();
1169 assert_eq!(scrolls.len(), 2);
1170 assert!(scrolls.contains_key("base-btn"));
1171 assert!(scrolls.contains_key("theme-btn"));
1172 }
1173
1174 #[test]
1175 fn test_load_external_variables_no_files() {
1176 let dir = tempdir().unwrap();
1177 let config_dir = dir.path().join("grimoire").join("config");
1178 fs::create_dir_all(&config_dir).unwrap();
1179
1180 let config_file = config_dir.join("grimoire.config.json");
1182 let config_content = r#"{
1183 "projects": [
1184 {
1185 "projectName": "main",
1186 "inputPaths": []
1187 }
1188 ]
1189 }"#;
1190 fs::write(&config_file, config_content).unwrap();
1191
1192 let result = ConfigFs::load_external_variables(dir.path(), None).unwrap();
1194 assert!(result.is_none());
1195 }
1196
1197 #[test]
1198 fn test_load_external_variables_single_file() {
1199 let dir = tempdir().unwrap();
1200 let config_dir = dir.path().join("grimoire").join("config");
1201 fs::create_dir_all(&config_dir).unwrap();
1202
1203 let config_file = config_dir.join("grimoire.config.json");
1205 let config_content = r#"{
1206 "projects": [
1207 {
1208 "projectName": "main",
1209 "inputPaths": []
1210 }
1211 ]
1212 }"#;
1213 fs::write(&config_file, config_content).unwrap();
1214
1215 let vars_file = config_dir.join("grimoire.theme.variables.json");
1217 let vars_content = r##"{
1218 "variables": {
1219 "primary-color": "#3366ff",
1220 "secondary-color": "#ff6633",
1221 "font-size-base": "16px"
1222 }
1223 }"##;
1224 fs::write(&vars_file, vars_content).unwrap();
1225
1226 let result = ConfigFs::load_external_variables(dir.path(), None).unwrap();
1228 assert!(result.is_some());
1229
1230 let variables = result.unwrap();
1231 assert_eq!(variables.len(), 3);
1232
1233 assert_eq!(variables[0].0, "font-size-base");
1235 assert_eq!(variables[0].1, "16px");
1236 assert_eq!(variables[1].0, "primary-color");
1237 assert_eq!(variables[1].1, "#3366ff");
1238 assert_eq!(variables[2].0, "secondary-color");
1239 assert_eq!(variables[2].1, "#ff6633");
1240 }
1241
1242 #[test]
1243 fn test_load_external_variables_multiple_files() {
1244 let dir = tempdir().unwrap();
1245 let config_dir = dir.path().join("grimoire").join("config");
1246 fs::create_dir_all(&config_dir).unwrap();
1247
1248 let config_file = config_dir.join("grimoire.config.json");
1250 let config_content = r#"{
1251 "projects": [
1252 {
1253 "projectName": "main",
1254 "inputPaths": []
1255 }
1256 ]
1257 }"#;
1258 fs::write(&config_file, config_content).unwrap();
1259
1260 let vars_file1 = config_dir.join("grimoire.colors.variables.json");
1262 let vars_content1 = r##"{
1263 "variables": {
1264 "primary-color": "#3366ff",
1265 "secondary-color": "#ff6633"
1266 }
1267 }"##;
1268 fs::write(&vars_file1, vars_content1).unwrap();
1269
1270 let vars_file2 = config_dir.join("grimoire.typography.variables.json");
1272 let vars_content2 = r##"{
1273 "variables": {
1274 "font-size-base": "16px",
1275 "font-family-sans": "Arial, sans-serif"
1276 }
1277 }"##;
1278 fs::write(&vars_file2, vars_content2).unwrap();
1279
1280 let result = ConfigFs::load_external_variables(dir.path(), None).unwrap();
1282 assert!(result.is_some());
1283
1284 let variables = result.unwrap();
1285 assert_eq!(variables.len(), 4);
1286
1287 let var_map: HashMap<String, String> = variables.into_iter().collect();
1289 assert!(var_map.contains_key("primary-color"));
1290 assert!(var_map.contains_key("secondary-color"));
1291 assert!(var_map.contains_key("font-size-base"));
1292 assert!(var_map.contains_key("font-family-sans"));
1293
1294 assert_eq!(var_map.get("primary-color").unwrap(), "#3366ff");
1295 assert_eq!(
1296 var_map.get("font-family-sans").unwrap(),
1297 "Arial, sans-serif"
1298 );
1299 }
1300
1301 #[test]
1302 fn test_merge_with_existing_variables() {
1303 let dir = tempdir().unwrap();
1304 let config_dir = dir.path().join("grimoire").join("config");
1305 fs::create_dir_all(&config_dir).unwrap();
1306
1307 let config_file = config_dir.join("grimoire.config.json");
1309 let config_content = r##"{
1310 "variables": {
1311 "primary-color": "#3366ff",
1312 "font-size-base": "16px"
1313 },
1314 "projects": [
1315 {
1316 "projectName": "main",
1317 "inputPaths": []
1318 }
1319 ]
1320 }"##;
1321 fs::write(&config_file, config_content).unwrap();
1322
1323 let vars_file = config_dir.join("grimoire.extra.variables.json");
1325 let vars_content = r##"{
1326 "variables": {
1327 "secondary-color": "#ff6633",
1328 "primary-color": "#ff0000",
1329 "spacing-unit": "8px"
1330 }
1331 }"##;
1332 fs::write(&vars_file, vars_content).unwrap();
1333
1334 let existing_variables = vec![
1336 ("primary-color".to_string(), "#3366ff".to_string()),
1337 ("font-size-base".to_string(), "16px".to_string()),
1338 ];
1339
1340 let result =
1342 ConfigFs::load_external_variables(dir.path(), Some(existing_variables)).unwrap();
1343 assert!(result.is_some());
1344
1345 let variables = result.unwrap();
1346 assert_eq!(variables.len(), 4); let var_map: HashMap<String, String> = variables.into_iter().collect();
1350
1351 assert_eq!(var_map.get("primary-color").unwrap(), "#3366ff");
1353
1354 assert_eq!(var_map.get("secondary-color").unwrap(), "#ff6633");
1356 assert_eq!(var_map.get("spacing-unit").unwrap(), "8px");
1357
1358 assert_eq!(var_map.get("font-size-base").unwrap(), "16px");
1360 }
1361
1362 #[test]
1363 fn test_full_config_with_external_variables() {
1364 let dir = tempdir().unwrap();
1365 let config_dir = dir.path().join("grimoire").join("config");
1366 fs::create_dir_all(&config_dir).unwrap();
1367
1368 let config_file = config_dir.join("grimoire.config.json");
1370 let config_content = r##"{
1371 "variables": {
1372 "primary-color": "#3366ff"
1373 },
1374 "projects": [
1375 {
1376 "projectName": "main",
1377 "inputPaths": []
1378 }
1379 ]
1380 }"##;
1381 fs::write(&config_file, config_content).unwrap();
1382
1383 let vars_file = config_dir.join("grimoire.theme.variables.json");
1385 let vars_content = r##"{
1386 "variables": {
1387 "secondary-color": "#ff6633",
1388 "spacing-unit": "8px"
1389 }
1390 }"##;
1391 fs::write(&vars_file, vars_content).unwrap();
1392
1393 let config = ConfigFs::load(dir.path()).expect("Failed to load config");
1395
1396 assert!(config.variables.is_some());
1398 let variables = config.variables.unwrap();
1399 assert_eq!(variables.len(), 3);
1400
1401 assert_eq!(variables[0].0, "primary-color");
1403 assert_eq!(variables[1].0, "secondary-color");
1404 assert_eq!(variables[2].0, "spacing-unit");
1405 }
1406}