Neale Pickett
·
2025-03-10
main.go
1package main
2
3import (
4 "errors"
5 "flag"
6 "fmt"
7 "io/fs"
8 "log/slog"
9 "net/url"
10 "os"
11 "os/signal"
12 "os/user"
13 "path/filepath"
14 "strings"
15 "time"
16
17 "github.com/dirtbags/pupate/docs"
18 "github.com/dirtbags/pupate/examples"
19 "github.com/dirtbags/pupate/internal/pupate"
20 "github.com/dirtbags/pupate/variants"
21 "github.com/dirtbags/pupate/web"
22 "github.com/fsnotify/fsnotify"
23 "github.com/lmittmann/tint"
24 "gopkg.in/yaml.v3"
25)
26
27// Version and date of this program
28var Version, VersionDate = docs.Version()
29
30var (
31 httpAddr = flag.String("listen", "localhost:8080", "HTTP service address")
32 safe = flag.Bool("prod", false, "Don't bundle answers and debug information")
33 variantPath = flag.String("variant", "default", "Build variant name or path to variant on filesystem")
34 loglevel = flag.String("loglevel", "INFO", "Set threshold for log messages: DEBUG, INFO, WARN, ERROR")
35 webdir = flag.String("web", "", "Path to web files; takes precedence over variant web files")
36 docsdir = flag.String("docs", "", "Path to internal documentation")
37 pollInterval = flag.Duration("poll", 0, "Change poll interval duration")
38)
39
40type Context struct {
41 *pupate.Context
42
43 // WebFS contains static web content
44 WebFS fs.FS
45
46 // DocsFS contains documentation
47 DocsFS fs.FS
48
49 // MyURL is the URL to me :)
50 MyURL url.URL
51
52 // sourcecollections holds paths to directories containing category source trees.
53 //
54 // An entry might contain `netarch`, which in turn contains `core`, `sequence`, and `js`.
55 // Each of those would contain puzzle directories.
56 sourcecollections []string
57
58 // addListener adds an event listener.
59 // The listener must be added again after every event is sent!
60 addListener chan chan<- []string
61
62 // listeners is the internal list of listeners.
63 // If you want a listener here, send it to addListener.
64 listeners []chan<- []string
65
66 // watcher tells us when something on the filesystem changes.
67 watcher *fsnotify.Watcher
68
69 // lastRebuild is the time when we last began a rebuild
70 lastRebuild time.Time
71
72 // buildErrors is a list of errors from the last build
73 buildErrors []error
74}
75
76// GetSources returns a list of category directories
77func (ctx *Context) GetSources() (sources []string, err error) {
78 for _, srcdir := range ctx.sourcecollections {
79 ctx.Watch(srcdir)
80 dents, err := os.ReadDir(srcdir)
81 if err != nil {
82 return nil, err
83 }
84 for _, dent := range dents {
85 if !dent.IsDir() {
86 continue
87 }
88 name := dent.Name()
89 if strings.HasPrefix(name, ".") {
90 continue
91 }
92 path := filepath.Join(srcdir, dent.Name())
93 sources = append(sources, path)
94 ctx.Watch(path)
95 }
96 }
97 return sources, nil
98}
99
100// Ask the OS to tell us about changes in this directory.
101func (ctx *Context) Watch(path string) {
102 if ctx.watcher == nil {
103 return
104 }
105
106 // Also watch any subdirectories
107 dents, _ := os.ReadDir(path)
108 for _, dent := range dents {
109 if !dent.IsDir() {
110 continue
111 }
112 ctx.Watch(filepath.Join(path, dent.Name()))
113 }
114
115 ctx.watcher.Add(path)
116}
117
118// WatchAndRebuild pupates all categories whenever a file changes.
119//
120// All listeners will be notified of the change.
121//
122// Any errors preventing monitoring result in a call to panic().
123func (ctx *Context) WatchAndRebuild() {
124 pollTicker := time.Tick(*pollInterval)
125 delayTimer := time.NewTimer(250 * time.Millisecond)
126 ctx.listeners = make([]chan<- []string, 0)
127 for {
128 select {
129 case err, ok := <-ctx.watcher.Errors:
130 if !ok {
131 return
132 }
133 slog.Error(err.Error())
134 panic(err)
135
136 case wevent, ok := <-ctx.watcher.Events:
137 if !ok {
138 return
139 }
140
141 slog.Debug("fsnotify", "event", wevent)
142
143 name := filepath.Base(wevent.Name)
144 switch {
145 case strings.HasPrefix(name, "."):
146 continue
147 case strings.HasPrefix(name, "#"):
148 continue
149 case strings.HasSuffix(name, "~"):
150 continue
151 case wevent.Op.Has(fsnotify.Chmod):
152 continue
153 case wevent.Op.Has(fsnotify.Create):
154 if info, err := os.Stat(wevent.Name); err != nil {
155 slog.Error(err.Error())
156 } else if info.IsDir() {
157 ctx.Watch(wevent.Name)
158 }
159 case wevent.Op.Has(fsnotify.Write):
160 if info, err := os.Stat(wevent.Name); err != nil {
161 continue
162 } else if info.IsDir() {
163 continue
164 }
165 }
166
167 // Debounce: editors may have more than one file to write.
168 delayTimer = time.NewTimer(100 * time.Millisecond)
169
170 case now := <-delayTimer.C:
171 go ctx.rebuild(now)
172
173 case now := <-pollTicker:
174 // Simulate fsnotify by looking for newer files
175 wdf := func(path string, d fs.DirEntry, err error) error {
176 info, err := d.Info()
177 if err != nil {
178 return err
179 }
180 if info.ModTime().After(ctx.lastRebuild) {
181 go ctx.rebuild(now)
182 return fs.SkipAll
183 }
184 return nil
185 }
186 for _, dn := range ctx.sourcecollections {
187 if err := filepath.WalkDir(dn, wdf); err != nil {
188 slog.Error(err.Error())
189 }
190 }
191
192 case listener, ok := <-ctx.addListener:
193 if !ok {
194 return
195 }
196 ctx.listeners = append(ctx.listeners, listener)
197 continue
198 }
199 }
200}
201
202func (ctx *Context) buildError(err error) {
203 slog.Error(err.Error())
204 ctx.buildErrors = append(ctx.buildErrors, err)
205}
206
207func (ctx *Context) rebuild(now time.Time) {
208 slog.Info("rebuilding")
209 ctx.lastRebuild = now
210 ctx.buildErrors = make([]error, 0)
211
212 dents, _ := os.ReadDir(ctx.OutDir)
213 for _, dent := range dents {
214 path := filepath.Join(ctx.OutDir, dent.Name())
215 os.RemoveAll(path)
216 }
217
218 event := make([]string, 0)
219 if srcdirs, err := ctx.GetSources(); err != nil {
220 ctx.buildError(err)
221 } else {
222 for _, srcdir := range srcdirs {
223 slog.Debug("pupating", "srcdir", srcdir)
224
225 var msg string
226 cat, errs := ctx.BuildCategory(srcdir)
227 for _, err := range errs {
228 msg = fmt.Sprintf("error: %v", err)
229 ctx.buildError(err)
230 }
231 if cat != nil {
232 // We can have errors and also a built category
233 slog.Debug("pupated", "cat", cat.ID)
234 msg = fmt.Sprintf("built: %s", cat.ID)
235 }
236 event = append(event, msg)
237
238 }
239 }
240
241 slog.Info("rebuild complete")
242
243 // Broadcast event to all listeners
244 for _, listener := range ctx.listeners {
245 listener <- event
246 }
247 ctx.listeners = ctx.listeners[:0]
248}
249
250func usage() {
251 fmt.Fprintf(flag.CommandLine.Output(), "Usage: %s [FLAGS] [CATEGORIES...]\n", os.Args[0])
252 flag.PrintDefaults()
253 fmt.Fprintln(flag.CommandLine.Output(), "Version", Version, "-", VersionDate)
254}
255
256// pause blocks until a signal is received
257func pause(sigs ...os.Signal) {
258 c := make(chan os.Signal, 1)
259 signal.Notify(c, sigs...)
260 <-c
261 signal.Stop(c)
262}
263
264func run() error {
265 // This takes up to 30s on my Windows box,
266 // but the module caches it.
267 go user.Current()
268
269 flag.Usage = usage
270 flag.Parse()
271
272 sloglevel := new(slog.LevelVar)
273 if err := sloglevel.UnmarshalText([]byte(*loglevel)); err != nil {
274 fmt.Fprintln(os.Stderr, err)
275 os.Exit(1)
276 }
277 loghandler := tint.NewHandler(os.Stderr, &tint.Options{
278 Level: sloglevel,
279 TimeFormat: time.TimeOnly,
280 })
281 slog.SetDefault(slog.New(loghandler))
282
283 ctx := &Context{
284 Context: &pupate.Context{},
285 addListener: make(chan chan<- []string, 50),
286 sourcecollections: flag.Args(),
287 WebFS: web.FS,
288 DocsFS: docs.FS,
289 }
290 defer close(ctx.addListener)
291
292 // If no flags, set up a "categories" directory, and serve it
293 if len(ctx.sourcecollections) == 0 {
294 defaultDir := filepath.Join(".", "categories")
295 ctx.sourcecollections = append(ctx.sourcecollections, defaultDir)
296 slog.Info("no CATEGORIES directories specified, using default", "directory", defaultDir)
297 if _, err := os.Stat(defaultDir); errors.Is(err, os.ErrNotExist) {
298 if err := os.CopyFS(defaultDir, examples.FS); err != nil {
299 return err
300 }
301 slog.Info("created and populated", "directory", defaultDir)
302 }
303 }
304
305 // Prepare to watch for changes
306 ctx.watcher, _ = fsnotify.NewWatcher()
307
308 // Set up variant FS
309 if _, err := os.Stat(*variantPath); err == nil {
310 ctx.Watch(*variantPath)
311 ctx.VariantFS = os.DirFS(*variantPath)
312 } else {
313 ctx.VariantFS, err = fs.Sub(variants.FS, *variantPath)
314 if err != nil {
315 return err
316
317 }
318 }
319
320 if fsys, err := fs.Sub(ctx.VariantFS, "puzzle"); err != nil {
321 return err
322 } else {
323 ctx.PuzzleTemplateFS = fsys
324 }
325
326 if fsys, err := fs.Sub(ctx.VariantFS, "category"); err != nil {
327 return err
328 } else {
329 ctx.CategoryTemplateFS = fsys
330 }
331
332 if *webdir != "" {
333 ctx.WebFS = os.DirFS(*webdir)
334 ctx.Watch(*webdir)
335 }
336
337 if *docsdir != "" {
338 ctx.DocsFS = os.DirFS(*docsdir)
339 ctx.Watch(*docsdir)
340 }
341
342 // Create temporary directory to store pupated puzzles
343 targetdir, err := os.MkdirTemp("", "cocoon-")
344 if err != nil {
345 return err
346 }
347 defer os.RemoveAll(targetdir)
348 defer slog.Info("removing temporary directory", "dir", targetdir)
349 slog.Info("created temporary directory", "dir", targetdir)
350 ctx.OutDir = targetdir
351
352 ctx.Config = &pupate.Config{
353 Variant: filepath.Base(*variantPath),
354 Unsafe: !*safe,
355 }
356 if configRaw, err := fs.ReadFile(ctx.VariantFS, "config.yaml"); err != nil {
357 // log.Println("Using builtin config")
358 } else if err := yaml.Unmarshal(configRaw, ctx.Config); err != nil {
359 return fmt.Errorf("parsing %s/config.yaml: %v", *variantPath, err)
360 }
361
362 if err := ctx.ServeHTTP(*httpAddr); err != nil {
363 return err
364 }
365 slog.Info("started HTTP server", "addr", *httpAddr, "url", ctx.MyURL.String())
366 pause(os.Interrupt) // Wait for ^C
367 slog.Info("exiting")
368 return nil
369}
370
371func main() {
372 if err := run(); err != nil {
373 slog.Error(err.Error())
374 os.Exit(1)
375 }
376}