From f4d5f300cf0d6e6e4cce7d459abe2d107d7a2151 Mon Sep 17 00:00:00 2001 From: Kartikay Jainwal Date: Mon, 4 May 2026 10:04:32 +0100 Subject: [PATCH 1/5] feat: add pagination to search-content command Closes #125 --- cmd/search_content.go | 14 +++++ cmd/search_content_test.go | 6 ++ pkg/actions/search_content.go | 95 ++++++++++++++++++++++++++++-- pkg/actions/search_content_test.go | 88 +++++++++++++++++++++++++++ 4 files changed, 199 insertions(+), 4 deletions(-) diff --git a/cmd/search_content.go b/cmd/search_content.go index 92ffede..d9a8556 100644 --- a/cmd/search_content.go +++ b/cmd/search_content.go @@ -45,6 +45,16 @@ func buildSearchContentOptions(cmd *cobra.Command, vault obsidian.VaultManager, return actions.SearchContentOptions{}, err } + page, err := cmd.Flags().GetInt("page") + if err != nil { + return actions.SearchContentOptions{}, err + } + + pageSize, err := cmd.Flags().GetInt("page-size") + if err != nil { + return actions.SearchContentOptions{}, err + } + useEditor := resolveUseEditor(cmd, vault) return actions.SearchContentOptions{ @@ -54,6 +64,8 @@ func buildSearchContentOptions(cmd *cobra.Command, vault obsidian.VaultManager, Format: format, InteractiveTerminal: interactiveTerminal, Output: os.Stdout, + Page: page, + PageSize: pageSize, }, nil } @@ -66,5 +78,7 @@ func init() { searchContentCmd.Flags().BoolP("editor", "e", false, "open in editor instead of Obsidian") searchContentCmd.Flags().Bool("no-interactive", false, "disable interactive selection and print results to stdout") searchContentCmd.Flags().String("format", "text", "output format for non-interactive mode: text|json") + searchContentCmd.Flags().Int("page", 0, "page number for paginated results (enables pagination)") + searchContentCmd.Flags().Int("page-size", 0, "results per page, max 100 (default 25 when pagination is enabled)") rootCmd.AddCommand(searchContentCmd) } diff --git a/cmd/search_content_test.go b/cmd/search_content_test.go index 647d6f4..bbe7649 100644 --- a/cmd/search_content_test.go +++ b/cmd/search_content_test.go @@ -57,6 +57,8 @@ func newSearchContentOptionsTestCmd() *cobra.Command { c.Flags().BoolP("editor", "e", false, "") c.Flags().Bool("no-interactive", false, "") c.Flags().String("format", "text", "") + c.Flags().Int("page", 0, "") + c.Flags().Int("page-size", 0, "") return c } @@ -65,8 +67,12 @@ func TestSearchContentCommandFlagsWired(t *testing.T) { assert.NotNil(t, searchContentCmd.Flags().Lookup("format")) assert.NotNil(t, searchContentCmd.Flags().Lookup("editor")) assert.NotNil(t, searchContentCmd.Flags().Lookup("vault")) + assert.NotNil(t, searchContentCmd.Flags().Lookup("page")) + assert.NotNil(t, searchContentCmd.Flags().Lookup("page-size")) assert.Equal(t, "text", searchContentCmd.Flags().Lookup("format").DefValue) + assert.Equal(t, "0", searchContentCmd.Flags().Lookup("page").DefValue) + assert.Equal(t, "0", searchContentCmd.Flags().Lookup("page-size").DefValue) assert.Contains(t, searchContentCmd.Aliases, "sc") } diff --git a/pkg/actions/search_content.go b/pkg/actions/search_content.go index 8e7310d..36e8c67 100644 --- a/pkg/actions/search_content.go +++ b/pkg/actions/search_content.go @@ -24,6 +24,8 @@ type SearchContentOptions struct { Format string InteractiveTerminal bool Output io.Writer + Page int + PageSize int } type searchContentJSONMatch struct { @@ -33,6 +35,20 @@ type searchContentJSONMatch struct { MatchType string `json:"match_type"` } +type searchContentPaginatedJSON struct { + Page int `json:"page"` + PageSize int `json:"page_size"` + TotalResults int `json:"total_results"` + ReturnedResults int `json:"returned_results"` + HasMore bool `json:"has_more"` + Results []searchContentJSONMatch `json:"results"` +} + +const ( + defaultPageSize = 25 + maxPageSize = 100 +) + // SearchNotesContent preserves backward-compatible interactive behavior. func SearchNotesContent(vault obsidian.VaultManager, note obsidian.NoteManager, uri obsidian.UriManager, fuzzyFinder obsidian.FuzzyFinderManager, searchTerm string, useEditor bool) error { return SearchNotesContentWithOptions(vault, note, uri, fuzzyFinder, searchTerm, SearchContentOptions{ @@ -84,7 +100,7 @@ func SearchNotesContentWithOptions(vault obsidian.VaultManager, note obsidian.No } if nonInteractiveMode { - return printMatches(matches, searchTerm, format, output) + return printMatches(matches, searchTerm, format, output, options) } if len(matches) == 0 { @@ -151,18 +167,90 @@ func normalizeSearchContentFormat(format string) (string, error) { } } -func printMatches(matches []obsidian.NoteMatch, searchTerm string, format string, output io.Writer) error { +func paginateMatches(matches []obsidian.NoteMatch, options SearchContentOptions) ([]obsidian.NoteMatch, int, int, bool) { + page := options.Page + pageSize := options.PageSize + + if page <= 0 { + page = 1 + } + if pageSize <= 0 { + pageSize = defaultPageSize + } + if pageSize > maxPageSize { + pageSize = maxPageSize + } + + total := len(matches) + start := (page - 1) * pageSize + if start >= total { + return nil, page, pageSize, false + } + end := start + pageSize + if end > total { + end = total + } + return matches[start:end], page, pageSize, end < total +} + +func isPaginationRequested(options SearchContentOptions) bool { + return options.Page > 0 || options.PageSize > 0 +} + +func printMatches(matches []obsidian.NoteMatch, searchTerm string, format string, output io.Writer, options SearchContentOptions) error { + paginate := isPaginationRequested(options) + switch format { case searchContentFormatText: if len(matches) == 0 { fmt.Fprintf(os.Stderr, "No notes found containing '%s'\n", searchTerm) return nil } - for _, match := range matches { + + displayMatches := matches + if paginate { + var page, pageSize int + var hasMore bool + displayMatches, page, pageSize, hasMore = paginateMatches(matches, options) + _ = hasMore + for _, match := range displayMatches { + _, _ = fmt.Fprintln(output, formatMatchForList(match)) + } + total := len(matches) + totalPages := (total + pageSize - 1) / pageSize + _, _ = fmt.Fprintf(output, "-- Page %d/%d (%d of %d results) --\n", page, totalPages, len(displayMatches), total) + return nil + } + + for _, match := range displayMatches { _, _ = fmt.Fprintln(output, formatMatchForList(match)) } return nil case searchContentFormatJSON: + if paginate { + pageMatches, page, pageSize, hasMore := paginateMatches(matches, options) + result := make([]searchContentJSONMatch, 0, len(pageMatches)) + for _, match := range pageMatches { + result = append(result, searchContentJSONMatch{ + File: match.FilePath, + Line: match.LineNumber, + Content: match.MatchLine, + MatchType: getMatchType(match), + }) + } + paginated := searchContentPaginatedJSON{ + Page: page, + PageSize: pageSize, + TotalResults: len(matches), + ReturnedResults: len(result), + HasMore: hasMore, + Results: result, + } + encoder := json.NewEncoder(output) + encoder.SetEscapeHTML(false) + return encoder.Encode(paginated) + } + result := make([]searchContentJSONMatch, 0, len(matches)) for _, match := range matches { result = append(result, searchContentJSONMatch{ @@ -172,7 +260,6 @@ func printMatches(matches []obsidian.NoteMatch, searchTerm string, format string MatchType: getMatchType(match), }) } - encoder := json.NewEncoder(output) encoder.SetEscapeHTML(false) return encoder.Encode(result) diff --git a/pkg/actions/search_content_test.go b/pkg/actions/search_content_test.go index de93018..f6adae3 100644 --- a/pkg/actions/search_content_test.go +++ b/pkg/actions/search_content_test.go @@ -354,4 +354,92 @@ func TestSearchNotesContent(t *testing.T) { assert.Error(t, err) assert.Contains(t, err.Error(), "invalid format") }) + + t.Run("JSON pagination wraps results in envelope", func(t *testing.T) { + vault := mocks.MockVaultOperator{Name: "myVault"} + uri := mocks.MockUriManager{} + note := mocks.MockNoteManager{} + fuzzyFinder := mocks.MockFuzzyFinder{} + output := &bytes.Buffer{} + + options := defaultOptions(output) + options.Format = "json" + options.NoInteractive = true + options.Page = 1 + options.PageSize = 1 + + err := actions.SearchNotesContentWithOptions(&vault, ¬e, &uri, &fuzzyFinder, "test", options) + assert.NoError(t, err) + + var result map[string]interface{} + decodeErr := json.Unmarshal(output.Bytes(), &result) + assert.NoError(t, decodeErr) + assert.Equal(t, float64(1), result["page"]) + assert.Equal(t, float64(1), result["page_size"]) + assert.Equal(t, float64(2), result["total_results"]) + assert.Equal(t, float64(1), result["returned_results"]) + assert.Equal(t, true, result["has_more"]) + assert.Len(t, result["results"], 1) + }) + + t.Run("JSON pagination page 2", func(t *testing.T) { + vault := mocks.MockVaultOperator{Name: "myVault"} + uri := mocks.MockUriManager{} + note := mocks.MockNoteManager{} + fuzzyFinder := mocks.MockFuzzyFinder{} + output := &bytes.Buffer{} + + options := defaultOptions(output) + options.Format = "json" + options.NoInteractive = true + options.Page = 2 + options.PageSize = 1 + + err := actions.SearchNotesContentWithOptions(&vault, ¬e, &uri, &fuzzyFinder, "test", options) + assert.NoError(t, err) + + var result map[string]interface{} + decodeErr := json.Unmarshal(output.Bytes(), &result) + assert.NoError(t, decodeErr) + assert.Equal(t, float64(2), result["page"]) + assert.Equal(t, false, result["has_more"]) + assert.Len(t, result["results"], 1) + }) + + t.Run("Text pagination appends footer", func(t *testing.T) { + vault := mocks.MockVaultOperator{Name: "myVault"} + uri := mocks.MockUriManager{} + note := mocks.MockNoteManager{} + fuzzyFinder := mocks.MockFuzzyFinder{} + output := &bytes.Buffer{} + + options := defaultOptions(output) + options.NoInteractive = true + options.Page = 1 + options.PageSize = 1 + + err := actions.SearchNotesContentWithOptions(&vault, ¬e, &uri, &fuzzyFinder, "test", options) + assert.NoError(t, err) + assert.Contains(t, output.String(), "-- Page 1/2 (1 of 2 results) --") + }) + + t.Run("Without pagination flags JSON output is flat array", func(t *testing.T) { + vault := mocks.MockVaultOperator{Name: "myVault"} + uri := mocks.MockUriManager{} + note := mocks.MockNoteManager{} + fuzzyFinder := mocks.MockFuzzyFinder{} + output := &bytes.Buffer{} + + options := defaultOptions(output) + options.Format = "json" + options.NoInteractive = true + + err := actions.SearchNotesContentWithOptions(&vault, ¬e, &uri, &fuzzyFinder, "test", options) + assert.NoError(t, err) + + var result []searchContentJSONMatch + decodeErr := json.Unmarshal(output.Bytes(), &result) + assert.NoError(t, decodeErr) + assert.Len(t, result, 2) + }) } From c61aa805174974924a32751fef69d5d85901e5ae Mon Sep 17 00:00:00 2001 From: Kartikay Jainwal Date: Mon, 4 May 2026 10:11:21 +0100 Subject: [PATCH 2/5] fix: skip hidden directories during search and list operations --- pkg/obsidian/note.go | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/pkg/obsidian/note.go b/pkg/obsidian/note.go index 690b75b..71d5ddd 100644 --- a/pkg/obsidian/note.go +++ b/pkg/obsidian/note.go @@ -184,6 +184,9 @@ func (m *Note) GetNotesList(vaultPath string) ([]string, error) { if err != nil { return err } + if d.IsDir() && d.Name() != "." && strings.HasPrefix(d.Name(), ".") { + return filepath.SkipDir + } relPath, err := filepath.Rel(vaultPath, path) if err != nil { return err @@ -214,6 +217,9 @@ func (m *Note) SearchNotesWithSnippets(vaultPath string, query string) ([]NoteMa if err != nil { return err } + if d.IsDir() && d.Name() != "." && strings.HasPrefix(d.Name(), ".") { + return filepath.SkipDir + } relPath, relErr := filepath.Rel(vaultPath, path) if relErr != nil { return relErr @@ -353,6 +359,9 @@ func (m *Note) FindBacklinks(vaultPath, noteName string) ([]NoteMatch, error) { if err != nil { return err } + if d.IsDir() && d.Name() != "." && strings.HasPrefix(d.Name(), ".") { + return filepath.SkipDir + } relPath, err := filepath.Rel(vaultPath, path) if err != nil { From 51ec450353131314618d4b5abbda095e065dd435 Mon Sep 17 00:00:00 2001 From: Kartikay Jainwal Date: Mon, 4 May 2026 10:12:58 +0100 Subject: [PATCH 3/5] docs: add pagination flags to search-content readme section --- README.md | 3 +++ 1 file changed, 3 insertions(+) diff --git a/README.md b/README.md index 8135168..2637a51 100644 --- a/README.md +++ b/README.md @@ -346,6 +346,9 @@ notesmd-cli search-content "search term" --no-interactive # Prints JSON for scripts (implies non-interactive mode) notesmd-cli search-content "search term" --format json +# Paginated results (default page size: 25, max: 100) +notesmd-cli search-content "search term" --format json --page 1 --page-size 50 + ``` ### List Vault Contents From f2221fc08a507b275b3261513ad776e9de40d292 Mon Sep 17 00:00:00 2001 From: Kartikay Jainwal Date: Tue, 5 May 2026 21:34:31 +0100 Subject: [PATCH 4/5] refactor: clean up pagination and hidden dir logic --- pkg/actions/search_content.go | 20 ++++++++++++-------- pkg/actions/search_content_test.go | 23 ++++++++--------------- pkg/obsidian/note.go | 11 +++++++---- 3 files changed, 27 insertions(+), 27 deletions(-) diff --git a/pkg/actions/search_content.go b/pkg/actions/search_content.go index 36e8c67..74fa502 100644 --- a/pkg/actions/search_content.go +++ b/pkg/actions/search_content.go @@ -182,6 +182,14 @@ func paginateMatches(matches []obsidian.NoteMatch, options SearchContentOptions) } total := len(matches) + totalPages := (total + pageSize - 1) / pageSize + if totalPages == 0 { + totalPages = 1 + } + if page > totalPages { + page = totalPages + } + start := (page - 1) * pageSize if start >= total { return nil, page, pageSize, false @@ -207,22 +215,18 @@ func printMatches(matches []obsidian.NoteMatch, searchTerm string, format string return nil } - displayMatches := matches if paginate { - var page, pageSize int - var hasMore bool - displayMatches, page, pageSize, hasMore = paginateMatches(matches, options) - _ = hasMore - for _, match := range displayMatches { + pageMatches, page, pageSize, _ := paginateMatches(matches, options) + for _, match := range pageMatches { _, _ = fmt.Fprintln(output, formatMatchForList(match)) } total := len(matches) totalPages := (total + pageSize - 1) / pageSize - _, _ = fmt.Fprintf(output, "-- Page %d/%d (%d of %d results) --\n", page, totalPages, len(displayMatches), total) + _, _ = fmt.Fprintf(output, "-- Page %d/%d (%d of %d results) --\n", page, totalPages, len(pageMatches), total) return nil } - for _, match := range displayMatches { + for _, match := range matches { _, _ = fmt.Fprintln(output, formatMatchForList(match)) } return nil diff --git a/pkg/actions/search_content_test.go b/pkg/actions/search_content_test.go index f6adae3..4b6a5e5 100644 --- a/pkg/actions/search_content_test.go +++ b/pkg/actions/search_content_test.go @@ -33,13 +33,6 @@ func (m *CustomMockNoteForSingleMatch) FindBacklinks(string, string) ([]obsidian return nil, nil } -type searchContentJSONMatch struct { - File string `json:"file"` - Line int `json:"line"` - Content string `json:"content"` - MatchType string `json:"match_type"` -} - func defaultOptions(output *bytes.Buffer) actions.SearchContentOptions { return actions.SearchContentOptions{ Format: "text", @@ -264,14 +257,14 @@ func TestSearchNotesContent(t *testing.T) { assert.NoError(t, err) assert.Equal(t, 0, uri.ExecuteCalls) - var result []searchContentJSONMatch + var result []map[string]any decodeErr := json.Unmarshal(output.Bytes(), &result) assert.NoError(t, decodeErr) assert.Len(t, result, 2) - assert.Equal(t, "note1.md", result[0].File) - assert.Equal(t, 5, result[0].Line) - assert.Equal(t, "example match line", result[0].Content) - assert.Equal(t, "content", result[0].MatchType) + assert.Equal(t, "note1.md", result[0]["file"]) + assert.Equal(t, float64(5), result[0]["line"]) + assert.Equal(t, "example match line", result[0]["content"]) + assert.Equal(t, "content", result[0]["match_type"]) }) t.Run("JSON format with no matches prints empty array", func(t *testing.T) { @@ -371,7 +364,7 @@ func TestSearchNotesContent(t *testing.T) { err := actions.SearchNotesContentWithOptions(&vault, ¬e, &uri, &fuzzyFinder, "test", options) assert.NoError(t, err) - var result map[string]interface{} + var result map[string]any decodeErr := json.Unmarshal(output.Bytes(), &result) assert.NoError(t, decodeErr) assert.Equal(t, float64(1), result["page"]) @@ -398,7 +391,7 @@ func TestSearchNotesContent(t *testing.T) { err := actions.SearchNotesContentWithOptions(&vault, ¬e, &uri, &fuzzyFinder, "test", options) assert.NoError(t, err) - var result map[string]interface{} + var result map[string]any decodeErr := json.Unmarshal(output.Bytes(), &result) assert.NoError(t, decodeErr) assert.Equal(t, float64(2), result["page"]) @@ -437,7 +430,7 @@ func TestSearchNotesContent(t *testing.T) { err := actions.SearchNotesContentWithOptions(&vault, ¬e, &uri, &fuzzyFinder, "test", options) assert.NoError(t, err) - var result []searchContentJSONMatch + var result []map[string]any decodeErr := json.Unmarshal(output.Bytes(), &result) assert.NoError(t, decodeErr) assert.Len(t, result, 2) diff --git a/pkg/obsidian/note.go b/pkg/obsidian/note.go index 71d5ddd..f337447 100644 --- a/pkg/obsidian/note.go +++ b/pkg/obsidian/note.go @@ -12,7 +12,10 @@ import ( "strings" ) -type Note struct { +type Note struct{} + +func isHiddenDir(d fs.DirEntry) bool { + return d.IsDir() && d.Name() != "." && strings.HasPrefix(d.Name(), ".") } type NoteMatch struct { @@ -184,7 +187,7 @@ func (m *Note) GetNotesList(vaultPath string) ([]string, error) { if err != nil { return err } - if d.IsDir() && d.Name() != "." && strings.HasPrefix(d.Name(), ".") { + if isHiddenDir(d) { return filepath.SkipDir } relPath, err := filepath.Rel(vaultPath, path) @@ -217,7 +220,7 @@ func (m *Note) SearchNotesWithSnippets(vaultPath string, query string) ([]NoteMa if err != nil { return err } - if d.IsDir() && d.Name() != "." && strings.HasPrefix(d.Name(), ".") { + if isHiddenDir(d) { return filepath.SkipDir } relPath, relErr := filepath.Rel(vaultPath, path) @@ -359,7 +362,7 @@ func (m *Note) FindBacklinks(vaultPath, noteName string) ([]NoteMatch, error) { if err != nil { return err } - if d.IsDir() && d.Name() != "." && strings.HasPrefix(d.Name(), ".") { + if isHiddenDir(d) { return filepath.SkipDir } From 283483e3cda47ed59454ac5a313bbdde3ad92dbb Mon Sep 17 00:00:00 2001 From: Kartikay Jainwal Date: Tue, 5 May 2026 21:39:24 +0100 Subject: [PATCH 5/5] refactor: extract toJSONMatches helper, return paginationResult struct, add missing tests --- pkg/actions/search_content.go | 82 +++++++++++++++++------------- pkg/actions/search_content_test.go | 69 +++++++++++++++++++++++++ 2 files changed, 115 insertions(+), 36 deletions(-) diff --git a/pkg/actions/search_content.go b/pkg/actions/search_content.go index 74fa502..7619556 100644 --- a/pkg/actions/search_content.go +++ b/pkg/actions/search_content.go @@ -150,6 +150,9 @@ func shouldUseNonInteractiveMode(options SearchContentOptions, format string) bo if format == searchContentFormatJSON { return true } + if isPaginationRequested(options) { + return true + } return !options.InteractiveTerminal } @@ -167,7 +170,15 @@ func normalizeSearchContentFormat(format string) (string, error) { } } -func paginateMatches(matches []obsidian.NoteMatch, options SearchContentOptions) ([]obsidian.NoteMatch, int, int, bool) { +type paginationResult struct { + items []obsidian.NoteMatch + page int + pageSize int + totalPages int + hasMore bool +} + +func paginateMatches(matches []obsidian.NoteMatch, options SearchContentOptions) paginationResult { page := options.Page pageSize := options.PageSize @@ -192,19 +203,38 @@ func paginateMatches(matches []obsidian.NoteMatch, options SearchContentOptions) start := (page - 1) * pageSize if start >= total { - return nil, page, pageSize, false + return paginationResult{page: page, pageSize: pageSize, totalPages: totalPages} } end := start + pageSize if end > total { end = total } - return matches[start:end], page, pageSize, end < total + return paginationResult{ + items: matches[start:end], + page: page, + pageSize: pageSize, + totalPages: totalPages, + hasMore: end < total, + } } func isPaginationRequested(options SearchContentOptions) bool { return options.Page > 0 || options.PageSize > 0 } +func toJSONMatches(matches []obsidian.NoteMatch) []searchContentJSONMatch { + result := make([]searchContentJSONMatch, 0, len(matches)) + for _, match := range matches { + result = append(result, searchContentJSONMatch{ + File: match.FilePath, + Line: match.LineNumber, + Content: match.MatchLine, + MatchType: getMatchType(match), + }) + } + return result +} + func printMatches(matches []obsidian.NoteMatch, searchTerm string, format string, output io.Writer, options SearchContentOptions) error { paginate := isPaginationRequested(options) @@ -216,13 +246,11 @@ func printMatches(matches []obsidian.NoteMatch, searchTerm string, format string } if paginate { - pageMatches, page, pageSize, _ := paginateMatches(matches, options) - for _, match := range pageMatches { + pg := paginateMatches(matches, options) + for _, match := range pg.items { _, _ = fmt.Fprintln(output, formatMatchForList(match)) } - total := len(matches) - totalPages := (total + pageSize - 1) / pageSize - _, _ = fmt.Fprintf(output, "-- Page %d/%d (%d of %d results) --\n", page, totalPages, len(pageMatches), total) + _, _ = fmt.Fprintf(output, "-- Page %d/%d (%d of %d results) --\n", pg.page, pg.totalPages, len(pg.items), len(matches)) return nil } @@ -232,41 +260,23 @@ func printMatches(matches []obsidian.NoteMatch, searchTerm string, format string return nil case searchContentFormatJSON: if paginate { - pageMatches, page, pageSize, hasMore := paginateMatches(matches, options) - result := make([]searchContentJSONMatch, 0, len(pageMatches)) - for _, match := range pageMatches { - result = append(result, searchContentJSONMatch{ - File: match.FilePath, - Line: match.LineNumber, - Content: match.MatchLine, - MatchType: getMatchType(match), - }) - } - paginated := searchContentPaginatedJSON{ - Page: page, - PageSize: pageSize, + pg := paginateMatches(matches, options) + result := toJSONMatches(pg.items) + encoder := json.NewEncoder(output) + encoder.SetEscapeHTML(false) + return encoder.Encode(searchContentPaginatedJSON{ + Page: pg.page, + PageSize: pg.pageSize, TotalResults: len(matches), ReturnedResults: len(result), - HasMore: hasMore, + HasMore: pg.hasMore, Results: result, - } - encoder := json.NewEncoder(output) - encoder.SetEscapeHTML(false) - return encoder.Encode(paginated) - } - - result := make([]searchContentJSONMatch, 0, len(matches)) - for _, match := range matches { - result = append(result, searchContentJSONMatch{ - File: match.FilePath, - Line: match.LineNumber, - Content: match.MatchLine, - MatchType: getMatchType(match), }) } + encoder := json.NewEncoder(output) encoder.SetEscapeHTML(false) - return encoder.Encode(result) + return encoder.Encode(toJSONMatches(matches)) default: return fmt.Errorf("unsupported output format: %s", format) } diff --git a/pkg/actions/search_content_test.go b/pkg/actions/search_content_test.go index 4b6a5e5..ee1f6e6 100644 --- a/pkg/actions/search_content_test.go +++ b/pkg/actions/search_content_test.go @@ -435,4 +435,73 @@ func TestSearchNotesContent(t *testing.T) { assert.NoError(t, decodeErr) assert.Len(t, result, 2) }) + + t.Run("Out-of-range page is clamped to last page", func(t *testing.T) { + vault := mocks.MockVaultOperator{Name: "myVault"} + uri := mocks.MockUriManager{} + note := mocks.MockNoteManager{} + fuzzyFinder := mocks.MockFuzzyFinder{} + output := &bytes.Buffer{} + + options := defaultOptions(output) + options.Format = "json" + options.NoInteractive = true + options.Page = 999 + options.PageSize = 25 + + err := actions.SearchNotesContentWithOptions(&vault, ¬e, &uri, &fuzzyFinder, "test", options) + assert.NoError(t, err) + + var result map[string]any + decodeErr := json.Unmarshal(output.Bytes(), &result) + assert.NoError(t, decodeErr) + assert.Equal(t, float64(1), result["page"]) + assert.Equal(t, float64(2), result["returned_results"]) + assert.Equal(t, false, result["has_more"]) + }) + + t.Run("Page-size only defaults to page 1", func(t *testing.T) { + vault := mocks.MockVaultOperator{Name: "myVault"} + uri := mocks.MockUriManager{} + note := mocks.MockNoteManager{} + fuzzyFinder := mocks.MockFuzzyFinder{} + output := &bytes.Buffer{} + + options := defaultOptions(output) + options.Format = "json" + options.PageSize = 1 + + err := actions.SearchNotesContentWithOptions(&vault, ¬e, &uri, &fuzzyFinder, "test", options) + assert.NoError(t, err) + + var result map[string]any + decodeErr := json.Unmarshal(output.Bytes(), &result) + assert.NoError(t, decodeErr) + assert.Equal(t, float64(1), result["page"]) + assert.Equal(t, float64(1), result["returned_results"]) + assert.Equal(t, true, result["has_more"]) + }) + + t.Run("Pagination flags imply non-interactive mode", func(t *testing.T) { + vault := mocks.MockVaultOperator{Name: "myVault"} + uri := mocks.MockUriManager{} + note := mocks.MockNoteManager{} + fuzzyFinder := mocks.MockFuzzyFinder{} + output := &bytes.Buffer{} + + options := defaultOptions(output) + options.Format = "json" + options.InteractiveTerminal = true + options.Page = 1 + options.PageSize = 10 + + err := actions.SearchNotesContentWithOptions(&vault, ¬e, &uri, &fuzzyFinder, "test", options) + assert.NoError(t, err) + assert.Equal(t, 0, uri.ExecuteCalls) + + var result map[string]any + decodeErr := json.Unmarshal(output.Bytes(), &result) + assert.NoError(t, decodeErr) + assert.NotNil(t, result["page"]) + }) }