1use std::{
2 collections::HashSet,
3 env::var,
4 fs::{File, copy, metadata, remove_file},
5 io::{self, Write},
6 mem::take,
7 path::{Path, PathBuf},
8 process::exit,
9};
10
11use clap::{CommandFactory, Parser};
12use rayon::prelude::*;
13use tes3::esp::{
14 AtmosphereData, Cell, CellFlags, EditorId, FixedString, Header, Light, ObjectFlags, Plugin,
15 TES3Object, types::FileType,
16};
17use vfstool_lib::VFS;
18
19use crate::{
20 LOG_NAME, LightArgs, LightConfig, PLUGIN_NAME, is_fixable_plugin, notification_box, save_plugin,
21};
22
23use crate::light_processing::process_light;
24
25type LoadedPlugin<'a> = (Plugin, &'a Path);
26
27struct GenerationResult {
28 plugin: Plugin,
29 header: Header,
30 logs: Vec<RecordLog>,
31}
32
33#[derive(Debug, PartialEq, Eq)]
34struct RecordLog {
35 kind: &'static str,
36 plugin: String,
37 id: String,
38 changes: Vec<String>,
39}
40
41struct RunMetadata {
42 version: &'static str,
43 config_path: PathBuf,
44 output_path: PathBuf,
45 content_files: usize,
46 loaded_plugins: usize,
47 masters: usize,
48 changed_cells: usize,
49 changed_lights: usize,
50}
51
52impl RunMetadata {
53 fn new(
54 selected_config_file: &Path,
55 output_dir: &Path,
56 content_files: usize,
57 loaded_plugins: usize,
58 header: &Header,
59 logs: &[RecordLog],
60 ) -> Self {
61 Self {
62 version: env!("CARGO_PKG_VERSION"),
63 config_path: selected_config_file.to_owned(),
64 output_path: output_dir.join(PLUGIN_NAME),
65 content_files,
66 loaded_plugins,
67 masters: header.masters.len(),
68 changed_cells: logs.iter().filter(|log| log.kind == "CELL").count(),
69 changed_lights: logs.iter().filter(|log| log.kind == "LIGH").count(),
70 }
71 }
72}
73
74fn selected_config_file_path(config: &openmw_config::OpenMWConfiguration) -> PathBuf {
75 config.user_config_path().join("openmw.cfg")
76}
77
78fn plugin_log_name(plugin_path: &Path) -> String {
79 plugin_path.file_name().map_or_else(
80 || plugin_path.display().to_string(),
81 |name| name.to_string_lossy().to_string(),
82 )
83}
84
85fn explicit_config_path(args: &LightArgs) -> Result<Option<PathBuf>, String> {
86 let Some(path) = args.openmw_cfg.as_ref() else {
87 return Ok(None);
88 };
89
90 if path.is_file() {
91 if path.file_name().is_some_and(|name| name == "openmw.cfg") {
92 let parent = path
93 .parent()
94 .filter(|parent| !parent.as_os_str().is_empty())
95 .unwrap_or_else(|| Path::new("."));
96 let config_dir = if parent.is_relative() {
97 parent.canonicalize().map_err(|error| {
98 format!(
99 "Explicit --openmw-cfg path {} could not be resolved: {error}",
100 parent.display()
101 )
102 })?
103 } else {
104 parent.to_owned()
105 };
106 return Ok(Some(config_dir));
107 }
108
109 return Err(format!(
110 "Explicit --openmw-cfg file {} must be named openmw.cfg",
111 path.display()
112 ));
113 }
114
115 let absolute_path = path.canonicalize().map_err(|error| {
116 format!(
117 "Explicit --openmw-cfg path {} could not be resolved: {error}",
118 path.display()
119 )
120 })?;
121
122 if absolute_path.is_dir() && absolute_path.join("openmw.cfg").is_file() {
123 return Ok(Some(absolute_path));
124 }
125
126 Err(format!(
127 "Explicit --openmw-cfg path {} must be a directory containing openmw.cfg",
128 path.display()
129 ))
130}
131
132fn load_openmw_config(
133 args: &LightArgs,
134 no_notifications: bool,
135) -> openmw_config::OpenMWConfiguration {
136 let loaded_config = if let Some(config_path) = match explicit_config_path(args) {
137 Ok(config_path) => config_path,
138 Err(error) => {
139 notification_box("Invalid --openmw-cfg path!", &error, no_notifications);
140 exit(127);
141 }
142 } {
143 openmw_config::OpenMWConfiguration::new(Some(config_path))
144 } else {
145 openmw_config::OpenMWConfiguration::from_env_or_user_config()
146 };
147
148 match loaded_config {
149 Ok(config) => config,
150 Err(error) => {
151 notification_box(
152 "Failed to read configuration file!",
153 &error.to_string(),
154 no_notifications,
155 );
156
157 exit(127);
158 }
159 }
160}
161
162fn content_files_or_exit(
163 config: &openmw_config::OpenMWConfiguration,
164 no_notifications: bool,
165) -> Vec<String> {
166 let content_files = config
167 .content_files_iter()
168 .map(|plugin| plugin.value_str().to_owned())
169 .collect::<Vec<_>>();
170
171 if content_files.is_empty() {
172 notification_box(
173 "No Plugins!",
174 "No plugins were found in openmw.cfg! No lights to fix!",
175 no_notifications,
176 );
177 exit(4);
178 }
179
180 content_files
181}
182
183fn load_plugins<'a>(
184 content_files: &[String],
185 light_config: &LightConfig,
186 vfs: &'a VFS,
187) -> Vec<LoadedPlugin<'a>> {
188 content_files
189 .par_iter()
190 .rev()
191 .filter_map(|plugin| {
192 let vfs_file = vfs.get_file(plugin.as_str())?;
193 let path = vfs_file.path();
194
195 if !is_fixable_plugin(path) || light_config.is_excluded_plugin(path) {
196 return None;
197 }
198
199 match Plugin::from_path_filtered(path, |tag| matches!(&tag, Cell::TAG | Light::TAG)) {
200 Ok(plugin) => Some((plugin, path)),
201 Err(err) => {
202 eprintln!(
203 "[ WARNING ]: Plugin {}: could not be loaded due to error: {}. Continuing light fixes without this mod . . . Everything will be okay. Yes, it's still working.\n",
204 path.display(),
205 err
206 );
207 None
208 }
209 }
210 })
211 .collect::<Vec<_>>()
212}
213
214fn apply_cell_ambient_overrides(
215 light_config: &LightConfig,
216 cell_id: &str,
217 atmo: &mut AtmosphereData,
218) -> bool {
219 let mut replaced = false;
220
221 for (pattern, replacement_data) in &light_config.ambient_regexes {
222 if !pattern.is_match(cell_id) {
223 continue;
224 }
225
226 if let Some(ambient) = &replacement_data.ambient {
227 atmo.ambient_color = ambient.to_esp_color();
228 replaced = true;
229 }
230
231 if let Some(fog) = &replacement_data.fog {
232 atmo.fog_color = fog.to_esp_color();
233 replaced = true;
234 }
235
236 if let Some(sunlight) = &replacement_data.sunlight {
237 atmo.sunlight_color = sunlight.to_esp_color();
238 replaced = true;
239 }
240
241 if let Some(density) = replacement_data.fog_density {
242 atmo.fog_density = density;
243 replaced = true;
244 }
245 }
246
247 replaced
248}
249
250fn cell_changes(original: &AtmosphereData, modified: &AtmosphereData) -> Vec<String> {
251 let mut changes = Vec::new();
252
253 if original.ambient_color != modified.ambient_color {
254 changes.push(format!(
255 "ambient {:?} -> {:?}",
256 original.ambient_color, modified.ambient_color
257 ));
258 }
259
260 if original.sunlight_color != modified.sunlight_color {
261 changes.push(format!(
262 "sunlight {:?} -> {:?}",
263 original.sunlight_color, modified.sunlight_color
264 ));
265 }
266
267 if original.fog_color != modified.fog_color {
268 changes.push(format!(
269 "fog {:?} -> {:?}",
270 original.fog_color, modified.fog_color
271 ));
272 }
273
274 if (original.fog_density - modified.fog_density).abs() > f32::EPSILON {
275 changes.push(format!(
276 "fog_density {} -> {}",
277 original.fog_density, modified.fog_density
278 ));
279 }
280
281 changes
282}
283
284fn process_cells(
285 plugin: &mut Plugin,
286 plugin_name: &str,
287 generated_plugin: &mut Plugin,
288 light_config: &LightConfig,
289 used_ids: &mut HashSet<String>,
290 logs: &mut Vec<RecordLog>,
291) -> u32 {
292 let mut used_objects = 0;
293
294 for cell in plugin.objects_of_type_mut::<Cell>().filter(|cell| {
295 cell.data.flags.contains(CellFlags::IS_INTERIOR) && cell.atmosphere_data.is_some()
296 }) {
297 let cell_id = cell.editor_id_ascii_lowercase().into_owned();
298
299 if used_ids.contains(&cell_id) || light_config.is_excluded_id(&cell_id) {
300 continue;
301 }
302
303 let original_atmo = cell.atmosphere_data.clone();
304 if let Some(ref mut atmo) = cell.atmosphere_data {
305 cell.references.clear();
308
309 if cell.water_height.is_some() {
310 cell.water_height = None;
311 }
312
313 let mut replaced = false;
314
315 if light_config.disable_interior_sun {
316 atmo.sunlight_color = [0, 0, 0, 0];
317 replaced = true;
318 }
319
320 replaced |= apply_cell_ambient_overrides(light_config, &cell_id, atmo);
321
322 if replaced {
323 if let Some(original_atmo) = &original_atmo {
324 let changes = cell_changes(original_atmo, atmo);
325
326 if !changes.is_empty() {
327 logs.push(RecordLog {
328 kind: "CELL",
329 plugin: plugin_name.to_owned(),
330 id: cell_id.clone(),
331 changes,
332 });
333 }
334 }
335
336 generated_plugin.objects.push(take(cell).into());
337 used_ids.insert(cell_id);
338 used_objects += 1;
339 }
340 }
341 }
342
343 used_objects
344}
345
346fn process_lights(
347 plugin: Plugin,
348 plugin_name: &str,
349 generated_plugin: &mut Plugin,
350 light_config: &LightConfig,
351 used_ids: &mut HashSet<String>,
352 logs: &mut Vec<RecordLog>,
353) -> u32 {
354 let mut used_objects = 0;
355
356 plugin
357 .into_objects_of_type::<Light>()
358 .filter_map(|light| {
359 let light_id = light.editor_id_ascii_lowercase().into_owned();
360
361 if !used_ids.contains(&light_id) && !light_config.is_excluded_id(&light_id) {
362 used_ids.insert(light_id);
363 Some(light)
364 } else {
365 None
366 }
367 })
368 .for_each(|mut light| {
369 let changes = process_light(light_config, &mut light);
370
371 if !changes.is_empty() {
372 logs.push(RecordLog {
373 kind: "LIGH",
374 plugin: plugin_name.to_owned(),
375 id: light.id.clone(),
376 changes,
377 });
378 }
379
380 generated_plugin.objects.push(light.into());
381 used_objects += 1;
382 });
383
384 used_objects
385}
386
387fn header_for_generated_plugin() -> Header {
388 Header {
389 version: 1.3,
390 author: FixedString("S3".to_string()),
391 description: FixedString("Plugin generated by s3-lightfixes".to_string()),
392 file_type: FileType::Esp,
393 flags: ObjectFlags::default(),
394 num_objects: 0,
395 masters: Vec::new(),
396 }
397}
398
399fn plugin_master(plugin_path: &Path, no_notifications: bool) -> io::Result<(String, u64)> {
400 let plugin_size = metadata(plugin_path)?.len();
401 let Some(name) = plugin_path.file_name() else {
402 notification_box(
403 "Bad plugin path!",
404 "Lightfixes could not resolve the name of one of your plugins! This is UBER Bad and should never happen!",
405 no_notifications,
406 );
407 exit(3);
408 };
409
410 Ok((name.to_string_lossy().to_string(), plugin_size))
411}
412
413fn generate_plugin(
414 plugins: Vec<LoadedPlugin<'_>>,
415 light_config: &LightConfig,
416) -> io::Result<GenerationResult> {
417 let mut plugin = Plugin::new();
418 let mut header = header_for_generated_plugin();
419 let mut used_ids = HashSet::new();
420 let mut logs = Vec::new();
421
422 for (mut source_plugin, plugin_path) in plugins {
423 let plugin_name = plugin_log_name(plugin_path);
424 let used_cell_objects = process_cells(
425 &mut source_plugin,
426 &plugin_name,
427 &mut plugin,
428 light_config,
429 &mut used_ids,
430 &mut logs,
431 );
432 let used_light_objects = process_lights(
433 source_plugin,
434 &plugin_name,
435 &mut plugin,
436 light_config,
437 &mut used_ids,
438 &mut logs,
439 );
440 let used_objects = used_cell_objects + used_light_objects;
441
442 if used_objects > 0 {
443 let (plugin_string, plugin_size) =
444 plugin_master(plugin_path, light_config.no_notifications)?;
445
446 header.masters.insert(0, (plugin_string, plugin_size));
447 header.num_objects += used_objects;
448 }
449 }
450
451 Ok(GenerationResult {
452 plugin,
453 header,
454 logs,
455 })
456}
457
458fn remove_old_plugin_from_data_local(config: &mut openmw_config::OpenMWConfiguration) {
459 if let Some(dir) = &mut config.data_local() {
460 let old_plug_path = dir.parsed().join(PLUGIN_NAME);
461 if old_plug_path.is_file() {
462 let _ = remove_file(old_plug_path);
463 }
464 }
465}
466
467fn backup_openmw_cfg(selected_config_file: &Path) -> io::Result<PathBuf> {
468 let file_name = selected_config_file.file_name().ok_or_else(|| {
469 io::Error::new(
470 io::ErrorKind::InvalidInput,
471 "selected OpenMW config path has no file name",
472 )
473 })?;
474 let backup_name = format!("{}.s3lightfixes.bak", file_name.to_string_lossy());
475 let backup_path = selected_config_file.with_file_name(backup_name);
476
477 copy(selected_config_file, &backup_path)?;
478
479 Ok(backup_path)
480}
481
482fn auto_enable_plugin(
483 config: &mut openmw_config::OpenMWConfiguration,
484 light_config: &LightConfig,
485 selected_config_file: &Path,
486) -> bool {
487 if !light_config.auto_enable {
488 return false;
489 }
490
491 if config.has_content_file(PLUGIN_NAME) {
492 return true;
493 }
494
495 let backup_path = match backup_openmw_cfg(selected_config_file) {
496 Ok(path) => path,
497 Err(err) => {
498 notification_box(
499 "Failed to back up openmw.cfg!",
500 &format!(
501 "Refusing to auto-enable {PLUGIN_NAME} because openmw.cfg could not be backed up: {err}"
502 ),
503 light_config.no_notifications,
504 );
505 return false;
506 }
507 };
508
509 match config.add_content_file(PLUGIN_NAME) {
510 Ok(()) => {
511 if let Err(err) = config.save_user() {
512 notification_box(
513 "Failed to resave openmw.cfg!",
514 &err.to_string(),
515 light_config.no_notifications,
516 );
517 false
518 } else {
519 let lightfix_enabled_msg = format!(
520 "Wrote selected OpenMW config at {} successfully! Backup saved at {}.",
521 selected_config_file.display(),
522 backup_path.display()
523 );
524 notification_box(
525 "Lightfixes enabled!",
526 &lightfix_enabled_msg,
527 light_config.no_notifications,
528 );
529 true
530 }
531 }
532 Err(err) => {
533 eprintln!("{err}");
534 exit(256);
535 }
536 }
537}
538
539fn write_log_outputs(
540 config: &openmw_config::OpenMWConfiguration,
541 metadata: &RunMetadata,
542 logs: &[RecordLog],
543) -> io::Result<()> {
544 let stdout = io::stdout();
545 let mut stdout = stdout.lock();
546 match write_log_to(&mut stdout, metadata, logs) {
547 Ok(()) => {}
548 Err(err) if err.kind() == io::ErrorKind::BrokenPipe => {}
549 Err(err) => return Err(err),
550 }
551
552 let path = config.user_config_path().join(LOG_NAME);
553 let mut file = File::create(path)?;
554 write_log_to(&mut file, metadata, logs)
555}
556
557fn write_dry_run_outputs(metadata: &RunMetadata, logs: &[RecordLog]) -> io::Result<()> {
558 let stdout = io::stdout();
559 let mut stdout = stdout.lock();
560 write_dry_run_to(&mut stdout, metadata, logs)
561}
562
563fn write_dry_run_to(
564 mut writer: impl Write,
565 metadata: &RunMetadata,
566 logs: &[RecordLog],
567) -> io::Result<()> {
568 writeln!(writer, "Dry run: no files written")?;
569 write_log_to(&mut writer, metadata, logs)
570}
571
572fn write_log_to(
573 mut writer: impl Write,
574 metadata: &RunMetadata,
575 logs: &[RecordLog],
576) -> io::Result<()> {
577 writeln!(writer, "# S3LightFixes {}", metadata.version)?;
578 writeln!(writer, "# config: {}", metadata.config_path.display())?;
579 writeln!(writer, "# output: {}", metadata.output_path.display())?;
580 writeln!(writer, "# content files: {}", metadata.content_files)?;
581 writeln!(writer, "# loaded plugins: {}", metadata.loaded_plugins)?;
582 writeln!(writer, "# masters: {}", metadata.masters)?;
583 writeln!(writer, "# changed cells: {}", metadata.changed_cells)?;
584 writeln!(writer, "# changed lights: {}", metadata.changed_lights)?;
585
586 for log in logs {
587 writeln!(
588 writer,
589 "{} {:?} from {:?}: {}",
590 log.kind,
591 log.id,
592 log.plugin,
593 log.changes.join(", ")
594 )?;
595 }
596
597 Ok(())
598}
599
600fn handle_generated_output(args: &LightArgs, stdout: &mut dyn Write) -> io::Result<bool> {
601 if let Some(shell) = args.generate_completion {
602 let mut command = LightArgs::command();
603 clap_complete::generate(shell, &mut command, "s3lightfixes", stdout);
604 return Ok(true);
605 }
606
607 if args.generate_manpage {
608 clap_mangen::Man::new(LightArgs::command()).render(stdout)?;
609 return Ok(true);
610 }
611
612 Ok(false)
613}
614
615#[allow(clippy::too_many_lines)]
623pub fn run() -> io::Result<()> {
624 let args = LightArgs::parse();
625
626 if handle_generated_output(&args, &mut io::stdout())? {
627 return Ok(());
628 }
629
630 let no_notifications = var("S3L_NO_NOTIFICATIONS").is_ok() || args.no_notifications;
631 let mut config = load_openmw_config(&args, no_notifications);
632 let selected_config_file = selected_config_file_path(&config);
633 let light_config = LightConfig::get(args, &config)?;
634
635 if light_config.validate_config {
636 println!(
637 "Validated {} successfully",
638 config
639 .user_config_path()
640 .join(crate::DEFAULT_CONFIG_NAME)
641 .display()
642 );
643 return Ok(());
644 }
645
646 let output_dir = light_config.output_dir.clone().unwrap_or_else(|| {
647 notification_box(
648 "Can't get output directory!",
649 "[ CRITICAL FAILURE ]: FAILED TO RESOLVE OUTPUT DIRECTORY!",
650 light_config.no_notifications,
651 );
652 exit(256);
653 });
654
655 if light_config.debug {
656 dbg!(&light_config, &config);
657 }
658
659 let content_files = content_files_or_exit(&config, light_config.no_notifications);
660 let directories = config
661 .data_directories_iter()
662 .map(openmw_config::DirectorySetting::parsed)
663 .collect::<Vec<_>>();
664 let vfs = VFS::from_directories(directories, None);
665 let plugins = load_plugins(&content_files, &light_config, &vfs);
666 let loaded_plugins = plugins.len();
667 let GenerationResult {
668 mut plugin,
669 header,
670 logs,
671 } = generate_plugin(plugins, &light_config)?;
672
673 if light_config.debug {
674 dbg!(&header);
675 }
676
677 let metadata = RunMetadata::new(
678 &selected_config_file,
679 &output_dir,
680 content_files.len(),
681 loaded_plugins,
682 &header,
683 &logs,
684 );
685
686 if header.masters.is_empty() {
687 if light_config.dry_run {
688 write_dry_run_outputs(&metadata, &logs)?;
689 return Ok(());
690 }
691
692 notification_box(
693 "No masters found!",
694 "The generated plugin was not found to have any master files! It's empty! Try running lightfixes again using the S3L_DEBUG environment variable",
695 light_config.no_notifications,
696 );
697 exit(2);
698 }
699
700 if light_config.dry_run {
701 write_dry_run_outputs(&metadata, &logs)?;
702 return Ok(());
703 }
704
705 plugin.objects.push(TES3Object::Header(header));
706 plugin.sort_objects();
707
708 remove_old_plugin_from_data_local(&mut config);
711
712 save_plugin(&output_dir, &mut plugin).inspect_err(|err| {
713 notification_box(
714 "Failed to save plugin!",
715 &err.to_string(),
716 light_config.no_notifications,
717 );
718 })?;
719
720 let enabled = auto_enable_plugin(&mut config, &light_config, &selected_config_file);
721 write_log_outputs(&config, &metadata, &logs)?;
722
723 let lights_fixed = if enabled {
724 format!(
725 "S3LightFixes.omwaddon generated, enabled, and saved in {}",
726 output_dir.display()
727 )
728 } else {
729 format!(
730 "S3LightFixes.omwaddon generated and saved in {}",
731 output_dir.display()
732 )
733 };
734
735 notification_box(
736 "Lightfixes successful!",
737 &lights_fixed,
738 light_config.no_notifications,
739 );
740
741 Ok(())
742}
743
744#[cfg(test)]
745mod tests {
746 use std::sync::atomic::{AtomicU64, Ordering};
747
748 use regex::Regex;
749 use tes3::esp::{AtmosphereData, CellData, LightData, LightFlags, Reference, TES3Object};
750
751 use super::*;
752 use crate::{CustomCellAmbient, light_override::TypedLightColor};
753
754 static NEXT_TEMP_FILE: AtomicU64 = AtomicU64::new(0);
755
756 struct TempPluginFile {
757 path: PathBuf,
758 }
759
760 impl TempPluginFile {
761 fn as_path(&self) -> &Path {
762 &self.path
763 }
764 }
765
766 impl Drop for TempPluginFile {
767 fn drop(&mut self) {
768 let _ = std::fs::remove_file(&self.path);
769 }
770 }
771
772 fn temp_plugin_file(name: &str, size: usize) -> TempPluginFile {
773 let path = std::env::temp_dir().join(format!(
774 "s3lightfixes-{name}-{}-{}",
775 std::process::id(),
776 NEXT_TEMP_FILE.fetch_add(1, Ordering::Relaxed)
777 ));
778 std::fs::write(&path, vec![0; size]).unwrap();
779
780 TempPluginFile { path }
781 }
782
783 fn light(id: &str, radius: u32) -> Light {
784 Light {
785 id: id.to_owned(),
786 data: LightData {
787 radius,
788 time: 10,
789 color: [255, 128, 0, 0],
790 flags: LightFlags::default(),
791 ..LightData::default()
792 },
793 ..Light::default()
794 }
795 }
796
797 fn plugin_with_lights(lights: impl IntoIterator<Item = Light>) -> Plugin {
798 Plugin {
799 objects: lights.into_iter().map(TES3Object::from).collect(),
800 }
801 }
802
803 fn generated_lights(plugin: &Plugin) -> Vec<&Light> {
804 plugin.objects_of_type::<Light>().collect()
805 }
806
807 fn generated_cells(plugin: &Plugin) -> Vec<&Cell> {
808 plugin.objects_of_type::<Cell>().collect()
809 }
810
811 fn config() -> LightConfig {
812 LightConfig {
813 standard_hue: 1.0,
814 standard_saturation: 1.0,
815 standard_value: 1.0,
816 standard_radius: 1.0,
817 colored_hue: 1.0,
818 colored_saturation: 1.0,
819 colored_value: 1.0,
820 colored_radius: 1.0,
821 duration_mult: 1.0,
822 ..LightConfig::default()
823 }
824 }
825
826 fn test_metadata(changed_cells: usize, changed_lights: usize) -> RunMetadata {
827 RunMetadata {
828 version: "test-version",
829 config_path: PathBuf::from("/tmp/openmw.cfg"),
830 output_path: PathBuf::from("/tmp/out/S3LightFixes.omwaddon"),
831 content_files: 3,
832 loaded_plugins: 2,
833 masters: 1,
834 changed_cells,
835 changed_lights,
836 }
837 }
838
839 #[test]
840 fn generated_completion_goes_to_stdout_without_running_lightfixes() {
841 let args = LightArgs::parse_from(["s3lightfixes", "--generate-completion", "bash"]);
842 let mut stdout = Vec::new();
843
844 assert!(handle_generated_output(&args, &mut stdout).unwrap());
845
846 let completion = String::from_utf8(stdout).unwrap();
847 assert!(completion.contains("_s3lightfixes"));
848 assert!(completion.contains("--generate-manpage"));
849 }
850
851 #[test]
852 fn generated_manpage_goes_to_stdout_without_running_lightfixes() {
853 let args = LightArgs::parse_from(["s3lightfixes", "--generate-manpage"]);
854 let mut stdout = Vec::new();
855
856 assert!(handle_generated_output(&args, &mut stdout).unwrap());
857
858 let manpage = String::from_utf8(stdout).unwrap();
859 assert!(manpage.contains("s3lightfixes"));
860 assert!(manpage.contains("A tool for modifying light values globally"));
861 }
862
863 #[test]
864 fn generated_outputs_conflict_with_each_other() {
865 let err = LightArgs::try_parse_from([
866 "s3lightfixes",
867 "--generate-completion",
868 "bash",
869 "--generate-manpage",
870 ])
871 .unwrap_err();
872
873 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
874 }
875
876 #[test]
877 fn dry_run_and_validate_config_conflict_with_each_other() {
878 let err = LightArgs::try_parse_from(["s3lightfixes", "--dry-run", "--validate-config"])
879 .unwrap_err();
880
881 assert_eq!(err.kind(), clap::error::ErrorKind::ArgumentConflict);
882 }
883
884 #[test]
885 fn dry_run_output_reports_target_and_planned_record_changes() {
886 let mut stdout = Vec::new();
887 let logs = [RecordLog {
888 kind: "LIGH",
889 plugin: "source.esp".to_owned(),
890 id: "torch_01".to_owned(),
891 changes: vec!["radius 10 -> 20".to_owned()],
892 }];
893 let metadata = test_metadata(0, 1);
894
895 write_dry_run_to(&mut stdout, &metadata, &logs).unwrap();
896
897 let output = String::from_utf8(stdout).unwrap();
898 assert!(output.contains("Dry run: no files written"));
899 assert!(output.contains("# output: /tmp/out/S3LightFixes.omwaddon"));
900 assert!(output.contains("# changed lights: 1"));
901 assert!(output.contains("LIGH \"torch_01\" from \"source.esp\": radius 10 -> 20"));
902 }
903
904 #[test]
905 fn backup_openmw_cfg_copies_existing_user_config_before_auto_enable() {
906 let temp_dir = std::env::temp_dir().join(format!(
907 "s3lightfixes-openmw-backup-{}-{}",
908 std::process::id(),
909 NEXT_TEMP_FILE.fetch_add(1, Ordering::Relaxed)
910 ));
911 std::fs::create_dir(&temp_dir).unwrap();
912 let selected_config = temp_dir.join("openmw.cfg");
913 std::fs::write(&selected_config, "content=Morrowind.esm\n").unwrap();
914
915 let backup_path = backup_openmw_cfg(&selected_config).unwrap();
916
917 assert_eq!(backup_path, temp_dir.join("openmw.cfg.s3lightfixes.bak"));
918 assert_eq!(
919 std::fs::read_to_string(backup_path).unwrap(),
920 "content=Morrowind.esm\n"
921 );
922
923 let _ = std::fs::remove_dir_all(temp_dir);
924 }
925
926 #[test]
927 fn explicit_openmw_cfg_directory_is_used_as_config_context() {
928 let temp_dir = std::env::temp_dir().join(format!(
929 "s3lightfixes-openmw-dir-{}-{}",
930 std::process::id(),
931 NEXT_TEMP_FILE.fetch_add(1, Ordering::Relaxed)
932 ));
933 std::fs::create_dir(&temp_dir).unwrap();
934 let selected_config = temp_dir.join("openmw.cfg");
935 std::fs::write(&selected_config, "content=Morrowind.esm\n").unwrap();
936 let args = LightArgs::parse_from([
937 "s3lightfixes",
938 "--openmw-cfg",
939 &temp_dir.display().to_string(),
940 ]);
941
942 assert_eq!(
943 explicit_config_path(&args)
944 .unwrap()
945 .unwrap()
946 .canonicalize()
947 .unwrap(),
948 temp_dir.canonicalize().unwrap()
949 );
950
951 let _ = std::fs::remove_dir_all(temp_dir);
952 }
953
954 #[test]
955 fn explicit_openmw_cfg_file_is_normalized_to_its_directory() {
956 let temp_dir = std::env::temp_dir().join(format!(
957 "s3lightfixes-openmw-file-{}-{}",
958 std::process::id(),
959 NEXT_TEMP_FILE.fetch_add(1, Ordering::Relaxed)
960 ));
961 std::fs::create_dir(&temp_dir).unwrap();
962 let selected_config = temp_dir.join("openmw.cfg");
963 std::fs::write(&selected_config, "content=Morrowind.esm\n").unwrap();
964 let args = LightArgs::parse_from([
965 "s3lightfixes",
966 "--openmw-cfg",
967 &selected_config.display().to_string(),
968 ]);
969
970 assert_eq!(explicit_config_path(&args).unwrap().unwrap(), temp_dir);
971
972 let _ = std::fs::remove_dir_all(temp_dir);
973 }
974
975 #[test]
976 fn explicit_openmw_cfg_rejects_custom_config_filename() {
977 let temp_dir = std::env::temp_dir().join(format!(
978 "s3lightfixes-openmw-custom-file-{}-{}",
979 std::process::id(),
980 NEXT_TEMP_FILE.fetch_add(1, Ordering::Relaxed)
981 ));
982 std::fs::create_dir(&temp_dir).unwrap();
983 let selected_config = temp_dir.join("friend-requested.cfg");
984 std::fs::write(&selected_config, "content=Morrowind.esm\n").unwrap();
985 let args = LightArgs::parse_from([
986 "s3lightfixes",
987 "--openmw-cfg",
988 &selected_config.display().to_string(),
989 ]);
990
991 assert_eq!(
992 explicit_config_path(&args).unwrap_err(),
993 format!(
994 "Explicit --openmw-cfg file {} must be named openmw.cfg",
995 selected_config.display()
996 )
997 );
998
999 let _ = std::fs::remove_dir_all(temp_dir);
1000 }
1001
1002 #[cfg(unix)]
1003 #[test]
1004 fn explicit_openmw_cfg_rejects_custom_filename_symlink_to_openmw_cfg() {
1005 let temp_dir = std::env::temp_dir().join(format!(
1006 "s3lightfixes-openmw-symlink-file-{}-{}",
1007 std::process::id(),
1008 NEXT_TEMP_FILE.fetch_add(1, Ordering::Relaxed)
1009 ));
1010 std::fs::create_dir(&temp_dir).unwrap();
1011 let target_config = temp_dir.join("openmw.cfg");
1012 let symlink_config = temp_dir.join("friend-requested.cfg");
1013 std::fs::write(&target_config, "content=Morrowind.esm\n").unwrap();
1014 std::os::unix::fs::symlink(&target_config, &symlink_config).unwrap();
1015 let args = LightArgs::parse_from([
1016 "s3lightfixes",
1017 "--openmw-cfg",
1018 &symlink_config.display().to_string(),
1019 ]);
1020
1021 assert_eq!(
1022 explicit_config_path(&args).unwrap_err(),
1023 format!(
1024 "Explicit --openmw-cfg file {} must be named openmw.cfg",
1025 symlink_config.display()
1026 )
1027 );
1028
1029 let _ = std::fs::remove_dir_all(temp_dir);
1030 }
1031
1032 #[test]
1033 fn auto_enable_updates_only_user_config_in_root_chain() {
1034 let temp_dir = std::env::temp_dir().join(format!(
1035 "s3lightfixes-openmw-root-chain-{}-{}",
1036 std::process::id(),
1037 NEXT_TEMP_FILE.fetch_add(1, Ordering::Relaxed)
1038 ));
1039 let root_dir = temp_dir.join("root");
1040 let user_dir = temp_dir.join("user");
1041 std::fs::create_dir_all(&root_dir).unwrap();
1042 std::fs::create_dir_all(&user_dir).unwrap();
1043 let root_config = root_dir.join("openmw.cfg");
1044 let user_config = user_dir.join("openmw.cfg");
1045 std::fs::write(
1046 &root_config,
1047 format!(
1048 "data=/engine/root\nconfig={}\ncontent=RootOnly.esm\n",
1049 user_dir.display()
1050 ),
1051 )
1052 .unwrap();
1053 std::fs::write(&user_config, "content=Morrowind.esm\n").unwrap();
1054 let mut openmw_config =
1055 openmw_config::OpenMWConfiguration::new(Some(root_dir.clone())).unwrap();
1056 let light_config = LightConfig {
1057 auto_enable: true,
1058 no_notifications: true,
1059 ..config()
1060 };
1061 let selected_config_file = selected_config_file_path(&openmw_config);
1062
1063 assert_eq!(selected_config_file, user_config);
1064 assert!(auto_enable_plugin(
1065 &mut openmw_config,
1066 &light_config,
1067 &selected_config_file,
1068 ));
1069
1070 assert_eq!(
1071 std::fs::read_to_string(&root_config).unwrap(),
1072 format!(
1073 "data=/engine/root\nconfig={}\ncontent=RootOnly.esm\n",
1074 user_dir.display()
1075 )
1076 );
1077 assert_eq!(
1078 std::fs::read_to_string(&user_config).unwrap(),
1079 "content=Morrowind.esm\ncontent=S3LightFixes.omwaddon\n"
1080 );
1081 assert_eq!(
1082 std::fs::read_to_string(user_dir.join("openmw.cfg.s3lightfixes.bak")).unwrap(),
1083 "content=Morrowind.esm\n"
1084 );
1085 assert!(!root_dir.join("openmw.cfg.s3lightfixes.bak").exists());
1086
1087 let _ = std::fs::remove_dir_all(temp_dir);
1088 }
1089
1090 fn interior_cell(id: &str) -> Cell {
1091 Cell {
1092 name: id.to_owned(),
1093 data: CellData {
1094 flags: CellFlags::IS_INTERIOR,
1095 ..CellData::default()
1096 },
1097 atmosphere_data: Some(AtmosphereData {
1098 ambient_color: [1, 2, 3, 0],
1099 sunlight_color: [4, 5, 6, 0],
1100 fog_color: [7, 8, 9, 0],
1101 fog_density: 0.25,
1102 }),
1103 water_height: Some(10.0),
1104 references: [((0, 1), Reference::default())].into(),
1105 ..Cell::default()
1106 }
1107 }
1108
1109 fn exterior_cell(id: &str) -> Cell {
1110 Cell {
1111 name: id.to_owned(),
1112 atmosphere_data: Some(AtmosphereData::default()),
1113 water_height: Some(10.0),
1114 references: [((0, 1), Reference::default())].into(),
1115 ..Cell::default()
1116 }
1117 }
1118
1119 #[test]
1120 fn generate_plugin_uses_first_processed_duplicate_id_and_keeps_unique_lights() {
1121 let later_path = temp_plugin_file("later.omwaddon", 11);
1122 let earlier_path = temp_plugin_file("earlier.omwaddon", 13);
1123 let light_config = config();
1124 let plugins = vec![
1125 (
1126 plugin_with_lights([light("Shared_Light", 300), light("later_only", 301)]),
1127 later_path.as_path(),
1128 ),
1129 (
1130 plugin_with_lights([light("shared_light", 100), light("earlier_only", 101)]),
1131 earlier_path.as_path(),
1132 ),
1133 ];
1134
1135 let result = generate_plugin(plugins, &light_config).unwrap();
1136 let lights = generated_lights(&result.plugin);
1137
1138 assert_eq!(lights.len(), 3);
1139 assert_eq!(
1140 lights
1141 .iter()
1142 .find(|light| light.id.eq_ignore_ascii_case("shared_light"))
1143 .unwrap()
1144 .data
1145 .radius,
1146 300
1147 );
1148 assert!(lights.iter().any(|light| light.id == "later_only"));
1149 assert!(lights.iter().any(|light| light.id == "earlier_only"));
1150 assert_eq!(result.header.num_objects, 3);
1151 }
1152
1153 #[test]
1154 fn generate_plugin_adds_masters_only_for_plugins_that_contribute_objects() {
1155 let contributing_a = temp_plugin_file("contributing_a.omwaddon", 11);
1156 let duplicate_only = temp_plugin_file("duplicate_only.omwaddon", 13);
1157 let contributing_c = temp_plugin_file("contributing_c.omwaddon", 17);
1158 let light_config = config();
1159 let plugins = vec![
1160 (
1161 plugin_with_lights([light("shared", 100)]),
1162 contributing_a.as_path(),
1163 ),
1164 (
1165 plugin_with_lights([light("shared", 200)]),
1166 duplicate_only.as_path(),
1167 ),
1168 (
1169 plugin_with_lights([light("unique", 300)]),
1170 contributing_c.as_path(),
1171 ),
1172 ];
1173
1174 let result = generate_plugin(plugins, &light_config).unwrap();
1175
1176 assert_eq!(result.header.num_objects, 2);
1177 assert_eq!(
1178 result.header.masters,
1179 vec![
1180 (
1181 contributing_c
1182 .as_path()
1183 .file_name()
1184 .unwrap()
1185 .to_string_lossy()
1186 .to_string(),
1187 17
1188 ),
1189 (
1190 contributing_a
1191 .as_path()
1192 .file_name()
1193 .unwrap()
1194 .to_string_lossy()
1195 .to_string(),
1196 11
1197 ),
1198 ]
1199 );
1200 }
1201
1202 #[test]
1203 fn compatibility_fixture_preserves_core_generation_contracts() {
1204 let first_processed = temp_plugin_file("first_processed.esp", 11);
1205 let second_processed = temp_plugin_file("second_processed.esp", 13);
1206 let mut light_config = config();
1207 light_config
1208 .excluded_id_regexes
1209 .push(Regex::new("excluded").unwrap());
1210 let plugins = vec![
1211 (
1212 plugin_with_lights([
1213 light("duplicate_light", 10),
1214 Light {
1215 id: "negative_light".to_owned(),
1216 data: LightData {
1217 radius: 20,
1218 color: [255, 128, 0, 0],
1219 flags: LightFlags::NEGATIVE,
1220 ..LightData::default()
1221 },
1222 ..Light::default()
1223 },
1224 light("excluded_light", 30),
1225 ]),
1226 first_processed.as_path(),
1227 ),
1228 (
1229 plugin_with_lights([
1230 light("duplicate_light", 99),
1231 light("second_unique_light", 40),
1232 ]),
1233 second_processed.as_path(),
1234 ),
1235 ];
1236
1237 let mut result = generate_plugin(plugins, &light_config).unwrap();
1238 result
1239 .plugin
1240 .objects
1241 .push(TES3Object::Header(result.header));
1242 result.plugin.sort_objects();
1243
1244 let generated = generated_lights(&result.plugin);
1245 assert_eq!(generated.len(), 3);
1246 assert!(
1247 generated
1248 .iter()
1249 .any(|light| light.id == "duplicate_light" && light.data.radius == 10)
1250 );
1251 assert!(generated.iter().all(|light| light.id != "excluded_light"));
1252 assert!(
1253 generated
1254 .iter()
1255 .any(|light| light.id == "second_unique_light" && light.data.radius == 40)
1256 );
1257 let negative = generated
1258 .iter()
1259 .find(|light| light.id == "negative_light")
1260 .unwrap();
1261 assert_eq!(negative.data.radius, 0);
1262 assert!(!negative.data.flags.contains(LightFlags::NEGATIVE));
1263
1264 let TES3Object::Header(header) = &result.plugin.objects[0] else {
1265 panic!("generated plugin header was not sorted first");
1266 };
1267 assert_eq!(header.num_objects, 3);
1268 assert_eq!(header.masters.len(), 2);
1269 assert_eq!(result.logs.len(), 1);
1270 assert_eq!(result.logs[0].id, "negative_light");
1271 }
1272
1273 #[test]
1274 fn process_lights_skips_excluded_ids_that_would_otherwise_emit() {
1275 let mut light_config = config();
1276 light_config
1277 .excluded_id_regexes
1278 .push(Regex::new("excluded_light").unwrap());
1279 let source_plugin = plugin_with_lights([light("excluded_light", 100), light("kept", 200)]);
1280 let mut generated_plugin = Plugin::new();
1281 let mut used_ids = HashSet::new();
1282 let mut logs = Vec::new();
1283
1284 let used_objects = process_lights(
1285 source_plugin,
1286 "TestPlugin.esp",
1287 &mut generated_plugin,
1288 &light_config,
1289 &mut used_ids,
1290 &mut logs,
1291 );
1292
1293 let lights = generated_lights(&generated_plugin);
1294 assert_eq!(used_objects, 1);
1295 assert_eq!(lights.len(), 1);
1296 assert_eq!(lights[0].id, "kept");
1297 assert!(!used_ids.contains("excluded_light"));
1298 assert!(used_ids.contains("kept"));
1299 assert!(logs.is_empty());
1300 }
1301
1302 #[test]
1303 fn process_lights_logs_actual_deltas_for_modified_lights() {
1304 let mut light_config = config();
1305 light_config.standard_radius = 2.0;
1306 let source_plugin = plugin_with_lights([light("modified_light", 100)]);
1307 let mut generated_plugin = Plugin::new();
1308 let mut used_ids = HashSet::new();
1309 let mut logs = Vec::new();
1310
1311 let used_objects = process_lights(
1312 source_plugin,
1313 "ModifiedPlugin.esp",
1314 &mut generated_plugin,
1315 &light_config,
1316 &mut used_ids,
1317 &mut logs,
1318 );
1319
1320 assert_eq!(used_objects, 1);
1321 assert_eq!(logs.len(), 1);
1322 assert_eq!(logs[0].kind, "LIGH");
1323 assert_eq!(logs[0].plugin, "ModifiedPlugin.esp");
1324 assert_eq!(logs[0].id, "modified_light");
1325 assert!(logs[0].changes.contains(&"radius 100 -> 200".to_owned()));
1326 }
1327
1328 #[test]
1329 fn ambient_cell_replacement_consumes_id_before_light_processing() {
1330 let mut light_config = config();
1331 light_config.disable_interior_sun = true;
1332 let path = temp_plugin_file("shared_cell_light.omwaddon", 19);
1333 let plugin = Plugin {
1334 objects: vec![
1335 interior_cell("shared_id").into(),
1336 light("shared_id", 100).into(),
1337 ],
1338 };
1339
1340 let result = generate_plugin(vec![(plugin, path.as_path())], &light_config).unwrap();
1341
1342 assert_eq!(generated_cells(&result.plugin).len(), 1);
1343 assert!(generated_lights(&result.plugin).is_empty());
1344 assert_eq!(result.header.num_objects, 1);
1345 }
1346
1347 #[test]
1348 fn process_cells_emits_ambient_replacement_and_strips_instance_state() {
1349 let mut light_config = config();
1350 light_config.ambient_regexes.push((
1351 Regex::new("ambient_cell").unwrap(),
1352 CustomCellAmbient {
1353 ambient: Some(TypedLightColor {
1354 red: 0,
1355 green: 255,
1356 blue: 255,
1357 }),
1358 sunlight: Some(TypedLightColor {
1359 red: 0,
1360 green: 0,
1361 blue: 255,
1362 }),
1363 fog: Some(TypedLightColor {
1364 red: 0,
1365 green: 255,
1366 blue: 0,
1367 }),
1368 fog_density: Some(0.75),
1369 },
1370 ));
1371 let mut source_plugin = Plugin {
1372 objects: vec![interior_cell("ambient_cell").into()],
1373 };
1374 let mut generated_plugin = Plugin::new();
1375 let mut used_ids = HashSet::new();
1376 let mut logs = Vec::new();
1377
1378 let used_objects = process_cells(
1379 &mut source_plugin,
1380 "AmbientPlugin.esp",
1381 &mut generated_plugin,
1382 &light_config,
1383 &mut used_ids,
1384 &mut logs,
1385 );
1386
1387 let cells = generated_cells(&generated_plugin);
1388 assert_eq!(used_objects, 1);
1389 assert_eq!(cells.len(), 1);
1390 assert!(used_ids.contains("ambient_cell"));
1391 assert!(cells[0].references.is_empty());
1392 assert!(cells[0].water_height.is_none());
1393
1394 let atmo = cells[0].atmosphere_data.as_ref().unwrap();
1395 assert_eq!(atmo.ambient_color, [0, 255, 255, 0]);
1396 assert_eq!(atmo.sunlight_color, [0, 0, 255, 0]);
1397 assert_eq!(atmo.fog_color, [0, 255, 0, 0]);
1398 assert!((atmo.fog_density - 0.75).abs() < f32::EPSILON);
1399 assert_eq!(logs.len(), 1);
1400 assert_eq!(logs[0].kind, "CELL");
1401 assert_eq!(logs[0].plugin, "AmbientPlugin.esp");
1402 assert_eq!(logs[0].id, "ambient_cell");
1403 assert!(
1404 logs[0]
1405 .changes
1406 .contains(&"ambient [1, 2, 3, 0] -> [0, 255, 255, 0]".to_owned())
1407 );
1408 assert!(
1409 logs[0]
1410 .changes
1411 .contains(&"sunlight [4, 5, 6, 0] -> [0, 0, 255, 0]".to_owned())
1412 );
1413 }
1414
1415 #[test]
1416 fn process_cells_disable_interior_sun_counts_as_replacement() {
1417 let mut light_config = config();
1418 light_config.disable_interior_sun = true;
1419 let mut source_plugin = Plugin {
1420 objects: vec![interior_cell("sun_cell").into()],
1421 };
1422 let mut generated_plugin = Plugin::new();
1423 let mut used_ids = HashSet::new();
1424 let mut logs = Vec::new();
1425
1426 let used_objects = process_cells(
1427 &mut source_plugin,
1428 "SunPlugin.esp",
1429 &mut generated_plugin,
1430 &light_config,
1431 &mut used_ids,
1432 &mut logs,
1433 );
1434
1435 let cells = generated_cells(&generated_plugin);
1436 assert_eq!(used_objects, 1);
1437 assert_eq!(cells.len(), 1);
1438 assert_eq!(
1439 cells[0].atmosphere_data.as_ref().unwrap().sunlight_color,
1440 [0, 0, 0, 0]
1441 );
1442 assert!(cells[0].references.is_empty());
1443 assert!(cells[0].water_height.is_none());
1444 assert!(used_ids.contains("sun_cell"));
1445 assert_eq!(logs.len(), 1);
1446 assert!(
1447 logs[0]
1448 .changes
1449 .contains(&"sunlight [4, 5, 6, 0] -> [0, 0, 0, 0]".to_owned())
1450 );
1451 }
1452
1453 #[test]
1454 fn process_cells_does_not_log_stripped_patch_only_state() {
1455 let mut light_config = config();
1456 light_config.disable_interior_sun = true;
1457 let mut source_plugin = Plugin {
1458 objects: vec![
1459 Cell {
1460 name: "already_dark_cell".to_owned(),
1461 data: CellData {
1462 flags: CellFlags::IS_INTERIOR,
1463 ..CellData::default()
1464 },
1465 atmosphere_data: Some(AtmosphereData {
1466 sunlight_color: [0, 0, 0, 0],
1467 ..AtmosphereData::default()
1468 }),
1469 references: [((0, 1), Reference::default())].into(),
1470 water_height: Some(42.0),
1471 ..Cell::default()
1472 }
1473 .into(),
1474 ],
1475 };
1476 let mut generated_plugin = Plugin::new();
1477 let mut used_ids = HashSet::new();
1478 let mut logs = Vec::new();
1479
1480 let used_objects = process_cells(
1481 &mut source_plugin,
1482 "AlreadyDark.esp",
1483 &mut generated_plugin,
1484 &light_config,
1485 &mut used_ids,
1486 &mut logs,
1487 );
1488
1489 assert_eq!(used_objects, 1);
1490 assert!(logs.is_empty());
1491 assert!(generated_cells(&generated_plugin)[0].references.is_empty());
1492 assert!(generated_cells(&generated_plugin)[0].water_height.is_none());
1493 }
1494
1495 #[test]
1496 fn process_cells_leaves_skipped_cells_out_of_generated_plugin() {
1497 let mut light_config = config();
1498 light_config.disable_interior_sun = true;
1499 light_config
1500 .excluded_id_regexes
1501 .push(Regex::new("excluded_cell").unwrap());
1502 let mut used_ids = HashSet::from(["duplicate_cell".to_owned()]);
1503 let mut source_plugin = Plugin {
1504 objects: vec![
1505 exterior_cell("exterior_cell").into(),
1506 Cell {
1507 name: "no_atmosphere".to_owned(),
1508 data: CellData {
1509 flags: CellFlags::IS_INTERIOR,
1510 ..CellData::default()
1511 },
1512 atmosphere_data: None,
1513 ..Cell::default()
1514 }
1515 .into(),
1516 interior_cell("excluded_cell").into(),
1517 interior_cell("duplicate_cell").into(),
1518 ],
1519 };
1520 let mut generated_plugin = Plugin::new();
1521 let mut logs = Vec::new();
1522
1523 let used_objects = process_cells(
1524 &mut source_plugin,
1525 "SkippedPlugin.esp",
1526 &mut generated_plugin,
1527 &light_config,
1528 &mut used_ids,
1529 &mut logs,
1530 );
1531
1532 assert_eq!(used_objects, 0);
1533 assert!(generated_cells(&generated_plugin).is_empty());
1534 assert!(used_ids.contains("duplicate_cell"));
1535 assert!(!used_ids.contains("excluded_cell"));
1536 assert!(logs.is_empty());
1537 }
1538
1539 #[test]
1540 fn write_log_reports_writer_errors() {
1541 struct BrokenWriter {
1542 attempted_write: bool,
1543 }
1544
1545 impl Write for BrokenWriter {
1546 fn write(&mut self, _buf: &[u8]) -> io::Result<usize> {
1547 self.attempted_write = true;
1548 Err(io::Error::other("broken writer"))
1549 }
1550
1551 fn flush(&mut self) -> io::Result<()> {
1552 Ok(())
1553 }
1554 }
1555
1556 let mut writer = BrokenWriter {
1557 attempted_write: false,
1558 };
1559 let logs = [RecordLog {
1560 kind: "LIGH",
1561 plugin: "BrokenPlugin.esp".to_owned(),
1562 id: "broken_writer".to_owned(),
1563 changes: vec!["radius 1 -> 2".to_owned()],
1564 }];
1565
1566 let metadata = test_metadata(0, 1);
1567
1568 let err = write_log_to(&mut writer, &metadata, &logs).unwrap_err();
1569
1570 assert!(writer.attempted_write);
1571 assert_eq!(err.kind(), io::ErrorKind::Other);
1572 }
1573
1574 #[test]
1575 fn write_log_emits_one_line_per_modified_record() {
1576 let logs = [
1577 RecordLog {
1578 kind: "CELL",
1579 plugin: "Morrowind.esm".to_owned(),
1580 id: "cell_id".to_owned(),
1581 changes: vec![
1582 "sunlight [1, 2, 3, 0] -> [0, 0, 0, 0]".to_owned(),
1583 "fog_density 0.5 -> 0.75".to_owned(),
1584 ],
1585 },
1586 RecordLog {
1587 kind: "LIGH",
1588 plugin: "Tribunal.esm".to_owned(),
1589 id: "light_id".to_owned(),
1590 changes: vec![
1591 "color [1, 2, 3, 0] -> [4, 5, 6, 0]".to_owned(),
1592 "radius 128 -> 256".to_owned(),
1593 ],
1594 },
1595 ];
1596 let mut output = Vec::new();
1597 let metadata = test_metadata(1, 1);
1598
1599 write_log_to(&mut output, &metadata, &logs).unwrap();
1600
1601 let output = String::from_utf8(output).unwrap();
1602 assert!(output.contains("# S3LightFixes test-version"));
1603 assert!(output.contains("# content files: 3"));
1604 assert!(output.contains("# loaded plugins: 2"));
1605 assert!(output.contains("# changed cells: 1"));
1606 assert!(output.contains("# changed lights: 1"));
1607 assert!(output.contains("CELL \"cell_id\" from \"Morrowind.esm\": sunlight [1, 2, 3, 0] -> [0, 0, 0, 0], fog_density 0.5 -> 0.75"));
1608 assert!(output.contains("LIGH \"light_id\" from \"Tribunal.esm\": color [1, 2, 3, 0] -> [4, 5, 6, 0], radius 128 -> 256"));
1609 }
1610}