This repository has been archived on 2026-03-10. You can view files and clone it. You cannot open issues or pull requests or push a commit.
Files
labmcp/internal/homemanager/indexer_test.go
Torjus Håkestad ea2d73d746 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>
2026-02-03 22:51:30 +01:00

257 lines
7.5 KiB
Go

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))
}