From 79c3d5aa230f8ec3fee64755789ccd78be7ebdf7 Mon Sep 17 00:00:00 2001 From: Levi Neely <141506390+lneely@users.noreply.github.com> Date: Tue, 25 Nov 2025 06:01:51 +0100 Subject: [PATCH] port tests from original author, fix bugs --- internal/metadata/frontmatter_test.go | 680 ++++++++++++++++++++++++ internal/metadata/metadata.go | 21 +- internal/metadata/metadata_test.go | 729 ++++++++++++++++++++++++++ 3 files changed, 1418 insertions(+), 12 deletions(-) create mode 100644 internal/metadata/frontmatter_test.go create mode 100644 internal/metadata/metadata_test.go diff --git a/internal/metadata/frontmatter_test.go b/internal/metadata/frontmatter_test.go new file mode 100644 index 0000000..0d0fc47 --- /dev/null +++ b/internal/metadata/frontmatter_test.go @@ -0,0 +1,680 @@ +package metadata + +import ( + "strings" + "testing" +) + +// TestFormatTags validates tag formatting for different file types +// Maps to dt-denote--format-front-matter from original tests +func TestFormatTags(t *testing.T) { + tests := []struct { + name string + tags []string + fileType string + want string + }{ + { + name: "org with multiple tags", + tags: []string{"tag1", "tag2"}, + fileType: "org", + want: ":tag1:tag2:", + }, + { + name: "org with single tag", + tags: []string{"single"}, + fileType: "org", + want: ":single:", + }, + { + name: "org with empty tags", + tags: []string{}, + fileType: "org", + want: "", + }, + { + name: "md-yaml with multiple tags", + tags: []string{"tag1", "tag2"}, + fileType: "md-yaml", + want: "[tag1, tag2]", + }, + { + name: "md-yaml with single tag", + tags: []string{"single"}, + fileType: "md-yaml", + want: "[single]", + }, + { + name: "md-yaml with empty tags", + tags: []string{}, + fileType: "md-yaml", + want: "", + }, + { + name: "md-toml with multiple tags", + tags: []string{"tag1", "tag2"}, + fileType: "md-toml", + want: "[tag1, tag2]", + }, + { + name: "md-toml with empty tags", + tags: []string{}, + fileType: "md-toml", + want: "", + }, + { + name: "txt with multiple tags", + tags: []string{"tag1", "tag2"}, + fileType: "txt", + want: "tag1 tag2", + }, + { + name: "txt with single tag", + tags: []string{"single"}, + fileType: "txt", + want: "single", + }, + { + name: "txt with empty tags", + tags: []string{}, + fileType: "txt", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := FormatTags(tt.tags, tt.fileType) + if got != tt.want { + t.Errorf("FormatTags(%v, %q) = %q, want %q", tt.tags, tt.fileType, got, tt.want) + } + }) + } +} + +// TestGenerate validates front matter generation for all file types +// Maps to dt-denote--format-front-matter from original tests +func TestGenerate(t *testing.T) { + identifier := "20240101T120000" + title := "Test Note" + tags := []string{"tag1", "tag2"} + + tests := []struct { + name string + fileType string + wantContains []string + wantNotContains []string + }{ + { + name: "org format", + fileType: "org", + wantContains: []string{ + "#+title: Test Note", + "#+filetags: :tag1:tag2:", + "#+identifier: 20240101T120000", + "#+date:", + }, + }, + { + name: "md-yaml format", + fileType: "md-yaml", + wantContains: []string{ + "---", + "title: Test Note", + "tags: [tag1, tag2]", + "identifier: 20240101T120000", + "date:", + }, + }, + { + name: "md-toml format", + fileType: "md-toml", + wantContains: []string{ + "+++", + "title = Test Note", + "tags = [tag1, tag2]", + "identifier = 20240101T120000", + "date =", + }, + }, + { + name: "txt format", + fileType: "txt", + wantContains: []string{ + "title: Test Note", + "tags: tag1 tag2", + "identifier: 20240101T120000", + "date:", + "---------------------------", + }, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Generate(title, tags, tt.fileType, identifier) + + for _, want := range tt.wantContains { + if !strings.Contains(got, want) { + t.Errorf("Generate(%q, %v, %q, %q) missing %q\nGot:\n%s", + title, tags, tt.fileType, identifier, want, got) + } + } + + for _, notWant := range tt.wantNotContains { + if strings.Contains(got, notWant) { + t.Errorf("Generate(%q, %v, %q, %q) should not contain %q\nGot:\n%s", + title, tags, tt.fileType, identifier, notWant, got) + } + } + }) + } +} + +// TestGenerateEmptyTags validates front matter generation with no tags +func TestGenerateEmptyTags(t *testing.T) { + identifier := "20240101T120000" + title := "Test Note" + tags := []string{} + + tests := []struct { + name string + fileType string + wantTags string + }{ + { + name: "org with empty tags", + fileType: "org", + wantTags: "#+filetags:", + }, + { + name: "md-yaml with empty tags", + fileType: "md-yaml", + wantTags: "tags:", + }, + { + name: "md-toml with empty tags", + fileType: "md-toml", + wantTags: "tags =", + }, + { + name: "txt with empty tags", + fileType: "txt", + wantTags: "tags:", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := Generate(title, tags, tt.fileType, identifier) + + if !strings.Contains(got, tt.wantTags) { + t.Errorf("Generate() with empty tags should contain %q\nGot:\n%s", + tt.wantTags, got) + } + }) + } +} + +// TestParseFrontMatter validates front matter parsing from content +func TestParseFrontMatter(t *testing.T) { + tests := []struct { + name string + content string + ext string + wantTitle string + wantTags []string + wantIdentifier string + wantFileType string + }{ + { + name: "org format", + content: `#+title: Org Note +#+date: [2024-01-01 Mon 12:00] +#+filetags: :work:emacs: +#+identifier: 20240101T120000 + +* First Heading`, + ext: ".org", + wantTitle: "Org Note", + wantTags: []string{"work", "emacs"}, + wantIdentifier: "20240101T120000", + wantFileType: "org", + }, + { + name: "org with single tag", + content: `#+title: Single Tag +#+filetags: :single: +#+identifier: 20240101T120000`, + ext: ".org", + wantTitle: "Single Tag", + wantTags: []string{"single"}, + wantIdentifier: "20240101T120000", + wantFileType: "org", + }, + { + name: "org without tags", + content: `#+title: No Tags +#+identifier: 20240101T120000`, + ext: ".org", + wantTitle: "No Tags", + wantTags: nil, + wantIdentifier: "20240101T120000", + wantFileType: "org", + }, + { + name: "markdown yaml", + content: `--- +title: Markdown Note +date: 2024-01-01 +tags: [work, personal] +identifier: 20240101T120000 +--- + +# Content`, + ext: ".md", + wantTitle: "Markdown Note", + wantTags: []string{"work", "personal"}, + wantIdentifier: "20240101T120000", + wantFileType: "md-yaml", + }, + { + name: "markdown yaml with quoted title", + content: `--- +title: "Quoted Title" +tags: [test] +identifier: 20240101T120000 +---`, + ext: ".md", + wantTitle: "Quoted Title", + wantTags: []string{"test"}, + wantIdentifier: "20240101T120000", + wantFileType: "md-yaml", + }, + { + name: "markdown toml", + content: `+++ +title = TOML Note +date = 2024-01-01 +tags = [rust, go] +identifier = 20240101T120000 ++++ + +Content`, + ext: ".md", + wantTitle: "TOML Note", + wantTags: []string{"rust", "go"}, + wantIdentifier: "20240101T120000", + wantFileType: "md-toml", + }, + { + name: "txt format", + content: `title: Plain Text +date: 2024-01-01 +tags: simple plain +identifier: 20240101T120000 +--------------------------- + +Content here`, + ext: ".txt", + wantTitle: "Plain Text", + wantTags: []string{"simple", "plain"}, + wantIdentifier: "20240101T120000", + wantFileType: "txt", + }, + { + name: "txt without tags", + content: `title: No Tags Text +identifier: 20240101T120000 +---------------------------`, + ext: ".txt", + wantTitle: "No Tags Text", + wantTags: nil, + wantIdentifier: "20240101T120000", + wantFileType: "txt", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := ParseFrontMatter(tt.content, tt.ext) + if err != nil { + t.Fatalf("ParseFrontMatter() error = %v", err) + } + + if got.Title != tt.wantTitle { + t.Errorf("ParseFrontMatter().Title = %q, want %q", got.Title, tt.wantTitle) + } + + if !SlicesEqual(got.Tags, tt.wantTags) { + t.Errorf("ParseFrontMatter().Tags = %v, want %v", got.Tags, tt.wantTags) + } + + if got.Identifier != tt.wantIdentifier { + t.Errorf("ParseFrontMatter().Identifier = %q, want %q", got.Identifier, tt.wantIdentifier) + } + + if got.FileType != tt.wantFileType { + t.Errorf("ParseFrontMatter().FileType = %q, want %q", got.FileType, tt.wantFileType) + } + }) + } +} + +// TestParseFrontMatterMissingFields validates handling of missing fields +func TestParseFrontMatterMissingFields(t *testing.T) { + content := `--- +date: 2024-01-01 +---` + + got, err := ParseFrontMatter(content, ".md") + if err != nil { + t.Fatalf("ParseFrontMatter() error = %v", err) + } + + // Should have empty values, not error + if got.Title != "" { + t.Errorf("ParseFrontMatter().Title = %q, want empty", got.Title) + } + if got.Identifier != "" { + t.Errorf("ParseFrontMatter().Identifier = %q, want empty", got.Identifier) + } + if got.Tags != nil { + t.Errorf("ParseFrontMatter().Tags = %v, want nil", got.Tags) + } +} + +// TestUpdateFrontMatter validates front matter updates +func TestUpdateFrontMatter(t *testing.T) { + tests := []struct { + name string + original string + fm *FrontMatter + wantContains []string + wantPreserve string + }{ + { + name: "update org front matter", + original: `#+title: Old Title +#+date: [2024-01-01 Mon 12:00] +#+filetags: :old: +#+identifier: 20240101T120000 + +* Original Content +This should be preserved`, + fm: &FrontMatter{ + Title: "New Title", + Tags: []string{"new", "updated"}, + Identifier: "20240101T120000", + FileType: "org", + }, + wantContains: []string{ + "#+title: New Title", + "#+filetags: :new:updated:", + "* Original Content", + "This should be preserved", + }, + wantPreserve: "* Original Content", + }, + { + name: "update markdown yaml front matter", + original: `--- +title: Old Title +tags: [old] +identifier: 20240101T120000 +--- + +# Original Heading +Content preserved`, + fm: &FrontMatter{ + Title: "New Title", + Tags: []string{"new"}, + Identifier: "20240101T120000", + FileType: "md-yaml", + }, + wantContains: []string{ + "---", + "title: New Title", + "tags: [new]", + "# Original Heading", + "Content preserved", + }, + wantPreserve: "# Original Heading", + }, + { + name: "update markdown toml front matter", + original: `+++ +title = Old Title +tags = [old] +identifier = 20240101T120000 ++++ + +Content here`, + fm: &FrontMatter{ + Title: "New Title", + Tags: []string{"updated"}, + Identifier: "20240101T120000", + FileType: "md-toml", + }, + wantContains: []string{ + "+++", + "title = New Title", + "tags = [updated]", + "Content here", + }, + wantPreserve: "Content here", + }, + { + name: "update txt front matter", + original: `title: Old Title +tags: old +identifier: 20240101T120000 +--------------------------- + +Text content`, + fm: &FrontMatter{ + Title: "New Title", + Tags: []string{"new"}, + Identifier: "20240101T120000", + FileType: "txt", + }, + wantContains: []string{ + "title: New Title", + "tags: new", + "---------------------------", + "Text content", + }, + wantPreserve: "Text content", + }, + { + name: "add front matter when missing (org)", + original: `* Original Heading`, + fm: &FrontMatter{ + Title: "Added Title", + Tags: []string{"added"}, + Identifier: "20240101T120000", + FileType: "org", + }, + wantContains: []string{ + "#+title: Added Title", + "#+filetags: :added:", + "* Original Heading", + }, + wantPreserve: "* Original Heading", + }, + { + name: "add front matter when missing (md-yaml)", + original: `# Original Heading`, + fm: &FrontMatter{ + Title: "Added Title", + Tags: []string{"added"}, + Identifier: "20240101T120000", + FileType: "md-yaml", + }, + wantContains: []string{ + "---", + "title: Added Title", + "tags: [added]", + "# Original Heading", + }, + wantPreserve: "# Original Heading", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got, err := UpdateFrontMatter(tt.original, tt.fm) + if err != nil { + t.Fatalf("UpdateFrontMatter() error = %v", err) + } + + for _, want := range tt.wantContains { + if !strings.Contains(got, want) { + t.Errorf("UpdateFrontMatter() missing %q\nGot:\n%s", want, got) + } + } + + // Verify original content is preserved + if !strings.Contains(got, tt.wantPreserve) { + t.Errorf("UpdateFrontMatter() didn't preserve %q\nGot:\n%s", tt.wantPreserve, got) + } + }) + } +} + +// TestUpdateFrontMatterEmptyTags validates updating with empty tags +func TestUpdateFrontMatterEmptyTags(t *testing.T) { + original := `--- +title: Test +tags: [old, tags] +identifier: 20240101T120000 +--- + +Content` + + fm := &FrontMatter{ + Title: "Test", + Tags: []string{}, + Identifier: "20240101T120000", + FileType: "md-yaml", + } + + got, err := UpdateFrontMatter(original, fm) + if err != nil { + t.Fatalf("UpdateFrontMatter() error = %v", err) + } + + // Should have empty tags field, not omit it + if !strings.Contains(got, "tags:") { + t.Errorf("UpdateFrontMatter() should include tags field even when empty") + } + + // Content should be preserved + if !strings.Contains(got, "Content") { + t.Errorf("UpdateFrontMatter() should preserve content") + } +} + +// TestUpdateFrontMatterUnsupportedType validates error handling +func TestUpdateFrontMatterUnsupportedType(t *testing.T) { + fm := &FrontMatter{ + Title: "Test", + Tags: []string{"test"}, + Identifier: "20240101T120000", + FileType: "unsupported", + } + + _, err := UpdateFrontMatter("content", fm) + if err == nil { + t.Error("UpdateFrontMatter() should error on unsupported file type") + } + + if !strings.Contains(err.Error(), "unsupported file type") { + t.Errorf("UpdateFrontMatter() error = %v, want 'unsupported file type'", err) + } +} + +// TestFileExtensions validates file extension mapping +func TestFileExtensions(t *testing.T) { + tests := []struct { + fileType string + want string + }{ + {"org", ".org"}, + {"md-yaml", ".md"}, + {"md-toml", ".md"}, + {"txt", ".txt"}, + } + + for _, tt := range tests { + t.Run(tt.fileType, func(t *testing.T) { + got, ok := FileExtensions[tt.fileType] + if !ok { + t.Errorf("FileExtensions[%q] not found", tt.fileType) + } + if got != tt.want { + t.Errorf("FileExtensions[%q] = %q, want %q", tt.fileType, got, tt.want) + } + }) + } +} + +// TestFrontMatterRoundTrip validates generate -> parse -> update cycle +func TestFrontMatterRoundTrip(t *testing.T) { + fileTypes := []string{"org", "md-yaml", "md-toml", "txt"} + title := "Test Note" + tags := []string{"tag1", "tag2"} + identifier := "20240101T120000" + + for _, fileType := range fileTypes { + t.Run(fileType, func(t *testing.T) { + // Generate front matter + content := Generate(title, tags, fileType, identifier) + + // Parse it back + ext := FileExtensions[fileType] + fm, err := ParseFrontMatter(content, ext) + if err != nil { + t.Fatalf("ParseFrontMatter() error = %v", err) + } + + // Verify parsed values + if fm.Title != title { + t.Errorf("Parsed title = %q, want %q", fm.Title, title) + } + if !SlicesEqual(fm.Tags, tags) { + t.Errorf("Parsed tags = %v, want %v", fm.Tags, tags) + } + if fm.Identifier != identifier { + t.Errorf("Parsed identifier = %q, want %q", fm.Identifier, identifier) + } + if fm.FileType != fileType { + t.Errorf("Parsed fileType = %q, want %q", fm.FileType, fileType) + } + + // Update with new values + newTitle := "Updated Title" + newTags := []string{"new"} + fm.Title = newTitle + fm.Tags = newTags + + updated, err := UpdateFrontMatter(content, fm) + if err != nil { + t.Fatalf("UpdateFrontMatter() error = %v", err) + } + + // Parse again and verify + fm2, err := ParseFrontMatter(updated, ext) + if err != nil { + t.Fatalf("ParseFrontMatter() after update error = %v", err) + } + + if fm2.Title != newTitle { + t.Errorf("After update title = %q, want %q", fm2.Title, newTitle) + } + if !SlicesEqual(fm2.Tags, newTags) { + t.Errorf("After update tags = %v, want %v", fm2.Tags, newTags) + } + }) + } +} diff --git a/internal/metadata/metadata.go b/internal/metadata/metadata.go index d81cafd..e91f4aa 100644 --- a/internal/metadata/metadata.go +++ b/internal/metadata/metadata.go @@ -4,6 +4,7 @@ import ( "fmt" "path/filepath" "regexp" + "slices" "sort" "strings" "time" @@ -37,8 +38,8 @@ func (es Results) Bytes() []byte { func (es Results) FromString(data string) (Results, error) { var results Results lines := strings.Split(strings.TrimSpace(data), "\n") - // Allow lowercase unicode letters and digits, no spaces - tagPattern := regexp.MustCompile(`^([\p{Ll}\p{Nd}]+,)*[\p{Ll}\p{Nd}]+$`) + // Allow lowercase Latin letters, other letters (CJK, etc.), and digits, no spaces + tagPattern := regexp.MustCompile(`^([\p{Ll}\p{Lo}\p{Nd}]+,)*[\p{Ll}\p{Lo}\p{Nd}]+$`) for lineNum, line := range lines { line = strings.TrimSpace(line) @@ -79,16 +80,10 @@ func (es Results) FromString(data string) (Results, error) { return results, nil } +// SlicesEqual compares two string slices for equality. +// Deprecated: Use slices.Equal from standard library instead. func SlicesEqual(a, b []string) bool { - if len(a) != len(b) { - return false - } - for i := range a { - if a[i] != b[i] { - return false - } - } - return true + return slices.Equal(a, b) } type SortBy string @@ -195,7 +190,9 @@ func GenerateIdentifier() string { // slugifyTitle converts a title to a filesystem-safe slug. func slugifyTitle(title string) string { - slug := strings.ReplaceAll(strings.ToLower(title), " ", "-") + slug := strings.ToLower(title) + slug = strings.ReplaceAll(slug, " ", "-") + slug = strings.ReplaceAll(slug, "_", "-") return regexp.MustCompile(`[^a-z0-9-]`).ReplaceAllString(slug, "") } diff --git a/internal/metadata/metadata_test.go b/internal/metadata/metadata_test.go new file mode 100644 index 0000000..7eee60e --- /dev/null +++ b/internal/metadata/metadata_test.go @@ -0,0 +1,729 @@ +package metadata + +import ( + "regexp" + "strings" + "testing" + "time" +) + +// TestGenerateIdentifier validates timestamp format (YYYYMMDDThhmmss) +// Maps to dt-denote-identifier-p from original tests +func TestGenerateIdentifier(t *testing.T) { + id := GenerateIdentifier() + + // Validate format + pattern := regexp.MustCompile(`^\d{8}T\d{6}$`) + if !pattern.MatchString(id) { + t.Errorf("GenerateIdentifier() = %q, want format YYYYMMDDThhmmss", id) + } + + // Validate it's a valid timestamp + _, err := time.Parse("20060102T150405", id) + if err != nil { + t.Errorf("GenerateIdentifier() = %q, not a valid timestamp: %v", id, err) + } +} + +// TestSlugifyTitle validates title slugification +// Maps to dt-denote-sluggify-title and dt-denote-sluggify from original tests +func TestSlugifyTitle(t *testing.T) { + tests := []struct { + name string + input string + want string + }{ + { + name: "basic spaces to hyphens", + input: "Hello World", + want: "hello-world", + }, + { + name: "underscores to hyphens", + input: "Test_File Name", + want: "test-file-name", + }, + { + name: "remove punctuation", + input: "Test File!", + want: "test-file", + }, + { + name: "mixed case", + input: "Mixed CASE 123", + want: "mixed-case-123", + }, + { + name: "special characters", + input: "Special@#$Chars", + want: "specialchars", + }, + { + name: "multiple spaces", + input: "Multiple Spaces", + want: "multiple---spaces", + }, + { + name: "leading and trailing spaces", + input: " Trim Me ", + want: "--trim-me--", + }, + { + name: "empty string", + input: "", + want: "", + }, + { + name: "numbers only", + input: "12345", + want: "12345", + }, + { + name: "hyphens preserved", + input: "already-hyphenated", + want: "already-hyphenated", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := slugifyTitle(tt.input) + if got != tt.want { + t.Errorf("slugifyTitle(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +// TestFormatKeywords validates keyword formatting for filenames +// Maps to dt-denote-sluggify-keywords from original tests +func TestFormatKeywords(t *testing.T) { + tests := []struct { + name string + input []string + want string + }{ + { + name: "multiple keywords", + input: []string{"tag1", "tag2"}, + want: "__tag1_tag2", + }, + { + name: "single keyword", + input: []string{"single"}, + want: "__single", + }, + { + name: "empty keywords", + input: []string{}, + want: "", + }, + { + name: "nil keywords", + input: nil, + want: "", + }, + { + name: "three keywords", + input: []string{"one", "two", "three"}, + want: "__one_two_three", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := formatKeywords(tt.input) + if got != tt.want { + t.Errorf("formatKeywords(%v) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +// TestBuildFilename validates filename construction +// Maps to dt-denote-format-file-name from original tests +func TestBuildFilename(t *testing.T) { + tests := []struct { + name string + identifier string + title string + keywords []string + ext string + want string + }{ + { + name: "complete filename with keywords", + identifier: "20231225T120000", + title: "My Title", + keywords: []string{"tag1", "tag2"}, + ext: ".md", + want: "20231225T120000--my-title__tag1_tag2.md", + }, + { + name: "filename without keywords", + identifier: "20231225T120000", + title: "My Title", + keywords: []string{}, + ext: ".md", + want: "20231225T120000--my-title.md", + }, + { + name: "filename with special chars in title", + identifier: "20231225T120000", + title: "Special!@# Title", + keywords: []string{"work"}, + ext: ".org", + want: "20231225T120000--special-title__work.org", + }, + { + name: "org format", + identifier: "20240101T000000", + title: "Org Note", + keywords: []string{"emacs"}, + ext: ".org", + want: "20240101T000000--org-note__emacs.org", + }, + { + name: "txt format", + identifier: "20240101T000000", + title: "Plain Text", + keywords: []string{}, + ext: ".txt", + want: "20240101T000000--plain-text.txt", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := BuildFilename(tt.identifier, tt.title, tt.keywords, tt.ext) + if got != tt.want { + t.Errorf("BuildFilename() = %q, want %q", got, tt.want) + } + }) + } +} + +// TestParseFilename validates filename parsing +// Maps to dt-denote-retrieve-filename-* tests from original +func TestParseFilename(t *testing.T) { + tests := []struct { + name string + path string + wantIdentifier string + wantTitle string + wantTags []string + }{ + { + name: "complete filename with tags", + path: "/home/notes/20231225T120000--my-title__tag1_tag2.md", + wantIdentifier: "20231225T120000", + wantTitle: "my title", + wantTags: []string{"tag1", "tag2"}, + }, + { + name: "filename without tags", + path: "/home/notes/20231225T120000--simple-title.md", + wantIdentifier: "20231225T120000", + wantTitle: "simple title", + wantTags: nil, + }, + { + name: "filename with single tag", + path: "20240101T000000--note__work.org", + wantIdentifier: "20240101T000000", + wantTitle: "note", + wantTags: []string{"work"}, + }, + { + name: "identifier only", + path: "20240101T000000.txt", + wantIdentifier: "20240101T000000", + wantTitle: "", + wantTags: nil, + }, + { + name: "multi-word title", + path: "20240101T000000--multi-word-title__personal_ideas.md", + wantIdentifier: "20240101T000000", + wantTitle: "multi word title", + wantTags: []string{"personal", "ideas"}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ParseFilename(tt.path) + + if got.Identifier != tt.wantIdentifier { + t.Errorf("ParseFilename(%q).Identifier = %q, want %q", + tt.path, got.Identifier, tt.wantIdentifier) + } + + if got.Title != tt.wantTitle { + t.Errorf("ParseFilename(%q).Title = %q, want %q", + tt.path, got.Title, tt.wantTitle) + } + + if !SlicesEqual(got.Tags, tt.wantTags) { + t.Errorf("ParseFilename(%q).Tags = %v, want %v", + tt.path, got.Tags, tt.wantTags) + } + + if got.Path != tt.path { + t.Errorf("ParseFilename(%q).Path = %q, want %q", + tt.path, got.Path, tt.path) + } + }) + } +} + +// TestExtractTitleFromContent validates title extraction from file content +func TestExtractTitleFromContent(t *testing.T) { + tests := []struct { + name string + content string + ext string + want string + }{ + { + name: "org mode with title directive", + content: `#+title: My Org Note +#+date: [2024-01-01] + +* First Heading`, + ext: ".org", + want: "My Org Note", + }, + { + name: "org mode fallback to heading", + content: `#+date: [2024-01-01] + +* Heading Title + +Some content`, + ext: ".org", + want: "Heading Title", + }, + { + name: "markdown yaml front matter", + content: `--- +title: Markdown Note +date: 2024-01-01 +--- + +# First Heading`, + ext: ".md", + want: "Markdown Note", + }, + { + name: "markdown yaml with quotes", + content: `--- +title: "Quoted Title" +date: 2024-01-01 +---`, + ext: ".md", + want: "Quoted Title", + }, + { + name: "markdown fallback to heading", + content: `--- +date: 2024-01-01 +--- + +# Heading Title + +Content here`, + ext: ".md", + want: "Heading Title", + }, + { + name: "markdown heading without front matter", + content: `# Simple Title + +Content`, + ext: ".md", + want: "Simple Title", + }, + { + name: "unsupported extension", + content: "Some content", + ext: ".pdf", + want: "", + }, + { + name: "empty content", + content: "", + ext: ".md", + want: "", + }, + { + name: "no title found", + content: "Just some text without structure", + ext: ".md", + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := ExtractTitleFromContent(tt.content, tt.ext) + if got != tt.want { + t.Errorf("ExtractTitleFromContent() = %q, want %q", got, tt.want) + } + }) + } +} + +// TestSort validates sorting functionality +func TestSort(t *testing.T) { + notes := Results{ + {Identifier: "20240103T120000", Title: "Charlie"}, + {Identifier: "20240101T120000", Title: "Alice"}, + {Identifier: "20240102T120000", Title: "Bob"}, + } + + t.Run("sort by id ascending", func(t *testing.T) { + testData := make(Results, len(notes)) + copy(testData, notes) + + Sort(testData, SortById, SortOrderAsc) + + if testData[0].Identifier != "20240101T120000" { + t.Errorf("First item identifier = %q, want %q", testData[0].Identifier, "20240101T120000") + } + if testData[2].Identifier != "20240103T120000" { + t.Errorf("Last item identifier = %q, want %q", testData[2].Identifier, "20240103T120000") + } + }) + + t.Run("sort by id descending", func(t *testing.T) { + testData := make(Results, len(notes)) + copy(testData, notes) + + Sort(testData, SortById, SortOrderDesc) + + if testData[0].Identifier != "20240103T120000" { + t.Errorf("First item identifier = %q, want %q", testData[0].Identifier, "20240103T120000") + } + if testData[2].Identifier != "20240101T120000" { + t.Errorf("Last item identifier = %q, want %q", testData[2].Identifier, "20240101T120000") + } + }) + + t.Run("sort by title ascending", func(t *testing.T) { + testData := make(Results, len(notes)) + copy(testData, notes) + + Sort(testData, SortByTitle, SortOrderAsc) + + if testData[0].Title != "Alice" { + t.Errorf("First item title = %q, want %q", testData[0].Title, "Alice") + } + if testData[2].Title != "Charlie" { + t.Errorf("Last item title = %q, want %q", testData[2].Title, "Charlie") + } + }) + + t.Run("sort by title descending", func(t *testing.T) { + testData := make(Results, len(notes)) + copy(testData, notes) + + Sort(testData, SortByTitle, SortOrderDesc) + + if testData[0].Title != "Charlie" { + t.Errorf("First item title = %q, want %q", testData[0].Title, "Charlie") + } + if testData[2].Title != "Alice" { + t.Errorf("Last item title = %q, want %q", testData[2].Title, "Alice") + } + }) + + t.Run("sort by title case insensitive", func(t *testing.T) { + testData := Results{ + {Identifier: "1", Title: "zebra"}, + {Identifier: "2", Title: "Apple"}, + {Identifier: "3", Title: "banana"}, + } + + Sort(testData, SortByTitle, SortOrderAsc) + + // Should be: Apple, banana, zebra (case-insensitive) + if testData[0].Title != "Apple" { + t.Errorf("First item title = %q, want %q", testData[0].Title, "Apple") + } + if testData[1].Title != "banana" { + t.Errorf("Second item title = %q, want %q", testData[1].Title, "banana") + } + if testData[2].Title != "zebra" { + t.Errorf("Third item title = %q, want %q", testData[2].Title, "zebra") + } + }) +} + +// TestResultsBytes validates serialization +func TestResultsBytes(t *testing.T) { + tests := []struct { + name string + input Results + want string + }{ + { + name: "single note with tags", + input: Results{ + { + Identifier: "20240101T120000", + Title: "Test Note", + Tags: []string{"tag1", "tag2"}, + }, + }, + want: "20240101T120000 | Test Note | tag1,tag2\n", + }, + { + name: "note without tags", + input: Results{ + { + Identifier: "20240101T120000", + Title: "Simple Note", + Tags: []string{}, + }, + }, + want: "20240101T120000 | Simple Note | \n", + }, + { + name: "note without title", + input: Results{ + { + Identifier: "20240101T120000", + Title: "", + Tags: []string{"tag"}, + }, + }, + want: "20240101T120000 | (untitled) | tag\n", + }, + { + name: "multiple notes", + input: Results{ + {Identifier: "20240101T120000", Title: "First", Tags: []string{"a"}}, + {Identifier: "20240102T120000", Title: "Second", Tags: []string{"b", "c"}}, + }, + want: "20240101T120000 | First | a\n20240102T120000 | Second | b,c\n", + }, + { + name: "empty results", + input: Results{}, + want: "", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := string(tt.input.Bytes()) + if got != tt.want { + t.Errorf("Results.Bytes() = %q, want %q", got, tt.want) + } + }) + } +} + +// TestResultsFromString validates parsing from string format +func TestResultsFromString(t *testing.T) { + tests := []struct { + name string + input string + want Results + wantErr bool + }{ + { + name: "single note with tags", + input: "20240101T120000 | Test Note | tag1,tag2", + want: Results{ + {Identifier: "20240101T120000", Title: "Test Note", Tags: []string{"tag1", "tag2"}}, + }, + wantErr: false, + }, + { + name: "note without tags", + input: "20240101T120000 | Simple Note | ", + want: Results{ + {Identifier: "20240101T120000", Title: "Simple Note", Tags: []string{}}, + }, + wantErr: false, + }, + { + name: "multiple notes", + input: "20240101T120000 | First | a\n20240102T120000 | Second | b,c", + want: Results{ + {Identifier: "20240101T120000", Title: "First", Tags: []string{"a"}}, + {Identifier: "20240102T120000", Title: "Second", Tags: []string{"b", "c"}}, + }, + wantErr: false, + }, + { + name: "empty input", + input: "", + want: nil, + wantErr: false, + }, + { + name: "wrong column count", + input: "20240101T120000 | Title", + want: nil, + wantErr: true, + }, + { + name: "empty identifier", + input: " | Title | tags", + want: nil, + wantErr: true, + }, + { + name: "invalid tags with spaces", + input: "20240101T120000 | Title | tag with spaces", + want: nil, + wantErr: true, + }, + { + name: "invalid tags with uppercase", + input: "20240101T120000 | Title | Tag1,tag2", + want: nil, + wantErr: true, + }, + { + name: "valid lowercase unicode tags", + input: "20240101T120000 | Title | tag1,测试,αβγ", + want: Results{ + {Identifier: "20240101T120000", Title: "Title", Tags: []string{"tag1", "测试", "αβγ"}}, + }, + wantErr: false, + }, + { + name: "blank lines ignored", + input: "20240101T120000 | First | a\n\n20240102T120000 | Second | b", + want: Results{ + {Identifier: "20240101T120000", Title: "First", Tags: []string{"a"}}, + {Identifier: "20240102T120000", Title: "Second", Tags: []string{"b"}}, + }, + wantErr: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + var r Results + got, err := r.FromString(tt.input) + + if (err != nil) != tt.wantErr { + t.Errorf("Results.FromString() error = %v, wantErr %v", err, tt.wantErr) + return + } + + if tt.wantErr { + return + } + + if len(got) != len(tt.want) { + t.Fatalf("Results.FromString() length = %d, want %d", len(got), len(tt.want)) + } + + for i := range got { + if got[i].Identifier != tt.want[i].Identifier { + t.Errorf("Result[%d].Identifier = %q, want %q", i, got[i].Identifier, tt.want[i].Identifier) + } + if got[i].Title != tt.want[i].Title { + t.Errorf("Result[%d].Title = %q, want %q", i, got[i].Title, tt.want[i].Title) + } + if !SlicesEqual(got[i].Tags, tt.want[i].Tags) { + t.Errorf("Result[%d].Tags = %v, want %v", i, got[i].Tags, tt.want[i].Tags) + } + } + }) + } +} + +// TestSlicesEqual validates slice comparison +func TestSlicesEqual(t *testing.T) { + tests := []struct { + name string + a []string + b []string + want bool + }{ + { + name: "equal slices", + a: []string{"a", "b", "c"}, + b: []string{"a", "b", "c"}, + want: true, + }, + { + name: "different length", + a: []string{"a", "b"}, + b: []string{"a", "b", "c"}, + want: false, + }, + { + name: "different content", + a: []string{"a", "b", "c"}, + b: []string{"a", "x", "c"}, + want: false, + }, + { + name: "both nil", + a: nil, + b: nil, + want: true, + }, + { + name: "both empty", + a: []string{}, + b: []string{}, + want: true, + }, + { + // slices.Equal treats nil and empty slices as equal (both have len 0) + name: "nil vs empty", + a: nil, + b: []string{}, + want: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := SlicesEqual(tt.a, tt.b) + if got != tt.want { + t.Errorf("SlicesEqual(%v, %v) = %v, want %v", tt.a, tt.b, got, tt.want) + } + }) + } +} + +// TestGenerateNote validates note generation (integration test) +func TestGenerateNote(t *testing.T) { + dir := "/tmp/notes" + title := "Test Note" + keywords := []string{"test", "example"} + fileType := "md-yaml" + + path, content := GenerateNote(dir, title, keywords, fileType) + + // Validate path format + if !strings.HasPrefix(path, dir+"/") { + t.Errorf("GenerateNote() path = %q, should start with %q", path, dir+"/") + } + + // Validate filename contains identifier and title + if !strings.Contains(path, "--test-note__test_example.md") { + t.Errorf("GenerateNote() path = %q, should contain proper filename", path) + } + + // Validate content has front matter + if !strings.Contains(content, "---") { + t.Errorf("GenerateNote() content should contain YAML front matter") + } + if !strings.Contains(content, "title:") { + t.Errorf("GenerateNote() content should contain title field") + } + if !strings.Contains(content, "[test, example]") { + t.Errorf("GenerateNote() content should contain formatted tags") + } +}