feat: MCP tools and nixpkgs indexer
- Add options.json parser with mdDoc support - Add nixpkgs indexer using nix-build - Implement all MCP tool handlers: - search_options: Full-text search with filters - get_option: Option details with children - get_file: Fetch file contents - index_revision: Build and index options - list_revisions: Show indexed versions - delete_revision: Remove indexed data - Add parser tests Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
363
internal/mcp/handlers.go
Normal file
363
internal/mcp/handlers.go
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
package mcp
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.t-juice.club/torjus/labmcp/internal/database"
|
||||||
|
"git.t-juice.club/torjus/labmcp/internal/nixos"
|
||||||
|
)
|
||||||
|
|
||||||
|
// RegisterHandlers registers all tool handlers on the server.
|
||||||
|
func (s *Server) RegisterHandlers(indexer *nixos.Indexer) {
|
||||||
|
s.tools["search_options"] = s.handleSearchOptions
|
||||||
|
s.tools["get_option"] = s.handleGetOption
|
||||||
|
s.tools["get_file"] = s.handleGetFile
|
||||||
|
s.tools["index_revision"] = s.makeIndexHandler(indexer)
|
||||||
|
s.tools["list_revisions"] = s.handleListRevisions
|
||||||
|
s.tools["delete_revision"] = s.handleDeleteRevision
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleSearchOptions handles the search_options tool.
|
||||||
|
func (s *Server) handleSearchOptions(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||||
|
query, _ := args["query"].(string)
|
||||||
|
if query == "" {
|
||||||
|
return ErrorContent(fmt.Errorf("query is required")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
revision, _ := args["revision"].(string)
|
||||||
|
rev, err := s.resolveRevision(ctx, revision)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorContent(err), nil
|
||||||
|
}
|
||||||
|
if rev == nil {
|
||||||
|
return ErrorContent(fmt.Errorf("no indexed revision available")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
filters := database.SearchFilters{
|
||||||
|
Limit: 50,
|
||||||
|
}
|
||||||
|
|
||||||
|
if t, ok := args["type"].(string); ok && t != "" {
|
||||||
|
filters.Type = t
|
||||||
|
}
|
||||||
|
if ns, ok := args["namespace"].(string); ok && ns != "" {
|
||||||
|
filters.Namespace = ns
|
||||||
|
}
|
||||||
|
if limit, ok := args["limit"].(float64); ok && limit > 0 {
|
||||||
|
filters.Limit = int(limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
options, err := s.store.SearchOptions(ctx, rev.ID, query, filters)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorContent(fmt.Errorf("search failed: %w", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format results
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("Found %d options matching '%s' in revision %s:\n\n", len(options), query, rev.GitHash[:8]))
|
||||||
|
|
||||||
|
for _, opt := range options {
|
||||||
|
sb.WriteString(fmt.Sprintf("## %s\n", opt.Name))
|
||||||
|
sb.WriteString(fmt.Sprintf("Type: %s\n", opt.Type))
|
||||||
|
if opt.Description != "" {
|
||||||
|
desc := opt.Description
|
||||||
|
if len(desc) > 200 {
|
||||||
|
desc = desc[:200] + "..."
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("Description: %s\n", desc))
|
||||||
|
}
|
||||||
|
sb.WriteString("\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
return CallToolResult{
|
||||||
|
Content: []Content{TextContent(sb.String())},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetOption handles the get_option tool.
|
||||||
|
func (s *Server) handleGetOption(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||||
|
name, _ := args["name"].(string)
|
||||||
|
if name == "" {
|
||||||
|
return ErrorContent(fmt.Errorf("name is required")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
revision, _ := args["revision"].(string)
|
||||||
|
rev, err := s.resolveRevision(ctx, revision)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorContent(err), nil
|
||||||
|
}
|
||||||
|
if rev == nil {
|
||||||
|
return ErrorContent(fmt.Errorf("no indexed revision available")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
option, err := s.store.GetOption(ctx, rev.ID, name)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorContent(fmt.Errorf("failed to get option: %w", err)), nil
|
||||||
|
}
|
||||||
|
if option == nil {
|
||||||
|
return ErrorContent(fmt.Errorf("option '%s' not found", name)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get declarations
|
||||||
|
declarations, err := s.store.GetDeclarations(ctx, option.ID)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Printf("Failed to get declarations: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Format result
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("# %s\n\n", option.Name))
|
||||||
|
sb.WriteString(fmt.Sprintf("**Type:** %s\n", option.Type))
|
||||||
|
|
||||||
|
if option.Description != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("\n**Description:**\n%s\n", option.Description))
|
||||||
|
}
|
||||||
|
|
||||||
|
if option.DefaultValue != "" && option.DefaultValue != "null" {
|
||||||
|
sb.WriteString(fmt.Sprintf("\n**Default:** `%s`\n", formatJSON(option.DefaultValue)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if option.Example != "" && option.Example != "null" {
|
||||||
|
sb.WriteString(fmt.Sprintf("\n**Example:** `%s`\n", formatJSON(option.Example)))
|
||||||
|
}
|
||||||
|
|
||||||
|
if option.ReadOnly {
|
||||||
|
sb.WriteString("\n**Read-only:** Yes\n")
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(declarations) > 0 {
|
||||||
|
sb.WriteString("\n**Declared in:**\n")
|
||||||
|
for _, decl := range declarations {
|
||||||
|
if decl.Line > 0 {
|
||||||
|
sb.WriteString(fmt.Sprintf("- %s:%d\n", decl.FilePath, decl.Line))
|
||||||
|
} else {
|
||||||
|
sb.WriteString(fmt.Sprintf("- %s\n", decl.FilePath))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Include children if requested (default: true)
|
||||||
|
includeChildren := true
|
||||||
|
if ic, ok := args["include_children"].(bool); ok {
|
||||||
|
includeChildren = ic
|
||||||
|
}
|
||||||
|
|
||||||
|
if includeChildren {
|
||||||
|
children, err := s.store.GetChildren(ctx, rev.ID, option.Name)
|
||||||
|
if err != nil {
|
||||||
|
s.logger.Printf("Failed to get children: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(children) > 0 {
|
||||||
|
sb.WriteString("\n**Sub-options:**\n")
|
||||||
|
for _, child := range children {
|
||||||
|
// Show just the last part of the name
|
||||||
|
shortName := child.Name
|
||||||
|
if strings.HasPrefix(child.Name, option.Name+".") {
|
||||||
|
shortName = child.Name[len(option.Name)+1:]
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("- `%s` (%s)\n", shortName, child.Type))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return CallToolResult{
|
||||||
|
Content: []Content{TextContent(sb.String())},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleGetFile handles the get_file tool.
|
||||||
|
func (s *Server) handleGetFile(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||||
|
path, _ := args["path"].(string)
|
||||||
|
if path == "" {
|
||||||
|
return ErrorContent(fmt.Errorf("path is required")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Security: validate path
|
||||||
|
if strings.Contains(path, "..") {
|
||||||
|
return ErrorContent(fmt.Errorf("invalid path: directory traversal not allowed")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
revision, _ := args["revision"].(string)
|
||||||
|
rev, err := s.resolveRevision(ctx, revision)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorContent(err), nil
|
||||||
|
}
|
||||||
|
if rev == nil {
|
||||||
|
return ErrorContent(fmt.Errorf("no indexed revision available")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := s.store.GetFile(ctx, rev.ID, path)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorContent(fmt.Errorf("failed to get file: %w", err)), nil
|
||||||
|
}
|
||||||
|
if file == nil {
|
||||||
|
return ErrorContent(fmt.Errorf("file '%s' not found (files may not be indexed for this revision)", path)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return CallToolResult{
|
||||||
|
Content: []Content{TextContent(fmt.Sprintf("```%s\n%s\n```", strings.TrimPrefix(file.Extension, "."), file.Content))},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// makeIndexHandler creates the index_revision handler with the indexer.
|
||||||
|
func (s *Server) makeIndexHandler(indexer *nixos.Indexer) ToolHandler {
|
||||||
|
return func(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||||
|
revision, _ := args["revision"].(string)
|
||||||
|
if revision == "" {
|
||||||
|
return ErrorContent(fmt.Errorf("revision is required")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := indexer.IndexRevision(ctx, revision)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorContent(fmt.Errorf("indexing failed: %w", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("Indexed revision: %s\n", result.Revision.GitHash))
|
||||||
|
if result.Revision.ChannelName != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf("Channel: %s\n", result.Revision.ChannelName))
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("Options: %d\n", result.OptionCount))
|
||||||
|
sb.WriteString(fmt.Sprintf("Duration: %s\n", result.Duration.Round(time.Millisecond)))
|
||||||
|
|
||||||
|
return CallToolResult{
|
||||||
|
Content: []Content{TextContent(sb.String())},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleListRevisions handles the list_revisions tool.
|
||||||
|
func (s *Server) handleListRevisions(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||||
|
revisions, err := s.store.ListRevisions(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorContent(fmt.Errorf("failed to list revisions: %w", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(revisions) == 0 {
|
||||||
|
return CallToolResult{
|
||||||
|
Content: []Content{TextContent("No revisions indexed. Use index_revision to index a nixpkgs version.")},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var sb strings.Builder
|
||||||
|
sb.WriteString(fmt.Sprintf("Indexed revisions (%d):\n\n", len(revisions)))
|
||||||
|
|
||||||
|
for _, rev := range revisions {
|
||||||
|
sb.WriteString(fmt.Sprintf("- **%s**", rev.GitHash[:12]))
|
||||||
|
if rev.ChannelName != "" {
|
||||||
|
sb.WriteString(fmt.Sprintf(" (%s)", rev.ChannelName))
|
||||||
|
}
|
||||||
|
sb.WriteString(fmt.Sprintf("\n Options: %d, Indexed: %s\n", rev.OptionCount, rev.IndexedAt.Format("2006-01-02 15:04")))
|
||||||
|
}
|
||||||
|
|
||||||
|
return CallToolResult{
|
||||||
|
Content: []Content{TextContent(sb.String())},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleDeleteRevision handles the delete_revision tool.
|
||||||
|
func (s *Server) handleDeleteRevision(ctx context.Context, args map[string]interface{}) (CallToolResult, error) {
|
||||||
|
revision, _ := args["revision"].(string)
|
||||||
|
if revision == "" {
|
||||||
|
return ErrorContent(fmt.Errorf("revision is required")), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
rev, err := s.resolveRevision(ctx, revision)
|
||||||
|
if err != nil {
|
||||||
|
return ErrorContent(err), nil
|
||||||
|
}
|
||||||
|
if rev == nil {
|
||||||
|
return ErrorContent(fmt.Errorf("revision '%s' not found", revision)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.store.DeleteRevision(ctx, rev.ID); err != nil {
|
||||||
|
return ErrorContent(fmt.Errorf("failed to delete revision: %w", err)), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
return CallToolResult{
|
||||||
|
Content: []Content{TextContent(fmt.Sprintf("Deleted revision %s", rev.GitHash))},
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveRevision resolves a revision string to a Revision object.
|
||||||
|
func (s *Server) resolveRevision(ctx context.Context, revision string) (*database.Revision, error) {
|
||||||
|
if revision == "" {
|
||||||
|
// Try to find a default revision
|
||||||
|
rev, err := s.store.GetRevisionByChannel(ctx, "nixos-stable")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if rev != nil {
|
||||||
|
return rev, nil
|
||||||
|
}
|
||||||
|
// Fall back to any available revision
|
||||||
|
revs, err := s.store.ListRevisions(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if len(revs) > 0 {
|
||||||
|
return revs[0], nil
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try by git hash first
|
||||||
|
rev, err := s.store.GetRevision(ctx, revision)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if rev != nil {
|
||||||
|
return rev, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try by channel name
|
||||||
|
rev, err = s.store.GetRevisionByChannel(ctx, revision)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
return rev, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// formatJSON formats a JSON string for display, handling compact representation.
|
||||||
|
func formatJSON(s string) string {
|
||||||
|
if s == "" || s == "null" {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse and reformat
|
||||||
|
var v interface{}
|
||||||
|
if err := json.Unmarshal([]byte(s), &v); err != nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
// For simple values, return as-is
|
||||||
|
switch val := v.(type) {
|
||||||
|
case bool, float64, string:
|
||||||
|
return s
|
||||||
|
case []interface{}:
|
||||||
|
if len(val) <= 3 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
case map[string]interface{}:
|
||||||
|
if len(val) <= 3 {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// For complex values, try to pretty print (truncated)
|
||||||
|
b, err := json.MarshalIndent(v, "", " ")
|
||||||
|
if err != nil {
|
||||||
|
return s
|
||||||
|
}
|
||||||
|
|
||||||
|
result := string(b)
|
||||||
|
if len(result) > 500 {
|
||||||
|
result = result[:500] + "..."
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
380
internal/nixos/indexer.go
Normal file
380
internal/nixos/indexer.go
Normal file
@@ -0,0 +1,380 @@
|
|||||||
|
package nixos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"archive/tar"
|
||||||
|
"compress/gzip"
|
||||||
|
"context"
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"os/exec"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.t-juice.club/torjus/labmcp/internal/database"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Indexer handles indexing of nixpkgs revisions.
|
||||||
|
type Indexer struct {
|
||||||
|
store database.Store
|
||||||
|
httpClient *http.Client
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewIndexer creates a new 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
|
||||||
|
}
|
||||||
|
|
||||||
|
// IndexRevision indexes a nixpkgs revision by git hash or channel name.
|
||||||
|
func (idx *Indexer) IndexRevision(ctx context.Context, revision string) (*IndexResult, error) {
|
||||||
|
start := time.Now()
|
||||||
|
|
||||||
|
// Resolve channel names to git refs
|
||||||
|
ref := 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 &IndexResult{
|
||||||
|
Revision: existing,
|
||||||
|
OptionCount: existing.OptionCount,
|
||||||
|
Duration: time.Since(start),
|
||||||
|
}, 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
|
||||||
|
optionsFile, err := os.Open(optionsPath)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to open options.json: %w", err)
|
||||||
|
}
|
||||||
|
defer optionsFile.Close()
|
||||||
|
|
||||||
|
options, err := 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: getChannelName(revision),
|
||||||
|
CommitDate: commitDate,
|
||||||
|
OptionCount: len(options),
|
||||||
|
}
|
||||||
|
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, options); err != nil {
|
||||||
|
// Cleanup on failure
|
||||||
|
idx.store.DeleteRevision(ctx, rev.ID)
|
||||||
|
return nil, fmt.Errorf("failed to store options: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &IndexResult{
|
||||||
|
Revision: rev,
|
||||||
|
OptionCount: len(options),
|
||||||
|
Duration: time.Since(start),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// buildOptions builds options.json for a nixpkgs revision.
|
||||||
|
func (idx *Indexer) buildOptions(ctx context.Context, ref string) (string, func(), error) {
|
||||||
|
// Create temp directory
|
||||||
|
tmpDir, err := os.MkdirTemp("", "nixos-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 NixOS options from the specified nixpkgs revision
|
||||||
|
nixExpr := fmt.Sprintf(`
|
||||||
|
let
|
||||||
|
nixpkgs = builtins.fetchTarball {
|
||||||
|
url = "https://github.com/NixOS/nixpkgs/archive/%s.tar.gz";
|
||||||
|
};
|
||||||
|
pkgs = import nixpkgs { config = {}; };
|
||||||
|
eval = import (nixpkgs + "/nixos/lib/eval-config.nix") {
|
||||||
|
modules = [];
|
||||||
|
system = "x86_64-linux";
|
||||||
|
};
|
||||||
|
opts = (pkgs.nixosOptionsDoc { options = eval.options; }).optionsJSON;
|
||||||
|
in opts
|
||||||
|
`, 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/nixos/options.json
|
||||||
|
storePath := strings.TrimSpace(string(output))
|
||||||
|
optionsPath := filepath.Join(storePath, "share", "doc", "nixos", "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, options map[string]*ParsedOption) error {
|
||||||
|
// Prepare batch of options
|
||||||
|
opts := make([]*database.Option, 0, len(options))
|
||||||
|
declsByName := make(map[string][]*database.Declaration)
|
||||||
|
|
||||||
|
for name, opt := range options {
|
||||||
|
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,
|
||||||
|
}
|
||||||
|
opts = append(opts, 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(opts); i += batchSize {
|
||||||
|
end := i + batchSize
|
||||||
|
if end > len(opts) {
|
||||||
|
end = len(opts)
|
||||||
|
}
|
||||||
|
batch := opts[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 opts {
|
||||||
|
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/NixOS/nixpkgs/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 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 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 nixpkgs 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 nixpkgs tarball
|
||||||
|
url := fmt.Sprintf("https://github.com/NixOS/nixpkgs/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 (nixpkgs-<hash>/)
|
||||||
|
path := header.Name
|
||||||
|
if idx := strings.Index(path, "/"); idx >= 0 {
|
||||||
|
path = path[idx+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
|
||||||
|
}
|
||||||
126
internal/nixos/parser.go
Normal file
126
internal/nixos/parser.go
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
package nixos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
"fmt"
|
||||||
|
"io"
|
||||||
|
)
|
||||||
|
|
||||||
|
// ParseOptions parses the options.json file from nixpkgs.
|
||||||
|
// The options.json structure is a map from option name to option definition.
|
||||||
|
func ParseOptions(r io.Reader) (map[string]*ParsedOption, error) {
|
||||||
|
var raw map[string]json.RawMessage
|
||||||
|
if err := json.NewDecoder(r).Decode(&raw); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to decode options.json: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
options := make(map[string]*ParsedOption, len(raw))
|
||||||
|
for name, data := range raw {
|
||||||
|
opt, err := parseOption(name, data)
|
||||||
|
if err != nil {
|
||||||
|
// Log but don't fail - some options might have unusual formats
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
options[name] = opt
|
||||||
|
}
|
||||||
|
|
||||||
|
return options, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// ParsedOption represents a parsed NixOS option with all its metadata.
|
||||||
|
type ParsedOption struct {
|
||||||
|
Name string
|
||||||
|
Type string
|
||||||
|
Description string
|
||||||
|
Default string // JSON-encoded value
|
||||||
|
Example string // JSON-encoded value
|
||||||
|
ReadOnly bool
|
||||||
|
Declarations []string
|
||||||
|
}
|
||||||
|
|
||||||
|
// optionJSON is the internal structure for parsing options.json entries.
|
||||||
|
type optionJSON struct {
|
||||||
|
Declarations []string `json:"declarations"`
|
||||||
|
Default json.RawMessage `json:"default,omitempty"`
|
||||||
|
Description interface{} `json:"description"` // Can be string or object
|
||||||
|
Example json.RawMessage `json:"example,omitempty"`
|
||||||
|
ReadOnly bool `json:"readOnly"`
|
||||||
|
Type string `json:"type"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// parseOption parses a single option entry.
|
||||||
|
func parseOption(name string, data json.RawMessage) (*ParsedOption, error) {
|
||||||
|
var opt optionJSON
|
||||||
|
if err := json.Unmarshal(data, &opt); err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to parse option %s: %w", name, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle description which can be a string or an object with _type: "mdDoc"
|
||||||
|
description := extractDescription(opt.Description)
|
||||||
|
|
||||||
|
// Convert declarations to relative paths
|
||||||
|
declarations := make([]string, 0, len(opt.Declarations))
|
||||||
|
for _, d := range opt.Declarations {
|
||||||
|
declarations = append(declarations, normalizeDeclarationPath(d))
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ParsedOption{
|
||||||
|
Name: name,
|
||||||
|
Type: opt.Type,
|
||||||
|
Description: description,
|
||||||
|
Default: string(opt.Default),
|
||||||
|
Example: string(opt.Example),
|
||||||
|
ReadOnly: opt.ReadOnly,
|
||||||
|
Declarations: declarations,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// extractDescription extracts the description string from various formats.
|
||||||
|
func extractDescription(desc interface{}) string {
|
||||||
|
switch v := desc.(type) {
|
||||||
|
case string:
|
||||||
|
return v
|
||||||
|
case map[string]interface{}:
|
||||||
|
// Handle mdDoc format: {"_type": "mdDoc", "text": "..."}
|
||||||
|
if text, ok := v["text"].(string); ok {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
// Try "description" key
|
||||||
|
if text, ok := v["description"].(string); ok {
|
||||||
|
return text
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
// normalizeDeclarationPath converts a full store path to a relative nixpkgs path.
|
||||||
|
// Input: "/nix/store/xxx-source/nixos/modules/services/web-servers/nginx/default.nix"
|
||||||
|
// Output: "nixos/modules/services/web-servers/nginx/default.nix"
|
||||||
|
func normalizeDeclarationPath(path string) string {
|
||||||
|
// Look for common prefixes and strip them
|
||||||
|
markers := []string{
|
||||||
|
"/nixos/",
|
||||||
|
"/pkgs/",
|
||||||
|
"/lib/",
|
||||||
|
"/maintainers/",
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, marker := range markers {
|
||||||
|
if idx := findSubstring(path, marker); idx >= 0 {
|
||||||
|
return path[idx+1:] // +1 to skip the leading /
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If no marker found, return as-is
|
||||||
|
return path
|
||||||
|
}
|
||||||
|
|
||||||
|
// findSubstring returns the index of the first occurrence of substr in s, or -1.
|
||||||
|
func findSubstring(s, substr string) int {
|
||||||
|
for i := 0; i <= len(s)-len(substr); i++ {
|
||||||
|
if s[i:i+len(substr)] == substr {
|
||||||
|
return i
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
}
|
||||||
154
internal/nixos/parser_test.go
Normal file
154
internal/nixos/parser_test.go
Normal file
@@ -0,0 +1,154 @@
|
|||||||
|
package nixos
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseOptions(t *testing.T) {
|
||||||
|
// Sample options.json content
|
||||||
|
input := `{
|
||||||
|
"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.caddy.enable": {
|
||||||
|
"declarations": ["/nix/store/xxx-source/nixos/modules/services/web-servers/caddy/default.nix"],
|
||||||
|
"default": false,
|
||||||
|
"description": "Enable Caddy web server",
|
||||||
|
"readOnly": false,
|
||||||
|
"type": "boolean"
|
||||||
|
}
|
||||||
|
}`
|
||||||
|
|
||||||
|
options, err := ParseOptions(strings.NewReader(input))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ParseOptions failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(options) != 3 {
|
||||||
|
t.Errorf("Expected 3 options, got %d", len(options))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check nginx.enable
|
||||||
|
opt := options["services.nginx.enable"]
|
||||||
|
if opt == nil {
|
||||||
|
t.Fatal("Expected services.nginx.enable option")
|
||||||
|
}
|
||||||
|
if opt.Type != "boolean" {
|
||||||
|
t.Errorf("Type = %q, want boolean", opt.Type)
|
||||||
|
}
|
||||||
|
if opt.Description != "Whether to enable Nginx Web Server." {
|
||||||
|
t.Errorf("Description = %q", opt.Description)
|
||||||
|
}
|
||||||
|
if opt.Default != "false" {
|
||||||
|
t.Errorf("Default = %q, want false", opt.Default)
|
||||||
|
}
|
||||||
|
if len(opt.Declarations) != 1 {
|
||||||
|
t.Errorf("Expected 1 declaration, got %d", len(opt.Declarations))
|
||||||
|
}
|
||||||
|
// Check declaration path normalization
|
||||||
|
if !strings.HasPrefix(opt.Declarations[0], "nixos/") {
|
||||||
|
t.Errorf("Declaration path not normalized: %q", opt.Declarations[0])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check nginx.package (mdDoc description)
|
||||||
|
opt = options["services.nginx.package"]
|
||||||
|
if opt == nil {
|
||||||
|
t.Fatal("Expected services.nginx.package option")
|
||||||
|
}
|
||||||
|
if opt.Description != "The nginx package to use." {
|
||||||
|
t.Errorf("Description = %q (mdDoc not extracted)", opt.Description)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNormalizeDeclarationPath(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
input string
|
||||||
|
expect string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"/nix/store/xxx-source/nixos/modules/services/web-servers/nginx/default.nix",
|
||||||
|
"nixos/modules/services/web-servers/nginx/default.nix",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/nix/store/xxx/pkgs/top-level/all-packages.nix",
|
||||||
|
"pkgs/top-level/all-packages.nix",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"/nix/store/abc123/lib/types.nix",
|
||||||
|
"lib/types.nix",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"relative/path.nix",
|
||||||
|
"relative/path.nix",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.input, func(t *testing.T) {
|
||||||
|
got := normalizeDeclarationPath(tt.input)
|
||||||
|
if got != tt.expect {
|
||||||
|
t.Errorf("normalizeDeclarationPath(%q) = %q, want %q", tt.input, got, tt.expect)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestExtractDescription(t *testing.T) {
|
||||||
|
tests := []struct {
|
||||||
|
name string
|
||||||
|
input interface{}
|
||||||
|
expect string
|
||||||
|
}{
|
||||||
|
{
|
||||||
|
"string",
|
||||||
|
"Simple description",
|
||||||
|
"Simple description",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"mdDoc",
|
||||||
|
map[string]interface{}{
|
||||||
|
"_type": "mdDoc",
|
||||||
|
"text": "Markdown description",
|
||||||
|
},
|
||||||
|
"Markdown description",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"description key",
|
||||||
|
map[string]interface{}{
|
||||||
|
"description": "Nested description",
|
||||||
|
},
|
||||||
|
"Nested description",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"nil",
|
||||||
|
nil,
|
||||||
|
"",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"empty map",
|
||||||
|
map[string]interface{}{},
|
||||||
|
"",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, tt := range tests {
|
||||||
|
t.Run(tt.name, func(t *testing.T) {
|
||||||
|
got := extractDescription(tt.input)
|
||||||
|
if got != tt.expect {
|
||||||
|
t.Errorf("extractDescription = %q, want %q", got, tt.expect)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user