diff --git a/.gitignore b/.gitignore index 4576974..22f2942 100644 --- a/.gitignore +++ b/.gitignore @@ -15,6 +15,7 @@ coverage.out *.swo TODO.md GEMINI.md +ANALYSIS.md codegraph codegraph.exe codegraph.sqlite diff --git a/internal/cli/app.go b/internal/cli/app.go index 56b3d0a..1f28856 100644 --- a/internal/cli/app.go +++ b/internal/cli/app.go @@ -62,46 +62,12 @@ func Run(ctx context.Context, args []string, stdout, stderr io.Writer) error { logger := logging.New(globalCfg.DefaultLogLevel, stderr) _ = logger - switch args[0] { - case "install": - return runInstall(stdout) - case "index": - return runIndex(ctx, globalCfg, stdout, args[1:], false) - case "update": - return runIndex(ctx, globalCfg, stdout, args[1:], true) - case "stats": - return runStats(ctx, globalCfg, stdout, args[1:]) - case "find-symbol": - return runQueryCommand(ctx, globalCfg, stdout, "find-symbol", args[1:]) - case "callers": - return runQueryCommand(ctx, globalCfg, stdout, "callers", args[1:]) - case "callees": - return runQueryCommand(ctx, globalCfg, stdout, "callees", args[1:]) - case "impact": - return runQueryCommand(ctx, globalCfg, stdout, "impact", args[1:]) - case "search": - return runQueryCommand(ctx, globalCfg, stdout, "search", args[1:]) - case "doctor": - return runDoctor(stdout, args[1:]) - case "config": - return runConfig(globalCfg, stdout, args[1:]) - case "benchmark": - return runBenchmark(ctx, stdout, args[1:]) - case "serve": - return runServe(ctx, globalCfg, stdout, stderr, args[1:]) - case "watch": - return runWatch(ctx, globalCfg, stdout, args[1:]) - case "graph": - return runGraph(ctx, globalCfg, stdout, args[1:]) - case "clean": - return runClean(ctx, globalCfg, stdout, args[1:]) - case "affected-tests": - return runAffectedTests(ctx, globalCfg, stdout, args[1:]) - case "visualize": - return runVisualize(ctx, globalCfg, stdout, args[1:]) - default: + cmd, ok := lookupCommand(args[0]) + if !ok { return fmt.Errorf("unknown command %q", args[0]) } + + return cmd.run(ctx, globalCfg, stdout, stderr, args[1:]) } func runDoctor(stdout io.Writer, args []string) error { @@ -1167,33 +1133,18 @@ func runAffectedTests(ctx context.Context, cfg config.Config, stdout io.Writer, } func printUsage(w io.Writer) { - for _, line := range []string{ - "codegraph commands:", - " install", - " index ", - " update ", - " add --jsonl for streaming line-delimited JSON events", - " serve --repo-root ", - " stats ", - " find-symbol ", - " search ", - " callers --symbol ", - " callees --symbol ", - " impact [--symbol ] [--file ]", - " doctor", - " add --fix for non-destructive autofixes", - " config ", - " config init [--repo PATH] [--force]", - " benchmark [--count N] [--benchtime DURATION] [--save-baseline]", - " graph export [--format json|dot]", - " watch ", - " add --jsonl for streaming line-delimited JSON events", - " affected-tests [--repo-root PATH] [--stdin] [--json] [--limit N] ...", - " find tests affected by changed files; pipe from git diff --name-only", - " visualize [--repo-root PATH] [--symbol NAME] [--depth N] [--output FILE]", - " interactive D3.js graph visualization; opens browser or writes HTML file", - " clean [repo-path] [--vacuum]", - } { - fmt.Fprintln(w, line) + fmt.Fprintln(w, "codegraph commands:") + for _, cmd := range commandList { + lines := cmd.usageLines + if len(lines) == 0 { + lines = []string{" " + cmd.name} + } + for i, line := range lines { + if i == 0 && cmd.description != "" { + fmt.Fprintf(w, "%s - %s\n", line, cmd.description) + continue + } + fmt.Fprintln(w, line) + } } } diff --git a/internal/cli/commands.go b/internal/cli/commands.go new file mode 100644 index 0000000..4a0ef76 --- /dev/null +++ b/internal/cli/commands.go @@ -0,0 +1,227 @@ +package cli + +import ( + "context" + "fmt" + "io" + + "github.com/isink17/codegraph/internal/config" +) + +type command struct { + name string + aliases []string + description string + usageLines []string + run func(context.Context, config.Config, io.Writer, io.Writer, []string) error +} + +var ( + commandList = newCommandList() + commandByName = newCommandRegistry(commandList) +) + +func lookupCommand(name string) (*command, bool) { + c, ok := commandByName[name] + return c, ok +} + +func newCommandRegistry(cmds []*command) map[string]*command { + reg := map[string]*command{} + + for _, c := range cmds { + registerCommand(reg, c) + } + + return reg +} + +func registerCommand(reg map[string]*command, c *command) { + if c == nil { + panic("command registry: nil command") + } + if c.name == "" { + panic("command registry: empty command name") + } + registerKey := func(key string) { + if key == "" { + panic(fmt.Sprintf("command registry: empty key for command %q", c.name)) + } + if prev, exists := reg[key]; exists { + panic(fmt.Sprintf("command registry: duplicate key %q for command %q (already used by %q)", key, c.name, prev.name)) + } + reg[key] = c + } + + registerKey(c.name) + for _, a := range c.aliases { + registerKey(a) + } +} + +func newCommandList() []*command { + return []*command{ + { + name: "install", + description: "install codegraph", + usageLines: []string{" install"}, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runInstall(stdout) + }, + }, + { + name: "index", + description: "index a repository", + usageLines: []string{" index "}, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runIndex(ctx, cfg, stdout, args, false) + }, + }, + { + name: "update", + description: "incrementally update an index", + usageLines: []string{ + " update ", + " add --jsonl for streaming line-delimited JSON events", + }, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runIndex(ctx, cfg, stdout, args, true) + }, + }, + { + name: "stats", + description: "show index stats", + usageLines: []string{" stats "}, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runStats(ctx, cfg, stdout, args) + }, + }, + { + name: "find-symbol", + description: "find symbols by name", + usageLines: []string{" find-symbol "}, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runQueryCommand(ctx, cfg, stdout, "find-symbol", args) + }, + }, + { + name: "callers", + description: "find callers of a symbol", + usageLines: []string{" callers --symbol "}, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runQueryCommand(ctx, cfg, stdout, "callers", args) + }, + }, + { + name: "callees", + description: "find callees of a symbol", + usageLines: []string{" callees --symbol "}, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runQueryCommand(ctx, cfg, stdout, "callees", args) + }, + }, + { + name: "impact", + description: "compute impact radius", + usageLines: []string{" impact [--symbol ] [--file ]"}, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runQueryCommand(ctx, cfg, stdout, "impact", args) + }, + }, + { + name: "search", + description: "search", + usageLines: []string{" search "}, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runQueryCommand(ctx, cfg, stdout, "search", args) + }, + }, + { + name: "doctor", + description: "run diagnostics", + usageLines: []string{ + " doctor", + " add --fix for non-destructive autofixes", + }, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runDoctor(stdout, args) + }, + }, + { + name: "config", + description: "config commands", + usageLines: []string{ + " config ", + " config init [--repo PATH] [--force]", + }, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runConfig(cfg, stdout, args) + }, + }, + { + name: "benchmark", + description: "benchmarks", + usageLines: []string{" benchmark [--count N] [--benchtime DURATION] [--save-baseline]"}, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runBenchmark(ctx, stdout, args) + }, + }, + { + name: "serve", + description: "start MCP server", + usageLines: []string{" serve --repo-root "}, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runServe(ctx, cfg, stdout, stderr, args) + }, + }, + { + name: "watch", + description: "watch repository and update index", + usageLines: []string{ + " watch ", + " add --jsonl for streaming line-delimited JSON events", + }, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runWatch(ctx, cfg, stdout, args) + }, + }, + { + name: "graph", + description: "graph commands", + usageLines: []string{" graph export [--format json|dot]"}, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runGraph(ctx, cfg, stdout, args) + }, + }, + { + name: "clean", + description: "clean index data", + usageLines: []string{" clean [repo-path] [--vacuum]"}, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runClean(ctx, cfg, stdout, args) + }, + }, + { + name: "affected-tests", + description: "find affected tests", + usageLines: []string{ + " affected-tests [--repo-root PATH] [--stdin] [--json] [--limit N] ...", + " find tests affected by changed files; pipe from git diff --name-only", + }, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runAffectedTests(ctx, cfg, stdout, args) + }, + }, + { + name: "visualize", + description: "generate interactive graph HTML", + usageLines: []string{ + " visualize [--repo-root PATH] [--symbol NAME] [--depth N] [--output FILE]", + " interactive D3.js graph visualization; opens browser or writes HTML file", + }, + run: func(ctx context.Context, cfg config.Config, stdout, stderr io.Writer, args []string) error { + return runVisualize(ctx, cfg, stdout, args) + }, + }, + } +} diff --git a/internal/cli/commands_test.go b/internal/cli/commands_test.go new file mode 100644 index 0000000..173bdc3 --- /dev/null +++ b/internal/cli/commands_test.go @@ -0,0 +1,27 @@ +package cli + +import ( + "testing" +) + +func TestRegisterCommandPanicsOnDuplicate(t *testing.T) { + reg := map[string]*command{} + registerCommand(reg, &command{name: "a"}) + defer func() { + if r := recover(); r == nil { + t.Fatalf("expected panic on duplicate key, got nil") + } + }() + registerCommand(reg, &command{name: "a"}) +} + +func TestRegisterCommandPanicsOnAliasDuplicate(t *testing.T) { + reg := map[string]*command{} + registerCommand(reg, &command{name: "a", aliases: []string{"x"}}) + defer func() { + if r := recover(); r == nil { + t.Fatalf("expected panic on duplicate alias key, got nil") + } + }() + registerCommand(reg, &command{name: "b", aliases: []string{"x"}}) +}