diff --git a/tests/cli_matrix.rs b/tests/cli_matrix.rs index dbadf5b..1583be7 100644 --- a/tests/cli_matrix.rs +++ b/tests/cli_matrix.rs @@ -1994,3 +1994,1046 @@ fn dry_run_shows_hook_errors_when_hooks_configured() { assert!(result.output.contains("hook-errors") || result.output.contains("Hook errors")); assert!(result.output.contains("fail")); } + +#[test] +fn help_topics_all_valid_and_invalid() { + let env = TestEnv::new(); + + // Valid help topics + for topic in &[ + "settings", + "profiles", + "bindings", + "doctor", + "completion", + "troubleshooting", + ] { + let r = env.run(&["help", topic]); + assert_ok(&r); + assert!(!r.output.is_empty(), "help {topic} gave empty output"); + } + + // help settings with filter + let r = env.run(&["help", "settings", "mangohud"]); + assert_ok(&r); + assert!( + r.output.contains("mangohud"), + "help settings mangohud should mention mangohud" + ); + + let r = env.run(&["help", "settings", "gamescope"]); + assert_ok(&r); + assert!(r.output.contains("gamescope")); + + // help settings bad group -> non-zero with message + let r = env.run(&["help", "settings", "badgroup"]); + assert!(r.status != 0, "help settings badgroup should fail"); + assert!( + r.output.contains("badgroup") + || r.output.to_lowercase().contains("unknown") + || r.output.to_lowercase().contains("not a known") + ); + + // help bad topic -> non-zero with message + let r = env.run(&["help", "completely-unknown-topic"]); + assert!(r.status != 0, "help on unknown topic should fail"); + assert!( + r.output.contains("completely-unknown-topic") + || r.output.to_lowercase().contains("not a known") + ); +} + +#[test] +fn version_and_bare_help() { + let env = TestEnv::new(); + + let r = env.run(&["--version"]); + assert_ok(&r); + assert!( + !r.output.trim().is_empty(), + "--version should print something" + ); + + let r = env.run(&["--help"]); + assert_ok(&r); + assert!( + r.output.contains("gamewrap"), + "--help should mention gamewrap" + ); +} + +#[test] +fn status_runs_cleanly() { + let env = TestEnv::new(); + let path = env.path_with_fake_bins(&["mangohud", "gamemoderun", "gamescope"]); + + let r = env.run_with_env(&["status"], &[("PATH", &path)]); + assert_ok(&r); + + // status with no deps: still exits 0 (informational command) + let empty_path = env.path_isolated_fake_bins(&[]); + let r = env.run_with_env(&["status"], &[("PATH", &empty_path)]); + assert_ok(&r); +} + +#[test] +fn all_toggle_settings_reject_invalid_values() { + let env = TestEnv::new(); + + let toggle_settings = &[ + "mangohud", + "gamemode", + "steam-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", + ]; + + let bad_values = &["maybe", "yes", "1", "ON", ""]; + + for setting in toggle_settings { + for bad in bad_values { + let r = env.run(&["config", "set", setting, bad]); + assert_eq!( + r.status, 3, + "toggle `{setting}` bad value `{bad}`:\n{}", + r.output + ); + // Verify error output mentions the bad value or the setting name + assert!( + r.output.contains(bad) + || r.output.contains(setting) + || r.output.to_lowercase().contains("valid"), + "toggle setting `{setting}` with value `{bad}` gave unhelpful error: {}", + r.output + ); + } + } +} + +#[test] +fn enum_settings_reject_invalid_values() { + let env = TestEnv::new(); + + let cases = &[ + ("game-libs", "badvalue"), + ("gamescope-scaler", "badvalue"), + ("gamescope-filter", "badvalue"), + ("gamescope-mode", "badvalue"), + ("vkbasalt-log-level", "badvalue"), + ("hook-errors", "badvalue"), + ]; + + for (setting, value) in cases { + let r = env.run(&["config", "set", setting, value]); + assert_eq!( + r.status, 3, + "enum `{setting}` with `{value}` should exit 3:\n{}", + r.output + ); + } +} + +#[test] +fn numeric_settings_reject_bad_values() { + let env = TestEnv::new(); + + let cases: &[(&str, &str)] = &[ + ("gamescope-fps", "4294967296"), + ("gamescope-fps", "abc"), + ("gamescope-fps", "1.5"), + ("gamescope-unfocused-fps", "4294967296"), + ("gamescope-nested-width", "4294967296"), + ("gamescope-nested-height", "4294967296"), + ("fps-cap", "4294967296"), + ("fps-cap", "abc"), + ("gamescope-sharpness", "21"), + ("gamescope-sharpness", "4294967296"), + ("gamescope-sharpness", "abc"), + ("gamescope-width", "abc"), + ("gamescope-height", "abc"), + ]; + + for (setting, value) in cases { + let r = env.run(&["config", "set", setting, value]); + assert_eq!( + r.status, 3, + "numeric `{setting}` with `{value}` should exit 3:\n{}", + r.output + ); + } +} + +#[test] +fn numeric_settings_boundary_valid_values() { + let env = TestEnv::new(); + + let valid_cases: &[(&str, &str)] = &[ + ("gamescope-sharpness", "0"), + ("gamescope-sharpness", "20"), + ("gamescope-fps", "1"), + ("gamescope-width", "native"), + ("gamescope-width", "1920"), + ("gamescope-height", "1080"), + ("gamescope-height", "native"), + ("gamescope-nested-width", "1"), + ("gamescope-nested-height", "1"), + ("gamescope-unfocused-fps", "1"), + ("fps-cap", "1"), + ]; + + for (setting, value) in valid_cases { + let r = env.run(&["config", "set", setting, value]); + assert_ok(&r); + // reset afterwards so settings don't bleed across sub-cases + env.run(&["config", "reset", setting]); + } +} + +#[test] +fn unknown_setting_name_rejected() { + let env = TestEnv::new(); + + let bad_names = &[ + "nonexistent-setting", + "MangoHud", // wrong case + "mangohud-extra", // plausible but wrong + "GAMEMODE", // all-caps + "hook_errors", // underscore instead of dash + ]; + + for name in bad_names { + let r = env.run(&["config", "set", name, "on"]); + assert_eq!( + r.status, 3, + "unknown setting `{name}` should exit 3:\n{}", + r.output + ); + assert!( + r.output.contains(name) + || r.output.to_lowercase().contains("unknown") + || r.output.to_lowercase().contains("not a known"), + "error for `{name}` should mention the name or say 'unknown':\n{}", + r.output + ); + } +} + +#[test] +fn setting_aliases_work() { + let env = TestEnv::new(); + + // host-libs alias for steam-host-libs + assert_ok(&env.run(&["config", "set", "host-libs", "on"])); + let r = env.run(&["config", "show"]); + assert_ok(&r); + assert!( + r.output.contains("steam-host-libs"), + "alias host-libs should set steam-host-libs:\n{}", + r.output + ); + assert_ok(&env.run(&["config", "reset", "host-libs"])); + let r = env.run(&["config", "show"]); + assert!( + !r.output.contains("steam-host-libs"), + "after reset via alias, steam-host-libs should be gone:\n{}", + r.output + ); + + // laa alias for large-address-aware + assert_ok(&env.run(&["config", "set", "laa", "on"])); + let r = env.run(&["config", "show"]); + assert!( + r.output.contains("large-address-aware"), + "alias laa should set large-address-aware:\n{}", + r.output + ); + assert_ok(&env.run(&["config", "reset", "laa"])); + let r = env.run(&["config", "show"]); + assert!( + !r.output.contains("large-address-aware"), + "after reset via laa alias, large-address-aware should be gone:\n{}", + r.output + ); +} + +#[test] +fn config_reset_edge_cases() { + let env = TestEnv::new(); + + // config reset with no setting and no --all -> exit 2 + let r = env.run(&["config", "reset"]); + assert_eq!( + r.status, 2, + "config reset with no args should exit 2:\n{}", + r.output + ); + + // config reset --all with a setting name -> error (conflicts) + let r = env.run(&["config", "reset", "--all", "mangohud"]); + assert!( + r.status != 0, + "config reset --all with setting name should fail:\n{}", + r.output + ); + + // config reset unknown setting -> exit 3 + let r = env.run(&["config", "reset", "nonexistent-setting"]); + assert_eq!( + r.status, 3, + "config reset nonexistent setting should exit 3:\n{}", + r.output + ); +} + +#[test] +fn config_export_stdout_and_file() { + let env = TestEnv::new(); + + assert_ok(&env.run(&["config", "set", "mangohud", "off"])); + + // Export to stdout + let stdout_path = env.path("stdout.gamewrap.toml"); + std::os::unix::fs::symlink("/dev/stdout", &stdout_path).unwrap(); + let r = env.run(&["config", "export", stdout_path.to_str().unwrap()]); + assert_ok(&r); + assert!( + r.stdout.contains("mangohud"), + "export to stdout should contain set field:\n{}", + r.stdout + ); + // Should be sparse - only the set field + assert!( + !r.stdout.contains("gamemode"), + "export should be sparse, not contain unset gamemode:\n{}", + r.stdout + ); + + // Export to file + let out_path = env.path("out"); + let r = env.run(&["config", "export", out_path.to_str().unwrap()]); + assert_ok(&r); + let file_path = out_path.with_extension("gamewrap.toml"); + assert!(file_path.exists(), "exported file should exist"); + let content = std::fs::read_to_string(&file_path).unwrap(); + assert!( + content.contains("mangohud"), + "exported file should contain the set field" + ); + assert!( + !content.contains("gamemode"), + "exported file should be sparse" + ); +} + +#[test] +fn config_import_error_cases() { + let env = TestEnv::new(); + + // Import nonexistent file -> non-zero (likely exit 1) + let r = env.run(&["config", "import", "/nonexistent/path/config.gamewrap.toml"]); + assert!( + r.status != 0, + "import nonexistent file should fail:\n{}", + r.output + ); + + // Import invalid TOML -> exit 3 + let bad_toml = env.path("bad.gamewrap.toml"); + std::fs::write(&bad_toml, "not valid toml [[[").unwrap(); + let r = env.run(&["config", "import", bad_toml.to_str().unwrap()]); + assert_eq!( + r.status, 3, + "import invalid TOML should exit 3:\n{}", + r.output + ); + + // Import config whose bindings reference a missing profile -> exit 3 + let bad_config = env.path("bad-binding.gamewrap.toml"); + std::fs::write( + &bad_config, + r#" +[defaults] +mangohud = true + +[[bindings]] +matcher = "game.exe" +profile = "nonexistent-profile" +"#, + ) + .unwrap(); + let r = env.run(&["config", "import", bad_config.to_str().unwrap()]); + assert_eq!( + r.status, 3, + "import config with missing-profile binding should exit 3:\n{}", + r.output + ); +} + +#[test] +fn profile_reserved_name_rejected() { + let env = TestEnv::new(); + + // profile create default -> exit 3 + let r = env.run(&["profile", "create", "default"]); + assert_eq!( + r.status, 3, + "profile create default should exit 3:\n{}", + r.output + ); + assert!( + r.output.to_lowercase().contains("reserved") || r.output.to_lowercase().contains("default"), + "error should mention reserved:\n{}", + r.output + ); + + // profile export default -> exit 3 + let r = env.run(&["profile", "export", "default"]); + assert_eq!( + r.status, 3, + "profile export default should exit 3:\n{}", + r.output + ); + + // profile import a file where profile name field is "default" -> exit 3 + let default_profile = env.path("default.gamewrap-profile.toml"); + std::fs::write( + &default_profile, + r#" +kind = "profile" +name = "default" +version = 1 + +[settings] +mangohud = false +"#, + ) + .unwrap(); + let r = env.run(&["profile", "import", default_profile.to_str().unwrap()]); + assert_eq!( + r.status, 3, + "importing a profile named 'default' should exit 3:\n{}", + r.output + ); +} + +#[test] +fn profile_duplicate_edge_cases() { + let env = TestEnv::new(); + + // Duplicate nonexistent source -> exit 3 + let r = env.run(&["profile", "duplicate", "nonexistent", "dest"]); + assert_eq!( + r.status, 3, + "duplicate nonexistent source should exit 3:\n{}", + r.output + ); + + // Duplicate to existing destination -> exit 3 + assert_ok(&env.run(&["profile", "create", "src"])); + assert_ok(&env.run(&["profile", "create", "dest"])); + let r = env.run(&["profile", "duplicate", "src", "dest"]); + assert_eq!( + r.status, 3, + "duplicate to existing dest should exit 3:\n{}", + r.output + ); + + // Duplicate to reserved name "default" -> exit 3 + let r = env.run(&["profile", "duplicate", "src", "default"]); + assert_eq!( + r.status, 3, + "duplicate to 'default' should exit 3:\n{}", + r.output + ); + + // Valid duplicate copies settings + assert_ok(&env.run(&["profile", "set", "src", "mangohud", "off"])); + let r = env.run(&["profile", "duplicate", "src", "dst-copy"]); + assert_ok(&r); + let r = env.run(&["profile", "show", "dst-copy"]); + assert_ok(&r); + assert!( + r.output.contains("mangohud"), + "duplicated profile should have copied settings:\n{}", + r.output + ); +} + +#[test] +fn profile_import_error_cases() { + let env = TestEnv::new(); + + // Import nonexistent file -> non-zero + let r = env.run(&["profile", "import", "/no/such/file.gamewrap-profile.toml"]); + assert!( + r.status != 0, + "import nonexistent profile file should fail:\n{}", + r.output + ); + + // Create and export a profile, then create a same-named profile and try importing -> exit 3 + assert_ok(&env.run(&["profile", "create", "dupe-test"])); + let export_path = env.path("dupe-test"); + assert_ok(&env.run(&[ + "profile", + "export", + "dupe-test", + export_path.to_str().unwrap(), + ])); + // profile "dupe-test" still exists; re-importing should fail + let export_file = export_path.with_extension("gamewrap-profile.toml"); + let r = env.run(&["profile", "import", export_file.to_str().unwrap()]); + assert_eq!( + r.status, 3, + "import duplicate profile name should exit 3:\n{}", + r.output + ); + assert!( + r.output.to_lowercase().contains("already exists") + || r.output.to_lowercase().contains("dupe-test"), + "error should mention the name:\n{}", + r.output + ); +} + +#[test] +fn profile_delete_nonexistent() { + let env = TestEnv::new(); + let r = env.run(&["profile", "delete", "no-such-profile"]); + assert_eq!( + r.status, 3, + "delete nonexistent profile should exit 3:\n{}", + r.output + ); +} + +#[test] +fn profile_reset_edge_cases() { + let env = TestEnv::new(); + + // Resetting a setting that was never set is a no-op -> exit 0 + assert_ok(&env.run(&["profile", "create", "p"])); + let r = env.run(&["profile", "reset", "p", "mangohud"]); + assert_ok(&r); + + // Reset in nonexistent profile -> exit 3 + let r = env.run(&["profile", "reset", "nonexistent", "mangohud"]); + assert_eq!( + r.status, 3, + "reset in nonexistent profile should exit 3:\n{}", + r.output + ); +} + +#[test] +fn profile_env_edge_cases() { + let env = TestEnv::new(); + assert_ok(&env.run(&["profile", "create", "p"])); + + // env list when no vars set -> exit 0, informational message + let r = env.run(&["profile", "env", "list", "p"]); + assert_ok(&r); + + // env clear when no vars set -> exit 0 + let r = env.run(&["profile", "env", "clear", "p"]); + assert_ok(&r); + + // env unset a key that was never set -> exit 0 + let r = env.run(&["profile", "env", "unset", "p", "NONEXISTENT_KEY"]); + assert_ok(&r); + + // Set a var, then unset it, then list -> empty again + assert_ok(&env.run(&["profile", "env", "set", "p", "MY_VAR", "hello"])); + let r = env.run(&["profile", "env", "list", "p"]); + assert_ok(&r); + assert!(r.output.contains("MY_VAR")); + assert_ok(&env.run(&["profile", "env", "unset", "p", "MY_VAR"])); + let r = env.run(&["profile", "env", "list", "p"]); + assert_ok(&r); + assert!( + !r.output.contains("MY_VAR"), + "after unset, var should be gone:\n{}", + r.output + ); +} + +#[test] +fn game_command_error_cases() { + let env = TestEnv::new(); + + // game show nonexistent matcher -> exit 3 + let r = env.run(&["game", "show", "nonexistent.exe"]); + assert_eq!( + r.status, 3, + "game show nonexistent should exit 3:\n{}", + r.output + ); + + // game bind to nonexistent profile -> exit 3 + let r = env.run(&["game", "bind", "game.exe", "no-such-profile"]); + assert_eq!( + r.status, 3, + "game bind to nonexistent profile should exit 3:\n{}", + r.output + ); + + // game unbind when no binding exists -> exit 3 + let r = env.run(&["game", "unbind", "unbound.exe"]); + assert_eq!( + r.status, 3, + "game unbind when no binding should exit 3:\n{}", + r.output + ); + + // game forget nonexistent -> exit 3 + let r = env.run(&["game", "forget", "unknown.exe"]); + assert_eq!( + r.status, 3, + "game forget unknown game should exit 3:\n{}", + r.output + ); + + // game rename nonexistent -> exit 3 + let r = env.run(&["game", "rename", "nonexistent.exe", "New Name"]); + assert_eq!( + r.status, 3, + "game rename nonexistent should exit 3:\n{}", + r.output + ); +} + +#[test] +fn game_list_empty() { + let env = TestEnv::new(); + let r = env.run(&["game", "list"]); + assert_ok(&r); + // Should print a "no games" placeholder rather than silently printing nothing + assert!( + !r.output.trim().is_empty(), + "game list on empty state should print a message" + ); +} + +#[test] +fn game_note_multiword() { + let env = TestEnv::new(); + + // Observe a game via a Steam-context launch + let path = env.path_with_fake_bins(&["gamemoderun", "mangohud"]); + env.run_with_env( + &["run", "--", "/bin/echo", "hello"], + &[ + ("PATH", &path), + ("SteamAppId", "999"), + ("SteamGameId", "999"), + ], + ); + + // After the launch, the game is observed as "/bin/echo" + // Use the executable basename as matcher + let r = env.run(&["game", "note", "echo", "word1", "word2", "word3"]); + assert_ok(&r); + + let r = env.run(&["game", "show", "echo"]); + assert_ok(&r); + assert!( + r.output.contains("word1 word2 word3") || r.output.contains("word1"), + "note should contain joined words:\n{}", + r.output + ); +} + +#[test] +fn game_list_full_flag() { + let env = TestEnv::new(); + let path = env.path_with_fake_bins(&["gamemoderun", "mangohud"]); + + // Observe a game first + env.run_with_env( + &["run", "--", "/bin/echo", "hi"], + &[("PATH", &path), ("SteamAppId", "99"), ("SteamGameId", "99")], + ); + + // list without --full: path may be abbreviated + let r_short = env.run_with_env(&["game", "list"], &[("PATH", &path)]); + assert_ok(&r_short); + + // list with --full: path is not abbreviated + let r_full = env.run_with_env(&["game", "list", "--full"], &[("PATH", &path)]); + assert_ok(&r_full); + // The full path /bin/echo should appear somewhere + assert!( + r_full.output.contains("/bin/echo"), + "game list --full should show full path:\n{}", + r_full.output + ); +} + +#[test] +fn dry_run_with_invalid_target() { + let env = TestEnv::new(); + let path = env.path_with_fake_bins(&["mangohud", "gamemoderun"]); + + let r = env.run_with_env( + &["dry-run", "--", "/nonexistent-binary"], + &[("PATH", &path)], + ); + assert_eq!( + r.status, 4, + "dry-run with nonexistent binary should exit 4:\n{}", + r.output + ); + assert!( + r.output.to_lowercase().contains("nonexistent") + || r.output.to_lowercase().contains("does not exist"), + "error should mention the path:\n{}", + r.output + ); +} + +#[test] +fn dry_run_shows_gamescope_command() { + let env = TestEnv::new(); + let path = env.path_with_fake_bins(&["mangohud", "gamemoderun", "gamescope"]); + + assert_ok(&env.run(&["config", "set", "gamescope", "on"])); + assert_ok(&env.run(&["config", "set", "gamescope-width", "1920"])); + assert_ok(&env.run(&["config", "set", "gamescope-fps", "60"])); + assert_ok(&env.run(&["config", "set", "gamescope-filter", "fsr"])); + assert_ok(&env.run(&["config", "set", "mangohud", "off"])); + assert_ok(&env.run(&["config", "set", "gamemode", "off"])); + + let r = env.run_with_env(&["dry-run", "--", "/bin/echo", "test"], &[("PATH", &path)]); + assert_ok(&r); + assert!( + r.output.contains("gamescope"), + "dry-run with gamescope=on should show gamescope in command:\n{}", + r.output + ); + assert!( + r.output.contains("-W") && r.output.contains("1920"), + "dry-run should show -W 1920:\n{}", + r.output + ); + assert!( + r.output.contains("-r") && r.output.contains("60"), + "dry-run should show -r 60:\n{}", + r.output + ); + assert!( + r.output.contains("-F") && r.output.contains("fsr"), + "dry-run should show -F fsr:\n{}", + r.output + ); +} + +#[test] +fn launch_option_like_target_rejected() { + let env = TestEnv::new(); + let path = env.path_with_fake_bins(&["mangohud", "gamemoderun"]); + + // A target starting with '--' should be rejected + let r = env.run_with_env(&["run", "--", "--help"], &[("PATH", &path)]); + assert_eq!( + r.status, 2, + "option-like target should exit 2:\n{}", + r.output + ); +} + +#[test] +fn launch_no_command() { + let env = TestEnv::new(); + + let r = env.run(&["run"]); + // run with no target is a usage error + assert!( + r.status != 0, + "run with no command should fail:\n{}", + r.output + ); +} + +#[test] +fn pre_fail_does_not_trigger_post_hook() { + let env = TestEnv::new(); + let path = env.path_with_fake_bins(&["mangohud", "gamemoderun"]); + + assert_ok(&env.run(&["config", "set", "hook-errors", "fail"])); + assert_ok(&env.run(&["config", "set", "pre-launch", "exit 1"])); + assert_ok(&env.run(&["config", "set", "post-launch", "echo POSTHOOK_RAN"])); + + let r = env.run_with_env(&["run", "--", "/bin/echo", "game"], &[("PATH", &path)]); + assert_eq!( + r.status, 1, + "pre-hook fail mode should exit 1:\n{}", + r.output + ); + assert!( + !r.output.contains("POSTHOOK_RAN"), + "post-hook should NOT run when pre-hook aborts:\n{}", + r.output + ); + assert!( + !r.output.contains("game"), + "game should NOT have run:\n{}", + r.output + ); +} + +#[test] +fn both_hooks_run_on_success() { + let env = TestEnv::new(); + let path = env.path_with_fake_bins(&["mangohud", "gamemoderun"]); + + assert_ok(&env.run(&["config", "set", "mangohud", "off"])); + assert_ok(&env.run(&["config", "set", "gamemode", "off"])); + assert_ok(&env.run(&["config", "set", "pre-launch", "echo PRE_HOOK"])); + assert_ok(&env.run(&["config", "set", "post-launch", "echo POST_HOOK"])); + + let r = env.run_with_env( + &["run", "--", "/bin/echo", "GAME_OUTPUT"], + &[("PATH", &path)], + ); + assert_ok(&r); + assert!( + r.output.contains("PRE_HOOK"), + "pre-hook should have run:\n{}", + r.output + ); + assert!( + r.output.contains("POST_HOOK"), + "post-hook should have run:\n{}", + r.output + ); + assert!( + r.output.contains("GAME_OUTPUT"), + "game should have run:\n{}", + r.output + ); + + // Verify ordering: PRE comes before GAME comes before POST + let pre_pos = r.output.find("PRE_HOOK").unwrap(); + let game_pos = r.output.find("GAME_OUTPUT").unwrap(); + let post_pos = r.output.find("POST_HOOK").unwrap(); + assert!( + pre_pos < game_pos, + "PRE_HOOK should appear before GAME_OUTPUT" + ); + assert!( + game_pos < post_pos, + "GAME_OUTPUT should appear before POST_HOOK" + ); +} + +#[test] +fn post_hook_runs_when_binary_missing() { + let env = TestEnv::new(); + let path = env.path_with_fake_bins(&["mangohud", "gamemoderun"]); + + assert_ok(&env.run(&["config", "set", "post-launch", "echo POSTHOOK_RAN"])); + + let r = env.run_with_env(&["run", "--", "/nonexistent-binary"], &[("PATH", &path)]); + assert_eq!( + r.status, 4, + "nonexistent binary should exit 4:\n{}", + r.output + ); + assert!( + r.output.contains("POSTHOOK_RAN"), + "post-hook should run even when binary is missing:\n{}", + r.output + ); +} + +#[test] +fn missing_dependency_errors() { + let env = TestEnv::new(); + + // mangohud=on but mangohud missing + let no_mangohud = env.path_isolated_fake_bins(&["gamemoderun"]); + assert_ok(&env.run(&["config", "set", "mangohud", "on"])); + assert_ok(&env.run(&["config", "set", "gamemode", "off"])); + let r = env.run_with_env(&["run", "--", "/bin/echo", "hi"], &[("PATH", &no_mangohud)]); + assert_eq!(r.status, 4, "missing mangohud should exit 4:\n{}", r.output); + assert!( + r.output.to_lowercase().contains("mangohud"), + "error should mention mangohud:\n{}", + r.output + ); + assert_ok(&env.run(&["config", "reset", "--all"])); + + // gamemode=on but gamemoderun missing + let env = TestEnv::new(); + let no_gamemode = env.path_isolated_fake_bins(&["mangohud"]); + assert_ok(&env.run(&["config", "set", "gamemode", "on"])); + assert_ok(&env.run(&["config", "set", "mangohud", "off"])); + let r = env.run_with_env(&["run", "--", "/bin/echo", "hi"], &[("PATH", &no_gamemode)]); + assert_eq!( + r.status, 4, + "missing gamemoderun should exit 4:\n{}", + r.output + ); + assert!( + r.output.to_lowercase().contains("gamemoderun") + || r.output.to_lowercase().contains("gamemode"), + "error should mention gamemode:\n{}", + r.output + ); + assert_ok(&env.run(&["config", "reset", "--all"])); + + // gamescope=on but gamescope missing + let env = TestEnv::new(); + let no_gamescope = env.path_isolated_fake_bins(&["mangohud", "gamemoderun"]); + assert_ok(&env.run(&["config", "set", "gamescope", "on"])); + assert_ok(&env.run(&["config", "set", "mangohud", "off"])); + assert_ok(&env.run(&["config", "set", "gamemode", "off"])); + let r = env.run_with_env( + &["run", "--", "/bin/echo", "hi"], + &[("PATH", &no_gamescope)], + ); + assert_eq!( + r.status, 4, + "missing gamescope should exit 4:\n{}", + r.output + ); + assert!( + r.output.to_lowercase().contains("gamescope"), + "error should mention gamescope:\n{}", + r.output + ); + assert_ok(&env.run(&["config", "reset", "--all"])); + + // gamescope-mangoapp=on but mangoapp missing + let env = TestEnv::new(); + let no_mangoapp = env.path_isolated_fake_bins(&["gamescope", "gamemoderun", "mangohud"]); + assert_ok(&env.run(&["config", "set", "gamescope", "on"])); + assert_ok(&env.run(&["config", "set", "gamescope-mangoapp", "on"])); + assert_ok(&env.run(&["config", "set", "mangohud", "off"])); + assert_ok(&env.run(&["config", "set", "gamemode", "off"])); + let r = env.run_with_env(&["run", "--", "/bin/echo", "hi"], &[("PATH", &no_mangoapp)]); + assert_eq!(r.status, 4, "missing mangoapp should exit 4:\n{}", r.output); + assert!( + r.output.to_lowercase().contains("mangoapp"), + "error should mention mangoapp:\n{}", + r.output + ); +} + +#[test] +fn last_with_no_history() { + let env = TestEnv::new(); + let r = env.run(&["last"]); + assert_ok(&r); + // Should print an informational message about no history + assert!( + !r.output.trim().is_empty(), + "last with no history should print a message" + ); +} + +#[test] +fn last_shows_most_recent() { + let env = TestEnv::new(); + let path = env.path_with_fake_bins(&["mangohud", "gamemoderun"]); + let steam_env: &[(&str, &str)] = &[("PATH", &path), ("SteamAppId", "1"), ("SteamGameId", "1")]; + + // Launch first game + env.run_with_env(&["run", "--", "/bin/echo", "first"], steam_env); + // Launch second game (different binary) + env.run_with_env(&["run", "--", "/bin/true"], steam_env); + + let r = env.run(&["last"]); + assert_ok(&r); + // The second game (true) should be shown as last played + assert!( + r.output.contains("true") || r.output.contains("/bin/true") || r.output.contains("true"), + "last should show the most recently launched game:\n{}", + r.output + ); +} + +#[test] +fn settings_list_command() { + let env = TestEnv::new(); + + let r = env.run(&["config", "settings"]); + assert_ok(&r); + // Spot-check a representative set of setting names + for name in &[ + "mangohud", + "gamemode", + "gamescope", + "fps-cap", + "vkbasalt", + "hook-errors", + "pre-launch", + "post-launch", + "log-file", + "esync", + "fsync", + "large-address-aware", + ] { + assert!( + r.output.contains(name), + "config settings should list `{name}`:\n{}", + r.output + ); + } + + // profile settings should produce the same list + let r2 = env.run(&["profile", "settings"]); + assert_ok(&r2); + for name in &["mangohud", "hook-errors", "gamescope"] { + assert!( + r2.output.contains(name), + "profile settings should list `{name}`:\n{}", + r2.output + ); + } +} + +#[test] +fn completion_edge_cases() { + let env = TestEnv::new(); + + // completion with no shell and no subcommand -> exit 2 + let r = env.run(&["completion"]); + assert_eq!( + r.status, 2, + "completion with no args should exit 2:\n{}", + r.output + ); + + // completion bash -> exit 0, produces a script + let r = env.run(&["completion", "bash"]); + assert_ok(&r); + assert!( + !r.stdout.is_empty(), + "completion bash should produce output" + ); + + // completion zsh -> exit 0, produces a script + let r = env.run(&["completion", "zsh"]); + assert_ok(&r); + assert!(!r.stdout.is_empty(), "completion zsh should produce output"); + + // completion fish -> exit 0, produces a script + let r = env.run(&["completion", "fish"]); + assert_ok(&r); + assert!( + !r.stdout.is_empty(), + "completion fish should produce output" + ); +}