diff --git a/flake.lock b/flake.lock new file mode 100644 index 0000000..3fce2c3 --- /dev/null +++ b/flake.lock @@ -0,0 +1,27 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1770115704, + "narHash": "sha256-KHFT9UWOF2yRPlAnSXQJh6uVcgNcWlFqqiAZ7OVlHNc=", + "owner": "nixos", + "repo": "nixpkgs", + "rev": "e6eae2ee2110f3d31110d5c222cd395303343b08", + "type": "github" + }, + "original": { + "owner": "nixos", + "ref": "nixos-unstable", + "repo": "nixpkgs", + "type": "github" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix index d641351..5fb6946 100644 --- a/flake.nix +++ b/flake.nix @@ -22,7 +22,7 @@ version = "0.1.0"; src = ./.; - vendorHash = null; # Will be set after first build + vendorHash = "sha256-D0KIxQC9ctIAaHBFTvkhBE06uOZwDUcIw8471Ug2doY="; subPackages = [ "cmd/nixos-options" ]; diff --git a/internal/database/benchmark_test.go b/internal/database/benchmark_test.go new file mode 100644 index 0000000..b601e07 --- /dev/null +++ b/internal/database/benchmark_test.go @@ -0,0 +1,246 @@ +package database + +import ( + "context" + "fmt" + "testing" + "time" +) + +func BenchmarkCreateOptions(b *testing.B) { + store, err := NewSQLiteStore(":memory:") + if err != nil { + b.Fatalf("Failed to create store: %v", err) + } + defer store.Close() + + ctx := context.Background() + if err := store.Initialize(ctx); err != nil { + b.Fatalf("Failed to initialize: %v", err) + } + + rev := &Revision{GitHash: "bench123", ChannelName: "bench"} + if err := store.CreateRevision(ctx, rev); err != nil { + b.Fatalf("Failed to create revision: %v", err) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + opt := &Option{ + RevisionID: rev.ID, + Name: fmt.Sprintf("services.test%d.enable", i), + ParentPath: fmt.Sprintf("services.test%d", i), + Type: "boolean", + DefaultValue: "false", + Description: "Test option", + } + if err := store.CreateOption(ctx, opt); err != nil { + b.Fatalf("Failed to create option: %v", err) + } + } +} + +func BenchmarkCreateOptionsBatch(b *testing.B) { + benchmarkBatch(b, 100) +} + +func BenchmarkCreateOptionsBatch1000(b *testing.B) { + benchmarkBatch(b, 1000) +} + +func benchmarkBatch(b *testing.B, batchSize int) { + store, err := NewSQLiteStore(":memory:") + if err != nil { + b.Fatalf("Failed to create store: %v", err) + } + defer store.Close() + + ctx := context.Background() + if err := store.Initialize(ctx); err != nil { + b.Fatalf("Failed to initialize: %v", err) + } + + rev := &Revision{GitHash: "batchbench", ChannelName: "bench"} + if err := store.CreateRevision(ctx, rev); err != nil { + b.Fatalf("Failed to create revision: %v", err) + } + + opts := make([]*Option, batchSize) + for i := 0; i < batchSize; i++ { + opts[i] = &Option{ + RevisionID: rev.ID, + Name: fmt.Sprintf("services.batch%d.enable", i), + ParentPath: fmt.Sprintf("services.batch%d", i), + Type: "boolean", + DefaultValue: "false", + Description: "Batch test option", + } + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + // Reset IDs for next iteration + for _, opt := range opts { + opt.ID = 0 + } + if err := store.CreateOptionsBatch(ctx, opts); err != nil { + b.Fatalf("Failed to create batch: %v", err) + } + + // Clean up for next iteration + store.DeleteRevision(ctx, rev.ID) + rev = &Revision{GitHash: fmt.Sprintf("batchbench%d", i), ChannelName: "bench"} + store.CreateRevision(ctx, rev) + for _, opt := range opts { + opt.RevisionID = rev.ID + } + } +} + +func BenchmarkSearchOptions(b *testing.B) { + store, err := NewSQLiteStore(":memory:") + if err != nil { + b.Fatalf("Failed to create store: %v", err) + } + defer store.Close() + + ctx := context.Background() + if err := store.Initialize(ctx); err != nil { + b.Fatalf("Failed to initialize: %v", err) + } + + rev := &Revision{GitHash: "searchbench", ChannelName: "bench"} + if err := store.CreateRevision(ctx, rev); err != nil { + b.Fatalf("Failed to create revision: %v", err) + } + + // Create 1000 options to search through + opts := make([]*Option, 1000) + for i := 0; i < 1000; i++ { + opts[i] = &Option{ + RevisionID: rev.ID, + Name: fmt.Sprintf("services.service%d.enable", i), + ParentPath: fmt.Sprintf("services.service%d", i), + Type: "boolean", + DefaultValue: "false", + Description: fmt.Sprintf("Enable service %d for testing purposes", i), + } + } + if err := store.CreateOptionsBatch(ctx, opts); err != nil { + b.Fatalf("Failed to create options: %v", err) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := store.SearchOptions(ctx, rev.ID, "enable service", SearchFilters{Limit: 50}) + if err != nil { + b.Fatalf("Search failed: %v", err) + } + } +} + +func BenchmarkGetChildren(b *testing.B) { + store, err := NewSQLiteStore(":memory:") + if err != nil { + b.Fatalf("Failed to create store: %v", err) + } + defer store.Close() + + ctx := context.Background() + if err := store.Initialize(ctx); err != nil { + b.Fatalf("Failed to initialize: %v", err) + } + + rev := &Revision{GitHash: "childrenbench", ChannelName: "bench"} + if err := store.CreateRevision(ctx, rev); err != nil { + b.Fatalf("Failed to create revision: %v", err) + } + + // Create parent and 100 children + opts := make([]*Option, 101) + opts[0] = &Option{ + RevisionID: rev.ID, + Name: "services", + ParentPath: "", + Type: "attrsOf", + } + for i := 1; i <= 100; i++ { + opts[i] = &Option{ + RevisionID: rev.ID, + Name: fmt.Sprintf("services.service%d", i), + ParentPath: "services", + Type: "submodule", + } + } + if err := store.CreateOptionsBatch(ctx, opts); err != nil { + b.Fatalf("Failed to create options: %v", err) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + _, err := store.GetChildren(ctx, rev.ID, "services") + if err != nil { + b.Fatalf("GetChildren failed: %v", err) + } + } +} + +func BenchmarkSchemaInitialize(b *testing.B) { + for i := 0; i < b.N; i++ { + store, err := NewSQLiteStore(":memory:") + if err != nil { + b.Fatalf("Failed to create store: %v", err) + } + + ctx := context.Background() + if err := store.Initialize(ctx); err != nil { + b.Fatalf("Failed to initialize: %v", err) + } + + store.Close() + } +} + +// BenchmarkRevisionCRUD benchmarks the full CRUD cycle for revisions. +func BenchmarkRevisionCRUD(b *testing.B) { + store, err := NewSQLiteStore(":memory:") + if err != nil { + b.Fatalf("Failed to create store: %v", err) + } + defer store.Close() + + ctx := context.Background() + if err := store.Initialize(ctx); err != nil { + b.Fatalf("Failed to initialize: %v", err) + } + + b.ResetTimer() + for i := 0; i < b.N; i++ { + rev := &Revision{ + GitHash: fmt.Sprintf("crud%d", i), + ChannelName: "test", + CommitDate: time.Now(), + } + + // Create + if err := store.CreateRevision(ctx, rev); err != nil { + b.Fatalf("Create failed: %v", err) + } + + // Read + _, err := store.GetRevision(ctx, rev.GitHash) + if err != nil { + b.Fatalf("Get failed: %v", err) + } + + // Update + if err := store.UpdateRevisionOptionCount(ctx, rev.ID, 100); err != nil { + b.Fatalf("Update failed: %v", err) + } + + // Delete + if err := store.DeleteRevision(ctx, rev.ID); err != nil { + b.Fatalf("Delete failed: %v", err) + } + } +} diff --git a/internal/mcp/server_test.go b/internal/mcp/server_test.go new file mode 100644 index 0000000..7a49d2a --- /dev/null +++ b/internal/mcp/server_test.go @@ -0,0 +1,190 @@ +package mcp + +import ( + "bytes" + "context" + "encoding/json" + "io" + "strings" + "testing" + + "git.t-juice.club/torjus/labmcp/internal/database" +) + +func TestServerInitialize(t *testing.T) { + store := setupTestStore(t) + server := NewServer(store, nil) + + input := `{"jsonrpc":"2.0","id":1,"method":"initialize","params":{"protocolVersion":"2024-11-05","capabilities":{},"clientInfo":{"name":"test","version":"1.0"}}}` + + resp := runRequest(t, server, input) + + if resp.Error != nil { + t.Fatalf("Unexpected error: %v", resp.Error) + } + + result, ok := resp.Result.(map[string]interface{}) + if !ok { + t.Fatalf("Expected map result, got %T", resp.Result) + } + + if result["protocolVersion"] != ProtocolVersion { + t.Errorf("protocolVersion = %v, want %v", result["protocolVersion"], ProtocolVersion) + } + + serverInfo := result["serverInfo"].(map[string]interface{}) + if serverInfo["name"] != "nixos-options" { + t.Errorf("serverInfo.name = %v, want nixos-options", serverInfo["name"]) + } +} + +func TestServerToolsList(t *testing.T) { + store := setupTestStore(t) + server := NewServer(store, nil) + + input := `{"jsonrpc":"2.0","id":1,"method":"tools/list"}` + + resp := runRequest(t, server, input) + + if resp.Error != nil { + t.Fatalf("Unexpected error: %v", resp.Error) + } + + result, ok := resp.Result.(map[string]interface{}) + if !ok { + t.Fatalf("Expected map result, got %T", resp.Result) + } + + tools, ok := result["tools"].([]interface{}) + if !ok { + t.Fatalf("Expected tools array, got %T", result["tools"]) + } + + // Should have 6 tools + if len(tools) != 6 { + t.Errorf("Expected 6 tools, got %d", len(tools)) + } + + // Check tool names + expectedTools := map[string]bool{ + "search_options": false, + "get_option": false, + "get_file": false, + "index_revision": false, + "list_revisions": false, + "delete_revision": false, + } + + for _, tool := range tools { + toolMap := tool.(map[string]interface{}) + name := toolMap["name"].(string) + if _, ok := expectedTools[name]; ok { + expectedTools[name] = true + } + } + + for name, found := range expectedTools { + if !found { + t.Errorf("Tool %q not found in tools list", name) + } + } +} + +func TestServerMethodNotFound(t *testing.T) { + store := setupTestStore(t) + server := NewServer(store, nil) + + input := `{"jsonrpc":"2.0","id":1,"method":"unknown/method"}` + + resp := runRequest(t, server, input) + + if resp.Error == nil { + t.Fatal("Expected error for unknown method") + } + + if resp.Error.Code != MethodNotFound { + t.Errorf("Error code = %d, want %d", resp.Error.Code, MethodNotFound) + } +} + +func TestServerParseError(t *testing.T) { + store := setupTestStore(t) + server := NewServer(store, nil) + + input := `not valid json` + + resp := runRequest(t, server, input) + + if resp.Error == nil { + t.Fatal("Expected parse error") + } + + if resp.Error.Code != ParseError { + t.Errorf("Error code = %d, want %d", resp.Error.Code, ParseError) + } +} + +func TestServerNotification(t *testing.T) { + store := setupTestStore(t) + server := NewServer(store, nil) + + // Notification (no response expected) + input := `{"jsonrpc":"2.0","method":"notifications/initialized"}` + + var output bytes.Buffer + ctx := context.Background() + err := server.Run(ctx, strings.NewReader(input+"\n"), &output) + if err != nil && err != io.EOF { + t.Fatalf("Run failed: %v", err) + } + + // Should not produce any output for notifications + if output.Len() > 0 { + t.Errorf("Expected no output for notification, got: %s", output.String()) + } +} + +// Helper functions + +func setupTestStore(t *testing.T) database.Store { + t.Helper() + + store, err := database.NewSQLiteStore(":memory:") + if err != nil { + t.Fatalf("Failed to create store: %v", err) + } + + if err := store.Initialize(context.Background()); err != nil { + t.Fatalf("Failed to initialize store: %v", err) + } + + t.Cleanup(func() { + store.Close() + }) + + return store +} + +func runRequest(t *testing.T, server *Server, input string) *Response { + t.Helper() + + var output bytes.Buffer + ctx := context.Background() + + // Run with input terminated by newline + err := server.Run(ctx, strings.NewReader(input+"\n"), &output) + if err != nil && err != io.EOF { + t.Fatalf("Run failed: %v", err) + } + + if output.Len() == 0 { + t.Fatal("Expected response, got empty output") + } + + var resp Response + if err := json.Unmarshal(output.Bytes(), &resp); err != nil { + t.Fatalf("Failed to parse response: %v\nOutput: %s", err, output.String()) + } + + return &resp +} diff --git a/testdata/options-sample.json b/testdata/options-sample.json new file mode 100644 index 0000000..5f6e459 --- /dev/null +++ b/testdata/options-sample.json @@ -0,0 +1,107 @@ +{ + "services.nginx.enable": { + "declarations": ["/nix/store/xxx-source/nixos/modules/services/web-servers/nginx/default.nix"], + "default": false, + "description": "Whether to enable Nginx Web Server.", + "readOnly": false, + "type": "boolean" + }, + "services.nginx.package": { + "declarations": ["/nix/store/xxx-source/nixos/modules/services/web-servers/nginx/default.nix"], + "default": {}, + "description": {"_type": "mdDoc", "text": "The nginx package to use."}, + "readOnly": false, + "type": "package" + }, + "services.nginx.user": { + "declarations": ["/nix/store/xxx-source/nixos/modules/services/web-servers/nginx/default.nix"], + "default": "nginx", + "description": "User account under which nginx runs.", + "readOnly": false, + "type": "string" + }, + "services.nginx.group": { + "declarations": ["/nix/store/xxx-source/nixos/modules/services/web-servers/nginx/default.nix"], + "default": "nginx", + "description": "Group account under which nginx runs.", + "readOnly": false, + "type": "string" + }, + "services.nginx.virtualHosts": { + "declarations": ["/nix/store/xxx-source/nixos/modules/services/web-servers/nginx/default.nix"], + "default": {}, + "description": "Declarative vhost configuration.", + "readOnly": false, + "type": "attrsOf (submodule)" + }, + "services.caddy.enable": { + "declarations": ["/nix/store/xxx-source/nixos/modules/services/web-servers/caddy/default.nix"], + "default": false, + "description": "Enable the Caddy web server.", + "readOnly": false, + "type": "boolean" + }, + "services.caddy.package": { + "declarations": ["/nix/store/xxx-source/nixos/modules/services/web-servers/caddy/default.nix"], + "default": {}, + "description": "Caddy package to use.", + "readOnly": false, + "type": "package" + }, + "programs.git.enable": { + "declarations": ["/nix/store/xxx-source/nixos/modules/programs/git.nix"], + "default": false, + "description": "Whether to enable git.", + "readOnly": false, + "type": "boolean" + }, + "programs.git.package": { + "declarations": ["/nix/store/xxx-source/nixos/modules/programs/git.nix"], + "default": {}, + "description": "The git package to use.", + "readOnly": false, + "type": "package" + }, + "programs.git.config": { + "declarations": ["/nix/store/xxx-source/nixos/modules/programs/git.nix"], + "default": {}, + "description": "Git configuration options.", + "readOnly": false, + "type": "attrsOf (attrsOf anything)" + }, + "networking.firewall.enable": { + "declarations": ["/nix/store/xxx-source/nixos/modules/services/networking/firewall.nix"], + "default": true, + "description": "Whether to enable the firewall.", + "readOnly": false, + "type": "boolean" + }, + "networking.firewall.allowedTCPPorts": { + "declarations": ["/nix/store/xxx-source/nixos/modules/services/networking/firewall.nix"], + "default": [], + "description": "List of TCP ports to allow through the firewall.", + "readOnly": false, + "type": "listOf port" + }, + "networking.firewall.allowedUDPPorts": { + "declarations": ["/nix/store/xxx-source/nixos/modules/services/networking/firewall.nix"], + "default": [], + "description": "List of UDP ports to allow through the firewall.", + "readOnly": false, + "type": "listOf port" + }, + "boot.loader.grub.enable": { + "declarations": ["/nix/store/xxx-source/nixos/modules/system/boot/loader/grub/grub.nix"], + "default": true, + "description": "Whether to enable GRUB.", + "readOnly": false, + "type": "boolean" + }, + "boot.loader.grub.device": { + "declarations": ["/nix/store/xxx-source/nixos/modules/system/boot/loader/grub/grub.nix"], + "default": "", + "description": "The device where GRUB will be installed.", + "readOnly": false, + "type": "string" + } +}