Skip to main content

s3lightfixes/
app.rs

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            // Need additional handling here for instance replacements!
306            // Filter out any instances which are not either in the `deletions` or `replacements` lists.
307            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/// Runs the command-line application.
616///
617/// # Errors
618///
619/// Returns filesystem errors encountered while creating the optional debug log. Configuration,
620/// plugin-save, and user-facing validation errors keep the historical notification/exit-code
621/// behavior of the binary.
622#[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    // If the old plugin format exists, remove it before serializing the new plugin, as the target
709    // dir may still be the old one.
710    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}