Initial import
This commit is contained in:
@@ -0,0 +1,776 @@
|
||||
//! 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,
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user