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

## [Unreleased]

### Changed

- Internal: all Unreal/Icarus format knowledge (base-pak location, pak
fingerprinting, the `.pak` convert test, the native `.exmodz` test,
merge-source kinds, and the merged/restored artifact filenames) moved
out of `internal/core` and behind the `source.MergeCompiler` interface,
which now states the complete contract a second compile-mode game would
implement (#256). The one user-visible change: rejecting a mixed
pak+exmodz install selection now names the two colliding files
(e.g. `Mod_P.pak and Mod.exmodz`) instead of the generic wording.

### Fixed

- TUI: a mutation that completes with two or more warnings now auto-opens a
Expand Down
1 change: 1 addition & 0 deletions cmd/lmm/install_compile_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ func writeFakeBasePak(t *testing.T, path string) {
// internal/core/service_icarus_compile_test.go's fakeCompilerSource, at the
// CLI layer instead of core's).
type compilerInstallSource struct {
fakeMergeFormat // #256: the format-vocabulary half of source.MergeCompiler
*fakeInstallSource
validateCalls int
compileCalls int
Expand Down
14 changes: 8 additions & 6 deletions cmd/lmm/install_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -381,19 +381,21 @@ func TestPromptMultiSelection_Range(t *testing.T) {
}

// mixedPakExmodzValidate mirrors the shape of the real
// Service.ValidateInstallFileSelection closure (#211): reject a selection
// that mixes an exmodz file with any other file.
// Service.ValidateInstallFileSelection closure (#211, message shape #256):
// reject a selection that mixes an exmodz file with any other file, naming
// the two colliding files the way production does.
func mixedPakExmodzValidate(sel []domain.DownloadableFile) error {
var ex, other bool
var exName, otherName string
for _, f := range sel {
if strings.HasSuffix(strings.ToLower(f.FileName), ".exmodz") {
ex = true
ex, exName = true, f.FileName
} else {
other = true
other, otherName = true, f.FileName
}
}
if ex && other {
return fmt.Errorf("pak and exmodz are alternate forms of the same mod - select one")
return fmt.Errorf("%s and %s are alternate forms of the same mod - select one", otherName, exName)
}
return nil
}
Expand Down Expand Up @@ -430,7 +432,7 @@ func TestSelectInstallFiles_FileFlagMixedRejected(t *testing.T) {
selected, err := selectInstallFilesFrom(strings.NewReader(""), files, mixedPakExmodzValidate)
require.Error(t, err)
assert.Nil(t, selected)
assert.Contains(t, err.Error(), "pak and exmodz are alternate forms of the same mod - select one")
assert.Contains(t, err.Error(), "Mod_P.pak and Mod.exmodz are alternate forms of the same mod - select one")
}

// TestSelectInstallFiles_EOFAfterRejectedSelection guards against a hang:
Expand Down
4 changes: 2 additions & 2 deletions cmd/lmm/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -162,7 +162,7 @@ func doList(cmd *cobra.Command, service *core.Service, game *domain.Game) error
LockedVersion: lockedVersion,
}
// Populate ConvertPaks only for merge-compile games with pak merge source
if game.DeployMode == domain.DeployCompile && service.ModHasPakMergeSource(&mod) {
if game.DeployMode == domain.DeployCompile && service.ModHasPakMergeSource(game, &mod) {
v := mod.ConvertPaks
row.ConvertPaks = &v
}
Expand Down Expand Up @@ -227,7 +227,7 @@ func doList(cmd *cobra.Command, service *core.Service, game *domain.Game) error
locked = lockedRef.Version
}
convert := "-"
if game.DeployMode == domain.DeployCompile && service.ModHasPakMergeSource(&mod) {
if game.DeployMode == domain.DeployCompile && service.ModHasPakMergeSource(game, &mod) {
if mod.ConvertPaks {
convert = "on"
} else {
Expand Down
58 changes: 58 additions & 0 deletions cmd/lmm/merge_format_helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package main

import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/DonovanMods/go-unrealpak"
"github.com/DonovanMods/linux-mod-manager/internal/domain"
)

// fakeMergeFormat supplies source.MergeCompiler's format-vocabulary methods
// (#256) for this package's compile-source fakes, mirroring the icarus
// conventions the fixtures already encode (writeFakeBasePak's
// Icarus/Content/Data/data.pak path, "pak"/"exmodz" fileIDs, the
// zzz_LMM_Merged_P.pak artifact name). Embed it in any fake that needs to
// satisfy source.MergeCompiler. Duplicated per test package by design -
// mirrors internal/core's identical helper.
type fakeMergeFormat struct{}

func (fakeMergeFormat) ResolveBaseArtifact(game *domain.Game) (string, error) {
candidate := filepath.Join(game.InstallPath, "Icarus", "Content", "Data", "data.pak")
if _, err := os.Stat(candidate); err != nil {
return "", fmt.Errorf("locating base pak for %q: %w", game.ID, err)
}
return candidate, nil
}

func (fakeMergeFormat) FingerprintBase(basePakPath string) (string, error) {
r, err := unrealpak.Open(basePakPath)
if err != nil {
return "", fmt.Errorf("reading base pak for compile fingerprint: %w", err)
}
defer r.Close() //nolint:errcheck
return r.IndexHash(), nil
}

func (fakeMergeFormat) IsNativeMergeSource(fileName string) bool {
return strings.HasSuffix(strings.ToLower(fileName), ".exmodz")
}

func (fakeMergeFormat) IsConvertibleArtifact(fileName string) bool {
return strings.HasSuffix(strings.ToLower(fileName), ".pak")
}

func (fakeMergeFormat) ClassifyMergeSource(id string) (string, bool) {
lower := strings.ToLower(id)
if lower == "pak" || strings.HasSuffix(lower, ".pak") {
return "pak", true
}
return "exmodz", false
}

func (fakeMergeFormat) MergedArtifactName() string { return "zzz_LMM_Merged_P.pak" }
func (fakeMergeFormat) MergedArtifactLabel() string { return "Icarus Merged Pak" }

func (fakeMergeFormat) RestoredArtifactName(modID string) string { return modID + "_P.pak" }
2 changes: 1 addition & 1 deletion cmd/lmm/mod.go
Original file line number Diff line number Diff line change
Expand Up @@ -787,7 +787,7 @@ func doModConvert(service *core.Service, game *domain.Game, modID string, conver

// Pak conversion only applies to mods with a pak-kind merge source.
// Exmodz-only mods have no pak to convert or leave raw, so reject the request.
if !service.ModHasPakMergeSource(mod) {
if !service.ModHasPakMergeSource(game, mod) {
return fmt.Errorf("mod %s has no pak merge source: pak conversion does not apply", modID)
}

Expand Down
6 changes: 5 additions & 1 deletion cmd/lmm/mod_convert_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,11 @@ func setupDoModConvertTest(t *testing.T) (*core.Service, *domain.Game, *fakeInst

src := newFakeInstallSource("src")
t.Cleanup(src.Close)
svc.RegisterSource(src)
// #256: convert-flag surfaces (list/mod show/mod convert) classify
// pak-kind fileIDs via the game's MergeCompiler source, so the
// registered "src" must implement it - wrapping the plain fake keeps
// callers stubbing through the returned inner fake (shared pointer).
svc.RegisterSource(&compilerInstallSource{fakeInstallSource: src})

game := &domain.Game{
ID: "g1",
Expand Down
14 changes: 8 additions & 6 deletions internal/core/flows.go
Original file line number Diff line number Diff line change
Expand Up @@ -4157,7 +4157,7 @@ func (s *Service) applyInstallPrimary(ctx context.Context, game *domain.Game, pl
if err != nil {
return nil, fmt.Errorf("resolving source %q: %w", plan.SourceID, err)
}
_, isMergeCompiler := src.(source.MergeCompiler)
mc, isMergeCompiler := src.(source.MergeCompiler)

// compiledFiles accumulates every file this loop actually compiled (game
// DeployCompile + a ".exmodz" file - the same condition
Expand Down Expand Up @@ -4211,15 +4211,17 @@ func (s *Service) applyInstallPrimary(ctx context.Context, game *domain.Game, pl
downloadedFileIDs = append(downloadedFileIDs, file.ID)

// Copilot round 1 (PR #222): compiledFiles collects BOTH kinds -
// .exmodz files and convert-eligible .pak files alike - so the
// .exmodz files and convert-eligible raw files alike - so the
// InstallCompiling/"Retaining ... for merge" messaging below (and
// in cmd/lmm/install.go's InstallCompiling case) fires for a
// convert-eligible raw pak exactly as it does for a native
// .exmodz; len(compiledFiles) > 0 doesn't care which kind matched.
// #221: gate .pak files on MergeCompiler capability, matching the
// ingest path's predicate. .exmodz files are still included because
// they hard-error later if the source lacks MergeCompiler.
if game.DeployMode == domain.DeployCompile && (isExmodzFile(file.FileName) || (isMergeCompiler && isConvertEligiblePakFile(game, file.FileName))) {
// #221: gate raw files on MergeCompiler capability, matching the
// ingest path's predicate. Native merge archives are still included
// when only the GAME's compile source (not this file's own source)
// recognizes them - isNativeMergeFile's fallback - because ingest
// hard-errors on exactly that mismatch later (#256).
if game.DeployMode == domain.DeployCompile && (s.isNativeMergeFile(game, mc, file.FileName) || (isMergeCompiler && isConvertEligibleArtifact(game, mc, file.FileName))) {
compiledFiles = append(compiledFiles, file)
}
}
Expand Down
7 changes: 4 additions & 3 deletions internal/core/flows_variant_exclusivity_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ import (
// because a single mod must offer BOTH a pak and an exmodz file so
// PlanInstall/ApplyInstall can drive a real mixed selection end to end.
type variantExclusivitySource struct {
fakeMergeFormat // #256: the format-vocabulary half of source.MergeCompiler
*mockSourceWithDownloads
files map[string][]domain.DownloadableFile // mod.ID -> served files, verbatim
}
Expand Down Expand Up @@ -141,7 +142,7 @@ func TestApplyInstall_StrictPath_TargetFileIDs_MixedVariants_Rejected(t *testing

opts := core.InstallOptions{TargetFileIDs: []string{"pak", "exmodz"}}
_, err = svc.ApplyInstall(context.Background(), game, plan, opts, nil)
require.ErrorContains(t, err, "pak and exmodz are alternate forms of the same mod - select one")
require.ErrorContains(t, err, "Mod_P.pak and Mod.exmodz are alternate forms of the same mod - select one")

require.Equal(t, 0, mc.DownloadCount(), "no download may happen for a rejected mixed selection")
gameCache := svc.GetGameCache(game)
Expand All @@ -166,7 +167,7 @@ func TestApplyInstall_StrictPath_CallerSuppliedMixedVariantFiles_Rejected(t *tes
plan.Files = mc.files["mod1"]

_, err = svc.ApplyInstall(context.Background(), game, plan, core.InstallOptions{}, nil)
require.ErrorContains(t, err, "pak and exmodz are alternate forms of the same mod - select one")
require.ErrorContains(t, err, "Mod_P.pak and Mod.exmodz are alternate forms of the same mod - select one")

require.Equal(t, 0, mc.DownloadCount(), "no download may happen for a rejected mixed selection")
gameCache := svc.GetGameCache(game)
Expand Down Expand Up @@ -203,7 +204,7 @@ func TestApplyInstall_BatchPath_TargetFileIDs_MixedVariants_Rejected(t *testing.

opts := core.InstallOptions{TargetFileIDs: []string{"pak", "exmodz"}}
_, err = svc.ApplyInstall(context.Background(), game, plan, opts, nil)
require.ErrorContains(t, err, "pak and exmodz are alternate forms of the same mod - select one")
require.ErrorContains(t, err, "Mod_P.pak and Mod.exmodz are alternate forms of the same mod - select one")

require.Equal(t, 0, mc.DownloadCount(), "no download may happen for a rejected mixed selection")
gameCache := svc.GetGameCache(game)
Expand Down
70 changes: 43 additions & 27 deletions internal/core/importer.go
Original file line number Diff line number Diff line change
Expand Up @@ -49,13 +49,14 @@ type Importer struct {
// cache. Empty means fall back to $TMPDIR — see newStagingDir.
stagingRoot string
// resolveMergeCompiler resolves the MergeCompiler-capable source mapped
// to a DeployCompile game's registry entry (#197), consulted only when
// importing a ".exmodz" archive for such a game — Import has no
// per-archive source pinned the way DownloadModToCache does, so it must
// look up the game's configured sources instead. nil when the Importer
// was built via the standalone NewImporter (no Service context):
// importing an .exmodz through such an Importer fails loud rather than
// silently caching an unvalidated archive.
// to a DeployCompile game's registry entry (#197), consulted for every
// import into such a game (#256: the compile source now answers the
// native/convertible format questions too) — Import has no per-archive
// source pinned the way DownloadModToCache does, so it must look up the
// game's configured sources instead. nil when the Importer was built
// via the standalone NewImporter (no Service context): a DeployCompile
// import through such an Importer fails loud rather than silently
// caching an unvalidated archive.
resolveMergeCompiler func(gameID string) (source.MergeCompiler, error)
}

Expand Down Expand Up @@ -118,27 +119,45 @@ func (i *Importer) Import(ctx context.Context, archivePath string, game *domain.
var fileCount int
var retainedFileID string

// mergeEligible (.exmodz) has no other valid interpretation for a
// DeployCompile game - an unresolvable MergeCompiler is a hard error.
// convertEligiblePak (.pak) DOES have one - the legacy extract/copy path
// below - so a resolver failure there falls through instead of erroring
// the whole import (#221 I1 fix, mirrors DownloadModToCache's identical
// fix): resolveMergeCompiler is a GAME-level lookup (Import has no
// per-archive source pinned the way a download does), so "no
// MergeCompiler-capable source configured for this game" is exactly the
// same "fall through for a pak, still hard-error for an exmodz" case as
// the download path's "this specific source lacks MergeCompiler".
mergeEligible := isExmodzFile(filename)
// #256: whether filename is the game's NATIVE merge format
// (mc.IsNativeMergeSource - the seam-routed successor to core's static
// ".exmodz" test) or a convertible artifact (mc.IsConvertibleArtifact)
// is the compile source's call, so the compiler is resolved up front
// for EVERY DeployCompile import, and a resolution failure is a hard
// error for every one of them - not just the native case the old
// static test could single out. Falling through instead would be
// unsafe: with no compiler, core cannot tell a native merge archive
// from anything else, and the legacy extract path CONTENT-SNIFFS zip
// magic (detectFormatFromPath), so a real, zip-backed native archive
// would be silently extracted and cached without ValidateSource -
// exactly the "never silently cache an unvalidated native archive"
// invariant the resolver error protects. This supersedes #221 I1's
// import-side pak fall-through: that was safe only while core itself
// knew which files were native. (The DOWNLOAD path's I1 fall-through
// stands - it pins eligibility to the file's own source, a per-archive
// signal Import does not have.) The resolver error names the fix
// ("map a source implementing source.MergeCompiler"), which is
// accurate for any import into a compile game whose compiler is
// missing or ambiguous.
//
// A NIL RESOLVER fails the same way for the same reason, with its own
// message: core.NewImporter (no Service context) cannot answer the
// format question for ANY file, and there is a correct importer to use
// instead. Production only ever constructs the service-backed importer
// (Service.NewImporter); this path is reachable only by direct
// core.NewImporter use.
var mc source.MergeCompiler
var mcErr error
if game.DeployMode == domain.DeployCompile && (mergeEligible || isConvertEligiblePakFile(game, filename)) {
if game.DeployMode == domain.DeployCompile {
if i.resolveMergeCompiler == nil {
mcErr = fmt.Errorf("game %q requires DeployCompile to import %q, but this Importer was constructed without service context (via core.NewImporter, not Service.NewImporter) and has no compiler resolver to consult - import via the service-backed importer instead", game.ID, filename)
} else {
mc, mcErr = i.resolveMergeCompiler(game.ID)
return nil, fmt.Errorf("game %q requires DeployCompile to import %q, but this Importer was constructed without service context (via core.NewImporter, not Service.NewImporter) and has no compiler resolver to consult - import via the service-backed importer instead", game.ID, filename)
}
var mcErr error
if mc, mcErr = i.resolveMergeCompiler(game.ID); mcErr != nil {
return nil, mcErr
}
}
convertEligiblePak := mcErr == nil && isConvertEligiblePakFile(game, filename)
mergeEligible := mc != nil && mc.IsNativeMergeSource(filename)
convertEligiblePak := mc != nil && isConvertEligibleArtifact(game, mc, filename)

// Handle based on game's deploy mode
if game.DeployMode == domain.DeployCompile && (mergeEligible || convertEligiblePak) {
Expand All @@ -148,9 +167,6 @@ func (i *Importer) Import(ctx context.Context, archivePath string, game *domain.
// keyed by the archive's own filename instead - stable across
// re-imports of the same name, and the ONLY identity Import ever
// has for this content.
if mcErr != nil {
return nil, mcErr
}
if err := mc.ValidateSource(archivePath); err != nil {
return nil, fmt.Errorf("validating %s: %w", filename, err)
}
Expand Down
58 changes: 58 additions & 0 deletions internal/core/merge_format_helpers_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package core_test

import (
"fmt"
"os"
"path/filepath"
"strings"

"github.com/DonovanMods/go-unrealpak"
"github.com/DonovanMods/linux-mod-manager/internal/domain"
)

// fakeMergeFormat supplies source.MergeCompiler's format-vocabulary methods
// (#256) for this package's compile-source fakes, mirroring the icarus
// conventions the fixtures already encode (writeFakeBasePak's
// Icarus/Content/Data/data.pak path, "pak"/"exmodz" fileIDs, the
// zzz_LMM_Merged_P.pak artifact name) without pulling the icarus package
// into internal/core's tests (fakeCompilerSource's own precedent). Embed it
// in any fake that needs to satisfy source.MergeCompiler.
type fakeMergeFormat struct{}

func (fakeMergeFormat) ResolveBaseArtifact(game *domain.Game) (string, error) {
candidate := filepath.Join(game.InstallPath, "Icarus", "Content", "Data", "data.pak")
if _, err := os.Stat(candidate); err != nil {
return "", fmt.Errorf("locating base pak for %q: %w", game.ID, err)
}
return candidate, nil
}

func (fakeMergeFormat) FingerprintBase(basePakPath string) (string, error) {
r, err := unrealpak.Open(basePakPath)
if err != nil {
return "", fmt.Errorf("reading base pak for compile fingerprint: %w", err)
}
defer r.Close() //nolint:errcheck
return r.IndexHash(), nil
}

func (fakeMergeFormat) IsNativeMergeSource(fileName string) bool {
return strings.HasSuffix(strings.ToLower(fileName), ".exmodz")
}

func (fakeMergeFormat) IsConvertibleArtifact(fileName string) bool {
return strings.HasSuffix(strings.ToLower(fileName), ".pak")
}

func (fakeMergeFormat) ClassifyMergeSource(id string) (string, bool) {
lower := strings.ToLower(id)
if lower == "pak" || strings.HasSuffix(lower, ".pak") {
return "pak", true
}
return "exmodz", false
}

func (fakeMergeFormat) MergedArtifactName() string { return "zzz_LMM_Merged_P.pak" }
func (fakeMergeFormat) MergedArtifactLabel() string { return "Icarus Merged Pak" }

func (fakeMergeFormat) RestoredArtifactName(modID string) string { return modID + "_P.pak" }
Loading
Loading