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