Files
mangotune/src/config/parser.rs
T
2026-03-30 23:06:06 -04:00

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,
}
}
}