Add a new nixpkgs-search CLI that combines NixOS options search with Nix package search functionality. This provides two MCP servers from a single binary: - `nixpkgs-search options serve` for NixOS options - `nixpkgs-search packages serve` for Nix packages Key changes: - Add packages table to database schema (version 3) - Add Package type and search methods to database layer - Create internal/packages/ with indexer and parser for nix-env JSON - Add MCP server mode (options/packages) with separate tool sets - Add package handlers: search_packages, get_package - Create cmd/nixpkgs-search with combined indexing support - Update flake.nix with nixpkgs-search package (now default) - Bump version to 0.2.0 The index command can index both options and packages together, or use --no-packages/--no-options flags for partial indexing. Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
216 lines
5.5 KiB
Go
216 lines
5.5 KiB
Go
package packages
|
|
|
|
import (
|
|
"strings"
|
|
"testing"
|
|
)
|
|
|
|
func TestParsePackages(t *testing.T) {
|
|
input := `{
|
|
"firefox": {
|
|
"name": "firefox-120.0",
|
|
"pname": "firefox",
|
|
"version": "120.0",
|
|
"system": "x86_64-linux",
|
|
"meta": {
|
|
"description": "A web browser built from Firefox source tree",
|
|
"homepage": "https://www.mozilla.org/firefox/",
|
|
"license": {"spdxId": "MPL-2.0", "fullName": "Mozilla Public License 2.0"},
|
|
"maintainers": [
|
|
{"name": "John Doe", "github": "johndoe", "githubId": 12345}
|
|
],
|
|
"platforms": ["x86_64-linux", "aarch64-linux"]
|
|
}
|
|
},
|
|
"python312Packages.requests": {
|
|
"name": "python3.12-requests-2.31.0",
|
|
"pname": "requests",
|
|
"version": "2.31.0",
|
|
"system": "x86_64-linux",
|
|
"meta": {
|
|
"description": "HTTP library for Python",
|
|
"homepage": ["https://requests.readthedocs.io/"],
|
|
"license": [{"spdxId": "Apache-2.0"}],
|
|
"unfree": false
|
|
}
|
|
}
|
|
}`
|
|
|
|
packages, err := ParsePackages(strings.NewReader(input))
|
|
if err != nil {
|
|
t.Fatalf("ParsePackages failed: %v", err)
|
|
}
|
|
|
|
if len(packages) != 2 {
|
|
t.Errorf("Expected 2 packages, got %d", len(packages))
|
|
}
|
|
|
|
// Check firefox
|
|
firefox, ok := packages["firefox"]
|
|
if !ok {
|
|
t.Fatal("firefox package not found")
|
|
}
|
|
if firefox.Pname != "firefox" {
|
|
t.Errorf("Expected pname 'firefox', got %q", firefox.Pname)
|
|
}
|
|
if firefox.Version != "120.0" {
|
|
t.Errorf("Expected version '120.0', got %q", firefox.Version)
|
|
}
|
|
if firefox.Homepage != "https://www.mozilla.org/firefox/" {
|
|
t.Errorf("Expected homepage 'https://www.mozilla.org/firefox/', got %q", firefox.Homepage)
|
|
}
|
|
if firefox.License != `["MPL-2.0"]` {
|
|
t.Errorf("Expected license '[\"MPL-2.0\"]', got %q", firefox.License)
|
|
}
|
|
|
|
// Check python requests
|
|
requests, ok := packages["python312Packages.requests"]
|
|
if !ok {
|
|
t.Fatal("python312Packages.requests package not found")
|
|
}
|
|
if requests.Pname != "requests" {
|
|
t.Errorf("Expected pname 'requests', got %q", requests.Pname)
|
|
}
|
|
// Homepage is array, should extract first element
|
|
if requests.Homepage != "https://requests.readthedocs.io/" {
|
|
t.Errorf("Expected homepage 'https://requests.readthedocs.io/', got %q", requests.Homepage)
|
|
}
|
|
}
|
|
|
|
func TestParsePackagesStream(t *testing.T) {
|
|
input := `{
|
|
"hello": {
|
|
"name": "hello-2.12",
|
|
"pname": "hello",
|
|
"version": "2.12",
|
|
"system": "x86_64-linux",
|
|
"meta": {
|
|
"description": "A program that produces a familiar, friendly greeting"
|
|
}
|
|
},
|
|
"world": {
|
|
"name": "world-1.0",
|
|
"pname": "world",
|
|
"version": "1.0",
|
|
"system": "x86_64-linux",
|
|
"meta": {}
|
|
}
|
|
}`
|
|
|
|
var packages []*ParsedPackage
|
|
count, err := ParsePackagesStream(strings.NewReader(input), func(pkg *ParsedPackage) error {
|
|
packages = append(packages, pkg)
|
|
return nil
|
|
})
|
|
|
|
if err != nil {
|
|
t.Fatalf("ParsePackagesStream failed: %v", err)
|
|
}
|
|
|
|
if count != 2 {
|
|
t.Errorf("Expected count 2, got %d", count)
|
|
}
|
|
|
|
if len(packages) != 2 {
|
|
t.Errorf("Expected 2 packages, got %d", len(packages))
|
|
}
|
|
}
|
|
|
|
func TestNormalizeLicense(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input interface{}
|
|
expected string
|
|
}{
|
|
{"nil", nil, "[]"},
|
|
{"string", "MIT", `["MIT"]`},
|
|
{"object with spdxId", map[string]interface{}{"spdxId": "MIT"}, `["MIT"]`},
|
|
{"object with fullName", map[string]interface{}{"fullName": "MIT License"}, `["MIT License"]`},
|
|
{"array of strings", []interface{}{"MIT", "Apache-2.0"}, `["MIT","Apache-2.0"]`},
|
|
{"array of objects", []interface{}{
|
|
map[string]interface{}{"spdxId": "MIT"},
|
|
map[string]interface{}{"spdxId": "Apache-2.0"},
|
|
}, `["MIT","Apache-2.0"]`},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
result := normalizeLicense(tc.input)
|
|
if result != tc.expected {
|
|
t.Errorf("Expected %q, got %q", tc.expected, result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNormalizeHomepage(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input interface{}
|
|
expected string
|
|
}{
|
|
{"nil", nil, ""},
|
|
{"string", "https://example.com", "https://example.com"},
|
|
{"array", []interface{}{"https://example.com", "https://docs.example.com"}, "https://example.com"},
|
|
{"empty array", []interface{}{}, ""},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
result := normalizeHomepage(tc.input)
|
|
if result != tc.expected {
|
|
t.Errorf("Expected %q, got %q", tc.expected, result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestNormalizeMaintainers(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
maintainers []Maintainer
|
|
expected string
|
|
}{
|
|
{"empty", nil, "[]"},
|
|
{"with name", []Maintainer{{Name: "John Doe"}}, `["John Doe"]`},
|
|
{"with github only", []Maintainer{{Github: "johndoe"}}, `["@johndoe"]`},
|
|
{"multiple", []Maintainer{{Name: "Alice"}, {Name: "Bob"}}, `["Alice","Bob"]`},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.name, func(t *testing.T) {
|
|
result := normalizeMaintainers(tc.maintainers)
|
|
if result != tc.expected {
|
|
t.Errorf("Expected %q, got %q", tc.expected, result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestSplitAttrPath(t *testing.T) {
|
|
tests := []struct {
|
|
input string
|
|
expected []string
|
|
}{
|
|
{"firefox", []string{"firefox"}},
|
|
{"python312Packages.requests", []string{"python312Packages", "requests"}},
|
|
{"haskellPackages.aeson.components.library", []string{"haskellPackages", "aeson", "components", "library"}},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.input, func(t *testing.T) {
|
|
result := SplitAttrPath(tc.input)
|
|
if len(result) != len(tc.expected) {
|
|
t.Errorf("Expected %v, got %v", tc.expected, result)
|
|
return
|
|
}
|
|
for i := range result {
|
|
if result[i] != tc.expected[i] {
|
|
t.Errorf("Expected %v, got %v", tc.expected, result)
|
|
return
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|