From d9aab773c62a1b7a45bcecabc30ea41f5db35e65 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torjus=20H=C3=A5kestad?= Date: Wed, 4 Feb 2026 01:30:39 +0100 Subject: [PATCH 1/4] feat(database): add file size metadata and range parameters - Add byte_size and line_count columns to files table - Increment SchemaVersion to 2 (requires re-indexing) - Add DeclarationWithMetadata, FileRange, FileResult types - Add GetDeclarationsWithMetadata method for file metadata lookup - Add GetFileWithRange method for paginated file retrieval - Implement countLines and applyLineRange helpers Co-Authored-By: Claude Opus 4.5 --- internal/database/interface.go | 26 ++++++ internal/database/postgres.go | 74 +++++++++++++++--- internal/database/schema.go | 6 +- internal/database/sqlite.go | 139 +++++++++++++++++++++++++++++++-- 4 files changed, 226 insertions(+), 19 deletions(-) diff --git a/internal/database/interface.go b/internal/database/interface.go index f9cd6dd..397c018 100644 --- a/internal/database/interface.go +++ b/internal/database/interface.go @@ -44,6 +44,30 @@ type File struct { FilePath string Extension string Content string + ByteSize int + LineCount int +} + +// DeclarationWithMetadata includes declaration info plus file metadata. +type DeclarationWithMetadata struct { + Declaration + ByteSize int // File size in bytes, 0 if file not indexed + LineCount int // Number of lines, 0 if file not indexed + HasFile bool // True if file is indexed +} + +// FileRange specifies a range of lines to return from a file. +type FileRange struct { + Offset int // Line offset (0-based) + Limit int // Maximum lines to return (0 = default 250) +} + +// FileResult contains a file with range metadata. +type FileResult struct { + *File + TotalLines int // Total lines in the file + StartLine int // First line returned (1-based) + EndLine int // Last line returned (1-based) } // SearchFilters contains optional filters for option search. @@ -80,9 +104,11 @@ type Store interface { CreateDeclaration(ctx context.Context, decl *Declaration) error CreateDeclarationsBatch(ctx context.Context, decls []*Declaration) error GetDeclarations(ctx context.Context, optionID int64) ([]*Declaration, error) + GetDeclarationsWithMetadata(ctx context.Context, revisionID, optionID int64) ([]*DeclarationWithMetadata, 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) + GetFileWithRange(ctx context.Context, revisionID int64, path string, r FileRange) (*FileResult, error) } diff --git a/internal/database/postgres.go b/internal/database/postgres.go index 2cde849..765cc9f 100644 --- a/internal/database/postgres.go +++ b/internal/database/postgres.go @@ -88,7 +88,9 @@ func (s *PostgresStore) Initialize(ctx context.Context) error { revision_id INTEGER NOT NULL REFERENCES revisions(id) ON DELETE CASCADE, file_path TEXT NOT NULL, extension TEXT, - content TEXT NOT NULL + content TEXT NOT NULL, + byte_size INTEGER NOT NULL DEFAULT 0, + line_count INTEGER NOT NULL DEFAULT 0 )`, IndexOptionsRevisionName, IndexOptionsRevisionParent, @@ -432,11 +434,19 @@ func (s *PostgresStore) GetDeclarations(ctx context.Context, optionID int64) ([] // CreateFile creates a new file record. func (s *PostgresStore) CreateFile(ctx context.Context, file *File) error { + // Compute metadata if not already set + if file.ByteSize == 0 { + file.ByteSize = len(file.Content) + } + if file.LineCount == 0 { + file.LineCount = countLines(file.Content) + } + err := s.db.QueryRowContext(ctx, ` - INSERT INTO files (revision_id, file_path, extension, content) - VALUES ($1, $2, $3, $4) + INSERT INTO files (revision_id, file_path, extension, content, byte_size, line_count) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`, - file.RevisionID, file.FilePath, file.Extension, file.Content, + file.RevisionID, file.FilePath, file.Extension, file.Content, file.ByteSize, file.LineCount, ).Scan(&file.ID) if err != nil { return fmt.Errorf("failed to create file: %w", err) @@ -453,8 +463,8 @@ func (s *PostgresStore) CreateFilesBatch(ctx context.Context, files []*File) err defer tx.Rollback() stmt, err := tx.PrepareContext(ctx, ` - INSERT INTO files (revision_id, file_path, extension, content) - VALUES ($1, $2, $3, $4) + INSERT INTO files (revision_id, file_path, extension, content, byte_size, line_count) + VALUES ($1, $2, $3, $4, $5, $6) RETURNING id`) if err != nil { return fmt.Errorf("failed to prepare statement: %w", err) @@ -462,7 +472,15 @@ func (s *PostgresStore) CreateFilesBatch(ctx context.Context, files []*File) err defer stmt.Close() for _, file := range files { - err := stmt.QueryRowContext(ctx, file.RevisionID, file.FilePath, file.Extension, file.Content).Scan(&file.ID) + // Compute metadata if not already set + if file.ByteSize == 0 { + file.ByteSize = len(file.Content) + } + if file.LineCount == 0 { + file.LineCount = countLines(file.Content) + } + + err := stmt.QueryRowContext(ctx, file.RevisionID, file.FilePath, file.Extension, file.Content, file.ByteSize, file.LineCount).Scan(&file.ID) if err != nil { return fmt.Errorf("failed to insert file: %w", err) } @@ -475,9 +493,9 @@ func (s *PostgresStore) CreateFilesBatch(ctx context.Context, files []*File) err 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 + SELECT id, revision_id, file_path, extension, content, byte_size, line_count FROM files WHERE revision_id = $1 AND file_path = $2`, revisionID, path, - ).Scan(&file.ID, &file.RevisionID, &file.FilePath, &file.Extension, &file.Content) + ).Scan(&file.ID, &file.RevisionID, &file.FilePath, &file.Extension, &file.Content, &file.ByteSize, &file.LineCount) if err == sql.ErrNoRows { return nil, nil } @@ -486,3 +504,41 @@ func (s *PostgresStore) GetFile(ctx context.Context, revisionID int64, path stri } return file, nil } + +// GetDeclarationsWithMetadata retrieves declarations with file metadata for an option. +func (s *PostgresStore) GetDeclarationsWithMetadata(ctx context.Context, revisionID, optionID int64) ([]*DeclarationWithMetadata, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT d.id, d.option_id, d.file_path, d.line, + COALESCE(f.byte_size, 0), COALESCE(f.line_count, 0), (f.id IS NOT NULL) + FROM declarations d + LEFT JOIN files f ON f.revision_id = $1 AND f.file_path = d.file_path + WHERE d.option_id = $2`, revisionID, optionID) + if err != nil { + return nil, fmt.Errorf("failed to get declarations with metadata: %w", err) + } + defer rows.Close() + + var decls []*DeclarationWithMetadata + for rows.Next() { + decl := &DeclarationWithMetadata{} + if err := rows.Scan(&decl.ID, &decl.OptionID, &decl.FilePath, &decl.Line, + &decl.ByteSize, &decl.LineCount, &decl.HasFile); err != nil { + return nil, fmt.Errorf("failed to scan declaration: %w", err) + } + decls = append(decls, decl) + } + return decls, rows.Err() +} + +// GetFileWithRange retrieves a file with a specified line range. +func (s *PostgresStore) GetFileWithRange(ctx context.Context, revisionID int64, path string, r FileRange) (*FileResult, error) { + file, err := s.GetFile(ctx, revisionID, path) + if err != nil { + return nil, err + } + if file == nil { + return nil, nil + } + + return applyLineRange(file, r), nil +} diff --git a/internal/database/schema.go b/internal/database/schema.go index 10f6d18..aacfb3c 100644 --- a/internal/database/schema.go +++ b/internal/database/schema.go @@ -2,7 +2,7 @@ package database // SchemaVersion is the current database schema version. // When this changes, the database will be dropped and recreated. -const SchemaVersion = 1 +const SchemaVersion = 2 // Common SQL statements shared between implementations. const ( @@ -53,7 +53,9 @@ const ( revision_id INTEGER NOT NULL REFERENCES revisions(id) ON DELETE CASCADE, file_path TEXT NOT NULL, extension TEXT, - content TEXT NOT NULL + content TEXT NOT NULL, + byte_size INTEGER NOT NULL DEFAULT 0, + line_count INTEGER NOT NULL DEFAULT 0 )` ) diff --git a/internal/database/sqlite.go b/internal/database/sqlite.go index e2be387..a381d6e 100644 --- a/internal/database/sqlite.go +++ b/internal/database/sqlite.go @@ -470,10 +470,18 @@ func (s *SQLiteStore) GetDeclarations(ctx context.Context, optionID int64) ([]*D // CreateFile creates a new file record. func (s *SQLiteStore) CreateFile(ctx context.Context, file *File) error { + // Compute metadata if not already set + if file.ByteSize == 0 { + file.ByteSize = len(file.Content) + } + if file.LineCount == 0 { + file.LineCount = countLines(file.Content) + } + result, err := s.db.ExecContext(ctx, ` - INSERT INTO files (revision_id, file_path, extension, content) - VALUES (?, ?, ?, ?)`, - file.RevisionID, file.FilePath, file.Extension, file.Content, + INSERT INTO files (revision_id, file_path, extension, content, byte_size, line_count) + VALUES (?, ?, ?, ?, ?, ?)`, + file.RevisionID, file.FilePath, file.Extension, file.Content, file.ByteSize, file.LineCount, ) if err != nil { return fmt.Errorf("failed to create file: %w", err) @@ -496,15 +504,23 @@ func (s *SQLiteStore) CreateFilesBatch(ctx context.Context, files []*File) error defer tx.Rollback() stmt, err := tx.PrepareContext(ctx, ` - INSERT INTO files (revision_id, file_path, extension, content) - VALUES (?, ?, ?, ?)`) + INSERT INTO files (revision_id, file_path, extension, content, byte_size, line_count) + 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) + // Compute metadata if not already set + if file.ByteSize == 0 { + file.ByteSize = len(file.Content) + } + if file.LineCount == 0 { + file.LineCount = countLines(file.Content) + } + + result, err := stmt.ExecContext(ctx, file.RevisionID, file.FilePath, file.Extension, file.Content, file.ByteSize, file.LineCount) if err != nil { return fmt.Errorf("failed to insert file: %w", err) } @@ -522,9 +538,9 @@ func (s *SQLiteStore) CreateFilesBatch(ctx context.Context, files []*File) error 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 + SELECT id, revision_id, file_path, extension, content, byte_size, line_count FROM files WHERE revision_id = ? AND file_path = ?`, revisionID, path, - ).Scan(&file.ID, &file.RevisionID, &file.FilePath, &file.Extension, &file.Content) + ).Scan(&file.ID, &file.RevisionID, &file.FilePath, &file.Extension, &file.Content, &file.ByteSize, &file.LineCount) if err == sql.ErrNoRows { return nil, nil } @@ -533,3 +549,110 @@ func (s *SQLiteStore) GetFile(ctx context.Context, revisionID int64, path string } return file, nil } + +// GetDeclarationsWithMetadata retrieves declarations with file metadata for an option. +func (s *SQLiteStore) GetDeclarationsWithMetadata(ctx context.Context, revisionID, optionID int64) ([]*DeclarationWithMetadata, error) { + rows, err := s.db.QueryContext(ctx, ` + SELECT d.id, d.option_id, d.file_path, d.line, + COALESCE(f.byte_size, 0), COALESCE(f.line_count, 0), (f.id IS NOT NULL) + FROM declarations d + LEFT JOIN files f ON f.revision_id = ? AND f.file_path = d.file_path + WHERE d.option_id = ?`, revisionID, optionID) + if err != nil { + return nil, fmt.Errorf("failed to get declarations with metadata: %w", err) + } + defer rows.Close() + + var decls []*DeclarationWithMetadata + for rows.Next() { + decl := &DeclarationWithMetadata{} + if err := rows.Scan(&decl.ID, &decl.OptionID, &decl.FilePath, &decl.Line, + &decl.ByteSize, &decl.LineCount, &decl.HasFile); err != nil { + return nil, fmt.Errorf("failed to scan declaration: %w", err) + } + decls = append(decls, decl) + } + return decls, rows.Err() +} + +// GetFileWithRange retrieves a file with a specified line range. +func (s *SQLiteStore) GetFileWithRange(ctx context.Context, revisionID int64, path string, r FileRange) (*FileResult, error) { + file, err := s.GetFile(ctx, revisionID, path) + if err != nil { + return nil, err + } + if file == nil { + return nil, nil + } + + return applyLineRange(file, r), nil +} + +// countLines counts the number of lines in content. +func countLines(content string) int { + if content == "" { + return 0 + } + count := 1 + for _, c := range content { + if c == '\n' { + count++ + } + } + // Don't count trailing newline as extra line + if len(content) > 0 && content[len(content)-1] == '\n' { + count-- + } + return count +} + +// applyLineRange extracts a range of lines from a file. +func applyLineRange(file *File, r FileRange) *FileResult { + lines := strings.Split(file.Content, "\n") + totalLines := len(lines) + + // Handle trailing newline + if totalLines > 0 && lines[totalLines-1] == "" { + totalLines-- + lines = lines[:totalLines] + } + + // Apply defaults + offset := r.Offset + if offset < 0 { + offset = 0 + } + limit := r.Limit + if limit <= 0 { + limit = 250 // Default limit + } + + // Calculate range + startLine := offset + 1 // 1-based + if offset >= totalLines { + // Beyond end of file + return &FileResult{ + File: &File{ID: file.ID, RevisionID: file.RevisionID, FilePath: file.FilePath, Extension: file.Extension, Content: "", ByteSize: file.ByteSize, LineCount: file.LineCount}, + TotalLines: totalLines, + StartLine: 0, + EndLine: 0, + } + } + + endIdx := offset + limit + if endIdx > totalLines { + endIdx = totalLines + } + endLine := endIdx // 1-based (last line included) + + // Extract lines + selectedLines := lines[offset:endIdx] + content := strings.Join(selectedLines, "\n") + + return &FileResult{ + File: &File{ID: file.ID, RevisionID: file.RevisionID, FilePath: file.FilePath, Extension: file.Extension, Content: content, ByteSize: file.ByteSize, LineCount: file.LineCount}, + TotalLines: totalLines, + StartLine: startLine, + EndLine: endLine, + } +} -- 2.49.1 From b188ca5088d4e7817758d39f12382f5bec28b477 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torjus=20H=C3=A5kestad?= Date: Wed, 4 Feb 2026 01:30:45 +0100 Subject: [PATCH 2/4] feat(mcp): add offset/limit params and show file metadata in declarations - Add offset and limit parameters to get_file tool schema - Default limit is 250 lines, offset is 0 - Show "Showing lines X-Y of Z total" header when range is applied - Update handleGetOption to use GetDeclarationsWithMetadata - Display file size metadata (bytes, lines) in declarations output Co-Authored-By: Claude Opus 4.5 --- internal/mcp/handlers.go | 37 ++++++++++++++++++++++++++++++------- internal/mcp/server.go | 14 ++++++++++++-- 2 files changed, 42 insertions(+), 9 deletions(-) diff --git a/internal/mcp/handlers.go b/internal/mcp/handlers.go index 0a53c9f..43cadfc 100644 --- a/internal/mcp/handlers.go +++ b/internal/mcp/handlers.go @@ -103,8 +103,8 @@ func (s *Server) handleGetOption(ctx context.Context, args map[string]interface{ return ErrorContent(fmt.Errorf("option '%s' not found", name)), nil } - // Get declarations - declarations, err := s.store.GetDeclarations(ctx, option.ID) + // Get declarations with file metadata + declarations, err := s.store.GetDeclarationsWithMetadata(ctx, rev.ID, option.ID) if err != nil { s.logger.Printf("Failed to get declarations: %v", err) } @@ -134,10 +134,15 @@ func (s *Server) handleGetOption(ctx context.Context, args map[string]interface{ 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)) + sb.WriteString(fmt.Sprintf("- %s:%d", decl.FilePath, decl.Line)) } else { - sb.WriteString(fmt.Sprintf("- %s\n", decl.FilePath)) + sb.WriteString(fmt.Sprintf("- %s", decl.FilePath)) } + // Add file metadata if available + if decl.HasFile && decl.ByteSize > 0 { + sb.WriteString(fmt.Sprintf(" (%d bytes, %d lines)", decl.ByteSize, decl.LineCount)) + } + sb.WriteString("\n") } } @@ -199,16 +204,34 @@ func (s *Server) handleGetFile(ctx context.Context, args map[string]interface{}) return ErrorContent(fmt.Errorf("no indexed revision available")), nil } - file, err := s.store.GetFile(ctx, rev.ID, path) + // Parse range parameters + var offset, limit int + if o, ok := args["offset"].(float64); ok { + offset = int(o) + } + if l, ok := args["limit"].(float64); ok { + limit = int(l) + } + + // Use GetFileWithRange + fileRange := database.FileRange{Offset: offset, Limit: limit} + result, err := s.store.GetFileWithRange(ctx, rev.ID, path, fileRange) if err != nil { return ErrorContent(fmt.Errorf("failed to get file: %w", err)), nil } - if file == nil { + if result == nil { return ErrorContent(fmt.Errorf("file '%s' not found (files may not be indexed for this revision)", path)), nil } + // Format output with range metadata + var sb strings.Builder + if result.TotalLines > 0 && (result.StartLine > 1 || result.EndLine < result.TotalLines) { + sb.WriteString(fmt.Sprintf("Showing lines %d-%d of %d total\n\n", result.StartLine, result.EndLine, result.TotalLines)) + } + sb.WriteString(fmt.Sprintf("```%s\n%s\n```", strings.TrimPrefix(result.Extension, "."), result.Content)) + return CallToolResult{ - Content: []Content{TextContent(fmt.Sprintf("```%s\n%s\n```", strings.TrimPrefix(file.Extension, "."), file.Content))}, + Content: []Content{TextContent(sb.String())}, }, nil } diff --git a/internal/mcp/server.go b/internal/mcp/server.go index 1b2f3b8..9a066a2 100644 --- a/internal/mcp/server.go +++ b/internal/mcp/server.go @@ -28,7 +28,7 @@ type ServerConfig struct { func DefaultNixOSConfig() ServerConfig { return ServerConfig{ Name: "nixos-options", - Version: "0.1.1", + Version: "0.1.2", DefaultChannel: "nixos-stable", SourceName: "nixpkgs", Instructions: `NixOS Options MCP Server - Search and query NixOS configuration options. @@ -47,7 +47,7 @@ This ensures option documentation matches the nixpkgs version the project actual func DefaultHomeManagerConfig() ServerConfig { return ServerConfig{ Name: "hm-options", - Version: "0.1.1", + Version: "0.1.2", DefaultChannel: "hm-stable", SourceName: "home-manager", Instructions: `Home Manager Options MCP Server - Search and query Home Manager configuration options. @@ -291,6 +291,16 @@ func (s *Server) getToolDefinitions() []Tool { Type: "string", Description: "Git hash or channel name. Uses default if not specified.", }, + "offset": { + Type: "integer", + Description: "Line offset (0-based). Default: 0", + Default: 0, + }, + "limit": { + Type: "integer", + Description: "Maximum lines to return. Default: 250, use 0 for all lines", + Default: 250, + }, }, Required: []string{"path"}, }, -- 2.49.1 From 9252ddcfae8a6ca748cd54df37349bf4fd22f746 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torjus=20H=C3=A5kestad?= Date: Wed, 4 Feb 2026 01:30:49 +0100 Subject: [PATCH 3/4] test: add tests for file metadata and range parameters - testFileRange: test GetFileWithRange with various offset/limit values - testDeclarationsWithMetadata: test file metadata in declarations - Verify byte_size and line_count are computed correctly - Test edge cases: offset beyond EOF, non-indexed files Co-Authored-By: Claude Opus 4.5 --- internal/database/database_test.go | 173 +++++++++++++++++++++++++++++ 1 file changed, 173 insertions(+) diff --git a/internal/database/database_test.go b/internal/database/database_test.go index 11200cc..bd8c713 100644 --- a/internal/database/database_test.go +++ b/internal/database/database_test.go @@ -19,6 +19,8 @@ func runStoreTests(t *testing.T, newStore func(t *testing.T) Store) { {"OptionChildren", testOptionChildren}, {"Declarations", testDeclarations}, {"Files", testFiles}, + {"FileRange", testFileRange}, + {"DeclarationsWithMetadata", testDeclarationsWithMetadata}, {"SchemaVersion", testSchemaVersion}, } @@ -451,6 +453,14 @@ func testFiles(t *testing.T, store Store) { t.Errorf("Extension = %q, want .nix", got.Extension) } + // Verify file metadata was computed + if got.ByteSize != len(file.Content) { + t.Errorf("ByteSize = %d, want %d", got.ByteSize, len(file.Content)) + } + if got.LineCount != 3 { + t.Errorf("LineCount = %d, want 3", got.LineCount) + } + // Get non-existent file got, err = store.GetFile(ctx, rev.ID, "nonexistent.nix") if err != nil { @@ -476,6 +486,169 @@ func testFiles(t *testing.T, store Store) { } } +func testFileRange(t *testing.T, store Store) { + ctx := context.Background() + if err := store.Initialize(ctx); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + rev := &Revision{GitHash: "range123", ChannelName: "test"} + if err := store.CreateRevision(ctx, rev); err != nil { + t.Fatalf("CreateRevision failed: %v", err) + } + + // Create a multi-line file + content := "line 1\nline 2\nline 3\nline 4\nline 5\nline 6\nline 7\nline 8\nline 9\nline 10" + file := &File{ + RevisionID: rev.ID, + FilePath: "multiline.nix", + Extension: ".nix", + Content: content, + } + if err := store.CreateFile(ctx, file); err != nil { + t.Fatalf("CreateFile failed: %v", err) + } + + // Test default range (first 250 lines, but we have less) + result, err := store.GetFileWithRange(ctx, rev.ID, "multiline.nix", FileRange{}) + if err != nil { + t.Fatalf("GetFileWithRange default failed: %v", err) + } + if result == nil { + t.Fatal("Expected result, got nil") + } + if result.TotalLines != 10 { + t.Errorf("TotalLines = %d, want 10", result.TotalLines) + } + if result.StartLine != 1 { + t.Errorf("StartLine = %d, want 1", result.StartLine) + } + if result.EndLine != 10 { + t.Errorf("EndLine = %d, want 10", result.EndLine) + } + + // Test with offset + result, err = store.GetFileWithRange(ctx, rev.ID, "multiline.nix", FileRange{Offset: 2, Limit: 3}) + if err != nil { + t.Fatalf("GetFileWithRange with offset failed: %v", err) + } + if result.StartLine != 3 { + t.Errorf("StartLine = %d, want 3", result.StartLine) + } + if result.EndLine != 5 { + t.Errorf("EndLine = %d, want 5", result.EndLine) + } + if result.Content != "line 3\nline 4\nline 5" { + t.Errorf("Content = %q, want lines 3-5", result.Content) + } + + // Test offset beyond file + result, err = store.GetFileWithRange(ctx, rev.ID, "multiline.nix", FileRange{Offset: 100}) + if err != nil { + t.Fatalf("GetFileWithRange beyond end failed: %v", err) + } + if result.StartLine != 0 { + t.Errorf("StartLine = %d, want 0 for beyond end", result.StartLine) + } + if result.Content != "" { + t.Errorf("Content = %q, want empty for beyond end", result.Content) + } + + // Test non-existent file + result, err = store.GetFileWithRange(ctx, rev.ID, "nonexistent.nix", FileRange{}) + if err != nil { + t.Fatalf("GetFileWithRange for nonexistent failed: %v", err) + } + if result != nil { + t.Error("Expected nil for nonexistent file") + } +} + +func testDeclarationsWithMetadata(t *testing.T, store Store) { + ctx := context.Background() + if err := store.Initialize(ctx); err != nil { + t.Fatalf("Initialize failed: %v", err) + } + + rev := &Revision{GitHash: "metadata123", ChannelName: "test"} + if err := store.CreateRevision(ctx, rev); err != nil { + t.Fatalf("CreateRevision failed: %v", err) + } + + // Create a file + file := &File{ + RevisionID: rev.ID, + FilePath: "modules/nginx.nix", + Extension: ".nix", + Content: "line 1\nline 2\nline 3", + } + if err := store.CreateFile(ctx, file); err != nil { + t.Fatalf("CreateFile failed: %v", err) + } + + // Create an option with declarations + opt := &Option{ + RevisionID: rev.ID, + Name: "services.nginx.enable", + ParentPath: "services.nginx", + Type: "boolean", + } + if err := store.CreateOption(ctx, opt); err != nil { + t.Fatalf("CreateOption failed: %v", err) + } + + // Create declarations - one pointing to indexed file, one to non-indexed + decls := []*Declaration{ + {OptionID: opt.ID, FilePath: "modules/nginx.nix", Line: 10}, + {OptionID: opt.ID, FilePath: "modules/other.nix", Line: 20}, + } + if err := store.CreateDeclarationsBatch(ctx, decls); err != nil { + t.Fatalf("CreateDeclarationsBatch failed: %v", err) + } + + // Get declarations with metadata + declMetas, err := store.GetDeclarationsWithMetadata(ctx, rev.ID, opt.ID) + if err != nil { + t.Fatalf("GetDeclarationsWithMetadata failed: %v", err) + } + if len(declMetas) != 2 { + t.Fatalf("Expected 2 declarations, got %d", len(declMetas)) + } + + // Find the declaration for the indexed file + var indexed, notIndexed *DeclarationWithMetadata + for _, d := range declMetas { + if d.FilePath == "modules/nginx.nix" { + indexed = d + } else { + notIndexed = d + } + } + + if indexed == nil { + t.Fatal("Expected indexed declaration") + } + if !indexed.HasFile { + t.Error("Expected HasFile=true for indexed file") + } + if indexed.ByteSize != len(file.Content) { + t.Errorf("ByteSize = %d, want %d", indexed.ByteSize, len(file.Content)) + } + if indexed.LineCount != 3 { + t.Errorf("LineCount = %d, want 3", indexed.LineCount) + } + + if notIndexed == nil { + t.Fatal("Expected not-indexed declaration") + } + if notIndexed.HasFile { + t.Error("Expected HasFile=false for non-indexed file") + } + if notIndexed.ByteSize != 0 { + t.Errorf("ByteSize = %d, want 0 for non-indexed", notIndexed.ByteSize) + } +} + func testSchemaVersion(t *testing.T, store Store) { ctx := context.Background() -- 2.49.1 From c829dd28a999ec805597a1fcde36a759c5ee3c7b Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Torjus=20H=C3=A5kestad?= Date: Wed, 4 Feb 2026 01:30:53 +0100 Subject: [PATCH 4/4] chore: bump version to 0.1.2 Co-Authored-By: Claude Opus 4.5 --- cmd/hm-options/main.go | 2 +- cmd/nixos-options/main.go | 2 +- nix/package.nix | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/cmd/hm-options/main.go b/cmd/hm-options/main.go index a1fc846..24425ca 100644 --- a/cmd/hm-options/main.go +++ b/cmd/hm-options/main.go @@ -20,7 +20,7 @@ import ( const ( defaultDatabase = "sqlite://hm-options.db" - version = "0.1.1" + version = "0.1.2" ) func main() { diff --git a/cmd/nixos-options/main.go b/cmd/nixos-options/main.go index e27065e..5d035fc 100644 --- a/cmd/nixos-options/main.go +++ b/cmd/nixos-options/main.go @@ -19,7 +19,7 @@ import ( const ( defaultDatabase = "sqlite://nixos-options.db" - version = "0.1.1" + version = "0.1.2" ) func main() { diff --git a/nix/package.nix b/nix/package.nix index ce0f987..9925465 100644 --- a/nix/package.nix +++ b/nix/package.nix @@ -7,7 +7,7 @@ buildGoModule { inherit pname src; - version = "0.1.1"; + version = "0.1.2"; vendorHash = "sha256-D0KIxQC9ctIAaHBFTvkhBE06uOZwDUcIw8471Ug2doY="; -- 2.49.1