Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

## [Unreleased]

### Added

- `lmm game list` — a table of every configured game (ID, name, install path, mod path, deploy mode, and a compact `source:id` rendering of its sources), marking the default game (see `lmm game show-default`) and pointing at `lmm game add`/`lmm game detect` when nothing is configured yet. Supports `--json` like `list`/`search`/`source list` (#205)

### Changed

- `lmm game detect` now marks a game already present in `games.yaml` as `[configured]` (mirroring `search`'s `[installed]` convention) and excludes it from the default "all" selection, since it needs no re-offering. It stays listed, and naming its number explicitly still selects it — the same re-add/repair path `lmm game add` has always used unconditionally (games.yaml entry + a fresh empty default profile, replacing any existing one) (#205)

## [1.28.0] - 2026-08-02

### Added
Expand Down
131 changes: 66 additions & 65 deletions README.md

Large diffs are not rendered by default.

113 changes: 87 additions & 26 deletions cmd/lmm/game.go
Original file line number Diff line number Diff line change
Expand Up @@ -23,8 +23,9 @@ var gameCmd = &cobra.Command{
lmm, their install/mod paths and configured sources (games.yaml), and
the default game used when --game/-g is omitted.

Use 'lmm game add' to configure a game interactively, or 'lmm game
detect' to find Steam installs automatically.`,
Use 'lmm game add' to configure a game interactively, 'lmm game detect'
to find Steam installs automatically, or 'lmm game list' to see what's
already configured.`,
}

var gameSetDefaultCmd = &cobra.Command{
Expand Down Expand Up @@ -70,10 +71,16 @@ var gameDetectCmd = &cobra.Command{
Short: "Detect Steam games and add them to config",
Long: `Scan Steam libraries for known moddable games and optionally add them to games.yaml.

Prompts for which games to add (e.g. 1,2 or all or none). Each added
game gets a NexusMods source mapping, the symlink link method, and an
empty default profile; edit games.yaml afterwards for anything more
specific, including the NexusMods slug if none was detected.
Prompts for which games to add (e.g. 1,2 or all or none). A game already
configured (present in games.yaml) is marked "[configured]" and is
excluded from the default "all" selection, since it needs no re-offering
- but it stays listed, and you can still name its number explicitly to
re-add/repair it (this replays the same games.yaml + default-profile
overwrite 'lmm game add' always performs, so a repair also resets the
default profile's mod list). Each added game gets a NexusMods source
mapping, the symlink link method, and an empty default profile; edit
games.yaml afterwards for anything more specific, including the
NexusMods slug if none was detected.

Examples:
lmm game detect`,
Expand Down Expand Up @@ -188,48 +195,62 @@ func runGameDetect(cmd *cobra.Command, args []string) error {
for _, w := range warnings {
fmt.Fprintf(os.Stderr, "Warning: %s\n", w)
}
reader := bufio.NewReader(os.Stdin)
return doGameDetect(cmd, reader, svcCfg.ConfigDir, games)
}

// doGameDetect drives the interactive detect-and-select flow against an
// already-detected games list, so it can be tested without a real Steam
// library scan. configDir is used both for the existing-games lookup (to
// mark/exclude already-configured games, #205 item 2) and for saving newly
// selected ones.
func doGameDetect(cmd *cobra.Command, reader *bufio.Reader, configDir string, games []steam.DetectedGame) error {
if len(games) == 0 {
cmd.Println("No moddable Steam games found.")
return nil
}

existingGames, err := config.LoadGames(configDir)
if err != nil {
return fmt.Errorf("loading games: %w", err)
}

cmd.Printf("Found %d moddable game(s):\n", len(games))
for i, g := range games {
cmd.Printf(" %d. %s (%s)\n", i+1, g.Name, g.Slug)
marker := ""
if _, ok := existingGames[g.Slug]; ok {
marker = " " + colorGreen("[configured]")
}
cmd.Printf(" %d. %s (%s)%s\n", i+1, g.Name, g.Slug, marker)
cmd.Printf(" Path: %s\n", g.InstallPath)
}
cmd.Print("Add games to config? [1,2/all/none]: ")
reader := bufio.NewReader(os.Stdin)
line, err := reader.ReadString('\n')
if err != nil {
return fmt.Errorf("reading input: %w", err)
}
line = strings.TrimSpace(strings.ToLower(line))
if line == "" || line == "n" || line == "none" {
cmd.Println("No games added.")
return nil

indices, err := gameDetectSelectionIndices(line, games, existingGames)
if err != nil {
return err
}
var indices []int
if line == "all" || line == "a" {
for i := 1; i <= len(games); i++ {
indices = append(indices, i)
}
} else {
for _, part := range strings.Split(line, ",") {
part = strings.TrimSpace(part)
n, err := strconv.Atoi(part)
if err != nil || n < 1 || n > len(games) {
return fmt.Errorf("invalid selection: %q (use numbers 1-%d, all, or none)", part, len(games))
}
indices = append(indices, n)
if len(indices) == 0 {
if line == "all" || line == "a" {
cmd.Println("All detected games are already configured. No new games added.")
} else {
cmd.Println("No games added.")
}
return nil
}

for _, n := range indices {
g := games[n-1]
game, err := gameFromDetected(g)
if err != nil {
return fmt.Errorf("converting detected game %s: %w", g.Slug, err)
}
if err := config.SaveGame(svcCfg.ConfigDir, game); err != nil {
if err := config.SaveGame(configDir, game); err != nil {
return fmt.Errorf("saving game %s: %w", g.Slug, err)
}
// No LinkMethod: a detected game's default profile should inherit the
Expand All @@ -240,14 +261,54 @@ func runGameDetect(cmd *cobra.Command, args []string) error {
Mods: nil,
IsDefault: true,
}
if err := config.SaveProfile(svcCfg.ConfigDir, defaultProfile); err != nil {
if err := config.SaveProfile(configDir, defaultProfile); err != nil {
return fmt.Errorf("creating default profile for %s: %w", g.Slug, err)
}
cmd.Printf("Added: %s (%s)\n", g.Name, g.Slug)
}
return nil
}

// gameDetectSelectionIndices parses the detect prompt's answer into the
// 1-based indices into games to add/repair.
//
// "all"/"a" defaults to every NOT-yet-configured game (#205 item 2): a game
// already in games.yaml doesn't need re-offering by default, since silently
// re-selecting it would replay doGameDetect's unconditional games.yaml +
// default-profile overwrite against a game the user already set up -
// possibly wiping its default profile's installed-mod list for no reason
// the user asked for. An explicit numeric selection (e.g. "2,5") is NOT
// filtered: naming an already-configured game's number is how a user
// deliberately repairs/re-adds it, mirroring the same overwrite 'lmm game
// add' has always performed unconditionally (it has no existing-ID guard
// either) - #205 asks only for visibility into what's already configured,
// not a merge-preserving repair.
func gameDetectSelectionIndices(line string, games []steam.DetectedGame, existingGames map[string]*domain.Game) ([]int, error) {
line = strings.TrimSpace(strings.ToLower(line))
if line == "" || line == "n" || line == "none" {
return nil, nil
}
var indices []int
if line == "all" || line == "a" {
for i, g := range games {
if _, ok := existingGames[g.Slug]; ok {
continue
}
indices = append(indices, i+1)
}
return indices, nil
}
for _, part := range strings.Split(line, ",") {
part = strings.TrimSpace(part)
n, err := strconv.Atoi(part)
if err != nil || n < 1 || n > len(games) {
return nil, fmt.Errorf("invalid selection: %q (use numbers 1-%d, all, or none)", part, len(games))
}
indices = append(indices, n)
}
return indices, nil
}

// gameFromDetected converts one steam.DetectedGame into the domain.Game
// runGameDetect saves. g.Sources, when the known-games entry supplied one
// (#177: games with a non-NexusMods or multi-source setup, e.g. Icarus),
Expand Down
179 changes: 179 additions & 0 deletions cmd/lmm/game_detect_selection_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,179 @@
package main

import (
"bufio"
"strings"
"testing"

"github.com/DonovanMods/linux-mod-manager/internal/domain"
"github.com/DonovanMods/linux-mod-manager/internal/source/steam"
"github.com/DonovanMods/linux-mod-manager/internal/storage/config"
"github.com/spf13/cobra"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

func detectedGamesFixture() []steam.DetectedGame {
return []steam.DetectedGame{
{Slug: "skyrim-se", Name: "Skyrim Special Edition", InstallPath: "/games/skyrim", NexusID: "skyrimspecialedition"},
{Slug: "starrupture", Name: "Star Rupture", InstallPath: "/games/starrupture", NexusID: "starrupture"},
{Slug: "icarus", Name: "Icarus", InstallPath: "/games/icarus", Sources: map[string]string{"icarus": "icarus"}},
}
}

// TestGameDetectSelectionIndices_AllExcludesConfigured pins #205 item 2:
// "all" defaults to only the NOT-yet-configured games.
func TestGameDetectSelectionIndices_AllExcludesConfigured(t *testing.T) {
games := detectedGamesFixture()
existing := map[string]*domain.Game{"skyrim-se": {ID: "skyrim-se"}}

indices, err := gameDetectSelectionIndices("all", games, existing)
require.NoError(t, err)
assert.Equal(t, []int{2, 3}, indices)
}

// TestGameDetectSelectionIndices_AllWhenNoneConfigured is the pre-#205
// baseline: nothing configured yet, "all" still selects everything.
func TestGameDetectSelectionIndices_AllWhenNoneConfigured(t *testing.T) {
games := detectedGamesFixture()

indices, err := gameDetectSelectionIndices("all", games, map[string]*domain.Game{})
require.NoError(t, err)
assert.Equal(t, []int{1, 2, 3}, indices)
}

// TestGameDetectSelectionIndices_AllWhenEveryGameConfigured pins the empty
// result when every detected game is already configured.
func TestGameDetectSelectionIndices_AllWhenEveryGameConfigured(t *testing.T) {
games := detectedGamesFixture()
existing := map[string]*domain.Game{
"skyrim-se": {ID: "skyrim-se"},
"starrupture": {ID: "starrupture"},
"icarus": {ID: "icarus"},
}

indices, err := gameDetectSelectionIndices("all", games, existing)
require.NoError(t, err)
assert.Empty(t, indices)
}

// TestGameDetectSelectionIndices_ExplicitSelectionIncludesConfigured pins
// the repair path (#205 item 2): explicitly naming an already-configured
// game's number still selects it - this is how a user deliberately
// re-adds/repairs a game, mirroring 'lmm game add's unconditional overwrite.
func TestGameDetectSelectionIndices_ExplicitSelectionIncludesConfigured(t *testing.T) {
games := detectedGamesFixture()
existing := map[string]*domain.Game{"skyrim-se": {ID: "skyrim-se"}}

indices, err := gameDetectSelectionIndices("1,2", games, existing)
require.NoError(t, err)
assert.Equal(t, []int{1, 2}, indices)
}

func TestGameDetectSelectionIndices_NoneAndEmpty(t *testing.T) {
games := detectedGamesFixture()
for _, in := range []string{"", "n", "none", "NONE"} {
indices, err := gameDetectSelectionIndices(in, games, nil)
require.NoError(t, err)
assert.Empty(t, indices, "input %q", in)
}
}

func TestGameDetectSelectionIndices_InvalidSelection(t *testing.T) {
games := detectedGamesFixture()
_, err := gameDetectSelectionIndices("99", games, nil)
require.Error(t, err)
assert.Contains(t, err.Error(), "invalid selection")
}

// TestDoGameDetect_MarksConfiguredGamesAndExcludesFromAll drives the full
// interactive flow with a stubbed detected-games list (no real Steam scan)
// against a configDir that already has skyrim-se configured: the printed
// list marks it [configured], and answering "all" adds only starrupture.
func TestDoGameDetect_MarksConfiguredGamesAndExcludesFromAll(t *testing.T) {
configDir = t.TempDir()
require.NoError(t, config.SaveGame(configDir, &domain.Game{ID: "skyrim-se", Name: "Skyrim Special Edition"}))

games := []steam.DetectedGame{
{Slug: "skyrim-se", Name: "Skyrim Special Edition", InstallPath: "/games/skyrim", NexusID: "skyrimspecialedition"},
{Slug: "starrupture", Name: "Star Rupture", InstallPath: "/games/starrupture", NexusID: "starrupture"},
}

var buf strings.Builder
cmd := &cobra.Command{}
cmd.SetOut(&buf)
reader := bufio.NewReader(strings.NewReader("all\n"))

err := doGameDetect(cmd, reader, configDir, games)
require.NoError(t, err)

out := buf.String()
assert.Contains(t, lineContaining(out, "skyrim-se"), "[configured]")
assert.NotContains(t, lineContaining(out, "starrupture"), "[configured]")
assert.Contains(t, out, "Added: Star Rupture (starrupture)")
assert.NotContains(t, out, "Added: Skyrim Special Edition")

saved, err := config.LoadGames(configDir)
require.NoError(t, err)
_, ok := saved["starrupture"]
assert.True(t, ok)
}

// TestDoGameDetect_ExplicitSelectionRepairsConfiguredGame pins the repair
// path end to end: naming an already-configured game's number re-saves it.
func TestDoGameDetect_ExplicitSelectionRepairsConfiguredGame(t *testing.T) {
configDir = t.TempDir()
require.NoError(t, config.SaveGame(configDir, &domain.Game{ID: "skyrim-se", Name: "Stale Name"}))

games := []steam.DetectedGame{
{Slug: "skyrim-se", Name: "Skyrim Special Edition", InstallPath: "/games/skyrim", NexusID: "skyrimspecialedition"},
}

var buf strings.Builder
cmd := &cobra.Command{}
cmd.SetOut(&buf)
reader := bufio.NewReader(strings.NewReader("1\n"))

err := doGameDetect(cmd, reader, configDir, games)
require.NoError(t, err)
assert.Contains(t, buf.String(), "Added: Skyrim Special Edition (skyrim-se)")

saved, err := config.LoadGames(configDir)
require.NoError(t, err)
require.Contains(t, saved, "skyrim-se")
assert.Equal(t, "Skyrim Special Edition", saved["skyrim-se"].Name)
}

// TestDoGameDetect_AllExcludedPrintsFriendlyMessage guards the fully-
// configured case: "all" selects nothing, and the message says so instead
// of the generic "No games added." (which reads as if the user declined).
func TestDoGameDetect_AllExcludedPrintsFriendlyMessage(t *testing.T) {
configDir = t.TempDir()
require.NoError(t, config.SaveGame(configDir, &domain.Game{ID: "skyrim-se", Name: "Skyrim Special Edition"}))

games := []steam.DetectedGame{
{Slug: "skyrim-se", Name: "Skyrim Special Edition", InstallPath: "/games/skyrim", NexusID: "skyrimspecialedition"},
}

var buf strings.Builder
cmd := &cobra.Command{}
cmd.SetOut(&buf)
reader := bufio.NewReader(strings.NewReader("all\n"))

err := doGameDetect(cmd, reader, configDir, games)
require.NoError(t, err)
assert.Contains(t, buf.String(), "already configured")
}

func TestDoGameDetect_NoGamesFound(t *testing.T) {
configDir = t.TempDir()

var buf strings.Builder
cmd := &cobra.Command{}
cmd.SetOut(&buf)
reader := bufio.NewReader(strings.NewReader(""))

err := doGameDetect(cmd, reader, configDir, nil)
require.NoError(t, err)
assert.Contains(t, buf.String(), "No moddable Steam games found.")
}
Loading
Loading