Download
222 lines 7.6 KiBGoModified 2026-10-28 raw
123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222
package config

import (
	"os"
	"path/filepath"
	"regexp"
	"strings"
	"testing"
)

// paths are the settings every config must have.
const paths = "git_dir: /srv/git\nhtdocs: /srv/www\nstate_dir: /srv/state\n"

func TestDefaultsAndParse(t *testing.T) {
	cfg, err := Parse([]byte("host: code.example.com\n" + paths))
	if err != nil {
		t.Fatal(err)
	}
	if cfg.GitDir != "/srv/git" || cfg.Htdocs != "/srv/www" || cfg.ListingTruncate != 5 ||
		cfg.Log != LogSyslog || len(cfg.LexerDirs) != 0 {
		t.Errorf("defaults: %+v", cfg)
	}
	for _, key := range []string{"git_dir", "htdocs", "state_dir"} {
		if _, err := Parse([]byte("host: h\n")); err == nil || !strings.Contains(err.Error(), key+" is required") {
			t.Errorf("no %s: %v", key, err)
		}
	}
	if got := cfg.CloneURL("foo"); got != "https://code.example.com/git/foo.git" {
		t.Errorf("clone url: %s", got)
	}

	full := `
host: code.example.com
git_dir: /srv/git
htdocs: /srv/www/code
state_dir: /srv/state
lexer_dirs: [/srv/repo-render/lexers]

site:
  title: Bruce's code
  clone_url: "git://code.example.com/{repo}"

sections:
  - name: tools
    title: Tools
  - name: tomo
    title: "[Tomo](https://tomo.example.com)"

lexers:
  tomo:
    globs: ["*.tm"]
    aliases: [tomo, tm]

repos:
  bp:
    branches: [dev]
`
	cfg, err = Parse([]byte(full))
	if err != nil {
		t.Fatal(err)
	}
	if cfg.CloneURL("bp") != "git://code.example.com/bp" || cfg.SectionIndex("tomo") != 1 || cfg.SectionIndex("zzz") != -1 || cfg.SectionIndex("") != -1 {
		t.Errorf("parsed: %+v", cfg)
	}
	if !cfg.Private("") || !cfg.Private("zzz") || cfg.Private("tomo") {
		t.Error("exactly the repos in no section (or one the config hasn't) should be private")
	}
	if len(cfg.Repo("bp").Branches) != 1 || cfg.LexerNames()[0] != "tomo" || cfg.Htdocs != "/srv/www/code" || cfg.LexerDirs[0] != "/srv/repo-render/lexers" {
		t.Errorf("parsed: %+v", cfg)
	}
}

func TestValidation(t *testing.T) {
	cases := map[string]string{
		"":                                 "host is required",
		"# just a comment\n":               "host is required",
		"host: h\nbogus: 1":                `unknown field "bogus"`,
		"host: h\nsite:\n  titel: x":       `unknown field "titel"`,
		"host: h\nhost: h2":                `"host" already defined`,
		"host: h\nsections:\n  - name: a":  "title is required",
		"host: h\nsections:\n  - title: a": "name is required",
		"host: h\nsections:\n  - {name: a b, title: a}":                               "name \"a b\" is not usable",
		"host: h\nsections:\n  - {name: a, title: a}\n  - {name: a, title: b}":        "name \"a\" is used twice",
		"host: h\n" + paths + "private_htdocs: /srv/www/private":                      "must be outside htdocs",
		"host: h\n" + paths + "private_htdocs: /srv":                                  "must be outside htdocs",
		"host: h\ngit_dir: /g\nstate_dir: /a\nhtdocs: /srv/a\nprivate_htdocs: /srv/a": "must be outside htdocs",
		"host: h\ngit_dir: \"\"":                                                      "git_dir is required",

		"host: ../x":                          "host must be a hostname",
		"host: a b":                           "host must be a hostname",
		"host: h\nrepos:\n  r:\n    bogus: 1": `unknown field "bogus"`,
	}
	for src, want := range cases {
		_, err := Parse([]byte(src))
		if err == nil || !strings.Contains(err.Error(), want) {
			t.Errorf("%q: got %v, want %q", src, err, want)
		}
	}
	for _, ok := range []string{"a", "my-repo", "x.y_z", "A1"} {
		if !ValidRepoName(ok) {
			t.Errorf("ValidRepoName(%q) = false", ok)
		}
	}
	for _, bad := range []string{"", ".", "..", ".hidden", "a/b", "a b", "repo.git", "-x"} {
		if ValidRepoName(bad) {
			t.Errorf("ValidRepoName(%q) = true", bad)
		}
	}
}

func TestBranchAllowlist(t *testing.T) {
	good := []string{"dev", "my-name/feature-with-kebabs", "release/1.2", "v2_x"}
	for _, b := range good {
		if !ValidBranchName(b) {
			t.Errorf("%q rejected", b)
		}
	}
	bad := []string{"", "/x", "x/", "a//b", "a/../b", ".hidden", "a/.b", "x.lock", "a b", "a:b", "a~b", "feat*", "a@{b}", "a..b", "é", "HEAD"}
	for _, b := range bad {
		if ValidBranchName(b) {
			t.Errorf("%q accepted", b)
		}
	}
	if k := ViewKey("tomo", "my-name/feature-with-kebabs"); k != "tomo@my-name:feature-with-kebabs" {
		t.Errorf("ViewKey = %q", k)
	}
	if ViewKey("tomo", "") != "tomo" {
		t.Error("default view key must be the repo name")
	}
	if ViewKey("a", "x/y") == ViewKey("a", "x-y") {
		t.Error("slugs of different branches collide")
	}
	for _, src := range []string{
		"host: h\nrepos:\n  r:\n    branches: [a b]\n",
		"host: h\nrepos:\n  r:\n    branches: [dev, dev]\n",
		"host: h\nrepos:\n  r:\n    branch: ../../x\n",
		"host: h\nrepos:\n  r:\n    branch: \"a\\nb\"\n",
	} {
		if _, err := Parse([]byte(src)); err == nil {
			t.Errorf("accepted:\n%s", src)
		}
	}
	cfg, err := Parse([]byte("host: h\n" + paths + "repos:\n  r:\n    branch: trunk/main\n    branches: [dev, a/b]\n"))
	if err != nil || len(cfg.Repo("r").Branches) != 2 || cfg.Repo("r").Branch != "trunk/main" {
		t.Errorf("good branches: %v", err)
	}
	for tag, ok := range map[string]bool{"lua": true, "vibe coding": true, "": false, " lua": false, "a\tb": false} {
		if ValidTag(tag) != ok {
			t.Errorf("ValidTag(%q) = %v", tag, !ok)
		}
	}
}

// TestLoadRelativePaths checks that relative paths in a config file are
// relative to the file, so one directory can hold a whole site.
func TestLoadRelativePaths(t *testing.T) {
	dir := t.TempDir()
	p := filepath.Join(dir, "repo-render.yaml")
	src := "host: h\ngit_dir: git\nhtdocs: htdocs\nstate_dir: /abs/state\nlexer_dirs: [lexers]\nsite:\n  favicon: icon.svg\n"
	if err := os.WriteFile(p, []byte(src), 0o644); err != nil {
		t.Fatal(err)
	}
	cfg, err := Load(p)
	if err != nil {
		t.Fatal(err)
	}
	for got, want := range map[string]string{
		cfg.GitDir:       filepath.Join(dir, "git"),
		cfg.Htdocs:       filepath.Join(dir, "htdocs"),
		cfg.StateDir:     "/abs/state",
		cfg.LexerDirs[0]: filepath.Join(dir, "lexers"),
		cfg.Site.Favicon: filepath.Join(dir, "icon.svg"),
		cfg.Path:         p,
	} {
		if got != want {
			t.Errorf("got %q, want %q", got, want)
		}
	}
}

func TestSiteURL(t *testing.T) {
	cfg, err := Parse([]byte("host: localhost\n" + paths + "site:\n  url: http://localhost:8080/\n"))
	if err != nil {
		t.Fatal(err)
	}
	if got := cfg.BaseURL(); got != "http://localhost:8080" {
		t.Errorf("base url: %s", got)
	}
	if got := cfg.CloneURL("foo"); got != "http://localhost:8080/git/foo.git" {
		t.Errorf("clone url: %s", got)
	}
	if _, err := Parse([]byte("host: localhost\nsite:\n  url: localhost:8080\n")); err == nil {
		t.Error("site.url without a scheme accepted")
	}
}

// TestExampleConfig keeps examples/repo-render.yaml valid as shipped, and
// with every commented-out setting uncommented, so that the defaults and
// examples it shows are ones the renderer takes. A commented-out setting
// is a "#" followed by no space or by two or more; prose has one.
func TestExampleConfig(t *testing.T) {
	src, err := os.ReadFile(filepath.Join("..", "..", "examples", "repo-render.yaml"))
	if err != nil {
		t.Fatal(err)
	}
	if _, err := Parse(src); err != nil {
		t.Errorf("as shipped: %v", err)
	}
	setting := regexp.MustCompile(`(?m)^(\s*)#(\S|  )`)
	all := setting.ReplaceAll(src, []byte("$1$2"))
	cfg, err := Parse(all)
	if err != nil {
		t.Fatalf("uncommented: %v\n%s", err, all)
	}
	if cfg.PrivateHtdocs == "" || len(cfg.Sections) != 2 || len(cfg.Lexers) != 3 || len(cfg.Repo("tomo").Branches) != 1 || cfg.Site.Footer == "" {
		t.Errorf("uncommented, settings are missing: %+v", cfg)
	}
	d := Defaults()
	if cfg.Style != d.Style || cfg.StyleLight != d.StyleLight || cfg.Site.Title != d.Site.Title {
		t.Error("a default the example shows is not the default")
	}
}