feat: add hm-options package for Home Manager options
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>
This commit is contained in:
256
internal/homemanager/indexer_test.go
Normal file
256
internal/homemanager/indexer_test.go
Normal file
@@ -0,0 +1,256 @@
|
||||
package homemanager
|
||||
|
||||
import (
|
||||
"context"
|
||||
"os/exec"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.t-juice.club/torjus/labmcp/internal/database"
|
||||
)
|
||||
|
||||
// TestHomeManagerRevision is a known release branch for testing.
|
||||
const TestHomeManagerRevision = "release-24.11"
|
||||
|
||||
// TestValidateRevision tests the revision validation function.
|
||||
func TestValidateRevision(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
revision string
|
||||
wantErr bool
|
||||
}{
|
||||
// Valid cases
|
||||
{"valid git hash", "abc123def456abc123def456abc123def456abc1", false},
|
||||
{"valid short hash", "abc123d", false},
|
||||
{"valid channel name", "hm-unstable", false},
|
||||
{"valid release", "release-24.11", false},
|
||||
{"valid master", "master", 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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestResolveRevision tests channel alias resolution.
|
||||
func TestResolveRevision(t *testing.T) {
|
||||
store, err := database.NewSQLiteStore(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create store: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
indexer := NewIndexer(store)
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"hm-unstable", "master"},
|
||||
{"hm-stable", "release-24.11"},
|
||||
{"master", "master"},
|
||||
{"release-24.11", "release-24.11"},
|
||||
{"release-24.05", "release-24.05"},
|
||||
{"release-23.11", "release-23.11"},
|
||||
{"abc123def", "abc123def"}, // Git hash passes through
|
||||
{"unknown-channel", "unknown-channel"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
result := indexer.ResolveRevision(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("ResolveRevision(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestGetChannelName tests channel name lookup.
|
||||
func TestGetChannelName(t *testing.T) {
|
||||
store, err := database.NewSQLiteStore(":memory:")
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create store: %v", err)
|
||||
}
|
||||
defer store.Close()
|
||||
|
||||
indexer := NewIndexer(store)
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
expected string
|
||||
}{
|
||||
{"hm-unstable", "hm-unstable"},
|
||||
{"hm-stable", "hm-stable"},
|
||||
{"master", "master"}, // "master" is both an alias and a ref
|
||||
{"release-24.11", "release-24.11"},
|
||||
{"abc123def", ""}, // Git hash has no channel name
|
||||
{"unknown", ""},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
result := indexer.GetChannelName(tt.input)
|
||||
if result != tt.expected {
|
||||
t.Errorf("GetChannelName(%q) = %q, want %q", tt.input, result, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// BenchmarkIndexRevision benchmarks indexing a full home-manager revision.
|
||||
// This is a slow benchmark that requires nix to be installed.
|
||||
// Run with: go test -bench=BenchmarkIndexRevision -benchtime=1x -timeout=30m ./internal/homemanager/...
|
||||
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, TestHomeManagerRevision); rev != nil {
|
||||
store.DeleteRevision(ctx, rev.ID)
|
||||
}
|
||||
|
||||
result, err := indexer.IndexRevision(ctx, TestHomeManagerRevision)
|
||||
if err != nil {
|
||||
b.Fatalf("IndexRevision failed: %v", err)
|
||||
}
|
||||
|
||||
b.ReportMetric(float64(result.OptionCount), "options")
|
||||
b.ReportMetric(float64(result.Duration.(time.Duration).Milliseconds()), "ms")
|
||||
}
|
||||
}
|
||||
|
||||
// TestIndexRevision is an integration test for the indexer.
|
||||
// Run with: go test -run=TestIndexRevision -timeout=30m ./internal/homemanager/...
|
||||
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 home-manager revision %s...", TestHomeManagerRevision)
|
||||
result, err := indexer.IndexRevision(ctx, TestHomeManagerRevision)
|
||||
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 (Home Manager has hundreds)
|
||||
if result.OptionCount < 100 {
|
||||
t.Errorf("Expected at least 100 options, got %d", result.OptionCount)
|
||||
}
|
||||
|
||||
// Verify revision was stored
|
||||
rev, err := store.GetRevision(ctx, TestHomeManagerRevision)
|
||||
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 for git options (programs.git is a common HM option)
|
||||
options, err := store.SearchOptions(ctx, rev.ID, "git", database.SearchFilters{Limit: 10})
|
||||
if err != nil {
|
||||
t.Fatalf("SearchOptions failed: %v", err)
|
||||
}
|
||||
if len(options) == 0 {
|
||||
t.Error("Expected to find git options")
|
||||
}
|
||||
t.Logf("Found %d git options", len(options))
|
||||
|
||||
// Test getting a specific option
|
||||
opt, err := store.GetOption(ctx, rev.ID, "programs.git.enable")
|
||||
if err != nil {
|
||||
t.Fatalf("GetOption failed: %v", err)
|
||||
}
|
||||
if opt == nil {
|
||||
t.Error("programs.git.enable not found")
|
||||
} else {
|
||||
t.Logf("programs.git.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, "programs.git")
|
||||
if err != nil {
|
||||
t.Fatalf("GetChildren failed: %v", err)
|
||||
}
|
||||
if len(children) == 0 {
|
||||
t.Error("Expected programs.git to have children")
|
||||
}
|
||||
t.Logf("programs.git has %d direct children", len(children))
|
||||
}
|
||||
Reference in New Issue
Block a user