| 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 |