moth/pkg/transpile/category.go

202 lines
5.2 KiB
Go

package transpile
import (
"bytes"
"context"
"encoding/json"
"fmt"
"log"
"os/exec"
"path"
"strconv"
"strings"
"time"
"github.com/spf13/afero"
)
// InventoryResponse is what's handed back when we ask for an inventory.
type InventoryResponse struct {
Puzzles []int
}
// Category defines the functionality required to be a puzzle category.
type Category interface {
// Inventory lists every puzzle in the category.
Inventory() ([]int, error)
// Puzzle provides a Puzzle structure for the given point value.
Puzzle(points int) (Puzzle, error)
// Open returns an io.ReadCloser for the given filename.
Open(points int, filename string) (ReadSeekCloser, error)
// Answer returns whether the given answer is correct.
Answer(points int, answer string) bool
}
// NopReadCloser provides an io.ReadCloser which does nothing.
type NopReadCloser struct {
}
// Read satisfies io.Reader.
func (n NopReadCloser) Read(b []byte) (int, error) {
return 0, nil
}
// Close satisfies io.Closer.
func (n NopReadCloser) Close() error {
return nil
}
// NewFsCategory returns a Category based on which files are present.
// If 'mkcategory' is present and executable, an FsCommandCategory is returned.
// Otherwise, FsCategory is returned.
func NewFsCategory(fs afero.Fs, cat string) Category {
bfs := NewRecursiveBasePathFs(fs, cat)
if info, err := bfs.Stat("mkcategory"); (err == nil) && (info.Mode()&0100 != 0) {
if command, err := bfs.RealPath(info.Name()); err != nil {
log.Println("Unable to resolve full path to", info.Name())
} else {
return FsCommandCategory{
fs: bfs,
command: command,
timeout: 2 * time.Second,
}
}
}
return FsCategory{fs: bfs}
}
// FsCategory provides a category backed by a .md file.
type FsCategory struct {
fs afero.Fs
}
// Inventory returns a list of point values for this category.
func (c FsCategory) Inventory() ([]int, error) {
puzzleEntries, err := afero.ReadDir(c.fs, ".")
if err != nil {
return nil, err
}
puzzles := make([]int, 0, len(puzzleEntries))
for _, ent := range puzzleEntries {
if !ent.IsDir() {
continue
}
if points, err := strconv.Atoi(ent.Name()); err != nil {
log.Println("Skipping non-numeric directory", ent.Name())
continue
} else {
puzzles = append(puzzles, points)
}
}
return puzzles, nil
}
// Puzzle returns a Puzzle structure for the given point value.
func (c FsCategory) Puzzle(points int) (Puzzle, error) {
return NewFsPuzzlePoints(c.fs, points).Puzzle()
}
// Open returns an io.ReadCloser for the given filename.
func (c FsCategory) Open(points int, filename string) (ReadSeekCloser, error) {
return NewFsPuzzlePoints(c.fs, points).Open(filename)
}
// Answer checks whether an answer is correct.
func (c FsCategory) Answer(points int, answer string) bool {
// BUG(neale): FsCategory.Answer should probably always return false, to prevent you from running uncompiled puzzles with participants.
p, err := c.Puzzle(points)
if err != nil {
return false
}
for _, a := range p.Answers {
if a == answer {
return true
}
}
return false
}
// FsCommandCategory provides a category backed by running an external command.
type FsCommandCategory struct {
fs afero.Fs
command string
timeout time.Duration
}
func (c FsCommandCategory) run(command string, args ...string) ([]byte, error) {
ctx, cancel := context.WithTimeout(context.Background(), c.timeout)
defer cancel()
cmdargs := append([]string{command}, args...)
cmd := exec.CommandContext(ctx, "./"+path.Base(c.command), cmdargs...)
cmd.Dir = path.Dir(c.command)
out, err := cmd.Output()
if err, ok := err.(*exec.ExitError); ok {
stderr := strings.TrimSpace(string(err.Stderr))
return nil, fmt.Errorf("%s (%s)", stderr, err.String())
}
return out, err
}
// Inventory returns a list of point values for this category.
func (c FsCommandCategory) Inventory() ([]int, error) {
stdout, err := c.run("inventory")
if exerr, ok := err.(*exec.ExitError); ok {
return nil, fmt.Errorf("inventory: %s: %s", err, string(exerr.Stderr))
} else if err != nil {
return nil, err
}
inv := InventoryResponse{}
if err := json.Unmarshal(stdout, &inv); err != nil {
return nil, err
}
return inv.Puzzles, nil
}
// Puzzle returns a Puzzle structure for the given point value.
func (c FsCommandCategory) Puzzle(points int) (Puzzle, error) {
var p Puzzle
stdout, err := c.run("puzzle", strconv.Itoa(points))
if err != nil {
return p, err
}
if err := json.Unmarshal(stdout, &p); err != nil {
return p, err
}
p.computeAnswerHashes()
return p, nil
}
// Open returns an io.ReadCloser for the given filename.
func (c FsCommandCategory) Open(points int, filename string) (ReadSeekCloser, error) {
stdout, err := c.run("file", strconv.Itoa(points), filename)
return nopCloser{bytes.NewReader(stdout)}, err
}
// Answer checks whether an answer is correct.
func (c FsCommandCategory) Answer(points int, answer string) bool {
stdout, err := c.run("answer", strconv.Itoa(points), answer)
if err != nil {
log.Printf("ERROR: Answering %d points: %s", points, err)
return false
}
ans := AnswerResponse{}
if err := json.Unmarshal(stdout, &ans); err != nil {
log.Printf("ERROR: Answering %d points: %s", points, err)
return false
}
return ans.Correct
}