summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorPaul Buetow <paul@buetow.org>2026-05-22 12:12:55 +0300
committerPaul Buetow <paul@buetow.org>2026-05-22 12:12:55 +0300
commitd34ecb37e49a0203a6c4db1fe9ab76e305ae8a66 (patch)
treee84f635a4f7c06a6dc531d1e5559a6cdf02d7d5d
parentcfd98d5bbf08680266e965c15e938aea87c4b832 (diff)
feat(rpn): implement metric subcommands
Add multi-word 'metric' command with subcommands for inspecting and configuring metric units: - metric show: show metric info (name, category, base unit, factor) for top of stack - metric list: list all registered metric categories - metric <Category>: list all metrics in a specific category (e.g., DataRate) - metric binary set: set prefix mode to IEC (1024-based) - metric decimal set: set prefix mode to SI (1000-based) - metric compatible: check if top two stack values have compatible metrics Adds prefixMode field to RPN struct with thread-safe getter/setter. Handles the 'metric' token in evaluate() before falling through to operators, consuming subsequent tokens as subcommands.
-rw-r--r--internal/rpn/operations.go5
-rw-r--r--internal/rpn/operations_metric_cmd.go99
-rw-r--r--internal/rpn/operations_metric_cmd_test.go238
-rw-r--r--internal/rpn/rpn_parse.go46
-rw-r--r--internal/rpn/rpn_state.go18
5 files changed, 406 insertions, 0 deletions
diff --git a/internal/rpn/operations.go b/internal/rpn/operations.go
index af30477..d483cc8 100644
--- a/internal/rpn/operations.go
+++ b/internal/rpn/operations.go
@@ -156,6 +156,11 @@ type Operator interface {
PowerIntOperator
// SetMode sets the calculation mode for number formatting
SetMode(CalculationMode)
+ // Metric command handlers
+ MetricShow(stack *Stack) (string, error)
+ MetricList(stack *Stack) (string, error)
+ MetricCategory(stack *Stack, categoryName string) (string, error)
+ MetricCompatible(stack *Stack) (string, error)
}
// Operations provides operator implementations and stack manipulation.
diff --git a/internal/rpn/operations_metric_cmd.go b/internal/rpn/operations_metric_cmd.go
new file mode 100644
index 0000000..9a09a67
--- /dev/null
+++ b/internal/rpn/operations_metric_cmd.go
@@ -0,0 +1,99 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 Paul Buetow
+
+package rpn
+
+import (
+ "fmt"
+ "sort"
+ "strings"
+)
+
+// MetricShow shows metric info for top of stack.
+func (o *Operations) MetricShow(stack *Stack) (string, error) {
+ if stack.Len() < 1 {
+ return "", fmt.Errorf("metric show: stack is empty")
+ }
+ val, err := stack.Peek()
+ if err != nil {
+ return "", buildError("metric show", err)
+ }
+ m := val.Metric()
+ if m == nil || m.Category == Universal {
+ return "Cool (Universal)", nil
+ }
+ factor := m.Factor(SI) // use SI for display
+ return fmt.Sprintf("%s, %s, base: %s, factor: %.0g", m.Name, m.Category, m.BaseUnit, factor), nil
+}
+
+// MetricList returns all category names.
+func (o *Operations) MetricList(stack *Stack) (string, error) {
+ reg := o.metricRegistry
+ all := reg.List()
+ seen := make(map[Category]bool)
+ var cats []string
+ for _, m := range all {
+ if !seen[m.Category] {
+ seen[m.Category] = true
+ cats = append(cats, m.Category.String())
+ }
+ }
+ sort.Strings(cats)
+ return strings.Join(cats, ", "), nil
+}
+
+// MetricCategory returns all metric names in the given category.
+func (o *Operations) MetricCategory(stack *Stack, categoryName string) (string, error) {
+ reg := o.metricRegistry
+ cat, ok := parseCategory(categoryName)
+ if !ok {
+ return "", fmt.Errorf("metric: unknown category %q", categoryName)
+ }
+ metrics := reg.ListByCategory(cat)
+ var names []string
+ for _, m := range metrics {
+ names = append(names, m.Name)
+ }
+ sort.Strings(names)
+ return strings.Join(names, ", "), nil
+}
+
+// MetricCompatible checks if top two stack metrics are compatible.
+func (o *Operations) MetricCompatible(stack *Stack) (string, error) {
+ if stack.Len() < 2 {
+ return "", fmt.Errorf("metric compatible: need at least 2 values on stack")
+ }
+ vals := stack.Values()
+ top := vals[len(vals)-1]
+ second := vals[len(vals)-2]
+ mA := resolveMetric(o.metricRegistry, second)
+ mB := resolveMetric(o.metricRegistry, top)
+ compatible := categoriesCompatible(mA, mB)
+ result := fmt.Sprintf("%s (%s) and %s (%s): %v",
+ mA.Name, mA.Category, mB.Name, mB.Category, compatible)
+ return result, nil
+}
+
+// parseCategory converts a category name string to a Category constant.
+func parseCategory(name string) (Category, bool) {
+ switch name {
+ case "Universal":
+ return Universal, true
+ case "DataRate":
+ return DataRate, true
+ case "DataSize":
+ return DataSize, true
+ case "Time":
+ return Time, true
+ case "Weight":
+ return Weight, true
+ case "Speed":
+ return Speed, true
+ case "Distance":
+ return Distance, true
+ case "Custom":
+ return Custom, true
+ default:
+ return 0, false
+ }
+}
diff --git a/internal/rpn/operations_metric_cmd_test.go b/internal/rpn/operations_metric_cmd_test.go
new file mode 100644
index 0000000..9b77439
--- /dev/null
+++ b/internal/rpn/operations_metric_cmd_test.go
@@ -0,0 +1,238 @@
+// SPDX-License-Identifier: MIT
+// Copyright (c) 2026 Paul Buetow
+
+package rpn
+
+import (
+ "strings"
+ "testing"
+)
+
+func TestMetricShow(t *testing.T) {
+ vars := NewVariables()
+ rpn := NewRPN(vars)
+
+ result, err := rpn.ParseAndEvaluate("100Mbps metric show")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !strings.Contains(result, "Mbps") {
+ t.Errorf("expected result to contain 'Mbps', got: %s", result)
+ }
+ if !strings.Contains(result, "DataRate") {
+ t.Errorf("expected result to contain 'DataRate', got: %s", result)
+ }
+}
+
+func TestMetricShowUniversal(t *testing.T) {
+ vars := NewVariables()
+ rpn := NewRPN(vars)
+
+ result, err := rpn.ParseAndEvaluate("42 metric show")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !strings.Contains(result, "Cool") {
+ t.Errorf("expected result to contain 'Cool', got: %s", result)
+ }
+ if !strings.Contains(result, "Universal") {
+ t.Errorf("expected result to contain 'Universal', got: %s", result)
+ }
+}
+
+func TestMetricShowEmptyStack(t *testing.T) {
+ vars := NewVariables()
+ rpn := NewRPN(vars)
+ _, err := rpn.ParseAndEvaluate("metric show")
+ if err == nil {
+ t.Error("expected error for empty stack")
+ }
+}
+
+func TestMetricList(t *testing.T) {
+ vars := NewVariables()
+ rpn := NewRPN(vars)
+ result, err := rpn.ParseAndEvaluate("metric list")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ expected := []string{"DataRate", "DataSize", "Distance", "Speed", "Time", "Universal", "Weight"}
+ for _, cat := range expected {
+ if !strings.Contains(result, cat) {
+ t.Errorf("expected result to contain %q, got: %s", cat, result)
+ }
+ }
+}
+
+func TestMetricCategory(t *testing.T) {
+ vars := NewVariables()
+ rpn := NewRPN(vars)
+ result, err := rpn.ParseAndEvaluate("metric DataRate")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ expected := []string{"bps", "Kbps", "Mbps", "Gbps", "Tbps"}
+ for _, name := range expected {
+ if !strings.Contains(result, name) {
+ t.Errorf("expected result to contain %q, got: %s", name, result)
+ }
+ }
+}
+
+func TestMetricCategoryTime(t *testing.T) {
+ vars := NewVariables()
+ rpn := NewRPN(vars)
+ result, err := rpn.ParseAndEvaluate("metric Time")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ expected := []string{"ms", "s", "min", "hr", "day"}
+ for _, name := range expected {
+ if !strings.Contains(result, name) {
+ t.Errorf("expected result to contain %q, got: %s", name, result)
+ }
+ }
+}
+
+func TestMetricCategoryUnknown(t *testing.T) {
+ vars := NewVariables()
+ rpn := NewRPN(vars)
+ _, err := rpn.ParseAndEvaluate("metric Nope")
+ if err == nil {
+ t.Error("expected error for unknown category")
+ }
+}
+
+func TestMetricSetModeDecimal(t *testing.T) {
+ vars := NewVariables()
+ rpn := NewRPN(vars)
+
+ result, err := rpn.ParseAndEvaluate("metric decimal set")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !strings.Contains(result, "SI") {
+ t.Errorf("expected SI, got: %s", result)
+ }
+ if rpn.GetPrefixMode() != SI {
+ t.Errorf("prefix mode = %v, want SI", rpn.GetPrefixMode())
+ }
+}
+
+func TestMetricSetModeBinary(t *testing.T) {
+ vars := NewVariables()
+ rpn := NewRPN(vars)
+
+ result, err := rpn.ParseAndEvaluate("metric binary set")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !strings.Contains(result, "IEC") {
+ t.Errorf("expected IEC, got: %s", result)
+ }
+ if rpn.GetPrefixMode() != IEC {
+ t.Errorf("prefix mode = %v, want IEC", rpn.GetPrefixMode())
+ }
+}
+
+func TestMetricSetModeToggle(t *testing.T) {
+ vars := NewVariables()
+ rpn := NewRPN(vars)
+
+ // Default is SI
+ if rpn.GetPrefixMode() != SI {
+ t.Errorf("default prefix mode = %v, want SI", rpn.GetPrefixMode())
+ }
+
+ // Switch to IEC
+ rpn.ParseAndEvaluate("metric binary set")
+ if rpn.GetPrefixMode() != IEC {
+ t.Errorf("after binary set, prefix mode = %v, want IEC", rpn.GetPrefixMode())
+ }
+
+ // Switch back to SI
+ rpn.ParseAndEvaluate("metric decimal set")
+ if rpn.GetPrefixMode() != SI {
+ t.Errorf("after decimal set, prefix mode = %v, want SI", rpn.GetPrefixMode())
+ }
+}
+
+func TestMetricSetModeBinaryIncomplete(t *testing.T) {
+ vars := NewVariables()
+ rpn := NewRPN(vars)
+ _, err := rpn.ParseAndEvaluate("metric binary")
+ if err == nil {
+ t.Error("expected error for incomplete 'metric binary'")
+ }
+}
+
+func TestMetricSetModeDecimalIncomplete(t *testing.T) {
+ vars := NewVariables()
+ rpn := NewRPN(vars)
+ _, err := rpn.ParseAndEvaluate("metric decimal")
+ if err == nil {
+ t.Error("expected error for incomplete 'metric decimal'")
+ }
+}
+
+func TestMetricCompatibleSameCategory(t *testing.T) {
+ vars := NewVariables()
+ rpn := NewRPN(vars)
+ result, err := rpn.ParseAndEvaluate("100Mbps 1Gbps metric compatible")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !strings.Contains(result, "true") {
+ t.Errorf("expected Mbps and Gbps to be compatible, got: %s", result)
+ }
+}
+
+func TestMetricCompatibleDifferentCategory(t *testing.T) {
+ vars := NewVariables()
+ rpn := NewRPN(vars)
+ result, err := rpn.ParseAndEvaluate("100Mbps 2hr metric compatible")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ if !strings.Contains(result, "Mbps") {
+ t.Errorf("expected result to mention Mbps, got: %s", result)
+ }
+ if !strings.Contains(result, "hr") {
+ t.Errorf("expected result to mention hr, got: %s", result)
+ }
+ // DataRate and Time are different categories, neither is Universal -> false
+ if !strings.Contains(result, "false") {
+ t.Errorf("expected DataRate and Time to be incompatible for +/-, got: %s", result)
+ }
+}
+
+func TestMetricCompatibleWithUniversal(t *testing.T) {
+ vars := NewVariables()
+ rpn := NewRPN(vars)
+ result, err := rpn.ParseAndEvaluate("100Mbps 42 metric compatible")
+ if err != nil {
+ t.Fatalf("unexpected error: %v", err)
+ }
+ // Universal (Cool) is compatible with anything
+ if !strings.Contains(result, "true") {
+ t.Errorf("expected Cool and Mbps to be compatible, got: %s", result)
+ }
+}
+
+func TestMetricCompatibleNotEnoughValues(t *testing.T) {
+ vars := NewVariables()
+ rpn := NewRPN(vars)
+ _, err := rpn.ParseAndEvaluate("100 metric compatible")
+ if err == nil {
+ t.Error("expected error for insufficient stack values")
+ }
+}
+
+func TestMetricCompatibleEmptyStack(t *testing.T) {
+ vars := NewVariables()
+ rpn := NewRPN(vars)
+ _, err := rpn.ParseAndEvaluate("metric compatible")
+ if err == nil {
+ t.Error("expected error for empty stack")
+ }
+}
diff --git a/internal/rpn/rpn_parse.go b/internal/rpn/rpn_parse.go
index f115090..5d5ef7d 100644
--- a/internal/rpn/rpn_parse.go
+++ b/internal/rpn/rpn_parse.go
@@ -349,6 +349,52 @@ func (r *RPN) evaluate(input string, tokens []string) (string, error) {
return "", fmt.Errorf("unknown metric %q in %q", metricName, token)
}
+ // Handle multi-word metric command: metric <subcommand>
+ if token == "metric" && i+1 < len(tokens) {
+ subCmd := tokens[i+1]
+ switch subCmd {
+ case "show":
+ result, err := r.ops.MetricShow(stack)
+ if err != nil {
+ return "", fmt.Errorf("rpn: metric show: %w", err)
+ }
+ return result, nil
+ case "list":
+ result, err := r.ops.MetricList(stack)
+ if err != nil {
+ return "", fmt.Errorf("rpn: metric list: %w", err)
+ }
+ return result, nil
+ case "binary":
+ if i+2 < len(tokens) && tokens[i+2] == "set" {
+ r.prefixMode = IEC
+ i += 2 // skip "binary" and "set"
+ return "prefix mode: IEC", nil
+ }
+ return "", fmt.Errorf("rpn: metric binary: use 'metric binary set'")
+ case "decimal":
+ if i+2 < len(tokens) && tokens[i+2] == "set" {
+ r.prefixMode = SI
+ i += 2 // skip "decimal" and "set"
+ return "prefix mode: SI", nil
+ }
+ return "", fmt.Errorf("rpn: metric decimal: use 'metric decimal set'")
+ case "compatible":
+ result, err := r.ops.MetricCompatible(stack)
+ if err != nil {
+ return "", fmt.Errorf("rpn: metric compatible: %w", err)
+ }
+ return result, nil
+ default:
+ // Try as a category name
+ result, err := r.ops.MetricCategory(stack, subCmd)
+ if err != nil {
+ return "", fmt.Errorf("rpn: metric %s: %w", subCmd, err)
+ }
+ return result, nil
+ }
+ }
+
// Check if this is a variable name for assignment (:= or =:)
// For := (right assignment): name value := - first token is always a variable name
// For =: (left assignment): value name =: - token before =: is a variable name
diff --git a/internal/rpn/rpn_state.go b/internal/rpn/rpn_state.go
index bf82abc..1d74e58 100644
--- a/internal/rpn/rpn_state.go
+++ b/internal/rpn/rpn_state.go
@@ -20,6 +20,7 @@ type RPN struct {
maxStack int
currentStack *Stack
mode CalculationMode
+ prefixMode PrefixMode
}
// NewRPN creates a new RPN parser and evaluator with the given variable store.
@@ -36,6 +37,7 @@ func NewRPN(vars VariableStore) *RPN {
maxStack: 1000, // Reasonable limit for RPN expressions
currentStack: NewStack(),
mode: FloatMode, // Default mode
+ prefixMode: SI, // Default prefix mode
}
}
@@ -92,3 +94,19 @@ func (r *RPN) SetCurrentStack(values []Number) {
func (r *RPN) Stack() []Number {
return r.GetCurrentStack()
}
+
+// SetPrefixMode sets the prefix mode (SI or IEC).
+// This method is thread-safe for writes.
+func (r *RPN) SetPrefixMode(mode PrefixMode) {
+ r.mu.Lock()
+ defer r.mu.Unlock()
+ r.prefixMode = mode
+}
+
+// GetPrefixMode returns the current prefix mode.
+// This method is thread-safe for concurrent reads.
+func (r *RPN) GetPrefixMode() PrefixMode {
+ r.mu.RLock()
+ defer r.mu.RUnlock()
+ return r.prefixMode
+}