mirror of
https://github.com/lukaszraczylo/filepuff-mcp.git
synced 2026-06-11 23:09:02 +00:00
feat(docs, ci, config): add comprehensive documentation and tooling
- [x] Add API reference documentation with tool descriptions and examples - [x] Add ERROR_CODES reference with error descriptions and remediation steps - [x] Add PERFORMANCE tuning guide with caching and optimization details - [x] Add GitHub Actions workflows for linting and security scanning - [x] Add golangci-lint configuration with comprehensive linter settings - [x] Add pre-commit hooks configuration for local development - [x] Add API documentation generator tool (cmd/docgen) - [x] Update Go version from 1.24 to 1.25 across workflows - [x] Add static build configuration to goreleaser - [x] Add metrics package with Prometheus-style metric types - [x] Add parser benchmarks for performance testing - [x] Add LSP manager integration tests - [x] Add server integration tests with MCP protocol flow testing - [x] Extract regex cache to shared utility package - [x] Add context cancellation handling in AST queries - [x] Add graceful shutdown with timeout to server - [x] Add configurable max parse size (MaxParseSize) - [x] Add Config.Validate() method with comprehensive checks - [x] Add parser cache statistics tracking - [x] Add file permission preservation in edit operations - [x] Improve line splitting for large files with bufio.Scanner - [x] Add comprehensive config tests for edge cases - [x] Update Makefile with new targets and documentation
This commit is contained in:
@@ -2,6 +2,7 @@
|
||||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
@@ -17,6 +18,7 @@ type Config struct {
|
||||
LSPTimeout time.Duration `json:"lsp_timeout"`
|
||||
SearchTimeout time.Duration `json:"search_timeout"`
|
||||
MaxFileSize int64 `json:"max_file_size"`
|
||||
MaxParseSize int64 `json:"max_parse_size"`
|
||||
MaxSearchResults int `json:"max_search_results"`
|
||||
MaxEditSize int64 `json:"max_edit_size"`
|
||||
EnableLSP bool `json:"enable_lsp"`
|
||||
@@ -29,6 +31,7 @@ const (
|
||||
DefaultLSPTimeout = 5 * time.Minute
|
||||
DefaultSearchTimeout = 30 * time.Second
|
||||
DefaultMaxFileSize = 10 * 1024 * 1024 // 10 MB
|
||||
DefaultMaxParseSize = 10 * 1024 * 1024 // 10 MB
|
||||
DefaultMaxSearchResults = 1000
|
||||
DefaultMaxEditSize = 100 * 1024 // 100 KB
|
||||
)
|
||||
@@ -40,6 +43,7 @@ func Default() *Config {
|
||||
LSPTimeout: DefaultLSPTimeout,
|
||||
SearchTimeout: DefaultSearchTimeout,
|
||||
MaxFileSize: DefaultMaxFileSize,
|
||||
MaxParseSize: DefaultMaxParseSize,
|
||||
MaxSearchResults: DefaultMaxSearchResults,
|
||||
MaxEditSize: DefaultMaxEditSize,
|
||||
EnableLSP: true,
|
||||
@@ -172,3 +176,41 @@ func (c *Config) IsPathAllowed(path string) bool {
|
||||
// Also reject empty relative path (which means it's the workspace root itself)
|
||||
return rel != "." && !strings.HasPrefix(rel, "..")
|
||||
}
|
||||
|
||||
// Validate validates the configuration and returns an error if invalid.
|
||||
// Checks include:
|
||||
// - MaxFileSize and MaxParseSize must be positive
|
||||
// - LSPTimeout must be positive
|
||||
// - WorkspaceRoot must exist (when not empty)
|
||||
func (c *Config) Validate() error {
|
||||
// Validate MaxFileSize
|
||||
if c.MaxFileSize <= 0 {
|
||||
return fmt.Errorf("max_file_size must be positive, got %d", c.MaxFileSize)
|
||||
}
|
||||
|
||||
// Validate MaxParseSize
|
||||
if c.MaxParseSize <= 0 {
|
||||
return fmt.Errorf("max_parse_size must be positive, got %d", c.MaxParseSize)
|
||||
}
|
||||
|
||||
// Validate LSPTimeout
|
||||
if c.LSPTimeout <= 0 {
|
||||
return fmt.Errorf("lsp_timeout must be positive, got %v", c.LSPTimeout)
|
||||
}
|
||||
|
||||
// Validate WorkspaceRoot exists
|
||||
if c.WorkspaceRoot != "" {
|
||||
info, err := os.Stat(c.WorkspaceRoot)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return fmt.Errorf("workspace_root does not exist: %s", c.WorkspaceRoot)
|
||||
}
|
||||
return fmt.Errorf("cannot access workspace_root: %w", err)
|
||||
}
|
||||
if !info.IsDir() {
|
||||
return fmt.Errorf("workspace_root is not a directory: %s", c.WorkspaceRoot)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -45,7 +45,7 @@ func TestLoad(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
|
||||
|
||||
cfg, err := Load(tmpDir)
|
||||
if err != nil {
|
||||
@@ -108,7 +108,7 @@ func TestIsPathAllowed(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
|
||||
|
||||
cfg := Default()
|
||||
cfg.WorkspaceRoot = tmpDir
|
||||
@@ -156,7 +156,7 @@ func TestLoadWithConfigFile(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
defer os.RemoveAll(tmpDir)
|
||||
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
|
||||
|
||||
// Write config file
|
||||
configPath := filepath.Join(tmpDir, ".mcp-filepuff.json")
|
||||
@@ -164,7 +164,7 @@ func TestLoadWithConfigFile(t *testing.T) {
|
||||
"enable_lsp": false,
|
||||
"follow_symlinks": false
|
||||
}`
|
||||
err = os.WriteFile(configPath, []byte(configContent), 0600)
|
||||
err = os.WriteFile(configPath, []byte(configContent), 0o600)
|
||||
if err != nil {
|
||||
t.Fatalf("failed to write config file: %v", err)
|
||||
}
|
||||
@@ -182,3 +182,381 @@ func TestLoadWithConfigFile(t *testing.T) {
|
||||
t.Error("expected FollowSymlinks to be false from config file")
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidate tests the Validate method with various inputs.
|
||||
func TestValidate(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cfg *Config
|
||||
expectErr bool
|
||||
errMsg string
|
||||
}{
|
||||
{
|
||||
name: "valid_config",
|
||||
cfg: Default(),
|
||||
expectErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid_max_file_size",
|
||||
cfg: &Config{
|
||||
WorkspaceRoot: ".",
|
||||
MaxFileSize: -1,
|
||||
MaxParseSize: DefaultMaxParseSize,
|
||||
LSPTimeout: DefaultLSPTimeout,
|
||||
},
|
||||
expectErr: true,
|
||||
errMsg: "max_file_size must be positive",
|
||||
},
|
||||
{
|
||||
name: "zero_max_file_size",
|
||||
cfg: &Config{
|
||||
WorkspaceRoot: ".",
|
||||
MaxFileSize: 0,
|
||||
MaxParseSize: DefaultMaxParseSize,
|
||||
LSPTimeout: DefaultLSPTimeout,
|
||||
},
|
||||
expectErr: true,
|
||||
errMsg: "max_file_size must be positive",
|
||||
},
|
||||
{
|
||||
name: "invalid_max_parse_size",
|
||||
cfg: &Config{
|
||||
WorkspaceRoot: ".",
|
||||
MaxFileSize: DefaultMaxFileSize,
|
||||
MaxParseSize: -1,
|
||||
LSPTimeout: DefaultLSPTimeout,
|
||||
},
|
||||
expectErr: true,
|
||||
errMsg: "max_parse_size must be positive",
|
||||
},
|
||||
{
|
||||
name: "zero_max_parse_size",
|
||||
cfg: &Config{
|
||||
WorkspaceRoot: ".",
|
||||
MaxFileSize: DefaultMaxFileSize,
|
||||
MaxParseSize: 0,
|
||||
LSPTimeout: DefaultLSPTimeout,
|
||||
},
|
||||
expectErr: true,
|
||||
errMsg: "max_parse_size must be positive",
|
||||
},
|
||||
{
|
||||
name: "invalid_lsp_timeout",
|
||||
cfg: &Config{
|
||||
WorkspaceRoot: ".",
|
||||
MaxFileSize: DefaultMaxFileSize,
|
||||
MaxParseSize: DefaultMaxParseSize,
|
||||
LSPTimeout: -1 * time.Second,
|
||||
},
|
||||
expectErr: true,
|
||||
errMsg: "lsp_timeout must be positive",
|
||||
},
|
||||
{
|
||||
name: "zero_lsp_timeout",
|
||||
cfg: &Config{
|
||||
WorkspaceRoot: ".",
|
||||
MaxFileSize: DefaultMaxFileSize,
|
||||
MaxParseSize: DefaultMaxParseSize,
|
||||
LSPTimeout: 0,
|
||||
},
|
||||
expectErr: true,
|
||||
errMsg: "lsp_timeout must be positive",
|
||||
},
|
||||
{
|
||||
name: "nonexistent_workspace",
|
||||
cfg: &Config{
|
||||
WorkspaceRoot: "/nonexistent/path/that/does/not/exist",
|
||||
MaxFileSize: DefaultMaxFileSize,
|
||||
MaxParseSize: DefaultMaxParseSize,
|
||||
LSPTimeout: DefaultLSPTimeout,
|
||||
},
|
||||
expectErr: true,
|
||||
errMsg: "workspace_root does not exist",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := tt.cfg.Validate()
|
||||
if tt.expectErr {
|
||||
if err == nil {
|
||||
t.Errorf("expected error containing %q, got nil", tt.errMsg)
|
||||
} else if !contains(err.Error(), tt.errMsg) {
|
||||
t.Errorf("expected error containing %q, got %q", tt.errMsg, err.Error())
|
||||
}
|
||||
} else {
|
||||
if err != nil {
|
||||
t.Errorf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestValidateWithFile tests validation with an actual file as workspace root.
|
||||
func TestValidateWithFile(t *testing.T) {
|
||||
// Create a temporary file
|
||||
tmpFile, err := os.CreateTemp("", "test-file-*.txt")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp file: %v", err)
|
||||
}
|
||||
_ = tmpFile.Close()
|
||||
t.Cleanup(func() { _ = os.Remove(tmpFile.Name()) })
|
||||
|
||||
cfg := &Config{
|
||||
WorkspaceRoot: tmpFile.Name(),
|
||||
MaxFileSize: DefaultMaxFileSize,
|
||||
MaxParseSize: DefaultMaxParseSize,
|
||||
LSPTimeout: DefaultLSPTimeout,
|
||||
}
|
||||
|
||||
err = cfg.Validate()
|
||||
if err == nil {
|
||||
t.Error("expected error when workspace_root is a file, got nil")
|
||||
} else if !contains(err.Error(), "is not a directory") {
|
||||
t.Errorf("expected error about not being a directory, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadEnvironmentPrecedence tests environment variable precedence.
|
||||
func TestLoadEnvironmentPrecedence(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "mcp-filepuff-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
|
||||
|
||||
// Write a config file with specific values
|
||||
configPath := filepath.Join(tmpDir, ".mcp-filepuff.json")
|
||||
configContent := `{
|
||||
"enable_lsp": false,
|
||||
"follow_symlinks": false,
|
||||
"lsp_timeout": 60000000000
|
||||
}`
|
||||
if err := os.WriteFile(configPath, []byte(configContent), 0o600); err != nil {
|
||||
t.Fatalf("failed to write config file: %v", err)
|
||||
}
|
||||
|
||||
// Save and restore environment variables
|
||||
origEnableLSP := os.Getenv("MCP_ENABLE_LSP")
|
||||
origLSPTimeout := os.Getenv("MCP_LSP_TIMEOUT")
|
||||
t.Cleanup(func() {
|
||||
_ = os.Setenv("MCP_ENABLE_LSP", origEnableLSP)
|
||||
_ = os.Setenv("MCP_LSP_TIMEOUT", origLSPTimeout)
|
||||
})
|
||||
|
||||
// Set environment variables that should override config file
|
||||
_ = os.Setenv("MCP_ENABLE_LSP", "false")
|
||||
_ = os.Setenv("MCP_LSP_TIMEOUT", "2m")
|
||||
|
||||
cfg, err := Load(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
// Environment variable should override config file
|
||||
if cfg.LSPTimeout != 2*time.Minute {
|
||||
t.Errorf("expected LSP timeout 2m from env, got %v", cfg.LSPTimeout)
|
||||
}
|
||||
|
||||
if cfg.EnableLSP {
|
||||
t.Error("expected EnableLSP to be false from env")
|
||||
}
|
||||
|
||||
// Value from config file (not overridden by env)
|
||||
if cfg.FollowSymlinks {
|
||||
t.Error("expected FollowSymlinks to be false from config file")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsPathAllowedEdgeCases tests edge cases in path validation.
|
||||
func TestIsPathAllowedEdgeCases(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "mcp-filepuff-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
|
||||
|
||||
cfg := Default()
|
||||
cfg.WorkspaceRoot = tmpDir
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
path string
|
||||
allowed bool
|
||||
desc string
|
||||
}{
|
||||
{
|
||||
name: "workspace_root_itself",
|
||||
path: tmpDir,
|
||||
allowed: false,
|
||||
desc: "workspace root itself should not be allowed",
|
||||
},
|
||||
{
|
||||
name: "dot_relative",
|
||||
path: ".",
|
||||
allowed: false,
|
||||
desc: "current directory should not be allowed",
|
||||
},
|
||||
{
|
||||
name: "empty_path",
|
||||
path: "",
|
||||
allowed: false,
|
||||
desc: "empty path should not be allowed",
|
||||
},
|
||||
{
|
||||
name: "path_with_double_dots",
|
||||
path: filepath.Join(tmpDir, "..", filepath.Base(tmpDir), "file.txt"),
|
||||
allowed: true,
|
||||
desc: "path with .. that resolves back inside workspace should be allowed",
|
||||
},
|
||||
{
|
||||
name: "deeply_nested_valid",
|
||||
path: filepath.Join(tmpDir, "a", "b", "c", "file.txt"),
|
||||
allowed: true,
|
||||
desc: "deeply nested path should be allowed",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result := cfg.IsPathAllowed(tt.path)
|
||||
if result != tt.allowed {
|
||||
t.Errorf("%s: IsPathAllowed(%q) = %v, want %v", tt.desc, tt.path, result, tt.allowed)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestIsPathAllowedWithSymlinks tests path validation with symbolic links.
|
||||
func TestIsPathAllowedWithSymlinks(t *testing.T) {
|
||||
// Create temporary directories
|
||||
tmpDir, err := os.MkdirTemp("", "mcp-filepuff-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
|
||||
|
||||
realDir := filepath.Join(tmpDir, "real")
|
||||
if err := os.MkdirAll(realDir, 0o755); err != nil {
|
||||
t.Fatalf("failed to create real dir: %v", err)
|
||||
}
|
||||
|
||||
// Create a symlink inside workspace
|
||||
symlinkPath := filepath.Join(tmpDir, "link")
|
||||
if err := os.Symlink(realDir, symlinkPath); err != nil {
|
||||
t.Skip("symlink creation not supported on this system")
|
||||
}
|
||||
|
||||
cfg := Default()
|
||||
cfg.WorkspaceRoot = tmpDir
|
||||
|
||||
// File accessed through symlink should be allowed
|
||||
fileViaSymlink := filepath.Join(symlinkPath, "test.txt")
|
||||
if !cfg.IsPathAllowed(fileViaSymlink) {
|
||||
t.Error("file accessed through symlink inside workspace should be allowed")
|
||||
}
|
||||
|
||||
// Direct access should also work
|
||||
fileDirect := filepath.Join(realDir, "test.txt")
|
||||
if !cfg.IsPathAllowed(fileDirect) {
|
||||
t.Error("file accessed directly should be allowed")
|
||||
}
|
||||
}
|
||||
|
||||
// TestLoadDefaultValues tests that default values are properly set.
|
||||
func TestLoadDefaultValues(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "mcp-filepuff-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
|
||||
|
||||
// Clear any environment variables that might affect defaults
|
||||
origVars := []struct{ key, val string }{
|
||||
{"MCP_ENABLE_LSP", os.Getenv("MCP_ENABLE_LSP")},
|
||||
{"MCP_FOLLOW_SYMLINKS", os.Getenv("MCP_FOLLOW_SYMLINKS")},
|
||||
{"MCP_RESPECT_GITIGNORE", os.Getenv("MCP_RESPECT_GITIGNORE")},
|
||||
}
|
||||
t.Cleanup(func() {
|
||||
for _, v := range origVars {
|
||||
_ = os.Setenv(v.key, v.val)
|
||||
}
|
||||
})
|
||||
for _, v := range origVars {
|
||||
_ = os.Unsetenv(v.key)
|
||||
}
|
||||
|
||||
cfg, err := Load(tmpDir)
|
||||
if err != nil {
|
||||
t.Fatalf("Load failed: %v", err)
|
||||
}
|
||||
|
||||
// Verify all default values
|
||||
if cfg.LSPTimeout != DefaultLSPTimeout {
|
||||
t.Errorf("expected LSPTimeout %v, got %v", DefaultLSPTimeout, cfg.LSPTimeout)
|
||||
}
|
||||
if cfg.SearchTimeout != DefaultSearchTimeout {
|
||||
t.Errorf("expected SearchTimeout %v, got %v", DefaultSearchTimeout, cfg.SearchTimeout)
|
||||
}
|
||||
if cfg.MaxFileSize != DefaultMaxFileSize {
|
||||
t.Errorf("expected MaxFileSize %d, got %d", DefaultMaxFileSize, cfg.MaxFileSize)
|
||||
}
|
||||
if cfg.MaxParseSize != DefaultMaxParseSize {
|
||||
t.Errorf("expected MaxParseSize %d, got %d", DefaultMaxParseSize, cfg.MaxParseSize)
|
||||
}
|
||||
if cfg.MaxSearchResults != DefaultMaxSearchResults {
|
||||
t.Errorf("expected MaxSearchResults %d, got %d", DefaultMaxSearchResults, cfg.MaxSearchResults)
|
||||
}
|
||||
if cfg.MaxEditSize != DefaultMaxEditSize {
|
||||
t.Errorf("expected MaxEditSize %d, got %d", DefaultMaxEditSize, cfg.MaxEditSize)
|
||||
}
|
||||
if !cfg.EnableLSP {
|
||||
t.Error("expected EnableLSP to be true by default")
|
||||
}
|
||||
if !cfg.FollowSymlinks {
|
||||
t.Error("expected FollowSymlinks to be true by default")
|
||||
}
|
||||
if !cfg.RespectGitignore {
|
||||
t.Error("expected RespectGitignore to be true by default")
|
||||
}
|
||||
if cfg.Formatters == nil {
|
||||
t.Error("expected Formatters map to be initialized")
|
||||
}
|
||||
}
|
||||
|
||||
// TestConfigFileLoadingErrors tests error handling during config file loading.
|
||||
func TestConfigFileLoadingErrors(t *testing.T) {
|
||||
tmpDir, err := os.MkdirTemp("", "mcp-filepuff-test")
|
||||
if err != nil {
|
||||
t.Fatalf("failed to create temp dir: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = os.RemoveAll(tmpDir) })
|
||||
|
||||
// Write invalid JSON
|
||||
configPath := filepath.Join(tmpDir, ".mcp-filepuff.json")
|
||||
invalidJSON := `{"enable_lsp": invalid_value}`
|
||||
if err := os.WriteFile(configPath, []byte(invalidJSON), 0o600); err != nil {
|
||||
t.Fatalf("failed to write config file: %v", err)
|
||||
}
|
||||
|
||||
_, err = Load(tmpDir)
|
||||
if err == nil {
|
||||
t.Error("expected error when loading invalid JSON config file")
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to check if a string contains a substring.
|
||||
func contains(s, substr string) bool {
|
||||
return len(s) >= len(substr) && (s == substr || len(substr) == 0 ||
|
||||
(len(s) > 0 && len(substr) > 0 && containsHelper(s, substr)))
|
||||
}
|
||||
|
||||
func containsHelper(s, substr string) bool {
|
||||
for i := 0; i <= len(s)-len(substr); i++ {
|
||||
if s[i:i+len(substr)] == substr {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user