From a75463ba4340535336f30a2d0334f0ed6699f674 Mon Sep 17 00:00:00 2001 From: Mario Finelli Date: Mon, 12 Oct 2026 22:12:50 +0200 Subject: [PATCH 01/14] Abstract out the path to the database file --- internal/database/database.go | 62 +++++++++++++++++----- internal/database/database_test.go | 79 +++++++++++++++++++++++++++++ internal/database/open_test.go | 51 ++++++++----------- internal/restore/full.go | 2 +- internal/restore/full_test.go | 20 ++------ internal/restore/game_test.go | 16 +++--- internal/restore/main_test.go | 37 ++++++++++++++ internal/restore/paths_test.go | 24 ++++----- internal/restore/targets_test.go | 8 ++- internal/testbuilder/testbuilder.go | 40 +++++++++++++-- 10 files changed, 253 insertions(+), 86 deletions(-) create mode 100644 internal/restore/main_test.go diff --git a/internal/database/database.go b/internal/database/database.go index f448742..a22f533 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -77,12 +77,35 @@ func DSN(path string, readOnly bool) string { return fmt.Sprintf("file:%s%s", url.PathEscape(path), pragmas) } +// SetupAt opens the read-write database at path, with the migrations not +// applied (see Migrate and OpenAt). Nothing is done to the file until the +// first statement is: the driver is lazy, so a path that is not there is no +// error here. +func SetupAt(path string) (*sql.DB, error) { + return sql.Open("sqlite3", DSN(path, false)) +} + +// SetupReadOnlyAt is SetupAt for a database that is only read: the connection +// can't change it, and it is not created by looking for it. +func SetupReadOnlyAt(path string) (*sql.DB, error) { + return sql.Open("sqlite3", DSN(path, true)) +} + +// Setup is SetupAt for the database that is configured. +// +// Transitional: it reads the global configuration, which nothing in this +// package or in the ones that use it may do. It is only here for the callers +// that do not have the path of the database of their own to give to SetupAt +// yet, and goes with them. func Setup() (*sql.DB, error) { - return sql.Open("sqlite3", DSN(viper.GetString("database"), false)) + return SetupAt(viper.GetString("database")) } +// SetupReadOnly is SetupReadOnlyAt for the database that is configured. +// +// Transitional: see Setup. func SetupReadOnly() (*sql.DB, error) { - return sql.Open("sqlite3", DSN(viper.GetString("database"), true)) + return SetupReadOnlyAt(viper.GetString("database")) } func GooseProvider(db *sql.DB) (*goose.Provider, error) { @@ -188,21 +211,28 @@ func latestMigrationVersion(fsys fs.FS) (int64, error) { return latest, nil } -// Open opens the database that the commands use: it has to be there (it is -// made by init), and it is migrated to the latest schema before it is handed -// over. The caller closes it. +// OpenAt opens the database that the commands use, at path: it has to be there +// (it is made by init), and it is migrated to the latest schema before it is +// handed over. The caller closes it. +func OpenAt(ctx context.Context, path string) (*sql.DB, error) { + return openWith(ctx, path, Migrate) +} + +// Open is OpenAt for the database that is configured. +// +// Transitional: see Setup. func Open(ctx context.Context) (*sql.DB, error) { - return openWith(ctx, Migrate) + return OpenAt(ctx, viper.GetString("database")) } -// openWith is Open with the migrating done by migrate, so that a test can +// openWith is OpenAt with the migrating done by migrate, so that a test can // have migrations that are not the ones that are embedded in the binary. -func openWith(ctx context.Context, migrate func(context.Context, *sql.DB) error) (*sql.DB, error) { - if err := EnsureExists(); err != nil { +func openWith(ctx context.Context, path string, migrate func(context.Context, *sql.DB) error) (*sql.DB, error) { + if err := EnsureExistsAt(path); err != nil { return nil, err } - db, err := Setup() + db, err := SetupAt(path) if err != nil { return nil, fmt.Errorf("error setting up database: %w", err) } @@ -215,10 +245,16 @@ func openWith(ctx context.Context, migrate func(context.Context, *sql.DB) error) return db, nil } -// EnsureExists verifies that the configured database file exists -// and is a regular file. If not, it returns a user-friendly error. +// EnsureExists is EnsureExistsAt for the database that is configured. +// +// Transitional: see Setup. func EnsureExists() error { - path := viper.GetString("database") + return EnsureExistsAt(viper.GetString("database")) +} + +// EnsureExistsAt verifies that the database file at path exists and is a +// regular file. If not, it returns a user-friendly error. +func EnsureExistsAt(path string) error { if path == "" { return fmt.Errorf("database path is not configured") } diff --git a/internal/database/database_test.go b/internal/database/database_test.go index 4fbcceb..5d7cdb6 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -149,6 +149,85 @@ func TestDSNReadOnly(t *testing.T) { }) } +func TestSetupAt(t *testing.T) { + t.Parallel() + + t.Run("it opens a database that can be written, with foreign keys enforced", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "modctl.db") + + db, err := SetupAt(path) + require.NoError(t, err) + defer db.Close() + + _, err = db.Exec(`CREATE TABLE things (id INTEGER PRIMARY KEY)`) + require.NoError(t, err) + assert.FileExists(t, path) + + var enabled int + require.NoError(t, db.QueryRow(`PRAGMA foreign_keys`).Scan(&enabled)) + assert.Equal(t, 1, enabled) + }) + + t.Run("what it opens is the database at the path it is given", func(t *testing.T) { + t.Parallel() + + first, err := SetupAt(filepath.Join(t.TempDir(), "first.db")) + require.NoError(t, err) + defer first.Close() + second, err := SetupAt(filepath.Join(t.TempDir(), "second.db")) + require.NoError(t, err) + defer second.Close() + + _, err = first.Exec(`CREATE TABLE things (id INTEGER PRIMARY KEY)`) + require.NoError(t, err) + + _, err = second.Exec(`SELECT * FROM things`) + assert.Error(t, err, "the other database has no such table") + }) +} + +func TestSetupReadOnlyAt(t *testing.T) { + t.Parallel() + + t.Run("it reads what is there and can not change it", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "modctl.db") + rw, err := SetupAt(path) + require.NoError(t, err) + defer rw.Close() + _, err = rw.Exec(`CREATE TABLE things (id INTEGER PRIMARY KEY)`) + require.NoError(t, err) + _, err = rw.Exec(`INSERT INTO things (id) VALUES (1)`) + require.NoError(t, err) + + db, err := SetupReadOnlyAt(path) + require.NoError(t, err) + defer db.Close() + + var n int + require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM things`).Scan(&n)) + assert.Equal(t, 1, n) + _, err = db.Exec(`INSERT INTO things (id) VALUES (2)`) + assert.Error(t, err) + }) + + t.Run("it does not make a database that is not there", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "nothing.db") + + db, err := SetupReadOnlyAt(path) + require.NoError(t, err) + defer db.Close() + + assert.Error(t, db.Ping()) + assert.NoFileExists(t, path) + }) +} + // useMigrationsOfRepository has Migrations be the ones of the repository for the // length of a test, as they are in the binary. It changes something that is // global, so the tests that use it are not run in parallel. diff --git a/internal/database/open_test.go b/internal/database/open_test.go index 82f6997..8b169c4 100644 --- a/internal/database/open_test.go +++ b/internal/database/open_test.go @@ -27,22 +27,10 @@ import ( "testing" "github.com/pressly/goose/v3" - "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) -// useDatabase has viper say where the database is for the length of a test. -// It changes something that is global, so the tests that use it are not run in -// parallel. -func useDatabase(t *testing.T, path string) { - t.Helper() - - old := viper.GetString("database") - viper.Set("database", path) - t.Cleanup(func() { viper.Set("database", old) }) -} - // migrateFromFiles is a migrate for openWith that applies the migrations of the // repository, which are not embedded in the binary of a test. func migrateFromFiles(ctx context.Context, db *sql.DB) error { @@ -66,12 +54,14 @@ func emptyDatabaseFile(t *testing.T) string { } func TestOpenDB(t *testing.T) { + t.Parallel() + ctx := context.Background() t.Run("a database that is there is opened, and migrated", func(t *testing.T) { - useDatabase(t, emptyDatabaseFile(t)) + t.Parallel() - db, err := openWith(ctx, migrateFromFiles) + db, err := openWith(ctx, emptyDatabaseFile(t), migrateFromFiles) require.NoError(t, err) defer db.Close() @@ -82,9 +72,9 @@ func TestOpenDB(t *testing.T) { }) t.Run("foreign keys are enforced on what it returns", func(t *testing.T) { - useDatabase(t, emptyDatabaseFile(t)) + t.Parallel() - db, err := openWith(ctx, migrateFromFiles) + db, err := openWith(ctx, emptyDatabaseFile(t), migrateFromFiles) require.NoError(t, err) defer db.Close() @@ -94,9 +84,9 @@ func TestOpenDB(t *testing.T) { }) t.Run("the database is not configured", func(t *testing.T) { - useDatabase(t, "") + t.Parallel() - db, err := openWith(ctx, migrateFromFiles) + db, err := openWith(ctx, "", migrateFromFiles) require.Error(t, err) assert.Contains(t, err.Error(), "database path is not configured") @@ -104,10 +94,11 @@ func TestOpenDB(t *testing.T) { }) t.Run("a database that is not there says what to do", func(t *testing.T) { + t.Parallel() + path := filepath.Join(t.TempDir(), "nothing.db") - useDatabase(t, path) - db, err := openWith(ctx, migrateFromFiles) + db, err := openWith(ctx, path, migrateFromFiles) require.Error(t, err) assert.Contains(t, err.Error(), path) @@ -117,9 +108,9 @@ func TestOpenDB(t *testing.T) { }) t.Run("a path that is not a file is not a database", func(t *testing.T) { - useDatabase(t, t.TempDir()) + t.Parallel() - db, err := openWith(ctx, migrateFromFiles) + db, err := openWith(ctx, t.TempDir(), migrateFromFiles) require.Error(t, err) assert.Contains(t, err.Error(), "not a regular file") @@ -127,10 +118,10 @@ func TestOpenDB(t *testing.T) { }) t.Run("nothing is migrated that is not there", func(t *testing.T) { - useDatabase(t, filepath.Join(t.TempDir(), "nothing.db")) + t.Parallel() called := false - _, err := openWith(ctx, func(context.Context, *sql.DB) error { + _, err := openWith(ctx, filepath.Join(t.TempDir(), "nothing.db"), func(context.Context, *sql.DB) error { called = true return nil }) @@ -140,10 +131,10 @@ func TestOpenDB(t *testing.T) { }) t.Run("migrating is done on the database that is handed over", func(t *testing.T) { - useDatabase(t, emptyDatabaseFile(t)) + t.Parallel() var migrated *sql.DB - db, err := openWith(ctx, func(_ context.Context, db *sql.DB) error { + db, err := openWith(ctx, emptyDatabaseFile(t), func(_ context.Context, db *sql.DB) error { migrated = db return nil }) @@ -155,11 +146,11 @@ func TestOpenDB(t *testing.T) { }) t.Run("a migration that fails is an error, and the database is closed", func(t *testing.T) { - useDatabase(t, emptyDatabaseFile(t)) + t.Parallel() boom := errors.New("migration 7 failed") var migrated *sql.DB - db, err := openWith(ctx, func(_ context.Context, db *sql.DB) error { + db, err := openWith(ctx, emptyDatabaseFile(t), func(_ context.Context, db *sql.DB) error { migrated = db return boom }) @@ -171,11 +162,11 @@ func TestOpenDB(t *testing.T) { }) t.Run("the migrations that are embedded are the ones that are used", func(t *testing.T) { - useDatabase(t, emptyDatabaseFile(t)) + t.Parallel() // Migrations is only filled in by the binary, so in a test there is // nothing to migrate with, which is an error and not an empty schema - db, err := Open(ctx) + db, err := OpenAt(ctx, emptyDatabaseFile(t)) require.Error(t, err) assert.Nil(t, db) diff --git a/internal/restore/full.go b/internal/restore/full.go index c16cca4..a48891f 100644 --- a/internal/restore/full.go +++ b/internal/restore/full.go @@ -114,7 +114,7 @@ func Full( } // Reopen and migrate (handles case where bundle schema is older than binary) - newDB, err := database.Setup() + newDB, err := database.SetupAt(dbPath) if err != nil { return res, fmt.Errorf("reopen database: %w", err) } diff --git a/internal/restore/full_test.go b/internal/restore/full_test.go index a216d8c..c334125 100644 --- a/internal/restore/full_test.go +++ b/internal/restore/full_test.go @@ -30,7 +30,6 @@ import ( "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/exporter" "github.com/mfinelli/modctl/internal/testbuilder" - "github.com/spf13/viper" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -49,18 +48,6 @@ func fileDatabase(t *testing.T, path string) *sql.DB { return db } -// useDatabase has viper say where the database is for the length of a test, -// which is where a full restore opens the database that it has restored. It -// changes something that is global, so the tests that use it are not run in -// parallel. -func useDatabase(t *testing.T, path string) { - t.Helper() - - old := viper.GetString("database") - viper.Set("database", path) - t.Cleanup(func() { viper.Set("database", old) }) -} - func gameNames(t *testing.T, db *sql.DB) []string { t.Helper() @@ -92,7 +79,6 @@ func newFullRestoreFixture(t *testing.T) fullRestoreFixture { dbPath := filepath.Join(t.TempDir(), "modctl.db") db := fileDatabase(t, dbPath) newBundleGame(t, db, "1").rename("The game that was there") - useDatabase(t, dbPath) bundleDir := t.TempDir() bundleDB := fileDatabase(t, filepath.Join(bundleDir, exporter.DatabaseFilename)) @@ -109,8 +95,9 @@ func newFullRestoreFixture(t *testing.T) fullRestoreFixture { } func TestFullReplacesTheDatabase(t *testing.T) { + t.Parallel() + ctx := context.Background() - useMigrationsOfRepository(t) f := newFullRestoreFixture(t) _, err := Full(ctx, f.db, dbq.New(f.db), blobstore.Store{}, f.bundle, Options{Force: true}, f.dbPath, "test", quietLogger()) @@ -124,8 +111,9 @@ func TestFullReplacesTheDatabase(t *testing.T) { } func TestFullThatCannotCopyTheBundleLeavesTheDatabase(t *testing.T) { + t.Parallel() + ctx := context.Background() - useMigrationsOfRepository(t) f := newFullRestoreFixture(t) // a bundle that is cut short: its database is not there to be copied require.NoError(t, f.bundle.BundleDB.Close()) diff --git a/internal/restore/game_test.go b/internal/restore/game_test.go index 5fe44b7..fdd19f9 100644 --- a/internal/restore/game_test.go +++ b/internal/restore/game_test.go @@ -49,12 +49,10 @@ func gameInstallID(t *testing.T, q *dbq.Queries, game dbq.GameInstall) (int64, e }) } -// These need migrations that are not embedded in a test, so they are not run in -// parallel. - func TestGameImportsABundle(t *testing.T) { + t.Parallel() + ctx := context.Background() - useMigrationsOfRepository(t) dest := testbuilder.SetupDB(t) bundleDB := testbuilder.SetupDB(t) @@ -77,8 +75,9 @@ func TestGameImportsABundle(t *testing.T) { } func TestGameWithAGameThatIsThere(t *testing.T) { + t.Parallel() + ctx := context.Background() - useMigrationsOfRepository(t) newBundle := func(t *testing.T) (dest *sql.DB, existing *bundleGame, bundle *Bundle, inBundle *bundleGame) { dest = testbuilder.SetupDB(t) @@ -92,6 +91,8 @@ func TestGameWithAGameThatIsThere(t *testing.T) { } t.Run("it is not replaced without --force", func(t *testing.T) { + t.Parallel() + dest, existing, bundle, _ := newBundle(t) _, err := Game(ctx, dest, existing.q, blobstore.Store{}, bundle, Options{SkipInventory: true}, "test", quietLogger()) @@ -105,6 +106,8 @@ func TestGameWithAGameThatIsThere(t *testing.T) { }) t.Run("it is replaced with --force", func(t *testing.T) { + t.Parallel() + dest, existing, bundle, inBundle := newBundle(t) _, err := Game(ctx, dest, existing.q, blobstore.Store{}, bundle, Options{Force: true, SkipInventory: true}, "test", quietLogger()) @@ -121,8 +124,9 @@ func TestGameWithAGameThatIsThere(t *testing.T) { } func TestGameThatCannotBeImportedToTheEndChangesNothing(t *testing.T) { + t.Parallel() + ctx := context.Background() - useMigrationsOfRepository(t) // the game is there, with something of its own dest := testbuilder.SetupDB(t) diff --git a/internal/restore/main_test.go b/internal/restore/main_test.go new file mode 100644 index 0000000..13874ba --- /dev/null +++ b/internal/restore/main_test.go @@ -0,0 +1,37 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package restore + +import ( + "os" + "testing" + + "github.com/mfinelli/modctl/internal/database" + "github.com/mfinelli/modctl/internal/testbuilder" +) + +// The restores migrate the databases that they make, with the migrations that +// are embedded in the binary, which a test does not have. They are the ones of +// the repository for all of the tests, set before any of them runs: they are +// not set by each test, since that would change something that is global while +// the other tests are using it. +func TestMain(m *testing.M) { + database.Migrations = testbuilder.MigrationsFS() + os.Exit(m.Run()) +} diff --git a/internal/restore/paths_test.go b/internal/restore/paths_test.go index a3de86d..97a384b 100644 --- a/internal/restore/paths_test.go +++ b/internal/restore/paths_test.go @@ -302,18 +302,6 @@ func TestValidateFullPaths(t *testing.T) { }) } -// A restore has to refuse a bundle like this before it changes anything. These -// need migrations that are not embedded in a test, so they are not run in -// parallel. - -func useMigrationsOfRepository(t *testing.T) { - t.Helper() - - old := database.Migrations - database.Migrations = os.DirFS("../..") - t.Cleanup(func() { database.Migrations = old }) -} - func testBundle(t *testing.T, bundleDB *sql.DB, game dbq.GameInstall, kind exporter.ExportKind) *Bundle { t.Helper() @@ -340,9 +328,11 @@ func quietLogger() *slog.Logger { return slog.New(slog.NewTextHandler(io.Discard, nil)) } +// A restore has to refuse a bundle like this before it changes anything. func TestGameRefusesAnUnsafeBundle(t *testing.T) { + t.Parallel() + ctx := context.Background() - useMigrationsOfRepository(t) // the game is already there, with something of its own dest := testbuilder.SetupDB(t) @@ -375,8 +365,9 @@ func TestGameRefusesAnUnsafeBundle(t *testing.T) { } func TestGameLetsASafeBundleBeLookedAt(t *testing.T) { + t.Parallel() + ctx := context.Background() - useMigrationsOfRepository(t) dest := testbuilder.SetupDB(t) bundleDB := testbuilder.SetupDB(t) @@ -390,8 +381,9 @@ func TestGameLetsASafeBundleBeLookedAt(t *testing.T) { } func TestFullRefusesAnUnsafeBundle(t *testing.T) { + t.Parallel() + ctx := context.Background() - useMigrationsOfRepository(t) for _, tc := range []struct { name string @@ -405,6 +397,8 @@ func TestFullRefusesAnUnsafeBundle(t *testing.T) { } { tc := tc t.Run(tc.name, func(t *testing.T) { + t.Parallel() + dest := testbuilder.SetupDB(t) bundleDB := testbuilder.SetupDB(t) g := newBundleGame(t, bundleDB, "1") diff --git a/internal/restore/targets_test.go b/internal/restore/targets_test.go index 927f66a..b3350c0 100644 --- a/internal/restore/targets_test.go +++ b/internal/restore/targets_test.go @@ -120,8 +120,9 @@ func (g *bundleGame) target0Root(t *testing.T, db *sql.DB) string { } func TestGameImportsTargetsDisabled(t *testing.T) { + t.Parallel() + ctx := context.Background() - useMigrationsOfRepository(t) dest := testbuilder.SetupDB(t) bundleDB := testbuilder.SetupDB(t) @@ -149,8 +150,9 @@ func TestGameImportsTargetsDisabled(t *testing.T) { } func TestFullImportsTargetsDisabledUnlessItIsTheSameMachine(t *testing.T) { + t.Parallel() + ctx := context.Background() - useMigrationsOfRepository(t) for _, tc := range []struct { name string @@ -161,6 +163,8 @@ func TestFullImportsTargetsDisabledUnlessItIsTheSameMachine(t *testing.T) { } { tc := tc t.Run(tc.name, func(t *testing.T) { + t.Parallel() + f := newFullRestoreFixture(t) // the bundle has its own game with the targets that matter bundleDB := f.bundle.BundleDB diff --git a/internal/testbuilder/testbuilder.go b/internal/testbuilder/testbuilder.go index ab9dea5..c300301 100644 --- a/internal/testbuilder/testbuilder.go +++ b/internal/testbuilder/testbuilder.go @@ -21,6 +21,8 @@ package testbuilder import ( "context" "database/sql" + "fmt" + "io/fs" "os" "path/filepath" "runtime" @@ -69,20 +71,52 @@ func NewProvider(t *testing.T, db *sql.DB) *goose.Provider { return p } +// MigrationsFS is what main gives to database.Migrations in the binary: the +// repository, which has its migrations in a "migrations" directory. The +// embedded copy is not there in a test, so a package whose tests need to +// migrate a database sets this once, in its TestMain, before any test runs: +// +// func TestMain(m *testing.M) { +// database.Migrations = testbuilder.MigrationsFS() +// os.Exit(m.Run()) +// } +// +// It is not set by a test for as long as the test runs, since that is a change +// of something that is global, which the tests that run at the same time would +// see. +func MigrationsFS() fs.FS { + dir, err := migrationsDir() + if err != nil { + panic(err) + } + + return os.DirFS(filepath.Dir(dir)) +} + func findMigrationsDir(t *testing.T) string { t.Helper() + + dir, err := migrationsDir() + require.NoError(t, err) + + return dir +} + +func migrationsDir() (string, error) { _, filename, _, ok := runtime.Caller(0) - require.True(t, ok, "runtime.Caller failed") + if !ok { + return "", fmt.Errorf("runtime.Caller failed") + } // filename is the absolute path to this source file; // walk up until we find a directory containing "migrations/" dir := filepath.Dir(filename) for { if _, err := os.Stat(filepath.Join(dir, "migrations")); err == nil { - return filepath.Join(dir, "migrations") + return filepath.Join(dir, "migrations"), nil } parent := filepath.Dir(dir) if parent == dir { - t.Fatal("could not find migrations directory") + return "", fmt.Errorf("could not find migrations directory") } dir = parent } From 043bccdb20b6eb5ff68f03cde6b2a5dfc5f07127 Mon Sep 17 00:00:00 2001 From: Mario Finelli Date: Mon, 12 Oct 2026 22:44:21 +0200 Subject: [PATCH 02/14] Fix flaky test --- internal/database/tx_test.go | 17 +++++++++++------ 1 file changed, 11 insertions(+), 6 deletions(-) diff --git a/internal/database/tx_test.go b/internal/database/tx_test.go index 63fff57..0b4545d 100644 --- a/internal/database/tx_test.go +++ b/internal/database/tx_test.go @@ -74,6 +74,15 @@ const ( sha2 = "0000000000000000000000000000000000000000000000000000000000000002" ) +// isCanceledCommit is whether err is what a commit fails with when the context +// of its transaction was canceled while it was open. database/sql rolls such a +// transaction back by itself, in a goroutine of its own, so the commit that +// follows finds either the context canceled or the transaction already rolled +// back, which one is a matter of timing. +func isCanceledCommit(err error) bool { + return errors.Is(err, context.Canceled) || errors.Is(err, sql.ErrTxDone) +} + func TestInTx(t *testing.T) { t.Parallel() @@ -156,11 +165,7 @@ func TestInTx(t *testing.T) { }) require.Error(t, err) - // database/sql rolls the transaction back by itself when its context - // is canceled, and whether the commit that follows finds the context - // canceled or the transaction already rolled back is a matter of timing - assert.True(t, errors.Is(err, context.Canceled) || errors.Is(err, sql.ErrTxDone), - "what went wrong is what it wraps: %v", err) + assert.True(t, isCanceledCommit(err), "what went wrong is what it wraps: %v", err) assert.Contains(t, err.Error(), "commit: ") assert.Equal(t, 0, blobCount(t, db)) }) @@ -292,7 +297,7 @@ func TestWithTx(t *testing.T) { require.Error(t, err) assert.Contains(t, err.Error(), "commit: ") - assert.ErrorIs(t, err, context.Canceled) + assert.True(t, isCanceledCommit(err), "what went wrong is what it wraps: %v", err) assert.Equal(t, 0, blobCount(t, db)) }) From 405ff11ae44ee227094cca81423ad61c0435d2f3 Mon Sep 17 00:00:00 2001 From: Mario Finelli Date: Mon, 12 Oct 2026 22:52:52 +0200 Subject: [PATCH 03/14] Add and update tests for state and completion --- internal/argresolver/game.go | 15 +- internal/argresolver/game_test.go | 84 ++++---- internal/completion/completer_test.go | 267 +++++++++++++++++++++++++ internal/completion/completion.go | 16 ++ internal/completion/games.go | 4 +- internal/completion/modfileversions.go | 16 +- internal/completion/modpages.go | 6 +- internal/completion/profiles.go | 6 +- internal/completion/store.go | 4 +- internal/completion/targets.go | 6 +- internal/completion/transitional.go | 66 ++++++ internal/state/active.go | 49 ++++- internal/state/active_test.go | 203 +++++++++++++++++++ internal/testbuilder/testbuilder.go | 20 ++ 14 files changed, 686 insertions(+), 76 deletions(-) create mode 100644 internal/completion/completer_test.go create mode 100644 internal/completion/transitional.go create mode 100644 internal/state/active_test.go diff --git a/internal/argresolver/game.go b/internal/argresolver/game.go index 79c8be6..3a440c5 100644 --- a/internal/argresolver/game.go +++ b/internal/argresolver/game.go @@ -44,12 +44,19 @@ import ( "github.com/mfinelli/modctl/internal/state" ) -// GameInstall resolves the game install that a command is for: the one -// that arg says (see ExplicitGameInstall), or the active one if arg is -// empty, which is what leaving out --game means. +// GameInstall is GameInstallIn for the state directory of the user. +// +// Transitional: see state.DefaultDir. func GameInstall(ctx context.Context, q *dbq.Queries, arg string) (dbq.GameInstall, error) { + return GameInstallIn(ctx, q, state.DefaultDir(), arg) +} + +// GameInstallIn resolves the game install that a command is for: the one +// that arg says (see ExplicitGameInstall), or the active one, from the state +// directory stateDir, if arg is empty, which is what leaving out --game means. +func GameInstallIn(ctx context.Context, q *dbq.Queries, stateDir string, arg string) (dbq.GameInstall, error) { if arg == "" { - active, err := state.LoadActive() + active, err := state.LoadActiveIn(stateDir) if err != nil { return dbq.GameInstall{}, fmt.Errorf("load active selection: %w", err) } diff --git a/internal/argresolver/game_test.go b/internal/argresolver/game_test.go index f257e25..5f5e27b 100644 --- a/internal/argresolver/game_test.go +++ b/internal/argresolver/game_test.go @@ -25,7 +25,6 @@ import ( "path/filepath" "testing" - "github.com/adrg/xdg" "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/state" "github.com/mfinelli/modctl/internal/testbuilder" @@ -168,62 +167,55 @@ func TestExplicitGameInstall(t *testing.T) { }) } -// useStateDir points the state directory (where the active game is kept) at a -// directory of the test. The environment is global, so the tests that use it -// are not run in parallel. -func useStateDir(t *testing.T) string { - t.Helper() +func TestGameInstallIn(t *testing.T) { + t.Parallel() - // registered first so that it runs after the environment is put back - t.Cleanup(xdg.Reload) - - dir := t.TempDir() - t.Setenv("XDG_STATE_HOME", dir) - xdg.Reload() - return dir -} - -func TestGameInstall(t *testing.T) { ctx := context.Background() t.Run("a game that is asked for is the one that is resolved", func(t *testing.T) { - useStateDir(t) + t.Parallel() + + dir := t.TempDir() db := testbuilder.SetupDB(t) q := dbq.New(db) asked := testbuilder.NewGame(t, db).WithName("Cyberpunk 2077").WithStoreGameID("1").Build() active := testbuilder.NewGame(t, db).WithName("Skyrim").WithStoreGameID("2").Build() - require.NoError(t, state.SaveActive(state.Active{ActiveGameInstallID: active.ID})) + require.NoError(t, state.SaveActiveIn(dir, state.Active{ActiveGameInstallID: active.ID})) - byID, err := GameInstall(ctx, q, fmt.Sprintf("%d", asked.ID)) + byID, err := GameInstallIn(ctx, q, dir, fmt.Sprintf("%d", asked.ID)) require.NoError(t, err) assert.Equal(t, asked.ID, byID.ID, "not the active one") - byName, err := GameInstall(ctx, q, "Cyberpunk 2077") + byName, err := GameInstallIn(ctx, q, dir, "Cyberpunk 2077") require.NoError(t, err) assert.Equal(t, asked.ID, byName.ID) }) t.Run("a game that is asked for and is not there is an error that says so", func(t *testing.T) { - useStateDir(t) + t.Parallel() + + dir := t.TempDir() db := testbuilder.SetupDB(t) active := testbuilder.NewGame(t, db).Build() - require.NoError(t, state.SaveActive(state.Active{ActiveGameInstallID: active.ID})) + require.NoError(t, state.SaveActiveIn(dir, state.Active{ActiveGameInstallID: active.ID})) - _, err := GameInstall(ctx, dbq.New(db), "999999") + _, err := GameInstallIn(ctx, dbq.New(db), dir, "999999") require.Error(t, err) assert.Contains(t, err.Error(), "no game install with id 999999") }) t.Run("without one it is the active game", func(t *testing.T) { - useStateDir(t) + t.Parallel() + + dir := t.TempDir() db := testbuilder.SetupDB(t) q := dbq.New(db) testbuilder.NewGame(t, db).WithName("Not Active").WithStoreGameID("1").Build() active := testbuilder.NewGame(t, db).WithName("Active").WithStoreGameID("2").Build() - require.NoError(t, state.SaveActive(state.Active{ActiveGameInstallID: active.ID})) + require.NoError(t, state.SaveActiveIn(dir, state.Active{ActiveGameInstallID: active.ID})) - got, err := GameInstall(ctx, q, "") + got, err := GameInstallIn(ctx, q, dir, "") require.NoError(t, err) assert.Equal(t, active.ID, got.ID) @@ -231,11 +223,13 @@ func TestGameInstall(t *testing.T) { }) t.Run("with no active game it says what to do", func(t *testing.T) { - useStateDir(t) + t.Parallel() + + dir := t.TempDir() db := testbuilder.SetupDB(t) testbuilder.NewGame(t, db).Build() - _, err := GameInstall(ctx, dbq.New(db), "") + _, err := GameInstallIn(ctx, dbq.New(db), dir, "") require.Error(t, err) assert.Contains(t, err.Error(), "no active game selected") @@ -244,37 +238,42 @@ func TestGameInstall(t *testing.T) { }) t.Run("an active selection that has no game in it is no active game", func(t *testing.T) { - useStateDir(t) + t.Parallel() + + dir := t.TempDir() db := testbuilder.SetupDB(t) // a store has been chosen, but not a game - require.NoError(t, state.SaveActive(state.Active{ActiveStoreID: "steam"})) + require.NoError(t, state.SaveActiveIn(dir, state.Active{ActiveStoreID: "steam"})) - _, err := GameInstall(ctx, dbq.New(db), "") + _, err := GameInstallIn(ctx, dbq.New(db), dir, "") require.Error(t, err) assert.Contains(t, err.Error(), "no active game selected") }) t.Run("an active game that is not there any more is an error that says so", func(t *testing.T) { - useStateDir(t) + t.Parallel() + + dir := t.TempDir() db := testbuilder.SetupDB(t) - require.NoError(t, state.SaveActive(state.Active{ActiveGameInstallID: 424242})) + require.NoError(t, state.SaveActiveIn(dir, state.Active{ActiveGameInstallID: 424242})) - _, err := GameInstall(ctx, dbq.New(db), "") + _, err := GameInstallIn(ctx, dbq.New(db), dir, "") require.Error(t, err) assert.Contains(t, err.Error(), "no game install with id 424242") }) t.Run("a state file that can't be read is an error that says so", func(t *testing.T) { - dir := useStateDir(t) + t.Parallel() + + dir := t.TempDir() db := testbuilder.SetupDB(t) testbuilder.NewGame(t, db).Build() - path := filepath.Join(dir, "modctl", "active.json") - require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + path := filepath.Join(dir, "active.json") require.NoError(t, os.WriteFile(path, []byte("{ not json"), 0o644)) - _, err := GameInstall(ctx, dbq.New(db), "") + _, err := GameInstallIn(ctx, dbq.New(db), dir, "") require.Error(t, err) assert.Contains(t, err.Error(), "load active selection") @@ -282,14 +281,15 @@ func TestGameInstall(t *testing.T) { }) t.Run("a state file that can't be read is not looked at when a game is asked for", func(t *testing.T) { - dir := useStateDir(t) + t.Parallel() + + dir := t.TempDir() db := testbuilder.SetupDB(t) gi := testbuilder.NewGame(t, db).Build() - path := filepath.Join(dir, "modctl", "active.json") - require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + path := filepath.Join(dir, "active.json") require.NoError(t, os.WriteFile(path, []byte("{ not json"), 0o644)) - got, err := GameInstall(ctx, dbq.New(db), fmt.Sprintf("%d", gi.ID)) + got, err := GameInstallIn(ctx, dbq.New(db), dir, fmt.Sprintf("%d", gi.ID)) require.NoError(t, err) assert.Equal(t, gi.ID, got.ID) diff --git a/internal/completion/completer_test.go b/internal/completion/completer_test.go new file mode 100644 index 0000000..3675d03 --- /dev/null +++ b/internal/completion/completer_test.go @@ -0,0 +1,267 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package completion + +import ( + "database/sql" + "os" + "path/filepath" + "testing" + + "github.com/mfinelli/modctl/internal/state" + "github.com/mfinelli/modctl/internal/testbuilder" + "github.com/spf13/cobra" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// newCompleter is a Completer for a database and a state directory of the +// test, and the database for what the test puts in it. +func newCompleter(t *testing.T) (Completer, *sql.DB) { + t.Helper() + + db, path := testbuilder.SetupDBFile(t) + return Completer{DatabasePath: path, StateDir: t.TempDir()}, db +} + +// commandWithGame is a command that has the --game flag, set to game unless +// that is empty. +func commandWithGame(t *testing.T, game string) *cobra.Command { + t.Helper() + + cmd := &cobra.Command{Use: "test"} + cmd.Flags().Int64("game", 0, "") + if game != "" { + require.NoError(t, cmd.Flags().Set("game", game)) + } + return cmd +} + +func TestCompleterGameInstallSelectors(t *testing.T) { + t.Parallel() + + t.Run("by title the title is completed, and the selector says which", func(t *testing.T) { + t.Parallel() + + c, db := newCompleter(t) + testbuilder.NewGame(t, db).WithName("Skyrim").WithStoreGameID("72850").Build() + testbuilder.NewGame(t, db).WithName("Fallout 4").WithStoreGameID("377160").Build() + + got, directive := c.GameInstallSelectors(&cobra.Command{}, "sky") + + assert.Equal(t, []string{"Skyrim\tsteam:72850#default"}, got) + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + }) + + t.Run("by selector the selector is completed, and the title says which", func(t *testing.T) { + t.Parallel() + + c, db := newCompleter(t) + testbuilder.NewGame(t, db).WithName("Skyrim").WithStoreGameID("72850").Build() + testbuilder.NewGame(t, db).WithName("Fallout 4").WithStoreGameID("377160").Build() + + got, _ := c.GameInstallSelectors(&cobra.Command{}, "steam:728") + + assert.Equal(t, []string{"steam:72850#default\tSkyrim"}, got) + }) + + t.Run("a game that is not there says so, and is after the ones that are", func(t *testing.T) { + t.Parallel() + + c, db := newCompleter(t) + testbuilder.NewGame(t, db).WithName("A gone game").WithStoreGameID("1").NotPresent().Build() + testbuilder.NewGame(t, db).WithName("B game").WithStoreGameID("2").Build() + + got, _ := c.GameInstallSelectors(&cobra.Command{}, "") + + assert.Equal(t, []string{ + "B game\tsteam:2#default", + "A gone game\tsteam:1#default (missing)", + }, got) + }) + + t.Run("what is typed is not a pattern", func(t *testing.T) { + t.Parallel() + + c, db := newCompleter(t) + testbuilder.NewGame(t, db).WithName("Skyrim").Build() + + for _, typed := range []string{"%", "_", `\`} { + got, _ := c.GameInstallSelectors(&cobra.Command{}, typed) + assert.Empty(t, got, "%q", typed) + } + }) + + t.Run("with no database there is nothing to complete, and files are not", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "nothing.db") + c := Completer{DatabasePath: path, StateDir: t.TempDir()} + + got, directive := c.GameInstallSelectors(&cobra.Command{}, "") + + assert.Empty(t, got) + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + assert.NoFileExists(t, path, "it is not made by looking for it") + }) +} + +func TestCompleterStoreIDs(t *testing.T) { + t.Parallel() + + t.Run("the stores that are enabled are completed, with their names", func(t *testing.T) { + t.Parallel() + + c, _ := newCompleter(t) + + got, directive := c.StoreIDs(&cobra.Command{}, "") + + assert.Equal(t, []string{"steam\tSteam"}, got) + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + }) + + t.Run("a prefix is matched whatever the case", func(t *testing.T) { + t.Parallel() + + c, _ := newCompleter(t) + + upper, _ := c.StoreIDs(&cobra.Command{}, "STE") + other, _ := c.StoreIDs(&cobra.Command{}, "gog") + + assert.Equal(t, []string{"steam\tSteam"}, upper) + assert.Empty(t, other) + }) + + t.Run("a store that is not enabled is not completed", func(t *testing.T) { + t.Parallel() + + c, db := newCompleter(t) + _, err := db.Exec(`UPDATE stores SET enabled = FALSE WHERE id = 'steam'`) + require.NoError(t, err) + + got, _ := c.StoreIDs(&cobra.Command{}, "") + + assert.Empty(t, got) + }) + + t.Run("with no database there is nothing to complete", func(t *testing.T) { + t.Parallel() + + c := Completer{DatabasePath: filepath.Join(t.TempDir(), "nothing.db"), StateDir: t.TempDir()} + + got, directive := c.StoreIDs(&cobra.Command{}, "") + + assert.Empty(t, got) + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + }) +} + +func TestCompleterProfileNames(t *testing.T) { + t.Parallel() + + t.Run("without --game it is the profiles of the active game, the active one first", func(t *testing.T) { + t.Parallel() + + c, db := newCompleter(t) + active := testbuilder.NewGame(t, db).WithName("Active").WithStoreGameID("1").Build() + testbuilder.NewProfile(t, db).WithGame(active).WithName("Hardcore").Build() + other := testbuilder.NewGame(t, db).WithName("Other").WithStoreGameID("2").Build() + testbuilder.NewProfile(t, db).WithGame(other).WithName("Of the other game").Build() + require.NoError(t, state.SaveActiveIn(c.StateDir, state.Active{ActiveGameInstallID: active.ID})) + + got, directive := c.ProfileNames(commandWithGame(t, ""), "") + + assert.Equal(t, []string{"default\tactive", "Hardcore"}, got) + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + }) + + t.Run("with --game it is the profiles of that game", func(t *testing.T) { + t.Parallel() + + c, db := newCompleter(t) + active := testbuilder.NewGame(t, db).WithName("Active").WithStoreGameID("1").Build() + other := testbuilder.NewGame(t, db).WithName("Other").WithStoreGameID("2").Build() + testbuilder.NewProfile(t, db).WithGame(other).WithName("Of the other game").Build() + require.NoError(t, state.SaveActiveIn(c.StateDir, state.Active{ActiveGameInstallID: active.ID})) + + got, _ := c.ProfileNames(commandWithGame(t, "2"), "of") + + assert.Equal(t, []string{"Of the other game"}, got, "and not the one of the active game") + }) + + t.Run("a prefix is matched whatever the case", func(t *testing.T) { + t.Parallel() + + c, db := newCompleter(t) + active := testbuilder.NewGame(t, db).Build() + testbuilder.NewProfile(t, db).WithGame(active).WithName("Hardcore").Build() + require.NoError(t, state.SaveActiveIn(c.StateDir, state.Active{ActiveGameInstallID: active.ID})) + + got, _ := c.ProfileNames(commandWithGame(t, ""), "HARD") + + assert.Equal(t, []string{"Hardcore"}, got) + }) + + t.Run("without an active game, or a --game, there is nothing to complete", func(t *testing.T) { + t.Parallel() + + c, db := newCompleter(t) + testbuilder.NewGame(t, db).Build() + + got, directive := c.ProfileNames(commandWithGame(t, ""), "") + + assert.Empty(t, got) + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + }) + + t.Run("an active selection that can't be read is nothing to complete", func(t *testing.T) { + t.Parallel() + + c, db := newCompleter(t) + testbuilder.NewGame(t, db).Build() + require.NoError(t, os.WriteFile(filepath.Join(c.StateDir, "active.json"), []byte("{ not json"), 0o644)) + + got, directive := c.ProfileNames(commandWithGame(t, ""), "") + + assert.Empty(t, got) + assert.Equal(t, cobra.ShellCompDirectiveNoFileComp, directive) + }) + + t.Run("a --game that is not a game is nothing to complete", func(t *testing.T) { + t.Parallel() + + c, db := newCompleter(t) + active := testbuilder.NewGame(t, db).Build() + require.NoError(t, state.SaveActiveIn(c.StateDir, state.Active{ActiveGameInstallID: active.ID})) + + got, _ := c.ProfileNames(commandWithGame(t, "-5"), "") + + assert.Empty(t, got, "the active game is not what it falls back to") + }) + + t.Run("with no database there is nothing to complete", func(t *testing.T) { + t.Parallel() + + c := Completer{DatabasePath: filepath.Join(t.TempDir(), "nothing.db"), StateDir: t.TempDir()} + + got, _ := c.ProfileNames(commandWithGame(t, "1"), "") + + assert.Empty(t, got) + }) +} diff --git a/internal/completion/completion.go b/internal/completion/completion.go index 67bf660..6ebc853 100644 --- a/internal/completion/completion.go +++ b/internal/completion/completion.go @@ -20,6 +20,22 @@ package completion import "strings" +// A Completer completes what a command is given, from the database and the +// active selection that it is given: what it knows is what it is made with, +// and nothing it can find by itself, so one is made for the place that a test +// has set up as easily as for the one that the user has. +// +// A completion is not allowed to fail loudly, or to fall back to the files of +// the directory it is in: if the database is not there, or the active game is +// not known, there is nothing to complete. +type Completer struct { + // DatabasePath is the file of the database, which is only read. + DatabasePath string + + // StateDir is the directory that the active selection is kept in. + StateDir string +} + func likePrefixPattern(s string) string { // Escape LIKE wildcards so user input is treated literally. // Then append % for prefix match. diff --git a/internal/completion/games.go b/internal/completion/games.go index 39ded45..22bbdec 100644 --- a/internal/completion/games.go +++ b/internal/completion/games.go @@ -31,10 +31,10 @@ import ( // GameInstallSelectors completes "games set-active ". // It returns *full selectors* (always includes #instance) with a description. -func GameInstallSelectors(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { +func (c Completer) GameInstallSelectors(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { ctx := context.Background() - db, err := database.SetupReadOnly() + db, err := database.SetupReadOnlyAt(c.DatabasePath) if err != nil { // No DB (not initialized) or error: don't fall back to file completion. return nil, cobra.ShellCompDirectiveNoFileComp diff --git a/internal/completion/modfileversions.go b/internal/completion/modfileversions.go index 389dc5f..6a4db51 100644 --- a/internal/completion/modfileversions.go +++ b/internal/completion/modfileversions.go @@ -34,9 +34,9 @@ import ( // ModFileVersionIDs completes mod file version IDs for the active (or // --game-scoped) game install. Returns candidates as numeric IDs with // a "Mod Page › File Label (version)" description. -func ModFileVersionIDs(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { +func (c Completer) ModFileVersionIDs(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { ctx := context.Background() - db, err := database.SetupReadOnly() + db, err := database.SetupReadOnlyAt(c.DatabasePath) if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } @@ -44,7 +44,7 @@ func ModFileVersionIDs(cmd *cobra.Command, toComplete string) ([]string, cobra.S q := dbq.New(db) - gi, err := resolveGameInstallForCompletion(cmd, ctx, q) + gi, err := c.resolveGameInstallForCompletion(cmd, ctx, q) if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } @@ -69,9 +69,9 @@ func ModFileVersionIDs(cmd *cobra.Command, toComplete string) ([]string, cobra.S // specific mod page. modPageArg may be a numeric ID or a name; if it is // non-empty and cannot be resolved unambiguously, no completions are returned. // Returns candidates as numeric IDs with a "File Label (version)" description. -func ModFileVersionIDsForPage(cmd *cobra.Command, modPageArg string, toComplete string) ([]string, cobra.ShellCompDirective) { +func (c Completer) ModFileVersionIDsForPage(cmd *cobra.Command, modPageArg string, toComplete string) ([]string, cobra.ShellCompDirective) { ctx := context.Background() - db, err := database.SetupReadOnly() + db, err := database.SetupReadOnlyAt(c.DatabasePath) if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } @@ -79,7 +79,7 @@ func ModFileVersionIDsForPage(cmd *cobra.Command, modPageArg string, toComplete q := dbq.New(db) - gi, err := resolveGameInstallForCompletion(cmd, ctx, q) + gi, err := c.resolveGameInstallForCompletion(cmd, ctx, q) if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } @@ -142,12 +142,12 @@ func ModFileVersionIDsForPage(cmd *cobra.Command, modPageArg string, toComplete // resolveGameInstallForCompletion resolves the game install for a completion // function, preferring the --game flag if set, otherwise falling back to the // active selection. -func resolveGameInstallForCompletion(cmd *cobra.Command, ctx context.Context, q *dbq.Queries) (dbq.GameInstall, error) { +func (c Completer) resolveGameInstallForCompletion(cmd *cobra.Command, ctx context.Context, q *dbq.Queries) (dbq.GameInstall, error) { gameArg := "" if f := cmd.Flags().Lookup("game"); f != nil && f.Changed { gameArg = f.Value.String() } else { - active, err := state.LoadActive() + active, err := state.LoadActiveIn(c.StateDir) if err != nil || active.ActiveGameInstallID <= 0 { return dbq.GameInstall{}, fmt.Errorf("no active game") } diff --git a/internal/completion/modpages.go b/internal/completion/modpages.go index 25c1355..49d5e54 100644 --- a/internal/completion/modpages.go +++ b/internal/completion/modpages.go @@ -32,9 +32,9 @@ import ( // ModPageIDs completes mod page IDs for the active (or --game-scoped) game // install. Returns candidates as numeric IDs with the mod page name and // source kind as the description. -func ModPageIDs(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { +func (c Completer) ModPageIDs(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { ctx := context.Background() - db, err := database.SetupReadOnly() + db, err := database.SetupReadOnlyAt(c.DatabasePath) if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } @@ -48,7 +48,7 @@ func ModPageIDs(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCom } gameID = v } else { - active, err := state.LoadActive() + active, err := state.LoadActiveIn(c.StateDir) if err != nil || active.ActiveGameInstallID <= 0 { return nil, cobra.ShellCompDirectiveNoFileComp } diff --git a/internal/completion/profiles.go b/internal/completion/profiles.go index 43f7aec..cad59dc 100644 --- a/internal/completion/profiles.go +++ b/internal/completion/profiles.go @@ -40,10 +40,10 @@ import ( // update here to use cmd.InheritedFlags() instead of cmd.Flags(). // // Returns candidates in "name\t(active)" format. -func ProfileNames(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { +func (c Completer) ProfileNames(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { ctx := context.Background() - db, err := database.SetupReadOnly() + db, err := database.SetupReadOnlyAt(c.DatabasePath) if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } @@ -58,7 +58,7 @@ func ProfileNames(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellC } gameID = v } else { - active, err := state.LoadActive() + active, err := state.LoadActiveIn(c.StateDir) if err != nil || active.ActiveGameInstallID <= 0 { return nil, cobra.ShellCompDirectiveNoFileComp } diff --git a/internal/completion/store.go b/internal/completion/store.go index b692595..c336cdb 100644 --- a/internal/completion/store.go +++ b/internal/completion/store.go @@ -30,10 +30,10 @@ import ( // StoreIDs completes (enabled) store IDs // Returns candidates in "id\tDisplay Name" format. -func StoreIDs(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { +func (c Completer) StoreIDs(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { ctx := context.Background() - db, err := database.SetupReadOnly() + db, err := database.SetupReadOnlyAt(c.DatabasePath) if err != nil { // No DB (not initialized) or error: don't fall back to file completion. return nil, cobra.ShellCompDirectiveNoFileComp diff --git a/internal/completion/targets.go b/internal/completion/targets.go index 354f240..fe67828 100644 --- a/internal/completion/targets.go +++ b/internal/completion/targets.go @@ -35,9 +35,9 @@ import ( // // Returns candidates in "name\troot_path" format so the user can see where // each target points without having to run a separate command. -func TargetNames(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { +func (c Completer) TargetNames(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { ctx := context.Background() - db, err := database.SetupReadOnly() + db, err := database.SetupReadOnlyAt(c.DatabasePath) if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } @@ -51,7 +51,7 @@ func TargetNames(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCo } gameID = v } else { - active, err := state.LoadActive() + active, err := state.LoadActiveIn(c.StateDir) if err != nil || active.ActiveGameInstallID <= 0 { return nil, cobra.ShellCompDirectiveNoFileComp } diff --git a/internal/completion/transitional.go b/internal/completion/transitional.go new file mode 100644 index 0000000..6ad0fd2 --- /dev/null +++ b/internal/completion/transitional.go @@ -0,0 +1,66 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package completion + +import ( + "github.com/mfinelli/modctl/internal/state" + "github.com/spf13/cobra" + "github.com/spf13/viper" +) + +// Transitional: the functions in this file are the completions of a Completer +// that is made with the global configuration and the environment, at the time +// that a completion is asked for (the commands are set up before the +// configuration is read, so it can't be before). They are here for the commands +// that do not have a Completer of their own yet, and go with them. +// configured is the only thing in this package that reads either. +func configured() Completer { + return Completer{ + DatabasePath: viper.GetString("database"), + StateDir: state.DefaultDir(), + } +} + +func GameInstallSelectors(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { + return configured().GameInstallSelectors(cmd, toComplete) +} + +func ModFileVersionIDs(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { + return configured().ModFileVersionIDs(cmd, toComplete) +} + +func ModFileVersionIDsForPage(cmd *cobra.Command, modPageArg string, toComplete string) ([]string, cobra.ShellCompDirective) { + return configured().ModFileVersionIDsForPage(cmd, modPageArg, toComplete) +} + +func ModPageIDs(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { + return configured().ModPageIDs(cmd, toComplete) +} + +func ProfileNames(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { + return configured().ProfileNames(cmd, toComplete) +} + +func StoreIDs(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { + return configured().StoreIDs(cmd, toComplete) +} + +func TargetNames(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { + return configured().TargetNames(cmd, toComplete) +} diff --git a/internal/state/active.go b/internal/state/active.go index bcf4fbe..08c7089 100644 --- a/internal/state/active.go +++ b/internal/state/active.go @@ -28,6 +28,10 @@ import ( "github.com/adrg/xdg" ) +// activeFilename is the file, in the state directory, that has the active +// selection. +const activeFilename = "active.json" + type Active struct { ActiveStoreID string `json:"active_store_id,omitempty"` ActiveGameInstallID int64 `json:"active_game_install_id,omitempty"` @@ -35,11 +39,11 @@ type Active struct { UpdatedAt string `json:"updated_at,omitempty"` } -func LoadActive() (Active, error) { - p, err := xdg.StateFile(filepath.Join("modctl", "active.json")) - if err != nil { - return Active{}, err - } +// LoadActiveIn reads the active selection from the state directory dir. There +// is none until one is saved, which is an empty Active and not an error, and +// nothing is made by looking: not the directory, and not the file. +func LoadActiveIn(dir string) (Active, error) { + p := filepath.Join(dir, activeFilename) b, err := os.ReadFile(p) if err != nil { @@ -56,11 +60,13 @@ func LoadActive() (Active, error) { return a, nil } -func SaveActive(a Active) error { - p, err := xdg.StateFile(filepath.Join("modctl", "active.json")) - if err != nil { - return err +// SaveActiveIn writes the active selection a to the state directory dir, which +// is made if it is not there. UpdatedAt is set to now, whatever a has in it. +func SaveActiveIn(dir string, a Active) error { + if err := os.MkdirAll(dir, 0o700); err != nil { + return fmt.Errorf("make %s: %w", dir, err) } + p := filepath.Join(dir, activeFilename) a.UpdatedAt = time.Now().UTC().Format("2006-01-02T15:04:05.000Z") @@ -83,3 +89,28 @@ func SaveActive(a Active) error { return nil } + +// DefaultDir is the state directory of the user: where the active selection is +// kept, and what LoadActive and SaveActive use. +// +// Transitional: it reads the environment, which nothing in this package or in +// the ones that use it may do. It is only here for the callers that do not +// have the state directory of their own to give to LoadActiveIn and +// SaveActiveIn yet, and goes with them. +func DefaultDir() string { + return filepath.Join(xdg.StateHome, "modctl") +} + +// LoadActive is LoadActiveIn for the state directory of the user. +// +// Transitional: see DefaultDir. +func LoadActive() (Active, error) { + return LoadActiveIn(DefaultDir()) +} + +// SaveActive is SaveActiveIn for the state directory of the user. +// +// Transitional: see DefaultDir. +func SaveActive(a Active) error { + return SaveActiveIn(DefaultDir(), a) +} diff --git a/internal/state/active_test.go b/internal/state/active_test.go new file mode 100644 index 0000000..bbe1fc4 --- /dev/null +++ b/internal/state/active_test.go @@ -0,0 +1,203 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package state + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadActiveIn(t *testing.T) { + t.Parallel() + + t.Run("there is none until one is saved", func(t *testing.T) { + t.Parallel() + + got, err := LoadActiveIn(t.TempDir()) + + require.NoError(t, err) + assert.Equal(t, Active{}, got) + }) + + t.Run("looking does not make anything", func(t *testing.T) { + t.Parallel() + + dir := filepath.Join(t.TempDir(), "not", "there", "yet") + + got, err := LoadActiveIn(dir) + + require.NoError(t, err) + assert.Equal(t, Active{}, got) + assert.NoDirExists(t, filepath.Join(dir, ".."), "not the directory, or any of the ones that lead to it") + assert.NoDirExists(t, dir) + }) + + t.Run("what is saved is what is read", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + saved := Active{ + ActiveStoreID: "steam", + ActiveGameInstallID: 7, + ActiveGameInstallSelector: "steam:123#default", + } + require.NoError(t, SaveActiveIn(dir, saved)) + + got, err := LoadActiveIn(dir) + + require.NoError(t, err) + saved.UpdatedAt = got.UpdatedAt // which is when it was saved + assert.Equal(t, saved, got) + assert.NotEmpty(t, got.UpdatedAt) + }) + + t.Run("a file that is not json is an error that says which", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "active.json") + require.NoError(t, os.WriteFile(path, []byte("{ not json"), 0o644)) + + _, err := LoadActiveIn(dir) + + require.Error(t, err) + assert.Contains(t, err.Error(), "parse "+path) + }) + + t.Run("a file that can't be read is an error that says which", func(t *testing.T) { + t.Parallel() + + // a directory where the file should be is not something that can be read + dir := t.TempDir() + path := filepath.Join(dir, "active.json") + require.NoError(t, os.Mkdir(path, 0o755)) + + _, err := LoadActiveIn(dir) + + require.Error(t, err) + assert.Contains(t, err.Error(), "read "+path) + }) + + t.Run("each directory has its own selection", func(t *testing.T) { + t.Parallel() + + first, second := t.TempDir(), t.TempDir() + require.NoError(t, SaveActiveIn(first, Active{ActiveGameInstallID: 1})) + require.NoError(t, SaveActiveIn(second, Active{ActiveGameInstallID: 2})) + + a, err := LoadActiveIn(first) + require.NoError(t, err) + b, err := LoadActiveIn(second) + require.NoError(t, err) + + assert.Equal(t, int64(1), a.ActiveGameInstallID) + assert.Equal(t, int64(2), b.ActiveGameInstallID) + }) +} + +func TestSaveActiveIn(t *testing.T) { + t.Parallel() + + t.Run("the directory is made if it is not there", func(t *testing.T) { + t.Parallel() + + dir := filepath.Join(t.TempDir(), "state", "modctl") + + require.NoError(t, SaveActiveIn(dir, Active{ActiveGameInstallID: 3})) + + assert.FileExists(t, filepath.Join(dir, "active.json")) + }) + + t.Run("it is saved over what was there", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, SaveActiveIn(dir, Active{ActiveGameInstallID: 1, ActiveStoreID: "steam"})) + + require.NoError(t, SaveActiveIn(dir, Active{ActiveGameInstallID: 2})) + + got, err := LoadActiveIn(dir) + require.NoError(t, err) + assert.Equal(t, int64(2), got.ActiveGameInstallID) + assert.Empty(t, got.ActiveStoreID, "what is not in it any more is gone") + }) + + t.Run("it is saved with when it was, whatever it is given", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + before := time.Now().UTC().Add(-time.Second) + + require.NoError(t, SaveActiveIn(dir, Active{UpdatedAt: "1999-01-01T00:00:00.000Z"})) + + got, err := LoadActiveIn(dir) + require.NoError(t, err) + at, err := time.Parse("2006-01-02T15:04:05.000Z", got.UpdatedAt) + require.NoError(t, err) + assert.True(t, at.After(before), "it is now and not 1999: %s", got.UpdatedAt) + }) + + t.Run("what is written is readable, and ends with a newline", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, SaveActiveIn(dir, Active{ActiveGameInstallID: 7})) + + b, err := os.ReadFile(filepath.Join(dir, "active.json")) + + require.NoError(t, err) + assert.Contains(t, string(b), "\n \"active_game_install_id\": 7") + assert.NotContains(t, string(b), "active_store_id", "what is empty is left out") + assert.Equal(t, byte('\n'), b[len(b)-1]) + }) + + t.Run("nothing but the file is left in the directory", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + require.NoError(t, SaveActiveIn(dir, Active{ActiveGameInstallID: 7})) + require.NoError(t, SaveActiveIn(dir, Active{ActiveGameInstallID: 8})) + + entries, err := os.ReadDir(dir) + require.NoError(t, err) + require.Len(t, entries, 1, "not the file that it is written to first") + assert.Equal(t, "active.json", entries[0].Name()) + }) + + t.Run("a directory that can't be made is an error that says which", func(t *testing.T) { + t.Parallel() + + // there is a file where the directory should be + parent := t.TempDir() + blocker := filepath.Join(parent, "state") + require.NoError(t, os.WriteFile(blocker, nil, 0o644)) + dir := filepath.Join(blocker, "modctl") + + err := SaveActiveIn(dir, Active{ActiveGameInstallID: 1}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "make "+dir) + }) +} diff --git a/internal/testbuilder/testbuilder.go b/internal/testbuilder/testbuilder.go index c300301..df7aa29 100644 --- a/internal/testbuilder/testbuilder.go +++ b/internal/testbuilder/testbuilder.go @@ -53,6 +53,26 @@ func SetupDB(t *testing.T) *sql.DB { return db } +// SetupDBFile is SetupDB for a database in a file, which is what something that +// opens its database by its path needs (a database that is not in memory is +// one for every connection, which is what lets the code that is tested open it +// for itself). It returns the database, for what the test puts in it, and the +// path to it. The file is in a directory of the test, and the database is +// closed when the test completes. +func SetupDBFile(t *testing.T) (*sql.DB, string) { + t.Helper() + + path := filepath.Join(t.TempDir(), "modctl.db") + db, err := sql.Open("sqlite3", database.DSN(path, false)) + require.NoError(t, err, "open sqlite file") + t.Cleanup(func() { db.Close() }) + + _, err = NewProvider(t, db).Up(context.Background()) + require.NoError(t, err, "run migrations") + + return db, path +} + // NewProvider returns a goose provider for db that reads migrations from the // filesystem, for tests that need to control how far a database is migrated. func NewProvider(t *testing.T, db *sql.DB) *goose.Provider { From 9825661b28ad73d8b3a0758b02aa05150c900df6 Mon Sep 17 00:00:00 2001 From: Mario Finelli Date: Mon, 12 Oct 2026 23:19:03 +0200 Subject: [PATCH 04/14] Switch to use new path functions --- cmd/apply.go | 9 ++- cmd/doctor.go | 12 ++-- cmd/env.go | 71 +++++++++++++++++++++++ cmd/export.go | 6 +- cmd/games_backups_delete.go | 10 ++-- cmd/games_backups_diff.go | 10 ++-- cmd/games_backups_list.go | 11 ++-- cmd/games_backups_restore.go | 10 ++-- cmd/games_backups_view.go | 10 ++-- cmd/games_info.go | 8 +-- cmd/games_list.go | 8 +-- cmd/games_notes_clear.go | 9 +-- cmd/games_notes_set.go | 9 +-- cmd/games_refresh.go | 3 +- cmd/games_setActive.go | 10 ++-- cmd/games_targets_add.go | 11 ++-- cmd/games_targets_disable.go | 11 ++-- cmd/games_targets_enable.go | 11 ++-- cmd/games_targets_list.go | 9 +-- cmd/games_targets_remove.go | 11 ++-- cmd/gc.go | 2 +- cmd/import.go | 6 +- cmd/init.go | 2 +- cmd/mods_import.go | 9 +-- cmd/mods_incompatible_add.go | 9 ++- cmd/mods_incompatible_list.go | 9 +-- cmd/mods_incompatible_remove.go | 10 ++-- cmd/mods_info.go | 10 ++-- cmd/mods_list.go | 9 +-- cmd/mods_nexus_checkUpdates.go | 9 +-- cmd/mods_nexus_link.go | 9 ++- cmd/mods_nexus_unlink.go | 10 ++-- cmd/mods_notes_clear.go | 10 ++-- cmd/mods_notes_set.go | 10 ++-- cmd/mods_remove.go | 12 ++-- cmd/mods_scanInventory.go | 3 +- cmd/operations_list.go | 9 +-- cmd/operations_show.go | 3 +- cmd/profiles_add.go | 13 ++--- cmd/profiles_create.go | 8 +-- cmd/profiles_delete.go | 10 ++-- cmd/profiles_deploys_skipBackup_add.go | 11 ++-- cmd/profiles_deploys_skipBackup_copy.go | 12 ++-- cmd/profiles_deploys_skipBackup_list.go | 12 ++-- cmd/profiles_deploys_skipBackup_remove.go | 12 ++-- cmd/profiles_deploys_writeOnce_add.go | 11 ++-- cmd/profiles_deploys_writeOnce_copy.go | 12 ++-- cmd/profiles_deploys_writeOnce_list.go | 12 ++-- cmd/profiles_deploys_writeOnce_remove.go | 12 ++-- cmd/profiles_diff.go | 10 ++-- cmd/profiles_disable.go | 12 ++-- cmd/profiles_enable.go | 12 ++-- cmd/profiles_list.go | 9 +-- cmd/profiles_order_compact.go | 9 ++- cmd/profiles_order_move.go | 13 ++--- cmd/profiles_order_set.go | 11 ++-- cmd/profiles_order_swap.go | 11 ++-- cmd/profiles_overrides_copy.go | 11 ++-- cmd/profiles_overrides_edit.go | 9 ++- cmd/profiles_overrides_list.go | 10 ++-- cmd/profiles_overrides_patch_list.go | 10 ++-- cmd/profiles_overrides_patch_preview.go | 10 ++-- cmd/profiles_overrides_patch_remove.go | 9 ++- cmd/profiles_overrides_patch_set.go | 9 ++- cmd/profiles_overrides_patch_unset.go | 9 ++- cmd/profiles_overrides_set.go | 9 ++- cmd/profiles_overrides_status.go | 10 ++-- cmd/profiles_overrides_unset.go | 10 ++-- cmd/profiles_preview.go | 12 ++-- cmd/profiles_remap_add.go | 12 ++-- cmd/profiles_remap_clear.go | 12 ++-- cmd/profiles_remap_copy.go | 12 ++-- cmd/profiles_remap_list.go | 12 ++-- cmd/profiles_remap_preview.go | 12 ++-- cmd/profiles_remap_remove.go | 12 ++-- cmd/profiles_remove.go | 11 ++-- cmd/profiles_rename.go | 10 ++-- cmd/profiles_setActive.go | 10 ++-- cmd/profiles_status.go | 10 ++-- cmd/profiles_upgrade.go | 13 ++--- cmd/root.go | 3 +- cmd/status.go | 3 +- cmd/stores_list.go | 3 +- cmd/stores_setActive.go | 10 ++-- cmd/unapply.go | 8 +-- internal/argresolver/game.go | 13 +---- internal/argresolver/game_test.go | 30 +++++----- internal/completion/completer_test.go | 8 +-- internal/completion/games.go | 2 +- internal/completion/modfileversions.go | 6 +- internal/completion/modpages.go | 4 +- internal/completion/profiles.go | 4 +- internal/completion/store.go | 2 +- internal/completion/targets.go | 4 +- internal/completion/transitional.go | 66 --------------------- internal/database/database.go | 56 ++++-------------- internal/database/database_test.go | 16 ++--- internal/database/open_test.go | 2 +- internal/restore/full.go | 2 +- internal/state/active.go | 35 ++--------- internal/state/active_test.go | 44 +++++++------- 101 files changed, 478 insertions(+), 689 deletions(-) create mode 100644 cmd/env.go delete mode 100644 internal/completion/transitional.go diff --git a/cmd/apply.go b/cmd/apply.go index 8381a14..142306b 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -30,7 +30,6 @@ import ( "github.com/mfinelli/modctl/internal" "github.com/mfinelli/modctl/internal/argresolver" "github.com/mfinelli/modctl/internal/blobstore" - "github.com/mfinelli/modctl/internal/completion" "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/extractor" "github.com/mfinelli/modctl/internal/lock" @@ -77,7 +76,7 @@ Use --dry-run to preview the plan without making any changes. Add the RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -86,7 +85,7 @@ Use --dry-run to preview the plan without making any changes. Add the q := dbq.New(db) // Resolve game install - gi, err := argresolver.GameInstall(ctx, q, applyGame) + gi, err := gameInstall(ctx, q, applyGame) if err != nil { return err } @@ -510,14 +509,14 @@ func init() { "Override the currently active game") applyCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) applyCmd.Flags().StringVarP(&applyProfile, "profile", "p", "", "Override the currently active profile") applyCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) applyCmd.Flags().BoolVar(&applyDryRun, "dry-run", false, diff --git a/cmd/doctor.go b/cmd/doctor.go index ec869fb..440b87a 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -126,11 +126,11 @@ func init() { // are pending. Returns error only for non-recoverable failures. func checkDb(ctx context.Context) error { style.Println(style.Header.Render("Database Checks")) - style.Println(style.Subtle.Render(" db: " + viper.GetString("database"))) + style.Println(style.Subtle.Render(" db: " + databasePath())) style.Println() // 1) DB file existence - dbPath := viper.GetString("database") + dbPath := databasePath() info, err := os.Stat(dbPath) if err != nil { if errors.Is(err, os.ErrNotExist) { @@ -156,7 +156,7 @@ func checkDb(ctx context.Context) error { defer cancel() // 2) Open DB + trivial query - db, err := database.Setup() + db, err := database.Setup(databasePath()) if err != nil { style.Println(style.Failure.Render(" ✗ could not open database")) style.Println(style.Subtle.Render(" " + err.Error())) @@ -417,7 +417,7 @@ func checkGameInstalls(ctx context.Context) error { style.Println(style.Header.Render("Game Install Checks")) style.Println() - db, err := database.Setup() + db, err := database.Setup(databasePath()) if err != nil { style.Println(style.Failure.Render(" ✗ could not open database")) style.Println(style.Subtle.Render(" " + err.Error())) @@ -495,7 +495,7 @@ func checkBlobs(ctx context.Context) error { style.Println(style.Subtle.Render(" overrides: " + viper.GetString("overrides_dir"))) style.Println() - db, err := database.Setup() + db, err := database.Setup(databasePath()) if err != nil { style.Println(style.Failure.Render(" ✗ could not open database")) style.Println(style.Subtle.Render(" " + err.Error())) @@ -712,7 +712,7 @@ func checkInstalledFiles(ctx context.Context) error { style.Println(style.Header.Render("Installed File Checks")) style.Println() - db, err := database.Setup() + db, err := database.Setup(databasePath()) if err != nil { style.Println(style.Failure.Render(" ✗ could not open database")) style.Println(style.Subtle.Render(" " + err.Error())) diff --git a/cmd/env.go b/cmd/env.go new file mode 100644 index 0000000..6a4cedd --- /dev/null +++ b/cmd/env.go @@ -0,0 +1,71 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package cmd + +import ( + "context" + "database/sql" + "path/filepath" + + "github.com/adrg/xdg" + "github.com/mfinelli/modctl/dbq" + "github.com/mfinelli/modctl/internal/argresolver" + "github.com/mfinelli/modctl/internal/completion" + "github.com/mfinelli/modctl/internal/database" + "github.com/spf13/viper" +) + +// The packages in internal/ are given what they work on: they don't look for +// the database, or the directory of the state, by themselves. This is where the +// commands get it from, for what they do all the time, so that a command +// doesn't have to know where it is (and can't have it wrong). + +// databasePath is the file of the database that is configured. +func databasePath() string { + return viper.GetString("database") +} + +// stateDir is the directory that modctl keeps its state in, which is the +// active selection and what has to be there while something is being done (the +// locks, in a directory of their own). +func stateDir() string { + return filepath.Join(xdg.StateHome, "modctl") +} + +// openDB opens the database that is configured, which has to be there (it is +// made by init) and is migrated to the latest schema. The caller closes it. +func openDB(ctx context.Context) (*sql.DB, error) { + return database.Open(ctx, databasePath()) +} + +// gameInstall resolves the game install that a command is for, from what it was +// given (the --game flag, which can be left out for the active game). +func gameInstall(ctx context.Context, q *dbq.Queries, arg string) (dbq.GameInstall, error) { + return argresolver.GameInstall(ctx, q, stateDir(), arg) +} + +// completer is what completes the arguments of the commands, for the database +// and the state that are configured. It is made when a completion is asked +// for, since the commands are set up before the configuration is read. +func completer() completion.Completer { + return completion.Completer{ + DatabasePath: databasePath(), + StateDir: stateDir(), + } +} diff --git a/cmd/export.go b/cmd/export.go index 47659ea..a5e5ec2 100644 --- a/cmd/export.go +++ b/cmd/export.go @@ -27,8 +27,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/argresolver" "github.com/mfinelli/modctl/internal/blobstore" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/exporter" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" @@ -65,7 +63,7 @@ Examples: RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -181,7 +179,7 @@ func init() { "Export only data for this game install") exportCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) exportCmd.Flags().StringVarP(&exportOutput, "output", "o", "", diff --git a/cmd/games_backups_delete.go b/cmd/games_backups_delete.go index c992b8e..dd02fef 100644 --- a/cmd/games_backups_delete.go +++ b/cmd/games_backups_delete.go @@ -23,8 +23,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" ) @@ -54,7 +52,7 @@ not immediately removed from disk; run 'modctl gc' to reclaim space.`, return err } - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -62,7 +60,7 @@ not immediately removed from disk; run 'modctl gc' to reclaim space.`, q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, gamesBackupsDeleteGame) + gi, err := gameInstall(ctx, q, gamesBackupsDeleteGame) if err != nil { return err } @@ -109,12 +107,12 @@ func init() { "Override the currently active game") gamesBackupsDeleteCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) gamesBackupsDeleteCmd.Flags().StringVarP(&gamesBackupsDeleteTarget, "target", "t", "", "Install target (default: game_dir)") gamesBackupsDeleteCmd.RegisterFlagCompletionFunc("target", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.TargetNames(cmd, toComplete) + return completer().TargetNames(cmd, toComplete) }) } diff --git a/cmd/games_backups_diff.go b/cmd/games_backups_diff.go index 26a24cf..51c1867 100644 --- a/cmd/games_backups_diff.go +++ b/cmd/games_backups_diff.go @@ -28,8 +28,6 @@ import ( "github.com/mfinelli/modctl/internal" "github.com/mfinelli/modctl/internal/argresolver" "github.com/mfinelli/modctl/internal/blobstore" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/fsutil" "github.com/mfinelli/modctl/internal/style" "github.com/pmezard/go-difflib/difflib" @@ -67,7 +65,7 @@ is passed.`, return err } - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -75,7 +73,7 @@ is passed.`, q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, gamesBackupsDiffGame) + gi, err := gameInstall(ctx, q, gamesBackupsDiffGame) if err != nil { return err } @@ -198,13 +196,13 @@ func init() { "Override the currently active game") gamesBackupsDiffCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) gamesBackupsDiffCmd.Flags().StringVarP(&gamesBackupsDiffTarget, "target", "t", "", "Install target (default: game_dir)") gamesBackupsDiffCmd.RegisterFlagCompletionFunc("target", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.TargetNames(cmd, toComplete) + return completer().TargetNames(cmd, toComplete) }) gamesBackupsDiffCmd.Flags().BoolVar(&gamesBackupsDiffForce, "force", false, diff --git a/cmd/games_backups_list.go b/cmd/games_backups_list.go index 92e5da4..52299bf 100644 --- a/cmd/games_backups_list.go +++ b/cmd/games_backups_list.go @@ -22,9 +22,6 @@ import ( "fmt" "github.com/mfinelli/modctl/dbq" - "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" ) @@ -46,7 +43,7 @@ not install. They are restored automatically on unapply.`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -54,7 +51,7 @@ not install. They are restored automatically on unapply.`, q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, gamesBackupsListGame) + gi, err := gameInstall(ctx, q, gamesBackupsListGame) if err != nil { return err } @@ -99,12 +96,12 @@ func init() { "Override the currently active game") gamesBackupsListCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) gamesBackupsListCmd.Flags().StringVarP(&gamesBackupsListTarget, "target", "t", "", "Filter by install target (default: all targets)") gamesBackupsListCmd.RegisterFlagCompletionFunc("target", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.TargetNames(cmd, toComplete) + return completer().TargetNames(cmd, toComplete) }) } diff --git a/cmd/games_backups_restore.go b/cmd/games_backups_restore.go index d95cc06..f1fc1a2 100644 --- a/cmd/games_backups_restore.go +++ b/cmd/games_backups_restore.go @@ -26,8 +26,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/argresolver" "github.com/mfinelli/modctl/internal/blobstore" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/fsutil" "github.com/mfinelli/modctl/internal/planner" "github.com/mfinelli/modctl/internal/style" @@ -66,7 +64,7 @@ that can't be read to check it.`, return err } - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -74,7 +72,7 @@ that can't be read to check it.`, q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, gamesBackupsRestoreGame) + gi, err := gameInstall(ctx, q, gamesBackupsRestoreGame) if err != nil { return err } @@ -183,13 +181,13 @@ func init() { "Override the currently active game") gamesBackupsRestoreCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) gamesBackupsRestoreCmd.Flags().StringVarP(&gamesBackupsRestoreTarget, "target", "t", "", "Install target (default: game_dir)") gamesBackupsRestoreCmd.RegisterFlagCompletionFunc("target", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.TargetNames(cmd, toComplete) + return completer().TargetNames(cmd, toComplete) }) gamesBackupsRestoreCmd.Flags().BoolVar(&gamesBackupsRestoreForce, "force", false, diff --git a/cmd/games_backups_view.go b/cmd/games_backups_view.go index b5a534e..785f50a 100644 --- a/cmd/games_backups_view.go +++ b/cmd/games_backups_view.go @@ -26,8 +26,6 @@ import ( "github.com/mfinelli/modctl/internal" "github.com/mfinelli/modctl/internal/argresolver" "github.com/mfinelli/modctl/internal/blobstore" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -57,7 +55,7 @@ Binary files are detected automatically and refused unless --force is passed.`, return err } - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -65,7 +63,7 @@ Binary files are detected automatically and refused unless --force is passed.`, q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, gamesBackupsViewGame) + gi, err := gameInstall(ctx, q, gamesBackupsViewGame) if err != nil { return err } @@ -127,13 +125,13 @@ func init() { "Override the currently active game") gamesBackupsViewCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) gamesBackupsViewCmd.Flags().StringVarP(&gamesBackupsViewTarget, "target", "t", "", "Install target (default: game_dir)") gamesBackupsViewCmd.RegisterFlagCompletionFunc("target", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.TargetNames(cmd, toComplete) + return completer().TargetNames(cmd, toComplete) }) gamesBackupsViewCmd.Flags().BoolVar(&gamesBackupsViewForce, "force", false, "Print binary files without refusing") diff --git a/cmd/games_info.go b/cmd/games_info.go index e1b5288..4a4a403 100644 --- a/cmd/games_info.go +++ b/cmd/games_info.go @@ -25,8 +25,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/state" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" @@ -51,12 +49,12 @@ provided.`, if len(args) != 0 { return nil, cobra.ShellCompDirectiveNoFileComp } - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -78,7 +76,7 @@ provided.`, return fmt.Errorf("list profiles: %w", err) } - a, err := state.LoadActive() + a, err := state.LoadActive(stateDir()) if err != nil { return err } diff --git a/cmd/games_list.go b/cmd/games_list.go index 51e8bab..b56e2ef 100644 --- a/cmd/games_list.go +++ b/cmd/games_list.go @@ -23,8 +23,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/state" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" @@ -48,7 +46,7 @@ The active game install (if any) is marked with an asterisk (*).`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -56,7 +54,7 @@ The active game install (if any) is marked with an asterisk (*).`, // the active selection is what is marked in the list (and, without // --all or --store, what decides which store to list) - active, err := state.LoadActive() + active, err := state.LoadActive(stateDir()) if err != nil { return fmt.Errorf("load active selection: %w", err) } @@ -125,7 +123,7 @@ func init() { "List games from the given store") gamesListCmd.RegisterFlagCompletionFunc("store", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.StoreIDs(cmd, toComplete) + return completer().StoreIDs(cmd, toComplete) }) gamesListCmd.MarkFlagsMutuallyExclusive("all", "store") diff --git a/cmd/games_notes_clear.go b/cmd/games_notes_clear.go index 5b8a205..4f879ad 100644 --- a/cmd/games_notes_clear.go +++ b/cmd/games_notes_clear.go @@ -20,9 +20,6 @@ package cmd import ( "github.com/mfinelli/modctl/dbq" - "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/spf13/cobra" ) @@ -36,7 +33,7 @@ var gamesNotesClearCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -44,7 +41,7 @@ var gamesNotesClearCmd = &cobra.Command{ q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, gamesNotesClearGame) + gi, err := gameInstall(ctx, q, gamesNotesClearGame) if err != nil { return err } @@ -60,6 +57,6 @@ func init() { "Override the currently active game") gamesNotesClearCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) } diff --git a/cmd/games_notes_set.go b/cmd/games_notes_set.go index 3e528c8..96e223f 100644 --- a/cmd/games_notes_set.go +++ b/cmd/games_notes_set.go @@ -26,9 +26,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal" - "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/spf13/cobra" ) @@ -48,7 +45,7 @@ Pass - as the argument to read note text from stdin: RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -56,7 +53,7 @@ Pass - as the argument to read note text from stdin: q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, gamesNotesSetGame) + gi, err := gameInstall(ctx, q, gamesNotesSetGame) if err != nil { return err } @@ -84,6 +81,6 @@ func init() { "Override the currently active game") gamesNotesSetCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) } diff --git a/cmd/games_refresh.go b/cmd/games_refresh.go index a4af173..6b95f7b 100644 --- a/cmd/games_refresh.go +++ b/cmd/games_refresh.go @@ -22,7 +22,6 @@ import ( "fmt" "github.com/mfinelli/modctl/internal" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" ) @@ -42,7 +41,7 @@ It is safe to run multiple times.`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } diff --git a/cmd/games_setActive.go b/cmd/games_setActive.go index 97ba04c..c35f3b0 100644 --- a/cmd/games_setActive.go +++ b/cmd/games_setActive.go @@ -22,8 +22,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/state" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" @@ -47,12 +45,12 @@ desired instance explicitly.`, if len(args) != 0 { return nil, cobra.ShellCompDirectiveNoFileComp } - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -73,7 +71,7 @@ func init() { } func persistActiveGameInstall(gi dbq.GameInstall) error { - a, err := state.LoadActive() + a, err := state.LoadActive(stateDir()) if err != nil { return err } @@ -84,7 +82,7 @@ func persistActiveGameInstall(gi dbq.GameInstall) error { a.ActiveGameInstallID = gi.ID a.ActiveGameInstallSelector = fullSel - if err := state.SaveActive(a); err != nil { + if err := state.SaveActive(stateDir(), a); err != nil { return err } diff --git a/cmd/games_targets_add.go b/cmd/games_targets_add.go index 1c5744d..357629f 100644 --- a/cmd/games_targets_add.go +++ b/cmd/games_targets_add.go @@ -25,9 +25,6 @@ import ( "path/filepath" "github.com/mfinelli/modctl/dbq" - "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" ) @@ -59,7 +56,7 @@ moves later, this target will not be updated automatically.`, name := args[0] rawPath := args[1] - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -67,7 +64,7 @@ moves later, this target will not be updated automatically.`, q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, gamesTargetsAddGame) + gi, err := gameInstall(ctx, q, gamesTargetsAddGame) if err != nil { return err } @@ -114,13 +111,13 @@ func init() { "Override the currently active game") gamesTargetsAddCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) gamesTargetsAddCmd.Flags().StringVar(&gamesTargetsAddRelTo, "relative-to", "", "Name of an existing target to resolve the path relative to") gamesTargetsAddCmd.RegisterFlagCompletionFunc("relative-to", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.TargetNames(cmd, toComplete) + return completer().TargetNames(cmd, toComplete) }) } diff --git a/cmd/games_targets_disable.go b/cmd/games_targets_disable.go index e702bb5..dab851b 100644 --- a/cmd/games_targets_disable.go +++ b/cmd/games_targets_disable.go @@ -25,9 +25,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal" - "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/lock" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" @@ -53,7 +50,7 @@ the only enabled target.`, if len(args) != 0 { return nil, cobra.ShellCompDirectiveNoFileComp } - return completion.TargetNames(cmd, toComplete) + return completer().TargetNames(cmd, toComplete) }, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -61,7 +58,7 @@ the only enabled target.`, name := args[0] - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -69,7 +66,7 @@ the only enabled target.`, q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, gamesTargetsDisableGame) + gi, err := gameInstall(ctx, q, gamesTargetsDisableGame) if err != nil { return err } @@ -144,6 +141,6 @@ func init() { "Override the currently active game") gamesTargetsDisableCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) } diff --git a/cmd/games_targets_enable.go b/cmd/games_targets_enable.go index fdbf979..120f8b9 100644 --- a/cmd/games_targets_enable.go +++ b/cmd/games_targets_enable.go @@ -25,9 +25,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal" - "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" ) @@ -43,7 +40,7 @@ var gamesTargetsEnableCmd = &cobra.Command{ if len(args) != 0 { return nil, cobra.ShellCompDirectiveNoFileComp } - return completion.TargetNames(cmd, toComplete) + return completer().TargetNames(cmd, toComplete) }, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -51,7 +48,7 @@ var gamesTargetsEnableCmd = &cobra.Command{ name := args[0] - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -59,7 +56,7 @@ var gamesTargetsEnableCmd = &cobra.Command{ q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, gamesTargetsEnableGame) + gi, err := gameInstall(ctx, q, gamesTargetsEnableGame) if err != nil { return err } @@ -99,6 +96,6 @@ func init() { "Override the currently active game") gamesTargetsEnableCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) } diff --git a/cmd/games_targets_list.go b/cmd/games_targets_list.go index a057c42..df5a5f6 100644 --- a/cmd/games_targets_list.go +++ b/cmd/games_targets_list.go @@ -23,9 +23,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal" - "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" ) @@ -40,7 +37,7 @@ var gamesTargetsListCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -48,7 +45,7 @@ var gamesTargetsListCmd = &cobra.Command{ q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, gamesTargetsListGame) + gi, err := gameInstall(ctx, q, gamesTargetsListGame) if err != nil { return err } @@ -90,6 +87,6 @@ func init() { "Override the currently active game") gamesTargetsListCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) } diff --git a/cmd/games_targets_remove.go b/cmd/games_targets_remove.go index e4e51bd..4d81b26 100644 --- a/cmd/games_targets_remove.go +++ b/cmd/games_targets_remove.go @@ -24,9 +24,6 @@ import ( "fmt" "github.com/mfinelli/modctl/dbq" - "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" ) @@ -47,7 +44,7 @@ Unapply any mods using this target first.`, if len(args) != 0 { return nil, cobra.ShellCompDirectiveNoFileComp } - return completion.TargetNames(cmd, toComplete) + return completer().TargetNames(cmd, toComplete) }, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -55,7 +52,7 @@ Unapply any mods using this target first.`, name := args[0] - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -63,7 +60,7 @@ Unapply any mods using this target first.`, q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, gamesTargetsRemoveGame) + gi, err := gameInstall(ctx, q, gamesTargetsRemoveGame) if err != nil { return err } @@ -107,6 +104,6 @@ func init() { "Override the currently active game") gamesTargetsRemoveCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) } diff --git a/cmd/gc.go b/cmd/gc.go index 22c323c..4c125b7 100644 --- a/cmd/gc.go +++ b/cmd/gc.go @@ -75,7 +75,7 @@ Use --dry-run to preview what would be removed without making any changes.`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } diff --git a/cmd/import.go b/cmd/import.go index 1ba44e1..ca5d725 100644 --- a/cmd/import.go +++ b/cmd/import.go @@ -105,10 +105,10 @@ Use --dry-run to preview what would be imported without making any changes.`, style.Println(style.Success.Render(" ✓ bundle integrity OK")) style.Println() - if err := database.EnsureExists(); err != nil { + if err := database.EnsureExists(databasePath()); err != nil { return err } - db, err := database.Setup() + db, err := database.Setup(databasePath()) if err != nil { return fmt.Errorf("error setting up database: %w", err) } @@ -169,7 +169,7 @@ Use --dry-run to preview what would be imported without making any changes.`, style.Println(style.Bold.Render("Importing (full)...")) result, importErr = restore.Full( ctx, db, q, bs, bundle, opts, - viper.GetString("database"), + databasePath(), rootCmd.Version, logger, ) diff --git a/cmd/init.go b/cmd/init.go index 2b54e45..ee04dd4 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -72,7 +72,7 @@ multiple times and will not overwrite existing data.`, return fmt.Errorf("error creating tmp directory: %w", err) } - db, err := database.Setup() + db, err := database.Setup(databasePath()) if err != nil { return fmt.Errorf("error opening database: %w", err) } diff --git a/cmd/mods_import.go b/cmd/mods_import.go index 4810145..389e851 100644 --- a/cmd/mods_import.go +++ b/cmd/mods_import.go @@ -35,10 +35,7 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/archivescanner" - "github.com/mfinelli/modctl/internal/argresolver" "github.com/mfinelli/modctl/internal/blobstore" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/fsutil" "github.com/mfinelli/modctl/internal/importer" "github.com/mfinelli/modctl/internal/nexus" @@ -94,7 +91,7 @@ has been safely stored and the database has been updated successfully.`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -159,7 +156,7 @@ has been safely stored and the database has been updated successfully.`, q := dbq.New(db) // Resolve game install id: --game overrides active selection - gi, err := argresolver.GameInstall(ctx, q, modsImportGame) + gi, err := gameInstall(ctx, q, modsImportGame) if err != nil { return err } @@ -295,7 +292,7 @@ func init() { "Override the currently active game") modsImportCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) modsImportCmd.Flags().StringVar(&modsImportName, "name", "", diff --git a/cmd/mods_incompatible_add.go b/cmd/mods_incompatible_add.go index 5334c0e..63d0720 100644 --- a/cmd/mods_incompatible_add.go +++ b/cmd/mods_incompatible_add.go @@ -24,7 +24,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" @@ -51,13 +50,13 @@ known crashes, conflicting game mechanics, or anything else.`, if len(args) >= 2 { return nil, cobra.ShellCompDirectiveNoFileComp } - return completion.ModPageIDs(cmd, toComplete) + return completer().ModPageIDs(cmd, toComplete) }, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -66,7 +65,7 @@ known crashes, conflicting game mechanics, or anything else.`, q := dbq.New(db) // Resolve game install id: --game overrides active selection - gi, err := argresolver.GameInstall(ctx, q, modsIncompatibleAddGame) + gi, err := gameInstall(ctx, q, modsIncompatibleAddGame) if err != nil { return err } @@ -140,7 +139,7 @@ func init() { "Override the currently active game") modsIncompatibleAddCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) modsIncompatibleAddCmd.Flags().StringVarP(&modsIncompatibleAddReason, "reason", "r", "", diff --git a/cmd/mods_incompatible_list.go b/cmd/mods_incompatible_list.go index 1b9ef53..80224f4 100644 --- a/cmd/mods_incompatible_list.go +++ b/cmd/mods_incompatible_list.go @@ -23,9 +23,6 @@ import ( "strings" "github.com/mfinelli/modctl/dbq" - "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" ) @@ -44,7 +41,7 @@ of which profiles they appear in.`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -53,7 +50,7 @@ of which profiles they appear in.`, q := dbq.New(db) // Resolve game install id: --game overrides active selection - gi, err := argresolver.GameInstall(ctx, q, modsIncompatibleListGame) + gi, err := gameInstall(ctx, q, modsIncompatibleListGame) if err != nil { return err } @@ -75,7 +72,7 @@ func init() { "Override the currently active game") modsIncompatibleListCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) } diff --git a/cmd/mods_incompatible_remove.go b/cmd/mods_incompatible_remove.go index 1a745b3..5b8c41f 100644 --- a/cmd/mods_incompatible_remove.go +++ b/cmd/mods_incompatible_remove.go @@ -23,8 +23,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" ) @@ -42,13 +40,13 @@ The order of the two IDs does not matter.`, if len(args) >= 2 { return nil, cobra.ShellCompDirectiveNoFileComp } - return completion.ModPageIDs(cmd, toComplete) + return completer().ModPageIDs(cmd, toComplete) }, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -57,7 +55,7 @@ The order of the two IDs does not matter.`, q := dbq.New(db) // Resolve game install id: --game overrides active selection - gi, err := argresolver.GameInstall(ctx, q, modsIncompatibleRemoveGame) + gi, err := gameInstall(ctx, q, modsIncompatibleRemoveGame) if err != nil { return err } @@ -112,6 +110,6 @@ func init() { "Override the currently active game") modsIncompatibleRemoveCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) } diff --git a/cmd/mods_info.go b/cmd/mods_info.go index cf3ab1f..840f0cd 100644 --- a/cmd/mods_info.go +++ b/cmd/mods_info.go @@ -29,8 +29,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/nexusclient" "github.com/mfinelli/modctl/internal/nexusclient/dbc" "github.com/mfinelli/modctl/internal/style" @@ -57,13 +55,13 @@ to refresh cached data.`, if len(args) != 0 { return nil, cobra.ShellCompDirectiveNoFileComp } - return completion.ModPageIDs(cmd, toComplete) + return completer().ModPageIDs(cmd, toComplete) }, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -72,7 +70,7 @@ to refresh cached data.`, q := dbq.New(db) // Resolve game install id: --game overrides active selection - gi, err := argresolver.GameInstall(ctx, q, modsInfoGame) + gi, err := gameInstall(ctx, q, modsInfoGame) if err != nil { return err } @@ -93,7 +91,7 @@ func init() { "Override the currently active game") modsInfoCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) modsInfoCmd.Flags().BoolVar(&modsInfoShowInventory, "show-inventory", false, diff --git a/cmd/mods_list.go b/cmd/mods_list.go index 3fe11d3..2e340c7 100644 --- a/cmd/mods_list.go +++ b/cmd/mods_list.go @@ -25,9 +25,6 @@ import ( "sort" "github.com/mfinelli/modctl/dbq" - "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/nexusclient" "github.com/mfinelli/modctl/internal/style" "github.com/mfinelli/modctl/internal/updatechain" @@ -60,7 +57,7 @@ only reads the cache: use ` + "`modctl mods nexus check-updates`" + ` to refresh RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -69,7 +66,7 @@ only reads the cache: use ` + "`modctl mods nexus check-updates`" + ` to refresh q := dbq.New(db) // Resolve game install id: --game overrides active selection - gi, err := argresolver.GameInstall(ctx, q, modsListGame) + gi, err := gameInstall(ctx, q, modsListGame) if err != nil { return err } @@ -402,6 +399,6 @@ func init() { "Override the currently active game") modsListCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) } diff --git a/cmd/mods_nexus_checkUpdates.go b/cmd/mods_nexus_checkUpdates.go index b314e66..e4ec770 100644 --- a/cmd/mods_nexus_checkUpdates.go +++ b/cmd/mods_nexus_checkUpdates.go @@ -29,9 +29,6 @@ import ( "time" "github.com/mfinelli/modctl/dbq" - "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/nexusclient" "github.com/mfinelli/modctl/internal/style" "github.com/mfinelli/modctl/internal/updatechain" @@ -75,7 +72,7 @@ to proceed even if the operation would exhaust your API quota.`, return fmt.Errorf("nexus api key not configured; set nexus.apikey in your config file") } - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -90,7 +87,7 @@ to proceed even if the operation would exhaust your API quota.`, q := dbq.New(db) // Resolve game install id: --game overrides active selection - gi, err := argresolver.GameInstall(ctx, q, modsNexusCheckUpdatesGame) + gi, err := gameInstall(ctx, q, modsNexusCheckUpdatesGame) if err != nil { return err } @@ -465,7 +462,7 @@ func init() { "Override the currently active game") modsNexusCheckUpdatesCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) modsNexusCheckUpdatesCmd.Flags().BoolVar(&modsNexusCheckUpdatesForce, "force", false, diff --git a/cmd/mods_nexus_link.go b/cmd/mods_nexus_link.go index 7571dc7..dd628c8 100644 --- a/cmd/mods_nexus_link.go +++ b/cmd/mods_nexus_link.go @@ -26,7 +26,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/nexus" "github.com/mfinelli/modctl/internal/nexusclient" @@ -64,7 +63,7 @@ version for manual linking.`, return fmt.Errorf("nexus api key not configured; set nexus.apikey in your config file") } - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -79,7 +78,7 @@ version for manual linking.`, q := dbq.New(db) // Resolve game install id: --game overrides active selection - gi, err := argresolver.GameInstall(ctx, q, modsNexusLinkGame) + gi, err := gameInstall(ctx, q, modsNexusLinkGame) if err != nil { return err } @@ -102,14 +101,14 @@ func init() { "Override the currently active game") modsNexusLinkCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) modsNexusLinkCmd.Flags().StringVar(&modsNexusLinkVersionID, "version-id", "", "mod file version ID to link (manual mode)") modsNexusLinkCmd.RegisterFlagCompletionFunc("version-id", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ModFileVersionIDs(cmd, toComplete) + return completer().ModFileVersionIDs(cmd, toComplete) }) modsNexusLinkCmd.Flags().StringVar(&modsNexusLinkNexusURL, "nexus-url", "", diff --git a/cmd/mods_nexus_unlink.go b/cmd/mods_nexus_unlink.go index eef779c..10a5bd9 100644 --- a/cmd/mods_nexus_unlink.go +++ b/cmd/mods_nexus_unlink.go @@ -25,8 +25,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" ) @@ -41,13 +39,13 @@ var modsNexusUnlinkCmd = &cobra.Command{ if len(args) != 0 { return nil, cobra.ShellCompDirectiveNoFileComp } - return completion.ModFileVersionIDs(cmd, toComplete) + return completer().ModFileVersionIDs(cmd, toComplete) }, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -56,7 +54,7 @@ var modsNexusUnlinkCmd = &cobra.Command{ q := dbq.New(db) // Resolve game install id: --game overrides active selection - gi, err := argresolver.GameInstall(ctx, q, modsNexusUnlinkGame) + gi, err := gameInstall(ctx, q, modsNexusUnlinkGame) if err != nil { return err } @@ -115,6 +113,6 @@ func init() { "Override the currently active game") modsNexusUnlinkCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) } diff --git a/cmd/mods_notes_clear.go b/cmd/mods_notes_clear.go index d0c78c4..d2c656a 100644 --- a/cmd/mods_notes_clear.go +++ b/cmd/mods_notes_clear.go @@ -21,8 +21,6 @@ package cmd import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/spf13/cobra" ) @@ -36,13 +34,13 @@ var modsNotesClearCmd = &cobra.Command{ if len(args) != 0 { return nil, cobra.ShellCompDirectiveNoFileComp } - return completion.ModPageIDs(cmd, toComplete) + return completer().ModPageIDs(cmd, toComplete) }, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -50,7 +48,7 @@ var modsNotesClearCmd = &cobra.Command{ q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, modsNotesClearGame) + gi, err := gameInstall(ctx, q, modsNotesClearGame) if err != nil { return err } @@ -74,6 +72,6 @@ func init() { "Override the currently active game") modsNotesClearCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) } diff --git a/cmd/mods_notes_set.go b/cmd/mods_notes_set.go index 64345a9..0de5858 100644 --- a/cmd/mods_notes_set.go +++ b/cmd/mods_notes_set.go @@ -27,8 +27,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/spf13/cobra" ) @@ -49,13 +47,13 @@ Pass - as the text to read it from stdin: if len(args) != 0 { return nil, cobra.ShellCompDirectiveNoFileComp } - return completion.ModPageIDs(cmd, toComplete) + return completer().ModPageIDs(cmd, toComplete) }, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -63,7 +61,7 @@ Pass - as the text to read it from stdin: q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, modsNotesSetGame) + gi, err := gameInstall(ctx, q, modsNotesSetGame) if err != nil { return err } @@ -97,6 +95,6 @@ func init() { "Override the currently active game") modsNotesSetCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) } diff --git a/cmd/mods_remove.go b/cmd/mods_remove.go index f5002f2..647ca04 100644 --- a/cmd/mods_remove.go +++ b/cmd/mods_remove.go @@ -25,8 +25,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" ) @@ -60,13 +58,13 @@ items are removed automatically via cascade.`, if len(args) != 0 { return nil, cobra.ShellCompDirectiveNoFileComp } - return completion.ModPageIDs(cmd, toComplete) + return completer().ModPageIDs(cmd, toComplete) }, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -75,7 +73,7 @@ items are removed automatically via cascade.`, q := dbq.New(db) // Resolve game install - gi, err := argresolver.GameInstall(ctx, q, modsRemoveGame) + gi, err := gameInstall(ctx, q, modsRemoveGame) if err != nil { return err } @@ -218,7 +216,7 @@ func init() { "Override the currently active game") modsRemoveCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) modsRemoveCmd.Flags().StringVar(&modsRemoveFileVersion, "file-version", "", @@ -229,7 +227,7 @@ func init() { if len(args) > 0 { modPageArg = strings.TrimSpace(args[0]) } - return completion.ModFileVersionIDsForPage(cmd, modPageArg, toComplete) + return completer().ModFileVersionIDsForPage(cmd, modPageArg, toComplete) }) modsRemoveCmd.Flags().BoolVar(&modsRemoveForce, "force", false, diff --git a/cmd/mods_scanInventory.go b/cmd/mods_scanInventory.go index 73b0121..57c095c 100644 --- a/cmd/mods_scanInventory.go +++ b/cmd/mods_scanInventory.go @@ -24,7 +24,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/archivescanner" "github.com/mfinelli/modctl/internal/blobstore" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -54,7 +53,7 @@ its existing inventory.`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } diff --git a/cmd/operations_list.go b/cmd/operations_list.go index 64798a8..74a61a1 100644 --- a/cmd/operations_list.go +++ b/cmd/operations_list.go @@ -25,9 +25,6 @@ import ( "time" "github.com/mfinelli/modctl/dbq" - "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" ) @@ -51,7 +48,7 @@ Use --limit to change the number of operations shown.`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -73,7 +70,7 @@ Use --limit to change the number of operations shown.`, } // Resolve game install - gi, err := argresolver.GameInstall(ctx, q, operationsListGame) + gi, err := gameInstall(ctx, q, operationsListGame) if err != nil { return err } @@ -103,7 +100,7 @@ func init() { "Override the currently active game") operationsListCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) operationsListCmd.Flags().BoolVarP(&operationsListAll, "all", "A", false, "Show operations across all game installs") diff --git a/cmd/operations_show.go b/cmd/operations_show.go index 3186032..d3a7355 100644 --- a/cmd/operations_show.go +++ b/cmd/operations_show.go @@ -27,7 +27,6 @@ import ( "time" "github.com/mfinelli/modctl/dbq" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" ) @@ -49,7 +48,7 @@ action taken, content hashes before and after, and backup references.`, return fmt.Errorf("invalid operation id %q (expected a positive integer)", args[0]) } - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } diff --git a/cmd/profiles_add.go b/cmd/profiles_add.go index fa139e3..b100afe 100644 --- a/cmd/profiles_add.go +++ b/cmd/profiles_add.go @@ -26,7 +26,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" @@ -62,13 +61,13 @@ targets cannot be used.`, if len(args) != 0 { return nil, cobra.ShellCompDirectiveNoFileComp } - return completion.ModFileVersionIDs(cmd, toComplete) + return completer().ModFileVersionIDs(cmd, toComplete) }, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -77,7 +76,7 @@ targets cannot be used.`, q := dbq.New(db) // Resolve game install id: --game overrides active selection - gi, err := argresolver.GameInstall(ctx, q, profilesAddGame) + gi, err := gameInstall(ctx, q, profilesAddGame) if err != nil { return err } @@ -193,21 +192,21 @@ func init() { "Override the currently active game") profilesAddCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesAddCmd.Flags().StringVarP(&profilesAddProfile, "profile", "p", "", "Override the currently active profile") profilesAddCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) profilesAddCmd.Flags().StringVarP(&profilesAddTarget, "target", "t", "", `Install target to deploy this mod to (default "game_dir", or the only enabled target)`) profilesAddCmd.RegisterFlagCompletionFunc("target", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.TargetNames(cmd, toComplete) + return completer().TargetNames(cmd, toComplete) }) profilesAddCmd.Flags().Int64Var(&profilesAddPriority, "priority", 0, diff --git a/cmd/profiles_create.go b/cmd/profiles_create.go index d379b3a..1c25b54 100644 --- a/cmd/profiles_create.go +++ b/cmd/profiles_create.go @@ -23,8 +23,6 @@ import ( "fmt" "github.com/mfinelli/modctl/dbq" - "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" @@ -52,7 +50,7 @@ Note: modctl automatically creates a "default" profile during game refresh.`, name := args[0] - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -61,7 +59,7 @@ Note: modctl automatically creates a "default" profile during game refresh.`, q := dbq.New(db) // Resolve game install id: --game overrides active selection - gi, err := argresolver.GameInstall(ctx, q, profilesCreateGame) + gi, err := gameInstall(ctx, q, profilesCreateGame) if err != nil { return err } @@ -97,7 +95,7 @@ func init() { "Override the currently active game") profilesCreateCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesCreateCmd.Flags().StringVarP(&profilesCreateDescription, "description", "d", "", diff --git a/cmd/profiles_delete.go b/cmd/profiles_delete.go index 8ff13df..e6b90df 100644 --- a/cmd/profiles_delete.go +++ b/cmd/profiles_delete.go @@ -25,8 +25,6 @@ import ( "os" "github.com/mfinelli/modctl/dbq" - "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" @@ -57,7 +55,7 @@ Safety checks: if len(args) > 0 { return nil, cobra.ShellCompDirectiveNoFileComp } - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -65,7 +63,7 @@ Safety checks: profileName := args[0] - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -74,7 +72,7 @@ Safety checks: q := dbq.New(db) // Resolve game install id: --game overrides active selection - gi, err := argresolver.GameInstall(ctx, q, profilesDeleteGame) + gi, err := gameInstall(ctx, q, profilesDeleteGame) if err != nil { return err } @@ -146,7 +144,7 @@ func init() { "Override the currently active game") profilesDeleteCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesDeleteCmd.Flags().BoolVar(&profilesDeleteForce, "force", false, diff --git a/cmd/profiles_deploys_skipBackup_add.go b/cmd/profiles_deploys_skipBackup_add.go index 088bf33..d3395c6 100644 --- a/cmd/profiles_deploys_skipBackup_add.go +++ b/cmd/profiles_deploys_skipBackup_add.go @@ -23,7 +23,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" @@ -53,7 +52,7 @@ Examples: Args: cobra.ExactArgs(2), ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) == 0 { - return completion.ModFileVersionIDs(cmd, toComplete) + return completer().ModFileVersionIDs(cmd, toComplete) } return nil, cobra.ShellCompDirectiveNoFileComp }, @@ -63,7 +62,7 @@ Examples: pattern := args[1] - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -71,7 +70,7 @@ Examples: q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, profilesDeploysSkipBackupAddGame) + gi, err := gameInstall(ctx, q, profilesDeploysSkipBackupAddGame) if err != nil { return err } @@ -113,13 +112,13 @@ func init() { "Override the currently active game") profilesDeploysSkipBackupAddCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesDeploysSkipBackupAddCmd.PersistentFlags().StringVarP(&profilesDeploysSkipBackupAddProfile, "profile", "p", "", "Override the currently active profile") profilesDeploysSkipBackupAddCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) } diff --git a/cmd/profiles_deploys_skipBackup_copy.go b/cmd/profiles_deploys_skipBackup_copy.go index 25f99b2..3d1a543 100644 --- a/cmd/profiles_deploys_skipBackup_copy.go +++ b/cmd/profiles_deploys_skipBackup_copy.go @@ -24,8 +24,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" ) @@ -50,13 +48,13 @@ the skip-backup configuration from the old version.`, if len(args) >= 2 { return nil, cobra.ShellCompDirectiveNoFileComp } - return completion.ModFileVersionIDs(cmd, toComplete) + return completer().ModFileVersionIDs(cmd, toComplete) }, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -64,7 +62,7 @@ the skip-backup configuration from the old version.`, q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, profilesDeploysSkipBackupCopyGame) + gi, err := gameInstall(ctx, q, profilesDeploysSkipBackupCopyGame) if err != nil { return err } @@ -115,12 +113,12 @@ func init() { "Override the currently active game") profilesDeploysSkipBackupCopyCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesDeploysSkipBackupCopyCmd.PersistentFlags().StringVarP(&profilesDeploysSkipBackupCopyProfile, "profile", "p", "", "Override the currently active profile") profilesDeploysSkipBackupCopyCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) } diff --git a/cmd/profiles_deploys_skipBackup_list.go b/cmd/profiles_deploys_skipBackup_list.go index 7935dc5..787e53c 100644 --- a/cmd/profiles_deploys_skipBackup_list.go +++ b/cmd/profiles_deploys_skipBackup_list.go @@ -23,8 +23,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" ) @@ -41,7 +39,7 @@ var profilesDeploysSkipBackupListCmd = &cobra.Command{ Args: cobra.ExactArgs(1), ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) == 0 { - return completion.ModFileVersionIDs(cmd, toComplete) + return completer().ModFileVersionIDs(cmd, toComplete) } return nil, cobra.ShellCompDirectiveNoFileComp }, @@ -49,7 +47,7 @@ var profilesDeploysSkipBackupListCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -57,7 +55,7 @@ var profilesDeploysSkipBackupListCmd = &cobra.Command{ q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, profilesDeploysSkipBackupListGame) + gi, err := gameInstall(ctx, q, profilesDeploysSkipBackupListGame) if err != nil { return err } @@ -102,13 +100,13 @@ func init() { "Override the currently active game") profilesDeploysSkipBackupListCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesDeploysSkipBackupListCmd.PersistentFlags().StringVarP(&profilesDeploysSkipBackupListProfile, "profile", "p", "", "Override the currently active profile") profilesDeploysSkipBackupListCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) } diff --git a/cmd/profiles_deploys_skipBackup_remove.go b/cmd/profiles_deploys_skipBackup_remove.go index 7549a2b..c42741b 100644 --- a/cmd/profiles_deploys_skipBackup_remove.go +++ b/cmd/profiles_deploys_skipBackup_remove.go @@ -23,8 +23,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" ) @@ -43,7 +41,7 @@ Use 'skip-backup list' to see current patterns.`, Args: cobra.ExactArgs(2), ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) == 0 { - return completion.ModFileVersionIDs(cmd, toComplete) + return completer().ModFileVersionIDs(cmd, toComplete) } return nil, cobra.ShellCompDirectiveNoFileComp }, @@ -53,7 +51,7 @@ Use 'skip-backup list' to see current patterns.`, pattern := args[1] - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -61,7 +59,7 @@ Use 'skip-backup list' to see current patterns.`, q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, profilesDeploysSkipBackupRemoveGame) + gi, err := gameInstall(ctx, q, profilesDeploysSkipBackupRemoveGame) if err != nil { return err } @@ -104,13 +102,13 @@ func init() { "Override the currently active game") profilesDeploysSkipBackupRemoveCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesDeploysSkipBackupRemoveCmd.PersistentFlags().StringVarP(&profilesDeploysSkipBackupRemoveProfile, "profile", "p", "", "Override the currently active profile") profilesDeploysSkipBackupRemoveCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) } diff --git a/cmd/profiles_deploys_writeOnce_add.go b/cmd/profiles_deploys_writeOnce_add.go index ddbe3c5..52af30b 100644 --- a/cmd/profiles_deploys_writeOnce_add.go +++ b/cmd/profiles_deploys_writeOnce_add.go @@ -23,7 +23,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" @@ -52,7 +51,7 @@ Examples: Args: cobra.ExactArgs(2), ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) == 0 { - return completion.ModFileVersionIDs(cmd, toComplete) + return completer().ModFileVersionIDs(cmd, toComplete) } return nil, cobra.ShellCompDirectiveNoFileComp }, @@ -62,7 +61,7 @@ Examples: pattern := args[1] - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -70,7 +69,7 @@ Examples: q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, profilesDeploysWriteOnceAddGame) + gi, err := gameInstall(ctx, q, profilesDeploysWriteOnceAddGame) if err != nil { return err } @@ -112,12 +111,12 @@ func init() { "Override the currently active game") profilesDeploysWriteOnceAddCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesDeploysWriteOnceAddCmd.PersistentFlags().StringVarP(&profilesDeploysWriteOnceAddProfile, "profile", "p", "", "Override the currently active profile") profilesDeploysWriteOnceAddCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) } diff --git a/cmd/profiles_deploys_writeOnce_copy.go b/cmd/profiles_deploys_writeOnce_copy.go index 9d7a681..8527ed7 100644 --- a/cmd/profiles_deploys_writeOnce_copy.go +++ b/cmd/profiles_deploys_writeOnce_copy.go @@ -24,8 +24,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" ) @@ -50,13 +48,13 @@ the write-once configuration from the old version.`, if len(args) >= 2 { return nil, cobra.ShellCompDirectiveNoFileComp } - return completion.ModFileVersionIDs(cmd, toComplete) + return completer().ModFileVersionIDs(cmd, toComplete) }, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -64,7 +62,7 @@ the write-once configuration from the old version.`, q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, profilesDeploysWriteOnceCopyGame) + gi, err := gameInstall(ctx, q, profilesDeploysWriteOnceCopyGame) if err != nil { return err } @@ -115,12 +113,12 @@ func init() { "Override the currently active game") profilesDeploysWriteOnceCopyCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesDeploysWriteOnceCopyCmd.PersistentFlags().StringVarP(&profilesDeploysWriteOnceCopyProfile, "profile", "p", "", "Override the currently active profile") profilesDeploysWriteOnceCopyCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) } diff --git a/cmd/profiles_deploys_writeOnce_list.go b/cmd/profiles_deploys_writeOnce_list.go index 2dc1681..9c44f22 100644 --- a/cmd/profiles_deploys_writeOnce_list.go +++ b/cmd/profiles_deploys_writeOnce_list.go @@ -23,8 +23,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" ) @@ -41,7 +39,7 @@ var profilesDeploysWriteOnceListCmd = &cobra.Command{ Args: cobra.ExactArgs(1), ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) == 0 { - return completion.ModFileVersionIDs(cmd, toComplete) + return completer().ModFileVersionIDs(cmd, toComplete) } return nil, cobra.ShellCompDirectiveNoFileComp }, @@ -49,7 +47,7 @@ var profilesDeploysWriteOnceListCmd = &cobra.Command{ RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -57,7 +55,7 @@ var profilesDeploysWriteOnceListCmd = &cobra.Command{ q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, profilesDeploysWriteOnceListGame) + gi, err := gameInstall(ctx, q, profilesDeploysWriteOnceListGame) if err != nil { return err } @@ -102,13 +100,13 @@ func init() { "Override the currently active game") profilesDeploysWriteOnceListCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesDeploysWriteOnceListCmd.PersistentFlags().StringVarP(&profilesDeploysWriteOnceListProfile, "profile", "p", "", "Override the currently active profile") profilesDeploysWriteOnceListCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) } diff --git a/cmd/profiles_deploys_writeOnce_remove.go b/cmd/profiles_deploys_writeOnce_remove.go index 962fb50..d9fa805 100644 --- a/cmd/profiles_deploys_writeOnce_remove.go +++ b/cmd/profiles_deploys_writeOnce_remove.go @@ -23,8 +23,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" ) @@ -43,7 +41,7 @@ Use 'write-once list' to see current patterns.`, Args: cobra.ExactArgs(2), ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) == 0 { - return completion.ModFileVersionIDs(cmd, toComplete) + return completer().ModFileVersionIDs(cmd, toComplete) } return nil, cobra.ShellCompDirectiveNoFileComp }, @@ -53,7 +51,7 @@ Use 'write-once list' to see current patterns.`, pattern := args[1] - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -61,7 +59,7 @@ Use 'write-once list' to see current patterns.`, q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, profilesDeploysWriteOnceRemoveGame) + gi, err := gameInstall(ctx, q, profilesDeploysWriteOnceRemoveGame) if err != nil { return err } @@ -104,13 +102,13 @@ func init() { "Override the currently active game") profilesDeploysWriteOnceRemoveCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesDeploysWriteOnceRemoveCmd.PersistentFlags().StringVarP(&profilesDeploysWriteOnceRemoveProfile, "profile", "p", "", "Override the currently active profile") profilesDeploysWriteOnceRemoveCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) } diff --git a/cmd/profiles_diff.go b/cmd/profiles_diff.go index da80bb9..a569978 100644 --- a/cmd/profiles_diff.go +++ b/cmd/profiles_diff.go @@ -26,8 +26,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" "go.finelli.dev/util" @@ -65,13 +63,13 @@ directional: profile A is the source and profile B is the target.`, if len(args) >= 2 { return nil, cobra.ShellCompDirectiveNoFileComp } - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -80,7 +78,7 @@ directional: profile A is the source and profile B is the target.`, q := dbq.New(db) // Resolve game install - gi, err := argresolver.GameInstall(ctx, q, profilesDiffGame) + gi, err := gameInstall(ctx, q, profilesDiffGame) if err != nil { return err } @@ -141,7 +139,7 @@ func init() { "Override the currently active game") profilesDiffCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesDiffCmd.Flags().BoolVar(&profilesDiffNoUnchanged, "no-unchanged", false, diff --git a/cmd/profiles_disable.go b/cmd/profiles_disable.go index 4ee313a..ea71866 100644 --- a/cmd/profiles_disable.go +++ b/cmd/profiles_disable.go @@ -24,8 +24,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" "go.finelli.dev/util" @@ -49,7 +47,7 @@ versions are ignored when computing the applied mod set.`, if len(args) != 0 || profilesDisableAll { return nil, cobra.ShellCompDirectiveNoFileComp } - return completion.ModFileVersionIDs(cmd, toComplete) + return completer().ModFileVersionIDs(cmd, toComplete) }, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -62,7 +60,7 @@ versions are ignored when computing the applied mod set.`, return fmt.Errorf("must specify a mod file version ID or pass --all") } - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -71,7 +69,7 @@ versions are ignored when computing the applied mod set.`, q := dbq.New(db) // Resolve game install id: --game overrides active selection - gi, err := argresolver.GameInstall(ctx, q, profilesDisableGame) + gi, err := gameInstall(ctx, q, profilesDisableGame) if err != nil { return err } @@ -113,14 +111,14 @@ func init() { "Override the currently active game") profilesDisableCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesDisableCmd.Flags().StringVarP(&profilesDisableProfile, "profile", "p", "", "Override the currently active profile") profilesDisableCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) profilesDisableCmd.Flags().BoolVarP(&profilesDisableAll, "all", "A", false, diff --git a/cmd/profiles_enable.go b/cmd/profiles_enable.go index 8f31447..36aab39 100644 --- a/cmd/profiles_enable.go +++ b/cmd/profiles_enable.go @@ -24,8 +24,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" "go.finelli.dev/util" @@ -49,7 +47,7 @@ priority or position in the load order.`, if len(args) != 0 || profilesEnableAll { return nil, cobra.ShellCompDirectiveNoFileComp } - return completion.ModFileVersionIDs(cmd, toComplete) + return completer().ModFileVersionIDs(cmd, toComplete) }, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { @@ -62,7 +60,7 @@ priority or position in the load order.`, return fmt.Errorf("must specify a mod file version ID or pass --all") } - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -71,7 +69,7 @@ priority or position in the load order.`, q := dbq.New(db) // Resolve game install id: --game overrides active selection - gi, err := argresolver.GameInstall(ctx, q, profilesEnableGame) + gi, err := gameInstall(ctx, q, profilesEnableGame) if err != nil { return err } @@ -113,14 +111,14 @@ func init() { "Override the currently active game") profilesEnableCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesEnableCmd.Flags().StringVarP(&profilesEnableProfile, "profile", "p", "", "Override the currently active profile") profilesEnableCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) profilesEnableCmd.Flags().BoolVarP(&profilesEnableAll, "all", "A", false, diff --git a/cmd/profiles_list.go b/cmd/profiles_list.go index f3ffce6..f79c9e4 100644 --- a/cmd/profiles_list.go +++ b/cmd/profiles_list.go @@ -22,9 +22,6 @@ import ( "fmt" "github.com/mfinelli/modctl/dbq" - "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" ) @@ -47,7 +44,7 @@ The current active game is used unless --game is provided.`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -56,7 +53,7 @@ The current active game is used unless --game is provided.`, q := dbq.New(db) // Resolve game install id: --game overrides active selection - gi, err := argresolver.GameInstall(ctx, q, profilesListGame) + gi, err := gameInstall(ctx, q, profilesListGame) if err != nil { return err } @@ -97,6 +94,6 @@ func init() { "Override the currently active game") profilesListCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) } diff --git a/cmd/profiles_order_compact.go b/cmd/profiles_order_compact.go index 8cd400e..1e428cb 100644 --- a/cmd/profiles_order_compact.go +++ b/cmd/profiles_order_compact.go @@ -24,7 +24,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" @@ -58,7 +57,7 @@ Use --multiple to assign priorities as N, 2N, 3N, ... (for example, --multiple return fmt.Errorf("--multiple must be > 0") } - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -67,7 +66,7 @@ Use --multiple to assign priorities as N, 2N, 3N, ... (for example, --multiple q := dbq.New(db) // Resolve game install id: --game overrides active selection - gi, err := argresolver.GameInstall(ctx, q, profilesOrderCompactGame) + gi, err := gameInstall(ctx, q, profilesOrderCompactGame) if err != nil { return err } @@ -119,14 +118,14 @@ func init() { "Override the currently active game") profilesOrderCompactCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesOrderCompactCmd.Flags().StringVarP(&profilesOrderCompactProfile, "profile", "p", "", "Override the currently active profile") profilesOrderCompactCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) profilesOrderCompactCmd.Flags().Int64VarP(&profilesOrderCompactMultiple, "multiple", "m", 1, diff --git a/cmd/profiles_order_move.go b/cmd/profiles_order_move.go index 9b5ba76..d530242 100644 --- a/cmd/profiles_order_move.go +++ b/cmd/profiles_order_move.go @@ -24,7 +24,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" @@ -56,7 +55,7 @@ Exactly one of --before or --after is required.`, return fmt.Errorf("exactly one of --before or --after is required") } - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -65,7 +64,7 @@ Exactly one of --before or --after is required.`, q := dbq.New(db) // Resolve game install id: --game overrides active selection - gi, err := argresolver.GameInstall(ctx, q, profilesOrderMoveGame) + gi, err := gameInstall(ctx, q, profilesOrderMoveGame) if err != nil { return err } @@ -197,26 +196,26 @@ func init() { "Override the currently active game") profilesOrderMoveCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesOrderMoveCmd.Flags().StringVarP(&profilesOrderMoveProfile, "profile", "p", "", "Override the currently active profile") profilesOrderMoveCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) profilesOrderMoveCmd.Flags().Int64VarP(&profilesOrderMoveAfter, "after", "a", 0, "Move the mod after the specified mod") profilesOrderMoveCmd.RegisterFlagCompletionFunc("after", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ModFileVersionIDs(cmd, toComplete) + return completer().ModFileVersionIDs(cmd, toComplete) }) profilesOrderMoveCmd.Flags().Int64VarP(&profilesOrderMoveBefore, "before", "b", 0, "Move the mod before the specified mod") profilesOrderMoveCmd.RegisterFlagCompletionFunc("before", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ModFileVersionIDs(cmd, toComplete) + return completer().ModFileVersionIDs(cmd, toComplete) }) } diff --git a/cmd/profiles_order_set.go b/cmd/profiles_order_set.go index 8201ef3..4251d11 100644 --- a/cmd/profiles_order_set.go +++ b/cmd/profiles_order_set.go @@ -26,7 +26,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" @@ -46,7 +45,7 @@ Higher priority wins conflicts. Priorities must be unique within a profile.`, Args: cobra.ExactArgs(2), ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) == 0 { - return completion.ModFileVersionIDs(cmd, toComplete) + return completer().ModFileVersionIDs(cmd, toComplete) } return nil, cobra.ShellCompDirectiveNoFileComp }, @@ -59,7 +58,7 @@ Higher priority wins conflicts. Priorities must be unique within a profile.`, return fmt.Errorf("invalid priority %q (expected a positive integer)", args[1]) } - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -68,7 +67,7 @@ Higher priority wins conflicts. Priorities must be unique within a profile.`, q := dbq.New(db) // Resolve game install id: --game overrides active selection - gi, err := argresolver.GameInstall(ctx, q, profilesOrderSetGame) + gi, err := gameInstall(ctx, q, profilesOrderSetGame) if err != nil { return err } @@ -148,13 +147,13 @@ func init() { "Override the currently active game") profilesOrderSetCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesOrderSetCmd.Flags().StringVarP(&profilesOrderSetProfile, "profile", "p", "", "Override the currently active profile") profilesOrderSetCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) } diff --git a/cmd/profiles_order_swap.go b/cmd/profiles_order_swap.go index 4224967..621e83f 100644 --- a/cmd/profiles_order_swap.go +++ b/cmd/profiles_order_swap.go @@ -25,7 +25,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" @@ -48,13 +47,13 @@ without having to choose unused priority numbers.`, if len(args) >= 2 { return nil, cobra.ShellCompDirectiveNoFileComp } - return completion.ModFileVersionIDs(cmd, toComplete) + return completer().ModFileVersionIDs(cmd, toComplete) }, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -63,7 +62,7 @@ without having to choose unused priority numbers.`, q := dbq.New(db) // Resolve game install id: --game overrides active selection - gi, err := argresolver.GameInstall(ctx, q, profilesAddGame) + gi, err := gameInstall(ctx, q, profilesAddGame) if err != nil { return err } @@ -167,13 +166,13 @@ func init() { "Override the currently active game") profilesOrderSwapCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesOrderSwapCmd.Flags().StringVarP(&profilesOrderSwapProfile, "profile", "p", "", "Override the currently active profile") profilesOrderSwapCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) } diff --git a/cmd/profiles_overrides_copy.go b/cmd/profiles_overrides_copy.go index 86e6747..f06a566 100644 --- a/cmd/profiles_overrides_copy.go +++ b/cmd/profiles_overrides_copy.go @@ -23,7 +23,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" @@ -51,13 +50,13 @@ are replaced.`, if len(args) != 0 { return nil, cobra.ShellCompDirectiveNoFileComp } - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -65,7 +64,7 @@ are replaced.`, q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, profilesOverridesCopyGame) + gi, err := gameInstall(ctx, q, profilesOverridesCopyGame) if err != nil { return err } @@ -156,13 +155,13 @@ func init() { "Override the currently active game") profilesOverridesCopyCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesOverridesCopyCmd.Flags().StringVarP(&profilesOverridesCopyProfile, "profile", "p", "", "Override the currently active profile") profilesOverridesCopyCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) profilesOverridesCopyCmd.Flags().BoolVar(&profilesOverridesCopyForce, "force", false, "Replace conflicting overrides in the destination profile") diff --git a/cmd/profiles_overrides_edit.go b/cmd/profiles_overrides_edit.go index d454678..04050bf 100644 --- a/cmd/profiles_overrides_edit.go +++ b/cmd/profiles_overrides_edit.go @@ -29,7 +29,6 @@ import ( "github.com/mfinelli/modctl/internal" "github.com/mfinelli/modctl/internal/argresolver" "github.com/mfinelli/modctl/internal/blobstore" - "github.com/mfinelli/modctl/internal/completion" "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/extractor" "github.com/mfinelli/modctl/internal/style" @@ -72,7 +71,7 @@ archives.`, return err } - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -80,7 +79,7 @@ archives.`, q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, profilesOverridesEditGame) + gi, err := gameInstall(ctx, q, profilesOverridesEditGame) if err != nil { return err } @@ -302,13 +301,13 @@ func init() { "Override the currently active game") profilesOverridesEditCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesOverridesEditCmd.Flags().StringVarP(&profilesOverridesEditProfile, "profile", "p", "", "Override the currently active profile") profilesOverridesEditCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) profilesOverridesEditCmd.Flags().BoolVar(&profilesOverridesEditReset, "reset", false, diff --git a/cmd/profiles_overrides_list.go b/cmd/profiles_overrides_list.go index 51c5134..8d8fbad 100644 --- a/cmd/profiles_overrides_list.go +++ b/cmd/profiles_overrides_list.go @@ -24,8 +24,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" ) @@ -49,7 +47,7 @@ Run 'profiles overrides status' for full staleness detail.`, ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -57,7 +55,7 @@ Run 'profiles overrides status' for full staleness detail.`, q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, profilesOverridesListGame) + gi, err := gameInstall(ctx, q, profilesOverridesListGame) if err != nil { return err } @@ -133,13 +131,13 @@ func init() { "Override the currently active game") profilesOverridesListCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesOverridesListCmd.Flags().StringVarP(&profilesOverridesListProfile, "profile", "p", "", "Override the currently active profile") profilesOverridesListCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) } diff --git a/cmd/profiles_overrides_patch_list.go b/cmd/profiles_overrides_patch_list.go index 8f7d0ad..1f986a2 100644 --- a/cmd/profiles_overrides_patch_list.go +++ b/cmd/profiles_overrides_patch_list.go @@ -24,8 +24,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" ) @@ -50,7 +48,7 @@ Entries are shown in the order they will be applied.`, return err } - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -58,7 +56,7 @@ Entries are shown in the order they will be applied.`, q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, profilesOverridesPatchListGame) + gi, err := gameInstall(ctx, q, profilesOverridesPatchListGame) if err != nil { return err } @@ -125,14 +123,14 @@ func init() { "Override the currently active game") profilesOverridesPatchListCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesOverridesPatchListCmd.Flags().StringVarP(&profilesOverridesPatchListProfile, "profile", "p", "", "Override the currently active profile") profilesOverridesPatchListCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) } diff --git a/cmd/profiles_overrides_patch_preview.go b/cmd/profiles_overrides_patch_preview.go index 5e7ad18..3ff8acb 100644 --- a/cmd/profiles_overrides_patch_preview.go +++ b/cmd/profiles_overrides_patch_preview.go @@ -28,8 +28,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/argresolver" "github.com/mfinelli/modctl/internal/blobstore" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/extractor" "github.com/mfinelli/modctl/internal/patchapply" "github.com/mfinelli/modctl/internal/style" @@ -64,7 +62,7 @@ Note: requires archive extraction which may be slow for large archives.`, return err } - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -72,7 +70,7 @@ Note: requires archive extraction which may be slow for large archives.`, q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, profilesOverridesPatchPreviewGame) + gi, err := gameInstall(ctx, q, profilesOverridesPatchPreviewGame) if err != nil { return err } @@ -244,13 +242,13 @@ func init() { "Override the currently active game") profilesOverridesPatchPreviewCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesOverridesPatchPreviewCmd.Flags().StringVarP(&profilesOverridesPatchPreviewProfile, "profile", "p", "", "Override the currently active profile") profilesOverridesPatchPreviewCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) } diff --git a/cmd/profiles_overrides_patch_remove.go b/cmd/profiles_overrides_patch_remove.go index 8602e11..e998cb3 100644 --- a/cmd/profiles_overrides_patch_remove.go +++ b/cmd/profiles_overrides_patch_remove.go @@ -24,7 +24,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" @@ -59,7 +58,7 @@ For ini patches, use --section to identify entries in a specific section.`, } entryKey := args[1] - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -67,7 +66,7 @@ For ini patches, use --section to identify entries in a specific section.`, q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, profilesOverridesPatchRemoveGame) + gi, err := gameInstall(ctx, q, profilesOverridesPatchRemoveGame) if err != nil { return err } @@ -174,13 +173,13 @@ func init() { "Override the currently active game") profilesOverridesPatchRemoveCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesOverridesPatchRemoveCmd.Flags().StringVarP(&profilesOverridesPatchRemoveProfile, "profile", "p", "", "Override the currently active profile") profilesOverridesPatchRemoveCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) profilesOverridesPatchRemoveCmd.Flags().StringVar(&profilesOverridesPatchRemoveSection, "section", "", "Section name (ini patches only)") diff --git a/cmd/profiles_overrides_patch_set.go b/cmd/profiles_overrides_patch_set.go index f59dcdd..c61dcb5 100644 --- a/cmd/profiles_overrides_patch_set.go +++ b/cmd/profiles_overrides_patch_set.go @@ -27,7 +27,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" @@ -67,7 +66,7 @@ Examples: entryKey := args[1] entryValue := args[2] - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -75,7 +74,7 @@ Examples: q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, profilesOverridesPatchSetGame) + gi, err := gameInstall(ctx, q, profilesOverridesPatchSetGame) if err != nil { return err } @@ -241,14 +240,14 @@ func init() { "Override the currently active game") profilesOverridesPatchSetCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesOverridesPatchSetCmd.Flags().StringVarP(&profilesOverridesPatchSetProfile, "profile", "p", "", "Override the currently active profile") profilesOverridesPatchSetCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) profilesOverridesPatchSetCmd.Flags().StringVar(&profilesOverridesPatchSetSection, "section", "", diff --git a/cmd/profiles_overrides_patch_unset.go b/cmd/profiles_overrides_patch_unset.go index de0ad8f..40d5d7f 100644 --- a/cmd/profiles_overrides_patch_unset.go +++ b/cmd/profiles_overrides_patch_unset.go @@ -25,7 +25,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" @@ -66,7 +65,7 @@ removing the node entirely.`, } entryKey := args[1] - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -74,7 +73,7 @@ removing the node entirely.`, q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, profilesOverridesPatchUnsetGame) + gi, err := gameInstall(ctx, q, profilesOverridesPatchUnsetGame) if err != nil { return err } @@ -242,14 +241,14 @@ func init() { "Override the currently active game") profilesOverridesPatchUnsetCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesOverridesPatchUnsetCmd.Flags().StringVarP(&profilesOverridesPatchUnsetProfile, "profile", "p", "", "Override the currently active profile") profilesOverridesPatchUnsetCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) profilesOverridesPatchUnsetCmd.Flags().BoolVar(&profilesOverridesPatchUnsetClear, "clear", false, diff --git a/cmd/profiles_overrides_set.go b/cmd/profiles_overrides_set.go index ca45b0e..ea1ccca 100644 --- a/cmd/profiles_overrides_set.go +++ b/cmd/profiles_overrides_set.go @@ -27,7 +27,6 @@ import ( "github.com/mfinelli/modctl/internal" "github.com/mfinelli/modctl/internal/argresolver" "github.com/mfinelli/modctl/internal/blobstore" - "github.com/mfinelli/modctl/internal/completion" "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" @@ -62,7 +61,7 @@ winner for this path in the active profile.`, } srcFile := args[1] - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -70,7 +69,7 @@ winner for this path in the active profile.`, q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, profilesOverridesSetGame) + gi, err := gameInstall(ctx, q, profilesOverridesSetGame) if err != nil { return err } @@ -189,13 +188,13 @@ func init() { "Override the currently active game") profilesOverridesSetCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesOverridesSetCmd.Flags().StringVarP(&profilesOverridesSetProfile, "profile", "p", "", "Override the currently active profile") profilesOverridesSetCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) profilesOverridesSetCmd.Flags().StringVar(&profilesOverridesSetNotes, "notes", "", "Optional notes for this override") diff --git a/cmd/profiles_overrides_status.go b/cmd/profiles_overrides_status.go index 62b08f6..e75a5cc 100644 --- a/cmd/profiles_overrides_status.go +++ b/cmd/profiles_overrides_status.go @@ -24,8 +24,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" ) @@ -51,7 +49,7 @@ Pass a path argument to show detail for a single override only.`, ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -59,7 +57,7 @@ Pass a path argument to show detail for a single override only.`, q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, profilesOverridesStatusGame) + gi, err := gameInstall(ctx, q, profilesOverridesStatusGame) if err != nil { return err } @@ -183,13 +181,13 @@ func init() { "Override the currently active game") profilesOverridesStatusCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesOverridesStatusCmd.Flags().StringVarP(&profilesOverridesStatusProfile, "profile", "p", "", "Override the currently active profile") profilesOverridesStatusCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) } diff --git a/cmd/profiles_overrides_unset.go b/cmd/profiles_overrides_unset.go index be946ca..9fe7854 100644 --- a/cmd/profiles_overrides_unset.go +++ b/cmd/profiles_overrides_unset.go @@ -23,8 +23,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" ) @@ -51,7 +49,7 @@ path back to the mod winner or remove it if no mod provides it.`, return err } - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -59,7 +57,7 @@ path back to the mod winner or remove it if no mod provides it.`, q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, profilesOverridesUnsetGame) + gi, err := gameInstall(ctx, q, profilesOverridesUnsetGame) if err != nil { return err } @@ -108,12 +106,12 @@ func init() { "Override the currently active game") profilesOverridesUnsetCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesOverridesUnsetCmd.Flags().StringVarP(&profilesOverridesUnsetProfile, "profile", "p", "", "Override the currently active profile") profilesOverridesUnsetCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) } diff --git a/cmd/profiles_preview.go b/cmd/profiles_preview.go index 16805ee..5a05490 100644 --- a/cmd/profiles_preview.go +++ b/cmd/profiles_preview.go @@ -29,8 +29,6 @@ import ( "github.com/mfinelli/modctl/internal" "github.com/mfinelli/modctl/internal/argresolver" "github.com/mfinelli/modctl/internal/blobstore" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/extractor" "github.com/mfinelli/modctl/internal/fsutil" "github.com/mfinelli/modctl/internal/planner" @@ -73,7 +71,7 @@ Binary files are detected automatically and refused unless --force is passed.`, return err } - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -81,7 +79,7 @@ Binary files are detected automatically and refused unless --force is passed.`, q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, profilesPreviewGame) + gi, err := gameInstall(ctx, q, profilesPreviewGame) if err != nil { return err } @@ -230,19 +228,19 @@ func init() { "Override the currently active game") profilesPreviewCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesPreviewCmd.Flags().StringVarP(&profilesPreviewProfile, "profile", "p", "", "Override the currently active profile") profilesPreviewCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) profilesPreviewCmd.Flags().StringVarP(&profilesPreviewTarget, "target", "t", "", "Install target (default: game_dir)") profilesPreviewCmd.RegisterFlagCompletionFunc("target", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.TargetNames(cmd, toComplete) + return completer().TargetNames(cmd, toComplete) }) profilesPreviewCmd.Flags().BoolVar(&profilesPreviewForce, "force", false, diff --git a/cmd/profiles_remap_add.go b/cmd/profiles_remap_add.go index ee369b8..e088034 100644 --- a/cmd/profiles_remap_add.go +++ b/cmd/profiles_remap_add.go @@ -26,8 +26,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" ) @@ -72,7 +70,7 @@ Examples: ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { switch len(args) { case 0: - return completion.ModFileVersionIDs(cmd, toComplete) + return completer().ModFileVersionIDs(cmd, toComplete) case 1: return []string{ "strip_components", @@ -101,7 +99,7 @@ Examples: return err } - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -109,7 +107,7 @@ Examples: q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, profilesRemapAddGame) + gi, err := gameInstall(ctx, q, profilesRemapAddGame) if err != nil { return err } @@ -145,13 +143,13 @@ func init() { "Override the currently active game") profilesRemapAddCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesRemapAddCmd.PersistentFlags().StringVarP(&profilesRemapAddProfile, "profile", "p", "", "Override the currently active profile") profilesRemapAddCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) profilesRemapAddCmd.Flags().Int64Var(&profilesRemapAddPosition, "position", -1, diff --git a/cmd/profiles_remap_clear.go b/cmd/profiles_remap_clear.go index 448a54f..cf21591 100644 --- a/cmd/profiles_remap_clear.go +++ b/cmd/profiles_remap_clear.go @@ -22,8 +22,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" ) @@ -45,13 +43,13 @@ recreating rules in a different order.`, if len(args) != 0 { return nil, cobra.ShellCompDirectiveNoFileComp } - return completion.ModFileVersionIDs(cmd, toComplete) + return completer().ModFileVersionIDs(cmd, toComplete) }, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -60,7 +58,7 @@ recreating rules in a different order.`, q := dbq.New(db) // Resolve game install id: --game overrides active selection - gi, err := argresolver.GameInstall(ctx, q, profilesRemapClearGame) + gi, err := gameInstall(ctx, q, profilesRemapClearGame) if err != nil { return err } @@ -100,12 +98,12 @@ func init() { "Override the currently active game") profilesRemapClearCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesRemapClearCmd.PersistentFlags().StringVarP(&profilesRemapClearProfile, "profile", "p", "", "Override the currently active profile") profilesRemapClearCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) } diff --git a/cmd/profiles_remap_copy.go b/cmd/profiles_remap_copy.go index 56fd1f3..a9cbe21 100644 --- a/cmd/profiles_remap_copy.go +++ b/cmd/profiles_remap_copy.go @@ -24,8 +24,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" ) @@ -50,13 +48,13 @@ version to the new version before removing the old version from the profile.`, if len(args) >= 2 { return nil, cobra.ShellCompDirectiveNoFileComp } - return completion.ModFileVersionIDs(cmd, toComplete) + return completer().ModFileVersionIDs(cmd, toComplete) }, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -64,7 +62,7 @@ version to the new version before removing the old version from the profile.`, q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, profilesRemapCopyGame) + gi, err := gameInstall(ctx, q, profilesRemapCopyGame) if err != nil { return err } @@ -115,12 +113,12 @@ func init() { "Override the currently active game") profilesRemapCopyCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesRemapCopyCmd.PersistentFlags().StringVarP(&profilesRemapCopyProfile, "profile", "p", "", "Override the currently active profile") profilesRemapCopyCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) } diff --git a/cmd/profiles_remap_list.go b/cmd/profiles_remap_list.go index f21edbb..ec7d0e8 100644 --- a/cmd/profiles_remap_list.go +++ b/cmd/profiles_remap_list.go @@ -23,8 +23,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" ) @@ -45,13 +43,13 @@ Rules are shown in the order they will be applied during planning.`, if len(args) != 0 { return nil, cobra.ShellCompDirectiveNoFileComp } - return completion.ModFileVersionIDs(cmd, toComplete) + return completer().ModFileVersionIDs(cmd, toComplete) }, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -59,7 +57,7 @@ Rules are shown in the order they will be applied during planning.`, q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, profilesRemapListGame) + gi, err := gameInstall(ctx, q, profilesRemapListGame) if err != nil { return err } @@ -105,13 +103,13 @@ func init() { "Override the currently active game") profilesRemapListCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesRemapListCmd.PersistentFlags().StringVarP(&profilesRemapListProfile, "profile", "p", "", "Override the currently active profile") profilesRemapListCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) } diff --git a/cmd/profiles_remap_preview.go b/cmd/profiles_remap_preview.go index 512c7a1..23e5864 100644 --- a/cmd/profiles_remap_preview.go +++ b/cmd/profiles_remap_preview.go @@ -23,8 +23,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/remap" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" @@ -53,13 +51,13 @@ if needed.`, if len(args) != 0 { return nil, cobra.ShellCompDirectiveNoFileComp } - return completion.ModFileVersionIDs(cmd, toComplete) + return completer().ModFileVersionIDs(cmd, toComplete) }, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -67,7 +65,7 @@ if needed.`, q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, profilesRemapPreviewGame) + gi, err := gameInstall(ctx, q, profilesRemapPreviewGame) if err != nil { return err } @@ -232,14 +230,14 @@ func init() { "Override the currently active game") profilesRemapPreviewCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesRemapPreviewCmd.Flags().StringVarP(&profilesRemapPreviewProfile, "profile", "p", "", "Override the currently active profile") profilesRemapPreviewCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) profilesRemapPreviewCmd.Flags().BoolVar(&profilesRemapPreviewShowFiltered, "show-filtered", false, diff --git a/cmd/profiles_remap_remove.go b/cmd/profiles_remap_remove.go index 56ced7d..786f038 100644 --- a/cmd/profiles_remap_remove.go +++ b/cmd/profiles_remap_remove.go @@ -24,8 +24,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" ) @@ -44,7 +42,7 @@ Use 'remap list' to see current rules and their positions.`, Args: cobra.ExactArgs(2), ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) == 0 { - return completion.ModFileVersionIDs(cmd, toComplete) + return completer().ModFileVersionIDs(cmd, toComplete) } return nil, cobra.ShellCompDirectiveNoFileComp }, @@ -57,7 +55,7 @@ Use 'remap list' to see current rules and their positions.`, return fmt.Errorf("invalid position %q (expected a non-negative integer)", args[1]) } - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -65,7 +63,7 @@ Use 'remap list' to see current rules and their positions.`, q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, profilesRemapRemoveGame) + gi, err := gameInstall(ctx, q, profilesRemapRemoveGame) if err != nil { return err } @@ -114,12 +112,12 @@ func init() { "Override the currently active game") profilesRemapRemoveCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesRemapRemoveCmd.PersistentFlags().StringVarP(&profilesRemapRemoveProfile, "profile", "p", "", "Override the currently active profile") profilesRemapRemoveCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) } diff --git a/cmd/profiles_remove.go b/cmd/profiles_remove.go index 7ebee63..1b11004 100644 --- a/cmd/profiles_remove.go +++ b/cmd/profiles_remove.go @@ -25,7 +25,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" @@ -48,13 +47,13 @@ It does not change files on disk; changes take effect the next time you apply.`, if len(args) != 0 { return nil, cobra.ShellCompDirectiveNoFileComp } - return completion.ModFileVersionIDs(cmd, toComplete) + return completer().ModFileVersionIDs(cmd, toComplete) }, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -63,7 +62,7 @@ It does not change files on disk; changes take effect the next time you apply.`, q := dbq.New(db) // Resolve game install id: --game overrides active selection - gi, err := argresolver.GameInstall(ctx, q, profilesRenameGame) + gi, err := gameInstall(ctx, q, profilesRenameGame) if err != nil { return err } @@ -113,14 +112,14 @@ func init() { "Override the currently active game") profilesRemoveCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesRemoveCmd.Flags().StringVarP(&profilesRemoveProfile, "profile", "p", "", "Override the currently active profile") profilesRemoveCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) } diff --git a/cmd/profiles_rename.go b/cmd/profiles_rename.go index 7fb3245..5f8b64a 100644 --- a/cmd/profiles_rename.go +++ b/cmd/profiles_rename.go @@ -24,8 +24,6 @@ import ( "fmt" "github.com/mfinelli/modctl/dbq" - "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" @@ -46,7 +44,7 @@ Profile names must be unique per game.`, ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { // Complete only the first positional arg (old profile name). if len(args) == 0 { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) } return nil, cobra.ShellCompDirectiveNoFileComp }, @@ -54,7 +52,7 @@ Profile names must be unique per game.`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -63,7 +61,7 @@ Profile names must be unique per game.`, q := dbq.New(db) // Resolve game install id: --game overrides active selection - gi, err := argresolver.GameInstall(ctx, q, profilesRenameGame) + gi, err := gameInstall(ctx, q, profilesRenameGame) if err != nil { return err } @@ -107,6 +105,6 @@ func init() { "Override the currently active game") profilesRenameCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) } diff --git a/cmd/profiles_setActive.go b/cmd/profiles_setActive.go index a38e079..b17ac91 100644 --- a/cmd/profiles_setActive.go +++ b/cmd/profiles_setActive.go @@ -24,8 +24,6 @@ import ( "fmt" "github.com/mfinelli/modctl/dbq" - "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" @@ -48,13 +46,13 @@ The current active game is used unless --game is provided.`, if len(args) > 0 { return nil, cobra.ShellCompDirectiveNoFileComp } - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -63,7 +61,7 @@ The current active game is used unless --game is provided.`, q := dbq.New(db) // Resolve game install id: --game overrides active selection - gi, err := argresolver.GameInstall(ctx, q, profilesSetActiveGame) + gi, err := gameInstall(ctx, q, profilesSetActiveGame) if err != nil { return err } @@ -117,6 +115,6 @@ func init() { "Override the currently active game") profilesSetActiveCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) } diff --git a/cmd/profiles_status.go b/cmd/profiles_status.go index e446e72..d65d01a 100644 --- a/cmd/profiles_status.go +++ b/cmd/profiles_status.go @@ -29,8 +29,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/nexusclient" "github.com/mfinelli/modctl/internal/style" "github.com/mfinelli/modctl/internal/updatechain" @@ -62,7 +60,7 @@ on the same path - run 'modctl apply --dry-run' for a precise diff.`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -79,7 +77,7 @@ on the same path - run 'modctl apply --dry-run' for a precise diff.`, q := dbq.New(db) // Resolve game install id: --game overrides active selection - gi, err := argresolver.GameInstall(ctx, q, profilesStatusGame) + gi, err := gameInstall(ctx, q, profilesStatusGame) if err != nil { return err } @@ -160,14 +158,14 @@ func init() { "Override the currently active game") profilesStatusCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesStatusCmd.Flags().StringVarP(&profilesStatusProfile, "profile", "p", "", "Override the currently active profile") profilesStatusCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) profilesStatusCmd.Flags().BoolVar(&profilesStatusCompact, "compact", false, diff --git a/cmd/profiles_upgrade.go b/cmd/profiles_upgrade.go index d938f12..0b1c775 100644 --- a/cmd/profiles_upgrade.go +++ b/cmd/profiles_upgrade.go @@ -25,7 +25,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/completion" "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" @@ -56,13 +55,13 @@ preserved.`, if len(args) != 0 { return nil, cobra.ShellCompDirectiveNoFileComp } - return completion.ModPageIDs(cmd, toComplete) + return completer().ModPageIDs(cmd, toComplete) }, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -70,7 +69,7 @@ preserved.`, q := dbq.New(db) - gi, err := argresolver.GameInstall(ctx, q, profilesUpgradeGame) + gi, err := gameInstall(ctx, q, profilesUpgradeGame) if err != nil { return err } @@ -189,20 +188,20 @@ func init() { "Override the currently active game") profilesUpgradeCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) profilesUpgradeCmd.Flags().StringVarP(&profilesUpgradeProfile, "profile", "p", "", "Override the currently active profile") profilesUpgradeCmd.RegisterFlagCompletionFunc("profile", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ProfileNames(cmd, toComplete) + return completer().ProfileNames(cmd, toComplete) }) profilesUpgradeCmd.Flags().StringVar(&profilesUpgradeTo, "to", "", "Specific mod file version ID to upgrade to") profilesUpgradeCmd.RegisterFlagCompletionFunc("to", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.ModFileVersionIDs(cmd, toComplete) + return completer().ModFileVersionIDs(cmd, toComplete) }) } diff --git a/cmd/root.go b/cmd/root.go index 65f0f68..85c1c52 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -118,8 +118,7 @@ func initConfig() { filepath.Join(xdg.DataHome, "modctl", "backups")) viper.SetDefault("overrides_dir", filepath.Join(xdg.DataHome, "modctl", "overrides")) - viper.SetDefault("locks_dir", - filepath.Join(xdg.StateHome, "modctl", "locks")) + viper.SetDefault("locks_dir", filepath.Join(stateDir(), "locks")) viper.SetDefault("cache_dir", filepath.Join(xdg.CacheHome, "modctl")) viper.SetDefault("tmp_dir", diff --git a/cmd/status.go b/cmd/status.go index bb3a751..5845d14 100644 --- a/cmd/status.go +++ b/cmd/status.go @@ -26,7 +26,6 @@ import ( "time" "github.com/mfinelli/modctl/dbq" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" "go.finelli.dev/util" @@ -48,7 +47,7 @@ For detailed profile information including mod list and conflicts, use RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } diff --git a/cmd/stores_list.go b/cmd/stores_list.go index 5101524..7908a18 100644 --- a/cmd/stores_list.go +++ b/cmd/stores_list.go @@ -22,7 +22,6 @@ import ( "fmt" "github.com/mfinelli/modctl/dbq" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" "go.finelli.dev/util" @@ -41,7 +40,7 @@ Only enabled stores are scanned during discovery.`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } diff --git a/cmd/stores_setActive.go b/cmd/stores_setActive.go index 41ea41c..78df8d5 100644 --- a/cmd/stores_setActive.go +++ b/cmd/stores_setActive.go @@ -25,8 +25,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal" - "github.com/mfinelli/modctl/internal/completion" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/state" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" @@ -47,13 +45,13 @@ The store must already be configured.`, if len(args) != 0 { return nil, cobra.ShellCompDirectiveNoFileComp } - return completion.StoreIDs(cmd, toComplete) + return completer().StoreIDs(cmd, toComplete) }, SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -73,14 +71,14 @@ The store must already be configured.`, return fmt.Errorf("store %q is disabled", storeID) } - a, err := state.LoadActive() + a, err := state.LoadActive(stateDir()) if err != nil { return err } a.ActiveStoreID = store.ID - if err := state.SaveActive(a); err != nil { + if err := state.SaveActive(stateDir(), a); err != nil { return err } diff --git a/cmd/unapply.go b/cmd/unapply.go index 86b44b2..9a6e7e6 100644 --- a/cmd/unapply.go +++ b/cmd/unapply.go @@ -27,9 +27,7 @@ import ( "time" "github.com/mfinelli/modctl/dbq" - "github.com/mfinelli/modctl/internal/argresolver" "github.com/mfinelli/modctl/internal/blobstore" - "github.com/mfinelli/modctl/internal/completion" "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/extractor" "github.com/mfinelli/modctl/internal/lock" @@ -63,7 +61,7 @@ Use --dry-run to preview the plan without making any changes.`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - db, err := database.Open(ctx) + db, err := openDB(ctx) if err != nil { return err } @@ -72,7 +70,7 @@ Use --dry-run to preview the plan without making any changes.`, q := dbq.New(db) // Resolve game install - gi, err := argresolver.GameInstall(ctx, q, unapplyGame) + gi, err := gameInstall(ctx, q, unapplyGame) if err != nil { return err } @@ -344,7 +342,7 @@ func init() { "Override the currently active game") unapplyCmd.RegisterFlagCompletionFunc("game", func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { - return completion.GameInstallSelectors(cmd, toComplete) + return completer().GameInstallSelectors(cmd, toComplete) }) unapplyCmd.Flags().BoolVar(&unapplyDryRun, "dry-run", false, diff --git a/internal/argresolver/game.go b/internal/argresolver/game.go index 3a440c5..2f3b608 100644 --- a/internal/argresolver/game.go +++ b/internal/argresolver/game.go @@ -44,19 +44,12 @@ import ( "github.com/mfinelli/modctl/internal/state" ) -// GameInstall is GameInstallIn for the state directory of the user. -// -// Transitional: see state.DefaultDir. -func GameInstall(ctx context.Context, q *dbq.Queries, arg string) (dbq.GameInstall, error) { - return GameInstallIn(ctx, q, state.DefaultDir(), arg) -} - -// GameInstallIn resolves the game install that a command is for: the one +// GameInstall resolves the game install that a command is for: the one // that arg says (see ExplicitGameInstall), or the active one, from the state // directory stateDir, if arg is empty, which is what leaving out --game means. -func GameInstallIn(ctx context.Context, q *dbq.Queries, stateDir string, arg string) (dbq.GameInstall, error) { +func GameInstall(ctx context.Context, q *dbq.Queries, stateDir string, arg string) (dbq.GameInstall, error) { if arg == "" { - active, err := state.LoadActiveIn(stateDir) + active, err := state.LoadActive(stateDir) if err != nil { return dbq.GameInstall{}, fmt.Errorf("load active selection: %w", err) } diff --git a/internal/argresolver/game_test.go b/internal/argresolver/game_test.go index 5f5e27b..7b2981d 100644 --- a/internal/argresolver/game_test.go +++ b/internal/argresolver/game_test.go @@ -167,7 +167,7 @@ func TestExplicitGameInstall(t *testing.T) { }) } -func TestGameInstallIn(t *testing.T) { +func TestGameInstall(t *testing.T) { t.Parallel() ctx := context.Background() @@ -180,13 +180,13 @@ func TestGameInstallIn(t *testing.T) { q := dbq.New(db) asked := testbuilder.NewGame(t, db).WithName("Cyberpunk 2077").WithStoreGameID("1").Build() active := testbuilder.NewGame(t, db).WithName("Skyrim").WithStoreGameID("2").Build() - require.NoError(t, state.SaveActiveIn(dir, state.Active{ActiveGameInstallID: active.ID})) + require.NoError(t, state.SaveActive(dir, state.Active{ActiveGameInstallID: active.ID})) - byID, err := GameInstallIn(ctx, q, dir, fmt.Sprintf("%d", asked.ID)) + byID, err := GameInstall(ctx, q, dir, fmt.Sprintf("%d", asked.ID)) require.NoError(t, err) assert.Equal(t, asked.ID, byID.ID, "not the active one") - byName, err := GameInstallIn(ctx, q, dir, "Cyberpunk 2077") + byName, err := GameInstall(ctx, q, dir, "Cyberpunk 2077") require.NoError(t, err) assert.Equal(t, asked.ID, byName.ID) }) @@ -197,9 +197,9 @@ func TestGameInstallIn(t *testing.T) { dir := t.TempDir() db := testbuilder.SetupDB(t) active := testbuilder.NewGame(t, db).Build() - require.NoError(t, state.SaveActiveIn(dir, state.Active{ActiveGameInstallID: active.ID})) + require.NoError(t, state.SaveActive(dir, state.Active{ActiveGameInstallID: active.ID})) - _, err := GameInstallIn(ctx, dbq.New(db), dir, "999999") + _, err := GameInstall(ctx, dbq.New(db), dir, "999999") require.Error(t, err) assert.Contains(t, err.Error(), "no game install with id 999999") @@ -213,9 +213,9 @@ func TestGameInstallIn(t *testing.T) { q := dbq.New(db) testbuilder.NewGame(t, db).WithName("Not Active").WithStoreGameID("1").Build() active := testbuilder.NewGame(t, db).WithName("Active").WithStoreGameID("2").Build() - require.NoError(t, state.SaveActiveIn(dir, state.Active{ActiveGameInstallID: active.ID})) + require.NoError(t, state.SaveActive(dir, state.Active{ActiveGameInstallID: active.ID})) - got, err := GameInstallIn(ctx, q, dir, "") + got, err := GameInstall(ctx, q, dir, "") require.NoError(t, err) assert.Equal(t, active.ID, got.ID) @@ -229,7 +229,7 @@ func TestGameInstallIn(t *testing.T) { db := testbuilder.SetupDB(t) testbuilder.NewGame(t, db).Build() - _, err := GameInstallIn(ctx, dbq.New(db), dir, "") + _, err := GameInstall(ctx, dbq.New(db), dir, "") require.Error(t, err) assert.Contains(t, err.Error(), "no active game selected") @@ -243,9 +243,9 @@ func TestGameInstallIn(t *testing.T) { dir := t.TempDir() db := testbuilder.SetupDB(t) // a store has been chosen, but not a game - require.NoError(t, state.SaveActiveIn(dir, state.Active{ActiveStoreID: "steam"})) + require.NoError(t, state.SaveActive(dir, state.Active{ActiveStoreID: "steam"})) - _, err := GameInstallIn(ctx, dbq.New(db), dir, "") + _, err := GameInstall(ctx, dbq.New(db), dir, "") require.Error(t, err) assert.Contains(t, err.Error(), "no active game selected") @@ -256,9 +256,9 @@ func TestGameInstallIn(t *testing.T) { dir := t.TempDir() db := testbuilder.SetupDB(t) - require.NoError(t, state.SaveActiveIn(dir, state.Active{ActiveGameInstallID: 424242})) + require.NoError(t, state.SaveActive(dir, state.Active{ActiveGameInstallID: 424242})) - _, err := GameInstallIn(ctx, dbq.New(db), dir, "") + _, err := GameInstall(ctx, dbq.New(db), dir, "") require.Error(t, err) assert.Contains(t, err.Error(), "no game install with id 424242") @@ -273,7 +273,7 @@ func TestGameInstallIn(t *testing.T) { path := filepath.Join(dir, "active.json") require.NoError(t, os.WriteFile(path, []byte("{ not json"), 0o644)) - _, err := GameInstallIn(ctx, dbq.New(db), dir, "") + _, err := GameInstall(ctx, dbq.New(db), dir, "") require.Error(t, err) assert.Contains(t, err.Error(), "load active selection") @@ -289,7 +289,7 @@ func TestGameInstallIn(t *testing.T) { path := filepath.Join(dir, "active.json") require.NoError(t, os.WriteFile(path, []byte("{ not json"), 0o644)) - got, err := GameInstallIn(ctx, dbq.New(db), dir, fmt.Sprintf("%d", gi.ID)) + got, err := GameInstall(ctx, dbq.New(db), dir, fmt.Sprintf("%d", gi.ID)) require.NoError(t, err) assert.Equal(t, gi.ID, got.ID) diff --git a/internal/completion/completer_test.go b/internal/completion/completer_test.go index 3675d03..b543494 100644 --- a/internal/completion/completer_test.go +++ b/internal/completion/completer_test.go @@ -183,7 +183,7 @@ func TestCompleterProfileNames(t *testing.T) { testbuilder.NewProfile(t, db).WithGame(active).WithName("Hardcore").Build() other := testbuilder.NewGame(t, db).WithName("Other").WithStoreGameID("2").Build() testbuilder.NewProfile(t, db).WithGame(other).WithName("Of the other game").Build() - require.NoError(t, state.SaveActiveIn(c.StateDir, state.Active{ActiveGameInstallID: active.ID})) + require.NoError(t, state.SaveActive(c.StateDir, state.Active{ActiveGameInstallID: active.ID})) got, directive := c.ProfileNames(commandWithGame(t, ""), "") @@ -198,7 +198,7 @@ func TestCompleterProfileNames(t *testing.T) { active := testbuilder.NewGame(t, db).WithName("Active").WithStoreGameID("1").Build() other := testbuilder.NewGame(t, db).WithName("Other").WithStoreGameID("2").Build() testbuilder.NewProfile(t, db).WithGame(other).WithName("Of the other game").Build() - require.NoError(t, state.SaveActiveIn(c.StateDir, state.Active{ActiveGameInstallID: active.ID})) + require.NoError(t, state.SaveActive(c.StateDir, state.Active{ActiveGameInstallID: active.ID})) got, _ := c.ProfileNames(commandWithGame(t, "2"), "of") @@ -211,7 +211,7 @@ func TestCompleterProfileNames(t *testing.T) { c, db := newCompleter(t) active := testbuilder.NewGame(t, db).Build() testbuilder.NewProfile(t, db).WithGame(active).WithName("Hardcore").Build() - require.NoError(t, state.SaveActiveIn(c.StateDir, state.Active{ActiveGameInstallID: active.ID})) + require.NoError(t, state.SaveActive(c.StateDir, state.Active{ActiveGameInstallID: active.ID})) got, _ := c.ProfileNames(commandWithGame(t, ""), "HARD") @@ -248,7 +248,7 @@ func TestCompleterProfileNames(t *testing.T) { c, db := newCompleter(t) active := testbuilder.NewGame(t, db).Build() - require.NoError(t, state.SaveActiveIn(c.StateDir, state.Active{ActiveGameInstallID: active.ID})) + require.NoError(t, state.SaveActive(c.StateDir, state.Active{ActiveGameInstallID: active.ID})) got, _ := c.ProfileNames(commandWithGame(t, "-5"), "") diff --git a/internal/completion/games.go b/internal/completion/games.go index 22bbdec..c6df36c 100644 --- a/internal/completion/games.go +++ b/internal/completion/games.go @@ -34,7 +34,7 @@ import ( func (c Completer) GameInstallSelectors(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { ctx := context.Background() - db, err := database.SetupReadOnlyAt(c.DatabasePath) + db, err := database.SetupReadOnly(c.DatabasePath) if err != nil { // No DB (not initialized) or error: don't fall back to file completion. return nil, cobra.ShellCompDirectiveNoFileComp diff --git a/internal/completion/modfileversions.go b/internal/completion/modfileversions.go index 6a4db51..3474205 100644 --- a/internal/completion/modfileversions.go +++ b/internal/completion/modfileversions.go @@ -36,7 +36,7 @@ import ( // a "Mod Page › File Label (version)" description. func (c Completer) ModFileVersionIDs(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { ctx := context.Background() - db, err := database.SetupReadOnlyAt(c.DatabasePath) + db, err := database.SetupReadOnly(c.DatabasePath) if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } @@ -71,7 +71,7 @@ func (c Completer) ModFileVersionIDs(cmd *cobra.Command, toComplete string) ([]s // Returns candidates as numeric IDs with a "File Label (version)" description. func (c Completer) ModFileVersionIDsForPage(cmd *cobra.Command, modPageArg string, toComplete string) ([]string, cobra.ShellCompDirective) { ctx := context.Background() - db, err := database.SetupReadOnlyAt(c.DatabasePath) + db, err := database.SetupReadOnly(c.DatabasePath) if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } @@ -147,7 +147,7 @@ func (c Completer) resolveGameInstallForCompletion(cmd *cobra.Command, ctx conte if f := cmd.Flags().Lookup("game"); f != nil && f.Changed { gameArg = f.Value.String() } else { - active, err := state.LoadActiveIn(c.StateDir) + active, err := state.LoadActive(c.StateDir) if err != nil || active.ActiveGameInstallID <= 0 { return dbq.GameInstall{}, fmt.Errorf("no active game") } diff --git a/internal/completion/modpages.go b/internal/completion/modpages.go index 49d5e54..e96fafa 100644 --- a/internal/completion/modpages.go +++ b/internal/completion/modpages.go @@ -34,7 +34,7 @@ import ( // source kind as the description. func (c Completer) ModPageIDs(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { ctx := context.Background() - db, err := database.SetupReadOnlyAt(c.DatabasePath) + db, err := database.SetupReadOnly(c.DatabasePath) if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } @@ -48,7 +48,7 @@ func (c Completer) ModPageIDs(cmd *cobra.Command, toComplete string) ([]string, } gameID = v } else { - active, err := state.LoadActiveIn(c.StateDir) + active, err := state.LoadActive(c.StateDir) if err != nil || active.ActiveGameInstallID <= 0 { return nil, cobra.ShellCompDirectiveNoFileComp } diff --git a/internal/completion/profiles.go b/internal/completion/profiles.go index cad59dc..0780e5c 100644 --- a/internal/completion/profiles.go +++ b/internal/completion/profiles.go @@ -43,7 +43,7 @@ import ( func (c Completer) ProfileNames(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { ctx := context.Background() - db, err := database.SetupReadOnlyAt(c.DatabasePath) + db, err := database.SetupReadOnly(c.DatabasePath) if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } @@ -58,7 +58,7 @@ func (c Completer) ProfileNames(cmd *cobra.Command, toComplete string) ([]string } gameID = v } else { - active, err := state.LoadActiveIn(c.StateDir) + active, err := state.LoadActive(c.StateDir) if err != nil || active.ActiveGameInstallID <= 0 { return nil, cobra.ShellCompDirectiveNoFileComp } diff --git a/internal/completion/store.go b/internal/completion/store.go index c336cdb..f373eb1 100644 --- a/internal/completion/store.go +++ b/internal/completion/store.go @@ -33,7 +33,7 @@ import ( func (c Completer) StoreIDs(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { ctx := context.Background() - db, err := database.SetupReadOnlyAt(c.DatabasePath) + db, err := database.SetupReadOnly(c.DatabasePath) if err != nil { // No DB (not initialized) or error: don't fall back to file completion. return nil, cobra.ShellCompDirectiveNoFileComp diff --git a/internal/completion/targets.go b/internal/completion/targets.go index fe67828..bc16dee 100644 --- a/internal/completion/targets.go +++ b/internal/completion/targets.go @@ -37,7 +37,7 @@ import ( // each target points without having to run a separate command. func (c Completer) TargetNames(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { ctx := context.Background() - db, err := database.SetupReadOnlyAt(c.DatabasePath) + db, err := database.SetupReadOnly(c.DatabasePath) if err != nil { return nil, cobra.ShellCompDirectiveNoFileComp } @@ -51,7 +51,7 @@ func (c Completer) TargetNames(cmd *cobra.Command, toComplete string) ([]string, } gameID = v } else { - active, err := state.LoadActiveIn(c.StateDir) + active, err := state.LoadActive(c.StateDir) if err != nil || active.ActiveGameInstallID <= 0 { return nil, cobra.ShellCompDirectiveNoFileComp } diff --git a/internal/completion/transitional.go b/internal/completion/transitional.go deleted file mode 100644 index 6ad0fd2..0000000 --- a/internal/completion/transitional.go +++ /dev/null @@ -1,66 +0,0 @@ -/* - * mod control (modctl): command-line mod manager - * Copyright © 2026 Mario Finelli - * - * This program is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * This program is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with this program. If not, see . - */ - -package completion - -import ( - "github.com/mfinelli/modctl/internal/state" - "github.com/spf13/cobra" - "github.com/spf13/viper" -) - -// Transitional: the functions in this file are the completions of a Completer -// that is made with the global configuration and the environment, at the time -// that a completion is asked for (the commands are set up before the -// configuration is read, so it can't be before). They are here for the commands -// that do not have a Completer of their own yet, and go with them. -// configured is the only thing in this package that reads either. -func configured() Completer { - return Completer{ - DatabasePath: viper.GetString("database"), - StateDir: state.DefaultDir(), - } -} - -func GameInstallSelectors(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { - return configured().GameInstallSelectors(cmd, toComplete) -} - -func ModFileVersionIDs(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { - return configured().ModFileVersionIDs(cmd, toComplete) -} - -func ModFileVersionIDsForPage(cmd *cobra.Command, modPageArg string, toComplete string) ([]string, cobra.ShellCompDirective) { - return configured().ModFileVersionIDsForPage(cmd, modPageArg, toComplete) -} - -func ModPageIDs(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { - return configured().ModPageIDs(cmd, toComplete) -} - -func ProfileNames(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { - return configured().ProfileNames(cmd, toComplete) -} - -func StoreIDs(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { - return configured().StoreIDs(cmd, toComplete) -} - -func TargetNames(cmd *cobra.Command, toComplete string) ([]string, cobra.ShellCompDirective) { - return configured().TargetNames(cmd, toComplete) -} diff --git a/internal/database/database.go b/internal/database/database.go index a22f533..30cd426 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -31,7 +31,6 @@ import ( _ "github.com/mattn/go-sqlite3" "github.com/pressly/goose/v3" goosedb "github.com/pressly/goose/v3/database" - "github.com/spf13/viper" ) // DB_PRAGMAS are the connection parameters for a read-write database: @@ -77,37 +76,20 @@ func DSN(path string, readOnly bool) string { return fmt.Sprintf("file:%s%s", url.PathEscape(path), pragmas) } -// SetupAt opens the read-write database at path, with the migrations not -// applied (see Migrate and OpenAt). Nothing is done to the file until the +// Setup opens the read-write database at path, with the migrations not +// applied (see Migrate and Open). Nothing is done to the file until the // first statement is: the driver is lazy, so a path that is not there is no // error here. -func SetupAt(path string) (*sql.DB, error) { +func Setup(path string) (*sql.DB, error) { return sql.Open("sqlite3", DSN(path, false)) } -// SetupReadOnlyAt is SetupAt for a database that is only read: the connection +// SetupReadOnly is Setup for a database that is only read: the connection // can't change it, and it is not created by looking for it. -func SetupReadOnlyAt(path string) (*sql.DB, error) { +func SetupReadOnly(path string) (*sql.DB, error) { return sql.Open("sqlite3", DSN(path, true)) } -// Setup is SetupAt for the database that is configured. -// -// Transitional: it reads the global configuration, which nothing in this -// package or in the ones that use it may do. It is only here for the callers -// that do not have the path of the database of their own to give to SetupAt -// yet, and goes with them. -func Setup() (*sql.DB, error) { - return SetupAt(viper.GetString("database")) -} - -// SetupReadOnly is SetupReadOnlyAt for the database that is configured. -// -// Transitional: see Setup. -func SetupReadOnly() (*sql.DB, error) { - return SetupReadOnlyAt(viper.GetString("database")) -} - func GooseProvider(db *sql.DB) (*goose.Provider, error) { // Make the provider FS point at the "migrations" directory within Migrations. fsys, err := migrationsFS() @@ -211,28 +193,21 @@ func latestMigrationVersion(fsys fs.FS) (int64, error) { return latest, nil } -// OpenAt opens the database that the commands use, at path: it has to be there +// Open opens the database that the commands use, at path: it has to be there // (it is made by init), and it is migrated to the latest schema before it is // handed over. The caller closes it. -func OpenAt(ctx context.Context, path string) (*sql.DB, error) { +func Open(ctx context.Context, path string) (*sql.DB, error) { return openWith(ctx, path, Migrate) } -// Open is OpenAt for the database that is configured. -// -// Transitional: see Setup. -func Open(ctx context.Context) (*sql.DB, error) { - return OpenAt(ctx, viper.GetString("database")) -} - -// openWith is OpenAt with the migrating done by migrate, so that a test can +// openWith is Open with the migrating done by migrate, so that a test can // have migrations that are not the ones that are embedded in the binary. func openWith(ctx context.Context, path string, migrate func(context.Context, *sql.DB) error) (*sql.DB, error) { - if err := EnsureExistsAt(path); err != nil { + if err := EnsureExists(path); err != nil { return nil, err } - db, err := SetupAt(path) + db, err := Setup(path) if err != nil { return nil, fmt.Errorf("error setting up database: %w", err) } @@ -245,16 +220,9 @@ func openWith(ctx context.Context, path string, migrate func(context.Context, *s return db, nil } -// EnsureExists is EnsureExistsAt for the database that is configured. -// -// Transitional: see Setup. -func EnsureExists() error { - return EnsureExistsAt(viper.GetString("database")) -} - -// EnsureExistsAt verifies that the database file at path exists and is a +// EnsureExists verifies that the database file at path exists and is a // regular file. If not, it returns a user-friendly error. -func EnsureExistsAt(path string) error { +func EnsureExists(path string) error { if path == "" { return fmt.Errorf("database path is not configured") } diff --git a/internal/database/database_test.go b/internal/database/database_test.go index 5d7cdb6..0df360b 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -149,7 +149,7 @@ func TestDSNReadOnly(t *testing.T) { }) } -func TestSetupAt(t *testing.T) { +func TestSetup(t *testing.T) { t.Parallel() t.Run("it opens a database that can be written, with foreign keys enforced", func(t *testing.T) { @@ -157,7 +157,7 @@ func TestSetupAt(t *testing.T) { path := filepath.Join(t.TempDir(), "modctl.db") - db, err := SetupAt(path) + db, err := Setup(path) require.NoError(t, err) defer db.Close() @@ -173,10 +173,10 @@ func TestSetupAt(t *testing.T) { t.Run("what it opens is the database at the path it is given", func(t *testing.T) { t.Parallel() - first, err := SetupAt(filepath.Join(t.TempDir(), "first.db")) + first, err := Setup(filepath.Join(t.TempDir(), "first.db")) require.NoError(t, err) defer first.Close() - second, err := SetupAt(filepath.Join(t.TempDir(), "second.db")) + second, err := Setup(filepath.Join(t.TempDir(), "second.db")) require.NoError(t, err) defer second.Close() @@ -188,14 +188,14 @@ func TestSetupAt(t *testing.T) { }) } -func TestSetupReadOnlyAt(t *testing.T) { +func TestSetupReadOnly(t *testing.T) { t.Parallel() t.Run("it reads what is there and can not change it", func(t *testing.T) { t.Parallel() path := filepath.Join(t.TempDir(), "modctl.db") - rw, err := SetupAt(path) + rw, err := Setup(path) require.NoError(t, err) defer rw.Close() _, err = rw.Exec(`CREATE TABLE things (id INTEGER PRIMARY KEY)`) @@ -203,7 +203,7 @@ func TestSetupReadOnlyAt(t *testing.T) { _, err = rw.Exec(`INSERT INTO things (id) VALUES (1)`) require.NoError(t, err) - db, err := SetupReadOnlyAt(path) + db, err := SetupReadOnly(path) require.NoError(t, err) defer db.Close() @@ -219,7 +219,7 @@ func TestSetupReadOnlyAt(t *testing.T) { path := filepath.Join(t.TempDir(), "nothing.db") - db, err := SetupReadOnlyAt(path) + db, err := SetupReadOnly(path) require.NoError(t, err) defer db.Close() diff --git a/internal/database/open_test.go b/internal/database/open_test.go index 8b169c4..e86e85e 100644 --- a/internal/database/open_test.go +++ b/internal/database/open_test.go @@ -166,7 +166,7 @@ func TestOpenDB(t *testing.T) { // Migrations is only filled in by the binary, so in a test there is // nothing to migrate with, which is an error and not an empty schema - db, err := OpenAt(ctx, emptyDatabaseFile(t)) + db, err := Open(ctx, emptyDatabaseFile(t)) require.Error(t, err) assert.Nil(t, db) diff --git a/internal/restore/full.go b/internal/restore/full.go index a48891f..128e71a 100644 --- a/internal/restore/full.go +++ b/internal/restore/full.go @@ -114,7 +114,7 @@ func Full( } // Reopen and migrate (handles case where bundle schema is older than binary) - newDB, err := database.SetupAt(dbPath) + newDB, err := database.Setup(dbPath) if err != nil { return res, fmt.Errorf("reopen database: %w", err) } diff --git a/internal/state/active.go b/internal/state/active.go index 08c7089..efc73c4 100644 --- a/internal/state/active.go +++ b/internal/state/active.go @@ -24,8 +24,6 @@ import ( "os" "path/filepath" "time" - - "github.com/adrg/xdg" ) // activeFilename is the file, in the state directory, that has the active @@ -39,10 +37,10 @@ type Active struct { UpdatedAt string `json:"updated_at,omitempty"` } -// LoadActiveIn reads the active selection from the state directory dir. There +// LoadActive reads the active selection from the state directory dir. There // is none until one is saved, which is an empty Active and not an error, and // nothing is made by looking: not the directory, and not the file. -func LoadActiveIn(dir string) (Active, error) { +func LoadActive(dir string) (Active, error) { p := filepath.Join(dir, activeFilename) b, err := os.ReadFile(p) @@ -60,9 +58,9 @@ func LoadActiveIn(dir string) (Active, error) { return a, nil } -// SaveActiveIn writes the active selection a to the state directory dir, which +// SaveActive writes the active selection a to the state directory dir, which // is made if it is not there. UpdatedAt is set to now, whatever a has in it. -func SaveActiveIn(dir string, a Active) error { +func SaveActive(dir string, a Active) error { if err := os.MkdirAll(dir, 0o700); err != nil { return fmt.Errorf("make %s: %w", dir, err) } @@ -89,28 +87,3 @@ func SaveActiveIn(dir string, a Active) error { return nil } - -// DefaultDir is the state directory of the user: where the active selection is -// kept, and what LoadActive and SaveActive use. -// -// Transitional: it reads the environment, which nothing in this package or in -// the ones that use it may do. It is only here for the callers that do not -// have the state directory of their own to give to LoadActiveIn and -// SaveActiveIn yet, and goes with them. -func DefaultDir() string { - return filepath.Join(xdg.StateHome, "modctl") -} - -// LoadActive is LoadActiveIn for the state directory of the user. -// -// Transitional: see DefaultDir. -func LoadActive() (Active, error) { - return LoadActiveIn(DefaultDir()) -} - -// SaveActive is SaveActiveIn for the state directory of the user. -// -// Transitional: see DefaultDir. -func SaveActive(a Active) error { - return SaveActiveIn(DefaultDir(), a) -} diff --git a/internal/state/active_test.go b/internal/state/active_test.go index bbe1fc4..7f98a5f 100644 --- a/internal/state/active_test.go +++ b/internal/state/active_test.go @@ -28,13 +28,13 @@ import ( "github.com/stretchr/testify/require" ) -func TestLoadActiveIn(t *testing.T) { +func TestLoadActive(t *testing.T) { t.Parallel() t.Run("there is none until one is saved", func(t *testing.T) { t.Parallel() - got, err := LoadActiveIn(t.TempDir()) + got, err := LoadActive(t.TempDir()) require.NoError(t, err) assert.Equal(t, Active{}, got) @@ -45,7 +45,7 @@ func TestLoadActiveIn(t *testing.T) { dir := filepath.Join(t.TempDir(), "not", "there", "yet") - got, err := LoadActiveIn(dir) + got, err := LoadActive(dir) require.NoError(t, err) assert.Equal(t, Active{}, got) @@ -62,9 +62,9 @@ func TestLoadActiveIn(t *testing.T) { ActiveGameInstallID: 7, ActiveGameInstallSelector: "steam:123#default", } - require.NoError(t, SaveActiveIn(dir, saved)) + require.NoError(t, SaveActive(dir, saved)) - got, err := LoadActiveIn(dir) + got, err := LoadActive(dir) require.NoError(t, err) saved.UpdatedAt = got.UpdatedAt // which is when it was saved @@ -79,7 +79,7 @@ func TestLoadActiveIn(t *testing.T) { path := filepath.Join(dir, "active.json") require.NoError(t, os.WriteFile(path, []byte("{ not json"), 0o644)) - _, err := LoadActiveIn(dir) + _, err := LoadActive(dir) require.Error(t, err) assert.Contains(t, err.Error(), "parse "+path) @@ -93,7 +93,7 @@ func TestLoadActiveIn(t *testing.T) { path := filepath.Join(dir, "active.json") require.NoError(t, os.Mkdir(path, 0o755)) - _, err := LoadActiveIn(dir) + _, err := LoadActive(dir) require.Error(t, err) assert.Contains(t, err.Error(), "read "+path) @@ -103,12 +103,12 @@ func TestLoadActiveIn(t *testing.T) { t.Parallel() first, second := t.TempDir(), t.TempDir() - require.NoError(t, SaveActiveIn(first, Active{ActiveGameInstallID: 1})) - require.NoError(t, SaveActiveIn(second, Active{ActiveGameInstallID: 2})) + require.NoError(t, SaveActive(first, Active{ActiveGameInstallID: 1})) + require.NoError(t, SaveActive(second, Active{ActiveGameInstallID: 2})) - a, err := LoadActiveIn(first) + a, err := LoadActive(first) require.NoError(t, err) - b, err := LoadActiveIn(second) + b, err := LoadActive(second) require.NoError(t, err) assert.Equal(t, int64(1), a.ActiveGameInstallID) @@ -116,7 +116,7 @@ func TestLoadActiveIn(t *testing.T) { }) } -func TestSaveActiveIn(t *testing.T) { +func TestSaveActive(t *testing.T) { t.Parallel() t.Run("the directory is made if it is not there", func(t *testing.T) { @@ -124,7 +124,7 @@ func TestSaveActiveIn(t *testing.T) { dir := filepath.Join(t.TempDir(), "state", "modctl") - require.NoError(t, SaveActiveIn(dir, Active{ActiveGameInstallID: 3})) + require.NoError(t, SaveActive(dir, Active{ActiveGameInstallID: 3})) assert.FileExists(t, filepath.Join(dir, "active.json")) }) @@ -133,11 +133,11 @@ func TestSaveActiveIn(t *testing.T) { t.Parallel() dir := t.TempDir() - require.NoError(t, SaveActiveIn(dir, Active{ActiveGameInstallID: 1, ActiveStoreID: "steam"})) + require.NoError(t, SaveActive(dir, Active{ActiveGameInstallID: 1, ActiveStoreID: "steam"})) - require.NoError(t, SaveActiveIn(dir, Active{ActiveGameInstallID: 2})) + require.NoError(t, SaveActive(dir, Active{ActiveGameInstallID: 2})) - got, err := LoadActiveIn(dir) + got, err := LoadActive(dir) require.NoError(t, err) assert.Equal(t, int64(2), got.ActiveGameInstallID) assert.Empty(t, got.ActiveStoreID, "what is not in it any more is gone") @@ -149,9 +149,9 @@ func TestSaveActiveIn(t *testing.T) { dir := t.TempDir() before := time.Now().UTC().Add(-time.Second) - require.NoError(t, SaveActiveIn(dir, Active{UpdatedAt: "1999-01-01T00:00:00.000Z"})) + require.NoError(t, SaveActive(dir, Active{UpdatedAt: "1999-01-01T00:00:00.000Z"})) - got, err := LoadActiveIn(dir) + got, err := LoadActive(dir) require.NoError(t, err) at, err := time.Parse("2006-01-02T15:04:05.000Z", got.UpdatedAt) require.NoError(t, err) @@ -162,7 +162,7 @@ func TestSaveActiveIn(t *testing.T) { t.Parallel() dir := t.TempDir() - require.NoError(t, SaveActiveIn(dir, Active{ActiveGameInstallID: 7})) + require.NoError(t, SaveActive(dir, Active{ActiveGameInstallID: 7})) b, err := os.ReadFile(filepath.Join(dir, "active.json")) @@ -177,8 +177,8 @@ func TestSaveActiveIn(t *testing.T) { dir := t.TempDir() - require.NoError(t, SaveActiveIn(dir, Active{ActiveGameInstallID: 7})) - require.NoError(t, SaveActiveIn(dir, Active{ActiveGameInstallID: 8})) + require.NoError(t, SaveActive(dir, Active{ActiveGameInstallID: 7})) + require.NoError(t, SaveActive(dir, Active{ActiveGameInstallID: 8})) entries, err := os.ReadDir(dir) require.NoError(t, err) @@ -195,7 +195,7 @@ func TestSaveActiveIn(t *testing.T) { require.NoError(t, os.WriteFile(blocker, nil, 0o644)) dir := filepath.Join(blocker, "modctl") - err := SaveActiveIn(dir, Active{ActiveGameInstallID: 1}) + err := SaveActive(dir, Active{ActiveGameInstallID: 1}) require.Error(t, err) assert.Contains(t, err.Error(), "make "+dir) From 066be6f4a69d74b8fff04637b60e374c833fa34b Mon Sep 17 00:00:00 2001 From: Mario Finelli Date: Mon, 12 Oct 2026 23:41:51 +0200 Subject: [PATCH 05/14] Add tests for nexusclient --- cmd/auth_nexus_status.go | 5 +- cmd/doctor.go | 2 +- cmd/env.go | 29 +++ cmd/export.go | 3 +- cmd/import.go | 3 +- cmd/init.go | 2 +- cmd/mods_import.go | 4 +- cmd/mods_info.go | 3 +- cmd/mods_list.go | 2 +- cmd/mods_nexus_checkUpdates.go | 8 +- cmd/mods_nexus_link.go | 2 +- cmd/profiles_status.go | 2 +- internal/nexusclient/cache_test.go | 284 +++++++++++++++++++++ internal/nexusclient/cachereader.go | 6 +- internal/nexusclient/client.go | 23 +- internal/nexusclient/client_test.go | 202 +++++++++++++++ internal/nexusclient/do.go | 4 +- internal/nexusclient/do_test.go | 308 +++++++++++++++++++++++ internal/nexusclient/nexusclient_test.go | 100 ++++++++ internal/nexusclient/sso.go | 29 ++- internal/nexusclient/sso_test.go | 261 +++++++++++++++++++ internal/nexusclient/state.go | 29 ++- internal/nexusclient/state_test.go | 202 +++++++++++++++ 23 files changed, 1467 insertions(+), 46 deletions(-) create mode 100644 internal/nexusclient/cache_test.go create mode 100644 internal/nexusclient/client_test.go create mode 100644 internal/nexusclient/do_test.go create mode 100644 internal/nexusclient/nexusclient_test.go create mode 100644 internal/nexusclient/sso_test.go create mode 100644 internal/nexusclient/state_test.go diff --git a/cmd/auth_nexus_status.go b/cmd/auth_nexus_status.go index fda6d4a..60ef227 100644 --- a/cmd/auth_nexus_status.go +++ b/cmd/auth_nexus_status.go @@ -23,7 +23,6 @@ import ( "strings" "time" - "github.com/mfinelli/modctl/internal/nexusclient" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" "github.com/spf13/viper" @@ -51,7 +50,7 @@ not count against your API request quota.`, } ctx := cmd.Context() - client, err := nexusclient.New(ctx, apiKey, logger, rootCmd.Version) + client, err := nexusClient(ctx, apiKey, logger) if err != nil { return fmt.Errorf("initializing nexus client: %w", err) } @@ -73,7 +72,7 @@ not count against your API request quota.`, kv.Write(&b, "username:", info.Name) // Rate limit state was updated as a side effect of ValidateUser - state, err := nexusclient.LoadRateLimitState() + state, err := loadRateLimitState() if err != nil { // Non-fatal: we already have the username, just skip quota display logger.Warn("failed to load rate limit state", "error", err) diff --git a/cmd/doctor.go b/cmd/doctor.go index 440b87a..a9dc596 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -298,7 +298,7 @@ func checkPaths() error { required := []string{ viper.GetString("archives_dir"), viper.GetString("backups_dir"), - viper.GetString("cache_dir"), + cacheDir(), viper.GetString("locks_dir"), viper.GetString("overrides_dir"), // viper.GetString("tmp_dir"), diff --git a/cmd/env.go b/cmd/env.go index 6a4cedd..73778cb 100644 --- a/cmd/env.go +++ b/cmd/env.go @@ -21,6 +21,7 @@ package cmd import ( "context" "database/sql" + "log/slog" "path/filepath" "github.com/adrg/xdg" @@ -28,6 +29,7 @@ import ( "github.com/mfinelli/modctl/internal/argresolver" "github.com/mfinelli/modctl/internal/completion" "github.com/mfinelli/modctl/internal/database" + "github.com/mfinelli/modctl/internal/nexusclient" "github.com/spf13/viper" ) @@ -48,6 +50,16 @@ func stateDir() string { return filepath.Join(xdg.StateHome, "modctl") } +// cacheDir is the directory that the cache of what Nexus Mods said is kept in. +func cacheDir() string { + return viper.GetString("cache_dir") +} + +// nexusCacheDBPath is the file of the cache of what Nexus Mods said. +func nexusCacheDBPath() string { + return nexusclient.CacheDBPath(cacheDir()) +} + // openDB opens the database that is configured, which has to be there (it is // made by init) and is migrated to the latest schema. The caller closes it. func openDB(ctx context.Context) (*sql.DB, error) { @@ -69,3 +81,20 @@ func completer() completion.Completer { StateDir: stateDir(), } } + +// nexusClient is the client of the API of Nexus Mods, with the cache and the +// rate limits that are configured. +func nexusClient(ctx context.Context, apiKey string, logger *slog.Logger) (*nexusclient.Client, error) { + return nexusclient.New(ctx, apiKey, logger, rootCmd.Version, cacheDir(), stateDir()) +} + +// nexusCacheReader is what reads the cache of what Nexus Mods said, for the +// commands that don't need the API. +func nexusCacheReader(ctx context.Context, logger *slog.Logger) (*nexusclient.CacheReader, error) { + return nexusclient.NewCacheReader(ctx, logger, cacheDir()) +} + +// loadRateLimitState is the rate limits that the API said last. +func loadRateLimitState() (*nexusclient.RateLimitState, error) { + return nexusclient.LoadRateLimitState(stateDir()) +} diff --git a/cmd/export.go b/cmd/export.go index a5e5ec2..ac5700b 100644 --- a/cmd/export.go +++ b/cmd/export.go @@ -21,7 +21,6 @@ package cmd import ( "fmt" "os" - "path/filepath" "time" "github.com/mfinelli/modctl/dbq" @@ -81,7 +80,7 @@ Examples: ModctlVersion: rootCmd.Version, SkipInventory: exportSkipInventory, NoVerify: exportNoVerify, - CacheDBPath: filepath.Join(viper.GetString("cache_dir"), "nexus_cache.db"), + CacheDBPath: nexusCacheDBPath(), Progress: printExportProgress, } diff --git a/cmd/import.go b/cmd/import.go index ca5d725..80afcea 100644 --- a/cmd/import.go +++ b/cmd/import.go @@ -22,7 +22,6 @@ import ( "context" "errors" "fmt" - "path/filepath" "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/blobstore" @@ -133,7 +132,7 @@ Use --dry-run to preview what would be imported without making any changes.`, SkipInventory: importSkipInventory, SameMachine: importSameMachine, Game: importGame, - CacheDBPath: filepath.Join(viper.GetString("cache_dir"), "nexus_cache.db"), + CacheDBPath: nexusCacheDBPath(), } // Warn if bundle modctl version is newer diff --git a/cmd/init.go b/cmd/init.go index ee04dd4..aee8ddd 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -52,7 +52,7 @@ multiple times and will not overwrite existing data.`, return fmt.Errorf("error creating backups directory: %w", err) } - err = os.MkdirAll(viper.GetString("cache_dir"), 0o0755) + err = os.MkdirAll(cacheDir(), 0o0755) if err != nil { return fmt.Errorf("error creating cache directory: %w", err) } diff --git a/cmd/mods_import.go b/cmd/mods_import.go index 389e851..9e82022 100644 --- a/cmd/mods_import.go +++ b/cmd/mods_import.go @@ -185,7 +185,7 @@ has been safely stored and the database has been updated successfully.`, if modsImportNexusUrl != "" && !modsImportSkipNexusLink { apiKey := viper.GetString("nexus.apikey") if apiKey != "" { - client, err := nexusclient.New(ctx, apiKey, logger, rootCmd.Version) + client, err := nexusClient(ctx, apiKey, logger) if err != nil { style.Println(style.Warning.Render(fmt.Sprintf(" ⚠ failed to initialize nexus client: %s", err))) } else { @@ -240,7 +240,7 @@ has been safely stored and the database has been updated successfully.`, if apiKey == "" { style.Println(style.Subtle.Render(" nexus api key not configured, skipping link")) } else { - client, err := nexusclient.New(ctx, apiKey, logger, rootCmd.Version) + client, err := nexusClient(ctx, apiKey, logger) if err != nil { // Non-fatal, warn and continue style.Println(style.Warning.Render(fmt.Sprintf(" ⚠ failed to initialize nexus client: %s", err))) diff --git a/cmd/mods_info.go b/cmd/mods_info.go index 840f0cd..3ce1807 100644 --- a/cmd/mods_info.go +++ b/cmd/mods_info.go @@ -29,7 +29,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal" "github.com/mfinelli/modctl/internal/argresolver" - "github.com/mfinelli/modctl/internal/nexusclient" "github.com/mfinelli/modctl/internal/nexusclient/dbc" "github.com/mfinelli/modctl/internal/style" "github.com/mfinelli/modctl/internal/updatechain" @@ -179,7 +178,7 @@ func runModsInfo( } } if mp.SourceKind == "nexus" && mp.NexusGameDomain.Valid && mp.NexusModID.Valid { - cacheReader, err := nexusclient.NewCacheReader(ctx, logger) + cacheReader, err := nexusCacheReader(ctx, logger) if err != nil { logger.Warn("failed to open nexus cache", "error", err) } else { diff --git a/cmd/mods_list.go b/cmd/mods_list.go index 2e340c7..5d56e70 100644 --- a/cmd/mods_list.go +++ b/cmd/mods_list.go @@ -163,7 +163,7 @@ only reads the cache: use ` + "`modctl mods nexus check-updates`" + ` to refresh for _, p := range pages { if p.NexusDomain.Valid && p.NexusModID.Valid { - c, err := nexusclient.NewCacheReader(ctx, logger) + c, err := nexusCacheReader(ctx, logger) if err != nil { logger.Warn("failed to open nexus cache", "error", err) break diff --git a/cmd/mods_nexus_checkUpdates.go b/cmd/mods_nexus_checkUpdates.go index e4ec770..c329bb9 100644 --- a/cmd/mods_nexus_checkUpdates.go +++ b/cmd/mods_nexus_checkUpdates.go @@ -78,7 +78,7 @@ to proceed even if the operation would exhaust your API quota.`, } defer db.Close() - client, err := nexusclient.New(ctx, apiKey, logger, rootCmd.Version) + client, err := nexusClient(ctx, apiKey, logger) if err != nil { return fmt.Errorf("initializing nexus client: %w", err) } @@ -105,7 +105,7 @@ to proceed even if the operation would exhaust your API quota.`, if _, err := client.ValidateUser(); err != nil { logger.Warn("failed to validate nexus user for quota check", "error", err) } - rateLimitState, err := nexusclient.LoadRateLimitState() + rateLimitState, err := loadRateLimitState() if err != nil { logger.Warn("failed to load rate limit state", "error", err) } @@ -124,7 +124,7 @@ to proceed even if the operation would exhaust your API quota.`, } // Open cache reader to check TTL and build superseded set - cacheReader, err := nexusclient.NewCacheReader(ctx, logger) + cacheReader, err := nexusCacheReader(ctx, logger) if err != nil { return fmt.Errorf("opening nexus cache: %w", err) } @@ -318,7 +318,7 @@ to proceed even if the operation would exhaust your API quota.`, // Re-sync remaining count from state file every 10 cold fetches if coldFetchCount%10 == 0 { - if s, err := nexusclient.LoadRateLimitState(); err == nil { + if s, err := loadRateLimitState(); err == nil { h, d := s.EffectiveRemaining() remaining = min(int64(h), int64(d)) } diff --git a/cmd/mods_nexus_link.go b/cmd/mods_nexus_link.go index dd628c8..ebd7caf 100644 --- a/cmd/mods_nexus_link.go +++ b/cmd/mods_nexus_link.go @@ -69,7 +69,7 @@ version for manual linking.`, } defer db.Close() - client, err := nexusclient.New(ctx, apiKey, logger, rootCmd.Version) + client, err := nexusClient(ctx, apiKey, logger) if err != nil { return fmt.Errorf("initializing nexus client: %w", err) } diff --git a/cmd/profiles_status.go b/cmd/profiles_status.go index d65d01a..8aa1821 100644 --- a/cmd/profiles_status.go +++ b/cmd/profiles_status.go @@ -66,7 +66,7 @@ on the same path - run 'modctl apply --dry-run' for a precise diff.`, } defer db.Close() - cacheReader, err := nexusclient.NewCacheReader(ctx, logger) + cacheReader, err := nexusCacheReader(ctx, logger) if err != nil { // non-fatal, just means we can't show nexus version info logger.Warn("failed to open nexus cache", "error", err) diff --git a/internal/nexusclient/cache_test.go b/internal/nexusclient/cache_test.go new file mode 100644 index 0000000..2f7a540 --- /dev/null +++ b/internal/nexusclient/cache_test.go @@ -0,0 +1,284 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package nexusclient + +import ( + "net/http" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const ( + modBody = `{"mod_id":1,"name":"SkyUI","summary":"An interface","author":"snakster","domain_name":"skyrim","version":"5.2","available":true}` + + filesBody = `{"files":[{"file_id":10,"name":"SkyUI","version":"5.1","category_name":"MAIN","is_primary":true,"file_name":"SkyUI-5.1.7z","size_in_bytes":1000,"uploaded_timestamp":1700000000},{"file_id":11,"name":"SkyUI","version":"5.2","category_name":"MAIN","is_primary":false,"file_name":"SkyUI-5.2.7z","size_in_bytes":2000,"uploaded_timestamp":1700001000}],"file_updates":[{"old_file_id":10,"new_file_id":11,"old_file_name":"a","new_file_name":"b","uploaded_timestamp":1700001000}]}` +) + +// ageCache makes what is cached older, by d, as if it had been fetched then. +func ageCache(t *testing.T, c *Client, d time.Duration) { + t.Helper() + + then := time.Now().Add(-d).UTC().Format(time.RFC3339) + _, err := c.db.Exec(`UPDATE nexus_mod_info SET fetched_at = ?`, then) + require.NoError(t, err) + _, err = c.db.Exec(`UPDATE nexus_file_info SET fetched_at = ?`, then) + require.NoError(t, err) +} + +func respondWith(body string) func(*http.Request) (*http.Response, error) { + return func(*http.Request) (*http.Response, error) { + return answer(http.StatusOK, body, nil), nil + } +} + +func TestGetModCached(t *testing.T) { + t.Parallel() + + t.Run("what is not cached is asked of the API, and cached", func(t *testing.T) { + t.Parallel() + + c, api := newTestClient(t, respondWith(modBody)) + + first, err := c.GetModCached("skyrim", 1) + require.NoError(t, err) + second, err := c.GetModCached("skyrim", 1) + require.NoError(t, err) + + assert.Equal(t, 1, api.asked(), "the second time it is in the cache") + assert.Equal(t, "/v1/games/skyrim/mods/1.json", api.last().URL.Path) + assert.Equal(t, "SkyUI", first.Name) + assert.Equal(t, first.Name, second.Name) + assert.Equal(t, first.Author, second.Author) + assert.Equal(t, "An interface", second.Summary) + assert.True(t, second.IsAvailable) + assert.Equal(t, "skyrim", second.DomainName) + }) + + t.Run("what is cached for less than a week is not asked of the API", func(t *testing.T) { + t.Parallel() + + c, api := newTestClient(t, respondWith(modBody)) + _, err := c.GetModCached("skyrim", 1) + require.NoError(t, err) + ageCache(t, c, ModInfoTTL-time.Hour) + + _, err = c.GetModCached("skyrim", 1) + + require.NoError(t, err) + assert.Equal(t, 1, api.asked()) + }) + + t.Run("what has been cached for more than a week is asked again", func(t *testing.T) { + t.Parallel() + + c, api := newTestClient(t, respondWith(modBody)) + _, err := c.GetModCached("skyrim", 1) + require.NoError(t, err) + ageCache(t, c, ModInfoTTL+time.Hour) + + _, err = c.GetModCached("skyrim", 1) + + require.NoError(t, err) + assert.Equal(t, 2, api.asked()) + }) + + t.Run("a time that can't be read is as old as it can be", func(t *testing.T) { + t.Parallel() + + c, api := newTestClient(t, respondWith(modBody)) + _, err := c.GetModCached("skyrim", 1) + require.NoError(t, err) + _, err = c.db.Exec(`UPDATE nexus_mod_info SET fetched_at = 'last tuesday'`) + require.NoError(t, err) + + _, err = c.GetModCached("skyrim", 1) + + require.NoError(t, err) + assert.Equal(t, 2, api.asked()) + }) + + t.Run("a mod is cached for the game that it is of", func(t *testing.T) { + t.Parallel() + + c, api := newTestClient(t, respondWith(modBody)) + _, err := c.GetModCached("skyrim", 1) + require.NoError(t, err) + + _, err = c.GetModCached("fallout4", 1) + + require.NoError(t, err) + assert.Equal(t, 2, api.asked(), "the same id of another game is another mod") + }) + + t.Run("the error of the API is the error, and nothing is cached", func(t *testing.T) { + t.Parallel() + + c, api := newTestClient(t, func(*http.Request) (*http.Response, error) { + return answer(http.StatusNotFound, "", nil), nil + }) + + _, err := c.GetModCached("skyrim", 1) + require.Error(t, err) + _, err = c.GetModCached("skyrim", 1) + require.Error(t, err) + + assert.Equal(t, 2, api.asked()) + assert.Contains(t, err.Error(), "fetching mod 1 (skyrim)") + }) +} + +func TestGetModFilesCached(t *testing.T) { + t.Parallel() + + t.Run("what is not cached is asked of the API, and cached with its updates", func(t *testing.T) { + t.Parallel() + + c, api := newTestClient(t, respondWith(filesBody)) + + first, err := c.GetModFilesCached("skyrim", 1) + require.NoError(t, err) + second, err := c.GetModFilesCached("skyrim", 1) + require.NoError(t, err) + + assert.Equal(t, 1, api.asked(), "the second time it is in the cache") + assert.Equal(t, "/v1/games/skyrim/mods/1/files.json", api.last().URL.Path) + require.Len(t, first.Files, 2) + require.Len(t, second.Files, 2) + got := map[int64]ModFileInfo{} + for _, f := range second.Files { + got[f.FileID] = f + } + assert.Equal(t, ModFileInfo{ + FileID: 10, Name: "SkyUI", Version: "5.1", CategoryName: "MAIN", IsPrimary: true, + FileName: "SkyUI-5.1.7z", SizeInBytes: 1000, UploadedTimestamp: 1700000000, + }, got[10]) + assert.False(t, got[11].IsPrimary) + require.Len(t, second.FileUpdates, 1) + assert.Equal(t, int64(10), second.FileUpdates[0].OldFileID) + assert.Equal(t, int64(11), second.FileUpdates[0].NewFileID) + }) + + t.Run("what has been cached for more than a day is asked again", func(t *testing.T) { + t.Parallel() + + c, api := newTestClient(t, respondWith(filesBody)) + _, err := c.GetModFilesCached("skyrim", 1) + require.NoError(t, err) + ageCache(t, c, ModFilesTTL+time.Hour) + + _, err = c.GetModFilesCached("skyrim", 1) + + require.NoError(t, err) + assert.Equal(t, 2, api.asked()) + }) + + t.Run("what is cached for less than a day is not", func(t *testing.T) { + t.Parallel() + + c, api := newTestClient(t, respondWith(filesBody)) + _, err := c.GetModFilesCached("skyrim", 1) + require.NoError(t, err) + ageCache(t, c, ModFilesTTL-time.Hour) + + _, err = c.GetModFilesCached("skyrim", 1) + + require.NoError(t, err) + assert.Equal(t, 1, api.asked()) + }) + + t.Run("the files that were cached are replaced by the ones that there are now", func(t *testing.T) { + t.Parallel() + + c, _ := newTestClient(t, nil) + require.NoError(t, c.cacheModFiles("skyrim", 1, &ModFilesResponse{ + Files: []ModFileInfo{{FileID: 10, Name: "old"}, {FileID: 11, Name: "old too"}}, + FileUpdates: []FileUpdateInfo{{OldFileID: 10, NewFileID: 11, UploadedTimestamp: 1}}, + })) + require.NoError(t, c.cacheModFiles("skyrim", 2, &ModFilesResponse{ + Files: []ModFileInfo{{FileID: 20, Name: "of another mod"}}, + })) + + require.NoError(t, c.cacheModFiles("skyrim", 1, &ModFilesResponse{ + Files: []ModFileInfo{{FileID: 12, Name: "new"}}, + })) + + got, err := c.GetModFilesCached("skyrim", 1) + require.NoError(t, err) + require.Len(t, got.Files, 1) + assert.Equal(t, int64(12), got.Files[0].FileID) + assert.Empty(t, got.FileUpdates, "and its updates") + other, err := c.GetModFilesCached("skyrim", 2) + require.NoError(t, err) + require.Len(t, other.Files, 1, "the files of the other mods are kept") + assert.Equal(t, int64(20), other.Files[0].FileID) + }) +} + +func TestCacheReaderGetUpdateChain(t *testing.T) { + t.Parallel() + + t.Run("a mod that has nothing cached has a chain in which every file is the latest", func(t *testing.T) { + t.Parallel() + + c, _ := newTestClient(t, nil) + + chain, err := c.GetUpdateChain("skyrim", 1) + + require.NoError(t, err) + assert.False(t, chain.HasSuccessor(10)) + assert.Equal(t, int64(10), chain.Head(10)) + }) + + t.Run("the updates that are cached are the chain", func(t *testing.T) { + t.Parallel() + + c, _ := newTestClient(t, nil) + require.NoError(t, c.cacheModFiles("skyrim", 1, &ModFilesResponse{ + Files: []ModFileInfo{{FileID: 10}, {FileID: 11}, {FileID: 12}}, + FileUpdates: []FileUpdateInfo{ + {OldFileID: 10, NewFileID: 11, UploadedTimestamp: 1}, + {OldFileID: 11, NewFileID: 12, UploadedTimestamp: 2}, + }, + })) + + chain, err := c.GetUpdateChain("skyrim", 1) + + require.NoError(t, err) + assert.True(t, chain.HasSuccessor(10)) + assert.Equal(t, int64(12), chain.Head(10)) + assert.False(t, chain.HasSuccessor(12)) + }) + + t.Run("the chain of a mod is not the chain of another", func(t *testing.T) { + t.Parallel() + + c, _ := newTestClient(t, nil) + require.NoError(t, c.cacheModFiles("skyrim", 1, &ModFilesResponse{ + FileUpdates: []FileUpdateInfo{{OldFileID: 10, NewFileID: 11, UploadedTimestamp: 1}}, + })) + + chain, err := c.GetUpdateChain("skyrim", 2) + + require.NoError(t, err) + assert.False(t, chain.HasSuccessor(10)) + }) +} diff --git a/internal/nexusclient/cachereader.go b/internal/nexusclient/cachereader.go index 7a5a86e..9266fbb 100644 --- a/internal/nexusclient/cachereader.go +++ b/internal/nexusclient/cachereader.go @@ -37,8 +37,10 @@ type CacheReader struct { logger *slog.Logger } -func NewCacheReader(ctx context.Context, logger *slog.Logger) (*CacheReader, error) { - db, err := openCacheDB(ctx) +// NewCacheReader opens the cache of what the API said, in the directory +// cacheDir (which has to be there). +func NewCacheReader(ctx context.Context, logger *slog.Logger, cacheDir string) (*CacheReader, error) { + db, err := openCacheDB(ctx, cacheDir) if err != nil { return nil, fmt.Errorf("opening nexus cache db: %w", err) } diff --git a/internal/nexusclient/client.go b/internal/nexusclient/client.go index f4f9bf1..da4b429 100644 --- a/internal/nexusclient/client.go +++ b/internal/nexusclient/client.go @@ -31,7 +31,6 @@ import ( "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/nexusclient/dbc" - "github.com/spf13/viper" "golang.org/x/time/rate" ) @@ -50,12 +49,18 @@ type Client struct { version string httpClient *http.Client limiter *rate.Limiter + + // stateDir is the directory that the rate limits are kept in. + stateDir string } -func New(ctx context.Context, apiKey string, logger *slog.Logger, version string) (*Client, error) { +// New makes a client for the API, with the cache of what it said in the +// directory cacheDir (which has to be there), and the rate limits that it said +// in the state directory stateDir. +func New(ctx context.Context, apiKey string, logger *slog.Logger, version string, cacheDir string, stateDir string) (*Client, error) { ua := buildUserAgent(version) - reader, err := NewCacheReader(ctx, logger) + reader, err := NewCacheReader(ctx, logger, cacheDir) if err != nil { return nil, fmt.Errorf("opening nexus cache db: %w", err) } @@ -74,11 +79,12 @@ func New(ctx context.Context, apiKey string, logger *slog.Logger, version string version: version, httpClient: httpClient, limiter: limiter, + stateDir: stateDir, }, nil } func (c *Client) RateLimitState() (*RateLimitState, error) { - return LoadRateLimitState() + return LoadRateLimitState(c.stateDir) } func buildUserAgent(version string) string { @@ -89,8 +95,13 @@ func buildUserAgent(version string) string { ) } -func openCacheDB(ctx context.Context) (*sql.DB, error) { - path := filepath.Join(viper.GetString("cache_dir"), "nexus_cache.db") +// CacheDBPath is the file of the cache, in the cache directory cacheDir. +func CacheDBPath(cacheDir string) string { + return filepath.Join(cacheDir, "nexus_cache.db") +} + +func openCacheDB(ctx context.Context, cacheDir string) (*sql.DB, error) { + path := CacheDBPath(cacheDir) db, err := sql.Open("sqlite3", database.DSN(path, false)) if err != nil { diff --git a/internal/nexusclient/client_test.go b/internal/nexusclient/client_test.go new file mode 100644 index 0000000..43a686f --- /dev/null +++ b/internal/nexusclient/client_test.go @@ -0,0 +1,202 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package nexusclient + +import ( + "context" + "database/sql" + "net/http" + "path/filepath" + "runtime" + "testing" + "time" + + "github.com/mfinelli/modctl/internal/nexusclient/dbc" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCacheDBPath(t *testing.T) { + t.Parallel() + + assert.Equal(t, filepath.Join("/some/cache", "nexus_cache.db"), CacheDBPath("/some/cache")) +} + +func TestNewCacheReader(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + t.Run("the cache is made in the directory that it is given", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + r, err := NewCacheReader(ctx, quietLogger(), dir) + require.NoError(t, err) + defer r.Close() + + assert.FileExists(t, CacheDBPath(dir)) + _, err = r.GetNexusModInfo("skyrim", 1) + assert.ErrorIs(t, err, sql.ErrNoRows, "and it has nothing in it") + }) + + t.Run("each directory has its own cache", func(t *testing.T) { + t.Parallel() + + first, err := NewCacheReader(ctx, quietLogger(), t.TempDir()) + require.NoError(t, err) + defer first.Close() + second, err := NewCacheReader(ctx, quietLogger(), t.TempDir()) + require.NoError(t, err) + defer second.Close() + + require.NoError(t, dbc.New(first.db).UpsertNexusModInfo(ctx, dbc.UpsertNexusModInfoParams{ + NexusGameDomain: "skyrim", NexusModID: 1, FetchedAt: time.Now().UTC().Format(time.RFC3339), + })) + + _, err = first.GetNexusModInfo("skyrim", 1) + assert.NoError(t, err) + _, err = second.GetNexusModInfo("skyrim", 1) + assert.ErrorIs(t, err, sql.ErrNoRows) + }) + + t.Run("a directory that is not there is an error that says so", func(t *testing.T) { + t.Parallel() + + r, err := NewCacheReader(ctx, quietLogger(), filepath.Join(t.TempDir(), "not-there")) + + require.Error(t, err) + assert.Nil(t, r) + assert.Contains(t, err.Error(), "opening nexus cache db") + }) + + t.Run("a cache that is of another version of the schema is started over", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + first, err := NewCacheReader(ctx, quietLogger(), dir) + require.NoError(t, err) + require.NoError(t, dbc.New(first.db).UpsertNexusModInfo(ctx, dbc.UpsertNexusModInfoParams{ + NexusGameDomain: "skyrim", NexusModID: 1, FetchedAt: time.Now().UTC().Format(time.RFC3339), + })) + _, err = first.db.Exec(`UPDATE cache_meta SET value = '0' WHERE key = 'schema_version'`) + require.NoError(t, err) + require.NoError(t, first.Close()) + + second, err := NewCacheReader(ctx, quietLogger(), dir) + require.NoError(t, err) + defer second.Close() + + _, err = second.GetNexusModInfo("skyrim", 1) + assert.ErrorIs(t, err, sql.ErrNoRows, "what was cached is not trusted") + version, err := dbc.New(second.db).ReadSchemaVersion(ctx) + require.NoError(t, err) + assert.Equal(t, cacheSchemaVersion, version) + }) + + t.Run("a cache that is of this version is kept", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + first, err := NewCacheReader(ctx, quietLogger(), dir) + require.NoError(t, err) + require.NoError(t, dbc.New(first.db).UpsertNexusModInfo(ctx, dbc.UpsertNexusModInfoParams{ + NexusGameDomain: "skyrim", NexusModID: 1, FetchedAt: time.Now().UTC().Format(time.RFC3339), + })) + require.NoError(t, first.Close()) + + second, err := NewCacheReader(ctx, quietLogger(), dir) + require.NoError(t, err) + defer second.Close() + + _, err = second.GetNexusModInfo("skyrim", 1) + assert.NoError(t, err) + }) +} + +func TestNew(t *testing.T) { + t.Parallel() + + t.Run("it is made with the directories that it is given", func(t *testing.T) { + t.Parallel() + + cacheDir, stateDir := t.TempDir(), t.TempDir() + + c, err := New(context.Background(), "the-key", quietLogger(), "1.2.3", cacheDir, stateDir) + require.NoError(t, err) + defer c.Close() + + assert.FileExists(t, CacheDBPath(cacheDir)) + assert.Equal(t, stateDir, c.stateDir) + assert.Equal(t, "the-key", c.apiKey) + }) + + t.Run("a cache directory that is not there is an error that says so", func(t *testing.T) { + t.Parallel() + + c, err := New(context.Background(), "k", quietLogger(), "1", filepath.Join(t.TempDir(), "not-there"), t.TempDir()) + + require.Error(t, err) + assert.Nil(t, c) + assert.Contains(t, err.Error(), "opening nexus cache db") + }) +} + +func TestBuildUserAgent(t *testing.T) { + t.Parallel() + + assert.Equal(t, + "modctl/1.2.3 ("+runtime.GOOS+"; "+runtime.GOARCH+") https://github.com/mfinelli/modctl", + buildUserAgent("1.2.3")) +} + +func TestClientRateLimitState(t *testing.T) { + t.Parallel() + + t.Run("it is the one of the state directory of the client", func(t *testing.T) { + t.Parallel() + + c, _ := newTestClient(t, nil) + require.NoError(t, SaveRateLimitState(c.stateDir, &RateLimitState{HourlyRemaining: 12})) + other, _ := newTestClient(t, nil) + + got, err := c.RateLimitState() + require.NoError(t, err) + gotOther, err := other.RateLimitState() + require.NoError(t, err) + + assert.Equal(t, 12, got.HourlyRemaining) + assert.Equal(t, 0, gotOther.HourlyRemaining, "and not the one of another client") + }) +} + +// the fake API is what a client is made to talk to by newTestClient: a client +// that is not given an answer has an error for every request +func TestNewTestClientHasNoNetwork(t *testing.T) { + t.Parallel() + + c, api := newTestClient(t, nil) + + _, err := c.GetMod("skyrim", 1) + + require.Error(t, err) + assert.Equal(t, 1, api.asked()) + assert.Equal(t, http.MethodGet, api.last().Method) +} diff --git a/internal/nexusclient/do.go b/internal/nexusclient/do.go index 4f436db..802aefc 100644 --- a/internal/nexusclient/do.go +++ b/internal/nexusclient/do.go @@ -108,7 +108,7 @@ func (c *Client) doRequest(method, path string, dst any, rawBody *[]byte) error } if resp.StatusCode == http.StatusTooManyRequests { - state, err := LoadRateLimitState() + state, err := LoadRateLimitState(c.stateDir) if err != nil { c.logger.Warn("failed to load rate limit state for 429 error", "error", err) } @@ -157,7 +157,7 @@ func (c *Client) updateRateLimitState(resp *http.Response) error { return nil } - return SaveRateLimitState(&RateLimitState{ + return SaveRateLimitState(c.stateDir, &RateLimitState{ HourlyLimit: hourlyLimit, HourlyRemaining: hourlyRemaining, HourlyReset: hourlyReset, diff --git a/internal/nexusclient/do_test.go b/internal/nexusclient/do_test.go new file mode 100644 index 0000000..0a242ef --- /dev/null +++ b/internal/nexusclient/do_test.go @@ -0,0 +1,308 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package nexusclient + +import ( + "errors" + "net/http" + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// rateLimitHeaders are the headers of a response of the API that say where the +// limits are: 90 of 100 are left for the hour, and 2400 of 2500 for the day. +func rateLimitHeaders() map[string]string { + return map[string]string{ + "X-RL-Hourly-Limit": "100", + "X-RL-Hourly-Remaining": "90", + "X-RL-Hourly-Reset": "2099-01-01 15:00:00 +0000", + "X-RL-Daily-Limit": "2500", + "X-RL-Daily-Remaining": "2400", + "X-RL-Daily-Reset": "2099-01-02 00:00:00 +0000", + } +} + +func TestDoRequest(t *testing.T) { + t.Parallel() + + t.Run("the request says who is asking, and what for", func(t *testing.T) { + t.Parallel() + + c, api := newTestClient(t, respondWith(`{}`)) + + var dst struct{} + require.NoError(t, c.doRequest(http.MethodGet, "/v1/games/skyrim/mods/1.json", &dst, nil)) + + req := api.last() + assert.Equal(t, http.MethodGet, req.Method) + assert.Equal(t, "https://api.nexusmods.com/v1/games/skyrim/mods/1.json", req.URL.String()) + assert.Equal(t, "the-key", req.Header.Get("apikey")) + assert.Equal(t, "application/json", req.Header.Get("Accept")) + assert.Equal(t, buildUserAgent("1.2.3"), req.Header.Get("User-Agent")) + assert.Equal(t, "modctl", req.Header.Get("Application-Name")) + assert.Equal(t, "1.2.3", req.Header.Get("Application-Version")) + }) + + t.Run("the answer is decoded, and what it was is kept", func(t *testing.T) { + t.Parallel() + + c, _ := newTestClient(t, respondWith(modBody)) + + var dst ModInfo + var raw []byte + require.NoError(t, c.doRequest(http.MethodGet, "/x", &dst, &raw)) + + assert.Equal(t, "SkyUI", dst.Name) + assert.Equal(t, modBody, string(raw)) + }) + + t.Run("what the API says about the limits is kept, whatever the answer is", func(t *testing.T) { + t.Parallel() + + for _, status := range []int{http.StatusOK, http.StatusNotFound, http.StatusInternalServerError} { + status := status + t.Run(http.StatusText(status), func(t *testing.T) { + t.Parallel() + + c, _ := newTestClient(t, func(*http.Request) (*http.Response, error) { + return answer(status, `{}`, rateLimitHeaders()), nil + }) + + _ = c.doRequest(http.MethodGet, "/x", &struct{}{}, nil) + + got, err := c.RateLimitState() + require.NoError(t, err) + assert.Equal(t, 90, got.HourlyRemaining) + assert.Equal(t, 2400, got.DailyRemaining) + }) + } + }) + + t.Run("a limit that can't be kept is not an error of the request", func(t *testing.T) { + t.Parallel() + + c, _ := newTestClient(t, func(*http.Request) (*http.Response, error) { + return answer(http.StatusOK, `{}`, rateLimitHeaders()), nil + }) + // there is a directory where the file should be + require.NoError(t, os.Mkdir(filepath.Join(c.stateDir, "nexus.json"), 0o755)) + + err := c.doRequest(http.MethodGet, "/x", &struct{}{}, nil) + + assert.NoError(t, err) + }) + + t.Run("a request that is too many says how many are left", func(t *testing.T) { + t.Parallel() + + headers := rateLimitHeaders() + headers["X-RL-Hourly-Remaining"] = "0" + headers["X-RL-Daily-Remaining"] = "12" + c, _ := newTestClient(t, func(*http.Request) (*http.Response, error) { + return answer(http.StatusTooManyRequests, ``, headers), nil + }) + + err := c.doRequest(http.MethodGet, "/x", &struct{}{}, nil) + + require.Error(t, err) + assert.Contains(t, err.Error(), "rate limit exceeded") + assert.Contains(t, err.Error(), "hourly remaining: 0") + assert.Contains(t, err.Error(), "daily remaining: 12") + }) + + t.Run("a limit that is over is all of it again", func(t *testing.T) { + t.Parallel() + + headers := rateLimitHeaders() + headers["X-RL-Hourly-Remaining"] = "0" + headers["X-RL-Hourly-Reset"] = "2001-01-01 00:00:00 +0000" + c, _ := newTestClient(t, func(*http.Request) (*http.Response, error) { + return answer(http.StatusTooManyRequests, ``, headers), nil + }) + + err := c.doRequest(http.MethodGet, "/x", &struct{}{}, nil) + + require.Error(t, err) + assert.Contains(t, err.Error(), "hourly remaining: 100") + }) + + t.Run("a status that is not a success is an error that says which", func(t *testing.T) { + t.Parallel() + + c, _ := newTestClient(t, func(*http.Request) (*http.Response, error) { + return answer(http.StatusForbidden, `{}`, nil), nil + }) + + err := c.doRequest(http.MethodGet, "/x", &struct{}{}, nil) + + require.Error(t, err) + assert.Contains(t, err.Error(), "status 403") + }) + + t.Run("an answer that is not what was asked for is an error", func(t *testing.T) { + t.Parallel() + + c, _ := newTestClient(t, respondWith(`not json`)) + + err := c.doRequest(http.MethodGet, "/x", &struct{}{}, nil) + + require.Error(t, err) + assert.Contains(t, err.Error(), "decoding response") + }) + + t.Run("a request that can't be made is an error that says so", func(t *testing.T) { + t.Parallel() + + boom := errors.New("connection refused") + c, _ := newTestClient(t, func(*http.Request) (*http.Response, error) { return nil, boom }) + + err := c.doRequest(http.MethodGet, "/x", &struct{}{}, nil) + + require.ErrorIs(t, err, boom) + assert.Contains(t, err.Error(), "executing request") + }) +} + +func TestUpdateRateLimitState(t *testing.T) { + t.Parallel() + + t.Run("the headers are the limits that are kept", func(t *testing.T) { + t.Parallel() + + c, _ := newTestClient(t, nil) + + require.NoError(t, c.updateRateLimitState(answer(http.StatusOK, ``, rateLimitHeaders()))) + + got, err := c.RateLimitState() + require.NoError(t, err) + assert.Equal(t, 100, got.HourlyLimit) + assert.Equal(t, 90, got.HourlyRemaining) + assert.Equal(t, 2500, got.DailyLimit) + assert.Equal(t, 2400, got.DailyRemaining) + assert.True(t, time.Date(2099, 1, 1, 15, 0, 0, 0, time.UTC).Equal(got.HourlyReset)) + assert.True(t, time.Date(2099, 1, 2, 0, 0, 0, 0, time.UTC).Equal(got.DailyReset)) + }) + + t.Run("a reset has the time zone that it says", func(t *testing.T) { + t.Parallel() + + c, _ := newTestClient(t, nil) + headers := rateLimitHeaders() + headers["X-RL-Hourly-Reset"] = "2099-01-01 15:00:00 +0200" + + require.NoError(t, c.updateRateLimitState(answer(http.StatusOK, ``, headers))) + + got, err := c.RateLimitState() + require.NoError(t, err) + assert.True(t, time.Date(2099, 1, 1, 13, 0, 0, 0, time.UTC).Equal(got.HourlyReset)) + }) + + t.Run("a header that is not there, or is not what it should be, keeps nothing", func(t *testing.T) { + t.Parallel() + + for _, header := range []string{ + "X-RL-Hourly-Limit", "X-RL-Hourly-Remaining", "X-RL-Hourly-Reset", + "X-RL-Daily-Limit", "X-RL-Daily-Remaining", "X-RL-Daily-Reset", + } { + header := header + for name, value := range map[string]string{"missing": "", "invalid": "soon"} { + value := value + t.Run(header+" "+name, func(t *testing.T) { + t.Parallel() + + c, _ := newTestClient(t, nil) + headers := rateLimitHeaders() + if value == "" { + delete(headers, header) + } else { + headers[header] = value + } + + err := c.updateRateLimitState(answer(http.StatusOK, ``, headers)) + + assert.NoError(t, err, "it is not an error that the API did not say") + assert.NoFileExists(t, filepath.Join(c.stateDir, "nexus.json")) + }) + } + } + }) +} + +func TestGetMod(t *testing.T) { + t.Parallel() + + t.Run("it is the mod that the API says, with what it said", func(t *testing.T) { + t.Parallel() + + c, api := newTestClient(t, respondWith(modBody)) + + got, err := c.GetMod("skyrim", 1) + + require.NoError(t, err) + assert.Equal(t, "/v1/games/skyrim/mods/1.json", api.last().URL.Path) + assert.Equal(t, "SkyUI", got.Name) + assert.Equal(t, "5.2", got.Version) + assert.Equal(t, modBody, string(got.RawJSON)) + }) + + t.Run("an error says which mod it was", func(t *testing.T) { + t.Parallel() + + c, _ := newTestClient(t, nil) + + _, err := c.GetMod("skyrim", 42) + + require.Error(t, err) + assert.Contains(t, err.Error(), "fetching mod 42 (skyrim)") + }) +} + +func TestGetModFiles(t *testing.T) { + t.Parallel() + + t.Run("they are the files that the API says, with what it said", func(t *testing.T) { + t.Parallel() + + c, api := newTestClient(t, respondWith(filesBody)) + + got, err := c.GetModFiles("skyrim", 1) + + require.NoError(t, err) + assert.Equal(t, "/v1/games/skyrim/mods/1/files.json", api.last().URL.Path) + assert.Len(t, got.Files, 2) + assert.Len(t, got.FileUpdates, 1) + assert.Equal(t, filesBody, string(got.RawJSON)) + }) + + t.Run("an error says which mod it was", func(t *testing.T) { + t.Parallel() + + c, _ := newTestClient(t, nil) + + _, err := c.GetModFiles("skyrim", 42) + + require.Error(t, err) + assert.Contains(t, err.Error(), "fetching files for mod 42 (skyrim)") + }) +} diff --git a/internal/nexusclient/nexusclient_test.go b/internal/nexusclient/nexusclient_test.go new file mode 100644 index 0000000..9b3db8a --- /dev/null +++ b/internal/nexusclient/nexusclient_test.go @@ -0,0 +1,100 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package nexusclient + +import ( + "context" + "errors" + "io" + "log/slog" + "net/http" + "strings" + "sync" + "testing" + + "github.com/stretchr/testify/require" +) + +func quietLogger() *slog.Logger { + return slog.New(slog.NewTextHandler(io.Discard, nil)) +} + +// fakeNexus is what a client talks to instead of Nexus Mods: it answers what +// the test says, and keeps what it was asked. A request that the test did not +// say what to answer to is an error of the request, so nothing that a test does +// can reach the real API. +type fakeNexus struct { + mu sync.Mutex + requests []*http.Request + respond func(r *http.Request) (*http.Response, error) +} + +func (f *fakeNexus) RoundTrip(r *http.Request) (*http.Response, error) { + f.mu.Lock() + f.requests = append(f.requests, r) + f.mu.Unlock() + + if f.respond == nil { + return nil, errors.New("no answer for the request: tests do not talk to Nexus Mods") + } + return f.respond(r) +} + +// asked is how many requests the client made. +func (f *fakeNexus) asked() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.requests) +} + +// last is the last request that the client made. +func (f *fakeNexus) last() *http.Request { + f.mu.Lock() + defer f.mu.Unlock() + return f.requests[len(f.requests)-1] +} + +// answer is a response of the API with the status and the body, and the headers +// that the API sets (the rate limits, usually). +func answer(status int, body string, headers map[string]string) *http.Response { + h := http.Header{} + for k, v := range headers { + h.Set(k, v) + } + return &http.Response{ + StatusCode: status, + Header: h, + Body: io.NopCloser(strings.NewReader(body)), + } +} + +// newTestClient is a client with a cache directory and a state directory of the +// test, that talks to the fake API that it returns, which answers what respond +// says (nothing, if it is nil: any request is an error). +func newTestClient(t *testing.T, respond func(r *http.Request) (*http.Response, error)) (*Client, *fakeNexus) { + t.Helper() + + c, err := New(context.Background(), "the-key", quietLogger(), "1.2.3", t.TempDir(), t.TempDir()) + require.NoError(t, err) + t.Cleanup(func() { c.Close() }) + + api := &fakeNexus{respond: respond} + c.httpClient = &http.Client{Transport: api} + return c, api +} diff --git a/internal/nexusclient/sso.go b/internal/nexusclient/sso.go index db3bfcc..95024df 100644 --- a/internal/nexusclient/sso.go +++ b/internal/nexusclient/sso.go @@ -57,7 +57,30 @@ type apiKeyPayload struct { // Progress messages are written to w. The caller should wrap ctx with a // timeout before calling (DefaultSSOTimeout is the recommended value) func Login(ctx context.Context, w io.Writer) (string, error) { - conn, _, err := websocket.Dial(ctx, ssoWSURL, nil) + return login(ctx, w, ssoServer{ + wsURL: ssoWSURL, + authURL: ssoAuthURL, + openBrowser: openBrowser, + }) +} + +// ssoServer is what the SSO flow talks to, and how it sends the user there. It +// is Nexus Mods and the browser of the user, which a test can't talk to (and +// must not open), so a test has one of its own. +type ssoServer struct { + // wsURL is where the flow is run, which is a websocket. + wsURL string + + // authURL is the page where the user authorizes modctl. + authURL string + + // openBrowser opens url for the user, if it can. + openBrowser func(url string) +} + +// login is Login for the server s. +func login(ctx context.Context, w io.Writer, s ssoServer) (string, error) { + conn, _, err := websocket.Dial(ctx, s.wsURL, nil) if err != nil { return "", fmt.Errorf("connecting to Nexus SSO: %w", err) } @@ -92,9 +115,9 @@ func Login(ctx context.Context, w io.Writer) (string, error) { var tok connectionTokenPayload if err := json.Unmarshal(envelope.Data, &tok); err == nil && tok.ConnectionToken != "" { - authURL := fmt.Sprintf("%s?id=%s&application=%s", ssoAuthURL, id, AppSlug) + authURL := fmt.Sprintf("%s?id=%s&application=%s", s.authURL, id, AppSlug) fmt.Fprintf(w, "\nOpen this URL in your browser to authorize modctl with Nexus Mods:\n\n %s\n\nWaiting for authorization (Ctrl+C to cancel)...\n", authURL) - openBrowser(authURL) + s.openBrowser(authURL) continue } diff --git a/internal/nexusclient/sso_test.go b/internal/nexusclient/sso_test.go new file mode 100644 index 0000000..02a4364 --- /dev/null +++ b/internal/nexusclient/sso_test.go @@ -0,0 +1,261 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package nexusclient + +import ( + "bytes" + "context" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/coder/websocket" + "github.com/coder/websocket/wsjson" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +const testAuthURL = "https://sso.example.test/authorize" + +// ssoScript is what the server of a test does, once the client has said hello: +// it is given the connection, and what the client said. +type ssoScript func(ctx context.Context, conn *websocket.Conn, hello map[string]any) + +// ssoTestServer is a server of the SSO flow that does what script says, and the +// address that the client talks to it at. A test can't talk to Nexus Mods, so +// this is what login is given. +func ssoTestServer(t *testing.T, script ssoScript) string { + t.Helper() + + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + conn, err := websocket.Accept(w, r, nil) + if err != nil { + return + } + defer conn.CloseNow() + + var hello map[string]any + if err := wsjson.Read(r.Context(), conn, &hello); err != nil { + return + } + script(r.Context(), conn, hello) + })) + t.Cleanup(srv.Close) + + return "ws" + strings.TrimPrefix(srv.URL, "http") +} + +// browser is what opens the page of the user in a test: it keeps what it was +// asked to open, and has nothing to do with the browser of whoever runs the +// tests. +type browser struct { + mu sync.Mutex + opened []string + then func() +} + +func (b *browser) open(url string) { + b.mu.Lock() + b.opened = append(b.opened, url) + b.mu.Unlock() + if b.then != nil { + b.then() + } +} + +func (b *browser) urls() []string { + b.mu.Lock() + defer b.mu.Unlock() + return append([]string(nil), b.opened...) +} + +func sendToken(ctx context.Context, conn *websocket.Conn) error { + return wsjson.Write(ctx, conn, map[string]any{"success": true, "data": map[string]any{"connection_token": "tok"}}) +} + +func sendKey(ctx context.Context, conn *websocket.Conn, key string) error { + return wsjson.Write(ctx, conn, map[string]any{"success": true, "data": map[string]any{"api_key": key}}) +} + +func TestLogin(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + t.Run("the user is sent to the page, and what they authorize is the key", func(t *testing.T) { + t.Parallel() + + heard := make(chan map[string]any, 1) + wsURL := ssoTestServer(t, func(ctx context.Context, conn *websocket.Conn, h map[string]any) { + heard <- h + _ = sendToken(ctx, conn) + _ = sendKey(ctx, conn, "the-api-key") + }) + b := &browser{} + var out bytes.Buffer + + key, err := login(ctx, &out, ssoServer{wsURL: wsURL, authURL: testAuthURL, openBrowser: b.open}) + + require.NoError(t, err) + assert.Equal(t, "the-api-key", key) + hello := <-heard + id, _ := hello["id"].(string) + require.NotEmpty(t, id, "it says who it is") + assert.Equal(t, float64(2), hello["protocol"]) + assert.Contains(t, hello, "token") + assert.Nil(t, hello["token"]) + want := testAuthURL + "?id=" + id + "&application=modctl-modctl" + assert.Equal(t, []string{want}, b.urls(), "the page is opened once, with the id of the flow") + assert.Contains(t, out.String(), want, "and it is said, for a browser that did not open") + }) + + t.Run("every flow has an id of its own", func(t *testing.T) { + t.Parallel() + + ids := make([]string, 2) + for i := range ids { + heard := make(chan map[string]any, 1) + wsURL := ssoTestServer(t, func(ctx context.Context, conn *websocket.Conn, h map[string]any) { + heard <- h + _ = sendKey(ctx, conn, "k") + }) + _, err := login(ctx, &bytes.Buffer{}, ssoServer{wsURL: wsURL, authURL: testAuthURL, openBrowser: (&browser{}).open}) + require.NoError(t, err) + ids[i], _ = (<-heard)["id"].(string) + } + + assert.NotEmpty(t, ids[0]) + assert.NotEqual(t, ids[0], ids[1]) + }) + + t.Run("a key that comes before there is a page to go to is still the key", func(t *testing.T) { + t.Parallel() + + wsURL := ssoTestServer(t, func(ctx context.Context, conn *websocket.Conn, _ map[string]any) { + _ = sendKey(ctx, conn, "early") + }) + b := &browser{} + + key, err := login(ctx, &bytes.Buffer{}, ssoServer{wsURL: wsURL, authURL: testAuthURL, openBrowser: b.open}) + + require.NoError(t, err) + assert.Equal(t, "early", key) + assert.Empty(t, b.urls()) + }) + + t.Run("a message that is not understood is said, and waited past", func(t *testing.T) { + t.Parallel() + + wsURL := ssoTestServer(t, func(ctx context.Context, conn *websocket.Conn, _ map[string]any) { + _ = wsjson.Write(ctx, conn, map[string]any{"success": true, "data": map[string]any{"something": "else"}}) + _ = sendKey(ctx, conn, "the-api-key") + }) + var out bytes.Buffer + + key, err := login(ctx, &out, ssoServer{wsURL: wsURL, authURL: testAuthURL, openBrowser: (&browser{}).open}) + + require.NoError(t, err) + assert.Equal(t, "the-api-key", key) + assert.Contains(t, out.String(), "unexpected SSO message") + }) + + t.Run("an error of the server is an error that says what it is", func(t *testing.T) { + t.Parallel() + + wsURL := ssoTestServer(t, func(ctx context.Context, conn *websocket.Conn, _ map[string]any) { + _ = wsjson.Write(ctx, conn, map[string]any{"success": false, "error": "the application is not known"}) + }) + b := &browser{} + + key, err := login(ctx, &bytes.Buffer{}, ssoServer{wsURL: wsURL, authURL: testAuthURL, openBrowser: b.open}) + + require.Error(t, err) + assert.Empty(t, key) + assert.Contains(t, err.Error(), "SSO error: the application is not known") + assert.Empty(t, b.urls()) + }) + + t.Run("an error of the server that does not say what it is, is not nothing", func(t *testing.T) { + t.Parallel() + + wsURL := ssoTestServer(t, func(ctx context.Context, conn *websocket.Conn, _ map[string]any) { + _ = wsjson.Write(ctx, conn, map[string]any{"success": false}) + }) + + _, err := login(ctx, &bytes.Buffer{}, ssoServer{wsURL: wsURL, authURL: testAuthURL, openBrowser: (&browser{}).open}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "SSO error: unknown error") + }) + + t.Run("a server that hangs up before there is a key is an error", func(t *testing.T) { + t.Parallel() + + wsURL := ssoTestServer(t, func(ctx context.Context, conn *websocket.Conn, _ map[string]any) { + _ = sendToken(ctx, conn) + conn.Close(websocket.StatusInternalError, "gone") + }) + + _, err := login(ctx, &bytes.Buffer{}, ssoServer{wsURL: wsURL, authURL: testAuthURL, openBrowser: (&browser{}).open}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "reading SSO message") + }) + + t.Run("a user who gives up is a login that is cancelled", func(t *testing.T) { + t.Parallel() + + wsURL := ssoTestServer(t, func(ctx context.Context, conn *websocket.Conn, _ map[string]any) { + _ = sendToken(ctx, conn) + // no key comes: the server waits for the client to leave + var ignored any + _ = wsjson.Read(ctx, conn, &ignored) + }) + ctx, cancel := context.WithCancel(ctx) + defer cancel() + // the user gives up while they are looking at the page + b := &browser{then: cancel} + + _, err := login(ctx, &bytes.Buffer{}, ssoServer{wsURL: wsURL, authURL: testAuthURL, openBrowser: b.open}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "SSO login cancelled") + assert.Len(t, b.urls(), 1) + }) + + t.Run("a server that is not there is an error that says so", func(t *testing.T) { + t.Parallel() + + srv := httptest.NewServer(http.NotFoundHandler()) + wsURL := "ws" + strings.TrimPrefix(srv.URL, "http") + srv.Close() // nothing is there any more + ctx, cancel := context.WithTimeout(ctx, 10*time.Second) + defer cancel() + b := &browser{} + + _, err := login(ctx, &bytes.Buffer{}, ssoServer{wsURL: wsURL, authURL: testAuthURL, openBrowser: b.open}) + + require.Error(t, err) + assert.Contains(t, err.Error(), "connecting to Nexus SSO") + assert.Empty(t, b.urls()) + }) +} diff --git a/internal/nexusclient/state.go b/internal/nexusclient/state.go index a76f734..825bbc9 100644 --- a/internal/nexusclient/state.go +++ b/internal/nexusclient/state.go @@ -23,10 +23,12 @@ import ( "os" "path/filepath" "time" - - "github.com/adrg/xdg" ) +// rateLimitFilename is the file, in the state directory, that has the rate +// limits that the last response of the API said. +const rateLimitFilename = "nexus.json" + type RateLimitState struct { HourlyLimit int `json:"hourly_limit"` HourlyRemaining int `json:"hourly_remaining"` @@ -51,13 +53,13 @@ func (r *RateLimitState) EffectiveRemaining() (hourly, daily int) { return } -func LoadRateLimitState() (*RateLimitState, error) { - path, err := xdg.StateFile(filepath.Join("modctl", "nexus.json")) - if err != nil { - return nil, err - } - - data, err := os.ReadFile(path) +// LoadRateLimitState reads the rate limits from the state directory dir. There +// are none until a response of the API has said what they are, which is a state +// with nothing in it and not an error, and nothing is made by looking: not the +// directory, and not the file. A file that is not what it should be is no +// state either. +func LoadRateLimitState(dir string) (*RateLimitState, error) { + data, err := os.ReadFile(filepath.Join(dir, rateLimitFilename)) if os.IsNotExist(err) { // No state yet, return a zeroed state (first API call will // populate it) @@ -75,9 +77,10 @@ func LoadRateLimitState() (*RateLimitState, error) { return &state, nil } -func SaveRateLimitState(state *RateLimitState) error { - path, err := xdg.StateFile(filepath.Join("modctl", "nexus.json")) - if err != nil { +// SaveRateLimitState writes the rate limits to the state directory dir, which +// is made if it is not there. +func SaveRateLimitState(dir string, state *RateLimitState) error { + if err := os.MkdirAll(dir, 0o700); err != nil { return err } @@ -86,5 +89,5 @@ func SaveRateLimitState(state *RateLimitState) error { return err } - return os.WriteFile(path, data, 0644) + return os.WriteFile(filepath.Join(dir, rateLimitFilename), data, 0o644) } diff --git a/internal/nexusclient/state_test.go b/internal/nexusclient/state_test.go new file mode 100644 index 0000000..3c38e55 --- /dev/null +++ b/internal/nexusclient/state_test.go @@ -0,0 +1,202 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package nexusclient + +import ( + "os" + "path/filepath" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestLoadRateLimitState(t *testing.T) { + t.Parallel() + + t.Run("there are none until a response says what they are", func(t *testing.T) { + t.Parallel() + + got, err := LoadRateLimitState(t.TempDir()) + + require.NoError(t, err) + assert.Equal(t, &RateLimitState{}, got) + }) + + t.Run("looking does not make anything", func(t *testing.T) { + t.Parallel() + + dir := filepath.Join(t.TempDir(), "not", "there", "yet") + + _, err := LoadRateLimitState(dir) + + require.NoError(t, err) + assert.NoDirExists(t, dir) + assert.NoDirExists(t, filepath.Join(dir, "..")) + }) + + t.Run("what is saved is what is read", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + saved := &RateLimitState{ + HourlyLimit: 100, + HourlyRemaining: 87, + HourlyReset: time.Date(2026, 10, 12, 15, 0, 0, 0, time.UTC), + DailyLimit: 2500, + DailyRemaining: 2400, + DailyReset: time.Date(2026, 10, 13, 0, 0, 0, 0, time.UTC), + } + require.NoError(t, SaveRateLimitState(dir, saved)) + + got, err := LoadRateLimitState(dir) + + require.NoError(t, err) + assert.Equal(t, 87, got.HourlyRemaining) + assert.Equal(t, 2400, got.DailyRemaining) + assert.True(t, saved.HourlyReset.Equal(got.HourlyReset)) + assert.True(t, saved.DailyReset.Equal(got.DailyReset)) + }) + + t.Run("a file that is not json is no state, and not an error", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, os.WriteFile(filepath.Join(dir, "nexus.json"), []byte("{ not json"), 0o644)) + + got, err := LoadRateLimitState(dir) + + require.NoError(t, err) + assert.Equal(t, &RateLimitState{}, got) + }) + + t.Run("a file that can't be read is an error", func(t *testing.T) { + t.Parallel() + + // a directory where the file should be is not something that can be read + dir := t.TempDir() + require.NoError(t, os.Mkdir(filepath.Join(dir, "nexus.json"), 0o755)) + + got, err := LoadRateLimitState(dir) + + require.Error(t, err) + assert.Nil(t, got) + }) + + t.Run("each directory has its own", func(t *testing.T) { + t.Parallel() + + first, second := t.TempDir(), t.TempDir() + require.NoError(t, SaveRateLimitState(first, &RateLimitState{HourlyLimit: 1})) + require.NoError(t, SaveRateLimitState(second, &RateLimitState{HourlyLimit: 2})) + + a, err := LoadRateLimitState(first) + require.NoError(t, err) + b, err := LoadRateLimitState(second) + require.NoError(t, err) + + assert.Equal(t, 1, a.HourlyLimit) + assert.Equal(t, 2, b.HourlyLimit) + }) +} + +func TestSaveRateLimitState(t *testing.T) { + t.Parallel() + + t.Run("the directory is made if it is not there", func(t *testing.T) { + t.Parallel() + + dir := filepath.Join(t.TempDir(), "state", "modctl") + + require.NoError(t, SaveRateLimitState(dir, &RateLimitState{HourlyLimit: 100})) + + assert.FileExists(t, filepath.Join(dir, "nexus.json")) + }) + + t.Run("it is saved over what was there", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + require.NoError(t, SaveRateLimitState(dir, &RateLimitState{HourlyRemaining: 50, DailyRemaining: 500})) + + require.NoError(t, SaveRateLimitState(dir, &RateLimitState{HourlyRemaining: 49})) + + got, err := LoadRateLimitState(dir) + require.NoError(t, err) + assert.Equal(t, 49, got.HourlyRemaining) + assert.Equal(t, 0, got.DailyRemaining, "what is not in it any more is gone") + }) + + t.Run("a directory that can't be made is an error", func(t *testing.T) { + t.Parallel() + + blocker := filepath.Join(t.TempDir(), "state") + require.NoError(t, os.WriteFile(blocker, nil, 0o644)) + + err := SaveRateLimitState(filepath.Join(blocker, "modctl"), &RateLimitState{}) + + assert.Error(t, err) + }) +} + +func TestRateLimitStateEffectiveRemaining(t *testing.T) { + t.Parallel() + + past := time.Now().Add(-time.Hour) + future := time.Now().Add(time.Hour) + + for _, tc := range []struct { + name string + state RateLimitState + hourly int + daily int + }{ + { + "windows that are not over have what is remaining", + RateLimitState{HourlyLimit: 100, HourlyRemaining: 7, HourlyReset: future, DailyLimit: 2500, DailyRemaining: 70, DailyReset: future}, + 7, 70, + }, + { + "windows that are over have all of their limit", + RateLimitState{HourlyLimit: 100, HourlyRemaining: 7, HourlyReset: past, DailyLimit: 2500, DailyRemaining: 70, DailyReset: past}, + 100, 2500, + }, + { + "each window is its own", + RateLimitState{HourlyLimit: 100, HourlyRemaining: 7, HourlyReset: past, DailyLimit: 2500, DailyRemaining: 70, DailyReset: future}, + 100, 70, + }, + { + "a state that was never saved is out of both windows, with nothing in them", + RateLimitState{}, + 0, 0, + }, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + hourly, daily := tc.state.EffectiveRemaining() + + assert.Equal(t, tc.hourly, hourly) + assert.Equal(t, tc.daily, daily) + }) + } +} From 657d47a728171b8fd05f41ab74e7ee2e2181c125 Mon Sep 17 00:00:00 2001 From: Mario Finelli Date: Mon, 12 Oct 2026 23:45:19 +0200 Subject: [PATCH 06/14] Fix nexusclient panic --- internal/nexusclient/do.go | 2 ++ internal/nexusclient/do_test.go | 19 +++++++++++++++++++ 2 files changed, 21 insertions(+) diff --git a/internal/nexusclient/do.go b/internal/nexusclient/do.go index 802aefc..d7e2f3b 100644 --- a/internal/nexusclient/do.go +++ b/internal/nexusclient/do.go @@ -111,6 +111,8 @@ func (c *Client) doRequest(method, path string, dst any, rawBody *[]byte) error state, err := LoadRateLimitState(c.stateDir) if err != nil { c.logger.Warn("failed to load rate limit state for 429 error", "error", err) + // there is no state to say how many are left + state = &RateLimitState{} } hourly, daily := state.EffectiveRemaining() return fmt.Errorf("nexus api rate limit exceeded (hourly remaining: %d, daily remaining: %d)", hourly, daily) diff --git a/internal/nexusclient/do_test.go b/internal/nexusclient/do_test.go index 0a242ef..7333864 100644 --- a/internal/nexusclient/do_test.go +++ b/internal/nexusclient/do_test.go @@ -131,6 +131,25 @@ func TestDoRequest(t *testing.T) { assert.Contains(t, err.Error(), "daily remaining: 12") }) + t.Run("a request that is too many is an error when the limits can't be read, too", func(t *testing.T) { + t.Parallel() + + c, _ := newTestClient(t, func(*http.Request) (*http.Response, error) { + return answer(http.StatusTooManyRequests, ``, nil), nil + }) + // there is a directory where the file of the limits should be + require.NoError(t, os.Mkdir(filepath.Join(c.stateDir, "nexus.json"), 0o755)) + + var err error + require.NotPanics(t, func() { + err = c.doRequest(http.MethodGet, "/x", &struct{}{}, nil) + }) + + require.Error(t, err) + assert.Contains(t, err.Error(), "rate limit exceeded") + assert.Contains(t, err.Error(), "hourly remaining: 0") + }) + t.Run("a limit that is over is all of it again", func(t *testing.T) { t.Parallel() From 48a91e6bc0c35164cf9996f347c91187033641f4 Mon Sep 17 00:00:00 2001 From: Mario Finelli Date: Tue, 13 Oct 2026 00:01:11 +0200 Subject: [PATCH 07/14] Make scratch directory configurable --- cmd/env.go | 11 + cmd/export.go | 1 + cmd/extract.go | 2 +- cmd/import.go | 2 +- cmd/verify.go | 2 +- internal/exporter/exporter.go | 13 + internal/exporter/full.go | 26 +- internal/exporter/game.go | 25 +- internal/restore/bundle_test.go | 424 ++++++++++++++++++++++++++++++++ internal/restore/restore.go | 13 +- 10 files changed, 494 insertions(+), 25 deletions(-) create mode 100644 internal/restore/bundle_test.go diff --git a/cmd/env.go b/cmd/env.go index 73778cb..108c6e4 100644 --- a/cmd/env.go +++ b/cmd/env.go @@ -22,6 +22,7 @@ import ( "context" "database/sql" "log/slog" + "os" "path/filepath" "github.com/adrg/xdg" @@ -60,6 +61,16 @@ func nexusCacheDBPath() string { return nexusclient.CacheDBPath(cacheDir()) } +// scratchDir is the directory that has room for a copy of what is exported or +// imported: the snapshots of the databases that are made for an export, and the +// bundle that is extracted for an import (which has all of the blobs in it). +// It is the temporary directory of the system, and not tmp_dir: that one is for +// the staging of the archives that are applied, and is by default on tmpfs, +// which is not where a bundle that is as big as all of the mods has to go. +func scratchDir() string { + return os.TempDir() +} + // openDB opens the database that is configured, which has to be there (it is // made by init) and is migrated to the latest schema. The caller closes it. func openDB(ctx context.Context) (*sql.DB, error) { diff --git a/cmd/export.go b/cmd/export.go index ac5700b..4bfd809 100644 --- a/cmd/export.go +++ b/cmd/export.go @@ -81,6 +81,7 @@ Examples: SkipInventory: exportSkipInventory, NoVerify: exportNoVerify, CacheDBPath: nexusCacheDBPath(), + ScratchDir: scratchDir(), Progress: printExportProgress, } diff --git a/cmd/extract.go b/cmd/extract.go index 9b51c47..7a11fb0 100644 --- a/cmd/extract.go +++ b/cmd/extract.go @@ -73,7 +73,7 @@ Examples: bundlePath := args[0] - bundle, err := restore.OpenAndValidate(ctx, bundlePath) + bundle, err := restore.OpenAndValidate(ctx, bundlePath, scratchDir()) if err != nil { return fmt.Errorf("invalid bundle: %w", err) } diff --git a/cmd/import.go b/cmd/import.go index 80afcea..cc6f87a 100644 --- a/cmd/import.go +++ b/cmd/import.go @@ -75,7 +75,7 @@ Use --dry-run to preview what would be imported without making any changes.`, // Open and validate bundle before touching the DB style.Println(style.Bold.Render("Validating bundle...")) - bundle, err := restore.OpenAndValidate(ctx, bundlePath) + bundle, err := restore.OpenAndValidate(ctx, bundlePath, scratchDir()) if err != nil { return fmt.Errorf("invalid bundle: %w", err) } diff --git a/cmd/verify.go b/cmd/verify.go index 6892556..ad26e6a 100644 --- a/cmd/verify.go +++ b/cmd/verify.go @@ -63,7 +63,7 @@ Exits non-zero if any integrity issues are found. Version warnings style.Println(style.Bold.Render("Verifying bundle: " + bundlePath)) style.Println() - bundle, err := restore.OpenAndValidate(ctx, bundlePath) + bundle, err := restore.OpenAndValidate(ctx, bundlePath, scratchDir()) if err != nil { return fmt.Errorf("bundle validation failed: %w", err) } diff --git a/internal/exporter/exporter.go b/internal/exporter/exporter.go index ee7c222..3da652a 100644 --- a/internal/exporter/exporter.go +++ b/internal/exporter/exporter.go @@ -50,11 +50,24 @@ type Options struct { NoVerify bool // CacheDBPath is the full path to the nexus_cache.db CacheDBPath string + // ScratchDir is the directory that the snapshots of the databases are + // made in, before they are written to the bundle (and removed). It has + // to be there, and to have room for a copy of the databases. It is not + // found by the exporter: nothing is made anywhere that it was not told. + ScratchDir string // Progress, if set, is told how verifying blobs is going. The exporter // itself prints nothing. Progress func(Progress) } +// validate says what is missing from the options that every export needs. +func (o Options) validate() error { + if o.ScratchDir == "" { + return errors.New("no scratch directory to make the snapshots of the databases in") + } + return nil +} + // ProgressKind says what a Progress update is about. type ProgressKind int diff --git a/internal/exporter/full.go b/internal/exporter/full.go index d294593..d947a91 100644 --- a/internal/exporter/full.go +++ b/internal/exporter/full.go @@ -41,6 +41,10 @@ func Full( bs blobstore.Store, opts Options, ) (Result, error) { + if err := opts.validate(); err != nil { + return Result{}, err + } + if !opts.NoVerify { var toVerify []blobToVerify archiveBlobs, err := q.ListBlobsByKind(ctx, string(blobstore.KindArchive)) @@ -93,14 +97,14 @@ func Full( defer tw.Close() // 1. Snapshot the database into a temp file - dbPath, dbSha256, err := snapshotDB(ctx, db) + dbPath, dbSha256, err := snapshotDB(ctx, db, opts.ScratchDir) if err != nil { return Result{}, fmt.Errorf("snapshot database: %w", err) } defer os.Remove(dbPath) // 1b. Snapshot the nexus cache database - cacheDBPath, cacheSha256, err := snapshotCacheDB(ctx, opts.CacheDBPath) + cacheDBPath, cacheSha256, err := snapshotCacheDB(ctx, opts.CacheDBPath, opts.ScratchDir) if err != nil { return Result{}, fmt.Errorf("snapshot nexus cache: %w", err) } @@ -207,10 +211,10 @@ func Full( return Result{SkippedBlobs: skipped}, nil } -// snapshotDB uses the SQLite backup API to create a consistent snapshot. -// Returns the path to the temp file and its sha256. -func snapshotDB(ctx context.Context, db *sql.DB) (path string, sha256hex string, err error) { - tmp, err := os.CreateTemp("", "modctl-export-db-*.sqlite") +// snapshotDB uses the SQLite backup API to create a consistent snapshot, in +// the directory scratchDir. Returns the path to the temp file and its sha256. +func snapshotDB(ctx context.Context, db *sql.DB, scratchDir string) (path string, sha256hex string, err error) { + tmp, err := os.CreateTemp(scratchDir, "modctl-export-db-*.sqlite") if err != nil { return "", "", fmt.Errorf("create temp file: %w", err) } @@ -232,15 +236,15 @@ func snapshotDB(ctx context.Context, db *sql.DB) (path string, sha256hex string, return tmpPath, sha, nil } -// snapshotCacheDB creates a consistent snapshot of the nexus cache database. -// Returns the path to the temp file and its sha256. If the cache DB does not -// exist yet, returns empty strings and no error. -func snapshotCacheDB(ctx context.Context, cacheDBPath string) (path string, sha256hex string, err error) { +// snapshotCacheDB creates a consistent snapshot of the nexus cache database, in +// the directory scratchDir. Returns the path to the temp file and its sha256. +// If the cache DB does not exist yet, returns empty strings and no error. +func snapshotCacheDB(ctx context.Context, cacheDBPath string, scratchDir string) (path string, sha256hex string, err error) { if _, err := os.Stat(cacheDBPath); os.IsNotExist(err) { return "", "", nil } - tmp, err := os.CreateTemp("", "modctl-export-cache-*.sqlite") + tmp, err := os.CreateTemp(scratchDir, "modctl-export-cache-*.sqlite") if err != nil { return "", "", fmt.Errorf("create temp file: %w", err) } diff --git a/internal/exporter/game.go b/internal/exporter/game.go index 9da391a..9d8c981 100644 --- a/internal/exporter/game.go +++ b/internal/exporter/game.go @@ -46,6 +46,10 @@ func Game( gi dbq.GameInstall, opts Options, ) (Result, error) { + if err := opts.validate(); err != nil { + return Result{}, err + } + if !opts.NoVerify { var toVerify []blobToVerify archiveBlobs, err := q.ListArchiveBlobsForGameInstall(ctx, gi.ID) @@ -92,7 +96,7 @@ func Game( // 1. Build the scoped SQLite database scopedDBPath, dbSha256, archiveCount, backupCount, overrideCount, err := buildGameScopedDB( - ctx, q, gi, opts.SkipInventory, + ctx, q, gi, opts.SkipInventory, opts.ScratchDir, ) if err != nil { return Result{}, fmt.Errorf("build game-scoped database: %w", err) @@ -101,7 +105,7 @@ func Game( // 1b. Build scoped nexus cache database cacheDBPath, cacheSha256, err := buildGameScopedCacheDB( - ctx, q, opts.CacheDBPath, gi.ID, + ctx, q, opts.CacheDBPath, gi.ID, opts.ScratchDir, ) if err != nil { return Result{}, fmt.Errorf("build game-scoped nexus cache: %w", err) @@ -200,15 +204,16 @@ func Game( } // buildGameScopedDB constructs a fresh SQLite database containing only rows -// relevant to the given game install. Returns the path to the temp file, -// the sha256 of the database, and blob counts. +// relevant to the given game install, in the directory scratchDir. Returns the +// path to the temp file, the sha256 of the database, and blob counts. func buildGameScopedDB( ctx context.Context, q *dbq.Queries, gi dbq.GameInstall, skipInventory bool, + scratchDir string, ) (path string, dbSha256 string, archiveCount int, backupCount int, overrideCount int, err error) { - tmp, err := os.CreateTemp("", "modctl-export-*.sqlite") + tmp, err := os.CreateTemp(scratchDir, "modctl-export-*.sqlite") if err != nil { return "", "", 0, 0, 0, fmt.Errorf("create temp db: %w", err) } @@ -597,14 +602,16 @@ func exportWriteOncePatterns(ctx context.Context, src, dst *dbq.Queries, gameIns } // buildGameScopedCacheDB constructs a fresh nexus cache database containing -// only rows relevant to the given game install's mod pages. Returns the path -// to the temp file and its sha256. If the cache DB does not exist or the game -// has no nexus-linked mod pages, returns empty strings and no error. +// only rows relevant to the given game install's mod pages, in the directory +// scratchDir. Returns the path to the temp file and its sha256. If the cache DB +// does not exist or the game has no nexus-linked mod pages, returns empty +// strings and no error. func buildGameScopedCacheDB( ctx context.Context, q *dbq.Queries, cacheDBPath string, gameInstallID int64, + scratchDir string, ) (path string, sha256hex string, err error) { if _, err := os.Stat(cacheDBPath); os.IsNotExist(err) { return "", "", nil @@ -627,7 +634,7 @@ func buildGameScopedCacheDB( defer srcCacheDB.Close() // Create destination temp file - tmp, err := os.CreateTemp("", "modctl-export-cache-*.sqlite") + tmp, err := os.CreateTemp(scratchDir, "modctl-export-cache-*.sqlite") if err != nil { return "", "", fmt.Errorf("create temp cache db: %w", err) } diff --git a/internal/restore/bundle_test.go b/internal/restore/bundle_test.go new file mode 100644 index 0000000..9fd089f --- /dev/null +++ b/internal/restore/bundle_test.go @@ -0,0 +1,424 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package restore + +import ( + "archive/tar" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/klauspost/compress/zstd" + "github.com/mfinelli/modctl/dbq" + "github.com/mfinelli/modctl/internal/blobstore" + "github.com/mfinelli/modctl/internal/database" + "github.com/mfinelli/modctl/internal/exporter" + "github.com/mfinelli/modctl/internal/nexusclient" + "github.com/mfinelli/modctl/internal/testbuilder" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// The bundles that are opened here are the ones that the exporter makes, so what +// is tested is the way there and back. They are in this package because the +// export migrates a database too, which needs the migrations that TestMain +// has set up for the tests of this package. + +// exportOptions are the options of an export to a bundle in a directory of the +// test, whose snapshots are made in scratch. +func exportOptions(t *testing.T, scratch string) exporter.Options { + t.Helper() + + return exporter.Options{ + ModctlVersion: "9.9.9", + OutputPath: filepath.Join(t.TempDir(), "bundle.tar.zst"), + // there is no cache of what Nexus Mods said, unless a test makes one + CacheDBPath: filepath.Join(t.TempDir(), "nexus_cache.db"), + ScratchDir: scratch, + } +} + +func storeInTestDir(t *testing.T) blobstore.Store { + t.Helper() + + return blobstore.Store{ + ArchivesDir: t.TempDir(), + BackupsDir: t.TempDir(), + OverridesDir: t.TempDir(), + } +} + +// assertEmpty is that dir has nothing in it: that what was made in it has been +// cleaned up. +func assertEmpty(t *testing.T, dir string, msgAndArgs ...any) { + t.Helper() + + entries, err := os.ReadDir(dir) + require.NoError(t, err) + names := make([]string, 0, len(entries)) + for _, e := range entries { + names = append(names, e.Name()) + } + assert.Empty(t, names, msgAndArgs...) +} + +// bundleEntry is a file in a bundle that is made by hand. +type bundleEntry struct { + name string + data []byte +} + +// writeBundle makes a bundle (a tar that is compressed with zstd) that has +// what it is given, which is not necessarily what a bundle has. +func writeBundle(t *testing.T, entries ...bundleEntry) string { + t.Helper() + + path := filepath.Join(t.TempDir(), "bundle.tar.zst") + f, err := os.Create(path) + require.NoError(t, err) + zw, err := zstd.NewWriter(f) + require.NoError(t, err) + tw := tar.NewWriter(zw) + for _, e := range entries { + require.NoError(t, tw.WriteHeader(&tar.Header{Name: e.name, Mode: 0o644, Size: int64(len(e.data))})) + _, err := tw.Write(e.data) + require.NoError(t, err) + } + require.NoError(t, tw.Close()) + require.NoError(t, zw.Close()) + require.NoError(t, f.Close()) + return path +} + +func manifestEntry(t *testing.T, m exporter.Manifest) bundleEntry { + t.Helper() + + data, err := json.Marshal(m) + require.NoError(t, err) + return bundleEntry{"manifest.json", data} +} + +func sha(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} + +func TestExportThenOpenAndValidate(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + t.Run("a full export is a bundle that is opened with everything in it", func(t *testing.T) { + t.Parallel() + + db, _ := testbuilder.SetupDBFile(t) + testbuilder.NewGame(t, db).WithName("Skyrim").WithStoreGameID("72850").Build() + testbuilder.NewGame(t, db).WithName("Fallout 4").WithStoreGameID("377160").Build() + scratch := t.TempDir() + opts := exportOptions(t, scratch) + + _, err := exporter.Full(ctx, db, dbq.New(db), storeInTestDir(t), opts) + require.NoError(t, err) + assertEmpty(t, scratch, "what the export made in the scratch directory is gone") + + bundle, err := OpenAndValidate(ctx, opts.OutputPath, scratch) + require.NoError(t, err) + defer bundle.Close() + + latest, err := database.LatestSchemaVersion() + require.NoError(t, err) + assert.Equal(t, exporter.ExportKindFull, bundle.Manifest.ExportKind) + assert.Equal(t, "9.9.9", bundle.Manifest.ModctlVersion) + assert.Equal(t, latest, bundle.Manifest.SchemaVersion) + assert.Equal(t, scratch, filepath.Dir(bundle.BundleDir), "it is extracted where it was told") + assert.ElementsMatch(t, []string{"Skyrim", "Fallout 4"}, gameNames(t, bundle.BundleDB)) + }) + + t.Run("a game export is a bundle that has only that game", func(t *testing.T) { + t.Parallel() + + db, _ := testbuilder.SetupDBFile(t) + skyrim := testbuilder.NewGame(t, db).WithName("Skyrim").WithStoreGameID("72850").Build() + testbuilder.NewGame(t, db).WithName("Fallout 4").WithStoreGameID("377160").Build() + scratch := t.TempDir() + opts := exportOptions(t, scratch) + + _, err := exporter.Game(ctx, db, dbq.New(db), storeInTestDir(t), skyrim, opts) + require.NoError(t, err) + assertEmpty(t, scratch, "what the export made in the scratch directory is gone") + + bundle, err := OpenAndValidate(ctx, opts.OutputPath, scratch) + require.NoError(t, err) + defer bundle.Close() + + assert.Equal(t, exporter.ExportKindGame, bundle.Manifest.ExportKind) + require.NotNil(t, bundle.Manifest.Game) + assert.Equal(t, "Skyrim", bundle.Manifest.Game.DisplayName) + assert.Equal(t, []string{"Skyrim"}, gameNames(t, bundle.BundleDB)) + }) + + t.Run("what Nexus Mods said is in the bundle, when there is a cache of it", func(t *testing.T) { + t.Parallel() + + db, _ := testbuilder.SetupDBFile(t) + testbuilder.NewGame(t, db).Build() + cacheDir := t.TempDir() + cache, err := nexusclient.NewCacheReader(ctx, quietLogger(), cacheDir) + require.NoError(t, err) + require.NoError(t, cache.Close()) + scratch := t.TempDir() + opts := exportOptions(t, scratch) + opts.CacheDBPath = nexusclient.CacheDBPath(cacheDir) + + _, err = exporter.Full(ctx, db, dbq.New(db), storeInTestDir(t), opts) + require.NoError(t, err) + assertEmpty(t, scratch, "the snapshot of the cache is gone, too") + + bundle, err := OpenAndValidate(ctx, opts.OutputPath, scratch) + require.NoError(t, err) + defer bundle.Close() + + assert.NotEmpty(t, bundle.Manifest.NexusCacheSha256) + assert.FileExists(t, filepath.Join(bundle.BundleDir, "nexus_cache.db")) + }) + + t.Run("closing the bundle takes away what was extracted", func(t *testing.T) { + t.Parallel() + + db, _ := testbuilder.SetupDBFile(t) + testbuilder.NewGame(t, db).Build() + scratch := t.TempDir() + opts := exportOptions(t, scratch) + _, err := exporter.Full(ctx, db, dbq.New(db), storeInTestDir(t), opts) + require.NoError(t, err) + bundle, err := OpenAndValidate(ctx, opts.OutputPath, scratch) + require.NoError(t, err) + require.DirExists(t, bundle.BundleDir) + + bundle.Close() + + assertEmpty(t, scratch) + }) +} + +func TestExportNeedsAScratchDirectory(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + for _, tc := range []struct { + name string + export func(t *testing.T, opts exporter.Options) error + }{ + {"full", func(t *testing.T, opts exporter.Options) error { + db, _ := testbuilder.SetupDBFile(t) + _, err := exporter.Full(ctx, db, dbq.New(db), storeInTestDir(t), opts) + return err + }}, + {"game", func(t *testing.T, opts exporter.Options) error { + db, _ := testbuilder.SetupDBFile(t) + gi := testbuilder.NewGame(t, db).Build() + _, err := exporter.Game(ctx, db, dbq.New(db), storeInTestDir(t), gi, opts) + return err + }}, + } { + tc := tc + t.Run(tc.name+" without one is an error, and nothing is made", func(t *testing.T) { + t.Parallel() + + opts := exportOptions(t, "") + + err := tc.export(t, opts) + + require.Error(t, err) + assert.Contains(t, err.Error(), "no scratch directory") + assert.NoFileExists(t, opts.OutputPath) + }) + + t.Run(tc.name+" with one that is not there is an error, and no output is left", func(t *testing.T) { + t.Parallel() + + opts := exportOptions(t, filepath.Join(t.TempDir(), "not-there")) + + err := tc.export(t, opts) + + require.Error(t, err) + assert.NoFileExists(t, opts.OutputPath, "a partial bundle is not kept") + }) + } +} + +func TestOpenAndValidate(t *testing.T) { + t.Parallel() + + ctx := context.Background() + dbBytes := []byte("not really a database, but the bytes that the manifest hashes") + + t.Run("without a scratch directory it is an error", func(t *testing.T) { + t.Parallel() + + bundle := writeBundle(t) + + got, err := OpenAndValidate(ctx, bundle, "") + + require.Error(t, err) + assert.Nil(t, got) + assert.Contains(t, err.Error(), "no scratch directory") + }) + + t.Run("a scratch directory that is not there is an error that says so", func(t *testing.T) { + t.Parallel() + + bundle := writeBundle(t) + + got, err := OpenAndValidate(ctx, bundle, filepath.Join(t.TempDir(), "not-there")) + + require.Error(t, err) + assert.Nil(t, got) + assert.Contains(t, err.Error(), "create temp dir") + }) + + t.Run("a bundle that is not there is an error, and nothing is left", func(t *testing.T) { + t.Parallel() + + scratch := t.TempDir() + + got, err := OpenAndValidate(ctx, filepath.Join(t.TempDir(), "nothing.tar.zst"), scratch) + + require.Error(t, err) + assert.Nil(t, got) + assert.Contains(t, err.Error(), "extract bundle") + assertEmpty(t, scratch) + }) + + t.Run("a file that is not a bundle is an error, and nothing is left", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "bundle.tar.zst") + require.NoError(t, os.WriteFile(path, []byte("this is not zstd"), 0o644)) + scratch := t.TempDir() + + got, err := OpenAndValidate(ctx, path, scratch) + + require.Error(t, err) + assert.Nil(t, got) + assertEmpty(t, scratch) + }) + + for _, tc := range []struct { + name string + entries func(t *testing.T) []bundleEntry + want string + }{ + { + "a bundle that has no manifest", + func(t *testing.T) []bundleEntry { return []bundleEntry{{"modctl.db", dbBytes}} }, + "read manifest", + }, + { + "a manifest that is not json", + func(t *testing.T) []bundleEntry { return []bundleEntry{{"manifest.json", []byte("{ nope")}} }, + "parse manifest", + }, + { + "a bundle of a format that is newer than this one", + func(t *testing.T) []bundleEntry { + return []bundleEntry{manifestEntry(t, exporter.Manifest{ExportFormatVersion: supportedFormatVersion + 1})} + }, + "newer than supported", + }, + { + "a manifest that has no database in the bundle", + func(t *testing.T) []bundleEntry { + return []bundleEntry{manifestEntry(t, exporter.Manifest{ExportFormatVersion: 1, DBSha256: sha(dbBytes)})} + }, + "hash bundle database", + }, + { + "a database that is not the one that the manifest says", + func(t *testing.T) []bundleEntry { + return []bundleEntry{ + manifestEntry(t, exporter.Manifest{ExportFormatVersion: 1, DBSha256: sha([]byte("another"))}), + {"modctl.db", dbBytes}, + } + }, + "bundle database integrity check failed", + }, + { + "a cache that is not the one that the manifest says", + func(t *testing.T) []bundleEntry { + return []bundleEntry{ + manifestEntry(t, exporter.Manifest{ExportFormatVersion: 1, DBSha256: sha(dbBytes), NexusCacheSha256: sha([]byte("another"))}), + {"modctl.db", dbBytes}, + {"nexus_cache.db", []byte("the cache")}, + } + }, + "bundle nexus cache integrity check failed", + }, + { + "a cache that the manifest says is there and is not", + func(t *testing.T) []bundleEntry { + return []bundleEntry{ + manifestEntry(t, exporter.Manifest{ExportFormatVersion: 1, DBSha256: sha(dbBytes), NexusCacheSha256: sha([]byte("the cache"))}), + {"modctl.db", dbBytes}, + } + }, + "hash bundle nexus cache", + }, + } { + tc := tc + t.Run(tc.name+" is an error, and nothing is left", func(t *testing.T) { + t.Parallel() + + scratch := t.TempDir() + + got, err := OpenAndValidate(ctx, writeBundle(t, tc.entries(t)...), scratch) + + require.Error(t, err) + assert.Nil(t, got) + assert.Contains(t, err.Error(), tc.want) + assertEmpty(t, scratch) + }) + } + + t.Run("a file of a bundle that is somewhere that is not in it is put in it", func(t *testing.T) { + t.Parallel() + + parent := t.TempDir() + scratch := filepath.Join(parent, "scratch") + require.NoError(t, os.Mkdir(scratch, 0o755)) + // the way out of the directory that it is extracted in + bundle := writeBundle(t, + bundleEntry{"../../escaped.txt", []byte("not here")}, + bundleEntry{"../../../escaped-too.txt", []byte("not here")}, + ) + + _, err := OpenAndValidate(ctx, bundle, scratch) + + require.Error(t, err, "there is no manifest in it") + assert.NoFileExists(t, filepath.Join(parent, "escaped.txt")) + assert.NoFileExists(t, filepath.Join(filepath.Dir(parent), "escaped-too.txt")) + assert.NoFileExists(t, filepath.Join(scratch, "escaped.txt")) + assertEmpty(t, scratch) + }) +} diff --git a/internal/restore/restore.go b/internal/restore/restore.go index bdd4cd2..11fdea6 100644 --- a/internal/restore/restore.go +++ b/internal/restore/restore.go @@ -85,9 +85,18 @@ func (b *Bundle) Close() { // OpenAndValidate extracts the bundle, verifies integrity, and returns a // validated Bundle ready for import. Caller must call Close() when done. -func OpenAndValidate(ctx context.Context, bundlePath string) (*Bundle, error) { +// +// The bundle is extracted to a directory of its own in scratchDir, which has to +// be there and to have room for all of the bundle (the blobs are in it, and +// that is most of it). It is not found by this function: nothing is made +// anywhere that it was not told. +func OpenAndValidate(ctx context.Context, bundlePath string, scratchDir string) (*Bundle, error) { + if scratchDir == "" { + return nil, errors.New("no scratch directory to extract the bundle in") + } + // Extract bundle to temp dir - tmpDir, err := os.MkdirTemp("", "modctl-import-*") + tmpDir, err := os.MkdirTemp(scratchDir, "modctl-import-*") if err != nil { return nil, fmt.Errorf("create temp dir: %w", err) } From 5e2c322c0824eb7fcb044d2007a7b70fe6b71eab Mon Sep 17 00:00:00 2001 From: Mario Finelli Date: Tue, 13 Oct 2026 13:41:57 +0200 Subject: [PATCH 08/14] Add tests for refresh --- cmd/env.go | 12 ++ cmd/games_refresh.go | 2 +- internal/refresh.go | 83 ++++++---- internal/refresh_test.go | 33 ++-- internal/scanstores_test.go | 314 ++++++++++++++++++++++++++++++++++++ 5 files changed, 401 insertions(+), 43 deletions(-) create mode 100644 internal/scanstores_test.go diff --git a/cmd/env.go b/cmd/env.go index 108c6e4..f71bbc9 100644 --- a/cmd/env.go +++ b/cmd/env.go @@ -27,6 +27,7 @@ import ( "github.com/adrg/xdg" "github.com/mfinelli/modctl/dbq" + "github.com/mfinelli/modctl/internal" "github.com/mfinelli/modctl/internal/argresolver" "github.com/mfinelli/modctl/internal/completion" "github.com/mfinelli/modctl/internal/database" @@ -71,6 +72,17 @@ func scratchDir() string { return os.TempDir() } +// userDirs are the directories of the user that the stores are looked for in. +func userDirs() internal.UserDirs { + // a user that has no home has nothing in it to look for + home, _ := os.UserHomeDir() + + return internal.UserDirs{ + Home: home, + DataHome: xdg.DataHome, + } +} + // openDB opens the database that is configured, which has to be there (it is // made by init) and is migrated to the latest schema. The caller closes it. func openDB(ctx context.Context) (*sql.DB, error) { diff --git a/cmd/games_refresh.go b/cmd/games_refresh.go index 6b95f7b..ed4bf13 100644 --- a/cmd/games_refresh.go +++ b/cmd/games_refresh.go @@ -50,7 +50,7 @@ It is safe to run multiple times.`, style.Println(style.Bold.Render("Scanning stores...")) style.Println() - result, err := internal.ScanStores(ctx, db) + result, err := internal.ScanStores(ctx, db, userDirs()) // warnings are worth showing even if the scan then failed for _, w := range result.Warnings { diff --git a/internal/refresh.go b/internal/refresh.go index 25e7f75..855e8b6 100644 --- a/internal/refresh.go +++ b/internal/refresh.go @@ -31,7 +31,6 @@ import ( "strings" "time" - "github.com/adrg/xdg" "github.com/andygrunwald/vdf" "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/database" @@ -156,10 +155,25 @@ type steamInstall struct { steamappsDir string // e.g. ~/.local/share/Steam/steamapps } -// ScanStores rescans every enabled store and updates the game installs in the -// database. It prints nothing: what it found is in the result. If it fails -// part way, the result still holds the warnings found up to then. -func ScanStores(ctx context.Context, db *sql.DB) (RefreshResult, error) { +// UserDirs are the directories of the user that the stores are looked for in. +// They are what a store is told, and not something that it finds out: a scan +// looks nowhere that it is not told to, so a test can have a user of its own. +type UserDirs struct { + // Home is the home directory of the user, which is what "~" is in the + // paths that the stores keep. It is empty for a user that has none, and + // then what is in it is not looked for. + Home string + + // DataHome is the directory for the data of the programs of the user + // ($XDG_DATA_HOME). It is empty if it is not known. + DataHome string +} + +// ScanStores rescans every enabled store, in the directories of the user dirs, +// and updates the game installs in the database. It prints nothing: what it +// found is in the result. If it fails part way, the result still holds the +// warnings found up to then. +func ScanStores(ctx context.Context, db *sql.DB, dirs UserDirs) (RefreshResult, error) { q := dbq.New(db) stores, err := q.ListEnabledStores(ctx) if err != nil { @@ -170,7 +184,7 @@ func ScanStores(ctx context.Context, db *sql.DB) (RefreshResult, error) { for _, store := range stores { switch store.Implementation { case "steam": - result, err := refreshSteam(ctx, db, q) + result, err := refreshSteam(ctx, db, q, dirs) combined.New = append(combined.New, result.New...) combined.Updated = append(combined.Updated, result.Updated...) @@ -192,8 +206,8 @@ func ScanStores(ctx context.Context, db *sql.DB) (RefreshResult, error) { return combined, nil } -func refreshSteam(ctx context.Context, db *sql.DB, q *dbq.Queries) (RefreshResult, error) { - steam, err := discoverSteamLibraries() +func refreshSteam(ctx context.Context, db *sql.DB, q *dbq.Queries, dirs UserDirs) (RefreshResult, error) { + steam, err := discoverSteamLibraries(dirs) if err != nil { return RefreshResult{Warnings: steam.Warnings}, fmt.Errorf("error scanning for steam libraries: %w", err) @@ -323,14 +337,17 @@ type steamLibraries struct { } // discoverSteamLibraries finds Steam library roots by locating and parsing -// steamapps/libraryfolders.vdf from common Steam installation roots. -func discoverSteamLibraries() (steamLibraries, error) { - return discoverSteamLibrariesIn(candidateSteamRoots()), nil +// steamapps/libraryfolders.vdf from common Steam installation roots, which are +// in the directories of the user dirs. +func discoverSteamLibraries(dirs UserDirs) (steamLibraries, error) { + return discoverSteamLibrariesIn(candidateSteamRoots(dirs), dirs.Home), nil } // discoverSteamLibrariesIn is discoverSteamLibraries for the given places to -// look for Steam installations, instead of the usual ones. -func discoverSteamLibrariesIn(candidates []string) steamLibraries { +// look for Steam installations, instead of the usual ones. A "~" in them, or +// in the paths of the libraries that the installations list, is the directory +// home. +func discoverSteamLibrariesIn(candidates []string, home string) steamLibraries { seenRoots := make(map[string]struct{}, len(candidates)) didScan := false @@ -341,7 +358,7 @@ func discoverSteamLibrariesIn(candidates []string) steamLibraries { // Deduplicate candidate roots (after best-effort canonicalization) var uniqRoots []string for _, r := range candidates { - r = expandHome(r) + r = expandHome(r, home) canon, err := canonicalizePathBestEffort(r) if err != nil { // root canonicalization failure isn't fatal; keep cleaned absolute @@ -397,7 +414,7 @@ func discoverSteamLibrariesIn(candidates []string) steamLibraries { if p == "" { continue } - p = expandHome(p) + p = expandHome(p, home) canon, cerr := canonicalizePathBestEffort(p) if cerr != nil { // best-effort: still include cleaned absolute-ish path @@ -977,32 +994,42 @@ func canonicalizePathBestEffort(p string) (string, error) { return p, nil } -func candidateSteamRoots() []string { - home, _ := os.UserHomeDir() +// candidateSteamRoots are the places that Steam is installed in, for the user +// with the directories dirs, in the order that they are preferred in. What is +// under a directory that is not known is not a place. +func candidateSteamRoots(dirs UserDirs) []string { + var roots []string // Primary: XDG data home + Steam - roots := []string{ - filepath.Join(xdg.DataHome, "Steam"), - // Common non-XDG path still seen in the wild: - filepath.Join(home, ".local", "share", "Steam"), - // Legacy symlink-style installs: - filepath.Join(home, ".steam", "steam"), - // Flatpak Steam: - filepath.Join(home, ".var", "app", "com.valvesoftware.Steam", "data", "Steam"), + if dirs.DataHome != "" { + roots = append(roots, filepath.Join(dirs.DataHome, "Steam")) + } + + if dirs.Home != "" { + roots = append(roots, + // Common non-XDG path still seen in the wild: + filepath.Join(dirs.Home, ".local", "share", "Steam"), + // Legacy symlink-style installs: + filepath.Join(dirs.Home, ".steam", "steam"), + // Flatpak Steam: + filepath.Join(dirs.Home, ".var", "app", "com.valvesoftware.Steam", "data", "Steam"), + ) } return roots } -func expandHome(p string) string { +// expandHome is p, with the home directory home for the "~" that it starts +// with (and not for "~user", which is somebody else's). A path that does not +// start with one, or a home that is not known, is as it is. +func expandHome(p string, home string) string { if p == "" { return p } if p[0] != '~' { return p } - home, err := os.UserHomeDir() - if err != nil || home == "" { + if home == "" { return p } if p == "~" { diff --git a/internal/refresh_test.go b/internal/refresh_test.go index f2ef5bf..18a96d1 100644 --- a/internal/refresh_test.go +++ b/internal/refresh_test.go @@ -293,6 +293,11 @@ func writeVDF(t *testing.T, root, content string) { require.NoError(t, os.WriteFile(filepath.Join(dir, "libraryfolders.vdf"), []byte(content), 0o644)) } +// testHome is the home directory of the user in the tests of discovery, which +// is not a directory that is there: what is looked at is in the directories of +// the test, and none of it is under the home. +const testHome = "/home/tester" + func TestDiscoverSteamLibrariesIn(t *testing.T) { t.Parallel() @@ -304,7 +309,7 @@ func TestDiscoverSteamLibrariesIn(t *testing.T) { ext := filepath.Join(dir, "games") writeSteamRoot(t, root, root, ext) - got := discoverSteamLibrariesIn([]string{root}) + got := discoverSteamLibrariesIn([]string{root}, testHome) assert.True(t, got.DidScan) assert.Equal(t, sortedPaths(ext, root), got.Libs, "in order of path") @@ -315,7 +320,7 @@ func TestDiscoverSteamLibrariesIn(t *testing.T) { t.Run("a place that is not a Steam installation is passed over", func(t *testing.T) { t.Parallel() - got := discoverSteamLibrariesIn([]string{filepath.Join(t.TempDir(), "nothing")}) + got := discoverSteamLibrariesIn([]string{filepath.Join(t.TempDir(), "nothing")}, testHome) assert.False(t, got.DidScan) assert.Empty(t, got.Libs) @@ -325,7 +330,7 @@ func TestDiscoverSteamLibrariesIn(t *testing.T) { t.Run("no places at all", func(t *testing.T) { t.Parallel() - got := discoverSteamLibrariesIn(nil) + got := discoverSteamLibrariesIn(nil, testHome) assert.False(t, got.DidScan) assert.Empty(t, got.Libs) @@ -342,7 +347,7 @@ func TestDiscoverSteamLibrariesIn(t *testing.T) { writeSteamRoot(t, native, native, shared) writeSteamRoot(t, flatpak, flatpak, shared) - got := discoverSteamLibrariesIn([]string{flatpak, native}) + got := discoverSteamLibrariesIn([]string{flatpak, native}, testHome) assert.Equal(t, []string{flatpak, native}, got.Roots) assert.Equal(t, sortedPaths(flatpak, native, shared), got.Libs, "a library that both list is there once") @@ -357,7 +362,7 @@ func TestDiscoverSteamLibrariesIn(t *testing.T) { link := filepath.Join(dir, "link-to-steam") require.NoError(t, os.Symlink(root, link)) - got := discoverSteamLibrariesIn([]string{root, link}) + got := discoverSteamLibrariesIn([]string{root, link}, testHome) assert.Equal(t, []string{root}, got.Roots) assert.Equal(t, []string{root}, got.Libs) @@ -372,7 +377,7 @@ func TestDiscoverSteamLibrariesIn(t *testing.T) { require.NoError(t, os.MkdirAll(ext, 0o755)) writeSteamRoot(t, root, root+"/", ext+"/", filepath.Join(ext, "..", "games")) - got := discoverSteamLibrariesIn([]string{root}) + got := discoverSteamLibrariesIn([]string{root}, testHome) assert.Equal(t, sortedPaths(ext, root), got.Libs) }) @@ -383,7 +388,7 @@ func TestDiscoverSteamLibrariesIn(t *testing.T) { root := filepath.Join(t.TempDir(), "Steam") writeVDF(t, root, "not vdf at all <<<>>>") - got := discoverSteamLibrariesIn([]string{root}) + got := discoverSteamLibrariesIn([]string{root}, testHome) assert.False(t, got.DidScan) assert.Empty(t, got.Roots) @@ -397,7 +402,7 @@ func TestDiscoverSteamLibrariesIn(t *testing.T) { root := filepath.Join(t.TempDir(), "Steam") writeVDF(t, root, "\"libraryfolders\"\n{\n}\n") - got := discoverSteamLibrariesIn([]string{root}) + got := discoverSteamLibrariesIn([]string{root}, testHome) assert.True(t, got.DidScan) assert.Empty(t, got.Libs) @@ -416,7 +421,7 @@ func TestDiscoverSteamLibrariesIn(t *testing.T) { last := filepath.Join(dir, "Z-games") writeSteamRoot(t, root, root, last, first) - found := discoverSteamLibrariesIn([]string{root}) + found := discoverSteamLibrariesIn([]string{root}, testHome) got := assignSteamInstanceIDs(found.Libs, found.Roots, nil, nil) assert.Equal(t, map[string]string{ @@ -1090,7 +1095,7 @@ func TestDiscoverSteamLibrariesInContentIDs(t *testing.T) { steamLibraryEntry{Path: ext, ContentID: "-222"}, steamLibraryEntry{Path: bare}) - got := discoverSteamLibrariesIn([]string{root}) + got := discoverSteamLibrariesIn([]string{root}, testHome) assert.Equal(t, map[string]string{root: "111", ext: "-222"}, got.ContentIDs) assert.Equal(t, sortedPaths(root, ext, bare), got.Libs) @@ -1104,7 +1109,7 @@ func TestDiscoverSteamLibrariesInContentIDs(t *testing.T) { root := filepath.Join(dir, "Steam") writeSteamRoot(t, root, root) - got := discoverSteamLibrariesIn([]string{root}) + got := discoverSteamLibrariesIn([]string{root}, testHome) assert.Empty(t, got.ContentIDs) }) @@ -1123,7 +1128,7 @@ func TestDiscoverSteamLibrariesInContentIDs(t *testing.T) { steamLibraryEntry{Path: flatpak, ContentID: "2"}, steamLibraryEntry{Path: shared, ContentID: "9"}) - got := discoverSteamLibrariesIn([]string{native, flatpak}) + got := discoverSteamLibrariesIn([]string{native, flatpak}, testHome) assert.Equal(t, map[string]string{native: "1", flatpak: "2", shared: "9"}, got.ContentIDs) assert.Empty(t, got.Warnings, "it is one library, not two that look alike") @@ -1139,7 +1144,7 @@ func TestDiscoverSteamLibrariesInContentIDs(t *testing.T) { writeSteamRootWithIDs(t, native, steamLibraryEntry{Path: shared, ContentID: "9"}) writeSteamRootWithIDs(t, flatpak, steamLibraryEntry{Path: shared, ContentID: "10"}) - got := discoverSteamLibrariesIn([]string{native, flatpak}) + got := discoverSteamLibrariesIn([]string{native, flatpak}, testHome) assert.Equal(t, "9", got.ContentIDs[shared]) require.Len(t, got.Warnings, 1) @@ -1159,7 +1164,7 @@ func TestDiscoverSteamLibrariesInContentIDs(t *testing.T) { steamLibraryEntry{Path: b, ContentID: "77"}, steamLibraryEntry{Path: a, ContentID: "77"}) - got := discoverSteamLibrariesIn([]string{root}) + got := discoverSteamLibrariesIn([]string{root}, testHome) assert.Equal(t, "77", got.ContentIDs[a]) assert.Equal(t, "77", got.ContentIDs[b]) diff --git a/internal/scanstores_test.go b/internal/scanstores_test.go new file mode 100644 index 0000000..d92cdb3 --- /dev/null +++ b/internal/scanstores_test.go @@ -0,0 +1,314 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package internal + +import ( + "context" + "os" + "path/filepath" + "testing" + + "github.com/mfinelli/modctl/dbq" + "github.com/mfinelli/modctl/internal/testbuilder" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +func TestCandidateSteamRoots(t *testing.T) { + t.Parallel() + + t.Run("they are in the directories of the user, the most preferred first", func(t *testing.T) { + t.Parallel() + + got := candidateSteamRoots(UserDirs{Home: "/home/me", DataHome: "/data"}) + + assert.Equal(t, []string{ + "/data/Steam", + "/home/me/.local/share/Steam", + "/home/me/.steam/steam", + "/home/me/.var/app/com.valvesoftware.Steam/data/Steam", + }, got) + }) + + t.Run("a data home that is not known is not a place", func(t *testing.T) { + t.Parallel() + + got := candidateSteamRoots(UserDirs{Home: "/home/me"}) + + assert.Equal(t, []string{ + "/home/me/.local/share/Steam", + "/home/me/.steam/steam", + "/home/me/.var/app/com.valvesoftware.Steam/data/Steam", + }, got) + }) + + t.Run("a user with no home has only the data home, and not what is in the directory that it is run in", func(t *testing.T) { + t.Parallel() + + got := candidateSteamRoots(UserDirs{DataHome: "/data"}) + + assert.Equal(t, []string{"/data/Steam"}, got) + }) + + t.Run("a user that has nothing has no place to look in", func(t *testing.T) { + t.Parallel() + + assert.Empty(t, candidateSteamRoots(UserDirs{})) + }) + + t.Run("the directories are cleaned", func(t *testing.T) { + t.Parallel() + + got := candidateSteamRoots(UserDirs{DataHome: "/data/"}) + + assert.Equal(t, []string{"/data/Steam"}, got) + }) +} + +func TestExpandHome(t *testing.T) { + t.Parallel() + + for _, tc := range []struct { + name string + path string + home string + want string + }{ + {"nothing is nothing", "", "/home/me", ""}, + {"an absolute path is as it is", "/games/steam", "/home/me", "/games/steam"}, + {"a relative path is as it is", "games/steam", "/home/me", "games/steam"}, + {"a tilde is the home", "~", "/home/me", "/home/me"}, + {"a tilde and a path is a path in the home", "~/Games/Steam", "/home/me", "/home/me/Games/Steam"}, + {"the home of somebody else is not this one", "~other/Games", "/home/me", "~other/Games"}, + {"a tilde that is not at the start is a character", "games/~/steam", "/home/me", "games/~/steam"}, + {"a home that is not known leaves it as it is", "~/Games", "", "~/Games"}, + {"and so does a tilde alone", "~", "", "~"}, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tc.want, expandHome(tc.path, tc.home)) + }) + } +} + +// canon is p as discovery has it, which is without the symlinks (the +// directories of a test can be in one). +func canon(t *testing.T, p string) string { + t.Helper() + + c, err := canonicalizePathBestEffort(p) + require.NoError(t, err) + return c +} + +func TestDiscoverSteamLibrariesInHome(t *testing.T) { + t.Parallel() + + t.Run("a place that starts with a tilde is in the home that it is given", func(t *testing.T) { + t.Parallel() + + home := t.TempDir() + root := filepath.Join(home, "Games", "Steam") + writeSteamRoot(t, root, root) + + got := discoverSteamLibrariesIn([]string{"~/Games/Steam"}, home) + + assert.Equal(t, []string{canon(t, root)}, got.Roots) + assert.True(t, got.DidScan) + }) + + t.Run("a library that starts with a tilde is in the home that it is given", func(t *testing.T) { + t.Parallel() + + home := t.TempDir() + root := filepath.Join(t.TempDir(), "Steam") + library := filepath.Join(home, "Games", "SteamLibrary") + writeSteamRoot(t, root, root, "~/Games/SteamLibrary") + + got := discoverSteamLibrariesIn([]string{root}, home) + + assert.ElementsMatch(t, []string{canon(t, root), canon(t, library)}, got.Libs) + }) +} + +func TestScanStores(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + // user is a user with a home, whose data home is where Steam is by default + type user struct { + dirs UserDirs + dataHome string + } + newUser := func(t *testing.T) user { + t.Helper() + + home := t.TempDir() + data := filepath.Join(home, ".local", "share") + return user{dirs: UserDirs{Home: home, DataHome: data}, dataHome: data} + } + + // installSteam makes a Steam installation at root, that is a library + // itself, with a game in it. + installSteam := func(t *testing.T, root, appid, name string) { + t.Helper() + + writeSteamRoot(t, root, root) + writeSteamGame(t, root, appid, name) + } + + t.Run("the games of the Steam of the user are found", func(t *testing.T) { + t.Parallel() + + u := newUser(t) + root := filepath.Join(u.dataHome, "Steam") + installSteam(t, root, "72850", "Skyrim") + db := testbuilder.SetupDB(t) + + result, err := ScanStores(ctx, db, u.dirs) + + require.NoError(t, err) + assert.Equal(t, []string{"Skyrim"}, result.New) + state := readSteamState(t, dbq.New(db)) + require.Contains(t, state, "72850") + assert.Equal(t, filepath.Join(canon(t, root), "steamapps", "common", "Skyrim"), state["72850"].InstallRoot) + }) + + t.Run("a Steam that is only in a place under the home is found, too", func(t *testing.T) { + t.Parallel() + + for name, rel := range map[string]string{ + "the old one": filepath.Join(".steam", "steam"), + "the one of Flatpak": filepath.Join(".var", "app", "com.valvesoftware.Steam", "data", "Steam"), + } { + name, rel := name, rel + t.Run(name, func(t *testing.T) { + t.Parallel() + + u := newUser(t) + installSteam(t, filepath.Join(u.dirs.Home, rel), "377160", "Fallout 4") + db := testbuilder.SetupDB(t) + + result, err := ScanStores(ctx, db, u.dirs) + + require.NoError(t, err) + assert.Equal(t, []string{"Fallout 4"}, result.New) + }) + } + }) + + t.Run("the Steam of another user is not looked at", func(t *testing.T) { + t.Parallel() + + other := newUser(t) + installSteam(t, filepath.Join(other.dataHome, "Steam"), "72850", "Skyrim") + mine := newUser(t) + db := testbuilder.SetupDB(t) + + result, err := ScanStores(ctx, db, mine.dirs) + + require.NoError(t, err) + assert.Empty(t, result.New) + assert.Empty(t, readSteamState(t, dbq.New(db))) + }) + + t.Run("a user that has no directories has no games, and it is not an error", func(t *testing.T) { + t.Parallel() + + db := testbuilder.SetupDB(t) + + result, err := ScanStores(ctx, db, UserDirs{}) + + require.NoError(t, err) + assert.Empty(t, result.New) + assert.Empty(t, readSteamState(t, dbq.New(db))) + }) + + t.Run("a library in the home that is told with a tilde is scanned", func(t *testing.T) { + t.Parallel() + + u := newUser(t) + root := filepath.Join(u.dataHome, "Steam") + writeSteamRoot(t, root, root, "~/Games/SteamLibrary") + writeSteamGame(t, filepath.Join(u.dirs.Home, "Games", "SteamLibrary"), "1091500", "Cyberpunk 2077") + db := testbuilder.SetupDB(t) + + result, err := ScanStores(ctx, db, u.dirs) + + require.NoError(t, err) + assert.Equal(t, []string{"Cyberpunk 2077"}, result.New) + }) + + t.Run("a game that is there again is not new, and one that is gone is missing", func(t *testing.T) { + t.Parallel() + + u := newUser(t) + root := filepath.Join(u.dataHome, "Steam") + installSteam(t, root, "72850", "Skyrim") + writeSteamGame(t, root, "377160", "Fallout 4") + db := testbuilder.SetupDB(t) + _, err := ScanStores(ctx, db, u.dirs) + require.NoError(t, err) + require.NoError(t, os.Remove(filepath.Join(root, "steamapps", "appmanifest_377160.acf"))) + + result, err := ScanStores(ctx, db, u.dirs) + + require.NoError(t, err) + assert.Empty(t, result.New) + assert.Equal(t, []string{"Skyrim"}, result.Updated) + assert.Equal(t, []string{"Fallout 4"}, result.Missing) + }) + + t.Run("a Steam that can't be found does not make the games that were found missing", func(t *testing.T) { + t.Parallel() + + u := newUser(t) + root := filepath.Join(u.dataHome, "Steam") + installSteam(t, root, "72850", "Skyrim") + db := testbuilder.SetupDB(t) + _, err := ScanStores(ctx, db, u.dirs) + require.NoError(t, err) + require.NoError(t, os.RemoveAll(root)) // an unmounted disk, say + + result, err := ScanStores(ctx, db, u.dirs) + + require.NoError(t, err) + assert.Empty(t, result.Missing, "nothing was scanned, so nothing is known to be gone") + assert.Equal(t, int64(1), readSteamState(t, dbq.New(db))["72850"].IsPresent) + }) + + t.Run("a store that is not enabled is not scanned", func(t *testing.T) { + t.Parallel() + + u := newUser(t) + installSteam(t, filepath.Join(u.dataHome, "Steam"), "72850", "Skyrim") + db := testbuilder.SetupDB(t) + _, err := db.Exec(`UPDATE stores SET enabled = FALSE WHERE id = 'steam'`) + require.NoError(t, err) + + result, err := ScanStores(ctx, db, u.dirs) + + require.NoError(t, err) + assert.Empty(t, result.New) + assert.Empty(t, readSteamState(t, dbq.New(db))) + }) +} From 46054aef41c3f3240f4bb4d9ecd9a05fbcafd480 Mon Sep 17 00:00:00 2001 From: Mario Finelli Date: Tue, 13 Oct 2026 23:38:54 +0200 Subject: [PATCH 09/14] Make migrations handling generic for tests --- cmd/doctor.go | 2 +- cmd/env.go | 8 +- cmd/export.go | 1 + cmd/extract.go | 2 +- cmd/import.go | 5 +- cmd/init.go | 2 +- cmd/verify.go | 4 +- internal/database/database.go | 94 +++++----- internal/database/database_test.go | 52 ------ internal/database/latestschemaversion_test.go | 43 ++++- internal/database/open_test.go | 32 ++-- internal/database/schema_test.go | 172 ++++++++++++++++++ internal/exporter/exporter.go | 8 + internal/exporter/full.go | 2 +- internal/exporter/game.go | 12 +- internal/restore/bundle_test.go | 102 +++++++++-- internal/restore/bundledb.go | 5 +- internal/restore/full.go | 9 +- internal/restore/full_test.go | 42 ++++- internal/restore/game.go | 6 +- internal/restore/game_test.go | 8 +- internal/restore/paths_test.go | 14 +- internal/restore/restore.go | 16 +- .../restore/{main_test.go => schema_test.go} | 37 +++- internal/restore/targets_test.go | 4 +- internal/testbuilder/testbuilder.go | 41 +---- main.go | 2 +- 27 files changed, 515 insertions(+), 210 deletions(-) create mode 100644 internal/database/schema_test.go rename internal/restore/{main_test.go => schema_test.go} (50%) diff --git a/cmd/doctor.go b/cmd/doctor.go index a9dc596..0b58f6e 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -177,7 +177,7 @@ func checkDb(ctx context.Context) error { style.Println(style.Success.Render(" ✓ basic query OK (SELECT 1)")) // 3) migrations status - p, err := database.GooseProvider(db) + p, err := Schema.Provider(db) if err != nil { // if we can't determine migration state treat it as fatal style.Println(style.Failure.Render(" ✗ could not determine migration status")) diff --git a/cmd/env.go b/cmd/env.go index f71bbc9..8954ef7 100644 --- a/cmd/env.go +++ b/cmd/env.go @@ -35,6 +35,12 @@ import ( "github.com/spf13/viper" ) +// Schema is the schema of the database, which main makes with the migrations +// that are embedded in the binary (they are not in a directory that +// internal/database can embed them from). It is what every command that migrates +// a database brings it to. +var Schema *database.Schema + // The packages in internal/ are given what they work on: they don't look for // the database, or the directory of the state, by themselves. This is where the // commands get it from, for what they do all the time, so that a command @@ -86,7 +92,7 @@ func userDirs() internal.UserDirs { // openDB opens the database that is configured, which has to be there (it is // made by init) and is migrated to the latest schema. The caller closes it. func openDB(ctx context.Context) (*sql.DB, error) { - return database.Open(ctx, databasePath()) + return Schema.Open(ctx, databasePath()) } // gameInstall resolves the game install that a command is for, from what it was diff --git a/cmd/export.go b/cmd/export.go index 4bfd809..2de0613 100644 --- a/cmd/export.go +++ b/cmd/export.go @@ -82,6 +82,7 @@ Examples: NoVerify: exportNoVerify, CacheDBPath: nexusCacheDBPath(), ScratchDir: scratchDir(), + Schema: Schema, Progress: printExportProgress, } diff --git a/cmd/extract.go b/cmd/extract.go index 7a11fb0..703570a 100644 --- a/cmd/extract.go +++ b/cmd/extract.go @@ -73,7 +73,7 @@ Examples: bundlePath := args[0] - bundle, err := restore.OpenAndValidate(ctx, bundlePath, scratchDir()) + bundle, err := restore.OpenAndValidate(ctx, bundlePath, scratchDir(), Schema) if err != nil { return fmt.Errorf("invalid bundle: %w", err) } diff --git a/cmd/import.go b/cmd/import.go index cc6f87a..fa9ebb7 100644 --- a/cmd/import.go +++ b/cmd/import.go @@ -75,7 +75,7 @@ Use --dry-run to preview what would be imported without making any changes.`, // Open and validate bundle before touching the DB style.Println(style.Bold.Render("Validating bundle...")) - bundle, err := restore.OpenAndValidate(ctx, bundlePath, scratchDir()) + bundle, err := restore.OpenAndValidate(ctx, bundlePath, scratchDir(), Schema) if err != nil { return fmt.Errorf("invalid bundle: %w", err) } @@ -113,7 +113,7 @@ Use --dry-run to preview what would be imported without making any changes.`, } defer db.Close() // NB if we do a full restore we'll close this // twice, but it shouldn't be a problem - if err := database.Migrate(ctx, db); err != nil { + if err := Schema.Migrate(ctx, db); err != nil { return fmt.Errorf("error migrating database: %w", err) } @@ -133,6 +133,7 @@ Use --dry-run to preview what would be imported without making any changes.`, SameMachine: importSameMachine, Game: importGame, CacheDBPath: nexusCacheDBPath(), + Schema: Schema, } // Warn if bundle modctl version is newer diff --git a/cmd/init.go b/cmd/init.go index aee8ddd..9459506 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -78,7 +78,7 @@ multiple times and will not overwrite existing data.`, } defer db.Close() - err = database.Migrate(ctx, db) + err = Schema.Migrate(ctx, db) if err != nil { return fmt.Errorf("error migrating database: %w", err) } diff --git a/cmd/verify.go b/cmd/verify.go index ad26e6a..5c182b4 100644 --- a/cmd/verify.go +++ b/cmd/verify.go @@ -63,7 +63,7 @@ Exits non-zero if any integrity issues are found. Version warnings style.Println(style.Bold.Render("Verifying bundle: " + bundlePath)) style.Println() - bundle, err := restore.OpenAndValidate(ctx, bundlePath, scratchDir()) + bundle, err := restore.OpenAndValidate(ctx, bundlePath, scratchDir(), Schema) if err != nil { return fmt.Errorf("bundle validation failed: %w", err) } @@ -106,7 +106,7 @@ Exits non-zero if any integrity issues are found. Version warnings // same thing (every command migrates it), but it may not exist yet, or // may not have been migrated since an upgrade. An older schema is not // worth a warning since import migrates it. - if latest, err := database.LatestSchemaVersion(); err == nil && + if latest, err := Schema.LatestVersion(); err == nil && bundle.Manifest.SchemaVersion > latest { style.Println(style.Warning.Render(fmt.Sprintf( " ⚠ bundle schema version %d is newer than the latest this version of modctl supports (%d) - upgrade modctl before importing", diff --git a/internal/database/database.go b/internal/database/database.go index 30cd426..08a6706 100644 --- a/internal/database/database.go +++ b/internal/database/database.go @@ -46,19 +46,33 @@ const DB_PRAGMAS = "?_foreign_keys=ON&_journal_mode=WAL&_synchronous=NORMAL" // that way. const DB_PRAGMAS_READONLY = "?_foreign_keys=ON&mode=ro" -// Migrations is where the migrations are read from, in a directory called -// "migrations": the ones that are embedded in the binary, which main sets. A -// test has none unless it sets some, which is what lets it have the ones of the -// repository. -var Migrations fs.FS - -// migrationsFS is the "migrations" directory of Migrations. -func migrationsFS() (fs.FS, error) { - if Migrations == nil { +// A Schema is the schema of the database: the migrations that make it. They +// are in a directory called "migrations" of the file system that the Schema is +// made with, which in the binary is the ones that are embedded in it (main has +// them, since they are not in a directory that this package can embed them +// from). +// +// What works on a database is given the Schema that it brings the database to, +// and does not find one: a test has the Schema of the repository, or one of +// its own, and none of them is anything but what it is given. +type Schema struct { + root fs.FS +} + +// NewSchema is the Schema that is made by the migrations in the "migrations" +// directory of root. A root that is nil has no migrations: every use of the +// Schema is an error that says so (and so is every use of a Schema that is nil). +func NewSchema(root fs.FS) *Schema { + return &Schema{root: root} +} + +// migrations is the "migrations" directory of the file system of s. +func (s *Schema) migrations() (fs.FS, error) { + if s == nil || s.root == nil { return nil, errors.New("no migrations are embedded") } - return fs.Sub(Migrations, "migrations") + return fs.Sub(s.root, "migrations") } // DSN returns the connection string for the SQLite database at path, which @@ -90,9 +104,10 @@ func SetupReadOnly(path string) (*sql.DB, error) { return sql.Open("sqlite3", DSN(path, true)) } -func GooseProvider(db *sql.DB) (*goose.Provider, error) { - // Make the provider FS point at the "migrations" directory within Migrations. - fsys, err := migrationsFS() +// Provider is a goose provider for db that has the migrations of the Schema. +func (s *Schema) Provider(db *sql.DB) (*goose.Provider, error) { + // Make the provider FS point at the "migrations" directory of the Schema. + fsys, err := s.migrations() if err != nil { return nil, fmt.Errorf("error preparing migrations fs: %w", err) } @@ -106,8 +121,9 @@ func GooseProvider(db *sql.DB) (*goose.Provider, error) { ) } -func Migrate(ctx context.Context, db *sql.DB) error { - p, err := GooseProvider(db) +// Migrate brings db to the latest migration of the Schema. +func (s *Schema) Migrate(ctx context.Context, db *sql.DB) error { + p, err := s.Provider(db) if err != nil { return fmt.Errorf("error setting up goose provider: %w", err) } @@ -120,13 +136,12 @@ func Migrate(ctx context.Context, db *sql.DB) error { return nil } -// CurrentSchemaVersion returns the version of the latest migration that has -// been applied to db. This is what the database itself is at, which is not -// necessarily the latest migration this build of modctl knows about: a new -// database reports 0 and one that has not been migrated since an upgrade -// reports its older version. -func CurrentSchemaVersion(ctx context.Context, db *sql.DB) (int64, error) { - p, err := GooseProvider(db) +// CurrentVersion returns the version of the latest migration that has been +// applied to db. This is what the database itself is at, which is not +// necessarily the latest migration of the Schema: a new database reports 0 and +// one that has not been migrated since an upgrade reports its older version. +func (s *Schema) CurrentVersion(ctx context.Context, db *sql.DB) (int64, error) { + p, err := s.Provider(db) if err != nil { return 0, fmt.Errorf("get goose provider: %w", err) } @@ -135,9 +150,8 @@ func CurrentSchemaVersion(ctx context.Context, db *sql.DB) (int64, error) { } // SchemaVersion returns the applied migration version as seen by p. It is -// split out from CurrentSchemaVersion so that tests can supply a provider that -// reads its migrations from the filesystem (the embedded copy is only -// populated in the real binary). +// split out from Schema.CurrentVersion so that a test can have a provider that +// is controlled further than a Schema is (how far it has migrated, say). func SchemaVersion(ctx context.Context, p *goose.Provider) (int64, error) { current, _, err := p.GetVersions(ctx) if err != nil { @@ -147,13 +161,13 @@ func SchemaVersion(ctx context.Context, p *goose.Provider) (int64, error) { return current, nil } -// LatestSchemaVersion returns the version of the latest migration known to -// this build of modctl: the highest version among the embedded migrations. It -// does not look at any database, so it is the version a database ends up at -// once it has been fully migrated, and what a bundle's schema version has to -// be compared against to tell whether this build can import it. -func LatestSchemaVersion() (int64, error) { - fsys, err := migrationsFS() +// LatestVersion returns the version of the latest migration of the Schema: the +// highest version among its migrations. It does not look at any database, so +// it is the version a database ends up at once it has been fully migrated, and +// what a bundle's schema version has to be compared against to tell whether +// this build can import it. +func (s *Schema) LatestVersion() (int64, error) { + fsys, err := s.migrations() if err != nil { return 0, fmt.Errorf("error preparing migrations fs: %w", err) } @@ -162,9 +176,7 @@ func LatestSchemaVersion() (int64, error) { } // latestMigrationVersion returns the highest version among the .sql -// migrations in the root of fsys. It is split out from LatestSchemaVersion so -// that tests can supply their own migrations (the embedded copy is only -// populated in the real binary). +// migrations in the root of fsys. func latestMigrationVersion(fsys fs.FS) (int64, error) { entries, err := fs.ReadDir(fsys, ".") if err != nil { @@ -194,14 +206,14 @@ func latestMigrationVersion(fsys fs.FS) (int64, error) { } // Open opens the database that the commands use, at path: it has to be there -// (it is made by init), and it is migrated to the latest schema before it is -// handed over. The caller closes it. -func Open(ctx context.Context, path string) (*sql.DB, error) { - return openWith(ctx, path, Migrate) +// (it is made by init), and it is migrated to the latest migration of the +// Schema before it is handed over. The caller closes it. +func (s *Schema) Open(ctx context.Context, path string) (*sql.DB, error) { + return openWith(ctx, path, s.Migrate) } -// openWith is Open with the migrating done by migrate, so that a test can -// have migrations that are not the ones that are embedded in the binary. +// openWith is Schema.Open with the migrating done by migrate, so that a test +// can have a migrating that fails, or that is watched. func openWith(ctx context.Context, path string, migrate func(context.Context, *sql.DB) error) (*sql.DB, error) { if err := EnsureExists(path); err != nil { return nil, err diff --git a/internal/database/database_test.go b/internal/database/database_test.go index 0df360b..7011b5e 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -19,7 +19,6 @@ package database import ( - "context" "database/sql" "os" "path/filepath" @@ -227,54 +226,3 @@ func TestSetupReadOnly(t *testing.T) { assert.NoFileExists(t, path) }) } - -// useMigrationsOfRepository has Migrations be the ones of the repository for the -// length of a test, as they are in the binary. It changes something that is -// global, so the tests that use it are not run in parallel. -func useMigrationsOfRepository(t *testing.T) { - t.Helper() - - old := Migrations - Migrations = os.DirFS("../..") - t.Cleanup(func() { Migrations = old }) -} - -func TestMigrations(t *testing.T) { - t.Run("there are none until there are some", func(t *testing.T) { - _, err := LatestSchemaVersion() - require.Error(t, err) - assert.Contains(t, err.Error(), "no migrations are embedded") - - db, err := sql.Open("sqlite3", ":memory:") - require.NoError(t, err) - defer db.Close() - _, err = GooseProvider(db) - require.Error(t, err) - assert.Contains(t, err.Error(), "no migrations are embedded") - }) - - t.Run("the ones that are set are the ones that are used", func(t *testing.T) { - useMigrationsOfRepository(t) - - latest, err := LatestSchemaVersion() - require.NoError(t, err) - want, err := latestMigrationVersion(os.DirFS("../../migrations")) - require.NoError(t, err) - assert.Equal(t, want, latest) - assert.Positive(t, latest) - - db, err := sql.Open("sqlite3", DSN(filepath.Join(t.TempDir(), "modctl.db"), false)) - require.NoError(t, err) - defer db.Close() - require.NoError(t, Migrate(context.Background(), db)) - - current, err := CurrentSchemaVersion(context.Background(), db) - require.NoError(t, err) - assert.Equal(t, latest, current) - }) - - t.Run("they are only there for the length of the test", func(t *testing.T) { - _, err := LatestSchemaVersion() - require.Error(t, err) - }) -} diff --git a/internal/database/latestschemaversion_test.go b/internal/database/latestschemaversion_test.go index fe75c76..2d3b75e 100644 --- a/internal/database/latestschemaversion_test.go +++ b/internal/database/latestschemaversion_test.go @@ -185,15 +185,46 @@ func TestLatestMigrationVersionMatchesGoose(t *testing.T) { assert.Equal(t, want, got) } -func TestLatestSchemaVersion(t *testing.T) { +func TestSchemaLatestVersion(t *testing.T) { t.Parallel() - t.Run("errors when no migrations are embedded", func(t *testing.T) { + t.Run("it is the highest of the migrations that the schema is made with", func(t *testing.T) { t.Parallel() - // Migrations is only populated by main, never in tests - got, err := LatestSchemaVersion() - require.Error(t, err) - assert.Equal(t, int64(0), got) + got, err := repositorySchema().LatestVersion() + + require.NoError(t, err) + want, err := latestMigrationVersion(os.DirFS("../../migrations")) + require.NoError(t, err) + assert.Equal(t, want, got) + assert.Positive(t, got) + }) + + t.Run("a schema that has no migrations is an error, and not version zero", func(t *testing.T) { + t.Parallel() + + for name, schema := range map[string]*Schema{ + "without a file system": NewSchema(nil), + "that is not there": nil, + } { + schema := schema + t.Run(name, func(t *testing.T) { + t.Parallel() + + got, err := schema.LatestVersion() + + require.Error(t, err) + assert.Contains(t, err.Error(), "no migrations are embedded") + assert.Equal(t, int64(0), got) + }) + } + }) + + t.Run("a file system that has no migrations directory is an error", func(t *testing.T) { + t.Parallel() + + _, err := NewSchema(fstest.MapFS{"other/00001_x.sql": {}}).LatestVersion() + + assert.Error(t, err) }) } diff --git a/internal/database/open_test.go b/internal/database/open_test.go index e86e85e..3db9047 100644 --- a/internal/database/open_test.go +++ b/internal/database/open_test.go @@ -26,7 +26,6 @@ import ( "path/filepath" "testing" - "github.com/pressly/goose/v3" "github.com/stretchr/testify/assert" "github.com/stretchr/testify/require" ) @@ -34,13 +33,7 @@ import ( // migrateFromFiles is a migrate for openWith that applies the migrations of the // repository, which are not embedded in the binary of a test. func migrateFromFiles(ctx context.Context, db *sql.DB) error { - p, err := goose.NewProvider(goose.DialectSQLite3, db, os.DirFS("../../migrations")) - if err != nil { - return err - } - - _, err = p.Up(ctx) - return err + return repositorySchema().Migrate(ctx, db) } // emptyDatabaseFile makes a database file that has nothing in it yet, which is @@ -161,14 +154,29 @@ func TestOpenDB(t *testing.T) { assert.Error(t, migrated.PingContext(ctx), "it is not left open") }) - t.Run("the migrations that are embedded are the ones that are used", func(t *testing.T) { + t.Run("the schema that is given is the one that is migrated to", func(t *testing.T) { t.Parallel() - // Migrations is only filled in by the binary, so in a test there is - // nothing to migrate with, which is an error and not an empty schema - db, err := Open(ctx, emptyDatabaseFile(t)) + db, err := repositorySchema().Open(ctx, emptyDatabaseFile(t)) + + require.NoError(t, err) + defer db.Close() + current, err := repositorySchema().CurrentVersion(ctx, db) + require.NoError(t, err) + latest, err := repositorySchema().LatestVersion() + require.NoError(t, err) + assert.Equal(t, latest, current) + }) + + t.Run("a schema that has no migrations is an error, and not an empty schema", func(t *testing.T) { + t.Parallel() + + path := emptyDatabaseFile(t) + + db, err := NewSchema(nil).Open(ctx, path) require.Error(t, err) assert.Nil(t, db) + assert.Contains(t, err.Error(), "no migrations are embedded") }) } diff --git a/internal/database/schema_test.go b/internal/database/schema_test.go new file mode 100644 index 0000000..54b9284 --- /dev/null +++ b/internal/database/schema_test.go @@ -0,0 +1,172 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package database + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// repositorySchema is the schema of the repository: the migrations that are +// embedded in the binary, which a test does not have, are the ones in the +// directory that they are in. Every test makes the one that it uses, so none of +// them is anything but what it is given. +func repositorySchema() *Schema { + return NewSchema(os.DirFS("../..")) +} + +// newFileDB is an empty database in a file of the test. +func newFileDB(t *testing.T) *sql.DB { + t.Helper() + + db, err := sql.Open("sqlite3", DSN(filepath.Join(t.TempDir(), "modctl.db"), false)) + require.NoError(t, err) + t.Cleanup(func() { db.Close() }) + return db +} + +func TestSchemaMigrate(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + t.Run("a database is brought to the latest migration of the schema", func(t *testing.T) { + t.Parallel() + + db := newFileDB(t) + schema := repositorySchema() + + require.NoError(t, schema.Migrate(ctx, db)) + + current, err := schema.CurrentVersion(ctx, db) + require.NoError(t, err) + latest, err := schema.LatestVersion() + require.NoError(t, err) + assert.Equal(t, latest, current) + }) + + t.Run("a database that is migrated is migrated again as it is", func(t *testing.T) { + t.Parallel() + + db := newFileDB(t) + schema := repositorySchema() + require.NoError(t, schema.Migrate(ctx, db)) + + assert.NoError(t, schema.Migrate(ctx, db)) + }) + + t.Run("a schema that has no migrations is an error that says so, and migrates nothing", func(t *testing.T) { + t.Parallel() + + db := newFileDB(t) + + err := NewSchema(nil).Migrate(ctx, db) + + require.Error(t, err) + assert.Contains(t, err.Error(), "no migrations are embedded") + var tables int + require.NoError(t, db.QueryRow(`SELECT COUNT(*) FROM sqlite_master WHERE type = 'table'`).Scan(&tables)) + assert.Zero(t, tables) + }) +} + +func TestSchemaCurrentVersion(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + t.Run("a database that has not been migrated is at the start", func(t *testing.T) { + t.Parallel() + + got, err := repositorySchema().CurrentVersion(ctx, newFileDB(t)) + + require.NoError(t, err) + assert.Equal(t, int64(0), got) + }) + + t.Run("a database is at the version that it was migrated to", func(t *testing.T) { + t.Parallel() + + db := newFileDB(t) + schema := repositorySchema() + p, err := schema.Provider(db) + require.NoError(t, err) + first, err := p.UpByOne(ctx) + require.NoError(t, err) + + got, err := schema.CurrentVersion(ctx, db) + + require.NoError(t, err) + assert.Equal(t, first.Source.Version, got, "and not the latest one") + }) + + t.Run("a schema that has no migrations is an error that says so", func(t *testing.T) { + t.Parallel() + + _, err := NewSchema(nil).CurrentVersion(ctx, newFileDB(t)) + + require.Error(t, err) + assert.Contains(t, err.Error(), "no migrations are embedded") + }) +} + +func TestSchemaProvider(t *testing.T) { + t.Parallel() + + t.Run("it has the migrations of the schema", func(t *testing.T) { + t.Parallel() + + p, err := repositorySchema().Provider(newFileDB(t)) + + require.NoError(t, err) + sources := p.ListSources() + require.NotEmpty(t, sources) + latest, err := repositorySchema().LatestVersion() + require.NoError(t, err) + assert.Equal(t, latest, sources[len(sources)-1].Version) + }) + + t.Run("a schema that has no migrations is an error that says so", func(t *testing.T) { + t.Parallel() + + p, err := NewSchema(nil).Provider(newFileDB(t)) + + require.Error(t, err) + assert.Nil(t, p) + assert.Contains(t, err.Error(), "no migrations are embedded") + }) + + t.Run("two schemas are their own, and one that has none does not take the other's", func(t *testing.T) { + t.Parallel() + + with, without := repositorySchema(), NewSchema(nil) + + _, errWith := with.LatestVersion() + _, errWithout := without.LatestVersion() + + assert.NoError(t, errWith) + assert.Error(t, errWithout) + }) +} diff --git a/internal/exporter/exporter.go b/internal/exporter/exporter.go index 3da652a..786e2fd 100644 --- a/internal/exporter/exporter.go +++ b/internal/exporter/exporter.go @@ -32,6 +32,7 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/blobstore" + "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/fsutil" ) @@ -55,6 +56,10 @@ type Options struct { // to be there, and to have room for a copy of the databases. It is not // found by the exporter: nothing is made anywhere that it was not told. ScratchDir string + // Schema is the schema of the databases that are in the bundle: the + // version that the bundle says the database is at is its, and so is what + // the database of a game is made with. It is not found by the exporter. + Schema *database.Schema // Progress, if set, is told how verifying blobs is going. The exporter // itself prints nothing. Progress func(Progress) @@ -65,6 +70,9 @@ func (o Options) validate() error { if o.ScratchDir == "" { return errors.New("no scratch directory to make the snapshots of the databases in") } + if o.Schema == nil { + return errors.New("no schema to make the databases of the bundle with") + } return nil } diff --git a/internal/exporter/full.go b/internal/exporter/full.go index d947a91..5f1dd8a 100644 --- a/internal/exporter/full.go +++ b/internal/exporter/full.go @@ -113,7 +113,7 @@ func Full( } // 2. Get schema version - schemaVersion, err := database.CurrentSchemaVersion(ctx, db) + schemaVersion, err := opts.Schema.CurrentVersion(ctx, db) if err != nil { return Result{}, fmt.Errorf("get schema version: %w", err) } diff --git a/internal/exporter/game.go b/internal/exporter/game.go index 9d8c981..e2948cc 100644 --- a/internal/exporter/game.go +++ b/internal/exporter/game.go @@ -96,7 +96,7 @@ func Game( // 1. Build the scoped SQLite database scopedDBPath, dbSha256, archiveCount, backupCount, overrideCount, err := buildGameScopedDB( - ctx, q, gi, opts.SkipInventory, opts.ScratchDir, + ctx, q, gi, opts.SkipInventory, opts.ScratchDir, opts.Schema, ) if err != nil { return Result{}, fmt.Errorf("build game-scoped database: %w", err) @@ -115,7 +115,7 @@ func Game( } // 2. Get schema version from source DB - schemaVersion, err := database.CurrentSchemaVersion(ctx, db) + schemaVersion, err := opts.Schema.CurrentVersion(ctx, db) if err != nil { return Result{}, fmt.Errorf("get schema version: %w", err) } @@ -204,14 +204,16 @@ func Game( } // buildGameScopedDB constructs a fresh SQLite database containing only rows -// relevant to the given game install, in the directory scratchDir. Returns the -// path to the temp file, the sha256 of the database, and blob counts. +// relevant to the given game install, in the directory scratchDir, with the +// schema schema. Returns the path to the temp file, the sha256 of the database, +// and blob counts. func buildGameScopedDB( ctx context.Context, q *dbq.Queries, gi dbq.GameInstall, skipInventory bool, scratchDir string, + schema *database.Schema, ) (path string, dbSha256 string, archiveCount int, backupCount int, overrideCount int, err error) { tmp, err := os.CreateTemp(scratchDir, "modctl-export-*.sqlite") if err != nil { @@ -227,7 +229,7 @@ func buildGameScopedDB( } defer scopedDB.Close() - if err := database.Migrate(ctx, scopedDB); err != nil { + if err := schema.Migrate(ctx, scopedDB); err != nil { os.Remove(tmpPath) return "", "", 0, 0, 0, fmt.Errorf("migrate scoped db: %w", err) } diff --git a/internal/restore/bundle_test.go b/internal/restore/bundle_test.go index 9fd089f..a306adc 100644 --- a/internal/restore/bundle_test.go +++ b/internal/restore/bundle_test.go @@ -31,7 +31,6 @@ import ( "github.com/klauspost/compress/zstd" "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/blobstore" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/exporter" "github.com/mfinelli/modctl/internal/nexusclient" "github.com/mfinelli/modctl/internal/testbuilder" @@ -40,9 +39,8 @@ import ( ) // The bundles that are opened here are the ones that the exporter makes, so what -// is tested is the way there and back. They are in this package because the -// export migrates a database too, which needs the migrations that TestMain -// has set up for the tests of this package. +// is tested is the way there and back: that is why the tests of the export that +// need what is opened are in this package, and not in the one of the exporter. // exportOptions are the options of an export to a bundle in a directory of the // test, whose snapshots are made in scratch. @@ -55,6 +53,7 @@ func exportOptions(t *testing.T, scratch string) exporter.Options { // there is no cache of what Nexus Mods said, unless a test makes one CacheDBPath: filepath.Join(t.TempDir(), "nexus_cache.db"), ScratchDir: scratch, + Schema: testbuilder.Schema(), } } @@ -141,11 +140,11 @@ func TestExportThenOpenAndValidate(t *testing.T) { require.NoError(t, err) assertEmpty(t, scratch, "what the export made in the scratch directory is gone") - bundle, err := OpenAndValidate(ctx, opts.OutputPath, scratch) + bundle, err := OpenAndValidate(ctx, opts.OutputPath, scratch, testbuilder.Schema()) require.NoError(t, err) defer bundle.Close() - latest, err := database.LatestSchemaVersion() + latest, err := testbuilder.Schema().LatestVersion() require.NoError(t, err) assert.Equal(t, exporter.ExportKindFull, bundle.Manifest.ExportKind) assert.Equal(t, "9.9.9", bundle.Manifest.ModctlVersion) @@ -167,7 +166,7 @@ func TestExportThenOpenAndValidate(t *testing.T) { require.NoError(t, err) assertEmpty(t, scratch, "what the export made in the scratch directory is gone") - bundle, err := OpenAndValidate(ctx, opts.OutputPath, scratch) + bundle, err := OpenAndValidate(ctx, opts.OutputPath, scratch, testbuilder.Schema()) require.NoError(t, err) defer bundle.Close() @@ -194,7 +193,7 @@ func TestExportThenOpenAndValidate(t *testing.T) { require.NoError(t, err) assertEmpty(t, scratch, "the snapshot of the cache is gone, too") - bundle, err := OpenAndValidate(ctx, opts.OutputPath, scratch) + bundle, err := OpenAndValidate(ctx, opts.OutputPath, scratch, testbuilder.Schema()) require.NoError(t, err) defer bundle.Close() @@ -211,7 +210,7 @@ func TestExportThenOpenAndValidate(t *testing.T) { opts := exportOptions(t, scratch) _, err := exporter.Full(ctx, db, dbq.New(db), storeInTestDir(t), opts) require.NoError(t, err) - bundle, err := OpenAndValidate(ctx, opts.OutputPath, scratch) + bundle, err := OpenAndValidate(ctx, opts.OutputPath, scratch, testbuilder.Schema()) require.NoError(t, err) require.DirExists(t, bundle.BundleDir) @@ -268,6 +267,66 @@ func TestExportNeedsAScratchDirectory(t *testing.T) { } } +func TestExportNeedsASchema(t *testing.T) { + t.Parallel() + + ctx := context.Background() + + t.Run("full without one is an error, and nothing is made", func(t *testing.T) { + t.Parallel() + + db, _ := testbuilder.SetupDBFile(t) + opts := exportOptions(t, t.TempDir()) + opts.Schema = nil + + _, err := exporter.Full(ctx, db, dbq.New(db), storeInTestDir(t), opts) + + require.Error(t, err) + assert.Contains(t, err.Error(), "no schema") + assert.NoFileExists(t, opts.OutputPath) + }) + + t.Run("game without one is an error, and nothing is made", func(t *testing.T) { + t.Parallel() + + db, _ := testbuilder.SetupDBFile(t) + gi := testbuilder.NewGame(t, db).Build() + opts := exportOptions(t, t.TempDir()) + opts.Schema = nil + + _, err := exporter.Game(ctx, db, dbq.New(db), storeInTestDir(t), gi, opts) + + require.Error(t, err) + assert.Contains(t, err.Error(), "no schema") + assert.NoFileExists(t, opts.OutputPath) + }) + + t.Run("what is exported is at the version of the schema that it is given", func(t *testing.T) { + t.Parallel() + + db, _ := testbuilder.SetupDBFile(t) + gi := testbuilder.NewGame(t, db).Build() + scratch := t.TempDir() + opts := exportOptions(t, scratch) + + _, err := exporter.Game(ctx, db, dbq.New(db), storeInTestDir(t), gi, opts) + require.NoError(t, err) + bundle, err := OpenAndValidate(ctx, opts.OutputPath, scratch, opts.Schema) + require.NoError(t, err) + defer bundle.Close() + + // the database of the bundle is only read, so what it says it is at is + // read from the migrations that were applied to it + var applied int64 + require.NoError(t, bundle.BundleDB.QueryRow( + `SELECT MAX(version_id) FROM schema_migrations WHERE is_applied`).Scan(&applied)) + latest, err := opts.Schema.LatestVersion() + require.NoError(t, err) + assert.Equal(t, latest, applied, "the database of a game is made with the schema") + assert.Equal(t, latest, bundle.Manifest.SchemaVersion) + }) +} + func TestOpenAndValidate(t *testing.T) { t.Parallel() @@ -279,19 +338,32 @@ func TestOpenAndValidate(t *testing.T) { bundle := writeBundle(t) - got, err := OpenAndValidate(ctx, bundle, "") + got, err := OpenAndValidate(ctx, bundle, "", testbuilder.Schema()) require.Error(t, err) assert.Nil(t, got) assert.Contains(t, err.Error(), "no scratch directory") }) + t.Run("without a schema it is an error, and nothing is left", func(t *testing.T) { + t.Parallel() + + scratch := t.TempDir() + + got, err := OpenAndValidate(ctx, writeBundle(t), scratch, nil) + + require.Error(t, err) + assert.Nil(t, got) + assert.Contains(t, err.Error(), "no schema") + assertEmpty(t, scratch) + }) + t.Run("a scratch directory that is not there is an error that says so", func(t *testing.T) { t.Parallel() bundle := writeBundle(t) - got, err := OpenAndValidate(ctx, bundle, filepath.Join(t.TempDir(), "not-there")) + got, err := OpenAndValidate(ctx, bundle, filepath.Join(t.TempDir(), "not-there"), testbuilder.Schema()) require.Error(t, err) assert.Nil(t, got) @@ -303,7 +375,7 @@ func TestOpenAndValidate(t *testing.T) { scratch := t.TempDir() - got, err := OpenAndValidate(ctx, filepath.Join(t.TempDir(), "nothing.tar.zst"), scratch) + got, err := OpenAndValidate(ctx, filepath.Join(t.TempDir(), "nothing.tar.zst"), scratch, testbuilder.Schema()) require.Error(t, err) assert.Nil(t, got) @@ -318,7 +390,7 @@ func TestOpenAndValidate(t *testing.T) { require.NoError(t, os.WriteFile(path, []byte("this is not zstd"), 0o644)) scratch := t.TempDir() - got, err := OpenAndValidate(ctx, path, scratch) + got, err := OpenAndValidate(ctx, path, scratch, testbuilder.Schema()) require.Error(t, err) assert.Nil(t, got) @@ -392,7 +464,7 @@ func TestOpenAndValidate(t *testing.T) { scratch := t.TempDir() - got, err := OpenAndValidate(ctx, writeBundle(t, tc.entries(t)...), scratch) + got, err := OpenAndValidate(ctx, writeBundle(t, tc.entries(t)...), scratch, testbuilder.Schema()) require.Error(t, err) assert.Nil(t, got) @@ -413,7 +485,7 @@ func TestOpenAndValidate(t *testing.T) { bundleEntry{"../../../escaped-too.txt", []byte("not here")}, ) - _, err := OpenAndValidate(ctx, bundle, scratch) + _, err := OpenAndValidate(ctx, bundle, scratch, testbuilder.Schema()) require.Error(t, err, "there is no manifest in it") assert.NoFileExists(t, filepath.Join(parent, "escaped.txt")) diff --git a/internal/restore/bundledb.go b/internal/restore/bundledb.go index 2aecdce..74fc671 100644 --- a/internal/restore/bundledb.go +++ b/internal/restore/bundledb.go @@ -28,9 +28,8 @@ import ( ) // providerFunc builds a goose provider (and so the set of known migrations) -// for a database. Production code uses database.GooseProvider; tests supply -// one that reads migrations from the filesystem because the embedded copy is -// only populated in the real binary. +// for a database. Production code uses the Provider of a database.Schema; a test +// can supply one that it controls further (how far it has migrated, say). type providerFunc func(*sql.DB) (*goose.Provider, error) // openBundleDB opens the extracted bundle database read-only. If the bundle diff --git a/internal/restore/full.go b/internal/restore/full.go index 128e71a..21b008f 100644 --- a/internal/restore/full.go +++ b/internal/restore/full.go @@ -21,6 +21,7 @@ package restore import ( "context" "database/sql" + "errors" "fmt" "log/slog" "os" @@ -48,8 +49,12 @@ func Full( ) (Result, error) { var res Result + if opts.Schema == nil { + return res, errors.New("no schema to restore the database with") + } + // Validate schema version - currentSchema, err := database.CurrentSchemaVersion(ctx, db) + currentSchema, err := opts.Schema.CurrentVersion(ctx, db) if err != nil { return res, fmt.Errorf("get current schema version: %w", err) } @@ -119,7 +124,7 @@ func Full( return res, fmt.Errorf("reopen database: %w", err) } defer newDB.Close() - if err := database.Migrate(ctx, newDB); err != nil { + if err := opts.Schema.Migrate(ctx, newDB); err != nil { return res, fmt.Errorf("migrate restored database: %w", err) } newQ := dbq.New(newDB) diff --git a/internal/restore/full_test.go b/internal/restore/full_test.go index c334125..a5871f5 100644 --- a/internal/restore/full_test.go +++ b/internal/restore/full_test.go @@ -100,7 +100,7 @@ func TestFullReplacesTheDatabase(t *testing.T) { ctx := context.Background() f := newFullRestoreFixture(t) - _, err := Full(ctx, f.db, dbq.New(f.db), blobstore.Store{}, f.bundle, Options{Force: true}, f.dbPath, "test", quietLogger()) + _, err := Full(ctx, f.db, dbq.New(f.db), blobstore.Store{}, f.bundle, Options{Schema: testbuilder.Schema(), Force: true}, f.dbPath, "test", quietLogger()) require.NoError(t, err) restored, err := sql.Open("sqlite3", database.DSN(f.dbPath, true)) @@ -110,6 +110,44 @@ func TestFullReplacesTheDatabase(t *testing.T) { assert.NoFileExists(t, f.dbPath+".restoring", "the copy was moved into place") } +func TestFullMigratesTheDatabaseItRestores(t *testing.T) { + t.Parallel() + + ctx := context.Background() + f := newFullRestoreFixture(t) + schema := testbuilder.Schema() + latest, err := schema.LatestVersion() + require.NoError(t, err) + + // the database of the bundle is of the version of the schema before this one, + // as it is for a bundle that was exported before modctl was upgraded + olderPath := filepath.Join(t.TempDir(), exporter.DatabaseFilename) + older, err := sql.Open("sqlite3", database.DSN(olderPath, false)) + require.NoError(t, err) + p := testbuilder.NewProvider(t, older) + sources := p.ListSources() + require.GreaterOrEqual(t, len(sources), 2) + before := sources[len(sources)-2].Version + _, err = p.UpTo(ctx, before) + require.NoError(t, err) + _, err = older.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`) + require.NoError(t, err) + require.NoError(t, older.Close()) + require.NoError(t, os.Rename(olderPath, filepath.Join(f.bundle.BundleDir, exporter.DatabaseFilename))) + + _, err = Full(ctx, f.db, dbq.New(f.db), blobstore.Store{}, f.bundle, + Options{Schema: schema, Force: true}, f.dbPath, "test", quietLogger()) + + require.NoError(t, err) + restored, err := database.Setup(f.dbPath) + require.NoError(t, err) + defer restored.Close() + current, err := schema.CurrentVersion(ctx, restored) + require.NoError(t, err) + assert.Equal(t, latest, current, "it is at the version of the schema that it is given, and not the one of the bundle") + assert.Less(t, before, latest) +} + func TestFullThatCannotCopyTheBundleLeavesTheDatabase(t *testing.T) { t.Parallel() @@ -120,7 +158,7 @@ func TestFullThatCannotCopyTheBundleLeavesTheDatabase(t *testing.T) { require.NoError(t, os.Remove(filepath.Join(f.bundle.BundleDir, exporter.DatabaseFilename))) f.bundle.BundleDB = testbuilder.SetupDB(t) // what it is checked with is not what is wrong - _, err := Full(ctx, f.db, dbq.New(f.db), blobstore.Store{}, f.bundle, Options{Force: true}, f.dbPath, "test", quietLogger()) + _, err := Full(ctx, f.db, dbq.New(f.db), blobstore.Store{}, f.bundle, Options{Schema: testbuilder.Schema(), Force: true}, f.dbPath, "test", quietLogger()) require.Error(t, err) assert.Contains(t, err.Error(), "restore database") diff --git a/internal/restore/game.go b/internal/restore/game.go index fd62234..b009f71 100644 --- a/internal/restore/game.go +++ b/internal/restore/game.go @@ -57,6 +57,10 @@ func Game( ) (Result, error) { var res Result + if opts.Schema == nil { + return res, errors.New("no schema to restore the game with") + } + // Resolve game identity: opts.Game takes precedence (set when extracting // a single game from a full bundle), otherwise use the bundle manifest. var storeID, storeGameID, displayName string @@ -76,7 +80,7 @@ func Game( } // Validate schema version - currentSchema, err := database.CurrentSchemaVersion(ctx, db) + currentSchema, err := opts.Schema.CurrentVersion(ctx, db) if err != nil { return res, fmt.Errorf("get current schema version: %w", err) } diff --git a/internal/restore/game_test.go b/internal/restore/game_test.go index fdd19f9..107f4d6 100644 --- a/internal/restore/game_test.go +++ b/internal/restore/game_test.go @@ -59,7 +59,7 @@ func TestGameImportsABundle(t *testing.T) { inBundle := newBundleGame(t, bundleDB, "1") bundle := testBundle(t, bundleDB, inBundle.game, exporter.ExportKindGame) - res, err := Game(ctx, dest, dbq.New(dest), blobstore.Store{}, bundle, Options{SkipInventory: true}, "test", quietLogger()) + res, err := Game(ctx, dest, dbq.New(dest), blobstore.Store{}, bundle, Options{Schema: testbuilder.Schema(), SkipInventory: true}, "test", quietLogger()) require.NoError(t, err) newID, err := gameInstallID(t, dbq.New(dest), inBundle.game) @@ -95,7 +95,7 @@ func TestGameWithAGameThatIsThere(t *testing.T) { dest, existing, bundle, _ := newBundle(t) - _, err := Game(ctx, dest, existing.q, blobstore.Store{}, bundle, Options{SkipInventory: true}, "test", quietLogger()) + _, err := Game(ctx, dest, existing.q, blobstore.Store{}, bundle, Options{Schema: testbuilder.Schema(), SkipInventory: true}, "test", quietLogger()) require.Error(t, err) assert.Contains(t, err.Error(), "already exists; use --force") @@ -110,7 +110,7 @@ func TestGameWithAGameThatIsThere(t *testing.T) { dest, existing, bundle, inBundle := newBundle(t) - _, err := Game(ctx, dest, existing.q, blobstore.Store{}, bundle, Options{Force: true, SkipInventory: true}, "test", quietLogger()) + _, err := Game(ctx, dest, existing.q, blobstore.Store{}, bundle, Options{Schema: testbuilder.Schema(), Force: true, SkipInventory: true}, "test", quietLogger()) require.NoError(t, err) id, getErr := gameInstallID(t, existing.q, inBundle.game) @@ -149,7 +149,7 @@ func TestGameThatCannotBeImportedToTheEndChangesNothing(t *testing.T) { inBundle.override("user.cfg") bundle := testBundle(t, bundleDB, inBundle.game, exporter.ExportKindGame) - _, err := Game(ctx, dest, existing.q, blobstore.Store{}, bundle, Options{Force: true, SkipInventory: true}, "test", quietLogger()) + _, err := Game(ctx, dest, existing.q, blobstore.Store{}, bundle, Options{Schema: testbuilder.Schema(), Force: true, SkipInventory: true}, "test", quietLogger()) require.Error(t, err) assert.Contains(t, err.Error(), "import overrides", "that is where it failed") diff --git a/internal/restore/paths_test.go b/internal/restore/paths_test.go index 97a384b..3c85554 100644 --- a/internal/restore/paths_test.go +++ b/internal/restore/paths_test.go @@ -31,7 +31,6 @@ import ( "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal/blobstore" - "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/exporter" "github.com/mfinelli/modctl/internal/testbuilder" "github.com/stretchr/testify/assert" @@ -305,7 +304,7 @@ func TestValidateFullPaths(t *testing.T) { func testBundle(t *testing.T, bundleDB *sql.DB, game dbq.GameInstall, kind exporter.ExportKind) *Bundle { t.Helper() - latest, err := database.LatestSchemaVersion() + latest, err := testbuilder.Schema().LatestVersion() require.NoError(t, err) return &Bundle{ @@ -346,6 +345,7 @@ func TestGameRefusesAnUnsafeBundle(t *testing.T) { bundle := testBundle(t, bundleDB, inBundle.game, exporter.ExportKindGame) for _, opts := range []Options{{Force: true}, {Force: true, DryRun: true}, {}} { + opts.Schema = testbuilder.Schema() _, err := Game(ctx, dest, existing.q, blobstore.Store{}, bundle, opts, "test", quietLogger()) require.ErrorIs(t, err, ErrUnsafePaths, "%+v", opts) @@ -375,7 +375,7 @@ func TestGameLetsASafeBundleBeLookedAt(t *testing.T) { inBundle.override("user.cfg") bundle := testBundle(t, bundleDB, inBundle.game, exporter.ExportKindGame) - _, err := Game(ctx, dest, dbq.New(dest), blobstore.Store{}, bundle, Options{DryRun: true}, "test", quietLogger()) + _, err := Game(ctx, dest, dbq.New(dest), blobstore.Store{}, bundle, Options{Schema: testbuilder.Schema(), DryRun: true}, "test", quietLogger()) assert.NoError(t, err) } @@ -390,10 +390,10 @@ func TestFullRefusesAnUnsafeBundle(t *testing.T) { fill func(g *bundleGame) opts Options }{ - {"an override", func(g *bundleGame) { g.override("../escape.cfg") }, Options{Force: true}}, - {"an override, in a dry run", func(g *bundleGame) { g.override("../escape.cfg") }, Options{DryRun: true}}, - {"a backup that is kept", func(g *bundleGame) { g.backup("../escape.cfg") }, Options{Force: true, SameMachine: true}}, - {"a file that is installed and kept", func(g *bundleGame) { g.installed("../escape.cfg") }, Options{Force: true, SameMachine: true}}, + {"an override", func(g *bundleGame) { g.override("../escape.cfg") }, Options{Schema: testbuilder.Schema(), Force: true}}, + {"an override, in a dry run", func(g *bundleGame) { g.override("../escape.cfg") }, Options{Schema: testbuilder.Schema(), DryRun: true}}, + {"a backup that is kept", func(g *bundleGame) { g.backup("../escape.cfg") }, Options{Schema: testbuilder.Schema(), Force: true, SameMachine: true}}, + {"a file that is installed and kept", func(g *bundleGame) { g.installed("../escape.cfg") }, Options{Schema: testbuilder.Schema(), Force: true, SameMachine: true}}, } { tc := tc t.Run(tc.name, func(t *testing.T) { diff --git a/internal/restore/restore.go b/internal/restore/restore.go index 11fdea6..b86de06 100644 --- a/internal/restore/restore.go +++ b/internal/restore/restore.go @@ -49,6 +49,11 @@ type Options struct { SameMachine bool Game string // "store_id:store_game_id", only set when extracting a game from a full bundle CacheDBPath string + + // Schema is the schema that the database that is restored is brought to, + // and compared with the one that the bundle has. It is not found by the + // restore. + Schema *database.Schema } type Result struct { @@ -88,12 +93,15 @@ func (b *Bundle) Close() { // // The bundle is extracted to a directory of its own in scratchDir, which has to // be there and to have room for all of the bundle (the blobs are in it, and -// that is most of it). It is not found by this function: nothing is made -// anywhere that it was not told. -func OpenAndValidate(ctx context.Context, bundlePath string, scratchDir string) (*Bundle, error) { +// that is most of it), and its database is brought to the schema schema. Neither +// is found by this function: nothing is made anywhere that it was not told. +func OpenAndValidate(ctx context.Context, bundlePath string, scratchDir string, schema *database.Schema) (*Bundle, error) { if scratchDir == "" { return nil, errors.New("no scratch directory to extract the bundle in") } + if schema == nil { + return nil, errors.New("no schema to bring the database of the bundle to") + } // Extract bundle to temp dir tmpDir, err := os.MkdirTemp(scratchDir, "modctl-import-*") @@ -159,7 +167,7 @@ func OpenAndValidate(ctx context.Context, bundlePath string, scratchDir string) // Open bundle DB, migrating it to the current schema first if the bundle // was exported by an older version (this only touches the extracted copy) - bundleDB, err := openBundleDB(ctx, dbPath, database.GooseProvider) + bundleDB, err := openBundleDB(ctx, dbPath, schema.Provider) if err != nil { os.RemoveAll(tmpDir) return nil, fmt.Errorf("open bundle database: %w", err) diff --git a/internal/restore/main_test.go b/internal/restore/schema_test.go similarity index 50% rename from internal/restore/main_test.go rename to internal/restore/schema_test.go index 13874ba..921723a 100644 --- a/internal/restore/main_test.go +++ b/internal/restore/schema_test.go @@ -19,19 +19,36 @@ package restore import ( - "os" + "context" "testing" - "github.com/mfinelli/modctl/internal/database" + "github.com/mfinelli/modctl/dbq" + "github.com/mfinelli/modctl/internal/blobstore" "github.com/mfinelli/modctl/internal/testbuilder" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" ) -// The restores migrate the databases that they make, with the migrations that -// are embedded in the binary, which a test does not have. They are the ones of -// the repository for all of the tests, set before any of them runs: they are -// not set by each test, since that would change something that is global while -// the other tests are using it. -func TestMain(m *testing.M) { - database.Migrations = testbuilder.MigrationsFS() - os.Exit(m.Run()) +func TestFullNeedsASchema(t *testing.T) { + t.Parallel() + + db := testbuilder.SetupDB(t) + + res, err := Full(context.Background(), db, dbq.New(db), blobstore.Store{}, nil, Options{Force: true}, "", "test", quietLogger()) + + require.Error(t, err) + assert.Contains(t, err.Error(), "no schema") + assert.Equal(t, Result{}, res) +} + +func TestGameNeedsASchema(t *testing.T) { + t.Parallel() + + db := testbuilder.SetupDB(t) + + res, err := Game(context.Background(), db, dbq.New(db), blobstore.Store{}, nil, Options{Force: true}, "test", quietLogger()) + + require.Error(t, err) + assert.Contains(t, err.Error(), "no schema") + assert.Equal(t, Result{}, res) } diff --git a/internal/restore/targets_test.go b/internal/restore/targets_test.go index b3350c0..895746b 100644 --- a/internal/restore/targets_test.go +++ b/internal/restore/targets_test.go @@ -129,7 +129,7 @@ func TestGameImportsTargetsDisabled(t *testing.T) { g := targetsOfABundle(t, bundleDB) bundle := testBundle(t, bundleDB, g.game, exporter.ExportKindGame) - res, err := Game(ctx, dest, dbq.New(dest), blobstore.Store{}, bundle, Options{SkipInventory: true}, "test", quietLogger()) + res, err := Game(ctx, dest, dbq.New(dest), blobstore.Store{}, bundle, Options{Schema: testbuilder.Schema(), SkipInventory: true}, "test", quietLogger()) require.NoError(t, err) got := targetStates(t, dest) @@ -175,7 +175,7 @@ func TestFullImportsTargetsDisabledUnlessItIsTheSameMachine(t *testing.T) { _, err := bundleDB.Exec(`PRAGMA wal_checkpoint(TRUNCATE)`) require.NoError(t, err) - res, err := Full(ctx, f.db, dbq.New(f.db), blobstore.Store{}, f.bundle, Options{Force: true, SameMachine: tc.sameMachine}, f.dbPath, "test", quietLogger()) + res, err := Full(ctx, f.db, dbq.New(f.db), blobstore.Store{}, f.bundle, Options{Schema: testbuilder.Schema(), Force: true, SameMachine: tc.sameMachine}, f.dbPath, "test", quietLogger()) require.NoError(t, err) restored, err := sql.Open("sqlite3", databaseDSN(f.dbPath)) diff --git a/internal/testbuilder/testbuilder.go b/internal/testbuilder/testbuilder.go index df7aa29..a272b40 100644 --- a/internal/testbuilder/testbuilder.go +++ b/internal/testbuilder/testbuilder.go @@ -22,7 +22,6 @@ import ( "context" "database/sql" "fmt" - "io/fs" "os" "path/filepath" "runtime" @@ -31,7 +30,6 @@ import ( _ "github.com/mattn/go-sqlite3" "github.com/mfinelli/modctl/internal/database" "github.com/pressly/goose/v3" - goosedb "github.com/pressly/goose/v3/database" "github.com/stretchr/testify/require" ) @@ -78,48 +76,23 @@ func SetupDBFile(t *testing.T) (*sql.DB, string) { func NewProvider(t *testing.T, db *sql.DB) *goose.Provider { t.Helper() - fsys := os.DirFS(findMigrationsDir(t)) - - base, err := goosedb.NewStore(goosedb.DialectSQLite3, "schema_migrations") - require.NoError(t, err, "create goose store") - - p, err := goose.NewProvider(goose.DialectCustom, db, fsys, - goose.WithStore(&database.SqliteStore{Store: base}), - ) + p, err := Schema().Provider(db) require.NoError(t, err, "create goose provider") return p } -// MigrationsFS is what main gives to database.Migrations in the binary: the -// repository, which has its migrations in a "migrations" directory. The -// embedded copy is not there in a test, so a package whose tests need to -// migrate a database sets this once, in its TestMain, before any test runs: -// -// func TestMain(m *testing.M) { -// database.Migrations = testbuilder.MigrationsFS() -// os.Exit(m.Run()) -// } -// -// It is not set by a test for as long as the test runs, since that is a change -// of something that is global, which the tests that run at the same time would -// see. -func MigrationsFS() fs.FS { +// Schema is the schema of the repository: what main makes with the migrations +// that are embedded in the binary, which a test does not have, so it is made +// with the migrations in the directory that they are in. A test gives it to +// what migrates a database, since nothing finds one by itself. +func Schema() *database.Schema { dir, err := migrationsDir() if err != nil { panic(err) } - return os.DirFS(filepath.Dir(dir)) -} - -func findMigrationsDir(t *testing.T) string { - t.Helper() - - dir, err := migrationsDir() - require.NoError(t, err) - - return dir + return database.NewSchema(os.DirFS(filepath.Dir(dir))) } func migrationsDir() (string, error) { diff --git a/main.go b/main.go index fc15521..e601ea9 100644 --- a/main.go +++ b/main.go @@ -34,7 +34,7 @@ var sampleTarGz []byte func main() { cmd.SampleTarGz = sampleTarGz - database.Migrations = migrations + cmd.Schema = database.NewSchema(migrations) if r := cmd.Execute(); r != 0 { os.Exit(r) From 5c0b2542324a766a8f45f136da38be51aee1ae55 Mon Sep 17 00:00:00 2001 From: Mario Finelli Date: Tue, 13 Oct 2026 23:39:27 +0200 Subject: [PATCH 10/14] Make no home directory clearer --- internal/refresh_test.go | 35 +++++++++++++++++------------------ 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/internal/refresh_test.go b/internal/refresh_test.go index 18a96d1..872c375 100644 --- a/internal/refresh_test.go +++ b/internal/refresh_test.go @@ -293,10 +293,9 @@ func writeVDF(t *testing.T, root, content string) { require.NoError(t, os.WriteFile(filepath.Join(dir, "libraryfolders.vdf"), []byte(content), 0o644)) } -// testHome is the home directory of the user in the tests of discovery, which -// is not a directory that is there: what is looked at is in the directories of -// the test, and none of it is under the home. -const testHome = "/home/tester" +// noHome is the home directory in the tests that have no use for one: none of +// them has a "~" to expand, so it is never looked at. +const noHome = "" func TestDiscoverSteamLibrariesIn(t *testing.T) { t.Parallel() @@ -309,7 +308,7 @@ func TestDiscoverSteamLibrariesIn(t *testing.T) { ext := filepath.Join(dir, "games") writeSteamRoot(t, root, root, ext) - got := discoverSteamLibrariesIn([]string{root}, testHome) + got := discoverSteamLibrariesIn([]string{root}, noHome) assert.True(t, got.DidScan) assert.Equal(t, sortedPaths(ext, root), got.Libs, "in order of path") @@ -320,7 +319,7 @@ func TestDiscoverSteamLibrariesIn(t *testing.T) { t.Run("a place that is not a Steam installation is passed over", func(t *testing.T) { t.Parallel() - got := discoverSteamLibrariesIn([]string{filepath.Join(t.TempDir(), "nothing")}, testHome) + got := discoverSteamLibrariesIn([]string{filepath.Join(t.TempDir(), "nothing")}, noHome) assert.False(t, got.DidScan) assert.Empty(t, got.Libs) @@ -330,7 +329,7 @@ func TestDiscoverSteamLibrariesIn(t *testing.T) { t.Run("no places at all", func(t *testing.T) { t.Parallel() - got := discoverSteamLibrariesIn(nil, testHome) + got := discoverSteamLibrariesIn(nil, noHome) assert.False(t, got.DidScan) assert.Empty(t, got.Libs) @@ -347,7 +346,7 @@ func TestDiscoverSteamLibrariesIn(t *testing.T) { writeSteamRoot(t, native, native, shared) writeSteamRoot(t, flatpak, flatpak, shared) - got := discoverSteamLibrariesIn([]string{flatpak, native}, testHome) + got := discoverSteamLibrariesIn([]string{flatpak, native}, noHome) assert.Equal(t, []string{flatpak, native}, got.Roots) assert.Equal(t, sortedPaths(flatpak, native, shared), got.Libs, "a library that both list is there once") @@ -362,7 +361,7 @@ func TestDiscoverSteamLibrariesIn(t *testing.T) { link := filepath.Join(dir, "link-to-steam") require.NoError(t, os.Symlink(root, link)) - got := discoverSteamLibrariesIn([]string{root, link}, testHome) + got := discoverSteamLibrariesIn([]string{root, link}, noHome) assert.Equal(t, []string{root}, got.Roots) assert.Equal(t, []string{root}, got.Libs) @@ -377,7 +376,7 @@ func TestDiscoverSteamLibrariesIn(t *testing.T) { require.NoError(t, os.MkdirAll(ext, 0o755)) writeSteamRoot(t, root, root+"/", ext+"/", filepath.Join(ext, "..", "games")) - got := discoverSteamLibrariesIn([]string{root}, testHome) + got := discoverSteamLibrariesIn([]string{root}, noHome) assert.Equal(t, sortedPaths(ext, root), got.Libs) }) @@ -388,7 +387,7 @@ func TestDiscoverSteamLibrariesIn(t *testing.T) { root := filepath.Join(t.TempDir(), "Steam") writeVDF(t, root, "not vdf at all <<<>>>") - got := discoverSteamLibrariesIn([]string{root}, testHome) + got := discoverSteamLibrariesIn([]string{root}, noHome) assert.False(t, got.DidScan) assert.Empty(t, got.Roots) @@ -402,7 +401,7 @@ func TestDiscoverSteamLibrariesIn(t *testing.T) { root := filepath.Join(t.TempDir(), "Steam") writeVDF(t, root, "\"libraryfolders\"\n{\n}\n") - got := discoverSteamLibrariesIn([]string{root}, testHome) + got := discoverSteamLibrariesIn([]string{root}, noHome) assert.True(t, got.DidScan) assert.Empty(t, got.Libs) @@ -421,7 +420,7 @@ func TestDiscoverSteamLibrariesIn(t *testing.T) { last := filepath.Join(dir, "Z-games") writeSteamRoot(t, root, root, last, first) - found := discoverSteamLibrariesIn([]string{root}, testHome) + found := discoverSteamLibrariesIn([]string{root}, noHome) got := assignSteamInstanceIDs(found.Libs, found.Roots, nil, nil) assert.Equal(t, map[string]string{ @@ -1095,7 +1094,7 @@ func TestDiscoverSteamLibrariesInContentIDs(t *testing.T) { steamLibraryEntry{Path: ext, ContentID: "-222"}, steamLibraryEntry{Path: bare}) - got := discoverSteamLibrariesIn([]string{root}, testHome) + got := discoverSteamLibrariesIn([]string{root}, noHome) assert.Equal(t, map[string]string{root: "111", ext: "-222"}, got.ContentIDs) assert.Equal(t, sortedPaths(root, ext, bare), got.Libs) @@ -1109,7 +1108,7 @@ func TestDiscoverSteamLibrariesInContentIDs(t *testing.T) { root := filepath.Join(dir, "Steam") writeSteamRoot(t, root, root) - got := discoverSteamLibrariesIn([]string{root}, testHome) + got := discoverSteamLibrariesIn([]string{root}, noHome) assert.Empty(t, got.ContentIDs) }) @@ -1128,7 +1127,7 @@ func TestDiscoverSteamLibrariesInContentIDs(t *testing.T) { steamLibraryEntry{Path: flatpak, ContentID: "2"}, steamLibraryEntry{Path: shared, ContentID: "9"}) - got := discoverSteamLibrariesIn([]string{native, flatpak}, testHome) + got := discoverSteamLibrariesIn([]string{native, flatpak}, noHome) assert.Equal(t, map[string]string{native: "1", flatpak: "2", shared: "9"}, got.ContentIDs) assert.Empty(t, got.Warnings, "it is one library, not two that look alike") @@ -1144,7 +1143,7 @@ func TestDiscoverSteamLibrariesInContentIDs(t *testing.T) { writeSteamRootWithIDs(t, native, steamLibraryEntry{Path: shared, ContentID: "9"}) writeSteamRootWithIDs(t, flatpak, steamLibraryEntry{Path: shared, ContentID: "10"}) - got := discoverSteamLibrariesIn([]string{native, flatpak}, testHome) + got := discoverSteamLibrariesIn([]string{native, flatpak}, noHome) assert.Equal(t, "9", got.ContentIDs[shared]) require.Len(t, got.Warnings, 1) @@ -1164,7 +1163,7 @@ func TestDiscoverSteamLibrariesInContentIDs(t *testing.T) { steamLibraryEntry{Path: b, ContentID: "77"}, steamLibraryEntry{Path: a, ContentID: "77"}) - got := discoverSteamLibrariesIn([]string{root}, testHome) + got := discoverSteamLibrariesIn([]string{root}, noHome) assert.Equal(t, "77", got.ContentIDs[a]) assert.Equal(t, "77", got.ContentIDs[b]) From 48c94cc030ff28d57176a6324c7d8d146f9ef1b4 Mon Sep 17 00:00:00 2001 From: Mario Finelli Date: Wed, 14 Oct 2026 00:21:49 +0200 Subject: [PATCH 11/14] Add and use new configuration package --- cmd/apply.go | 15 +- cmd/auth_nexus_login.go | 37 +- cmd/auth_nexus_logout.go | 40 +- cmd/auth_nexus_status.go | 3 +- cmd/config.go | 23 - cmd/config_get.go | 15 +- cmd/config_list.go | 28 +- cmd/config_set.go | 82 +-- cmd/doctor.go | 25 +- cmd/env.go | 35 +- cmd/export.go | 7 +- cmd/extract.go | 3 +- cmd/games_backups_diff.go | 7 +- cmd/games_backups_restore.go | 7 +- cmd/games_backups_view.go | 7 +- cmd/games_targets_disable.go | 3 +- cmd/gc.go | 7 +- cmd/import.go | 9 +- cmd/init.go | 17 +- cmd/mods_import.go | 15 +- cmd/mods_nexus_checkUpdates.go | 3 +- cmd/mods_nexus_link.go | 3 +- cmd/mods_scanInventory.go | 5 +- cmd/profiles_overrides_edit.go | 13 +- cmd/profiles_overrides_patch_preview.go | 13 +- cmd/profiles_overrides_set.go | 9 +- cmd/profiles_preview.go | 13 +- cmd/root.go | 66 +- cmd/unapply.go | 13 +- internal/config/config.go | 380 ++++++++++++ internal/config/config_test.go | 784 ++++++++++++++++++++++++ 31 files changed, 1324 insertions(+), 363 deletions(-) create mode 100644 internal/config/config.go create mode 100644 internal/config/config_test.go diff --git a/cmd/apply.go b/cmd/apply.go index 142306b..531661a 100644 --- a/cmd/apply.go +++ b/cmd/apply.go @@ -37,7 +37,6 @@ import ( "github.com/mfinelli/modctl/internal/planner" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var ( @@ -125,7 +124,7 @@ Use --dry-run to preview the plan without making any changes. Add the } // Acquire per-game lock to prevent concurrent apply/unapply - unlock, err := lock.GameInstall(viper.GetString("locks_dir"), gi.ID) + unlock, err := lock.GameInstall(cfg.LocksDir, gi.ID) if err != nil { return err } @@ -180,16 +179,16 @@ Use --dry-run to preview the plan without making any changes. Add the style.Println() bs := blobstore.Store{ - ArchivesDir: viper.GetString("archives_dir"), - BackupsDir: viper.GetString("backups_dir"), - OverridesDir: viper.GetString("overrides_dir"), - TmpDir: viper.GetString("tmp_dir"), + ArchivesDir: cfg.ArchivesDir, + BackupsDir: cfg.BackupsDir, + OverridesDir: cfg.OverridesDir, + TmpDir: cfg.TmpDir, } ext := extractor.Extractor{ - BsdtarPath: viper.GetString("bsdtar"), + BsdtarPath: cfg.Bsdtar, BlobStore: bs, - StagingDir: viper.GetString("tmp_dir"), + StagingDir: cfg.TmpDir, } // Clear staging before starting. diff --git a/cmd/auth_nexus_login.go b/cmd/auth_nexus_login.go index d39987f..d830dc7 100644 --- a/cmd/auth_nexus_login.go +++ b/cmd/auth_nexus_login.go @@ -22,14 +22,11 @@ import ( "context" "fmt" "os" - "path/filepath" - "github.com/adrg/xdg" + "github.com/mfinelli/modctl/internal/config" "github.com/mfinelli/modctl/internal/nexusclient" "github.com/mfinelli/modctl/internal/style" - "github.com/pelletier/go-toml/v2" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var authNexusLoginForce bool @@ -49,7 +46,7 @@ you complete authorization.`, Args: cobra.ExactArgs(0), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - if viper.GetString("nexus.apikey") != "" && !authNexusLoginForce { + if cfg.NexusAPIKey != "" && !authNexusLoginForce { style.Println(style.Failure.Render(" ✗ a Nexus Mods API key is already configured")) style.Println(style.Subtle.Render(" run with --force to replace it")) style.Println() @@ -70,36 +67,14 @@ you complete authorization.`, return fmt.Errorf("nexus SSO login: %w", err) } - configPath := viper.ConfigFileUsed() - if configPath == "" { - configPath = filepath.Join(xdg.ConfigHome, "modctl", "config.toml") - } - if err := ensureConfigFile(configPath); err != nil { - return fmt.Errorf("create config file: %w", err) - } - cfg, err := readOrCreateToml(configPath) - if err != nil { - return fmt.Errorf("read config file: %w", err) - } - - nexusCfg, ok := cfg["nexus"].(map[string]any) - if !ok { - nexusCfg = make(map[string]any) - } - nexusCfg["apikey"] = apiKey - cfg["nexus"] = nexusCfg - - data, err := toml.Marshal(cfg) - if err != nil { - return fmt.Errorf("marshal config: %w", err) - } - if err := os.WriteFile(configPath, data, 0o600); err != nil { - return fmt.Errorf("write config file: %w", err) + path := configPath() + if err := config.Set(path, "nexus.apikey", apiKey); err != nil { + return err } style.Println() style.Println(style.Success.Render(" ✓ authenticated with Nexus Mods")) - style.Println(style.Subtle.Render(" API key saved to " + configPath)) + style.Println(style.Subtle.Render(" API key saved to " + path)) style.Println(style.Subtle.Render(" Note: the key is stored in plain text (mode 0600)")) style.Println() diff --git a/cmd/auth_nexus_logout.go b/cmd/auth_nexus_logout.go index c507607..36010eb 100644 --- a/cmd/auth_nexus_logout.go +++ b/cmd/auth_nexus_logout.go @@ -19,15 +19,9 @@ package cmd import ( - "fmt" - "os" - "path/filepath" - - "github.com/adrg/xdg" + "github.com/mfinelli/modctl/internal/config" "github.com/mfinelli/modctl/internal/style" - "github.com/pelletier/go-toml/v2" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var authNexusLogoutCmd = &cobra.Command{ @@ -43,40 +37,14 @@ visit your Nexus Mods account settings at: Args: cobra.ExactArgs(0), SilenceUsage: true, RunE: func(cmd *cobra.Command, args []string) error { - if viper.GetString("nexus.apikey") == "" { + if cfg.NexusAPIKey == "" { style.Println(style.Warning.Render(" ⚠ no Nexus Mods API key is configured")) style.Println() return nil } - configPath := viper.ConfigFileUsed() - if configPath == "" { - configPath = filepath.Join(xdg.ConfigHome, "modctl", "config.toml") - } - if err := ensureConfigFile(configPath); err != nil { - return fmt.Errorf("create config file: %w", err) - } - cfg, err := readOrCreateToml(configPath) - if err != nil { - return fmt.Errorf("read config file: %w", err) - } - - if nexusCfg, ok := cfg["nexus"].(map[string]any); ok { - delete(nexusCfg, "apikey") - if len(nexusCfg) == 0 { - delete(cfg, "nexus") - } else { - cfg["nexus"] = nexusCfg - } - } - - data, err := toml.Marshal(cfg) - if err != nil { - return fmt.Errorf("marshal config: %w", err) - } - - if err := os.WriteFile(configPath, data, 0o600); err != nil { - return fmt.Errorf("write config file: %w", err) + if err := config.Unset(configPath(), "nexus.apikey"); err != nil { + return err } style.Println(style.Success.Render(" ✓ Nexus Mods API key removed")) diff --git a/cmd/auth_nexus_status.go b/cmd/auth_nexus_status.go index 60ef227..b86b32f 100644 --- a/cmd/auth_nexus_status.go +++ b/cmd/auth_nexus_status.go @@ -25,7 +25,6 @@ import ( "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var authNexusStatusCmd = &cobra.Command{ @@ -41,7 +40,7 @@ not count against your API request quota.`, RunE: func(cmd *cobra.Command, args []string) error { kv := style.KV{Indent: 2, Width: 16} - apiKey := viper.GetString("nexus.apikey") + apiKey := cfg.NexusAPIKey if apiKey == "" { style.Println(style.Warning.Render(" ⚠ not authenticated")) style.Println(style.Subtle.Render(" run `modctl auth nexus login` to authenticate")) diff --git a/cmd/config.go b/cmd/config.go index a3d4a7c..e1a3c9f 100644 --- a/cmd/config.go +++ b/cmd/config.go @@ -20,7 +20,6 @@ package cmd import ( "github.com/spf13/cobra" - "github.com/spf13/viper" ) var configCmd = &cobra.Command{ @@ -31,25 +30,3 @@ var configCmd = &cobra.Command{ func init() { rootCmd.AddCommand(configCmd) } - -var knownConfigKeys = []string{ - "bsdtar", - "database", - "archives_dir", - "backups_dir", - "cache_dir", - "locks_dir", - "overrides_dir", - "tmp_dir", - "nexus.apikey", -} - -// resolveConfigKey returns the effective value for a config key and whether -// it was explicitly set in the config file (as opposed to being a default). -func resolveConfigKey(key string) (value string, fromFile bool) { - value = viper.GetString(key) - // viper.IsSet returns true for defaults too, so we check the - // in-file settings directly - fromFile = viper.InConfig(key) - return value, fromFile -} diff --git a/cmd/config_get.go b/cmd/config_get.go index 89ec005..5236189 100644 --- a/cmd/config_get.go +++ b/cmd/config_get.go @@ -19,10 +19,7 @@ package cmd import ( - "fmt" - "slices" - "strings" - + "github.com/mfinelli/modctl/internal/config" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" ) @@ -40,18 +37,18 @@ Valid keys: locks_dir, tmp_dir, nexus.apikey`, Args: cobra.ExactArgs(1), SilenceUsage: true, - ValidArgs: knownConfigKeys, + ValidArgs: config.Keys(), RunE: func(cmd *cobra.Command, args []string) error { kv := style.KV{Indent: 2, Width: 16} key := args[0] - if !slices.Contains(knownConfigKeys, key) { - return fmt.Errorf("unknown config key %q\n valid keys: %s", key, strings.Join(knownConfigKeys, ", ")) + value, ok := cfg.Value(key) + if !ok { + return config.UnknownKeyError{Key: key} } - value, fromFile := resolveConfigKey(key) origin := style.Subtle.Render("(default)") - if fromFile { + if cfg.FromFile(key) { origin = style.Subtle.Render("(set)") } diff --git a/cmd/config_list.go b/cmd/config_list.go index 5a46a9a..141a394 100644 --- a/cmd/config_list.go +++ b/cmd/config_list.go @@ -19,13 +19,9 @@ package cmd import ( - "path/filepath" - "strings" - - "github.com/adrg/xdg" + "github.com/mfinelli/modctl/internal/config" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var configListCmd = &cobra.Command{ @@ -41,25 +37,20 @@ default value.`, RunE: func(cmd *cobra.Command, args []string) error { kv := style.KV{Indent: 2, Width: 16} - configPath := viper.ConfigFileUsed() - if configPath == "" { - configPath = filepath.Join(xdg.ConfigHome, "modctl", "config.toml") - } - style.Println(style.Header.Render("Configuration")) - style.Println(style.Subtle.Render(" file: " + configPath)) + style.Println(style.Subtle.Render(" file: " + configPath())) style.Println() - for _, key := range knownConfigKeys { - value, fromFile := resolveConfigKey(key) + for _, key := range config.Keys() { + value, _ := cfg.Value(key) // mask the api key if key == "nexus.apikey" && value != "" { - value = maskAPIKey(value) + value = config.Mask(value) } origin := style.Subtle.Render("(default)") - if fromFile { + if cfg.FromFile(key) { origin = style.Subtle.Render("(set)") } @@ -73,10 +64,3 @@ default value.`, func init() { configCmd.AddCommand(configListCmd) } - -func maskAPIKey(key string) string { - if len(key) <= 4 { - return strings.Repeat("*", len(key)) - } - return strings.Repeat("*", len(key)-4) + key[len(key)-4:] -} diff --git a/cmd/config_set.go b/cmd/config_set.go index 66fe8ac..09592a4 100644 --- a/cmd/config_set.go +++ b/cmd/config_set.go @@ -19,18 +19,11 @@ package cmd import ( - "errors" "fmt" - "os" - "path/filepath" - "slices" - "strings" - "github.com/adrg/xdg" + "github.com/mfinelli/modctl/internal/config" "github.com/mfinelli/modctl/internal/style" - "github.com/pelletier/go-toml/v2" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var configSetCmd = &cobra.Command{ @@ -49,15 +42,15 @@ Valid keys: SilenceUsage: true, ValidArgsFunction: func(cmd *cobra.Command, args []string, toComplete string) ([]string, cobra.ShellCompDirective) { if len(args) == 0 { - return knownConfigKeys, cobra.ShellCompDirectiveNoFileComp + return config.Keys(), cobra.ShellCompDirectiveNoFileComp } // second arg is the value; no completions return nil, cobra.ShellCompDirectiveNoFileComp }, RunE: func(cmd *cobra.Command, args []string) error { key, value := args[0], args[1] - if !slices.Contains(knownConfigKeys, key) { - return fmt.Errorf("unknown config key %q\n valid keys: %s", key, strings.Join(knownConfigKeys, ", ")) + if _, ok := cfg.Value(key); !ok { + return config.UnknownKeyError{Key: key} } if key == "nexus.apikey" { @@ -65,42 +58,12 @@ Valid keys: style.Println() } - configPath := viper.ConfigFileUsed() - if configPath == "" { - // config file doesn't exist yet; use the default location - configPath = filepath.Join(xdg.ConfigHome, "modctl", "config.toml") + path := configPath() + if err := config.Set(path, key, value); err != nil { + return err } - if err := ensureConfigFile(configPath); err != nil { - return fmt.Errorf("create config file: %w", err) - } - - cfg, err := readOrCreateToml(configPath) - if err != nil { - return fmt.Errorf("read config file: %w", err) - } - - if key == "nexus.apikey" { - nexus, ok := cfg["nexus"].(map[string]any) - if !ok { - nexus = make(map[string]any) - } - nexus["apikey"] = value - cfg["nexus"] = nexus - } else { - cfg[key] = value - } - - data, err := toml.Marshal(cfg) - if err != nil { - return fmt.Errorf("marshal config: %w", err) - } - - if err := os.WriteFile(configPath, data, 0o600); err != nil { - return fmt.Errorf("write config file: %w", err) - } - - style.Println(style.Success.Render(fmt.Sprintf(" ✓ %s set in %s", key, configPath))) + style.Println(style.Success.Render(fmt.Sprintf(" ✓ %s set in %s", key, path))) return nil }, @@ -109,32 +72,3 @@ Valid keys: func init() { configCmd.AddCommand(configSetCmd) } - -func ensureConfigFile(path string) error { - if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { - return err - } - f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL, 0o600) - if err != nil { - if errors.Is(err, os.ErrExist) { - return nil - } - return err - } - return f.Close() -} - -func readOrCreateToml(path string) (map[string]any, error) { - data, err := os.ReadFile(path) - if err != nil { - return nil, err - } - out := make(map[string]any) - if len(strings.TrimSpace(string(data))) == 0 { - return out, nil - } - if err := toml.Unmarshal(data, &out); err != nil { - return nil, err - } - return out, nil -} diff --git a/cmd/doctor.go b/cmd/doctor.go index 0b58f6e..9234eee 100644 --- a/cmd/doctor.go +++ b/cmd/doctor.go @@ -37,7 +37,6 @@ import ( "github.com/mfinelli/modctl/internal/fsutil" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" - "github.com/spf13/viper" "go.finelli.dev/util" ) @@ -296,12 +295,12 @@ func checkPaths() error { style.Println() required := []string{ - viper.GetString("archives_dir"), - viper.GetString("backups_dir"), + cfg.ArchivesDir, + cfg.BackupsDir, cacheDir(), - viper.GetString("locks_dir"), - viper.GetString("overrides_dir"), - // viper.GetString("tmp_dir"), + cfg.LocksDir, + cfg.OverridesDir, + // cfg.TmpDir, } var fatalErr error @@ -339,7 +338,7 @@ func checkPaths() error { } func checkBsdtar(ctx context.Context) error { - bsdtar := viper.GetString("bsdtar") + bsdtar := cfg.Bsdtar style.Println(style.Header.Render("bsdtar Checks")) style.Println(style.Subtle.Render(" search: " + bsdtar)) style.Println() @@ -490,9 +489,9 @@ func checkGameInstalls(ctx context.Context) error { // add a second pass later to stream-hash and update verified_at. func checkBlobs(ctx context.Context) error { style.Println(style.Header.Render("Blob Store Checks")) - style.Println(style.Subtle.Render(" archives: " + viper.GetString("archives_dir"))) - style.Println(style.Subtle.Render(" backups: " + viper.GetString("backups_dir"))) - style.Println(style.Subtle.Render(" overrides: " + viper.GetString("overrides_dir"))) + style.Println(style.Subtle.Render(" archives: " + cfg.ArchivesDir)) + style.Println(style.Subtle.Render(" backups: " + cfg.BackupsDir)) + style.Println(style.Subtle.Render(" overrides: " + cfg.OverridesDir)) style.Println() db, err := database.Setup(databasePath()) @@ -507,9 +506,9 @@ func checkBlobs(ctx context.Context) error { q := dbq.New(db) bs := blobstore.Store{ - ArchivesDir: viper.GetString("archives_dir"), - BackupsDir: viper.GetString("backups_dir"), - OverridesDir: viper.GetString("overrides_dir"), + ArchivesDir: cfg.ArchivesDir, + BackupsDir: cfg.BackupsDir, + OverridesDir: cfg.OverridesDir, } kinds := []blobstore.Kind{ diff --git a/cmd/env.go b/cmd/env.go index 8954ef7..5d56629 100644 --- a/cmd/env.go +++ b/cmd/env.go @@ -23,16 +23,15 @@ import ( "database/sql" "log/slog" "os" - "path/filepath" "github.com/adrg/xdg" "github.com/mfinelli/modctl/dbq" "github.com/mfinelli/modctl/internal" "github.com/mfinelli/modctl/internal/argresolver" "github.com/mfinelli/modctl/internal/completion" + "github.com/mfinelli/modctl/internal/config" "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/nexusclient" - "github.com/spf13/viper" ) // Schema is the schema of the database, which main makes with the migrations @@ -41,26 +40,52 @@ import ( // a database brings it to. var Schema *database.Schema +// cfg is the configuration, which is read once, before a command runs (see +// initConfig). It is where what the commands are configured to comes from: what +// the config file says, and the defaults for the rest. +var cfg config.Config + // The packages in internal/ are given what they work on: they don't look for // the database, or the directory of the state, by themselves. This is where the // commands get it from, for what they do all the time, so that a command // doesn't have to know where it is (and can't have it wrong). +// configDirs are the directories of the user that the defaults of the +// configuration are in. +func configDirs() config.Dirs { + return config.Dirs{ + ConfigHome: xdg.ConfigHome, + DataHome: xdg.DataHome, + CacheHome: xdg.CacheHome, + StateHome: xdg.StateHome, + RuntimeDir: xdg.RuntimeDir, + } +} + +// configPath is the config file that a command that changes it changes: the one +// that was read, or the default one if there was none. +func configPath() string { + if cfg.File != "" { + return cfg.File + } + return config.DefaultPath(configDirs()) +} + // databasePath is the file of the database that is configured. func databasePath() string { - return viper.GetString("database") + return cfg.Database } // stateDir is the directory that modctl keeps its state in, which is the // active selection and what has to be there while something is being done (the // locks, in a directory of their own). func stateDir() string { - return filepath.Join(xdg.StateHome, "modctl") + return configDirs().State() } // cacheDir is the directory that the cache of what Nexus Mods said is kept in. func cacheDir() string { - return viper.GetString("cache_dir") + return cfg.CacheDir } // nexusCacheDBPath is the file of the cache of what Nexus Mods said. diff --git a/cmd/export.go b/cmd/export.go index 2de0613..9a41dbe 100644 --- a/cmd/export.go +++ b/cmd/export.go @@ -29,7 +29,6 @@ import ( "github.com/mfinelli/modctl/internal/exporter" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var ( @@ -71,9 +70,9 @@ Examples: q := dbq.New(db) bs := blobstore.Store{ - ArchivesDir: viper.GetString("archives_dir"), - BackupsDir: viper.GetString("backups_dir"), - OverridesDir: viper.GetString("overrides_dir"), + ArchivesDir: cfg.ArchivesDir, + BackupsDir: cfg.BackupsDir, + OverridesDir: cfg.OverridesDir, } opts := exporter.Options{ diff --git a/cmd/extract.go b/cmd/extract.go index 703570a..b1b1690 100644 --- a/cmd/extract.go +++ b/cmd/extract.go @@ -31,7 +31,6 @@ import ( "github.com/mfinelli/modctl/internal/restore" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var ( @@ -476,7 +475,7 @@ func resolveOutputName( // determine the archive format and compression, returning a file extension. func detectArchiveExt(ctx context.Context, blobPath string) (string, error) { format, compression, err := archivescanner.DetectFormat( - ctx, viper.GetString("bsdtar"), blobPath, + ctx, cfg.Bsdtar, blobPath, ) if err != nil { return "", err diff --git a/cmd/games_backups_diff.go b/cmd/games_backups_diff.go index 51c1867..7f9a5fe 100644 --- a/cmd/games_backups_diff.go +++ b/cmd/games_backups_diff.go @@ -32,7 +32,6 @@ import ( "github.com/mfinelli/modctl/internal/style" "github.com/pmezard/go-difflib/difflib" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var ( @@ -101,9 +100,9 @@ is passed.`, } bs := blobstore.Store{ - ArchivesDir: viper.GetString("archives_dir"), - BackupsDir: viper.GetString("backups_dir"), - TmpDir: viper.GetString("tmp_dir"), + ArchivesDir: cfg.ArchivesDir, + BackupsDir: cfg.BackupsDir, + TmpDir: cfg.TmpDir, } blobPath, err := bs.PathFor(blobstore.KindBackup, backup.BackupBlobSha256) diff --git a/cmd/games_backups_restore.go b/cmd/games_backups_restore.go index f1fc1a2..18153bb 100644 --- a/cmd/games_backups_restore.go +++ b/cmd/games_backups_restore.go @@ -30,7 +30,6 @@ import ( "github.com/mfinelli/modctl/internal/planner" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var ( @@ -100,9 +99,9 @@ that can't be read to check it.`, } bs := blobstore.Store{ - ArchivesDir: viper.GetString("archives_dir"), - BackupsDir: viper.GetString("backups_dir"), - TmpDir: viper.GetString("tmp_dir"), + ArchivesDir: cfg.ArchivesDir, + BackupsDir: cfg.BackupsDir, + TmpDir: cfg.TmpDir, } blobPath, err := bs.PathFor(blobstore.KindBackup, backup.BackupBlobSha256) diff --git a/cmd/games_backups_view.go b/cmd/games_backups_view.go index 785f50a..7123d9d 100644 --- a/cmd/games_backups_view.go +++ b/cmd/games_backups_view.go @@ -28,7 +28,6 @@ import ( "github.com/mfinelli/modctl/internal/blobstore" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var ( @@ -91,9 +90,9 @@ Binary files are detected automatically and refused unless --force is passed.`, } bs := blobstore.Store{ - ArchivesDir: viper.GetString("archives_dir"), - BackupsDir: viper.GetString("backups_dir"), - TmpDir: viper.GetString("tmp_dir"), + ArchivesDir: cfg.ArchivesDir, + BackupsDir: cfg.BackupsDir, + TmpDir: cfg.TmpDir, } blobPath, err := bs.PathFor(blobstore.KindBackup, backup.BackupBlobSha256) diff --git a/cmd/games_targets_disable.go b/cmd/games_targets_disable.go index dab851b..df732c0 100644 --- a/cmd/games_targets_disable.go +++ b/cmd/games_targets_disable.go @@ -28,7 +28,6 @@ import ( "github.com/mfinelli/modctl/internal/lock" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var gamesTargetsDisableGame string @@ -73,7 +72,7 @@ the only enabled target.`, // Hold the per-game lock so an apply can't record installed files for // this target between the checks below and the update. - unlock, err := lock.GameInstall(viper.GetString("locks_dir"), gi.ID) + unlock, err := lock.GameInstall(cfg.LocksDir, gi.ID) if err != nil { return err } diff --git a/cmd/gc.go b/cmd/gc.go index 4c125b7..55fcff1 100644 --- a/cmd/gc.go +++ b/cmd/gc.go @@ -33,7 +33,6 @@ import ( "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var ( @@ -91,9 +90,9 @@ Use --dry-run to preview what would be removed without making any changes.`, } bs := blobstore.Store{ - ArchivesDir: viper.GetString("archives_dir"), - BackupsDir: viper.GetString("backups_dir"), - OverridesDir: viper.GetString("overrides_dir"), + ArchivesDir: cfg.ArchivesDir, + BackupsDir: cfg.BackupsDir, + OverridesDir: cfg.OverridesDir, } q := dbq.New(db) diff --git a/cmd/import.go b/cmd/import.go index fa9ebb7..36d03ca 100644 --- a/cmd/import.go +++ b/cmd/import.go @@ -29,7 +29,6 @@ import ( "github.com/mfinelli/modctl/internal/restore" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" - "github.com/spf13/viper" "golang.org/x/mod/semver" ) @@ -120,10 +119,10 @@ Use --dry-run to preview what would be imported without making any changes.`, q := dbq.New(db) bs := blobstore.Store{ - ArchivesDir: viper.GetString("archives_dir"), - BackupsDir: viper.GetString("backups_dir"), - OverridesDir: viper.GetString("overrides_dir"), - TmpDir: viper.GetString("tmp_dir"), + ArchivesDir: cfg.ArchivesDir, + BackupsDir: cfg.BackupsDir, + OverridesDir: cfg.OverridesDir, + TmpDir: cfg.TmpDir, } opts := restore.Options{ diff --git a/cmd/init.go b/cmd/init.go index 9459506..e9ee2f4 100644 --- a/cmd/init.go +++ b/cmd/init.go @@ -21,9 +21,9 @@ package cmd import ( "fmt" "os" + "path/filepath" "github.com/spf13/cobra" - "github.com/spf13/viper" "github.com/mfinelli/modctl/internal/database" ) @@ -42,12 +42,12 @@ multiple times and will not overwrite existing data.`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - err := os.MkdirAll(viper.GetString("archives_dir"), 0o0755) + err := os.MkdirAll(cfg.ArchivesDir, 0o0755) if err != nil { return fmt.Errorf("error creating archives directory: %w", err) } - err = os.MkdirAll(viper.GetString("backups_dir"), 0o0755) + err = os.MkdirAll(cfg.BackupsDir, 0o0755) if err != nil { return fmt.Errorf("error creating backups directory: %w", err) } @@ -57,21 +57,26 @@ multiple times and will not overwrite existing data.`, return fmt.Errorf("error creating cache directory: %w", err) } - err = os.MkdirAll(viper.GetString("locks_dir"), 0o0755) + err = os.MkdirAll(cfg.LocksDir, 0o0755) if err != nil { return fmt.Errorf("error creating locks directory: %w", err) } - err = os.MkdirAll(viper.GetString("overrides_dir"), 0o0755) + err = os.MkdirAll(cfg.OverridesDir, 0o0755) if err != nil { return fmt.Errorf("error creating overrides directory: %w", err) } - err = os.MkdirAll(viper.GetString("tmp_dir"), 0o0755) + err = os.MkdirAll(cfg.TmpDir, 0o0755) if err != nil { return fmt.Errorf("error creating tmp directory: %w", err) } + err = os.MkdirAll(filepath.Dir(databasePath()), 0o0755) + if err != nil { + return fmt.Errorf("error creating database directory: %w", err) + } + db, err := database.Setup(databasePath()) if err != nil { return fmt.Errorf("error opening database: %w", err) diff --git a/cmd/mods_import.go b/cmd/mods_import.go index 9e82022..ac4011b 100644 --- a/cmd/mods_import.go +++ b/cmd/mods_import.go @@ -42,7 +42,6 @@ import ( "github.com/mfinelli/modctl/internal/nexusclient" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var ( @@ -98,12 +97,12 @@ has been safely stored and the database has been updated successfully.`, defer db.Close() inputPath := args[0] - archivesDir := viper.GetString("archives_dir") + archivesDir := cfg.ArchivesDir bs := blobstore.Store{ ArchivesDir: archivesDir, - BackupsDir: viper.GetString("backups_dir"), - OverridesDir: viper.GetString("overrides_dir"), + BackupsDir: cfg.BackupsDir, + OverridesDir: cfg.OverridesDir, } // Optional nexus parse @@ -183,7 +182,7 @@ has been safely stored and the database has been updated successfully.`, // If we have a nexus URL and an API key, pre-fetch the file list to identify // the correct label before creating the mod_file row if modsImportNexusUrl != "" && !modsImportSkipNexusLink { - apiKey := viper.GetString("nexus.apikey") + apiKey := cfg.NexusAPIKey if apiKey != "" { client, err := nexusClient(ctx, apiKey, logger) if err != nil { @@ -236,7 +235,7 @@ has been safely stored and the database has been updated successfully.`, // Attempt Nexus linking if we have a URL, an API key, and --skip-nexus-link was not set if modsImportNexusUrl != "" && !modsImportSkipNexusLink { - apiKey := viper.GetString("nexus.apikey") + apiKey := cfg.NexusAPIKey if apiKey == "" { style.Println(style.Subtle.Render(" nexus api key not configured, skipping link")) } else { @@ -329,7 +328,7 @@ func prepareImportArchive(ctx context.Context, inputPath string, listTimeout tim } // Not an archive (or bsdtar couldn't list it) -- wrap into tar.gz. - tmpDir := viper.GetString("tmp_dir") + tmpDir := cfg.TmpDir wrapped, cleanup, err := wrapIntoTarGz(tmpDir, inputPath) if err != nil { return prepareArchiveResult{}, err @@ -348,7 +347,7 @@ func prepareImportArchive(ctx context.Context, inputPath string, listTimeout tim func bsdtarListOK(ctx context.Context, archivePath string) error { // Keep output quiet on success; capture stderr for failure message. - cmd := exec.CommandContext(ctx, viper.GetString("bsdtar"), "-t", "-f", archivePath) + cmd := exec.CommandContext(ctx, cfg.Bsdtar, "-t", "-f", archivePath) var stderr bytes.Buffer cmd.Stdout = io.Discard cmd.Stderr = &stderr diff --git a/cmd/mods_nexus_checkUpdates.go b/cmd/mods_nexus_checkUpdates.go index c329bb9..c180a5d 100644 --- a/cmd/mods_nexus_checkUpdates.go +++ b/cmd/mods_nexus_checkUpdates.go @@ -33,7 +33,6 @@ import ( "github.com/mfinelli/modctl/internal/style" "github.com/mfinelli/modctl/internal/updatechain" "github.com/spf13/cobra" - "github.com/spf13/viper" "golang.org/x/term" ) @@ -67,7 +66,7 @@ to proceed even if the operation would exhaust your API quota.`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - apiKey := viper.GetString("nexus.apikey") + apiKey := cfg.NexusAPIKey if apiKey == "" { return fmt.Errorf("nexus api key not configured; set nexus.apikey in your config file") } diff --git a/cmd/mods_nexus_link.go b/cmd/mods_nexus_link.go index ebd7caf..5835e8c 100644 --- a/cmd/mods_nexus_link.go +++ b/cmd/mods_nexus_link.go @@ -31,7 +31,6 @@ import ( "github.com/mfinelli/modctl/internal/nexusclient" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" - "github.com/spf13/viper" "go.finelli.dev/util" ) @@ -58,7 +57,7 @@ version for manual linking.`, RunE: func(cmd *cobra.Command, args []string) error { ctx := cmd.Context() - apiKey := viper.GetString("nexus.apikey") + apiKey := cfg.NexusAPIKey if apiKey == "" { return fmt.Errorf("nexus api key not configured; set nexus.apikey in your config file") } diff --git a/cmd/mods_scanInventory.go b/cmd/mods_scanInventory.go index 57c095c..9ac1cd6 100644 --- a/cmd/mods_scanInventory.go +++ b/cmd/mods_scanInventory.go @@ -26,7 +26,6 @@ import ( "github.com/mfinelli/modctl/internal/blobstore" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var modsScanInventoryRescan bool @@ -62,11 +61,11 @@ its existing inventory.`, q := dbq.New(db) bs := blobstore.Store{ - ArchivesDir: viper.GetString("archives_dir"), + ArchivesDir: cfg.ArchivesDir, } scanner := archivescanner.Scanner{ - BsdtarPath: viper.GetString("bsdtar"), + BsdtarPath: cfg.Bsdtar, } scanAll := archivescanner.ScanAll diff --git a/cmd/profiles_overrides_edit.go b/cmd/profiles_overrides_edit.go index 04050bf..2e75c1e 100644 --- a/cmd/profiles_overrides_edit.go +++ b/cmd/profiles_overrides_edit.go @@ -33,7 +33,6 @@ import ( "github.com/mfinelli/modctl/internal/extractor" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var ( @@ -101,16 +100,16 @@ archives.`, } bs := blobstore.Store{ - ArchivesDir: viper.GetString("archives_dir"), - BackupsDir: viper.GetString("backups_dir"), - OverridesDir: viper.GetString("overrides_dir"), - TmpDir: viper.GetString("tmp_dir"), + ArchivesDir: cfg.ArchivesDir, + BackupsDir: cfg.BackupsDir, + OverridesDir: cfg.OverridesDir, + TmpDir: cfg.TmpDir, } ex := extractor.Extractor{ - BsdtarPath: viper.GetString("bsdtar"), + BsdtarPath: cfg.Bsdtar, BlobStore: bs, - StagingDir: viper.GetString("tmp_dir"), + StagingDir: cfg.TmpDir, } // check for existing override diff --git a/cmd/profiles_overrides_patch_preview.go b/cmd/profiles_overrides_patch_preview.go index 3ff8acb..3d5ace2 100644 --- a/cmd/profiles_overrides_patch_preview.go +++ b/cmd/profiles_overrides_patch_preview.go @@ -33,7 +33,6 @@ import ( "github.com/mfinelli/modctl/internal/style" "github.com/pmezard/go-difflib/difflib" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var ( @@ -130,16 +129,16 @@ Note: requires archive extraction which may be slow for large archives.`, } bs := blobstore.Store{ - ArchivesDir: viper.GetString("archives_dir"), - BackupsDir: viper.GetString("backups_dir"), - OverridesDir: viper.GetString("overrides_dir"), - TmpDir: viper.GetString("tmp_dir"), + ArchivesDir: cfg.ArchivesDir, + BackupsDir: cfg.BackupsDir, + OverridesDir: cfg.OverridesDir, + TmpDir: cfg.TmpDir, } ex := extractor.Extractor{ - BsdtarPath: viper.GetString("bsdtar"), + BsdtarPath: cfg.Bsdtar, BlobStore: bs, - StagingDir: viper.GetString("tmp_dir"), + StagingDir: cfg.TmpDir, } // fetch base file content (extract from winning mod if available) diff --git a/cmd/profiles_overrides_set.go b/cmd/profiles_overrides_set.go index ea1ccca..7df1546 100644 --- a/cmd/profiles_overrides_set.go +++ b/cmd/profiles_overrides_set.go @@ -30,7 +30,6 @@ import ( "github.com/mfinelli/modctl/internal/database" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var ( @@ -106,10 +105,10 @@ winner for this path in the active profile.`, } bs := blobstore.Store{ - ArchivesDir: viper.GetString("archives_dir"), - BackupsDir: viper.GetString("backups_dir"), - OverridesDir: viper.GetString("overrides_dir"), - TmpDir: viper.GetString("tmp_dir"), + ArchivesDir: cfg.ArchivesDir, + BackupsDir: cfg.BackupsDir, + OverridesDir: cfg.OverridesDir, + TmpDir: cfg.TmpDir, } // ingest the file into the override blob store diff --git a/cmd/profiles_preview.go b/cmd/profiles_preview.go index 5a05490..dbb34b4 100644 --- a/cmd/profiles_preview.go +++ b/cmd/profiles_preview.go @@ -35,7 +35,6 @@ import ( "github.com/mfinelli/modctl/internal/style" "github.com/pmezard/go-difflib/difflib" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var ( @@ -125,16 +124,16 @@ Binary files are detected automatically and refused unless --force is passed.`, winner := winnerOp.File.Winner() bs := blobstore.Store{ - ArchivesDir: viper.GetString("archives_dir"), - BackupsDir: viper.GetString("backups_dir"), - OverridesDir: viper.GetString("overrides_dir"), - TmpDir: viper.GetString("tmp_dir"), + ArchivesDir: cfg.ArchivesDir, + BackupsDir: cfg.BackupsDir, + OverridesDir: cfg.OverridesDir, + TmpDir: cfg.TmpDir, } ex := extractor.Extractor{ - BsdtarPath: viper.GetString("bsdtar"), + BsdtarPath: cfg.Bsdtar, BlobStore: bs, - StagingDir: viper.GetString("tmp_dir"), + StagingDir: cfg.TmpDir, } stagingDir, err := ex.ExtractArchive(ctx, winner.Entry.ArchiveSha256) diff --git a/cmd/root.go b/cmd/root.go index 85c1c52..518a52e 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -20,16 +20,13 @@ package cmd import ( "context" - "errors" "log/slog" "os" "os/signal" - "path/filepath" "syscall" - "github.com/adrg/xdg" + "github.com/mfinelli/modctl/internal/config" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var ( @@ -63,8 +60,8 @@ You should have received a copy of the GNU General Public License (version })) slog.SetDefault(logger) - if viper.ConfigFileUsed() != "" { - logger.Debug("using config file", "path", viper.ConfigFileUsed()) + if cfg.File != "" { + logger.Debug("using config file", "path", cfg.File) } return nil @@ -103,58 +100,13 @@ func init() { ) } -// initConfig reads in config file and ENV variables if set. +// initConfig reads in the config file, if there is one (the one that was asked +// for must be there and be right, and the default one is only read if it is +// there). It makes nothing: not the directories that the configuration names, +// or the one that the config file would be in. func initConfig() { - // if unspecified just search $PATH - viper.SetDefault("bsdtar", "bsdtar") - - dbPath, err := xdg.DataFile(filepath.Join("modctl", "modctl.db")) - cobra.CheckErr(err) - viper.SetDefault("database", dbPath) - - viper.SetDefault("archives_dir", - filepath.Join(xdg.DataHome, "modctl", "archives")) - viper.SetDefault("backups_dir", - filepath.Join(xdg.DataHome, "modctl", "backups")) - viper.SetDefault("overrides_dir", - filepath.Join(xdg.DataHome, "modctl", "overrides")) - viper.SetDefault("locks_dir", filepath.Join(stateDir(), "locks")) - viper.SetDefault("cache_dir", - filepath.Join(xdg.CacheHome, "modctl")) - viper.SetDefault("tmp_dir", - filepath.Join(xdg.RuntimeDir, "modctl")) - - if cfgFile != "" { - // User explicitly provided a config file: it must work. - viper.SetConfigFile(cfgFile) - viper.SetConfigType("toml") - - if err := viper.ReadInConfig(); err != nil { - cobra.CheckErr(err) - } - - return - } - - defaultPath, err := xdg.ConfigFile(filepath.Join("modctl", "config.toml")) + c, err := config.Load(configDirs(), cfgFile) cobra.CheckErr(err) - if _, err := os.Stat(defaultPath); errors.Is(err, os.ErrNotExist) { - return // default config file doesn't exist -- use defaults - } - - viper.SetConfigFile(defaultPath) - viper.SetConfigType("toml") - - if err := viper.ReadInConfig(); err != nil { - // missing config file is fine -- use the built-in defaults - var notFound viper.ConfigFileNotFoundError - if errors.As(err, ¬Found) { - return - } - - // parse/permission errors should fail loudly - cobra.CheckErr(err) - return - } + cfg = c } diff --git a/cmd/unapply.go b/cmd/unapply.go index 9a6e7e6..4c1662e 100644 --- a/cmd/unapply.go +++ b/cmd/unapply.go @@ -34,7 +34,6 @@ import ( "github.com/mfinelli/modctl/internal/planner" "github.com/mfinelli/modctl/internal/style" "github.com/spf13/cobra" - "github.com/spf13/viper" ) var ( @@ -121,7 +120,7 @@ Use --dry-run to preview the plan without making any changes.`, } // Acquire per-game lock to prevent concurrent apply/unapply - unlock, err := lock.GameInstall(viper.GetString("locks_dir"), gi.ID) + unlock, err := lock.GameInstall(cfg.LocksDir, gi.ID) if err != nil { return err } @@ -188,15 +187,15 @@ Use --dry-run to preview the plan without making any changes.`, style.Println() bs := blobstore.Store{ - ArchivesDir: viper.GetString("archives_dir"), - BackupsDir: viper.GetString("backups_dir"), - TmpDir: viper.GetString("tmp_dir"), + ArchivesDir: cfg.ArchivesDir, + BackupsDir: cfg.BackupsDir, + TmpDir: cfg.TmpDir, } ext := extractor.Extractor{ - BsdtarPath: viper.GetString("bsdtar"), + BsdtarPath: cfg.Bsdtar, BlobStore: bs, - StagingDir: viper.GetString("tmp_dir"), + StagingDir: cfg.TmpDir, } // Create operation record diff --git a/internal/config/config.go b/internal/config/config.go new file mode 100644 index 0000000..879a651 --- /dev/null +++ b/internal/config/config.go @@ -0,0 +1,380 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +// Package config is the configuration of modctl: what it is when nothing has +// been said, what a config file says, and how the file is changed. +// +// Nothing in it finds anything by itself. Where the directories of the user +// are, and which file is the config file, are given to it, and so it makes +// nothing that it was not asked to: reading a configuration does not make the +// directories that it names, or the one that the config file is in. +package config + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "slices" + "sort" + "strings" + + "github.com/pelletier/go-toml/v2" + "github.com/spf13/viper" +) + +// Dirs are the directories of the user that the defaults are in, which are the +// XDG ones. +type Dirs struct { + // ConfigHome is where the config file is ($XDG_CONFIG_HOME). + ConfigHome string + // DataHome is where what modctl keeps is ($XDG_DATA_HOME). + DataHome string + // CacheHome is where what can be made again is ($XDG_CACHE_HOME). + CacheHome string + // StateHome is where what is about what has been done is + // ($XDG_STATE_HOME). + StateHome string + // RuntimeDir is where what is only needed while modctl runs is + // ($XDG_RUNTIME_DIR). + RuntimeDir string +} + +// State is the directory that modctl keeps its state in, which is the active +// selection and what has to be there while something is being done (the locks, +// in a directory of their own). +func (d Dirs) State() string { + return filepath.Join(d.StateHome, "modctl") +} + +// DefaultPath is the config file that is read when there is not one that has +// been asked for, and the one that is made when there is not any. +func DefaultPath(d Dirs) string { + return filepath.Join(d.ConfigHome, "modctl", "config.toml") +} + +// field is a key of the configuration, and where its value is in a Config. +type field struct { + key string + value func(*Config) *string +} + +// fields are the keys, in the order that they are listed in. +var fields = []field{ + {"bsdtar", func(c *Config) *string { return &c.Bsdtar }}, + {"database", func(c *Config) *string { return &c.Database }}, + {"archives_dir", func(c *Config) *string { return &c.ArchivesDir }}, + {"backups_dir", func(c *Config) *string { return &c.BackupsDir }}, + {"cache_dir", func(c *Config) *string { return &c.CacheDir }}, + {"locks_dir", func(c *Config) *string { return &c.LocksDir }}, + {"overrides_dir", func(c *Config) *string { return &c.OverridesDir }}, + {"tmp_dir", func(c *Config) *string { return &c.TmpDir }}, + {"nexus.apikey", func(c *Config) *string { return &c.NexusAPIKey }}, +} + +// Keys are the keys that there are, in the order that they are listed in. A key +// in the second level is named with a dot ("nexus.apikey"), which is how it is +// in the commands, and in the file it is a key of a table. +func Keys() []string { + keys := make([]string, len(fields)) + for i, f := range fields { + keys[i] = f.key + } + return keys +} + +// A Config is what modctl is configured to: the defaults, with what the config +// file says on top of them. +type Config struct { + // Bsdtar is the program that reads and extracts archives. It is found in + // the PATH if it is not a path. + Bsdtar string + // Database is the file of the database. + Database string + // ArchivesDir, BackupsDir and OverridesDir are where the blobs of each kind + // are kept. + ArchivesDir string + BackupsDir string + OverridesDir string + CacheDir string + LocksDir string + TmpDir string + NexusAPIKey string + File string // the config file that was read, if one was + fromFile map[string]bool +} + +// Defaults is the configuration of a user whose directories are dirs, who has +// not said anything. +func Defaults(d Dirs) Config { + return Config{ + Bsdtar: "bsdtar", + Database: filepath.Join(d.DataHome, "modctl", "modctl.db"), + ArchivesDir: filepath.Join(d.DataHome, "modctl", "archives"), + BackupsDir: filepath.Join(d.DataHome, "modctl", "backups"), + OverridesDir: filepath.Join(d.DataHome, "modctl", "overrides"), + LocksDir: filepath.Join(d.State(), "locks"), + CacheDir: filepath.Join(d.CacheHome, "modctl"), + TmpDir: filepath.Join(d.RuntimeDir, "modctl"), + } +} + +// Value is the value of key, and whether there is such a key. +func (c Config) Value(key string) (string, bool) { + for _, f := range fields { + if f.key == key { + return *f.value(&c), true + } + } + return "", false +} + +// FromFile is whether the config file says what key is, and not the defaults. +// A key that the file says is what it is by default is still said. +func (c Config) FromFile(key string) bool { + return c.fromFile[key] +} + +// Load is the configuration of a user whose directories are dirs. It is the +// defaults, and what the config file says over them: the file at path, which +// has to be there, or the default one (see DefaultPath) if path is empty, which +// does not have to be. A file that can't be read or is not a config file is an +// error, whichever one it is. +// +// The file is read by an instance of viper of its own, which is what knows how a +// config file is read (the keys are not case sensitive, and a value is the text +// of what it is), and which nothing else in the program shares. +func Load(d Dirs, path string) (Config, error) { + c := Defaults(d) + + if path == "" { + path = DefaultPath(d) + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + // a config file that was not asked for, and is not there: the + // defaults are what it is + return c, nil + } + } + + v := viper.New() + for _, f := range fields { + v.SetDefault(f.key, *f.value(&c)) + } + v.SetConfigFile(path) + v.SetConfigType("toml") + if err := v.ReadInConfig(); err != nil { + return Config{}, err + } + + c.File = v.ConfigFileUsed() + c.fromFile = make(map[string]bool) + for _, f := range fields { + *f.value(&c) = v.GetString(f.key) + c.fromFile[f.key] = v.InConfig(f.key) + } + + return c, nil +} + +// UnknownKeyError is a key that there is not. +type UnknownKeyError struct { + Key string +} + +func (e UnknownKeyError) Error() string { + return fmt.Sprintf("unknown config key %q\n valid keys: %s", e.Key, strings.Join(Keys(), ", ")) +} + +func checkKey(key string) error { + if !slices.Contains(Keys(), key) { + return UnknownKeyError{Key: key} + } + return nil +} + +// Set makes the config file at path say that key is value. The file is made if +// it is not there (and so is the directory that it is in, which only the user +// can read), and what else it says is kept as it is, apart from how it is +// written: the comments and the formatting of a file that was written by hand +// are not. +func Set(path, key, value string) error { + if err := checkKey(key); err != nil { + return err + } + + return edit(path, func(m map[string]any) { + setIn(m, strings.Split(key, "."), value) + }) +} + +// Unset makes the config file at path not say anything about key, so that it is +// what it is by default. A table that has nothing else in it is not left. +func Unset(path, key string) error { + if err := checkKey(key); err != nil { + return err + } + + return edit(path, func(m map[string]any) { + unsetIn(m, strings.Split(key, ".")) + }) +} + +// foldKeys are the keys of table that are key, whatever their case is (a +// config file is read that way, so it is changed that way: a key that is +// written next to another that is the same key in another case would not be +// the one that is read). +func foldKeys(table map[string]any, key string) []string { + var names []string + for name := range table { + if strings.EqualFold(name, key) { + names = append(names, name) + } + } + sort.Strings(names) + return names +} + +// takeTable removes the tables that are key from table, and returns what was in +// them (as one table, if there was more than one), or nil if there was none. +// What is called key and is not a table is left where it is. +func takeTable(table map[string]any, key string) map[string]any { + var merged map[string]any + for _, name := range foldKeys(table, key) { + t, ok := table[name].(map[string]any) + if !ok { + continue + } + if merged == nil { + merged = make(map[string]any) + } + for k, v := range t { + if _, has := merged[k]; !has { + merged[k] = v + } + } + delete(table, name) + } + return merged +} + +func setIn(table map[string]any, path []string, value string) { + if len(path) == 1 { + for _, name := range foldKeys(table, path[0]) { + delete(table, name) + } + table[path[0]] = value + return + } + + next := takeTable(table, path[0]) + // what is not a table where there is to be one is not kept + for _, name := range foldKeys(table, path[0]) { + delete(table, name) + } + if next == nil { + next = make(map[string]any) + } + setIn(next, path[1:], value) + table[path[0]] = next +} + +func unsetIn(table map[string]any, path []string) { + if len(path) == 1 { + for _, name := range foldKeys(table, path[0]) { + delete(table, name) + } + return + } + + next := takeTable(table, path[0]) + if next == nil { + return + } + unsetIn(next, path[1:]) + if len(next) > 0 { + table[path[0]] = next + } +} + +// edit changes the config file at path, which is made if it is not there, by +// what change does to what it says. +func edit(path string, change func(map[string]any)) error { + if err := ensureFile(path); err != nil { + return fmt.Errorf("create config file: %w", err) + } + + m, err := readTable(path) + if err != nil { + return fmt.Errorf("read config file: %w", err) + } + + change(m) + + data, err := toml.Marshal(m) + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + + if err := os.WriteFile(path, data, 0o600); err != nil { + return fmt.Errorf("write config file: %w", err) + } + + return nil +} + +// ensureFile makes the file at path, if it is not there, so that only the user +// can read it (there can be an API key in it). +func ensureFile(path string) error { + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + f, err := os.OpenFile(path, os.O_CREATE|os.O_EXCL, 0o600) + if err != nil { + if errors.Is(err, os.ErrExist) { + return nil + } + return err + } + return f.Close() +} + +// readTable is what the file at path says, as it is in the file. A file that +// has nothing in it says nothing. +func readTable(path string) (map[string]any, error) { + data, err := os.ReadFile(path) + if err != nil { + return nil, err + } + out := make(map[string]any) + if len(strings.TrimSpace(string(data))) == 0 { + return out, nil + } + if err := toml.Unmarshal(data, &out); err != nil { + return nil, err + } + return out, nil +} + +// Mask is key (an API key) as it is shown: with all of it but the last four +// characters not there. +func Mask(key string) string { + if len(key) <= 4 { + return strings.Repeat("*", len(key)) + } + return strings.Repeat("*", len(key)-4) + key[len(key)-4:] +} diff --git a/internal/config/config_test.go b/internal/config/config_test.go new file mode 100644 index 0000000..696d009 --- /dev/null +++ b/internal/config/config_test.go @@ -0,0 +1,784 @@ +/* + * mod control (modctl): command-line mod manager + * Copyright © 2026 Mario Finelli + * + * This program is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + */ + +package config + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/spf13/viper" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// dirsIn are the directories of a user whose home is home, which are the ones +// of the specification. +func dirsIn(home string) Dirs { + return Dirs{ + ConfigHome: filepath.Join(home, ".config"), + DataHome: filepath.Join(home, ".local", "share"), + CacheHome: filepath.Join(home, ".cache"), + StateHome: filepath.Join(home, ".local", "state"), + RuntimeDir: filepath.Join("/run", "user", "1000"), + } +} + +// writeFile writes a config file at path (and the directories that lead to it). +func writeFile(t *testing.T, path, content string) { + t.Helper() + + require.NoError(t, os.MkdirAll(filepath.Dir(path), 0o755)) + require.NoError(t, os.WriteFile(path, []byte(content), 0o644)) +} + +// entries is what there is in dir, all the way down: what a test has not +// asked for must not be there. +func entries(t *testing.T, dir string) []string { + t.Helper() + + var found []string + require.NoError(t, filepath.WalkDir(dir, func(p string, _ os.DirEntry, err error) error { + if err != nil { + return err + } + if p != dir { + rel, _ := filepath.Rel(dir, p) + found = append(found, rel) + } + return nil + })) + return found +} + +func TestDirsState(t *testing.T) { + t.Parallel() + + assert.Equal(t, "/home/me/.local/state/modctl", dirsIn("/home/me").State()) +} + +func TestDefaultPath(t *testing.T) { + t.Parallel() + + assert.Equal(t, "/home/me/.config/modctl/config.toml", DefaultPath(dirsIn("/home/me"))) +} + +func TestKeys(t *testing.T) { + t.Parallel() + + t.Run("they are listed in the order of the documentation", func(t *testing.T) { + t.Parallel() + + assert.Equal(t, []string{ + "bsdtar", "database", "archives_dir", "backups_dir", "cache_dir", + "locks_dir", "overrides_dir", "tmp_dir", "nexus.apikey", + }, Keys()) + }) + + t.Run("there is a value for each of them", func(t *testing.T) { + t.Parallel() + + c := Defaults(dirsIn("/home/me")) + for _, key := range Keys() { + _, ok := c.Value(key) + assert.True(t, ok, key) + } + }) + + t.Run("a list that is changed is not what the next one is", func(t *testing.T) { + t.Parallel() + + keys := Keys() + keys[0] = "something else" + + assert.Equal(t, "bsdtar", Keys()[0]) + }) +} + +func TestDefaults(t *testing.T) { + t.Parallel() + + t.Run("they are in the directories of the user", func(t *testing.T) { + t.Parallel() + + c := Defaults(dirsIn("/home/me")) + + assert.Equal(t, "bsdtar", c.Bsdtar) + assert.Equal(t, "/home/me/.local/share/modctl/modctl.db", c.Database) + assert.Equal(t, "/home/me/.local/share/modctl/archives", c.ArchivesDir) + assert.Equal(t, "/home/me/.local/share/modctl/backups", c.BackupsDir) + assert.Equal(t, "/home/me/.local/share/modctl/overrides", c.OverridesDir) + assert.Equal(t, "/home/me/.local/state/modctl/locks", c.LocksDir) + assert.Equal(t, "/home/me/.cache/modctl", c.CacheDir) + assert.Equal(t, "/run/user/1000/modctl", c.TmpDir) + assert.Empty(t, c.NexusAPIKey) + }) + + t.Run("nothing is said by a file, and there is none", func(t *testing.T) { + t.Parallel() + + c := Defaults(dirsIn("/home/me")) + + assert.Empty(t, c.File) + for _, key := range Keys() { + assert.False(t, c.FromFile(key), key) + } + }) + + t.Run("it makes nothing", func(t *testing.T) { + t.Parallel() + + home := t.TempDir() + + Defaults(dirsIn(home)) + + assert.Empty(t, entries(t, home)) + }) +} + +func TestConfigValue(t *testing.T) { + t.Parallel() + + t.Run("a key has the value that it is for", func(t *testing.T) { + t.Parallel() + + c := Config{ + Bsdtar: "a", Database: "b", ArchivesDir: "c", BackupsDir: "d", CacheDir: "e", + LocksDir: "f", OverridesDir: "g", TmpDir: "h", NexusAPIKey: "i", + } + + for key, want := range map[string]string{ + "bsdtar": "a", "database": "b", "archives_dir": "c", "backups_dir": "d", "cache_dir": "e", + "locks_dir": "f", "overrides_dir": "g", "tmp_dir": "h", "nexus.apikey": "i", + } { + got, ok := c.Value(key) + assert.True(t, ok, key) + assert.Equal(t, want, got, key) + } + }) + + t.Run("a key that there is not is not there", func(t *testing.T) { + t.Parallel() + + got, ok := Defaults(dirsIn("/home/me")).Value("not_a_key") + + assert.False(t, ok) + assert.Empty(t, got) + }) + + t.Run("the keys are not case sensitive in the file, and are in the commands", func(t *testing.T) { + t.Parallel() + + _, ok := Defaults(dirsIn("/home/me")).Value("DATABASE") + + assert.False(t, ok) + }) +} + +func TestLoad(t *testing.T) { + t.Parallel() + + t.Run("without a file it is the defaults, and nothing is made", func(t *testing.T) { + t.Parallel() + + home := t.TempDir() + d := dirsIn(home) + + c, err := Load(d, "") + + require.NoError(t, err) + want := Defaults(d) + assert.Equal(t, want, c) + assert.Empty(t, c.File) + assert.Empty(t, entries(t, home), "not even the directory that the config file would be in") + }) + + t.Run("the default file is read when it is there", func(t *testing.T) { + t.Parallel() + + home := t.TempDir() + d := dirsIn(home) + writeFile(t, DefaultPath(d), "database = \"/elsewhere/db\"\n") + + c, err := Load(d, "") + + require.NoError(t, err) + assert.Equal(t, "/elsewhere/db", c.Database) + assert.Equal(t, DefaultPath(d), c.File) + assert.Equal(t, Defaults(d).ArchivesDir, c.ArchivesDir, "what it does not say is what it is by default") + }) + + t.Run("the file that is asked for is read, and not the default one", func(t *testing.T) { + t.Parallel() + + home := t.TempDir() + d := dirsIn(home) + writeFile(t, DefaultPath(d), "database = \"/default/db\"\n") + asked := filepath.Join(t.TempDir(), "mine.toml") + writeFile(t, asked, "database = \"/mine/db\"\n") + + c, err := Load(d, asked) + + require.NoError(t, err) + assert.Equal(t, "/mine/db", c.Database) + assert.Equal(t, asked, c.File) + }) + + t.Run("a file that is asked for and is not there is an error", func(t *testing.T) { + t.Parallel() + + missing := filepath.Join(t.TempDir(), "nothing.toml") + + _, err := Load(dirsIn(t.TempDir()), missing) + + require.Error(t, err) + assert.ErrorIs(t, err, os.ErrNotExist) + assert.Equal(t, "open "+missing+": no such file or directory", err.Error()) + }) + + t.Run("every key can be said", func(t *testing.T) { + t.Parallel() + + d := dirsIn(t.TempDir()) + path := filepath.Join(t.TempDir(), "all.toml") + writeFile(t, path, ` +bsdtar = "/usr/bin/bsdtar" +database = "/x/db" +archives_dir = "/x/archives" +backups_dir = "/x/backups" +cache_dir = "/x/cache" +locks_dir = "/x/locks" +overrides_dir = "/x/overrides" +tmp_dir = "/x/tmp" + +[nexus] +apikey = "the-key" +`) + + c, err := Load(d, path) + + require.NoError(t, err) + for key, want := range map[string]string{ + "bsdtar": "/usr/bin/bsdtar", "database": "/x/db", "archives_dir": "/x/archives", + "backups_dir": "/x/backups", "cache_dir": "/x/cache", "locks_dir": "/x/locks", + "overrides_dir": "/x/overrides", "tmp_dir": "/x/tmp", "nexus.apikey": "the-key", + } { + got, _ := c.Value(key) + assert.Equal(t, want, got, key) + assert.True(t, c.FromFile(key), key) + } + }) + + t.Run("a key that is a dotted key is the key of a table, as it is in toml", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "dotted.toml") + writeFile(t, path, "nexus.apikey = \"the-key\"\n") + + c, err := Load(dirsIn(t.TempDir()), path) + + require.NoError(t, err) + assert.Equal(t, "the-key", c.NexusAPIKey) + }) + + t.Run("what is said is from the file, and what is not is not", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "some.toml") + writeFile(t, path, "database = \"/x/db\"\n") + + c, err := Load(dirsIn(t.TempDir()), path) + + require.NoError(t, err) + assert.True(t, c.FromFile("database")) + assert.False(t, c.FromFile("archives_dir")) + }) + + t.Run("a key that is said to be what it is by default is still said", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "same.toml") + writeFile(t, path, "bsdtar = \"bsdtar\"\n") + + c, err := Load(dirsIn(t.TempDir()), path) + + require.NoError(t, err) + assert.True(t, c.FromFile("bsdtar")) + }) + + t.Run("the keys are not case sensitive", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "case.toml") + writeFile(t, path, "Database = \"/up/db\"\nARCHIVES_DIR = \"/up/archives\"\n[Nexus]\nAPIKey = \"secret\"\n") + + c, err := Load(dirsIn(t.TempDir()), path) + + require.NoError(t, err) + assert.Equal(t, "/up/db", c.Database) + assert.Equal(t, "/up/archives", c.ArchivesDir) + assert.Equal(t, "secret", c.NexusAPIKey) + }) + + t.Run("a value that is empty is a value, and not what is by default", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "empty.toml") + writeFile(t, path, "tmp_dir = \"\"\n") + + c, err := Load(dirsIn(t.TempDir()), path) + + require.NoError(t, err) + assert.Empty(t, c.TmpDir) + assert.True(t, c.FromFile("tmp_dir")) + }) + + t.Run("a key that there is not is left as it is", func(t *testing.T) { + t.Parallel() + + d := dirsIn(t.TempDir()) + path := filepath.Join(t.TempDir(), "unknown.toml") + writeFile(t, path, "something_else = 1\n[nexus]\nother = \"x\"\n[other_table]\nx = 1\n") + + c, err := Load(d, path) + + require.NoError(t, err) + assert.Equal(t, Defaults(d).Database, c.Database) + assert.Equal(t, path, c.File) + }) + + t.Run("a table where there should be a key says nothing about it", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "table.toml") + writeFile(t, path, "nexus = \"not a table\"\n") + + c, err := Load(dirsIn(t.TempDir()), path) + + require.NoError(t, err) + assert.Empty(t, c.NexusAPIKey) + assert.False(t, c.FromFile("nexus.apikey")) + }) + + t.Run("a file that is not toml is an error, and says what it was doing", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "broken.toml") + writeFile(t, path, "database = \"/x/db\"\n[nexus\napikey = ") + + _, err := Load(dirsIn(t.TempDir()), path) + + require.Error(t, err) + var parse viper.ConfigParseError + require.ErrorAs(t, err, &parse) + assert.True(t, strings.HasPrefix(err.Error(), "While parsing config: toml: "), err.Error()) + }) + + t.Run("the default file that is not toml is an error too", func(t *testing.T) { + t.Parallel() + + d := dirsIn(t.TempDir()) + writeFile(t, DefaultPath(d), "[nexus\n") + + _, err := Load(d, "") + + var parse viper.ConfigParseError + assert.ErrorAs(t, err, &parse) + }) + + t.Run("a default file that can't be read is an error, and not the defaults", func(t *testing.T) { + t.Parallel() + + d := dirsIn(t.TempDir()) + // a directory where the file should be + require.NoError(t, os.MkdirAll(DefaultPath(d), 0o755)) + + _, err := Load(d, "") + + require.Error(t, err) + assert.False(t, errors.Is(err, os.ErrNotExist)) + }) + + t.Run("a default file that can't be looked for is an error, and not the defaults", func(t *testing.T) { + t.Parallel() + + home := t.TempDir() + d := dirsIn(home) + // there is a file where the directory of the configuration should be + require.NoError(t, os.WriteFile(d.ConfigHome, nil, 0o644)) + + _, err := Load(d, "") + + require.Error(t, err) + assert.False(t, errors.Is(err, os.ErrNotExist)) + }) + + t.Run("a value that is not text is the text of what it is", func(t *testing.T) { + t.Parallel() + + for name, tc := range map[string]struct{ content, want string }{ + "a number": {"database = 5\n", "5"}, + "a float": {"database = 1.5\n", "1.5"}, + "a bool": {"database = true\n", "true"}, + "a date": {"database = 2026-10-13\n", "2026-10-13"}, + } { + tc := tc + t.Run(name, func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "type.toml") + writeFile(t, path, tc.content) + + c, err := Load(dirsIn(t.TempDir()), path) + + require.NoError(t, err) + assert.Equal(t, tc.want, c.Database) + assert.True(t, c.FromFile("database")) + }) + } + }) + + t.Run("reading a file makes nothing", func(t *testing.T) { + t.Parallel() + + home := t.TempDir() + d := dirsIn(home) + writeFile(t, DefaultPath(d), "database = \"/x/db\"\n") + before := entries(t, home) + + _, err := Load(d, "") + + require.NoError(t, err) + assert.Equal(t, before, entries(t, home)) + }) + + t.Run("a configuration is its own, whichever way it was got", func(t *testing.T) { + t.Parallel() + + d := dirsIn(t.TempDir()) + path := filepath.Join(t.TempDir(), "a.toml") + writeFile(t, path, "database = \"/x/db\"\n") + + first, err := Load(d, path) + require.NoError(t, err) + second, err := Load(d, "") + require.NoError(t, err) + + assert.Equal(t, "/x/db", first.Database) + assert.Equal(t, Defaults(d).Database, second.Database) + assert.False(t, second.FromFile("database")) + }) +} + +func TestSet(t *testing.T) { + t.Parallel() + + t.Run("the file is made, and the directory that it is in", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "config", "modctl", "config.toml") + + require.NoError(t, Set(path, "database", "/x/db")) + + assert.FileExists(t, path) + info, err := os.Stat(path) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o600), info.Mode().Perm(), "there can be an API key in it") + dir, err := os.Stat(filepath.Dir(path)) + require.NoError(t, err) + assert.Equal(t, os.FileMode(0o700), dir.Mode().Perm()) + }) + + t.Run("what is set is what is read", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, Set(path, "archives_dir", "/x/archives")) + require.NoError(t, Set(path, "nexus.apikey", "the-key")) + + c, err := Load(dirsIn(t.TempDir()), path) + + require.NoError(t, err) + assert.Equal(t, "/x/archives", c.ArchivesDir) + assert.Equal(t, "the-key", c.NexusAPIKey) + }) + + t.Run("a key is set over what it was", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, Set(path, "database", "/first")) + + require.NoError(t, Set(path, "database", "/second")) + + c, err := Load(dirsIn(t.TempDir()), path) + require.NoError(t, err) + assert.Equal(t, "/second", c.Database) + }) + + t.Run("what else the file says is kept", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "config.toml") + writeFile(t, path, "tmp_dir = \"/x/tmp\"\nunknown_key = \"kept\"\n\n[nexus]\napikey = \"old\"\nother = \"kept too\"\n\n[other]\nx = 1\n") + + require.NoError(t, Set(path, "nexus.apikey", "new")) + + data, err := os.ReadFile(path) + require.NoError(t, err) + text := string(data) + assert.Contains(t, text, `tmp_dir = '/x/tmp'`) + assert.Contains(t, text, `unknown_key = 'kept'`) + assert.Contains(t, text, `other = 'kept too'`) + assert.Contains(t, text, `apikey = 'new'`) + assert.NotContains(t, text, "old") + assert.Contains(t, text, "[other]") + }) + + t.Run("a file that has nothing in it is a file that says nothing", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "config.toml") + writeFile(t, path, "\n \n") + + require.NoError(t, Set(path, "database", "/x/db")) + + c, err := Load(dirsIn(t.TempDir()), path) + require.NoError(t, err) + assert.Equal(t, "/x/db", c.Database) + }) + + t.Run("a key that there is not is an error that says which are, and changes nothing", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + path := filepath.Join(dir, "config.toml") + + err := Set(path, "not_a_key", "x") + + var unknown UnknownKeyError + require.ErrorAs(t, err, &unknown) + assert.Equal(t, "not_a_key", unknown.Key) + assert.Contains(t, err.Error(), `unknown config key "not_a_key"`) + assert.Contains(t, err.Error(), "valid keys: bsdtar, database, archives_dir") + assert.Empty(t, entries(t, dir), "not even the file") + }) + + t.Run("a file that is not toml is an error, and is left as it is", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "config.toml") + writeFile(t, path, "[nexus\n") + + err := Set(path, "database", "/x/db") + + require.Error(t, err) + assert.Contains(t, err.Error(), "read config file") + data, _ := os.ReadFile(path) + assert.Equal(t, "[nexus\n", string(data)) + }) + + t.Run("a directory that can't be made is an error that says so", func(t *testing.T) { + t.Parallel() + + blocker := filepath.Join(t.TempDir(), "config") + require.NoError(t, os.WriteFile(blocker, nil, 0o644)) + + err := Set(filepath.Join(blocker, "modctl", "config.toml"), "database", "/x/db") + + require.Error(t, err) + assert.Contains(t, err.Error(), "create config file") + }) + + t.Run("a key that is in the file in another case is the one that is set", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "config.toml") + writeFile(t, path, "Database = \"/old\"\nDATABASE = \"/older\"\ntmp_dir = \"/x/tmp\"\n") + + require.NoError(t, Set(path, "database", "/new")) + + c, err := Load(dirsIn(t.TempDir()), path) + require.NoError(t, err) + assert.Equal(t, "/new", c.Database) + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, 1, strings.Count(strings.ToLower(string(data)), "database"), "it is said once") + assert.Equal(t, "/x/tmp", c.TmpDir) + }) + + t.Run("a table that is in the file in another case is the one that is set in", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "config.toml") + writeFile(t, path, "[Nexus]\nAPIKey = \"old\"\nother = \"kept\"\n") + + require.NoError(t, Set(path, "nexus.apikey", "new")) + + c, err := Load(dirsIn(t.TempDir()), path) + require.NoError(t, err) + assert.Equal(t, "new", c.NexusAPIKey) + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Equal(t, 1, strings.Count(strings.ToLower(string(data)), "[nexus]"), "it is one table") + assert.Contains(t, string(data), "kept", "with what else was in it") + }) + + t.Run("a table that is not a table is not kept, where there is to be one", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "config.toml") + writeFile(t, path, "nexus = \"not a table\"\n") + + require.NoError(t, Set(path, "nexus.apikey", "the-key")) + + c, err := Load(dirsIn(t.TempDir()), path) + require.NoError(t, err) + assert.Equal(t, "the-key", c.NexusAPIKey) + }) +} + +func TestUnset(t *testing.T) { + t.Parallel() + + t.Run("a key that was said is not said any more", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, Set(path, "database", "/x/db")) + require.NoError(t, Set(path, "tmp_dir", "/x/tmp")) + + require.NoError(t, Unset(path, "database")) + + d := dirsIn(t.TempDir()) + c, err := Load(d, path) + require.NoError(t, err) + assert.Equal(t, Defaults(d).Database, c.Database) + assert.False(t, c.FromFile("database")) + assert.Equal(t, "/x/tmp", c.TmpDir, "and the others are") + }) + + t.Run("a table that has nothing else in it is not left", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "config.toml") + require.NoError(t, Set(path, "nexus.apikey", "the-key")) + + require.NoError(t, Unset(path, "nexus.apikey")) + + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.NotContains(t, string(data), "nexus") + }) + + t.Run("a table that has something else in it is kept", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "config.toml") + writeFile(t, path, "[nexus]\napikey = \"the-key\"\nother = \"kept\"\n") + + require.NoError(t, Unset(path, "nexus.apikey")) + + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(data), "[nexus]") + assert.Contains(t, string(data), "other = 'kept'") + assert.NotContains(t, string(data), "apikey") + }) + + t.Run("a key that is in the file in another case is the one that is not said any more", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "config.toml") + writeFile(t, path, "Database = \"/x/db\"\n[Nexus]\nAPIKey = \"the-key\"\n") + + require.NoError(t, Unset(path, "database")) + require.NoError(t, Unset(path, "nexus.apikey")) + + d := dirsIn(t.TempDir()) + c, err := Load(d, path) + require.NoError(t, err) + assert.Equal(t, Defaults(d).Database, c.Database) + assert.Empty(t, c.NexusAPIKey) + assert.False(t, c.FromFile("nexus.apikey")) + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.NotContains(t, strings.ToLower(string(data)), "nexus", "and the table that has nothing in it is not left") + }) + + t.Run("a key that was not said is not a problem", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "config.toml") + writeFile(t, path, "database = \"/x/db\"\n") + + require.NoError(t, Unset(path, "nexus.apikey")) + require.NoError(t, Unset(path, "tmp_dir")) + + c, err := Load(dirsIn(t.TempDir()), path) + require.NoError(t, err) + assert.Equal(t, "/x/db", c.Database) + }) + + t.Run("a table that is not a table is left as it is", func(t *testing.T) { + t.Parallel() + + path := filepath.Join(t.TempDir(), "config.toml") + writeFile(t, path, "nexus = \"not a table\"\n") + + require.NoError(t, Unset(path, "nexus.apikey")) + + data, err := os.ReadFile(path) + require.NoError(t, err) + assert.Contains(t, string(data), "not a table") + }) + + t.Run("a key that there is not is an error, and changes nothing", func(t *testing.T) { + t.Parallel() + + dir := t.TempDir() + + err := Unset(filepath.Join(dir, "config.toml"), "not_a_key") + + var unknown UnknownKeyError + assert.ErrorAs(t, err, &unknown) + assert.Empty(t, entries(t, dir)) + }) +} + +func TestMask(t *testing.T) { + t.Parallel() + + for _, tc := range []struct{ name, key, want string }{ + {"nothing is nothing", "", ""}, + {"a key of four is all of it", "abcd", "****"}, + {"a key of less is all of it", "ab", "**"}, + {"a key that is longer has its last four", "abcdefgh", "****efgh"}, + {"as many stars as what is not shown", "0123456789", "******6789"}, + } { + tc := tc + t.Run(tc.name, func(t *testing.T) { + t.Parallel() + + assert.Equal(t, tc.want, Mask(tc.key)) + }) + } +} From e0ad0e209fbef7900d5d26236683e84e295fdea2 Mon Sep 17 00:00:00 2001 From: Mario Finelli Date: Wed, 14 Oct 2026 09:09:04 +0200 Subject: [PATCH 12/14] Add tests rules to readme --- README.md | 33 +++++++++++++++++++++++++++++++++ 1 file changed, 33 insertions(+) diff --git a/README.md b/README.md index fed6cc8..196404d 100644 --- a/README.md +++ b/README.md @@ -160,6 +160,39 @@ Each command or subcommand group has its own file following the naming convention already established in the package (e.g. `profiles_create.go`, `mods_nexus_link.go`). +### Writing tests + +Tests must not touch the machine that they run on, and the packages in +`internal/` are written so that they can't. CI runs the tests through +`scripts/test.bash`, which points `HOME`, the XDG directories and `TMPDIR` at a +temporary directory (the ones that hold state are read-only) and fails if +anything is left in it or in the repository. + +Respect the following rules when writing tests: + +- **Packages in `internal/` are given what they work on.** They don't read the + environment, `$HOME`, the working directory or the system temporary directory, + and they don't import `xdg` or `viper` (`internal/config` is the only + exception: it reads the config file). +- **`cmd/` is the only place that knows where things are.** `cmd/env.go` turns + the configuration and the XDG directories into those arguments. When a + function in `internal/` needs another path or setting, add a parameter instead + of reading it from its surroundings. +- **No package-level variables in `internal/`** apart from read-only values + (tables, styles, embedded files) and sentinel errors. State that is shared is + what makes tests depend on each other and keeps them from running in parallel. +- **Tests set up everything they use**: `t.TempDir()` for files, + `testbuilder.SetupDB` or `SetupDBFile` for a database, `testbuilder.Schema()` + for the migrations, and a fake `http.RoundTripper` or a local server for the + network. Nothing in a test talks to the real Nexus Mods. +- **Tests don't change global state**: no assigning to a package-level variable + (of any package), no `t.Setenv`, `os.Setenv` or `os.Chdir`, and no `TestMain` + that sets something up for the others. Tests should use `t.Parallel()` where + possible, and `go test -race -shuffle=on` should always pass. The tests of + `internal/style` are the only exception: they check how the output behaves on + a terminal and with `NO_COLOR`, which is the environment, so they run one at a + time. + ## Future ideas - Additional stores (Heroic, Lutris, GOG) From f987b04a77e03c6dace77ac6d7ad9eaf2bbe3076 Mon Sep 17 00:00:00 2001 From: Mario Finelli Date: Wed, 14 Oct 2026 09:27:32 +0200 Subject: [PATCH 13/14] Update design document --- DESIGN.md | 115 ++++++++++++++++++++++++++++++++++++++++++------------ 1 file changed, 90 insertions(+), 25 deletions(-) diff --git a/DESIGN.md b/DESIGN.md index cbfb60b..4402501 100644 --- a/DESIGN.md +++ b/DESIGN.md @@ -517,6 +517,13 @@ The default `tmp_dir` is under `$XDG_RUNTIME_DIR`. This is intentional since cleaned up on logout. Users with large mod archives or small tmpfs allocations can override `tmp_dir` in config to point elsewhere. +Exports and imports do not use `tmp_dir`. A bundle holds every blob, so the +snapshots of the databases that an export makes and the bundle that `import`, +`verify` and `extract` extract need room for a copy of the data, which a tmpfs +may not have. They go in the temporary directory of the system (`$TMPDIR`, +usually `/tmp`). The commands give that directory to the code that needs it, the +same as everything else under `internal/` (see section 17). + ## 6. Safety model Only entries with `entry_type = 'file'` are considered during planning. @@ -1459,7 +1466,7 @@ This preserves a clean v1 while allowing richer v2. bundle (they can be scanned later with `mods scan-inventory`). Note: no profiles are applied automatically after import - run `profiles set-active` and `apply` to deploy mods. Requires temporary disk space roughly equal to the - bundle size. + bundle size, in the temporary directory of the system (see section 5). - `gc` - garbage collect unreferenced blobs from the blob store - `verify ` - verify the integrity of a modctl export bundle without importing it. Checks the database snapshot against the manifest sha256, runs @@ -1689,6 +1696,45 @@ Include in `testdata/`: entries, malformed lines) should be tested at the unit level and included in `testdata/` fixture archives for integration tests +### Test isolation + +Tests are hermetic: running them never reads or changes anything on the machine +that they run on, and they do not depend on each other. The same way that code +under `internal/` does not print (see section 16), it does not look for what it +works on: + +- It does not read the environment, `$HOME`, the working directory or the + temporary directory of the system, and it does not import `xdg` or `viper` + (`internal/config` reads the config file, and is the one that does). The + database path, the state, cache and scratch directories, the schema (the + migrations), the directories of the user and the like are arguments, or fields + of an options struct. `cmd/` is the only place that knows where things are + (`cmd/env.go`), and the configuration is read once, at startup. +- A function that needs something it was not given should fail instead of + falling back to a default that it found by itself. An export without a scratch + directory or a schema is an error, for example. +- There are no package-level variables under `internal/` apart from read-only + values (tables, styles, embedded files) and sentinel errors. The migrations + are not global either: a `database.Schema` has them, `main` makes one from the + embedded files, and a test makes one from the directory that they are in + (`testbuilder.Schema()`). +- A test sets up what it uses: `t.TempDir()` for files, `testbuilder.SetupDB` or + `SetupDBFile` for a database, a fake HTTP transport or a local WebSocket + server for Nexus Mods. Nothing in a test uses the network or opens a browser. +- A test does not change global state: it does not assign to package-level + variables, call `t.Setenv`, `os.Setenv` or `os.Chdir`, or have a `TestMain` + that sets something up for the others. That is what lets every test call + `t.Parallel()`, and what keeps `go test -race -shuffle=on` passing. The tests + of `internal/style` are the exception: they check how the output behaves on a + terminal and with `NO_COLOR`, which is the environment, so they run one at a + time. + +CI runs the tests with `scripts/test.bash`, which runs `go test -race -count=1` +with `HOME`, the XDG directories and `TMPDIR` in a temporary directory (the ones +that hold state are read-only), and fails if anything is left in it or in the +repository. The results of a test are never taken from the cache: a test that is +not run cannot find anything. + ## 18. Operational considerations - lock per game during apply to avoid concurrent changes @@ -1704,7 +1750,15 @@ Include in `testdata/`: The config file is a TOML file at `$XDG_CONFIG_HOME/modctl/config.toml` by default. Any command accepts `--config ` to use a different file. The file -is optional: if it does not exist, all built-in defaults are used. +is optional: if it does not exist, all built-in defaults are used. A file that +is asked for with `--config` must exist, and a file that cannot be read or is +not valid TOML is an error, whichever file it is. + +The configuration is read once, when a command starts, by `internal/config`, +which makes a typed value for the commands to use. Reading it does not create +anything: not the file, the directory that it is in, or the directories that it +names (`init` makes the ones that it needs, the directory of the database +included). The keys are not case sensitive in the file. ### Keys and defaults @@ -1737,8 +1791,11 @@ read/write only). ### config command The `config` command allows reading and writing config values without hand- -editing the file. The file is created if it does not exist when `config set` is -run. +editing the file. `config set`, `auth nexus login` and `auth nexus logout` all +change the file the same way: it is created if it does not exist (with mode +`0600`, in a directory with mode `0700`), and the key that is changed is the one +that is in the file, whatever the case of its name is in a file that was edited +by hand, so that there are never two. Note that `config set` rewrites the entire config file. Any comments or custom formatting added by hand will not be preserved. @@ -1775,9 +1832,11 @@ The Nexus API key can be provisioned in two ways: - **Manual**: `config set nexus.apikey ` for users who prefer to manage their own keys directly from their Nexus account settings page. -The SSO flow is implemented in `internal/nexussso` using -`github.com/coder/websocket`. A UUID is generated per login attempt and used as -the session identifier shared between the local WebSocket connection and the +The SSO flow is implemented in `internal/nexusclient` (`sso.go`) using +`github.com/coder/websocket`. It talks to the server and opens the browser +through values that it is given, so that a test runs it against a local +WebSocket server and no browser. A UUID is generated per login attempt and used +as the session identifier shared between the local WebSocket connection and the browser authorization page. No connection state is persisted between attempts; if the connection drops the user simply re-runs `auth nexus login`. @@ -1794,10 +1853,10 @@ state is updated as a side effect of the validate call and persisted to Nexus enforces per-user rate limits (2,500 requests/24h, 100 requests/hour once the daily limit is exceeded). Rate limit state is persisted to -`$XDG_STATE_HOME/modctl/nexus_rate_limits.json` and updated after every API call -from response headers. Batch operations perform a pre-flight check and warn the -user if quota may be insufficient, with `--force` to proceed anyway. The client -also enforces a local 30 req/sec limit via a token bucket rate limiter to avoid +`$XDG_STATE_HOME/modctl/nexus.json` and updated after every API call from +response headers. Batch operations perform a pre-flight check and warn the user +if quota may be insufficient, with `--force` to proceed anyway. The client also +enforces a local 30 req/sec limit via a token bucket rate limiter to avoid nginx-level 429s. Rate limit reset timestamps in Nexus API response headers use the format @@ -1855,14 +1914,15 @@ versions are shown as "old version" without an update indicator. The `internal/nexusclient` package provides: - `Client`: full client with API key, HTTP client, rate limiter, and cache DB. - Used for commands that make API calls. + It is given the directories of the cache and of the rate limit state. Used for + commands that make API calls. - `CacheReader`: lightweight read-only cache accessor. Used by `profiles status` and `mods info` which need cached data but should not make API calls. `Client` embeds `CacheReader`. -The API key is read from config (`nexus.apikey`). If not configured, Nexus -linking is silently skipped at import time; commands that require it error with -a helpful message. +The API key is read from config (`nexus.apikey`) by the command, which gives it +to the client. If not configured, Nexus linking is silently skipped at import +time; commands that require it error with a helpful message. ## 21. Garbage Collection @@ -1959,6 +2019,10 @@ For game-scoped exports, a fresh SQLite database is created, migrations are run to bring it to the current schema, and only the relevant rows are inserted in foreign key dependency order. +The snapshots, and the fresh database, are made in the scratch directory that +the export is given (the temporary directory of the system, see section 5), and +are removed when the export is over, whether it worked or not. + ### Blob verification Before writing the bundle, all blobs are hashed and compared against their @@ -1996,16 +2060,17 @@ Bundles exported by an older version are supported. After the integrity check the extracted `modctl.db` is migrated to the current schema (goose `Up`) before anything reads it, so that the current generated queries work against it. This happens on the temporary extracted copy only: the bundle file is opened -read-only and streamed into a temp directory, so the original export is never -modified. Once migrated, the bundle database is opened with a read-only -connection, since nothing that reads a bundle writes to it. Every consumer of -the bundle database (full and game-scoped import, `verify`, `extract`) goes -through the same open path (`restore.OpenAndValidate`). A bundle database is -just a smaller database built with the same migration chain, so this is the same -code path as a normal upgrade. The database's own schema version is what decides -whether to migrate (current: leave alone, older: migrate, newer: leave as it -is), so the check does not rely on the manifest. A newer database is not touched -here: import refuses it as described above, while `verify` only warns. +read-only and streamed into a directory of its own in the scratch directory (see +section 5), so the original export is never modified. Once migrated, the bundle +database is opened with a read-only connection, since nothing that reads a +bundle writes to it. Every consumer of the bundle database (full and game-scoped +import, `verify`, `extract`) goes through the same open path +(`restore.OpenAndValidate`). A bundle database is just a smaller database built +with the same migration chain, so this is the same code path as a normal +upgrade. The database's own schema version is what decides whether to migrate +(current: leave alone, older: migrate, newer: leave as it is), so the check does +not rely on the manifest. A newer database is not touched here: import refuses +it as described above, while `verify` only warns. Blob files are verified by hashing their content against their filename (which is their sha256) before ingestion. A mismatch causes import to abort. From 0744bb9292016fd539d7fdf71f2614ac66832052 Mon Sep 17 00:00:00 2001 From: Mario Finelli Date: Wed, 14 Oct 2026 09:31:09 +0200 Subject: [PATCH 14/14] Update changelog --- CHANGELOG.md | 2 ++ 1 file changed, 2 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2602586..df13298 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -53,6 +53,8 @@ based on the [Keep a Changelog](https://keepachangelog.com/en/1.0.0/) format. - `apply` now records the hash and size of the file that an overwrite replaces, from the check for drift that it already does or from the backup it made. Nothing is read just for this so with `--no-recheck` nothing is recorded. +- Fix a crash when Nexus Mods answered that the rate limit was exceeded and the + file that keeps the rate limits could not be read. ## v0.8.0 - 2026-10-10