diff options
| author | Paul Buetow <paul@buetow.org> | 2025-11-25 22:32:54 +0200 |
|---|---|---|
| committer | Paul Buetow <paul@buetow.org> | 2025-11-25 22:32:54 +0200 |
| commit | b97e4b19ec9c415cd5f3d204e23e5fde5180db26 (patch) | |
| tree | af324415c9e0e71724d4a920a62cc4295877dbc0 | |
Initial commit: perc v0.0.0 - percentage calculatorv0.0.0
Amp-Thread-ID: https://ampcode.com/threads/T-e4f4a959-8cc6-4ac0-b6fb-2779867e8b2a
Co-authored-by: Amp <amp@ampcode.com>
| -rw-r--r-- | AGENT.md | 5 | ||||
| -rw-r--r-- | Magefile.go | 25 | ||||
| -rw-r--r-- | README.md | 67 | ||||
| -rw-r--r-- | cmd/perc/main.go | 48 | ||||
| -rw-r--r-- | go.mod | 5 | ||||
| -rw-r--r-- | go.sum | 2 | ||||
| -rw-r--r-- | internal/calculator/calculator.go | 81 | ||||
| -rw-r--r-- | internal/calculator/calculator_test.go | 267 | ||||
| -rw-r--r-- | internal/version.go | 3 |
9 files changed, 503 insertions, 0 deletions
diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..c6be708 --- /dev/null +++ b/AGENT.md @@ -0,0 +1,5 @@ +* Prefer value semantics over pointer semantics if feasible +* Have either pointer or value receivers, not both, for methods on a type +* Have constants, global variables, and type definitions always at the top of the file, before functions and methods +* Have public functions and method before private ones in the file. +* constructors must be always the first functions in a file (before all the methods), immediately after type definitions. even if they're non-public. diff --git a/Magefile.go b/Magefile.go new file mode 100644 index 0000000..bf51250 --- /dev/null +++ b/Magefile.go @@ -0,0 +1,25 @@ +//go:build mage + +package main + +import ( + "github.com/magefile/mage/sh" +) + +var Default = Build + +func Build() error { + return sh.RunV("go", "build", "-o", "perc", "./cmd/perc") +} + +func Run() error { + return sh.RunV("go", "run", "./cmd/perc") +} + +func Test() error { + return sh.RunV("go", "test", "./...") +} + +func Install() error { + return sh.RunV("go", "install", "./cmd/perc") +} diff --git a/README.md b/README.md new file mode 100644 index 0000000..7ce4fdb --- /dev/null +++ b/README.md @@ -0,0 +1,67 @@ +# perc + +A simple command-line percentage calculator written in Go. + +## Installation + +```bash +go install codeberg.org/snonux/perc/cmd/perc@latest +``` + +Or using mage: + +```bash +mage install +``` + +## Usage + +`perc` supports various percentage calculation formats: + +### Calculate X% of Y + +```bash +perc 20% of 150 +# Output: 20.00% of 150.00 = 30.00 + +perc what is 20% of 150 +# Output: 20.00% of 150.00 = 30.00 +``` + +### Find what percentage X is of Y + +```bash +perc 30 is what % of 150 +# Output: 30.00 is 20.00% of 150.00 +``` + +### Find the whole when X is Y% of it + +```bash +perc 30 is 20% of what +# Output: 30.00 is 20.00% of 150.00 +``` + +## Building + +Using mage: + +```bash +mage build +``` + +Or using go directly: + +```bash +go build -o perc ./cmd/perc +``` + +## Testing + +```bash +mage test +``` + +## License + +See LICENSE file for details. diff --git a/cmd/perc/main.go b/cmd/perc/main.go new file mode 100644 index 0000000..a5764b5 --- /dev/null +++ b/cmd/perc/main.go @@ -0,0 +1,48 @@ +package main + +import ( + "fmt" + "os" + "strings" + + "codeberg.org/snonux/perc/internal" + "codeberg.org/snonux/perc/internal/calculator" +) + +func main() { + output, err := runCommand(os.Args) + if err != nil { + fmt.Println("Error:", err) + os.Exit(1) + } + fmt.Println(output) +} + +func runCommand(args []string) (string, error) { + if len(args) < 2 { + printUsage() + return "", fmt.Errorf("no input provided") + } + + if args[1] == "version" { + return internal.Version, nil + } + + input := strings.Join(args[1:], " ") + result, err := calculator.Parse(input) + if err != nil { + return "", err + } + + return result, nil +} + +func printUsage() { + fmt.Println("Usage: perc <calculation>") + fmt.Println(" perc version") + fmt.Println("\nExamples:") + fmt.Println(" perc 20% of 150") + fmt.Println(" perc what is 20% of 150") + fmt.Println(" perc 30 is what % of 150") + fmt.Println(" perc 30 is 20% of what") +} @@ -0,0 +1,5 @@ +module codeberg.org/snonux/perc + +go 1.25.4 + +require github.com/magefile/mage v1.15.0 @@ -0,0 +1,2 @@ +github.com/magefile/mage v1.15.0 h1:BvGheCMAsG3bWUDbZ8AyXXpCNwU9u5CB6sM+HNb9HYg= +github.com/magefile/mage v1.15.0/go.mod h1:z5UZb/iS3GoOSn0JgWuiw7dxlurVYTu+/jHXqQg881A= diff --git a/internal/calculator/calculator.go b/internal/calculator/calculator.go new file mode 100644 index 0000000..ee1ff6f --- /dev/null +++ b/internal/calculator/calculator.go @@ -0,0 +1,81 @@ +package calculator + +import ( + "fmt" + "regexp" + "strconv" + "strings" +) + +func Parse(input string) (string, error) { + input = strings.ToLower(strings.TrimSpace(input)) + input = strings.ReplaceAll(input, "what is ", "") + input = strings.TrimSpace(input) + + if result, ok := parseXPercentOfY(input); ok { + return result, nil + } + + if result, ok := parseXIsWhatPercentOfY(input); ok { + return result, nil + } + + if result, ok := parseXIsYPercentOfWhat(input); ok { + return result, nil + } + + return "", fmt.Errorf("unable to parse input. See usage for examples") +} + +func parseXPercentOfY(input string) (string, bool) { + re := regexp.MustCompile(`^(\d+(?:\.\d+)?)\s*%\s*(?:of\s+)?(\d+(?:\.\d+)?)$`) + matches := re.FindStringSubmatch(input) + + if matches == nil { + return "", false + } + + percent, _ := strconv.ParseFloat(matches[1], 64) + base, _ := strconv.ParseFloat(matches[2], 64) + + result := (percent / 100.0) * base + return fmt.Sprintf("%.2f%% of %.2f = %.2f", percent, base, result), true +} + +func parseXIsWhatPercentOfY(input string) (string, bool) { + re := regexp.MustCompile(`^(\d+(?:\.\d+)?)\s+is\s+what\s*%\s*(?:of\s+)?(\d+(?:\.\d+)?)$`) + matches := re.FindStringSubmatch(input) + + if matches == nil { + return "", false + } + + part, _ := strconv.ParseFloat(matches[1], 64) + whole, _ := strconv.ParseFloat(matches[2], 64) + + if whole == 0 { + return "", false + } + + percent := (part / whole) * 100.0 + return fmt.Sprintf("%.2f is %.2f%% of %.2f", part, percent, whole), true +} + +func parseXIsYPercentOfWhat(input string) (string, bool) { + re := regexp.MustCompile(`^(\d+(?:\.\d+)?)\s+is\s+(\d+(?:\.\d+)?)\s*%\s*(?:of\s+)?what$`) + matches := re.FindStringSubmatch(input) + + if matches == nil { + return "", false + } + + part, _ := strconv.ParseFloat(matches[1], 64) + percent, _ := strconv.ParseFloat(matches[2], 64) + + if percent == 0 { + return "", false + } + + whole := (part / percent) * 100.0 + return fmt.Sprintf("%.2f is %.2f%% of %.2f", part, percent, whole), true +} diff --git a/internal/calculator/calculator_test.go b/internal/calculator/calculator_test.go new file mode 100644 index 0000000..defbd13 --- /dev/null +++ b/internal/calculator/calculator_test.go @@ -0,0 +1,267 @@ +package calculator + +import ( + "strings" + "testing" +) + +func TestParseXPercentOfY(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "20% of 150", + input: "20% of 150", + expected: "20.00% of 150.00 = 30.00", + }, + { + name: "what is 20% of 150", + input: "what is 20% of 150", + expected: "20.00% of 150.00 = 30.00", + }, + { + name: "50% of 200", + input: "50% of 200", + expected: "50.00% of 200.00 = 100.00", + }, + { + name: "decimal percent", + input: "12.5% of 80", + expected: "12.50% of 80.00 = 10.00", + }, + { + name: "decimal base", + input: "20% of 75.5", + expected: "20.00% of 75.50 = 15.10", + }, + { + name: "without 'of'", + input: "25% 400", + expected: "25.00% of 400.00 = 100.00", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := Parse(tt.input) + if err != nil { + t.Fatalf("Parse(%q) returned error: %v", tt.input, err) + } + if result != tt.expected { + t.Errorf("Parse(%q) = %q, expected %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestParseXIsWhatPercentOfY(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "30 is what % of 150", + input: "30 is what % of 150", + expected: "30.00 is 20.00% of 150.00", + }, + { + name: "50 is what % of 200", + input: "50 is what % of 200", + expected: "50.00 is 25.00% of 200.00", + }, + { + name: "decimal values", + input: "12.5 is what % of 50", + expected: "12.50 is 25.00% of 50.00", + }, + { + name: "without spaces around %", + input: "75 is what% of 300", + expected: "75.00 is 25.00% of 300.00", + }, + { + name: "without 'of'", + input: "100 is what % 400", + expected: "100.00 is 25.00% of 400.00", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := Parse(tt.input) + if err != nil { + t.Fatalf("Parse(%q) returned error: %v", tt.input, err) + } + if result != tt.expected { + t.Errorf("Parse(%q) = %q, expected %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestParseXIsYPercentOfWhat(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "30 is 20% of what", + input: "30 is 20% of what", + expected: "30.00 is 20.00% of 150.00", + }, + { + name: "50 is 25% of what", + input: "50 is 25% of what", + expected: "50.00 is 25.00% of 200.00", + }, + { + name: "decimal values", + input: "15 is 30% of what", + expected: "15.00 is 30.00% of 50.00", + }, + { + name: "without spaces around %", + input: "75 is 25% of what", + expected: "75.00 is 25.00% of 300.00", + }, + { + name: "without 'of'", + input: "40 is 20% what", + expected: "40.00 is 20.00% of 200.00", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := Parse(tt.input) + if err != nil { + t.Fatalf("Parse(%q) returned error: %v", tt.input, err) + } + if result != tt.expected { + t.Errorf("Parse(%q) = %q, expected %q", tt.input, result, tt.expected) + } + }) + } +} + +func TestParseErrors(t *testing.T) { + tests := []struct { + name string + input string + }{ + { + name: "invalid input", + input: "hello world", + }, + { + name: "incomplete input", + input: "20%", + }, + { + name: "missing numbers", + input: "% of", + }, + { + name: "random text", + input: "calculate percentage", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Parse(tt.input) + if err == nil { + t.Errorf("Parse(%q) expected error, got nil", tt.input) + } + }) + } +} + +func TestParseCaseInsensitive(t *testing.T) { + tests := []struct { + name string + input string + }{ + { + name: "uppercase WHAT IS", + input: "WHAT IS 20% OF 150", + }, + { + name: "mixed case What Is", + input: "What Is 20% Of 150", + }, + { + name: "uppercase IS WHAT", + input: "30 IS WHAT % OF 150", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Parse(tt.input) + if err != nil { + t.Errorf("Parse(%q) should be case-insensitive, got error: %v", tt.input, err) + } + }) + } +} + +func TestParseDivisionByZero(t *testing.T) { + tests := []struct { + name string + input string + }{ + { + name: "X is what % of 0", + input: "30 is what % of 0", + }, + { + name: "X is 0% of what", + input: "30 is 0% of what", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := Parse(tt.input) + if err == nil { + t.Errorf("Parse(%q) should handle division by zero, expected error", tt.input) + } + }) + } +} + +func TestParseWhitespace(t *testing.T) { + tests := []struct { + name string + input string + expected string + }{ + { + name: "extra spaces", + input: " 20% of 150 ", + expected: "20.00% of 150.00 = 30.00", + }, + { + name: "tabs and spaces", + input: "30 is what % of 150", + expected: "30.00 is 20.00% of 150.00", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := Parse(tt.input) + if err != nil { + t.Fatalf("Parse(%q) returned error: %v", tt.input, err) + } + if !strings.Contains(result, "of") { + t.Errorf("Parse(%q) should handle whitespace properly, got %q", tt.input, result) + } + }) + } +} diff --git a/internal/version.go b/internal/version.go new file mode 100644 index 0000000..3fd5dc6 --- /dev/null +++ b/internal/version.go @@ -0,0 +1,3 @@ +package internal + +const Version = "v0.0.0" |
