From 6326b3a3c1bf1227cea496fff85936951403ff0e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torjus=20H=C3=A5kestad?= Date: Tue, 3 Feb 2026 17:30:11 +0100 Subject: [PATCH] feat: project structure and nix build setup - Add CLI entry point with urfave/cli/v2 (serve, index, list, search commands) - Add database interface and implementations for PostgreSQL and SQLite - Add schema versioning with automatic recreation on version mismatch - Add MCP protocol types and server scaffold - Add NixOS option types - Configure flake.nix with devShell and buildGoModule package Co-Authored-By: Claude Opus 4.5 --- cmd/nixos-options/main.go | 99 +++++++ flake.nix | 59 +++- go.mod | 27 +- go.sum | 59 ++++ internal/database/interface.go | 88 ++++++ internal/database/postgres.go | 473 ++++++++++++++++++++++++++++++ internal/database/schema.go | 102 +++++++ internal/database/sqlite.go | 507 +++++++++++++++++++++++++++++++++ internal/mcp/server.go | 329 +++++++++++++++++++++ internal/mcp/types.go | 139 +++++++++ internal/nixos/types.go | 45 +++ 11 files changed, 1921 insertions(+), 6 deletions(-) create mode 100644 cmd/nixos-options/main.go create mode 100644 go.sum create mode 100644 internal/database/interface.go create mode 100644 internal/database/postgres.go create mode 100644 internal/database/schema.go create mode 100644 internal/database/sqlite.go create mode 100644 internal/mcp/server.go create mode 100644 internal/mcp/types.go create mode 100644 internal/nixos/types.go diff --git a/cmd/nixos-options/main.go b/cmd/nixos-options/main.go new file mode 100644 index 0000000..023d2ef --- /dev/null +++ b/cmd/nixos-options/main.go @@ -0,0 +1,99 @@ +package main + +import ( + "fmt" + "log" + "os" + + "github.com/urfave/cli/v2" +) + +func main() { + app := &cli.App{ + Name: "nixos-options", + Usage: "MCP server for NixOS options search and query", + Commands: []*cli.Command{ + { + Name: "serve", + Usage: "Run MCP server (stdio)", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "database", + Aliases: []string{"d"}, + Usage: "Database connection string (postgres://... or sqlite://...)", + EnvVars: []string{"NIXOS_OPTIONS_DATABASE"}, + }, + }, + Action: func(c *cli.Context) error { + fmt.Println("MCP server not yet implemented") + return nil + }, + }, + { + Name: "index", + Usage: "Index a nixpkgs revision", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "database", + Aliases: []string{"d"}, + Usage: "Database connection string", + EnvVars: []string{"NIXOS_OPTIONS_DATABASE"}, + }, + }, + Action: func(c *cli.Context) error { + if c.NArg() < 1 { + return fmt.Errorf("revision argument required") + } + fmt.Printf("Indexing revision: %s\n", c.Args().First()) + return nil + }, + }, + { + Name: "list", + Usage: "List indexed revisions", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "database", + Aliases: []string{"d"}, + Usage: "Database connection string", + EnvVars: []string{"NIXOS_OPTIONS_DATABASE"}, + }, + }, + Action: func(c *cli.Context) error { + fmt.Println("List revisions not yet implemented") + return nil + }, + }, + { + Name: "search", + Usage: "Search for options", + ArgsUsage: "", + Flags: []cli.Flag{ + &cli.StringFlag{ + Name: "database", + Aliases: []string{"d"}, + Usage: "Database connection string", + EnvVars: []string{"NIXOS_OPTIONS_DATABASE"}, + }, + &cli.StringFlag{ + Name: "revision", + Aliases: []string{"r"}, + Usage: "Revision to search (default: nixos-stable)", + }, + }, + Action: func(c *cli.Context) error { + if c.NArg() < 1 { + return fmt.Errorf("query argument required") + } + fmt.Printf("Searching for: %s\n", c.Args().First()) + return nil + }, + }, + }, + } + + if err := app.Run(os.Args); err != nil { + log.Fatal(err) + } +} diff --git a/flake.nix b/flake.nix index c7a9a1c..d641351 100644 --- a/flake.nix +++ b/flake.nix @@ -1,15 +1,64 @@ { - description = "A very basic flake"; + description = "LabMCP - Collection of MCP servers"; inputs = { nixpkgs.url = "github:nixos/nixpkgs?ref=nixos-unstable"; }; - outputs = { self, nixpkgs }: { + outputs = { self, nixpkgs }: + let + supportedSystems = [ "x86_64-linux" "aarch64-linux" "x86_64-darwin" "aarch64-darwin" ]; + forAllSystems = nixpkgs.lib.genAttrs supportedSystems; + pkgsFor = system: nixpkgs.legacyPackages.${system}; + in + { + packages = forAllSystems (system: + let + pkgs = pkgsFor system; + in + { + nixos-options = pkgs.buildGoModule { + pname = "nixos-options-mcp"; + version = "0.1.0"; + src = ./.; - packages.x86_64-linux.hello = nixpkgs.legacyPackages.x86_64-linux.hello; + vendorHash = null; # Will be set after first build - packages.x86_64-linux.default = self.packages.x86_64-linux.hello; + subPackages = [ "cmd/nixos-options" ]; - }; + meta = with pkgs.lib; { + description = "MCP server for NixOS options search and query"; + homepage = "https://git.t-juice.club/torjus/labmcp"; + license = licenses.mit; + maintainers = [ ]; + mainProgram = "nixos-options"; + }; + }; + + default = self.packages.${system}.nixos-options; + }); + + devShells = forAllSystems (system: + let + pkgs = pkgsFor system; + in + { + default = pkgs.mkShell { + buildInputs = with pkgs; [ + go_1_24 + gopls + gotools + go-tools + golangci-lint + postgresql + sqlite + ]; + + shellHook = '' + echo "LabMCP development shell" + echo "Go version: $(go version)" + ''; + }; + }); + }; } diff --git a/go.mod b/go.mod index 9988097..d0ae844 100644 --- a/go.mod +++ b/go.mod @@ -1,3 +1,28 @@ module git.t-juice.club/torjus/labmcp -go 1.25.5 +go 1.24 + +require ( + github.com/lib/pq v1.10.9 + github.com/urfave/cli/v2 v2.27.5 + modernc.org/sqlite v1.34.4 +) + +require ( + github.com/cpuguy83/go-md2man/v2 v2.0.5 // indirect + github.com/dustin/go-humanize v1.0.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/hashicorp/golang-lru/v2 v2.0.7 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/ncruces/go-strftime v0.1.9 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect + github.com/russross/blackfriday/v2 v2.1.0 // indirect + github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 // indirect + golang.org/x/sys v0.22.0 // indirect + modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 // indirect + modernc.org/libc v1.55.3 // indirect + modernc.org/mathutil v1.6.0 // indirect + modernc.org/memory v1.8.0 // indirect + modernc.org/strutil v1.2.0 // indirect + modernc.org/token v1.1.0 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..d786f63 --- /dev/null +++ b/go.sum @@ -0,0 +1,59 @@ +github.com/cpuguy83/go-md2man/v2 v2.0.5 h1:ZtcqGrnekaHpVLArFSe4HK5DoKx1T0rq2DwVB0alcyc= +github.com/cpuguy83/go-md2man/v2 v2.0.5/go.mod h1:tgQtvFlXSQOSOSIRvRPT7W67SCa46tRHOmNcaadrF8o= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd h1:gbpYu9NMq8jhDVbvlGkMFWCjLFlqqEZjEmObmhUy6Vo= +github.com/google/pprof v0.0.0-20240409012703-83162a5b38cd/go.mod h1:kf6iHlnVGwgKolg33glAes7Yg/8iWP8ukqeldJSO7jw= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/russross/blackfriday/v2 v2.1.0 h1:JIOH55/0cWyOuilr9/qlrm0BSXldqnqwMsf35Ld67mk= +github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM= +github.com/urfave/cli/v2 v2.27.5 h1:WoHEJLdsXr6dDWoJgMq/CboDmyY/8HMMH1fTECbih+w= +github.com/urfave/cli/v2 v2.27.5/go.mod h1:3Sevf16NykTbInEnD0yKkjDAeZDS0A6bzhBH5hrMvTQ= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1 h1:gEOO8jv9F4OT7lGCjxCBTO/36wtF6j2nSip77qHd4x4= +github.com/xrash/smetrics v0.0.0-20240521201337-686a1a2994c1/go.mod h1:Ohn+xnUBiLI6FVj/9LpzZWtj1/D6lUovWYBkxHVV3aM= +golang.org/x/mod v0.16.0 h1:QX4fJ0Rr5cPQCF7O9lh9Se4pmwfwskqZfq5moyldzic= +golang.org/x/mod v0.16.0/go.mod h1:hTbmBsO62+eylJbnUtE2MGJUyE7QWk4xUqPFrRgJ+7c= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.22.0 h1:RI27ohtqKCnwULzJLqkv897zojh5/DwS/ENaMzUOaWI= +golang.org/x/sys v0.22.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/tools v0.19.0 h1:tfGCXNR1OsFG+sVdLAitlpjAvD/I6dHDKnYrpEZUHkw= +golang.org/x/tools v0.19.0/go.mod h1:qoJWxmGSIBmAeriMx19ogtrEPrGtDbPK634QFIcLAhc= +modernc.org/cc/v4 v4.21.4 h1:3Be/Rdo1fpr8GrQ7IVw9OHtplU4gWbb+wNgeoBMmGLQ= +modernc.org/cc/v4 v4.21.4/go.mod h1:HM7VJTZbUCR3rV8EYBi9wxnJ0ZBRiGE5OeGXNA0IsLQ= +modernc.org/ccgo/v4 v4.19.2 h1:lwQZgvboKD0jBwdaeVCTouxhxAyN6iawF3STraAal8Y= +modernc.org/ccgo/v4 v4.19.2/go.mod h1:ysS3mxiMV38XGRTTcgo0DQTeTmAO4oCmJl1nX9VFI3s= +modernc.org/fileutil v1.3.0 h1:gQ5SIzK3H9kdfai/5x41oQiKValumqNTDXMvKo62HvE= +modernc.org/fileutil v1.3.0/go.mod h1:XatxS8fZi3pS8/hKG2GH/ArUogfxjpEKs3Ku3aK4JyQ= +modernc.org/gc/v2 v2.4.1 h1:9cNzOqPyMJBvrUipmynX0ZohMhcxPtMccYgGOJdOiBw= +modernc.org/gc/v2 v2.4.1/go.mod h1:wzN5dK1AzVGoH6XOzc3YZ+ey/jPgYHLuVckd62P0GYU= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6 h1:5D53IMaUuA5InSeMu9eJtlQXS2NxAhyWQvkKEgXZhHI= +modernc.org/gc/v3 v3.0.0-20240107210532-573471604cb6/go.mod h1:Qz0X07sNOR1jWYCrJMEnbW/X55x206Q7Vt4mz6/wHp4= +modernc.org/libc v1.55.3 h1:AzcW1mhlPNrRtjS5sS+eW2ISCgSOLLNyFzRh/V3Qj/U= +modernc.org/libc v1.55.3/go.mod h1:qFXepLhz+JjFThQ4kzwzOjA/y/artDeg+pcYnY+Q83w= +modernc.org/mathutil v1.6.0 h1:fRe9+AmYlaej+64JsEEhoWuAYBkOtQiMEU7n/XgfYi4= +modernc.org/mathutil v1.6.0/go.mod h1:Ui5Q9q1TR2gFm0AQRqQUaBWFLAhQpCwNcuhBOSedWPo= +modernc.org/memory v1.8.0 h1:IqGTL6eFMaDZZhEWwcREgeMXYwmW83LYW8cROZYkg+E= +modernc.org/memory v1.8.0/go.mod h1:XPZ936zp5OMKGWPqbD3JShgd/ZoQ7899TUuQqxY+peU= +modernc.org/opt v0.1.3 h1:3XOZf2yznlhC+ibLltsDGzABUGVx8J6pnFMS3E4dcq4= +modernc.org/opt v0.1.3/go.mod h1:WdSiB5evDcignE70guQKxYUl14mgWtbClRi5wmkkTX0= +modernc.org/sortutil v1.2.0 h1:jQiD3PfS2REGJNzNCMMaLSp/wdMNieTbKX920Cqdgqc= +modernc.org/sortutil v1.2.0/go.mod h1:TKU2s7kJMf1AE84OoiGppNHJwvB753OYfNl2WRb++Ss= +modernc.org/sqlite v1.34.4 h1:sjdARozcL5KJBvYQvLlZEmctRgW9xqIZc2ncN7PU0P8= +modernc.org/sqlite v1.34.4/go.mod h1:3QQFCG2SEMtc2nv+Wq4cQCH7Hjcg+p/RMlS1XK+zwbk= +modernc.org/strutil v1.2.0 h1:agBi9dp1I+eOnxXeiZawM8F4LawKv4NzGWSaLfyeNZA= +modernc.org/strutil v1.2.0/go.mod h1:/mdcBmfOibveCTBxUl5B5l6W+TTH1FXPLHZE6bTosX0= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/internal/database/interface.go b/internal/database/interface.go new file mode 100644 index 0000000..f9cd6dd --- /dev/null +++ b/internal/database/interface.go @@ -0,0 +1,88 @@ +// Package database provides database abstraction for storing NixOS options. +package database + +import ( + "context" + "time" +) + +// Revision represents an indexed nixpkgs revision. +type Revision struct { + ID int64 + GitHash string + ChannelName string + CommitDate time.Time + IndexedAt time.Time + OptionCount int +} + +// Option represents a NixOS configuration option. +type Option struct { + ID int64 + RevisionID int64 + Name string + ParentPath string + Type string + DefaultValue string // JSON text + Example string // JSON text + Description string + ReadOnly bool +} + +// Declaration represents a file where an option is declared. +type Declaration struct { + ID int64 + OptionID int64 + FilePath string + Line int +} + +// File represents a cached file from nixpkgs. +type File struct { + ID int64 + RevisionID int64 + FilePath string + Extension string + Content string +} + +// SearchFilters contains optional filters for option search. +type SearchFilters struct { + Type string + Namespace string + HasDefault *bool + Limit int + Offset int +} + +// Store defines the interface for database operations. +type Store interface { + // Schema operations + Initialize(ctx context.Context) error + Close() error + + // Revision operations + CreateRevision(ctx context.Context, rev *Revision) error + GetRevision(ctx context.Context, gitHash string) (*Revision, error) + GetRevisionByChannel(ctx context.Context, channel string) (*Revision, error) + ListRevisions(ctx context.Context) ([]*Revision, error) + DeleteRevision(ctx context.Context, id int64) error + UpdateRevisionOptionCount(ctx context.Context, id int64, count int) error + + // Option operations + CreateOption(ctx context.Context, opt *Option) error + CreateOptionsBatch(ctx context.Context, opts []*Option) error + GetOption(ctx context.Context, revisionID int64, name string) (*Option, error) + GetChildren(ctx context.Context, revisionID int64, parentPath string) ([]*Option, error) + SearchOptions(ctx context.Context, revisionID int64, query string, filters SearchFilters) ([]*Option, error) + + // Declaration operations + CreateDeclaration(ctx context.Context, decl *Declaration) error + CreateDeclarationsBatch(ctx context.Context, decls []*Declaration) error + GetDeclarations(ctx context.Context, optionID int64) ([]*Declaration, error) + + // File operations + CreateFile(ctx context.Context, file *File) error + CreateFilesBatch(ctx context.Context, files []*File) error + GetFile(ctx context.Context, revisionID int64, path string) (*File, error) +} diff --git a/internal/database/postgres.go b/internal/database/postgres.go new file mode 100644 index 0000000..8ba552d --- /dev/null +++ b/internal/database/postgres.go @@ -0,0 +1,473 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + + _ "github.com/lib/pq" +) + +// PostgresStore implements Store using PostgreSQL. +type PostgresStore struct { + db *sql.DB +} + +// NewPostgresStore creates a new PostgreSQL store. +func NewPostgresStore(connStr string) (*PostgresStore, error) { + db, err := sql.Open("postgres", connStr) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + if err := db.Ping(); err != nil { + db.Close() + return nil, fmt.Errorf("failed to ping database: %w", err) + } + + return &PostgresStore{db: db}, nil +} + +// Initialize creates or migrates the database schema. +func (s *PostgresStore) Initialize(ctx context.Context) error { + // Check current schema version + var version int + err := s.db.QueryRowContext(ctx, + "SELECT version FROM schema_info LIMIT 1").Scan(&version) + + needsRecreate := err != nil || version != SchemaVersion + + if needsRecreate { + // Drop all tables in correct order (respecting foreign keys) + dropStmts := []string{ + DropDeclarations, + DropOptions, + DropFiles, + DropRevisions, + DropSchemaInfo, + } + for _, stmt := range dropStmts { + if _, err := s.db.ExecContext(ctx, stmt); err != nil { + return fmt.Errorf("failed to drop table: %w", err) + } + } + } + + // Create tables + createStmts := []string{ + SchemaInfoTable, + // PostgreSQL uses SERIAL for auto-increment + `CREATE TABLE IF NOT EXISTS revisions ( + id SERIAL PRIMARY KEY, + git_hash TEXT NOT NULL UNIQUE, + channel_name TEXT, + commit_date TIMESTAMP, + indexed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + option_count INTEGER NOT NULL DEFAULT 0 + )`, + `CREATE TABLE IF NOT EXISTS options ( + id SERIAL PRIMARY KEY, + revision_id INTEGER NOT NULL REFERENCES revisions(id) ON DELETE CASCADE, + name TEXT NOT NULL, + parent_path TEXT NOT NULL, + type TEXT, + default_value TEXT, + example TEXT, + description TEXT, + read_only BOOLEAN NOT NULL DEFAULT FALSE + )`, + `CREATE TABLE IF NOT EXISTS declarations ( + id SERIAL PRIMARY KEY, + option_id INTEGER NOT NULL REFERENCES options(id) ON DELETE CASCADE, + file_path TEXT NOT NULL, + line INTEGER + )`, + `CREATE TABLE IF NOT EXISTS files ( + id SERIAL PRIMARY KEY, + revision_id INTEGER NOT NULL REFERENCES revisions(id) ON DELETE CASCADE, + file_path TEXT NOT NULL, + extension TEXT, + content TEXT NOT NULL + )`, + IndexOptionsRevisionName, + IndexOptionsRevisionParent, + IndexFilesRevisionPath, + IndexDeclarationsOption, + } + + for _, stmt := range createStmts { + if _, err := s.db.ExecContext(ctx, stmt); err != nil { + return fmt.Errorf("failed to create schema: %w", err) + } + } + + // Create full-text search index for PostgreSQL + _, err = s.db.ExecContext(ctx, ` + CREATE INDEX IF NOT EXISTS idx_options_fts + ON options USING GIN(to_tsvector('english', name || ' ' || COALESCE(description, ''))) + `) + if err != nil { + return fmt.Errorf("failed to create FTS index: %w", err) + } + + // Set schema version + if needsRecreate { + _, err = s.db.ExecContext(ctx, + "INSERT INTO schema_info (version) VALUES ($1)", SchemaVersion) + if err != nil { + return fmt.Errorf("failed to set schema version: %w", err) + } + } + + return nil +} + +// Close closes the database connection. +func (s *PostgresStore) Close() error { + return s.db.Close() +} + +// CreateRevision creates a new revision record. +func (s *PostgresStore) CreateRevision(ctx context.Context, rev *Revision) error { + err := s.db.QueryRowContext(ctx, ` + INSERT INTO revisions (git_hash, channel_name, commit_date, option_count) + VALUES ($1, $2, $3, $4) + RETURNING id, indexed_at`, + rev.GitHash, rev.ChannelName, rev.CommitDate, rev.OptionCount, + ).Scan(&rev.ID, &rev.IndexedAt) + if err != nil { + return fmt.Errorf("failed to create revision: %w", err) + } + return nil +} + +// GetRevision retrieves a revision by git hash. +func (s *PostgresStore) GetRevision(ctx context.Context, gitHash string) (*Revision, error) { + rev := &Revision{} + err := s.db.QueryRowContext(ctx, ` + SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count + FROM revisions WHERE git_hash = $1`, gitHash, + ).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to get revision: %w", err) + } + return rev, nil +} + +// GetRevisionByChannel retrieves a revision by channel name. +func (s *PostgresStore) GetRevisionByChannel(ctx context.Context, channel string) (*Revision, error) { + rev := &Revision{} + err := s.db.QueryRowContext(ctx, ` + SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count + FROM revisions WHERE channel_name = $1 + ORDER BY indexed_at DESC LIMIT 1`, channel, + ).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to get revision by channel: %w", err) + } + return rev, nil +} + +// ListRevisions returns all indexed revisions. +func (s *PostgresStore) ListRevisions(ctx context.Context) ([]*Revision, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count + FROM revisions ORDER BY indexed_at DESC`) + if err != nil { + return nil, fmt.Errorf("failed to list revisions: %w", err) + } + defer rows.Close() + + var revisions []*Revision + for rows.Next() { + rev := &Revision{} + if err := rows.Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount); err != nil { + return nil, fmt.Errorf("failed to scan revision: %w", err) + } + revisions = append(revisions, rev) + } + return revisions, rows.Err() +} + +// DeleteRevision removes a revision and all associated data. +func (s *PostgresStore) DeleteRevision(ctx context.Context, id int64) error { + _, err := s.db.ExecContext(ctx, "DELETE FROM revisions WHERE id = $1", id) + if err != nil { + return fmt.Errorf("failed to delete revision: %w", err) + } + return nil +} + +// UpdateRevisionOptionCount updates the option count for a revision. +func (s *PostgresStore) UpdateRevisionOptionCount(ctx context.Context, id int64, count int) error { + _, err := s.db.ExecContext(ctx, + "UPDATE revisions SET option_count = $1 WHERE id = $2", count, id) + if err != nil { + return fmt.Errorf("failed to update option count: %w", err) + } + return nil +} + +// CreateOption creates a new option record. +func (s *PostgresStore) CreateOption(ctx context.Context, opt *Option) error { + err := s.db.QueryRowContext(ctx, ` + INSERT INTO options (revision_id, name, parent_path, type, default_value, example, description, read_only) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id`, + opt.RevisionID, opt.Name, opt.ParentPath, opt.Type, opt.DefaultValue, opt.Example, opt.Description, opt.ReadOnly, + ).Scan(&opt.ID) + if err != nil { + return fmt.Errorf("failed to create option: %w", err) + } + return nil +} + +// CreateOptionsBatch creates multiple options in a batch. +func (s *PostgresStore) CreateOptionsBatch(ctx context.Context, opts []*Option) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + stmt, err := tx.PrepareContext(ctx, ` + INSERT INTO options (revision_id, name, parent_path, type, default_value, example, description, read_only) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id`) + if err != nil { + return fmt.Errorf("failed to prepare statement: %w", err) + } + defer stmt.Close() + + for _, opt := range opts { + err := stmt.QueryRowContext(ctx, + opt.RevisionID, opt.Name, opt.ParentPath, opt.Type, opt.DefaultValue, opt.Example, opt.Description, opt.ReadOnly, + ).Scan(&opt.ID) + if err != nil { + return fmt.Errorf("failed to insert option %s: %w", opt.Name, err) + } + } + + return tx.Commit() +} + +// GetOption retrieves an option by revision and name. +func (s *PostgresStore) GetOption(ctx context.Context, revisionID int64, name string) (*Option, error) { + opt := &Option{} + err := s.db.QueryRowContext(ctx, ` + SELECT id, revision_id, name, parent_path, type, default_value, example, description, read_only + FROM options WHERE revision_id = $1 AND name = $2`, revisionID, name, + ).Scan(&opt.ID, &opt.RevisionID, &opt.Name, &opt.ParentPath, &opt.Type, &opt.DefaultValue, &opt.Example, &opt.Description, &opt.ReadOnly) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to get option: %w", err) + } + return opt, nil +} + +// GetChildren retrieves direct children of an option. +func (s *PostgresStore) GetChildren(ctx context.Context, revisionID int64, parentPath string) ([]*Option, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, revision_id, name, parent_path, type, default_value, example, description, read_only + FROM options WHERE revision_id = $1 AND parent_path = $2 + ORDER BY name`, revisionID, parentPath) + if err != nil { + return nil, fmt.Errorf("failed to get children: %w", err) + } + defer rows.Close() + + var options []*Option + for rows.Next() { + opt := &Option{} + if err := rows.Scan(&opt.ID, &opt.RevisionID, &opt.Name, &opt.ParentPath, &opt.Type, &opt.DefaultValue, &opt.Example, &opt.Description, &opt.ReadOnly); err != nil { + return nil, fmt.Errorf("failed to scan option: %w", err) + } + options = append(options, opt) + } + return options, rows.Err() +} + +// SearchOptions searches for options matching a query. +func (s *PostgresStore) SearchOptions(ctx context.Context, revisionID int64, query string, filters SearchFilters) ([]*Option, error) { + // Use PostgreSQL full-text search + baseQuery := ` + SELECT id, revision_id, name, parent_path, type, default_value, example, description, read_only + FROM options + WHERE revision_id = $1 + AND to_tsvector('english', name || ' ' || COALESCE(description, '')) @@ plainto_tsquery('english', $2)` + + args := []interface{}{revisionID, query} + argNum := 3 + + if filters.Type != "" { + baseQuery += fmt.Sprintf(" AND type = $%d", argNum) + args = append(args, filters.Type) + argNum++ + } + + if filters.Namespace != "" { + baseQuery += fmt.Sprintf(" AND name LIKE $%d", argNum) + args = append(args, filters.Namespace+"%") + argNum++ + } + + if filters.HasDefault != nil { + if *filters.HasDefault { + baseQuery += " AND default_value IS NOT NULL" + } else { + baseQuery += " AND default_value IS NULL" + } + } + + baseQuery += " ORDER BY name" + + if filters.Limit > 0 { + baseQuery += fmt.Sprintf(" LIMIT %d", filters.Limit) + } + if filters.Offset > 0 { + baseQuery += fmt.Sprintf(" OFFSET %d", filters.Offset) + } + + rows, err := s.db.QueryContext(ctx, baseQuery, args...) + if err != nil { + return nil, fmt.Errorf("failed to search options: %w", err) + } + defer rows.Close() + + var options []*Option + for rows.Next() { + opt := &Option{} + if err := rows.Scan(&opt.ID, &opt.RevisionID, &opt.Name, &opt.ParentPath, &opt.Type, &opt.DefaultValue, &opt.Example, &opt.Description, &opt.ReadOnly); err != nil { + return nil, fmt.Errorf("failed to scan option: %w", err) + } + options = append(options, opt) + } + return options, rows.Err() +} + +// CreateDeclaration creates a new declaration record. +func (s *PostgresStore) CreateDeclaration(ctx context.Context, decl *Declaration) error { + err := s.db.QueryRowContext(ctx, ` + INSERT INTO declarations (option_id, file_path, line) + VALUES ($1, $2, $3) + RETURNING id`, + decl.OptionID, decl.FilePath, decl.Line, + ).Scan(&decl.ID) + if err != nil { + return fmt.Errorf("failed to create declaration: %w", err) + } + return nil +} + +// CreateDeclarationsBatch creates multiple declarations in a batch. +func (s *PostgresStore) CreateDeclarationsBatch(ctx context.Context, decls []*Declaration) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + stmt, err := tx.PrepareContext(ctx, ` + INSERT INTO declarations (option_id, file_path, line) + VALUES ($1, $2, $3) + RETURNING id`) + if err != nil { + return fmt.Errorf("failed to prepare statement: %w", err) + } + defer stmt.Close() + + for _, decl := range decls { + err := stmt.QueryRowContext(ctx, decl.OptionID, decl.FilePath, decl.Line).Scan(&decl.ID) + if err != nil { + return fmt.Errorf("failed to insert declaration: %w", err) + } + } + + return tx.Commit() +} + +// GetDeclarations retrieves declarations for an option. +func (s *PostgresStore) GetDeclarations(ctx context.Context, optionID int64) ([]*Declaration, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, option_id, file_path, line + FROM declarations WHERE option_id = $1`, optionID) + if err != nil { + return nil, fmt.Errorf("failed to get declarations: %w", err) + } + defer rows.Close() + + var decls []*Declaration + for rows.Next() { + decl := &Declaration{} + if err := rows.Scan(&decl.ID, &decl.OptionID, &decl.FilePath, &decl.Line); err != nil { + return nil, fmt.Errorf("failed to scan declaration: %w", err) + } + decls = append(decls, decl) + } + return decls, rows.Err() +} + +// CreateFile creates a new file record. +func (s *PostgresStore) CreateFile(ctx context.Context, file *File) error { + err := s.db.QueryRowContext(ctx, ` + INSERT INTO files (revision_id, file_path, extension, content) + VALUES ($1, $2, $3, $4) + RETURNING id`, + file.RevisionID, file.FilePath, file.Extension, file.Content, + ).Scan(&file.ID) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + return nil +} + +// CreateFilesBatch creates multiple files in a batch. +func (s *PostgresStore) CreateFilesBatch(ctx context.Context, files []*File) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + stmt, err := tx.PrepareContext(ctx, ` + INSERT INTO files (revision_id, file_path, extension, content) + VALUES ($1, $2, $3, $4) + RETURNING id`) + if err != nil { + return fmt.Errorf("failed to prepare statement: %w", err) + } + defer stmt.Close() + + for _, file := range files { + err := stmt.QueryRowContext(ctx, file.RevisionID, file.FilePath, file.Extension, file.Content).Scan(&file.ID) + if err != nil { + return fmt.Errorf("failed to insert file: %w", err) + } + } + + return tx.Commit() +} + +// GetFile retrieves a file by revision and path. +func (s *PostgresStore) GetFile(ctx context.Context, revisionID int64, path string) (*File, error) { + file := &File{} + err := s.db.QueryRowContext(ctx, ` + SELECT id, revision_id, file_path, extension, content + FROM files WHERE revision_id = $1 AND file_path = $2`, revisionID, path, + ).Scan(&file.ID, &file.RevisionID, &file.FilePath, &file.Extension, &file.Content) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to get file: %w", err) + } + return file, nil +} diff --git a/internal/database/schema.go b/internal/database/schema.go new file mode 100644 index 0000000..10f6d18 --- /dev/null +++ b/internal/database/schema.go @@ -0,0 +1,102 @@ +package database + +// SchemaVersion is the current database schema version. +// When this changes, the database will be dropped and recreated. +const SchemaVersion = 1 + +// Common SQL statements shared between implementations. +const ( + // SchemaInfoTable creates the schema version tracking table. + SchemaInfoTable = ` + CREATE TABLE IF NOT EXISTS schema_info ( + version INTEGER NOT NULL + )` + + // RevisionsTable creates the revisions table. + RevisionsTable = ` + CREATE TABLE IF NOT EXISTS revisions ( + id INTEGER PRIMARY KEY, + git_hash TEXT NOT NULL UNIQUE, + channel_name TEXT, + commit_date TIMESTAMP, + indexed_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + option_count INTEGER NOT NULL DEFAULT 0 + )` + + // OptionsTable creates the options table. + OptionsTable = ` + CREATE TABLE IF NOT EXISTS options ( + id INTEGER PRIMARY KEY, + revision_id INTEGER NOT NULL REFERENCES revisions(id) ON DELETE CASCADE, + name TEXT NOT NULL, + parent_path TEXT NOT NULL, + type TEXT, + default_value TEXT, + example TEXT, + description TEXT, + read_only BOOLEAN NOT NULL DEFAULT FALSE + )` + + // DeclarationsTable creates the declarations table. + DeclarationsTable = ` + CREATE TABLE IF NOT EXISTS declarations ( + id INTEGER PRIMARY KEY, + option_id INTEGER NOT NULL REFERENCES options(id) ON DELETE CASCADE, + file_path TEXT NOT NULL, + line INTEGER + )` + + // FilesTable creates the files table. + FilesTable = ` + CREATE TABLE IF NOT EXISTS files ( + id INTEGER PRIMARY KEY, + revision_id INTEGER NOT NULL REFERENCES revisions(id) ON DELETE CASCADE, + file_path TEXT NOT NULL, + extension TEXT, + content TEXT NOT NULL + )` +) + +// Index creation statements. +const ( + // IndexOptionsRevisionName creates an index on options(revision_id, name). + IndexOptionsRevisionName = ` + CREATE INDEX IF NOT EXISTS idx_options_revision_name + ON options(revision_id, name)` + + // IndexOptionsRevisionParent creates an index on options(revision_id, parent_path). + IndexOptionsRevisionParent = ` + CREATE INDEX IF NOT EXISTS idx_options_revision_parent + ON options(revision_id, parent_path)` + + // IndexFilesRevisionPath creates an index on files(revision_id, file_path). + IndexFilesRevisionPath = ` + CREATE UNIQUE INDEX IF NOT EXISTS idx_files_revision_path + ON files(revision_id, file_path)` + + // IndexDeclarationsOption creates an index on declarations(option_id). + IndexDeclarationsOption = ` + CREATE INDEX IF NOT EXISTS idx_declarations_option + ON declarations(option_id)` +) + +// Drop statements for schema recreation. +const ( + DropSchemaInfo = `DROP TABLE IF EXISTS schema_info` + DropDeclarations = `DROP TABLE IF EXISTS declarations` + DropOptions = `DROP TABLE IF EXISTS options` + DropFiles = `DROP TABLE IF EXISTS files` + DropRevisions = `DROP TABLE IF EXISTS revisions` +) + +// ParentPath extracts the parent path from an option name. +// For example, "services.nginx.enable" returns "services.nginx". +// Top-level options return an empty string. +func ParentPath(name string) string { + for i := len(name) - 1; i >= 0; i-- { + if name[i] == '.' { + return name[:i] + } + } + return "" +} diff --git a/internal/database/sqlite.go b/internal/database/sqlite.go new file mode 100644 index 0000000..1591640 --- /dev/null +++ b/internal/database/sqlite.go @@ -0,0 +1,507 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + + _ "modernc.org/sqlite" +) + +// SQLiteStore implements Store using SQLite. +type SQLiteStore struct { + db *sql.DB +} + +// NewSQLiteStore creates a new SQLite store. +func NewSQLiteStore(path string) (*SQLiteStore, error) { + db, err := sql.Open("sqlite", path) + if err != nil { + return nil, fmt.Errorf("failed to open database: %w", err) + } + + // Enable foreign keys + if _, err := db.Exec("PRAGMA foreign_keys = ON"); err != nil { + db.Close() + return nil, fmt.Errorf("failed to enable foreign keys: %w", err) + } + + return &SQLiteStore{db: db}, nil +} + +// Initialize creates or migrates the database schema. +func (s *SQLiteStore) Initialize(ctx context.Context) error { + // Check current schema version + var version int + err := s.db.QueryRowContext(ctx, + "SELECT version FROM schema_info LIMIT 1").Scan(&version) + + needsRecreate := err != nil || version != SchemaVersion + + if needsRecreate { + // Drop all tables in correct order (respecting foreign keys) + dropStmts := []string{ + DropDeclarations, + DropOptions, + DropFiles, + DropRevisions, + DropSchemaInfo, + "DROP TABLE IF EXISTS options_fts", + } + for _, stmt := range dropStmts { + if _, err := s.db.ExecContext(ctx, stmt); err != nil { + return fmt.Errorf("failed to drop table: %w", err) + } + } + } + + // Create tables (SQLite uses INTEGER PRIMARY KEY for auto-increment) + createStmts := []string{ + SchemaInfoTable, + RevisionsTable, + OptionsTable, + DeclarationsTable, + FilesTable, + IndexOptionsRevisionName, + IndexOptionsRevisionParent, + IndexFilesRevisionPath, + IndexDeclarationsOption, + } + + for _, stmt := range createStmts { + if _, err := s.db.ExecContext(ctx, stmt); err != nil { + return fmt.Errorf("failed to create schema: %w", err) + } + } + + // Create FTS5 virtual table for SQLite full-text search + _, err = s.db.ExecContext(ctx, ` + CREATE VIRTUAL TABLE IF NOT EXISTS options_fts USING fts5( + name, + description, + content='options', + content_rowid='id' + ) + `) + if err != nil { + return fmt.Errorf("failed to create FTS table: %w", err) + } + + // Create triggers to keep FTS in sync + triggers := []string{ + `CREATE TRIGGER IF NOT EXISTS options_ai AFTER INSERT ON options BEGIN + INSERT INTO options_fts(rowid, name, description) VALUES (new.id, new.name, new.description); + END`, + `CREATE TRIGGER IF NOT EXISTS options_ad AFTER DELETE ON options BEGIN + INSERT INTO options_fts(options_fts, rowid, name, description) VALUES('delete', old.id, old.name, old.description); + END`, + `CREATE TRIGGER IF NOT EXISTS options_au AFTER UPDATE ON options BEGIN + INSERT INTO options_fts(options_fts, rowid, name, description) VALUES('delete', old.id, old.name, old.description); + INSERT INTO options_fts(rowid, name, description) VALUES (new.id, new.name, new.description); + END`, + } + for _, trigger := range triggers { + if _, err := s.db.ExecContext(ctx, trigger); err != nil { + return fmt.Errorf("failed to create trigger: %w", err) + } + } + + // Set schema version + if needsRecreate { + _, err = s.db.ExecContext(ctx, + "INSERT INTO schema_info (version) VALUES (?)", SchemaVersion) + if err != nil { + return fmt.Errorf("failed to set schema version: %w", err) + } + } + + return nil +} + +// Close closes the database connection. +func (s *SQLiteStore) Close() error { + return s.db.Close() +} + +// CreateRevision creates a new revision record. +func (s *SQLiteStore) CreateRevision(ctx context.Context, rev *Revision) error { + result, err := s.db.ExecContext(ctx, ` + INSERT INTO revisions (git_hash, channel_name, commit_date, option_count) + VALUES (?, ?, ?, ?)`, + rev.GitHash, rev.ChannelName, rev.CommitDate, rev.OptionCount, + ) + if err != nil { + return fmt.Errorf("failed to create revision: %w", err) + } + + id, err := result.LastInsertId() + if err != nil { + return fmt.Errorf("failed to get last insert id: %w", err) + } + rev.ID = id + + // Fetch the indexed_at timestamp + err = s.db.QueryRowContext(ctx, + "SELECT indexed_at FROM revisions WHERE id = ?", id).Scan(&rev.IndexedAt) + if err != nil { + return fmt.Errorf("failed to get indexed_at: %w", err) + } + + return nil +} + +// GetRevision retrieves a revision by git hash. +func (s *SQLiteStore) GetRevision(ctx context.Context, gitHash string) (*Revision, error) { + rev := &Revision{} + err := s.db.QueryRowContext(ctx, ` + SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count + FROM revisions WHERE git_hash = ?`, gitHash, + ).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to get revision: %w", err) + } + return rev, nil +} + +// GetRevisionByChannel retrieves a revision by channel name. +func (s *SQLiteStore) GetRevisionByChannel(ctx context.Context, channel string) (*Revision, error) { + rev := &Revision{} + err := s.db.QueryRowContext(ctx, ` + SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count + FROM revisions WHERE channel_name = ? + ORDER BY indexed_at DESC LIMIT 1`, channel, + ).Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to get revision by channel: %w", err) + } + return rev, nil +} + +// ListRevisions returns all indexed revisions. +func (s *SQLiteStore) ListRevisions(ctx context.Context) ([]*Revision, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, git_hash, channel_name, commit_date, indexed_at, option_count + FROM revisions ORDER BY indexed_at DESC`) + if err != nil { + return nil, fmt.Errorf("failed to list revisions: %w", err) + } + defer rows.Close() + + var revisions []*Revision + for rows.Next() { + rev := &Revision{} + if err := rows.Scan(&rev.ID, &rev.GitHash, &rev.ChannelName, &rev.CommitDate, &rev.IndexedAt, &rev.OptionCount); err != nil { + return nil, fmt.Errorf("failed to scan revision: %w", err) + } + revisions = append(revisions, rev) + } + return revisions, rows.Err() +} + +// DeleteRevision removes a revision and all associated data. +func (s *SQLiteStore) DeleteRevision(ctx context.Context, id int64) error { + _, err := s.db.ExecContext(ctx, "DELETE FROM revisions WHERE id = ?", id) + if err != nil { + return fmt.Errorf("failed to delete revision: %w", err) + } + return nil +} + +// UpdateRevisionOptionCount updates the option count for a revision. +func (s *SQLiteStore) UpdateRevisionOptionCount(ctx context.Context, id int64, count int) error { + _, err := s.db.ExecContext(ctx, + "UPDATE revisions SET option_count = ? WHERE id = ?", count, id) + if err != nil { + return fmt.Errorf("failed to update option count: %w", err) + } + return nil +} + +// CreateOption creates a new option record. +func (s *SQLiteStore) CreateOption(ctx context.Context, opt *Option) error { + result, err := s.db.ExecContext(ctx, ` + INSERT INTO options (revision_id, name, parent_path, type, default_value, example, description, read_only) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, + opt.RevisionID, opt.Name, opt.ParentPath, opt.Type, opt.DefaultValue, opt.Example, opt.Description, opt.ReadOnly, + ) + if err != nil { + return fmt.Errorf("failed to create option: %w", err) + } + + id, err := result.LastInsertId() + if err != nil { + return fmt.Errorf("failed to get last insert id: %w", err) + } + opt.ID = id + return nil +} + +// CreateOptionsBatch creates multiple options in a batch. +func (s *SQLiteStore) CreateOptionsBatch(ctx context.Context, opts []*Option) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + stmt, err := tx.PrepareContext(ctx, ` + INSERT INTO options (revision_id, name, parent_path, type, default_value, example, description, read_only) + VALUES (?, ?, ?, ?, ?, ?, ?, ?)`) + if err != nil { + return fmt.Errorf("failed to prepare statement: %w", err) + } + defer stmt.Close() + + for _, opt := range opts { + result, err := stmt.ExecContext(ctx, + opt.RevisionID, opt.Name, opt.ParentPath, opt.Type, opt.DefaultValue, opt.Example, opt.Description, opt.ReadOnly, + ) + if err != nil { + return fmt.Errorf("failed to insert option %s: %w", opt.Name, err) + } + id, err := result.LastInsertId() + if err != nil { + return fmt.Errorf("failed to get last insert id: %w", err) + } + opt.ID = id + } + + return tx.Commit() +} + +// GetOption retrieves an option by revision and name. +func (s *SQLiteStore) GetOption(ctx context.Context, revisionID int64, name string) (*Option, error) { + opt := &Option{} + err := s.db.QueryRowContext(ctx, ` + SELECT id, revision_id, name, parent_path, type, default_value, example, description, read_only + FROM options WHERE revision_id = ? AND name = ?`, revisionID, name, + ).Scan(&opt.ID, &opt.RevisionID, &opt.Name, &opt.ParentPath, &opt.Type, &opt.DefaultValue, &opt.Example, &opt.Description, &opt.ReadOnly) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to get option: %w", err) + } + return opt, nil +} + +// GetChildren retrieves direct children of an option. +func (s *SQLiteStore) GetChildren(ctx context.Context, revisionID int64, parentPath string) ([]*Option, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, revision_id, name, parent_path, type, default_value, example, description, read_only + FROM options WHERE revision_id = ? AND parent_path = ? + ORDER BY name`, revisionID, parentPath) + if err != nil { + return nil, fmt.Errorf("failed to get children: %w", err) + } + defer rows.Close() + + var options []*Option + for rows.Next() { + opt := &Option{} + if err := rows.Scan(&opt.ID, &opt.RevisionID, &opt.Name, &opt.ParentPath, &opt.Type, &opt.DefaultValue, &opt.Example, &opt.Description, &opt.ReadOnly); err != nil { + return nil, fmt.Errorf("failed to scan option: %w", err) + } + options = append(options, opt) + } + return options, rows.Err() +} + +// SearchOptions searches for options matching a query. +func (s *SQLiteStore) SearchOptions(ctx context.Context, revisionID int64, query string, filters SearchFilters) ([]*Option, error) { + // Use SQLite FTS5 for full-text search + baseQuery := ` + SELECT o.id, o.revision_id, o.name, o.parent_path, o.type, o.default_value, o.example, o.description, o.read_only + FROM options o + INNER JOIN options_fts fts ON o.id = fts.rowid + WHERE o.revision_id = ? + AND options_fts MATCH ?` + + args := []interface{}{revisionID, query} + + if filters.Type != "" { + baseQuery += " AND o.type = ?" + args = append(args, filters.Type) + } + + if filters.Namespace != "" { + baseQuery += " AND o.name LIKE ?" + args = append(args, filters.Namespace+"%") + } + + if filters.HasDefault != nil { + if *filters.HasDefault { + baseQuery += " AND o.default_value IS NOT NULL" + } else { + baseQuery += " AND o.default_value IS NULL" + } + } + + baseQuery += " ORDER BY o.name" + + if filters.Limit > 0 { + baseQuery += fmt.Sprintf(" LIMIT %d", filters.Limit) + } + if filters.Offset > 0 { + baseQuery += fmt.Sprintf(" OFFSET %d", filters.Offset) + } + + rows, err := s.db.QueryContext(ctx, baseQuery, args...) + if err != nil { + return nil, fmt.Errorf("failed to search options: %w", err) + } + defer rows.Close() + + var options []*Option + for rows.Next() { + opt := &Option{} + if err := rows.Scan(&opt.ID, &opt.RevisionID, &opt.Name, &opt.ParentPath, &opt.Type, &opt.DefaultValue, &opt.Example, &opt.Description, &opt.ReadOnly); err != nil { + return nil, fmt.Errorf("failed to scan option: %w", err) + } + options = append(options, opt) + } + return options, rows.Err() +} + +// CreateDeclaration creates a new declaration record. +func (s *SQLiteStore) CreateDeclaration(ctx context.Context, decl *Declaration) error { + result, err := s.db.ExecContext(ctx, ` + INSERT INTO declarations (option_id, file_path, line) + VALUES (?, ?, ?)`, + decl.OptionID, decl.FilePath, decl.Line, + ) + if err != nil { + return fmt.Errorf("failed to create declaration: %w", err) + } + + id, err := result.LastInsertId() + if err != nil { + return fmt.Errorf("failed to get last insert id: %w", err) + } + decl.ID = id + return nil +} + +// CreateDeclarationsBatch creates multiple declarations in a batch. +func (s *SQLiteStore) CreateDeclarationsBatch(ctx context.Context, decls []*Declaration) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + stmt, err := tx.PrepareContext(ctx, ` + INSERT INTO declarations (option_id, file_path, line) + VALUES (?, ?, ?)`) + if err != nil { + return fmt.Errorf("failed to prepare statement: %w", err) + } + defer stmt.Close() + + for _, decl := range decls { + result, err := stmt.ExecContext(ctx, decl.OptionID, decl.FilePath, decl.Line) + if err != nil { + return fmt.Errorf("failed to insert declaration: %w", err) + } + id, err := result.LastInsertId() + if err != nil { + return fmt.Errorf("failed to get last insert id: %w", err) + } + decl.ID = id + } + + return tx.Commit() +} + +// GetDeclarations retrieves declarations for an option. +func (s *SQLiteStore) GetDeclarations(ctx context.Context, optionID int64) ([]*Declaration, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT id, option_id, file_path, line + FROM declarations WHERE option_id = ?`, optionID) + if err != nil { + return nil, fmt.Errorf("failed to get declarations: %w", err) + } + defer rows.Close() + + var decls []*Declaration + for rows.Next() { + decl := &Declaration{} + if err := rows.Scan(&decl.ID, &decl.OptionID, &decl.FilePath, &decl.Line); err != nil { + return nil, fmt.Errorf("failed to scan declaration: %w", err) + } + decls = append(decls, decl) + } + return decls, rows.Err() +} + +// CreateFile creates a new file record. +func (s *SQLiteStore) CreateFile(ctx context.Context, file *File) error { + result, err := s.db.ExecContext(ctx, ` + INSERT INTO files (revision_id, file_path, extension, content) + VALUES (?, ?, ?, ?)`, + file.RevisionID, file.FilePath, file.Extension, file.Content, + ) + if err != nil { + return fmt.Errorf("failed to create file: %w", err) + } + + id, err := result.LastInsertId() + if err != nil { + return fmt.Errorf("failed to get last insert id: %w", err) + } + file.ID = id + return nil +} + +// CreateFilesBatch creates multiple files in a batch. +func (s *SQLiteStore) CreateFilesBatch(ctx context.Context, files []*File) error { + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return fmt.Errorf("failed to begin transaction: %w", err) + } + defer tx.Rollback() + + stmt, err := tx.PrepareContext(ctx, ` + INSERT INTO files (revision_id, file_path, extension, content) + VALUES (?, ?, ?, ?)`) + if err != nil { + return fmt.Errorf("failed to prepare statement: %w", err) + } + defer stmt.Close() + + for _, file := range files { + result, err := stmt.ExecContext(ctx, file.RevisionID, file.FilePath, file.Extension, file.Content) + if err != nil { + return fmt.Errorf("failed to insert file: %w", err) + } + id, err := result.LastInsertId() + if err != nil { + return fmt.Errorf("failed to get last insert id: %w", err) + } + file.ID = id + } + + return tx.Commit() +} + +// GetFile retrieves a file by revision and path. +func (s *SQLiteStore) GetFile(ctx context.Context, revisionID int64, path string) (*File, error) { + file := &File{} + err := s.db.QueryRowContext(ctx, ` + SELECT id, revision_id, file_path, extension, content + FROM files WHERE revision_id = ? AND file_path = ?`, revisionID, path, + ).Scan(&file.ID, &file.RevisionID, &file.FilePath, &file.Extension, &file.Content) + if err == sql.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("failed to get file: %w", err) + } + return file, nil +} diff --git a/internal/mcp/server.go b/internal/mcp/server.go new file mode 100644 index 0000000..fbb8e0a --- /dev/null +++ b/internal/mcp/server.go @@ -0,0 +1,329 @@ +package mcp + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "log" + + "git.t-juice.club/torjus/labmcp/internal/database" +) + +// Server is an MCP server that handles JSON-RPC requests over stdio. +type Server struct { + store database.Store + tools map[string]ToolHandler + initialized bool + logger *log.Logger +} + +// ToolHandler is a function that handles a tool call. +type ToolHandler func(ctx context.Context, args map[string]interface{}) (CallToolResult, error) + +// NewServer creates a new MCP server. +func NewServer(store database.Store, logger *log.Logger) *Server { + if logger == nil { + logger = log.New(io.Discard, "", 0) + } + s := &Server{ + store: store, + tools: make(map[string]ToolHandler), + logger: logger, + } + s.registerTools() + return s +} + +// registerTools registers all available tools. +func (s *Server) registerTools() { + // Tools will be implemented in handlers.go +} + +// Run starts the server, reading from r and writing to w. +func (s *Server) Run(ctx context.Context, r io.Reader, w io.Writer) error { + scanner := bufio.NewScanner(r) + encoder := json.NewEncoder(w) + + for scanner.Scan() { + select { + case <-ctx.Done(): + return ctx.Err() + default: + } + + line := scanner.Bytes() + if len(line) == 0 { + continue + } + + var req Request + if err := json.Unmarshal(line, &req); err != nil { + s.logger.Printf("Failed to parse request: %v", err) + resp := Response{ + JSONRPC: "2.0", + Error: &Error{ + Code: ParseError, + Message: "Parse error", + Data: err.Error(), + }, + } + if err := encoder.Encode(resp); err != nil { + return fmt.Errorf("failed to write response: %w", err) + } + continue + } + + resp := s.handleRequest(ctx, &req) + if resp != nil { + if err := encoder.Encode(resp); err != nil { + return fmt.Errorf("failed to write response: %w", err) + } + } + } + + if err := scanner.Err(); err != nil { + return fmt.Errorf("scanner error: %w", err) + } + + return nil +} + +// handleRequest processes a single request and returns a response. +func (s *Server) handleRequest(ctx context.Context, req *Request) *Response { + s.logger.Printf("Received request: method=%s id=%v", req.Method, req.ID) + + switch req.Method { + case MethodInitialize: + return s.handleInitialize(req) + case MethodInitialized: + // This is a notification, no response needed + s.initialized = true + return nil + case MethodToolsList: + return s.handleToolsList(req) + case MethodToolsCall: + return s.handleToolsCall(ctx, req) + default: + return &Response{ + JSONRPC: "2.0", + ID: req.ID, + Error: &Error{ + Code: MethodNotFound, + Message: "Method not found", + Data: req.Method, + }, + } + } +} + +// handleInitialize processes the initialize request. +func (s *Server) handleInitialize(req *Request) *Response { + var params InitializeParams + if req.Params != nil { + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return &Response{ + JSONRPC: "2.0", + ID: req.ID, + Error: &Error{ + Code: InvalidParams, + Message: "Invalid params", + Data: err.Error(), + }, + } + } + } + + s.logger.Printf("Client: %s %s, protocol: %s", + params.ClientInfo.Name, params.ClientInfo.Version, params.ProtocolVersion) + + result := InitializeResult{ + ProtocolVersion: ProtocolVersion, + Capabilities: Capabilities{ + Tools: &ToolsCapability{ + ListChanged: false, + }, + }, + ServerInfo: Implementation{ + Name: "nixos-options", + Version: "0.1.0", + }, + } + + return &Response{ + JSONRPC: "2.0", + ID: req.ID, + Result: result, + } +} + +// handleToolsList returns the list of available tools. +func (s *Server) handleToolsList(req *Request) *Response { + tools := s.getToolDefinitions() + return &Response{ + JSONRPC: "2.0", + ID: req.ID, + Result: ListToolsResult{Tools: tools}, + } +} + +// getToolDefinitions returns the tool definitions. +func (s *Server) getToolDefinitions() []Tool { + return []Tool{ + { + Name: "search_options", + Description: "Search for NixOS configuration options by name or description", + InputSchema: InputSchema{ + Type: "object", + Properties: map[string]Property{ + "query": { + Type: "string", + Description: "Search query (matches option names and descriptions)", + }, + "revision": { + Type: "string", + Description: "Git hash or channel name (e.g., 'nixos-unstable'). Uses default if not specified.", + }, + "type": { + Type: "string", + Description: "Filter by option type (e.g., 'boolean', 'string', 'list')", + }, + "namespace": { + Type: "string", + Description: "Filter by namespace prefix (e.g., 'services.nginx')", + }, + "limit": { + Type: "integer", + Description: "Maximum number of results (default: 50)", + Default: 50, + }, + }, + Required: []string{"query"}, + }, + }, + { + Name: "get_option", + Description: "Get full details for a specific NixOS option including its children", + InputSchema: InputSchema{ + Type: "object", + Properties: map[string]Property{ + "name": { + Type: "string", + Description: "Full option path (e.g., 'services.nginx.enable')", + }, + "revision": { + Type: "string", + Description: "Git hash or channel name. Uses default if not specified.", + }, + "include_children": { + Type: "boolean", + Description: "Include direct children of this option (default: true)", + Default: true, + }, + }, + Required: []string{"name"}, + }, + }, + { + Name: "get_file", + Description: "Fetch the contents of a file from nixpkgs", + InputSchema: InputSchema{ + Type: "object", + Properties: map[string]Property{ + "path": { + Type: "string", + Description: "File path relative to nixpkgs root (e.g., 'nixos/modules/services/web-servers/nginx/default.nix')", + }, + "revision": { + Type: "string", + Description: "Git hash or channel name. Uses default if not specified.", + }, + }, + Required: []string{"path"}, + }, + }, + { + Name: "index_revision", + Description: "Index a nixpkgs revision to make its options searchable", + InputSchema: InputSchema{ + Type: "object", + Properties: map[string]Property{ + "revision": { + Type: "string", + Description: "Git hash (full or short) or channel name (e.g., 'nixos-unstable', 'nixos-24.05')", + }, + }, + Required: []string{"revision"}, + }, + }, + { + Name: "list_revisions", + Description: "List all indexed nixpkgs revisions", + InputSchema: InputSchema{ + Type: "object", + Properties: map[string]Property{}, + }, + }, + { + Name: "delete_revision", + Description: "Delete an indexed revision and all its data", + InputSchema: InputSchema{ + Type: "object", + Properties: map[string]Property{ + "revision": { + Type: "string", + Description: "Git hash or channel name of the revision to delete", + }, + }, + Required: []string{"revision"}, + }, + }, + } +} + +// handleToolsCall handles a tool invocation. +func (s *Server) handleToolsCall(ctx context.Context, req *Request) *Response { + var params CallToolParams + if err := json.Unmarshal(req.Params, ¶ms); err != nil { + return &Response{ + JSONRPC: "2.0", + ID: req.ID, + Error: &Error{ + Code: InvalidParams, + Message: "Invalid params", + Data: err.Error(), + }, + } + } + + s.logger.Printf("Tool call: %s with args %v", params.Name, params.Arguments) + + handler, ok := s.tools[params.Name] + if !ok { + return &Response{ + JSONRPC: "2.0", + ID: req.ID, + Result: CallToolResult{ + Content: []Content{TextContent(fmt.Sprintf("Unknown tool: %s", params.Name))}, + IsError: true, + }, + } + } + + result, err := handler(ctx, params.Arguments) + if err != nil { + s.logger.Printf("Tool error: %v", err) + return &Response{ + JSONRPC: "2.0", + ID: req.ID, + Result: ErrorContent(err), + } + } + + return &Response{ + JSONRPC: "2.0", + ID: req.ID, + Result: result, + } +} diff --git a/internal/mcp/types.go b/internal/mcp/types.go new file mode 100644 index 0000000..6cd9674 --- /dev/null +++ b/internal/mcp/types.go @@ -0,0 +1,139 @@ +// Package mcp implements the Model Context Protocol (MCP) over JSON-RPC. +package mcp + +import "encoding/json" + +// JSON-RPC 2.0 types + +// Request represents a JSON-RPC request. +type Request struct { + JSONRPC string `json:"jsonrpc"` + ID interface{} `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +// Response represents a JSON-RPC response. +type Response struct { + JSONRPC string `json:"jsonrpc"` + ID interface{} `json:"id,omitempty"` + Result interface{} `json:"result,omitempty"` + Error *Error `json:"error,omitempty"` +} + +// Error represents a JSON-RPC error. +type Error struct { + Code int `json:"code"` + Message string `json:"message"` + Data interface{} `json:"data,omitempty"` +} + +// Standard JSON-RPC error codes +const ( + ParseError = -32700 + InvalidRequest = -32600 + MethodNotFound = -32601 + InvalidParams = -32602 + InternalError = -32603 +) + +// MCP Protocol types + +// InitializeParams are sent by the client during initialization. +type InitializeParams struct { + ProtocolVersion string `json:"protocolVersion"` + Capabilities Capabilities `json:"capabilities"` + ClientInfo Implementation `json:"clientInfo"` +} + +// InitializeResult is returned after successful initialization. +type InitializeResult struct { + ProtocolVersion string `json:"protocolVersion"` + Capabilities Capabilities `json:"capabilities"` + ServerInfo Implementation `json:"serverInfo"` +} + +// Capabilities describes client or server capabilities. +type Capabilities struct { + Tools *ToolsCapability `json:"tools,omitempty"` +} + +// ToolsCapability describes tool-related capabilities. +type ToolsCapability struct { + ListChanged bool `json:"listChanged,omitempty"` +} + +// Implementation describes a client or server implementation. +type Implementation struct { + Name string `json:"name"` + Version string `json:"version"` +} + +// Tool describes an MCP tool. +type Tool struct { + Name string `json:"name"` + Description string `json:"description,omitempty"` + InputSchema InputSchema `json:"inputSchema"` +} + +// InputSchema describes the JSON Schema for tool inputs. +type InputSchema struct { + Type string `json:"type"` + Properties map[string]Property `json:"properties,omitempty"` + Required []string `json:"required,omitempty"` +} + +// Property describes a single property in an input schema. +type Property struct { + Type string `json:"type"` + Description string `json:"description,omitempty"` + Enum []string `json:"enum,omitempty"` + Default any `json:"default,omitempty"` +} + +// ListToolsResult is returned by tools/list. +type ListToolsResult struct { + Tools []Tool `json:"tools"` +} + +// CallToolParams are sent when calling a tool. +type CallToolParams struct { + Name string `json:"name"` + Arguments map[string]interface{} `json:"arguments,omitempty"` +} + +// CallToolResult is returned after calling a tool. +type CallToolResult struct { + Content []Content `json:"content"` + IsError bool `json:"isError,omitempty"` +} + +// Content represents a piece of content in a tool result. +type Content struct { + Type string `json:"type"` + Text string `json:"text,omitempty"` +} + +// TextContent creates a text content item. +func TextContent(text string) Content { + return Content{Type: "text", Text: text} +} + +// ErrorContent creates an error content item. +func ErrorContent(err error) CallToolResult { + return CallToolResult{ + Content: []Content{TextContent(err.Error())}, + IsError: true, + } +} + +// MCP method names +const ( + MethodInitialize = "initialize" + MethodInitialized = "notifications/initialized" + MethodToolsList = "tools/list" + MethodToolsCall = "tools/call" +) + +// Protocol version +const ProtocolVersion = "2024-11-05" diff --git a/internal/nixos/types.go b/internal/nixos/types.go new file mode 100644 index 0000000..7879b92 --- /dev/null +++ b/internal/nixos/types.go @@ -0,0 +1,45 @@ +// Package nixos contains types and logic specific to NixOS options. +package nixos + +// RawOption represents an option as parsed from options.json. +// The structure matches the output of `nix-build '' -A options`. +type RawOption struct { + Declarations []string `json:"declarations"` + Default *OptionValue `json:"default,omitempty"` + Description string `json:"description"` + Example *OptionValue `json:"example,omitempty"` + ReadOnly bool `json:"readOnly"` + Type string `json:"type"` + Loc []string `json:"loc,omitempty"` +} + +// OptionValue wraps a value that may be a literal or a Nix expression. +type OptionValue struct { + // Text is the raw JSON representation of the value + Text string +} + +// OptionsFile represents the top-level structure of options.json. +// It's a map from option name to option definition. +type OptionsFile map[string]RawOption + +// 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, +} + +// ChannelAliases maps friendly channel names to their git branch/ref patterns. +var ChannelAliases = map[string]string{ + "nixos-unstable": "nixos-unstable", + "nixos-stable": "nixos-24.11", // Update this as new stable releases come out + "nixos-24.11": "nixos-24.11", + "nixos-24.05": "nixos-24.05", + "nixos-23.11": "nixos-23.11", + "nixos-23.05": "nixos-23.05", +}