diff --git a/internal/pipeline/cobra.go b/internal/pipeline/cobra.go new file mode 100644 index 00000000..7c401735 --- /dev/null +++ b/internal/pipeline/cobra.go @@ -0,0 +1,115 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipeline + +import ( + "log/slog" + "os" + + "github.com/spf13/cobra" + "github.com/spf13/pflag" +) + +// RunPreParse resolves the target command from the raw args, extracts +// flag names from the Cobra command tree, and runs all PreParse +// handlers. The corrected args are set back on the root command via +// SetArgs so that Cobra's subsequent ExecuteC uses the corrected +// values. +// +// If the target command cannot be resolved (e.g. the user typed a +// non-existent command), PreParse is skipped silently and Cobra will +// handle the error. +func RunPreParse(root *cobra.Command, engine *Engine) { + if engine == nil || !engine.HasHandlers(PreParse) { + return + } + + rawArgs := os.Args[1:] + if len(rawArgs) == 0 { + return + } + + // Traverse the command tree to find the target command. + target, _, err := root.Traverse(rawArgs) + if err != nil || target == nil { + return + } + + // Build FlagInfo from the target command's registered flags. + flagInfos := FlagInfoFromCommand(target) + if len(flagInfos) == 0 { + return + } + + ctx := &Context{ + Args: append([]string{}, rawArgs...), + FlagSpecs: flagInfos, + } + + if err := engine.RunPhase(PreParse, ctx); err != nil { + slog.Debug("pipeline pre-parse", "error", err) + return + } + + // Only set corrected args if PreParse actually changed something. + if len(ctx.Corrections) > 0 { + root.SetArgs(ctx.Args) + for _, c := range ctx.Corrections { + slog.Debug("pipeline correction", + "handler", c.Handler, + "kind", c.Kind, + "field", c.Field, + "original", c.Original, + "corrected", c.Corrected, + ) + } + } +} + +// FlagInfoFromCommand extracts FlagInfo entries from a Cobra +// command's registered flags (both local and inherited). +func FlagInfoFromCommand(cmd *cobra.Command) []FlagInfo { + if cmd == nil { + return nil + } + + seen := make(map[string]bool) + var infos []FlagInfo + + cmd.Flags().VisitAll(func(f *pflag.Flag) { + if seen[f.Name] { + return + } + seen[f.Name] = true + infos = append(infos, FlagInfo{ + Name: f.Name, + PropertyName: f.Name, + Type: f.Value.Type(), + }) + }) + + cmd.InheritedFlags().VisitAll(func(f *pflag.Flag) { + if seen[f.Name] { + return + } + seen[f.Name] = true + infos = append(infos, FlagInfo{ + Name: f.Name, + PropertyName: f.Name, + Type: f.Value.Type(), + }) + }) + + return infos +} diff --git a/internal/pipeline/engine.go b/internal/pipeline/engine.go new file mode 100644 index 00000000..87496191 --- /dev/null +++ b/internal/pipeline/engine.go @@ -0,0 +1,98 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipeline + +import "fmt" + +// Engine manages handler registration and executes the pipeline +// chain. Handlers are grouped by phase and executed in registration +// order within each phase. The engine is safe to use concurrently +// for reads after all handlers have been registered; registration +// itself is not concurrent-safe and should be done at startup. +type Engine struct { + handlers map[Phase][]Handler +} + +// NewEngine creates a pipeline engine with no registered handlers. +func NewEngine() *Engine { + return &Engine{ + handlers: make(map[Phase][]Handler), + } +} + +// Register adds a handler to its declared phase. Handlers within +// the same phase execute in registration order. +func (e *Engine) Register(h Handler) { + phase := h.Phase() + e.handlers[phase] = append(e.handlers[phase], h) +} + +// RegisterAll registers multiple handlers at once. +func (e *Engine) RegisterAll(handlers ...Handler) { + for _, h := range handlers { + e.Register(h) + } +} + +// Handlers returns the registered handlers for a given phase, in +// registration order. The returned slice must not be modified. +func (e *Engine) Handlers(phase Phase) []Handler { + return e.handlers[phase] +} + +// HasHandlers reports whether at least one handler is registered +// for the given phase. +func (e *Engine) HasHandlers(phase Phase) bool { + return len(e.handlers[phase]) > 0 +} + +// RunPhase executes all handlers registered for the given phase in +// chain order. If any handler returns an error, execution stops +// immediately and the error is returned with the handler name as +// context. +func (e *Engine) RunPhase(phase Phase, ctx *Context) error { + for _, h := range e.handlers[phase] { + if err := h.Handle(ctx); err != nil { + return fmt.Errorf("pipeline %s handler %q: %w", phase, h.Name(), err) + } + } + return nil +} + +// Run executes all phases in order: Register → PreParse → PostParse +// → PreRequest → PostResponse. Each phase runs its handler chain +// completely before the next phase begins. +// +// Callers typically do not use Run directly — the CLI integration +// calls RunPhase at each stage of the execution flow. Run is +// provided for testing and for cases where the full pipeline must +// be exercised in one shot. +func (e *Engine) Run(ctx *Context) error { + for _, phase := range phases() { + if err := e.RunPhase(phase, ctx); err != nil { + return err + } + } + return nil +} + +// HandlerCount returns the total number of registered handlers +// across all phases. +func (e *Engine) HandlerCount() int { + total := 0 + for _, handlers := range e.handlers { + total += len(handlers) + } + return total +} diff --git a/internal/pipeline/engine_test.go b/internal/pipeline/engine_test.go new file mode 100644 index 00000000..ce798769 --- /dev/null +++ b/internal/pipeline/engine_test.go @@ -0,0 +1,300 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipeline + +import ( + "errors" + "strings" + "testing" +) + +// --- test helpers --- + +type stubHandler struct { + name string + phase Phase + fn func(*Context) error + called bool + callSeq *[]string +} + +func (h *stubHandler) Name() string { return h.name } +func (h *stubHandler) Phase() Phase { return h.phase } +func (h *stubHandler) Handle(ctx *Context) error { + h.called = true + if h.callSeq != nil { + *h.callSeq = append(*h.callSeq, h.name) + } + if h.fn != nil { + return h.fn(ctx) + } + return nil +} + +func newStub(name string, phase Phase, fn func(*Context) error) *stubHandler { + return &stubHandler{name: name, phase: phase, fn: fn} +} + +func newStubWithSeq(name string, phase Phase, seq *[]string) *stubHandler { + return &stubHandler{name: name, phase: phase, callSeq: seq} +} + +// --- tests --- + +func TestNewEngine(t *testing.T) { + e := NewEngine() + if e == nil { + t.Fatal("NewEngine returned nil") + } + if got := e.HandlerCount(); got != 0 { + t.Errorf("HandlerCount = %d, want 0", got) + } +} + +func TestRegisterAndHandlerCount(t *testing.T) { + e := NewEngine() + e.Register(newStub("a", PreParse, nil)) + e.Register(newStub("b", PreParse, nil)) + e.Register(newStub("c", PostParse, nil)) + + if got := e.HandlerCount(); got != 3 { + t.Errorf("HandlerCount = %d, want 3", got) + } + if got := len(e.Handlers(PreParse)); got != 2 { + t.Errorf("PreParse handlers = %d, want 2", got) + } + if got := len(e.Handlers(PostParse)); got != 1 { + t.Errorf("PostParse handlers = %d, want 1", got) + } + if got := len(e.Handlers(PreRequest)); got != 0 { + t.Errorf("PreRequest handlers = %d, want 0", got) + } +} + +func TestRegisterAll(t *testing.T) { + e := NewEngine() + e.RegisterAll( + newStub("a", PreParse, nil), + newStub("b", PostParse, nil), + newStub("c", PreRequest, nil), + ) + if got := e.HandlerCount(); got != 3 { + t.Errorf("HandlerCount = %d, want 3", got) + } +} + +func TestHasHandlers(t *testing.T) { + e := NewEngine() + if e.HasHandlers(PreParse) { + t.Error("HasHandlers(PreParse) = true on empty engine") + } + e.Register(newStub("a", PreParse, nil)) + if !e.HasHandlers(PreParse) { + t.Error("HasHandlers(PreParse) = false after registration") + } + if e.HasHandlers(PostResponse) { + t.Error("HasHandlers(PostResponse) = true, want false") + } +} + +func TestRunPhaseChainOrder(t *testing.T) { + var seq []string + e := NewEngine() + e.Register(newStubWithSeq("first", PreParse, &seq)) + e.Register(newStubWithSeq("second", PreParse, &seq)) + e.Register(newStubWithSeq("third", PreParse, &seq)) + + ctx := &Context{Args: []string{"test"}} + if err := e.RunPhase(PreParse, ctx); err != nil { + t.Fatalf("RunPhase returned error: %v", err) + } + want := "first,second,third" + got := strings.Join(seq, ",") + if got != want { + t.Errorf("execution order = %q, want %q", got, want) + } +} + +func TestRunPhaseContextMutationPropagates(t *testing.T) { + e := NewEngine() + e.Register(newStub("append-foo", PreParse, func(ctx *Context) error { + ctx.Args = append(ctx.Args, "--foo") + return nil + })) + e.Register(newStub("append-bar", PreParse, func(ctx *Context) error { + ctx.Args = append(ctx.Args, "--bar") + return nil + })) + + ctx := &Context{Args: []string{"cmd"}} + if err := e.RunPhase(PreParse, ctx); err != nil { + t.Fatalf("RunPhase error: %v", err) + } + want := "cmd,--foo,--bar" + got := strings.Join(ctx.Args, ",") + if got != want { + t.Errorf("Args = %q, want %q", got, want) + } +} + +func TestRunPhaseErrorAbortsChain(t *testing.T) { + boom := errors.New("boom") + h1 := newStub("ok", PreParse, nil) + h2 := newStub("fail", PreParse, func(*Context) error { return boom }) + h3 := newStub("never", PreParse, nil) + + e := NewEngine() + e.RegisterAll(h1, h2, h3) + + ctx := &Context{} + err := e.RunPhase(PreParse, ctx) + if err == nil { + t.Fatal("expected error, got nil") + } + if !errors.Is(err, boom) { + t.Errorf("error = %v, want wrapped boom", err) + } + if !strings.Contains(err.Error(), "fail") { + t.Errorf("error should contain handler name, got %q", err.Error()) + } + if !h1.called { + t.Error("h1 should have been called") + } + if !h2.called { + t.Error("h2 should have been called") + } + if h3.called { + t.Error("h3 should NOT have been called after error") + } +} + +func TestRunPhaseEmptyPhaseNoError(t *testing.T) { + e := NewEngine() + ctx := &Context{} + if err := e.RunPhase(PreParse, ctx); err != nil { + t.Errorf("RunPhase on empty phase returned error: %v", err) + } +} + +func TestRunAllPhasesOrder(t *testing.T) { + var seq []string + e := NewEngine() + e.Register(newStubWithSeq("post-resp", PostResponse, &seq)) + e.Register(newStubWithSeq("pre-parse", PreParse, &seq)) + e.Register(newStubWithSeq("register", Register, &seq)) + e.Register(newStubWithSeq("post-parse", PostParse, &seq)) + e.Register(newStubWithSeq("pre-req", PreRequest, &seq)) + + ctx := &Context{} + if err := e.Run(ctx); err != nil { + t.Fatalf("Run returned error: %v", err) + } + want := "register,pre-parse,post-parse,pre-req,post-resp" + got := strings.Join(seq, ",") + if got != want { + t.Errorf("phase execution order = %q, want %q", got, want) + } +} + +func TestRunAbortsOnPhaseError(t *testing.T) { + var seq []string + e := NewEngine() + e.Register(newStubWithSeq("pre-parse", PreParse, &seq)) + e.Register(newStub("post-parse-fail", PostParse, func(*Context) error { + return errors.New("fail") + })) + e.Register(newStubWithSeq("pre-req", PreRequest, &seq)) + + ctx := &Context{} + err := e.Run(ctx) + if err == nil { + t.Fatal("expected error") + } + if len(seq) != 1 || seq[0] != "pre-parse" { + t.Errorf("seq = %v, want [pre-parse]", seq) + } +} + +func TestContextAddCorrection(t *testing.T) { + ctx := &Context{} + ctx.AddCorrection("alias", PreParse, "--userId", "--userId", "--user-id", "alias") + ctx.AddCorrection("sticky", PreParse, "--limit", "--limit100", "--limit 100", "sticky") + + if got := len(ctx.Corrections); got != 2 { + t.Fatalf("Corrections count = %d, want 2", got) + } + c := ctx.Corrections[0] + if c.Handler != "alias" || c.Original != "--userId" || c.Corrected != "--user-id" { + t.Errorf("first correction = %+v", c) + } +} + +func TestPhaseString(t *testing.T) { + tests := []struct { + phase Phase + want string + }{ + {Register, "register"}, + {PreParse, "pre-parse"}, + {PostParse, "post-parse"}, + {PreRequest, "pre-request"}, + {PostResponse, "post-response"}, + {Phase(99), "unknown"}, + } + for _, tt := range tests { + if got := tt.phase.String(); got != tt.want { + t.Errorf("Phase(%d).String() = %q, want %q", tt.phase, got, tt.want) + } + } +} + +func TestRunPhaseErrorContainsPhaseAndHandler(t *testing.T) { + e := NewEngine() + e.Register(newStub("my-handler", PostParse, func(*Context) error { + return errors.New("bad value") + })) + + err := e.RunPhase(PostParse, &Context{}) + if err == nil { + t.Fatal("expected error") + } + msg := err.Error() + if !strings.Contains(msg, "post-parse") { + t.Errorf("error should contain phase name, got %q", msg) + } + if !strings.Contains(msg, "my-handler") { + t.Errorf("error should contain handler name, got %q", msg) + } +} + +func TestMultipleHandlersSameAndDifferentPhases(t *testing.T) { + var seq []string + e := NewEngine() + e.Register(newStubWithSeq("pp1", PreParse, &seq)) + e.Register(newStubWithSeq("pp2", PreParse, &seq)) + e.Register(newStubWithSeq("op1", PostParse, &seq)) + e.Register(newStubWithSeq("rq1", PreRequest, &seq)) + e.Register(newStubWithSeq("rq2", PreRequest, &seq)) + e.Register(newStubWithSeq("rs1", PostResponse, &seq)) + + ctx := &Context{} + if err := e.Run(ctx); err != nil { + t.Fatalf("Run error: %v", err) + } + want := "pp1,pp2,op1,rq1,rq2,rs1" + got := strings.Join(seq, ",") + if got != want { + t.Errorf("order = %q, want %q", got, want) + } +} diff --git a/internal/pipeline/handler.go b/internal/pipeline/handler.go new file mode 100644 index 00000000..7bd2a963 --- /dev/null +++ b/internal/pipeline/handler.go @@ -0,0 +1,37 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipeline + +// Handler is the core abstraction for pipeline extensions. Each +// handler declares the phase it belongs to and provides a Handle +// method that receives a mutable context. Handlers are executed in +// registration order within a phase; each handler's output becomes +// the next handler's input. +// +// A handler that returns a non-nil error aborts the chain — no +// further handlers in the same phase (or subsequent phases) will +// run. +type Handler interface { + // Name returns a short, unique identifier for the handler + // (e.g. "sticky", "alias", "date-normalise"). Used in + // correction records and log output. + Name() string + + // Phase returns the pipeline phase this handler belongs to. + Phase() Phase + + // Handle processes the context and returns an error to abort + // the chain, or nil to continue. + Handle(ctx *Context) error +} diff --git a/internal/pipeline/handlers/alias.go b/internal/pipeline/handlers/alias.go new file mode 100644 index 00000000..f88cf595 --- /dev/null +++ b/internal/pipeline/handlers/alias.go @@ -0,0 +1,146 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handlers + +import ( + "strings" + "unicode" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" +) + +// AliasHandler normalises flag names in raw argv so that common +// model-generated variants resolve to the canonical kebab-case name. +// +// Supported normalisations: +// - camelCase → kebab-case (--userId → --user-id) +// - snake_case → kebab-case (--user_name → --user-name) +// - UPPER-CASE → lower-case (--USER-ID → --user-id) +// - PascalCase → kebab-case (--UserName → --user-name) +// +// The handler only rewrites tokens that start with "--" and whose +// normalised form matches a known flag name. Unknown flags are left +// untouched so that Cobra can report them as errors. +type AliasHandler struct{} + +func (AliasHandler) Name() string { return "alias" } +func (AliasHandler) Phase() pipeline.Phase { return pipeline.PreParse } + +func (AliasHandler) Handle(ctx *pipeline.Context) error { + if len(ctx.Args) == 0 || len(ctx.FlagSpecs) == 0 { + return nil + } + + known := buildFlagSet(ctx.FlagSpecs) + result := make([]string, 0, len(ctx.Args)) + + for i, arg := range ctx.Args { + rewritten, ok := tryNormaliseFlag(arg, known) + if ok { + ctx.AddCorrection("alias", pipeline.PreParse, rewritten, arg, rewritten, "alias") + result = append(result, rewritten) + } else { + result = append(result, ctx.Args[i]) + } + } + + ctx.Args = result + return nil +} + +// tryNormaliseFlag checks whether arg is a "--flag" token that can be +// normalised to a known flag name. It handles both bare flags and +// "--flag=value" syntax. +func tryNormaliseFlag(arg string, known map[string]bool) (string, bool) { + if !strings.HasPrefix(arg, "--") { + return "", false + } + + bare := arg[2:] + if bare == "" { + return "", false + } + + // Handle --flag=value syntax: split, normalise the key, reassemble. + var suffix string + if idx := strings.IndexByte(bare, '='); idx >= 0 { + suffix = bare[idx:] // includes "=" + bare = bare[:idx] + } + + // Already a known flag in its current form — no change needed. + if known[bare] { + return "", false + } + + normalised := toKebabCase(bare) + if normalised == bare { + return "", false + } + if !known[normalised] { + return "", false + } + + return "--" + normalised + suffix, true +} + +// toKebabCase converts a string from camelCase, PascalCase, or +// snake_case to kebab-case. Examples: +// +// "userId" → "user-id" +// "UserName" → "user-name" +// "user_name" → "user-name" +// "USER_ID" → "user-id" +// "pageSize" → "page-size" +func toKebabCase(s string) string { + if s == "" { + return "" + } + + var b strings.Builder + b.Grow(len(s) + 4) // small extra for hyphens + + runes := []rune(s) + for i, r := range runes { + if r == '_' || r == ' ' { + if b.Len() > 0 { + b.WriteByte('-') + } + continue + } + + if unicode.IsUpper(r) { + // Insert hyphen before an uppercase letter when: + // 1. Not at start, AND + // 2. Previous char was lowercase, OR + // 3. Next char is lowercase (handles "userID" → "user-id" + // at the boundary between "I" and "D" in "ID" we don't + // split, but "IDs" → we split before "s" which is + // handled by the lowercase check at the next iteration). + if i > 0 { + prev := runes[i-1] + if unicode.IsLower(prev) { + b.WriteByte('-') + } else if unicode.IsUpper(prev) && i+1 < len(runes) && unicode.IsLower(runes[i+1]) { + b.WriteByte('-') + } + } + b.WriteRune(unicode.ToLower(r)) + } else { + b.WriteRune(unicode.ToLower(r)) + } + } + + return strings.Trim(b.String(), "-") +} diff --git a/internal/pipeline/handlers/alias_test.go b/internal/pipeline/handlers/alias_test.go new file mode 100644 index 00000000..dd76a2fd --- /dev/null +++ b/internal/pipeline/handlers/alias_test.go @@ -0,0 +1,186 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handlers + +import ( + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" +) + +func TestToKebabCase(t *testing.T) { + tests := []struct { + input string + want string + }{ + {"userId", "user-id"}, + {"UserName", "user-name"}, + {"user_name", "user-name"}, + {"USER_ID", "user-id"}, + {"pageSize", "page-size"}, + {"user-id", "user-id"}, + {"limit", "limit"}, + {"ID", "id"}, + {"userID", "user-id"}, + {"HTMLParser", "html-parser"}, + {"getHTTPResponse", "get-http-response"}, + {"a", "a"}, + {"", ""}, + {"ABC", "abc"}, + {"already-kebab", "already-kebab"}, + {"with spaces", "with-spaces"}, + {"__leading_underscores", "leading-underscores"}, + {"mixedCamel_and_snake", "mixed-camel-and-snake"}, + } + + for _, tt := range tests { + t.Run(tt.input, func(t *testing.T) { + got := toKebabCase(tt.input) + if got != tt.want { + t.Errorf("toKebabCase(%q) = %q, want %q", tt.input, got, tt.want) + } + }) + } +} + +func TestAliasHandler(t *testing.T) { + tests := []struct { + name string + args []string + flags []string + want string + corrections int + }{ + { + name: "camelCase to kebab-case", + args: []string{"--userId", "123"}, + flags: []string{"user-id"}, + want: "--user-id 123", + corrections: 1, + }, + { + name: "snake_case to kebab-case", + args: []string{"--user_name", "alice"}, + flags: []string{"user-name"}, + want: "--user-name alice", + corrections: 1, + }, + { + name: "UPPER to lower", + args: []string{"--USER-ID", "456"}, + flags: []string{"user-id"}, + want: "--user-id 456", + corrections: 1, + }, + { + name: "PascalCase to kebab-case", + args: []string{"--PageSize", "20"}, + flags: []string{"page-size"}, + want: "--page-size 20", + corrections: 1, + }, + { + name: "already correct — no change", + args: []string{"--user-id", "789"}, + flags: []string{"user-id"}, + want: "--user-id 789", + corrections: 0, + }, + { + name: "unknown flag — left untouched", + args: []string{"--fooBar", "val"}, + flags: []string{"user-id"}, + want: "--fooBar val", + corrections: 0, + }, + { + name: "with = syntax", + args: []string{"--userId=123"}, + flags: []string{"user-id"}, + want: "--user-id=123", + corrections: 1, + }, + { + name: "multiple flags mixed", + args: []string{"--userId", "1", "--pageSize", "10", "--name", "test"}, + flags: []string{"user-id", "page-size", "name"}, + want: "--user-id 1 --page-size 10 --name test", + corrections: 2, + }, + { + name: "single dash is ignored", + args: []string{"-v"}, + flags: []string{"verbose"}, + want: "-v", + corrections: 0, + }, + { + name: "empty args", + args: []string{}, + flags: []string{"limit"}, + want: "", + corrections: 0, + }, + { + name: "bare double dash", + args: []string{"--"}, + flags: []string{"limit"}, + want: "--", + corrections: 0, + }, + { + name: "normalised form not in known set", + args: []string{"--unknownFlag"}, + flags: []string{"limit"}, + want: "--unknownFlag", + corrections: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &pipeline.Context{ + Args: append([]string{}, tt.args...), + FlagSpecs: flagSpecs(tt.flags...), + } + h := AliasHandler{} + if err := h.Handle(ctx); err != nil { + t.Fatalf("Handle error: %v", err) + } + got := strings.Join(ctx.Args, " ") + if got != tt.want { + t.Errorf("Args = %q, want %q", got, tt.want) + } + if len(ctx.Corrections) != tt.corrections { + t.Errorf("Corrections = %d, want %d", len(ctx.Corrections), tt.corrections) + } + for _, c := range ctx.Corrections { + if c.Kind != "alias" { + t.Errorf("correction kind = %q, want %q", c.Kind, "alias") + } + } + }) + } +} + +func TestAliasHandlerNameAndPhase(t *testing.T) { + h := AliasHandler{} + if h.Name() != "alias" { + t.Errorf("Name() = %q, want %q", h.Name(), "alias") + } + if h.Phase() != pipeline.PreParse { + t.Errorf("Phase() = %v, want PreParse", h.Phase()) + } +} diff --git a/internal/pipeline/handlers/integration_test.go b/internal/pipeline/handlers/integration_test.go new file mode 100644 index 00000000..eee94cd3 --- /dev/null +++ b/internal/pipeline/handlers/integration_test.go @@ -0,0 +1,284 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handlers + +import ( + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" +) + +// TestFullPreParsePipeline exercises the complete PreParse handler +// chain: AliasHandler → StickyHandler → ParamNameHandler. It +// simulates a model-generated CLI invocation with multiple errors +// and verifies the pipeline corrects all of them in one pass. +func TestFullPreParsePipeline(t *testing.T) { + engine := pipeline.NewEngine() + engine.RegisterAll( + AliasHandler{}, + StickyHandler{}, + ParamNameHandler{}, + ) + + tests := []struct { + name string + args []string + flags []string + want string + corrections int + }{ + { + name: "camelCase + sticky combined", + args: []string{"--userId", "123", "--pageSize50"}, + flags: []string{"user-id", "page-size"}, + want: "--user-id 123 --page-size 50", + corrections: 2, // alias(userId) + sticky(pageSize50) + }, + { + name: "camelCase + typo combined", + args: []string{"--userId", "123", "--limt", "10"}, + flags: []string{"user-id", "limit"}, + want: "--user-id 123 --limit 10", + corrections: 2, // alias(userId) + fuzzy(limt) + }, + { + name: "triple error: case + sticky + typo", + args: []string{"--UserName", "alice", "--limit100", "--offse", "0"}, + flags: []string{"user-name", "limit", "offset"}, + want: "--user-name alice --limit 100 --offset 0", + corrections: 3, + }, + { + name: "snake_case + sticky", + args: []string{"--user_id", "42", "--pageSize20"}, + flags: []string{"user-id", "page-size"}, + want: "--user-id 42 --page-size 20", + corrections: 2, + }, + { + name: "all correct — zero corrections", + args: []string{"--user-id", "123", "--limit", "10"}, + flags: []string{"user-id", "limit"}, + want: "--user-id 123 --limit 10", + corrections: 0, + }, + { + name: "UPPER case flags", + args: []string{"--USER-ID", "999"}, + flags: []string{"user-id"}, + want: "--user-id 999", + corrections: 1, + }, + { + name: "= syntax with camelCase", + args: []string{"--userId=123", "--pageSize=50"}, + flags: []string{"user-id", "page-size"}, + want: "--user-id=123 --page-size=50", + corrections: 2, + }, + { + name: "camelCase sticky split with normalisation", + args: []string{"--limitValue100"}, + flags: []string{"limit-value"}, + want: "--limit-value 100", + corrections: 1, // sticky handles both kebab-normalisation and split + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &pipeline.Context{ + Args: append([]string{}, tt.args...), + FlagSpecs: flagSpecs(tt.flags...), + } + if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil { + t.Fatalf("RunPhase error: %v", err) + } + got := strings.Join(ctx.Args, " ") + if got != tt.want { + t.Errorf("Args = %q, want %q", got, tt.want) + } + if len(ctx.Corrections) != tt.corrections { + t.Errorf("Corrections = %d, want %d", len(ctx.Corrections), tt.corrections) + for _, c := range ctx.Corrections { + t.Logf(" %s/%s: %q → %q", c.Handler, c.Kind, c.Original, c.Corrected) + } + } + }) + } +} + +// TestFullPostParsePipeline exercises the PostParse handler chain +// with the ParamValueHandler normalising multiple value types in a +// single invocation. +func TestFullPostParsePipeline(t *testing.T) { + engine := pipeline.NewEngine() + engine.RegisterAll(ParamValueHandler{}) + + ctx := &pipeline.Context{ + Command: "aitable.list_records", + Params: map[string]any{ + "verbose": "yes", + "limit": "1,000", + "status": "ACTIVE", + "created_at": "2024/03/29", + "name": "test", + }, + Schema: map[string]any{ + "properties": map[string]any{ + "verbose": map[string]any{"type": "boolean"}, + "limit": map[string]any{"type": "integer"}, + "status": map[string]any{"type": "string", "enum": []any{"active", "inactive"}}, + "created_at": map[string]any{"type": "string", "format": "date"}, + "name": map[string]any{"type": "string"}, + }, + }, + } + + if err := engine.RunPhase(pipeline.PostParse, ctx); err != nil { + t.Fatalf("RunPhase error: %v", err) + } + + if got := ctx.Params["verbose"]; got != true { + t.Errorf("verbose = %v, want true", got) + } + if got := ctx.Params["limit"]; got != int64(1000) { + t.Errorf("limit = %v, want 1000", got) + } + if got := ctx.Params["status"]; got != "active" { + t.Errorf("status = %q, want %q", got, "active") + } + if got := ctx.Params["created_at"]; got != "2024-03-29" { + t.Errorf("created_at = %q, want %q", got, "2024-03-29") + } + // "name" is a plain string — should be untouched. + if got := ctx.Params["name"]; got != "test" { + t.Errorf("name = %q, want %q", got, "test") + } + if len(ctx.Corrections) != 4 { + t.Errorf("Corrections = %d, want 4", len(ctx.Corrections)) + } +} + +// TestFullPipelineEndToEnd exercises all phases in sequence: +// PreParse → PostParse, simulating a model invocation that has +// errors at both the argv level and the value level. +func TestFullPipelineEndToEnd(t *testing.T) { + engine := pipeline.NewEngine() + engine.RegisterAll( + AliasHandler{}, + StickyHandler{}, + ParamNameHandler{}, + ParamValueHandler{}, + ) + + // Phase 1: PreParse — fix raw argv. + ctx := &pipeline.Context{ + Args: []string{ + "--userId", "u001", + "--pageSize50", + "--verbosetrue", + }, + FlagSpecs: flagSpecs("user-id", "page-size", "verbose"), + } + + if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil { + t.Fatalf("PreParse error: %v", err) + } + + want := "--user-id u001 --page-size 50 --verbose true" + got := strings.Join(ctx.Args, " ") + if got != want { + t.Errorf("after PreParse: Args = %q, want %q", got, want) + } + + preParseCorrections := len(ctx.Corrections) + if preParseCorrections != 3 { + t.Errorf("PreParse corrections = %d, want 3", preParseCorrections) + } + + // Phase 2: PostParse — simulate Cobra having parsed the corrected + // args into structured params, then normalise values. + ctx.Command = "chat.send_message" + ctx.Params = map[string]any{ + "user_id": "u001", + "page_size": "50", + "verbose": "true", + } + ctx.Schema = map[string]any{ + "properties": map[string]any{ + "user_id": map[string]any{"type": "string"}, + "page_size": map[string]any{"type": "integer"}, + "verbose": map[string]any{"type": "boolean"}, + }, + } + + if err := engine.RunPhase(pipeline.PostParse, ctx); err != nil { + t.Fatalf("PostParse error: %v", err) + } + + if got := ctx.Params["verbose"]; got != true { + t.Errorf("verbose = %v (%T), want true (bool)", got, got) + } + // page_size "50" has no grouping separators — unchanged. + if got := ctx.Params["page_size"]; got != "50" { + t.Errorf("page_size = %v, want %q", got, "50") + } + + totalCorrections := len(ctx.Corrections) + postParseCorrections := totalCorrections - preParseCorrections + if postParseCorrections != 1 { + t.Errorf("PostParse corrections = %d, want 1", postParseCorrections) + } +} + +// TestPreParseDoesNotBreakValidArgs verifies that valid, correctly +// formatted args pass through the pipeline without modification. +func TestPreParseDoesNotBreakValidArgs(t *testing.T) { + engine := pipeline.NewEngine() + engine.RegisterAll( + AliasHandler{}, + StickyHandler{}, + ParamNameHandler{}, + ) + + original := []string{ + "--user-id", "123", + "--limit", "10", + "--offset", "0", + "--format", "json", + "--verbose", + } + ctx := &pipeline.Context{ + Args: append([]string{}, original...), + FlagSpecs: flagSpecs("user-id", "limit", "offset", "format", "verbose"), + } + + if err := engine.RunPhase(pipeline.PreParse, ctx); err != nil { + t.Fatalf("RunPhase error: %v", err) + } + + got := strings.Join(ctx.Args, " ") + want := strings.Join(original, " ") + if got != want { + t.Errorf("Args changed!\n got: %q\n want: %q", got, want) + } + if len(ctx.Corrections) != 0 { + t.Errorf("unexpected corrections: %d", len(ctx.Corrections)) + for _, c := range ctx.Corrections { + t.Logf(" %s: %q → %q", c.Handler, c.Original, c.Corrected) + } + } +} diff --git a/internal/pipeline/handlers/paramname.go b/internal/pipeline/handlers/paramname.go new file mode 100644 index 00000000..1cd4207a --- /dev/null +++ b/internal/pipeline/handlers/paramname.go @@ -0,0 +1,164 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handlers + +import ( + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" +) + +// ParamNameHandler performs fuzzy correction on flag names that are +// not recognised after alias normalisation. It uses Levenshtein edit +// distance to find the closest known flag name, with a conservative +// threshold to avoid false positives. +// +// Correction rules: +// - Edit distance must be ≤ maxEditDistance (default 2). +// - The match must be unambiguous (exactly one candidate within threshold). +// - Very short flag names (≤ 3 chars) use a tighter threshold of 1. +// +// This handler should run after AliasHandler in the PreParse phase +// so that obvious normalisation (camelCase → kebab-case) is already +// done and fuzzy matching only handles genuine near-misses. +type ParamNameHandler struct{} + +const maxEditDistance = 2 + +func (ParamNameHandler) Name() string { return "paramname" } +func (ParamNameHandler) Phase() pipeline.Phase { return pipeline.PreParse } + +func (ParamNameHandler) Handle(ctx *pipeline.Context) error { + if len(ctx.Args) == 0 || len(ctx.FlagSpecs) == 0 { + return nil + } + + known := buildFlagSet(ctx.FlagSpecs) + names := make([]string, 0, len(ctx.FlagSpecs)) + for _, spec := range ctx.FlagSpecs { + if spec.Name != "" { + names = append(names, spec.Name) + } + } + + result := make([]string, 0, len(ctx.Args)) + for _, arg := range ctx.Args { + rewritten, ok := tryFuzzyMatch(arg, known, names) + if ok { + ctx.AddCorrection("paramname", pipeline.PreParse, rewritten, arg, rewritten, "fuzzy") + result = append(result, rewritten) + } else { + result = append(result, arg) + } + } + + ctx.Args = result + return nil +} + +// tryFuzzyMatch attempts to correct an unrecognised "--flag" token by +// finding the closest known flag name within the edit distance threshold. +func tryFuzzyMatch(arg string, known map[string]bool, candidates []string) (string, bool) { + if !strings.HasPrefix(arg, "--") { + return "", false + } + + bare := arg[2:] + if bare == "" { + return "", false + } + + // Handle --flag=value syntax. + var suffix string + if idx := strings.IndexByte(bare, '='); idx >= 0 { + suffix = bare[idx:] + bare = bare[:idx] + } + + // Already known — nothing to fix. + if known[bare] { + return "", false + } + + threshold := maxEditDistance + if len(bare) <= 3 { + threshold = 1 + } + + bestDist := threshold + 1 + bestMatch := "" + ambiguous := false + + for _, candidate := range candidates { + dist := levenshtein(bare, candidate) + if dist < bestDist { + bestDist = dist + bestMatch = candidate + ambiguous = false + } else if dist == bestDist && candidate != bestMatch { + ambiguous = true + } + } + + if bestDist > threshold || ambiguous || bestMatch == "" { + return "", false + } + + return "--" + bestMatch + suffix, true +} + +// levenshtein computes the edit distance between two strings using +// the standard dynamic programming approach with O(min(m,n)) space. +func levenshtein(a, b string) int { + if a == b { + return 0 + } + + la, lb := len(a), len(b) + if la == 0 { + return lb + } + if lb == 0 { + return la + } + + // Ensure a is the shorter string for O(min) space. + if la > lb { + a, b = b, a + la, lb = lb, la + } + + prev := make([]int, la+1) + curr := make([]int, la+1) + for i := range prev { + prev[i] = i + } + + for j := 1; j <= lb; j++ { + curr[0] = j + for i := 1; i <= la; i++ { + cost := 1 + if a[i-1] == b[j-1] { + cost = 0 + } + curr[i] = min( + prev[i]+1, // deletion + curr[i-1]+1, // insertion + prev[i-1]+cost, // substitution + ) + } + prev, curr = curr, prev + } + return prev[la] +} diff --git a/internal/pipeline/handlers/paramname_test.go b/internal/pipeline/handlers/paramname_test.go new file mode 100644 index 00000000..ef3f6c07 --- /dev/null +++ b/internal/pipeline/handlers/paramname_test.go @@ -0,0 +1,191 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handlers + +import ( + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" +) + +func TestLevenshtein(t *testing.T) { + tests := []struct { + a, b string + want int + }{ + {"", "", 0}, + {"a", "", 1}, + {"", "a", 1}, + {"abc", "abc", 0}, + {"abc", "abd", 1}, + {"abc", "abcd", 1}, + {"kitten", "sitting", 3}, + {"limit", "limt", 1}, + {"limit", "limti", 2}, + {"user-id", "user-di", 2}, + {"page", "pge", 1}, + } + + for _, tt := range tests { + t.Run(tt.a+"→"+tt.b, func(t *testing.T) { + got := levenshtein(tt.a, tt.b) + if got != tt.want { + t.Errorf("levenshtein(%q, %q) = %d, want %d", tt.a, tt.b, got, tt.want) + } + // Verify symmetry. + gotRev := levenshtein(tt.b, tt.a) + if gotRev != got { + t.Errorf("asymmetric: (%q,%q)=%d but (%q,%q)=%d", tt.a, tt.b, got, tt.b, tt.a, gotRev) + } + }) + } +} + +func TestParamNameHandler(t *testing.T) { + tests := []struct { + name string + args []string + flags []string + want string + corrections int + }{ + { + name: "typo correction — limt → limit", + args: []string{"--limt", "10"}, + flags: []string{"limit", "offset", "name"}, + want: "--limit 10", + corrections: 1, + }, + { + name: "transposition — limti → limit", + args: []string{"--limti", "10"}, + flags: []string{"limit", "offset"}, + want: "--limit 10", + corrections: 1, + }, + { + name: "known flag unchanged", + args: []string{"--limit", "10"}, + flags: []string{"limit"}, + want: "--limit 10", + corrections: 0, + }, + { + name: "too far — no correction", + args: []string{"--xyz", "10"}, + flags: []string{"limit"}, + want: "--xyz 10", + corrections: 0, + }, + { + name: "ambiguous — two equally close", + args: []string{"--nae", "val"}, + flags: []string{"name", "nave"}, + want: "--nae val", + corrections: 0, + }, + { + name: "short flag tighter threshold", + args: []string{"--id", "1"}, + flags: []string{"id"}, + want: "--id 1", + corrections: 0, + }, + { + name: "short flag one edit away", + args: []string{"--ix", "1"}, + flags: []string{"id"}, + want: "--id 1", + corrections: 1, + }, + { + name: "short flag two edits away rejected", + args: []string{"--xy", "1"}, + flags: []string{"id"}, + want: "--xy 1", + corrections: 0, + }, + { + name: "with = syntax", + args: []string{"--limt=10"}, + flags: []string{"limit"}, + want: "--limit=10", + corrections: 1, + }, + { + name: "empty args", + args: []string{}, + flags: []string{"limit"}, + want: "", + corrections: 0, + }, + { + name: "single dash ignored", + args: []string{"-l"}, + flags: []string{"limit"}, + want: "-l", + corrections: 0, + }, + { + name: "missing char — offse → offset", + args: []string{"--offse", "0"}, + flags: []string{"offset", "limit"}, + want: "--offset 0", + corrections: 1, + }, + { + name: "extra char — offsett → offset", + args: []string{"--offsett", "0"}, + flags: []string{"offset", "limit"}, + want: "--offset 0", + corrections: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &pipeline.Context{ + Args: append([]string{}, tt.args...), + FlagSpecs: flagSpecs(tt.flags...), + } + h := ParamNameHandler{} + if err := h.Handle(ctx); err != nil { + t.Fatalf("Handle error: %v", err) + } + got := strings.Join(ctx.Args, " ") + if got != tt.want { + t.Errorf("Args = %q, want %q", got, tt.want) + } + if len(ctx.Corrections) != tt.corrections { + t.Errorf("Corrections = %d, want %d", len(ctx.Corrections), tt.corrections) + } + for _, c := range ctx.Corrections { + if c.Kind != "fuzzy" { + t.Errorf("correction kind = %q, want %q", c.Kind, "fuzzy") + } + } + }) + } +} + +func TestParamNameHandlerNameAndPhase(t *testing.T) { + h := ParamNameHandler{} + if h.Name() != "paramname" { + t.Errorf("Name() = %q, want %q", h.Name(), "paramname") + } + if h.Phase() != pipeline.PreParse { + t.Errorf("Phase() = %v, want PreParse", h.Phase()) + } +} diff --git a/internal/pipeline/handlers/paramvalue.go b/internal/pipeline/handlers/paramvalue.go new file mode 100644 index 00000000..90444f93 --- /dev/null +++ b/internal/pipeline/handlers/paramvalue.go @@ -0,0 +1,312 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handlers + +import ( + "fmt" + "strconv" + "strings" + "time" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" +) + +// ParamValueHandler normalises parameter values after Cobra parsing. +// It operates on the structured Params map using the tool's input +// schema to determine value types and apply format-specific +// corrections. +// +// Supported normalisations: +// - Boolean: "yes"/"no"/"1"/"0"/"on"/"off"/"True"/"FALSE" → true/false +// - Number: "1,000" / "1_000" → 1000 (strip grouping separators) +// - Date: multiple input formats → RFC 3339 / ISO 8601 +// - Enum: case-insensitive matching against allowed values +type ParamValueHandler struct{} + +func (ParamValueHandler) Name() string { return "paramvalue" } +func (ParamValueHandler) Phase() pipeline.Phase { return pipeline.PostParse } + +func (ParamValueHandler) Handle(ctx *pipeline.Context) error { + if len(ctx.Params) == 0 || len(ctx.Schema) == 0 { + return nil + } + + properties, ok := schemaProperties(ctx.Schema) + if !ok { + return nil + } + + for key, value := range ctx.Params { + propSchema, ok := properties[key].(map[string]any) + if !ok { + continue + } + + corrected, changed := normaliseValue(value, propSchema) + if changed { + original := fmt.Sprintf("%v", value) + ctx.Params[key] = corrected + ctx.AddCorrection("paramvalue", pipeline.PostParse, key, original, fmt.Sprintf("%v", corrected), classifyCorrection(propSchema)) + } + } + return nil +} + +// normaliseValue dispatches to the appropriate normaliser based on +// the schema type and format. +func normaliseValue(value any, schema map[string]any) (any, bool) { + // Enum normalisation takes priority — it is type-independent. + if enumValues, ok := schemaEnum(schema); ok { + return normaliseEnum(value, enumValues) + } + + schemaType, _ := schema["type"].(string) + schemaFormat, _ := schema["format"].(string) + + switch schemaType { + case "boolean": + return normaliseBoolean(value) + case "integer": + return normaliseInteger(value) + case "number": + return normaliseNumber(value) + case "string": + if isDateFormat(schemaFormat) { + return normaliseDate(value, schemaFormat) + } + } + return value, false +} + +// --- boolean normalisation --- + +var booleanTrueValues = map[string]bool{ + "true": true, "yes": true, "on": true, "1": true, +} +var booleanFalseValues = map[string]bool{ + "false": true, "no": true, "off": true, "0": true, +} + +func normaliseBoolean(value any) (any, bool) { + // Already a bool — no change needed. + if _, ok := value.(bool); ok { + return value, false + } + + str, ok := value.(string) + if !ok { + return value, false + } + lower := strings.ToLower(strings.TrimSpace(str)) + if booleanTrueValues[lower] { + return true, true + } + if booleanFalseValues[lower] { + return false, true + } + return value, false +} + +// --- integer normalisation --- + +func normaliseInteger(value any) (any, bool) { + str, ok := value.(string) + if !ok { + return value, false + } + cleaned := stripNumberGrouping(str) + if cleaned == str { + return value, false + } + parsed, err := strconv.ParseInt(cleaned, 10, 64) + if err != nil { + return value, false + } + return parsed, true +} + +// --- number normalisation --- + +func normaliseNumber(value any) (any, bool) { + str, ok := value.(string) + if !ok { + return value, false + } + cleaned := stripNumberGrouping(str) + if cleaned == str { + return value, false + } + parsed, err := strconv.ParseFloat(cleaned, 64) + if err != nil { + return value, false + } + return parsed, true +} + +// stripNumberGrouping removes comma and underscore grouping +// separators from a numeric string. +func stripNumberGrouping(s string) string { + s = strings.TrimSpace(s) + s = strings.ReplaceAll(s, ",", "") + s = strings.ReplaceAll(s, "_", "") + return s +} + +// --- date normalisation --- + +// Supported input date formats, tried in order. +var dateFormats = []string{ + time.RFC3339, // 2006-01-02T15:04:05Z07:00 + "2006-01-02T15:04:05", // ISO without timezone + "2006-01-02 15:04:05", // space-separated datetime + "2006-01-02", // date only + "2006/01/02", // slash-separated + "02-01-2006", // DD-MM-YYYY + "01/02/2006", // US format MM/DD/YYYY + "Jan 2, 2006", // English short month + "January 2, 2006", // English full month + "2 Jan 2006", // Day-first English + "20060102", // Compact YYYYMMDD + "20060102T150405", // Compact with time + time.RFC1123, // Mon, 02 Jan 2006 15:04:05 MST + time.RFC1123Z, // Mon, 02 Jan 2006 15:04:05 -0700 +} + +func isDateFormat(format string) bool { + switch format { + case "date", "date-time", "datetime": + return true + } + return false +} + +func normaliseDate(value any, schemaFormat string) (any, bool) { + str, ok := value.(string) + if !ok { + return value, false + } + str = strings.TrimSpace(str) + if str == "" { + return value, false + } + + // Try parsing as millisecond timestamp. + if ms, err := strconv.ParseInt(str, 10, 64); err == nil && len(str) >= 10 { + t := time.UnixMilli(ms).UTC() + formatted := formatDate(t, schemaFormat) + if formatted != str { + return formatted, true + } + return value, false + } + + // Try parsing as second timestamp. + if sec, err := strconv.ParseInt(str, 10, 64); err == nil && len(str) >= 10 { + t := time.Unix(sec, 0).UTC() + formatted := formatDate(t, schemaFormat) + if formatted != str { + return formatted, true + } + return value, false + } + + // Try known date formats. + for _, layout := range dateFormats { + t, err := time.Parse(layout, str) + if err == nil { + formatted := formatDate(t, schemaFormat) + if formatted != str { + return formatted, true + } + return value, false + } + } + + return value, false +} + +// formatDate produces the output format based on the schema format +// field. "date" produces YYYY-MM-DD, everything else produces +// RFC 3339. +func formatDate(t time.Time, schemaFormat string) string { + if schemaFormat == "date" { + return t.Format("2006-01-02") + } + return t.Format(time.RFC3339) +} + +// --- enum normalisation --- + +func schemaEnum(schema map[string]any) ([]any, bool) { + raw, ok := schema["enum"].([]any) + if !ok || len(raw) == 0 { + return nil, false + } + return raw, true +} + +// normaliseEnum performs case-insensitive matching of a value against +// the allowed enum values. If exactly one enum entry matches +// (case-insensitive), the value is rewritten to match the canonical +// case from the schema. +func normaliseEnum(value any, allowed []any) (any, bool) { + str, ok := value.(string) + if !ok { + return value, false + } + lower := strings.ToLower(strings.TrimSpace(str)) + + var match any + matches := 0 + for _, candidate := range allowed { + candidateStr, ok := candidate.(string) + if !ok { + continue + } + if strings.ToLower(candidateStr) == lower { + match = candidate + matches++ + } + } + + if matches == 1 && match != value { + return match, true + } + return value, false +} + +// --- helpers --- + +func schemaProperties(schema map[string]any) (map[string]any, bool) { + properties, ok := schema["properties"].(map[string]any) + return properties, ok +} + +func classifyCorrection(schema map[string]any) string { + if _, ok := schemaEnum(schema); ok { + return "enum" + } + schemaType, _ := schema["type"].(string) + schemaFormat, _ := schema["format"].(string) + if schemaType == "boolean" { + return "boolean" + } + if schemaType == "integer" || schemaType == "number" { + return "number" + } + if isDateFormat(schemaFormat) { + return "date" + } + return "value" +} diff --git a/internal/pipeline/handlers/paramvalue_test.go b/internal/pipeline/handlers/paramvalue_test.go new file mode 100644 index 00000000..4894fbe4 --- /dev/null +++ b/internal/pipeline/handlers/paramvalue_test.go @@ -0,0 +1,352 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handlers + +import ( + "fmt" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" +) + +func makeSchema(properties map[string]any) map[string]any { + return map[string]any{"properties": properties} +} + +func TestParamValueHandlerBoolean(t *testing.T) { + tests := []struct { + input any + want any + }{ + {"yes", true}, + {"Yes", true}, + {"YES", true}, + {"no", false}, + {"No", false}, + {"on", true}, + {"off", false}, + {"1", true}, + {"0", false}, + {"True", true}, + {"FALSE", false}, + {"true", true}, // already correct string form + {"false", false}, // already correct string form + {true, true}, // already bool — no change + {false, false}, // already bool — no change + {"maybe", "maybe"}, // not a boolean — unchanged + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%v", tt.input), func(t *testing.T) { + ctx := &pipeline.Context{ + Params: map[string]any{"flag": tt.input}, + Schema: makeSchema(map[string]any{ + "flag": map[string]any{"type": "boolean"}, + }), + } + h := ParamValueHandler{} + if err := h.Handle(ctx); err != nil { + t.Fatalf("Handle error: %v", err) + } + got := ctx.Params["flag"] + if got != tt.want { + t.Errorf("got %v (%T), want %v (%T)", got, got, tt.want, tt.want) + } + }) + } +} + +func TestParamValueHandlerInteger(t *testing.T) { + tests := []struct { + input any + want any + }{ + {"1,000", int64(1000)}, + {"1_000", int64(1000)}, + {"1,000,000", int64(1000000)}, + {"42", "42"}, // no grouping — unchanged + {42, 42}, // already int — unchanged + {"abc", "abc"}, // not a number — unchanged + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%v", tt.input), func(t *testing.T) { + ctx := &pipeline.Context{ + Params: map[string]any{"count": tt.input}, + Schema: makeSchema(map[string]any{ + "count": map[string]any{"type": "integer"}, + }), + } + h := ParamValueHandler{} + if err := h.Handle(ctx); err != nil { + t.Fatalf("Handle error: %v", err) + } + got := ctx.Params["count"] + if got != tt.want { + t.Errorf("got %v (%T), want %v (%T)", got, got, tt.want, tt.want) + } + }) + } +} + +func TestParamValueHandlerNumber(t *testing.T) { + tests := []struct { + input any + want any + }{ + {"1,234.56", 1234.56}, + {"1_000.5", 1000.5}, + {"3.14", "3.14"}, // no grouping — unchanged + {3.14, 3.14}, // already float — unchanged + } + + for _, tt := range tests { + t.Run(fmt.Sprintf("%v", tt.input), func(t *testing.T) { + ctx := &pipeline.Context{ + Params: map[string]any{"amount": tt.input}, + Schema: makeSchema(map[string]any{ + "amount": map[string]any{"type": "number"}, + }), + } + h := ParamValueHandler{} + if err := h.Handle(ctx); err != nil { + t.Fatalf("Handle error: %v", err) + } + got := ctx.Params["amount"] + if got != tt.want { + t.Errorf("got %v (%T), want %v (%T)", got, got, tt.want, tt.want) + } + }) + } +} + +func TestParamValueHandlerDate(t *testing.T) { + tests := []struct { + name string + input string + format string + want string + }{ + { + name: "slash date → ISO", + input: "2024/03/29", + format: "date", + want: "2024-03-29", + }, + { + name: "already ISO date — no change", + input: "2024-03-29", + format: "date", + want: "2024-03-29", + }, + { + name: "space datetime → RFC3339", + input: "2024-03-29 14:30:00", + format: "date-time", + want: "2024-03-29T14:30:00Z", + }, + { + name: "compact YYYYMMDD → ISO date", + input: "20240329", + format: "date", + want: "2024-03-29", + }, + { + name: "English short month → RFC3339", + input: "Mar 29, 2024", + format: "date-time", + want: "2024-03-29T00:00:00Z", + }, + { + name: "English full month → date", + input: "January 15, 2024", + format: "date", + want: "2024-01-15", + }, + { + name: "millisecond timestamp → RFC3339", + input: "1711699200000", + format: "date-time", + want: "2024-03-29T08:00:00Z", + }, + { + name: "already RFC3339 — no change", + input: "2024-03-29T14:30:00Z", + format: "date-time", + want: "2024-03-29T14:30:00Z", + }, + { + name: "ISO without timezone → RFC3339", + input: "2024-03-29T14:30:00", + format: "date-time", + want: "2024-03-29T14:30:00Z", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &pipeline.Context{ + Params: map[string]any{"date": tt.input}, + Schema: makeSchema(map[string]any{ + "date": map[string]any{"type": "string", "format": tt.format}, + }), + } + h := ParamValueHandler{} + if err := h.Handle(ctx); err != nil { + t.Fatalf("Handle error: %v", err) + } + got, ok := ctx.Params["date"].(string) + if !ok { + t.Fatalf("result not a string: %v (%T)", ctx.Params["date"], ctx.Params["date"]) + } + if got != tt.want { + t.Errorf("got %q, want %q", got, tt.want) + } + }) + } +} + +func TestParamValueHandlerEnum(t *testing.T) { + tests := []struct { + name string + input any + enum []any + want any + }{ + { + name: "uppercase → canonical", + input: "ACTIVE", + enum: []any{"active", "inactive"}, + want: "active", + }, + { + name: "mixed case → canonical", + input: "Active", + enum: []any{"active", "inactive"}, + want: "active", + }, + { + name: "already correct — no change", + input: "active", + enum: []any{"active", "inactive"}, + want: "active", + }, + { + name: "no match — unchanged", + input: "deleted", + enum: []any{"active", "inactive"}, + want: "deleted", + }, + { + name: "non-string value — unchanged", + input: 42, + enum: []any{"active"}, + want: 42, + }, + { + name: "canonical preserves original case", + input: "json", + enum: []any{"JSON", "XML", "CSV"}, + want: "JSON", + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &pipeline.Context{ + Params: map[string]any{"status": tt.input}, + Schema: makeSchema(map[string]any{ + "status": map[string]any{"type": "string", "enum": tt.enum}, + }), + } + h := ParamValueHandler{} + if err := h.Handle(ctx); err != nil { + t.Fatalf("Handle error: %v", err) + } + if got := ctx.Params["status"]; got != tt.want { + t.Errorf("got %v (%T), want %v (%T)", got, got, tt.want, tt.want) + } + }) + } +} + +func TestParamValueHandlerMultipleParams(t *testing.T) { + ctx := &pipeline.Context{ + Params: map[string]any{ + "verbose": "yes", + "count": "1,000", + "status": "ACTIVE", + "created_at": "2024/03/29", + }, + Schema: makeSchema(map[string]any{ + "verbose": map[string]any{"type": "boolean"}, + "count": map[string]any{"type": "integer"}, + "status": map[string]any{"type": "string", "enum": []any{"active", "inactive"}}, + "created_at": map[string]any{"type": "string", "format": "date"}, + }), + } + + h := ParamValueHandler{} + if err := h.Handle(ctx); err != nil { + t.Fatalf("Handle error: %v", err) + } + + if got := ctx.Params["verbose"]; got != true { + t.Errorf("verbose = %v, want true", got) + } + if got := ctx.Params["count"]; got != int64(1000) { + t.Errorf("count = %v, want 1000", got) + } + if got := ctx.Params["status"]; got != "active" { + t.Errorf("status = %q, want %q", got, "active") + } + if got := ctx.Params["created_at"]; got != "2024-03-29" { + t.Errorf("created_at = %q, want %q", got, "2024-03-29") + } + if len(ctx.Corrections) != 4 { + t.Errorf("corrections = %d, want 4", len(ctx.Corrections)) + } +} + +func TestParamValueHandlerEmptyInputs(t *testing.T) { + h := ParamValueHandler{} + + // nil params + ctx := &pipeline.Context{Params: nil, Schema: makeSchema(map[string]any{})} + if err := h.Handle(ctx); err != nil { + t.Errorf("nil params: %v", err) + } + + // nil schema + ctx = &pipeline.Context{Params: map[string]any{"a": "b"}, Schema: nil} + if err := h.Handle(ctx); err != nil { + t.Errorf("nil schema: %v", err) + } + + // schema without properties + ctx = &pipeline.Context{Params: map[string]any{"a": "b"}, Schema: map[string]any{}} + if err := h.Handle(ctx); err != nil { + t.Errorf("no properties: %v", err) + } +} + +func TestParamValueHandlerNameAndPhase(t *testing.T) { + h := ParamValueHandler{} + if h.Name() != "paramvalue" { + t.Errorf("Name() = %q, want %q", h.Name(), "paramvalue") + } + if h.Phase() != pipeline.PostParse { + t.Errorf("Phase() = %v, want PostParse", h.Phase()) + } +} diff --git a/internal/pipeline/handlers/sticky.go b/internal/pipeline/handlers/sticky.go new file mode 100644 index 00000000..af99aed9 --- /dev/null +++ b/internal/pipeline/handlers/sticky.go @@ -0,0 +1,139 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handlers + +import ( + "strings" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" +) + +// StickyHandler detects glued flag-value pairs in raw argv and splits +// them into separate tokens. For example, "--limit100" becomes +// "--limit", "100" when "limit" is a known flag name. +// +// The handler only operates on tokens that start with "--" and do not +// contain "=". It tries to match the longest known flag name prefix +// and, if the remaining suffix is non-empty, splits the token. +type StickyHandler struct{} + +func (StickyHandler) Name() string { return "sticky" } +func (StickyHandler) Phase() pipeline.Phase { return pipeline.PreParse } + +func (StickyHandler) Handle(ctx *pipeline.Context) error { + if len(ctx.Args) == 0 || len(ctx.FlagSpecs) == 0 { + return nil + } + + known := buildFlagSet(ctx.FlagSpecs) + result := make([]string, 0, len(ctx.Args)) + + for _, arg := range ctx.Args { + split, ok := trySplitSticky(arg, known) + if ok { + ctx.AddCorrection("sticky", pipeline.PreParse, split.flag, arg, split.flag+" "+split.value, "sticky") + result = append(result, split.flag, split.value) + } else { + result = append(result, arg) + } + } + + ctx.Args = result + return nil +} + +type stickyPair struct { + flag string + value string +} + +// trySplitSticky checks if arg looks like a glued flag-value (e.g. +// "--limit100") and returns the split result. It requires: +// - arg starts with "--" +// - arg does not contain "=" (that is a valid Cobra syntax) +// - the prefix matches a known flag name (directly or after +// kebab-case normalisation) +// - the remaining suffix is non-empty +// +// When multiple flag names match as prefixes, the longest one wins. +// The handler also tries kebab-case normalisation of each prefix so +// that camelCase+glued values like "--pageSize50" are correctly +// split to "--page-size", "50". +func trySplitSticky(arg string, known map[string]bool) (stickyPair, bool) { + if !strings.HasPrefix(arg, "--") || strings.Contains(arg, "=") { + return stickyPair{}, false + } + + // Strip "--" prefix to work with the bare token. + bare := arg[2:] + if bare == "" { + return stickyPair{}, false + } + + // If the whole token is a known flag, it is not sticky — it is + // a normal flag expecting a separate value token. + if known[bare] || known[toKebabCase(bare)] { + return stickyPair{}, false + } + + // Try longest-prefix match: walk from len-1 down to 1, looking + // for the longest known flag that is a prefix of bare. For each + // candidate prefix, try both the raw form and kebab-case form. + bestLen := 0 + bestFlag := "" + for i := len(bare) - 1; i >= 1; i-- { + prefix := bare[:i] + + matchedFlag := "" + if known[prefix] { + matchedFlag = prefix + } else { + kebab := toKebabCase(prefix) + if kebab != "" && known[kebab] { + matchedFlag = kebab + } + } + + if matchedFlag != "" && i > bestLen { + bestLen = i + bestFlag = matchedFlag + break // longest first since we walk from the end + } + } + if bestFlag == "" { + return stickyPair{}, false + } + + suffix := bare[bestLen:] + if suffix == "" { + return stickyPair{}, false + } + + return stickyPair{ + flag: "--" + bestFlag, + value: suffix, + }, true +} + +// buildFlagSet creates a set of known flag names (without "--" prefix) +// from the context's FlagSpecs. +func buildFlagSet(specs []pipeline.FlagInfo) map[string]bool { + m := make(map[string]bool, len(specs)) + for _, spec := range specs { + if spec.Name != "" { + m[spec.Name] = true + } + } + return m +} diff --git a/internal/pipeline/handlers/sticky_test.go b/internal/pipeline/handlers/sticky_test.go new file mode 100644 index 00000000..434c356f --- /dev/null +++ b/internal/pipeline/handlers/sticky_test.go @@ -0,0 +1,183 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package handlers + +import ( + "strings" + "testing" + + "github.com/DingTalk-Real-AI/dingtalk-workspace-cli/internal/pipeline" +) + +func flagSpecs(names ...string) []pipeline.FlagInfo { + specs := make([]pipeline.FlagInfo, len(names)) + for i, name := range names { + specs[i] = pipeline.FlagInfo{Name: name, Type: "string"} + } + return specs +} + +func TestStickyHandler(t *testing.T) { + tests := []struct { + name string + args []string + flags []string + want string + corrections int + }{ + { + name: "basic split --limit100", + args: []string{"--limit100"}, + flags: []string{"limit"}, + want: "--limit 100", + corrections: 1, + }, + { + name: "no split when flag takes value separately", + args: []string{"--limit", "100"}, + flags: []string{"limit"}, + want: "--limit 100", + corrections: 0, + }, + { + name: "no split when = syntax used", + args: []string{"--limit=100"}, + flags: []string{"limit"}, + want: "--limit=100", + corrections: 0, + }, + { + name: "no split when flag name is not known", + args: []string{"--unknown100"}, + flags: []string{"limit"}, + want: "--unknown100", + corrections: 0, + }, + { + name: "split with string value", + args: []string{"--nameJohn"}, + flags: []string{"name"}, + want: "--name John", + corrections: 1, + }, + { + name: "longest prefix wins", + args: []string{"--user-id123"}, + flags: []string{"user", "user-id"}, + want: "--user-id 123", + corrections: 1, + }, + { + name: "multiple sticky args in one invocation", + args: []string{"--limit100", "--offset50"}, + flags: []string{"limit", "offset"}, + want: "--limit 100 --offset 50", + corrections: 2, + }, + { + name: "mixed sticky and normal args", + args: []string{"--limit100", "--name", "test", "--offset50"}, + flags: []string{"limit", "name", "offset"}, + want: "--limit 100 --name test --offset 50", + corrections: 2, + }, + { + name: "single dash prefix is ignored", + args: []string{"-l100"}, + flags: []string{"l"}, + want: "-l100", + corrections: 0, + }, + { + name: "empty args", + args: []string{}, + flags: []string{"limit"}, + want: "", + corrections: 0, + }, + { + name: "bare double dash", + args: []string{"--"}, + flags: []string{"limit"}, + want: "--", + corrections: 0, + }, + { + name: "no flag specs available", + args: []string{"--limit100"}, + flags: []string{}, + want: "--limit100", + corrections: 0, + }, + { + name: "exact flag name is not split", + args: []string{"--limit"}, + flags: []string{"limit"}, + want: "--limit", + corrections: 0, + }, + { + name: "boolean-like value still splits", + args: []string{"--verbosetrue"}, + flags: []string{"verbose"}, + want: "--verbose true", + corrections: 1, + }, + { + name: "hyphenated flag name with numeric suffix", + args: []string{"--page-size50"}, + flags: []string{"page-size", "page"}, + want: "--page-size 50", + corrections: 1, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + ctx := &pipeline.Context{ + Args: append([]string{}, tt.args...), + FlagSpecs: flagSpecs(tt.flags...), + } + h := StickyHandler{} + if err := h.Handle(ctx); err != nil { + t.Fatalf("Handle returned error: %v", err) + } + got := strings.Join(ctx.Args, " ") + if got != tt.want { + t.Errorf("Args = %q, want %q", got, tt.want) + } + if len(ctx.Corrections) != tt.corrections { + t.Errorf("Corrections count = %d, want %d", len(ctx.Corrections), tt.corrections) + } + for _, c := range ctx.Corrections { + if c.Kind != "sticky" { + t.Errorf("correction kind = %q, want %q", c.Kind, "sticky") + } + if c.Handler != "sticky" { + t.Errorf("correction handler = %q, want %q", c.Handler, "sticky") + } + } + }) + } +} + +func TestStickyHandlerNameAndPhase(t *testing.T) { + h := StickyHandler{} + if h.Name() != "sticky" { + t.Errorf("Name() = %q, want %q", h.Name(), "sticky") + } + if h.Phase() != pipeline.PreParse { + t.Errorf("Phase() = %v, want PreParse", h.Phase()) + } +} diff --git a/internal/pipeline/phase.go b/internal/pipeline/phase.go new file mode 100644 index 00000000..92acb766 --- /dev/null +++ b/internal/pipeline/phase.go @@ -0,0 +1,158 @@ +// Copyright 2026 Alibaba Group +// Licensed under the Apache License, Version 2.0 (the "License"); +// you may not use this file except in compliance with the License. +// You may obtain a copy of the License at +// +// http://www.apache.org/licenses/LICENSE-2.0 +// +// Unless required by applicable law or agreed to in writing, software +// distributed under the License is distributed on an "AS IS" BASIS, +// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +// See the License for the specific language governing permissions and +// limitations under the License. + +package pipeline + +// Phase represents a named stage in the CLI execution pipeline. +// Handlers are grouped by phase and executed in chain order within +// each phase. Phases themselves execute in a fixed order defined +// by the engine. +type Phase int + +const ( + // Register runs at CLI startup when the Cobra command tree is + // being built. Handlers can add, remove, or modify commands. + Register Phase = iota + + // PreParse runs before Cobra parses the raw argv. Handlers + // receive the raw argument slice and can rewrite it — for + // example to fix flag-name typos or split glued values. + PreParse + + // PostParse runs after Cobra has successfully parsed flags and + // args. Handlers receive structured parameters plus the tool + // schema, enabling value-level corrections such as date format + // normalisation. + PostParse + + // PreRequest runs after validation, just before the JSON-RPC + // call is dispatched. Handlers can inspect or mutate the final + // payload. + PreRequest + + // PostResponse runs after the transport returns a result and + // before the output is written to stdout. + PostResponse +) + +// String returns the human-readable name of the phase. +func (p Phase) String() string { + switch p { + case Register: + return "register" + case PreParse: + return "pre-parse" + case PostParse: + return "post-parse" + case PreRequest: + return "pre-request" + case PostResponse: + return "post-response" + default: + return "unknown" + } +} + +// phases returns all phases in execution order. +func phases() []Phase { + return []Phase{Register, PreParse, PostParse, PreRequest, PostResponse} +} + +// Context carries mutable state through the handler chain. Each phase +// populates additional fields; earlier-phase fields remain available in +// later phases so that handlers can correlate raw input with structured +// parameters. +type Context struct { + // Args is the raw argv slice (available from PreParse onward). + // PreParse handlers may rewrite this in place. + Args []string + + // Command is the resolved product.tool canonical path + // (available from PostParse onward). + Command string + + // Params holds structured key→value parameters after Cobra + // parsing (available from PostParse onward). Handlers may + // mutate values or add/remove keys. + Params map[string]any + + // Schema is the JSON Schema for the resolved tool's input + // (available from PostParse onward). Handlers must treat this + // as read-only. + Schema map[string]any + + // Payload is the merged, validated payload ready to be sent + // over the wire (available from PreRequest onward). + Payload map[string]any + + // Response is the JSON-RPC result returned by the server + // (available from PostResponse onward). Handlers may mutate + // the response before it is written to stdout. + Response map[string]any + + // FlagSpecs provides the list of known flag names for the + // current tool, derived from the input schema. PreParse + // handlers use this to match against raw argv tokens. + FlagSpecs []FlagInfo + + // Corrections records every correction applied by handlers, + // enabling downstream logging and debugging. + Corrections []Correction +} + +// FlagInfo describes a single CLI flag derived from a tool's input +// schema. PreParse handlers use this to recognise valid flag names +// when performing fuzzy matching or alias resolution. +type FlagInfo struct { + // Name is the canonical kebab-case flag name (e.g. "user-id"). + Name string + + // PropertyName is the original schema property key (e.g. "userId"). + PropertyName string + + // Type is the JSON Schema type ("string", "integer", etc.). + Type string +} + +// Correction records a single input correction applied by a handler. +type Correction struct { + // Handler is the name of the handler that applied the correction. + Handler string + + // Phase is the pipeline phase in which the correction occurred. + Phase Phase + + // Field identifies the affected flag or parameter name. + Field string + + // Original is the value before correction. + Original string + + // Corrected is the value after correction. + Corrected string + + // Kind classifies the correction (e.g. "alias", "sticky", "case"). + Kind string +} + +// AddCorrection appends a correction record to the context. +func (c *Context) AddCorrection(handler string, phase Phase, field, original, corrected, kind string) { + c.Corrections = append(c.Corrections, Correction{ + Handler: handler, + Phase: phase, + Field: field, + Original: original, + Corrected: corrected, + Kind: kind, + }) +}