diff --git a/CHANGELOG.md b/CHANGELOG.md index f95c851..75b0588 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/README.md b/README.md index 0b6d9b1..cd6d3e5 100644 --- a/README.md +++ b/README.md @@ -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: diff --git a/cmd/lmm/list.go b/cmd/lmm/list.go index 703d981..680d035 100644 --- a/cmd/lmm/list.go +++ b/cmd/lmm/list.go @@ -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: @@ -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 { diff --git a/cmd/lmm/list_order_test.go b/cmd/lmm/list_order_test.go new file mode 100644 index 0000000..1339443 --- /dev/null +++ b/cmd/lmm/list_order_test.go @@ -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") +} diff --git a/cmd/lmm/list_test.go b/cmd/lmm/list_test.go index f7b206e..196aa36 100644 --- a/cmd/lmm/list_test.go +++ b/cmd/lmm/list_test.go @@ -2,6 +2,7 @@ package main import ( "bytes" + "strings" "testing" "github.com/spf13/cobra" @@ -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) diff --git a/docs/configuration.md b/docs/configuration.md index 74748cb..d5f8369 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -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 diff --git a/docs/man/man1/lmm-list.1 b/docs/man/man1/lmm-list.1 index aee9f87..f6670fe 100644 --- a/docs/man/man1/lmm-list.1 +++ b/docs/man/man1/lmm-list.1 @@ -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.