Skip to main content

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, ScrollDefinition},
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    /// The Grimoire CSS version that last wrote/validated this config.
19    pub grimoire_css_version: Option<String>,
20    pub variables: Option<Vec<(String, String)>>,
21    pub scrolls: Option<HashMap<String, ScrollDefinition>>,
22    pub projects: Vec<ConfigFsProject>,
23    pub shared: Option<Vec<ConfigFsShared>>,
24    pub critical: Option<Vec<ConfigFsCritical>>,
25    /// A set of shared spells used across different projects.
26    pub shared_spells: HashSet<String>,
27    pub lock: Option<bool>,
28
29    pub custom_animations: HashMap<String, String>,
30}
31
32/// Shared configuration for GrimoireCSS projects.
33#[derive(Debug, Clone)]
34pub struct ConfigFsShared {
35    pub output_path: String,
36    pub styles: Option<Vec<String>>,
37    pub css_custom_properties: Option<Vec<ConfigFsCssCustomProperties>>,
38}
39
40/// Critical styles configuration to be inlined into specific HTML files.
41#[derive(Debug, Clone)]
42pub struct ConfigFsCritical {
43    pub file_to_inline_paths: Vec<String>,
44    pub styles: Option<Vec<String>>,
45    pub css_custom_properties: Option<Vec<ConfigFsCssCustomProperties>>,
46}
47
48/// Represents custom CSS properties associated with specific elements.
49#[derive(Debug, Clone)]
50pub struct ConfigFsCssCustomProperties {
51    pub element: String,
52    pub data_param: String,
53    pub data_value: String,
54    pub css_variables: Vec<(String, String)>,
55}
56
57/// Represents a project in GrimoireCSS.
58#[derive(Debug, Clone)]
59pub struct ConfigFsProject {
60    pub project_name: String,
61    pub input_paths: Vec<String>,
62    pub output_dir_path: Option<String>,
63    pub single_output_file_name: Option<String>,
64}
65
66// ---
67
68/// The main struct used to represent the JSON structure of the GrimoireCSS configuration.
69///
70/// This struct is used internally to serialize and deserialize the configuration data.
71#[derive(Serialize, Deserialize, Debug, Clone)]
72struct ConfigFsJSON {
73    #[serde(rename = "$schema")]
74    pub schema: Option<String>,
75    pub version: Option<String>,
76    /// Optional framework-level variables used during compilation.
77    pub variables: Option<HashMap<String, String>>,
78    /// Optional shared configuration settings used across multiple projects.
79    pub scrolls: Option<Vec<ConfigFsScrollJSON>>,
80    /// A list of projects included in the configuration.
81    pub projects: Vec<ConfigFsProjectJSON>,
82    pub shared: Option<Vec<ConfigFsSharedJSON>>,
83    pub critical: Option<Vec<ConfigFsCriticalJSON>>,
84    pub lock: Option<bool>,
85}
86
87/// Represents a scrolls which may contain external or combined CSS rules.
88#[derive(Serialize, Deserialize, Debug, Clone)]
89pub struct ConfigFsScrollJSON {
90    pub name: String,
91    pub spells: Vec<String>,
92    #[serde(rename = "spellsByArgs")]
93    pub spells_by_args: Option<HashMap<String, Vec<String>>>,
94    pub extends: Option<Vec<String>>,
95}
96
97/// A struct representing a project within GrimoireCSS.
98#[derive(Serialize, Deserialize, Debug, Clone)]
99#[serde(rename_all = "camelCase")]
100struct ConfigFsProjectJSON {
101    /// The name of the project.
102    pub project_name: String,
103    /// A list of input paths for the project.
104    pub input_paths: Vec<String>,
105    /// Optional output directory path for the project.
106    pub output_dir_path: Option<String>,
107    /// Optional file name for a single output file.
108    pub single_output_file_name: Option<String>,
109}
110
111/// Represents shared configuration settings used across multiple projects.
112#[derive(Serialize, Deserialize, Debug, Clone)]
113#[serde(rename_all = "camelCase")]
114struct ConfigFsSharedJSON {
115    pub output_path: String,
116    pub styles: Option<Vec<String>>,
117    pub css_custom_properties: Option<Vec<ConfigFsCSSCustomPropertiesJSON>>,
118}
119
120/// Represents critical styles configuration for inlining into HTML files.
121#[derive(Serialize, Deserialize, Debug, Clone)]
122#[serde(rename_all = "camelCase")]
123struct ConfigFsCriticalJSON {
124    pub file_to_inline_paths: Vec<String>,
125    pub styles: Option<Vec<String>>,
126    pub css_custom_properties: Option<Vec<ConfigFsCSSCustomPropertiesJSON>>,
127}
128
129/// Represents a custom CSS property item, including associated variables.
130#[derive(Serialize, Deserialize, Debug, Clone)]
131#[serde(rename_all = "camelCase")]
132struct ConfigFsCSSCustomPropertiesJSON {
133    /// The optional DOM element (`tag`, `class`, `id`, `:root` (default)) associated with the CSS variables.
134    pub element: Option<String>,
135    /// A parameter name used within the CSS configuration.
136    pub data_param: String,
137    /// A value corresponding to the data parameter.
138    pub data_value: String,
139    /// A set of associated CSS variables and their values.
140    pub css_variables: HashMap<String, String>,
141}
142
143impl Default for ConfigFs {
144    /// Provides a default configuration for `Config`, initializing the `scrolls`, `projects`, and other fields.
145    fn default() -> Self {
146        let projects = vec![ConfigFsProject {
147            project_name: "main".to_string(),
148            input_paths: Vec::new(),
149            output_dir_path: None,
150            single_output_file_name: None,
151        }];
152
153        Self {
154            grimoire_css_version: Some(env!("CARGO_PKG_VERSION").to_string()),
155            scrolls: None,
156            shared: None,
157            critical: None,
158            projects,
159            variables: None,
160            shared_spells: HashSet::new(),
161            custom_animations: HashMap::new(),
162            lock: None,
163        }
164    }
165}
166
167impl ConfigFs {
168    /// Loads the configuration from the file system.
169    ///
170    /// Reads a JSON configuration file from the file system and deserializes it into a `Config` object.
171    /// Also searches for and loads any external scroll files (grimoire.*.scrolls.json)
172    /// and any external variables files (grimoire.*.variables.json).
173    ///
174    /// # Errors
175    ///
176    /// Returns a `GrimoireCSSError` if reading or parsing the file fails.
177    pub fn load(current_dir: &Path) -> Result<Self, GrimoireCssError> {
178        let config_path = Filesystem::get_config_path(current_dir)?;
179        let content = fs::read_to_string(&config_path)?;
180        let json_config: ConfigFsJSON = serde_json::from_str(&content)?;
181        let mut config = Self::from_json(json_config);
182
183        // Load custom animations
184        config.custom_animations = Self::find_custom_animations(current_dir)?;
185
186        // Load external scroll files
187        config.scrolls = Self::load_external_scrolls(current_dir, config.scrolls)?;
188
189        // Load external variable files
190        config.variables = Self::load_external_variables(current_dir, config.variables)?;
191
192        Ok(config)
193    }
194
195    /// Saves the current configuration to the file system.
196    ///
197    /// Serializes the current configuration into JSON format and writes it to the file system.
198    ///
199    /// # Errors
200    ///
201    /// Returns a `GrimoireCSSError` if writing to the file system fails.
202    pub fn save(&self, current_dir: &Path) -> Result<(), GrimoireCssError> {
203        let config_path = Filesystem::get_config_path(current_dir)?;
204        let json_config = self.to_json();
205        let content = serde_json::to_string_pretty(&json_config)?;
206        fs::write(&config_path, content)?;
207
208        Ok(())
209    }
210
211    /// Extracts common spells from the configuration and adds them to a `HashSet`.
212    ///
213    /// # Arguments
214    ///
215    /// * `config` - A reference to the `ConfigJSON` structure that holds the spells data.
216    ///
217    /// # Returns
218    ///
219    /// A `HashSet` of common spell names used across projects.
220    fn get_common_spells_set(config: &ConfigFsJSON) -> HashSet<String> {
221        let mut common_spells = HashSet::new();
222
223        if let Some(shared) = &config.shared {
224            for shared_item in shared {
225                if let Some(styles) = &shared_item.styles {
226                    common_spells.extend(styles.iter().cloned());
227                }
228            }
229        }
230
231        if let Some(critical) = &config.critical {
232            for critical_item in critical {
233                if let Some(styles) = &critical_item.styles {
234                    common_spells.extend(styles.iter().cloned());
235                }
236            }
237        }
238
239        common_spells
240    }
241
242    /// Converts a JSON representation of the configuration into a `Config` instance.
243    ///
244    /// # Arguments
245    ///
246    /// * `json_config` - A `ConfigJSON` object representing the deserialized configuration data.
247    ///
248    /// # Returns
249    ///
250    /// A new `Config` instance.
251    fn from_json(json_config: ConfigFsJSON) -> Self {
252        let shared_spells = Self::get_common_spells_set(&json_config);
253
254        let variables = json_config.variables.map(|vars| {
255            let mut sorted_vars: Vec<_> = vars.into_iter().collect();
256            sorted_vars.sort_by(|a, b| a.0.cmp(&b.0));
257            sorted_vars
258        });
259
260        let projects = Self::projects_from_json(json_config.projects);
261
262        // Expand glob patterns in shared and critical configurations
263        let shared = Self::shared_from_json(json_config.shared);
264        let critical = Self::critical_from_json(json_config.critical);
265        let scrolls = Self::scrolls_from_json(json_config.scrolls);
266
267        ConfigFs {
268            grimoire_css_version: json_config.version,
269            variables,
270            scrolls,
271            projects,
272            shared,
273            critical,
274            shared_spells,
275            custom_animations: HashMap::new(),
276            lock: json_config.lock,
277        }
278    }
279
280    /// Converts shared JSON configuration into internal structure.
281    fn shared_from_json(shared: Option<Vec<ConfigFsSharedJSON>>) -> Option<Vec<ConfigFsShared>> {
282        shared.map(|shared_vec| {
283            shared_vec
284                .into_iter()
285                .map(|c| ConfigFsShared {
286                    output_path: c.output_path,
287                    styles: c.styles,
288                    css_custom_properties: Self::convert_css_custom_properties_from_json(
289                        c.css_custom_properties,
290                    ),
291                })
292                .collect()
293        })
294    }
295
296    /// Converts critical JSON configuration into internal structure.
297    fn critical_from_json(
298        critical: Option<Vec<ConfigFsCriticalJSON>>,
299    ) -> Option<Vec<ConfigFsCritical>> {
300        critical.map(|critical_vec| {
301            critical_vec
302                .into_iter()
303                .map(|c| ConfigFsCritical {
304                    file_to_inline_paths: Self::expand_glob_patterns(c.file_to_inline_paths),
305                    styles: c.styles,
306                    css_custom_properties: Self::convert_css_custom_properties_from_json(
307                        c.css_custom_properties,
308                    ),
309                })
310                .collect()
311        })
312    }
313
314    fn scrolls_from_json(
315        scrolls: Option<Vec<ConfigFsScrollJSON>>,
316    ) -> Option<HashMap<String, ScrollDefinition>> {
317        scrolls.map(|scrolls_vec| {
318            let mut scrolls_map = HashMap::new();
319
320            for scroll in &scrolls_vec {
321                let mut base_spells = Vec::new();
322                let mut spells_by_args: HashMap<String, Vec<String>> = HashMap::new();
323
324                // Recursively resolve parent spells (base + overloads)
325                Self::resolve_spells(scroll, &scrolls_vec, &mut base_spells);
326                Self::resolve_spells_by_args(scroll, &scrolls_vec, &mut spells_by_args);
327
328                // Add the spells of the current scroll
329                base_spells.extend_from_slice(&scroll.spells);
330
331                // Add overloads of the current scroll (child after parent)
332                if let Some(own) = &scroll.spells_by_args {
333                    for (k, v) in own {
334                        spells_by_args
335                            .entry(k.clone())
336                            .or_default()
337                            .extend(v.iter().cloned());
338                    }
339                }
340
341                let spells_by_args = if spells_by_args.is_empty() {
342                    None
343                } else {
344                    Some(spells_by_args)
345                };
346
347                scrolls_map.insert(
348                    scroll.name.clone(),
349                    ScrollDefinition {
350                        spells: base_spells,
351                        spells_by_args,
352                    },
353                );
354            }
355
356            scrolls_map
357        })
358    }
359
360    /// Recursively resolve spells for a given scroll, including extended scrolls
361    fn resolve_spells(
362        scroll: &ConfigFsScrollJSON,
363        scrolls_vec: &[ConfigFsScrollJSON],
364        collected_spells: &mut Vec<String>,
365    ) {
366        if let Some(extends) = &scroll.extends {
367            for ext_name in extends {
368                // Find the parent scroll
369                if let Some(parent_scroll) = scrolls_vec.iter().find(|s| &s.name == ext_name) {
370                    // Recursively resolve parent spells if it also extends other scrolls
371                    Self::resolve_spells(parent_scroll, scrolls_vec, collected_spells);
372
373                    // Add the spells of the parent scroll
374                    collected_spells.extend_from_slice(&parent_scroll.spells);
375                }
376            }
377        }
378    }
379
380    /// Recursively resolve overload spells (`spellsByArgs`) for a given scroll, including extended scrolls.
381    fn resolve_spells_by_args(
382        scroll: &ConfigFsScrollJSON,
383        scrolls_vec: &[ConfigFsScrollJSON],
384        collected: &mut HashMap<String, Vec<String>>,
385    ) {
386        if let Some(extends) = &scroll.extends {
387            for ext_name in extends {
388                if let Some(parent_scroll) = scrolls_vec.iter().find(|s| &s.name == ext_name) {
389                    Self::resolve_spells_by_args(parent_scroll, scrolls_vec, collected);
390
391                    if let Some(parent_map) = &parent_scroll.spells_by_args {
392                        for (k, v) in parent_map {
393                            collected
394                                .entry(k.clone())
395                                .or_default()
396                                .extend(v.iter().cloned());
397                        }
398                    }
399                }
400            }
401        }
402    }
403
404    /// Converts custom CSS properties from JSON to internal structure.
405    fn convert_css_custom_properties_from_json(
406        css_custom_properties_vec: Option<Vec<ConfigFsCSSCustomPropertiesJSON>>,
407    ) -> Option<Vec<ConfigFsCssCustomProperties>> {
408        css_custom_properties_vec.map(|items: Vec<ConfigFsCSSCustomPropertiesJSON>| {
409            items
410                .into_iter()
411                .map(|item| ConfigFsCssCustomProperties {
412                    element: item.element.unwrap_or_else(|| String::from(":root")),
413                    data_param: item.data_param,
414                    data_value: item.data_value,
415                    css_variables: {
416                        let mut vars: Vec<_> = item.css_variables.into_iter().collect();
417                        vars.sort_by(|a, b| a.0.cmp(&b.0));
418                        vars
419                    },
420                })
421                .collect()
422        })
423    }
424
425    /// Converts a list of project JSON configurations to the internal `Project` type.
426    fn projects_from_json(projects: Vec<ConfigFsProjectJSON>) -> Vec<ConfigFsProject> {
427        projects
428            .into_iter()
429            .map(|p| {
430                let input_paths = Self::expand_glob_patterns(p.input_paths);
431                ConfigFsProject {
432                    project_name: p.project_name,
433                    input_paths,
434                    output_dir_path: p.output_dir_path,
435                    single_output_file_name: p.single_output_file_name,
436                }
437            })
438            .collect()
439    }
440
441    /// Converts the internal `Config` into its JSON representation.
442    fn to_json(&self) -> ConfigFsJSON {
443        let variables_hash_map = self.variables.as_ref().map(|vars| {
444            let mut sorted_vars: Vec<_> = vars.iter().collect();
445            sorted_vars.sort_by(|a, b| a.0.cmp(&b.0));
446            sorted_vars
447                .into_iter()
448                .map(|(key, value)| (key.clone(), value.clone()))
449                .collect()
450        });
451
452        ConfigFsJSON {
453            schema: Some("https://raw.githubusercontent.com/persevie/grimoire-css/main/src/core/config/config-schema.json".to_string()),
454            version: self.grimoire_css_version.clone(),
455            variables: variables_hash_map,
456            scrolls: Self::scrolls_to_json(self.scrolls.clone()),
457            projects: Self::projects_to_json(self.projects.clone()),
458            shared: Self::shared_to_json(self.shared.as_ref()),
459            critical: Self::critical_to_json(self.critical.as_ref()),
460            lock: self.lock,
461        }
462    }
463
464    /// Updates only the `version` field in the on-disk config JSON.
465    ///
466    /// This avoids rewriting the full config via [`ConfigFs::save`], which could inline
467    /// externally-loaded scroll/variable files.
468    pub fn update_config_version_only(
469        current_dir: &Path,
470        grimoire_css_version: &str,
471    ) -> Result<(), GrimoireCssError> {
472        let config_path = Filesystem::get_config_path(current_dir)?;
473        let content = fs::read_to_string(&config_path)?;
474        let mut json_value: serde_json::Value = serde_json::from_str(&content)?;
475
476        if let serde_json::Value::Object(map) = &mut json_value {
477            map.insert(
478                "version".to_string(),
479                serde_json::Value::String(grimoire_css_version.to_string()),
480            );
481
482            // Ensure schema exists in new/legacy configs.
483            map.entry("$schema".to_string()).or_insert_with(|| {
484                serde_json::Value::String(
485                    "https://raw.githubusercontent.com/persevie/grimoire-css/main/src/core/config/config-schema.json".to_string(),
486                )
487            });
488        }
489
490        let updated = serde_json::to_string_pretty(&json_value)?;
491        fs::write(&config_path, updated)?;
492        Ok(())
493    }
494
495    /// Converts the internal list of shared configurations into JSON.
496    fn shared_to_json(shared: Option<&Vec<ConfigFsShared>>) -> Option<Vec<ConfigFsSharedJSON>> {
497        shared.map(|common_vec: &Vec<ConfigFsShared>| {
498            common_vec
499                .iter()
500                .map(|c| ConfigFsSharedJSON {
501                    output_path: c.output_path.clone(),
502                    styles: c.styles.clone(),
503                    css_custom_properties: Self::css_custom_properties_to_json(
504                        c.css_custom_properties.as_ref(),
505                    ),
506                })
507                .collect()
508        })
509    }
510
511    /// Converts the internal list of critical configurations into JSON.
512    fn critical_to_json(
513        critical: Option<&Vec<ConfigFsCritical>>,
514    ) -> Option<Vec<ConfigFsCriticalJSON>> {
515        critical.map(|common_vec| {
516            common_vec
517                .iter()
518                .map(|c| ConfigFsCriticalJSON {
519                    file_to_inline_paths: c.file_to_inline_paths.clone(),
520                    styles: c.styles.clone(),
521                    css_custom_properties: Self::css_custom_properties_to_json(
522                        c.css_custom_properties.as_ref(),
523                    ),
524                })
525                .collect()
526        })
527    }
528
529    /// Converts custom CSS properties to JSON format.
530    fn css_custom_properties_to_json(
531        css_custom_properties_vec: Option<&Vec<ConfigFsCssCustomProperties>>,
532    ) -> Option<Vec<ConfigFsCSSCustomPropertiesJSON>> {
533        css_custom_properties_vec.map(|items: &Vec<ConfigFsCssCustomProperties>| {
534            items
535                .iter()
536                .map(|item| ConfigFsCSSCustomPropertiesJSON {
537                    element: Some(item.element.clone()),
538                    data_param: item.data_param.clone(),
539                    data_value: item.data_value.clone(),
540                    css_variables: item.css_variables.clone().into_iter().collect(),
541                })
542                .collect()
543        })
544    }
545
546    fn scrolls_to_json(
547        config_scrolls: Option<HashMap<String, ScrollDefinition>>,
548    ) -> Option<Vec<ConfigFsScrollJSON>> {
549        config_scrolls.map(|scrolls| {
550            let mut scrolls_vec = Vec::new();
551            for (name, def) in scrolls {
552                scrolls_vec.push(ConfigFsScrollJSON {
553                    name,
554                    spells: def.spells,
555                    spells_by_args: def.spells_by_args,
556                    extends: None,
557                });
558            }
559            scrolls_vec
560        })
561    }
562
563    /// Converts the internal list of `Project` into its JSON representation.
564    fn projects_to_json(projects: Vec<ConfigFsProject>) -> Vec<ConfigFsProjectJSON> {
565        projects
566            .into_iter()
567            .map(|p| ConfigFsProjectJSON {
568                project_name: p.project_name,
569                input_paths: p.input_paths,
570                output_dir_path: p.output_dir_path,
571                single_output_file_name: p.single_output_file_name,
572            })
573            .collect()
574    }
575
576    /// Searches for and loads custom animation files from the "animations" subdirectory.
577    ///
578    /// This function scans the "animations" subdirectory within the given `current_dir/grimoire`,
579    /// reads the content of each file, and stores it in a `HashMap`. The key of the
580    /// HashMap is the file name (without extension), and the value is the file content.
581    ///
582    /// # Arguments
583    ///
584    /// * `current_dir` - A reference to a `Path` representing the directory to search in.
585    ///
586    /// # Returns
587    ///
588    /// Returns a `Result` containing:
589    /// - `Ok(HashMap<String, String>)`: A HashMap where keys are file names (without extension)
590    ///   and values are the contents of the animation files.
591    /// - `Err(GrimoireCSSError)`: An error if there's an issue reading the directory or files.
592    ///
593    /// # Errors
594    ///
595    /// This function will return an error if:
596    /// - The "animations" subdirectory cannot be read.
597    /// - There's an issue reading any of the files in the subdirectory.
598    /// - File names cannot be converted to valid UTF-8 strings.
599    fn find_custom_animations(
600        current_dir: &Path,
601    ) -> Result<HashMap<String, String>, GrimoireCssError> {
602        let animations_dir =
603            Filesystem::get_or_create_grimoire_path(current_dir)?.join("animations");
604
605        if !animations_dir.exists() {
606            return Ok(HashMap::new());
607        }
608
609        let mut entries = animations_dir.read_dir()?.peekable();
610
611        if entries.peek().is_none() {
612            add_message("No custom animations were found in the 'animations' directory. Deleted unnecessary 'animations' directory".to_string());
613            fs::remove_dir(&animations_dir)?;
614            return Ok(HashMap::new());
615        }
616
617        let mut map = HashMap::new();
618
619        for entry in entries {
620            let entry = entry?;
621            let path = entry.path();
622
623            if path.is_file() {
624                if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
625                    if ext == "css" {
626                        if let Some(file_stem) = path.file_stem().and_then(|s| s.to_str()) {
627                            let content = fs::read_to_string(&path)?;
628                            map.insert(file_stem.to_owned(), content);
629                        }
630                    } else {
631                        add_message(format!(
632                            "Only CSS files are supported in the 'animations' directory. Skipping non-CSS file: {}.",
633                            path.display()
634                        ));
635                    }
636                }
637            } else {
638                add_message(format!(
639                    "Only files are supported in the 'animations' directory. Skipping directory: {}.",
640                    path.display()
641                ));
642            }
643        }
644
645        Ok(map)
646    }
647
648    fn expand_glob_patterns(patterns: Vec<String>) -> Vec<String> {
649        let mut paths = Vec::new();
650        for pattern in patterns {
651            match glob(&pattern) {
652                Ok(glob_paths) => {
653                    for path_result in glob_paths.flatten() {
654                        if let Some(path_str) = path_result.to_str() {
655                            paths.push(path_str.to_string());
656                        }
657                    }
658                }
659                Err(e) => {
660                    add_message(format!("Failed to read glob pattern {pattern}: {e}"));
661                }
662            }
663        }
664        paths
665    }
666
667    /// Loads external scrolls from files matching the pattern "grimoire.*.scrolls.json" in the config directory.
668    /// If the main config already has scrolls, they will be merged with the external ones.
669    /// Scrolls from the main configuration have higher priority and are not overwritten.
670    ///
671    /// # Arguments
672    ///
673    /// * `current_dir` - A reference to the current working directory
674    /// * `existing_scrolls` - Optional HashMap of existing scrolls from main config
675    ///
676    /// # Returns
677    ///
678    /// * `Option<HashMap<String, Vec<String>>>` - Merged scrolls from main config and external files
679    ///
680    /// # Errors
681    ///
682    /// Returns a `GrimoireCSSError` if reading or parsing any external scroll file fails.
683    fn load_external_scrolls(
684        current_dir: &Path,
685        existing_scrolls: Option<HashMap<String, ScrollDefinition>>,
686    ) -> Result<Option<HashMap<String, ScrollDefinition>>, GrimoireCssError> {
687        // Get the config directory path
688        let config_dir = Filesystem::get_or_create_grimoire_path(current_dir)?.join("config");
689
690        // Initialize with existing scrolls or create new HashMap
691        let mut all_scrolls = existing_scrolls.unwrap_or_default();
692        let mut existing_scroll_names: HashSet<String> = all_scrolls.keys().cloned().collect();
693        let mut external_files_found = false;
694
695        // Use glob pattern to directly find matching files instead of reading entire directory
696        let pattern = config_dir
697            .join("grimoire.*.scrolls.json")
698            .to_string_lossy()
699            .to_string();
700
701        match glob::glob(&pattern) {
702            Ok(entries) => {
703                for entry in entries.flatten() {
704                    if let Some(file_name) = entry.file_name().and_then(|s| s.to_str()) {
705                        // Read and parse the external scroll file
706                        match fs::read_to_string(&entry) {
707                            Ok(content) => {
708                                match serde_json::from_str::<serde_json::Value>(&content) {
709                                    Ok(json) => {
710                                        // Extract and process scrolls from the JSON
711                                        if let Some(scrolls) =
712                                            json.get("scrolls").and_then(|s| s.as_array())
713                                        {
714                                            external_files_found = true;
715
716                                            // Parse each scroll from the array
717                                            for scroll in scrolls {
718                                                if let (Some(name), Some(spells_arr)) = (
719                                                    scroll.get("name").and_then(|n| n.as_str()),
720                                                    scroll.get("spells").and_then(|s| s.as_array()),
721                                                ) {
722                                                    // Don't override existing scrolls from main config, just add new ones
723                                                    if !existing_scroll_names.contains(name) {
724                                                        // Convert the spell array to Vec<String>
725                                                        let spells: Vec<String> = spells_arr
726                                                            .iter()
727                                                            .filter_map(|s| {
728                                                                s.as_str().map(|s| s.to_string())
729                                                            })
730                                                            .collect();
731
732                                                        // Optional spellsByArgs
733                                                        let spells_by_args = scroll
734                                                            .get("spellsByArgs")
735                                                            .and_then(|s| s.as_object())
736                                                            .and_then(|obj| {
737                                                                let mut map: HashMap<String, Vec<String>> =
738                                                                    HashMap::new();
739                                                                for (k, v) in obj {
740                                                                    if let Some(arr) = v.as_array() {
741                                                                        let spells: Vec<String> = arr
742                                                                            .iter()
743                                                                            .filter_map(|s| {
744                                                                                s.as_str().map(|s| s.to_string())
745                                                                            })
746                                                                            .collect();
747                                                                        map.insert(k.clone(), spells);
748                                                                    }
749                                                                }
750                                                                if map.is_empty() { None } else { Some(map) }
751                                                            });
752
753                                                        // Insert new scroll
754                                                        all_scrolls.insert(
755                                                            name.to_string(),
756                                                            ScrollDefinition {
757                                                                spells,
758                                                                spells_by_args,
759                                                            },
760                                                        );
761                                                        existing_scroll_names
762                                                            .insert(name.to_string());
763                                                    }
764                                                    // Existing scrolls from main config have higher priority
765                                                }
766                                            }
767
768                                            add_message(format!(
769                                                "Loaded external scrolls from '{file_name}'"
770                                            ));
771                                        }
772                                    }
773                                    Err(err) => {
774                                        add_message(format!(
775                                            "Failed to parse external scroll file '{file_name}': {err}"
776                                        ));
777                                    }
778                                }
779                            }
780                            Err(err) => {
781                                add_message(format!(
782                                    "Failed to read external scroll file '{file_name}': {err}"
783                                ));
784                            }
785                        }
786                    }
787                }
788            }
789            Err(err) => {
790                add_message(format!("Failed to search for external scroll files: {err}"));
791            }
792        }
793
794        // Only return Some if we have scrolls, otherwise None
795        if all_scrolls.is_empty() {
796            Ok(None)
797        } else {
798            // Add a message if we loaded external scrolls
799            if external_files_found {
800                add_message("External scroll files were merged with configuration".to_string());
801            }
802            Ok(Some(all_scrolls))
803        }
804    }
805
806    /// Loads external variables from files matching the pattern "grimoire.*.variables.json" in the config directory.
807    /// If the main config already has variables, they will be merged with the external ones.
808    ///
809    /// # Arguments
810    ///
811    /// * `current_dir` - A reference to the current working directory
812    /// * `existing_variables` - Optional Vector of existing variables from main config
813    ///
814    /// # Returns
815    ///
816    /// * `Option<Vec<(String, String)>>` - Merged variables from main config and external files
817    ///
818    /// # Errors
819    ///
820    /// Returns a `GrimoireCSSError` if reading or parsing any external variables file fails.
821    fn load_external_variables(
822        current_dir: &Path,
823        existing_variables: Option<Vec<(String, String)>>,
824    ) -> Result<Option<Vec<(String, String)>>, GrimoireCssError> {
825        // Get the config directory path
826        let config_dir = Filesystem::get_or_create_grimoire_path(current_dir)?.join("config");
827
828        // Initialize with existing variables or create new Vec
829        let mut all_variables = existing_variables.unwrap_or_default();
830        let mut existing_keys: HashSet<String> =
831            all_variables.iter().map(|(key, _)| key.clone()).collect();
832        let mut external_files_found = false;
833
834        // Use glob pattern to directly find matching files
835        let pattern = config_dir
836            .join("grimoire.*.variables.json")
837            .to_string_lossy()
838            .to_string();
839
840        match glob::glob(&pattern) {
841            Ok(entries) => {
842                for entry in entries.flatten() {
843                    if let Some(file_name) = entry.file_name().and_then(|s| s.to_str()) {
844                        // Read and parse the external variables file
845                        match fs::read_to_string(&entry) {
846                            Ok(content) => {
847                                match serde_json::from_str::<serde_json::Value>(&content) {
848                                    Ok(json) => {
849                                        // Extract and process variables from the JSON
850                                        if let Some(variables) =
851                                            json.get("variables").and_then(|v| v.as_object())
852                                        {
853                                            external_files_found = true;
854
855                                            // Parse each variable from the object
856                                            for (key, value) in variables {
857                                                if let Some(value_str) = value.as_str() {
858                                                    // If the key doesn't exist yet, add it
859                                                    if !existing_keys.contains(key) {
860                                                        all_variables.push((
861                                                            key.clone(),
862                                                            value_str.to_string(),
863                                                        ));
864                                                        existing_keys.insert(key.clone());
865                                                    }
866                                                    // If the key exists, we don't override it - first come, first served
867                                                }
868                                            }
869
870                                            add_message(format!(
871                                                "Loaded external variables from '{file_name}'"
872                                            ));
873                                        }
874                                    }
875                                    Err(err) => {
876                                        add_message(format!(
877                                            "Failed to parse external variables file '{file_name}': {err}"
878                                        ));
879                                    }
880                                }
881                            }
882                            Err(err) => {
883                                add_message(format!(
884                                    "Failed to read external variables file '{file_name}': {err}"
885                                ));
886                            }
887                        }
888                    }
889                }
890            }
891            Err(err) => {
892                add_message(format!(
893                    "Failed to search for external variables files: {err}"
894                ));
895            }
896        }
897
898        // Sort variables by key for consistency
899        if !all_variables.is_empty() {
900            all_variables.sort_by(|a, b| a.0.cmp(&b.0));
901
902            // Add a message if we loaded external variables
903            if external_files_found {
904                add_message("External variable files were merged with configuration".to_string());
905            }
906            Ok(Some(all_variables))
907        } else {
908            Ok(None)
909        }
910    }
911}
912
913#[cfg(test)]
914mod tests {
915    use super::*;
916    use std::fs::File;
917    use std::io::Write;
918    use tempfile::tempdir;
919
920    #[test]
921    fn test_default_config() {
922        let config = ConfigFs::default();
923        assert!(config.variables.is_none());
924        assert!(config.scrolls.is_none());
925        assert!(config.shared.is_none());
926        assert!(config.critical.is_none());
927        assert_eq!(config.projects.len(), 1);
928        assert_eq!(config.projects[0].project_name, "main");
929    }
930
931    #[test]
932    fn test_load_nonexistent_config() {
933        let dir = tempdir().unwrap();
934        let result = ConfigFs::load(dir.path());
935        assert!(result.is_err());
936    }
937
938    #[test]
939    fn test_save_and_load_config() {
940        let dir = tempdir().unwrap();
941        let config = ConfigFs::default();
942        config.save(dir.path()).expect("Failed to save config");
943
944        let loaded_config = ConfigFs::load(dir.path()).expect("Failed to load config");
945        assert_eq!(
946            config.projects[0].project_name,
947            loaded_config.projects[0].project_name
948        );
949    }
950
951    #[test]
952    fn test_expand_glob_patterns() {
953        let dir = tempdir().unwrap();
954        let file_path = dir.path().join("test.txt");
955        File::create(&file_path).unwrap();
956
957        let patterns = vec![format!("{}/**/*.txt", dir.path().to_str().unwrap())];
958        let expanded = ConfigFs::expand_glob_patterns(patterns);
959        assert_eq!(expanded.len(), 1);
960        assert!(expanded[0].ends_with("test.txt"));
961    }
962
963    #[test]
964    fn test_find_custom_animations_empty() {
965        let dir = tempdir().unwrap();
966        let animations = ConfigFs::find_custom_animations(dir.path()).unwrap();
967        assert!(animations.is_empty());
968    }
969
970    #[test]
971    fn test_find_custom_animations_with_files() {
972        let dir = tempdir().unwrap();
973        let animations_dir = dir.path().join("grimoire").join("animations");
974        fs::create_dir_all(&animations_dir).unwrap();
975
976        let animation_file = animations_dir.join("fade_in.css");
977        let mut file = File::create(&animation_file).unwrap();
978        writeln!(
979            file,
980            "@keyframes fade_in {{ from {{ opacity: 0; }} to {{ opacity: 1; }} }}"
981        )
982        .unwrap();
983
984        let animations = ConfigFs::find_custom_animations(dir.path()).unwrap();
985        assert_eq!(animations.len(), 1);
986        assert!(animations.contains_key("fade_in"));
987    }
988
989    #[test]
990    fn test_get_common_spells_set() {
991        let json = ConfigFsJSON {
992            schema: None,
993            version: None,
994            variables: None,
995            scrolls: None,
996            projects: vec![],
997            shared: Some(vec![ConfigFsSharedJSON {
998                output_path: "styles.css".to_string(),
999                styles: Some(vec!["spell1".to_string(), "spell2".to_string()]),
1000                css_custom_properties: None,
1001            }]),
1002            critical: Some(vec![ConfigFsCriticalJSON {
1003                file_to_inline_paths: vec!["index.html".to_string()],
1004                styles: Some(vec!["spell3".to_string()]),
1005                css_custom_properties: None,
1006            }]),
1007            lock: None,
1008        };
1009
1010        let common_spells = ConfigFs::get_common_spells_set(&json);
1011        assert_eq!(common_spells.len(), 3);
1012        assert!(common_spells.contains("spell1"));
1013        assert!(common_spells.contains("spell2"));
1014        assert!(common_spells.contains("spell3"));
1015    }
1016
1017    #[test]
1018    fn test_load_external_scrolls_no_files() {
1019        let dir = tempdir().unwrap();
1020        let config_dir = dir.path().join("grimoire").join("config");
1021        fs::create_dir_all(&config_dir).unwrap();
1022
1023        // Create a basic config file to prevent load() from failing
1024        let config_file = config_dir.join("grimoire.config.json");
1025        let config_content = r#"{
1026            "projects": [
1027                {
1028                    "projectName": "main",
1029                    "inputPaths": []
1030                }
1031            ]
1032        }"#;
1033        fs::write(&config_file, config_content).unwrap();
1034
1035        // No external scroll files
1036        let result = ConfigFs::load_external_scrolls(dir.path(), None).unwrap();
1037        assert!(result.is_none());
1038    }
1039
1040    #[test]
1041    fn test_load_external_scrolls_single_file() {
1042        let dir = tempdir().unwrap();
1043        let config_dir = dir.path().join("grimoire").join("config");
1044        fs::create_dir_all(&config_dir).unwrap();
1045
1046        // Create a basic config file to prevent load() from failing
1047        let config_file = config_dir.join("grimoire.config.json");
1048        let config_content = r#"{
1049            "projects": [
1050                {
1051                    "projectName": "main",
1052                    "inputPaths": []
1053                }
1054            ]
1055        }"#;
1056        fs::write(&config_file, config_content).unwrap();
1057
1058        // Create an external scrolls file
1059        let scrolls_file = config_dir.join("grimoire.tailwindcss.scrolls.json");
1060        let scrolls_content = r#"{
1061            "scrolls": [
1062                {
1063                    "name": "tw-btn",
1064                    "spells": [
1065                        "p=4px",
1066                        "bg=blue",
1067                        "c=white",
1068                        "br=4px"
1069                    ]
1070                }
1071            ]
1072        }"#;
1073        fs::write(&scrolls_file, scrolls_content).unwrap();
1074
1075        // Load external scrolls
1076        let result = ConfigFs::load_external_scrolls(dir.path(), None).unwrap();
1077        assert!(result.is_some());
1078
1079        let scrolls = result.unwrap();
1080        assert_eq!(scrolls.len(), 1);
1081        assert!(scrolls.contains_key("tw-btn"));
1082        assert_eq!(scrolls.get("tw-btn").unwrap().spells.len(), 4);
1083    }
1084
1085    #[test]
1086    fn test_load_external_scrolls_multiple_files() {
1087        let dir = tempdir().unwrap();
1088        let config_dir = dir.path().join("grimoire").join("config");
1089        fs::create_dir_all(&config_dir).unwrap();
1090
1091        // Create a basic config file
1092        let config_file = config_dir.join("grimoire.config.json");
1093        let config_content = r#"{
1094            "projects": [
1095                {
1096                    "projectName": "main",
1097                    "inputPaths": []
1098                }
1099            ]
1100        }"#;
1101        fs::write(&config_file, config_content).unwrap();
1102
1103        // Create first external scrolls file
1104        let scrolls_file1 = config_dir.join("grimoire.tailwindcss.scrolls.json");
1105        let scrolls_content1 = r#"{
1106            "scrolls": [
1107                {
1108                    "name": "tw-btn",
1109                    "spells": [
1110                        "p=4px",
1111                        "bg=blue",
1112                        "c=white",
1113                        "br=4px"
1114                    ]
1115                }
1116            ]
1117        }"#;
1118        fs::write(&scrolls_file1, scrolls_content1).unwrap();
1119
1120        // Create second external scrolls file
1121        let scrolls_file2 = config_dir.join("grimoire.bootstrap.scrolls.json");
1122        let scrolls_content2 = r#"{
1123            "scrolls": [
1124                {
1125                    "name": "bs-card",
1126                    "spells": [
1127                        "border=1px_solid_#ccc",
1128                        "br=8px",
1129                        "shadow=0_2px_8px_rgba(0,0,0,0.1)"
1130                    ]
1131                }
1132            ]
1133        }"#;
1134        fs::write(&scrolls_file2, scrolls_content2).unwrap();
1135
1136        // Load external scrolls
1137        let result = ConfigFs::load_external_scrolls(dir.path(), None).unwrap();
1138        assert!(result.is_some());
1139
1140        let scrolls = result.unwrap();
1141        assert_eq!(scrolls.len(), 2);
1142        assert!(scrolls.contains_key("tw-btn"));
1143        assert!(scrolls.contains_key("bs-card"));
1144        assert_eq!(scrolls.get("tw-btn").unwrap().spells.len(), 4);
1145        assert_eq!(scrolls.get("bs-card").unwrap().spells.len(), 3);
1146    }
1147
1148    #[test]
1149    fn test_merge_with_existing_scrolls() {
1150        let dir = tempdir().unwrap();
1151        let config_dir = dir.path().join("grimoire").join("config");
1152        fs::create_dir_all(&config_dir).unwrap();
1153
1154        // Create a basic config file
1155        let config_file = config_dir.join("grimoire.config.json");
1156        let config_content = r#"{
1157            "scrolls": [
1158                {
1159                    "name": "main-btn",
1160                    "spells": [
1161                        "p=10px",
1162                        "fw=bold",
1163                        "c=black"
1164                    ]
1165                }
1166            ],
1167            "projects": [
1168                {
1169                    "projectName": "main",
1170                    "inputPaths": []
1171                }
1172            ]
1173        }"#;
1174        fs::write(&config_file, config_content).unwrap();
1175
1176        // Create an external scrolls file
1177        let scrolls_file = config_dir.join("grimoire.extra.scrolls.json");
1178        let scrolls_content = r#"{
1179            "scrolls": [
1180                {
1181                    "name": "main-btn",
1182                    "spells": [
1183                        "bg=green",
1184                        "hover:bg=darkgreen"
1185                    ]
1186                },
1187                {
1188                    "name": "extra-btn",
1189                    "spells": [
1190                        "fs=16px",
1191                        "m=10px"
1192                    ]
1193                }
1194            ]
1195        }"#;
1196        fs::write(&scrolls_file, scrolls_content).unwrap();
1197
1198        // Create mock existing scrolls
1199        let mut existing_scrolls = HashMap::new();
1200        existing_scrolls.insert(
1201            "main-btn".to_string(),
1202            ScrollDefinition {
1203                spells: vec![
1204                    "p=10px".to_string(),
1205                    "fw=bold".to_string(),
1206                    "c=black".to_string(),
1207                ],
1208                spells_by_args: None,
1209            },
1210        );
1211
1212        // Load and merge external scrolls
1213        let result = ConfigFs::load_external_scrolls(dir.path(), Some(existing_scrolls)).unwrap();
1214        assert!(result.is_some());
1215
1216        let scrolls = result.unwrap();
1217        assert_eq!(scrolls.len(), 2);
1218
1219        // Check that main-btn has combined spells from both sources
1220        assert!(scrolls.contains_key("main-btn"));
1221        assert_eq!(scrolls.get("main-btn").unwrap().spells.len(), 3);
1222
1223        // Check that extra-btn was added
1224        assert!(scrolls.contains_key("extra-btn"));
1225        assert_eq!(scrolls.get("extra-btn").unwrap().spells.len(), 2);
1226    }
1227
1228    #[test]
1229    fn test_full_config_with_external_scrolls() {
1230        let dir = tempdir().unwrap();
1231        let config_dir = dir.path().join("grimoire").join("config");
1232        fs::create_dir_all(&config_dir).unwrap();
1233
1234        // Create a basic config file
1235        let config_file = config_dir.join("grimoire.config.json");
1236        let config_content = r#"{
1237            "scrolls": [
1238                {
1239                    "name": "base-btn",
1240                    "spells": [
1241                        "p=10px",
1242                        "br=4px"
1243                    ]
1244                }
1245            ],
1246            "projects": [
1247                {
1248                    "projectName": "main",
1249                    "inputPaths": []
1250                }
1251            ]
1252        }"#;
1253        fs::write(&config_file, config_content).unwrap();
1254
1255        // Create an external scrolls file
1256        let scrolls_file = config_dir.join("grimoire.theme.scrolls.json");
1257        let scrolls_content = r#"{
1258            "scrolls": [
1259                {
1260                    "name": "theme-btn",
1261                    "spells": [
1262                        "bg=purple",
1263                        "c=white"
1264                    ]
1265                }
1266            ]
1267        }"#;
1268        fs::write(&scrolls_file, scrolls_content).unwrap();
1269
1270        // Load the full configuration
1271        let config = ConfigFs::load(dir.path()).expect("Failed to load config");
1272
1273        // Check that both scrolls are loaded
1274        assert!(config.scrolls.is_some());
1275        let scrolls = config.scrolls.unwrap();
1276        assert_eq!(scrolls.len(), 2);
1277        assert!(scrolls.contains_key("base-btn"));
1278        assert!(scrolls.contains_key("theme-btn"));
1279    }
1280
1281    #[test]
1282    fn test_load_external_variables_no_files() {
1283        let dir = tempdir().unwrap();
1284        let config_dir = dir.path().join("grimoire").join("config");
1285        fs::create_dir_all(&config_dir).unwrap();
1286
1287        // Create a basic config file to prevent load() from failing
1288        let config_file = config_dir.join("grimoire.config.json");
1289        let config_content = r#"{
1290            "projects": [
1291                {
1292                    "projectName": "main",
1293                    "inputPaths": []
1294                }
1295            ]
1296        }"#;
1297        fs::write(&config_file, config_content).unwrap();
1298
1299        // No external variable files
1300        let result = ConfigFs::load_external_variables(dir.path(), None).unwrap();
1301        assert!(result.is_none());
1302    }
1303
1304    #[test]
1305    fn test_load_external_variables_single_file() {
1306        let dir = tempdir().unwrap();
1307        let config_dir = dir.path().join("grimoire").join("config");
1308        fs::create_dir_all(&config_dir).unwrap();
1309
1310        // Create a basic config file to prevent load() from failing
1311        let config_file = config_dir.join("grimoire.config.json");
1312        let config_content = r#"{
1313            "projects": [
1314                {
1315                    "projectName": "main",
1316                    "inputPaths": []
1317                }
1318            ]
1319        }"#;
1320        fs::write(&config_file, config_content).unwrap();
1321
1322        // Create an external variables file
1323        let vars_file = config_dir.join("grimoire.theme.variables.json");
1324        let vars_content = r##"{
1325            "variables": {
1326                "primary-color": "#3366ff",
1327                "secondary-color": "#ff6633",
1328                "font-size-base": "16px"
1329            }
1330        }"##;
1331        fs::write(&vars_file, vars_content).unwrap();
1332
1333        // Load external variables
1334        let result = ConfigFs::load_external_variables(dir.path(), None).unwrap();
1335        assert!(result.is_some());
1336
1337        let variables = result.unwrap();
1338        assert_eq!(variables.len(), 3);
1339
1340        // Check that variables are sorted by key
1341        assert_eq!(variables[0].0, "font-size-base");
1342        assert_eq!(variables[0].1, "16px");
1343        assert_eq!(variables[1].0, "primary-color");
1344        assert_eq!(variables[1].1, "#3366ff");
1345        assert_eq!(variables[2].0, "secondary-color");
1346        assert_eq!(variables[2].1, "#ff6633");
1347    }
1348
1349    #[test]
1350    fn test_load_external_variables_multiple_files() {
1351        let dir = tempdir().unwrap();
1352        let config_dir = dir.path().join("grimoire").join("config");
1353        fs::create_dir_all(&config_dir).unwrap();
1354
1355        // Create a basic config file
1356        let config_file = config_dir.join("grimoire.config.json");
1357        let config_content = r#"{
1358            "projects": [
1359                {
1360                    "projectName": "main",
1361                    "inputPaths": []
1362                }
1363            ]
1364        }"#;
1365        fs::write(&config_file, config_content).unwrap();
1366
1367        // Create first external variables file
1368        let vars_file1 = config_dir.join("grimoire.colors.variables.json");
1369        let vars_content1 = r##"{
1370            "variables": {
1371                "primary-color": "#3366ff",
1372                "secondary-color": "#ff6633"
1373            }
1374        }"##;
1375        fs::write(&vars_file1, vars_content1).unwrap();
1376
1377        // Create second external variables file
1378        let vars_file2 = config_dir.join("grimoire.typography.variables.json");
1379        let vars_content2 = r##"{
1380            "variables": {
1381                "font-size-base": "16px",
1382                "font-family-sans": "Arial, sans-serif"
1383            }
1384        }"##;
1385        fs::write(&vars_file2, vars_content2).unwrap();
1386
1387        // Load external variables
1388        let result = ConfigFs::load_external_variables(dir.path(), None).unwrap();
1389        assert!(result.is_some());
1390
1391        let variables = result.unwrap();
1392        assert_eq!(variables.len(), 4);
1393
1394        // Create a map for easier testing
1395        let var_map: HashMap<String, String> = variables.into_iter().collect();
1396        assert!(var_map.contains_key("primary-color"));
1397        assert!(var_map.contains_key("secondary-color"));
1398        assert!(var_map.contains_key("font-size-base"));
1399        assert!(var_map.contains_key("font-family-sans"));
1400
1401        assert_eq!(var_map.get("primary-color").unwrap(), "#3366ff");
1402        assert_eq!(
1403            var_map.get("font-family-sans").unwrap(),
1404            "Arial, sans-serif"
1405        );
1406    }
1407
1408    #[test]
1409    fn test_merge_with_existing_variables() {
1410        let dir = tempdir().unwrap();
1411        let config_dir = dir.path().join("grimoire").join("config");
1412        fs::create_dir_all(&config_dir).unwrap();
1413
1414        // Create a basic config file with variables
1415        let config_file = config_dir.join("grimoire.config.json");
1416        let config_content = r##"{
1417            "variables": {
1418                "primary-color": "#3366ff",
1419                "font-size-base": "16px"
1420            },
1421            "projects": [
1422                {
1423                    "projectName": "main",
1424                    "inputPaths": []
1425                }
1426            ]
1427        }"##;
1428        fs::write(&config_file, config_content).unwrap();
1429
1430        // Create an external variables file
1431        let vars_file = config_dir.join("grimoire.extra.variables.json");
1432        let vars_content = r##"{
1433            "variables": {
1434                "secondary-color": "#ff6633",
1435                "primary-color": "#ff0000",
1436                "spacing-unit": "8px"
1437            }
1438        }"##;
1439        fs::write(&vars_file, vars_content).unwrap();
1440
1441        // Create mock existing variables
1442        let existing_variables = vec![
1443            ("primary-color".to_string(), "#3366ff".to_string()),
1444            ("font-size-base".to_string(), "16px".to_string()),
1445        ];
1446
1447        // Load and merge external variables
1448        let result =
1449            ConfigFs::load_external_variables(dir.path(), Some(existing_variables)).unwrap();
1450        assert!(result.is_some());
1451
1452        let variables = result.unwrap();
1453        assert_eq!(variables.len(), 4); // primary-color, font-size-base, secondary-color, spacing-unit
1454
1455        // Create a map for easier testing
1456        let var_map: HashMap<String, String> = variables.into_iter().collect();
1457
1458        // Primary color should remain from the original config (not overwritten)
1459        assert_eq!(var_map.get("primary-color").unwrap(), "#3366ff");
1460
1461        // New variables should be added
1462        assert_eq!(var_map.get("secondary-color").unwrap(), "#ff6633");
1463        assert_eq!(var_map.get("spacing-unit").unwrap(), "8px");
1464
1465        // Original variables should be preserved
1466        assert_eq!(var_map.get("font-size-base").unwrap(), "16px");
1467    }
1468
1469    #[test]
1470    fn test_full_config_with_external_variables() {
1471        let dir = tempdir().unwrap();
1472        let config_dir = dir.path().join("grimoire").join("config");
1473        fs::create_dir_all(&config_dir).unwrap();
1474
1475        // Create a basic config file with variables
1476        let config_file = config_dir.join("grimoire.config.json");
1477        let config_content = r##"{
1478            "variables": {
1479                "primary-color": "#3366ff"
1480            },
1481            "projects": [
1482                {
1483                    "projectName": "main",
1484                    "inputPaths": []
1485                }
1486            ]
1487        }"##;
1488        fs::write(&config_file, config_content).unwrap();
1489
1490        // Create an external variables file
1491        let vars_file = config_dir.join("grimoire.theme.variables.json");
1492        let vars_content = r##"{
1493            "variables": {
1494                "secondary-color": "#ff6633",
1495                "spacing-unit": "8px"
1496            }
1497        }"##;
1498        fs::write(&vars_file, vars_content).unwrap();
1499
1500        // Load the full configuration
1501        let config = ConfigFs::load(dir.path()).expect("Failed to load config");
1502
1503        // Check that variables from both sources are loaded
1504        assert!(config.variables.is_some());
1505        let variables = config.variables.unwrap();
1506        assert_eq!(variables.len(), 3);
1507
1508        // Variables should be sorted by key
1509        assert_eq!(variables[0].0, "primary-color");
1510        assert_eq!(variables[1].0, "secondary-color");
1511        assert_eq!(variables[2].0, "spacing-unit");
1512    }
1513}