Skip to content

Commit

Permalink
labctl challenge catalog & list commands
Browse files Browse the repository at this point in the history
  • Loading branch information
iximiuz committed Sep 2, 2024
1 parent 82bff49 commit 53b0173
Show file tree
Hide file tree
Showing 5 changed files with 164 additions and 44 deletions.
81 changes: 81 additions & 0 deletions cmd/challenge/catalog.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,81 @@
package challenge

import (
"context"
"fmt"

"github.com/spf13/cobra"
"gopkg.in/yaml.v2"

"github.com/iximiuz/labctl/internal/api"
"github.com/iximiuz/labctl/internal/labcli"
)

type catalogOptions struct {
category string
}

func newCatalogCommand(cli labcli.CLI) *cobra.Command {
var opts catalogOptions

cmd := &cobra.Command{
Use: "catalog [--category <linux|containers|kubernetes|...>]",
Aliases: []string{"catalog"},
Short: "List challenges from the catalog, optionally filtered by category",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return labcli.WrapStatusError(runCatalogChallenges(cmd.Context(), cli, &opts))
},
}

flags := cmd.Flags()

flags.StringVar(
&opts.category,
"category",
"",
`Category to filter by - one of linux, containers, kubernetes, ... (an empty string means all)`,
)

return cmd
}

type catalogItem struct {
Name string `json:"name" yaml:"name"`
Title string `json:"title" yaml:"title"`
Description string `json:"description" yaml:"description"`
Categories []string `json:"categories" yaml:"categories"`
Tags []string `json:"tags,omitempty" yaml:"tags,omitempty"`
URL string `json:"url" yaml:"url"`
Attempted int `json:"attempted" yaml:"attempted"`
Completed int `json:"completed" yaml:"completed"`
}

func runCatalogChallenges(ctx context.Context, cli labcli.CLI, opts *catalogOptions) error {
challenges, err := cli.Client().ListChallenges(ctx, &api.ListChallengesOptions{
Category: opts.category,
})
if err != nil {
return fmt.Errorf("cannot list challenges: %w", err)
}

var items []catalogItem
for _, ch := range challenges {
items = append(items, catalogItem{
Name: ch.Name,
Title: ch.Title,
Description: ch.Description,
Categories: ch.Categories,
Tags: ch.Tags,
URL: ch.PageURL,
Attempted: ch.AttemptCount,
Completed: ch.CompletionCount,
})
}

if err := yaml.NewEncoder(cli.OutputStream()).Encode(items); err != nil {
return err
}

return nil
}
1 change: 1 addition & 0 deletions cmd/challenge/challenge.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ func NewCommand(cli labcli.CLI) *cobra.Command {
}

cmd.AddCommand(
newCatalogCommand(cli),
newListCommand(cli),
newStartCommand(cli),
newCompleteCommand(cli),
Expand Down
113 changes: 75 additions & 38 deletions cmd/challenge/list.go
Original file line number Diff line number Diff line change
Expand Up @@ -3,25 +3,27 @@ package challenge
import (
"context"
"fmt"
"io"
"strings"
"text/tabwriter"

"github.com/spf13/cobra"
"gopkg.in/yaml.v2"

"github.com/iximiuz/labctl/internal/api"
"github.com/iximiuz/labctl/internal/labcli"
)

type listOptions struct {
category string
quiet bool
}

func newListCommand(cli labcli.CLI) *cobra.Command {
var opts listOptions

cmd := &cobra.Command{
Use: "list [--category <linux|containers|kubernetes|...>]",
Use: "list",
Aliases: []string{"ls"},
Short: "List challenges, optionally filtered by category",
Short: "List running challenge attempts",
Args: cobra.NoArgs,
RunE: func(cmd *cobra.Command, args []string) error {
return labcli.WrapStatusError(runListChallenges(cmd.Context(), cli, &opts))
Expand All @@ -30,52 +32,87 @@ func newListCommand(cli labcli.CLI) *cobra.Command {

flags := cmd.Flags()

flags.StringVar(
&opts.category,
"category",
"",
`Category to filter by - one of linux, containers, kubernetes, ... (an empty string means all)`,
flags.BoolVarP(
&opts.quiet,
"quiet",
"q",
false,
`Only print challenge names`,
)

return cmd
}

type challengeListItem struct {
Name string `json:"name" yaml:"name"`
Title string `json:"title" yaml:"title"`
Description string `json:"description" yaml:"description"`
Categories []string `json:"categories" yaml:"categories"`
Tags []string `json:"tags,omitempty" yaml:"tags,omitempty"`
URL string `json:"url" yaml:"url"`
Attempted int `json:"attempted" yaml:"attempted"`
Completed int `json:"completed" yaml:"completed"`
}

func runListChallenges(ctx context.Context, cli labcli.CLI, opts *listOptions) error {
challenges, err := cli.Client().ListChallenges(ctx, &api.ListChallengesOptions{
Category: opts.category,
})
plays, err := cli.Client().ListPlays(ctx)
if err != nil {
return fmt.Errorf("cannot list challenges: %w", err)
return fmt.Errorf("cannot list plays: %w", err)
}

var items []challengeListItem
for _, ch := range challenges {
items = append(items, challengeListItem{
Name: ch.Name,
Title: ch.Title,
Description: ch.Description,
Categories: ch.Categories,
Tags: ch.Tags,
URL: ch.PageURL,
Attempted: ch.AttemptCount,
Completed: ch.CompletionCount,
})
var challenges []*api.Challenge
for _, play := range plays {
if !play.Active || play.ChallengeName == "" {
continue
}

chal, err := cli.Client().GetChallenge(ctx, play.ChallengeName)
if err != nil {
return fmt.Errorf("cannot get challenge %s: %w", play.ChallengeName, err)
}
challenges = append(challenges, chal)
}

if err := yaml.NewEncoder(cli.OutputStream()).Encode(items); err != nil {
return err
printer := newListPrinter(cli.OutputStream(), opts.quiet)
defer printer.flush()

printer.printHeader()

for _, chal := range challenges {
printer.printOne(chal)
}

return nil
}

type listPrinter struct {
quiet bool
header []string
writer *tabwriter.Writer
}

func newListPrinter(outStream io.Writer, quiet bool) *listPrinter {
header := []string{
"TITLE",
"URL",
}

return &listPrinter{
quiet: quiet,
header: header,
writer: tabwriter.NewWriter(outStream, 0, 4, 2, ' ', 0),
}
}

func (p *listPrinter) printHeader() {
if !p.quiet {
fmt.Fprintln(p.writer, strings.Join(p.header, "\t"))
}
}

func (p *listPrinter) printOne(chal *api.Challenge) {
if p.quiet {
fmt.Fprintln(p.writer, chal.Name)
return
}

fields := []string{
chal.Title,
chal.PageURL,
}

fmt.Fprintln(p.writer, strings.Join(fields, "\t"))
}

func (p *listPrinter) flush() {
p.writer.Flush()
}
9 changes: 5 additions & 4 deletions cmd/challenge/start.go
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,14 @@ func newStartCommand(cli labcli.CLI) *cobra.Command {
var opts startOptions

cmd := &cobra.Command{
Use: "start [flags] <challenge-url|challenge-name>",
Short: `Solve a challenge from the comfort of your local command line`,
Args: cobra.MaximumNArgs(1),
Use: "start [flags] <challenge-url|challenge-name>",
Short: `Solve a challenge from the comfort of your local command line`,
Aliases: []string{"solve"},
Args: cobra.MaximumNArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
if len(args) == 0 {
return labcli.NewStatusError(1,
"challenge name is required\n\nHint: Use `labctl challenge list` to see all available challenges",
"challenge name is required\n\nHint: Use `labctl challenge catalog` to see all available challenges",
)
}

Expand Down
4 changes: 2 additions & 2 deletions internal/api/challenges.go
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,8 @@ type ListChallengesOptions struct {
Category string
}

func (c *Client) ListChallenges(ctx context.Context, opts *ListChallengesOptions) ([]Challenge, error) {
var challenges []Challenge
func (c *Client) ListChallenges(ctx context.Context, opts *ListChallengesOptions) ([]*Challenge, error) {
var challenges []*Challenge
query := url.Values{}
if opts.Category != "" {
query.Set("category", opts.Category)
Expand Down

0 comments on commit 53b0173

Please sign in to comment.