grimoire_css_lib/core/config/
config_fs.rs

1//! This module provides the configuration management for GrimoireCSS.
2
3use 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/// Represents the main configuration structure for GrimoireCSS.
16#[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    /// A set of shared spells used across different projects.
24    pub shared_spells: HashSet<String>,
25    pub lock: Option<bool>,
26
27    pub custom_animations: HashMap<String, String>,
28}
29
30/// Shared configuration for GrimoireCSS projects.
31#[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/// Critical styles configuration to be inlined into specific HTML files.
39#[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/// Represents custom CSS properties associated with specific elements.
47#[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/// Represents a project in GrimoireCSS.
56#[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// ---
65
66/// The main struct used to represent the JSON structure of the GrimoireCSS configuration.
67///
68/// This struct is used internally to serialize and deserialize the configuration data.
69#[derive(Serialize, Deserialize, Debug, Clone)]
70struct ConfigFsJSON {
71    #[serde(rename = "$schema")]
72    pub schema: Option<String>,
73    /// Optional framework-level variables used during compilation.
74    pub variables: Option<HashMap<String, String>>,
75    /// Optional shared configuration settings used across multiple projects.
76    pub scrolls: Option<Vec<ConfigFsScrollJSON>>,
77    /// A list of projects included in the configuration.
78    pub projects: Vec<ConfigFsProjectJSON>,
79    pub shared: Option<Vec<ConfigFsSharedJSON>>,
80    pub critical: Option<Vec<ConfigFsCriticalJSON>>,
81    pub lock: Option<bool>,
82}
83
84/// Represents a scrolls which may contain external or combined CSS rules.
85#[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/// A struct representing a project within GrimoireCSS.
93#[derive(Serialize, Deserialize, Debug, Clone)]
94#[serde(rename_all = "camelCase")]
95struct ConfigFsProjectJSON {
96    /// The name of the project.
97    pub project_name: String,
98    /// A list of input paths for the project.
99    pub input_paths: Vec<String>,
100    /// Optional output directory path for the project.
101    pub output_dir_path: Option<String>,
102    /// Optional file name for a single output file.
103    pub single_output_file_name: Option<String>,
104}
105
106/// Represents shared configuration settings used across multiple projects.
107#[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/// Represents critical styles configuration for inlining into HTML files.
116#[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/// Represents a custom CSS property item, including associated variables.
125#[derive(Serialize, Deserialize, Debug, Clone)]
126#[serde(rename_all = "camelCase")]
127struct ConfigFsCSSCustomPropertiesJSON {
128    /// The optional DOM element (`tag`, `class`, `id`, `:root` (default)) associated with the CSS variables.
129    pub element: Option<String>,
130    /// A parameter name used within the CSS configuration.
131    pub data_param: String,
132    /// A value corresponding to the data parameter.
133    pub data_value: String,
134    /// A set of associated CSS variables and their values.
135    pub css_variables: HashMap<String, String>,
136}
137
138impl Default for ConfigFs {
139    /// Provides a default configuration for `Config`, initializing the `scrolls`, `projects`, and other fields.
140    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    /// Loads the configuration from the file system.
163    ///
164    /// Reads a JSON configuration file from the file system and deserializes it into a `Config` object.
165    ///
166    /// # Errors
167    ///
168    /// Returns a `GrimoireCSSError` if reading or parsing the file fails.
169    pub fn load(current_dir: &Path) -> Result<Self, GrimoireCssError> {
170        let config_path = Filesystem::get_config_path(current_dir)?;
171        let content = fs::read_to_string(&config_path)?;
172        let json_config: ConfigFsJSON = serde_json::from_str(&content)?;
173        let mut config = Self::from_json(json_config);
174
175        config.custom_animations = Self::find_custom_animations(current_dir)?;
176
177        Ok(config)
178    }
179
180    /// Saves the current configuration to the file system.
181    ///
182    /// Serializes the current configuration into JSON format and writes it to the file system.
183    ///
184    /// # Errors
185    ///
186    /// Returns a `GrimoireCSSError` if writing to the file system fails.
187    pub fn save(&self, current_dir: &Path) -> Result<(), GrimoireCssError> {
188        let config_path = Filesystem::get_config_path(current_dir)?;
189        let json_config = self.to_json();
190        let content = serde_json::to_string_pretty(&json_config)?;
191        fs::write(&config_path, content)?;
192
193        Ok(())
194    }
195
196    /// Extracts common spells from the configuration and adds them to a `HashSet`.
197    ///
198    /// # Arguments
199    ///
200    /// * `config` - A reference to the `ConfigJSON` structure that holds the spells data.
201    ///
202    /// # Returns
203    ///
204    /// A `HashSet` of common spell names used across projects.
205    fn get_common_spells_set(config: &ConfigFsJSON) -> HashSet<String> {
206        let mut common_spells = HashSet::new();
207
208        if let Some(shared) = &config.shared {
209            for shared_item in shared {
210                if let Some(styles) = &shared_item.styles {
211                    common_spells.extend(styles.iter().cloned());
212                }
213            }
214        }
215
216        if let Some(critical) = &config.critical {
217            for critical_item in critical {
218                if let Some(styles) = &critical_item.styles {
219                    common_spells.extend(styles.iter().cloned());
220                }
221            }
222        }
223
224        common_spells
225    }
226
227    /// Converts a JSON representation of the configuration into a `Config` instance.
228    ///
229    /// # Arguments
230    ///
231    /// * `json_config` - A `ConfigJSON` object representing the deserialized configuration data.
232    ///
233    /// # Returns
234    ///
235    /// A new `Config` instance.
236    fn from_json(json_config: ConfigFsJSON) -> Self {
237        let shared_spells = Self::get_common_spells_set(&json_config);
238
239        let variables = json_config.variables.map(|vars| {
240            let mut sorted_vars: Vec<_> = vars.into_iter().collect();
241            sorted_vars.sort_by(|a, b| a.0.cmp(&b.0));
242            sorted_vars
243        });
244
245        let projects = Self::projects_from_json(json_config.projects);
246
247        // Expand glob patterns in shared and critical configurations
248        let shared = Self::shared_from_json(json_config.shared);
249        let critical = Self::critical_from_json(json_config.critical);
250        let scrolls = Self::scrolls_from_json(json_config.scrolls);
251
252        ConfigFs {
253            variables,
254            scrolls,
255            projects,
256            shared,
257            critical,
258            shared_spells,
259            custom_animations: HashMap::new(),
260            lock: json_config.lock,
261        }
262    }
263
264    /// Converts shared JSON configuration into internal structure.
265    fn shared_from_json(shared: Option<Vec<ConfigFsSharedJSON>>) -> Option<Vec<ConfigFsShared>> {
266        shared.map(|shared_vec| {
267            shared_vec
268                .into_iter()
269                .map(|c| ConfigFsShared {
270                    output_path: c.output_path,
271                    styles: c.styles,
272                    css_custom_properties: Self::convert_css_custom_properties_from_json(
273                        c.css_custom_properties,
274                    ),
275                })
276                .collect()
277        })
278    }
279
280    /// Converts critical JSON configuration into internal structure.
281    fn critical_from_json(
282        critical: Option<Vec<ConfigFsCriticalJSON>>,
283    ) -> Option<Vec<ConfigFsCritical>> {
284        critical.map(|critical_vec| {
285            critical_vec
286                .into_iter()
287                .map(|c| ConfigFsCritical {
288                    file_to_inline_paths: Self::expand_glob_patterns(c.file_to_inline_paths),
289                    styles: c.styles,
290                    css_custom_properties: Self::convert_css_custom_properties_from_json(
291                        c.css_custom_properties,
292                    ),
293                })
294                .collect()
295        })
296    }
297
298    fn scrolls_from_json(
299        scrolls: Option<Vec<ConfigFsScrollJSON>>,
300    ) -> Option<HashMap<String, Vec<String>>> {
301        scrolls.map(|scrolls_vec| {
302            let mut scrolls_map = HashMap::new();
303
304            for scroll in &scrolls_vec {
305                let mut scroll_spells = Vec::new();
306
307                // Recursively resolve parent spells
308                Self::resolve_spells(scroll, &scrolls_vec, &mut scroll_spells);
309
310                // Add the spells of the current scroll
311                scroll_spells.extend_from_slice(&scroll.spells);
312
313                // Insert the resolved spells into the map
314                scrolls_map.insert(scroll.name.clone(), scroll_spells);
315            }
316
317            scrolls_map
318        })
319    }
320
321    /// Recursively resolve spells for a given scroll, including extended scrolls
322    fn resolve_spells(
323        scroll: &ConfigFsScrollJSON,
324        scrolls_vec: &[ConfigFsScrollJSON],
325        collected_spells: &mut Vec<String>,
326    ) {
327        if let Some(extends) = &scroll.extends {
328            for ext_name in extends {
329                // Find the parent scroll
330                if let Some(parent_scroll) = scrolls_vec.iter().find(|s| &s.name == ext_name) {
331                    // Recursively resolve parent spells if it also extends other scrolls
332                    Self::resolve_spells(parent_scroll, scrolls_vec, collected_spells);
333
334                    // Add the spells of the parent scroll
335                    collected_spells.extend_from_slice(&parent_scroll.spells);
336                }
337            }
338        }
339    }
340
341    /// Converts custom CSS properties from JSON to internal structure.
342    fn convert_css_custom_properties_from_json(
343        css_custom_properties_vec: Option<Vec<ConfigFsCSSCustomPropertiesJSON>>,
344    ) -> Option<Vec<ConfigFsCssCustomProperties>> {
345        css_custom_properties_vec.map(|items: Vec<ConfigFsCSSCustomPropertiesJSON>| {
346            items
347                .into_iter()
348                .map(|item| ConfigFsCssCustomProperties {
349                    element: item.element.unwrap_or_else(|| String::from(":root")),
350                    data_param: item.data_param,
351                    data_value: item.data_value,
352                    css_variables: {
353                        let mut vars: Vec<_> = item.css_variables.into_iter().collect();
354                        vars.sort_by(|a, b| a.0.cmp(&b.0));
355                        vars
356                    },
357                })
358                .collect()
359        })
360    }
361
362    /// Converts a list of project JSON configurations to the internal `Project` type.
363    fn projects_from_json(projects: Vec<ConfigFsProjectJSON>) -> Vec<ConfigFsProject> {
364        projects
365            .into_iter()
366            .map(|p| {
367                let input_paths = Self::expand_glob_patterns(p.input_paths);
368                ConfigFsProject {
369                    project_name: p.project_name,
370                    input_paths,
371                    output_dir_path: p.output_dir_path,
372                    single_output_file_name: p.single_output_file_name,
373                }
374            })
375            .collect()
376    }
377
378    /// Converts the internal `Config` into its JSON representation.
379    fn to_json(&self) -> ConfigFsJSON {
380        let variables_hash_map = self.variables.as_ref().map(|vars| {
381            let mut sorted_vars: Vec<_> = vars.iter().collect();
382            sorted_vars.sort_by(|a, b| a.0.cmp(&b.0));
383            sorted_vars
384                .into_iter()
385                .map(|(key, value)| (key.clone(), value.clone()))
386                .collect()
387        });
388
389        ConfigFsJSON {
390            schema: Some("https://raw.githubusercontent.com/persevie/grimoire-css/main/src/core/config/config-schema.json".to_string()),
391            variables: variables_hash_map,
392            scrolls: Self::scrolls_to_json(self.scrolls.clone()),
393            projects: Self::projects_to_json(self.projects.clone()),
394            shared: Self::shared_to_json(self.shared.as_ref()),
395            critical: Self::critical_to_json(self.critical.as_ref()),
396            lock: self.lock,
397        }
398    }
399
400    /// Converts the internal list of shared configurations into JSON.
401    fn shared_to_json(shared: Option<&Vec<ConfigFsShared>>) -> Option<Vec<ConfigFsSharedJSON>> {
402        shared.map(|common_vec: &Vec<ConfigFsShared>| {
403            common_vec
404                .iter()
405                .map(|c| ConfigFsSharedJSON {
406                    output_path: c.output_path.clone(),
407                    styles: c.styles.clone(),
408                    css_custom_properties: Self::css_custom_properties_to_json(
409                        c.css_custom_properties.as_ref(),
410                    ),
411                })
412                .collect()
413        })
414    }
415
416    /// Converts the internal list of critical configurations into JSON.
417    fn critical_to_json(
418        critical: Option<&Vec<ConfigFsCritical>>,
419    ) -> Option<Vec<ConfigFsCriticalJSON>> {
420        critical.map(|common_vec| {
421            common_vec
422                .iter()
423                .map(|c| ConfigFsCriticalJSON {
424                    file_to_inline_paths: c.file_to_inline_paths.clone(),
425                    styles: c.styles.clone(),
426                    css_custom_properties: Self::css_custom_properties_to_json(
427                        c.css_custom_properties.as_ref(),
428                    ),
429                })
430                .collect()
431        })
432    }
433
434    /// Converts custom CSS properties to JSON format.
435    fn css_custom_properties_to_json(
436        css_custom_properties_vec: Option<&Vec<ConfigFsCssCustomProperties>>,
437    ) -> Option<Vec<ConfigFsCSSCustomPropertiesJSON>> {
438        css_custom_properties_vec.map(|items: &Vec<ConfigFsCssCustomProperties>| {
439            items
440                .iter()
441                .map(|item| ConfigFsCSSCustomPropertiesJSON {
442                    element: Some(item.element.clone()),
443                    data_param: item.data_param.clone(),
444                    data_value: item.data_value.clone(),
445                    css_variables: item.css_variables.clone().into_iter().collect(),
446                })
447                .collect()
448        })
449    }
450
451    fn scrolls_to_json(
452        config_scrolls: Option<HashMap<String, Vec<String>>>,
453    ) -> Option<Vec<ConfigFsScrollJSON>> {
454        config_scrolls.map(|scrolls| {
455            let mut scrolls_vec = Vec::new();
456            for (name, spells) in scrolls {
457                scrolls_vec.push(ConfigFsScrollJSON {
458                    name,
459                    spells,
460                    extends: None,
461                });
462            }
463            scrolls_vec
464        })
465    }
466
467    /// Converts the internal list of `Project` into its JSON representation.
468    fn projects_to_json(projects: Vec<ConfigFsProject>) -> Vec<ConfigFsProjectJSON> {
469        projects
470            .into_iter()
471            .map(|p| ConfigFsProjectJSON {
472                project_name: p.project_name,
473                input_paths: p.input_paths,
474                output_dir_path: p.output_dir_path,
475                single_output_file_name: p.single_output_file_name,
476            })
477            .collect()
478    }
479
480    /// Searches for and loads custom animation files from the "animations" subdirectory.
481    ///
482    /// This function scans the "animations" subdirectory within the given `current_dir/grimoire`,
483    /// reads the content of each file, and stores it in a `HashMap`. The key of the
484    /// HashMap is the file name (without extension), and the value is the file content.
485    ///
486    /// # Arguments
487    ///
488    /// * `current_dir` - A reference to a `Path` representing the directory to search in.
489    ///
490    /// # Returns
491    ///
492    /// Returns a `Result` containing:
493    /// - `Ok(HashMap<String, String>)`: A HashMap where keys are file names (without extension)
494    ///   and values are the contents of the animation files.
495    /// - `Err(GrimoireCSSError)`: An error if there's an issue reading the directory or files.
496    ///
497    /// # Errors
498    ///
499    /// This function will return an error if:
500    /// - The "animations" subdirectory cannot be read.
501    /// - There's an issue reading any of the files in the subdirectory.
502    /// - File names cannot be converted to valid UTF-8 strings.
503    fn find_custom_animations(
504        current_dir: &Path,
505    ) -> Result<HashMap<String, String>, GrimoireCssError> {
506        let animations_dir =
507            Filesystem::get_or_create_grimoire_path(current_dir)?.join("animations");
508
509        if !animations_dir.exists() {
510            return Ok(HashMap::new());
511        }
512
513        let mut entries = animations_dir.read_dir()?.peekable();
514
515        if entries.peek().is_none() {
516            add_message("No custom animations were found in the 'animations' directory. Deleted unnecessary 'animations' directory".to_string());
517            fs::remove_dir(&animations_dir)?;
518            return Ok(HashMap::new());
519        }
520
521        let mut map = HashMap::new();
522
523        for entry in entries {
524            let entry = entry?;
525            let path = entry.path();
526
527            if path.is_file() {
528                if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
529                    if ext == "css" {
530                        if let Some(file_stem) = path.file_stem().and_then(|s| s.to_str()) {
531                            let content = fs::read_to_string(&path)?;
532                            map.insert(file_stem.to_owned(), content);
533                        }
534                    } else {
535                        add_message(format!(
536                            "Only CSS files are supported in the 'animations' directory. Skipping non-CSS file: {}.",
537                            path.display()
538                        ));
539                    }
540                }
541            } else {
542                add_message(format!(
543                    "Only files are supported in the 'animations' directory. Skipping directory: {}.",
544                    path.display()
545                ));
546            }
547        }
548
549        Ok(map)
550    }
551
552    fn expand_glob_patterns(patterns: Vec<String>) -> Vec<String> {
553        let mut paths = Vec::new();
554        for pattern in patterns {
555            match glob(&pattern) {
556                Ok(glob_paths) => {
557                    for path_result in glob_paths.flatten() {
558                        if let Some(path_str) = path_result.to_str() {
559                            paths.push(path_str.to_string());
560                        }
561                    }
562                }
563                Err(e) => {
564                    add_message(format!("Failed to read glob pattern {}: {}", pattern, e));
565                }
566            }
567        }
568        paths
569    }
570}
571
572#[cfg(test)]
573mod tests {
574    use super::*;
575    use std::fs::File;
576    use std::io::Write;
577    use tempfile::tempdir;
578
579    #[test]
580    fn test_default_config() {
581        let config = ConfigFs::default();
582        assert!(config.variables.is_none());
583        assert!(config.scrolls.is_none());
584        assert!(config.shared.is_none());
585        assert!(config.critical.is_none());
586        assert_eq!(config.projects.len(), 1);
587        assert_eq!(config.projects[0].project_name, "main");
588    }
589
590    #[test]
591    fn test_load_nonexistent_config() {
592        let dir = tempdir().unwrap();
593        let result = ConfigFs::load(dir.path());
594        assert!(result.is_err());
595    }
596
597    #[test]
598    fn test_save_and_load_config() {
599        let dir = tempdir().unwrap();
600        let config = ConfigFs::default();
601        config.save(dir.path()).expect("Failed to save config");
602
603        let loaded_config = ConfigFs::load(dir.path()).expect("Failed to load config");
604        assert_eq!(
605            config.projects[0].project_name,
606            loaded_config.projects[0].project_name
607        );
608    }
609
610    #[test]
611    fn test_expand_glob_patterns() {
612        let dir = tempdir().unwrap();
613        let file_path = dir.path().join("test.txt");
614        File::create(&file_path).unwrap();
615
616        let patterns = vec![format!("{}/**/*.txt", dir.path().to_str().unwrap())];
617        let expanded = ConfigFs::expand_glob_patterns(patterns);
618        assert_eq!(expanded.len(), 1);
619        assert!(expanded[0].ends_with("test.txt"));
620    }
621
622    #[test]
623    fn test_find_custom_animations_empty() {
624        let dir = tempdir().unwrap();
625        let animations = ConfigFs::find_custom_animations(dir.path()).unwrap();
626        assert!(animations.is_empty());
627    }
628
629    #[test]
630    fn test_find_custom_animations_with_files() {
631        let dir = tempdir().unwrap();
632        let animations_dir = dir.path().join("grimoire").join("animations");
633        fs::create_dir_all(&animations_dir).unwrap();
634
635        let animation_file = animations_dir.join("fade_in.css");
636        let mut file = File::create(&animation_file).unwrap();
637        writeln!(
638            file,
639            "@keyframes fade_in {{ from {{ opacity: 0; }} to {{ opacity: 1; }} }}"
640        )
641        .unwrap();
642
643        let animations = ConfigFs::find_custom_animations(dir.path()).unwrap();
644        assert_eq!(animations.len(), 1);
645        assert!(animations.contains_key("fade_in"));
646    }
647
648    #[test]
649    fn test_get_common_spells_set() {
650        let json = ConfigFsJSON {
651            schema: None,
652            variables: None,
653            scrolls: None,
654            projects: vec![],
655            shared: Some(vec![ConfigFsSharedJSON {
656                output_path: "styles.css".to_string(),
657                styles: Some(vec!["spell1".to_string(), "spell2".to_string()]),
658                css_custom_properties: None,
659            }]),
660            critical: Some(vec![ConfigFsCriticalJSON {
661                file_to_inline_paths: vec!["index.html".to_string()],
662                styles: Some(vec!["spell3".to_string()]),
663                css_custom_properties: None,
664            }]),
665            lock: None,
666        };
667
668        let common_spells = ConfigFs::get_common_spells_set(&json);
669        assert_eq!(common_spells.len(), 3);
670        assert!(common_spells.contains("spell1"));
671        assert!(common_spells.contains("spell2"));
672        assert!(common_spells.contains("spell3"));
673    }
674}