Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion VERSION
Original file line number Diff line number Diff line change
@@ -1 +1 @@
v0.9.2
v0.9.3
15 changes: 11 additions & 4 deletions cmd/book/mark.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package cmd

import (
"errors"
"fmt"
"net/url"
"strings"
Expand Down Expand Up @@ -63,10 +64,16 @@ func addMark(bs *book.BookShelves, URL string, tags string, shelfName string, co
} else {
fetchedTitle, err := web.LoadWebsite(mark.URL)
if err != nil {
return err
}
if fetchedTitle == "" {
mark.Name = "couldn't fetch page title"
if errors.Is(err, web.ErrTitleUnavailable) {
// Non-interactive path can't prompt for a title.
if shelfName != "" && collectionName != "" {
return fmt.Errorf("couldn't fetch title for %s; provide --title", mark.URL)
}
// Interactive path: leave the title empty so the user is
// forced to enter it manually in the edit form.
} else {
return err
}
} else {
mark.Name = fetchedTitle
}
Expand Down
290 changes: 290 additions & 0 deletions internal/book/types_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,290 @@
package book

import (
"slices"
"testing"
)

func TestDedupUnique(t *testing.T) {
tests := []struct {
name string
in [][]string
want []string
}{
{
name: "preserves first-seen order",
in: [][]string{{"a", "b", "c"}, {"b", "a", "d"}},
want: []string{"a", "b", "c", "d"},
},
{
name: "merges multiple slices",
in: [][]string{{"x"}, {"y"}, {"z"}, {"x"}},
want: []string{"x", "y", "z"},
},
{
name: "empty input",
in: [][]string{},
want: []string{},
},
{
name: "empty slices are ignored",
in: [][]string{{}, {"a"}, {}, {"a", "b"}},
want: []string{"a", "b"},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := DedupUnique(tt.in...)
if !slices.Equal(got, tt.want) {
t.Errorf("DedupUnique() = %v, want %v", got, tt.want)
}
})
}
}

func TestStructIsEmpty(t *testing.T) {
tests := []struct {
name string
ptr *Mark
want bool
}{
{
name: "nil pointer",
ptr: nil,
want: true,
},
{
name: "zero struct",
ptr: &Mark{},
want: true,
},
{
name: "non-zero struct",
ptr: &Mark{Name: "example"},
want: false,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := StructIsEmpty(tt.ptr); got != tt.want {
t.Errorf("StructIsEmpty() = %v, want %v", got, tt.want)
}
})
}
}

func TestVerifyUniqueURL(t *testing.T) {
bs := BookShelves{
{
Name: "shelf-a",
Collections: map[string]*Collection{
"col-1": {
Name: "col-1",
Marks: []*Mark{
{ID: "abc12345", Name: "first", URL: "https://example.com/first"},
},
},
},
},
{
Name: "shelf-b",
Collections: map[string]*Collection{
"col-2": {
Name: "col-2",
Marks: []*Mark{
{ID: "def67890", Name: "second", URL: "https://example.com/second"},
},
},
},
},
}
bs.LoadParents()

tests := []struct {
name string
id string
wantErr bool
}{
{
name: "unique id passes",
id: "00000000",
wantErr: false,
},
{
name: "duplicate in first shelf",
id: "abc12345",
wantErr: true,
},
{
name: "duplicate in second shelf",
id: "def67890",
wantErr: true,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
err := bs.VerifyUniqueURL(tt.id)
if tt.wantErr && err == nil {
t.Errorf("VerifyUniqueURL(%q) expected error, got nil", tt.id)
}
if !tt.wantErr && err != nil {
t.Errorf("VerifyUniqueURL(%q) unexpected error: %v", tt.id, err)
}
})
}
}

func TestAllTags(t *testing.T) {
tests := []struct {
name string
col *Collection
want []string
}{
{
name: "sorts and merges across marks",
col: &Collection{
Marks: []*Mark{
{Tags: []string{"z", "a"}},
{Tags: []string{"b", "a"}},
},
},
want: []string{"a", "a", "b", "z"},
},
{
name: "empty collection",
col: &Collection{},
want: nil,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := tt.col.AllTags()
if !slices.Equal(got, tt.want) {
t.Errorf("AllTags() = %v, want %v", got, tt.want)
}
})
}
}

func TestDeleteMark(t *testing.T) {
markA := &Mark{Name: "a"}
markB := &Mark{Name: "b"}
markC := &Mark{Name: "c"}

tests := []struct {
name string
start []*Mark
remove *Mark
wantNames []string
wantLength int
}{
{
name: "removes by pointer identity",
start: []*Mark{markA, markB, markC},
remove: markB,
wantNames: []string{"a", "c"},
wantLength: 2,
},
{
name: "removing absent mark is no-op",
start: []*Mark{markA, markC},
remove: markB,
wantNames: []string{"a", "c"},
wantLength: 2,
},
{
name: "removes only exact pointer match",
start: []*Mark{markA, {Name: "a"}},
remove: markA,
wantNames: []string{"a"},
wantLength: 1,
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
col := &Collection{Marks: tt.start}
col.DeleteMark(tt.remove)

if len(col.Marks) != tt.wantLength {
t.Errorf("len(Marks) = %d, want %d", len(col.Marks), tt.wantLength)
}

gotNames := make([]string, len(col.Marks))
for i, m := range col.Marks {
gotNames[i] = m.Name
}
if !slices.Equal(gotNames, tt.wantNames) {
t.Errorf("remaining marks = %v, want %v", gotNames, tt.wantNames)
}
})
}
}

func TestGenerateID(t *testing.T) {
tests := []struct {
name string
url string
want string
}{
{
name: "known URL golden value",
url: "https://example.com",
want: "100680ad",
},
{
name: "different URL different id",
url: "https://example.org",
want: "50d7a905",
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := GenerateID(tt.url)
if len(got) != 8 {
t.Errorf("GenerateID() length = %d, want 8", len(got))
}
if got != tt.want {
t.Errorf("GenerateID() = %q, want %q", got, tt.want)
}
})
}
}

func TestMergeTags(t *testing.T) {
tests := []struct {
name string
in [][]string
want []string
}{
{
name: "dedups and removes empty strings",
in: [][]string{{"a", "", "b"}, {"", "b", "c"}},
want: []string{"a", "b", "c"},
},
{
name: "earlier arguments have priority",
in: [][]string{{"z", "a"}, {"a", "b"}},
want: []string{"z", "a", "b"},
},
{
name: "empty input",
in: [][]string{},
want: []string{},
},
}

for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
got := MergeTags(tt.in...)
if !slices.Equal(got, tt.want) {
t.Errorf("MergeTags() = %v, want %v", got, tt.want)
}
})
}
}
18 changes: 17 additions & 1 deletion internal/catalog/toml.go
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,23 @@ func CreateTOML(t book.TOMLFile) (err error) {
return err
}

return os.Rename(tmpPath, writePath)
if err = f.Sync(); err != nil {
return err
}

if err = os.Rename(tmpPath, writePath); err != nil {
return err
}

dir, err := os.Open(filepath.Dir(writePath))
if err != nil {
return err
}
if err = dir.Sync(); err != nil {
_ = dir.Close()
return err
}
return dir.Close()
}

// UpdateShelfFile persists the given shelf to its on-disk TOML file.
Expand Down
Loading
Loading