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

18
backend/Dockerfile Normal file
View file

@ -0,0 +1,18 @@
# Development image for the Go backend. `docker-compose up` bind-mounts the
# source and runs `go run main.go`, so this image only needs the toolchain.
FROM golang:1.27-alpine
RUN apk add --no-cache git ca-certificates tzdata wget
ENV CGO_ENABLED=0 \
GOFLAGS=-mod=mod
WORKDIR /app/backend
# Warm the module cache before the bind mount replaces the working directory.
COPY go.mod ./
RUN go mod download || true
EXPOSE 8080
CMD ["go", "run", "main.go", "-addr", "0.0.0.0:8080"]

95
backend/auth/auth.go Normal file
View file

@ -0,0 +1,95 @@
package auth
import (
"context"
"net/http"
"go-doc/go-loose/client"
)
// User represents an authenticated user from go-loose
type User struct {
ID string
Email string
DisplayName string
AvatarURL string
TenantID string
TenantSlug string
ApplicationID string
Application string
Role string
Permissions []string
}
func (u User) Can(permission string) bool {
for _, granted := range u.Permissions {
if granted == permission {
return true
}
}
return false
}
func (u User) IsAdmin() bool {
return u.Role == "admin" || u.Role == "owner" || u.Can("console.read")
}
type contextKey string
const userContextKey = contextKey("go-doc-user")
func WithUser(ctx context.Context, user User) context.Context {
return context.WithValue(ctx, userContextKey, user)
}
func UserFromContext(ctx context.Context) (User, bool) {
user, ok := ctx.Value(userContextKey).(User)
return user, ok
}
func TenantIDFromContext(ctx context.Context) (string, bool) {
user, ok := UserFromContext(ctx)
if !ok {
return "", false
}
return user.TenantID, true
}
func UserIDFromContext(ctx context.Context) (string, bool) {
user, ok := UserFromContext(ctx)
if !ok {
return "", false
}
return user.ID, true
}
func ConvertUser(looseUser client.User) User {
return User{
ID: looseUser.ID,
Email: looseUser.Email,
DisplayName: looseUser.DisplayName,
AvatarURL: looseUser.AvatarURL,
TenantID: looseUser.TenantID,
TenantSlug: looseUser.TenantSlug,
ApplicationID: looseUser.ApplicationID,
Application: looseUser.Application,
Role: looseUser.Role,
Permissions: looseUser.Permissions,
}
}
func BrowserAuthMiddleware(browserAuth *client.BrowserAuth) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
if browserAuth == nil {
return next
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
browserAuth.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if user, ok := client.UserFromContext(r.Context()); ok {
r = r.WithContext(WithUser(r.Context(), ConvertUser(user)))
}
next.ServeHTTP(w, r)
})).ServeHTTP(w, r)
})
}
}

728
backend/docgen/docgen.go Normal file
View file

@ -0,0 +1,728 @@
// Package docgen analyses a Go project and renders audience-segmented
// documentation for go-doc.
//
// The pipeline is deliberately mechanical: Analyze walks a project tree and
// extracts facts that can be read off the filesystem (module metadata, HTTP
// routes, CLI flags, environment variables, Make targets, container services,
// Helm charts, existing Markdown, language breakdown), and Render turns those
// facts into the same three-audience layout go-doc uses natively:
//
// ## For Users what the thing does and how to use it
// ## For Operators running, configuring and deploying it
// ## For Developers contracts, layout and dependencies
//
// Import wraps the rendered page in generated markers so re-running the tool
// refreshes only the machine-written region and never clobbers prose a human
// added outside it.
package docgen
import (
"crypto/sha256"
"encoding/hex"
"fmt"
"io"
"io/fs"
"os"
"path"
"path/filepath"
"sort"
"strings"
"time"
)
// maxReadBytes caps how much of any single file is read into memory.
const maxReadBytes = 2 << 20
// maxWalkFiles caps the walk so a mis-pointed root cannot spin forever.
const maxWalkFiles = 200000
// Route is one HTTP endpoint discovered in the source.
type Route struct {
Method string
Path string
Handler string
}
// String renders the route the way it is registered.
func (r Route) String() string {
if r.Method == "" {
return r.Path
}
return r.Method + " " + r.Path
}
// SortKey orders routes by path, then method, so output is stable.
func (r Route) SortKey() string { return r.Path + "\x00" + r.Method + "\x00" + r.Handler }
// Flag is a command-line flag declared with the flag package.
type Flag struct {
Name string
Type string
Default string
Usage string
}
// EnvVar is an environment variable the code reads, plus a note when it was
// discovered in a .env.example file rather than in source.
type EnvVar struct {
Name string
Example string
FromEnv bool
}
// Dependency is one requirement line from a go.mod.
type Dependency struct {
Path string
Version string
Indirect bool
}
// Module is one go.mod file found in the tree.
type Module struct {
Path string
File string
GoVersion string
Direct []Dependency
Indirect int
}
// ComposeService is a service declared in a docker-compose file.
type ComposeService struct {
Name string
Image string
Ports string
File string
}
// DockerInfo summarises the project's Dockerfile.
type DockerInfo struct {
File string
Stages []string
Bases []string
Exposed []string
}
// DocFile is an existing Markdown document that ships with the project.
type DocFile struct {
Path string
Title string
Bytes int
}
// Dir is a first-level source directory with a purpose inferred from its name.
type Dir struct {
Name string
Files int
Lines int
}
// Language is a per-extension line count.
type Language struct {
Name string
Files int
Lines int
}
// Stats are the headline counters shown in the generated page.
type Stats struct {
GoFiles int
TestFiles int
TestFuncs int
Components int
TotalLines int
OldestCommit string
}
// Project is the full analysis result for one repository.
type Project struct {
Name string
Path string
Summary string
Readme string
Modules []Module
Routes []Route
Flags []Flag
EnvVars []EnvVar
MakeTargets []string
Compose []ComposeService
Docker *DockerInfo
HelmCharts []string
Docs []DocFile
Dirs []Dir
Languages []Language
Ports []string
Stats Stats
// Signature changes whenever any analysed input changes. Watch mode uses it
// to decide whether a regeneration is needed.
Signature string
}
// Analyze walks root and extracts everything Render needs.
//
// A missing or partial project is not an error: a repository without a go.mod
// or without a README still yields a usable page describing whatever was found.
func Analyze(name, root string) (*Project, error) {
info, err := os.Stat(root)
if err != nil {
return nil, fmt.Errorf("docgen: stat %s: %w", root, err)
}
if !info.IsDir() {
return nil, fmt.Errorf("docgen: %s is not a directory", root)
}
p := &Project{Name: name, Path: root}
files, err := collectFiles(root)
if err != nil {
return nil, err
}
p.Signature = signature(files)
for _, file := range files {
rel := file.rel
switch {
case strings.HasSuffix(rel, ".go"):
p.Stats.GoFiles++
p.Stats.TotalLines += file.lines
if strings.HasSuffix(rel, "_test.go") {
p.Stats.TestFiles++
p.Stats.TestFuncs += countTestFuncs(file.text())
}
if isComponentPath(rel) {
p.Stats.Components++
}
analyzeGoFile(p, file)
case filepath.Base(rel) == "go.mod":
p.Modules = append(p.Modules, parseGoMod(rel, file.text()))
case isMarkdown(rel):
p.Docs = append(p.Docs, DocFile{
Path: rel,
Title: markdownTitle(file.text()),
Bytes: int(file.size),
})
case filepath.Base(rel) == "Makefile" || filepath.Base(rel) == "makefile":
p.MakeTargets = append(p.MakeTargets, makeTargets(file.text())...)
case isDockerfile(rel):
if p.Docker == nil {
p.Docker = parseDockerfile(rel, file.text())
}
case isComposeFile(rel):
p.Compose = append(p.Compose, parseCompose(rel, file.text())...)
case strings.HasPrefix(filepath.Base(rel), ".env"):
p.EnvVars = append(p.EnvVars, envFromExample(file.text())...)
case isHelmFile(rel):
p.HelmCharts = append(p.HelmCharts, chartRoot(rel))
}
p.Languages = addLanguage(p.Languages, file)
if portBearingFiles(rel) {
p.Ports = append(p.Ports, portsIn(file.text())...)
}
}
finish(p)
return p, nil
}
// ---------------------------------------------------------------------------
// Normalisation
// ---------------------------------------------------------------------------
func finish(p *Project) {
p.Readme = findReadme(p)
p.Summary = summarize(p)
p.Modules = dedupeModules(p.Modules)
p.Routes = dedupeRoutes(p.Routes)
p.Flags = dedupeFlags(p.Flags)
p.EnvVars = dedupeEnv(p.EnvVars)
p.MakeTargets = dedupeStrings(p.MakeTargets)
p.HelmCharts = dedupeStrings(p.HelmCharts)
p.Ports = dedupeStrings(p.Ports)
p.Dirs = topDirs(p)
p.Languages = rankLanguages(p.Languages)
}
// ---------------------------------------------------------------------------
// Walking
// ---------------------------------------------------------------------------
type scannedFile struct {
path string
rel string
size int64
lines int
}
func (f scannedFile) text() string {
data, err := readCapped(f.path, maxReadBytes)
if err != nil {
return ""
}
return string(data)
}
// ignoredDirs never contain first-party source worth documenting.
var ignoredDirs = map[string]struct{}{
"node_modules": {}, ".git": {}, ".idea": {}, ".vscode": {}, "dist": {},
"build": {}, "bin": {}, "vendor": {}, "coverage": {}, "tmp": {},
".cache": {}, ".next": {}, "target": {}, "__pycache__": {}, ".venv": {},
}
func collectFiles(root string) ([]scannedFile, error) {
var files []scannedFile
err := filepath.WalkDir(root, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
// An unreadable subtree must not abort the whole analysis.
if entry != nil && entry.IsDir() {
return fs.SkipDir
}
return nil
}
if len(files) >= maxWalkFiles {
return fs.SkipAll
}
name := entry.Name()
if entry.IsDir() {
if _, skip := ignoredDirs[name]; skip && path != root {
return fs.SkipDir
}
return nil
}
if !entry.Type().IsRegular() {
return nil
}
if isNoise(name) {
return nil
}
info, err := entry.Info()
if err != nil {
return nil
}
rel, err := filepath.Rel(root, path)
if err != nil {
rel = name
}
files = append(files, scannedFile{
path: path,
rel: filepath.ToSlash(rel),
size: info.Size(),
lines: countLinesFast(path),
})
return nil
})
if err != nil {
return nil, fmt.Errorf("docgen: walk %s: %w", root, err)
}
return files, nil
}
// isNoise filters artefacts that would pollute the analysis.
func isNoise(name string) bool {
switch filepath.Ext(name) {
case ".log", ".lock", ".sum", ".pem", ".crt", ".key", ".png", ".jpg", ".jpeg",
".gif", ".svg", ".ico", ".woff", ".woff2", ".ttf", ".eot", ".zip", ".tar",
".gz", ".mp4", ".sql.gz", ".db", ".sqlite", ".pyc":
return true
}
// Certificates and keys are frequently committed without extensions.
switch {
case strings.HasSuffix(name, ".crt"), strings.HasSuffix(name, ".key"):
return true
case strings.HasSuffix(name, "-chain.crt"), strings.HasSuffix(name, "-root-ca.crt"):
return true
}
return false
}
func isMarkdown(rel string) bool {
return strings.EqualFold(filepath.Ext(rel), ".md")
}
func isDockerfile(rel string) bool {
base := filepath.Base(rel)
return base == "Dockerfile" || strings.HasPrefix(base, "Dockerfile.")
}
func isComposeFile(rel string) bool {
base := strings.ToLower(filepath.Base(rel))
if !strings.HasPrefix(base, "docker-compose") && base != "compose.yaml" && base != "compose.yml" {
return false
}
return strings.HasSuffix(base, ".yml") || strings.HasSuffix(base, ".yaml")
}
func isHelmChart(rel string) bool {
parts := strings.Split(rel, "/")
for i, part := range parts {
if part == "helm" || part == "charts" {
// A chart is a directory that owns a values.yaml or Chart.yaml.
if i+1 < len(parts) {
return true
}
}
}
return false
}
// chartRoot collapses a chart file path to the directory that owns the chart,
// so the page lists one entry per chart rather than one per template.
func chartRoot(rel string) string {
dir := path.Dir(rel)
if dir == "." {
return "./ (repository root)"
}
return dir
}
func isComponentPath(rel string) bool {
parts := strings.Split(rel, "/")
if len(parts) < 2 {
return false
}
switch parts[len(parts)-2] {
case "internal", "pkg", "cmd", "api", "services", "web", "ui", "frontend", "backend":
return true
}
return false
}
func readCapped(path string, limit int64) ([]byte, error) {
file, err := os.Open(path)
if err != nil {
return nil, err
}
defer file.Close()
return io.ReadAll(io.LimitReader(file, limit))
}
func countLinesFast(path string) int {
file, err := os.Open(path)
if err != nil {
return 0
}
defer file.Close()
// Skip .md / .json / .lock style files where line counts are meaningless.
switch strings.ToLower(filepath.Ext(path)) {
case ".md", ".json", ".yaml", ".yml", ".txt", ".lock", ".css", ".html":
return 0
}
buf := make([]byte, 32<<10)
lines := 0
for {
n, err := file.Read(buf)
if n > 0 {
for _, b := range buf[:n] {
if b == '\n' {
lines++
}
}
}
if err != nil {
break
}
}
return lines
}
// signature hashes the identity of every analysed file. It deliberately uses
// size and mtime rather than content so watch mode can poll large trees cheaply.
func signature(files []scannedFile) string {
hash := sha256.New()
for _, file := range files {
info, err := os.Stat(file.path)
if err != nil {
continue
}
fmt.Fprintf(hash, "%s|%d|%d\n", file.rel, file.size, info.ModTime().UnixNano())
}
return hex.EncodeToString(hash.Sum(nil))[:32]
}
func countTestFuncs(source string) int {
return strings.Count(source, "func Test") + strings.Count(source, "func Fuzz") +
strings.Count(source, "func Benchmark")
}
// noiseExtensions are file types that carry no language signal: build
// metadata, ignore lists and editor configuration all have extensions that would
// otherwise show up as their own "language".
var noiseExtensions = map[string]struct{}{
"mod": {}, "sum": {}, "lock": {}, "gitignore": {}, "dockerignore": {},
"helmignore": {}, "npmignore": {}, "eslintignore": {}, "gitattributes": {},
"env": {},
"editorconfig": {}, "prettierignore": {}, "example": {}, "template": {},
}
func addLanguage(langs []Language, file scannedFile) []Language {
ext := strings.ToLower(strings.TrimPrefix(filepath.Ext(file.path), "."))
if ext == "" {
return langs
}
if _, skip := noiseExtensions[ext]; skip {
return langs
}
name := languageName(ext)
lines := file.lines
if lines == 0 {
lines = countAllLinesFast(file.path)
}
for i := range langs {
if langs[i].Name == name {
langs[i].Files++
langs[i].Lines += lines
return langs
}
}
return append(langs, Language{Name: name, Files: 1, Lines: lines})
}
func countAllLinesFast(path string) int {
data, err := readCapped(path, maxReadBytes)
if err != nil {
return 0
}
lines := strings.Count(string(data), "\n")
if len(data) > 0 && !strings.HasSuffix(string(data), "\n") {
lines++
}
return lines
}
func languageName(ext string) string {
switch ext {
case "go":
return "Go"
case "js", "mjs", "cjs":
return "JavaScript"
case "jsx":
return "JSX"
case "ts", "tsx":
return "TypeScript"
case "css":
return "CSS"
case "html", "htm":
return "HTML"
case "sh", "bash":
return "Shell"
case "sql":
return "SQL"
case "yml", "yaml":
return "YAML"
case "json":
return "JSON"
case "md":
return "Markdown"
case "tf":
return "Terraform"
case "proto":
return "Protobuf"
case "tpl", "gotmpl":
return "Templates"
case "py":
return "Python"
default:
return ext
}
}
func rankLanguages(langs []Language) []Language {
sort.Slice(langs, func(i, j int) bool {
if langs[i].Lines == langs[j].Lines {
return langs[i].Name < langs[j].Name
}
return langs[i].Lines > langs[j].Lines
})
// A page does not benefit from a tail of one-file languages.
if len(langs) > maxLanguages {
langs = langs[:maxLanguages]
}
return langs
}
// maxLanguages caps the language table on a generated page.
const maxLanguages = 10
func topDirs(p *Project) []Dir {
byName := map[string]*Dir{}
err := filepath.WalkDir(p.Path, func(path string, entry fs.DirEntry, err error) error {
if err != nil {
return nil
}
if entry.IsDir() {
if path != p.Path {
if _, skip := ignoredDirs[entry.Name()]; skip {
return fs.SkipDir
}
// Dot directories hold tooling, not first-party source.
if strings.HasPrefix(entry.Name(), ".") {
return fs.SkipDir
}
}
return nil
}
rel, err := filepath.Rel(p.Path, path)
if err != nil {
return nil
}
parts := strings.Split(filepath.ToSlash(rel), "/")
if len(parts) < 2 {
return nil
}
dir := byName[parts[0]]
if dir == nil {
dir = &Dir{Name: parts[0]}
byName[parts[0]] = dir
}
dir.Files++
dir.Lines += countLinesFast(path)
return nil
})
if err != nil {
return nil
}
dirs := make([]Dir, 0, len(byName))
for _, dir := range byName {
dirs = append(dirs, *dir)
}
sort.Slice(dirs, func(i, j int) bool {
if dirs[i].Files == dirs[j].Files {
return dirs[i].Name < dirs[j].Name
}
return dirs[i].Files > dirs[j].Files
})
if len(dirs) > 14 {
dirs = dirs[:14]
}
return dirs
}
func max(a, b int) int {
if a > b {
return a
}
return b
}
func dedupeStrings(in []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(in))
for _, value := range in {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, dup := seen[value]; dup {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
sort.Strings(out)
return out
}
// dedupeInOrder removes duplicates while preserving first-seen order, for lists
// whose order is meaningful (Docker build stages, published ports).
func dedupeInOrder(in []string) []string {
seen := map[string]struct{}{}
out := make([]string, 0, len(in))
for _, value := range in {
value = strings.TrimSpace(value)
if value == "" {
continue
}
if _, dup := seen[value]; dup {
continue
}
seen[value] = struct{}{}
out = append(out, value)
}
return out
}
func dedupeModules(mods []Module) []Module {
sort.Slice(mods, func(i, j int) bool { return mods[i].File < mods[j].File })
return mods
}
// dedupeRoutes collapses registrations that describe the same endpoint. The
// key is method plus path, because one endpoint is often registered in several
// places (a group helper plus the router); when several carry a handler name,
// the one with a handler wins over the one without.
func dedupeRoutes(routes []Route) []Route {
best := make(map[string]Route, len(routes))
for _, route := range routes {
if route.Path == "" {
continue
}
key := route.Method + " " + route.Path
existing, seen := best[key]
switch {
case !seen:
best[key] = route
case existing.Handler == "" && route.Handler != "":
best[key] = route
}
}
out := make([]Route, 0, len(best))
for _, route := range best {
out = append(out, route)
}
sort.Slice(out, func(i, j int) bool {
if out[i].Path == out[j].Path {
return out[i].Method < out[j].Method
}
return out[i].Path < out[j].Path
})
return out
}
func dedupeFlags(flags []Flag) []Flag {
seen := map[string]struct{}{}
out := make([]Flag, 0, len(flags))
for _, flag := range flags {
if _, dup := seen[flag.Name]; dup {
continue
}
seen[flag.Name] = struct{}{}
out = append(out, flag)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
func dedupeEnv(vars []EnvVar) []EnvVar {
seen := map[string]struct{}{}
out := make([]EnvVar, 0, len(vars))
for _, v := range vars {
if _, dup := seen[v.Name]; dup {
continue
}
seen[v.Name] = struct{}{}
out = append(out, v)
}
sort.Slice(out, func(i, j int) bool { return out[i].Name < out[j].Name })
return out
}
// Timestamp is used inside generated markers so a reader can tell how fresh a
// machine-written region is.
func Timestamp() string { return time.Now().UTC().Format("2006-01-02 15:04 UTC") }

View file

@ -0,0 +1,621 @@
package docgen
import (
"os"
"path/filepath"
"strings"
"testing"
)
// fixture writes a small but representative project tree: all three route
// registration styles, a module file, a Makefile, a Dockerfile, a compose file,
// an .env, a README and one unrelated Markdown document.
func fixture(t *testing.T) string {
t.Helper()
root := t.TempDir()
files := map[string]string{
"README.md": "# Demo Service\n\nDemo is a service that answers questions about things. " +
"It runs behind Go Loose and exposes a JSON API.\n\n" +
"## Install\n\nNot part of the summary.\n",
"go.mod": "module github.com/danyel/demo\n\ngo 1.27.1\n\nrequire (\n" +
"\tgithub.com/go-chi/chi/v5 v5.2.3\n\tgithub.com/spf13/cobra v1.9.1\n" +
"\tgolang.org/x/sync v0.14.0 // indirect\n)\n",
"Makefile": "build:\n\tgo build ./...\n\n.PHONY: build\n\ntest:\n\tgo test ./...\n",
"Dockerfile": "FROM golang:1.27-alpine AS builder\nRUN go build ./...\n\n" +
"FROM alpine:3.20\nCOPY --from=builder /app/demo /demo\nEXPOSE 8080\n",
"docker-compose.yml": "services:\n api:\n image: demo:latest\n ports:\n" +
" - \"8080:8080\"\n db:\n image: postgres:17\n environment:\n" +
" POSTGRES_PASSWORD: secret\n",
".env.example": "# comment\nDEMO_HTTP_PORT=8080\nDEMO_API_KEY=\n",
"internal/server/routes.go": "package server\n\n" +
"import (\n\t\"net/http\"\n\t\"os\"\n)\n\n" +
"func Routes(mux *http.ServeMux, r chi.Router) {\n" +
"\tmux.HandleFunc(\"GET /healthz\", health)\n" +
"\tmux.HandleFunc(\"/plain\", plainHandler)\n" +
"\tregister(r, http.MethodPost, \"/api/items\", createItem)\n" +
"\tr.Get(\"/api/items/{id}\", getItem)\n" +
"\t_ = os.Getenv(\"DEMO_RUNTIME_SECRET\")\n}\n",
"internal/server/routes_test.go": "package server\n\n" +
"func TestRoutes(t *testing.T) {\n" +
"\t_ = httptest.NewRequest(http.MethodGet, \"/api/jobs\", nil)\n}\n",
"cmd/demo/main.go": "package main\n\nimport \"flag\"\n\nfunc main() {\n" +
"\tport := flag.String(\"port\", \"8080\", \"listen port\")\n" +
"\tverbose := flag.Bool(\"verbose\", false, \"chatty output\")\n" +
"\t_ = port\n\t_ = verbose\n}\n",
"docs/OPERATIONS.md": "# Operations\n\nHow to run the thing.\n",
}
for name, content := range files {
path := filepath.Join(root, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", path, err)
}
}
// A directory that must never be walked.
if err := os.MkdirAll(filepath.Join(root, "node_modules", "left-pad"), 0o755); err != nil {
t.Fatalf("mkdir node_modules: %v", err)
}
if err := os.WriteFile(filepath.Join(root, "node_modules", "index.js"), []byte("x\n"), 0o644); err != nil {
t.Fatalf("write node_modules file: %v", err)
}
return root
}
func analyzeFixture(t *testing.T) *Project {
t.Helper()
p, err := Analyze("demo", fixture(t))
if err != nil {
t.Fatalf("Analyze: %v", err)
}
return p
}
func TestAnalyzeExtractsModuleMetadata(t *testing.T) {
p := analyzeFixture(t)
if len(p.Modules) != 1 {
t.Fatalf("want 1 module, got %d: %+v", len(p.Modules), p.Modules)
}
module := p.Modules[0]
if module.Path != "github.com/danyel/demo" {
t.Errorf("module path = %q", module.Path)
}
if module.GoVersion != "1.27.1" {
t.Errorf("go version = %q", module.GoVersion)
}
if len(module.Direct) != 2 {
t.Errorf("want 2 direct deps, got %d: %+v", len(module.Direct), module.Direct)
}
if module.Direct[0].Path != "github.com/go-chi/chi/v5" || module.Direct[0].Version != "v5.2.3" {
t.Errorf("unexpected first dep: %+v", module.Direct[0])
}
if module.Indirect != 1 {
t.Errorf("want 1 indirect dep, got %d", module.Indirect)
}
}
func TestAnalyzeExtractsAllRouteStyles(t *testing.T) {
p := analyzeFixture(t)
want := map[string]string{
"GET /healthz": "health",
"/plain": "plainHandler",
"POST /api/items": "createItem",
"GET /api/items/{id}": "getItem",
}
for key, handler := range want {
var found *Route
for i := range p.Routes {
if p.Routes[i].String() == key {
found = &p.Routes[i]
}
}
if found == nil {
t.Errorf("route %q not found; got %v", key, routeStrings(p.Routes))
continue
}
if found.Handler != handler {
t.Errorf("route %q handler = %q, want %q", key, found.Handler, handler)
}
}
if len(p.Routes) != len(want) {
t.Errorf("want %d routes, got %d: %v", len(want), len(p.Routes), routeStrings(p.Routes))
}
}
func TestAnalyzeIgnoresRoutesInTestFiles(t *testing.T) {
p := analyzeFixture(t)
for _, route := range p.Routes {
if route.Path == "/api/jobs" {
t.Fatalf("route from a _test.go file leaked into the analysis: %v", route)
}
}
}
func TestAnalyzeExtractsFlagsAndEnvironment(t *testing.T) {
p := analyzeFixture(t)
flags := map[string]Flag{}
for _, flag := range p.Flags {
flags[flag.Name] = flag
}
if flags["port"].Default != "8080" || flags["port"].Usage != "listen port" {
t.Errorf("port flag = %+v", flags["port"])
}
if flags["verbose"].Type != "bool" {
t.Errorf("verbose flag = %+v", flags["verbose"])
}
env := map[string]EnvVar{}
for _, v := range p.EnvVars {
env[v.Name] = v
}
if env["DEMO_RUNTIME_SECRET"].FromEnv {
t.Error("os.Getenv should not be reported as coming from a .env file")
}
if !env["DEMO_HTTP_PORT"].FromEnv || env["DEMO_HTTP_PORT"].Example != "8080" {
t.Errorf("DEMO_HTTP_PORT = %+v", env["DEMO_HTTP_PORT"])
}
}
func TestAnalyzeExtractsOperationalFacts(t *testing.T) {
p := analyzeFixture(t)
if got := p.MakeTargets; len(got) != 2 || got[0] != "build" || got[1] != "test" {
t.Errorf("make targets = %v", got)
}
if p.Docker == nil {
t.Fatal("no Dockerfile analysed")
}
if len(p.Docker.Bases) != 2 || p.Docker.Bases[0] != "golang:1.27-alpine" {
t.Errorf("docker bases = %v", p.Docker.Bases)
}
if len(p.Docker.Exposed) != 1 || p.Docker.Exposed[0] != "8080" {
t.Errorf("docker exposed = %v", p.Docker.Exposed)
}
if len(p.Compose) != 2 {
t.Fatalf("compose services = %+v", p.Compose)
}
if p.Compose[0].Name != "api" || p.Compose[0].Image != "demo:latest" {
t.Errorf("compose[0] = %+v", p.Compose[0])
}
if len(p.Ports) == 0 {
t.Error("expected the compose file port to be picked up")
}
if p.Readme != "README.md" {
t.Errorf("readme = %q", p.Readme)
}
if len(p.Docs) != 2 {
t.Errorf("docs = %+v", p.Docs)
}
}
func TestAnalyzeIgnoresVendoredTrees(t *testing.T) {
p := analyzeFixture(t)
for _, lang := range p.Languages {
if lang.Name == "JavaScript" {
t.Errorf("node_modules leaked into the language table: %+v", p.Languages)
}
}
for _, dir := range p.Dirs {
if dir.Name == "node_modules" {
t.Errorf("node_modules leaked into the directory map: %+v", dir)
}
}
}
func TestSummaryComesFromReadmeProse(t *testing.T) {
p := analyzeFixture(t)
if !strings.HasPrefix(p.Summary, "Demo is a service") {
t.Errorf("summary = %q", p.Summary)
}
if strings.Contains(p.Summary, "Install") {
t.Errorf("summary leaked a later section: %q", p.Summary)
}
}
func TestAnalyzeWithoutReadmeFallsBackToFacts(t *testing.T) {
root := t.TempDir()
if err := os.WriteFile(filepath.Join(root, "main.go"), []byte("package main\n"), 0o644); err != nil {
t.Fatal(err)
}
p, err := Analyze("bare", root)
if err != nil {
t.Fatalf("Analyze: %v", err)
}
if p.Summary == "" {
t.Error("a project with no README still needs a summary")
}
if p.Stats.GoFiles != 1 {
t.Errorf("go files = %d", p.Stats.GoFiles)
}
}
func TestSignatureChangesOnlyWithTheTree(t *testing.T) {
root := fixture(t)
first, err := Analyze("demo", root)
if err != nil {
t.Fatal(err)
}
again, err := Analyze("demo", root)
if err != nil {
t.Fatal(err)
}
if first.Signature != again.Signature {
t.Errorf("signature is not stable: %q vs %q", first.Signature, again.Signature)
}
if err := os.WriteFile(filepath.Join(root, "notes.txt"), []byte("hello\n"), 0o644); err != nil {
t.Fatal(err)
}
changed, err := Analyze("demo", root)
if err != nil {
t.Fatal(err)
}
if changed.Signature == first.Signature {
t.Error("signature did not change after a file was added")
}
}
// ---------------------------------------------------------------------------
// Rendering
// ---------------------------------------------------------------------------
func TestRenderProducesAudienceSections(t *testing.T) {
page, err := Render(analyzeFixture(t))
if err != nil {
t.Fatalf("Render: %v", err)
}
for _, want := range []string{
"# demo",
"## For Users",
"## For Operators",
"## For Developers",
"### HTTP API",
"| Method | Path | Handler |",
"| `GET` | `/healthz` | `health` |",
"### Direct dependencies",
"`github.com/go-chi/chi/v5` v5.2.3",
"### Network surface",
"`make build`",
} {
if !strings.Contains(page, want) {
t.Errorf("rendered page is missing %q", want)
}
}
}
func TestRenderIsWrappedInMarkers(t *testing.T) {
page, err := Render(analyzeFixture(t))
if err != nil {
t.Fatalf("Render: %v", err)
}
if !strings.HasPrefix(page, beginPrefix) {
t.Errorf("page does not start with the opening marker:\n%s", firstLine(page))
}
if !strings.HasSuffix(page, EndMarker) {
t.Errorf("page does not end with the closing marker:\n%s", lastLine(page))
}
if !FindBlock(page).Found {
t.Error("FindBlock cannot locate the markers on a freshly rendered page")
}
}
func TestRenderKeepsMarkdownSpacingSane(t *testing.T) {
page, err := Render(analyzeFixture(t))
if err != nil {
t.Fatalf("Render: %v", err)
}
// Trim the marker lines: they are not part of the Markdown body.
body := page
if block := FindBlock(page); block.Found {
body = page[block.Body : block.End-len(EndMarker)]
}
lines := strings.Split(body, "\n")
for i := 0; i < len(lines)-1; i++ {
if lines[i] == "" && lines[i+1] == "" {
t.Errorf("blank line %d: two blank lines in a row", i+1)
}
if strings.HasPrefix(lines[i], "#") && lines[i+1] != "" {
t.Errorf("heading on line %d is glued to %q", i+1, lines[i+1])
}
}
if strings.HasPrefix(strings.TrimSpace(lines[0]), "<!--") {
t.Errorf("body starts with a comment: %q", lines[0])
}
}
func TestRenderIsDeterministic(t *testing.T) {
root := fixture(t)
first, err := Analyze("demo", root)
if err != nil {
t.Fatal(err)
}
second, err := Analyze("demo", root)
if err != nil {
t.Fatal(err)
}
a, err := Render(first)
if err != nil {
t.Fatal(err)
}
b, err := Render(second)
if err != nil {
t.Fatal(err)
}
// The only permitted difference is the generation timestamp.
if stampPattern.ReplaceAllString(a, "") != stampPattern.ReplaceAllString(b, "") {
t.Error("Render is not deterministic for an unchanged tree")
}
}
// ---------------------------------------------------------------------------
// Merging
// ---------------------------------------------------------------------------
func TestMergeCreatesWhenNoPageExists(t *testing.T) {
merged, status, err := Merge("", "generated body\n", false)
if err != nil {
t.Fatal(err)
}
if status != StatusCreated {
t.Errorf("status = %q", status)
}
if merged != "generated body\n" {
t.Errorf("merged = %q", merged)
}
}
func TestMergeSkipsUnmarkedPages(t *testing.T) {
const handWritten = "# demo\n\nWritten by a person.\n"
merged, status, err := Merge(handWritten, "generated\n", false)
if err != nil {
t.Fatal(err)
}
if status != StatusSkipped {
t.Errorf("status = %q", status)
}
if merged != handWritten {
t.Errorf("a hand-written page was modified: %q", merged)
}
}
func TestMergeForcesOverUnmarkedPages(t *testing.T) {
merged, status, err := Merge("# demo\n\nWritten by a person.\n", "generated\n", true)
if err != nil {
t.Fatal(err)
}
if status != StatusUpdated {
t.Errorf("status = %q", status)
}
if merged != "generated\n" {
t.Errorf("merged = %q", merged)
}
}
func TestMergePreservesProseOutsideMarkers(t *testing.T) {
page, err := Render(analyzeFixture(t))
if err != nil {
t.Fatal(err)
}
withFooter := page + "\n## House rules\n\nNever edit the generated part by hand.\n"
// A second render of the same tree with different content, as if the
// project's README had been rewritten.
block := FindBlock(withFooter)
if !block.Found {
t.Fatal("markers missing from the page under test")
}
updated := strings.Replace(page, "Demo is a service", "Demo is a service, renamed", 1)
if updated == page {
t.Fatal("test setup failed to change the generated content")
}
merged, status, err := Merge(withFooter, updated, false)
if err != nil {
t.Fatal(err)
}
if status != StatusUpdated {
t.Errorf("status = %q", status)
}
if !strings.Contains(merged, "Never edit the generated part by hand.") {
t.Error("hand-written footer was dropped")
}
if !strings.Contains(merged, "Demo is a service, renamed") {
t.Error("generated region was not replaced")
}
if got := strings.Count(merged, EndMarker); got != 1 {
t.Errorf("want exactly 1 closing marker, got %d", got)
}
if got := strings.Count(merged, beginPrefix); got != 1 {
t.Errorf("want exactly 1 opening marker, got %d", got)
}
}
func TestMergeReportsUnchangedAndKeepsTheFileStable(t *testing.T) {
page, err := Render(analyzeFixture(t))
if err != nil {
t.Fatal(err)
}
// Storage normalises the trailing newline, so the on-disk page has one.
onDisk := page + "\n"
merged, status, err := Merge(onDisk, page, false)
if err != nil {
t.Fatal(err)
}
if status != StatusUnchanged {
t.Errorf("status = %q, want unchanged", status)
}
if merged != onDisk {
t.Error("an unchanged sync must return the file untouched, timestamp included")
}
}
func TestFindBlockIgnoresMalformedMarkers(t *testing.T) {
tests := map[string]string{
"no markers at all": "# demo\n\nBody.\n",
"only an opening marker": beginPrefix + " source=demo -->\n\nBody.\n",
"only a closing marker": "Body.\n" + EndMarker + "\n",
"marker not at line start": "text " + beginPrefix + " -->\nBody.\n" + EndMarker + "\n",
"markers out of order": EndMarker + "\n" + beginPrefix + " -->\n",
}
for name, content := range tests {
if block := FindBlock(content); block.Found {
t.Errorf("%s: expected no block, got %+v", name, block)
}
}
}
func TestFindBlockReportsBoundaries(t *testing.T) {
page, err := Render(analyzeFixture(t))
if err != nil {
t.Fatal(err)
}
block := FindBlock(page)
if !block.Found {
t.Fatal("block not found")
}
if block.Marker != 0 {
t.Errorf("marker offset = %d, want 0", block.Marker)
}
if !strings.HasPrefix(page[block.Body:], "# demo") {
t.Errorf("body does not start after the opening marker line: %q", page[block.Body:block.Body+8])
}
if got := page[block.End:]; got != "" {
t.Errorf("bytes after the block = %q, want none", got)
}
}
func TestWrapNormalisesTrailingNewlines(t *testing.T) {
page := Wrap("demo", "sig", "body\n\n\n")
if strings.HasSuffix(page, "\n") {
t.Error("Wrap must not end with a newline")
}
if !strings.HasSuffix(page, "\n"+EndMarker) {
t.Errorf("the closing marker must start its own line: %q", lastLine(page))
}
}
// ---------------------------------------------------------------------------
// Helpers
// ---------------------------------------------------------------------------
func routeStrings(routes []Route) []string {
out := make([]string, 0, len(routes))
for _, route := range routes {
out = append(out, route.String())
}
return out
}
func firstLine(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}
func lastLine(s string) string {
if i := strings.LastIndexByte(s, '\n'); i >= 0 {
return s[i+1:]
}
return s
}
// TestMergeIgnoresAMinuteOldTimestamp is the regression test for a bug that
// only showed up once a minute: two renders of an unchanged project carry
// different "generated=" stamps, and a pattern that stopped at the first space
// left "15:04 UTC" in place, so every sync in a new minute rewrote all four
// pages and reported "updated" for content that had not changed at all.
func TestMergeIgnoresAMinuteOldTimestamp(t *testing.T) {
page, err := Render(analyzeFixture(t))
if err != nil {
t.Fatal(err)
}
// Simulate a page imported an hour ago, down to the stamp.
stale := strings.Replace(page, "generated="+Timestamp(), "generated=2001-01-01 00:00 UTC", 1)
if stale == page {
t.Fatal("could not age the timestamp; the marker format changed")
}
merged, status, err := Merge(stale, page, false)
if err != nil {
t.Fatal(err)
}
if status != StatusUnchanged {
t.Errorf("status = %q, want unchanged for an hour-old page with identical content", status)
}
if merged != stale {
t.Error("an unchanged sync must return the file untouched, timestamp included")
}
// A genuine content change must still be reported, even with an old stamp.
edited := strings.Replace(stale, "# demo", "# demo, revised", 1)
if edited == stale {
t.Fatal("could not edit the title")
}
merged, status, err = Merge(stale, edited, false)
if err != nil {
t.Fatal(err)
}
if status != StatusUpdated {
t.Errorf("status = %q, want updated for a real content change", status)
}
if !strings.Contains(merged, "# demo, revised") {
t.Error("the edit did not reach the merged page")
}
}
// TestWrapStampIsFullyStripped guards the pattern itself: every byte of the
// timestamp has to be removed, not just the date.
func TestWrapStampIsFullyStripped(t *testing.T) {
page, err := Render(analyzeFixture(t))
if err != nil {
t.Fatal(err)
}
stripped := stampPattern.ReplaceAllString(page, stampReplacement)
if strings.Contains(stripped, " UTC") {
t.Errorf("the timestamp was not fully stripped: %q", firstLineOf(stripped))
}
if strings.Count(stripped, "-->") != strings.Count(page, "-->") {
t.Error("stripping the stamp swallowed more than the timestamp")
}
}
func firstLineOf(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}

560
backend/docgen/extract.go Normal file
View file

@ -0,0 +1,560 @@
package docgen
import (
"os"
"path/filepath"
"regexp"
"strconv"
"strings"
)
// ---------------------------------------------------------------------------
// Go source extraction
// ---------------------------------------------------------------------------
var (
// net/http style registrations, including the Go 1.22 method-prefixed form
// mux.HandleFunc("GET /health", h). The literal is captured whole and
// validated in parseRoute, which is where the method prefix is split off.
routeHandleCall = regexp.MustCompile(`\b(?:Handle|HandleFunc)\(\s*"([^"]+)"\s*(?:,\s*([A-Za-z0-9_.]+))?`)
// Route tables that pass the method as an http.MethodXxx constant, as
// go-guess does: register(router, spec, false, http.MethodGet, "/api/health", h)
routeMethodConst = regexp.MustCompile(`\bhttp\.Method([A-Za-z]+)\s*,\s*"(/[^"]*)"\s*(?:,\s*([A-Za-z0-9_.]+)([,)]))?`)
// Router-value registrations: r.Get("/api/x", h.X)
routeRouterCall = regexp.MustCompile(`\b\w+\.(Get|Post|Put|Patch|Delete|Head|Options|Trace)\(\s*"(/[^"]*)"\s*(?:,\s*([A-Za-z0-9_.]+)([,)]))?`)
// "GET /api/v1/x" as a literal argument.
methodPrefix = regexp.MustCompile(`^(GET|POST|PUT|PATCH|DELETE|HEAD|OPTIONS)\s+(\S.*)$`)
// flag.String("name", default, "usage")
flagCall = regexp.MustCompile(`flag\.(\w+)\(\s*"([^"]+)"\s*(?:,\s*([^,)]+))?\s*(?:,\s*"([^"]*)")?`)
// os.Getenv("X") / os.LookupEnv("X")
envCall = regexp.MustCompile(`os\.(?:Getenv|LookupEnv)\(\s*"([A-Z][A-Z0-9_]*)"`)
// Port literals anywhere in the project.
portLiteral = regexp.MustCompile(`(?:\bPORT\b|\bport\b|EXPOSE)[^0-9]{0,20}([0-9]{2,5})\b`)
testFuncDecl = regexp.MustCompile(`(?m)^func\s+(Test|Benchmark|Fuzz)\w*\(`)
)
// analyzeGoFile pulls routes, flags and environment variables out of one source
// file. Each extractor is independent: a syntax oddity in one never affects the
// others because these are textual scans, not a type-checked AST walk.
//
// Test files are excluded. They build requests with httptest.NewRequest, which
// looks exactly like a route registration, and a route table is never declared
// in a _test.go file.
func analyzeGoFile(p *Project, file scannedFile) {
if strings.HasSuffix(file.rel, "_test.go") {
return
}
source := file.text()
if source == "" {
return
}
for _, match := range routeHandleCall.FindAllStringSubmatch(source, -1) {
if route := parseRoute(match[1], match[2]); route.Path != "" {
p.Routes = append(p.Routes, route)
}
}
for _, match := range routeMethodConst.FindAllStringSubmatch(source, -1) {
p.Routes = append(p.Routes, Route{
Method: strings.ToUpper(match[1]),
Path: match[2],
Handler: handlerName(match[3]),
})
}
for _, match := range routeRouterCall.FindAllStringSubmatch(source, -1) {
p.Routes = append(p.Routes, Route{
Method: strings.ToUpper(match[1]),
Path: match[2],
Handler: handlerName(match[3]),
})
}
for _, match := range flagCall.FindAllStringSubmatch(source, -1) {
p.Flags = append(p.Flags, Flag{
Name: match[2],
Type: strings.ToLower(match[1]),
Default: unquote(strings.TrimSpace(match[3])),
Usage: strings.TrimSpace(match[4]),
})
}
for _, match := range envCall.FindAllStringSubmatch(source, -1) {
p.EnvVars = append(p.EnvVars, EnvVar{Name: match[1]})
}
}
// parseRoute splits an optional method prefix and normalises the path. A
// literal that is not a route — a query key, a header name, a MIME type — is
// rejected by requiring the path to look like one.
func parseRoute(raw, handler string) Route {
method, path := "", strings.TrimSpace(raw)
if match := methodPrefix.FindStringSubmatch(raw); match != nil {
method, path = match[1], strings.TrimSpace(match[2])
}
if !strings.HasPrefix(path, "/") {
return Route{}
}
return Route{Method: method, Path: path, Handler: handlerName(handler)}
}
// handlerNames that are never useful in a route table: they are literal
// arguments, receivers or framework values rather than handler functions.
var noiseHandlers = map[string]struct{}{
"nil": {}, "true": {}, "false": {}, "spec": {}, "router": {}, "mux": {},
"r": {}, "g": {}, "chi": {}, "group": {}, "protected": {}, "public": {},
"api": {}, "v1": {}, "sub": {}, "app": {}, "server": {}, "srv": {},
"ctx": {}, "c": {}, "w": {}, "req": {}, "request": {}, "http": {},
}
// handlerName reduces a handler expression to a readable name, dropping the
// receiver and rejecting anything that is not plainly a function reference.
func handlerName(handler string) string {
handler = strings.TrimSpace(handler)
if handler == "" {
return ""
}
if i := strings.LastIndex(handler, "."); i >= 0 {
handler = handler[i+1:]
}
handler = strings.TrimSuffix(handler, ".func1")
if _, noise := noiseHandlers[handler]; noise {
return ""
}
if handler == "HandlerFunc" || strings.Contains(handler, "(") {
return ""
}
return handler
}
// ---------------------------------------------------------------------------
// go.mod
// ---------------------------------------------------------------------------
var (
modLine = regexp.MustCompile(`^module\s+(\S+)`)
goVersionLine = regexp.MustCompile(`^go\s+(\S+)`)
requireLine = regexp.MustCompile(`^\s*(\S+)\s+(v\S+)(.*)$`)
)
// parseGoMod reads a go.mod outside any require block.
func parseGoMod(rel, source string) Module {
module := Module{File: rel}
inBlock := false
for _, raw := range strings.Split(source, "\n") {
line := strings.TrimRight(raw, " \t")
trimmed := strings.TrimSpace(line)
switch {
case strings.HasPrefix(trimmed, "module "):
if module.Path == "" {
module.Path = strings.TrimSpace(strings.TrimPrefix(trimmed, "module "))
}
case strings.HasPrefix(trimmed, "go "):
if module.GoVersion == "" {
if match := goVersionLine.FindStringSubmatch(trimmed); match != nil {
module.GoVersion = match[1]
}
}
case trimmed == "require (":
inBlock = true
case inBlock && trimmed == ")":
inBlock = false
case inBlock && trimmed != "" && !strings.HasPrefix(trimmed, "//"):
match := requireLine.FindStringSubmatch(line)
if match != nil {
addDependency(&module, match[1], match[2], strings.Contains(match[3], "// indirect"))
}
case strings.HasPrefix(trimmed, "require ") && !inBlock:
rest := strings.TrimSpace(strings.TrimPrefix(trimmed, "require "))
if match := requireLine.FindStringSubmatch(" " + rest); match != nil {
addDependency(&module, match[1], match[2], strings.Contains(match[3], "// indirect"))
}
}
}
return module
}
func addDependency(module *Module, path, version string, indirect bool) {
if path == "" || strings.HasPrefix(path, "//") {
return
}
if indirect {
module.Indirect++
return
}
module.Direct = append(module.Direct, Dependency{Path: path, Version: version})
}
// ---------------------------------------------------------------------------
// Makefile
// ---------------------------------------------------------------------------
var makeTargetLine = regexp.MustCompile(`^([A-Za-z0-9][A-Za-z0-9_.-]*):`)
// makeTargets extracts invocable targets, skipping .PHONY declarations and
// variable assignments.
func makeTargets(source string) []string {
var targets []string
phony := map[string]struct{}{}
for _, raw := range strings.Split(source, "\n") {
if strings.HasPrefix(raw, "\t") || strings.HasPrefix(raw, " ") {
continue
}
line := strings.TrimRight(raw, " \t")
if strings.HasPrefix(line, ".PHONY") {
for _, name := range strings.Fields(line) {
if name == ".PHONY:" || name == ".PHONY" {
continue
}
phony[strings.TrimSuffix(name, ":")] = struct{}{}
}
continue
}
match := makeTargetLine.FindStringSubmatch(line)
if match == nil {
continue
}
name := match[1]
if strings.HasPrefix(name, ".") {
continue
}
if _, isPhony := phony[name]; isPhony {
continue
}
targets = append(targets, name)
}
return targets
}
// ---------------------------------------------------------------------------
// Docker
// ---------------------------------------------------------------------------
var (
dockerFrom = regexp.MustCompile(`(?mi)^\s*FROM\s+(\S+)(?:\s+AS\s+(\S+))?`)
dockerExp = regexp.MustCompile(`(?mi)^\s*EXPOSE\s+(\S+)`)
)
func parseDockerfile(rel, source string) *DockerInfo {
info := &DockerInfo{File: rel}
for _, match := range dockerFrom.FindAllStringSubmatch(source, -1) {
info.Bases = append(info.Bases, match[1])
if match[2] != "" {
info.Stages = append(info.Stages, match[2])
}
}
for _, match := range dockerExp.FindAllStringSubmatch(source, -1) {
info.Exposed = append(info.Exposed, match[1])
}
// Order matters: the first FROM is the build stage and the last is the
// runtime image, so these lists are deduplicated but never sorted.
info.Bases = dedupeInOrder(info.Bases)
info.Stages = dedupeInOrder(info.Stages)
info.Exposed = dedupeInOrder(info.Exposed)
return info
}
// ---------------------------------------------------------------------------
// docker-compose
// ---------------------------------------------------------------------------
// parseCompose reads service names, images and published ports out of a compose
// file. It is a line scanner rather than a YAML parser: compose files in these
// projects are consistently indented two spaces per level, and avoiding a YAML
// dependency keeps the module dependency-free.
func parseCompose(rel, source string) []ComposeService {
var (
services []ComposeService
inBlock bool
current int = -1
)
for _, raw := range strings.Split(source, "\n") {
line := strings.TrimRight(raw, " \t")
trimmed := strings.TrimSpace(line)
if trimmed == "" || strings.HasPrefix(trimmed, "#") {
continue
}
indent := len(line) - len(strings.TrimLeft(line, " "))
if indent == 0 {
inBlock = trimmed == "services:"
current = -1
continue
}
if !inBlock {
continue
}
if indent == 2 && strings.HasSuffix(trimmed, ":") {
services = append(services, ComposeService{Name: strings.TrimSuffix(trimmed, ":"), File: rel})
current = len(services) - 1
continue
}
if current < 0 || indent != 4 {
continue
}
key, value, found := strings.Cut(trimmed, ":")
if !found {
continue
}
switch strings.TrimSpace(key) {
case "image":
services[current].Image = strings.Trim(strings.TrimSpace(value), `"'`)
case "ports":
if services[current].Ports == "" {
services[current].Ports = strings.Trim(strings.TrimSpace(value), `"'`)
} else {
services[current].Ports += ", " + strings.Trim(strings.TrimSpace(value), `"'`)
}
}
}
// Drop the well-known internal networks and volume declarations that the
// indentation scanner otherwise mistakes for services.
names := map[string]struct{}{}
for _, name := range []string{
"networks", "volumes", "configs", "secrets", "services",
} {
names[name] = struct{}{}
}
out := services[:0]
for _, service := range services {
if _, skip := names[service.Name]; skip {
continue
}
out = append(out, service)
}
return out
}
// ---------------------------------------------------------------------------
// .env files
// ---------------------------------------------------------------------------
// unquote strips the quoting Go string literals carry around literal values.
func unquote(value string) string {
if len(value) >= 2 {
if (value[0] == '"' && value[len(value)-1] == '"') ||
(value[0] == '`' && value[len(value)-1] == '`') {
return value[1 : len(value)-1]
}
}
return value
}
// envFromExample reads KEY=VALUE pairs out of a .env or .env.example file.
func envFromExample(source string) []EnvVar {
var out []EnvVar
for _, raw := range strings.Split(source, "\n") {
line := strings.TrimSpace(raw)
if line == "" || strings.HasPrefix(line, "#") {
continue
}
key, value, found := strings.Cut(line, "=")
if !found {
continue
}
key = strings.TrimSpace(key)
if key == "" || !strings.ContainsAny(key, "ABCDEFGHIJKLMNOPQRSTUVWXYZ") {
continue
}
out = append(out, EnvVar{Name: key, Example: strings.Trim(strings.TrimSpace(value), `"'`), FromEnv: true})
}
return out
}
// ---------------------------------------------------------------------------
// Misc
// ---------------------------------------------------------------------------
// portsIn extracts plausible TCP port literals from any file.
func portsIn(source string) []string {
if source == "" {
return nil
}
var out []string
for _, match := range portLiteral.FindAllStringSubmatch(source, -1) {
port, err := strconv.Atoi(match[1])
if err != nil || port < 1024 || port > 65535 {
continue
}
out = append(out, match[1])
}
return out
}
// portBearingFiles reports whether a file is a plausible source of a declared
// port. Scanning every source file for port literals produces noise (version
// numbers, row counts), so only operational files are considered.
func portBearingFiles(rel string) bool {
base := strings.ToLower(filepath.Base(rel))
switch {
case isDockerfile(rel), isComposeFile(rel), isHelmFile(rel):
return true
case base == "makefile", base == "makefile.am":
return true
case base == "readme.md":
return true
case strings.HasPrefix(base, ".env"):
return true
case strings.HasSuffix(base, ".service"), strings.HasSuffix(base, ".env.example"):
return true
default:
return false
}
}
// isHelmFile reports whether a path is part of a Helm chart, anchored on the two
// files that define one.
func isHelmFile(rel string) bool {
base := strings.ToLower(filepath.Base(rel))
return base == "chart.yaml" || base == "values.yaml"
}
// markdownTitle returns the first heading of a Markdown document.
func markdownTitle(source string) string {
for _, raw := range strings.Split(source, "\n") {
line := strings.TrimSpace(raw)
if strings.HasPrefix(line, "#") {
return strings.TrimSpace(strings.TrimLeft(line, "#"))
}
if line != "" {
return ""
}
}
return ""
}
// findReadme locates the project's README, preferring the repository root.
func findReadme(p *Project) string {
for _, candidate := range []string{"README.md", "readme.md", "README.markdown", "Readme.md"} {
if info, err := os.Stat(filepath.Join(p.Path, candidate)); err == nil && !info.IsDir() {
return candidate
}
}
for _, doc := range p.Docs {
if strings.EqualFold(filepath.Base(doc.Path), "readme.md") {
return doc.Path
}
}
return ""
}
// summarize produces the one-paragraph preamble shown above the audience tabs.
// It prefers the README's opening prose and falls back to a description derived
// from what the analyser found, so a project without a README still gets one.
func summarize(p *Project) string {
if p.Readme != "" {
if data, err := readCapped(p.Path+"/"+p.Readme, maxReadBytes); err == nil {
if summary := firstParagraph(string(data)); summary != "" {
return collapse(summary, 460)
}
}
}
parts := []string{}
if module := primaryModule(p); module != "" {
parts = append(parts, "Go module `"+module+"`")
}
if p.Stats.GoFiles > 0 {
parts = append(parts, plural(p.Stats.GoFiles, "Go source file", "Go source files"))
}
if len(p.Routes) > 0 {
parts = append(parts, plural(len(p.Routes), "HTTP endpoint", "HTTP endpoints"))
}
if len(p.EnvVars) > 0 {
parts = append(parts, plural(len(p.EnvVars), "environment variable", "environment variables"))
}
if len(parts) == 0 {
return "This project has no README yet. The sections below were derived from its structure on disk."
}
return "This project has no README yet. The analyser found " + joinList(parts) + "."
}
// firstParagraph returns the opening prose of a document: the first run of
// non-empty lines that is not a heading, badge, table or HTML block.
func firstParagraph(source string) string {
var lines []string
for _, raw := range strings.Split(source, "\n") {
trimmed := strings.TrimSpace(raw)
if strings.HasPrefix(trimmed, "```") {
break // prose never starts after a code fence in these READMEs
}
switch {
case trimmed == "":
if len(lines) > 0 {
return strings.Join(lines, " ")
}
case strings.HasPrefix(trimmed, "#"):
continue
case strings.HasPrefix(trimmed, "!"), strings.HasPrefix(trimmed, "[!"),
strings.HasPrefix(trimmed, "<"), strings.HasPrefix(trimmed, "|"):
continue // badge, HTML or table row
default:
lines = append(lines, trimmed)
}
}
return strings.Join(lines, " ")
}
// collapse folds whitespace and truncates on a word boundary.
func collapse(text string, limit int) string {
fields := strings.Fields(text)
if len(fields) == 0 {
return ""
}
out := strings.Join(fields, " ")
if len(out) <= limit {
return out
}
cut := out[:limit]
if i := strings.LastIndexByte(cut, ' '); i > limit/2 {
cut = cut[:i]
}
return strings.TrimRight(cut, " ,;:.") + "…"
}
func primaryModule(p *Project) string {
for _, module := range p.Modules {
if module.Path != "" {
return module.Path
}
}
return ""
}
func plural(n int, singular, many string) string {
if n == 1 {
return "1 " + singular
}
return strconv.Itoa(n) + " " + many
}
func joinList(parts []string) string {
switch len(parts) {
case 0:
return ""
case 1:
return parts[0]
case 2:
return parts[0] + " and " + parts[1]
default:
return strings.Join(parts[:len(parts)-1], ", ") + " and " + parts[len(parts)-1]
}
}

143
backend/docgen/merge.go Normal file
View file

@ -0,0 +1,143 @@
package docgen
import (
"fmt"
"regexp"
"strings"
)
// beginPrefix opens the machine-written region of an imported page. Everything
// between begin and EndMarker is replaced on every sync; anything outside it is
// preserved exactly as written.
const beginPrefix = "<!-- go-doc:generated:begin"
// EndMarker closes the machine-written region.
const EndMarker = "<!-- go-doc:generated:end -->"
// stampPattern matches the generated=<time> field of the opening marker. It is
// stripped before comparing an existing page with a freshly rendered one, because
// the timestamp changes on every run and would otherwise make every sync look
// like a real edit.
//
// The field has to be matched all the way to the "-->" that closes the comment:
// Timestamp writes "2006-01-02 15:04 UTC", so a pattern that stopped at the
// first space would leave the time behind and every sync that crossed a minute
// boundary would rewrite all four pages for nothing.
var stampPattern = regexp.MustCompile(`generated=[^\n]*?-->`)
// stampReplacement stands in for the stripped timestamp on both sides of the
// comparison, so an old page and a fresh render still compare equal.
const stampReplacement = "generated=STAMP-->"
// Status describes what Merge did, so callers can log precisely.
type Status string
// Possible merge outcomes.
const (
// StatusCreated means no file existed, so the page was written whole.
StatusCreated Status = "created"
// StatusUpdated means the generated region was replaced in place.
StatusUpdated Status = "updated"
// StatusUnchanged means the generated region already matched.
StatusUnchanged Status = "unchanged"
// StatusSkipped means the file had no generated markers and force was off.
StatusSkipped Status = "skipped"
)
// Block locates the three boundaries of the generated region inside a page:
// the opening marker, the body that follows it, and the byte after the closing
// marker. All offsets are absolute and end-exclusive where it matters, so a
// caller can rebuild a page as prefix + replacement + suffix.
type Block struct {
// Marker is the index of the opening marker.
Marker int
// Body is the index of the first byte after the opening marker's line.
Body int
// End is the index of the first byte after the closing marker.
End int
Found bool
}
// FindBlock locates the generated markers. Both must be present and the opening
// marker must sit at the start of a line, otherwise Found is false — which is
// the signal that a file was authored by hand and must not be clobbered.
func FindBlock(content string) Block {
start := strings.Index(content, beginPrefix)
if start < 0 {
return Block{}
}
// The marker must own its line: either it opens the file or a newline
// precedes it.
if start > 0 && content[start-1] != '\n' {
return Block{}
}
lineEnd := strings.IndexByte(content[start:], '\n')
if lineEnd < 0 {
return Block{}
}
body := start + lineEnd + 1
rest := content[body:]
end := strings.Index(rest, EndMarker)
if end < 0 {
return Block{}
}
return Block{
Marker: start,
Body: body,
End: body + end + len(EndMarker),
Found: true,
}
}
// Merge folds a freshly generated page into the existing file.
//
// Hand-written prose outside the markers survives untouched. A file with no
// markers at all is left alone unless force is set, so importing can never
// destroy a document a human wrote. An identical page is reported unchanged and
// returned verbatim, so a no-op sync does not rewrite the file or churn its
// modification time.
func Merge(existing string, generated string, force bool) (string, Status, error) {
if strings.TrimSpace(existing) == "" {
return generated, StatusCreated, nil
}
block := FindBlock(existing)
if !block.Found {
if !force {
return existing, StatusSkipped, nil
}
return generated, StatusUpdated, nil
}
// The replacement carries its own markers, so the preserved prefix stops
// before the old opening marker rather than after it.
var b strings.Builder
b.WriteString(existing[:block.Marker])
b.WriteString(generated)
b.WriteString(existing[block.End:])
merged := b.String()
if stampPattern.ReplaceAllString(merged, stampReplacement) ==
stampPattern.ReplaceAllString(existing, stampReplacement) {
return existing, StatusUnchanged, nil
}
return merged, StatusUpdated, nil
}
// Wrap surrounds body with the generated markers, stamping provenance so a
// reader can tell which project and which source revision produced the page.
//
// The body is normalised to exactly one trailing newline so the closing marker
// always starts its own line, and no newline is emitted after that marker: the
// marker is the last thing Wrap writes, so replacing a region in place and
// keeping the following bytes of the file cannot double the trailing blank line.
func Wrap(source, signature, body string) string {
return fmt.Sprintf(
"%s source=%s signature=%s generated=%s -->\n%s\n%s",
beginPrefix, source, signature, Timestamp(),
strings.TrimRight(body, "\n"), EndMarker,
)
}

565
backend/docgen/render.go Normal file
View file

@ -0,0 +1,565 @@
package docgen
import (
"fmt"
"sort"
"strings"
)
// Render produces the full Markdown page for a project, wrapped in generated
// markers. The output always carries the three level-2 audience headings that
// go-doc's reader keys off, so an imported page is immediately navigable.
func Render(p *Project) (string, error) {
view := buildView(p)
var b builder
b.h1(p.Name)
b.para(p.Summary)
b.table(
[]string{"", ""},
[]row{
{"Module", view.ModuleSummary},
{"Endpoints", view.EndpointCount},
{"Components", view.ComponentCount},
{"Tests", view.TestSummary},
{"Go lines", view.LineSummary},
},
)
b.note("Everything between the generated markers is rewritten by every sync. " +
"Notes written outside the markers are kept.")
users(&b, view)
operators(&b, view)
developers(&b, view)
return Wrap(p.Name, p.Signature, strings.TrimRight(b.String(), "\n")), nil
}
// ---------------------------------------------------------------------------
// Audience sections
// ---------------------------------------------------------------------------
func users(b *builder, v docView) {
b.h2("For Users")
b.h3("What this application does")
b.para(v.Summary)
if v.HasReadme {
b.para(fmt.Sprintf(
"The project ships its own README at `%s`, which is the long form of this page. "+
"The three audience tabs split the same facts three ways so you only read the part "+
"that concerns you.", v.Readme))
} else {
b.para("This project ships no README, so the summary above was derived from the source " +
"tree. If that is wrong, write a README and re-run the sync.")
}
b.h3("Reading this in go-doc")
b.para("Open the page and pick the tab that matches you:")
b.bullets([]string{
"**User** — you use the finished application. This section is all you need.",
"**Operator** — you run it. Configuration, ports and deployment notes are under that tab.",
"**Developer** — you integrate with it or change it. The API and layout tables are under that tab.",
})
b.para("The feedback panel stores a note per audience, tagged with your badge, so operators " +
"and developers can see which audience raised it.")
if len(v.Docs) > 0 {
b.h3("Where the original documentation lives")
b.para(fmt.Sprintf(
"This project already carries %s. They stay in the project repository: this page "+
"points at them instead of copying them, so there is always exactly one copy of "+
"the truth.", plural(len(v.Docs), "Markdown document", "Markdown documents")))
rows := make([]row, 0, len(v.Docs))
for _, doc := range v.Docs {
rows = append(rows, row{"`" + doc.Path + "`", fmt.Sprintf("%d bytes", doc.Bytes)})
}
b.table([]string{"Document", "Size"}, rows)
}
}
func operators(b *builder, v docView) {
b.rule()
b.h2("For Operators")
b.h3("Running it locally")
if len(v.MakeTargets) > 0 {
b.para(fmt.Sprintf("The root Makefile defines %s:",
plural(len(v.MakeTargets), "target", "targets")))
b.bullets(backtickEach(v.MakeTargets, "make "))
} else {
b.para("This project has no root Makefile, so the binary or container is run directly.")
}
if len(v.Compose) > 0 {
b.h3("Local containers")
b.para("The compose file defines these services:")
rows := make([]row, 0, len(v.Compose))
for _, service := range v.Compose {
image := "built locally"
if service.Image != "" {
image = "`" + service.Image + "`"
}
ports := "internal only"
if service.Ports != "" {
ports = service.Ports
}
rows = append(rows, row{service.Name, image, ports})
}
b.table([]string{"Service", "Image", "Published ports"}, rows)
}
if v.Docker != nil {
b.h3("Image")
var sentence strings.Builder
fmt.Fprintf(&sentence, "`%s` builds from", v.Docker.File)
for i, base := range v.Docker.Bases {
if i > 0 {
sentence.WriteString(",")
}
fmt.Fprintf(&sentence, " `%s`", base)
}
if len(v.Docker.Stages) > 0 {
fmt.Fprintf(&sentence, " in stages: %s", strings.Join(v.Docker.Stages, ", "))
}
if len(v.Docker.Exposed) > 0 {
fmt.Fprintf(&sentence, " and exposes %s", strings.Join(v.Docker.Exposed, ", "))
}
sentence.WriteString(".")
b.para(sentence.String())
}
b.h3("Configuration")
if len(v.EnvVars) > 0 {
b.para("Every environment variable found in the source or in a `.env` file:")
rows := make([]row, 0, len(v.EnvVars))
for _, env := range v.EnvVars {
example := "—"
if env.Example != "" {
example = "`" + env.Example + "`"
}
source := "source"
if env.FromEnv {
source = ".env file"
}
rows = append(rows, row{"`" + env.Name + "`", example, source})
}
b.table([]string{"Variable", "Example", "Found in"}, rows)
} else {
b.para("No environment variables were found in the source or in a `.env` file.")
}
if len(v.Flags) > 0 {
b.para("Command-line flags override the environment where both exist:")
rows := make([]row, 0, len(v.Flags))
for _, flag := range v.Flags {
decl := "-" + flag.Name
if flag.Default != "" {
decl += "=" + flag.Default
}
def := "—"
if flag.Default != "" {
def = "`" + flag.Default + "`"
}
usage := "—"
if flag.Usage != "" {
usage = collapse(flag.Usage, 90)
}
rows = append(rows, row{"`" + decl + "`", def, usage})
}
b.table([]string{"Flag", "Default", "Purpose"}, rows)
}
b.h3("Network surface")
if len(v.Ports) > 0 {
quoted := make([]string, 0, len(v.Ports))
for _, port := range v.Ports {
quoted = append(quoted, "`"+port+"`")
}
b.para("Ports referenced across the project: " + strings.Join(quoted, ", ") + ".")
} else {
b.para("No explicit port declaration was found. Check the README and the compose file " +
"before assuming a default.")
}
b.h3("Deployment artefacts")
if len(v.HelmCharts) > 0 {
b.para(fmt.Sprintf("Helm %s found:",
plural(len(v.HelmCharts), "chart", "charts")))
b.bullets(backtickEach(v.HelmCharts, ""))
} else {
b.para("No Helm chart was found, so deployment is expected to be plain Docker or a " +
"managed platform.")
}
if len(v.OperatorDocs) > 0 {
b.h3("Operations documentation that already exists")
b.para("Read these in place rather than duplicating them here:")
b.bullets(docBullets(v.OperatorDocs))
}
for _, note := range v.Notes {
b.note(note)
}
}
func developers(b *builder, v docView) {
b.rule()
b.h2("For Developers")
b.h3("Module layout")
if len(v.ModuleSummaries) > 0 {
b.bullets(v.ModuleSummaries)
} else {
b.para("No go.mod in this tree: the project may be vendored, a workspace member, or " +
"not Go at all.")
}
b.h3("HTTP API")
if len(v.Routes) > 0 {
b.para("Every route registration found in the source:")
rows := make([]row, 0, len(v.Routes))
for _, route := range v.Routes {
method := "ANY"
if route.Method != "" {
method = "`" + route.Method + "`"
}
handler := "—"
if route.Handler != "" {
handler = "`" + route.Handler + "`"
}
rows = append(rows, row{method, "`" + route.Path + "`", handler})
}
b.table([]string{"Method", "Path", "Handler"}, rows)
} else {
b.para("No route registrations were detected, which is expected for a CLI, a library " +
"or a worker.")
}
b.h3("Direct dependencies")
if deps := v.DirectDeps(); len(deps) > 0 {
items := make([]string, 0, len(deps))
for _, dep := range deps {
items = append(items, "`"+dep.Path+"` "+dep.Version)
}
b.bullets(items)
} else {
b.para("No third-party dependencies are declared.")
}
b.h3("Directory map")
if len(v.Dirs) > 0 {
rows := make([]row, 0, len(v.Dirs))
for _, dir := range v.Dirs {
rows = append(rows, row{
"`" + dir.Name + "/`",
fmt.Sprintf("%d", dir.Files),
fmt.Sprintf("%d", dir.Lines),
directoryPurpose(dir.Name),
})
}
b.table([]string{"Directory", "Files", "Lines", "Likely purpose"}, rows)
} else {
b.para("No first-level directories were found.")
}
b.h3("Languages")
if len(v.Languages) > 0 {
items := make([]string, 0, len(v.Languages))
for _, lang := range v.Languages {
items = append(items, fmt.Sprintf("**%s** — %s, %d lines", lang.Name,
plural(lang.Files, "file", "files"), lang.Lines))
}
b.bullets(items)
} else {
b.para("No source files were recognised.")
}
if len(v.DeveloperDocs) > 0 {
b.h3("Developer documentation that already exists")
b.bullets(docBullets(v.DeveloperDocs))
}
}
// ---------------------------------------------------------------------------
// Markdown builder
// ---------------------------------------------------------------------------
// builder assembles Markdown with predictable spacing. Block elements ask for a
// separator instead of writing blank lines by hand, so every block is preceded
// by exactly one blank line no matter which branch produced it — the failure
// mode this replaces was headings glued to the paragraph above them.
type builder struct {
out strings.Builder
lastBlank bool
}
func (b *builder) String() string { return b.out.String() }
// ensureBlank separates the next line from the previous one unless it already is.
func (b *builder) ensureBlank() {
if b.out.Len() == 0 || b.lastBlank {
return
}
b.out.WriteString("\n")
}
func (b *builder) line(text string) {
b.out.WriteString(text)
b.out.WriteString("\n")
b.lastBlank = text == ""
}
func (b *builder) h1(text string) { b.ensureBlank(); b.line("# " + text) }
func (b *builder) h2(text string) { b.ensureBlank(); b.line("## " + text) }
func (b *builder) h3(text string) { b.ensureBlank(); b.line("### " + text) }
func (b *builder) para(text string) { b.ensureBlank(); b.line(text) }
func (b *builder) note(text string) { b.ensureBlank(); b.line("> " + text) }
// rule writes a horizontal rule, which always gets its own block.
func (b *builder) rule() { b.ensureBlank(); b.line("---") }
// bullets writes a tight list: the separator goes before the list, never between
// its items.
func (b *builder) bullets(items []string) {
if len(items) == 0 {
return
}
b.ensureBlank()
for _, item := range items {
b.line("- " + item)
}
}
// row is one Markdown table row.
type row []string
// table writes a GitHub-flavoured table. Cell content is emitted verbatim: every
// caller has already added its own backticks, so the builder must not guess.
func (b *builder) table(headers []string, rows []row) {
b.ensureBlank()
cells := make([]string, len(headers))
for i, header := range headers {
cells[i] = header
}
b.line("| " + strings.Join(cells, " | ") + " |")
separators := make([]string, len(headers))
for i := range separators {
separators[i] = "---"
}
b.line("| " + strings.Join(separators, " | ") + " |")
for _, r := range rows {
b.line("| " + strings.Join(r, " | ") + " |")
}
}
// backtickEach renders values as a list of code-formatted bullet items.
func backtickEach(values []string, prefix string) []string {
out := make([]string, 0, len(values))
for _, value := range values {
out = append(out, "`"+prefix+value+"`")
}
return out
}
// docBullets renders discovered documents as code-formatted bullets with their
// titles when they have one.
func docBullets(docs []DocFile) []string {
out := make([]string, 0, len(docs))
for _, doc := range docs {
item := "`" + doc.Path + "`"
if doc.Title != "" {
item += " — " + doc.Title
}
out = append(out, item)
}
return out
}
// ---------------------------------------------------------------------------
// View model
// ---------------------------------------------------------------------------
// docView is the pre-computed, presentation-ready view of a project.
type docView struct {
*Project
ModuleSummary string
EndpointCount string
ComponentCount string
TestSummary string
LineSummary string
HasReadme bool
OperatorDocs []DocFile
DeveloperDocs []DocFile
ModuleSummaries []string
Notes []string
}
// DirectDeps flattens the direct requirements of every module in the tree.
func (v docView) DirectDeps() []Dependency {
var out []Dependency
for _, m := range v.Modules {
out = append(out, m.Direct...)
}
sort.Slice(out, func(i, j int) bool { return out[i].Path < out[j].Path })
return out
}
func buildView(p *Project) docView {
v := docView{Project: p}
switch module := primaryModule(p); {
case module == "":
v.ModuleSummary = "no go.mod in this tree"
default:
parts := []string{module}
for _, m := range p.Modules {
if m.GoVersion != "" {
parts = append(parts, "go "+m.GoVersion)
break
}
}
parts = append(parts, plural(p.Stats.GoFiles, "Go file", "Go files"))
v.ModuleSummary = strings.Join(parts, " · ")
}
v.EndpointCount = plural(len(p.Routes), "endpoint", "endpoints")
v.ComponentCount = plural(p.Stats.Components, "component directory", "component directories")
v.LineSummary = plural(p.Stats.TotalLines, "line", "lines")
v.HasReadme = p.Readme != ""
if p.Stats.TestFuncs > 0 {
v.TestSummary = fmt.Sprintf("%d in %s", p.Stats.TestFuncs,
plural(p.Stats.TestFiles, "file", "files"))
} else {
v.TestSummary = "none detected"
}
// Route existing project docs under the audience that most likely owns them,
// so the generated page points rather than duplicates.
for _, doc := range p.Docs {
lower := strings.ToLower(doc.Path)
switch {
case containsAny(lower, "operat", "runbook", "deploy", "infra", "install", "troubleshoot"):
v.OperatorDocs = append(v.OperatorDocs, doc)
case containsAny(lower, "architect", "develop", "api", "integration", "contract", "coding", "design"):
v.DeveloperDocs = append(v.DeveloperDocs, doc)
}
}
for _, m := range p.Modules {
var summary strings.Builder
summary.WriteString(m.File)
if m.Path != "" {
summary.WriteString(" — module " + m.Path)
}
if m.GoVersion != "" {
summary.WriteString(", go " + m.GoVersion)
}
fmt.Fprintf(&summary, ", %d direct and %d indirect dependencies", len(m.Direct), m.Indirect)
v.ModuleSummaries = append(v.ModuleSummaries, summary.String())
}
// Anything a reader should know before trusting the generated content.
if !v.HasReadme {
v.Notes = append(v.Notes,
"This project ships no README, so the summary was derived from the tree layout. Add one and re-run the sync.")
}
if len(p.Modules) == 0 {
v.Notes = append(v.Notes, "No go.mod was found, so this tree was analysed as plain source files.")
}
if p.Docker == nil {
v.Notes = append(v.Notes, "No Dockerfile was found at any level.")
}
if len(p.Routes) == 0 && len(p.Modules) > 0 {
v.Notes = append(v.Notes,
"No HTTP route registrations were detected. If this is a server, the API table is incomplete — check for routes built dynamically.")
}
return v
}
func containsAny(haystack string, needles ...string) bool {
for _, needle := range needles {
if strings.Contains(haystack, needle) {
return true
}
}
return false
}
// directoryPurpose guesses what a top-level directory is for. It is a heuristic
// on names only, and it admits ignorance rather than inventing an answer.
func directoryPurpose(name string) string {
switch strings.ToLower(name) {
case "internal":
return "unimportable application internals"
case "pkg":
return "public library packages"
case "cmd", "bin", "main":
return "binary entry points"
case "api":
return "HTTP handlers and routing"
case "server":
return "server entry point"
case "services", "service":
return "domain services"
case "web", "ui", "frontend", "client":
return "frontend assets"
case "bdd", "e2e", "tests", "test", "features":
return "tests"
case "deploy", "deployment", "charts", "helm", "k8s", "kubernetes":
return "deployment manifests"
case "docker", "scripts", "script", "hack":
return "operational tooling"
case "docs", "documentation":
return "project documentation"
case "migrations":
return "schema migrations"
case "contracts", "openapi", "proto", "schemas":
return "shared schemas or contracts"
case "data":
return "flat-file data"
case "dist", "build", "out":
return "build output"
case "examples", "example":
return "worked examples"
case "auth":
return "authentication"
case "config":
return "configuration loading"
case "security":
return "security controls"
case "store", "storage":
return "persistence layer"
case "database":
return "database access"
case "models", "model", "schema":
return "domain models"
case "middleware":
return "HTTP middleware"
case "templates", "views":
return "render templates"
case "assets", "static":
return "static assets"
case "feedback", "comments":
return "user feedback"
default:
return "unknown — inspect directly"
}
}

BIN
backend/go-doc Executable file

Binary file not shown.

View file

@ -0,0 +1,141 @@
// Package client provides fail-closed API-key authorization middleware for Go services.
package client
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net"
"net/http"
"strings"
"time"
)
const DefaultHeader = "X-API-Key"
type Config struct {
BaseURL string
Tenant string
Application string
Header string
HTTPClient *http.Client
}
type Client struct {
baseURL string
tenant string
application string
header string
httpClient *http.Client
}
type Principal struct {
TenantID string `json:"tenant_id"`
TenantSlug string `json:"tenant_slug"`
ApplicationID string `json:"application_id"`
ApplicationSlug string `json:"application_slug"`
APIKeyID string `json:"api_key_id"`
APIKeyName string `json:"api_key_name"`
}
type authorizationResponse struct {
Allowed bool `json:"allowed"`
Reason string `json:"reason"`
Principal
}
const XTenantId = "X-Tenant-Id"
type contextKey struct{}
func New(cfg Config) (*Client, error) {
if strings.TrimSpace(cfg.BaseURL) == "" || strings.TrimSpace(cfg.Application) == "" {
return nil, errors.New("go-loose client requires BaseURL and Application")
}
if cfg.Header == "" {
cfg.Header = DefaultHeader
}
if cfg.HTTPClient == nil {
cfg.HTTPClient = &http.Client{Timeout: 3 * time.Second}
}
return &Client{
baseURL: strings.TrimRight(cfg.BaseURL, "/"), tenant: cfg.Tenant,
application: cfg.Application, header: cfg.Header, httpClient: cfg.HTTPClient,
}, nil
}
func (c *Client) Authorize(ctx context.Context, apiKey, method, requestPath, host string) (Principal, error) {
payload, err := json.Marshal(map[string]string{
"tenant": c.tenant, "application": c.application, "method": method,
"path": requestPath, "host": stripPort(host),
})
if err != nil {
return Principal{}, fmt.Errorf("encode authorization request: %w", err)
}
request, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/api/v1/authorize", bytes.NewReader(payload))
if err != nil {
return Principal{}, fmt.Errorf("create authorization request: %w", err)
}
request.Header.Set("Content-Type", "application/json")
request.Header.Set(c.header, apiKey)
response, err := c.httpClient.Do(request)
if err != nil {
return Principal{}, fmt.Errorf("authorize with Go Loose: %w", err)
}
defer response.Body.Close()
body, err := io.ReadAll(io.LimitReader(response.Body, 1<<20))
if err != nil {
return Principal{}, fmt.Errorf("read authorization response: %w", err)
}
var result authorizationResponse
if err := json.Unmarshal(body, &result); err != nil {
return Principal{}, fmt.Errorf("decode authorization response (HTTP %d): %w", response.StatusCode, err)
}
if response.StatusCode != http.StatusOK || !result.Allowed {
if result.Reason == "" {
result.Reason = http.StatusText(response.StatusCode)
}
return Principal{}, fmt.Errorf("request denied by Go Loose: %s", result.Reason)
}
return result.Principal, nil
}
// Middleware rejects missing, invalid, or unverifiable keys and places the
// authorized Principal in the request context.
func (c *Client) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
apiKey := strings.TrimSpace(r.Header.Get(c.header))
if apiKey == "" {
writeMiddlewareError(w, http.StatusUnauthorized, "missing API key")
return
}
principal, err := c.Authorize(r.Context(), apiKey, r.Method, r.URL.Path, r.Host)
if err != nil {
writeMiddlewareError(w, http.StatusForbidden, "API key not authorized")
return
}
r.Header.Del(c.header)
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), contextKey{}, principal)))
})
}
func PrincipalFromContext(ctx context.Context) (Principal, bool) {
principal, ok := ctx.Value(contextKey{}).(Principal)
return principal, ok
}
func stripPort(host string) string {
if parsed, _, err := net.SplitHostPort(host); err == nil {
return strings.ToLower(parsed)
}
return strings.ToLower(host)
}
func writeMiddlewareError(w http.ResponseWriter, status int, message string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(map[string]string{"error": message})
}

View file

@ -0,0 +1,54 @@
package client
import (
"net/http"
"net/http/httptest"
"testing"
)
func TestMiddlewareAuthorizesAndAddsPrincipal(t *testing.T) {
authorizationServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get(DefaultHeader) != "gl_test_key_that_is_long_enough_for_client" {
t.Errorf("unexpected API key")
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"allowed":true,"tenant_id":"tenant-1","tenant_slug":"nmbs","application_id":"app-1","application_slug":"guess","api_key_id":"key-1","api_key_name":"test"}`))
}))
defer authorizationServer.Close()
client, err := New(Config{BaseURL: authorizationServer.URL, Tenant: "nmbs", Application: "guess"})
if err != nil {
t.Fatalf("New() error = %v", err)
}
handler := client.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
principal, ok := PrincipalFromContext(r.Context())
if !ok || principal.TenantSlug != "nmbs" {
t.Fatalf("missing principal: %#v", principal)
}
if r.Header.Get(DefaultHeader) != "" {
t.Fatal("middleware must not forward the credential")
}
w.WriteHeader(http.StatusNoContent)
}))
request := httptest.NewRequest(http.MethodGet, "http://nmbs.guess.local/guesses", nil)
request.Header.Set(DefaultHeader, "gl_test_key_that_is_long_enough_for_client")
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusNoContent {
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
}
}
func TestMiddlewareFailsClosedWithoutKey(t *testing.T) {
client, err := New(Config{BaseURL: "http://go-loose", Application: "guess"})
if err != nil {
t.Fatalf("New() error = %v", err)
}
called := false
handler := client.Middleware(http.HandlerFunc(func(http.ResponseWriter, *http.Request) { called = true }))
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, httptest.NewRequest(http.MethodGet, "/", nil))
if recorder.Code != http.StatusUnauthorized || called {
t.Fatalf("status = %d, called = %v", recorder.Code, called)
}
}

View file

@ -0,0 +1,237 @@
package client
import (
"context"
"crypto/rand"
"crypto/subtle"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"net/url"
"strings"
"time"
)
const (
defaultSessionCookie = "go_loose_user_session"
stateCookie = "go_loose_login_state"
)
type BrowserAuthConfig struct {
BaseURL string
ClientID string
ClientSecret string
RedirectURL string
AfterLoginURL string
AfterLogoutURL string
LoginPath string
CookieName string
CookieSecure bool
HTTPClient *http.Client
}
type BrowserAuth struct {
config BrowserAuthConfig
}
type User struct {
ID string `json:"id"`
Email string `json:"email"`
DisplayName string `json:"display_name"`
AvatarURL string `json:"avatar_url"`
TenantID string `json:"tenant_id"`
TenantSlug string `json:"tenant_slug"`
ApplicationID string `json:"application_id"`
Application string `json:"application"`
// Role is the user's membership role in the owning tenant, for example
// user, operator, developer, admin, or owner.
Role string `json:"role"`
// Permissions lists the capability names granted by Role, such as
// "console.read", "keys.manage", or "profile.edit". Gate application
// features on these instead of comparing role names.
Permissions []string `json:"permissions"`
}
// Can reports whether the role behind this session grants a capability.
func (u User) Can(permission string) bool {
for _, granted := range u.Permissions {
if granted == permission {
return true
}
}
return false
}
type userContextKey struct{}
func NewBrowserAuth(config BrowserAuthConfig) (*BrowserAuth, error) {
if config.BaseURL == "" || config.ClientID == "" || config.ClientSecret == "" || config.RedirectURL == "" {
return nil, errors.New("browser auth requires BaseURL, ClientID, ClientSecret, and RedirectURL")
}
redirect, err := url.Parse(config.RedirectURL)
if err != nil || !redirect.IsAbs() {
return nil, errors.New("RedirectURL must be absolute")
}
config.BaseURL = strings.TrimRight(config.BaseURL, "/")
if config.AfterLoginURL == "" {
config.AfterLoginURL = "/"
}
if config.AfterLogoutURL == "" {
config.AfterLogoutURL = "/"
}
if config.LoginPath == "" {
config.LoginPath = "/login"
}
if config.CookieName == "" {
config.CookieName = defaultSessionCookie
}
if config.HTTPClient == nil {
config.HTTPClient = &http.Client{Timeout: 5 * time.Second}
}
return &BrowserAuth{config: config}, nil
}
func (a *BrowserAuth) LoginHandler(w http.ResponseWriter, r *http.Request) {
state, err := randomValue(24)
if err != nil {
http.Error(w, "could not start login", http.StatusInternalServerError)
return
}
http.SetCookie(w, &http.Cookie{
Name: stateCookie, Value: state, Path: callbackPath(a.config.RedirectURL),
HttpOnly: true, Secure: a.config.CookieSecure, SameSite: http.SameSiteLaxMode, MaxAge: 600,
})
query := url.Values{
"response_type": {"code"}, "client_id": {a.config.ClientID},
"redirect_uri": {a.config.RedirectURL}, "state": {state},
}
http.Redirect(w, r, a.config.BaseURL+"/connect/authorize?"+query.Encode(), http.StatusFound)
}
func (a *BrowserAuth) CallbackHandler(w http.ResponseWriter, r *http.Request) {
state, err := r.Cookie(stateCookie)
if err != nil || state.Value == "" || subtle.ConstantTimeCompare([]byte(state.Value), []byte(r.URL.Query().Get("state"))) != 1 {
http.Error(w, "invalid login state", http.StatusBadRequest)
return
}
code := r.URL.Query().Get("code")
if code == "" {
http.Error(w, "authorization code missing", http.StatusBadRequest)
return
}
form := url.Values{
"grant_type": {"authorization_code"}, "code": {code},
"redirect_uri": {a.config.RedirectURL},
}
request, err := http.NewRequestWithContext(r.Context(), http.MethodPost, a.config.BaseURL+"/connect/token", strings.NewReader(form.Encode()))
if err != nil {
http.Error(w, "could not complete login", http.StatusInternalServerError)
return
}
request.Header.Set("Content-Type", "application/x-www-form-urlencoded")
request.SetBasicAuth(a.config.ClientID, a.config.ClientSecret)
response, err := a.config.HTTPClient.Do(request)
if err != nil {
http.Error(w, "identity service unavailable", http.StatusBadGateway)
return
}
defer response.Body.Close()
var tokenResponse struct {
AccessToken string `json:"access_token"`
ExpiresIn int `json:"expires_in"`
}
if err := json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&tokenResponse); err != nil || response.StatusCode != http.StatusOK || tokenResponse.AccessToken == "" {
http.Error(w, "authorization code exchange failed", http.StatusUnauthorized)
return
}
http.SetCookie(w, &http.Cookie{
Name: a.config.CookieName, Value: tokenResponse.AccessToken, Path: "/",
HttpOnly: true, Secure: a.config.CookieSecure, SameSite: http.SameSiteLaxMode,
MaxAge: tokenResponse.ExpiresIn,
})
http.SetCookie(w, &http.Cookie{Name: stateCookie, Value: "", Path: callbackPath(a.config.RedirectURL), MaxAge: -1, HttpOnly: true})
http.Redirect(w, r, a.config.AfterLoginURL, http.StatusSeeOther)
}
func (a *BrowserAuth) LogoutHandler(w http.ResponseWriter, r *http.Request) {
var revokeErr error
if cookie, err := r.Cookie(a.config.CookieName); err == nil {
request, requestErr := http.NewRequestWithContext(r.Context(), http.MethodPost, a.config.BaseURL+"/connect/logout", nil)
if requestErr != nil {
revokeErr = requestErr
} else {
request.Header.Set("Authorization", "Bearer "+cookie.Value)
response, responseErr := a.config.HTTPClient.Do(request)
if responseErr != nil {
revokeErr = responseErr
} else {
response.Body.Close()
if response.StatusCode != http.StatusNoContent {
revokeErr = fmt.Errorf("Go Loose logout returned HTTP %d", response.StatusCode)
}
}
}
}
http.SetCookie(w, &http.Cookie{
Name: a.config.CookieName, Value: "", Path: "/", MaxAge: -1,
HttpOnly: true, Secure: a.config.CookieSecure, SameSite: http.SameSiteLaxMode,
})
if revokeErr != nil {
http.Error(w, "local session cleared, but central session revocation failed", http.StatusBadGateway)
return
}
http.Redirect(w, r, a.config.AfterLogoutURL, http.StatusSeeOther)
}
func (a *BrowserAuth) Middleware(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
cookie, err := r.Cookie(a.config.CookieName)
if err != nil || cookie.Value == "" {
http.Redirect(w, r, a.config.LoginPath, http.StatusSeeOther)
return
}
request, err := http.NewRequestWithContext(r.Context(), http.MethodGet, a.config.BaseURL+"/connect/userinfo", nil)
if err != nil {
http.Error(w, "could not validate session", http.StatusInternalServerError)
return
}
request.Header.Set("Authorization", "Bearer "+cookie.Value)
response, err := a.config.HTTPClient.Do(request)
if err != nil {
http.Error(w, "identity service unavailable", http.StatusServiceUnavailable)
return
}
defer response.Body.Close()
var user User
if response.StatusCode != http.StatusOK || json.NewDecoder(io.LimitReader(response.Body, 1<<20)).Decode(&user) != nil {
http.SetCookie(w, &http.Cookie{Name: a.config.CookieName, Value: "", Path: "/", MaxAge: -1, HttpOnly: true})
http.Redirect(w, r, a.config.LoginPath, http.StatusSeeOther)
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userContextKey{}, user)))
})
}
func UserFromContext(ctx context.Context) (User, bool) {
user, ok := ctx.Value(userContextKey{}).(User)
return user, ok
}
func randomValue(size int) (string, error) {
value := make([]byte, size)
if _, err := rand.Read(value); err != nil {
return "", fmt.Errorf("generate random value: %w", err)
}
return base64.RawURLEncoding.EncodeToString(value), nil
}
func callbackPath(rawURL string) string {
parsed, err := url.Parse(rawURL)
if err != nil || parsed.Path == "" {
return "/"
}
return parsed.Path
}

View file

@ -0,0 +1,101 @@
package client
import (
"net/http"
"net/http/httptest"
"net/url"
"testing"
)
func TestBrowserLoginRedirect(t *testing.T) {
auth, err := NewBrowserAuth(BrowserAuthConfig{
BaseURL: "https://nmbs.goloose.local", ClientID: "client",
ClientSecret: "secret", RedirectURL: "https://nmbs.guess.local/auth/callback",
})
if err != nil {
t.Fatalf("NewBrowserAuth() error = %v", err)
}
recorder := httptest.NewRecorder()
auth.LoginHandler(recorder, httptest.NewRequest(http.MethodGet, "/login", nil))
if recorder.Code != http.StatusFound {
t.Fatalf("status = %d", recorder.Code)
}
target, err := url.Parse(recorder.Header().Get("Location"))
if err != nil {
t.Fatalf("parse location: %v", err)
}
if target.Path != "/connect/authorize" || target.Query().Get("client_id") != "client" || target.Query().Get("state") == "" {
t.Fatalf("unexpected redirect: %s", target)
}
if len(recorder.Result().Cookies()) != 1 || !recorder.Result().Cookies()[0].HttpOnly {
t.Fatal("expected an HTTP-only state cookie")
}
}
func TestBrowserMiddlewareAddsUser(t *testing.T) {
identity := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.Header.Get("Authorization") != "Bearer session-token" {
t.Fatal("missing bearer token")
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"id":"user-1","email":"user@example.com","tenant_slug":"nmbs","application":"guess"}`))
}))
defer identity.Close()
auth, err := NewBrowserAuth(BrowserAuthConfig{
BaseURL: identity.URL, ClientID: "client", ClientSecret: "secret",
RedirectURL: "http://localhost/auth/callback",
})
if err != nil {
t.Fatalf("NewBrowserAuth() error = %v", err)
}
handler := auth.Middleware(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, ok := UserFromContext(r.Context())
if !ok || user.Email != "user@example.com" {
t.Fatalf("unexpected user: %#v", user)
}
w.WriteHeader(http.StatusNoContent)
}))
request := httptest.NewRequest(http.MethodGet, "http://localhost/private", nil)
request.AddCookie(&http.Cookie{Name: defaultSessionCookie, Value: "session-token"})
recorder := httptest.NewRecorder()
handler.ServeHTTP(recorder, request)
if recorder.Code != http.StatusNoContent {
t.Fatalf("status = %d", recorder.Code)
}
}
func TestBrowserCallbackUsesBasicClientAuthentication(t *testing.T) {
identity := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
clientID, secret, ok := r.BasicAuth()
if !ok || clientID != "client-id" || secret != "client-secret" {
t.Fatalf("unexpected client authentication: %q, %q, %v", clientID, secret, ok)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"access_token":"session-token","expires_in":3600}`))
}))
defer identity.Close()
auth, err := NewBrowserAuth(BrowserAuthConfig{
BaseURL: identity.URL, ClientID: "client-id", ClientSecret: "client-secret",
RedirectURL: "http://localhost/auth/callback",
})
if err != nil {
t.Fatalf("NewBrowserAuth() error = %v", err)
}
request := httptest.NewRequest(http.MethodGet, "/auth/callback?code=one-time-code&state=login-state", nil)
request.AddCookie(&http.Cookie{Name: stateCookie, Value: "login-state"})
recorder := httptest.NewRecorder()
auth.CallbackHandler(recorder, request)
if recorder.Code != http.StatusSeeOther {
t.Fatalf("status = %d, body = %s", recorder.Code, recorder.Body.String())
}
var session *http.Cookie
for _, cookie := range recorder.Result().Cookies() {
if cookie.Name == defaultSessionCookie {
session = cookie
}
}
if session == nil || session.Value != "session-token" || !session.HttpOnly {
t.Fatalf("unexpected session cookie: %#v", session)
}
}

5
backend/go.mod Normal file
View file

@ -0,0 +1,5 @@
module go-doc
go 1.27.1
require github.com/danyel/go-loose v0.0.0-20261002224534-b011701522b1

1
backend/go.sum Normal file
View file

@ -0,0 +1 @@
github.com/danyel/go-loose v0.0.0-20261002224534-b011701522b1/go.mod h1:qOO4ay0c2MWiiLwz7WHCXCqDwxxA3sKTKKUnqbFgsOk=

226
backend/main.go Normal file
View file

@ -0,0 +1,226 @@
// Command godoc runs the go-doc documentation and feedback platform.
//
// It serves the REST API on /api/* and, when the React bundle has been compiled
// in via `make build`, serves the single-page application on every other path.
// Shutdown is graceful: SIGINT/SIGTERM stop accepting connections, let in-flight
// requests finish, and close the process cleanly.
package main
import (
"context"
"errors"
"flag"
"fmt"
"log/slog"
"net"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"go-doc/go-loose/client"
"go-doc/router"
"go-doc/storage"
"go-doc/webui"
)
const (
defaultAddr = "0.0.0.0:8080"
readHeaderTimeout = 10 * time.Second
readTimeout = 30 * time.Second
writeTimeout = 60 * time.Second
idleTimeout = 120 * time.Second
shutdownGracePeriod = 10 * time.Second
shutdownSignalWindow = 5 * time.Second
)
func main() {
if err := run(); err != nil {
slog.Error("fatal", "error", err)
os.Exit(1)
}
}
func run() error {
var (
addr = flag.String("addr", envOr("GO_DOC_ADDR", defaultAddr), "host:port the API listens on")
dataDir = flag.String("data", "", "flat-file data directory (defaults to GO_DOC_DATA_DIR or auto-discovery)")
origins = flag.String("cors", envOr("GO_DOC_CORS", ""), "comma-separated allowed origins (default: *)")
logLevel = flag.String("log-level", envOr("GO_DOC_LOG_LEVEL", "info"), "debug, info, warn or error")
logFormat = flag.String("log-format", envOr("GO_DOC_LOG_FORMAT", "text"), "text or json")
looseBaseURL = flag.String("loose-base-url", envOr("GO_LOOSE_BASE_URL", ""), "go-loose base URL for authentication (e.g., https://auth.example.com)")
looseClientID = flag.String("loose-client-id", envOr("GO_LOOSE_CLIENT_ID", ""), "go-loose client ID for OAuth")
looseSecret = flag.String("loose-client-secret", envOr("GO_LOOSE_CLIENT_SECRET", ""), "go-loose client secret for OAuth")
looseRedirect = flag.String("loose-redirect-url", envOr("GO_LOOSE_REDIRECT_URL", ""), "go-loose redirect URL after login (e.g., https://doc.example.com/auth/callback)")
)
flag.Parse()
logger := newLogger(*logLevel, *logFormat)
slog.SetDefault(logger)
root := *dataDir
if root == "" {
root = storage.ResolveRoot()
}
engine, err := storage.New(root)
if err != nil {
return fmt.Errorf("initialise storage: %w", err)
}
logger.Info("storage ready",
"data_root", engine.Root(),
"docs", engine.DocsDir(),
"comments", engine.CommentsDir(),
)
staticFS := webui.FS()
if staticFS == nil {
logger.Warn("no embedded frontend bundle found; serving API only",
"hint", "run `make frontend-run` for the Vite dev server or `make build` to embed assets")
}
// Initialize go-loose browser auth if configured
var browserAuth *client.BrowserAuth
if *looseBaseURL != "" && *looseClientID != "" && *looseSecret != "" && *looseRedirect != "" {
browserAuthConfig := client.BrowserAuthConfig{
BaseURL: *looseBaseURL,
ClientID: *looseClientID,
ClientSecret: *looseSecret,
RedirectURL: *looseRedirect,
AfterLoginURL: "/",
AfterLogoutURL: "/",
LoginPath: "/auth/login",
CookieName: "go_doc_session",
CookieSecure: true,
}
browserAuth, err = client.NewBrowserAuth(browserAuthConfig)
if err != nil {
logger.Warn("go-loose browser auth not configured", "error", err)
} else {
logger.Info("go-loose browser auth enabled", "base_url", *looseBaseURL)
}
}
cfg := router.Config{
Engine: engine,
Static: staticFS,
AllowedOrigins: splitAndTrim(*origins),
Logger: logger,
BrowserAuth: browserAuth,
}
if len(cfg.AllowedOrigins) == 0 {
cfg.AllowedOrigins = []string{router.DefaultAllowedOrigin}
}
handler, err := router.NewWithConfig(cfg)
if err != nil {
return fmt.Errorf("build router: %w", err)
}
srv := &http.Server{
Addr: *addr,
Handler: handler,
ReadHeaderTimeout: readHeaderTimeout,
ReadTimeout: readTimeout,
WriteTimeout: writeTimeout,
IdleTimeout: idleTimeout,
ErrorLog: slog.NewLogLogger(logger.Handler(), slog.LevelWarn),
}
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
listener, err := net.Listen("tcp", *addr)
if err != nil {
return fmt.Errorf("listen on %s: %w", *addr, err)
}
logger.Info("go-doc listening",
"addr", listener.Addr().String(),
"frontend_embedded", staticFS != nil,
"cors_origins", strings.Join(cfg.AllowedOrigins, ", "),
)
serveErr := make(chan error, 1)
go func() {
if err := srv.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) {
serveErr <- err
return
}
serveErr <- nil
}()
select {
case err := <-serveErr:
if err != nil {
return fmt.Errorf("http server: %w", err)
}
return nil
case <-ctx.Done():
stop()
logger.Info("shutdown signal received", "grace", shutdownGracePeriod.String())
}
shutdownCtx, cancel := context.WithTimeout(context.Background(), shutdownGracePeriod)
defer cancel()
if err := srv.Shutdown(shutdownCtx); err != nil {
logger.Error("graceful shutdown failed; forcing close", "error", err)
if closeErr := srv.Close(); closeErr != nil {
return fmt.Errorf("force close: %w", closeErr)
}
return nil
}
select {
case err := <-serveErr:
if err != nil {
return fmt.Errorf("http server: %w", err)
}
case <-time.After(shutdownSignalWindow):
}
logger.Info("stopped cleanly")
return nil
}
func newLogger(level, format string) *slog.Logger {
var lvl slog.Level
switch strings.ToLower(strings.TrimSpace(level)) {
case "debug":
lvl = slog.LevelDebug
case "warn", "warning":
lvl = slog.LevelWarn
case "error":
lvl = slog.LevelError
default:
lvl = slog.LevelInfo
}
opts := &slog.HandlerOptions{Level: lvl}
if strings.EqualFold(strings.TrimSpace(format), "json") {
return slog.New(slog.NewJSONHandler(os.Stdout, opts))
}
return slog.New(slog.NewTextHandler(os.Stdout, opts))
}
func envOr(key, fallback string) string {
if v := strings.TrimSpace(os.Getenv(key)); v != "" {
return v
}
return fallback
}
func splitAndTrim(raw string) []string {
if strings.TrimSpace(raw) == "" {
return nil
}
parts := strings.Split(raw, ",")
out := make([]string, 0, len(parts))
for _, p := range parts {
if trimmed := strings.TrimSpace(p); trimmed != "" {
out = append(out, trimmed)
}
}
return out
}

768
backend/router/router.go Normal file
View file

@ -0,0 +1,768 @@
// Package router wires the go-doc REST API onto net/http.
//
// It exposes flat-file CRUD for Markdown documents, audience-badged comment
// tracking, and -- when built via `make build` -- serves the compiled React
// bundle from an embedded filesystem so the Go binary is fully self-contained.
// Routes use the standard library's method-and-wildcard patterns, which keeps
// the backend dependency-free.
package router
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"io/fs"
"log/slog"
"net/http"
"os"
"path"
"runtime/debug"
"strconv"
"strings"
"time"
"go-doc/auth"
"go-doc/go-loose/client"
"go-doc/storage"
)
// Default payload ceilings, applied with http.MaxBytesReader before any parsing.
const (
DefaultMaxDocBytes = 5 << 20 // 5 MiB of Markdown
DefaultMaxCommentBytes = 256 << 10 // 256 KiB per comment
DefaultAllowedOrigin = "*"
)
// Config tunes the HTTP layer. Zero values fall back to the defaults above.
type Config struct {
Engine *storage.Engine
Static fs.FS
AllowedOrigins []string
MaxDocBytes int64
MaxCommentByte int64
Logger *slog.Logger
// DevFallback is returned when Static is nil so a developer hitting the API
// port directly gets an actionable hint instead of a bare 404.
DevFallback string
// BrowserAuth enables go-loose OAuth authentication for the web UI.
BrowserAuth *client.BrowserAuth
}
func (c Config) withDefaults() Config {
if c.MaxDocBytes <= 0 {
c.MaxDocBytes = DefaultMaxDocBytes
}
if c.MaxCommentByte <= 0 {
c.MaxCommentByte = DefaultMaxCommentBytes
}
if len(c.AllowedOrigins) == 0 {
c.AllowedOrigins = []string{DefaultAllowedOrigin}
}
if c.Logger == nil {
c.Logger = slog.Default()
}
return c
}
// New builds the fully decorated handler around a storage engine.
func New(engine *storage.Engine) (http.Handler, error) {
return NewWithConfig(Config{Engine: engine})
}
// NewWithConfig builds the handler stack from an explicit configuration.
func NewWithConfig(cfg Config) (http.Handler, error) {
cfg = cfg.withDefaults()
if cfg.Engine == nil {
engine, err := storage.Default()
if err != nil {
return nil, fmt.Errorf("router: resolve storage engine: %w", err)
}
cfg.Engine = engine
}
h := &handler{engine: cfg.Engine, cfg: cfg}
mux := http.NewServeMux()
mux.HandleFunc("GET /api/health", h.health)
mux.HandleFunc("GET /api/roles", h.roles)
mux.HandleFunc("GET /api/docs", h.listDocs)
mux.HandleFunc("GET /api/docs/{slug}", h.getDoc)
mux.HandleFunc("HEAD /api/docs/{slug}", h.getDoc)
mux.HandleFunc("POST /api/docs/{slug}", h.saveDoc)
mux.HandleFunc("PUT /api/docs/{slug}", h.saveDoc)
mux.HandleFunc("DELETE /api/docs/{slug}", h.deleteDoc)
mux.HandleFunc("GET /api/docs/{slug}/comments", h.listComments)
mux.HandleFunc("POST /api/docs/{slug}/comments", h.createComment)
mux.HandleFunc("DELETE /api/docs/{slug}/comments/{id}", h.deleteComment)
mux.HandleFunc("GET /api/docs/{slug}/stats", h.docStats)
mux.HandleFunc("GET /api/stats", h.globalStats)
// Operations endpoints
mux.HandleFunc("GET /api/ops/scripts", h.downloadScripts)
mux.HandleFunc("GET /api/ops/docs", h.getOpsDoc)
// Auth endpoints
mux.HandleFunc("GET /auth/login", h.login)
mux.HandleFunc("GET /auth/callback", h.callback)
mux.HandleFunc("POST /auth/logout", h.logout)
mux.HandleFunc("GET /api/auth/profile", h.profile)
mux.Handle("/", auth.BrowserAuthMiddleware(cfg.BrowserAuth)(h.frontend()))
var handler http.Handler = mux
handler = corsMiddleware(cfg.AllowedOrigins)(handler)
handler = loggingMiddleware(cfg.Logger)(handler)
handler = recoverMiddleware(cfg.Logger)(handler)
return handler, nil
}
type handler struct {
engine *storage.Engine
cfg Config
}
// ---------------------------------------------------------------------------
// Response helpers
// ---------------------------------------------------------------------------
func writeJSON(w http.ResponseWriter, status int, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(status)
enc := json.NewEncoder(w)
enc.SetIndent("", " ")
if err := enc.Encode(payload); err != nil {
slog.Error("router: encode response", "error", err)
}
}
type errorBody struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
RequestID string `json:"request_id,omitempty"`
} `json:"error"`
Status int `json:"status"`
}
func writeError(w http.ResponseWriter, r *http.Request, status int, code, message string) {
var body errorBody
body.Error.Code = code
body.Error.Message = message
body.Error.RequestID = requestIDFrom(r.Context())
body.Status = status
writeJSON(w, status, body)
}
// writeStoreError maps engine sentinels onto HTTP status codes.
func (h *handler) writeStoreError(w http.ResponseWriter, r *http.Request, err error) {
switch {
case errors.Is(err, storage.ErrNotFound):
writeError(w, r, http.StatusNotFound, "not_found", err.Error())
case errors.Is(err, storage.ErrInvalidSlug):
writeError(w, r, http.StatusBadRequest, "invalid_slug", err.Error())
case errors.Is(err, storage.ErrInvalidRole):
writeError(w, r, http.StatusBadRequest, "invalid_role", err.Error())
case errors.Is(err, storage.ErrInvalidComment):
writeError(w, r, http.StatusBadRequest, "invalid_comment", err.Error())
default:
h.cfg.Logger.Error("storage failure", "error", err, "path", r.URL.Path)
writeError(w, r, http.StatusInternalServerError, "internal_error", "storage operation failed")
}
}
// ---------------------------------------------------------------------------
// API: documents
// ---------------------------------------------------------------------------
func (h *handler) health(w http.ResponseWriter, r *http.Request) {
writeJSON(w, http.StatusOK, map[string]any{
"status": "ok",
"service": "go-doc",
"data_root": h.engine.Root(),
"time": time.Now().UTC(),
})
}
func (h *handler) roles(w http.ResponseWriter, r *http.Request) {
roles := make([]map[string]string, 0, 3)
for _, role := range storage.Roles() {
roles = append(roles, map[string]string{
"role": string(role),
"badge": role.Badge(),
"label": role.Label(),
"hex": role.Hex(),
})
}
writeJSON(w, http.StatusOK, map[string]any{"roles": roles})
}
func (h *handler) listDocs(w http.ResponseWriter, r *http.Request) {
query := strings.TrimSpace(r.URL.Query().Get("q"))
detailed := r.URL.Query().Get("counts") == "1" || r.URL.Query().Get("counts") == "true"
var (
docs []storage.DocMeta
err error
)
if detailed {
docs, err = h.engine.ListDocsDetailed()
} else {
docs, err = h.engine.Search(query)
}
if err != nil {
h.writeStoreError(w, r, err)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"docs": docs,
"count": len(docs),
"query": query,
"data_root": h.engine.Root(),
})
}
func (h *handler) getDoc(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
content, err := h.engine.ReadDoc(slug)
if err != nil {
h.writeStoreError(w, r, err)
return
}
if wantsJSON(r) {
meta, metaErr := h.engine.Stat(slug)
if metaErr != nil {
h.writeStoreError(w, r, metaErr)
return
}
writeJSON(w, http.StatusOK, map[string]any{
"slug": meta.Slug,
"title": meta.Title,
"content": content,
"size": meta.Size,
"modified": meta.Modified,
"comments": meta.Comments,
})
return
}
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
if _, err := io.WriteString(w, content); err != nil {
h.cfg.Logger.Error("write markdown response", "error", err, "slug", slug)
}
}
// saveRequest is the JSON body accepted by POST /api/docs/{slug}. Raw
// text/markdown bodies are accepted too, which is what the Markdown editor tab
// in the UI sends.
type saveRequest struct {
Title string `json:"title"`
Content string `json:"content"`
Slug string `json:"slug"`
}
func (h *handler) saveDoc(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
body := http.MaxBytesReader(w, r.Body, h.cfg.MaxDocBytes)
title := strings.TrimSpace(r.URL.Query().Get("title"))
var content string
if isJSONRequest(r) {
var payload saveRequest
if err := json.NewDecoder(body).Decode(&payload); err != nil {
writeError(w, r, http.StatusBadRequest, "invalid_body", "expected JSON with a content field: "+err.Error())
return
}
content = payload.Content
if t := strings.TrimSpace(payload.Title); t != "" {
title = t
}
} else {
raw, err := io.ReadAll(body)
if err != nil {
writeError(w, r, http.StatusBadRequest, "body_too_large", fmt.Sprintf("document exceeds %d bytes", h.cfg.MaxDocBytes))
return
}
content = string(raw)
}
existed, err := h.engine.DocExists(slug)
if err != nil {
h.writeStoreError(w, r, err)
return
}
normalized, err := h.engine.SaveDoc(slug, content)
if err != nil {
h.writeStoreError(w, r, err)
return
}
meta, err := h.engine.Stat(normalized)
if err != nil {
h.writeStoreError(w, r, err)
return
}
if title != "" {
meta.Title = title
}
status := http.StatusOK
if !existed {
status = http.StatusCreated
}
writeJSON(w, status, map[string]any{
"slug": meta.Slug,
"title": meta.Title,
"size": meta.Size,
"modified": meta.Modified,
"created": !existed,
})
}
func (h *handler) deleteDoc(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
if err := h.engine.DeleteDoc(slug); err != nil {
h.writeStoreError(w, r, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *handler) listComments(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
comments, err := h.engine.ListComments(slug)
if err != nil {
h.writeStoreError(w, r, err)
return
}
// Filter comments by tenant if user is authenticated
if user, ok := auth.UserFromContext(r.Context()); ok {
filtered := make([]storage.Comment, 0, len(comments))
for _, c := range comments {
// Admin can see all comments, otherwise only show comments from same tenant
if user.IsAdmin() || c.TenantID == user.TenantID {
filtered = append(filtered, c)
}
}
comments = filtered
}
writeJSON(w, http.StatusOK, map[string]any{
"slug": storage.Slugify(slug),
"comments": comments,
"count": len(comments),
})
}
func (h *handler) createComment(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
body := http.MaxBytesReader(w, r.Body, h.cfg.MaxCommentByte)
var input storage.CommentInput
if err := json.NewDecoder(body).Decode(&input); err != nil {
writeError(w, r, http.StatusBadRequest, "invalid_body", "expected JSON with author, role and content: "+err.Error())
return
}
// Attach tenant/user info from auth context if available
if user, ok := auth.UserFromContext(r.Context()); ok {
input.TenantID = user.TenantID
input.TenantSlug = user.TenantSlug
input.UserID = user.ID
if input.Author == "" {
input.Author = user.DisplayName
}
}
comment, err := h.engine.AppendComment(slug, input)
if err != nil {
h.writeStoreError(w, r, err)
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"comment": comment,
"badge": comment.Badge,
})
}
func (h *handler) deleteComment(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
removed, err := h.engine.DeleteComment(slug, r.PathValue("id"))
if err != nil {
h.writeStoreError(w, r, err)
return
}
if !removed {
writeError(w, r, http.StatusNotFound, "not_found", "comment not found")
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *handler) docStats(w http.ResponseWriter, r *http.Request) {
slug := r.PathValue("slug")
stats, err := h.engine.Stats(slug)
if err != nil {
h.writeStoreError(w, r, err)
return
}
writeJSON(w, http.StatusOK, stats)
}
func (h *handler) globalStats(w http.ResponseWriter, r *http.Request) {
docs, err := h.engine.ListDocs()
if err != nil {
h.writeStoreError(w, r, err)
return
}
byRole := map[string]int{}
totalComments := 0
for _, doc := range docs {
stats, err := h.engine.Stats(doc.Slug)
if err != nil {
continue
}
totalComments += stats.Total
for role, n := range stats.ByRole {
byRole[role] += n
}
}
writeJSON(w, http.StatusOK, map[string]any{
"docs": len(docs),
"comments": totalComments,
"by_role": byRole,
})
}
// downloadScripts serves the operations scripts zip file.
func (h *handler) downloadScripts(w http.ResponseWriter, r *http.Request) {
zipPath := path.Join(h.engine.DocsDir(), "ops", "scripts.zip")
if _, err := os.Stat(zipPath); os.IsNotExist(err) {
writeError(w, r, http.StatusNotFound, "not_found", "scripts archive not found")
return
}
w.Header().Set("Content-Type", "application/zip")
w.Header().Set("Content-Disposition", "attachment; filename=\"ops-scripts.zip\"")
w.Header().Set("Cache-Control", "no-store")
http.ServeFile(w, r, zipPath)
}
// getOpsDoc serves the operations documentation.
func (h *handler) getOpsDoc(w http.ResponseWriter, r *http.Request) {
docPath := path.Join(h.engine.DocsDir(), "ops", "DEPLOYMENT.md")
content, err := os.ReadFile(docPath)
if err != nil {
if os.IsNotExist(err) {
writeError(w, r, http.StatusNotFound, "not_found", "documentation not found")
return
}
writeError(w, r, http.StatusInternalServerError, "internal_error", "failed to read documentation")
return
}
w.Header().Set("Content-Type", "text/markdown; charset=utf-8")
w.Header().Set("Cache-Control", "no-store")
w.WriteHeader(http.StatusOK)
w.Write(content)
}
// ---------------------------------------------------------------------------
// Auth
// ---------------------------------------------------------------------------
// login redirects to go-loose for authentication.
func (h *handler) login(w http.ResponseWriter, r *http.Request) {
if h.cfg.BrowserAuth == nil {
writeError(w, r, http.StatusNotImplemented, "not_implemented", "go-loose authentication not configured")
return
}
h.cfg.BrowserAuth.LoginHandler(w, r)
}
// callback handles the OAuth callback from go-loose.
func (h *handler) callback(w http.ResponseWriter, r *http.Request) {
if h.cfg.BrowserAuth == nil {
writeError(w, r, http.StatusNotImplemented, "not_implemented", "go-loose authentication not configured")
return
}
h.cfg.BrowserAuth.CallbackHandler(w, r)
}
// logout clears the session and redirects to go-loose logout.
func (h *handler) logout(w http.ResponseWriter, r *http.Request) {
if h.cfg.BrowserAuth == nil {
writeError(w, r, http.StatusNotImplemented, "not_implemented", "go-loose authentication not configured")
return
}
h.cfg.BrowserAuth.LogoutHandler(w, r)
}
// profile returns the current user's profile.
func (h *handler) profile(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, r, http.StatusUnauthorized, "unauthorized", "not authenticated")
return
}
writeJSON(w, http.StatusOK, map[string]any{
"id": user.ID,
"email": user.Email,
"display_name": user.DisplayName,
"avatar_url": user.AvatarURL,
"tenant_id": user.TenantID,
"tenant_slug": user.TenantSlug,
"application_id": user.ApplicationID,
"application": user.Application,
"role": user.Role,
"permissions": user.Permissions,
"memberships": []any{}, // TODO: fetch from go-loose if needed
})
}
// ---------------------------------------------------------------------------
// Frontend
// ---------------------------------------------------------------------------
func (h *handler) frontend() http.Handler {
if h.cfg.Static == nil {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
writeError(w, r, http.StatusNotFound, "not_found", "unknown endpoint "+r.URL.Path)
return
}
message := h.cfg.DevFallback
if message == "" {
message = "go-doc backend is running. Start the Vite dev server with `make frontend-run`, or run `make build` to embed the production bundle."
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.WriteHeader(http.StatusOK)
fmt.Fprintf(w, "<!doctype html><meta charset=\"utf-8\"><title>go-doc API</title><body style=\"font-family:ui-monospace,monospace;background:#0b0f14;color:#cbd5e1;padding:3rem\"><h1>go-doc API</h1><p>%s</p><p>Endpoints: <code>/api/health</code>, <code>/api/docs</code>, <code>/api/docs/{slug}</code>, <code>/api/docs/{slug}/comments</code>.</p></body>", message)
})
}
return spaHandler(h.cfg.Static)
}
// spaHandler serves the embedded React bundle and falls back to index.html for
// client-side routes such as /docs/core-service-api.
func spaHandler(static fs.FS) http.Handler {
files := http.FileServerFS(static)
serveIndex := func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Cache-Control", "no-store")
data, err := fs.ReadFile(static, "index.html")
if err != nil {
http.Error(w, "frontend bundle missing: "+err.Error(), http.StatusInternalServerError)
return
}
w.Header().Set("Content-Type", "text/html; charset=utf-8")
w.Header().Set("Content-Length", strconv.Itoa(len(data)))
w.WriteHeader(http.StatusOK)
if r.Method != http.MethodHead {
w.Write(data)
}
}
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if strings.HasPrefix(r.URL.Path, "/api/") {
writeError(w, r, http.StatusNotFound, "not_found", "unknown endpoint "+r.URL.Path)
return
}
name := strings.TrimPrefix(path.Clean("/"+r.URL.Path), "/")
if name == "" || name == "." {
serveIndex(w, r)
return
}
if !fs.ValidPath(name) {
writeError(w, r, http.StatusBadRequest, "invalid_path", "invalid request path")
return
}
f, err := static.Open(name)
if err != nil {
serveIndex(w, r)
return
}
info, statErr := f.Stat()
f.Close()
if statErr != nil || info.IsDir() {
serveIndex(w, r)
return
}
// Hashed build artefacts are immutable; everything else must revalidate.
if strings.HasPrefix(name, "assets/") {
w.Header().Set("Cache-Control", "public, max-age=31536000, immutable")
} else {
w.Header().Set("Cache-Control", "no-cache")
}
files.ServeHTTP(w, r)
})
}
// ---------------------------------------------------------------------------
// Middleware
// ---------------------------------------------------------------------------
func corsMiddleware(allowed []string) func(http.Handler) http.Handler {
set := make(map[string]struct{}, len(allowed))
for _, o := range allowed {
if trimmed := strings.TrimSpace(o); trimmed != "" {
set[strings.ToLower(trimmed)] = struct{}{}
}
}
// A literal "*" entry means "reflect any origin", which is the default
// configuration. Credentials are never allowed, so the wildcard stays safe.
_, allowAll := set["*"]
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
switch {
case origin == "":
w.Header().Set("Access-Control-Allow-Origin", DefaultAllowedOrigin)
case allowAll:
w.Header().Set("Access-Control-Allow-Origin", "*")
default:
normalized := strings.TrimSpace(strings.ToLower(origin))
if _, ok := set[normalized]; ok {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Add("Vary", "Origin")
}
}
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PUT, PATCH, DELETE, OPTIONS")
w.Header().Set(
"Access-Control-Allow-Headers",
"Origin, Content-Type, Content-Length, Accept, Authorization, X-Requested-With, X-Request-Id",
)
w.Header().Set("Access-Control-Expose-Headers", "X-Request-Id, Content-Length, Content-Type")
w.Header().Set("Access-Control-Max-Age", "600")
if r.Method == http.MethodOptions {
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
}
type statusRecorder struct {
http.ResponseWriter
status int
bytes int
}
func (s *statusRecorder) WriteHeader(code int) {
if s.status == 0 {
s.status = code
s.ResponseWriter.WriteHeader(code)
}
}
func (s *statusRecorder) Write(b []byte) (int, error) {
if s.status == 0 {
s.status = http.StatusOK
}
n, err := s.ResponseWriter.Write(b)
s.bytes += n
return n, err
}
func (s *statusRecorder) Flush() {
if f, ok := s.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}
func loggingMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
start := time.Now()
rec := &statusRecorder{ResponseWriter: w}
next.ServeHTTP(rec, r)
status := rec.status
if status == 0 {
status = http.StatusOK
}
level := slog.LevelInfo
if status >= 500 {
level = slog.LevelError
} else if status >= 400 {
level = slog.LevelWarn
}
logger.Log(r.Context(), level, "http",
"method", r.Method,
"path", r.URL.Path,
"status", status,
"bytes", rec.bytes,
"duration_ms", time.Since(start).Milliseconds(),
)
})
}
}
func recoverMiddleware(logger *slog.Logger) func(http.Handler) http.Handler {
return func(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
defer func() {
if rec := recover(); rec != nil {
logger.Error("panic recovered",
"error", fmt.Sprint(rec),
"path", r.URL.Path,
"stack", string(debug.Stack()),
)
writeError(w, r, http.StatusInternalServerError, "internal_error", "unexpected server error")
}
}()
next.ServeHTTP(w, r)
})
}
}
// ---------------------------------------------------------------------------
// Small helpers
// ---------------------------------------------------------------------------
func isJSONRequest(r *http.Request) bool {
ct := r.Header.Get("Content-Type")
if ct == "" {
return false
}
if i := strings.IndexByte(ct, ';'); i >= 0 {
ct = ct[:i]
}
ct = strings.ToLower(strings.TrimSpace(ct))
return ct == "application/json" || strings.HasSuffix(ct, "+json")
}
func wantsJSON(r *http.Request) bool {
switch strings.ToLower(strings.TrimSpace(r.URL.Query().Get("format"))) {
case "json":
return true
}
accept := r.Header.Get("Accept")
return strings.Contains(accept, "application/json") && !strings.Contains(accept, "text/markdown")
}
func requestIDFrom(ctx context.Context) string {
if ctx == nil {
return ""
}
if v, ok := ctx.Value(requestIDKey).(string); ok {
return v
}
return ""
}
const requestIDKey = contextKey("godoc-request-id")
type contextKey string

View file

@ -0,0 +1,498 @@
// Command docgen analyses every go-* project under a workspace root and imports
// audience-segmented documentation for each one into go-doc.
//
// One-shot sync:
//
// go run ./scripts/docgen -root /home/dnoulet/go -data ../data
//
// Continuous sync, re-importing whenever a project's docs or source change:
//
// go run ./scripts/docgen -root /home/dnoulet/go -data ../data -watch
//
// Imported pages are wrapped in generated markers. Re-running replaces only the
// machine-written region, so prose written outside the markers survives, and a
// page that was never generated is left untouched unless -force is passed.
package main
import (
"flag"
"fmt"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"sync"
"time"
"go-doc/docgen"
"go-doc/storage"
)
func main() {
if err := run(); err != nil {
slog.Error("fatal", "error", err)
os.Exit(1)
}
}
type options struct {
root string
dataDir string
apps string
skip string
watch bool
interval time.Duration
dryRun bool
force bool
importSubdoc bool
verbose bool
}
func run() error {
var opts options
flag.StringVar(&opts.root, "root", defaultRoot(), "directory containing the go-* projects")
flag.StringVar(&opts.dataDir, "data", storage.ResolveRoot(), "go-doc data directory to import into")
flag.StringVar(&opts.apps, "apps", "", "comma-separated project names to limit the run (default: all)")
flag.StringVar(&opts.skip, "skip", "go-doc", "comma-separated project names to ignore")
flag.BoolVar(&opts.watch, "watch", false, "keep running and re-import when a project changes")
flag.DurationVar(&opts.interval, "interval", 5*time.Second, "poll interval for -watch")
flag.BoolVar(&opts.dryRun, "dry-run", false, "analyse and render without writing anything")
flag.BoolVar(&opts.force, "force", false, "overwrite pages that carry no generated markers")
flag.BoolVar(&opts.importSubdoc, "import-subdocs", false, "also import each project's own Markdown as separate go-doc pages")
flag.BoolVar(&opts.verbose, "verbose", false, "log at debug level")
flag.Parse()
level := slog.LevelInfo
if opts.verbose {
level = slog.LevelDebug
}
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: level}))
engine, err := storage.New(opts.dataDir)
if err != nil {
return fmt.Errorf("cannot open the go-doc data directory: %w", err)
}
im := newImporter(opts, engine, logger)
if !opts.watch {
result, err := im.SyncOnce()
if err != nil {
return err
}
result.report(logger)
if result.Failed > 0 {
return fmt.Errorf("%d project(s) failed to import", result.Failed)
}
return nil
}
logger.Info("watching for documentation changes",
"root", opts.root,
"interval", opts.interval.String(),
"data", engine.Root(),
"hint", "Ctrl-C to stop",
)
ticker := time.NewTicker(opts.interval)
defer ticker.Stop()
for {
changed, err := im.Scan()
switch {
case err != nil:
logger.Error("scan failed", "error", err)
case len(changed) == 0:
logger.Debug("no changes")
default:
logger.Info("change detected", "projects", strings.Join(sortedKeys(changed), ", "))
result, syncErr := im.Import(sortedKeys(changed))
if syncErr != nil {
logger.Error("sync failed", "error", syncErr)
}
result.report(logger)
}
<-ticker.C
}
}
func defaultRoot() string {
if v := strings.TrimSpace(os.Getenv("GO_DOC_WORKSPACE")); v != "" {
return v
}
return "/home/dnoulet/go"
}
// Result summarises one sync pass.
type Result struct {
Created int
Updated int
Unchanged int
Skipped int
Failed int
Subdocs int
Pages []pageResult
}
func (r Result) report(logger *slog.Logger) {
logger.Info("sync complete",
"created", r.Created,
"updated", r.Updated,
"unchanged", r.Unchanged,
"skipped", r.Skipped,
"subdocs", r.Subdocs,
"failed", r.Failed,
)
for _, page := range r.Pages {
attrs := []any{"project", page.Project, "slug", page.Slug, "status", string(page.Status)}
if len(page.Notes) > 0 {
attrs = append(attrs, "notes", strings.Join(page.Notes, ", "))
}
if page.Sub {
attrs = append(attrs, "subdocument", true)
}
if page.Status == docgen.StatusSkipped {
logger.Warn("skipped", attrs...)
continue
}
logger.Info("imported", attrs...)
}
}
// pageResult is the outcome for one written page.
type pageResult struct {
Project string
Slug string
Status docgen.Status
Notes []string
Sub bool
}
// ---------------------------------------------------------------------------
// Import pipeline
// ---------------------------------------------------------------------------
// importer holds the sync state shared by the one-shot and watch paths.
type importer struct {
opts options
engine *storage.Engine
logger *slog.Logger
only map[string]struct{}
skipSet map[string]struct{}
// seen maps a project name to the signature observed on the previous poll,
// which is what makes "did this change?" a cheap map lookup.
seen map[string]string
mu sync.Mutex
}
func newImporter(opts options, engine *storage.Engine, logger *slog.Logger) *importer {
return &importer{
opts: opts,
engine: engine,
logger: logger,
only: splitSet(opts.apps),
skipSet: splitSet(opts.skip),
seen: map[string]string{},
}
}
func splitSet(raw string) map[string]struct{} {
out := map[string]struct{}{}
for _, part := range strings.Split(raw, ",") {
if trimmed := strings.TrimSpace(part); trimmed != "" {
out[trimmed] = struct{}{}
}
}
return out
}
// Discover returns the project names selected by -apps and -skip.
func (im *importer) Discover() ([]string, error) {
entries, err := os.ReadDir(im.opts.root)
if err != nil {
return nil, fmt.Errorf("cannot read workspace root %s: %w", im.opts.root, err)
}
var names []string
for _, entry := range entries {
name := entry.Name()
if !entry.IsDir() || !strings.HasPrefix(name, "go-") {
continue
}
if _, skip := im.skipSet[name]; skip {
continue
}
if len(im.only) > 0 {
if _, wanted := im.only[name]; !wanted {
continue
}
}
names = append(names, name)
}
sort.Strings(names)
return names, nil
}
// Signatures returns the current source signature per project.
func (im *importer) Signatures() (map[string]string, error) {
names, err := im.Discover()
if err != nil {
return nil, err
}
out := make(map[string]string, len(names))
for _, name := range names {
project, err := docgen.Analyze(name, filepath.Join(im.opts.root, name))
if err != nil {
im.logger.Debug("cannot analyse project", "project", name, "error", err)
continue
}
out[name] = project.Signature
}
return out, nil
}
// SyncOnce analyses and imports every discovered project.
func (im *importer) SyncOnce() (Result, error) {
names, err := im.Discover()
if err != nil {
return Result{}, err
}
if len(names) == 0 {
im.logger.Warn("no go-* projects discovered", "root", im.opts.root)
}
return im.Import(names)
}
// Scan returns the projects whose signature changed since the previous poll,
// including any project seen for the first time. Remembering every signature —
// not just the changed ones — keeps a project from re-importing forever once it
// has been synced. A project that has left the workspace is forgotten and
// reported: its page stays on disk untouched, because a sync that silently
// deletes documentation is worse than one that leaves it stale.
func (im *importer) Scan() (map[string]string, error) {
sigs, err := im.Signatures()
if err != nil {
return nil, err
}
im.mu.Lock()
defer im.mu.Unlock()
changed := map[string]string{}
for name, sig := range sigs {
if previous, seen := im.seen[name]; !seen || previous != sig {
changed[name] = sig
}
}
for name, sig := range sigs {
im.seen[name] = sig
}
var departed []string
for name := range im.seen {
if _, present := sigs[name]; !present {
departed = append(departed, name)
}
}
sort.Strings(departed)
for _, name := range departed {
delete(im.seen, name)
im.logger.Info("project left the workspace; its page was left as is",
"project", name,
"hint", "delete the page by hand if the project is gone for good",
)
}
return changed, nil
}
// Import analyses each named project and writes its page through the storage
// engine, so the result is slugified, atomically written and immediately
// visible to the API and the frontend.
func (im *importer) Import(names []string) (Result, error) {
var (
result Result
mu sync.Mutex
wg sync.WaitGroup
)
for _, name := range names {
wg.Add(1)
go func(name string) {
defer wg.Done()
pages, subdocs, err := im.importOne(name)
mu.Lock()
defer mu.Unlock()
if err != nil {
result.Failed++
im.logger.Error("import failed", "project", name, "error", err)
return
}
result.Subdocs += subdocs
result.Pages = append(result.Pages, pages...)
for _, page := range pages {
if page.Sub {
continue
}
switch page.Status {
case docgen.StatusCreated:
result.Created++
case docgen.StatusUpdated:
result.Updated++
case docgen.StatusUnchanged:
result.Unchanged++
case docgen.StatusSkipped:
result.Skipped++
}
}
}(name)
}
wg.Wait()
sort.Slice(result.Pages, func(i, j int) bool { return result.Pages[i].Slug < result.Pages[j].Slug })
return result, nil
}
// importOne builds and writes the page for a single project, plus any sub-pages
// when -import-subdocs is set.
func (im *importer) importOne(name string) ([]pageResult, int, error) {
root := filepath.Join(im.opts.root, name)
project, err := docgen.Analyze(name, root)
if err != nil {
return nil, 0, err
}
page, err := docgen.Render(project)
if err != nil {
return nil, 0, err
}
slug := storage.Slugify(name)
status, err := im.write(slug, page)
if err != nil {
return nil, 0, err
}
out := []pageResult{{
Project: name,
Slug: slug,
Status: status,
Notes: notes(project),
}}
if !im.opts.importSubdoc {
return out, 0, nil
}
subpages := im.subdocPages(project)
for _, sub := range subpages {
subStatus, err := im.write(sub.slug, sub.body)
if err != nil {
im.logger.Warn("subdocument import failed",
"project", name, "path", sub.source, "error", err)
continue
}
out = append(out, pageResult{
Project: name,
Slug: sub.slug,
Status: subStatus,
Sub: true,
})
}
return out, len(subpages), nil
}
type subdocPage struct {
source string
slug string
body string
}
// subdocPages turns each Markdown file the project already owns into its own
// go-doc page, wrapped so a later sync refreshes it instead of orphaning it.
func (im *importer) subdocPages(project *docgen.Project) []subdocPage {
base := storage.Slugify(project.Name)
var out []subdocPage
for _, doc := range project.Docs {
if doc.Path == project.Readme {
continue // already the summary of the main page
}
stem := strings.TrimSuffix(filepath.Base(doc.Path), filepath.Ext(doc.Path))
slug := storage.Slugify(project.Name + "-" + stem)
if slug == "" || slug == base {
continue
}
data, err := os.ReadFile(filepath.Join(project.Path, filepath.FromSlash(doc.Path)))
if err != nil {
continue
}
body := fmt.Sprintf(
"Imported from %s in the %s repository.\n\n---\n\n%s",
doc.Path, project.Name, strings.TrimRight(string(data), "\n"),
)
out = append(out, subdocPage{
source: doc.Path,
slug: slug,
body: docgen.Wrap(project.Name+":"+doc.Path, project.Signature, body),
})
}
return out
}
// write merges a generated page with whatever is already on disk. Existing prose
// outside the markers survives; a page with no markers is skipped unless force.
func (im *importer) write(slug, page string) (docgen.Status, error) {
var existing string
if data, err := os.ReadFile(filepath.Join(im.engine.DocsDir(), slug+".md")); err == nil {
existing = string(data)
}
merged, status, err := docgen.Merge(existing, page, im.opts.force)
if err != nil {
return "", err
}
if im.opts.dryRun {
im.logger.Debug("dry run", "slug", slug, "status", string(status), "bytes", len(merged))
return status, nil
}
if _, err := im.engine.SaveDoc(slug, merged); err != nil {
return "", err
}
return status, nil
}
// notes surfaces anything a reader should know about a generated page.
func notes(project *docgen.Project) []string {
var out []string
if project.Readme == "" {
out = append(out, "no README found")
}
if len(project.Routes) == 0 {
out = append(out, "no HTTP routes detected")
}
if len(project.Modules) == 0 {
out = append(out, "no go.mod found")
}
return out
}
func sortedKeys(m map[string]string) []string {
keys := make([]string, 0, len(m))
for key := range m {
keys = append(keys, key)
}
sort.Strings(keys)
return keys
}

View file

@ -0,0 +1,172 @@
package main
import (
"io"
"log/slog"
"os"
"path/filepath"
"sort"
"strings"
"testing"
"go-doc/storage"
)
func TestScanDetectsNewProjectsAndEditsButSettlesWhenIdle(t *testing.T) {
t.Parallel()
root := t.TempDir()
writeProject(t, filepath.Join(root, "go-alpha"))
im := newImporter(options{root: root}, newEngine(t), discardLogger())
// A project nobody has seen counts as changed.
changed := scan(t, im)
if want := []string{"go-alpha"}; !equal(changed, want) {
t.Fatalf("first scan = %v, want %v", changed, want)
}
// An untouched project must not re-import on every poll.
if changed := scan(t, im); len(changed) != 0 {
t.Errorf("idle scan reported %v, want no changes", changed)
}
// A project that appears mid-run is picked up.
writeProject(t, filepath.Join(root, "go-beta"))
if changed := scan(t, im); !equal(changed, []string{"go-beta"}) {
t.Errorf("scan after a new project = %v, want [go-beta]", changed)
}
// An edit to an existing project is picked up, once.
writeProject(t, filepath.Join(root, "go-alpha"))
if changed := scan(t, im); !equal(changed, []string{"go-alpha"}) {
t.Errorf("scan after an edit = %v, want [go-alpha]", changed)
}
if changed := scan(t, im); len(changed) != 0 {
t.Errorf("the edit was reported twice: %v", changed)
}
}
func TestScanForgetsProjectsThatLeaveTheWorkspace(t *testing.T) {
t.Parallel()
root := t.TempDir()
project := filepath.Join(root, "go-alpha")
writeProject(t, project)
var logs strings.Builder
im := newImporter(options{root: root}, newEngine(t),
slog.New(slog.NewTextHandler(&logs, &slog.HandlerOptions{Level: slog.LevelDebug})))
if changed := scan(t, im); len(changed) != 1 {
t.Fatalf("first scan = %v, want one project", changed)
}
logs.Reset()
if err := os.RemoveAll(project); err != nil {
t.Fatalf("remove project: %v", err)
}
if changed := scan(t, im); len(changed) != 0 {
t.Errorf("a departed project was treated as changed: %v", changed)
}
if !strings.Contains(logs.String(), "project left the workspace") {
t.Errorf("a departed project was not reported:\n%s", logs.String())
}
// Coming back is a change again rather than a silent no-op.
writeProject(t, project)
if changed := scan(t, im); !equal(changed, []string{"go-alpha"}) {
t.Errorf("scan after the project returned = %v, want [go-alpha]", changed)
}
}
func TestDiscoverHonoursAppsAndSkip(t *testing.T) {
t.Parallel()
root := t.TempDir()
for _, name := range []string{"go-alpha", "go-beta", "go-doc", "not-a-go-app"} {
writeProject(t, filepath.Join(root, name))
}
cases := []struct {
name string
opts options
want []string
}{
{name: "default skips go-doc", opts: options{root: root, skip: "go-doc"}, want: []string{"go-alpha", "go-beta"}},
{name: "-apps narrows the run", opts: options{root: root, skip: "", apps: "go-beta"}, want: []string{"go-beta"}},
{name: "no skip at all", opts: options{root: root}, want: []string{"go-alpha", "go-beta", "go-doc"}},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
im := newImporter(tc.opts, newEngine(t), discardLogger())
got, err := im.Discover()
if err != nil {
t.Fatalf("discover: %v", err)
}
if strings.Join(got, ",") != strings.Join(tc.want, ",") {
t.Errorf("discover = %v, want %v", got, tc.want)
}
})
}
}
func TestDiscoverReportsAMissingWorkspace(t *testing.T) {
t.Parallel()
im := newImporter(options{root: filepath.Join(t.TempDir(), "absent")}, newEngine(t), discardLogger())
if _, err := im.Discover(); err == nil {
t.Fatal("a missing workspace should be reported, not silently ignored")
}
}
func scan(t *testing.T, im *importer) []string {
t.Helper()
changed, err := im.Scan()
if err != nil {
t.Fatalf("scan: %v", err)
}
names := make([]string, 0, len(changed))
for name := range changed {
names = append(names, name)
}
sort.Strings(names)
return names
}
func equal(got, want []string) bool {
return strings.Join(got, ",") == strings.Join(want, ",")
}
func newEngine(t *testing.T) *storage.Engine {
t.Helper()
engine, err := storage.New(t.TempDir())
if err != nil {
t.Fatalf("provision engine: %v", err)
}
return engine
}
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelDebug}))
}
func writeProject(t *testing.T, root string) {
t.Helper()
if err := os.MkdirAll(filepath.Join(root, "internal"), 0o755); err != nil {
t.Fatalf("mkdir %s: %v", root, err)
}
files := map[string]string{
"go.mod": "module example.com/" + filepath.Base(root) + "\n\ngo 1.27.1\n",
"README.md": "# " + filepath.Base(root) + "\n\nIt serves an HTTP API.\n",
"internal/app.go": "package internal\n\n// Run starts the app.\nfunc Run() error { return nil }\n",
"internal/app_t.go": "placeholder\n",
}
for name, content := range files {
if err := os.WriteFile(filepath.Join(root, filepath.FromSlash(name)), []byte(content), 0o644); err != nil {
t.Fatalf("write %s: %v", name, err)
}
}
}

View file

@ -0,0 +1,300 @@
// Command seed_perf_data floods the go-doc flat-file store with generated
// documentation and feedback so the platform can be exercised under realistic
// load.
//
// go run ./scripts/seed_perf_data.go -data ../data -docs 120 -comments 25 -workers 8
//
// Every document is a syntactically valid Markdown page carrying all three
// audience sections, and every comment is a real JSON record with a real badge.
// The generator is deterministic for a given -seed so runs are reproducible.
package main
import (
"flag"
"fmt"
"log/slog"
"math/rand"
"os"
"path/filepath"
"strings"
"sync"
"sync/atomic"
"time"
"go-doc/storage"
)
func main() {
var (
dataDir = flag.String("data", storage.ResolveRoot(), "flat-file data directory")
docCount = flag.Int("docs", 120, "number of Markdown documents to generate (spec floor: 100)")
perDoc = flag.Int("comments", 25, "comments to inject per document")
workers = flag.Int("workers", 8, "parallel seeding workers")
seed = flag.Int64("seed", 20260101, "PRNG seed for reproducible output")
force = flag.Bool("force", false, "overwrite documents that already exist")
minBodyKB = flag.Int("min-body-kb", 2, "minimum document body size in KiB")
skipTemplate = flag.Bool("skip-go-doc", true, "leave data/docs/go-doc.md untouched")
)
flag.Parse()
logger := slog.New(slog.NewTextHandler(os.Stdout, &slog.HandlerOptions{Level: slog.LevelInfo}))
if *docCount < 100 && !*force {
logger.Warn("doc count is below the 100 document floor from the specification", "docs", *docCount)
}
engine, err := storage.New(*dataDir)
if err != nil {
logger.Error("cannot open the data directory", "error", err, "data", *dataDir)
os.Exit(1)
}
logger.Info("seeding flat-file store",
"data_root", engine.Root(),
"docs", *docCount,
"comments_per_doc", *perDoc,
"total_comments", *docCount**perDoc,
"workers", *workers,
)
jobs := make(chan int, *docCount)
for i := 0; i < *docCount; i++ {
jobs <- i
}
close(jobs)
var (
docsWritten atomic.Int64
docsSkipped atomic.Int64
commentsWritten atomic.Int64
bytesWritten atomic.Int64
wg sync.WaitGroup
failures atomic.Int64
)
start := time.Now()
for w := 0; w < *workers; w++ {
wg.Add(1)
go func(worker int) {
defer wg.Done()
// Each worker owns its own PRNG stream, which keeps output
// reproducible regardless of scheduling order.
rng := rand.New(rand.NewSource(*seed + int64(worker)*7919))
for index := range jobs {
title := documentTitle(index)
slug := storage.Slugify(title)
if *skipTemplate && slug == "go-doc" {
docsSkipped.Add(1)
continue
}
if !*force {
exists, err := engine.DocExists(slug)
if err != nil {
failures.Add(1)
logger.Error("existence check failed", "slug", slug, "error", err)
continue
}
if exists {
docsSkipped.Add(1)
continue
}
}
body := documentBody(title, slug, *minBodyKB, rng)
if _, err := engine.SaveDoc(slug, body); err != nil {
failures.Add(1)
logger.Error("document write failed", "slug", slug, "error", err)
continue
}
docsWritten.Add(1)
bytesWritten.Add(int64(len(body)))
for n := 0; n < *perDoc; n++ {
role := storage.Roles()[rng.Intn(len(storage.Roles()))]
comment := storage.CommentInput{
Author: authorName(rng, role),
Role: string(role),
Content: commentBody(role, slug, n, rng),
}
if _, err := engine.AppendComment(slug, comment); err != nil {
failures.Add(1)
logger.Error("comment write failed", "slug", slug, "error", err)
break
}
commentsWritten.Add(1)
}
}
}(w)
}
wg.Wait()
elapsed := time.Since(start)
metas, err := engine.ListDocs()
if err != nil {
logger.Error("index read failed", "error", err)
os.Exit(1)
}
totalComments, byRole := 0, map[string]int{}
for _, meta := range metas {
stats, err := engine.Stats(meta.Slug)
if err != nil {
continue
}
totalComments += stats.Total
for role, n := range stats.ByRole {
byRole[role] += n
}
}
rate := 0.0
if elapsed > 0 {
rate = float64(commentsWritten.Load()) / elapsed.Seconds()
}
logger.Info("seed complete",
"elapsed", elapsed.Round(time.Millisecond),
"documents_written", docsWritten.Load(),
"documents_skipped", docsSkipped.Load(),
"documents_indexed", len(metas),
"comments_written", commentsWritten.Load(),
"comments_indexed", totalComments,
"markdown_bytes", bytesWritten.Load(),
"comments_per_second", int(rate),
"by_role", byRole,
)
if failures.Load() > 0 {
logger.Error("seed completed with failures", "failures", failures.Load())
os.Exit(1)
}
fmt.Printf("\nSeeded %d documents and %d comments into %s\n",
docsWritten.Load(), commentsWritten.Load(), filepath.Clean(engine.Root()))
}
var serviceNouns = []string{
"Gateway", "Pipeline", "Registry", "Collector", "Scheduler", "Router",
"Indexer", "Notifier", "Bridge", "Vault", "Ledger", "Beacon",
"Annex", "Relay", "Forge", "Harbor", "Signal", "Archive",
"Compass", "Foundry", "Sentinel", "Orchid", "Quartz", "Meridian",
}
var serviceScopes = []string{
"Billing", "Identity", "Storage", "Messaging", "Search", "Payments",
"Analytics", "Provisioning", "Fulfilment", "Telemetry", "Consent", "Routing",
}
// documentTitle builds a stable, human title for the nth generated document.
func documentTitle(index int) string {
scope := serviceScopes[index%len(serviceScopes)]
noun := serviceNouns[(index/len(serviceScopes))%len(serviceNouns)]
return fmt.Sprintf("%s %s Service %03d", scope, noun, index)
}
func documentBody(title, slug string, minBodyKB int, rng *rand.Rand) string {
var b strings.Builder
fmt.Fprintf(&b, "# %s\n\n", title)
fmt.Fprintf(&b, "> Generated performance fixture for `%s`. Regenerate with `make seed`.\n\n", slug)
b.WriteString("This page isolates the same system across three audiences. Only the section that\n")
b.WriteString("matches the selected tab is rendered in the reader.\n\n")
b.WriteString("## For Users\n\n")
writeParagraphs(&b, rng, 6, minBodyKB/6+1, []string{
"The guided flow walks a new user from an empty workspace to a first successful run.",
"Every destructive action asks for confirmation and explains what is about to change.",
"Keyboard shortcuts mirror the visual toolbar so the workflow stays fast.",
})
b.WriteString("\n### Common tasks\n\n")
for _, task := range []string{"Create your first workspace", "Invite a teammate", "Export a report", "Reset your password"} {
fmt.Fprintf(&b, "- %s\n", task)
}
b.WriteString("\n")
b.WriteString("## For Operators\n\n")
writeParagraphs(&b, rng, 6, minBodyKB/6+1, []string{
"The deployment is idempotent: re-running the pipeline converges on the declared state.",
"Health checks probe the read path before the write path is allowed to serve traffic.",
"Rollback restores the previous artefact and replays the migration in reverse.",
})
b.WriteString("\n### Runbook\n\n")
b.WriteString("```bash\n# verify the release is healthy\ncurl -fsS https://localhost:8080/api/health\n\n# roll back to the previous artefact\nmake rollback RELEASE=previous\n```\n\n")
b.WriteString("| Signal | Threshold | Action |\n| --- | --- | --- |\n| p99 latency | 250 ms | Page the on-call |\n| error rate | 1 % | Halt the rollout |\n| queue depth | 1000 | Scale workers |\n\n")
b.WriteString("## For Developers\n\n")
writeParagraphs(&b, rng, 6, minBodyKB/6+1, []string{
"The API is versioned in the path; a breaking change ships under a new major prefix.",
"Every write is atomic: readers observe either the previous file or the new one, never a partial write.",
"The engine slugifies titles into lower-case, hyphen-separated identifiers.",
})
b.WriteString("\n### Endpoints\n\n")
b.WriteString("```http\nGET /api/docs\nGET /api/docs/{slug}\nPOST /api/docs/{slug}\nDELETE /api/docs/{slug}\nGET /api/docs/{slug}/comments\nPOST /api/docs/{slug}/comments\n```\n\n")
b.WriteString("### Integration notes\n\n")
fmt.Fprintf(&b, "Inline references such as `data/docs/%s.md` and **bold emphasis** must survive a round trip through the editor.\n", slug)
// Top up the body so every fixture is at least minBodyKB.
for b.Len() < minBodyKB*1024 {
b.WriteString("\nAdditional reference material for load testing. ")
fmt.Fprintf(&b, "Section %d of the generated body for `%s`. ", rng.Intn(1<<20), slug)
}
b.WriteString("\n")
return b.String()
}
func writeParagraphs(b *strings.Builder, rng *rand.Rand, count, targetLen int, seeds []string) {
for i := 0; i < count; i++ {
seed := seeds[i%len(seeds)]
fmt.Fprintf(b, "%s ", seed)
for len(b.String()) < targetLen*1024 && i == count-1 {
fmt.Fprintf(b, "Reference sentence %d covering the behaviour of `%s`. ", rng.Intn(1<<20), "service")
break
}
b.WriteString("\n\n")
}
}
var givenNames = []string{"ana", "bo", "chi", "dee", "eli", "fen", "gita", "hugo", "iris", "jo", "kit", "lena", "mo", "nia", "omar", "pia"}
func authorName(rng *rand.Rand, role storage.Role) string {
name := givenNames[rng.Intn(len(givenNames))]
suffix := map[storage.Role]string{
storage.RoleUser: " (user)",
storage.RoleOperator: " (ops)",
storage.RoleDeveloper: " (dev)",
}[role]
return name + suffix
}
var commentSeeds = map[storage.Role][]string{
storage.RoleUser: {
"The guided setup was easier to follow than the older docs.",
"I could not find the export button on the first screen.",
"Step 3 assumes a workspace that most new accounts do not have.",
"The keyboard shortcut list is genuinely useful.",
"Copy in the empty state does not match what actually happens.",
},
storage.RoleOperator: {
"Rollback took 20 minutes; the runbook should call that out.",
"Health checks pass while the write path is still degraded.",
"We need the p99 latency threshold surfaced on the dashboard.",
"The idempotent redeploy saved us during last week's incident.",
"Queue depth alerting fires too late to be actionable.",
},
storage.RoleDeveloper: {
"The retry budget is undocumented; please add the numbers.",
"A `--dry-run` flag would make this safe to run in CI.",
"The response schema changed without a version bump.",
"Slug collisions are possible when titles differ only by punctuation.",
"Atomic writes look correct, but the rename is not fsynced in CI images.",
},
}
func commentBody(role storage.Role, slug string, index int, rng *rand.Rand) string {
seeds := commentSeeds[role]
base := seeds[rng.Intn(len(seeds))]
return fmt.Sprintf("%s (finding %d on %s)", base, index+1, slug)
}

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

View file

@ -0,0 +1,295 @@
package bdd
import (
"strings"
"testing"
"go-doc/storage"
)
// TestFeatureSlugifiedMarkdownStorage covers:
//
// Given a new application title
// When it is saved
// Then it should be slugified and written to a Markdown file
func TestFeatureSlugifiedMarkdownStorage(t *testing.T) {
Run(t, Feature{
Name: "Slugified Markdown storage",
Scenarios: []Scenario{
{
Name: "a new application title is slugified and written to a Markdown file",
Given: Steps{
{Text: `a new application title "Core Service API"`, Do: func(c *Context) error {
return ExpectEqual(storage.Slugify("Core Service API"), "core-service-api", "slugified title")
}},
},
When: Steps{
{Text: "the title is saved", Do: func(c *Context) error {
_, err := c.Engine.SaveDoc("Core Service API", "# Core Service API\n\nHandles billing.\n")
return err
}},
},
Then: Steps{
{Text: "the engine reports the canonical slug", Do: func(c *Context) error {
meta, err := c.Engine.Stat("Core Service API")
if err != nil {
return err
}
return ExpectEqual(meta.Slug, "core-service-api", "canonical slug")
}},
{Text: "data/docs/core-service-api.md exists", Do: func(c *Context) error {
exists, err := c.FileExists(c.DocPath("core-service-api"))
if err != nil {
return err
}
if !exists {
return errf("expected %s to exist", c.DocPath("core-service-api"))
}
return nil
}},
{Text: "the Markdown content is stored verbatim", Do: func(c *Context) error {
content, err := c.RawDoc("core-service-api")
if err != nil {
return err
}
return ExpectContains(content, "# Core Service API", "stored markdown")
}},
{Text: "no file is created for the un-slugified title", Do: func(c *Context) error {
exists, err := c.FileExists(c.DocPath("Core Service API"))
if err != nil {
return err
}
if exists {
return errf("a non-slugified filename was created")
}
return nil
}},
},
},
{
Name: "titles with punctuation, casing and diacritics are normalised",
Given: Steps{
{Text: "a batch of awkward application titles", Do: func(c *Context) error {
cases := map[string]string{
" Core Service API ": "core-service-api",
"Deploy & Rollback": "deploy-rollback",
"Überwachen der Dienste": "uberwachen-der-dienste",
"API_v2 (Internal)": "api-v2-internal",
"!!!": "",
"Release 2024/Q1 -- Notes": "release-2024-q1-notes",
"User Facing / End Users": "user-facing-end-users",
"already-a-canonical-slug-1": "already-a-canonical-slug-1",
}
for input, want := range cases {
if got := storage.Slugify(input); got != want {
return errf("Slugify(%q) = %q, want %q", input, got, want)
}
}
return nil
}},
},
When: Steps{
{Text: "each title is saved", Do: func(c *Context) error {
for _, title := range []string{"Deploy & Rollback", "Überwachen der Dienste", "API_v2 (Internal)"} {
if _, err := c.Engine.SaveDoc(title, "# "+title+"\n"); err != nil {
return err
}
}
return nil
}},
},
Then: Steps{
{Text: "each slugified filename exists on disk", Do: func(c *Context) error {
for _, slug := range []string{"deploy-rollback", "uberwachen-der-dienste", "api-v2-internal"} {
exists, err := c.FileExists(c.DocPath(slug))
if err != nil {
return err
}
if !exists {
return errf("expected data/docs/%s.md to exist", slug)
}
}
return nil
}},
},
},
{
Name: "saving through the API slugifies the slug taken from the URL",
Given: Steps{
{Text: "the API is running against a temporary data directory", Do: func(c *Context) error {
return nil
}},
},
When: Steps{
{Text: `a document is POSTed to /api/docs/Core Service API`, Do: func(c *Context) error {
response, payload, err := c.Do(
"POST", "/api/docs/Core%20Service%20API", "text/markdown", "# Core Service API\n",
)
if err != nil {
return err
}
if err := ExpectStatus(response, 201); err != nil {
return errf("%w (body: %s)", err, payload)
}
saved, err := Decode[struct {
Slug string `json:"slug"`
}](payload)
if err != nil {
return err
}
c.Remember("slug", saved.Slug)
return nil
}},
},
Then: Steps{
{Text: "the response reports the slugified slug", Do: func(c *Context) error {
return ExpectEqual(c.RecallAs[string]("slug"), "core-service-api", "response slug")
}},
{Text: "the Markdown file is on disk", Do: func(c *Context) error {
exists, err := c.FileExists(c.DocPath("core-service-api"))
if err != nil {
return err
}
if !exists {
return errf("data/docs/core-service-api.md was not created")
}
return nil
}},
{Text: "GET /api/docs/core-service-api returns the raw Markdown", Do: func(c *Context) error {
response, payload, err := c.Do("GET", "/api/docs/core-service-api", "", "")
if err != nil {
return err
}
if err := ExpectStatus(response, 200); err != nil {
return err
}
if contentType := response.Header.Get("Content-Type"); !strings.HasPrefix(contentType, "text/markdown") {
return errf("expected text/markdown, got %q", contentType)
}
return ExpectContains(string(payload), "# Core Service API", "markdown response")
}},
},
},
{
Name: "saving an existing document replaces its content wholesale",
Given: Steps{
{Text: "an existing document with two sections", Do: func(c *Context) error {
_, err := c.Engine.SaveDoc("release-runbook", "# Runbook\n\n## Deploy\n\nstep\n\n## Rollback\n\nstep\n")
return err
}},
},
When: Steps{
{Text: "the document is rewritten with a single section", Do: func(c *Context) error {
response, _, err := c.Do("POST", "/api/docs/release-runbook", "text/markdown", "# Runbook v2\n\n## Deploy only\n")
if err != nil {
return err
}
return ExpectStatus(response, 200)
}},
},
Then: Steps{
{Text: "the previous sections are gone", Do: func(c *Context) error {
content, err := c.RawDoc("release-runbook")
if err != nil {
return err
}
if strings.Contains(content, "## Rollback") {
return errf("stale section survived the update: %q", content)
}
return ExpectContains(content, "## Deploy only", "updated markdown")
}},
},
},
{
Name: "deleting a document removes the file and its feedback",
Given: Steps{
{Text: "a document that already has comments", Do: func(c *Context) error {
if _, err := c.Engine.SaveDoc("obsolete-service", "# Obsolete\n"); err != nil {
return err
}
_, err := c.Engine.AppendComment("obsolete-service", storage.CommentInput{
Author: "ana",
Role: "user",
Content: "is this still needed?",
})
return err
}},
},
When: Steps{
{Text: "DELETE /api/docs/obsolete-service is called", Do: func(c *Context) error {
response, _, err := c.Do("DELETE", "/api/docs/obsolete-service", "", "")
if err != nil {
return err
}
return ExpectStatus(response, 204)
}},
},
Then: Steps{
{Text: "the Markdown file is gone", Do: func(c *Context) error {
exists, err := c.FileExists(c.DocPath("obsolete-service"))
if err != nil {
return err
}
if exists {
return errf("data/docs/obsolete-service.md still exists")
}
return nil
}},
{Text: "the comment file is gone", Do: func(c *Context) error {
exists, err := c.FileExists(c.CommentsPath("obsolete-service"))
if err != nil {
return err
}
if exists {
return errf("the feedback file was not cascaded")
}
return nil
}},
{Text: "reading the deleted document returns 404", Do: func(c *Context) error {
response, _, err := c.Do("GET", "/api/docs/obsolete-service", "", "")
if err != nil {
return err
}
return ExpectStatus(response, 404)
}},
},
},
{
Name: "path traversal in a slug is rejected",
Given: Steps{
{Text: "a request for a traversal slug", Do: func(c *Context) error {
return nil
}},
},
When: Steps{
{Text: `POST /api/docs/..%2F..%2Fetc%2Fpasswd is sent`, Do: func(c *Context) error {
response, payload, err := c.Do("POST", "/api/docs/..%2F..%2Fetc%2Fpasswd", "text/markdown", "nope")
if err != nil {
return err
}
c.Remember("status", response.StatusCode)
c.Remember("body", string(payload))
return nil
}},
},
Then: Steps{
{Text: "the request is rejected with 400", Do: func(c *Context) error {
return ExpectEqual(c.RecallAs[int]("status"), 400, "status code")
}},
{Text: "nothing is written outside data/docs", Do: func(c *Context) error {
for _, name := range []string{"etc", "passwd", ".."} {
exists, err := c.FileExists(c.DocPath(name))
if err != nil {
return err
}
if exists {
return errf("unexpected file created for slug %q", name)
}
}
return nil
}},
},
},
},
})
}

View file

@ -0,0 +1,360 @@
package bdd
import (
"strings"
"testing"
"go-doc/storage"
)
// TestFeatureBadgedCommentInjection covers:
//
// Given an existing documentation page
// When a developer adds a comment with a developer badge
// Then it should be appended to the JSON array and rendered with a red visual badge
func TestFeatureBadgedCommentInjection(t *testing.T) {
Run(t, Feature{
Name: "Badged comment injection",
Scenarios: []Scenario{
{
Name: "a developer comment is appended to the JSON array with a red badge",
Given: Steps{
{Text: "an existing documentation page for Core Service API", Do: func(c *Context) error {
_, err := c.Engine.SaveDoc("Core Service API", "# Core Service API\n\nBilling endpoints.\n")
return err
}},
},
When: Steps{
{Text: "a developer posts a comment through POST /api/docs/core-service-api/comments", Do: func(c *Context) error {
response, payload, err := c.Do(
"POST", "/api/docs/core-service-api/comments", "application/json",
`{"author":"ana","role":"Developer","content":"The retry budget is undocumented."}`,
)
if err != nil {
return err
}
if err := ExpectStatus(response, 201); err != nil {
return errf("%w (body: %s)", err, payload)
}
created, err := Decode[struct {
Comment storage.Comment `json:"comment"`
Badge string `json:"badge"`
}](payload)
if err != nil {
return err
}
c.Remember("comment", created.Comment)
c.Remember("badge", created.Badge)
return nil
}},
},
Then: Steps{
{Text: "the comment is stored with the developer role", Do: func(c *Context) error {
comment := c.RecallAs[storage.Comment]("comment")
return ExpectEqual(comment.Role, storage.RoleDeveloper, "stored role")
}},
{Text: "the comment carries the red 🔴 developer badge", Do: func(c *Context) error {
return ExpectEqual(c.RecallAs[string]("badge"), badgeContract[storage.RoleDeveloper], "response badge")
}},
{Text: "data/comments/core-service-api-comments.json is a JSON array containing it", Do: func(c *Context) error {
raw, err := c.RawJSON("core-service-api")
if err != nil {
return err
}
if !strings.HasPrefix(strings.TrimLeft(string(raw), " \n\t"), JSONArrayPrefix) {
return errf("feedback file is not a JSON array: %q", string(raw))
}
comments, err := c.RawComments("core-service-api")
if err != nil {
return err
}
if len(comments) != 1 {
return errf("expected 1 stored comment, got %d", len(comments))
}
stored := comments[0]
if stored.ID == "" {
return errf("stored comment has no ID")
}
if stored.Author != "ana" {
return errf("expected author ana, got %q", stored.Author)
}
if !strings.Contains(stored.Content, "retry budget") {
return errf("stored content mismatch: %q", stored.Content)
}
return ExpectEqual(stored.Badge, badgeContract[storage.RoleDeveloper], "stored badge")
}},
{Text: "the badge renders in the red developer colour #ef4444", Do: func(c *Context) error {
role := c.RecallAs[storage.Comment]("comment").Role
return ExpectEqual(role.Hex(), badgeHex[storage.RoleDeveloper], "badge colour")
}},
{Text: "GET /api/docs/core-service-api/comments returns the badged comment", Do: func(c *Context) error {
response, payload, err := c.Do("GET", "/api/docs/core-service-api/comments", "", "")
if err != nil {
return err
}
if err := ExpectStatus(response, 200); err != nil {
return err
}
listed, err := Decode[struct {
Comments []storage.Comment `json:"comments"`
Count int `json:"count"`
}](payload)
if err != nil {
return err
}
if listed.Count != 1 {
return errf("expected count 1, got %d", listed.Count)
}
return ExpectEqual(listed.Comments[0].Badge, "🔴", "listed badge")
}},
},
},
{
Name: "all three audience badges are applied to their own roles",
Given: Steps{
{Text: "a documentation page with an existing user comment", Do: func(c *Context) error {
if _, err := c.Engine.SaveDoc("deploy-pipeline", "# Deploy Pipeline\n"); err != nil {
return err
}
_, err := appendComment(c, "deploy-pipeline", storage.RoleUser, "sam", "The wizard is confusing")
return err
}},
},
When: Steps{
{Text: "an operator and a developer comment are added", Do: func(c *Context) error {
if _, err := appendComment(c, "deploy-pipeline", storage.RoleOperator, "robin", "Rollback takes 20 minutes"); err != nil {
return err
}
_, err := appendComment(c, "deploy-pipeline", storage.RoleDeveloper, "kit", "Add a --dry-run flag")
return err
}},
},
Then: Steps{
{Text: "the JSON array holds all three comments in insertion order", Do: func(c *Context) error {
comments, err := c.RawComments("deploy-pipeline")
if err != nil {
return err
}
if len(comments) != 3 {
return errf("expected 3 comments, got %d", len(comments))
}
want := []storage.Role{storage.RoleUser, storage.RoleOperator, storage.RoleDeveloper}
for i, role := range want {
if comments[i].Role != role {
return errf("comment %d: expected role %s, got %s", i, role, comments[i].Role)
}
if comments[i].Badge != badgeContract[role] {
return errf("comment %d: expected badge %s, got %s", i, badgeContract[role], comments[i].Badge)
}
}
return nil
}},
{Text: "each badge matches its role colour", Do: func(c *Context) error {
comments, err := c.RawComments("deploy-pipeline")
if err != nil {
return err
}
for _, comment := range comments {
if comment.Role.Hex() != badgeHex[comment.Role] {
return errf("unexpected colour for %s", comment.Role)
}
}
return nil
}},
{Text: "GET /api/docs/deploy-pipeline/stats aggregates by role", Do: func(c *Context) error {
response, payload, err := c.Do("GET", "/api/docs/deploy-pipeline/stats", "", "")
if err != nil {
return err
}
if err := ExpectStatus(response, 200); err != nil {
return err
}
stats, err := Decode[storage.CommentStats](payload)
if err != nil {
return err
}
if stats.Total != 3 {
return errf("expected total 3, got %d", stats.Total)
}
for _, role := range []string{"user", "operator", "developer"} {
if stats.ByRole[role] != 1 {
return errf("expected 1 %s comment, got %d", role, stats.ByRole[role])
}
}
return nil
}},
},
},
{
Name: "the badge is derived server-side and cannot be spoofed by the client",
Given: Steps{
{Text: "a documentation page", Do: func(c *Context) error {
_, err := c.Engine.SaveDoc("trust-boundary", "# Trust Boundary\n")
return err
}},
},
When: Steps{
{Text: `a developer posts a comment claiming the user badge "🟢"`, Do: func(c *Context) error {
response, payload, err := c.Do(
"POST", "/api/docs/trust-boundary/comments", "application/json",
`{"author":"mallory","role":"developer","content":"I am a user, honest","badge":"🟢"}`,
)
if err != nil {
return err
}
if err := ExpectStatus(response, 201); err != nil {
return errf("%w (body: %s)", err, payload)
}
c.Remember("payload", string(payload))
return nil
}},
},
Then: Steps{
{Text: "the server overrides the claim with the developer badge", Do: func(c *Context) error {
comments, err := c.RawComments("trust-boundary")
if err != nil {
return err
}
if len(comments) != 1 {
return errf("expected 1 comment, got %d", len(comments))
}
return ExpectEqual(comments[0].Badge, "🔴", "server-assigned badge")
}},
},
},
{
Name: "an unknown role is rejected with a 400",
Given: Steps{
{Text: "a documentation page", Do: func(c *Context) error {
_, err := c.Engine.SaveDoc("role-guard", "# Role Guard\n")
return err
}},
},
When: Steps{
{Text: `a comment is posted with role "root"`, Do: func(c *Context) error {
response, payload, err := c.Do(
"POST", "/api/docs/role-guard/comments", "application/json",
`{"author":"mallory","role":"root","content":"let me in"}`,
)
if err != nil {
return err
}
c.Remember("status", response.StatusCode)
c.Remember("body", string(payload))
return nil
}},
},
Then: Steps{
{Text: "the API answers 400 with the invalid_role code", Do: func(c *Context) error {
if err := ExpectEqual(c.RecallAs[int]("status"), 400, "status"); err != nil {
return err
}
return ExpectContains(c.RecallAs[string]("body"), "invalid_role", "error body")
}},
{Text: "no feedback file is created", Do: func(c *Context) error {
exists, err := c.FileExists(c.CommentsPath("role-guard"))
if err != nil {
return err
}
if exists {
return errf("a feedback file was created for a rejected comment")
}
return nil
}},
},
},
{
Name: "an empty comment is rejected",
Given: Steps{
{Text: "a documentation page", Do: func(c *Context) error {
_, err := c.Engine.SaveDoc("empty-guard", "# Empty Guard\n")
return err
}},
},
When: Steps{
{Text: `a comment with blank content is posted`, Do: func(c *Context) error {
response, _, err := c.Do(
"POST", "/api/docs/empty-guard/comments", "application/json",
`{"author":"ana","role":"operator","content":" "}`,
)
if err != nil {
return err
}
c.Remember("status", response.StatusCode)
return nil
}},
},
Then: Steps{
{Text: "the API answers 400", Do: func(c *Context) error {
return ExpectEqual(c.RecallAs[int]("status"), 400, "status")
}},
{Text: "no feedback file is created", Do: func(c *Context) error {
exists, err := c.FileExists(c.CommentsPath("empty-guard"))
if err != nil {
return err
}
if exists {
return errf("a feedback file was created for a rejected comment")
}
return nil
}},
},
},
{
Name: "a comment on an unknown slug still creates the feedback file",
Given: Steps{
{Text: "no document exists for the slug new-service", Do: func(c *Context) error {
exists, err := c.FileExists(c.DocPath("new-service"))
if err != nil {
return err
}
if exists {
return errf("the document should not exist yet")
}
return nil
}},
},
When: Steps{
{Text: "the first user comment is posted for new-service", Do: func(c *Context) error {
response, _, err := c.Do(
"POST", "/api/docs/new-service/comments", "application/json",
`{"author":"jo","role":"user","content":"Is this documented?"}`,
)
if err != nil {
return err
}
return ExpectStatus(response, 201)
}},
},
Then: Steps{
{Text: "the feedback file holds the user badge 🟢", Do: func(c *Context) error {
comments, err := c.RawComments("new-service")
if err != nil {
return err
}
if len(comments) != 1 {
return errf("expected 1 comment, got %d", len(comments))
}
return ExpectEqual(comments[0].Badge, "🟢", "badge")
}},
{Text: "reading the comments of a slug with no feedback returns an empty list", Do: func(c *Context) error {
response, payload, err := c.Do("GET", "/api/docs/never-seen/comments", "", "")
if err != nil {
return err
}
if err := ExpectStatus(response, 200); err != nil {
return err
}
listed, err := Decode[struct {
Comments []storage.Comment `json:"comments"`
}](payload)
if err != nil {
return err
}
return ExpectEqual(len(listed.Comments), 0, "comment count")
}},
},
},
},
})
}

View file

@ -0,0 +1,285 @@
// Package bdd is a dependency-free BDD harness for the go-doc backend.
//
// Features are declared as Gherkin-shaped Given/When/Then scenarios and executed
// against the real storage engine and a real HTTP server backed by a temporary
// directory on disk. There are no mocks and no fakes: every Then step asserts on
// bytes that were actually written to the filesystem.
package bdd
import (
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"go-doc/router"
"go-doc/storage"
)
// Step is a single Gherkin clause. Text is used verbatim as the subtest name, so
// a failing run reads as a sentence.
type Step struct {
Text string
Do func(c *Context) error
}
func (s Step) run(c *Context) error {
if err := s.Do(c); err != nil {
return fmt.Errorf("%s: %w", s.Text, err)
}
return nil
}
// Steps is an ordered clause list.
type Steps []Step
func (s Steps) run(c *Context) error {
for _, step := range s {
if err := step.run(c); err != nil {
return err
}
}
return nil
}
// Scenario is one executable example.
type Scenario struct {
Name string
Given Steps
When Steps
Then Steps
}
// Feature groups the scenarios for one business capability.
type Feature struct {
Name string
Scenarios []Scenario
}
// Context carries the world a scenario runs against: a private data directory, a
// storage engine, and a live HTTP server. Steps share state through Remember and
// Recall so a When clause can hand data to a Then clause.
type Context struct {
T *testing.T
DataDir string
Engine *storage.Engine
Server *httptest.Server
Client *http.Client
// StartedAt timestamps scenario creation so load steps can assert a budget.
StartedAt time.Time
mu sync.Mutex
state map[string]any
}
// NewContext provisions an isolated data directory and a live API server.
func NewContext(t *testing.T) *Context {
t.Helper()
dataDir := t.TempDir()
engine, err := storage.New(dataDir)
if err != nil {
t.Fatalf("bdd: provision storage engine: %v", err)
}
handler, err := router.NewWithConfig(router.Config{
Engine: engine,
Logger: quietLogger(),
})
if err != nil {
t.Fatalf("bdd: build router: %v", err)
}
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
return &Context{
T: t,
StartedAt: time.Now(),
DataDir: dataDir,
Engine: engine,
Server: server,
Client: server.Client(),
state: make(map[string]any),
}
}
// Remember stores a value for later steps in the same scenario.
func (c *Context) Remember(key string, value any) {
c.mu.Lock()
defer c.mu.Unlock()
c.state[key] = value
}
// Recall reads a remembered value, failing the step if it is absent or nil.
func (c *Context) Recall(key string) any {
c.mu.Lock()
defer c.mu.Unlock()
value, ok := c.state[key]
if !ok || value == nil {
c.T.Fatalf("bdd: scenario state has no value for %q", key)
}
return value
}
// RecallAs is Recall with a type assertion.
func (c *Context) RecallAs[T any](key string) T {
value, ok := c.Recall(key).(T)
if !ok {
c.T.Fatalf("bdd: state %q is %T, not the requested type", key, c.Recall(key))
}
return value
}
// DocPath is the on-disk location a slug must land on.
func (c *Context) DocPath(slug string) string {
return filepath.Join(c.DataDir, "docs", slug+".md")
}
// CommentsPath is the on-disk location a slug's feedback must land on.
func (c *Context) CommentsPath(slug string) string {
return filepath.Join(c.DataDir, "comments", slug+"-comments.json")
}
// RawDoc reads the Markdown file straight off disk, bypassing the API.
func (c *Context) RawDoc(slug string) (string, error) {
data, err := os.ReadFile(c.DocPath(slug))
return string(data), err
}
// RawComments decodes the JSON array straight off disk, bypassing the API.
func (c *Context) RawComments(slug string) ([]storage.Comment, error) {
data, err := os.ReadFile(c.CommentsPath(slug))
if err != nil {
return nil, err
}
var comments []storage.Comment
if err := json.Unmarshal(data, &comments); err != nil {
return nil, fmt.Errorf("feedback file is not a valid JSON array: %w", err)
}
return comments, nil
}
// RawJSON returns the undecoded bytes of the feedback file.
func (c *Context) RawJSON(slug string) ([]byte, error) {
return os.ReadFile(c.CommentsPath(slug))
}
// Do performs an HTTP request against the live server and returns the response
// together with its body.
func (c *Context) Do(method, path, contentType string, body string) (*http.Response, []byte, error) {
var reader io.Reader
if body != "" {
reader = strings.NewReader(body)
}
request, err := http.NewRequest(method, c.Server.URL+path, reader)
if err != nil {
return nil, nil, err
}
if contentType != "" {
request.Header.Set("Content-Type", contentType)
}
response, err := c.Client.Do(request)
if err != nil {
return nil, nil, err
}
defer response.Body.Close()
payload, err := io.ReadAll(response.Body)
if err != nil {
return response, nil, err
}
return response, payload, nil
}
// Decode unmarshals a JSON response body.
func Decode[T any](payload []byte) (T, error) {
var out T
err := json.Unmarshal(payload, &out)
return out, err
}
// ExpectStatus fails the step unless the response carries the wanted status.
func ExpectStatus(response *http.Response, want int) error {
if response == nil {
return fmt.Errorf("no response")
}
if response.StatusCode != want {
return fmt.Errorf("expected HTTP %d, got %d", want, response.StatusCode)
}
return nil
}
// ExpectContains fails the step unless haystack contains needle.
func ExpectContains(haystack, needle, label string) error {
if !strings.Contains(haystack, needle) {
return fmt.Errorf("%s: expected to contain %q, got %q", label, needle, haystack)
}
return nil
}
// ExpectEqual fails the step unless got equals want.
func ExpectEqual[T comparable](got, want T, label string) error {
if got != want {
return fmt.Errorf("%s: expected %v, got %v", label, want, got)
}
return nil
}
// FileExists reports whether path is present on disk.
func (c *Context) FileExists(path string) (bool, error) {
_, err := os.Stat(path)
if err == nil {
return true, nil
}
if os.IsNotExist(err) {
return false, nil
}
return false, err
}
// Run executes a feature: every scenario gets a fresh Context, and every clause
// becomes its own subtest so failures point at the exact sentence that broke.
func Run(t *testing.T, feature Feature) {
t.Helper()
t.Run(feature.Name, func(t *testing.T) {
for _, scenario := range feature.Scenarios {
scenario := scenario
t.Run(scenario.Name, func(t *testing.T) {
c := NewContext(t)
phase(t, "Given", c, scenario.Given)
phase(t, "When", c, scenario.When)
phase(t, "Then", c, scenario.Then)
})
}
})
}
func phase(t *testing.T, keyword string, c *Context, steps Steps) {
t.Helper()
for _, step := range steps {
t.Run(keyword+" "+step.Text, func(t *testing.T) {
if err := step.Do(c); err != nil {
t.Fatalf("%s %s\n%s", keyword, step.Text, indent(err.Error()))
}
})
}
}
func indent(text string) string {
lines := strings.Split(strings.TrimRight(text, "\n"), "\n")
for i, line := range lines {
lines[i] = " " + line
}
return strings.Join(lines, "\n")
}

View file

@ -0,0 +1,61 @@
package bdd
import (
"fmt"
"os"
"go-doc/storage"
)
func errf(format string, args ...any) error {
return fmt.Errorf(format, args...)
}
// Badge assertions shared by the feedback features, keyed to the role -> badge
// contract mandated by the specification: User 🟢, Operator 🔵, Developer 🔴.
var badgeContract = map[storage.Role]string{
storage.RoleUser: "🟢",
storage.RoleOperator: "🔵",
storage.RoleDeveloper: "🔴",
}
// Hex colours the frontend mirrors when it renders the badge.
var badgeHex = map[storage.Role]string{
storage.RoleUser: "#22c55e",
storage.RoleOperator: "#3b82f6",
storage.RoleDeveloper: "#ef4444",
}
// JSONArrayPrefix is the first byte of the on-disk feedback file. The spec
// requires the file to *be* the JSON array, so every write starts with "[".
const JSONArrayPrefix = `[`
func appendComment(c *Context, slug string, role storage.Role, author, content string) (storage.Comment, error) {
return c.Engine.AppendComment(slug, storage.CommentInput{
Author: author,
Role: string(role),
Content: content,
})
}
// listDir returns the entry names of a directory, for temp-file leak checks.
func listDir(dir string) ([]string, error) {
entries, err := os.ReadDir(dir)
if err != nil {
return nil, err
}
names := make([]string, 0, len(entries))
for _, entry := range entries {
names = append(names, entry.Name())
}
return names, nil
}
// truncate shortens a document for readable failure output.
func truncate(text string) string {
const limit = 120
if len(text) <= limit {
return text
}
return text[:limit] + "…"
}

View file

@ -0,0 +1,417 @@
package bdd
import (
"encoding/json"
"fmt"
"strings"
"sync"
"testing"
"time"
"go-doc/storage"
)
// TestFeatureFileUpdatesUnderLoad covers:
//
// Given many documents and thousands of comments in flight
// When they are written concurrently through the API
// Then every update lands on disk with no torn or lost writes
func TestFeatureFileUpdatesUnderLoad(t *testing.T) {
Run(t, Feature{
Name: "File updates under load",
Scenarios: []Scenario{
{
Name: "concurrent comment injection loses no entry",
Given: Steps{
{Text: "a documentation page with 400 concurrent feedback writers", Do: func(c *Context) error {
_, err := c.Engine.SaveDoc("hot-service", "# Hot Service\n\n## For Operators\n\nScaling notes.\n")
return err
}},
},
When: Steps{
{Text: "400 comments are appended concurrently across all three roles", Do: func(c *Context) error {
const writers = 400
roles := []storage.Role{storage.RoleUser, storage.RoleOperator, storage.RoleDeveloper}
var wg sync.WaitGroup
errs := make(chan error, writers)
start := make(chan struct{})
for i := 0; i < writers; i++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
<-start // release every writer at once
role := roles[index%len(roles)]
if _, err := appendComment(c, "hot-service", role,
fmt.Sprintf("user-%03d", index),
fmt.Sprintf("finding number %d", index),
); err != nil {
errs <- err
}
}(i)
}
close(start)
wg.Wait()
close(errs)
for err := range errs {
return err
}
return nil
}},
},
Then: Steps{
{Text: "all 400 comments are present in the JSON array", Do: func(c *Context) error {
comments, err := c.RawComments("hot-service")
if err != nil {
return err
}
if len(comments) != 400 {
return errf("expected 400 persisted comments, got %d", len(comments))
}
return nil
}},
{Text: "no comment was lost to a read-modify-write race", Do: func(c *Context) error {
comments, err := c.RawComments("hot-service")
if err != nil {
return err
}
seen := make(map[string]struct{}, len(comments))
for _, comment := range comments {
key := fmt.Sprintf("%s|%s", comment.Author, comment.Content)
if _, duplicate := seen[key]; duplicate {
return errf("duplicate entry survived: %s", key)
}
seen[key] = struct{}{}
}
if len(seen) != 400 {
return errf("expected 400 distinct entries, got %d", len(seen))
}
return nil
}},
{Text: "every comment carries the badge for its own role", Do: func(c *Context) error {
comments, err := c.RawComments("hot-service")
if err != nil {
return err
}
for _, comment := range comments {
if comment.Badge != badgeContract[comment.Role] {
return errf("comment %s has badge %q, want %q",
comment.ID, comment.Badge, badgeContract[comment.Role])
}
}
return nil
}},
{Text: "the file on disk is a single valid JSON array, never a torn write", Do: func(c *Context) error {
raw, err := c.RawJSON("hot-service")
if err != nil {
return err
}
if !strings.HasPrefix(strings.TrimSpace(string(raw)), "[") ||
!strings.HasSuffix(strings.TrimSpace(string(raw)), "]") {
return errf("feedback file is not a well-formed JSON array")
}
var decoded []storage.Comment
if err := json.Unmarshal(raw, &decoded); err != nil {
return errf("feedback file failed to decode: %w", err)
}
return ExpectEqual(len(decoded), 400, "decoded comment count")
}},
{Text: "the HTTP read path agrees with the file on disk", Do: func(c *Context) error {
response, payload, err := c.Do("GET", "/api/docs/hot-service/comments", "", "")
if err != nil {
return err
}
if err := ExpectStatus(response, 200); err != nil {
return err
}
listed, err := Decode[struct {
Count int `json:"count"`
}](payload)
if err != nil {
return err
}
return ExpectEqual(listed.Count, 400, "API comment count")
}},
},
},
{
Name: "readers never observe a partially written document while writers stream updates",
Given: Steps{
{Text: "a document with a long body", Do: func(c *Context) error {
body := strings.Repeat("Sentinel paragraph for torn-read detection.\n\n", 400)
_, err := c.Engine.SaveDoc("live-doc", "# Live Doc\n\n"+body)
return err
}},
},
When: Steps{
{Text: "20 writers and 40 readers hammer the API concurrently", Do: func(c *Context) error {
const (
writers = 20
readers = 40
rounds = 25
)
var wg sync.WaitGroup
errs := make(chan error, writers+readers)
for w := 0; w < writers; w++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
for round := 0; round < rounds; round++ {
body := strings.Repeat(
fmt.Sprintf("writer %d round %d payload line.\n\n", index, round), 200)
if _, err := c.Engine.SaveDoc("live-doc", "# Live Doc\n\n"+body); err != nil {
errs <- err
return
}
}
}(w)
}
for r := 0; r < readers; r++ {
wg.Add(1)
go func() {
defer wg.Done()
for round := 0; round < rounds*4; round++ {
content, err := c.Engine.ReadDoc("live-doc")
if err != nil {
errs <- err
return
}
if !strings.HasPrefix(content, "# Live Doc\n\n") {
errs <- errf("reader saw a truncated document header: %q", truncate(content))
return
}
}
}()
}
wg.Wait()
close(errs)
for err := range errs {
return err
}
return nil
}},
},
Then: Steps{
{Text: "every read returned a complete document", Do: func(c *Context) error {
content, err := c.RawDoc("live-doc")
if err != nil {
return err
}
if !strings.HasPrefix(content, "# Live Doc\n\n") {
return errf("final document is malformed")
}
return nil
}},
{Text: "no temporary file was left behind in data/docs", Do: func(c *Context) error {
entries, err := listDir(c.DataDir + "/docs")
if err != nil {
return err
}
for _, name := range entries {
if strings.HasPrefix(name, ".godoc-tmp-") {
return errf("atomic-write temp file leaked: %s", name)
}
}
return nil
}},
},
},
{
Name: "many documents can be seeded in parallel without cross-contamination",
Given: Steps{
{Text: "120 documents seeded across 8 workers with 25 comments each", Do: func(c *Context) error {
const (
docs = 120
perDoc = 25
numWorkers = 8
)
var wg sync.WaitGroup
errs := make(chan error, numWorkers)
jobs := make(chan int, docs)
for i := 0; i < docs; i++ {
jobs <- i
}
close(jobs)
for w := 0; w < numWorkers; w++ {
wg.Add(1)
go func(worker int) {
defer wg.Done()
roles := []storage.Role{storage.RoleUser, storage.RoleOperator, storage.RoleDeveloper}
for index := range jobs {
title := fmt.Sprintf("Service Suite %03d Overview", index)
slug := storage.Slugify(title)
body := fmt.Sprintf("# %s\n\n## For Users\n\nUsage.\n\n## For Operators\n\nRunbook.\n\n## For Developers\n\nAPI.\n",
title)
if _, err := c.Engine.SaveDoc(title, body); err != nil {
errs <- err
return
}
for n := 0; n < perDoc; n++ {
role := roles[n%len(roles)]
if _, err := c.Engine.AppendComment(slug, storage.CommentInput{
Author: fmt.Sprintf("w%d-u%d", worker, n),
Role: string(role),
Content: fmt.Sprintf("note %d for %s", n, slug),
}); err != nil {
errs <- err
return
}
}
}
}(w)
}
wg.Wait()
close(errs)
for err := range errs {
return err
}
return nil
}},
},
When: Steps{
{Text: "the document index is read back", Do: func(c *Context) error {
return nil
}},
},
Then: Steps{
{Text: "all 120 slugified documents are indexed", Do: func(c *Context) error {
metas, err := c.Engine.ListDocs()
if err != nil {
return err
}
return ExpectEqual(len(metas), 120, "indexed document count")
}},
{Text: "each document owns exactly its own 25 comments", Do: func(c *Context) error {
for index := 0; index < 120; index++ {
slug := storage.Slugify(fmt.Sprintf("Service Suite %03d Overview", index))
comments, err := c.RawComments(slug)
if err != nil {
return err
}
if len(comments) != 25 {
return errf("%s: expected 25 comments, got %d", slug, len(comments))
}
for _, comment := range comments {
if !strings.Contains(comment.Content, slug) {
return errf("%s: comment from another document leaked in: %q", slug, comment.Content)
}
}
}
return nil
}},
{Text: "the aggregate stats total 3000 comments", Do: func(c *Context) error {
response, payload, err := c.Do("GET", "/api/stats", "", "")
if err != nil {
return err
}
if err := ExpectStatus(response, 200); err != nil {
return err
}
stats, err := Decode[struct {
Docs int `json:"docs"`
Comments int `json:"comments"`
}](payload)
if err != nil {
return err
}
if stats.Docs != 120 {
return errf("expected 120 docs, got %d", stats.Docs)
}
return ExpectEqual(stats.Comments, 3000, "aggregate comment count")
}},
},
},
{
Name: "a burst of HTTP comment posts is durable",
Given: Steps{
{Text: "a documentation page served over HTTP", Do: func(c *Context) error {
response, _, err := c.Do("POST", "/api/docs/http-burst", "text/markdown", "# HTTP Burst\n")
if err != nil {
return err
}
return ExpectStatus(response, 201)
}},
},
When: Steps{
{Text: "200 concurrent POSTs hit /api/docs/http-burst/comments", Do: func(c *Context) error {
const posts = 200
var wg sync.WaitGroup
errs := make(chan error, posts)
for i := 0; i < posts; i++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
role := []string{"user", "operator", "developer"}[index%3]
body := fmt.Sprintf(`{"author":"burst-%03d","role":"%s","content":"burst comment %d"}`, index, role, index)
response, payload, err := c.Do("POST", "/api/docs/http-burst/comments", "application/json", body)
if err != nil {
errs <- err
return
}
if response.StatusCode != 201 {
errs <- errf("POST %d returned %d: %s", index, response.StatusCode, payload)
}
}(i)
}
wg.Wait()
close(errs)
for err := range errs {
return err
}
return nil
}},
},
Then: Steps{
{Text: "the feedback file holds all 200 posts", Do: func(c *Context) error {
comments, err := c.RawComments("http-burst")
if err != nil {
return err
}
if len(comments) != 200 {
return errf("expected 200 persisted comments, got %d", len(comments))
}
return nil
}},
{Text: "every comment has a unique ID and a role badge", Do: func(c *Context) error {
comments, err := c.RawComments("http-burst")
if err != nil {
return err
}
ids := make(map[string]struct{}, len(comments))
for _, comment := range comments {
if _, duplicate := ids[comment.ID]; duplicate {
return errf("duplicate comment ID %s", comment.ID)
}
ids[comment.ID] = struct{}{}
if comment.Badge == "" {
return errf("comment %s has no badge", comment.ID)
}
if comment.Timestamp.IsZero() {
return errf("comment %s has no timestamp", comment.ID)
}
}
return nil
}},
{Text: "the write completes well inside a test timeout", Do: func(c *Context) error {
// Guards against a regression to unbounded fsync storms: the whole
// scenario must finish inside the 10 minute suite budget.
if elapsed := time.Since(c.StartedAt); elapsed > 2*time.Minute {
return errf("burst scenario took %s", elapsed)
}
return nil
}},
},
},
},
})
}

View file

@ -0,0 +1,12 @@
package bdd
import (
"io"
"log/slog"
)
// quietLogger keeps the BDD output readable: the harness reports failures itself,
// so the router's per-request access log is discarded.
func quietLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
}

View file

@ -0,0 +1,220 @@
// This file holds the end-to-end suite for the documentation importer: analyse a
// real project tree on disk, render a page, merge it through the storage engine,
// and read it back over HTTP. No mocks anywhere -- if the pipeline breaks, these
// tests break.
package tests
import (
"net/http"
"os"
"path/filepath"
"strings"
"testing"
"go-doc/docgen"
)
// getJSON fetches a resource as JSON, which is how the frontend reads pages.
// The API negotiates with ?format=json rather than the Accept header.
func (h *harness) getJSON(path string) map[string]any {
h.t.Helper()
if !strings.Contains(path, "?") {
path += "?format=json"
}
payload := h.expectStatus(http.MethodGet, path, "", "", http.StatusOK)
return decodeJSON[map[string]any](h.t, payload)
}
func TestIntegrationDocgenImport(t *testing.T) {
t.Parallel()
h := newHarness(t)
workspace := t.TempDir()
writeFixtureProject(t, filepath.Join(workspace, "go-fixture"))
project, err := docgen.Analyze("go-fixture", filepath.Join(workspace, "go-fixture"))
if err != nil {
t.Fatalf("integration: analyse fixture: %v", err)
}
page, err := docgen.Render(project)
if err != nil {
t.Fatalf("integration: render fixture: %v", err)
}
slug, err := h.engine.SaveDoc(project.Name, page)
if err != nil {
t.Fatalf("integration: import page: %v", err)
}
if slug != "go-fixture" {
t.Fatalf("integration: expected slug go-fixture, got %q", slug)
}
// The page lands on disk exactly once, wrapped in generated markers that
// record which project it came from.
onDisk := string(requireFile(t, filepath.Join(h.docsDir, "go-fixture.md")))
if !strings.HasPrefix(onDisk, "<!-- go-doc:generated:begin") {
t.Errorf("imported page is missing its opening marker:\n%s", firstLineOf(onDisk))
}
if !strings.Contains(onDisk, "source=go-fixture") {
t.Errorf("imported page does not record its source project:\n%s", firstLineOf(onDisk))
}
if strings.Count(onDisk, "go-doc:generated:end") != 1 {
t.Errorf("imported page has %d closing markers, want 1",
strings.Count(onDisk, "go-doc:generated:end"))
}
// The API serves it under the title the index shows.
doc := h.getJSON("/api/docs/go-fixture")
if doc["title"] != "go-fixture" {
t.Errorf("title = %v, want go-fixture", doc["title"])
}
content, _ := doc["content"].(string)
if content != onDisk {
t.Error("API content differs from the file on disk")
}
// Every audience tab the reader keys off is present, in order.
users := strings.Index(content, "## For Users")
operators := strings.Index(content, "## For Operators")
developers := strings.Index(content, "## For Developers")
if users < 0 || operators < 0 || developers < 0 {
t.Fatalf("audience sections missing: %d %d %d", users, operators, developers)
}
if !(users < operators && operators < developers) {
t.Errorf("audience sections are out of order: %d %d %d", users, operators, developers)
}
// Facts extracted from the source reached the page.
for _, want := range []string{"| `GET` | `/healthz` | `health` |", "`DEMO_HTTP_PORT`", "`make build`"} {
if !strings.Contains(content, want) {
t.Errorf("imported page is missing extracted fact %q", want)
}
}
// It appears in the index.
index := h.getJSON("/api/docs")
docs, _ := index["docs"].([]any)
if len(docs) != 1 {
t.Fatalf("expected 1 indexed document, got %d", len(docs))
}
entry, _ := docs[0].(map[string]any)
if entry["slug"] != "go-fixture" {
t.Errorf("index slug = %v, want go-fixture", entry["slug"])
}
// A comment attaches to a generated page like any other document.
h.expectStatus(http.MethodPost, "/api/docs/go-fixture/comments",
"application/json",
`{"role":"operator","author":"sre","content":"The port table needs the compose port too."}`,
http.StatusCreated)
stats := h.getJSON("/api/docs/go-fixture/stats")
if stats["total"] != float64(1) {
t.Errorf("comment count = %v, want 1", stats["total"])
}
// Re-importing identical content changes nothing, and prose a human added
// outside the markers survives the sync.
pagePath := filepath.Join(h.docsDir, "go-fixture.md")
withFooter := onDisk + "\n## Notes from the team\n\nCheck the runbook before deploying.\n"
if err := os.WriteFile(pagePath, []byte(withFooter), 0o644); err != nil {
t.Fatalf("integration: write page: %v", err)
}
merged, status, err := docgen.Merge(withFooter, page, false)
if err != nil {
t.Fatalf("integration: re-merge: %v", err)
}
if status != docgen.StatusUnchanged {
t.Errorf("re-importing an unchanged project reported %q, want unchanged", status)
}
if _, err := h.engine.SaveDoc(project.Name, merged); err != nil {
t.Fatalf("integration: re-import: %v", err)
}
if reread := string(requireFile(t, pagePath)); !strings.Contains(reread, "Check the runbook before deploying.") {
t.Error("re-import dropped hand-written prose")
}
}
// TestIntegrationDocgenProtectsHandWrittenPages proves the importer never
// overwrites a document that it did not generate.
func TestIntegrationDocgenProtectsHandWrittenPages(t *testing.T) {
t.Parallel()
h := newHarness(t)
const handWritten = "# go-doc\n\nWritten by a person, with no generated markers.\n"
if _, err := h.engine.SaveDoc("go-doc", handWritten); err != nil {
t.Fatalf("integration: seed hand-written page: %v", err)
}
generated, err := docgen.Render(&docgen.Project{Name: "go-doc", Summary: "Machine written."})
if err != nil {
t.Fatalf("integration: render: %v", err)
}
pagePath := filepath.Join(h.docsDir, "go-doc.md")
existing := string(requireFile(t, pagePath))
merged, status, err := docgen.Merge(existing, generated, false)
if err != nil {
t.Fatalf("integration: merge: %v", err)
}
if status != docgen.StatusSkipped {
t.Errorf("merge status = %q, want skipped for an unmarked page", status)
}
if merged != existing {
t.Error("a hand-written page was modified by the importer")
}
if _, err := h.engine.SaveDoc("go-doc", merged); err != nil {
t.Fatalf("integration: save: %v", err)
}
if reread := string(requireFile(t, pagePath)); reread != handWritten {
t.Errorf("hand-written page changed on disk:\n%s", reread)
}
}
// TestIntegrationDocgenMissingProjectIsReportedNotIgnored covers the case the
// importer must never paper over: a project directory that cannot be analysed.
func TestIntegrationDocgenMissingProjectIsReportedNotIgnored(t *testing.T) {
t.Parallel()
workspace := t.TempDir()
if _, err := docgen.Analyze("go-absent", filepath.Join(workspace, "go-absent")); err == nil {
t.Fatal("analysing a missing project should fail loudly")
}
}
// writeFixtureProject lays out a small project exercising the extractors the
// importer depends on: routes, flags, environment, a module, a Makefile and a
// README.
func writeFixtureProject(t *testing.T, root string) {
t.Helper()
files := map[string]string{
"README.md": "# Demo Service\n\nDemo answers questions and exposes a JSON API.\n",
"go.mod": "module github.com/danyel/demo\n\ngo 1.27.1\n\nrequire github.com/go-chi/chi/v5 v5.2.3\n",
"Makefile": "build:\n\tgo build ./...\n",
".env.example": "DEMO_HTTP_PORT=8080\n",
"internal/server/routes.go": "package server\n\n" +
"import \"net/http\"\n\n" +
"func Routes(mux *http.ServeMux) {\n" +
"\tmux.HandleFunc(\"GET /healthz\", health)\n" +
"\tmux.HandleFunc(\"/api/items\", listItems)\n}\n",
}
for name, content := range files {
path := filepath.Join(root, filepath.FromSlash(name))
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatalf("integration: mkdir %s: %v", path, err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatalf("integration: write %s: %v", path, err)
}
}
}
func firstLineOf(s string) string {
if i := strings.IndexByte(s, '\n'); i >= 0 {
return s[:i]
}
return s
}

View file

@ -0,0 +1,842 @@
// Package tests contains the end-to-end API suite.
//
// These are true integration tests: every request travels over HTTP to a live
// server, every assertion inspects the real filesystem. There are no mocks, no
// fakes and no in-memory doubles anywhere in this file -- if the flat-file engine
// breaks, these tests break.
package tests
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"
"go-doc/router"
"go-doc/storage"
)
// harness wires a real storage engine to a real HTTP server over a temporary
// data directory.
type harness struct {
t *testing.T
dataDir string
engine *storage.Engine
server *httptest.Server
client *http.Client
docsDir string
notePath string
}
func newHarness(t *testing.T) *harness {
t.Helper()
dataDir := t.TempDir()
engine, err := storage.New(dataDir)
if err != nil {
t.Fatalf("integration: provision engine: %v", err)
}
handler, err := router.NewWithConfig(router.Config{
Engine: engine,
Logger: discardLogger(),
})
if err != nil {
t.Fatalf("integration: build router: %v", err)
}
server := httptest.NewServer(handler)
t.Cleanup(server.Close)
return &harness{
t: t,
dataDir: dataDir,
engine: engine,
server: server,
client: server.Client(),
docsDir: filepath.Join(dataDir, "docs"),
notePath: filepath.Join(dataDir, "comments"),
}
}
// request performs an HTTP call and returns the response with its body.
func (h *harness) request(method, path, contentType, body string) (*http.Response, []byte) {
h.t.Helper()
var reader io.Reader
if body != "" {
reader = strings.NewReader(body)
}
req, err := http.NewRequest(method, h.server.URL+path, reader)
if err != nil {
h.t.Fatalf("integration: build %s %s: %v", method, path, err)
}
if contentType != "" {
req.Header.Set("Content-Type", contentType)
}
resp, err := h.client.Do(req)
if err != nil {
h.t.Fatalf("integration: %s %s: %v", method, path, err)
}
defer resp.Body.Close()
payload, err := io.ReadAll(resp.Body)
if err != nil {
h.t.Fatalf("integration: read body of %s %s: %v", method, path, err)
}
return resp, payload
}
func (h *harness) expectStatus(method, path, contentType, body string, want int) []byte {
h.t.Helper()
resp, payload := h.request(method, path, contentType, body)
if resp.StatusCode != want {
h.t.Fatalf("%s %s: expected HTTP %d, got %d (body: %s)", method, path, want, resp.StatusCode, payload)
}
return payload
}
func decodeJSON[T any](t *testing.T, payload []byte) T {
t.Helper()
var out T
if err := json.Unmarshal(payload, &out); err != nil {
t.Fatalf("integration: decode %s: %v", payload, err)
}
return out
}
// requireFile asserts a file exists on the real filesystem and returns its bytes.
func requireFile(t *testing.T, path string) []byte {
t.Helper()
data, err := os.ReadFile(path)
if err != nil {
t.Fatalf("integration: expected %s to exist: %v", path, err)
}
return data
}
func requireNoFile(t *testing.T, path string) {
t.Helper()
if _, err := os.Stat(path); err == nil {
t.Fatalf("integration: expected %s to be absent", path)
} else if !os.IsNotExist(err) {
t.Fatalf("integration: stat %s: %v", path, err)
}
}
// ---------------------------------------------------------------------------
// TestIntegrationDocumentLifecycle
// ---------------------------------------------------------------------------
func TestIntegrationDocumentLifecycle(t *testing.T) {
t.Parallel()
h := newHarness(t)
t.Run("health reports the live data root", func(t *testing.T) {
payload := h.expectStatus("GET", "/api/health", "", "", http.StatusOK)
health := decodeJSON[struct {
Status string `json:"status"`
DataRoot string `json:"data_root"`
}](t, payload)
if health.Status != "ok" {
t.Fatalf("expected status ok, got %q", health.Status)
}
if health.DataRoot != h.dataDir {
t.Fatalf("expected data root %q, got %q", h.dataDir, health.DataRoot)
}
})
t.Run("POST creates a slugified Markdown file on disk", func(t *testing.T) {
payload := h.expectStatus(
"POST", "/api/docs/Core%20Service%20API", "text/markdown",
"# Core Service API\n\nBilling and invoicing endpoints.\n",
http.StatusCreated,
)
saved := decodeJSON[struct {
Slug string `json:"slug"`
Title string `json:"title"`
Created bool `json:"created"`
}](t, payload)
if saved.Slug != "core-service-api" {
t.Fatalf("expected slug core-service-api, got %q", saved.Slug)
}
if !saved.Created {
t.Fatal("expected created=true on first write")
}
if saved.Title != "Core Service API" {
t.Fatalf("expected the H1 to become the title, got %q", saved.Title)
}
onDisk := requireFile(t, filepath.Join(h.docsDir, "core-service-api.md"))
if !bytes.Contains(onDisk, []byte("Billing and invoicing endpoints.")) {
t.Fatalf("file content mismatch: %s", onDisk)
}
})
t.Run("GET returns the raw Markdown with the right content type", func(t *testing.T) {
resp, payload := h.request("GET", "/api/docs/core-service-api", "", "")
if resp.StatusCode != http.StatusOK {
t.Fatalf("expected 200, got %d", resp.StatusCode)
}
if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/markdown") {
t.Fatalf("expected text/markdown, got %q", ct)
}
if !strings.HasPrefix(string(payload), "# Core Service API") {
t.Fatalf("unexpected markdown body: %s", payload)
}
})
t.Run("GET ?format=json returns the document envelope", func(t *testing.T) {
payload := h.expectStatus("GET", "/api/docs/core-service-api?format=json", "", "", http.StatusOK)
doc := decodeJSON[struct {
Slug string `json:"slug"`
Title string `json:"title"`
Content string `json:"content"`
Size int64 `json:"size"`
}](t, payload)
if doc.Slug != "core-service-api" || doc.Title != "Core Service API" {
t.Fatalf("unexpected envelope: %+v", doc)
}
if doc.Size <= 0 {
t.Fatalf("expected a positive size, got %d", doc.Size)
}
})
t.Run("POST with a JSON body updates the file in place", func(t *testing.T) {
h.expectStatus(
"POST", "/api/docs/core-service-api", "application/json",
`{"title":"Core Service API v2","content":"# Core Service API v2\n\nRewritten.\n"}`,
http.StatusOK,
)
onDisk := string(requireFile(t, filepath.Join(h.docsDir, "core-service-api.md")))
if strings.Contains(onDisk, "Billing and invoicing") {
t.Fatalf("update did not replace the content wholesale: %s", onDisk)
}
if !strings.Contains(onDisk, "# Core Service API v2") {
t.Fatalf("update missing from disk: %s", onDisk)
}
})
t.Run("GET /api/docs indexes the document", func(t *testing.T) {
payload := h.expectStatus("GET", "/api/docs", "", "", http.StatusOK)
index := decodeJSON[struct {
Count int `json:"count"`
Docs []storage.DocMeta `json:"docs"`
}](t, payload)
if index.Count != 1 || index.Docs[0].Slug != "core-service-api" {
t.Fatalf("unexpected index: %+v", index)
}
})
t.Run("search filters the index", func(t *testing.T) {
h.expectStatus("POST", "/api/docs/Payments%20Gateway", "text/markdown", "# Payments Gateway\n", http.StatusCreated)
payload := h.expectStatus("GET", "/api/docs?q=payment", "", "", http.StatusOK)
index := decodeJSON[struct {
Count int `json:"count"`
}](t, payload)
if index.Count != 1 {
t.Fatalf("expected exactly one search hit, got %d", index.Count)
}
payload = h.expectStatus("GET", "/api/docs?q=nothing-matches", "", "", http.StatusOK)
index = decodeJSON[struct {
Count int `json:"count"`
}](t, payload)
if index.Count != 0 {
t.Fatalf("expected no hits, got %d", index.Count)
}
})
t.Run("DELETE removes the file and cascades the feedback", func(t *testing.T) {
h.expectStatus("POST", "/api/docs/core-service-api/comments", "application/json",
`{"author":"ana","role":"developer","content":"delete me"}`, http.StatusCreated)
requireFile(t, filepath.Join(h.notePath, "core-service-api-comments.json"))
h.expectStatus("DELETE", "/api/docs/core-service-api", "", "", http.StatusNoContent)
requireNoFile(t, filepath.Join(h.docsDir, "core-service-api.md"))
requireNoFile(t, filepath.Join(h.notePath, "core-service-api-comments.json"))
h.expectStatus("GET", "/api/docs/core-service-api", "", "", http.StatusNotFound)
h.expectStatus("DELETE", "/api/docs/core-service-api", "", "", http.StatusNotFound)
})
}
// ---------------------------------------------------------------------------
// TestIntegrationCommentTracking
// ---------------------------------------------------------------------------
func TestIntegrationCommentTracking(t *testing.T) {
t.Parallel()
h := newHarness(t)
h.expectStatus("POST", "/api/docs/Deploy%20Pipeline", "text/markdown", "# Deploy Pipeline\n", http.StatusCreated)
type created struct {
Comment storage.Comment `json:"comment"`
Badge string `json:"badge"`
}
cases := []struct {
role string
author string
content string
badge string
}{
{"user", "sam", "The wizard confused me", "🟢"},
{"Operator", "robin", "Rollback takes 20 minutes", "🔵"},
{"DEVELOPER", "kit", "Add a --dry-run flag", "🔴"},
}
for _, tc := range cases {
t.Run("role "+tc.role+" receives badge "+tc.badge, func(t *testing.T) {
body := fmt.Sprintf(`{"author":%q,"role":%q,"content":%q}`, tc.author, tc.role, tc.content)
payload := h.expectStatus("POST", "/api/docs/deploy-pipeline/comments", "application/json", body, http.StatusCreated)
result := decodeJSON[created](t, payload)
if result.Badge != tc.badge {
t.Fatalf("expected badge %q, got %q", tc.badge, result.Badge)
}
if result.Comment.Badge != tc.badge {
t.Fatalf("expected the stored badge %q, got %q", tc.badge, result.Comment.Badge)
}
if result.Comment.ID == "" {
t.Fatal("expected a generated comment ID")
}
if result.Comment.Timestamp.IsZero() {
t.Fatal("expected a timestamp")
}
if string(result.Comment.Role) != strings.ToLower(tc.role) {
t.Fatalf("expected the canonical role, got %q", result.Comment.Role)
}
})
}
t.Run("the feedback file is a JSON array in insertion order", func(t *testing.T) {
raw := requireFile(t, filepath.Join(h.notePath, "deploy-pipeline-comments.json"))
if !strings.HasPrefix(strings.TrimSpace(string(raw)), "[") {
t.Fatalf("expected a JSON array, got: %s", raw)
}
var stored []storage.Comment
if err := json.Unmarshal(raw, &stored); err != nil {
t.Fatalf("feedback file is not valid JSON: %v", err)
}
if len(stored) != 3 {
t.Fatalf("expected 3 comments, got %d", len(stored))
}
for i, want := range []struct{ role, badge string }{
{"user", "🟢"},
{"operator", "🔵"},
{"developer", "🔴"},
} {
if string(stored[i].Role) != want.role || stored[i].Badge != want.badge {
t.Fatalf("comment %d: got %s/%s, want %s/%s", i, stored[i].Role, stored[i].Badge, want.role, want.badge)
}
}
})
t.Run("GET lists the comments with their badges", func(t *testing.T) {
payload := h.expectStatus("GET", "/api/docs/deploy-pipeline/comments", "", "", http.StatusOK)
listed := decodeJSON[struct {
Comments []storage.Comment `json:"comments"`
Count int `json:"count"`
}](t, payload)
if listed.Count != 3 || len(listed.Comments) != 3 {
t.Fatalf("expected 3 comments, got %d", listed.Count)
}
if listed.Comments[2].Badge != "🔴" {
t.Fatalf("expected the developer badge, got %q", listed.Comments[2].Badge)
}
})
t.Run("DELETE removes a single comment by ID", func(t *testing.T) {
payload := h.expectStatus("GET", "/api/docs/deploy-pipeline/comments", "", "", http.StatusOK)
listed := decodeJSON[struct {
Comments []storage.Comment `json:"comments"`
}](t, payload)
target := listed.Comments[0].ID
h.expectStatus("DELETE", "/api/docs/deploy-pipeline/comments/"+target, "", "", http.StatusNoContent)
h.expectStatus("DELETE", "/api/docs/deploy-pipeline/comments/"+target, "", "", http.StatusNotFound)
remaining := decodeJSON[struct {
Count int `json:"count"`
}](t, h.expectStatus("GET", "/api/docs/deploy-pipeline/comments", "", "", http.StatusOK))
if remaining.Count != 2 {
t.Fatalf("expected 2 remaining comments, got %d", remaining.Count)
}
})
t.Run("stats aggregate by role", func(t *testing.T) {
payload := h.expectStatus("GET", "/api/docs/deploy-pipeline/stats", "", "", http.StatusOK)
stats := decodeJSON[storage.CommentStats](t, payload)
if stats.Total != 2 {
t.Fatalf("expected total 2, got %d", stats.Total)
}
if stats.ByRole["operator"] != 1 || stats.ByRole["developer"] != 1 {
t.Fatalf("unexpected role tally: %+v", stats.ByRole)
}
})
t.Run("the role catalogue is exposed for the UI", func(t *testing.T) {
payload := h.expectStatus("GET", "/api/roles", "", "", http.StatusOK)
catalogue := decodeJSON[struct {
Roles []struct {
Role string `json:"role"`
Badge string `json:"badge"`
Label string `json:"label"`
Hex string `json:"hex"`
} `json:"roles"`
}](t, payload)
want := map[string][3]string{
"user": {"🟢", "User", "#22c55e"},
"operator": {"🔵", "Operator", "#3b82f6"},
"developer": {"🔴", "Developer", "#ef4444"},
}
if len(catalogue.Roles) != 3 {
t.Fatalf("expected 3 roles, got %d", len(catalogue.Roles))
}
for _, role := range catalogue.Roles {
expected, ok := want[role.Role]
if !ok {
t.Fatalf("unexpected role %q", role.Role)
}
if role.Badge != expected[0] || role.Label != expected[1] || role.Hex != expected[2] {
t.Fatalf("role %s mismatch: %+v", role.Role, role)
}
}
})
}
// ---------------------------------------------------------------------------
// TestIntegrationValidationAndErrors
// ---------------------------------------------------------------------------
func TestIntegrationValidationAndErrors(t *testing.T) {
t.Parallel()
h := newHarness(t)
t.Run("unknown document returns a structured 404", func(t *testing.T) {
payload := h.expectStatus("GET", "/api/docs/does-not-exist", "", "", http.StatusNotFound)
body := decodeJSON[struct {
Error struct {
Code string `json:"code"`
Message string `json:"message"`
} `json:"error"`
Status int `json:"status"`
}](t, payload)
if body.Error.Code != "not_found" || body.Status != http.StatusNotFound {
t.Fatalf("unexpected error body: %s", payload)
}
})
t.Run("invalid role returns 400 invalid_role", func(t *testing.T) {
h.expectStatus("POST", "/api/docs/guarded", "text/markdown", "# Guarded\n", http.StatusCreated)
payload := h.expectStatus("POST", "/api/docs/guarded/comments", "application/json",
`{"author":"mallory","role":"root","content":"escalate"}`, http.StatusBadRequest)
if !strings.Contains(string(payload), "invalid_role") {
t.Fatalf("expected invalid_role, got %s", payload)
}
requireNoFile(t, filepath.Join(h.notePath, "guarded-comments.json"))
})
t.Run("empty comment content returns 400 invalid_comment", func(t *testing.T) {
payload := h.expectStatus("POST", "/api/docs/guarded/comments", "application/json",
`{"author":"ana","role":"user","content":" "}`, http.StatusBadRequest)
if !strings.Contains(string(payload), "invalid_comment") {
t.Fatalf("expected invalid_comment, got %s", payload)
}
})
t.Run("malformed JSON returns 400 invalid_body", func(t *testing.T) {
payload := h.expectStatus("POST", "/api/docs/guarded/comments", "application/json", `{"author":`, http.StatusBadRequest)
if !strings.Contains(string(payload), "invalid_body") {
t.Fatalf("expected invalid_body, got %s", payload)
}
})
t.Run("traversal slugs are refused", func(t *testing.T) {
for _, path := range []string{
"/api/docs/..%2F..%2Fetc%2Fpasswd",
"/api/docs/%2Fetc%2Fpasswd",
} {
h.expectStatus("POST", path, "text/markdown", "nope", http.StatusBadRequest)
}
})
t.Run("oversized documents are refused", func(t *testing.T) {
huge := strings.Repeat("a", int(router.DefaultMaxDocBytes)+1024)
h.expectStatus("POST", "/api/docs/huge", "text/markdown", huge, http.StatusBadRequest)
requireNoFile(t, filepath.Join(h.docsDir, "huge.md"))
})
t.Run("unknown API routes return JSON 404, not an HTML page", func(t *testing.T) {
payload := h.expectStatus("GET", "/api/nope", "", "", http.StatusNotFound)
if !strings.Contains(string(payload), "not_found") {
t.Fatalf("expected a JSON error, got %s", payload)
}
})
}
// ---------------------------------------------------------------------------
// TestIntegrationCORS
// ---------------------------------------------------------------------------
func TestIntegrationCORS(t *testing.T) {
t.Parallel()
h := newHarness(t)
t.Run("preflight is answered for the Vite dev server", func(t *testing.T) {
resp, _ := h.request("OPTIONS", "/api/docs/core-service-api", "", "")
if resp.StatusCode != http.StatusNoContent {
t.Fatalf("expected 204, got %d", resp.StatusCode)
}
if origin := resp.Header.Get("Access-Control-Allow-Origin"); origin != "*" {
t.Fatalf("expected a wildcard origin, got %q", origin)
}
methods := resp.Header.Get("Access-Control-Allow-Methods")
for _, verb := range []string{"GET", "POST", "PUT", "DELETE", "OPTIONS"} {
if !strings.Contains(methods, verb) {
t.Fatalf("expected %s in %q", verb, methods)
}
}
if headers := resp.Header.Get("Access-Control-Allow-Headers"); !strings.Contains(headers, "Content-Type") {
t.Fatalf("expected Content-Type to be allowed, got %q", headers)
}
})
t.Run("cross-origin reads carry the CORS header", func(t *testing.T) {
req, err := http.NewRequest("GET", h.server.URL+"/api/docs", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Origin", "http://127.0.0.1:5173")
resp, err := h.client.Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
if origin := resp.Header.Get("Access-Control-Allow-Origin"); origin != "*" {
t.Fatalf("expected a CORS header, got %q", origin)
}
})
t.Run("an allow-list echoes only permitted origins", func(t *testing.T) {
dataDir := t.TempDir()
engine, err := storage.New(dataDir)
if err != nil {
t.Fatal(err)
}
handler, err := router.NewWithConfig(router.Config{
Engine: engine,
AllowedOrigins: []string{"http://127.0.0.1:5173"},
Logger: discardLogger(),
})
if err != nil {
t.Fatal(err)
}
server := httptest.NewServer(handler)
defer server.Close()
for origin, wantEchoed := range map[string]bool{
"http://127.0.0.1:5173": true,
"http://evil.example.com": false,
} {
req, err := http.NewRequest("GET", server.URL+"/api/health", nil)
if err != nil {
t.Fatal(err)
}
req.Header.Set("Origin", origin)
resp, err := server.Client().Do(req)
if err != nil {
t.Fatal(err)
}
resp.Body.Close()
got := resp.Header.Get("Access-Control-Allow-Origin")
if wantEchoed && got != origin {
t.Fatalf("expected %q to be echoed, got %q", origin, got)
}
if !wantEchoed && got == origin {
t.Fatalf("origin %q should not have been echoed", origin)
}
}
})
}
// ---------------------------------------------------------------------------
// TestIntegrationConcurrentLoad
// ---------------------------------------------------------------------------
func TestIntegrationConcurrentLoad(t *testing.T) {
t.Parallel()
h := newHarness(t)
const (
docs = 40
perDoc = 30
commentsTotal = docs * perDoc
)
t.Run("concurrent writers all land on disk", func(t *testing.T) {
var wg sync.WaitGroup
errs := make(chan error, docs)
for d := 0; d < docs; d++ {
wg.Add(1)
go func(index int) {
defer wg.Done()
title := fmt.Sprintf("Load Service %02d", index)
body := fmt.Sprintf("# %s\n\n## For Users\n\nUsage.\n\n## For Operators\n\nRunbook.\n\n## For Developers\n\nAPI.\n", title)
resp, payload := h.request("POST", "/api/docs/"+storage.Slugify(title), "text/markdown", body)
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
errs <- fmt.Errorf("save %s returned %d: %s", title, resp.StatusCode, payload)
return
}
for n := 0; n < perDoc; n++ {
role := []string{"user", "operator", "developer"}[n%3]
comment := fmt.Sprintf(
`{"author":"load-%02d","role":%q,"content":"comment %d"}`, index, role, n)
resp, payload := h.request("POST", "/api/docs/"+storage.Slugify(title)+"/comments", "application/json", comment)
if resp.StatusCode != http.StatusCreated {
errs <- fmt.Errorf("comment on %s returned %d: %s", title, resp.StatusCode, payload)
return
}
}
}(d)
}
wg.Wait()
close(errs)
for err := range errs {
t.Fatal(err)
}
})
t.Run("the filesystem holds every document", func(t *testing.T) {
entries, err := os.ReadDir(h.docsDir)
if err != nil {
t.Fatal(err)
}
if len(entries) != docs {
t.Fatalf("expected %d Markdown files, got %d", docs, len(entries))
}
})
t.Run("the filesystem holds every comment", func(t *testing.T) {
entries, err := os.ReadDir(h.notePath)
if err != nil {
t.Fatal(err)
}
if len(entries) != docs {
t.Fatalf("expected %d feedback files, got %d", docs, len(entries))
}
total := 0
for _, entry := range entries {
raw, err := os.ReadFile(filepath.Join(h.notePath, entry.Name()))
if err != nil {
t.Fatal(err)
}
var stored []storage.Comment
if err := json.Unmarshal(raw, &stored); err != nil {
t.Fatalf("%s is not a valid JSON array: %v", entry.Name(), err)
}
total += len(stored)
}
if total != commentsTotal {
t.Fatalf("expected %d comments across all files, got %d", commentsTotal, total)
}
})
t.Run("the API agrees with the filesystem", func(t *testing.T) {
payload := h.expectStatus("GET", "/api/stats", "", "", http.StatusOK)
stats := decodeJSON[struct {
Docs int `json:"docs"`
Comments int `json:"comments"`
}](t, payload)
if stats.Docs != docs {
t.Fatalf("expected %d docs, got %d", docs, stats.Docs)
}
if stats.Comments != commentsTotal {
t.Fatalf("expected %d comments, got %d", commentsTotal, stats.Comments)
}
})
t.Run("no temp files leaked and every comment is badged", func(t *testing.T) {
entries, err := os.ReadDir(h.docsDir)
if err != nil {
t.Fatal(err)
}
for _, entry := range entries {
if strings.HasPrefix(entry.Name(), ".godoc-tmp-") {
t.Fatalf("leaked atomic-write temp file: %s", entry.Name())
}
}
for d := 0; d < docs; d++ {
slug := storage.Slugify(fmt.Sprintf("Load Service %02d", d))
raw := requireFile(t, filepath.Join(h.notePath, slug+"-comments.json"))
var stored []storage.Comment
if err := json.Unmarshal(raw, &stored); err != nil {
t.Fatal(err)
}
if len(stored) != perDoc {
t.Fatalf("%s: expected %d comments, got %d", slug, perDoc, len(stored))
}
for _, comment := range stored {
if comment.Badge != comment.Role.Badge() {
t.Fatalf("%s: comment %s has badge %q for role %s", slug, comment.ID, comment.Badge, comment.Role)
}
}
}
})
}
// ---------------------------------------------------------------------------
// TestIntegrationPersistenceAcrossRestart
// ---------------------------------------------------------------------------
func TestIntegrationPersistenceAcrossRestart(t *testing.T) {
t.Parallel()
dataDir := t.TempDir()
// First "process": write through the API.
engine, err := storage.New(dataDir)
if err != nil {
t.Fatal(err)
}
handler, err := router.NewWithConfig(router.Config{Engine: engine, Logger: discardLogger()})
if err != nil {
t.Fatal(err)
}
first := httptest.NewServer(handler)
post := func(method, path, contentType, body string) []byte {
t.Helper()
req, err := http.NewRequest(method, first.URL+path, strings.NewReader(body))
if err != nil {
t.Fatal(err)
}
req.Header.Set("Content-Type", contentType)
resp, err := first.Client().Do(req)
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
payload, _ := io.ReadAll(resp.Body)
if resp.StatusCode >= 300 {
t.Fatalf("%s %s returned %d: %s", method, path, resp.StatusCode, payload)
}
return payload
}
post("POST", "/api/docs/Persistent%20Store", "text/markdown", "# Persistent Store\n\nSurvives restarts.\n")
post("POST", "/api/docs/persistent-store/comments", "application/json",
`{"author":"ana","role":"developer","content":"still here after a restart"}`)
first.Close()
// Second "process": a brand new engine over the same directory.
restarted, err := storage.New(dataDir)
if err != nil {
t.Fatal(err)
}
handler, err = router.NewWithConfig(router.Config{Engine: restarted, Logger: discardLogger()})
if err != nil {
t.Fatal(err)
}
second := httptest.NewServer(handler)
defer second.Close()
t.Run("the document survives", func(t *testing.T) {
resp, err := second.Client().Get(second.URL + "/api/docs/persistent-store?format=json")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
doc := decodeJSON[struct {
Title string `json:"title"`
Content string `json:"content"`
}](t, mustReadAll(resp))
if doc.Title != "Persistent Store" {
t.Fatalf("unexpected title %q", doc.Title)
}
if !strings.Contains(doc.Content, "Survives restarts.") {
t.Fatalf("unexpected content %q", doc.Content)
}
})
t.Run("the feedback survives with its badge", func(t *testing.T) {
resp, err := second.Client().Get(second.URL + "/api/docs/persistent-store/comments")
if err != nil {
t.Fatal(err)
}
defer resp.Body.Close()
listed := decodeJSON[struct {
Count int `json:"count"`
Comments []storage.Comment `json:"comments"`
}](t, mustReadAll(resp))
if listed.Count != 1 {
t.Fatalf("expected 1 comment, got %d", listed.Count)
}
if listed.Comments[0].Badge != "🔴" {
t.Fatalf("expected the developer badge, got %q", listed.Comments[0].Badge)
}
})
}
func mustReadAll(resp *http.Response) []byte {
data, err := io.ReadAll(resp.Body)
if err != nil {
panic(err)
}
return data
}
func TestIntegrationTimingBudget(t *testing.T) {
t.Parallel()
h := newHarness(t)
start := time.Now()
h.expectStatus("POST", "/api/docs/timing", "text/markdown", strings.Repeat("Line of content.\n\n", 500), http.StatusCreated)
for i := 0; i < 100; i++ {
h.expectStatus("POST", "/api/docs/timing/comments", "application/json",
fmt.Sprintf(`{"author":"bench","role":"user","content":"note %d"}`, i), http.StatusCreated)
}
h.expectStatus("GET", "/api/docs/timing", "", "", http.StatusOK)
if elapsed := time.Since(start); elapsed > 30*time.Second {
t.Fatalf("write path took %s, which is far too slow for a flat-file engine", elapsed)
}
}

View file

@ -0,0 +1,12 @@
package tests
import (
"io"
"log/slog"
)
// discardLogger silences the router's request log so `go test -v` output stays
// focused on assertions.
func discardLogger() *slog.Logger {
return slog.New(slog.NewTextHandler(io.Discard, &slog.HandlerOptions{Level: slog.LevelError}))
}

View file

@ -0,0 +1,33 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8" />
<title>go-doc &middot; frontend not built</title>
<style>
body {
margin: 0;
min-height: 100vh;
display: grid;
place-items: center;
background: #0b0f14;
color: #cbd5e1;
font: 16px/1.6 ui-monospace, SFMono-Regular, Menlo, monospace;
}
main { max-width: 46rem; padding: 3rem; }
h1 { color: #f8fafc; font-size: 1.6rem; }
code { color: #7dd3fc; }
ul { padding-left: 1.2rem; }
</style>
</head>
<body>
<main>
<h1>go-doc API is running</h1>
<p>This is the placeholder page shipped with the Go binary. The React bundle has not been compiled into it yet.</p>
<ul>
<li>Run <code>make frontend-run</code> for the Vite dev server on port 5173.</li>
<li>Run <code>make build</code> to embed the production bundle into the binary.</li>
</ul>
<p>API: <code>/api/health</code> &middot; <code>/api/docs</code> &middot; <code>/api/docs/{slug}</code> &middot; <code>/api/docs/{slug}/comments</code></p>
</main>
</body>
</html>

14
backend/webui/dist/index.html vendored Normal file
View file

@ -0,0 +1,14 @@
<!doctype html>
<html lang="en" class="dark">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="description" content="go-doc — segmented documentation and audience feedback for users, operators and developers" />
<title>Go Doc: Application Documentation tool.</title>
<script type="module" crossorigin src="/assets/index-76KU_cS9.js"></script>
<link rel="stylesheet" crossorigin href="/assets/index-fwxz_0Za.css">
</head>
<body>
<div id="root"></div>
</body>
</html>

31
backend/webui/embed.go Normal file
View file

@ -0,0 +1,31 @@
// Package webui embeds the compiled React bundle produced by `make build`.
//
// The `dist` directory is populated by the build pipeline (npm run build copies
// frontend/dist into backend/webui/dist). A placeholder index.html is checked
// in so the backend always compiles and runs, even in a fresh checkout where
// the frontend has not been built yet.
package webui
import (
"embed"
"io/fs"
)
//go:embed all:dist
var assets embed.FS
// FS returns the root of the embedded bundle, or nil when the placeholder build
// is still in place and no real frontend bundle has been compiled.
func FS() fs.FS {
sub, err := fs.Sub(assets, "dist")
if err != nil {
return nil
}
if _, err := fs.Stat(sub, "index.html"); err != nil {
return nil
}
return sub
}
// Assets exposes the raw embedded filesystem, including the dist prefix.
func Assets() fs.FS { return assets }