feat: hook reliability, hook-errors setting, sparse config, and logging

Hook reliability:
- Add hook-errors = "warn" | "fail" setting (default: warn); in fail
  mode, abort launch when pre-launch hook exits nonzero or can't execute
- Ensure post-launch hook runs unconditionally, even when execute_wait()
  fails to spawn the game
- Propagate game's real exit status via std::process::exit(); report
  post-hook failures clearly to stderr
- Centralize hook execution via run_hook() helper (sh -c)

New features in this batch:
- Sparse config and profile support: only configured fields are written;
  unset fields fall back through profile → global chain
- config show --effective flag: renders the fully-resolved view
- Config migration: upgrades legacy flat config to current schema
- Structured decision logging (src/log.rs) for session-level audit trail
- Gamescope improvements: additional flags and validation
- CHANGELOG.md tracking template releases

Schema / UX:
- HookErrors enum (Warn/Fail) added to Settings and ResolvedSettings
- hook-errors key in keys.rs, mod.rs rendering, completion candidates,
  doctor output, help text, README, and dry-run display
- 9 focused tests covering warn/fail behavior, exit propagation,
  round-trip (set/show/reset), profile round-trip, export/import

Co-Authored-By: claude-flow <ruv@ruv.net>
This commit is contained in:
2026-06-14 09:35:24 -04:00
parent a2364b1692
commit f984acf0e3
21 changed files with 4070 additions and 1105 deletions
+216 -15
View File
@@ -1,48 +1,89 @@
use crate::config::{GameLibsMode, Settings};
use crate::config::{
GameLibsMode, GamescopeFilter, GamescopeScaler, GamescopeSize, GamescopeWindowMode, Settings,
VkbasaltLogLevel,
};
use crate::error::{AppError, config_error};
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum SettingKey {
Overlay,
Performance,
Mangohud,
Gamemode,
SteamHostLibs,
GameLibs,
Verbose,
LogFile,
LogPath,
Gamescope,
GamescopeWidth,
GamescopeHeight,
GamescopeFps,
GamescopeNestedWidth,
GamescopeNestedHeight,
GamescopeUnfocusedFps,
GamescopeScaler,
GamescopeFilter,
GamescopeSharpness,
GamescopeMode,
GamescopeAdaptiveSync,
GamescopeHdr,
GamescopeSteam,
GamescopeExposeWayland,
GamescopeMangoapp,
FpsCap,
MangohudLog,
MangohudLogPath,
Vkbasalt,
VkbasaltConfig,
VkbasaltLogLevel,
Esync,
Fsync,
LargeAddressAware,
PreLaunch,
PostLaunch,
HookErrors,
}
impl SettingKey {
pub fn parse(value: &str) -> Result<Self, AppError> {
match value {
"overlay" => Ok(Self::Overlay),
"performance" => Ok(Self::Performance),
"mangohud" => Ok(Self::Mangohud),
"gamemode" => Ok(Self::Gamemode),
"steam-host-libs" | "host-libs" => Ok(Self::SteamHostLibs),
"game-libs" => Ok(Self::GameLibs),
"verbose" => Ok(Self::Verbose),
"log-file" => Ok(Self::LogFile),
"log-path" => Ok(Self::LogPath),
"gamescope" => Ok(Self::Gamescope),
"gamescope-width" => Ok(Self::GamescopeWidth),
"gamescope-height" => Ok(Self::GamescopeHeight),
"gamescope-fps" => Ok(Self::GamescopeFps),
"gamescope-nested-width" => Ok(Self::GamescopeNestedWidth),
"gamescope-nested-height" => Ok(Self::GamescopeNestedHeight),
"gamescope-unfocused-fps" => Ok(Self::GamescopeUnfocusedFps),
"gamescope-scaler" => Ok(Self::GamescopeScaler),
"gamescope-filter" => Ok(Self::GamescopeFilter),
"gamescope-sharpness" => Ok(Self::GamescopeSharpness),
"gamescope-mode" => Ok(Self::GamescopeMode),
"gamescope-adaptive-sync" => Ok(Self::GamescopeAdaptiveSync),
"gamescope-hdr" => Ok(Self::GamescopeHdr),
"gamescope-steam" => Ok(Self::GamescopeSteam),
"gamescope-expose-wayland" => Ok(Self::GamescopeExposeWayland),
"gamescope-mangoapp" => Ok(Self::GamescopeMangoapp),
"fps-cap" => Ok(Self::FpsCap),
"mangohud-log" => Ok(Self::MangohudLog),
"mangohud-log-path" => Ok(Self::MangohudLogPath),
"vkbasalt" => Ok(Self::Vkbasalt),
"vkbasalt-config" => Ok(Self::VkbasaltConfig),
"vkbasalt-log-level" => Ok(Self::VkbasaltLogLevel),
"esync" => Ok(Self::Esync),
"fsync" => Ok(Self::Fsync),
"large-address-aware" | "laa" => Ok(Self::LargeAddressAware),
"pre-launch" => Ok(Self::PreLaunch),
"post-launch" => Ok(Self::PostLaunch),
"hook-errors" => Ok(Self::HookErrors),
_ => Err(config_error(
format!("`{value}` is not a known setting."),
"Valid settings: overlay, performance, steam-host-libs, game-libs, verbose, gamescope, gamescope-width, gamescope-height, gamescope-fps, fps-cap, vkbasalt, esync, fsync, large-address-aware, pre-launch, post-launch.",
"Run `gamewrap help settings` to see all available settings.",
)),
}
}
@@ -50,20 +91,58 @@ impl SettingKey {
pub fn set_value(settings: &mut Settings, key: SettingKey, value: &str) -> Result<(), AppError> {
match key {
SettingKey::Overlay => settings.overlay = Some(parse_toggle(value)?),
SettingKey::Performance => settings.performance = Some(parse_toggle(value)?),
SettingKey::Mangohud => settings.mangohud = Some(parse_toggle(value)?),
SettingKey::Gamemode => settings.gamemode = Some(parse_toggle(value)?),
SettingKey::SteamHostLibs => settings.steam_host_libs = Some(parse_toggle(value)?),
SettingKey::GameLibs => settings.game_libs = Some(parse_game_libs(value)?),
SettingKey::Verbose => settings.verbose = Some(parse_toggle(value)?),
SettingKey::LogFile => settings.log_file = Some(parse_toggle(value)?),
SettingKey::LogPath => settings.log_path = Some(value.to_string()),
SettingKey::Gamescope => settings.gamescope = Some(parse_toggle(value)?),
SettingKey::GamescopeWidth => {
settings.gamescope_width = Some(parse_pixel_count(value)?);
settings.gamescope_width = Some(parse_gamescope_size(value)?);
}
SettingKey::GamescopeHeight => {
settings.gamescope_height = Some(parse_pixel_count(value)?);
settings.gamescope_height = Some(parse_gamescope_size(value)?);
}
SettingKey::GamescopeFps => {
settings.gamescope_fps = Some(parse_gamescope_fps(value)?);
settings.gamescope_fps = Some(parse_fps(value, "gamescope-fps")?);
}
SettingKey::GamescopeNestedWidth => {
settings.gamescope_nested_width = Some(parse_pixel_count(value)?);
}
SettingKey::GamescopeNestedHeight => {
settings.gamescope_nested_height = Some(parse_pixel_count(value)?);
}
SettingKey::GamescopeUnfocusedFps => {
settings.gamescope_unfocused_fps = Some(parse_fps(value, "gamescope-unfocused-fps")?);
}
SettingKey::GamescopeScaler => {
settings.gamescope_scaler = Some(parse_gamescope_scaler(value)?);
}
SettingKey::GamescopeFilter => {
settings.gamescope_filter = Some(parse_gamescope_filter(value)?);
}
SettingKey::GamescopeSharpness => {
settings.gamescope_sharpness = Some(parse_sharpness(value)?);
}
SettingKey::GamescopeMode => {
settings.gamescope_window_mode = Some(parse_gamescope_window_mode(value)?);
}
SettingKey::GamescopeAdaptiveSync => {
settings.gamescope_adaptive_sync = Some(parse_toggle(value)?);
}
SettingKey::GamescopeHdr => {
settings.gamescope_hdr = Some(parse_toggle(value)?);
}
SettingKey::GamescopeSteam => {
settings.gamescope_steam = Some(parse_toggle(value)?);
}
SettingKey::GamescopeExposeWayland => {
settings.gamescope_expose_wayland = Some(parse_toggle(value)?);
}
SettingKey::GamescopeMangoapp => {
settings.gamescope_mangoapp = Some(parse_toggle(value)?);
}
SettingKey::FpsCap => {
let n: u32 = value.parse().map_err(|_| {
@@ -74,34 +153,60 @@ pub fn set_value(settings: &mut Settings, key: SettingKey, value: &str) -> Resul
})?;
settings.fps_cap = Some(n);
}
SettingKey::MangohudLog => settings.mangohud_log = Some(parse_toggle(value)?),
SettingKey::MangohudLogPath => settings.mangohud_log_path = Some(value.to_string()),
SettingKey::Vkbasalt => settings.vkbasalt = Some(parse_toggle(value)?),
SettingKey::VkbasaltConfig => settings.vkbasalt_config = Some(value.to_string()),
SettingKey::VkbasaltLogLevel => {
settings.vkbasalt_log_level = Some(parse_vkbasalt_log_level(value)?);
}
SettingKey::Esync => settings.esync = Some(parse_toggle(value)?),
SettingKey::Fsync => settings.fsync = Some(parse_toggle(value)?),
SettingKey::LargeAddressAware => settings.large_address_aware = Some(parse_toggle(value)?),
SettingKey::PreLaunch => settings.pre_launch = Some(value.to_string()),
SettingKey::PostLaunch => settings.post_launch = Some(value.to_string()),
SettingKey::HookErrors => settings.hook_errors = Some(parse_hook_errors(value)?),
}
Ok(())
}
pub fn reset_value(settings: &mut Settings, key: SettingKey) {
match key {
SettingKey::Overlay => settings.overlay = None,
SettingKey::Performance => settings.performance = None,
SettingKey::Mangohud => settings.mangohud = None,
SettingKey::Gamemode => settings.gamemode = None,
SettingKey::SteamHostLibs => settings.steam_host_libs = None,
SettingKey::GameLibs => settings.game_libs = None,
SettingKey::Verbose => settings.verbose = None,
SettingKey::LogFile => settings.log_file = None,
SettingKey::LogPath => settings.log_path = None,
SettingKey::Gamescope => settings.gamescope = None,
SettingKey::GamescopeWidth => settings.gamescope_width = None,
SettingKey::GamescopeHeight => settings.gamescope_height = None,
SettingKey::GamescopeFps => settings.gamescope_fps = None,
SettingKey::GamescopeNestedWidth => settings.gamescope_nested_width = None,
SettingKey::GamescopeNestedHeight => settings.gamescope_nested_height = None,
SettingKey::GamescopeUnfocusedFps => settings.gamescope_unfocused_fps = None,
SettingKey::GamescopeScaler => settings.gamescope_scaler = None,
SettingKey::GamescopeFilter => settings.gamescope_filter = None,
SettingKey::GamescopeSharpness => settings.gamescope_sharpness = None,
SettingKey::GamescopeMode => settings.gamescope_window_mode = None,
SettingKey::GamescopeAdaptiveSync => settings.gamescope_adaptive_sync = None,
SettingKey::GamescopeHdr => settings.gamescope_hdr = None,
SettingKey::GamescopeSteam => settings.gamescope_steam = None,
SettingKey::GamescopeExposeWayland => settings.gamescope_expose_wayland = None,
SettingKey::GamescopeMangoapp => settings.gamescope_mangoapp = None,
SettingKey::FpsCap => settings.fps_cap = None,
SettingKey::MangohudLog => settings.mangohud_log = None,
SettingKey::MangohudLogPath => settings.mangohud_log_path = None,
SettingKey::Vkbasalt => settings.vkbasalt = None,
SettingKey::VkbasaltConfig => settings.vkbasalt_config = None,
SettingKey::VkbasaltLogLevel => settings.vkbasalt_log_level = None,
SettingKey::Esync => settings.esync = None,
SettingKey::Fsync => settings.fsync = None,
SettingKey::LargeAddressAware => settings.large_address_aware = None,
SettingKey::PreLaunch => settings.pre_launch = None,
SettingKey::PostLaunch => settings.post_launch = None,
SettingKey::HookErrors => settings.hook_errors = None,
}
}
@@ -128,6 +233,91 @@ fn parse_game_libs(value: &str) -> Result<GameLibsMode, AppError> {
}
}
fn parse_vkbasalt_log_level(value: &str) -> Result<VkbasaltLogLevel, AppError> {
match value {
"debug" => Ok(VkbasaltLogLevel::Debug),
"info" => Ok(VkbasaltLogLevel::Info),
"warning" => Ok(VkbasaltLogLevel::Warning),
"error" => Ok(VkbasaltLogLevel::Error),
"none" => Ok(VkbasaltLogLevel::None),
_ => Err(config_error(
format!("`{value}` is not a valid vkBasalt log level."),
"Use one of: debug, info, warning, error, none.",
)),
}
}
fn parse_gamescope_scaler(value: &str) -> Result<GamescopeScaler, AppError> {
match value {
"auto" => Ok(GamescopeScaler::Auto),
"integer" => Ok(GamescopeScaler::Integer),
"fit" => Ok(GamescopeScaler::Fit),
"fill" => Ok(GamescopeScaler::Fill),
"stretch" => Ok(GamescopeScaler::Stretch),
_ => Err(config_error(
format!("`{value}` is not a valid gamescope scaler."),
"Use one of: auto, integer, fit, fill, stretch.",
)),
}
}
fn parse_gamescope_filter(value: &str) -> Result<GamescopeFilter, AppError> {
match value {
"linear" => Ok(GamescopeFilter::Linear),
"nearest" => Ok(GamescopeFilter::Nearest),
"fsr" => Ok(GamescopeFilter::Fsr),
"nis" => Ok(GamescopeFilter::Nis),
"pixel" => Ok(GamescopeFilter::Pixel),
_ => Err(config_error(
format!("`{value}` is not a valid gamescope filter."),
"Use one of: linear, nearest, fsr, nis, pixel.",
)),
}
}
fn parse_gamescope_size(value: &str) -> Result<GamescopeSize, AppError> {
if value == "native" {
return Ok(GamescopeSize::Native);
}
let n: u32 = value.parse().map_err(|_| {
config_error(
format!(
"`{value}` is not a valid resolution. Use a pixel count like `1920` or `native`."
),
"Use a whole-number pixel count or `native` to auto-detect the display resolution.",
)
})?;
Ok(GamescopeSize::Pixels(n))
}
fn parse_gamescope_window_mode(value: &str) -> Result<GamescopeWindowMode, AppError> {
match value {
"windowed" => Ok(GamescopeWindowMode::Windowed),
"borderless" => Ok(GamescopeWindowMode::Borderless),
"fullscreen" => Ok(GamescopeWindowMode::Fullscreen),
_ => Err(config_error(
format!("`{value}` is not a valid window mode."),
"Use one of: windowed, borderless, fullscreen.",
)),
}
}
fn parse_sharpness(value: &str) -> Result<u8, AppError> {
let n: u8 = value.parse().map_err(|_| {
config_error(
format!("`{value}` is not a valid sharpness value. Use a number from 0 to 20."),
"0 is maximum sharpness, 20 is minimum sharpness.",
)
})?;
if n > 20 {
return Err(config_error(
format!("`{value}` is out of range. Sharpness must be 020."),
"0 is maximum sharpness, 20 is minimum sharpness.",
));
}
Ok(n)
}
fn parse_pixel_count(value: &str) -> Result<u32, AppError> {
value.parse::<u32>().map_err(|_| {
config_error(
@@ -137,11 +327,22 @@ fn parse_pixel_count(value: &str) -> Result<u32, AppError> {
})
}
fn parse_gamescope_fps(value: &str) -> Result<u32, AppError> {
fn parse_fps(value: &str, setting: &str) -> Result<u32, AppError> {
value.parse::<u32>().map_err(|_| {
config_error(
format!("`{value}` is not a valid FPS. Use a number like `60`."),
"Use `gamewrap config set gamescope-fps 60` for a 60 FPS gamescope target.",
format!("Use `gamewrap config set {setting} 60` for a 60 FPS target."),
)
})
}
fn parse_hook_errors(value: &str) -> Result<crate::config::HookErrors, AppError> {
match value {
"warn" => Ok(crate::config::HookErrors::Warn),
"fail" => Ok(crate::config::HookErrors::Fail),
_ => Err(config_error(
format!("`{value}` is not a valid hook-errors value."),
"Use `warn` (log and continue) or `fail` (abort launch on hook failure).",
)),
}
}