diff --git a/.doc/doki/doc/ruki/semantics.md b/.doc/doki/doc/ruki/semantics.md index 4f25679..796f6c8 100644 --- a/.doc/doki/doc/ruki/semantics.md +++ b/.doc/doki/doc/ruki/semantics.md @@ -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` diff --git a/.doc/doki/doc/ruki/types-and-values.md b/.doc/doki/doc/ruki/types-and-values.md index ad914d0..b3ad284 100644 --- a/.doc/doki/doc/ruki/types-and-values.md +++ b/.doc/doki/doc/ruki/types-and-values.md @@ -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 diff --git a/.doc/doki/doc/ruki/validation-and-errors.md b/.doc/doki/doc/ruki/validation-and-errors.md index 4ed8114..b1f1fb7 100644 --- a/.doc/doki/doc/ruki/validation-and-errors.md +++ b/.doc/doki/doc/ruki/validation-and-errors.md @@ -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: diff --git a/internal/ruki/runtime/runner.go b/internal/ruki/runtime/runner.go index cbcff53..beb60d7 100644 --- a/internal/ruki/runtime/runner.go +++ b/internal/ruki/runtime/runner.go @@ -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) { diff --git a/internal/ruki/runtime/runner_test.go b/internal/ruki/runtime/runner_test.go index 292a143..1adee2d 100644 --- a/internal/ruki/runtime/runner_test.go +++ b/internal/ruki/runtime/runner_test.go @@ -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) +} diff --git a/plugin/filter/duration.go b/plugin/filter/duration.go index a4bd87f..c9be9d3 100644 --- a/plugin/filter/duration.go +++ b/plugin/filter/duration.go @@ -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) } diff --git a/plugin/filter/filter_time_test.go b/plugin/filter/filter_time_test.go index 62f38f8..a6fd01b 100644 --- a/plugin/filter/filter_time_test.go +++ b/plugin/filter/filter_time_test.go @@ -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)", diff --git a/ruki/executor.go b/ruki/executor.go index c7449cc..52dcc9f 100644 --- a/ruki/executor.go +++ b/ruki/executor.go @@ -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 - } -} diff --git a/ruki/executor_test.go b/ruki/executor_test.go index b465ee4..58bc088 100644 --- a/ruki/executor_test.go +++ b/ruki/executor_test.go @@ -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") } } diff --git a/ruki/lexer.go b/ruki/lexer.go index 0a317df..8d2698a 100644 --- a/ruki/lexer.go +++ b/ruki/lexer.go @@ -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: `"(?:[^"\\]|\\.)*"`}, diff --git a/ruki/lower.go b/ruki/lower.go index db6dbe3..1c55ae4 100644 --- a/ruki/lower.go +++ b/ruki/lower.go @@ -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 } diff --git a/store/memory_store.go b/store/memory_store.go index 8c385fc..6a43822 100644 --- a/store/memory_store.go +++ b/store/memory_store.go @@ -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 diff --git a/store/memory_store_test.go b/store/memory_store_test.go index 874e027..a7fd0ee 100644 --- a/store/memory_store_test.go +++ b/store/memory_store_test.go @@ -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() diff --git a/util/duration/duration.go b/util/duration/duration.go new file mode 100644 index 0000000..6aa38bd --- /dev/null +++ b/util/duration/duration.go @@ -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, "|") +} diff --git a/util/duration/duration_test.go b/util/duration/duration_test.go new file mode 100644 index 0000000..da83e3c --- /dev/null +++ b/util/duration/duration_test.go @@ -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) + } +}