1use crate::{
4 buffer::add_message,
5 core::{Filesystem, GrimoireCssError},
6};
7use glob::glob;
8use serde::{Deserialize, Serialize};
9use std::{
10 collections::{HashMap, HashSet},
11 fs,
12 path::Path,
13};
14
15#[derive(Debug, Clone)]
17pub struct ConfigFs {
18 pub variables: Option<Vec<(String, String)>>,
19 pub scrolls: Option<HashMap<String, Vec<String>>>,
20 pub projects: Vec<ConfigFsProject>,
21 pub shared: Option<Vec<ConfigFsShared>>,
22 pub critical: Option<Vec<ConfigFsCritical>>,
23 pub shared_spells: HashSet<String>,
25 pub lock: Option<bool>,
26
27 pub custom_animations: HashMap<String, String>,
28}
29
30#[derive(Debug, Clone)]
32pub struct ConfigFsShared {
33 pub output_path: String,
34 pub styles: Option<Vec<String>>,
35 pub css_custom_properties: Option<Vec<ConfigFsCssCustomProperties>>,
36}
37
38#[derive(Debug, Clone)]
40pub struct ConfigFsCritical {
41 pub file_to_inline_paths: Vec<String>,
42 pub styles: Option<Vec<String>>,
43 pub css_custom_properties: Option<Vec<ConfigFsCssCustomProperties>>,
44}
45
46#[derive(Debug, Clone)]
48pub struct ConfigFsCssCustomProperties {
49 pub element: String,
50 pub data_param: String,
51 pub data_value: String,
52 pub css_variables: Vec<(String, String)>,
53}
54
55#[derive(Debug, Clone)]
57pub struct ConfigFsProject {
58 pub project_name: String,
59 pub input_paths: Vec<String>,
60 pub output_dir_path: Option<String>,
61 pub single_output_file_name: Option<String>,
62}
63
64#[derive(Serialize, Deserialize, Debug, Clone)]
70struct ConfigFsJSON {
71 #[serde(rename = "$schema")]
72 pub schema: Option<String>,
73 pub variables: Option<HashMap<String, String>>,
75 pub scrolls: Option<Vec<ConfigFsScrollJSON>>,
77 pub projects: Vec<ConfigFsProjectJSON>,
79 pub shared: Option<Vec<ConfigFsSharedJSON>>,
80 pub critical: Option<Vec<ConfigFsCriticalJSON>>,
81 pub lock: Option<bool>,
82}
83
84#[derive(Serialize, Deserialize, Debug, Clone)]
86pub struct ConfigFsScrollJSON {
87 pub name: String,
88 pub spells: Vec<String>,
89 pub extends: Option<Vec<String>>,
90}
91
92#[derive(Serialize, Deserialize, Debug, Clone)]
94#[serde(rename_all = "camelCase")]
95struct ConfigFsProjectJSON {
96 pub project_name: String,
98 pub input_paths: Vec<String>,
100 pub output_dir_path: Option<String>,
102 pub single_output_file_name: Option<String>,
104}
105
106#[derive(Serialize, Deserialize, Debug, Clone)]
108#[serde(rename_all = "camelCase")]
109struct ConfigFsSharedJSON {
110 pub output_path: String,
111 pub styles: Option<Vec<String>>,
112 pub css_custom_properties: Option<Vec<ConfigFsCSSCustomPropertiesJSON>>,
113}
114
115#[derive(Serialize, Deserialize, Debug, Clone)]
117#[serde(rename_all = "camelCase")]
118struct ConfigFsCriticalJSON {
119 pub file_to_inline_paths: Vec<String>,
120 pub styles: Option<Vec<String>>,
121 pub css_custom_properties: Option<Vec<ConfigFsCSSCustomPropertiesJSON>>,
122}
123
124#[derive(Serialize, Deserialize, Debug, Clone)]
126#[serde(rename_all = "camelCase")]
127struct ConfigFsCSSCustomPropertiesJSON {
128 pub element: Option<String>,
130 pub data_param: String,
132 pub data_value: String,
134 pub css_variables: HashMap<String, String>,
136}
137
138impl Default for ConfigFs {
139 fn default() -> Self {
141 let projects = vec![ConfigFsProject {
142 project_name: "main".to_string(),
143 input_paths: Vec::new(),
144 output_dir_path: None,
145 single_output_file_name: None,
146 }];
147
148 Self {
149 scrolls: None,
150 shared: None,
151 critical: None,
152 projects,
153 variables: None,
154 shared_spells: HashSet::new(),
155 custom_animations: HashMap::new(),
156 lock: None,
157 }
158 }
159}
160
161impl ConfigFs {
162 pub fn load(current_dir: &Path) -> Result<Self, GrimoireCssError> {
170 let config_path = Filesystem::get_config_path(current_dir)?;
171 let content = fs::read_to_string(&config_path)?;
172 let json_config: ConfigFsJSON = serde_json::from_str(&content)?;
173 let mut config = Self::from_json(json_config);
174
175 config.custom_animations = Self::find_custom_animations(current_dir)?;
176
177 Ok(config)
178 }
179
180 pub fn save(&self, current_dir: &Path) -> Result<(), GrimoireCssError> {
188 let config_path = Filesystem::get_config_path(current_dir)?;
189 let json_config = self.to_json();
190 let content = serde_json::to_string_pretty(&json_config)?;
191 fs::write(&config_path, content)?;
192
193 Ok(())
194 }
195
196 fn get_common_spells_set(config: &ConfigFsJSON) -> HashSet<String> {
206 let mut common_spells = HashSet::new();
207
208 if let Some(shared) = &config.shared {
209 for shared_item in shared {
210 if let Some(styles) = &shared_item.styles {
211 common_spells.extend(styles.iter().cloned());
212 }
213 }
214 }
215
216 if let Some(critical) = &config.critical {
217 for critical_item in critical {
218 if let Some(styles) = &critical_item.styles {
219 common_spells.extend(styles.iter().cloned());
220 }
221 }
222 }
223
224 common_spells
225 }
226
227 fn from_json(json_config: ConfigFsJSON) -> Self {
237 let shared_spells = Self::get_common_spells_set(&json_config);
238
239 let variables = json_config.variables.map(|vars| {
240 let mut sorted_vars: Vec<_> = vars.into_iter().collect();
241 sorted_vars.sort_by(|a, b| a.0.cmp(&b.0));
242 sorted_vars
243 });
244
245 let projects = Self::projects_from_json(json_config.projects);
246
247 let shared = Self::shared_from_json(json_config.shared);
249 let critical = Self::critical_from_json(json_config.critical);
250 let scrolls = Self::scrolls_from_json(json_config.scrolls);
251
252 ConfigFs {
253 variables,
254 scrolls,
255 projects,
256 shared,
257 critical,
258 shared_spells,
259 custom_animations: HashMap::new(),
260 lock: json_config.lock,
261 }
262 }
263
264 fn shared_from_json(shared: Option<Vec<ConfigFsSharedJSON>>) -> Option<Vec<ConfigFsShared>> {
266 shared.map(|shared_vec| {
267 shared_vec
268 .into_iter()
269 .map(|c| ConfigFsShared {
270 output_path: c.output_path,
271 styles: c.styles,
272 css_custom_properties: Self::convert_css_custom_properties_from_json(
273 c.css_custom_properties,
274 ),
275 })
276 .collect()
277 })
278 }
279
280 fn critical_from_json(
282 critical: Option<Vec<ConfigFsCriticalJSON>>,
283 ) -> Option<Vec<ConfigFsCritical>> {
284 critical.map(|critical_vec| {
285 critical_vec
286 .into_iter()
287 .map(|c| ConfigFsCritical {
288 file_to_inline_paths: Self::expand_glob_patterns(c.file_to_inline_paths),
289 styles: c.styles,
290 css_custom_properties: Self::convert_css_custom_properties_from_json(
291 c.css_custom_properties,
292 ),
293 })
294 .collect()
295 })
296 }
297
298 fn scrolls_from_json(
299 scrolls: Option<Vec<ConfigFsScrollJSON>>,
300 ) -> Option<HashMap<String, Vec<String>>> {
301 scrolls.map(|scrolls_vec| {
302 let mut scrolls_map = HashMap::new();
303
304 for scroll in &scrolls_vec {
305 let mut scroll_spells = Vec::new();
306
307 Self::resolve_spells(scroll, &scrolls_vec, &mut scroll_spells);
309
310 scroll_spells.extend_from_slice(&scroll.spells);
312
313 scrolls_map.insert(scroll.name.clone(), scroll_spells);
315 }
316
317 scrolls_map
318 })
319 }
320
321 fn resolve_spells(
323 scroll: &ConfigFsScrollJSON,
324 scrolls_vec: &[ConfigFsScrollJSON],
325 collected_spells: &mut Vec<String>,
326 ) {
327 if let Some(extends) = &scroll.extends {
328 for ext_name in extends {
329 if let Some(parent_scroll) = scrolls_vec.iter().find(|s| &s.name == ext_name) {
331 Self::resolve_spells(parent_scroll, scrolls_vec, collected_spells);
333
334 collected_spells.extend_from_slice(&parent_scroll.spells);
336 }
337 }
338 }
339 }
340
341 fn convert_css_custom_properties_from_json(
343 css_custom_properties_vec: Option<Vec<ConfigFsCSSCustomPropertiesJSON>>,
344 ) -> Option<Vec<ConfigFsCssCustomProperties>> {
345 css_custom_properties_vec.map(|items: Vec<ConfigFsCSSCustomPropertiesJSON>| {
346 items
347 .into_iter()
348 .map(|item| ConfigFsCssCustomProperties {
349 element: item.element.unwrap_or_else(|| String::from(":root")),
350 data_param: item.data_param,
351 data_value: item.data_value,
352 css_variables: {
353 let mut vars: Vec<_> = item.css_variables.into_iter().collect();
354 vars.sort_by(|a, b| a.0.cmp(&b.0));
355 vars
356 },
357 })
358 .collect()
359 })
360 }
361
362 fn projects_from_json(projects: Vec<ConfigFsProjectJSON>) -> Vec<ConfigFsProject> {
364 projects
365 .into_iter()
366 .map(|p| {
367 let input_paths = Self::expand_glob_patterns(p.input_paths);
368 ConfigFsProject {
369 project_name: p.project_name,
370 input_paths,
371 output_dir_path: p.output_dir_path,
372 single_output_file_name: p.single_output_file_name,
373 }
374 })
375 .collect()
376 }
377
378 fn to_json(&self) -> ConfigFsJSON {
380 let variables_hash_map = self.variables.as_ref().map(|vars| {
381 let mut sorted_vars: Vec<_> = vars.iter().collect();
382 sorted_vars.sort_by(|a, b| a.0.cmp(&b.0));
383 sorted_vars
384 .into_iter()
385 .map(|(key, value)| (key.clone(), value.clone()))
386 .collect()
387 });
388
389 ConfigFsJSON {
390 schema: Some("https://raw.githubusercontent.com/persevie/grimoire-css/main/src/core/config/config-schema.json".to_string()),
391 variables: variables_hash_map,
392 scrolls: Self::scrolls_to_json(self.scrolls.clone()),
393 projects: Self::projects_to_json(self.projects.clone()),
394 shared: Self::shared_to_json(self.shared.as_ref()),
395 critical: Self::critical_to_json(self.critical.as_ref()),
396 lock: self.lock,
397 }
398 }
399
400 fn shared_to_json(shared: Option<&Vec<ConfigFsShared>>) -> Option<Vec<ConfigFsSharedJSON>> {
402 shared.map(|common_vec: &Vec<ConfigFsShared>| {
403 common_vec
404 .iter()
405 .map(|c| ConfigFsSharedJSON {
406 output_path: c.output_path.clone(),
407 styles: c.styles.clone(),
408 css_custom_properties: Self::css_custom_properties_to_json(
409 c.css_custom_properties.as_ref(),
410 ),
411 })
412 .collect()
413 })
414 }
415
416 fn critical_to_json(
418 critical: Option<&Vec<ConfigFsCritical>>,
419 ) -> Option<Vec<ConfigFsCriticalJSON>> {
420 critical.map(|common_vec| {
421 common_vec
422 .iter()
423 .map(|c| ConfigFsCriticalJSON {
424 file_to_inline_paths: c.file_to_inline_paths.clone(),
425 styles: c.styles.clone(),
426 css_custom_properties: Self::css_custom_properties_to_json(
427 c.css_custom_properties.as_ref(),
428 ),
429 })
430 .collect()
431 })
432 }
433
434 fn css_custom_properties_to_json(
436 css_custom_properties_vec: Option<&Vec<ConfigFsCssCustomProperties>>,
437 ) -> Option<Vec<ConfigFsCSSCustomPropertiesJSON>> {
438 css_custom_properties_vec.map(|items: &Vec<ConfigFsCssCustomProperties>| {
439 items
440 .iter()
441 .map(|item| ConfigFsCSSCustomPropertiesJSON {
442 element: Some(item.element.clone()),
443 data_param: item.data_param.clone(),
444 data_value: item.data_value.clone(),
445 css_variables: item.css_variables.clone().into_iter().collect(),
446 })
447 .collect()
448 })
449 }
450
451 fn scrolls_to_json(
452 config_scrolls: Option<HashMap<String, Vec<String>>>,
453 ) -> Option<Vec<ConfigFsScrollJSON>> {
454 config_scrolls.map(|scrolls| {
455 let mut scrolls_vec = Vec::new();
456 for (name, spells) in scrolls {
457 scrolls_vec.push(ConfigFsScrollJSON {
458 name,
459 spells,
460 extends: None,
461 });
462 }
463 scrolls_vec
464 })
465 }
466
467 fn projects_to_json(projects: Vec<ConfigFsProject>) -> Vec<ConfigFsProjectJSON> {
469 projects
470 .into_iter()
471 .map(|p| ConfigFsProjectJSON {
472 project_name: p.project_name,
473 input_paths: p.input_paths,
474 output_dir_path: p.output_dir_path,
475 single_output_file_name: p.single_output_file_name,
476 })
477 .collect()
478 }
479
480 fn find_custom_animations(
504 current_dir: &Path,
505 ) -> Result<HashMap<String, String>, GrimoireCssError> {
506 let animations_dir =
507 Filesystem::get_or_create_grimoire_path(current_dir)?.join("animations");
508
509 if !animations_dir.exists() {
510 return Ok(HashMap::new());
511 }
512
513 let mut entries = animations_dir.read_dir()?.peekable();
514
515 if entries.peek().is_none() {
516 add_message("No custom animations were found in the 'animations' directory. Deleted unnecessary 'animations' directory".to_string());
517 fs::remove_dir(&animations_dir)?;
518 return Ok(HashMap::new());
519 }
520
521 let mut map = HashMap::new();
522
523 for entry in entries {
524 let entry = entry?;
525 let path = entry.path();
526
527 if path.is_file() {
528 if let Some(ext) = path.extension().and_then(|s| s.to_str()) {
529 if ext == "css" {
530 if let Some(file_stem) = path.file_stem().and_then(|s| s.to_str()) {
531 let content = fs::read_to_string(&path)?;
532 map.insert(file_stem.to_owned(), content);
533 }
534 } else {
535 add_message(format!(
536 "Only CSS files are supported in the 'animations' directory. Skipping non-CSS file: {}.",
537 path.display()
538 ));
539 }
540 }
541 } else {
542 add_message(format!(
543 "Only files are supported in the 'animations' directory. Skipping directory: {}.",
544 path.display()
545 ));
546 }
547 }
548
549 Ok(map)
550 }
551
552 fn expand_glob_patterns(patterns: Vec<String>) -> Vec<String> {
553 let mut paths = Vec::new();
554 for pattern in patterns {
555 match glob(&pattern) {
556 Ok(glob_paths) => {
557 for path_result in glob_paths.flatten() {
558 if let Some(path_str) = path_result.to_str() {
559 paths.push(path_str.to_string());
560 }
561 }
562 }
563 Err(e) => {
564 add_message(format!("Failed to read glob pattern {}: {}", pattern, e));
565 }
566 }
567 }
568 paths
569 }
570}
571
572#[cfg(test)]
573mod tests {
574 use super::*;
575 use std::fs::File;
576 use std::io::Write;
577 use tempfile::tempdir;
578
579 #[test]
580 fn test_default_config() {
581 let config = ConfigFs::default();
582 assert!(config.variables.is_none());
583 assert!(config.scrolls.is_none());
584 assert!(config.shared.is_none());
585 assert!(config.critical.is_none());
586 assert_eq!(config.projects.len(), 1);
587 assert_eq!(config.projects[0].project_name, "main");
588 }
589
590 #[test]
591 fn test_load_nonexistent_config() {
592 let dir = tempdir().unwrap();
593 let result = ConfigFs::load(dir.path());
594 assert!(result.is_err());
595 }
596
597 #[test]
598 fn test_save_and_load_config() {
599 let dir = tempdir().unwrap();
600 let config = ConfigFs::default();
601 config.save(dir.path()).expect("Failed to save config");
602
603 let loaded_config = ConfigFs::load(dir.path()).expect("Failed to load config");
604 assert_eq!(
605 config.projects[0].project_name,
606 loaded_config.projects[0].project_name
607 );
608 }
609
610 #[test]
611 fn test_expand_glob_patterns() {
612 let dir = tempdir().unwrap();
613 let file_path = dir.path().join("test.txt");
614 File::create(&file_path).unwrap();
615
616 let patterns = vec![format!("{}/**/*.txt", dir.path().to_str().unwrap())];
617 let expanded = ConfigFs::expand_glob_patterns(patterns);
618 assert_eq!(expanded.len(), 1);
619 assert!(expanded[0].ends_with("test.txt"));
620 }
621
622 #[test]
623 fn test_find_custom_animations_empty() {
624 let dir = tempdir().unwrap();
625 let animations = ConfigFs::find_custom_animations(dir.path()).unwrap();
626 assert!(animations.is_empty());
627 }
628
629 #[test]
630 fn test_find_custom_animations_with_files() {
631 let dir = tempdir().unwrap();
632 let animations_dir = dir.path().join("grimoire").join("animations");
633 fs::create_dir_all(&animations_dir).unwrap();
634
635 let animation_file = animations_dir.join("fade_in.css");
636 let mut file = File::create(&animation_file).unwrap();
637 writeln!(
638 file,
639 "@keyframes fade_in {{ from {{ opacity: 0; }} to {{ opacity: 1; }} }}"
640 )
641 .unwrap();
642
643 let animations = ConfigFs::find_custom_animations(dir.path()).unwrap();
644 assert_eq!(animations.len(), 1);
645 assert!(animations.contains_key("fade_in"));
646 }
647
648 #[test]
649 fn test_get_common_spells_set() {
650 let json = ConfigFsJSON {
651 schema: None,
652 variables: None,
653 scrolls: None,
654 projects: vec![],
655 shared: Some(vec![ConfigFsSharedJSON {
656 output_path: "styles.css".to_string(),
657 styles: Some(vec!["spell1".to_string(), "spell2".to_string()]),
658 css_custom_properties: None,
659 }]),
660 critical: Some(vec![ConfigFsCriticalJSON {
661 file_to_inline_paths: vec!["index.html".to_string()],
662 styles: Some(vec!["spell3".to_string()]),
663 css_custom_properties: None,
664 }]),
665 lock: None,
666 };
667
668 let common_spells = ConfigFs::get_common_spells_set(&json);
669 assert_eq!(common_spells.len(), 3);
670 assert!(common_spells.contains("spell1"));
671 assert!(common_spells.contains("spell2"));
672 assert!(common_spells.contains("spell3"));
673 }
674}