Range over slice copies each element by value; use index or pointer receiver for large structs to avoid copies
for name, want := range cases {
1// SPDX-License-Identifier: MIT23package main45import (6 "os"7 "os/exec"8 "path/filepath"9 "slices"10 "strings"11 "testing"1213 "github.com/boyter/scc/v3/processor"14 "github.com/spf13/pflag"15)1617// noGlobalConfig forces SCC_CONFIG_PATH empty (treated as unset) so a value in18// the developer's environment cannot pollute config tests. Pass it as the env19// argument to runSCCDir for any test that does not set its own global config.20var noGlobalConfig = []string{SccConfigEnv + "="}2122// runSCCDir runs the test binary as scc in the given working directory, with23// optional extra environment entries (KEY=VALUE).24func runSCCDir(t *testing.T, dir string, env []string, args ...string) (string, error) {25 t.Helper()26 bin, err := filepath.Abs(sccBinPath)27 if err != nil {28 t.Fatal(err)29 }30 full := slices.Insert(slices.Clone(args), 0, sccTestFlag)31 cmd := exec.Command(bin, full...)32 cmd.Dir = dir33 if len(env) > 0 {34 cmd.Env = append(os.Environ(), env...)35 }36 out, err := cmd.CombinedOutput()37 return string(out), err38}3940func TestParseConfigArgs(t *testing.T) {41 t.Parallel()42 tests := []struct {43 name string44 content string45 allowPositional bool46 want []string47 }{48 {49 name: "single flag per line",50 content: "--no-cocomo\n--by-file\n",51 want: []string{"--no-cocomo", "--by-file"},52 },53 {54 name: "multiple tokens per line",55 content: "--exclude-dir vendor\n",56 want: []string{"--exclude-dir", "vendor"},57 },58 {59 name: "single quotes group a value",60 content: "--count-as 'jsp:html'\n",61 want: []string{"--count-as", "jsp:html"},62 },63 {64 name: "double quotes allow embedded single quote",65 content: "--count-as \"a'b\"\n",66 want: []string{"--count-as", "a'b"},67 },68 {69 name: "whole-line comment skipped",70 content: "# a comment\n--by-file\n",71 want: []string{"--by-file"},72 },73 {74 name: "inline trailing comment stripped",75 content: "--format wide # comment\n",76 want: []string{"--format", "wide"},77 },78 {79 name: "blank lines produce no tokens",80 content: "\n\n--by-file\n\n \n",81 want: []string{"--by-file"},82 },83 {84 name: "backslash is literal (windows path)",85 content: "--exclude-dir C:\\build\\out\n",86 want: []string{"--exclude-dir", "C:\\build\\out"},87 },88 {89 name: "crlf line endings",90 content: "--no-cocomo\r\n--by-file\r\n",91 want: []string{"--no-cocomo", "--by-file"},92 },93 {94 name: "positional line dropped when not allowed",95 content: "src/\n--by-file\n",96 allowPositional: false,97 want: []string{"--by-file"},98 },99 {100 name: "positional line kept when allowed",101 content: "src/\n--by-file\n",102 allowPositional: true,103 want: []string{"src/", "--by-file"},104 },105 {106 name: "empty quoted value still a token",107 content: "--report-title \"\"\n",108 want: []string{"--report-title", ""},109 },110 {111 name: "end-of-flags marker dropped in config",112 content: "--\n--by-file\n",113 allowPositional: false,114 want: []string{"--by-file"},115 },116 {117 name: "end-of-flags marker dropped mid-line in config",118 content: "--exclude-dir vendor --\n",119 allowPositional: false,120 want: []string{"--exclude-dir", "vendor"},121 },122 {123 name: "end-of-flags marker kept in @file",124 content: "--\nsrc/\n",125 allowPositional: true,126 want: []string{"--", "src/"},127 },128 }129130 for _, tc := range tests {131 t.Run(tc.name, func(t *testing.T) {132 got := parseConfigArgs(tc.content, tc.allowPositional)133 if !slices.Equal(got, tc.want) {134 t.Errorf("parseConfigArgs(%q, %v) = %q, want %q", tc.content, tc.allowPositional, got, tc.want)135 }136 })137 }138}139140func TestPreScanConfig(t *testing.T) {141 t.Parallel()142 tests := []struct {143 name string144 args []string145 wantNoConfig bool146 wantFindRoot bool147 wantPath string148 }{149 {name: "nothing", args: []string{"scc", "."}},150 {name: "no-config", args: []string{"scc", "--no-config"}, wantNoConfig: true},151 {name: "long find-root-config", args: []string{"scc", "--find-root-config"}, wantFindRoot: true},152 {name: "no -r shorthand", args: []string{"scc", "-r"}},153 {name: "-r no longer clusters into find-root", args: []string{"scc", "-rv"}},154 {name: "non-r cluster", args: []string{"scc", "-vd"}},155 {name: "config space form", args: []string{"scc", "--config", "team.sccconfig"}, wantPath: "team.sccconfig"},156 {name: "config equals form", args: []string{"scc", "--config=team.sccconfig"}, wantPath: "team.sccconfig"},157 {name: "config as final token (typo)", args: []string{"scc", "--config"}},158 {name: "all three", args: []string{"scc", "--no-config", "--find-root-config", "--config=x"}, wantNoConfig: true, wantFindRoot: true, wantPath: "x"},159 }160161 for _, tc := range tests {162 t.Run(tc.name, func(t *testing.T) {163 noConfig, findRoot, path := preScanConfig(tc.args)164 if noConfig != tc.wantNoConfig || findRoot != tc.wantFindRoot || path != tc.wantPath {165 t.Errorf("preScanConfig(%q) = (%v,%v,%q), want (%v,%v,%q)",166 tc.args, noConfig, findRoot, path, tc.wantNoConfig, tc.wantFindRoot, tc.wantPath)167 }168 })169 }170}171172func TestMergeSliceDefault(t *testing.T) {173 t.Parallel()174 defaults := []string{".git", ".hg", ".svn"}175176 if got := mergeSliceDefault(nil, defaults); !slices.Equal(got, defaults) {177 t.Errorf("empty set should fall back to defaults, got %q", got)178 }179 if got := mergeSliceDefault([]string{"vendor"}, defaults); !slices.Equal(got, []string{".git", ".hg", ".svn", "vendor"}) {180 t.Errorf("defaults should be preserved, got %q", got)181 }182 if got := mergeSliceDefault([]string{"vendor", "dist"}, defaults); !slices.Equal(got, []string{".git", ".hg", ".svn", "vendor", "dist"}) {183 t.Errorf("union should be order-stable, got %q", got)184 }185 if got := mergeSliceDefault([]string{".git", "vendor"}, defaults); !slices.Equal(got, []string{".git", ".hg", ".svn", "vendor"}) {186 t.Errorf("duplicate should be removed, got %q", got)187 }188}189190// TestRegisterFlagsExhaustive asserts every flag registerFlags is meant to191// register is present in the resulting flag set. The write-only mode must192// give every non-write flag a sink; a missing flag there is silent state193// corruption, so this check is the insurance.194func TestRegisterFlagsExhaustive(t *testing.T) {195 t.Parallel()196 fs := pflag.NewFlagSet("test", pflag.ContinueOnError)197 var out, report, multi string198 registerFlags(fs, &flagBindings{output: &out, report: &report, formatMulti: &multi, inert: true})199200 expected := []string{201 "character", "percent", "uloc", "dryness", "cognitive", "binary", "by-file", "ci",202 "no-ignore", "no-scc-ignore", "no-gitignore", "no-gitmodule", "count-ignore",203 "ignore-file",204 "debug", "exclude-dir", "file-gc-count", "file-list-queue-size",205 "file-process-job-workers", "file-summary-job-queue-size",206 "directory-walker-job-workers", "format", "report", "report-skip",207 "report-title", "include-ext", "exclude-ext", "exclude-file", "languages",208 "avg-wage", "overhead", "eaf", "sloccount-format", "no-cocomo",209 "cocomo-project-type", "no-size", "no-hborder", "size-unit", "no-complexity",210 "no-duplicates", "min-gen", "min", "gen", "generated-markers", "no-min-gen",211 "no-min", "no-gen", "min-gen-line-length", "not-match", "output", "sort",212 "trace", "verbose", "wide", "no-large", "include-symlinks", "large-line-count",213 "large-byte-count", "count-as", "count-as-pattern", "format-multi",214 "sql-project", "remap-unknown", "remap-all", "currency-symbol", "locomo",215 "cost-comparison", "locomo-preset", "locomo-review", "locomo-config",216 "locomo-input-price", "locomo-output-price", "locomo-tps", "locomo-cycles",217 "hotspots", "by-author", "depth", "timeline", "buckets", "no-fold-authors",218 "mcp",219 }220 for _, name := range expected {221 if fs.Lookup(name) == nil {222 t.Errorf("registerFlags did not register --%s", name)223 }224 }225}226227func TestRegisterFlagsSliceDefValue(t *testing.T) {228 t.Parallel()229 fs := pflag.NewFlagSet("test", pflag.ContinueOnError)230 var out, report, multi string231 registerFlags(fs, &flagBindings{output: &out, report: &report, formatMulti: &multi, inert: true})232233 cases := map[string]string{234 "exclude-dir": "[.git,.hg,.svn]",235 "exclude-file": "[package-lock.json,Cargo.lock,yarn.lock,pubspec.lock,Podfile.lock,pnpm-lock.yaml]",236 "generated-markers": "[do not edit,<auto-generated />]",237 }238 for name, want := range cases {239 f := fs.Lookup(name)240 if f == nil {241 t.Fatalf("missing flag --%s", name)242 }243 if f.DefValue != want {244 t.Errorf("--%s DefValue = %q, want %q", name, f.DefValue, want)245 }246 }247}248249// TestCognitiveFlagParses asserts that parsing --cognitive on the real flag set250// toggles the processor.Cognitive global.251func TestCognitiveFlagParses(t *testing.T) {252 prev := processor.Cognitive253 defer func() { processor.Cognitive = prev }()254 processor.Cognitive = false255256 fs := pflag.NewFlagSet("test", pflag.ContinueOnError)257 var out, report, multi string258 registerFlags(fs, &flagBindings{output: &out, report: &report, formatMulti: &multi})259260 if err := fs.Parse([]string{"--cognitive"}); err != nil {261 t.Fatalf("parse --cognitive: %v", err)262 }263 if !processor.Cognitive {264 t.Error("parsing --cognitive did not set processor.Cognitive")265 }266}267268// TestCognitiveFlagConfigRoundTrip writes a .sccconfig containing --cognitive,269// parses it back through the same config-arg reader and flag set that scc uses270// at startup, and asserts the flag survives the round-trip. Mirrors how a bool271// flag reaches the real global from a project config file.272func TestCognitiveFlagConfigRoundTrip(t *testing.T) {273 prev := processor.Cognitive274 defer func() { processor.Cognitive = prev }()275 processor.Cognitive = false276277 args := parseConfigArgs("--cognitive\n", false)278 if !slices.Equal(args, []string{"--cognitive"}) {279 t.Fatalf("config parse produced %q, want [--cognitive]", args)280 }281282 fs := pflag.NewFlagSet("test", pflag.ContinueOnError)283 var out, report, multi string284 registerFlags(fs, &flagBindings{output: &out, report: &report, formatMulti: &multi})285 if err := fs.Parse(args); err != nil {286 t.Fatalf("parse config args: %v", err)287 }288 if !processor.Cognitive {289 t.Error("--cognitive from config did not set processor.Cognitive")290 }291}292293// writeSccConfig creates a temp dir with a .sccconfig file and returns the dir.294func writeSccConfig(t *testing.T, content string) string {295 t.Helper()296 dir := t.TempDir()297 if err := os.WriteFile(filepath.Join(dir, ".sccconfig"), []byte(content), 0644); err != nil {298 t.Fatal(err)299 }300 // a source file so output is meaningful301 if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\nfunc main() {}\n"), 0644); err != nil {302 t.Fatal(err)303 }304 return dir305}306307func TestConfigProjectLoaded(t *testing.T) {308 dir := writeSccConfig(t, "--format csv\n--no-cocomo\n")309 out, err := runSCCDir(t, dir, noGlobalConfig)310 if err != nil {311 t.Fatal(err)312 }313 if !strings.Contains(out, "Language,Lines,Code") {314 t.Errorf("project .sccconfig --format csv not applied, output:\n%s", out)315 }316 if strings.Contains(out, "Estimated Cost to Develop") {317 t.Errorf("project .sccconfig --no-cocomo not applied, output:\n%s", out)318 }319}320321func TestConfigNoWalkUp(t *testing.T) {322 dir := writeSccConfig(t, "--format csv\n")323 sub := filepath.Join(dir, "sub")324 if err := os.Mkdir(sub, 0755); err != nil {325 t.Fatal(err)326 }327 if err := os.WriteFile(filepath.Join(sub, "x.go"), []byte("package x\n"), 0644); err != nil {328 t.Fatal(err)329 }330 out, err := runSCCDir(t, sub, noGlobalConfig)331 if err != nil {332 t.Fatal(err)333 }334 if strings.Contains(out, "Language,Lines,Code") {335 t.Errorf("config should NOT be picked up from a subdirectory (no walk-up), output:\n%s", out)336 }337}338339func TestConfigPrecedenceCLIWins(t *testing.T) {340 dir := writeSccConfig(t, "--format csv\n")341 out, err := runSCCDir(t, dir, noGlobalConfig, "--format", "json")342 if err != nil {343 t.Fatal(err)344 }345 if !strings.HasPrefix(strings.TrimSpace(out), "[{") {346 t.Errorf("CLI --format json should override config csv, output:\n%s", out)347 }348}349350func TestConfigNeverWritesFile(t *testing.T) {351 cases := []string{352 "-o evil.csv\n",353 "--output evil.csv\n",354 "-do evil.csv\n", // clustered: -d + -o (headline case)355 "-do/evil.csv\n", // clustered, no space356 "--report=evil.csv\n",357 "--format-multi tabular:stdout,csv:evil.csv\n",358 }359 for _, content := range cases {360 dir := writeSccConfig(t, content)361 _, err := runSCCDir(t, dir, noGlobalConfig)362 if err != nil {363 t.Fatalf("scc errored for config %q: %v", content, err)364 }365 if _, statErr := os.Stat(filepath.Join(dir, "evil.csv")); statErr == nil {366 t.Errorf("config %q caused a file write (evil.csv exists)", content)367 }368 }369}370371func TestConfigPresentCLICanWrite(t *testing.T) {372 dir := writeSccConfig(t, "--no-cocomo\n")373 target := filepath.Join(dir, "good.csv")374 _, err := runSCCDir(t, dir, noGlobalConfig, "-f", "csv", "-o", target)375 if err != nil {376 t.Fatal(err)377 }378 if info, statErr := os.Stat(target); statErr != nil || info.Size() == 0 {379 t.Errorf("genuine CLI -o should write the file even with config present")380 }381}382383func TestConfigControlFlagWithWriteFlag(t *testing.T) {384 // scc --config x.sccconfig -o out.csv: the CLI-only parse must accept --config so385 // -o still writes.386 dir := t.TempDir()387 cfg := filepath.Join(dir, "team.sccconfig")388 if err := os.WriteFile(cfg, []byte("--no-cocomo\n"), 0644); err != nil {389 t.Fatal(err)390 }391 if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n"), 0644); err != nil {392 t.Fatal(err)393 }394 target := filepath.Join(dir, "out.csv")395 _, err := runSCCDir(t, dir, noGlobalConfig, "--config", cfg, "-f", "csv", "-o", target)396 if err != nil {397 t.Fatal(err)398 }399 if info, statErr := os.Stat(target); statErr != nil || info.Size() == 0 {400 t.Errorf("scc --config x.sccconfig -o out.csv should still write out.csv")401 }402}403404// TestConfigDashDashDoesNotDisableCLIFlags guards against a '--' end-of-flags405// marker in a config file. Config tokens are prepended, so an unstripped '--'406// would terminate flag parsing and turn the genuine CLI's flags into positional407// paths (here --by-file would be read as a file path).408func TestConfigDashDashDoesNotDisableCLIFlags(t *testing.T) {409 dir := writeSccConfig(t, "--\n")410 out, err := runSCCDir(t, dir, noGlobalConfig, "--by-file")411 if err != nil {412 t.Fatalf("scc errored: %v\n%s", err, out)413 }414 if strings.Contains(out, "could not be read") {415 t.Errorf("--by-file was treated as a path; '--' from config leaked through:\n%s", out)416 }417}418419// TestConfigExplicitEmptyWriteFlagNoFalseWarn checks that an explicit empty420// write flag on the CLI (--output=) is recognised as "set on the CLI" and does421// not trigger the "ignoring --output from config" warning, while a config-only422// write still does warn.423func TestConfigExplicitEmptyWriteFlagNoFalseWarn(t *testing.T) {424 dir := writeSccConfig(t, "--output evil.csv\n")425 const warn = "ignoring --output from config"426427 // CLI explicitly sets --output= (empty -> stdout): the flag WAS set on the428 // command line, so no warning should fire.429 out, err := runSCCDir(t, dir, noGlobalConfig, "--output=")430 if err != nil {431 t.Fatalf("scc errored: %v\n%s", err, out)432 }433 if strings.Contains(out, warn) {434 t.Errorf("explicit --output= on CLI should not warn about config:\n%s", out)435 }436437 // Control: config-only write (no CLI override) should still warn.438 out, err = runSCCDir(t, dir, noGlobalConfig)439 if err != nil {440 t.Fatalf("scc errored: %v\n%s", err, out)441 }442 if !strings.Contains(out, warn) {443 t.Errorf("config-only --output should still warn, got:\n%s", out)444 }445}446447func TestConfigEnvVarGlobal(t *testing.T) {448 dir := t.TempDir()449 if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n"), 0644); err != nil {450 t.Fatal(err)451 }452 global := filepath.Join(dir, "global.sccconfig")453 if err := os.WriteFile(global, []byte("--format csv\n"), 0644); err != nil {454 t.Fatal(err)455 }456 out, err := runSCCDir(t, dir, []string{SccConfigEnv + "=" + global})457 if err != nil {458 t.Fatal(err)459 }460 if !strings.Contains(out, "Language,Lines,Code") {461 t.Errorf("SCC_CONFIG_PATH global not applied, output:\n%s", out)462 }463464 // set-but-empty is treated as unset (tabular default)465 out, err = runSCCDir(t, dir, []string{SccConfigEnv + "="})466 if err != nil {467 t.Fatal(err)468 }469 if strings.Contains(out, "Language,Lines,Code") {470 t.Errorf("empty SCC_CONFIG_PATH should be treated as unset, output:\n%s", out)471 }472}473474func TestConfigNoConfigComposition(t *testing.T) {475 // --config x.sccconfig --no-config loads exactly x.sccconfig and skips the project .sccconfig.476 dir := writeSccConfig(t, "--by-file\n") // project .sccconfig would set --by-file477 global := filepath.Join(dir, "iso.sccconfig")478 if err := os.WriteFile(global, []byte("--format csv\n"), 0644); err != nil {479 t.Fatal(err)480 }481 out, err := runSCCDir(t, dir, noGlobalConfig, "--config", global, "--no-config")482 if err != nil {483 t.Fatal(err)484 }485 if !strings.Contains(out, "Language,Lines,Code") {486 t.Errorf("--config should still load with --no-config, output:\n%s", out)487 }488 // --by-file from the project .sccconfig must NOT apply under --no-config: the489 // per-file CSV header carries a Location column, the summary one does not.490 if strings.Contains(out, "Location") {491 t.Errorf("--no-config should skip the project .sccconfig, output:\n%s", out)492 }493}494495func TestConfigInsideFileIsInert(t *testing.T) {496 // A --config written inside a project .sccconfig must not chain-load another file.497 dir := t.TempDir()498 if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n"), 0644); err != nil {499 t.Fatal(err)500 }501 chained := filepath.Join(dir, "chained.sccconfig")502 if err := os.WriteFile(chained, []byte("--format csv\n"), 0644); err != nil {503 t.Fatal(err)504 }505 if err := os.WriteFile(filepath.Join(dir, ".sccconfig"), []byte("--config "+chained+"\n"), 0644); err != nil {506 t.Fatal(err)507 }508 out, err := runSCCDir(t, dir, noGlobalConfig)509 if err != nil {510 t.Fatal(err)511 }512 if strings.Contains(out, "Language,Lines,Code") {513 t.Errorf("--config inside a .sccconfig must be inert (no chain-load), output:\n%s", out)514 }515}516517func TestConfigUnreadableExplicitErrors(t *testing.T) {518 dir := t.TempDir()519 out, err := runSCCDir(t, dir, noGlobalConfig, "--config", filepath.Join(dir, "does-not-exist.sccconfig"))520 if err == nil {521 t.Fatalf("scc should exit non-zero for an unreadable --config, output:\n%s", out)522 }523 if !strings.Contains(out, "could not read config") {524 t.Errorf("expected a clear error for unreadable --config, output:\n%s", out)525 }526}527528func TestConfigCoupledMinFlags(t *testing.T) {529 // config --min + CLI --no-min must resolve exactly as without the feature:530 // the CLI-only write parse must not re-fire the coupled min/gen closures.531 dir := writeSccConfig(t, "--min\n")532 // --no-min on the CLI wins (last); the run should still succeed and not crash.533 if _, err := runSCCDir(t, dir, noGlobalConfig, "--no-min"); err != nil {534 t.Fatalf("coupled min flags run failed: %v", err)535 }536}537538func TestConfigUnknownFlagAttribution(t *testing.T) {539 dir := writeSccConfig(t, "--not-a-real-flag\n")540 out, err := runSCCDir(t, dir, noGlobalConfig)541 if err == nil {542 t.Fatalf("unknown flag in config should exit non-zero, output:\n%s", out)543 }544 if !strings.Contains(out, "project") || !strings.Contains(out, "unknown flag --not-a-real-flag") {545 t.Errorf("unknown config flag should be attributed to the project file, output:\n%s", out)546 }547}548549func TestConfigCompletionUnaffected(t *testing.T) {550 // scc completion --shell bash in a dir containing ./.sccconfig must still emit551 // completions (config prepend must not shift args[1]).552 dir := writeSccConfig(t, "--format csv\n")553 out, err := runSCCDir(t, dir, noGlobalConfig, "completion", "--shell", "bash")554 if err != nil {555 t.Fatal(err)556 }557 if !strings.Contains(out, "bash completion") && !strings.Contains(out, "complete -") {558 t.Errorf("completion should still work with ./.sccconfig present, output:\n%s", out[:min(len(out), 200)])559 }560}561562func TestConfigInsideAtFileHonored(t *testing.T) {563 // --config inside an @file IS honored (asymmetry vs config files): @file564 // replaces os.Args before the pre-scan, so it is part of the genuine CLI.565 dir := t.TempDir()566 if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n"), 0644); err != nil {567 t.Fatal(err)568 }569 global := filepath.Join(dir, "from-atfile.sccconfig")570 if err := os.WriteFile(global, []byte("--format csv\n"), 0644); err != nil {571 t.Fatal(err)572 }573 atFile := filepath.Join(dir, "flags.txt")574 if err := os.WriteFile(atFile, []byte("--config "+global+"\n"), 0644); err != nil {575 t.Fatal(err)576 }577 out, err := runSCCDir(t, dir, noGlobalConfig, "@"+atFile)578 if err != nil {579 t.Fatal(err)580 }581 if !strings.Contains(out, "Language,Lines,Code") {582 t.Errorf("--config inside an @file should be honored, output:\n%s", out)583 }584}585586// TestConfigCompleteDynamicUnaffected is the regression guard for cobra's587// hidden __complete command (the one a shell calls on every TAB). Config tokens588// are prepended ahead of the genuine argv, so without the completion bypass the589// __complete subcommand is shifted out of args[0] and dynamic completion breaks590// in any directory containing a ./.sccconfig. (TestConfigCompletionUnaffected covers591// only the separate `completion --shell` script-generation path.)592func TestConfigCompleteDynamicUnaffected(t *testing.T) {593 dir := writeSccConfig(t, "--format csv\n")594 out, err := runSCCDir(t, dir, noGlobalConfig, "__complete", "--for")595 if err != nil {596 t.Fatalf("__complete errored with ./.sccconfig present: %v\n%s", err, out)597 }598 if !strings.Contains(out, "--format") {599 t.Errorf("dynamic completion broken with ./.sccconfig present, output:\n%s", out)600 }601 // A leaked config token would surface as an unknown-flag / read error.602 if strings.Contains(out, "unknown flag") || strings.Contains(out, "could not be read") {603 t.Errorf("config tokens leaked into the completion invocation, output:\n%s", out)604 }605}606607// TestConfigSliceUnion locks the §7 union semantics end-to-end: a slice flag set608// in the config file and again on the CLI must combine with each other AND with609// the built-in defaults (defaults ∪ config ∪ CLI), rather than replacing them.610func TestConfigSliceUnion(t *testing.T) {611 dir := t.TempDir()612 for _, d := range []string{"vendor", "dist", "keep", ".git"} {613 if err := os.Mkdir(filepath.Join(dir, d), 0755); err != nil {614 t.Fatal(err)615 }616 }617 for _, f := range []string{"vendor/v.go", "dist/d.go", "keep/k.go", ".git/g.go"} {618 if err := os.WriteFile(filepath.Join(dir, f), []byte("package x\n"), 0644); err != nil {619 t.Fatal(err)620 }621 }622 // config excludes vendor; CLI excludes dist; .git is a built-in default.623 if err := os.WriteFile(filepath.Join(dir, ".sccconfig"), []byte("--exclude-dir vendor\n"), 0644); err != nil {624 t.Fatal(err)625 }626627 out, err := runSCCDir(t, dir, noGlobalConfig, "--exclude-dir", "dist", "-f", "csv", "--by-file")628 if err != nil {629 t.Fatal(err)630 }631 if !strings.Contains(out, "k.go") {632 t.Errorf("keep/k.go should be counted, output:\n%s", out)633 }634 for _, gone := range []string{"v.go", "d.go", "g.go"} {635 if strings.Contains(out, gone) {636 t.Errorf("%s should be excluded (defaults ∪ config ∪ CLI), output:\n%s", gone, out)637 }638 }639}640641// TestAtFileMultiTokenAndComments locks the phase-01 @file improvements: a line642// may carry multiple whitespace-separated tokens, '#' comments are stripped and643// blank lines are dropped (the old splitter emitted one token per line and a644// stray empty arg per blank line).645func TestAtFileMultiTokenAndComments(t *testing.T) {646 dir := t.TempDir()647 if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n"), 0644); err != nil {648 t.Fatal(err)649 }650 atFile := filepath.Join(dir, "flags.txt")651 // multi-token flag line, a whole-line comment, an inline comment and a blank.652 content := "# count this project\n\n--format csv # as CSV\n--no-cocomo\n"653 if err := os.WriteFile(atFile, []byte(content), 0644); err != nil {654 t.Fatal(err)655 }656 out, err := runSCCDir(t, dir, noGlobalConfig, "@"+atFile)657 if err != nil {658 t.Fatalf("@file errored: %v\n%s", err, out)659 }660 if !strings.Contains(out, "Language,Lines,Code") {661 t.Errorf("@file multi-token --format csv not applied, output:\n%s", out)662 }663 if strings.Contains(out, "Estimated Cost to Develop") {664 t.Errorf("@file --no-cocomo not applied, output:\n%s", out)665 }666}667668// TestAtFileQuotedSpacePath documents the @file consequence of the shared669// quote-aware tokenizer: a path containing spaces must be quoted (the old670// splitter kept a whole line as one token, so unquoted spaces used to work).671// This pins the supported workaround.672func TestAtFileQuotedSpacePath(t *testing.T) {673 dir := t.TempDir()674 if err := os.WriteFile(filepath.Join(dir, "my file.go"), []byte("package x\n"), 0644); err != nil {675 t.Fatal(err)676 }677 atFile := filepath.Join(dir, "flags.txt")678 if err := os.WriteFile(atFile, []byte("'my file.go'\n"), 0644); err != nil {679 t.Fatal(err)680 }681 out, err := runSCCDir(t, dir, noGlobalConfig, "@"+atFile)682 if err != nil {683 t.Fatalf("@file errored: %v\n%s", err, out)684 }685 if strings.Contains(out, "could not be read") {686 t.Errorf("quoted space path in @file should resolve as one token, output:\n%s", out)687 }688 if !strings.Contains(out, "Go") {689 t.Errorf("quoted 'my file.go' should be counted, output:\n%s", out)690 }691}692693// TestConfigFindRootWalkUp locks the headline --find-root-config feature694// end-to-end (§3.2): run from a subdirectory of a repo, --find-root-config must695// walk up to the repository root (detected via a .git dir) and load the root696// .sccconfig, where the default ./.sccconfig discovery finds nothing. TestConfigNoWalkUp697// already proves the default does NOT walk; this proves --find-root-config does.698func TestConfigFindRootWalkUp(t *testing.T) {699 root := t.TempDir()700 if err := os.Mkdir(filepath.Join(root, ".git"), 0755); err != nil {701 t.Fatal(err)702 }703 if err := os.WriteFile(filepath.Join(root, ".sccconfig"), []byte("--format csv\n"), 0644); err != nil {704 t.Fatal(err)705 }706 sub := filepath.Join(root, "sub")707 if err := os.Mkdir(sub, 0755); err != nil {708 t.Fatal(err)709 }710 if err := os.WriteFile(filepath.Join(sub, "x.go"), []byte("package x\n"), 0644); err != nil {711 t.Fatal(err)712 }713714 // Control: without --find-root-config, no walk-up, the root .sccconfig is not seen (tabular).715 out, err := runSCCDir(t, sub, noGlobalConfig)716 if err != nil {717 t.Fatal(err)718 }719 if strings.Contains(out, "Language,Lines,Code") {720 t.Errorf("default discovery must not walk up to the repo-root .sccconfig, output:\n%s", out)721 }722723 // --find-root-config walks up to the repo root and loads its .sccconfig (csv).724 out, err = runSCCDir(t, sub, noGlobalConfig, "--find-root-config")725 if err != nil {726 t.Fatal(err)727 }728 if !strings.Contains(out, "Language,Lines,Code") {729 t.Errorf("--find-root-config should walk up to the repo-root .sccconfig, output:\n%s", out)730 }731}732733// TestConfigFindRootOutsideRepoFallback locks the §3.2 graceful-degradation734// guarantee: when the CWD is not inside any repo, FindRepositoryRoot returns the735// supplied directory unchanged, so --find-root-config resolves to ./.sccconfig -736// identical to default discovery, never an error. --find-root-config must737// therefore always be safe to leave on.738func TestConfigFindRootOutsideRepoFallback(t *testing.T) {739 dir := writeSccConfig(t, "--format csv\n") // ./.sccconfig, no .git anywhere above740 out, err := runSCCDir(t, dir, noGlobalConfig, "--find-root-config")741 if err != nil {742 t.Fatalf("--find-root-config outside a repo should not error, got: %v\n%s", err, out)743 }744 if !strings.Contains(out, "Language,Lines,Code") {745 t.Errorf("--find-root-config outside a repo should fall back to ./.sccconfig, output:\n%s", out)746 }747}748749// TestConfigEnvVarUnreadableErrors covers the SCC_CONFIG_PATH arm of the §8750// "explicit source the user asked for" rule: a set-but-unreadable global must751// exit non-zero with a clear error. TestConfigUnreadableExplicitErrors only752// covers the --config arm; the env-var resolution is a separate branch.753func TestConfigEnvVarUnreadableErrors(t *testing.T) {754 dir := t.TempDir()755 if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n"), 0644); err != nil {756 t.Fatal(err)757 }758 missing := filepath.Join(dir, "does-not-exist.sccconfig")759 out, err := runSCCDir(t, dir, []string{SccConfigEnv + "=" + missing})760 if err == nil {761 t.Fatalf("an unreadable SCC_CONFIG_PATH should exit non-zero, output:\n%s", out)762 }763 if !strings.Contains(out, "could not read config") {764 t.Errorf("expected a clear error for an unreadable SCC_CONFIG_PATH, output:\n%s", out)765 }766}767768// TestConfigNoConfigDisablesEnvGlobal proves --no-config skips a *set*769// SCC_CONFIG_PATH global (§4). TestConfigNoConfigComposition only exercises770// --config (which is honored even under --no-config); the env-var global is the771// arm that --no-config actually disables.772func TestConfigNoConfigDisablesEnvGlobal(t *testing.T) {773 dir := t.TempDir()774 if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n"), 0644); err != nil {775 t.Fatal(err)776 }777 global := filepath.Join(dir, "global.sccconfig")778 if err := os.WriteFile(global, []byte("--format csv\n"), 0644); err != nil {779 t.Fatal(err)780 }781782 // Sanity: the env global applies when --no-config is absent.783 out, err := runSCCDir(t, dir, []string{SccConfigEnv + "=" + global})784 if err != nil {785 t.Fatal(err)786 }787 if !strings.Contains(out, "Language,Lines,Code") {788 t.Fatalf("env global should apply without --no-config, output:\n%s", out)789 }790791 // --no-config must disable the env global (back to tabular default).792 out, err = runSCCDir(t, dir, []string{SccConfigEnv + "=" + global}, "--no-config")793 if err != nil {794 t.Fatal(err)795 }796 if strings.Contains(out, "Language,Lines,Code") {797 t.Errorf("--no-config should disable the SCC_CONFIG_PATH global, output:\n%s", out)798 }799}800801// TestConfigPresentCLIFormatMultiWrites is the --format-multi counterpart of802// TestConfigPresentCLICanWrite: with config present (so the two-mode write split803// engages), a genuine-CLI --format-multi must still write its file. The §5804// capability model blocks config from writing, not the CLI; resolveWriteFlags805// must therefore source --format-multi from the genuine CLI too, not just -o.806func TestConfigPresentCLIFormatMultiWrites(t *testing.T) {807 dir := writeSccConfig(t, "--no-cocomo\n")808 target := filepath.Join(dir, "multi.csv")809 _, err := runSCCDir(t, dir, noGlobalConfig, "--format-multi", "csv:"+target)810 if err != nil {811 t.Fatal(err)812 }813 if info, statErr := os.Stat(target); statErr != nil || info.Size() == 0 {814 t.Errorf("genuine CLI --format-multi should write the file even with config present")815 }816}817818func TestNoConfigFastPathWritesFile(t *testing.T) {819 // No config present: -o on the CLI writes exactly as today (fast path).820 dir := t.TempDir()821 if err := os.WriteFile(filepath.Join(dir, "main.go"), []byte("package main\n"), 0644); err != nil {822 t.Fatal(err)823 }824 target := filepath.Join(dir, "out.csv")825 if _, err := runSCCDir(t, dir, noGlobalConfig, "-f", "csv", "-o", target); err != nil {826 t.Fatal(err)827 }828 if info, statErr := os.Stat(target); statErr != nil || info.Size() == 0 {829 t.Errorf("no-config fast path: CLI -o should write the file")830 }831}
Same data, no extra tab — call code_get_file + code_get_findings over MCP from Claude/Cursor/Copilot.