Skip to main content

rpfm_extensions/diagnostics/
mod.rs

1//---------------------------------------------------------------------------//
2// Copyright (c) 2017-2026 Ismael Gutiérrez González. All rights reserved.
3//
4// This file is part of the Rusted PackFile Manager (RPFM) project,
5// which can be found here: https://github.com/Frodo45127/rpfm.
6//
7// This file is licensed under the MIT license, which can be found here:
8// https://github.com/Frodo45127/rpfm/blob/master/LICENSE.
9//---------------------------------------------------------------------------//
10
11//! Pack validation and diagnostic checking system.
12//!
13//! This module provides comprehensive validation for Total War mod packs, detecting
14//! common errors, potential issues, and best practice violations. Diagnostics help
15//! modders identify problems before they cause crashes or unexpected behavior in-game.
16//!
17//! # Diagnostic Types
18//!
19//! The system checks multiple aspects of a pack:
20//!
21//! - **Table Diagnostics** ([`table`]): DB and Loc table validation
22//!   - Invalid foreign key references
23//!   - Empty required fields (keys, values)
24//!   - Duplicate rows
25//!   - Orphaned localisation entries
26//!
27//! - **Pack Diagnostics** ([`pack`]): Pack-level checks
28//!   - Files conflicting with vanilla
29//!   - Missing declared dependencies
30//!
31//! - **Dependency Diagnostics** ([`dependency`]): Cross-pack validation
32//!   - References to non-existent files
33//!   - Circular dependencies
34//!
35//! - **Portrait Settings Diagnostics** ([`portrait_settings`]): Unit portrait validation
36//!   - Invalid art set references
37//!   - Missing variant definitions
38//!
39//! - **Animation Fragment Diagnostics** ([`anim_fragment_battle`]): Animation validation
40//!   - Invalid animation references
41//!   - Malformed fragment data
42//!
43//! - **Text Diagnostics** ([`text`]): Script validation
44//!
45//! - **Config Diagnostics** ([`config`]): Configuration file validation
46//!
47//! # Diagnostic Levels
48//!
49//! Each diagnostic has an associated severity level:
50//!
51//! - **Error**: Critical issues that will likely cause crashes or major problems
52//! - **Warning**: Issues that may cause problems or indicate mistakes
53//! - **Info**: Suggestions and informational notes
54//!
55//! # Cell Position Encoding
56//!
57//! For table diagnostics, the affected cells are encoded as (row, column) pairs:
58//!
59//! - `(-1, -1)`: Affects the entire table
60//! - `(row, -1)`: Affects all columns in a single row
61//! - `(-1, column)`: Affects all rows in a single column
62//! - `(row, column)`: Affects a specific cell
63//!
64//! # Filtering
65//!
66//! Diagnostics can be filtered by:
67//!
68//! - Ignored folders (skip entire directory trees)
69//! - Ignored files (skip specific files)
70//! - Ignored fields (skip specific table columns)
71//! - Ignored diagnostic types
72//!
73//! # Usage Example
74//!
75//! ```ignore
76//! use rpfm_extensions::diagnostics::Diagnostics;
77//!
78//! let mut diagnostics = Diagnostics::default();
79//! diagnostics.check(
80//!     &mut pack,
81//!     &mut dependencies,
82//!     &schema,
83//!     &game_info,
84//!     game_path,
85//!     &[],  // Check all paths
86//!     false, // Don't check AK-only references
87//! );
88//!
89//! for result in diagnostics.results() {
90//!     println!("{}: {}", result.path(), result.message());
91//! }
92//! ```
93
94use getset::{Getters, MutGetters};
95use rayon::prelude::*;
96use serde_derive::{Serialize, Deserialize};
97
98use std::borrow::Cow;
99use std::collections::{BTreeMap, HashMap, HashSet};
100use std::cmp::Ordering;
101use std::{fmt, fmt::Display};
102use std::path::Path;
103
104use rpfm_lib::error::Result;
105use rpfm_lib::files::{ContainerPath, Container, DecodeableExtraData, FileType, pack::{DiagnosticIgnoreEntry, Pack}, RFile, RFileDecoded};
106use rpfm_lib::games::{GameInfo, VanillaDBTableNameLogic};
107use rpfm_lib::schema::{FieldType, Schema};
108
109use crate::dependencies::Dependencies;
110
111use self::anim_fragment_battle::*;
112use self::config::*;
113use self::dependency::*;
114use self::pack::*;
115use self::portrait_settings::*;
116use self::table::*;
117use self::text::TextDiagnostic;
118
119pub mod anim_fragment_battle;
120pub mod config;
121pub mod dependency;
122pub mod pack;
123pub mod portrait_settings;
124pub mod table;
125pub mod text;
126
127//-------------------------------------------------------------------------------//
128//                              Trait definitions
129//-------------------------------------------------------------------------------//
130
131/// Trait for types that can report diagnostic information.
132///
133/// All diagnostic result types implement this trait to provide a consistent
134/// interface for accessing the diagnostic message and severity level.
135pub trait DiagnosticReport {
136
137    /// Returns the human-readable message describing this diagnostic.
138    ///
139    /// The message should clearly explain what the issue is and, where possible,
140    /// suggest how to fix it.
141    fn message(&self) -> String;
142
143    /// Returns the severity level of this diagnostic.
144    ///
145    /// Used for filtering and prioritizing diagnostic results.
146    fn level(&self) -> DiagnosticLevel;
147}
148
149//-------------------------------------------------------------------------------//
150//                              Enums & Structs
151//-------------------------------------------------------------------------------//
152
153/// Container for diagnostic check results and configuration.
154///
155/// This struct holds both the configuration for which diagnostics to run
156/// (via ignore lists) and the results of the diagnostic check.
157///
158/// # Filtering
159///
160/// Use the ignore fields to exclude certain items from diagnostic checks:
161///
162/// - `folders_ignored`: Skip entire folder trees (e.g., "db/deprecated_tables")
163/// - `files_ignored`: Skip specific files by path
164/// - `fields_ignored`: Skip specific table columns (format: "table_name/field_name")
165/// - `diagnostics_ignored`: Skip specific diagnostic types by identifier
166#[derive(Debug, Clone, Default, Getters, MutGetters, Serialize, Deserialize)]
167#[getset(get = "pub", get_mut = "pub")]
168pub struct Diagnostics {
169
170    /// Folder paths to exclude from diagnostic checks.
171    ///
172    /// Files within these folders (and subfolders) will not be checked.
173    folders_ignored: Vec<String>,
174
175    /// File paths to exclude from diagnostic checks.
176    files_ignored: Vec<String>,
177
178    /// Table fields to exclude from diagnostic checks.
179    ///
180    /// Format: "table_name/field_name" (e.g., "units_tables/key")
181    fields_ignored: Vec<String>,
182
183    /// Diagnostic type identifiers to skip.
184    ///
185    /// Use this to disable specific checks that produce false positives
186    /// or are not relevant to your mod.
187    diagnostics_ignored: Vec<String>,
188
189    /// The diagnostic results from the most recent check.
190    results: Vec<DiagnosticType>
191}
192
193/// Wrapper enum for all diagnostic result types.
194///
195/// Each variant corresponds to a different file type or check category,
196/// containing the specific diagnostic struct for that type.
197#[derive(Debug, Clone, Serialize, Deserialize)]
198pub enum DiagnosticType {
199    /// Diagnostics for animation fragment battle files.
200    AnimFragmentBattle(AnimFragmentBattleDiagnostic),
201    /// Diagnostics for configuration files.
202    Config(ConfigDiagnostic),
203    /// Diagnostics for dependency-related issues.
204    Dependency(DependencyDiagnostic),
205    /// Diagnostics for DB tables.
206    DB(TableDiagnostic),
207    /// Diagnostics for Loc (localisation) tables.
208    Loc(TableDiagnostic),
209    /// Diagnostics for pack-level issues.
210    Pack(PackDiagnostic),
211    /// Diagnostics for portrait settings files.
212    PortraitSettings(PortraitSettingsDiagnostic),
213    /// Diagnostics for text/script files.
214    Text(TextDiagnostic),
215}
216
217/// Severity level of a diagnostic result.
218///
219/// Used to categorize diagnostics by importance and filter results
220/// in the user interface.
221#[derive(Debug, Clone, Default, Serialize, Deserialize)]
222pub enum DiagnosticLevel {
223    /// Informational message or suggestion.
224    ///
225    /// These don't indicate errors but may highlight potential improvements
226    /// or provide useful information about the mod.
227    #[default]
228    Info,
229    /// Potential issue that may cause problems.
230    ///
231    /// Warnings indicate things that might be mistakes or could cause
232    /// issues in certain circumstances, but aren't definite errors.
233    Warning,
234    /// Critical issue that will likely cause problems.
235    ///
236    /// Errors indicate definite problems that should be fixed, such as
237    /// invalid references or malformed data that could crash the game.
238    Error,
239}
240
241/// Per-file ignore state derived from the pack's `diagnostics_files_to_ignore` setting.
242///
243/// Tuple shape: `(ignored_fields, ignored_diagnostics, ignored_diagnostics_for_fields)`.
244/// `None` means the whole file is skipped.
245pub type FileIgnoreState = (Vec<String>, HashSet<String>, HashMap<String, Vec<String>>);
246
247//-------------------------------------------------------------------------------//
248//                             Implementations
249//-------------------------------------------------------------------------------//
250
251impl Default for DiagnosticType {
252    fn default() -> Self {
253        Self::Pack(PackDiagnostic::default())
254    }
255}
256
257impl DiagnosticType {
258    pub fn path(&self) -> &str {
259        match self {
260            Self::AnimFragmentBattle(ref diag) => diag.path(),
261            Self::DB(ref diag) |
262            Self::Loc(ref diag) => diag.path(),
263            Self::Pack(_) => "",
264            Self::PortraitSettings(diag) => diag.path(),
265            Self::Text(diag) => diag.path(),
266            Self::Dependency(diag) => diag.path(),
267            Self::Config(_) => "",
268        }
269    }
270
271    pub fn pack(&self) -> &str {
272        match self {
273            Self::AnimFragmentBattle(ref diag) => diag.pack(),
274            Self::DB(ref diag) |
275            Self::Loc(ref diag) => diag.pack(),
276            Self::Pack(diag) => diag.pack(),
277            Self::PortraitSettings(diag) => diag.pack(),
278            Self::Text(diag) => diag.pack(),
279            Self::Dependency(diag) => diag.pack(),
280            Self::Config(_) => "",
281        }
282    }
283}
284
285impl Diagnostics {
286
287    /// This function performs a search over the parts of the provided Packs, storing his results.
288    #[allow(clippy::too_many_arguments)]
289    pub fn check(&mut self, packs: &mut BTreeMap<String, Pack>, dependencies: &mut Dependencies, schema: &Schema, game_info: &GameInfo, game_path: &Path, paths_to_check: &[ContainerPath], check_ak_only_refs: bool) {
290
291        // Clear the diagnostics first if we're doing a full check, or only the config ones and the ones for the path to update if we're doing a partial check.
292        if paths_to_check.is_empty() {
293            self.results.clear();
294        } else {
295            self.results.retain(|diagnostic| !paths_to_check.contains(&ContainerPath::File(diagnostic.path().to_string())));
296            self.results.iter_mut().for_each(|x| {
297                if let DiagnosticType::Config(config) = x {
298                    config.results_mut().retain(|x|
299                        match x.report_type() {
300                            ConfigDiagnosticReportType::DependenciesCacheNotGenerated |
301                            ConfigDiagnosticReportType::DependenciesCacheOutdated |
302                            ConfigDiagnosticReportType::DependenciesCacheCouldNotBeLoaded(_) |
303                            ConfigDiagnosticReportType::IncorrectGamePath => false,
304                        }
305                    );
306                }
307            });
308        }
309
310        // First, check for config issues, as some of them may stop the checking prematurely.
311        if let Some(diagnostics) = ConfigDiagnostic::check(dependencies, game_info, game_path) {
312            let is_diagnostic_blocking = if let DiagnosticType::Config(ref diagnostic) = diagnostics {
313                diagnostic.results().iter().any(|diagnostic| matches!(diagnostic.report_type(),
314                    ConfigDiagnosticReportType::IncorrectGamePath |
315                    ConfigDiagnosticReportType::DependenciesCacheNotGenerated |
316                    ConfigDiagnosticReportType::DependenciesCacheOutdated |
317                    ConfigDiagnosticReportType::DependenciesCacheCouldNotBeLoaded(_)))
318            } else { false };
319
320            // If we have one of the blocking diagnostics, report it and return.
321            self.results.push(diagnostics);
322            if is_diagnostic_blocking {
323                return;
324            }
325        }
326
327        // TODO: Check if we should split this so each pack is only affected by their own ignored files.
328        let files_to_ignore = packs.values().find_map(|pack| pack.settings().diagnostics_files_to_ignore());
329
330        // To make sure we can read any non-db and non-loc file, we need to pre-decode them here.
331        {
332            // Extra data to decode animfragmentbattle files.
333            let mut extra_data = DecodeableExtraData::default();
334            extra_data.set_game_info(Some(game_info));
335            let extra_data = Some(extra_data);
336
337            for pack in packs.values_mut() {
338                pack.files_by_type_mut(&[FileType::AnimFragmentBattle, FileType::Text, FileType::PortraitSettings])
339                    .par_iter_mut()
340                    .for_each(|file| { let _ = file.decode(&extra_data, true, false); });
341            }
342        }
343
344        // Logic here: we want to process the tables on batches containing all the tables of the same type, so we can check duplicates in different tables.
345        // To do that, we have to sort/split the file list, the process that.
346        let files: Vec<(&str, &RFile)> = if paths_to_check.is_empty() {
347            packs.iter().flat_map(|(key, pack)| pack.files_by_type(&[FileType::AnimFragmentBattle, FileType::DB, FileType::Loc, FileType::Text, FileType::PortraitSettings]).into_iter().map(move |file| (key.as_str(), file))).collect()
348        } else {
349            packs.iter().flat_map(|(key, pack)| pack.files_by_type_and_paths(&[FileType::AnimFragmentBattle, FileType::DB, FileType::Loc, FileType::Text, FileType::PortraitSettings], paths_to_check, false).into_iter().map(move |file| (key.as_str(), file))).collect()
350        };
351
352        let mut files_split: HashMap<&str, Vec<(&str, &RFile)>> = HashMap::new();
353        let mut we_need_loc_data = false;
354        for (pack_key, file) in &files {
355            match file.file_type() {
356                FileType::AnimFragmentBattle => {
357                    if let Some(table_set) = files_split.get_mut("anim_fragment_battle") {
358                        table_set.push((pack_key, file));
359                    } else {
360                        files_split.insert("anim_fragment_battle", vec![(pack_key, file)]);
361                    }
362                },
363                FileType::DB => {
364                    we_need_loc_data = true;
365
366                    let path_split = file.path_in_container_split();
367                    if path_split.len() > 2 {
368                        if let Some(table_set) = files_split.get_mut(path_split[1]) {
369                            table_set.push((pack_key, file));
370                        } else {
371                            files_split.insert(path_split[1], vec![(pack_key, file)]);
372                        }
373                    }
374                },
375                FileType::Loc => {
376                    if let Some(table_set) = files_split.get_mut("locs") {
377                        table_set.push((pack_key, file));
378                    } else {
379                        files_split.insert("locs", vec![(pack_key, file)]);
380                    }
381                },
382                FileType::Text => {
383                    if let Some(name) = file.file_name() {
384                        if name.ends_with(".lua") {
385                            if let Some(table_set) = files_split.get_mut("lua") {
386                                table_set.push((pack_key, file));
387                            } else {
388                                files_split.insert("lua", vec![(pack_key, file)]);
389                            }
390                        }
391                    }
392                },
393                FileType::PortraitSettings => {
394                    if let Some(table_set) = files_split.get_mut("portrait_settings") {
395                        table_set.push((pack_key, file));
396                    } else {
397                        files_split.insert("portrait_settings", vec![(pack_key, file)]);
398                    }
399                },
400                _ => {},
401            }
402        }
403
404        // Getting this here speeds up a lot path-checking later.
405        let mut local_file_path_list = HashMap::new();
406        for pack in packs.values() {
407            local_file_path_list.extend(pack.paths_cache().iter().map(|(k, v)| (k.clone(), v.clone())));
408        }
409        let local_file_path_list = &local_file_path_list;
410
411        let loc_files: Vec<&RFile> = packs.values().flat_map(|pack| pack.files_by_type(&[FileType::Loc])).collect();
412        let loc_decoded = loc_files.iter()
413            .filter_map(|file| if let Ok(RFileDecoded::Loc(loc)) = file.decoded() { Some(loc) } else { None })
414            .map(|file| file.data())
415            .collect::<Vec<_>>();
416
417        // Loc data takes a few ms to get, and it's only needed if we're going to check on tables, for the lookup data. So only get it if we really need it.
418        let loc_data = if we_need_loc_data {
419            Some(loc_decoded.par_iter()
420            .flat_map(|data| data.par_iter()
421                .map(|entry| (entry[0].data_to_string(), entry[1].data_to_string()))
422                .collect::<Vec<(_,_)>>()
423            ).collect::<HashMap<_,_>>())
424        } else {
425            None
426        };
427
428        // That way we can get it fast on the first try, and skip.
429        let table_names = files_split.iter().filter(|(key, _)| **key != "anim_fragment_battle" && **key != "locs" && **key != "lua" && **key != "portrait_settings").map(|(key, _)| key.to_string()).collect::<Vec<_>>();
430
431        // If table names is empty this triggers a full regeneration, which is slow as fuck. So make sure to avoid that if we're only doing a partial check.
432        if !table_names.is_empty() || (table_names.is_empty() && paths_to_check.is_empty()) {
433            dependencies.generate_local_db_references(schema, packs, &table_names);
434        }
435
436        // Caches for Portrait Settings diagnostics. There are some alt lookups for tables with differently named columns between games.
437        let art_set_ids = dependencies.db_values_from_table_name_and_column_name(Some(packs), "campaign_character_arts_tables", "art_set_id", true, true);
438        let mut variant_filenames = dependencies.db_values_from_table_name_and_column_name(Some(packs), "variants_tables", "variant_filename", true, true);
439        if variant_filenames.is_empty() {
440            variant_filenames = dependencies.db_values_from_table_name_and_column_name(Some(packs), "variants_tables", "variant_name", true, true);
441        }
442
443        // Process the files in batches.
444        self.results.append(&mut files_split.par_iter().filter_map(|(_, files)| {
445            let mut diagnostics = Vec::with_capacity(files.len());
446
447            // Ignore empty groups, which should never happen, but just in case.
448            if let Some(file_type) = files.first().map(|(_, x)| x.file_type()) {
449
450                // DB groups are processed as a group, not per file, so we are able to detect duplicated lines between files.
451                // Same for locs.
452                match file_type {
453                    FileType::DB => {
454                        diagnostics.extend_from_slice(&TableDiagnostic::check_db(
455                            files,
456                            dependencies,
457                            &self.diagnostics_ignored,
458                            game_info,
459                            local_file_path_list,
460                            check_ak_only_refs,
461                            &files_to_ignore,
462                            packs,
463                            schema,
464                            &loc_data
465                        ));
466                    },
467                    FileType::Loc => {
468                        diagnostics.extend_from_slice(&TableDiagnostic::check_loc(
469                            files,
470                            &self.diagnostics_ignored,
471                            &files_to_ignore,
472                        ));
473                    }
474                    _ => {
475                        for (pack_key, file) in files {
476                            let (ignored_fields, ignored_diagnostics, ignored_diagnostics_for_fields) = Self::ignore_data_for_file(file, &files_to_ignore)?;
477
478                            let diagnostic = match file.file_type() {
479                                FileType::AnimFragmentBattle => AnimFragmentBattleDiagnostic::check(
480                                    pack_key,
481                                    file,
482                                    dependencies,
483                                    &self.diagnostics_ignored,
484                                    &ignored_fields,
485                                    &ignored_diagnostics,
486                                    &ignored_diagnostics_for_fields,
487                                    local_file_path_list,
488                                ),
489
490                                FileType::Text => TextDiagnostic::check(pack_key, file, packs, dependencies, &self.diagnostics_ignored, &ignored_fields, &ignored_diagnostics, &ignored_diagnostics_for_fields),
491                                FileType::PortraitSettings => PortraitSettingsDiagnostic::check(pack_key, file, &art_set_ids, &variant_filenames, dependencies, &self.diagnostics_ignored, &ignored_fields, &ignored_diagnostics, &ignored_diagnostics_for_fields, local_file_path_list),
492                                _ => None,
493                            };
494
495                            if let Some(diagnostic) = diagnostic {
496                                diagnostics.push(diagnostic);
497                            }
498                        }
499                    }
500                }
501            }
502
503            Some(diagnostics)
504        }).flatten().collect());
505
506        // These two are global, so do not execute on file-specific runs.
507        if paths_to_check.is_empty() {
508            self.results_mut().extend(DependencyDiagnostic::check(packs));
509            self.results_mut().extend(PackDiagnostic::check(packs, dependencies, game_info, game_path));
510        }
511
512        self.results_mut().sort_by(|a, b| {
513            if !a.path().is_empty() && !b.path().is_empty() {
514                a.path().cmp(b.path())
515            } else if a.path().is_empty() && !b.path().is_empty() {
516                Ordering::Greater
517            } else if !a.path().is_empty() && b.path().is_empty() {
518                Ordering::Less
519            } else {
520                Ordering::Equal
521            }
522        });
523    }
524
525    /// Function to know if an specific field/diagnostic must be ignored.
526    fn ignore_diagnostic(global_ignored_diagnostics: &[String], field_name: Option<&str>, diagnostic: Option<&str>, ignored_fields: &[String], ignored_diagnostics: &HashSet<String>, ignored_diagnostics_for_fields: &HashMap<String, Vec<String>>) -> bool {
527        let mut ignore_diagnostic = false;
528
529        if let Some(diagnostic) = diagnostic {
530            return global_ignored_diagnostics.iter().any(|x| x == diagnostic);
531        }
532
533        // If we have a field, and it's in the ignored list, ignore it.
534        if let Some(field_name) = field_name {
535            ignore_diagnostic = ignored_fields.iter().any(|x| x == field_name);
536        }
537
538        // If we have a diagnostic, and it's in the ignored list, ignore it.
539        else if let Some(diagnostic) = diagnostic {
540            ignore_diagnostic = ignored_diagnostics.get(diagnostic).is_some();
541        }
542
543        // If we have not yet being ignored, check for specific diagnostics for specific fields.
544        if !ignore_diagnostic {
545            if let Some(field_name) = field_name {
546                if let Some(diagnostic) = diagnostic {
547                    if let Some(diags) = ignored_diagnostics_for_fields.get(field_name) {
548                        ignore_diagnostic = diags.iter().any(|x| x == diagnostic);
549                    }
550                }
551            }
552        }
553
554        ignore_diagnostic
555    }
556
557    /// Ignore entire tables if their path starts with the one we have (so we can do mass ignores) and we didn't specified a field to ignore.
558    fn ignore_data_for_file(file: &RFile, files_to_ignore: &Option<Vec<DiagnosticIgnoreEntry>>) -> Option<FileIgnoreState> {
559        let mut ignored_fields = vec![];
560        let mut ignored_diagnostics = HashSet::new();
561        let mut ignored_diagnostics_for_fields: HashMap<String, Vec<String>> = HashMap::new();
562        if let Some(ref files_to_ignore) = files_to_ignore {
563            for (path_to_ignore, fields, diags_to_ignore) in files_to_ignore {
564
565                // If the rule doesn't affect this PackedFile, ignore it.
566                if !path_to_ignore.is_empty() && file.path_in_container_raw().starts_with(path_to_ignore) {
567
568                    // If we don't have either fields or diags specified, we ignore the entire file.
569                    if fields.is_empty() && diags_to_ignore.is_empty() {
570                        return None;
571                    }
572
573                    // If we have both, fields and diags, disable only those diags for those fields.
574                    if !fields.is_empty() && !diags_to_ignore.is_empty() {
575                        for field in fields {
576                            match ignored_diagnostics_for_fields.get_mut(field) {
577                                Some(diagnostics) => diagnostics.append(&mut diags_to_ignore.to_vec()),
578                                None => { ignored_diagnostics_for_fields.insert(field.to_owned(), diags_to_ignore.to_vec()); },
579                            }
580                        }
581                    }
582
583                    // Otherwise, check if we only have fields or diags, and put them separately.
584                    else if !fields.is_empty() {
585                        ignored_fields.append(&mut fields.to_vec());
586                    }
587
588                    else if !diags_to_ignore.is_empty() {
589                        ignored_diagnostics.extend(diags_to_ignore.to_vec());
590                    }
591                }
592            }
593        }
594        Some((ignored_fields, ignored_diagnostics, ignored_diagnostics_for_fields))
595    }
596
597    /// This function converts an entire diagnostics struct into a JSon string.
598    pub fn json(&self) -> Result<String> {
599        serde_json::to_string_pretty(self).map_err(From::from)
600    }
601}
602
603impl Display for DiagnosticType {
604    fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
605        Display::fmt(match self {
606            Self::AnimFragmentBattle(_) => "AnimFragmentBattle",
607            Self::Config(_) => "Config",
608            Self::DB(_) => "DB",
609            Self::Loc(_) => "Loc",
610            Self::Pack(_) => "Packfile",
611            Self::PortraitSettings(_) => "PortraitSettings",
612            Self::Text(_) => "Text",
613            Self::Dependency(_) => "DependencyManager",
614        }, f)
615    }
616}