1067 lines
32 KiB
Go
1067 lines
32 KiB
Go
package config
|
|
|
|
import (
|
|
"os"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
// legacyHome points HOME / config-dir / .env resolution at a fresh temp tree and
|
|
// returns the legacy config.json path and the v1+ dest config path.
|
|
func legacyHome(t *testing.T) (src, dest, home string) {
|
|
t.Helper()
|
|
home = t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
|
|
t.Setenv("USERPROFILE", home) // os.UserHomeDir on Windows
|
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) // os.UserConfigDir on Linux
|
|
t.Setenv("AppData", filepath.Join(home, "AppData")) // os.UserConfigDir on Windows
|
|
return filepath.Join(home, ".reasonix", "config.json"), userConfigPath(), home
|
|
}
|
|
|
|
func writeLegacy(t *testing.T, src, body string) {
|
|
t.Helper()
|
|
if err := os.MkdirAll(filepath.Dir(src), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(src, []byte(body), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
func TestMigrateImportsKeyPluginsAndLang(t *testing.T) {
|
|
src, dest, home := legacyHome(t)
|
|
writeLegacy(t, src, `{
|
|
"apiKey": "sk-legacy-123",
|
|
"model": "deepseek-v4-pro",
|
|
"lang": "zh",
|
|
"mcpServers": {
|
|
"fs": {"command": "npx", "args": ["-y", "server-fs"], "type": "stdio"},
|
|
"stripe": {"type": "http", "url": "https://mcp.stripe.com", "disabled": true}
|
|
},
|
|
"mcpEnv": {"fs": {"ROOT": "/tmp"}}
|
|
}`)
|
|
|
|
res, err := MigrateLegacyIfNeeded()
|
|
if err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
if res == nil {
|
|
t.Fatal("expected a migration result")
|
|
} else if !res.KeyToEnv || res.Plugins != 2 {
|
|
t.Errorf("result = %+v, want KeyToEnv=true Plugins=2", res)
|
|
}
|
|
|
|
envData, err := os.ReadFile(UserCredentialsPath())
|
|
if err != nil {
|
|
t.Fatalf("read credentials: %v", err)
|
|
}
|
|
if !strings.Contains(string(envData), "DEEPSEEK_API_KEY=sk-legacy-123") {
|
|
t.Errorf("credentials missing key: %q", envData)
|
|
}
|
|
if _, err := os.Stat(filepath.Join(home, ".env")); !os.IsNotExist(err) {
|
|
t.Errorf("migration must not write the user's ~/.env, stat err=%v", err)
|
|
}
|
|
|
|
got, err := os.ReadFile(dest)
|
|
if err != nil {
|
|
t.Fatalf("read dest config: %v", err)
|
|
}
|
|
toml := string(got)
|
|
for _, want := range []string{`language = "zh"`, `[desktop]`, `language = "zh"`, `name = "fs"`, `name = "stripe"`, `type = "http"`, `auto_start = false`} {
|
|
if !strings.Contains(toml, want) {
|
|
t.Errorf("dest config missing %q:\n%s", want, toml)
|
|
}
|
|
}
|
|
if !strings.Contains(toml, `default_model = "deepseek-pro/deepseek-v4-pro"`) {
|
|
t.Errorf("dest config missing imported model:\n%s", toml)
|
|
}
|
|
|
|
loaded, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
if loaded.DefaultModel != "deepseek-pro/deepseek-v4-pro" {
|
|
t.Errorf("DefaultModel = %q, want deepseek-pro/deepseek-v4-pro", loaded.DefaultModel)
|
|
}
|
|
|
|
if _, err := os.Stat(src); err != nil {
|
|
t.Errorf("legacy file must be left untouched: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMigrateImportsLegacyQQConfig(t *testing.T) {
|
|
src, dest, _ := legacyHome(t)
|
|
writeLegacy(t, src, `{
|
|
"qq": {
|
|
"enabled": true,
|
|
"appId": "qq-app-id",
|
|
"appSecret": "qq-secret",
|
|
"sandbox": true,
|
|
"ownerOpenId": " owner-openid ",
|
|
"allowlist": ["owner-openid", " member-openid ", "member-openid", ""]
|
|
}
|
|
}`)
|
|
|
|
res, err := MigrateLegacyIfNeeded()
|
|
if err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
if res == nil {
|
|
t.Fatal("expected migration result")
|
|
}
|
|
envData, err := os.ReadFile(UserCredentialsPath())
|
|
if err != nil {
|
|
t.Fatalf("read credentials: %v", err)
|
|
}
|
|
if !strings.Contains(string(envData), "QQ_BOT_APP_SECRET=qq-secret") {
|
|
t.Fatalf("credentials missing QQ secret: %q", envData)
|
|
}
|
|
tomlData, err := os.ReadFile(dest)
|
|
if err != nil {
|
|
t.Fatalf("read dest: %v", err)
|
|
}
|
|
toml := string(tomlData)
|
|
for _, want := range []string{
|
|
`[bot.qq]`,
|
|
`enabled = true`,
|
|
`app_id = "qq-app-id"`,
|
|
`app_secret_env = "QQ_BOT_APP_SECRET"`,
|
|
`sandbox = true`,
|
|
`qq_users = ["owner-openid", "member-openid"]`,
|
|
} {
|
|
if !strings.Contains(toml, want) {
|
|
t.Fatalf("migrated config missing %q:\n%s", want, toml)
|
|
}
|
|
}
|
|
if strings.Contains(toml, "qq-secret") {
|
|
t.Fatalf("migrated TOML must not contain QQ secret:\n%s", toml)
|
|
}
|
|
}
|
|
|
|
// TestMigrateImportsLegacyMCPStringList covers the pre-mcpServers `mcp` format
|
|
// (#3949): `--mcp`-style strings, with mcpEnv/mcpDisabled keyed by name and
|
|
// mcpServers winning a name collision.
|
|
func TestMigrateImportsLegacyMCPStringList(t *testing.T) {
|
|
src, _, _ := legacyHome(t)
|
|
writeLegacy(t, src, `{
|
|
"mcp": [
|
|
"memory=npx -y @modelcontextprotocol/server-memory",
|
|
"search=https://mcp.example.com/sse",
|
|
"stream=streamable+https://mcp.example.com/http",
|
|
"fs=node old-fs.js",
|
|
"off=npx -y server-off"
|
|
],
|
|
"mcpServers": {"fs": {"command": "npx", "args": ["-y", "server-fs"]}},
|
|
"mcpEnv": {"memory": {"MEMORY_PATH": "/tmp/mem"}},
|
|
"mcpDisabled": ["off"]
|
|
}`)
|
|
|
|
if _, err := MigrateLegacyIfNeeded(); err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
byName := map[string]PluginEntry{}
|
|
for _, p := range cfg.Plugins {
|
|
byName[p.Name] = p
|
|
}
|
|
mem := byName["memory"]
|
|
if mem.Command != "npx" || len(mem.Args) != 2 || mem.Args[1] != "@modelcontextprotocol/server-memory" {
|
|
t.Errorf("memory spec not parsed: %+v", mem)
|
|
}
|
|
if mem.Env["MEMORY_PATH"] != "/tmp/mem" {
|
|
t.Errorf("mcpEnv not applied to memory: %+v", mem.Env)
|
|
}
|
|
if s := byName["search"]; s.Type != "sse" || s.URL != "https://mcp.example.com/sse" {
|
|
t.Errorf("plain URL should migrate as SSE: %+v", s)
|
|
}
|
|
if s := byName["stream"]; s.Type != "http" || s.URL != "https://mcp.example.com/http" {
|
|
t.Errorf("streamable+ URL should migrate as http: %+v", s)
|
|
}
|
|
if fs := byName["fs"]; len(fs.Args) != 2 || fs.Args[1] != "server-fs" {
|
|
t.Errorf("mcpServers should win the fs name collision: %+v", fs)
|
|
}
|
|
if off := byName["off"]; off.AutoStart == nil || *off.AutoStart {
|
|
t.Errorf("mcpDisabled entry should migrate with auto_start=false: %+v", off)
|
|
}
|
|
if len(cfg.Plugins) != 5 {
|
|
t.Errorf("got %d plugins, want 5: %+v", len(cfg.Plugins), cfg.Plugins)
|
|
}
|
|
}
|
|
|
|
func TestMigrateRoundTripsThroughLoad(t *testing.T) {
|
|
src, _, _ := legacyHome(t)
|
|
writeLegacy(t, src, `{"apiKey":"sk-x","mcpServers":{"fs":{"command":"npx","env":{"A":"1"}}}}`)
|
|
|
|
if _, err := MigrateLegacyIfNeeded(); err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("load: %v", err)
|
|
}
|
|
if len(cfg.Plugins) != 1 || cfg.Plugins[0].Name != "fs" || cfg.Plugins[0].Command != "npx" {
|
|
t.Errorf("plugins did not round-trip through Load: %+v", cfg.Plugins)
|
|
}
|
|
if cfg.Plugins[0].Env["A"] != "1" {
|
|
t.Errorf("plugin env lost: %+v", cfg.Plugins[0].Env)
|
|
}
|
|
}
|
|
|
|
func TestMigrateSkipsWhenDestExists(t *testing.T) {
|
|
src, dest, _ := legacyHome(t)
|
|
writeLegacy(t, src, `{"apiKey":"sk-x"}`)
|
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(dest, []byte("default_model = \"deepseek-flash\"\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
res, err := MigrateLegacyIfNeeded()
|
|
if err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
if res != nil {
|
|
t.Errorf("must not migrate over an existing v1+ config, got %+v", res)
|
|
}
|
|
}
|
|
|
|
func TestMigrateMCPToUserConfigOnUpgradeCollectsKnownSources(t *testing.T) {
|
|
srcJSON, dest, _ := legacyHome(t)
|
|
writeLegacy(t, srcJSON, `{
|
|
"mcpServers": {
|
|
"legacy-json": {"command": "legacy-json-bin"},
|
|
"disabled-json": {"command": "disabled-json-bin", "disabled": true},
|
|
"project-json": {"command": "legacy-json-should-not-win"},
|
|
"global": {"command": "legacy-should-not-win"}
|
|
}
|
|
}`)
|
|
writeLegacy(t, filepath.Join(filepath.Dir(dest), "reasonix.toml"), `
|
|
[[plugins]]
|
|
name = "legacy-toml"
|
|
command = "legacy-toml-bin"
|
|
`)
|
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(dest, []byte(`
|
|
[[plugins]]
|
|
name = "global"
|
|
command = "global-bin"
|
|
`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
projectTOML := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(projectTOML, "reasonix.toml"), []byte(`
|
|
[[plugins]]
|
|
name = "project-toml"
|
|
command = "project-toml-bin"
|
|
|
|
[[plugins]]
|
|
name = "global"
|
|
command = "project-should-not-win"
|
|
`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
projectJSON := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(projectJSON, ".mcp.json"), []byte(`{
|
|
"mcpServers": {
|
|
"project-json": {"command": "project-json-bin"}
|
|
}
|
|
}`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
res, err := MigrateMCPToUserConfigOnUpgrade([]string{projectTOML, projectJSON, projectTOML})
|
|
if err != nil {
|
|
t.Fatalf("MigrateMCPToUserConfigOnUpgrade: %v", err)
|
|
}
|
|
if res == nil || res.Added != 5 {
|
|
t.Fatalf("migration result = %+v, want 5 added", res)
|
|
}
|
|
cfg := LoadForEdit(dest)
|
|
byName := map[string]PluginEntry{}
|
|
for _, p := range cfg.Plugins {
|
|
byName[p.Name] = p
|
|
}
|
|
for name, command := range map[string]string{
|
|
"global": "global-bin",
|
|
"disabled-json": "disabled-json-bin",
|
|
"legacy-toml": "legacy-toml-bin",
|
|
"legacy-json": "legacy-json-bin",
|
|
"project-toml": "project-toml-bin",
|
|
"project-json": "project-json-bin",
|
|
} {
|
|
if byName[name].Command != command {
|
|
t.Fatalf("%s command = %q, want %q; plugins=%+v", name, byName[name].Command, command, cfg.Plugins)
|
|
}
|
|
}
|
|
if p := byName["disabled-json"]; p.AutoStart == nil || *p.AutoStart {
|
|
t.Fatalf("disabled legacy MCP should migrate with auto_start=false: %+v", p)
|
|
}
|
|
if _, err := os.Stat(mcpGlobalMigrationMarkerPath()); err != nil {
|
|
t.Fatalf("migration marker missing: %v", err)
|
|
}
|
|
|
|
lateProject := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(lateProject, "reasonix.toml"), []byte(`
|
|
[[plugins]]
|
|
name = "late"
|
|
command = "late-bin"
|
|
`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
res, err = MigrateMCPToUserConfigOnUpgrade([]string{lateProject})
|
|
if err != nil {
|
|
t.Fatalf("second migration: %v", err)
|
|
}
|
|
if res != nil {
|
|
t.Fatalf("second migration result = %+v, want nil due marker", res)
|
|
}
|
|
if got := LoadForEdit(dest); len(got.Plugins) != len(cfg.Plugins) {
|
|
t.Fatalf("second migration changed plugins: %+v", got.Plugins)
|
|
}
|
|
}
|
|
|
|
func TestMCPMigrationMarkerMakesCurrentConfigAuthoritativeAfterRemoval(t *testing.T) {
|
|
src, dest, _ := legacyHome(t)
|
|
writeLegacy(t, src, `{
|
|
"mcpServers": {
|
|
"legacy-only": {"command": "legacy-mcp-bin"}
|
|
}
|
|
}`)
|
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(dest, []byte("config_version = 1\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
res, err := MigrateMCPToUserConfigOnUpgrade(nil)
|
|
if err != nil {
|
|
t.Fatalf("MigrateMCPToUserConfigOnUpgrade: %v", err)
|
|
}
|
|
if res == nil || res.Added != 1 {
|
|
t.Fatalf("migration result = %+v, want one imported MCP", res)
|
|
}
|
|
|
|
removed, err := RemovePluginFromSourcesForRoot(t.TempDir(), "legacy-only")
|
|
if err != nil {
|
|
t.Fatalf("RemovePluginFromSourcesForRoot: %v", err)
|
|
}
|
|
if !removed {
|
|
t.Fatal("RemovePluginFromSourcesForRoot reported no removal")
|
|
}
|
|
|
|
loaded, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("Load after removal: %v", err)
|
|
}
|
|
for _, p := range loaded.Plugins {
|
|
if p.Name == "legacy-only" {
|
|
t.Fatalf("removed MCP was resurrected from legacy config: %+v", loaded.Plugins)
|
|
}
|
|
}
|
|
if got := loadLegacyMCP(src); len(got) != 0 {
|
|
t.Fatalf("an older runtime would resurrect the removed legacy MCP: %+v", got)
|
|
}
|
|
legacyRaw, err := os.ReadFile(src)
|
|
if err != nil {
|
|
t.Fatalf("read legacy source: %v", err)
|
|
}
|
|
if !strings.Contains(string(legacyRaw), `"legacy-only"`) || !strings.Contains(string(legacyRaw), `"mcpDisabled"`) {
|
|
t.Fatalf("legacy source should retain the server and add a compatibility disable marker:\n%s", legacyRaw)
|
|
}
|
|
}
|
|
|
|
func TestMigrateMCPToUserConfigOnUpgradeDoesNotMarkEmptyScan(t *testing.T) {
|
|
_, _, _ = legacyHome(t)
|
|
res, err := MigrateMCPToUserConfigOnUpgrade(nil)
|
|
if err != nil {
|
|
t.Fatalf("MigrateMCPToUserConfigOnUpgrade: %v", err)
|
|
}
|
|
if res != nil {
|
|
t.Fatalf("result = %+v, want nil", res)
|
|
}
|
|
if _, err := os.Stat(mcpGlobalMigrationMarkerPath()); !os.IsNotExist(err) {
|
|
t.Fatalf("empty scan should not write marker, stat err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestMigrateMCPToUserConfigOnUpgradeRefusesMalformedGlobalConfig(t *testing.T) {
|
|
srcJSON, dest, _ := legacyHome(t)
|
|
writeLegacy(t, srcJSON, `{
|
|
"mcpServers": {
|
|
"legacy-json": {"command": "legacy-json-bin"}
|
|
}
|
|
}`)
|
|
const malformed = "[[plugins]\nname = \"broken\"\n"
|
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(dest, []byte(malformed), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
res, err := MigrateMCPToUserConfigOnUpgrade(nil)
|
|
if err == nil {
|
|
t.Fatal("expected malformed global config to abort MCP migration")
|
|
}
|
|
if res != nil {
|
|
t.Fatalf("result = %+v, want nil", res)
|
|
}
|
|
if got, readErr := os.ReadFile(dest); readErr != nil {
|
|
t.Fatalf("read dest: %v", readErr)
|
|
} else if string(got) != malformed {
|
|
t.Fatalf("malformed config was overwritten:\n%s", got)
|
|
}
|
|
if _, statErr := os.Stat(mcpGlobalMigrationMarkerPath()); !os.IsNotExist(statErr) {
|
|
t.Fatalf("failed migration must not write marker, stat err=%v", statErr)
|
|
}
|
|
}
|
|
|
|
func TestMigrateMCPToUserConfigOnUpgradePreservesConfigVersion(t *testing.T) {
|
|
_, dest, _ := legacyHome(t)
|
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(dest, []byte("config_version = 1\n"), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
project := t.TempDir()
|
|
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
|
[[plugins]]
|
|
name = "project"
|
|
command = "project-bin"
|
|
`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
res, err := MigrateMCPToUserConfigOnUpgrade([]string{project})
|
|
if err != nil {
|
|
t.Fatalf("MigrateMCPToUserConfigOnUpgrade: %v", err)
|
|
}
|
|
if res == nil || res.Added != 1 {
|
|
t.Fatalf("result = %+v, want 1 added", res)
|
|
}
|
|
got, err := os.ReadFile(dest)
|
|
if err != nil {
|
|
t.Fatalf("read dest: %v", err)
|
|
}
|
|
if !strings.Contains(string(got), "config_version = 1") {
|
|
t.Fatalf("MCP migration should not advance config_version:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestMigrateImportsLegacyV1TOMLBeforeJSON(t *testing.T) {
|
|
srcJSON, dest, _ := legacyHome(t)
|
|
legacyTOML := filepath.Join(filepath.Dir(dest), "reasonix.toml")
|
|
if err := os.MkdirAll(filepath.Dir(legacyTOML), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(legacyTOML, []byte(`
|
|
default_model = "deepseek-flash"
|
|
language = "en"
|
|
|
|
[ui]
|
|
theme = "light"
|
|
theme_style = "glacier"
|
|
close_behavior = "quit"
|
|
|
|
[[plugins]]
|
|
name = "legacy-v1"
|
|
command = "legacy-bin"
|
|
`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeLegacy(t, srcJSON, `{"apiKey":"sk-json-should-not-win"}`)
|
|
|
|
res, err := MigrateLegacyIfNeeded()
|
|
if err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
if res == nil || res.From != legacyTOML {
|
|
t.Fatalf("expected v1 TOML migration, got %+v", res)
|
|
}
|
|
|
|
got, err := os.ReadFile(dest)
|
|
if err != nil {
|
|
t.Fatalf("read migrated config: %v", err)
|
|
}
|
|
text := string(got)
|
|
for _, want := range []string{`config_version = 4`, `[desktop]`, `close_behavior = "quit"`, `name = "legacy-v1"`} {
|
|
if !strings.Contains(text, want) {
|
|
t.Fatalf("migrated TOML missing %q:\n%s", want, text)
|
|
}
|
|
}
|
|
if _, err := os.Stat(UserCredentialsPath()); !os.IsNotExist(err) {
|
|
t.Fatalf("v1 TOML migration should not import lower-priority JSON key, credentials stat err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestMigrateImportsLegacyV1HomeTOMLBeforeJSON(t *testing.T) {
|
|
srcJSON, dest, home := legacyHome(t)
|
|
legacyTOML := filepath.Join(home, ".reasonix", "reasonix.toml")
|
|
if err := os.MkdirAll(filepath.Dir(legacyTOML), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(legacyTOML, []byte(`
|
|
default_model = "deepseek-flash"
|
|
|
|
[[plugins]]
|
|
name = "legacy-home-v1"
|
|
command = "legacy-home-bin"
|
|
`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
writeLegacy(t, srcJSON, `{"apiKey":"sk-json-should-not-win","mcpServers":{"json":{"command":"json-bin"}}}`)
|
|
|
|
res, err := MigrateLegacyIfNeeded()
|
|
if err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
if res == nil || res.From != legacyTOML {
|
|
t.Fatalf("expected home v1 TOML migration, got %+v", res)
|
|
}
|
|
|
|
got, err := os.ReadFile(dest)
|
|
if err != nil {
|
|
t.Fatalf("read migrated config: %v", err)
|
|
}
|
|
text := string(got)
|
|
if !strings.Contains(text, `name = "legacy-home-v1"`) {
|
|
t.Fatalf("home v1 plugin was not migrated:\n%s", text)
|
|
}
|
|
if strings.Contains(text, `name = "json"`) {
|
|
t.Fatalf("lower-priority v0.5 JSON should not be merged when v1 TOML exists:\n%s", text)
|
|
}
|
|
}
|
|
|
|
func TestLoadFallsBackToLegacyOSConfigWhenPrimaryMissing(t *testing.T) {
|
|
_, dest, _ := legacyHome(t)
|
|
legacy := legacyUserConfigPath()
|
|
if legacy == "" {
|
|
t.Skip("legacy OS config path matches primary path on this platform")
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(legacy), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(legacy, []byte(`default_model = "legacy-provider/legacy-model"`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if source := SourcePath(); source != legacy {
|
|
t.Fatalf("SourcePath() = %q, want legacy path %q", source, legacy)
|
|
}
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if cfg.DefaultModel != "legacy-provider/legacy-model" {
|
|
t.Fatalf("DefaultModel = %q, want legacy value", cfg.DefaultModel)
|
|
}
|
|
if _, err := os.Stat(dest); !os.IsNotExist(err) {
|
|
t.Fatalf("Load fallback should not create primary config, stat err=%v", err)
|
|
}
|
|
}
|
|
|
|
func TestLoadPrefersPrimaryConfigOverLegacyOSConfig(t *testing.T) {
|
|
_, dest, _ := legacyHome(t)
|
|
legacy := legacyUserConfigPath()
|
|
if legacy == "" {
|
|
t.Skip("legacy OS config path matches primary path on this platform")
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(dest, []byte(`default_model = "primary-provider/primary-model"`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(legacy), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(legacy, []byte(`default_model = "legacy-provider/legacy-model"`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if source := SourcePath(); source != dest {
|
|
t.Fatalf("SourcePath() = %q, want primary path %q", source, dest)
|
|
}
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("Load: %v", err)
|
|
}
|
|
if cfg.DefaultModel != "primary-provider/primary-model" {
|
|
t.Fatalf("DefaultModel = %q, want primary value", cfg.DefaultModel)
|
|
}
|
|
}
|
|
|
|
func TestMigrateImportsLegacyOSConfigToPrimaryConfig(t *testing.T) {
|
|
_, dest, _ := legacyHome(t)
|
|
legacy := legacyUserConfigPath()
|
|
if legacy == "" {
|
|
t.Skip("legacy OS config path matches primary path on this platform")
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(legacy), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(legacy, []byte(`
|
|
default_model = "legacy-provider/legacy-model"
|
|
|
|
[[plugins]]
|
|
name = "legacy-os"
|
|
command = "legacy-os-bin"
|
|
`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
res, err := MigrateLegacyIfNeeded()
|
|
if err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
if res == nil || res.From != legacy || res.To != dest {
|
|
t.Fatalf("migration result = %+v, want %s -> %s", res, legacy, dest)
|
|
}
|
|
got, err := os.ReadFile(dest)
|
|
if err != nil {
|
|
t.Fatalf("read migrated config: %v", err)
|
|
}
|
|
if !strings.Contains(string(got), `name = "legacy-os"`) {
|
|
t.Fatalf("dest missing legacy plugin:\n%s", got)
|
|
}
|
|
if _, err := os.Stat(legacy); err != nil {
|
|
t.Fatalf("legacy file must remain untouched: %v", err)
|
|
}
|
|
}
|
|
|
|
func TestMigrateImportsLegacyXDGConfigToPrimaryConfig(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("legacy XDG paths are Unix-only")
|
|
}
|
|
_, dest, home := legacyHome(t)
|
|
legacy := filepath.Join(home, ".config", "reasonix", "config.toml")
|
|
if samePath(legacy, dest) {
|
|
t.Skip("legacy XDG config path matches primary path on this platform")
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(legacy), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(legacy, []byte(`
|
|
default_model = "legacy-xdg-provider/legacy-xdg-model"
|
|
|
|
[[plugins]]
|
|
name = "legacy-xdg"
|
|
command = "legacy-xdg-bin"
|
|
`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
res, err := MigrateLegacyIfNeeded()
|
|
if err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
if res == nil || res.From != legacy || res.To != dest {
|
|
t.Fatalf("migration result = %+v, want %s -> %s", res, legacy, dest)
|
|
}
|
|
got, err := os.ReadFile(dest)
|
|
if err != nil {
|
|
t.Fatalf("read migrated config: %v", err)
|
|
}
|
|
if !strings.Contains(string(got), `name = "legacy-xdg"`) {
|
|
t.Fatalf("dest missing legacy XDG plugin:\n%s", got)
|
|
}
|
|
}
|
|
|
|
func TestMigrateImportsLegacyCredentialsEvenWhenPrimaryConfigExists(t *testing.T) {
|
|
_, dest, _ := legacyHome(t)
|
|
legacy := legacyUserConfigPath()
|
|
if legacy == "" {
|
|
t.Skip("legacy OS config path matches primary path on this platform")
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(dest, []byte(`default_model = "deepseek-flash/deepseek-chat"`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
legacyCred := filepath.Join(filepath.Dir(legacy), "credentials")
|
|
if err := os.MkdirAll(filepath.Dir(legacyCred), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(legacyCred, []byte("DEEPSEEK_API_KEY=sk-old-creds\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
res, err := MigrateLegacyIfNeeded()
|
|
if err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
if res != nil {
|
|
t.Fatalf("primary config exists, config migration should be skipped, got %+v", res)
|
|
}
|
|
data, err := os.ReadFile(UserCredentialsPath())
|
|
if err != nil {
|
|
t.Fatalf("read migrated credentials: %v", err)
|
|
}
|
|
if string(data) != "DEEPSEEK_API_KEY=sk-old-creds\n" {
|
|
t.Fatalf("migrated credentials = %q", data)
|
|
}
|
|
}
|
|
|
|
func TestMigrateImportsLegacyKeyringCredentials(t *testing.T) {
|
|
legacyHome(t)
|
|
old := legacyKeyringCredentialValueLookup
|
|
legacyKeyringCredentialValueLookup = func(key string) (string, bool) {
|
|
if key == "DEEPSEEK_API_KEY" {
|
|
return "sk-old-keyring", true
|
|
}
|
|
return "", false
|
|
}
|
|
t.Cleanup(func() { legacyKeyringCredentialValueLookup = old })
|
|
|
|
res, err := MigrateLegacyIfNeeded()
|
|
if err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
if res != nil {
|
|
t.Fatalf("no config migration should be needed, got %+v", res)
|
|
}
|
|
data, err := os.ReadFile(UserCredentialsPath())
|
|
if err != nil {
|
|
t.Fatalf("read migrated credentials: %v", err)
|
|
}
|
|
if string(data) != "DEEPSEEK_API_KEY=sk-old-keyring\n" {
|
|
t.Fatalf("migrated credentials = %q", data)
|
|
}
|
|
}
|
|
|
|
func TestMigrateLegacyCredentialsUsesWorkspaceRootForKeyring(t *testing.T) {
|
|
_, dest, _ := legacyHome(t)
|
|
project := t.TempDir()
|
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(dest, []byte(`default_model = "deepseek-flash/deepseek-chat"`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(project, "reasonix.toml"), []byte(`
|
|
default_model = "custom/m"
|
|
[[providers]]
|
|
name = "custom"
|
|
kind = "openai"
|
|
base_url = "https://example.invalid/v1"
|
|
model = "m"
|
|
api_key_env = "WORKSPACE_ONLY_KEY"
|
|
`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
old := legacyKeyringCredentialValueLookup
|
|
legacyKeyringCredentialValueLookup = func(key string) (string, bool) {
|
|
if key == "WORKSPACE_ONLY_KEY" {
|
|
return "sk-workspace", true
|
|
}
|
|
return "", false
|
|
}
|
|
t.Cleanup(func() { legacyKeyringCredentialValueLookup = old })
|
|
|
|
if err := MigrateLegacyCredentialsForRoot(project); err != nil {
|
|
t.Fatalf("MigrateLegacyCredentialsForRoot: %v", err)
|
|
}
|
|
data, err := os.ReadFile(UserCredentialsPath())
|
|
if err != nil {
|
|
t.Fatalf("read migrated credentials: %v", err)
|
|
}
|
|
if string(data) != "WORKSPACE_ONLY_KEY=sk-workspace\n" {
|
|
t.Fatalf("migrated credentials = %q", data)
|
|
}
|
|
}
|
|
|
|
func TestMigrateLegacyCredentialsSkipsKeyringWhenIsolated(t *testing.T) {
|
|
home := t.TempDir()
|
|
isolated := filepath.Join(home, "isolated-home")
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("USERPROFILE", home)
|
|
t.Setenv("AppData", filepath.Join(home, "AppData"))
|
|
t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
|
|
t.Setenv("REASONIX_HOME", isolated)
|
|
t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
|
|
|
|
old := legacyKeyringCredentialValueLookup
|
|
legacyKeyringCredentialValueLookup = func(key string) (string, bool) {
|
|
if key == "DEEPSEEK_API_KEY" {
|
|
return "legacy-keyring-value", true
|
|
}
|
|
return "", false
|
|
}
|
|
t.Cleanup(func() { legacyKeyringCredentialValueLookup = old })
|
|
|
|
if err := MigrateLegacyCredentialsForRoot("."); err != nil {
|
|
t.Fatalf("MigrateLegacyCredentialsForRoot: %v", err)
|
|
}
|
|
if _, err := os.Stat(UserCredentialsPath()); !os.IsNotExist(err) {
|
|
t.Fatalf("isolated runtime imported legacy credentials to %s; stat err=%v", UserCredentialsPath(), err)
|
|
}
|
|
}
|
|
|
|
func TestMigrateLegacyCredentialsDoesNotReimportClearedKey(t *testing.T) {
|
|
_, dest, _ := legacyHome(t)
|
|
legacy := legacyUserConfigPath()
|
|
if legacy == "" {
|
|
t.Skip("legacy OS config path matches primary path on this platform")
|
|
}
|
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(dest, []byte(`default_model = "deepseek-flash/deepseek-chat"`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
legacyCred := filepath.Join(filepath.Dir(legacy), "credentials")
|
|
if err := os.MkdirAll(filepath.Dir(legacyCred), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(legacyCred, []byte("DEEPSEEK_API_KEY=sk-old-creds\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
if err := MigrateLegacyCredentialsForRoot("."); err != nil {
|
|
t.Fatalf("initial migrate: %v", err)
|
|
}
|
|
if err := RemoveCredential("DEEPSEEK_API_KEY"); err != nil {
|
|
t.Fatalf("RemoveCredential: %v", err)
|
|
}
|
|
if err := MigrateLegacyCredentialsForRoot("."); err != nil {
|
|
t.Fatalf("second migrate: %v", err)
|
|
}
|
|
data, err := os.ReadFile(UserCredentialsPath())
|
|
if err != nil {
|
|
t.Fatalf("read current credentials: %v", err)
|
|
}
|
|
if strings.Contains(string(data), "sk-old-creds") || CredentialStored("DEEPSEEK_API_KEY") {
|
|
t.Fatalf("cleared key was re-imported:\n%s", data)
|
|
}
|
|
if !strings.Contains(string(data), credentialClearedPrefix+"DEEPSEEK_API_KEY") {
|
|
t.Fatalf("cleared marker missing:\n%s", data)
|
|
}
|
|
}
|
|
|
|
func TestMigrateSkipsLegacyCredentialsAlreadyInCurrentAutoStore(t *testing.T) {
|
|
_, dest, _ := legacyHome(t)
|
|
t.Setenv("REASONIX_CREDENTIALS_STORE", "")
|
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(dest, []byte(`default_model = "deepseek-flash/deepseek-chat"`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
currentCred := UserCredentialsPath()
|
|
if err := os.MkdirAll(filepath.Dir(currentCred), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(currentCred, []byte("DEEPSEEK_API_KEY=sk-current\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
legacyPaths := legacyCredentialsPaths()
|
|
if len(legacyPaths) == 0 {
|
|
t.Skip("no legacy credentials path on this platform")
|
|
}
|
|
legacyCred := legacyPaths[0]
|
|
if err := os.MkdirAll(filepath.Dir(legacyCred), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(legacyCred, []byte("DEEPSEEK_API_KEY=sk-stale\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
res, err := MigrateLegacyIfNeeded()
|
|
if err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
if res != nil {
|
|
t.Fatalf("primary config exists, config migration should be skipped, got %+v", res)
|
|
}
|
|
data, err := os.ReadFile(currentCred)
|
|
if err != nil {
|
|
t.Fatalf("read current credentials: %v", err)
|
|
}
|
|
if string(data) != "DEEPSEEK_API_KEY=sk-current\n" {
|
|
t.Fatalf("current credentials were overwritten: %q", data)
|
|
}
|
|
}
|
|
|
|
func TestMigrateImportsLegacyStateHomeDotEnvCredentials(t *testing.T) {
|
|
_, dest, _ := legacyHome(t)
|
|
state := t.TempDir()
|
|
t.Setenv("REASONIX_STATE_HOME", state)
|
|
t.Setenv("REASONIX_CREDENTIALS_STORE", "")
|
|
os.Unsetenv("REASONIX_CREDENTIALS_STORE")
|
|
if err := os.MkdirAll(filepath.Dir(dest), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(dest, []byte(`default_model = "deepseek-flash/deepseek-chat"`), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(filepath.Join(state, ".env"), []byte("DEEPSEEK_API_KEY=state-env-value\n"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
|
|
currentCred := UserCredentialsPath()
|
|
if strings.HasPrefix(currentCred, state) {
|
|
t.Fatalf("current credentials path should not be under REASONIX_STATE_HOME: %q", currentCred)
|
|
}
|
|
res, err := MigrateLegacyIfNeeded()
|
|
if err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
if res != nil {
|
|
t.Fatalf("primary config exists, config migration should be skipped, got %+v", res)
|
|
}
|
|
data, err := os.ReadFile(currentCred)
|
|
if err != nil {
|
|
t.Fatalf("read current credentials: %v", err)
|
|
}
|
|
if string(data) != "DEEPSEEK_API_KEY=state-env-value\n" {
|
|
t.Fatalf("migrated credentials = %q", data)
|
|
}
|
|
}
|
|
|
|
func TestMigrateNoLegacyIsNoop(t *testing.T) {
|
|
legacyHome(t)
|
|
res, err := MigrateLegacyIfNeeded()
|
|
if err != nil || res != nil {
|
|
t.Errorf("no legacy install should be a silent no-op, got res=%+v err=%v", res, err)
|
|
}
|
|
}
|
|
|
|
func TestMigrateToleratesUTF8BOM(t *testing.T) {
|
|
src, _, _ := legacyHome(t)
|
|
writeLegacy(t, src, "\ufeff"+`{"apiKey":"sk-bom"}`)
|
|
res, err := MigrateLegacyIfNeeded()
|
|
if err != nil {
|
|
t.Fatalf("a BOM-prefixed legacy config must still parse: %v", err)
|
|
}
|
|
if res == nil || !res.KeyToEnv {
|
|
t.Fatalf("BOM-prefixed config did not migrate: %+v", res)
|
|
}
|
|
data, _ := os.ReadFile(UserCredentialsPath())
|
|
if !strings.Contains(string(data), "DEEPSEEK_API_KEY=sk-bom") {
|
|
t.Errorf("key not migrated from BOM-prefixed config: %q", data)
|
|
}
|
|
}
|
|
|
|
func TestMigrateCustomBaseURLWarns(t *testing.T) {
|
|
src, _, _ := legacyHome(t)
|
|
writeLegacy(t, src, `{"apiKey":"sk-x","baseUrl":"https://my-proxy.example/v1"}`)
|
|
res, err := MigrateLegacyIfNeeded()
|
|
if err != nil {
|
|
t.Fatalf("migrate: %v", err)
|
|
}
|
|
if len(res.Warnings) == 0 {
|
|
t.Error("a non-DeepSeek base_url should produce a warning")
|
|
}
|
|
cfg, err := Load()
|
|
if err != nil {
|
|
t.Fatalf("load migrated config: %v", err)
|
|
}
|
|
for _, name := range []string{"deepseek-flash", "deepseek-pro"} {
|
|
p, ok := cfg.Provider(name)
|
|
if !ok || p.BaseURL != "https://my-proxy.example/v1" {
|
|
t.Fatalf("%s base_url was not migrated: %+v", name, p)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMigrateSupportData(t *testing.T) {
|
|
if runtime.GOOS == "windows" {
|
|
t.Skip("skipping since legacyOSSupportDir equals current reasonixHomeDir on Windows")
|
|
}
|
|
home := t.TempDir()
|
|
t.Setenv("HOME", home)
|
|
t.Setenv("REASONIX_CREDENTIALS_STORE", "file")
|
|
t.Setenv("USERPROFILE", home)
|
|
t.Setenv("AppData", filepath.Join(home, "AppData"))
|
|
|
|
legacyConf := legacyUserConfigPath()
|
|
if legacyConf == "" {
|
|
t.Skip("skipping because legacy config path is empty")
|
|
}
|
|
legacyDir := filepath.Dir(legacyConf)
|
|
|
|
// Write data to the legacy support directory
|
|
filesToWrite := map[string]string{
|
|
"config.toml": "language = \"zh\"",
|
|
"hooks.json": `{"hook":"test"}`,
|
|
"sessions/s1.json": `{"id":"s1"}`,
|
|
"projects/p1/sessions/s2.json": `{"id":"s2"}`,
|
|
"skills/custom.md": `custom skill`,
|
|
"archive/a1.json": `{"compacted": true}`,
|
|
}
|
|
for rel, content := range filesToWrite {
|
|
path := filepath.Join(legacyDir, rel)
|
|
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
if runtime.GOOS != "windows" {
|
|
if err := os.Chmod(filepath.Join(legacyDir, "sessions"), 0o700); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chmod(filepath.Join(legacyDir, "sessions", "s1.json"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if err := os.Chmod(filepath.Join(legacyDir, "hooks.json"), 0o600); err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
}
|
|
|
|
res, err := MigrateLegacyIfNeeded()
|
|
if err != nil {
|
|
t.Fatalf("MigrateLegacyIfNeeded failed: %v", err)
|
|
}
|
|
if res == nil {
|
|
t.Fatal("expected migration result, got nil")
|
|
}
|
|
|
|
newDir := filepath.Dir(userConfigPath())
|
|
for rel, expectedContent := range filesToWrite {
|
|
if rel == "config.toml" {
|
|
continue
|
|
}
|
|
newPath := filepath.Join(newDir, rel)
|
|
data, err := os.ReadFile(newPath)
|
|
if err != nil {
|
|
t.Errorf("expected file %s to be migrated, but got error: %v", rel, err)
|
|
continue
|
|
}
|
|
if string(data) != expectedContent {
|
|
t.Errorf("file %s content mismatch: got %q, want %q", rel, string(data), expectedContent)
|
|
}
|
|
}
|
|
if runtime.GOOS != "windows" {
|
|
for _, check := range []struct {
|
|
rel string
|
|
perm os.FileMode
|
|
}{
|
|
{rel: "sessions", perm: 0o700},
|
|
{rel: "sessions/s1.json", perm: 0o600},
|
|
{rel: "hooks.json", perm: 0o600},
|
|
} {
|
|
info, err := os.Stat(filepath.Join(newDir, check.rel))
|
|
if err != nil {
|
|
t.Fatalf("stat migrated %s: %v", check.rel, err)
|
|
}
|
|
if got := info.Mode().Perm(); got != check.perm {
|
|
t.Fatalf("migrated %s mode = %o, want %o", check.rel, got, check.perm)
|
|
}
|
|
}
|
|
}
|
|
}
|