package config import ( "os" "path/filepath" "runtime" "strings" "testing" "github.com/BurntSushi/toml" ) func TestConfigValidate(t *testing.T) { tests := []struct { name string cfg Config wantErr string }{ { name: "requires at least one project", cfg: Config{}, wantErr: "at least one [[projects]] entry is required", }, { name: "requires project name", cfg: Config{ Projects: []ProjectConfig{ validProject(""), }, }, wantErr: `projects[0].name is required`, }, { name: "requires agent type", cfg: Config{ Projects: []ProjectConfig{ func() ProjectConfig { p := validProject("demo") p.Agent.Type = "" return p }(), }, }, wantErr: `projects[0].agent.type is required`, }, { name: "requires at least one platform", cfg: Config{ Projects: []ProjectConfig{ func() ProjectConfig { p := validProject("demo") p.Platforms = nil return p }(), }, }, wantErr: `projects[0] needs at least one [[projects.platforms]]`, }, { name: "requires platform type", cfg: Config{ Projects: []ProjectConfig{ func() ProjectConfig { p := validProject("demo") p.Platforms[0].Type = "" return p }(), }, }, wantErr: `projects[0].platforms[0].type is required`, }, { name: "multi workspace requires base dir", cfg: Config{ Projects: []ProjectConfig{ func() ProjectConfig { p := validProject("demo") p.Mode = "multi-workspace" return p }(), }, }, wantErr: `project "demo": multi-workspace mode requires base_dir`, }, { name: "multi workspace rejects work dir", cfg: Config{ Projects: []ProjectConfig{ func() ProjectConfig { p := validProject("demo") p.Mode = "multi-workspace" p.BaseDir = "~/workspace" p.Agent.Options["work_dir"] = "/tmp/demo" return p }(), }, }, wantErr: `project "demo": multi-workspace mode conflicts with agent work_dir`, }, { name: "accepts valid config", cfg: Config{ Projects: []ProjectConfig{validProject("demo")}, }, }, { name: "accepts valid references config", cfg: Config{ Projects: []ProjectConfig{ func() ProjectConfig { p := validProject("demo") p.References = ReferenceConfig{ NormalizeAgents: []string{"codex", "claudecode"}, RenderPlatforms: []string{"feishu", "weixin"}, DisplayPath: "dirname_basename", MarkerStyle: "emoji", EnclosureStyle: "code", } return p }(), }, }, }, { name: "rejects unsupported reference agent", cfg: Config{ Projects: []ProjectConfig{ func() ProjectConfig { p := validProject("demo") p.References.NormalizeAgents = []string{"gemini"} return p }(), }, }, wantErr: `projects[0].references.normalize_agents has unsupported value "gemini"`, }, { name: "rejects unsupported reference platform", cfg: Config{ Projects: []ProjectConfig{ func() ProjectConfig { p := validProject("demo") p.References.RenderPlatforms = []string{"telegram"} return p }(), }, }, wantErr: `projects[0].references.render_platforms has unsupported value "telegram"`, }, { name: "rejects unsupported reference display path", cfg: Config{ Projects: []ProjectConfig{ func() ProjectConfig { p := validProject("demo") p.References.DisplayPath = "full" return p }(), }, }, wantErr: `projects[0].references.display_path has unsupported value "full"`, }, { name: "accepts all shorthand in references scopes", cfg: Config{ Projects: []ProjectConfig{ func() ProjectConfig { p := validProject("demo") p.References.NormalizeAgents = []string{"all"} p.References.RenderPlatforms = []string{"all"} return p }(), }, }, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := tt.cfg.validate() if tt.wantErr == "" { if err != nil { t.Fatalf("validate() unexpected error: %v", err) } return } assertErrContains(t, err, tt.wantErr) }) } } func TestRunAsEnv_RejectsDangerousVars(t *testing.T) { dangerous := []string{"PATH", "path", "LD_PRELOAD", "HOME", "USER", "SHELL", "SUDO_USER", "SUDO_COMMAND", "LD_LIBRARY_PATH", "DYLD_INSERT_LIBRARIES"} for _, v := range dangerous { err := validateRunAsEnv("projects[0]", []string{v}) if err == nil { t.Errorf("validateRunAsEnv(%q) = nil, want error", v) } } safe := []string{"ANTHROPIC_API_KEY", "OPENAI_API_KEY", "CUSTOM_VAR"} for _, v := range safe { err := validateRunAsEnv("projects[0]", []string{v}) if err != nil { t.Errorf("validateRunAsEnv(%q) = %v, want nil", v, err) } } } func TestEffectiveDisplayQuiet(t *testing.T) { tru, fal := true, false compact := DisplayModeCompact quiet := DisplayModeQuiet tests := []struct { name string cfg Config proj ProjectConfig wantMode string wantTM bool wantTool bool }{ { name: "defaults no quiet", cfg: Config{}, proj: ProjectConfig{}, wantMode: "full", wantTM: true, wantTool: true, }, { name: "global quiet maps to quiet mode", cfg: Config{Quiet: &tru}, proj: ProjectConfig{}, wantMode: "quiet", wantTM: false, wantTool: false, }, { name: "project quiet maps to quiet mode", cfg: Config{}, proj: ProjectConfig{Quiet: &tru}, wantMode: "quiet", wantTM: false, wantTool: false, }, { name: "explicit thinking_messages wins over quiet", cfg: Config{ Quiet: &tru, Display: DisplayConfig{ThinkingMessages: &tru}, }, proj: ProjectConfig{}, wantMode: "quiet", wantTM: true, wantTool: false, }, { name: "project quiet false overrides global quiet", cfg: Config{Quiet: &tru}, proj: ProjectConfig{Quiet: &fal}, wantMode: "full", wantTM: true, wantTool: true, }, { name: "explicit mode compact", cfg: Config{Display: DisplayConfig{Mode: &compact}}, proj: ProjectConfig{}, wantMode: "compact", wantTM: false, wantTool: false, }, { name: "project mode overrides global mode", cfg: Config{Display: DisplayConfig{Mode: &quiet}}, proj: ProjectConfig{Display: &DisplayConfig{Mode: &compact}}, wantMode: "compact", wantTM: false, wantTool: false, }, { name: "explicit mode wins over legacy quiet", cfg: Config{Quiet: &tru, Display: DisplayConfig{Mode: &compact}}, proj: ProjectConfig{}, wantMode: "compact", wantTM: false, wantTool: false, }, { name: "explicit mode quiet with thinking override", cfg: Config{ Display: DisplayConfig{Mode: &quiet, ThinkingMessages: &tru}, }, proj: ProjectConfig{}, wantMode: "quiet", wantTM: true, wantTool: false, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { mode, tm, tool, _, _, _, _ := EffectiveDisplay(&tt.cfg, &tt.proj) if mode != tt.wantMode { t.Fatalf("Mode = %q, want %q", mode, tt.wantMode) } if tm != tt.wantTM { t.Fatalf("ThinkingMessages = %v, want %v", tm, tt.wantTM) } if tool != tt.wantTool { t.Fatalf("ToolMessages = %v, want %v", tool, tt.wantTool) } }) } } func TestEffectiveDisplay_ProjectOverride(t *testing.T) { tru, fal := true, false maxA, maxB := 100, 200 tests := []struct { name string cfg Config proj ProjectConfig wantTM bool wantTool bool wantThinkLen int wantToolMaxLen int }{ { name: "project overrides global thinking_messages", cfg: Config{ Display: DisplayConfig{ThinkingMessages: &tru, ToolMessages: &tru}, }, proj: ProjectConfig{ Display: &DisplayConfig{ThinkingMessages: &fal}, }, wantTM: false, wantTool: true, wantThinkLen: 300, wantToolMaxLen: 500, }, { name: "project unset falls back to global", cfg: Config{ Display: DisplayConfig{ThinkingMessages: &fal, ToolMessages: &fal}, }, proj: ProjectConfig{ Display: &DisplayConfig{}, }, wantTM: false, wantTool: false, wantThinkLen: 300, wantToolMaxLen: 500, }, { name: "both unset falls back to default", cfg: Config{}, proj: ProjectConfig{ Display: &DisplayConfig{}, }, wantTM: true, wantTool: true, wantThinkLen: 300, wantToolMaxLen: 500, }, { name: "project overrides max-len fields", cfg: Config{ Display: DisplayConfig{ThinkingMaxLen: &maxA, ToolMaxLen: &maxA}, }, proj: ProjectConfig{ Display: &DisplayConfig{ThinkingMaxLen: &maxB, ToolMaxLen: &maxB}, }, wantTM: true, wantTool: true, wantThinkLen: 200, wantToolMaxLen: 200, }, { name: "project quiet still respected when project display unset", cfg: Config{Quiet: &tru}, proj: ProjectConfig{ Display: &DisplayConfig{}, }, wantTM: false, wantTool: false, wantThinkLen: 300, wantToolMaxLen: 500, }, { name: "project display.thinking_messages true overrides project quiet", cfg: Config{Quiet: &tru}, proj: ProjectConfig{ Display: &DisplayConfig{ThinkingMessages: &tru}, }, wantTM: true, wantTool: false, wantThinkLen: 300, wantToolMaxLen: 500, }, { name: "nil project display behaves like before", cfg: Config{ Display: DisplayConfig{ThinkingMessages: &fal}, }, proj: ProjectConfig{}, wantTM: false, wantTool: true, wantThinkLen: 300, wantToolMaxLen: 500, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { _, tm, tool, thinkLen, toolMaxLen, _, _ := EffectiveDisplay(&tt.cfg, &tt.proj) if tm != tt.wantTM { t.Errorf("ThinkingMessages = %v, want %v", tm, tt.wantTM) } if tool != tt.wantTool { t.Errorf("ToolMessages = %v, want %v", tool, tt.wantTool) } if thinkLen != tt.wantThinkLen { t.Errorf("ThinkingMaxLen = %d, want %d", thinkLen, tt.wantThinkLen) } if toolMaxLen != tt.wantToolMaxLen { t.Errorf("ToolMaxLen = %d, want %d", toolMaxLen, tt.wantToolMaxLen) } }) } } func TestValidateProjectDisplayConfig(t *testing.T) { mode := "verbose" cardMode := "modern" tests := []struct { name string display *DisplayConfig wantErr string }{ { name: "invalid project display mode", display: &DisplayConfig{Mode: &mode}, wantErr: `projects[0].display.mode must be "full", "compact", or "quiet"`, }, { name: "invalid project card mode", display: &DisplayConfig{CardMode: &cardMode}, wantErr: `projects[0].display.card_mode must be "legacy" or "rich"`, }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { cfg := Config{Projects: []ProjectConfig{validProject("demo")}} cfg.Projects[0].Display = tt.display err := cfg.validate() if err == nil { t.Fatalf("validate() = nil, want %q", tt.wantErr) } if !strings.Contains(err.Error(), tt.wantErr) { t.Fatalf("validate() = %q, want contains %q", err.Error(), tt.wantErr) } }) } } func TestLoad_DefaultsDataDir(t *testing.T) { dir := t.TempDir() t.Setenv("HOME", dir) cfgPath := filepath.Join(dir, "config.toml") if err := os.WriteFile(cfgPath, []byte(baseConfigTOML), 0o644); err != nil { t.Fatalf("write config: %v", err) } cfg, err := Load(cfgPath) if err != nil { t.Fatalf("Load() error: %v", err) } want := filepath.Join(dir, ".cc-connect") if cfg.DataDir != want { t.Fatalf("Load() data_dir = %q, want %q", cfg.DataDir, want) } } func TestLoad_ResolvesEnvPlaceholders(t *testing.T) { root := t.TempDir() t.Setenv("CC_ROOT", root) t.Setenv("TG_TOKEN", "tg-secret") t.Setenv("HOOK_TOKEN", "hook-secret") t.Setenv("OPENAI_API_KEY", "sk-test") t.Setenv("HTTP_PROXY", "http://127.0.0.1:7890") configPath := writeConfigFixture(t, ` data_dir = "${CC_ROOT}/state" [webhook] token = "${HOOK_TOKEN}" [[projects]] name = "demo" [projects.agent] type = "codex" [projects.agent.options] work_dir = "${CC_ROOT}/repo" note = "prefix-${HOOK_TOKEN}-suffix" retries = 3 [[projects.agent.providers]] name = "relay" api_key = "${OPENAI_API_KEY}" base_url = "https://relay.example/${HOOK_TOKEN}" [projects.agent.providers.env] HTTP_PROXY = "${HTTP_PROXY}" [[projects.platforms]] type = "telegram" [projects.platforms.options] token = "${TG_TOKEN}" chat_id = 12345 `) cfg, err := Load(configPath) if err != nil { t.Fatalf("Load() error: %v", err) } if got, want := cfg.DataDir, filepath.Join(root, "state"); got != want { t.Fatalf("DataDir = %q, want %q", got, want) } if got := cfg.Webhook.Token; got != "hook-secret" { t.Fatalf("Webhook.Token = %q, want hook-secret", got) } if got := stringMapValue(cfg.Projects[0].Agent.Options, "work_dir"); got != filepath.Join(root, "repo") { t.Fatalf("work_dir = %q, want %q", got, filepath.Join(root, "repo")) } if got := stringMapValue(cfg.Projects[0].Agent.Options, "note"); got != "prefix-hook-secret-suffix" { t.Fatalf("note = %q, want prefix-hook-secret-suffix", got) } if got := cfg.Projects[0].Agent.Providers[0].APIKey; got != "sk-test" { t.Fatalf("provider api_key = %q, want sk-test", got) } if got := cfg.Projects[0].Agent.Providers[0].Env["HTTP_PROXY"]; got != "http://127.0.0.1:7890" { t.Fatalf("provider env HTTP_PROXY = %q, want http://127.0.0.1:7890", got) } if got := stringMapValue(cfg.Projects[0].Platforms[0].Options, "token"); got != "tg-secret" { t.Fatalf("platform token = %q, want tg-secret", got) } if _, ok := cfg.Projects[0].Platforms[0].Options["chat_id"].(int64); !ok { t.Fatalf("chat_id type = %T, want int64", cfg.Projects[0].Platforms[0].Options["chat_id"]) } } func TestLoad_MissingEnvPlaceholderBecomesEmptyString(t *testing.T) { configPath := writeConfigFixture(t, ` [[projects]] name = "demo" [projects.agent] type = "codex" [projects.agent.options] work_dir = "/tmp/demo" retries = 5 [[projects.agent.providers]] name = "relay" api_key = "${MISSING_API_KEY}" [projects.agent.providers.env] HTTPS_PROXY = "${MISSING_PROXY}" [[projects.platforms]] type = "telegram" [projects.platforms.options] token = "prefix-${MISSING_TOKEN}-suffix" `) cfg, err := Load(configPath) if err != nil { t.Fatalf("Load() error: %v", err) } if got := cfg.Projects[0].Agent.Providers[0].APIKey; got != "" { t.Fatalf("provider api_key = %q, want empty", got) } if got := cfg.Projects[0].Agent.Providers[0].Env["HTTPS_PROXY"]; got != "" { t.Fatalf("provider env HTTPS_PROXY = %q, want empty", got) } if got := stringMapValue(cfg.Projects[0].Platforms[0].Options, "token"); got != "prefix--suffix" { t.Fatalf("platform token = %q, want prefix--suffix", got) } if _, ok := cfg.Projects[0].Agent.Options["retries"].(int64); !ok { t.Fatalf("retries type = %T, want int64", cfg.Projects[0].Agent.Options["retries"]) } } func TestListProjects(t *testing.T) { writeTestConfig(t, baseConfigTOML) names, err := ListProjects() if err != nil { t.Fatalf("ListProjects() error: %v", err) } if len(names) != 1 || names[0] != "demo" { t.Fatalf("ListProjects() = %#v, want [demo]", names) } } func TestSaveLanguage(t *testing.T) { writeTestConfig(t, baseConfigTOML) if err := SaveLanguage("zh"); err != nil { t.Fatalf("SaveLanguage() error: %v", err) } cfg := readTestConfig(t) if cfg.Language != "zh" { t.Fatalf("Language = %q, want zh", cfg.Language) } } func TestProviderConfig_SaveActiveProviderAndGetProjectProviders(t *testing.T) { writeTestConfig(t, providerConfigTOML) if err := SaveActiveProvider("demo", "backup"); err != nil { t.Fatalf("SaveActiveProvider() error: %v", err) } providers, active, err := GetProjectProviders("demo") if err != nil { t.Fatalf("GetProjectProviders() error: %v", err) } if active != "backup" { t.Fatalf("active provider = %q, want backup", active) } if len(providers) != 2 { t.Fatalf("provider count = %d, want 2", len(providers)) } } func TestProviderConfig_AddAndRemove(t *testing.T) { writeTestConfig(t, providerConfigTOML) newProvider := ProviderConfig{Name: "relay", APIKey: "sk-relay", BaseURL: "https://example.com"} if err := AddProviderToConfig("demo", newProvider); err != nil { t.Fatalf("AddProviderToConfig() error: %v", err) } if err := AddProviderToConfig("demo", newProvider); err == nil { t.Fatal("AddProviderToConfig() duplicate provider: expected error") } cfg := readTestConfig(t) if len(cfg.Projects[0].Agent.Providers) != 3 { t.Fatalf("provider count after add = %d, want 3", len(cfg.Projects[0].Agent.Providers)) } if err := RemoveProviderFromConfig("demo", "relay"); err != nil { t.Fatalf("RemoveProviderFromConfig() error: %v", err) } if err := RemoveProviderFromConfig("demo", "relay"); err == nil { t.Fatal("RemoveProviderFromConfig() missing provider: expected error") } } func TestProviderConfig_SaveProviderModel(t *testing.T) { writeTestConfig(t, providerConfigTOML) if err := SaveProviderModel("demo", "primary", "gpt-5.4"); err != nil { t.Fatalf("SaveProviderModel() error: %v", err) } cfg := readTestConfig(t) if got := cfg.Projects[0].Agent.Providers[0].Model; got != "gpt-5.4" { t.Fatalf("provider model = %q, want gpt-5.4", got) } if err := SaveProviderModel("demo", "missing", "gpt-4.1"); err == nil { t.Fatal("SaveProviderModel() missing provider: expected error") } } func TestSaveAgentModel(t *testing.T) { writeTestConfig(t, providerConfigTOML) if err := SaveAgentModel("demo", "gpt-5.4"); err != nil { t.Fatalf("SaveAgentModel() error: %v", err) } cfg := readTestConfig(t) if got, _ := cfg.Projects[0].Agent.Options["model"].(string); got != "gpt-5.4" { t.Fatalf("agent.options.model = %q, want gpt-5.4", got) } if got, _ := cfg.Projects[0].Agent.Options["mode"].(string); got != "default" { t.Fatalf("agent.options.mode = %q, want default", got) } if got, _ := cfg.Projects[0].Agent.Options["provider"].(string); got != "primary" { t.Fatalf("agent.options.provider = %q, want primary", got) } if len(cfg.Projects[0].Agent.Providers) != 2 { t.Fatalf("provider count = %d, want 2", len(cfg.Projects[0].Agent.Providers)) } } const providerConfigWithCommentsTOML = `# This is my config file # Very important - do not lose this! custom_top = "keep_me" [[projects]] name = "demo" work_dir = "/tmp/demo" # inline comment [projects.agent] type = "claudecode" [projects.agent.options] mode = "default" provider = "primary" custom_option = "still_here" # keep inline comment [[projects.agent.providers]] name = "primary" api_key = "sk-primary" [[projects.agent.providers]] name = "backup" api_key = "sk-backup" [[projects.platforms]] type = "telegram" [projects.platforms.options] token = "test-token" ` func TestSaveActiveProvider_PreservesCommentsAndUnknownFields(t *testing.T) { writeTestConfig(t, providerConfigWithCommentsTOML) if err := SaveActiveProvider("demo", "backup"); err != nil { t.Fatalf("SaveActiveProvider() error: %v", err) } content, err := os.ReadFile(ConfigPath) if err != nil { t.Fatalf("read config: %v", err) } text := string(content) if !strings.Contains(text, "# This is my config file") { t.Fatalf("expected top comment to be preserved, got:\n%s", text) } if !strings.Contains(text, "# Very important - do not lose this!") { t.Fatalf("expected second comment to be preserved, got:\n%s", text) } if !strings.Contains(text, `custom_top = "keep_me"`) { t.Fatalf("expected unknown top-level field to be preserved, got:\n%s", text) } if !strings.Contains(text, `custom_option = "still_here"`) { t.Fatalf("expected unknown options field to be preserved, got:\n%s", text) } if !strings.Contains(text, "keep inline comment") { t.Fatalf("expected inline comment to be preserved, got:\n%s", text) } if !strings.Contains(text, `mode = "default"`) { t.Fatalf("expected mode to be preserved, got:\n%s", text) } if !strings.Contains(text, `provider = "backup"`) { t.Fatalf("expected provider to be updated to backup, got:\n%s", text) } if !strings.Contains(text, `work_dir = "/tmp/demo"`) { t.Fatalf("expected work_dir to be preserved, got:\n%s", text) } cfg := readTestConfig(t) active, _ := cfg.Projects[0].Agent.Options["provider"].(string) if active != "backup" { t.Fatalf("active provider = %q, want backup", active) } } func TestSaveAgentModel_PreservesCommentsAndUnknownFields(t *testing.T) { writeTestConfig(t, providerConfigWithCommentsTOML) if err := SaveAgentModel("demo", "gpt-5.4"); err != nil { t.Fatalf("SaveAgentModel() error: %v", err) } content, err := os.ReadFile(ConfigPath) if err != nil { t.Fatalf("read config: %v", err) } text := string(content) if !strings.Contains(text, "# This is my config file") { t.Fatalf("expected top comment to be preserved, got:\n%s", text) } if !strings.Contains(text, `custom_option = "still_here"`) { t.Fatalf("expected unknown options field to be preserved, got:\n%s", text) } if !strings.Contains(text, `provider = "primary"`) { t.Fatalf("expected provider to be preserved, got:\n%s", text) } if !strings.Contains(text, `model = "gpt-5.4"`) { t.Fatalf("expected model to be set, got:\n%s", text) } } func TestSaveProviderModel_PreservesCommentsAndUnknownFields(t *testing.T) { writeTestConfig(t, providerConfigWithCommentsTOML) if err := SaveProviderModel("demo", "primary", "gpt-5.4"); err != nil { t.Fatalf("SaveProviderModel() error: %v", err) } content, err := os.ReadFile(ConfigPath) if err != nil { t.Fatalf("read config: %v", err) } text := string(content) if !strings.Contains(text, "# This is my config file") { t.Fatalf("expected top comment to be preserved, got:\n%s", text) } if !strings.Contains(text, `custom_option = "still_here"`) { t.Fatalf("expected unknown options field to be preserved, got:\n%s", text) } if !strings.Contains(text, `model = "gpt-5.4"`) { t.Fatalf("expected model to be set in provider, got:\n%s", text) } } func TestSaveLanguage_PreservesComments(t *testing.T) { writeTestConfig(t, providerConfigWithCommentsTOML) if err := SaveLanguage("zh"); err != nil { t.Fatalf("SaveLanguage() error: %v", err) } content, err := os.ReadFile(ConfigPath) if err != nil { t.Fatalf("read config: %v", err) } text := string(content) if !strings.Contains(text, "# This is my config file") { t.Fatalf("expected top comment to be preserved, got:\n%s", text) } if !strings.Contains(text, `custom_option = "still_here"`) { t.Fatalf("expected unknown options field to be preserved, got:\n%s", text) } if !strings.Contains(text, `language = "zh"`) { t.Fatalf("expected language to be set, got:\n%s", text) } cfg := readTestConfig(t) if cfg.Language != "zh" { t.Fatalf("Language = %q, want zh", cfg.Language) } } func TestSaveDisplayConfig_PreservesComments(t *testing.T) { configWithDisplay := providerConfigWithCommentsTOML + ` [display] # display settings below thinking_messages = true custom_display = "keep" # also keep ` writeTestConfig(t, configWithDisplay) thinking := 200 toolShow := false if err := SaveDisplayConfig(nil, nil, &thinking, nil, &toolShow); err != nil { t.Fatalf("SaveDisplayConfig() error: %v", err) } content, err := os.ReadFile(ConfigPath) if err != nil { t.Fatalf("read config: %v", err) } text := string(content) if !strings.Contains(text, "# This is my config file") { t.Fatalf("expected top comment to be preserved, got:\n%s", text) } if !strings.Contains(text, "# display settings below") { t.Fatalf("expected display comment to be preserved, got:\n%s", text) } if !strings.Contains(text, `custom_display = "keep"`) { t.Fatalf("expected unknown display field to be preserved, got:\n%s", text) } if !strings.Contains(text, `thinking_max_len = 200`) { t.Fatalf("expected thinking_max_len to be set, got:\n%s", text) } if !strings.Contains(text, `tool_messages = false`) { t.Fatalf("expected tool_messages to be set, got:\n%s", text) } } func TestSaveTTSMode_PreservesComments(t *testing.T) { configWithTTS := providerConfigWithCommentsTOML + ` [tts] # tts config tts_mode = "auto" ` writeTestConfig(t, configWithTTS) if err := SaveTTSMode("always"); err != nil { t.Fatalf("SaveTTSMode() error: %v", err) } content, err := os.ReadFile(ConfigPath) if err != nil { t.Fatalf("read config: %v", err) } text := string(content) if !strings.Contains(text, "# This is my config file") { t.Fatalf("expected top comment to be preserved, got:\n%s", text) } if !strings.Contains(text, "# tts config") { t.Fatalf("expected tts comment to be preserved, got:\n%s", text) } if !strings.Contains(text, `tts_mode = "always"`) { t.Fatalf("expected tts_mode to be updated, got:\n%s", text) } } const multiProjectConfigTOML = `# multi-project config [[projects]] name = "alpha" work_dir = "/tmp/alpha" [projects.agent] type = "codex" [projects.agent.options] provider = "openai" [[projects.platforms]] type = "telegram" [projects.platforms.options] token = "alpha-token" [[projects]] name = "beta" work_dir = "/tmp/beta" [projects.agent] type = "claudecode" [projects.agent.options] provider = "anthropic" [[projects.platforms]] type = "feishu" [projects.platforms.options] app_id = "beta-app" ` func TestSaveActiveProvider_MultiProject(t *testing.T) { writeTestConfig(t, multiProjectConfigTOML) if err := SaveActiveProvider("beta", "openai"); err != nil { t.Fatalf("SaveActiveProvider() error: %v", err) } content, err := os.ReadFile(ConfigPath) if err != nil { t.Fatalf("read config: %v", err) } text := string(content) if !strings.Contains(text, "# multi-project config") { t.Fatalf("expected top comment preserved, got:\n%s", text) } cfg := readTestConfig(t) alphaProvider, _ := cfg.Projects[0].Agent.Options["provider"].(string) betaProvider, _ := cfg.Projects[1].Agent.Options["provider"].(string) if alphaProvider != "openai" { t.Fatalf("alpha provider = %q, want openai (untouched)", alphaProvider) } if betaProvider != "openai" { t.Fatalf("beta provider = %q, want openai (updated)", betaProvider) } } const globalProviderRefConfigTOML = `# global provider refs [[providers]] name = "shared-openai" api_key = "sk-shared" model = "gpt-4o" [[projects]] name = "demo" work_dir = "/tmp/demo" [projects.agent] type = "codex" provider_refs = ["shared-openai"] [[projects.platforms]] type = "telegram" [projects.platforms.options] token = "demo-token" ` func TestSaveProviderModel_GlobalProviderRef(t *testing.T) { writeTestConfig(t, globalProviderRefConfigTOML) if err := SaveProviderModel("demo", "shared-openai", "gpt-5"); err != nil { t.Fatalf("SaveProviderModel() error: %v", err) } content, err := os.ReadFile(ConfigPath) if err != nil { t.Fatalf("read config: %v", err) } text := string(content) if !strings.Contains(text, "# global provider refs") { t.Fatalf("expected comment preserved, got:\n%s", text) } if !strings.Contains(text, `model = "gpt-5"`) { t.Fatalf("expected model updated in global provider, got:\n%s", text) } if !strings.Contains(text, `api_key = "sk-shared"`) { t.Fatalf("expected api_key preserved, got:\n%s", text) } cfg := readTestConfig(t) if cfg.Providers[0].Model != "gpt-5" { t.Fatalf("global provider model = %q, want gpt-5", cfg.Providers[0].Model) } } func TestCommandConfig_AddAndRemove(t *testing.T) { writeTestConfig(t, baseConfigTOML) cmd := CommandConfig{Name: "review", Description: "code review", Prompt: "review {{args}}"} if err := AddCommand(cmd); err != nil { t.Fatalf("AddCommand() error: %v", err) } if err := AddCommand(cmd); err == nil { t.Fatal("AddCommand() duplicate command: expected error") } cfg := readTestConfig(t) if len(cfg.Commands) != 1 || cfg.Commands[0].Name != "review" { t.Fatalf("commands after add = %#v, want one review command", cfg.Commands) } if err := RemoveCommand("review"); err != nil { t.Fatalf("RemoveCommand() error: %v", err) } if err := RemoveCommand("review"); err == nil { t.Fatal("RemoveCommand() missing command: expected error") } } func TestAliasConfig_AddAndRemove(t *testing.T) { writeTestConfig(t, baseConfigTOML) if err := AddAlias(AliasConfig{Name: "帮助", Command: "/help"}); err != nil { t.Fatalf("AddAlias() error: %v", err) } if err := AddAlias(AliasConfig{Name: "帮助", Command: "/list"}); err != nil { t.Fatalf("AddAlias() update error: %v", err) } cfg := readTestConfig(t) if len(cfg.Aliases) != 1 || cfg.Aliases[0].Command != "/list" { t.Fatalf("aliases after update = %#v, want one updated alias", cfg.Aliases) } if err := RemoveAlias("帮助"); err != nil { t.Fatalf("RemoveAlias() error: %v", err) } if err := RemoveAlias("帮助"); err == nil { t.Fatal("RemoveAlias() missing alias: expected error") } } func TestDisplayConfig_Save(t *testing.T) { writeTestConfig(t, baseConfigTOML) thinking := 120 tool := 240 showTools := false if err := SaveDisplayConfig(nil, nil, &thinking, &tool, &showTools); err != nil { t.Fatalf("SaveDisplayConfig() error: %v", err) } cfg := readTestConfig(t) if cfg.Display.ThinkingMaxLen == nil || *cfg.Display.ThinkingMaxLen != 120 { t.Fatalf("ThinkingMaxLen = %#v, want 120", cfg.Display.ThinkingMaxLen) } if cfg.Display.ToolMaxLen == nil || *cfg.Display.ToolMaxLen != 240 { t.Fatalf("ToolMaxLen = %#v, want 240", cfg.Display.ToolMaxLen) } if cfg.Display.ToolMessages == nil || *cfg.Display.ToolMessages { t.Fatalf("ToolMessages = %#v, want false", cfg.Display.ToolMessages) } thinking = 360 if err := SaveDisplayConfig(nil, nil, &thinking, nil, nil); err != nil { t.Fatalf("SaveDisplayConfig() second update error: %v", err) } cfg = readTestConfig(t) if cfg.Display.ThinkingMaxLen == nil || *cfg.Display.ThinkingMaxLen != 360 { t.Fatalf("ThinkingMaxLen after update = %#v, want 360", cfg.Display.ThinkingMaxLen) } if cfg.Display.ToolMaxLen == nil || *cfg.Display.ToolMaxLen != 240 { t.Fatalf("ToolMaxLen after nil update = %#v, want 240", cfg.Display.ToolMaxLen) } if cfg.Display.ToolMessages == nil || *cfg.Display.ToolMessages { t.Fatalf("ToolMessages after nil update = %#v, want false", cfg.Display.ToolMessages) } } func TestTTSConfig_SaveMode(t *testing.T) { writeTestConfig(t, baseConfigTOML) if err := SaveTTSMode("always"); err != nil { t.Fatalf("SaveTTSMode() error: %v", err) } cfg := readTestConfig(t) if cfg.TTS.TTSMode != "always" { t.Fatalf("TTSMode = %q, want always", cfg.TTS.TTSMode) } } const attachmentSendConfigFixture = ` attachment_send = "off" [[projects]] name = "alpha" [projects.agent] type = "codex" [projects.agent.options] work_dir = "/tmp/alpha" [[projects.platforms]] type = "telegram" [projects.platforms.options] bot_token = "token_xxx" ` const relayConfigFixture = ` [relay] timeout_secs = 300 [[projects]] name = "alpha" [projects.agent] type = "codex" [projects.agent.options] work_dir = "/tmp/alpha" [[projects.platforms]] type = "telegram" [projects.platforms.options] bot_token = "token_xxx" ` const relayConfigNegativeFixture = ` [relay] timeout_secs = -1 [[projects]] name = "alpha" [projects.agent] type = "codex" [projects.agent.options] work_dir = "/tmp/alpha" [[projects.platforms]] type = "telegram" [projects.platforms.options] bot_token = "token_xxx" ` func TestSaveFeishuPlatformCredentials_UpdateFirstCandidateAndAllowFrom(t *testing.T) { configPath := writeConfigFixture(t, feishuConfigFixture) patchConfigPath(t, configPath) result, err := SaveFeishuPlatformCredentials(FeishuCredentialUpdateOptions{ ProjectName: "alpha", AppID: "cli_new_app", AppSecret: "sec_new_secret", OwnerOpenID: "ou_new_owner", SetAllowFromEmpty: true, }) if err != nil { t.Fatalf("SaveFeishuPlatformCredentials returned error: %v", err) } if result.ProjectName != "alpha" { t.Fatalf("result.ProjectName = %q, want %q", result.ProjectName, "alpha") } if result.PlatformAbsIndex != 1 { t.Fatalf("result.PlatformAbsIndex = %d, want 1", result.PlatformAbsIndex) } if result.AllowFrom != "ou_new_owner" { t.Fatalf("result.AllowFrom = %q, want %q", result.AllowFrom, "ou_new_owner") } cfg := readConfigFixture(t, configPath) platform := cfg.Projects[0].Platforms[1] if platform.Type != "feishu" { t.Fatalf("platform.Type = %q, want %q", platform.Type, "feishu") } if got := stringMapValue(platform.Options, "app_id"); got != "cli_new_app" { t.Fatalf("app_id = %q, want %q", got, "cli_new_app") } if got := stringMapValue(platform.Options, "app_secret"); got != "sec_new_secret" { t.Fatalf("app_secret = %q, want %q", got, "sec_new_secret") } if got := stringMapValue(platform.Options, "allow_from"); got != "ou_new_owner" { t.Fatalf("allow_from = %q, want %q", got, "ou_new_owner") } } func TestSaveFeishuPlatformCredentials_SelectByIndexAndOverrideType(t *testing.T) { configPath := writeConfigFixture(t, feishuConfigFixture) patchConfigPath(t, configPath) result, err := SaveFeishuPlatformCredentials(FeishuCredentialUpdateOptions{ ProjectName: "alpha", PlatformIndex: 2, PlatformType: "feishu", AppID: "cli_second_app", AppSecret: "sec_second_secret", OwnerOpenID: "ou_should_not_override", SetAllowFromEmpty: true, }) if err != nil { t.Fatalf("SaveFeishuPlatformCredentials returned error: %v", err) } if result.PlatformAbsIndex != 2 { t.Fatalf("result.PlatformAbsIndex = %d, want 2", result.PlatformAbsIndex) } if result.PlatformType != "feishu" { t.Fatalf("result.PlatformType = %q, want %q", result.PlatformType, "feishu") } if result.AllowFrom != "ou_existing_owner,ou_should_not_override" { t.Fatalf("result.AllowFrom = %q, want %q", result.AllowFrom, "ou_existing_owner,ou_should_not_override") } cfg := readConfigFixture(t, configPath) platform := cfg.Projects[0].Platforms[2] if platform.Type != "feishu" { t.Fatalf("platform.Type = %q, want %q", platform.Type, "feishu") } if got := stringMapValue(platform.Options, "app_id"); got != "cli_second_app" { t.Fatalf("app_id = %q, want %q", got, "cli_second_app") } if got := stringMapValue(platform.Options, "app_secret"); got != "sec_second_secret" { t.Fatalf("app_secret = %q, want %q", got, "sec_second_secret") } if got := stringMapValue(platform.Options, "allow_from"); got != "ou_existing_owner,ou_should_not_override" { t.Fatalf("allow_from = %q, want %q", got, "ou_existing_owner,ou_should_not_override") } } func TestSaveFeishuPlatformCredentials_AppendsOwnerToAllowFrom(t *testing.T) { configPath := writeConfigFixture(t, feishuConfigFixture) patchConfigPath(t, configPath) result, err := SaveFeishuPlatformCredentials(FeishuCredentialUpdateOptions{ ProjectName: "alpha", PlatformIndex: 2, PlatformType: "feishu", AppID: "cli_second_app", AppSecret: "sec_second_secret", OwnerOpenID: "ou_new_owner", SetAllowFromEmpty: true, }) if err != nil { t.Fatalf("SaveFeishuPlatformCredentials returned error: %v", err) } if result.AllowFrom != "ou_existing_owner,ou_new_owner" { t.Fatalf("result.AllowFrom = %q, want %q", result.AllowFrom, "ou_existing_owner,ou_new_owner") } cfg := readConfigFixture(t, configPath) platform := cfg.Projects[0].Platforms[2] if got := stringMapValue(platform.Options, "allow_from"); got != "ou_existing_owner,ou_new_owner" { t.Fatalf("allow_from = %q, want %q", got, "ou_existing_owner,ou_new_owner") } } func TestSaveFeishuPlatformCredentials_LeavesWildcardAllowFromUnchanged(t *testing.T) { configPath := writeConfigFixture(t, strings.Replace(feishuConfigFixture, `allow_from = "ou_existing_owner"`, `allow_from = "*"`, 1)) patchConfigPath(t, configPath) result, err := SaveFeishuPlatformCredentials(FeishuCredentialUpdateOptions{ ProjectName: "alpha", PlatformIndex: 2, OwnerOpenID: "ou_new_owner", AppID: "cli_second_app", AppSecret: "sec_second_secret", SetAllowFromEmpty: true, }) if err != nil { t.Fatalf("SaveFeishuPlatformCredentials returned error: %v", err) } if result.AllowFrom != "*" { t.Fatalf("result.AllowFrom = %q, want %q", result.AllowFrom, "*") } cfg := readConfigFixture(t, configPath) platform := cfg.Projects[0].Platforms[2] if got := stringMapValue(platform.Options, "allow_from"); got != "*" { t.Fatalf("allow_from = %q, want %q", got, "*") } } func TestSaveFeishuPlatformCredentials_ReturnsIndexRangeError(t *testing.T) { configPath := writeConfigFixture(t, feishuConfigFixture) patchConfigPath(t, configPath) _, err := SaveFeishuPlatformCredentials(FeishuCredentialUpdateOptions{ ProjectName: "alpha", PlatformIndex: 3, AppID: "cli_any", AppSecret: "sec_any", }) if err == nil { t.Fatal("expected error for out-of-range platform index, got nil") } if !strings.Contains(err.Error(), "out of range") { t.Fatalf("error = %q, want contains %q", err.Error(), "out of range") } } func TestEnsureProjectWithFeishuPlatform_CreatesMissingProject(t *testing.T) { configPath := writeConfigFixture(t, feishuConfigFixture) patchConfigPath(t, configPath) result, err := EnsureProjectWithFeishuPlatform(EnsureProjectWithFeishuOptions{ ProjectName: "gamma", PlatformType: "lark", WorkDir: "/tmp/gamma", }) if err != nil { t.Fatalf("EnsureProjectWithFeishuPlatform returned error: %v", err) } if !result.Created { t.Fatal("result.Created = false, want true") } if result.AddedPlatform { t.Fatal("result.AddedPlatform = true, want false") } cfg := readConfigFixture(t, configPath) if len(cfg.Projects) != 2 { t.Fatalf("len(cfg.Projects) = %d, want 2", len(cfg.Projects)) } proj := cfg.Projects[1] if proj.Name != "gamma" { t.Fatalf("proj.Name = %q, want %q", proj.Name, "gamma") } if len(proj.Platforms) != 1 { t.Fatalf("len(proj.Platforms) = %d, want 1", len(proj.Platforms)) } if proj.Platforms[0].Type != "lark" { t.Fatalf("platform type = %q, want %q", proj.Platforms[0].Type, "lark") } if got := stringMapValue(proj.Agent.Options, "work_dir"); got != "/tmp/gamma" { t.Fatalf("work_dir = %q, want explicit override %q", got, "/tmp/gamma") } } func TestEnsureProjectWithFeishuPlatform_AddsPlatformWhenProjectExistsWithoutFeishu(t *testing.T) { configPath := writeConfigFixture(t, projectWithoutFeishuFixture) patchConfigPath(t, configPath) result, err := EnsureProjectWithFeishuPlatform(EnsureProjectWithFeishuOptions{ ProjectName: "beta", PlatformType: "feishu", }) if err != nil { t.Fatalf("EnsureProjectWithFeishuPlatform returned error: %v", err) } if result.Created { t.Fatal("result.Created = true, want false") } if !result.AddedPlatform { t.Fatal("result.AddedPlatform = false, want true") } cfg := readConfigFixture(t, configPath) proj := cfg.Projects[0] if len(proj.Platforms) != 2 { t.Fatalf("len(proj.Platforms) = %d, want 2", len(proj.Platforms)) } if proj.Platforms[1].Type != "feishu" { t.Fatalf("platform type = %q, want %q", proj.Platforms[1].Type, "feishu") } } func TestSaveFeishuPlatformCredentials_PreservesCommentsAndUnknownFields(t *testing.T) { configPath := writeConfigFixture(t, preserveFormatFixture) patchConfigPath(t, configPath) _, err := SaveFeishuPlatformCredentials(FeishuCredentialUpdateOptions{ ProjectName: "alpha", AppID: "cli_new_app", AppSecret: "sec_new_secret", }) if err != nil { t.Fatalf("SaveFeishuPlatformCredentials returned error: %v", err) } content, err := os.ReadFile(configPath) if err != nil { t.Fatalf("read config fixture: %v", err) } text := string(content) if !strings.Contains(text, "# top comment should stay") { t.Fatalf("expected top comment to be preserved, got:\n%s", text) } if !strings.Contains(text, `custom_top = "keep_me"`) { t.Fatalf("expected unknown top-level field to be preserved, got:\n%s", text) } if !strings.Contains(text, `custom_option = "still_here"`) { t.Fatalf("expected unknown options field to be preserved, got:\n%s", text) } if !strings.Contains(text, "keep inline comment") { t.Fatalf("expected inline comment to be preserved, got:\n%s", text) } } func TestLoad_DefaultsAttachmentSendToOn(t *testing.T) { configPath := writeConfigFixture(t, projectWithoutFeishuFixture) cfg, err := Load(configPath) if err != nil { t.Fatalf("Load returned error: %v", err) } if cfg.AttachmentSend != "on" { t.Fatalf("cfg.AttachmentSend = %q, want %q", cfg.AttachmentSend, "on") } } func TestLoad_DefaultsAutoCompressDisabled(t *testing.T) { configPath := writeConfigFixture(t, projectWithoutFeishuFixture) cfg, err := Load(configPath) if err != nil { t.Fatalf("Load returned error: %v", err) } if len(cfg.Projects) == 0 { t.Fatalf("expected at least one project") } if cfg.Projects[0].AutoCompress.Enabled != nil { t.Fatalf("expected auto_compress.enabled to default to nil") } } func TestLoad_ParsesResetOnIdleMins(t *testing.T) { configPath := writeConfigFixture(t, projectWithResetOnIdleFixture) cfg, err := Load(configPath) if err != nil { t.Fatalf("Load returned error: %v", err) } if cfg.Projects[0].ResetOnIdleMins == nil { t.Fatal("expected reset_on_idle_mins to be parsed") } if got := *cfg.Projects[0].ResetOnIdleMins; got != 60 { t.Fatalf("reset_on_idle_mins = %d, want 60", got) } } func TestLoad_RejectsNegativeResetOnIdleMins(t *testing.T) { configPath := writeConfigFixture(t, projectWithNegativeResetOnIdleFixture) _, err := Load(configPath) if err == nil { t.Fatal("expected error for negative reset_on_idle_mins") } if !strings.Contains(err.Error(), "reset_on_idle_mins") { t.Fatalf("error = %q, want reset_on_idle_mins validation", err.Error()) } } func TestLoad_ParsesRunAsUser(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("run_as_user is only supported on Linux/macOS") } configPath := writeConfigFixture(t, projectWithRunAsUserFixture) cfg, err := Load(configPath) if err != nil { t.Fatalf("Load returned error: %v", err) } if got := cfg.Projects[0].RunAsUser; got != "partseeker-coder" { t.Fatalf("run_as_user = %q, want %q", got, "partseeker-coder") } if got := cfg.Projects[0].RunAsEnv; len(got) != 2 || got[0] != "PGSSLROOTCERT" || got[1] != "PGSSLMODE" { t.Fatalf("run_as_env = %v, want [PGSSLROOTCERT PGSSLMODE]", got) } } func TestLoad_RejectsRunAsUserRoot(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("run_as_user is only supported on Linux/macOS") } configPath := writeConfigFixture(t, projectWithRunAsUserRootFixture) _, err := Load(configPath) if err == nil { t.Fatal("expected error for run_as_user = root") } if !strings.Contains(err.Error(), "must not be root") { t.Fatalf("error = %q, want 'must not be root' validation", err.Error()) } } func TestLoad_RejectsRunAsUserInvalidChars(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("run_as_user is only supported on Linux/macOS") } configPath := writeConfigFixture(t, projectWithRunAsUserInvalidFixture) _, err := Load(configPath) if err == nil { t.Fatal("expected error for invalid run_as_user") } if !strings.Contains(err.Error(), "invalid characters") { t.Fatalf("error = %q, want 'invalid characters' validation", err.Error()) } } func TestValidateRunAsUser_ValidNames(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("run_as_user is only supported on Linux/macOS") } valid := []string{"leigh", "partseeker-coder", "user_name", "user.name", "u1", "_internal"} for _, name := range valid { if err := validateRunAsUser("projects[0]", name); err != nil { t.Errorf("validateRunAsUser(%q) = %v, want nil", name, err) } } } func TestValidateRunAsUser_InvalidNames(t *testing.T) { if runtime.GOOS == "windows" { t.Skip("run_as_user is only supported on Linux/macOS") } invalid := []string{ "-leading-dash", "1leading-digit", "has space", "has/slash", "has;semi", "has$dollar", "has`tick", strings.Repeat("a", 33), // too long } for _, name := range invalid { if err := validateRunAsUser("projects[0]", name); err == nil { t.Errorf("validateRunAsUser(%q) = nil, want error", name) } } } func TestLoad_ParsesAttachmentSendOff(t *testing.T) { configPath := writeConfigFixture(t, attachmentSendConfigFixture) cfg, err := Load(configPath) if err != nil { t.Fatalf("Load returned error: %v", err) } if cfg.AttachmentSend != "off" { t.Fatalf("cfg.AttachmentSend = %q, want %q", cfg.AttachmentSend, "off") } } func TestLoad_FilterExternalSessionsDefault(t *testing.T) { configPath := writeConfigFixture(t, attachmentSendConfigFixture) cfg, err := Load(configPath) if err != nil { t.Fatalf("Load: %v", err) } proj := cfg.Projects[0] if proj.FilterExternalSessions != nil { t.Fatalf("FilterExternalSessions should be nil by default, got %v", *proj.FilterExternalSessions) } } func TestLoad_FilterExternalSessionsTrue(t *testing.T) { fixture := ` [[projects]] name = "beta" filter_external_sessions = true [projects.agent] type = "codex" [projects.agent.options] work_dir = "/tmp/beta" [[projects.platforms]] type = "telegram" [projects.platforms.options] token = "test" ` configPath := writeConfigFixture(t, fixture) cfg, err := Load(configPath) if err != nil { t.Fatalf("Load: %v", err) } proj := cfg.Projects[0] if proj.FilterExternalSessions == nil || !*proj.FilterExternalSessions { t.Fatalf("FilterExternalSessions should be true, got %v", proj.FilterExternalSessions) } } func TestLoad_FilterExternalSessionsFalse(t *testing.T) { fixture := ` [[projects]] name = "gamma" filter_external_sessions = false [projects.agent] type = "codex" [projects.agent.options] work_dir = "/tmp/gamma" [[projects.platforms]] type = "telegram" [projects.platforms.options] token = "test" ` configPath := writeConfigFixture(t, fixture) cfg, err := Load(configPath) if err != nil { t.Fatalf("Load: %v", err) } proj := cfg.Projects[0] if proj.FilterExternalSessions == nil || *proj.FilterExternalSessions { t.Fatalf("FilterExternalSessions should be false, got %v", proj.FilterExternalSessions) } } func validProject(name string) ProjectConfig { return ProjectConfig{ Name: name, Agent: AgentConfig{ Type: "claudecode", Options: map[string]any{"mode": "default"}, }, Platforms: []PlatformConfig{ {Type: "telegram", Options: map[string]any{"token": "test-token"}}, }, } } func assertErrContains(t *testing.T, err error, want string) { t.Helper() if err == nil { t.Fatalf("expected error containing %q, got nil", want) } if !strings.Contains(err.Error(), want) { t.Fatalf("error = %q, want substring %q", err.Error(), want) } } func writeTestConfig(t *testing.T, content string) { t.Helper() dir := t.TempDir() path := filepath.Join(dir, "config.toml") if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatalf("write config: %v", err) } oldPath := ConfigPath ConfigPath = path t.Cleanup(func() { ConfigPath = oldPath }) } func readTestConfig(t *testing.T) Config { t.Helper() data, err := os.ReadFile(ConfigPath) if err != nil { t.Fatalf("read config: %v", err) } var cfg Config if err := toml.Unmarshal(data, &cfg); err != nil { t.Fatalf("parse config: %v", err) } return cfg } func TestLoadRelayTimeoutConfig(t *testing.T) { configPath := writeConfigFixture(t, relayConfigFixture) cfg, err := Load(configPath) if err != nil { t.Fatalf("Load returned error: %v", err) } if cfg.Relay.TimeoutSecs == nil { t.Fatal("cfg.Relay.TimeoutSecs = nil, want non-nil") } if *cfg.Relay.TimeoutSecs != 300 { t.Fatalf("cfg.Relay.TimeoutSecs = %d, want 300", *cfg.Relay.TimeoutSecs) } } func TestLoadRejectsNegativeRelayTimeout(t *testing.T) { configPath := writeConfigFixture(t, relayConfigNegativeFixture) _, err := Load(configPath) if err == nil { t.Fatal("expected error for negative relay timeout, got nil") } if !strings.Contains(err.Error(), "relay.timeout_secs must be >= 0") { t.Fatalf("error = %q, want contains %q", err.Error(), "relay.timeout_secs must be >= 0") } } func writeConfigFixture(t *testing.T, content string) string { t.Helper() dir := t.TempDir() path := filepath.Join(dir, "config.toml") if err := os.WriteFile(path, []byte(content), 0o644); err != nil { t.Fatalf("write config fixture: %v", err) } return path } func patchConfigPath(t *testing.T, path string) { t.Helper() prev := ConfigPath ConfigPath = path t.Cleanup(func() { ConfigPath = prev }) } func readConfigFixture(t *testing.T, path string) *Config { t.Helper() data, err := os.ReadFile(path) if err != nil { t.Fatalf("read config fixture: %v", err) } cfg := &Config{} if err := toml.Unmarshal(data, cfg); err != nil { t.Fatalf("parse config fixture: %v", err) } return cfg } func stringMapValue(m map[string]any, key string) string { if m == nil { return "" } if v, ok := m[key].(string); ok { return v } return "" } const baseConfigTOML = ` [[projects]] name = "demo" [projects.agent] type = "claudecode" [projects.agent.options] mode = "default" [[projects.platforms]] type = "telegram" [projects.platforms.options] token = "test-token" ` const providerConfigTOML = ` [[projects]] name = "demo" [projects.agent] type = "claudecode" [projects.agent.options] mode = "default" provider = "primary" [[projects.agent.providers]] name = "primary" api_key = "sk-primary" [[projects.agent.providers]] name = "backup" api_key = "sk-backup" [[projects.platforms]] type = "telegram" [projects.platforms.options] token = "test-token" ` const feishuConfigFixture = ` [[projects]] name = "alpha" [projects.agent] type = "codex" [projects.agent.options] work_dir = "/tmp/alpha" [[projects.platforms]] type = "telegram" [projects.platforms.options] bot_token = "token_xxx" [[projects.platforms]] type = "feishu" [projects.platforms.options] app_id = "old_feishu_app" app_secret = "old_feishu_secret" [[projects.platforms]] type = "lark" [projects.platforms.options] app_id = "old_lark_app" app_secret = "old_lark_secret" allow_from = "ou_existing_owner" ` const projectWithoutFeishuFixture = ` [[projects]] name = "beta" [projects.agent] type = "codex" [projects.agent.options] work_dir = "/tmp/beta" [[projects.platforms]] type = "telegram" [projects.platforms.options] bot_token = "token_xxx" ` const projectWithResetOnIdleFixture = ` [[projects]] name = "beta" reset_on_idle_mins = 60 [projects.agent] type = "codex" [projects.agent.options] work_dir = "/tmp/beta" [[projects.platforms]] type = "telegram" [projects.platforms.options] bot_token = "token_xxx" ` const projectWithNegativeResetOnIdleFixture = ` [[projects]] name = "beta" reset_on_idle_mins = -1 [projects.agent] type = "codex" [projects.agent.options] work_dir = "/tmp/beta" [[projects.platforms]] type = "telegram" [projects.platforms.options] bot_token = "token_xxx" ` const projectWithRunAsUserFixture = ` [[projects]] name = "sandboxed" run_as_user = "partseeker-coder" run_as_env = ["PGSSLROOTCERT", "PGSSLMODE"] [projects.agent] type = "claudecode" [projects.agent.options] work_dir = "/tmp/sandboxed" [[projects.platforms]] type = "slack" [projects.platforms.options] app_token = "xapp-token" bot_token = "xoxb-token" ` const projectWithRunAsUserRootFixture = ` [[projects]] name = "bad" run_as_user = "root" [projects.agent] type = "claudecode" [projects.agent.options] work_dir = "/tmp/bad" [[projects.platforms]] type = "slack" [projects.platforms.options] app_token = "xapp-token" bot_token = "xoxb-token" ` const projectWithRunAsUserInvalidFixture = ` [[projects]] name = "bad" run_as_user = "has space" [projects.agent] type = "claudecode" [projects.agent.options] work_dir = "/tmp/bad" [[projects.platforms]] type = "slack" [projects.platforms.options] app_token = "xapp-token" bot_token = "xoxb-token" ` const weixinConfigFixture = ` [[projects]] name = "alpha" [projects.agent] type = "codex" [projects.agent.options] work_dir = "/tmp/alpha" [[projects.platforms]] type = "weixin" [projects.platforms.options] token = "old_weixin_token" base_url = "https://ilink.example" ` const preserveFormatFixture = `# top comment should stay custom_top = "keep_me" [[projects]] name = "alpha" [projects.agent] type = "codex" [projects.agent.options] work_dir = "/tmp/alpha" [[projects.platforms]] type = "feishu" [projects.platforms.options] app_id = "old_app" # keep inline comment app_secret = "old_secret" custom_option = "still_here" ` // --- validateUsersConfig tests --- func TestValidateUsersConfig(t *testing.T) { tests := []struct { name string cfg Config wantErr string }{ { name: "nil users is valid", cfg: Config{ Projects: []ProjectConfig{{ Name: "p1", Agent: AgentConfig{Type: "codex"}, Platforms: []PlatformConfig{{Type: "telegram", Options: map[string]any{"token": "x"}}}, Users: nil, }}, }, wantErr: "", }, { name: "empty roles", cfg: Config{ Projects: []ProjectConfig{{ Name: "p1", Agent: AgentConfig{Type: "codex"}, Platforms: []PlatformConfig{{Type: "telegram", Options: map[string]any{"token": "x"}}}, Users: &UsersConfig{Roles: map[string]RoleConfig{}}, }}, }, wantErr: `no roles defined`, }, { name: "empty user_ids in role", cfg: Config{ Projects: []ProjectConfig{{ Name: "p1", Agent: AgentConfig{Type: "codex"}, Platforms: []PlatformConfig{{Type: "telegram", Options: map[string]any{"token": "x"}}}, Users: &UsersConfig{ Roles: map[string]RoleConfig{ "admin": {UserIDs: []string{}}, }, }, }}, }, wantErr: `empty user_ids`, }, { name: "duplicate user in different roles", cfg: Config{ Projects: []ProjectConfig{{ Name: "p1", Agent: AgentConfig{Type: "codex"}, Platforms: []PlatformConfig{{Type: "telegram", Options: map[string]any{"token": "x"}}}, Users: &UsersConfig{ Roles: map[string]RoleConfig{ "admin": {UserIDs: []string{"user1"}}, "member": {UserIDs: []string{"user1"}}, }, }, }}, }, wantErr: `appears in both role`, }, { name: "wildcard in multiple roles", cfg: Config{ Projects: []ProjectConfig{{ Name: "p1", Agent: AgentConfig{Type: "codex"}, Platforms: []PlatformConfig{{Type: "telegram", Options: map[string]any{"token": "x"}}}, Users: &UsersConfig{ Roles: map[string]RoleConfig{ "admin": {UserIDs: []string{"*"}}, "member": {UserIDs: []string{"*"}}, }, }, }}, }, wantErr: `wildcard`, }, { name: "default_role not matching any role", cfg: Config{ Projects: []ProjectConfig{{ Name: "p1", Agent: AgentConfig{Type: "codex"}, Platforms: []PlatformConfig{{Type: "telegram", Options: map[string]any{"token": "x"}}}, Users: &UsersConfig{ DefaultRole: "superadmin", Roles: map[string]RoleConfig{ "admin": {UserIDs: []string{"u1"}}, }, }, }}, }, wantErr: `default_role`, }, { name: "valid users config", cfg: Config{ Projects: []ProjectConfig{{ Name: "p1", Agent: AgentConfig{Type: "codex"}, Platforms: []PlatformConfig{{Type: "telegram", Options: map[string]any{"token": "x"}}}, Users: &UsersConfig{ DefaultRole: "member", Roles: map[string]RoleConfig{ "admin": {UserIDs: []string{"admin1"}}, "member": {UserIDs: []string{"*"}}, }, }, }}, }, wantErr: "", }, { name: "valid with wildcard in one role only", cfg: Config{ Projects: []ProjectConfig{{ Name: "p1", Agent: AgentConfig{Type: "codex"}, Platforms: []PlatformConfig{{Type: "telegram", Options: map[string]any{"token": "x"}}}, Users: &UsersConfig{ Roles: map[string]RoleConfig{ "admin": {UserIDs: []string{"u1"}}, "member": {UserIDs: []string{"*", "u2"}}, }, }, }}, }, wantErr: "", }, } for _, tt := range tests { t.Run(tt.name, func(t *testing.T) { err := validateUsersConfig("projects[0]", tt.cfg.Projects[0].Users) if tt.wantErr == "" { if err != nil { t.Errorf("unexpected error: %v", err) } } else { if err == nil { t.Error("expected error, got nil") } else if !strings.Contains(err.Error(), tt.wantErr) { t.Errorf("error = %q, want substring %q", err.Error(), tt.wantErr) } } }) } } // --- cloneStringMap tests --- func TestCloneStringMap(t *testing.T) { // nil map if got := cloneStringMap(nil); got != nil { t.Errorf("cloneStringMap(nil) = %v, want nil", got) } // empty map empty := cloneStringMap(map[string]string{}) if got := cloneStringMap(empty); got == nil || len(got) != 0 { t.Errorf("cloneStringMap(empty) = %v, want empty non-nil map", got) } // populated map orig := map[string]string{"key1": "val1", "key2": "val2"} cloned := cloneStringMap(orig) if len(cloned) != len(orig) { t.Errorf("length mismatch: got %d, want %d", len(cloned), len(orig)) } for k, v := range orig { if cloned[k] != v { t.Errorf("cloneStringMap[%q] = %q, want %q", k, cloned[k], v) } } // verify it's a deep copy delete(cloned, "key1") if _, ok := orig["key1"]; !ok { t.Error("cloneStringMap returned same map reference, not a copy") } } // --- pickAgentTemplateForNewProject tests --- func TestPickAgentTemplateForNewProject(t *testing.T) { baseProj := ProjectConfig{ Name: "base", Agent: AgentConfig{ Type: "claudecode", Options: map[string]any{"mode": "yolo"}, Providers: []ProviderConfig{{ Name: "openai", APIKey: "sk-test", Model: "gpt-4", }}, }, Platforms: []PlatformConfig{{Type: "telegram", Options: map[string]any{"token": "x"}}}, } t.Run("clone from existing project", func(t *testing.T) { cfg := &Config{Projects: []ProjectConfig{baseProj}} opts := EnsureProjectWithFeishuOptions{CloneFromProject: "base"} got := pickAgentTemplateForNewProject(cfg, opts) if got.Type != "claudecode" { t.Errorf("Type = %q, want claudecode", got.Type) } if len(got.Providers) != 1 || got.Providers[0].APIKey != "sk-test" { t.Errorf("Providers not cloned correctly") } }) t.Run("no clone but has projects", func(t *testing.T) { cfg := &Config{Projects: []ProjectConfig{baseProj}} opts := EnsureProjectWithFeishuOptions{} got := pickAgentTemplateForNewProject(cfg, opts) if got.Type != "claudecode" { t.Errorf("Type = %q, want claudecode", got.Type) } }) t.Run("no projects uses default codex", func(t *testing.T) { cfg := &Config{Projects: []ProjectConfig{}} opts := EnsureProjectWithFeishuOptions{} got := pickAgentTemplateForNewProject(cfg, opts) if got.Type != "codex" { t.Errorf("Type = %q, want codex", got.Type) } if got.Options == nil { t.Error("Options should not be nil") } }) t.Run("no projects with explicit agent type", func(t *testing.T) { cfg := &Config{Projects: []ProjectConfig{}} opts := EnsureProjectWithFeishuOptions{AgentType: "gemini"} got := pickAgentTemplateForNewProject(cfg, opts) if got.Type != "gemini" { t.Errorf("Type = %q, want gemini", got.Type) } }) t.Run("explicit agent type overrides clone from first project", func(t *testing.T) { cfg := &Config{Projects: []ProjectConfig{baseProj}} opts := EnsureProjectWithFeishuOptions{AgentType: "cursor"} got := pickAgentTemplateForNewProject(cfg, opts) if got.Type != "cursor" { t.Errorf("Type = %q, want cursor (explicit AgentType should take priority over cloning first project)", got.Type) } }) } // --- cloneAgentConfig tests --- func TestCloneAgentConfig(t *testing.T) { t.Run("without providers", func(t *testing.T) { in := AgentConfig{ Type: "codex", Options: map[string]any{"mode": "default"}, } got := cloneAgentConfig(in) if got.Type != "codex" { t.Errorf("Type = %q, want codex", got.Type) } if got.Options["mode"] != "default" { t.Errorf("Options not cloned") } if len(got.Providers) != 0 { t.Errorf("Providers length = %d, want 0", len(got.Providers)) } }) t.Run("with providers", func(t *testing.T) { in := AgentConfig{ Type: "claudecode", Options: map[string]any{"work_dir": "/tmp/test"}, Providers: []ProviderConfig{ { Name: "openai", APIKey: "sk-test", BaseURL: "https://api.openai.com", Model: "gpt-4", Thinking: "on", Env: map[string]string{"DEBUG": "1"}, }, }, } got := cloneAgentConfig(in) if len(got.Providers) != 1 { t.Fatalf("Providers length = %d, want 1", len(got.Providers)) } p := got.Providers[0] if p.Name != "openai" || p.APIKey != "sk-test" || p.BaseURL != "https://api.openai.com" || p.Model != "gpt-4" { t.Errorf("Provider fields not cloned correctly: %+v", p) } if p.Env["DEBUG"] != "1" { t.Errorf("Provider Env not cloned correctly") } // Verify deep copy of Options got.Options["mode"] = "changed" if in.Options["mode"] == "changed" { t.Error("Options is same reference, not a deep copy") } // Verify deep copy of Provider Env delete(got.Providers[0].Env, "DEBUG") if in.Providers[0].Env["DEBUG"] == "" { t.Error("Provider Env is same reference, not a deep copy") } }) } func TestEnsureProjectWithWeixinPlatform_CreatesMissingProject(t *testing.T) { configPath := writeConfigFixture(t, feishuConfigFixture) patchConfigPath(t, configPath) result, err := EnsureProjectWithWeixinPlatform(EnsureProjectWithWeixinOptions{ ProjectName: "gamma", WorkDir: "/tmp/gamma", }) if err != nil { t.Fatalf("EnsureProjectWithWeixinPlatform returned error: %v", err) } if !result.Created { t.Fatal("result.Created = false, want true") } if result.AddedPlatform { t.Fatal("result.AddedPlatform = true, want false") } cfg := readConfigFixture(t, configPath) if len(cfg.Projects) != 2 { t.Fatalf("len(cfg.Projects) = %d, want 2", len(cfg.Projects)) } proj := cfg.Projects[1] if proj.Name != "gamma" { t.Fatalf("proj.Name = %q, want %q", proj.Name, "gamma") } if len(proj.Platforms) != 1 { t.Fatalf("len(proj.Platforms) = %d, want 1", len(proj.Platforms)) } if proj.Platforms[0].Type != "weixin" { t.Fatalf("platform type = %q, want weixin", proj.Platforms[0].Type) } } func TestEnsureProjectWithWeixinPlatform_AddsPlatformWhenMissing(t *testing.T) { configPath := writeConfigFixture(t, projectWithoutFeishuFixture) patchConfigPath(t, configPath) result, err := EnsureProjectWithWeixinPlatform(EnsureProjectWithWeixinOptions{ ProjectName: "beta", }) if err != nil { t.Fatalf("EnsureProjectWithWeixinPlatform returned error: %v", err) } if result.Created { t.Fatal("result.Created = true, want false") } if !result.AddedPlatform { t.Fatal("result.AddedPlatform = false, want true") } cfg := readConfigFixture(t, configPath) proj := cfg.Projects[0] if len(proj.Platforms) != 2 { t.Fatalf("len(proj.Platforms) = %d, want 2", len(proj.Platforms)) } if proj.Platforms[1].Type != "weixin" { t.Fatalf("platform type = %q, want weixin", proj.Platforms[1].Type) } } func TestSaveWeixinPlatformCredentials_UpdateToken(t *testing.T) { configPath := writeConfigFixture(t, weixinConfigFixture) patchConfigPath(t, configPath) _, err := SaveWeixinPlatformCredentials(WeixinCredentialUpdateOptions{ ProjectName: "alpha", Token: "new_weixin_token", BaseURL: "https://ilinkai.weixin.qq.com", }) if err != nil { t.Fatalf("SaveWeixinPlatformCredentials returned error: %v", err) } cfg := readConfigFixture(t, configPath) tok, _ := cfg.Projects[0].Platforms[0].Options["token"].(string) if tok != "new_weixin_token" { t.Fatalf("token = %q, want new_weixin_token", tok) } bu, _ := cfg.Projects[0].Platforms[0].Options["base_url"].(string) if bu != "https://ilinkai.weixin.qq.com" { t.Fatalf("base_url = %q", bu) } } func TestSaveWeixinPlatformCredentials_AppendsScannedUserToAllowFrom(t *testing.T) { configPath := writeConfigFixture(t, strings.Replace(weixinConfigFixture, `base_url = "https://ilink.example"`, "base_url = \"https://ilink.example\"\nallow_from = \"wx_user_1\"", 1)) patchConfigPath(t, configPath) result, err := SaveWeixinPlatformCredentials(WeixinCredentialUpdateOptions{ ProjectName: "alpha", Token: "new_weixin_token", ScannedUserID: "wx_user_2", SetAllowFromEmpty: true, }) if err != nil { t.Fatalf("SaveWeixinPlatformCredentials returned error: %v", err) } if result.AllowFrom != "wx_user_1,wx_user_2" { t.Fatalf("result.AllowFrom = %q, want %q", result.AllowFrom, "wx_user_1,wx_user_2") } cfg := readConfigFixture(t, configPath) if got := stringMapValue(cfg.Projects[0].Platforms[0].Options, "allow_from"); got != "wx_user_1,wx_user_2" { t.Fatalf("allow_from = %q, want %q", got, "wx_user_1,wx_user_2") } } func TestSaveWeixinPlatformCredentials_LeavesWildcardAllowFromUnchanged(t *testing.T) { configPath := writeConfigFixture(t, strings.Replace(weixinConfigFixture, `base_url = "https://ilink.example"`, "base_url = \"https://ilink.example\"\nallow_from = \"*\"", 1)) patchConfigPath(t, configPath) result, err := SaveWeixinPlatformCredentials(WeixinCredentialUpdateOptions{ ProjectName: "alpha", Token: "new_weixin_token", ScannedUserID: "wx_user_2", SetAllowFromEmpty: true, }) if err != nil { t.Fatalf("SaveWeixinPlatformCredentials returned error: %v", err) } if result.AllowFrom != "*" { t.Fatalf("result.AllowFrom = %q, want %q", result.AllowFrom, "*") } cfg := readConfigFixture(t, configPath) if got := stringMapValue(cfg.Projects[0].Platforms[0].Options, "allow_from"); got != "*" { t.Fatalf("allow_from = %q, want %q", got, "*") } } func TestSaveProjectSettings_ExtraFields(t *testing.T) { configPath := writeConfigFixture(t, feishuConfigFixture) patchConfigPath(t, configPath) show := true wd := "/tmp/patched" mode := "yolo" err := SaveProjectSettings("alpha", ProjectSettingsUpdate{ WorkDir: &wd, Mode: &mode, ShowContextIndicator: &show, PlatformAllowFrom: map[string]string{"telegram": "u1", "Feishu": "u2"}, }) if err != nil { t.Fatalf("SaveProjectSettings: %v", err) } cfg := readConfigFixture(t, configPath) proj := cfg.Projects[0] if stringMapValue(proj.Agent.Options, "work_dir") != wd { t.Fatalf("work_dir = %q, want %q", stringMapValue(proj.Agent.Options, "work_dir"), wd) } if stringMapValue(proj.Agent.Options, "mode") != mode { t.Fatalf("mode = %q, want %q", stringMapValue(proj.Agent.Options, "mode"), mode) } if proj.ShowContextIndicator == nil || !*proj.ShowContextIndicator { t.Fatalf("ShowContextIndicator = %v, want true", proj.ShowContextIndicator) } if stringMapValue(proj.Platforms[0].Options, "allow_from") != "u1" { t.Fatalf("telegram allow_from = %q, want u1", stringMapValue(proj.Platforms[0].Options, "allow_from")) } if stringMapValue(proj.Platforms[1].Options, "allow_from") != "u2" { t.Fatalf("feishu allow_from = %q, want u2", stringMapValue(proj.Platforms[1].Options, "allow_from")) } } func TestGetProjectConfigDetails(t *testing.T) { configPath := writeConfigFixture(t, feishuConfigFixture) patchConfigPath(t, configPath) details := GetProjectConfigDetails("alpha") if details == nil { t.Fatal("GetProjectConfigDetails returned nil") } if details["work_dir"] != "/tmp/alpha" { t.Fatalf("work_dir = %v", details["work_dir"]) } pcs, ok := details["platform_configs"].([]map[string]any) if !ok || len(pcs) < 2 { t.Fatalf("platform_configs = %#v", details["platform_configs"]) } } func TestAddPlatformToProject_NewProjectWithAgentTypeAndWorkDir(t *testing.T) { configPath := writeConfigFixture(t, feishuConfigFixture) patchConfigPath(t, configPath) err := AddPlatformToProject("sigma", PlatformConfig{Type: "slack", Options: map[string]any{"token": "x"}}, "/sigma", "gemini") if err != nil { t.Fatalf("AddPlatformToProject: %v", err) } cfg := readConfigFixture(t, configPath) if len(cfg.Projects) != 2 { t.Fatalf("len(projects) = %d, want 2", len(cfg.Projects)) } proj := cfg.Projects[1] if proj.Name != "sigma" { t.Fatalf("name = %q", proj.Name) } if proj.Agent.Type != "gemini" { t.Fatalf("agent type = %q, want gemini", proj.Agent.Type) } if stringMapValue(proj.Agent.Options, "work_dir") != "/sigma" { t.Fatalf("work_dir = %q", stringMapValue(proj.Agent.Options, "work_dir")) } if len(proj.Platforms) != 1 || proj.Platforms[0].Type != "slack" { t.Fatalf("platforms = %#v", proj.Platforms) } } func TestAddPlatformToProject_NewProjectClonesAgentWhenAgentTypeEmpty(t *testing.T) { configPath := writeConfigFixture(t, feishuConfigFixture) patchConfigPath(t, configPath) err := AddPlatformToProject("tau", PlatformConfig{Type: "slack", Options: map[string]any{"token": "x"}}, "", "") if err != nil { t.Fatalf("AddPlatformToProject: %v", err) } cfg := readConfigFixture(t, configPath) proj := cfg.Projects[len(cfg.Projects)-1] if proj.Agent.Type != "codex" { t.Fatalf("agent type = %q, want codex (cloned)", proj.Agent.Type) } if stringMapValue(proj.Agent.Options, "work_dir") != "/tmp/alpha" { t.Fatalf("cloned work_dir = %q, want /tmp/alpha", stringMapValue(proj.Agent.Options, "work_dir")) } } func TestFormatTOML(t *testing.T) { tests := []struct { name, input, want string }{ { name: "collapse multiple blank lines", input: "a = 1\n\n\n\nb = 2\n", want: "a = 1\n\nb = 2\n", }, { name: "blank line before section header", input: "a = 1\n[section]\nb = 2\n", want: "a = 1\n\n[section]\nb = 2\n", }, { name: "strip trailing whitespace", input: "a = 1 \nb = 2\t\n", want: "a = 1\nb = 2\n", }, { name: "remove empty section", input: "[empty]\n\n[real]\nk = 1\n", want: "[real]\nk = 1\n", }, { name: "already formatted", input: "[section]\na = 1\n", want: "[section]\na = 1\n", }, { name: "preserves comments", input: "# comment\na = 1\n\n[section]\n# inline\nb = 2\n", want: "# comment\na = 1\n\n[section]\n# inline\nb = 2\n", }, } for _, tc := range tests { t.Run(tc.name, func(t *testing.T) { got := formatTOML(tc.input) if got != tc.want { t.Errorf("formatTOML:\n input: %q\n got: %q\n want: %q", tc.input, got, tc.want) } }) } } func TestFormatConfigFile(t *testing.T) { dir := t.TempDir() path := filepath.Join(dir, "config.toml") messy := "language = \"en\" \n\n\n\n[[projects]]\nname = \"test\"\n\n\n[projects.agent]\ntype = \"codex\"\n\n[projects.agent.options]\n\n[[projects.platforms]]\ntype = \"telegram\"\n\n[projects.platforms.options]\ntoken = \"abc\"\n" os.WriteFile(path, []byte(messy), 0o644) if err := FormatConfigFile(path); err != nil { t.Fatalf("FormatConfigFile: %v", err) } data, _ := os.ReadFile(path) content := string(data) if strings.Contains(content, " \n") { t.Error("trailing whitespace not stripped") } if strings.Contains(content, "\n\n\n") { t.Error("consecutive blank lines not collapsed") } cfg := &Config{} if _, err := toml.Decode(content, cfg); err != nil { t.Fatalf("formatted config is invalid TOML: %v", err) } if len(cfg.Projects) != 1 || cfg.Projects[0].Name != "test" { t.Error("formatting corrupted config content") } t.Run("no-op when already formatted", func(t *testing.T) { before, _ := os.ReadFile(path) if err := FormatConfigFile(path); err != nil { t.Fatalf("second FormatConfigFile: %v", err) } after, _ := os.ReadFile(path) if string(before) != string(after) { t.Error("idempotent format produced different output") } }) t.Run("rejects invalid TOML", func(t *testing.T) { badPath := filepath.Join(dir, "bad.toml") os.WriteFile(badPath, []byte("[invalid\n"), 0o644) if err := FormatConfigFile(badPath); err == nil { t.Error("expected error for invalid TOML") } }) } func TestResolveProviderRefs(t *testing.T) { cfg := &Config{ Providers: []ProviderConfig{ {Name: "global-a", APIKey: "key-a", BaseURL: "https://a.com"}, {Name: "global-b", APIKey: "key-b", BaseURL: "https://b.com"}, }, Projects: []ProjectConfig{ { Name: "proj-with-refs", Agent: AgentConfig{ Type: "claudecode", ProviderRefs: []string{"global-a", "global-b"}, }, }, { Name: "proj-inline-only", Agent: AgentConfig{ Type: "codex", Providers: []ProviderConfig{ {Name: "inline-p", APIKey: "inline-key"}, }, }, }, { Name: "proj-mixed", Agent: AgentConfig{ Type: "claudecode", ProviderRefs: []string{"global-a", "global-b"}, Providers: []ProviderConfig{ {Name: "global-a", APIKey: "override-key", BaseURL: "https://override.com"}, }, }, }, }, } cfg.ResolveProviderRefs() // proj-with-refs: should have both global providers p0 := cfg.Projects[0].Agent.Providers if len(p0) != 2 { t.Fatalf("proj-with-refs: expected 2 providers, got %d", len(p0)) } if p0[0].Name != "global-a" || p0[0].APIKey != "key-a" { t.Errorf("proj-with-refs[0]: expected global-a/key-a, got %s/%s", p0[0].Name, p0[0].APIKey) } if p0[1].Name != "global-b" || p0[1].APIKey != "key-b" { t.Errorf("proj-with-refs[1]: expected global-b/key-b, got %s/%s", p0[1].Name, p0[1].APIKey) } // proj-inline-only: should remain unchanged p1 := cfg.Projects[1].Agent.Providers if len(p1) != 1 || p1[0].Name != "inline-p" { t.Errorf("proj-inline-only: expected 1 inline provider, got %d", len(p1)) } // proj-mixed: inline override takes precedence for global-a, global-b from ref p2 := cfg.Projects[2].Agent.Providers if len(p2) != 2 { t.Fatalf("proj-mixed: expected 2 providers, got %d", len(p2)) } // global-b is resolved from ref (since no inline override) if p2[0].Name != "global-b" || p2[0].APIKey != "key-b" { t.Errorf("proj-mixed[0]: expected global-b from ref, got %s/%s", p2[0].Name, p2[0].APIKey) } // global-a is from inline override if p2[1].Name != "global-a" || p2[1].APIKey != "override-key" { t.Errorf("proj-mixed[1]: expected global-a override, got %s/%s", p2[1].Name, p2[1].APIKey) } } func TestResolveProviderRefs_MissingRef(t *testing.T) { cfg := &Config{ Providers: []ProviderConfig{ {Name: "exists", APIKey: "key"}, }, Projects: []ProjectConfig{ { Name: "proj", Agent: AgentConfig{ Type: "claudecode", ProviderRefs: []string{"exists", "nonexistent"}, }, }, }, } cfg.ResolveProviderRefs() providers := cfg.Projects[0].Agent.Providers if len(providers) != 1 || providers[0].Name != "exists" { t.Errorf("expected 1 resolved provider 'exists', got %d: %+v", len(providers), providers) } } func TestResolveProviderRefs_AgentTypeFiltering(t *testing.T) { cfg := &Config{ Providers: []ProviderConfig{ {Name: "claude-only", APIKey: "key-c", AgentTypes: []string{"claudecode"}}, {Name: "codex-only", APIKey: "key-x", AgentTypes: []string{"codex"}}, {Name: "universal", APIKey: "key-u"}, // no agent_types = works for all }, Projects: []ProjectConfig{ { Name: "proj-claude", Agent: AgentConfig{ Type: "claudecode", ProviderRefs: []string{"claude-only", "codex-only", "universal"}, }, }, { Name: "proj-codex", Agent: AgentConfig{ Type: "codex", ProviderRefs: []string{"claude-only", "codex-only", "universal"}, }, }, }, } cfg.ResolveProviderRefs() // claudecode project: gets claude-only + universal, skips codex-only p0 := cfg.Projects[0].Agent.Providers if len(p0) != 2 { t.Fatalf("proj-claude: expected 2 providers, got %d: %+v", len(p0), p0) } if p0[0].Name != "claude-only" { t.Errorf("proj-claude[0]: expected claude-only, got %s", p0[0].Name) } if p0[1].Name != "universal" { t.Errorf("proj-claude[1]: expected universal, got %s", p0[1].Name) } // codex project: gets codex-only + universal, skips claude-only p1 := cfg.Projects[1].Agent.Providers if len(p1) != 2 { t.Fatalf("proj-codex: expected 2 providers, got %d: %+v", len(p1), p1) } if p1[0].Name != "codex-only" { t.Errorf("proj-codex[0]: expected codex-only, got %s", p1[0].Name) } if p1[1].Name != "universal" { t.Errorf("proj-codex[1]: expected universal, got %s", p1[1].Name) } } func TestResolveProviderRefs_NoGlobalProviders(t *testing.T) { cfg := &Config{ Projects: []ProjectConfig{ { Name: "proj", Agent: AgentConfig{ Type: "claudecode", ProviderRefs: []string{"foo"}, Providers: []ProviderConfig{ {Name: "bar", APIKey: "key"}, }, }, }, }, } cfg.ResolveProviderRefs() providers := cfg.Projects[0].Agent.Providers if len(providers) != 1 || providers[0].Name != "bar" { t.Errorf("expected only inline provider 'bar', got %+v", providers) } } func TestResolveProviderRefs_Basic(t *testing.T) { cfg := &Config{ Providers: []ProviderConfig{ {Name: "global1", APIKey: "key1", BaseURL: "https://example.com", Model: "model-a"}, }, Projects: []ProjectConfig{{ Name: "proj", Agent: AgentConfig{ Type: "claudecode", ProviderRefs: []string{"global1"}, }, }}, } cfg.ResolveProviderRefs() ps := cfg.Projects[0].Agent.Providers if len(ps) != 1 || ps[0].Name != "global1" || ps[0].BaseURL != "https://example.com" { t.Fatalf("expected resolved global1, got %+v", ps) } } func TestResolveProviderRefs_AgentTypesFilter(t *testing.T) { cfg := &Config{ Providers: []ProviderConfig{ {Name: "claude-only", AgentTypes: []string{"claudecode"}}, {Name: "codex-only", AgentTypes: []string{"codex"}}, {Name: "universal"}, }, Projects: []ProjectConfig{{ Name: "codex-proj", Agent: AgentConfig{ Type: "codex", ProviderRefs: []string{"claude-only", "codex-only", "universal"}, }, }}, } cfg.ResolveProviderRefs() ps := cfg.Projects[0].Agent.Providers names := make([]string, len(ps)) for i, p := range ps { names[i] = p.Name } if len(ps) != 2 { t.Fatalf("expected 2 providers (codex-only + universal), got %v", names) } if names[0] != "codex-only" || names[1] != "universal" { t.Fatalf("unexpected providers: %v", names) } } func TestResolveProviderRefs_EndpointsOverride(t *testing.T) { cfg := &Config{ Providers: []ProviderConfig{{ Name: "multi", BaseURL: "https://provider.com/api", Model: "claude-sonnet-4", Endpoints: map[string]string{ "codex": "https://provider.com/api/v1", }, AgentModels: map[string]string{ "codex": "openai/gpt-5.3-codex", }, }}, Projects: []ProjectConfig{ { Name: "claude-proj", Agent: AgentConfig{ Type: "claudecode", ProviderRefs: []string{"multi"}, }, }, { Name: "codex-proj", Agent: AgentConfig{ Type: "codex", ProviderRefs: []string{"multi"}, }, }, }, } cfg.ResolveProviderRefs() // claudecode project: should keep original base_url and model cp := cfg.Projects[0].Agent.Providers if len(cp) != 1 { t.Fatalf("claude-proj: expected 1 provider, got %d", len(cp)) } if cp[0].BaseURL != "https://provider.com/api" { t.Errorf("claude-proj: base_url = %q, want original", cp[0].BaseURL) } if cp[0].Model != "claude-sonnet-4" { t.Errorf("claude-proj: model = %q, want original", cp[0].Model) } // codex project: should have overridden base_url and model xp := cfg.Projects[1].Agent.Providers if len(xp) != 1 { t.Fatalf("codex-proj: expected 1 provider, got %d", len(xp)) } if xp[0].BaseURL != "https://provider.com/api/v1" { t.Errorf("codex-proj: base_url = %q, want codex endpoint", xp[0].BaseURL) } if xp[0].Model != "openai/gpt-5.3-codex" { t.Errorf("codex-proj: model = %q, want codex model", xp[0].Model) } } func TestResolveProviderRefs_SplitProviderPattern(t *testing.T) { cfg := &Config{ Providers: []ProviderConfig{ { Name: "ssy", APIKey: "key-xxx", BaseURL: "https://router.example.com/api", Model: "claude-sonnet-4-6", AgentTypes: []string{"claudecode", "gemini"}, Models: []ProviderModelConfig{ {Model: "claude-sonnet-4-6"}, {Model: "claude-opus-4"}, }, }, { Name: "ssy-codex", APIKey: "key-xxx", BaseURL: "https://router.example.com/api/v1", Model: "openai/gpt-5.3-codex", AgentTypes: []string{"codex"}, Models: []ProviderModelConfig{ {Model: "openai/gpt-5.3-codex"}, {Model: "openai/gpt-5.4"}, }, Codex: &CodexProviderConfig{WireAPI: "responses"}, }, }, Projects: []ProjectConfig{ { Name: "my-claude", Agent: AgentConfig{ Type: "claudecode", ProviderRefs: []string{"ssy", "ssy-codex"}, }, }, { Name: "my-codex", Agent: AgentConfig{ Type: "codex", ProviderRefs: []string{"ssy", "ssy-codex"}, }, }, }, } cfg.ResolveProviderRefs() // claudecode project should only get "ssy" (not ssy-codex) cp := cfg.Projects[0].Agent.Providers if len(cp) != 1 || cp[0].Name != "ssy" { names := make([]string, len(cp)) for i, p := range cp { names[i] = p.Name } t.Fatalf("claude project: expected [ssy], got %v", names) } if len(cp[0].Models) != 2 || cp[0].Models[0].Model != "claude-sonnet-4-6" { t.Errorf("claude project: unexpected models: %+v", cp[0].Models) } // codex project should only get "ssy-codex" (not ssy) xp := cfg.Projects[1].Agent.Providers if len(xp) != 1 || xp[0].Name != "ssy-codex" { names := make([]string, len(xp)) for i, p := range xp { names[i] = p.Name } t.Fatalf("codex project: expected [ssy-codex], got %v", names) } if xp[0].BaseURL != "https://router.example.com/api/v1" { t.Errorf("codex project: base_url = %q", xp[0].BaseURL) } if xp[0].Model != "openai/gpt-5.3-codex" { t.Errorf("codex project: model = %q", xp[0].Model) } if xp[0].Codex == nil || xp[0].Codex.WireAPI != "responses" { t.Errorf("codex project: codex config missing or wrong: %+v", xp[0].Codex) } } func TestResolveProviderRefs_InlineOverridesGlobal(t *testing.T) { cfg := &Config{ Providers: []ProviderConfig{ {Name: "global1", BaseURL: "https://global.com", Model: "global-model"}, }, Projects: []ProjectConfig{{ Name: "proj", Agent: AgentConfig{ Type: "claudecode", ProviderRefs: []string{"global1"}, Providers: []ProviderConfig{ {Name: "global1", BaseURL: "https://override.com", Model: "override-model"}, }, }, }}, } cfg.ResolveProviderRefs() ps := cfg.Projects[0].Agent.Providers if len(ps) != 1 { t.Fatalf("expected 1 provider (inline override), got %d", len(ps)) } if ps[0].BaseURL != "https://override.com" { t.Errorf("inline override not applied: base_url = %q", ps[0].BaseURL) } } func TestResolveProviderRefs_TOMLParsing(t *testing.T) { input := ` [[providers]] name = "ssy" api_key = "key123" base_url = "https://router.example.com/api" model = "claude-sonnet-4-6" agent_types = ["claudecode", "gemini"] [[providers.models]] model = "claude-sonnet-4-6" [[providers]] name = "ssy-codex" api_key = "key123" base_url = "https://router.example.com/api/v1" model = "openai/gpt-5.3-codex" agent_types = ["codex"] [providers.endpoints] codex = "https://router.example.com/api/v1" [providers.agent_models] codex = "openai/gpt-5.3-codex" [[providers.models]] model = "openai/gpt-5.3-codex" [providers.codex] wire_api = "responses" [[projects]] name = "test-codex" [projects.agent] type = "codex" provider_refs = ["ssy", "ssy-codex"] [[projects.platforms]] type = "feishu" [projects.platforms.options] app_id = "test" app_secret = "test" ` var cfg Config if _, err := toml.Decode(input, &cfg); err != nil { t.Fatalf("TOML decode: %v", err) } if len(cfg.Providers) != 2 { t.Fatalf("expected 2 global providers, got %d", len(cfg.Providers)) } codexProv := cfg.Providers[1] if codexProv.Codex == nil { t.Fatal("ssy-codex: codex config not parsed") } if codexProv.Codex.WireAPI != "responses" { t.Errorf("ssy-codex: wire_api = %q, want responses", codexProv.Codex.WireAPI) } if codexProv.Endpoints["codex"] != "https://router.example.com/api/v1" { t.Errorf("ssy-codex: endpoints not parsed: %+v", codexProv.Endpoints) } cfg.ResolveProviderRefs() ps := cfg.Projects[0].Agent.Providers if len(ps) != 1 || ps[0].Name != "ssy-codex" { names := make([]string, len(ps)) for i, p := range ps { names[i] = p.Name } t.Fatalf("expected [ssy-codex], got %v", names) } } func TestRemoveGlobalProvider_CleansUpProviderRefs(t *testing.T) { input := ` [[providers]] name = "prov-a" api_key = "key-a" [[providers]] name = "prov-b" api_key = "key-b" [[projects]] name = "proj1" [projects.agent] type = "claudecode" provider_refs = ["prov-a", "prov-b"] [[projects.platforms]] type = "feishu" [projects.platforms.options] app_id = "x" app_secret = "y" [[projects]] name = "proj2" [projects.agent] type = "codex" provider_refs = ["prov-a"] [[projects.platforms]] type = "telegram" [projects.platforms.options] token = "t" ` writeTestConfig(t, input) if err := RemoveGlobalProvider("prov-a"); err != nil { t.Fatalf("RemoveGlobalProvider: %v", err) } cfg, err := loadLocked() if err != nil { t.Fatalf("loadLocked: %v", err) } if len(cfg.Providers) != 1 || cfg.Providers[0].Name != "prov-b" { t.Fatalf("expected only prov-b remaining, got %v", cfg.Providers) } refs1 := cfg.Projects[0].Agent.ProviderRefs if len(refs1) != 1 || refs1[0] != "prov-b" { t.Errorf("proj1 provider_refs: want [prov-b], got %v", refs1) } refs2 := cfg.Projects[1].Agent.ProviderRefs if len(refs2) != 0 { t.Errorf("proj2 provider_refs: want [], got %v", refs2) } }