返回 DeepSeek-Reasonix
loadedit_test.go
根目录 / internal / config / loadedit_test.go
1 package config
2
3 import (
4 "os"
5 "path/filepath"
6 "testing"
7
8 fileencoding "reasonix/internal/fileutil/encoding"
9 )
10
11 func TestLoadForEdit(t *testing.T) {
12 dir := t.TempDir()
13 path := filepath.Join(dir, "reasonix.toml")
14 custom := `default_model = "custom"
15 [[providers]]
16 name = "custom"
17 kind = "openai"
18 base_url = "https://x"
19 model = "m"
20 api_key_env = "X_KEY"
21 `
22 if err := os.WriteFile(path, []byte(custom), 0o644); err != nil {
23 t.Fatal(err)
24 }
25
26 // Existing file: its providers/default override the built-in defaults, so a
27 // reconfigure preserves the user's setup.
28 cfg := LoadForEdit(path)
29 if cfg.DefaultModel != "custom" {
30 t.Errorf("default_model = %q, want custom", cfg.DefaultModel)
31 }
32 if len(cfg.Providers) != 1 || cfg.Providers[0].Name != "custom" {
33 t.Errorf("providers = %v, want a single custom provider", cfg.Providers)
34 }
35
36 // Missing file: falls back to the built-in defaults.
37 if cfg := LoadForEdit(filepath.Join(dir, "absent.toml")); cfg.DefaultModel != Default().DefaultModel {
38 t.Errorf("missing-file default = %q, want %q", cfg.DefaultModel, Default().DefaultModel)
39 }
40 }
41
42 func TestMergeTOMLProviderAccessPreservesExplicitEmpty(t *testing.T) {
43 userPath := writeUserProviderAccess(t, "[desktop]\nprovider_access = []\n")
44
45 access, declared, err := mergeTOMLProviderAccess([]string{userPath})
46 if err != nil {
47 t.Fatalf("mergeTOMLProviderAccess: %v", err)
48 }
49 if !declared {
50 t.Fatal("provider_access declaration was not detected")
51 }
52 if access == nil || len(access) != 0 {
53 t.Fatalf("provider_access = %#v, want a non-nil empty slice", access)
54 }
55 }
56
57 func TestMergeTOMLProviderAccessIgnoresProjectOnlyList(t *testing.T) {
58 isolateUserConfigHome(t)
59 userPath := UserConfigPath()
60 if err := os.MkdirAll(filepath.Dir(userPath), 0o755); err != nil {
61 t.Fatal(err)
62 }
63 if err := os.WriteFile(userPath, []byte("default_model = \"deepseek/deepseek-v4-flash\"\n"), 0o644); err != nil {
64 t.Fatal(err)
65 }
66 projectPath := filepath.Join(t.TempDir(), "reasonix.toml")
67 if err := os.WriteFile(projectPath, []byte("[desktop]\nprovider_access = [\"deepseek\"]\n"), 0o644); err != nil {
68 t.Fatal(err)
69 }
70
71 access, declared, err := mergeTOMLProviderAccess([]string{userPath, projectPath})
72 if err != nil {
73 t.Fatalf("mergeTOMLProviderAccess: %v", err)
74 }
75 if declared || access != nil {
76 t.Fatalf("project-only access = %#v (declared=%v); an undeclared user list means allow-all", access, declared)
77 }
78 }
79
80 func TestMergeTOMLProviderAccessUnionsWhenUserDeclares(t *testing.T) {
81 userPath := writeUserProviderAccess(t, "[desktop]\nprovider_access = [\"deepseek\"]\n")
82 projectPath := filepath.Join(t.TempDir(), "reasonix.toml")
83 if err := os.WriteFile(projectPath, []byte("[desktop]\nprovider_access = [\"project-b\"]\n"), 0o644); err != nil {
84 t.Fatal(err)
85 }
86
87 access, declared, err := mergeTOMLProviderAccess([]string{userPath, projectPath})
88 if err != nil {
89 t.Fatalf("mergeTOMLProviderAccess: %v", err)
90 }
91 if !declared || len(access) != 2 || access[0] != "deepseek" || access[1] != "project-b" {
92 t.Fatalf("access = %#v (declared=%v), want the union of both scopes", access, declared)
93 }
94 }
95
96 func writeUserProviderAccess(t *testing.T, body string) string {
97 t.Helper()
98 isolateUserConfigHome(t)
99 path := UserConfigPath()
100 if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
101 t.Fatal(err)
102 }
103 if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
104 t.Fatal(err)
105 }
106 return path
107 }
108
109 func TestLoadForEditDecodesGB18030TOML(t *testing.T) {
110 dir := t.TempDir()
111 path := filepath.Join(dir, "config.toml")
112 body := `default_model = "local/中文模型"
113
114 [[providers]]
115 name = "local"
116 kind = "openai"
117 base_url = "https://example.com/v1"
118 model = "中文模型"
119 api_key_env = "LOCAL_KEY"
120 `
121 if err := os.WriteFile(path, fileencoding.Encode(body, fileencoding.GB18030), 0o644); err != nil {
122 t.Fatal(err)
123 }
124
125 cfg := LoadForEdit(path)
126 if cfg.DefaultModel != "local/中文模型" {
127 t.Fatalf("default_model = %q", cfg.DefaultModel)
128 }
129 if len(cfg.Providers) != 1 || cfg.Providers[0].Model != "中文模型" {
130 t.Fatalf("providers = %+v, want decoded Chinese model", cfg.Providers)
131 }
132 }
133
134 func TestLoadForEditNormalizesLegacyMCPTiersWithoutWriting(t *testing.T) {
135 dir := t.TempDir()
136 path := filepath.Join(dir, "reasonix.toml")
137 body := `
138 [[plugins]]
139 name = "playwright"
140 command = "npx"
141 tier = "lazy"
142
143 [[providers]]
144 name = "local"
145 kind = "openai"
146 base_url = "https://x"
147 model = "m"
148 `
149 if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
150 t.Fatal(err)
151 }
152
153 cfg := LoadForEdit(path)
154 if len(cfg.Plugins) != 1 || cfg.Plugins[0].Tier != "" {
155 t.Fatalf("plugins after migration = %+v, want empty tier", cfg.Plugins)
156 }
157 updated, err := os.ReadFile(path)
158 if err != nil {
159 t.Fatal(err)
160 }
161 if string(updated) != body {
162 t.Fatalf("LoadForEdit must not rewrite config outside its caller's edit transaction:\n%s", updated)
163 }
164 }
165
166 func TestLoadForEditReadOnlyStrictDoesNotMigrateDisk(t *testing.T) {
167 path := filepath.Join(t.TempDir(), "reasonix.toml")
168 body := []byte(`
169 [[plugins]]
170 name = "playwright"
171 command = "npx"
172 tier = "lazy"
173 `)
174 if err := os.WriteFile(path, body, 0o644); err != nil {
175 t.Fatal(err)
176 }
177 cfg, err := LoadForEditReadOnlyStrict(path)
178 if err != nil {
179 t.Fatal(err)
180 }
181 if len(cfg.Plugins) != 1 || cfg.Plugins[0].Tier != "" {
182 t.Fatalf("read-only normalized plugins = %+v", cfg.Plugins)
183 }
184 after, err := os.ReadFile(path)
185 if err != nil {
186 t.Fatal(err)
187 }
188 if string(after) != string(body) {
189 t.Fatalf("read-only load changed config on disk:\n%s", after)
190 }
191 }
192
193 func TestLoadForEditIgnoresProjectDotEnvForProviderCredentials(t *testing.T) {
194 project := t.TempDir()
195 launch := t.TempDir()
196 home := t.TempDir()
197 path := filepath.Join(project, "reasonix.toml")
198 body := `default_model = "custom/m"
199 [[providers]]
200 name = "custom"
201 kind = "openai"
202 base_url = "https://example.invalid/v1"
203 model = "m"
204 api_key_env = "PROJECT_ONLY_KEY"
205 `
206 if err := os.WriteFile(path, []byte(body), 0o644); err != nil {
207 t.Fatal(err)
208 }
209 if err := os.WriteFile(filepath.Join(project, ".env"), []byte("PROJECT_ONLY_KEY=from-project\n"), 0o600); err != nil {
210 t.Fatal(err)
211 }
212 t.Chdir(launch)
213 t.Setenv("HOME", home)
214 t.Setenv("USERPROFILE", home)
215 t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config"))
216 t.Setenv("AppData", filepath.Join(home, "AppData"))
217 t.Setenv("PROJECT_ONLY_KEY", "")
218 os.Unsetenv("PROJECT_ONLY_KEY")
219
220 cfg := LoadForEdit(path)
221 provider, ok := cfg.Provider("custom")
222 if !ok {
223 t.Fatalf("provider missing from edited config: %+v", cfg.Providers)
224 }
225 if provider.Configured() {
226 t.Fatalf("provider should not resolve api_key_env from project .env next to edited config")
227 }
228 if got := ResolveCredentialForRootGlobalFirst(project, "PROJECT_ONLY_KEY"); got.Set {
229 t.Fatalf("credential = %+v, want project .env ignored for provider key", got)
230 }
231 }
232
232 lines GO