From d4ae36126b6004e80f99c523c36e231657fc6197 Mon Sep 17 00:00:00 2001 From: zerone0x <39543393+zerone0x@users.noreply.github.com> Date: Sat, 4 Jul 2026 20:05:57 +0000 Subject: [PATCH] fix: update brain index for changed files Fixes #20 Co-Authored-By: Hermes Agent --- internal/daemon/brain/handler.go | 253 +++++++++++++++++++++++++- internal/daemon/brain/handler_test.go | 118 ++++++++++++ 2 files changed, 370 insertions(+), 1 deletion(-) create mode 100644 internal/daemon/brain/handler_test.go diff --git a/internal/daemon/brain/handler.go b/internal/daemon/brain/handler.go index d0bd66a..fac836b 100644 --- a/internal/daemon/brain/handler.go +++ b/internal/daemon/brain/handler.go @@ -2,9 +2,12 @@ package brain import ( "context" + "encoding/json" "fmt" "os" "path/filepath" + "sort" + "time" "github.com/get-vix/vix/internal/config" "github.com/get-vix/vix/internal/daemon/brain/lsp" @@ -81,5 +84,253 @@ func doBrainInit(data map[string]any, cred config.Credential) (map[string]any, e } func doBrainUpdateFiles(data map[string]any, cred config.Credential) (map[string]any, error) { - return map[string]any{"status": "ok"}, nil + start := time.Now() + params, _ := data["params"].(map[string]any) + + projectPath, _ := params["project_path"].(string) + if projectPath == "" { + projectPath = "." + } + root, _ := filepath.Abs(projectPath) + info, err := os.Stat(root) + if err != nil || !info.IsDir() { + return map[string]any{"status": "error", "message": fmt.Sprintf("Not a directory: %s", root)}, nil + } + + brainDir, _ := params["brain_dir"].(string) + if brainDir == "" { + brainDir = filepath.Join(root, ".vix") + } + if err := os.MkdirAll(brainDir, 0o755); err != nil { + return map[string]any{"status": "error", "message": fmt.Sprintf("Failed to create brain dir: %v", err)}, nil + } + + files := stringSliceParam(params["files"]) + if len(files) == 0 { + return map[string]any{"status": "error", "message": "missing 'files'"}, nil + } + + index, err := loadBrainIndex(brainDir) + if err != nil { + return map[string]any{"status": "error", "message": fmt.Sprintf("Failed to load brain index: %v", err)}, nil + } + if index.Project.RootPath == "" { + index.Project.RootPath = root + } + if index.Project.Name == "" { + index.Project.Name = filepath.Base(root) + } + + updated := 0 + removed := 0 + for _, filePath := range files { + relPath, ok := normalizeProjectPath(root, filePath) + if !ok { + continue + } + + fileInfo := ScanSingleFile(root, relPath) + if fileInfo == nil { + if removeFileFromIndex(index, relPath) { + removed++ + } + continue + } + + upsertFileInfo(index, *fileInfo) + refreshFileAnalysis(index, root, *fileInfo) + updated++ + } + + recomputeProjectMetadata(index, root) + if err := saveBrainIndex(brainDir, index); err != nil { + return map[string]any{"status": "error", "message": fmt.Sprintf("Failed to save brain index: %v", err)}, nil + } + + return map[string]any{ + "status": "ok", + "data": map[string]any{ + "updated_files": updated, + "removed_files": removed, + "duration_seconds": time.Since(start).Seconds(), + }, + }, nil +} + +func stringSliceParam(raw any) []string { + switch v := raw.(type) { + case []string: + return v + case []any: + out := make([]string, 0, len(v)) + for _, item := range v { + if s, ok := item.(string); ok && s != "" { + out = append(out, s) + } + } + return out + default: + return nil + } +} + +func normalizeProjectPath(root, filePath string) (string, bool) { + if filePath == "" { + return "", false + } + var absPath string + if filepath.IsAbs(filePath) { + absPath = filepath.Clean(filePath) + } else { + absPath = filepath.Join(root, filepath.Clean(filePath)) + } + relPath, err := filepath.Rel(root, absPath) + if err != nil || relPath == "." || relPath == "" || relPath == ".." || len(relPath) >= 3 && relPath[:3] == "../" { + return "", false + } + return relPath, true +} + +func loadBrainIndex(brainDir string) (*BrainIndex, error) { + path := filepath.Join(brainDir, "index.json") + data, err := os.ReadFile(path) + if err != nil { + if os.IsNotExist(err) { + return &BrainIndex{}, nil + } + return nil, err + } + var index BrainIndex + if err := json.Unmarshal(data, &index); err != nil { + return nil, err + } + return &index, nil +} + +func saveBrainIndex(brainDir string, index *BrainIndex) error { + data, err := json.MarshalIndent(index, "", " ") + if err != nil { + return err + } + return os.WriteFile(filepath.Join(brainDir, "index.json"), append(data, '\n'), 0o644) +} + +func upsertFileInfo(index *BrainIndex, fileInfo FileInfo) { + for i, existing := range index.Files { + if existing.Path == fileInfo.Path { + index.Files[i] = fileInfo + return + } + } + index.Files = append(index.Files, fileInfo) +} + +func removeFileFromIndex(index *BrainIndex, relPath string) bool { + found := false + files := index.Files[:0] + for _, fileInfo := range index.Files { + if fileInfo.Path == relPath { + found = true + continue + } + files = append(files, fileInfo) + } + index.Files = files + removeFileAnalysis(index, relPath) + return found +} + +func refreshFileAnalysis(index *BrainIndex, root string, fileInfo FileInfo) { + removeFileAnalysis(index, fileInfo.Path) + if fileInfo.Language == "" { + return + } + + symbols, calls := ParseFile(fileInfo.Path, root, fileInfo.Language) + index.Symbols = append(index.Symbols, symbols...) + index.Calls = append(index.Calls, calls...) + + data, err := os.ReadFile(filepath.Join(root, fileInfo.Path)) + if err != nil { + return + } + filePaths := make(map[string]bool, len(index.Files)) + for _, file := range index.Files { + filePaths[file.Path] = true + } + index.Imports = append(index.Imports, ExtractFileImports(string(data), fileInfo.Path, filePaths, root, fileInfo.Language)...) +} + +func removeFileAnalysis(index *BrainIndex, relPath string) { + symbols := index.Symbols[:0] + for _, symbol := range index.Symbols { + if symbol.FilePath != relPath { + symbols = append(symbols, symbol) + } + } + index.Symbols = symbols + + calls := index.Calls[:0] + for _, call := range index.Calls { + if call.FilePath != relPath { + calls = append(calls, call) + } + } + index.Calls = calls + + imports := index.Imports[:0] + for _, imp := range index.Imports { + if imp.SourceFile != relPath && imp.TargetFile != relPath { + imports = append(imports, imp) + } + } + index.Imports = imports +} + +func recomputeProjectMetadata(index *BrainIndex, root string) { + sort.Slice(index.Files, func(i, j int) bool { return index.Files[i].Path < index.Files[j].Path }) + sort.Slice(index.Symbols, func(i, j int) bool { return index.Symbols[i].ID < index.Symbols[j].ID }) + sort.Slice(index.Imports, func(i, j int) bool { + if index.Imports[i].SourceFile == index.Imports[j].SourceFile { + return index.Imports[i].Module < index.Imports[j].Module + } + return index.Imports[i].SourceFile < index.Imports[j].SourceFile + }) + + languages := make(map[string]int) + totalLines := 0 + entryPoints := make([]string, 0) + configFiles := make([]string, 0) + for _, fileInfo := range index.Files { + if fileInfo.Language != "" { + languages[fileInfo.Language]++ + } + totalLines += fileInfo.LineCount + if fileInfo.IsEntryPoint { + entryPoints = append(entryPoints, fileInfo.Path) + } + if fileInfo.IsConfig { + configFiles = append(configFiles, fileInfo.Path) + } + } + sort.Strings(entryPoints) + sort.Strings(configFiles) + + externalDeps := ParseDependencies(root) + frameworks := DetectFrameworks(root, index.Files, externalDeps) + index.Project = ProjectMeta{ + Name: filepath.Base(root), + RootPath: root, + TotalFiles: len(index.Files), + TotalLines: totalLines, + Languages: languages, + EntryPoints: entryPoints, + ConfigFiles: configFiles, + ExternalDeps: externalDeps, + Frameworks: frameworks.Frameworks, + Patterns: frameworks.Patterns, + TestingFrameworks: frameworks.Testing, + CICD: frameworks.CICD, + } + index.HubFiles = FindHubFiles(index.Imports, 10) } diff --git a/internal/daemon/brain/handler_test.go b/internal/daemon/brain/handler_test.go new file mode 100644 index 0000000..d2ed1ec --- /dev/null +++ b/internal/daemon/brain/handler_test.go @@ -0,0 +1,118 @@ +package brain + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + + "github.com/get-vix/vix/internal/config" + "github.com/get-vix/vix/internal/daemon/brain/lsp" +) + +func resetLanguageMapForTest(t *testing.T) { + t.Helper() + extMapMu.Lock() + extMap = nil + extMapMu.Unlock() + InitLanguageMapFromConfigs([]lsp.LanguageConfig{{Name: "go", Extensions: []string{".go"}}}) +} + +func readTestIndex(t *testing.T, brainDir string) BrainIndex { + t.Helper() + data, err := os.ReadFile(filepath.Join(brainDir, "index.json")) + if err != nil { + t.Fatalf("read index: %v", err) + } + var index BrainIndex + if err := json.Unmarshal(data, &index); err != nil { + t.Fatalf("unmarshal index: %v", err) + } + return index +} + +func TestBrainUpdateFilesUpsertsIndex(t *testing.T) { + resetLanguageMapForTest(t) + root := t.TempDir() + brainDir := filepath.Join(root, ".vix") + if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n\nfunc main() {}\n"), 0o644); err != nil { + t.Fatalf("write source: %v", err) + } + + resp, err := doBrainUpdateFiles(map[string]any{"params": map[string]any{ + "project_path": root, + "brain_dir": brainDir, + "files": []any{"main.go"}, + }}, config.Credential{}) + if err != nil { + t.Fatalf("update files returned error: %v", err) + } + if resp["status"] != "ok" { + t.Fatalf("status = %v, want ok (resp=%v)", resp["status"], resp) + } + + index := readTestIndex(t, brainDir) + if got, want := index.Project.TotalFiles, 1; got != want { + t.Fatalf("TotalFiles = %d, want %d", got, want) + } + if len(index.Files) != 1 || index.Files[0].Path != "main.go" { + t.Fatalf("files = %#v, want main.go", index.Files) + } + if got, want := index.Project.Languages["go"], 1; got != want { + t.Fatalf("go language count = %d, want %d", got, want) + } + + if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n\nfunc main() { println(1) }\n"), 0o644); err != nil { + t.Fatalf("rewrite source: %v", err) + } + resp, err = doBrainUpdateFiles(map[string]any{"params": map[string]any{ + "project_path": root, + "brain_dir": brainDir, + "files": []string{"main.go"}, + }}, config.Credential{}) + if err != nil || resp["status"] != "ok" { + t.Fatalf("second update resp=%v err=%v", resp, err) + } + index = readTestIndex(t, brainDir) + if len(index.Files) != 1 { + t.Fatalf("duplicate file entries after upsert: %#v", index.Files) + } + if index.Files[0].SizeBytes == len("package main\n\nfunc main() {}\n") { + t.Fatalf("file metadata did not refresh: %#v", index.Files[0]) + } +} + +func TestBrainUpdateFilesRemovesDeletedFile(t *testing.T) { + resetLanguageMapForTest(t) + root := t.TempDir() + brainDir := filepath.Join(root, ".vix") + if err := os.MkdirAll(brainDir, 0o755); err != nil { + t.Fatalf("mkdir brain dir: %v", err) + } + index := BrainIndex{ + Project: ProjectMeta{Name: filepath.Base(root), RootPath: root, TotalFiles: 1, Languages: map[string]int{"go": 1}}, + Files: []FileInfo{{Path: "gone.go", Language: "go", SizeBytes: 10, LineCount: 1}}, + Symbols: []SymbolInfo{{ID: "gone.go:f", Name: "f", FilePath: "gone.go"}}, + Imports: []ImportInfo{{SourceFile: "gone.go", Module: "fmt", IsExternal: true}}, + } + if err := saveBrainIndex(brainDir, &index); err != nil { + t.Fatalf("seed index: %v", err) + } + + resp, err := doBrainUpdateFiles(map[string]any{"params": map[string]any{ + "project_path": root, + "brain_dir": brainDir, + "files": []string{"gone.go"}, + }}, config.Credential{}) + if err != nil || resp["status"] != "ok" { + t.Fatalf("delete update resp=%v err=%v", resp, err) + } + + updated := readTestIndex(t, brainDir) + if len(updated.Files) != 0 || len(updated.Symbols) != 0 || len(updated.Imports) != 0 { + t.Fatalf("deleted file references remain: %#v", updated) + } + if updated.Project.TotalFiles != 0 { + t.Fatalf("TotalFiles = %d, want 0", updated.Project.TotalFiles) + } +}