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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Changed

- `lmm list` now displays mods in the profile's load order (the same order `lmm profile reorder` sets and the TUI's mod list already showed) instead of DB install order (`installed_at`) — the visible order is now the order that actually decides merge precedence for a `deploy_mode: compile` game. A mod installed but missing from the load order is still shown, never silently dropped, placed first (lowest priority). README and `docs/configuration.md` gain a "Merge precedence" paragraph explaining that later-in-load-order mods win conflicting table-row _fields_ (a per-field upsert; untouched fields from earlier mods survive) while bundled assets are whole-file last-wins with a warning, and that `lmm profile reorder` regenerates the merged pak immediately (#201)
- An unrecognized, non-empty `link_method` (`games.yaml`, profile files, imported profiles) or `deploy_mode` (`games.yaml`; also `lmm game detect`'s `steam-games.yaml`) is now a load-time error naming the field, the offending value, the owning game/profile, and the valid options — instead of silently falling back to the default (`symlink`/`extract`). **Breaking for configs that were already silently misbehaving:** a typo like `deploy_mode: compil` previously ran as `extract` with no warning; it now refuses to load until fixed. An empty/absent value is unaffected and keeps today's default exactly (#172)

### Fixed
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -433,6 +433,8 @@ games:

Steam auto-detection (`lmm game detect`) knows about Icarus (App ID `1149460`) and generates an equivalent entry for you, `install_path`/`mod_path` filled in from your actual Steam library — the YAML above is kept here as reference for what gets written, not something you need to type by hand.

**Merge precedence**: with more than one `compile`-mode mod installed (currently Icarus only), the profile's load order — the same order `lmm list` displays and `lmm profile reorder` changes — decides how conflicting changes resolve. Mods are merged in load order, so a mod later in the list is applied later and wins conflicting _fields_ on a shared data-table row; it's a per-field upsert, not a whole-row overwrite, so untouched fields from earlier mods still survive. Bundled asset files can't compose that way — a same-path collision between two mods is whole-file last-wins, and installing or updating a colliding mod prints a warning naming both. Either way, the bottom of the load order has final say, and `lmm profile reorder` regenerates the merged pak immediately, so a reorder's effect on precedence is visible right away rather than at the next deploy.

### Deployment Methods

Mods can be deployed using three methods:
Expand Down
22 changes: 22 additions & 0 deletions cmd/lmm/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,14 @@ var listCmd = &cobra.Command{
Short: "List installed mods",
Long: `List all mods installed in the specified game and profile.

Mods are printed in the profile's load order (see 'lmm profile reorder')
- the same order that decides merge precedence for a compiled/merged pak:
a mod later in the load order is merged later and wins conflicting
fields on a shared data-table row (untouched fields from earlier mods
still survive). A mod installed but missing from the load order is
still shown (never silently dropped), placed first since it has no
claim to the final say.

Use --profiles to list profile names for the game instead of mods.

Examples:
Expand Down Expand Up @@ -102,6 +110,20 @@ func doList(cmd *cobra.Command, service *core.Service, game *domain.Game) error
}
}

// #201: display the profile's load order - the order that actually
// decides merge precedence (later = merged later = wins) - not
// installed_at (GetInstalledMods' own DB order), which has no
// relationship to it. core.OrderByProfile, not the deploy-only
// GetInstalledModsInProfileOrder seam: that one deliberately OMITS a
// mod absent from the profile's load order (correct for deploy - an
// untracked mod must never silently deploy), which would make such a
// mod vanish from a listing instead of just being placed first (lowest
// priority, since it has no claim to "final say"). OrderByProfile is
// the same never-omitting seam the TUI's mod list already uses
// (internal/tui/service_core.go's Overview) - reusing it here keeps the
// CLI and TUI in agreement on what "the load order" looks like.
mods = core.OrderByProfile(profileYAML, mods)

if jsonOutput {
out := listJSONOutput{GameID: game.ID, Profile: profileName, Mods: make([]listModJSON, len(mods))}
for i, mod := range mods {
Expand Down
97 changes: 97 additions & 0 deletions cmd/lmm/list_order_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package main

import (
"encoding/json"
"strings"
"testing"

"github.com/DonovanMods/linux-mod-manager/internal/domain"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// modOrder returns each named mod's line index in out, in the order the
// names first appear - used to assert relative ordering without depending
// on exact column widths.
func modOrder(t *testing.T, out string, names ...string) []int {
t.Helper()
lines := strings.Split(out, "\n")
indices := make([]int, len(names))
for i, name := range names {
indices[i] = -1
for lineIdx, l := range lines {
if strings.Contains(l, name) {
indices[i] = lineIdx
break
}
}
require.NotEqual(t, -1, indices[i], "expected to find %q in output:\n%s", name, out)
}
return indices
}

// TestList_DisplaysProfileLoadOrder_NotInstallOrder guards #201: `lmm list`
// used to print mods in DB install order (installed_at), not the profile's
// load order that actually decides merge precedence. Mod A is installed
// before Mod B (install order: A, B) but the profile's load order is then
// reversed to [B, A] - the listing must follow the load order, not
// installed_at.
func TestList_DisplaysProfileLoadOrder_NotInstallOrder(t *testing.T) {
svc, game := setupDoDeployTest(t)
seedDeployableMod(t, svc, game, "a", "Mod A", "a.esp")
seedDeployableMod(t, svc, game, "b", "Mod B", "b.esp")

require.NoError(t, svc.NewProfileManager().ReorderMods(game.ID, "default", []domain.ModReference{
{SourceID: "src", ModID: "b", Version: "1.0"},
{SourceID: "src", ModID: "a", Version: "1.0"},
}))

t.Run("non-verbose", func(t *testing.T) {
out := listNonVerbose(t, svc, game)
idx := modOrder(t, out, "Mod B", "Mod A")
assert.Less(t, idx[0], idx[1], "profile load order is [Mod B, Mod A] (Mod A is last - final say); the listing must follow that array order, not install order (which was A then B)")
})

t.Run("verbose", func(t *testing.T) {
out := listVerbose(t, svc, game, false)
idx := modOrder(t, out, "Mod B", "Mod A")
assert.Less(t, idx[0], idx[1], "profile load order is [Mod B, Mod A] (Mod A is last - final say); the listing must follow that array order, not install order (which was A then B)")
})

t.Run("json", func(t *testing.T) {
raw := listVerbose(t, svc, game, true)
var out listJSONOutput
require.NoError(t, json.Unmarshal([]byte(raw), &out))
require.Len(t, out.Mods, 2)
assert.Equal(t, "b", out.Mods[0].ID)
assert.Equal(t, "a", out.Mods[1].ID)
})
}

// TestList_ModMissingFromLoadOrder_StillShown guards the never-omit
// requirement (#201): GetInstalledModsInProfileOrder (deploy's seam)
// deliberately OMITS a mod absent from the profile's load order - correct
// for deploy, since an untracked mod must never silently deploy, but wrong
// for a listing, where every installed mod must still be visible. list.go
// uses core.OrderByProfile instead (the same seam the TUI's mod list
// already uses - internal/tui/service_core.go's Overview), which never
// omits: a load-order-absent mod is placed first (lowest priority - it has
// no claim to "final say"), never dropped.
func TestList_ModMissingFromLoadOrder_StillShown(t *testing.T) {
svc, game := setupDoDeployTest(t)
seedDeployableMod(t, svc, game, "a", "Tracked Mod", "a.esp")

// Install "b" without ever adding it to the profile's load order -
// simulates the edge case a normal add/remove flow shouldn't produce,
// but which must not make the mod vanish from `lmm list`.
require.NoError(t, svc.SaveInstalledMod(&domain.InstalledMod{
Mod: domain.Mod{ID: "b", SourceID: "src", Name: "Untracked Mod", Version: "1.0", GameID: game.ID},
ProfileName: "default",
UpdatePolicy: domain.UpdateNotify,
Enabled: true,
}))

out := listNonVerbose(t, svc, game)
assert.Contains(t, out, "Untracked Mod", "a mod absent from the profile's load order must still be listed")
assert.Contains(t, out, "2 mod(s)", "both the tracked and untracked mod must count toward the total")
}
11 changes: 11 additions & 0 deletions cmd/lmm/list_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"bytes"
"strings"
"testing"

"github.com/spf13/cobra"
Expand Down Expand Up @@ -35,6 +36,16 @@ func TestListCmd_Structure(t *testing.T) {
assert.NotNil(t, listCmd.Flags().Lookup("profiles"))
}

// TestListCmd_DocMentionsLoadOrder guards #201: the help text must describe
// the mod ordering it actually shows (the profile's load order, which
// decides merge precedence) rather than staying silent about it or, worse,
// claiming the old install order.
func TestListCmd_DocMentionsLoadOrder(t *testing.T) {
assert.Contains(t, listCmd.Long, "load order")
assert.NotContains(t, strings.ToLower(listCmd.Long), "install order",
"list must not claim install order - it shows profile load order")
}

func TestStatusCmd_Structure(t *testing.T) {
assert.Equal(t, "status", statusCmd.Use)
assert.NotEmpty(t, statusCmd.Short)
Expand Down
2 changes: 2 additions & 0 deletions docs/configuration.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,6 +61,8 @@ The `deploy_mode` option controls how downloaded mod archives are handled:
- **`copy`**: Archives are copied as-is to the mod path without extraction. Use for games that expect mod files to remain as archives (e.g., Minecraft `.jar` files, some Unity games).
- **`compile`**: The downloaded file is compiled into a new artifact before caching (currently Icarus only: an `.exmodz` diff is applied to the game's base data tables to produce a deployable `_P.pak`). Only sources that implement compiling support this mode. The base data tables are read directly from the installed game's own `data.pak`, so a compile always matches the installed game version and needs no network access.

**Merge precedence**: with more than one `compile`-mode mod installed, the merge applies each mod's changes in the profile's load order (the `mods` list's order - see [Profile files](#profile-files) below, and the same order `lmm list` displays) against the same evolving base tables, so a mod later in the load order is applied later. Table-row conflicts compose at the _field_ level: an upsert, not a whole-row overwrite, so two mods patching different fields of the same row - or different rows entirely - both survive; only a genuine same-row-same-field write is last-wins, which is an expected outcome of ordinary upserts, not something that gets a warning. Bundled asset files can't compose that way - a same-path asset collision between two mods is necessarily whole-file last-wins, and is reported as a warning (installing or updating a colliding mod prints it). Either way, the mod at the bottom of the load order has final say, and reordering the profile (`lmm profile reorder`) regenerates the merged pak immediately, so the new precedence takes effect right away.

Example:

```yaml
Expand Down
9 changes: 9 additions & 0 deletions docs/man/man1/lmm-list.1
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,15 @@ lmm-list - List installed mods
.SH DESCRIPTION
List all mods installed in the specified game and profile.

.PP
Mods are printed in the profile's load order (see 'lmm profile reorder')
- the same order that decides merge precedence for a compiled/merged pak:
a mod later in the load order is merged later and wins conflicting
fields on a shared data-table row (untouched fields from earlier mods
still survive). A mod installed but missing from the load order is
still shown (never silently dropped), placed first since it has no
claim to the final say.

.PP
Use --profiles to list profile names for the game instead of mods.

Expand Down
Loading