diff --git a/app/app.go b/app/app.go index eda41f1..5f3ce92 100644 --- a/app/app.go +++ b/app/app.go @@ -15,11 +15,12 @@ import ( ) type App struct { - ctx context.Context - engine *core.Engine - settings *core.Settings - profile *core.UserProfile - paths AppPaths + ctx context.Context + engine *core.Engine + settings *core.Settings + profile *core.UserProfile + paths AppPaths + persistPreferredRoot bool } type AppPaths struct { @@ -76,16 +77,25 @@ type QuoteKeywordRegenerationResult struct { } func NewApp(root string) (*App, error) { + return NewAppWithOptions(root, AppOptions{PersistPreferredRoot: true}) +} + +type AppOptions struct { + PersistPreferredRoot bool +} + +func NewAppWithOptions(root string, opts AppOptions) (*App, error) { runtimeState, err := OpenRuntime(root) if err != nil { return nil, err } return &App{ - engine: runtimeState.Engine, - settings: runtimeState.Settings, - profile: runtimeState.Profile, - paths: runtimeState.Paths, + engine: runtimeState.Engine, + settings: runtimeState.Settings, + profile: runtimeState.Profile, + paths: runtimeState.Paths, + persistPreferredRoot: opts.PersistPreferredRoot, }, nil } @@ -256,12 +266,12 @@ func (a *App) ApplyRuntimeProvider(provider core.ProviderConfig) error { } func (a *App) SaveSettings(settings core.Settings) (*core.Settings, error) { - nextRuntime, err := SwitchRuntime(&RuntimeState{ + nextRuntime, err := SwitchRuntimeWithOptions(&RuntimeState{ Engine: a.engine, Settings: a.settings, Profile: a.profile, Paths: a.paths, - }, &settings) + }, &settings, RuntimeSwitchOptions{PersistPreferredRoot: a.persistPreferredRoot}) if err != nil { if nextRuntime != nil { a.engine = nextRuntime.Engine diff --git a/app/app_test.go b/app/app_test.go index b7667e0..250e82d 100644 --- a/app/app_test.go +++ b/app/app_test.go @@ -13,9 +13,9 @@ import ( func TestDesktopBackendQuoteShareRoundTrip(t *testing.T) { t.Parallel() - alice, err := NewApp(filepath.Join(t.TempDir(), "alice")) + alice, err := NewAppWithOptions(filepath.Join(t.TempDir(), "alice"), AppOptions{}) if err != nil { - t.Fatalf("NewApp(alice) error = %v", err) + t.Fatalf("NewAppWithOptions(alice) error = %v", err) } t.Cleanup(func() { alice.Shutdown(context.Background()) }) @@ -35,9 +35,9 @@ func TestDesktopBackendQuoteShareRoundTrip(t *testing.T) { t.Fatalf("Stat(exportPath) error = %v", err) } - bob, err := NewApp(filepath.Join(t.TempDir(), "bob")) + bob, err := NewAppWithOptions(filepath.Join(t.TempDir(), "bob"), AppOptions{}) if err != nil { - t.Fatalf("NewApp(bob) error = %v", err) + t.Fatalf("NewAppWithOptions(bob) error = %v", err) } t.Cleanup(func() { bob.Shutdown(context.Background()) }) @@ -70,9 +70,9 @@ func TestDesktopBackendQuoteShareRoundTrip(t *testing.T) { func TestDesktopBackendBootstrapState(t *testing.T) { t.Parallel() - app, err := NewApp(filepath.Join(t.TempDir(), "desktop")) + app, err := NewAppWithOptions(filepath.Join(t.TempDir(), "desktop"), AppOptions{}) if err != nil { - t.Fatalf("NewApp() error = %v", err) + t.Fatalf("NewAppWithOptions() error = %v", err) } t.Cleanup(func() { app.Shutdown(context.Background()) }) @@ -105,9 +105,9 @@ func TestDesktopBackendBootstrapState(t *testing.T) { func TestDesktopBackendCountQuotes(t *testing.T) { t.Parallel() - app, err := NewApp(filepath.Join(t.TempDir(), "desktop-count")) + app, err := NewAppWithOptions(filepath.Join(t.TempDir(), "desktop-count"), AppOptions{}) if err != nil { - t.Fatalf("NewApp() error = %v", err) + t.Fatalf("NewAppWithOptions() error = %v", err) } t.Cleanup(func() { app.Shutdown(context.Background()) }) @@ -135,9 +135,9 @@ func TestDesktopBackendCountQuotes(t *testing.T) { func TestDesktopBackendRecallHistoryLifecycle(t *testing.T) { t.Parallel() - app, err := NewApp(filepath.Join(t.TempDir(), "desktop-history")) + app, err := NewAppWithOptions(filepath.Join(t.TempDir(), "desktop-history"), AppOptions{}) if err != nil { - t.Fatalf("NewApp() error = %v", err) + t.Fatalf("NewAppWithOptions() error = %v", err) } t.Cleanup(func() { app.Shutdown(context.Background()) }) @@ -216,9 +216,9 @@ func TestDesktopBackendRecallHistoryLifecycle(t *testing.T) { func TestDesktopBackendSaveRecallAsQuote(t *testing.T) { t.Parallel() - app, err := NewApp(filepath.Join(t.TempDir(), "desktop-recall-quote")) + app, err := NewAppWithOptions(filepath.Join(t.TempDir(), "desktop-recall-quote"), AppOptions{}) if err != nil { - t.Fatalf("NewApp() error = %v", err) + t.Fatalf("NewAppWithOptions() error = %v", err) } t.Cleanup(func() { app.Shutdown(context.Background()) }) @@ -248,9 +248,9 @@ func TestDesktopBackendSaveRecallAsQuote(t *testing.T) { func TestDesktopBackendImportQuotesPayload(t *testing.T) { t.Parallel() - app, err := NewApp(filepath.Join(t.TempDir(), "desktop-import-payload")) + app, err := NewAppWithOptions(filepath.Join(t.TempDir(), "desktop-import-payload"), AppOptions{}) if err != nil { - t.Fatalf("NewApp() error = %v", err) + t.Fatalf("NewAppWithOptions() error = %v", err) } t.Cleanup(func() { app.Shutdown(context.Background()) }) @@ -268,9 +268,9 @@ func TestDesktopBackendImportQuotesPayload(t *testing.T) { t.Fatalf("PreviewQuoteExport() error = %v", err) } - target, err := NewApp(filepath.Join(t.TempDir(), "desktop-import-target")) + target, err := NewAppWithOptions(filepath.Join(t.TempDir(), "desktop-import-target"), AppOptions{}) if err != nil { - t.Fatalf("NewApp(target) error = %v", err) + t.Fatalf("NewAppWithOptions(target) error = %v", err) } t.Cleanup(func() { target.Shutdown(context.Background()) }) @@ -292,9 +292,9 @@ func TestNewAppUsesDefaultStorageWhenRootIsEmpty(t *testing.T) { t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "xdg-config")) t.Setenv("XDG_STATE_HOME", filepath.Join(t.TempDir(), "xdg-state")) - app, err := NewApp("") + app, err := NewAppWithOptions("", AppOptions{}) if err != nil { - t.Fatalf("NewApp(\"\") error = %v", err) + t.Fatalf("NewAppWithOptions(\"\") error = %v", err) } t.Cleanup(func() { app.Shutdown(context.Background()) }) @@ -313,7 +313,8 @@ func TestNewAppUsesDefaultStorageWhenRootIsEmpty(t *testing.T) { } func TestDesktopBackendSaveSettingsSwitchesStorageRoot(t *testing.T) { - t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "xdg-config")) + xdgConfig := filepath.Join(t.TempDir(), "xdg-config") + t.Setenv("XDG_CONFIG_HOME", xdgConfig) sourceRoot := filepath.Join(t.TempDir(), "desktop-source") targetRoot := filepath.Join(t.TempDir(), "desktop-target") @@ -371,15 +372,18 @@ func TestDesktopBackendSaveSettingsSwitchesStorageRoot(t *testing.T) { if preferredRoot != absTarget { t.Fatalf("preferred root = %q, want %q", preferredRoot, absTarget) } + if _, err := os.Stat(filepath.Join(xdgConfig, "irecall", config.PreferredRootFileName)); err != nil { + t.Fatalf("preferred root marker missing from isolated config dir: %v", err) + } } func TestApplyRuntimeProviderInitializesMissingSettings(t *testing.T) { t.Parallel() root := filepath.Join(t.TempDir(), "runtime-provider") - app, err := NewApp(root) + app, err := NewAppWithOptions(root, AppOptions{}) if err != nil { - t.Fatalf("NewApp() error = %v", err) + t.Fatalf("NewAppWithOptions() error = %v", err) } t.Cleanup(func() { app.Shutdown(context.Background()) }) @@ -410,9 +414,9 @@ func TestApplyRuntimeProviderInitializesMissingSettings(t *testing.T) { func TestDesktopBackendUpdateAndDeleteQuotes(t *testing.T) { t.Parallel() - app, err := NewApp(filepath.Join(t.TempDir(), "desktop-update-delete")) + app, err := NewAppWithOptions(filepath.Join(t.TempDir(), "desktop-update-delete"), AppOptions{}) if err != nil { - t.Fatalf("NewApp() error = %v", err) + t.Fatalf("NewAppWithOptions() error = %v", err) } t.Cleanup(func() { app.Shutdown(context.Background()) }) @@ -452,9 +456,9 @@ func TestDesktopBackendUpdateAndDeleteQuotes(t *testing.T) { func TestDesktopBackendPasswordAndAPITokenHelpers(t *testing.T) { t.Parallel() - app, err := NewApp(filepath.Join(t.TempDir(), "desktop-auth-helpers")) + app, err := NewAppWithOptions(filepath.Join(t.TempDir(), "desktop-auth-helpers"), AppOptions{}) if err != nil { - t.Fatalf("NewApp() error = %v", err) + t.Fatalf("NewAppWithOptions() error = %v", err) } t.Cleanup(func() { app.Shutdown(context.Background()) }) @@ -512,9 +516,9 @@ func TestDesktopBackendPasswordAndAPITokenHelpers(t *testing.T) { func TestDesktopBackendGetUserProfileReflectsSavedProfile(t *testing.T) { t.Parallel() - app, err := NewApp(filepath.Join(t.TempDir(), "desktop-profile")) + app, err := NewAppWithOptions(filepath.Join(t.TempDir(), "desktop-profile"), AppOptions{}) if err != nil { - t.Fatalf("NewApp() error = %v", err) + t.Fatalf("NewAppWithOptions() error = %v", err) } t.Cleanup(func() { app.Shutdown(context.Background()) }) @@ -539,9 +543,9 @@ func TestDesktopBackendGetUserProfileReflectsSavedProfile(t *testing.T) { func TestDesktopBackendRunRecallWithMockLLM(t *testing.T) { t.Parallel() - app, err := NewApp(filepath.Join(t.TempDir(), "desktop-run-recall")) + app, err := NewAppWithOptions(filepath.Join(t.TempDir(), "desktop-run-recall"), AppOptions{}) if err != nil { - t.Fatalf("NewApp() error = %v", err) + t.Fatalf("NewAppWithOptions() error = %v", err) } t.Cleanup(func() { app.Shutdown(context.Background()) }) @@ -575,3 +579,33 @@ func TestDesktopBackendRunRecallWithMockLLM(t *testing.T) { t.Fatalf("response = %q, want joined mock quote content", result.Response) } } + +func TestDesktopBackendCanSkipPreferredRootPersistence(t *testing.T) { + xdgConfig := filepath.Join(t.TempDir(), "xdg-config") + t.Setenv("XDG_CONFIG_HOME", xdgConfig) + + sourceRoot := filepath.Join(t.TempDir(), "source") + targetRoot := filepath.Join(t.TempDir(), "target") + app, err := NewAppWithOptions(sourceRoot, AppOptions{}) + if err != nil { + t.Fatalf("NewAppWithOptions() error = %v", err) + } + t.Cleanup(func() { app.Shutdown(context.Background()) }) + + settings := *app.GetSettings() + settings.RootDir = targetRoot + if _, err := app.SaveSettings(settings); err != nil { + t.Fatalf("SaveSettings() error = %v", err) + } + + preferredRoot, err := config.LoadPreferredRootPath() + if err != nil { + t.Fatalf("LoadPreferredRootPath() error = %v", err) + } + if preferredRoot != "" { + t.Fatalf("preferred root = %q, want empty when app persistence disabled", preferredRoot) + } + if _, err := os.Stat(filepath.Join(xdgConfig, "irecall", config.PreferredRootFileName)); !os.IsNotExist(err) { + t.Fatalf("preferred root marker exists or stat failed: %v", err) + } +} diff --git a/app/runtime.go b/app/runtime.go index 5a6f2ac..0e751ca 100644 --- a/app/runtime.go +++ b/app/runtime.go @@ -64,6 +64,14 @@ func OpenRuntime(root string) (*RuntimeState, error) { } func SwitchRuntime(current *RuntimeState, nextSettings *core.Settings) (*RuntimeState, error) { + return SwitchRuntimeWithOptions(current, nextSettings, RuntimeSwitchOptions{PersistPreferredRoot: true}) +} + +type RuntimeSwitchOptions struct { + PersistPreferredRoot bool +} + +func SwitchRuntimeWithOptions(current *RuntimeState, nextSettings *core.Settings, opts RuntimeSwitchOptions) (*RuntimeState, error) { if current == nil || current.Engine == nil { return nil, fmt.Errorf("runtime is not initialized") } @@ -83,8 +91,10 @@ func SwitchRuntime(current *RuntimeState, nextSettings *core.Settings) (*Runtime if err := current.Engine.SaveSettings(context.Background(), nextSettings); err != nil { return nil, err } - if err := config.SavePreferredRootPath(nextRoot); err != nil { - return nil, fmt.Errorf("persist preferred root: %w", err) + if opts.PersistPreferredRoot { + if err := config.SavePreferredRootPath(nextRoot); err != nil { + return nil, fmt.Errorf("persist preferred root: %w", err) + } } current.Settings = nextSettings current.Paths.RootDir = nextRoot @@ -129,9 +139,11 @@ func SwitchRuntime(current *RuntimeState, nextSettings *core.Settings) (*Runtime } nextRuntime.Settings = nextSettings - if err := config.SavePreferredRootPath(nextRoot); err != nil { - _ = nextRuntime.Engine.Close() - return restoreOnError(fmt.Errorf("persist preferred root: %w", err)) + if opts.PersistPreferredRoot { + if err := config.SavePreferredRootPath(nextRoot); err != nil { + _ = nextRuntime.Engine.Close() + return restoreOnError(fmt.Errorf("persist preferred root: %w", err)) + } } return nextRuntime, nil diff --git a/app/runtime_test.go b/app/runtime_test.go index 6f5df97..4f56df4 100644 --- a/app/runtime_test.go +++ b/app/runtime_test.go @@ -119,6 +119,30 @@ func TestSwitchRuntimeUsesExistingTargetWithoutCopyingSourceData(t *testing.T) { } } +func TestSwitchRuntimeCanSkipPreferredRootPersistence(t *testing.T) { + t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "xdg-config")) + + current := openRuntimeForTest(t, filepath.Join(t.TempDir(), "current")) + defer func() { _ = current.Engine.Close() }() + + next := *current.Settings + next.RootDir = filepath.Join(t.TempDir(), "target") + + switched, err := SwitchRuntimeWithOptions(current, &next, RuntimeSwitchOptions{PersistPreferredRoot: false}) + if err != nil { + t.Fatalf("SwitchRuntimeWithOptions() error = %v", err) + } + defer func() { _ = switched.Engine.Close() }() + + preferredRoot, err := config.LoadPreferredRootPath() + if err != nil { + t.Fatalf("LoadPreferredRootPath() error = %v", err) + } + if preferredRoot != "" { + t.Fatalf("preferred root = %q, want empty when persistence disabled", preferredRoot) + } +} + func TestSwitchRuntimeRestoresOriginalRuntimeOnCopyFailure(t *testing.T) { sourceRoot := filepath.Join(t.TempDir(), "source") seedQuoteForRuntimeTest(t, sourceRoot, "restore me") @@ -291,9 +315,9 @@ func openRuntimeForTest(t *testing.T, root string) *RuntimeState { func seedQuoteForRuntimeTest(t *testing.T, root, content string) { t.Helper() - app, err := NewApp(root) + app, err := NewAppWithOptions(root, AppOptions{}) if err != nil { - t.Fatalf("NewApp(%q) error = %v", root, err) + t.Fatalf("NewAppWithOptions(%q) error = %v", root, err) } if _, err := app.AddQuote(content); err != nil { _ = app.engine.Close() diff --git a/web/auth_cli_test.go b/web/auth_cli_test.go index e6a433a..38b7168 100644 --- a/web/auth_cli_test.go +++ b/web/auth_cli_test.go @@ -46,9 +46,9 @@ func TestAuthCommandIssueTokenWithoutWebPasswordAndWritesTokenFile(t *testing.T) t.Fatalf("token file mode = %o, want 600", got) } - runtimeApp, err := irecallapp.NewApp(root) + runtimeApp, err := irecallapp.NewAppWithOptions(root, irecallapp.AppOptions{}) if err != nil { - t.Fatalf("NewApp() error = %v", err) + t.Fatalf("NewAppWithOptions() error = %v", err) } defer runtimeApp.Shutdown(context.Background()) ok, err := runtimeApp.VerifyAPIToken(token) @@ -75,9 +75,9 @@ func TestAuthCommandRotateAndRevokeTokenWithoutWebPassword(t *testing.T) { t.Fatalf("rotated token matched first token") } - runtimeApp, err := irecallapp.NewApp(root) + runtimeApp, err := irecallapp.NewAppWithOptions(root, irecallapp.AppOptions{}) if err != nil { - t.Fatalf("NewApp() error = %v", err) + t.Fatalf("NewAppWithOptions() error = %v", err) } ok, err := runtimeApp.VerifyAPIToken(firstToken) if err != nil { @@ -99,9 +99,9 @@ func TestAuthCommandRotateAndRevokeTokenWithoutWebPassword(t *testing.T) { if err := runAuthCommand([]string{"revoke-token", "--data-path", root}, strings.NewReader(""), &stdout); err != nil { t.Fatalf("runAuthCommand(revoke-token) error = %v", err) } - runtimeApp, err = irecallapp.NewApp(root) + runtimeApp, err = irecallapp.NewAppWithOptions(root, irecallapp.AppOptions{}) if err != nil { - t.Fatalf("NewApp(after revoke) error = %v", err) + t.Fatalf("NewAppWithOptions(after revoke) error = %v", err) } defer runtimeApp.Shutdown(context.Background()) ok, err = runtimeApp.VerifyAPIToken(secondToken) @@ -169,9 +169,9 @@ func TestAuthCommandTokenStatusWithConfiguredToken(t *testing.T) { t.Fatalf("token-status output = %q, want configured", output) } - runtimeApp, err := irecallapp.NewApp(root) + runtimeApp, err := irecallapp.NewAppWithOptions(root, irecallapp.AppOptions{}) if err != nil { - t.Fatalf("NewApp() error = %v", err) + t.Fatalf("NewAppWithOptions() error = %v", err) } defer runtimeApp.Shutdown(context.Background()) status, err := runtimeApp.GetAPITokenStatus() diff --git a/web/main_test.go b/web/main_test.go index 32d74e7..9633793 100644 --- a/web/main_test.go +++ b/web/main_test.go @@ -88,9 +88,9 @@ func TestServerOptionsValidateRejectsConflictingFlags(t *testing.T) { } func TestEnsureWebPasswordConfiguredRequiresPasswordInNormalMode(t *testing.T) { - runtimeApp, err := irecallapp.NewApp(t.TempDir()) + runtimeApp, err := irecallapp.NewAppWithOptions(t.TempDir(), irecallapp.AppOptions{}) if err != nil { - t.Fatalf("NewApp() error = %v", err) + t.Fatalf("NewAppWithOptions() error = %v", err) } t.Cleanup(func() { runtimeApp.Shutdown(context.Background()) }) @@ -110,9 +110,9 @@ func TestEnsureWebPasswordConfiguredRequiresPasswordInNormalMode(t *testing.T) { } func TestEnsureWebPasswordConfiguredAllowsExistingPassword(t *testing.T) { - runtimeApp, err := irecallapp.NewApp(t.TempDir()) + runtimeApp, err := irecallapp.NewAppWithOptions(t.TempDir(), irecallapp.AppOptions{}) if err != nil { - t.Fatalf("NewApp() error = %v", err) + t.Fatalf("NewAppWithOptions() error = %v", err) } t.Cleanup(func() { runtimeApp.Shutdown(context.Background()) }) diff --git a/web/mcp_bootstrap_test.go b/web/mcp_bootstrap_test.go index b59b53a..0b6e560 100644 --- a/web/mcp_bootstrap_test.go +++ b/web/mcp_bootstrap_test.go @@ -43,9 +43,9 @@ func TestOperatorBootstrapIssuesTokenAndMCPHealthChecksRealWebServer(t *testing. t.Fatalf("stdout leaked full token: %q", stdout.String()) } - runtimeApp, err := irecallapp.NewApp(root) + runtimeApp, err := irecallapp.NewAppWithOptions(root, irecallapp.AppOptions{}) if err != nil { - t.Fatalf("NewApp() error = %v", err) + t.Fatalf("NewAppWithOptions() error = %v", err) } t.Cleanup(func() { runtimeApp.Shutdown(context.Background()) }) settings := *runtimeApp.GetSettings() diff --git a/web/provider_startup_test.go b/web/provider_startup_test.go index ed55b1e..8e8036b 100644 --- a/web/provider_startup_test.go +++ b/web/provider_startup_test.go @@ -215,9 +215,9 @@ func TestApplyAPIOnlyProviderStartupConfigOverridesRuntimeWithoutPersisting(t *t t.Setenv("XDG_CONFIG_HOME", filepath.Join(t.TempDir(), "xdg-config")) root := filepath.Join(t.TempDir(), "runtime-provider") - runtimeApp, err := irecallapp.NewApp(root) + runtimeApp, err := irecallapp.NewAppWithOptions(root, irecallapp.AppOptions{}) if err != nil { - t.Fatalf("NewApp() error = %v", err) + t.Fatalf("NewAppWithOptions() error = %v", err) } savedSettings := *runtimeApp.GetSettings() @@ -266,9 +266,9 @@ func TestApplyAPIOnlyProviderStartupConfigOverridesRuntimeWithoutPersisting(t *t runtimeApp.Shutdown(context.Background()) - reopened, err := irecallapp.NewApp(root) + reopened, err := irecallapp.NewAppWithOptions(root, irecallapp.AppOptions{}) if err != nil { - t.Fatalf("NewApp(reopen) error = %v", err) + t.Fatalf("NewAppWithOptions(reopen) error = %v", err) } t.Cleanup(func() { reopened.Shutdown(context.Background()) }) diff --git a/web/server_test.go b/web/server_test.go index f8f8256..549d881 100644 --- a/web/server_test.go +++ b/web/server_test.go @@ -697,9 +697,9 @@ func TestHandleRunRecallViaBearerAuth(t *testing.T) { func newTestApp(t *testing.T) *irecallapp.App { t.Helper() - app, err := irecallapp.NewApp(t.TempDir()) + app, err := irecallapp.NewAppWithOptions(t.TempDir(), irecallapp.AppOptions{}) if err != nil { - t.Fatalf("NewApp() error = %v", err) + t.Fatalf("NewAppWithOptions() error = %v", err) } t.Cleanup(func() { app.Shutdown(context.Background())