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