execute create and delete

This commit is contained in:
booleanmaybe 2026-04-04 16:46:56 -04:00
parent d78b187ac0
commit ca5a16e984
15 changed files with 1140 additions and 119 deletions

View file

@ -34,8 +34,10 @@ This page explains how Ruki statements, triggers, conditions, and expressions be
- `create` is a list of assignments.
- At least one assignment is required.
- The resulting task must have a non-empty `title`. This can come from an explicit `title=...` assignment or from the task template.
- Duplicate assignments to the same field are rejected.
- Every assigned field must exist in the injected schema.
- `id`, `createdBy`, `createdAt`, and `updatedAt` are immutable and cannot be assigned.
`update`
@ -43,6 +45,7 @@ This page explains how Ruki statements, triggers, conditions, and expressions be
- At least one assignment in `set` is required.
- The `where` clause and every right-hand side expression are validated.
- Duplicate assignments inside `set` are rejected.
- `id`, `createdBy`, `createdAt`, and `updatedAt` are immutable and cannot be assigned.
`delete`

View file

@ -81,7 +81,8 @@ Qualified and unqualified references are not literals, but they participate in t
Implemented behavior:
- `empty` can be assigned to any field type
- `empty` can be assigned to most field types
- `title`, `status`, `type`, and `priority` reject `empty` assignment — these fields are required
- `empty` can be compared against any typed expression
- `is empty` and `is not empty` are allowed for any expression type

View file

@ -80,6 +80,15 @@ select where foo = "bar"
create title="x" foo="bar"
```
Immutable field errors:
- `id`, `createdBy`, `createdAt`, and `updatedAt` cannot be assigned in `create` or `update`
```sql
create title="x" id="TIKI-ABC123"
update where status = "done" set createdBy="someone"
```
Qualifier misuse:
- `old.` and `new.` are invalid in standalone statements
@ -97,6 +106,18 @@ before delete where new.status = "done" deny "x"
before update where dependsOn any old.status = "done" deny "blocked"
```
## Required field errors
The resulting task from `create` must have a non-empty `title`. If the template does not provide one, a `title=...` assignment is required.
`title`, `status`, `type`, and `priority` reject `empty` assignment:
```sql
create title="" priority=2
create title="x" status=empty
update where id = "TIKI-ABC123" set priority=empty
```
## Type and operator errors
Comparison mismatches:

View file

@ -7,11 +7,11 @@ import (
"github.com/boolean-maybe/tiki/ruki"
"github.com/boolean-maybe/tiki/store"
"github.com/boolean-maybe/tiki/task"
)
// RunQuery parses and executes a ruki statement against the given store,
// writing formatted results to out. SELECT and UPDATE are supported;
// CREATE and DELETE are rejected.
// writing formatted results to out.
func RunQuery(taskStore store.Store, query string, out io.Writer) error {
query = strings.TrimSuffix(strings.TrimSpace(query), ";")
if query == "" {
@ -32,6 +32,20 @@ func RunQuery(taskStore store.Store, query string, out io.Writer) error {
return fmt.Errorf("parse: %w", err)
}
// for CREATE, fetch template before execution so field references
// (e.g. tags=tags+["new"]) resolve from template defaults
var template *task.Task
if stmt.Create != nil {
template, err = taskStore.NewTaskTemplate()
if err != nil {
return fmt.Errorf("create template: %w", err)
}
if template == nil {
return fmt.Errorf("create template: store returned nil template")
}
executor.SetTemplate(template)
}
tasks := taskStore.GetAllTasks()
result, err := executor.Execute(stmt, tasks)
if err != nil {
@ -46,13 +60,37 @@ func RunQuery(taskStore store.Store, query string, out io.Writer) error {
case result.Update != nil:
return persistAndSummarize(taskStore, result.Update, out)
case result.Create != nil:
return persistCreate(taskStore, result.Create, template, out)
case result.Delete != nil:
return persistDelete(taskStore, result.Delete, out)
default:
return fmt.Errorf("unsupported statement type")
}
}
// RunSelectQuery is the legacy entry point. It delegates to RunQuery.
// RunSelectQuery is the legacy entry point restricted to SELECT statements.
// Non-SELECT statements (CREATE, UPDATE, DELETE) are rejected to preserve
// read-only semantics expected by callers of this function.
func RunSelectQuery(taskStore store.Store, query string, out io.Writer) error {
trimmed := strings.TrimSuffix(strings.TrimSpace(query), ";")
if trimmed == "" {
return fmt.Errorf("empty query")
}
schema := NewSchema()
parser := ruki.NewParser(schema)
stmt, err := parser.ParseStatement(trimmed)
if err != nil {
return fmt.Errorf("parse: %w", err)
}
if stmt.Select == nil {
return fmt.Errorf("RunSelectQuery only supports SELECT statements")
}
return RunQuery(taskStore, query, out)
}
@ -80,6 +118,44 @@ func persistAndSummarize(taskStore store.Store, ur *ruki.UpdateResult, out io.Wr
return nil
}
func persistCreate(taskStore store.Store, cr *ruki.CreateResult, template *task.Task, out io.Writer) error {
t := cr.Task
t.ID = template.ID
t.CreatedBy = template.CreatedBy
t.CreatedAt = template.CreatedAt
if strings.TrimSpace(t.Title) == "" {
return fmt.Errorf("create requires a title")
}
if err := taskStore.CreateTask(t); err != nil {
return fmt.Errorf("create task: %w", err)
}
_, _ = fmt.Fprintf(out, "created %s\n", t.ID)
return nil
}
func persistDelete(taskStore store.Store, dr *ruki.DeleteResult, out io.Writer) error {
var succeeded, failed int
for _, t := range dr.Deleted {
taskStore.DeleteTask(t.ID)
if taskStore.GetTask(t.ID) != nil {
failed++
} else {
succeeded++
}
}
if failed > 0 {
_, _ = fmt.Fprintf(out, "deleted %d tasks (%d failed)\n", succeeded, failed)
return fmt.Errorf("delete partially failed: %d of %d tasks failed", failed, succeeded+failed)
}
_, _ = fmt.Fprintf(out, "deleted %d tasks\n", succeeded)
return nil
}
// resolveUser returns the current user name from the store.
// Returns an error if the user cannot be determined.
func resolveUser(s store.Store) (string, error) {

View file

@ -88,16 +88,29 @@ func TestRunSelectQueryParseError(t *testing.T) {
}
}
func TestRunSelectQueryNonSelectRejected(t *testing.T) {
func TestRunSelectQueryRejectsNonSelect(t *testing.T) {
s := setupRunnerTest(t)
var buf bytes.Buffer
err := RunSelectQuery(s, `create title="test"`, &buf)
if err == nil {
t.Fatal("expected error for unsupported statement")
tests := []struct {
name string
query string
}{
{"rejects create", `create title="via legacy"`},
{"rejects update", `update where id = "TIKI-AAA001" set title="x"`},
{"rejects delete", `delete where id = "TIKI-AAA001"`},
}
if !strings.Contains(err.Error(), "not supported") {
t.Errorf("error should mention not supported: %v", err)
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
var buf bytes.Buffer
err := RunSelectQuery(s, tt.query, &buf)
if err == nil {
t.Fatal("expected error for non-SELECT statement via RunSelectQuery")
}
if !strings.Contains(err.Error(), "only supports SELECT") {
t.Errorf("expected 'only supports SELECT' error, got: %v", err)
}
})
}
}
@ -314,11 +327,11 @@ func TestRunQueryResolveUserErrorUpdate(t *testing.T) {
func TestRunQueryExecuteError(t *testing.T) {
s := setupRunnerTest(t)
// delete statement is parsed but executor returns "not supported" error
// call() is rejected at runtime, triggering an execute error
var buf bytes.Buffer
err := RunQuery(s, `delete where id = "X"`, &buf)
err := RunQuery(s, `select where call("echo") = "x"`, &buf)
if err == nil {
t.Fatal("expected error for unsupported delete")
t.Fatal("expected execute error")
}
if !strings.Contains(err.Error(), "execute") {
t.Errorf("expected execute error, got: %v", err)
@ -337,3 +350,221 @@ func TestRunQueryUpdateInvalidPointsE2E(t *testing.T) {
t.Errorf("expected 'invalid points' error, got: %v", err)
}
}
// --- CREATE via runner ---
func TestRunQueryCreatePersists(t *testing.T) {
s := setupRunnerTest(t)
var buf bytes.Buffer
err := RunQuery(s, `create title="New Task" status="ready" priority=1`, &buf)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := buf.String()
if !strings.Contains(out, "created TIKI-") {
t.Fatalf("expected 'created TIKI-' in output, got: %s", out)
}
// verify task exists in store
allTasks := s.GetAllTasks()
var found *task.Task
for _, tk := range allTasks {
if tk.Title == "New Task" {
found = tk
break
}
}
if found == nil {
t.Fatal("created task not found in store")
}
if !strings.HasPrefix(found.ID, "TIKI-") || len(found.ID) != 11 {
t.Errorf("ID = %q, want TIKI-XXXXXX format (11 chars)", found.ID)
}
if found.Priority != 1 {
t.Errorf("priority = %d, want 1", found.Priority)
}
}
func TestRunQueryCreateMissingTitle(t *testing.T) {
s := setupRunnerTest(t)
var buf bytes.Buffer
err := RunQuery(s, `create priority=1 status="ready"`, &buf)
if err == nil {
t.Fatal("expected error for missing title")
}
if !strings.Contains(err.Error(), "title") {
t.Errorf("expected title error, got: %v", err)
}
}
func TestRunQueryCreateTemplateDefaults(t *testing.T) {
s := setupRunnerTest(t)
var buf bytes.Buffer
err := RunQuery(s, `create title="Templated" tags=tags+["extra"]`, &buf)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
allTasks := s.GetAllTasks()
var found *task.Task
for _, tk := range allTasks {
if tk.Title == "Templated" {
found = tk
break
}
}
if found == nil {
t.Fatal("created task not found in store")
}
// InMemoryStore template has tags=["idea"], so result should be ["idea", "extra"]
if len(found.Tags) != 2 || found.Tags[0] != "idea" || found.Tags[1] != "extra" {
t.Errorf("tags = %v, want [idea extra]", found.Tags)
}
// priority should be template default (7)
if found.Priority != 7 {
t.Errorf("priority = %d, want 7 (template default)", found.Priority)
}
}
func TestRunQueryCreateTemplateFailure(t *testing.T) {
s := setupRunnerTest(t)
fs := &failingTemplateStore{Store: s}
var buf bytes.Buffer
err := RunQuery(fs, `create title="test"`, &buf)
if err == nil {
t.Fatal("expected error for template failure")
}
if !strings.Contains(err.Error(), "create template") {
t.Errorf("expected 'create template' error, got: %v", err)
}
}
// failingTemplateStore wraps a Store and fails NewTaskTemplate.
type failingTemplateStore struct {
store.Store
}
func (f *failingTemplateStore) NewTaskTemplate() (*task.Task, error) {
return nil, fmt.Errorf("simulated template failure")
}
func TestRunQueryCreateNilTemplate(t *testing.T) {
s := setupRunnerTest(t)
fs := &nilTemplateStore{Store: s}
var buf bytes.Buffer
err := RunQuery(fs, `create title="test"`, &buf)
if err == nil {
t.Fatal("expected error for nil template")
}
if !strings.Contains(err.Error(), "nil template") {
t.Errorf("expected 'nil template' error, got: %v", err)
}
}
// nilTemplateStore wraps a Store and returns (nil, nil) from NewTaskTemplate.
type nilTemplateStore struct {
store.Store
}
func (f *nilTemplateStore) NewTaskTemplate() (*task.Task, error) {
return nil, nil
}
func TestRunQueryCreateTaskFailure(t *testing.T) {
s := setupRunnerTest(t)
fs := &failingCreateStore{Store: s}
var buf bytes.Buffer
err := RunQuery(fs, `create title="test"`, &buf)
if err == nil {
t.Fatal("expected error for CreateTask failure")
}
if !strings.Contains(err.Error(), "create task") {
t.Errorf("expected 'create task' error, got: %v", err)
}
}
// failingCreateStore wraps a Store and fails CreateTask.
type failingCreateStore struct {
store.Store
}
func (f *failingCreateStore) CreateTask(t *task.Task) error {
return fmt.Errorf("simulated create failure")
}
// --- DELETE via runner ---
func TestRunQueryDeletePersists(t *testing.T) {
s := setupRunnerTest(t)
var buf bytes.Buffer
err := RunQuery(s, `delete where id = "TIKI-AAA001"`, &buf)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := buf.String()
if !strings.Contains(out, "deleted 1 tasks") {
t.Errorf("expected 'deleted 1 tasks' in output, got: %s", out)
}
if s.GetTask("TIKI-AAA001") != nil {
t.Error("task should be deleted from store")
}
}
func TestRunQueryDeleteZeroMatches(t *testing.T) {
s := setupRunnerTest(t)
var buf bytes.Buffer
err := RunQuery(s, `delete where id = "NONEXISTENT"`, &buf)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
out := buf.String()
if !strings.Contains(out, "deleted 0 tasks") {
t.Errorf("expected 'deleted 0 tasks' in output, got: %s", out)
}
}
func TestRunQueryDeletePartialFailure(t *testing.T) {
s := setupRunnerTest(t)
// add a second ready task so we match multiple
_ = s.CreateTask(&task.Task{ID: "TIKI-CCC003", Title: "Third", Status: "ready", Priority: 3})
fs := &failingDeleteStore{Store: s, failID: "TIKI-AAA001"}
var buf bytes.Buffer
err := RunQuery(fs, `delete where status = "ready"`, &buf)
out := buf.String()
if err == nil {
t.Fatal("expected error for partial failure")
}
if !strings.Contains(out, "failed") {
t.Errorf("expected 'failed' in output, got: %s", out)
}
if !strings.Contains(err.Error(), "partially failed") {
t.Errorf("expected 'partially failed' in error, got: %v", err)
}
}
// failingDeleteStore wraps a Store and silently no-ops DeleteTask for a specific ID.
type failingDeleteStore struct {
store.Store
failID string
}
func (f *failingDeleteStore) DeleteTask(id string) {
if id == f.failID {
return // simulate silent failure
}
f.Store.DeleteTask(id)
}

View file

@ -3,41 +3,25 @@ package filter
import (
"fmt"
"regexp"
"strconv"
"strings"
"time"
"github.com/boolean-maybe/tiki/util/duration"
)
// Duration pattern: number followed by unit (month, week, day, hour, min)
var durationPattern = regexp.MustCompile(`^(\d+)(month|week|day|hour|min)s?$`)
// durationPattern matches a number followed by a duration unit, with optional plural "s".
var durationPattern = regexp.MustCompile(`^(\d+)(` + duration.Pattern() + `)s?$`)
// IsDurationLiteral checks if a string is a valid duration literal
// IsDurationLiteral checks if a string is a valid duration literal.
func IsDurationLiteral(s string) bool {
return durationPattern.MatchString(strings.ToLower(s))
}
// ParseDuration parses a duration literal like "24hour" or "1week"
// ParseDuration parses a duration literal like "24hour" or "1week".
func ParseDuration(s string) (time.Duration, error) {
s = strings.ToLower(s)
matches := durationPattern.FindStringSubmatch(s)
if matches == nil {
val, unit, err := duration.Parse(strings.ToLower(s))
if err != nil {
return 0, fmt.Errorf("invalid duration: %s", s)
}
value, _ := strconv.Atoi(matches[1])
unit := matches[2]
switch unit {
case "min":
return time.Duration(value) * time.Minute, nil
case "hour":
return time.Duration(value) * time.Hour, nil
case "day":
return time.Duration(value) * 24 * time.Hour, nil
case "week":
return time.Duration(value) * 7 * 24 * time.Hour, nil
case "month":
return time.Duration(value) * 30 * 24 * time.Hour, nil
}
return 0, fmt.Errorf("unknown duration unit: %s", unit)
return duration.ToDuration(val, unit)
}

View file

@ -203,6 +203,34 @@ func TestTimeExpressions(t *testing.T) {
expect: true, // Updated 5 seconds ago
},
// seconds unit
{
name: "seconds - under threshold",
expr: "NOW - UpdatedAt < 30secs",
task: &task.Task{UpdatedAt: now.Add(-10 * time.Second)},
expect: true, // Updated 10 seconds ago
},
{
name: "seconds - over threshold",
expr: "NOW - UpdatedAt < 10sec",
task: &task.Task{UpdatedAt: now.Add(-30 * time.Second)},
expect: false, // Updated 30 seconds ago
},
// years unit
{
name: "years - under threshold",
expr: "NOW - CreatedAt < 1year",
task: &task.Task{CreatedAt: now.Add(-200 * 24 * time.Hour)},
expect: true, // Created 200 days ago, less than 365 days
},
{
name: "years - over threshold",
expr: "NOW - CreatedAt > 1year",
task: &task.Task{CreatedAt: now.Add(-400 * 24 * time.Hour)},
expect: true, // Created 400 days ago, more than 365 days
},
// Edge case: future time (shouldn't normally happen, but test negative duration)
{
name: "future time - negative duration",
@ -260,6 +288,16 @@ func TestTimeExpressionParsing(t *testing.T) {
expr: "NOW - UpdatedAt < 2months",
shouldError: false,
},
{
name: "valid with seconds",
expr: "NOW - UpdatedAt < 30secs",
shouldError: false,
},
{
name: "valid with years",
expr: "NOW - CreatedAt > 1year",
shouldError: false,
},
{
name: "valid with parentheses",
expr: "(NOW - UpdatedAt < 1hour)",

View file

@ -7,14 +7,21 @@ import (
"time"
"github.com/boolean-maybe/tiki/task"
"github.com/boolean-maybe/tiki/util/duration"
)
// Executor evaluates parsed ruki statements against a set of tasks.
type Executor struct {
schema Schema
userFunc func() string
template *task.Task
}
// SetTemplate sets the base task template for CREATE execution.
// When set, CREATE assignments are evaluated against a clone of this template,
// so field references (e.g. tags=tags+["new"]) resolve from template defaults.
func (e *Executor) SetTemplate(t *task.Task) { e.template = t }
// NewExecutor constructs an Executor with the given schema and user function.
// If userFunc is nil, calling user() at runtime will return "".
func NewExecutor(schema Schema, userFunc func() string) *Executor {
@ -29,6 +36,8 @@ func NewExecutor(schema Schema, userFunc func() string) *Executor {
type Result struct {
Select *TaskProjection
Update *UpdateResult
Create *CreateResult
Delete *DeleteResult
}
// UpdateResult holds the cloned, mutated tasks produced by an UPDATE statement.
@ -36,6 +45,16 @@ type UpdateResult struct {
Updated []*task.Task
}
// CreateResult holds the new task produced by a CREATE statement.
type CreateResult struct {
Task *task.Task
}
// DeleteResult holds the tasks matched by a DELETE statement's WHERE clause.
type DeleteResult struct {
Deleted []*task.Task
}
// TaskProjection holds the filtered, sorted tasks and the requested field list.
type TaskProjection struct {
Tasks []*task.Task
@ -51,11 +70,11 @@ func (e *Executor) Execute(stmt *Statement, tasks []*task.Task) (*Result, error)
case stmt.Select != nil:
return e.executeSelect(stmt.Select, tasks)
case stmt.Create != nil:
return nil, fmt.Errorf("create is not supported yet")
return e.executeCreate(stmt.Create, tasks)
case stmt.Update != nil:
return e.executeUpdate(stmt.Update, tasks)
case stmt.Delete != nil:
return nil, fmt.Errorf("delete is not supported yet")
return e.executeDelete(stmt.Delete, tasks)
default:
return nil, fmt.Errorf("empty statement")
}
@ -105,6 +124,36 @@ func (e *Executor) executeUpdate(upd *UpdateStmt, tasks []*task.Task) (*Result,
return &Result{Update: &UpdateResult{Updated: clones}}, nil
}
func (e *Executor) executeCreate(cr *CreateStmt, tasks []*task.Task) (*Result, error) {
var t *task.Task
if e.template != nil {
t = e.template.Clone()
} else {
t = &task.Task{}
}
for _, a := range cr.Assignments {
val, err := e.evalExpr(a.Value, t, tasks)
if err != nil {
return nil, fmt.Errorf("field %q: %w", a.Field, err)
}
if err := e.setField(t, a.Field, val); err != nil {
return nil, fmt.Errorf("field %q: %w", a.Field, err)
}
}
return &Result{Create: &CreateResult{Task: t}}, nil
}
func (e *Executor) executeDelete(del *DeleteStmt, tasks []*task.Task) (*Result, error) {
matched, err := e.filterTasks(del.Where, tasks)
if err != nil {
return nil, err
}
return &Result{Delete: &DeleteResult{Deleted: matched}}, nil
}
func (e *Executor) setField(t *task.Task, name string, val interface{}) error {
switch name {
case "id", "createdBy", "createdAt", "updatedAt":
@ -452,7 +501,11 @@ func (e *Executor) evalExpr(expr Expr, t *task.Task, allTasks []*task.Task) (int
case *DateLiteral:
return expr.Value, nil
case *DurationLiteral:
return durationToTimeDelta(expr.Value, expr.Unit), nil
d, err := duration.ToDuration(expr.Value, expr.Unit)
if err != nil {
return nil, err
}
return d, nil
case *ListLiteral:
return e.evalListLiteral(expr, t, allTasks)
case *EmptyLiteral:
@ -1085,22 +1138,3 @@ func isZeroValue(v interface{}) bool {
return false
}
}
func durationToTimeDelta(value int, unit string) time.Duration {
switch unit {
case "day":
return time.Duration(value) * 24 * time.Hour
case "week":
return time.Duration(value) * 7 * 24 * time.Hour
case "month":
return time.Duration(value) * 30 * 24 * time.Hour
case "year":
return time.Duration(value) * 365 * 24 * time.Hour
case "hour":
return time.Duration(value) * time.Hour
case "minute":
return time.Duration(value) * time.Minute
default:
return time.Duration(value) * 24 * time.Hour
}
}

View file

@ -698,37 +698,343 @@ func TestExecuteCallRejected(t *testing.T) {
}
}
// --- unsupported statement types ---
// --- CREATE execution ---
func TestExecuteUnsupportedStatements(t *testing.T) {
func TestExecuteCreateBasic(t *testing.T) {
e := newTestExecutor()
p := newTestParser()
tests := []struct {
name string
input string
}{
{"create", `create title="test"`},
{"delete", `delete where id = "X"`},
stmt, err := p.ParseStatement(`create title="Fix login" priority=2 status="ready"`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, nil)
if err != nil {
t.Fatalf("execute: %v", err)
}
if result.Create == nil {
t.Fatal("expected Create result")
}
tk := result.Create.Task
if tk.Title != "Fix login" {
t.Errorf("title = %q, want %q", tk.Title, "Fix login")
}
if tk.Priority != 2 {
t.Errorf("priority = %d, want 2", tk.Priority)
}
if tk.Status != "ready" {
t.Errorf("status = %q, want %q", tk.Status, "ready")
}
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
stmt, err := p.ParseStatement(tt.input)
if err != nil {
t.Fatalf("parse: %v", err)
func TestExecuteCreateWithUser(t *testing.T) {
e := newTestExecutor() // userFunc returns "alice"
p := newTestParser()
stmt, err := p.ParseStatement(`create title="test" assignee=user()`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, nil)
if err != nil {
t.Fatalf("execute: %v", err)
}
if result.Create.Task.Assignee != "alice" {
t.Errorf("assignee = %q, want %q", result.Create.Task.Assignee, "alice")
}
}
func TestExecuteCreateEnumNormalization(t *testing.T) {
e := newTestExecutor()
p := newTestParser()
stmt, err := p.ParseStatement(`create title="test" status="todo" type="feature"`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, nil)
if err != nil {
t.Fatalf("execute: %v", err)
}
tk := result.Create.Task
if tk.Status != "ready" {
t.Errorf("status = %q, want normalized %q", tk.Status, "ready")
}
if tk.Type != "story" {
t.Errorf("type = %q, want normalized %q", tk.Type, "story")
}
}
func TestExecuteCreateImmutableFieldRejected(t *testing.T) {
e := newTestExecutor()
for _, field := range []string{"id", "createdBy", "createdAt", "updatedAt"} {
t.Run(field, func(t *testing.T) {
stmt := &Statement{
Create: &CreateStmt{
Assignments: []Assignment{
{Field: "title", Value: &StringLiteral{Value: "x"}},
{Field: field, Value: &StringLiteral{Value: "test"}},
},
},
}
_, err = e.Execute(stmt, makeTasks())
_, err := e.Execute(stmt, nil)
if err == nil {
t.Fatal("expected error for unsupported statement")
t.Fatal("expected error for immutable field")
}
if !strings.Contains(err.Error(), "not supported") {
t.Fatalf("expected 'not supported' error, got: %v", err)
if !strings.Contains(err.Error(), "immutable") {
t.Errorf("expected immutable error, got: %v", err)
}
})
}
}
func TestExecuteCreateEmptyTitleRejected(t *testing.T) {
e := newTestExecutor()
p := newTestParser()
stmt, err := p.ParseStatement(`create title=""`)
if err != nil {
t.Fatalf("parse: %v", err)
}
_, err = e.Execute(stmt, nil)
if err == nil {
t.Fatal("expected error for empty title")
}
if !strings.Contains(err.Error(), "empty") {
t.Errorf("expected empty error, got: %v", err)
}
}
func TestExecuteCreateExprError(t *testing.T) {
e := newTestExecutor()
stmt := &Statement{
Create: &CreateStmt{
Assignments: []Assignment{
{Field: "title", Value: &QualifiedRef{Qualifier: "old", Name: "title"}},
},
},
}
_, err := e.Execute(stmt, nil)
if err == nil {
t.Fatal("expected error from eval expression")
}
}
func TestExecuteCreateListField(t *testing.T) {
e := newTestExecutor()
p := newTestParser()
stmt, err := p.ParseStatement(`create title="test" tags=["a","b"]`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, nil)
if err != nil {
t.Fatalf("execute: %v", err)
}
tags := result.Create.Task.Tags
if len(tags) != 2 || tags[0] != "a" || tags[1] != "b" {
t.Errorf("tags = %v, want [a b]", tags)
}
}
func TestExecuteCreateDateField(t *testing.T) {
e := newTestExecutor()
p := newTestParser()
stmt, err := p.ParseStatement(`create title="test" due=2026-06-01`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, nil)
if err != nil {
t.Fatalf("execute: %v", err)
}
due := result.Create.Task.Due
if due.Year() != 2026 || due.Month() != 6 || due.Day() != 1 {
t.Errorf("due = %v, want 2026-06-01", due)
}
}
func TestExecuteCreatePriorityOutOfRange(t *testing.T) {
e := newTestExecutor()
for _, prio := range []int{0, 99, -1} {
t.Run(fmt.Sprintf("priority=%d", prio), func(t *testing.T) {
stmt := &Statement{
Create: &CreateStmt{
Assignments: []Assignment{
{Field: "title", Value: &StringLiteral{Value: "x"}},
{Field: "priority", Value: &IntLiteral{Value: prio}},
},
},
}
_, err := e.Execute(stmt, nil)
if err == nil {
t.Fatal("expected error for out-of-range priority")
}
})
}
}
func TestExecuteCreateEmptyTasks(t *testing.T) {
e := newTestExecutor()
p := newTestParser()
stmt, err := p.ParseStatement(`create title="test"`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, []*task.Task{})
if err != nil {
t.Fatalf("execute: %v", err)
}
if result.Create.Task.Title != "test" {
t.Errorf("title = %q, want %q", result.Create.Task.Title, "test")
}
}
func TestExecuteCreateWithTemplate(t *testing.T) {
e := newTestExecutor()
p := newTestParser()
// set template with tags=["idea"] and priority=7
e.SetTemplate(&task.Task{
Tags: []string{"idea"},
Priority: 7,
Status: "ready",
Type: "story",
})
stmt, err := p.ParseStatement(`create title="x" tags=tags+["new"]`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, nil)
if err != nil {
t.Fatalf("execute: %v", err)
}
tk := result.Create.Task
// tags should be template's ["idea"] + ["new"]
if len(tk.Tags) != 2 || tk.Tags[0] != "idea" || tk.Tags[1] != "new" {
t.Errorf("tags = %v, want [idea new]", tk.Tags)
}
// priority should be preserved from template (not set by assignment)
if tk.Priority != 7 {
t.Errorf("priority = %d, want 7 (template default)", tk.Priority)
}
}
func TestExecuteCreateWithoutTemplate(t *testing.T) {
e := newTestExecutor()
p := newTestParser()
// no SetTemplate call — template is nil
stmt, err := p.ParseStatement(`create title="x" priority=3`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, nil)
if err != nil {
t.Fatalf("execute: %v", err)
}
tk := result.Create.Task
if tk.Title != "x" {
t.Errorf("title = %q, want %q", tk.Title, "x")
}
if tk.Priority != 3 {
t.Errorf("priority = %d, want 3", tk.Priority)
}
// unset fields should be zero-valued
if tk.Points != 0 {
t.Errorf("points = %d, want 0 (zero-value)", tk.Points)
}
}
// --- DELETE execution ---
func TestExecuteDeleteBasic(t *testing.T) {
e := newTestExecutor()
p := newTestParser()
tasks := makeTasks()
stmt, err := p.ParseStatement(`delete where id = "TIKI-000001"`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
if result.Delete == nil {
t.Fatal("expected Delete result")
}
if len(result.Delete.Deleted) != 1 {
t.Fatalf("expected 1 deleted, got %d", len(result.Delete.Deleted))
}
if result.Delete.Deleted[0].ID != "TIKI-000001" {
t.Errorf("deleted ID = %q, want TIKI-000001", result.Delete.Deleted[0].ID)
}
}
func TestExecuteDeleteMultipleMatches(t *testing.T) {
e := newTestExecutor()
p := newTestParser()
tasks := makeTasks()
stmt, err := p.ParseStatement(`delete where type = "story"`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
// TIKI-000001 and TIKI-000003 are stories
if len(result.Delete.Deleted) != 2 {
t.Fatalf("expected 2 deleted, got %d", len(result.Delete.Deleted))
}
}
func TestExecuteDeleteNoMatches(t *testing.T) {
e := newTestExecutor()
p := newTestParser()
tasks := makeTasks()
stmt, err := p.ParseStatement(`delete where id = "NONEXISTENT"`)
if err != nil {
t.Fatalf("parse: %v", err)
}
result, err := e.Execute(stmt, tasks)
if err != nil {
t.Fatalf("execute: %v", err)
}
if len(result.Delete.Deleted) != 0 {
t.Fatalf("expected 0 deleted, got %d", len(result.Delete.Deleted))
}
}
func TestExecuteDeleteWhereError(t *testing.T) {
e := newTestExecutor()
tasks := makeTasks()
stmt := &Statement{
Delete: &DeleteStmt{
Where: &CompareExpr{
Left: &QualifiedRef{Qualifier: "old", Name: "status"},
Op: "=",
Right: &StringLiteral{Value: "done"},
},
},
}
_, err := e.Execute(stmt, tasks)
if err == nil {
t.Fatal("expected error from WHERE evaluation")
}
}
// --- quantifier ---
func TestExecuteQuantifier(t *testing.T) {
@ -1178,26 +1484,28 @@ func TestIsZeroValue(t *testing.T) {
// --- durationToTimeDelta full coverage ---
func TestDurationToTimeDelta(t *testing.T) {
tests := []struct {
unit string
want time.Duration
}{
{"day", 24 * time.Hour},
{"week", 7 * 24 * time.Hour},
{"month", 30 * 24 * time.Hour},
{"year", 365 * 24 * time.Hour},
{"hour", time.Hour},
{"minute", time.Minute},
{"unknown", 24 * time.Hour}, // default = day
func TestDurationLiteralUnknownUnitError(t *testing.T) {
e := &Executor{}
tasks := []*task.Task{{ID: "TIKI-AAA001", Title: "test"}}
// unknown unit should produce an error, not silently default to days
stmt, err := newTestParser().ParseStatement(`select where due > 2026-01-01 + 1day`)
if err != nil {
t.Fatal(err)
}
for _, tt := range tests {
t.Run(tt.unit, func(t *testing.T) {
got := durationToTimeDelta(1, tt.unit)
if got != tt.want {
t.Errorf("durationToTimeDelta(1, %q) = %v, want %v", tt.unit, got, tt.want)
}
})
// manually inject an unknown unit into the AST
cmp, ok := stmt.Select.Where.(*CompareExpr)
if !ok {
t.Fatal("expected *CompareExpr")
}
add, ok := cmp.Right.(*BinaryExpr)
if !ok {
t.Fatal("expected *BinaryExpr")
}
add.Right = &DurationLiteral{Value: 1, Unit: "bogus"}
_, err = e.Execute(stmt, tasks)
if err == nil {
t.Fatal("expected error for unknown duration unit, got nil")
}
}

View file

@ -1,13 +1,17 @@
package ruki
import "github.com/alecthomas/participle/v2/lexer"
import (
"github.com/alecthomas/participle/v2/lexer"
"github.com/boolean-maybe/tiki/util/duration"
)
// rukiLexer defines the token rules for the ruki DSL.
// rule ordering is critical: longer/more-specific patterns first.
var rukiLexer = lexer.MustSimple([]lexer.SimpleRule{
{Name: "Comment", Pattern: `--[^\n]*`},
{Name: "Whitespace", Pattern: `\s+`},
{Name: "Duration", Pattern: `\d+(?:sec|min|hour|day|week|month|year)s?`},
{Name: "Duration", Pattern: `\d+(?:` + duration.Pattern() + `)s?`},
{Name: "Date", Pattern: `\d{4}-\d{2}-\d{2}`},
{Name: "Int", Pattern: `\d+`},
{Name: "String", Pattern: `"(?:[^"\\]|\\.)*"`},

View file

@ -3,8 +3,9 @@ package ruki
import (
"fmt"
"strconv"
"strings"
"time"
"github.com/boolean-maybe/tiki/util/duration"
)
// lower.go converts participle grammar structs into clean AST types.
@ -394,20 +395,9 @@ func parseDateLiteral(s string) (Expr, error) {
}
func parseDurationLiteral(s string) (Expr, error) {
// find where digits end and unit begins
i := 0
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
if i == 0 || i == len(s) {
return nil, fmt.Errorf("invalid duration literal %q", s)
}
val, err := strconv.Atoi(s[:i])
val, unit, err := duration.Parse(s)
if err != nil {
return nil, fmt.Errorf("invalid duration value in %q: %w", s, err)
return nil, err
}
unit := strings.TrimSuffix(s[i:], "s") // normalize "days" → "day"
return &DurationLiteral{Value: val, Unit: unit}, nil
}

View file

@ -6,6 +6,7 @@ import (
"sync"
"time"
"github.com/boolean-maybe/tiki/config"
"github.com/boolean-maybe/tiki/store/internal/git"
"github.com/boolean-maybe/tiki/task"
)
@ -19,6 +20,7 @@ type InMemoryStore struct {
tasks map[string]*task.Task
listeners map[int]ChangeListener
nextListenerID int
idGenerator func() string // injectable for testing; defaults to config.GenerateRandomID
}
func normalizeTaskID(id string) string {
@ -31,6 +33,7 @@ func NewInMemoryStore() *InMemoryStore {
tasks: make(map[string]*task.Task),
listeners: make(map[int]ChangeListener),
nextListenerID: 1, // Start at 1 to avoid conflict with zero-value sentinel
idGenerator: config.GenerateRandomID,
}
}
@ -222,22 +225,38 @@ func (s *InMemoryStore) GetGitOps() git.GitOps {
return nil
}
// NewTaskTemplate returns a new task with hardcoded defaults.
// MemoryStore doesn't load templates from files.
const maxIDAttempts = 100
// NewTaskTemplate returns a new task with hardcoded defaults and an auto-generated ID.
func (s *InMemoryStore) NewTaskTemplate() (*task.Task, error) {
task := &task.Task{
ID: "", // Caller must set ID
s.mu.RLock()
defer s.mu.RUnlock()
var taskID string
for range maxIDAttempts {
taskID = normalizeTaskID(fmt.Sprintf("TIKI-%s", s.idGenerator()))
if _, exists := s.tasks[taskID]; !exists {
break
}
taskID = "" // mark as failed so we can detect exhaustion
}
if taskID == "" {
return nil, fmt.Errorf("failed to generate unique task ID after %d attempts", maxIDAttempts)
}
t := &task.Task{
ID: taskID,
Title: "",
Description: "",
Type: task.TypeStory,
Status: task.DefaultStatus(),
Priority: 7, // Match embedded template default
Priority: 7, // match embedded template default
Points: 1,
Tags: []string{"idea"},
CreatedAt: time.Now(),
CreatedBy: "memory-user",
}
return task, nil
return t, nil
}
// ensure InMemoryStore implements Store

View file

@ -1,6 +1,7 @@
package store
import (
"strings"
"testing"
"time"
@ -331,6 +332,12 @@ func TestInMemoryStore_NewTaskTemplate(t *testing.T) {
if err != nil {
t.Fatalf("NewTaskTemplate() error = %v", err)
}
if !strings.HasPrefix(tmpl.ID, "TIKI-") || len(tmpl.ID) != 11 {
t.Errorf("ID = %q, want TIKI-XXXXXX format (11 chars)", tmpl.ID)
}
if tmpl.ID != strings.ToUpper(tmpl.ID) {
t.Errorf("ID = %q, should be uppercased", tmpl.ID)
}
if tmpl.Priority != 7 {
t.Errorf("Priority = %d, want 7", tmpl.Priority)
}
@ -348,6 +355,50 @@ func TestInMemoryStore_NewTaskTemplate(t *testing.T) {
}
}
func TestInMemoryStore_NewTaskTemplateCollision(t *testing.T) {
s := NewInMemoryStore()
// pre-populate store with a task that will collide
_ = s.CreateTask(&taskpkg.Task{ID: "TIKI-AAAAAA", Title: "existing"})
callCount := 0
s.idGenerator = func() string {
callCount++
if callCount == 1 {
return "aaaaaa" // will collide (normalized to TIKI-AAAAAA)
}
return "bbbbbb" // will succeed
}
tmpl, err := s.NewTaskTemplate()
if err != nil {
t.Fatalf("NewTaskTemplate() error = %v", err)
}
if tmpl.ID != "TIKI-BBBBBB" {
t.Errorf("ID = %q, want TIKI-BBBBBB (should skip collision)", tmpl.ID)
}
if callCount != 2 {
t.Errorf("idGenerator called %d times, want 2 (one collision + one success)", callCount)
}
}
func TestInMemoryStore_NewTaskTemplateExhaustion(t *testing.T) {
s := NewInMemoryStore()
// pre-populate with the only ID the generator will ever produce
_ = s.CreateTask(&taskpkg.Task{ID: "TIKI-AAAAAA", Title: "existing"})
s.idGenerator = func() string { return "aaaaaa" }
_, err := s.NewTaskTemplate()
if err == nil {
t.Fatal("expected error for ID exhaustion, got nil")
}
if !strings.Contains(err.Error(), "failed to generate unique task ID") {
t.Errorf("unexpected error message: %v", err)
}
}
func TestInMemoryStore_GetAllTasks(t *testing.T) {
t.Run("empty store returns empty slice", func(t *testing.T) {
s := NewInMemoryStore()

86
util/duration/duration.go Normal file
View file

@ -0,0 +1,86 @@
package duration
import (
"fmt"
"sort"
"strconv"
"strings"
"time"
)
// unit pairs a canonical short name with its time.Duration equivalent.
type unit struct {
name string
duration time.Duration
}
// units is the canonical, immutable list of supported duration units.
var units = [...]unit{
{"sec", time.Second},
{"min", time.Minute},
{"hour", time.Hour},
{"day", 24 * time.Hour},
{"week", 7 * 24 * time.Hour},
{"month", 30 * 24 * time.Hour},
{"year", 365 * 24 * time.Hour},
}
var unitMap map[string]time.Duration
func init() {
unitMap = make(map[string]time.Duration, len(units))
for _, u := range units {
unitMap[u.name] = u.duration
}
}
// Parse splits a duration string like "30min" or "2days" into its numeric
// value and canonical unit name. Plural trailing "s" is stripped.
func Parse(s string) (int, string, error) {
i := 0
for i < len(s) && s[i] >= '0' && s[i] <= '9' {
i++
}
if i == 0 || i == len(s) {
return 0, "", fmt.Errorf("invalid duration literal %q", s)
}
val, err := strconv.Atoi(s[:i])
if err != nil {
return 0, "", fmt.Errorf("invalid duration value in %q: %w", s, err)
}
unit := strings.TrimSuffix(s[i:], "s")
if !IsValidUnit(unit) {
return 0, "", fmt.Errorf("unknown duration unit %q in %q", unit, s)
}
return val, unit, nil
}
// ToDuration converts a value and canonical unit name to a time.Duration.
func ToDuration(value int, unit string) (time.Duration, error) {
d, ok := unitMap[unit]
if !ok {
return 0, fmt.Errorf("unknown duration unit %q", unit)
}
return time.Duration(value) * d, nil
}
// IsValidUnit reports whether name is a recognized duration unit.
func IsValidUnit(name string) bool {
_, ok := unitMap[name]
return ok
}
// Pattern returns a regex alternation of all unit names, sorted longest-first
// so that greedy matching prefers "month" over "min".
func Pattern() string {
names := make([]string, len(units))
for i, u := range units {
names[i] = u.name
}
sort.Slice(names, func(i, j int) bool {
return len(names[i]) > len(names[j])
})
return strings.Join(names, "|")
}

View file

@ -0,0 +1,175 @@
package duration
import (
"fmt"
"regexp"
"strings"
"testing"
"time"
)
func TestParse(t *testing.T) {
tests := []struct {
input string
wantVal int
wantUnit string
}{
{"1sec", 1, "sec"},
{"10secs", 10, "sec"},
{"1min", 1, "min"},
{"30mins", 30, "min"},
{"1hour", 1, "hour"},
{"2hours", 2, "hour"},
{"1day", 1, "day"},
{"7days", 7, "day"},
{"1week", 1, "week"},
{"3weeks", 3, "week"},
{"1month", 1, "month"},
{"6months", 6, "month"},
{"1year", 1, "year"},
{"2years", 2, "year"},
}
for _, tt := range tests {
t.Run(tt.input, func(t *testing.T) {
val, unit, err := Parse(tt.input)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if val != tt.wantVal {
t.Errorf("value = %d, want %d", val, tt.wantVal)
}
if unit != tt.wantUnit {
t.Errorf("unit = %q, want %q", unit, tt.wantUnit)
}
})
}
}
func TestParseErrors(t *testing.T) {
tests := []struct {
input string
desc string
}{
{"", "empty string"},
{"min", "no digits"},
{"30", "no unit"},
{"30bogus", "unknown unit"},
{"0xAhour", "non-decimal digits"},
{"99999999999999999999sec", "integer overflow"},
}
for _, tt := range tests {
t.Run(tt.desc, func(t *testing.T) {
_, _, err := Parse(tt.input)
if err == nil {
t.Fatal("expected error, got nil")
}
})
}
}
func TestToDuration(t *testing.T) {
tests := []struct {
unit string
want time.Duration
}{
{"sec", time.Second},
{"min", time.Minute},
{"hour", time.Hour},
{"day", 24 * time.Hour},
{"week", 7 * 24 * time.Hour},
{"month", 30 * 24 * time.Hour},
{"year", 365 * 24 * time.Hour},
}
for _, tt := range tests {
t.Run(tt.unit, func(t *testing.T) {
got, err := ToDuration(1, tt.unit)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("ToDuration(1, %q) = %v, want %v", tt.unit, got, tt.want)
}
})
}
}
func TestToDurationMultiplier(t *testing.T) {
tests := []struct {
value int
unit string
want time.Duration
}{
{3, "sec", 3 * time.Second},
{5, "min", 5 * time.Minute},
{2, "hour", 2 * time.Hour},
{5, "day", 5 * 24 * time.Hour},
{3, "week", 3 * 7 * 24 * time.Hour},
{2, "month", 2 * 30 * 24 * time.Hour},
{4, "year", 4 * 365 * 24 * time.Hour},
}
for _, tt := range tests {
t.Run(fmt.Sprintf("%d_%s", tt.value, tt.unit), func(t *testing.T) {
got, err := ToDuration(tt.value, tt.unit)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if got != tt.want {
t.Errorf("ToDuration(%d, %q) = %v, want %v", tt.value, tt.unit, got, tt.want)
}
})
}
}
func TestToDurationUnknownUnit(t *testing.T) {
_, err := ToDuration(1, "unknown")
if err == nil {
t.Fatal("expected error for unknown unit")
}
}
func TestIsValidUnit(t *testing.T) {
for _, u := range units {
if !IsValidUnit(u.name) {
t.Errorf("IsValidUnit(%q) = false, want true", u.name)
}
}
if IsValidUnit("minute") {
t.Error("IsValidUnit(\"minute\") = true, want false")
}
if IsValidUnit("bogus") {
t.Error("IsValidUnit(\"bogus\") = true, want false")
}
}
func TestPatternContainsAllUnits(t *testing.T) {
p := Pattern()
for _, u := range units {
if !strings.Contains(p, u.name) {
t.Errorf("Pattern() missing unit %q", u.name)
}
}
}
func TestPatternRegexMatchesAllUnits(t *testing.T) {
re := regexp.MustCompile(`^\d+(?:` + Pattern() + `)s?$`)
for _, u := range units {
singular := "1" + u.name
if !re.MatchString(singular) {
t.Errorf("pattern does not match %q", singular)
}
plural := "1" + u.name + "s"
if !re.MatchString(plural) {
t.Errorf("pattern does not match %q", plural)
}
}
}
func TestPatternLongestFirst(t *testing.T) {
p := Pattern()
// "month" must appear before "min" so regex matches greedily
monthIdx := strings.Index(p, "month")
minIdx := strings.Index(p, "min")
if monthIdx > minIdx {
t.Errorf("Pattern() has 'min' before 'month': %s", p)
}
}