Add a new MCP server for Home Manager options, mirroring the functionality of nixos-options but targeting the home-manager repository. Changes: - Add shared options.Indexer interface for both implementations - Add internal/homemanager package with indexer and channel aliases - Add cmd/hm-options CLI entry point - Parameterize MCP server with ServerConfig for name/instructions - Parameterize nix/package.nix for building both packages - Add hm-options package and NixOS module to flake.nix - Add nix/hm-options-module.nix for systemd deployment Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
282 lines
8.7 KiB
Go
282 lines
8.7 KiB
Go
package nixos
|
|
|
|
import (
|
|
"context"
|
|
"os/exec"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.t-juice.club/torjus/labmcp/internal/database"
|
|
)
|
|
|
|
// TestNixpkgsRevision is the revision from flake.lock used for testing.
|
|
const TestNixpkgsRevision = "e6eae2ee2110f3d31110d5c222cd395303343b08"
|
|
|
|
// TestValidateRevision tests the revision validation function.
|
|
func TestValidateRevision(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
revision string
|
|
wantErr bool
|
|
}{
|
|
// Valid cases
|
|
{"valid git hash", "e6eae2ee2110f3d31110d5c222cd395303343b08", false},
|
|
{"valid short hash", "e6eae2e", false},
|
|
{"valid channel name", "nixos-unstable", false},
|
|
{"valid channel with version", "nixos-24.11", false},
|
|
{"valid underscore", "some_branch", false},
|
|
{"valid mixed", "release-24.05_beta", false},
|
|
|
|
// Invalid cases - injection attempts
|
|
{"injection semicolon", "foo; rm -rf /", true},
|
|
{"injection quotes", `"; builtins.readFile /etc/passwd; "`, true},
|
|
{"injection backticks", "foo`whoami`", true},
|
|
{"injection dollar", "foo$(whoami)", true},
|
|
{"injection newline", "foo\nbar", true},
|
|
{"injection space", "foo bar", true},
|
|
{"injection slash", "foo/bar", true},
|
|
{"injection backslash", "foo\\bar", true},
|
|
{"injection pipe", "foo|bar", true},
|
|
{"injection ampersand", "foo&bar", true},
|
|
{"injection redirect", "foo>bar", true},
|
|
{"injection less than", "foo<bar", true},
|
|
{"injection curly braces", "foo{bar}", true},
|
|
{"injection parens", "foo(bar)", true},
|
|
{"injection brackets", "foo[bar]", true},
|
|
|
|
// Edge cases
|
|
{"empty string", "", true},
|
|
{"too long", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true},
|
|
{"just dots", "...", false}, // dots are allowed, path traversal is handled elsewhere
|
|
{"single char", "a", false},
|
|
{"max length 64", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", false},
|
|
{"65 chars", "aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa", true},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
err := ValidateRevision(tt.revision)
|
|
if (err != nil) != tt.wantErr {
|
|
t.Errorf("ValidateRevision(%q) error = %v, wantErr %v", tt.revision, err, tt.wantErr)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// BenchmarkIndexRevision benchmarks indexing a full nixpkgs revision.
|
|
// This is a slow benchmark that requires nix to be installed.
|
|
// Run with: go test -bench=BenchmarkIndexRevision -benchtime=1x -timeout=30m ./internal/nixos/...
|
|
func BenchmarkIndexRevision(b *testing.B) {
|
|
// Check if nix-build is available
|
|
if _, err := exec.LookPath("nix-build"); err != nil {
|
|
b.Skip("nix-build not found, skipping indexer benchmark")
|
|
}
|
|
|
|
// Use in-memory SQLite for the benchmark
|
|
store, err := database.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 store: %v", err)
|
|
}
|
|
|
|
indexer := NewIndexer(store)
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
// Delete any existing revision first (for repeated runs)
|
|
if rev, _ := store.GetRevision(ctx, TestNixpkgsRevision); rev != nil {
|
|
store.DeleteRevision(ctx, rev.ID)
|
|
}
|
|
|
|
result, err := indexer.IndexRevision(ctx, TestNixpkgsRevision)
|
|
if err != nil {
|
|
b.Fatalf("IndexRevision failed: %v", err)
|
|
}
|
|
|
|
b.ReportMetric(float64(result.OptionCount), "options")
|
|
if dur, ok := result.Duration.(time.Duration); ok {
|
|
b.ReportMetric(float64(dur.Milliseconds()), "ms")
|
|
}
|
|
}
|
|
}
|
|
|
|
// BenchmarkIndexRevisionWithFiles benchmarks indexing with file content storage.
|
|
// This downloads the full nixpkgs tarball and stores allowed file types.
|
|
// Run with: go test -bench=BenchmarkIndexRevisionWithFiles -benchtime=1x -timeout=60m ./internal/nixos/...
|
|
func BenchmarkIndexRevisionWithFiles(b *testing.B) {
|
|
// Check if nix-build is available
|
|
if _, err := exec.LookPath("nix-build"); err != nil {
|
|
b.Skip("nix-build not found, skipping indexer benchmark")
|
|
}
|
|
|
|
// Use in-memory SQLite for the benchmark
|
|
store, err := database.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 store: %v", err)
|
|
}
|
|
|
|
indexer := NewIndexer(store)
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
// Delete any existing revision first
|
|
if rev, _ := store.GetRevision(ctx, TestNixpkgsRevision); rev != nil {
|
|
store.DeleteRevision(ctx, rev.ID)
|
|
}
|
|
|
|
result, err := indexer.IndexRevision(ctx, TestNixpkgsRevision)
|
|
if err != nil {
|
|
b.Fatalf("IndexRevision failed: %v", err)
|
|
}
|
|
|
|
fileStart := time.Now()
|
|
fileCount, err := indexer.IndexFiles(ctx, result.Revision.ID, TestNixpkgsRevision)
|
|
if err != nil {
|
|
b.Fatalf("IndexFiles failed: %v", err)
|
|
}
|
|
fileDuration := time.Since(fileStart)
|
|
|
|
b.ReportMetric(float64(result.OptionCount), "options")
|
|
if dur, ok := result.Duration.(time.Duration); ok {
|
|
b.ReportMetric(float64(dur.Milliseconds()), "options_ms")
|
|
}
|
|
b.ReportMetric(float64(fileCount), "files")
|
|
b.ReportMetric(float64(fileDuration.Milliseconds()), "files_ms")
|
|
}
|
|
}
|
|
|
|
// BenchmarkIndexFilesOnly benchmarks only the file indexing phase.
|
|
// Assumes options are already indexed. Useful for measuring file indexing in isolation.
|
|
// Run with: go test -bench=BenchmarkIndexFilesOnly -benchtime=1x -timeout=60m ./internal/nixos/...
|
|
func BenchmarkIndexFilesOnly(b *testing.B) {
|
|
if _, err := exec.LookPath("nix-build"); err != nil {
|
|
b.Skip("nix-build not found, skipping indexer benchmark")
|
|
}
|
|
|
|
store, err := database.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 store: %v", err)
|
|
}
|
|
|
|
indexer := NewIndexer(store)
|
|
|
|
// Index options first (outside of benchmark timing)
|
|
result, err := indexer.IndexRevision(ctx, TestNixpkgsRevision)
|
|
if err != nil {
|
|
b.Fatalf("IndexRevision failed: %v", err)
|
|
}
|
|
b.Logf("Pre-indexed %d options", result.OptionCount)
|
|
|
|
b.ResetTimer()
|
|
for i := 0; i < b.N; i++ {
|
|
fileCount, err := indexer.IndexFiles(ctx, result.Revision.ID, TestNixpkgsRevision)
|
|
if err != nil {
|
|
b.Fatalf("IndexFiles failed: %v", err)
|
|
}
|
|
|
|
b.ReportMetric(float64(fileCount), "files")
|
|
}
|
|
}
|
|
|
|
// TestIndexRevision is an integration test for the indexer.
|
|
// Run with: go test -run=TestIndexRevision -timeout=30m ./internal/nixos/...
|
|
func TestIndexRevision(t *testing.T) {
|
|
if testing.Short() {
|
|
t.Skip("Skipping integration test in short mode")
|
|
}
|
|
|
|
// Check if nix-build is available
|
|
if _, err := exec.LookPath("nix-build"); err != nil {
|
|
t.Skip("nix-build not found, skipping indexer test")
|
|
}
|
|
|
|
store, err := database.NewSQLiteStore(":memory:")
|
|
if err != nil {
|
|
t.Fatalf("Failed to create store: %v", err)
|
|
}
|
|
defer store.Close()
|
|
|
|
ctx := context.Background()
|
|
if err := store.Initialize(ctx); err != nil {
|
|
t.Fatalf("Failed to initialize store: %v", err)
|
|
}
|
|
|
|
indexer := NewIndexer(store)
|
|
|
|
t.Logf("Indexing nixpkgs revision %s...", TestNixpkgsRevision[:12])
|
|
result, err := indexer.IndexRevision(ctx, TestNixpkgsRevision)
|
|
if err != nil {
|
|
t.Fatalf("IndexRevision failed: %v", err)
|
|
}
|
|
|
|
t.Logf("Indexed %d options in %s", result.OptionCount, result.Duration)
|
|
|
|
// Verify we got a reasonable number of options (NixOS has thousands)
|
|
if result.OptionCount < 1000 {
|
|
t.Errorf("Expected at least 1000 options, got %d", result.OptionCount)
|
|
}
|
|
|
|
// Verify revision was stored
|
|
rev, err := store.GetRevision(ctx, TestNixpkgsRevision)
|
|
if err != nil {
|
|
t.Fatalf("GetRevision failed: %v", err)
|
|
}
|
|
if rev == nil {
|
|
t.Fatal("Revision not found after indexing")
|
|
}
|
|
if rev.OptionCount != result.OptionCount {
|
|
t.Errorf("Stored option count %d != result count %d", rev.OptionCount, result.OptionCount)
|
|
}
|
|
|
|
// Test searching
|
|
options, err := store.SearchOptions(ctx, rev.ID, "nginx", database.SearchFilters{Limit: 10})
|
|
if err != nil {
|
|
t.Fatalf("SearchOptions failed: %v", err)
|
|
}
|
|
if len(options) == 0 {
|
|
t.Error("Expected to find nginx options")
|
|
}
|
|
t.Logf("Found %d nginx options", len(options))
|
|
|
|
// Test getting a specific option
|
|
opt, err := store.GetOption(ctx, rev.ID, "services.nginx.enable")
|
|
if err != nil {
|
|
t.Fatalf("GetOption failed: %v", err)
|
|
}
|
|
if opt == nil {
|
|
t.Error("services.nginx.enable not found")
|
|
} else {
|
|
t.Logf("services.nginx.enable: type=%s", opt.Type)
|
|
if opt.Type != "boolean" {
|
|
t.Errorf("Expected type 'boolean', got %q", opt.Type)
|
|
}
|
|
}
|
|
|
|
// Test getting children
|
|
children, err := store.GetChildren(ctx, rev.ID, "services.nginx")
|
|
if err != nil {
|
|
t.Fatalf("GetChildren failed: %v", err)
|
|
}
|
|
if len(children) == 0 {
|
|
t.Error("Expected services.nginx to have children")
|
|
}
|
|
t.Logf("services.nginx has %d direct children", len(children))
|
|
}
|