//! 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 = 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 { 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) -> AnnotatedConfig { let mut lines = Vec::new(); let mut options: IndexMap = 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::>(); let anchor_set = anchor_keys .iter() .map(String::as_str) .collect::>(); 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::>(); let mut anchor_entries = anchor_keys .iter() .filter_map(|key| { config .options .get(key) .cloned() .map(|(line_idx, _)| (line_idx, key.clone())) }) .collect::>(); 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::>(); let moving_lines = moving_indices .iter() .map(|idx| config.lines[*idx].clone()) .collect::>(); 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 = 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)> { 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 { 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) -> 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 { 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, } } }