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    /// Also searches for and loads any external scroll files (grimoire.*.scrolls.json)
166    /// and any external variables files (grimoire.*.variables.json).
167    ///
168    /// # Errors
169    ///
170    /// Returns a `GrimoireCSSError` if reading or parsing the file fails.
171    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        // Load custom animations
178        config.custom_animations = Self::find_custom_animations(current_dir)?;
179
180        // Load external scroll files
181        config.scrolls = Self::load_external_scrolls(current_dir, config.scrolls)?;
182
183        // Load external variable files
184        config.variables = Self::load_external_variables(current_dir, config.variables)?;
185
186        Ok(config)
187    }
188
189    /// Saves the current configuration to the file system.
190    ///
191    /// Serializes the current configuration into JSON format and writes it to the file system.
192    ///
193    /// # Errors
194    ///
195    /// Returns a `GrimoireCSSError` if writing to the file system fails.
196    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    /// Extracts common spells from the configuration and adds them to a `HashSet`.
206    ///
207    /// # Arguments
208    ///
209    /// * `config` - A reference to the `ConfigJSON` structure that holds the spells data.
210    ///
211    /// # Returns
212    ///
213    /// A `HashSet` of common spell names used across projects.
214    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    /// Converts a JSON representation of the configuration into a `Config` instance.
237    ///
238    /// # Arguments
239    ///
240    /// * `json_config` - A `ConfigJSON` object representing the deserialized configuration data.
241    ///
242    /// # Returns
243    ///
244    /// A new `Config` instance.
245    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        // Expand glob patterns in shared and critical configurations
257        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    /// Converts shared JSON configuration into internal structure.
274    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    /// Converts critical JSON configuration into internal structure.
290    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                // Recursively resolve parent spells
317                Self::resolve_spells(scroll, &scrolls_vec, &mut scroll_spells);
318
319                // Add the spells of the current scroll
320                scroll_spells.extend_from_slice(&scroll.spells);
321
322                // Insert the resolved spells into the map
323                scrolls_map.insert(scroll.name.clone(), scroll_spells);
324            }
325
326            scrolls_map
327        })
328    }
329
330    /// Recursively resolve spells for a given scroll, including extended scrolls
331    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                // Find the parent scroll
339                if let Some(parent_scroll) = scrolls_vec.iter().find(|s| &s.name == ext_name) {
340                    // Recursively resolve parent spells if it also extends other scrolls
341                    Self::resolve_spells(parent_scroll, scrolls_vec, collected_spells);
342
343                    // Add the spells of the parent scroll
344                    collected_spells.extend_from_slice(&parent_scroll.spells);
345                }
346            }
347        }
348    }
349
350    /// Converts custom CSS properties from JSON to internal structure.
351    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    /// Converts a list of project JSON configurations to the internal `Project` type.
372    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    /// Converts the internal `Config` into its JSON representation.
388    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    /// Converts the internal list of shared configurations into JSON.
410    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    /// Converts the internal list of critical configurations into JSON.
426    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    /// Converts custom CSS properties to JSON format.
444    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    /// Converts the internal list of `Project` into its JSON representation.
477    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    /// Searches for and loads custom animation files from the "animations" subdirectory.
490    ///
491    /// This function scans the "animations" subdirectory within the given `current_dir/grimoire`,
492    /// reads the content of each file, and stores it in a `HashMap`. The key of the
493    /// HashMap is the file name (without extension), and the value is the file content.
494    ///
495    /// # Arguments
496    ///
497    /// * `current_dir` - A reference to a `Path` representing the directory to search in.
498    ///
499    /// # Returns
500    ///
501    /// Returns a `Result` containing:
502    /// - `Ok(HashMap<String, String>)`: A HashMap where keys are file names (without extension)
503    ///   and values are the contents of the animation files.
504    /// - `Err(GrimoireCSSError)`: An error if there's an issue reading the directory or files.
505    ///
506    /// # Errors
507    ///
508    /// This function will return an error if:
509    /// - The "animations" subdirectory cannot be read.
510    /// - There's an issue reading any of the files in the subdirectory.
511    /// - File names cannot be converted to valid UTF-8 strings.
512    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    /// Loads external scrolls from files matching the pattern "grimoire.*.scrolls.json" in the config directory.
581    /// If the main config already has scrolls, they will be merged with the external ones.
582    /// Scrolls from the main configuration have higher priority and are not overwritten.
583    ///
584    /// # Arguments
585    ///
586    /// * `current_dir` - A reference to the current working directory
587    /// * `existing_scrolls` - Optional HashMap of existing scrolls from main config
588    ///
589    /// # Returns
590    ///
591    /// * `Option<HashMap<String, Vec<String>>>` - Merged scrolls from main config and external files
592    ///
593    /// # Errors
594    ///
595    /// Returns a `GrimoireCSSError` if reading or parsing any external scroll file fails.
596    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        // Get the config directory path
601        let config_dir = Filesystem::get_or_create_grimoire_path(current_dir)?.join("config");
602
603        // Initialize with existing scrolls or create new HashMap
604        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        // Use glob pattern to directly find matching files instead of reading entire directory
609        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                        // Read and parse the external scroll file
619                        match fs::read_to_string(&entry) {
620                            Ok(content) => {
621                                match serde_json::from_str::<serde_json::Value>(&content) {
622                                    Ok(json) => {
623                                        // Extract and process scrolls from the JSON
624                                        if let Some(scrolls) =
625                                            json.get("scrolls").and_then(|s| s.as_array())
626                                        {
627                                            external_files_found = true;
628
629                                            // Parse each scroll from the array
630                                            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                                                    // Don't override existing scrolls from main config, just add new ones
636                                                    if !existing_scroll_names.contains(name) {
637                                                        // Convert the spell array to Vec<String>
638                                                        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                                                        // Insert new scroll
646                                                        all_scrolls
647                                                            .insert(name.to_string(), spells);
648                                                        existing_scroll_names
649                                                            .insert(name.to_string());
650                                                    }
651                                                    // Existing scrolls from main config have higher priority
652                                                }
653                                            }
654
655                                            add_message(format!(
656                                                "Loaded external scrolls from '{file_name}'"
657                                            ));
658                                        }
659                                    }
660                                    Err(err) => {
661                                        add_message(format!(
662                                            "Failed to parse external scroll file '{file_name}': {err}"
663                                        ));
664                                    }
665                                }
666                            }
667                            Err(err) => {
668                                add_message(format!(
669                                    "Failed to read external scroll file '{file_name}': {err}"
670                                ));
671                            }
672                        }
673                    }
674                }
675            }
676            Err(err) => {
677                add_message(format!("Failed to search for external scroll files: {err}"));
678            }
679        }
680
681        // Only return Some if we have scrolls, otherwise None
682        if all_scrolls.is_empty() {
683            Ok(None)
684        } else {
685            // Add a message if we loaded external scrolls
686            if external_files_found {
687                add_message("External scroll files were merged with configuration".to_string());
688            }
689            Ok(Some(all_scrolls))
690        }
691    }
692
693    /// Loads external variables from files matching the pattern "grimoire.*.variables.json" in the config directory.
694    /// If the main config already has variables, they will be merged with the external ones.
695    ///
696    /// # Arguments
697    ///
698    /// * `current_dir` - A reference to the current working directory
699    /// * `existing_variables` - Optional Vector of existing variables from main config
700    ///
701    /// # Returns
702    ///
703    /// * `Option<Vec<(String, String)>>` - Merged variables from main config and external files
704    ///
705    /// # Errors
706    ///
707    /// Returns a `GrimoireCSSError` if reading or parsing any external variables file fails.
708    fn load_external_variables(
709        current_dir: &Path,
710        existing_variables: Option<Vec<(String, String)>>,
711    ) -> Result<Option<Vec<(String, String)>>, GrimoireCssError> {
712        // Get the config directory path
713        let config_dir = Filesystem::get_or_create_grimoire_path(current_dir)?.join("config");
714
715        // Initialize with existing variables or create new Vec
716        let mut all_variables = existing_variables.unwrap_or_default();
717        let mut existing_keys: HashSet<String> =
718            all_variables.iter().map(|(key, _)| key.clone()).collect();
719        let mut external_files_found = false;
720
721        // Use glob pattern to directly find matching files
722        let pattern = config_dir
723            .join("grimoire.*.variables.json")
724            .to_string_lossy()
725            .to_string();
726
727        match glob::glob(&pattern) {
728            Ok(entries) => {
729                for entry in entries.flatten() {
730                    if let Some(file_name) = entry.file_name().and_then(|s| s.to_str()) {
731                        // Read and parse the external variables file
732                        match fs::read_to_string(&entry) {
733                            Ok(content) => {
734                                match serde_json::from_str::<serde_json::Value>(&content) {
735                                    Ok(json) => {
736                                        // Extract and process variables from the JSON
737                                        if let Some(variables) =
738                                            json.get("variables").and_then(|v| v.as_object())
739                                        {
740                                            external_files_found = true;
741
742                                            // Parse each variable from the object
743                                            for (key, value) in variables {
744                                                if let Some(value_str) = value.as_str() {
745                                                    // If the key doesn't exist yet, add it
746                                                    if !existing_keys.contains(key) {
747                                                        all_variables.push((
748                                                            key.clone(),
749                                                            value_str.to_string(),
750                                                        ));
751                                                        existing_keys.insert(key.clone());
752                                                    }
753                                                    // If the key exists, we don't override it - first come, first served
754                                                }
755                                            }
756
757                                            add_message(format!(
758                                                "Loaded external variables from '{file_name}'"
759                                            ));
760                                        }
761                                    }
762                                    Err(err) => {
763                                        add_message(format!(
764                                            "Failed to parse external variables file '{file_name}': {err}"
765                                        ));
766                                    }
767                                }
768                            }
769                            Err(err) => {
770                                add_message(format!(
771                                    "Failed to read external variables file '{file_name}': {err}"
772                                ));
773                            }
774                        }
775                    }
776                }
777            }
778            Err(err) => {
779                add_message(format!(
780                    "Failed to search for external variables files: {err}"
781                ));
782            }
783        }
784
785        // Sort variables by key for consistency
786        if !all_variables.is_empty() {
787            all_variables.sort_by(|a, b| a.0.cmp(&b.0));
788
789            // Add a message if we loaded external variables
790            if external_files_found {
791                add_message("External variable files were merged with configuration".to_string());
792            }
793            Ok(Some(all_variables))
794        } else {
795            Ok(None)
796        }
797    }
798}
799
800#[cfg(test)]
801mod tests {
802    use super::*;
803    use std::fs::File;
804    use std::io::Write;
805    use tempfile::tempdir;
806
807    #[test]
808    fn test_default_config() {
809        let config = ConfigFs::default();
810        assert!(config.variables.is_none());
811        assert!(config.scrolls.is_none());
812        assert!(config.shared.is_none());
813        assert!(config.critical.is_none());
814        assert_eq!(config.projects.len(), 1);
815        assert_eq!(config.projects[0].project_name, "main");
816    }
817
818    #[test]
819    fn test_load_nonexistent_config() {
820        let dir = tempdir().unwrap();
821        let result = ConfigFs::load(dir.path());
822        assert!(result.is_err());
823    }
824
825    #[test]
826    fn test_save_and_load_config() {
827        let dir = tempdir().unwrap();
828        let config = ConfigFs::default();
829        config.save(dir.path()).expect("Failed to save config");
830
831        let loaded_config = ConfigFs::load(dir.path()).expect("Failed to load config");
832        assert_eq!(
833            config.projects[0].project_name,
834            loaded_config.projects[0].project_name
835        );
836    }
837
838    #[test]
839    fn test_expand_glob_patterns() {
840        let dir = tempdir().unwrap();
841        let file_path = dir.path().join("test.txt");
842        File::create(&file_path).unwrap();
843
844        let patterns = vec![format!("{}/**/*.txt", dir.path().to_str().unwrap())];
845        let expanded = ConfigFs::expand_glob_patterns(patterns);
846        assert_eq!(expanded.len(), 1);
847        assert!(expanded[0].ends_with("test.txt"));
848    }
849
850    #[test]
851    fn test_find_custom_animations_empty() {
852        let dir = tempdir().unwrap();
853        let animations = ConfigFs::find_custom_animations(dir.path()).unwrap();
854        assert!(animations.is_empty());
855    }
856
857    #[test]
858    fn test_find_custom_animations_with_files() {
859        let dir = tempdir().unwrap();
860        let animations_dir = dir.path().join("grimoire").join("animations");
861        fs::create_dir_all(&animations_dir).unwrap();
862
863        let animation_file = animations_dir.join("fade_in.css");
864        let mut file = File::create(&animation_file).unwrap();
865        writeln!(
866            file,
867            "@keyframes fade_in {{ from {{ opacity: 0; }} to {{ opacity: 1; }} }}"
868        )
869        .unwrap();
870
871        let animations = ConfigFs::find_custom_animations(dir.path()).unwrap();
872        assert_eq!(animations.len(), 1);
873        assert!(animations.contains_key("fade_in"));
874    }
875
876    #[test]
877    fn test_get_common_spells_set() {
878        let json = ConfigFsJSON {
879            schema: None,
880            variables: None,
881            scrolls: None,
882            projects: vec![],
883            shared: Some(vec![ConfigFsSharedJSON {
884                output_path: "styles.css".to_string(),
885                styles: Some(vec!["spell1".to_string(), "spell2".to_string()]),
886                css_custom_properties: None,
887            }]),
888            critical: Some(vec![ConfigFsCriticalJSON {
889                file_to_inline_paths: vec!["index.html".to_string()],
890                styles: Some(vec!["spell3".to_string()]),
891                css_custom_properties: None,
892            }]),
893            lock: None,
894        };
895
896        let common_spells = ConfigFs::get_common_spells_set(&json);
897        assert_eq!(common_spells.len(), 3);
898        assert!(common_spells.contains("spell1"));
899        assert!(common_spells.contains("spell2"));
900        assert!(common_spells.contains("spell3"));
901    }
902
903    #[test]
904    fn test_load_external_scrolls_no_files() {
905        let dir = tempdir().unwrap();
906        let config_dir = dir.path().join("grimoire").join("config");
907        fs::create_dir_all(&config_dir).unwrap();
908
909        // Create a basic config file to prevent load() from failing
910        let config_file = config_dir.join("grimoire.config.json");
911        let config_content = r#"{
912            "projects": [
913                {
914                    "projectName": "main",
915                    "inputPaths": []
916                }
917            ]
918        }"#;
919        fs::write(&config_file, config_content).unwrap();
920
921        // No external scroll files
922        let result = ConfigFs::load_external_scrolls(dir.path(), None).unwrap();
923        assert!(result.is_none());
924    }
925
926    #[test]
927    fn test_load_external_scrolls_single_file() {
928        let dir = tempdir().unwrap();
929        let config_dir = dir.path().join("grimoire").join("config");
930        fs::create_dir_all(&config_dir).unwrap();
931
932        // Create a basic config file to prevent load() from failing
933        let config_file = config_dir.join("grimoire.config.json");
934        let config_content = r#"{
935            "projects": [
936                {
937                    "projectName": "main",
938                    "inputPaths": []
939                }
940            ]
941        }"#;
942        fs::write(&config_file, config_content).unwrap();
943
944        // Create an external scrolls file
945        let scrolls_file = config_dir.join("grimoire.tailwindcss.scrolls.json");
946        let scrolls_content = r#"{
947            "scrolls": [
948                {
949                    "name": "tw-btn",
950                    "spells": [
951                        "p=4px",
952                        "bg=blue",
953                        "c=white",
954                        "br=4px"
955                    ]
956                }
957            ]
958        }"#;
959        fs::write(&scrolls_file, scrolls_content).unwrap();
960
961        // Load external scrolls
962        let result = ConfigFs::load_external_scrolls(dir.path(), None).unwrap();
963        assert!(result.is_some());
964
965        let scrolls = result.unwrap();
966        assert_eq!(scrolls.len(), 1);
967        assert!(scrolls.contains_key("tw-btn"));
968        assert_eq!(scrolls.get("tw-btn").unwrap().len(), 4);
969    }
970
971    #[test]
972    fn test_load_external_scrolls_multiple_files() {
973        let dir = tempdir().unwrap();
974        let config_dir = dir.path().join("grimoire").join("config");
975        fs::create_dir_all(&config_dir).unwrap();
976
977        // Create a basic config file
978        let config_file = config_dir.join("grimoire.config.json");
979        let config_content = r#"{
980            "projects": [
981                {
982                    "projectName": "main",
983                    "inputPaths": []
984                }
985            ]
986        }"#;
987        fs::write(&config_file, config_content).unwrap();
988
989        // Create first external scrolls file
990        let scrolls_file1 = config_dir.join("grimoire.tailwindcss.scrolls.json");
991        let scrolls_content1 = r#"{
992            "scrolls": [
993                {
994                    "name": "tw-btn",
995                    "spells": [
996                        "p=4px",
997                        "bg=blue",
998                        "c=white",
999                        "br=4px"
1000                    ]
1001                }
1002            ]
1003        }"#;
1004        fs::write(&scrolls_file1, scrolls_content1).unwrap();
1005
1006        // Create second external scrolls file
1007        let scrolls_file2 = config_dir.join("grimoire.bootstrap.scrolls.json");
1008        let scrolls_content2 = r#"{
1009            "scrolls": [
1010                {
1011                    "name": "bs-card",
1012                    "spells": [
1013                        "border=1px_solid_#ccc",
1014                        "br=8px",
1015                        "shadow=0_2px_8px_rgba(0,0,0,0.1)"
1016                    ]
1017                }
1018            ]
1019        }"#;
1020        fs::write(&scrolls_file2, scrolls_content2).unwrap();
1021
1022        // Load external scrolls
1023        let result = ConfigFs::load_external_scrolls(dir.path(), None).unwrap();
1024        assert!(result.is_some());
1025
1026        let scrolls = result.unwrap();
1027        assert_eq!(scrolls.len(), 2);
1028        assert!(scrolls.contains_key("tw-btn"));
1029        assert!(scrolls.contains_key("bs-card"));
1030        assert_eq!(scrolls.get("tw-btn").unwrap().len(), 4);
1031        assert_eq!(scrolls.get("bs-card").unwrap().len(), 3);
1032    }
1033
1034    #[test]
1035    fn test_merge_with_existing_scrolls() {
1036        let dir = tempdir().unwrap();
1037        let config_dir = dir.path().join("grimoire").join("config");
1038        fs::create_dir_all(&config_dir).unwrap();
1039
1040        // Create a basic config file
1041        let config_file = config_dir.join("grimoire.config.json");
1042        let config_content = r#"{
1043            "scrolls": [
1044                {
1045                    "name": "main-btn",
1046                    "spells": [
1047                        "p=10px",
1048                        "fw=bold",
1049                        "c=black"
1050                    ]
1051                }
1052            ],
1053            "projects": [
1054                {
1055                    "projectName": "main",
1056                    "inputPaths": []
1057                }
1058            ]
1059        }"#;
1060        fs::write(&config_file, config_content).unwrap();
1061
1062        // Create an external scrolls file
1063        let scrolls_file = config_dir.join("grimoire.extra.scrolls.json");
1064        let scrolls_content = r#"{
1065            "scrolls": [
1066                {
1067                    "name": "main-btn",
1068                    "spells": [
1069                        "bg=green",
1070                        "hover:bg=darkgreen"
1071                    ]
1072                },
1073                {
1074                    "name": "extra-btn",
1075                    "spells": [
1076                        "fs=16px",
1077                        "m=10px"
1078                    ]
1079                }
1080            ]
1081        }"#;
1082        fs::write(&scrolls_file, scrolls_content).unwrap();
1083
1084        // Create mock existing scrolls
1085        let mut existing_scrolls = HashMap::new();
1086        existing_scrolls.insert(
1087            "main-btn".to_string(),
1088            vec![
1089                "p=10px".to_string(),
1090                "fw=bold".to_string(),
1091                "c=black".to_string(),
1092            ],
1093        );
1094
1095        // Load and merge external scrolls
1096        let result = ConfigFs::load_external_scrolls(dir.path(), Some(existing_scrolls)).unwrap();
1097        assert!(result.is_some());
1098
1099        let scrolls = result.unwrap();
1100        assert_eq!(scrolls.len(), 2);
1101
1102        // Check that main-btn has combined spells from both sources
1103        assert!(scrolls.contains_key("main-btn"));
1104        assert_eq!(scrolls.get("main-btn").unwrap().len(), 3);
1105
1106        // Check that extra-btn was added
1107        assert!(scrolls.contains_key("extra-btn"));
1108        assert_eq!(scrolls.get("extra-btn").unwrap().len(), 2);
1109    }
1110
1111    #[test]
1112    fn test_full_config_with_external_scrolls() {
1113        let dir = tempdir().unwrap();
1114        let config_dir = dir.path().join("grimoire").join("config");
1115        fs::create_dir_all(&config_dir).unwrap();
1116
1117        // Create a basic config file
1118        let config_file = config_dir.join("grimoire.config.json");
1119        let config_content = r#"{
1120            "scrolls": [
1121                {
1122                    "name": "base-btn",
1123                    "spells": [
1124                        "p=10px",
1125                        "br=4px"
1126                    ]
1127                }
1128            ],
1129            "projects": [
1130                {
1131                    "projectName": "main",
1132                    "inputPaths": []
1133                }
1134            ]
1135        }"#;
1136        fs::write(&config_file, config_content).unwrap();
1137
1138        // Create an external scrolls file
1139        let scrolls_file = config_dir.join("grimoire.theme.scrolls.json");
1140        let scrolls_content = r#"{
1141            "scrolls": [
1142                {
1143                    "name": "theme-btn",
1144                    "spells": [
1145                        "bg=purple",
1146                        "c=white"
1147                    ]
1148                }
1149            ]
1150        }"#;
1151        fs::write(&scrolls_file, scrolls_content).unwrap();
1152
1153        // Load the full configuration
1154        let config = ConfigFs::load(dir.path()).expect("Failed to load config");
1155
1156        // Check that both scrolls are loaded
1157        assert!(config.scrolls.is_some());
1158        let scrolls = config.scrolls.unwrap();
1159        assert_eq!(scrolls.len(), 2);
1160        assert!(scrolls.contains_key("base-btn"));
1161        assert!(scrolls.contains_key("theme-btn"));
1162    }
1163
1164    #[test]
1165    fn test_load_external_variables_no_files() {
1166        let dir = tempdir().unwrap();
1167        let config_dir = dir.path().join("grimoire").join("config");
1168        fs::create_dir_all(&config_dir).unwrap();
1169
1170        // Create a basic config file to prevent load() from failing
1171        let config_file = config_dir.join("grimoire.config.json");
1172        let config_content = r#"{
1173            "projects": [
1174                {
1175                    "projectName": "main",
1176                    "inputPaths": []
1177                }
1178            ]
1179        }"#;
1180        fs::write(&config_file, config_content).unwrap();
1181
1182        // No external variable files
1183        let result = ConfigFs::load_external_variables(dir.path(), None).unwrap();
1184        assert!(result.is_none());
1185    }
1186
1187    #[test]
1188    fn test_load_external_variables_single_file() {
1189        let dir = tempdir().unwrap();
1190        let config_dir = dir.path().join("grimoire").join("config");
1191        fs::create_dir_all(&config_dir).unwrap();
1192
1193        // Create a basic config file to prevent load() from failing
1194        let config_file = config_dir.join("grimoire.config.json");
1195        let config_content = r#"{
1196            "projects": [
1197                {
1198                    "projectName": "main",
1199                    "inputPaths": []
1200                }
1201            ]
1202        }"#;
1203        fs::write(&config_file, config_content).unwrap();
1204
1205        // Create an external variables file
1206        let vars_file = config_dir.join("grimoire.theme.variables.json");
1207        let vars_content = r##"{
1208            "variables": {
1209                "primary-color": "#3366ff",
1210                "secondary-color": "#ff6633",
1211                "font-size-base": "16px"
1212            }
1213        }"##;
1214        fs::write(&vars_file, vars_content).unwrap();
1215
1216        // Load external variables
1217        let result = ConfigFs::load_external_variables(dir.path(), None).unwrap();
1218        assert!(result.is_some());
1219
1220        let variables = result.unwrap();
1221        assert_eq!(variables.len(), 3);
1222
1223        // Check that variables are sorted by key
1224        assert_eq!(variables[0].0, "font-size-base");
1225        assert_eq!(variables[0].1, "16px");
1226        assert_eq!(variables[1].0, "primary-color");
1227        assert_eq!(variables[1].1, "#3366ff");
1228        assert_eq!(variables[2].0, "secondary-color");
1229        assert_eq!(variables[2].1, "#ff6633");
1230    }
1231
1232    #[test]
1233    fn test_load_external_variables_multiple_files() {
1234        let dir = tempdir().unwrap();
1235        let config_dir = dir.path().join("grimoire").join("config");
1236        fs::create_dir_all(&config_dir).unwrap();
1237
1238        // Create a basic config file
1239        let config_file = config_dir.join("grimoire.config.json");
1240        let config_content = r#"{
1241            "projects": [
1242                {
1243                    "projectName": "main",
1244                    "inputPaths": []
1245                }
1246            ]
1247        }"#;
1248        fs::write(&config_file, config_content).unwrap();
1249
1250        // Create first external variables file
1251        let vars_file1 = config_dir.join("grimoire.colors.variables.json");
1252        let vars_content1 = r##"{
1253            "variables": {
1254                "primary-color": "#3366ff",
1255                "secondary-color": "#ff6633"
1256            }
1257        }"##;
1258        fs::write(&vars_file1, vars_content1).unwrap();
1259
1260        // Create second external variables file
1261        let vars_file2 = config_dir.join("grimoire.typography.variables.json");
1262        let vars_content2 = r##"{
1263            "variables": {
1264                "font-size-base": "16px",
1265                "font-family-sans": "Arial, sans-serif"
1266            }
1267        }"##;
1268        fs::write(&vars_file2, vars_content2).unwrap();
1269
1270        // Load external variables
1271        let result = ConfigFs::load_external_variables(dir.path(), None).unwrap();
1272        assert!(result.is_some());
1273
1274        let variables = result.unwrap();
1275        assert_eq!(variables.len(), 4);
1276
1277        // Create a map for easier testing
1278        let var_map: HashMap<String, String> = variables.into_iter().collect();
1279        assert!(var_map.contains_key("primary-color"));
1280        assert!(var_map.contains_key("secondary-color"));
1281        assert!(var_map.contains_key("font-size-base"));
1282        assert!(var_map.contains_key("font-family-sans"));
1283
1284        assert_eq!(var_map.get("primary-color").unwrap(), "#3366ff");
1285        assert_eq!(
1286            var_map.get("font-family-sans").unwrap(),
1287            "Arial, sans-serif"
1288        );
1289    }
1290
1291    #[test]
1292    fn test_merge_with_existing_variables() {
1293        let dir = tempdir().unwrap();
1294        let config_dir = dir.path().join("grimoire").join("config");
1295        fs::create_dir_all(&config_dir).unwrap();
1296
1297        // Create a basic config file with variables
1298        let config_file = config_dir.join("grimoire.config.json");
1299        let config_content = r##"{
1300            "variables": {
1301                "primary-color": "#3366ff",
1302                "font-size-base": "16px"
1303            },
1304            "projects": [
1305                {
1306                    "projectName": "main",
1307                    "inputPaths": []
1308                }
1309            ]
1310        }"##;
1311        fs::write(&config_file, config_content).unwrap();
1312
1313        // Create an external variables file
1314        let vars_file = config_dir.join("grimoire.extra.variables.json");
1315        let vars_content = r##"{
1316            "variables": {
1317                "secondary-color": "#ff6633",
1318                "primary-color": "#ff0000",
1319                "spacing-unit": "8px"
1320            }
1321        }"##;
1322        fs::write(&vars_file, vars_content).unwrap();
1323
1324        // Create mock existing variables
1325        let existing_variables = vec![
1326            ("primary-color".to_string(), "#3366ff".to_string()),
1327            ("font-size-base".to_string(), "16px".to_string()),
1328        ];
1329
1330        // Load and merge external variables
1331        let result =
1332            ConfigFs::load_external_variables(dir.path(), Some(existing_variables)).unwrap();
1333        assert!(result.is_some());
1334
1335        let variables = result.unwrap();
1336        assert_eq!(variables.len(), 4); // primary-color, font-size-base, secondary-color, spacing-unit
1337
1338        // Create a map for easier testing
1339        let var_map: HashMap<String, String> = variables.into_iter().collect();
1340
1341        // Primary color should remain from the original config (not overwritten)
1342        assert_eq!(var_map.get("primary-color").unwrap(), "#3366ff");
1343
1344        // New variables should be added
1345        assert_eq!(var_map.get("secondary-color").unwrap(), "#ff6633");
1346        assert_eq!(var_map.get("spacing-unit").unwrap(), "8px");
1347
1348        // Original variables should be preserved
1349        assert_eq!(var_map.get("font-size-base").unwrap(), "16px");
1350    }
1351
1352    #[test]
1353    fn test_full_config_with_external_variables() {
1354        let dir = tempdir().unwrap();
1355        let config_dir = dir.path().join("grimoire").join("config");
1356        fs::create_dir_all(&config_dir).unwrap();
1357
1358        // Create a basic config file with variables
1359        let config_file = config_dir.join("grimoire.config.json");
1360        let config_content = r##"{
1361            "variables": {
1362                "primary-color": "#3366ff"
1363            },
1364            "projects": [
1365                {
1366                    "projectName": "main",
1367                    "inputPaths": []
1368                }
1369            ]
1370        }"##;
1371        fs::write(&config_file, config_content).unwrap();
1372
1373        // Create an external variables file
1374        let vars_file = config_dir.join("grimoire.theme.variables.json");
1375        let vars_content = r##"{
1376            "variables": {
1377                "secondary-color": "#ff6633",
1378                "spacing-unit": "8px"
1379            }
1380        }"##;
1381        fs::write(&vars_file, vars_content).unwrap();
1382
1383        // Load the full configuration
1384        let config = ConfigFs::load(dir.path()).expect("Failed to load config");
1385
1386        // Check that variables from both sources are loaded
1387        assert!(config.variables.is_some());
1388        let variables = config.variables.unwrap();
1389        assert_eq!(variables.len(), 3);
1390
1391        // Variables should be sorted by key
1392        assert_eq!(variables[0].0, "primary-color");
1393        assert_eq!(variables[1].0, "secondary-color");
1394        assert_eq!(variables[2].0, "spacing-unit");
1395    }
1396}