initial commit

This commit is contained in:
Daniel Noulet 2026-10-07 15:03:50 +02:00
commit 23816d00f7
71 changed files with 14497 additions and 0 deletions

381
backend/storage/comments.go Normal file
View 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
View 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()
}

View 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")
}
}