Neale Pickett
·
2021-03-26
category.go
1package transpile
2
3import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "log"
9 "os/exec"
10 "path"
11 "strconv"
12 "strings"
13 "time"
14
15 "github.com/spf13/afero"
16)
17
18// InventoryResponse is what's handed back when we ask for an inventory.
19type InventoryResponse struct {
20 Puzzles []int
21}
22
23// Category defines the functionality required to be a puzzle category.
24type Category interface {
25 // Inventory lists every puzzle in the category.
26 Inventory() ([]int, error)
27
28 // Puzzle provides a Puzzle structure for the given point value.
29 Puzzle(points int) (Puzzle, error)
30
31 // Open returns an io.ReadCloser for the given filename.
32 Open(points int, filename string) (ReadSeekCloser, error)
33
34 // Answer returns whether the given answer is correct.
35 Answer(points int, answer string) bool
36}
37
38// NopReadCloser provides an io.ReadCloser which does nothing.
39type NopReadCloser struct {
40}
41
42// Read satisfies io.Reader.
43func (n NopReadCloser) Read(b []byte) (int, error) {
44 return 0, nil
45}
46
47// Close satisfies io.Closer.
48func (n NopReadCloser) Close() error {
49 return nil
50}
51
52// NewFsCategory returns a Category based on which files are present.
53// If 'mkcategory' is present and executable, an FsCommandCategory is returned.
54// Otherwise, FsCategory is returned.
55func NewFsCategory(fs afero.Fs, cat string) Category {
56 bfs := NewRecursiveBasePathFs(fs, cat)
57 if info, err := bfs.Stat("mkcategory"); (err == nil) && (info.Mode()&0100 != 0) {
58 if command, err := bfs.RealPath(info.Name()); err != nil {
59 log.Println("Unable to resolve full path to", info.Name())
60 } else {
61 return FsCommandCategory{
62 fs: bfs,
63 command: command,
64 timeout: 2 * time.Second,
65 }
66 }
67 }
68 return FsCategory{fs: bfs}
69}
70
71// FsCategory provides a category backed by a .md file.
72type FsCategory struct {
73 fs afero.Fs
74}
75
76// Inventory returns a list of point values for this category.
77func (c FsCategory) Inventory() ([]int, error) {
78 puzzleEntries, err := afero.ReadDir(c.fs, ".")
79 if err != nil {
80 return nil, err
81 }
82
83 puzzles := make([]int, 0, len(puzzleEntries))
84 for _, ent := range puzzleEntries {
85 if !ent.IsDir() {
86 continue
87 }
88 if points, err := strconv.Atoi(ent.Name()); err != nil {
89 log.Println("Skipping non-numeric directory", ent.Name())
90 continue
91 } else {
92 puzzles = append(puzzles, points)
93 }
94 }
95 return puzzles, nil
96}
97
98// Puzzle returns a Puzzle structure for the given point value.
99func (c FsCategory) Puzzle(points int) (Puzzle, error) {
100 return NewFsPuzzlePoints(c.fs, points).Puzzle()
101}
102
103// Open returns an io.ReadCloser for the given filename.
104func (c FsCategory) Open(points int, filename string) (ReadSeekCloser, error) {
105 return NewFsPuzzlePoints(c.fs, points).Open(filename)
106}
107
108// Answer checks whether an answer is correct.
109func (c FsCategory) Answer(points int, answer string) bool {
110 // BUG(neale): FsCategory.Answer should probably always return false, to prevent you from running uncompiled puzzles with participants.
111 p, err := c.Puzzle(points)
112 if err != nil {
113 return false
114 }
115 for _, a := range p.Answers {
116 if a == answer {
117 return true
118 }
119 }
120 return false
121}
122
123// FsCommandCategory provides a category backed by running an external command.
124type FsCommandCategory struct {
125 fs afero.Fs
126 command string
127 timeout time.Duration
128}
129
130func (c FsCommandCategory) run(command string, args ...string) ([]byte, error) {
131 ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
132 defer cancel()
133
134 cmdargs := append([]string{command}, args...)
135 cmd := exec.CommandContext(ctx, "./"+path.Base(c.command), cmdargs...)
136 cmd.Dir = path.Dir(c.command)
137 out, err := cmd.Output()
138 if err, ok := err.(*exec.ExitError); ok {
139 stderr := strings.TrimSpace(string(err.Stderr))
140 return nil, fmt.Errorf("%s (%s)", stderr, err.String())
141 }
142 return out, err
143}
144
145// Inventory returns a list of point values for this category.
146func (c FsCommandCategory) Inventory() ([]int, error) {
147 stdout, err := c.run("inventory")
148 if exerr, ok := err.(*exec.ExitError); ok {
149 return nil, fmt.Errorf("inventory: %s: %s", err, string(exerr.Stderr))
150 } else if err != nil {
151 return nil, err
152 }
153
154 inv := InventoryResponse{}
155 if err := json.Unmarshal(stdout, &inv); err != nil {
156 return nil, err
157 }
158
159 return inv.Puzzles, nil
160}
161
162// Puzzle returns a Puzzle structure for the given point value.
163func (c FsCommandCategory) Puzzle(points int) (Puzzle, error) {
164 var p Puzzle
165
166 stdout, err := c.run("puzzle", strconv.Itoa(points))
167 if err != nil {
168 return p, err
169 }
170
171 if err := json.Unmarshal(stdout, &p); err != nil {
172 return p, err
173 }
174
175 p.computeAnswerHashes()
176
177 return p, nil
178}
179
180// Open returns an io.ReadCloser for the given filename.
181func (c FsCommandCategory) Open(points int, filename string) (ReadSeekCloser, error) {
182 stdout, err := c.run("file", strconv.Itoa(points), filename)
183 return nopCloser{bytes.NewReader(stdout)}, err
184}
185
186// Answer checks whether an answer is correct.
187func (c FsCommandCategory) Answer(points int, answer string) bool {
188 stdout, err := c.run("answer", strconv.Itoa(points), answer)
189 if err != nil {
190 log.Printf("ERROR: Answering %d points: %s", points, err)
191 return false
192 }
193
194 ans := AnswerResponse{}
195 if err := json.Unmarshal(stdout, &ans); err != nil {
196 log.Printf("ERROR: Answering %d points: %s", points, err)
197 return false
198 }
199
200 return ans.Correct
201}