777 lines
26 KiB
Rust
777 lines
26 KiB
Rust
//! MangoHud config file parser and writer.
|
|
|
|
use crate::config::types::{AnnotatedConfig, ConfigLine, ConfigValue};
|
|
use anyhow::{Context, Result};
|
|
use indexmap::IndexMap;
|
|
use once_cell::sync::Lazy;
|
|
use regex::Regex;
|
|
use std::fs;
|
|
use std::path::{Path, PathBuf};
|
|
|
|
static KEY_RE: Lazy<Regex> =
|
|
Lazy::new(|| Regex::new(r"^[a-zA-Z_][a-zA-Z0-9_]*$").expect("valid key regex"));
|
|
|
|
pub struct Parser;
|
|
|
|
impl Parser {
|
|
pub fn new() -> Self {
|
|
Self
|
|
}
|
|
|
|
/// Parse a config file from disk.
|
|
pub fn read(path: &Path) -> Result<AnnotatedConfig> {
|
|
let content = fs::read_to_string(path)
|
|
.with_context(|| format!("failed to read config file {}", path.display()))?;
|
|
Ok(Self::parse_str(&content, Some(path.to_path_buf())))
|
|
}
|
|
|
|
/// Parse config from a string (for env var inline and tests).
|
|
pub fn parse_str(content: &str, path: Option<PathBuf>) -> AnnotatedConfig {
|
|
let mut lines = Vec::new();
|
|
let mut options: IndexMap<String, (usize, ConfigValue)> = IndexMap::new();
|
|
|
|
for raw_line in content.lines() {
|
|
let idx = lines.len();
|
|
if raw_line.trim().is_empty() {
|
|
lines.push(ConfigLine::Blank);
|
|
continue;
|
|
}
|
|
|
|
if let Some(comment) = raw_line.strip_prefix('#') {
|
|
let candidate = comment.trim_start();
|
|
if let Some((key, value)) = parse_option_candidate(candidate) {
|
|
let line = ConfigLine::CommentedOption {
|
|
key: key.clone(),
|
|
value: value.clone(),
|
|
raw: raw_line.to_string(),
|
|
};
|
|
if let Some((old_idx, _)) =
|
|
options.insert(key.clone(), (idx, ConfigValue::Disabled))
|
|
{
|
|
if let Some(old_line) = lines.get_mut(old_idx) {
|
|
*old_line = ConfigLine::Blank;
|
|
}
|
|
}
|
|
lines.push(line);
|
|
} else {
|
|
lines.push(ConfigLine::Comment(raw_line.to_string()));
|
|
}
|
|
continue;
|
|
}
|
|
|
|
if let Some((key, value)) = parse_option_candidate(raw_line) {
|
|
let cfg_value = match value.clone() {
|
|
Some(v) => ConfigValue::Value(v),
|
|
None => ConfigValue::Flag,
|
|
};
|
|
let line = ConfigLine::Option {
|
|
key: key.clone(),
|
|
value,
|
|
raw: raw_line.to_string(),
|
|
};
|
|
if let Some((old_idx, _)) = options.insert(key.clone(), (idx, cfg_value.clone())) {
|
|
if let Some(old_line) = lines.get_mut(old_idx) {
|
|
*old_line = ConfigLine::Blank;
|
|
}
|
|
}
|
|
lines.push(line);
|
|
continue;
|
|
}
|
|
|
|
lines.push(ConfigLine::Comment(raw_line.to_string()));
|
|
}
|
|
|
|
AnnotatedConfig {
|
|
lines,
|
|
options,
|
|
path,
|
|
dirty: false,
|
|
}
|
|
}
|
|
|
|
/// Write an AnnotatedConfig back to disk safely.
|
|
pub fn write(config: &AnnotatedConfig) -> Result<()> {
|
|
let path = config
|
|
.path
|
|
.as_ref()
|
|
.context("cannot write config without a backing file path")?;
|
|
let backup_path = PathBuf::from(format!("{}.mangotune.bak", path.display()));
|
|
let tmp_path = PathBuf::from(format!("{}.mangotune.tmp", path.display()));
|
|
let content = Self::to_string(config);
|
|
debug_log(&format!("parser::write begin path={}", path.display()));
|
|
let mut backup_created = false;
|
|
|
|
if path.exists() {
|
|
if backup_path.exists() {
|
|
debug_log(&format!(
|
|
"parser::write removing stale backup {}",
|
|
backup_path.display()
|
|
));
|
|
if let Err(err) = remove_existing_path(&backup_path) {
|
|
debug_log(&format!(
|
|
"parser::write could not remove stale backup {}: {err}",
|
|
backup_path.display()
|
|
));
|
|
}
|
|
}
|
|
debug_log(&format!(
|
|
"parser::write copy backup {} -> {}",
|
|
path.display(),
|
|
backup_path.display()
|
|
));
|
|
match fs::copy(path, &backup_path) {
|
|
Ok(_) => backup_created = true,
|
|
Err(err) => debug_log(&format!(
|
|
"parser::write backup skipped for {}: {err}",
|
|
backup_path.display()
|
|
)),
|
|
}
|
|
}
|
|
|
|
debug_log(&format!("parser::write write temp {}", tmp_path.display()));
|
|
let write_res = fs::write(&tmp_path, content)
|
|
.with_context(|| format!("failed writing temp config {}", tmp_path.display()))
|
|
.and_then(|_| {
|
|
debug_log(&format!(
|
|
"parser::write rename temp {} -> {}",
|
|
tmp_path.display(),
|
|
path.display()
|
|
));
|
|
fs::rename(&tmp_path, path).with_context(|| {
|
|
format!(
|
|
"failed to atomically replace config {} with {}",
|
|
path.display(),
|
|
tmp_path.display()
|
|
)
|
|
})
|
|
});
|
|
|
|
if let Err(err) = write_res {
|
|
debug_log(&format!("parser::write failure: {err}"));
|
|
let _ = fs::remove_file(&tmp_path);
|
|
if backup_created && backup_path.exists() {
|
|
debug_log(&format!(
|
|
"parser::write restoring backup {} -> {}",
|
|
backup_path.display(),
|
|
path.display()
|
|
));
|
|
let _ = fs::copy(&backup_path, path);
|
|
}
|
|
return Err(err);
|
|
}
|
|
|
|
debug_log(&format!("parser::write success path={}", path.display()));
|
|
Ok(())
|
|
}
|
|
|
|
/// Update a specific key's value in the config lines.
|
|
pub fn set_value(config: &mut AnnotatedConfig, key: &str, value: ConfigValue) {
|
|
if let Some((line_idx, _)) = config.options.get(key).cloned() {
|
|
if let Some(line) = config.lines.get_mut(line_idx) {
|
|
let prior = extract_prior_value(line);
|
|
*line = line_from_value(key, &value, prior);
|
|
match &value {
|
|
ConfigValue::Absent => {
|
|
config.options.shift_remove(key);
|
|
}
|
|
ConfigValue::Disabled => {
|
|
config
|
|
.options
|
|
.insert(key.to_string(), (line_idx, ConfigValue::Disabled));
|
|
}
|
|
ConfigValue::Flag => {
|
|
config
|
|
.options
|
|
.insert(key.to_string(), (line_idx, ConfigValue::Flag));
|
|
}
|
|
ConfigValue::Value(v) => {
|
|
config
|
|
.options
|
|
.insert(key.to_string(), (line_idx, ConfigValue::Value(v.clone())));
|
|
}
|
|
}
|
|
config.dirty = true;
|
|
return;
|
|
}
|
|
}
|
|
|
|
match value {
|
|
ConfigValue::Absent => {
|
|
config.options.shift_remove(key);
|
|
}
|
|
ConfigValue::Flag | ConfigValue::Value(_) | ConfigValue::Disabled => {
|
|
let line = line_from_value(key, &value, None);
|
|
let idx = config.lines.len();
|
|
config.lines.push(line);
|
|
config.options.insert(key.to_string(), (idx, value));
|
|
config.dirty = true;
|
|
}
|
|
}
|
|
}
|
|
|
|
/// Serialize config to a string.
|
|
pub fn to_string(config: &AnnotatedConfig) -> String {
|
|
let mut out = String::new();
|
|
for line in &config.lines {
|
|
match line {
|
|
ConfigLine::Comment(raw) => out.push_str(raw),
|
|
ConfigLine::Blank => {}
|
|
ConfigLine::Option { key, value, .. } => {
|
|
out.push_str(key);
|
|
if let Some(v) = value {
|
|
out.push('=');
|
|
out.push_str(v);
|
|
}
|
|
}
|
|
ConfigLine::CommentedOption { key, value, .. } => {
|
|
out.push_str("# ");
|
|
out.push_str(key);
|
|
if let Some(v) = value {
|
|
out.push('=');
|
|
out.push_str(v);
|
|
}
|
|
}
|
|
}
|
|
out.push('\n');
|
|
}
|
|
out
|
|
}
|
|
|
|
pub fn move_option_before(
|
|
config: &mut AnnotatedConfig,
|
|
moving_key: &str,
|
|
anchor_key: &str,
|
|
) -> bool {
|
|
move_option_relative(config, moving_key, anchor_key, true)
|
|
}
|
|
|
|
pub fn move_option_after(
|
|
config: &mut AnnotatedConfig,
|
|
moving_key: &str,
|
|
anchor_key: &str,
|
|
) -> bool {
|
|
move_option_relative(config, moving_key, anchor_key, false)
|
|
}
|
|
|
|
pub fn move_option_group_before(
|
|
config: &mut AnnotatedConfig,
|
|
moving_keys: &[String],
|
|
anchor_keys: &[String],
|
|
) -> bool {
|
|
move_option_group_relative(config, moving_keys, anchor_keys, true)
|
|
}
|
|
|
|
pub fn move_option_group_after(
|
|
config: &mut AnnotatedConfig,
|
|
moving_keys: &[String],
|
|
anchor_keys: &[String],
|
|
) -> bool {
|
|
move_option_group_relative(config, moving_keys, anchor_keys, false)
|
|
}
|
|
}
|
|
|
|
fn remove_existing_path(path: &Path) -> Result<()> {
|
|
let metadata = fs::symlink_metadata(path)
|
|
.with_context(|| format!("failed to inspect existing backup {}", path.display()))?;
|
|
if metadata.is_dir() {
|
|
fs::remove_dir_all(path)
|
|
.with_context(|| format!("failed to remove backup directory {}", path.display()))?;
|
|
} else {
|
|
fs::remove_file(path)
|
|
.with_context(|| format!("failed to remove backup file {}", path.display()))?;
|
|
}
|
|
Ok(())
|
|
}
|
|
|
|
fn debug_log(message: &str) {
|
|
crate::debug_log::record(message);
|
|
}
|
|
|
|
fn move_option_relative(
|
|
config: &mut AnnotatedConfig,
|
|
moving_key: &str,
|
|
anchor_key: &str,
|
|
insert_before: bool,
|
|
) -> bool {
|
|
if moving_key == anchor_key {
|
|
return false;
|
|
}
|
|
let Some((moving_idx, _)) = config.options.get(moving_key).cloned() else {
|
|
return false;
|
|
};
|
|
let Some((anchor_idx, _)) = config.options.get(anchor_key).cloned() else {
|
|
return false;
|
|
};
|
|
if moving_idx >= config.lines.len() || anchor_idx >= config.lines.len() {
|
|
return false;
|
|
}
|
|
|
|
let moving_line = config.lines.remove(moving_idx);
|
|
let mut insertion_idx = anchor_idx;
|
|
if moving_idx < anchor_idx {
|
|
insertion_idx = insertion_idx.saturating_sub(1);
|
|
}
|
|
if !insert_before {
|
|
insertion_idx += 1;
|
|
}
|
|
insertion_idx = insertion_idx.min(config.lines.len());
|
|
config.lines.insert(insertion_idx, moving_line);
|
|
rebuild_option_index(config);
|
|
config.dirty = true;
|
|
true
|
|
}
|
|
|
|
fn move_option_group_relative(
|
|
config: &mut AnnotatedConfig,
|
|
moving_keys: &[String],
|
|
anchor_keys: &[String],
|
|
insert_before: bool,
|
|
) -> bool {
|
|
use std::collections::HashSet;
|
|
|
|
if moving_keys.is_empty() || anchor_keys.is_empty() {
|
|
return false;
|
|
}
|
|
|
|
let moving_set = moving_keys
|
|
.iter()
|
|
.map(String::as_str)
|
|
.collect::<HashSet<_>>();
|
|
let anchor_set = anchor_keys
|
|
.iter()
|
|
.map(String::as_str)
|
|
.collect::<HashSet<_>>();
|
|
if !moving_set.is_disjoint(&anchor_set) {
|
|
return false;
|
|
}
|
|
|
|
let mut moving_entries = moving_keys
|
|
.iter()
|
|
.filter_map(|key| {
|
|
config
|
|
.options
|
|
.get(key)
|
|
.cloned()
|
|
.map(|(line_idx, _)| (line_idx, key.clone()))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
let mut anchor_entries = anchor_keys
|
|
.iter()
|
|
.filter_map(|key| {
|
|
config
|
|
.options
|
|
.get(key)
|
|
.cloned()
|
|
.map(|(line_idx, _)| (line_idx, key.clone()))
|
|
})
|
|
.collect::<Vec<_>>();
|
|
|
|
if moving_entries.is_empty() || anchor_entries.is_empty() {
|
|
return false;
|
|
}
|
|
|
|
moving_entries.sort_by_key(|(idx, _)| *idx);
|
|
anchor_entries.sort_by_key(|(idx, _)| *idx);
|
|
|
|
let moving_indices = moving_entries
|
|
.iter()
|
|
.map(|(idx, _)| *idx)
|
|
.collect::<Vec<_>>();
|
|
let moving_lines = moving_indices
|
|
.iter()
|
|
.map(|idx| config.lines[*idx].clone())
|
|
.collect::<Vec<_>>();
|
|
|
|
for idx in moving_indices.iter().rev() {
|
|
config.lines.remove(*idx);
|
|
}
|
|
|
|
let anchor_target_idx = if insert_before {
|
|
anchor_entries.first().map(|(idx, _)| *idx).unwrap_or(0)
|
|
} else {
|
|
anchor_entries.last().map(|(idx, _)| *idx + 1).unwrap_or(0)
|
|
};
|
|
let removed_before_anchor = moving_indices
|
|
.iter()
|
|
.filter(|idx| **idx < anchor_target_idx)
|
|
.count();
|
|
let insertion_idx = anchor_target_idx
|
|
.saturating_sub(removed_before_anchor)
|
|
.min(config.lines.len());
|
|
|
|
for (offset, line) in moving_lines.into_iter().enumerate() {
|
|
config.lines.insert(insertion_idx + offset, line);
|
|
}
|
|
|
|
rebuild_option_index(config);
|
|
config.dirty = true;
|
|
true
|
|
}
|
|
|
|
fn rebuild_option_index(config: &mut AnnotatedConfig) {
|
|
let mut options: IndexMap<String, (usize, ConfigValue)> = IndexMap::new();
|
|
let mut duplicate_indices = Vec::new();
|
|
for (idx, line) in config.lines.iter().enumerate() {
|
|
let Some((key, value)) = option_state_from_line(line) else {
|
|
continue;
|
|
};
|
|
if let Some((old_idx, _)) = options.insert(key, (idx, value)) {
|
|
duplicate_indices.push(old_idx);
|
|
}
|
|
}
|
|
for old_idx in duplicate_indices {
|
|
if let Some(old_line) = config.lines.get_mut(old_idx) {
|
|
*old_line = ConfigLine::Blank;
|
|
}
|
|
}
|
|
config.options = options;
|
|
}
|
|
|
|
fn option_state_from_line(line: &ConfigLine) -> Option<(String, ConfigValue)> {
|
|
match line {
|
|
ConfigLine::Option { key, value, .. } => Some((
|
|
key.clone(),
|
|
match value {
|
|
Some(v) => ConfigValue::Value(v.clone()),
|
|
None => ConfigValue::Flag,
|
|
},
|
|
)),
|
|
ConfigLine::CommentedOption { key, .. } => Some((key.clone(), ConfigValue::Disabled)),
|
|
ConfigLine::Comment(_) | ConfigLine::Blank => None,
|
|
}
|
|
}
|
|
|
|
impl Default for Parser {
|
|
fn default() -> Self {
|
|
Self::new()
|
|
}
|
|
}
|
|
|
|
fn parse_option_candidate(line: &str) -> Option<(String, Option<String>)> {
|
|
if let Some((lhs, rhs)) = line.split_once('=') {
|
|
let key = lhs.trim();
|
|
if !KEY_RE.is_match(key) {
|
|
return None;
|
|
}
|
|
let value = rhs.trim().to_string();
|
|
return Some((key.to_string(), Some(value)));
|
|
}
|
|
|
|
let key = line.trim();
|
|
if KEY_RE.is_match(key) {
|
|
return Some((key.to_string(), None));
|
|
}
|
|
|
|
None
|
|
}
|
|
|
|
fn extract_prior_value(line: &ConfigLine) -> Option<String> {
|
|
match line {
|
|
ConfigLine::Option { value, .. } | ConfigLine::CommentedOption { value, .. } => {
|
|
value.clone()
|
|
}
|
|
ConfigLine::Comment(_) | ConfigLine::Blank => None,
|
|
}
|
|
}
|
|
|
|
fn line_from_value(key: &str, value: &ConfigValue, prior_value: Option<String>) -> ConfigLine {
|
|
match value {
|
|
ConfigValue::Flag => ConfigLine::Option {
|
|
key: key.to_string(),
|
|
value: None,
|
|
raw: key.to_string(),
|
|
},
|
|
ConfigValue::Value(v) => ConfigLine::Option {
|
|
key: key.to_string(),
|
|
value: Some(v.clone()),
|
|
raw: format!("{key}={v}"),
|
|
},
|
|
ConfigValue::Disabled if disabled_flag_requires_explicit_zero(key) => ConfigLine::Option {
|
|
key: key.to_string(),
|
|
value: Some("0".to_string()),
|
|
raw: format!("{key}=0"),
|
|
},
|
|
ConfigValue::Disabled | ConfigValue::Absent => {
|
|
let raw = match &prior_value {
|
|
Some(v) if !v.is_empty() => format!("# {key}={v}"),
|
|
_ => format!("# {key}"),
|
|
};
|
|
ConfigLine::CommentedOption {
|
|
key: key.to_string(),
|
|
value: prior_value,
|
|
raw,
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
pub fn flag_defaults_to_enabled(key: &str) -> bool {
|
|
matches!(
|
|
key,
|
|
"cpu_stats"
|
|
| "fps"
|
|
| "frame_timing"
|
|
| "frametime"
|
|
| "gpu_stats"
|
|
| "horizontal_stretch"
|
|
| "legacy_layout"
|
|
| "text_outline"
|
|
)
|
|
}
|
|
|
|
fn disabled_flag_requires_explicit_zero(key: &str) -> bool {
|
|
flag_defaults_to_enabled(key)
|
|
}
|
|
|
|
#[cfg(test)]
|
|
mod tests {
|
|
use super::*;
|
|
use crate::config::schema::get_schema_entry;
|
|
use crate::config::schema::MANGOHUD_SCHEMA;
|
|
use crate::config::types::{OptionType, ValidationResult};
|
|
use crate::config::validator;
|
|
use tempfile::tempdir;
|
|
|
|
#[test]
|
|
fn parse_all_line_types() {
|
|
let content = "# comment\n\nfps=60\nframetime\n# gpu_temp\n# cpu_color=FF0000\n";
|
|
let parsed = Parser::parse_str(content, None);
|
|
assert_eq!(parsed.lines.len(), 6);
|
|
assert_eq!(
|
|
parsed.options.get("fps").map(|v| &v.1),
|
|
Some(&ConfigValue::Value("60".into()))
|
|
);
|
|
assert_eq!(
|
|
parsed.options.get("frametime").map(|v| &v.1),
|
|
Some(&ConfigValue::Flag)
|
|
);
|
|
assert_eq!(
|
|
parsed.options.get("gpu_temp").map(|v| &v.1),
|
|
Some(&ConfigValue::Disabled)
|
|
);
|
|
assert_eq!(
|
|
parsed.options.get("cpu_color").map(|v| &v.1),
|
|
Some(&ConfigValue::Disabled)
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn round_trip_parse_write_parse_values_match() {
|
|
let dir = tempdir().expect("tempdir");
|
|
let path = dir.path().join("MangoHud.conf");
|
|
let original = "# header\nfps=60\n# gpu_temp\nframetime\n";
|
|
fs::write(&path, original).expect("write fixture");
|
|
|
|
let parsed = Parser::read(&path).expect("read");
|
|
Parser::write(&parsed).expect("write");
|
|
let reparsed = Parser::read(&path).expect("re-read");
|
|
|
|
assert_eq!(parsed.options, reparsed.options);
|
|
assert_eq!(parsed.lines, reparsed.lines);
|
|
}
|
|
|
|
#[test]
|
|
fn set_value_updates_existing_key_preserving_surrounding_lines() {
|
|
let mut cfg = Parser::parse_str("# top\nfps=60\n# bottom\n", None);
|
|
Parser::set_value(&mut cfg, "fps", ConfigValue::Value("120".into()));
|
|
let out = Parser::to_string(&cfg);
|
|
assert!(out.contains("# top\nfps=120\n# bottom\n"));
|
|
}
|
|
|
|
#[test]
|
|
fn set_value_adds_new_key_at_end() {
|
|
let mut cfg = Parser::parse_str("fps=60\n", None);
|
|
Parser::set_value(&mut cfg, "gpu_temp", ConfigValue::Flag);
|
|
let out = Parser::to_string(&cfg);
|
|
assert!(out.ends_with("fps=60\ngpu_temp\n"));
|
|
}
|
|
|
|
#[test]
|
|
fn set_value_disable_key_comments_it_out() {
|
|
let mut cfg = Parser::parse_str("gpu_temp\n", None);
|
|
Parser::set_value(&mut cfg, "gpu_temp", ConfigValue::Disabled);
|
|
let out = Parser::to_string(&cfg);
|
|
assert_eq!(out, "# gpu_temp\n");
|
|
}
|
|
|
|
#[test]
|
|
fn set_value_disable_horizontal_stretch_writes_explicit_zero() {
|
|
let mut cfg = Parser::parse_str("horizontal_stretch\n", None);
|
|
Parser::set_value(&mut cfg, "horizontal_stretch", ConfigValue::Disabled);
|
|
let out = Parser::to_string(&cfg);
|
|
assert_eq!(out, "horizontal_stretch=0\n");
|
|
}
|
|
|
|
#[test]
|
|
fn set_value_disable_default_on_flags_writes_explicit_zero() {
|
|
let mut cfg =
|
|
Parser::parse_str("fps\nframetime\nframe_timing\ngpu_stats\ncpu_stats\n", None);
|
|
Parser::set_value(&mut cfg, "fps", ConfigValue::Disabled);
|
|
Parser::set_value(&mut cfg, "frametime", ConfigValue::Disabled);
|
|
Parser::set_value(&mut cfg, "frame_timing", ConfigValue::Disabled);
|
|
Parser::set_value(&mut cfg, "gpu_stats", ConfigValue::Disabled);
|
|
Parser::set_value(&mut cfg, "cpu_stats", ConfigValue::Disabled);
|
|
let out = Parser::to_string(&cfg);
|
|
assert!(out.contains("fps=0\n"));
|
|
assert!(out.contains("frametime=0\n"));
|
|
assert!(out.contains("frame_timing=0\n"));
|
|
assert!(out.contains("gpu_stats=0\n"));
|
|
assert!(out.contains("cpu_stats=0\n"));
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_key_last_value_wins() {
|
|
let cfg = Parser::parse_str("fps=30\nfps=60\n", None);
|
|
assert_eq!(
|
|
cfg.options.get("fps").map(|v| &v.1),
|
|
Some(&ConfigValue::Value("60".into()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn duplicate_key_does_not_round_trip_old_line() {
|
|
let cfg = Parser::parse_str("fps=30\nfps=60\n", None);
|
|
assert_eq!(Parser::to_string(&cfg), "\nfps=60\n");
|
|
}
|
|
|
|
#[test]
|
|
fn trims_leading_and_trailing_value_whitespace() {
|
|
let cfg = Parser::parse_str("fps= 60 \n", None);
|
|
assert_eq!(
|
|
cfg.options.get("fps").map(|v| &v.1),
|
|
Some(&ConfigValue::Value("60".into()))
|
|
);
|
|
}
|
|
|
|
#[test]
|
|
fn write_recovers_when_stale_backup_path_is_a_directory() {
|
|
let dir = tempdir().expect("tempdir");
|
|
let path = dir.path().join("MangoHud.conf");
|
|
let backup = dir.path().join("MangoHud.conf.mangotune.bak");
|
|
|
|
fs::write(&path, "fps=60\n").expect("write config");
|
|
fs::create_dir(&backup).expect("create stale backup dir");
|
|
|
|
let parsed = Parser::read(&path).expect("read");
|
|
Parser::write(&parsed).expect("write with stale backup dir");
|
|
|
|
let written = fs::read_to_string(&path).expect("read output");
|
|
assert_eq!(written, "fps=60\n");
|
|
assert!(backup.is_file());
|
|
}
|
|
|
|
#[test]
|
|
fn move_option_before_reorders_lines() {
|
|
let mut cfg = Parser::parse_str("fps\ngpu_stats\ncpu_stats\n", None);
|
|
assert!(Parser::move_option_before(
|
|
&mut cfg,
|
|
"cpu_stats",
|
|
"gpu_stats"
|
|
));
|
|
assert_eq!(Parser::to_string(&cfg), "fps\ncpu_stats\ngpu_stats\n");
|
|
}
|
|
|
|
#[test]
|
|
fn move_option_after_reorders_lines() {
|
|
let mut cfg = Parser::parse_str("fps\ngpu_stats\ncpu_stats\n", None);
|
|
assert!(Parser::move_option_after(&mut cfg, "fps", "gpu_stats"));
|
|
assert_eq!(Parser::to_string(&cfg), "gpu_stats\nfps\ncpu_stats\n");
|
|
}
|
|
|
|
#[test]
|
|
fn utf8_comments_are_preserved() {
|
|
let cfg = Parser::parse_str("# Привет мир\nfps=60\n", None);
|
|
match &cfg.lines[0] {
|
|
ConfigLine::Comment(text) => assert_eq!(text, "# Привет мир"),
|
|
_ => panic!("first line should be comment"),
|
|
}
|
|
}
|
|
|
|
#[test]
|
|
fn full_schema_representative_round_trip_and_validate() {
|
|
let dir = tempdir().expect("tempdir");
|
|
let path = dir.path().join("MangoHud.conf");
|
|
let mut content = String::from("# generated-by-test\n");
|
|
|
|
for entry in MANGOHUD_SCHEMA.iter() {
|
|
if let Some(line) = representative_line_for_entry(entry, dir.path()) {
|
|
content.push_str(&line);
|
|
content.push('\n');
|
|
}
|
|
}
|
|
|
|
fs::write(&path, &content).expect("write fixture");
|
|
let parsed = Parser::read(&path).expect("read");
|
|
for (key, (_, value)) in &parsed.options {
|
|
if let Some(schema) = get_schema_entry(key) {
|
|
let result = validator::validate_value(key, value, schema);
|
|
assert!(
|
|
!matches!(result, ValidationResult::Error(_)),
|
|
"generated value should type-validate for key '{}': {:?}",
|
|
key,
|
|
result
|
|
);
|
|
}
|
|
}
|
|
|
|
Parser::write(&parsed).expect("write");
|
|
let reparsed = Parser::read(&path).expect("re-read");
|
|
assert_eq!(parsed.options, reparsed.options);
|
|
}
|
|
|
|
fn representative_line_for_entry(
|
|
entry: &crate::config::types::SchemaEntry,
|
|
temp_root: &std::path::Path,
|
|
) -> Option<String> {
|
|
let value = match entry.key {
|
|
"fps_metrics" | "benchmark_percentiles" => ConfigValue::Value("AVG,95".to_string()),
|
|
"time_format" => ConfigValue::Value("%H:%M:%S".to_string()),
|
|
"pci_dev" => ConfigValue::Value("0000:01:00.0".to_string()),
|
|
"ftrace" => ConfigValue::Value("histogram/foo/bar".to_string()),
|
|
"control" => ConfigValue::Value("-1".to_string()),
|
|
"fps_color" | "gpu_load_color" | "cpu_load_color" => {
|
|
ConfigValue::Value("FF4D4D,FFD24D,66FF99".to_string())
|
|
}
|
|
_ => match &entry.option_type {
|
|
OptionType::Flag => ConfigValue::Flag,
|
|
OptionType::Bool => ConfigValue::Value("1".to_string()),
|
|
OptionType::Int { min, .. } => ConfigValue::Value(min.to_string()),
|
|
OptionType::Float { min, .. } => ConfigValue::Value(min.to_string()),
|
|
OptionType::Str { .. } => ConfigValue::Value("sample".to_string()),
|
|
OptionType::Color => ConfigValue::Value("A1B2C3".to_string()),
|
|
OptionType::Enum { variants } => {
|
|
ConfigValue::Value(variants.first().cloned().unwrap_or_default())
|
|
}
|
|
OptionType::FpsLimitList => ConfigValue::Value("0,60,120".to_string()),
|
|
OptionType::KeyBind => ConfigValue::Value("Shift_R+F12".to_string()),
|
|
OptionType::CommaSepInts => ConfigValue::Value("1,2,3".to_string()),
|
|
OptionType::CommaSepFloats => ConfigValue::Value("0.5,1.5".to_string()),
|
|
OptionType::CommaSepStrings { valid_values } => {
|
|
let value = valid_values
|
|
.as_ref()
|
|
.and_then(|values| values.first().cloned())
|
|
.unwrap_or_else(|| "sample".to_string());
|
|
ConfigValue::Value(value)
|
|
}
|
|
OptionType::Path {
|
|
must_exist,
|
|
must_be_writable: _,
|
|
} => {
|
|
let path = if *must_exist {
|
|
temp_root.to_path_buf()
|
|
} else {
|
|
temp_root.join("generated-path")
|
|
};
|
|
ConfigValue::Value(path.display().to_string())
|
|
}
|
|
},
|
|
};
|
|
|
|
match value {
|
|
ConfigValue::Flag => Some(entry.key.to_string()),
|
|
ConfigValue::Value(v) => Some(format!("{}={v}", entry.key)),
|
|
ConfigValue::Absent | ConfigValue::Disabled => None,
|
|
}
|
|
}
|
|
}
|