Skip to content

Commit

Permalink
feat: A simple CLI implementation (#81)
Browse files Browse the repository at this point in the history
*  Added version command
* Added CLI options and flags
* Update CI

---------

Signed-off-by: AlexNg <[email protected]>
  • Loading branch information
caffeine-addictt committed Aug 30, 2024
2 parents f76dd30 + f04ccad commit d281da1
Show file tree
Hide file tree
Showing 18 changed files with 406 additions and 2 deletions.
2 changes: 1 addition & 1 deletion .github/workflows/linting.yml
Original file line number Diff line number Diff line change
Expand Up @@ -61,7 +61,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
language-version: 1.23.0
language-version: latest

- name: Lint
run: |
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/test-worker.yml
Original file line number Diff line number Diff line change
Expand Up @@ -67,7 +67,7 @@ jobs:
- name: Set up Go
uses: actions/setup-go@v5
with:
language-version: 1.23.0
language-version: latest

- name: Test
run: |
Expand Down
8 changes: 8 additions & 0 deletions cmd/commands/root.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
package commands

import "github.com/spf13/cobra"

// To initialize all the commands as subcommands of root
func InitCommands(root *cobra.Command) {
root.AddCommand(VersionCmd)
}
14 changes: 14 additions & 0 deletions cmd/commands/version.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package commands

import (
"github.com/caffeine-addictt/template/cmd/global"
"github.com/spf13/cobra"
)

var VersionCmd = &cobra.Command{
Use: "version",
Aliases: []string{"ver"},
Run: func(cmd *cobra.Command, args []string) {
cmd.Println(global.Version)
},
}
24 changes: 24 additions & 0 deletions cmd/commands/version_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
package commands_test

import (
"testing"

"github.com/caffeine-addictt/template/cmd/commands"
"github.com/caffeine-addictt/template/cmd/global"
"github.com/caffeine-addictt/template/cmd/helpers"
)

func TestVersionOut(t *testing.T) {
stdout, stderr, err := helpers.ExecuteCommand(commands.VersionCmd, []string{})
if err != nil {
t.Fatal(err)
}

if stdout != global.Version+"\n" {
t.Errorf("Expected version %s, got %s", global.Version, stdout)
}

if stderr != "" {
t.Fatalf("Expected no stderr, got %s", err)
}
}
4 changes: 4 additions & 0 deletions cmd/global/version.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
package global

// The current app version
const Version = "2.0.0"
17 changes: 17 additions & 0 deletions cmd/global/version_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
package global_test

import (
"regexp"
"testing"

"github.com/caffeine-addictt/template/cmd/global"
)

// Regex taken from https://semver.org
var semverRegex = regexp.MustCompile(`^(0|[1-9]\d*)\.(0|[1-9]\d*)\.(0|[1-9]\d*)(?:-((?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*)(?:\.(?:0|[1-9]\d*|\d*[a-zA-Z-][0-9a-zA-Z-]*))*))?(?:\+([0-9a-zA-Z-]+(?:\.[0-9a-zA-Z-]+)*))?$`)

func TestFollowsSemVer(t *testing.T) {
if !semverRegex.MatchString(global.Version) {
t.Fatalf("%v does not follow semver", global.Version)
}
}
27 changes: 27 additions & 0 deletions cmd/helpers/testing.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
package helpers

import (
"bytes"
"strings"

"github.com/spf13/cobra"
)

// For testing command execution
func ExecuteCommand(cmd *cobra.Command, stdin []string, args ...string) (stdout, stderr string, e error) {
cmd.SetArgs(args)

out := bytes.Buffer{}
errout := bytes.Buffer{}

cmd.SetOut(&out)
cmd.SetErr(&errout)
cmd.SetIn(strings.NewReader(strings.Join(stdin, "\n")))

err := cmd.Execute()
if err != nil {
return out.String(), errout.String(), err
}

return out.String(), errout.String(), nil
}
52 changes: 52 additions & 0 deletions cmd/helpers/testing_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,52 @@
package helpers_test

import (
"testing"

"github.com/caffeine-addictt/template/cmd/helpers"
"github.com/spf13/cobra"
)

func TestExecuteCommandCapturesStderr(t *testing.T) {
msg := "I'm in stderr"
dummyCmd := cobra.Command{
Run: func(cmd *cobra.Command, args []string) {
cmd.PrintErr(msg)
},
}

stdout, stderr, err := helpers.ExecuteCommand(&dummyCmd, []string{}, "")
if err != nil {
t.Fatalf("Failed to execute command: %v", stderr)
}

if stderr != msg {
t.Fatalf("Expected stderr to be '%s', got '%s'", msg, stderr)
}

if stdout != "" {
t.Fatalf("Expected stdout to be empty, got '%s'", stdout)
}
}

func TestExecuteCommandCapturesStdout(t *testing.T) {
msg := "I'm in stdout"
dummyCmd := cobra.Command{
Run: func(cmd *cobra.Command, args []string) {
cmd.Print(msg)
},
}

stdout, stderr, err := helpers.ExecuteCommand(&dummyCmd, []string{}, "")
if err != nil {
t.Fatalf("Failed to execute command: %v", stderr)
}

if stdout != msg {
t.Fatalf("Expected stdout to be '%s', got '%s'", msg, stdout)
}

if stderr != "" {
t.Fatalf("Expected stderr to be empty, got '%s'", stderr)
}
}
12 changes: 12 additions & 0 deletions cmd/options/options.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package options

import "github.com/caffeine-addictt/template/cmd/utils/types"

type Options struct {
// The repository Url to use
// Should be this repository by default
Repo types.ValueGuard[string]

// Wheter or not debug mode should be enabled
Debug bool
}
11 changes: 11 additions & 0 deletions cmd/options/root.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
package options

import (
"github.com/caffeine-addictt/template/cmd/utils/types"
)

// The global options for the CLI
var Opts = Options{
Debug: false,
Repo: *types.NewValueGuardNoParsing("", "<repo>"),
}
31 changes: 31 additions & 0 deletions cmd/root.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
package cmd

import (
"log"

"github.com/caffeine-addictt/template/cmd/commands"
"github.com/caffeine-addictt/template/cmd/options"
"github.com/spf13/cobra"
)

// The Root command
var RootCmd = &cobra.Command{
Use: "template",
Short: "Let's make starting new projects feel like a breeze again.",
Long: "This tool helps you to create a new project from templates.",
}

// Setting up configuration
func init() {
RootCmd.PersistentFlags().BoolVarP(&options.Opts.Debug, "debug", "d", false, "Debug mode")
RootCmd.PersistentFlags().VarP(&options.Opts.Repo, "repo", "r", "Community source repository for templates")

commands.InitCommands(RootCmd)
}

// The main entry point for the command line tool
func Execute() {
if err := RootCmd.Execute(); err != nil {
log.Fatal(err)
}
}
23 changes: 23 additions & 0 deletions cmd/root_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
package cmd_test

import (
"testing"

"github.com/caffeine-addictt/template/cmd"
"github.com/caffeine-addictt/template/cmd/helpers"
)

func TestRootCommandCanRun(t *testing.T) {
stdout, stderr, err := helpers.ExecuteCommand(cmd.RootCmd, []string{})
if err != nil {
t.Fatalf("Failed to run root command: %v", err)
}

if stdout == "" {
t.Fatalf("Expected non-empty stdout, but got: %s", stdout)
}

if stderr != "" {
t.Fatalf("Expected empty stderr, but got: %s", stderr)
}
}
77 changes: 77 additions & 0 deletions cmd/utils/types/value_guard.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
package types

import (
"errors"
"fmt"
)

// A value guard to validate values being set
type ValueGuard[T any] struct {
// The value
value T
// The validation and mutation function
parseValue func(v T) (T, error)
// The human readable string type
typeString string
}

// Creating a new value guard with value parsing
func TryNewValueGuard[T any](value T, parseValue func(v T) (T, error), typeString string) (*ValueGuard[T], error) {
v := ValueGuard[T]{
parseValue: parseValue,
typeString: typeString,
}

if err := v.Set(value); err != nil {
return nil, err
}
return &v, nil
}

// Creating a new value guard without validating value
func NewValueGuard[T any](value T, parseValue func(v T) (T, error), typeString string) *ValueGuard[T] {
return &ValueGuard[T]{
value: value,
typeString: typeString,
parseValue: parseValue,
}
}

// Creating a new value guard without value parsing
func NewValueGuardNoParsing[T any](value T, typeString string) *ValueGuard[T] {
return &ValueGuard[T]{
value: value,
typeString: typeString,
}
}

// Access the underlying value
func (v *ValueGuard[T]) Value() T {
return v.value
}

// Mutate the underlying value
func (v *ValueGuard[T]) Set(value T) error {
if v.parseValue != nil {
value, err := v.parseValue(value)
if err != nil {
return errors.Join(fmt.Errorf("Cannot set value %v", value), err)
}

v.value = value
return nil
}

v.value = value
return nil
}

// Access the human readable string type
func (v *ValueGuard[T]) Type() string {
return v.typeString
}

// Return the string representation
func (v *ValueGuard[T]) String() string {
return fmt.Sprintf("%v", v.value)
}
Loading

0 comments on commit d281da1

Please sign in to comment.