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