1use std::{
2 fmt,
3 fs::{File, read_dir, read_to_string},
4 io::{self, Write},
5 marker::PhantomData,
6 path::PathBuf,
7};
8
9use ordered_hash_map::OrderedHashMap;
10use serde::{
11 Deserialize, Deserializer, Serialize, Serializer,
12 de::{MapAccess, Visitor},
13};
14
15use crate::{
16 CustomCellAmbient, CustomLightData, DEFAULT_CONFIG_NAME, LightArgs, default, notification_box,
17 to_io_error,
18};
19
20pub fn deserialize_ordered_hash_map<'de, D, K, V>(
21 deserializer: D,
22) -> Result<OrderedHashMap<K, V>, D::Error>
23where
24 D: Deserializer<'de>,
25 K: Deserialize<'de> + Eq + std::hash::Hash,
26 V: Deserialize<'de>,
27{
28 struct OrderedHashMapVisitor<K, V> {
29 marker: PhantomData<fn() -> OrderedHashMap<K, V>>,
30 }
31
32 impl<K, V> OrderedHashMapVisitor<K, V> {
33 fn new() -> Self {
34 OrderedHashMapVisitor {
35 marker: PhantomData,
36 }
37 }
38 }
39
40 impl<'de, K, V> Visitor<'de> for OrderedHashMapVisitor<K, V>
41 where
42 K: Deserialize<'de> + Eq + std::hash::Hash,
43 V: Deserialize<'de>,
44 {
45 type Value = OrderedHashMap<K, V>;
46
47 fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result {
48 formatter.write_str("a map")
49 }
50
51 fn visit_map<M>(self, mut access: M) -> Result<Self::Value, M::Error>
52 where
53 M: MapAccess<'de>,
54 {
55 let mut map = OrderedHashMap::with_capacity(access.size_hint().unwrap_or(0));
56
57 while let Some((key, value)) = access.next_entry()? {
58 map.insert(key, value);
59 }
60
61 Ok(map)
62 }
63 }
64
65 deserializer.deserialize_map(OrderedHashMapVisitor::new())
66}
67
68pub fn serialize_ordered_hash_map<S, K, V>(
69 map: &OrderedHashMap<K, V>,
70 serializer: S,
71) -> Result<S::Ok, S::Error>
72where
73 S: Serializer,
74 K: Serialize,
75 V: Serialize,
76{
77 use serde::ser::SerializeMap;
78
79 let mut ser_map = serializer.serialize_map(Some(map.len()))?;
80 for (k, v) in map {
81 ser_map.serialize_entry(k, v)?;
82 }
83 ser_map.end()
84}
85
86#[derive(Debug, Deserialize, Serialize)]
87#[allow(clippy::struct_excessive_bools)]
90pub struct LightConfig {
91 #[serde(default)]
95 pub disable_interior_sun: bool,
96
97 #[serde(default = "default::disable_flicker")]
98 pub disable_flickering: bool,
99
100 #[serde(default = "default::disable_pulse")]
101 pub disable_pulse: bool,
102
103 #[serde(default = "default::disable_negative_lights")]
104 pub disable_negative_lights: bool,
105
106 #[serde(default = "default::auto_enable")]
107 pub auto_enable: bool,
108
109 #[serde(default)]
110 pub no_notifications: bool,
111
112 #[serde(default)]
113 pub debug: bool,
114
115 #[serde(default)]
116 pub dry_run: bool,
117
118 #[serde(default)]
119 pub validate_config: bool,
120
121 #[serde(default = "default::standard_hue")]
122 pub standard_hue: f32,
123
124 #[serde(default = "default::standard_saturation")]
125 pub standard_saturation: f32,
126
127 #[serde(default = "default::standard_value")]
128 pub standard_value: f32,
129
130 #[serde(default = "default::standard_radius")]
131 pub standard_radius: f32,
132
133 #[serde(default = "default::colored_hue")]
134 pub colored_hue: f32,
135
136 #[serde(default = "default::colored_saturation")]
137 pub colored_saturation: f32,
138
139 #[serde(default = "default::colored_value")]
140 pub colored_value: f32,
141
142 #[serde(default = "default::colored_radius")]
143 pub colored_radius: f32,
144
145 #[serde(default = "default::duration_mult")]
146 pub duration_mult: f32,
147
148 #[serde(default = "default::excluded_plugins")]
149 pub excluded_plugins: Vec<String>,
150
151 #[serde(default)]
152 pub excluded_ids: Vec<String>,
153
154 #[serde(
155 default,
156 serialize_with = "serialize_ordered_hash_map",
157 deserialize_with = "deserialize_ordered_hash_map"
158 )]
159 pub light_overrides: OrderedHashMap<String, CustomLightData>,
160
161 #[serde(
162 default,
163 serialize_with = "serialize_ordered_hash_map",
164 deserialize_with = "deserialize_ordered_hash_map"
165 )]
166 pub ambient_overrides: OrderedHashMap<String, CustomCellAmbient>,
167
168 pub output_dir: Option<PathBuf>,
169
170 #[serde(default)]
171 pub save_config: bool,
172
173 #[serde(skip)]
174 pub excluded_id_regexes: Vec<regex::Regex>,
175 #[serde(skip)]
176 pub excluded_plugin_regexes: Vec<regex::Regex>,
177 #[serde(skip)]
178 pub light_regexes: Vec<(regex::Regex, CustomLightData)>,
179 #[serde(skip)]
180 pub ambient_regexes: Vec<(regex::Regex, CustomCellAmbient)>,
181}
182
183impl LightConfig {
186 fn find(root_path: &PathBuf) -> Result<PathBuf, io::Error> {
187 read_dir(root_path)?
188 .filter_map(std::result::Result::ok)
189 .find(|entry| entry.file_name().eq_ignore_ascii_case(DEFAULT_CONFIG_NAME))
190 .map(|entry| entry.path())
191 .ok_or_else(|| io::Error::new(io::ErrorKind::NotFound, "Light config not found"))
192 }
193
194 fn load(
195 user_config_path: &std::path::Path,
196 early_no_notifications: bool,
197 ) -> io::Result<(Self, bool)> {
198 let Ok(config_path) = Self::find(&user_config_path.to_path_buf()) else {
199 return Ok((LightConfig::default(), true));
200 };
201
202 let config_contents = read_to_string(config_path)?;
203 let config = toml::from_str(&config_contents).unwrap_or_else(|error| {
204 notification_box(
205 "Failed to read light config!",
206 &format!("Lightconfig.toml couldn't be read: {error}"),
207 early_no_notifications,
208 );
209 std::process::exit(256);
210 });
211
212 Ok((config, false))
213 }
214
215 fn apply_scalar_args(&mut self, light_args: &mut LightArgs) {
216 Self::overwrite_if_some([
217 (&mut self.standard_hue, &mut light_args.standard_hue),
218 (
219 &mut self.standard_saturation,
220 &mut light_args.standard_saturation,
221 ),
222 (&mut self.standard_value, &mut light_args.standard_value),
223 (&mut self.standard_radius, &mut light_args.standard_radius),
224 (&mut self.colored_hue, &mut light_args.colored_hue),
225 (
226 &mut self.colored_saturation,
227 &mut light_args.colored_saturation,
228 ),
229 (&mut self.colored_value, &mut light_args.colored_value),
230 (&mut self.colored_radius, &mut light_args.colored_radius),
231 (&mut self.duration_mult, &mut light_args.duration_mult),
232 ]);
233 }
234
235 fn apply_bool_args(&mut self, light_args: &LightArgs) {
236 Self::overwrite_if_some([
237 (
238 &mut self.disable_pulse,
239 &mut light_args.disable_pulse.clone(),
240 ),
241 (
242 &mut self.disable_flickering,
243 &mut light_args.disable_flickering.clone(),
244 ),
245 (
246 &mut self.disable_negative_lights,
247 &mut light_args.disable_negative_lights.clone(),
248 ),
249 (
250 &mut self.auto_enable,
251 &mut light_args.auto_enable.then_some(true),
252 ),
253 (
254 &mut self.no_notifications,
255 &mut light_args.no_notifications.then_some(true),
256 ),
257 (&mut self.debug, &mut light_args.debug.then_some(true)),
258 ]);
259
260 if let Some(dry_run) = light_args.dry_run {
261 self.dry_run = dry_run;
262 if dry_run {
263 self.validate_config = false;
264 }
265 }
266
267 if let Some(validate_config) = light_args.validate_config {
268 self.validate_config = validate_config;
269 if validate_config {
270 self.dry_run = false;
271 }
272 }
273 }
274
275 fn effective_non_writing_modes(&self, light_args: &LightArgs) -> io::Result<(bool, bool)> {
276 let mut dry_run = self.dry_run;
277 let mut validate_config = self.validate_config;
278
279 if let Some(cli_dry_run) = light_args.dry_run {
280 dry_run = cli_dry_run;
281 if cli_dry_run {
282 validate_config = false;
283 }
284 }
285
286 if let Some(cli_validate_config) = light_args.validate_config {
287 validate_config = cli_validate_config;
288 if cli_validate_config {
289 dry_run = false;
290 }
291 }
292
293 if dry_run && validate_config {
294 return Err(io::Error::new(
295 io::ErrorKind::InvalidInput,
296 "dry_run and validate_config cannot both be true",
297 ));
298 }
299
300 Ok((dry_run, validate_config))
301 }
302
303 fn apply_collection_args(&mut self, light_args: &mut LightArgs) {
304 self.excluded_ids
305 .extend(std::mem::take(&mut light_args.excluded_ids));
306 self.excluded_plugins
307 .extend(std::mem::take(&mut light_args.excluded_plugins));
308 self.light_overrides
309 .extend(std::mem::take(&mut light_args.light_overrides));
310 self.ambient_overrides
311 .extend(std::mem::take(&mut light_args.ambient_overrides));
312 }
313
314 fn configure_output_dir(
315 &mut self,
316 output_dir: Option<PathBuf>,
317 openmw_config: &openmw_config::OpenMWConfiguration,
318 ) -> io::Result<()> {
319 if let Some(out_dir) = output_dir {
320 if out_dir.is_dir() {
321 self.output_dir = Some(out_dir);
322 return Ok(());
323 }
324
325 notification_box(
326 "Can't find output location!",
327 &format!(
328 "WARNING: The requested output path {} does not exist! Terminating.",
329 out_dir.display()
330 ),
331 self.no_notifications,
332 );
333 std::process::exit(1);
334 }
335
336 if self.output_dir.is_none() {
337 self.output_dir = Some(match openmw_config.data_local() {
338 Some(path) => path.parsed().to_owned(),
339 None => std::env::current_dir()?,
340 });
341 }
342
343 Ok(())
344 }
345
346 fn save_to_user_config(&self, user_config_path: &std::path::Path) -> io::Result<()> {
347 let config_serialized = toml::to_string_pretty(self).map_err(to_io_error)?;
348 let config_path = user_config_path.join(DEFAULT_CONFIG_NAME);
349 let mut config_file = File::create(config_path)?;
350 write!(config_file, "{config_serialized}")
351 }
352
353 fn save_to_user_config_without_runtime_flags(
354 &mut self,
355 user_config_path: &std::path::Path,
356 persisted_no_notifications: bool,
357 persisted_debug: bool,
358 ) -> io::Result<()> {
359 let runtime_no_notifications =
360 std::mem::replace(&mut self.no_notifications, persisted_no_notifications);
361 let runtime_debug = std::mem::replace(&mut self.debug, persisted_debug);
362 let result = self.save_to_user_config(user_config_path);
363 self.no_notifications = runtime_no_notifications;
364 self.debug = runtime_debug;
365
366 result
367 }
368
369 fn compile_regexes(&mut self) -> io::Result<()> {
370 let mut errors = Vec::new();
371
372 for id in std::mem::take(&mut self.excluded_ids) {
373 match regex::Regex::new(&id) {
374 Ok(pattern) => self.excluded_id_regexes.push(pattern),
375 Err(error) => {
376 let message = format!("Couldn't compile excluded id regex: {id}: {error}");
377 notification_box(
378 "Invalid excluded id regex!",
379 &message,
380 self.no_notifications,
381 );
382 errors.push(message);
383 }
384 }
385 }
386
387 for id in std::mem::take(&mut self.excluded_plugins) {
388 match regex::Regex::new(&id) {
389 Ok(pattern) => self.excluded_plugin_regexes.push(pattern),
390 Err(error) => {
391 let message = format!("Couldn't compile excluded plugin regex: {id}: {error}");
392 notification_box(
393 "Invalid excluded plugin regex!",
394 &message,
395 self.no_notifications,
396 );
397 errors.push(message);
398 }
399 }
400 }
401
402 for (id, light_data) in std::mem::take(&mut self.light_overrides) {
403 match regex::Regex::new(&id) {
404 Ok(pattern) => self.light_regexes.push((pattern, light_data)),
405 Err(error) => {
406 let message = format!("Couldn't compile light override regex: {id}: {error}");
407 notification_box("Invalid light override!", &message, self.no_notifications);
408 errors.push(message);
409 }
410 }
411 }
412
413 for (id, light_data) in std::mem::take(&mut self.ambient_overrides) {
414 match regex::Regex::new(&id) {
415 Ok(pattern) => self.ambient_regexes.push((pattern, light_data)),
416 Err(error) => {
417 let message = format!("Couldn't compile ambient override regex: {id}: {error}");
418 notification_box("Invalid ambient override!", &message, self.no_notifications);
419 errors.push(message);
420 }
421 }
422 }
423
424 if errors.is_empty() {
425 Ok(())
426 } else {
427 Err(io::Error::new(
428 io::ErrorKind::InvalidInput,
429 errors.join("\n"),
430 ))
431 }
432 }
433
434 fn overwrite_if_some<'a, I, T>(pairs: I)
435 where
436 I: IntoIterator<Item = (&'a mut T, &'a mut Option<T>)>,
438 T: Copy + Default + 'a,
440 {
441 for (field, maybe_val) in pairs {
442 if let Some(v) = maybe_val {
443 *field = std::mem::take(v); }
445 }
446 }
447
448 pub fn get(
458 mut light_args: LightArgs,
459 openmw_config: &openmw_config::OpenMWConfiguration,
460 ) -> Result<LightConfig, io::Error> {
461 let user_config_path = openmw_config.user_config_path();
462
463 let early_no_notifications =
464 std::env::var("S3L_NO_NOTIFICATIONS").is_ok() || light_args.no_notifications;
465 let (mut light_config, write_config) =
466 Self::load(&user_config_path, early_no_notifications)?;
467
468 let debug_from_env = std::env::var("S3L_DEBUG").is_ok();
469
470 let (effective_dry_run, effective_validate_config) =
471 light_config.effective_non_writing_modes(&light_args)?;
472 let allow_config_writes = !effective_dry_run && !effective_validate_config;
473
474 light_config.apply_scalar_args(&mut light_args);
475 light_config.apply_bool_args(&light_args);
476 let persisted_no_notifications = light_config.no_notifications;
477 let persisted_debug = light_config.debug;
478 light_config.no_notifications |= std::env::var("S3L_NO_NOTIFICATIONS").is_ok();
479 light_config.debug |= debug_from_env;
480
481 if !effective_validate_config {
482 light_config.configure_output_dir(light_args.output.take(), openmw_config)?;
483 }
484 light_config.apply_collection_args(&mut light_args);
485
486 if light_args.use_classic {
492 light_config.disable_interior_sun = true;
493 }
494
495 if allow_config_writes
496 && (write_config || light_config.save_config || light_args.update_light_config)
497 {
498 light_config.save_to_user_config_without_runtime_flags(
499 &user_config_path,
500 persisted_no_notifications,
501 persisted_debug,
502 )?;
503 }
504
505 light_config.compile_regexes()?;
507
508 Ok(light_config)
509 }
510
511 #[must_use]
512 pub fn is_excluded_plugin(&self, plugin_path: &std::path::Path) -> bool {
513 let file_name = match plugin_path.file_name() {
514 None => return false,
515 Some(name) => name.to_ascii_lowercase().into_string().unwrap_or_default(),
516 };
517
518 for pattern in &self.excluded_plugin_regexes {
519 if pattern.is_match(&file_name) {
520 return true;
521 }
522 }
523
524 false
525 }
526
527 #[must_use]
528 pub fn is_excluded_id(&self, record_id: &str) -> bool {
529 for pattern in &self.excluded_id_regexes {
530 if pattern.is_match(record_id) {
531 return true;
532 }
533 }
534
535 false
536 }
537}
538
539impl Default for LightConfig {
540 fn default() -> LightConfig {
541 LightConfig {
542 save_config: false,
543 debug: false,
544 dry_run: false,
545 validate_config: false,
546 no_notifications: false,
547 output_dir: None,
548 disable_interior_sun: false,
549 disable_flickering: default::disable_flicker(),
550 disable_pulse: default::disable_pulse(),
551 disable_negative_lights: default::disable_negative_lights(),
552 auto_enable: default::auto_enable(),
553 standard_hue: default::standard_hue(),
554 standard_saturation: default::standard_saturation(),
555 standard_value: default::standard_value(),
556 standard_radius: default::standard_radius(),
557 colored_hue: default::colored_hue(),
558 colored_saturation: default::colored_saturation(),
559 colored_value: default::colored_value(),
560 colored_radius: default::colored_radius(),
561 duration_mult: default::duration_mult(),
562 excluded_ids: Vec::new(),
563 excluded_plugins: default::excluded_plugins(),
564 excluded_id_regexes: Vec::new(),
565 excluded_plugin_regexes: Vec::new(),
566 light_regexes: Vec::new(),
567 light_overrides: OrderedHashMap::new(),
568 ambient_overrides: OrderedHashMap::new(),
569 ambient_regexes: Vec::new(),
570 }
571 }
572}
573
574#[cfg(test)]
575mod tests {
576 use std::path::{Path, PathBuf};
577
578 use clap::Parser;
579
580 use super::*;
581
582 struct TempDir {
583 path: PathBuf,
584 }
585
586 impl TempDir {
587 fn new(name: &str) -> Self {
588 let path = std::env::temp_dir()
589 .join(format!("s3lightfixes-config-{name}-{}", std::process::id()));
590 let _ = std::fs::remove_dir_all(&path);
591 std::fs::create_dir(&path).unwrap();
592
593 Self { path }
594 }
595
596 fn path(&self) -> &Path {
597 &self.path
598 }
599 }
600
601 impl Drop for TempDir {
602 fn drop(&mut self) {
603 let _ = std::fs::remove_dir_all(&self.path);
604 }
605 }
606
607 #[test]
608 fn toml_light_overrides_preserve_declaration_order() {
609 let config = toml::from_str::<LightConfig>(
610 r"
611 [light_overrides.first]
612 radius = 1
613
614 [light_overrides.second]
615 radius = 2
616
617 [light_overrides.third]
618 radius = 3
619 ",
620 )
621 .unwrap();
622
623 assert_eq!(
624 config.light_overrides.keys().collect::<Vec<_>>(),
625 vec!["first", "second", "third"]
626 );
627 }
628
629 #[test]
630 fn disable_negative_lights_defaults_true_and_can_be_loaded_false() {
631 let defaulted = toml::from_str::<LightConfig>("").unwrap();
632 assert!(defaulted.disable_negative_lights);
633
634 let configured = toml::from_str::<LightConfig>("disable_negative_lights = false").unwrap();
635 assert!(!configured.disable_negative_lights);
636 }
637
638 #[test]
639 fn cli_disable_negative_lights_overrides_config_without_short_form() {
640 let mut config = LightConfig {
641 disable_negative_lights: true,
642 ..LightConfig::default()
643 };
644 let args = LightArgs::parse_from([
645 "s3lightfixes",
646 "--disable-negative-lights",
647 "false",
648 "--no-flicker",
649 "false",
650 ]);
651
652 config.apply_bool_args(&args);
653
654 assert!(!config.disable_negative_lights);
655 assert!(!config.disable_flickering);
656 }
657
658 #[test]
659 fn non_writing_modes_are_toml_configurable_and_cli_overridable() {
660 let mut config = toml::from_str::<LightConfig>(
661 r"
662 dry_run = true
663 validate_config = false
664 ",
665 )
666 .unwrap();
667 let args = LightArgs::parse_from(["s3lightfixes", "--validate-config"]);
668
669 config.apply_bool_args(&args);
670
671 assert!(!config.dry_run);
672 assert!(config.validate_config);
673 }
674
675 #[test]
676 fn cli_false_can_disable_configured_non_writing_mode() {
677 let mut config = LightConfig {
678 dry_run: true,
679 ..LightConfig::default()
680 };
681 let args = LightArgs::parse_from(["s3lightfixes", "--dry-run", "false"]);
682
683 config.apply_bool_args(&args);
684
685 assert!(!config.dry_run);
686 }
687
688 #[test]
689 fn configured_dry_run_and_validate_config_conflict_without_cli_override() {
690 let config = LightConfig {
691 dry_run: true,
692 validate_config: true,
693 ..LightConfig::default()
694 };
695 let args = LightArgs::parse_from(["s3lightfixes"]);
696
697 let err = config.effective_non_writing_modes(&args).unwrap_err();
698
699 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
700 }
701
702 #[test]
703 fn invalid_regexes_are_validation_errors_instead_of_silent_drops() {
704 let mut config = LightConfig {
705 excluded_ids: vec!["[".to_owned()],
706 no_notifications: true,
707 ..LightConfig::default()
708 };
709
710 let err = config.compile_regexes().unwrap_err();
711
712 assert_eq!(err.kind(), io::ErrorKind::InvalidInput);
713 assert!(
714 err.to_string()
715 .contains("Couldn't compile excluded id regex")
716 );
717 }
718
719 #[test]
720 fn legacy_hsv_light_colors_are_preserved() {
721 let config = toml::from_str::<LightConfig>(
722 r"
723 [light_overrides.torch]
724 hue = 180
725 saturation = 1.0
726 value = 1.0
727 ",
728 )
729 .unwrap();
730
731 let serialized = toml::to_string(&config).unwrap();
732 assert!(serialized.contains("hue = 180"));
733 assert!(serialized.contains("saturation = 1.0"));
734 assert!(serialized.contains("value = 1.0"));
735 assert!(!serialized.contains("red ="));
736 }
737
738 #[test]
739 fn final_save_strips_env_only_notification_and_debug_state() {
740 let temp_dir = TempDir::new("final-save-strips-runtime-state");
741 let mut config = LightConfig {
742 no_notifications: true,
743 debug: true,
744 ..LightConfig::default()
745 };
746
747 config
748 .save_to_user_config_without_runtime_flags(temp_dir.path(), false, false)
749 .unwrap();
750
751 assert!(config.no_notifications);
752 assert!(config.debug);
753
754 let saved = std::fs::read_to_string(temp_dir.path().join(DEFAULT_CONFIG_NAME)).unwrap();
755 assert!(!saved.contains("no_notifications = true"));
756 assert!(!saved.contains("debug = true"));
757 }
758}