mirror of
https://github.com/boolean-maybe/tiki
synced 2026-04-21 13:37:20 +00:00
execute create and delete
This commit is contained in:
parent
d78b187ac0
commit
ca5a16e984
15 changed files with 1140 additions and 119 deletions
|
|
@ -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`
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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: `"(?:[^"\\]|\\.)*"`},
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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
86
util/duration/duration.go
Normal 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, "|")
|
||||
}
|
||||
175
util/duration/duration_test.go
Normal file
175
util/duration/duration_test.go
Normal 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)
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue