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:
428
internal/homemanager/indexer.go
Normal file
428
internal/homemanager/indexer.go
Normal file
@@ -0,0 +1,428 @@
|
||||
package homemanager
|
||||
|
||||
import (
|
||||
"archive/tar"
|
||||
"compress/gzip"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"git.t-juice.club/torjus/labmcp/internal/database"
|
||||
"git.t-juice.club/torjus/labmcp/internal/nixos"
|
||||
"git.t-juice.club/torjus/labmcp/internal/options"
|
||||
)
|
||||
|
||||
// revisionPattern validates revision strings to prevent injection attacks.
|
||||
// Allows: alphanumeric, hyphens, underscores, dots (for channel names like "release-24.11"
|
||||
// and git hashes). Must be 1-64 characters.
|
||||
var revisionPattern = regexp.MustCompile(`^[a-zA-Z0-9._-]{1,64}$`)
|
||||
|
||||
// Indexer handles indexing of home-manager revisions.
|
||||
type Indexer struct {
|
||||
store database.Store
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewIndexer creates a new Home Manager indexer.
|
||||
func NewIndexer(store database.Store) *Indexer {
|
||||
return &Indexer{
|
||||
store: store,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 5 * time.Minute,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// IndexResult contains the results of an indexing operation.
|
||||
type IndexResult struct {
|
||||
Revision *database.Revision
|
||||
OptionCount int
|
||||
FileCount int
|
||||
Duration time.Duration
|
||||
AlreadyIndexed bool // True if revision was already indexed (skipped)
|
||||
}
|
||||
|
||||
// ValidateRevision checks if a revision string is safe to use.
|
||||
// Returns an error if the revision contains potentially dangerous characters.
|
||||
func ValidateRevision(revision string) error {
|
||||
if !revisionPattern.MatchString(revision) {
|
||||
return fmt.Errorf("invalid revision format: must be 1-64 alphanumeric characters, hyphens, underscores, or dots")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// IndexRevision indexes a home-manager revision by git hash or channel name.
|
||||
func (idx *Indexer) IndexRevision(ctx context.Context, revision string) (*options.IndexResult, error) {
|
||||
start := time.Now()
|
||||
|
||||
// Validate revision to prevent injection attacks
|
||||
if err := ValidateRevision(revision); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// Resolve channel names to git refs
|
||||
ref := idx.ResolveRevision(revision)
|
||||
|
||||
// Check if already indexed
|
||||
existing, err := idx.store.GetRevision(ctx, ref)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check existing revision: %w", err)
|
||||
}
|
||||
if existing != nil {
|
||||
return &options.IndexResult{
|
||||
Revision: existing,
|
||||
OptionCount: existing.OptionCount,
|
||||
Duration: time.Since(start),
|
||||
AlreadyIndexed: true,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// Build options.json using nix
|
||||
optionsPath, cleanup, err := idx.buildOptions(ctx, ref)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to build options: %w", err)
|
||||
}
|
||||
defer cleanup()
|
||||
|
||||
// Parse options.json (reuse nixos parser - same format)
|
||||
optionsFile, err := os.Open(optionsPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to open options.json: %w", err)
|
||||
}
|
||||
defer optionsFile.Close()
|
||||
|
||||
opts, err := nixos.ParseOptions(optionsFile)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse options: %w", err)
|
||||
}
|
||||
|
||||
// Get commit info
|
||||
commitDate, err := idx.getCommitDate(ctx, ref)
|
||||
if err != nil {
|
||||
// Non-fatal, use current time
|
||||
commitDate = time.Now()
|
||||
}
|
||||
|
||||
// Create revision record
|
||||
rev := &database.Revision{
|
||||
GitHash: ref,
|
||||
ChannelName: idx.GetChannelName(revision),
|
||||
CommitDate: commitDate,
|
||||
OptionCount: len(opts),
|
||||
}
|
||||
if err := idx.store.CreateRevision(ctx, rev); err != nil {
|
||||
return nil, fmt.Errorf("failed to create revision: %w", err)
|
||||
}
|
||||
|
||||
// Store options
|
||||
if err := idx.storeOptions(ctx, rev.ID, opts); err != nil {
|
||||
// Cleanup on failure
|
||||
idx.store.DeleteRevision(ctx, rev.ID)
|
||||
return nil, fmt.Errorf("failed to store options: %w", err)
|
||||
}
|
||||
|
||||
return &options.IndexResult{
|
||||
Revision: rev,
|
||||
OptionCount: len(opts),
|
||||
Duration: time.Since(start),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ReindexRevision forces re-indexing of a revision, deleting existing data first.
|
||||
func (idx *Indexer) ReindexRevision(ctx context.Context, revision string) (*options.IndexResult, error) {
|
||||
// Validate revision to prevent injection attacks
|
||||
if err := ValidateRevision(revision); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
ref := idx.ResolveRevision(revision)
|
||||
|
||||
// Delete existing revision if present
|
||||
existing, err := idx.store.GetRevision(ctx, ref)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to check existing revision: %w", err)
|
||||
}
|
||||
if existing != nil {
|
||||
if err := idx.store.DeleteRevision(ctx, existing.ID); err != nil {
|
||||
return nil, fmt.Errorf("failed to delete existing revision: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Now index fresh
|
||||
return idx.IndexRevision(ctx, revision)
|
||||
}
|
||||
|
||||
// buildOptions builds options.json for a home-manager revision.
|
||||
func (idx *Indexer) buildOptions(ctx context.Context, ref string) (string, func(), error) {
|
||||
// Create temp directory
|
||||
tmpDir, err := os.MkdirTemp("", "hm-options-*")
|
||||
if err != nil {
|
||||
return "", nil, fmt.Errorf("failed to create temp dir: %w", err)
|
||||
}
|
||||
|
||||
cleanup := func() {
|
||||
os.RemoveAll(tmpDir)
|
||||
}
|
||||
|
||||
// Build options.json using nix-build
|
||||
// This evaluates the Home Manager options from the specified revision
|
||||
nixExpr := fmt.Sprintf(`
|
||||
let
|
||||
hm = builtins.fetchTarball {
|
||||
url = "https://github.com/nix-community/home-manager/archive/%s.tar.gz";
|
||||
};
|
||||
nixpkgs = builtins.fetchTarball {
|
||||
url = "https://github.com/NixOS/nixpkgs/archive/nixos-unstable.tar.gz";
|
||||
};
|
||||
pkgs = import nixpkgs { config = {}; };
|
||||
lib = import (hm + "/modules/lib/stdlib-extended.nix") pkgs.lib;
|
||||
docs = import (hm + "/docs") { inherit pkgs lib; release = "24.11"; isReleaseBranch = false; };
|
||||
in docs.options.json
|
||||
`, ref)
|
||||
|
||||
cmd := exec.CommandContext(ctx, "nix-build", "--no-out-link", "-E", nixExpr)
|
||||
cmd.Dir = tmpDir
|
||||
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
cleanup()
|
||||
if exitErr, ok := err.(*exec.ExitError); ok {
|
||||
return "", nil, fmt.Errorf("nix-build failed: %s", string(exitErr.Stderr))
|
||||
}
|
||||
return "", nil, fmt.Errorf("nix-build failed: %w", err)
|
||||
}
|
||||
|
||||
// The output is the store path containing share/doc/home-manager/options.json
|
||||
storePath := strings.TrimSpace(string(output))
|
||||
optionsPath := filepath.Join(storePath, "share", "doc", "home-manager", "options.json")
|
||||
|
||||
if _, err := os.Stat(optionsPath); err != nil {
|
||||
cleanup()
|
||||
return "", nil, fmt.Errorf("options.json not found at %s", optionsPath)
|
||||
}
|
||||
|
||||
return optionsPath, cleanup, nil
|
||||
}
|
||||
|
||||
// storeOptions stores parsed options in the database.
|
||||
func (idx *Indexer) storeOptions(ctx context.Context, revisionID int64, opts map[string]*nixos.ParsedOption) error {
|
||||
// Prepare batch of options
|
||||
dbOpts := make([]*database.Option, 0, len(opts))
|
||||
declsByName := make(map[string][]*database.Declaration)
|
||||
|
||||
for name, opt := range opts {
|
||||
dbOpt := &database.Option{
|
||||
RevisionID: revisionID,
|
||||
Name: name,
|
||||
ParentPath: database.ParentPath(name),
|
||||
Type: opt.Type,
|
||||
DefaultValue: opt.Default,
|
||||
Example: opt.Example,
|
||||
Description: opt.Description,
|
||||
ReadOnly: opt.ReadOnly,
|
||||
}
|
||||
dbOpts = append(dbOpts, dbOpt)
|
||||
|
||||
// Prepare declarations for this option
|
||||
decls := make([]*database.Declaration, 0, len(opt.Declarations))
|
||||
for _, path := range opt.Declarations {
|
||||
decls = append(decls, &database.Declaration{
|
||||
FilePath: path,
|
||||
})
|
||||
}
|
||||
declsByName[name] = decls
|
||||
}
|
||||
|
||||
// Store options in batches
|
||||
batchSize := 1000
|
||||
for i := 0; i < len(dbOpts); i += batchSize {
|
||||
end := i + batchSize
|
||||
if end > len(dbOpts) {
|
||||
end = len(dbOpts)
|
||||
}
|
||||
batch := dbOpts[i:end]
|
||||
|
||||
if err := idx.store.CreateOptionsBatch(ctx, batch); err != nil {
|
||||
return fmt.Errorf("failed to store options batch: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
// Store declarations
|
||||
for _, opt := range dbOpts {
|
||||
decls := declsByName[opt.Name]
|
||||
for _, decl := range decls {
|
||||
decl.OptionID = opt.ID
|
||||
}
|
||||
if len(decls) > 0 {
|
||||
if err := idx.store.CreateDeclarationsBatch(ctx, decls); err != nil {
|
||||
return fmt.Errorf("failed to store declarations for %s: %w", opt.Name, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// getCommitDate gets the commit date for a git ref.
|
||||
func (idx *Indexer) getCommitDate(ctx context.Context, ref string) (time.Time, error) {
|
||||
// Use GitHub API to get commit info
|
||||
url := fmt.Sprintf("https://api.github.com/repos/nix-community/home-manager/commits/%s", ref)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
req.Header.Set("Accept", "application/vnd.github.v3+json")
|
||||
|
||||
resp, err := idx.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return time.Time{}, fmt.Errorf("GitHub API returned %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
var commit struct {
|
||||
Commit struct {
|
||||
Committer struct {
|
||||
Date time.Time `json:"date"`
|
||||
} `json:"committer"`
|
||||
} `json:"commit"`
|
||||
}
|
||||
|
||||
if err := json.NewDecoder(resp.Body).Decode(&commit); err != nil {
|
||||
return time.Time{}, err
|
||||
}
|
||||
|
||||
return commit.Commit.Committer.Date, nil
|
||||
}
|
||||
|
||||
// ResolveRevision resolves a channel name or ref to a git ref.
|
||||
func (idx *Indexer) ResolveRevision(revision string) string {
|
||||
// Check if it's a known channel alias
|
||||
if ref, ok := ChannelAliases[revision]; ok {
|
||||
return ref
|
||||
}
|
||||
return revision
|
||||
}
|
||||
|
||||
// GetChannelName returns the channel name if the revision matches one.
|
||||
func (idx *Indexer) GetChannelName(revision string) string {
|
||||
if _, ok := ChannelAliases[revision]; ok {
|
||||
return revision
|
||||
}
|
||||
// Check if the revision is a channel ref value
|
||||
for name, ref := range ChannelAliases {
|
||||
if ref == revision {
|
||||
return name
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// IndexFiles indexes files from a home-manager tarball.
|
||||
// This is a separate operation that can be run after IndexRevision.
|
||||
func (idx *Indexer) IndexFiles(ctx context.Context, revisionID int64, ref string) (int, error) {
|
||||
// Download home-manager tarball
|
||||
url := fmt.Sprintf("https://github.com/nix-community/home-manager/archive/%s.tar.gz", ref)
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := idx.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to download tarball: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return 0, fmt.Errorf("download failed with status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
// Extract and index files
|
||||
gz, err := gzip.NewReader(resp.Body)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to create gzip reader: %w", err)
|
||||
}
|
||||
defer gz.Close()
|
||||
|
||||
tr := tar.NewReader(gz)
|
||||
count := 0
|
||||
batch := make([]*database.File, 0, 100)
|
||||
|
||||
for {
|
||||
header, err := tr.Next()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return count, fmt.Errorf("tar read error: %w", err)
|
||||
}
|
||||
|
||||
// Skip directories
|
||||
if header.Typeflag != tar.TypeReg {
|
||||
continue
|
||||
}
|
||||
|
||||
// Check file extension
|
||||
ext := filepath.Ext(header.Name)
|
||||
if !AllowedExtensions[ext] {
|
||||
continue
|
||||
}
|
||||
|
||||
// Skip very large files (> 1MB)
|
||||
if header.Size > 1024*1024 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Remove the top-level directory (home-manager-<hash>/)
|
||||
path := header.Name
|
||||
if i := strings.Index(path, "/"); i >= 0 {
|
||||
path = path[i+1:]
|
||||
}
|
||||
|
||||
// Read content
|
||||
content, err := io.ReadAll(tr)
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
file := &database.File{
|
||||
RevisionID: revisionID,
|
||||
FilePath: path,
|
||||
Extension: ext,
|
||||
Content: string(content),
|
||||
}
|
||||
batch = append(batch, file)
|
||||
count++
|
||||
|
||||
// Store in batches
|
||||
if len(batch) >= 100 {
|
||||
if err := idx.store.CreateFilesBatch(ctx, batch); err != nil {
|
||||
return count, fmt.Errorf("failed to store files batch: %w", err)
|
||||
}
|
||||
batch = batch[:0]
|
||||
}
|
||||
}
|
||||
|
||||
// Store remaining files
|
||||
if len(batch) > 0 {
|
||||
if err := idx.store.CreateFilesBatch(ctx, batch); err != nil {
|
||||
return count, fmt.Errorf("failed to store final files batch: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
return count, nil
|
||||
}
|
||||
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))
|
||||
}
|
||||
24
internal/homemanager/types.go
Normal file
24
internal/homemanager/types.go
Normal file
@@ -0,0 +1,24 @@
|
||||
// Package homemanager contains types and logic specific to Home Manager options.
|
||||
package homemanager
|
||||
|
||||
// ChannelAliases maps friendly channel names to their git branch/ref patterns.
|
||||
var ChannelAliases = map[string]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",
|
||||
"release-23.05": "release-23.05",
|
||||
}
|
||||
|
||||
// AllowedExtensions is the default set of file extensions to index.
|
||||
var AllowedExtensions = map[string]bool{
|
||||
".nix": true,
|
||||
".json": true,
|
||||
".md": true,
|
||||
".txt": true,
|
||||
".toml": true,
|
||||
".yaml": true,
|
||||
".yml": true,
|
||||
}
|
||||
Reference in New Issue
Block a user