From 00802cf9a0ff7a15e0c91323ce9ca0461cfaf2ba Mon Sep 17 00:00:00 2001 From: "Donovan C. Young" Date: Sun, 2 Aug 2026 00:22:37 -0400 Subject: [PATCH 1/2] feat: document merge precedence; display list in profile load order (#201) lmm list showed mods in DB install order (installed_at), which has no relationship to what actually decides merge precedence for a deploy_mode: compile game (Icarus). Switched to core.OrderByProfile - the same seam the TUI's mod list already uses (Overview, service_core.go) - rather than the deploy-only GetInstalledModsInProfileOrder: that helper deliberately OMITS a mod absent from the profile's load order (correct for deploy, where an untracked mod must never silently deploy), which would have made such a mod vanish from a listing instead of just showing up first (lowest priority, since it has no claim to "final say"). Using OrderByProfile gives list.go the exact same order the TUI already shows, so CLI and TUI now genuinely agree - true parity, confirmed with the coordinator - rather than inventing a third, list-only convention. Added a "Merge precedence" paragraph to README and docs/configuration.md, verified against the actual merge implementation (internal/source/icarus/merge.go): later-in-load-order mods win conflicting table-row fields via a per-field upsert (untouched fields from earlier mods survive); bundled assets are whole-file last-wins with a warning (install/update surface it; reorder itself regenerates the pak silently); the bottom of the load order has final say; lmm profile reorder regenerates the merged pak immediately. list's help text now names the load-order behavior explicitly (ran `make man` to regenerate the stale committed man page the genman test caught). --- CHANGELOG.md | 1 + README.md | 2 + cmd/lmm/list.go | 20 ++++++++ cmd/lmm/list_order_test.go | 97 ++++++++++++++++++++++++++++++++++++++ cmd/lmm/list_test.go | 11 +++++ docs/configuration.md | 2 + docs/man/man1/lmm-list.1 | 7 +++ 7 files changed, 140 insertions(+) create mode 100644 cmd/lmm/list_order_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index f95c851f..75b05881 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 0b6d9b1f..cd6d3e5b 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 703d9817..faa28e85 100644 --- a/cmd/lmm/list.go +++ b/cmd/lmm/list.go @@ -48,6 +48,12 @@ 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 rows. +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 +108,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 00000000..a338ee86 --- /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], "Mod B (later in load order) must print before Mod A") + }) + + 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], "Mod B (later in load order) must print before Mod A") + }) + + 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 f7b206e6..196aa36e 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 74748cb6..d5f83696 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 aee9f87f..735d1bb0 100644 --- a/docs/man/man1/lmm-list.1 +++ b/docs/man/man1/lmm-list.1 @@ -12,6 +12,13 @@ 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 rows. +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. From a27cc482aa61f557c97dcd4cdd80261fa12b508f Mon Sep 17 00:00:00 2001 From: "Donovan C. Young" Date: Sun, 2 Aug 2026 00:27:47 -0400 Subject: [PATCH 2/2] fix: correct field-vs-row and load-order wording (#201 review) - cmd/lmm/list.go help text said a later mod "wins conflicting rows" - the actual merge semantics are field-level (a per-field upsert on a shared row, not a whole-row overwrite), matching the README/ configuration.md wording already written for #201. Ran `make man` to regenerate the now-stale lmm-list.1 page. - list_order_test.go's assertion messages labeled Mod B as "later in load order" when the test's own ReorderMods call ([B, A]) actually makes Mod A last (final say) and Mod B first (lowest priority) - a failure would have pointed at the wrong mod. Reworded to state the actual array order and which mod has final say. --- cmd/lmm/list.go | 8 +++++--- cmd/lmm/list_order_test.go | 4 ++-- docs/man/man1/lmm-list.1 | 8 +++++--- 3 files changed, 12 insertions(+), 8 deletions(-) diff --git a/cmd/lmm/list.go b/cmd/lmm/list.go index faa28e85..680d0358 100644 --- a/cmd/lmm/list.go +++ b/cmd/lmm/list.go @@ -50,9 +50,11 @@ var listCmd = &cobra.Command{ 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 rows. -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. +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. diff --git a/cmd/lmm/list_order_test.go b/cmd/lmm/list_order_test.go index a338ee86..13394430 100644 --- a/cmd/lmm/list_order_test.go +++ b/cmd/lmm/list_order_test.go @@ -49,13 +49,13 @@ func TestList_DisplaysProfileLoadOrder_NotInstallOrder(t *testing.T) { 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], "Mod B (later in load order) must print before 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], "Mod B (later in load order) must print before 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) { diff --git a/docs/man/man1/lmm-list.1 b/docs/man/man1/lmm-list.1 index 735d1bb0..f6670fef 100644 --- a/docs/man/man1/lmm-list.1 +++ b/docs/man/man1/lmm-list.1 @@ -15,9 +15,11 @@ 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 rows. -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. +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.