From 0b0ada3ccd2d9999063b9509e773bb5066272437 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torjus=20H=C3=A5kestad?= Date: Tue, 3 Feb 2026 17:34:50 +0100 Subject: [PATCH] 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 --- internal/mcp/handlers.go | 363 ++++++++++++++++++++++++++++++++ internal/nixos/indexer.go | 380 ++++++++++++++++++++++++++++++++++ internal/nixos/parser.go | 126 +++++++++++ internal/nixos/parser_test.go | 154 ++++++++++++++ 4 files changed, 1023 insertions(+) create mode 100644 internal/mcp/handlers.go create mode 100644 internal/nixos/indexer.go create mode 100644 internal/nixos/parser.go create mode 100644 internal/nixos/parser_test.go diff --git a/internal/mcp/handlers.go b/internal/mcp/handlers.go new file mode 100644 index 0000000..0dc82ec --- /dev/null +++ b/internal/mcp/handlers.go @@ -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 +} diff --git a/internal/nixos/indexer.go b/internal/nixos/indexer.go new file mode 100644 index 0000000..6da231c --- /dev/null +++ b/internal/nixos/indexer.go @@ -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-/) + 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 +} diff --git a/internal/nixos/parser.go b/internal/nixos/parser.go new file mode 100644 index 0000000..dee621f --- /dev/null +++ b/internal/nixos/parser.go @@ -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 +} diff --git a/internal/nixos/parser_test.go b/internal/nixos/parser_test.go new file mode 100644 index 0000000..c16dea7 --- /dev/null +++ b/internal/nixos/parser_test.go @@ -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) + } + }) + } +}