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 @@ -20,6 +20,17 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0

### Fixed

- Deploy output on a compile-mode game (Icarus) no longer presents merged
mods as individual deployments (#255). The header drops the misleading
`using <method>` claim, each mod's `✓` line is labeled by how its content
actually reaches the game directory — `(merged)` for merge participants,
`(raw)` for a conversion-opted-out pak, unlabeled for ordinary loose-file
mods — and a post-sync footer finally names the one artifact that really
deployed (`Merged N mod(s) → zzz_LMM_Merged_P.pak`, with a
`(N deployed raw)` count when conversions fell back). The TUI's deploy
status line reports the same readout (`Deployed N mod(s) — merged N → …`).
`Deployed: N` still counts merge participants, and non-compile deploy
output is unchanged, byte for byte.
- TUI: a mutation that completes with two or more warnings now auto-opens a
scrollable overlay listing every warning in full, instead of collapsing
them to an unreadable `(N warnings)` status suffix — on merged-pak games
Expand Down
52 changes: 49 additions & 3 deletions cmd/lmm/deploy.go
Original file line number Diff line number Diff line change
Expand Up @@ -124,15 +124,31 @@ func doDeploy(ctx context.Context, service *core.Service, game *domain.Game, arg
// per-mod progress events at all when there is nothing to deploy, which
// is exactly the "No mods to deploy" case the pre-extraction CLI checked
// via len(modsToDeploy) before it had been folded into the flow.
//
// On a DeployCompile game the header drops "using <method>" (#255): the
// listed mods are mostly carried by one merged artifact deployed after
// the loop, so claiming a per-mod link method up front asserted
// something untrue about most of the lines below it.
compileMode := game.DeployMode == domain.DeployCompile
deployHeaderPrinted := false
printDeployHeaderOnce := func(total int) {
if deployHeaderPrinted {
return
}
deployHeaderPrinted = true
fmt.Printf("Deploying %d mod(s) using %s...\n\n", total, methodName)
if compileMode {
fmt.Printf("Deploying %d mod(s) — compile mode...\n\n", total)
} else {
fmt.Printf("Deploying %d mod(s) using %s...\n\n", total, methodName)
}
}

// mergeFooterPrinted: a DeployMergeSynced footer was printed (#255), so
// the summary below skips its own leading blank line - the footer
// already separates the per-mod block and "Deployed: N" reads as part
// of the same closing readout.
mergeFooterPrinted := false

// progress prints every diagnostic and per-mod status line at its exact
// point of occurrence, driven entirely by core.DeployProfile's progress
// events - including diagnostics that also land in result.Warnings/
Expand Down Expand Up @@ -166,6 +182,20 @@ func doDeploy(ctx context.Context, service *core.Service, game *domain.Game, arg
// --purge pass; handled so a future change can't accidentally
// route them into printDeployHeaderOnce below.
return
case core.DeployMergeSynced:
// #255: the post-sync footer naming the merged artifact. Fires
// only after the deploy loop (some per-mod event has already
// printed the header), and its Total counts the mods the merged
// artifact actually carries (raw fallbacks excluded - they ride
// RawFallbacks), not the deploy total - so it must not fall
// through to printDeployHeaderOnce below.
fmt.Printf("\nMerged %d mod(s) → %s", p.Total, p.Detail)
if p.RawFallbacks > 0 {
fmt.Printf(" (%d deployed raw)", p.RawFallbacks)
}
fmt.Println()
mergeFooterPrinted = true
return
}

printDeployHeaderOnce(p.Total)
Expand All @@ -185,7 +215,19 @@ func doDeploy(ctx context.Context, service *core.Service, game *domain.Game, arg
case core.DeploySkipped:
fmt.Printf(" %s %s - %s\n", colorRed("✗"), p.ModName, p.Detail)
case core.DeployDeployed:
fmt.Printf(" %s %s\n", colorGreen("✓"), p.ModName)
// #255: on a compile game, label how the mod's content actually
// reaches the game dir - "(merged)" rides the merged artifact
// (optimistic for a pak whose conversion then fails; the
// conversion warning + footer carry the correction), "(raw)" is
// a ConvertPaks-opted-out pak deploying itself.
switch p.ModClass {
case core.DeployModMerged:
fmt.Printf(" %s %s (merged)\n", colorGreen("✓"), p.ModName)
case core.DeployModRaw:
fmt.Printf(" %s %s (raw)\n", colorGreen("✓"), p.ModName)
default:
fmt.Printf(" %s %s\n", colorGreen("✓"), p.ModName)
}
case core.DeployNote:
if verbose {
fmt.Printf(" %s\n", p.Detail)
Expand All @@ -212,7 +254,11 @@ func doDeploy(ctx context.Context, service *core.Service, game *domain.Game, arg
return nil
}

fmt.Printf("\nDeployed: %d", result.Deployed)
if mergeFooterPrinted {
fmt.Printf("Deployed: %d", result.Deployed)
} else {
fmt.Printf("\nDeployed: %d", result.Deployed)
}
if failed := len(result.Skipped); failed > 0 {
fmt.Printf(", Failed: %d", failed)
}
Expand Down
198 changes: 198 additions & 0 deletions cmd/lmm/deploy_compile_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,198 @@
package main

import (
"context"
"fmt"
"os"
"path/filepath"
"testing"

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

// setupDoDeployCompileTest extends setupDoDeployTest into a DeployCompile
// game (#255): ConvertPaks on, base pak present, merge-compiler source
// registered under "fake-compiler", game registered (mergeCompilerForGame
// resolves the game's configured sources).
func setupDoDeployCompileTest(t *testing.T) (*core.Service, *domain.Game, *compilerInstallSource) {
t.Helper()
svc, game := setupDoDeployTest(t)
game.DeployMode = domain.DeployCompile
game.ConvertPaks = true
game.InstallPath = t.TempDir()
game.SourceIDs = map[string]string{"fake-compiler": "external-icarus-id"}
require.NoError(t, svc.AddGame(game))

basePak := filepath.Join(game.InstallPath, "Icarus", "Content", "Data", "data.pak")
require.NoError(t, os.MkdirAll(filepath.Dir(basePak), 0o755))
writeFakeBasePak(t, basePak)

compiler := &compilerInstallSource{fakeInstallSource: newFakeInstallSource("fake-compiler")}
svc.RegisterSource(compiler)
return svc, game, compiler
}

// ensureDefaultProfile creates the "default" profile if a seed helper runs
// before any seedDeployableMod call (which otherwise creates it).
func ensureDefaultProfile(t *testing.T, svc *core.Service, game *domain.Game) {
t.Helper()
pm := svc.NewProfileManager()
if _, err := pm.Get(game.ID, "default"); err != nil {
require.ErrorIs(t, err, domain.ErrProfileNotFound)
_, err := pm.Create(game.ID, "default")
require.NoError(t, err)
}
}

// seedCompileExmodzMod installs an enabled native merge-source mod: retained
// source only, zero deployment members of its own (#197).
func seedCompileExmodzMod(t *testing.T, svc *core.Service, game *domain.Game, modID, name, fileID string) {
t.Helper()
ensureDefaultProfile(t, svc, game)
gameCache := svc.GetGameCache(game)
require.NoError(t, gameCache.Store(game.ID, "fake-compiler", modID, "1.0", cache.RetainedSourceName(fileID), []byte(name+"-bytes")))
require.NoError(t, svc.SaveInstalledMod(&domain.InstalledMod{
Mod: domain.Mod{ID: modID, SourceID: "fake-compiler", Name: name, Version: "1.0", GameID: game.ID},
ProfileName: "default",
Enabled: true,
FileIDs: []string{fileID},
UpdatePolicy: domain.UpdateNotify,
}))
pm := svc.NewProfileManager()
require.NoError(t, pm.UpsertMod(game.ID, "default", domain.ModReference{SourceID: "fake-compiler", ModID: modID, Version: "1.0", FileIDs: []string{fileID}}))
}

// seedCompilePakMod installs an enabled convert-eligible pak mod in the
// shape #221 ingest produces: retained source plus a deployable raw copy
// recorded as the manifest's sole member (raw-deploy default until a merge
// flips it). fileID must classify as a convertible kind (suffix ".pak").
func seedCompilePakMod(t *testing.T, svc *core.Service, game *domain.Game, modID, name, fileID string) {
t.Helper()
ensureDefaultProfile(t, svc, game)
gameCache := svc.GetGameCache(game)
pakContent := []byte(name + "-pak-bytes")
require.NoError(t, gameCache.Store(game.ID, "fake-compiler", modID, "1.0", cache.RetainedSourceName(fileID), pakContent))
member := modID + ".pak"
require.NoError(t, gameCache.Store(game.ID, "fake-compiler", modID, "1.0", member, pakContent))
versionDir := gameCache.ModPath(game.ID, "fake-compiler", modID, "1.0")
require.NoError(t, cache.MarkFileCompleteWithMembers(versionDir, fileID, []string{member}))
require.NoError(t, svc.SaveInstalledMod(&domain.InstalledMod{
Mod: domain.Mod{ID: modID, SourceID: "fake-compiler", Name: name, Version: "1.0", GameID: game.ID},
ProfileName: "default",
Enabled: true,
FileIDs: []string{fileID},
UpdatePolicy: domain.UpdateNotify,
}))
pm := svc.NewProfileManager()
require.NoError(t, pm.UpsertMod(game.ID, "default", domain.ModReference{SourceID: "fake-compiler", ModID: modID, Version: "1.0", FileIDs: []string{fileID}}))
}

// TestDoDeploy_Compile_LabelsMergedRawAndLooseAndPrintsFooter is #255's CLI
// acceptance test: on a DeployCompile game the header stops claiming a
// per-mod link method, merge participants are labeled "(merged)", an
// opted-out pak deploying raw is labeled "(raw)", an ordinary loose-file
// mod keeps its plain line, and a post-sync footer names the merged
// artifact with its participant count, directly above "Deployed: N" (which
// still counts merge participants).
func TestDoDeploy_Compile_LabelsMergedRawAndLooseAndPrintsFooter(t *testing.T) {
svc, game, _ := setupDoDeployCompileTest(t)
seedCompileExmodzMod(t, svc, game, "bear-mount", "Bear Mount", "exmodz-file")
seedCompilePakMod(t, svc, game, "raw-pak", "Raw Pak Mod", "raw.pak")
require.NoError(t, svc.SetModConvertPaks("fake-compiler", "raw-pak", game.ID, "default", false))
seedDeployableMod(t, svc, game, "loose", "Loose Mod", "loose.esp")

out := captureStdout(t, func() error {
return doDeploy(context.Background(), svc, game, nil)
})

assert.Contains(t, out, "Deploying 3 mod(s) — compile mode...\n\n", "the compile header must not claim a per-mod link method")
assert.NotContains(t, out, "using symlink")
assert.Contains(t, out, " ✓ Bear Mount (merged)\n")
assert.Contains(t, out, " ✓ Raw Pak Mod (raw)\n")
assert.Contains(t, out, " ✓ Loose Mod\n")
assert.NotContains(t, out, "Loose Mod (", "a loose-file mod keeps its plain, unlabeled line")
assert.Contains(t, out, "\nMerged 1 mod(s) → zzz_LMM_Merged_P.pak\nDeployed: 3\n",
"the footer must name the merged artifact and sit directly above the summary")

_, err := os.Lstat(filepath.Join(game.ModPath, "zzz_LMM_Merged_P.pak"))
assert.NoError(t, err, "the merged artifact must actually be deployed")
_, err = os.Lstat(filepath.Join(game.ModPath, "raw-pak.pak"))
assert.NoError(t, err, "the opted-out pak must be deployed raw")
}

// pakFailCompilerSource wraps compilerInstallSource so a CLI test can script
// per-ref pak-conversion failures, mirroring internal/source/icarus/merge.go's
// real failure path (a "... - deploying raw" warning per skipped ref).
type pakFailCompilerSource struct {
*compilerInstallSource
failRefs map[string]string
}

func (s *pakFailCompilerSource) MergeCompile(ctx context.Context, basePakPath string, sources []source.MergeSource, outputPath string) ([]string, []source.MergeFailure, error) {
var out []byte
var warnings []string
var failed []source.MergeFailure
for _, src := range sources {
if reason, bad := s.failRefs[src.ModRef]; bad {
failed = append(failed, source.MergeFailure{ModRef: src.ModRef, Reason: reason})
warnings = append(warnings, fmt.Sprintf("mod %s: pak conversion failed: %s - deploying raw", src.ModName, reason))
continue
}
data, err := os.ReadFile(src.SourcePath)
if err != nil {
return nil, nil, err
}
out = append(out, data...)
}
return warnings, failed, os.WriteFile(outputPath, out, 0o644)
}

var _ source.MergeCompiler = (*pakFailCompilerSource)(nil)

// TestDoDeploy_Compile_ConversionFailure_FooterCorrectsOptimisticLabel covers
// #255's accepted optimistic case end to end: an opted-in pak is labeled
// "(merged)" inline (at ✓ time the merge hasn't run), its conversion then
// fails during the post-loop sync - the existing warning carries the
// correction, and the footer reports the raw fallback.
func TestDoDeploy_Compile_ConversionFailure_FooterCorrectsOptimisticLabel(t *testing.T) {
svc, game, compiler := setupDoDeployCompileTest(t)
svc.RegisterSource(&pakFailCompilerSource{
compilerInstallSource: compiler,
failRefs: map[string]string{"fake-compiler:flaky-pak": "irreconcilable"},
})
seedCompileExmodzMod(t, svc, game, "bear-mount", "Bear Mount", "exmodz-file")
seedCompilePakMod(t, svc, game, "flaky-pak", "Flaky Pak", "flaky.pak")

out := captureCombined(t, func() error {
return doDeploy(context.Background(), svc, game, nil)
})

assert.Contains(t, out, " ✓ Flaky Pak (merged)\n", "inline label is optimistic by design - the footer/warning correct it")
assert.Contains(t, out, "pak conversion failed", "the existing conversion-failure warning must still print")
assert.Contains(t, out, "\nMerged 1 mod(s) → zzz_LMM_Merged_P.pak (1 deployed raw)\nDeployed: 2\n",
"the footer must report the raw fallback and still sit directly above the summary")
}

// TestDoDeploy_NonCompile_NoCompileReadout guards the gate #255 must not
// move: a non-compile deploy's output is byte-identical to before - the
// original header, no labels, no merge footer.
func TestDoDeploy_NonCompile_NoCompileReadout(t *testing.T) {
svc, game := setupDoDeployTest(t)
seedDeployableMod(t, svc, game, "a", "Mod A", "a.esp")

out := captureStdout(t, func() error {
return doDeploy(context.Background(), svc, game, nil)
})

assert.Contains(t, out, "Deploying 1 mod(s) using symlink...\n\n")
assert.Contains(t, out, " ✓ Mod A\n")
assert.Contains(t, out, "\nDeployed: 1\n")
assert.NotContains(t, out, "compile mode")
assert.NotContains(t, out, "(merged)")
assert.NotContains(t, out, "Merged ")
}
Loading
Loading