From bbefcf38c5391c8338bb2edf205ab775e3a38f74 Mon Sep 17 00:00:00 2001 From: Paul Buetow Date: Fri, 22 May 2026 13:16:48 +0300 Subject: feat: implement custom metric commands (define, undefine, list, show) - Add Unregister() to MetricRegistry for removing custom metrics - Handle 'custom show', 'custom list', 'custom define', 'custom undefine' in rpn_parse.go evaluate() - Add CustomList, CustomDefine, CustomUndefine methods to Operations - Update Operator interface with new custom metric methods - Add comprehensive tests for all custom metric operations --- internal/rpn/metric_registry.go | 22 ++++++++ internal/rpn/operations.go | 4 ++ internal/rpn/operations_metric_cmd.go | 42 +++++++++++++++ internal/rpn/operations_metric_cmd_test.go | 86 ++++++++++++++++++++++++++++++ internal/rpn/rpn_parse.go | 47 ++++++++++++++++ 5 files changed, 201 insertions(+) diff --git a/internal/rpn/metric_registry.go b/internal/rpn/metric_registry.go index de6b1a4..7a8b9aa 100644 --- a/internal/rpn/metric_registry.go +++ b/internal/rpn/metric_registry.go @@ -160,3 +160,25 @@ func (r *MetricRegistry) ListByCategory(cat Category) []*Metric { } return result } + +// Unregister removes a custom metric from the registry. +// Returns error if the metric is not custom (built-in metrics cannot be removed). +func (r *MetricRegistry) Unregister(name string) error { + r.mu.Lock() + defer r.mu.Unlock() + m, ok := r.metrics[name] + if !ok { + return fmt.Errorf("metric %q not found", name) + } + if !m.IsCustom { + return fmt.Errorf("cannot remove built-in metric %q", name) + } + delete(r.metrics, name) + // Also remove any aliases pointing to this metric + for alias, canonical := range r.aliases { + if canonical == name { + delete(r.aliases, alias) + } + } + return nil +} diff --git a/internal/rpn/operations.go b/internal/rpn/operations.go index d45ffd1..9e5bbca 100644 --- a/internal/rpn/operations.go +++ b/internal/rpn/operations.go @@ -165,6 +165,10 @@ type Operator interface { MetricList(stack *Stack) (string, error) MetricCategory(stack *Stack, categoryName string) (string, error) MetricCompatible(stack *Stack) (string, error) + // Custom metric commands + CustomList(stack *Stack) (string, error) + CustomDefine(name string, factor float64, category string) error + CustomUndefine(name 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 index 5ed5fc6..f461511 100644 --- a/internal/rpn/operations_metric_cmd.go +++ b/internal/rpn/operations_metric_cmd.go @@ -97,3 +97,45 @@ func parseCategory(name string) (Category, bool) { return 0, false } } + +// CustomList returns all custom metric names. +func (o *Operations) CustomList(stack *Stack) (string, error) { + reg := o.metricRegistry + metrics := reg.ListByCategory(Custom) + var names []string + for _, m := range metrics { + names = append(names, m.Name) + } + sort.Strings(names) + if len(names) == 0 { + return "no custom metrics defined", nil + } + return strings.Join(names, ", "), nil +} + +// CustomDefine registers a custom metric. +func (o *Operations) CustomDefine(name string, factor float64, category string) error { + reg := o.metricRegistry + // Check if already exists + if _, ok := reg.Find(name); ok { + return fmt.Errorf("metric %q already exists", name) + } + cat, ok := parseCategory(category) + if !ok { + return fmt.Errorf("unknown category %q", category) + } + m := &Metric{ + Name: name, + Category: cat, + BaseUnit: cat.String() + "_base", + Factor: func(PrefixMode) float64 { return factor }, + IsCustom: true, + } + reg.Register(m) + return nil +} + +// CustomUndefine removes a custom metric. +func (o *Operations) CustomUndefine(name string) error { + return o.metricRegistry.Unregister(name) +} diff --git a/internal/rpn/operations_metric_cmd_test.go b/internal/rpn/operations_metric_cmd_test.go index 298f8b3..e7307f7 100644 --- a/internal/rpn/operations_metric_cmd_test.go +++ b/internal/rpn/operations_metric_cmd_test.go @@ -380,3 +380,89 @@ func TestPrefixMode(t *testing.T) { }) } } + +func TestCustomDefineAndList(t *testing.T) { + vars := NewVariables() + rpn := NewRPN(vars) + + // Define a custom metric + result, err := rpn.ParseAndEvaluate("custom define foobar 42 Custom") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(result, "defined") { + t.Errorf("expected 'defined' in result, got: %s", result) + } + + // List custom metrics + result, err = rpn.ParseAndEvaluate("custom list") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(result, "foobar") { + t.Errorf("expected 'foobar' in custom list, got: %s", result) + } + + // Use the custom metric + result, err = rpn.ParseAndEvaluate("10foobar 5 +") + if err != nil { + t.Fatalf("unexpected error using custom metric: %v", err) + } + + // Undefine + result, err = rpn.ParseAndEvaluate("custom undefine foobar") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(result, "removed") { + t.Errorf("expected 'removed' in result, got: %s", result) + } +} + +func TestCustomDefineDuplicate(t *testing.T) { + vars := NewVariables() + rpn := NewRPN(vars) + _, err := rpn.ParseAndEvaluate("custom define Cool 1 Universal") + if err == nil { + t.Error("expected error for duplicate metric name") + } +} + +func TestCustomDefineInvalidCategory(t *testing.T) { + vars := NewVariables() + rpn := NewRPN(vars) + _, err := rpn.ParseAndEvaluate("custom define foo 1 Nope") + if err == nil { + t.Error("expected error for invalid category") + } +} + +func TestCustomUndefineBuiltIn(t *testing.T) { + vars := NewVariables() + rpn := NewRPN(vars) + _, err := rpn.ParseAndEvaluate("custom undefine Cool") + if err == nil { + t.Error("expected error for undefining built-in metric") + } +} + +func TestCustomUndefineNotFound(t *testing.T) { + vars := NewVariables() + rpn := NewRPN(vars) + _, err := rpn.ParseAndEvaluate("custom undefine nonexistent") + if err == nil { + t.Error("expected error for undefining non-existent metric") + } +} + +func TestCustomListEmpty(t *testing.T) { + vars := NewVariables() + rpn := NewRPN(vars) + result, err := rpn.ParseAndEvaluate("custom list") + if err != nil { + t.Fatalf("unexpected error: %v", err) + } + if !strings.Contains(result, "no custom") { + t.Errorf("expected 'no custom' when empty, got: %s", result) + } +} diff --git a/internal/rpn/rpn_parse.go b/internal/rpn/rpn_parse.go index ab64ec7..200b080 100644 --- a/internal/rpn/rpn_parse.go +++ b/internal/rpn/rpn_parse.go @@ -393,6 +393,53 @@ func (r *RPN) evaluate(input string, tokens []string) (string, error) { } } + // Handle multi-word custom command: custom + if token == "custom" && 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: custom show: %w", err) + } + return result, nil + case "list": + result, err := r.ops.CustomList(stack) + if err != nil { + return "", fmt.Errorf("rpn: custom list: %w", err) + } + return result, nil + case "define": + if i+4 < len(tokens) { + name := tokens[i+2] + factorStr := tokens[i+3] + category := tokens[i+4] + factor, err := strconv.ParseFloat(factorStr, 64) + if err != nil { + return "", fmt.Errorf("rpn: custom define: invalid factor %q", factorStr) + } + err = r.ops.CustomDefine(name, factor, category) + if err != nil { + return "", fmt.Errorf("rpn: custom define: %w", err) + } + return fmt.Sprintf("defined custom metric %q (factor: %g, category: %s)", name, factor, category), nil + } + return "", fmt.Errorf("rpn: custom define: usage: custom define ") + case "undefine": + if i+2 < len(tokens) { + name := tokens[i+2] + err := r.ops.CustomUndefine(name) + if err != nil { + return "", fmt.Errorf("rpn: custom undefine: %w", err) + } + return fmt.Sprintf("removed custom metric %q", name), nil + } + return "", fmt.Errorf("rpn: custom undefine: usage: custom undefine ") + default: + return "", fmt.Errorf("rpn: unknown custom subcommand %q. Use: show, list, define, undefine", subCmd) + } + } + // 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 -- cgit v1.2.3