Compare commits
14 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
138927baa5 | ||
|
|
2a90ae65b7 | ||
|
|
8e7fb0e5a3 | ||
|
|
d091fa6202 | ||
|
|
58ca902320 | ||
|
|
a0660e5802 | ||
|
|
fcdccdbe87 | ||
|
|
c2418a2737 | ||
|
|
175ad1e361 | ||
|
|
50afecea6d | ||
|
|
92a2260e21 | ||
|
|
e3fdbe9809 | ||
|
|
e66115f036 | ||
|
|
2aff7a3503 |
34 changed files with 8035 additions and 61 deletions
3
.gitignore
vendored
3
.gitignore
vendored
|
|
@ -17,7 +17,8 @@ dist/
|
|||
tasks
|
||||
/core
|
||||
/i18n-validate
|
||||
cmd/
|
||||
cmd/*
|
||||
!cmd/gocmd/
|
||||
.angular/
|
||||
|
||||
patch_cov.*
|
||||
|
|
|
|||
15
cmd/gocmd/cmd_commands.go
Normal file
15
cmd/gocmd/cmd_commands.go
Normal file
|
|
@ -0,0 +1,15 @@
|
|||
// Package gocmd provides Go development commands with enhanced output.
|
||||
//
|
||||
// Note: Package named gocmd because 'go' is a reserved keyword.
|
||||
//
|
||||
// Commands:
|
||||
// - test: Run tests with colour-coded coverage summary
|
||||
// - cov: Run tests with detailed coverage reports (HTML, thresholds)
|
||||
// - fmt: Format code using goimports or gofmt
|
||||
// - lint: Run golangci-lint
|
||||
// - install: Install binary to $GOPATH/bin
|
||||
// - mod: Module management (tidy, download, verify, graph)
|
||||
// - work: Workspace management (sync, init, use)
|
||||
//
|
||||
// Sets MACOSX_DEPLOYMENT_TARGET to suppress linker warnings on macOS.
|
||||
package gocmd
|
||||
177
cmd/gocmd/cmd_format.go
Normal file
177
cmd/gocmd/cmd_format.go
Normal file
|
|
@ -0,0 +1,177 @@
|
|||
package gocmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
var (
|
||||
fmtFix bool
|
||||
fmtDiff bool
|
||||
fmtCheck bool
|
||||
fmtAll bool
|
||||
)
|
||||
|
||||
func addGoFmtCommand(parent *cli.Command) {
|
||||
fmtCmd := &cli.Command{
|
||||
Use: "fmt",
|
||||
Short: "Format Go code",
|
||||
Long: "Format Go code using goimports or gofmt. By default only checks changed files.",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
// Get list of files to check
|
||||
var files []string
|
||||
if fmtAll {
|
||||
// Check all Go files
|
||||
files = []string{"."}
|
||||
} else {
|
||||
// Only check changed Go files (git-aware)
|
||||
files = getChangedGoFiles()
|
||||
if len(files) == 0 {
|
||||
cli.Print("%s\n", i18n.T("cmd.go.fmt.no_changes"))
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// Validate flag combinations
|
||||
if fmtCheck && fmtFix {
|
||||
return cli.Err("--check and --fix are mutually exclusive")
|
||||
}
|
||||
|
||||
fmtArgs := []string{}
|
||||
if fmtFix {
|
||||
fmtArgs = append(fmtArgs, "-w")
|
||||
}
|
||||
if fmtDiff {
|
||||
fmtArgs = append(fmtArgs, "-d")
|
||||
}
|
||||
if !fmtFix && !fmtDiff {
|
||||
fmtArgs = append(fmtArgs, "-l")
|
||||
}
|
||||
fmtArgs = append(fmtArgs, files...)
|
||||
|
||||
// Try goimports first, fall back to gofmt
|
||||
var execCmd *exec.Cmd
|
||||
if _, err := exec.LookPath("goimports"); err == nil {
|
||||
execCmd = exec.Command("goimports", fmtArgs...)
|
||||
} else {
|
||||
execCmd = exec.Command("gofmt", fmtArgs...)
|
||||
}
|
||||
|
||||
// For --check mode, capture output to detect unformatted files
|
||||
if fmtCheck {
|
||||
output, err := execCmd.CombinedOutput()
|
||||
if err != nil {
|
||||
_, _ = os.Stderr.Write(output)
|
||||
return err
|
||||
}
|
||||
if len(output) > 0 {
|
||||
_, _ = os.Stdout.Write(output)
|
||||
return cli.Err("files need formatting (use --fix)")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
execCmd.Stdout = os.Stdout
|
||||
execCmd.Stderr = os.Stderr
|
||||
return execCmd.Run()
|
||||
},
|
||||
}
|
||||
|
||||
fmtCmd.Flags().BoolVar(&fmtFix, "fix", false, i18n.T("common.flag.fix"))
|
||||
fmtCmd.Flags().BoolVar(&fmtDiff, "diff", false, i18n.T("common.flag.diff"))
|
||||
fmtCmd.Flags().BoolVar(&fmtCheck, "check", false, i18n.T("cmd.go.fmt.flag.check"))
|
||||
fmtCmd.Flags().BoolVar(&fmtAll, "all", false, i18n.T("cmd.go.fmt.flag.all"))
|
||||
|
||||
parent.AddCommand(fmtCmd)
|
||||
}
|
||||
|
||||
// getChangedGoFiles returns Go files that have been modified, staged, or are untracked.
|
||||
func getChangedGoFiles() []string {
|
||||
var files []string
|
||||
|
||||
// Get modified and staged files
|
||||
cmd := exec.Command("git", "diff", "--name-only", "--diff-filter=ACMR", "HEAD")
|
||||
output, err := cmd.Output()
|
||||
if err == nil {
|
||||
files = append(files, filterGoFiles(string(output))...)
|
||||
}
|
||||
|
||||
// Get untracked files
|
||||
cmd = exec.Command("git", "ls-files", "--others", "--exclude-standard")
|
||||
output, err = cmd.Output()
|
||||
if err == nil {
|
||||
files = append(files, filterGoFiles(string(output))...)
|
||||
}
|
||||
|
||||
// Deduplicate
|
||||
seen := make(map[string]bool)
|
||||
var unique []string
|
||||
for _, f := range files {
|
||||
if !seen[f] {
|
||||
seen[f] = true
|
||||
// Verify file exists (might have been deleted)
|
||||
if _, err := os.Stat(f); err == nil {
|
||||
unique = append(unique, f)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return unique
|
||||
}
|
||||
|
||||
// filterGoFiles filters a newline-separated list of files to only include .go files.
|
||||
func filterGoFiles(output string) []string {
|
||||
var goFiles []string
|
||||
scanner := bufio.NewScanner(strings.NewReader(output))
|
||||
for scanner.Scan() {
|
||||
file := strings.TrimSpace(scanner.Text())
|
||||
if file != "" && filepath.Ext(file) == ".go" {
|
||||
goFiles = append(goFiles, file)
|
||||
}
|
||||
}
|
||||
return goFiles
|
||||
}
|
||||
|
||||
var (
|
||||
lintFix bool
|
||||
lintAll bool
|
||||
)
|
||||
|
||||
func addGoLintCommand(parent *cli.Command) {
|
||||
lintCmd := &cli.Command{
|
||||
Use: "lint",
|
||||
Short: "Run golangci-lint",
|
||||
Long: "Run golangci-lint for comprehensive static analysis. By default only lints changed files.",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
lintArgs := []string{"run"}
|
||||
if lintFix {
|
||||
lintArgs = append(lintArgs, "--fix")
|
||||
}
|
||||
|
||||
if !lintAll {
|
||||
// Use --new-from-rev=HEAD to only report issues in uncommitted changes
|
||||
// This is golangci-lint's native way to handle incremental linting
|
||||
lintArgs = append(lintArgs, "--new-from-rev=HEAD")
|
||||
}
|
||||
|
||||
// Always lint all packages
|
||||
lintArgs = append(lintArgs, "./...")
|
||||
|
||||
execCmd := exec.Command("golangci-lint", lintArgs...)
|
||||
execCmd.Stdout = os.Stdout
|
||||
execCmd.Stderr = os.Stderr
|
||||
return execCmd.Run()
|
||||
},
|
||||
}
|
||||
|
||||
lintCmd.Flags().BoolVar(&lintFix, "fix", false, i18n.T("common.flag.fix"))
|
||||
lintCmd.Flags().BoolVar(&lintAll, "all", false, i18n.T("cmd.go.lint.flag.all"))
|
||||
|
||||
parent.AddCommand(lintCmd)
|
||||
}
|
||||
169
cmd/gocmd/cmd_fuzz.go
Normal file
169
cmd/gocmd/cmd_fuzz.go
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
package gocmd
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
var (
|
||||
fuzzDuration time.Duration
|
||||
fuzzPkg string
|
||||
fuzzRun string
|
||||
fuzzVerbose bool
|
||||
)
|
||||
|
||||
func addGoFuzzCommand(parent *cli.Command) {
|
||||
fuzzCmd := &cli.Command{
|
||||
Use: "fuzz",
|
||||
Short: "Run Go fuzz tests",
|
||||
Long: `Run Go fuzz tests with configurable duration.
|
||||
|
||||
Discovers Fuzz* functions across the project and runs each with go test -fuzz.
|
||||
|
||||
Examples:
|
||||
core go fuzz # Run all fuzz targets for 10s each
|
||||
core go fuzz --duration=30s # Run each target for 30s
|
||||
core go fuzz --pkg=./pkg/... # Fuzz specific package
|
||||
core go fuzz --run=FuzzE # Run only matching fuzz targets`,
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runGoFuzz(fuzzDuration, fuzzPkg, fuzzRun, fuzzVerbose)
|
||||
},
|
||||
}
|
||||
|
||||
fuzzCmd.Flags().DurationVar(&fuzzDuration, "duration", 10*time.Second, "Duration per fuzz target")
|
||||
fuzzCmd.Flags().StringVar(&fuzzPkg, "pkg", "", "Package to fuzz (default: auto-discover)")
|
||||
fuzzCmd.Flags().StringVar(&fuzzRun, "run", "", "Only run fuzz targets matching pattern")
|
||||
fuzzCmd.Flags().BoolVarP(&fuzzVerbose, "verbose", "v", false, "Verbose output")
|
||||
|
||||
parent.AddCommand(fuzzCmd)
|
||||
}
|
||||
|
||||
// fuzzTarget represents a discovered fuzz function and its package.
|
||||
type fuzzTarget struct {
|
||||
Pkg string
|
||||
Name string
|
||||
}
|
||||
|
||||
func runGoFuzz(duration time.Duration, pkg, run string, verbose bool) error {
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("fuzz")), i18n.ProgressSubject("run", "fuzz tests"))
|
||||
cli.Blank()
|
||||
|
||||
targets, err := discoverFuzzTargets(pkg, run)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "discover fuzz targets")
|
||||
}
|
||||
|
||||
if len(targets) == 0 {
|
||||
cli.Print(" %s no fuzz targets found\n", dimStyle.Render("—"))
|
||||
return nil
|
||||
}
|
||||
|
||||
cli.Print(" %s %d target(s), %s each\n", dimStyle.Render(i18n.Label("targets")), len(targets), duration)
|
||||
cli.Blank()
|
||||
|
||||
passed := 0
|
||||
failed := 0
|
||||
|
||||
for _, t := range targets {
|
||||
cli.Print(" %s %s in %s\n", dimStyle.Render("→"), t.Name, t.Pkg)
|
||||
|
||||
args := []string{
|
||||
"test",
|
||||
fmt.Sprintf("-fuzz=^%s$", t.Name),
|
||||
fmt.Sprintf("-fuzztime=%s", duration),
|
||||
"-run=^$", // Don't run unit tests
|
||||
}
|
||||
if verbose {
|
||||
args = append(args, "-v")
|
||||
}
|
||||
args = append(args, t.Pkg)
|
||||
|
||||
cmd := exec.Command("go", args...)
|
||||
cmd.Env = append(os.Environ(), "MACOSX_DEPLOYMENT_TARGET=26.0", "CGO_ENABLED=0")
|
||||
cmd.Dir, _ = os.Getwd()
|
||||
|
||||
output, runErr := cmd.CombinedOutput()
|
||||
outputStr := string(output)
|
||||
|
||||
if runErr != nil {
|
||||
failed++
|
||||
cli.Print(" %s %s\n", errorStyle.Render(cli.Glyph(":cross:")), runErr.Error())
|
||||
if outputStr != "" {
|
||||
cli.Text(outputStr)
|
||||
}
|
||||
} else {
|
||||
passed++
|
||||
cli.Print(" %s %s\n", successStyle.Render(cli.Glyph(":check:")), i18n.T("i18n.done.pass"))
|
||||
if verbose && outputStr != "" {
|
||||
cli.Text(outputStr)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
cli.Blank()
|
||||
if failed > 0 {
|
||||
cli.Print("%s %d passed, %d failed\n", errorStyle.Render(cli.Glyph(":cross:")), passed, failed)
|
||||
return cli.Err("fuzz: %d target(s) failed", failed)
|
||||
}
|
||||
|
||||
cli.Print("%s %d passed\n", successStyle.Render(cli.Glyph(":check:")), passed)
|
||||
return nil
|
||||
}
|
||||
|
||||
// discoverFuzzTargets scans for Fuzz* functions in test files.
|
||||
func discoverFuzzTargets(pkg, pattern string) ([]fuzzTarget, error) {
|
||||
root := "."
|
||||
if pkg != "" {
|
||||
// Convert Go package pattern to filesystem path
|
||||
root = strings.TrimPrefix(pkg, "./")
|
||||
root = strings.TrimSuffix(root, "/...")
|
||||
}
|
||||
|
||||
fuzzRe := regexp.MustCompile(`^func\s+(Fuzz\w+)\s*\(\s*\w+\s+\*testing\.F\s*\)`)
|
||||
var matchRe *regexp.Regexp
|
||||
if pattern != "" {
|
||||
var err error
|
||||
matchRe, err = regexp.Compile(pattern)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid --run pattern: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
var targets []fuzzTarget
|
||||
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if info.IsDir() || !strings.HasSuffix(info.Name(), "_test.go") {
|
||||
return nil
|
||||
}
|
||||
|
||||
data, readErr := os.ReadFile(path)
|
||||
if readErr != nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
dir := "./" + filepath.Dir(path)
|
||||
for line := range strings.SplitSeq(string(data), "\n") {
|
||||
m := fuzzRe.FindStringSubmatch(line)
|
||||
if m == nil {
|
||||
continue
|
||||
}
|
||||
name := m[1]
|
||||
if matchRe != nil && !matchRe.MatchString(name) {
|
||||
continue
|
||||
}
|
||||
targets = append(targets, fuzzTarget{Pkg: dir, Name: name})
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return targets, err
|
||||
}
|
||||
36
cmd/gocmd/cmd_go.go
Normal file
36
cmd/gocmd/cmd_go.go
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
// Package gocmd provides Go development commands.
|
||||
//
|
||||
// Note: Package named gocmd because 'go' is a reserved keyword.
|
||||
package gocmd
|
||||
|
||||
import (
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
// Style aliases for shared styles
|
||||
var (
|
||||
successStyle = cli.SuccessStyle
|
||||
errorStyle = cli.ErrorStyle
|
||||
dimStyle = cli.DimStyle
|
||||
)
|
||||
|
||||
// AddGoCommands adds Go development commands.
|
||||
func AddGoCommands(root *cli.Command) {
|
||||
goCmd := &cli.Command{
|
||||
Use: "go",
|
||||
Short: i18n.T("cmd.go.short"),
|
||||
Long: i18n.T("cmd.go.long"),
|
||||
}
|
||||
|
||||
root.AddCommand(goCmd)
|
||||
addGoQACommand(goCmd)
|
||||
addGoTestCommand(goCmd)
|
||||
addGoCovCommand(goCmd)
|
||||
addGoFmtCommand(goCmd)
|
||||
addGoLintCommand(goCmd)
|
||||
addGoInstallCommand(goCmd)
|
||||
addGoModCommand(goCmd)
|
||||
addGoWorkCommand(goCmd)
|
||||
addGoFuzzCommand(goCmd)
|
||||
}
|
||||
430
cmd/gocmd/cmd_gotest.go
Normal file
430
cmd/gocmd/cmd_gotest.go
Normal file
|
|
@ -0,0 +1,430 @@
|
|||
package gocmd
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
var (
|
||||
testCoverage bool
|
||||
testPkg string
|
||||
testRun string
|
||||
testShort bool
|
||||
testRace bool
|
||||
testJSON bool
|
||||
testVerbose bool
|
||||
)
|
||||
|
||||
func addGoTestCommand(parent *cli.Command) {
|
||||
testCmd := &cli.Command{
|
||||
Use: "test",
|
||||
Short: "Run Go tests",
|
||||
Long: "Run Go tests with optional coverage, filtering, and race detection",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
return runGoTest(testCoverage, testPkg, testRun, testShort, testRace, testJSON, testVerbose)
|
||||
},
|
||||
}
|
||||
|
||||
testCmd.Flags().BoolVar(&testCoverage, "coverage", false, "Generate coverage report")
|
||||
testCmd.Flags().StringVar(&testPkg, "pkg", "", "Package to test")
|
||||
testCmd.Flags().StringVar(&testRun, "run", "", "Run only tests matching pattern")
|
||||
testCmd.Flags().BoolVar(&testShort, "short", false, "Run only short tests")
|
||||
testCmd.Flags().BoolVar(&testRace, "race", false, "Enable race detector")
|
||||
testCmd.Flags().BoolVar(&testJSON, "json", false, "Output as JSON")
|
||||
testCmd.Flags().BoolVarP(&testVerbose, "verbose", "v", false, "Verbose output")
|
||||
|
||||
parent.AddCommand(testCmd)
|
||||
}
|
||||
|
||||
func runGoTest(coverage bool, pkg, run string, short, race, jsonOut, verbose bool) error {
|
||||
if pkg == "" {
|
||||
pkg = "./..."
|
||||
}
|
||||
|
||||
args := []string{"test"}
|
||||
|
||||
var covPath string
|
||||
if coverage {
|
||||
args = append(args, "-cover", "-covermode=atomic")
|
||||
covFile, err := os.CreateTemp("", "coverage-*.out")
|
||||
if err == nil {
|
||||
covPath = covFile.Name()
|
||||
_ = covFile.Close()
|
||||
args = append(args, "-coverprofile="+covPath)
|
||||
defer os.Remove(covPath)
|
||||
}
|
||||
}
|
||||
|
||||
if run != "" {
|
||||
args = append(args, "-run", run)
|
||||
}
|
||||
if short {
|
||||
args = append(args, "-short")
|
||||
}
|
||||
if race {
|
||||
args = append(args, "-race")
|
||||
}
|
||||
if verbose {
|
||||
args = append(args, "-v")
|
||||
}
|
||||
|
||||
args = append(args, pkg)
|
||||
|
||||
if !jsonOut {
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("test")), i18n.ProgressSubject("run", "tests"))
|
||||
cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("package")), pkg)
|
||||
cli.Blank()
|
||||
}
|
||||
|
||||
cmd := exec.Command("go", args...)
|
||||
cmd.Env = append(os.Environ(), "MACOSX_DEPLOYMENT_TARGET=26.0", "CGO_ENABLED=0")
|
||||
cmd.Dir, _ = os.Getwd()
|
||||
|
||||
output, err := cmd.CombinedOutput()
|
||||
outputStr := string(output)
|
||||
|
||||
// Filter linker warnings
|
||||
lines := strings.Split(outputStr, "\n")
|
||||
var filtered []string
|
||||
for _, line := range lines {
|
||||
if !strings.Contains(line, "ld: warning:") {
|
||||
filtered = append(filtered, line)
|
||||
}
|
||||
}
|
||||
outputStr = strings.Join(filtered, "\n")
|
||||
|
||||
// Parse results
|
||||
passed, failed, skipped := parseTestResults(outputStr)
|
||||
cov := parseOverallCoverage(outputStr)
|
||||
|
||||
if jsonOut {
|
||||
cli.Print(`{"passed":%d,"failed":%d,"skipped":%d,"coverage":%.1f,"exit_code":%d}`,
|
||||
passed, failed, skipped, cov, cmd.ProcessState.ExitCode())
|
||||
cli.Blank()
|
||||
return err
|
||||
}
|
||||
|
||||
// Print filtered output if verbose or failed
|
||||
if verbose || err != nil {
|
||||
cli.Text(outputStr)
|
||||
}
|
||||
|
||||
// Summary
|
||||
if err == nil {
|
||||
cli.Print(" %s %s\n", successStyle.Render(cli.Glyph(":check:")), i18n.T("i18n.count.test", passed)+" "+i18n.T("i18n.done.pass"))
|
||||
} else {
|
||||
cli.Print(" %s %s, %s\n", errorStyle.Render(cli.Glyph(":cross:")),
|
||||
i18n.T("i18n.count.test", passed)+" "+i18n.T("i18n.done.pass"),
|
||||
i18n.T("i18n.count.test", failed)+" "+i18n.T("i18n.done.fail"))
|
||||
}
|
||||
|
||||
if cov > 0 {
|
||||
cli.Print("\n %s %s\n", cli.KeyStyle.Render(i18n.Label("statements")), formatCoverage(cov))
|
||||
if covPath != "" {
|
||||
branchCov, err := calculateBlockCoverage(covPath)
|
||||
if err != nil {
|
||||
cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("branches")), cli.ErrorStyle.Render("unable to calculate"))
|
||||
} else {
|
||||
cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("branches")), formatCoverage(branchCov))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err == nil {
|
||||
cli.Print("\n%s\n", successStyle.Render(i18n.T("i18n.done.pass")))
|
||||
} else {
|
||||
cli.Print("\n%s\n", errorStyle.Render(i18n.T("i18n.done.fail")))
|
||||
}
|
||||
|
||||
return err
|
||||
}
|
||||
|
||||
func parseTestResults(output string) (passed, failed, skipped int) {
|
||||
passRe := regexp.MustCompile(`(?m)^ok\s+`)
|
||||
failRe := regexp.MustCompile(`(?m)^FAIL\s+`)
|
||||
skipRe := regexp.MustCompile(`(?m)^\?\s+`)
|
||||
|
||||
passed = len(passRe.FindAllString(output, -1))
|
||||
failed = len(failRe.FindAllString(output, -1))
|
||||
skipped = len(skipRe.FindAllString(output, -1))
|
||||
return
|
||||
}
|
||||
|
||||
func parseOverallCoverage(output string) float64 {
|
||||
re := regexp.MustCompile(`coverage:\s+([\d.]+)%`)
|
||||
matches := re.FindAllStringSubmatch(output, -1)
|
||||
if len(matches) == 0 {
|
||||
return 0
|
||||
}
|
||||
|
||||
var total float64
|
||||
for _, m := range matches {
|
||||
var cov float64
|
||||
_, _ = fmt.Sscanf(m[1], "%f", &cov)
|
||||
total += cov
|
||||
}
|
||||
return total / float64(len(matches))
|
||||
}
|
||||
|
||||
var (
|
||||
covPkg string
|
||||
covHTML bool
|
||||
covOpen bool
|
||||
covThreshold float64
|
||||
covBranchThreshold float64
|
||||
covOutput string
|
||||
)
|
||||
|
||||
func addGoCovCommand(parent *cli.Command) {
|
||||
covCmd := &cli.Command{
|
||||
Use: "cov",
|
||||
Short: "Run tests with coverage report",
|
||||
Long: "Run tests with detailed coverage reports, HTML output, and threshold checking",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
pkg := covPkg
|
||||
if pkg == "" {
|
||||
// Auto-discover packages with tests
|
||||
pkgs, err := findTestPackages(".")
|
||||
if err != nil {
|
||||
return cli.Wrap(err, i18n.T("i18n.fail.find", "test packages"))
|
||||
}
|
||||
if len(pkgs) == 0 {
|
||||
return errors.New("no test packages found")
|
||||
}
|
||||
pkg = strings.Join(pkgs, " ")
|
||||
}
|
||||
|
||||
// Create temp file for coverage data
|
||||
covFile, err := os.CreateTemp("", "coverage-*.out")
|
||||
if err != nil {
|
||||
return cli.Wrap(err, i18n.T("i18n.fail.create", "coverage file"))
|
||||
}
|
||||
covPath := covFile.Name()
|
||||
_ = covFile.Close()
|
||||
defer func() {
|
||||
if covOutput == "" {
|
||||
_ = os.Remove(covPath)
|
||||
} else {
|
||||
// Copy to output destination before removing
|
||||
src, _ := os.Open(covPath)
|
||||
dst, _ := os.Create(covOutput)
|
||||
if src != nil && dst != nil {
|
||||
_, _ = io.Copy(dst, src)
|
||||
_ = src.Close()
|
||||
_ = dst.Close()
|
||||
}
|
||||
_ = os.Remove(covPath)
|
||||
}
|
||||
}()
|
||||
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("coverage")), i18n.ProgressSubject("run", "tests"))
|
||||
// Truncate package list if too long for display
|
||||
displayPkg := pkg
|
||||
if len(displayPkg) > 60 {
|
||||
displayPkg = displayPkg[:57] + "..."
|
||||
}
|
||||
cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("package")), displayPkg)
|
||||
cli.Blank()
|
||||
|
||||
// Run tests with coverage
|
||||
// We need to split pkg into individual arguments if it contains spaces
|
||||
pkgArgs := strings.Fields(pkg)
|
||||
cmdArgs := append([]string{"test", "-coverprofile=" + covPath, "-covermode=atomic"}, pkgArgs...)
|
||||
|
||||
goCmd := exec.Command("go", cmdArgs...)
|
||||
goCmd.Env = append(os.Environ(), "MACOSX_DEPLOYMENT_TARGET=26.0")
|
||||
goCmd.Stdout = os.Stdout
|
||||
goCmd.Stderr = os.Stderr
|
||||
|
||||
testErr := goCmd.Run()
|
||||
|
||||
// Get coverage percentage
|
||||
coverCmd := exec.Command("go", "tool", "cover", "-func="+covPath)
|
||||
covOutput, err := coverCmd.Output()
|
||||
if err != nil {
|
||||
if testErr != nil {
|
||||
return testErr
|
||||
}
|
||||
return cli.Wrap(err, i18n.T("i18n.fail.get", "coverage"))
|
||||
}
|
||||
|
||||
// Parse total coverage from last line
|
||||
lines := strings.Split(strings.TrimSpace(string(covOutput)), "\n")
|
||||
var statementCov float64
|
||||
if len(lines) > 0 {
|
||||
lastLine := lines[len(lines)-1]
|
||||
// Format: "total: (statements) XX.X%"
|
||||
if strings.Contains(lastLine, "total:") {
|
||||
parts := strings.Fields(lastLine)
|
||||
if len(parts) >= 3 {
|
||||
covStr := strings.TrimSuffix(parts[len(parts)-1], "%")
|
||||
_, _ = fmt.Sscanf(covStr, "%f", &statementCov)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Calculate branch coverage (block coverage)
|
||||
branchCov, err := calculateBlockCoverage(covPath)
|
||||
if err != nil {
|
||||
return cli.Wrap(err, "calculate branch coverage")
|
||||
}
|
||||
|
||||
// Print coverage summary
|
||||
cli.Blank()
|
||||
cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("statements")), formatCoverage(statementCov))
|
||||
cli.Print(" %s %s\n", cli.KeyStyle.Render(i18n.Label("branches")), formatCoverage(branchCov))
|
||||
|
||||
// Generate HTML if requested
|
||||
if covHTML || covOpen {
|
||||
htmlPath := "coverage.html"
|
||||
htmlCmd := exec.Command("go", "tool", "cover", "-html="+covPath, "-o="+htmlPath)
|
||||
if err := htmlCmd.Run(); err != nil {
|
||||
return cli.Wrap(err, i18n.T("i18n.fail.generate", "HTML"))
|
||||
}
|
||||
cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("html")), htmlPath)
|
||||
|
||||
if covOpen {
|
||||
// Open in browser
|
||||
var openCmd *exec.Cmd
|
||||
switch {
|
||||
case exec.Command("which", "open").Run() == nil:
|
||||
openCmd = exec.Command("open", htmlPath)
|
||||
case exec.Command("which", "xdg-open").Run() == nil:
|
||||
openCmd = exec.Command("xdg-open", htmlPath)
|
||||
default:
|
||||
cli.Print(" %s\n", dimStyle.Render("Open coverage.html in your browser"))
|
||||
}
|
||||
if openCmd != nil {
|
||||
_ = openCmd.Run()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check thresholds
|
||||
if covThreshold > 0 && statementCov < covThreshold {
|
||||
cli.Print("\n%s Statements: %.1f%% < %.1f%%\n", errorStyle.Render(i18n.T("i18n.fail.meet", "threshold")), statementCov, covThreshold)
|
||||
return errors.New("statement coverage below threshold")
|
||||
}
|
||||
if covBranchThreshold > 0 && branchCov < covBranchThreshold {
|
||||
cli.Print("\n%s Branches: %.1f%% < %.1f%%\n", errorStyle.Render(i18n.T("i18n.fail.meet", "threshold")), branchCov, covBranchThreshold)
|
||||
return errors.New("branch coverage below threshold")
|
||||
}
|
||||
|
||||
if testErr != nil {
|
||||
return testErr
|
||||
}
|
||||
|
||||
cli.Print("\n%s\n", successStyle.Render(i18n.T("i18n.done.pass")))
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
covCmd.Flags().StringVar(&covPkg, "pkg", "", "Package to test")
|
||||
covCmd.Flags().BoolVar(&covHTML, "html", false, "Generate HTML report")
|
||||
covCmd.Flags().BoolVar(&covOpen, "open", false, "Open HTML report in browser")
|
||||
covCmd.Flags().Float64Var(&covThreshold, "threshold", 0, "Minimum statement coverage percentage")
|
||||
covCmd.Flags().Float64Var(&covBranchThreshold, "branch-threshold", 0, "Minimum branch coverage percentage")
|
||||
covCmd.Flags().StringVarP(&covOutput, "output", "o", "", "Output file for coverage profile")
|
||||
|
||||
parent.AddCommand(covCmd)
|
||||
}
|
||||
|
||||
// calculateBlockCoverage parses a Go coverage profile and returns the percentage of basic
|
||||
// blocks that have a non-zero execution count. Go's coverage profile contains one line per
|
||||
// basic block, where the last field is the execution count, not explicit branch coverage.
|
||||
// The resulting block coverage is used here only as a proxy for branch coverage; computing
|
||||
// true branch coverage would require more detailed control-flow analysis.
|
||||
func calculateBlockCoverage(path string) (float64, error) {
|
||||
file, err := os.Open(path)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
defer file.Close()
|
||||
|
||||
scanner := bufio.NewScanner(file)
|
||||
var totalBlocks, coveredBlocks int
|
||||
|
||||
// Skip the first line (mode: atomic/set/count)
|
||||
if !scanner.Scan() {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
if line == "" {
|
||||
continue
|
||||
}
|
||||
fields := strings.Fields(line)
|
||||
if len(fields) < 3 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Last field is the count
|
||||
count, err := strconv.Atoi(fields[len(fields)-1])
|
||||
if err != nil {
|
||||
continue
|
||||
}
|
||||
|
||||
totalBlocks++
|
||||
if count > 0 {
|
||||
coveredBlocks++
|
||||
}
|
||||
}
|
||||
|
||||
if err := scanner.Err(); err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
if totalBlocks == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
return (float64(coveredBlocks) / float64(totalBlocks)) * 100, nil
|
||||
}
|
||||
|
||||
func findTestPackages(root string) ([]string, error) {
|
||||
pkgMap := make(map[string]bool)
|
||||
err := filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if !info.IsDir() && strings.HasSuffix(info.Name(), "_test.go") {
|
||||
dir := filepath.Dir(path)
|
||||
if !strings.HasPrefix(dir, ".") {
|
||||
dir = "./" + dir
|
||||
}
|
||||
pkgMap[dir] = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var pkgs []string
|
||||
for pkg := range pkgMap {
|
||||
pkgs = append(pkgs, pkg)
|
||||
}
|
||||
return pkgs, nil
|
||||
}
|
||||
|
||||
func formatCoverage(cov float64) string {
|
||||
s := fmt.Sprintf("%.1f%%", cov)
|
||||
if cov >= 80 {
|
||||
return cli.SuccessStyle.Render(s)
|
||||
} else if cov >= 50 {
|
||||
return cli.WarningStyle.Render(s)
|
||||
}
|
||||
return cli.ErrorStyle.Render(s)
|
||||
}
|
||||
639
cmd/gocmd/cmd_qa.go
Normal file
639
cmd/gocmd/cmd_qa.go
Normal file
|
|
@ -0,0 +1,639 @@
|
|||
package gocmd
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"regexp"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"forge.lthn.ai/core/cli/cmd/qa"
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
// QA command flags - comprehensive options for all agents
|
||||
var (
|
||||
qaFix bool
|
||||
qaChanged bool
|
||||
qaAll bool
|
||||
qaSkip string
|
||||
qaOnly string
|
||||
qaCoverage bool
|
||||
qaThreshold float64
|
||||
qaBranchThreshold float64
|
||||
qaDocblockThreshold float64
|
||||
qaJSON bool
|
||||
qaVerbose bool
|
||||
qaQuiet bool
|
||||
qaTimeout time.Duration
|
||||
qaShort bool
|
||||
qaRace bool
|
||||
qaBench bool
|
||||
qaFailFast bool
|
||||
qaMod bool
|
||||
qaCI bool
|
||||
)
|
||||
|
||||
func addGoQACommand(parent *cli.Command) {
|
||||
qaCmd := &cli.Command{
|
||||
Use: "qa",
|
||||
Short: "Run QA checks",
|
||||
Long: `Run comprehensive code quality checks for Go projects.
|
||||
|
||||
Checks available: fmt, vet, lint, test, race, fuzz, vuln, sec, bench, docblock
|
||||
|
||||
Examples:
|
||||
core go qa # Default: fmt, lint, test
|
||||
core go qa --fix # Auto-fix formatting and lint issues
|
||||
core go qa --only=test # Only run tests
|
||||
core go qa --skip=vuln,sec # Skip vulnerability and security scans
|
||||
core go qa --coverage --threshold=80 # Require 80% coverage
|
||||
core go qa --changed # Only check changed files (git-aware)
|
||||
core go qa --ci # CI mode: strict, coverage, fail-fast
|
||||
core go qa --race --short # Quick tests with race detection
|
||||
core go qa --json # Output results as JSON`,
|
||||
RunE: runGoQA,
|
||||
}
|
||||
|
||||
// Fix and modification flags (persistent so subcommands inherit them)
|
||||
qaCmd.PersistentFlags().BoolVar(&qaFix, "fix", false, "Auto-fix issues where possible")
|
||||
qaCmd.PersistentFlags().BoolVar(&qaMod, "mod", false, "Run go mod tidy before checks")
|
||||
|
||||
// Scope flags
|
||||
qaCmd.PersistentFlags().BoolVar(&qaChanged, "changed", false, "Only check changed files (git-aware)")
|
||||
qaCmd.PersistentFlags().BoolVar(&qaAll, "all", false, "Check all files (override git-aware)")
|
||||
qaCmd.PersistentFlags().StringVar(&qaSkip, "skip", "", "Skip checks (comma-separated: fmt,vet,lint,test,race,fuzz,vuln,sec,bench)")
|
||||
qaCmd.PersistentFlags().StringVar(&qaOnly, "only", "", "Only run these checks (comma-separated)")
|
||||
|
||||
// Coverage flags
|
||||
qaCmd.PersistentFlags().BoolVar(&qaCoverage, "coverage", false, "Include coverage reporting")
|
||||
qaCmd.PersistentFlags().BoolVarP(&qaCoverage, "cov", "c", false, "Include coverage reporting (shorthand)")
|
||||
qaCmd.PersistentFlags().Float64Var(&qaThreshold, "threshold", 0, "Minimum statement coverage threshold (0-100), fail if below")
|
||||
qaCmd.PersistentFlags().Float64Var(&qaBranchThreshold, "branch-threshold", 0, "Minimum branch coverage threshold (0-100), fail if below")
|
||||
qaCmd.PersistentFlags().Float64Var(&qaDocblockThreshold, "docblock-threshold", 80, "Minimum docblock coverage threshold (0-100)")
|
||||
|
||||
// Test flags
|
||||
qaCmd.PersistentFlags().BoolVar(&qaShort, "short", false, "Run tests with -short flag")
|
||||
qaCmd.PersistentFlags().BoolVar(&qaRace, "race", false, "Include race detection in tests")
|
||||
qaCmd.PersistentFlags().BoolVar(&qaBench, "bench", false, "Include benchmarks")
|
||||
|
||||
// Output flags
|
||||
qaCmd.PersistentFlags().BoolVar(&qaJSON, "json", false, "Output results as JSON")
|
||||
qaCmd.PersistentFlags().BoolVarP(&qaVerbose, "verbose", "v", false, "Show verbose output")
|
||||
qaCmd.PersistentFlags().BoolVarP(&qaQuiet, "quiet", "q", false, "Only show errors")
|
||||
|
||||
// Control flags
|
||||
qaCmd.PersistentFlags().DurationVar(&qaTimeout, "timeout", 10*time.Minute, "Timeout for all checks")
|
||||
qaCmd.PersistentFlags().BoolVar(&qaFailFast, "fail-fast", false, "Stop on first failure")
|
||||
qaCmd.PersistentFlags().BoolVar(&qaCI, "ci", false, "CI mode: strict checks, coverage required, fail-fast")
|
||||
|
||||
// Preset subcommands for convenience
|
||||
qaCmd.AddCommand(&cli.Command{
|
||||
Use: "quick",
|
||||
Short: "Quick QA: fmt, vet, lint (no tests)",
|
||||
RunE: func(cmd *cli.Command, args []string) error { qaOnly = "fmt,vet,lint"; return runGoQA(cmd, args) },
|
||||
})
|
||||
|
||||
qaCmd.AddCommand(&cli.Command{
|
||||
Use: "full",
|
||||
Short: "Full QA: all checks including race, vuln, sec",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
qaOnly = "fmt,vet,lint,test,race,vuln,sec"
|
||||
return runGoQA(cmd, args)
|
||||
},
|
||||
})
|
||||
|
||||
qaCmd.AddCommand(&cli.Command{
|
||||
Use: "pre-commit",
|
||||
Short: "Pre-commit checks: fmt --fix, lint --fix, test --short",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
qaFix = true
|
||||
qaShort = true
|
||||
qaOnly = "fmt,lint,test"
|
||||
return runGoQA(cmd, args)
|
||||
},
|
||||
})
|
||||
|
||||
qaCmd.AddCommand(&cli.Command{
|
||||
Use: "pr",
|
||||
Short: "PR checks: full QA with coverage threshold",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
qaCoverage = true
|
||||
if qaThreshold == 0 {
|
||||
qaThreshold = 50 // Default PR threshold
|
||||
}
|
||||
qaOnly = "fmt,vet,lint,test"
|
||||
return runGoQA(cmd, args)
|
||||
},
|
||||
})
|
||||
|
||||
parent.AddCommand(qaCmd)
|
||||
}
|
||||
|
||||
// QAResult holds the result of a QA run for JSON output
|
||||
type QAResult struct {
|
||||
Success bool `json:"success"`
|
||||
Duration string `json:"duration"`
|
||||
Checks []CheckResult `json:"checks"`
|
||||
Coverage *float64 `json:"coverage,omitempty"`
|
||||
BranchCoverage *float64 `json:"branch_coverage,omitempty"`
|
||||
Threshold *float64 `json:"threshold,omitempty"`
|
||||
BranchThreshold *float64 `json:"branch_threshold,omitempty"`
|
||||
}
|
||||
|
||||
// CheckResult holds the result of a single check
|
||||
type CheckResult struct {
|
||||
Name string `json:"name"`
|
||||
Passed bool `json:"passed"`
|
||||
Duration string `json:"duration"`
|
||||
Error string `json:"error,omitempty"`
|
||||
Output string `json:"output,omitempty"`
|
||||
FixHint string `json:"fix_hint,omitempty"`
|
||||
}
|
||||
|
||||
func runGoQA(cmd *cli.Command, args []string) error {
|
||||
// Apply CI mode defaults
|
||||
if qaCI {
|
||||
qaCoverage = true
|
||||
qaFailFast = true
|
||||
if qaThreshold == 0 {
|
||||
qaThreshold = 50
|
||||
}
|
||||
}
|
||||
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return cli.Wrap(err, i18n.T("i18n.fail.get", "working directory"))
|
||||
}
|
||||
|
||||
// Detect if this is a Go project
|
||||
if _, err := os.Stat("go.mod"); os.IsNotExist(err) {
|
||||
return cli.Err("not a Go project (no go.mod found)")
|
||||
}
|
||||
|
||||
// Determine which checks to run
|
||||
checkNames := determineChecks()
|
||||
|
||||
if !qaJSON && !qaQuiet {
|
||||
cli.Print("%s %s\n\n", cli.DimStyle.Render(i18n.Label("qa")), i18n.ProgressSubject("run", "Go QA"))
|
||||
}
|
||||
|
||||
// Run go mod tidy if requested
|
||||
if qaMod {
|
||||
if !qaQuiet {
|
||||
cli.Print("%s %s\n", cli.DimStyle.Render("→"), "Running go mod tidy...")
|
||||
}
|
||||
modCmd := exec.Command("go", "mod", "tidy")
|
||||
modCmd.Dir = cwd
|
||||
if err := modCmd.Run(); err != nil {
|
||||
return cli.Wrap(err, "go mod tidy failed")
|
||||
}
|
||||
}
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), qaTimeout)
|
||||
defer cancel()
|
||||
|
||||
startTime := time.Now()
|
||||
checks := buildChecks(checkNames)
|
||||
results := make([]CheckResult, 0, len(checks))
|
||||
passed := 0
|
||||
failed := 0
|
||||
|
||||
for _, check := range checks {
|
||||
checkStart := time.Now()
|
||||
|
||||
if !qaJSON && !qaQuiet {
|
||||
cli.Print("%s %s\n", cli.DimStyle.Render("→"), i18n.Progress(check.Name))
|
||||
}
|
||||
|
||||
output, err := runCheckCapture(ctx, cwd, check)
|
||||
checkDuration := time.Since(checkStart)
|
||||
|
||||
result := CheckResult{
|
||||
Name: check.Name,
|
||||
Duration: checkDuration.Round(time.Millisecond).String(),
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
result.Passed = false
|
||||
result.Error = err.Error()
|
||||
if qaVerbose {
|
||||
result.Output = output
|
||||
}
|
||||
result.FixHint = fixHintFor(check.Name, output)
|
||||
failed++
|
||||
|
||||
if !qaJSON && !qaQuiet {
|
||||
cli.Print(" %s %s\n", cli.ErrorStyle.Render(cli.Glyph(":cross:")), err.Error())
|
||||
if qaVerbose && output != "" {
|
||||
cli.Text(output)
|
||||
}
|
||||
if result.FixHint != "" {
|
||||
cli.Hint("fix", result.FixHint)
|
||||
}
|
||||
}
|
||||
|
||||
if qaFailFast {
|
||||
results = append(results, result)
|
||||
break
|
||||
}
|
||||
} else {
|
||||
result.Passed = true
|
||||
if qaVerbose {
|
||||
result.Output = output
|
||||
}
|
||||
passed++
|
||||
|
||||
if !qaJSON && !qaQuiet {
|
||||
cli.Print(" %s %s\n", cli.SuccessStyle.Render(cli.Glyph(":check:")), i18n.T("i18n.done.pass"))
|
||||
}
|
||||
}
|
||||
|
||||
results = append(results, result)
|
||||
}
|
||||
|
||||
// Run coverage if requested
|
||||
var coverageVal *float64
|
||||
var branchVal *float64
|
||||
if qaCoverage && !qaFailFast || (qaCoverage && failed == 0) {
|
||||
cov, branch, err := runCoverage(ctx, cwd)
|
||||
if err == nil {
|
||||
coverageVal = &cov
|
||||
branchVal = &branch
|
||||
if !qaJSON && !qaQuiet {
|
||||
cli.Print("\n%s %.1f%%\n", cli.DimStyle.Render("Statement Coverage:"), cov)
|
||||
cli.Print("%s %.1f%%\n", cli.DimStyle.Render("Branch Coverage:"), branch)
|
||||
}
|
||||
if qaThreshold > 0 && cov < qaThreshold {
|
||||
failed++
|
||||
if !qaJSON && !qaQuiet {
|
||||
cli.Print(" %s Statement coverage %.1f%% below threshold %.1f%%\n",
|
||||
cli.ErrorStyle.Render(cli.Glyph(":cross:")), cov, qaThreshold)
|
||||
}
|
||||
}
|
||||
if qaBranchThreshold > 0 && branch < qaBranchThreshold {
|
||||
failed++
|
||||
if !qaJSON && !qaQuiet {
|
||||
cli.Print(" %s Branch coverage %.1f%% below threshold %.1f%%\n",
|
||||
cli.ErrorStyle.Render(cli.Glyph(":cross:")), branch, qaBranchThreshold)
|
||||
}
|
||||
}
|
||||
|
||||
if failed > 0 && !qaJSON && !qaQuiet {
|
||||
cli.Hint("fix", "Run 'core go cov --open' to see uncovered lines, then add tests.")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
duration := time.Since(startTime).Round(time.Millisecond)
|
||||
|
||||
// JSON output
|
||||
if qaJSON {
|
||||
qaResult := QAResult{
|
||||
Success: failed == 0,
|
||||
Duration: duration.String(),
|
||||
Checks: results,
|
||||
Coverage: coverageVal,
|
||||
BranchCoverage: branchVal,
|
||||
}
|
||||
if qaThreshold > 0 {
|
||||
qaResult.Threshold = &qaThreshold
|
||||
}
|
||||
if qaBranchThreshold > 0 {
|
||||
qaResult.BranchThreshold = &qaBranchThreshold
|
||||
}
|
||||
enc := json.NewEncoder(os.Stdout)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(qaResult)
|
||||
}
|
||||
|
||||
// Summary
|
||||
if !qaQuiet {
|
||||
cli.Blank()
|
||||
if failed > 0 {
|
||||
cli.Print("%s %s, %s (%s)\n",
|
||||
cli.ErrorStyle.Render(cli.Glyph(":cross:")),
|
||||
i18n.T("i18n.count.check", passed)+" "+i18n.T("i18n.done.pass"),
|
||||
i18n.T("i18n.count.check", failed)+" "+i18n.T("i18n.done.fail"),
|
||||
duration)
|
||||
} else {
|
||||
cli.Print("%s %s (%s)\n",
|
||||
cli.SuccessStyle.Render(cli.Glyph(":check:")),
|
||||
i18n.T("i18n.count.check", passed)+" "+i18n.T("i18n.done.pass"),
|
||||
duration)
|
||||
}
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
return cli.Err("QA checks failed: %d passed, %d failed", passed, failed)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func determineChecks() []string {
|
||||
// If --only is specified, use those
|
||||
if qaOnly != "" {
|
||||
return strings.Split(qaOnly, ",")
|
||||
}
|
||||
|
||||
// Default checks
|
||||
checks := []string{"fmt", "lint", "test", "fuzz", "docblock"}
|
||||
|
||||
// Add race if requested
|
||||
if qaRace {
|
||||
// Replace test with race (which includes test)
|
||||
for i, c := range checks {
|
||||
if c == "test" {
|
||||
checks[i] = "race"
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add bench if requested
|
||||
if qaBench {
|
||||
checks = append(checks, "bench")
|
||||
}
|
||||
|
||||
// Remove skipped checks
|
||||
if qaSkip != "" {
|
||||
skipMap := make(map[string]bool)
|
||||
for _, s := range strings.Split(qaSkip, ",") {
|
||||
skipMap[strings.TrimSpace(s)] = true
|
||||
}
|
||||
filtered := make([]string, 0, len(checks))
|
||||
for _, c := range checks {
|
||||
if !skipMap[c] {
|
||||
filtered = append(filtered, c)
|
||||
}
|
||||
}
|
||||
checks = filtered
|
||||
}
|
||||
|
||||
return checks
|
||||
}
|
||||
|
||||
// QACheck represents a single QA check.
|
||||
type QACheck struct {
|
||||
Name string
|
||||
Command string
|
||||
Args []string
|
||||
}
|
||||
|
||||
func buildChecks(names []string) []QACheck {
|
||||
var checks []QACheck
|
||||
for _, name := range names {
|
||||
name = strings.TrimSpace(name)
|
||||
check := buildCheck(name)
|
||||
if check.Command != "" {
|
||||
checks = append(checks, check)
|
||||
}
|
||||
}
|
||||
return checks
|
||||
}
|
||||
|
||||
func buildCheck(name string) QACheck {
|
||||
switch name {
|
||||
case "fmt", "format":
|
||||
args := []string{"-l", "."}
|
||||
if qaFix {
|
||||
args = []string{"-w", "."}
|
||||
}
|
||||
return QACheck{Name: "format", Command: "gofmt", Args: args}
|
||||
|
||||
case "vet":
|
||||
return QACheck{Name: "vet", Command: "go", Args: []string{"vet", "./..."}}
|
||||
|
||||
case "lint":
|
||||
args := []string{"run"}
|
||||
if qaFix {
|
||||
args = append(args, "--fix")
|
||||
}
|
||||
if qaChanged && !qaAll {
|
||||
args = append(args, "--new-from-rev=HEAD")
|
||||
}
|
||||
args = append(args, "./...")
|
||||
return QACheck{Name: "lint", Command: "golangci-lint", Args: args}
|
||||
|
||||
case "test":
|
||||
args := []string{"test"}
|
||||
if qaShort {
|
||||
args = append(args, "-short")
|
||||
}
|
||||
if qaVerbose {
|
||||
args = append(args, "-v")
|
||||
}
|
||||
args = append(args, "./...")
|
||||
return QACheck{Name: "test", Command: "go", Args: args}
|
||||
|
||||
case "race":
|
||||
args := []string{"test", "-race"}
|
||||
if qaShort {
|
||||
args = append(args, "-short")
|
||||
}
|
||||
if qaVerbose {
|
||||
args = append(args, "-v")
|
||||
}
|
||||
args = append(args, "./...")
|
||||
return QACheck{Name: "race", Command: "go", Args: args}
|
||||
|
||||
case "bench":
|
||||
args := []string{"test", "-bench=.", "-benchmem", "-run=^$"}
|
||||
args = append(args, "./...")
|
||||
return QACheck{Name: "bench", Command: "go", Args: args}
|
||||
|
||||
case "vuln":
|
||||
return QACheck{Name: "vuln", Command: "govulncheck", Args: []string{"./..."}}
|
||||
|
||||
case "sec":
|
||||
return QACheck{Name: "sec", Command: "gosec", Args: []string{"-quiet", "./..."}}
|
||||
|
||||
case "fuzz":
|
||||
return QACheck{Name: "fuzz", Command: "_internal_"}
|
||||
|
||||
case "docblock":
|
||||
// Special internal check - handled separately
|
||||
return QACheck{Name: "docblock", Command: "_internal_"}
|
||||
|
||||
default:
|
||||
return QACheck{}
|
||||
}
|
||||
}
|
||||
|
||||
// fixHintFor returns an actionable fix instruction for a given check failure.
|
||||
func fixHintFor(checkName, output string) string {
|
||||
switch checkName {
|
||||
case "format", "fmt":
|
||||
return "Run 'core go qa fmt --fix' to auto-format."
|
||||
case "vet":
|
||||
return "Fix the issues reported by go vet — typically genuine bugs."
|
||||
case "lint":
|
||||
return "Run 'core go qa lint --fix' for auto-fixable issues."
|
||||
case "test":
|
||||
if name := extractFailingTest(output); name != "" {
|
||||
return fmt.Sprintf("Run 'go test -run %s -v ./...' to debug.", name)
|
||||
}
|
||||
return "Run 'go test -run <TestName> -v ./path/' to debug."
|
||||
case "race":
|
||||
return "Data race detected. Add mutex, channel, or atomic to synchronise shared state."
|
||||
case "bench":
|
||||
return "Benchmark regression. Run 'go test -bench=. -benchmem' to reproduce."
|
||||
case "vuln":
|
||||
return "Run 'govulncheck ./...' for details. Update affected deps with 'go get -u'."
|
||||
case "sec":
|
||||
return "Review gosec findings. Common fixes: validate inputs, parameterised queries."
|
||||
case "fuzz":
|
||||
return "Add a regression test for the crashing input in testdata/fuzz/<Target>/."
|
||||
case "docblock":
|
||||
return "Add doc comments to exported symbols: '// Name does X.' before each declaration."
|
||||
default:
|
||||
return ""
|
||||
}
|
||||
}
|
||||
|
||||
var failTestRe = regexp.MustCompile(`--- FAIL: (\w+)`)
|
||||
|
||||
// extractFailingTest parses the first failing test name from go test output.
|
||||
func extractFailingTest(output string) string {
|
||||
if m := failTestRe.FindStringSubmatch(output); len(m) > 1 {
|
||||
return m[1]
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func runCheckCapture(ctx context.Context, dir string, check QACheck) (string, error) {
|
||||
// Handle internal checks
|
||||
if check.Command == "_internal_" {
|
||||
return runInternalCheck(check)
|
||||
}
|
||||
|
||||
// Check if command exists
|
||||
if _, err := exec.LookPath(check.Command); err != nil {
|
||||
return "", cli.Err("%s: not installed", check.Command)
|
||||
}
|
||||
|
||||
cmd := exec.CommandContext(ctx, check.Command, check.Args...)
|
||||
cmd.Dir = dir
|
||||
|
||||
// For gofmt -l, capture output to check if files need formatting
|
||||
if check.Name == "format" && len(check.Args) > 0 && check.Args[0] == "-l" {
|
||||
output, err := cmd.Output()
|
||||
if err != nil {
|
||||
return string(output), err
|
||||
}
|
||||
if len(output) > 0 {
|
||||
// Show files that need formatting
|
||||
if !qaQuiet && !qaJSON {
|
||||
cli.Text(string(output))
|
||||
}
|
||||
return string(output), cli.Err("files need formatting (use --fix)")
|
||||
}
|
||||
return "", nil
|
||||
}
|
||||
|
||||
// For other commands, stream or capture based on quiet mode
|
||||
if qaQuiet || qaJSON {
|
||||
output, err := cmd.CombinedOutput()
|
||||
return string(output), err
|
||||
}
|
||||
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
return "", cmd.Run()
|
||||
}
|
||||
|
||||
func runCoverage(ctx context.Context, dir string) (float64, float64, error) {
|
||||
// Create temp file for coverage data
|
||||
covFile, err := os.CreateTemp("", "coverage-*.out")
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
covPath := covFile.Name()
|
||||
_ = covFile.Close()
|
||||
defer os.Remove(covPath)
|
||||
|
||||
args := []string{"test", "-cover", "-covermode=atomic", "-coverprofile=" + covPath}
|
||||
if qaShort {
|
||||
args = append(args, "-short")
|
||||
}
|
||||
args = append(args, "./...")
|
||||
|
||||
cmd := exec.CommandContext(ctx, "go", args...)
|
||||
cmd.Dir = dir
|
||||
if !qaQuiet && !qaJSON {
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
}
|
||||
|
||||
if err := cmd.Run(); err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
// Parse statement coverage
|
||||
coverCmd := exec.CommandContext(ctx, "go", "tool", "cover", "-func="+covPath)
|
||||
output, err := coverCmd.Output()
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
|
||||
// Parse last line for total coverage
|
||||
lines := strings.Split(strings.TrimSpace(string(output)), "\n")
|
||||
var statementPct float64
|
||||
if len(lines) > 0 {
|
||||
lastLine := lines[len(lines)-1]
|
||||
fields := strings.Fields(lastLine)
|
||||
if len(fields) >= 3 {
|
||||
// Parse percentage (e.g., "45.6%")
|
||||
pctStr := strings.TrimSuffix(fields[len(fields)-1], "%")
|
||||
_, _ = fmt.Sscanf(pctStr, "%f", &statementPct)
|
||||
}
|
||||
}
|
||||
|
||||
// Parse branch coverage
|
||||
branchPct, err := calculateBlockCoverage(covPath)
|
||||
if err != nil {
|
||||
return statementPct, 0, err
|
||||
}
|
||||
|
||||
return statementPct, branchPct, nil
|
||||
}
|
||||
|
||||
// runInternalCheck runs internal Go-based checks (not external commands).
|
||||
func runInternalCheck(check QACheck) (string, error) {
|
||||
switch check.Name {
|
||||
case "fuzz":
|
||||
// Short burst fuzz in QA (3s per target)
|
||||
duration := 3 * time.Second
|
||||
if qaTimeout > 0 && qaTimeout < 30*time.Second {
|
||||
duration = 2 * time.Second
|
||||
}
|
||||
return "", runGoFuzz(duration, "", "", qaVerbose)
|
||||
|
||||
case "docblock":
|
||||
result, err := qa.CheckDocblockCoverage([]string{"./..."})
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
result.Threshold = qaDocblockThreshold
|
||||
result.Passed = result.Coverage >= qaDocblockThreshold
|
||||
|
||||
if !result.Passed {
|
||||
var output strings.Builder
|
||||
output.WriteString(fmt.Sprintf("Docblock coverage: %.1f%% (threshold: %.1f%%)\n",
|
||||
result.Coverage, qaDocblockThreshold))
|
||||
for _, m := range result.Missing {
|
||||
output.WriteString(fmt.Sprintf("%s:%d\n", m.File, m.Line))
|
||||
}
|
||||
return output.String(), cli.Err("docblock coverage %.1f%% below threshold %.1f%%",
|
||||
result.Coverage, qaDocblockThreshold)
|
||||
}
|
||||
return fmt.Sprintf("Docblock coverage: %.1f%%", result.Coverage), nil
|
||||
|
||||
default:
|
||||
return "", cli.Err("unknown internal check: %s", check.Name)
|
||||
}
|
||||
}
|
||||
236
cmd/gocmd/cmd_tools.go
Normal file
236
cmd/gocmd/cmd_tools.go
Normal file
|
|
@ -0,0 +1,236 @@
|
|||
package gocmd
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"forge.lthn.ai/core/go/pkg/i18n"
|
||||
)
|
||||
|
||||
var (
|
||||
installVerbose bool
|
||||
installNoCgo bool
|
||||
)
|
||||
|
||||
func addGoInstallCommand(parent *cli.Command) {
|
||||
installCmd := &cli.Command{
|
||||
Use: "install [path]",
|
||||
Short: "Install Go binary",
|
||||
Long: "Install Go binary to $GOPATH/bin",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
// Get install path from args or default to current dir
|
||||
installPath := "./..."
|
||||
if len(args) > 0 {
|
||||
installPath = args[0]
|
||||
}
|
||||
|
||||
// Detect if we're in a module with cmd/ subdirectories or a root main.go
|
||||
if installPath == "./..." {
|
||||
if _, err := os.Stat("core.go"); err == nil {
|
||||
installPath = "."
|
||||
} else if entries, err := os.ReadDir("cmd"); err == nil && len(entries) > 0 {
|
||||
installPath = "./cmd/..."
|
||||
} else if _, err := os.Stat("main.go"); err == nil {
|
||||
installPath = "."
|
||||
}
|
||||
}
|
||||
|
||||
cli.Print("%s %s\n", dimStyle.Render(i18n.Label("install")), i18n.Progress("install"))
|
||||
cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("path")), installPath)
|
||||
if installNoCgo {
|
||||
cli.Print(" %s %s\n", dimStyle.Render(i18n.Label("cgo")), "disabled")
|
||||
}
|
||||
|
||||
cmdArgs := []string{"install"}
|
||||
if installVerbose {
|
||||
cmdArgs = append(cmdArgs, "-v")
|
||||
}
|
||||
cmdArgs = append(cmdArgs, installPath)
|
||||
|
||||
execCmd := exec.Command("go", cmdArgs...)
|
||||
if installNoCgo {
|
||||
execCmd.Env = append(os.Environ(), "CGO_ENABLED=0")
|
||||
}
|
||||
execCmd.Stdout = os.Stdout
|
||||
execCmd.Stderr = os.Stderr
|
||||
|
||||
if err := execCmd.Run(); err != nil {
|
||||
cli.Print("\n%s\n", errorStyle.Render(i18n.T("i18n.fail.install", "binary")))
|
||||
return err
|
||||
}
|
||||
|
||||
// Show where it was installed
|
||||
gopath := os.Getenv("GOPATH")
|
||||
if gopath == "" {
|
||||
home, _ := os.UserHomeDir()
|
||||
gopath = filepath.Join(home, "go")
|
||||
}
|
||||
binDir := filepath.Join(gopath, "bin")
|
||||
|
||||
cli.Print("\n%s %s\n", successStyle.Render(i18n.T("i18n.done.install")), binDir)
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
installCmd.Flags().BoolVarP(&installVerbose, "verbose", "v", false, "Verbose output")
|
||||
installCmd.Flags().BoolVar(&installNoCgo, "no-cgo", false, "Disable CGO")
|
||||
|
||||
parent.AddCommand(installCmd)
|
||||
}
|
||||
|
||||
func addGoModCommand(parent *cli.Command) {
|
||||
modCmd := &cli.Command{
|
||||
Use: "mod",
|
||||
Short: "Module management",
|
||||
Long: "Go module management commands",
|
||||
}
|
||||
|
||||
// tidy
|
||||
tidyCmd := &cli.Command{
|
||||
Use: "tidy",
|
||||
Short: "Run go mod tidy",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
execCmd := exec.Command("go", "mod", "tidy")
|
||||
execCmd.Stdout = os.Stdout
|
||||
execCmd.Stderr = os.Stderr
|
||||
return execCmd.Run()
|
||||
},
|
||||
}
|
||||
|
||||
// download
|
||||
downloadCmd := &cli.Command{
|
||||
Use: "download",
|
||||
Short: "Download module dependencies",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
execCmd := exec.Command("go", "mod", "download")
|
||||
execCmd.Stdout = os.Stdout
|
||||
execCmd.Stderr = os.Stderr
|
||||
return execCmd.Run()
|
||||
},
|
||||
}
|
||||
|
||||
// verify
|
||||
verifyCmd := &cli.Command{
|
||||
Use: "verify",
|
||||
Short: "Verify module checksums",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
execCmd := exec.Command("go", "mod", "verify")
|
||||
execCmd.Stdout = os.Stdout
|
||||
execCmd.Stderr = os.Stderr
|
||||
return execCmd.Run()
|
||||
},
|
||||
}
|
||||
|
||||
// graph
|
||||
graphCmd := &cli.Command{
|
||||
Use: "graph",
|
||||
Short: "Print module dependency graph",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
execCmd := exec.Command("go", "mod", "graph")
|
||||
execCmd.Stdout = os.Stdout
|
||||
execCmd.Stderr = os.Stderr
|
||||
return execCmd.Run()
|
||||
},
|
||||
}
|
||||
|
||||
modCmd.AddCommand(tidyCmd)
|
||||
modCmd.AddCommand(downloadCmd)
|
||||
modCmd.AddCommand(verifyCmd)
|
||||
modCmd.AddCommand(graphCmd)
|
||||
parent.AddCommand(modCmd)
|
||||
}
|
||||
|
||||
func addGoWorkCommand(parent *cli.Command) {
|
||||
workCmd := &cli.Command{
|
||||
Use: "work",
|
||||
Short: "Workspace management",
|
||||
Long: "Go workspace management commands",
|
||||
}
|
||||
|
||||
// sync
|
||||
syncCmd := &cli.Command{
|
||||
Use: "sync",
|
||||
Short: "Sync workspace modules",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
execCmd := exec.Command("go", "work", "sync")
|
||||
execCmd.Stdout = os.Stdout
|
||||
execCmd.Stderr = os.Stderr
|
||||
return execCmd.Run()
|
||||
},
|
||||
}
|
||||
|
||||
// init
|
||||
initCmd := &cli.Command{
|
||||
Use: "init",
|
||||
Short: "Initialise a new workspace",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
execCmd := exec.Command("go", "work", "init")
|
||||
execCmd.Stdout = os.Stdout
|
||||
execCmd.Stderr = os.Stderr
|
||||
if err := execCmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
// Auto-add current module if go.mod exists
|
||||
if _, err := os.Stat("go.mod"); err == nil {
|
||||
execCmd = exec.Command("go", "work", "use", ".")
|
||||
execCmd.Stdout = os.Stdout
|
||||
execCmd.Stderr = os.Stderr
|
||||
return execCmd.Run()
|
||||
}
|
||||
return nil
|
||||
},
|
||||
}
|
||||
|
||||
// use
|
||||
useCmd := &cli.Command{
|
||||
Use: "use [modules...]",
|
||||
Short: "Add modules to workspace",
|
||||
RunE: func(cmd *cli.Command, args []string) error {
|
||||
if len(args) == 0 {
|
||||
// Auto-detect modules
|
||||
modules := findGoModules(".")
|
||||
if len(modules) == 0 {
|
||||
return errors.New("no Go modules found")
|
||||
}
|
||||
for _, mod := range modules {
|
||||
execCmd := exec.Command("go", "work", "use", mod)
|
||||
execCmd.Stdout = os.Stdout
|
||||
execCmd.Stderr = os.Stderr
|
||||
if err := execCmd.Run(); err != nil {
|
||||
return err
|
||||
}
|
||||
cli.Print("%s %s\n", successStyle.Render(i18n.T("i18n.done.add")), mod)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
cmdArgs := append([]string{"work", "use"}, args...)
|
||||
execCmd := exec.Command("go", cmdArgs...)
|
||||
execCmd.Stdout = os.Stdout
|
||||
execCmd.Stderr = os.Stderr
|
||||
return execCmd.Run()
|
||||
},
|
||||
}
|
||||
|
||||
workCmd.AddCommand(syncCmd)
|
||||
workCmd.AddCommand(initCmd)
|
||||
workCmd.AddCommand(useCmd)
|
||||
parent.AddCommand(workCmd)
|
||||
}
|
||||
|
||||
func findGoModules(root string) []string {
|
||||
var modules []string
|
||||
_ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error {
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
if info.Name() == "go.mod" && path != "go.mod" {
|
||||
modules = append(modules, filepath.Dir(path))
|
||||
}
|
||||
return nil
|
||||
})
|
||||
return modules
|
||||
}
|
||||
229
cmd/gocmd/coverage_test.go
Normal file
229
cmd/gocmd/coverage_test.go
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
package gocmd
|
||||
|
||||
import (
|
||||
"os"
|
||||
"testing"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/cli"
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestCalculateBlockCoverage(t *testing.T) {
|
||||
// Create a dummy coverage profile
|
||||
content := `mode: set
|
||||
forge.lthn.ai/core/go/pkg/foo.go:1.2,3.4 5 1
|
||||
forge.lthn.ai/core/go/pkg/foo.go:5.6,7.8 2 0
|
||||
forge.lthn.ai/core/go/pkg/bar.go:10.1,12.20 10 5
|
||||
`
|
||||
tmpfile, err := os.CreateTemp("", "test-coverage-*.out")
|
||||
assert.NoError(t, err)
|
||||
defer os.Remove(tmpfile.Name())
|
||||
|
||||
_, err = tmpfile.Write([]byte(content))
|
||||
assert.NoError(t, err)
|
||||
err = tmpfile.Close()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Test calculation
|
||||
// 3 blocks total, 2 covered (count > 0)
|
||||
// Expect (2/3) * 100 = 66.666...
|
||||
pct, err := calculateBlockCoverage(tmpfile.Name())
|
||||
assert.NoError(t, err)
|
||||
assert.InDelta(t, 66.67, pct, 0.01)
|
||||
|
||||
// Test empty file (only header)
|
||||
contentEmpty := "mode: atomic\n"
|
||||
tmpfileEmpty, _ := os.CreateTemp("", "test-coverage-empty-*.out")
|
||||
defer os.Remove(tmpfileEmpty.Name())
|
||||
tmpfileEmpty.Write([]byte(contentEmpty))
|
||||
tmpfileEmpty.Close()
|
||||
|
||||
pct, err = calculateBlockCoverage(tmpfileEmpty.Name())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0.0, pct)
|
||||
|
||||
// Test non-existent file
|
||||
pct, err = calculateBlockCoverage("non-existent-file")
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, 0.0, pct)
|
||||
|
||||
// Test malformed file
|
||||
contentMalformed := `mode: set
|
||||
forge.lthn.ai/core/go/pkg/foo.go:1.2,3.4 5
|
||||
forge.lthn.ai/core/go/pkg/foo.go:1.2,3.4 5 notanumber
|
||||
`
|
||||
tmpfileMalformed, _ := os.CreateTemp("", "test-coverage-malformed-*.out")
|
||||
defer os.Remove(tmpfileMalformed.Name())
|
||||
tmpfileMalformed.Write([]byte(contentMalformed))
|
||||
tmpfileMalformed.Close()
|
||||
|
||||
pct, err = calculateBlockCoverage(tmpfileMalformed.Name())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0.0, pct)
|
||||
|
||||
// Test malformed file - missing fields
|
||||
contentMalformed2 := `mode: set
|
||||
forge.lthn.ai/core/go/pkg/foo.go:1.2,3.4 5
|
||||
`
|
||||
tmpfileMalformed2, _ := os.CreateTemp("", "test-coverage-malformed2-*.out")
|
||||
defer os.Remove(tmpfileMalformed2.Name())
|
||||
tmpfileMalformed2.Write([]byte(contentMalformed2))
|
||||
tmpfileMalformed2.Close()
|
||||
|
||||
pct, err = calculateBlockCoverage(tmpfileMalformed2.Name())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0.0, pct)
|
||||
|
||||
// Test completely empty file
|
||||
tmpfileEmpty2, _ := os.CreateTemp("", "test-coverage-empty2-*.out")
|
||||
defer os.Remove(tmpfileEmpty2.Name())
|
||||
tmpfileEmpty2.Close()
|
||||
pct, err = calculateBlockCoverage(tmpfileEmpty2.Name())
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, 0.0, pct)
|
||||
}
|
||||
|
||||
func TestParseOverallCoverage(t *testing.T) {
|
||||
output := `ok forge.lthn.ai/core/go/pkg/foo 0.100s coverage: 50.0% of statements
|
||||
ok forge.lthn.ai/core/go/pkg/bar 0.200s coverage: 100.0% of statements
|
||||
`
|
||||
pct := parseOverallCoverage(output)
|
||||
assert.Equal(t, 75.0, pct)
|
||||
|
||||
outputNoCov := "ok forge.lthn.ai/core/go/pkg/foo 0.100s"
|
||||
pct = parseOverallCoverage(outputNoCov)
|
||||
assert.Equal(t, 0.0, pct)
|
||||
}
|
||||
|
||||
func TestFormatCoverage(t *testing.T) {
|
||||
assert.Contains(t, formatCoverage(85.0), "85.0%")
|
||||
assert.Contains(t, formatCoverage(65.0), "65.0%")
|
||||
assert.Contains(t, formatCoverage(25.0), "25.0%")
|
||||
}
|
||||
|
||||
func TestAddGoCovCommand(t *testing.T) {
|
||||
cmd := &cli.Command{Use: "test"}
|
||||
addGoCovCommand(cmd)
|
||||
assert.True(t, cmd.HasSubCommands())
|
||||
sub := cmd.Commands()[0]
|
||||
assert.Equal(t, "cov", sub.Name())
|
||||
}
|
||||
|
||||
func TestAddGoQACommand(t *testing.T) {
|
||||
cmd := &cli.Command{Use: "test"}
|
||||
addGoQACommand(cmd)
|
||||
assert.True(t, cmd.HasSubCommands())
|
||||
sub := cmd.Commands()[0]
|
||||
assert.Equal(t, "qa", sub.Name())
|
||||
}
|
||||
|
||||
func TestDetermineChecks(t *testing.T) {
|
||||
// Default checks
|
||||
qaOnly = ""
|
||||
qaSkip = ""
|
||||
qaRace = false
|
||||
qaBench = false
|
||||
checks := determineChecks()
|
||||
assert.Contains(t, checks, "fmt")
|
||||
assert.Contains(t, checks, "test")
|
||||
|
||||
// Only
|
||||
qaOnly = "fmt,lint"
|
||||
checks = determineChecks()
|
||||
assert.Equal(t, []string{"fmt", "lint"}, checks)
|
||||
|
||||
// Skip
|
||||
qaOnly = ""
|
||||
qaSkip = "fmt,lint"
|
||||
checks = determineChecks()
|
||||
assert.NotContains(t, checks, "fmt")
|
||||
assert.NotContains(t, checks, "lint")
|
||||
assert.Contains(t, checks, "test")
|
||||
|
||||
// Race
|
||||
qaSkip = ""
|
||||
qaRace = true
|
||||
checks = determineChecks()
|
||||
assert.Contains(t, checks, "race")
|
||||
assert.NotContains(t, checks, "test")
|
||||
|
||||
// Reset
|
||||
qaRace = false
|
||||
}
|
||||
|
||||
func TestBuildCheck(t *testing.T) {
|
||||
qaFix = false
|
||||
c := buildCheck("fmt")
|
||||
assert.Equal(t, "format", c.Name)
|
||||
assert.Equal(t, []string{"-l", "."}, c.Args)
|
||||
|
||||
qaFix = true
|
||||
c = buildCheck("fmt")
|
||||
assert.Equal(t, []string{"-w", "."}, c.Args)
|
||||
|
||||
c = buildCheck("vet")
|
||||
assert.Equal(t, "vet", c.Name)
|
||||
|
||||
c = buildCheck("lint")
|
||||
assert.Equal(t, "lint", c.Name)
|
||||
|
||||
c = buildCheck("test")
|
||||
assert.Equal(t, "test", c.Name)
|
||||
|
||||
c = buildCheck("race")
|
||||
assert.Equal(t, "race", c.Name)
|
||||
|
||||
c = buildCheck("bench")
|
||||
assert.Equal(t, "bench", c.Name)
|
||||
|
||||
c = buildCheck("vuln")
|
||||
assert.Equal(t, "vuln", c.Name)
|
||||
|
||||
c = buildCheck("sec")
|
||||
assert.Equal(t, "sec", c.Name)
|
||||
|
||||
c = buildCheck("fuzz")
|
||||
assert.Equal(t, "fuzz", c.Name)
|
||||
|
||||
c = buildCheck("docblock")
|
||||
assert.Equal(t, "docblock", c.Name)
|
||||
|
||||
c = buildCheck("unknown")
|
||||
assert.Equal(t, "", c.Name)
|
||||
}
|
||||
|
||||
func TestBuildChecks(t *testing.T) {
|
||||
checks := buildChecks([]string{"fmt", "vet", "unknown"})
|
||||
assert.Equal(t, 2, len(checks))
|
||||
assert.Equal(t, "format", checks[0].Name)
|
||||
assert.Equal(t, "vet", checks[1].Name)
|
||||
}
|
||||
|
||||
func TestFixHintFor(t *testing.T) {
|
||||
assert.Contains(t, fixHintFor("format", ""), "core go qa fmt --fix")
|
||||
assert.Contains(t, fixHintFor("vet", ""), "go vet")
|
||||
assert.Contains(t, fixHintFor("lint", ""), "core go qa lint --fix")
|
||||
assert.Contains(t, fixHintFor("test", "--- FAIL: TestFoo"), "TestFoo")
|
||||
assert.Contains(t, fixHintFor("race", ""), "Data race")
|
||||
assert.Contains(t, fixHintFor("bench", ""), "Benchmark regression")
|
||||
assert.Contains(t, fixHintFor("vuln", ""), "govulncheck")
|
||||
assert.Contains(t, fixHintFor("sec", ""), "gosec")
|
||||
assert.Contains(t, fixHintFor("fuzz", ""), "crashing input")
|
||||
assert.Contains(t, fixHintFor("docblock", ""), "doc comments")
|
||||
assert.Equal(t, "", fixHintFor("unknown", ""))
|
||||
}
|
||||
|
||||
func TestRunGoQA_NoGoMod(t *testing.T) {
|
||||
// runGoQA should fail if go.mod is not present in CWD
|
||||
// We run it in a temp dir without go.mod
|
||||
tmpDir, _ := os.MkdirTemp("", "test-qa-*")
|
||||
defer os.RemoveAll(tmpDir)
|
||||
cwd, _ := os.Getwd()
|
||||
os.Chdir(tmpDir)
|
||||
defer os.Chdir(cwd)
|
||||
|
||||
cmd := &cli.Command{Use: "qa"}
|
||||
err := runGoQA(cmd, []string{})
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "no go.mod found")
|
||||
}
|
||||
|
|
@ -30,7 +30,7 @@ The `cli` package is a comprehensive application runtime and UI framework design
|
|||
#### Command Building
|
||||
- `func NewCommand(use, short, long string, run func(*Command, []string) error) *Command`: Factory for standard commands.
|
||||
- `func NewGroup(use, short, long string) *Command`: Factory for parent commands (no run logic).
|
||||
- `func RegisterCommands(fn CommandRegistration)`: Registers a callback to add commands to the root at runtime.
|
||||
- `func WithCommands(name string, register func(root *Command)) framework.Option`: Registers a command group as a framework service.
|
||||
|
||||
#### Output & Styling
|
||||
- `type AnsiStyle`: Fluent builder for text styling (Bold, Dim, Foreground, Background).
|
||||
|
|
@ -67,7 +67,7 @@ The `cli` package is a comprehensive application runtime and UI framework design
|
|||
|
||||
### 5. Test Coverage Notes
|
||||
- **Interactive Prompts**: Tests must mock `stdin` to verify `Confirm`, `Prompt`, and `Select` behavior without hanging.
|
||||
- **Command Registration**: Verify `RegisterCommands` works both before and after `Init` is called.
|
||||
- **Command Registration**: Verify `WithCommands` services receive the root command during `OnStartup`.
|
||||
- **Daemon Lifecycle**: Tests needed for `PIDFile` locking and `HealthServer` endpoints (/health, /ready).
|
||||
- **Layout Rendering**: Snapshot testing is recommended for `Layout` and `Table` rendering to ensure ANSI codes and alignment are correct.
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
# MCP Integration Implementation Plan
|
||||
|
||||
> **Status:** Completed. MCP command now lives in `go-ai/cmd/mcpcmd/`. Code examples below use the old `init()` + `RegisterCommands()` pattern — the current approach uses `cli.WithCommands()` (see cli-meta-package-design.md).
|
||||
|
||||
> **For Claude:** REQUIRED SUB-SKILL: Use superpowers:executing-plans to implement this plan task-by-task.
|
||||
|
||||
**Goal:** Add `core mcp serve` command with RAG and metrics tools, then configure the agentic-flows plugin to use it.
|
||||
|
|
|
|||
128
docs/plans/2026-02-21-cli-meta-package-design.md
Normal file
128
docs/plans/2026-02-21-cli-meta-package-design.md
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
# CLI Meta-Package Restructure — Design
|
||||
|
||||
**Goal:** Transform `core/cli` from a 35K LOC monolith into a thin assembly repo that ships variant binaries. Domain repos own their commands. `go/pkg/cli` is the only import any domain package needs for CLI concerns.
|
||||
|
||||
**Architecture:** Commands register as framework services via `cli.WithCommands()`, passed to `cli.Main()`. Command code lives in the domain repos that own the business logic. The cli repo is a thin `main.go` that wires them together.
|
||||
|
||||
**Tech Stack:** go/pkg/cli (wraps cobra + charmbracelet), Core framework lifecycle, Taskfile
|
||||
|
||||
---
|
||||
|
||||
## 1. CLI SDK — The Single Import
|
||||
|
||||
`forge.lthn.ai/core/go/pkg/cli` is the **only** import domain packages use for CLI concerns. It wraps cobra, charmbracelet, and stdlib behind a stable API. If the underlying libraries change, only `go/pkg/cli` is touched — every domain repo is insulated.
|
||||
|
||||
### Already done
|
||||
|
||||
- **Cobra:** `Command` type alias, `NewCommand()`, `NewGroup()`, `NewRun()`, flag helpers (`StringFlag`, `BoolFlag`, `IntFlag`, `StringSliceFlag`), arg validators
|
||||
- **Output:** `Success()`, `Error()`, `Warn()`, `Info()`, `Table`, `Section()`, `Label()`, `Task()`, `Hint()`
|
||||
- **Prompts:** `Confirm()`, `Question()`, `Choose()`, `ChooseMulti()` with grammar-based action variants
|
||||
- **Styles:** 17 pre-built styles, `AnsiStyle` builder, Tailwind colour constants (47 hex values)
|
||||
- **Glyphs:** `:check:`, `:cross:`, `:warn:` etc. with Unicode/Emoji/ASCII themes
|
||||
- **Layout:** HLCRF composite renderer (Header/Left/Content/Right/Footer)
|
||||
- **Errors:** `Wrap()`, `WrapVerb()`, `ExitError`, `Is()`, `As()`
|
||||
- **Logging:** `LogDebug()`, `LogInfo()`, `LogWarn()`, `LogError()`, `LogSecurity()`
|
||||
- **TUI primitives:** `Spinner`, `ProgressBar`, `InteractiveList`, `TextInput`, `Viewport`, `RunTUI`
|
||||
- **Command registration:** `WithCommands(name, fn)` — registers commands as framework services
|
||||
|
||||
### Stubbed for later (interface exists, returns simple fallback)
|
||||
|
||||
- `Form(fields []FormField) (map[string]string, error)` — multi-field form (backed by huh later)
|
||||
- `FilePicker(opts ...FilePickerOption) (string, error)` — file browser
|
||||
- `Tabs(items []TabItem) error` — tabbed content panes
|
||||
|
||||
### Rule
|
||||
|
||||
Domain packages import `forge.lthn.ai/core/go/pkg/cli` and **nothing else** for CLI concerns. No `cobra`, no `lipgloss`, no `bubbletea`.
|
||||
|
||||
---
|
||||
|
||||
## 2. Command Registration — Framework Lifecycle
|
||||
|
||||
Commands register through the Core framework's service lifecycle, not through global state or `init()` functions.
|
||||
|
||||
### The contract
|
||||
|
||||
Each domain repo exports an `Add*Commands(root *cli.Command)` function. The CLI binary wires it in via `cli.WithCommands()`:
|
||||
|
||||
```go
|
||||
// go-ai/cmd/daemon/cmd.go
|
||||
package daemon
|
||||
|
||||
import "forge.lthn.ai/core/go/pkg/cli"
|
||||
|
||||
// AddDaemonCommand adds the 'daemon' command group to the root.
|
||||
func AddDaemonCommand(root *cli.Command) {
|
||||
daemonCmd := cli.NewGroup("daemon", "Manage the core daemon", "")
|
||||
root.AddCommand(daemonCmd)
|
||||
// subcommands...
|
||||
}
|
||||
```
|
||||
|
||||
No `init()`. No blank imports. No `cli.RegisterCommands()`.
|
||||
|
||||
### How it works
|
||||
|
||||
`cli.WithCommands(name, fn)` wraps the registration function as a framework service implementing `Startable`. During `Core.ServiceStartup()`, the service's `OnStartup()` casts `Core.App` to `*cobra.Command` and calls the registration function. Core services (i18n, log, workspace) start first since they're registered before command services.
|
||||
|
||||
```go
|
||||
// cli/main.go
|
||||
func main() {
|
||||
cli.Main(
|
||||
cli.WithCommands("config", config.AddConfigCommands),
|
||||
cli.WithCommands("doctor", doctor.AddDoctorCommands),
|
||||
// ...
|
||||
)
|
||||
}
|
||||
```
|
||||
|
||||
### Migration status (completed)
|
||||
|
||||
| Source | Destination | Status |
|
||||
|--------|-------------|--------|
|
||||
| `cmd/dev, setup, qa, docs, gitcmd, monitor` | `go-devops/cmd/` | Done |
|
||||
| `cmd/lab` | `go-ai/cmd/` | Done |
|
||||
| `cmd/workspace` | `go-agentic/cmd/` | Done |
|
||||
| `cmd/go` | `core/go/cmd/gocmd` | Done |
|
||||
| `cmd/vanity-import, community` | `go-devops/cmd/` | Done |
|
||||
| `cmd/updater` | `go-update` | Done (own repo) |
|
||||
| `cmd/daemon, mcpcmd, security` | `go-ai/cmd/` | Done |
|
||||
| `cmd/crypt` | `go-crypt/cmd/` | Done |
|
||||
| `cmd/rag` | `go-rag/cmd/` | Done |
|
||||
| `cmd/unifi` | `go-netops/cmd/` | Done |
|
||||
| `cmd/api` | `go-api/cmd/` | Done |
|
||||
| `cmd/collect, forge, gitea` | `go-scm/cmd/` | Done |
|
||||
| `cmd/deploy, prod, vm` | `go-devops/cmd/` | Done |
|
||||
|
||||
### Stays in cli/ (meta/framework commands)
|
||||
|
||||
`config`, `doctor`, `help`, `module`, `pkgcmd`, `plugin`, `session`
|
||||
|
||||
---
|
||||
|
||||
## 3. Variant Binaries (future)
|
||||
|
||||
The cli/ repo can produce variant binaries by creating multiple `main.go` files that wire different sets of commands.
|
||||
|
||||
```
|
||||
cli/
|
||||
├── main.go # Current — meta commands only
|
||||
├── cmd/core-full/main.go # Full CLI — all ecosystem commands
|
||||
├── cmd/core-ci/main.go # CI agent dispatch + SCM
|
||||
├── cmd/core-mlx/main.go # ML inference subprocess
|
||||
└── cmd/core-ops/main.go # DevOps + infra management
|
||||
```
|
||||
|
||||
Each variant calls `cli.Main()` with its specific `cli.WithCommands()` set. No blank imports needed.
|
||||
|
||||
### Why variants matter
|
||||
|
||||
- `core-mlx` ships to the homelab as a ~10MB binary, not 50MB with devops/forge/netops
|
||||
- `core-ci` deploys to agent machines without ML or CGO dependencies
|
||||
- Adding a new variant = one new `main.go` with the right `WithCommands` calls
|
||||
|
||||
---
|
||||
|
||||
## 4. Current State
|
||||
|
||||
cli/ has 7 meta packages, one `main.go`, and zero business logic. Everything else lives in the domain repos that own it. Total cli/ LOC is ~2K.
|
||||
1724
docs/plans/2026-02-21-cli-sdk-expansion-plan.md
Normal file
1724
docs/plans/2026-02-21-cli-sdk-expansion-plan.md
Normal file
File diff suppressed because it is too large
Load diff
286
docs/plans/2026-02-21-go-forge-design.md
Normal file
286
docs/plans/2026-02-21-go-forge-design.md
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
# go-forge Design Document
|
||||
|
||||
## Overview
|
||||
|
||||
**go-forge** is a full-coverage Go client for the Forgejo API (450 endpoints, 284 paths, 229 types). It uses a generic `Resource[T, C, U]` pattern for CRUD operations (91% of endpoints) and hand-written methods for 39 unique action endpoints. Types are generated from Forgejo's `swagger.v1.json` spec.
|
||||
|
||||
**Module path:** `forge.lthn.ai/core/go-forge`
|
||||
|
||||
**Origin:** Extracted from `go-scm/forge/` (45 methods covering 10% of API), expanded to full coverage.
|
||||
|
||||
## Architecture
|
||||
|
||||
```
|
||||
forge.lthn.ai/core/go-forge
|
||||
├── client.go # HTTP client: auth, headers, rate limiting, context.Context
|
||||
├── pagination.go # Generic paginated request helper
|
||||
├── resource.go # Resource[T, C, U] generic CRUD (List/Get/Create/Update/Delete)
|
||||
├── errors.go # Typed error handling (APIError, NotFound, Forbidden, etc.)
|
||||
├── forge.go # Top-level Forge client aggregating all services
|
||||
│
|
||||
├── types/ # Generated from swagger.v1.json
|
||||
│ ├── generate.go # //go:generate directive
|
||||
│ ├── repo.go # Repository, CreateRepoOption, EditRepoOption
|
||||
│ ├── issue.go # Issue, CreateIssueOption, EditIssueOption
|
||||
│ ├── pr.go # PullRequest, CreatePullRequestOption
|
||||
│ ├── user.go # User, CreateUserOption
|
||||
│ ├── org.go # Organisation, CreateOrgOption
|
||||
│ ├── team.go # Team, CreateTeamOption
|
||||
│ ├── label.go # Label, CreateLabelOption
|
||||
│ ├── release.go # Release, CreateReleaseOption
|
||||
│ ├── branch.go # Branch, BranchProtection
|
||||
│ ├── milestone.go # Milestone, CreateMilestoneOption
|
||||
│ ├── hook.go # Hook, CreateHookOption
|
||||
│ ├── key.go # DeployKey, PublicKey, GPGKey
|
||||
│ ├── notification.go # NotificationThread, NotificationSubject
|
||||
│ ├── package.go # Package, PackageFile
|
||||
│ ├── action.go # ActionRunner, ActionSecret, ActionVariable
|
||||
│ ├── commit.go # Commit, CommitStatus, CombinedStatus
|
||||
│ ├── content.go # ContentsResponse, FileOptions
|
||||
│ ├── wiki.go # WikiPage, WikiPageMetaData
|
||||
│ ├── review.go # PullReview, PullReviewComment
|
||||
│ ├── reaction.go # Reaction
|
||||
│ ├── topic.go # TopicResponse
|
||||
│ ├── misc.go # Markdown, License, GitignoreTemplate, NodeInfo
|
||||
│ ├── admin.go # Cron, QuotaGroup, QuotaRule
|
||||
│ ├── activity.go # Activity, Feed
|
||||
│ └── common.go # Shared types: Permission, ExternalTracker, etc.
|
||||
│
|
||||
├── repos.go # RepoService: CRUD + fork, mirror, transfer, template
|
||||
├── issues.go # IssueService: CRUD + pin, deadline, reactions, stopwatch
|
||||
├── pulls.go # PullService: CRUD + merge, update, reviews, dismiss
|
||||
├── orgs.go # OrgService: CRUD + members, avatar, block, hooks
|
||||
├── users.go # UserService: CRUD + keys, followers, starred, settings
|
||||
├── teams.go # TeamService: CRUD + members, repos
|
||||
├── admin.go # AdminService: users, orgs, cron, runners, quota, unadopted
|
||||
├── branches.go # BranchService: CRUD + protection rules
|
||||
├── releases.go # ReleaseService: CRUD + assets
|
||||
├── labels.go # LabelService: repo + org + issue labels
|
||||
├── webhooks.go # WebhookService: CRUD + test hook
|
||||
├── notifications.go # NotificationService: list, mark read
|
||||
├── packages.go # PackageService: list, get, delete
|
||||
├── actions.go # ActionsService: runners, secrets, variables, workflow dispatch
|
||||
├── contents.go # ContentService: file read/write/delete via API
|
||||
├── wiki.go # WikiService: pages
|
||||
├── commits.go # CommitService: status, notes, diff
|
||||
├── misc.go # MiscService: markdown, licenses, gitignore, nodeinfo
|
||||
│
|
||||
├── config.go # URL/token resolution: env → config file → flags
|
||||
│
|
||||
├── cmd/forgegen/ # Code generator: swagger.v1.json → types/*.go
|
||||
│ ├── main.go
|
||||
│ ├── parser.go # Parse OpenAPI 2.0 definitions
|
||||
│ ├── generator.go # Render Go source files
|
||||
│ └── templates/ # Go text/template files for codegen
|
||||
│
|
||||
└── testdata/
|
||||
└── swagger.v1.json # Pinned spec for testing + generation
|
||||
```
|
||||
|
||||
## Key Design Decisions
|
||||
|
||||
### 1. Generic Resource[T, C, U]
|
||||
|
||||
Three type parameters: T (resource type), C (create options), U (update options).
|
||||
|
||||
```go
|
||||
type Resource[T any, C any, U any] struct {
|
||||
client *Client
|
||||
path string // e.g. "/api/v1/repos/{owner}/{repo}/issues"
|
||||
}
|
||||
|
||||
func (r *Resource[T, C, U]) List(ctx context.Context, params Params, opts ListOptions) ([]T, error)
|
||||
func (r *Resource[T, C, U]) Get(ctx context.Context, params Params, id string) (*T, error)
|
||||
func (r *Resource[T, C, U]) Create(ctx context.Context, params Params, body *C) (*T, error)
|
||||
func (r *Resource[T, C, U]) Update(ctx context.Context, params Params, id string, body *U) (*T, error)
|
||||
func (r *Resource[T, C, U]) Delete(ctx context.Context, params Params, id string) error
|
||||
```
|
||||
|
||||
`Params` is `map[string]string` resolving path variables: `{"owner": "core", "repo": "go-forge"}`.
|
||||
|
||||
This covers 411 of 450 endpoints (91%).
|
||||
|
||||
### 2. Service Structs Embed Resource
|
||||
|
||||
```go
|
||||
type IssueService struct {
|
||||
Resource[types.Issue, types.CreateIssueOption, types.EditIssueOption]
|
||||
}
|
||||
|
||||
// CRUD comes free. Actions are hand-written:
|
||||
func (s *IssueService) Pin(ctx context.Context, owner, repo string, index int64) error
|
||||
func (s *IssueService) SetDeadline(ctx context.Context, owner, repo string, index int64, deadline *time.Time) error
|
||||
```
|
||||
|
||||
### 3. Top-Level Forge Client
|
||||
|
||||
```go
|
||||
type Forge struct {
|
||||
client *Client
|
||||
Repos *RepoService
|
||||
Issues *IssueService
|
||||
Pulls *PullService
|
||||
Orgs *OrgService
|
||||
Users *UserService
|
||||
Teams *TeamService
|
||||
Admin *AdminService
|
||||
Branches *BranchService
|
||||
Releases *ReleaseService
|
||||
Labels *LabelService
|
||||
Webhooks *WebhookService
|
||||
Notifications *NotificationService
|
||||
Packages *PackageService
|
||||
Actions *ActionsService
|
||||
Contents *ContentService
|
||||
Wiki *WikiService
|
||||
Commits *CommitService
|
||||
Misc *MiscService
|
||||
}
|
||||
|
||||
func NewForge(url, token string, opts ...Option) *Forge
|
||||
```
|
||||
|
||||
### 4. Codegen from swagger.v1.json
|
||||
|
||||
The `cmd/forgegen/` tool reads the OpenAPI 2.0 spec and generates:
|
||||
- Go struct definitions with JSON tags and doc comments
|
||||
- Enum constants
|
||||
- Type mapping (OpenAPI → Go)
|
||||
|
||||
229 type definitions → ~25 grouped Go files in `types/`.
|
||||
|
||||
Type mapping rules:
|
||||
| OpenAPI | Go |
|
||||
|---------|-----|
|
||||
| `string` | `string` |
|
||||
| `string` + `date-time` | `time.Time` |
|
||||
| `integer` + `int64` | `int64` |
|
||||
| `integer` | `int` |
|
||||
| `boolean` | `bool` |
|
||||
| `array` of T | `[]T` |
|
||||
| `$ref` | `*T` (pointer) |
|
||||
| nullable | pointer type |
|
||||
| `binary` | `[]byte` |
|
||||
|
||||
### 5. HTTP Client
|
||||
|
||||
```go
|
||||
type Client struct {
|
||||
baseURL string
|
||||
token string
|
||||
httpClient *http.Client
|
||||
userAgent string
|
||||
}
|
||||
|
||||
func New(url, token string, opts ...Option) *Client
|
||||
|
||||
func (c *Client) Get(ctx context.Context, path string, out any) error
|
||||
func (c *Client) Post(ctx context.Context, path string, body, out any) error
|
||||
func (c *Client) Patch(ctx context.Context, path string, body, out any) error
|
||||
func (c *Client) Put(ctx context.Context, path string, body, out any) error
|
||||
func (c *Client) Delete(ctx context.Context, path string) error
|
||||
```
|
||||
|
||||
Options: `WithHTTPClient`, `WithUserAgent`, `WithRateLimit`, `WithLogger`.
|
||||
|
||||
### 6. Pagination
|
||||
|
||||
Forgejo uses `page` + `limit` query params and `X-Total-Count` response header.
|
||||
|
||||
```go
|
||||
type ListOptions struct {
|
||||
Page int
|
||||
Limit int // default 50, max configurable
|
||||
}
|
||||
|
||||
type PagedResult[T any] struct {
|
||||
Items []T
|
||||
TotalCount int
|
||||
Page int
|
||||
HasMore bool
|
||||
}
|
||||
|
||||
// ListAll fetches all pages automatically.
|
||||
func (r *Resource[T, C, U]) ListAll(ctx context.Context, params Params) ([]T, error)
|
||||
```
|
||||
|
||||
### 7. Error Handling
|
||||
|
||||
```go
|
||||
type APIError struct {
|
||||
StatusCode int
|
||||
Message string
|
||||
URL string
|
||||
}
|
||||
|
||||
func IsNotFound(err error) bool
|
||||
func IsForbidden(err error) bool
|
||||
func IsConflict(err error) bool
|
||||
```
|
||||
|
||||
### 8. Config Resolution (from go-scm/forge)
|
||||
|
||||
Priority: flags → environment → config file.
|
||||
|
||||
```go
|
||||
func NewFromConfig(flagURL, flagToken string) (*Forge, error)
|
||||
func ResolveConfig(flagURL, flagToken string) (url, token string, err error)
|
||||
func SaveConfig(url, token string) error
|
||||
```
|
||||
|
||||
Env vars: `FORGE_URL`, `FORGE_TOKEN`. Config file: `~/.config/forge/config.json`.
|
||||
|
||||
## API Coverage
|
||||
|
||||
| Category | Endpoints | CRUD | Actions |
|
||||
|----------|-----------|------|---------|
|
||||
| Repository | 175 | 165 | 10 (fork, mirror, transfer, template, avatar, diffpatch) |
|
||||
| User | 74 | 70 | 4 (avatar, GPG verify) |
|
||||
| Issue | 67 | 57 | 10 (pin, deadline, reactions, stopwatch, labels) |
|
||||
| Organisation | 63 | 59 | 4 (avatar, block/unblock) |
|
||||
| Admin | 39 | 35 | 4 (cron run, rename, adopt, quota set) |
|
||||
| Miscellaneous | 12 | 7 | 5 (markdown render, markup, nodeinfo) |
|
||||
| Notification | 7 | 7 | 0 |
|
||||
| ActivityPub | 6 | 3 | 3 (inbox POST) |
|
||||
| Package | 4 | 4 | 0 |
|
||||
| Settings | 4 | 4 | 0 |
|
||||
| **Total** | **450** | **411** | **39** |
|
||||
|
||||
## Integration Points
|
||||
|
||||
### go-api
|
||||
|
||||
Services implement `DescribableGroup` from go-api Phase 3, enabling:
|
||||
- REST endpoint generation via ToolBridge
|
||||
- Auto-generated OpenAPI spec
|
||||
- Multi-language SDK codegen
|
||||
|
||||
### go-scm
|
||||
|
||||
go-scm/forge/ becomes a thin adapter importing go-forge types. Existing go-scm users are unaffected — the multi-provider abstraction layer stays.
|
||||
|
||||
### go-ai/mcp
|
||||
|
||||
The MCP subsystem can register go-forge operations as MCP tools, giving AI agents full Forgejo API access.
|
||||
|
||||
## 39 Unique Action Methods
|
||||
|
||||
These require hand-written implementation:
|
||||
|
||||
**Repository:** migrate, fork, generate (template), transfer, accept/reject transfer, mirror sync, push mirror sync, avatar, diffpatch, contents (multi-file modify)
|
||||
|
||||
**Pull Requests:** merge, update (rebase), submit review, dismiss/undismiss review
|
||||
|
||||
**Issues:** pin, set deadline, add reaction, start/stop stopwatch, add issue labels
|
||||
|
||||
**Comments:** add reaction
|
||||
|
||||
**Admin:** run cron task, adopt unadopted, rename user, set quota groups
|
||||
|
||||
**Misc:** render markdown, render raw markdown, render markup, GPG key verify
|
||||
|
||||
**ActivityPub:** inbox POST (actor, repo, user)
|
||||
|
||||
**Actions:** dispatch workflow
|
||||
|
||||
**Git:** set note on commit, test webhook
|
||||
2549
docs/plans/2026-02-21-go-forge-plan.md
Normal file
2549
docs/plans/2026-02-21-go-forge-plan.md
Normal file
File diff suppressed because it is too large
Load diff
22
go.mod
22
go.mod
|
|
@ -2,12 +2,13 @@ module forge.lthn.ai/core/go
|
|||
|
||||
go 1.25.5
|
||||
|
||||
require forge.lthn.ai/core/go-crypt v0.0.0
|
||||
require forge.lthn.ai/core/go-crypt v0.0.0-20260221190941-9585da8e6649
|
||||
|
||||
require (
|
||||
github.com/Snider/Borg v0.2.0
|
||||
github.com/aws/aws-sdk-go-v2 v1.41.1
|
||||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
github.com/gorilla/websocket v1.5.3
|
||||
github.com/spf13/cobra v1.10.2
|
||||
github.com/spf13/viper v1.21.0
|
||||
|
|
@ -18,7 +19,7 @@ require (
|
|||
google.golang.org/grpc v1.79.1
|
||||
google.golang.org/protobuf v1.36.11
|
||||
gopkg.in/yaml.v3 v3.0.1
|
||||
modernc.org/sqlite v1.45.0
|
||||
modernc.org/sqlite v1.46.1
|
||||
)
|
||||
|
||||
require (
|
||||
|
|
@ -32,24 +33,39 @@ require (
|
|||
github.com/aws/aws-sdk-go-v2/service/internal/presigned-url v1.13.17 // indirect
|
||||
github.com/aws/aws-sdk-go-v2/service/internal/s3shared v1.19.17 // indirect
|
||||
github.com/aws/smithy-go v1.24.0 // indirect
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc // indirect
|
||||
github.com/charmbracelet/lipgloss v1.1.0 // indirect
|
||||
github.com/charmbracelet/x/ansi v0.10.1 // indirect
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd // indirect
|
||||
github.com/charmbracelet/x/term v0.2.1 // indirect
|
||||
github.com/cloudflare/circl v1.6.3 // indirect
|
||||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect
|
||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
|
||||
github.com/fsnotify/fsnotify v1.9.0 // indirect
|
||||
github.com/go-viper/mapstructure/v2 v2.5.0 // indirect
|
||||
github.com/google/uuid v1.6.0 // indirect
|
||||
github.com/inconshreveable/mousetrap v1.1.0 // indirect
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 // indirect
|
||||
github.com/mattn/go-isatty v0.0.20 // indirect
|
||||
github.com/mattn/go-localereader v0.0.1 // indirect
|
||||
github.com/mattn/go-runewidth v0.0.16 // indirect
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect
|
||||
github.com/muesli/cancelreader v0.2.2 // indirect
|
||||
github.com/muesli/termenv v0.16.0 // indirect
|
||||
github.com/ncruces/go-strftime v1.0.0 // indirect
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
|
||||
github.com/rivo/uniseg v0.4.7 // indirect
|
||||
github.com/rogpeppe/go-internal v1.14.1 // indirect
|
||||
github.com/sagikazarmark/locafero v0.12.0 // indirect
|
||||
github.com/spf13/afero v1.15.0 // indirect
|
||||
github.com/spf13/cast v1.10.0 // indirect
|
||||
github.com/spf13/pflag v1.0.10 // indirect
|
||||
github.com/subosito/gotenv v1.6.0 // indirect
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
|
||||
go.yaml.in/yaml/v3 v3.0.4 // indirect
|
||||
golang.org/x/exp v0.0.0-20260212183809-81e46e3db34a // indirect
|
||||
golang.org/x/net v0.50.0 // indirect
|
||||
|
|
@ -61,5 +77,3 @@ require (
|
|||
modernc.org/mathutil v1.7.1 // indirect
|
||||
modernc.org/memory v1.11.0 // indirect
|
||||
)
|
||||
|
||||
replace forge.lthn.ai/core/go-crypt => ../go-crypt
|
||||
|
|
|
|||
40
go.sum
40
go.sum
|
|
@ -1,3 +1,5 @@
|
|||
forge.lthn.ai/core/go-crypt v0.0.0-20260221190941-9585da8e6649 h1:Rs3bfSU8u1wkzYeL21asL7IcJIBVwOhtRidcEVj/PkA=
|
||||
forge.lthn.ai/core/go-crypt v0.0.0-20260221190941-9585da8e6649/go.mod h1:RS+sz5lChrbc1AEmzzOULsTiMv3bwcwVtwbZi+c/Yjk=
|
||||
github.com/ProtonMail/go-crypto v1.3.0 h1:ILq8+Sf5If5DCpHQp4PbZdS1J7HDFRXz/+xKBiRGFrw=
|
||||
github.com/ProtonMail/go-crypto v1.3.0/go.mod h1:9whxjD8Rbs29b4XWbB8irEcE8KHMqaR2e7GWU1R+/PE=
|
||||
github.com/Snider/Borg v0.2.0 h1:iCyDhY4WTXi39+FexRwXbn2YpZ2U9FUXVXDZk9xRCXQ=
|
||||
|
|
@ -24,8 +26,22 @@ github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0 h1:oeu8VPlOre74lBA/PMhxa5vewaMIM
|
|||
github.com/aws/aws-sdk-go-v2/service/s3 v1.96.0/go.mod h1:5jggDlZ2CLQhwJBiZJb4vfk4f0GxWdEDruWKEJ1xOdo=
|
||||
github.com/aws/smithy-go v1.24.0 h1:LpilSUItNPFr1eY85RYgTIg5eIEPtvFbskaFcmmIUnk=
|
||||
github.com/aws/smithy-go v1.24.0/go.mod h1:LEj2LM3rBRQJxPZTB4KuzZkaZYnZPnvgIhb4pu07mx0=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
||||
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
|
||||
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
|
||||
github.com/charmbracelet/bubbletea v1.3.10 h1:otUDHWMMzQSB0Pkc87rm691KZ3SWa4KUlvF9nRvCICw=
|
||||
github.com/charmbracelet/bubbletea v1.3.10/go.mod h1:ORQfo0fk8U+po9VaNvnV95UPWA1BitP1E0N6xJPlHr4=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc h1:4pZI35227imm7yK2bGPcfpFEmuY1gc2YSTShr4iJBfs=
|
||||
github.com/charmbracelet/colorprofile v0.2.3-0.20250311203215-f60798e515dc/go.mod h1:X4/0JoqgTIPSFcRA/P6INZzIuyqdFY5rm8tb41s9okk=
|
||||
github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
|
||||
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
|
||||
github.com/charmbracelet/x/ansi v0.10.1 h1:rL3Koar5XvX0pHGfovN03f5cxLbCF2YvLeyz7D2jVDQ=
|
||||
github.com/charmbracelet/x/ansi v0.10.1/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd h1:vy0GVL4jeHEwG5YOXDmi86oYw2yuYUGqz6a8sLwg0X8=
|
||||
github.com/charmbracelet/x/cellbuf v0.0.13-0.20250311204145-2c3ea96c31dd/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
|
||||
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
|
||||
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
|
||||
github.com/cloudflare/circl v1.6.3 h1:9GPOhQGF9MCYUeXyMYlqTR6a5gTrgR/fBLXvUgtVcg8=
|
||||
github.com/cloudflare/circl v1.6.3/go.mod h1:2eXP6Qfat4O/Yhh8BznvKnJ+uzEoTQ6jVKJRn81BiS4=
|
||||
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
|
||||
|
|
@ -33,6 +49,8 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1
|
|||
github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
|
||||
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM=
|
||||
github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8=
|
||||
github.com/frankban/quicktest v1.14.6/go.mod h1:4ptaffx2x8+WTWXmUCuVU6aPUX1/Mz7zb5vbUoiM6w0=
|
||||
github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k=
|
||||
|
|
@ -64,8 +82,20 @@ github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ=
|
|||
github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI=
|
||||
github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY=
|
||||
github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY=
|
||||
github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0=
|
||||
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
|
||||
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
|
||||
github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4=
|
||||
github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88=
|
||||
github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
|
||||
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI=
|
||||
github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo=
|
||||
github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA=
|
||||
github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo=
|
||||
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
|
||||
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
|
||||
github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w=
|
||||
github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
|
||||
github.com/pelletier/go-toml/v2 v2.2.4 h1:mye9XuhQ6gvn5h28+VilKrrPoQVanw5PMw/TB0t5Ec4=
|
||||
|
|
@ -74,6 +104,9 @@ github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRI
|
|||
github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
|
||||
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
|
||||
github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc=
|
||||
github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ=
|
||||
github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88=
|
||||
github.com/rogpeppe/go-internal v1.14.1 h1:UQB4HGPB6osV0SQTLymcB4TgvyWu6ZyliaW0tI/otEQ=
|
||||
github.com/rogpeppe/go-internal v1.14.1/go.mod h1:MaRKkUm5W0goXpeCfT7UZI6fk/L7L7so1lCWt35ZSgc=
|
||||
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
|
||||
|
|
@ -94,6 +127,8 @@ github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu
|
|||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
github.com/subosito/gotenv v1.6.0 h1:9NlTDc1FTs4qu0DDq7AEtTPNw6SVm7uBMsUCUjABIf8=
|
||||
github.com/subosito/gotenv v1.6.0/go.mod h1:Dk4QP5c2W3ibzajGcXpNraDfq2IrhjMIvMSWPKKo0FU=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
|
||||
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64=
|
||||
go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y=
|
||||
go.opentelemetry.io/otel v1.39.0 h1:8yPrr/S0ND9QEfTfdP9V+SiwT4E0G7Y5MO7p85nis48=
|
||||
|
|
@ -118,6 +153,7 @@ golang.org/x/net v0.50.0 h1:ucWh9eiCGyDR3vtzso0WMQinm2Dnt8cFMuQa9K33J60=
|
|||
golang.org/x/net v0.50.0/go.mod h1:UgoSli3F/pBgdJBHCTc+tp3gmrU4XswgGRgtnwWTfyM=
|
||||
golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4=
|
||||
golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
|
||||
golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
|
||||
golang.org/x/sys v0.41.0 h1:Ivj+2Cp/ylzLiEU89QhWblYnOE9zerudt9Ftecq2C6k=
|
||||
golang.org/x/sys v0.41.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
|
||||
|
|
@ -162,8 +198,8 @@ modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
|
|||
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
|
||||
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
|
||||
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
|
||||
modernc.org/sqlite v1.45.0 h1:r51cSGzKpbptxnby+EIIz5fop4VuE4qFoVEjNvWoObs=
|
||||
modernc.org/sqlite v1.45.0/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU=
|
||||
modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA=
|
||||
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
|
||||
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
|
||||
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
|
||||
|
|
|
|||
|
|
@ -48,9 +48,16 @@ func SemVer() string {
|
|||
}
|
||||
|
||||
// Main initialises and runs the CLI application.
|
||||
// This is the main entry point for the CLI.
|
||||
// Pass command services via WithCommands to register CLI commands
|
||||
// through the Core framework lifecycle.
|
||||
//
|
||||
// cli.Main(
|
||||
// cli.WithCommands("config", config.AddConfigCommands),
|
||||
// cli.WithCommands("doctor", doctor.AddDoctorCommands),
|
||||
// )
|
||||
//
|
||||
// Exits with code 1 on error or panic.
|
||||
func Main() {
|
||||
func Main(commands ...framework.Option) {
|
||||
// Recovery from panics
|
||||
defer func() {
|
||||
if r := recover(); r != nil {
|
||||
|
|
@ -60,17 +67,21 @@ func Main() {
|
|||
}
|
||||
}()
|
||||
|
||||
// Core services load first, then command services
|
||||
services := []framework.Option{
|
||||
framework.WithName("i18n", NewI18nService(I18nOptions{})),
|
||||
framework.WithName("log", NewLogService(log.Options{
|
||||
Level: log.LevelInfo,
|
||||
})),
|
||||
framework.WithName("workspace", workspace.New),
|
||||
}
|
||||
services = append(services, commands...)
|
||||
|
||||
// Initialise CLI runtime with services
|
||||
if err := Init(Options{
|
||||
AppName: AppName,
|
||||
Version: SemVer(),
|
||||
Services: []framework.Option{
|
||||
framework.WithName("i18n", NewI18nService(I18nOptions{})),
|
||||
framework.WithName("log", NewLogService(log.Options{
|
||||
Level: log.LevelInfo,
|
||||
})),
|
||||
framework.WithName("workspace", workspace.New),
|
||||
},
|
||||
AppName: AppName,
|
||||
Version: SemVer(),
|
||||
Services: services,
|
||||
}); err != nil {
|
||||
Error(err.Error())
|
||||
os.Exit(1)
|
||||
|
|
|
|||
|
|
@ -2,49 +2,34 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"context"
|
||||
|
||||
"forge.lthn.ai/core/go/pkg/framework"
|
||||
"github.com/spf13/cobra"
|
||||
)
|
||||
|
||||
// CommandRegistration is a function that adds commands to the root.
|
||||
type CommandRegistration func(root *cobra.Command)
|
||||
|
||||
var (
|
||||
registeredCommands []CommandRegistration
|
||||
registeredCommandsMu sync.Mutex
|
||||
commandsAttached bool
|
||||
)
|
||||
|
||||
// RegisterCommands registers a function that adds commands to the CLI.
|
||||
// Call this in your package's init() to register commands.
|
||||
// WithCommands creates a framework Option that registers a command group.
|
||||
// The register function receives the root command during service startup,
|
||||
// allowing commands to participate in the Core lifecycle.
|
||||
//
|
||||
// func init() {
|
||||
// cli.RegisterCommands(AddCommands)
|
||||
// }
|
||||
//
|
||||
// func AddCommands(root *cobra.Command) {
|
||||
// root.AddCommand(myCmd)
|
||||
// }
|
||||
func RegisterCommands(fn CommandRegistration) {
|
||||
registeredCommandsMu.Lock()
|
||||
defer registeredCommandsMu.Unlock()
|
||||
registeredCommands = append(registeredCommands, fn)
|
||||
|
||||
// If commands already attached (CLI already running), attach immediately
|
||||
if commandsAttached && instance != nil && instance.root != nil {
|
||||
fn(instance.root)
|
||||
}
|
||||
// cli.Main(
|
||||
// cli.WithCommands("config", config.AddConfigCommands),
|
||||
// cli.WithCommands("doctor", doctor.AddDoctorCommands),
|
||||
// )
|
||||
func WithCommands(name string, register func(root *Command)) framework.Option {
|
||||
return framework.WithName("cmd."+name, func(c *framework.Core) (any, error) {
|
||||
return &commandService{core: c, register: register}, nil
|
||||
})
|
||||
}
|
||||
|
||||
// attachRegisteredCommands calls all registered command functions.
|
||||
// Called by Init() after creating the root command.
|
||||
func attachRegisteredCommands(root *cobra.Command) {
|
||||
registeredCommandsMu.Lock()
|
||||
defer registeredCommandsMu.Unlock()
|
||||
|
||||
for _, fn := range registeredCommands {
|
||||
fn(root)
|
||||
}
|
||||
commandsAttached = true
|
||||
type commandService struct {
|
||||
core *framework.Core
|
||||
register func(root *Command)
|
||||
}
|
||||
|
||||
func (s *commandService) OnStartup(_ context.Context) error {
|
||||
if root, ok := s.core.App.(*cobra.Command); ok {
|
||||
s.register(root)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
144
pkg/cli/list.go
Normal file
144
pkg/cli/list.go
Normal file
|
|
@ -0,0 +1,144 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// listModel is the internal bubbletea model for interactive list selection.
|
||||
type listModel struct {
|
||||
items []string
|
||||
cursor int
|
||||
title string
|
||||
selected bool
|
||||
quitted bool
|
||||
}
|
||||
|
||||
func newListModel(items []string, title string) *listModel {
|
||||
return &listModel{
|
||||
items: items,
|
||||
title: title,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *listModel) moveDown() {
|
||||
m.cursor++
|
||||
if m.cursor >= len(m.items) {
|
||||
m.cursor = 0
|
||||
}
|
||||
}
|
||||
|
||||
func (m *listModel) moveUp() {
|
||||
m.cursor--
|
||||
if m.cursor < 0 {
|
||||
m.cursor = len(m.items) - 1
|
||||
}
|
||||
}
|
||||
|
||||
func (m *listModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *listModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyUp, tea.KeyShiftTab:
|
||||
m.moveUp()
|
||||
case tea.KeyDown, tea.KeyTab:
|
||||
m.moveDown()
|
||||
case tea.KeyEnter:
|
||||
m.selected = true
|
||||
return m, tea.Quit
|
||||
case tea.KeyEscape, tea.KeyCtrlC:
|
||||
m.quitted = true
|
||||
return m, tea.Quit
|
||||
case tea.KeyRunes:
|
||||
switch string(msg.Runes) {
|
||||
case "j":
|
||||
m.moveDown()
|
||||
case "k":
|
||||
m.moveUp()
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *listModel) View() string {
|
||||
var sb strings.Builder
|
||||
|
||||
if m.title != "" {
|
||||
sb.WriteString(BoldStyle.Render(m.title) + "\n\n")
|
||||
}
|
||||
|
||||
for i, item := range m.items {
|
||||
cursor := " "
|
||||
style := DimStyle
|
||||
if i == m.cursor {
|
||||
cursor = AccentStyle.Render(Glyph(":pointer:")) + " "
|
||||
style = BoldStyle
|
||||
}
|
||||
sb.WriteString(fmt.Sprintf("%s%s\n", cursor, style.Render(item)))
|
||||
}
|
||||
|
||||
sb.WriteString("\n" + DimStyle.Render("↑/↓ navigate • enter select • esc cancel"))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ListOption configures InteractiveList behaviour.
|
||||
type ListOption func(*listConfig)
|
||||
|
||||
type listConfig struct {
|
||||
height int
|
||||
}
|
||||
|
||||
// WithListHeight sets the visible height of the list (number of items shown).
|
||||
func WithListHeight(n int) ListOption {
|
||||
return func(c *listConfig) {
|
||||
c.height = n
|
||||
}
|
||||
}
|
||||
|
||||
// InteractiveList presents an interactive scrollable list and returns the
|
||||
// selected item's index and value. Returns -1 and empty string if cancelled.
|
||||
//
|
||||
// Falls back to numbered Select() when stdin is not a terminal (e.g. piped input).
|
||||
//
|
||||
// idx, value := cli.InteractiveList("Pick a repo:", repos)
|
||||
func InteractiveList(title string, items []string, opts ...ListOption) (int, string) {
|
||||
if len(items) == 0 {
|
||||
return -1, ""
|
||||
}
|
||||
|
||||
// Fall back to simple Select if not a terminal
|
||||
if !term.IsTerminal(0) {
|
||||
result, err := Select(title, items)
|
||||
if err != nil {
|
||||
return -1, ""
|
||||
}
|
||||
for i, item := range items {
|
||||
if item == result {
|
||||
return i, result
|
||||
}
|
||||
}
|
||||
return -1, ""
|
||||
}
|
||||
|
||||
m := newListModel(items, title)
|
||||
p := tea.NewProgram(m)
|
||||
finalModel, err := p.Run()
|
||||
if err != nil {
|
||||
return -1, ""
|
||||
}
|
||||
|
||||
final := finalModel.(*listModel)
|
||||
if final.quitted || !final.selected {
|
||||
return -1, ""
|
||||
}
|
||||
return final.cursor, final.items[final.cursor]
|
||||
}
|
||||
52
pkg/cli/list_test.go
Normal file
52
pkg/cli/list_test.go
Normal file
|
|
@ -0,0 +1,52 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestListModel_Good_Create(t *testing.T) {
|
||||
items := []string{"alpha", "beta", "gamma"}
|
||||
m := newListModel(items, "Pick one:")
|
||||
assert.Equal(t, 3, len(m.items))
|
||||
assert.Equal(t, 0, m.cursor)
|
||||
assert.Equal(t, "Pick one:", m.title)
|
||||
}
|
||||
|
||||
func TestListModel_Good_MoveDown(t *testing.T) {
|
||||
m := newListModel([]string{"a", "b", "c"}, "")
|
||||
m.moveDown()
|
||||
assert.Equal(t, 1, m.cursor)
|
||||
m.moveDown()
|
||||
assert.Equal(t, 2, m.cursor)
|
||||
}
|
||||
|
||||
func TestListModel_Good_MoveUp(t *testing.T) {
|
||||
m := newListModel([]string{"a", "b", "c"}, "")
|
||||
m.moveDown()
|
||||
m.moveDown()
|
||||
m.moveUp()
|
||||
assert.Equal(t, 1, m.cursor)
|
||||
}
|
||||
|
||||
func TestListModel_Good_WrapAround(t *testing.T) {
|
||||
m := newListModel([]string{"a", "b", "c"}, "")
|
||||
m.moveUp() // Should wrap to bottom
|
||||
assert.Equal(t, 2, m.cursor)
|
||||
}
|
||||
|
||||
func TestListModel_Good_View(t *testing.T) {
|
||||
m := newListModel([]string{"alpha", "beta"}, "Choose:")
|
||||
view := m.View()
|
||||
assert.Contains(t, view, "Choose:")
|
||||
assert.Contains(t, view, "alpha")
|
||||
assert.Contains(t, view, "beta")
|
||||
}
|
||||
|
||||
func TestListModel_Good_Selected(t *testing.T) {
|
||||
m := newListModel([]string{"a", "b", "c"}, "")
|
||||
m.moveDown()
|
||||
m.selected = true
|
||||
assert.Equal(t, "b", m.items[m.cursor])
|
||||
}
|
||||
106
pkg/cli/progressbar.go
Normal file
106
pkg/cli/progressbar.go
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"sync"
|
||||
)
|
||||
|
||||
// ProgressHandle controls a progress bar.
|
||||
type ProgressHandle struct {
|
||||
mu sync.Mutex
|
||||
current int
|
||||
total int
|
||||
message string
|
||||
width int
|
||||
}
|
||||
|
||||
// NewProgressBar creates a new progress bar with the given total.
|
||||
func NewProgressBar(total int) *ProgressHandle {
|
||||
return &ProgressHandle{
|
||||
total: total,
|
||||
width: 30,
|
||||
}
|
||||
}
|
||||
|
||||
// Current returns the current progress value.
|
||||
func (p *ProgressHandle) Current() int {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
return p.current
|
||||
}
|
||||
|
||||
// Total returns the total value.
|
||||
func (p *ProgressHandle) Total() int {
|
||||
return p.total
|
||||
}
|
||||
|
||||
// Increment advances the progress by 1.
|
||||
func (p *ProgressHandle) Increment() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if p.current < p.total {
|
||||
p.current++
|
||||
}
|
||||
p.render()
|
||||
}
|
||||
|
||||
// Set sets the progress to a specific value.
|
||||
func (p *ProgressHandle) Set(n int) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
if n > p.total {
|
||||
n = p.total
|
||||
}
|
||||
if n < 0 {
|
||||
n = 0
|
||||
}
|
||||
p.current = n
|
||||
p.render()
|
||||
}
|
||||
|
||||
// SetMessage sets the message displayed alongside the bar.
|
||||
func (p *ProgressHandle) SetMessage(msg string) {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.message = msg
|
||||
p.render()
|
||||
}
|
||||
|
||||
// Done completes the progress bar and moves to a new line.
|
||||
func (p *ProgressHandle) Done() {
|
||||
p.mu.Lock()
|
||||
defer p.mu.Unlock()
|
||||
p.current = p.total
|
||||
p.render()
|
||||
fmt.Println()
|
||||
}
|
||||
|
||||
// String returns the rendered progress bar without ANSI cursor control.
|
||||
func (p *ProgressHandle) String() string {
|
||||
pct := 0
|
||||
if p.total > 0 {
|
||||
pct = (p.current * 100) / p.total
|
||||
}
|
||||
|
||||
filled := 0
|
||||
if p.total > 0 {
|
||||
filled = (p.width * p.current) / p.total
|
||||
}
|
||||
if filled > p.width {
|
||||
filled = p.width
|
||||
}
|
||||
empty := p.width - filled
|
||||
|
||||
bar := "[" + strings.Repeat("\u2588", filled) + strings.Repeat("\u2591", empty) + "]"
|
||||
|
||||
if p.message != "" {
|
||||
return fmt.Sprintf("%s %3d%% %s", bar, pct, p.message)
|
||||
}
|
||||
return fmt.Sprintf("%s %3d%%", bar, pct)
|
||||
}
|
||||
|
||||
// render outputs the progress bar, overwriting the current line.
|
||||
func (p *ProgressHandle) render() {
|
||||
fmt.Printf("\033[2K\r%s", p.String())
|
||||
}
|
||||
60
pkg/cli/progressbar_test.go
Normal file
60
pkg/cli/progressbar_test.go
Normal file
|
|
@ -0,0 +1,60 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestProgressBar_Good_Create(t *testing.T) {
|
||||
pb := NewProgressBar(100)
|
||||
require.NotNil(t, pb)
|
||||
assert.Equal(t, 0, pb.Current())
|
||||
assert.Equal(t, 100, pb.Total())
|
||||
}
|
||||
|
||||
func TestProgressBar_Good_Increment(t *testing.T) {
|
||||
pb := NewProgressBar(10)
|
||||
pb.Increment()
|
||||
assert.Equal(t, 1, pb.Current())
|
||||
pb.Increment()
|
||||
assert.Equal(t, 2, pb.Current())
|
||||
}
|
||||
|
||||
func TestProgressBar_Good_SetMessage(t *testing.T) {
|
||||
pb := NewProgressBar(10)
|
||||
pb.SetMessage("Processing file.go")
|
||||
assert.Equal(t, "Processing file.go", pb.message)
|
||||
}
|
||||
|
||||
func TestProgressBar_Good_Set(t *testing.T) {
|
||||
pb := NewProgressBar(100)
|
||||
pb.Set(50)
|
||||
assert.Equal(t, 50, pb.Current())
|
||||
}
|
||||
|
||||
func TestProgressBar_Good_Done(t *testing.T) {
|
||||
pb := NewProgressBar(5)
|
||||
for i := 0; i < 5; i++ {
|
||||
pb.Increment()
|
||||
}
|
||||
pb.Done()
|
||||
// After Done, Current == Total
|
||||
assert.Equal(t, 5, pb.Current())
|
||||
}
|
||||
|
||||
func TestProgressBar_Bad_ExceedsTotal(t *testing.T) {
|
||||
pb := NewProgressBar(2)
|
||||
pb.Increment()
|
||||
pb.Increment()
|
||||
pb.Increment() // Should clamp to total
|
||||
assert.Equal(t, 2, pb.Current())
|
||||
}
|
||||
|
||||
func TestProgressBar_Good_Render(t *testing.T) {
|
||||
pb := NewProgressBar(10)
|
||||
pb.Set(5)
|
||||
rendered := pb.String()
|
||||
assert.Contains(t, rendered, "50%")
|
||||
}
|
||||
|
|
@ -63,9 +63,6 @@ func Init(opts Options) error {
|
|||
SilenceUsage: true,
|
||||
}
|
||||
|
||||
// Attach all registered commands
|
||||
attachRegisteredCommands(rootCmd)
|
||||
|
||||
// Build signal service options
|
||||
var signalOpts []SignalOption
|
||||
if opts.OnReload != nil {
|
||||
|
|
|
|||
107
pkg/cli/spinner.go
Normal file
107
pkg/cli/spinner.go
Normal file
|
|
@ -0,0 +1,107 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
"time"
|
||||
)
|
||||
|
||||
// SpinnerHandle controls a running spinner.
|
||||
type SpinnerHandle struct {
|
||||
mu sync.Mutex
|
||||
message string
|
||||
done bool
|
||||
ticker *time.Ticker
|
||||
stopCh chan struct{}
|
||||
}
|
||||
|
||||
// NewSpinner starts an async spinner with the given message.
|
||||
// Call Stop(), Done(), or Fail() to stop it.
|
||||
func NewSpinner(message string) *SpinnerHandle {
|
||||
s := &SpinnerHandle{
|
||||
message: message,
|
||||
ticker: time.NewTicker(100 * time.Millisecond),
|
||||
stopCh: make(chan struct{}),
|
||||
}
|
||||
|
||||
frames := []string{"⠋", "⠙", "⠹", "⠸", "⠼", "⠴", "⠦", "⠧", "⠇", "⠏"}
|
||||
if !ColorEnabled() {
|
||||
frames = []string{"|", "/", "-", "\\"}
|
||||
}
|
||||
|
||||
go func() {
|
||||
i := 0
|
||||
for {
|
||||
select {
|
||||
case <-s.stopCh:
|
||||
return
|
||||
case <-s.ticker.C:
|
||||
s.mu.Lock()
|
||||
if !s.done {
|
||||
fmt.Printf("\033[2K\r%s %s", DimStyle.Render(frames[i%len(frames)]), s.message)
|
||||
}
|
||||
s.mu.Unlock()
|
||||
i++
|
||||
}
|
||||
}
|
||||
}()
|
||||
|
||||
return s
|
||||
}
|
||||
|
||||
// Message returns the current spinner message.
|
||||
func (s *SpinnerHandle) Message() string {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
return s.message
|
||||
}
|
||||
|
||||
// Update changes the spinner message.
|
||||
func (s *SpinnerHandle) Update(message string) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.message = message
|
||||
}
|
||||
|
||||
// Stop stops the spinner silently (clears the line).
|
||||
func (s *SpinnerHandle) Stop() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if s.done {
|
||||
return
|
||||
}
|
||||
s.done = true
|
||||
s.ticker.Stop()
|
||||
close(s.stopCh)
|
||||
fmt.Print("\033[2K\r")
|
||||
}
|
||||
|
||||
// Done stops the spinner with a success message.
|
||||
func (s *SpinnerHandle) Done(message string) {
|
||||
s.mu.Lock()
|
||||
alreadyDone := s.done
|
||||
s.done = true
|
||||
s.mu.Unlock()
|
||||
|
||||
if alreadyDone {
|
||||
return
|
||||
}
|
||||
s.ticker.Stop()
|
||||
close(s.stopCh)
|
||||
fmt.Printf("\033[2K\r%s\n", SuccessStyle.Render(Glyph(":check:")+" "+message))
|
||||
}
|
||||
|
||||
// Fail stops the spinner with an error message.
|
||||
func (s *SpinnerHandle) Fail(message string) {
|
||||
s.mu.Lock()
|
||||
alreadyDone := s.done
|
||||
s.done = true
|
||||
s.mu.Unlock()
|
||||
|
||||
if alreadyDone {
|
||||
return
|
||||
}
|
||||
s.ticker.Stop()
|
||||
close(s.stopCh)
|
||||
fmt.Printf("\033[2K\r%s\n", ErrorStyle.Render(Glyph(":cross:")+" "+message))
|
||||
}
|
||||
41
pkg/cli/spinner_test.go
Normal file
41
pkg/cli/spinner_test.go
Normal file
|
|
@ -0,0 +1,41 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestSpinner_Good_CreateAndStop(t *testing.T) {
|
||||
s := NewSpinner("Loading...")
|
||||
require.NotNil(t, s)
|
||||
assert.Equal(t, "Loading...", s.Message())
|
||||
s.Stop()
|
||||
}
|
||||
|
||||
func TestSpinner_Good_UpdateMessage(t *testing.T) {
|
||||
s := NewSpinner("Step 1")
|
||||
s.Update("Step 2")
|
||||
assert.Equal(t, "Step 2", s.Message())
|
||||
s.Stop()
|
||||
}
|
||||
|
||||
func TestSpinner_Good_Done(t *testing.T) {
|
||||
s := NewSpinner("Building")
|
||||
s.Done("Build complete")
|
||||
// After Done, spinner is stopped — calling Stop again is safe
|
||||
s.Stop()
|
||||
}
|
||||
|
||||
func TestSpinner_Good_Fail(t *testing.T) {
|
||||
s := NewSpinner("Checking")
|
||||
s.Fail("Check failed")
|
||||
s.Stop()
|
||||
}
|
||||
|
||||
func TestSpinner_Good_DoubleStop(t *testing.T) {
|
||||
s := NewSpinner("Loading")
|
||||
s.Stop()
|
||||
s.Stop() // Should not panic
|
||||
}
|
||||
146
pkg/cli/stubs.go
Normal file
146
pkg/cli/stubs.go
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
package cli
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Form (stubbed — simple fallback, will use charmbracelet/huh later)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// FieldType defines the type of a form field.
|
||||
type FieldType string
|
||||
|
||||
const (
|
||||
FieldText FieldType = "text"
|
||||
FieldPassword FieldType = "password"
|
||||
FieldConfirm FieldType = "confirm"
|
||||
FieldSelect FieldType = "select"
|
||||
)
|
||||
|
||||
// FormField describes a single field in a form.
|
||||
type FormField struct {
|
||||
Label string
|
||||
Key string
|
||||
Type FieldType
|
||||
Default string
|
||||
Placeholder string
|
||||
Options []string // For FieldSelect
|
||||
Required bool
|
||||
Validator func(string) error
|
||||
}
|
||||
|
||||
// Form presents a multi-field form and returns the values keyed by FormField.Key.
|
||||
// Currently falls back to sequential Question()/Confirm()/Select() calls.
|
||||
// Will be replaced with charmbracelet/huh interactive form later.
|
||||
//
|
||||
// results, err := cli.Form([]cli.FormField{
|
||||
// {Label: "Name", Key: "name", Type: cli.FieldText, Required: true},
|
||||
// {Label: "Password", Key: "pass", Type: cli.FieldPassword},
|
||||
// {Label: "Accept terms?", Key: "terms", Type: cli.FieldConfirm},
|
||||
// })
|
||||
func Form(fields []FormField) (map[string]string, error) {
|
||||
results := make(map[string]string, len(fields))
|
||||
|
||||
for _, f := range fields {
|
||||
switch f.Type {
|
||||
case FieldPassword:
|
||||
val := Question(f.Label+":", WithDefault(f.Default))
|
||||
results[f.Key] = val
|
||||
case FieldConfirm:
|
||||
if Confirm(f.Label) {
|
||||
results[f.Key] = "true"
|
||||
} else {
|
||||
results[f.Key] = "false"
|
||||
}
|
||||
case FieldSelect:
|
||||
val, err := Select(f.Label, f.Options)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
results[f.Key] = val
|
||||
default: // FieldText
|
||||
var opts []QuestionOption
|
||||
if f.Default != "" {
|
||||
opts = append(opts, WithDefault(f.Default))
|
||||
}
|
||||
if f.Required {
|
||||
opts = append(opts, RequiredInput())
|
||||
}
|
||||
if f.Validator != nil {
|
||||
opts = append(opts, WithValidator(f.Validator))
|
||||
}
|
||||
results[f.Key] = Question(f.Label+":", opts...)
|
||||
}
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// FilePicker (stubbed — will use charmbracelet/filepicker later)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// FilePickerOption configures FilePicker behaviour.
|
||||
type FilePickerOption func(*filePickerConfig)
|
||||
|
||||
type filePickerConfig struct {
|
||||
dir string
|
||||
extensions []string
|
||||
}
|
||||
|
||||
// InDirectory sets the starting directory for the file picker.
|
||||
func InDirectory(dir string) FilePickerOption {
|
||||
return func(c *filePickerConfig) {
|
||||
c.dir = dir
|
||||
}
|
||||
}
|
||||
|
||||
// WithExtensions filters to specific file extensions (e.g. ".go", ".yaml").
|
||||
func WithExtensions(exts ...string) FilePickerOption {
|
||||
return func(c *filePickerConfig) {
|
||||
c.extensions = exts
|
||||
}
|
||||
}
|
||||
|
||||
// FilePicker presents a file browser and returns the selected path.
|
||||
// Currently falls back to a text prompt. Will be replaced with an
|
||||
// interactive file browser later.
|
||||
//
|
||||
// path, err := cli.FilePicker(cli.InDirectory("."), cli.WithExtensions(".go"))
|
||||
func FilePicker(opts ...FilePickerOption) (string, error) {
|
||||
cfg := &filePickerConfig{dir: "."}
|
||||
for _, opt := range opts {
|
||||
opt(cfg)
|
||||
}
|
||||
|
||||
hint := "File path"
|
||||
if cfg.dir != "." {
|
||||
hint += " (from " + cfg.dir + ")"
|
||||
}
|
||||
return Question(hint + ":"), nil
|
||||
}
|
||||
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
// Tabs (stubbed — will use bubbletea model later)
|
||||
// ──────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
// TabItem describes a tab with a title and content.
|
||||
type TabItem struct {
|
||||
Title string
|
||||
Content string
|
||||
}
|
||||
|
||||
// Tabs displays tabbed content. Currently prints all tabs sequentially.
|
||||
// Will be replaced with an interactive tab switcher later.
|
||||
//
|
||||
// cli.Tabs([]cli.TabItem{
|
||||
// {Title: "Overview", Content: summaryText},
|
||||
// {Title: "Details", Content: detailText},
|
||||
// })
|
||||
func Tabs(items []TabItem) error {
|
||||
for i, tab := range items {
|
||||
if i > 0 {
|
||||
Blank()
|
||||
}
|
||||
Section(tab.Title)
|
||||
Println("%s", tab.Content)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
35
pkg/cli/stubs_test.go
Normal file
35
pkg/cli/stubs_test.go
Normal file
|
|
@ -0,0 +1,35 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestFormField_Good_Types(t *testing.T) {
|
||||
fields := []FormField{
|
||||
{Label: "Name", Key: "name", Type: FieldText},
|
||||
{Label: "Password", Key: "pass", Type: FieldPassword},
|
||||
{Label: "Accept", Key: "ok", Type: FieldConfirm},
|
||||
}
|
||||
assert.Equal(t, 3, len(fields))
|
||||
assert.Equal(t, FieldText, fields[0].Type)
|
||||
assert.Equal(t, FieldPassword, fields[1].Type)
|
||||
assert.Equal(t, FieldConfirm, fields[2].Type)
|
||||
}
|
||||
|
||||
func TestFieldType_Good_Constants(t *testing.T) {
|
||||
assert.Equal(t, FieldType("text"), FieldText)
|
||||
assert.Equal(t, FieldType("password"), FieldPassword)
|
||||
assert.Equal(t, FieldType("confirm"), FieldConfirm)
|
||||
assert.Equal(t, FieldType("select"), FieldSelect)
|
||||
}
|
||||
|
||||
func TestTabItem_Good_Structure(t *testing.T) {
|
||||
tabs := []TabItem{
|
||||
{Title: "Overview", Content: "overview content"},
|
||||
{Title: "Details", Content: "detail content"},
|
||||
}
|
||||
assert.Equal(t, 2, len(tabs))
|
||||
assert.Equal(t, "Overview", tabs[0].Title)
|
||||
}
|
||||
183
pkg/cli/textinput.go
Normal file
183
pkg/cli/textinput.go
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// textInputModel is the internal bubbletea model for text input.
|
||||
type textInputModel struct {
|
||||
title string
|
||||
placeholder string
|
||||
value string
|
||||
masked bool
|
||||
submitted bool
|
||||
cancelled bool
|
||||
cursorPos int
|
||||
validator func(string) error
|
||||
err error
|
||||
}
|
||||
|
||||
func newTextInputModel(title, placeholder string) *textInputModel {
|
||||
return &textInputModel{
|
||||
title: title,
|
||||
placeholder: placeholder,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *textInputModel) insertChar(ch rune) {
|
||||
m.value = m.value[:m.cursorPos] + string(ch) + m.value[m.cursorPos:]
|
||||
m.cursorPos++
|
||||
}
|
||||
|
||||
func (m *textInputModel) backspace() {
|
||||
if m.cursorPos > 0 {
|
||||
m.value = m.value[:m.cursorPos-1] + m.value[m.cursorPos:]
|
||||
m.cursorPos--
|
||||
}
|
||||
}
|
||||
|
||||
func (m *textInputModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *textInputModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyEnter:
|
||||
if m.validator != nil {
|
||||
if err := m.validator(m.value); err != nil {
|
||||
m.err = err
|
||||
return m, nil
|
||||
}
|
||||
}
|
||||
if m.value == "" && m.placeholder != "" {
|
||||
m.value = m.placeholder
|
||||
}
|
||||
m.submitted = true
|
||||
return m, tea.Quit
|
||||
case tea.KeyEscape, tea.KeyCtrlC:
|
||||
m.cancelled = true
|
||||
return m, tea.Quit
|
||||
case tea.KeyBackspace:
|
||||
m.backspace()
|
||||
m.err = nil
|
||||
case tea.KeyLeft:
|
||||
if m.cursorPos > 0 {
|
||||
m.cursorPos--
|
||||
}
|
||||
case tea.KeyRight:
|
||||
if m.cursorPos < len(m.value) {
|
||||
m.cursorPos++
|
||||
}
|
||||
case tea.KeyRunes:
|
||||
for _, ch := range msg.Runes {
|
||||
m.insertChar(ch)
|
||||
}
|
||||
m.err = nil
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *textInputModel) View() string {
|
||||
var sb strings.Builder
|
||||
|
||||
sb.WriteString(BoldStyle.Render(m.title) + "\n\n")
|
||||
|
||||
display := m.value
|
||||
if m.masked {
|
||||
display = strings.Repeat("*", len(m.value))
|
||||
}
|
||||
|
||||
if display == "" && m.placeholder != "" {
|
||||
sb.WriteString(DimStyle.Render(m.placeholder))
|
||||
} else {
|
||||
sb.WriteString(display)
|
||||
}
|
||||
sb.WriteString(AccentStyle.Render("\u2588")) // Cursor block
|
||||
|
||||
if m.err != nil {
|
||||
sb.WriteString("\n" + ErrorStyle.Render(fmt.Sprintf(" %s", m.err)))
|
||||
}
|
||||
|
||||
sb.WriteString("\n\n" + DimStyle.Render("enter submit \u2022 esc cancel"))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// TextInputOption configures TextInput behaviour.
|
||||
type TextInputOption func(*textInputConfig)
|
||||
|
||||
type textInputConfig struct {
|
||||
placeholder string
|
||||
masked bool
|
||||
validator func(string) error
|
||||
}
|
||||
|
||||
// WithTextPlaceholder sets placeholder text shown when input is empty.
|
||||
func WithTextPlaceholder(text string) TextInputOption {
|
||||
return func(c *textInputConfig) {
|
||||
c.placeholder = text
|
||||
}
|
||||
}
|
||||
|
||||
// WithMask hides input characters (for passwords).
|
||||
func WithMask() TextInputOption {
|
||||
return func(c *textInputConfig) {
|
||||
c.masked = true
|
||||
}
|
||||
}
|
||||
|
||||
// WithInputValidator adds a validation function for the input.
|
||||
func WithInputValidator(fn func(string) error) TextInputOption {
|
||||
return func(c *textInputConfig) {
|
||||
c.validator = fn
|
||||
}
|
||||
}
|
||||
|
||||
// TextInput presents a styled text input prompt and returns the entered value.
|
||||
// Returns empty string if cancelled.
|
||||
//
|
||||
// Falls back to Question() when stdin is not a terminal.
|
||||
//
|
||||
// name, err := cli.TextInput("Enter your name:", cli.WithTextPlaceholder("Anonymous"))
|
||||
// pass, err := cli.TextInput("Password:", cli.WithMask())
|
||||
func TextInput(title string, opts ...TextInputOption) (string, error) {
|
||||
cfg := &textInputConfig{}
|
||||
for _, opt := range opts {
|
||||
opt(cfg)
|
||||
}
|
||||
|
||||
// Fall back to simple Question if not a terminal
|
||||
if !term.IsTerminal(0) {
|
||||
var qopts []QuestionOption
|
||||
if cfg.placeholder != "" {
|
||||
qopts = append(qopts, WithDefault(cfg.placeholder))
|
||||
}
|
||||
if cfg.validator != nil {
|
||||
qopts = append(qopts, WithValidator(cfg.validator))
|
||||
}
|
||||
return Question(title, qopts...), nil
|
||||
}
|
||||
|
||||
m := newTextInputModel(title, cfg.placeholder)
|
||||
m.masked = cfg.masked
|
||||
m.validator = cfg.validator
|
||||
|
||||
p := tea.NewProgram(m)
|
||||
finalModel, err := p.Run()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
final := finalModel.(*textInputModel)
|
||||
if final.cancelled {
|
||||
return "", nil
|
||||
}
|
||||
return final.value, nil
|
||||
}
|
||||
59
pkg/cli/textinput_test.go
Normal file
59
pkg/cli/textinput_test.go
Normal file
|
|
@ -0,0 +1,59 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestTextInputModel_Good_Create(t *testing.T) {
|
||||
m := newTextInputModel("Enter name:", "")
|
||||
assert.Equal(t, "Enter name:", m.title)
|
||||
assert.Equal(t, "", m.value)
|
||||
}
|
||||
|
||||
func TestTextInputModel_Good_WithPlaceholder(t *testing.T) {
|
||||
m := newTextInputModel("Name:", "John")
|
||||
assert.Equal(t, "John", m.placeholder)
|
||||
}
|
||||
|
||||
func TestTextInputModel_Good_TypeCharacters(t *testing.T) {
|
||||
m := newTextInputModel("Name:", "")
|
||||
m.insertChar('H')
|
||||
m.insertChar('i')
|
||||
assert.Equal(t, "Hi", m.value)
|
||||
}
|
||||
|
||||
func TestTextInputModel_Good_Backspace(t *testing.T) {
|
||||
m := newTextInputModel("Name:", "")
|
||||
m.insertChar('A')
|
||||
m.insertChar('B')
|
||||
m.backspace()
|
||||
assert.Equal(t, "A", m.value)
|
||||
}
|
||||
|
||||
func TestTextInputModel_Good_BackspaceEmpty(t *testing.T) {
|
||||
m := newTextInputModel("Name:", "")
|
||||
m.backspace() // Should not panic
|
||||
assert.Equal(t, "", m.value)
|
||||
}
|
||||
|
||||
func TestTextInputModel_Good_Masked(t *testing.T) {
|
||||
m := newTextInputModel("Password:", "")
|
||||
m.masked = true
|
||||
m.insertChar('s')
|
||||
m.insertChar('e')
|
||||
m.insertChar('c')
|
||||
assert.Equal(t, "sec", m.value) // Internal value is real
|
||||
view := m.View()
|
||||
assert.NotContains(t, view, "sec") // Display is masked
|
||||
assert.Contains(t, view, "***")
|
||||
}
|
||||
|
||||
func TestTextInputModel_Good_View(t *testing.T) {
|
||||
m := newTextInputModel("Enter:", "")
|
||||
m.insertChar('X')
|
||||
view := m.View()
|
||||
assert.Contains(t, view, "Enter:")
|
||||
assert.Contains(t, view, "X")
|
||||
}
|
||||
85
pkg/cli/tui.go
Normal file
85
pkg/cli/tui.go
Normal file
|
|
@ -0,0 +1,85 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
// Model is the interface for interactive TUI applications.
|
||||
// It mirrors bubbletea's Model but uses our own types so domain
|
||||
// packages never import bubbletea directly.
|
||||
type Model interface {
|
||||
// Init returns an initial command to run.
|
||||
Init() Cmd
|
||||
|
||||
// Update handles a message and returns the updated model and command.
|
||||
Update(msg Msg) (Model, Cmd)
|
||||
|
||||
// View returns the string representation of the UI.
|
||||
View() string
|
||||
}
|
||||
|
||||
// Msg is a message passed to Update. Can be any type.
|
||||
type Msg = tea.Msg
|
||||
|
||||
// Cmd is a function that returns a message. Nil means no command.
|
||||
type Cmd = tea.Cmd
|
||||
|
||||
// Quit is a command that tells the TUI to exit.
|
||||
var Quit = tea.Quit
|
||||
|
||||
// KeyMsg represents a key press event.
|
||||
type KeyMsg = tea.KeyMsg
|
||||
|
||||
// KeyType represents the type of key pressed.
|
||||
type KeyType = tea.KeyType
|
||||
|
||||
// Key type constants.
|
||||
const (
|
||||
KeyEnter KeyType = tea.KeyEnter
|
||||
KeyEsc KeyType = tea.KeyEscape
|
||||
KeyCtrlC KeyType = tea.KeyCtrlC
|
||||
KeyUp KeyType = tea.KeyUp
|
||||
KeyDown KeyType = tea.KeyDown
|
||||
KeyLeft KeyType = tea.KeyLeft
|
||||
KeyRight KeyType = tea.KeyRight
|
||||
KeyTab KeyType = tea.KeyTab
|
||||
KeyBackspace KeyType = tea.KeyBackspace
|
||||
KeySpace KeyType = tea.KeySpace
|
||||
KeyHome KeyType = tea.KeyHome
|
||||
KeyEnd KeyType = tea.KeyEnd
|
||||
KeyPgUp KeyType = tea.KeyPgUp
|
||||
KeyPgDown KeyType = tea.KeyPgDown
|
||||
KeyDelete KeyType = tea.KeyDelete
|
||||
KeyShiftTab KeyType = tea.KeyShiftTab
|
||||
KeyRunes KeyType = tea.KeyRunes
|
||||
)
|
||||
|
||||
// adapter wraps our Model interface into a bubbletea.Model.
|
||||
type adapter struct {
|
||||
inner Model
|
||||
}
|
||||
|
||||
func (a adapter) Init() tea.Cmd {
|
||||
return a.inner.Init()
|
||||
}
|
||||
|
||||
func (a adapter) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
m, cmd := a.inner.Update(msg)
|
||||
return adapter{inner: m}, cmd
|
||||
}
|
||||
|
||||
func (a adapter) View() string {
|
||||
return a.inner.View()
|
||||
}
|
||||
|
||||
// RunTUI runs an interactive TUI application using the provided Model.
|
||||
// This is the escape hatch for complex interactive UIs that need the
|
||||
// full bubbletea event loop. For simple spinners, progress bars, and
|
||||
// lists, use the dedicated helpers instead.
|
||||
//
|
||||
// err := cli.RunTUI(&myModel{items: items})
|
||||
func RunTUI(m Model) error {
|
||||
p := tea.NewProgram(adapter{inner: m})
|
||||
_, err := p.Run()
|
||||
return err
|
||||
}
|
||||
55
pkg/cli/tui_test.go
Normal file
55
pkg/cli/tui_test.go
Normal file
|
|
@ -0,0 +1,55 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
// testModel is a minimal Model that quits immediately.
|
||||
type testModel struct {
|
||||
initCalled bool
|
||||
updateCalled bool
|
||||
viewCalled bool
|
||||
}
|
||||
|
||||
func (m *testModel) Init() Cmd {
|
||||
m.initCalled = true
|
||||
return Quit
|
||||
}
|
||||
|
||||
func (m *testModel) Update(msg Msg) (Model, Cmd) {
|
||||
m.updateCalled = true
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *testModel) View() string {
|
||||
m.viewCalled = true
|
||||
return "test view"
|
||||
}
|
||||
|
||||
func TestModel_Good_InterfaceSatisfied(t *testing.T) {
|
||||
var m Model = &testModel{}
|
||||
assert.NotNil(t, m)
|
||||
}
|
||||
|
||||
func TestQuitCmd_Good_ReturnsQuitMsg(t *testing.T) {
|
||||
cmd := Quit
|
||||
assert.NotNil(t, cmd)
|
||||
}
|
||||
|
||||
func TestKeyMsg_Good_Type(t *testing.T) {
|
||||
// Verify our re-exported KeyType constants match bubbletea's
|
||||
assert.Equal(t, KeyEnter, KeyEnter)
|
||||
assert.Equal(t, KeyEsc, KeyEsc)
|
||||
}
|
||||
|
||||
func TestKeyTypes_Good_Constants(t *testing.T) {
|
||||
// Verify key type constants exist and are distinct
|
||||
keys := []KeyType{KeyEnter, KeyEsc, KeyCtrlC, KeyUp, KeyDown, KeyTab, KeyBackspace}
|
||||
seen := make(map[KeyType]bool)
|
||||
for _, k := range keys {
|
||||
assert.False(t, seen[k], "duplicate key type")
|
||||
seen[k] = true
|
||||
}
|
||||
}
|
||||
176
pkg/cli/viewport.go
Normal file
176
pkg/cli/viewport.go
Normal file
|
|
@ -0,0 +1,176 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"golang.org/x/term"
|
||||
)
|
||||
|
||||
// viewportModel is the internal bubbletea model for scrollable content.
|
||||
type viewportModel struct {
|
||||
title string
|
||||
lines []string
|
||||
offset int
|
||||
height int
|
||||
quitted bool
|
||||
}
|
||||
|
||||
func newViewportModel(content, title string, height int) *viewportModel {
|
||||
lines := strings.Split(content, "\n")
|
||||
return &viewportModel{
|
||||
title: title,
|
||||
lines: lines,
|
||||
height: height,
|
||||
}
|
||||
}
|
||||
|
||||
func (m *viewportModel) scrollDown() {
|
||||
maxOffset := len(m.lines) - m.height
|
||||
if maxOffset < 0 {
|
||||
maxOffset = 0
|
||||
}
|
||||
if m.offset < maxOffset {
|
||||
m.offset++
|
||||
}
|
||||
}
|
||||
|
||||
func (m *viewportModel) scrollUp() {
|
||||
if m.offset > 0 {
|
||||
m.offset--
|
||||
}
|
||||
}
|
||||
|
||||
func (m *viewportModel) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
func (m *viewportModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
case tea.KeyMsg:
|
||||
switch msg.Type {
|
||||
case tea.KeyUp:
|
||||
m.scrollUp()
|
||||
case tea.KeyDown:
|
||||
m.scrollDown()
|
||||
case tea.KeyPgUp:
|
||||
for i := 0; i < m.height; i++ {
|
||||
m.scrollUp()
|
||||
}
|
||||
case tea.KeyPgDown:
|
||||
for i := 0; i < m.height; i++ {
|
||||
m.scrollDown()
|
||||
}
|
||||
case tea.KeyHome:
|
||||
m.offset = 0
|
||||
case tea.KeyEnd:
|
||||
maxOffset := len(m.lines) - m.height
|
||||
if maxOffset < 0 {
|
||||
maxOffset = 0
|
||||
}
|
||||
m.offset = maxOffset
|
||||
case tea.KeyEscape, tea.KeyCtrlC:
|
||||
m.quitted = true
|
||||
return m, tea.Quit
|
||||
case tea.KeyRunes:
|
||||
switch string(msg.Runes) {
|
||||
case "q":
|
||||
m.quitted = true
|
||||
return m, tea.Quit
|
||||
case "j":
|
||||
m.scrollDown()
|
||||
case "k":
|
||||
m.scrollUp()
|
||||
case "g":
|
||||
m.offset = 0
|
||||
case "G":
|
||||
maxOffset := len(m.lines) - m.height
|
||||
if maxOffset < 0 {
|
||||
maxOffset = 0
|
||||
}
|
||||
m.offset = maxOffset
|
||||
}
|
||||
}
|
||||
}
|
||||
return m, nil
|
||||
}
|
||||
|
||||
func (m *viewportModel) View() string {
|
||||
var sb strings.Builder
|
||||
|
||||
if m.title != "" {
|
||||
sb.WriteString(BoldStyle.Render(m.title) + "\n")
|
||||
sb.WriteString(DimStyle.Render(strings.Repeat("\u2500", len(m.title))) + "\n")
|
||||
}
|
||||
|
||||
// Visible window
|
||||
end := m.offset + m.height
|
||||
if end > len(m.lines) {
|
||||
end = len(m.lines)
|
||||
}
|
||||
for _, line := range m.lines[m.offset:end] {
|
||||
sb.WriteString(line + "\n")
|
||||
}
|
||||
|
||||
// Scroll indicator
|
||||
total := len(m.lines)
|
||||
if total > m.height {
|
||||
pct := (m.offset * 100) / (total - m.height)
|
||||
sb.WriteString(DimStyle.Render(fmt.Sprintf("\n%d%% (%d/%d lines)", pct, m.offset+m.height, total)))
|
||||
}
|
||||
|
||||
sb.WriteString("\n" + DimStyle.Render("\u2191/\u2193 scroll \u2022 PgUp/PgDn page \u2022 q quit"))
|
||||
|
||||
return sb.String()
|
||||
}
|
||||
|
||||
// ViewportOption configures Viewport behaviour.
|
||||
type ViewportOption func(*viewportConfig)
|
||||
|
||||
type viewportConfig struct {
|
||||
title string
|
||||
height int
|
||||
}
|
||||
|
||||
// WithViewportTitle sets the title shown above the viewport.
|
||||
func WithViewportTitle(title string) ViewportOption {
|
||||
return func(c *viewportConfig) {
|
||||
c.title = title
|
||||
}
|
||||
}
|
||||
|
||||
// WithViewportHeight sets the visible height in lines.
|
||||
func WithViewportHeight(n int) ViewportOption {
|
||||
return func(c *viewportConfig) {
|
||||
c.height = n
|
||||
}
|
||||
}
|
||||
|
||||
// Viewport displays scrollable content in the terminal.
|
||||
// Falls back to printing the full content when stdin is not a terminal.
|
||||
//
|
||||
// cli.Viewport(longContent, WithViewportTitle("Build Log"), WithViewportHeight(20))
|
||||
func Viewport(content string, opts ...ViewportOption) error {
|
||||
cfg := &viewportConfig{
|
||||
height: 20,
|
||||
}
|
||||
for _, opt := range opts {
|
||||
opt(cfg)
|
||||
}
|
||||
|
||||
// Fall back to plain output if not a terminal
|
||||
if !term.IsTerminal(0) {
|
||||
if cfg.title != "" {
|
||||
fmt.Println(BoldStyle.Render(cfg.title))
|
||||
fmt.Println(DimStyle.Render(strings.Repeat("\u2500", len(cfg.title))))
|
||||
}
|
||||
fmt.Println(content)
|
||||
return nil
|
||||
}
|
||||
|
||||
m := newViewportModel(content, cfg.title, cfg.height)
|
||||
p := tea.NewProgram(m)
|
||||
_, err := p.Run()
|
||||
return err
|
||||
}
|
||||
61
pkg/cli/viewport_test.go
Normal file
61
pkg/cli/viewport_test.go
Normal file
|
|
@ -0,0 +1,61 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestViewportModel_Good_Create(t *testing.T) {
|
||||
content := "line 1\nline 2\nline 3"
|
||||
m := newViewportModel(content, "Title", 5)
|
||||
assert.Equal(t, "Title", m.title)
|
||||
assert.Equal(t, 3, len(m.lines))
|
||||
assert.Equal(t, 0, m.offset)
|
||||
}
|
||||
|
||||
func TestViewportModel_Good_ScrollDown(t *testing.T) {
|
||||
lines := make([]string, 20)
|
||||
for i := range lines {
|
||||
lines[i] = strings.Repeat("x", 10)
|
||||
}
|
||||
m := newViewportModel(strings.Join(lines, "\n"), "", 5)
|
||||
m.scrollDown()
|
||||
assert.Equal(t, 1, m.offset)
|
||||
}
|
||||
|
||||
func TestViewportModel_Good_ScrollUp(t *testing.T) {
|
||||
lines := make([]string, 20)
|
||||
for i := range lines {
|
||||
lines[i] = strings.Repeat("x", 10)
|
||||
}
|
||||
m := newViewportModel(strings.Join(lines, "\n"), "", 5)
|
||||
m.scrollDown()
|
||||
m.scrollDown()
|
||||
m.scrollUp()
|
||||
assert.Equal(t, 1, m.offset)
|
||||
}
|
||||
|
||||
func TestViewportModel_Good_NoScrollPastTop(t *testing.T) {
|
||||
m := newViewportModel("a\nb\nc", "", 5)
|
||||
m.scrollUp() // Already at top
|
||||
assert.Equal(t, 0, m.offset)
|
||||
}
|
||||
|
||||
func TestViewportModel_Good_NoScrollPastBottom(t *testing.T) {
|
||||
m := newViewportModel("a\nb\nc", "", 5)
|
||||
for i := 0; i < 10; i++ {
|
||||
m.scrollDown()
|
||||
}
|
||||
// Should clamp -- can't scroll past content
|
||||
assert.GreaterOrEqual(t, m.offset, 0)
|
||||
}
|
||||
|
||||
func TestViewportModel_Good_View(t *testing.T) {
|
||||
m := newViewportModel("line 1\nline 2", "My Title", 10)
|
||||
view := m.View()
|
||||
assert.Contains(t, view, "My Title")
|
||||
assert.Contains(t, view, "line 1")
|
||||
assert.Contains(t, view, "line 2")
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue