diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..2ee2528 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "WebFetch(domain:github.com)" + ], + "deny": [], + "ask": [] + } +} diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000..82813ad --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,33 @@ +repos: + # Go hooks + - repo: https://github.com/dnephin/pre-commit-golang + rev: v0.5.1 + hooks: + - id: go-fmt + - id: go-mod-tidy + - id: go-unit-tests + - id: golangci-lint + + # Markdown hooks + - repo: https://github.com/igorshubovych/markdownlint-cli + rev: v0.43.0 + hooks: + - id: markdownlint + args: ['--fix'] + + # Spellcheck + - repo: https://github.com/codespell-project/codespell + rev: v2.3.0 + hooks: + - id: codespell + name: Check spelling + + # General hooks + - repo: https://github.com/pre-commit/pre-commit-hooks + rev: v5.0.0 + hooks: + - id: trailing-whitespace + - id: end-of-file-fixer + - id: check-yaml + - id: check-added-large-files + - id: check-merge-conflict diff --git a/CLAUDE.md b/CLAUDE.md index bac6277..9a2cf69 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -52,14 +52,18 @@ goreleaser release --snapshot --clean goreleaser check ``` -### Linting +### Code Quality ```bash -# Markdown linting (runs in GitHub Actions, installed via brew) -markdownlint-cli2 "**/*.md" +# Run pre-commit hooks on all files +pre-commit run --all-files -# Go linting (if golangci-lint is installed) -golangci-lint run +# Run pre-commit hooks on staged files +pre-commit run + +# Manually run specific linters +golangci-lint run # Go linting +markdownlint-cli2 "**/*.md" # Markdown linting ``` ## Architecture @@ -99,6 +103,7 @@ The project follows a standard Go CLI application structure: - The root command is named "example" and should be renamed for your application - Debug logging is available via the `--debug` flag - Configuration precedence: flags > env vars > config file > defaults -- Logger instance is globally available as `cmd.Logger` +- Logger instance is passed via dependency injection to `NewRootCmd()` - Releases are automated via GoReleaser when tags are pushed to GitHub - Binary builds have CGO disabled for maximum portability +- Pre-commit hooks automatically run on git commit to ensure code quality diff --git a/README.md b/README.md index bf0c76e..977fa8c 100644 --- a/README.md +++ b/README.md @@ -184,7 +184,86 @@ go test -cover ./... go test -v ./... ``` -### Linting +### Code Quality + +#### Pre-commit Hooks + +This project uses [pre-commit](https://pre-commit.com/) to ensure code quality +and consistency. The hooks automatically run before each commit. + +##### Installing Pre-commit and Required Tools + +macOS (Homebrew) + +```bash +# Install all tools via Homebrew +brew install pre-commit golangci-lint markdownlint-cli codespell +``` + +Linux (Ubuntu/Debian) + +```bash +# Install available packages via apt +sudo apt update +sudo apt install pre-commit golang-golangci-lint + +# Install Node.js and markdownlint-cli +sudo apt install nodejs npm +sudo npm install -g markdownlint-cli + +# Install codespell via pip +sudo apt install python3-pip +pip3 install codespell +``` + +Windows + +```bash +# Using winget for golangci-lint +winget install GolangCI.golangci-lint + +# Using pip for Python tools (requires Python installed) +pip install pre-commit codespell + +# Using npm for markdownlint-cli (requires Node.js installed) +npm install -g markdownlint-cli +``` + +Alternative: Using pip and npm directly (all platforms) + +```bash +# Install Go linter +go install github.com/golangci/golangci-lint/cmd/golangci-lint@latest + +# Install Python tools +pip install pre-commit codespell + +# Install Node.js tools +npm install -g markdownlint-cli +``` + +##### Setting up pre-commit in your repository + +```bash +# Install the git hooks +pre-commit install + +# Run hooks manually on all files +pre-commit run --all-files + +# Run hooks on staged files only +pre-commit run +``` + +The following hooks are configured: + +- **Go**: `go fmt`, `go mod tidy`, unit tests, and `golangci-lint` +- **Markdown**: `markdownlint` with auto-fix +- **Spelling**: `codespell` for checking spelling errors +- **General**: trailing whitespace, end-of-file fixer, YAML validation, + large file detection, merge conflict detection + +#### Manual Linting ```bash # Install Go linter diff --git a/cmd/root.go b/cmd/root.go index 50819e5..f5c1e90 100644 --- a/cmd/root.go +++ b/cmd/root.go @@ -1,6 +1,9 @@ +// Package cmd implements the command-line interface using Cobra and Viper. package cmd import ( + "errors" + "fmt" "os" "github.com/charmbracelet/log" @@ -9,49 +12,70 @@ import ( "github.com/spf13/viper" ) -var ( - configFile string - Debug bool - Logger *log.Logger - EnvVar string -) +// ErrRootCmd indicates a failure in root command execution. +var ErrRootCmd = errors.New("failed to run example command") + +// NewRootCmd creates the root command with configured flags and pre-run hooks. +func NewRootCmd(logger *log.Logger) *cobra.Command { + cmd := &cobra.Command{ + Use: "example", + Short: "An example application.", + Long: "An example application, it doesn't do anything.", + PreRunE: func(cmd *cobra.Command, args []string) error { + if err := viper.BindPFlag("debug", cmd.PersistentFlags().Lookup("debug")); err != nil { + return fmt.Errorf("%w: %s", ErrRootCmd, err) + } + + return nil + }, + } -// RootCmd is the base command when called without any subcommands. -var RootCmd = &cobra.Command{ - Use: "example", - Short: "An example application.", - Long: "An example application, it doesn't do anything.", -} + var configFile string + var debug bool -// Execute adds initialization. -func Execute() { - err := RootCmd.Execute() - if err != nil { - Logger.Error("error running command") - os.Exit(1) - } + cmd.PersistentFlags().StringVar(&configFile, "config", "", "config file (default is $HOME/.config.toml)") + cmd.PersistentFlags().BoolVar(&debug, "debug", false, "enable debug mode") + + // cmd.AddCommand(NewChildCommand()) + + return cmd } -// init sets and binds flags. -func init() { - cobra.OnInitialize(initConfig) +// Execute initializes the logger, configuration, and runs the root command. +// It returns the command instance regardless of execution success. +func Execute() *cobra.Command { + logger := log.NewWithOptions(os.Stderr, log.Options{ + ReportCaller: true, + ReportTimestamp: true, + Level: log.WarnLevel, + }) - RootCmd.PersistentFlags().StringVar(&configFile, "config", "", "config file (default is $HOME/.config.toml)") - RootCmd.PersistentFlags().BoolVar(&Debug, "debug", false, "enable debug mode") + cobra.OnInitialize(func() { + initConfig(logger) + }) - _ = viper.BindPFlag("debug", RootCmd.PersistentFlags().Lookup("debug")) -} + cmd := NewRootCmd(logger) + + if err := cmd.Execute(); err != nil { + logger.Error(ErrRootCmd.Error(), "error", err) + + return nil + } -// initConfig loads env variables and the config file. -func initConfig() { - initLogger() + return cmd +} +// initConfig loads configuration from environment variables, config files, and flags. +// Configuration precedence: flags > env > config file > defaults. +func initConfig(logger *log.Logger) { if err := godotenv.Load(); err != nil { - Logger.Debug(".env file not found, using environment variables") + logger.Debug(".env file not found, using environment variables") } else { - Logger.Debug(".env file loaded successfully") + logger.Debug(".env file loaded successfully") } + configFile := viper.GetString("config") + if configFile != "" { viper.SetConfigFile(configFile) } else { @@ -65,33 +89,21 @@ func initConfig() { } viper.AutomaticEnv() - _ = viper.BindEnv("envVar", "ENV_VAR") + if err := viper.BindEnv("debug", "DEBUG"); err != nil { + logger.Error(ErrRootCmd.Error(), "error", err) + } if err := viper.ReadInConfig(); err != nil { if _, ok := err.(viper.ConfigFileNotFoundError); ok { - Logger.Debug("config file not found") + logger.Debug("config file not found") } else { - Logger.Error("error loading config file", "error", err) + logger.Error("error loading config file", "error", err) } } else { - Logger.Debug("using config file", "file", viper.ConfigFileUsed()) + logger.Debug("using config file", "file", viper.ConfigFileUsed()) } if viper.GetBool("debug") { - Debug = true - initLogger() - } -} - -// initLogger initializes the logger. -func initLogger() { - Logger = log.New(os.Stderr) - Logger.SetReportCaller(true) - Logger.SetReportTimestamp(true) - - if Debug { - Logger.SetLevel(log.DebugLevel) - } else { - Logger.SetLevel(log.WarnLevel) + logger.SetLevel(log.DebugLevel) } } diff --git a/cmd/root_test.go b/cmd/root_test.go new file mode 100644 index 0000000..0cb4c52 --- /dev/null +++ b/cmd/root_test.go @@ -0,0 +1,128 @@ +package cmd + +import ( + "io" + "os" + "path/filepath" + "testing" + + "github.com/charmbracelet/log" + "github.com/spf13/viper" +) + +func TestNewRootCmd(t *testing.T) { + t.Run("creates root command with correct configuration", func(t *testing.T) { + t.Parallel() + + logger := log.New(io.Discard) + cmd := NewRootCmd(logger) + + if cmd == nil { + t.Fatal("expected command to be created, got nil") + } + + if cmd.Use != "example" { + t.Errorf("expected Use to be 'example', got %q", cmd.Use) + } + + if cmd.Short != "An example application." { + t.Errorf("expected Short to be 'An example application.', got %q", cmd.Short) + } + + if cmd.Long != "An example application, it doesn't do anything." { + t.Errorf("expected Long to be 'An example application, it doesn't do anything.', got %q", cmd.Long) + } + + configFlag := cmd.PersistentFlags().Lookup("config") + if configFlag == nil { + t.Error("expected config flag to be set") + } + + debugFlag := cmd.PersistentFlags().Lookup("debug") + if debugFlag == nil { + t.Error("expected debug flag to be set") + } + + if cmd.PreRunE == nil { + t.Error("expected PreRunE to be set") + } + + // Check for subcommands. + // found := false + // for _, subCmd := range cmd.Commands() { + // if subCmd.Use == "export" { + // found = true + // break + // } + // } + // if !found { + // t.Error("expected export subcommand to be added") + // } + }) +} + +func TestInitConfig(t *testing.T) { + t.Run("updates logger level when debug is enabled", func(t *testing.T) { + logger := log.NewWithOptions(io.Discard, log.Options{ + ReportCaller: false, + ReportTimestamp: false, + Level: log.WarnLevel, + }) + + viper.Reset() + + tmpDir := t.TempDir() + configFile := filepath.Join(tmpDir, ".granola.toml") + configContent := `debug = true` + + if err := os.WriteFile(configFile, []byte(configContent), 0644); err != nil { + t.Fatalf("failed to write to test configFile: %v", err) + } + + viper.Set("config", configFile) + + initConfig(logger) + + if logger.GetLevel() != log.DebugLevel { + t.Errorf("expected logger level to be DebugLevel, got %v", logger.GetLevel()) + } + + if !viper.GetBool("debug") { + t.Error("expected debug mode to be enabled in viper") + } + }) + + t.Run("loads environment variables from .env file", func(t *testing.T) { + logger := log.New(io.Discard) + + viper.Reset() + + tmpDir := t.TempDir() + envFile := filepath.Join(tmpDir, ".env") + envContent := `DEBUG=true` + + if err := os.WriteFile(envFile, []byte(envContent), 0644); err != nil { + t.Fatalf("failed to write to test .env file: %v", err) + } + + oldWd, err := os.Getwd() + if err != nil { + t.Fatalf("failed to get the current working directory: %v", err) + } + + defer func() { + if err := os.Chdir(oldWd); err != nil { + t.Fatalf("failed to change to old working directory: %v", err) + } + }() + + if err := os.Chdir(tmpDir); err != nil { + t.Fatalf("failed to change to temp directory: %v", err) + } + + initConfig(logger) + if !viper.GetBool("debug") { + t.Error("expected DEBUG_MODE from .env to be loaded") + } + }) +} diff --git a/main.go b/main.go index b76bbb4..1ed4b40 100644 --- a/main.go +++ b/main.go @@ -9,7 +9,7 @@ import ( ) func main() { - if err := fang.Execute(context.Background(), cmd.RootCmd); err != nil { + if err := fang.Execute(context.Background(), cmd.Execute()); err != nil { os.Exit(1) } }