From 9ec6588f6b38766ee4a016a37823e4085ebb98e2 Mon Sep 17 00:00:00 2001 From: booleanmaybe Date: Tue, 14 Apr 2026 18:13:50 -0400 Subject: [PATCH] ruki pipes --- .doc/doki/doc/ideas/plugins.md | 14 + .doc/doki/doc/ruki/examples.md | 7 + .doc/doki/doc/ruki/operators-and-builtins.md | 32 +- .doc/doki/doc/ruki/quick-start.md | 8 + .doc/doki/doc/ruki/semantics.md | 36 ++ .doc/doki/doc/ruki/syntax.md | 3 +- .doc/doki/doc/ruki/triggers.md | 2 + .doc/doki/doc/ruki/validation-and-errors.md | 43 +++ controller/plugin.go | 12 +- plugin/parser.go | 4 +- plugin/parser_test.go | 21 +- ruki/ast.go | 3 +- ruki/executor.go | 48 +++ ruki/grammar.go | 1 + ruki/lexer.go | 1 + ruki/lower.go | 7 + ruki/pipe_test.go | 369 +++++++++++++++++++ ruki/semantic_validate.go | 22 +- ruki/validate.go | 50 ++- service/shell.go | 40 ++ service/shell_test.go | 62 ++++ service/trigger_engine.go | 12 +- 22 files changed, 771 insertions(+), 26 deletions(-) create mode 100644 ruki/pipe_test.go create mode 100644 service/shell.go create mode 100644 service/shell_test.go diff --git a/.doc/doki/doc/ideas/plugins.md b/.doc/doki/doc/ideas/plugins.md index 3613e1a..9521c8c 100644 --- a/.doc/doki/doc/ideas/plugins.md +++ b/.doc/doki/doc/ideas/plugins.md @@ -3,6 +3,7 @@ - [Assign to me](#assign-to-me--plugin-action) - [Add tag to task](#add-tag-to-task--plugin-action) - [Custom status + reject action](#custom-status--reject-action) +- [Implement with Claude](#implement-with-claude--pipe-action) - [Search all tikis](#search-all-tikis--single-lane-plugin) - [Quick assign](#quick-assign--lane-based-assignment) - [Stale task detection](#stale-task-detection--time-trigger--plugin) @@ -58,6 +59,19 @@ statuses: action: update where id = id() set status="rejected" ``` +## Implement with Claude Code — pipe action + +Shortcut key that pipes the selected task's title and description to Claude Code for implementation. + +```yaml +actions: + - key: "i" + label: "Implement" + action: > + select title, description where id = id() + | run("claude -p 'Implement this: $1. Details: $2'") +``` + ## Search all tikis — single-lane plugin A plugin with one unfiltered lane shows every task. Press `/` to search across all of them. diff --git a/.doc/doki/doc/ruki/examples.md b/.doc/doki/doc/ruki/examples.md index 1e6e1f7..7c55916 100644 --- a/.doc/doki/doc/ruki/examples.md +++ b/.doc/doki/doc/ruki/examples.md @@ -136,6 +136,13 @@ Use `call(...)` in a value: create title=call("echo hi") ``` +Pipe select results to a shell command: + +```sql +select id where id = id() | run("echo $1 | pbcopy") +select id, title where status = "done" | run("myscript $1 $2") +``` + ## Before triggers Block completion when dependencies remain open: diff --git a/.doc/doki/doc/ruki/operators-and-builtins.md b/.doc/doki/doc/ruki/operators-and-builtins.md index c8cb1b0..fafb0c6 100644 --- a/.doc/doki/doc/ruki/operators-and-builtins.md +++ b/.doc/doki/doc/ruki/operators-and-builtins.md @@ -246,10 +246,32 @@ after update where new.status = "in progress" run("echo hello") ## Shell-related forms -`ruki` includes two shell-related forms: +`ruki` includes three shell-related forms: -- `call(...)` as a string-returning expression -- `run(...)` as an `after`-trigger action +**`call(...)`** — a string-returning expression -- `call(...)` returns a string -- `run(...)` is used as the top-level action of an `after` trigger +- Returns a string result from a shell command +- Can be used in any expression context + +**`run(...)` in triggers** — an `after`-trigger action + +- Used as the top-level action of an `after` trigger +- Command string may reference `old.` and `new.` fields +- Example: `after update where new.status = "in progress" run("echo hello")` + +**`| run(...)` on select** — a pipe suffix + +- Executes a command for each row returned by `select` +- Uses positional arguments `$1`, `$2`, etc. for field substitution +- Command must be a string literal or expression, but **field references are not allowed in the command** itself +- `|` is a statement suffix, not an operator +- Example: `select id, title where status = "done" | run("myscript $1 $2")` + +Pipe `| run(...)` is distinct from trigger `run()`: + +| Form | Context | Field access | Substitution | +|---|---|---|---| +| trigger `run()` | after-trigger action | `old.`, `new.` allowed | expression evaluation | +| pipe `| run()` | select suffix | field refs disallowed in command | positional args `$1`, `$2` | + +See [Semantics](semantics.md#pipe-actions-on-select) for pipe evaluation model. diff --git a/.doc/doki/doc/ruki/quick-start.md b/.doc/doki/doc/ruki/quick-start.md index c7daef6..c7afd79 100644 --- a/.doc/doki/doc/ruki/quick-start.md +++ b/.doc/doki/doc/ruki/quick-start.md @@ -63,6 +63,14 @@ delete where id = "TIKI-ABC123" delete where status = "cancelled" and "old" in tags ``` +`select` may pipe results to a shell command: + +```sql +select id, title where status = "done" | run("myscript $1 $2") +``` + +Each row executes the command with field values as positional arguments (`$1`, `$2`). + ## Conditions and expressions Conditions support: diff --git a/.doc/doki/doc/ruki/semantics.md b/.doc/doki/doc/ruki/semantics.md index b4847a5..4190e2b 100644 --- a/.doc/doki/doc/ruki/semantics.md +++ b/.doc/doki/doc/ruki/semantics.md @@ -167,3 +167,39 @@ Binary `+` and `-` are semantic rather than purely numeric: For the detailed type rules and built-ins, see [Types And Values](types-and-values.md) and [Operators And Built-ins](operators-and-builtins.md). +## Pipe actions on select + +`select` statements may include an optional pipe suffix: + +```text +select where | run() +``` + +Evaluation model: + +- The `select` runs first, producing zero or more rows. +- For each row, the `run()` command is executed with positional arguments (`$1`, `$2`, etc.) substituted from the selected fields in left-to-right order. +- Each command execution has a **30-second timeout**. +- Command failures are **non-fatal** — remaining rows still execute. +- Stdout and stderr are **fire-and-forget** (not captured or returned). + +Rules: + +- Explicit field names are required — `select *` and bare `select` are rejected when used with a pipe. +- The command expression must be a string literal or string-typed expression, but **field references are not allowed** in the command string itself. +- Positional arguments `$1`, `$2`, etc. are substituted by the runtime before each command execution. + +Example: + +```sql +select id, title where status = "done" | run("myscript $1 $2") +``` + +For a task with `id = "TIKI-ABC123"` and `title = "Fix bug"`, the command becomes: + +```bash +myscript "TIKI-ABC123" "Fix bug" +``` + +Pipe `| run(...)` on select is distinct from trigger `run()` actions. See [Triggers](triggers.md) for the difference. + diff --git a/.doc/doki/doc/ruki/syntax.md b/.doc/doki/doc/ruki/syntax.md index c7d3e3c..1adfed2 100644 --- a/.doc/doki/doc/ruki/syntax.md +++ b/.doc/doki/doc/ruki/syntax.md @@ -48,8 +48,9 @@ The following EBNF-style summary shows the grammar: ```text statement = selectStmt | createStmt | updateStmt | deleteStmt ; -selectStmt = "select" [ fieldList | "*" ] [ "where" condition ] [ orderBy ] ; +selectStmt = "select" [ fieldList | "*" ] [ "where" condition ] [ orderBy ] [ pipeAction ] ; fieldList = identifier { "," identifier } ; +pipeAction = "|" runAction ; createStmt = "create" assignment { assignment } ; orderBy = "order" "by" sortField { "," sortField } ; diff --git a/.doc/doki/doc/ruki/triggers.md b/.doc/doki/doc/ruki/triggers.md index b33e428..de232f2 100644 --- a/.doc/doki/doc/ruki/triggers.md +++ b/.doc/doki/doc/ruki/triggers.md @@ -248,6 +248,8 @@ The maximum cascade depth is **8**. Termination is graceful — a warning is log ## The run() action +**Note:** This section describes trigger `run()` actions. For the pipe syntax `| run(...)` on select statements, see [Semantics](semantics.md#pipe-actions-on-select) and [Operators And Built-ins](operators-and-builtins.md#shell-related-forms). + When an after-trigger uses `run(...)`, the command expression is evaluated to a string, then executed: - The command runs via `sh -c `. diff --git a/.doc/doki/doc/ruki/validation-and-errors.md b/.doc/doki/doc/ruki/validation-and-errors.md index 4a78808..27f729c 100644 --- a/.doc/doki/doc/ruki/validation-and-errors.md +++ b/.doc/doki/doc/ruki/validation-and-errors.md @@ -216,6 +216,49 @@ Order by inside a subquery: select where count(select where status = "done" order by priority) >= 1 ``` +## Pipe validation errors + +Pipe actions on `select` have several restrictions: + +`select *` with pipe: + +```sql +select * where status = "done" | run("echo $1") +``` + +Bare `select` with pipe: + +```sql +select | run("echo $1") +``` + +Both are rejected because explicit field names are required when using a pipe. + +Non-string command: + +```sql +select id where status = "done" | run(42) +``` + +Field references in command: + +```sql +select id, title where status = "done" | run(title + " " + id) +select id, title where status = "done" | run("echo " + title) +``` + +Field references are not allowed in the pipe command expression itself. Use positional arguments (`$1`, `$2`) instead. + +Pipe on non-select statements: + +```sql +update where status = "done" set priority=1 | run("echo done") +create title="x" | run("echo created") +delete where id = "TIKI-ABC123" | run("echo deleted") +``` + +Pipe suffix is only valid on `select` statements. + ## Built-in and subquery errors Unknown function: diff --git a/controller/plugin.go b/controller/plugin.go index a8e2761..61e6054 100644 --- a/controller/plugin.go +++ b/controller/plugin.go @@ -159,7 +159,7 @@ func (pc *PluginController) handlePluginAction(r rune) bool { input := ruki.ExecutionInput{} taskID := pc.getSelectedTaskID(pc.GetFilteredTasksForLane) - if pa.Action.IsUpdate() || pa.Action.IsDelete() { + if pa.Action.IsUpdate() || pa.Action.IsDelete() || pa.Action.IsPipe() { if taskID == "" { return false } @@ -212,6 +212,16 @@ func (pc *PluginController) handlePluginAction(r rune) bool { return false } } + case result.Pipe != nil: + for _, row := range result.Pipe.Rows { + if err := service.ExecutePipeCommand(ctx, result.Pipe.Command, row); err != nil { + slog.Error("pipe command failed", "command", result.Pipe.Command, "args", row, "key", string(r), "error", err) + if pc.statusline != nil { + pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true) + } + // non-fatal: continue remaining rows + } + } } slog.Info("plugin action applied", "task_id", taskID, "key", string(r), "label", pa.Label, "plugin", pc.pluginDef.Name) diff --git a/plugin/parser.go b/plugin/parser.go index f9e7e0d..bdaa43c 100644 --- a/plugin/parser.go +++ b/plugin/parser.go @@ -210,8 +210,8 @@ func parsePluginActions(configs []PluginActionConfig, parser *ruki.Parser) ([]Pl if err != nil { return nil, fmt.Errorf("parsing action %d (key %q): %w", i, cfg.Key, err) } - if actionStmt.IsSelect() { - return nil, fmt.Errorf("action %d (key %q) must be UPDATE, CREATE, or DELETE — not SELECT", i, cfg.Key) + if actionStmt.IsSelect() && !actionStmt.IsPipe() { + return nil, fmt.Errorf("action %d (key %q) must be UPDATE, CREATE, DELETE, or a piped SELECT — not plain SELECT", i, cfg.Key) } actions = append(actions, PluginAction{ diff --git a/plugin/parser_test.go b/plugin/parser_test.go index 9d0dba7..25e202e 100644 --- a/plugin/parser_test.go +++ b/plugin/parser_test.go @@ -504,8 +504,25 @@ func TestParsePluginActions_SelectRejectedAsAction(t *testing.T) { if err == nil { t.Fatal("expected error for SELECT as plugin action") } - if !strings.Contains(err.Error(), "not SELECT") { - t.Errorf("expected 'not SELECT' error, got: %v", err) + if !strings.Contains(err.Error(), "not plain SELECT") { + t.Errorf("expected 'not plain SELECT' error, got: %v", err) + } +} + +func TestParsePluginActions_PipeAcceptedAsAction(t *testing.T) { + parser := testParser() + configs := []PluginActionConfig{ + {Key: "c", Label: "Copy ID", Action: `select id where id = id() | run("echo $1")`}, + } + actions, err := parsePluginActions(configs, parser) + if err != nil { + t.Fatalf("expected pipe action to be accepted, got error: %v", err) + } + if len(actions) != 1 { + t.Fatalf("expected 1 action, got %d", len(actions)) + } + if !actions[0].Action.IsPipe() { + t.Error("expected IsPipe() = true for pipe action") } } diff --git a/ruki/ast.go b/ruki/ast.go index d5f1af7..394b30f 100644 --- a/ruki/ast.go +++ b/ruki/ast.go @@ -13,11 +13,12 @@ type Statement struct { Delete *DeleteStmt } -// SelectStmt represents "select [fields] [where ] [order by [asc|desc], ...]". +// SelectStmt represents "select [fields] [where ] [order by [asc|desc], ...] [| run(...)]". type SelectStmt struct { Fields []string // nil = all ("select" or "select *"); non-nil = specific fields Where Condition // nil = select all OrderBy []OrderByClause // nil = unordered + Pipe *RunAction // optional pipe suffix: "| run(...)" } // CreateStmt represents "create =...". diff --git a/ruki/executor.go b/ruki/executor.go index d8a49fd..a808847 100644 --- a/ruki/executor.go +++ b/ruki/executor.go @@ -38,6 +38,14 @@ type Result struct { Update *UpdateResult Create *CreateResult Delete *DeleteResult + Pipe *PipeResult +} + +// PipeResult holds the shell command and per-row positional args from a piped select. +// The ruki executor builds this; the service layer performs the actual shell execution. +type PipeResult struct { + Command string + Rows [][]string } // UpdateResult holds the cloned, mutated tasks produced by an UPDATE statement. @@ -138,6 +146,10 @@ func (e *Executor) executeSelect(sel *SelectStmt, tasks []*task.Task) (*Result, e.sortTasks(filtered, sel.OrderBy) } + if sel.Pipe != nil { + return e.buildPipeResult(sel.Pipe, sel.Fields, filtered, tasks) + } + return &Result{ Select: &TaskProjection{ Tasks: filtered, @@ -146,6 +158,42 @@ func (e *Executor) executeSelect(sel *SelectStmt, tasks []*task.Task) (*Result, }, nil } +func (e *Executor) buildPipeResult(pipe *RunAction, fields []string, matched []*task.Task, allTasks []*task.Task) (*Result, error) { + // evaluate command once with a nil-sentinel task — validation ensures no field refs + cmdVal, err := e.evalExpr(pipe.Command, nil, allTasks) + if err != nil { + return nil, fmt.Errorf("pipe command: %w", err) + } + cmdStr, ok := cmdVal.(string) + if !ok { + return nil, fmt.Errorf("pipe command must evaluate to string, got %T", cmdVal) + } + + rows := make([][]string, len(matched)) + for i, t := range matched { + row := make([]string, len(fields)) + for j, f := range fields { + row[j] = pipeArgString(extractField(t, f)) + } + rows[i] = row + } + + return &Result{Pipe: &PipeResult{Command: cmdStr, Rows: rows}}, nil +} + +// pipeArgString space-joins list fields (tags, dependsOn) instead of using Go's +// default fmt.Sprint which produces "[a b c]" with brackets. +func pipeArgString(val interface{}) string { + if list, ok := val.([]interface{}); ok { + parts := make([]string, len(list)) + for i, elem := range list { + parts[i] = normalizeToString(elem) + } + return strings.Join(parts, " ") + } + return normalizeToString(val) +} + func (e *Executor) executeUpdate(upd *UpdateStmt, tasks []*task.Task) (*Result, error) { matched, err := e.filterTasks(upd.Where, tasks) if err != nil { diff --git a/ruki/grammar.go b/ruki/grammar.go index 6008289..6c98e26 100644 --- a/ruki/grammar.go +++ b/ruki/grammar.go @@ -23,6 +23,7 @@ type selectGrammar struct { Fields *fieldNamesGrammar `parser:" | @@ )?"` Where *orCond `parser:"( 'where' @@ )?"` OrderBy *orderByGrammar `parser:"@@?"` + Pipe *runGrammar `parser:"( Pipe @@ )?"` } // --- order by grammar --- diff --git a/ruki/lexer.go b/ruki/lexer.go index 8d2698a..2b7a0c8 100644 --- a/ruki/lexer.go +++ b/ruki/lexer.go @@ -25,6 +25,7 @@ var rukiLexer = lexer.MustSimple([]lexer.SimpleRule{ {Name: "LBracket", Pattern: `\[`}, {Name: "RBracket", Pattern: `\]`}, {Name: "Comma", Pattern: `,`}, + {Name: "Pipe", Pattern: `\|`}, {Name: "Keyword", Pattern: keywordPattern()}, {Name: "Ident", Pattern: `[a-zA-Z_][a-zA-Z0-9_]*`}, }) diff --git a/ruki/lower.go b/ruki/lower.go index bf0aec5..385c5ba 100644 --- a/ruki/lower.go +++ b/ruki/lower.go @@ -17,6 +17,13 @@ func lowerStatement(g *statementGrammar) (*Statement, error) { if err != nil { return nil, err } + if g.Select.Pipe != nil { + cmd, err := lowerExpr(&g.Select.Pipe.Command) + if err != nil { + return nil, err + } + s.Pipe = &RunAction{Command: cmd} + } return &Statement{Select: s}, nil case g.Create != nil: s, err := lowerCreate(g.Create) diff --git a/ruki/pipe_test.go b/ruki/pipe_test.go new file mode 100644 index 0000000..beecf36 --- /dev/null +++ b/ruki/pipe_test.go @@ -0,0 +1,369 @@ +package ruki + +import ( + "strings" + "testing" + + "github.com/boolean-maybe/tiki/task" +) + +// --- lexer --- + +func TestTokenizePipe(t *testing.T) { + pipeType := rukiLexer.Symbols()["Pipe"] + + t.Run("bare pipe", func(t *testing.T) { + tokens := tokenize(t, "|") + if len(tokens) != 1 { + t.Fatalf("expected 1 token, got %d: %v", len(tokens), tokens) + } + if tokens[0].Type != pipeType { + t.Errorf("expected Pipe token, got type %d", tokens[0].Type) + } + }) + + t.Run("pipe in context", func(t *testing.T) { + tokens := tokenize(t, `select id | run("echo $1")`) + found := false + for _, tok := range tokens { + if tok.Type == pipeType { + found = true + break + } + } + if !found { + t.Error("expected to find Pipe token in tokenized output") + } + }) +} + +// --- parser --- + +func TestParsePipeSelect(t *testing.T) { + p := newTestParser() + + tests := []struct { + name string + input string + }{ + {"basic pipe", `select id where id = id() | run("echo $1")`}, + {"multi-field pipe", `select id, title where status = "done" | run("myscript $1 $2")`}, + {"pipe without where", `select id | run("echo $1")`}, + {"pipe with order by", `select id, priority where status = "ready" order by priority | run("echo $1 $2")`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stmt, err := p.ParseAndValidateStatement(tt.input, ExecutorRuntimePlugin) + if err != nil { + t.Fatalf("parse error: %v", err) + } + if !stmt.IsSelect() { + t.Fatal("expected IsSelect() true") + } + if !stmt.IsPipe() { + t.Fatal("expected IsPipe() true") + } + }) + } +} + +func TestParsePipeDoesNotBreakPlainSelect(t *testing.T) { + p := newTestParser() + + tests := []struct { + name string + input string + }{ + {"bare select", "select"}, + {"select star", "select *"}, + {"select with where", `select where status = "done"`}, + {"select with fields", `select id, title where status = "done"`}, + {"select with order by", `select order by priority`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + stmt, err := p.ParseStatement(tt.input) + if err != nil { + t.Fatalf("parse error: %v", err) + } + if stmt.Select == nil { + t.Fatal("expected Select") + } + if stmt.Select.Pipe != nil { + t.Fatal("expected no Pipe on plain select") + } + }) + } +} + +// --- lowering --- + +func TestLowerPipeProducesSelectAndPipe(t *testing.T) { + p := newTestParser() + stmt, err := p.ParseStatement(`select id where id = id() | run("echo $1")`) + if err != nil { + t.Fatalf("parse error: %v", err) + } + if stmt.Select == nil { + t.Fatal("expected Select non-nil") + } + if stmt.Select.Pipe == nil { + t.Fatal("expected Select.Pipe non-nil") + } + if stmt.Select.Pipe.Command == nil { + t.Fatal("expected Select.Pipe.Command non-nil") + } +} + +// --- validation --- + +func TestPipeRejectSelectStar(t *testing.T) { + p := newTestParser() + _, err := p.ParseStatement(`select * | run("echo")`) + if err == nil { + t.Fatal("expected error for select * with pipe") + } + if !strings.Contains(err.Error(), "explicit field names") { + t.Errorf("expected 'explicit field names' error, got: %v", err) + } +} + +func TestPipeRejectBareSelect(t *testing.T) { + p := newTestParser() + _, err := p.ParseStatement(`select | run("echo")`) + if err == nil { + t.Fatal("expected error for bare select with pipe") + } + if !strings.Contains(err.Error(), "explicit field names") { + t.Errorf("expected 'explicit field names' error, got: %v", err) + } +} + +func TestPipeRejectNonStringCommand(t *testing.T) { + p := newTestParser() + _, err := p.ParseStatement(`select id | run(42)`) + if err == nil { + t.Fatal("expected error for non-string pipe command") + } + if !strings.Contains(err.Error(), "pipe command must be string") { + t.Errorf("expected 'pipe command must be string' error, got: %v", err) + } +} + +func TestPipeRejectFieldRefInCommand(t *testing.T) { + p := newTestParser() + + tests := []struct { + name string + input string + }{ + {"bare field ref", `select id | run(title)`}, + {"qualified ref", `select id | run(old.title)`}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + _, err := p.ParseStatement(tt.input) + if err == nil { + t.Fatal("expected error for field ref in pipe command") + } + // either the field-ref check catches it, or type inference + // rejects it earlier (e.g. "old. qualifier not valid") + }) + } +} + +func TestExprContainsFieldRef(t *testing.T) { + tests := []struct { + name string + expr Expr + expected bool + }{ + {"string literal", &StringLiteral{Value: "echo"}, false}, + {"int literal", &IntLiteral{Value: 42}, false}, + {"bare field ref", &FieldRef{Name: "title"}, true}, + {"qualified ref", &QualifiedRef{Qualifier: "old", Name: "title"}, true}, + {"field in binary expr", &BinaryExpr{ + Op: "+", + Left: &StringLiteral{Value: "echo "}, + Right: &FieldRef{Name: "title"}, + }, true}, + {"field in function arg", &FunctionCall{ + Name: "concat", + Args: []Expr{&FieldRef{Name: "title"}}, + }, true}, + {"clean function", &FunctionCall{ + Name: "id", + Args: nil, + }, false}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := exprContainsFieldRef(tt.expr) + if got != tt.expected { + t.Errorf("exprContainsFieldRef() = %v, want %v", got, tt.expected) + } + }) + } +} + +// --- semantic validation --- + +func TestIsPipeMethod(t *testing.T) { + p := newTestParser() + + pipeStmt, err := p.ParseAndValidateStatement(`select id where id = id() | run("echo $1")`, ExecutorRuntimePlugin) + if err != nil { + t.Fatalf("parse error: %v", err) + } + if !pipeStmt.IsPipe() { + t.Error("expected IsPipe() = true for pipe statement") + } + + plainStmt, err := p.ParseAndValidateStatement(`select where status = "done"`, ExecutorRuntimeCLI) + if err != nil { + t.Fatalf("parse error: %v", err) + } + if plainStmt.IsPipe() { + t.Error("expected IsPipe() = false for plain select") + } + + updateStmt, err := p.ParseAndValidateStatement(`update where id = id() set status = "done"`, ExecutorRuntimePlugin) + if err != nil { + t.Fatalf("parse error: %v", err) + } + if updateStmt.IsPipe() { + t.Error("expected IsPipe() = false for update statement") + } +} + +func TestPipeIDDetectedInWhereClause(t *testing.T) { + p := newTestParser() + stmt, err := p.ParseAndValidateStatement(`select id where id = id() | run("echo $1")`, ExecutorRuntimePlugin) + if err != nil { + t.Fatalf("parse error: %v", err) + } + if !stmt.UsesIDBuiltin() { + t.Error("expected UsesIDBuiltin() = true for pipe with id() in where") + } +} + +// --- executor --- + +func TestExecutePipeReturnsResult(t *testing.T) { + e := NewExecutor(testSchema{}, nil, ExecutorRuntime{Mode: ExecutorRuntimePlugin}) + p := newTestParser() + tasks := makeTasks() + + stmt, err := p.ParseStatement(`select id, title where status = "done" | run("echo $1 $2")`) + if err != nil { + t.Fatalf("parse: %v", err) + } + + result, err := e.Execute(stmt, tasks, ExecutionInput{SelectedTaskID: "TIKI-000003"}) + if err != nil { + t.Fatalf("execute: %v", err) + } + if result.Pipe == nil { + t.Fatal("expected Pipe result") + } + if result.Select != nil { + t.Fatal("expected Select to be nil when pipe is present") + } + if result.Pipe.Command != "echo $1 $2" { + t.Errorf("command = %q, want %q", result.Pipe.Command, "echo $1 $2") + } + if len(result.Pipe.Rows) != 1 { + t.Fatalf("expected 1 row (status=done), got %d", len(result.Pipe.Rows)) + } + row := result.Pipe.Rows[0] + if len(row) != 2 { + t.Fatalf("expected 2 fields, got %d", len(row)) + } + if row[0] != "TIKI-000003" { + t.Errorf("row[0] (id) = %q, want %q", row[0], "TIKI-000003") + } + if row[1] != "Write docs" { + t.Errorf("row[1] (title) = %q, want %q", row[1], "Write docs") + } +} + +func TestExecuteSelectStillWorksWithoutPipe(t *testing.T) { + e := newTestExecutor() + p := newTestParser() + tasks := makeTasks() + + stmt, err := p.ParseStatement(`select where status = "done"`) + if err != nil { + t.Fatalf("parse: %v", err) + } + + result, err := e.Execute(stmt, tasks) + if err != nil { + t.Fatalf("execute: %v", err) + } + if result.Select == nil { + t.Fatal("expected Select result for plain select") + } + if result.Pipe != nil { + t.Fatal("expected no Pipe result for plain select") + } +} + +func TestExecutePipeListFieldSpaceJoined(t *testing.T) { + e := NewExecutor(testSchema{}, nil, ExecutorRuntime{Mode: ExecutorRuntimePlugin}) + p := newTestParser() + tasks := []*task.Task{ + {ID: "TIKI-000001", Title: "Test", Status: "ready", Type: "story", + Priority: 1, Tags: []string{"a", "b", "c"}}, + } + + stmt, err := p.ParseStatement(`select id, tags where id = id() | run("echo $1 $2")`) + if err != nil { + t.Fatalf("parse: %v", err) + } + + result, err := e.Execute(stmt, tasks, ExecutionInput{SelectedTaskID: "TIKI-000001"}) + if err != nil { + t.Fatalf("execute: %v", err) + } + if result.Pipe == nil { + t.Fatal("expected Pipe result") + } + if len(result.Pipe.Rows) != 1 { + t.Fatalf("expected 1 row, got %d", len(result.Pipe.Rows)) + } + row := result.Pipe.Rows[0] + if row[1] != "a b c" { + t.Errorf("tags field = %q, want %q", row[1], "a b c") + } +} + +// --- pipeArgString --- + +func TestPipeArgString(t *testing.T) { + tests := []struct { + name string + val interface{} + want string + }{ + {"string", "hello", "hello"}, + {"int", 3, "3"}, + {"string slice", []interface{}{"a", "b"}, "a b"}, + {"empty slice", []interface{}{}, ""}, + {"nil", nil, ""}, + {"status", task.Status("done"), "done"}, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + got := pipeArgString(tt.val) + if got != tt.want { + t.Errorf("pipeArgString(%v) = %q, want %q", tt.val, got, tt.want) + } + }) + } +} diff --git a/ruki/semantic_validate.go b/ruki/semantic_validate.go index 262c3b2..a0ac297 100644 --- a/ruki/semantic_validate.go +++ b/ruki/semantic_validate.go @@ -46,6 +46,9 @@ func (v *ValidatedStatement) IsCreate() bool { func (v *ValidatedStatement) IsDelete() bool { return v != nil && v.statement != nil && v.statement.Delete != nil } +func (v *ValidatedStatement) IsPipe() bool { + return v != nil && v.statement != nil && v.statement.Select != nil && v.statement.Select.Pipe != nil +} func (v *ValidatedStatement) mustBeSealed() error { if v == nil || v.seal != validatedSeal || v.statement == nil { @@ -384,7 +387,18 @@ func scanSelectSemantics(sel *SelectStmt) (usesID bool, hasCall bool, err error) if sel == nil { return false, false, nil } - return scanConditionSemantics(sel.Where) + u, c, err := scanConditionSemantics(sel.Where) + if err != nil { + return false, false, err + } + if sel.Pipe != nil { + u2, c2, err := scanExprSemantics(sel.Pipe.Command) + if err != nil { + return false, false, err + } + u, c = u || u2, c || c2 + } + return u, c, nil } func scanTriggerSemantics(trig *Trigger) (usesID bool, hasCall bool, err error) { @@ -566,11 +580,15 @@ func cloneSelect(sel *SelectStmt) *SelectStmt { if sel.OrderBy != nil { orderBy = append([]OrderByClause(nil), sel.OrderBy...) } - return &SelectStmt{ + out := &SelectStmt{ Fields: fields, Where: cloneCondition(sel.Where), OrderBy: orderBy, } + if sel.Pipe != nil { + out.Pipe = &RunAction{Command: cloneExpr(sel.Pipe.Command)} + } + return out } func cloneCreate(cr *CreateStmt) *CreateStmt { diff --git a/ruki/validate.go b/ruki/validate.go index 1844827..f0442c6 100644 --- a/ruki/validate.go +++ b/ruki/validate.go @@ -67,7 +67,25 @@ func (p *Parser) validateStatement(s *Statement) error { return err } } - return p.validateOrderBy(s.Select.OrderBy) + if err := p.validateOrderBy(s.Select.OrderBy); err != nil { + return err + } + if s.Select.Pipe != nil { + if len(s.Select.Fields) == 0 { + return fmt.Errorf("pipe requires explicit field names in select (not select * or bare select)") + } + typ, err := p.inferExprType(s.Select.Pipe.Command) + if err != nil { + return fmt.Errorf("pipe command: %w", err) + } + if typ != ValueString { + return fmt.Errorf("pipe command must be string, got %s", typeName(typ)) + } + if exprContainsFieldRef(s.Select.Pipe.Command) { + return fmt.Errorf("pipe command must not contain field references — use $1, $2 for positional args") + } + } + return nil default: return fmt.Errorf("empty statement") } @@ -881,3 +899,33 @@ func typeName(t ValueType) string { return "unknown" } } + +// exprContainsFieldRef returns true if the expression tree contains any +// *FieldRef or *QualifiedRef node. Used to reject field references in +// pipe commands, where positional args ($1, $2) should be used instead. +func exprContainsFieldRef(expr Expr) bool { + switch e := expr.(type) { + case *FieldRef: + return true + case *QualifiedRef: + return true + case *BinaryExpr: + return exprContainsFieldRef(e.Left) || exprContainsFieldRef(e.Right) + case *FunctionCall: + for _, arg := range e.Args { + if exprContainsFieldRef(arg) { + return true + } + } + return false + case *ListLiteral: + for _, elem := range e.Elements { + if exprContainsFieldRef(elem) { + return true + } + } + return false + default: + return false + } +} diff --git a/service/shell.go b/service/shell.go new file mode 100644 index 0000000..db0d4b0 --- /dev/null +++ b/service/shell.go @@ -0,0 +1,40 @@ +package service + +import ( + "context" + "log/slog" + "os/exec" + "time" +) + +// shellCommandTimeout is the timeout for shell commands executed by pipes and triggers. +const shellCommandTimeout = 30 * time.Second + +// RunShellCommand executes a shell command via "sh -c" with optional positional args. +// When args are provided, "_" occupies $0 and args land in $1, $2, etc. +// This is standard POSIX shell behavior for "sh -c