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, PathBuf},
13};
14
15pub(crate) fn external_config_files(
17 config_dir: &Path,
18 suffix: &str,
19) -> Result<Vec<PathBuf>, GrimoireCssError> {
20 let mut paths = fs::read_dir(config_dir)?
21 .filter_map(Result::ok)
22 .map(|entry| entry.path())
23 .filter(|path| {
24 path.is_file()
25 && path
26 .file_name()
27 .and_then(|name| name.to_str())
28 .and_then(|name| name.strip_prefix("grimoire."))
29 .is_some_and(|name| name.ends_with(suffix))
30 })
31 .collect::<Vec<_>>();
32 paths.sort();
33 Ok(paths)
34}
35
36#[derive(Debug, Clone)]
38pub struct ConfigFs {
39 pub grimoire_css_version: Option<String>,
41 pub variables: Option<Vec<(String, String)>>,
42 pub scrolls: Option<HashMap<String, ScrollDefinition>>,
43 pub projects: Vec<ConfigFsProject>,
44 pub shared: Option<Vec<ConfigFsShared>>,
45 pub critical: Option<Vec<ConfigFsCritical>>,
46 pub shared_spells: HashSet<String>,
48 pub lock: Option<bool>,
49
50 pub custom_animations: HashMap<String, String>,
51}
52
53#[derive(Debug, Clone)]
55pub struct ConfigFsShared {
56 pub output_path: String,
57 pub styles: Option<Vec<String>>,
58 pub css_custom_properties: Option<Vec<ConfigFsCssCustomProperties>>,
59}
60
61#[derive(Debug, Clone)]
63pub struct ConfigFsCritical {
64 pub file_to_inline_paths: Vec<String>,
65 pub styles: Option<Vec<String>>,
66 pub css_custom_properties: Option<Vec<ConfigFsCssCustomProperties>>,
67}
68
69#[derive(Debug, Clone)]
71pub struct ConfigFsCssCustomProperties {
72 pub element: String,
73 pub data_param: String,
74 pub data_value: String,
75 pub css_variables: Vec<(String, String)>,
76}
77
78#[derive(Debug, Clone)]
80pub struct ConfigFsProject {
81 pub project_name: String,
82 pub input_paths: Vec<String>,
83 pub output_dir_path: Option<String>,
84 pub single_output_file_name: Option<String>,
85}
86
87#[derive(Serialize, Deserialize, Debug, Clone)]
93struct ConfigFsJSON {
94 #[serde(rename = "$schema")]
95 pub schema: Option<String>,
96 pub version: Option<String>,
97 pub variables: Option<HashMap<String, String>>,
99 pub scrolls: Option<Vec<ConfigFsScrollJSON>>,
101 pub projects: Vec<ConfigFsProjectJSON>,
103 pub shared: Option<Vec<ConfigFsSharedJSON>>,
104 pub critical: Option<Vec<ConfigFsCriticalJSON>>,
105 pub lock: Option<bool>,
106}
107
108#[derive(Serialize, Deserialize, Debug, Clone)]
110pub struct ConfigFsScrollJSON {
111 pub name: String,
112 pub spells: Vec<String>,
113 #[serde(rename = "spellsByArgs")]
114 pub spells_by_args: Option<HashMap<String, Vec<String>>>,
115 pub extends: Option<Vec<String>>,
116}
117
118#[derive(Serialize, Deserialize, Debug, Clone)]
120#[serde(rename_all = "camelCase")]
121struct ConfigFsProjectJSON {
122 pub project_name: String,
124 pub input_paths: Vec<String>,
126 pub output_dir_path: Option<String>,
128 pub single_output_file_name: Option<String>,
130}
131
132#[derive(Serialize, Deserialize, Debug, Clone)]
134#[serde(rename_all = "camelCase")]
135struct ConfigFsSharedJSON {
136 pub output_path: String,
137 pub styles: Option<Vec<String>>,
138 pub css_custom_properties: Option<Vec<ConfigFsCSSCustomPropertiesJSON>>,
139}
140
141#[derive(Serialize, Deserialize, Debug, Clone)]
143#[serde(rename_all = "camelCase")]
144struct ConfigFsCriticalJSON {
145 pub file_to_inline_paths: Vec<String>,
146 pub styles: Option<Vec<String>>,
147 pub css_custom_properties: Option<Vec<ConfigFsCSSCustomPropertiesJSON>>,
148}
149
150#[derive(Serialize, Deserialize, Debug, Clone)]
152#[serde(rename_all = "camelCase")]
153struct ConfigFsCSSCustomPropertiesJSON {
154 pub element: Option<String>,
156 pub data_param: String,
158 pub data_value: String,
160 pub css_variables: HashMap<String, String>,
162}
163
164impl Default for ConfigFs {
165 fn default() -> Self {
167 let projects = vec![ConfigFsProject {
168 project_name: "main".to_string(),
169 input_paths: Vec::new(),
170 output_dir_path: None,
171 single_output_file_name: None,
172 }];
173
174 Self {
175 grimoire_css_version: Some(env!("CARGO_PKG_VERSION").to_string()),
176 scrolls: None,
177 shared: None,
178 critical: None,
179 projects,
180 variables: None,
181 shared_spells: HashSet::new(),
182 custom_animations: HashMap::new(),
183 lock: None,
184 }
185 }
186}
187
188impl ConfigFs {
189 pub fn load(current_dir: &Path) -> Result<Self, GrimoireCssError> {
199 let config_path = Filesystem::get_config_path(current_dir)?;
200 Self::load_from_path(current_dir, &config_path, true)
201 }
202
203 #[cfg(feature = "analyzer")]
206 pub(crate) fn load_read_only(current_dir: &Path) -> Result<Self, GrimoireCssError> {
207 let config_path = current_dir.join("grimoire/config/grimoire.config.json");
208 Self::load_from_path(current_dir, &config_path, false)
209 }
210
211 fn load_from_path(
212 current_dir: &Path,
213 config_path: &Path,
214 prune_empty_animations: bool,
215 ) -> Result<Self, GrimoireCssError> {
216 let content = fs::read_to_string(config_path)?;
217 let json_config: ConfigFsJSON = serde_json::from_str(&content)?;
218 Self::validate_scroll_inheritance(json_config.scrolls.as_deref().unwrap_or_default())?;
219 let mut config = Self::from_json(json_config, &std::path::absolute(current_dir)?);
220
221 config.custom_animations =
223 Self::find_custom_animations_with_cleanup(current_dir, prune_empty_animations)?;
224
225 config.scrolls = Self::load_external_scrolls(current_dir, config.scrolls)?;
227
228 config.variables = Self::load_external_variables(current_dir, config.variables)?;
230
231 Ok(config)
232 }
233
234 pub fn save(&self, current_dir: &Path) -> Result<(), GrimoireCssError> {
242 let config_path = Filesystem::get_config_path(current_dir)?;
243 let json_config = self.to_json();
244 let content = serde_json::to_string_pretty(&json_config)?;
245 fs::write(&config_path, content)?;
246
247 Ok(())
248 }
249
250 fn get_common_spells_set(config: &ConfigFsJSON) -> HashSet<String> {
260 let mut common_spells = HashSet::new();
261
262 if let Some(shared) = &config.shared {
263 for shared_item in shared {
264 if let Some(styles) = &shared_item.styles {
265 common_spells.extend(styles.iter().cloned());
266 }
267 }
268 }
269
270 if let Some(critical) = &config.critical {
271 for critical_item in critical {
272 if let Some(styles) = &critical_item.styles {
273 common_spells.extend(styles.iter().cloned());
274 }
275 }
276 }
277
278 common_spells
279 }
280
281 fn from_json(json_config: ConfigFsJSON, current_dir: &Path) -> Self {
291 let shared_spells = Self::get_common_spells_set(&json_config);
292
293 let variables = json_config.variables.map(|vars| {
294 let mut sorted_vars: Vec<_> = vars.into_iter().collect();
295 sorted_vars.sort_by(|a, b| a.0.cmp(&b.0));
296 sorted_vars
297 });
298
299 let projects = Self::projects_from_json(json_config.projects, current_dir);
300
301 let shared = Self::shared_from_json(json_config.shared);
303 let critical = Self::critical_from_json(json_config.critical, current_dir);
304 let scrolls = Self::scrolls_from_json(json_config.scrolls);
305
306 ConfigFs {
307 grimoire_css_version: json_config.version,
308 variables,
309 scrolls,
310 projects,
311 shared,
312 critical,
313 shared_spells,
314 custom_animations: HashMap::new(),
315 lock: json_config.lock,
316 }
317 }
318
319 fn shared_from_json(shared: Option<Vec<ConfigFsSharedJSON>>) -> Option<Vec<ConfigFsShared>> {
321 shared.map(|shared_vec| {
322 shared_vec
323 .into_iter()
324 .map(|c| ConfigFsShared {
325 output_path: c.output_path,
326 styles: c.styles,
327 css_custom_properties: Self::convert_css_custom_properties_from_json(
328 c.css_custom_properties,
329 ),
330 })
331 .collect()
332 })
333 }
334
335 fn critical_from_json(
337 critical: Option<Vec<ConfigFsCriticalJSON>>,
338 current_dir: &Path,
339 ) -> Option<Vec<ConfigFsCritical>> {
340 critical.map(|critical_vec| {
341 critical_vec
342 .into_iter()
343 .map(|c| ConfigFsCritical {
344 file_to_inline_paths: Self::expand_glob_patterns(
345 c.file_to_inline_paths,
346 current_dir,
347 ),
348 styles: c.styles,
349 css_custom_properties: Self::convert_css_custom_properties_from_json(
350 c.css_custom_properties,
351 ),
352 })
353 .collect()
354 })
355 }
356
357 fn validate_scroll_inheritance(scrolls: &[ConfigFsScrollJSON]) -> Result<(), GrimoireCssError> {
358 let mut by_name = HashMap::new();
359 for scroll in scrolls {
360 by_name.entry(scroll.name.as_str()).or_insert(scroll);
361 }
362 let mut complete = HashSet::new();
363 let mut active = HashSet::new();
364 for scroll in scrolls {
365 let mut pending = vec![(scroll.name.as_str(), false)];
366 while let Some((name, leaving)) = pending.pop() {
367 if leaving {
368 active.remove(name);
369 complete.insert(name);
370 continue;
371 }
372 if complete.contains(name) {
373 continue;
374 }
375 if !active.insert(name) {
376 return Err(GrimoireCssError::InvalidInput(format!(
377 "Cyclic scroll inheritance involving '{name}'"
378 )));
379 }
380 pending.push((name, true));
381 if let Some(parents) = by_name.get(name).and_then(|scroll| scroll.extends.as_ref())
382 {
383 for parent in parents.iter().rev() {
384 if by_name.contains_key(parent.as_str()) {
385 pending.push((parent.as_str(), false));
386 }
387 }
388 }
389 }
390 }
391 Ok(())
392 }
393
394 fn scrolls_from_json(
395 scrolls: Option<Vec<ConfigFsScrollJSON>>,
396 ) -> Option<HashMap<String, ScrollDefinition>> {
397 scrolls.map(|scrolls_vec| {
398 let mut scrolls_map = HashMap::new();
399
400 for scroll in &scrolls_vec {
401 let mut base_spells = Vec::new();
402 let mut spells_by_args: HashMap<String, Vec<String>> = HashMap::new();
403
404 Self::resolve_spells(scroll, &scrolls_vec, &mut base_spells);
406 Self::resolve_spells_by_args(scroll, &scrolls_vec, &mut spells_by_args);
407
408 base_spells.extend_from_slice(&scroll.spells);
410
411 if let Some(own) = &scroll.spells_by_args {
413 for (k, v) in own {
414 spells_by_args
415 .entry(k.clone())
416 .or_default()
417 .extend(v.iter().cloned());
418 }
419 }
420
421 let spells_by_args = if spells_by_args.is_empty() {
422 None
423 } else {
424 Some(spells_by_args)
425 };
426
427 scrolls_map.insert(
428 scroll.name.clone(),
429 ScrollDefinition {
430 spells: base_spells,
431 spells_by_args,
432 },
433 );
434 }
435
436 scrolls_map
437 })
438 }
439
440 fn resolve_spells(
442 scroll: &ConfigFsScrollJSON,
443 scrolls_vec: &[ConfigFsScrollJSON],
444 collected_spells: &mut Vec<String>,
445 ) {
446 if let Some(extends) = &scroll.extends {
447 for ext_name in extends {
448 if let Some(parent_scroll) = scrolls_vec.iter().find(|s| &s.name == ext_name) {
450 Self::resolve_spells(parent_scroll, scrolls_vec, collected_spells);
452
453 collected_spells.extend_from_slice(&parent_scroll.spells);
455 }
456 }
457 }
458 }
459
460 fn resolve_spells_by_args(
462 scroll: &ConfigFsScrollJSON,
463 scrolls_vec: &[ConfigFsScrollJSON],
464 collected: &mut HashMap<String, Vec<String>>,
465 ) {
466 if let Some(extends) = &scroll.extends {
467 for ext_name in extends {
468 if let Some(parent_scroll) = scrolls_vec.iter().find(|s| &s.name == ext_name) {
469 Self::resolve_spells_by_args(parent_scroll, scrolls_vec, collected);
470
471 if let Some(parent_map) = &parent_scroll.spells_by_args {
472 for (k, v) in parent_map {
473 collected
474 .entry(k.clone())
475 .or_default()
476 .extend(v.iter().cloned());
477 }
478 }
479 }
480 }
481 }
482 }
483
484 fn convert_css_custom_properties_from_json(
486 css_custom_properties_vec: Option<Vec<ConfigFsCSSCustomPropertiesJSON>>,
487 ) -> Option<Vec<ConfigFsCssCustomProperties>> {
488 css_custom_properties_vec.map(|items: Vec<ConfigFsCSSCustomPropertiesJSON>| {
489 items
490 .into_iter()
491 .map(|item| ConfigFsCssCustomProperties {
492 element: item.element.unwrap_or_else(|| String::from(":root")),
493 data_param: item.data_param,
494 data_value: item.data_value,
495 css_variables: {
496 let mut vars: Vec<_> = item.css_variables.into_iter().collect();
497 vars.sort_by(|a, b| a.0.cmp(&b.0));
498 vars
499 },
500 })
501 .collect()
502 })
503 }
504
505 fn projects_from_json(
507 projects: Vec<ConfigFsProjectJSON>,
508 current_dir: &Path,
509 ) -> Vec<ConfigFsProject> {
510 projects
511 .into_iter()
512 .map(|p| {
513 let input_paths = Self::expand_glob_patterns(p.input_paths, current_dir);
514 ConfigFsProject {
515 project_name: p.project_name,
516 input_paths,
517 output_dir_path: p.output_dir_path,
518 single_output_file_name: p.single_output_file_name,
519 }
520 })
521 .collect()
522 }
523
524 fn to_json(&self) -> ConfigFsJSON {
526 let variables_hash_map = self.variables.as_ref().map(|vars| {
527 let mut sorted_vars: Vec<_> = vars.iter().collect();
528 sorted_vars.sort_by(|a, b| a.0.cmp(&b.0));
529 sorted_vars
530 .into_iter()
531 .map(|(key, value)| (key.clone(), value.clone()))
532 .collect()
533 });
534
535 ConfigFsJSON {
536 schema: Some("https://raw.githubusercontent.com/persevie/grimoire-css/main/src/core/config/config-schema.json".to_string()),
537 version: self.grimoire_css_version.clone(),
538 variables: variables_hash_map,
539 scrolls: Self::scrolls_to_json(self.scrolls.clone()),
540 projects: Self::projects_to_json(self.projects.clone()),
541 shared: Self::shared_to_json(self.shared.as_ref()),
542 critical: Self::critical_to_json(self.critical.as_ref()),
543 lock: self.lock,
544 }
545 }
546
547 pub fn update_config_version_only(
552 current_dir: &Path,
553 grimoire_css_version: &str,
554 ) -> Result<(), GrimoireCssError> {
555 let config_path = Filesystem::get_config_path(current_dir)?;
556 let content = fs::read_to_string(&config_path)?;
557 let mut json_value: serde_json::Value = serde_json::from_str(&content)?;
558
559 if let serde_json::Value::Object(map) = &mut json_value {
560 map.insert(
561 "version".to_string(),
562 serde_json::Value::String(grimoire_css_version.to_string()),
563 );
564
565 map.entry("$schema".to_string()).or_insert_with(|| {
567 serde_json::Value::String(
568 "https://raw.githubusercontent.com/persevie/grimoire-css/main/src/core/config/config-schema.json".to_string(),
569 )
570 });
571 }
572
573 let updated = serde_json::to_string_pretty(&json_value)?;
574 fs::write(&config_path, updated)?;
575 Ok(())
576 }
577
578 fn shared_to_json(shared: Option<&Vec<ConfigFsShared>>) -> Option<Vec<ConfigFsSharedJSON>> {
580 shared.map(|common_vec: &Vec<ConfigFsShared>| {
581 common_vec
582 .iter()
583 .map(|c| ConfigFsSharedJSON {
584 output_path: c.output_path.clone(),
585 styles: c.styles.clone(),
586 css_custom_properties: Self::css_custom_properties_to_json(
587 c.css_custom_properties.as_ref(),
588 ),
589 })
590 .collect()
591 })
592 }
593
594 fn critical_to_json(
596 critical: Option<&Vec<ConfigFsCritical>>,
597 ) -> Option<Vec<ConfigFsCriticalJSON>> {
598 critical.map(|common_vec| {
599 common_vec
600 .iter()
601 .map(|c| ConfigFsCriticalJSON {
602 file_to_inline_paths: c.file_to_inline_paths.clone(),
603 styles: c.styles.clone(),
604 css_custom_properties: Self::css_custom_properties_to_json(
605 c.css_custom_properties.as_ref(),
606 ),
607 })
608 .collect()
609 })
610 }
611
612 fn css_custom_properties_to_json(
614 css_custom_properties_vec: Option<&Vec<ConfigFsCssCustomProperties>>,
615 ) -> Option<Vec<ConfigFsCSSCustomPropertiesJSON>> {
616 css_custom_properties_vec.map(|items: &Vec<ConfigFsCssCustomProperties>| {
617 items
618 .iter()
619 .map(|item| ConfigFsCSSCustomPropertiesJSON {
620 element: Some(item.element.clone()),
621 data_param: item.data_param.clone(),
622 data_value: item.data_value.clone(),
623 css_variables: item.css_variables.clone().into_iter().collect(),
624 })
625 .collect()
626 })
627 }
628
629 fn scrolls_to_json(
630 config_scrolls: Option<HashMap<String, ScrollDefinition>>,
631 ) -> Option<Vec<ConfigFsScrollJSON>> {
632 config_scrolls.map(|scrolls| {
633 let mut scrolls_vec = Vec::new();
634 for (name, def) in scrolls {
635 scrolls_vec.push(ConfigFsScrollJSON {
636 name,
637 spells: def.spells,
638 spells_by_args: def.spells_by_args,
639 extends: None,
640 });
641 }
642 scrolls_vec
643 })
644 }
645
646 fn projects_to_json(projects: Vec<ConfigFsProject>) -> Vec<ConfigFsProjectJSON> {
648 projects
649 .into_iter()
650 .map(|p| ConfigFsProjectJSON {
651 project_name: p.project_name,
652 input_paths: p.input_paths,
653 output_dir_path: p.output_dir_path,
654 single_output_file_name: p.single_output_file_name,
655 })
656 .collect()
657 }
658
659 #[cfg(test)]
683 fn find_custom_animations(
684 current_dir: &Path,
685 ) -> Result<HashMap<String, String>, GrimoireCssError> {
686 Self::find_custom_animations_with_cleanup(current_dir, true)
687 }
688
689 fn find_custom_animations_with_cleanup(
690 current_dir: &Path,
691 prune_empty: bool,
692 ) -> Result<HashMap<String, String>, GrimoireCssError> {
693 let animations_dir = current_dir.join("grimoire/animations");
694
695 if !animations_dir.exists() {
696 return Ok(HashMap::new());
697 }
698
699 let mut entries = animations_dir.read_dir()?.peekable();
700
701 if entries.peek().is_none() {
702 if prune_empty {
703 add_message("No custom animations were found in the 'animations' directory. Deleted unnecessary 'animations' directory".to_string());
704 fs::remove_dir(&animations_dir)?;
705 }
706 return Ok(HashMap::new());
707 }
708
709 let mut map = HashMap::new();
710
711 for entry in entries {
712 let entry = entry?;
713 let path = entry.path();
714
715 if path.is_file() {
716 if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
717 if ext == "css" {
718 if let Some(file_stem) = path.file_stem().and_then(|s| s.to_str()) {
719 let content = fs::read_to_string(&path)?;
720 map.insert(file_stem.to_owned(), content);
721 }
722 } else {
723 add_message(format!(
724 "Only CSS files are supported in the 'animations' directory. Skipping non-CSS file: {}.",
725 path.display()
726 ));
727 }
728 }
729 } else {
730 add_message(format!(
731 "Only files are supported in the 'animations' directory. Skipping directory: {}.",
732 path.display()
733 ));
734 }
735 }
736
737 Ok(map)
738 }
739
740 fn expand_glob_patterns(patterns: Vec<String>, current_dir: &Path) -> Vec<String> {
741 let mut paths = Vec::new();
742 for pattern in patterns {
743 let relative = Path::new(&pattern).is_relative();
744 let rooted_pattern = if relative {
746 format!(
747 "{}/{}",
748 glob::Pattern::escape(¤t_dir.to_string_lossy()),
749 pattern
750 )
751 } else {
752 pattern.clone()
753 };
754 match glob(&rooted_pattern) {
755 Ok(glob_paths) => {
756 for path_result in glob_paths.flatten() {
757 let path = if relative {
758 path_result
759 .strip_prefix(current_dir)
760 .unwrap_or(&path_result)
761 } else {
762 &path_result
763 };
764 if let Some(path_str) = path.to_str() {
765 paths.push(path_str.to_string());
766 }
767 }
768 }
769 Err(e) => {
770 add_message(format!("Failed to read glob pattern {pattern}: {e}"));
771 }
772 }
773 }
774 paths
775 }
776
777 fn load_external_scrolls(
794 current_dir: &Path,
795 existing_scrolls: Option<HashMap<String, ScrollDefinition>>,
796 ) -> Result<Option<HashMap<String, ScrollDefinition>>, GrimoireCssError> {
797 let config_dir = Filesystem::get_or_create_grimoire_path(current_dir)?.join("config");
799
800 let mut all_scrolls = existing_scrolls.unwrap_or_default();
802 let mut existing_scroll_names: HashSet<String> = all_scrolls.keys().cloned().collect();
803 let mut external_files_found = false;
804
805 match external_config_files(&config_dir, ".scrolls.json") {
806 Ok(entries) => {
807 for entry in entries {
808 if let Some(file_name) = entry.file_name().and_then(|s| s.to_str()) {
809 match fs::read_to_string(&entry) {
811 Ok(content) => {
812 match serde_json::from_str::<serde_json::Value>(&content) {
813 Ok(json) => {
814 if let Some(scrolls) =
816 json.get("scrolls").and_then(|s| s.as_array())
817 {
818 external_files_found = true;
819
820 for scroll in scrolls {
822 if let (Some(name), Some(spells_arr)) = (
823 scroll.get("name").and_then(|n| n.as_str()),
824 scroll.get("spells").and_then(|s| s.as_array()),
825 ) {
826 if !existing_scroll_names.contains(name) {
828 let spells: Vec<String> = spells_arr
830 .iter()
831 .filter_map(|s| {
832 s.as_str().map(|s| s.to_string())
833 })
834 .collect();
835
836 let spells_by_args = scroll
838 .get("spellsByArgs")
839 .and_then(|s| s.as_object())
840 .and_then(|obj| {
841 let mut map: HashMap<String, Vec<String>> =
842 HashMap::new();
843 for (k, v) in obj {
844 if let Some(arr) = v.as_array() {
845 let spells: Vec<String> = arr
846 .iter()
847 .filter_map(|s| {
848 s.as_str().map(|s| s.to_string())
849 })
850 .collect();
851 map.insert(k.clone(), spells);
852 }
853 }
854 if map.is_empty() { None } else { Some(map) }
855 });
856
857 all_scrolls.insert(
859 name.to_string(),
860 ScrollDefinition {
861 spells,
862 spells_by_args,
863 },
864 );
865 existing_scroll_names
866 .insert(name.to_string());
867 }
868 }
870 }
871
872 add_message(format!(
873 "Loaded external scrolls from '{file_name}'"
874 ));
875 }
876 }
877 Err(err) => {
878 add_message(format!(
879 "Failed to parse external scroll file '{file_name}': {err}"
880 ));
881 }
882 }
883 }
884 Err(err) => {
885 add_message(format!(
886 "Failed to read external scroll file '{file_name}': {err}"
887 ));
888 }
889 }
890 }
891 }
892 }
893 Err(err) => {
894 add_message(format!("Failed to search for external scroll files: {err}"));
895 }
896 }
897
898 if all_scrolls.is_empty() {
900 Ok(None)
901 } else {
902 if external_files_found {
904 add_message("External scroll files were merged with configuration".to_string());
905 }
906 Ok(Some(all_scrolls))
907 }
908 }
909
910 fn load_external_variables(
926 current_dir: &Path,
927 existing_variables: Option<Vec<(String, String)>>,
928 ) -> Result<Option<Vec<(String, String)>>, GrimoireCssError> {
929 let config_dir = Filesystem::get_or_create_grimoire_path(current_dir)?.join("config");
931
932 let mut all_variables = existing_variables.unwrap_or_default();
934 let mut existing_keys: HashSet<String> =
935 all_variables.iter().map(|(key, _)| key.clone()).collect();
936 let mut external_files_found = false;
937
938 match external_config_files(&config_dir, ".variables.json") {
939 Ok(entries) => {
940 for entry in entries {
941 if let Some(file_name) = entry.file_name().and_then(|s| s.to_str()) {
942 match fs::read_to_string(&entry) {
944 Ok(content) => {
945 match serde_json::from_str::<serde_json::Value>(&content) {
946 Ok(json) => {
947 if let Some(variables) =
949 json.get("variables").and_then(|v| v.as_object())
950 {
951 external_files_found = true;
952
953 for (key, value) in variables {
955 if let Some(value_str) = value.as_str() {
956 if !existing_keys.contains(key) {
958 all_variables.push((
959 key.clone(),
960 value_str.to_string(),
961 ));
962 existing_keys.insert(key.clone());
963 }
964 }
966 }
967
968 add_message(format!(
969 "Loaded external variables from '{file_name}'"
970 ));
971 }
972 }
973 Err(err) => {
974 add_message(format!(
975 "Failed to parse external variables file '{file_name}': {err}"
976 ));
977 }
978 }
979 }
980 Err(err) => {
981 add_message(format!(
982 "Failed to read external variables file '{file_name}': {err}"
983 ));
984 }
985 }
986 }
987 }
988 }
989 Err(err) => {
990 add_message(format!(
991 "Failed to search for external variables files: {err}"
992 ));
993 }
994 }
995
996 if !all_variables.is_empty() {
998 all_variables.sort_by(|a, b| a.0.cmp(&b.0));
999
1000 if external_files_found {
1002 add_message("External variable files were merged with configuration".to_string());
1003 }
1004 Ok(Some(all_variables))
1005 } else {
1006 Ok(None)
1007 }
1008 }
1009}
1010
1011#[cfg(test)]
1012mod tests {
1013 use super::*;
1014 use std::fs::File;
1015 use std::io::Write;
1016 use tempfile::tempdir;
1017
1018 #[test]
1019 fn test_default_config() {
1020 let config = ConfigFs::default();
1021 assert!(config.variables.is_none());
1022 assert!(config.scrolls.is_none());
1023 assert!(config.shared.is_none());
1024 assert!(config.critical.is_none());
1025 assert_eq!(config.projects.len(), 1);
1026 assert_eq!(config.projects[0].project_name, "main");
1027 }
1028
1029 #[test]
1030 fn test_load_nonexistent_config() {
1031 let dir = tempdir().unwrap();
1032 let result = ConfigFs::load(dir.path());
1033 assert!(result.is_err());
1034 }
1035
1036 #[test]
1037 fn test_save_and_load_config() {
1038 let dir = tempdir().unwrap();
1039 let config = ConfigFs::default();
1040 config.save(dir.path()).expect("Failed to save config");
1041
1042 let loaded_config = ConfigFs::load(dir.path()).expect("Failed to load config");
1043 assert_eq!(
1044 config.projects[0].project_name,
1045 loaded_config.projects[0].project_name
1046 );
1047 }
1048
1049 #[test]
1050 fn test_expand_glob_patterns() {
1051 let dir = tempdir().unwrap();
1052 let file_path = dir.path().join("test.txt");
1053 File::create(&file_path).unwrap();
1054
1055 let patterns = vec![format!("{}/**/*.txt", dir.path().to_str().unwrap())];
1056 let expanded = ConfigFs::expand_glob_patterns(patterns, dir.path());
1057 assert_eq!(expanded.len(), 1);
1058 assert!(expanded[0].ends_with("test.txt"));
1059 }
1060
1061 #[test]
1062 fn relative_globs_use_the_literal_project_root_and_remain_relative() {
1063 let dir = tempdir().unwrap();
1064 let root = dir.path().join("project[1]");
1065 fs::create_dir_all(root.join("src/nested")).unwrap();
1066 fs::write(root.join("src/index.html"), "").unwrap();
1067 fs::write(root.join("src/nested/page.html"), "").unwrap();
1068 let absolute_file = dir.path().join("external.html");
1069 fs::write(&absolute_file, "").unwrap();
1070 let paths = ConfigFs::expand_glob_patterns(
1071 vec![
1072 "src/**/*.html".into(),
1073 "missing/**/*.html".into(),
1074 "src/nested".into(),
1075 absolute_file.to_str().unwrap().into(),
1076 ],
1077 &root,
1078 );
1079 let expected = vec![
1080 PathBuf::from("src/index.html"),
1081 PathBuf::from("src/nested/page.html"),
1082 PathBuf::from("src/nested"),
1083 absolute_file,
1084 ];
1085 assert_eq!(
1086 paths.into_iter().map(PathBuf::from).collect::<Vec<_>>(),
1087 expected
1088 );
1089 }
1090
1091 #[test]
1092 fn test_find_custom_animations_empty() {
1093 let dir = tempdir().unwrap();
1094 let animations = ConfigFs::find_custom_animations(dir.path()).unwrap();
1095 assert!(animations.is_empty());
1096 }
1097
1098 #[test]
1099 fn test_find_custom_animations_with_files() {
1100 let dir = tempdir().unwrap();
1101 let animations_dir = dir.path().join("grimoire").join("animations");
1102 fs::create_dir_all(&animations_dir).unwrap();
1103
1104 let animation_file = animations_dir.join("fade_in.css");
1105 let mut file = File::create(&animation_file).unwrap();
1106 writeln!(
1107 file,
1108 "@keyframes fade_in {{ from {{ opacity: 0; }} to {{ opacity: 1; }} }}"
1109 )
1110 .unwrap();
1111
1112 let animations = ConfigFs::find_custom_animations(dir.path()).unwrap();
1113 assert_eq!(animations.len(), 1);
1114 assert!(animations.contains_key("fade_in"));
1115 }
1116
1117 #[test]
1118 fn test_get_common_spells_set() {
1119 let json = ConfigFsJSON {
1120 schema: None,
1121 version: None,
1122 variables: None,
1123 scrolls: None,
1124 projects: vec![],
1125 shared: Some(vec![ConfigFsSharedJSON {
1126 output_path: "styles.css".to_string(),
1127 styles: Some(vec!["spell1".to_string(), "spell2".to_string()]),
1128 css_custom_properties: None,
1129 }]),
1130 critical: Some(vec![ConfigFsCriticalJSON {
1131 file_to_inline_paths: vec!["index.html".to_string()],
1132 styles: Some(vec!["spell3".to_string()]),
1133 css_custom_properties: None,
1134 }]),
1135 lock: None,
1136 };
1137
1138 let common_spells = ConfigFs::get_common_spells_set(&json);
1139 assert_eq!(common_spells.len(), 3);
1140 assert!(common_spells.contains("spell1"));
1141 assert!(common_spells.contains("spell2"));
1142 assert!(common_spells.contains("spell3"));
1143 }
1144
1145 #[test]
1146 fn test_load_external_scrolls_no_files() {
1147 let dir = tempdir().unwrap();
1148 let config_dir = dir.path().join("grimoire").join("config");
1149 fs::create_dir_all(&config_dir).unwrap();
1150
1151 let config_file = config_dir.join("grimoire.config.json");
1153 let config_content = r#"{
1154 "projects": [
1155 {
1156 "projectName": "main",
1157 "inputPaths": []
1158 }
1159 ]
1160 }"#;
1161 fs::write(&config_file, config_content).unwrap();
1162
1163 let result = ConfigFs::load_external_scrolls(dir.path(), None).unwrap();
1165 assert!(result.is_none());
1166 }
1167
1168 #[test]
1169 fn test_load_external_scrolls_single_file() {
1170 let dir = tempdir().unwrap();
1171 let config_dir = dir.path().join("grimoire").join("config");
1172 fs::create_dir_all(&config_dir).unwrap();
1173
1174 let config_file = config_dir.join("grimoire.config.json");
1176 let config_content = r#"{
1177 "projects": [
1178 {
1179 "projectName": "main",
1180 "inputPaths": []
1181 }
1182 ]
1183 }"#;
1184 fs::write(&config_file, config_content).unwrap();
1185
1186 let scrolls_file = config_dir.join("grimoire.tailwindcss.scrolls.json");
1188 let scrolls_content = r#"{
1189 "scrolls": [
1190 {
1191 "name": "tw-btn",
1192 "spells": [
1193 "p=4px",
1194 "bg=blue",
1195 "c=white",
1196 "br=4px"
1197 ]
1198 }
1199 ]
1200 }"#;
1201 fs::write(&scrolls_file, scrolls_content).unwrap();
1202
1203 let result = ConfigFs::load_external_scrolls(dir.path(), None).unwrap();
1205 assert!(result.is_some());
1206
1207 let scrolls = result.unwrap();
1208 assert_eq!(scrolls.len(), 1);
1209 assert!(scrolls.contains_key("tw-btn"));
1210 assert_eq!(scrolls.get("tw-btn").unwrap().spells.len(), 4);
1211 }
1212
1213 #[test]
1214 fn test_load_external_scrolls_multiple_files() {
1215 let dir = tempdir().unwrap();
1216 let config_dir = dir.path().join("grimoire").join("config");
1217 fs::create_dir_all(&config_dir).unwrap();
1218
1219 let config_file = config_dir.join("grimoire.config.json");
1221 let config_content = r#"{
1222 "projects": [
1223 {
1224 "projectName": "main",
1225 "inputPaths": []
1226 }
1227 ]
1228 }"#;
1229 fs::write(&config_file, config_content).unwrap();
1230
1231 let scrolls_file1 = config_dir.join("grimoire.tailwindcss.scrolls.json");
1233 let scrolls_content1 = r#"{
1234 "scrolls": [
1235 {
1236 "name": "tw-btn",
1237 "spells": [
1238 "p=4px",
1239 "bg=blue",
1240 "c=white",
1241 "br=4px"
1242 ]
1243 }
1244 ]
1245 }"#;
1246 fs::write(&scrolls_file1, scrolls_content1).unwrap();
1247
1248 let scrolls_file2 = config_dir.join("grimoire.bootstrap.scrolls.json");
1250 let scrolls_content2 = r#"{
1251 "scrolls": [
1252 {
1253 "name": "bs-card",
1254 "spells": [
1255 "border=1px_solid_#ccc",
1256 "br=8px",
1257 "shadow=0_2px_8px_rgba(0,0,0,0.1)"
1258 ]
1259 }
1260 ]
1261 }"#;
1262 fs::write(&scrolls_file2, scrolls_content2).unwrap();
1263
1264 let result = ConfigFs::load_external_scrolls(dir.path(), None).unwrap();
1266 assert!(result.is_some());
1267
1268 let scrolls = result.unwrap();
1269 assert_eq!(scrolls.len(), 2);
1270 assert!(scrolls.contains_key("tw-btn"));
1271 assert!(scrolls.contains_key("bs-card"));
1272 assert_eq!(scrolls.get("tw-btn").unwrap().spells.len(), 4);
1273 assert_eq!(scrolls.get("bs-card").unwrap().spells.len(), 3);
1274 }
1275
1276 #[test]
1277 fn test_merge_with_existing_scrolls() {
1278 let dir = tempdir().unwrap();
1279 let config_dir = dir.path().join("grimoire").join("config");
1280 fs::create_dir_all(&config_dir).unwrap();
1281
1282 let config_file = config_dir.join("grimoire.config.json");
1284 let config_content = r#"{
1285 "scrolls": [
1286 {
1287 "name": "main-btn",
1288 "spells": [
1289 "p=10px",
1290 "fw=bold",
1291 "c=black"
1292 ]
1293 }
1294 ],
1295 "projects": [
1296 {
1297 "projectName": "main",
1298 "inputPaths": []
1299 }
1300 ]
1301 }"#;
1302 fs::write(&config_file, config_content).unwrap();
1303
1304 let scrolls_file = config_dir.join("grimoire.extra.scrolls.json");
1306 let scrolls_content = r#"{
1307 "scrolls": [
1308 {
1309 "name": "main-btn",
1310 "spells": [
1311 "bg=green",
1312 "hover:bg=darkgreen"
1313 ]
1314 },
1315 {
1316 "name": "extra-btn",
1317 "spells": [
1318 "fs=16px",
1319 "m=10px"
1320 ]
1321 }
1322 ]
1323 }"#;
1324 fs::write(&scrolls_file, scrolls_content).unwrap();
1325
1326 let mut existing_scrolls = HashMap::new();
1328 existing_scrolls.insert(
1329 "main-btn".to_string(),
1330 ScrollDefinition {
1331 spells: vec![
1332 "p=10px".to_string(),
1333 "fw=bold".to_string(),
1334 "c=black".to_string(),
1335 ],
1336 spells_by_args: None,
1337 },
1338 );
1339
1340 let result = ConfigFs::load_external_scrolls(dir.path(), Some(existing_scrolls)).unwrap();
1342 assert!(result.is_some());
1343
1344 let scrolls = result.unwrap();
1345 assert_eq!(scrolls.len(), 2);
1346
1347 assert!(scrolls.contains_key("main-btn"));
1349 assert_eq!(scrolls.get("main-btn").unwrap().spells.len(), 3);
1350
1351 assert!(scrolls.contains_key("extra-btn"));
1353 assert_eq!(scrolls.get("extra-btn").unwrap().spells.len(), 2);
1354 }
1355
1356 #[test]
1357 fn test_full_config_with_external_scrolls() {
1358 let dir = tempdir().unwrap();
1359 let config_dir = dir.path().join("grimoire").join("config");
1360 fs::create_dir_all(&config_dir).unwrap();
1361
1362 let config_file = config_dir.join("grimoire.config.json");
1364 let config_content = r#"{
1365 "scrolls": [
1366 {
1367 "name": "base-btn",
1368 "spells": [
1369 "p=10px",
1370 "br=4px"
1371 ]
1372 }
1373 ],
1374 "projects": [
1375 {
1376 "projectName": "main",
1377 "inputPaths": []
1378 }
1379 ]
1380 }"#;
1381 fs::write(&config_file, config_content).unwrap();
1382
1383 let scrolls_file = config_dir.join("grimoire.theme.scrolls.json");
1385 let scrolls_content = r#"{
1386 "scrolls": [
1387 {
1388 "name": "theme-btn",
1389 "spells": [
1390 "bg=purple",
1391 "c=white"
1392 ]
1393 }
1394 ]
1395 }"#;
1396 fs::write(&scrolls_file, scrolls_content).unwrap();
1397
1398 let config = ConfigFs::load(dir.path()).expect("Failed to load config");
1400
1401 assert!(config.scrolls.is_some());
1403 let scrolls = config.scrolls.unwrap();
1404 assert_eq!(scrolls.len(), 2);
1405 assert!(scrolls.contains_key("base-btn"));
1406 assert!(scrolls.contains_key("theme-btn"));
1407 }
1408
1409 #[test]
1410 fn test_load_external_variables_no_files() {
1411 let dir = tempdir().unwrap();
1412 let config_dir = dir.path().join("grimoire").join("config");
1413 fs::create_dir_all(&config_dir).unwrap();
1414
1415 let config_file = config_dir.join("grimoire.config.json");
1417 let config_content = r#"{
1418 "projects": [
1419 {
1420 "projectName": "main",
1421 "inputPaths": []
1422 }
1423 ]
1424 }"#;
1425 fs::write(&config_file, config_content).unwrap();
1426
1427 let result = ConfigFs::load_external_variables(dir.path(), None).unwrap();
1429 assert!(result.is_none());
1430 }
1431
1432 #[test]
1433 fn test_load_external_variables_single_file() {
1434 let dir = tempdir().unwrap();
1435 let config_dir = dir.path().join("grimoire").join("config");
1436 fs::create_dir_all(&config_dir).unwrap();
1437
1438 let config_file = config_dir.join("grimoire.config.json");
1440 let config_content = r#"{
1441 "projects": [
1442 {
1443 "projectName": "main",
1444 "inputPaths": []
1445 }
1446 ]
1447 }"#;
1448 fs::write(&config_file, config_content).unwrap();
1449
1450 let vars_file = config_dir.join("grimoire.theme.variables.json");
1452 let vars_content = r##"{
1453 "variables": {
1454 "primary-color": "#3366ff",
1455 "secondary-color": "#ff6633",
1456 "font-size-base": "16px"
1457 }
1458 }"##;
1459 fs::write(&vars_file, vars_content).unwrap();
1460
1461 let result = ConfigFs::load_external_variables(dir.path(), None).unwrap();
1463 assert!(result.is_some());
1464
1465 let variables = result.unwrap();
1466 assert_eq!(variables.len(), 3);
1467
1468 assert_eq!(variables[0].0, "font-size-base");
1470 assert_eq!(variables[0].1, "16px");
1471 assert_eq!(variables[1].0, "primary-color");
1472 assert_eq!(variables[1].1, "#3366ff");
1473 assert_eq!(variables[2].0, "secondary-color");
1474 assert_eq!(variables[2].1, "#ff6633");
1475 }
1476
1477 #[test]
1478 fn test_load_external_variables_multiple_files() {
1479 let dir = tempdir().unwrap();
1480 let config_dir = dir.path().join("grimoire").join("config");
1481 fs::create_dir_all(&config_dir).unwrap();
1482
1483 let config_file = config_dir.join("grimoire.config.json");
1485 let config_content = r#"{
1486 "projects": [
1487 {
1488 "projectName": "main",
1489 "inputPaths": []
1490 }
1491 ]
1492 }"#;
1493 fs::write(&config_file, config_content).unwrap();
1494
1495 let vars_file1 = config_dir.join("grimoire.colors.variables.json");
1497 let vars_content1 = r##"{
1498 "variables": {
1499 "primary-color": "#3366ff",
1500 "secondary-color": "#ff6633"
1501 }
1502 }"##;
1503 fs::write(&vars_file1, vars_content1).unwrap();
1504
1505 let vars_file2 = config_dir.join("grimoire.typography.variables.json");
1507 let vars_content2 = r##"{
1508 "variables": {
1509 "font-size-base": "16px",
1510 "font-family-sans": "Arial, sans-serif"
1511 }
1512 }"##;
1513 fs::write(&vars_file2, vars_content2).unwrap();
1514
1515 let result = ConfigFs::load_external_variables(dir.path(), None).unwrap();
1517 assert!(result.is_some());
1518
1519 let variables = result.unwrap();
1520 assert_eq!(variables.len(), 4);
1521
1522 let var_map: HashMap<String, String> = variables.into_iter().collect();
1524 assert!(var_map.contains_key("primary-color"));
1525 assert!(var_map.contains_key("secondary-color"));
1526 assert!(var_map.contains_key("font-size-base"));
1527 assert!(var_map.contains_key("font-family-sans"));
1528
1529 assert_eq!(var_map.get("primary-color").unwrap(), "#3366ff");
1530 assert_eq!(
1531 var_map.get("font-family-sans").unwrap(),
1532 "Arial, sans-serif"
1533 );
1534 }
1535
1536 #[test]
1537 fn test_merge_with_existing_variables() {
1538 let dir = tempdir().unwrap();
1539 let config_dir = dir.path().join("grimoire").join("config");
1540 fs::create_dir_all(&config_dir).unwrap();
1541
1542 let config_file = config_dir.join("grimoire.config.json");
1544 let config_content = r##"{
1545 "variables": {
1546 "primary-color": "#3366ff",
1547 "font-size-base": "16px"
1548 },
1549 "projects": [
1550 {
1551 "projectName": "main",
1552 "inputPaths": []
1553 }
1554 ]
1555 }"##;
1556 fs::write(&config_file, config_content).unwrap();
1557
1558 let vars_file = config_dir.join("grimoire.extra.variables.json");
1560 let vars_content = r##"{
1561 "variables": {
1562 "secondary-color": "#ff6633",
1563 "primary-color": "#ff0000",
1564 "spacing-unit": "8px"
1565 }
1566 }"##;
1567 fs::write(&vars_file, vars_content).unwrap();
1568
1569 let existing_variables = vec![
1571 ("primary-color".to_string(), "#3366ff".to_string()),
1572 ("font-size-base".to_string(), "16px".to_string()),
1573 ];
1574
1575 let result =
1577 ConfigFs::load_external_variables(dir.path(), Some(existing_variables)).unwrap();
1578 assert!(result.is_some());
1579
1580 let variables = result.unwrap();
1581 assert_eq!(variables.len(), 4); let var_map: HashMap<String, String> = variables.into_iter().collect();
1585
1586 assert_eq!(var_map.get("primary-color").unwrap(), "#3366ff");
1588
1589 assert_eq!(var_map.get("secondary-color").unwrap(), "#ff6633");
1591 assert_eq!(var_map.get("spacing-unit").unwrap(), "8px");
1592
1593 assert_eq!(var_map.get("font-size-base").unwrap(), "16px");
1595 }
1596
1597 #[test]
1598 fn test_full_config_with_external_variables() {
1599 let dir = tempdir().unwrap();
1600 let config_dir = dir.path().join("grimoire").join("config");
1601 fs::create_dir_all(&config_dir).unwrap();
1602
1603 let config_file = config_dir.join("grimoire.config.json");
1605 let config_content = r##"{
1606 "variables": {
1607 "primary-color": "#3366ff"
1608 },
1609 "projects": [
1610 {
1611 "projectName": "main",
1612 "inputPaths": []
1613 }
1614 ]
1615 }"##;
1616 fs::write(&config_file, config_content).unwrap();
1617
1618 let vars_file = config_dir.join("grimoire.theme.variables.json");
1620 let vars_content = r##"{
1621 "variables": {
1622 "secondary-color": "#ff6633",
1623 "spacing-unit": "8px"
1624 }
1625 }"##;
1626 fs::write(&vars_file, vars_content).unwrap();
1627
1628 let config = ConfigFs::load(dir.path()).expect("Failed to load config");
1630
1631 assert!(config.variables.is_some());
1633 let variables = config.variables.unwrap();
1634 assert_eq!(variables.len(), 3);
1635
1636 assert_eq!(variables[0].0, "primary-color");
1638 assert_eq!(variables[1].0, "secondary-color");
1639 assert_eq!(variables[2].0, "spacing-unit");
1640 }
1641}