code.pablomurad.com

patchwork

Publishes the revisions of your pages as a feed, with notes and readable diffs, written in Go

cmd/patchwork/doctor.go raw
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
package main

import (
	"context"
	"fmt"
	"os"
	"sort"
	"strings"
	"unicode/utf8"

	"git.pablomurad.com/pablo/patchwork/internal/config"
	"git.pablomurad.com/pablo/patchwork/internal/doc"
	"git.pablomurad.com/pablo/patchwork/internal/slug"
	"git.pablomurad.com/pablo/patchwork/internal/source/folder"
	"git.pablomurad.com/pablo/patchwork/internal/store"
)

// cmdDoctor checks everything it can without changing anything. It keeps
// going after a problem so one run shows them all.
func cmdDoctor(ctx context.Context, args []string) error {
	fs := newFlags("doctor", "")
	cfgPath := configFlag(fs)
	if err := fs.Parse(args); err != nil {
		return err
	}
	problems := 0
	ok := func(format string, a ...any) { fmt.Fprintf(stdout, "ok    "+format+"\n", a...) }
	warn := func(format string, a ...any) { fmt.Fprintf(stdout, "warn  "+format+"\n", a...) }
	bad := func(format string, a ...any) { problems++; fmt.Fprintf(stdout, "FAIL  "+format+"\n", a...) }

	cfg, err := config.Load(*cfgPath)
	if err != nil {
		bad("config: %v", err)
		return errProblems
	}
	ok("config %s", *cfgPath)
	if cfg.Site.BaseURL == "" {
		warn("site.base_url is empty; feeds need the public address of the site")
	}
	if cfg.Site.TagAuthority == "" {
		warn("site.tag_authority is empty; feed entry ids need it, and it must never change once feeds are public")
	}

	if strings.HasPrefix(cfg.DB, `\\`) || strings.HasPrefix(cfg.DB, "//") {
		warn("db %s is on a network share; SQLite locking is not safe there", cfg.DB)
	}
	st, err := store.Open(cfg.DB)
	if err != nil {
		bad("database: %v", err)
	} else {
		defer st.Close()
		pages, err := st.Pages(ctx, "", false)
		if err != nil {
			bad("database: %v", err)
		} else {
			ok("database %s (%d active pages)", cfg.DB, len(pages))
		}
	}

	m := folder.Matcher{Include: cfg.Source.Include, Exclude: cfg.Source.Exclude}
	files, err := folder.List(cfg.Source.Path, m)
	if err != nil {
		bad("source: %v", err)
		return errProblems
	}
	if len(files) == 0 {
		warn("source %s has no files matching %v", cfg.Source.Path, cfg.Source.Include)
	} else {
		ok("source %s (%d files)", cfg.Source.Path, len(files))
	}

	rule, _ := slug.Lookup(cfg.Source.Slug)
	opt := cfg.DocOptions()
	bySlug := map[string][]string{}
	for _, f := range files {
		raw, err := os.ReadFile(f.Abs)
		if err != nil {
			bad("%s: %v", f.Rel, err)
			continue
		}
		s := rule(f.Rel)
		bySlug[s] = append(bySlug[s], f.Rel)
		if s == "" {
			bad("%s: the file name produces an empty slug, so the page has no URL; rename it", f.Rel)
		}
		if !utf8.Valid(raw) {
			bad("%s: not valid UTF-8", f.Rel)
		}
		d := doc.Parse(raw, opt)
		if d.FrontmatterErr != nil {
			bad("%s: frontmatter is not valid YAML: %v", f.Rel, d.FrontmatterErr)
		}
		if !doc.Valid(d.Kind, doc.Kinds) {
			bad("%s: %s %q should be one of %s", f.Rel, opt.KindKey, d.Kind, strings.Join(doc.Kinds, ", "))
		}
		if !doc.Valid(d.Maturity, doc.Maturities) {
			bad("%s: %s %q should be one of %s", f.Rel, opt.MaturityKey, d.Maturity, strings.Join(doc.Maturities, ", "))
		}
	}
	var slugs []string
	for s, paths := range bySlug {
		if s != "" && len(paths) > 1 {
			slugs = append(slugs, s)
		}
	}
	sort.Strings(slugs)
	for _, s := range slugs {
		bad("slug %q is shared by %s; only the first one seen gets the URL", s, strings.Join(bySlug[s], ", "))
	}

	if problems > 0 {
		fmt.Fprintf(stdout, "\n%d problem(s)\n", problems)
		return errProblems
	}
	fmt.Fprintln(stdout, "\nno problems found")
	return nil
}