From f984acf0e39b84aabe56ff5b079cfff8ed266c24 Mon Sep 17 00:00:00 2001 From: 44r0n7 <44r0n7+gitea@pm.me> Date: Sun, 14 Jun 2026 09:35:24 -0400 Subject: [PATCH] feat: hook reliability, hook-errors setting, sparse config, and logging MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- CHANGELOG.md | 10 + Cargo.lock | 2 +- PROJECT_MAP.md | 48 +- README.md | 61 ++- docs/roadmap.md | 98 +++- src/cli.rs | 688 ++++++++++++++++++------- src/completion.rs | 267 ++++++++-- src/config/keys.rs | 231 ++++++++- src/config/migrate.rs | 123 +++++ src/config/mod.rs | 742 +++++++++++++++++++++------ src/config/schema.rs | 376 +++++++++++++- src/doctor.rs | 108 +++- src/env.rs | 105 +++- src/help.rs | 315 ++++++++++-- src/launch.rs | 234 +++++++-- src/lib.rs | 1 + src/log.rs | 74 +++ src/profile.rs | 209 +------- src/share.rs | 152 +++--- src/status.rs | 191 +++++-- tests/cli_matrix.rs | 1140 +++++++++++++++++++++++++++++++++++------ 21 files changed, 4070 insertions(+), 1105 deletions(-) create mode 100644 CHANGELOG.md create mode 100644 src/config/migrate.rs create mode 100644 src/log.rs diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..15003a8 --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,10 @@ +## v2.0.0 (unreleased) + +### Breaking changes +- Profile-to-profile inheritance removed. Profiles are now flat game-specific overrides applied directly on top of global defaults. +- `profile tree`, `profile inherit`, and `profile clear-inherit` commands removed. +- `profile export` now exports only explicitly configured settings (sparse), not fully resolved settings. Old exports with all settings baked in can still be imported. +- Settings renamed: `overlay` → `mangohud`, `performance` → `gamemode` (from prior work). + +### Migration +If upgrading from v1: run `gamewrap config migrate` to update renamed setting keys. Profile files exported with v1 can still be imported but will include all resolved settings — re-export after import to get the sparse format. diff --git a/Cargo.lock b/Cargo.lock index 956b5fd..1e5e659 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -179,7 +179,7 @@ checksum = "d9c4f5dac5e15c24eb999c26181a6ca40b39fe946cbe4c263c7209467bc83af2" [[package]] name = "gamewrap" -version = "0.3.0" +version = "1.0.0" dependencies = [ "clap", "clap_complete", diff --git a/PROJECT_MAP.md b/PROJECT_MAP.md index e91654a..fbf15e2 100644 --- a/PROJECT_MAP.md +++ b/PROJECT_MAP.md @@ -37,17 +37,19 @@ Representative flow: | `src/config/mod.rs` | Config/state I/O | XDG path discovery, TOML load/save, CLI rendering of config/profile views. | | `src/config/schema.rs` | Persistent structs | `Settings`, `ResolvedSettings`, `ProfileConfig`, `ConfigFile`, `ObservedGame`, `StateFile`. | | `src/config/keys.rs` | Setting mutation helpers | Friendly key parsing and value application/reset logic. | -| `src/profile.rs` | Resolution + validation | Resolves inherited profiles and validates parent chains/binding targets. | +| `src/config/migrate.rs` | Config migration logic | Renames retired setting keys in raw config/profile TOML before typed deserialization. | +| `src/profile.rs` | Resolution + validation | Applies global defaults plus one named profile and validates binding targets. | | `src/bindings.rs` | Binding operations | Matching logic, set/remove binding, resolve profile for executable/observed game. | | `src/detect.rs` | Executable detection | Pulls real game executable out of Steam/Proton wrapper shapes and records observed launches. | | `src/launch.rs` | Launch planning/execution | Dependency checks, dry-run rendering, final command execution. | | `src/env.rs` | Env shaping | Steam-context detection, host-library injection decisions, env var construction. | +| `src/log.rs` | Decision log | Best-effort timestamped launch, hook, exit, and playtime logging. | | `src/doctor.rs` | Doctor rendering | Formats preflight results. | | `src/status.rs` | Status rendering | Formats dependency/config/state summary. | | `src/help.rs` | Long-form help text | Shared topic help and top-level examples. | | `src/error.rs` | Error model | Exit-code-bearing error categories and constructors. | | `src/notify.rs` | GUI failure notifier | `zenity` / `kdialog` / `xmessage` / `notify-send` selection and popup logic. | -| `src/share.rs` | Share/import formats | Resolved full-config and single-profile portable TOML formats. | +| `src/share.rs` | Share/import formats | Sparse full-config and single-profile portable TOML formats. | | `tests/cli_matrix.rs` | Integration CLI coverage | End-to-end command validation in isolated XDG temp envs. | Skip `target/` and other generated artifacts when extending this map. @@ -89,12 +91,12 @@ Skip `target/` and other generated artifacts when extending this map. ## 🔑 Key Concepts & Domain Terms - **Defaults**: global baseline settings from config -- **Profile**: reusable settings bundle; may inherit from another named profile +- **Profile**: reusable game-specific settings bundle applied directly over global defaults - **Binding**: matcher string mapping a known executable/path to a profile - **Observed game**: recorded launch entry with executable, path, last launched profile, optional note/display name, launch count, and last launch timestamp -- **Resolved settings**: effective values after applying defaults + inheritance + explicit overrides +- **Resolved settings**: effective values after applying global defaults + explicit profile overrides - **Steam context**: environment where implicit launch mode and GUI failure notifications are enabled -- **Share format**: portable resolved export file, distinct from the sparse internal config layout +- **Share format**: portable sparse export matching the internal defaults/profile layout --- @@ -103,18 +105,20 @@ Skip `target/` and other generated artifacts when extending this map. ### Internal local files - Config path: `~/.config/gamewrap/config.toml` - State path: `~/.local/state/gamewrap/state.toml` +- Default decision log path: `~/.local/state/gamewrap/gamewrap.log` when `log-file` is enabled - Config file is sparse/raw: - stores only explicit overrides - - profiles may be empty and may inherit from parents + - profiles may be empty and always apply directly over global defaults - bindings are stored separately + - gamescope output width/height accept an integer or `native`; window mode is one of `windowed`, `borderless`, or `fullscreen` ### Portable share files - Full config export suffix: `.gamewrap.toml` - Profile export suffix: `.gamewrap-profile.toml` -- Export commands write resolved values, not sparse internal overrides -- Profile import creates a standalone explicit profile with no inheritance -- Per-profile env overrides are stored as an optional `env-vars` map on profile settings and merge through inheritance; child keys override parent keys -- Pre/post launch hook commands are stored as optional string settings; pre-launch runs before exec, while post-launch is persisted/displayed but deferred until wrapped launching exists +- Export commands preserve sparse defaults and profile overrides +- Profile import preserves explicitly configured settings; unset values use the receiving config's global defaults +- Per-profile env overrides are stored as an optional `env-vars` map on profile settings and apply over global env defaults +- Pre/post launch hook commands are stored as optional string settings; setting post-launch switches execution to spawn/wait so the hook can run after the game exits - Config import accepts: - current resolved share format - legacy raw config format as fallback @@ -137,29 +141,28 @@ Skip `target/` and other generated artifacts when extending this map. - `completion` ### `config` -- `show` +- `show [--effective]` - `edit` - `set ` -- `reset ` +- `reset ` / `reset --all` - `export [name-or-path]` - `import ` +- `migrate` ### `profile` - `list` -- `tree` - `create ` - `duplicate ` - `show ` - `export [name-or-path]` - `import ` +- `migrate [--dry-run]` - `env set ` - `env unset ` - `env list ` - `env clear ` - `set ` - `reset ` -- `inherit ` -- `clear-inherit ` - `delete ` ### `game` @@ -190,14 +193,14 @@ If you change: - command surface -> update `tests/cli_matrix.rs` - share/import/export formats -> update `tests/cli_matrix.rs` and `src/share.rs` tests - completion install/candidates -> update `tests/cli_matrix.rs` and `src/completion.rs` tests -- profile inheritance logic -> update `src/profile.rs` tests and related CLI matrix cases +- profile resolution logic -> update `src/profile.rs` tests and related CLI matrix cases --- ## 🧩 Feature Areas & Ownership Map - **CLI / UX**: `src/cli.rs`, `src/help.rs`, `README.md` - **Persistence**: `src/config/*`, `src/share.rs` -- **Profiles / inheritance**: `src/profile.rs` +- **Profiles / resolution**: `src/profile.rs` - **Game observation / matching / bindings**: `src/detect.rs`, `src/bindings.rs` - **Launch execution**: `src/launch.rs`, `src/env.rs` - **Diagnostics**: `src/doctor.rs`, `src/status.rs` @@ -235,6 +238,17 @@ Do not update this file for: - [2026-05-31] Added TTY/NO_COLOR-aware ANSI output helpers for doctor and status rendering. - [2026-05-31] Added game forget, config edit, profile list/tree improvements, and notify test commands. - [2026-05-31] Added gamescope settings, launch wrapping, dependency diagnostics, completion candidates, and docs. +- [2026-06-06] Removed retired setting aliases and added automatic and explicit TOML migration for configs and profile exports. - [2026-05-31] Added observed-game display names, launch timestamps/counts, `last`, `game rename`, and `fps-cap`. - [2026-05-31] Added per-profile env overrides, vkBasalt support, and Proton esync/fsync compatibility controls. - [2026-05-31] Added pre-launch shell hooks plus persisted/dry-run/doctor visibility for deferred post-launch hooks. +- [2026-06-05] Expanded gamescope config surface: nested-width/height, unfocused-fps, scaler (-S), filter (-F), sharpness, fullscreen, borderless, adaptive-sync, hdr, steam, expose-wayland. Added gamescope-mangoapp for native MangoHud overlay integration (--mangoapp flag); doctor warns when mangohud prefix is used inside gamescope without mangoapp. +- [2026-06-06] Added optional vkbasalt-config profile/default setting and gated VKBASALT_CONFIG_FILE launch environment support. +- [2026-06-06] Replaced gamescope fullscreen/borderless toggles with `gamescope-mode` and added native display resolution detection for output width/height. +- [2026-06-06] Clarified unset values in `config show` and added `config reset --all` for restoring built-in defaults. +- [2026-06-06] Added gamewrap decision logging plus MangoHud session logging and vkBasalt log-level settings. +- [2026-06-06] Made `config show` display only explicitly configured defaults, with `--effective` for the full computed view. +- [2026-06-06] Added --effective flag to `profile show` and profile sections of `config show`; default now shows only explicitly configured profile settings. +- [2026-06-06] Renamed the MangoHud/GameMode settings to `mangohud`/`gamemode` with legacy aliases, and grouped settings help with per-tool filters. +- [2026-06-06] Removed profile inheritance, flattened resolution to defaults plus one profile, and made config/profile exports sparse. +- [2026-06-14] Added hook-errors setting (warn/fail) to control pre-launch hook failure behavior; ensured post-hook runs after game spawn failures; propagated game exit codes; centralized hook execution via run_hook(). diff --git a/README.md b/README.md index b5f8865..4805194 100644 --- a/README.md +++ b/README.md @@ -19,10 +19,10 @@ gamewrap %command% ## Features - Short Steam launch options -- Friendly setting names like `overlay` and `performance` +- Friendly setting names like `mangohud` and `gamemode` - Persistent config outside the Steam UI - Named profiles for reusable setups -- Profile inheritance for layered setups +- Flat game-specific profiles layered on global defaults - Game-specific profile binding through `game bind` - Quick access to the last played game through `gamewrap last` - Play time and launch count tracking per game @@ -32,7 +32,6 @@ gamewrap %command% - Config export/import for backup and sharing - Direct config editing through `config edit` - Profile export/import for sharing one setup at a time -- Profile tree view for inheritance checks - Per-profile environment variable overrides - gamescope Wayland compositor integration - FPS cap through MangoHud @@ -97,13 +96,13 @@ $HOME/.cargo/bin/gamewrap %command% 2. Turn MangoHud on by default: ```bash -gamewrap config set overlay on +gamewrap config set mangohud on ``` 3. Turn GameMode on by default: ```bash -gamewrap config set performance on +gamewrap config set gamemode on ``` 4. Check your setup before launching a real game: @@ -133,13 +132,11 @@ gamewrap profile create benchmark gamewrap game bind "Game.exe" benchmark ``` -8. Layer one profile on top of another when you want shared defaults: +8. Add game-specific overrides while keeping shared values global: ```bash -gamewrap profile create base -gamewrap profile set base overlay on +gamewrap config set mangohud on gamewrap profile create benchmark -gamewrap profile inherit benchmark base gamewrap profile set benchmark verbose on ``` @@ -181,6 +178,8 @@ gamewrap run -- /path/to/game/executable ```bash gamewrap --help gamewrap help settings +gamewrap help settings gamescope +gamewrap help settings mangohud gamewrap help doctor gamewrap game list gamewrap game list "elden" @@ -196,20 +195,23 @@ gamewrap completion zsh gamewrap completion install zsh gamewrap completion path zsh gamewrap config show +gamewrap config show --effective gamewrap config edit +gamewrap config reset --all +gamewrap config migrate gamewrap config export shared gamewrap config import shared gamewrap last gamewrap profile list -gamewrap profile tree gamewrap profile create benchmark +gamewrap profile show benchmark +gamewrap profile show benchmark --effective gamewrap profile duplicate benchmark benchmark-copy -gamewrap profile inherit benchmark base -gamewrap profile clear-inherit benchmark gamewrap profile export benchmark benchmark gamewrap profile import benchmark -gamewrap profile set benchmark overlay on -gamewrap profile reset benchmark overlay +gamewrap profile migrate old-benchmark +gamewrap profile set benchmark mangohud on +gamewrap profile reset benchmark mangohud gamewrap profile env set benchmark DXVK_ASYNC 1 gamewrap profile env list benchmark gamewrap profile env unset benchmark DXVK_ASYNC @@ -222,15 +224,20 @@ gamewrap game clear-note "eldenring.exe" ## Friendly Settings -- `overlay`: turns MangoHud on or off -- `performance`: turns GameMode on or off +- `mangohud`: turns MangoHud on or off +- `gamemode`: turns GameMode on or off - `steam-host-libs`: prefers host libraries inside Steam runtime environments and sets `STEAM_RUNTIME_PREFER_HOST_LIBRARIES=1` - `game-libs`: controls whether `gamewrap` injects auto-detected host library directories into `LD_LIBRARY_PATH` - `verbose`: shows more detail in diagnostic commands +- `log-file`, `log-path`: append gamewrap launch decisions to the default state log or a custom path - `gamescope`: wraps the game in the gamescope Wayland compositor -- `gamescope-width`, `gamescope-height`, `gamescope-fps`: pass `-W`, `-H`, and `-r` values to gamescope when `gamescope` is on -- `fps-cap`: caps frame rate through MangoHud when `overlay` is on, for example `gamewrap config set fps-cap 60` +- `gamescope-width`, `gamescope-height`: set output resolution with a pixel count or `native`; unset uses gamescope's 1280x720 default +- `gamescope-mode`: selects `windowed`, `borderless`, or `fullscreen` +- `gamescope-fps`: passes the `-r` target refresh value when gamescope is on +- `fps-cap`: caps frame rate through MangoHud when `mangohud` is on, for example `gamewrap config set fps-cap 60` +- `mangohud-log`, `mangohud-log-path`: enable full-session MangoHud performance logging and optionally choose its output path - `vkbasalt`: enables vkBasalt post-processing with `ENABLE_VKBASALT=1` +- `vkbasalt-log-level`: sets `VKBASALT_LOG_LEVEL` to `debug`, `info`, `warning`, `error`, or `none` when vkBasalt is on - `esync`: forces Proton esync on or off with `PROTON_NO_ESYNC`; leave unset to use Steam/Proton defaults - `fsync`: forces Proton fsync on or off with `PROTON_NO_FSYNC`; leave unset to use Steam/Proton defaults - `large-address-aware` / `laa`: sets `PROTON_LARGE_ADDRESS_AWARE=1` for older 32-bit Proton games that need more than 2 GB of address space @@ -238,9 +245,20 @@ gamewrap game clear-note "eldenring.exe" - `post-launch`: runs a shell command after the game exits; when set, gamewrap spawns the game and waits for it to finish before running this hook - `env-vars`: per-profile environment overrides managed with `gamewrap profile env set/list/unset/clear` +**hook-errors** (`warn` | `fail`, default: `warn`) + +Controls what happens when a pre-launch hook exits nonzero or cannot be executed. +- `warn` — a message is printed to stderr and the game launches anyway. +- `fail` — gamewrap aborts with an error and the game is not launched. + +The post-launch hook always runs after the pre-launch hook succeeds, even if the game itself fails to start. + +Example: + gamewrap config set hook-errors fail + ## How It Works -When `gamewrap` launches a game, it resolves your defaults, any inherited profile chain, and any matching game binding, prepares the needed environment variables, and then prefixes the game command with `gamescope`, `mangohud`, and/or `gamemoderun` when those features are enabled. +When `gamewrap` launches a game, it applies global defaults, then any matching profile overrides, prepares the needed environment variables, and prefixes the game command with `gamescope`, `mangohud`, and/or `gamemoderun` when those features are enabled. If something important is missing, `gamewrap` is designed to fail clearly instead of silently skipping the requested behavior. The `doctor` and `status` commands help you verify that before launching through Steam. @@ -253,7 +271,6 @@ For terminal usage, `gamewrap` distinguishes between management commands and exp - `gamewrap config edit` opens the config file in `$VISUAL`, `$EDITOR`, or `nano` - use `gamewrap config export` and `gamewrap config import ...` for full-config backup and sharing - use `gamewrap profile export` and `gamewrap profile import ...` for sharing one profile -- use `gamewrap profile tree` to inspect profile inheritance - use `gamewrap profile env set ` for per-profile environment overrides - use `gamewrap notify test` to verify graphical failure notifications - use `gamewrap run ` when you want to explicitly launch from the terminal @@ -302,9 +319,9 @@ gamewrap profile import benchmark `gamewrap` automatically adds `.gamewrap.toml` or `.gamewrap-profile.toml` when needed. -`gamewrap config export` writes resolved settings, not just the sparse internal overrides. That makes the exported file more predictable when you import it on another machine with different defaults. +`gamewrap config export` writes the sparse global defaults and profile overrides exactly as configured. -`gamewrap profile export` also writes resolved settings. Imported profiles are brought in as standalone profiles with explicit values, so they keep the same behavior even if the importing machine uses different defaults. +`gamewrap profile export` writes only explicitly configured profile settings. Imported profiles use the receiving machine's global defaults for every unset value. ## Files diff --git a/docs/roadmap.md b/docs/roadmap.md index ef77dc9..c49cae1 100644 --- a/docs/roadmap.md +++ b/docs/roadmap.md @@ -33,7 +33,7 @@ Implemented features: - Steam-first launcher flow with `gamewrap %command%` - Default launch mode with MangoHud and GameMode - Friendly setting names for all launch options -- Named profiles with full inheritance chains and cycle detection +- Named game-specific profiles (flat two-tier: global defaults + per-game overrides) - Per-profile environment variable overrides (`profile env set/unset/list/clear`) - Manual executable-to-profile bindings - Observed game history with launch count, timestamps, and display names @@ -51,14 +51,13 @@ Implemented features: - `dry-run` launch plan preview - `config edit` to open config in `$EDITOR` - Config and profile export/import for sharing -- `profile tree` for visualizing inheritance chains - `notify test` for graphical notification verification - ANSI color output (respects NO_COLOR, CLICOLOR, CLICOLOR_FORCE) - Shell completion with live candidates (profiles, games, settings) - Clear runtime dependency checks and actionable error messages Friendly settings supported: -- `overlay`, `performance`, `steam-host-libs`, `game-libs`, `verbose` +- `mangohud`, `gamemode`, `steam-host-libs`, `game-libs`, `verbose` - `gamescope`, `gamescope-width`, `gamescope-height`, `gamescope-fps` - `fps-cap`, `vkbasalt` - `esync`, `fsync`, `large-address-aware` @@ -132,6 +131,7 @@ Features that were deferred from the initial scope and are now implemented: Features still deferred: - Benchmark and recording profile presets (named templates that bundle several settings) - Additional launcher helpers beyond the current set +- **Profile description field for community sharing** — add `description: Option` to `ProfileConfig` and `SharedProfileFile`, shown at the top of exported `.gamewrap-profile.toml` files so ProtonDB/forum posts are self-documenting. CLI: `gamewrap profile describe ` and `profile clear-describe `. Preserved through import and shown in `profile show`/`profile list`. No tags, AppIDs, or author fields — those belong on the posting platform, not in the file. ## Recommended Expansion Order @@ -245,3 +245,95 @@ Before starting packaging or deferred features, re-check: - whether the project is going public - whether package maintenance is worth the overhead - whether v1 behavior is stable enough to freeze the main CLI/config model + +--- + +## Post-v1 Backlog + +Logged 2026-06-06. Priority order reflects dependencies — B1 sets naming used by B5, and JSON output (B6 Phase 1) is prerequisite for any GUI work. + +### B1 + B4 — Settings grouping, tool-prefixed names, help system overhaul + +**Problem:** +- `gamewrap help settings` outputs 36 settings in a flat, ungrouped list with no visual separation by tool +- `overlay` (MangoHud) and `performance` (GameMode) have no tool prefix while every gamescope/vkbasalt/mangohud-log setting already does +- A gamescope configuration note currently sits orphaned between log options and the gamescope settings block in help output + +**Proposed changes:** +- Rename `overlay` → `mangohud` (the MangoHud enable/disable toggle), consistent with `mangohud-log` +- Rename `performance` → `gamemode` (the GameMode enable/disable toggle) +- Group `gamewrap help settings` into tool-based sections: **Core**, **MangoHud**, **GameMode**, **Gamescope**, **vkBasalt**, **Proton/Wine**, **Hooks & Logging** +- Move the gamescope configuration note into the Gamescope section header (fixes B4) +- Enhance `topic_text()` in `src/help.rs` to accept an optional tool filter: `gamewrap help settings gamescope` +- Propagate renames everywhere: `Settings` struct fields, CLI parser, `config/keys.rs`, TOML serialization, shell completion, tests, README, docs +- Add a migration path for existing configs using the old names (clear error pointing to new name, or silent rename on load) + +**Note:** Breaking change — existing config files and profiles using `overlay` or `performance` will need migration. Sets the naming foundation for B5. + +**Key files:** `src/help.rs`, `src/config/schema.rs`, `src/config/keys.rs`, `src/config/mod.rs`, `src/cli.rs`, `src/completion.rs`, `tests/`, `README.md` + +--- + +### B2 — Large game list management + +**Problem:** +- After using gamewrap on many games (demos, uninstalled games, one-offs), `game list` grows without bound +- The only removal option is `game forget` (hard deletion); no way to soft-hide entries + +**Proposed changes:** +- Add `game archive ` / `game unarchive ` — sets an `archived` flag on the state entry, excluded from default `game list` +- `game list --all` shows archived entries alongside active ones (visually marked) +- `game forget` remains for permanent removal +- `game list` shows an entry count and a hint if the list exceeds a threshold (e.g. 20 entries) + +**Key files:** `src/cli.rs`, state file handling in `src/config/mod.rs`, `src/detect.rs`, tests + +--- + +### B3 — Profile export semantics + +**Question:** When a profile is exported to share: +- **Option A — sparse:** Export only what was explicitly set in the profile. Recipient's globals fill the rest at their end. Simple and portable; behavior depends on recipient's global config. +- **Option B — effective:** Export fully resolved settings (profile + globals baked in). Recipient gets a "known good" snapshot but must manually strip their own preferences. +- **Option C — sparse + informational context:** Export sparse profile, plus a `# context:` comment block showing what the exporter's globals were at export time. Informational only — not interpreted by import. + +**Current behavior:** Option A (sparse). + +**Resolved:** Sparse export implemented. Profiles are now flat (no inheritance). Export produces only explicitly set settings. + +--- + +### B5 — Grouped, cleaner `config show` / `profile show` display + +**Problem:** +- `config show` output (especially `--effective`) has no visual grouping — 30+ settings appear in struct field order +- No section headers separating e.g. gamescope settings from MangoHud settings + +**Proposed changes:** +- Refactor `render_resolved_settings()` and `render_profile_settings_with_indent()` to emit settings under dimmed section headers using the same tool groups as B1 +- Configured mode (default): only show a section header if that section has at least one configured value +- Effective mode (`--effective`): always show section headers; show `(nothing configured)` per-section if empty + +**Dependency:** Best done after B1, which defines the canonical section groupings. Can be scoped independently by hardcoding groups if B1 is deferred. + +**Key files:** `src/config/mod.rs`, tests + +--- + +### B6 — JSON output flag + GUI (discussion needed) + +**Phase 1 — JSON flag (independent):** +- Add `--json` flag to read commands: `config show`, `profile show`, `game list`, `status`, `dry-run` +- Write/mutating commands return `{"ok": true}` or the updated state on success +- Useful for scripting independently of any GUI + +**Phase 2 — GUI (dependent on Phase 1):** +- Build a GUI that invokes `gamewrap` as a subprocess and renders the JSON output +- CLI remains the authoritative source of truth; GUI is a frontend only + +**Discussion points before Phase 2:** +- Framework: egui (Rust, self-contained), iced (Rust async), Tauri (web frontend, heavier), GTK4 (gtk-rs)? +- Scope: full settings editor, profile manager, game list browser, or read-only status overlay? +- Packaging: GUI as an optional Cargo feature flag to keep the base CLI install slim? + +**Recommended starting point:** Phase 1 JSON flag only. diff --git a/src/cli.rs b/src/cli.rs index 3207d35..d71f05f 100644 --- a/src/cli.rs +++ b/src/cli.rs @@ -1,6 +1,6 @@ use std::ffi::OsString; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Command; use clap::{Args, CommandFactory, Parser, Subcommand, ValueHint, error::ErrorKind}; @@ -11,7 +11,8 @@ use crate::bindings; use crate::color; use crate::completion; use crate::config::keys::{SettingKey, reset_value, set_value}; -use crate::config::{self, AppPaths, ConfigFile, ProfileConfig, StateFile}; +use crate::config::migrate::MigrateAction; +use crate::config::{self, AppPaths, ConfigFile, HookErrors, ProfileConfig, Settings, StateFile}; use crate::detect; use crate::doctor; use crate::env; @@ -115,7 +116,7 @@ pub fn parse(args: impl IntoIterator) -> Result }, + Help { + topic: Option, + /// Optional group filter when topic is 'settings' (e.g. gamescope, mangohud, vkbasalt, proton, logging) + filter: Option, + }, #[command(about = "Show installed dependencies and resolved default settings")] Status, #[command(about = "Run preflight checks and show what a launch would do")] @@ -182,7 +187,7 @@ enum Commands { Last, #[command(about = "Show, edit, set, export, or import the global config")] Config(ConfigCommands), - #[command(about = "Create, show, set, inherit, export, or import profiles")] + #[command(about = "Create, show, set, export, or import profiles")] Profile(ProfileCommands), #[command(about = "List, show, bind, rename, forget, and annotate observed games")] Game(GameCommands), @@ -211,26 +216,50 @@ struct ConfigCommands { #[derive(Debug, Subcommand)] enum ConfigAction { - Show, + /// Show the current global configuration. + /// + /// By default, only explicitly configured settings are shown. + /// Use --effective to show all settings with their computed values. + Show { + /// Show all effective (computed) settings, including defaults for unset fields. + #[arg(long)] + effective: bool, + }, + /// Open the global configuration in your editor. Edit, + /// Set a global default setting. Set { #[arg(add = ArgValueCandidates::new(completion::setting_name_candidates))] setting: String, #[arg(add = ArgValueCandidates::new(completion::setting_value_candidates))] value: String, }, + /// Reset a global default setting. Reset { - #[arg(add = ArgValueCandidates::new(completion::setting_name_candidates))] - setting: String, + #[arg( + add = ArgValueCandidates::new(completion::setting_name_candidates), + required_unless_present = "all" + )] + setting: Option, + /// Reset all default settings to built-in defaults. + #[arg(long, conflicts_with = "setting")] + all: bool, }, + /// Export the global configuration. Export { #[arg(value_hint = ValueHint::FilePath, add = ArgValueCompleter::new(PathCompleter::any()))] path: Option, }, + /// Import a global configuration. Import { #[arg(value_hint = ValueHint::FilePath, add = ArgValueCompleter::new(PathCompleter::file()))] path: PathBuf, }, + /// Migrate a config file to use current setting names. + #[command(about = "Migrate config file to use current setting names")] + Migrate, + /// List all available settings with one-line descriptions. + Settings, } #[derive(Debug, Args)] @@ -241,31 +270,51 @@ struct ProfileCommands { #[derive(Debug, Subcommand)] enum ProfileAction { + /// List configured profiles. List, - Tree, + /// Manage environment variables for a profile. Env(ProfileEnvCommands), - Create { - name: String, - }, + /// Create a new profile. + Create { name: String }, + /// Copy an existing profile. Duplicate { #[arg(add = ArgValueCandidates::new(completion::named_profile_candidates))] source: String, destination: String, }, + /// Show a profile and its resolved settings. + /// + /// By default, only explicitly configured settings are shown. + /// Use --effective to show all settings including defaults. Show { #[arg(add = ArgValueCandidates::new(completion::profile_show_candidates))] name: String, + /// Show all effective (computed) settings, including defaults. + #[arg(long)] + effective: bool, }, + /// Export a profile. Export { #[arg(add = ArgValueCandidates::new(completion::named_profile_candidates))] name: String, #[arg(value_hint = ValueHint::FilePath, add = ArgValueCompleter::new(PathCompleter::any()))] path: Option, }, + /// Import a profile. Import { #[arg(value_hint = ValueHint::FilePath, add = ArgValueCompleter::new(PathCompleter::file()))] path: PathBuf, }, + /// Migrate a profile export file to use current setting names. + #[command(about = "Migrate a profile export file to use current setting names")] + Migrate { + /// Path to the .gamewrap-profile.toml file to migrate. + path: PathBuf, + /// Print the migrated content without writing the file. + #[arg(long)] + dry_run: bool, + }, + /// Set a profile setting. Set { #[arg(add = ArgValueCandidates::new(completion::named_profile_candidates))] name: String, @@ -274,26 +323,20 @@ enum ProfileAction { #[arg(add = ArgValueCandidates::new(completion::setting_value_candidates))] value: String, }, + /// Reset a profile setting to use the global default. Reset { #[arg(add = ArgValueCandidates::new(completion::named_profile_candidates))] name: String, #[arg(add = ArgValueCandidates::new(completion::setting_name_candidates))] setting: String, }, - Inherit { - #[arg(add = ArgValueCandidates::new(completion::named_profile_candidates))] - name: String, - #[arg(add = ArgValueCandidates::new(completion::named_profile_candidates))] - parent: String, - }, - ClearInherit { - #[arg(add = ArgValueCandidates::new(completion::named_profile_candidates))] - name: String, - }, + /// Delete a profile and its bindings. Delete { #[arg(add = ArgValueCandidates::new(completion::named_profile_candidates))] name: String, }, + /// List all available settings with one-line descriptions. + Settings, } #[derive(Debug, Args)] @@ -335,36 +378,44 @@ struct NoteArgs { #[derive(Debug, Subcommand)] enum GameAction { + /// List observed games. List { #[arg(add = ArgValueCandidates::new(completion::observed_game_candidates))] matcher: Option, #[arg(short, long, help = "Show full paths without abbreviation")] full: bool, }, + /// Show details for an observed game. Show { #[arg(add = ArgValueCandidates::new(completion::observed_game_candidates))] matcher: String, }, + /// Bind an observed game to a profile. Bind { #[arg(add = ArgValueCandidates::new(completion::observed_game_candidates))] matcher: String, #[arg(add = ArgValueCandidates::new(completion::named_profile_candidates))] profile: String, }, + /// Remove an observed game's profile binding. Unbind { #[arg(add = ArgValueCandidates::new(completion::observed_game_candidates))] matcher: String, }, + /// Add or replace a note on an observed game. Note(NoteArgs), + /// Set a display name for an observed game. Rename { #[arg(add = ArgValueCandidates::new(completion::observed_game_candidates))] matcher: String, name: String, }, + /// Remove the note from an observed game. ClearNote { #[arg(add = ArgValueCandidates::new(completion::observed_game_candidates))] matcher: String, }, + /// Remove a game from launch history. Forget { #[arg(add = ArgValueCandidates::new(completion::observed_game_candidates))] matcher: String, @@ -403,13 +454,23 @@ enum CompletionAction { fn execute_management(paths: &AppPaths, cli: ManageCli) -> Result<(), AppError> { match cli.command { - Commands::Help { topic } => { + Commands::Help { topic, filter } => { if let Some(topic) = topic { - let text = topic_text(&topic).ok_or_else(|| { - usage_error( - format!("`{topic}` is not a known help topic."), - HELP_TOPICS_HINT, - ) + let text = topic_text(&topic, filter.as_deref()).ok_or_else(|| { + if topic == "settings" && filter.is_some() { + usage_error( + format!( + "'{}' is not a known settings group.", + filter.as_deref().unwrap_or("") + ), + "Known groups: core, mangohud, gamemode, gamescope, vkbasalt, proton, logging", + ) + } else { + usage_error( + format!("`{topic}` is not a known help topic."), + HELP_TOPICS_HINT, + ) + } })?; println!("{text}"); } else { @@ -431,9 +492,15 @@ fn execute_management(paths: &AppPaths, cli: ManageCli) -> Result<(), AppError> let mut state = config::load_state(paths)?; launch_command(paths, &mut config, &mut state, &args.command, false) } - Commands::DryRun(args) => { + Commands::DryRun(mut args) => { let mut config = config::load(paths)?; - let mut state = config::load_state(paths)?; + let mut state = match config::load_state(paths) { + Ok(state) => { + args.command = resolve_dry_run_command(&args.command, &state)?; + state + } + Err(_) => StateFile::default(), + }; launch_command(paths, &mut config, &mut state, &args.command, true) } Commands::Last => execute_last(paths), @@ -456,7 +523,7 @@ fn execute_last(paths: &AppPaths) -> Result<(), AppError> { .or_else(|| state.games.last()); let Some(game) = game else { - println!("No games observed yet."); + println!("{}", crate::color::dim("No games observed yet.")); return Ok(()); }; @@ -576,10 +643,14 @@ fn execute_doctor(paths: &AppPaths, command: &[OsString]) -> Result<(), AppError } fn execute_config(paths: &AppPaths, command: ConfigCommands) -> Result<(), AppError> { + if matches!(command.action, ConfigAction::Migrate) { + return migrate_config_file(paths); + } + let mut config = config::load(paths)?; match command.action { - ConfigAction::Show => { - print!("{}", config::render_config(&config)); + ConfigAction::Show { effective } => { + print!("{}", config::render_config(&config, effective)); } ConfigAction::Edit => { let editor = std::env::var("VISUAL") @@ -604,31 +675,33 @@ fn execute_config(paths: &AppPaths, command: ConfigCommands) -> Result<(), AppEr config::save(paths, &config)?; println!("Updated default setting `{setting}` to `{value}`."); } - ConfigAction::Reset { setting } => { - let key = SettingKey::parse(&setting)?; - reset_value(&mut config.defaults, key); - config::save(paths, &config)?; - println!("Reset default setting `{setting}`."); + ConfigAction::Reset { setting, all } => { + if all { + config.defaults = Settings::default(); + config::save(paths, &config)?; + println!("Reset all default settings to built-in defaults."); + } else if let Some(setting) = setting { + let key = SettingKey::parse(&setting)?; + reset_value(&mut config.defaults, key); + config::save(paths, &config)?; + println!("Reset default setting `{setting}`."); + } else { + return Err(usage_error( + "No setting was provided to reset.", + "Use `gamewrap config reset ` or `gamewrap config reset --all`.", + )); + } } ConfigAction::Export { path } => { let exported = share::export_config(&config)?; let content = toml::to_string_pretty(&exported) .map_err(|error| internal_error(format!("failed to serialize config: {error}")))?; - if let Some(path) = path { - let path = share::with_default_config_suffix(&path); - fs::write(&path, content).map_err(|error| { - io_to_internal("Failed writing export file", Some(&path), error) - })?; - println!( - "{}", - color::ok(&format!("Exported config to `{}`.", path.display())) - ); - } else { - print!("{content}"); - } + let path = + share::with_default_config_suffix(&path.unwrap_or_else(|| PathBuf::from("config"))); + write_or_print(content, Some(&path), "config")?; } ConfigAction::Import { path } => { - let path = resolve_import_path(path, share::CONFIG_EXPORT_SUFFIX); + let path = resolve_import_path(path, share::with_default_config_suffix); let content = fs::read_to_string(&path).map_err(|error| { io_to_internal("Failed reading import file", Some(&path), error) })?; @@ -645,44 +718,85 @@ fn execute_config(paths: &AppPaths, command: ConfigCommands) -> Result<(), AppEr color::ok(&format!("Imported config from `{}`.", path.display())) ); } + ConfigAction::Migrate => unreachable!("handled before loading config"), + ConfigAction::Settings => { + print_settings_list(); + } } Ok(()) } +fn migrate_config_file(paths: &AppPaths) -> Result<(), AppError> { + let path = &paths.config_file; + if !path.exists() { + println!("No config file found at {}.", path.display()); + return Ok(()); + } + let content = fs::read_to_string(path) + .map_err(|error| io_to_internal("Failed reading config file", Some(path), error))?; + let mut value: toml::Value = toml::from_str(&content).map_err(|error| { + config_error( + format!("Config file is invalid: {error}"), + "Fix the TOML syntax first.", + ) + })?; + let changes = config::migrate::migrate_config(&mut value); + if changes.is_empty() { + println!("Config is up to date — no migration needed."); + return Ok(()); + } + let migrated = toml::to_string_pretty(&value) + .map_err(|error| internal_error(format!("failed to serialize migrated config: {error}")))?; + fs::write(path, &migrated) + .map_err(|error| io_to_internal("Failed writing config file", Some(path), error))?; + for action in &changes { + print_migrate_action(action); + } + println!("Config migrated successfully."); + Ok(()) +} + +fn print_settings_list() { + let name_width = completion::SETTING_NAME_HINTS + .iter() + .map(|(name, _)| name.len()) + .max() + .unwrap_or(20); + for (name, description) in completion::SETTING_NAME_HINTS { + println!( + " {} {}", + crate::color::accent(&format!("{: Result<(), AppError> { + if let ProfileAction::Migrate { path, dry_run } = &command.action { + return migrate_profile_file(path, *dry_run); + } + let mut config = config::load(paths)?; - let _state = config::load_state(paths)?; match command.action { ProfileAction::List => { if config.profiles.is_empty() { - println!("No profiles configured."); + println!("{}", crate::color::dim("No profiles configured.")); } else { - for (name, profile) in &config.profiles { + for name in config.profiles.keys() { let binding_count = config .bindings .iter() .filter(|binding| binding.profile == *name) .count(); - let binding_text = binding_count_text(binding_count); - if let Some(parent) = &profile.inherits { - if binding_count > 0 { - println!( - "{} {}", - color::accent(name), - color::dim(&format!("(inherits: {parent}, {binding_text})")) - ); - } else { - println!( - "{} {}", - color::accent(name), - color::dim(&format!("(inherits: {parent})")) - ); - } - } else if binding_count > 0 { + if binding_count > 0 { println!( "{} {}", color::accent(name), - color::dim(&format!("({binding_text})")) + color::dim(&format!("({})", binding_count_text(binding_count))) ); } else { println!("{}", color::accent(name)); @@ -690,14 +804,6 @@ fn execute_profile(paths: &AppPaths, command: ProfileCommands) -> Result<(), App } } } - ProfileAction::Tree => { - if config.profiles.is_empty() { - println!("No profiles configured."); - } else { - println!("{} {}", color::dim("default"), color::dim("(built-in)")); - print_profile_tree(&config, None, ""); - } - } ProfileAction::Env(command) => match command.action { ProfileEnvAction::Set { name, key, value } => { let profile_config = require_profile_mut(&mut config, &name)?; @@ -734,7 +840,7 @@ fn execute_profile(paths: &AppPaths, command: ProfileCommands) -> Result<(), App ProfileEnvAction::List { name } => { let resolved = profile::resolve_named(&config, &name)?; if resolved.settings.env_vars.is_empty() { - println!("(no env vars set on this profile)"); + println!("{}", crate::color::dim("(no env vars set on this profile)")); } else { for (key, value) in resolved.settings.env_vars { println!("{key}={value}"); @@ -790,24 +896,31 @@ fn execute_profile(paths: &AppPaths, command: ProfileCommands) -> Result<(), App let profile = require_profile(&config, &source)?; config.profiles.insert(destination.clone(), profile.clone()); config::save(paths, &config)?; - println!("Duplicated profile `{source}` to `{destination}`."); + println!( + "Duplicated profile {} to {}.", + crate::color::accent(&source), + crate::color::accent(&destination) + ); } - ProfileAction::Show { name } => { + ProfileAction::Show { name, effective } => { if name == "default" { print!( "{}", - config::render_config(&ConfigFile { - defaults: config.defaults, - profiles: Default::default(), - bindings: Vec::new(), - }) + config::render_config( + &ConfigFile { + defaults: config.defaults, + profiles: Default::default(), + bindings: Vec::new(), + }, + effective, + ) ); } else { let profile_config = require_profile(&config, &name)?; let resolved = profile::resolve_named(&config, &name)?; print!( "{}", - config::render_profile(&name, profile_config, &resolved.settings) + config::render_profile(&name, profile_config, &resolved.settings, effective) ); } } @@ -823,22 +936,20 @@ fn execute_profile(paths: &AppPaths, command: ProfileCommands) -> Result<(), App let content = toml::to_string_pretty(&exported).map_err(|error| { internal_error(format!("failed to serialize profile export: {error}")) })?; - if let Some(path) = path { - let path = share::with_default_profile_suffix(&path); - fs::write(&path, content).map_err(|error| { - io_to_internal("Failed writing profile export file", Some(&path), error) - })?; - println!("Exported profile `{name}` to `{}`.", path.display()); - } else { - print!("{content}"); - } + let path = + share::with_default_profile_suffix(&path.unwrap_or_else(|| PathBuf::from(&name))); + write_or_print( + content, + Some(&path), + &format!("profile {}", color::accent(&name)), + )?; } ProfileAction::Import { path } => { - let path = resolve_import_path(path, share::PROFILE_EXPORT_SUFFIX); + let path = resolve_import_path(path, share::with_default_profile_suffix); let content = fs::read_to_string(&path).map_err(|error| { io_to_internal("Failed reading profile import file", Some(&path), error) })?; - let imported: share::SharedProfileFile = toml::from_str(&content).map_err(|error| { + let imported = share::parse_imported_profile(&content).map_err(|error| { config_error( format!("Profile import file {} is invalid: {error}", path.display()), "Use a `.gamewrap-profile.toml` export from `gamewrap profile export`.", @@ -859,8 +970,13 @@ fn execute_profile(paths: &AppPaths, command: ProfileCommands) -> Result<(), App } config.profiles.insert(name.clone(), imported_profile); config::save(paths, &config)?; - println!("Imported profile `{name}` from `{}`.", path.display()); + println!( + "Imported profile {} from {}.", + crate::color::accent(&name), + crate::color::dim(&path.display().to_string()) + ); } + ProfileAction::Migrate { .. } => unreachable!("handled before loading config"), ProfileAction::Set { name, setting, @@ -870,50 +986,25 @@ fn execute_profile(paths: &AppPaths, command: ProfileCommands) -> Result<(), App let profile_config = require_profile_mut(&mut config, &name)?; set_value(&mut profile_config.settings, key, &value)?; config::save(paths, &config)?; - println!("Updated profile `{name}`: `{setting}` = `{value}`."); + println!( + "Updated profile {}: {} = {}.", + crate::color::accent(&name), + crate::color::accent(&setting), + crate::color::accent(&value) + ); } ProfileAction::Reset { name, setting } => { let key = SettingKey::parse(&setting)?; let profile_config = require_profile_mut(&mut config, &name)?; reset_value(&mut profile_config.settings, key); config::save(paths, &config)?; - println!("Reset profile `{name}` setting `{setting}` to inherit."); - } - ProfileAction::Inherit { name, parent } => { - if parent == "default" { - return Err(config_error( - "`default` is already the base for every profile.", - "Use `gamewrap profile clear-inherit ` if you want the profile to inherit only from the defaults.", - )); - } - if !config.profiles.contains_key(&parent) { - return Err(config_error( - format!("Parent profile `{parent}` does not exist."), - "Create it first with `gamewrap profile create `.", - )); - } - let profile_config = require_profile_mut(&mut config, &name)?; - profile_config.inherits = Some(parent.clone()); - config::save(paths, &config)?; - println!("Profile `{name}` now inherits from `{parent}`."); - } - ProfileAction::ClearInherit { name } => { - let profile_config = require_profile_mut(&mut config, &name)?; - profile_config.inherits = None; - config::save(paths, &config)?; - println!("Cleared inherited parent for `{name}`."); + println!( + "Reset profile {} setting {} to use the global default.", + crate::color::accent(&name), + crate::color::accent(&setting) + ); } ProfileAction::Delete { name } => { - if let Some((child, _)) = config - .profiles - .iter() - .find(|(_, profile)| profile.inherits.as_deref() == Some(name.as_str())) - { - return Err(config_error( - format!("Cannot delete profile `{name}` because `{child}` inherits from it."), - "Clear or change the child's parent first with `gamewrap profile clear-inherit ` or `gamewrap profile inherit `.", - )); - } if config.profiles.remove(&name).is_none() { return Err(profile_not_found_error(&name)); } @@ -926,10 +1017,53 @@ fn execute_profile(paths: &AppPaths, command: ProfileCommands) -> Result<(), App )) ); } + ProfileAction::Settings => { + print_settings_list(); + } } Ok(()) } +fn migrate_profile_file(path: &std::path::Path, dry_run: bool) -> Result<(), AppError> { + let path = share::with_default_profile_suffix(path); + let content = fs::read_to_string(&path) + .map_err(|error| io_to_internal("Failed reading profile file", Some(&path), error))?; + let mut value: toml::Value = toml::from_str(&content).map_err(|error| { + config_error( + format!("Profile file is invalid: {error}"), + "Check the file syntax.", + ) + })?; + let changes = config::migrate::migrate_profile_export(&mut value); + if changes.is_empty() { + println!("Profile export is up to date — no migration needed."); + return Ok(()); + } + let migrated = toml::to_string_pretty(&value).map_err(|error| { + internal_error(format!("failed to serialize migrated profile: {error}")) + })?; + if dry_run { + print!("{migrated}"); + } else { + fs::write(&path, &migrated) + .map_err(|error| io_to_internal("Failed writing profile file", Some(&path), error))?; + for action in &changes { + print_migrate_action(action); + } + println!("Profile export migrated successfully."); + } + Ok(()) +} + +fn print_migrate_action(action: &MigrateAction) { + match action { + MigrateAction::Renamed { from, to } => println!(" Renamed '{from}' → '{to}'"), + MigrateAction::Removed { old, kept } => { + println!(" Removed deprecated '{old}' (existing '{kept}' value preserved)") + } + } +} + fn execute_game(paths: &AppPaths, command: GameCommands) -> Result<(), AppError> { let mut config = config::load(paths)?; let mut state = config::load_state(paths)?; @@ -945,7 +1079,7 @@ fn execute_game(paths: &AppPaths, command: GameCommands) -> Result<(), AppError> }) .collect::>(); if games.is_empty() { - println!("(no observed games yet)"); + println!("{}", crate::color::dim("(no observed games yet)")); } else { let game_width = games .iter() @@ -1053,8 +1187,9 @@ fn execute_game(paths: &AppPaths, command: GameCommands) -> Result<(), AppError> bindings::set_binding(&mut config, matcher.clone(), profile.clone())?; config::save(paths, &config)?; println!( - "{}", - color::ok(&format!("Bound `{matcher}` to profile `{profile}`.")) + "Bound {} to profile {}.", + crate::color::accent(&matcher), + crate::color::accent(&profile) ); } GameAction::Unbind { matcher } => { @@ -1073,10 +1208,7 @@ fn execute_game(paths: &AppPaths, command: GameCommands) -> Result<(), AppError> } } config::save(paths, &config)?; - println!( - "{}", - color::ok(&format!("Removed binding for `{matcher}`.")) - ); + println!("Removed binding for {}.", crate::color::accent(&matcher)); } GameAction::Note(args) => { let note = join_os_words(&args.note)?; @@ -1084,7 +1216,7 @@ fn execute_game(paths: &AppPaths, command: GameCommands) -> Result<(), AppError> .ok_or_else(|| game_not_found_error(&args.matcher))?; game.note = Some(note); config::save_state(paths, &state)?; - println!("Saved note for `{}`.", args.matcher); + println!("Set note for {}.", crate::color::accent(&args.matcher)); } GameAction::Rename { matcher, name } => { let game = find_observed_game_mut(&mut state, &matcher) @@ -1092,8 +1224,9 @@ fn execute_game(paths: &AppPaths, command: GameCommands) -> Result<(), AppError> game.display_name = Some(name.clone()); config::save_state(paths, &state)?; println!( - "{}", - color::ok(&format!("Renamed `{matcher}` to `{name}`.")) + "Renamed {} to {}.", + crate::color::accent(&matcher), + crate::color::accent(&name) ); } GameAction::ClearNote { matcher } => { @@ -1101,7 +1234,7 @@ fn execute_game(paths: &AppPaths, command: GameCommands) -> Result<(), AppError> .ok_or_else(|| game_not_found_error(&matcher))?; game.note = None; config::save_state(paths, &state)?; - println!("Cleared note for `{matcher}`."); + println!("Cleared note for {}.", crate::color::accent(&matcher)); } GameAction::Forget { matcher } => { find_observed_game_mut(&mut state, &matcher) @@ -1111,8 +1244,8 @@ fn execute_game(paths: &AppPaths, command: GameCommands) -> Result<(), AppError> .retain(|game| !matches_observed_game(&matcher, game)); config::save_state(paths, &state)?; println!( - "{}", - color::ok(&format!("Removed `{matcher}` from observed games.")) + "Removed {} from observed games.", + crate::color::accent(&matcher) ); } } @@ -1123,28 +1256,6 @@ fn binding_count_text(count: usize) -> String { format!("{count} binding{}", if count == 1 { "" } else { "s" }) } -fn print_profile_tree(config: &ConfigFile, parent: Option<&str>, prefix: &str) { - let children = config - .profiles - .iter() - .filter(|(_, profile)| profile.inherits.as_deref() == parent) - .collect::>(); - - for (index, (name, _)) in children.iter().enumerate() { - let is_last = index + 1 == children.len(); - let connector = if is_last { "└── " } else { "├── " }; - println!( - "{}{}{}", - color::dim(&format!("{prefix}{connector}")), - color::accent(name), - "" - ); - - let child_prefix = format!("{prefix}{}", if is_last { " " } else { "│ " }); - print_profile_tree(config, Some(name.as_str()), &child_prefix); - } -} - fn find_observed_game<'a>( state: &'a StateFile, matcher: &str, @@ -1175,6 +1286,50 @@ fn matches_observed_game(matcher: &str, game: &crate::config::ObservedGame) -> b || game.command_path.to_ascii_lowercase().contains(&matcher) } +fn resolve_dry_run_command( + command: &[OsString], + state: &StateFile, +) -> Result, AppError> { + let [argument] = command else { + return Ok(command.to_vec()); + }; + if argument.as_encoded_bytes().contains(&b'/') { + return Ok(command.to_vec()); + } + + let argument = argument.to_string_lossy(); + let matches = state + .games + .iter() + .filter(|game| { + game.executable.eq_ignore_ascii_case(&argument) + || game + .display_name + .as_deref() + .is_some_and(|name| name.eq_ignore_ascii_case(&argument)) + }) + .collect::>(); + + match matches.as_slice() { + [game] => Ok(vec![OsString::from(&game.command_path)]), + [] => { + println!( + "Note: not found in launch history. Use the full path, or launch via Steam first to register it." + ); + Ok(command.to_vec()) + } + matches => { + println!("Multiple games match:"); + for game in matches { + println!(" {}", game.command_path); + } + Err(AppError::Config( + "Multiple games match — use the full path to disambiguate.".to_string(), + )) + } + } +} + fn join_os_words(words: &[OsString]) -> Result { let parts = words .iter() @@ -1221,16 +1376,12 @@ fn abbreviate_path(path: &str, max_len: usize) -> String { format!("{}...", &path[..max_len.saturating_sub(3)]) } -fn resolve_import_path(path: PathBuf, suffix: &str) -> PathBuf { +fn resolve_import_path(path: PathBuf, suffix_fn: fn(&Path) -> PathBuf) -> PathBuf { if path.exists() { return path; } - let with_suffix = if suffix == share::CONFIG_EXPORT_SUFFIX { - share::with_default_config_suffix(&path) - } else { - share::with_default_profile_suffix(&path) - }; + let with_suffix = suffix_fn(&path); if with_suffix.exists() { with_suffix @@ -1239,6 +1390,26 @@ fn resolve_import_path(path: PathBuf, suffix: &str) -> PathBuf { } } +fn write_or_print(content: String, path: Option<&Path>, label: &str) -> Result<(), AppError> { + match path { + Some(p) => { + std::fs::write(p, &content).map_err(|error| { + io_to_internal(&format!("Failed writing {label}"), Some(p), error) + })?; + println!( + "Exported {label} to {}.", + crate::color::dim(&p.display().to_string()) + ); + } + None => print!("{content}"), + } + Ok(()) +} + +fn run_hook(cmd: &str) -> Result { + std::process::Command::new("sh").arg("-c").arg(cmd).status() +} + fn launch_command( paths: &AppPaths, config: &mut ConfigFile, @@ -1251,6 +1422,13 @@ fn launch_command( let verbose = resolved.settings.verbose; let settings = resolved.settings.clone(); let plan = launch::build_plan(command, settings.clone())?; + let log_path = settings.log_file.then(|| { + settings + .log_path + .as_deref() + .map(PathBuf::from) + .unwrap_or_else(|| crate::log::default_log_path(&paths.state_dir)) + }); if dry_run { println!( @@ -1258,46 +1436,168 @@ fn launch_command( launch::render_plan(&plan, &resolved.profile_name, verbose) ); if let Some(pre_cmd) = &settings.pre_launch { - println!("Pre-launch hook:\n sh -c {:?}", pre_cmd); + println!( + "{}\n sh -c {:?}", + crate::color::bold("Pre-launch hook:"), + pre_cmd + ); } if let Some(post_cmd) = &settings.post_launch { println!( - "Post-launch hook (runs after game exits):\n sh -c {:?}", + "{}\n sh -c {:?}", + crate::color::bold("Post-launch hook (runs after game exits):"), post_cmd ); } + if settings.pre_launch.is_some() || settings.post_launch.is_some() { + println!( + "{} {}", + crate::color::bold("Hook errors:"), + crate::color::accent(settings.hook_errors.as_str()) + ); + } return Ok(()); } - if let Some(pre_cmd) = &settings.pre_launch { - let _ = std::process::Command::new("sh") - .arg("-c") - .arg(pre_cmd) - .status(); + // Log the launch header. + if let Some(log_path) = &log_path { + crate::log::append( + log_path, + &[ + "--- launch ---".to_string(), + format!("executable: {}", executable.basename), + format!("profile: {}", resolved.profile_name), + format!("command: {}", format_command(&plan.command)), + ], + ); } + // Run pre-launch hook. + if let Some(pre_cmd) = settings.pre_launch.as_deref() { + let hook_result = run_hook(pre_cmd); + let log_label = match &hook_result { + Ok(status) => exit_status_label(*status), + Err(_) => "unavailable".to_string(), + }; + if let Some(log_path) = &log_path { + crate::log::append( + log_path, + &[format!("pre-launch: {pre_cmd} → exit {log_label}")], + ); + } + match hook_result { + Ok(status) if !status.success() => { + let msg = format!( + "pre-launch hook exited {}: {pre_cmd}", + exit_status_label(status) + ); + match settings.hook_errors { + HookErrors::Fail => return Err(internal_error(msg)), + HookErrors::Warn => eprintln!("gamewrap: {msg}"), + } + } + Err(err) => { + let msg = format!("could not run pre-launch hook ({pre_cmd}): {err}"); + match settings.hook_errors { + HookErrors::Fail => return Err(internal_error(msg)), + HookErrors::Warn => eprintln!("gamewrap: {msg}"), + } + } + Ok(_) => {} + } + } + + // Record the launch in state (Steam context only). if env::is_steam_context() { detect::record_launch(state, &executable, &resolved.profile_name); config::save_state(paths, state)?; } if let Some(post_cmd) = settings.post_launch.clone() { - let elapsed = launch::execute_wait(plan)?; - let elapsed_secs = elapsed.as_secs(); - if env::is_steam_context() { - detect::record_play_time(state, &executable, elapsed_secs); - config::save_state(paths, state)?; + // Spawn the game and wait. Capture both success and failure so the + // post-hook can always run once we've passed the pre-hook check. + let game_result = launch::execute_wait(plan); + + let (elapsed_secs, game_exit_label) = match &game_result { + Ok((exit_status, elapsed)) => { + let secs = elapsed.as_secs(); + if env::is_steam_context() { + detect::record_play_time(state, &executable, secs); + let _ = config::save_state(paths, state); + } + (secs, exit_status_label(*exit_status)) + } + Err(err) => { + eprintln!("gamewrap: {err}"); + (0, "unavailable".to_string()) + } + }; + + // Run post-launch hook unconditionally (even when the game failed to spawn). + let post_result = run_hook(&post_cmd); + let post_log_label = match &post_result { + Ok(status) => exit_status_label(*status), + Err(_) => "unavailable".to_string(), + }; + + if let Some(log_path) = &log_path { + crate::log::append( + log_path, + &[ + format!("exit: {game_exit_label}"), + format!("playtime: {elapsed_secs}s"), + format!("post-launch: {post_cmd} → exit {post_log_label}"), + ], + ); + } + + // Report post-hook failure clearly. + match post_result { + Ok(ref status) if !status.success() => { + eprintln!( + "gamewrap: post-launch hook exited {}: {post_cmd}", + exit_status_label(*status) + ); + } + Err(ref err) => { + eprintln!("gamewrap: could not run post-launch hook ({post_cmd}): {err}"); + } + Ok(_) => {} + } + + // Propagate the game's result. If the game spawn failed, return an + // error (post-hook already ran). If the game exited nonzero, exit + // with the game's exit code directly so the caller sees it. + match game_result { + Err(err) => Err(err), + Ok((exit_status, _)) => { + let code = exit_status.code().unwrap_or(1); + if code != 0 { + std::process::exit(code); + } + Ok(()) + } } - let _ = std::process::Command::new("sh") - .arg("-c") - .arg(&post_cmd) - .status(); - Ok(()) } else { launch::execute(plan) } } +fn format_command(command: &[OsString]) -> String { + command + .iter() + .map(|part| part.to_string_lossy()) + .collect::>() + .join(" ") +} + +fn exit_status_label(status: std::process::ExitStatus) -> String { + status + .code() + .map(|code| code.to_string()) + .unwrap_or_else(|| "signal".to_string()) +} + fn format_duration(seconds: u64) -> String { let hours = seconds / 3_600; let minutes = (seconds % 3_600) / 60; diff --git a/src/completion.rs b/src/completion.rs index b45752b..71cb713 100644 --- a/src/completion.rs +++ b/src/completion.rs @@ -58,7 +58,7 @@ pub fn install(shell: Shell, paths: &AppPaths) -> Result Result { Ok(output) } +pub const SETTING_NAME_HINTS: &[(&str, &str)] = &[ + ("mangohud", "Turn MangoHud on or off"), + ("gamemode", "Turn GameMode on or off"), + ( + "steam-host-libs", + "Prefer host libraries inside the Steam runtime", + ), + ("game-libs", "Control host library path injection for games"), + ("verbose", "Show extra diagnostic detail"), + ("log-file", "Write a launch decision log to a file"), + ("log-path", "Path to gamewrap log file"), + ("gamescope", "Wrap launches in the gamescope compositor"), + ( + "gamescope-width", + "Set the gamescope output width (pixels or native)", + ), + ( + "gamescope-height", + "Set the gamescope output height (pixels or native)", + ), + ("gamescope-fps", "Set the gamescope target FPS (-r)"), + ( + "gamescope-nested-width", + "Set the game render width (-w, for upscaling)", + ), + ( + "gamescope-nested-height", + "Set the game render height (-h, for upscaling)", + ), + ( + "gamescope-unfocused-fps", + "Set FPS limit when gamescope window is unfocused", + ), + ( + "gamescope-scaler", + "Set the gamescope scaling mode (auto, integer, fit, fill, stretch)", + ), + ( + "gamescope-filter", + "Set the gamescope upscale filter (linear, nearest, fsr, nis, pixel)", + ), + ( + "gamescope-sharpness", + "Set upscale sharpness 0–20 (0=max, 20=min, fsr/nis only)", + ), + ( + "gamescope-mode", + "Set the gamescope window mode (windowed, borderless, fullscreen)", + ), + ( + "gamescope-adaptive-sync", + "Enable adaptive sync / VRR in gamescope", + ), + ("gamescope-hdr", "Enable HDR output in gamescope"), + ("gamescope-steam", "Enable Steam integration in gamescope"), + ( + "gamescope-expose-wayland", + "Expose Wayland socket for native Wayland clients", + ), + ( + "gamescope-mangoapp", + "Use gamescope native MangoHud overlay instead of mangohud prefix", + ), + ("fps-cap", "Cap frame rate when MangoHud overlay is enabled"), + ("mangohud-log", "Enable MangoHud session performance log"), + ("mangohud-log-path", "Path for MangoHud log output"), + ("vkbasalt", "Enable the vkBasalt post-processing layer"), + ( + "vkbasalt-config", + "Path to a vkBasalt config file (sets VKBASALT_CONFIG_FILE)", + ), + ("vkbasalt-log-level", "vkBasalt log verbosity"), + ("esync", "Force Proton esync on or off"), + ("fsync", "Force Proton fsync on or off"), + ( + "large-address-aware", + "Set PROTON_LARGE_ADDRESS_AWARE for Proton", + ), + ("laa", "Alias for large-address-aware"), + ( + "pre-launch", + "Run a shell command before launching the game", + ), + ( + "post-launch", + "Store a shell command for a future wrapped post-game hook", + ), + ( + "hook-errors", + "Control pre-launch hook failure behavior (warn/fail)", + ), + ("host-libs", "Alias for steam-host-libs"), +]; + pub fn setting_name_candidates() -> Vec { - [ - ("overlay", "Turn MangoHud on or off"), - ("performance", "Turn GameMode on or off"), - ( - "steam-host-libs", - "Prefer host libraries inside the Steam runtime", - ), - ("game-libs", "Control host library path injection for games"), - ("verbose", "Show extra diagnostic detail"), - ("gamescope", "Wrap launches in the gamescope compositor"), - ("gamescope-width", "Set the gamescope target width"), - ("gamescope-height", "Set the gamescope target height"), - ("gamescope-fps", "Set the gamescope target FPS"), - ("fps-cap", "Cap frame rate when MangoHud overlay is enabled"), - ("vkbasalt", "Enable the vkBasalt post-processing layer"), - ("esync", "Force Proton esync on or off"), - ("fsync", "Force Proton fsync on or off"), - ( - "large-address-aware", - "Set PROTON_LARGE_ADDRESS_AWARE for Proton", - ), - ("laa", "Alias for large-address-aware"), - ( - "pre-launch", - "Run a shell command before launching the game", - ), - ( - "post-launch", - "Store a shell command for a future wrapped post-game hook", - ), - ("host-libs", "Alias for steam-host-libs"), - ] - .into_iter() - .map(|(value, help)| CompletionCandidate::new(value).help(Some(help.into()))) - .collect() + SETTING_NAME_HINTS + .iter() + .map(|(value, help)| CompletionCandidate::new(*value).help(Some((*help).into()))) + .collect() } pub fn setting_value_candidates() -> Vec { - [ - ("on", "Enable the setting"), - ("off", "Disable the setting"), - ("auto", "Use automatic behavior"), - ("keep", "Keep the current value unchanged"), - ("gamemode", "Always add GameMode library paths"), - ] - .into_iter() - .map(|(value, help)| CompletionCandidate::new(value).help(Some(help.into()))) - .collect() + // In completion mode, gamewrap is invoked with the full command line as process args. + // The setting name is always the second-to-last arg (last is the partial value being typed). + let args: Vec = std::env::args().collect(); + let setting = args + .len() + .checked_sub(2) + .and_then(|i| args.get(i)) + .map(String::as_str); + candidates_for_setting(setting) +} + +fn candidates_for_setting(setting: Option<&str>) -> Vec { + let c = |v: &'static str, h: &'static str| CompletionCandidate::new(v).help(Some(h.into())); + let toggle = || { + vec![ + c("on", "Enable the setting"), + c("off", "Disable the setting"), + ] + }; + match setting { + Some( + "mangohud" + | "gamemode" + | "steam-host-libs" + | "host-libs" + | "verbose" + | "log-file" + | "gamescope" + | "gamescope-adaptive-sync" + | "gamescope-hdr" + | "gamescope-steam" + | "gamescope-expose-wayland" + | "gamescope-mangoapp" + | "mangohud-log" + | "vkbasalt" + | "esync" + | "fsync" + | "large-address-aware" + | "laa", + ) => toggle(), + Some("game-libs") => vec![ + c("auto", "Automatic library path handling"), + c("keep", "Keep current library path unchanged"), + c("gamemode", "Always add GameMode library paths"), + ], + Some("gamescope-scaler") => vec![ + c("auto", "Automatic scaling"), + c("integer", "Integer scale mode"), + c("fit", "Fit to output resolution"), + c("fill", "Fill output resolution"), + c("stretch", "Stretch to output resolution"), + ], + Some("gamescope-filter") => vec![ + c("linear", "Linear filter"), + c("nearest", "Nearest-neighbor filter"), + c("fsr", "AMD FidelityFX Super Resolution upscaling"), + c("nis", "NVIDIA Image Scaling upscaling"), + c("pixel", "Pixel-art filter"), + ], + Some("gamescope-mode") => vec![ + c("windowed", "Windowed mode"), + c("borderless", "Borderless windowed mode"), + c("fullscreen", "Fullscreen mode"), + ], + Some("vkbasalt-log-level") => vec![ + c("debug", "Debug logging"), + c("info", "Informational logging"), + c("warning", "Warning logging"), + c("error", "Error logging"), + c("none", "Disable logging"), + ], + Some("hook-errors") => vec![ + c("warn", "Log hook failure and continue launching (default)"), + c("fail", "Abort launch when pre-launch hook fails"), + ], + // Numeric and free-text settings have no useful static candidates + Some( + "gamescope-width" + | "gamescope-height" + | "gamescope-nested-width" + | "gamescope-nested-height" + | "gamescope-fps" + | "gamescope-unfocused-fps" + | "gamescope-sharpness" + | "fps-cap" + | "log-path" + | "mangohud-log-path" + | "vkbasalt-config" + | "pre-launch" + | "post-launch", + ) => vec![], + // Unknown setting or no context — show all possible values + _ => [ + ("on", "Enable the setting"), + ("off", "Disable the setting"), + ("auto", "Use automatic behavior"), + ("keep", "Keep current value unchanged"), + ("gamemode", "Always add GameMode library paths"), + ("fsr", "AMD FidelityFX Super Resolution upscaling"), + ("nis", "NVIDIA Image Scaling upscaling"), + ("linear", "Linear filter"), + ("nearest", "Nearest-neighbor filter"), + ("pixel", "Pixel-art filter"), + ("integer", "Integer scale mode"), + ("fit", "Fit to output resolution"), + ("fill", "Fill output resolution"), + ("stretch", "Stretch to output resolution"), + ("windowed", "Windowed mode"), + ("borderless", "Borderless windowed mode"), + ("fullscreen", "Fullscreen mode"), + ("debug", "Enable debug logging"), + ("info", "Informational logging"), + ("warning", "Warning logging"), + ("error", "Error logging"), + ("none", "Disable tool logging"), + ] + .into_iter() + .map(|(v, h)| c(v, h)) + .collect(), + } } pub fn named_profile_candidates() -> Vec { load_named_profiles() .into_iter() - .map(|name| CompletionCandidate::new(name)) + .map(CompletionCandidate::new) .collect() } diff --git a/src/config/keys.rs b/src/config/keys.rs index 2062b54..fbe9d68 100644 --- a/src/config/keys.rs +++ b/src/config/keys.rs @@ -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 { 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 { } } +fn parse_vkbasalt_log_level(value: &str) -> Result { + 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 { + 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 { + 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 { + 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 { + 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 { + 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 0–20."), + "0 is maximum sharpness, 20 is minimum sharpness.", + )); + } + Ok(n) +} + fn parse_pixel_count(value: &str) -> Result { value.parse::().map_err(|_| { config_error( @@ -137,11 +327,22 @@ fn parse_pixel_count(value: &str) -> Result { }) } -fn parse_gamescope_fps(value: &str) -> Result { +fn parse_fps(value: &str, setting: &str) -> Result { value.parse::().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 { + 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).", + )), + } +} diff --git a/src/config/migrate.rs b/src/config/migrate.rs new file mode 100644 index 0000000..78c833f --- /dev/null +++ b/src/config/migrate.rs @@ -0,0 +1,123 @@ +use std::collections::HashSet; + +/// Rename table: (old_key, new_key). Grows over time as field names change. +pub static SETTING_RENAMES: &[(&str, &str)] = + &[("overlay", "mangohud"), ("performance", "gamemode")]; + +#[derive(Debug, PartialEq, Eq)] +pub enum MigrateAction { + /// Old key renamed to new key, value transferred. + Renamed { from: String, to: String }, + /// Old key removed because new key already had a value. + Removed { old: String, kept: String }, +} + +/// Apply SETTING_RENAMES to a Settings-shaped TOML table in place. +/// Returns the actions taken for retired keys. +fn migrate_settings_table(table: &mut toml::map::Map) -> Vec { + let mut actions = Vec::new(); + for (old, new) in SETTING_RENAMES { + if let Some(value) = table.remove(*old) { + if !table.contains_key(*new) { + table.insert(new.to_string(), value); + actions.push(MigrateAction::Renamed { + from: old.to_string(), + to: new.to_string(), + }); + } else { + actions.push(MigrateAction::Removed { + old: old.to_string(), + kept: new.to_string(), + }); + } + } + } + actions +} + +/// Apply SETTING_RENAMES to a ConfigFile-shaped TOML value. +/// Migrates [defaults] and each [profiles.] section. +/// Returns the actions taken for retired keys. +pub fn migrate_config(value: &mut toml::Value) -> Vec { + let mut changes = Vec::new(); + if let Some(table) = value.as_table_mut() { + if let Some(toml::Value::Table(defaults)) = table.get_mut("defaults") { + changes.extend(migrate_settings_table(defaults)); + } + if let Some(toml::Value::Table(profiles)) = table.get_mut("profiles") { + let names: Vec = profiles.keys().cloned().collect(); + for name in names { + if let Some(toml::Value::Table(profile)) = profiles.get_mut(&name) { + changes.extend(migrate_settings_table(profile)); + } + } + } + } + changes +} + +/// Apply SETTING_RENAMES to a SharedProfileFile-shaped TOML value. +/// Migrates the [settings] section. +/// Returns the actions taken for retired keys. +pub fn migrate_profile_export(value: &mut toml::Value) -> Vec { + if let Some(toml::Value::Table(settings)) = + value.as_table_mut().and_then(|t| t.get_mut("settings")) + { + migrate_settings_table(settings) + } else { + Vec::new() + } +} + +/// Check a settings-shaped TOML table for renamed-away setting keys. +/// Returns unrecognized key names so profile imports can warn about skipped fields. +pub fn unknown_settings_in_table(table: &toml::map::Map) -> Vec { + let old_names: HashSet<&str> = SETTING_RENAMES.iter().map(|(old, _)| *old).collect(); + table + .keys() + .filter(|key| old_names.contains(key.as_str())) + .cloned() + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn config_migration_preserves_current_values_on_conflict() { + let mut value: toml::Value = toml::from_str( + r#" +[defaults] +overlay = false +mangohud = true + +[profiles.benchmark] +performance = false +"#, + ) + .expect("valid TOML"); + + let changes = migrate_config(&mut value); + + assert_eq!( + changes, + vec![ + MigrateAction::Removed { + old: "overlay".to_string(), + kept: "mangohud".to_string(), + }, + MigrateAction::Renamed { + from: "performance".to_string(), + to: "gamemode".to_string(), + }, + ] + ); + assert_eq!(value["defaults"]["mangohud"].as_bool(), Some(true)); + assert!(value["defaults"].get("overlay").is_none()); + assert_eq!( + value["profiles"]["benchmark"]["gamemode"].as_bool(), + Some(false) + ); + } +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 971e83e..db64974 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,4 +1,5 @@ pub mod keys; +pub mod migrate; pub mod schema; use std::collections::BTreeMap; @@ -11,8 +12,9 @@ use crate::detect; use crate::error::{AppError, config_error, internal_error, io_to_internal}; pub use schema::{ - Binding, ConfigFile, GameLibsMode, ObservedGame, ProfileConfig, ResolvedSettings, Settings, - StateFile, + Binding, ConfigFile, GameLibsMode, GamescopeFilter, GamescopeScaler, GamescopeSize, + GamescopeWindowMode, HookErrors, ObservedGame, ProfileConfig, ResolvedSettings, Settings, + StateFile, VkbasaltLogLevel, }; #[derive(Debug, Clone)] @@ -59,6 +61,7 @@ pub fn load(paths: &AppPaths) -> Result { error, ) })?; + let content = migrate_content_if_needed(content, &paths.config_file); let config = toml::from_str(&content).map_err(|error| { config_error( @@ -70,6 +73,27 @@ pub fn load(paths: &AppPaths) -> Result { Ok(config) } +fn migrate_content_if_needed(content: String, path: &Path) -> String { + let mut value: toml::Value = match toml::from_str(&content) { + Ok(value) => value, + Err(_) => return content, + }; + let changes = migrate::migrate_config(&mut value); + if changes.is_empty() { + return content; + } + if let Ok(migrated) = toml::to_string_pretty(&value) { + if let Err(e) = fs::write(path, &migrated) { + eprintln!( + "warning: could not update config file after migration ({}): {e}", + path.display() + ); + } + return migrated; + } + content +} + pub fn save(paths: &AppPaths, config: &ConfigFile) -> Result<(), AppError> { crate::profile::validate_config(config)?; ensure_dir(&paths.config_dir)?; @@ -121,31 +145,33 @@ fn ensure_dir(path: &Path) -> Result<(), AppError> { .map_err(|error| internal_error(format!("failed creating {}: {error}", path.display()))) } -pub fn render_config(config: &ConfigFile) -> String { +pub fn render_config(config: &ConfigFile, effective: bool) -> String { let mut output = String::new(); let mut resolved_defaults = ResolvedSettings::default(); resolved_defaults.apply(&config.defaults); output.push_str(&format!("{}\n", crate::color::bold("[defaults]"))); - output.push_str(&render_resolved_settings(&resolved_defaults)); + let sparse = if effective { + None + } else { + Some(&config.defaults) + }; + output.push_str(&render_resolved_settings(&resolved_defaults, sparse)); output.push_str(&format!("\n{}\n", crate::color::bold("[profiles]"))); if config.profiles.is_empty() { output.push_str(&format!(" {}\n", crate::color::dim("(none)"))); } else { for (name, profile) in &config.profiles { - output.push_str(&format!( - " {}{}\n", - crate::color::accent(name), - crate::color::dim(&inherits_suffix(&profile.inherits)) - )); - let inherited = crate::profile::resolve_named(config, name) + output.push_str(&format!(" {}\n", crate::color::accent(name))); + let resolved = crate::profile::resolve_named(config, name) .map(|resolved| resolved.settings) .unwrap_or_else(|_| resolved_defaults.clone()); output.push_str(&render_profile_settings_with_indent( &profile.settings, - &inherited, + &resolved, " ", + effective, )); } } @@ -167,41 +193,41 @@ pub fn render_config(config: &ConfigFile) -> String { output } -pub fn render_profile(name: &str, profile: &ProfileConfig, inherited: &ResolvedSettings) -> String { +pub fn render_profile( + name: &str, + profile: &ProfileConfig, + effective_settings: &ResolvedSettings, + effective: bool, +) -> String { let mut output = String::new(); output.push_str(&crate::color::accent(name)); output.push('\n'); - if let Some(parent) = &profile.inherits { - output.push_str(&format!( - "{} {}\n", - crate::color::dim("inherits ="), - crate::color::accent(parent) - )); - } output.push_str(&render_profile_settings_with_indent( &profile.settings, - inherited, + effective_settings, "", + effective, )); output } fn render_profile_settings_with_indent( settings: &Settings, - inherited: &ResolvedSettings, + effective_settings: &ResolvedSettings, indent: &str, + effective: bool, ) -> String { let mut fields = BTreeMap::new(); fields.insert( - "overlay", + "mangohud", settings - .overlay + .mangohud .map(|value| if value { "on" } else { "off" }.to_string()), ); fields.insert( - "performance", + "gamemode", settings - .performance + .gamemode .map(|value| if value { "on" } else { "off" }.to_string()), ); fields.insert( @@ -220,67 +246,148 @@ fn render_profile_settings_with_indent( .verbose .map(|value| if value { "on" } else { "off" }.to_string()), ); + fields.insert( + "log-file", + settings + .log_file + .map(|value| if value { "on" } else { "off" }.to_string()), + ); + fields.insert("log-path", settings.log_path.clone()); fields.insert( "gamescope", settings .gamescope .map(|value| if value { "on" } else { "off" }.to_string()), ); - if settings.gamescope_width.is_some() || inherited.gamescope_width.is_some() { - fields.insert( - "gamescope-width", - settings.gamescope_width.map(|value| value.to_string()), - ); - } - if settings.gamescope_height.is_some() || inherited.gamescope_height.is_some() { - fields.insert( - "gamescope-height", - settings.gamescope_height.map(|value| value.to_string()), - ); - } - if settings.gamescope_fps.is_some() || inherited.gamescope_fps.is_some() { - fields.insert( - "gamescope-fps", - settings.gamescope_fps.map(|value| value.to_string()), - ); - } - if settings.fps_cap.is_some() || inherited.fps_cap.is_some() { - fields.insert("fps-cap", settings.fps_cap.map(|value| value.to_string())); - } + fields.insert( + "gamescope-width", + settings.gamescope_width.map(|value| value.as_display_str()), + ); + fields.insert( + "gamescope-height", + settings + .gamescope_height + .map(|value| value.as_display_str()), + ); + fields.insert( + "gamescope-fps", + settings.gamescope_fps.map(|value| value.to_string()), + ); + fields.insert( + "gamescope-nested-width", + settings + .gamescope_nested_width + .map(|value| value.to_string()), + ); + fields.insert( + "gamescope-nested-height", + settings + .gamescope_nested_height + .map(|value| value.to_string()), + ); + fields.insert( + "gamescope-unfocused-fps", + settings + .gamescope_unfocused_fps + .map(|value| value.to_string()), + ); + fields.insert( + "gamescope-scaler", + settings + .gamescope_scaler + .map(|value| value.as_str().to_string()), + ); + fields.insert( + "gamescope-filter", + settings + .gamescope_filter + .map(|value| value.as_str().to_string()), + ); + fields.insert( + "gamescope-sharpness", + settings.gamescope_sharpness.map(|value| value.to_string()), + ); + fields.insert( + "gamescope-mode", + settings + .gamescope_window_mode + .map(|value| value.as_str().to_string()), + ); + fields.insert( + "gamescope-adaptive-sync", + settings + .gamescope_adaptive_sync + .map(|value| if value { "on" } else { "off" }.to_string()), + ); + fields.insert( + "gamescope-hdr", + settings + .gamescope_hdr + .map(|value| if value { "on" } else { "off" }.to_string()), + ); + fields.insert( + "gamescope-steam", + settings + .gamescope_steam + .map(|value| if value { "on" } else { "off" }.to_string()), + ); + fields.insert( + "gamescope-expose-wayland", + settings + .gamescope_expose_wayland + .map(|value| if value { "on" } else { "off" }.to_string()), + ); + fields.insert( + "gamescope-mangoapp", + settings + .gamescope_mangoapp + .map(|value| if value { "on" } else { "off" }.to_string()), + ); + fields.insert("fps-cap", settings.fps_cap.map(|value| value.to_string())); + fields.insert( + "mangohud-log", + settings + .mangohud_log + .map(|value| if value { "on" } else { "off" }.to_string()), + ); + fields.insert("mangohud-log-path", settings.mangohud_log_path.clone()); fields.insert( "vkbasalt", settings .vkbasalt .map(|value| if value { "on" } else { "off" }.to_string()), ); - if settings.esync.is_some() || inherited.esync.is_some() { - fields.insert( - "esync", - settings - .esync - .map(|value| if value { "on" } else { "off" }.to_string()), - ); - } - if settings.fsync.is_some() || inherited.fsync.is_some() { - fields.insert( - "fsync", - settings - .fsync - .map(|value| if value { "on" } else { "off" }.to_string()), - ); - } + fields.insert("vkbasalt-config", settings.vkbasalt_config.clone()); + fields.insert( + "vkbasalt-log-level", + settings + .vkbasalt_log_level + .map(|value| value.as_str().to_string()), + ); + fields.insert( + "esync", + settings + .esync + .map(|value| if value { "on" } else { "off" }.to_string()), + ); + fields.insert( + "fsync", + settings + .fsync + .map(|value| if value { "on" } else { "off" }.to_string()), + ); fields.insert( "large-address-aware", settings .large_address_aware .map(|value| if value { "on" } else { "off" }.to_string()), ); - if settings.pre_launch.is_some() || inherited.pre_launch.is_some() { - fields.insert("pre-launch", settings.pre_launch.clone()); - } - if settings.post_launch.is_some() || inherited.post_launch.is_some() { - fields.insert("post-launch", settings.post_launch.clone()); - } + fields.insert("pre-launch", settings.pre_launch.clone()); + fields.insert("post-launch", settings.post_launch.clone()); + fields.insert( + "hook-errors", + settings.hook_errors.map(|value| value.as_str().to_string()), + ); let mut output = String::new(); for (name, value) in fields { @@ -295,15 +402,32 @@ fn render_profile_settings_with_indent( _ => crate::color::accent(value), } )), - None => output.push_str(&format!( - "{}{} = {}\n", - indent, - crate::color::dim(name), - crate::color::dim(&format!("(inherits: {})", inherited_value(name, inherited))) - )), + None if effective => { + let default_value = effective_value(name, effective_settings); + let display = if default_value == "none" { + crate::color::dim("(not set)") + } else { + crate::color::dim(&format!("(default: {})", default_value)) + }; + output.push_str(&format!( + "{}{} = {}\n", + indent, + crate::color::dim(name), + display + )); + } + None => {} } } - if settings.env_vars.is_some() || !inherited.env_vars.is_empty() { + let show_env = if effective { + settings.env_vars.is_some() || !effective_settings.env_vars.is_empty() + } else { + settings + .env_vars + .as_ref() + .is_some_and(|vars| !vars.is_empty()) + }; + if show_env { output.push_str(&format!("{indent}{}\n", crate::color::dim("env-vars:"))); match &settings.env_vars { Some(vars) if !vars.is_empty() => { @@ -316,179 +440,469 @@ fn render_profile_settings_with_indent( )); } } - _ if inherited.env_vars.is_empty() => { + _ if effective_settings.env_vars.is_empty() => { output.push_str(&format!("{} {}\n", indent, crate::color::dim("(none)"))); } _ => { - for (key, value) in &inherited.env_vars { + for (key, value) in &effective_settings.env_vars { output.push_str(&format!( "{} {} = {} {}\n", indent, crate::color::accent(key), value, - crate::color::dim("(inherited)") + crate::color::dim("(default)") )); } } } } + if !effective && output.is_empty() { + output.push_str(&format!( + "{} {}\n", + indent, + crate::color::dim("(no settings configured for this profile)") + )); + } output } -fn render_resolved_settings(settings: &ResolvedSettings) -> String { +fn render_resolved_settings(settings: &ResolvedSettings, sparse: Option<&Settings>) -> String { + macro_rules! show_if { + ($field:ident) => { + sparse.map_or(true, |s| s.$field.is_some()) + }; + } + let mut output = String::new(); - output.push_str(&format!( - "{} = {}\n", - crate::color::dim("overlay"), - crate::color::on_off(settings.overlay) - )); - output.push_str(&format!( - "{} = {}\n", - crate::color::dim("performance"), - crate::color::on_off(settings.performance) - )); - output.push_str(&format!( - "{} = {}\n", - crate::color::dim("steam-host-libs"), - crate::color::on_off(settings.steam_host_libs) - )); - output.push_str(&format!( - "{} = {}\n", - crate::color::dim("game-libs"), - crate::color::accent(settings.game_libs.as_str()) - )); - output.push_str(&format!( - "{} = {}\n", - crate::color::dim("verbose"), - crate::color::on_off(settings.verbose) - )); - output.push_str(&format!( - "{} = {}\n", - crate::color::dim("gamescope"), - crate::color::on_off(settings.gamescope) - )); - if let Some(width) = settings.gamescope_width { + if show_if!(mangohud) { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("mangohud"), + crate::color::on_off(settings.mangohud) + )); + } + if show_if!(gamemode) { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("gamemode"), + crate::color::on_off(settings.gamemode) + )); + } + if show_if!(steam_host_libs) { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("steam-host-libs"), + crate::color::on_off(settings.steam_host_libs) + )); + } + if show_if!(game_libs) { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("game-libs"), + crate::color::accent(settings.game_libs.as_str()) + )); + } + if show_if!(verbose) { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("verbose"), + crate::color::on_off(settings.verbose) + )); + } + if show_if!(log_file) { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("log-file"), + if settings.log_file { + crate::color::ok("on") + } else { + crate::color::dim("off (default)") + } + )); + } + if show_if!(log_path) { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("log-path"), + match &settings.log_path { + Some(v) => crate::color::accent(v), + None => crate::color::dim("(not set — ~/.local/state/gamewrap/gamewrap.log)"), + } + )); + } + if show_if!(gamescope) { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("gamescope"), + crate::color::on_off(settings.gamescope) + )); + } + if show_if!(gamescope_width) { output.push_str(&format!( "{} = {}\n", crate::color::dim("gamescope-width"), - crate::color::accent(&width.to_string()) + match settings.gamescope_width { + Some(crate::config::GamescopeSize::Pixels(v)) => { + crate::color::accent(&v.to_string()) + } + Some(crate::config::GamescopeSize::Native) => crate::color::accent("native"), + None => crate::color::dim("(not set — gamescope default: 1280)"), + } )); } - if let Some(height) = settings.gamescope_height { + if show_if!(gamescope_height) { output.push_str(&format!( "{} = {}\n", crate::color::dim("gamescope-height"), - crate::color::accent(&height.to_string()) + match settings.gamescope_height { + Some(crate::config::GamescopeSize::Pixels(v)) => { + crate::color::accent(&v.to_string()) + } + Some(crate::config::GamescopeSize::Native) => crate::color::accent("native"), + None => crate::color::dim("(not set — gamescope default: 720)"), + } )); } - if let Some(fps) = settings.gamescope_fps { + if show_if!(gamescope_fps) { output.push_str(&format!( "{} = {}\n", crate::color::dim("gamescope-fps"), - crate::color::accent(&fps.to_string()) + match settings.gamescope_fps { + Some(v) => crate::color::accent(&v.to_string()), + None => crate::color::dim("(not set — unlimited)"), + } )); } - if let Some(fps_cap) = settings.fps_cap { + if show_if!(gamescope_nested_width) { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("gamescope-nested-width"), + match settings.gamescope_nested_width { + Some(v) => crate::color::accent(&v.to_string()), + None => crate::color::dim("(not set — matches output width)"), + } + )); + } + if show_if!(gamescope_nested_height) { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("gamescope-nested-height"), + match settings.gamescope_nested_height { + Some(v) => crate::color::accent(&v.to_string()), + None => crate::color::dim("(not set — matches output height)"), + } + )); + } + if show_if!(gamescope_unfocused_fps) { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("gamescope-unfocused-fps"), + match settings.gamescope_unfocused_fps { + Some(v) => crate::color::accent(&v.to_string()), + None => crate::color::dim("(not set — no limit)"), + } + )); + } + if show_if!(gamescope_scaler) { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("gamescope-scaler"), + match settings.gamescope_scaler { + Some(v) => crate::color::accent(v.as_str()), + None => crate::color::dim("(not set — auto)"), + } + )); + } + if show_if!(gamescope_filter) { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("gamescope-filter"), + match settings.gamescope_filter { + Some(v) => crate::color::accent(v.as_str()), + None => crate::color::dim("(not set — linear)"), + } + )); + } + if show_if!(gamescope_sharpness) { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("gamescope-sharpness"), + match settings.gamescope_sharpness { + Some(v) => crate::color::accent(&v.to_string()), + None => crate::color::dim("(not set — no sharpening)"), + } + )); + } + if show_if!(gamescope_window_mode) { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("gamescope-mode"), + match settings.gamescope_window_mode { + Some(v) => crate::color::accent(v.as_str()), + None => crate::color::dim("windowed (default)"), + } + )); + } + if show_if!(gamescope_adaptive_sync) { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("gamescope-adaptive-sync"), + crate::color::on_off(settings.gamescope_adaptive_sync) + )); + } + if show_if!(gamescope_hdr) { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("gamescope-hdr"), + crate::color::on_off(settings.gamescope_hdr) + )); + } + if show_if!(gamescope_steam) { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("gamescope-steam"), + crate::color::on_off(settings.gamescope_steam) + )); + } + if show_if!(gamescope_expose_wayland) { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("gamescope-expose-wayland"), + crate::color::on_off(settings.gamescope_expose_wayland) + )); + } + if show_if!(gamescope_mangoapp) { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("gamescope-mangoapp"), + crate::color::on_off(settings.gamescope_mangoapp) + )); + } + if show_if!(fps_cap) { output.push_str(&format!( "{} = {}\n", crate::color::dim("fps-cap"), - crate::color::accent(&fps_cap.to_string()) + match settings.fps_cap { + Some(v) => crate::color::accent(&v.to_string()), + None => crate::color::dim("(not set — no cap)"), + } )); } - output.push_str(&format!( - "{} = {}\n", - crate::color::dim("vkbasalt"), - crate::color::on_off(settings.vkbasalt) - )); - if let Some(esync) = settings.esync { + if show_if!(mangohud_log) { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("mangohud-log"), + crate::color::on_off(settings.mangohud_log) + )); + } + if show_if!(mangohud_log_path) { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("mangohud-log-path"), + match &settings.mangohud_log_path { + Some(v) => crate::color::accent(v), + None => crate::color::dim("(not set — MangoHud default location)"), + } + )); + } + if show_if!(vkbasalt) { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("vkbasalt"), + crate::color::on_off(settings.vkbasalt) + )); + } + if show_if!(vkbasalt_config) { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("vkbasalt-config"), + match &settings.vkbasalt_config { + Some(v) => crate::color::accent(v), + None => crate::color::dim("(not set — ~/.config/vkBasalt/vkBasalt.conf)"), + } + )); + } + if show_if!(vkbasalt_log_level) { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("vkbasalt-log-level"), + match settings.vkbasalt_log_level { + Some(v) => crate::color::accent(v.as_str()), + None => crate::color::dim("(not set — vkBasalt default)"), + } + )); + } + if show_if!(esync) { output.push_str(&format!( "{} = {}\n", crate::color::dim("esync"), - crate::color::on_off(esync) + match settings.esync { + Some(value) => crate::color::on_off(value), + None => crate::color::dim("(not set — Proton default)"), + } )); } - if let Some(fsync) = settings.fsync { + if show_if!(fsync) { output.push_str(&format!( "{} = {}\n", crate::color::dim("fsync"), - crate::color::on_off(fsync) + match settings.fsync { + Some(value) => crate::color::on_off(value), + None => crate::color::dim("(not set — Proton default)"), + } )); } - output.push_str(&format!( - "{} = {}\n", - crate::color::dim("large-address-aware"), - crate::color::on_off(settings.large_address_aware) - )); - if let Some(pre_launch) = &settings.pre_launch { + if show_if!(large_address_aware) { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("large-address-aware"), + crate::color::on_off(settings.large_address_aware) + )); + } + if show_if!(pre_launch) { output.push_str(&format!( "{} = {}\n", crate::color::dim("pre-launch"), - crate::color::accent(pre_launch) + match &settings.pre_launch { + Some(v) => crate::color::accent(v), + None => crate::color::dim("(not set — none)"), + } )); } - if let Some(post_launch) = &settings.post_launch { + if show_if!(post_launch) { output.push_str(&format!( "{} = {}\n", crate::color::dim("post-launch"), - crate::color::accent(post_launch) + match &settings.post_launch { + Some(v) => crate::color::accent(v), + None => crate::color::dim("(not set — none)"), + } )); } - if !settings.env_vars.is_empty() { + if show_if!(hook_errors) { + output.push_str(&format!( + "{} = {}\n", + crate::color::dim("hook-errors"), + match settings.hook_errors { + HookErrors::Fail => crate::color::warn("fail"), + HookErrors::Warn => crate::color::dim("warn (default)"), + } + )); + } + if sparse.is_none_or(|s| s.env_vars.is_some()) && !settings.env_vars.is_empty() { output.push_str(&format!("{}\n", crate::color::dim("env-vars:"))); for (key, value) in &settings.env_vars { output.push_str(&format!(" {} = {}\n", crate::color::accent(key), value)); } } + if sparse.is_some() && output.is_empty() { + output.push_str(&format!( + " {}\n", + crate::color::dim( + "(no global settings configured — run 'gamewrap config set' to customize)" + ) + )); + } output } -fn inherited_value(name: &str, inherited: &ResolvedSettings) -> String { +fn effective_value(name: &str, settings: &ResolvedSettings) -> String { match name { - "overlay" => crate::color::on_off(inherited.overlay), - "performance" => crate::color::on_off(inherited.performance), - "steam-host-libs" => crate::color::on_off(inherited.steam_host_libs), - "game-libs" => inherited.game_libs.as_str().to_string(), - "verbose" => crate::color::on_off(inherited.verbose), - "gamescope" => crate::color::on_off(inherited.gamescope), - "gamescope-width" => inherited + "mangohud" => crate::color::on_off(settings.mangohud), + "gamemode" => crate::color::on_off(settings.gamemode), + "steam-host-libs" => crate::color::on_off(settings.steam_host_libs), + "game-libs" => settings.game_libs.as_str().to_string(), + "verbose" => crate::color::on_off(settings.verbose), + "log-file" => crate::color::on_off(settings.log_file), + "log-path" => settings.log_path.as_deref().unwrap_or("none").to_string(), + "gamescope" => crate::color::on_off(settings.gamescope), + "gamescope-width" => settings .gamescope_width - .map(|value| value.to_string()) + .map(|value| value.as_display_str()) .unwrap_or_else(|| "none".to_string()), - "gamescope-height" => inherited + "gamescope-height" => settings .gamescope_height - .map(|value| value.to_string()) + .map(|value| value.as_display_str()) .unwrap_or_else(|| "none".to_string()), - "gamescope-fps" => inherited + "gamescope-fps" => settings .gamescope_fps .map(|value| value.to_string()) .unwrap_or_else(|| "none".to_string()), - "fps-cap" => inherited + "gamescope-nested-width" => settings + .gamescope_nested_width + .map(|value| value.to_string()) + .unwrap_or_else(|| "none".to_string()), + "gamescope-nested-height" => settings + .gamescope_nested_height + .map(|value| value.to_string()) + .unwrap_or_else(|| "none".to_string()), + "gamescope-unfocused-fps" => settings + .gamescope_unfocused_fps + .map(|value| value.to_string()) + .unwrap_or_else(|| "none".to_string()), + "gamescope-scaler" => settings + .gamescope_scaler + .map(|value| value.as_str().to_string()) + .unwrap_or_else(|| "none".to_string()), + "gamescope-filter" => settings + .gamescope_filter + .map(|value| value.as_str().to_string()) + .unwrap_or_else(|| "none".to_string()), + "gamescope-sharpness" => settings + .gamescope_sharpness + .map(|value| value.to_string()) + .unwrap_or_else(|| "none".to_string()), + "gamescope-mode" => settings + .gamescope_window_mode + .map(|value| value.as_str().to_string()) + .unwrap_or_else(|| "none".to_string()), + "gamescope-adaptive-sync" => crate::color::on_off(settings.gamescope_adaptive_sync), + "gamescope-hdr" => crate::color::on_off(settings.gamescope_hdr), + "gamescope-steam" => crate::color::on_off(settings.gamescope_steam), + "gamescope-expose-wayland" => crate::color::on_off(settings.gamescope_expose_wayland), + "gamescope-mangoapp" => crate::color::on_off(settings.gamescope_mangoapp), + "fps-cap" => settings .fps_cap .map(|value| value.to_string()) .unwrap_or_else(|| "none".to_string()), - "vkbasalt" => crate::color::on_off(inherited.vkbasalt), - "esync" => crate::color::option_on_off(inherited.esync), - "fsync" => crate::color::option_on_off(inherited.fsync), - "large-address-aware" => crate::color::on_off(inherited.large_address_aware), - "pre-launch" => inherited + "mangohud-log" => crate::color::on_off(settings.mangohud_log), + "mangohud-log-path" => settings + .mangohud_log_path + .as_deref() + .unwrap_or("none") + .to_string(), + "vkbasalt" => crate::color::on_off(settings.vkbasalt), + "vkbasalt-config" => settings + .vkbasalt_config + .as_deref() + .unwrap_or("none") + .to_string(), + "vkbasalt-log-level" => settings + .vkbasalt_log_level + .map(|value| value.as_str().to_string()) + .unwrap_or_else(|| "none".to_string()), + "esync" => settings + .esync + .map(crate::color::on_off) + .unwrap_or_else(|| "none".to_string()), + "fsync" => settings + .fsync + .map(crate::color::on_off) + .unwrap_or_else(|| "none".to_string()), + "large-address-aware" => crate::color::on_off(settings.large_address_aware), + "pre-launch" => settings .pre_launch .clone() .unwrap_or_else(|| "none".to_string()), - "post-launch" => inherited + "post-launch" => settings .post_launch .clone() .unwrap_or_else(|| "none".to_string()), + "hook-errors" => settings.hook_errors.as_str().to_string(), _ => "unknown".to_string(), } } -fn inherits_suffix(parent: &Option) -> String { - parent - .as_ref() - .map(|parent| format!(" (inherits: {parent})")) - .unwrap_or_default() -} - #[cfg(test)] pub fn test_paths(root: &Path) -> AppPaths { AppPaths { @@ -510,7 +924,7 @@ mod tests { let temp = tempdir().expect("temp dir"); let paths = test_paths(temp.path()); let mut config = ConfigFile::default(); - config.defaults.overlay = Some(true); + config.defaults.mangohud = Some(true); config .profiles .insert("benchmark".to_string(), ProfileConfig::default()); @@ -518,7 +932,7 @@ mod tests { save(&paths, &config).expect("save"); let loaded = load(&paths).expect("load"); - assert_eq!(loaded.defaults.overlay, Some(true)); + assert_eq!(loaded.defaults.mangohud, Some(true)); assert!(loaded.profiles.contains_key("benchmark")); } } diff --git a/src/config/schema.rs b/src/config/schema.rs index e81ec13..4472755 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -21,12 +21,177 @@ impl GameLibsMode { } } +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum GamescopeScaler { + Auto, + Integer, + Fit, + Fill, + Stretch, +} + +impl GamescopeScaler { + pub fn as_str(self) -> &'static str { + match self { + Self::Auto => "auto", + Self::Integer => "integer", + Self::Fit => "fit", + Self::Fill => "fill", + Self::Stretch => "stretch", + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum GamescopeFilter { + Linear, + Nearest, + Fsr, + Nis, + Pixel, +} + +impl GamescopeFilter { + pub fn as_str(self) -> &'static str { + match self { + Self::Linear => "linear", + Self::Nearest => "nearest", + Self::Fsr => "fsr", + Self::Nis => "nis", + Self::Pixel => "pixel", + } + } +} + +/// Output resolution value for gamescope -W/-H. +/// Native = detect display resolution at launch; Pixels = explicit pixel count. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum GamescopeSize { + Native, + Pixels(u32), +} + +impl GamescopeSize { + pub fn as_display_str(self) -> String { + match self { + Self::Native => "native".to_string(), + Self::Pixels(n) => n.to_string(), + } + } +} + +impl serde::Serialize for GamescopeSize { + fn serialize(&self, s: S) -> Result { + match self { + Self::Native => s.serialize_str("native"), + Self::Pixels(n) => s.serialize_u32(*n), + } + } +} + +impl<'de> serde::Deserialize<'de> for GamescopeSize { + fn deserialize>(d: D) -> Result { + struct Visitor; + + impl<'de> serde::de::Visitor<'de> for Visitor { + type Value = GamescopeSize; + + fn expecting(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!(f, "a pixel count integer or the string \"native\"") + } + + fn visit_u64(self, v: u64) -> Result { + Ok(GamescopeSize::Pixels(v as u32)) + } + + fn visit_i64(self, v: i64) -> Result { + if v < 0 { + Err(E::custom("pixel count cannot be negative")) + } else { + Ok(GamescopeSize::Pixels(v as u32)) + } + } + + fn visit_str(self, v: &str) -> Result { + if v == "native" { + Ok(GamescopeSize::Native) + } else { + v.parse::().map(GamescopeSize::Pixels).map_err(|_| { + E::custom(format!("expected a pixel count or \"native\", got {v:?}")) + }) + } + } + } + + d.deserialize_any(Visitor) + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum GamescopeWindowMode { + Windowed, + Borderless, + Fullscreen, +} + +impl GamescopeWindowMode { + pub fn as_str(self) -> &'static str { + match self { + Self::Windowed => "windowed", + Self::Borderless => "borderless", + Self::Fullscreen => "fullscreen", + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "kebab-case")] +pub enum VkbasaltLogLevel { + Debug, + Info, + Warning, + Error, + None, +} + +impl VkbasaltLogLevel { + pub fn as_str(self) -> &'static str { + match self { + Self::Debug => "debug", + Self::Info => "info", + Self::Warning => "warning", + Self::Error => "error", + Self::None => "none", + } + } +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)] +#[serde(rename_all = "kebab-case")] +pub enum HookErrors { + #[default] + Warn, + Fail, +} + +impl HookErrors { + pub fn as_str(self) -> &'static str { + match self { + Self::Warn => "warn", + Self::Fail => "fail", + } + } +} + #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] pub struct Settings { #[serde(default, skip_serializing_if = "Option::is_none")] - pub overlay: Option, + pub mangohud: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub performance: Option, + pub gamemode: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub steam_host_libs: Option, #[serde(default, skip_serializing_if = "Option::is_none")] @@ -34,18 +199,54 @@ pub struct Settings { #[serde(default, skip_serializing_if = "Option::is_none")] pub verbose: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub log_file: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub log_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub gamescope: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub gamescope_width: Option, + pub gamescope_width: Option, #[serde(default, skip_serializing_if = "Option::is_none")] - pub gamescope_height: Option, + pub gamescope_height: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub gamescope_fps: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub gamescope_nested_width: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gamescope_nested_height: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gamescope_unfocused_fps: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gamescope_scaler: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gamescope_filter: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gamescope_sharpness: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gamescope_window_mode: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gamescope_adaptive_sync: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gamescope_hdr: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gamescope_steam: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gamescope_expose_wayland: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub gamescope_mangoapp: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub fps_cap: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub mangohud_log: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub mangohud_log_path: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub vkbasalt: Option, #[serde(default, skip_serializing_if = "Option::is_none")] + pub vkbasalt_config: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub vkbasalt_log_level: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] pub esync: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub fsync: Option, @@ -55,50 +256,138 @@ pub struct Settings { pub pre_launch: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub post_launch: Option, + #[serde( + default, + rename = "hook-errors", + skip_serializing_if = "Option::is_none" + )] + pub hook_errors: Option, #[serde(default, skip_serializing_if = "Option::is_none")] pub env_vars: Option>, } #[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)] +#[serde(default)] pub struct ResolvedSettings { - pub overlay: bool, - pub performance: bool, + pub mangohud: bool, + pub gamemode: bool, pub steam_host_libs: bool, pub game_libs: GameLibsMode, pub verbose: bool, + pub log_file: bool, + pub log_path: Option, pub gamescope: bool, - pub gamescope_width: Option, - pub gamescope_height: Option, + pub gamescope_width: Option, + pub gamescope_height: Option, pub gamescope_fps: Option, + pub gamescope_nested_width: Option, + pub gamescope_nested_height: Option, + pub gamescope_unfocused_fps: Option, + pub gamescope_scaler: Option, + pub gamescope_filter: Option, + pub gamescope_sharpness: Option, + pub gamescope_window_mode: Option, + pub gamescope_adaptive_sync: bool, + pub gamescope_hdr: bool, + pub gamescope_steam: bool, + pub gamescope_expose_wayland: bool, + pub gamescope_mangoapp: bool, pub fps_cap: Option, + pub mangohud_log: bool, + pub mangohud_log_path: Option, pub vkbasalt: bool, + pub vkbasalt_config: Option, + pub vkbasalt_log_level: Option, pub esync: Option, pub fsync: Option, pub large_address_aware: bool, pub pre_launch: Option, pub post_launch: Option, + pub hook_errors: HookErrors, pub env_vars: BTreeMap, } +impl From for Settings { + fn from(r: ResolvedSettings) -> Self { + Settings { + mangohud: Some(r.mangohud), + gamemode: Some(r.gamemode), + steam_host_libs: Some(r.steam_host_libs), + game_libs: Some(r.game_libs), + verbose: Some(r.verbose), + log_file: Some(r.log_file), + log_path: r.log_path, + gamescope: Some(r.gamescope), + gamescope_width: r.gamescope_width, + gamescope_height: r.gamescope_height, + gamescope_fps: r.gamescope_fps, + gamescope_nested_width: r.gamescope_nested_width, + gamescope_nested_height: r.gamescope_nested_height, + gamescope_unfocused_fps: r.gamescope_unfocused_fps, + gamescope_scaler: r.gamescope_scaler, + gamescope_filter: r.gamescope_filter, + gamescope_sharpness: r.gamescope_sharpness, + gamescope_window_mode: r.gamescope_window_mode, + gamescope_adaptive_sync: Some(r.gamescope_adaptive_sync), + gamescope_hdr: Some(r.gamescope_hdr), + gamescope_steam: Some(r.gamescope_steam), + gamescope_expose_wayland: Some(r.gamescope_expose_wayland), + gamescope_mangoapp: Some(r.gamescope_mangoapp), + fps_cap: r.fps_cap, + mangohud_log: Some(r.mangohud_log), + mangohud_log_path: r.mangohud_log_path, + vkbasalt: Some(r.vkbasalt), + vkbasalt_config: r.vkbasalt_config, + vkbasalt_log_level: r.vkbasalt_log_level, + esync: r.esync, + fsync: r.fsync, + large_address_aware: Some(r.large_address_aware), + pre_launch: r.pre_launch, + post_launch: r.post_launch, + hook_errors: Some(r.hook_errors), + env_vars: (!r.env_vars.is_empty()).then_some(r.env_vars), + } + } +} + impl Default for ResolvedSettings { fn default() -> Self { Self { - overlay: true, - performance: true, + mangohud: true, + gamemode: true, steam_host_libs: true, game_libs: GameLibsMode::Auto, verbose: false, + log_file: false, + log_path: None, gamescope: false, gamescope_width: None, gamescope_height: None, gamescope_fps: None, + gamescope_nested_width: None, + gamescope_nested_height: None, + gamescope_unfocused_fps: None, + gamescope_scaler: None, + gamescope_filter: None, + gamescope_sharpness: None, + gamescope_window_mode: None, + gamescope_adaptive_sync: false, + gamescope_hdr: false, + gamescope_steam: false, + gamescope_expose_wayland: false, + gamescope_mangoapp: false, fps_cap: None, + mangohud_log: false, + mangohud_log_path: None, vkbasalt: false, + vkbasalt_config: None, + vkbasalt_log_level: None, esync: None, fsync: None, large_address_aware: false, pre_launch: None, post_launch: None, + hook_errors: HookErrors::Warn, env_vars: BTreeMap::new(), } } @@ -106,11 +395,11 @@ impl Default for ResolvedSettings { impl ResolvedSettings { pub fn apply(&mut self, settings: &Settings) { - if let Some(value) = settings.overlay { - self.overlay = value; + if let Some(value) = settings.mangohud { + self.mangohud = value; } - if let Some(value) = settings.performance { - self.performance = value; + if let Some(value) = settings.gamemode { + self.gamemode = value; } if let Some(value) = settings.steam_host_libs { self.steam_host_libs = value; @@ -121,6 +410,12 @@ impl ResolvedSettings { if let Some(value) = settings.verbose { self.verbose = value; } + if let Some(value) = settings.log_file { + self.log_file = value; + } + if let Some(ref value) = settings.log_path { + self.log_path = Some(value.clone()); + } if let Some(value) = settings.gamescope { self.gamescope = value; } @@ -133,12 +428,60 @@ impl ResolvedSettings { if let Some(value) = settings.gamescope_fps { self.gamescope_fps = Some(value); } + if let Some(value) = settings.gamescope_nested_width { + self.gamescope_nested_width = Some(value); + } + if let Some(value) = settings.gamescope_nested_height { + self.gamescope_nested_height = Some(value); + } + if let Some(value) = settings.gamescope_unfocused_fps { + self.gamescope_unfocused_fps = Some(value); + } + if let Some(value) = settings.gamescope_scaler { + self.gamescope_scaler = Some(value); + } + if let Some(value) = settings.gamescope_filter { + self.gamescope_filter = Some(value); + } + if let Some(value) = settings.gamescope_sharpness { + self.gamescope_sharpness = Some(value); + } + if let Some(value) = settings.gamescope_window_mode { + self.gamescope_window_mode = Some(value); + } + if let Some(value) = settings.gamescope_adaptive_sync { + self.gamescope_adaptive_sync = value; + } + if let Some(value) = settings.gamescope_hdr { + self.gamescope_hdr = value; + } + if let Some(value) = settings.gamescope_steam { + self.gamescope_steam = value; + } + if let Some(value) = settings.gamescope_expose_wayland { + self.gamescope_expose_wayland = value; + } + if let Some(value) = settings.gamescope_mangoapp { + self.gamescope_mangoapp = value; + } if let Some(value) = settings.fps_cap { self.fps_cap = Some(value); } + if let Some(value) = settings.mangohud_log { + self.mangohud_log = value; + } + if let Some(ref value) = settings.mangohud_log_path { + self.mangohud_log_path = Some(value.clone()); + } if let Some(value) = settings.vkbasalt { self.vkbasalt = value; } + if let Some(ref value) = settings.vkbasalt_config { + self.vkbasalt_config = Some(value.clone()); + } + if let Some(value) = settings.vkbasalt_log_level { + self.vkbasalt_log_level = Some(value); + } if let Some(value) = settings.esync { self.esync = Some(value); } @@ -154,6 +497,9 @@ impl ResolvedSettings { if let Some(ref value) = settings.post_launch { self.post_launch = Some(value.clone()); } + if let Some(value) = settings.hook_errors { + self.hook_errors = value; + } if let Some(ref vars) = settings.env_vars { for (key, value) in vars { self.env_vars.insert(key.clone(), value.clone()); @@ -170,8 +516,6 @@ pub struct Binding { #[derive(Debug, Clone, Serialize, Deserialize, Default, PartialEq, Eq)] pub struct ProfileConfig { - #[serde(default, skip_serializing_if = "Option::is_none")] - pub inherits: Option, #[serde(flatten)] pub settings: Settings, } diff --git a/src/doctor.rs b/src/doctor.rs index e1f46de..18ddff2 100644 --- a/src/doctor.rs +++ b/src/doctor.rs @@ -11,60 +11,110 @@ pub fn render( command: Option<&[OsString]>, ) -> String { let mut output = String::new(); - output.push_str("gamewrap doctor\n"); - output.push_str("Assumed launch context: Steam\n"); + output.push_str(&format!("{}\n", crate::color::bold("gamewrap doctor"))); output.push_str(&format!( - "Failure notifier: {}\n", + "{} Steam\n", + crate::color::dim("Assumed launch context:") + )); + output.push_str(&format!( + "{} {}\n", + crate::color::dim("Failure notifier:"), notify::available_notifier_name() )); - output.push_str("\nResolved settings:\n"); - output.push_str(&format!(" overlay: {}\n", color::on_off(settings.overlay))); + output.push_str(&format!("\n{}\n", crate::color::bold("Resolved settings:"))); output.push_str(&format!( - " performance: {}\n", - color::on_off(settings.performance) + " {} {}\n", + crate::color::dim("mangohud:"), + color::on_off(settings.mangohud) )); output.push_str(&format!( - " steam-host-libs: {}\n", + " {} {}\n", + crate::color::dim("gamemode:"), + color::on_off(settings.gamemode) + )); + output.push_str(&format!( + " {} {}\n", + crate::color::dim("steam-host-libs:"), color::on_off(settings.steam_host_libs) )); - output.push_str(&format!(" game-libs: {}\n", settings.game_libs.as_str())); - output.push_str(&format!(" verbose: {}\n", color::on_off(settings.verbose))); output.push_str(&format!( - " gamescope: {}\n", + " {} {}\n", + crate::color::dim("game-libs:"), + crate::color::accent(settings.game_libs.as_str()) + )); + output.push_str(&format!( + " {} {}\n", + crate::color::dim("verbose:"), + color::on_off(settings.verbose) + )); + output.push_str(&format!( + " {} {}\n", + crate::color::dim("gamescope:"), color::on_off(settings.gamescope) )); if settings.gamescope { if let Some(width) = settings.gamescope_width { - output.push_str(&format!(" gamescope-width: {width}\n")); + output.push_str(&format!( + " {} {}\n", + crate::color::dim("gamescope-width:"), + crate::color::accent(&width.as_display_str()) + )); } if let Some(height) = settings.gamescope_height { - output.push_str(&format!(" gamescope-height: {height}\n")); + output.push_str(&format!( + " {} {}\n", + crate::color::dim("gamescope-height:"), + crate::color::accent(&height.as_display_str()) + )); } if let Some(fps) = settings.gamescope_fps { - output.push_str(&format!(" gamescope-fps: {fps}\n")); + output.push_str(&format!( + " {} {}\n", + crate::color::dim("gamescope-fps:"), + crate::color::accent(&fps.to_string()) + )); } } output.push_str(&format!( - " vkbasalt: {}\n", + " {} {}\n", + crate::color::dim("vkbasalt:"), color::on_off(settings.vkbasalt) )); output.push_str(&format!( - " esync: {}\n", + " {} {}\n", + crate::color::dim("esync:"), color::option_on_off(settings.esync) )); output.push_str(&format!( - " fsync: {}\n", + " {} {}\n", + crate::color::dim("fsync:"), color::option_on_off(settings.fsync) )); output.push_str(&format!( - " large-address-aware: {}\n", + " {} {}\n", + crate::color::dim("large-address-aware:"), color::on_off(settings.large_address_aware) )); if let Some(pre_cmd) = &settings.pre_launch { - output.push_str(&format!(" pre-launch: {pre_cmd}\n")); + output.push_str(&format!( + " {} {}\n", + crate::color::dim("pre-launch:"), + crate::color::accent(pre_cmd) + )); } if let Some(post_cmd) = &settings.post_launch { - output.push_str(&format!(" post-launch: {post_cmd}\n")); + output.push_str(&format!( + " {} {}\n", + crate::color::dim("post-launch:"), + crate::color::accent(post_cmd) + )); + } + if settings.pre_launch.is_some() || settings.post_launch.is_some() { + output.push_str(&format!( + " {} {}\n", + crate::color::dim("hook-errors:"), + crate::color::accent(settings.hook_errors.as_str()) + )); } if let Some(command) = command { @@ -73,22 +123,27 @@ pub fn render( .map(|part| part.to_string_lossy().into_owned()) .collect::>() .join(" "); - output.push_str(&format!("\nTarget command:\n {rendered}\n")); + output.push_str(&format!( + "\n{}\n {}\n", + crate::color::bold("Target command:"), + crate::color::accent(&rendered) + )); } - output.push_str("\nChecks:\n"); + output.push_str(&format!("\n{}\n", crate::color::bold("Checks:"))); for check in &report.checks { output.push_str(&format!( " [{}] {}: {}\n", status_label(check.status), - check.name, + crate::color::dim(&check.name), check.detail )); } - output.push_str("\nSummary:\n"); + output.push_str(&format!("\n{}\n", crate::color::bold("Summary:"))); output.push_str(&format!( - " overall: {}\n", + " {} {}\n", + crate::color::dim("overall:"), if report.has_failures() { color::fail("fail") } else if report.has_warnings() { @@ -98,7 +153,8 @@ pub fn render( } )); output.push_str(&format!( - " launchable: {}\n", + " {} {}\n", + crate::color::dim("launchable:"), if report.launchable() { color::ok("yes") } else { diff --git a/src/env.rs b/src/env.rs index 0718a35..ae109ce 100644 --- a/src/env.rs +++ b/src/env.rs @@ -36,9 +36,32 @@ pub fn build_env(settings: ResolvedSettings) -> BTreeMap { if settings.vkbasalt { env.insert(OsString::from("ENABLE_VKBASALT"), OsString::from("1")); + if let Some(ref cfg) = settings.vkbasalt_config { + env.insert( + OsString::from("VKBASALT_CONFIG_FILE"), + OsString::from(cfg.as_str()), + ); + } + if let Some(level) = settings.vkbasalt_log_level { + env.insert( + OsString::from("VKBASALT_LOG_LEVEL"), + OsString::from(level.as_str()), + ); + } } - if let Some(fps_cap) = settings.fps_cap.filter(|_| settings.overlay) { + if settings.mangohud && settings.mangohud_log { + env.insert(OsString::from("MANGOHUD_LOG"), OsString::from("1")); + env.insert(OsString::from("MANGOHUD_LOG_DURATION"), OsString::from("0")); + if let Some(ref path) = settings.mangohud_log_path { + env.insert( + OsString::from("MANGOHUD_OUTPUT"), + OsString::from(path.as_str()), + ); + } + } + + if let Some(fps_cap) = settings.fps_cap.filter(|_| settings.mangohud) { env.insert( OsString::from("MANGOHUD_PARAMS"), OsString::from(format!("fps_limit={fps_cap}")), @@ -72,7 +95,7 @@ pub fn build_env(settings: ResolvedSettings) -> BTreeMap { } pub fn needs_host_library_injection(settings: &ResolvedSettings) -> bool { - if !settings.performance { + if !settings.gamemode { return false; } @@ -116,7 +139,7 @@ mod tests { #[test] fn gamemode_mode_always_sets_library_path() { let env = build_env(ResolvedSettings { - performance: true, + gamemode: true, game_libs: GameLibsMode::Gamemode, ..ResolvedSettings::default() }); @@ -131,7 +154,7 @@ mod tests { #[test] fn keep_mode_preserves_library_path() { let env = build_env(ResolvedSettings { - performance: true, + gamemode: true, game_libs: GameLibsMode::Keep, ..ResolvedSettings::default() }); @@ -142,7 +165,7 @@ mod tests { #[test] fn fps_cap_uses_mangohud_params_when_overlay_is_enabled() { let env = build_env(ResolvedSettings { - overlay: true, + mangohud: true, fps_cap: Some(60), ..ResolvedSettings::default() }); @@ -156,7 +179,7 @@ mod tests { #[test] fn fps_cap_is_ignored_when_overlay_is_disabled() { let env = build_env(ResolvedSettings { - overlay: false, + mangohud: false, fps_cap: Some(60), ..ResolvedSettings::default() }); @@ -164,10 +187,43 @@ mod tests { assert!(!env.contains_key(&OsString::from("MANGOHUD_PARAMS"))); } + #[test] + fn mangohud_logging_requires_overlay_and_sets_optional_output() { + let env = build_env(ResolvedSettings { + mangohud: true, + mangohud_log: true, + mangohud_log_path: Some("/tmp/mangohud.csv".to_string()), + ..ResolvedSettings::default() + }); + + assert_eq!( + env.get(&OsString::from("MANGOHUD_LOG")), + Some(&OsString::from("1")) + ); + assert_eq!( + env.get(&OsString::from("MANGOHUD_LOG_DURATION")), + Some(&OsString::from("0")) + ); + assert_eq!( + env.get(&OsString::from("MANGOHUD_OUTPUT")), + Some(&OsString::from("/tmp/mangohud.csv")) + ); + + let disabled = build_env(ResolvedSettings { + mangohud: false, + mangohud_log: true, + mangohud_log_path: Some("/tmp/mangohud.csv".to_string()), + ..ResolvedSettings::default() + }); + assert!(!disabled.contains_key(&OsString::from("MANGOHUD_LOG"))); + assert!(!disabled.contains_key(&OsString::from("MANGOHUD_OUTPUT"))); + } + #[test] fn proton_and_vkbasalt_settings_set_expected_env_vars() { let env = build_env(ResolvedSettings { vkbasalt: true, + vkbasalt_config: Some("/tmp/vkbasalt.conf".to_string()), esync: Some(true), fsync: Some(false), ..ResolvedSettings::default() @@ -177,6 +233,10 @@ mod tests { env.get(&OsString::from("ENABLE_VKBASALT")), Some(&OsString::from("1")) ); + assert_eq!( + env.get(&OsString::from("VKBASALT_CONFIG_FILE")), + Some(&OsString::from("/tmp/vkbasalt.conf")) + ); assert_eq!( env.get(&OsString::from("PROTON_NO_ESYNC")), Some(&OsString::from("0")) @@ -187,6 +247,39 @@ mod tests { ); } + #[test] + fn vkbasalt_config_is_ignored_when_vkbasalt_is_disabled() { + let env = build_env(ResolvedSettings { + vkbasalt: false, + vkbasalt_config: Some("/tmp/vkbasalt.conf".to_string()), + ..ResolvedSettings::default() + }); + + assert!(!env.contains_key(&OsString::from("VKBASALT_CONFIG_FILE"))); + } + + #[test] + fn vkbasalt_log_level_requires_vkbasalt() { + use crate::config::VkbasaltLogLevel; + + let env = build_env(ResolvedSettings { + vkbasalt: true, + vkbasalt_log_level: Some(VkbasaltLogLevel::Warning), + ..ResolvedSettings::default() + }); + assert_eq!( + env.get(&OsString::from("VKBASALT_LOG_LEVEL")), + Some(&OsString::from("warning")) + ); + + let disabled = build_env(ResolvedSettings { + vkbasalt: false, + vkbasalt_log_level: Some(VkbasaltLogLevel::Debug), + ..ResolvedSettings::default() + }); + assert!(!disabled.contains_key(&OsString::from("VKBASALT_LOG_LEVEL"))); + } + #[test] fn large_address_aware_sets_env_var() { let env = build_env(ResolvedSettings { diff --git a/src/help.rs b/src/help.rs index e56666e..60486fc 100644 --- a/src/help.rs +++ b/src/help.rs @@ -18,21 +18,27 @@ pub const TOP_LEVEL_AFTER_HELP: &str = r#"Common tasks: gamewrap game rename "eldenring.exe" "Elden Ring" Give a game a readable display name. - gamewrap config set overlay on + gamewrap config settings + List all available settings with one-line descriptions. + + gamewrap config set mangohud on Enable MangoHud overlay by default. gamewrap config set fps-cap 60 - Cap frame rate through MangoHud (requires overlay to be on). + Cap frame rate through MangoHud (requires mangohud to be on). gamewrap config set gamescope on Wrap all launches in the gamescope Wayland compositor. - gamewrap config set gamescope-width 1920 - Set the gamescope target resolution width. + gamewrap config set gamescope-width native + Match the gamescope output width to the connected display. gamewrap config set pre-launch "notify-send Starting" Run a shell hook before launching the game. + gamewrap config reset --all + Reset every global setting to its built-in default. + gamewrap profile create benchmark Create a named profile with its own settings. @@ -42,9 +48,6 @@ pub const TOP_LEVEL_AFTER_HELP: &str = r#"Common tasks: gamewrap profile set benchmark fps-cap 120 Override a setting for one specific profile. - gamewrap profile inherit benchmark base - Make a profile build on top of another profile. - gamewrap doctor Check that your default setup is ready for a Steam launch. @@ -56,15 +59,25 @@ pub const TOP_LEVEL_AFTER_HELP: &str = r#"Common tasks: Help topics (run for detailed docs): gamewrap help settings all settings and their effects - gamewrap help profiles profiles, inheritance, and env overrides + gamewrap help profiles profiles, defaults, and env overrides gamewrap help bindings binding games to profiles gamewrap help doctor understanding preflight checks gamewrap help completion shell completion setup gamewrap help troubleshooting"#; -pub fn topic_text(topic: &str) -> Option<&'static str> { +pub fn topic_text(topic: &str, filter: Option<&str>) -> Option<&'static str> { match topic { - "settings" => Some(SETTINGS_HELP), + "settings" => match filter { + Some("core") => Some(SECTION_CORE), + Some("mangohud") => Some(SECTION_MANGOHUD), + Some("gamemode") => Some(SECTION_GAMEMODE), + Some("gamescope") => Some(SECTION_GAMESCOPE), + Some("vkbasalt") => Some(SECTION_VKBASALT), + Some("proton" | "wine" | "proton-wine") => Some(SECTION_PROTON), + Some("logging" | "hooks") => Some(SECTION_LOGGING), + Some(_) => None, + None => Some(SETTINGS_HELP), + }, "doctor" => Some(DOCTOR_HELP), "profiles" => Some(PROFILES_HELP), "bindings" => Some(BINDINGS_HELP), @@ -74,17 +87,9 @@ pub fn topic_text(topic: &str) -> Option<&'static str> { } } -const SETTINGS_HELP: &str = r#"Settings - -overlay - What it does: turn MangoHud on or off. - Technical effect: prefixes the launch command with mangohud. - Values: on, off - -performance - What it does: turn GameMode on or off. - Technical effect: prefixes the launch command with gamemoderun. - Values: on, off +macro_rules! section_core { + () => { + r#"── Core ──────────────────────────────────────────────────────────── steam-host-libs What it does: prefer host libraries when Steam is using its runtime. @@ -95,7 +100,7 @@ game-libs What it does: control how game-related library paths are handled. Technical effect: controls whether auto-detected host library directories are appended to LD_LIBRARY_PATH. Values: - auto append auto-detected host library paths only in Steam-like contexts when performance is on + auto append auto-detected host library paths only in Steam-like contexts when gamemode is on keep leave LD_LIBRARY_PATH alone gamemode always append auto-detected host library paths @@ -103,33 +108,180 @@ verbose What it does: show more detail in diagnostic commands. Technical effect: enables extra explanatory output from gamewrap itself. Values: on, off +"# + }; +} + +macro_rules! section_mangohud { + () => { + r#"── MangoHud ───────────────────────────────────────────────────────── + +mangohud (was: overlay) + What it does: turn MangoHud on or off. + Technical effect: prefixes the launch command with mangohud. + Values: on, off + Default: on + +fps-cap + What it does: cap frame rate. + Technical effect: sets MangoHud fps_limit through MANGOHUD_PARAMS when mangohud is on. + Values: number, or reset to use the default/clear + Example: gamewrap config set fps-cap 60 + +mangohud-log + What it does: log full-session FPS and performance data to a file. + Technical effect: sets MANGOHUD_LOG=1 and MANGOHUD_LOG_DURATION=0. + Values: on, off + Default: off + Requires: mangohud = on + +mangohud-log-path + What it does: override where MangoHud writes the performance log. + Technical effect: sets MANGOHUD_OUTPUT when mangohud-log and mangohud are on. + Values: file path + Default: MangoHud's default location. +"# + }; +} + +macro_rules! section_gamemode { + () => { + r#"── GameMode ───────────────────────────────────────────────────────── + +gamemode (was: performance) + What it does: turn GameMode on or off. + Technical effect: prefixes the launch command with gamemoderun. + Values: on, off + Default: on +"# + }; +} + +macro_rules! section_gamescope { + () => { + r#"── Gamescope ──────────────────────────────────────────────────────── + +Gamescope is a Wayland micro-compositor that provides resolution scaling, +refresh rate control, and HDR support. Enable it with 'gamescope = on', +then set width/height/fps to your preferences. gamescope-nested-* controls +the inner game resolution when scaling is active. + +Recommended setup: + gamewrap config set gamescope on + gamewrap config set gamescope-mode fullscreen + gamewrap config set gamescope-width native + gamewrap config set gamescope-height native + +Output vs. render resolution: + gamescope-width / gamescope-height = what the monitor displays (-W / -H flags) + gamescope-nested-width / gamescope-nested-height = what the game renders (-w / -h flags) + For native-res gaming, set gamescope-width/height to native and leave nested unset. + For upscaling: set nested to a lower render resolution and width/height to your monitor res. + Example: render at 1080p, output at 4K: + gamewrap config set gamescope-nested-width 1920 + gamewrap config set gamescope-nested-height 1080 + gamewrap config set gamescope-width native + gamewrap config set gamescope-height native gamescope What it does: wrap the game in the gamescope Wayland compositor. - Technical effect: prefixes the launch command with gamescope [-- args] --. + Technical effect: prefixes the launch command with gamescope [args] --. Values: on, off Requires: gamescope installed (https://github.com/ValveSoftware/gamescope) gamescope-width - What it does: set the target game resolution width. + What it does: set the gamescope output width (what the monitor displays). Technical effect: passes -W to gamescope. - Values: number (e.g. 1920). Only effective when gamescope is on. + Values: pixel count (e.g. 1920) or native (auto-detects connected display) + Default: not set (gamescope uses 1280) + Example: gamewrap config set gamescope-width native gamescope-height - What it does: set the target game resolution height. + What it does: set the gamescope output height (what the monitor displays). Technical effect: passes -H to gamescope. - Values: number (e.g. 1080). Only effective when gamescope is on. + Values: pixel count (e.g. 1080) or native (auto-detects connected display) + Default: not set (gamescope uses 720) + Example: gamewrap config set gamescope-height native gamescope-fps What it does: set the target FPS for the gamescope compositor. Technical effect: passes -r to gamescope. Values: number (e.g. 60). Only effective when gamescope is on. -fps-cap - What it does: cap frame rate. - Technical effect: sets MangoHud fps_limit through MANGOHUD_PARAMS when overlay is on. - Values: number, or reset to inherit/clear - Example: gamewrap config set fps-cap 60 +gamescope-nested-width + What it does: set the game render width (inner resolution). + Technical effect: passes -w to gamescope. + Values: number (e.g. 1280). Only effective when gamescope is on. + Note: combine with gamescope-width and gamescope-filter fsr/nis to upscale from a lower res. + +gamescope-nested-height + What it does: set the game render height (inner resolution). + Technical effect: passes -h to gamescope. + Values: number (e.g. 720). Only effective when gamescope is on. + +gamescope-unfocused-fps + What it does: throttle FPS when the gamescope window is unfocused. + Technical effect: passes -o to gamescope. + Values: number (e.g. 30). Only effective when gamescope is on. + +gamescope-scaler + What it does: set how content is scaled to the output resolution. + Technical effect: passes -S to gamescope. + Values: auto, integer, fit, fill, stretch. Only effective when gamescope is on. + +gamescope-filter + What it does: set the upscaling/filtering algorithm. + Technical effect: passes -F to gamescope. + Values: linear, nearest, fsr, nis, pixel. Only effective when gamescope is on. + Note: fsr (AMD FidelityFX SR) and nis (NVIDIA Image Scaling) are the quality upscalers. + +gamescope-sharpness + What it does: set upscaler sharpness for fsr or nis filter. + Technical effect: passes --sharpness to gamescope. + Values: 0 (maximum sharpness) to 20 (minimum sharpness). Only meaningful with fsr or nis filter. + +gamescope-mode + What it does: set the gamescope window mode. + Technical effect: fullscreen passes -f; borderless passes -b; windowed passes neither. + Values: windowed, borderless, fullscreen + Default: windowed (no flag passed) + Example: gamewrap config set gamescope-mode fullscreen + +gamescope-adaptive-sync + What it does: enable adaptive sync / variable refresh rate (VRR / FreeSync / G-Sync). + Technical effect: passes --adaptive-sync to gamescope. + Values: on, off. Requires a VRR-capable display. Only effective when gamescope is on. + +gamescope-hdr + What it does: enable HDR output. + Technical effect: passes --hdr-enabled to gamescope. + Values: on, off. Requires gamescope WSI layer enabled and a compatible HDR display. + Only effective when gamescope is on. + +gamescope-steam + What it does: enable Steam integration so the Steam overlay works inside gamescope. + Technical effect: passes -e to gamescope. + Values: on, off. Only effective when gamescope is on. + +gamescope-expose-wayland + What it does: expose a Wayland socket for native Wayland game clients. + Technical effect: passes --expose-wayland to gamescope. + Values: on, off. Only effective when gamescope is on. + +gamescope-mangoapp + What it does: use gamescope's native mangohud overlay (mangoapp) instead of running mangohud as a prefix. + Technical effect: passes --mangoapp to gamescope; skips the mangohud prefix in the command chain. + Values: on, off. Default: off. + Requires: mangoapp binary (included with the mangohud package on most distros). + Note: when both mangohud and gamescope are on, enabling this gives better reliability — the HUD + renders through gamescope's own compositor layer rather than via LD_PRELOAD injection. +"# + }; +} + +macro_rules! section_vkbasalt { + () => { + r#"── vkBasalt ───────────────────────────────────────────────────────── vkbasalt What it does: enable vkBasalt post-processing. @@ -138,6 +290,25 @@ vkbasalt Default: off Requires: vkBasalt installed. +vkbasalt-config + What it does: point vkBasalt at a specific config file. + Technical effect: sets VKBASALT_CONFIG_FILE to the given path. Only active when vkbasalt is on. + Values: file path + Example: gamewrap profile set screenshot vkbasalt-config ~/.config/vkBasalt/hq.conf + +vkbasalt-log-level + What it does: set vkBasalt log verbosity. + Technical effect: sets VKBASALT_LOG_LEVEL when vkbasalt is on. + Values: debug, info, warning, error, none + Requires: vkbasalt = on +"# + }; +} + +macro_rules! section_proton { + () => { + r#"── Proton / Wine ──────────────────────────────────────────────────── + esync What it does: force Proton esync on or off. Technical effect: sets PROTON_NO_ESYNC=0 when on, or PROTON_NO_ESYNC=1 when off. @@ -156,6 +327,13 @@ large-address-aware / laa Values: on, off Default: off Context: only active in Proton context. +"# + }; +} + +macro_rules! section_logging { + () => { + r#"── Hooks & Logging ────────────────────────────────────────────────── pre-launch What it does: run a shell command before launching the game. @@ -166,40 +344,85 @@ pre-launch post-launch What it does: run a shell command after the game exits. Technical effect: when set, gamewrap spawns the launch plan, waits for it to exit, then runs `sh -c `. + The post-hook is attempted even if the game fails to spawn, as long as the pre-launch hook succeeded. Values: shell command string Example: gamewrap profile set benchmark post-launch 'notify-send "Game exited"' +hook-errors + What it does: control what happens when a pre-launch hook fails. + Technical effect: in fail mode, gamewrap aborts before launching when the pre-launch hook + exits nonzero or cannot be executed. In warn mode (the default), the failure is logged to + stderr and the launch continues. + Values: warn (default), fail + Note: the post-launch hook always runs after the pre-launch hook succeeds, even if the game + fails to start. + +log-file + What it does: enable or disable gamewrap's own log file. + Technical effect: records what gamewrap decided at launch time — resolved profile, final command, env changes, and hook results. + Values: on, off + Default: off + +log-path + What it does: override the default gamewrap log file location. + Values: file path + Default: ~/.local/state/gamewrap/gamewrap.log + env-vars What it does: apply arbitrary environment variable overrides at launch. Technical effect: inserts KEY=value pairs after gamewrap's standard environment variables, so they can override gamewrap defaults. Values: set through `gamewrap profile env set `, not `gamewrap config set`. Default: none -"#; +"# + }; +} + +const SECTION_CORE: &str = section_core!(); +const SECTION_MANGOHUD: &str = section_mangohud!(); +const SECTION_GAMEMODE: &str = section_gamemode!(); +const SECTION_GAMESCOPE: &str = section_gamescope!(); +const SECTION_VKBASALT: &str = section_vkbasalt!(); +const SECTION_PROTON: &str = section_proton!(); +const SECTION_LOGGING: &str = section_logging!(); + +const SETTINGS_HELP: &str = concat!( + section_core!(), + "\n", + section_mangohud!(), + "\n", + section_gamemode!(), + "\n", + section_gamescope!(), + "\n", + section_vkbasalt!(), + "\n", + section_proton!(), + "\n", + section_logging!(), +); const PROFILES_HELP: &str = r#"Profiles Profiles are reusable setting bundles. The default profile is built from your global config. Named profiles override only the settings you change. -Profiles can also inherit from another named profile. Examples: gamewrap profile list - gamewrap profile tree gamewrap profile create benchmark gamewrap profile duplicate benchmark benchmark-copy - gamewrap profile inherit benchmark base - gamewrap profile clear-inherit benchmark gamewrap profile export benchmark benchmark gamewrap profile import benchmark - gamewrap profile set benchmark overlay on - gamewrap profile reset benchmark overlay + gamewrap profile migrate old-benchmark + gamewrap profile set benchmark mangohud on + gamewrap profile reset benchmark mangohud gamewrap profile env set benchmark DXVK_ASYNC 1 gamewrap profile env list benchmark gamewrap profile env unset benchmark DXVK_ASYNC gamewrap profile env clear benchmark - gamewrap profile set benchmark performance on + gamewrap profile set benchmark gamemode on gamewrap profile show benchmark + gamewrap profile show benchmark --effective "#; const DOCTOR_HELP: &str = r#"Doctor @@ -215,12 +438,13 @@ Examples: Check a specific game executable as if Steam were launching it. What it checks: - - MangoHud availability when overlay is on + - MangoHud availability when mangohud is on (or mangoapp when gamescope-mangoapp is on) - gamescope availability when gamescope is on - - GameMode availability when performance is on + - GameMode availability when gamemode is on - vkBasalt layer availability when vkbasalt is on - auto-detected host library path injection for Steam/Proton-style launches - whether a provided target command looks runnable + - warns when MangoHud is used as a prefix inside gamescope (suggest gamescope-mangoapp instead) What the summary means: overall: ok @@ -257,14 +481,17 @@ Matching is case-insensitive and checks the executable basename first. const TROUBLESHOOTING_HELP: &str = r#"Troubleshooting Missing mangohud - Install MangoHud or turn overlay off. + Install MangoHud or turn mangohud off. Missing gamemoderun - Install GameMode or turn performance off. + Install GameMode or turn gamemode off. Missing gamescope Install gamescope or turn gamescope off. +Missing mangoapp + Install mangohud (which includes mangoapp) or turn gamescope-mangoapp off. + Steam runtime library issues Try: gamewrap config set steam-host-libs on diff --git a/src/launch.rs b/src/launch.rs index 9a8860a..25971ca 100644 --- a/src/launch.rs +++ b/src/launch.rs @@ -52,33 +52,150 @@ impl PreflightReport { } } +/// Detect the primary display's native resolution. +/// Tries xrandr first, falls back to /sys/class/drm. +/// Returns None if detection fails; caller should warn and skip the flag. +fn detect_display_resolution() -> Option<(u32, u32)> { + if let Ok(output) = std::process::Command::new("xrandr") + .arg("--current") + .output() + && output.status.success() + { + let text = String::from_utf8_lossy(&output.stdout); + for line in text.lines() { + if line.contains('*') { + let trimmed = line.trim(); + if let Some(res_part) = trimmed.split_whitespace().next() + && let Some((w_str, h_str)) = res_part.split_once('x') + && let (Ok(w), Ok(h)) = (w_str.parse::(), h_str.parse::()) + { + return Some((w, h)); + } + } + } + } + + if let Ok(entries) = std::fs::read_dir("/sys/class/drm") { + let mut paths: Vec<_> = entries.flatten().map(|entry| entry.path()).collect(); + paths.sort(); + for path in paths { + let modes_path = path.join("modes"); + if let Ok(content) = std::fs::read_to_string(&modes_path) + && let Some(first_line) = content.lines().next() + && let Some((w_str, h_str)) = first_line.trim().split_once('x') + && let (Ok(w), Ok(h)) = (w_str.parse::(), h_str.parse::()) + { + return Some((w, h)); + } + } + } + + None +} + pub fn build_plan(target: &[OsString], settings: ResolvedSettings) -> Result { validate_launch(target, &settings, env::is_steam_context())?; let mut command = Vec::with_capacity(target.len() + 10); + let use_mangoapp = settings.gamescope && settings.gamescope_mangoapp; + if settings.gamescope { command.push(OsString::from("gamescope")); - if let Some(width) = settings.gamescope_width { - command.push(OsString::from("-W")); + match settings.gamescope_width { + Some(crate::config::GamescopeSize::Pixels(width)) => { + command.push(OsString::from("-W")); + command.push(OsString::from(width.to_string())); + } + Some(crate::config::GamescopeSize::Native) => { + if let Some((width, _)) = detect_display_resolution() { + command.push(OsString::from("-W")); + command.push(OsString::from(width.to_string())); + } else { + eprintln!( + "gamewrap: could not detect native display width; gamescope will use its default (1280)" + ); + } + } + None => {} + } + match settings.gamescope_height { + Some(crate::config::GamescopeSize::Pixels(height)) => { + command.push(OsString::from("-H")); + command.push(OsString::from(height.to_string())); + } + Some(crate::config::GamescopeSize::Native) => { + if let Some((_, height)) = detect_display_resolution() { + command.push(OsString::from("-H")); + command.push(OsString::from(height.to_string())); + } else { + eprintln!( + "gamewrap: could not detect native display height; gamescope will use its default (720)" + ); + } + } + None => {} + } + if let Some(width) = settings.gamescope_nested_width { + command.push(OsString::from("-w")); command.push(OsString::from(width.to_string())); } - if let Some(height) = settings.gamescope_height { - command.push(OsString::from("-H")); + if let Some(height) = settings.gamescope_nested_height { + command.push(OsString::from("-h")); command.push(OsString::from(height.to_string())); } if let Some(fps) = settings.gamescope_fps { command.push(OsString::from("-r")); command.push(OsString::from(fps.to_string())); } + if let Some(fps) = settings.gamescope_unfocused_fps { + command.push(OsString::from("-o")); + command.push(OsString::from(fps.to_string())); + } + if let Some(scaler) = settings.gamescope_scaler { + command.push(OsString::from("-S")); + command.push(OsString::from(scaler.as_str())); + } + if let Some(filter) = settings.gamescope_filter { + command.push(OsString::from("-F")); + command.push(OsString::from(filter.as_str())); + } + if let Some(sharpness) = settings.gamescope_sharpness { + command.push(OsString::from("--sharpness")); + command.push(OsString::from(sharpness.to_string())); + } + match settings.gamescope_window_mode { + Some(crate::config::GamescopeWindowMode::Fullscreen) => { + command.push(OsString::from("-f")); + } + Some(crate::config::GamescopeWindowMode::Borderless) => { + command.push(OsString::from("-b")); + } + Some(crate::config::GamescopeWindowMode::Windowed) | None => {} + } + if settings.gamescope_adaptive_sync { + command.push(OsString::from("--adaptive-sync")); + } + if settings.gamescope_hdr { + command.push(OsString::from("--hdr-enabled")); + } + if settings.gamescope_steam { + command.push(OsString::from("-e")); + } + if settings.gamescope_expose_wayland { + command.push(OsString::from("--expose-wayland")); + } + if use_mangoapp { + command.push(OsString::from("--mangoapp")); + } command.push(OsString::from("--")); } - if settings.overlay { + if settings.mangohud && !use_mangoapp { command.push(OsString::from("mangohud")); } - if settings.performance { + if settings.gamemode { command.push(OsString::from("gamemoderun")); } @@ -95,13 +212,31 @@ pub fn preflight( ) -> PreflightReport { let mut checks = Vec::new(); - if settings.overlay { - checks.push(check_dependency("overlay wrapper", "mangohud")); + let use_mangoapp = settings.gamescope && settings.gamescope_mangoapp; + + if use_mangoapp { + checks.push(Check { + name: "mangohud wrapper".to_string(), + status: CheckStatus::Ok, + detail: + "gamescope-mangoapp is on — gamescope provides the MangoHud overlay via mangoapp." + .to_string(), + }); + checks.push(check_dependency("gamescope mangoapp", "mangoapp")); + } else if settings.mangohud { + checks.push(check_dependency("mangohud wrapper", "mangohud")); + if settings.gamescope { + checks.push(Check { + name: "gamescope overlay".to_string(), + status: CheckStatus::Warn, + detail: "MangoHud is running as a prefix inside gamescope. For better reliability, enable `gamescope-mangoapp` to use gamescope's native overlay instead.".to_string(), + }); + } } else { checks.push(Check { - name: "overlay wrapper".to_string(), + name: "mangohud wrapper".to_string(), status: CheckStatus::Ok, - detail: "overlay is off, so MangoHud is not required.".to_string(), + detail: "mangohud is off, so MangoHud is not required.".to_string(), }); } @@ -115,13 +250,13 @@ pub fn preflight( }); } - if settings.performance { - checks.push(check_dependency("performance wrapper", "gamemoderun")); + if settings.gamemode { + checks.push(check_dependency("gamemode wrapper", "gamemoderun")); } else { checks.push(Check { - name: "performance wrapper".to_string(), + name: "gamemode wrapper".to_string(), status: CheckStatus::Ok, - detail: "performance is off, so GameMode is not required.".to_string(), + detail: "gamemode is off, so GameMode is not required.".to_string(), }); } @@ -186,7 +321,9 @@ pub fn execute(plan: LaunchPlan) -> Result<(), AppError> { ))) } -pub fn execute_wait(plan: LaunchPlan) -> Result { +pub fn execute_wait( + plan: LaunchPlan, +) -> Result<(std::process::ExitStatus, std::time::Duration), AppError> { let executable = plan .command .first() @@ -205,40 +342,43 @@ pub fn execute_wait(plan: LaunchPlan) -> Result { let mut child = command .spawn() .map_err(|error| internal_error(format!("Failed to spawn launch command: {error}")))?; - child + let status = child .wait() .map_err(|error| internal_error(format!("Failed to wait for game process: {error}")))?; - Ok(start.elapsed()) + Ok((status, start.elapsed())) } pub fn render_plan(plan: &LaunchPlan, profile: &str, verbose: bool) -> String { let mut output = String::new(); - output.push_str(&format!("Resolved profile: {profile}\n")); + output.push_str(&format!( + "{} {}\n", + crate::color::dim("Resolved profile:"), + crate::color::accent(profile) + )); if verbose { - output.push_str("Environment changes:\n"); + output.push_str(&format!("{}\n", crate::color::bold("Environment changes:"))); if plan.env.is_empty() { - output.push_str(" (none)\n"); + output.push_str(&format!(" {}\n", crate::color::dim("(none)"))); } else { for (key, value) in &plan.env { output.push_str(&format!( " {}={}\n", - key.to_string_lossy(), - value.to_string_lossy() + crate::color::accent(&key.to_string_lossy()), + crate::color::dim(&value.to_string_lossy()) )); } } } - output.push_str("Final command:\n "); - output.push_str( - &plan - .command - .iter() - .map(|part| shell_escape(part)) - .collect::>() - .join(" "), - ); + output.push_str(&format!("{}\n ", crate::color::bold("Final command:"))); + let cmd_str = plan + .command + .iter() + .map(shell_escape) + .collect::>() + .join(" "); + output.push_str(&crate::color::accent(&cmd_str)); output } @@ -303,12 +443,23 @@ fn validate_launch( .ok_or_else(|| internal_error("Missing target command during launch planning."))?; ensure_target_command(target_program)?; - if settings.overlay { - ensure_dependency("mangohud", "overlay")?; + let use_mangoapp = settings.gamescope && settings.gamescope_mangoapp; + + if use_mangoapp { + which("mangoapp").map(|_| ()).map_err(|_| { + dependency_error( + "`mangoapp` is required because `gamescope-mangoapp` is enabled.", + "Install `mangohud` (which includes `mangoapp`), or turn `gamescope-mangoapp` off with `gamewrap config set gamescope-mangoapp off`.", + ) + })?; } - if settings.performance { - ensure_dependency("gamemoderun", "performance")?; + if settings.mangohud && !use_mangoapp { + ensure_dependency("mangohud", "mangohud")?; + } + + if settings.gamemode { + ensure_dependency("gamemoderun", "gamemode")?; } if settings.gamescope { @@ -338,7 +489,7 @@ fn ensure_library_paths_for_context( } fn needs_host_libs_for_context(settings: &ResolvedSettings, steam_context: bool) -> bool { - if !settings.performance { + if !settings.gamemode { return false; } @@ -451,7 +602,8 @@ mod tests { }; let rendered = render_plan(&plan, "default", false); - assert!(rendered.contains("Resolved profile: default")); + assert!(rendered.contains("Resolved profile:")); + assert!(rendered.contains("default")); assert!(rendered.contains("mangohud game.exe")); } @@ -460,8 +612,8 @@ mod tests { let error = build_plan( &[], ResolvedSettings { - overlay: false, - performance: false, + mangohud: false, + gamemode: false, steam_host_libs: false, game_libs: GameLibsMode::Keep, verbose: false, @@ -483,8 +635,8 @@ mod tests { let error = build_plan( &[OsString::from("--help")], ResolvedSettings { - overlay: false, - performance: false, + mangohud: false, + gamemode: false, steam_host_libs: false, game_libs: GameLibsMode::Keep, verbose: false, diff --git a/src/lib.rs b/src/lib.rs index 31e1627..a61e0ab 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -9,6 +9,7 @@ mod env; mod error; mod help; mod launch; +mod log; mod notify; mod profile; mod share; diff --git a/src/log.rs b/src/log.rs new file mode 100644 index 0000000..d02aca2 --- /dev/null +++ b/src/log.rs @@ -0,0 +1,74 @@ +use std::fs::{self, OpenOptions}; +use std::io::Write; +use std::path::{Path, PathBuf}; +use std::time::{SystemTime, UNIX_EPOCH}; + +pub fn default_log_path(state_dir: &Path) -> PathBuf { + state_dir.join("gamewrap.log") +} + +/// Append timestamped lines to the log file. Logging must never crash gamewrap. +pub fn append(path: &Path, lines: &[String]) { + let _ = try_append(path, lines); +} + +fn try_append(path: &Path, lines: &[String]) -> std::io::Result<()> { + if let Some(parent) = path.parent() { + fs::create_dir_all(parent)?; + } + let mut file = OpenOptions::new().create(true).append(true).open(path)?; + let now = iso_timestamp(); + for line in lines { + writeln!(file, "[{now}] {line}")?; + } + Ok(()) +} + +fn iso_timestamp() -> String { + let secs = SystemTime::now() + .duration_since(UNIX_EPOCH) + .unwrap_or_default() + .as_secs(); + let s = secs % 60; + let m = (secs / 60) % 60; + let h = (secs / 3600) % 24; + let days = secs / 86400; + let (y, mo, d) = days_to_ymd(days); + format!("{y:04}-{mo:02}-{d:02} {h:02}:{m:02}:{s:02} UTC") +} + +fn days_to_ymd(days: u64) -> (u32, u32, u32) { + let days = days as i64 + 719468; + let era = if days >= 0 { days } else { days - 146096 } / 146097; + let doe = (days - era * 146097) as u32; + let yoe = (doe - doe / 1460 + doe / 36524 - doe / 146096) / 365; + let y = yoe as i64 + era * 400; + let doy = doe - (365 * yoe + yoe / 4 - yoe / 100); + let mp = (5 * doy + 2) / 153; + let d = doy - (153 * mp + 2) / 5 + 1; + let m = if mp < 10 { mp + 3 } else { mp - 9 }; + let y = if m <= 2 { y + 1 } else { y }; + (y as u32, m, d) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn converts_epoch_days_to_gregorian_dates() { + assert_eq!(days_to_ymd(0), (1970, 1, 1)); + assert_eq!(days_to_ymd(19_782), (2024, 2, 29)); + } + + #[test] + fn appends_timestamped_lines() { + let temp = tempfile::tempdir().expect("temp dir"); + let path = temp.path().join("nested/gamewrap.log"); + + append(&path, &["--- launch ---".to_string()]); + + let content = fs::read_to_string(path).expect("log contents"); + assert!(content.contains(" UTC] --- launch ---")); + } +} diff --git a/src/profile.rs b/src/profile.rs index b1e8a06..2ff37b8 100644 --- a/src/profile.rs +++ b/src/profile.rs @@ -1,7 +1,5 @@ -use std::collections::BTreeSet; - use crate::bindings; -use crate::config::{ConfigFile, ProfileConfig, ResolvedSettings}; +use crate::config::{ConfigFile, ResolvedSettings}; use crate::detect::ExecutableInfo; use crate::error::{AppError, config_error}; @@ -46,7 +44,7 @@ pub fn resolve_named(config: &ConfigFile, profile_name: &str) -> Result Result<(), AppError> { } } - let mut visiting = BTreeSet::new(); - let mut visited = BTreeSet::new(); - for name in config.profiles.keys() { - validate_profile_chain(config, name, &mut visiting, &mut visited)?; - } - Ok(()) } - -fn apply_profile_chain( - config: &ConfigFile, - profile: &ProfileConfig, - settings: &mut ResolvedSettings, -) -> Result<(), AppError> { - if let Some(parent) = &profile.inherits { - let parent_profile = config.profiles.get(parent).ok_or_else(|| { - config_error( - format!("Profile inherits from missing profile `{parent}`."), - "Fix the parent profile name or clear the inheritance chain.", - ) - })?; - apply_profile_chain(config, parent_profile, settings)?; - } - - settings.apply(&profile.settings); - Ok(()) -} - -fn validate_profile_chain( - config: &ConfigFile, - name: &str, - visiting: &mut BTreeSet, - visited: &mut BTreeSet, -) -> Result<(), AppError> { - if visited.contains(name) { - return Ok(()); - } - - if !visiting.insert(name.to_string()) { - return Err(config_error( - format!("Profile inheritance cycle detected at `{name}`."), - "Break the cycle with `gamewrap profile clear-inherit ` or choose a different parent profile.", - )); - } - - if let Some(parent) = config - .profiles - .get(name) - .ok_or_else(|| { - config_error( - format!("Profile `{name}` does not exist."), - "Run `gamewrap profile list` to see the available profiles.", - ) - })? - .inherits - .as_ref() - { - if parent == name { - return Err(config_error( - format!("Profile `{name}` cannot inherit from itself."), - "Choose another parent profile or clear the inheritance chain.", - )); - } - if !config.profiles.contains_key(parent) { - return Err(config_error( - format!("Profile `{name}` inherits from missing profile `{parent}`."), - "Create the parent profile first or clear the inheritance chain.", - )); - } - validate_profile_chain(config, parent, visiting, visited)?; - } - - visiting.remove(name); - visited.insert(name.to_string()); - - Ok(()) -} - -#[cfg(test)] -mod tests { - use std::collections::BTreeMap; - - use super::*; - use crate::config::{ConfigFile, GameLibsMode, Settings}; - - #[test] - fn resolve_named_applies_inheritance_chain() { - let config = ConfigFile { - defaults: Settings { - overlay: Some(false), - performance: Some(false), - steam_host_libs: None, - game_libs: None, - verbose: None, - gamescope: None, - gamescope_width: None, - gamescope_height: None, - gamescope_fps: None, - fps_cap: None, - env_vars: Some(BTreeMap::from([( - "GW_DEFAULT".to_string(), - "default".to_string(), - )])), - ..Settings::default() - }, - profiles: BTreeMap::from([ - ( - "base".to_string(), - ProfileConfig { - inherits: None, - settings: Settings { - overlay: Some(true), - performance: Some(true), - steam_host_libs: None, - game_libs: Some(GameLibsMode::Keep), - verbose: None, - gamescope: None, - gamescope_width: None, - gamescope_height: None, - gamescope_fps: None, - fps_cap: None, - env_vars: Some(BTreeMap::from([ - ("GW_BASE".to_string(), "base".to_string()), - ("GW_OVERRIDE".to_string(), "base".to_string()), - ])), - ..Settings::default() - }, - }, - ), - ( - "benchmark".to_string(), - ProfileConfig { - inherits: Some("base".to_string()), - settings: Settings { - overlay: None, - performance: None, - steam_host_libs: Some(false), - game_libs: None, - verbose: Some(true), - gamescope: None, - gamescope_width: None, - gamescope_height: None, - gamescope_fps: None, - fps_cap: Some(60), - env_vars: Some(BTreeMap::from([( - "GW_OVERRIDE".to_string(), - "benchmark".to_string(), - )])), - ..Settings::default() - }, - }, - ), - ]), - bindings: Vec::new(), - }; - - let resolved = resolve_named(&config, "benchmark").expect("resolve benchmark"); - assert_eq!(resolved.profile_name, "benchmark"); - assert!(resolved.settings.overlay); - assert!(resolved.settings.performance); - assert!(!resolved.settings.steam_host_libs); - assert_eq!(resolved.settings.game_libs, GameLibsMode::Keep); - assert!(resolved.settings.verbose); - assert_eq!(resolved.settings.fps_cap, Some(60)); - assert_eq!( - resolved.settings.env_vars.get("GW_DEFAULT"), - Some(&"default".to_string()) - ); - assert_eq!( - resolved.settings.env_vars.get("GW_BASE"), - Some(&"base".to_string()) - ); - assert_eq!( - resolved.settings.env_vars.get("GW_OVERRIDE"), - Some(&"benchmark".to_string()) - ); - } - - #[test] - fn validate_config_rejects_inheritance_cycles() { - let config = ConfigFile { - defaults: Settings::default(), - profiles: BTreeMap::from([ - ( - "alpha".to_string(), - ProfileConfig { - inherits: Some("beta".to_string()), - settings: Settings::default(), - }, - ), - ( - "beta".to_string(), - ProfileConfig { - inherits: Some("alpha".to_string()), - settings: Settings::default(), - }, - ), - ]), - bindings: Vec::new(), - }; - - let error = validate_config(&config).expect_err("expected cycle validation to fail"); - assert!(error.to_string().contains("inheritance cycle")); - } -} diff --git a/src/share.rs b/src/share.rs index 4413777..774c35e 100644 --- a/src/share.rs +++ b/src/share.rs @@ -2,9 +2,9 @@ use std::collections::BTreeMap; use serde::{Deserialize, Serialize}; -use crate::config::{Binding, ConfigFile, ProfileConfig, ResolvedSettings, Settings}; +use crate::config::migrate; +use crate::config::{Binding, ConfigFile, ProfileConfig, Settings}; use crate::error::{AppError, config_error}; -use crate::profile; const CONFIG_KIND: &str = "gamewrap-config"; const PROFILE_KIND: &str = "gamewrap-profile"; @@ -16,9 +16,9 @@ pub const PROFILE_EXPORT_SUFFIX: &str = ".gamewrap-profile.toml"; pub struct SharedConfigFile { pub kind: String, pub version: u32, - pub defaults: ResolvedSettings, + pub defaults: Settings, #[serde(default)] - pub profiles: BTreeMap, + pub profiles: BTreeMap, #[serde(default)] pub bindings: Vec, } @@ -28,22 +28,19 @@ pub struct SharedProfileFile { pub kind: String, pub version: u32, pub name: String, - pub settings: ResolvedSettings, + pub settings: Settings, } pub fn export_config(config: &ConfigFile) -> Result { - let defaults = resolved_defaults(&config.defaults); - let mut profiles = BTreeMap::new(); - for name in config.profiles.keys() { - let resolved = profile::resolve_named(config, name)?; - profiles.insert(name.clone(), resolved.settings); - } - Ok(SharedConfigFile { kind: CONFIG_KIND.to_string(), version: FORMAT_VERSION, - defaults, - profiles, + defaults: config.defaults.clone(), + profiles: config + .profiles + .iter() + .map(|(name, profile)| (name.clone(), profile.settings.clone())) + .collect(), bindings: config.bindings.clone(), }) } @@ -53,31 +50,28 @@ pub fn import_config(shared: SharedConfigFile) -> Result { validate_version(shared.version, "config")?; Ok(ConfigFile { - defaults: explicit_settings(shared.defaults), + defaults: shared.defaults, profiles: shared .profiles .into_iter() - .map(|(name, settings)| { - ( - name, - ProfileConfig { - inherits: None, - settings: explicit_settings(settings), - }, - ) - }) + .map(|(name, settings)| (name, ProfileConfig { settings })) .collect(), bindings: shared.bindings, }) } pub fn export_profile(config: &ConfigFile, name: &str) -> Result { - let resolved = profile::resolve_named(config, name)?; + let profile = config.profiles.get(name).ok_or_else(|| { + config_error( + format!("Profile `{name}` does not exist."), + "Run `gamewrap profile list` to see available profiles.", + ) + })?; Ok(SharedProfileFile { kind: PROFILE_KIND.to_string(), version: FORMAT_VERSION, name: name.to_string(), - settings: resolved.settings, + settings: profile.settings.clone(), }) } @@ -88,12 +82,34 @@ pub fn import_profile(shared: SharedProfileFile) -> Result<(String, ProfileConfi Ok(( shared.name, ProfileConfig { - inherits: None, - settings: explicit_settings(shared.settings), + settings: shared.settings, }, )) } +/// Parse a profile export TOML string, warn about renamed setting keys, +/// then return the parsed SharedProfileFile. +pub fn parse_imported_profile(content: &str) -> Result { + if let Ok(raw) = toml::from_str::(content) + && let Some(toml::Value::Table(settings)) = raw.get("settings") + { + let unknown = migrate::unknown_settings_in_table(settings); + if !unknown.is_empty() { + let keys = unknown.join(", "); + eprintln!("warning: This profile uses renamed settings that were skipped: {keys}."); + eprintln!( + " Run `gamewrap profile migrate ` to update the file before importing." + ); + } + } + toml::from_str::(content).map_err(|error| { + config_error( + format!("Profile export file is invalid: {error}"), + "Use a `.gamewrap-profile.toml` export from `gamewrap profile export`.", + ) + }) +} + pub fn parse_imported_config(content: &str) -> Result { if let Ok(shared) = toml::from_str::(content) { return import_config(shared); @@ -130,7 +146,7 @@ fn validate_kind(kind: &str, expected: &str, label: &str) -> Result<(), AppError } else { Err(config_error( format!("This file is not a {label} export."), - &format!("Expected kind `{expected}`, but found `{kind}`."), + format!("Expected kind `{expected}`, but found `{kind}`."), )) } } @@ -141,92 +157,38 @@ fn validate_version(version: u32, label: &str) -> Result<(), AppError> { } else { Err(config_error( format!("This {label} export uses unsupported version `{version}`."), - &format!("Expected version `{FORMAT_VERSION}`."), + format!("Expected version `{FORMAT_VERSION}`."), )) } } -fn resolved_defaults(settings: &Settings) -> ResolvedSettings { - let mut resolved = ResolvedSettings::default(); - resolved.apply(settings); - resolved -} - -fn explicit_settings(settings: ResolvedSettings) -> Settings { - Settings { - overlay: Some(settings.overlay), - performance: Some(settings.performance), - steam_host_libs: Some(settings.steam_host_libs), - game_libs: Some(settings.game_libs), - verbose: Some(settings.verbose), - gamescope: Some(settings.gamescope), - gamescope_width: settings.gamescope_width, - gamescope_height: settings.gamescope_height, - gamescope_fps: settings.gamescope_fps, - fps_cap: settings.fps_cap, - vkbasalt: Some(settings.vkbasalt), - esync: settings.esync, - fsync: settings.fsync, - large_address_aware: Some(settings.large_address_aware), - pre_launch: settings.pre_launch, - post_launch: settings.post_launch, - env_vars: (!settings.env_vars.is_empty()).then_some(settings.env_vars), - } -} - #[cfg(test)] mod tests { use super::*; - use crate::config::GameLibsMode; + use crate::config::GamescopeSize; #[test] - fn import_profile_clears_inheritance_and_makes_values_explicit() { + fn import_profile_preserves_sparse_settings() { let shared = SharedProfileFile { kind: PROFILE_KIND.to_string(), version: FORMAT_VERSION, name: "benchmark".to_string(), - settings: ResolvedSettings { - overlay: true, - performance: false, - steam_host_libs: true, - game_libs: GameLibsMode::Gamemode, - verbose: false, - gamescope: true, - gamescope_width: Some(1920), - gamescope_height: Some(1080), - gamescope_fps: Some(60), - fps_cap: Some(60), - vkbasalt: true, - esync: Some(true), - fsync: Some(false), - large_address_aware: true, - env_vars: BTreeMap::from([("GW_FLAG".to_string(), "1".to_string())]), - ..ResolvedSettings::default() + settings: Settings { + mangohud: Some(true), + gamescope: Some(true), + gamescope_width: Some(GamescopeSize::Pixels(1920)), + ..Settings::default() }, }; let (name, imported) = import_profile(shared).expect("import profile"); assert_eq!(name, "benchmark"); - assert_eq!(imported.inherits, None); - assert_eq!(imported.settings.overlay, Some(true)); - assert_eq!(imported.settings.performance, Some(false)); - assert_eq!(imported.settings.game_libs, Some(GameLibsMode::Gamemode)); + assert_eq!(imported.settings.mangohud, Some(true)); assert_eq!(imported.settings.gamescope, Some(true)); - assert_eq!(imported.settings.gamescope_width, Some(1920)); - assert_eq!(imported.settings.gamescope_height, Some(1080)); - assert_eq!(imported.settings.gamescope_fps, Some(60)); - assert_eq!(imported.settings.fps_cap, Some(60)); - assert_eq!(imported.settings.vkbasalt, Some(true)); - assert_eq!(imported.settings.esync, Some(true)); - assert_eq!(imported.settings.fsync, Some(false)); - assert_eq!(imported.settings.large_address_aware, Some(true)); assert_eq!( - imported - .settings - .env_vars - .as_ref() - .and_then(|vars| vars.get("GW_FLAG")), - Some(&"1".to_string()) + imported.settings.gamescope_width, + Some(GamescopeSize::Pixels(1920)) ); + assert_eq!(imported.settings.gamemode, None); } } diff --git a/src/status.rs b/src/status.rs index 594c1d6..e43f3bb 100644 --- a/src/status.rs +++ b/src/status.rs @@ -13,104 +13,207 @@ pub fn render(paths: &AppPaths, config: &ConfigFile, state: &StateFile) -> Strin }; let mut output = String::new(); - output.push_str("Paths:\n"); - output.push_str(&format!(" config: {}\n", paths.config_file.display())); - output.push_str(&format!(" state: {}\n", paths.state_file.display())); - output.push_str("\nDependencies:\n"); - output.push_str(&format!(" mangohud: {}\n", dependency_state("mangohud"))); + output.push_str(&format!("{}\n", crate::color::bold("Paths:"))); output.push_str(&format!( - " gamemoderun: {}\n", + " {} {}\n", + crate::color::dim("config:"), + crate::color::dim(&paths.config_file.display().to_string()) + )); + output.push_str(&format!( + " {} {}\n", + crate::color::dim("state:"), + crate::color::dim(&paths.state_file.display().to_string()) + )); + output.push_str(&format!("\n{}\n", crate::color::bold("Dependencies:"))); + output.push_str(&format!( + " {} {}\n", + crate::color::dim("mangohud:"), + dependency_state("mangohud") + )); + output.push_str(&format!( + " {} {}\n", + crate::color::dim("gamemoderun:"), dependency_state("gamemoderun") )); - output.push_str(&format!(" zenity: {}\n", dependency_state("zenity"))); output.push_str(&format!( - " notify-send: {}\n", + " {} {}\n", + crate::color::dim("zenity:"), + dependency_state("zenity") + )); + output.push_str(&format!( + " {} {}\n", + crate::color::dim("notify-send:"), dependency_state("notify-send") )); - output.push_str(&format!(" vkbasalt: {}\n", vkbasalt_state())); - output.push_str(&format!(" gamescope: {}\n", dependency_state("gamescope"))); output.push_str(&format!( - " failure notifier: {}\n", + " {} {}\n", + crate::color::dim("vkbasalt:"), + vkbasalt_state() + )); + output.push_str(&format!( + " {} {}\n", + crate::color::dim("gamescope:"), + dependency_state("gamescope") + )); + output.push_str(&format!( + " {} {}\n", + crate::color::dim("failure notifier:"), notify::available_notifier_name() )); - output.push_str(&format!(" host library dirs: {}\n", host_dirs_summary())); - output.push_str("\nResolved defaults:\n"); - output.push_str(&format!(" overlay: {}\n", color::on_off(defaults.overlay))); output.push_str(&format!( - " performance: {}\n", - color::on_off(defaults.performance) + " {} {}\n", + crate::color::dim("host library dirs:"), + host_dirs_summary() + )); + output.push_str(&format!("\n{}\n", crate::color::bold("Resolved defaults:"))); + output.push_str(&format!( + " {} {}\n", + crate::color::dim("mangohud:"), + color::on_off(defaults.mangohud) )); output.push_str(&format!( - " steam-host-libs: {}\n", + " {} {}\n", + crate::color::dim("gamemode:"), + color::on_off(defaults.gamemode) + )); + output.push_str(&format!( + " {} {}\n", + crate::color::dim("steam-host-libs:"), color::on_off(defaults.steam_host_libs) )); - output.push_str(&format!(" game-libs: {}\n", defaults.game_libs.as_str())); - output.push_str(&format!(" verbose: {}\n", color::on_off(defaults.verbose))); output.push_str(&format!( - " gamescope: {}\n", + " {} {}\n", + crate::color::dim("game-libs:"), + crate::color::accent(defaults.game_libs.as_str()) + )); + output.push_str(&format!( + " {} {}\n", + crate::color::dim("verbose:"), + color::on_off(defaults.verbose) + )); + output.push_str(&format!( + " {} {}\n", + crate::color::dim("gamescope:"), color::on_off(defaults.gamescope) )); if let Some(width) = defaults.gamescope_width { - output.push_str(&format!(" gamescope-width: {width}\n")); + output.push_str(&format!( + " {} {}\n", + crate::color::dim("gamescope-width:"), + crate::color::accent(&width.as_display_str()) + )); } if let Some(height) = defaults.gamescope_height { - output.push_str(&format!(" gamescope-height: {height}\n")); + output.push_str(&format!( + " {} {}\n", + crate::color::dim("gamescope-height:"), + crate::color::accent(&height.as_display_str()) + )); } if let Some(fps) = defaults.gamescope_fps { - output.push_str(&format!(" gamescope-fps: {fps}\n")); + output.push_str(&format!( + " {} {}\n", + crate::color::dim("gamescope-fps:"), + crate::color::accent(&fps.to_string()) + )); } output.push_str(&format!( - " vkbasalt: {}\n", + " {} {}\n", + crate::color::dim("vkbasalt:"), color::on_off(defaults.vkbasalt) )); output.push_str(&format!( - " esync: {}\n", + " {} {}\n", + crate::color::dim("esync:"), color::option_on_off(defaults.esync) )); output.push_str(&format!( - " fsync: {}\n", + " {} {}\n", + crate::color::dim("fsync:"), color::option_on_off(defaults.fsync) )); output.push_str(&format!( - " large-address-aware: {}\n", + " {} {}\n", + crate::color::dim("large-address-aware:"), color::on_off(defaults.large_address_aware) )); - output.push_str("\nProfiles:\n"); - output.push_str(&format!(" count: {}\n", config.profiles.len())); + output.push_str(&format!("\n{}\n", crate::color::bold("Profiles:"))); + output.push_str(&format!( + " {} {}\n", + crate::color::dim("count:"), + config.profiles.len() + )); if config.profiles.is_empty() { - output.push_str(" names: (none)\n"); + output.push_str(&format!( + " {} {}\n", + crate::color::dim("names:"), + crate::color::dim("(none)") + )); } else { let names = config .profiles .keys() - .map(String::as_str) + .map(|name| crate::color::accent(name)) .collect::>() .join(", "); - output.push_str(&format!(" names: {names}\n")); + output.push_str(&format!(" {} {names}\n", crate::color::dim("names:"))); } - output.push_str("Bindings:\n"); - output.push_str(&format!(" count: {}\n", config.bindings.len())); + output.push_str(&format!("{}\n", crate::color::bold("Bindings:"))); + output.push_str(&format!( + " {} {}\n", + crate::color::dim("count:"), + config.bindings.len() + )); if config.bindings.is_empty() { - output.push_str(" items: (none)\n"); + output.push_str(&format!( + " {} {}\n", + crate::color::dim("items:"), + crate::color::dim("(none)") + )); } else { for binding in &config.bindings { - output.push_str(&format!(" {} -> {}\n", binding.matcher, binding.profile)); + output.push_str(&format!( + " {} {} {}\n", + crate::color::accent(&binding.matcher), + crate::color::dim("->"), + crate::color::accent(&binding.profile) + )); } } - output.push_str("Observed games:\n"); - output.push_str(&format!(" count: {}\n", state.games.len())); + output.push_str(&format!("{}\n", crate::color::bold("Observed games:"))); + output.push_str(&format!( + " {} {}\n", + crate::color::dim("count:"), + state.games.len() + )); if state.games.is_empty() { - output.push_str(" items: (none)\n"); + output.push_str(&format!( + " {} {}\n", + crate::color::dim("items:"), + crate::color::dim("(none)") + )); } else { for game in &state.games { let resolved_profile = bindings::resolve_profile_for_observed(config, game).unwrap_or("default"); - output.push_str(&format!(" {}\n", game.executable)); - output.push_str(&format!(" resolved profile: {resolved_profile}\n")); - output.push_str(&format!(" last launched: {}\n", game.last_profile)); - output.push_str(&format!(" path: {}\n", game.command_path)); + output.push_str(&format!(" {}\n", crate::color::bold(&game.executable))); + output.push_str(&format!( + " {} {}\n", + crate::color::dim("resolved profile:"), + crate::color::accent(resolved_profile) + )); + output.push_str(&format!( + " {} {}\n", + crate::color::dim("last launched:"), + game.last_profile + )); + output.push_str(&format!( + " {} {}\n", + crate::color::dim("path:"), + crate::color::dim(&game.command_path) + )); if let Some(note) = &game.note { - output.push_str(&format!(" note: {note}\n")); + output.push_str(&format!(" {} {note}\n", crate::color::dim("note:"))); } } } diff --git a/tests/cli_matrix.rs b/tests/cli_matrix.rs index 276e3fa..dbadf5b 100644 --- a/tests/cli_matrix.rs +++ b/tests/cli_matrix.rs @@ -14,6 +14,7 @@ struct TestEnv { struct CmdResult { status: i32, output: String, + stdout: String, } impl TestEnv { @@ -40,6 +41,7 @@ impl TestEnv { fn run_with_env(&self, args: &[&str], extra_env: &[(&str, &str)]) -> CmdResult { let output = Command::new(env!("CARGO_BIN_EXE_gamewrap")) .args(args) + .current_dir(&self.home) .env("XDG_CONFIG_HOME", &self.config_home) .env("XDG_STATE_HOME", &self.state_home) .env("HOME", &self.home) @@ -48,6 +50,8 @@ impl TestEnv { .env_remove("SteamGameId") .env_remove("STEAM_COMPAT_DATA_PATH") .env_remove("STEAM_COMPAT_CLIENT_INSTALL_PATH") + .env_remove("DISPLAY") + .env_remove("WAYLAND_DISPLAY") .envs(extra_env.iter().copied()) .output() .expect("run gamewrap"); @@ -59,6 +63,7 @@ impl TestEnv { CmdResult { status: output.status.code().unwrap_or(-1), output: combined, + stdout: String::from_utf8_lossy(&output.stdout).into_owned(), } } @@ -70,18 +75,30 @@ impl TestEnv { let bin_dir = self.home.join("bin"); fs::create_dir_all(&bin_dir).expect("fake bin dir"); for name in names { - let path = bin_dir.join(name); - fs::write(&path, "#!/bin/sh\nexit 0\n").expect("write fake bin"); - let mut permissions = fs::metadata(&path) - .expect("fake bin metadata") - .permissions(); - permissions.set_mode(0o755); - fs::set_permissions(&path, permissions).expect("chmod fake bin"); + create_fake_bin(&bin_dir.join(name)); } let current_path = std::env::var_os("PATH").unwrap_or_default(); format!("{}:{}", bin_dir.display(), current_path.to_string_lossy()) } + + // Like path_with_fake_bins but returns ONLY the fake bin dir, not prepended to system PATH. + // Use this for "missing binary" dependency tests where the real binary may be installed. + fn path_isolated_fake_bins(&self, names: &[&str]) -> String { + let bin_dir = self.home.join("bin-isolated"); + fs::create_dir_all(&bin_dir).expect("isolated bin dir"); + for name in names { + create_fake_bin(&bin_dir.join(name)); + } + bin_dir.display().to_string() + } +} + +fn create_fake_bin(path: &std::path::Path) { + fs::write(path, "#!/bin/sh\nexit 0\n").expect("write fake bin"); + let mut perms = fs::metadata(path).expect("fake bin metadata").permissions(); + perms.set_mode(0o755); + fs::set_permissions(path, perms).expect("chmod fake bin"); } fn assert_ok(result: &CmdResult) { @@ -153,6 +170,58 @@ fn help_and_launch_commands_work() { assert_exit(&result, 2); assert!(result.output.contains("not a known help topic")); + let result = env.run(&["help", "settings"]); + assert_ok(&result); + assert!(result.output.contains("── MangoHud")); + assert!(result.output.contains("── Gamescope")); + + let result = env.run(&["help", "settings", "gamescope"]); + assert_ok(&result); + assert!(result.output.contains("Gamescope")); + assert!(result.output.contains("gamescope-filter")); + assert!(!result.output.contains("MangoHud")); + + let result = env.run(&["help", "settings", "mangohud"]); + assert_ok(&result); + assert!(result.output.contains("MangoHud")); + assert!(result.output.contains("mangohud")); + assert!(!result.output.contains("Gamescope")); + + let result = env.run(&["help", "settings", "unknowngroup"]); + assert_exit(&result, 2); + assert!(result.output.contains("not a known settings group")); + + for retired_setting in ["overlay", "performance"] { + let result = env.run(&["config", "set", retired_setting, "on"]); + assert_exit(&result, 3); + assert!( + result + .output + .contains(&format!("`{retired_setting}` is not a known setting")) + ); + } + + let result = env.run(&["config", "settings"]); + assert_ok(&result); + assert!(result.output.contains("mangohud")); + assert!(result.output.contains("gamemode")); + assert!(result.output.contains("gamescope-filter")); + assert!(result.output.contains("gamescope-mangoapp")); + assert!(result.output.contains("gamewrap help settings")); + + let result = env.run(&["config", "show", "--effective"]); + assert_ok(&result); + assert!( + result + .output + .contains("gamescope-fps = (not set — unlimited)") + ); + assert!( + result + .output + .contains("gamescope-filter = (not set — linear)") + ); + let result = env.run(&["status"]); assert_ok(&result); assert!(result.output.contains("Resolved defaults:")); @@ -284,7 +353,7 @@ fn help_and_launch_commands_work() { } #[test] -fn config_profiles_import_export_and_inheritance_work() { +fn config_profiles_import_export_work() { let env = TestEnv::new(); let result = env.run(&["config", "--help"]); @@ -295,17 +364,17 @@ fn config_profiles_import_export_and_inheritance_work() { assert!(result.output.contains("export")); assert!(result.output.contains("import")); - let result = env.run(&["config", "show"]); + let result = env.run(&["config", "show", "--effective"]); assert_ok(&result); - assert!(result.output.contains("overlay = on")); + assert!(result.output.contains("mangohud = on")); let result = env.run_with_env(&["config", "edit"], &[("EDITOR", "true")]); assert_ok(&result); assert!(result.output.is_empty()); for (setting, value) in [ - ("overlay", "off"), - ("performance", "off"), + ("mangohud", "off"), + ("gamemode", "off"), ("steam-host-libs", "off"), ("host-libs", "on"), ("game-libs", "keep"), @@ -314,8 +383,21 @@ fn config_profiles_import_export_and_inheritance_work() { ("gamescope-width", "1920"), ("gamescope-height", "1080"), ("gamescope-fps", "60"), + ("gamescope-nested-width", "1280"), + ("gamescope-nested-height", "720"), + ("gamescope-unfocused-fps", "15"), + ("gamescope-filter", "fsr"), + ("gamescope-scaler", "fit"), + ("gamescope-sharpness", "5"), + ("gamescope-mode", "fullscreen"), + ("gamescope-adaptive-sync", "on"), + ("gamescope-hdr", "on"), + ("gamescope-steam", "on"), + ("gamescope-expose-wayland", "on"), + ("gamescope-mangoapp", "on"), ("fps-cap", "60"), ("vkbasalt", "on"), + ("vkbasalt-config", "/tmp/default-vkbasalt.conf"), ("esync", "off"), ("fsync", "on"), ("large-address-aware", "on"), @@ -329,8 +411,8 @@ fn config_profiles_import_export_and_inheritance_work() { let result = env.run(&["config", "show"]); assert_ok(&result); - assert!(result.output.contains("overlay = off")); - assert!(result.output.contains("performance = off")); + assert!(result.output.contains("mangohud = off")); + assert!(result.output.contains("gamemode = off")); assert!(result.output.contains("steam-host-libs = on")); assert!(result.output.contains("game-libs = keep")); assert!(result.output.contains("verbose = on")); @@ -338,8 +420,25 @@ fn config_profiles_import_export_and_inheritance_work() { assert!(result.output.contains("gamescope-width = 1920")); assert!(result.output.contains("gamescope-height = 1080")); assert!(result.output.contains("gamescope-fps = 60")); + assert!(result.output.contains("gamescope-nested-width = 1280")); + assert!(result.output.contains("gamescope-nested-height = 720")); + assert!(result.output.contains("gamescope-unfocused-fps = 15")); + assert!(result.output.contains("gamescope-filter = fsr")); + assert!(result.output.contains("gamescope-scaler = fit")); + assert!(result.output.contains("gamescope-sharpness = 5")); + assert!(result.output.contains("gamescope-mode = fullscreen")); + assert!(result.output.contains("gamescope-adaptive-sync = on")); + assert!(result.output.contains("gamescope-hdr = on")); + assert!(result.output.contains("gamescope-steam = on")); + assert!(result.output.contains("gamescope-expose-wayland = on")); + assert!(result.output.contains("gamescope-mangoapp = on")); assert!(result.output.contains("fps-cap = 60")); assert!(result.output.contains("vkbasalt = on")); + assert!( + result + .output + .contains("vkbasalt-config = /tmp/default-vkbasalt.conf") + ); assert!(result.output.contains("esync = off")); assert!(result.output.contains("fsync = on")); assert!(result.output.contains("large-address-aware = on")); @@ -350,7 +449,7 @@ fn config_profiles_import_export_and_inheritance_work() { assert_exit(&result, 3); assert!(result.output.contains("not a known setting")); - let result = env.run(&["config", "set", "overlay", "maybe"]); + let result = env.run(&["config", "set", "mangohud", "maybe"]); assert_exit(&result, 3); assert!(result.output.contains("valid on/off value")); @@ -364,15 +463,28 @@ fn config_profiles_import_export_and_inheritance_work() { let result = env.run(&["config", "set", "gamescope-width", "wide"]); assert_exit(&result, 3); - assert!(result.output.contains("valid pixel count")); + assert!(result.output.contains("valid resolution")); + + let result = env.run(&["config", "set", "gamescope-mode", "maximized"]); + assert_exit(&result, 3); + assert!(result.output.contains("valid window mode")); + + assert_ok(&env.run(&["config", "set", "gamescope-width", "native"])); + assert_ok(&env.run(&["config", "set", "gamescope-height", "native"])); + let result = env.run(&["config", "show"]); + assert_ok(&result); + assert!(result.output.contains("gamescope-width = native")); + assert!(result.output.contains("gamescope-height = native")); + assert_ok(&env.run(&["config", "set", "gamescope-width", "1920"])); + assert_ok(&env.run(&["config", "set", "gamescope-height", "1080"])); let result = env.run(&["config", "set", "gamescope-fps", "fast"]); assert_exit(&result, 3); assert!(result.output.contains("valid FPS")); - let result = env.run(&["config", "reset", "overlay"]); + let result = env.run(&["config", "reset", "mangohud"]); assert_ok(&result); - assert!(result.output.contains("Reset default setting `overlay`.")); + assert!(result.output.contains("Reset default setting `mangohud`.")); let result = env.run(&["config", "reset", "nope"]); assert_exit(&result, 3); @@ -380,11 +492,8 @@ fn config_profiles_import_export_and_inheritance_work() { let result = env.run(&["profile", "--help"]); assert_ok(&result); - assert!(result.output.contains("tree")); assert!(result.output.contains("reset")); assert!(result.output.contains("duplicate")); - assert!(result.output.contains("inherit")); - assert!(result.output.contains("clear-inherit")); assert!(result.output.contains("env")); assert!(result.output.contains("export")); assert!(result.output.contains("import")); @@ -411,35 +520,13 @@ fn config_profiles_import_export_and_inheritance_work() { assert_ok(&result); assert!(result.output.contains("[defaults]")); - let result = env.run(&["profile", "show", "benchmark"]); + let result = env.run(&["profile", "show", "benchmark", "--effective"]); assert_ok(&result); - assert!(result.output.contains("overlay = (inherits: on)")); - assert!(result.output.contains("performance = (inherits: off)")); + assert!(result.output.contains("mangohud = (default: on)")); + assert!(result.output.contains("gamemode = (default: off)")); + assert!(result.output.contains("gamescope-fps = (default: 60)")); + assert!(result.output.contains("gamescope-filter = (default: fsr)")); - let result = env.run(&["profile", "set", "base", "overlay", "on"]); - assert_ok(&result); - let result = env.run(&["profile", "set", "base", "performance", "on"]); - assert_ok(&result); - let result = env.run(&["profile", "inherit", "benchmark", "base"]); - assert_ok(&result); - assert!( - result - .output - .contains("Profile `benchmark` now inherits from `base`.") - ); - - let result = env.run(&["profile", "show", "benchmark"]); - assert_ok(&result); - assert!(result.output.contains("inherits = base")); - assert!(result.output.contains("overlay = (inherits: on)")); - - let result = env.run(&["profile", "env", "set", "base", "GW_PARENT", "base-value"]); - assert_ok(&result); - assert!( - result - .output - .contains("Set env `GW_PARENT=base-value` on profile `base`.") - ); let result = env.run(&[ "profile", "env", @@ -462,7 +549,6 @@ fn config_profiles_import_export_and_inheritance_work() { assert_ok(&result); assert!(result.output.contains("GW_CHILD=bench-value")); assert!(result.output.contains("GW_PARENT=child-value")); - assert!(!result.output.contains("GW_PARENT=base-value")); let result = env.run(&["profile", "env", "unset", "benchmark", "GW_CHILD"]); assert_ok(&result); @@ -487,29 +573,19 @@ fn config_profiles_import_export_and_inheritance_work() { ); let result = env.run(&["profile", "env", "list", "benchmark"]); assert_ok(&result); - assert!(result.output.contains("GW_PARENT=base-value")); - - let result = env.run(&["profile", "tree"]); - assert_ok(&result); - assert!(result.output.contains("default (built-in)")); - assert!(result.output.contains("base")); - assert!(result.output.contains("benchmark")); + assert!(result.output.contains("(no env vars set on this profile)")); let result = env.run(&["game", "bind", "Demo.exe", "benchmark"]); assert_ok(&result); let result = env.run(&["profile", "list"]); assert_ok(&result); - assert!( - result - .output - .contains("benchmark (inherits: base, 1 binding)") - ); + assert!(result.output.contains("benchmark (1 binding)")); let result = env.run(&["game", "unbind", "Demo.exe"]); assert_ok(&result); for (setting, value) in [ - ("overlay", "off"), - ("performance", "on"), + ("mangohud", "off"), + ("gamemode", "on"), ("steam-host-libs", "off"), ("game-libs", "gamemode"), ("verbose", "off"), @@ -517,8 +593,21 @@ fn config_profiles_import_export_and_inheritance_work() { ("gamescope-width", "2560"), ("gamescope-height", "1440"), ("gamescope-fps", "120"), + ("gamescope-nested-width", "1920"), + ("gamescope-nested-height", "1080"), + ("gamescope-unfocused-fps", "30"), + ("gamescope-filter", "nis"), + ("gamescope-scaler", "fill"), + ("gamescope-sharpness", "10"), + ("gamescope-mode", "borderless"), + ("gamescope-adaptive-sync", "on"), + ("gamescope-hdr", "off"), + ("gamescope-steam", "on"), + ("gamescope-expose-wayland", "off"), + ("gamescope-mangoapp", "on"), ("fps-cap", "120"), ("vkbasalt", "off"), + ("vkbasalt-config", "/tmp/profile-vkbasalt.conf"), ("esync", "on"), ("fsync", "off"), ("laa", "off"), @@ -527,14 +616,13 @@ fn config_profiles_import_export_and_inheritance_work() { ] { let result = env.run(&["profile", "set", "benchmark", setting, value]); assert_ok(&result); - assert!(result.output.contains("Updated profile `benchmark`")); + assert!(result.output.contains("Updated profile benchmark")); } let result = env.run(&["profile", "show", "benchmark"]); assert_ok(&result); - assert!(result.output.contains("inherits = base")); - assert!(result.output.contains("overlay = off")); - assert!(result.output.contains("performance = on")); + assert!(result.output.contains("mangohud = off")); + assert!(result.output.contains("gamemode = on")); assert!(result.output.contains("steam-host-libs = off")); assert!(result.output.contains("game-libs = gamemode")); assert!(result.output.contains("verbose = off")); @@ -542,8 +630,23 @@ fn config_profiles_import_export_and_inheritance_work() { assert!(result.output.contains("gamescope-width = 2560")); assert!(result.output.contains("gamescope-height = 1440")); assert!(result.output.contains("gamescope-fps = 120")); + assert!(result.output.contains("gamescope-nested-width = 1920")); + assert!(result.output.contains("gamescope-nested-height = 1080")); + assert!(result.output.contains("gamescope-unfocused-fps = 30")); + assert!(result.output.contains("gamescope-filter = nis")); + assert!(result.output.contains("gamescope-scaler = fill")); + assert!(result.output.contains("gamescope-sharpness = 10")); + assert!(result.output.contains("gamescope-mode = borderless")); + assert!(result.output.contains("gamescope-adaptive-sync = on")); + assert!(result.output.contains("gamescope-steam = on")); + assert!(result.output.contains("gamescope-mangoapp = on")); assert!(result.output.contains("fps-cap = 120")); assert!(result.output.contains("vkbasalt = off")); + assert!( + result + .output + .contains("vkbasalt-config = /tmp/profile-vkbasalt.conf") + ); assert!(result.output.contains("esync = on")); assert!(result.output.contains("fsync = off")); assert!(result.output.contains("large-address-aware = off")); @@ -555,13 +658,12 @@ fn config_profiles_import_export_and_inheritance_work() { assert!( result .output - .contains("Duplicated profile `benchmark` to `benchmark-copy`.") + .contains("Duplicated profile benchmark to benchmark-copy.") ); let result = env.run(&["profile", "show", "benchmark-copy"]); assert_ok(&result); - assert!(result.output.contains("inherits = base")); - assert!(result.output.contains("overlay = off")); + assert!(result.output.contains("mangohud = off")); let result = env.run(&["profile", "duplicate", "missing", "whatever"]); assert_exit(&result, 3); @@ -575,7 +677,7 @@ fn config_profiles_import_export_and_inheritance_work() { .contains("Profile `benchmark-copy` already exists.") ); - let result = env.run(&["profile", "set", "missing", "overlay", "on"]); + let result = env.run(&["profile", "set", "missing", "mangohud", "on"]); assert_exit(&result, 3); assert!(result.output.contains("Profile `missing` does not exist.")); @@ -583,13 +685,13 @@ fn config_profiles_import_export_and_inheritance_work() { assert_exit(&result, 3); assert!(result.output.contains("not a known setting")); - let result = env.run(&["profile", "set", "benchmark", "overlay", "maybe"]); + let result = env.run(&["profile", "set", "benchmark", "mangohud", "maybe"]); assert_exit(&result, 3); assert!(result.output.contains("valid on/off value")); for setting in [ - "overlay", - "performance", + "mangohud", + "gamemode", "steam-host-libs", "game-libs", "verbose", @@ -597,8 +699,21 @@ fn config_profiles_import_export_and_inheritance_work() { "gamescope-width", "gamescope-height", "gamescope-fps", + "gamescope-nested-width", + "gamescope-nested-height", + "gamescope-unfocused-fps", + "gamescope-filter", + "gamescope-scaler", + "gamescope-sharpness", + "gamescope-mode", + "gamescope-adaptive-sync", + "gamescope-hdr", + "gamescope-steam", + "gamescope-expose-wayland", + "gamescope-mangoapp", "fps-cap", "vkbasalt", + "vkbasalt-config", "esync", "fsync", "large-address-aware", @@ -607,85 +722,51 @@ fn config_profiles_import_export_and_inheritance_work() { ] { let result = env.run(&["profile", "reset", "benchmark", setting]); assert_ok(&result); - assert!(result.output.contains("Reset profile `benchmark` setting")); + assert!(result.output.contains("Reset profile benchmark setting")); } - let result = env.run(&["profile", "show", "benchmark"]); + let result = env.run(&["profile", "show", "benchmark", "--effective"]); assert_ok(&result); - assert!(result.output.contains("inherits = base")); - assert!(result.output.contains("overlay = (inherits: on)")); - assert!(result.output.contains("performance = (inherits: on)")); - assert!(result.output.contains("steam-host-libs = (inherits: on)")); - assert!(result.output.contains("game-libs = (inherits: keep)")); - assert!(result.output.contains("verbose = (inherits: on)")); - assert!(result.output.contains("gamescope = (inherits: on)")); - assert!(result.output.contains("gamescope-width = (inherits: 1920)")); + assert!(result.output.contains("mangohud = (default: on)")); + assert!(result.output.contains("gamemode = (default: off)")); + assert!(result.output.contains("steam-host-libs = (default: on)")); + assert!(result.output.contains("game-libs = (default: keep)")); + assert!(result.output.contains("verbose = (default: on)")); + assert!(result.output.contains("gamescope = (default: on)")); + assert!(result.output.contains("gamescope-width = (default: 1920)")); + assert!(result.output.contains("gamescope-height = (default: 1080)")); + assert!(result.output.contains("gamescope-fps = (default: 60)")); assert!( result .output - .contains("gamescope-height = (inherits: 1080)") + .contains("gamescope-mode = (default: fullscreen)") ); - assert!(result.output.contains("gamescope-fps = (inherits: 60)")); - assert!(result.output.contains("fps-cap = (inherits: 60)")); - assert!(result.output.contains("vkbasalt = (inherits: on)")); - assert!(result.output.contains("esync = (inherits: off)")); - assert!(result.output.contains("fsync = (inherits: on)")); + assert!(result.output.contains("fps-cap = (default: 60)")); + assert!(result.output.contains("vkbasalt = (default: on)")); assert!( result .output - .contains("large-address-aware = (inherits: on)") + .contains("vkbasalt-config = (default: /tmp/default-vkbasalt.conf)") + ); + assert!(result.output.contains("esync = (default: off)")); + assert!(result.output.contains("fsync = (default: on)")); + assert!( + result + .output + .contains("large-address-aware = (default: on)") ); assert!( result .output - .contains("pre-launch = (inherits: printf default-pre)") + .contains("pre-launch = (default: printf default-pre)") ); assert!( result .output - .contains("post-launch = (inherits: printf default-post)") + .contains("post-launch = (default: printf default-post)") ); - let result = env.run(&["profile", "inherit", "benchmark-copy", "recording"]); - assert_ok(&result); - let result = env.run(&["profile", "show", "benchmark-copy"]); - assert_ok(&result); - assert!(result.output.contains("inherits = recording")); - - let result = env.run(&["profile", "inherit", "benchmark-copy", "default"]); - assert_exit(&result, 3); - assert!(result.output.contains("`default` is already the base")); - - let result = env.run(&["profile", "inherit", "benchmark-copy", "missing"]); - assert_exit(&result, 3); - assert!( - result - .output - .contains("Parent profile `missing` does not exist.") - ); - - let result = env.run(&["profile", "inherit", "recording", "benchmark-copy"]); - assert_exit(&result, 3); - assert!(result.output.contains("Profile inheritance cycle detected")); - - let result = env.run(&["profile", "delete", "recording"]); - assert_exit(&result, 3); - assert!(result.output.contains("Cannot delete profile `recording`")); - - let result = env.run(&["profile", "clear-inherit", "benchmark-copy"]); - assert_ok(&result); - assert!( - result - .output - .contains("Cleared inherited parent for `benchmark-copy`.") - ); - - let result = env.run(&["profile", "show", "benchmark-copy"]); - assert_ok(&result); - assert!(!result.output.contains("inherits = ")); - assert!(result.output.contains("overlay = off")); - - let result = env.run(&["profile", "reset", "missing", "overlay"]); + let result = env.run(&["profile", "reset", "missing", "mangohud"]); assert_exit(&result, 3); assert!(result.output.contains("Profile `missing` does not exist.")); @@ -695,15 +776,18 @@ fn config_profiles_import_export_and_inheritance_work() { let export_base = env.path("exported-config"); let export_file = env.path("exported-config.gamewrap.toml"); + // no-path export writes config.gamewrap.toml in cwd (self.home via current_dir) + let default_export_file = env.path("config.gamewrap.toml"); let result = env.run(&["config", "export"]); assert_ok(&result); - assert!(result.output.contains("kind = \"gamewrap-config\"")); - assert!(result.output.contains("version = 1")); - assert!(result.output.contains("[defaults]")); - assert!(result.output.contains("overlay = true")); - assert!(result.output.contains("[profiles.base]")); - assert!(result.output.contains("[profiles.benchmark]")); - assert!(result.output.contains("[profiles.benchmark-copy]")); + assert!(result.output.contains("Exported config to")); + let default_exported = fs::read_to_string(&default_export_file).expect("default export file"); + assert!(default_exported.contains("kind = \"gamewrap-config\"")); + assert!(default_exported.contains("version = 1")); + assert!(default_exported.contains("[defaults]")); + assert!(default_exported.contains("[profiles.base]")); + assert!(default_exported.contains("[profiles.benchmark]")); + assert!(default_exported.contains("[profiles.benchmark-copy]")); let result = env.run(&["config", "export", export_base.to_str().expect("utf8 path")]); assert_ok(&result); @@ -718,10 +802,8 @@ fn config_profiles_import_export_and_inheritance_work() { assert_ok(&result); let result = imported_env.run(&["config", "show"]); assert_ok(&result); - assert!(!result.output.contains("(inherits:")); assert!(result.output.contains("benchmark")); assert!(result.output.contains("benchmark-copy")); - assert!(result.output.contains("overlay = on")); let profile_export_base = env.path("benchmark"); let profile_export = env.path("benchmark.gamewrap-profile.toml"); @@ -732,7 +814,7 @@ fn config_profiles_import_export_and_inheritance_work() { profile_export_base.to_str().expect("utf8 path"), ]); assert_ok(&result); - assert!(result.output.contains("Exported profile `benchmark`")); + assert!(result.output.contains("Exported profile benchmark")); let exported_profile = fs::read_to_string(&profile_export).expect("profile export"); assert!(exported_profile.contains("kind = \"gamewrap-profile\"")); assert!(exported_profile.contains("name = \"benchmark\"")); @@ -745,11 +827,14 @@ fn config_profiles_import_export_and_inheritance_work() { profile_export_base.to_str().expect("utf8 path"), ]); assert_ok(&result); - assert!(result.output.contains("Imported profile `benchmark`")); + assert!(result.output.contains("Imported profile benchmark")); let result = imported_profile_env.run(&["profile", "show", "benchmark"]); assert_ok(&result); - assert!(!result.output.contains("inherits =")); - assert!(result.output.contains("overlay = on")); + assert!( + result + .output + .contains("(no settings configured for this profile)") + ); let result = imported_profile_env.run(&[ "profile", @@ -788,12 +873,71 @@ fn config_profiles_import_export_and_inheritance_work() { assert!(result.output.contains("Profile import file")); } +#[test] +fn profile_export_is_sparse() { + let env = TestEnv::new(); + assert_ok(&env.run(&["profile", "create", "benchmark"])); + assert_ok(&env.run(&["profile", "set", "benchmark", "gamescope", "on"])); + assert_ok(&env.run(&["profile", "set", "benchmark", "gamescope-width", "1920"])); + + let export_base = env.path("sparse-benchmark"); + let result = env.run(&[ + "profile", + "export", + "benchmark", + export_base.to_str().expect("utf8 path"), + ]); + assert_ok(&result); + + let exported = fs::read_to_string(env.path("sparse-benchmark.gamewrap-profile.toml")) + .expect("profile export"); + assert!(exported.contains("gamescope = true")); + assert!(exported.contains("gamescope_width = 1920")); + assert!(!exported.contains("mangohud")); + assert!(!exported.contains("gamemode")); +} + +#[test] +fn config_reset_all_restores_built_in_defaults() { + let env = TestEnv::new(); + + assert_ok(&env.run(&["config", "set", "mangohud", "off"])); + assert_ok(&env.run(&["config", "set", "fps-cap", "60"])); + + let result = env.run(&["config", "reset"]); + assert_exit(&result, 2); + + let result = env.run(&["config", "reset", "--all"]); + assert_ok(&result); + assert!( + result + .output + .contains("Reset all default settings to built-in defaults.") + ); + + let result = env.run(&["config", "show", "--effective"]); + assert_ok(&result); + assert!(result.output.contains("mangohud = on")); + assert!(result.output.contains("fps-cap = (not set — no cap)")); + assert!( + result + .output + .contains("gamescope-width = (not set — gamescope default: 1280)") + ); + assert!( + result + .output + .contains("vkbasalt-config = (not set — ~/.config/vkBasalt/vkBasalt.conf)") + ); + assert!(result.output.contains("esync = (not set — Proton default)")); +} + #[test] fn games_bindings_notes_and_filters_work() { let env = TestEnv::new(); - assert_ok(&env.run(&["config", "set", "overlay", "off"])); - assert_ok(&env.run(&["config", "set", "performance", "off"])); + assert_ok(&env.run(&["config", "set", "mangohud", "off"])); + assert_ok(&env.run(&["config", "set", "gamemode", "off"])); assert_ok(&env.run(&["profile", "create", "benchmark"])); assert_ok(&env.run(&["profile", "create", "recording"])); @@ -885,7 +1029,7 @@ fn games_bindings_notes_and_filters_work() { assert!( result .output - .contains("Renamed `StarRuptureGameSteam.exe` to `Star Rupture`.") + .contains("Renamed StarRuptureGameSteam.exe to Star Rupture.") ); let result = env.run(&["game", "list"]); @@ -902,7 +1046,7 @@ fn games_bindings_notes_and_filters_work() { assert!( result .output - .contains("Bound `StarRuptureGameSteam.exe` to profile `benchmark`.") + .contains("Bound StarRuptureGameSteam.exe to profile benchmark.") ); let result = env.run(&[ @@ -915,7 +1059,7 @@ fn games_bindings_notes_and_filters_work() { assert!( result .output - .contains("Bound `/games/Grind Survivors/GrindSurvivors.exe` to profile `recording`.") + .contains("Bound /games/Grind Survivors/GrindSurvivors.exe to profile recording.") ); let result = env.run(&["game", "list"]); @@ -943,7 +1087,7 @@ fn games_bindings_notes_and_filters_work() { assert!( result .output - .contains("Saved note for `StarRuptureGameSteam.exe`.") + .contains("Set note for StarRuptureGameSteam.exe.") ); let result = env.run(&["game", "show", "StarRuptureGameSteam.exe"]); @@ -969,7 +1113,7 @@ fn games_bindings_notes_and_filters_work() { let result = env.run(&["game", "unbind", "starrupture"]); assert_ok(&result); - assert!(result.output.contains("Removed binding for `starrupture`.")); + assert!(result.output.contains("Removed binding for starrupture.")); let result = env.run(&["config", "show"]); assert_ok(&result); @@ -995,7 +1139,7 @@ fn games_bindings_notes_and_filters_work() { assert!( result .output - .contains("Cleared note for `StarRuptureGameSteam.exe`.") + .contains("Cleared note for StarRuptureGameSteam.exe.") ); let result = env.run(&["game", "show", "StarRuptureGameSteam.exe"]); @@ -1007,7 +1151,7 @@ fn games_bindings_notes_and_filters_work() { assert!( result .output - .contains("Removed `StarRuptureGameSteam.exe` from observed games.") + .contains("Removed StarRuptureGameSteam.exe from observed games.") ); let result = env.run(&["game", "show", "StarRuptureGameSteam.exe"]); @@ -1078,7 +1222,7 @@ fn subcommand_typos_and_extra_args_fail_cleanly() { assert_exit(&result, 2); assert!(result.output.contains("unrecognized subcommand 'crtate'")); - let result = env.run(&["config", "sett", "overlay", "on"]); + let result = env.run(&["config", "sett", "mangohud", "on"]); assert_exit(&result, 2); assert!(result.output.contains("unrecognized subcommand 'sett'")); @@ -1091,9 +1235,9 @@ fn subcommand_typos_and_extra_args_fail_cleanly() { fn post_launch_hook_runs_after_game_exits() { let env = TestEnv::new(); - // Disable overlay and performance so we don't need mangohud/gamemoderun. - assert_ok(&env.run(&["config", "set", "overlay", "off"])); - assert_ok(&env.run(&["config", "set", "performance", "off"])); + // Disable mangohud and gamemode so we don't need mangohud/gamemoderun. + assert_ok(&env.run(&["config", "set", "mangohud", "off"])); + assert_ok(&env.run(&["config", "set", "gamemode", "off"])); // Set a post-launch hook that prints a distinctive string to stdout. assert_ok(&env.run(&["config", "set", "post-launch", "printf POSTHOOK_RAN"])); @@ -1118,14 +1262,73 @@ fn post_launch_hook_runs_after_game_exits() { assert!(result.output.contains("printf POSTHOOK_RAN")); } +#[test] +fn launch_decision_log_records_hooks_exit_and_playtime() { + let env = TestEnv::new(); + let log_path = env.state_home.join("custom/gamewrap.log"); + let log_path_str = log_path.to_string_lossy().into_owned(); + + assert_ok(&env.run(&["config", "set", "mangohud", "off"])); + assert_ok(&env.run(&["config", "set", "gamemode", "off"])); + assert_ok(&env.run(&["config", "set", "log-file", "on"])); + assert_ok(&env.run(&["config", "set", "log-path", &log_path_str])); + assert_ok(&env.run(&["config", "set", "pre-launch", "exit 3"])); + assert_ok(&env.run(&["config", "set", "post-launch", "exit 4"])); + + let result = env.run(&["run", "/bin/sh", "-c", "exit 7"]); + assert_exit(&result, 7); + + let log = fs::read_to_string(log_path).expect("decision log"); + assert!(log.contains("--- launch ---")); + assert!(log.contains("executable: sh")); + assert!(log.contains("profile: default")); + assert!(log.contains("command: /bin/sh -c exit 7")); + assert!(log.contains("pre-launch: exit 3 → exit 3")); + assert!(log.contains("exit: 7")); + assert!(log.contains("playtime: 0s")); + assert!(log.contains("post-launch: exit 4 → exit 4")); +} + +#[test] +fn logging_settings_round_trip_and_validate_values() { + let env = TestEnv::new(); + + for (setting, value) in [ + ("log-file", "on"), + ("log-path", "/tmp/gamewrap.log"), + ("mangohud-log", "on"), + ("mangohud-log-path", "/tmp/mangohud.csv"), + ("vkbasalt-log-level", "warning"), + ] { + assert_ok(&env.run(&["config", "set", setting, value])); + } + + let result = env.run(&["config", "show"]); + assert_ok(&result); + assert!(result.output.contains("log-file = on")); + assert!(result.output.contains("log-path = /tmp/gamewrap.log")); + assert!(result.output.contains("mangohud-log = on")); + assert!( + result + .output + .contains("mangohud-log-path = /tmp/mangohud.csv") + ); + assert!(result.output.contains("vkbasalt-log-level = warning")); + + let invalid = env.run(&["config", "set", "vkbasalt-log-level", "warn"]); + assert_exit(&invalid, 3); + assert!(invalid.output.contains("debug, info, warning, error, none")); +} + #[test] fn env_vars_appear_in_verbose_dry_run() { let env = TestEnv::new(); - assert_ok(&env.run(&["config", "set", "overlay", "on"])); - assert_ok(&env.run(&["config", "set", "performance", "off"])); + assert_ok(&env.run(&["config", "set", "mangohud", "on"])); + assert_ok(&env.run(&["config", "set", "gamemode", "off"])); assert_ok(&env.run(&["config", "set", "verbose", "on"])); assert_ok(&env.run(&["config", "set", "vkbasalt", "on"])); + assert_ok(&env.run(&["config", "set", "vkbasalt-config", "/tmp/vkbasalt.conf"])); assert_ok(&env.run(&["config", "set", "esync", "on"])); assert_ok(&env.run(&["config", "set", "fsync", "off"])); assert_ok(&env.run(&["config", "set", "large-address-aware", "on"])); @@ -1138,6 +1341,11 @@ fn env_vars_appear_in_verbose_dry_run() { // Verbose mode shows environment changes. assert!(result.output.contains("Environment changes:")); assert!(result.output.contains("ENABLE_VKBASALT=1")); + assert!( + result + .output + .contains("VKBASALT_CONFIG_FILE=/tmp/vkbasalt.conf") + ); assert!(result.output.contains("PROTON_NO_ESYNC=0")); assert!(result.output.contains("PROTON_NO_FSYNC=1")); assert!(result.output.contains("PROTON_LARGE_ADDRESS_AWARE=1")); @@ -1147,12 +1355,383 @@ fn env_vars_appear_in_verbose_dry_run() { assert!(result.output.contains("mangohud /usr/bin/true")); } +#[test] +fn gamescope_extended_settings_and_mangoapp_work() { + let env = TestEnv::new(); + + // Set up: gamescope on, full output + nested res, filter, scaler, sharpness. + assert_ok(&env.run(&["config", "set", "gamescope", "on"])); + assert_ok(&env.run(&["config", "set", "gamescope-width", "1920"])); + assert_ok(&env.run(&["config", "set", "gamescope-height", "1080"])); + assert_ok(&env.run(&["config", "set", "gamescope-nested-width", "1280"])); + assert_ok(&env.run(&["config", "set", "gamescope-nested-height", "720"])); + assert_ok(&env.run(&["config", "set", "gamescope-fps", "60"])); + assert_ok(&env.run(&["config", "set", "gamescope-unfocused-fps", "15"])); + assert_ok(&env.run(&["config", "set", "gamescope-filter", "fsr"])); + assert_ok(&env.run(&["config", "set", "gamescope-scaler", "fit"])); + assert_ok(&env.run(&["config", "set", "gamescope-sharpness", "5"])); + assert_ok(&env.run(&["config", "set", "gamescope-mode", "fullscreen"])); + assert_ok(&env.run(&["config", "set", "gamescope-adaptive-sync", "on"])); + assert_ok(&env.run(&["config", "set", "gamescope-hdr", "on"])); + assert_ok(&env.run(&["config", "set", "gamescope-steam", "on"])); + assert_ok(&env.run(&["config", "set", "gamescope-expose-wayland", "on"])); + + // Verify config show reflects the new keys. + let result = env.run(&["config", "show"]); + assert_ok(&result); + assert!(result.output.contains("gamescope-nested-width = 1280")); + assert!(result.output.contains("gamescope-nested-height = 720")); + assert!(result.output.contains("gamescope-unfocused-fps = 15")); + assert!(result.output.contains("gamescope-filter = fsr")); + assert!(result.output.contains("gamescope-scaler = fit")); + assert!(result.output.contains("gamescope-sharpness = 5")); + assert!(result.output.contains("gamescope-mode = fullscreen")); + assert!(result.output.contains("gamescope-adaptive-sync = on")); + assert!(result.output.contains("gamescope-hdr = on")); + assert!(result.output.contains("gamescope-steam = on")); + assert!(result.output.contains("gamescope-expose-wayland = on")); + + // Dry-run: mangohud off — all new flags should appear in gamescope args. + assert_ok(&env.run(&["config", "set", "mangohud", "off"])); + assert_ok(&env.run(&["config", "set", "gamemode", "off"])); + let fake_path = env.path_with_fake_bins(&["gamescope"]); + let result = env.run_with_env(&["dry-run", "/usr/bin/true"], &[("PATH", &fake_path)]); + assert_ok(&result); + assert!(result.output.contains("-W 1920")); + assert!(result.output.contains("-H 1080")); + assert!(result.output.contains("-w 1280")); + assert!(result.output.contains("-h 720")); + assert!(result.output.contains("-r 60")); + assert!(result.output.contains("-o 15")); + assert!(result.output.contains("-F fsr")); + assert!(result.output.contains("-S fit")); + assert!(result.output.contains("--sharpness 5")); + assert!(result.output.contains("-f")); + assert!(result.output.contains("--adaptive-sync")); + assert!(result.output.contains("--hdr-enabled")); + assert!(result.output.contains("-e")); + assert!(result.output.contains("--expose-wayland")); + // No mangoapp flag since gamescope-mangoapp is off. + assert!(!result.output.contains("--mangoapp")); + + // Validation: invalid filter value rejected. + let result = env.run(&["config", "set", "gamescope-filter", "magic"]); + assert_exit(&result, 3); + assert!(result.output.contains("not a valid gamescope filter")); + + // Validation: invalid scaler value rejected. + let result = env.run(&["config", "set", "gamescope-scaler", "zoom"]); + assert_exit(&result, 3); + assert!(result.output.contains("not a valid gamescope scaler")); + + // Validation: sharpness out of range rejected. + let result = env.run(&["config", "set", "gamescope-sharpness", "25"]); + assert_exit(&result, 3); + assert!(result.output.contains("out of range")); + + // mangoapp ON + mangohud ON: --mangoapp in gamescope args, no mangohud prefix. + assert_ok(&env.run(&["config", "set", "mangohud", "on"])); + assert_ok(&env.run(&["config", "set", "gamescope-mangoapp", "on"])); + let fake_path = env.path_with_fake_bins(&["gamescope", "mangoapp"]); + let result = env.run_with_env(&["dry-run", "/usr/bin/true"], &[("PATH", &fake_path)]); + assert_ok(&result); + assert!(result.output.contains("--mangoapp")); + assert!(!result.output.contains("mangohud")); + + // mangoapp OFF + mangohud ON + gamescope ON: mangohud prefix inside gamescope. + assert_ok(&env.run(&["config", "set", "gamescope-mangoapp", "off"])); + let fake_path = env.path_with_fake_bins(&["gamescope", "mangohud"]); + let result = env.run_with_env(&["dry-run", "/usr/bin/true"], &[("PATH", &fake_path)]); + assert_ok(&result); + assert!(!result.output.contains("--mangoapp")); + assert!(result.output.contains("-- mangohud")); + + // Doctor: warns about mangohud prefix inside gamescope when mangoapp is off. + let result = env.run_with_env(&["doctor"], &[("PATH", &fake_path)]); + assert_ok(&result); + assert!(result.output.contains("gamescope overlay")); + + // mangoapp dependency error when mangoapp binary is missing. + assert_ok(&env.run(&["config", "set", "gamescope-mangoapp", "on"])); + // Use isolated PATH so the system's real mangoapp (from mangohud package) can't be found. + let fake_path_no_mangoapp = env.path_isolated_fake_bins(&["gamescope"]); + let result = env.run_with_env( + &["dry-run", "/usr/bin/true"], + &[("PATH", &fake_path_no_mangoapp)], + ); + assert_exit(&result, 4); + assert!(result.output.contains("mangoapp")); + assert!(result.output.contains("mangohud")); + + // Profile: gamescope-mangoapp and new keys round-trip through profile set/show. + assert_ok(&env.run(&["profile", "create", "gaming"])); + assert_ok(&env.run(&["profile", "set", "gaming", "gamescope-mangoapp", "on"])); + assert_ok(&env.run(&["profile", "set", "gaming", "gamescope-filter", "nis"])); + assert_ok(&env.run(&["profile", "set", "gaming", "gamescope-sharpness", "10"])); + assert_ok(&env.run(&["profile", "set", "gaming", "gamescope-adaptive-sync", "on"])); + assert_ok(&env.run(&["profile", "set", "gaming", "gamescope-hdr", "on"])); + assert_ok(&env.run(&["profile", "set", "gaming", "gamescope-nested-width", "2560"])); + assert_ok(&env.run(&[ + "profile", + "set", + "gaming", + "gamescope-nested-height", + "1440", + ])); + + let result = env.run(&["profile", "show", "gaming"]); + assert_ok(&result); + assert!(result.output.contains("gamescope-mangoapp = on")); + assert!(result.output.contains("gamescope-filter = nis")); + assert!(result.output.contains("gamescope-sharpness = 10")); + assert!(result.output.contains("gamescope-adaptive-sync = on")); + assert!(result.output.contains("gamescope-hdr = on")); + assert!(result.output.contains("gamescope-nested-width = 2560")); + assert!(result.output.contains("gamescope-nested-height = 1440")); + + // Reset new keys. + for setting in [ + "gamescope-nested-width", + "gamescope-nested-height", + "gamescope-unfocused-fps", + "gamescope-filter", + "gamescope-scaler", + "gamescope-sharpness", + "gamescope-mode", + "gamescope-adaptive-sync", + "gamescope-hdr", + "gamescope-steam", + "gamescope-expose-wayland", + "gamescope-mangoapp", + ] { + let result = env.run(&["profile", "reset", "gaming", setting]); + assert_ok(&result); + } +} + +#[test] +fn test_config_show_modes() { + let env = TestEnv::new(); + + // Configured-only (default) with nothing set: show placeholder + let result = env.run(&["config", "show"]); + assert_ok(&result); + assert!( + result.output.contains("no global settings configured"), + "expected placeholder for empty config, got: {}", + result.output + ); + + // Set a single value, then show: only that setting appears + assert_ok(&env.run(&["config", "set", "mangohud", "off"])); + let result = env.run(&["config", "show"]); + assert_ok(&result); + assert!(result.output.contains("mangohud = off")); + assert!( + !result.output.contains("gamemode"), + "configured mode should not show unset fields" + ); + + // --effective shows all settings including defaults + let result = env.run(&["config", "show", "--effective"]); + assert_ok(&result); + assert!(result.output.contains("mangohud = off")); + assert!( + result.output.contains("gamemode = on"), + "--effective should show default settings" + ); + assert!( + result.output.contains("gamescope = off"), + "--effective should show all fields" + ); + + assert_ok(&env.run(&["profile", "create", "benchmark"])); + + let result = env.run(&["config", "show"]); + assert_ok(&result); + assert!( + result + .output + .contains("(no settings configured for this profile)") + ); + assert!(!result.output.contains("(default:")); + + let result = env.run(&["config", "show", "--effective"]); + assert_ok(&result); + assert!(result.output.contains("gamemode = (default: on)")); + + let result = env.run(&["profile", "show", "benchmark"]); + assert_ok(&result); + assert!( + result + .output + .contains("(no settings configured for this profile)") + ); + assert!(!result.output.contains("(default:")); + + let result = env.run(&["profile", "show", "benchmark", "--effective"]); + assert_ok(&result); + assert!(result.output.contains("gamemode = (default: on)")); +} + +#[test] +fn test_config_auto_migrate() { + let env = TestEnv::new(); + let config_dir = env.config_home.join("gamewrap"); + let config_file = config_dir.join("config.toml"); + fs::create_dir_all(&config_dir).expect("config dir"); + fs::write( + &config_file, + "[defaults]\noverlay = true\nperformance = false\n", + ) + .expect("write old config"); + + let result = env.run(&["config", "show"]); + assert_ok(&result); + + let migrated = fs::read_to_string(config_file).expect("migrated config"); + assert!(migrated.contains("mangohud = true")); + assert!(migrated.contains("gamemode = false")); + assert!(!migrated.contains("overlay")); + assert!(!migrated.contains("performance")); +} + +#[test] +fn test_config_migrate_command() { + let env = TestEnv::new(); + let config_dir = env.config_home.join("gamewrap"); + let config_file = config_dir.join("config.toml"); + fs::create_dir_all(&config_dir).expect("config dir"); + fs::write( + &config_file, + "[defaults]\noverlay = true\nperformance = false\nfps_cap = 60\n", + ) + .expect("write old config"); + + let result = env.run(&["config", "migrate"]); + assert_ok(&result); + assert!(result.output.contains("Config migrated successfully.")); + + let migrated = fs::read_to_string(config_file).expect("migrated config"); + assert!(migrated.contains("mangohud = true")); + assert!(migrated.contains("gamemode = false")); + assert!(migrated.contains("fps_cap = 60")); + assert!(!migrated.contains("overlay")); + assert!(!migrated.contains("performance")); +} + +#[test] +fn test_profile_migrate_command() { + let env = TestEnv::new(); + let path = env.path("old-bench.gamewrap-profile.toml"); + fs::write( + &path, + r#"kind = "gamewrap-profile" +version = 1 +name = "old-bench" + +[settings] +overlay = true +performance = false +fps_cap = 60 +gamescope = false +"#, + ) + .expect("write old profile"); + + let result = env.run(&["profile", "migrate", path.to_str().expect("utf8 path")]); + assert_ok(&result); + assert!( + result + .output + .contains("Profile export migrated successfully.") + ); + + let migrated = fs::read_to_string(path).expect("migrated profile"); + assert!(migrated.contains("mangohud = true")); + assert!(migrated.contains("gamemode = false")); + assert!(migrated.contains("fps_cap = 60")); + assert!(!migrated.contains("overlay")); + assert!(!migrated.contains("performance")); +} + +#[test] +fn test_profile_migrate_dry_run() { + let env = TestEnv::new(); + let path = env.path("dry-run.gamewrap-profile.toml"); + let original = r#"kind = "gamewrap-profile" +version = 1 +name = "old-bench" + +[settings] +overlay = true +performance = false +"#; + fs::write(&path, original).expect("write old profile"); + + let result = env.run(&[ + "profile", + "migrate", + "--dry-run", + path.to_str().expect("utf8 path"), + ]); + assert_ok(&result); + assert!(result.stdout.contains("mangohud = true")); + assert!(result.stdout.contains("gamemode = false")); + assert_eq!( + fs::read_to_string(path).expect("original profile"), + original + ); +} + +#[test] +fn test_profile_import_warns_on_old_keys() { + let env = TestEnv::new(); + let path = env.path("old-import.gamewrap-profile.toml"); + fs::write( + &path, + r#"kind = "gamewrap-profile" +version = 1 +name = "old-import" + +[settings] +overlay = true +performance = false +fps_cap = 60 +"#, + ) + .expect("write old profile"); + + let result = env.run(&["profile", "import", path.to_str().expect("utf8 path")]); + assert_ok(&result); + assert!(result.output.contains("renamed settings that were skipped")); + assert!(result.output.contains("overlay")); + assert!(result.output.contains("performance")); +} + +#[test] +fn test_config_migrate_no_op() { + let env = TestEnv::new(); + let config_dir = env.config_home.join("gamewrap"); + let config_file = config_dir.join("config.toml"); + fs::create_dir_all(&config_dir).expect("config dir"); + fs::write( + config_file, + "[defaults]\nmangohud = true\ngamemode = false\n", + ) + .expect("write current config"); + + let result = env.run(&["config", "migrate"]); + assert_ok(&result); + assert!(result.output.contains("no migration needed")); +} + #[test] fn launch_count_and_playtime_tracked() { let env = TestEnv::new(); - assert_ok(&env.run(&["config", "set", "overlay", "off"])); - assert_ok(&env.run(&["config", "set", "performance", "off"])); + assert_ok(&env.run(&["config", "set", "mangohud", "off"])); + assert_ok(&env.run(&["config", "set", "gamemode", "off"])); // Simulate two Steam launches of the same game. for _ in 0..2 { @@ -1180,3 +1759,238 @@ fn launch_count_and_playtime_tracked() { assert!(result.output.contains("Last played: TestGame.exe")); assert!(result.output.contains("Launches: 2")); } + +#[test] +fn hook_errors_setting_round_trip() { + let env = TestEnv::new(); + + // Default: hook-errors is unset (show only shows explicitly configured) + let result = env.run(&["config", "show"]); + assert_ok(&result); + // Not shown by default (no setting configured) + assert!(!result.output.contains("hook-errors")); + + // Set to fail + let result = env.run(&["config", "set", "hook-errors", "fail"]); + assert_ok(&result); + + let result = env.run(&["config", "show"]); + assert_ok(&result); + assert!(result.output.contains("hook-errors")); + assert!(result.output.contains("fail")); + + // Reset + let result = env.run(&["config", "reset", "hook-errors"]); + assert_ok(&result); + + let result = env.run(&["config", "show"]); + assert_ok(&result); + assert!(!result.output.contains("hook-errors")); + + // Invalid value + let result = env.run(&["config", "set", "hook-errors", "crash"]); + assert_exit(&result, 3); + assert!(result.output.contains("not a valid hook-errors value")); +} + +#[test] +fn pre_hook_failure_warns_by_default() { + let env = TestEnv::new(); + let path = env.path_with_fake_bins(&["mangohud", "gamemoderun", "game.exe"]); + + // Configure a pre-hook that exits 1 + let result = env.run(&["config", "set", "pre-launch", "exit 1"]); + assert_ok(&result); + + // Default is warn mode — game should still launch (exit 0 from fake bin) + let result = env.run_with_env( + &["run", &env.path("bin/game.exe").to_string_lossy()], + &[("PATH", &path)], + ); + // The fake game exits 0, so overall success + assert_ok(&result); + // Stderr should mention the hook failure + assert!( + result.output.contains("pre-launch hook exited 1") || result.output.contains("pre-launch") + ); +} + +#[test] +fn pre_hook_failure_aborts_in_fail_mode() { + let env = TestEnv::new(); + let path = env.path_with_fake_bins(&["mangohud", "gamemoderun", "game.exe"]); + + env.run(&["config", "set", "pre-launch", "exit 1"]); + env.run(&["config", "set", "hook-errors", "fail"]); + + let result = env.run_with_env( + &["run", &env.path("bin/game.exe").to_string_lossy()], + &[("PATH", &path)], + ); + // Should fail because pre-hook failed and hook-errors=fail + assert_exit(&result, 1); + assert!(result.output.contains("pre-launch hook exited 1")); +} + +#[test] +fn post_hook_runs_after_game_exits_nonzero() { + let env = TestEnv::new(); + + // Create a game binary that exits with code 42 + let bin_dir = env.home.join("bin"); + std::fs::create_dir_all(&bin_dir).expect("bin dir"); + let game_path = bin_dir.join("failing_game.sh"); + std::fs::write(&game_path, "#!/bin/sh\nexit 42\n").expect("write failing game"); + let mut perms = std::fs::metadata(&game_path).expect("meta").permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&game_path, perms).expect("chmod"); + + // Create a post-hook marker file so we can verify the hook ran + let marker = env.home.join("post_hook_ran"); + let post_hook_cmd = format!("touch {}", marker.display()); + + let fake_path = env.path_with_fake_bins(&["mangohud", "gamemoderun"]); + + env.run(&["config", "set", "mangohud", "off"]); + env.run(&["config", "set", "gamemode", "off"]); + let set_result = env.run(&["config", "set", "post-launch", &post_hook_cmd]); + assert_ok(&set_result); + + let result = env.run_with_env( + &["run", &game_path.to_string_lossy()], + &[("PATH", &fake_path)], + ); + + // Game exited 42, so gamewrap should exit 42 + assert_eq!( + result.status, 42, + "expected game exit code propagated:\n{}", + result.output + ); + // Post-hook must have run + assert!( + marker.exists(), + "post-launch hook did not run after nonzero game exit" + ); +} + +#[test] +fn post_hook_runs_after_successful_game_exit() { + let env = TestEnv::new(); + + let bin_dir = env.home.join("bin"); + std::fs::create_dir_all(&bin_dir).expect("bin dir"); + let game_path = bin_dir.join("ok_game.sh"); + std::fs::write(&game_path, "#!/bin/sh\nexit 0\n").expect("write ok game"); + let mut perms = std::fs::metadata(&game_path).expect("meta").permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&game_path, perms).expect("chmod"); + + let marker = env.home.join("post_hook_ok_ran"); + let post_hook_cmd = format!("touch {}", marker.display()); + + env.run(&["config", "set", "mangohud", "off"]); + env.run(&["config", "set", "gamemode", "off"]); + env.run(&["config", "set", "post-launch", &post_hook_cmd]); + + let result = env.run_with_env(&["run", &game_path.to_string_lossy()], &[]); + assert_ok(&result); + assert!( + marker.exists(), + "post-launch hook did not run after successful game exit" + ); +} + +#[test] +fn post_hook_failure_is_reported() { + let env = TestEnv::new(); + + let bin_dir = env.home.join("bin"); + std::fs::create_dir_all(&bin_dir).expect("bin dir"); + let game_path = bin_dir.join("noop_game.sh"); + std::fs::write(&game_path, "#!/bin/sh\nexit 0\n").expect("write noop game"); + let mut perms = std::fs::metadata(&game_path).expect("meta").permissions(); + perms.set_mode(0o755); + std::fs::set_permissions(&game_path, perms).expect("chmod"); + + env.run(&["config", "set", "mangohud", "off"]); + env.run(&["config", "set", "gamemode", "off"]); + env.run(&["config", "set", "post-launch", "exit 7"]); + + let result = env.run_with_env(&["run", &game_path.to_string_lossy()], &[]); + // Overall should succeed (game exited 0, post-hook failure is reported but not fatal) + assert_ok(&result); + // Should report post-hook failure on stderr (captured in result.output) + assert!( + result.output.contains("post-launch hook exited 7"), + "Expected post-hook failure report in output:\n{}", + result.output + ); +} + +#[test] +fn hook_errors_profile_round_trip() { + let env = TestEnv::new(); + + env.run(&["profile", "create", "myprofile"]); + let result = env.run(&["profile", "set", "myprofile", "hook-errors", "fail"]); + assert_ok(&result); + + let result = env.run(&["profile", "show", "myprofile"]); + assert_ok(&result); + assert!(result.output.contains("hook-errors")); + assert!(result.output.contains("fail")); + + let result = env.run(&["profile", "reset", "myprofile", "hook-errors"]); + assert_ok(&result); + + let result = env.run(&["profile", "show", "myprofile"]); + assert_ok(&result); + // After reset, hook-errors should not appear (no explicit setting) + assert!(!result.output.contains("hook-errors")); +} + +#[test] +fn hook_errors_config_export_import() { + let env = TestEnv::new(); + let export_path = env.path("my-config"); + + env.run(&["config", "set", "hook-errors", "fail"]); + let result = env.run(&["config", "export", &export_path.to_string_lossy()]); + assert_ok(&result); + + // The exported file should contain hook-errors + let suffix_path = env.path("my-config.gamewrap.toml"); + let content = std::fs::read_to_string(&suffix_path).expect("read export"); + assert!( + content.contains("hook-errors"), + "export should contain hook-errors:\n{content}" + ); + assert!(content.contains("fail")); + + // Import into a fresh env + let env2 = TestEnv::new(); + let result = env2.run(&["config", "import", &suffix_path.to_string_lossy()]); + assert_ok(&result); + let result = env2.run(&["config", "show"]); + assert_ok(&result); + assert!(result.output.contains("hook-errors")); + assert!(result.output.contains("fail")); +} + +#[test] +fn dry_run_shows_hook_errors_when_hooks_configured() { + let env = TestEnv::new(); + let path = env.path_with_fake_bins(&["mangohud", "gamemoderun", "game.exe"]); + + env.run(&["config", "set", "pre-launch", "echo hello"]); + env.run(&["config", "set", "hook-errors", "fail"]); + + let result = env.run_with_env( + &["dry-run", &env.path("bin/game.exe").to_string_lossy()], + &[("PATH", &path)], + ); + assert_ok(&result); + assert!(result.output.contains("hook-errors") || result.output.contains("Hook errors")); + assert!(result.output.contains("fail")); +}