diff --git a/cmd/transpile/category.go b/cmd/transpile/category.go index d742eea..f2ab73a 100644 --- a/cmd/transpile/category.go +++ b/cmd/transpile/category.go @@ -1,27 +1,42 @@ package main import ( + "fmt" + "io" "log" "strconv" "github.com/spf13/afero" ) -// NewCategory returns a new category for the given path in the given fs. -func NewCategory(fs afero.Fs, cat string) Category { - return Category{ - Fs: NewBasePathFs(fs, cat), +type NopReadCloser struct { +} + +func (n NopReadCloser) Read(b []byte) (int, error) { + return 0, nil +} +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) Category { + if info, err := fs.Stat("mkcategory"); (err == nil) && (info.Mode()&0100 != 0) { + return FsCommandCategory{fs: fs} + } else { + return FsCategory{fs: fs} } } -// Category represents an on-disk category. -type Category struct { - afero.Fs +type FsCategory struct { + fs afero.Fs } -// Puzzles returns a list of puzzle values. -func (c Category) Puzzles() ([]int, error) { - puzzleEntries, err := afero.ReadDir(c, ".") +// Category returns a list of puzzle values. +func (c FsCategory) Inventory() ([]int, error) { + puzzleEntries, err := afero.ReadDir(c.fs, ".") if err != nil { return nil, err } @@ -41,7 +56,44 @@ func (c Category) Puzzles() ([]int, error) { return puzzles, nil } -// PuzzleDir returns the PuzzleDir associated with points. -func (c Category) PuzzleDir(points int) *PuzzleDir { - return NewPuzzleDir(c.Fs, points) +func (c FsCategory) Puzzle(points int) (Puzzle, error) { + return NewFsPuzzle(c.fs, points).Puzzle() +} + +func (c FsCategory) Open(points int, filename string) (io.ReadCloser, error) { + return NewFsPuzzle(c.fs, points).Open(filename) +} + +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 +} + +type FsCommandCategory struct { + fs afero.Fs +} + +func (c FsCommandCategory) Inventory() ([]int, error) { + return nil, fmt.Errorf("Not implemented") +} + +func (c FsCommandCategory) Puzzle(points int) (Puzzle, error) { + return Puzzle{}, fmt.Errorf("Not implemented") +} + +func (c FsCommandCategory) Open(points int, filename string) (io.ReadCloser, error) { + return NopReadCloser{}, fmt.Errorf("Not implemented") +} + +func (c FsCommandCategory) Answer(points int, answer string) bool { + return false } diff --git a/cmd/transpile/main.go b/cmd/transpile/main.go index 4aeec1e..9562746 100644 --- a/cmd/transpile/main.go +++ b/cmd/transpile/main.go @@ -13,6 +13,54 @@ import ( "github.com/spf13/afero" ) +// 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) (io.ReadCloser, error) + + // Answer returns whether the given answer is correct. + Answer(points int, answer string) bool +} + +// PuzzleDef contains everything about a puzzle. +type Puzzle struct { + Pre struct { + Authors []string + Attachments []Attachment + Scripts []Attachment + AnswerPattern string + Body string + } + Post struct { + Objective string + Success struct { + Acceptable string + Mastery string + } + KSAs []string + } + Debug struct { + Log []string + Errors []string + Hints []string + Summary string + } + Answers []string +} + +// Attachment carries information about an attached file. +type Attachment struct { + Filename string // Filename presented as part of puzzle + FilesystemPath string // Filename in backing FS (URL, mothball, or local FS) + Listed bool // Whether this file is listed as an attachment +} + // T contains everything required for a transpilation invocation (across the nation). type T struct { // What action to take @@ -24,11 +72,6 @@ type T struct { Fs afero.Fs } -// NewCategory returns a new Category as specified by cat. -func (t *T) NewCategory(cat string) Category { - return NewCategory(t.Fs, cat) -} - // ParseArgs parses command-line arguments into T, returning the action to take func (t *T) ParseArgs() string { action := flag.String("action", "inventory", "Action to take: must be 'inventory', 'open', 'answer', or 'mothball'") @@ -66,7 +109,7 @@ func (t *T) PrintInventory() error { for _, ent := range dirEnts { if ent.IsDir() { c := t.NewCategory(ent.Name()) - if puzzles, err := c.Puzzles(); err != nil { + if puzzles, err := c.Inventory(); err != nil { log.Print(err) continue } else { @@ -86,11 +129,10 @@ func (t *T) PrintInventory() error { // Open writes a file to the writer. func (t *T) Open() error { c := t.NewCategory(t.Cat) - pd := c.PuzzleDir(t.Points) switch t.Filename { case "puzzle.json", "": - p, err := pd.Export() + p, err := c.Puzzle(t.Points) if err != nil { return err } @@ -100,7 +142,7 @@ func (t *T) Open() error { } t.w.Write(jp) default: - f, err := pd.Open(t.Filename) + f, err := c.Open(t.Points, t.Filename) if err != nil { return err } @@ -113,6 +155,10 @@ func (t *T) Open() error { return nil } +func (t *T) NewCategory(name string) Category { + return NewFsCategory(NewBasePathFs(t.Fs, name)) +} + func main() { // XXX: Convert puzzle.py to standalone thingies diff --git a/cmd/transpile/puzzle.go b/cmd/transpile/puzzle.go index 2202e4c..cf6d5a9 100644 --- a/cmd/transpile/puzzle.go +++ b/cmd/transpile/puzzle.go @@ -9,7 +9,6 @@ import ( "io" "log" "net/mail" - "os" "os/exec" "strconv" "strings" @@ -20,56 +19,27 @@ import ( "gopkg.in/yaml.v2" ) -// NewPuzzleDir returns a new PuzzleDir for points. -func NewPuzzleDir(fs afero.Fs, points int) *PuzzleDir { - pd := &PuzzleDir{ +// NewFsPuzzle returns a new FsPuzzle for points. +func NewFsPuzzle(fs afero.Fs, points int) *FsPuzzle { + fp := &FsPuzzle{ fs: NewBasePathFs(fs, strconv.Itoa(points)), } - // BUG(neale): Doesn't yet handle "puzzle.py" or "mkpuzzle" - return pd + return fp } -// PuzzleDir is a single puzzle's directory. -type PuzzleDir struct { - fs afero.Fs +// FsPuzzle is a single puzzle's directory. +type FsPuzzle struct { + fs afero.Fs mkpuzzle bool } -// Open returns a newly-opened file. -func (pd *PuzzleDir) Open(name string) (io.ReadCloser, error) { - // BUG(neale): You cannot open generated files in puzzles, only files actually on the disk - if _, err := pd.fs.Stat("" - return pd.fs.Open(name) -} - -// Export returns a Puzzle struct for the current puzzle. -func (pd *PuzzleDir) Export() (Puzzle, error) { - p, staticErr := pd.exportStatic() - if staticErr == nil { - return p, nil - } - - // Only fall through if the static files don't exist. Otherwise, report the error. - if !os.IsNotExist(staticErr) { - return p, staticErr - } - - if p, cmdErr := pd.exportCommand(); cmdErr == nil { - return p, nil - } else if os.IsNotExist(cmdErr) { - // If the command doesn't exist either, report the non-existence of the static file instead. - return p, staticErr - } else { - return p, cmdErr - } -} - -func (pd *PuzzleDir) exportStatic() (Puzzle, error) { - r, err := pd.fs.Open("puzzle.md") +// Puzzle returns a Puzzle struct for the current puzzle. +func (fp FsPuzzle) Puzzle() (Puzzle, error) { + r, err := fp.fs.Open("puzzle.md") if err != nil { var err2 error - if r, err2 = pd.fs.Open("puzzle.moth"); err2 != nil { + if r, err2 = fp.fs.Open("puzzle.moth"); err2 != nil { return Puzzle{}, err } } @@ -124,34 +94,9 @@ func (pd *PuzzleDir) exportStatic() (Puzzle, error) { return puzzle, nil } -func (pd *PuzzleDir) exportCommand() (Puzzle, error) { - bfs, ok := pd.fs.(*BasePathFs) - if !ok { - return Puzzle{}, fmt.Errorf("Fs won't resolve real paths for %v", pd) - } - mkpuzzlePath, err := bfs.RealPath("mkpuzzle") - if err != nil { - return Puzzle{}, err - } - log.Print(mkpuzzlePath) - - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - - cmd := exec.CommandContext(ctx, mkpuzzlePath) - stdout, err := cmd.Output() - if err != nil { - return Puzzle{}, err - } - - jsdec := json.NewDecoder(bytes.NewReader(stdout)) - jsdec.DisallowUnknownFields() - puzzle := Puzzle{} - if err := jsdec.Decode(&puzzle); err != nil { - return Puzzle{}, err - } - - return puzzle, nil +// Open returns a newly-opened file. +func (fp FsPuzzle) Open(name string) (io.ReadCloser, error) { + return fp.fs.Open(name) } func legacyAttachmentParser(val []string) []Attachment { @@ -175,39 +120,6 @@ func legacyAttachmentParser(val []string) []Attachment { return ret } -// Puzzle contains everything about a puzzle. -type Puzzle struct { - Pre struct { - Authors []string - Attachments []Attachment - Scripts []Attachment - AnswerPattern string - Body string - } - Post struct { - Objective string - Success struct { - Acceptable string - Mastery string - } - KSAs []string - } - Debug struct { - Log []string - Errors []string - Hints []string - Summary string - } - Answers []string -} - -// Attachment carries information about an attached file. -type Attachment struct { - Filename string // Filename presented as part of puzzle - FilesystemPath string // Filename in backing FS (URL, mothball, or local FS) - Listed bool // Whether this file is listed as an attachment -} - func yamlHeaderParser(r io.Reader) (Puzzle, error) { p := Puzzle{} decoder := yaml.NewDecoder(r) @@ -249,3 +161,49 @@ func rfc822HeaderParser(r io.Reader) (Puzzle, error) { return p, nil } + +func (fp FsPuzzle) Answer(answer string) bool { + return false +} + +type FsCommandPuzzle struct { + fs afero.Fs +} + +func (fp FsCommandPuzzle) Puzzle() (Puzzle, error) { + bfs, ok := fp.fs.(*BasePathFs) + if !ok { + return Puzzle{}, fmt.Errorf("Fs won't resolve real paths for %v", fp) + } + mkpuzzlePath, err := bfs.RealPath("mkpuzzle") + if err != nil { + return Puzzle{}, err + } + log.Print(mkpuzzlePath) + + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + + cmd := exec.CommandContext(ctx, mkpuzzlePath) + stdout, err := cmd.Output() + if err != nil { + return Puzzle{}, err + } + + jsdec := json.NewDecoder(bytes.NewReader(stdout)) + jsdec.DisallowUnknownFields() + puzzle := Puzzle{} + if err := jsdec.Decode(&puzzle); err != nil { + return Puzzle{}, err + } + + return puzzle, nil +} + +func (fp FsCommandPuzzle) Open(filename string) (io.ReadCloser, error) { + return NopReadCloser{}, fmt.Errorf("Not implemented") +} + +func (fp FsCommandPuzzle) Answer(answer string) bool { + return false +} diff --git a/cmd/transpile/puzzle_test.go b/cmd/transpile/puzzle_test.go index 3f1788f..9b6a6d9 100644 --- a/cmd/transpile/puzzle_test.go +++ b/cmd/transpile/puzzle_test.go @@ -14,8 +14,8 @@ func TestPuzzle(t *testing.T) { catFs := afero.NewBasePathFs(puzzleFs, "cat0") { - pd := NewPuzzleDir(catFs, 1) - p, err := pd.Export() + pd := NewFsPuzzle(catFs, 1) + p, err := pd.Puzzle() if err != nil { t.Error(err) } @@ -32,7 +32,7 @@ func TestPuzzle(t *testing.T) { } { - p, err := NewPuzzleDir(catFs, 2).Export() + p, err := NewFsPuzzle(catFs, 2).Puzzle() if err != nil { t.Error(err) } @@ -47,24 +47,24 @@ func TestPuzzle(t *testing.T) { } } - if _, err := NewPuzzleDir(catFs, 3).Export(); err != nil { + if _, err := NewFsPuzzle(catFs, 3).Puzzle(); err != nil { t.Error("Legacy `puzzle.moth` file:", err) } - if _, err := NewPuzzleDir(catFs, 99).Export(); err == nil { + if _, err := NewFsPuzzle(catFs, 99).Puzzle(); err == nil { t.Error("Non-existent puzzle", err) } - if _, err := NewPuzzleDir(catFs, 10).Export(); err == nil { + if _, err := NewFsPuzzle(catFs, 10).Puzzle(); err == nil { t.Error("Broken YAML") } - if _, err := NewPuzzleDir(catFs, 20).Export(); err == nil { + if _, err := NewFsPuzzle(catFs, 20).Puzzle(); err == nil { t.Error("Bad RFC822 header") } - if _, err := NewPuzzleDir(catFs, 21).Export(); err == nil { + if _, err := NewFsPuzzle(catFs, 21).Puzzle(); err == nil { t.Error("Boken RFC822 header") } - if p, err := NewPuzzleDir(catFs, 22).Export(); err == nil { + if p, err := NewFsPuzzle(catFs, 22).Puzzle(); err == nil { t.Error("Duplicate bodies") } else if !strings.HasPrefix(err.Error(), "Puzzle body present") { t.Log(p) @@ -75,16 +75,16 @@ func TestPuzzle(t *testing.T) { func TestFsPuzzle(t *testing.T) { catFs := afero.NewBasePathFs(afero.NewOsFs(), "testdata") - if _, err := NewPuzzleDir(catFs, 1).Export(); err != nil { + if _, err := NewFsPuzzle(catFs, 1).Puzzle(); err != nil { t.Error(err) } - if _, err := NewPuzzleDir(catFs, 2).Export(); err != nil { + if _, err := NewFsPuzzle(catFs, 2).Puzzle(); err != nil { t.Error(err) } - mkpuzzleDir := NewPuzzleDir(catFs, 3) - if _, err := mkpuzzleDir.Export(); err != nil { + mkpuzzleDir := NewFsPuzzle(catFs, 3) + if _, err := mkpuzzleDir.Puzzle(); err != nil { t.Error(err) } diff --git a/cmd/transpile/puzzlehandler.go b/cmd/transpile/puzzlehandler.go new file mode 100644 index 0000000..06ab7d0 --- /dev/null +++ b/cmd/transpile/puzzlehandler.go @@ -0,0 +1 @@ +package main