5
\$\begingroup\$

TLDR

Python developer; first project in Go; looking for feedback :)

Repo

Overview

I just started learning Go (need to use it at work). As a fan of project-based learning, I put together a small program to practice writing Go and setting up a project from scratch.

As a Python developer, many of Go's elements are new to me (structs, interfaces, etc) - not to mention static typing and compilation.

I was hoping someone could review the code & project organization and share some constructive feedback. Thanks in advance!

What I am interested in:

  • Improvements to overall program structure
  • Improvements to non-idiomatic uses of the language
  • Improvements to usage of types
  • Error handling?
  • Any other feedback you might have!

Not interested in:

  • Replacing code with libraries - I know there some libraries that help with CLI interface, args parsing etc. but I wanted to build this the hard way since this is primarily a learning exercise :)

The Program

A simple CLI tool to help you manage and jump around your local projects.

Usage

Create Projects

$ pm add newproject ~/code/repos/newproject
$ pm add another ~/code/go/src/github.com/username/another

Remove project

$ pm remove newproject

pm add and pm remove is used to implicitly manage a json file with those configs. This config file is used by other commands to track "projects".

~/.pm.json

{
 "projects": [
  {
   "path": "~/code/repos/newproject",
   "name": "newproject"
  },
  {
   "path": "~/code/go/src/github.com/username/another",
   "name": "another"
  }
 ]
}

Go to project (finds project in config and starts new shell at config location

$ pm go newproject
Starting Shell...
~/code/repos/newproject $

$ pm go another
Starting Shell...
~/code/go/src/github.com/username/another $

List project (shows list of projects)

$ pm list
newproject
another

Code

The full program is on this repo but I copied all relevant files below.

Package Structure

pm/
 + main.go
 + cmd/     
   + main.go 
 + internal/
   + commands/
   + cli/
   + config/

Entry Point

// main.go
package main

import (
    "github.com/gtalarico/pm/cmd"
)

func main() {
    cmd.Run()
}

Cmd Entry Point

// cmd/main.go
package cmd

import (
    "os"

    "github.com/gtalarico/pm/internal/cli"
    "github.com/gtalarico/pm/internal/commands"
    "github.com/gtalarico/pm/internal/config"
)

func Run() {

    // Get all args, excluding binary name
    var args []string = os.Args[1:]
    cmdName, posArgs, err := cli.ValidateArgs(args)

    // No Args
    if err != nil {
        cli.ShowUsage()
        os.Exit(1)
    }

    // Checks for invalid command name
    // and number of args for a given command
    cmd, err := commands.GetCommand(cmdName)
    if err != nil {
        cli.Abort(err)
    }
    if cmd.NumArgs != len(posArgs) {
        cli.ShowCmdUsage(cmd.UsageMsg)
    }

    // Get Config
    config, err := config.ReadConfig()
    if err != nil {
        cli.Abort(err)
    }

    // Run Command
    cmd.Run(posArgs, config)

}

Commands Module

Types, command functions and definitions

Command Type

// internal/commands/types.go
package commands

import "github.com/gtalarico/pm/internal/config"

type Command struct {
    Name     string
    NumArgs  int
    UsageMsg string
    Run      func([]string, config.Config)
}

Commands Main

// internal/commands/commands.go
package commands

import (
    "fmt"
    "os"
    "path/filepath"

    "github.com/gtalarico/pm/internal/cli"
    "github.com/gtalarico/pm/internal/config"
    "github.com/pkg/errors"
)

func GetCommand(cmdName string) (command Command, err error) {
    for _, cmd := range Commands {
        if cmd.Name == cmdName {
            command = cmd
            return
        }
    }
    err = errors.New("invalid command")
    return
}

func CommandList(args []string, config config.Config) {
    cli.PrintProjects(config.Projects)
}

func CommandGo(args []string, cfg config.Config) {
    projectName := args[0]
    project, err := config.GetOneProject(projectName, cfg)
    if err != nil {
        cli.Abort(errors.Wrap(err, projectName))
    } else {
        cli.Shell(project.Path)
    }
}

func CommandAdd(args []string, cfg config.Config) {
    projectName := args[0]
    projectPath := args[1]
    absPath, err := filepath.Abs(projectPath)
    if err != nil {
        cli.Abort(errors.Wrap(err, "invalid path"))
    }
    newProject := config.Project{
        Name: projectName,
        Path: absPath,
    }
    projects := config.SearchProjects(projectName, cfg)
    if len(projects) == 0 {
        cfg.Projects = append(cfg.Projects, newProject)
    } else {
        for i, project := range cfg.Projects {
            if project.Name == newProject.Name {
                cfg.Projects[i] = newProject
            }
        }
    }
    writeError := config.WriteConfig(cfg)
    if writeError != nil {
        cli.Abort(writeError)
    }
    cli.PrintProjects(cfg.Projects)
}

func CommandRemove(args []string, cfg config.Config) {
    var projectToKeep []config.Project
    projectName := args[0]
    matchedProject, err := config.GetOneProject(projectName, cfg)
    if err != nil {
        cli.Abort(errors.Wrap(err, projectName))
    } else {
        for _, project := range cfg.Projects {
            if project.Name != matchedProject.Name {
                projectToKeep = append(projectToKeep, project)
            }
        }
        cfg.Projects = projectToKeep

        promptMsg := fmt.Sprintf("Delete '%s' [Y/n]? ", matchedProject.Name)
        confirm := cli.ConfirmPrompt(promptMsg, true)
        if confirm == false {
            os.Exit(0)
        }

        writeError := config.WriteConfig(cfg)
        if writeError != nil {
            cli.Abort(writeError)
        }
        cli.PrintProjects(projectToKeep)
    }
}

var Commands = [...]Command{
    Command{
        Name:     "list",
        NumArgs:  0, // pm list
        UsageMsg: "list",
        Run:      CommandList,
    },
    Command{
        Name:     "add",
        NumArgs:  2, // pm add <name> <path>
        UsageMsg: "add <project-name> <path>",
        Run:      CommandAdd,
    },
    Command{
        Name:     "remove",
        NumArgs:  1, // pm remove <query>
        UsageMsg: "remove <project-name>",
        Run:      CommandRemove,
    },
    Command{
        Name:     "go",
        NumArgs:  1, // pm go <project-name>
        UsageMsg: "go <project-name>",
        Run:      CommandGo,
    },
}

Cli Module

Usage

// internal/commands/usage.go
package cli

import (
    "fmt"
    "os"
)

// Shows usage of all available commands and exits
func ShowUsage() {
    usage :=
        `Usage:
    pm list
    pm go <project-name>
    pm add <project-name> <project-path>
    pm remove <project-name>`
    fmt.Fprintln(os.Stderr, usage)
}

// Shows usage of a command and exits
func ShowCmdUsage(usageMsg string) {
    fmt.Fprint(os.Stderr, fmt.Sprintf("Usage: pm %s", usageMsg))
    os.Exit(1)
}

Args

// internal/commands/args.go
package cli

import "github.com/pkg/errors"

func ValidateArgs(args []string) (cmdName string, posArgs []string, err error) {

    if len(args) == 0 {
        err = errors.New("missing command name")
        return
    }

    if len(args) > 1 {
        posArgs = args[1:]
    }
    cmdName = args[0]
    return

}

Output

// internal/cli/output.go
// Poorly named file...
package cli

import (
    "fmt"
    "log"
    "os"
    "strings"

    "github.com/gtalarico/pm/internal/config"
)

func Abort(err error) {
    // Show error message and exit with error
    fmt.Fprint(os.Stderr, err.Error())
    os.Exit(1)
}

// Prompts user to confirm a "y" or "n" and returns as boolean
func ConfirmPrompt(promptMsg string, default_ bool) bool {
    fmt.Print(promptMsg)

    var response string
    _, err := fmt.Scanln(&response)
    if err != nil {
        log.Fatal(err)
    }

    if response == "" {
        return default_
    }

    r := (strings.ToLower(response) == "y")
    return r
}

func PrintProjects(projects []config.Project) {
    for _, project := range projects {
        fmt.Println(project.Name)
    }
}

Shell Handles New Shell Process Spawning

// internal/cli/shell.go
package cli

import (
    "fmt"
    "os"
    "os/user"

    "github.com/pkg/errors"
)

func handleShellError() {
    shellError := recover()
    if shellError != nil {
        err := errors.New("shell error")
        Abort(err)
    }
}

func Shell(cwd string) {
    //technosophos.com/2014/07/11/start-an-interactive-shell-from-within-go.html
    defer handleShellError()

    fmt.Println("Starting new shell")
    fmt.Println("Use 'CTRL + C' or '$ exit' to terminate child shell")

    // Get the current user.
    me, err := user.Current()
    if err != nil {
        panic(err)
    }

    // If needed: sets env vars
    // os.Setenv("SOME_VAR", "1")

    // Transfer stdin, stdout, and stderr to the new process
    // and also set target directory for the shell to start in.
    pa := os.ProcAttr{
        Files: []*os.File{os.Stdin, os.Stdout, os.Stderr},
        Dir:   cwd,
    }

    // Start up a new shell.
    // Note that we supply "login" twice.
    // -fpl means "don't prompt for PW and pass through environment."
    // fmt.Print(">> Starting a new interactive shell")
    proc, err := os.StartProcess("/usr/bin/login", []string{"login", "-fpl", me.Username}, &pa)
    if err != nil {
        panic(err)
    }

    // Wait until user exits the shell
    state, err := proc.Wait()
    if err != nil {
        panic(err)
    }

    fmt.Printf("Exited Go Sub Shell\n %s\n", state.String())
}

Config Module

Handles reading and writing of config file, including types for json encoding/decoding.

Types

// internal/config/types.go
package config

type Config struct {
    Projects []Project `json:"projects"`
}

type Project struct {
    Path string `json:"path"`
    Name string `json:"name"`
}

Config - Main config functions

// internal/config/config.go
package config

import (
    "encoding/json"
    "fmt"
    "io/ioutil"
    "os"

    "github.com/pkg/errors"
)

const CFG_FILENAME = ".pm.json"

func WriteConfig(config Config) (err error) {
    path := ConfigFilepath()
    configJson, _ := json.MarshalIndent(config, "", " ")
    writeErr := ioutil.WriteFile(path, configJson, 0644)
    err = errors.Wrap(writeErr, path)
    return
}

func CreateConfig(path string) (cfg Config, err error) {
    projects := []Project{}
    cfg = Config{projects}
    err = WriteConfig(cfg)
    return
}

func ReadConfig() (cfg Config, err error) {
    path := ConfigFilepath()
    configBytes, readErr := ioutil.ReadFile(path)
    if readErr != nil {
        // Try creating new file in case of read error (eg. file not found)
        cfg, err = CreateConfig(path)
        return
    }
    parsingError := json.Unmarshal(configBytes, &cfg)
    if parsingError != nil {
        err = errors.Wrap(parsingError, path)
        return
    }
    return
}

// Gets user home path using environment variable '$HOME'
func UserHomePath() (path string) {
    path = os.Getenv("HOME")
    if path == "" {
        err, _ := fmt.Printf("Could not get home directory")
        panic(err)
    }
    return
}

// Gets the full config filepath
func ConfigFilepath() string {
    return UserHomePath() + fmt.Sprintf("/%s", CFG_FILENAME)
}

Search

// internal/config/search.go
package config

import (
    "errors"
    "fmt"
    "strings"
)

func SearchProjects(query string, config Config) []Project {
    var projects []Project
    for _, project := range config.Projects {
        if strings.Contains(project.Name, query) {
            projects = append(projects, project)
        }
    }
    return projects
}

func GetOneProject(query string, config Config) (project Project, err error) {
    projects := SearchProjects(query, config)
    numProjects := len(projects)

    if numProjects == 1 {
        project = projects[0]
    }
    if numProjects == 0 {
        err = errors.New("no match")
    }
    if numProjects > 1 {
        for _, project := range projects {
            fmt.Println(project.Name)
        }
        err = errors.New("multiple matches")
    }
    return
}
\$\endgroup\$

0

Your Answer

By clicking “Post Your Answer”, you agree to our terms of service and acknowledge that you have read and understand our privacy policy and code of conduct.

Browse other questions tagged or ask your own question.