summaryrefslogtreecommitdiff
path: root/internal/perc
diff options
context:
space:
mode:
Diffstat (limited to 'internal/perc')
-rw-r--r--internal/perc/perc.go233
-rw-r--r--internal/perc/perc_test.go282
2 files changed, 515 insertions, 0 deletions
diff --git a/internal/perc/perc.go b/internal/perc/perc.go
new file mode 100644
index 0000000..9921ce4
--- /dev/null
+++ b/internal/perc/perc.go
@@ -0,0 +1,233 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 Paul Buetow
+
+package perc
+
+import (
+ "fmt"
+ "regexp"
+ "strconv"
+ "strings"
+)
+
+// CalculationType represents the type of calculation performed.
+type CalculationType int
+
+const (
+ // PercentOfY: "X% of Y" → "X.00% of Y.00 = Z.00"
+ PercentOfY CalculationType = iota
+ // IsWhatPercentOfY: "X is what % of Y" → "X.00 is P.00% of Y.00"
+ IsWhatPercentOfY
+ // IsYPercentOfWhat: "X is Y% of what" → "X.00 is Y.00% of W.00"
+ IsYPercentOfWhat
+)
+
+// Calculation represents the result of a percentage calculation.
+type Calculation struct {
+ Type CalculationType
+ Percent float64
+ Base float64
+ Result float64
+ Steps string
+}
+
+// Format returns the formatted calculation result.
+func (c *Calculation) Format() string {
+ var baseStr string
+ switch c.Type {
+ case PercentOfY:
+ baseStr = fmt.Sprintf("%.2f%% of %.2f = %.2f", c.Percent, c.Base, c.Result)
+ case IsWhatPercentOfY:
+ // percent is the result, base is the "whole"
+ baseStr = fmt.Sprintf("%.2f is %.2f%% of %.2f", c.Result, c.Percent, c.Base)
+ case IsYPercentOfWhat:
+ // percent is the known value, base is the "what"
+ baseStr = fmt.Sprintf("%.2f is %.2f%% of %.2f", c.Result, c.Percent, c.Base)
+ }
+ if c.Steps != "" {
+ return baseStr + "\n Steps: " + c.Steps
+ }
+ return baseStr
+}
+
+// ParsingStrategy represents a parsing function that attempts to parse input.
+// Returns a Calculation if handled, or error if not.
+type ParsingStrategy func(input string) (*Calculation, bool, error)
+
+// strategyRegistry maintains a registry of parsing strategies.
+type strategyRegistry struct {
+ strategies []ParsingStrategy
+}
+
+// newStrategyRegistry creates a new strategy registry.
+func newStrategyRegistry() *strategyRegistry {
+ return &strategyRegistry{
+ strategies: make([]ParsingStrategy, 0),
+ }
+}
+
+// register adds a parsing strategy to the registry.
+func (r *strategyRegistry) register(strategy ParsingStrategy) {
+ r.strategies = append(r.strategies, strategy)
+}
+
+// parse attempts to parse input using registered strategies in order.
+func (r *strategyRegistry) parse(input string) (*Calculation, bool, error) {
+ for _, strategy := range r.strategies {
+ if result, handled, err := strategy(input); handled {
+ return result, true, err
+ }
+ }
+ return nil, false, nil
+}
+
+// Parse parses a percentage calculation input string and returns the result as a formatted string.
+// It handles formats like "20% of 150", "30 is what % of 150", and "30 is 20% of what".
+// Note: This function only handles percentage calculations, not RPN expressions.
+func Parse(input string) (string, error) {
+ input = strings.ToLower(strings.TrimSpace(input))
+ input = strings.ReplaceAll(input, "what is ", "")
+ input = strings.TrimSpace(input)
+
+ // Create registry and register percentage parsing strategies
+ registry := newStrategyRegistry()
+ registry.register(parseXPercentOfY)
+ registry.register(parseXIsWhatPercentOfY)
+ registry.register(parseXIsYPercentOfWhat)
+
+ calc, ok, err := registry.parse(input)
+ if ok {
+ return calc.Format(), nil
+ }
+ if err != nil {
+ return "", fmt.Errorf("perc: unable to parse input %q: %w", input, err)
+ }
+
+ return "", fmt.Errorf("perc: unable to parse input %q: unknown error", input)
+}
+
+// ParseCalculation parses a percentage calculation input string and returns the Calculation object.
+// It handles formats like "20% of 150", "30 is what % of 150", and "30 is 20% of what".
+// This provides callers with more flexibility to access raw values and formatting options.
+func ParseCalculation(input string) (*Calculation, error) {
+ input = strings.ToLower(strings.TrimSpace(input))
+ input = strings.ReplaceAll(input, "what is ", "")
+ input = strings.TrimSpace(input)
+
+ // Create registry and register percentage parsing strategies
+ registry := newStrategyRegistry()
+ registry.register(parseXPercentOfY)
+ registry.register(parseXIsWhatPercentOfY)
+ registry.register(parseXIsYPercentOfWhat)
+
+ calc, ok, err := registry.parse(input)
+ if ok {
+ return calc, nil
+ }
+ if err != nil {
+ return nil, err
+ }
+
+ return nil, fmt.Errorf("perc: unable to parse input %q. See usage for examples", input)
+}
+
+// parseXPercentOfY calculates "X% of Y" and returns a Calculation.
+func parseXPercentOfY(input string) (*Calculation, bool, error) {
+ re := regexp.MustCompile(`^(\d+(?:\.\d+)?)\s*%\s*(?:of\s+)?(\d+(?:\.\d+)?)$`)
+ matches := re.FindStringSubmatch(input)
+
+ if matches == nil {
+ return nil, false, nil
+ }
+
+ percent, err := strconv.ParseFloat(matches[1], 64)
+ if err != nil {
+ return nil, false, err
+ }
+ base, err := strconv.ParseFloat(matches[2], 64)
+ if err != nil {
+ return nil, false, err
+ }
+
+ result := (percent / 100.0) * base
+
+ calc := &Calculation{
+ Type: PercentOfY,
+ Percent: percent,
+ Base: base,
+ Result: result,
+ Steps: fmt.Sprintf("(%.2f / 100) * %.2f = %.2f * %.2f = %.2f", percent, base, percent/100.0, base, result),
+ }
+
+ return calc, true, nil
+}
+
+// parseXIsWhatPercentOfY calculates "X is what % of Y" and returns a Calculation.
+func parseXIsWhatPercentOfY(input string) (*Calculation, bool, error) {
+ re := regexp.MustCompile(`^(\d+(?:\.\d+)?)\s+is\s+what\s*%\s*(?:of\s+)?(\d+(?:\.\d+)?)$`)
+ matches := re.FindStringSubmatch(input)
+
+ if matches == nil {
+ return nil, false, nil
+ }
+
+ part, err := strconv.ParseFloat(matches[1], 64)
+ if err != nil {
+ return nil, false, err
+ }
+ whole, err := strconv.ParseFloat(matches[2], 64)
+ if err != nil {
+ return nil, false, err
+ }
+
+ if whole == 0 {
+ return nil, false, fmt.Errorf("division by zero")
+ }
+
+ percent := (part / whole) * 100.0
+
+ calc := &Calculation{
+ Type: IsWhatPercentOfY,
+ Percent: percent,
+ Base: whole,
+ Result: part,
+ Steps: fmt.Sprintf("(%.2f / %.2f) * 100 = %.2f * 100 = %.2f%%", part, whole, part/whole, percent),
+ }
+
+ return calc, true, nil
+}
+
+// parseXIsYPercentOfWhat calculates "X is Y% of what" and returns a Calculation.
+func parseXIsYPercentOfWhat(input string) (*Calculation, bool, error) {
+ re := regexp.MustCompile(`^(\d+(?:\.\d+)?)\s+is\s+(\d+(?:\.\d+)?)\s*%\s*(?:of\s+)?what$`)
+ matches := re.FindStringSubmatch(input)
+
+ if matches == nil {
+ return nil, false, nil
+ }
+
+ part, err := strconv.ParseFloat(matches[1], 64)
+ if err != nil {
+ return nil, false, err
+ }
+ percent, err := strconv.ParseFloat(matches[2], 64)
+ if err != nil {
+ return nil, false, err
+ }
+
+ if percent == 0 {
+ return nil, false, fmt.Errorf("division by zero")
+ }
+
+ whole := (part / percent) * 100.0
+
+ calc := &Calculation{
+ Type: IsYPercentOfWhat,
+ Percent: percent,
+ Base: whole,
+ Result: part,
+ Steps: fmt.Sprintf("(%.2f / %.2f) * 100 = %.2f * 100 = %.2f", part, percent, part/percent, whole),
+ }
+
+ return calc, true, nil
+}
diff --git a/internal/perc/perc_test.go b/internal/perc/perc_test.go
new file mode 100644
index 0000000..d96fabb
--- /dev/null
+++ b/internal/perc/perc_test.go
@@ -0,0 +1,282 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 Paul Buetow
+
+package perc
+
+import (
+ "strings"
+ "testing"
+)
+
+// commonTestCases contains common test case patterns used across multiple tests
+var commonTestCases = []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",
+ },
+ {
+ 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",
+ },
+ {
+ 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",
+ },
+}
+
+// runParseTest runs a parse test with common validation logic
+func runParseTest(t *testing.T, tests []struct {
+ name string
+ input string
+ expected string
+}) {
+ 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.HasPrefix(result, tt.expected) {
+ t.Errorf("Parse(%q) = %q, expected to start with %q", tt.input, result, tt.expected)
+ }
+ if !strings.Contains(result, "Steps:") {
+ t.Errorf("Parse(%q) = %q, expected to contain calculation steps", tt.input, result)
+ }
+ })
+ }
+}
+func TestParseXPercentOfY(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected string
+ }{
+ commonTestCases[0], // "20% of 150"
+ commonTestCases[1], // "what is 20% of 150"
+ commonTestCases[2], // "50% of 200"
+ commonTestCases[3], // "decimal percent"
+ commonTestCases[4], // "decimal base"
+ commonTestCases[5], // "without 'of'"
+ }
+ runParseTest(t, tests)
+}
+
+func TestParseXIsWhatPercentOfY(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected string
+ }{
+ commonTestCases[6], // "30 is what % of 150"
+ commonTestCases[7], // "50 is what % of 200"
+ commonTestCases[8], // "decimal values"
+ commonTestCases[9], // "without spaces around %"
+ commonTestCases[10], // "without 'of'"
+ }
+ runParseTest(t, tests)
+}
+
+func TestParseXIsYPercentOfWhat(t *testing.T) {
+ tests := []struct {
+ name string
+ input string
+ expected string
+ }{
+ commonTestCases[11], // "30 is 20% of what"
+ commonTestCases[12], // "50 is 25% of what"
+ commonTestCases[13], // "decimal values"
+ commonTestCases[14], // "without spaces around %"
+ commonTestCases[15], // "without 'of'"
+ }
+ runParseTest(t, tests)
+}
+
+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)
+ }
+ })
+ }
+}