initial commit
This commit is contained in:
commit
23816d00f7
71 changed files with 14497 additions and 0 deletions
381
backend/storage/comments.go
Normal file
381
backend/storage/comments.go
Normal file
|
|
@ -0,0 +1,381 @@
|
|||
package storage
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
// Role identifies which audience segment a piece of feedback belongs to.
|
||||
type Role string
|
||||
|
||||
// The three audience segments isolated by go-doc.
|
||||
const (
|
||||
RoleUser Role = "user"
|
||||
RoleOperator Role = "operator"
|
||||
RoleDeveloper Role = "developer"
|
||||
)
|
||||
|
||||
// Badge glyphs and display labels per role. Badges are derived server-side from
|
||||
// the role so a client can never spoof the visual marker of another audience.
|
||||
var roleMeta = map[Role]struct {
|
||||
Badge string
|
||||
Label string
|
||||
Hex string
|
||||
}{
|
||||
RoleUser: {Badge: "🟢", Label: "User", Hex: "#22c55e"},
|
||||
RoleOperator: {Badge: "🔵", Label: "Operator", Hex: "#3b82f6"},
|
||||
RoleDeveloper: {Badge: "🔴", Label: "Developer", Hex: "#ef4444"},
|
||||
}
|
||||
|
||||
// Badge returns the coloured badge glyph for the role (User 🟢, Operator 🔵,
|
||||
// Developer 🔴). Unknown roles yield an empty badge.
|
||||
func (r Role) Badge() string { return roleMeta[r].Badge }
|
||||
|
||||
// Label returns the human name of the role: User, Operator or Developer.
|
||||
func (r Role) Label() string { return roleMeta[r].Label }
|
||||
|
||||
// Hex returns the badge colour as a CSS hex value for the frontend to mirror.
|
||||
func (r Role) Hex() string { return roleMeta[r].Hex }
|
||||
|
||||
// Valid reports whether r is one of the three known roles.
|
||||
func (r Role) Valid() bool {
|
||||
_, ok := roleMeta[r]
|
||||
return ok
|
||||
}
|
||||
|
||||
// ParseRole canonicalises user input ("Developer", "DEV", " developer ") into a
|
||||
// Role. It returns ErrInvalidRole for anything unrecognised.
|
||||
func ParseRole(raw string) (Role, error) {
|
||||
normalized := strings.ToLower(strings.TrimSpace(raw))
|
||||
switch normalized {
|
||||
case string(RoleUser):
|
||||
return RoleUser, nil
|
||||
case string(RoleOperator):
|
||||
return RoleOperator, nil
|
||||
case string(RoleDeveloper):
|
||||
return RoleDeveloper, nil
|
||||
default:
|
||||
return "", fmt.Errorf("%w: %q (want user, operator or developer)", ErrInvalidRole, raw)
|
||||
}
|
||||
}
|
||||
|
||||
// Roles lists every supported role in audience order.
|
||||
func Roles() []Role { return []Role{RoleUser, RoleOperator, RoleDeveloper} }
|
||||
|
||||
// Comment is one audience message attached to a document.
|
||||
type Comment struct {
|
||||
ID string `json:"id"`
|
||||
Author string `json:"author"`
|
||||
Role Role `json:"role"`
|
||||
Badge string `json:"badge"`
|
||||
Content string `json:"content"`
|
||||
Timestamp time.Time `json:"timestamp"`
|
||||
TenantID string `json:"tenant_id,omitempty"`
|
||||
TenantSlug string `json:"tenant_slug,omitempty"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
// CommentInput is the write-side payload for an incoming comment. Badge and
|
||||
// Timestamp are optional; the engine fills both in when they are absent.
|
||||
type CommentInput struct {
|
||||
Author string `json:"author"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
Timestamp time.Time `json:"timestamp,omitempty"`
|
||||
TenantID string `json:"tenant_id,omitempty"`
|
||||
TenantSlug string `json:"tenant_slug,omitempty"`
|
||||
UserID string `json:"user_id,omitempty"`
|
||||
}
|
||||
|
||||
const (
|
||||
maxAuthorLen = 120
|
||||
maxCommentLen = 8 << 10
|
||||
)
|
||||
|
||||
// commentsSuffix keeps feedback for slug "core-service-api" in
|
||||
// core-service-api-comments.json, as mandated by the storage layout.
|
||||
const commentsSuffix = "-comments.json"
|
||||
|
||||
func (e *Engine) commentPath(slug string) string {
|
||||
return filepath.Join(e.commentsDir, slug+commentsSuffix)
|
||||
}
|
||||
|
||||
// normalizeBody canonicalises line endings in a comment body without appending a
|
||||
// trailing newline, so consumers can render it verbatim.
|
||||
func normalizeBody(content string) string {
|
||||
normalized := strings.ReplaceAll(content, "\r\n", "\n")
|
||||
normalized = strings.ReplaceAll(normalized, "\r", "\n")
|
||||
return strings.Trim(normalized, "\n")
|
||||
}
|
||||
|
||||
// AppendComment validates and stores a new comment for slug, returning the
|
||||
// stored record with its generated ID, badge, and timestamp. The read-modify-write
|
||||
// cycle is serialised per slug and committed atomically, so concurrent writers
|
||||
// cannot lose or interleave entries.
|
||||
func (e *Engine) AppendComment(slug string, in CommentInput) (Comment, error) {
|
||||
normalized, err := NormalizeSlug(slug)
|
||||
if err != nil {
|
||||
return Comment{}, err
|
||||
}
|
||||
|
||||
role, err := ParseRole(in.Role)
|
||||
if err != nil {
|
||||
return Comment{}, err
|
||||
}
|
||||
|
||||
author := strings.TrimSpace(in.Author)
|
||||
if author == "" {
|
||||
author = role.Label()
|
||||
}
|
||||
if utf8.RuneCountInString(author) > maxAuthorLen {
|
||||
return Comment{}, fmt.Errorf("%w: author exceeds %d characters", ErrInvalidComment, maxAuthorLen)
|
||||
}
|
||||
|
||||
content := strings.TrimSpace(in.Content)
|
||||
if content == "" {
|
||||
return Comment{}, fmt.Errorf("%w: content is empty", ErrInvalidComment)
|
||||
}
|
||||
content = normalizeBody(content)
|
||||
if len(content) > maxCommentLen {
|
||||
return Comment{}, fmt.Errorf("%w: content exceeds %d bytes", ErrInvalidComment, maxCommentLen)
|
||||
}
|
||||
|
||||
comment := Comment{
|
||||
ID: NewCommentID(),
|
||||
Author: author,
|
||||
Role: role,
|
||||
Badge: role.Badge(),
|
||||
Content: content,
|
||||
Timestamp: in.Timestamp.UTC(),
|
||||
TenantID: in.TenantID,
|
||||
TenantSlug: in.TenantSlug,
|
||||
UserID: in.UserID,
|
||||
}
|
||||
if in.Timestamp.IsZero() {
|
||||
comment.Timestamp = e.now().UTC()
|
||||
}
|
||||
|
||||
mu := e.lockFor(normalized)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
existing, err := e.readComments(normalized)
|
||||
if err != nil {
|
||||
return Comment{}, err
|
||||
}
|
||||
|
||||
existing = append(existing, comment)
|
||||
if err := e.writeComments(normalized, existing); err != nil {
|
||||
return Comment{}, err
|
||||
}
|
||||
return comment, nil
|
||||
}
|
||||
|
||||
// ListComments returns every comment for slug in insertion order, oldest first.
|
||||
// A document without feedback yields an empty slice and no error.
|
||||
func (e *Engine) ListComments(slug string) ([]Comment, error) {
|
||||
normalized, err := NormalizeSlug(slug)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
mu := e.lockFor(normalized)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
return e.readComments(normalized)
|
||||
}
|
||||
|
||||
// DeleteComment removes a single comment by ID and reports whether it existed.
|
||||
func (e *Engine) DeleteComment(slug, id string) (bool, error) {
|
||||
normalized, err := NormalizeSlug(slug)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
id = strings.TrimSpace(id)
|
||||
if id == "" {
|
||||
return false, fmt.Errorf("%w: comment id is empty", ErrInvalidComment)
|
||||
}
|
||||
|
||||
mu := e.lockFor(normalized)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
existing, err := e.readComments(normalized)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
remaining := make([]Comment, 0, len(existing))
|
||||
found := false
|
||||
for _, c := range existing {
|
||||
if !found && c.ID == id {
|
||||
found = true
|
||||
continue
|
||||
}
|
||||
remaining = append(remaining, c)
|
||||
}
|
||||
if !found {
|
||||
return false, nil
|
||||
}
|
||||
if err := e.writeComments(normalized, remaining); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// DeleteAllComments drops every comment registered for slug.
|
||||
func (e *Engine) DeleteAllComments(slug string) error {
|
||||
normalized, err := NormalizeSlug(slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
mu := e.lockFor(normalized)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if err := os.Remove(e.commentPath(normalized)); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("storage: delete comments for %q: %w", slug, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// CommentStats summarises the feedback load on a single document.
|
||||
type CommentStats struct {
|
||||
Total int `json:"total"`
|
||||
ByRole map[string]int `json:"by_role"`
|
||||
LatestAt *time.Time `json:"latest_at,omitempty"`
|
||||
OldestAt *time.Time `json:"oldest_at,omitempty"`
|
||||
MostActive string `json:"most_active,omitempty"`
|
||||
}
|
||||
|
||||
// Stats aggregates a document's comments by role.
|
||||
func (e *Engine) Stats(slug string) (CommentStats, error) {
|
||||
comments, err := e.ListComments(slug)
|
||||
if err != nil {
|
||||
return CommentStats{}, err
|
||||
}
|
||||
stats := CommentStats{Total: len(comments), ByRole: map[string]int{}}
|
||||
if len(comments) == 0 {
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
oldest, newest := comments[0].Timestamp, comments[0].Timestamp
|
||||
for _, c := range comments {
|
||||
stats.ByRole[string(c.Role)]++
|
||||
if c.Timestamp.Before(oldest) {
|
||||
oldest = c.Timestamp
|
||||
}
|
||||
if c.Timestamp.After(newest) {
|
||||
newest = c.Timestamp
|
||||
}
|
||||
}
|
||||
stats.OldestAt = &oldest
|
||||
stats.LatestAt = &newest
|
||||
|
||||
best, bestCount := "", -1
|
||||
for _, role := range Roles() {
|
||||
if n := stats.ByRole[string(role)]; n > bestCount {
|
||||
best, bestCount = string(role), n
|
||||
}
|
||||
}
|
||||
if bestCount > 0 {
|
||||
stats.MostActive = best
|
||||
}
|
||||
return stats, nil
|
||||
}
|
||||
|
||||
// readComments loads the JSON array backing a slug. Callers must hold the slug
|
||||
// lock. A missing or empty file yields an empty slice.
|
||||
func (e *Engine) readComments(slug string) ([]Comment, error) {
|
||||
path := e.commentPath(slug)
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return []Comment{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("storage: read comments for %q: %w", slug, err)
|
||||
}
|
||||
if len(strings.TrimSpace(string(data))) == 0 {
|
||||
return []Comment{}, nil
|
||||
}
|
||||
|
||||
var comments []Comment
|
||||
if err := json.Unmarshal(data, &comments); err != nil {
|
||||
return nil, fmt.Errorf("storage: decode comments for %q: %w", slug, err)
|
||||
}
|
||||
for i := range comments {
|
||||
if comments[i].Badge == "" {
|
||||
comments[i].Badge = comments[i].Role.Badge()
|
||||
}
|
||||
}
|
||||
return comments, nil
|
||||
}
|
||||
|
||||
// writeComments serialises comments back as a JSON array. Callers must hold the
|
||||
// slug lock. Entries are sorted by timestamp so the file order matches the
|
||||
// reader's view even when the seeding script injects backdated records.
|
||||
func (e *Engine) writeComments(slug string, comments []Comment) error {
|
||||
if comments == nil {
|
||||
comments = []Comment{}
|
||||
}
|
||||
sorted := make([]Comment, len(comments))
|
||||
copy(sorted, comments)
|
||||
sort.SliceStable(sorted, func(i, j int) bool {
|
||||
if sorted[i].Timestamp.Equal(sorted[j].Timestamp) {
|
||||
return sorted[i].ID < sorted[j].ID
|
||||
}
|
||||
return sorted[i].Timestamp.Before(sorted[j].Timestamp)
|
||||
})
|
||||
|
||||
encoded, err := json.MarshalIndent(sorted, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: encode comments for %q: %w", slug, err)
|
||||
}
|
||||
encoded = append(encoded, '\n')
|
||||
|
||||
if err := writeFileAtomic(e.commentPath(slug), encoded, 0o644); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// NewCommentID returns a collision-resistant identifier: a millisecond timestamp
|
||||
// prefix for rough chronological ordering plus 8 random bytes.
|
||||
func NewCommentID() string {
|
||||
var buf [8]byte
|
||||
if _, err := rand.Read(buf[:]); err != nil {
|
||||
// crypto/rand cannot fail on Linux; fall back to a time-derived value so
|
||||
// the API degrades rather than errors.
|
||||
return fmt.Sprintf("c%d", time.Now().UnixNano())
|
||||
}
|
||||
return fmt.Sprintf("c%x_%s", time.Now().UnixMilli(), hex.EncodeToString(buf[:]))
|
||||
}
|
||||
|
||||
// AppendComment stores feedback through the process-wide engine.
|
||||
func AppendComment(slug string, in CommentInput) (Comment, error) {
|
||||
e, err := Default()
|
||||
if err != nil {
|
||||
return Comment{}, err
|
||||
}
|
||||
return e.AppendComment(slug, in)
|
||||
}
|
||||
|
||||
// ListComments reads feedback through the process-wide engine.
|
||||
func ListComments(slug string) ([]Comment, error) {
|
||||
e, err := Default()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.ListComments(slug)
|
||||
}
|
||||
635
backend/storage/engine.go
Normal file
635
backend/storage/engine.go
Normal file
|
|
@ -0,0 +1,635 @@
|
|||
// Package storage implements the flat-file persistence engine behind go-doc.
|
||||
//
|
||||
// Documents live on disk as raw Markdown files at data/docs/<slug>.md and
|
||||
// audience feedback is stored as JSON arrays at data/comments/<slug>-comments.json.
|
||||
//
|
||||
// Every mutation is written with a temp-file + fsync + rename sequence so a
|
||||
// concurrent reader can never observe a half-written file, and read-modify-write
|
||||
// cycles on the same document are serialised by a keyed mutex. That combination
|
||||
// is what lets the engine stay correct while the seed script floods it with
|
||||
// thousands of concurrent comments.
|
||||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
"unicode"
|
||||
"unicode/utf8"
|
||||
)
|
||||
|
||||
const (
|
||||
// DocExt is the file extension used for every Markdown document.
|
||||
DocExt = ".md"
|
||||
|
||||
// maxSlugLen caps generated slugs so a pathological title cannot produce an
|
||||
// unusable filename.
|
||||
maxSlugLen = 120
|
||||
|
||||
// maxTitleScanBytes limits how much of a document is scanned when extracting
|
||||
// its title from the leading heading.
|
||||
maxTitleScanBytes = 64 << 10
|
||||
|
||||
docsDirName = "docs"
|
||||
commentsDirName = "comments"
|
||||
)
|
||||
|
||||
// Errors returned by the engine. Callers map these onto HTTP status codes.
|
||||
var (
|
||||
// ErrNotFound is returned when a document or comment does not exist.
|
||||
ErrNotFound = errors.New("storage: not found")
|
||||
// ErrInvalidSlug is returned for slugs that cannot be safely slugified.
|
||||
ErrInvalidSlug = errors.New("storage: invalid slug")
|
||||
// ErrInvalidRole is returned when a comment carries an unknown role.
|
||||
ErrInvalidRole = errors.New("storage: invalid role")
|
||||
// ErrInvalidComment is returned when a comment payload is unusable.
|
||||
ErrInvalidComment = errors.New("storage: invalid comment")
|
||||
)
|
||||
|
||||
// Engine owns a data directory and all flat-file operations against it.
|
||||
// Engine is safe for concurrent use by multiple goroutines.
|
||||
type Engine struct {
|
||||
root string
|
||||
docsDir string
|
||||
commentsDir string
|
||||
|
||||
locksMu sync.Mutex
|
||||
locks map[string]*sync.Mutex
|
||||
|
||||
// Now is injectable so tests can assert deterministic timestamps.
|
||||
Now func() time.Time
|
||||
}
|
||||
|
||||
// New returns an Engine rooted at root and creates docs/ and comments/ beneath
|
||||
// it when they are missing.
|
||||
func New(root string) (*Engine, error) {
|
||||
if strings.TrimSpace(root) == "" {
|
||||
return nil, fmt.Errorf("storage: empty data root")
|
||||
}
|
||||
abs, err := filepath.Abs(root)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("storage: resolve data root: %w", err)
|
||||
}
|
||||
e := &Engine{
|
||||
root: abs,
|
||||
docsDir: filepath.Join(abs, docsDirName),
|
||||
commentsDir: filepath.Join(abs, commentsDirName),
|
||||
locks: make(map[string]*sync.Mutex),
|
||||
Now: time.Now,
|
||||
}
|
||||
if err := os.MkdirAll(e.docsDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("storage: create docs dir: %w", err)
|
||||
}
|
||||
if err := os.MkdirAll(e.commentsDir, 0o755); err != nil {
|
||||
return nil, fmt.Errorf("storage: create comments dir: %w", err)
|
||||
}
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// Root returns the absolute data directory backing the engine.
|
||||
func (e *Engine) Root() string { return e.root }
|
||||
|
||||
// DocsDir returns the absolute directory holding Markdown documents.
|
||||
func (e *Engine) DocsDir() string { return e.docsDir }
|
||||
|
||||
// CommentsDir returns the absolute directory holding comment JSON files.
|
||||
func (e *Engine) CommentsDir() string { return e.commentsDir }
|
||||
|
||||
func (e *Engine) now() time.Time {
|
||||
if e.Now != nil {
|
||||
return e.Now()
|
||||
}
|
||||
return time.Now()
|
||||
}
|
||||
|
||||
// lockFor returns the mutex guarding a single slug. RLock-free by design: the
|
||||
// critical sections are short, and per-slug granularity is what allows writes to
|
||||
// different documents to proceed fully in parallel.
|
||||
func (e *Engine) lockFor(slug string) *sync.Mutex {
|
||||
e.locksMu.Lock()
|
||||
defer e.locksMu.Unlock()
|
||||
m, ok := e.locks[slug]
|
||||
if !ok {
|
||||
m = &sync.Mutex{}
|
||||
e.locks[slug] = m
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
// ResolveRoot discovers the data directory without any configuration. It honours
|
||||
// GO_DOC_DATA_DIR first, then walks up from the working directory looking for an
|
||||
// existing data/ folder, and finally falls back to a relative "data".
|
||||
func ResolveRoot() string {
|
||||
if v := strings.TrimSpace(os.Getenv("GO_DOC_DATA_DIR")); v != "" {
|
||||
return v
|
||||
}
|
||||
dir, err := os.Getwd()
|
||||
if err == nil {
|
||||
for i := 0; i < 8; i++ {
|
||||
candidate := filepath.Join(dir, "data")
|
||||
if info, err := os.Stat(filepath.Join(candidate, docsDirName)); err == nil && info.IsDir() {
|
||||
return candidate
|
||||
}
|
||||
parent := filepath.Dir(dir)
|
||||
if parent == dir {
|
||||
break
|
||||
}
|
||||
dir = parent
|
||||
}
|
||||
}
|
||||
return "data"
|
||||
}
|
||||
|
||||
var (
|
||||
defaultOnce sync.Once
|
||||
defaultEng *Engine
|
||||
defaultErr error
|
||||
)
|
||||
|
||||
// Default returns the process-wide engine rooted at ResolveRoot().
|
||||
func Default() (*Engine, error) {
|
||||
defaultOnce.Do(func() {
|
||||
defaultEng, defaultErr = New(ResolveRoot())
|
||||
})
|
||||
return defaultEng, defaultErr
|
||||
}
|
||||
|
||||
// ResetDefault drops the cached engine so the next Default call re-resolves the
|
||||
// root. Tests use it between suites that point at different temp directories.
|
||||
func ResetDefault() {
|
||||
defaultOnce = sync.Once{}
|
||||
defaultEng = nil
|
||||
defaultErr = nil
|
||||
}
|
||||
|
||||
// SetDefault installs root as the process-wide engine.
|
||||
func SetDefault(root string) (*Engine, error) {
|
||||
e, err := New(root)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
ResetDefault()
|
||||
defaultOnce.Do(func() {})
|
||||
defaultEng, defaultErr = e, nil
|
||||
return e, nil
|
||||
}
|
||||
|
||||
// latinFold maps lower-case Latin letters carrying diacritics, plus common
|
||||
// ligatures, onto their ASCII skeleton. Folding happens after ToLower, so the
|
||||
// keys are always lower case.
|
||||
var latinFold = map[rune]rune{
|
||||
'á': 'a', 'à': 'a', 'â': 'a', 'ä': 'a', 'ã': 'a', 'å': 'a', 'ā': 'a', 'ă': 'a', 'ą': 'a',
|
||||
'ç': 'c', 'ć': 'c', 'č': 'c', 'ĉ': 'c', 'ċ': 'c',
|
||||
'ď': 'd', 'đ': 'd',
|
||||
'é': 'e', 'è': 'e', 'ê': 'e', 'ë': 'e', 'ē': 'e', 'ė': 'e', 'ę': 'e', 'ě': 'e',
|
||||
'ğ': 'g', 'ĝ': 'g', 'ġ': 'g', 'ģ': 'g',
|
||||
'í': 'i', 'ì': 'i', 'î': 'i', 'ï': 'i', 'ī': 'i', 'į': 'i', 'ı': 'i',
|
||||
'ĵ': 'j',
|
||||
'ķ': 'k',
|
||||
'ł': 'l', 'ľ': 'l', 'ĺ': 'l',
|
||||
'ñ': 'n', 'ń': 'n', 'ň': 'n',
|
||||
'ó': 'o', 'ò': 'o', 'ô': 'o', 'ö': 'o', 'õ': 'o', 'ø': 'o', 'ō': 'o', 'ő': 'o',
|
||||
'ŕ': 'r', 'ř': 'r',
|
||||
'ś': 's', 'š': 's', 'ş': 's', 'ș': 's',
|
||||
'ß': 's', 'þ': 't', 'ť': 't',
|
||||
'ú': 'u', 'ù': 'u', 'û': 'u', 'ü': 'u', 'ū': 'u', 'ů': 'u', 'ű': 'u',
|
||||
'ŵ': 'w', 'ý': 'y', 'ÿ': 'y', 'ŷ': 'y',
|
||||
'ź': 'z', 'ż': 'z', 'ž': 'z',
|
||||
}
|
||||
|
||||
// Slugify converts an arbitrary human title into a lower-case, hyphen-separated
|
||||
// slug: "Core Service API" becomes "core-service-api". Diacritics are folded to
|
||||
// ASCII, every other separator run collapses into a single hyphen, and the result
|
||||
// is trimmed and length-capped.
|
||||
func Slugify(title string) string {
|
||||
var b strings.Builder
|
||||
b.Grow(len(title))
|
||||
|
||||
pendingSep := true // suppresses a leading hyphen
|
||||
for _, r := range strings.ToLower(title) {
|
||||
if folded, ok := latinFold[r]; ok {
|
||||
r = folded
|
||||
}
|
||||
switch {
|
||||
case (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'):
|
||||
b.WriteRune(r)
|
||||
pendingSep = false
|
||||
default:
|
||||
// Everything else -- punctuation, whitespace, non-ASCII letters that
|
||||
// carry no ASCII skeleton, and invalid UTF-8 -- collapses into a single
|
||||
// separator so slugs stay safe in URLs and filenames everywhere.
|
||||
if !pendingSep {
|
||||
b.WriteByte('-')
|
||||
pendingSep = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
slug := strings.Trim(b.String(), "-")
|
||||
if utf8.RuneCountInString(slug) > maxSlugLen {
|
||||
truncated := slug[:maxSlugLen]
|
||||
for len(truncated) > 0 && truncated[len(truncated)-1] == '-' {
|
||||
truncated = truncated[:len(truncated)-1]
|
||||
}
|
||||
slug = truncated
|
||||
}
|
||||
return slug
|
||||
}
|
||||
|
||||
// NormalizeSlug validates and canonicalises a slug coming from a URL path.
|
||||
// Path separators, traversal segments and control characters are rejected
|
||||
// outright with ErrInvalidSlug; anything else is slugified so that
|
||||
// "Core Service API" and "core-service-api" address the same document.
|
||||
func NormalizeSlug(slug string) (string, error) {
|
||||
trimmed := strings.TrimSpace(slug)
|
||||
if trimmed == "" {
|
||||
return "", fmt.Errorf("%w: slug is empty", ErrInvalidSlug)
|
||||
}
|
||||
if len(trimmed) > 2*maxSlugLen {
|
||||
return "", fmt.Errorf("%w: slug exceeds %d bytes", ErrInvalidSlug, 2*maxSlugLen)
|
||||
}
|
||||
if strings.ContainsAny(trimmed, "/\\\x00") || strings.Contains(trimmed, "..") {
|
||||
return "", fmt.Errorf("%w: slug %q contains path separators", ErrInvalidSlug, slug)
|
||||
}
|
||||
for _, r := range trimmed {
|
||||
if unicode.IsControl(r) {
|
||||
return "", fmt.Errorf("%w: slug %q contains control characters", ErrInvalidSlug, slug)
|
||||
}
|
||||
}
|
||||
|
||||
normalized := Slugify(trimmed)
|
||||
if normalized == "" {
|
||||
return "", fmt.Errorf("%w: slug %q has no usable characters", ErrInvalidSlug, slug)
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
var strictSlug = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
|
||||
|
||||
// IsCanonicalSlug reports whether slug is already in the canonical form produced
|
||||
// by Slugify. Handlers use it to decide whether a rename touched the filesystem.
|
||||
func IsCanonicalSlug(slug string) bool { return strictSlug.MatchString(slug) }
|
||||
|
||||
func (e *Engine) docPath(slug string) (string, error) {
|
||||
normalized, err := NormalizeSlug(slug)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return filepath.Join(e.docsDir, normalized+DocExt), nil
|
||||
}
|
||||
|
||||
// SaveDoc writes content to data/docs/<slug>.md, creating the document when it
|
||||
// does not exist and replacing it wholesale otherwise. The write is atomic and
|
||||
// therefore safe to call concurrently with readers.
|
||||
func (e *Engine) SaveDoc(slug, content string) (string, error) {
|
||||
normalized, err := NormalizeSlug(slug)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
path := filepath.Join(e.docsDir, normalized+DocExt)
|
||||
|
||||
mu := e.lockFor(normalized)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
normalizedContent := NormalizeMarkdown(content)
|
||||
if err := writeFileAtomic(path, []byte(normalizedContent), 0o644); err != nil {
|
||||
return "", err
|
||||
}
|
||||
return normalized, nil
|
||||
}
|
||||
|
||||
// NormalizeMarkdown converts CRLF/CR line endings to LF, guarantees a single
|
||||
// trailing newline, and leaves the author's content otherwise untouched.
|
||||
func NormalizeMarkdown(content string) string {
|
||||
if content == "" {
|
||||
return ""
|
||||
}
|
||||
normalized := strings.ReplaceAll(content, "\r\n", "\n")
|
||||
normalized = strings.ReplaceAll(normalized, "\r", "\n")
|
||||
normalized = strings.TrimRight(normalized, "\n") + "\n"
|
||||
return normalized
|
||||
}
|
||||
|
||||
// ReadDoc returns the raw Markdown stored at data/docs/<slug>.doc.
|
||||
// A missing document yields ErrNotFound.
|
||||
func (e *Engine) ReadDoc(slug string) (string, error) {
|
||||
path, err := e.docPath(slug)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return "", fmt.Errorf("%w: doc %q", ErrNotFound, slug)
|
||||
}
|
||||
return "", fmt.Errorf("storage: read doc %q: %w", slug, err)
|
||||
}
|
||||
return string(data), nil
|
||||
}
|
||||
|
||||
// DeleteDoc permanently removes data/docs/<slug>.md together with the document's
|
||||
// comment file. Removing a document that does not exist yields ErrNotFound.
|
||||
func (e *Engine) DeleteDoc(slug string) error {
|
||||
normalized, err := NormalizeSlug(slug)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
path := filepath.Join(e.docsDir, normalized+DocExt)
|
||||
|
||||
mu := e.lockFor(normalized)
|
||||
mu.Lock()
|
||||
defer mu.Unlock()
|
||||
|
||||
if err := os.Remove(path); err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("%w: doc %q", ErrNotFound, slug)
|
||||
}
|
||||
return fmt.Errorf("storage: delete doc %q: %w", slug, err)
|
||||
}
|
||||
|
||||
// Cascade the comment file so a recreated slug does not inherit stale history.
|
||||
commentPath := e.commentPath(normalized)
|
||||
if err := os.Remove(commentPath); err != nil && !errors.Is(err, fs.ErrNotExist) {
|
||||
return fmt.Errorf("storage: delete comments for %q: %w", slug, err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// DocMeta is the index entry describing one stored document.
|
||||
type DocMeta struct {
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
Size int64 `json:"size"`
|
||||
Comments int `json:"comments"`
|
||||
Modified time.Time `json:"modified"`
|
||||
}
|
||||
|
||||
var headingPattern = regexp.MustCompile(`(?m)^#{1,6}[ \t]+(.+?)[ \t]*#*[ \t]*$`)
|
||||
|
||||
// ExtractTitle returns the first Markdown heading in content, falling back to a
|
||||
// title-cased rendering of the slug.
|
||||
func ExtractTitle(content, slug string) string {
|
||||
if len(content) > maxTitleScanBytes {
|
||||
content = content[:maxTitleScanBytes]
|
||||
}
|
||||
if m := headingPattern.FindStringSubmatch(content); m != nil {
|
||||
if title := strings.TrimSpace(m[1]); title != "" {
|
||||
return title
|
||||
}
|
||||
}
|
||||
return TitleizeSlug(slug)
|
||||
}
|
||||
|
||||
// TitleizeSlug renders a slug as a human title: "core-service-api" becomes
|
||||
// "Core Service API".
|
||||
func TitleizeSlug(slug string) string {
|
||||
words := strings.FieldsFunc(slug, func(r rune) bool { return r == '-' || r == '_' })
|
||||
for i, w := range words {
|
||||
if w == "" {
|
||||
continue
|
||||
}
|
||||
runes := []rune(strings.ToLower(w))
|
||||
runes[0] = unicode.ToUpper(runes[0])
|
||||
words[i] = string(runes)
|
||||
}
|
||||
if len(words) == 0 {
|
||||
return slug
|
||||
}
|
||||
return strings.Join(words, " ")
|
||||
}
|
||||
|
||||
// DocExists reports whether a document is present on disk.
|
||||
func (e *Engine) DocExists(slug string) (bool, error) {
|
||||
path, err := e.docPath(slug)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
return false, fmt.Errorf("storage: stat doc %q: %w", slug, err)
|
||||
}
|
||||
return !info.IsDir(), nil
|
||||
}
|
||||
|
||||
// Stat returns index metadata for a single document.
|
||||
func (e *Engine) Stat(slug string) (DocMeta, error) {
|
||||
normalized, err := NormalizeSlug(slug)
|
||||
if err != nil {
|
||||
return DocMeta{}, err
|
||||
}
|
||||
path := filepath.Join(e.docsDir, normalized+DocExt)
|
||||
info, err := os.Stat(path)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return DocMeta{}, fmt.Errorf("%w: doc %q", ErrNotFound, slug)
|
||||
}
|
||||
return DocMeta{}, fmt.Errorf("storage: stat doc %q: %w", slug, err)
|
||||
}
|
||||
|
||||
meta := DocMeta{Slug: normalized, Size: info.Size(), Modified: info.ModTime().UTC()}
|
||||
if head, err := os.ReadFile(path); err == nil {
|
||||
meta.Title = ExtractTitle(string(head), normalized)
|
||||
} else {
|
||||
meta.Title = TitleizeSlug(normalized)
|
||||
}
|
||||
if comments, err := e.ListComments(normalized); err == nil {
|
||||
meta.Comments = len(comments)
|
||||
}
|
||||
return meta, nil
|
||||
}
|
||||
|
||||
// ListDocs indexes every Markdown document on disk, newest modification first.
|
||||
// It reads only the head of each file to recover the document title, so listing
|
||||
// hundreds of documents stays cheap; use ListDocsDetailed when comment counts are
|
||||
// needed as well.
|
||||
func (e *Engine) ListDocs() ([]DocMeta, error) { return e.listDocs(false) }
|
||||
|
||||
// ListDocsDetailed is ListDocs with per-document comment counts filled in.
|
||||
func (e *Engine) ListDocsDetailed() ([]DocMeta, error) { return e.listDocs(true) }
|
||||
|
||||
func (e *Engine) listDocs(withComments bool) ([]DocMeta, error) {
|
||||
entries, err := os.ReadDir(e.docsDir)
|
||||
if err != nil {
|
||||
if errors.Is(err, fs.ErrNotExist) {
|
||||
return []DocMeta{}, nil
|
||||
}
|
||||
return nil, fmt.Errorf("storage: list docs: %w", err)
|
||||
}
|
||||
|
||||
metas := make([]DocMeta, 0, len(entries))
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() {
|
||||
continue
|
||||
}
|
||||
name := entry.Name()
|
||||
if !strings.HasSuffix(name, DocExt) || strings.HasPrefix(name, ".") {
|
||||
continue
|
||||
}
|
||||
slug := strings.TrimSuffix(name, DocExt)
|
||||
if !IsCanonicalSlug(slug) {
|
||||
continue
|
||||
}
|
||||
info, err := entry.Info()
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
meta := DocMeta{
|
||||
Slug: slug,
|
||||
Title: e.readTitle(filepath.Join(e.docsDir, name), slug),
|
||||
Size: info.Size(),
|
||||
Modified: info.ModTime().UTC(),
|
||||
}
|
||||
if withComments {
|
||||
if comments, err := e.ListComments(slug); err == nil {
|
||||
meta.Comments = len(comments)
|
||||
}
|
||||
}
|
||||
metas = append(metas, meta)
|
||||
}
|
||||
|
||||
sort.Slice(metas, func(i, j int) bool {
|
||||
if metas[i].Modified.Equal(metas[j].Modified) {
|
||||
return metas[i].Slug < metas[j].Slug
|
||||
}
|
||||
return metas[i].Modified.After(metas[j].Modified)
|
||||
})
|
||||
return metas, nil
|
||||
}
|
||||
|
||||
// Search returns documents whose slug or extracted title contains query,
|
||||
// case-insensitively. An empty query lists everything.
|
||||
func (e *Engine) Search(query string) ([]DocMeta, error) {
|
||||
metas, err := e.ListDocs()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
needle := strings.ToLower(strings.TrimSpace(query))
|
||||
if needle == "" {
|
||||
return metas, nil
|
||||
}
|
||||
matches := make([]DocMeta, 0, len(metas))
|
||||
for _, meta := range metas {
|
||||
if strings.Contains(strings.ToLower(meta.Slug), needle) ||
|
||||
strings.Contains(strings.ToLower(meta.Title), needle) {
|
||||
matches = append(matches, meta)
|
||||
}
|
||||
}
|
||||
return matches, nil
|
||||
}
|
||||
|
||||
// readTitle recovers a document title from the head of the file, reading at most
|
||||
// maxTitleScanBytes so a huge document cannot slow the index down. Any failure
|
||||
// degrades to a title derived from the slug.
|
||||
func (e *Engine) readTitle(path, slug string) string {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return TitleizeSlug(slug)
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
head := make([]byte, maxTitleScanBytes)
|
||||
n, err := file.Read(head)
|
||||
if n <= 0 || (err != nil && n == 0) {
|
||||
return TitleizeSlug(slug)
|
||||
}
|
||||
return ExtractTitle(string(head[:n]), slug)
|
||||
}
|
||||
|
||||
// writeFileAtomic writes data to path via a temporary file in the same directory,
|
||||
// fsyncs it, renames it into place, and fsyncs the parent directory so the
|
||||
// rename survives a crash. Readers therefore see either the old or the new file.
|
||||
func writeFileAtomic(path string, data []byte, perm os.FileMode) error {
|
||||
dir := filepath.Dir(path)
|
||||
tmp, err := os.CreateTemp(dir, ".godoc-tmp-*")
|
||||
if err != nil {
|
||||
return fmt.Errorf("storage: create temp file: %w", err)
|
||||
}
|
||||
tmpName := tmp.Name()
|
||||
|
||||
cleanup := func() {
|
||||
tmp.Close()
|
||||
os.Remove(tmpName)
|
||||
}
|
||||
|
||||
if _, err := tmp.Write(data); err != nil {
|
||||
cleanup()
|
||||
return fmt.Errorf("storage: write temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Sync(); err != nil {
|
||||
cleanup()
|
||||
return fmt.Errorf("storage: sync temp file: %w", err)
|
||||
}
|
||||
if err := tmp.Close(); err != nil {
|
||||
os.Remove(tmpName)
|
||||
return fmt.Errorf("storage: close temp file: %w", err)
|
||||
}
|
||||
if err := os.Chmod(tmpName, perm); err != nil {
|
||||
os.Remove(tmpName)
|
||||
return fmt.Errorf("storage: chmod temp file: %w", err)
|
||||
}
|
||||
if err := os.Rename(tmpName, path); err != nil {
|
||||
os.Remove(tmpName)
|
||||
return fmt.Errorf("storage: rename into place: %w", err)
|
||||
}
|
||||
if err := syncDir(dir); err != nil {
|
||||
return fmt.Errorf("storage: sync dir: %w", err)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// syncDir flushes a directory entry so a completed rename is durable.
|
||||
func syncDir(dir string) error {
|
||||
f, err := os.Open(dir)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer f.Close()
|
||||
return f.Sync()
|
||||
}
|
||||
|
||||
// SaveDoc writes content to data/docs/<slug>.md through the process-wide engine.
|
||||
func SaveDoc(slug, content string) (string, error) {
|
||||
e, err := Default()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return e.SaveDoc(slug, content)
|
||||
}
|
||||
|
||||
// ReadDoc reads data/docs/<slug>.md through the process-wide engine.
|
||||
func ReadDoc(slug string) (string, error) {
|
||||
e, err := Default()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return e.ReadDoc(slug)
|
||||
}
|
||||
|
||||
// DeleteDoc removes data/docs/<slug>.md through the process-wide engine.
|
||||
func DeleteDoc(slug string) error {
|
||||
e, err := Default()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return e.DeleteDoc(slug)
|
||||
}
|
||||
|
||||
// ListDocs indexes documents through the process-wide engine.
|
||||
func ListDocs() ([]DocMeta, error) {
|
||||
e, err := Default()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.ListDocs()
|
||||
}
|
||||
578
backend/storage/engine_test.go
Normal file
578
backend/storage/engine_test.go
Normal file
|
|
@ -0,0 +1,578 @@
|
|||
package storage
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func newTestEngine(t *testing.T) *Engine {
|
||||
t.Helper()
|
||||
engine, err := New(t.TempDir())
|
||||
if err != nil {
|
||||
t.Fatalf("New: %v", err)
|
||||
}
|
||||
return engine
|
||||
}
|
||||
|
||||
func TestSlugify(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
input string
|
||||
want string
|
||||
}{
|
||||
{"Core Service API", "core-service-api"},
|
||||
{"core-service-api", "core-service-api"},
|
||||
{" Spaced Out Title ", "spaced-out-title"},
|
||||
{"Deploy & Rollback", "deploy-rollback"},
|
||||
{"API_v2 (Internal)", "api-v2-internal"},
|
||||
{"Release 2024/Q1 -- Notes", "release-2024-q1-notes"},
|
||||
{"Überwachen der Dienste", "uberwachen-der-dienste"},
|
||||
{"Straße", "strase"},
|
||||
{"C++ & Rust: side by side", "c-rust-side-by-side"},
|
||||
{"emoji 🚀 launch", "emoji-launch"},
|
||||
{"multiple---hyphens", "multiple-hyphens"},
|
||||
{"trailing hyphen-", "trailing-hyphen"},
|
||||
{"---leading", "leading"},
|
||||
{"!!!", ""},
|
||||
{"", ""},
|
||||
{"日本語 title", "title"},
|
||||
{"snake_case_and__doubles", "snake-case-and-doubles"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
if got := Slugify(tc.input); got != tc.want {
|
||||
t.Errorf("Slugify(%q) = %q, want %q", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSlugifyCapsLength(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
long := strings.Repeat("word ", 100)
|
||||
got := Slugify(long)
|
||||
if len(got) > maxSlugLen {
|
||||
t.Fatalf("slug length %d exceeds the %d cap", len(got), maxSlugLen)
|
||||
}
|
||||
if strings.HasSuffix(got, "-") {
|
||||
t.Fatalf("capped slug must not end in a hyphen: %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeSlug(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
valid := map[string]string{
|
||||
"Core Service API": "core-service-api",
|
||||
"core-service-api": "core-service-api",
|
||||
" Spaced ": "spaced",
|
||||
"Ünïcödé Slug": "unicode-slug",
|
||||
}
|
||||
for input, want := range valid {
|
||||
got, err := NormalizeSlug(input)
|
||||
if err != nil {
|
||||
t.Errorf("NormalizeSlug(%q) returned %v", input, err)
|
||||
continue
|
||||
}
|
||||
if got != want {
|
||||
t.Errorf("NormalizeSlug(%q) = %q, want %q", input, got, want)
|
||||
}
|
||||
}
|
||||
|
||||
invalid := []string{
|
||||
"",
|
||||
" ",
|
||||
"../etc/passwd",
|
||||
"..",
|
||||
"nested/path",
|
||||
"back\\slash",
|
||||
"null\x00byte",
|
||||
"!!!",
|
||||
strings.Repeat("x", 2*maxSlugLen+1),
|
||||
}
|
||||
for _, input := range invalid {
|
||||
if _, err := NormalizeSlug(input); !errors.Is(err, ErrInvalidSlug) {
|
||||
t.Errorf("NormalizeSlug(%q) should be rejected, got %v", input, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveReadDeleteDoc(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine := newTestEngine(t)
|
||||
|
||||
slug, err := engine.SaveDoc("Core Service API", "# Core Service API\n\nBody.\n")
|
||||
if err != nil {
|
||||
t.Fatalf("SaveDoc: %v", err)
|
||||
}
|
||||
if slug != "core-service-api" {
|
||||
t.Fatalf("expected core-service-api, got %q", slug)
|
||||
}
|
||||
|
||||
if _, err := os.Stat(filepath.Join(engine.DocsDir(), "core-service-api.md")); err != nil {
|
||||
t.Fatalf("Markdown file missing: %v", err)
|
||||
}
|
||||
|
||||
content, err := engine.ReadDoc("Core Service API")
|
||||
if err != nil {
|
||||
t.Fatalf("ReadDoc: %v", err)
|
||||
}
|
||||
if !strings.Contains(content, "Body.") {
|
||||
t.Fatalf("unexpected content: %q", content)
|
||||
}
|
||||
|
||||
if _, err := engine.ReadDoc("never-saved"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected ErrNotFound, got %v", err)
|
||||
}
|
||||
|
||||
if err := engine.DeleteDoc("Core Service API"); err != nil {
|
||||
t.Fatalf("DeleteDoc: %v", err)
|
||||
}
|
||||
if _, err := engine.ReadDoc("core-service-api"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected ErrNotFound after delete, got %v", err)
|
||||
}
|
||||
if err := engine.DeleteDoc("core-service-api"); !errors.Is(err, ErrNotFound) {
|
||||
t.Fatalf("expected ErrNotFound on a second delete, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteDocCascadesComments(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine := newTestEngine(t)
|
||||
|
||||
if _, err := engine.SaveDoc("doomed", "# Doomed\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := engine.AppendComment("doomed", CommentInput{Author: "a", Role: "user", Content: "hi"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
commentPath := filepath.Join(engine.CommentsDir(), "doomed-comments.json")
|
||||
if _, err := os.Stat(commentPath); err != nil {
|
||||
t.Fatalf("expected the comment file to exist: %v", err)
|
||||
}
|
||||
|
||||
if err := engine.DeleteDoc("doomed"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := os.Stat(commentPath); !os.IsNotExist(err) {
|
||||
t.Fatalf("expected the comment file to be removed, stat returned %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSaveDocNormalizesLineEndings(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine := newTestEngine(t)
|
||||
|
||||
if _, err := engine.SaveDoc("crlf", "line one\r\nline two\r\n\r\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
content, err := engine.ReadDoc("crlf")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if strings.Contains(content, "\r") {
|
||||
t.Fatalf("carriage returns survived: %q", content)
|
||||
}
|
||||
if content != "line one\nline two\n" {
|
||||
t.Fatalf("unexpected normalization: %q", content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentBadges(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := map[Role]string{
|
||||
RoleUser: "🟢",
|
||||
RoleOperator: "🔵",
|
||||
RoleDeveloper: "🔴",
|
||||
}
|
||||
for role, badge := range cases {
|
||||
if role.Badge() != badge {
|
||||
t.Errorf("role %s badge = %q, want %q", role, role.Badge(), badge)
|
||||
}
|
||||
if !role.Valid() {
|
||||
t.Errorf("role %s should be valid", role)
|
||||
}
|
||||
}
|
||||
if Role("admin").Badge() != "" || Role("admin").Valid() {
|
||||
t.Error("an unknown role must have no badge and be invalid")
|
||||
}
|
||||
|
||||
for _, raw := range []string{"user", "USER", " User "} {
|
||||
if role, err := ParseRole(raw); err != nil || role != RoleUser {
|
||||
t.Errorf("ParseRole(%q) = %v, %v", raw, role, err)
|
||||
}
|
||||
}
|
||||
if _, err := ParseRole("root"); !errors.Is(err, ErrInvalidRole) {
|
||||
t.Errorf("expected ErrInvalidRole, got %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAppendCommentValidation(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine := newTestEngine(t)
|
||||
|
||||
if _, err := engine.AppendComment("valid", CommentInput{Role: "developer", Content: " "}); !errors.Is(err, ErrInvalidComment) {
|
||||
t.Errorf("blank content should be rejected, got %v", err)
|
||||
}
|
||||
if _, err := engine.AppendComment("valid", CommentInput{Role: "wizard", Content: "hello"}); !errors.Is(err, ErrInvalidRole) {
|
||||
t.Errorf("unknown role should be rejected, got %v", err)
|
||||
}
|
||||
if _, err := engine.AppendComment("../escape", CommentInput{Role: "user", Content: "hi"}); !errors.Is(err, ErrInvalidSlug) {
|
||||
t.Errorf("traversal slug should be rejected, got %v", err)
|
||||
}
|
||||
|
||||
comment, err := engine.AppendComment("valid", CommentInput{Role: "user", Content: "hello"})
|
||||
if err != nil {
|
||||
t.Fatalf("AppendComment: %v", err)
|
||||
}
|
||||
if comment.Author != "User" {
|
||||
t.Errorf("an omitted author should default to the role label, got %q", comment.Author)
|
||||
}
|
||||
if comment.Badge != "🟢" {
|
||||
t.Errorf("expected the user badge, got %q", comment.Badge)
|
||||
}
|
||||
if comment.ID == "" || comment.Timestamp.IsZero() {
|
||||
t.Error("expected an ID and a timestamp")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCommentsAreSortedAndDeduplicatedByID(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine := newTestEngine(t)
|
||||
|
||||
base := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC)
|
||||
for i, offset := range []int{30, 10, 20} {
|
||||
if _, err := engine.AppendComment("ordered", CommentInput{
|
||||
Author: fmt.Sprintf("user-%d", i),
|
||||
Role: "user",
|
||||
Content: fmt.Sprintf("comment %d", i),
|
||||
Timestamp: base.Add(time.Duration(offset) * time.Minute),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
comments, err := engine.ListComments("ordered")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(comments) != 3 {
|
||||
t.Fatalf("expected 3 comments, got %d", len(comments))
|
||||
}
|
||||
for i := 1; i < len(comments); i++ {
|
||||
if comments[i].Timestamp.Before(comments[i-1].Timestamp) {
|
||||
t.Fatalf("comments are not in chronological order: %+v", comments)
|
||||
}
|
||||
}
|
||||
if strings.TrimSpace(comments[0].Content) != "comment 1" {
|
||||
t.Fatalf("expected the earliest comment first, got %q", comments[0].Content)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteComment(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine := newTestEngine(t)
|
||||
|
||||
first, err := engine.AppendComment("prune", CommentInput{Role: "user", Content: "first"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := engine.AppendComment("prune", CommentInput{Role: "user", Content: "second"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
removed, err := engine.DeleteComment("prune", first.ID)
|
||||
if err != nil || !removed {
|
||||
t.Fatalf("expected the comment to be removed, got %v / %v", removed, err)
|
||||
}
|
||||
removed, err = engine.DeleteComment("prune", "does-not-exist")
|
||||
if err != nil || removed {
|
||||
t.Fatalf("deleting an unknown ID must be a no-op, got %v / %v", removed, err)
|
||||
}
|
||||
|
||||
remaining, err := engine.ListComments("prune")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(remaining) != 1 || strings.TrimSpace(remaining[0].Content) != "second" {
|
||||
t.Fatalf("unexpected remaining comments: %+v", remaining)
|
||||
}
|
||||
}
|
||||
|
||||
func TestListAndSearchDocs(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine := newTestEngine(t)
|
||||
|
||||
for _, title := range []string{"Alpha Service", "Beta Service", "Gamma Gateway"} {
|
||||
if _, err := engine.SaveDoc(title, "# "+title+"\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if _, err := engine.AppendComment("alpha-service", CommentInput{Role: "user", Content: "hi"}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
all, err := engine.ListDocs()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(all) != 3 {
|
||||
t.Fatalf("expected 3 documents, got %d", len(all))
|
||||
}
|
||||
// Titles come from the document's H1, not from a slug-derived guess.
|
||||
for _, doc := range all {
|
||||
if doc.Title != "Alpha Service" && doc.Title != "Beta Service" && doc.Title != "Gamma Gateway" {
|
||||
t.Fatalf("document %s has title %q; expected the H1 heading", doc.Slug, doc.Title)
|
||||
}
|
||||
}
|
||||
|
||||
detailed, err := engine.ListDocsDetailed()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, doc := range detailed {
|
||||
if doc.Slug == "alpha-service" && doc.Comments != 1 {
|
||||
t.Fatalf("expected 1 comment on alpha-service, got %d", doc.Comments)
|
||||
}
|
||||
}
|
||||
|
||||
hits, err := engine.Search("gateway")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(hits) != 1 || hits[0].Slug != "gamma-gateway" {
|
||||
t.Fatalf("unexpected search hits: %+v", hits)
|
||||
}
|
||||
|
||||
// Temp files left by an interrupted write must never surface in the index.
|
||||
if err := os.WriteFile(filepath.Join(engine.DocsDir(), ".godoc-tmp-123"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(engine.DocsDir(), "not-markdown.txt"), []byte("x"), 0o644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
all, err = engine.ListDocs()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(all) != 3 {
|
||||
t.Fatalf("index must ignore temp and non-Markdown files, got %d entries", len(all))
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractTitleAndTitleize(t *testing.T) {
|
||||
t.Parallel()
|
||||
|
||||
cases := []struct {
|
||||
content string
|
||||
slug string
|
||||
want string
|
||||
}{
|
||||
{"# Core Service API\n\nbody", "core-service-api", "Core Service API"},
|
||||
{"## Only an H2\n\nbody", "only-an-h2", "Only an H2"},
|
||||
{"no heading here", "plain-slug", "Plain Slug"},
|
||||
{"", "empty-slug", "Empty Slug"},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
if got := ExtractTitle(tc.content, tc.slug); got != tc.want {
|
||||
t.Errorf("ExtractTitle(%q, %q) = %q, want %q", tc.content, tc.slug, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentAppendsLoseNothing(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine := newTestEngine(t)
|
||||
|
||||
const (
|
||||
writers = 50
|
||||
perDoc = 40
|
||||
)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, writers)
|
||||
|
||||
for w := 0; w < writers; w++ {
|
||||
wg.Add(1)
|
||||
go func(worker int) {
|
||||
defer wg.Done()
|
||||
role := Roles()[worker%3]
|
||||
for n := 0; n < perDoc; n++ {
|
||||
if _, err := engine.AppendComment("hot", CommentInput{
|
||||
Author: fmt.Sprintf("worker-%02d", worker),
|
||||
Role: string(role),
|
||||
Content: fmt.Sprintf("note %d from %d", n, worker),
|
||||
}); err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
|
||||
for err := range errs {
|
||||
t.Fatalf("concurrent append failed: %v", err)
|
||||
}
|
||||
|
||||
comments, err := engine.ListComments("hot")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(comments) != writers*perDoc {
|
||||
t.Fatalf("expected %d comments, got %d", writers*perDoc, len(comments))
|
||||
}
|
||||
|
||||
seen := make(map[string]struct{}, len(comments))
|
||||
for _, comment := range comments {
|
||||
key := comment.Author + "|" + comment.Content
|
||||
if _, dup := seen[key]; dup {
|
||||
t.Fatalf("duplicate entry after concurrent writes: %s", key)
|
||||
}
|
||||
seen[key] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
func TestConcurrentReadsNeverSeeTornWrites(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine := newTestEngine(t)
|
||||
|
||||
const marker = "SENTINEL-HEADER\n"
|
||||
body := strings.Repeat("payload line\n", 500)
|
||||
|
||||
var wg sync.WaitGroup
|
||||
errs := make(chan error, 32)
|
||||
|
||||
for w := 0; w < 16; w++ {
|
||||
wg.Add(1)
|
||||
go func(worker int) {
|
||||
defer wg.Done()
|
||||
for n := 0; n < 25; n++ {
|
||||
if _, err := engine.SaveDoc("live", marker+body); err != nil {
|
||||
errs <- err
|
||||
return
|
||||
}
|
||||
}
|
||||
}(w)
|
||||
}
|
||||
for r := 0; r < 16; r++ {
|
||||
wg.Add(1)
|
||||
go func() {
|
||||
defer wg.Done()
|
||||
for n := 0; n < 100; n++ {
|
||||
content, err := engine.ReadDoc("live")
|
||||
if err != nil {
|
||||
if !errors.Is(err, ErrNotFound) {
|
||||
errs <- err
|
||||
}
|
||||
return
|
||||
}
|
||||
if !strings.HasPrefix(content, marker) {
|
||||
errs <- fmt.Errorf("torn read: %q", content[:min(len(content), 40)])
|
||||
return
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
wg.Wait()
|
||||
close(errs)
|
||||
|
||||
for err := range errs {
|
||||
t.Fatalf("concurrent access failed: %v", err)
|
||||
}
|
||||
|
||||
entries, err := os.ReadDir(engine.DocsDir())
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, entry := range entries {
|
||||
if strings.HasPrefix(entry.Name(), ".godoc-tmp-") {
|
||||
t.Fatalf("atomic write leaked a temp file: %s", entry.Name())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestStats(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine := newTestEngine(t)
|
||||
|
||||
if _, err := engine.SaveDoc("counted", "# Counted\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, role := range []Role{RoleUser, RoleUser, RoleOperator, RoleDeveloper, RoleDeveloper, RoleDeveloper} {
|
||||
if _, err := engine.AppendComment("counted", CommentInput{
|
||||
Author: fmt.Sprintf("author-%d", i),
|
||||
Role: string(role),
|
||||
Content: fmt.Sprintf("comment %d", i),
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
stats, err := engine.Stats("counted")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if stats.Total != 6 {
|
||||
t.Fatalf("expected 6 comments, got %d", stats.Total)
|
||||
}
|
||||
if stats.ByRole["developer"] != 3 || stats.ByRole["user"] != 2 || stats.ByRole["operator"] != 1 {
|
||||
t.Fatalf("unexpected tally: %+v", stats.ByRole)
|
||||
}
|
||||
if stats.MostActive != "developer" {
|
||||
t.Fatalf("expected developer as the most active role, got %q", stats.MostActive)
|
||||
}
|
||||
if stats.OldestAt == nil || stats.LatestAt == nil {
|
||||
t.Fatal("expected the time bounds to be populated")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEngineSatisfiesDocumentedInterface(t *testing.T) {
|
||||
t.Parallel()
|
||||
engine := newTestEngine(t)
|
||||
|
||||
exists, err := engine.DocExists("Core Service API")
|
||||
if err != nil || exists {
|
||||
t.Fatalf("DocExists before save = %v / %v", exists, err)
|
||||
}
|
||||
if _, err := engine.SaveDoc("Core Service API", "# Core Service API\n"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
exists, err = engine.DocExists("core-service-api")
|
||||
if err != nil || !exists {
|
||||
t.Fatalf("DocExists after save = %v / %v", exists, err)
|
||||
}
|
||||
|
||||
meta, err := engine.Stat("core-service-api")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if meta.Title != "Core Service API" {
|
||||
t.Fatalf("Stat title = %q", meta.Title)
|
||||
}
|
||||
if meta.Size <= 0 || meta.Modified.IsZero() {
|
||||
t.Fatalf("Stat returned incomplete metadata: %+v", meta)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResolveRootHonoursEnvironment(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
t.Setenv("GO_DOC_DATA_DIR", dir)
|
||||
if got := ResolveRoot(); got != dir {
|
||||
t.Fatalf("ResolveRoot() = %q, want %q", got, dir)
|
||||
}
|
||||
|
||||
if _, err := New(" "); err == nil {
|
||||
t.Fatal("New must reject an empty root")
|
||||
}
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue