|
|
@ -5,6 +5,7 @@
|
|||
- [Markdown viewer](markdown-viewer.md)
|
||||
- [Image support](image-requirements.md)
|
||||
- [Customization](customization.md)
|
||||
- [Ruki](ruki/index.md)
|
||||
- [tiki format](tiki-format.md)
|
||||
- [Quick capture](quick-capture.md)
|
||||
- [AI skills](skills.md)
|
||||
272
.doc/doki/doc/ruki/examples.md
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
# Examples
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Simple statements](#simple-statements)
|
||||
- [Conditions and lists](#conditions-and-lists)
|
||||
- [Functions and dates](#functions-and-dates)
|
||||
- [Before triggers](#before-triggers)
|
||||
- [After triggers](#after-triggers)
|
||||
- [Invalid examples](#invalid-examples)
|
||||
|
||||
## Overview
|
||||
|
||||
The examples show common patterns, useful combinations, and a few edge cases.
|
||||
|
||||
## Simple statements
|
||||
|
||||
Select all tikis:
|
||||
|
||||
```sql
|
||||
select
|
||||
```
|
||||
|
||||
Select with a basic filter:
|
||||
|
||||
```sql
|
||||
select where status = "done" and priority <= 2
|
||||
```
|
||||
|
||||
Select specific fields:
|
||||
|
||||
```sql
|
||||
select title, status
|
||||
select id, title where status = "done"
|
||||
select * where priority <= 2
|
||||
select title, status where "bug" in tags order by priority
|
||||
```
|
||||
|
||||
Select with ordering:
|
||||
|
||||
```sql
|
||||
select order by priority
|
||||
select where status = "done" order by updatedAt desc
|
||||
select where "bug" in tags order by priority asc, createdAt desc
|
||||
```
|
||||
|
||||
Create a tiki:
|
||||
|
||||
```sql
|
||||
create title="Fix login" priority=2 status="ready" tags=["bug"]
|
||||
```
|
||||
|
||||
Update matching tikis:
|
||||
|
||||
```sql
|
||||
update where status = "ready" and "sprint-3" in tags set status="cancelled"
|
||||
```
|
||||
|
||||
Delete matching tikis:
|
||||
|
||||
```sql
|
||||
delete where status = "cancelled" and "old" in tags
|
||||
```
|
||||
|
||||
## Conditions and lists
|
||||
|
||||
Check whether a tag is present:
|
||||
|
||||
```sql
|
||||
select where "bug" in tags
|
||||
```
|
||||
|
||||
Check whether one tiki depends on another:
|
||||
|
||||
```sql
|
||||
select where id in dependsOn
|
||||
```
|
||||
|
||||
Check whether status is one of several values:
|
||||
|
||||
```sql
|
||||
select where status in ["done", "cancelled"]
|
||||
```
|
||||
|
||||
Check whether dependencies match a condition:
|
||||
|
||||
```sql
|
||||
select where dependsOn any status != "done"
|
||||
select where dependsOn all status = "done"
|
||||
```
|
||||
|
||||
Boolean grouping:
|
||||
|
||||
```sql
|
||||
select where not (status = "done" or priority = 1)
|
||||
```
|
||||
|
||||
## Functions and dates
|
||||
|
||||
Count matching tikis:
|
||||
|
||||
```sql
|
||||
select where count(select where status = "done") >= 1
|
||||
```
|
||||
|
||||
Current user:
|
||||
|
||||
```sql
|
||||
select where assignee = user()
|
||||
```
|
||||
|
||||
Compare timestamps:
|
||||
|
||||
```sql
|
||||
select where updatedAt < now()
|
||||
select where updatedAt - createdAt > 1day
|
||||
```
|
||||
|
||||
Calculate a date from recurrence:
|
||||
|
||||
```sql
|
||||
create title="x" due=next_date(recurrence)
|
||||
```
|
||||
|
||||
Add to or remove from a list:
|
||||
|
||||
```sql
|
||||
create title="x" tags=tags + ["needs-triage"]
|
||||
create title="x" dependsOn=dependsOn - ["TIKI-ABC123"]
|
||||
```
|
||||
|
||||
Use `call(...)` in a value:
|
||||
|
||||
```sql
|
||||
create title=call("echo hi")
|
||||
```
|
||||
|
||||
## Before triggers
|
||||
|
||||
Block completion when dependencies remain open:
|
||||
|
||||
```sql
|
||||
before update where new.status = "done" and dependsOn any status != "done" deny "cannot complete tiki with open dependencies"
|
||||
```
|
||||
|
||||
Require a description for high-priority work:
|
||||
|
||||
```sql
|
||||
before update where new.priority <= 2 and new.description is empty deny "high priority tikis need a description"
|
||||
```
|
||||
|
||||
Limit how many in-progress tikis someone can have:
|
||||
|
||||
```sql
|
||||
before update where new.status = "in progress" and count(select where assignee = new.assignee and status = "in progress") >= 3 deny "WIP limit reached for this assignee"
|
||||
```
|
||||
|
||||
## After triggers
|
||||
|
||||
Auto-assign urgent new work:
|
||||
|
||||
```sql
|
||||
after create where new.priority <= 2 and new.assignee is empty update where id = new.id set assignee="booleanmaybe"
|
||||
```
|
||||
|
||||
Create the next recurring tiki:
|
||||
|
||||
```sql
|
||||
after update where new.status = "done" and old.recurrence is not empty create title=old.title priority=old.priority tags=old.tags recurrence=old.recurrence due=next_date(old.recurrence) status="ready"
|
||||
```
|
||||
|
||||
Clear recurrence on the completed source tiki:
|
||||
|
||||
```sql
|
||||
after update where new.status = "done" and old.recurrence is not empty update where id = old.id set recurrence=empty
|
||||
```
|
||||
|
||||
Clean up reverse dependencies on delete:
|
||||
|
||||
```sql
|
||||
after delete update where old.id in dependsOn set dependsOn=dependsOn - [old.id]
|
||||
```
|
||||
|
||||
Run a command after an update:
|
||||
|
||||
```sql
|
||||
after update where new.status = "in progress" and "claude" in new.tags run("claude -p 'implement tiki " + old.id + "'")
|
||||
```
|
||||
|
||||
## Time triggers
|
||||
|
||||
Move stale in-progress tasks back to backlog:
|
||||
|
||||
```sql
|
||||
every 1hour update where status = "in_progress" and updatedAt < now() - 7day set status="backlog"
|
||||
```
|
||||
|
||||
Delete expired done tasks:
|
||||
|
||||
```sql
|
||||
every 1day delete where status = "done" and updatedAt < now() - 30day
|
||||
```
|
||||
|
||||
Create a weekly review task:
|
||||
|
||||
```sql
|
||||
every 1week create title="weekly review" status="ready" priority=3
|
||||
```
|
||||
|
||||
## Invalid examples
|
||||
|
||||
Unknown field:
|
||||
|
||||
```sql
|
||||
select where foo = "bar"
|
||||
```
|
||||
|
||||
Wrong field value type:
|
||||
|
||||
```sql
|
||||
create title="x" priority="high"
|
||||
```
|
||||
|
||||
Using the wrong operator with status:
|
||||
|
||||
```sql
|
||||
select where status < "done"
|
||||
```
|
||||
|
||||
Using `any` on the wrong kind of field:
|
||||
|
||||
```sql
|
||||
select where tags any status = "done"
|
||||
```
|
||||
|
||||
Using `old.` where it is not allowed:
|
||||
|
||||
```sql
|
||||
select where old.status = "done"
|
||||
```
|
||||
|
||||
A list with mixed value types:
|
||||
|
||||
```sql
|
||||
select where status in ["done", 1]
|
||||
```
|
||||
|
||||
Trigger is missing the right action:
|
||||
|
||||
```sql
|
||||
after update where new.status = "done" deny "no"
|
||||
```
|
||||
|
||||
Non-string `run(...)` command:
|
||||
|
||||
```sql
|
||||
after update run(1 + 2)
|
||||
```
|
||||
|
||||
Ordering by a non-orderable field:
|
||||
|
||||
```sql
|
||||
select order by tags
|
||||
select order by dependsOn
|
||||
```
|
||||
|
||||
Order by inside a subquery:
|
||||
|
||||
```sql
|
||||
select where count(select where status = "done" order by priority) >= 1
|
||||
```
|
||||
202
.doc/doki/doc/ruki/images/binary-op-types.svg
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 780 580">
|
||||
<!--
|
||||
Grid system:
|
||||
- Column width: 84px, row height: 28px
|
||||
- Row header: 90px wide
|
||||
- Header bar: 26px tall
|
||||
- All fills use opacity for dark/light background compatibility
|
||||
- Valid cells: green pill with result type
|
||||
- Invalid cells: dim dash
|
||||
-->
|
||||
<defs>
|
||||
<style>
|
||||
.title { font: 700 16px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #A5B4FC; }
|
||||
.op-title { font: 700 14px -apple-system, 'Segoe UI', Roboto, sans-serif; }
|
||||
.hdr { font: 600 11px 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; fill: #E2E8F0; }
|
||||
.rhdr { font: 600 11px 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; fill: #94A3B8; }
|
||||
.cell-ok { font: 600 10px 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; fill: #34D399; }
|
||||
.cell-na { font: 400 11px -apple-system, sans-serif; fill: #475569; }
|
||||
.footnote { font: italic 11px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #64748B; }
|
||||
.legend-text { font: 400 11px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #94A3B8; }
|
||||
</style>
|
||||
<filter id="shadow" x="-4%" y="-4%" width="108%" height="116%">
|
||||
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-color="#000000" flood-opacity="0.3"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<text class="title" x="390" y="24" text-anchor="middle">Binary operator type resolution</text>
|
||||
|
||||
<!-- ==================== + OPERATOR ==================== -->
|
||||
<g transform="translate(20, 44)">
|
||||
<!-- Operator label -->
|
||||
<rect x="0" y="0" width="26" height="26" rx="7" fill="#818CF8" fill-opacity="0.25" stroke="#818CF8" stroke-width="1" stroke-opacity="0.4" filter="url(#shadow)"/>
|
||||
<text x="13" y="17" text-anchor="middle" font-size="16" font-weight="700" fill="#A5B4FC">+</text>
|
||||
<text class="op-title" x="36" y="17" fill="#A5B4FC">addition / concatenation</text>
|
||||
|
||||
<!-- Column header bar -->
|
||||
<rect x="90" y="34" width="646" height="26" rx="6" fill="#334155" fill-opacity="0.6"/>
|
||||
<text class="hdr" x="178" y="51" text-anchor="middle">string</text>
|
||||
<text class="hdr" x="264" y="51" text-anchor="middle">int</text>
|
||||
<text class="hdr" x="350" y="51" text-anchor="middle">date</text>
|
||||
<text class="hdr" x="436" y="51" text-anchor="middle">tstamp</text>
|
||||
<text class="hdr" x="522" y="51" text-anchor="middle">duration</text>
|
||||
<text class="hdr" x="608" y="51" text-anchor="middle">list<str></text>
|
||||
<text class="hdr" x="694" y="51" text-anchor="middle">list<ref></text>
|
||||
|
||||
<!-- Row 1: string -->
|
||||
<text class="rhdr" x="50" y="79" text-anchor="middle">string</text>
|
||||
<rect x="140" y="66" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
|
||||
<text class="cell-ok" x="178" y="81" text-anchor="middle">string</text>
|
||||
<text class="cell-na" x="264" y="81" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="350" y="81" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="436" y="81" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="522" y="81" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="608" y="81" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="694" y="81" text-anchor="middle">—</text>
|
||||
|
||||
<!-- Row 2: int (alternating row background) -->
|
||||
<rect x="90" y="92" width="646" height="28" rx="4" fill="#94A3B8" fill-opacity="0.04"/>
|
||||
<text class="rhdr" x="50" y="107" text-anchor="middle">int</text>
|
||||
<text class="cell-na" x="178" y="107" text-anchor="middle">—</text>
|
||||
<rect x="226" y="94" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
|
||||
<text class="cell-ok" x="264" y="109" text-anchor="middle">int</text>
|
||||
<text class="cell-na" x="350" y="107" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="436" y="107" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="522" y="107" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="608" y="107" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="694" y="107" text-anchor="middle">—</text>
|
||||
|
||||
<!-- Row 3: date -->
|
||||
<text class="rhdr" x="50" y="135" text-anchor="middle">date</text>
|
||||
<text class="cell-na" x="178" y="135" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="264" y="135" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="350" y="135" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="436" y="135" text-anchor="middle">—</text>
|
||||
<rect x="484" y="122" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
|
||||
<text class="cell-ok" x="522" y="137" text-anchor="middle">date</text>
|
||||
<text class="cell-na" x="608" y="135" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="694" y="135" text-anchor="middle">—</text>
|
||||
|
||||
<!-- Row 4: timestamp (alternating row background) -->
|
||||
<rect x="90" y="148" width="646" height="28" rx="4" fill="#94A3B8" fill-opacity="0.04"/>
|
||||
<text class="rhdr" x="50" y="163" text-anchor="middle">tstamp</text>
|
||||
<text class="cell-na" x="178" y="163" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="264" y="163" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="350" y="163" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="436" y="163" text-anchor="middle">—</text>
|
||||
<rect x="484" y="150" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
|
||||
<text class="cell-ok" x="522" y="165" text-anchor="middle">tstamp</text>
|
||||
<text class="cell-na" x="608" y="163" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="694" y="163" text-anchor="middle">—</text>
|
||||
|
||||
<!-- Row 5: list<string> -->
|
||||
<text class="rhdr" x="50" y="191" text-anchor="middle">list<str></text>
|
||||
<rect x="140" y="178" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
|
||||
<text class="cell-ok" x="178" y="193" text-anchor="middle">list<str></text>
|
||||
<text class="cell-na" x="264" y="191" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="350" y="191" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="436" y="191" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="522" y="191" text-anchor="middle">—</text>
|
||||
<rect x="570" y="178" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
|
||||
<text class="cell-ok" x="608" y="193" text-anchor="middle">list<str></text>
|
||||
<text class="cell-na" x="694" y="191" text-anchor="middle">—</text>
|
||||
|
||||
<!-- Row 6: list<ref> (alternating row background) -->
|
||||
<rect x="90" y="204" width="646" height="28" rx="4" fill="#94A3B8" fill-opacity="0.04"/>
|
||||
<text class="rhdr" x="50" y="219" text-anchor="middle">list<ref></text>
|
||||
<text class="cell-na" x="178" y="219" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="264" y="219" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="350" y="219" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="436" y="219" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="522" y="219" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="608" y="219" text-anchor="middle">—</text>
|
||||
<rect x="656" y="206" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
|
||||
<text class="cell-ok" x="694" y="221" text-anchor="middle">list<ref></text>
|
||||
</g>
|
||||
|
||||
<!-- ==================== - OPERATOR ==================== -->
|
||||
<g transform="translate(20, 300)">
|
||||
<!-- Operator label -->
|
||||
<rect x="0" y="0" width="26" height="26" rx="7" fill="#F87171" fill-opacity="0.25" stroke="#F87171" stroke-width="1" stroke-opacity="0.4" filter="url(#shadow)"/>
|
||||
<text x="13" y="17" text-anchor="middle" font-size="16" font-weight="700" fill="#F87171">−</text>
|
||||
<text class="op-title" x="36" y="17" fill="#F87171">subtraction / removal</text>
|
||||
|
||||
<!-- Column header bar -->
|
||||
<rect x="90" y="34" width="646" height="26" rx="6" fill="#334155" fill-opacity="0.6"/>
|
||||
<text class="hdr" x="178" y="51" text-anchor="middle">string</text>
|
||||
<text class="hdr" x="264" y="51" text-anchor="middle">int</text>
|
||||
<text class="hdr" x="350" y="51" text-anchor="middle">date</text>
|
||||
<text class="hdr" x="436" y="51" text-anchor="middle">tstamp</text>
|
||||
<text class="hdr" x="522" y="51" text-anchor="middle">duration</text>
|
||||
<text class="hdr" x="608" y="51" text-anchor="middle">list<str></text>
|
||||
<text class="hdr" x="694" y="51" text-anchor="middle">list<ref></text>
|
||||
|
||||
<!-- Row 1: int -->
|
||||
<text class="rhdr" x="50" y="79" text-anchor="middle">int</text>
|
||||
<text class="cell-na" x="178" y="79" text-anchor="middle">—</text>
|
||||
<rect x="226" y="66" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
|
||||
<text class="cell-ok" x="264" y="81" text-anchor="middle">int</text>
|
||||
<text class="cell-na" x="350" y="79" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="436" y="79" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="522" y="79" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="608" y="79" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="694" y="79" text-anchor="middle">—</text>
|
||||
|
||||
<!-- Row 2: date (alternating row background) -->
|
||||
<rect x="90" y="92" width="646" height="28" rx="4" fill="#94A3B8" fill-opacity="0.04"/>
|
||||
<text class="rhdr" x="50" y="107" text-anchor="middle">date</text>
|
||||
<text class="cell-na" x="178" y="107" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="264" y="107" text-anchor="middle">—</text>
|
||||
<rect x="312" y="94" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
|
||||
<text class="cell-ok" x="350" y="109" text-anchor="middle">duration</text>
|
||||
<text class="cell-na" x="436" y="107" text-anchor="middle">—</text>
|
||||
<rect x="484" y="94" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
|
||||
<text class="cell-ok" x="522" y="109" text-anchor="middle">date</text>
|
||||
<text class="cell-na" x="608" y="107" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="694" y="107" text-anchor="middle">—</text>
|
||||
|
||||
<!-- Row 3: timestamp -->
|
||||
<text class="rhdr" x="50" y="135" text-anchor="middle">tstamp</text>
|
||||
<text class="cell-na" x="178" y="135" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="264" y="135" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="350" y="135" text-anchor="middle">—</text>
|
||||
<rect x="398" y="122" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
|
||||
<text class="cell-ok" x="436" y="137" text-anchor="middle">duration</text>
|
||||
<rect x="484" y="122" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
|
||||
<text class="cell-ok" x="522" y="137" text-anchor="middle">tstamp</text>
|
||||
<text class="cell-na" x="608" y="135" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="694" y="135" text-anchor="middle">—</text>
|
||||
|
||||
<!-- Row 4: list<string> (alternating row background) -->
|
||||
<rect x="90" y="148" width="646" height="28" rx="4" fill="#94A3B8" fill-opacity="0.04"/>
|
||||
<text class="rhdr" x="50" y="163" text-anchor="middle">list<str></text>
|
||||
<rect x="140" y="150" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
|
||||
<text class="cell-ok" x="178" y="165" text-anchor="middle">list<str></text>
|
||||
<text class="cell-na" x="264" y="163" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="350" y="163" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="436" y="163" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="522" y="163" text-anchor="middle">—</text>
|
||||
<rect x="570" y="150" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
|
||||
<text class="cell-ok" x="608" y="165" text-anchor="middle">list<str></text>
|
||||
<text class="cell-na" x="694" y="163" text-anchor="middle">—</text>
|
||||
|
||||
<!-- Row 5: list<ref> -->
|
||||
<text class="rhdr" x="50" y="191" text-anchor="middle">list<ref></text>
|
||||
<text class="cell-na" x="178" y="191" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="264" y="191" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="350" y="191" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="436" y="191" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="522" y="191" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="608" y="191" text-anchor="middle">—</text>
|
||||
<rect x="656" y="178" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
|
||||
<text class="cell-ok" x="694" y="193" text-anchor="middle">list<ref></text>
|
||||
</g>
|
||||
|
||||
<!-- Legend + footnotes -->
|
||||
<rect x="270" y="520" width="14" height="14" rx="7" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<text class="legend-text" x="290" y="531">valid (shows result type)</text>
|
||||
<text class="legend-text" x="460" y="531">— invalid</text>
|
||||
|
||||
<text class="footnote" x="390" y="554" text-anchor="middle">string includes string-like types: string, status, type, id, ref</text>
|
||||
<text class="footnote" x="390" y="570" text-anchor="middle">list<ref> + also accepts bare id/ref values on the right side</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
339
.doc/doki/doc/ruki/images/cond-railroad.svg
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 888">
|
||||
<!--
|
||||
Condition grammar railroad diagram — transparent/dark-bg compatible
|
||||
Tracks: condition, orCond, andCond, notCond, primaryCond, exprCond
|
||||
-->
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="10" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 3.5 L 0 7 z" fill="#94A3B8"/>
|
||||
</marker>
|
||||
<filter id="shadow" x="-4%" y="-4%" width="108%" height="116%">
|
||||
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-color="#000000" flood-opacity="0.3"/>
|
||||
</filter>
|
||||
<style>
|
||||
.title { font: 700 16px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #A5B4FC; }
|
||||
.label { font: bold 14px 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; fill: #94A3B8; }
|
||||
.terminal rect { fill: #3B82F6; fill-opacity: 0.15; stroke: #3B82F6; stroke-width: 1.5; stroke-opacity: 0.5; filter: url(#shadow); }
|
||||
.terminal text { font: 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #93C5FD; font-weight: 600; }
|
||||
.nonterminal rect { fill: #F59E0B; fill-opacity: 0.15; stroke: #F59E0B; stroke-width: 1.5; stroke-opacity: 0.5; filter: url(#shadow); }
|
||||
.nonterminal text { font: 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #FCD34D; font-weight: 500; }
|
||||
.track { stroke: #94A3B8; stroke-width: 1.5; fill: none; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<!-- title -->
|
||||
<text class="title" x="450" y="22" text-anchor="middle">Condition grammar</text>
|
||||
|
||||
<!-- all track content shifted down 8px for title clearance -->
|
||||
<g transform="translate(0, 8)">
|
||||
|
||||
<!-- ==================== condition ==================== -->
|
||||
<g transform="translate(0, 20)">
|
||||
<g>
|
||||
<rect x="2" y="22" width="92" height="28" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
|
||||
<text class="label" x="10" y="36" text-anchor="start">condition</text>
|
||||
</g>
|
||||
|
||||
<circle cx="130" cy="32" r="4" fill="#94A3B8"/>
|
||||
<circle cx="130" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<line class="track" x1="134" y1="32" x2="160" y2="32"/>
|
||||
|
||||
<g class="nonterminal" transform="translate(160, 16)">
|
||||
<rect width="82" height="32" rx="4"/>
|
||||
<text x="41" y="16" text-anchor="middle" dominant-baseline="central">orCond</text>
|
||||
</g>
|
||||
|
||||
<line class="track" x1="242" y1="32" x2="280" y2="32"/>
|
||||
<circle cx="280" cy="32" r="4" fill="#94A3B8"/>
|
||||
<circle cx="280" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
</g>
|
||||
|
||||
<!-- separator -->
|
||||
<line x1="10" y1="80" x2="890" y2="80" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- ==================== orCond ==================== -->
|
||||
<!-- andCond with loopback via "or" below -->
|
||||
<g transform="translate(0, 100)">
|
||||
<g>
|
||||
<rect x="2" y="22" width="66" height="28" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
|
||||
<text class="label" x="10" y="36" text-anchor="start">orCond</text>
|
||||
</g>
|
||||
|
||||
<circle cx="130" cy="32" r="4" fill="#94A3B8"/>
|
||||
<circle cx="130" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<line class="track" x1="134" y1="32" x2="170" y2="32"/>
|
||||
|
||||
<g class="nonterminal" transform="translate(170, 16)">
|
||||
<rect width="96" height="32" rx="4"/>
|
||||
<text x="48" y="16" text-anchor="middle" dominant-baseline="central">andCond</text>
|
||||
</g>
|
||||
<line class="track" x1="266" y1="32" x2="320" y2="32"/>
|
||||
|
||||
<!-- loopback below with "or" keyword -->
|
||||
<path class="track" d="M 310,32 C 310,62 300,76 280,76 L 240,76"/>
|
||||
<g class="terminal" transform="translate(200, 60)">
|
||||
<rect width="40" height="32" rx="16"/>
|
||||
<text x="20" y="16" text-anchor="middle" dominant-baseline="central">or</text>
|
||||
</g>
|
||||
<path class="track" d="M 200,76 C 180,76 170,62 170,42"/>
|
||||
<path d="M 165,46 L 170,36 L 175,46 z" fill="#94A3B8"/>
|
||||
|
||||
<circle cx="320" cy="32" r="4" fill="#94A3B8"/>
|
||||
<circle cx="320" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
</g>
|
||||
|
||||
<!-- separator -->
|
||||
<line x1="10" y1="195" x2="890" y2="195" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- ==================== andCond ==================== -->
|
||||
<!-- notCond with loopback via "and" below -->
|
||||
<g transform="translate(0, 210)">
|
||||
<g>
|
||||
<rect x="2" y="22" width="75" height="28" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
|
||||
<text class="label" x="10" y="36" text-anchor="start">andCond</text>
|
||||
</g>
|
||||
|
||||
<circle cx="130" cy="32" r="4" fill="#94A3B8"/>
|
||||
<circle cx="130" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<line class="track" x1="134" y1="32" x2="170" y2="32"/>
|
||||
|
||||
<g class="nonterminal" transform="translate(170, 16)">
|
||||
<rect width="96" height="32" rx="4"/>
|
||||
<text x="48" y="16" text-anchor="middle" dominant-baseline="central">notCond</text>
|
||||
</g>
|
||||
<line class="track" x1="266" y1="32" x2="320" y2="32"/>
|
||||
|
||||
<!-- loopback below with "and" keyword -->
|
||||
<path class="track" d="M 310,32 C 310,62 300,76 280,76 L 240,76"/>
|
||||
<g class="terminal" transform="translate(195, 60)">
|
||||
<rect width="50" height="32" rx="16"/>
|
||||
<text x="25" y="16" text-anchor="middle" dominant-baseline="central">and</text>
|
||||
</g>
|
||||
<path class="track" d="M 195,76 C 175,76 170,62 170,42"/>
|
||||
<path d="M 165,46 L 170,36 L 175,46 z" fill="#94A3B8"/>
|
||||
|
||||
<circle cx="320" cy="32" r="4" fill="#94A3B8"/>
|
||||
<circle cx="320" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
</g>
|
||||
|
||||
<!-- separator -->
|
||||
<line x1="10" y1="305" x2="890" y2="305" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- ==================== notCond ==================== -->
|
||||
<!-- Two alternatives: "not" notCond (recursive) | primaryCond -->
|
||||
<g transform="translate(0, 320)">
|
||||
<g>
|
||||
<rect x="2" y="40" width="75" height="28" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
|
||||
<text class="label" x="10" y="54" text-anchor="start">notCond</text>
|
||||
</g>
|
||||
|
||||
<circle cx="130" cy="50" r="4" fill="#94A3B8"/>
|
||||
<circle cx="130" cy="50" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<line class="track" x1="134" y1="50" x2="170" y2="50"/>
|
||||
|
||||
<!-- upper: not + notCond -->
|
||||
<path class="track" d="M 170,50 C 170,20 180,10 200,10"/>
|
||||
<g class="terminal" transform="translate(200, -6)">
|
||||
<rect width="50" height="32" rx="16"/>
|
||||
<text x="25" y="16" text-anchor="middle" dominant-baseline="central">not</text>
|
||||
</g>
|
||||
<line class="track" x1="250" y1="10" x2="274" y2="10"/>
|
||||
<g class="nonterminal" transform="translate(274, -6)">
|
||||
<rect width="96" height="32" rx="4"/>
|
||||
<text x="48" y="16" text-anchor="middle" dominant-baseline="central">notCond</text>
|
||||
</g>
|
||||
<path class="track" d="M 370,10 C 390,10 400,20 400,50"/>
|
||||
|
||||
<!-- lower: primaryCond (straight through) -->
|
||||
<line class="track" x1="170" y1="50" x2="200" y2="50"/>
|
||||
<g class="nonterminal" transform="translate(200, 34)">
|
||||
<rect width="130" height="32" rx="4"/>
|
||||
<text x="65" y="16" text-anchor="middle" dominant-baseline="central">primaryCond</text>
|
||||
</g>
|
||||
<line class="track" x1="330" y1="50" x2="400" y2="50"/>
|
||||
|
||||
<line class="track" x1="400" y1="50" x2="440" y2="50"/>
|
||||
<circle cx="440" cy="50" r="4" fill="#94A3B8"/>
|
||||
<circle cx="440" cy="50" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
</g>
|
||||
|
||||
<!-- separator -->
|
||||
<line x1="10" y1="420" x2="890" y2="420" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- ==================== primaryCond ==================== -->
|
||||
<!-- Two alternatives: ( condition ) | exprCond -->
|
||||
<g transform="translate(0, 440)">
|
||||
<g>
|
||||
<rect x="2" y="40" width="108" height="28" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
|
||||
<text class="label" x="10" y="54" text-anchor="start">primaryCond</text>
|
||||
</g>
|
||||
|
||||
<circle cx="140" cy="50" r="4" fill="#94A3B8"/>
|
||||
<circle cx="140" cy="50" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<line class="track" x1="144" y1="50" x2="180" y2="50"/>
|
||||
|
||||
<!-- upper: ( condition ) -->
|
||||
<path class="track" d="M 180,50 C 180,20 190,10 210,10"/>
|
||||
<g class="terminal" transform="translate(210, -6)">
|
||||
<rect width="30" height="32" rx="16"/>
|
||||
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">(</text>
|
||||
</g>
|
||||
<line class="track" x1="240" y1="10" x2="258" y2="10"/>
|
||||
<g class="nonterminal" transform="translate(258, -6)">
|
||||
<rect width="100" height="32" rx="4"/>
|
||||
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">condition</text>
|
||||
</g>
|
||||
<line class="track" x1="358" y1="10" x2="376" y2="10"/>
|
||||
<g class="terminal" transform="translate(376, -6)">
|
||||
<rect width="30" height="32" rx="16"/>
|
||||
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">)</text>
|
||||
</g>
|
||||
<path class="track" d="M 406,10 C 426,10 436,20 436,50"/>
|
||||
|
||||
<!-- lower: exprCond (straight through) -->
|
||||
<line class="track" x1="180" y1="50" x2="210" y2="50"/>
|
||||
<g class="nonterminal" transform="translate(210, 34)">
|
||||
<rect width="100" height="32" rx="4"/>
|
||||
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">exprCond</text>
|
||||
</g>
|
||||
<line class="track" x1="310" y1="50" x2="436" y2="50"/>
|
||||
|
||||
<line class="track" x1="436" y1="50" x2="476" y2="50"/>
|
||||
<circle cx="476" cy="50" r="4" fill="#94A3B8"/>
|
||||
<circle cx="476" cy="50" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
</g>
|
||||
|
||||
<!-- separator -->
|
||||
<line x1="10" y1="537" x2="890" y2="537" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- ==================== exprCond ==================== -->
|
||||
<!-- expr followed by optional tail: 7 alternatives or bypass -->
|
||||
<g transform="translate(0, 555)">
|
||||
<g>
|
||||
<rect x="2" y="40" width="83" height="28" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
|
||||
<text class="label" x="10" y="54" text-anchor="start">exprCond</text>
|
||||
</g>
|
||||
|
||||
<circle cx="130" cy="50" r="4" fill="#94A3B8"/>
|
||||
<circle cx="130" cy="50" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<line class="track" x1="134" y1="50" x2="160" y2="50"/>
|
||||
|
||||
<g class="nonterminal" transform="translate(160, 34)">
|
||||
<rect width="60" height="32" rx="4"/>
|
||||
<text x="30" y="16" text-anchor="middle" dominant-baseline="central">expr</text>
|
||||
</g>
|
||||
<line class="track" x1="220" y1="50" x2="260" y2="50"/>
|
||||
|
||||
<!-- branch: bypass (no tail) above, or one of 7 tails -->
|
||||
<!-- bypass: straight across above -->
|
||||
<path class="track" d="M 250,50 C 250,15 260,5 280,5 L 730,5 C 750,5 760,15 760,50"/>
|
||||
|
||||
<!-- compareTail: compareOp + expr -->
|
||||
<path class="track" d="M 260,50 C 260,68 270,78 290,78"/>
|
||||
<g class="nonterminal" transform="translate(290, 62)">
|
||||
<rect width="110" height="32" rx="4"/>
|
||||
<text x="55" y="16" text-anchor="middle" dominant-baseline="central">compareOp</text>
|
||||
</g>
|
||||
<line class="track" x1="400" y1="78" x2="420" y2="78"/>
|
||||
<g class="nonterminal" transform="translate(420, 62)">
|
||||
<rect width="60" height="32" rx="4"/>
|
||||
<text x="30" y="16" text-anchor="middle" dominant-baseline="central">expr</text>
|
||||
</g>
|
||||
<path class="track" d="M 480,78 C 740,78 740,60 760,50"/>
|
||||
|
||||
<!-- isEmptyTail: is empty -->
|
||||
<path class="track" d="M 260,50 C 260,108 270,118 290,118"/>
|
||||
<g class="terminal" transform="translate(290, 102)">
|
||||
<rect width="36" height="32" rx="16"/>
|
||||
<text x="18" y="16" text-anchor="middle" dominant-baseline="central">is</text>
|
||||
</g>
|
||||
<line class="track" x1="326" y1="118" x2="346" y2="118"/>
|
||||
<g class="terminal" transform="translate(346, 102)">
|
||||
<rect width="70" height="32" rx="16"/>
|
||||
<text x="35" y="16" text-anchor="middle" dominant-baseline="central">empty</text>
|
||||
</g>
|
||||
<path class="track" d="M 416,118 C 740,118 740,60 760,50"/>
|
||||
|
||||
<!-- isNotEmptyTail: is not empty -->
|
||||
<path class="track" d="M 260,50 C 260,148 270,158 290,158"/>
|
||||
<g class="terminal" transform="translate(290, 142)">
|
||||
<rect width="36" height="32" rx="16"/>
|
||||
<text x="18" y="16" text-anchor="middle" dominant-baseline="central">is</text>
|
||||
</g>
|
||||
<line class="track" x1="326" y1="158" x2="346" y2="158"/>
|
||||
<g class="terminal" transform="translate(346, 142)">
|
||||
<rect width="50" height="32" rx="16"/>
|
||||
<text x="25" y="16" text-anchor="middle" dominant-baseline="central">not</text>
|
||||
</g>
|
||||
<line class="track" x1="396" y1="158" x2="416" y2="158"/>
|
||||
<g class="terminal" transform="translate(416, 142)">
|
||||
<rect width="70" height="32" rx="16"/>
|
||||
<text x="35" y="16" text-anchor="middle" dominant-baseline="central">empty</text>
|
||||
</g>
|
||||
<path class="track" d="M 486,158 C 740,158 740,60 760,50"/>
|
||||
|
||||
<!-- inTail: in expr -->
|
||||
<path class="track" d="M 260,50 C 260,188 270,198 290,198"/>
|
||||
<g class="terminal" transform="translate(290, 182)">
|
||||
<rect width="36" height="32" rx="16"/>
|
||||
<text x="18" y="16" text-anchor="middle" dominant-baseline="central">in</text>
|
||||
</g>
|
||||
<line class="track" x1="326" y1="198" x2="346" y2="198"/>
|
||||
<g class="nonterminal" transform="translate(346, 182)">
|
||||
<rect width="60" height="32" rx="4"/>
|
||||
<text x="30" y="16" text-anchor="middle" dominant-baseline="central">expr</text>
|
||||
</g>
|
||||
<path class="track" d="M 406,198 C 740,198 740,60 760,50"/>
|
||||
|
||||
<!-- notInTail: not in expr -->
|
||||
<path class="track" d="M 260,50 C 260,228 270,238 290,238"/>
|
||||
<g class="terminal" transform="translate(290, 222)">
|
||||
<rect width="50" height="32" rx="16"/>
|
||||
<text x="25" y="16" text-anchor="middle" dominant-baseline="central">not</text>
|
||||
</g>
|
||||
<line class="track" x1="340" y1="238" x2="360" y2="238"/>
|
||||
<g class="terminal" transform="translate(360, 222)">
|
||||
<rect width="36" height="32" rx="16"/>
|
||||
<text x="18" y="16" text-anchor="middle" dominant-baseline="central">in</text>
|
||||
</g>
|
||||
<line class="track" x1="396" y1="238" x2="416" y2="238"/>
|
||||
<g class="nonterminal" transform="translate(416, 222)">
|
||||
<rect width="60" height="32" rx="4"/>
|
||||
<text x="30" y="16" text-anchor="middle" dominant-baseline="central">expr</text>
|
||||
</g>
|
||||
<path class="track" d="M 476,238 C 740,238 740,60 760,50"/>
|
||||
|
||||
<!-- anyTail: any primaryCond -->
|
||||
<path class="track" d="M 260,50 C 260,268 270,278 290,278"/>
|
||||
<g class="terminal" transform="translate(290, 262)">
|
||||
<rect width="50" height="32" rx="16"/>
|
||||
<text x="25" y="16" text-anchor="middle" dominant-baseline="central">any</text>
|
||||
</g>
|
||||
<line class="track" x1="340" y1="278" x2="360" y2="278"/>
|
||||
<g class="nonterminal" transform="translate(360, 262)">
|
||||
<rect width="130" height="32" rx="4"/>
|
||||
<text x="65" y="16" text-anchor="middle" dominant-baseline="central">primaryCond</text>
|
||||
</g>
|
||||
<path class="track" d="M 490,278 C 740,278 740,60 760,50"/>
|
||||
|
||||
<!-- allTail: all primaryCond -->
|
||||
<path class="track" d="M 260,50 C 260,308 270,318 290,318"/>
|
||||
<g class="terminal" transform="translate(290, 302)">
|
||||
<rect width="50" height="32" rx="16"/>
|
||||
<text x="25" y="16" text-anchor="middle" dominant-baseline="central">all</text>
|
||||
</g>
|
||||
<line class="track" x1="340" y1="318" x2="360" y2="318"/>
|
||||
<g class="nonterminal" transform="translate(360, 302)">
|
||||
<rect width="130" height="32" rx="4"/>
|
||||
<text x="65" y="16" text-anchor="middle" dominant-baseline="central">primaryCond</text>
|
||||
</g>
|
||||
<path class="track" d="M 490,318 C 740,318 740,60 760,50"/>
|
||||
|
||||
<!-- exit -->
|
||||
<line class="track" x1="760" y1="50" x2="800" y2="50"/>
|
||||
<circle cx="800" cy="50" r="4" fill="#94A3B8"/>
|
||||
<circle cx="800" cy="50" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
</g>
|
||||
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 16 KiB |
303
.doc/doki/doc/ruki/images/expr-railroad.svg
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 712">
|
||||
<!--
|
||||
Expression grammar railroad diagram — transparent/dark-bg compatible
|
||||
Tracks: expr, unaryExpr, funcCall, qualifiedRef
|
||||
-->
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="10" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 3.5 L 0 7 z" fill="#94A3B8"/>
|
||||
</marker>
|
||||
<filter id="shadow" x="-4%" y="-4%" width="108%" height="116%">
|
||||
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-color="#000000" flood-opacity="0.3"/>
|
||||
</filter>
|
||||
<style>
|
||||
.title { font: 700 16px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #A5B4FC; }
|
||||
.label { font: bold 14px 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; fill: #94A3B8; }
|
||||
.terminal rect { fill: #3B82F6; fill-opacity: 0.15; stroke: #3B82F6; stroke-width: 1.5; stroke-opacity: 0.5; filter: url(#shadow); }
|
||||
.terminal text { font: 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #93C5FD; font-weight: 600; }
|
||||
.nonterminal rect { fill: #F59E0B; fill-opacity: 0.15; stroke: #F59E0B; stroke-width: 1.5; stroke-opacity: 0.5; filter: url(#shadow); }
|
||||
.nonterminal text { font: 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #FCD34D; font-weight: 500; }
|
||||
.lit-terminal rect { fill: #6366F1; fill-opacity: 0.15; stroke: #6366F1; stroke-width: 1.5; stroke-opacity: 0.5; filter: url(#shadow); }
|
||||
.lit-terminal text { font: 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #A5B4FC; font-weight: 500; }
|
||||
.track { stroke: #94A3B8; stroke-width: 1.5; fill: none; }
|
||||
.track-dot { fill: #94A3B8; }
|
||||
.label-pill { fill: #94A3B8; fill-opacity: 0.08; stroke: #94A3B8; stroke-width: 0.5; stroke-opacity: 0.2; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<!-- Title -->
|
||||
<text class="title" x="450" y="22" text-anchor="middle">Expression grammar</text>
|
||||
|
||||
<!-- All content shifted down 8px for title -->
|
||||
<g transform="translate(0, 8)">
|
||||
|
||||
<!-- ==================== expr ==================== -->
|
||||
<!-- unaryExpr with loopback via +/- below -->
|
||||
<g transform="translate(0, 20)">
|
||||
<!-- label background pill -->
|
||||
<rect class="label-pill" x="2" y="20" width="50" height="24" rx="6"/>
|
||||
<text class="label" x="10" y="36" text-anchor="start">expr</text>
|
||||
|
||||
<!-- entry dot with outer ring -->
|
||||
<circle fill="#94A3B8" cx="100" cy="32" r="4"/>
|
||||
<circle fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3" cx="100" cy="32" r="7"/>
|
||||
<line class="track" x1="104" y1="32" x2="140" y2="32"/>
|
||||
|
||||
<g class="nonterminal" transform="translate(140, 16)">
|
||||
<rect width="110" height="32" rx="4"/>
|
||||
<text x="55" y="16" text-anchor="middle" dominant-baseline="central">unaryExpr</text>
|
||||
</g>
|
||||
<line class="track" x1="250" y1="32" x2="310" y2="32"/>
|
||||
|
||||
<!-- loopback below with +/- alternatives -->
|
||||
<path class="track" d="M 300,32 C 300,58 292,72 276,72"/>
|
||||
|
||||
<!-- two alternatives for the operator: + and - -->
|
||||
<!-- + path (upper of the two) -->
|
||||
<g class="terminal" transform="translate(220, 56)">
|
||||
<rect width="30" height="32" rx="16"/>
|
||||
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">+</text>
|
||||
</g>
|
||||
<path class="track" d="M 276,72 L 250,72"/>
|
||||
<path class="track" d="M 220,72 C 200,72 190,62 180,52"/>
|
||||
|
||||
<!-- - path (lower) -->
|
||||
<path class="track" d="M 276,72 C 276,98 268,108 250,108"/>
|
||||
<g class="terminal" transform="translate(220, 92)">
|
||||
<rect width="30" height="32" rx="16"/>
|
||||
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">-</text>
|
||||
</g>
|
||||
<path class="track" d="M 220,108 C 200,108 186,90 180,52"/>
|
||||
|
||||
<!-- arrow pointing up at rejoin -->
|
||||
<path class="track" d="M 180,52 C 175,42 170,38 140,42"/>
|
||||
<path d="M 135,46 L 140,36 L 145,46 z" fill="#94A3B8"/>
|
||||
|
||||
<!-- exit dot with outer ring -->
|
||||
<circle fill="#94A3B8" cx="310" cy="32" r="4"/>
|
||||
<circle fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5" cx="310" cy="32" r="7"/>
|
||||
</g>
|
||||
|
||||
<!-- separator line between expr and unaryExpr -->
|
||||
<line x1="10" y1="155" x2="890" y2="155" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- ==================== unaryExpr ==================== -->
|
||||
<!-- 11-way alternative fan. Use literal type boxes for value types -->
|
||||
<g transform="translate(0, 160)">
|
||||
<!-- label background pill -->
|
||||
<rect class="label-pill" x="2" y="84" width="98" height="24" rx="6"/>
|
||||
<text class="label" x="10" y="100" text-anchor="start">unaryExpr</text>
|
||||
|
||||
<!-- entry dot with outer ring -->
|
||||
<circle fill="#94A3B8" cx="120" cy="96" r="4"/>
|
||||
<circle fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3" cx="120" cy="96" r="7"/>
|
||||
<line class="track" x1="124" y1="96" x2="160" y2="96"/>
|
||||
|
||||
<!-- 1. funcCall (y=-30) -->
|
||||
<path class="track" d="M 160,96 C 160,0 170,-30 190,-30"/>
|
||||
<g class="nonterminal" transform="translate(190, -46)">
|
||||
<rect width="92" height="32" rx="4"/>
|
||||
<text x="46" y="16" text-anchor="middle" dominant-baseline="central">funcCall</text>
|
||||
</g>
|
||||
<path class="track" d="M 282,-30 C 380,-30 390,0 390,96"/>
|
||||
|
||||
<!-- 2. subQuery (y=2) -->
|
||||
<path class="track" d="M 160,96 C 160,30 170,2 190,2"/>
|
||||
<g class="nonterminal" transform="translate(190, -14)">
|
||||
<rect width="92" height="32" rx="4"/>
|
||||
<text x="46" y="16" text-anchor="middle" dominant-baseline="central">subQuery</text>
|
||||
</g>
|
||||
<path class="track" d="M 282,2 C 370,2 380,30 390,96"/>
|
||||
|
||||
<!-- 3. qualifiedRef (y=34) -->
|
||||
<path class="track" d="M 160,96 C 160,54 170,34 190,34"/>
|
||||
<g class="nonterminal" transform="translate(190, 18)">
|
||||
<rect width="120" height="32" rx="4"/>
|
||||
<text x="60" y="16" text-anchor="middle" dominant-baseline="central">qualifiedRef</text>
|
||||
</g>
|
||||
<path class="track" d="M 310,34 C 370,34 380,54 390,96"/>
|
||||
|
||||
<!-- 4. listLiteral (y=66) -->
|
||||
<path class="track" d="M 160,96 C 160,76 170,66 190,66"/>
|
||||
<g class="nonterminal" transform="translate(190, 50)">
|
||||
<rect width="110" height="32" rx="4"/>
|
||||
<text x="55" y="16" text-anchor="middle" dominant-baseline="central">listLiteral</text>
|
||||
</g>
|
||||
<path class="track" d="M 300,66 C 370,66 380,76 390,96"/>
|
||||
|
||||
<!-- 5. string (y=96 — straight through) -->
|
||||
<line class="track" x1="160" y1="96" x2="190" y2="96"/>
|
||||
<g class="lit-terminal" transform="translate(190, 80)">
|
||||
<rect width="78" height="32" rx="16"/>
|
||||
<text x="39" y="16" text-anchor="middle" dominant-baseline="central">string</text>
|
||||
</g>
|
||||
<line class="track" x1="268" y1="96" x2="390" y2="96"/>
|
||||
|
||||
<!-- 6. date (y=128) -->
|
||||
<path class="track" d="M 160,96 C 160,116 170,128 190,128"/>
|
||||
<g class="lit-terminal" transform="translate(190, 112)">
|
||||
<rect width="64" height="32" rx="16"/>
|
||||
<text x="32" y="16" text-anchor="middle" dominant-baseline="central">date</text>
|
||||
</g>
|
||||
<path class="track" d="M 254,128 C 370,128 380,116 390,96"/>
|
||||
|
||||
<!-- 7. duration (y=160) -->
|
||||
<path class="track" d="M 160,96 C 160,140 170,160 190,160"/>
|
||||
<g class="lit-terminal" transform="translate(190, 144)">
|
||||
<rect width="86" height="32" rx="16"/>
|
||||
<text x="43" y="16" text-anchor="middle" dominant-baseline="central">duration</text>
|
||||
</g>
|
||||
<path class="track" d="M 276,160 C 370,160 380,140 390,96"/>
|
||||
|
||||
<!-- 8. int (y=192) -->
|
||||
<path class="track" d="M 160,96 C 160,166 170,192 190,192"/>
|
||||
<g class="lit-terminal" transform="translate(190, 176)">
|
||||
<rect width="50" height="32" rx="16"/>
|
||||
<text x="25" y="16" text-anchor="middle" dominant-baseline="central">int</text>
|
||||
</g>
|
||||
<path class="track" d="M 240,192 C 370,192 380,166 390,96"/>
|
||||
|
||||
<!-- 9. empty (y=224) -->
|
||||
<path class="track" d="M 160,96 C 160,196 170,224 190,224"/>
|
||||
<g class="lit-terminal" transform="translate(190, 208)">
|
||||
<rect width="70" height="32" rx="16"/>
|
||||
<text x="35" y="16" text-anchor="middle" dominant-baseline="central">empty</text>
|
||||
</g>
|
||||
<path class="track" d="M 260,224 C 370,224 380,196 390,96"/>
|
||||
|
||||
<!-- 10. fieldRef (y=256) -->
|
||||
<path class="track" d="M 160,96 C 160,224 170,256 190,256"/>
|
||||
<g class="nonterminal" transform="translate(190, 240)">
|
||||
<rect width="88" height="32" rx="4"/>
|
||||
<text x="44" y="16" text-anchor="middle" dominant-baseline="central">fieldRef</text>
|
||||
</g>
|
||||
<path class="track" d="M 278,256 C 370,256 380,224 390,96"/>
|
||||
|
||||
<!-- 11. ( expr ) (y=288) -->
|
||||
<path class="track" d="M 160,96 C 160,256 170,288 190,288"/>
|
||||
<g class="terminal" transform="translate(190, 272)">
|
||||
<rect width="30" height="32" rx="16"/>
|
||||
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">(</text>
|
||||
</g>
|
||||
<line class="track" x1="220" y1="288" x2="236" y2="288"/>
|
||||
<g class="nonterminal" transform="translate(236, 272)">
|
||||
<rect width="60" height="32" rx="4"/>
|
||||
<text x="30" y="16" text-anchor="middle" dominant-baseline="central">expr</text>
|
||||
</g>
|
||||
<line class="track" x1="296" y1="288" x2="312" y2="288"/>
|
||||
<g class="terminal" transform="translate(312, 272)">
|
||||
<rect width="30" height="32" rx="16"/>
|
||||
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">)</text>
|
||||
</g>
|
||||
<path class="track" d="M 342,288 C 370,288 380,256 390,96"/>
|
||||
|
||||
<!-- exit -->
|
||||
<line class="track" x1="390" y1="96" x2="430" y2="96"/>
|
||||
<!-- exit dot with outer ring -->
|
||||
<circle fill="#94A3B8" cx="430" cy="96" r="4"/>
|
||||
<circle fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5" cx="430" cy="96" r="7"/>
|
||||
</g>
|
||||
|
||||
<!-- separator line between unaryExpr and funcCall -->
|
||||
<line x1="10" y1="505" x2="890" y2="505" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- ==================== funcCall ==================== -->
|
||||
<g transform="translate(0, 510)">
|
||||
<!-- label background pill -->
|
||||
<rect class="label-pill" x="2" y="20" width="82" height="24" rx="6"/>
|
||||
<text class="label" x="10" y="36" text-anchor="start">funcCall</text>
|
||||
|
||||
<!-- entry dot with outer ring -->
|
||||
<circle fill="#94A3B8" cx="120" cy="32" r="4"/>
|
||||
<circle fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3" cx="120" cy="32" r="7"/>
|
||||
<line class="track" x1="124" y1="32" x2="150" y2="32"/>
|
||||
|
||||
<g class="nonterminal" transform="translate(150, 16)">
|
||||
<rect width="100" height="32" rx="4"/>
|
||||
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">identifier</text>
|
||||
</g>
|
||||
<line class="track" x1="250" y1="32" x2="270" y2="32"/>
|
||||
|
||||
<g class="terminal" transform="translate(270, 16)">
|
||||
<rect width="30" height="32" rx="16"/>
|
||||
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">(</text>
|
||||
</g>
|
||||
<line class="track" x1="300" y1="32" x2="330" y2="32"/>
|
||||
|
||||
<!-- optional args: bypass above, expr with comma loopback below -->
|
||||
<!-- main path: expr -->
|
||||
<g class="nonterminal" transform="translate(330, 16)">
|
||||
<rect width="60" height="32" rx="4"/>
|
||||
<text x="30" y="16" text-anchor="middle" dominant-baseline="central">expr</text>
|
||||
</g>
|
||||
<line class="track" x1="390" y1="32" x2="440" y2="32"/>
|
||||
|
||||
<!-- loopback below with comma -->
|
||||
<path class="track" d="M 430,32 C 430,62 420,72 400,72"/>
|
||||
<g class="terminal" transform="translate(355, 56)">
|
||||
<rect width="30" height="32" rx="16"/>
|
||||
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">,</text>
|
||||
</g>
|
||||
<path class="track" d="M 355,72 C 340,72 330,62 330,42"/>
|
||||
<path d="M 325,46 L 330,36 L 335,46 z" fill="#94A3B8"/>
|
||||
|
||||
<!-- bypass above: no args -->
|
||||
<path class="track" d="M 320,32 C 320,5 330,-5 350,-5 L 410,-5 C 430,-5 440,5 440,32"/>
|
||||
|
||||
<g class="terminal" transform="translate(440, 16)">
|
||||
<rect width="30" height="32" rx="16"/>
|
||||
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">)</text>
|
||||
</g>
|
||||
<line class="track" x1="470" y1="32" x2="510" y2="32"/>
|
||||
<!-- exit dot with outer ring -->
|
||||
<circle fill="#94A3B8" cx="510" cy="32" r="4"/>
|
||||
<circle fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5" cx="510" cy="32" r="7"/>
|
||||
</g>
|
||||
|
||||
<!-- separator line between funcCall and qualifiedRef -->
|
||||
<line x1="10" y1="605" x2="890" y2="605" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- ==================== qualifiedRef ==================== -->
|
||||
<g transform="translate(0, 610)">
|
||||
<!-- label background pill -->
|
||||
<rect class="label-pill" x="2" y="20" width="118" height="24" rx="6"/>
|
||||
<text class="label" x="10" y="36" text-anchor="start">qualifiedRef</text>
|
||||
|
||||
<!-- entry dot with outer ring -->
|
||||
<circle fill="#94A3B8" cx="140" cy="32" r="4"/>
|
||||
<circle fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3" cx="140" cy="32" r="7"/>
|
||||
<line class="track" x1="144" y1="32" x2="180" y2="32"/>
|
||||
|
||||
<!-- alternative: old | new -->
|
||||
<path class="track" d="M 180,32 C 180,10 190,0 210,0"/>
|
||||
<g class="terminal" transform="translate(210, -16)">
|
||||
<rect width="50" height="32" rx="16"/>
|
||||
<text x="25" y="16" text-anchor="middle" dominant-baseline="central">old</text>
|
||||
</g>
|
||||
<path class="track" d="M 260,0 C 280,0 290,10 290,32"/>
|
||||
|
||||
<path class="track" d="M 180,32 C 180,54 190,64 210,64"/>
|
||||
<g class="terminal" transform="translate(210, 48)">
|
||||
<rect width="50" height="32" rx="16"/>
|
||||
<text x="25" y="16" text-anchor="middle" dominant-baseline="central">new</text>
|
||||
</g>
|
||||
<path class="track" d="M 260,64 C 280,64 290,54 290,32"/>
|
||||
|
||||
<line class="track" x1="290" y1="32" x2="310" y2="32"/>
|
||||
<g class="terminal" transform="translate(310, 16)">
|
||||
<rect width="24" height="32" rx="16"/>
|
||||
<text x="12" y="16" text-anchor="middle" dominant-baseline="central">.</text>
|
||||
</g>
|
||||
<line class="track" x1="334" y1="32" x2="354" y2="32"/>
|
||||
<g class="nonterminal" transform="translate(354, 16)">
|
||||
<rect width="100" height="32" rx="4"/>
|
||||
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">identifier</text>
|
||||
</g>
|
||||
<line class="track" x1="454" y1="32" x2="490" y2="32"/>
|
||||
<!-- exit dot with outer ring -->
|
||||
<circle fill="#94A3B8" cx="490" cy="32" r="4"/>
|
||||
<circle fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5" cx="490" cy="32" r="7"/>
|
||||
</g>
|
||||
|
||||
</g><!-- end content wrapper -->
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
95
.doc/doki/doc/ruki/images/qualifier-scope.svg
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 350">
|
||||
<!--
|
||||
Grid system:
|
||||
- Row height: 40px, gap: 6px
|
||||
- Column widths: context=200, old=100, new=100
|
||||
- Start x=40, y=60
|
||||
- Indicator pills: 60x28, rx=14
|
||||
- All fills use opacity for transparency on any background
|
||||
-->
|
||||
<defs>
|
||||
<style>
|
||||
.title { font: 700 16px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #A5B4FC; }
|
||||
.col-hdr { font: 700 15px 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; }
|
||||
.row-label { font: 500 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #CBD5E1; }
|
||||
.note-text { font: 500 11px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #FBBF24; }
|
||||
.legend-text { font: 400 11px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #94A3B8; }
|
||||
.indicator { font: 700 14px -apple-system, sans-serif; }
|
||||
</style>
|
||||
<!-- Checkmark icon -->
|
||||
<symbol id="check" viewBox="0 0 16 16">
|
||||
<path d="M3 8.5 L6.5 12 L13 4" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</symbol>
|
||||
<!-- X icon -->
|
||||
<symbol id="cross" viewBox="0 0 16 16">
|
||||
<path d="M4 4 L12 12 M12 4 L4 12" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" fill="none"/>
|
||||
</symbol>
|
||||
<!-- Drop shadow filter -->
|
||||
<filter id="shadow" x="-4%" y="-4%" width="108%" height="116%">
|
||||
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-color="#000000" flood-opacity="0.3"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Title -->
|
||||
<text class="title" x="250" y="30" text-anchor="middle">Qualifier scope by context</text>
|
||||
|
||||
<!-- Subtle top border -->
|
||||
<line x1="40" y1="38" x2="470" y2="38" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- Column header backgrounds -->
|
||||
<rect x="270" y="42" width="80" height="28" rx="6" fill="#818CF8" fill-opacity="0.08"/>
|
||||
<rect x="380" y="42" width="80" height="28" rx="6" fill="#34D399" fill-opacity="0.08"/>
|
||||
|
||||
<!-- Column headers -->
|
||||
<text class="col-hdr" x="310" y="62" text-anchor="middle" fill="#818CF8">old.</text>
|
||||
<text class="col-hdr" x="420" y="62" text-anchor="middle" fill="#34D399">new.</text>
|
||||
|
||||
<!-- Separator line -->
|
||||
<line x1="40" y1="74" x2="470" y2="74" stroke="#475569" stroke-width="1" stroke-opacity="0.5"/>
|
||||
|
||||
<!-- Row 1: standalone statement -->
|
||||
<text class="row-label" x="50" y="102">standalone statement</text>
|
||||
<rect x="280" y="86" width="60" height="28" rx="14" fill="#EF4444" fill-opacity="0.2" stroke="#EF4444" stroke-width="1" stroke-opacity="0.4" filter="url(#shadow)"/>
|
||||
<use href="#cross" x="296" y="92" width="16" height="16" color="#F87171"/>
|
||||
<rect x="390" y="86" width="60" height="28" rx="14" fill="#EF4444" fill-opacity="0.2" stroke="#EF4444" stroke-width="1" stroke-opacity="0.4" filter="url(#shadow)"/>
|
||||
<use href="#cross" x="406" y="92" width="16" height="16" color="#F87171"/>
|
||||
|
||||
<!-- Row 2: create trigger (alternate row highlight) -->
|
||||
<rect x="40" y="120" width="430" height="40" rx="4" fill="#94A3B8" fill-opacity="0.04"/>
|
||||
<text class="row-label" x="50" y="148">create trigger</text>
|
||||
<rect x="280" y="132" width="60" height="28" rx="14" fill="#EF4444" fill-opacity="0.2" stroke="#EF4444" stroke-width="1" stroke-opacity="0.4" filter="url(#shadow)"/>
|
||||
<use href="#cross" x="296" y="138" width="16" height="16" color="#F87171"/>
|
||||
<rect x="390" y="132" width="60" height="28" rx="14" fill="#10B981" fill-opacity="0.2" stroke="#10B981" stroke-width="1" stroke-opacity="0.4" filter="url(#shadow)"/>
|
||||
<use href="#check" x="406" y="138" width="16" height="16" color="#34D399"/>
|
||||
|
||||
<!-- Row 3: update trigger -->
|
||||
<text class="row-label" x="50" y="194">update trigger</text>
|
||||
<rect x="280" y="178" width="60" height="28" rx="14" fill="#10B981" fill-opacity="0.2" stroke="#10B981" stroke-width="1" stroke-opacity="0.4" filter="url(#shadow)"/>
|
||||
<use href="#check" x="296" y="184" width="16" height="16" color="#34D399"/>
|
||||
<rect x="390" y="178" width="60" height="28" rx="14" fill="#10B981" fill-opacity="0.2" stroke="#10B981" stroke-width="1" stroke-opacity="0.4" filter="url(#shadow)"/>
|
||||
<use href="#check" x="406" y="184" width="16" height="16" color="#34D399"/>
|
||||
|
||||
<!-- Row 4: delete trigger (alternate row highlight) -->
|
||||
<rect x="40" y="212" width="430" height="40" rx="4" fill="#94A3B8" fill-opacity="0.04"/>
|
||||
<text class="row-label" x="50" y="240">delete trigger</text>
|
||||
<rect x="280" y="224" width="60" height="28" rx="14" fill="#10B981" fill-opacity="0.2" stroke="#10B981" stroke-width="1" stroke-opacity="0.4" filter="url(#shadow)"/>
|
||||
<use href="#check" x="296" y="230" width="16" height="16" color="#34D399"/>
|
||||
<rect x="390" y="224" width="60" height="28" rx="14" fill="#EF4444" fill-opacity="0.2" stroke="#EF4444" stroke-width="1" stroke-opacity="0.4" filter="url(#shadow)"/>
|
||||
<use href="#cross" x="406" y="230" width="16" height="16" color="#F87171"/>
|
||||
|
||||
<!-- Separator -->
|
||||
<line x1="40" y1="264" x2="470" y2="264" stroke="#475569" stroke-width="1" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- Note about quantifier body -->
|
||||
<rect x="40" y="274" width="430" height="30" rx="8" fill="#FBBF24" fill-opacity="0.1" stroke="#FBBF24" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
|
||||
<text class="note-text" x="255" y="293" text-anchor="middle">quantifier body (any / all): both old. and new. are disabled</text>
|
||||
|
||||
<!-- Legend -->
|
||||
<rect x="100" y="316" width="16" height="16" rx="8" fill="#10B981" fill-opacity="0.2" stroke="#10B981" stroke-width="1" stroke-opacity="0.4"/>
|
||||
<use href="#check" x="102" y="318" width="12" height="12" color="#34D399"/>
|
||||
<text class="legend-text" x="124" y="328">allowed</text>
|
||||
|
||||
<rect x="230" y="316" width="16" height="16" rx="8" fill="#EF4444" fill-opacity="0.2" stroke="#EF4444" stroke-width="1" stroke-opacity="0.4"/>
|
||||
<use href="#cross" x="232" y="318" width="12" height="12" color="#F87171"/>
|
||||
<text class="legend-text" x="254" y="328">not allowed</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.8 KiB |
286
.doc/doki/doc/ruki/images/stmt-railroad.svg
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1020 560">
|
||||
<!--
|
||||
Grid system for Ruki railroad diagrams (transparent/dark-bg compatible)
|
||||
========================================
|
||||
Terminal (keyword) box: height=32, rx=16 (fully rounded ends)
|
||||
Non-terminal box: height=32, rx=4 (subtle rounding)
|
||||
Track vertical spacing: ~130px between track baselines
|
||||
Label column: x=10, track starts at x=140
|
||||
Line/dot color: #94A3B8
|
||||
Terminal: fill=#3B82F6 fill-opacity=0.15, stroke=#3B82F6 stroke-opacity=0.5
|
||||
Non-terminal: fill=#F59E0B fill-opacity=0.15, stroke=#F59E0B stroke-opacity=0.4
|
||||
Loopback: below main line (standard railroad repetition)
|
||||
Bypass: above main line for optional elements
|
||||
|
||||
Visual polish:
|
||||
- Drop shadow filter on all boxes (subtle, dark-bg friendly)
|
||||
- Label background pills with rounded rect behind each rule name
|
||||
- Entry dots with outer ring; exit dots with double circle
|
||||
- Separator lines between tracks
|
||||
- Title text at top in accent color
|
||||
- viewBox height +20 to accommodate title; content shifted down 8px
|
||||
-->
|
||||
<defs>
|
||||
<filter id="shadow" x="-4%" y="-4%" width="108%" height="116%">
|
||||
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-color="#000000" flood-opacity="0.3"/>
|
||||
</filter>
|
||||
<marker id="arrow" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="10" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 3.5 L 0 7 z" fill="#94A3B8"/>
|
||||
</marker>
|
||||
<style>
|
||||
.title { font: 700 16px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #A5B4FC; }
|
||||
.label { font: bold 14px 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; fill: #94A3B8; }
|
||||
.terminal rect { fill: #3B82F6; fill-opacity: 0.15; stroke: #3B82F6; stroke-width: 1.5; stroke-opacity: 0.5; filter: url(#shadow); }
|
||||
.terminal text { font: 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #93C5FD; font-weight: 600; }
|
||||
.nonterminal rect { fill: #F59E0B; fill-opacity: 0.15; stroke: #F59E0B; stroke-width: 1.5; stroke-opacity: 0.4; filter: url(#shadow); }
|
||||
.nonterminal text { font: 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #FCD34D; font-weight: 500; }
|
||||
.track { stroke: #94A3B8; stroke-width: 1.5; fill: none; }
|
||||
.track-dot { fill: #94A3B8; }
|
||||
.separator { stroke: #475569; stroke-width: 0.5; stroke-opacity: 0.3; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<!-- diagram title -->
|
||||
<text class="title" x="410" y="22" text-anchor="middle">Statement grammar</text>
|
||||
|
||||
<!-- content shifted down 8px to accommodate title -->
|
||||
<g transform="translate(0, 8)">
|
||||
|
||||
<!-- ==================== selectStmt ==================== -->
|
||||
<!-- Main line at y=50, field-list bypass above at y=10, loopback below at y=90 -->
|
||||
<g transform="translate(0, 30)">
|
||||
<!-- label background pill -->
|
||||
<g class="label-group">
|
||||
<rect x="5" y="38" width="120" height="24" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
|
||||
<text class="label" x="65" y="54" text-anchor="middle">selectStmt</text>
|
||||
</g>
|
||||
|
||||
<!-- entry dot with ring -->
|
||||
<circle class="track-dot" cx="140" cy="50" r="4"/>
|
||||
<circle cx="140" cy="50" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<line class="track" x1="144" y1="50" x2="170" y2="50"/>
|
||||
|
||||
<!-- select keyword -->
|
||||
<g class="terminal" transform="translate(170, 34)">
|
||||
<rect width="72" height="32" rx="16"/>
|
||||
<text x="36" y="16" text-anchor="middle" dominant-baseline="central">select</text>
|
||||
</g>
|
||||
<line class="track" x1="242" y1="50" x2="260" y2="50"/>
|
||||
|
||||
<!-- === optional field list group (260–460) === -->
|
||||
|
||||
<!-- main path: * terminal -->
|
||||
<line class="track" x1="260" y1="50" x2="290" y2="50"/>
|
||||
<g class="terminal" transform="translate(290, 34)">
|
||||
<rect width="32" height="32" rx="16"/>
|
||||
<text x="16" y="16" text-anchor="middle" dominant-baseline="central">*</text>
|
||||
</g>
|
||||
<line class="track" x1="322" y1="50" x2="460" y2="50"/>
|
||||
|
||||
<!-- alternate path below: identifier { , identifier } at y=90 -->
|
||||
<path class="track" d="M 270,50 C 270,70 280,90 300,90"/>
|
||||
<g class="nonterminal" transform="translate(300, 74)">
|
||||
<rect width="100" height="32" rx="4"/>
|
||||
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">identifier</text>
|
||||
</g>
|
||||
<line class="track" x1="400" y1="90" x2="440" y2="90"/>
|
||||
<path class="track" d="M 440,90 C 450,90 460,80 460,50"/>
|
||||
|
||||
<!-- loopback below identifier: , identifier at y=130 -->
|
||||
<path class="track" d="M 430,90 C 430,110 420,130 400,130"/>
|
||||
<g class="terminal" transform="translate(356, 114)">
|
||||
<rect width="32" height="32" rx="16"/>
|
||||
<text x="16" y="16" text-anchor="middle" dominant-baseline="central">,</text>
|
||||
</g>
|
||||
<line class="track" x1="356" y1="130" x2="340" y2="130"/>
|
||||
<g class="nonterminal" transform="translate(230, 114)">
|
||||
<rect width="100" height="32" rx="4"/>
|
||||
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">identifier</text>
|
||||
</g>
|
||||
<path class="track" d="M 230,130 C 220,130 210,110 210,100"/>
|
||||
<!-- arrowhead pointing up at the rejoin into the identifier path -->
|
||||
<path d="M 205,100 L 210,90 L 215,100 z" fill="#94A3B8"/>
|
||||
|
||||
<!-- bypass path above: skip field list entirely at y=10 -->
|
||||
<path class="track" d="M 260,50 C 260,30 270,10 290,10 L 430,10 C 450,10 460,30 460,50"/>
|
||||
|
||||
<!-- === end field list group === -->
|
||||
<line class="track" x1="460" y1="50" x2="490" y2="50"/>
|
||||
|
||||
<!-- === optional where + condition group (490–680) === -->
|
||||
<g class="terminal" transform="translate(490, 34)">
|
||||
<rect width="70" height="32" rx="16"/>
|
||||
<text x="35" y="16" text-anchor="middle" dominant-baseline="central">where</text>
|
||||
</g>
|
||||
<line class="track" x1="560" y1="50" x2="580" y2="50"/>
|
||||
<g class="nonterminal" transform="translate(580, 34)">
|
||||
<rect width="100" height="32" rx="4"/>
|
||||
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">condition</text>
|
||||
</g>
|
||||
<line class="track" x1="680" y1="50" x2="710" y2="50"/>
|
||||
|
||||
<!-- bypass above: skip where+condition -->
|
||||
<path class="track" d="M 480,50 C 480,20 490,10 510,10 L 680,10 C 700,10 710,20 710,50"/>
|
||||
|
||||
<!-- === optional orderBy group (710–830) === -->
|
||||
<line class="track" x1="710" y1="50" x2="740" y2="50"/>
|
||||
<g class="nonterminal" transform="translate(740, 34)">
|
||||
<rect width="88" height="32" rx="4"/>
|
||||
<text x="44" y="16" text-anchor="middle" dominant-baseline="central">orderBy</text>
|
||||
</g>
|
||||
<line class="track" x1="828" y1="50" x2="860" y2="50"/>
|
||||
|
||||
<!-- bypass above: skip orderBy -->
|
||||
<path class="track" d="M 730,50 C 730,20 740,10 760,10 L 830,10 C 850,10 860,20 860,50"/>
|
||||
|
||||
<!-- exit dot with double circle -->
|
||||
<circle class="track-dot" cx="860" cy="50" r="4"/>
|
||||
<circle cx="860" cy="50" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
</g>
|
||||
|
||||
<!-- separator between selectStmt and createStmt -->
|
||||
<line class="separator" x1="10" y1="140" x2="1010" y2="140"/>
|
||||
|
||||
<!-- ==================== createStmt ==================== -->
|
||||
<!-- Main line at y=32, loopback below at y=72 -->
|
||||
<g transform="translate(0, 150)">
|
||||
<!-- label background pill -->
|
||||
<g class="label-group">
|
||||
<rect x="5" y="18" width="122" height="24" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
|
||||
<text class="label" x="66" y="34" text-anchor="middle">createStmt</text>
|
||||
</g>
|
||||
|
||||
<!-- entry dot with ring -->
|
||||
<circle class="track-dot" cx="140" cy="32" r="4"/>
|
||||
<circle cx="140" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<line class="track" x1="144" y1="32" x2="170" y2="32"/>
|
||||
|
||||
<!-- create keyword -->
|
||||
<g class="terminal" transform="translate(170, 16)">
|
||||
<rect width="74" height="32" rx="16"/>
|
||||
<text x="37" y="16" text-anchor="middle" dominant-baseline="central">create</text>
|
||||
</g>
|
||||
<line class="track" x1="244" y1="32" x2="280" y2="32"/>
|
||||
|
||||
<!-- assignment non-terminal -->
|
||||
<g class="nonterminal" transform="translate(280, 16)">
|
||||
<rect width="110" height="32" rx="4"/>
|
||||
<text x="55" y="16" text-anchor="middle" dominant-baseline="central">assignment</text>
|
||||
</g>
|
||||
<line class="track" x1="390" y1="32" x2="440" y2="32"/>
|
||||
|
||||
<!-- loopback below: from after assignment back to before assignment -->
|
||||
<path class="track" d="M 430,32 C 430,62 420,76 400,76 L 300,76 C 280,76 270,62 270,42"/>
|
||||
<!-- arrowhead pointing up at the rejoin -->
|
||||
<path d="M 265,46 L 270,36 L 275,46 z" fill="#94A3B8"/>
|
||||
|
||||
<!-- exit dot with double circle -->
|
||||
<circle class="track-dot" cx="440" cy="32" r="4"/>
|
||||
<circle cx="440" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
</g>
|
||||
|
||||
<!-- separator between createStmt and updateStmt -->
|
||||
<line class="separator" x1="10" y1="270" x2="1010" y2="270"/>
|
||||
|
||||
<!-- ==================== updateStmt ==================== -->
|
||||
<!-- Main line at y=32, loopback below at y=76 -->
|
||||
<g transform="translate(0, 280)">
|
||||
<!-- label background pill -->
|
||||
<g class="label-group">
|
||||
<rect x="5" y="18" width="124" height="24" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
|
||||
<text class="label" x="67" y="34" text-anchor="middle">updateStmt</text>
|
||||
</g>
|
||||
|
||||
<!-- entry dot with ring -->
|
||||
<circle class="track-dot" cx="140" cy="32" r="4"/>
|
||||
<circle cx="140" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<line class="track" x1="144" y1="32" x2="170" y2="32"/>
|
||||
|
||||
<!-- update keyword -->
|
||||
<g class="terminal" transform="translate(170, 16)">
|
||||
<rect width="78" height="32" rx="16"/>
|
||||
<text x="39" y="16" text-anchor="middle" dominant-baseline="central">update</text>
|
||||
</g>
|
||||
<line class="track" x1="248" y1="32" x2="268" y2="32"/>
|
||||
|
||||
<!-- where keyword -->
|
||||
<g class="terminal" transform="translate(268, 16)">
|
||||
<rect width="70" height="32" rx="16"/>
|
||||
<text x="35" y="16" text-anchor="middle" dominant-baseline="central">where</text>
|
||||
</g>
|
||||
<line class="track" x1="338" y1="32" x2="358" y2="32"/>
|
||||
|
||||
<!-- condition -->
|
||||
<g class="nonterminal" transform="translate(358, 16)">
|
||||
<rect width="100" height="32" rx="4"/>
|
||||
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">condition</text>
|
||||
</g>
|
||||
<line class="track" x1="458" y1="32" x2="478" y2="32"/>
|
||||
|
||||
<!-- set keyword -->
|
||||
<g class="terminal" transform="translate(478, 16)">
|
||||
<rect width="50" height="32" rx="16"/>
|
||||
<text x="25" y="16" text-anchor="middle" dominant-baseline="central">set</text>
|
||||
</g>
|
||||
<line class="track" x1="528" y1="32" x2="548" y2="32"/>
|
||||
|
||||
<!-- assignment non-terminal -->
|
||||
<g class="nonterminal" transform="translate(548, 16)">
|
||||
<rect width="110" height="32" rx="4"/>
|
||||
<text x="55" y="16" text-anchor="middle" dominant-baseline="central">assignment</text>
|
||||
</g>
|
||||
<line class="track" x1="658" y1="32" x2="710" y2="32"/>
|
||||
|
||||
<!-- loopback below -->
|
||||
<path class="track" d="M 700,32 C 700,62 690,76 670,76 L 568,76 C 548,76 538,62 538,42"/>
|
||||
<path d="M 533,46 L 538,36 L 543,46 z" fill="#94A3B8"/>
|
||||
|
||||
<!-- exit dot with double circle -->
|
||||
<circle class="track-dot" cx="710" cy="32" r="4"/>
|
||||
<circle cx="710" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
</g>
|
||||
|
||||
<!-- separator between updateStmt and deleteStmt -->
|
||||
<line class="separator" x1="10" y1="400" x2="1010" y2="400"/>
|
||||
|
||||
<!-- ==================== deleteStmt ==================== -->
|
||||
<g transform="translate(0, 420)">
|
||||
<!-- label background pill -->
|
||||
<g class="label-group">
|
||||
<rect x="5" y="18" width="120" height="24" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
|
||||
<text class="label" x="65" y="34" text-anchor="middle">deleteStmt</text>
|
||||
</g>
|
||||
|
||||
<!-- entry dot with ring -->
|
||||
<circle class="track-dot" cx="140" cy="32" r="4"/>
|
||||
<circle cx="140" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<line class="track" x1="144" y1="32" x2="170" y2="32"/>
|
||||
|
||||
<!-- delete keyword -->
|
||||
<g class="terminal" transform="translate(170, 16)">
|
||||
<rect width="72" height="32" rx="16"/>
|
||||
<text x="36" y="16" text-anchor="middle" dominant-baseline="central">delete</text>
|
||||
</g>
|
||||
<line class="track" x1="242" y1="32" x2="270" y2="32"/>
|
||||
|
||||
<!-- where keyword -->
|
||||
<g class="terminal" transform="translate(270, 16)">
|
||||
<rect width="70" height="32" rx="16"/>
|
||||
<text x="35" y="16" text-anchor="middle" dominant-baseline="central">where</text>
|
||||
</g>
|
||||
<line class="track" x1="340" y1="32" x2="370" y2="32"/>
|
||||
|
||||
<!-- condition -->
|
||||
<g class="nonterminal" transform="translate(370, 16)">
|
||||
<rect width="100" height="32" rx="4"/>
|
||||
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">condition</text>
|
||||
</g>
|
||||
<line class="track" x1="470" y1="32" x2="510" y2="32"/>
|
||||
|
||||
<!-- exit dot with double circle -->
|
||||
<circle class="track-dot" cx="510" cy="32" r="4"/>
|
||||
<circle cx="510" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
</g>
|
||||
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
307
.doc/doki/doc/ruki/images/trigger-railroad.svg
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 708">
|
||||
<!--
|
||||
Trigger railroad diagram — transparent/dark-bg compatible
|
||||
6 tracks: trigger, timing, event, action, deny, runAction
|
||||
-->
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="10" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 3.5 L 0 7 z" fill="#94A3B8"/>
|
||||
</marker>
|
||||
<filter id="shadow" x="-4%" y="-4%" width="108%" height="116%">
|
||||
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-color="#000000" flood-opacity="0.3"/>
|
||||
</filter>
|
||||
<style>
|
||||
.label { font: bold 14px 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; fill: #94A3B8; }
|
||||
.title { font: 700 16px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #A5B4FC; }
|
||||
.terminal rect { fill: #3B82F6; fill-opacity: 0.15; stroke: #3B82F6; stroke-width: 1.5; stroke-opacity: 0.5; filter: url(#shadow); }
|
||||
.terminal text { font: 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #93C5FD; font-weight: 600; }
|
||||
.nonterminal rect { fill: #F59E0B; fill-opacity: 0.15; stroke: #F59E0B; stroke-width: 1.5; stroke-opacity: 0.5; filter: url(#shadow); }
|
||||
.nonterminal text { font: 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #FCD34D; font-weight: 500; }
|
||||
.track { stroke: #94A3B8; stroke-width: 1.5; fill: none; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<!-- title -->
|
||||
<text class="title" x="450" y="22" text-anchor="middle">Trigger grammar</text>
|
||||
|
||||
<!-- all tracks shifted down 8px to accommodate title -->
|
||||
<g transform="translate(0, 8)">
|
||||
|
||||
<!-- ==================== trigger ==================== -->
|
||||
<!-- Main line y=50, bypass above at y=10 for optional where -->
|
||||
<g transform="translate(0, 30)">
|
||||
<g>
|
||||
<rect x="2" y="40" width="71" height="24" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
|
||||
<text class="label" x="10" y="54" text-anchor="start">trigger</text>
|
||||
</g>
|
||||
|
||||
<!-- entry dot with outer ring -->
|
||||
<circle cx="120" cy="50" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<circle cx="120" cy="50" r="4" fill="#94A3B8"/>
|
||||
<line class="track" x1="124" y1="50" x2="150" y2="50"/>
|
||||
|
||||
<!-- timing non-terminal -->
|
||||
<g class="nonterminal" transform="translate(150, 34)">
|
||||
<rect width="80" height="32" rx="4"/>
|
||||
<text x="40" y="16" text-anchor="middle" dominant-baseline="central">timing</text>
|
||||
</g>
|
||||
<line class="track" x1="230" y1="50" x2="256" y2="50"/>
|
||||
|
||||
<!-- event non-terminal -->
|
||||
<g class="nonterminal" transform="translate(256, 34)">
|
||||
<rect width="72" height="32" rx="4"/>
|
||||
<text x="36" y="16" text-anchor="middle" dominant-baseline="central">event</text>
|
||||
</g>
|
||||
<line class="track" x1="328" y1="50" x2="358" y2="50"/>
|
||||
|
||||
<!-- optional where + condition: main path -->
|
||||
<g class="terminal" transform="translate(358, 34)">
|
||||
<rect width="70" height="32" rx="16"/>
|
||||
<text x="35" y="16" text-anchor="middle" dominant-baseline="central">where</text>
|
||||
</g>
|
||||
<line class="track" x1="428" y1="50" x2="454" y2="50"/>
|
||||
<g class="nonterminal" transform="translate(454, 34)">
|
||||
<rect width="100" height="32" rx="4"/>
|
||||
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">condition</text>
|
||||
</g>
|
||||
<line class="track" x1="554" y1="50" x2="600" y2="50"/>
|
||||
|
||||
<!-- bypass above for optional where+condition -->
|
||||
<path class="track" d="M 348,50 C 348,20 358,10 378,10 L 570,10 C 590,10 600,20 600,50"/>
|
||||
|
||||
<!-- alternative: action or deny -->
|
||||
<!-- upper path: action -->
|
||||
<path class="track" d="M 600,50 C 600,20 610,10 630,10"/>
|
||||
<g class="nonterminal" transform="translate(630, -6)">
|
||||
<rect width="80" height="32" rx="4"/>
|
||||
<text x="40" y="16" text-anchor="middle" dominant-baseline="central">action</text>
|
||||
</g>
|
||||
<path class="track" d="M 710,10 C 730,10 740,20 740,50"/>
|
||||
|
||||
<!-- lower path: deny -->
|
||||
<path class="track" d="M 600,50 C 600,80 610,90 630,90"/>
|
||||
<g class="nonterminal" transform="translate(630, 74)">
|
||||
<rect width="80" height="32" rx="4"/>
|
||||
<text x="40" y="16" text-anchor="middle" dominant-baseline="central">deny</text>
|
||||
</g>
|
||||
<path class="track" d="M 710,90 C 730,90 740,80 740,50"/>
|
||||
|
||||
<line class="track" x1="740" y1="50" x2="780" y2="50"/>
|
||||
<!-- exit dot with thicker outer ring -->
|
||||
<circle cx="780" cy="50" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
<circle cx="780" cy="50" r="4" fill="#94A3B8"/>
|
||||
</g>
|
||||
|
||||
<!-- separator between trigger and timing -->
|
||||
<line x1="10" y1="160" x2="890" y2="160" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- ==================== timing ==================== -->
|
||||
<g transform="translate(0, 170)">
|
||||
<g>
|
||||
<rect x="2" y="22" width="64" height="24" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
|
||||
<text class="label" x="10" y="36" text-anchor="start">timing</text>
|
||||
</g>
|
||||
|
||||
<!-- entry dot with outer ring -->
|
||||
<circle cx="120" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<circle cx="120" cy="32" r="4" fill="#94A3B8"/>
|
||||
<line class="track" x1="124" y1="32" x2="160" y2="32"/>
|
||||
|
||||
<!-- upper: before -->
|
||||
<path class="track" d="M 160,32 C 160,10 170,0 190,0"/>
|
||||
<g class="terminal" transform="translate(190, -16)">
|
||||
<rect width="78" height="32" rx="16"/>
|
||||
<text x="39" y="16" text-anchor="middle" dominant-baseline="central">before</text>
|
||||
</g>
|
||||
<path class="track" d="M 268,0 C 288,0 298,10 298,32"/>
|
||||
|
||||
<!-- lower: after -->
|
||||
<path class="track" d="M 160,32 C 160,54 170,64 190,64"/>
|
||||
<g class="terminal" transform="translate(190, 48)">
|
||||
<rect width="78" height="32" rx="16"/>
|
||||
<text x="39" y="16" text-anchor="middle" dominant-baseline="central">after</text>
|
||||
</g>
|
||||
<path class="track" d="M 268,64 C 288,64 298,54 298,32"/>
|
||||
|
||||
<line class="track" x1="298" y1="32" x2="340" y2="32"/>
|
||||
<!-- exit dot with thicker outer ring -->
|
||||
<circle cx="340" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
<circle cx="340" cy="32" r="4" fill="#94A3B8"/>
|
||||
</g>
|
||||
|
||||
<!-- separator between timing and event -->
|
||||
<line x1="10" y1="278" x2="890" y2="278" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- ==================== event ==================== -->
|
||||
<g transform="translate(0, 290)">
|
||||
<g>
|
||||
<rect x="2" y="22" width="56" height="24" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
|
||||
<text class="label" x="10" y="36" text-anchor="start">event</text>
|
||||
</g>
|
||||
|
||||
<!-- entry dot with outer ring -->
|
||||
<circle cx="120" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<circle cx="120" cy="32" r="4" fill="#94A3B8"/>
|
||||
<line class="track" x1="124" y1="32" x2="160" y2="32"/>
|
||||
|
||||
<!-- top: create -->
|
||||
<path class="track" d="M 160,32 C 160,2 170,-8 190,-8"/>
|
||||
<g class="terminal" transform="translate(190, -24)">
|
||||
<rect width="78" height="32" rx="16"/>
|
||||
<text x="39" y="16" text-anchor="middle" dominant-baseline="central">create</text>
|
||||
</g>
|
||||
<path class="track" d="M 268,-8 C 288,-8 298,2 298,32"/>
|
||||
|
||||
<!-- middle: update -->
|
||||
<g class="terminal" transform="translate(190, 16)">
|
||||
<rect width="78" height="32" rx="16"/>
|
||||
<text x="39" y="16" text-anchor="middle" dominant-baseline="central">update</text>
|
||||
</g>
|
||||
<!-- straight through for middle option -->
|
||||
<line class="track" x1="160" y1="32" x2="190" y2="32"/>
|
||||
<line class="track" x1="268" y1="32" x2="298" y2="32"/>
|
||||
|
||||
<!-- bottom: delete -->
|
||||
<path class="track" d="M 160,32 C 160,62 170,72 190,72"/>
|
||||
<g class="terminal" transform="translate(190, 56)">
|
||||
<rect width="78" height="32" rx="16"/>
|
||||
<text x="39" y="16" text-anchor="middle" dominant-baseline="central">delete</text>
|
||||
</g>
|
||||
<path class="track" d="M 268,72 C 288,72 298,62 298,32"/>
|
||||
|
||||
<line class="track" x1="298" y1="32" x2="340" y2="32"/>
|
||||
<!-- exit dot with thicker outer ring -->
|
||||
<circle cx="340" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
<circle cx="340" cy="32" r="4" fill="#94A3B8"/>
|
||||
</g>
|
||||
|
||||
<!-- separator between event and action -->
|
||||
<line x1="10" y1="407" x2="890" y2="407" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- ==================== action ==================== -->
|
||||
<!-- Center at y=55 to give room for 4 alternatives above and below -->
|
||||
<g transform="translate(0, 420)">
|
||||
<g>
|
||||
<rect x="2" y="45" width="64" height="24" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
|
||||
<text class="label" x="10" y="59" text-anchor="start">action</text>
|
||||
</g>
|
||||
|
||||
<!-- entry dot with outer ring -->
|
||||
<circle cx="120" cy="55" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<circle cx="120" cy="55" r="4" fill="#94A3B8"/>
|
||||
<line class="track" x1="124" y1="55" x2="160" y2="55"/>
|
||||
|
||||
<!-- top: runAction -->
|
||||
<path class="track" d="M 160,55 C 160,10 170,-5 190,-5"/>
|
||||
<g class="nonterminal" transform="translate(190, -21)">
|
||||
<rect width="104" height="32" rx="4"/>
|
||||
<text x="52" y="16" text-anchor="middle" dominant-baseline="central">runAction</text>
|
||||
</g>
|
||||
<path class="track" d="M 294,-5 C 314,-5 324,10 324,55"/>
|
||||
|
||||
<!-- upper-middle: createStmt -->
|
||||
<path class="track" d="M 160,55 C 160,35 170,25 190,25"/>
|
||||
<g class="nonterminal" transform="translate(190, 9)">
|
||||
<rect width="104" height="32" rx="4"/>
|
||||
<text x="52" y="16" text-anchor="middle" dominant-baseline="central">createStmt</text>
|
||||
</g>
|
||||
<path class="track" d="M 294,25 C 314,25 324,35 324,55"/>
|
||||
|
||||
<!-- lower-middle: updateStmt (straight through) -->
|
||||
<line class="track" x1="160" y1="55" x2="190" y2="55"/>
|
||||
<g class="nonterminal" transform="translate(190, 39)">
|
||||
<rect width="104" height="32" rx="4"/>
|
||||
<text x="52" y="16" text-anchor="middle" dominant-baseline="central">updateStmt</text>
|
||||
</g>
|
||||
<line class="track" x1="294" y1="55" x2="324" y2="55"/>
|
||||
|
||||
<!-- bottom: deleteStmt -->
|
||||
<path class="track" d="M 160,55 C 160,75 170,85 190,85"/>
|
||||
<g class="nonterminal" transform="translate(190, 69)">
|
||||
<rect width="104" height="32" rx="4"/>
|
||||
<text x="52" y="16" text-anchor="middle" dominant-baseline="central">deleteStmt</text>
|
||||
</g>
|
||||
<path class="track" d="M 294,85 C 314,85 324,75 324,55"/>
|
||||
|
||||
<line class="track" x1="324" y1="55" x2="370" y2="55"/>
|
||||
<!-- exit dot with thicker outer ring -->
|
||||
<circle cx="370" cy="55" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
<circle cx="370" cy="55" r="4" fill="#94A3B8"/>
|
||||
</g>
|
||||
|
||||
<!-- separator between action and deny -->
|
||||
<line x1="10" y1="548" x2="890" y2="548" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- ==================== deny ==================== -->
|
||||
<g transform="translate(0, 560)">
|
||||
<g>
|
||||
<rect x="2" y="22" width="52" height="24" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
|
||||
<text class="label" x="10" y="36" text-anchor="start">deny</text>
|
||||
</g>
|
||||
|
||||
<!-- entry dot with outer ring -->
|
||||
<circle cx="120" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<circle cx="120" cy="32" r="4" fill="#94A3B8"/>
|
||||
<line class="track" x1="124" y1="32" x2="150" y2="32"/>
|
||||
|
||||
<g class="terminal" transform="translate(150, 16)">
|
||||
<rect width="62" height="32" rx="16"/>
|
||||
<text x="31" y="16" text-anchor="middle" dominant-baseline="central">deny</text>
|
||||
</g>
|
||||
<line class="track" x1="212" y1="32" x2="240" y2="32"/>
|
||||
|
||||
<g class="nonterminal" transform="translate(240, 16)">
|
||||
<rect width="72" height="32" rx="4"/>
|
||||
<text x="36" y="16" text-anchor="middle" dominant-baseline="central">string</text>
|
||||
</g>
|
||||
<line class="track" x1="312" y1="32" x2="350" y2="32"/>
|
||||
<!-- exit dot with thicker outer ring -->
|
||||
<circle cx="350" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
<circle cx="350" cy="32" r="4" fill="#94A3B8"/>
|
||||
</g>
|
||||
|
||||
<!-- separator between deny and runAction -->
|
||||
<line x1="10" y1="620" x2="890" y2="620" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- ==================== runAction ==================== -->
|
||||
<g transform="translate(0, 630)">
|
||||
<g>
|
||||
<rect x="2" y="22" width="90" height="24" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
|
||||
<text class="label" x="10" y="36" text-anchor="start">runAction</text>
|
||||
</g>
|
||||
|
||||
<!-- entry dot with outer ring -->
|
||||
<circle cx="120" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<circle cx="120" cy="32" r="4" fill="#94A3B8"/>
|
||||
<line class="track" x1="124" y1="32" x2="150" y2="32"/>
|
||||
|
||||
<g class="terminal" transform="translate(150, 16)">
|
||||
<rect width="52" height="32" rx="16"/>
|
||||
<text x="26" y="16" text-anchor="middle" dominant-baseline="central">run</text>
|
||||
</g>
|
||||
<line class="track" x1="202" y1="32" x2="222" y2="32"/>
|
||||
|
||||
<g class="terminal" transform="translate(222, 16)">
|
||||
<rect width="30" height="32" rx="16"/>
|
||||
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">(</text>
|
||||
</g>
|
||||
<line class="track" x1="252" y1="32" x2="272" y2="32"/>
|
||||
|
||||
<g class="nonterminal" transform="translate(272, 16)">
|
||||
<rect width="60" height="32" rx="4"/>
|
||||
<text x="30" y="16" text-anchor="middle" dominant-baseline="central">expr</text>
|
||||
</g>
|
||||
<line class="track" x1="332" y1="32" x2="352" y2="32"/>
|
||||
|
||||
<g class="terminal" transform="translate(352, 16)">
|
||||
<rect width="30" height="32" rx="16"/>
|
||||
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">)</text>
|
||||
</g>
|
||||
<line class="track" x1="382" y1="32" x2="420" y2="32"/>
|
||||
<!-- exit dot with thicker outer ring -->
|
||||
<circle cx="420" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
<circle cx="420" cy="32" r="4" fill="#94A3B8"/>
|
||||
</g>
|
||||
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 15 KiB |
106
.doc/doki/doc/ruki/images/validation-pipeline.svg
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 820 310">
|
||||
<defs>
|
||||
<filter id="shadow" x="-4%" y="-4%" width="108%" height="116%">
|
||||
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-color="#000000" flood-opacity="0.3"/>
|
||||
</filter>
|
||||
<filter id="success-glow" x="-10%" y="-10%" width="120%" height="120%">
|
||||
<feGaussianBlur stdDeviation="3" result="blur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="blur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
<marker id="flow-arrow" viewBox="0 0 12 8" refX="12" refY="4" markerWidth="12" markerHeight="8" orient="auto">
|
||||
<path d="M 0 0 L 12 4 L 0 8 z" fill="#94A3B8"/>
|
||||
</marker>
|
||||
<marker id="err-arrow" viewBox="0 0 12 8" refX="12" refY="4" markerWidth="12" markerHeight="8" orient="auto">
|
||||
<path d="M 0 0 L 12 4 L 0 8 z" fill="#F87171"/>
|
||||
</marker>
|
||||
<style>
|
||||
.title { font: 700 16px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #A5B4FC; }
|
||||
.stage-text { font: 700 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #93C5FD; }
|
||||
.data-text { font: 600 12px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #6EE7B7; }
|
||||
.err-title { font: 700 11px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #FCA5A5; }
|
||||
.err-detail { font: 400 10px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #FDA4AF; }
|
||||
.flow-line { stroke: #94A3B8; stroke-width: 1.5; fill: none; marker-end: url(#flow-arrow); }
|
||||
.err-line { stroke: #F87171; stroke-width: 1.5; fill: none; stroke-dasharray: 5,3; marker-end: url(#err-arrow); }
|
||||
.err-line-solid { stroke: #F87171; stroke-width: 1.5; fill: none; marker-end: url(#err-arrow); }
|
||||
.stage-label { font: 500 10px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #64748B; }
|
||||
.legend-text { font: 400 11px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #94A3B8; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<text class="title" x="410" y="24" text-anchor="middle">Validation pipeline</text>
|
||||
|
||||
<!-- ===== Main pipeline (y=70) ===== -->
|
||||
|
||||
<!-- Input text -->
|
||||
<rect x="20" y="50" width="90" height="40" rx="10" fill="#10B981" fill-opacity="0.12" stroke="#10B981" stroke-width="1.5" stroke-opacity="0.4" filter="url(#shadow)"/>
|
||||
<text class="data-text" x="65" y="74" text-anchor="middle">Input text</text>
|
||||
<line class="flow-line" x1="110" y1="70" x2="148" y2="70"/>
|
||||
|
||||
<!-- Lexer / Parser -->
|
||||
<rect x="148" y="46" width="134" height="48" rx="10" fill="#3B82F6" fill-opacity="0.12" stroke="#3B82F6" stroke-width="1.5" stroke-opacity="0.4" filter="url(#shadow)"/>
|
||||
<text class="stage-text" x="215" y="74" text-anchor="middle">Lexer / Parser</text>
|
||||
<line class="flow-line" x1="282" y1="70" x2="318" y2="70"/>
|
||||
|
||||
<!-- AST -->
|
||||
<rect x="318" y="52" width="60" height="36" rx="10" fill="#10B981" fill-opacity="0.12" stroke="#10B981" stroke-width="1.5" stroke-opacity="0.4" filter="url(#shadow)"/>
|
||||
<text class="data-text" x="348" y="74" text-anchor="middle">AST</text>
|
||||
<line class="flow-line" x1="378" y1="70" x2="414" y2="70"/>
|
||||
|
||||
<!-- Semantic Validator -->
|
||||
<rect x="414" y="46" width="176" height="48" rx="10" fill="#3B82F6" fill-opacity="0.12" stroke="#3B82F6" stroke-width="1.5" stroke-opacity="0.4" filter="url(#shadow)"/>
|
||||
<text class="stage-text" x="502" y="74" text-anchor="middle">Semantic Validator</text>
|
||||
<line class="flow-line" x1="590" y1="70" x2="626" y2="70"/>
|
||||
|
||||
<!-- Valid AST -->
|
||||
<rect x="626" y="50" width="100" height="40" rx="10" fill="#10B981" fill-opacity="0.12" stroke="#10B981" stroke-width="1.5" stroke-opacity="0.4" filter="url(#success-glow)"/>
|
||||
<text class="data-text" x="676" y="74" text-anchor="middle">Valid AST</text>
|
||||
|
||||
<!-- ===== Parse errors (branch down from Lexer/Parser) ===== -->
|
||||
<line class="err-line" x1="215" y1="94" x2="215" y2="140"/>
|
||||
<!-- stage 1 label with pill background -->
|
||||
<rect x="191" y="122" width="50" height="16" rx="4" fill="#64748B" fill-opacity="0.15"/>
|
||||
<text class="stage-label" x="215" y="134" text-anchor="middle">stage 1</text>
|
||||
|
||||
<rect x="130" y="144" width="170" height="52" rx="8" fill="#EF4444" fill-opacity="0.1" stroke="#EF4444" stroke-width="1.5" stroke-opacity="0.35" filter="url(#shadow)"/>
|
||||
<text class="err-title" x="215" y="164" text-anchor="middle">Parse errors</text>
|
||||
<text class="err-detail" x="215" y="180" text-anchor="middle">unknown keyword, missing clause, bad syntax</text>
|
||||
|
||||
<!-- ===== Semantic errors (branch down from Semantic Validator) ===== -->
|
||||
<line class="err-line" x1="502" y1="94" x2="502" y2="140"/>
|
||||
<!-- stage 2 label with pill background -->
|
||||
<rect x="478" y="122" width="50" height="16" rx="4" fill="#64748B" fill-opacity="0.15"/>
|
||||
<text class="stage-label" x="502" y="134" text-anchor="middle">stage 2</text>
|
||||
|
||||
<!-- Semantic error umbrella -->
|
||||
<rect x="350" y="144" width="304" height="36" rx="8" fill="#EF4444" fill-opacity="0.1" stroke="#EF4444" stroke-width="1.5" stroke-opacity="0.35" filter="url(#shadow)"/>
|
||||
<text class="err-title" x="502" y="166" text-anchor="middle">Semantic validation errors</text>
|
||||
|
||||
<!-- Sub-category connectors -->
|
||||
<line class="err-line-solid" x1="400" y1="180" x2="400" y2="200"/>
|
||||
<line class="err-line-solid" x1="502" y1="180" x2="502" y2="200"/>
|
||||
<line class="err-line-solid" x1="604" y1="180" x2="604" y2="200"/>
|
||||
|
||||
<!-- Sub-category boxes -->
|
||||
<rect x="345" y="200" width="110" height="52" rx="8" fill="#EF4444" fill-opacity="0.08" stroke="#EF4444" stroke-width="1" stroke-opacity="0.25" filter="url(#shadow)"/>
|
||||
<text class="err-title" x="400" y="219" text-anchor="middle">Structural</text>
|
||||
<text class="err-detail" x="400" y="237" text-anchor="middle">dup fields, trigger rules</text>
|
||||
|
||||
<rect x="467" y="200" width="110" height="52" rx="8" fill="#EF4444" fill-opacity="0.08" stroke="#EF4444" stroke-width="1" stroke-opacity="0.25" filter="url(#shadow)"/>
|
||||
<text class="err-title" x="522" y="219" text-anchor="middle">Field / Qualifier</text>
|
||||
<text class="err-detail" x="522" y="237" text-anchor="middle">unknown, old./new. scope</text>
|
||||
|
||||
<rect x="589" y="200" width="110" height="52" rx="8" fill="#EF4444" fill-opacity="0.08" stroke="#EF4444" stroke-width="1" stroke-opacity="0.25" filter="url(#shadow)"/>
|
||||
<text class="err-title" x="644" y="219" text-anchor="middle">Type / Operator</text>
|
||||
<text class="err-detail" x="644" y="237" text-anchor="middle">mismatch, bad usage</text>
|
||||
|
||||
<!-- ===== Legend ===== -->
|
||||
<rect x="200" y="275" width="12" height="12" rx="3" fill="#3B82F6" fill-opacity="0.12" stroke="#3B82F6" stroke-width="1" stroke-opacity="0.4"/>
|
||||
<text class="legend-text" x="218" y="285">processing stage</text>
|
||||
<rect x="350" y="275" width="12" height="12" rx="3" fill="#10B981" fill-opacity="0.12" stroke="#10B981" stroke-width="1" stroke-opacity="0.4"/>
|
||||
<text class="legend-text" x="368" y="285">data</text>
|
||||
<rect x="430" y="275" width="12" height="12" rx="3" fill="#EF4444" fill-opacity="0.1" stroke="#EF4444" stroke-width="1" stroke-opacity="0.35"/>
|
||||
<text class="legend-text" x="448" y="285">error</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.9 KiB |
25
.doc/doki/doc/ruki/index.md
Normal file
|
|
@ -0,0 +1,25 @@
|
|||
# Ruki Documentation
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Ruki](#ruki)
|
||||
- [Quick start](#quick-start)
|
||||
- [More details](#more-details)
|
||||
|
||||
## Ruki
|
||||
|
||||
This section documents the Ruki language. Ruki is a small language for finding, creating, updating, and deleting tikis, with SQL-like statements and trigger rules.
|
||||
|
||||
## Quick start
|
||||
|
||||
New users: start with [Quick Start](quick-start.md) and [Examples](examples.md).
|
||||
|
||||
## More details
|
||||
|
||||
- [Syntax](syntax.md): lexical structure and grammar-oriented reference.
|
||||
- [Semantics](semantics.md): statement behavior, trigger structure, qualifier scope, and evaluation model.
|
||||
- [Triggers](triggers.md): configuration, runtime execution model, cascade behavior, and operational patterns.
|
||||
- [Types And Values](types-and-values.md): value categories, literals, `empty`, enums, and schema-dependent typing.
|
||||
- [Operators And Built-ins](operators-and-builtins.md): precedence, operators, built-in functions, and shell-adjacent capabilities.
|
||||
- [Validation And Errors](validation-and-errors.md): parse errors, validation failures, edge cases, and strictness rules.
|
||||
|
||||
255
.doc/doki/doc/ruki/operators-and-builtins.md
Normal file
|
|
@ -0,0 +1,255 @@
|
|||
# Operators And Built-ins
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Operator precedence and associativity](#operator-precedence-and-associativity)
|
||||
- [Comparison operators](#comparison-operators)
|
||||
- [Membership](#membership)
|
||||
- [Any And All](#any-and-all)
|
||||
- [Binary expression operators](#binary-expression-operators)
|
||||
- [Built-in functions](#built-in-functions)
|
||||
- [Shell-related forms](#shell-related-forms)
|
||||
|
||||
## Overview
|
||||
|
||||
This page describes the operators and built-in functions available in Ruki.
|
||||
|
||||
## Operator precedence and associativity
|
||||
|
||||
Condition precedence:
|
||||
|
||||
| Level | Operators or forms | Associativity |
|
||||
|---|---|---|
|
||||
| 1 | condition in parentheses, comparison, `is empty`, `in`, `not in`, `any`, `all` | n/a |
|
||||
| 2 | `not` | right-associative by grammar recursion |
|
||||
| 3 | `and` | left-associative |
|
||||
| 4 | `or` | left-associative |
|
||||
|
||||
Expression precedence:
|
||||
|
||||
| Level | Operators | Associativity |
|
||||
|---|---|---|
|
||||
| 1 | `+`, `-` | left-associative |
|
||||
|
||||
Examples:
|
||||
|
||||
```sql
|
||||
select where not status = "done"
|
||||
select where priority = 1 or priority = 2 and status = "done"
|
||||
create title="x" due=2026-03-25 + 2day - 1day
|
||||
```
|
||||
|
||||
## Comparison operators
|
||||
|
||||
Supported operators:
|
||||
|
||||
- `=`
|
||||
- `!=`
|
||||
- `<`
|
||||
- `>`
|
||||
- `<=`
|
||||
- `>=`
|
||||
|
||||
Supported by type:
|
||||
|
||||
| Type | `=` / `!=` | Ordering operators |
|
||||
|---|---|---|
|
||||
| `string` | yes | no |
|
||||
| `status` | yes | no |
|
||||
| `type` | yes | no |
|
||||
| `id` | yes | no |
|
||||
| `ref` | yes | no |
|
||||
| `int` | yes | yes |
|
||||
| `date` | yes | yes |
|
||||
| `timestamp` | yes | yes |
|
||||
| `duration` | yes | yes |
|
||||
| `bool` | yes | no |
|
||||
| `recurrence` | yes | no |
|
||||
| list types | yes | no |
|
||||
|
||||
Examples:
|
||||
|
||||
```sql
|
||||
select where priority <= 2
|
||||
select where due < 2026-03-25
|
||||
select where updatedAt - createdAt > 7day
|
||||
select where title != "hello"
|
||||
```
|
||||
|
||||
Invalid examples:
|
||||
|
||||
```sql
|
||||
select where status < "done"
|
||||
select where title < "hello"
|
||||
```
|
||||
|
||||
## Membership
|
||||
|
||||
Membership and substring:
|
||||
|
||||
- `<expr> in <collection>`
|
||||
- `<expr> not in <collection>`
|
||||
|
||||
The `in` operator has two modes depending on the right-hand side:
|
||||
|
||||
**List membership** — when the right side is a list:
|
||||
|
||||
- checks whether the value appears in the list
|
||||
- membership uses stricter compatibility than general comparison typing
|
||||
- `id` and `ref` are treated as compatible for membership
|
||||
|
||||
**Substring check** — when the right side is a `string` field:
|
||||
|
||||
- checks whether the left side is a substring of the right side
|
||||
- both sides must be `string` type (not `status`, `type`, `id`, or `ref`)
|
||||
|
||||
Examples:
|
||||
|
||||
```sql
|
||||
select where "bug" in tags
|
||||
select where id in dependsOn
|
||||
select where status in ["done", "cancelled"]
|
||||
select where status not in ["done"]
|
||||
select where "bug" in title
|
||||
select where "bug" in title and "fix" in title
|
||||
select where "ali" in assignee
|
||||
select where "bug" not in title
|
||||
```
|
||||
|
||||
Invalid examples:
|
||||
|
||||
```sql
|
||||
select where "done" in status
|
||||
select where "x" in id
|
||||
```
|
||||
|
||||
## Any And All
|
||||
|
||||
`any` means at least one related tiki matches the condition.
|
||||
|
||||
`all` means every related tiki matches the condition.
|
||||
|
||||
Use them after a field that contains related tikis, such as `dependsOn`:
|
||||
|
||||
- `<expr> any <condition>`
|
||||
- `<expr> all <condition>`
|
||||
|
||||
Rules:
|
||||
|
||||
- the left side must be a field that contains related tikis, such as `dependsOn`
|
||||
- after `any` or `all`, write a condition about those related tikis
|
||||
- do not use `old.` or `new.` inside that condition
|
||||
|
||||
Examples:
|
||||
|
||||
```sql
|
||||
select where dependsOn any status != "done"
|
||||
select where dependsOn all status = "done"
|
||||
```
|
||||
|
||||
These mean:
|
||||
|
||||
- `dependsOn any status != "done"`: at least one dependency is not done
|
||||
- `dependsOn all status = "done"`: every dependency is done
|
||||
|
||||
Invalid examples:
|
||||
|
||||
```sql
|
||||
select where tags any status = "done"
|
||||
before update where dependsOn any old.status = "done" deny "blocked"
|
||||
```
|
||||
|
||||
## Binary expression operators
|
||||
|
||||
`+`
|
||||
|
||||
- string-like plus string-like yields `string`
|
||||
- `int + int` yields `int`
|
||||
- `list<string> + string` and `list<string> + list<string>` yield `list<string>`
|
||||
- `list<ref> + id-or-ref-compatible value` and `list<ref> + list<ref>` yield `list<ref>`
|
||||
- `date + duration` yields `date`
|
||||
- `timestamp + duration` yields `timestamp`
|
||||
|
||||
`-`
|
||||
|
||||
- `int - int` yields `int`
|
||||
- `list<string> - string` and `list<string> - list<string>` yield `list<string>`
|
||||
- `list<ref> - id-or-ref-compatible value` and `list<ref> - list<ref>` yield `list<ref>`
|
||||
- `date - duration` yields `date`
|
||||
- `date - date` yields `duration`
|
||||
- `timestamp - duration` yields `timestamp`
|
||||
- `timestamp - timestamp` yields `duration`
|
||||
|
||||
Examples:
|
||||
|
||||
```sql
|
||||
create title="hello" + " world"
|
||||
create title="x" tags=tags + ["new"]
|
||||
create title="x" dependsOn=dependsOn - ["TIKI-ABC123"]
|
||||
create title="x" due=2026-03-25 + 2day
|
||||
select where updatedAt - createdAt > 1day
|
||||
```
|
||||
|
||||
Invalid examples:
|
||||
|
||||
```sql
|
||||
create title="x" priority=1 + "a"
|
||||
select where due = 2026-03-25 + 2026-03-20
|
||||
create title="x" dependsOn=dependsOn + tags
|
||||
```
|
||||
|
||||
## Built-in functions
|
||||
|
||||
Ruki has these built-ins:
|
||||
|
||||
| Name | Result type | Arguments | Notes |
|
||||
|---|---|---|---|
|
||||
| `count(...)` | `int` | exactly 1 | argument must be a `select` subquery |
|
||||
| `now()` | `timestamp` | 0 | no additional validation |
|
||||
| `next_date(...)` | `date` | exactly 1 | argument must be `recurrence` |
|
||||
| `blocks(...)` | `list<ref>` | exactly 1 | argument must be `id`, `ref`, or string literal |
|
||||
| `id()` | `id` | 0 | valid only in plugin runtime; resolves to selected tiki ID |
|
||||
| `call(...)` | `string` | exactly 1 | argument must be `string` |
|
||||
| `user()` | `string` | 0 | no additional validation |
|
||||
|
||||
Examples:
|
||||
|
||||
```sql
|
||||
select where count(select where status = "done") >= 1
|
||||
select where updatedAt < now()
|
||||
create title="x" due=next_date(recurrence)
|
||||
select where blocks(id) is empty
|
||||
select where id() in dependsOn
|
||||
create title=call("echo hi")
|
||||
select where assignee = user()
|
||||
```
|
||||
|
||||
Runtime notes:
|
||||
|
||||
- `id()` is semantically valid only in plugin runtime.
|
||||
- When a validated statement uses `id()`, plugin execution must provide a non-empty selected task ID.
|
||||
- `id()` is rejected for CLI, event-trigger, and time-trigger semantic runtimes.
|
||||
- `call(...)` is currently rejected by semantic validation.
|
||||
|
||||
`run(...)`
|
||||
|
||||
- not a normal expression built-in
|
||||
- only valid as the top-level action of an `after` trigger
|
||||
- its command expression must validate as `string`
|
||||
|
||||
Example:
|
||||
|
||||
```sql
|
||||
after update where new.status = "in progress" run("echo hello")
|
||||
```
|
||||
|
||||
## Shell-related forms
|
||||
|
||||
Ruki includes two shell-related forms:
|
||||
|
||||
- `call(...)` as a string-returning expression
|
||||
- `run(...)` as an `after`-trigger action
|
||||
|
||||
- `call(...)` returns a string
|
||||
- `run(...)` is used as the top-level action of an `after` trigger
|
||||
129
.doc/doki/doc/ruki/quick-start.md
Normal file
|
|
@ -0,0 +1,129 @@
|
|||
# Quick Start
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Mental model](#mental-model)
|
||||
- [CRUD statements](#crud-statements)
|
||||
- [Conditions and expressions](#conditions-and-expressions)
|
||||
- [Triggers](#triggers)
|
||||
- [Where to go next](#where-to-go-next)
|
||||
|
||||
## Overview
|
||||
|
||||
This page is a practical introduction to the Ruki language. It covers the main statement forms, the conditions they use, and the trigger rules that let you block or react to changes.
|
||||
|
||||
## Mental model
|
||||
|
||||
Ruki has two top-level forms:
|
||||
|
||||
- Statements: `select`, `create`, `update`, and `delete`
|
||||
- Triggers: `before` or `after` rules attached to `create`, `update`, or `delete`
|
||||
|
||||
Statements read and change tiki fields such as `status`, `type`, `tags`, `dependsOn`, `priority`, and `due`. Triggers use the same fields and conditions, but add `before` or `after` timing around `create`, `update`, or `delete`.
|
||||
|
||||
The simplest way to read Ruki is:
|
||||
|
||||
- `select` filters tikis
|
||||
- `create` assigns fields for a new tiki
|
||||
- `update` finds tikis with `where`, then applies `set`
|
||||
- `delete` finds tikis with `where`, then removes them
|
||||
- `before ... deny "message"` blocks an operation when its guard matches
|
||||
- `after ... <action>` reacts to an operation by creating, updating, deleting, or `run(...)`
|
||||
|
||||
## CRUD statements
|
||||
|
||||
`select` reads:
|
||||
|
||||
```sql
|
||||
select
|
||||
select title, status
|
||||
select id, title where status = "done"
|
||||
select where "bug" in tags and priority <= 2
|
||||
```
|
||||
|
||||
`create` writes one or more assignments:
|
||||
|
||||
```sql
|
||||
create title="Fix login"
|
||||
create title="Fix login" priority=2 status="ready" tags=["bug"]
|
||||
```
|
||||
|
||||
`update` always has a `where` clause and a `set` clause:
|
||||
|
||||
```sql
|
||||
update where id = "TIKI-ABC123" set status="done"
|
||||
update where status = "ready" and "sprint-3" in tags set status="cancelled"
|
||||
```
|
||||
|
||||
`delete` always has a `where` clause:
|
||||
|
||||
```sql
|
||||
delete where id = "TIKI-ABC123"
|
||||
delete where status = "cancelled" and "old" in tags
|
||||
```
|
||||
|
||||
## Conditions and expressions
|
||||
|
||||
Conditions support:
|
||||
|
||||
- comparisons such as `status = "done"` or `priority <= 2`
|
||||
- emptiness checks such as `assignee is empty`
|
||||
- membership checks such as `"bug" in tags`
|
||||
- quantifiers over `list<ref>` values such as `dependsOn any status != "done"`
|
||||
- boolean composition with `not`, `and`, and `or`
|
||||
|
||||
Examples:
|
||||
|
||||
```sql
|
||||
select where status = "done" and priority <= 2
|
||||
select where assignee is empty
|
||||
select where status not in ["done", "cancelled"]
|
||||
select where dependsOn all status = "done"
|
||||
select where not (status = "done" or priority = 1)
|
||||
```
|
||||
|
||||
Expressions include literals, field references, built-in calls, list literals, subqueries for `count(...)`, and `+` or `-` binary expressions:
|
||||
|
||||
```sql
|
||||
create title="Fix login"
|
||||
create title="x" due=2026-03-25 + 2day
|
||||
create title="x" tags=tags + ["needs-triage"]
|
||||
create title="x" due=next_date(recurrence)
|
||||
select where count(select where status = "done") >= 1
|
||||
```
|
||||
|
||||
## Triggers
|
||||
|
||||
Triggers add timing and event context around the same condition language.
|
||||
|
||||
`before` triggers can only `deny`:
|
||||
|
||||
```sql
|
||||
before update where new.status = "done" and dependsOn any status != "done" deny "cannot complete tiki with open dependencies"
|
||||
before delete where old.priority <= 2 deny "cannot delete high priority tikis"
|
||||
```
|
||||
|
||||
`after` triggers can perform an action:
|
||||
|
||||
```sql
|
||||
after create where new.priority <= 2 and new.assignee is empty update where id = new.id set assignee="booleanmaybe"
|
||||
after update where new.status = "done" and old.recurrence is not empty create title=old.title priority=old.priority tags=old.tags recurrence=old.recurrence due=next_date(old.recurrence) status="ready"
|
||||
after delete update where old.id in dependsOn set dependsOn=dependsOn - [old.id]
|
||||
```
|
||||
|
||||
`after` triggers may also use `run(...)` as a top-level action:
|
||||
|
||||
```sql
|
||||
after update where new.status = "in progress" and "claude" in new.tags run("claude -p 'implement tiki " + old.id + "'")
|
||||
```
|
||||
|
||||
Triggers are configured in `workflow.yaml` under the `triggers:` key. See [Triggers](triggers.md) for configuration details and runtime behavior.
|
||||
|
||||
## Where to go next
|
||||
|
||||
- Use [Triggers](triggers.md) for configuration, execution model, and runtime behavior.
|
||||
- Use [Syntax](syntax.md) for the grammar-level reference.
|
||||
- Use [Types And Values](types-and-values.md) for the type system and literal rules.
|
||||
- Use [Operators And Built-ins](operators-and-builtins.md) for precedence and function signatures.
|
||||
- Use [Examples](examples.md) for more complete programs and invalid cases.
|
||||
169
.doc/doki/doc/ruki/semantics.md
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
# Semantics
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Statement semantics](#statement-semantics)
|
||||
- [Trigger semantics](#trigger-semantics)
|
||||
- [Qualifier scope](#qualifier-scope)
|
||||
- [Time trigger semantics](#time-trigger-semantics)
|
||||
- [Condition and expression semantics](#condition-and-expression-semantics)
|
||||
|
||||
## Overview
|
||||
|
||||
This page explains how Ruki statements, triggers, conditions, and expressions behave.
|
||||
|
||||
## Statement semantics
|
||||
|
||||
`select`
|
||||
|
||||
- `select` without `where` means a statement with no condition node.
|
||||
- `select where ...` validates the condition and its contained expressions.
|
||||
- `select ... order by <field> [asc|desc], ...` specifies result ordering.
|
||||
- A subquery form `select` or `select where ...` can appear only inside `count(...)`. Subqueries do not support `order by`.
|
||||
|
||||
`order by`
|
||||
|
||||
- Each field must exist in the schema and be an orderable type.
|
||||
- Orderable types: `int`, `date`, `timestamp`, `duration`, `string`, `status`, `type`, `id`, `ref`.
|
||||
- Non-orderable types: `list<string>`, `list<ref>`, `recurrence`, `bool`.
|
||||
- Default direction is ascending. Use `desc` for descending.
|
||||
- Duplicate fields are rejected.
|
||||
- Only bare field names are allowed — `old.` and `new.` qualifiers are not valid in `order by`.
|
||||
|
||||
`create`
|
||||
|
||||
- `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`
|
||||
|
||||
- `update` has two parts: a `where` condition and a `set` assignment list.
|
||||
- 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`
|
||||
|
||||
- `delete` always requires a `where` condition.
|
||||
- The `where` condition is validated exactly like `select where ...`.
|
||||
|
||||
## Trigger semantics
|
||||
|
||||
Triggers have the shape:
|
||||
|
||||
```text
|
||||
<timing> <event> [where <condition>] <deny-or-action>
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `before` triggers must have `deny`.
|
||||
- `before` triggers must not have an action or `run(...)`.
|
||||
- `after` triggers must not have `deny`.
|
||||
- `after` triggers must have either a CRUD action or `run(...)`.
|
||||
- trigger CRUD actions may be `create`, `update`, or `delete`, but not `select`
|
||||
|
||||
Examples:
|
||||
|
||||
```sql
|
||||
before update where new.status = "done" and dependsOn any status != "done" deny "cannot complete tiki with open dependencies"
|
||||
after create where new.priority <= 2 and new.assignee is empty update where id = new.id set assignee="booleanmaybe"
|
||||
after delete update where old.id in dependsOn set dependsOn=dependsOn - [old.id]
|
||||
```
|
||||
|
||||
At runtime, triggers execute in a pipeline: before-triggers run as validators before persistence, the mutation is persisted, then after-triggers run as hooks. For the full execution model, cascade behavior, configuration, and runtime details, see [Triggers](triggers.md).
|
||||
|
||||
## Qualifier scope
|
||||
|
||||
Qualifier rules depend on the event:
|
||||
|
||||
- `create` triggers: `new.` is allowed, `old.` is not
|
||||
- `delete` triggers: `old.` is allowed, `new.` is not
|
||||
- `update` triggers: both `old.` and `new.` are allowed
|
||||
- standalone statements: neither `old.` nor `new.` is allowed
|
||||
|
||||
Examples:
|
||||
|
||||
```sql
|
||||
before create where new.type = "story" and new.description is empty deny "stories must have a description"
|
||||
before delete where old.priority <= 2 deny "cannot delete high priority tikis"
|
||||
before update where old.status = "in progress" and new.status = "done" deny "tikis must go through review before completion"
|
||||
```
|
||||
|
||||

|
||||
|
||||
Important special case:
|
||||
|
||||
- inside a quantifier body such as `dependsOn any ...`, qualifiers are disabled again
|
||||
- use bare fields inside the quantifier body, not `old.` or `new.`
|
||||
|
||||
Example:
|
||||
|
||||
```sql
|
||||
before update where dependsOn any status = "done" deny "blocked"
|
||||
```
|
||||
|
||||
## Time trigger semantics
|
||||
|
||||
Time triggers have the shape:
|
||||
|
||||
```text
|
||||
every <duration> <statement>
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- the interval must be a positive duration (e.g. `1hour`, `2day`, `1week`)
|
||||
- the inner statement must be `create`, `update`, or `delete` — not `select`
|
||||
- `run()` is not allowed inside a time trigger
|
||||
- `old.` and `new.` qualifiers are not allowed — there is no mutation context for a periodic operation
|
||||
- bare field references in the inner statement resolve against the tasks being matched, exactly as in standalone statements
|
||||
|
||||
Examples:
|
||||
|
||||
```sql
|
||||
every 1hour update where status = "in_progress" and updatedAt < now() - 7day set status="backlog"
|
||||
every 1day delete where status = "done" and updatedAt < now() - 30day
|
||||
every 2week create title="sprint review" status="ready" priority=3
|
||||
```
|
||||
|
||||
## Condition and expression semantics
|
||||
|
||||
Conditions:
|
||||
|
||||
- comparisons validate both operand types before checking operator legality
|
||||
- `is empty` and `is not empty` are allowed on every supported type
|
||||
- `in` and `not in` require a collection on the right side
|
||||
- `any` and `all` require `list<ref>` on the left side
|
||||
|
||||
Expressions:
|
||||
|
||||
- field references resolve through the injected schema
|
||||
- qualified references use the same field catalog, then apply qualifier-policy checks
|
||||
- list literals must be homogeneous
|
||||
- `empty` is a context-sensitive zero value, resolved by surrounding type checks
|
||||
- subqueries are only legal as the argument to `count(...)`
|
||||
|
||||
Binary `+` and `-` are semantic rather than purely numeric:
|
||||
|
||||
- string-like `+` yields `string`
|
||||
- `int + int` and `int - int` yield `int`
|
||||
- `list<string> +/- string-or-list<string>` yields `list<string>`
|
||||
- `list<ref> +/- id-ref-compatible values` yields `list<ref>`
|
||||
- `date + duration` yields `date`
|
||||
- `date - duration` yields `date`
|
||||
- `date - date` yields `duration`
|
||||
- `timestamp + duration` yields `timestamp`
|
||||
- `timestamp - duration` yields `timestamp`
|
||||
- `timestamp - timestamp` yields `duration`
|
||||
|
||||

|
||||
|
||||
For the detailed type rules and built-ins, see [Types And Values](types-and-values.md) and [Operators And Built-ins](operators-and-builtins.md).
|
||||
|
||||
218
.doc/doki/doc/ruki/syntax.md
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
# Syntax
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Lexical structure](#lexical-structure)
|
||||
- [Top-level grammar](#top-level-grammar)
|
||||
- [Condition grammar](#condition-grammar)
|
||||
- [Expression grammar](#expression-grammar)
|
||||
- [Operator binding summary](#operator-binding-summary)
|
||||
- [Syntax notes](#syntax-notes)
|
||||
|
||||
## Overview
|
||||
|
||||
This page describes Ruki syntax. It starts with tokens and then shows the grammar for statements, triggers, conditions, and expressions.
|
||||
|
||||
## Lexical structure
|
||||
|
||||
Ruki uses these token classes:
|
||||
|
||||
- comments: `--` to end of line
|
||||
- whitespace: ignored between tokens
|
||||
- durations: `\d+(sec|min|hour|day|week|month|year)s?`
|
||||
- dates: `YYYY-MM-DD`
|
||||
- integers: decimal digits only
|
||||
- strings: double-quoted strings with backslash escapes
|
||||
- comparison operators: `=`, `!=`, `<`, `>`, `<=`, `>=`
|
||||
- binary operators: `+`, `-`
|
||||
- star: `*`
|
||||
- punctuation: `.`, `(`, `)`, `[`, `]`, `,`
|
||||
- identifiers: `[a-zA-Z_][a-zA-Z0-9_]*`
|
||||
|
||||
Examples:
|
||||
|
||||
```sql
|
||||
-- line comment
|
||||
2026-03-25
|
||||
2day
|
||||
"hello"
|
||||
dependsOn
|
||||
new.status
|
||||
```
|
||||
|
||||
## Top-level grammar
|
||||
|
||||
The following EBNF-style summary shows the grammar:
|
||||
|
||||
```text
|
||||
statement = selectStmt | createStmt | updateStmt | deleteStmt ;
|
||||
|
||||
selectStmt = "select" [ fieldList | "*" ] [ "where" condition ] [ orderBy ] ;
|
||||
fieldList = identifier { "," identifier } ;
|
||||
createStmt = "create" assignment { assignment } ;
|
||||
|
||||
orderBy = "order" "by" sortField { "," sortField } ;
|
||||
sortField = identifier [ "asc" | "desc" ] ;
|
||||
updateStmt = "update" "where" condition "set" assignment { assignment } ;
|
||||
deleteStmt = "delete" "where" condition ;
|
||||
|
||||
assignment = identifier "=" expr ;
|
||||
|
||||
trigger = timing event [ "where" condition ] ( action | deny ) ;
|
||||
timing = "before" | "after" ;
|
||||
event = "create" | "update" | "delete" ;
|
||||
|
||||
action = runAction | createStmt | updateStmt | deleteStmt ;
|
||||
runAction = "run" "(" expr ")" ;
|
||||
deny = "deny" string ;
|
||||
|
||||
timeTrigger = "every" duration ( createStmt | updateStmt | deleteStmt ) ;
|
||||
```
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
Notes:
|
||||
|
||||
- `select` is a valid top-level statement, but it is not valid as a trigger action.
|
||||
- `create` requires at least one assignment.
|
||||
- `update` requires both `where` and `set`.
|
||||
- `delete` requires `where`.
|
||||
- `order by` is only valid on `select`, not on subqueries inside `count(...)`.
|
||||
- `asc`, `desc`, `order`, and `by` are contextual keywords — they are only special in the ORDER BY clause.
|
||||
- Bare `select` and `select *` both mean all fields. A field list like `select title, status` projects only the named fields.
|
||||
- `every` wraps a CRUD statement with a periodic interval. Only `create`, `update`, and `delete` are allowed
|
||||
|
||||
## Condition grammar
|
||||
|
||||
Condition precedence follows this order:
|
||||
|
||||
```text
|
||||
condition = orCond ;
|
||||
orCond = andCond { "or" andCond } ;
|
||||
andCond = notCond { "and" notCond } ;
|
||||
notCond = "not" notCond | primaryCond ;
|
||||
primaryCond = "(" condition ")" | exprCond ;
|
||||
|
||||
exprCond = expr
|
||||
[ compareTail
|
||||
| isEmptyTail
|
||||
| isNotEmptyTail
|
||||
| notInTail
|
||||
| inTail
|
||||
| anyTail
|
||||
| allTail ] ;
|
||||
|
||||
compareTail = compareOp expr ;
|
||||
isEmptyTail = "is" "empty" ;
|
||||
isNotEmptyTail = "is" "not" "empty" ;
|
||||
inTail = "in" expr ;
|
||||
notInTail = "not" "in" expr ;
|
||||
anyTail = "any" primaryCond ;
|
||||
allTail = "all" primaryCond ;
|
||||
```
|
||||
|
||||

|
||||
|
||||
Examples:
|
||||
|
||||
```sql
|
||||
select where status = "done"
|
||||
select where assignee is empty
|
||||
select where status not in ["done", "cancelled"]
|
||||
select where dependsOn any status != "done"
|
||||
select where not (status = "done" or priority = 1)
|
||||
```
|
||||
|
||||
Field list:
|
||||
|
||||
```sql
|
||||
select title, status
|
||||
select id, title where status = "done"
|
||||
select * where priority <= 2
|
||||
select title, status where "bug" in tags order by priority
|
||||
```
|
||||
|
||||
Order by:
|
||||
|
||||
```sql
|
||||
select order by priority
|
||||
select where status = "done" order by updatedAt desc
|
||||
select where "bug" in tags order by priority asc, createdAt desc
|
||||
```
|
||||
|
||||
## Expression grammar
|
||||
|
||||
Expressions support literals, field references, qualifiers, function calls, list literals, parenthesized expressions, subqueries, and left-associative `+` or `-` chains:
|
||||
|
||||
```text
|
||||
expr = unaryExpr { ("+" | "-") unaryExpr } ;
|
||||
|
||||
unaryExpr = funcCall
|
||||
| subQuery
|
||||
| qualifiedRef
|
||||
| listLiteral
|
||||
| string
|
||||
| date
|
||||
| duration
|
||||
| int
|
||||
| emptyLiteral
|
||||
| fieldRef
|
||||
| "(" expr ")" ;
|
||||
|
||||
funcCall = identifier "(" [ expr { "," expr } ] ")" ;
|
||||
subQuery = "select" [ "where" condition ] ;
|
||||
qualifiedRef = ( "old" | "new" ) "." identifier ;
|
||||
listLiteral = "[" [ expr { "," expr } ] "]" ;
|
||||
emptyLiteral = "empty" ;
|
||||
fieldRef = identifier ;
|
||||
```
|
||||
|
||||

|
||||
|
||||
Examples:
|
||||
|
||||
```sql
|
||||
title
|
||||
old.status
|
||||
["bug", "frontend"]
|
||||
next_date(recurrence)
|
||||
count(select where status = "done")
|
||||
2026-03-25 + 2day
|
||||
tags + ["needs-triage"]
|
||||
```
|
||||
|
||||
## Operator binding summary
|
||||
|
||||
Condition operators:
|
||||
|
||||
- highest: a condition in parentheses, or a condition built from a single expression
|
||||
- then: `not`
|
||||
- then: `and`
|
||||
- lowest: `or`
|
||||
|
||||
Expression operators:
|
||||
|
||||
- only one binary precedence level exists for expressions
|
||||
- `+` and `-` associate left to right
|
||||
|
||||
That means:
|
||||
|
||||
```sql
|
||||
select where priority = 1 or priority = 2 and status = "done"
|
||||
```
|
||||
|
||||
parses as:
|
||||
|
||||
```text
|
||||
priority = 1 or (priority = 2 and status = "done")
|
||||
```
|
||||
|
||||
## Syntax notes
|
||||
|
||||
- `any` and `all` apply to the condition that comes right after them. If you want to combine that condition with `and` or `or`, use parentheses.
|
||||
- `select` used inside expressions is only valid as a `count(...)` argument. Bare subqueries are rejected during validation.
|
||||
- The grammar accepts `run(<expr>)`, but only as the top-level action of an `after` trigger.
|
||||
- `old.` and `new.` are only allowed in some trigger conditions. See [Semantics](semantics.md) and [Validation And Errors](validation-and-errors.md).
|
||||
314
.doc/doki/doc/ruki/triggers.md
Normal file
|
|
@ -0,0 +1,314 @@
|
|||
# Triggers
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [What triggers look like](#what-triggers-look-like)
|
||||
- [Configuration](#configuration)
|
||||
- [Patterns](#patterns)
|
||||
- [Tips and gotchas](#tips-and-gotchas)
|
||||
- [Execution pipeline](#execution-pipeline)
|
||||
- [Before-trigger behavior](#before-trigger-behavior)
|
||||
- [After-trigger behavior](#after-trigger-behavior)
|
||||
- [Cascade depth](#cascade-depth)
|
||||
- [The run() action](#the-run-action)
|
||||
- [Configuration discovery details](#configuration-discovery-details)
|
||||
- [Time triggers](#time-triggers)
|
||||
- [Startup and error handling](#startup-and-error-handling)
|
||||
|
||||
## Overview
|
||||
|
||||
Triggers are reactive rules that fire when tikis are created, updated, or deleted. A before-trigger can block a mutation with a denial message. An after-trigger can react to a mutation by creating, updating, deleting tikis, or running a shell command.
|
||||
|
||||
This page covers how triggers are configured, how they execute at runtime, and common patterns. For the grammar, see [Syntax](syntax.md). For structural rules and qualifier scoping, see [Semantics](semantics.md). For parse and validation errors, see [Validation And Errors](validation-and-errors.md).
|
||||
|
||||
## What triggers look like
|
||||
|
||||
A before-trigger guards against unwanted changes:
|
||||
|
||||
```sql
|
||||
-- block completing a tiki that has unfinished dependencies
|
||||
before update
|
||||
where new.status = "done" and dependsOn any status != "done"
|
||||
deny "cannot complete tiki with open dependencies"
|
||||
```
|
||||
|
||||
- `before update` — fires before an update is persisted
|
||||
- `where ...` — the guard condition; the trigger only fires when this matches
|
||||
- `deny "..."` — the rejection message returned to the caller
|
||||
|
||||
An after-trigger automates a reaction:
|
||||
|
||||
```sql
|
||||
-- when a recurring tiki is completed, create the next occurrence
|
||||
after update
|
||||
where new.status = "done" and old.recurrence is not empty
|
||||
create title=old.title priority=old.priority tags=old.tags
|
||||
recurrence=old.recurrence due=next_date(old.recurrence) status="ready"
|
||||
```
|
||||
|
||||
- `after update` — fires after an update is persisted
|
||||
- `where ...` — the guard; the action only runs when this matches
|
||||
- `create ...` — the action to perform (can also be `update`, `delete`, or `run(...)`)
|
||||
|
||||
Triggers without a `where` clause fire on every matching event:
|
||||
|
||||
```sql
|
||||
-- clean up reverse dependencies whenever a tiki is deleted
|
||||
after delete
|
||||
update where old.id in dependsOn set dependsOn=dependsOn - [old.id]
|
||||
```
|
||||
|
||||
## Configuration
|
||||
|
||||
Triggers are defined in `workflow.yaml` under the `triggers:` key. Each entry has two fields:
|
||||
|
||||
- `ruki` — the trigger rule in Ruki syntax (required)
|
||||
- `description` — an optional label
|
||||
|
||||
```yaml
|
||||
triggers:
|
||||
- description: "block done with open deps"
|
||||
ruki: >-
|
||||
before update
|
||||
where new.status = "done" and dependsOn any status != "done"
|
||||
deny "resolve dependencies first"
|
||||
|
||||
- description: "auto-assign urgent"
|
||||
ruki: >-
|
||||
after create
|
||||
where new.priority <= 2 and new.assignee is empty
|
||||
update where id = new.id set assignee="booleanmaybe"
|
||||
|
||||
- description: "cleanup deps on delete"
|
||||
ruki: >-
|
||||
after delete
|
||||
update where old.id in dependsOn set dependsOn=dependsOn - [old.id]
|
||||
```
|
||||
|
||||
`workflow.yaml` is searched in the standard configuration locations described in [Configuration](../config.md#configuration-precedence). If multiple files define a `triggers:` section, the last one wins — cwd overrides project, which overrides user. A file without a `triggers:` key does not override anything. An explicit empty list (`triggers: []`) overrides inherited triggers to zero.
|
||||
|
||||
## Patterns
|
||||
|
||||
### Limit work in progress
|
||||
|
||||
Prevent anyone from having too many in-progress tikis at once:
|
||||
|
||||
```sql
|
||||
before update
|
||||
where new.status = "in progress"
|
||||
and count(select where assignee = new.assignee and status = "in progress") >= 3
|
||||
deny "WIP limit reached for this assignee"
|
||||
```
|
||||
|
||||
The `count(select ...)` evaluates against the candidate state — the proposed update is already reflected in the count, so the limit fires before persistence.
|
||||
|
||||
### Auto-assign urgent work
|
||||
|
||||
Automatically assign high-priority tikis that arrive without an owner:
|
||||
|
||||
```sql
|
||||
after create
|
||||
where new.priority <= 2 and new.assignee is empty
|
||||
update where id = new.id set assignee="booleanmaybe"
|
||||
```
|
||||
|
||||
The after-trigger fires after the tiki is persisted, then updates it with the assignee. This cascades through the mutation gate, so any update validators (like WIP limits) still apply to the auto-assignment.
|
||||
|
||||
### Recurring task creation
|
||||
|
||||
When a recurring tiki is completed, create the next occurrence:
|
||||
|
||||
```sql
|
||||
after update
|
||||
where new.status = "done" and old.recurrence is not empty
|
||||
create title=old.title priority=old.priority tags=old.tags
|
||||
recurrence=old.recurrence due=next_date(old.recurrence) status="ready"
|
||||
```
|
||||
|
||||
The new tiki inherits the original's title, priority, tags, and recurrence pattern. Its due date is set to the next occurrence using `next_date()`.
|
||||
|
||||
### Dependency cleanup on delete
|
||||
|
||||
When a tiki is deleted, remove it from every other tiki's `dependsOn` list:
|
||||
|
||||
```sql
|
||||
after delete
|
||||
update where old.id in dependsOn set dependsOn=dependsOn - [old.id]
|
||||
```
|
||||
|
||||
This fires after every delete, with no guard condition. The `old.id in dependsOn` condition finds tikis that depend on the deleted one, and the set clause removes the reference.
|
||||
|
||||
### Cascade completion
|
||||
|
||||
Auto-complete an epic when all its dependencies are done:
|
||||
|
||||
```sql
|
||||
after update
|
||||
where new.status = "done"
|
||||
update where id in blocks(old.id) and type = "epic"
|
||||
and dependsOn all status = "done"
|
||||
set status="done"
|
||||
```
|
||||
|
||||
When any tiki is marked done, this finds epics that block on it. If all of the epic's other dependencies are also done, the epic is completed automatically. This itself fires further after-update triggers, so cascade chains work naturally (up to the depth limit).
|
||||
|
||||
### Propagate cancellation
|
||||
|
||||
When a tiki is cancelled, cancel downstream tikis that haven't started:
|
||||
|
||||
```sql
|
||||
after update
|
||||
where new.status = "cancelled"
|
||||
update where id in blocks(old.id) and status in ["backlog", "ready"]
|
||||
set status="cancelled"
|
||||
```
|
||||
|
||||
Only tikis in `backlog` or `ready` are affected — in-progress work is not cancelled automatically.
|
||||
|
||||
### Run an external command
|
||||
|
||||
Trigger a script when a tiki enters a specific state:
|
||||
|
||||
```sql
|
||||
after update
|
||||
where new.status = "in progress" and "claude" in new.tags
|
||||
run("claude -p 'implement tiki " + old.id + "'")
|
||||
```
|
||||
|
||||
The `run()` action evaluates the expression to a command string, then executes it via `sh -c` with a 30-second timeout. Command failures are logged but do not block the mutation chain.
|
||||
|
||||
## Tips and gotchas
|
||||
|
||||
- Test your guard condition as a `select where ...` statement first. If the select returns unexpected results, the trigger will fire unexpectedly too.
|
||||
- Before-triggers are fail-closed. If the guard expression itself has a runtime error, the mutation is rejected. Keep guard logic straightforward.
|
||||
- Triggers that modify the same fields they guard on can cascade. For example, an after-update trigger that changes `status` will fire other after-update triggers. Design triggers to converge — avoid chains that cycle indefinitely. The cascade depth limit (8) prevents runaway loops, but silent termination is rarely what you want.
|
||||
- `run()` commands execute with the permissions of the tiki process. Treat the `ruki` field in `workflow.yaml` the same as any other executable configuration.
|
||||
- A parse error in any trigger definition prevents the app from starting. Validate your `workflow.yaml` before deploying.
|
||||
|
||||
---
|
||||
|
||||
## Execution pipeline
|
||||
|
||||
When a tiki is created, updated, or deleted, the mutation goes through this pipeline:
|
||||
|
||||
1. **Depth check** — reject if the trigger cascade depth exceeds the limit
|
||||
2. **Before-validators** — run all registered before-triggers for this event; collect rejections
|
||||
3. **Persist** — write the change to the store
|
||||
4. **After-hooks** — run all registered after-triggers for this event
|
||||
|
||||
Before-triggers are registered as mutation validators. They run before persistence and can block the mutation. After-triggers are registered as hooks. They run after persistence and cannot undo it.
|
||||
|
||||
All validators for a given event run — rejections are accumulated, not short-circuited. If any validator rejects, the mutation is blocked and none of the rejection messages are lost.
|
||||
|
||||
After-hooks run in definition order. Each hook's errors are logged but do not propagate — the original mutation is unaffected.
|
||||
|
||||
## Before-trigger behavior
|
||||
|
||||
Before-triggers use **fail-closed** semantics:
|
||||
|
||||
- If the guard condition matches, the mutation is rejected with the `deny` message.
|
||||
- If the guard condition evaluation itself errors (e.g. a runtime type error), the mutation is also rejected. This prevents bad triggers from silently allowing mutations they were meant to block.
|
||||
|
||||
The context provided to before-triggers depends on the event:
|
||||
|
||||
| Event | `old` | `new` | `allTasks` |
|
||||
|---|---|---|---|
|
||||
| create | nil | proposed task | stored tasks + proposed |
|
||||
| update | persisted (cloned) | proposed version | stored tasks with proposed applied |
|
||||
| delete | task being deleted | nil | current stored tasks |
|
||||
|
||||
For before-update triggers, `allTasks` reflects the **candidate state** — the proposed update is already applied in the task list. This matters for aggregate predicates like WIP limits using `count(select ...)`, which need to see the world as it would look after the update.
|
||||
|
||||
## After-trigger behavior
|
||||
|
||||
After-triggers use **fail-open** semantics:
|
||||
|
||||
- If the guard condition matches, the action executes.
|
||||
- If the guard condition evaluation itself errors, the trigger is skipped and the error is logged. The mutation chain continues.
|
||||
- If the action fails, the error is logged. The original mutation is not rolled back.
|
||||
|
||||
After-hooks read a fresh task list from the store each time they fire. This means cascaded triggers see the current state of the world, including changes made by earlier triggers in the chain.
|
||||
|
||||
After-triggers support two action forms:
|
||||
|
||||
- A CRUD action (`create`, `update`, or `delete`) — executed through the mutation gate, which fires its own triggers
|
||||
- A `run()` command — executed as a shell command (see [The run() action](#the-run-action))
|
||||
|
||||
## Cascade depth
|
||||
|
||||
After-triggers can cause further mutations, which fire their own triggers, and so on. To prevent infinite loops, cascade depth is tracked:
|
||||
|
||||
- The root mutation (user-initiated) runs at depth 0.
|
||||
- Each triggered mutation increments the depth by 1.
|
||||
- At depth >= 8, after-hooks are skipped with a warning log.
|
||||
- At depth > 8, the mutation gate rejects the mutation entirely.
|
||||
|
||||
The maximum cascade depth is **8**. Termination is graceful — a warning is logged, not a panic. Within a cascade, each after-hook reads the latest store state, so it sees changes from earlier triggers.
|
||||
|
||||
## The run() action
|
||||
|
||||
When an after-trigger uses `run(...)`, the command expression is evaluated to a string, then executed:
|
||||
|
||||
- The command runs via `sh -c <command-string>`.
|
||||
- A **30-second timeout** is enforced. If the command exceeds it, the child process is killed.
|
||||
- Command failure (non-zero exit) is **logged** but does not block the mutation chain.
|
||||
- Command success is also logged.
|
||||
|
||||
The command string is dynamically evaluated from the trigger's expression, which may reference `old.id`, `new.status`, or other fields via string concatenation.
|
||||
|
||||
## Configuration discovery details
|
||||
|
||||
Trigger definitions are loaded from `workflow.yaml` using the standard [configuration precedence](../config.md#configuration-precedence). The **last file with a `triggers:` key wins**:
|
||||
|
||||
| File | `triggers:` key | Effect |
|
||||
|---|---|---|
|
||||
| user config | yes, 2 triggers | base: 2 triggers |
|
||||
| project config | absent | no override, user triggers survive |
|
||||
| cwd config | `triggers: []` | override: 0 triggers |
|
||||
|
||||
A file that exists but has no `triggers:` key expresses no opinion and does not override. An explicit empty list (`triggers: []`) is an active override that disables inherited triggers.
|
||||
|
||||
If two candidate paths resolve to the same absolute path (e.g. when the project root is the current directory), the file is read once.
|
||||
|
||||
## Time triggers
|
||||
|
||||
Time triggers use the `every` keyword to define a periodic CRUD operation:
|
||||
|
||||
```
|
||||
every <duration> <statement>
|
||||
```
|
||||
|
||||
Where `<statement>` is `create`, `update`, or `delete` (not `select` or `run()`). The interval must be a positive duration.
|
||||
|
||||
```yaml
|
||||
triggers:
|
||||
- description: stale tasks go back to backlog
|
||||
ruki: >
|
||||
every 1hour
|
||||
update where status = "in_progress" and updatedAt < now() - 7day set status="backlog"
|
||||
|
||||
- description: delete expired tasks
|
||||
ruki: >
|
||||
every 1day
|
||||
delete where status = "done" and updatedAt < now() - 30day
|
||||
```
|
||||
|
||||
Time triggers differ from event triggers in several ways:
|
||||
|
||||
- No timing/event pair (`before`/`after` + `create`/`update`/`delete`) — just `every` + duration
|
||||
- No `where` guard at the trigger level — filtering belongs inside the CRUD statement
|
||||
- No `old.`/`new.` qualifiers — there is no "old" or "new" task context for a periodic operation
|
||||
- No `deny` or `run()` — only mutating CRUD statements
|
||||
|
||||
Time triggers are parsed and validated at startup alongside event triggers. A parse error in any trigger definition prevents the app from starting.
|
||||
|
||||
**Note:** the time trigger scheduler (executor) is not yet implemented. Time trigger definitions are parsed and stored, but they do not run periodically at this time.
|
||||
|
||||
## Startup and error handling
|
||||
|
||||
Triggers are loaded during application startup, after the store is initialized but before controllers are created.
|
||||
|
||||
- Each trigger definition is parsed with the ruki parser. A parse error in any trigger is **fail-fast**: the application will not start, and the error message identifies the failing trigger by its `description` (or by index if no description is set).
|
||||
- If no `triggers:` section is found in any workflow file, zero triggers are loaded and the app starts normally.
|
||||
- Successfully loaded triggers are logged with a count at startup.
|
||||
164
.doc/doki/doc/ruki/types-and-values.md
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
# Types And Values
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Value types](#value-types)
|
||||
- [Field catalog](#field-catalog)
|
||||
- [Literals](#literals)
|
||||
- [The empty literal](#the-empty-literal)
|
||||
- [Enum normalization](#enum-normalization)
|
||||
- [List constraints](#list-constraints)
|
||||
- [Type notes](#type-notes)
|
||||
|
||||
## Overview
|
||||
|
||||
This page explains the value types used in Ruki. You do not write types explicitly. Ruki works them out from the values, expressions, built-in functions, and tiki fields you use.
|
||||
|
||||
## Value types
|
||||
|
||||
Ruki uses these value types:
|
||||
|
||||
| Type | Meaning |
|
||||
|---|---|
|
||||
| `string` | plain string values |
|
||||
| `int` | integer values such as `priority` and `points` |
|
||||
| `date` | day-level date values |
|
||||
| `timestamp` | timestamp values such as `createdAt` and `updatedAt` |
|
||||
| `duration` | duration literals and date or timestamp differences |
|
||||
| `bool` | boolean result type (reserved, not currently produced by any expression) |
|
||||
| `id` | tiki identifier |
|
||||
| `ref` | tiki reference |
|
||||
| `recurrence` | recurrence value |
|
||||
| `list<string>` | list of strings |
|
||||
| `list<ref>` | list of references |
|
||||
| `status` | workflow status enum |
|
||||
| `type` | workflow tiki-type enum |
|
||||
|
||||
## Field catalog
|
||||
|
||||
The workflow field catalog exposes these fields to Ruki:
|
||||
|
||||
| Field | Type |
|
||||
|---|---|
|
||||
| `id` | `id` |
|
||||
| `title` | `string` |
|
||||
| `description` | `string` |
|
||||
| `status` | `status` |
|
||||
| `type` | `type` |
|
||||
| `tags` | `list<string>` |
|
||||
| `dependsOn` | `list<ref>` |
|
||||
| `due` | `date` |
|
||||
| `recurrence` | `recurrence` |
|
||||
| `assignee` | `string` |
|
||||
| `priority` | `int` |
|
||||
| `points` | `int` |
|
||||
| `createdBy` | `string` |
|
||||
| `createdAt` | `timestamp` |
|
||||
| `updatedAt` | `timestamp` |
|
||||
|
||||
## Literals
|
||||
|
||||
Implemented literal forms:
|
||||
|
||||
| Form | Example | Inferred type |
|
||||
|---|---|---|
|
||||
| string literal | `"Fix login"` | `string` |
|
||||
| int literal | `2` | `int` |
|
||||
| date literal | `2026-03-25` | `date` |
|
||||
| duration literal | `2day` | `duration` |
|
||||
| list literal | `["bug", "frontend"]` | usually `list<string>` |
|
||||
| empty literal | `empty` | context-sensitive |
|
||||
|
||||
Qualified and unqualified references are not literals, but they participate in type inference:
|
||||
|
||||
- `status` resolves through the schema
|
||||
- `old.status` or `new.status` resolve through the same schema, then pass qualifier checks
|
||||
|
||||
## The empty literal
|
||||
|
||||
`empty` is a special context-sensitive literal. Its type depends on where you use it.
|
||||
|
||||
Implemented behavior:
|
||||
|
||||
- `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
|
||||
|
||||
Examples:
|
||||
|
||||
```sql
|
||||
create title="x" assignee=empty
|
||||
create title="x" priority=empty
|
||||
select where assignee = empty
|
||||
select where due is empty
|
||||
```
|
||||
|
||||
## Enum normalization
|
||||
|
||||
`status` and `type` are special:
|
||||
|
||||
- they have dedicated semantic types
|
||||
- they accept validated string literals
|
||||
- comparisons against enum fields are stricter than generic string comparisons
|
||||
|
||||
`status`
|
||||
|
||||
- normalized through the injected schema
|
||||
- production normalization lowercases, trims, and converts `-` and space to `_`
|
||||
- recognized values depend on the workflow status registry
|
||||
|
||||
`type`
|
||||
|
||||
- normalized through the injected schema
|
||||
- production normalization lowercases, trims, and removes separators
|
||||
- default built-in types are `story`, `bug`, `spike`, and `epic`
|
||||
- default aliases include `feature` and `task` mapping to `story`
|
||||
|
||||
Examples:
|
||||
|
||||
```sql
|
||||
select where status = "done"
|
||||
select where type = "bug"
|
||||
create title="x" status="done"
|
||||
create title="x" type="feature"
|
||||
```
|
||||
|
||||
## List constraints
|
||||
|
||||
List rules are intentionally strict:
|
||||
|
||||
- list literals must be homogeneous
|
||||
- non-empty lists infer their type from the first element
|
||||
- empty list literals default to `list<string>` until context narrows them
|
||||
- `id` and `ref` elements cause a list literal to be treated as `list<ref>`
|
||||
|
||||
Important edge cases:
|
||||
|
||||
- `dependsOn=["TIKI-ABC123"]` is valid because a string-literal list can be assigned to `list<ref>`
|
||||
- `dependsOn=["TIKI-ABC123", title]` is invalid because `list<ref>` assignment only permits literal string elements in that special case
|
||||
- `tags=[1, 2]` is invalid because `tags` is `list<string>`
|
||||
|
||||
Examples:
|
||||
|
||||
```sql
|
||||
create title="x" tags=["bug", "frontend"]
|
||||
create title="x" dependsOn=["TIKI-ABC123", "TIKI-DEF456"]
|
||||
create title="x" dependsOn=[]
|
||||
```
|
||||
|
||||
Invalid examples:
|
||||
|
||||
```sql
|
||||
create title="x" tags=[1, 2]
|
||||
create title="x" dependsOn=["TIKI-ABC123", title]
|
||||
select where status in ["done", 1]
|
||||
```
|
||||
|
||||
## Type notes
|
||||
|
||||
- `string`, `status`, `type`, `id`, and `ref` are treated as string-like in some comparison and concatenation paths, but they are not interchangeable everywhere.
|
||||
- Membership checks are stricter than general comparison compatibility. For `in` and `not in` with list collections, only exact type matches count, except that `id` and `ref` are treated as compatible with each other. When the right side is a `string` field, `in` performs a substring check — both sides must be `string` type (not `status`, `type`, `id`, or `ref`).
|
||||
- Enum fields reject non-literal field references in assignments such as `status=title` or `type=title`.
|
||||
- The exact accepted status values depend on runtime workflow configuration, while the accepted type values depend on the type registry supplied to the parser.
|
||||
257
.doc/doki/doc/ruki/validation-and-errors.md
Normal file
|
|
@ -0,0 +1,257 @@
|
|||
# Validation And Errors
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Validation layers](#validation-layers)
|
||||
- [Structural statement and trigger errors](#structural-statement-and-trigger-errors)
|
||||
- [Field and qualifier errors](#field-and-qualifier-errors)
|
||||
- [Type and operator errors](#type-and-operator-errors)
|
||||
- [Enum and list errors](#enum-and-list-errors)
|
||||
- [Order by errors](#order-by-errors)
|
||||
- [Built-in and subquery errors](#built-in-and-subquery-errors)
|
||||
|
||||
## Overview
|
||||
|
||||
This page explains the errors you can get in Ruki. It covers syntax errors, unknown fields, type mismatches, invalid enum values, unsupported operators, and invalid trigger structure.
|
||||
|
||||
## Validation layers
|
||||
|
||||

|
||||
|
||||
Ruki has two distinct failure stages:
|
||||
|
||||
1. Parse-time failures
|
||||
2. Validation-time failures
|
||||
|
||||
Parse-time failures happen when the input does not fit the grammar at all.
|
||||
|
||||
Examples:
|
||||
|
||||
```sql
|
||||
drop where id = 1
|
||||
update set status="done"
|
||||
delete id = "x"
|
||||
after update select
|
||||
```
|
||||
|
||||
Validation-time failures happen after parsing, once the AST is checked against schema and semantic rules.
|
||||
|
||||
Examples:
|
||||
|
||||
```sql
|
||||
select where foo = "bar"
|
||||
create title="x" priority="high"
|
||||
select where status < "done"
|
||||
before update where new.status = "done"
|
||||
```
|
||||
|
||||
## Structural statement and trigger errors
|
||||
|
||||
Statements:
|
||||
|
||||
- `create` must have at least one assignment
|
||||
- `update` must have at least one assignment in `set`
|
||||
- duplicate assignments to the same field are rejected
|
||||
|
||||
Triggers:
|
||||
|
||||
- `before` triggers must have `deny`
|
||||
- `before` triggers must not have action or `run(...)`
|
||||
- `after` triggers must have an action or `run(...)`
|
||||
- `after` triggers must not have `deny`
|
||||
- trigger actions must not be `select`
|
||||
|
||||
Examples:
|
||||
|
||||
```sql
|
||||
before update where new.status = "done" update where id = old.id set status="done"
|
||||
after update where new.status = "done" deny "no"
|
||||
before update where new.status = "done"
|
||||
after update where new.status = "done"
|
||||
```
|
||||
|
||||
## Field and qualifier errors
|
||||
|
||||
Unknown field errors:
|
||||
|
||||
```sql
|
||||
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
|
||||
- `old.` is invalid in create-trigger contexts
|
||||
- `new.` is invalid in delete-trigger contexts
|
||||
- both are invalid inside quantifier bodies
|
||||
|
||||
Examples:
|
||||
|
||||
```sql
|
||||
select where old.status = "done"
|
||||
create title=old.title
|
||||
after create where old.status = "done" update where id = new.id set status="done"
|
||||
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:
|
||||
|
||||
```sql
|
||||
select where priority = "high"
|
||||
select where status = title
|
||||
select where type = assignee
|
||||
```
|
||||
|
||||
Unsupported operators:
|
||||
|
||||
```sql
|
||||
select where title < "hello"
|
||||
select where status < "done"
|
||||
select where recurrence < recurrence
|
||||
```
|
||||
|
||||
Invalid assignment types:
|
||||
|
||||
```sql
|
||||
create title="x" priority="high"
|
||||
create title="x" assignee=42
|
||||
create title="x" status=title
|
||||
update where id="x" set title=status
|
||||
```
|
||||
|
||||
Invalid binary expressions:
|
||||
|
||||
```sql
|
||||
create title="x" priority=1 + "a"
|
||||
select where due = 2026-03-25 + 2026-03-20
|
||||
create title="x" dependsOn=dependsOn + status
|
||||
create title="x" dependsOn=dependsOn + tags
|
||||
```
|
||||
|
||||
## Enum and list errors
|
||||
|
||||
Unknown enum values:
|
||||
|
||||
```sql
|
||||
select where status = "nonexistent"
|
||||
select where type = "nonexistent"
|
||||
create title="x" status="nonexistent"
|
||||
create title="x" type="nonexistent"
|
||||
```
|
||||
|
||||
Invalid enum list membership:
|
||||
|
||||
```sql
|
||||
select where status in ["done", "bogus"]
|
||||
select where type in ["bug", "bogus"]
|
||||
```
|
||||
|
||||
List strictness:
|
||||
|
||||
- list literals must be homogeneous
|
||||
- `list<string>` fields reject non-string elements
|
||||
- the special `list<ref>` assignment path accepts string-literal lists, but not arbitrary string fields or mixed element expressions
|
||||
|
||||
Invalid examples:
|
||||
|
||||
```sql
|
||||
select where status in ["done", 1]
|
||||
create title="x" tags=[1, 2]
|
||||
create title="x" dependsOn=["TIKI-ABC123", title]
|
||||
select where status in dependsOn
|
||||
select where tags any status = "done"
|
||||
```
|
||||
|
||||
## Order by errors
|
||||
|
||||
Unknown field:
|
||||
|
||||
```sql
|
||||
select order by nonexistent
|
||||
```
|
||||
|
||||
Non-orderable types:
|
||||
|
||||
```sql
|
||||
select order by tags
|
||||
select order by dependsOn
|
||||
select order by recurrence
|
||||
```
|
||||
|
||||
Duplicate field:
|
||||
|
||||
```sql
|
||||
select order by priority, priority desc
|
||||
```
|
||||
|
||||
Order by inside a subquery:
|
||||
|
||||
```sql
|
||||
select where count(select where status = "done" order by priority) >= 1
|
||||
```
|
||||
|
||||
## Built-in and subquery errors
|
||||
|
||||
Unknown function:
|
||||
|
||||
```sql
|
||||
select where foo(1) = 1
|
||||
```
|
||||
|
||||
Argument count errors:
|
||||
|
||||
```sql
|
||||
select where now(1) = now()
|
||||
select where count() >= 1
|
||||
select where user(1) = "bob"
|
||||
```
|
||||
|
||||
Argument type errors:
|
||||
|
||||
```sql
|
||||
select where blocks(priority) is empty
|
||||
create title=call(42)
|
||||
create title="x" due=next_date(42)
|
||||
```
|
||||
|
||||
Subquery restrictions:
|
||||
|
||||
- only `count(...)` accepts a subquery
|
||||
- bare subqueries elsewhere are rejected
|
||||
- `count(...)` validates the subquery body recursively
|
||||
|
||||
Examples:
|
||||
|
||||
```sql
|
||||
select where count(select where status = "done") >= 1
|
||||
select where count(select where nosuchfield = "x") >= 1
|
||||
select where select = 1
|
||||
create title=select
|
||||
```
|
||||
|
||||
|
|
@ -104,3 +104,23 @@ views:
|
|||
foreground: "#ff9966"
|
||||
background: "#2b3a42"
|
||||
key: "F2"
|
||||
|
||||
triggers:
|
||||
- description: block completion with open dependencies
|
||||
ruki: >
|
||||
before update
|
||||
where new.status = "done" and new.dependsOn any status != "done"
|
||||
deny "cannot complete: has open dependencies"
|
||||
- description: no jumping from backlog to done
|
||||
ruki: >
|
||||
before update
|
||||
where old.status = "backlog" and new.status = "done"
|
||||
deny "cannot move directly from backlog to done"
|
||||
- description: remove deleted task from dependency lists
|
||||
ruki: >
|
||||
after delete
|
||||
update where old.id in dependsOn set dependsOn=dependsOn - [old.id]
|
||||
- description: clean up completed tasks after 24 hours
|
||||
ruki: >
|
||||
every 1day
|
||||
delete where status = "done" and updatedAt < now() - 1day
|
||||
|
|
|
|||
|
|
@ -4,34 +4,28 @@ import (
|
|||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
|
||||
"github.com/boolean-maybe/tiki/workflow"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// StatusDef defines a single workflow status loaded from workflow.yaml.
|
||||
type StatusDef struct {
|
||||
Key string `yaml:"key"`
|
||||
Label string `yaml:"label"`
|
||||
Emoji string `yaml:"emoji"`
|
||||
Active bool `yaml:"active"`
|
||||
Default bool `yaml:"default"`
|
||||
Done bool `yaml:"done"`
|
||||
}
|
||||
// StatusDef is a type alias for workflow.StatusDef.
|
||||
// Kept for backward compatibility during migration.
|
||||
type StatusDef = workflow.StatusDef
|
||||
|
||||
// StatusRegistry is the central, ordered collection of valid statuses.
|
||||
// It is loaded once from workflow.yaml during bootstrap and accessed globally.
|
||||
type StatusRegistry struct {
|
||||
statuses []StatusDef
|
||||
byKey map[string]StatusDef
|
||||
defaultKey string
|
||||
doneKey string
|
||||
// StatusRegistry is a type alias for workflow.StatusRegistry.
|
||||
type StatusRegistry = workflow.StatusRegistry
|
||||
|
||||
// NormalizeStatusKey delegates to workflow.NormalizeStatusKey.
|
||||
func NormalizeStatusKey(key string) string {
|
||||
return string(workflow.NormalizeStatusKey(key))
|
||||
}
|
||||
|
||||
var (
|
||||
globalRegistry *StatusRegistry
|
||||
registryMu sync.RWMutex
|
||||
globalStatusRegistry *workflow.StatusRegistry
|
||||
globalTypeRegistry *workflow.TypeRegistry
|
||||
registryMu sync.RWMutex
|
||||
)
|
||||
|
||||
// LoadStatusRegistry reads the statuses: section from workflow.yaml files.
|
||||
|
|
@ -53,17 +47,27 @@ func LoadStatusRegistry() error {
|
|||
}
|
||||
|
||||
registryMu.Lock()
|
||||
globalRegistry = reg
|
||||
globalStatusRegistry = reg
|
||||
registryMu.Unlock()
|
||||
slog.Debug("loaded status registry", "file", path, "count", len(reg.statuses))
|
||||
slog.Debug("loaded status registry", "file", path, "count", len(reg.All()))
|
||||
|
||||
// also initialize type registry with defaults
|
||||
typeReg, err := workflow.NewTypeRegistry(workflow.DefaultTypeDefs())
|
||||
if err != nil {
|
||||
return fmt.Errorf("initializing type registry: %w", err)
|
||||
}
|
||||
registryMu.Lock()
|
||||
globalTypeRegistry = typeReg
|
||||
registryMu.Unlock()
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// loadStatusRegistryFromFiles iterates workflow files and returns the registry
|
||||
// from the last file that contains a non-empty statuses section.
|
||||
// Returns a parse error immediately if any file is malformed.
|
||||
func loadStatusRegistryFromFiles(files []string) (*StatusRegistry, string, error) {
|
||||
var lastReg *StatusRegistry
|
||||
func loadStatusRegistryFromFiles(files []string) (*workflow.StatusRegistry, string, error) {
|
||||
var lastReg *workflow.StatusRegistry
|
||||
var lastFile string
|
||||
|
||||
for _, path := range files {
|
||||
|
|
@ -83,100 +87,67 @@ func loadStatusRegistryFromFiles(files []string) (*StatusRegistry, string, error
|
|||
// GetStatusRegistry returns the global StatusRegistry.
|
||||
// Panics if LoadStatusRegistry() was never called — this is a programming error,
|
||||
// not a user-facing path.
|
||||
func GetStatusRegistry() *StatusRegistry {
|
||||
func GetStatusRegistry() *workflow.StatusRegistry {
|
||||
registryMu.RLock()
|
||||
defer registryMu.RUnlock()
|
||||
if globalRegistry == nil {
|
||||
if globalStatusRegistry == nil {
|
||||
panic("config: GetStatusRegistry called before LoadStatusRegistry")
|
||||
}
|
||||
return globalRegistry
|
||||
return globalStatusRegistry
|
||||
}
|
||||
|
||||
// GetTypeRegistry returns the global TypeRegistry.
|
||||
// Panics if LoadStatusRegistry() was never called.
|
||||
func GetTypeRegistry() *workflow.TypeRegistry {
|
||||
registryMu.RLock()
|
||||
defer registryMu.RUnlock()
|
||||
if globalTypeRegistry == nil {
|
||||
panic("config: GetTypeRegistry called before LoadStatusRegistry")
|
||||
}
|
||||
return globalTypeRegistry
|
||||
}
|
||||
|
||||
// MaybeGetTypeRegistry returns the global TypeRegistry if it has been
|
||||
// initialized, or (nil, false) when LoadStatusRegistry() has not run yet.
|
||||
func MaybeGetTypeRegistry() (*workflow.TypeRegistry, bool) {
|
||||
registryMu.RLock()
|
||||
defer registryMu.RUnlock()
|
||||
return globalTypeRegistry, globalTypeRegistry != nil
|
||||
}
|
||||
|
||||
// ResetStatusRegistry replaces the global registry with one built from the given defs.
|
||||
// Intended for tests only.
|
||||
func ResetStatusRegistry(defs []StatusDef) {
|
||||
reg, err := buildRegistry(defs)
|
||||
func ResetStatusRegistry(defs []workflow.StatusDef) {
|
||||
reg, err := workflow.NewStatusRegistry(defs)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("ResetStatusRegistry: %v", err))
|
||||
}
|
||||
typeReg, err := workflow.NewTypeRegistry(workflow.DefaultTypeDefs())
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("ResetStatusRegistry: type registry: %v", err))
|
||||
}
|
||||
registryMu.Lock()
|
||||
globalRegistry = reg
|
||||
globalStatusRegistry = reg
|
||||
globalTypeRegistry = typeReg
|
||||
registryMu.Unlock()
|
||||
}
|
||||
|
||||
// ClearStatusRegistry removes the global registry. Intended for test teardown.
|
||||
// ClearStatusRegistry removes the global registries. Intended for test teardown.
|
||||
func ClearStatusRegistry() {
|
||||
registryMu.Lock()
|
||||
globalRegistry = nil
|
||||
globalStatusRegistry = nil
|
||||
globalTypeRegistry = nil
|
||||
registryMu.Unlock()
|
||||
}
|
||||
|
||||
// --- Registry methods ---
|
||||
|
||||
// All returns the ordered list of status definitions.
|
||||
func (r *StatusRegistry) All() []StatusDef {
|
||||
return r.statuses
|
||||
}
|
||||
|
||||
// Lookup returns the StatusDef for a given key (normalized) and whether it exists.
|
||||
func (r *StatusRegistry) Lookup(key string) (StatusDef, bool) {
|
||||
def, ok := r.byKey[NormalizeStatusKey(key)]
|
||||
return def, ok
|
||||
}
|
||||
|
||||
// IsValid reports whether key is a recognized status.
|
||||
func (r *StatusRegistry) IsValid(key string) bool {
|
||||
_, ok := r.byKey[NormalizeStatusKey(key)]
|
||||
return ok
|
||||
}
|
||||
|
||||
// IsActive reports whether the status has the active flag set.
|
||||
func (r *StatusRegistry) IsActive(key string) bool {
|
||||
def, ok := r.byKey[NormalizeStatusKey(key)]
|
||||
return ok && def.Active
|
||||
}
|
||||
|
||||
// IsDone reports whether the status has the done flag set.
|
||||
func (r *StatusRegistry) IsDone(key string) bool {
|
||||
def, ok := r.byKey[NormalizeStatusKey(key)]
|
||||
return ok && def.Done
|
||||
}
|
||||
|
||||
// DefaultKey returns the key of the status with default: true.
|
||||
func (r *StatusRegistry) DefaultKey() string {
|
||||
return r.defaultKey
|
||||
}
|
||||
|
||||
// DoneKey returns the key of the status with done: true.
|
||||
func (r *StatusRegistry) DoneKey() string {
|
||||
return r.doneKey
|
||||
}
|
||||
|
||||
// Keys returns all status keys in definition order.
|
||||
func (r *StatusRegistry) Keys() []string {
|
||||
keys := make([]string, len(r.statuses))
|
||||
for i, s := range r.statuses {
|
||||
keys[i] = s.Key
|
||||
}
|
||||
return keys
|
||||
}
|
||||
|
||||
// NormalizeStatusKey lowercases, trims, and normalizes separators in a status key.
|
||||
func NormalizeStatusKey(key string) string {
|
||||
normalized := strings.ToLower(strings.TrimSpace(key))
|
||||
normalized = strings.ReplaceAll(normalized, "-", "_")
|
||||
normalized = strings.ReplaceAll(normalized, " ", "_")
|
||||
return normalized
|
||||
}
|
||||
|
||||
// --- internal ---
|
||||
|
||||
// workflowStatusData is the YAML shape we unmarshal to extract just the statuses key.
|
||||
type workflowStatusData struct {
|
||||
Statuses []StatusDef `yaml:"statuses"`
|
||||
Statuses []workflow.StatusDef `yaml:"statuses"`
|
||||
}
|
||||
|
||||
func loadStatusesFromFile(path string) (*StatusRegistry, error) {
|
||||
func loadStatusesFromFile(path string) (*workflow.StatusRegistry, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading %s: %w", path, err)
|
||||
|
|
@ -191,55 +162,5 @@ func loadStatusesFromFile(path string) (*StatusRegistry, error) {
|
|||
return nil, nil // no statuses in this file, try next
|
||||
}
|
||||
|
||||
return buildRegistry(ws.Statuses)
|
||||
}
|
||||
|
||||
func buildRegistry(defs []StatusDef) (*StatusRegistry, error) {
|
||||
if len(defs) == 0 {
|
||||
return nil, fmt.Errorf("statuses list is empty")
|
||||
}
|
||||
|
||||
reg := &StatusRegistry{
|
||||
statuses: make([]StatusDef, 0, len(defs)),
|
||||
byKey: make(map[string]StatusDef, len(defs)),
|
||||
}
|
||||
|
||||
for i, def := range defs {
|
||||
if def.Key == "" {
|
||||
return nil, fmt.Errorf("status at index %d has empty key", i)
|
||||
}
|
||||
|
||||
normalized := NormalizeStatusKey(def.Key)
|
||||
def.Key = normalized
|
||||
|
||||
if _, exists := reg.byKey[normalized]; exists {
|
||||
return nil, fmt.Errorf("duplicate status key %q", normalized)
|
||||
}
|
||||
|
||||
if def.Default {
|
||||
if reg.defaultKey != "" {
|
||||
slog.Warn("multiple statuses marked default; using first", "first", reg.defaultKey, "duplicate", normalized)
|
||||
} else {
|
||||
reg.defaultKey = normalized
|
||||
}
|
||||
}
|
||||
if def.Done {
|
||||
if reg.doneKey != "" {
|
||||
slog.Warn("multiple statuses marked done; using first", "first", reg.doneKey, "duplicate", normalized)
|
||||
} else {
|
||||
reg.doneKey = normalized
|
||||
}
|
||||
}
|
||||
|
||||
reg.byKey[normalized] = def
|
||||
reg.statuses = append(reg.statuses, def)
|
||||
}
|
||||
|
||||
// If no explicit default, use the first status
|
||||
if reg.defaultKey == "" {
|
||||
reg.defaultKey = reg.statuses[0].Key
|
||||
slog.Warn("no status marked default; using first status", "key", reg.defaultKey)
|
||||
}
|
||||
|
||||
return reg, nil
|
||||
return workflow.NewStatusRegistry(ws.Statuses)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,10 +4,12 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/boolean-maybe/tiki/workflow"
|
||||
)
|
||||
|
||||
func defaultTestStatuses() []StatusDef {
|
||||
return []StatusDef{
|
||||
func defaultTestStatuses() []workflow.StatusDef {
|
||||
return []workflow.StatusDef{
|
||||
{Key: "backlog", Label: "Backlog", Emoji: "📥", Default: true},
|
||||
{Key: "ready", Label: "Ready", Emoji: "📋", Active: true},
|
||||
{Key: "in_progress", Label: "In Progress", Emoji: "⚙️", Active: true},
|
||||
|
|
@ -16,7 +18,7 @@ func defaultTestStatuses() []StatusDef {
|
|||
}
|
||||
}
|
||||
|
||||
func setupTestRegistry(t *testing.T, defs []StatusDef) {
|
||||
func setupTestRegistry(t *testing.T, defs []workflow.StatusDef) {
|
||||
t.Helper()
|
||||
ResetStatusRegistry(defs)
|
||||
t.Cleanup(func() { ClearStatusRegistry() })
|
||||
|
|
@ -40,7 +42,7 @@ func TestBuildRegistry_DefaultStatuses(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestBuildRegistry_CustomStatuses(t *testing.T) {
|
||||
custom := []StatusDef{
|
||||
custom := []workflow.StatusDef{
|
||||
{Key: "new", Label: "New", Emoji: "🆕", Default: true},
|
||||
{Key: "wip", Label: "Work In Progress", Emoji: "🔧", Active: true},
|
||||
{Key: "closed", Label: "Closed", Emoji: "🔒", Done: true},
|
||||
|
|
@ -137,7 +139,7 @@ func TestRegistry_Keys(t *testing.T) {
|
|||
reg := GetStatusRegistry()
|
||||
|
||||
keys := reg.Keys()
|
||||
expected := []string{"backlog", "ready", "in_progress", "review", "done"}
|
||||
expected := []workflow.StatusKey{"backlog", "ready", "in_progress", "review", "done"}
|
||||
|
||||
if len(keys) != len(expected) {
|
||||
t.Fatalf("expected %d keys, got %d", len(expected), len(keys))
|
||||
|
|
@ -150,7 +152,7 @@ func TestRegistry_Keys(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestRegistry_NormalizesKeys(t *testing.T) {
|
||||
custom := []StatusDef{
|
||||
custom := []workflow.StatusDef{
|
||||
{Key: "In-Progress", Label: "In Progress", Default: true},
|
||||
{Key: " DONE ", Label: "Done", Done: true},
|
||||
}
|
||||
|
|
@ -166,39 +168,39 @@ func TestRegistry_NormalizesKeys(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestBuildRegistry_EmptyKey(t *testing.T) {
|
||||
defs := []StatusDef{
|
||||
defs := []workflow.StatusDef{
|
||||
{Key: "", Label: "No Key"},
|
||||
}
|
||||
_, err := buildRegistry(defs)
|
||||
_, err := workflow.NewStatusRegistry(defs)
|
||||
if err == nil {
|
||||
t.Error("expected error for empty key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRegistry_DuplicateKey(t *testing.T) {
|
||||
defs := []StatusDef{
|
||||
defs := []workflow.StatusDef{
|
||||
{Key: "ready", Label: "Ready", Default: true},
|
||||
{Key: "ready", Label: "Ready 2"},
|
||||
}
|
||||
_, err := buildRegistry(defs)
|
||||
_, err := workflow.NewStatusRegistry(defs)
|
||||
if err == nil {
|
||||
t.Error("expected error for duplicate key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRegistry_Empty(t *testing.T) {
|
||||
_, err := buildRegistry(nil)
|
||||
_, err := workflow.NewStatusRegistry(nil)
|
||||
if err == nil {
|
||||
t.Error("expected error for empty statuses")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRegistry_DefaultFallsToFirst(t *testing.T) {
|
||||
defs := []StatusDef{
|
||||
defs := []workflow.StatusDef{
|
||||
{Key: "alpha", Label: "Alpha"},
|
||||
{Key: "beta", Label: "Beta"},
|
||||
}
|
||||
reg, err := buildRegistry(defs)
|
||||
reg, err := workflow.NewStatusRegistry(defs)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
|
@ -239,6 +241,110 @@ func writeTempWorkflow(t *testing.T, dir, content string) string {
|
|||
return path
|
||||
}
|
||||
|
||||
// setupLoadRegistryTest creates temp dirs and configures the path manager so
|
||||
// LoadStatusRegistry can discover workflow.yaml files via FindWorkflowFiles.
|
||||
func setupLoadRegistryTest(t *testing.T) (cwdDir string) {
|
||||
t.Helper()
|
||||
ClearStatusRegistry()
|
||||
t.Cleanup(func() { ResetStatusRegistry(defaultTestStatuses()) })
|
||||
|
||||
userDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", userDir)
|
||||
if err := os.MkdirAll(filepath.Join(userDir, "tiki"), 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
projectDir := t.TempDir()
|
||||
docDir := filepath.Join(projectDir, ".doc")
|
||||
if err := os.MkdirAll(docDir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cwdDir = t.TempDir()
|
||||
originalDir, _ := os.Getwd()
|
||||
t.Cleanup(func() { _ = os.Chdir(originalDir) })
|
||||
_ = os.Chdir(cwdDir)
|
||||
|
||||
ResetPathManager()
|
||||
pm := mustGetPathManager()
|
||||
pm.projectRoot = projectDir
|
||||
|
||||
return cwdDir
|
||||
}
|
||||
|
||||
func TestLoadStatusRegistry_HappyPath(t *testing.T) {
|
||||
cwdDir := setupLoadRegistryTest(t)
|
||||
|
||||
content := `
|
||||
statuses:
|
||||
- key: open
|
||||
label: Open
|
||||
emoji: "🔓"
|
||||
default: true
|
||||
- key: closed
|
||||
label: Closed
|
||||
emoji: "🔒"
|
||||
done: true
|
||||
views:
|
||||
- name: board
|
||||
lanes:
|
||||
- name: Open
|
||||
filter: "status = 'open'"
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := LoadStatusRegistry(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
reg := GetStatusRegistry()
|
||||
if reg.DefaultKey() != "open" {
|
||||
t.Errorf("expected default key 'open', got %q", reg.DefaultKey())
|
||||
}
|
||||
if reg.DoneKey() != "closed" {
|
||||
t.Errorf("expected done key 'closed', got %q", reg.DoneKey())
|
||||
}
|
||||
|
||||
// type registry should also be initialized
|
||||
typeReg := GetTypeRegistry()
|
||||
if !typeReg.IsValid("story") {
|
||||
t.Error("expected type 'story' to be valid after LoadStatusRegistry")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadStatusRegistry_NoWorkflowFiles(t *testing.T) {
|
||||
_ = setupLoadRegistryTest(t)
|
||||
// no workflow.yaml files anywhere
|
||||
|
||||
err := LoadStatusRegistry()
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no workflow files found")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadStatusRegistry_NoStatusesDefined(t *testing.T) {
|
||||
cwdDir := setupLoadRegistryTest(t)
|
||||
|
||||
// workflow.yaml exists with views but no statuses
|
||||
content := `
|
||||
views:
|
||||
- name: board
|
||||
lanes:
|
||||
- name: All
|
||||
filter: "status = 'ready'"
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := LoadStatusRegistry()
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no statuses defined")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadStatusRegistryFromFiles_LastFileWins(t *testing.T) {
|
||||
dir1 := t.TempDir()
|
||||
dir2 := t.TempDir()
|
||||
|
|
@ -345,3 +451,166 @@ func TestRegistry_IsDone(t *testing.T) {
|
|||
t.Error("expected 'backlog' to not be marked as done")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetTypeRegistry(t *testing.T) {
|
||||
setupTestRegistry(t, defaultTestStatuses())
|
||||
reg := GetTypeRegistry()
|
||||
|
||||
if !reg.IsValid("story") {
|
||||
t.Error("expected 'story' to be valid")
|
||||
}
|
||||
if !reg.IsValid("bug") {
|
||||
t.Error("expected 'bug' to be valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeGetTypeRegistry_Initialized(t *testing.T) {
|
||||
setupTestRegistry(t, defaultTestStatuses())
|
||||
|
||||
reg, ok := MaybeGetTypeRegistry()
|
||||
if !ok {
|
||||
t.Fatal("expected MaybeGetTypeRegistry to return true after init")
|
||||
}
|
||||
if reg == nil {
|
||||
t.Fatal("expected non-nil registry")
|
||||
}
|
||||
if !reg.IsValid("story") {
|
||||
t.Error("expected 'story' to be valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMaybeGetTypeRegistry_Uninitialized(t *testing.T) {
|
||||
ClearStatusRegistry()
|
||||
t.Cleanup(func() {
|
||||
ResetStatusRegistry(defaultTestStatuses())
|
||||
})
|
||||
|
||||
reg, ok := MaybeGetTypeRegistry()
|
||||
if ok {
|
||||
t.Error("expected MaybeGetTypeRegistry to return false when uninitialized")
|
||||
}
|
||||
if reg != nil {
|
||||
t.Error("expected nil registry when uninitialized")
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetStatusRegistry_PanicsWhenUninitialized(t *testing.T) {
|
||||
ClearStatusRegistry()
|
||||
t.Cleanup(func() {
|
||||
ResetStatusRegistry(defaultTestStatuses())
|
||||
})
|
||||
|
||||
defer func() {
|
||||
r := recover()
|
||||
if r == nil {
|
||||
t.Fatal("expected panic from GetStatusRegistry when uninitialized")
|
||||
}
|
||||
}()
|
||||
GetStatusRegistry()
|
||||
}
|
||||
|
||||
func TestGetTypeRegistry_PanicsWhenUninitialized(t *testing.T) {
|
||||
ClearStatusRegistry()
|
||||
t.Cleanup(func() {
|
||||
ResetStatusRegistry(defaultTestStatuses())
|
||||
})
|
||||
|
||||
defer func() {
|
||||
r := recover()
|
||||
if r == nil {
|
||||
t.Fatal("expected panic from GetTypeRegistry when uninitialized")
|
||||
}
|
||||
}()
|
||||
GetTypeRegistry()
|
||||
}
|
||||
|
||||
func TestLoadStatusRegistryFromFiles_NoStatuses(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := writeTempWorkflow(t, dir, `
|
||||
views:
|
||||
- name: backlog
|
||||
`)
|
||||
|
||||
reg, path, err := loadStatusRegistryFromFiles([]string{f})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if reg != nil {
|
||||
t.Error("expected nil registry when no statuses defined")
|
||||
}
|
||||
if path != "" {
|
||||
t.Errorf("expected empty path, got %q", path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadStatusRegistryFromFiles_ReadError(t *testing.T) {
|
||||
_, _, err := loadStatusRegistryFromFiles([]string{"/nonexistent/path/workflow.yaml"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadStatusesFromFile_InvalidYAML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := filepath.Join(dir, "workflow.yaml")
|
||||
if err := os.WriteFile(f, []byte("{{{{invalid yaml"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, err := loadStatusesFromFile(f)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid YAML")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadStatusesFromFile_EmptyStatuses(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := filepath.Join(dir, "workflow.yaml")
|
||||
if err := os.WriteFile(f, []byte("statuses: []\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
reg, err := loadStatusesFromFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if reg != nil {
|
||||
t.Error("expected nil registry for empty statuses list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadStatusRegistry_MalformedFile(t *testing.T) {
|
||||
cwdDir := setupLoadRegistryTest(t)
|
||||
|
||||
// write a workflow.yaml with invalid YAML so loadStatusRegistryFromFiles returns an error
|
||||
content := `statuses: [[[not valid yaml`
|
||||
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := LoadStatusRegistry()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for malformed workflow.yaml")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadStatusRegistryFromFiles_AllFilesEmpty(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f1 := filepath.Join(dir, "workflow1.yaml")
|
||||
f2 := filepath.Join(dir, "workflow2.yaml")
|
||||
if err := os.WriteFile(f1, []byte("other_key: true\n"), 0644); err != nil {
|
||||
t.Fatalf("failed to write file: %v", err)
|
||||
}
|
||||
if err := os.WriteFile(f2, []byte("statuses: []\n"), 0644); err != nil {
|
||||
t.Fatalf("failed to write file: %v", err)
|
||||
}
|
||||
|
||||
reg, path, err := loadStatusRegistryFromFiles([]string{f1, f2})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if reg != nil {
|
||||
t.Error("expected nil registry when all files have empty statuses")
|
||||
}
|
||||
if path != "" {
|
||||
t.Errorf("expected empty path, got %q", path)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
91
config/triggers.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// TriggerDef represents a single trigger entry in workflow.yaml.
|
||||
type TriggerDef struct {
|
||||
Description string `yaml:"description"`
|
||||
Ruki string `yaml:"ruki"`
|
||||
}
|
||||
|
||||
// triggerFileData is the minimal YAML structure for reading triggers from workflow.yaml.
|
||||
type triggerFileData struct {
|
||||
Triggers []TriggerDef `yaml:"triggers"`
|
||||
}
|
||||
|
||||
// LoadTriggerDefs discovers and returns raw trigger definitions from workflow.yaml files.
|
||||
// Uses its own discovery path (not FindWorkflowFiles) to avoid the empty-views filter.
|
||||
// Override semantics: last file with a triggers: section wins (cwd > project > user).
|
||||
// An explicit empty list (triggers: []) overrides inherited triggers.
|
||||
// The caller is responsible for parsing each TriggerDef.Ruki with ruki.ParseTrigger.
|
||||
func LoadTriggerDefs() ([]TriggerDef, error) {
|
||||
pm := mustGetPathManager()
|
||||
|
||||
// candidate paths in discovery order: user config → project config → cwd
|
||||
candidates := []string{
|
||||
pm.UserConfigWorkflowFile(),
|
||||
filepath.Join(pm.ProjectConfigDir(), defaultWorkflowFilename),
|
||||
defaultWorkflowFilename, // relative to cwd
|
||||
}
|
||||
|
||||
var winningDefs []TriggerDef
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, path := range candidates {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
abs = path
|
||||
}
|
||||
if seen[abs] {
|
||||
continue
|
||||
}
|
||||
seen[abs] = true
|
||||
|
||||
defs, found, err := readTriggersFromFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading triggers from %s: %w", path, err)
|
||||
}
|
||||
if found {
|
||||
// last file with a triggers: section wins
|
||||
winningDefs = defs
|
||||
}
|
||||
}
|
||||
|
||||
return winningDefs, nil
|
||||
}
|
||||
|
||||
// readTriggersFromFile reads a workflow.yaml and returns its triggers section.
|
||||
// Returns (defs, true, nil) if the file exists and has a triggers: key.
|
||||
// Returns (nil, false, nil) if the file doesn't exist or has no triggers: key.
|
||||
func readTriggersFromFile(path string) ([]TriggerDef, bool, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// check whether the YAML contains a triggers: key at all
|
||||
// (absent section = no opinion, does not override)
|
||||
var raw map[string]interface{}
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
return nil, false, fmt.Errorf("parsing YAML: %w", err)
|
||||
}
|
||||
if _, hasTriggers := raw["triggers"]; !hasTriggers {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
var tf triggerFileData
|
||||
if err := yaml.Unmarshal(data, &tf); err != nil {
|
||||
return nil, false, fmt.Errorf("parsing triggers: %w", err)
|
||||
}
|
||||
|
||||
return tf.Triggers, true, nil
|
||||
}
|
||||
422
config/triggers_test.go
Normal file
|
|
@ -0,0 +1,422 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// --- readTriggersFromFile unit tests ---
|
||||
|
||||
func TestReadTriggersFromFile_NonExistent(t *testing.T) {
|
||||
defs, found, err := readTriggersFromFile("/no/such/file.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Fatal("expected found=false for missing file")
|
||||
}
|
||||
if defs != nil {
|
||||
t.Fatalf("expected nil defs, got %v", defs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadTriggersFromFile_NoTriggersKey(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "workflow.yaml")
|
||||
if err := os.WriteFile(path, []byte("views:\n - name: board\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defs, found, err := readTriggersFromFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if found {
|
||||
t.Fatal("expected found=false when triggers: key absent")
|
||||
}
|
||||
if defs != nil {
|
||||
t.Fatalf("expected nil defs, got %v", defs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadTriggersFromFile_EmptyTriggersList(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "workflow.yaml")
|
||||
if err := os.WriteFile(path, []byte("triggers: []\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defs, found, err := readTriggersFromFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected found=true when triggers: key present (even if empty)")
|
||||
}
|
||||
if len(defs) != 0 {
|
||||
t.Fatalf("expected 0 defs, got %d", len(defs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadTriggersFromFile_WithTriggers(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "workflow.yaml")
|
||||
content := `triggers:
|
||||
- description: "block done"
|
||||
ruki: 'before update where new.status = "done" deny "no"'
|
||||
- description: "auto-assign"
|
||||
ruki: 'after create where new.assignee is empty update where id = new.id set assignee="bot"'
|
||||
`
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
defs, found, err := readTriggersFromFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !found {
|
||||
t.Fatal("expected found=true")
|
||||
}
|
||||
if len(defs) != 2 {
|
||||
t.Fatalf("expected 2 defs, got %d", len(defs))
|
||||
}
|
||||
if defs[0].Description != "block done" {
|
||||
t.Errorf("defs[0].Description = %q, want %q", defs[0].Description, "block done")
|
||||
}
|
||||
if defs[1].Description != "auto-assign" {
|
||||
t.Errorf("defs[1].Description = %q, want %q", defs[1].Description, "auto-assign")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadTriggersFromFile_InvalidYAML(t *testing.T) {
|
||||
path := filepath.Join(t.TempDir(), "workflow.yaml")
|
||||
if err := os.WriteFile(path, []byte(":\ninvalid: [yaml\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
_, _, err := readTriggersFromFile(path)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid YAML")
|
||||
}
|
||||
}
|
||||
|
||||
// --- LoadTriggerDefs precedence tests ---
|
||||
|
||||
// setupTriggerPrecedenceTest creates temp dirs for user, project, and cwd,
|
||||
// resets the PathManager, and returns a cleanup function.
|
||||
func setupTriggerPrecedenceTest(t *testing.T) (userTikiDir, projectDocDir, cwdDir string) {
|
||||
t.Helper()
|
||||
|
||||
userDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", userDir)
|
||||
userTikiDir = filepath.Join(userDir, "tiki")
|
||||
if err := os.MkdirAll(userTikiDir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
projectDir := t.TempDir()
|
||||
projectDocDir = filepath.Join(projectDir, ".doc")
|
||||
if err := os.MkdirAll(projectDocDir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
cwdDir = t.TempDir()
|
||||
|
||||
originalDir, _ := os.Getwd()
|
||||
t.Cleanup(func() { _ = os.Chdir(originalDir) })
|
||||
_ = os.Chdir(cwdDir)
|
||||
|
||||
ResetPathManager()
|
||||
pm := mustGetPathManager()
|
||||
pm.projectRoot = projectDir
|
||||
|
||||
return userTikiDir, projectDocDir, cwdDir
|
||||
}
|
||||
|
||||
func writeTriggerFile(t *testing.T, dir, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(filepath.Join(dir, "workflow.yaml"), []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTriggerDefs_CwdOverridesProjectAndUser(t *testing.T) {
|
||||
userDir, projectDir, cwdDir := setupTriggerPrecedenceTest(t)
|
||||
|
||||
writeTriggerFile(t, userDir, `triggers:
|
||||
- description: "user trigger"
|
||||
ruki: 'before update deny "user"'
|
||||
`)
|
||||
writeTriggerFile(t, projectDir, `triggers:
|
||||
- description: "project trigger"
|
||||
ruki: 'before update deny "project"'
|
||||
`)
|
||||
writeTriggerFile(t, cwdDir, `triggers:
|
||||
- description: "cwd trigger"
|
||||
ruki: 'before update deny "cwd"'
|
||||
`)
|
||||
|
||||
defs, err := LoadTriggerDefs()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(defs) != 1 {
|
||||
t.Fatalf("expected 1 def (cwd wins), got %d", len(defs))
|
||||
}
|
||||
if defs[0].Description != "cwd trigger" {
|
||||
t.Errorf("expected cwd trigger, got %q", defs[0].Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTriggerDefs_ProjectOverridesUser(t *testing.T) {
|
||||
userDir, projectDir, _ := setupTriggerPrecedenceTest(t)
|
||||
|
||||
writeTriggerFile(t, userDir, `triggers:
|
||||
- description: "user trigger"
|
||||
ruki: 'before update deny "user"'
|
||||
`)
|
||||
writeTriggerFile(t, projectDir, `triggers:
|
||||
- description: "project trigger"
|
||||
ruki: 'before update deny "project"'
|
||||
`)
|
||||
// no cwd workflow.yaml
|
||||
|
||||
defs, err := LoadTriggerDefs()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(defs) != 1 {
|
||||
t.Fatalf("expected 1 def (project wins), got %d", len(defs))
|
||||
}
|
||||
if defs[0].Description != "project trigger" {
|
||||
t.Errorf("expected project trigger, got %q", defs[0].Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTriggerDefs_UserFallback(t *testing.T) {
|
||||
userDir, _, _ := setupTriggerPrecedenceTest(t)
|
||||
|
||||
writeTriggerFile(t, userDir, `triggers:
|
||||
- description: "user trigger"
|
||||
ruki: 'before update deny "user"'
|
||||
`)
|
||||
// no project or cwd workflow.yaml
|
||||
|
||||
defs, err := LoadTriggerDefs()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(defs) != 1 {
|
||||
t.Fatalf("expected 1 def (user fallback), got %d", len(defs))
|
||||
}
|
||||
if defs[0].Description != "user trigger" {
|
||||
t.Errorf("expected user trigger, got %q", defs[0].Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTriggerDefs_EmptyListOverridesInherited(t *testing.T) {
|
||||
userDir, projectDir, _ := setupTriggerPrecedenceTest(t)
|
||||
|
||||
writeTriggerFile(t, userDir, `triggers:
|
||||
- description: "user trigger"
|
||||
ruki: 'before update deny "user"'
|
||||
`)
|
||||
// project explicitly disables triggers with empty list
|
||||
writeTriggerFile(t, projectDir, "triggers: []\n")
|
||||
|
||||
defs, err := LoadTriggerDefs()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(defs) != 0 {
|
||||
t.Fatalf("expected 0 defs (empty list overrides user), got %d", len(defs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTriggerDefs_NoTriggersKeyDoesNotOverride(t *testing.T) {
|
||||
userDir, projectDir, _ := setupTriggerPrecedenceTest(t)
|
||||
|
||||
writeTriggerFile(t, userDir, `triggers:
|
||||
- description: "user trigger"
|
||||
ruki: 'before update deny "user"'
|
||||
`)
|
||||
// project has workflow.yaml but no triggers: key — should not override
|
||||
writeTriggerFile(t, projectDir, "views:\n - name: board\n")
|
||||
|
||||
defs, err := LoadTriggerDefs()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(defs) != 1 {
|
||||
t.Fatalf("expected 1 def (user preserved), got %d", len(defs))
|
||||
}
|
||||
if defs[0].Description != "user trigger" {
|
||||
t.Errorf("expected user trigger, got %q", defs[0].Description)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTriggerDefs_NoWorkflowFiles(t *testing.T) {
|
||||
_, _, _ = setupTriggerPrecedenceTest(t)
|
||||
// no workflow.yaml files anywhere
|
||||
|
||||
defs, err := LoadTriggerDefs()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(defs) != 0 {
|
||||
t.Fatalf("expected 0 defs, got %d", len(defs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTriggerDefs_DeduplicatesAbsPath(t *testing.T) {
|
||||
// when project root == cwd, the project and cwd candidates resolve to the same file
|
||||
userDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", userDir)
|
||||
if err := os.MkdirAll(filepath.Join(userDir, "tiki"), 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
sharedDir := t.TempDir()
|
||||
docDir := filepath.Join(sharedDir, ".doc")
|
||||
if err := os.MkdirAll(docDir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
originalDir, _ := os.Getwd()
|
||||
t.Cleanup(func() { _ = os.Chdir(originalDir) })
|
||||
_ = os.Chdir(sharedDir)
|
||||
|
||||
ResetPathManager()
|
||||
pm := mustGetPathManager()
|
||||
pm.projectRoot = sharedDir
|
||||
|
||||
// write workflow.yaml in cwd (== project root's parent of .doc — but workflow.yaml is at cwd level)
|
||||
writeTriggerFile(t, sharedDir, `triggers:
|
||||
- description: "shared trigger"
|
||||
ruki: 'before update deny "shared"'
|
||||
`)
|
||||
|
||||
defs, err := LoadTriggerDefs()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// should find it once, not duplicated
|
||||
if len(defs) != 1 {
|
||||
t.Fatalf("expected 1 def (deduped), got %d", len(defs))
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadTriggersFromFile_MalformedTriggersSection(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := filepath.Join(dir, "workflow.yaml")
|
||||
// triggers key is present but with wrong type (string instead of list)
|
||||
content := "triggers: \"not a list\"\n"
|
||||
if err := os.WriteFile(f, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, _, err := readTriggersFromFile(f)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for malformed triggers section")
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadTriggersFromFile_PermissionError(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := filepath.Join(dir, "workflow.yaml")
|
||||
if err := os.WriteFile(f, []byte("triggers: []\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// make file unreadable
|
||||
if err := os.Chmod(f, 0000); err != nil {
|
||||
t.Skip("cannot change file permissions on this platform")
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(f, 0600) })
|
||||
// on Windows, chmod succeeds but doesn't restrict reads — verify it actually worked
|
||||
if r, openErr := os.Open(f); openErr == nil {
|
||||
_ = r.Close()
|
||||
t.Skip("chmod 0000 did not restrict read access on this platform")
|
||||
}
|
||||
|
||||
_, _, err := readTriggersFromFile(f)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unreadable file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTriggerDefs_FileReadError(t *testing.T) {
|
||||
userDir, projectDir, _ := setupTriggerPrecedenceTest(t)
|
||||
|
||||
// user dir has a valid file
|
||||
writeTriggerFile(t, userDir, `triggers:
|
||||
- description: "user trigger"
|
||||
ruki: 'before update deny "user"'
|
||||
`)
|
||||
|
||||
// project dir has an unreadable file (not invalid YAML, but unreadable)
|
||||
f := filepath.Join(projectDir, "workflow.yaml")
|
||||
if err := os.WriteFile(f, []byte("triggers: []\n"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.Chmod(f, 0000); err != nil {
|
||||
t.Skip("cannot change file permissions on this platform")
|
||||
}
|
||||
t.Cleanup(func() { _ = os.Chmod(f, 0600) })
|
||||
if r, openErr := os.Open(f); openErr == nil {
|
||||
_ = r.Close()
|
||||
t.Skip("chmod 0000 did not restrict read access on this platform")
|
||||
}
|
||||
|
||||
_, err := LoadTriggerDefs()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unreadable workflow.yaml")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTriggerDefs_CwdEqualsProjectConfigDir(t *testing.T) {
|
||||
// when cwd == ProjectConfigDir(), candidates 2 and 3 resolve to the same
|
||||
// absolute path, exercising the seen[abs] dedup branch.
|
||||
userDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", userDir)
|
||||
if err := os.MkdirAll(filepath.Join(userDir, "tiki"), 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
projectDir := t.TempDir()
|
||||
// resolve symlinks so projectRoot matches what filepath.Abs returns from cwd
|
||||
// (on macOS /var/folders -> /private/var/folders via symlink)
|
||||
projectDir, err := filepath.EvalSymlinks(projectDir)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
docDir := filepath.Join(projectDir, ".doc")
|
||||
if err := os.MkdirAll(docDir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// set cwd to the project config dir (.doc/) so that:
|
||||
// candidate 2 = projectRoot/.doc/workflow.yaml
|
||||
// candidate 3 = cwd/workflow.yaml = projectRoot/.doc/workflow.yaml (same abs path)
|
||||
originalDir, _ := os.Getwd()
|
||||
t.Cleanup(func() { _ = os.Chdir(originalDir) })
|
||||
_ = os.Chdir(docDir)
|
||||
|
||||
ResetPathManager()
|
||||
pm := mustGetPathManager()
|
||||
pm.projectRoot = projectDir
|
||||
|
||||
writeTriggerFile(t, docDir, `triggers:
|
||||
- description: "doc trigger"
|
||||
ruki: 'before update deny "doc"'
|
||||
`)
|
||||
|
||||
defs, err := LoadTriggerDefs()
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
// the file should be read exactly once despite two candidates resolving to it
|
||||
if len(defs) != 1 {
|
||||
t.Fatalf("expected 1 def (deduped), got %d", len(defs))
|
||||
}
|
||||
if defs[0].Description != "doc trigger" {
|
||||
t.Errorf("expected 'doc trigger', got %q", defs[0].Description)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/plugin"
|
||||
"github.com/boolean-maybe/tiki/service"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
|
@ -27,6 +29,7 @@ type DepsController struct {
|
|||
// NewDepsController creates a dependency editor controller.
|
||||
func NewDepsController(
|
||||
taskStore store.Store,
|
||||
mutationGate *service.TaskMutationGate,
|
||||
pluginConfig *model.PluginConfig,
|
||||
pluginDef *plugin.TikiPlugin,
|
||||
navController *NavigationController,
|
||||
|
|
@ -35,6 +38,7 @@ func NewDepsController(
|
|||
return &DepsController{
|
||||
pluginBase: pluginBase{
|
||||
taskStore: taskStore,
|
||||
mutationGate: mutationGate,
|
||||
pluginConfig: pluginConfig,
|
||||
pluginDef: pluginDef,
|
||||
navController: navController,
|
||||
|
|
@ -191,8 +195,11 @@ func (dc *DepsController) handleMoveTask(offset int) bool {
|
|||
slog.Error("deps move: failed to apply action", "task_id", u.taskID, "error", err)
|
||||
return false
|
||||
}
|
||||
if err := dc.taskStore.UpdateTask(updated); err != nil {
|
||||
if err := dc.mutationGate.UpdateTask(context.Background(), updated); err != nil {
|
||||
slog.Error("deps move: failed to update task", "task_id", u.taskID, "error", err)
|
||||
if dc.statusline != nil {
|
||||
dc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
|
||||
}
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/plugin"
|
||||
"github.com/boolean-maybe/tiki/service"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
|
@ -47,8 +48,11 @@ func newDepsTestEnv(t *testing.T) (*DepsController, store.Store) {
|
|||
pluginConfig := model.NewPluginConfig("Dependency")
|
||||
pluginConfig.SetLaneLayout([]int{1, 2, 1}, nil)
|
||||
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
|
||||
nav := newMockNavigationController()
|
||||
dc := NewDepsController(taskStore, pluginConfig, pluginDef, nav, nil)
|
||||
dc := NewDepsController(taskStore, gate, pluginConfig, pluginDef, nav, nil)
|
||||
return dc, taskStore
|
||||
}
|
||||
|
||||
|
|
@ -430,6 +434,102 @@ func TestDepsController_EnsureFirstNonEmptyLaneSelection(t *testing.T) {
|
|||
})
|
||||
}
|
||||
|
||||
func TestDepsController_DeleteTask_GateError(t *testing.T) {
|
||||
// when gate rejects delete, handleDeleteTask should return false
|
||||
taskStore := store.NewInMemoryStore()
|
||||
|
||||
tasks := []*task.Task{
|
||||
{ID: testCtxID, Title: "Context", Status: task.StatusReady, Type: task.TypeStory, Priority: 3, DependsOn: []string{testDepID}},
|
||||
{ID: testBlkID, Title: "Blocker", Status: task.StatusReady, Type: task.TypeStory, Priority: 3, DependsOn: []string{testCtxID}},
|
||||
{ID: testDepID, Title: "Depends", Status: task.StatusReady, Type: task.TypeStory, Priority: 3},
|
||||
{ID: testFreeID, Title: "Free", Status: task.StatusReady, Type: task.TypeStory, Priority: 3},
|
||||
}
|
||||
for _, tt := range tasks {
|
||||
if err := taskStore.CreateTask(tt); err != nil {
|
||||
t.Fatalf("create task %s: %v", tt.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "Dependency:" + testCtxID, ConfigIndex: -1, Type: "tiki"},
|
||||
TaskID: testCtxID,
|
||||
Lanes: []plugin.TikiLane{{Name: "Blocks"}, {Name: "All"}, {Name: "Depends"}},
|
||||
}
|
||||
pluginConfig := model.NewPluginConfig("Dependency")
|
||||
pluginConfig.SetLaneLayout([]int{1, 2, 1}, nil)
|
||||
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
// register a before-delete validator that blocks all deletes
|
||||
gate.OnDelete(func(old, new *task.Task, allTasks []*task.Task) *service.Rejection {
|
||||
return &service.Rejection{Reason: "deletes blocked for test"}
|
||||
})
|
||||
|
||||
nav := newMockNavigationController()
|
||||
statusline := model.NewStatuslineConfig()
|
||||
dc := NewDepsController(taskStore, gate, pluginConfig, pluginDef, nav, statusline)
|
||||
|
||||
// select free task in All lane
|
||||
dc.pluginConfig.SetSelectedLane(depsLaneAll)
|
||||
dc.pluginConfig.SetSelectedIndexForLane(depsLaneAll, 0)
|
||||
|
||||
result := dc.HandleAction(ActionDeleteTask)
|
||||
if result {
|
||||
t.Error("expected delete to fail when gate rejects")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDepsController_MoveTask_UpdateError(t *testing.T) {
|
||||
// when gate rejects the update, statusline should receive the error
|
||||
taskStore := store.NewInMemoryStore()
|
||||
|
||||
tasks := []*task.Task{
|
||||
{ID: testCtxID, Title: "Context", Status: task.StatusReady, Type: task.TypeStory, Priority: 3, DependsOn: []string{testDepID}},
|
||||
{ID: testBlkID, Title: "Blocker", Status: task.StatusReady, Type: task.TypeStory, Priority: 3, DependsOn: []string{testCtxID}},
|
||||
{ID: testDepID, Title: "Depends", Status: task.StatusReady, Type: task.TypeStory, Priority: 3},
|
||||
{ID: testFreeID, Title: "Free", Status: task.StatusReady, Type: task.TypeStory, Priority: 3},
|
||||
}
|
||||
for _, tt := range tasks {
|
||||
if err := taskStore.CreateTask(tt); err != nil {
|
||||
t.Fatalf("create task %s: %v", tt.ID, err)
|
||||
}
|
||||
}
|
||||
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "Dependency:" + testCtxID, ConfigIndex: -1, Type: "tiki"},
|
||||
TaskID: testCtxID,
|
||||
Lanes: []plugin.TikiLane{{Name: "Blocks"}, {Name: "All"}, {Name: "Depends"}},
|
||||
}
|
||||
pluginConfig := model.NewPluginConfig("Dependency")
|
||||
pluginConfig.SetLaneLayout([]int{1, 2, 1}, nil)
|
||||
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
// register a validator that blocks all updates
|
||||
gate.OnUpdate(func(old, new *task.Task, allTasks []*task.Task) *service.Rejection {
|
||||
return &service.Rejection{Reason: "updates blocked for test"}
|
||||
})
|
||||
|
||||
nav := newMockNavigationController()
|
||||
statusline := model.NewStatuslineConfig()
|
||||
dc := NewDepsController(taskStore, gate, pluginConfig, pluginDef, nav, statusline)
|
||||
|
||||
// select free task in All lane, move left → Blocks
|
||||
dc.pluginConfig.SetSelectedLane(depsLaneAll)
|
||||
dc.pluginConfig.SetSelectedIndexForLane(depsLaneAll, 0)
|
||||
|
||||
result := dc.handleMoveTask(-1)
|
||||
if result {
|
||||
t.Error("expected move to fail when gate rejects update")
|
||||
}
|
||||
|
||||
// statusline should have received the error
|
||||
msg, _, _ := statusline.GetMessage()
|
||||
if msg == "" {
|
||||
t.Error("expected statusline to have error message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDepsViewActions(t *testing.T) {
|
||||
registry := DepsViewActions()
|
||||
actions := registry.GetActions()
|
||||
|
|
@ -522,8 +622,11 @@ func newDepsNavEnv(t *testing.T, blockers int, allTasks int, depends int, laneCo
|
|||
pluginConfig := model.NewPluginConfig("Dependency")
|
||||
pluginConfig.SetLaneLayout(laneColumns, nil)
|
||||
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
|
||||
nav := newMockNavigationController()
|
||||
return NewDepsController(taskStore, pluginConfig, pluginDef, nav, nil)
|
||||
return NewDepsController(taskStore, gate, pluginConfig, pluginDef, nav, nil)
|
||||
}
|
||||
|
||||
func TestDepsController_NavRightAdjacentNonEmptyPreservesRow(t *testing.T) {
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@ import (
|
|||
"github.com/boolean-maybe/tiki/config"
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/plugin"
|
||||
"github.com/boolean-maybe/tiki/service"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
|
||||
|
|
@ -48,6 +49,7 @@ type InputRouter struct {
|
|||
pluginControllers map[string]PluginControllerInterface // keyed by plugin name
|
||||
globalActions *ActionRegistry
|
||||
taskStore store.Store
|
||||
mutationGate *service.TaskMutationGate
|
||||
statusline *model.StatuslineConfig
|
||||
registerPlugin func(name string, cfg *model.PluginConfig, def plugin.Plugin, ctrl PluginControllerInterface)
|
||||
}
|
||||
|
|
@ -58,6 +60,7 @@ func NewInputRouter(
|
|||
taskController *TaskController,
|
||||
pluginControllers map[string]PluginControllerInterface,
|
||||
taskStore store.Store,
|
||||
mutationGate *service.TaskMutationGate,
|
||||
statusline *model.StatuslineConfig,
|
||||
) *InputRouter {
|
||||
return &InputRouter{
|
||||
|
|
@ -67,6 +70,7 @@ func NewInputRouter(
|
|||
pluginControllers: pluginControllers,
|
||||
globalActions: DefaultGlobalActions(),
|
||||
taskStore: taskStore,
|
||||
mutationGate: mutationGate,
|
||||
statusline: statusline,
|
||||
}
|
||||
}
|
||||
|
|
@ -240,7 +244,7 @@ func (ir *InputRouter) openDepsEditor(taskID string) bool {
|
|||
pluginConfig.SetViewMode(vm)
|
||||
}
|
||||
|
||||
ctrl := NewDepsController(ir.taskStore, pluginConfig, pluginDef, ir.navController, ir.statusline)
|
||||
ctrl := NewDepsController(ir.taskStore, ir.mutationGate, pluginConfig, pluginDef, ir.navController, ir.statusline)
|
||||
|
||||
if ir.registerPlugin != nil {
|
||||
ir.registerPlugin(name, pluginConfig, pluginDef, ctrl)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
|
@ -9,6 +10,7 @@ import (
|
|||
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/plugin"
|
||||
"github.com/boolean-maybe/tiki/service"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
|
@ -21,6 +23,7 @@ type PluginController struct {
|
|||
// NewPluginController creates a plugin controller
|
||||
func NewPluginController(
|
||||
taskStore store.Store,
|
||||
mutationGate *service.TaskMutationGate,
|
||||
pluginConfig *model.PluginConfig,
|
||||
pluginDef *plugin.TikiPlugin,
|
||||
navController *NavigationController,
|
||||
|
|
@ -29,6 +32,7 @@ func NewPluginController(
|
|||
pc := &PluginController{
|
||||
pluginBase: pluginBase{
|
||||
taskStore: taskStore,
|
||||
mutationGate: mutationGate,
|
||||
pluginConfig: pluginConfig,
|
||||
pluginDef: pluginDef,
|
||||
navController: navController,
|
||||
|
|
@ -164,8 +168,11 @@ func (pc *PluginController) handlePluginAction(r rune) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
if err := pc.taskStore.UpdateTask(updated); err != nil {
|
||||
if err := pc.mutationGate.UpdateTask(context.Background(), updated); err != nil {
|
||||
slog.Error("failed to update task after plugin action", "task_id", taskID, "key", string(r), "error", err)
|
||||
if pc.statusline != nil {
|
||||
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -202,8 +209,11 @@ func (pc *PluginController) handleMoveTask(offset int) bool {
|
|||
return false
|
||||
}
|
||||
|
||||
if err := pc.taskStore.UpdateTask(updated); err != nil {
|
||||
if err := pc.mutationGate.UpdateTask(context.Background(), updated); err != nil {
|
||||
slog.Error("failed to update task after lane move", "task_id", taskID, "error", err)
|
||||
if pc.statusline != nil {
|
||||
pc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -1,11 +1,13 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log/slog"
|
||||
"strings"
|
||||
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/plugin"
|
||||
"github.com/boolean-maybe/tiki/service"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
|
@ -14,6 +16,7 @@ import (
|
|||
// Methods that depend on per-controller filtering accept a filteredTasks callback.
|
||||
type pluginBase struct {
|
||||
taskStore store.Store
|
||||
mutationGate *service.TaskMutationGate
|
||||
pluginConfig *model.PluginConfig
|
||||
pluginDef *plugin.TikiPlugin
|
||||
navController *NavigationController
|
||||
|
|
@ -347,7 +350,17 @@ func (pb *pluginBase) handleDeleteTask(filteredTasks func(int) []*task.Task) boo
|
|||
if taskID == "" {
|
||||
return false
|
||||
}
|
||||
pb.taskStore.DeleteTask(taskID)
|
||||
taskItem := pb.taskStore.GetTask(taskID)
|
||||
if taskItem == nil {
|
||||
return false
|
||||
}
|
||||
if err := pb.mutationGate.DeleteTask(context.Background(), taskItem); err != nil {
|
||||
slog.Error("failed to delete task", "task_id", taskID, "error", err)
|
||||
if pb.statusline != nil {
|
||||
pb.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import (
|
|||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/plugin"
|
||||
"github.com/boolean-maybe/tiki/plugin/filter"
|
||||
"github.com/boolean-maybe/tiki/service"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
|
@ -101,7 +102,9 @@ func TestEnsureFirstNonEmptyLaneSelectionSelectsFirstTask(t *testing.T) {
|
|||
pluginConfig.SetSelectedLane(0)
|
||||
pluginConfig.SetSelectedIndexForLane(0, 1)
|
||||
|
||||
pc := NewPluginController(taskStore, pluginConfig, pluginDef, nil, nil)
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil)
|
||||
pc.EnsureFirstNonEmptyLaneSelection()
|
||||
|
||||
if pluginConfig.GetSelectedLane() != 1 {
|
||||
|
|
@ -142,7 +145,9 @@ func TestEnsureFirstNonEmptyLaneSelectionKeepsCurrentLane(t *testing.T) {
|
|||
pluginConfig.SetSelectedLane(1)
|
||||
pluginConfig.SetSelectedIndexForLane(1, 0)
|
||||
|
||||
pc := NewPluginController(taskStore, pluginConfig, pluginDef, nil, nil)
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil)
|
||||
pc.EnsureFirstNonEmptyLaneSelection()
|
||||
|
||||
if pluginConfig.GetSelectedLane() != 1 {
|
||||
|
|
@ -174,7 +179,9 @@ func TestEnsureFirstNonEmptyLaneSelectionNoTasks(t *testing.T) {
|
|||
pluginConfig.SetSelectedLane(1)
|
||||
pluginConfig.SetSelectedIndexForLane(1, 2)
|
||||
|
||||
pc := NewPluginController(taskStore, pluginConfig, pluginDef, nil, nil)
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil)
|
||||
pc.EnsureFirstNonEmptyLaneSelection()
|
||||
|
||||
if pluginConfig.GetSelectedLane() != 1 {
|
||||
|
|
@ -503,3 +510,290 @@ func TestLaneSwitchFromEmptySourceUsesTopViewportContext(t *testing.T) {
|
|||
t.Fatalf("expected selected index 4, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginController_HandleOpenTask(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
_ = taskStore.CreateTask(&task.Task{
|
||||
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory,
|
||||
})
|
||||
|
||||
todoFilter, _ := filter.ParseFilter("status = 'ready'")
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Todo", Columns: 1, Filter: todoFilter}},
|
||||
}
|
||||
pluginConfig := model.NewPluginConfig("TestPlugin")
|
||||
pluginConfig.SetLaneLayout([]int{1}, nil)
|
||||
pluginConfig.SetSelectedLane(0)
|
||||
pluginConfig.SetSelectedIndexForLane(0, 0)
|
||||
|
||||
navController := newMockNavigationController()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, navController, nil)
|
||||
|
||||
if !pc.HandleAction(ActionOpenFromPlugin) {
|
||||
t.Error("expected HandleAction(open) to return true when task is selected")
|
||||
}
|
||||
|
||||
// verify navigation was pushed
|
||||
if navController.navState.depth() == 0 {
|
||||
t.Error("expected navigation push for open task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginController_HandleOpenTask_Empty(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
emptyFilter, _ := filter.ParseFilter("status = 'done'")
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Empty", Columns: 1, Filter: emptyFilter}},
|
||||
}
|
||||
pluginConfig := model.NewPluginConfig("TestPlugin")
|
||||
pluginConfig.SetLaneLayout([]int{1}, nil)
|
||||
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, newMockNavigationController(), nil)
|
||||
|
||||
if pc.HandleAction(ActionOpenFromPlugin) {
|
||||
t.Error("expected false when no task is selected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginController_HandleDeleteTask(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
_ = taskStore.CreateTask(&task.Task{
|
||||
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
|
||||
})
|
||||
|
||||
todoFilter, _ := filter.ParseFilter("status = 'ready'")
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Todo", Columns: 1, Filter: todoFilter}},
|
||||
}
|
||||
pluginConfig := model.NewPluginConfig("TestPlugin")
|
||||
pluginConfig.SetLaneLayout([]int{1}, nil)
|
||||
pluginConfig.SetSelectedLane(0)
|
||||
pluginConfig.SetSelectedIndexForLane(0, 0)
|
||||
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, newMockNavigationController(), nil)
|
||||
|
||||
if !pc.HandleAction(ActionDeleteTask) {
|
||||
t.Error("expected HandleAction(delete) to return true")
|
||||
}
|
||||
|
||||
if taskStore.GetTask("T-1") != nil {
|
||||
t.Error("task should have been deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginController_HandleDeleteTask_Empty(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
emptyFilter, _ := filter.ParseFilter("status = 'done'")
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Empty", Columns: 1, Filter: emptyFilter}},
|
||||
}
|
||||
pluginConfig := model.NewPluginConfig("TestPlugin")
|
||||
pluginConfig.SetLaneLayout([]int{1}, nil)
|
||||
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, newMockNavigationController(), nil)
|
||||
|
||||
if pc.HandleAction(ActionDeleteTask) {
|
||||
t.Error("expected false when no task is selected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginController_HandleDeleteTask_Rejected(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
_ = taskStore.CreateTask(&task.Task{
|
||||
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
|
||||
})
|
||||
|
||||
todoFilter, _ := filter.ParseFilter("status = 'ready'")
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Todo", Columns: 1, Filter: todoFilter}},
|
||||
}
|
||||
pluginConfig := model.NewPluginConfig("TestPlugin")
|
||||
pluginConfig.SetLaneLayout([]int{1}, nil)
|
||||
pluginConfig.SetSelectedLane(0)
|
||||
pluginConfig.SetSelectedIndexForLane(0, 0)
|
||||
|
||||
statusline := model.NewStatuslineConfig()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
gate.OnDelete(func(_, _ *task.Task, _ []*task.Task) *service.Rejection {
|
||||
return &service.Rejection{Reason: "cannot delete"}
|
||||
})
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, newMockNavigationController(), statusline)
|
||||
|
||||
if pc.HandleAction(ActionDeleteTask) {
|
||||
t.Error("expected false when delete is rejected")
|
||||
}
|
||||
|
||||
// task should still exist
|
||||
if taskStore.GetTask("T-1") == nil {
|
||||
t.Error("task should not have been deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginController_GetNameAndRegistry(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
todoFilter, _ := filter.ParseFilter("status = 'ready'")
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "MyPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Todo", Columns: 1, Filter: todoFilter}},
|
||||
}
|
||||
pluginConfig := model.NewPluginConfig("MyPlugin")
|
||||
pluginConfig.SetLaneLayout([]int{1}, nil)
|
||||
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil)
|
||||
|
||||
if pc.GetPluginName() != "MyPlugin" {
|
||||
t.Errorf("GetPluginName() = %q, want %q", pc.GetPluginName(), "MyPlugin")
|
||||
}
|
||||
if pc.GetActionRegistry() == nil {
|
||||
t.Error("GetActionRegistry() should not be nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginController_HandleMoveTask_Rejected(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
_ = taskStore.CreateTask(&task.Task{
|
||||
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
|
||||
})
|
||||
|
||||
readyFilter, _ := filter.ParseFilter("status = 'ready'")
|
||||
inProgressFilter, _ := filter.ParseFilter("status = 'in_progress'")
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{
|
||||
{Name: "Ready", Columns: 1, Filter: readyFilter},
|
||||
{
|
||||
Name: "InProgress", Columns: 1, Filter: inProgressFilter,
|
||||
Action: plugin.LaneAction{
|
||||
Ops: []plugin.LaneActionOp{
|
||||
{Field: plugin.ActionFieldStatus, Operator: plugin.ActionOperatorAssign, StrValue: "in_progress"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
pluginConfig := model.NewPluginConfig("TestPlugin")
|
||||
pluginConfig.SetLaneLayout([]int{1, 1}, nil)
|
||||
pluginConfig.SetSelectedLane(0)
|
||||
pluginConfig.SetSelectedIndexForLane(0, 0)
|
||||
|
||||
statusline := model.NewStatuslineConfig()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
gate.OnUpdate(func(_, _ *task.Task, _ []*task.Task) *service.Rejection {
|
||||
return &service.Rejection{Reason: "updates blocked"}
|
||||
})
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, statusline)
|
||||
|
||||
if pc.HandleAction(ActionMoveTaskRight) {
|
||||
t.Error("expected false when move is rejected by gate")
|
||||
}
|
||||
|
||||
// task should still have original status
|
||||
tk := taskStore.GetTask("T-1")
|
||||
if tk.Status != task.StatusReady {
|
||||
t.Errorf("expected status ready, got %s", tk.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginController_HandlePluginAction_Success(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
_ = taskStore.CreateTask(&task.Task{
|
||||
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
|
||||
})
|
||||
|
||||
readyFilter, _ := filter.ParseFilter("status = 'ready'")
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
|
||||
Actions: []plugin.PluginAction{
|
||||
{
|
||||
Rune: 'd',
|
||||
Label: "Mark Done",
|
||||
Action: plugin.LaneAction{
|
||||
Ops: []plugin.LaneActionOp{
|
||||
{Field: plugin.ActionFieldStatus, Operator: plugin.ActionOperatorAssign, StrValue: "done"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
pluginConfig := model.NewPluginConfig("TestPlugin")
|
||||
pluginConfig.SetLaneLayout([]int{1}, nil)
|
||||
pluginConfig.SetSelectedLane(0)
|
||||
pluginConfig.SetSelectedIndexForLane(0, 0)
|
||||
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, nil)
|
||||
|
||||
if !pc.HandleAction(pluginActionID('d')) {
|
||||
t.Error("expected true for successful plugin action")
|
||||
}
|
||||
|
||||
tk := taskStore.GetTask("T-1")
|
||||
if tk.Status != "done" {
|
||||
t.Errorf("expected status done, got %s", tk.Status)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPluginController_HandlePluginAction_Rejected(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
_ = taskStore.CreateTask(&task.Task{
|
||||
ID: "T-1", Title: "Task 1", Status: task.StatusReady, Type: task.TypeStory, Priority: 3,
|
||||
})
|
||||
|
||||
readyFilter, _ := filter.ParseFilter("status = 'ready'")
|
||||
pluginDef := &plugin.TikiPlugin{
|
||||
BasePlugin: plugin.BasePlugin{Name: "TestPlugin"},
|
||||
Lanes: []plugin.TikiLane{{Name: "Ready", Columns: 1, Filter: readyFilter}},
|
||||
Actions: []plugin.PluginAction{
|
||||
{
|
||||
Rune: 'd',
|
||||
Label: "Mark Done",
|
||||
Action: plugin.LaneAction{
|
||||
Ops: []plugin.LaneActionOp{
|
||||
{Field: plugin.ActionFieldStatus, Operator: plugin.ActionOperatorAssign, StrValue: "done"},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
pluginConfig := model.NewPluginConfig("TestPlugin")
|
||||
pluginConfig.SetLaneLayout([]int{1}, nil)
|
||||
pluginConfig.SetSelectedLane(0)
|
||||
pluginConfig.SetSelectedIndexForLane(0, 0)
|
||||
|
||||
statusline := model.NewStatuslineConfig()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
gate.OnUpdate(func(_, _ *task.Task, _ []*task.Task) *service.Rejection {
|
||||
return &service.Rejection{Reason: "updates blocked"}
|
||||
})
|
||||
pc := NewPluginController(taskStore, gate, pluginConfig, pluginDef, nil, statusline)
|
||||
|
||||
if pc.HandleAction(pluginActionID('d')) {
|
||||
t.Error("expected false when plugin action is rejected by gate")
|
||||
}
|
||||
|
||||
// task should still have original status
|
||||
tk := taskStore.GetTask("T-1")
|
||||
if tk.Status != task.StatusReady {
|
||||
t.Errorf("expected status ready, got %s", tk.Status)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package controller
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"path/filepath"
|
||||
|
|
@ -8,6 +9,7 @@ import (
|
|||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/service"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
taskpkg "github.com/boolean-maybe/tiki/task"
|
||||
|
||||
|
|
@ -19,6 +21,7 @@ import (
|
|||
// TaskController handles task detail view actions
|
||||
type TaskController struct {
|
||||
taskStore store.Store
|
||||
mutationGate *service.TaskMutationGate
|
||||
navController *NavigationController
|
||||
statusline *model.StatuslineConfig
|
||||
currentTaskID string
|
||||
|
|
@ -34,11 +37,13 @@ type TaskController struct {
|
|||
// It initializes action registries for both detail and edit views.
|
||||
func NewTaskController(
|
||||
taskStore store.Store,
|
||||
mutationGate *service.TaskMutationGate,
|
||||
navController *NavigationController,
|
||||
statusline *model.StatuslineConfig,
|
||||
) *TaskController {
|
||||
return &TaskController{
|
||||
taskStore: taskStore,
|
||||
mutationGate: mutationGate,
|
||||
navController: navController,
|
||||
statusline: statusline,
|
||||
registry: TaskDetailViewActions(),
|
||||
|
|
@ -105,21 +110,9 @@ func (tc *TaskController) CancelEditSession() {
|
|||
func (tc *TaskController) CommitEditSession() error {
|
||||
// Handle draft task creation
|
||||
if tc.draftTask != nil {
|
||||
// Validate draft task before persisting
|
||||
if errors := tc.draftTask.Validate(); errors.HasErrors() {
|
||||
slog.Warn("draft task validation failed", "errors", errors.Error())
|
||||
return nil // Don't save invalid draft
|
||||
}
|
||||
|
||||
// Set timestamps and author for new task
|
||||
now := time.Now()
|
||||
if tc.draftTask.CreatedAt.IsZero() {
|
||||
tc.draftTask.CreatedAt = now
|
||||
}
|
||||
setAuthorFromGit(tc.draftTask, tc.taskStore)
|
||||
|
||||
// Create the task file
|
||||
if err := tc.taskStore.CreateTask(tc.draftTask); err != nil {
|
||||
if err := tc.mutationGate.CreateTask(context.Background(), tc.draftTask); err != nil {
|
||||
slog.Error("failed to create draft task", "error", err)
|
||||
return fmt.Errorf("failed to create task: %w", err)
|
||||
}
|
||||
|
|
@ -134,12 +127,6 @@ func (tc *TaskController) CommitEditSession() error {
|
|||
return nil // No active edit session, nothing to commit
|
||||
}
|
||||
|
||||
// Validate editing task before persisting
|
||||
if errors := tc.editingTask.Validate(); errors.HasErrors() {
|
||||
slog.Warn("editing task validation failed", "taskID", tc.currentTaskID, "errors", errors.Error())
|
||||
return fmt.Errorf("validation failed: %w", errors)
|
||||
}
|
||||
|
||||
// Check for conflicts (file was modified externally)
|
||||
currentTask := tc.taskStore.GetTask(tc.currentTaskID)
|
||||
if currentTask != nil && !currentTask.LoadedMtime.Equal(tc.originalMtime) {
|
||||
|
|
@ -148,8 +135,7 @@ func (tc *TaskController) CommitEditSession() error {
|
|||
// For now, proceed with save (last write wins)
|
||||
}
|
||||
|
||||
// Update the task in the store
|
||||
if err := tc.taskStore.UpdateTask(tc.editingTask); err != nil {
|
||||
if err := tc.mutationGate.UpdateTask(context.Background(), tc.editingTask); err != nil {
|
||||
slog.Error("failed to update task", "taskID", tc.currentTaskID, "error", err)
|
||||
return fmt.Errorf("failed to update task: %w", err)
|
||||
}
|
||||
|
|
@ -296,10 +282,10 @@ func (tc *TaskController) SaveStatus(statusDisplay string) bool {
|
|||
newStatus = taskpkg.NormalizeStatus(statusDisplay)
|
||||
}
|
||||
|
||||
// Validate using StatusValidator
|
||||
// Validate status
|
||||
tempTask := &taskpkg.Task{Status: newStatus}
|
||||
if err := tempTask.ValidateField("status"); err != nil {
|
||||
slog.Warn("invalid status", "display", statusDisplay, "normalized", newStatus, "error", err.Message)
|
||||
if msg := taskpkg.ValidateStatus(tempTask); msg != "" {
|
||||
slog.Warn("invalid status", "display", statusDisplay, "normalized", newStatus, "error", msg)
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -312,31 +298,17 @@ func (tc *TaskController) SaveStatus(statusDisplay string) bool {
|
|||
// SaveType saves the new type to the current task after validating the display value.
|
||||
// Returns true if the type was successfully updated, false otherwise.
|
||||
func (tc *TaskController) SaveType(typeDisplay string) bool {
|
||||
// Parse type display back to TaskType
|
||||
var newType taskpkg.Type
|
||||
typeFound := false
|
||||
|
||||
for _, t := range []taskpkg.Type{
|
||||
taskpkg.TypeStory,
|
||||
taskpkg.TypeBug,
|
||||
taskpkg.TypeSpike,
|
||||
taskpkg.TypeEpic,
|
||||
} {
|
||||
if taskpkg.TypeDisplay(t) == typeDisplay {
|
||||
newType = t
|
||||
typeFound = true
|
||||
break
|
||||
}
|
||||
// reverse the display string ("Bug 💥") back to a canonical key ("bug")
|
||||
newType, ok := taskpkg.ParseDisplay(typeDisplay)
|
||||
if !ok {
|
||||
slog.Warn("unrecognized type display", "display", typeDisplay)
|
||||
return false
|
||||
}
|
||||
|
||||
if !typeFound {
|
||||
newType = taskpkg.NormalizeType(typeDisplay)
|
||||
}
|
||||
|
||||
// Validate using TypeValidator
|
||||
// Validate type
|
||||
tempTask := &taskpkg.Task{Type: newType}
|
||||
if err := tempTask.ValidateField("type"); err != nil {
|
||||
slog.Warn("invalid type", "display", typeDisplay, "normalized", newType, "error", err.Message)
|
||||
if msg := taskpkg.ValidateType(tempTask); msg != "" {
|
||||
slog.Warn("invalid type", "display", typeDisplay, "normalized", newType, "error", msg)
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -348,10 +320,10 @@ func (tc *TaskController) SaveType(typeDisplay string) bool {
|
|||
// SavePriority saves the new priority to the current task.
|
||||
// Returns true if the priority was successfully updated, false otherwise.
|
||||
func (tc *TaskController) SavePriority(priority int) bool {
|
||||
// Validate using PriorityValidator
|
||||
// Validate priority
|
||||
tempTask := &taskpkg.Task{Priority: priority}
|
||||
if err := tempTask.ValidateField("priority"); err != nil {
|
||||
slog.Warn("invalid priority", "value", priority, "error", err.Message)
|
||||
if msg := taskpkg.ValidatePriority(tempTask); msg != "" {
|
||||
slog.Warn("invalid priority", "value", priority, "error", msg)
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -377,10 +349,10 @@ func (tc *TaskController) SaveAssignee(assignee string) bool {
|
|||
// SavePoints saves the new story points to the current task.
|
||||
// Returns true if the points were successfully updated, false otherwise.
|
||||
func (tc *TaskController) SavePoints(points int) bool {
|
||||
// Validate using PointsValidator
|
||||
// Validate points
|
||||
tempTask := &taskpkg.Task{Points: points}
|
||||
if err := tempTask.ValidateField("points"); err != nil {
|
||||
slog.Warn("invalid points", "value", points, "error", err.Message)
|
||||
if msg := taskpkg.ValidatePoints(tempTask); msg != "" {
|
||||
slog.Warn("invalid points", "value", points, "error", msg)
|
||||
return false
|
||||
}
|
||||
|
||||
|
|
@ -451,9 +423,9 @@ func (tc *TaskController) SetFocusedField(field model.EditField) {
|
|||
tc.focusedField = field
|
||||
}
|
||||
|
||||
// UpdateTask persists changes to the specified task in the store.
|
||||
// UpdateTask persists changes to the specified task via the mutation gate.
|
||||
func (tc *TaskController) UpdateTask(task *taskpkg.Task) {
|
||||
_ = tc.taskStore.UpdateTask(task)
|
||||
_ = tc.mutationGate.UpdateTask(context.Background(), task)
|
||||
}
|
||||
|
||||
// AddComment adds a new comment to the current task with the specified author and text.
|
||||
|
|
@ -468,5 +440,12 @@ func (tc *TaskController) AddComment(author, text string) bool {
|
|||
Author: author,
|
||||
Text: text,
|
||||
}
|
||||
return tc.taskStore.AddComment(tc.currentTaskID, comment)
|
||||
if err := tc.mutationGate.AddComment(tc.currentTaskID, comment); err != nil {
|
||||
slog.Error("failed to add comment", "taskID", tc.currentTaskID, "error", err)
|
||||
if tc.statusline != nil {
|
||||
tc.statusline.SetMessage(err.Error(), model.MessageLevelError, true)
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,13 +7,15 @@ import (
|
|||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/service"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
"github.com/boolean-maybe/tiki/workflow"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Set up the default status registry for tests.
|
||||
config.ResetStatusRegistry([]config.StatusDef{
|
||||
// set up the default status registry for tests.
|
||||
config.ResetStatusRegistry([]workflow.StatusDef{
|
||||
{Key: "backlog", Label: "Backlog", Emoji: "📥", Default: true},
|
||||
{Key: "ready", Label: "Ready", Emoji: "📋", Active: true},
|
||||
{Key: "in_progress", Label: "In Progress", Emoji: "⚙️", Active: true},
|
||||
|
|
@ -26,8 +28,10 @@ func init() {
|
|||
|
||||
func TestTaskController_SetDraft(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
navController := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, navController, nil)
|
||||
tc := NewTaskController(taskStore, gate, navController, nil)
|
||||
|
||||
draft := newTestTask()
|
||||
tc.SetDraft(draft)
|
||||
|
|
@ -43,8 +47,10 @@ func TestTaskController_SetDraft(t *testing.T) {
|
|||
|
||||
func TestTaskController_ClearDraft(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
navController := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, navController, nil)
|
||||
tc := NewTaskController(taskStore, gate, navController, nil)
|
||||
|
||||
tc.SetDraft(newTestTask())
|
||||
tc.ClearDraft()
|
||||
|
|
@ -56,8 +62,10 @@ func TestTaskController_ClearDraft(t *testing.T) {
|
|||
|
||||
func TestTaskController_StartEditSession(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
navController := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, navController, nil)
|
||||
tc := NewTaskController(taskStore, gate, navController, nil)
|
||||
|
||||
// Create a task in the store
|
||||
original := newTestTask()
|
||||
|
|
@ -86,8 +94,10 @@ func TestTaskController_StartEditSession(t *testing.T) {
|
|||
|
||||
func TestTaskController_StartEditSession_NonExistent(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
navController := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, navController, nil)
|
||||
tc := NewTaskController(taskStore, gate, navController, nil)
|
||||
|
||||
editingTask := tc.StartEditSession("NONEXISTENT")
|
||||
|
||||
|
|
@ -98,8 +108,10 @@ func TestTaskController_StartEditSession_NonExistent(t *testing.T) {
|
|||
|
||||
func TestTaskController_CancelEditSession(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
navController := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, navController, nil)
|
||||
tc := NewTaskController(taskStore, gate, navController, nil)
|
||||
|
||||
// Start an edit session
|
||||
original := newTestTask()
|
||||
|
|
@ -182,8 +194,10 @@ func TestTaskController_SaveStatus(t *testing.T) {
|
|||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
navController := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, navController, nil)
|
||||
tc := NewTaskController(taskStore, gate, navController, nil)
|
||||
|
||||
tt.setupTask(tc, taskStore)
|
||||
|
||||
|
|
@ -221,7 +235,7 @@ func TestTaskController_SaveType(t *testing.T) {
|
|||
setupTask: func(tc *TaskController, s store.Store) {
|
||||
tc.SetDraft(newTestTask())
|
||||
},
|
||||
typeDisplay: "Bug",
|
||||
typeDisplay: task.TypeDisplay(task.TypeBug),
|
||||
wantType: task.TypeBug,
|
||||
wantSuccess: true,
|
||||
},
|
||||
|
|
@ -232,25 +246,25 @@ func TestTaskController_SaveType(t *testing.T) {
|
|||
_ = s.CreateTask(t)
|
||||
tc.StartEditSession(t.ID)
|
||||
},
|
||||
typeDisplay: "Spike",
|
||||
typeDisplay: task.TypeDisplay(task.TypeSpike),
|
||||
wantType: task.TypeSpike,
|
||||
wantSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "invalid type normalizes to default",
|
||||
name: "invalid type is rejected",
|
||||
setupTask: func(tc *TaskController, s store.Store) {
|
||||
tc.SetDraft(newTestTask())
|
||||
},
|
||||
typeDisplay: "InvalidType",
|
||||
wantType: task.TypeStory, // NormalizeType defaults to story
|
||||
wantSuccess: true,
|
||||
wantType: task.TypeStory, // task type unchanged from setup
|
||||
wantSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "no active task",
|
||||
setupTask: func(tc *TaskController, s store.Store) {
|
||||
// Don't set up any task
|
||||
},
|
||||
typeDisplay: "Story",
|
||||
typeDisplay: task.TypeDisplay(task.TypeStory),
|
||||
wantSuccess: false,
|
||||
},
|
||||
}
|
||||
|
|
@ -258,8 +272,10 @@ func TestTaskController_SaveType(t *testing.T) {
|
|||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
navController := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, navController, nil)
|
||||
tc := NewTaskController(taskStore, gate, navController, nil)
|
||||
|
||||
tt.setupTask(tc, taskStore)
|
||||
|
||||
|
|
@ -341,8 +357,10 @@ func TestTaskController_SavePriority(t *testing.T) {
|
|||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
navController := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, navController, nil)
|
||||
tc := NewTaskController(taskStore, gate, navController, nil)
|
||||
|
||||
tt.setupTask(tc, taskStore)
|
||||
|
||||
|
|
@ -417,8 +435,10 @@ func TestTaskController_SaveAssignee(t *testing.T) {
|
|||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
navController := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, navController, nil)
|
||||
tc := NewTaskController(taskStore, gate, navController, nil)
|
||||
|
||||
tt.setupTask(tc, taskStore)
|
||||
|
||||
|
|
@ -492,8 +512,10 @@ func TestTaskController_SavePoints(t *testing.T) {
|
|||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
navController := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, navController, nil)
|
||||
tc := NewTaskController(taskStore, gate, navController, nil)
|
||||
|
||||
tt.setupTask(tc, taskStore)
|
||||
|
||||
|
|
@ -571,8 +593,10 @@ func TestTaskController_SaveTitle(t *testing.T) {
|
|||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
navController := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, navController, nil)
|
||||
tc := NewTaskController(taskStore, gate, navController, nil)
|
||||
|
||||
tt.setupTask(tc, taskStore)
|
||||
|
||||
|
|
@ -638,8 +662,10 @@ func TestTaskController_SaveDescription(t *testing.T) {
|
|||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
navController := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, navController, nil)
|
||||
tc := NewTaskController(taskStore, gate, navController, nil)
|
||||
|
||||
tt.setupTask(tc, taskStore)
|
||||
|
||||
|
|
@ -668,8 +694,10 @@ func TestTaskController_SaveDescription(t *testing.T) {
|
|||
|
||||
func TestTaskController_CommitEditSession_Draft(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
navController := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, navController, nil)
|
||||
tc := NewTaskController(taskStore, gate, navController, nil)
|
||||
|
||||
draft := newTestTaskWithID()
|
||||
draft.Title = "Draft Title"
|
||||
|
|
@ -698,18 +726,18 @@ func TestTaskController_CommitEditSession_Draft(t *testing.T) {
|
|||
|
||||
func TestTaskController_CommitEditSession_DraftValidationFailure(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.BuildGate()
|
||||
gate.SetStore(taskStore)
|
||||
navController := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, navController, nil)
|
||||
tc := NewTaskController(taskStore, gate, navController, nil)
|
||||
|
||||
draft := newTestTaskWithID()
|
||||
draft.Title = "" // Invalid - empty title
|
||||
tc.SetDraft(draft)
|
||||
|
||||
err := tc.CommitEditSession()
|
||||
if err != nil {
|
||||
// Note: Current implementation returns nil on validation failure for drafts
|
||||
// and just logs a warning. This test documents that behavior.
|
||||
t.Logf("CommitEditSession returned error as expected: %v", err)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty title")
|
||||
}
|
||||
|
||||
// Draft should still exist since validation failed
|
||||
|
|
@ -720,8 +748,10 @@ func TestTaskController_CommitEditSession_DraftValidationFailure(t *testing.T) {
|
|||
|
||||
func TestTaskController_CommitEditSession_Existing(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
navController := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, navController, nil)
|
||||
tc := NewTaskController(taskStore, gate, navController, nil)
|
||||
|
||||
// Create original task
|
||||
original := newTestTask()
|
||||
|
|
@ -754,8 +784,10 @@ func TestTaskController_CommitEditSession_Existing(t *testing.T) {
|
|||
|
||||
func TestTaskController_CommitEditSession_NoActiveSession(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
navController := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, navController, nil)
|
||||
tc := NewTaskController(taskStore, gate, navController, nil)
|
||||
|
||||
err := tc.CommitEditSession()
|
||||
if err != nil {
|
||||
|
|
@ -767,8 +799,10 @@ func TestTaskController_CommitEditSession_NoActiveSession(t *testing.T) {
|
|||
|
||||
func TestTaskController_GetCurrentTask(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
navController := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, navController, nil)
|
||||
tc := NewTaskController(taskStore, gate, navController, nil)
|
||||
|
||||
// Create task
|
||||
original := newTestTask()
|
||||
|
|
@ -789,8 +823,10 @@ func TestTaskController_GetCurrentTask(t *testing.T) {
|
|||
|
||||
func TestTaskController_GetCurrentTask_Empty(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
navController := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, navController, nil)
|
||||
tc := NewTaskController(taskStore, gate, navController, nil)
|
||||
|
||||
current := tc.GetCurrentTask()
|
||||
if current != nil {
|
||||
|
|
@ -800,8 +836,10 @@ func TestTaskController_GetCurrentTask_Empty(t *testing.T) {
|
|||
|
||||
func TestTaskController_GetCurrentTask_NonExistent(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
navController := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, navController, nil)
|
||||
tc := NewTaskController(taskStore, gate, navController, nil)
|
||||
|
||||
tc.SetCurrentTask("NONEXISTENT")
|
||||
|
||||
|
|
@ -815,8 +853,10 @@ func TestTaskController_GetCurrentTask_NonExistent(t *testing.T) {
|
|||
|
||||
func TestTaskController_GetActionRegistry(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
navController := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, navController, nil)
|
||||
tc := NewTaskController(taskStore, gate, navController, nil)
|
||||
|
||||
registry := tc.GetActionRegistry()
|
||||
if registry == nil {
|
||||
|
|
@ -832,8 +872,10 @@ func TestTaskController_GetActionRegistry(t *testing.T) {
|
|||
|
||||
func TestTaskController_GetEditActionRegistry(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
navController := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, navController, nil)
|
||||
tc := NewTaskController(taskStore, gate, navController, nil)
|
||||
|
||||
registry := tc.GetEditActionRegistry()
|
||||
if registry == nil {
|
||||
|
|
@ -851,8 +893,10 @@ func TestTaskController_GetEditActionRegistry(t *testing.T) {
|
|||
|
||||
func TestTaskController_FocusedField(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
navController := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, navController, nil)
|
||||
tc := NewTaskController(taskStore, gate, navController, nil)
|
||||
|
||||
// Initially should be empty
|
||||
if tc.GetFocusedField() != "" {
|
||||
|
|
@ -926,8 +970,10 @@ func TestTaskController_SaveDue(t *testing.T) {
|
|||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
navController := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, navController, nil)
|
||||
tc := NewTaskController(taskStore, gate, navController, nil)
|
||||
|
||||
tt.setupTask(tc, taskStore)
|
||||
|
||||
|
|
@ -957,6 +1003,249 @@ func TestTaskController_SaveDue(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestTaskController_HandleAction(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
actionID ActionID
|
||||
hasTask bool
|
||||
want bool
|
||||
}{
|
||||
{"edit title with task", ActionEditTitle, true, true},
|
||||
{"edit title without task", ActionEditTitle, false, false},
|
||||
{"clone task", ActionCloneTask, true, true},
|
||||
{"unknown action", ActionID("unknown"), true, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
navController := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, gate, navController, nil)
|
||||
|
||||
if tt.hasTask {
|
||||
original := newTestTask()
|
||||
_ = taskStore.CreateTask(original)
|
||||
tc.SetCurrentTask(original.ID)
|
||||
}
|
||||
|
||||
got := tc.HandleAction(tt.actionID)
|
||||
if got != tt.want {
|
||||
t.Errorf("HandleAction(%q) = %v, want %v", tt.actionID, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskController_SaveRecurrence(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
setupTask func(*TaskController, store.Store)
|
||||
cron string
|
||||
wantRecurrence task.Recurrence
|
||||
wantDueSet bool
|
||||
wantSuccess bool
|
||||
}{
|
||||
{
|
||||
name: "valid daily recurrence on draft",
|
||||
setupTask: func(tc *TaskController, s store.Store) {
|
||||
tc.SetDraft(newTestTask())
|
||||
},
|
||||
cron: string(task.RecurrenceDaily),
|
||||
wantRecurrence: task.RecurrenceDaily,
|
||||
wantDueSet: true,
|
||||
wantSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "clear recurrence sets none and clears due",
|
||||
setupTask: func(tc *TaskController, s store.Store) {
|
||||
draft := newTestTask()
|
||||
draft.Due = time.Date(2025, 6, 15, 0, 0, 0, 0, time.UTC)
|
||||
draft.Recurrence = task.RecurrenceDaily
|
||||
tc.SetDraft(draft)
|
||||
},
|
||||
cron: string(task.RecurrenceNone),
|
||||
wantRecurrence: task.RecurrenceNone,
|
||||
wantDueSet: false,
|
||||
wantSuccess: true,
|
||||
},
|
||||
{
|
||||
name: "invalid recurrence rejected",
|
||||
setupTask: func(tc *TaskController, s store.Store) {
|
||||
tc.SetDraft(newTestTask())
|
||||
},
|
||||
cron: "invalid-cron",
|
||||
wantSuccess: false,
|
||||
},
|
||||
{
|
||||
name: "no active task",
|
||||
setupTask: func(tc *TaskController, s store.Store) {
|
||||
// no task
|
||||
},
|
||||
cron: string(task.RecurrenceDaily),
|
||||
wantSuccess: false,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
navController := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, gate, navController, nil)
|
||||
|
||||
tt.setupTask(tc, taskStore)
|
||||
|
||||
got := tc.SaveRecurrence(tt.cron)
|
||||
if got != tt.wantSuccess {
|
||||
t.Errorf("SaveRecurrence() = %v, want %v", got, tt.wantSuccess)
|
||||
}
|
||||
|
||||
if tt.wantSuccess && tc.draftTask != nil {
|
||||
if tc.draftTask.Recurrence != tt.wantRecurrence {
|
||||
t.Errorf("Recurrence = %q, want %q", tc.draftTask.Recurrence, tt.wantRecurrence)
|
||||
}
|
||||
if tt.wantDueSet && tc.draftTask.Due.IsZero() {
|
||||
t.Error("expected Due to be set for non-none recurrence")
|
||||
}
|
||||
if !tt.wantDueSet && !tc.draftTask.Due.IsZero() {
|
||||
t.Error("expected Due to be zero for none recurrence")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskController_UpdateTask(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
navController := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, gate, navController, nil)
|
||||
|
||||
original := newTestTask()
|
||||
_ = taskStore.CreateTask(original)
|
||||
|
||||
updated := original.Clone()
|
||||
updated.Title = "Updated via UpdateTask"
|
||||
tc.UpdateTask(updated)
|
||||
|
||||
persisted := taskStore.GetTask(original.ID)
|
||||
if persisted.Title != "Updated via UpdateTask" {
|
||||
t.Errorf("task not updated, got title %q", persisted.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskController_AddComment(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
navController := newMockNavigationController()
|
||||
statusline := model.NewStatuslineConfig()
|
||||
tc := NewTaskController(taskStore, gate, navController, statusline)
|
||||
|
||||
// no current task — should return false
|
||||
if tc.AddComment("user", "hello") {
|
||||
t.Error("expected false when no current task")
|
||||
}
|
||||
|
||||
original := newTestTask()
|
||||
_ = taskStore.CreateTask(original)
|
||||
tc.SetCurrentTask(original.ID)
|
||||
|
||||
if !tc.AddComment("user", "hello") {
|
||||
t.Error("expected true for successful comment")
|
||||
}
|
||||
|
||||
persisted := taskStore.GetTask(original.ID)
|
||||
if len(persisted.Comments) != 1 {
|
||||
t.Fatalf("expected 1 comment, got %d", len(persisted.Comments))
|
||||
}
|
||||
if persisted.Comments[0].Text != "hello" {
|
||||
t.Errorf("comment text = %q, want %q", persisted.Comments[0].Text, "hello")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskController_HandleAction_EditSource(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
navController := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, gate, navController, nil)
|
||||
|
||||
// with no task, should return false
|
||||
got := tc.HandleAction(ActionEditSource)
|
||||
if got {
|
||||
t.Error("HandleAction(EditSource) should return false with no current task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskController_CommitEditSession_UpdateError(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.BuildGate()
|
||||
gate.SetStore(taskStore)
|
||||
navController := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, gate, navController, nil)
|
||||
|
||||
original := newTestTask()
|
||||
_ = taskStore.CreateTask(original)
|
||||
tc.StartEditSession(original.ID)
|
||||
tc.editingTask.Title = "" // invalid - empty title will fail validation
|
||||
|
||||
err := tc.CommitEditSession()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty title in edit session")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskController_AddComment_Error(t *testing.T) {
|
||||
// use a store wrapper that makes AddComment fail
|
||||
taskStore := store.NewInMemoryStore()
|
||||
fs := &failingCommentStore{Store: taskStore}
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(fs)
|
||||
navController := newMockNavigationController()
|
||||
statusline := model.NewStatuslineConfig()
|
||||
tc := NewTaskController(fs, gate, navController, statusline)
|
||||
|
||||
original := newTestTask()
|
||||
_ = fs.CreateTask(original)
|
||||
tc.SetCurrentTask(original.ID)
|
||||
|
||||
if tc.AddComment("user", "hello") {
|
||||
t.Error("expected false when AddComment fails")
|
||||
}
|
||||
}
|
||||
|
||||
// failingCommentStore wraps a Store and always fails AddComment.
|
||||
type failingCommentStore struct {
|
||||
store.Store
|
||||
}
|
||||
|
||||
func (f *failingCommentStore) AddComment(_ string, _ task.Comment) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func TestTaskController_SaveType_InvalidType(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
navController := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, gate, navController, nil)
|
||||
|
||||
original := newTestTask()
|
||||
_ = taskStore.CreateTask(original)
|
||||
tc.StartEditSession(original.ID)
|
||||
|
||||
// unrecognized display string
|
||||
got := tc.SaveType("Nonexistent Type 🤷")
|
||||
if got {
|
||||
t.Error("SaveType should return false for unrecognized type display")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskController_SaveTags(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
|
|
@ -1028,8 +1317,10 @@ func TestTaskController_SaveTags(t *testing.T) {
|
|||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
navController := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, navController, nil)
|
||||
tc := NewTaskController(taskStore, gate, navController, nil)
|
||||
|
||||
tt.setupTask(tc, taskStore)
|
||||
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"testing"
|
||||
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/service"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
|
|
@ -89,8 +90,10 @@ func TestTaskEditCoordinator_HandleKey_TagsOnly_Backtab(t *testing.T) {
|
|||
|
||||
func TestTaskEditCoordinator_HandleKey_Escape(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
nav := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, nav, nil)
|
||||
tc := NewTaskController(taskStore, gate, nav, nil)
|
||||
tc.SetDraft(newTestTask())
|
||||
|
||||
coord := NewTaskEditCoordinator(nav, tc)
|
||||
|
|
@ -110,8 +113,10 @@ func TestTaskEditCoordinator_HandleKey_Escape(t *testing.T) {
|
|||
|
||||
func TestTaskEditCoordinator_Commit_SavesTags(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
nav := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, nav, nil)
|
||||
tc := NewTaskController(taskStore, gate, nav, nil)
|
||||
|
||||
draft := newTestTask()
|
||||
draft.Title = "Tagged Task"
|
||||
|
|
@ -140,8 +145,11 @@ func TestTaskEditCoordinator_Commit_SavesTags(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestTaskEditCoordinator_Commit_NonEditView(t *testing.T) {
|
||||
s := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(s)
|
||||
nav := newMockNavigationController()
|
||||
tc := NewTaskController(store.NewInMemoryStore(), nav, nil)
|
||||
tc := NewTaskController(s, gate, nav, nil)
|
||||
coord := NewTaskEditCoordinator(nav, tc)
|
||||
|
||||
got := coord.commit(&mockNonEditView{})
|
||||
|
|
@ -152,8 +160,10 @@ func TestTaskEditCoordinator_Commit_NonEditView(t *testing.T) {
|
|||
|
||||
func TestTaskEditCoordinator_Commit_ValidationFails(t *testing.T) {
|
||||
taskStore := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(taskStore)
|
||||
nav := newMockNavigationController()
|
||||
tc := NewTaskController(taskStore, nav, nil)
|
||||
tc := NewTaskController(taskStore, gate, nav, nil)
|
||||
tc.SetDraft(newTestTask())
|
||||
|
||||
coord := NewTaskEditCoordinator(nav, tc)
|
||||
|
|
@ -177,7 +187,10 @@ func TestTaskEditCoordinator_Commit_ValidationFails(t *testing.T) {
|
|||
func TestTaskEditCoordinator_FieldHint_RecurrencePatternFocused(t *testing.T) {
|
||||
sl := model.NewStatuslineConfig()
|
||||
nav := newMockNavigationController()
|
||||
tc := NewTaskController(store.NewInMemoryStore(), nav, sl)
|
||||
s := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(s)
|
||||
tc := NewTaskController(s, gate, nav, sl)
|
||||
|
||||
coord := NewTaskEditCoordinator(nav, tc)
|
||||
view := &mockFieldFocusableView{focusedField: model.EditFieldRecurrence, valueFocused: false}
|
||||
|
|
@ -196,7 +209,10 @@ func TestTaskEditCoordinator_FieldHint_RecurrencePatternFocused(t *testing.T) {
|
|||
func TestTaskEditCoordinator_FieldHint_RecurrenceValueFocused(t *testing.T) {
|
||||
sl := model.NewStatuslineConfig()
|
||||
nav := newMockNavigationController()
|
||||
tc := NewTaskController(store.NewInMemoryStore(), nav, sl)
|
||||
s := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(s)
|
||||
tc := NewTaskController(s, gate, nav, sl)
|
||||
|
||||
coord := NewTaskEditCoordinator(nav, tc)
|
||||
view := &mockFieldFocusableView{focusedField: model.EditFieldRecurrence, valueFocused: true}
|
||||
|
|
@ -215,7 +231,10 @@ func TestTaskEditCoordinator_FieldHint_RecurrenceValueFocused(t *testing.T) {
|
|||
func TestTaskEditCoordinator_FieldHint_NonRecurrenceClearsHint(t *testing.T) {
|
||||
sl := model.NewStatuslineConfig()
|
||||
nav := newMockNavigationController()
|
||||
tc := NewTaskController(store.NewInMemoryStore(), nav, sl)
|
||||
s := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(s)
|
||||
tc := NewTaskController(s, gate, nav, sl)
|
||||
|
||||
coord := NewTaskEditCoordinator(nav, tc)
|
||||
|
||||
|
|
@ -234,7 +253,10 @@ func TestTaskEditCoordinator_FieldHint_NonRecurrenceClearsHint(t *testing.T) {
|
|||
func TestTaskEditCoordinator_FieldHint_FocusNextSetsHint(t *testing.T) {
|
||||
sl := model.NewStatuslineConfig()
|
||||
nav := newMockNavigationController()
|
||||
tc := NewTaskController(store.NewInMemoryStore(), nav, sl)
|
||||
s := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(s)
|
||||
tc := NewTaskController(s, gate, nav, sl)
|
||||
|
||||
coord := NewTaskEditCoordinator(nav, tc)
|
||||
// Due is right before Recurrence in navigation order
|
||||
|
|
@ -254,7 +276,10 @@ func TestTaskEditCoordinator_FieldHint_FocusNextSetsHint(t *testing.T) {
|
|||
func TestTaskEditCoordinator_FieldHint_CancelClearsHint(t *testing.T) {
|
||||
sl := model.NewStatuslineConfig()
|
||||
nav := newMockNavigationController()
|
||||
tc := NewTaskController(store.NewInMemoryStore(), nav, sl)
|
||||
s := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(s)
|
||||
tc := NewTaskController(s, gate, nav, sl)
|
||||
|
||||
coord := NewTaskEditCoordinator(nav, tc)
|
||||
sl.SetMessage("some hint", model.MessageLevelInfo, false)
|
||||
|
|
@ -268,8 +293,11 @@ func TestTaskEditCoordinator_FieldHint_CancelClearsHint(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestTaskEditCoordinator_FieldHint_NilStatuslineNoOp(t *testing.T) {
|
||||
s := store.NewInMemoryStore()
|
||||
gate := service.NewTaskMutationGate()
|
||||
gate.SetStore(s)
|
||||
nav := newMockNavigationController()
|
||||
tc := NewTaskController(store.NewInMemoryStore(), nav, nil)
|
||||
tc := NewTaskController(s, gate, nav, nil)
|
||||
|
||||
coord := NewTaskEditCoordinator(nav, tc)
|
||||
view := &mockFieldFocusableView{focusedField: model.EditFieldRecurrence}
|
||||
|
|
|
|||
1
go.mod
|
|
@ -3,6 +3,7 @@ module github.com/boolean-maybe/tiki
|
|||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/alecthomas/participle/v2 v2.1.4
|
||||
github.com/boolean-maybe/navidown v0.4.16
|
||||
github.com/charmbracelet/bubbles v0.21.1-0.20250623103423-23b8fd6302d7
|
||||
github.com/charmbracelet/bubbletea v1.3.10
|
||||
|
|
|
|||
6
go.sum
|
|
@ -7,10 +7,12 @@ github.com/Microsoft/go-winio v0.6.2 h1:F2VQgta7ecxGYO8k3ZZz3RS8fVIXVxONVUPlNERo
|
|||
github.com/Microsoft/go-winio v0.6.2/go.mod h1:yd8OoFMLzJbo9gZq8j5qaps8bJ9aShtEA8Ipt1oGCvU=
|
||||
github.com/ProtonMail/go-crypto v1.1.6 h1:ZcV+Ropw6Qn0AX9brlQLAUXfqLBc7Bl+f/DmNxpLfdw=
|
||||
github.com/ProtonMail/go-crypto v1.1.6/go.mod h1:rA3QumHc/FZ8pAHreoekgiAbzpNsfQAosU5td4SnOrE=
|
||||
github.com/alecthomas/assert/v2 v2.7.0 h1:QtqSACNS3tF7oasA8CU6A6sXZSBDqnm7RfpLl9bZqbE=
|
||||
github.com/alecthomas/assert/v2 v2.7.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/assert/v2 v2.11.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0 h1:R3+wzpnUArGcQz7fCETQBzO5n9IMNi13iIs46aU4V9E=
|
||||
github.com/alecthomas/chroma/v2 v2.14.0/go.mod h1:QolEbTfmUHIMVpBqxeDnNBj2uoeI4EbYP4i6n68SG4I=
|
||||
github.com/alecthomas/participle/v2 v2.1.4 h1:W/H79S8Sat/krZ3el6sQMvMaahJ+XcM9WSI2naI7w2U=
|
||||
github.com/alecthomas/participle/v2 v2.1.4/go.mod h1:8tqVbpTX20Ru4NfYQgZf4mP18eXPTBViyMWiArNEgGI=
|
||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/anmitsu/go-shlex v0.0.0-20200514113438-38f4b401e2be h1:9AeTilPcZAjCFIImctFaOjnTIavg87rW78vTPkQqLI8=
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import (
|
|||
"github.com/boolean-maybe/tiki/controller"
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/plugin"
|
||||
"github.com/boolean-maybe/tiki/service"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
)
|
||||
|
||||
|
|
@ -20,18 +21,20 @@ type Controllers struct {
|
|||
func BuildControllers(
|
||||
app *tview.Application,
|
||||
taskStore store.Store,
|
||||
mutationGate *service.TaskMutationGate,
|
||||
plugins []plugin.Plugin,
|
||||
pluginConfigs map[string]*model.PluginConfig,
|
||||
statuslineConfig *model.StatuslineConfig,
|
||||
) *Controllers {
|
||||
navController := controller.NewNavigationController(app)
|
||||
taskController := controller.NewTaskController(taskStore, navController, statuslineConfig)
|
||||
taskController := controller.NewTaskController(taskStore, mutationGate, navController, statuslineConfig)
|
||||
|
||||
pluginControllers := make(map[string]controller.PluginControllerInterface)
|
||||
for _, p := range plugins {
|
||||
if tp, ok := p.(*plugin.TikiPlugin); ok {
|
||||
pluginControllers[p.GetName()] = controller.NewPluginController(
|
||||
taskStore,
|
||||
mutationGate,
|
||||
pluginConfigs[p.GetName()],
|
||||
tp,
|
||||
navController,
|
||||
|
|
|
|||
|
|
@ -11,8 +11,10 @@ import (
|
|||
"github.com/boolean-maybe/tiki/controller"
|
||||
"github.com/boolean-maybe/tiki/internal/app"
|
||||
"github.com/boolean-maybe/tiki/internal/background"
|
||||
rukiRuntime "github.com/boolean-maybe/tiki/internal/ruki/runtime"
|
||||
"github.com/boolean-maybe/tiki/model"
|
||||
"github.com/boolean-maybe/tiki/plugin"
|
||||
"github.com/boolean-maybe/tiki/service"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
"github.com/boolean-maybe/tiki/store/tikistore"
|
||||
"github.com/boolean-maybe/tiki/util/sysinfo"
|
||||
|
|
@ -29,6 +31,7 @@ type Result struct {
|
|||
// Fields include: OS, Architecture, TermType, DetectedTheme, ColorSupport, ColorCount.
|
||||
// Collected early using terminfo lookup (no screen initialization needed).
|
||||
SystemInfo *sysinfo.SystemInfo
|
||||
MutationGate *service.TaskMutationGate
|
||||
TikiStore *tikistore.TikiStore
|
||||
TaskStore store.Store
|
||||
HeaderConfig *model.HeaderConfig
|
||||
|
|
@ -90,11 +93,15 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
|
|||
// Collect early (before app creation) using terminfo lookup for future visual adjustments
|
||||
systemInfo := InitColorAndGradientSupport(cfg)
|
||||
|
||||
// Phase 3.7: Mutation gate (before store, so validators can register early)
|
||||
gate := service.BuildGate()
|
||||
|
||||
// Phase 4: Store initialization
|
||||
tikiStore, taskStore, err := InitStores()
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
gate.SetStore(taskStore)
|
||||
|
||||
// Phase 5: Model initialization
|
||||
headerConfig, layoutModel := InitHeaderAndLayoutModels()
|
||||
|
|
@ -109,6 +116,17 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
|
|||
syncHeaderPluginActions(headerConfig)
|
||||
pluginConfigs, pluginDefs := BuildPluginConfigsAndDefs(plugins)
|
||||
|
||||
// Phase 6.5: Trigger system
|
||||
schema := rukiRuntime.NewSchema()
|
||||
userName, _, _ := taskStore.GetCurrentUser()
|
||||
triggerEngine, triggerCount, err := service.LoadAndRegisterTriggers(gate, schema, func() string { return userName })
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("load triggers: %w", err)
|
||||
}
|
||||
if triggerCount > 0 {
|
||||
slog.Info("triggers loaded", "count", triggerCount)
|
||||
}
|
||||
|
||||
// Phase 7: Application and controllers
|
||||
application := app.NewApp()
|
||||
app.SetupSignalHandler(application)
|
||||
|
|
@ -116,6 +134,7 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
|
|||
controllers := BuildControllers(
|
||||
application,
|
||||
taskStore,
|
||||
gate,
|
||||
plugins,
|
||||
pluginConfigs,
|
||||
statuslineConfig,
|
||||
|
|
@ -127,6 +146,7 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
|
|||
controllers.Task,
|
||||
controllers.Plugins,
|
||||
taskStore,
|
||||
gate,
|
||||
statuslineConfig,
|
||||
)
|
||||
|
||||
|
|
@ -158,6 +178,7 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
|
|||
// Phase 11: Background tasks
|
||||
ctx, cancel := context.WithCancel(context.Background()) //nolint:gosec // G118: cancel stored in Result.CancelFunc, called by app shutdown
|
||||
background.StartBurndownHistoryBuilder(ctx, tikiStore, headerConfig, application)
|
||||
triggerEngine.StartScheduler(ctx)
|
||||
|
||||
// Phase 12: Navigation and input wiring
|
||||
wireNavigation(controllers.Nav, layoutModel, rootLayout)
|
||||
|
|
@ -171,6 +192,7 @@ func Bootstrap(tikiSkillContent, dokiSkillContent string) (*Result, error) {
|
|||
Cfg: cfg,
|
||||
LogLevel: logLevel,
|
||||
SystemInfo: systemInfo,
|
||||
MutationGate: gate,
|
||||
TikiStore: tikiStore,
|
||||
TaskStore: taskStore,
|
||||
HeaderConfig: headerConfig,
|
||||
|
|
|
|||
|
|
@ -13,8 +13,23 @@ import (
|
|||
// It opens a log file next to the executable (tiki.log) or falls back to stderr.
|
||||
// Returns the configured log level.
|
||||
func InitLogging(cfg *config.Config) slog.Level {
|
||||
logOutput := openLogOutput()
|
||||
return initLogging(cfg, slog.LevelDebug)
|
||||
}
|
||||
|
||||
// InitCLILogging sets up logging for non-TUI subcommands (exec, sysinfo, etc.).
|
||||
// Logs go to the log file like TUI mode; if the file can't be opened and we
|
||||
// fall back to stderr, only ERROR-level messages are emitted so that
|
||||
// structured stdout output isn't polluted by warnings.
|
||||
func InitCLILogging(cfg *config.Config) slog.Level {
|
||||
return initLogging(cfg, slog.LevelError)
|
||||
}
|
||||
|
||||
func initLogging(cfg *config.Config, stderrMinLevel slog.Level) slog.Level {
|
||||
logOutput, isStderr := openLogOutput()
|
||||
logLevel := parseLogLevel(cfg.Logging.Level)
|
||||
if isStderr && stderrMinLevel > logLevel {
|
||||
logLevel = stderrMinLevel
|
||||
}
|
||||
logger := slog.New(slog.NewTextHandler(logOutput, &slog.HandlerOptions{
|
||||
Level: logLevel,
|
||||
}))
|
||||
|
|
@ -24,21 +39,21 @@ func InitLogging(cfg *config.Config) slog.Level {
|
|||
}
|
||||
|
||||
// openLogOutput opens the configured log output destination, falling back to stderr.
|
||||
func openLogOutput() *os.File {
|
||||
logOutput := os.Stderr
|
||||
// Returns the file and whether it fell back to stderr.
|
||||
func openLogOutput() (*os.File, bool) {
|
||||
exePath, err := os.Executable()
|
||||
if err != nil {
|
||||
return logOutput
|
||||
return os.Stderr, true
|
||||
}
|
||||
|
||||
logPath := filepath.Join(filepath.Dir(exePath), "tiki.log")
|
||||
//nolint:gosec // G302: 0644 is appropriate for log files
|
||||
file, err := os.OpenFile(logPath, os.O_APPEND|os.O_CREATE|os.O_WRONLY, 0644)
|
||||
if err != nil {
|
||||
return logOutput
|
||||
return os.Stderr, true
|
||||
}
|
||||
// Let the OS close the file on exit
|
||||
return file
|
||||
return file, false
|
||||
}
|
||||
|
||||
// parseLogLevel parses the configured log level string into slog.Level.
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
package pipe
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"log/slog"
|
||||
|
|
@ -9,6 +10,8 @@ import (
|
|||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
"github.com/boolean-maybe/tiki/internal/bootstrap"
|
||||
rukiRuntime "github.com/boolean-maybe/tiki/internal/ruki/runtime"
|
||||
"github.com/boolean-maybe/tiki/service"
|
||||
)
|
||||
|
||||
// IsPipedInput reports whether stdin is connected to a pipe or redirected file
|
||||
|
|
@ -84,12 +87,22 @@ func CreateTaskFromReader(r io.Reader) (string, error) {
|
|||
return "", fmt.Errorf("load status registry: %w", err)
|
||||
}
|
||||
|
||||
tikiStore, _, err := bootstrap.InitStores()
|
||||
gate := service.BuildGate()
|
||||
|
||||
_, taskStore, err := bootstrap.InitStores()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("initialize store: %w", err)
|
||||
}
|
||||
gate.SetStore(taskStore)
|
||||
|
||||
task, err := tikiStore.NewTaskTemplate()
|
||||
// load triggers so piped creates fire them
|
||||
schema := rukiRuntime.NewSchema()
|
||||
userName, _, _ := taskStore.GetCurrentUser()
|
||||
if _, _, loadErr := service.LoadAndRegisterTriggers(gate, schema, func() string { return userName }); loadErr != nil {
|
||||
return "", fmt.Errorf("load triggers: %w", loadErr)
|
||||
}
|
||||
|
||||
task, err := taskStore.NewTaskTemplate()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("create task template: %w", err)
|
||||
}
|
||||
|
|
@ -97,11 +110,7 @@ func CreateTaskFromReader(r io.Reader) (string, error) {
|
|||
task.Title = title
|
||||
task.Description = description
|
||||
|
||||
if errs := task.Validate(); errs.HasErrors() {
|
||||
return "", fmt.Errorf("validation failed: %s", errs.Error())
|
||||
}
|
||||
|
||||
if err := tikiStore.CreateTask(task); err != nil {
|
||||
if err := gate.CreateTask(context.Background(), task); err != nil {
|
||||
return "", fmt.Errorf("create task: %w", err)
|
||||
}
|
||||
|
||||
|
|
|
|||
235
internal/ruki/runtime/format.go
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
package runtime
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
"github.com/boolean-maybe/tiki/workflow"
|
||||
)
|
||||
|
||||
// Formatter renders a TaskProjection to an io.Writer.
|
||||
type Formatter interface {
|
||||
Format(w io.Writer, proj *ruki.TaskProjection) error
|
||||
}
|
||||
|
||||
// TableFormatter renders results as an ASCII table.
|
||||
type TableFormatter struct{}
|
||||
|
||||
// NewTableFormatter returns a Formatter that produces ASCII table output.
|
||||
func NewTableFormatter() Formatter {
|
||||
return &TableFormatter{}
|
||||
}
|
||||
|
||||
func (f *TableFormatter) Format(w io.Writer, proj *ruki.TaskProjection) error {
|
||||
fields := resolveFields(proj.Fields)
|
||||
|
||||
// build cell grid
|
||||
rows := make([][]string, len(proj.Tasks))
|
||||
widths := make([]int, len(fields))
|
||||
for i, fd := range fields {
|
||||
if len(fd.Name) > widths[i] {
|
||||
widths[i] = len(fd.Name)
|
||||
}
|
||||
}
|
||||
for r, t := range proj.Tasks {
|
||||
row := make([]string, len(fields))
|
||||
for c, fd := range fields {
|
||||
row[c] = formatCell(t, fd)
|
||||
if len(row[c]) > widths[c] {
|
||||
widths[c] = len(row[c])
|
||||
}
|
||||
}
|
||||
rows[r] = row
|
||||
}
|
||||
|
||||
// render
|
||||
sep := buildSeparator(widths)
|
||||
header := buildRow(fieldNames(fields), widths)
|
||||
|
||||
if _, err := fmt.Fprintln(w, sep); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(w, header); err != nil {
|
||||
return err
|
||||
}
|
||||
if _, err := fmt.Fprintln(w, sep); err != nil {
|
||||
return err
|
||||
}
|
||||
for _, row := range rows {
|
||||
if _, err := fmt.Fprintln(w, buildRow(row, widths)); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if _, err := fmt.Fprintln(w, sep); err != nil {
|
||||
return err
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveFields returns the FieldDefs for the requested field names.
|
||||
// If names is nil/empty (bare select), returns all fields in canonical order.
|
||||
func resolveFields(names []string) []workflow.FieldDef {
|
||||
if len(names) == 0 {
|
||||
return workflow.Fields()
|
||||
}
|
||||
result := make([]workflow.FieldDef, 0, len(names))
|
||||
for _, name := range names {
|
||||
if fd, ok := workflow.Field(name); ok {
|
||||
result = append(result, fd)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func fieldNames(fields []workflow.FieldDef) []string {
|
||||
names := make([]string, len(fields))
|
||||
for i, f := range fields {
|
||||
names[i] = f.Name
|
||||
}
|
||||
return names
|
||||
}
|
||||
|
||||
// formatCell extracts and formats a single cell value from a task.
|
||||
func formatCell(t *task.Task, fd workflow.FieldDef) string {
|
||||
val := extractFieldValue(t, fd.Name)
|
||||
return renderValue(val, fd.Type)
|
||||
}
|
||||
|
||||
// extractFieldValue pulls a raw value from a task by field name.
|
||||
func extractFieldValue(t *task.Task, name string) interface{} {
|
||||
switch name {
|
||||
case "id":
|
||||
return t.ID
|
||||
case "title":
|
||||
return t.Title
|
||||
case "description":
|
||||
return t.Description
|
||||
case "status":
|
||||
return string(t.Status)
|
||||
case "type":
|
||||
return string(t.Type)
|
||||
case "tags":
|
||||
return t.Tags
|
||||
case "dependsOn":
|
||||
return t.DependsOn
|
||||
case "due":
|
||||
return t.Due
|
||||
case "recurrence":
|
||||
return string(t.Recurrence)
|
||||
case "assignee":
|
||||
return t.Assignee
|
||||
case "priority":
|
||||
return t.Priority
|
||||
case "points":
|
||||
return t.Points
|
||||
case "createdBy":
|
||||
return t.CreatedBy
|
||||
case "createdAt":
|
||||
return t.CreatedAt
|
||||
case "updatedAt":
|
||||
return t.UpdatedAt
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// renderValue formats a value according to its workflow type.
|
||||
func renderValue(val interface{}, vt workflow.ValueType) string {
|
||||
if val == nil {
|
||||
return ""
|
||||
}
|
||||
|
||||
switch vt {
|
||||
case workflow.TypeDate:
|
||||
return renderDate(val)
|
||||
case workflow.TypeTimestamp:
|
||||
return renderTimestamp(val)
|
||||
case workflow.TypeListString, workflow.TypeListRef:
|
||||
return renderList(val)
|
||||
case workflow.TypeInt:
|
||||
return renderInt(val)
|
||||
default:
|
||||
return escapeScalar(fmt.Sprint(val))
|
||||
}
|
||||
}
|
||||
|
||||
func renderDate(val interface{}) string {
|
||||
t, ok := val.(time.Time)
|
||||
if !ok || t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.Format("2006-01-02")
|
||||
}
|
||||
|
||||
func renderTimestamp(val interface{}) string {
|
||||
t, ok := val.(time.Time)
|
||||
if !ok || t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format(time.RFC3339)
|
||||
}
|
||||
|
||||
func renderList(val interface{}) string {
|
||||
ss, ok := val.([]string)
|
||||
if !ok || ss == nil {
|
||||
return ""
|
||||
}
|
||||
// JSON array encoding — this is the final cell text, not passed through escapeScalar
|
||||
b, err := json.Marshal(ss)
|
||||
if err != nil {
|
||||
return "[]"
|
||||
}
|
||||
return string(b)
|
||||
}
|
||||
|
||||
func renderInt(val interface{}) string {
|
||||
n, ok := val.(int)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprint(n)
|
||||
}
|
||||
|
||||
// escapeScalar escapes backslash, newline, and tab in scalar text cells.
|
||||
func escapeScalar(s string) string {
|
||||
s = strings.ReplaceAll(s, `\`, `\\`)
|
||||
s = strings.ReplaceAll(s, "\n", `\n`)
|
||||
s = strings.ReplaceAll(s, "\t", `\t`)
|
||||
return s
|
||||
}
|
||||
|
||||
// buildSeparator creates a "+---+---+" style separator line.
|
||||
func buildSeparator(widths []int) string {
|
||||
var b strings.Builder
|
||||
b.WriteByte('+')
|
||||
for _, w := range widths {
|
||||
b.WriteByte('-')
|
||||
for range w {
|
||||
b.WriteByte('-')
|
||||
}
|
||||
b.WriteByte('-')
|
||||
b.WriteByte('+')
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// buildRow creates a "| val | val |" style row.
|
||||
func buildRow(cells []string, widths []int) string {
|
||||
var b strings.Builder
|
||||
b.WriteByte('|')
|
||||
for i, cell := range cells {
|
||||
b.WriteByte(' ')
|
||||
b.WriteString(cell)
|
||||
// pad
|
||||
for range widths[i] - len(cell) {
|
||||
b.WriteByte(' ')
|
||||
}
|
||||
b.WriteString(" |")
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
558
internal/ruki/runtime/format_test.go
Normal file
|
|
@ -0,0 +1,558 @@
|
|||
package runtime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
func TestTableFormatterProjectedFields(t *testing.T) {
|
||||
proj := &ruki.TaskProjection{
|
||||
Fields: []string{"id", "title", "status"},
|
||||
Tasks: []*task.Task{
|
||||
{ID: "TIKI-AAA001", Title: "First", Status: "ready"},
|
||||
{ID: "TIKI-BBB002", Title: "Second", Status: "done"},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
f := NewTableFormatter()
|
||||
if err := f.Format(&buf, proj); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := buf.String()
|
||||
|
||||
// header should contain the field names
|
||||
if !strings.Contains(out, "id") || !strings.Contains(out, "title") || !strings.Contains(out, "status") {
|
||||
t.Errorf("header missing field names:\n%s", out)
|
||||
}
|
||||
// data rows
|
||||
if !strings.Contains(out, "TIKI-AAA001") || !strings.Contains(out, "First") {
|
||||
t.Errorf("missing first row data:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "TIKI-BBB002") || !strings.Contains(out, "Second") {
|
||||
t.Errorf("missing second row data:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableFormatterFieldOrder(t *testing.T) {
|
||||
proj := &ruki.TaskProjection{
|
||||
Fields: []string{"status", "id"},
|
||||
Tasks: []*task.Task{{ID: "TIKI-A00001", Status: "ready"}},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := NewTableFormatter().Format(&buf, proj); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lines := strings.Split(buf.String(), "\n")
|
||||
// header row is lines[1] (after separator)
|
||||
header := lines[1]
|
||||
statusIdx := strings.Index(header, "status")
|
||||
idIdx := strings.Index(header, "id")
|
||||
if statusIdx < 0 || idIdx < 0 {
|
||||
t.Fatalf("header missing fields: %q", header)
|
||||
}
|
||||
if statusIdx >= idIdx {
|
||||
t.Errorf("status should appear before id in header, got status@%d id@%d", statusIdx, idIdx)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableFormatterEmptyResult(t *testing.T) {
|
||||
proj := &ruki.TaskProjection{
|
||||
Fields: []string{"id", "title"},
|
||||
Tasks: nil,
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := NewTableFormatter().Format(&buf, proj); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := buf.String()
|
||||
|
||||
// should have header but no data rows
|
||||
lines := nonEmptyLines(out)
|
||||
// top sep + header + sep + bottom sep = 4 lines
|
||||
if len(lines) != 4 {
|
||||
t.Errorf("empty result should produce 4 lines (sep+header+sep+sep), got %d:\n%s", len(lines), out)
|
||||
}
|
||||
if !strings.Contains(out, "id") || !strings.Contains(out, "title") {
|
||||
t.Errorf("header-only table missing field names:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableFormatterDateFormatting(t *testing.T) {
|
||||
due := time.Date(2025, 3, 15, 0, 0, 0, 0, time.UTC)
|
||||
proj := &ruki.TaskProjection{
|
||||
Fields: []string{"due"},
|
||||
Tasks: []*task.Task{{Due: due}},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := NewTableFormatter().Format(&buf, proj); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if !strings.Contains(buf.String(), "2025-03-15") {
|
||||
t.Errorf("date should be YYYY-MM-DD format:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableFormatterTimestampFormatting(t *testing.T) {
|
||||
ts := time.Date(2025, 6, 1, 14, 30, 0, 0, time.FixedZone("EST", -5*3600))
|
||||
proj := &ruki.TaskProjection{
|
||||
Fields: []string{"createdAt"},
|
||||
Tasks: []*task.Task{{CreatedAt: ts}},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := NewTableFormatter().Format(&buf, proj); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// should be RFC3339 in UTC
|
||||
if !strings.Contains(buf.String(), "2025-06-01T19:30:00Z") {
|
||||
t.Errorf("timestamp should be RFC3339 UTC:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableFormatterZeroDate(t *testing.T) {
|
||||
proj := &ruki.TaskProjection{
|
||||
Fields: []string{"due"},
|
||||
Tasks: []*task.Task{{Due: time.Time{}}},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := NewTableFormatter().Format(&buf, proj); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// zero date cell should be empty (just spaces between pipes)
|
||||
lines := strings.Split(buf.String(), "\n")
|
||||
dataRow := lines[3] // sep, header, sep, data
|
||||
// extract cell content between pipes
|
||||
parts := strings.Split(dataRow, "|")
|
||||
if len(parts) < 3 {
|
||||
t.Fatalf("unexpected row format: %q", dataRow)
|
||||
}
|
||||
cell := strings.TrimSpace(parts[1])
|
||||
if cell != "" {
|
||||
t.Errorf("zero date should render as empty cell, got %q", cell)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableFormatterZeroTimestamp(t *testing.T) {
|
||||
proj := &ruki.TaskProjection{
|
||||
Fields: []string{"createdAt"},
|
||||
Tasks: []*task.Task{{CreatedAt: time.Time{}}},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := NewTableFormatter().Format(&buf, proj); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lines := strings.Split(buf.String(), "\n")
|
||||
dataRow := lines[3]
|
||||
parts := strings.Split(dataRow, "|")
|
||||
cell := strings.TrimSpace(parts[1])
|
||||
if cell != "" {
|
||||
t.Errorf("zero timestamp should render as empty cell, got %q", cell)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableFormatterListJSON(t *testing.T) {
|
||||
proj := &ruki.TaskProjection{
|
||||
Fields: []string{"tags"},
|
||||
Tasks: []*task.Task{
|
||||
{Tags: []string{"backend", "urgent"}},
|
||||
{Tags: []string{}},
|
||||
{Tags: nil},
|
||||
},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := NewTableFormatter().Format(&buf, proj); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, `["backend","urgent"]`) {
|
||||
t.Errorf("list should be JSON array:\n%s", out)
|
||||
}
|
||||
// empty slice renders as empty cell
|
||||
// nil slice also renders as empty cell
|
||||
}
|
||||
|
||||
func TestTableFormatterListEscaping(t *testing.T) {
|
||||
// list cells with special characters should use standard JSON escaping
|
||||
proj := &ruki.TaskProjection{
|
||||
Fields: []string{"tags"},
|
||||
Tasks: []*task.Task{{Tags: []string{`back\slash`, "new\nline", `"quoted"`}}},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := NewTableFormatter().Format(&buf, proj); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := buf.String()
|
||||
|
||||
// JSON encoding handles escaping
|
||||
if !strings.Contains(out, `"back\\slash"`) {
|
||||
t.Errorf("backslash should be JSON-escaped:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"new\nline"`) {
|
||||
t.Errorf("newline should be JSON-escaped:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, `"\"quoted\""`) {
|
||||
t.Errorf("quotes should be JSON-escaped:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableFormatterScalarEscaping(t *testing.T) {
|
||||
proj := &ruki.TaskProjection{
|
||||
Fields: []string{"title"},
|
||||
Tasks: []*task.Task{{Title: "line1\nline2\ttab\\slash"}},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := NewTableFormatter().Format(&buf, proj); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, `line1\nline2\ttab\\slash`) {
|
||||
t.Errorf("scalar should have escaped \\n, \\t, \\\\:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableFormatterNoRowFooter(t *testing.T) {
|
||||
proj := &ruki.TaskProjection{
|
||||
Fields: []string{"id"},
|
||||
Tasks: []*task.Task{{ID: "TIKI-A00001"}, {ID: "TIKI-B00002"}},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := NewTableFormatter().Format(&buf, proj); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := buf.String()
|
||||
|
||||
// should not contain "2 rows" or similar
|
||||
lower := strings.ToLower(out)
|
||||
if strings.Contains(lower, "row") {
|
||||
t.Errorf("output should not contain row count footer:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableFormatterBorders(t *testing.T) {
|
||||
proj := &ruki.TaskProjection{
|
||||
Fields: []string{"id"},
|
||||
Tasks: []*task.Task{{ID: "TIKI-A00001"}},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := NewTableFormatter().Format(&buf, proj); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lines := nonEmptyLines(buf.String())
|
||||
// expect: separator, header, separator, data, separator = 5 lines
|
||||
if len(lines) != 5 {
|
||||
t.Errorf("expected 5 lines (3 separators + header + 1 data), got %d:\n%s", len(lines), buf.String())
|
||||
}
|
||||
for _, i := range []int{0, 2, 4} {
|
||||
if !strings.HasPrefix(lines[i], "+") || !strings.HasSuffix(lines[i], "+") {
|
||||
t.Errorf("separator line %d should start and end with +: %q", i, lines[i])
|
||||
}
|
||||
}
|
||||
for _, i := range []int{1, 3} {
|
||||
if !strings.HasPrefix(lines[i], "|") || !strings.HasSuffix(lines[i], "|") {
|
||||
t.Errorf("content line %d should start and end with |: %q", i, lines[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableFormatterEmptyString(t *testing.T) {
|
||||
proj := &ruki.TaskProjection{
|
||||
Fields: []string{"assignee"},
|
||||
Tasks: []*task.Task{{Assignee: ""}},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := NewTableFormatter().Format(&buf, proj); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
lines := strings.Split(buf.String(), "\n")
|
||||
dataRow := lines[3]
|
||||
parts := strings.Split(dataRow, "|")
|
||||
cell := strings.TrimSpace(parts[1])
|
||||
if cell != "" {
|
||||
t.Errorf("empty string should render as empty cell, got %q", cell)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableFormatterAllFieldsDefault(t *testing.T) {
|
||||
// bare select with nil fields resolves to all canonical fields
|
||||
proj := &ruki.TaskProjection{
|
||||
Fields: nil,
|
||||
Tasks: []*task.Task{{ID: "TIKI-A00001", Title: "Test", Status: "ready", Priority: 3}},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := NewTableFormatter().Format(&buf, proj); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := buf.String()
|
||||
|
||||
// should include all canonical fields in the header
|
||||
if !strings.Contains(out, "id") || !strings.Contains(out, "title") || !strings.Contains(out, "priority") {
|
||||
t.Errorf("all-fields table should contain canonical fields:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderIntEdgeCases(t *testing.T) {
|
||||
// renderInt with non-int value
|
||||
if got := renderInt("not-an-int"); got != "" {
|
||||
t.Errorf("renderInt(string) = %q, want empty", got)
|
||||
}
|
||||
|
||||
// renderInt with 0
|
||||
if got := renderInt(0); got != "0" {
|
||||
t.Errorf("renderInt(0) = %q, want %q", got, "0")
|
||||
}
|
||||
|
||||
// renderInt with valid int
|
||||
if got := renderInt(42); got != "42" {
|
||||
t.Errorf("renderInt(42) = %q, want %q", got, "42")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderValueNil(t *testing.T) {
|
||||
if got := renderValue(nil, 0); got != "" {
|
||||
t.Errorf("renderValue(nil) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderDateEdgeCases(t *testing.T) {
|
||||
// renderDate with non-time value
|
||||
if got := renderDate("not-a-time"); got != "" {
|
||||
t.Errorf("renderDate(string) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTimestampEdgeCases(t *testing.T) {
|
||||
// renderTimestamp with non-time value
|
||||
if got := renderTimestamp("not-a-time"); got != "" {
|
||||
t.Errorf("renderTimestamp(string) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderListEdgeCases(t *testing.T) {
|
||||
// renderList with non-slice value
|
||||
if got := renderList(42); got != "" {
|
||||
t.Errorf("renderList(int) = %q, want empty", got)
|
||||
}
|
||||
|
||||
// renderList with nil slice
|
||||
if got := renderList([]string(nil)); got != "" {
|
||||
t.Errorf("renderList(nil) = %q, want empty", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExtractFieldValueUnknown(t *testing.T) {
|
||||
tk := &task.Task{ID: "TIKI-A00001"}
|
||||
if got := extractFieldValue(tk, "nonexistent"); got != nil {
|
||||
t.Errorf("extractFieldValue(unknown) = %v, want nil", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableFormatterIntField(t *testing.T) {
|
||||
proj := &ruki.TaskProjection{
|
||||
Fields: []string{"priority", "points"},
|
||||
Tasks: []*task.Task{{Priority: 3, Points: 8}},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := NewTableFormatter().Format(&buf, proj); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "3") {
|
||||
t.Errorf("missing priority value:\n%s", out)
|
||||
}
|
||||
if !strings.Contains(out, "8") {
|
||||
t.Errorf("missing points value:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableFormatterRecurrenceField(t *testing.T) {
|
||||
proj := &ruki.TaskProjection{
|
||||
Fields: []string{"recurrence"},
|
||||
Tasks: []*task.Task{{Recurrence: "0 0 * * MON"}},
|
||||
}
|
||||
|
||||
var buf bytes.Buffer
|
||||
if err := NewTableFormatter().Format(&buf, proj); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
out := buf.String()
|
||||
|
||||
if !strings.Contains(out, "0 0 * * MON") {
|
||||
t.Errorf("missing recurrence value:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func nonEmptyLines(s string) []string {
|
||||
var result []string
|
||||
for _, line := range strings.Split(s, "\n") {
|
||||
if strings.TrimSpace(line) != "" {
|
||||
result = append(result, line)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func TestTableFormatterWriteError(t *testing.T) {
|
||||
proj := &ruki.TaskProjection{
|
||||
Fields: []string{"id"},
|
||||
Tasks: []*task.Task{{ID: "TIKI-ABC123"}},
|
||||
}
|
||||
|
||||
ew := &errorWriter{failAfter: 0}
|
||||
err := NewTableFormatter().Format(ew, proj)
|
||||
if err == nil {
|
||||
t.Fatal("expected write error")
|
||||
}
|
||||
}
|
||||
|
||||
type errorWriter struct {
|
||||
writes int
|
||||
failAfter int
|
||||
}
|
||||
|
||||
func (w *errorWriter) Write(p []byte) (int, error) {
|
||||
if w.writes >= w.failAfter {
|
||||
return 0, fmt.Errorf("write error")
|
||||
}
|
||||
w.writes++
|
||||
return len(p), nil
|
||||
}
|
||||
|
||||
func TestRenderListNilSlice(t *testing.T) {
|
||||
got := renderList(nil)
|
||||
if got != "" {
|
||||
t.Errorf("expected empty for nil, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderListNonStringSlice(t *testing.T) {
|
||||
got := renderList(42)
|
||||
if got != "" {
|
||||
t.Errorf("expected empty for non-slice, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderIntNonInt(t *testing.T) {
|
||||
got := renderInt("not an int")
|
||||
if got != "" {
|
||||
t.Errorf("expected empty for non-int, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderDateNonTime(t *testing.T) {
|
||||
got := renderDate("not a time")
|
||||
if got != "" {
|
||||
t.Errorf("expected empty for non-time, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRenderTimestampNonTime(t *testing.T) {
|
||||
got := renderTimestamp("not a time")
|
||||
if got != "" {
|
||||
t.Errorf("expected empty for non-time, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
// --- write-error tests at each stage of Format ---
|
||||
|
||||
func TestTableFormatterWriteErrorAtHeader(t *testing.T) {
|
||||
proj := &ruki.TaskProjection{
|
||||
Fields: []string{"id"},
|
||||
Tasks: []*task.Task{{ID: "TIKI-ABC123"}},
|
||||
}
|
||||
|
||||
// fail on second write (header row)
|
||||
ew := &errorWriter{failAfter: 1}
|
||||
err := NewTableFormatter().Format(ew, proj)
|
||||
if err == nil {
|
||||
t.Fatal("expected write error at header")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableFormatterWriteErrorAtSecondSep(t *testing.T) {
|
||||
proj := &ruki.TaskProjection{
|
||||
Fields: []string{"id"},
|
||||
Tasks: []*task.Task{{ID: "TIKI-ABC123"}},
|
||||
}
|
||||
|
||||
// fail on third write (second separator after header)
|
||||
ew := &errorWriter{failAfter: 2}
|
||||
err := NewTableFormatter().Format(ew, proj)
|
||||
if err == nil {
|
||||
t.Fatal("expected write error at second separator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableFormatterWriteErrorAtDataRow(t *testing.T) {
|
||||
proj := &ruki.TaskProjection{
|
||||
Fields: []string{"id"},
|
||||
Tasks: []*task.Task{{ID: "TIKI-ABC123"}},
|
||||
}
|
||||
|
||||
// fail on fourth write (data row)
|
||||
ew := &errorWriter{failAfter: 3}
|
||||
err := NewTableFormatter().Format(ew, proj)
|
||||
if err == nil {
|
||||
t.Fatal("expected write error at data row")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTableFormatterWriteErrorAtClosingSep(t *testing.T) {
|
||||
proj := &ruki.TaskProjection{
|
||||
Fields: []string{"id"},
|
||||
Tasks: []*task.Task{{ID: "TIKI-ABC123"}},
|
||||
}
|
||||
|
||||
// fail on fifth write (closing separator)
|
||||
ew := &errorWriter{failAfter: 4}
|
||||
err := NewTableFormatter().Format(ew, proj)
|
||||
if err == nil {
|
||||
t.Fatal("expected write error at closing separator")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEscapeScalar(t *testing.T) {
|
||||
tests := []struct {
|
||||
input, want string
|
||||
}{
|
||||
{"hello", "hello"},
|
||||
{"line\nnewline", `line\nnewline`},
|
||||
{"tab\there", `tab\there`},
|
||||
{`back\slash`, `back\\slash`},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
got := escapeScalar(tt.input)
|
||||
if got != tt.want {
|
||||
t.Errorf("escapeScalar(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
187
internal/ruki/runtime/runner.go
Normal file
|
|
@ -0,0 +1,187 @@
|
|||
package runtime
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"strings"
|
||||
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
"github.com/boolean-maybe/tiki/service"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
// RunQuery parses and executes a ruki statement against the given gate,
|
||||
// writing formatted results to out.
|
||||
func RunQuery(gate *service.TaskMutationGate, query string, out io.Writer) error {
|
||||
query = strings.TrimSuffix(strings.TrimSpace(query), ";")
|
||||
if query == "" {
|
||||
return fmt.Errorf("empty query")
|
||||
}
|
||||
|
||||
readStore := gate.ReadStore()
|
||||
|
||||
schema := NewSchema()
|
||||
parser := ruki.NewParser(schema)
|
||||
|
||||
userName, err := resolveUser(readStore)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve current user: %w", err)
|
||||
}
|
||||
executor := ruki.NewExecutor(schema, func() string { return userName }, ruki.ExecutorRuntime{Mode: ruki.ExecutorRuntimeCLI})
|
||||
|
||||
stmt, err := parser.ParseAndValidateStatement(query, ruki.ExecutorRuntimeCLI)
|
||||
if err != nil {
|
||||
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 input ruki.ExecutionInput
|
||||
if stmt.RequiresCreateTemplate() {
|
||||
var template *task.Task
|
||||
template, err = readStore.NewTaskTemplate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create template: %w", err)
|
||||
}
|
||||
if template == nil {
|
||||
return fmt.Errorf("create template: store returned nil template")
|
||||
}
|
||||
input.CreateTemplate = template
|
||||
}
|
||||
|
||||
tasks := readStore.GetAllTasks()
|
||||
result, err := executor.Execute(stmt, tasks, input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("execute: %w", err)
|
||||
}
|
||||
|
||||
ctx := context.Background()
|
||||
|
||||
switch {
|
||||
case result.Select != nil:
|
||||
formatter := NewTableFormatter()
|
||||
return formatter.Format(out, result.Select)
|
||||
|
||||
case result.Update != nil:
|
||||
return persistAndSummarize(ctx, gate, result.Update, out)
|
||||
|
||||
case result.Create != nil:
|
||||
return persistCreate(ctx, gate, result.Create, out)
|
||||
|
||||
case result.Delete != nil:
|
||||
return persistDelete(ctx, gate, result.Delete, out)
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unsupported statement type")
|
||||
}
|
||||
}
|
||||
|
||||
// RunSelectQuery is the read-only 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(readStore store.ReadStore, 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")
|
||||
}
|
||||
validated, err := ruki.NewSemanticValidator(ruki.ExecutorRuntimeCLI).ValidateStatement(stmt)
|
||||
if err != nil {
|
||||
return fmt.Errorf("semantic validate: %w", err)
|
||||
}
|
||||
|
||||
userName, err := resolveUser(readStore)
|
||||
if err != nil {
|
||||
return fmt.Errorf("resolve current user: %w", err)
|
||||
}
|
||||
executor := ruki.NewExecutor(schema, func() string { return userName }, ruki.ExecutorRuntime{Mode: ruki.ExecutorRuntimeCLI})
|
||||
|
||||
tasks := readStore.GetAllTasks()
|
||||
result, err := executor.Execute(validated, tasks, ruki.ExecutionInput{})
|
||||
if err != nil {
|
||||
return fmt.Errorf("execute: %w", err)
|
||||
}
|
||||
|
||||
formatter := NewTableFormatter()
|
||||
return formatter.Format(out, result.Select)
|
||||
}
|
||||
|
||||
func persistAndSummarize(ctx context.Context, gate *service.TaskMutationGate, ur *ruki.UpdateResult, out io.Writer) error {
|
||||
var succeeded, failed int
|
||||
var firstErr error
|
||||
|
||||
for _, t := range ur.Updated {
|
||||
if err := gate.UpdateTask(ctx, t); err != nil {
|
||||
failed++
|
||||
if firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
} else {
|
||||
succeeded++
|
||||
}
|
||||
}
|
||||
|
||||
if failed > 0 {
|
||||
_, _ = fmt.Fprintf(out, "updated %d tasks (%d failed)\n", succeeded, failed)
|
||||
return fmt.Errorf("update partially failed: %d of %d tasks failed: %w", failed, succeeded+failed, firstErr)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(out, "updated %d tasks\n", succeeded)
|
||||
return nil
|
||||
}
|
||||
|
||||
func persistCreate(ctx context.Context, gate *service.TaskMutationGate, cr *ruki.CreateResult, out io.Writer) error {
|
||||
t := cr.Task
|
||||
|
||||
if err := gate.CreateTask(ctx, t); err != nil {
|
||||
return fmt.Errorf("create task: %w", err)
|
||||
}
|
||||
|
||||
_, _ = fmt.Fprintf(out, "created %s\n", t.ID)
|
||||
return nil
|
||||
}
|
||||
|
||||
func persistDelete(ctx context.Context, gate *service.TaskMutationGate, dr *ruki.DeleteResult, out io.Writer) error {
|
||||
readStore := gate.ReadStore()
|
||||
var succeeded, failed int
|
||||
for _, t := range dr.Deleted {
|
||||
if err := gate.DeleteTask(ctx, t); err != nil {
|
||||
failed++
|
||||
} else if readStore.GetTask(t.ID) != nil {
|
||||
// store silently failed to delete
|
||||
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.ReadStore) (string, error) {
|
||||
name, _, err := s.GetCurrentUser()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
return name, nil
|
||||
}
|
||||
754
internal/ruki/runtime/runner_test.go
Normal file
|
|
@ -0,0 +1,754 @@
|
|||
package runtime
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
"github.com/boolean-maybe/tiki/service"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
"github.com/boolean-maybe/tiki/workflow"
|
||||
)
|
||||
|
||||
func setupRunnerTest(t *testing.T) store.Store {
|
||||
t.Helper()
|
||||
config.ResetStatusRegistry([]workflow.StatusDef{
|
||||
{Key: "backlog", Label: "Backlog", Emoji: "📥", Default: true},
|
||||
{Key: "ready", Label: "Ready", Emoji: "📋", Active: true},
|
||||
{Key: "in_progress", Label: "In Progress", Emoji: "⚙️", Active: true},
|
||||
{Key: "done", Label: "Done", Emoji: "✅", Done: true},
|
||||
})
|
||||
|
||||
s := store.NewInMemoryStore()
|
||||
_ = s.CreateTask(&task.Task{ID: "TIKI-AAA001", Title: "Build API", Status: "ready", Priority: 1})
|
||||
_ = s.CreateTask(&task.Task{ID: "TIKI-BBB002", Title: "Write Docs", Status: "done", Priority: 2})
|
||||
return s
|
||||
}
|
||||
|
||||
// gateFor wraps a store in a bare gate (no field validators) for tests.
|
||||
func gateFor(s store.Store) *service.TaskMutationGate {
|
||||
g := service.NewTaskMutationGate()
|
||||
g.SetStore(s)
|
||||
return g
|
||||
}
|
||||
|
||||
func TestRunSelectQuerySuccess(t *testing.T) {
|
||||
s := setupRunnerTest(t)
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := RunSelectQuery(s, `select id, title where status = "ready"`, &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "TIKI-AAA001") {
|
||||
t.Errorf("expected TIKI-AAA001 in output:\n%s", out)
|
||||
}
|
||||
if strings.Contains(out, "TIKI-BBB002") {
|
||||
t.Errorf("TIKI-BBB002 should be filtered out:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSelectQueryBareSelect(t *testing.T) {
|
||||
s := setupRunnerTest(t)
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := RunSelectQuery(s, "select", &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
// bare select returns all tasks with all fields
|
||||
if !strings.Contains(out, "TIKI-AAA001") || !strings.Contains(out, "TIKI-BBB002") {
|
||||
t.Errorf("bare select should return all tasks:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSelectQuerySemicolon(t *testing.T) {
|
||||
s := setupRunnerTest(t)
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := RunSelectQuery(s, "select id, title;", &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("trailing semicolon should be accepted: %v", err)
|
||||
}
|
||||
|
||||
if !strings.Contains(buf.String(), "TIKI-AAA001") {
|
||||
t.Errorf("semicolon query should produce results:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSelectQueryParseError(t *testing.T) {
|
||||
s := setupRunnerTest(t)
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := RunSelectQuery(s, "select from where", &buf)
|
||||
if err == nil {
|
||||
t.Fatal("expected parse error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "parse") {
|
||||
t.Errorf("error should mention parse: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSelectQueryRejectsNonSelect(t *testing.T) {
|
||||
s := setupRunnerTest(t)
|
||||
|
||||
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"`},
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSelectQueryEmptyQuery(t *testing.T) {
|
||||
s := setupRunnerTest(t)
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := RunSelectQuery(s, "", &buf)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty query")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "empty") {
|
||||
t.Errorf("error should mention empty: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSelectQuerySemicolonOnly(t *testing.T) {
|
||||
s := setupRunnerTest(t)
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := RunSelectQuery(s, ";", &buf)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for semicolon-only query")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSelectQueryUserFunction(t *testing.T) {
|
||||
s := setupRunnerTest(t)
|
||||
// InMemoryStore returns "memory-user"
|
||||
_ = s.CreateTask(&task.Task{ID: "TIKI-CCC003", Title: "My Task", Status: "ready", Assignee: "memory-user"})
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := RunSelectQuery(s, `select id where assignee = user()`, &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "TIKI-CCC003") {
|
||||
t.Errorf("user() should resolve to memory-user:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSelectQueryWhitespaceOnly(t *testing.T) {
|
||||
s := setupRunnerTest(t)
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := RunSelectQuery(s, " ", &buf)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for whitespace-only query")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "empty") {
|
||||
t.Errorf("error should mention empty: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSelectQueryWithOrderBy(t *testing.T) {
|
||||
s := setupRunnerTest(t)
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := RunSelectQuery(s, `select id, title order by priority`, &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "TIKI-AAA001") || !strings.Contains(out, "TIKI-BBB002") {
|
||||
t.Errorf("order by query should return all tasks:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
// --- UPDATE via runner ---
|
||||
|
||||
func TestRunQueryUpdatePersists(t *testing.T) {
|
||||
s := setupRunnerTest(t)
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := RunQuery(gateFor(s), `update where id = "TIKI-AAA001" set title="Updated API"`, &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
updated := s.GetTask("TIKI-AAA001")
|
||||
if updated == nil {
|
||||
t.Fatal("task not found after update")
|
||||
}
|
||||
if updated.Title != "Updated API" {
|
||||
t.Errorf("expected title 'Updated API', got %q", updated.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQueryUpdateSummarySuccess(t *testing.T) {
|
||||
s := setupRunnerTest(t)
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := RunQuery(gateFor(s), `update where status = "ready" set priority=5`, &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "updated 1 tasks") {
|
||||
t.Errorf("expected 'updated 1 tasks' in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQueryUpdateZeroMatches(t *testing.T) {
|
||||
s := setupRunnerTest(t)
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := RunQuery(gateFor(s), `update where id = "NONEXISTENT" set title="x"`, &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "updated 0 tasks") {
|
||||
t.Errorf("expected 'updated 0 tasks' in output, got: %s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQueryUpdateListArithmeticE2E(t *testing.T) {
|
||||
s := setupRunnerTest(t)
|
||||
// set up a task with tags
|
||||
_ = s.CreateTask(&task.Task{ID: "TIKI-TAG001", Title: "Tagged", Status: "ready", Tags: []string{"old"}})
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := RunQuery(gateFor(s), `update where id = "TIKI-TAG001" set tags=tags+"new"`, &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
updated := s.GetTask("TIKI-TAG001")
|
||||
if len(updated.Tags) != 2 || updated.Tags[0] != "old" || updated.Tags[1] != "new" {
|
||||
t.Errorf("expected tags [old new], got %v", updated.Tags)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQueryUpdatePartialFailure(t *testing.T) {
|
||||
s := setupRunnerTest(t)
|
||||
|
||||
// create a second ready task so we update multiple
|
||||
_ = s.CreateTask(&task.Task{ID: "TIKI-CCC003", Title: "Third", Status: "ready", Priority: 3})
|
||||
|
||||
// delete the first task's file to cause UpdateTask to fail on it
|
||||
// (InMemoryStore won't fail on UpdateTask, so we test with a wrapper)
|
||||
fs := &failingUpdateStore{Store: s, failID: "TIKI-AAA001"}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := RunQuery(gateFor(fs), `update where status = "ready" set priority=5`, &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)
|
||||
}
|
||||
}
|
||||
|
||||
// failingUpdateStore wraps a Store and fails UpdateTask for a specific task ID.
|
||||
type failingUpdateStore struct {
|
||||
store.Store
|
||||
failID string
|
||||
}
|
||||
|
||||
func (f *failingUpdateStore) UpdateTask(t *task.Task) error {
|
||||
if t.ID == f.failID {
|
||||
return fmt.Errorf("simulated update failure for %s", t.ID)
|
||||
}
|
||||
return f.Store.UpdateTask(t)
|
||||
}
|
||||
|
||||
// failingUserStore wraps a Store and makes GetCurrentUser fail.
|
||||
type failingUserStore struct {
|
||||
store.Store
|
||||
}
|
||||
|
||||
func (f *failingUserStore) GetCurrentUser() (string, string, error) {
|
||||
return "", "", fmt.Errorf("simulated user resolution failure")
|
||||
}
|
||||
|
||||
func TestRunQueryResolveUserError(t *testing.T) {
|
||||
s := setupRunnerTest(t)
|
||||
fs := &failingUserStore{Store: s}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := RunQuery(gateFor(fs), "select", &buf)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for user resolution failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "resolve current user") {
|
||||
t.Errorf("expected 'resolve current user' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQueryResolveUserErrorUpdate(t *testing.T) {
|
||||
s := setupRunnerTest(t)
|
||||
fs := &failingUserStore{Store: s}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := RunQuery(gateFor(fs), `update where id = "TIKI-AAA001" set title="x"`, &buf)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for user resolution failure on update")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "resolve current user") {
|
||||
t.Errorf("expected 'resolve current user' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQueryExecuteError(t *testing.T) {
|
||||
s := setupRunnerTest(t)
|
||||
|
||||
// call() is rejected during semantic validation in RunQuery.
|
||||
var buf bytes.Buffer
|
||||
err := RunQuery(gateFor(s), `select where call("echo") = "x"`, &buf)
|
||||
if err == nil {
|
||||
t.Fatal("expected semantic validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "call() is not supported yet") {
|
||||
t.Errorf("expected call() semantic validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQueryUpdateInvalidPointsE2E(t *testing.T) {
|
||||
s := setupRunnerTest(t)
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := RunQuery(gateFor(s), `update where id = "TIKI-AAA001" set points=999`, &buf)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid points")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "points value out of range") {
|
||||
t.Errorf("expected points range error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- CREATE via runner ---
|
||||
|
||||
func TestRunQueryCreatePersists(t *testing.T) {
|
||||
s := setupRunnerTest(t)
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := RunQuery(gateFor(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)
|
||||
|
||||
// use BuildGate (with field validators) to catch empty title
|
||||
g := service.BuildGate()
|
||||
g.SetStore(s)
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := RunQuery(g, `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(gateFor(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(gateFor(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(gateFor(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(gateFor(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(gateFor(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(gateFor(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(gateFor(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)
|
||||
}
|
||||
|
||||
func TestRunQueryEmptyQuery(t *testing.T) {
|
||||
s := setupRunnerTest(t)
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := RunQuery(gateFor(s), "", &buf)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty query")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "empty") {
|
||||
t.Errorf("expected 'empty' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQueryParseError(t *testing.T) {
|
||||
s := setupRunnerTest(t)
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := RunQuery(gateFor(s), "select from where", &buf)
|
||||
if err == nil {
|
||||
t.Fatal("expected parse error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "parse") {
|
||||
t.Errorf("expected 'parse' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSelectQueryResolveUserError(t *testing.T) {
|
||||
s := setupRunnerTest(t)
|
||||
fs := &failingUserStore{Store: s}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := RunSelectQuery(fs, "select", &buf)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for user resolution failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "resolve current user") {
|
||||
t.Errorf("expected 'resolve current user' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- UPDATE via RunQuery (covers the result.Update branch in RunQuery) ---
|
||||
|
||||
func TestRunQuerySelectViaRunQuery(t *testing.T) {
|
||||
s := setupRunnerTest(t)
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := RunQuery(gateFor(s), `select id where status = "ready"`, &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !strings.Contains(buf.String(), "TIKI-AAA001") {
|
||||
t.Errorf("expected TIKI-AAA001 in output:\n%s", buf.String())
|
||||
}
|
||||
}
|
||||
|
||||
// --- DELETE partial failure via silent DeleteTask no-op detection ---
|
||||
|
||||
func TestRunQueryDeleteSilentFailure(t *testing.T) {
|
||||
s := setupRunnerTest(t)
|
||||
// failingDeleteStore silently ignores delete for failID
|
||||
fs := &failingDeleteStore{Store: s, failID: "TIKI-AAA001"}
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := RunQuery(gateFor(fs), `delete where id = "TIKI-AAA001"`, &buf)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for silent delete failure")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "partially failed") {
|
||||
t.Errorf("expected 'partially failed' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunSelectQueryExecuteError(t *testing.T) {
|
||||
s := setupRunnerTest(t)
|
||||
|
||||
// call() is rejected during semantic validation in RunSelectQuery.
|
||||
var buf bytes.Buffer
|
||||
err := RunSelectQuery(s, `select where call("echo") = "x"`, &buf)
|
||||
if err == nil {
|
||||
t.Fatal("expected semantic validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "call() is not supported yet") {
|
||||
t.Errorf("expected call() semantic validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// failingDeleteTaskStore wraps a Store and makes DeleteTask error via the gate.
|
||||
type failingDeleteTaskStore struct {
|
||||
store.Store
|
||||
failID string
|
||||
}
|
||||
|
||||
func (f *failingDeleteTaskStore) DeleteTask(id string) {
|
||||
if id != f.failID {
|
||||
f.Store.DeleteTask(id)
|
||||
}
|
||||
// for failID: silently no-op
|
||||
}
|
||||
|
||||
func TestRunQueryDeleteGateError(t *testing.T) {
|
||||
s := setupRunnerTest(t)
|
||||
|
||||
// use a gate with a validator that rejects the delete
|
||||
g := service.NewTaskMutationGate()
|
||||
fds := &failingDeleteTaskStore{Store: s, failID: "TIKI-AAA001"}
|
||||
g.SetStore(fds)
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := RunQuery(g, `delete where id = "TIKI-AAA001"`, &buf)
|
||||
// the store silently fails to delete, so persistDelete detects task still exists
|
||||
if err == nil {
|
||||
t.Fatal("expected error for delete gate failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQueryUserFunction(t *testing.T) {
|
||||
s := setupRunnerTest(t)
|
||||
|
||||
// select where assignee = user() — exercises the user() closure (line 32)
|
||||
var buf bytes.Buffer
|
||||
err := RunSelectQuery(s, `select where assignee = user()`, &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// TestRunQueryUserFunctionViaRunQuery exercises the user() closure inside RunQuery
|
||||
// (not RunSelectQuery). The closure at line 32 captures the resolved user name.
|
||||
func TestRunQueryUserFunctionViaRunQuery(t *testing.T) {
|
||||
s := setupRunnerTest(t)
|
||||
// InMemoryStore.GetCurrentUser returns "memory-user"
|
||||
_ = s.CreateTask(&task.Task{ID: "TIKI-CCC003", Title: "Owned", Status: "ready", Assignee: "memory-user"})
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := RunQuery(gateFor(s), `select id where assignee = user()`, &buf)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
out := buf.String()
|
||||
if !strings.Contains(out, "TIKI-CCC003") {
|
||||
t.Errorf("expected TIKI-CCC003 in output:\n%s", out)
|
||||
}
|
||||
// tasks without the matching assignee should be filtered out
|
||||
if strings.Contains(out, "TIKI-AAA001") {
|
||||
t.Errorf("TIKI-AAA001 should be filtered out:\n%s", out)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunQueryDeleteValidatorRejection(t *testing.T) {
|
||||
s := setupRunnerTest(t)
|
||||
|
||||
g := service.NewTaskMutationGate()
|
||||
g.SetStore(s)
|
||||
g.OnDelete(func(_, _ *task.Task, _ []*task.Task) *service.Rejection {
|
||||
return &service.Rejection{Reason: "deletes forbidden"}
|
||||
})
|
||||
|
||||
var buf bytes.Buffer
|
||||
err := RunQuery(g, `delete where id = "TIKI-AAA001"`, &buf)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when delete is rejected by validator")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "partially failed") {
|
||||
t.Errorf("expected 'partially failed' error, got: %v", err)
|
||||
}
|
||||
// task should still exist
|
||||
if s.GetTask("TIKI-AAA001") == nil {
|
||||
t.Error("task should not have been deleted")
|
||||
}
|
||||
}
|
||||
83
internal/ruki/runtime/schema.go
Normal file
|
|
@ -0,0 +1,83 @@
|
|||
package runtime
|
||||
|
||||
import (
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
"github.com/boolean-maybe/tiki/workflow"
|
||||
)
|
||||
|
||||
// workflowSchema adapts workflow.Fields(), config.GetStatusRegistry(), and
|
||||
// config.GetTypeRegistry() into the ruki.Schema interface used by the parser
|
||||
// and executor.
|
||||
type workflowSchema struct {
|
||||
statusReg *workflow.StatusRegistry
|
||||
typeReg *workflow.TypeRegistry
|
||||
}
|
||||
|
||||
// NewSchema constructs a ruki.Schema backed by the loaded workflow registries.
|
||||
// Must be called after config.LoadStatusRegistry().
|
||||
func NewSchema() ruki.Schema {
|
||||
return &workflowSchema{
|
||||
statusReg: config.GetStatusRegistry(),
|
||||
typeReg: config.GetTypeRegistry(),
|
||||
}
|
||||
}
|
||||
|
||||
func (s *workflowSchema) Field(name string) (ruki.FieldSpec, bool) {
|
||||
fd, ok := workflow.Field(name)
|
||||
if !ok {
|
||||
return ruki.FieldSpec{}, false
|
||||
}
|
||||
return ruki.FieldSpec{
|
||||
Name: fd.Name,
|
||||
Type: mapValueType(fd.Type),
|
||||
}, true
|
||||
}
|
||||
|
||||
func (s *workflowSchema) NormalizeStatus(raw string) (string, bool) {
|
||||
def, ok := s.statusReg.Lookup(raw)
|
||||
if !ok {
|
||||
return "", false
|
||||
}
|
||||
return def.Key, true
|
||||
}
|
||||
|
||||
func (s *workflowSchema) NormalizeType(raw string) (string, bool) {
|
||||
canonical, ok := s.typeReg.ParseType(raw)
|
||||
return string(canonical), ok
|
||||
}
|
||||
|
||||
// mapValueType converts workflow.ValueType to ruki.ValueType.
|
||||
// The two enums are defined in lockstep, so this is a 1:1 mapping.
|
||||
func mapValueType(wt workflow.ValueType) ruki.ValueType {
|
||||
switch wt {
|
||||
case workflow.TypeString:
|
||||
return ruki.ValueString
|
||||
case workflow.TypeInt:
|
||||
return ruki.ValueInt
|
||||
case workflow.TypeDate:
|
||||
return ruki.ValueDate
|
||||
case workflow.TypeTimestamp:
|
||||
return ruki.ValueTimestamp
|
||||
case workflow.TypeDuration:
|
||||
return ruki.ValueDuration
|
||||
case workflow.TypeBool:
|
||||
return ruki.ValueBool
|
||||
case workflow.TypeID:
|
||||
return ruki.ValueID
|
||||
case workflow.TypeRef:
|
||||
return ruki.ValueRef
|
||||
case workflow.TypeRecurrence:
|
||||
return ruki.ValueRecurrence
|
||||
case workflow.TypeListString:
|
||||
return ruki.ValueListString
|
||||
case workflow.TypeListRef:
|
||||
return ruki.ValueListRef
|
||||
case workflow.TypeStatus:
|
||||
return ruki.ValueStatus
|
||||
case workflow.TypeTaskType:
|
||||
return ruki.ValueTaskType
|
||||
default:
|
||||
return ruki.ValueString
|
||||
}
|
||||
}
|
||||
149
internal/ruki/runtime/schema_test.go
Normal file
|
|
@ -0,0 +1,149 @@
|
|||
package runtime
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
"github.com/boolean-maybe/tiki/workflow"
|
||||
)
|
||||
|
||||
func initTestRegistries() {
|
||||
config.ResetStatusRegistry([]workflow.StatusDef{
|
||||
{Key: "backlog", Label: "Backlog", Emoji: "📥", Default: true},
|
||||
{Key: "ready", Label: "Ready", Emoji: "📋", Active: true},
|
||||
{Key: "in_progress", Label: "In Progress", Emoji: "⚙️", Active: true},
|
||||
{Key: "done", Label: "Done", Emoji: "✅", Done: true},
|
||||
})
|
||||
}
|
||||
|
||||
func TestSchemaFieldMapping(t *testing.T) {
|
||||
initTestRegistries()
|
||||
s := NewSchema()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
wantType ruki.ValueType
|
||||
}{
|
||||
{"id", ruki.ValueID},
|
||||
{"title", ruki.ValueString},
|
||||
{"status", ruki.ValueStatus},
|
||||
{"type", ruki.ValueTaskType},
|
||||
{"tags", ruki.ValueListString},
|
||||
{"dependsOn", ruki.ValueListRef},
|
||||
{"due", ruki.ValueDate},
|
||||
{"priority", ruki.ValueInt},
|
||||
{"createdAt", ruki.ValueTimestamp},
|
||||
{"updatedAt", ruki.ValueTimestamp},
|
||||
{"recurrence", ruki.ValueRecurrence},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
spec, ok := s.Field(tt.name)
|
||||
if !ok {
|
||||
t.Fatalf("Field(%q) not found", tt.name)
|
||||
}
|
||||
if spec.Type != tt.wantType {
|
||||
t.Errorf("Field(%q).Type = %d, want %d", tt.name, spec.Type, tt.wantType)
|
||||
}
|
||||
if spec.Name != tt.name {
|
||||
t.Errorf("Field(%q).Name = %q, want %q", tt.name, spec.Name, tt.name)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaUnknownField(t *testing.T) {
|
||||
initTestRegistries()
|
||||
s := NewSchema()
|
||||
|
||||
_, ok := s.Field("nonexistent")
|
||||
if ok {
|
||||
t.Error("Field(nonexistent) should return false")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaNormalizeStatus(t *testing.T) {
|
||||
initTestRegistries()
|
||||
s := NewSchema()
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
wantOK bool
|
||||
}{
|
||||
{"done", "done", true},
|
||||
{"backlog", "backlog", true},
|
||||
{"in_progress", "in_progress", true},
|
||||
{"unknown_status", "", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got, ok := s.NormalizeStatus(tt.input)
|
||||
if ok != tt.wantOK {
|
||||
t.Errorf("NormalizeStatus(%q) ok = %v, want %v", tt.input, ok, tt.wantOK)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("NormalizeStatus(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestSchemaNormalizeType(t *testing.T) {
|
||||
initTestRegistries()
|
||||
s := NewSchema()
|
||||
|
||||
tests := []struct {
|
||||
input string
|
||||
want string
|
||||
wantOK bool
|
||||
}{
|
||||
{"story", "story", true},
|
||||
{"bug", "bug", true},
|
||||
{"feature", "story", true}, // alias
|
||||
{"task", "story", true}, // alias
|
||||
{"unknown_type", "story", false}, // falls back to first type
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.input, func(t *testing.T) {
|
||||
got, ok := s.NormalizeType(tt.input)
|
||||
if ok != tt.wantOK {
|
||||
t.Errorf("NormalizeType(%q) ok = %v, want %v", tt.input, ok, tt.wantOK)
|
||||
}
|
||||
if got != tt.want {
|
||||
t.Errorf("NormalizeType(%q) = %q, want %q", tt.input, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapValueTypeCompleteness(t *testing.T) {
|
||||
// verify every workflow type maps to a distinct ruki type
|
||||
seen := make(map[ruki.ValueType]workflow.ValueType)
|
||||
types := []workflow.ValueType{
|
||||
workflow.TypeString, workflow.TypeInt, workflow.TypeDate,
|
||||
workflow.TypeTimestamp, workflow.TypeDuration, workflow.TypeBool,
|
||||
workflow.TypeID, workflow.TypeRef, workflow.TypeRecurrence,
|
||||
workflow.TypeListString, workflow.TypeListRef,
|
||||
workflow.TypeStatus, workflow.TypeTaskType,
|
||||
}
|
||||
|
||||
for _, wt := range types {
|
||||
rv := mapValueType(wt)
|
||||
if prev, exists := seen[rv]; exists {
|
||||
t.Errorf("mapValueType(%d) and mapValueType(%d) both map to ruki.ValueType %d", prev, wt, rv)
|
||||
}
|
||||
seen[rv] = wt
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapValueTypeUnknownFallback(t *testing.T) {
|
||||
got := mapValueType(workflow.ValueType(999))
|
||||
if got != ruki.ValueString {
|
||||
t.Errorf("expected fallback to ValueString, got %d", got)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,14 @@
|
|||
package teststatuses
|
||||
|
||||
import "github.com/boolean-maybe/tiki/config"
|
||||
import (
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
"github.com/boolean-maybe/tiki/workflow"
|
||||
)
|
||||
|
||||
// Init registers the canonical test status set.
|
||||
// Call this from init() in each package's testinit_test.go.
|
||||
func Init() {
|
||||
config.ResetStatusRegistry([]config.StatusDef{
|
||||
config.ResetStatusRegistry([]workflow.StatusDef{
|
||||
{Key: "backlog", Label: "Backlog", Emoji: "📥", Default: true},
|
||||
{Key: "ready", Label: "Ready", Emoji: "📋", Active: true},
|
||||
{Key: "in_progress", Label: "In Progress", Emoji: "⚙️", Active: true},
|
||||
|
|
|
|||
94
main.go
|
|
@ -12,7 +12,9 @@ import (
|
|||
"github.com/boolean-maybe/tiki/internal/app"
|
||||
"github.com/boolean-maybe/tiki/internal/bootstrap"
|
||||
"github.com/boolean-maybe/tiki/internal/pipe"
|
||||
rukiRuntime "github.com/boolean-maybe/tiki/internal/ruki/runtime"
|
||||
"github.com/boolean-maybe/tiki/internal/viewer"
|
||||
"github.com/boolean-maybe/tiki/service"
|
||||
"github.com/boolean-maybe/tiki/util/sysinfo"
|
||||
)
|
||||
|
||||
|
|
@ -60,6 +62,11 @@ func main() {
|
|||
os.Exit(1)
|
||||
}
|
||||
|
||||
// Handle exec command: execute ruki statement and exit
|
||||
if len(os.Args) > 1 && os.Args[1] == "exec" {
|
||||
os.Exit(runExec(os.Args[2:]))
|
||||
}
|
||||
|
||||
// Handle piped stdin: create a task and exit without launching TUI
|
||||
if pipe.IsPipedInput() && !pipe.HasPositionalArgs(os.Args[1:]) {
|
||||
taskID, err := pipe.CreateTaskFromReader(os.Stdin)
|
||||
|
|
@ -76,7 +83,7 @@ func main() {
|
|||
|
||||
// Handle viewer mode (standalone markdown viewer)
|
||||
// "init" is reserved to prevent treating it as a markdown file
|
||||
viewerInput, runViewer, err := viewer.ParseViewerInput(os.Args[1:], map[string]struct{}{"init": {}, "demo": {}})
|
||||
viewerInput, runViewer, err := viewer.ParseViewerInput(os.Args[1:], map[string]struct{}{"init": {}, "demo": {}, "exec": {}})
|
||||
if err != nil {
|
||||
if errors.Is(err, viewer.ErrMultipleInputs) {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
|
|
@ -171,19 +178,88 @@ func runDemo() error {
|
|||
return nil
|
||||
}
|
||||
|
||||
// exit codes for tiki exec
|
||||
const (
|
||||
exitOK = 0
|
||||
exitInternal = 1
|
||||
exitUsage = 2
|
||||
exitStartupFailure = 3
|
||||
exitQueryError = 4
|
||||
)
|
||||
|
||||
// runExec implements `tiki exec '<statement>'`. Returns an exit code.
|
||||
func runExec(args []string) int {
|
||||
if len(args) != 1 {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "usage: tiki exec '<ruki-statement>'")
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
if err := bootstrap.EnsureGitRepo(); err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return exitStartupFailure
|
||||
}
|
||||
|
||||
if !config.IsProjectInitialized() {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error: project not initialized: run 'tiki init' first")
|
||||
return exitStartupFailure
|
||||
}
|
||||
|
||||
cfg, err := bootstrap.LoadConfig()
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "error: load config: %v\n", err)
|
||||
return exitStartupFailure
|
||||
}
|
||||
|
||||
bootstrap.InitCLILogging(cfg)
|
||||
|
||||
if err := config.InstallDefaultWorkflow(); err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "warning: install default workflow: %v\n", err)
|
||||
}
|
||||
|
||||
if err := config.LoadStatusRegistry(); err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "error: load status registry: %v\n", err)
|
||||
return exitStartupFailure
|
||||
}
|
||||
|
||||
gate := service.BuildGate()
|
||||
|
||||
_, taskStore, err := bootstrap.InitStores()
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "error: initialize store: %v\n", err)
|
||||
return exitStartupFailure
|
||||
}
|
||||
gate.SetStore(taskStore)
|
||||
|
||||
// load triggers so exec queries fire them
|
||||
schema := rukiRuntime.NewSchema()
|
||||
userName, _, _ := taskStore.GetCurrentUser()
|
||||
if _, _, err := service.LoadAndRegisterTriggers(gate, schema, func() string { return userName }); err != nil {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "error: load triggers: %v\n", err)
|
||||
return exitStartupFailure
|
||||
}
|
||||
|
||||
if err := rukiRuntime.RunQuery(gate, args[0], os.Stdout); err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return exitQueryError
|
||||
}
|
||||
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// printUsage prints usage information when tiki is run in an uninitialized repo.
|
||||
func printUsage() {
|
||||
fmt.Print(`tiki - Terminal-based task and documentation management
|
||||
|
||||
Usage:
|
||||
tiki Launch TUI in initialized repo
|
||||
tiki init Initialize project in current git repo
|
||||
tiki demo Clone demo project and launch TUI
|
||||
tiki file.md/URL View markdown file or image
|
||||
echo "Title" | tiki Create task from piped input
|
||||
tiki sysinfo Display system information
|
||||
tiki --help Show this help message
|
||||
tiki --version Show version
|
||||
tiki Launch TUI in initialized repo
|
||||
tiki init Initialize project in current git repo
|
||||
tiki exec '<statement>' Execute a ruki query and exit
|
||||
tiki demo Clone demo project and launch TUI
|
||||
tiki file.md/URL View markdown file or image
|
||||
echo "Title" | tiki Create task from piped input
|
||||
tiki sysinfo Display system information
|
||||
tiki --help Show this help message
|
||||
tiki --version Show version
|
||||
|
||||
Options:
|
||||
--log-level <level> Set log level (debug, info, warn, error)
|
||||
|
|
|
|||
|
|
@ -239,10 +239,6 @@ func ApplyLaneAction(src *task.Task, action LaneAction, currentUser string) (*ta
|
|||
}
|
||||
}
|
||||
|
||||
if validation := task.QuickValidate(clone); validation.HasErrors() {
|
||||
return nil, fmt.Errorf("action resulted in invalid task: %w", validation)
|
||||
}
|
||||
|
||||
return clone, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -261,6 +261,8 @@ func TestApplyLaneAction(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestApplyLaneAction_InvalidResult(t *testing.T) {
|
||||
// ApplyLaneAction no longer validates the result — the gate does that
|
||||
// at persistence time. This test verifies the action is applied as-is.
|
||||
base := &task.Task{
|
||||
ID: "TASK-1",
|
||||
Title: "Task",
|
||||
|
|
@ -280,9 +282,12 @@ func TestApplyLaneAction_InvalidResult(t *testing.T) {
|
|||
},
|
||||
}
|
||||
|
||||
_, err := ApplyLaneAction(base, action, "")
|
||||
if err == nil {
|
||||
t.Fatalf("expected validation error")
|
||||
result, err := ApplyLaneAction(base, action, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result.Priority != 99 {
|
||||
t.Errorf("expected priority 99, got %d", result.Priority)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -584,3 +589,60 @@ func TestApplyLaneAction_Due(t *testing.T) {
|
|||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseLaneAction_EmptyString(t *testing.T) {
|
||||
action, err := ParseLaneAction("")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if len(action.Ops) != 0 {
|
||||
t.Errorf("expected 0 ops for empty input, got %d", len(action.Ops))
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseLaneAction_InvalidInteger(t *testing.T) {
|
||||
_, err := ParseLaneAction("priority=abc")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-integer priority")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "invalid integer") {
|
||||
t.Errorf("expected 'invalid integer' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLaneAction_NilTask(t *testing.T) {
|
||||
action := LaneAction{Ops: []LaneActionOp{{Field: ActionFieldStatus, Operator: ActionOperatorAssign, StrValue: "done"}}}
|
||||
_, err := ApplyLaneAction(nil, action, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nil task")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "task is nil") {
|
||||
t.Errorf("expected 'task is nil' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLaneAction_NoOps(t *testing.T) {
|
||||
base := &task.Task{ID: "TASK-1", Title: "Task", Status: task.StatusBacklog}
|
||||
result, err := ApplyLaneAction(base, LaneAction{}, "")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if result == base {
|
||||
t.Error("expected clone, not original pointer")
|
||||
}
|
||||
if result.Title != "Task" {
|
||||
t.Errorf("expected title 'Task', got %q", result.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestApplyLaneAction_UnsupportedField(t *testing.T) {
|
||||
base := &task.Task{ID: "TASK-1", Title: "Task", Status: task.StatusBacklog}
|
||||
action := LaneAction{Ops: []LaneActionOp{{Field: "bogus", Operator: ActionOperatorAssign, StrValue: "x"}}}
|
||||
_, err := ApplyLaneAction(base, action, "")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unsupported field")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "unsupported action field") {
|
||||
t.Errorf("expected 'unsupported action field' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)",
|
||||
|
|
@ -360,3 +398,17 @@ func TestMultipleTimeConditions(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDuration_ParseError(t *testing.T) {
|
||||
_, err := ParseDuration("abc")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-numeric duration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDuration_UnknownUnit(t *testing.T) {
|
||||
_, err := ParseDuration("10xyz")
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown duration unit")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
214
ruki/ast.go
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
package ruki
|
||||
|
||||
import "time"
|
||||
|
||||
// --- top-level union types ---
|
||||
|
||||
// Statement is the result of parsing a CRUD command.
|
||||
// Exactly one variant is non-nil.
|
||||
type Statement struct {
|
||||
Select *SelectStmt
|
||||
Create *CreateStmt
|
||||
Update *UpdateStmt
|
||||
Delete *DeleteStmt
|
||||
}
|
||||
|
||||
// SelectStmt represents "select [fields] [where <condition>] [order by <field> [asc|desc], ...]".
|
||||
type SelectStmt struct {
|
||||
Fields []string // nil = all ("select" or "select *"); non-nil = specific fields
|
||||
Where Condition // nil = select all
|
||||
OrderBy []OrderByClause // nil = unordered
|
||||
}
|
||||
|
||||
// CreateStmt represents "create <field>=<value>...".
|
||||
type CreateStmt struct {
|
||||
Assignments []Assignment
|
||||
}
|
||||
|
||||
// UpdateStmt represents "update where <condition> set <field>=<value>...".
|
||||
type UpdateStmt struct {
|
||||
Where Condition
|
||||
Set []Assignment
|
||||
}
|
||||
|
||||
// DeleteStmt represents "delete where <condition>".
|
||||
type DeleteStmt struct {
|
||||
Where Condition
|
||||
}
|
||||
|
||||
// --- triggers ---
|
||||
|
||||
// Trigger is the result of parsing a reactive rule.
|
||||
type Trigger struct {
|
||||
Timing string // "before" or "after"
|
||||
Event string // "create", "update", or "delete"
|
||||
Where Condition // optional guard (nil if omitted)
|
||||
Action *Statement // after-triggers only (create/update/delete, not select)
|
||||
Run *RunAction // after-triggers only (alternative to Action)
|
||||
Deny *string // before-triggers only
|
||||
}
|
||||
|
||||
// RunAction represents "run(<string-expr>)" as a top-level trigger action.
|
||||
type RunAction struct {
|
||||
Command Expr
|
||||
}
|
||||
|
||||
// TimeTrigger is the result of parsing a periodic time trigger.
|
||||
// It wraps a mutating statement (create, update, or delete) with a schedule interval.
|
||||
type TimeTrigger struct {
|
||||
Interval DurationLiteral // e.g. {1, "hour"}, {1, "day"}
|
||||
Action *Statement // create, update, or delete (never select)
|
||||
}
|
||||
|
||||
// Rule is the result of parsing a trigger definition.
|
||||
// Exactly one variant is non-nil.
|
||||
type Rule struct {
|
||||
Trigger *Trigger
|
||||
TimeTrigger *TimeTrigger
|
||||
}
|
||||
|
||||
// --- conditions ---
|
||||
|
||||
// Condition is the interface for all boolean condition nodes.
|
||||
type Condition interface {
|
||||
conditionNode()
|
||||
}
|
||||
|
||||
// BinaryCondition represents "<condition> and/or <condition>".
|
||||
type BinaryCondition struct {
|
||||
Op string // "and" or "or"
|
||||
Left Condition
|
||||
Right Condition
|
||||
}
|
||||
|
||||
// NotCondition represents "not <condition>".
|
||||
type NotCondition struct {
|
||||
Inner Condition
|
||||
}
|
||||
|
||||
// CompareExpr represents "<expr> <op> <expr>".
|
||||
type CompareExpr struct {
|
||||
Left Expr
|
||||
Op string // "=", "!=", "<", ">", "<=", ">="
|
||||
Right Expr
|
||||
}
|
||||
|
||||
// IsEmptyExpr represents "<expr> is [not] empty".
|
||||
type IsEmptyExpr struct {
|
||||
Expr Expr
|
||||
Negated bool // true = "is not empty"
|
||||
}
|
||||
|
||||
// InExpr represents "<value> [not] in <collection>".
|
||||
type InExpr struct {
|
||||
Value Expr
|
||||
Collection Expr
|
||||
Negated bool // true = "not in"
|
||||
}
|
||||
|
||||
// QuantifierExpr represents "<expr> any/all <condition>".
|
||||
type QuantifierExpr struct {
|
||||
Expr Expr
|
||||
Kind string // "any" or "all"
|
||||
Condition Condition
|
||||
}
|
||||
|
||||
func (*BinaryCondition) conditionNode() {}
|
||||
func (*NotCondition) conditionNode() {}
|
||||
func (*CompareExpr) conditionNode() {}
|
||||
func (*IsEmptyExpr) conditionNode() {}
|
||||
func (*InExpr) conditionNode() {}
|
||||
func (*QuantifierExpr) conditionNode() {}
|
||||
|
||||
// --- expressions ---
|
||||
|
||||
// Expr is the interface for all expression nodes.
|
||||
type Expr interface {
|
||||
exprNode()
|
||||
}
|
||||
|
||||
// FieldRef represents a bare field name like "status" or "priority".
|
||||
type FieldRef struct {
|
||||
Name string
|
||||
}
|
||||
|
||||
// QualifiedRef represents "old.field" or "new.field".
|
||||
type QualifiedRef struct {
|
||||
Qualifier string // "old" or "new"
|
||||
Name string
|
||||
}
|
||||
|
||||
// StringLiteral represents a double-quoted string value.
|
||||
type StringLiteral struct {
|
||||
Value string
|
||||
}
|
||||
|
||||
// IntLiteral represents an integer value.
|
||||
type IntLiteral struct {
|
||||
Value int
|
||||
}
|
||||
|
||||
// DateLiteral represents a YYYY-MM-DD date.
|
||||
type DateLiteral struct {
|
||||
Value time.Time
|
||||
}
|
||||
|
||||
// DurationLiteral represents a number+unit like "2day" or "1week".
|
||||
type DurationLiteral struct {
|
||||
Value int
|
||||
Unit string
|
||||
}
|
||||
|
||||
// ListLiteral represents ["a", "b", ...].
|
||||
type ListLiteral struct {
|
||||
Elements []Expr
|
||||
}
|
||||
|
||||
// EmptyLiteral represents the "empty" keyword.
|
||||
type EmptyLiteral struct{}
|
||||
|
||||
// FunctionCall represents "name(args...)".
|
||||
type FunctionCall struct {
|
||||
Name string
|
||||
Args []Expr
|
||||
}
|
||||
|
||||
// BinaryExpr represents "<expr> +/- <expr>".
|
||||
type BinaryExpr struct {
|
||||
Op string // "+" or "-"
|
||||
Left Expr
|
||||
Right Expr
|
||||
}
|
||||
|
||||
// SubQuery represents "select [where <condition>]" used inside count().
|
||||
type SubQuery struct {
|
||||
Where Condition // nil = select all
|
||||
}
|
||||
|
||||
func (*FieldRef) exprNode() {}
|
||||
func (*QualifiedRef) exprNode() {}
|
||||
func (*StringLiteral) exprNode() {}
|
||||
func (*IntLiteral) exprNode() {}
|
||||
func (*DateLiteral) exprNode() {}
|
||||
func (*DurationLiteral) exprNode() {}
|
||||
func (*ListLiteral) exprNode() {}
|
||||
func (*EmptyLiteral) exprNode() {}
|
||||
func (*FunctionCall) exprNode() {}
|
||||
func (*BinaryExpr) exprNode() {}
|
||||
func (*SubQuery) exprNode() {}
|
||||
|
||||
// --- order by ---
|
||||
|
||||
// OrderByClause represents a single sort criterion in "order by <field> [asc|desc]".
|
||||
type OrderByClause struct {
|
||||
Field string // field name
|
||||
Desc bool // true = descending, false = ascending (default)
|
||||
}
|
||||
|
||||
// --- assignments ---
|
||||
|
||||
// Assignment represents "field=value" in create/update statements.
|
||||
type Assignment struct {
|
||||
Field string
|
||||
Value Expr
|
||||
}
|
||||
48
ruki/ast_test.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package ruki
|
||||
|
||||
import "testing"
|
||||
|
||||
// TestConditionNodeInterface verifies all Condition implementors satisfy the interface.
|
||||
func TestConditionNodeInterface(t *testing.T) {
|
||||
conditions := []Condition{
|
||||
&BinaryCondition{Op: "and"},
|
||||
&NotCondition{},
|
||||
&CompareExpr{Op: "="},
|
||||
&IsEmptyExpr{},
|
||||
&InExpr{},
|
||||
&QuantifierExpr{Kind: "any"},
|
||||
}
|
||||
|
||||
for _, c := range conditions {
|
||||
c.conditionNode() // exercise marker method
|
||||
}
|
||||
|
||||
if len(conditions) != 6 {
|
||||
t.Errorf("expected 6 condition types, got %d", len(conditions))
|
||||
}
|
||||
}
|
||||
|
||||
// TestExprNodeInterface verifies all Expr implementors satisfy the interface.
|
||||
func TestExprNodeInterface(t *testing.T) {
|
||||
exprs := []Expr{
|
||||
&FieldRef{Name: "status"},
|
||||
&QualifiedRef{Qualifier: "old", Name: "status"},
|
||||
&StringLiteral{Value: "hello"},
|
||||
&IntLiteral{Value: 42},
|
||||
&DateLiteral{},
|
||||
&DurationLiteral{Value: 1, Unit: "day"},
|
||||
&ListLiteral{},
|
||||
&EmptyLiteral{},
|
||||
&FunctionCall{Name: "now"},
|
||||
&BinaryExpr{Op: "+"},
|
||||
&SubQuery{},
|
||||
}
|
||||
|
||||
for _, e := range exprs {
|
||||
e.exprNode() // exercise marker method
|
||||
}
|
||||
|
||||
if len(exprs) != 11 {
|
||||
t.Errorf("expected 11 expr types, got %d", len(exprs))
|
||||
}
|
||||
}
|
||||
1214
ruki/executor.go
Normal file
74
ruki/executor_runtime.go
Normal file
|
|
@ -0,0 +1,74 @@
|
|||
package ruki
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
// ExecutorRuntimeMode identifies the semantic/runtime environment in which
|
||||
// a validated AST is intended to execute.
|
||||
type ExecutorRuntimeMode string
|
||||
|
||||
const (
|
||||
ExecutorRuntimeCLI ExecutorRuntimeMode = "cli"
|
||||
ExecutorRuntimePlugin ExecutorRuntimeMode = "plugin"
|
||||
ExecutorRuntimeEventTrigger ExecutorRuntimeMode = "eventTrigger"
|
||||
ExecutorRuntimeTimeTrigger ExecutorRuntimeMode = "timeTrigger"
|
||||
)
|
||||
|
||||
// ExecutorRuntime configures executor identity/runtime semantics.
|
||||
// Per-execution payload (e.g. selected task id, create template) is passed
|
||||
// via ExecutionInput and is intentionally not part of this struct.
|
||||
type ExecutorRuntime struct {
|
||||
Mode ExecutorRuntimeMode
|
||||
}
|
||||
|
||||
// normalize returns a runtime with defaults applied.
|
||||
func (r ExecutorRuntime) normalize() ExecutorRuntime {
|
||||
if r.Mode == "" {
|
||||
r.Mode = ExecutorRuntimeCLI
|
||||
}
|
||||
return r
|
||||
}
|
||||
|
||||
// ExecutionInput carries per-execution payload that is not part of executor
|
||||
// runtime identity.
|
||||
type ExecutionInput struct {
|
||||
SelectedTaskID string
|
||||
CreateTemplate *task.Task
|
||||
}
|
||||
|
||||
// RuntimeMismatchError reports execution with a wrapper validated for a
|
||||
// different runtime mode.
|
||||
type RuntimeMismatchError struct {
|
||||
ValidatedFor ExecutorRuntimeMode
|
||||
Runtime ExecutorRuntimeMode
|
||||
}
|
||||
|
||||
func (e *RuntimeMismatchError) Error() string {
|
||||
return fmt.Sprintf("validated runtime %q does not match executor runtime %q", e.ValidatedFor, e.Runtime)
|
||||
}
|
||||
|
||||
func (e *RuntimeMismatchError) Unwrap() error { return ErrRuntimeMismatch }
|
||||
|
||||
// MissingSelectedTaskIDError reports plugin execution that requires selected id
|
||||
// (due to syntactic id() usage) but did not receive it.
|
||||
type MissingSelectedTaskIDError struct{}
|
||||
|
||||
func (e *MissingSelectedTaskIDError) Error() string {
|
||||
return "selected task id is required for plugin runtime when id() is used"
|
||||
}
|
||||
|
||||
// MissingCreateTemplateError reports CREATE execution without required template.
|
||||
type MissingCreateTemplateError struct{}
|
||||
|
||||
func (e *MissingCreateTemplateError) Error() string {
|
||||
return "create template is required for create execution"
|
||||
}
|
||||
|
||||
var (
|
||||
// ErrRuntimeMismatch is used with errors.Is for runtime mismatch failures.
|
||||
ErrRuntimeMismatch = errors.New("runtime mismatch")
|
||||
)
|
||||
150
ruki/executor_runtime_test.go
Normal file
|
|
@ -0,0 +1,150 @@
|
|||
package ruki
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExecutorRuntimeNormalize(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mode ExecutorRuntimeMode
|
||||
expected ExecutorRuntimeMode
|
||||
}{
|
||||
{"empty mode defaults to cli", "", ExecutorRuntimeCLI},
|
||||
{"cli preserved", ExecutorRuntimeCLI, ExecutorRuntimeCLI},
|
||||
{"plugin preserved", ExecutorRuntimePlugin, ExecutorRuntimePlugin},
|
||||
{"event trigger preserved", ExecutorRuntimeEventTrigger, ExecutorRuntimeEventTrigger},
|
||||
{"time trigger preserved", ExecutorRuntimeTimeTrigger, ExecutorRuntimeTimeTrigger},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
r := ExecutorRuntime{Mode: tt.mode}
|
||||
got := r.normalize()
|
||||
if got.Mode != tt.expected {
|
||||
t.Errorf("normalize().Mode = %q, want %q", got.Mode, tt.expected)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestExecutorRuntimeNormalizeDoesNotMutateReceiver verifies normalize returns
|
||||
// a copy and leaves the original unchanged.
|
||||
func TestExecutorRuntimeNormalizeDoesNotMutateReceiver(t *testing.T) {
|
||||
r := ExecutorRuntime{Mode: ""}
|
||||
normalized := r.normalize()
|
||||
if r.Mode != "" {
|
||||
t.Errorf("original mutated: Mode = %q, want %q", r.Mode, "")
|
||||
}
|
||||
if normalized.Mode != ExecutorRuntimeCLI {
|
||||
t.Errorf("normalized.Mode = %q, want %q", normalized.Mode, ExecutorRuntimeCLI)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeMismatchErrorMessage(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
validatedFor ExecutorRuntimeMode
|
||||
runtime ExecutorRuntimeMode
|
||||
want string
|
||||
}{
|
||||
{
|
||||
"plugin vs cli",
|
||||
ExecutorRuntimePlugin,
|
||||
ExecutorRuntimeCLI,
|
||||
`validated runtime "plugin" does not match executor runtime "cli"`,
|
||||
},
|
||||
{
|
||||
"event trigger vs time trigger",
|
||||
ExecutorRuntimeEventTrigger,
|
||||
ExecutorRuntimeTimeTrigger,
|
||||
`validated runtime "eventTrigger" does not match executor runtime "timeTrigger"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := &RuntimeMismatchError{
|
||||
ValidatedFor: tt.validatedFor,
|
||||
Runtime: tt.runtime,
|
||||
}
|
||||
if got := err.Error(); got != tt.want {
|
||||
t.Errorf("Error() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeMismatchErrorUnwrap(t *testing.T) {
|
||||
err := &RuntimeMismatchError{
|
||||
ValidatedFor: ExecutorRuntimePlugin,
|
||||
Runtime: ExecutorRuntimeCLI,
|
||||
}
|
||||
|
||||
if unwrapped := err.Unwrap(); unwrapped != ErrRuntimeMismatch {
|
||||
t.Errorf("Unwrap() = %v, want %v", unwrapped, ErrRuntimeMismatch)
|
||||
}
|
||||
|
||||
if !errors.Is(err, ErrRuntimeMismatch) {
|
||||
t.Error("errors.Is(err, ErrRuntimeMismatch) = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingSelectedTaskIDErrorMessage(t *testing.T) {
|
||||
err := &MissingSelectedTaskIDError{}
|
||||
want := "selected task id is required for plugin runtime when id() is used"
|
||||
if got := err.Error(); got != want {
|
||||
t.Errorf("Error() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMissingCreateTemplateErrorMessage(t *testing.T) {
|
||||
err := &MissingCreateTemplateError{}
|
||||
want := "create template is required for create execution"
|
||||
if got := err.Error(); got != want {
|
||||
t.Errorf("Error() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestErrorTypesWithErrorsAs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
err error
|
||||
// check runs errors.As against the appropriate target type
|
||||
check func(error) bool
|
||||
}{
|
||||
{
|
||||
"RuntimeMismatchError",
|
||||
&RuntimeMismatchError{ValidatedFor: ExecutorRuntimeCLI, Runtime: ExecutorRuntimePlugin},
|
||||
func(err error) bool {
|
||||
var target *RuntimeMismatchError
|
||||
return errors.As(err, &target)
|
||||
},
|
||||
},
|
||||
{
|
||||
"MissingSelectedTaskIDError",
|
||||
&MissingSelectedTaskIDError{},
|
||||
func(err error) bool {
|
||||
var target *MissingSelectedTaskIDError
|
||||
return errors.As(err, &target)
|
||||
},
|
||||
},
|
||||
{
|
||||
"MissingCreateTemplateError",
|
||||
&MissingCreateTemplateError{},
|
||||
func(err error) bool {
|
||||
var target *MissingCreateTemplateError
|
||||
return errors.As(err, &target)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if !tt.check(tt.err) {
|
||||
t.Errorf("errors.As failed for %T", tt.err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
3740
ruki/executor_test.go
Normal file
210
ruki/grammar.go
Normal file
|
|
@ -0,0 +1,210 @@
|
|||
package ruki
|
||||
|
||||
// grammar.go — unexported participle grammar structs.
|
||||
// these encode operator precedence via grammar layering.
|
||||
// consumers never see these; lower.go converts them to clean AST types.
|
||||
|
||||
// --- top-level statement grammar ---
|
||||
|
||||
type statementGrammar struct {
|
||||
Select *selectGrammar `parser:" @@"`
|
||||
Create *createGrammar `parser:"| @@"`
|
||||
Update *updateGrammar `parser:"| @@"`
|
||||
Delete *deleteGrammar `parser:"| @@"`
|
||||
}
|
||||
|
||||
type fieldNamesGrammar struct {
|
||||
First string `parser:"@Ident"`
|
||||
Rest []string `parser:"( ',' @Ident )*"`
|
||||
}
|
||||
|
||||
type selectGrammar struct {
|
||||
Star *string `parser:"'select' ( @Star"`
|
||||
Fields *fieldNamesGrammar `parser:" | @@ )?"`
|
||||
Where *orCond `parser:"( 'where' @@ )?"`
|
||||
OrderBy *orderByGrammar `parser:"@@?"`
|
||||
}
|
||||
|
||||
// --- order by grammar ---
|
||||
|
||||
type orderByGrammar struct {
|
||||
First orderByField `parser:"'order' 'by' @@"`
|
||||
Rest []orderByField `parser:"( ',' @@ )*"`
|
||||
}
|
||||
|
||||
type orderByField struct {
|
||||
Field string `parser:"@Ident"`
|
||||
Direction *string `parser:"@( 'asc' | 'desc' )?"`
|
||||
}
|
||||
|
||||
type createGrammar struct {
|
||||
Assignments []assignmentGrammar `parser:"'create' @@+"`
|
||||
}
|
||||
|
||||
type updateGrammar struct {
|
||||
Where orCond `parser:"'update' 'where' @@"`
|
||||
Set []assignmentGrammar `parser:"'set' @@+"`
|
||||
}
|
||||
|
||||
type deleteGrammar struct {
|
||||
Where orCond `parser:"'delete' 'where' @@"`
|
||||
}
|
||||
|
||||
type assignmentGrammar struct {
|
||||
Field string `parser:"@Ident '='"`
|
||||
Value exprGrammar `parser:"@@"`
|
||||
}
|
||||
|
||||
// --- trigger grammar ---
|
||||
|
||||
type triggerGrammar struct {
|
||||
Timing string `parser:"@( 'before' | 'after' )"`
|
||||
Event string `parser:"@( 'create' | 'update' | 'delete' )"`
|
||||
Where *orCond `parser:"( 'where' @@ )?"`
|
||||
Action *actionGrammar `parser:"( @@"`
|
||||
Deny *denyGrammar `parser:"| @@ )?"`
|
||||
}
|
||||
|
||||
type actionGrammar struct {
|
||||
Run *runGrammar `parser:" @@"`
|
||||
Create *createGrammar `parser:"| @@"`
|
||||
Update *updateGrammar `parser:"| @@"`
|
||||
Delete *deleteGrammar `parser:"| @@"`
|
||||
}
|
||||
|
||||
type runGrammar struct {
|
||||
Command exprGrammar `parser:"'run' '(' @@ ')'"`
|
||||
}
|
||||
|
||||
type denyGrammar struct {
|
||||
Message string `parser:"'deny' @String"`
|
||||
}
|
||||
|
||||
// --- time trigger grammar ---
|
||||
|
||||
type timeTriggerGrammar struct {
|
||||
Interval string `parser:"'every' @Duration"`
|
||||
Create *createGrammar `parser:"( @@"`
|
||||
Update *updateGrammar `parser:"| @@"`
|
||||
Delete *deleteGrammar `parser:"| @@ )"`
|
||||
}
|
||||
|
||||
// --- rule grammar (union of event trigger and time trigger) ---
|
||||
|
||||
type ruleGrammar struct {
|
||||
TimeTrigger *timeTriggerGrammar `parser:" @@"`
|
||||
Trigger *triggerGrammar `parser:"| @@"`
|
||||
}
|
||||
|
||||
// --- condition grammar (precedence layers) ---
|
||||
|
||||
// orCond is the lowest-precedence condition layer.
|
||||
type orCond struct {
|
||||
Left andCond `parser:"@@"`
|
||||
Right []andCond `parser:"( 'or' @@ )*"`
|
||||
}
|
||||
|
||||
type andCond struct {
|
||||
Left notCond `parser:"@@"`
|
||||
Right []notCond `parser:"( 'and' @@ )*"`
|
||||
}
|
||||
|
||||
type notCond struct {
|
||||
Not *notCond `parser:" 'not' @@"`
|
||||
Primary *primaryCond `parser:"| @@"`
|
||||
}
|
||||
|
||||
type primaryCond struct {
|
||||
Paren *orCond `parser:" '(' @@ ')'"`
|
||||
Expr *exprCond `parser:"| @@"`
|
||||
}
|
||||
|
||||
// exprCond parses an expression followed by a condition operator.
|
||||
type exprCond struct {
|
||||
Left exprGrammar `parser:"@@"`
|
||||
Compare *compareTail `parser:"( @@"`
|
||||
IsEmpty *isEmptyTail `parser:"| @@"`
|
||||
IsNotEmpty *isNotEmptyTail `parser:"| @@"`
|
||||
NotIn *notInTail `parser:"| @@"`
|
||||
In *inTail `parser:"| @@"`
|
||||
Any *quantifierTail `parser:"| @@"`
|
||||
All *allQuantTail `parser:"| @@ )?"`
|
||||
}
|
||||
|
||||
type compareTail struct {
|
||||
Op string `parser:"@CompareOp"`
|
||||
Right exprGrammar `parser:"@@"`
|
||||
}
|
||||
|
||||
type isEmptyTail struct {
|
||||
Is string `parser:"@'is' 'empty'"`
|
||||
}
|
||||
|
||||
type isNotEmptyTail struct {
|
||||
Is string `parser:"@'is' 'not' 'empty'"`
|
||||
}
|
||||
|
||||
type inTail struct {
|
||||
Collection exprGrammar `parser:"'in' @@"`
|
||||
}
|
||||
|
||||
type notInTail struct {
|
||||
Collection exprGrammar `parser:"'not' 'in' @@"`
|
||||
}
|
||||
|
||||
type quantifierTail struct {
|
||||
Condition primaryCond `parser:"'any' @@"`
|
||||
}
|
||||
|
||||
type allQuantTail struct {
|
||||
Condition primaryCond `parser:"'all' @@"`
|
||||
}
|
||||
|
||||
// --- expression grammar ---
|
||||
|
||||
type exprGrammar struct {
|
||||
Left unaryExpr `parser:"@@"`
|
||||
Tail []exprBinTail `parser:"@@*"`
|
||||
}
|
||||
|
||||
type exprBinTail struct {
|
||||
Op string `parser:"@( Plus | Minus )"`
|
||||
Right unaryExpr `parser:"@@"`
|
||||
}
|
||||
|
||||
type unaryExpr struct {
|
||||
FuncCall *funcCallExpr `parser:" @@"`
|
||||
SubQuery *subQueryExpr `parser:"| @@"`
|
||||
QualRef *qualRefExpr `parser:"| @@"`
|
||||
ListLit *listLitExpr `parser:"| @@"`
|
||||
StrLit *string `parser:"| @String"`
|
||||
DateLit *string `parser:"| @Date"`
|
||||
DurLit *string `parser:"| @Duration"`
|
||||
IntLit *int `parser:"| @Int"`
|
||||
Empty *emptyExpr `parser:"| @@"`
|
||||
FieldRef *string `parser:"| @Ident"`
|
||||
Paren *exprGrammar `parser:"| '(' @@ ')'"`
|
||||
}
|
||||
|
||||
type funcCallExpr struct {
|
||||
Name string `parser:"@Ident '('"`
|
||||
Args []exprGrammar `parser:"( @@ ( ',' @@ )* )? ')'"`
|
||||
}
|
||||
|
||||
type subQueryExpr struct {
|
||||
Where *orCond `parser:"'select' ( 'where' @@ )?"`
|
||||
OrderBy *orderByGrammar `parser:"@@?"`
|
||||
}
|
||||
|
||||
type qualRefExpr struct {
|
||||
Qualifier string `parser:"@( 'old' | 'new' ) '.'"`
|
||||
Name string `parser:"@Ident"`
|
||||
}
|
||||
|
||||
type listLitExpr struct {
|
||||
Elements []exprGrammar `parser:"'[' ( @@ ( ',' @@ )* )? ']'"`
|
||||
}
|
||||
|
||||
type emptyExpr struct {
|
||||
Keyword string `parser:"@'empty'"`
|
||||
}
|
||||
43
ruki/keyword/keyword.go
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
package keyword
|
||||
|
||||
import "strings"
|
||||
|
||||
// reserved is the canonical, immutable list of ruki reserved words.
|
||||
var reserved = [...]string{
|
||||
"select", "create", "update", "delete",
|
||||
"where", "set", "order", "by",
|
||||
"asc", "desc",
|
||||
"before", "after", "deny", "run",
|
||||
"every",
|
||||
"and", "or", "not",
|
||||
"is", "empty", "in",
|
||||
"any", "all",
|
||||
"old", "new",
|
||||
}
|
||||
|
||||
var reservedSet map[string]struct{}
|
||||
|
||||
func init() {
|
||||
reservedSet = make(map[string]struct{}, len(reserved))
|
||||
for _, kw := range reserved {
|
||||
reservedSet[strings.ToLower(kw)] = struct{}{}
|
||||
}
|
||||
}
|
||||
|
||||
// IsReserved reports whether name is a ruki reserved word (case-insensitive).
|
||||
func IsReserved(name string) bool {
|
||||
_, ok := reservedSet[strings.ToLower(name)]
|
||||
return ok
|
||||
}
|
||||
|
||||
// List returns a copy of the reserved keyword list.
|
||||
func List() []string {
|
||||
result := make([]string, len(reserved))
|
||||
copy(result, reserved[:])
|
||||
return result
|
||||
}
|
||||
|
||||
// Pattern returns the regex alternation for the lexer Keyword rule.
|
||||
func Pattern() string {
|
||||
return `\b(` + strings.Join(reserved[:], "|") + `)\b`
|
||||
}
|
||||
66
ruki/keyword/keyword_test.go
Normal file
|
|
@ -0,0 +1,66 @@
|
|||
package keyword
|
||||
|
||||
import (
|
||||
"regexp"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestIsReserved(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
want bool
|
||||
}{
|
||||
{"select", true},
|
||||
{"SELECT", true},
|
||||
{"SeLeCt", true},
|
||||
{"where", true},
|
||||
{"and", true},
|
||||
{"old", true},
|
||||
{"new", true},
|
||||
{"title", false},
|
||||
{"priority", false},
|
||||
{"foobar", false},
|
||||
{"", false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := IsReserved(tt.name); got != tt.want {
|
||||
t.Errorf("IsReserved(%q) = %v, want %v", tt.name, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestList_ReturnsCopy(t *testing.T) {
|
||||
list := List()
|
||||
if len(list) != len(reserved) {
|
||||
t.Fatalf("List() returned %d keywords, want %d", len(list), len(reserved))
|
||||
}
|
||||
|
||||
// mutate returned slice — internal state must be unaffected
|
||||
list[0] = "MUTATED"
|
||||
fresh := List()
|
||||
if fresh[0] == "MUTATED" {
|
||||
t.Fatal("mutating List() result affected internal state")
|
||||
}
|
||||
}
|
||||
|
||||
func TestPattern(t *testing.T) {
|
||||
pat := Pattern()
|
||||
|
||||
// must be a valid regex
|
||||
re, err := regexp.Compile(pat)
|
||||
if err != nil {
|
||||
t.Fatalf("Pattern() is not valid regex: %v", err)
|
||||
}
|
||||
|
||||
// must match all reserved keywords
|
||||
for _, kw := range reserved {
|
||||
if !re.MatchString(kw) {
|
||||
t.Errorf("Pattern() does not match keyword %q", kw)
|
||||
}
|
||||
}
|
||||
|
||||
// must not match non-keywords
|
||||
if re.MatchString("foobar") {
|
||||
t.Error("Pattern() should not match 'foobar'")
|
||||
}
|
||||
}
|
||||
18
ruki/keywords.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
package ruki
|
||||
|
||||
import "github.com/boolean-maybe/tiki/ruki/keyword"
|
||||
|
||||
// IsReservedKeyword reports whether name is a ruki reserved word (case-insensitive).
|
||||
func IsReservedKeyword(name string) bool {
|
||||
return keyword.IsReserved(name)
|
||||
}
|
||||
|
||||
// ReservedKeywordsList returns a copy of the reserved keyword list.
|
||||
func ReservedKeywordsList() []string {
|
||||
return keyword.List()
|
||||
}
|
||||
|
||||
// keywordPattern returns the regex alternation for the lexer Keyword rule.
|
||||
func keywordPattern() string {
|
||||
return keyword.Pattern()
|
||||
}
|
||||
30
ruki/lexer.go
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
package ruki
|
||||
|
||||
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+(?:` + duration.Pattern() + `)s?`},
|
||||
{Name: "Date", Pattern: `\d{4}-\d{2}-\d{2}`},
|
||||
{Name: "Int", Pattern: `\d+`},
|
||||
{Name: "String", Pattern: `"(?:[^"\\]|\\.)*"`},
|
||||
{Name: "CompareOp", Pattern: `!=|<=|>=|[=<>]`},
|
||||
{Name: "Star", Pattern: `\*`},
|
||||
{Name: "Plus", Pattern: `\+`},
|
||||
{Name: "Minus", Pattern: `-`},
|
||||
{Name: "Dot", Pattern: `\.`},
|
||||
{Name: "LParen", Pattern: `\(`},
|
||||
{Name: "RParen", Pattern: `\)`},
|
||||
{Name: "LBracket", Pattern: `\[`},
|
||||
{Name: "RBracket", Pattern: `\]`},
|
||||
{Name: "Comma", Pattern: `,`},
|
||||
{Name: "Keyword", Pattern: keywordPattern()},
|
||||
{Name: "Ident", Pattern: `[a-zA-Z_][a-zA-Z0-9_]*`},
|
||||
})
|
||||
226
ruki/lexer_test.go
Normal file
|
|
@ -0,0 +1,226 @@
|
|||
package ruki
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/alecthomas/participle/v2/lexer"
|
||||
)
|
||||
|
||||
// tokenize lexes input and returns non-whitespace, non-EOF tokens.
|
||||
func tokenize(t *testing.T, input string) []lexer.Token {
|
||||
t.Helper()
|
||||
lex, err := rukiLexer.Lex("", strings.NewReader(input))
|
||||
if err != nil {
|
||||
t.Fatalf("lex error: %v", err)
|
||||
}
|
||||
all, err := lexer.ConsumeAll(lex)
|
||||
if err != nil {
|
||||
t.Fatalf("consume error: %v", err)
|
||||
}
|
||||
symbols := rukiLexer.Symbols()
|
||||
wsType := symbols["Whitespace"]
|
||||
var result []lexer.Token
|
||||
for _, tok := range all {
|
||||
if tok.Type != wsType && tok.Type != lexer.EOF {
|
||||
result = append(result, tok)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func TestTokenizeKeywords(t *testing.T) {
|
||||
keywordType := rukiLexer.Symbols()["Keyword"]
|
||||
|
||||
for _, kw := range ReservedKeywordsList() {
|
||||
t.Run(kw, func(t *testing.T) {
|
||||
tokens := tokenize(t, kw)
|
||||
if len(tokens) != 1 {
|
||||
t.Fatalf("expected 1 token, got %d: %v", len(tokens), tokens)
|
||||
}
|
||||
if tokens[0].Type != keywordType {
|
||||
t.Errorf("expected Keyword token for %q, got type %d", kw, tokens[0].Type)
|
||||
}
|
||||
if tokens[0].Value != kw {
|
||||
t.Errorf("expected value %q, got %q", kw, tokens[0].Value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenizeWordBoundary(t *testing.T) {
|
||||
identType := rukiLexer.Symbols()["Ident"]
|
||||
|
||||
cases := []struct {
|
||||
input string
|
||||
rationale string
|
||||
}{
|
||||
{"selectAll", "keyword 'select' as prefix"},
|
||||
{"nowhere", "keyword 'where' as suffix"},
|
||||
{"ordering", "keyword 'order' as prefix"},
|
||||
{"inline", "keyword 'in' as prefix"},
|
||||
{"orchestra", "keyword 'or' as prefix"},
|
||||
{"newsletter", "keyword 'new' as prefix"},
|
||||
{"dataset", "keyword 'set' as suffix"},
|
||||
{"island", "keyword 'is' as prefix"},
|
||||
{"notify", "keyword 'not' as prefix"},
|
||||
{"bygone", "keyword 'by' as prefix"},
|
||||
{"descendant", "keyword 'desc' as prefix"},
|
||||
{"ascend", "keyword 'asc' as prefix"},
|
||||
{"inbound", "keyword 'in' as prefix"},
|
||||
{"android", "keyword 'and' as prefix"},
|
||||
{"emptySet", "keyword 'empty' as prefix"},
|
||||
{"denying", "keyword 'deny' as prefix"},
|
||||
{"running", "keyword 'run' as prefix"},
|
||||
{"anyone", "keyword 'any' as prefix"},
|
||||
{"overall", "keyword 'all' as suffix"},
|
||||
{"oldest", "keyword 'old' as prefix"},
|
||||
{"afterword", "keyword 'after' as prefix"},
|
||||
{"beforehand", "keyword 'before' as prefix"},
|
||||
{"everyone", "keyword 'every' as prefix"},
|
||||
{"everyday", "keyword 'every' as prefix"},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.input, func(t *testing.T) {
|
||||
tokens := tokenize(t, tc.input)
|
||||
if len(tokens) != 1 {
|
||||
t.Fatalf("expected 1 Ident token for %q (%s), got %d tokens: %v",
|
||||
tc.input, tc.rationale, len(tokens), tokens)
|
||||
}
|
||||
if tokens[0].Type != identType {
|
||||
t.Errorf("expected Ident for %q (%s), got type %d",
|
||||
tc.input, tc.rationale, tokens[0].Type)
|
||||
}
|
||||
if tokens[0].Value != tc.input {
|
||||
t.Errorf("expected value %q, got %q", tc.input, tokens[0].Value)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTokenizeStar(t *testing.T) {
|
||||
symbols := rukiLexer.Symbols()
|
||||
starType := symbols["Star"]
|
||||
keywordType := symbols["Keyword"]
|
||||
identType := symbols["Ident"]
|
||||
|
||||
t.Run("bare star", func(t *testing.T) {
|
||||
tokens := tokenize(t, "*")
|
||||
if len(tokens) != 1 {
|
||||
t.Fatalf("expected 1 token, got %d: %v", len(tokens), tokens)
|
||||
}
|
||||
if tokens[0].Type != starType {
|
||||
t.Errorf("expected Star token, got type %d", tokens[0].Type)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("star among keywords", func(t *testing.T) {
|
||||
tokens := tokenize(t, "select * where")
|
||||
if len(tokens) != 3 {
|
||||
t.Fatalf("expected 3 tokens, got %d: %v", len(tokens), tokens)
|
||||
}
|
||||
if tokens[0].Type != keywordType {
|
||||
t.Errorf("expected Keyword for 'select', got type %d", tokens[0].Type)
|
||||
}
|
||||
if tokens[1].Type != starType {
|
||||
t.Errorf("expected Star for '*', got type %d", tokens[1].Type)
|
||||
}
|
||||
if tokens[2].Type != keywordType {
|
||||
t.Errorf("expected Keyword for 'where', got type %d", tokens[2].Type)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("star not consumed as ident", func(t *testing.T) {
|
||||
tokens := tokenize(t, "*foo")
|
||||
if len(tokens) != 2 {
|
||||
t.Fatalf("expected 2 tokens, got %d: %v", len(tokens), tokens)
|
||||
}
|
||||
if tokens[0].Type != starType {
|
||||
t.Errorf("expected Star for '*', got type %d", tokens[0].Type)
|
||||
}
|
||||
if tokens[1].Type != identType {
|
||||
t.Errorf("expected Ident for 'foo', got type %d", tokens[1].Type)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestKeywordInIdentPosition_ParseError(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
cases := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
// statement keywords as field refs
|
||||
{"select as field ref", `select where select = "done"`},
|
||||
{"where as field ref", `select where where = "done"`},
|
||||
{"delete as field ref", `select where delete = "done"`},
|
||||
{"create as field ref", `select where create = "done"`},
|
||||
{"update as field ref", `select where update = "done"`},
|
||||
|
||||
// clause keywords as field refs
|
||||
{"set as assignment target", `create set="value"`},
|
||||
{"order as order-by field", `select order by order`},
|
||||
{"where as order-by field", `select order by where desc`},
|
||||
{"by as field ref", `select where by = "x"`},
|
||||
|
||||
// logical keywords as field refs
|
||||
{"and as field ref", `select where and = "x"`},
|
||||
{"or as field ref", `select where or = "x"`},
|
||||
{"not as field ref", `select where not = "x"`},
|
||||
|
||||
// test keywords as field refs
|
||||
{"is as field ref", `select where is = "x"`},
|
||||
{"in as field ref", `select where in = "x"`},
|
||||
// note: 'empty' is intentionally omitted — it's a valid expression literal
|
||||
// (emptyExpr in grammar), so `select where empty = "x"` is valid grammar
|
||||
// that fails at validation, not parsing.
|
||||
|
||||
// quantifier keywords as field refs
|
||||
{"any as field ref", `select where any = "x"`},
|
||||
{"all as field ref", `select where all = "x"`},
|
||||
|
||||
// sort keywords as field refs
|
||||
{"asc as field ref", `select where asc = "x"`},
|
||||
{"desc as field ref", `select where desc = "x"`},
|
||||
|
||||
// trigger keywords as field refs
|
||||
{"before as field ref", `select where before = "x"`},
|
||||
{"after as field ref", `select where after = "x"`},
|
||||
{"deny as field ref", `select where deny = "x"`},
|
||||
{"run as field ref", `select where run = "x"`},
|
||||
|
||||
// time trigger keyword as field ref
|
||||
{"every as field ref", `select where every = "x"`},
|
||||
|
||||
// qualifier keywords as field refs
|
||||
{"old as field ref", `select where old = "x"`},
|
||||
{"new as field ref", `select where new = "x"`},
|
||||
}
|
||||
|
||||
for _, tc := range cases {
|
||||
t.Run(tc.name, func(t *testing.T) {
|
||||
_, err := p.ParseStatement(tc.input)
|
||||
if err == nil {
|
||||
t.Errorf("expected parse error for %q, got nil", tc.input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsReservedKeyword(t *testing.T) {
|
||||
reserved := []string{"select", "SELECT", "where", "create", "update", "delete", "and", "or", "not", "in", "is", "empty", "any", "all", "set", "order", "by", "asc", "desc", "before", "after", "deny", "run", "every", "old", "new"}
|
||||
for _, kw := range reserved {
|
||||
if !IsReservedKeyword(kw) {
|
||||
t.Errorf("expected %q to be reserved", kw)
|
||||
}
|
||||
}
|
||||
|
||||
notReserved := []string{"title", "status", "foo", "bar", "hello"}
|
||||
for _, kw := range notReserved {
|
||||
if IsReservedKeyword(kw) {
|
||||
t.Errorf("expected %q to NOT be reserved", kw)
|
||||
}
|
||||
}
|
||||
}
|
||||
462
ruki/lower.go
Normal file
|
|
@ -0,0 +1,462 @@
|
|||
package ruki
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strconv"
|
||||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/util/duration"
|
||||
)
|
||||
|
||||
// lower.go converts participle grammar structs into clean AST types.
|
||||
|
||||
func lowerStatement(g *statementGrammar) (*Statement, error) {
|
||||
switch {
|
||||
case g.Select != nil:
|
||||
s, err := lowerSelect(g.Select)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Statement{Select: s}, nil
|
||||
case g.Create != nil:
|
||||
s, err := lowerCreate(g.Create)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Statement{Create: s}, nil
|
||||
case g.Update != nil:
|
||||
s, err := lowerUpdate(g.Update)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Statement{Update: s}, nil
|
||||
case g.Delete != nil:
|
||||
s, err := lowerDelete(g.Delete)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Statement{Delete: s}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("empty statement")
|
||||
}
|
||||
}
|
||||
|
||||
func lowerSelect(g *selectGrammar) (*SelectStmt, error) {
|
||||
var fields []string
|
||||
if g.Star == nil && g.Fields != nil {
|
||||
fields = make([]string, 0, 1+len(g.Fields.Rest))
|
||||
fields = append(fields, g.Fields.First)
|
||||
fields = append(fields, g.Fields.Rest...)
|
||||
}
|
||||
|
||||
var where Condition
|
||||
if g.Where != nil {
|
||||
var err error
|
||||
where, err = lowerOrCond(g.Where)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
orderBy := lowerOrderBy(g.OrderBy)
|
||||
return &SelectStmt{Fields: fields, Where: where, OrderBy: orderBy}, nil
|
||||
}
|
||||
|
||||
func lowerCreate(g *createGrammar) (*CreateStmt, error) {
|
||||
assignments, err := lowerAssignments(g.Assignments)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CreateStmt{Assignments: assignments}, nil
|
||||
}
|
||||
|
||||
func lowerUpdate(g *updateGrammar) (*UpdateStmt, error) {
|
||||
where, err := lowerOrCond(&g.Where)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
set, err := lowerAssignments(g.Set)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &UpdateStmt{Where: where, Set: set}, nil
|
||||
}
|
||||
|
||||
func lowerDelete(g *deleteGrammar) (*DeleteStmt, error) {
|
||||
where, err := lowerOrCond(&g.Where)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &DeleteStmt{Where: where}, nil
|
||||
}
|
||||
|
||||
func lowerAssignments(gs []assignmentGrammar) ([]Assignment, error) {
|
||||
result := make([]Assignment, len(gs))
|
||||
for i, g := range gs {
|
||||
val, err := lowerExpr(&g.Value)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[i] = Assignment{Field: g.Field, Value: val}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// --- trigger lowering ---
|
||||
|
||||
func lowerTrigger(g *triggerGrammar) (*Trigger, error) {
|
||||
t := &Trigger{
|
||||
Timing: g.Timing,
|
||||
Event: g.Event,
|
||||
}
|
||||
|
||||
if g.Where != nil {
|
||||
where, err := lowerOrCond(g.Where)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
t.Where = where
|
||||
}
|
||||
|
||||
if g.Action != nil {
|
||||
if err := lowerTriggerAction(g.Action, t); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
|
||||
if g.Deny != nil {
|
||||
msg := unquoteString(g.Deny.Message)
|
||||
t.Deny = &msg
|
||||
}
|
||||
|
||||
return t, nil
|
||||
}
|
||||
|
||||
func lowerTriggerAction(g *actionGrammar, t *Trigger) error {
|
||||
switch {
|
||||
case g.Run != nil:
|
||||
cmd, err := lowerExpr(&g.Run.Command)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.Run = &RunAction{Command: cmd}
|
||||
case g.Create != nil:
|
||||
s, err := lowerCreate(g.Create)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.Action = &Statement{Create: s}
|
||||
case g.Update != nil:
|
||||
s, err := lowerUpdate(g.Update)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.Action = &Statement{Update: s}
|
||||
case g.Delete != nil:
|
||||
s, err := lowerDelete(g.Delete)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
t.Action = &Statement{Delete: s}
|
||||
default:
|
||||
return fmt.Errorf("empty trigger action")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- rule lowering (union dispatch) ---
|
||||
|
||||
func lowerRule(g *ruleGrammar) (*Rule, error) {
|
||||
switch {
|
||||
case g.TimeTrigger != nil:
|
||||
tt, err := lowerTimeTrigger(g.TimeTrigger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Rule{TimeTrigger: tt}, nil
|
||||
case g.Trigger != nil:
|
||||
trig, err := lowerTrigger(g.Trigger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &Rule{Trigger: trig}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("empty rule")
|
||||
}
|
||||
}
|
||||
|
||||
// --- time trigger lowering ---
|
||||
|
||||
func lowerTimeTrigger(g *timeTriggerGrammar) (*TimeTrigger, error) {
|
||||
val, unit, err := duration.Parse(g.Interval)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid interval: %w", err)
|
||||
}
|
||||
|
||||
var stmt *Statement
|
||||
switch {
|
||||
case g.Create != nil:
|
||||
s, err := lowerCreate(g.Create)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stmt = &Statement{Create: s}
|
||||
case g.Update != nil:
|
||||
s, err := lowerUpdate(g.Update)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stmt = &Statement{Update: s}
|
||||
case g.Delete != nil:
|
||||
s, err := lowerDelete(g.Delete)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stmt = &Statement{Delete: s}
|
||||
default:
|
||||
return nil, fmt.Errorf("empty time trigger action")
|
||||
}
|
||||
|
||||
return &TimeTrigger{
|
||||
Interval: DurationLiteral{Value: val, Unit: unit},
|
||||
Action: stmt,
|
||||
}, nil
|
||||
}
|
||||
|
||||
// --- condition lowering ---
|
||||
|
||||
func lowerOrCond(g *orCond) (Condition, error) {
|
||||
left, err := lowerAndCond(&g.Left)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range g.Right {
|
||||
right, err := lowerAndCond(&r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
left = &BinaryCondition{Op: "or", Left: left, Right: right}
|
||||
}
|
||||
return left, nil
|
||||
}
|
||||
|
||||
func lowerAndCond(g *andCond) (Condition, error) {
|
||||
left, err := lowerNotCond(&g.Left)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range g.Right {
|
||||
right, err := lowerNotCond(&r)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
left = &BinaryCondition{Op: "and", Left: left, Right: right}
|
||||
}
|
||||
return left, nil
|
||||
}
|
||||
|
||||
func lowerNotCond(g *notCond) (Condition, error) {
|
||||
if g.Not != nil {
|
||||
inner, err := lowerNotCond(g.Not)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &NotCondition{Inner: inner}, nil
|
||||
}
|
||||
return lowerPrimaryCond(g.Primary)
|
||||
}
|
||||
|
||||
func lowerPrimaryCond(g *primaryCond) (Condition, error) {
|
||||
if g.Paren != nil {
|
||||
return lowerOrCond(g.Paren)
|
||||
}
|
||||
return lowerExprCond(g.Expr)
|
||||
}
|
||||
|
||||
func lowerExprCond(g *exprCond) (Condition, error) {
|
||||
left, err := lowerExpr(&g.Left)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
switch {
|
||||
case g.Compare != nil:
|
||||
right, err := lowerExpr(&g.Compare.Right)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &CompareExpr{Left: left, Op: g.Compare.Op, Right: right}, nil
|
||||
|
||||
case g.IsEmpty != nil:
|
||||
return &IsEmptyExpr{Expr: left, Negated: false}, nil
|
||||
|
||||
case g.IsNotEmpty != nil:
|
||||
return &IsEmptyExpr{Expr: left, Negated: true}, nil
|
||||
|
||||
case g.In != nil:
|
||||
coll, err := lowerExpr(&g.In.Collection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &InExpr{Value: left, Collection: coll, Negated: false}, nil
|
||||
|
||||
case g.NotIn != nil:
|
||||
coll, err := lowerExpr(&g.NotIn.Collection)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &InExpr{Value: left, Collection: coll, Negated: true}, nil
|
||||
|
||||
case g.Any != nil:
|
||||
cond, err := lowerPrimaryCond(&g.Any.Condition)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &QuantifierExpr{Expr: left, Kind: "any", Condition: cond}, nil
|
||||
|
||||
case g.All != nil:
|
||||
cond, err := lowerPrimaryCond(&g.All.Condition)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &QuantifierExpr{Expr: left, Kind: "all", Condition: cond}, nil
|
||||
|
||||
default:
|
||||
// bare expression used as condition — this is a parse error
|
||||
return nil, fmt.Errorf("expression used as condition without comparison operator")
|
||||
}
|
||||
}
|
||||
|
||||
// --- expression lowering ---
|
||||
|
||||
func lowerExpr(g *exprGrammar) (Expr, error) {
|
||||
left, err := lowerUnary(&g.Left)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, tail := range g.Tail {
|
||||
right, err := lowerUnary(&tail.Right)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
left = &BinaryExpr{Op: tail.Op, Left: left, Right: right}
|
||||
}
|
||||
return left, nil
|
||||
}
|
||||
|
||||
func lowerUnary(g *unaryExpr) (Expr, error) {
|
||||
switch {
|
||||
case g.FuncCall != nil:
|
||||
return lowerFuncCall(g.FuncCall)
|
||||
case g.SubQuery != nil:
|
||||
return lowerSubQuery(g.SubQuery)
|
||||
case g.QualRef != nil:
|
||||
return &QualifiedRef{Qualifier: g.QualRef.Qualifier, Name: g.QualRef.Name}, nil
|
||||
case g.ListLit != nil:
|
||||
return lowerListLit(g.ListLit)
|
||||
case g.StrLit != nil:
|
||||
return &StringLiteral{Value: unquoteString(*g.StrLit)}, nil
|
||||
case g.DateLit != nil:
|
||||
return parseDateLiteral(*g.DateLit)
|
||||
case g.DurLit != nil:
|
||||
return parseDurationLiteral(*g.DurLit)
|
||||
case g.IntLit != nil:
|
||||
return &IntLiteral{Value: *g.IntLit}, nil
|
||||
case g.Empty != nil:
|
||||
return &EmptyLiteral{}, nil
|
||||
case g.FieldRef != nil:
|
||||
return &FieldRef{Name: *g.FieldRef}, nil
|
||||
case g.Paren != nil:
|
||||
return lowerExpr(g.Paren)
|
||||
default:
|
||||
return nil, fmt.Errorf("empty expression")
|
||||
}
|
||||
}
|
||||
|
||||
func lowerFuncCall(g *funcCallExpr) (Expr, error) {
|
||||
args := make([]Expr, len(g.Args))
|
||||
for i, a := range g.Args {
|
||||
arg, err := lowerExpr(&a)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
args[i] = arg
|
||||
}
|
||||
return &FunctionCall{Name: g.Name, Args: args}, nil
|
||||
}
|
||||
|
||||
func lowerSubQuery(g *subQueryExpr) (Expr, error) {
|
||||
if g.OrderBy != nil {
|
||||
return nil, fmt.Errorf("order by is not valid inside a subquery")
|
||||
}
|
||||
var where Condition
|
||||
if g.Where != nil {
|
||||
var err error
|
||||
where, err = lowerOrCond(g.Where)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &SubQuery{Where: where}, nil
|
||||
}
|
||||
|
||||
func lowerListLit(g *listLitExpr) (Expr, error) {
|
||||
elems := make([]Expr, len(g.Elements))
|
||||
for i, e := range g.Elements {
|
||||
elem, err := lowerExpr(&e)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
elems[i] = elem
|
||||
}
|
||||
return &ListLiteral{Elements: elems}, nil
|
||||
}
|
||||
|
||||
// --- order by lowering ---
|
||||
|
||||
func lowerOrderBy(g *orderByGrammar) []OrderByClause {
|
||||
if g == nil {
|
||||
return nil
|
||||
}
|
||||
clauses := make([]OrderByClause, 0, 1+len(g.Rest))
|
||||
clauses = append(clauses, lowerOrderByField(&g.First))
|
||||
for i := range g.Rest {
|
||||
clauses = append(clauses, lowerOrderByField(&g.Rest[i]))
|
||||
}
|
||||
return clauses
|
||||
}
|
||||
|
||||
func lowerOrderByField(g *orderByField) OrderByClause {
|
||||
desc := g.Direction != nil && *g.Direction == "desc"
|
||||
return OrderByClause{Field: g.Field, Desc: desc}
|
||||
}
|
||||
|
||||
// --- literal helpers ---
|
||||
|
||||
func unquoteString(s string) string {
|
||||
// strip surrounding quotes and unescape
|
||||
if len(s) >= 2 && s[0] == '"' && s[len(s)-1] == '"' {
|
||||
unquoted, err := strconv.Unquote(s)
|
||||
if err == nil {
|
||||
return unquoted
|
||||
}
|
||||
// fallback: just strip quotes
|
||||
return s[1 : len(s)-1]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func parseDateLiteral(s string) (Expr, error) {
|
||||
t, err := time.Parse("2006-01-02", s)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("invalid date literal %q: %w", s, err)
|
||||
}
|
||||
return &DateLiteral{Value: t}, nil
|
||||
}
|
||||
|
||||
func parseDurationLiteral(s string) (Expr, error) {
|
||||
val, unit, err := duration.Parse(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &DurationLiteral{Value: val, Unit: unit}, nil
|
||||
}
|
||||
1238
ruki/lower_test.go
Normal file
147
ruki/parser.go
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
package ruki
|
||||
|
||||
import (
|
||||
"github.com/alecthomas/participle/v2"
|
||||
)
|
||||
|
||||
// Schema provides the canonical field catalog and normalization functions
|
||||
// that the parser uses for validation. Production code adapts this from
|
||||
// workflow.Fields(), workflow.StatusRegistry, and workflow.TypeRegistry.
|
||||
type Schema interface {
|
||||
// Field returns the field spec for a given field name.
|
||||
Field(name string) (FieldSpec, bool)
|
||||
// NormalizeStatus validates and normalizes a raw status string.
|
||||
// returns the canonical key and true, or ("", false) for unknown values.
|
||||
NormalizeStatus(raw string) (string, bool)
|
||||
// NormalizeType validates and normalizes a raw type string.
|
||||
// returns the canonical key and true, or ("", false) for unknown values.
|
||||
NormalizeType(raw string) (string, bool)
|
||||
}
|
||||
|
||||
// ValueType identifies the semantic type of a field in the DSL.
|
||||
type ValueType int
|
||||
|
||||
const (
|
||||
ValueString ValueType = iota
|
||||
ValueInt // priority, points
|
||||
ValueDate // due
|
||||
ValueTimestamp // createdAt, updatedAt
|
||||
ValueDuration // duration literals
|
||||
ValueBool // boolean result type
|
||||
ValueID // task identifier
|
||||
ValueRef // reference to another task
|
||||
ValueRecurrence // recurrence pattern
|
||||
ValueListString // tags
|
||||
ValueListRef // dependsOn
|
||||
ValueStatus // status enum
|
||||
ValueTaskType // type enum
|
||||
)
|
||||
|
||||
// FieldSpec describes a single task field for the parser.
|
||||
type FieldSpec struct {
|
||||
Name string
|
||||
Type ValueType
|
||||
}
|
||||
|
||||
// Parser parses ruki DSL statements and triggers.
|
||||
type Parser struct {
|
||||
stmtParser *participle.Parser[statementGrammar]
|
||||
triggerParser *participle.Parser[triggerGrammar]
|
||||
timeTriggerParser *participle.Parser[timeTriggerGrammar]
|
||||
ruleParser *participle.Parser[ruleGrammar]
|
||||
schema Schema
|
||||
qualifiers qualifierPolicy // set before each validation pass
|
||||
requireQualifiers bool // when true, bare FieldRef is a parse error (trigger where-guards)
|
||||
}
|
||||
|
||||
// NewParser constructs a Parser with the given schema for validation.
|
||||
// panics if the grammar is invalid (programming error, not user error).
|
||||
func NewParser(schema Schema) *Parser {
|
||||
opts := []participle.Option{
|
||||
participle.Lexer(rukiLexer),
|
||||
participle.Elide("Comment", "Whitespace"),
|
||||
participle.UseLookahead(3),
|
||||
}
|
||||
return &Parser{
|
||||
stmtParser: participle.MustBuild[statementGrammar](opts...),
|
||||
triggerParser: participle.MustBuild[triggerGrammar](opts...),
|
||||
timeTriggerParser: participle.MustBuild[timeTriggerGrammar](opts...),
|
||||
ruleParser: participle.MustBuild[ruleGrammar](opts...),
|
||||
schema: schema,
|
||||
}
|
||||
}
|
||||
|
||||
// ParseStatement parses a CRUD statement and performs syntax, structural,
|
||||
// built-in signature/arity, and type validation.
|
||||
// Semantic runtime validation is a separate step.
|
||||
func (p *Parser) ParseStatement(input string) (*Statement, error) {
|
||||
g, err := p.stmtParser.ParseString("", input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
stmt, err := lowerStatement(g)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.qualifiers = noQualifiers
|
||||
if err := p.validateStatement(stmt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return stmt, nil
|
||||
}
|
||||
|
||||
// ParseTrigger parses a reactive trigger rule and performs syntax, structural,
|
||||
// built-in signature/arity, and type validation.
|
||||
// Semantic runtime validation is a separate step.
|
||||
func (p *Parser) ParseTrigger(input string) (*Trigger, error) {
|
||||
g, err := p.triggerParser.ParseString("", input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
trig, err := lowerTrigger(g)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.qualifiers = triggerQualifiers(trig.Event)
|
||||
if err := p.validateTrigger(trig); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return trig, nil
|
||||
}
|
||||
|
||||
// ParseTimeTrigger parses a periodic time trigger and performs syntax,
|
||||
// structural, built-in signature/arity, and type validation.
|
||||
// Semantic runtime validation is a separate step.
|
||||
func (p *Parser) ParseTimeTrigger(input string) (*TimeTrigger, error) {
|
||||
g, err := p.timeTriggerParser.ParseString("", input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
tt, err := lowerTimeTrigger(g)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := p.validateTimeTrigger(tt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return tt, nil
|
||||
}
|
||||
|
||||
// ParseRule parses a trigger definition that is either an event trigger
|
||||
// (before/after) or a time trigger (every), and performs syntax, structural,
|
||||
// built-in signature/arity, and type validation.
|
||||
// Semantic runtime validation is a separate step after branching.
|
||||
func (p *Parser) ParseRule(input string) (*Rule, error) {
|
||||
g, err := p.ruleParser.ParseString("", input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rule, err := lowerRule(g)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := p.validateRule(rule); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return rule, nil
|
||||
}
|
||||
826
ruki/parser_test.go
Normal file
|
|
@ -0,0 +1,826 @@
|
|||
package ruki
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// testSchema implements Schema for tests with standard tiki fields.
|
||||
type testSchema struct{}
|
||||
|
||||
func (testSchema) Field(name string) (FieldSpec, bool) {
|
||||
fields := map[string]FieldSpec{
|
||||
"id": {Name: "id", Type: ValueID},
|
||||
"title": {Name: "title", Type: ValueString},
|
||||
"description": {Name: "description", Type: ValueString},
|
||||
"status": {Name: "status", Type: ValueStatus},
|
||||
"type": {Name: "type", Type: ValueTaskType},
|
||||
"tags": {Name: "tags", Type: ValueListString},
|
||||
"dependsOn": {Name: "dependsOn", Type: ValueListRef},
|
||||
"due": {Name: "due", Type: ValueDate},
|
||||
"recurrence": {Name: "recurrence", Type: ValueRecurrence},
|
||||
"assignee": {Name: "assignee", Type: ValueString},
|
||||
"priority": {Name: "priority", Type: ValueInt},
|
||||
"points": {Name: "points", Type: ValueInt},
|
||||
"createdBy": {Name: "createdBy", Type: ValueString},
|
||||
"createdAt": {Name: "createdAt", Type: ValueTimestamp},
|
||||
"updatedAt": {Name: "updatedAt", Type: ValueTimestamp},
|
||||
}
|
||||
f, ok := fields[name]
|
||||
return f, ok
|
||||
}
|
||||
|
||||
func (testSchema) NormalizeStatus(raw string) (string, bool) {
|
||||
valid := map[string]string{
|
||||
"backlog": "backlog",
|
||||
"ready": "ready",
|
||||
"todo": "ready",
|
||||
"in progress": "in_progress",
|
||||
"in_progress": "in_progress",
|
||||
"review": "review",
|
||||
"done": "done",
|
||||
"cancelled": "cancelled",
|
||||
}
|
||||
canonical, ok := valid[raw]
|
||||
return canonical, ok
|
||||
}
|
||||
|
||||
func (testSchema) NormalizeType(raw string) (string, bool) {
|
||||
valid := map[string]string{
|
||||
"story": "story",
|
||||
"feature": "story",
|
||||
"task": "story",
|
||||
"bug": "bug",
|
||||
"spike": "spike",
|
||||
"epic": "epic",
|
||||
}
|
||||
canonical, ok := valid[raw]
|
||||
return canonical, ok
|
||||
}
|
||||
|
||||
func newTestParser() *Parser {
|
||||
return NewParser(testSchema{})
|
||||
}
|
||||
|
||||
func TestParseSelect(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantWhere bool
|
||||
}{
|
||||
{"select all", "select", false},
|
||||
{"select with where", `select where status = "done"`, true},
|
||||
{"select with and", `select where status = "done" and priority <= 2`, true},
|
||||
{"select with in", `select where "bug" in tags`, true},
|
||||
{"select with quantifier", `select where dependsOn any status != "done"`, true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
stmt, err := p.ParseStatement(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
if stmt.Select == nil {
|
||||
t.Fatal("expected Select, got nil")
|
||||
}
|
||||
if tt.wantWhere && stmt.Select.Where == nil {
|
||||
t.Fatal("expected Where condition, got nil")
|
||||
}
|
||||
if !tt.wantWhere && stmt.Select.Where != nil {
|
||||
t.Fatal("expected nil Where, got condition")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCreate(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantFields int
|
||||
}{
|
||||
{
|
||||
"basic create",
|
||||
`create title="Fix login" priority=2 status="ready" tags=["bug"]`,
|
||||
4,
|
||||
},
|
||||
{
|
||||
"single field",
|
||||
`create title="hello"`,
|
||||
1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
stmt, err := p.ParseStatement(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
if stmt.Create == nil {
|
||||
t.Fatal("expected Create, got nil")
|
||||
}
|
||||
if len(stmt.Create.Assignments) != tt.wantFields {
|
||||
t.Fatalf("expected %d assignments, got %d", tt.wantFields, len(stmt.Create.Assignments))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseUpdate(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantSet int
|
||||
}{
|
||||
{
|
||||
"update by id",
|
||||
`update where id = "TIKI-ABC123" set status="done"`,
|
||||
1,
|
||||
},
|
||||
{
|
||||
"update with complex where",
|
||||
`update where status = "ready" and "sprint-3" in tags set status="cancelled"`,
|
||||
1,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
stmt, err := p.ParseStatement(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
if stmt.Update == nil {
|
||||
t.Fatal("expected Update, got nil")
|
||||
}
|
||||
if len(stmt.Update.Set) != tt.wantSet {
|
||||
t.Fatalf("expected %d set assignments, got %d", tt.wantSet, len(stmt.Update.Set))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseDelete(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"delete by id", `delete where id = "TIKI-ABC123"`},
|
||||
{"delete with complex where", `delete where status = "cancelled" and "old" in tags`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
stmt, err := p.ParseStatement(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
if stmt.Delete == nil {
|
||||
t.Fatal("expected Delete, got nil")
|
||||
}
|
||||
if stmt.Delete.Where == nil {
|
||||
t.Fatal("expected Where condition, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseExpressions(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
check func(t *testing.T, stmt *Statement)
|
||||
}{
|
||||
{
|
||||
"string literal in assignment",
|
||||
`create title="hello world"`,
|
||||
func(t *testing.T, stmt *Statement) {
|
||||
t.Helper()
|
||||
sl, ok := stmt.Create.Assignments[0].Value.(*StringLiteral)
|
||||
if !ok {
|
||||
t.Fatalf("expected StringLiteral, got %T", stmt.Create.Assignments[0].Value)
|
||||
}
|
||||
if sl.Value != "hello world" {
|
||||
t.Fatalf("expected %q, got %q", "hello world", sl.Value)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"int literal in assignment",
|
||||
`create title="x" priority=2`,
|
||||
func(t *testing.T, stmt *Statement) {
|
||||
t.Helper()
|
||||
il, ok := stmt.Create.Assignments[1].Value.(*IntLiteral)
|
||||
if !ok {
|
||||
t.Fatalf("expected IntLiteral, got %T", stmt.Create.Assignments[1].Value)
|
||||
}
|
||||
if il.Value != 2 {
|
||||
t.Fatalf("expected 2, got %d", il.Value)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"date literal in assignment",
|
||||
`create title="x" due=2026-03-25`,
|
||||
func(t *testing.T, stmt *Statement) {
|
||||
t.Helper()
|
||||
dl, ok := stmt.Create.Assignments[1].Value.(*DateLiteral)
|
||||
if !ok {
|
||||
t.Fatalf("expected DateLiteral, got %T", stmt.Create.Assignments[1].Value)
|
||||
}
|
||||
expected := time.Date(2026, 3, 25, 0, 0, 0, 0, time.UTC)
|
||||
if !dl.Value.Equal(expected) {
|
||||
t.Fatalf("expected %v, got %v", expected, dl.Value)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"list literal in assignment",
|
||||
`create title="x" tags=["bug", "frontend"]`,
|
||||
func(t *testing.T, stmt *Statement) {
|
||||
t.Helper()
|
||||
ll, ok := stmt.Create.Assignments[1].Value.(*ListLiteral)
|
||||
if !ok {
|
||||
t.Fatalf("expected ListLiteral, got %T", stmt.Create.Assignments[1].Value)
|
||||
}
|
||||
if len(ll.Elements) != 2 {
|
||||
t.Fatalf("expected 2 elements, got %d", len(ll.Elements))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"empty literal in assignment",
|
||||
`create title="x" assignee=empty`,
|
||||
func(t *testing.T, stmt *Statement) {
|
||||
t.Helper()
|
||||
if _, ok := stmt.Create.Assignments[1].Value.(*EmptyLiteral); !ok {
|
||||
t.Fatalf("expected EmptyLiteral, got %T", stmt.Create.Assignments[1].Value)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"function call in assignment",
|
||||
`create title="x" due=next_date(recurrence)`,
|
||||
func(t *testing.T, stmt *Statement) {
|
||||
t.Helper()
|
||||
fc, ok := stmt.Create.Assignments[1].Value.(*FunctionCall)
|
||||
if !ok {
|
||||
t.Fatalf("expected FunctionCall, got %T", stmt.Create.Assignments[1].Value)
|
||||
}
|
||||
if fc.Name != "next_date" {
|
||||
t.Fatalf("expected next_date, got %s", fc.Name)
|
||||
}
|
||||
if len(fc.Args) != 1 {
|
||||
t.Fatalf("expected 1 arg, got %d", len(fc.Args))
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"binary plus expression",
|
||||
`create title="x" tags=tags + ["new"]`,
|
||||
func(t *testing.T, stmt *Statement) {
|
||||
t.Helper()
|
||||
be, ok := stmt.Create.Assignments[1].Value.(*BinaryExpr)
|
||||
if !ok {
|
||||
t.Fatalf("expected BinaryExpr, got %T", stmt.Create.Assignments[1].Value)
|
||||
}
|
||||
if be.Op != "+" {
|
||||
t.Fatalf("expected +, got %s", be.Op)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"duration literal",
|
||||
`create title="x" due=2026-03-25 + 2day`,
|
||||
func(t *testing.T, stmt *Statement) {
|
||||
t.Helper()
|
||||
be, ok := stmt.Create.Assignments[1].Value.(*BinaryExpr)
|
||||
if !ok {
|
||||
t.Fatalf("expected BinaryExpr, got %T", stmt.Create.Assignments[1].Value)
|
||||
}
|
||||
dur, ok := be.Right.(*DurationLiteral)
|
||||
if !ok {
|
||||
t.Fatalf("expected DurationLiteral, got %T", be.Right)
|
||||
}
|
||||
if dur.Value != 2 || dur.Unit != "day" {
|
||||
t.Fatalf("expected 2day, got %d%s", dur.Value, dur.Unit)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
stmt, err := p.ParseStatement(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
tt.check(t, stmt)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseConditions(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
check func(t *testing.T, stmt *Statement)
|
||||
}{
|
||||
{
|
||||
"simple compare",
|
||||
`select where status = "done"`,
|
||||
func(t *testing.T, stmt *Statement) {
|
||||
t.Helper()
|
||||
cmp, ok := stmt.Select.Where.(*CompareExpr)
|
||||
if !ok {
|
||||
t.Fatalf("expected CompareExpr, got %T", stmt.Select.Where)
|
||||
}
|
||||
if cmp.Op != "=" {
|
||||
t.Fatalf("expected =, got %s", cmp.Op)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"is empty",
|
||||
`select where assignee is empty`,
|
||||
func(t *testing.T, stmt *Statement) {
|
||||
t.Helper()
|
||||
ie, ok := stmt.Select.Where.(*IsEmptyExpr)
|
||||
if !ok {
|
||||
t.Fatalf("expected IsEmptyExpr, got %T", stmt.Select.Where)
|
||||
}
|
||||
if ie.Negated {
|
||||
t.Fatal("expected Negated=false")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"is not empty",
|
||||
`select where description is not empty`,
|
||||
func(t *testing.T, stmt *Statement) {
|
||||
t.Helper()
|
||||
ie, ok := stmt.Select.Where.(*IsEmptyExpr)
|
||||
if !ok {
|
||||
t.Fatalf("expected IsEmptyExpr, got %T", stmt.Select.Where)
|
||||
}
|
||||
if !ie.Negated {
|
||||
t.Fatal("expected Negated=true")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"value in field",
|
||||
`select where "bug" in tags`,
|
||||
func(t *testing.T, stmt *Statement) {
|
||||
t.Helper()
|
||||
in, ok := stmt.Select.Where.(*InExpr)
|
||||
if !ok {
|
||||
t.Fatalf("expected InExpr, got %T", stmt.Select.Where)
|
||||
}
|
||||
if in.Negated {
|
||||
t.Fatal("expected Negated=false")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"value not in list",
|
||||
`select where status not in ["done", "cancelled"]`,
|
||||
func(t *testing.T, stmt *Statement) {
|
||||
t.Helper()
|
||||
in, ok := stmt.Select.Where.(*InExpr)
|
||||
if !ok {
|
||||
t.Fatalf("expected InExpr, got %T", stmt.Select.Where)
|
||||
}
|
||||
if !in.Negated {
|
||||
t.Fatal("expected Negated=true")
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"and precedence",
|
||||
`select where status = "done" and priority <= 2`,
|
||||
func(t *testing.T, stmt *Statement) {
|
||||
t.Helper()
|
||||
bc, ok := stmt.Select.Where.(*BinaryCondition)
|
||||
if !ok {
|
||||
t.Fatalf("expected BinaryCondition, got %T", stmt.Select.Where)
|
||||
}
|
||||
if bc.Op != "and" {
|
||||
t.Fatalf("expected and, got %s", bc.Op)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"or precedence — and binds tighter",
|
||||
`select where priority = 1 or priority = 2 and status = "done"`,
|
||||
func(t *testing.T, stmt *Statement) {
|
||||
t.Helper()
|
||||
// should parse as: priority=1 or (priority=2 and status="done")
|
||||
bc, ok := stmt.Select.Where.(*BinaryCondition)
|
||||
if !ok {
|
||||
t.Fatalf("expected BinaryCondition, got %T", stmt.Select.Where)
|
||||
}
|
||||
if bc.Op != "or" {
|
||||
t.Fatalf("expected or at top, got %s", bc.Op)
|
||||
}
|
||||
// right side should be an and
|
||||
right, ok := bc.Right.(*BinaryCondition)
|
||||
if !ok {
|
||||
t.Fatalf("expected BinaryCondition on right, got %T", bc.Right)
|
||||
}
|
||||
if right.Op != "and" {
|
||||
t.Fatalf("expected and on right, got %s", right.Op)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"not condition",
|
||||
`select where not status = "done"`,
|
||||
func(t *testing.T, stmt *Statement) {
|
||||
t.Helper()
|
||||
nc, ok := stmt.Select.Where.(*NotCondition)
|
||||
if !ok {
|
||||
t.Fatalf("expected NotCondition, got %T", stmt.Select.Where)
|
||||
}
|
||||
if _, ok := nc.Inner.(*CompareExpr); !ok {
|
||||
t.Fatalf("expected CompareExpr inside not, got %T", nc.Inner)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"parenthesized condition",
|
||||
`select where (status = "done" or status = "cancelled") and priority = 1`,
|
||||
func(t *testing.T, stmt *Statement) {
|
||||
t.Helper()
|
||||
bc, ok := stmt.Select.Where.(*BinaryCondition)
|
||||
if !ok {
|
||||
t.Fatalf("expected BinaryCondition, got %T", stmt.Select.Where)
|
||||
}
|
||||
if bc.Op != "and" {
|
||||
t.Fatalf("expected and at top, got %s", bc.Op)
|
||||
}
|
||||
// left should be an or (the parenthesized group)
|
||||
left, ok := bc.Left.(*BinaryCondition)
|
||||
if !ok {
|
||||
t.Fatalf("expected BinaryCondition on left, got %T", bc.Left)
|
||||
}
|
||||
if left.Op != "or" {
|
||||
t.Fatalf("expected or on left, got %s", left.Op)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"quantifier any",
|
||||
`select where dependsOn any status != "done"`,
|
||||
func(t *testing.T, stmt *Statement) {
|
||||
t.Helper()
|
||||
qe, ok := stmt.Select.Where.(*QuantifierExpr)
|
||||
if !ok {
|
||||
t.Fatalf("expected QuantifierExpr, got %T", stmt.Select.Where)
|
||||
}
|
||||
if qe.Kind != "any" {
|
||||
t.Fatalf("expected any, got %s", qe.Kind)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"quantifier all",
|
||||
`select where dependsOn all status = "done"`,
|
||||
func(t *testing.T, stmt *Statement) {
|
||||
t.Helper()
|
||||
qe, ok := stmt.Select.Where.(*QuantifierExpr)
|
||||
if !ok {
|
||||
t.Fatalf("expected QuantifierExpr, got %T", stmt.Select.Where)
|
||||
}
|
||||
if qe.Kind != "all" {
|
||||
t.Fatalf("expected all, got %s", qe.Kind)
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
"quantifier binds to primary — and separates",
|
||||
`select where dependsOn any status != "done" and priority = 1`,
|
||||
func(t *testing.T, stmt *Statement) {
|
||||
t.Helper()
|
||||
// should parse as: (dependsOn any (status != "done")) and (priority = 1)
|
||||
bc, ok := stmt.Select.Where.(*BinaryCondition)
|
||||
if !ok {
|
||||
t.Fatalf("expected BinaryCondition at top, got %T", stmt.Select.Where)
|
||||
}
|
||||
if bc.Op != "and" {
|
||||
t.Fatalf("expected and, got %s", bc.Op)
|
||||
}
|
||||
if _, ok := bc.Left.(*QuantifierExpr); !ok {
|
||||
t.Fatalf("expected QuantifierExpr on left, got %T", bc.Left)
|
||||
}
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
stmt, err := p.ParseStatement(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
tt.check(t, stmt)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseQualifiedRefs(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
input := `select where status = "done"`
|
||||
stmt, err := p.ParseStatement(input)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
cmp, ok := stmt.Select.Where.(*CompareExpr)
|
||||
if !ok {
|
||||
t.Fatalf("expected CompareExpr, got %T", stmt.Select.Where)
|
||||
}
|
||||
fr, ok := cmp.Left.(*FieldRef)
|
||||
if !ok {
|
||||
t.Fatalf("expected FieldRef, got %T", cmp.Left)
|
||||
}
|
||||
if fr.Name != "status" {
|
||||
t.Fatalf("expected status, got %s", fr.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSubQuery(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
input := `select where count(select where status = "in progress" and assignee = "bob") >= 3`
|
||||
stmt, err := p.ParseStatement(input)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
|
||||
cmp, ok := stmt.Select.Where.(*CompareExpr)
|
||||
if !ok {
|
||||
t.Fatalf("expected CompareExpr, got %T", stmt.Select.Where)
|
||||
}
|
||||
|
||||
fc, ok := cmp.Left.(*FunctionCall)
|
||||
if !ok {
|
||||
t.Fatalf("expected FunctionCall, got %T", cmp.Left)
|
||||
}
|
||||
if fc.Name != "count" {
|
||||
t.Fatalf("expected count, got %s", fc.Name)
|
||||
}
|
||||
|
||||
sq, ok := fc.Args[0].(*SubQuery)
|
||||
if !ok {
|
||||
t.Fatalf("expected SubQuery arg, got %T", fc.Args[0])
|
||||
}
|
||||
if sq.Where == nil {
|
||||
t.Fatal("expected SubQuery Where, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseStatementErrors(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"empty input", ""},
|
||||
{"unknown keyword", "drop where id = 1"},
|
||||
{"missing where in update", `update set status="done"`},
|
||||
{"missing set in update", `update where id = "x"`},
|
||||
{"missing where in delete", `delete id = "x"`},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := p.ParseStatement(tt.input)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSelectOrderBy(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantWhere bool
|
||||
wantOrderBy []OrderByClause
|
||||
}{
|
||||
{
|
||||
"order by single field",
|
||||
"select order by priority",
|
||||
false,
|
||||
[]OrderByClause{{Field: "priority", Desc: false}},
|
||||
},
|
||||
{
|
||||
"order by desc",
|
||||
"select order by priority desc",
|
||||
false,
|
||||
[]OrderByClause{{Field: "priority", Desc: true}},
|
||||
},
|
||||
{
|
||||
"order by asc",
|
||||
"select order by priority asc",
|
||||
false,
|
||||
[]OrderByClause{{Field: "priority", Desc: false}},
|
||||
},
|
||||
{
|
||||
"order by multiple fields",
|
||||
"select order by priority desc, createdAt asc",
|
||||
false,
|
||||
[]OrderByClause{
|
||||
{Field: "priority", Desc: true},
|
||||
{Field: "createdAt", Desc: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
"order by mixed directions",
|
||||
"select order by status, priority desc, title",
|
||||
false,
|
||||
[]OrderByClause{
|
||||
{Field: "status", Desc: false},
|
||||
{Field: "priority", Desc: true},
|
||||
{Field: "title", Desc: false},
|
||||
},
|
||||
},
|
||||
{
|
||||
"where and order by",
|
||||
`select where status = "done" order by updatedAt desc`,
|
||||
true,
|
||||
[]OrderByClause{{Field: "updatedAt", Desc: true}},
|
||||
},
|
||||
{
|
||||
"where and order by multiple",
|
||||
`select where "bug" in tags order by priority asc, createdAt desc`,
|
||||
true,
|
||||
[]OrderByClause{
|
||||
{Field: "priority", Desc: false},
|
||||
{Field: "createdAt", Desc: true},
|
||||
},
|
||||
},
|
||||
{
|
||||
"select without order by still works",
|
||||
`select where status = "done"`,
|
||||
true,
|
||||
nil,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
stmt, err := p.ParseStatement(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
if stmt.Select == nil {
|
||||
t.Fatal("expected Select")
|
||||
}
|
||||
if tt.wantWhere && stmt.Select.Where == nil {
|
||||
t.Fatal("expected Where condition")
|
||||
}
|
||||
if !tt.wantWhere && stmt.Select.Where != nil {
|
||||
t.Fatal("unexpected Where condition")
|
||||
}
|
||||
if len(tt.wantOrderBy) == 0 && len(stmt.Select.OrderBy) != 0 {
|
||||
t.Fatalf("expected no OrderBy, got %v", stmt.Select.OrderBy)
|
||||
}
|
||||
if len(tt.wantOrderBy) != len(stmt.Select.OrderBy) {
|
||||
t.Fatalf("expected %d OrderBy clauses, got %d", len(tt.wantOrderBy), len(stmt.Select.OrderBy))
|
||||
}
|
||||
for i, want := range tt.wantOrderBy {
|
||||
got := stmt.Select.OrderBy[i]
|
||||
if got.Field != want.Field {
|
||||
t.Errorf("OrderBy[%d].Field = %q, want %q", i, got.Field, want.Field)
|
||||
}
|
||||
if got.Desc != want.Desc {
|
||||
t.Errorf("OrderBy[%d].Desc = %v, want %v", i, got.Desc, want.Desc)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSelectFields(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantFields []string // nil = all fields
|
||||
wantWhere bool
|
||||
wantOrderBy int
|
||||
}{
|
||||
{"bare select", "select", nil, false, 0},
|
||||
{"select star", "select *", nil, false, 0},
|
||||
{"single field", "select title", []string{"title"}, false, 0},
|
||||
{"two fields", "select id, title", []string{"id", "title"}, false, 0},
|
||||
{"many fields", "select id, title, status, priority", []string{"id", "title", "status", "priority"}, false, 0},
|
||||
{"fields + where", `select title, status where priority = 1`, []string{"title", "status"}, true, 0},
|
||||
{"single field + where", `select title where status = "done"`, []string{"title"}, true, 0},
|
||||
{"fields + order by", "select title order by priority", []string{"title"}, false, 1},
|
||||
{"fields + where + order by", `select id, title where status = "done" order by priority desc`, []string{"id", "title"}, true, 1},
|
||||
{"star + where", `select * where status = "done"`, nil, true, 0},
|
||||
{"star + order by", "select * order by title", nil, false, 1},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
stmt, err := p.ParseStatement(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
if stmt.Select == nil {
|
||||
t.Fatal("expected Select")
|
||||
}
|
||||
|
||||
// check fields
|
||||
if tt.wantFields == nil {
|
||||
if stmt.Select.Fields != nil {
|
||||
t.Fatalf("expected nil Fields (all), got %v", stmt.Select.Fields)
|
||||
}
|
||||
} else {
|
||||
if len(stmt.Select.Fields) != len(tt.wantFields) {
|
||||
t.Fatalf("expected %d fields, got %d: %v", len(tt.wantFields), len(stmt.Select.Fields), stmt.Select.Fields)
|
||||
}
|
||||
for i, want := range tt.wantFields {
|
||||
if stmt.Select.Fields[i] != want {
|
||||
t.Errorf("Fields[%d] = %q, want %q", i, stmt.Select.Fields[i], want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// check where
|
||||
if tt.wantWhere && stmt.Select.Where == nil {
|
||||
t.Fatal("expected Where condition")
|
||||
}
|
||||
if !tt.wantWhere && stmt.Select.Where != nil {
|
||||
t.Fatal("unexpected Where condition")
|
||||
}
|
||||
|
||||
// check order by
|
||||
if len(stmt.Select.OrderBy) != tt.wantOrderBy {
|
||||
t.Fatalf("expected %d OrderBy clauses, got %d", tt.wantOrderBy, len(stmt.Select.OrderBy))
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseSelectFieldsErrors(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{"trailing comma", "select title,"},
|
||||
{"leading comma", "select , title"},
|
||||
{"star + named fields", "select *, title"},
|
||||
{"named fields + star", "select title, *"},
|
||||
{"double star", "select * *"},
|
||||
{"comma only", "select ,"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := p.ParseStatement(tt.input)
|
||||
if err == nil {
|
||||
t.Fatalf("expected parse error for %q, got nil", tt.input)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseComment(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
input := `-- this is a comment
|
||||
select where status = "done"`
|
||||
stmt, err := p.ParseStatement(input)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
if stmt.Select == nil {
|
||||
t.Fatal("expected Select")
|
||||
}
|
||||
}
|
||||
166
ruki/runtime_safety_test.go
Normal file
|
|
@ -0,0 +1,166 @@
|
|||
package ruki
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestExecuteRawStatementRejectsCallBeforeEvaluation(t *testing.T) {
|
||||
e := newTestExecutor()
|
||||
p := newTestParser()
|
||||
|
||||
stmt, err := p.ParseStatement(`select where 1 = 2 and call("echo hello") = "x"`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
_, err = e.Execute(stmt, makeTasks())
|
||||
if err == nil {
|
||||
t.Fatal("expected semantic validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "call() is not supported yet") {
|
||||
t.Fatalf("expected call() semantic validation error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteRawStatementRejectsIDOutsidePluginRuntime(t *testing.T) {
|
||||
e := newTestExecutor()
|
||||
p := newTestParser()
|
||||
|
||||
stmt, err := p.ParseStatement(`select where 1 = 2 and id() = "TIKI-000001"`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse: %v", err)
|
||||
}
|
||||
_, err = e.Execute(stmt, makeTasks())
|
||||
if err == nil {
|
||||
t.Fatal("expected semantic validation error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "id() is only available in plugin runtime") {
|
||||
t.Fatalf("expected id() runtime error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteValidatedStatementRuntimeMismatch(t *testing.T) {
|
||||
e := newTestExecutor()
|
||||
p := newTestParser()
|
||||
|
||||
validated, err := p.ParseAndValidateStatement(`select`, ExecutorRuntimePlugin)
|
||||
if err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
_, err = e.Execute(validated, makeTasks())
|
||||
if err == nil {
|
||||
t.Fatal("expected runtime mismatch error")
|
||||
}
|
||||
var mismatch *RuntimeMismatchError
|
||||
if !errors.As(err, &mismatch) {
|
||||
t.Fatalf("expected RuntimeMismatchError, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteUnsealedValidatedStatementRejected(t *testing.T) {
|
||||
e := newTestExecutor()
|
||||
unsealed := &ValidatedStatement{
|
||||
statement: &Statement{Select: &SelectStmt{}},
|
||||
}
|
||||
|
||||
_, err := e.Execute(unsealed, makeTasks())
|
||||
if err == nil {
|
||||
t.Fatal("expected unvalidated wrapper error")
|
||||
}
|
||||
var unvalidated *UnvalidatedWrapperError
|
||||
if !errors.As(err, &unvalidated) {
|
||||
t.Fatalf("expected UnvalidatedWrapperError, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecuteValidatedCreateRequiresTemplate(t *testing.T) {
|
||||
e := newTestExecutor()
|
||||
p := newTestParser()
|
||||
|
||||
validated, err := p.ParseAndValidateStatement(`create title="x"`, ExecutorRuntimeCLI)
|
||||
if err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
_, err = e.Execute(validated, nil)
|
||||
if err == nil {
|
||||
t.Fatal("expected missing create template error")
|
||||
}
|
||||
var missing *MissingCreateTemplateError
|
||||
if !errors.As(err, &missing) {
|
||||
t.Fatalf("expected MissingCreateTemplateError, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestExecutePluginIDRequiresSelectedTaskID(t *testing.T) {
|
||||
p := newTestParser()
|
||||
e := NewExecutor(testSchema{}, func() string { return "alice" }, ExecutorRuntime{Mode: ExecutorRuntimePlugin})
|
||||
|
||||
validated, err := p.ParseAndValidateStatement(`select where id() = "TIKI-000001"`, ExecutorRuntimePlugin)
|
||||
if err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
_, err = e.Execute(validated, makeTasks())
|
||||
if err == nil {
|
||||
t.Fatal("expected missing selected task id error")
|
||||
}
|
||||
var missing *MissingSelectedTaskIDError
|
||||
if !errors.As(err, &missing) {
|
||||
t.Fatalf("expected MissingSelectedTaskIDError, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatedTriggerCloneIsolated(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
validated, err := p.ParseAndValidateTrigger(`before create deny "blocked"`, ExecutorRuntimeEventTrigger)
|
||||
if err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
clone := validated.TriggerClone()
|
||||
if clone == nil {
|
||||
t.Fatal("expected non-nil trigger clone")
|
||||
}
|
||||
|
||||
clone.Timing = "after"
|
||||
clone.Event = "delete"
|
||||
clone.Deny = nil
|
||||
|
||||
after := validated.TriggerClone()
|
||||
if after == nil {
|
||||
t.Fatal("expected non-nil trigger clone after mutation")
|
||||
}
|
||||
if after.Timing != "before" || after.Event != "create" {
|
||||
t.Fatalf("validated trigger was mutated: timing=%q event=%q", after.Timing, after.Event)
|
||||
}
|
||||
if after.Deny == nil || *after.Deny != "blocked" {
|
||||
t.Fatalf("expected deny message to remain unchanged, got %#v", after.Deny)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidatedTimeTriggerCloneIsolated(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
validated, err := p.ParseAndValidateTimeTrigger(`every 2day create title="x"`, ExecutorRuntimeTimeTrigger)
|
||||
if err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
clone := validated.TimeTriggerClone()
|
||||
if clone == nil {
|
||||
t.Fatal("expected non-nil time trigger clone")
|
||||
}
|
||||
|
||||
clone.Interval = DurationLiteral{Value: 9, Unit: "week"}
|
||||
clone.Action = nil
|
||||
|
||||
after := validated.TimeTriggerClone()
|
||||
if after == nil {
|
||||
t.Fatal("expected non-nil time trigger clone after mutation")
|
||||
}
|
||||
if after.Interval.Value != 2 || after.Interval.Unit != "day" {
|
||||
t.Fatalf("validated time trigger interval was mutated: %+v", after.Interval)
|
||||
}
|
||||
if after.Action == nil || after.Action.Create == nil {
|
||||
t.Fatal("expected action to remain unchanged")
|
||||
}
|
||||
}
|
||||
715
ruki/semantic_validate.go
Normal file
|
|
@ -0,0 +1,715 @@
|
|||
package ruki
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
type validationSeal struct{}
|
||||
|
||||
var validatedSeal = &validationSeal{}
|
||||
|
||||
// UnvalidatedWrapperError is returned when a validated wrapper was not created
|
||||
// by semantic validator constructors.
|
||||
type UnvalidatedWrapperError struct {
|
||||
Wrapper string
|
||||
}
|
||||
|
||||
func (e *UnvalidatedWrapperError) Error() string {
|
||||
return fmt.Sprintf("%s wrapper is not semantically validated", e.Wrapper)
|
||||
}
|
||||
|
||||
// ValidatedStatement is an immutable, semantically validated statement wrapper.
|
||||
type ValidatedStatement struct {
|
||||
seal *validationSeal
|
||||
runtime ExecutorRuntimeMode
|
||||
usesIDFunc bool
|
||||
statement *Statement
|
||||
}
|
||||
|
||||
func (v *ValidatedStatement) RuntimeMode() ExecutorRuntimeMode { return v.runtime }
|
||||
func (v *ValidatedStatement) UsesIDBuiltin() bool { return v.usesIDFunc }
|
||||
func (v *ValidatedStatement) RequiresCreateTemplate() bool {
|
||||
return v != nil && v.statement != nil && v.statement.Create != nil
|
||||
}
|
||||
|
||||
func (v *ValidatedStatement) mustBeSealed() error {
|
||||
if v == nil || v.seal != validatedSeal || v.statement == nil {
|
||||
return &UnvalidatedWrapperError{Wrapper: "statement"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidatedTrigger is an immutable, semantically validated event-trigger wrapper.
|
||||
type ValidatedTrigger struct {
|
||||
seal *validationSeal
|
||||
runtime ExecutorRuntimeMode
|
||||
usesIDFunc bool
|
||||
trigger *Trigger
|
||||
}
|
||||
|
||||
func (v *ValidatedTrigger) RuntimeMode() ExecutorRuntimeMode { return v.runtime }
|
||||
func (v *ValidatedTrigger) UsesIDBuiltin() bool { return v.usesIDFunc }
|
||||
func (v *ValidatedTrigger) Timing() string {
|
||||
if v == nil || v.trigger == nil {
|
||||
return ""
|
||||
}
|
||||
return v.trigger.Timing
|
||||
}
|
||||
func (v *ValidatedTrigger) Event() string {
|
||||
if v == nil || v.trigger == nil {
|
||||
return ""
|
||||
}
|
||||
return v.trigger.Event
|
||||
}
|
||||
func (v *ValidatedTrigger) HasRunAction() bool {
|
||||
return v != nil && v.trigger != nil && v.trigger.Run != nil
|
||||
}
|
||||
func (v *ValidatedTrigger) DenyMessage() (string, bool) {
|
||||
if v == nil || v.trigger == nil || v.trigger.Deny == nil {
|
||||
return "", false
|
||||
}
|
||||
return *v.trigger.Deny, true
|
||||
}
|
||||
func (v *ValidatedTrigger) RequiresCreateTemplate() bool {
|
||||
return v != nil && v.trigger != nil && v.trigger.Action != nil && v.trigger.Action.Create != nil
|
||||
}
|
||||
func (v *ValidatedTrigger) TriggerClone() *Trigger {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneTrigger(v.trigger)
|
||||
}
|
||||
|
||||
func (v *ValidatedTrigger) mustBeSealed() error {
|
||||
if v == nil || v.seal != validatedSeal || v.trigger == nil {
|
||||
return &UnvalidatedWrapperError{Wrapper: "trigger"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidatedTimeTrigger is an immutable, semantically validated time-trigger wrapper.
|
||||
type ValidatedTimeTrigger struct {
|
||||
seal *validationSeal
|
||||
runtime ExecutorRuntimeMode
|
||||
usesIDFunc bool
|
||||
timeTrigger *TimeTrigger
|
||||
}
|
||||
|
||||
func (v *ValidatedTimeTrigger) RuntimeMode() ExecutorRuntimeMode { return v.runtime }
|
||||
func (v *ValidatedTimeTrigger) UsesIDBuiltin() bool { return v.usesIDFunc }
|
||||
func (v *ValidatedTimeTrigger) IntervalLiteral() DurationLiteral {
|
||||
if v == nil || v.timeTrigger == nil {
|
||||
return DurationLiteral{}
|
||||
}
|
||||
return v.timeTrigger.Interval
|
||||
}
|
||||
func (v *ValidatedTimeTrigger) RequiresCreateTemplate() bool {
|
||||
return v != nil && v.timeTrigger != nil && v.timeTrigger.Action != nil && v.timeTrigger.Action.Create != nil
|
||||
}
|
||||
func (v *ValidatedTimeTrigger) TimeTriggerClone() *TimeTrigger {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
return cloneTimeTrigger(v.timeTrigger)
|
||||
}
|
||||
|
||||
func (v *ValidatedTimeTrigger) mustBeSealed() error {
|
||||
if v == nil || v.seal != validatedSeal || v.timeTrigger == nil {
|
||||
return &UnvalidatedWrapperError{Wrapper: "time trigger"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// ValidatedRule is a discriminated union for ParseAndValidateRule.
|
||||
type ValidatedRule interface {
|
||||
isValidatedRule()
|
||||
RuntimeMode() ExecutorRuntimeMode
|
||||
}
|
||||
|
||||
// ValidatedEventRule wraps a validated event trigger.
|
||||
type ValidatedEventRule struct {
|
||||
seal *validationSeal
|
||||
trigger *ValidatedTrigger
|
||||
}
|
||||
|
||||
func (ValidatedEventRule) isValidatedRule() {}
|
||||
func (r ValidatedEventRule) RuntimeMode() ExecutorRuntimeMode {
|
||||
if r.trigger == nil {
|
||||
return ""
|
||||
}
|
||||
return r.trigger.RuntimeMode()
|
||||
}
|
||||
func (r ValidatedEventRule) Trigger() *ValidatedTrigger { return r.trigger }
|
||||
|
||||
// ValidatedTimeRule wraps a validated time trigger.
|
||||
type ValidatedTimeRule struct {
|
||||
seal *validationSeal
|
||||
time *ValidatedTimeTrigger
|
||||
}
|
||||
|
||||
func (ValidatedTimeRule) isValidatedRule() {}
|
||||
func (r ValidatedTimeRule) RuntimeMode() ExecutorRuntimeMode {
|
||||
if r.time == nil {
|
||||
return ""
|
||||
}
|
||||
return r.time.RuntimeMode()
|
||||
}
|
||||
func (r ValidatedTimeRule) TimeTrigger() *ValidatedTimeTrigger { return r.time }
|
||||
|
||||
// SemanticValidator performs runtime-aware semantic validation after parse/type validation.
|
||||
type SemanticValidator struct {
|
||||
runtime ExecutorRuntimeMode
|
||||
}
|
||||
|
||||
// NewSemanticValidator creates a semantic validator for a specific runtime mode.
|
||||
func NewSemanticValidator(runtime ExecutorRuntimeMode) *SemanticValidator {
|
||||
if runtime == "" {
|
||||
runtime = ExecutorRuntimeCLI
|
||||
}
|
||||
return &SemanticValidator{runtime: runtime}
|
||||
}
|
||||
|
||||
// ParseAndValidateStatement parses a statement and applies runtime-aware semantic validation.
|
||||
func (p *Parser) ParseAndValidateStatement(input string, runtime ExecutorRuntimeMode) (*ValidatedStatement, error) {
|
||||
stmt, err := p.ParseStatement(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewSemanticValidator(runtime).ValidateStatement(stmt)
|
||||
}
|
||||
|
||||
// ParseAndValidateTrigger parses an event trigger and applies runtime-aware semantic validation.
|
||||
func (p *Parser) ParseAndValidateTrigger(input string, runtime ExecutorRuntimeMode) (*ValidatedTrigger, error) {
|
||||
trig, err := p.ParseTrigger(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewSemanticValidator(runtime).ValidateTrigger(trig)
|
||||
}
|
||||
|
||||
// ParseAndValidateTimeTrigger parses a time trigger and applies runtime-aware semantic validation.
|
||||
func (p *Parser) ParseAndValidateTimeTrigger(input string, runtime ExecutorRuntimeMode) (*ValidatedTimeTrigger, error) {
|
||||
tt, err := p.ParseTimeTrigger(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return NewSemanticValidator(runtime).ValidateTimeTrigger(tt)
|
||||
}
|
||||
|
||||
// ParseAndValidateRule parses a trigger rule union and applies the correct semantic runtime
|
||||
// validation branch (event trigger vs time trigger).
|
||||
func (p *Parser) ParseAndValidateRule(input string) (ValidatedRule, error) {
|
||||
rule, err := p.ParseRule(input)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch {
|
||||
case rule == nil:
|
||||
return nil, fmt.Errorf("empty rule")
|
||||
case rule.Trigger != nil:
|
||||
vt, err := NewSemanticValidator(ExecutorRuntimeEventTrigger).ValidateTrigger(rule.Trigger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ValidatedEventRule{seal: validatedSeal, trigger: vt}, nil
|
||||
case rule.TimeTrigger != nil:
|
||||
vt, err := NewSemanticValidator(ExecutorRuntimeTimeTrigger).ValidateTimeTrigger(rule.TimeTrigger)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return ValidatedTimeRule{seal: validatedSeal, time: vt}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("empty rule")
|
||||
}
|
||||
}
|
||||
|
||||
// ValidateStatement applies runtime-aware semantic checks to a parsed statement.
|
||||
func (v *SemanticValidator) ValidateStatement(stmt *Statement) (*ValidatedStatement, error) {
|
||||
if stmt == nil {
|
||||
return nil, fmt.Errorf("nil statement")
|
||||
}
|
||||
usesID, hasCall, err := scanStatementSemantics(stmt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hasCall {
|
||||
return nil, fmt.Errorf("call() is not supported yet")
|
||||
}
|
||||
if usesID && v.runtime != ExecutorRuntimePlugin {
|
||||
return nil, fmt.Errorf("id() is only available in plugin runtime")
|
||||
}
|
||||
if err := validateStatementAssignmentsSemantics(stmt); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &ValidatedStatement{
|
||||
seal: validatedSeal,
|
||||
runtime: v.runtime,
|
||||
usesIDFunc: usesID,
|
||||
statement: cloneStatement(stmt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateTrigger applies runtime-aware semantic checks to a parsed event trigger.
|
||||
func (v *SemanticValidator) ValidateTrigger(trig *Trigger) (*ValidatedTrigger, error) {
|
||||
if trig == nil {
|
||||
return nil, fmt.Errorf("nil trigger")
|
||||
}
|
||||
usesID, hasCall, err := scanTriggerSemantics(trig)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hasCall {
|
||||
return nil, fmt.Errorf("call() is not supported yet")
|
||||
}
|
||||
if usesID && v.runtime != ExecutorRuntimePlugin {
|
||||
return nil, fmt.Errorf("id() is only available in plugin runtime")
|
||||
}
|
||||
if trig.Action != nil {
|
||||
if err := validateStatementAssignmentsSemantics(trig.Action); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &ValidatedTrigger{
|
||||
seal: validatedSeal,
|
||||
runtime: v.runtime,
|
||||
usesIDFunc: usesID,
|
||||
trigger: cloneTrigger(trig),
|
||||
}, nil
|
||||
}
|
||||
|
||||
// ValidateTimeTrigger applies runtime-aware semantic checks to a parsed time trigger.
|
||||
func (v *SemanticValidator) ValidateTimeTrigger(tt *TimeTrigger) (*ValidatedTimeTrigger, error) {
|
||||
if tt == nil {
|
||||
return nil, fmt.Errorf("nil time trigger")
|
||||
}
|
||||
usesID, hasCall, err := scanTimeTriggerSemantics(tt)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if hasCall {
|
||||
return nil, fmt.Errorf("call() is not supported yet")
|
||||
}
|
||||
if usesID && v.runtime != ExecutorRuntimePlugin {
|
||||
return nil, fmt.Errorf("id() is only available in plugin runtime")
|
||||
}
|
||||
if tt.Action != nil {
|
||||
if err := validateStatementAssignmentsSemantics(tt.Action); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
return &ValidatedTimeTrigger{
|
||||
seal: validatedSeal,
|
||||
runtime: v.runtime,
|
||||
usesIDFunc: usesID,
|
||||
timeTrigger: cloneTimeTrigger(tt),
|
||||
}, nil
|
||||
}
|
||||
|
||||
func validateStatementAssignmentsSemantics(stmt *Statement) error {
|
||||
switch {
|
||||
case stmt.Create != nil:
|
||||
return validateAssignmentsSemantics(stmt.Create.Assignments)
|
||||
case stmt.Update != nil:
|
||||
return validateAssignmentsSemantics(stmt.Update.Set)
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func validateAssignmentsSemantics(assignments []Assignment) error {
|
||||
for _, a := range assignments {
|
||||
switch a.Field {
|
||||
case "id", "createdBy", "createdAt", "updatedAt":
|
||||
return fmt.Errorf("field %q is immutable", a.Field)
|
||||
}
|
||||
switch a.Field {
|
||||
case "title", "status", "type", "priority":
|
||||
if _, ok := a.Value.(*EmptyLiteral); ok {
|
||||
return fmt.Errorf("field %q cannot be empty", a.Field)
|
||||
}
|
||||
}
|
||||
switch a.Field {
|
||||
case "priority":
|
||||
if lit, ok := a.Value.(*IntLiteral); ok && !task.IsValidPriority(lit.Value) {
|
||||
return fmt.Errorf("priority value out of range: %d", lit.Value)
|
||||
}
|
||||
case "points":
|
||||
if lit, ok := a.Value.(*IntLiteral); ok && !task.IsValidPoints(lit.Value) {
|
||||
return fmt.Errorf("points value out of range: %d", lit.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func scanStatementSemantics(stmt *Statement) (usesID bool, hasCall bool, err error) {
|
||||
switch {
|
||||
case stmt.Select != nil:
|
||||
return scanSelectSemantics(stmt.Select)
|
||||
case stmt.Create != nil:
|
||||
return scanAssignmentsSemantics(stmt.Create.Assignments)
|
||||
case stmt.Update != nil:
|
||||
u1, c1, err := scanConditionSemantics(stmt.Update.Where)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
u2, c2, err := scanAssignmentsSemantics(stmt.Update.Set)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
return u1 || u2, c1 || c2, nil
|
||||
case stmt.Delete != nil:
|
||||
return scanConditionSemantics(stmt.Delete.Where)
|
||||
default:
|
||||
return false, false, fmt.Errorf("empty statement")
|
||||
}
|
||||
}
|
||||
|
||||
func scanSelectSemantics(sel *SelectStmt) (usesID bool, hasCall bool, err error) {
|
||||
if sel == nil {
|
||||
return false, false, nil
|
||||
}
|
||||
return scanConditionSemantics(sel.Where)
|
||||
}
|
||||
|
||||
func scanTriggerSemantics(trig *Trigger) (usesID bool, hasCall bool, err error) {
|
||||
var u, c bool
|
||||
if trig.Where != nil {
|
||||
uu, cc, err := scanConditionSemantics(trig.Where)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
u, c = u || uu, c || cc
|
||||
}
|
||||
if trig.Action != nil {
|
||||
uu, cc, err := scanStatementSemantics(trig.Action)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
u, c = u || uu, c || cc
|
||||
}
|
||||
if trig.Run != nil {
|
||||
uu, cc, err := scanExprSemantics(trig.Run.Command)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
u, c = u || uu, c || cc
|
||||
}
|
||||
return u, c, nil
|
||||
}
|
||||
|
||||
func scanTimeTriggerSemantics(tt *TimeTrigger) (usesID bool, hasCall bool, err error) {
|
||||
if tt == nil || tt.Action == nil {
|
||||
return false, false, nil
|
||||
}
|
||||
return scanStatementSemantics(tt.Action)
|
||||
}
|
||||
|
||||
func scanAssignmentsSemantics(assignments []Assignment) (usesID bool, hasCall bool, err error) {
|
||||
for _, a := range assignments {
|
||||
u, c, err := scanExprSemantics(a.Value)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
usesID = usesID || u
|
||||
hasCall = hasCall || c
|
||||
}
|
||||
return usesID, hasCall, nil
|
||||
}
|
||||
|
||||
func scanConditionSemantics(cond Condition) (usesID bool, hasCall bool, err error) {
|
||||
if cond == nil {
|
||||
return false, false, nil
|
||||
}
|
||||
switch c := cond.(type) {
|
||||
case *BinaryCondition:
|
||||
u1, c1, err := scanConditionSemantics(c.Left)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
u2, c2, err := scanConditionSemantics(c.Right)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
return u1 || u2, c1 || c2, nil
|
||||
case *NotCondition:
|
||||
return scanConditionSemantics(c.Inner)
|
||||
case *CompareExpr:
|
||||
u1, c1, err := scanExprSemantics(c.Left)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
u2, c2, err := scanExprSemantics(c.Right)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
return u1 || u2, c1 || c2, nil
|
||||
case *IsEmptyExpr:
|
||||
return scanExprSemantics(c.Expr)
|
||||
case *InExpr:
|
||||
u1, c1, err := scanExprSemantics(c.Value)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
u2, c2, err := scanExprSemantics(c.Collection)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
return u1 || u2, c1 || c2, nil
|
||||
case *QuantifierExpr:
|
||||
u1, c1, err := scanExprSemantics(c.Expr)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
u2, c2, err := scanConditionSemantics(c.Condition)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
return u1 || u2, c1 || c2, nil
|
||||
default:
|
||||
return false, false, fmt.Errorf("unknown condition type %T", c)
|
||||
}
|
||||
}
|
||||
|
||||
func scanExprSemantics(expr Expr) (usesID bool, hasCall bool, err error) {
|
||||
if expr == nil {
|
||||
return false, false, nil
|
||||
}
|
||||
switch e := expr.(type) {
|
||||
case *FunctionCall:
|
||||
if e.Name == "id" {
|
||||
usesID = true
|
||||
}
|
||||
if e.Name == "call" {
|
||||
hasCall = true
|
||||
}
|
||||
for _, arg := range e.Args {
|
||||
u, c, err := scanExprSemantics(arg)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
usesID = usesID || u
|
||||
hasCall = hasCall || c
|
||||
}
|
||||
return usesID, hasCall, nil
|
||||
case *BinaryExpr:
|
||||
u1, c1, err := scanExprSemantics(e.Left)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
u2, c2, err := scanExprSemantics(e.Right)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
return u1 || u2, c1 || c2, nil
|
||||
case *ListLiteral:
|
||||
for _, elem := range e.Elements {
|
||||
u, c, err := scanExprSemantics(elem)
|
||||
if err != nil {
|
||||
return false, false, err
|
||||
}
|
||||
usesID = usesID || u
|
||||
hasCall = hasCall || c
|
||||
}
|
||||
return usesID, hasCall, nil
|
||||
case *SubQuery:
|
||||
return scanConditionSemantics(e.Where)
|
||||
default:
|
||||
return false, false, nil
|
||||
}
|
||||
}
|
||||
|
||||
func cloneStatement(stmt *Statement) *Statement {
|
||||
if stmt == nil {
|
||||
return nil
|
||||
}
|
||||
out := &Statement{}
|
||||
if stmt.Select != nil {
|
||||
out.Select = cloneSelect(stmt.Select)
|
||||
}
|
||||
if stmt.Create != nil {
|
||||
out.Create = cloneCreate(stmt.Create)
|
||||
}
|
||||
if stmt.Update != nil {
|
||||
out.Update = cloneUpdate(stmt.Update)
|
||||
}
|
||||
if stmt.Delete != nil {
|
||||
out.Delete = cloneDelete(stmt.Delete)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneSelect(sel *SelectStmt) *SelectStmt {
|
||||
if sel == nil {
|
||||
return nil
|
||||
}
|
||||
var fields []string
|
||||
if sel.Fields != nil {
|
||||
fields = append([]string(nil), sel.Fields...)
|
||||
}
|
||||
var orderBy []OrderByClause
|
||||
if sel.OrderBy != nil {
|
||||
orderBy = append([]OrderByClause(nil), sel.OrderBy...)
|
||||
}
|
||||
return &SelectStmt{
|
||||
Fields: fields,
|
||||
Where: cloneCondition(sel.Where),
|
||||
OrderBy: orderBy,
|
||||
}
|
||||
}
|
||||
|
||||
func cloneCreate(cr *CreateStmt) *CreateStmt {
|
||||
if cr == nil {
|
||||
return nil
|
||||
}
|
||||
out := &CreateStmt{Assignments: make([]Assignment, len(cr.Assignments))}
|
||||
for i, a := range cr.Assignments {
|
||||
out.Assignments[i] = cloneAssignment(a)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneUpdate(up *UpdateStmt) *UpdateStmt {
|
||||
if up == nil {
|
||||
return nil
|
||||
}
|
||||
out := &UpdateStmt{
|
||||
Where: cloneCondition(up.Where),
|
||||
Set: make([]Assignment, len(up.Set)),
|
||||
}
|
||||
for i, a := range up.Set {
|
||||
out.Set[i] = cloneAssignment(a)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneDelete(del *DeleteStmt) *DeleteStmt {
|
||||
if del == nil {
|
||||
return nil
|
||||
}
|
||||
return &DeleteStmt{Where: cloneCondition(del.Where)}
|
||||
}
|
||||
|
||||
func cloneTrigger(trig *Trigger) *Trigger {
|
||||
if trig == nil {
|
||||
return nil
|
||||
}
|
||||
out := &Trigger{
|
||||
Timing: trig.Timing,
|
||||
Event: trig.Event,
|
||||
Where: cloneCondition(trig.Where),
|
||||
Action: cloneStatement(trig.Action),
|
||||
}
|
||||
if trig.Run != nil {
|
||||
out.Run = &RunAction{Command: cloneExpr(trig.Run.Command)}
|
||||
}
|
||||
if trig.Deny != nil {
|
||||
s := *trig.Deny
|
||||
out.Deny = &s
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneTimeTrigger(tt *TimeTrigger) *TimeTrigger {
|
||||
if tt == nil {
|
||||
return nil
|
||||
}
|
||||
return &TimeTrigger{
|
||||
Interval: tt.Interval,
|
||||
Action: cloneStatement(tt.Action),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneAssignment(a Assignment) Assignment {
|
||||
return Assignment{
|
||||
Field: a.Field,
|
||||
Value: cloneExpr(a.Value),
|
||||
}
|
||||
}
|
||||
|
||||
func cloneCondition(cond Condition) Condition {
|
||||
if cond == nil {
|
||||
return nil
|
||||
}
|
||||
switch c := cond.(type) {
|
||||
case *BinaryCondition:
|
||||
return &BinaryCondition{
|
||||
Op: c.Op,
|
||||
Left: cloneCondition(c.Left),
|
||||
Right: cloneCondition(c.Right),
|
||||
}
|
||||
case *NotCondition:
|
||||
return &NotCondition{Inner: cloneCondition(c.Inner)}
|
||||
case *CompareExpr:
|
||||
return &CompareExpr{
|
||||
Left: cloneExpr(c.Left),
|
||||
Op: c.Op,
|
||||
Right: cloneExpr(c.Right),
|
||||
}
|
||||
case *IsEmptyExpr:
|
||||
return &IsEmptyExpr{
|
||||
Expr: cloneExpr(c.Expr),
|
||||
Negated: c.Negated,
|
||||
}
|
||||
case *InExpr:
|
||||
return &InExpr{
|
||||
Value: cloneExpr(c.Value),
|
||||
Collection: cloneExpr(c.Collection),
|
||||
Negated: c.Negated,
|
||||
}
|
||||
case *QuantifierExpr:
|
||||
return &QuantifierExpr{
|
||||
Expr: cloneExpr(c.Expr),
|
||||
Kind: c.Kind,
|
||||
Condition: cloneCondition(c.Condition),
|
||||
}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func cloneExpr(expr Expr) Expr {
|
||||
if expr == nil {
|
||||
return nil
|
||||
}
|
||||
switch e := expr.(type) {
|
||||
case *FieldRef:
|
||||
return &FieldRef{Name: e.Name}
|
||||
case *QualifiedRef:
|
||||
return &QualifiedRef{Qualifier: e.Qualifier, Name: e.Name}
|
||||
case *StringLiteral:
|
||||
return &StringLiteral{Value: e.Value}
|
||||
case *IntLiteral:
|
||||
return &IntLiteral{Value: e.Value}
|
||||
case *DateLiteral:
|
||||
return &DateLiteral{Value: e.Value}
|
||||
case *DurationLiteral:
|
||||
return &DurationLiteral{Value: e.Value, Unit: e.Unit}
|
||||
case *ListLiteral:
|
||||
elems := make([]Expr, len(e.Elements))
|
||||
for i, elem := range e.Elements {
|
||||
elems[i] = cloneExpr(elem)
|
||||
}
|
||||
return &ListLiteral{Elements: elems}
|
||||
case *EmptyLiteral:
|
||||
return &EmptyLiteral{}
|
||||
case *FunctionCall:
|
||||
args := make([]Expr, len(e.Args))
|
||||
for i, arg := range e.Args {
|
||||
args[i] = cloneExpr(arg)
|
||||
}
|
||||
return &FunctionCall{Name: e.Name, Args: args}
|
||||
case *BinaryExpr:
|
||||
return &BinaryExpr{
|
||||
Op: e.Op,
|
||||
Left: cloneExpr(e.Left),
|
||||
Right: cloneExpr(e.Right),
|
||||
}
|
||||
case *SubQuery:
|
||||
return &SubQuery{Where: cloneCondition(e.Where)}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
2408
ruki/semantic_validate_test.go
Normal file
354
ruki/time_trigger_test.go
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
package ruki
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseTimeTrigger_HappyPath(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantValue int
|
||||
wantUnit string
|
||||
wantCreate bool
|
||||
wantUpdate bool
|
||||
wantDelete bool
|
||||
}{
|
||||
{
|
||||
"update stale tasks",
|
||||
`every 1hour update where status = "in_progress" and updatedAt < now() - 7day set status="backlog"`,
|
||||
1, "hour",
|
||||
false, true, false,
|
||||
},
|
||||
{
|
||||
"delete expired",
|
||||
`every 1day delete where status = "done" and updatedAt < now() - 30day`,
|
||||
1, "day",
|
||||
false, false, true,
|
||||
},
|
||||
{
|
||||
"create weekly review",
|
||||
`every 2week create title="weekly review" status="ready" priority=3`,
|
||||
2, "week",
|
||||
true, false, false,
|
||||
},
|
||||
{
|
||||
"plural duration",
|
||||
`every 3days delete where status = "cancelled"`,
|
||||
3, "day",
|
||||
false, false, true,
|
||||
},
|
||||
{
|
||||
"month interval",
|
||||
`every 1month delete where status = "done"`,
|
||||
1, "month",
|
||||
false, false, true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := p.ParseTimeTrigger(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
if result.Interval.Value != tt.wantValue {
|
||||
t.Fatalf("expected interval value %d, got %d", tt.wantValue, result.Interval.Value)
|
||||
}
|
||||
if result.Interval.Unit != tt.wantUnit {
|
||||
t.Fatalf("expected interval unit %q, got %q", tt.wantUnit, result.Interval.Unit)
|
||||
}
|
||||
if tt.wantCreate && result.Action.Create == nil {
|
||||
t.Fatal("expected Create action")
|
||||
}
|
||||
if tt.wantUpdate && result.Action.Update == nil {
|
||||
t.Fatal("expected Update action")
|
||||
}
|
||||
if tt.wantDelete && result.Action.Delete == nil {
|
||||
t.Fatal("expected Delete action")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTimeTrigger_ASTVerification(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
input := `every 1hour update where status = "in_progress" set status="backlog"`
|
||||
tt, err := p.ParseTimeTrigger(input)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
|
||||
// verify interval
|
||||
if tt.Interval.Value != 1 || tt.Interval.Unit != "hour" {
|
||||
t.Fatalf("expected 1hour, got %d%s", tt.Interval.Value, tt.Interval.Unit)
|
||||
}
|
||||
|
||||
// verify action is update with where and set
|
||||
if tt.Action.Update == nil {
|
||||
t.Fatal("expected Update action")
|
||||
}
|
||||
if tt.Action.Update.Where == nil {
|
||||
t.Fatal("expected Where condition")
|
||||
}
|
||||
if len(tt.Action.Update.Set) != 1 {
|
||||
t.Fatalf("expected 1 assignment, got %d", len(tt.Action.Update.Set))
|
||||
}
|
||||
if tt.Action.Update.Set[0].Field != "status" {
|
||||
t.Fatalf("expected assignment to status, got %s", tt.Action.Update.Set[0].Field)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTimeTrigger_Errors(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{
|
||||
"select not allowed",
|
||||
`every 1day select where status = "done"`,
|
||||
},
|
||||
{
|
||||
"run not allowed",
|
||||
`every 1hour run("echo hi")`,
|
||||
},
|
||||
{
|
||||
"qualifier old rejected",
|
||||
`every 1day update where old.status = "done" set status="backlog"`,
|
||||
},
|
||||
{
|
||||
"qualifier new rejected",
|
||||
`every 1day update where new.status = "done" set status="backlog"`,
|
||||
},
|
||||
{
|
||||
"unknown field",
|
||||
`every 1day update where foo = "bar" set status="done"`,
|
||||
},
|
||||
{
|
||||
"type mismatch",
|
||||
`every 1day create title="x" priority="high"`,
|
||||
},
|
||||
{
|
||||
"zero interval",
|
||||
`every 0day delete where status = "done"`,
|
||||
},
|
||||
{
|
||||
"missing statement",
|
||||
`every 1day`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := p.ParseTimeTrigger(tt.input)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- ParseRule tests ---
|
||||
|
||||
func TestParseRule_EventTrigger(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
rule, err := p.ParseRule(`before update where new.status = "done" deny "blocked"`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
if rule.Trigger == nil {
|
||||
t.Fatal("expected event Trigger, got nil")
|
||||
}
|
||||
if rule.TimeTrigger != nil {
|
||||
t.Fatal("expected TimeTrigger to be nil")
|
||||
}
|
||||
if rule.Trigger.Timing != "before" || rule.Trigger.Event != "update" {
|
||||
t.Fatalf("expected before update, got %s %s", rule.Trigger.Timing, rule.Trigger.Event)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRule_TimeTrigger(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
rule, err := p.ParseRule(`every 1day delete where status = "done"`)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
if rule.TimeTrigger == nil {
|
||||
t.Fatal("expected TimeTrigger, got nil")
|
||||
}
|
||||
if rule.Trigger != nil {
|
||||
t.Fatal("expected event Trigger to be nil")
|
||||
}
|
||||
if rule.TimeTrigger.Interval.Value != 1 || rule.TimeTrigger.Interval.Unit != "day" {
|
||||
t.Fatalf("expected 1day, got %d%s", rule.TimeTrigger.Interval.Value, rule.TimeTrigger.Interval.Unit)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRule_ParseError(t *testing.T) {
|
||||
p := newTestParser()
|
||||
_, err := p.ParseRule(`not a valid rule at all`)
|
||||
if err == nil {
|
||||
t.Fatal("expected parse error, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseRule_ValidationError(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{
|
||||
"event trigger validation: unknown field",
|
||||
`before update where new.foo = "bar" deny "no"`,
|
||||
},
|
||||
{
|
||||
"time trigger validation: zero interval",
|
||||
`every 0day delete where status = "done"`,
|
||||
},
|
||||
{
|
||||
"time trigger validation: qualifier rejected",
|
||||
`every 1day update where old.status = "done" set status="backlog"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := p.ParseRule(tt.input)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateTimeTrigger_RejectsSelect(t *testing.T) {
|
||||
p := newTestParser()
|
||||
// construct a TimeTrigger with a Select action directly — the grammar prevents this,
|
||||
// but the validator should catch it as defense-in-depth
|
||||
tt := &TimeTrigger{
|
||||
Interval: DurationLiteral{Value: 1, Unit: "day"},
|
||||
Action: &Statement{Select: &SelectStmt{}},
|
||||
}
|
||||
err := p.validateTimeTrigger(tt)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for select in time trigger")
|
||||
}
|
||||
if err.Error() != "time trigger action must not be select" {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// --- defense-in-depth: test internal lowering/validation with hand-crafted structs ---
|
||||
|
||||
func TestLowerRule_EmptyRule(t *testing.T) {
|
||||
_, err := lowerRule(&ruleGrammar{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty rule")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLowerRule_TimeTriggerLoweringError(t *testing.T) {
|
||||
// invalid duration triggers a lowering error in lowerTimeTrigger
|
||||
_, err := lowerRule(&ruleGrammar{
|
||||
TimeTrigger: &timeTriggerGrammar{Interval: "bad"},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bad time trigger interval")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLowerRule_EventTriggerLoweringError(t *testing.T) {
|
||||
badDate := "bad-date"
|
||||
_, err := lowerRule(&ruleGrammar{
|
||||
Trigger: &triggerGrammar{
|
||||
Timing: "before",
|
||||
Event: "update",
|
||||
Where: &orCond{Left: andCond{Left: notCond{
|
||||
Primary: &primaryCond{Expr: &exprCond{
|
||||
Left: exprGrammar{Left: unaryExpr{DateLit: &badDate}},
|
||||
}},
|
||||
}}},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid event trigger")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLowerTimeTrigger_InvalidDuration(t *testing.T) {
|
||||
_, err := lowerTimeTrigger(&timeTriggerGrammar{Interval: "xyz"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid duration")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLowerTimeTrigger_EmptyAction(t *testing.T) {
|
||||
_, err := lowerTimeTrigger(&timeTriggerGrammar{Interval: "1day"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty action")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLowerTimeTrigger_CreateLoweringError(t *testing.T) {
|
||||
badDate := "bad-date"
|
||||
_, err := lowerTimeTrigger(&timeTriggerGrammar{
|
||||
Interval: "1day",
|
||||
Create: &createGrammar{Assignments: []assignmentGrammar{
|
||||
{Field: "title", Value: exprGrammar{Left: unaryExpr{DateLit: &badDate}}},
|
||||
}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for create lowering failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLowerTimeTrigger_UpdateLoweringError(t *testing.T) {
|
||||
badDate := "bad-date"
|
||||
okStr := `"ok"`
|
||||
_, err := lowerTimeTrigger(&timeTriggerGrammar{
|
||||
Interval: "1day",
|
||||
Update: &updateGrammar{
|
||||
Where: orCond{Left: andCond{Left: notCond{
|
||||
Primary: &primaryCond{Expr: &exprCond{
|
||||
Left: exprGrammar{Left: unaryExpr{DateLit: &badDate}},
|
||||
}},
|
||||
}}},
|
||||
Set: []assignmentGrammar{
|
||||
{Field: "title", Value: exprGrammar{Left: unaryExpr{StrLit: &okStr}}},
|
||||
},
|
||||
},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for update lowering failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLowerTimeTrigger_DeleteLoweringError(t *testing.T) {
|
||||
badDate := "bad-date"
|
||||
_, err := lowerTimeTrigger(&timeTriggerGrammar{
|
||||
Interval: "1day",
|
||||
Delete: &deleteGrammar{Where: orCond{Left: andCond{Left: notCond{
|
||||
Primary: &primaryCond{Expr: &exprCond{
|
||||
Left: exprGrammar{Left: unaryExpr{DateLit: &badDate}},
|
||||
}},
|
||||
}}}},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for delete lowering failure")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateRule_EmptyRule(t *testing.T) {
|
||||
p := newTestParser()
|
||||
err := p.validateRule(&Rule{})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for empty rule")
|
||||
}
|
||||
}
|
||||
659
ruki/trigger_executor.go
Normal file
|
|
@ -0,0 +1,659 @@
|
|||
package ruki
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
// TriggerExecutor evaluates trigger guards and actions in a trigger context.
|
||||
// It wraps Executor with old/new mutation context for QualifiedRef resolution.
|
||||
// A fresh Executor is created per call — no shared mutable state.
|
||||
type TriggerExecutor struct {
|
||||
schema Schema
|
||||
userFunc func() string
|
||||
}
|
||||
|
||||
// NewTriggerExecutor creates a TriggerExecutor.
|
||||
func NewTriggerExecutor(schema Schema, userFunc func() string) *TriggerExecutor {
|
||||
if userFunc == nil {
|
||||
userFunc = func() string { return "" }
|
||||
}
|
||||
return &TriggerExecutor{schema: schema, userFunc: userFunc}
|
||||
}
|
||||
|
||||
// TriggerContext holds the old/new task snapshots and allTasks for trigger evaluation.
|
||||
type TriggerContext struct {
|
||||
Old *task.Task // nil for create
|
||||
New *task.Task // nil for delete
|
||||
AllTasks []*task.Task
|
||||
}
|
||||
|
||||
// EvalGuard evaluates a trigger's where condition against the triggering event.
|
||||
// Returns true if the trigger should fire (guard passes or no guard).
|
||||
func (te *TriggerExecutor) EvalGuard(trig any, tc *TriggerContext) (bool, error) {
|
||||
validated, err := validateEventTriggerInput(trig)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
where := validated.trigger.Where
|
||||
if where == nil {
|
||||
return true, nil
|
||||
}
|
||||
// the guard evaluates qualified refs against old/new directly;
|
||||
// there is no "current task" — we use a sentinel that QualifiedRef overrides
|
||||
sentinel := te.guardSentinel(tc)
|
||||
exec := te.newExecWithOverrides(tc)
|
||||
return exec.evalCondition(where, sentinel, tc.AllTasks)
|
||||
}
|
||||
|
||||
// ExecTimeTriggerAction executes a time trigger's action against all tasks.
|
||||
// Uses a plain Executor (no old/new overrides) since time triggers have no
|
||||
// mutation context — the parser forbids qualified refs in them.
|
||||
func (te *TriggerExecutor) ExecTimeTriggerAction(tt any, allTasks []*task.Task, inputs ...ExecutionInput) (*Result, error) {
|
||||
var input ExecutionInput
|
||||
if len(inputs) > 0 {
|
||||
input = inputs[0]
|
||||
}
|
||||
|
||||
exec := NewExecutor(te.schema, te.userFunc, ExecutorRuntime{Mode: ExecutorRuntimeTimeTrigger})
|
||||
switch t := tt.(type) {
|
||||
case *ValidatedTimeTrigger:
|
||||
if err := t.mustBeSealed(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t.runtime != ExecutorRuntimeTimeTrigger {
|
||||
return nil, &RuntimeMismatchError{
|
||||
ValidatedFor: t.runtime,
|
||||
Runtime: ExecutorRuntimeTimeTrigger,
|
||||
}
|
||||
}
|
||||
if t.timeTrigger.Action == nil {
|
||||
return nil, fmt.Errorf("time trigger has no action")
|
||||
}
|
||||
action := &ValidatedStatement{
|
||||
seal: validatedSeal,
|
||||
runtime: ExecutorRuntimeTimeTrigger,
|
||||
usesIDFunc: t.usesIDFunc,
|
||||
statement: cloneStatement(t.timeTrigger.Action),
|
||||
}
|
||||
return exec.Execute(action, allTasks, input)
|
||||
case *TimeTrigger:
|
||||
if t.Action == nil {
|
||||
return nil, fmt.Errorf("time trigger has no action")
|
||||
}
|
||||
return exec.Execute(t.Action, allTasks, input)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported time trigger type %T", tt)
|
||||
}
|
||||
}
|
||||
|
||||
// ExecAction executes a trigger's CRUD action statement and returns the result.
|
||||
// QualifiedRefs resolve against tc.Old/tc.New. Bare fields resolve against target tasks.
|
||||
// Returns *Result for persistence by service/.
|
||||
func (te *TriggerExecutor) ExecAction(trig any, tc *TriggerContext, inputs ...ExecutionInput) (*Result, error) {
|
||||
var input ExecutionInput
|
||||
if len(inputs) > 0 {
|
||||
input = inputs[0]
|
||||
}
|
||||
|
||||
exec := te.newExecWithOverrides(tc)
|
||||
switch t := trig.(type) {
|
||||
case *ValidatedTrigger:
|
||||
if err := t.mustBeSealed(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t.runtime != ExecutorRuntimeEventTrigger {
|
||||
return nil, &RuntimeMismatchError{
|
||||
ValidatedFor: t.runtime,
|
||||
Runtime: ExecutorRuntimeEventTrigger,
|
||||
}
|
||||
}
|
||||
if t.trigger.Action == nil {
|
||||
return nil, fmt.Errorf("trigger has no action")
|
||||
}
|
||||
action := &ValidatedStatement{
|
||||
seal: validatedSeal,
|
||||
runtime: ExecutorRuntimeEventTrigger,
|
||||
usesIDFunc: t.usesIDFunc,
|
||||
statement: cloneStatement(t.trigger.Action),
|
||||
}
|
||||
return exec.Execute(action, tc.AllTasks, input)
|
||||
case *Trigger:
|
||||
if t.Action == nil {
|
||||
return nil, fmt.Errorf("trigger has no action")
|
||||
}
|
||||
return exec.Execute(t.Action, tc.AllTasks, input)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported trigger type %T", trig)
|
||||
}
|
||||
}
|
||||
|
||||
// ExecRun evaluates the run() command expression to a string against the trigger context.
|
||||
// Returns the command string for execution by service/.
|
||||
func (te *TriggerExecutor) ExecRun(trig any, tc *TriggerContext) (string, error) {
|
||||
validated, err := validateEventTriggerInput(trig)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
if validated.trigger.Run == nil {
|
||||
return "", fmt.Errorf("trigger has no run action")
|
||||
}
|
||||
command := validated.trigger.Run.Command
|
||||
if command == nil {
|
||||
return "", fmt.Errorf("trigger has no run action")
|
||||
}
|
||||
sentinel := te.guardSentinel(tc)
|
||||
exec := te.newExecWithOverrides(tc)
|
||||
val, err := exec.evalExpr(command, sentinel, tc.AllTasks)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("evaluating run command: %w", err)
|
||||
}
|
||||
s, ok := val.(string)
|
||||
if !ok {
|
||||
return "", fmt.Errorf("run command did not evaluate to string, got %T", val)
|
||||
}
|
||||
return s, nil
|
||||
}
|
||||
|
||||
// guardSentinel returns the best "current task" for guard evaluation.
|
||||
// In guards, all references should be qualified (old./new.), but the executor
|
||||
// still needs a task to evaluate against. We prefer new (proposed) over old.
|
||||
func (te *TriggerExecutor) guardSentinel(tc *TriggerContext) *task.Task {
|
||||
if tc.New != nil {
|
||||
return tc.New
|
||||
}
|
||||
if tc.Old != nil {
|
||||
return tc.Old
|
||||
}
|
||||
return &task.Task{}
|
||||
}
|
||||
|
||||
func validateEventTriggerInput(trig any) (*ValidatedTrigger, error) {
|
||||
switch t := trig.(type) {
|
||||
case *ValidatedTrigger:
|
||||
if err := t.mustBeSealed(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if t.runtime != ExecutorRuntimeEventTrigger {
|
||||
return nil, &RuntimeMismatchError{
|
||||
ValidatedFor: t.runtime,
|
||||
Runtime: ExecutorRuntimeEventTrigger,
|
||||
}
|
||||
}
|
||||
return t, nil
|
||||
case *Trigger:
|
||||
return NewSemanticValidator(ExecutorRuntimeEventTrigger).ValidateTrigger(t)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported trigger type %T", trig)
|
||||
}
|
||||
}
|
||||
|
||||
// triggerExecOverride wraps Executor and intercepts QualifiedRef evaluation.
|
||||
type triggerExecOverride struct {
|
||||
*Executor
|
||||
tc *TriggerContext
|
||||
}
|
||||
|
||||
// newExecWithOverrides creates a fresh Executor with QualifiedRef interception.
|
||||
func (te *TriggerExecutor) newExecWithOverrides(tc *TriggerContext) *triggerExecOverride {
|
||||
return &triggerExecOverride{
|
||||
Executor: NewExecutor(te.schema, te.userFunc, ExecutorRuntime{Mode: ExecutorRuntimeEventTrigger}),
|
||||
tc: tc,
|
||||
}
|
||||
}
|
||||
|
||||
// evalExpr overrides the base Executor to handle QualifiedRef.
|
||||
func (e *triggerExecOverride) evalExpr(expr Expr, t *task.Task, allTasks []*task.Task) (interface{}, error) {
|
||||
if qr, ok := expr.(*QualifiedRef); ok {
|
||||
return e.resolveQualifiedRef(qr)
|
||||
}
|
||||
// for non-QualifiedRef expressions, delegate to base but with our overridden evalExpr
|
||||
// for nested expressions
|
||||
return e.evalExprRecursive(expr, t, allTasks)
|
||||
}
|
||||
|
||||
func (e *triggerExecOverride) resolveQualifiedRef(qr *QualifiedRef) (interface{}, error) {
|
||||
switch qr.Qualifier {
|
||||
case "old":
|
||||
if e.tc.Old == nil {
|
||||
return nil, nil // old is nil for create events
|
||||
}
|
||||
return extractField(e.tc.Old, qr.Name), nil
|
||||
case "new":
|
||||
if e.tc.New == nil {
|
||||
return nil, nil // new is nil for delete events
|
||||
}
|
||||
return extractField(e.tc.New, qr.Name), nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown qualifier %q", qr.Qualifier)
|
||||
}
|
||||
}
|
||||
|
||||
// evalExprRecursive handles all expression types, dispatching QualifiedRef
|
||||
// to resolveQualifiedRef and delegating everything else to the base Executor.
|
||||
func (e *triggerExecOverride) evalExprRecursive(expr Expr, t *task.Task, allTasks []*task.Task) (interface{}, error) {
|
||||
switch expr := expr.(type) {
|
||||
case *QualifiedRef:
|
||||
return e.resolveQualifiedRef(expr)
|
||||
case *FieldRef:
|
||||
return extractField(t, expr.Name), nil
|
||||
case *BinaryExpr:
|
||||
leftVal, err := e.evalExpr(expr.Left, t, allTasks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rightVal, err := e.evalExpr(expr.Right, t, allTasks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
switch expr.Op {
|
||||
case "+":
|
||||
return addValues(leftVal, rightVal)
|
||||
case "-":
|
||||
return subtractValues(leftVal, rightVal)
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown binary operator %q", expr.Op)
|
||||
}
|
||||
case *ListLiteral:
|
||||
result := make([]interface{}, len(expr.Elements))
|
||||
for i, elem := range expr.Elements {
|
||||
val, err := e.evalExpr(elem, t, allTasks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
result[i] = val
|
||||
}
|
||||
return result, nil
|
||||
case *FunctionCall:
|
||||
return e.evalFunctionCallOverride(expr, t, allTasks)
|
||||
default:
|
||||
// StringLiteral, IntLiteral, DateLiteral, DurationLiteral, EmptyLiteral, SubQuery
|
||||
return e.Executor.evalExpr(expr, t, allTasks)
|
||||
}
|
||||
}
|
||||
|
||||
// evalCondition overrides the base to use our evalExpr for expression evaluation.
|
||||
func (e *triggerExecOverride) evalCondition(c Condition, t *task.Task, allTasks []*task.Task) (bool, error) {
|
||||
switch c := c.(type) {
|
||||
case *BinaryCondition:
|
||||
left, err := e.evalCondition(c.Left, t, allTasks)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
switch c.Op {
|
||||
case "and":
|
||||
if !left {
|
||||
return false, nil
|
||||
}
|
||||
return e.evalCondition(c.Right, t, allTasks)
|
||||
case "or":
|
||||
if left {
|
||||
return true, nil
|
||||
}
|
||||
return e.evalCondition(c.Right, t, allTasks)
|
||||
default:
|
||||
return false, fmt.Errorf("unknown binary operator %q", c.Op)
|
||||
}
|
||||
case *NotCondition:
|
||||
val, err := e.evalCondition(c.Inner, t, allTasks)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return !val, nil
|
||||
case *CompareExpr:
|
||||
leftVal, err := e.evalExpr(c.Left, t, allTasks)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
rightVal, err := e.evalExpr(c.Right, t, allTasks)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
return e.compareValues(leftVal, rightVal, c.Op, c.Left, c.Right)
|
||||
case *IsEmptyExpr:
|
||||
val, err := e.evalExpr(c.Expr, t, allTasks)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
empty := isZeroValue(val)
|
||||
if c.Negated {
|
||||
return !empty, nil
|
||||
}
|
||||
return empty, nil
|
||||
case *InExpr:
|
||||
return e.evalInOverride(c, t, allTasks)
|
||||
case *QuantifierExpr:
|
||||
return e.evalQuantifierOverride(c, t, allTasks)
|
||||
default:
|
||||
return false, fmt.Errorf("unknown condition type %T", c)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *triggerExecOverride) evalInOverride(c *InExpr, t *task.Task, allTasks []*task.Task) (bool, error) {
|
||||
val, err := e.evalExpr(c.Value, t, allTasks)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
collVal, err := e.evalExpr(c.Collection, t, allTasks)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
|
||||
if list, ok := collVal.([]interface{}); ok {
|
||||
valStr := normalizeToString(val)
|
||||
found := false
|
||||
for _, elem := range list {
|
||||
if normalizeToString(elem) == valStr {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if c.Negated {
|
||||
return !found, nil
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
if haystack, ok := collVal.(string); ok {
|
||||
needle, ok := val.(string)
|
||||
if !ok {
|
||||
return false, fmt.Errorf("in: substring check requires string value")
|
||||
}
|
||||
found := strings.Contains(haystack, needle)
|
||||
if c.Negated {
|
||||
return !found, nil
|
||||
}
|
||||
return found, nil
|
||||
}
|
||||
|
||||
return false, fmt.Errorf("in: collection is not a list or string")
|
||||
}
|
||||
|
||||
func (e *triggerExecOverride) evalQuantifierOverride(q *QuantifierExpr, t *task.Task, allTasks []*task.Task) (bool, error) {
|
||||
listVal, err := e.evalExpr(q.Expr, t, allTasks)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
refs, ok := listVal.([]interface{})
|
||||
if !ok {
|
||||
return false, fmt.Errorf("quantifier: expression is not a list")
|
||||
}
|
||||
|
||||
refTasks := resolveRefTasks(refs, allTasks)
|
||||
|
||||
switch q.Kind {
|
||||
case "any":
|
||||
for _, rt := range refTasks {
|
||||
match, err := e.evalCondition(q.Condition, rt, allTasks)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if match {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
return false, nil
|
||||
case "all":
|
||||
if len(refTasks) == 0 {
|
||||
return true, nil
|
||||
}
|
||||
for _, rt := range refTasks {
|
||||
match, err := e.evalCondition(q.Condition, rt, allTasks)
|
||||
if err != nil {
|
||||
return false, err
|
||||
}
|
||||
if !match {
|
||||
return false, nil
|
||||
}
|
||||
}
|
||||
return true, nil
|
||||
default:
|
||||
return false, fmt.Errorf("unknown quantifier %q", q.Kind)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *triggerExecOverride) evalFunctionCallOverride(fc *FunctionCall, t *task.Task, allTasks []*task.Task) (interface{}, error) {
|
||||
switch fc.Name {
|
||||
case "count":
|
||||
return e.evalCountOverride(fc, allTasks)
|
||||
case "blocks":
|
||||
return e.evalBlocksOverride(fc, t, allTasks)
|
||||
case "next_date":
|
||||
return e.evalNextDateOverride(fc, t, allTasks)
|
||||
default:
|
||||
// now, user, call — delegate to base (no expression args needing QualifiedRef resolution)
|
||||
return e.evalFunctionCall(fc, t, allTasks)
|
||||
}
|
||||
}
|
||||
|
||||
func (e *triggerExecOverride) evalCountOverride(fc *FunctionCall, allTasks []*task.Task) (interface{}, error) {
|
||||
sq, ok := fc.Args[0].(*SubQuery)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("count() argument must be a select subquery")
|
||||
}
|
||||
if sq.Where == nil {
|
||||
return len(allTasks), nil
|
||||
}
|
||||
count := 0
|
||||
for _, t := range allTasks {
|
||||
match, err := e.evalCondition(sq.Where, t, allTasks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if match {
|
||||
count++
|
||||
}
|
||||
}
|
||||
return count, nil
|
||||
}
|
||||
|
||||
func (e *triggerExecOverride) evalBlocksOverride(fc *FunctionCall, t *task.Task, allTasks []*task.Task) (interface{}, error) {
|
||||
val, err := e.evalExpr(fc.Args[0], t, allTasks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return blocksLookup(val, allTasks), nil
|
||||
}
|
||||
|
||||
func (e *triggerExecOverride) evalNextDateOverride(fc *FunctionCall, t *task.Task, allTasks []*task.Task) (interface{}, error) {
|
||||
val, err := e.evalExpr(fc.Args[0], t, allTasks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
rec, ok := val.(task.Recurrence)
|
||||
if !ok {
|
||||
return nil, fmt.Errorf("next_date() argument must be a recurrence value")
|
||||
}
|
||||
return task.NextOccurrence(rec), nil
|
||||
}
|
||||
|
||||
// Execute overrides the base Executor to use our evalExpr/evalCondition.
|
||||
func (e *triggerExecOverride) Execute(stmt any, tasks []*task.Task, inputs ...ExecutionInput) (*Result, error) {
|
||||
var input ExecutionInput
|
||||
if len(inputs) > 0 {
|
||||
input = inputs[0]
|
||||
}
|
||||
if stmt == nil {
|
||||
return nil, fmt.Errorf("nil statement")
|
||||
}
|
||||
var validated *ValidatedStatement
|
||||
var rawStmt *Statement
|
||||
rawInput := false
|
||||
requiresCreateTemplate := false
|
||||
switch s := stmt.(type) {
|
||||
case *ValidatedStatement:
|
||||
validated = s
|
||||
requiresCreateTemplate = true
|
||||
case *Statement:
|
||||
rawInput = true
|
||||
var err error
|
||||
validated, err = NewSemanticValidator(e.runtime.Mode).ValidateStatement(s)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported statement type %T", stmt)
|
||||
}
|
||||
|
||||
if validated != nil {
|
||||
if err := validated.mustBeSealed(); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if validated.runtime != e.runtime.Mode {
|
||||
return nil, &RuntimeMismatchError{
|
||||
ValidatedFor: validated.runtime,
|
||||
Runtime: e.runtime.Mode,
|
||||
}
|
||||
}
|
||||
if validated.usesIDFunc && e.runtime.Mode == ExecutorRuntimePlugin && strings.TrimSpace(input.SelectedTaskID) == "" {
|
||||
return nil, &MissingSelectedTaskIDError{}
|
||||
}
|
||||
rawStmt = validated.statement
|
||||
if rawInput {
|
||||
requiresCreateTemplate = false
|
||||
}
|
||||
}
|
||||
e.currentInput = input
|
||||
defer func() { e.currentInput = ExecutionInput{} }()
|
||||
|
||||
switch {
|
||||
case rawStmt.Create != nil:
|
||||
return e.executeCreate(rawStmt.Create, tasks, requiresCreateTemplate)
|
||||
case rawStmt.Update != nil:
|
||||
return e.executeUpdate(rawStmt.Update, tasks)
|
||||
case rawStmt.Delete != nil:
|
||||
return e.executeDelete(rawStmt.Delete, tasks)
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported trigger action type")
|
||||
}
|
||||
}
|
||||
|
||||
func (e *triggerExecOverride) executeUpdate(upd *UpdateStmt, tasks []*task.Task) (*Result, error) {
|
||||
matched, err := e.filterTasks(upd.Where, tasks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
clones := make([]*task.Task, len(matched))
|
||||
for i, t := range matched {
|
||||
clones[i] = t.Clone()
|
||||
}
|
||||
|
||||
for _, clone := range clones {
|
||||
for _, a := range upd.Set {
|
||||
val, err := e.evalExpr(a.Value, clone, tasks)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("field %q: %w", a.Field, err)
|
||||
}
|
||||
if err := e.setField(clone, a.Field, val); err != nil {
|
||||
return nil, fmt.Errorf("field %q: %w", a.Field, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return &Result{Update: &UpdateResult{Updated: clones}}, nil
|
||||
}
|
||||
|
||||
func (e *triggerExecOverride) executeCreate(cr *CreateStmt, tasks []*task.Task, requireTemplate bool) (*Result, error) {
|
||||
if requireTemplate && e.currentInput.CreateTemplate == nil {
|
||||
return nil, &MissingCreateTemplateError{}
|
||||
}
|
||||
var t *task.Task
|
||||
if e.currentInput.CreateTemplate != nil {
|
||||
t = e.currentInput.CreateTemplate.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 *triggerExecOverride) 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 *triggerExecOverride) filterTasks(where Condition, tasks []*task.Task) ([]*task.Task, error) {
|
||||
if where == nil {
|
||||
result := make([]*task.Task, len(tasks))
|
||||
copy(result, tasks)
|
||||
return result, nil
|
||||
}
|
||||
var result []*task.Task
|
||||
for _, t := range tasks {
|
||||
match, err := e.evalCondition(where, t, tasks)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if match {
|
||||
result = append(result, t)
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// resolveRefTasks finds tasks by ID from a list of ref values.
|
||||
func resolveRefTasks(refs []interface{}, allTasks []*task.Task) []*task.Task {
|
||||
result := make([]*task.Task, 0, len(refs))
|
||||
for _, ref := range refs {
|
||||
refID := normalizeToString(ref)
|
||||
for _, at := range allTasks {
|
||||
if equalFoldID(at.ID, refID) {
|
||||
result = append(result, at)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// equalFoldID compares two task IDs case-insensitively.
|
||||
func equalFoldID(a, b string) bool {
|
||||
if len(a) != len(b) {
|
||||
return false
|
||||
}
|
||||
for i := range a {
|
||||
ca, cb := a[i], b[i]
|
||||
if ca >= 'a' && ca <= 'z' {
|
||||
ca -= 32
|
||||
}
|
||||
if cb >= 'a' && cb <= 'z' {
|
||||
cb -= 32
|
||||
}
|
||||
if ca != cb {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// blocksLookup finds all task IDs that have the given ID in their dependsOn.
|
||||
func blocksLookup(val interface{}, allTasks []*task.Task) []interface{} {
|
||||
targetID := normalizeToString(val)
|
||||
var blockers []interface{}
|
||||
for _, at := range allTasks {
|
||||
for _, dep := range at.DependsOn {
|
||||
if equalFoldID(dep, targetID) {
|
||||
blockers = append(blockers, at.ID)
|
||||
break
|
||||
}
|
||||
}
|
||||
}
|
||||
if blockers == nil {
|
||||
blockers = []interface{}{}
|
||||
}
|
||||
return blockers
|
||||
}
|
||||
3818
ruki/trigger_executor_test.go
Normal file
354
ruki/trigger_test.go
Normal file
|
|
@ -0,0 +1,354 @@
|
|||
package ruki
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestParseTrigger_BeforeDeny(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
event string
|
||||
}{
|
||||
{
|
||||
"block completion with open deps",
|
||||
`before update where new.status = "done" and new.dependsOn any status != "done" deny "cannot complete task with open dependencies"`,
|
||||
"update",
|
||||
},
|
||||
{
|
||||
"deny delete high priority",
|
||||
`before delete where old.priority <= 2 deny "cannot delete high priority tasks"`,
|
||||
"delete",
|
||||
},
|
||||
{
|
||||
"require description for high priority",
|
||||
`before update where new.priority <= 2 and new.description is empty deny "high priority tasks need a description"`,
|
||||
"update",
|
||||
},
|
||||
{
|
||||
"require description for stories",
|
||||
`before create where new.type = "story" and new.description is empty deny "stories must have a description"`,
|
||||
"create",
|
||||
},
|
||||
{
|
||||
"prevent skipping review",
|
||||
`before update where old.status = "in progress" and new.status = "done" deny "tasks must go through review before completion"`,
|
||||
"update",
|
||||
},
|
||||
{
|
||||
"protect high priority from demotion",
|
||||
`before update where old.priority = 1 and old.status = "in progress" and new.priority > 1 deny "cannot demote priority of active critical tasks"`,
|
||||
"update",
|
||||
},
|
||||
{
|
||||
"no empty epics",
|
||||
`before update where new.status = "done" and new.type = "epic" and blocks(new.id) is empty deny "epic has no dependencies"`,
|
||||
"update",
|
||||
},
|
||||
{
|
||||
"WIP limit",
|
||||
`before update where new.status = "in progress" and count(select where assignee = new.assignee and status = "in progress") >= 3 deny "WIP limit reached for this assignee"`,
|
||||
"update",
|
||||
},
|
||||
{
|
||||
"points required before start",
|
||||
`before update where new.status = "in progress" and new.points = 0 deny "tasks must be estimated before starting work"`,
|
||||
"update",
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
trig, err := p.ParseTrigger(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
if trig.Timing != "before" {
|
||||
t.Fatalf("expected before, got %s", trig.Timing)
|
||||
}
|
||||
if trig.Event != tt.event {
|
||||
t.Fatalf("expected %s, got %s", tt.event, trig.Event)
|
||||
}
|
||||
if trig.Deny == nil {
|
||||
t.Fatal("expected Deny, got nil")
|
||||
}
|
||||
if trig.Action != nil {
|
||||
t.Fatal("expected nil Action in before-trigger")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrigger_AfterAction(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
event string
|
||||
wantCreate bool
|
||||
wantUpdate bool
|
||||
wantDelete bool
|
||||
wantRun bool
|
||||
}{
|
||||
{
|
||||
"recurring task create next",
|
||||
`after update where new.status = "done" and old.recurrence is not empty create title=old.title priority=old.priority tags=old.tags recurrence=old.recurrence due=next_date(old.recurrence) status="ready"`,
|
||||
"update",
|
||||
true, false, false, false,
|
||||
},
|
||||
{
|
||||
"recurring task clear recurrence",
|
||||
`after update where new.status = "done" and old.recurrence is not empty update where id = old.id set recurrence=empty`,
|
||||
"update",
|
||||
false, true, false, false,
|
||||
},
|
||||
{
|
||||
"auto assign urgent",
|
||||
`after create where new.priority <= 2 and new.assignee is empty update where id = new.id set assignee="booleanmaybe"`,
|
||||
"create",
|
||||
false, true, false, false,
|
||||
},
|
||||
{
|
||||
"cascade epic completion",
|
||||
`after update where new.status = "done" update where id in blocks(old.id) and type = "epic" and dependsOn all status = "done" set status="done"`,
|
||||
"update",
|
||||
false, true, false, false,
|
||||
},
|
||||
{
|
||||
"reopen epic on regression",
|
||||
`after update where old.status = "done" and new.status != "done" update where id in blocks(old.id) and type = "epic" and status = "done" set status="in progress"`,
|
||||
"update",
|
||||
false, true, false, false,
|
||||
},
|
||||
{
|
||||
"auto tag bugs",
|
||||
`after create where new.type = "bug" update where id = new.id set tags=new.tags + ["needs-triage"]`,
|
||||
"create",
|
||||
false, true, false, false,
|
||||
},
|
||||
{
|
||||
"propagate cancellation",
|
||||
`after update where new.status = "cancelled" update where id in blocks(old.id) and status in ["backlog", "ready"] set status="cancelled"`,
|
||||
"update",
|
||||
false, true, false, false,
|
||||
},
|
||||
{
|
||||
"unblock on last blocker",
|
||||
`after update where new.status = "done" update where old.id in dependsOn and dependsOn all status = "done" and status = "backlog" set status="ready"`,
|
||||
"update",
|
||||
false, true, false, false,
|
||||
},
|
||||
{
|
||||
"cleanup on delete",
|
||||
`after delete update where old.id in dependsOn set dependsOn=dependsOn - [old.id]`,
|
||||
"delete",
|
||||
false, true, false, false,
|
||||
},
|
||||
{
|
||||
"auto delete stale",
|
||||
`after update where new.status = "done" and old.updatedAt < now() - 2day delete where id = old.id`,
|
||||
"update",
|
||||
false, false, true, false,
|
||||
},
|
||||
{
|
||||
"run action",
|
||||
`after update where new.status = "in progress" and "claude" in new.tags run("claude -p 'implement tiki " + old.id + "'")`,
|
||||
"update",
|
||||
false, false, false, true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
trig, err := p.ParseTrigger(tt.input)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
if trig.Timing != "after" {
|
||||
t.Fatalf("expected after, got %s", trig.Timing)
|
||||
}
|
||||
if trig.Event != tt.event {
|
||||
t.Fatalf("expected %s, got %s", tt.event, trig.Event)
|
||||
}
|
||||
if trig.Deny != nil {
|
||||
t.Fatal("expected nil Deny in after-trigger")
|
||||
}
|
||||
|
||||
if tt.wantRun {
|
||||
if trig.Run == nil {
|
||||
t.Fatal("expected Run action, got nil")
|
||||
}
|
||||
} else {
|
||||
if trig.Action == nil {
|
||||
t.Fatal("expected Action, got nil")
|
||||
}
|
||||
if tt.wantCreate && trig.Action.Create == nil {
|
||||
t.Fatal("expected Create action")
|
||||
}
|
||||
if tt.wantUpdate && trig.Action.Update == nil {
|
||||
t.Fatal("expected Update action")
|
||||
}
|
||||
if tt.wantDelete && trig.Action.Delete == nil {
|
||||
t.Fatal("expected Delete action")
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrigger_StructuralErrors(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{
|
||||
"before with action",
|
||||
`before update where new.status = "done" update where id = old.id set status="done"`,
|
||||
},
|
||||
{
|
||||
"after with deny",
|
||||
`after update where new.status = "done" deny "no"`,
|
||||
},
|
||||
{
|
||||
"before without deny",
|
||||
`before update where new.status = "done"`,
|
||||
},
|
||||
{
|
||||
"after without action",
|
||||
`after update where new.status = "done"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := p.ParseTrigger(tt.input)
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrigger_QualifiedRefsInWhere(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
input := `before update where old.status = "in progress" and new.status = "done" deny "skip"`
|
||||
trig, err := p.ParseTrigger(input)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
|
||||
bc, ok := trig.Where.(*BinaryCondition)
|
||||
if !ok {
|
||||
t.Fatalf("expected BinaryCondition, got %T", trig.Where)
|
||||
}
|
||||
|
||||
// check left side has old.status
|
||||
leftCmp, ok := bc.Left.(*CompareExpr)
|
||||
if !ok {
|
||||
t.Fatalf("expected CompareExpr on left, got %T", bc.Left)
|
||||
}
|
||||
qr, ok := leftCmp.Left.(*QualifiedRef)
|
||||
if !ok {
|
||||
t.Fatalf("expected QualifiedRef, got %T", leftCmp.Left)
|
||||
}
|
||||
if qr.Qualifier != "old" || qr.Name != "status" {
|
||||
t.Fatalf("expected old.status, got %s.%s", qr.Qualifier, qr.Name)
|
||||
}
|
||||
|
||||
// check right side has new.status
|
||||
rightCmp, ok := bc.Right.(*CompareExpr)
|
||||
if !ok {
|
||||
t.Fatalf("expected CompareExpr on right, got %T", bc.Right)
|
||||
}
|
||||
qr2, ok := rightCmp.Left.(*QualifiedRef)
|
||||
if !ok {
|
||||
t.Fatalf("expected QualifiedRef, got %T", rightCmp.Left)
|
||||
}
|
||||
if qr2.Qualifier != "new" || qr2.Name != "status" {
|
||||
t.Fatalf("expected new.status, got %s.%s", qr2.Qualifier, qr2.Name)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrigger_NoWhereGuard(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
input := `after delete update where old.id in dependsOn set dependsOn=dependsOn - [old.id]`
|
||||
trig, err := p.ParseTrigger(input)
|
||||
if err != nil {
|
||||
t.Fatalf("parse error: %v", err)
|
||||
}
|
||||
if trig.Where != nil {
|
||||
t.Fatal("expected nil Where guard")
|
||||
}
|
||||
if trig.Action == nil || trig.Action.Update == nil {
|
||||
t.Fatal("expected Update action")
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrigger_BareFieldInGuard_Rejected(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
}{
|
||||
{
|
||||
"bare field in comparison",
|
||||
`before update where status = "done" deny "no"`,
|
||||
},
|
||||
{
|
||||
"bare field in quantifier collection",
|
||||
`before update where dependsOn any status = "done" deny "no"`,
|
||||
},
|
||||
{
|
||||
"bare field in is empty",
|
||||
`before create where description is empty deny "need description"`,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
_, err := p.ParseTrigger(tt.input)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for bare field in trigger guard")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrigger_BareFieldInsideQuantifier_Allowed(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
// bare status inside quantifier body is OK (zone 3), even within a trigger guard
|
||||
input := `before update where new.status = "done" and new.dependsOn all status != "done" deny "open deps"`
|
||||
_, err := p.ParseTrigger(input)
|
||||
if err != nil {
|
||||
t.Fatalf("expected success for bare field inside quantifier body: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrigger_BareFieldInsideSubquery_Allowed(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
// bare fields inside count(select where ...) are OK (zone 4), qualifiers also OK
|
||||
input := `before update where new.status = "in progress" and count(select where assignee = new.assignee and status = "in progress") >= 3 deny "WIP limit"`
|
||||
_, err := p.ParseTrigger(input)
|
||||
if err != nil {
|
||||
t.Fatalf("expected success for bare field inside subquery: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseTrigger_QualifierInQuantifierBody_Rejected(t *testing.T) {
|
||||
p := newTestParser()
|
||||
|
||||
// qualifiers inside quantifier bodies are forbidden (zone 3)
|
||||
input := `before update where new.dependsOn all old.status = "done" deny "no"`
|
||||
_, err := p.ParseTrigger(input)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for qualifier inside quantifier body")
|
||||
}
|
||||
}
|
||||
883
ruki/validate.go
Normal file
|
|
@ -0,0 +1,883 @@
|
|||
package ruki
|
||||
|
||||
import "fmt"
|
||||
|
||||
// validate.go — structural validation and semantic type-checking.
|
||||
|
||||
// qualifierPolicy controls which old./new. qualifiers are allowed during validation.
|
||||
type qualifierPolicy struct {
|
||||
allowOld bool
|
||||
allowNew bool
|
||||
}
|
||||
|
||||
// no qualifiers allowed (standalone statements).
|
||||
var noQualifiers = qualifierPolicy{}
|
||||
|
||||
func triggerQualifiers(event string) qualifierPolicy {
|
||||
switch event {
|
||||
case "create":
|
||||
return qualifierPolicy{allowNew: true}
|
||||
case "delete":
|
||||
return qualifierPolicy{allowOld: true}
|
||||
default: // "update"
|
||||
return qualifierPolicy{allowOld: true, allowNew: true}
|
||||
}
|
||||
}
|
||||
|
||||
// known builtins and their return types.
|
||||
var builtinFuncs = map[string]struct {
|
||||
returnType ValueType
|
||||
minArgs int
|
||||
maxArgs int
|
||||
}{
|
||||
"count": {ValueInt, 1, 1},
|
||||
"id": {ValueID, 0, 0},
|
||||
"now": {ValueTimestamp, 0, 0},
|
||||
"next_date": {ValueDate, 1, 1},
|
||||
"blocks": {ValueListRef, 1, 1},
|
||||
"call": {ValueString, 1, 1},
|
||||
"user": {ValueString, 0, 0},
|
||||
}
|
||||
|
||||
// --- structural validation ---
|
||||
|
||||
func (p *Parser) validateStatement(s *Statement) error {
|
||||
switch {
|
||||
case s.Create != nil:
|
||||
if len(s.Create.Assignments) == 0 {
|
||||
return fmt.Errorf("create must have at least one assignment")
|
||||
}
|
||||
return p.validateAssignments(s.Create.Assignments)
|
||||
case s.Update != nil:
|
||||
if len(s.Update.Set) == 0 {
|
||||
return fmt.Errorf("update must have at least one assignment in set")
|
||||
}
|
||||
if err := p.validateCondition(s.Update.Where); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.validateAssignments(s.Update.Set)
|
||||
case s.Delete != nil:
|
||||
return p.validateCondition(s.Delete.Where)
|
||||
case s.Select != nil:
|
||||
if err := p.validateSelectFields(s.Select.Fields); err != nil {
|
||||
return err
|
||||
}
|
||||
if s.Select.Where != nil {
|
||||
if err := p.validateCondition(s.Select.Where); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return p.validateOrderBy(s.Select.OrderBy)
|
||||
default:
|
||||
return fmt.Errorf("empty statement")
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Parser) validateTrigger(t *Trigger) error {
|
||||
if t.Timing == "before" {
|
||||
if t.Action != nil || t.Run != nil {
|
||||
return fmt.Errorf("before-trigger must not have an action")
|
||||
}
|
||||
if t.Deny == nil {
|
||||
return fmt.Errorf("before-trigger must have deny")
|
||||
}
|
||||
}
|
||||
if t.Timing == "after" {
|
||||
if t.Deny != nil {
|
||||
return fmt.Errorf("after-trigger must not have deny")
|
||||
}
|
||||
if t.Action == nil && t.Run == nil {
|
||||
return fmt.Errorf("after-trigger must have an action")
|
||||
}
|
||||
}
|
||||
|
||||
// zone 1: trigger where-guard requires qualifiers
|
||||
if t.Where != nil {
|
||||
p.requireQualifiers = true
|
||||
err := p.validateCondition(t.Where)
|
||||
p.requireQualifiers = false
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
// zone 2: action statement — bare fields resolve against target task
|
||||
if t.Action != nil {
|
||||
if t.Action.Select != nil {
|
||||
return fmt.Errorf("trigger action must not be select")
|
||||
}
|
||||
if err := p.validateStatement(t.Action); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
|
||||
if t.Run != nil {
|
||||
typ, err := p.inferExprType(t.Run.Command)
|
||||
if err != nil {
|
||||
return fmt.Errorf("run command: %w", err)
|
||||
}
|
||||
if typ != ValueString {
|
||||
return fmt.Errorf("run command must be string, got %s", typeName(typ))
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *Parser) validateRule(r *Rule) error {
|
||||
switch {
|
||||
case r.TimeTrigger != nil:
|
||||
p.qualifiers = noQualifiers
|
||||
return p.validateTimeTrigger(r.TimeTrigger)
|
||||
case r.Trigger != nil:
|
||||
p.qualifiers = triggerQualifiers(r.Trigger.Event)
|
||||
return p.validateTrigger(r.Trigger)
|
||||
default:
|
||||
return fmt.Errorf("empty rule")
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Parser) validateTimeTrigger(tt *TimeTrigger) error {
|
||||
if tt.Interval.Value <= 0 {
|
||||
return fmt.Errorf("every interval must be positive, got %d%s", tt.Interval.Value, tt.Interval.Unit)
|
||||
}
|
||||
if tt.Action.Select != nil {
|
||||
return fmt.Errorf("time trigger action must not be select")
|
||||
}
|
||||
p.qualifiers = noQualifiers
|
||||
return p.validateStatement(tt.Action)
|
||||
}
|
||||
|
||||
func (p *Parser) validateAssignments(assignments []Assignment) error {
|
||||
seen := make(map[string]struct{}, len(assignments))
|
||||
for _, a := range assignments {
|
||||
if _, dup := seen[a.Field]; dup {
|
||||
return fmt.Errorf("duplicate assignment to field %q", a.Field)
|
||||
}
|
||||
seen[a.Field] = struct{}{}
|
||||
fs, ok := p.schema.Field(a.Field)
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown field %q in assignment", a.Field)
|
||||
}
|
||||
rhsType, err := p.inferExprType(a.Value)
|
||||
if err != nil {
|
||||
return fmt.Errorf("field %q: %w", a.Field, err)
|
||||
}
|
||||
if err := p.checkAssignmentCompat(fs.Type, rhsType, a.Value); err != nil {
|
||||
return fmt.Errorf("field %q: %w", a.Field, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- select field validation ---
|
||||
|
||||
func (p *Parser) validateSelectFields(fields []string) error {
|
||||
if len(fields) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(fields))
|
||||
for _, f := range fields {
|
||||
if _, dup := seen[f]; dup {
|
||||
return fmt.Errorf("duplicate field %q in select", f)
|
||||
}
|
||||
seen[f] = struct{}{}
|
||||
if _, ok := p.schema.Field(f); !ok {
|
||||
return fmt.Errorf("unknown field %q in select", f)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- order by validation ---
|
||||
|
||||
func (p *Parser) validateOrderBy(clauses []OrderByClause) error {
|
||||
if len(clauses) == 0 {
|
||||
return nil
|
||||
}
|
||||
seen := make(map[string]struct{}, len(clauses))
|
||||
for _, c := range clauses {
|
||||
if _, dup := seen[c.Field]; dup {
|
||||
return fmt.Errorf("duplicate field %q in order by", c.Field)
|
||||
}
|
||||
seen[c.Field] = struct{}{}
|
||||
fs, ok := p.schema.Field(c.Field)
|
||||
if !ok {
|
||||
return fmt.Errorf("unknown field %q in order by", c.Field)
|
||||
}
|
||||
if !isOrderableType(fs.Type) {
|
||||
return fmt.Errorf("cannot order by %s field %q", typeName(fs.Type), c.Field)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func isOrderableType(t ValueType) bool {
|
||||
switch t {
|
||||
case ValueInt, ValueDate, ValueTimestamp, ValueDuration,
|
||||
ValueString, ValueStatus, ValueTaskType, ValueID, ValueRef:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// --- condition validation with type-checking ---
|
||||
|
||||
func (p *Parser) validateCondition(c Condition) error {
|
||||
switch c := c.(type) {
|
||||
case *BinaryCondition:
|
||||
if err := p.validateCondition(c.Left); err != nil {
|
||||
return err
|
||||
}
|
||||
return p.validateCondition(c.Right)
|
||||
|
||||
case *NotCondition:
|
||||
return p.validateCondition(c.Inner)
|
||||
|
||||
case *CompareExpr:
|
||||
return p.validateCompare(c)
|
||||
|
||||
case *IsEmptyExpr:
|
||||
_, err := p.inferExprType(c.Expr)
|
||||
return err
|
||||
|
||||
case *InExpr:
|
||||
return p.validateIn(c)
|
||||
|
||||
case *QuantifierExpr:
|
||||
return p.validateQuantifier(c)
|
||||
|
||||
default:
|
||||
return fmt.Errorf("unknown condition type %T", c)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Parser) validateCompare(c *CompareExpr) error {
|
||||
leftType, err := p.inferExprType(c.Left)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
rightType, err := p.inferExprType(c.Right)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// resolve empty from context
|
||||
leftType, rightType = resolveEmptyPair(leftType, rightType)
|
||||
|
||||
if !typesCompatible(leftType, rightType) {
|
||||
return fmt.Errorf("cannot compare %s %s %s", typeName(leftType), c.Op, typeName(rightType))
|
||||
}
|
||||
|
||||
// reject cross-type comparisons involving enum fields,
|
||||
// unless the other side is a string literal (e.g. status = "done")
|
||||
if err := p.checkCompareCompat(leftType, rightType, c.Left, c.Right); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// use the most specific type for operator and enum validation
|
||||
enumType := leftType
|
||||
if rightType == ValueStatus || rightType == ValueTaskType {
|
||||
enumType = rightType
|
||||
}
|
||||
|
||||
if err := checkCompareOp(enumType, c.Op); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
return p.validateEnumLiterals(c.Left, c.Right, enumType)
|
||||
}
|
||||
|
||||
func (p *Parser) validateIn(c *InExpr) error {
|
||||
valType, err := p.inferExprType(c.Value)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
collType, err := p.inferExprType(c.Collection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// list membership mode: collection is a list type
|
||||
if listElementType(collType) != -1 {
|
||||
elemType, err := p.inferListElementType(c.Collection)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !membershipCompatible(valType, elemType) {
|
||||
ll, isLiteral := c.Collection.(*ListLiteral)
|
||||
if !isLiteral || !isStringLike(valType) || !allStringLiterals(ll) {
|
||||
return fmt.Errorf("element type mismatch: %s in %s", typeName(valType), typeName(collType))
|
||||
}
|
||||
}
|
||||
return p.validateEnumListElements(c.Collection, valType)
|
||||
}
|
||||
|
||||
// substring mode: both sides must be string (not string-like)
|
||||
if valType == ValueString && collType == ValueString {
|
||||
return nil
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot check %s in %s", typeName(valType), typeName(collType))
|
||||
}
|
||||
|
||||
func (p *Parser) validateQuantifier(q *QuantifierExpr) error {
|
||||
exprType, err := p.inferExprType(q.Expr)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if exprType != ValueListRef {
|
||||
return fmt.Errorf("quantifier %s requires list<ref>, got %s", q.Kind, typeName(exprType))
|
||||
}
|
||||
// zone 3: quantifier bodies — bare fields refer to each related task,
|
||||
// qualifiers and requireQualifiers are both reset for the body
|
||||
savedQualifiers := p.qualifiers
|
||||
savedRequire := p.requireQualifiers
|
||||
p.qualifiers = noQualifiers
|
||||
p.requireQualifiers = false
|
||||
err = p.validateCondition(q.Condition)
|
||||
p.qualifiers = savedQualifiers
|
||||
p.requireQualifiers = savedRequire
|
||||
return err
|
||||
}
|
||||
|
||||
// --- type inference ---
|
||||
|
||||
func (p *Parser) inferExprType(e Expr) (ValueType, error) {
|
||||
switch e := e.(type) {
|
||||
case *FieldRef:
|
||||
if p.requireQualifiers {
|
||||
return 0, fmt.Errorf("bare field %q not allowed in trigger guard — use old.%s or new.%s", e.Name, e.Name, e.Name)
|
||||
}
|
||||
fs, ok := p.schema.Field(e.Name)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unknown field %q", e.Name)
|
||||
}
|
||||
return fs.Type, nil
|
||||
|
||||
case *QualifiedRef:
|
||||
if e.Qualifier == "old" && !p.qualifiers.allowOld {
|
||||
return 0, fmt.Errorf("old. qualifier is not valid in this context")
|
||||
}
|
||||
if e.Qualifier == "new" && !p.qualifiers.allowNew {
|
||||
return 0, fmt.Errorf("new. qualifier is not valid in this context")
|
||||
}
|
||||
fs, ok := p.schema.Field(e.Name)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unknown field %q in %s.%s", e.Name, e.Qualifier, e.Name)
|
||||
}
|
||||
return fs.Type, nil
|
||||
|
||||
case *StringLiteral:
|
||||
return ValueString, nil
|
||||
|
||||
case *IntLiteral:
|
||||
return ValueInt, nil
|
||||
|
||||
case *DateLiteral:
|
||||
return ValueDate, nil
|
||||
|
||||
case *DurationLiteral:
|
||||
return ValueDuration, nil
|
||||
|
||||
case *ListLiteral:
|
||||
return p.inferListType(e)
|
||||
|
||||
case *EmptyLiteral:
|
||||
return -1, nil // sentinel: resolved from context
|
||||
|
||||
case *FunctionCall:
|
||||
return p.inferFuncCallType(e)
|
||||
|
||||
case *BinaryExpr:
|
||||
return p.inferBinaryExprType(e)
|
||||
|
||||
case *SubQuery:
|
||||
return 0, fmt.Errorf("subquery is only valid as argument to count()")
|
||||
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown expression type %T", e)
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Parser) inferListType(l *ListLiteral) (ValueType, error) {
|
||||
if len(l.Elements) == 0 {
|
||||
return ValueListString, nil // default empty list type
|
||||
}
|
||||
firstType, err := p.inferExprType(l.Elements[0])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
for i := 1; i < len(l.Elements); i++ {
|
||||
t, err := p.inferExprType(l.Elements[i])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if !typesCompatible(firstType, t) {
|
||||
return 0, fmt.Errorf("list elements must be the same type: got %s and %s", typeName(firstType), typeName(t))
|
||||
}
|
||||
}
|
||||
switch firstType {
|
||||
case ValueRef, ValueID:
|
||||
return ValueListRef, nil
|
||||
default:
|
||||
return ValueListString, nil
|
||||
}
|
||||
}
|
||||
|
||||
// inferListElementType returns the element type of a list expression,
|
||||
// checking literal elements directly when the list type enum is too coarse.
|
||||
func (p *Parser) inferListElementType(e Expr) (ValueType, error) {
|
||||
if ll, ok := e.(*ListLiteral); ok && len(ll.Elements) > 0 {
|
||||
return p.inferExprType(ll.Elements[0])
|
||||
}
|
||||
collType, err := p.inferExprType(e)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
elem := listElementType(collType)
|
||||
if elem == -1 {
|
||||
return collType, nil // not a list type — return as-is for error reporting
|
||||
}
|
||||
return elem, nil
|
||||
}
|
||||
|
||||
func (p *Parser) inferFuncCallType(fc *FunctionCall) (ValueType, error) {
|
||||
builtin, ok := builtinFuncs[fc.Name]
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("unknown function %q", fc.Name)
|
||||
}
|
||||
if len(fc.Args) < builtin.minArgs || len(fc.Args) > builtin.maxArgs {
|
||||
if builtin.minArgs == builtin.maxArgs {
|
||||
return 0, fmt.Errorf("%s() expects %d argument(s), got %d", fc.Name, builtin.minArgs, len(fc.Args))
|
||||
}
|
||||
return 0, fmt.Errorf("%s() expects %d-%d arguments, got %d", fc.Name, builtin.minArgs, builtin.maxArgs, len(fc.Args))
|
||||
}
|
||||
|
||||
// validate argument types for specific functions
|
||||
switch fc.Name {
|
||||
case "count":
|
||||
sq, ok := fc.Args[0].(*SubQuery)
|
||||
if !ok {
|
||||
return 0, fmt.Errorf("count() argument must be a select subquery")
|
||||
}
|
||||
if sq.Where != nil {
|
||||
// zone 4: subquery bodies — bare fields refer to each candidate task,
|
||||
// qualifiers stay allowed (e.g. assignee = new.assignee), but requireQualifiers is reset
|
||||
savedRequire := p.requireQualifiers
|
||||
p.requireQualifiers = false
|
||||
err := p.validateCondition(sq.Where)
|
||||
p.requireQualifiers = savedRequire
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("count() subquery: %w", err)
|
||||
}
|
||||
}
|
||||
case "blocks":
|
||||
argType, err := p.inferExprType(fc.Args[0])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if argType != ValueID && argType != ValueRef && argType != ValueString {
|
||||
return 0, fmt.Errorf("blocks() argument must be an id or ref, got %s", typeName(argType))
|
||||
}
|
||||
if argType == ValueString {
|
||||
if _, ok := fc.Args[0].(*StringLiteral); !ok {
|
||||
return 0, fmt.Errorf("blocks() argument must be an id or ref, got %s", typeName(argType))
|
||||
}
|
||||
}
|
||||
case "call":
|
||||
t, err := p.inferExprType(fc.Args[0])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if t != ValueString {
|
||||
return 0, fmt.Errorf("call() argument must be string, got %s", typeName(t))
|
||||
}
|
||||
case "next_date":
|
||||
t, err := p.inferExprType(fc.Args[0])
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
if t != ValueRecurrence {
|
||||
return 0, fmt.Errorf("next_date() argument must be recurrence, got %s", typeName(t))
|
||||
}
|
||||
}
|
||||
|
||||
return builtin.returnType, nil
|
||||
}
|
||||
|
||||
func (p *Parser) inferBinaryExprType(b *BinaryExpr) (ValueType, error) {
|
||||
leftType, err := p.inferExprType(b.Left)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
rightType, err := p.inferExprType(b.Right)
|
||||
if err != nil {
|
||||
return 0, err
|
||||
}
|
||||
|
||||
leftType, rightType = resolveEmptyPair(leftType, rightType)
|
||||
|
||||
switch b.Op {
|
||||
case "+":
|
||||
return p.inferPlusType(leftType, rightType, b.Right)
|
||||
case "-":
|
||||
return p.inferMinusType(leftType, rightType, b.Right)
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown binary operator %q", b.Op)
|
||||
}
|
||||
}
|
||||
|
||||
func isStringLike(t ValueType) bool {
|
||||
switch t {
|
||||
case ValueString, ValueStatus, ValueTaskType, ValueID, ValueRef:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Parser) inferPlusType(left, right ValueType, rightExpr Expr) (ValueType, error) {
|
||||
switch {
|
||||
case isStringLike(left) && isStringLike(right):
|
||||
return ValueString, nil
|
||||
case left == ValueInt && right == ValueInt:
|
||||
return ValueInt, nil
|
||||
case left == ValueListString && (right == ValueString || right == ValueListString):
|
||||
return ValueListString, nil
|
||||
case left == ValueListRef && (isRefCompatible(right) || right == ValueListRef):
|
||||
return ValueListRef, nil
|
||||
case left == ValueListRef && right == ValueString:
|
||||
if _, ok := rightExpr.(*StringLiteral); ok {
|
||||
return ValueListRef, nil
|
||||
}
|
||||
return 0, fmt.Errorf("cannot add %s + %s", typeName(left), typeName(right))
|
||||
case left == ValueListRef && right == ValueListString:
|
||||
if _, ok := rightExpr.(*ListLiteral); ok {
|
||||
return ValueListRef, nil
|
||||
}
|
||||
return 0, fmt.Errorf("cannot add list<string> field to list<ref>")
|
||||
case left == ValueDate && right == ValueDuration:
|
||||
return ValueDate, nil
|
||||
case left == ValueTimestamp && right == ValueDuration:
|
||||
return ValueTimestamp, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("cannot add %s + %s", typeName(left), typeName(right))
|
||||
}
|
||||
}
|
||||
|
||||
func (p *Parser) inferMinusType(left, right ValueType, rightExpr Expr) (ValueType, error) {
|
||||
switch {
|
||||
case left == ValueListString && (right == ValueString || right == ValueListString):
|
||||
return ValueListString, nil
|
||||
case left == ValueListRef && (isRefCompatible(right) || right == ValueListRef):
|
||||
return ValueListRef, nil
|
||||
case left == ValueListRef && right == ValueString:
|
||||
if _, ok := rightExpr.(*StringLiteral); ok {
|
||||
return ValueListRef, nil
|
||||
}
|
||||
return 0, fmt.Errorf("cannot subtract %s - %s", typeName(left), typeName(right))
|
||||
case left == ValueListRef && right == ValueListString:
|
||||
if _, ok := rightExpr.(*ListLiteral); ok {
|
||||
return ValueListRef, nil
|
||||
}
|
||||
return 0, fmt.Errorf("cannot subtract list<string> field from list<ref>")
|
||||
case left == ValueInt && right == ValueInt:
|
||||
return ValueInt, nil
|
||||
case left == ValueDate && right == ValueDuration:
|
||||
return ValueDate, nil
|
||||
case left == ValueDate && right == ValueDate:
|
||||
return ValueDuration, nil
|
||||
case left == ValueTimestamp && right == ValueDuration:
|
||||
return ValueTimestamp, nil
|
||||
case left == ValueTimestamp && right == ValueTimestamp:
|
||||
return ValueDuration, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("cannot subtract %s - %s", typeName(left), typeName(right))
|
||||
}
|
||||
}
|
||||
|
||||
// --- enum literal validation ---
|
||||
|
||||
func (p *Parser) validateEnumLiterals(left, right Expr, resolvedType ValueType) error {
|
||||
if resolvedType == ValueStatus {
|
||||
if s, ok := right.(*StringLiteral); ok {
|
||||
if _, valid := p.schema.NormalizeStatus(s.Value); !valid {
|
||||
return fmt.Errorf("unknown status %q", s.Value)
|
||||
}
|
||||
}
|
||||
if s, ok := left.(*StringLiteral); ok {
|
||||
if _, valid := p.schema.NormalizeStatus(s.Value); !valid {
|
||||
return fmt.Errorf("unknown status %q", s.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if resolvedType == ValueTaskType {
|
||||
if s, ok := right.(*StringLiteral); ok {
|
||||
if _, valid := p.schema.NormalizeType(s.Value); !valid {
|
||||
return fmt.Errorf("unknown type %q", s.Value)
|
||||
}
|
||||
}
|
||||
if s, ok := left.(*StringLiteral); ok {
|
||||
if _, valid := p.schema.NormalizeType(s.Value); !valid {
|
||||
return fmt.Errorf("unknown type %q", s.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// validateEnumListElements checks string literals inside a list expression
|
||||
// against the appropriate enum normalizer, based on the value type being checked.
|
||||
func (p *Parser) validateEnumListElements(collection Expr, valType ValueType) error {
|
||||
ll, ok := collection.(*ListLiteral)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
for _, elem := range ll.Elements {
|
||||
s, ok := elem.(*StringLiteral)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
if valType == ValueStatus {
|
||||
if _, valid := p.schema.NormalizeStatus(s.Value); !valid {
|
||||
return fmt.Errorf("unknown status %q", s.Value)
|
||||
}
|
||||
}
|
||||
if valType == ValueTaskType {
|
||||
if _, valid := p.schema.NormalizeType(s.Value); !valid {
|
||||
return fmt.Errorf("unknown type %q", s.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// --- assignment compatibility ---
|
||||
|
||||
func (p *Parser) checkAssignmentCompat(fieldType, rhsType ValueType, rhs Expr) error {
|
||||
// empty is assignable to anything
|
||||
if _, ok := rhs.(*EmptyLiteral); ok {
|
||||
return nil
|
||||
}
|
||||
if rhsType == -1 { // unresolved empty
|
||||
return nil
|
||||
}
|
||||
|
||||
if typesCompatible(fieldType, rhsType) {
|
||||
// enum fields only accept same-type or string literals
|
||||
if (fieldType == ValueStatus || fieldType == ValueTaskType) && rhsType != fieldType {
|
||||
if _, ok := rhs.(*StringLiteral); !ok {
|
||||
return fmt.Errorf("cannot assign %s to %s field", typeName(rhsType), typeName(fieldType))
|
||||
}
|
||||
}
|
||||
// non-enum string-like fields reject enum-typed RHS
|
||||
if (fieldType == ValueString || fieldType == ValueID || fieldType == ValueRef) &&
|
||||
(rhsType == ValueStatus || rhsType == ValueTaskType) {
|
||||
return fmt.Errorf("cannot assign %s to %s field", typeName(rhsType), typeName(fieldType))
|
||||
}
|
||||
|
||||
// list<string> field rejects list literals with non-string elements
|
||||
if fieldType == ValueListString {
|
||||
if ll, ok := rhs.(*ListLiteral); ok {
|
||||
for _, elem := range ll.Elements {
|
||||
elemType, err := p.inferExprType(elem)
|
||||
if err == nil && elemType != ValueString {
|
||||
if _, isLit := elem.(*StringLiteral); !isLit {
|
||||
return fmt.Errorf("cannot assign %s to list<string> field", typeName(elemType))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// validate enum values
|
||||
if fieldType == ValueStatus {
|
||||
if s, ok := rhs.(*StringLiteral); ok {
|
||||
if _, valid := p.schema.NormalizeStatus(s.Value); !valid {
|
||||
return fmt.Errorf("unknown status %q", s.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
if fieldType == ValueTaskType {
|
||||
if s, ok := rhs.(*StringLiteral); ok {
|
||||
if _, valid := p.schema.NormalizeType(s.Value); !valid {
|
||||
return fmt.Errorf("unknown type %q", s.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// list<string> literal is assignable to list<ref>, but only if all elements are string literals
|
||||
if fieldType == ValueListRef && rhsType == ValueListString {
|
||||
if ll, ok := rhs.(*ListLiteral); ok && allStringLiterals(ll) {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("cannot assign %s to %s field", typeName(rhsType), typeName(fieldType))
|
||||
}
|
||||
|
||||
// --- type helpers ---
|
||||
|
||||
func typesCompatible(a, b ValueType) bool {
|
||||
if a == b {
|
||||
return true
|
||||
}
|
||||
if a == -1 || b == -1 { // unresolved empty
|
||||
return true
|
||||
}
|
||||
// string-like types are compatible with each other
|
||||
stringLike := map[ValueType]bool{
|
||||
ValueString: true,
|
||||
ValueStatus: true,
|
||||
ValueTaskType: true,
|
||||
ValueID: true,
|
||||
ValueRef: true,
|
||||
}
|
||||
return stringLike[a] && stringLike[b]
|
||||
}
|
||||
|
||||
func isEnumType(t ValueType) bool {
|
||||
return t == ValueStatus || t == ValueTaskType
|
||||
}
|
||||
|
||||
// allStringLiterals returns true if every element in the list is a *StringLiteral.
|
||||
func allStringLiterals(ll *ListLiteral) bool {
|
||||
for _, elem := range ll.Elements {
|
||||
if _, ok := elem.(*StringLiteral); !ok {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// checkCompareCompat rejects nonsensical cross-type comparisons in WHERE clauses.
|
||||
// e.g. status = title (enum vs string field) is rejected,
|
||||
// but status = "done" (enum vs string literal) is allowed.
|
||||
func (p *Parser) checkCompareCompat(leftType, rightType ValueType, left, right Expr) error {
|
||||
if isEnumType(leftType) && rightType != leftType {
|
||||
if err := checkEnumOperand(leftType, rightType, right); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
if isEnumType(rightType) && leftType != rightType {
|
||||
if err := checkEnumOperand(rightType, leftType, left); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func checkEnumOperand(enumType, otherType ValueType, other Expr) error {
|
||||
if otherType == ValueString {
|
||||
if _, ok := other.(*StringLiteral); !ok {
|
||||
return fmt.Errorf("cannot compare %s with %s field", typeName(enumType), typeName(otherType))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
return fmt.Errorf("cannot compare %s with %s", typeName(enumType), typeName(otherType))
|
||||
}
|
||||
|
||||
// membershipCompatible checks strict type compatibility for in/not in
|
||||
// expressions. Unlike typesCompatible, it does not treat all string-like
|
||||
// types as interchangeable — only ID and Ref are interchangeable.
|
||||
func membershipCompatible(a, b ValueType) bool {
|
||||
if a == b {
|
||||
return true
|
||||
}
|
||||
if a == -1 || b == -1 {
|
||||
return true
|
||||
}
|
||||
// ID and Ref are the same concept
|
||||
if (a == ValueID || a == ValueRef) && (b == ValueID || b == ValueRef) {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// isRefCompatible returns true for types that can appear as operands
|
||||
// in list<ref> add/remove operations.
|
||||
func isRefCompatible(t ValueType) bool {
|
||||
switch t {
|
||||
case ValueRef, ValueID:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func resolveEmptyPair(a, b ValueType) (ValueType, ValueType) {
|
||||
if a == -1 && b != -1 {
|
||||
a = b
|
||||
}
|
||||
if b == -1 && a != -1 {
|
||||
b = a
|
||||
}
|
||||
return a, b
|
||||
}
|
||||
|
||||
func listElementType(t ValueType) ValueType {
|
||||
switch t {
|
||||
case ValueListString:
|
||||
return ValueString
|
||||
case ValueListRef:
|
||||
return ValueRef
|
||||
default:
|
||||
return -1
|
||||
}
|
||||
}
|
||||
|
||||
func checkCompareOp(t ValueType, op string) error {
|
||||
switch op {
|
||||
case "=", "!=":
|
||||
return nil // all types support equality
|
||||
case "<", ">", "<=", ">=":
|
||||
switch t {
|
||||
case ValueInt, ValueDate, ValueTimestamp, ValueDuration:
|
||||
return nil
|
||||
default:
|
||||
return fmt.Errorf("operator %s not supported for %s", op, typeName(t))
|
||||
}
|
||||
default:
|
||||
return fmt.Errorf("unknown operator %q", op)
|
||||
}
|
||||
}
|
||||
|
||||
func typeName(t ValueType) string {
|
||||
switch t {
|
||||
case ValueString:
|
||||
return "string"
|
||||
case ValueInt:
|
||||
return "int"
|
||||
case ValueDate:
|
||||
return "date"
|
||||
case ValueTimestamp:
|
||||
return "timestamp"
|
||||
case ValueDuration:
|
||||
return "duration"
|
||||
case ValueBool:
|
||||
return "bool"
|
||||
case ValueID:
|
||||
return "id"
|
||||
case ValueRef:
|
||||
return "ref"
|
||||
case ValueRecurrence:
|
||||
return "recurrence"
|
||||
case ValueListString:
|
||||
return "list<string>"
|
||||
case ValueListRef:
|
||||
return "list<ref>"
|
||||
case ValueStatus:
|
||||
return "status"
|
||||
case ValueTaskType:
|
||||
return "type"
|
||||
case -1:
|
||||
return "empty"
|
||||
default:
|
||||
return "unknown"
|
||||
}
|
||||
}
|
||||
2509
ruki/validate_test.go
Normal file
9
service/build.go
Normal file
|
|
@ -0,0 +1,9 @@
|
|||
package service
|
||||
|
||||
// BuildGate creates a TaskMutationGate with standard field validators registered.
|
||||
// Call SetStore() on the returned gate after store initialization.
|
||||
func BuildGate() *TaskMutationGate {
|
||||
gate := NewTaskMutationGate()
|
||||
RegisterFieldValidators(gate)
|
||||
return gate
|
||||
}
|
||||
18
service/cmdutil_unix.go
Normal file
|
|
@ -0,0 +1,18 @@
|
|||
//go:build !windows
|
||||
|
||||
package service
|
||||
|
||||
import (
|
||||
"os/exec"
|
||||
"syscall"
|
||||
)
|
||||
|
||||
// setProcessGroup configures the command to run in its own process group
|
||||
// and overrides Cancel to kill the entire group (parent + children).
|
||||
func setProcessGroup(cmd *exec.Cmd) {
|
||||
cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true}
|
||||
cmd.Cancel = func() error {
|
||||
// negative pid → kill the whole process group
|
||||
return syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL)
|
||||
}
|
||||
}
|
||||
10
service/cmdutil_windows.go
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
//go:build windows
|
||||
|
||||
package service
|
||||
|
||||
import "os/exec"
|
||||
|
||||
// setProcessGroup is a no-op on Windows.
|
||||
// Windows has no process-group kill equivalent to Unix's kill(-pgid).
|
||||
// cmd.WaitDelay (set by the caller) bounds the pipe drain if children outlive the parent.
|
||||
func setProcessGroup(_ *exec.Cmd) {}
|
||||
259
service/task_mutation_gate.go
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
// triggerDepthKey is the context key for tracking trigger cascade depth.
|
||||
type triggerDepthKey struct{}
|
||||
|
||||
// triggerDepth returns the current trigger cascade depth from the context.
|
||||
// Returns 0 if no depth has been set (root mutation) or if ctx is nil.
|
||||
func triggerDepth(ctx context.Context) int {
|
||||
if ctx == nil {
|
||||
return 0
|
||||
}
|
||||
if v, ok := ctx.Value(triggerDepthKey{}).(int); ok {
|
||||
return v
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// withTriggerDepth returns a derived context with the given trigger cascade depth.
|
||||
// Falls back to context.Background() if ctx is nil.
|
||||
func withTriggerDepth(ctx context.Context, depth int) context.Context {
|
||||
if ctx == nil {
|
||||
ctx = context.Background()
|
||||
}
|
||||
return context.WithValue(ctx, triggerDepthKey{}, depth)
|
||||
}
|
||||
|
||||
// Rejection is returned by a validator to deny a mutation.
|
||||
type Rejection struct {
|
||||
Reason string
|
||||
}
|
||||
|
||||
// RejectionError holds one or more rejections from validators.
|
||||
type RejectionError struct {
|
||||
Rejections []Rejection
|
||||
}
|
||||
|
||||
func (e *RejectionError) Error() string {
|
||||
if len(e.Rejections) == 1 {
|
||||
return e.Rejections[0].Reason
|
||||
}
|
||||
msgs := make([]string, len(e.Rejections))
|
||||
for i, r := range e.Rejections {
|
||||
msgs[i] = r.Reason
|
||||
}
|
||||
return "validation failed: " + strings.Join(msgs, "; ")
|
||||
}
|
||||
|
||||
// MutationValidator inspects a mutation and optionally rejects it.
|
||||
// For create: old=nil, new=proposed task.
|
||||
// For update: old=current persisted version (cloned), new=proposed version.
|
||||
// For delete: old=task being deleted, new=nil.
|
||||
type MutationValidator func(old, new *task.Task, allTasks []*task.Task) *Rejection
|
||||
|
||||
// AfterHook runs after a successful mutation for side effects (e.g. trigger cascades).
|
||||
// Hooks receive the context (with trigger depth), old and new task snapshots.
|
||||
// Errors are logged but do not propagate — the original mutation is not affected.
|
||||
type AfterHook func(ctx context.Context, old, new *task.Task) error
|
||||
|
||||
// TaskMutationGate is the single gateway for all task mutations.
|
||||
// All Create/Update/Delete/AddComment operations must go through this gate.
|
||||
// Validators are registered per operation type and run before persistence.
|
||||
// After-hooks run post-persist for side effects; their errors are logged, not propagated.
|
||||
type TaskMutationGate struct {
|
||||
store store.Store
|
||||
createValidators []MutationValidator
|
||||
updateValidators []MutationValidator
|
||||
deleteValidators []MutationValidator
|
||||
afterCreateHooks []AfterHook
|
||||
afterUpdateHooks []AfterHook
|
||||
afterDeleteHooks []AfterHook
|
||||
}
|
||||
|
||||
// NewTaskMutationGate creates a gate without a store.
|
||||
// Call SetStore after store initialization. Validator registration
|
||||
// is safe before SetStore — mutations are not.
|
||||
func NewTaskMutationGate() *TaskMutationGate {
|
||||
return &TaskMutationGate{}
|
||||
}
|
||||
|
||||
// SetStore wires the persistence layer into the gate.
|
||||
func (g *TaskMutationGate) SetStore(s store.Store) {
|
||||
g.store = s
|
||||
}
|
||||
|
||||
// ReadStore returns the underlying store as a read-only interface.
|
||||
func (g *TaskMutationGate) ReadStore() store.ReadStore {
|
||||
g.ensureStore()
|
||||
return g.store
|
||||
}
|
||||
|
||||
// OnCreate registers a validator that runs before CreateTask.
|
||||
func (g *TaskMutationGate) OnCreate(v MutationValidator) {
|
||||
g.createValidators = append(g.createValidators, v)
|
||||
}
|
||||
|
||||
// OnUpdate registers a validator that runs before UpdateTask.
|
||||
func (g *TaskMutationGate) OnUpdate(v MutationValidator) {
|
||||
g.updateValidators = append(g.updateValidators, v)
|
||||
}
|
||||
|
||||
// OnDelete registers a validator that runs before DeleteTask.
|
||||
func (g *TaskMutationGate) OnDelete(v MutationValidator) {
|
||||
g.deleteValidators = append(g.deleteValidators, v)
|
||||
}
|
||||
|
||||
// OnAfterCreate registers a hook that runs after a successful CreateTask.
|
||||
func (g *TaskMutationGate) OnAfterCreate(h AfterHook) {
|
||||
g.afterCreateHooks = append(g.afterCreateHooks, h)
|
||||
}
|
||||
|
||||
// OnAfterUpdate registers a hook that runs after a successful UpdateTask.
|
||||
func (g *TaskMutationGate) OnAfterUpdate(h AfterHook) {
|
||||
g.afterUpdateHooks = append(g.afterUpdateHooks, h)
|
||||
}
|
||||
|
||||
// OnAfterDelete registers a hook that runs after a successful DeleteTask.
|
||||
func (g *TaskMutationGate) OnAfterDelete(h AfterHook) {
|
||||
g.afterDeleteHooks = append(g.afterDeleteHooks, h)
|
||||
}
|
||||
|
||||
// CreateTask validates the task, sets timestamps, persists it, and runs after-hooks.
|
||||
func (g *TaskMutationGate) CreateTask(ctx context.Context, t *task.Task) error {
|
||||
if err := checkTriggerDepth(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
g.ensureStore()
|
||||
allTasks := append(g.store.GetAllTasks(), t)
|
||||
if err := g.runValidators(g.createValidators, nil, t, allTasks); err != nil {
|
||||
return err
|
||||
}
|
||||
now := time.Now()
|
||||
if t.CreatedAt.IsZero() {
|
||||
t.CreatedAt = now
|
||||
}
|
||||
t.UpdatedAt = now
|
||||
if err := g.store.CreateTask(t); err != nil {
|
||||
return err
|
||||
}
|
||||
g.runAfterHooks(ctx, g.afterCreateHooks, nil, t.Clone())
|
||||
return nil
|
||||
}
|
||||
|
||||
// UpdateTask validates the task, sets UpdatedAt, persists changes, and runs after-hooks.
|
||||
func (g *TaskMutationGate) UpdateTask(ctx context.Context, t *task.Task) error {
|
||||
if err := checkTriggerDepth(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
g.ensureStore()
|
||||
raw := g.store.GetTask(t.ID)
|
||||
if raw == nil {
|
||||
return fmt.Errorf("task not found: %s", t.ID)
|
||||
}
|
||||
old := raw.Clone()
|
||||
allTasks := g.candidateAllTasks(t)
|
||||
if err := g.runValidators(g.updateValidators, old, t, allTasks); err != nil {
|
||||
return err
|
||||
}
|
||||
t.UpdatedAt = time.Now()
|
||||
if err := g.store.UpdateTask(t); err != nil {
|
||||
return err
|
||||
}
|
||||
g.runAfterHooks(ctx, g.afterUpdateHooks, old, t.Clone())
|
||||
return nil
|
||||
}
|
||||
|
||||
// DeleteTask validates, removes a task, and runs after-hooks.
|
||||
// Receives the full task so delete validators can inspect it.
|
||||
func (g *TaskMutationGate) DeleteTask(ctx context.Context, t *task.Task) error {
|
||||
if err := checkTriggerDepth(ctx); err != nil {
|
||||
return err
|
||||
}
|
||||
g.ensureStore()
|
||||
raw := g.store.GetTask(t.ID)
|
||||
if raw == nil {
|
||||
// task already gone — skip
|
||||
return nil
|
||||
}
|
||||
old := raw.Clone()
|
||||
allTasks := g.store.GetAllTasks()
|
||||
if err := g.runValidators(g.deleteValidators, old, nil, allTasks); err != nil {
|
||||
return err
|
||||
}
|
||||
g.store.DeleteTask(t.ID)
|
||||
g.runAfterHooks(ctx, g.afterDeleteHooks, old, nil)
|
||||
return nil
|
||||
}
|
||||
|
||||
// AddComment adds a comment to a task.
|
||||
// Returns an error if the task does not exist.
|
||||
func (g *TaskMutationGate) AddComment(taskID string, comment task.Comment) error {
|
||||
g.ensureStore()
|
||||
if !g.store.AddComment(taskID, comment) {
|
||||
return fmt.Errorf("task not found: %s", taskID)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// candidateAllTasks returns a snapshot of all tasks with the proposed update
|
||||
// applied. This lets before-update validators evaluate aggregate predicates
|
||||
// (e.g. WIP limits via count(select ...)) against the candidate world state
|
||||
// rather than the stale pre-mutation snapshot.
|
||||
func (g *TaskMutationGate) candidateAllTasks(proposed *task.Task) []*task.Task {
|
||||
stored := g.store.GetAllTasks()
|
||||
result := make([]*task.Task, len(stored))
|
||||
for i, t := range stored {
|
||||
if t.ID == proposed.ID {
|
||||
result[i] = proposed
|
||||
} else {
|
||||
result[i] = t
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (g *TaskMutationGate) runValidators(validators []MutationValidator, old, new *task.Task, allTasks []*task.Task) error {
|
||||
var rejections []Rejection
|
||||
for _, v := range validators {
|
||||
if r := v(old, new, allTasks); r != nil {
|
||||
rejections = append(rejections, *r)
|
||||
}
|
||||
}
|
||||
if len(rejections) > 0 {
|
||||
return &RejectionError{Rejections: rejections}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *TaskMutationGate) runAfterHooks(ctx context.Context, hooks []AfterHook, old, new *task.Task) {
|
||||
for _, h := range hooks {
|
||||
if err := h(ctx, old, new); err != nil {
|
||||
slog.Error("after-hook failed", "error", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// checkTriggerDepth returns an error if the trigger cascade depth exceeds the limit.
|
||||
func checkTriggerDepth(ctx context.Context) error {
|
||||
if triggerDepth(ctx) > maxTriggerDepth {
|
||||
return fmt.Errorf("trigger cascade depth exceeded (max %d)", maxTriggerDepth)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func (g *TaskMutationGate) ensureStore() {
|
||||
if g.store == nil {
|
||||
panic("TaskMutationGate: store not set — call SetStore before using mutations or ReadStore")
|
||||
}
|
||||
}
|
||||
767
service/task_mutation_gate_test.go
Normal file
|
|
@ -0,0 +1,767 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
"github.com/boolean-maybe/tiki/store"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
"github.com/boolean-maybe/tiki/workflow"
|
||||
)
|
||||
|
||||
func init() {
|
||||
config.ResetStatusRegistry([]workflow.StatusDef{
|
||||
{Key: "backlog", Label: "Backlog", Emoji: "📥", Default: true},
|
||||
{Key: "ready", Label: "Ready", Emoji: "📋", Active: true},
|
||||
{Key: "in_progress", Label: "In Progress", Emoji: "⚙️", Active: true},
|
||||
{Key: "done", Label: "Done", Emoji: "✅", Done: true},
|
||||
})
|
||||
}
|
||||
|
||||
func newGateWithStore() (*TaskMutationGate, store.Store) {
|
||||
gate := NewTaskMutationGate()
|
||||
s := store.NewInMemoryStore()
|
||||
gate.SetStore(s)
|
||||
return gate, s
|
||||
}
|
||||
|
||||
func TestCreateTask_Success(t *testing.T) {
|
||||
gate, s := newGateWithStore()
|
||||
|
||||
tk := &task.Task{
|
||||
ID: "TIKI-ABC123",
|
||||
Title: "test task",
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: 3,
|
||||
}
|
||||
|
||||
if err := gate.CreateTask(context.Background(), tk); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if s.GetTask("TIKI-ABC123") == nil {
|
||||
t.Fatal("task not persisted")
|
||||
}
|
||||
|
||||
if tk.CreatedAt.IsZero() {
|
||||
t.Error("CreatedAt not set")
|
||||
}
|
||||
if tk.UpdatedAt.IsZero() {
|
||||
t.Error("UpdatedAt not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTask_DoesNotOverwriteCreatedAt(t *testing.T) {
|
||||
// verify the gate does not zero an existing CreatedAt before passing to store.
|
||||
// note: the in-memory store unconditionally sets CreatedAt, so we test
|
||||
// the gate's behavior by checking the task state *before* store.CreateTask.
|
||||
gate := NewTaskMutationGate()
|
||||
|
||||
var passedCreatedAt time.Time
|
||||
past := time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC)
|
||||
|
||||
spy := &spyStore{
|
||||
Store: store.NewInMemoryStore(),
|
||||
onCreate: func(tk *task.Task) { passedCreatedAt = tk.CreatedAt },
|
||||
}
|
||||
gate.SetStore(spy)
|
||||
|
||||
tk := &task.Task{
|
||||
ID: "TIKI-ABC123",
|
||||
Title: "test",
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: 3,
|
||||
CreatedAt: past,
|
||||
}
|
||||
|
||||
if err := gate.CreateTask(context.Background(), tk); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !passedCreatedAt.Equal(past) {
|
||||
t.Errorf("gate changed CreatedAt before passing to store: got %v, want %v", passedCreatedAt, past)
|
||||
}
|
||||
}
|
||||
|
||||
// spyStore wraps a Store and calls hooks before delegating.
|
||||
type spyStore struct {
|
||||
store.Store
|
||||
onCreate func(*task.Task)
|
||||
}
|
||||
|
||||
func (s *spyStore) CreateTask(tk *task.Task) error {
|
||||
if s.onCreate != nil {
|
||||
s.onCreate(tk)
|
||||
}
|
||||
return s.Store.CreateTask(tk)
|
||||
}
|
||||
|
||||
func TestCreateTask_RejectedByValidator(t *testing.T) {
|
||||
gate, s := newGateWithStore()
|
||||
gate.OnCreate(func(_, _ *task.Task, _ []*task.Task) *Rejection {
|
||||
return &Rejection{Reason: "blocked"}
|
||||
})
|
||||
|
||||
tk := &task.Task{
|
||||
ID: "TIKI-ABC123",
|
||||
Title: "test",
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: 3,
|
||||
}
|
||||
|
||||
err := gate.CreateTask(context.Background(), tk)
|
||||
if err == nil {
|
||||
t.Fatal("expected rejection error")
|
||||
}
|
||||
|
||||
re, ok := err.(*RejectionError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *RejectionError, got %T", err)
|
||||
}
|
||||
if re.Rejections[0].Reason != "blocked" {
|
||||
t.Errorf("unexpected reason: %s", re.Rejections[0].Reason)
|
||||
}
|
||||
if s.GetTask("TIKI-ABC123") != nil {
|
||||
t.Error("task should not have been persisted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateTask_Success(t *testing.T) {
|
||||
gate, s := newGateWithStore()
|
||||
|
||||
tk := &task.Task{
|
||||
ID: "TIKI-ABC123",
|
||||
Title: "original",
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: 3,
|
||||
}
|
||||
_ = s.CreateTask(tk)
|
||||
|
||||
tk.Title = "updated"
|
||||
if err := gate.UpdateTask(context.Background(), tk); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
stored := s.GetTask("TIKI-ABC123")
|
||||
if stored.Title != "updated" {
|
||||
t.Errorf("title not updated: got %q", stored.Title)
|
||||
}
|
||||
if tk.UpdatedAt.IsZero() {
|
||||
t.Error("UpdatedAt not set")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateTask_RejectedByValidator(t *testing.T) {
|
||||
gate, s := newGateWithStore()
|
||||
gate.OnUpdate(func(_, new *task.Task, _ []*task.Task) *Rejection {
|
||||
if new.Title == "bad" {
|
||||
return &Rejection{Reason: "title cannot be 'bad'"}
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
original := &task.Task{
|
||||
ID: "TIKI-ABC123",
|
||||
Title: "good",
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: 3,
|
||||
}
|
||||
_ = s.CreateTask(original)
|
||||
|
||||
// clone to avoid mutating the store's pointer
|
||||
modified := original.Clone()
|
||||
modified.Title = "bad"
|
||||
err := gate.UpdateTask(context.Background(), modified)
|
||||
if err == nil {
|
||||
t.Fatal("expected rejection")
|
||||
}
|
||||
|
||||
stored := s.GetTask("TIKI-ABC123")
|
||||
if stored.Title != "good" {
|
||||
t.Errorf("task should not have been updated: got %q", stored.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteTask_Success(t *testing.T) {
|
||||
gate, s := newGateWithStore()
|
||||
|
||||
tk := &task.Task{
|
||||
ID: "TIKI-ABC123",
|
||||
Title: "to delete",
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: 3,
|
||||
}
|
||||
_ = s.CreateTask(tk)
|
||||
|
||||
if err := gate.DeleteTask(context.Background(), tk); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if s.GetTask("TIKI-ABC123") != nil {
|
||||
t.Error("task should have been deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteTask_RejectedByValidator(t *testing.T) {
|
||||
gate, s := newGateWithStore()
|
||||
gate.OnDelete(func(_, _ *task.Task, _ []*task.Task) *Rejection {
|
||||
return &Rejection{Reason: "cannot delete"}
|
||||
})
|
||||
|
||||
tk := &task.Task{
|
||||
ID: "TIKI-ABC123",
|
||||
Title: "protected",
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: 3,
|
||||
}
|
||||
_ = s.CreateTask(tk)
|
||||
|
||||
err := gate.DeleteTask(context.Background(), tk)
|
||||
if err == nil {
|
||||
t.Fatal("expected rejection")
|
||||
}
|
||||
|
||||
if s.GetTask("TIKI-ABC123") == nil {
|
||||
t.Error("task should not have been deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddComment_Success(t *testing.T) {
|
||||
gate, s := newGateWithStore()
|
||||
|
||||
tk := &task.Task{
|
||||
ID: "TIKI-ABC123",
|
||||
Title: "test",
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: 3,
|
||||
}
|
||||
_ = s.CreateTask(tk)
|
||||
|
||||
comment := task.Comment{
|
||||
ID: "c1",
|
||||
Author: "user",
|
||||
Text: "hello",
|
||||
}
|
||||
if err := gate.AddComment("TIKI-ABC123", comment); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
stored := s.GetTask("TIKI-ABC123")
|
||||
if len(stored.Comments) != 1 {
|
||||
t.Fatalf("expected 1 comment, got %d", len(stored.Comments))
|
||||
}
|
||||
}
|
||||
|
||||
func TestAddComment_TaskNotFound(t *testing.T) {
|
||||
gate, _ := newGateWithStore()
|
||||
|
||||
comment := task.Comment{ID: "c1", Author: "user", Text: "hello"}
|
||||
err := gate.AddComment("TIKI-NONEXIST", comment)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing task")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMultipleRejections(t *testing.T) {
|
||||
gate, _ := newGateWithStore()
|
||||
|
||||
gate.OnCreate(func(_, _ *task.Task, _ []*task.Task) *Rejection {
|
||||
return &Rejection{Reason: "reason one"}
|
||||
})
|
||||
gate.OnCreate(func(_, _ *task.Task, _ []*task.Task) *Rejection {
|
||||
return &Rejection{Reason: "reason two"}
|
||||
})
|
||||
|
||||
tk := &task.Task{
|
||||
ID: "TIKI-ABC123",
|
||||
Title: "test",
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: 3,
|
||||
}
|
||||
|
||||
err := gate.CreateTask(context.Background(), tk)
|
||||
if err == nil {
|
||||
t.Fatal("expected rejection error")
|
||||
}
|
||||
|
||||
re, ok := err.(*RejectionError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *RejectionError, got %T", err)
|
||||
}
|
||||
if len(re.Rejections) != 2 {
|
||||
t.Fatalf("expected 2 rejections, got %d", len(re.Rejections))
|
||||
}
|
||||
|
||||
errStr := re.Error()
|
||||
if !strings.Contains(errStr, "reason one") || !strings.Contains(errStr, "reason two") {
|
||||
t.Errorf("error should contain both reasons: %s", errStr)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSingleRejection_ErrorFormat(t *testing.T) {
|
||||
re := &RejectionError{
|
||||
Rejections: []Rejection{{Reason: "single reason"}},
|
||||
}
|
||||
if re.Error() != "single reason" {
|
||||
t.Errorf("expected plain reason, got %q", re.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldValidators_RejectInvalidTask(t *testing.T) {
|
||||
gate, s := newGateWithStore()
|
||||
RegisterFieldValidators(gate)
|
||||
|
||||
// create a valid task first so UpdateTask can find it in the store
|
||||
valid := &task.Task{
|
||||
ID: "TIKI-ABC123",
|
||||
Title: "test",
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: 3,
|
||||
}
|
||||
_ = s.CreateTask(valid)
|
||||
|
||||
// now try to update with invalid priority — should be rejected
|
||||
tk := valid.Clone()
|
||||
tk.Priority = 99
|
||||
|
||||
err := gate.UpdateTask(context.Background(), tk)
|
||||
if err == nil {
|
||||
t.Fatal("expected rejection for invalid priority")
|
||||
}
|
||||
|
||||
re, ok := err.(*RejectionError)
|
||||
if !ok {
|
||||
t.Fatalf("expected *RejectionError, got %T", err)
|
||||
}
|
||||
|
||||
found := false
|
||||
for _, r := range re.Rejections {
|
||||
if strings.Contains(r.Reason, "priority") {
|
||||
found = true
|
||||
break
|
||||
}
|
||||
}
|
||||
if !found {
|
||||
t.Errorf("expected priority rejection, got: %v", re.Rejections)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFieldValidators_AcceptValidTask(t *testing.T) {
|
||||
gate, _ := newGateWithStore()
|
||||
RegisterFieldValidators(gate)
|
||||
|
||||
tk := &task.Task{
|
||||
ID: "TIKI-ABC123",
|
||||
Title: "valid task",
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: 3,
|
||||
}
|
||||
|
||||
if err := gate.CreateTask(context.Background(), tk); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestReadStore(t *testing.T) {
|
||||
gate, s := newGateWithStore()
|
||||
|
||||
tk := &task.Task{
|
||||
ID: "TIKI-ABC123",
|
||||
Title: "test",
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: 3,
|
||||
}
|
||||
_ = s.CreateTask(tk)
|
||||
|
||||
rs := gate.ReadStore()
|
||||
if rs.GetTask("TIKI-ABC123") == nil {
|
||||
t.Error("ReadStore should return task from underlying store")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureStore_Panics(t *testing.T) {
|
||||
gate := NewTaskMutationGate()
|
||||
|
||||
defer func() {
|
||||
r := recover()
|
||||
if r == nil {
|
||||
t.Fatal("expected panic")
|
||||
}
|
||||
}()
|
||||
|
||||
_ = gate.CreateTask(context.Background(), &task.Task{})
|
||||
}
|
||||
|
||||
func TestCreateValidatorDoesNotAffectUpdate(t *testing.T) {
|
||||
gate, s := newGateWithStore()
|
||||
|
||||
// register a validator only on create
|
||||
gate.OnCreate(func(_, _ *task.Task, _ []*task.Task) *Rejection {
|
||||
return &Rejection{Reason: "create blocked"}
|
||||
})
|
||||
|
||||
// update should still work
|
||||
tk := &task.Task{
|
||||
ID: "TIKI-ABC123",
|
||||
Title: "test",
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: 3,
|
||||
}
|
||||
_ = s.CreateTask(tk)
|
||||
|
||||
tk.Title = "updated"
|
||||
if err := gate.UpdateTask(context.Background(), tk); err != nil {
|
||||
t.Fatalf("update should not be affected by create validator: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildGate(t *testing.T) {
|
||||
gate := BuildGate()
|
||||
s := store.NewInMemoryStore()
|
||||
gate.SetStore(s)
|
||||
|
||||
// BuildGate registers field validators, so an invalid task should be rejected
|
||||
tk := &task.Task{
|
||||
ID: "TIKI-ABC123",
|
||||
Title: "", // invalid
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: 3,
|
||||
}
|
||||
if err := gate.CreateTask(context.Background(), tk); err == nil {
|
||||
t.Fatal("expected rejection for empty title")
|
||||
}
|
||||
|
||||
// a valid task should succeed
|
||||
tk.Title = "valid"
|
||||
if err := gate.CreateTask(context.Background(), tk); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterHook_CalledWithCorrectOldNew(t *testing.T) {
|
||||
gate, s := newGateWithStore()
|
||||
|
||||
tk := &task.Task{
|
||||
ID: "TIKI-ABC123",
|
||||
Title: "original",
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: 3,
|
||||
}
|
||||
_ = s.CreateTask(tk)
|
||||
|
||||
var hookOld, hookNew *task.Task
|
||||
gate.OnAfterUpdate(func(_ context.Context, old, new *task.Task) error {
|
||||
hookOld = old
|
||||
hookNew = new
|
||||
return nil
|
||||
})
|
||||
|
||||
updated := tk.Clone()
|
||||
updated.Title = "changed"
|
||||
if err := gate.UpdateTask(context.Background(), updated); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if hookOld == nil || hookOld.Title != "original" {
|
||||
t.Errorf("after-hook old should have original title, got %v", hookOld)
|
||||
}
|
||||
if hookNew == nil || hookNew.Title != "changed" {
|
||||
t.Errorf("after-hook new should have changed title, got %v", hookNew)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterHook_ErrorSwallowed(t *testing.T) {
|
||||
gate, s := newGateWithStore()
|
||||
|
||||
tk := &task.Task{
|
||||
ID: "TIKI-ABC123",
|
||||
Title: "test",
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: 3,
|
||||
}
|
||||
_ = s.CreateTask(tk)
|
||||
|
||||
gate.OnAfterUpdate(func(_ context.Context, _, _ *task.Task) error {
|
||||
return fmt.Errorf("hook error")
|
||||
})
|
||||
|
||||
updated := tk.Clone()
|
||||
updated.Title = "new title"
|
||||
// error from after-hook should not propagate
|
||||
if err := gate.UpdateTask(context.Background(), updated); err != nil {
|
||||
t.Fatalf("after-hook error should not propagate: %v", err)
|
||||
}
|
||||
|
||||
// task should still be persisted
|
||||
stored := s.GetTask("TIKI-ABC123")
|
||||
if stored.Title != "new title" {
|
||||
t.Errorf("task should have been updated despite hook error, got %q", stored.Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterHook_CreateAndDelete(t *testing.T) {
|
||||
gate, s := newGateWithStore()
|
||||
|
||||
var createCalled, deleteCalled bool
|
||||
gate.OnAfterCreate(func(_ context.Context, old, new *task.Task) error {
|
||||
createCalled = true
|
||||
if old != nil {
|
||||
t.Error("create after-hook: old should be nil")
|
||||
}
|
||||
if new == nil || new.Title != "new task" {
|
||||
t.Error("create after-hook: new should have title")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
gate.OnAfterDelete(func(_ context.Context, old, new *task.Task) error {
|
||||
deleteCalled = true
|
||||
if old == nil || old.Title != "new task" {
|
||||
t.Error("delete after-hook: old should have title")
|
||||
}
|
||||
if new != nil {
|
||||
t.Error("delete after-hook: new should be nil")
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
tk := &task.Task{
|
||||
ID: "TIKI-ABC123",
|
||||
Title: "new task",
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: 3,
|
||||
}
|
||||
if err := gate.CreateTask(context.Background(), tk); err != nil {
|
||||
t.Fatalf("create error: %v", err)
|
||||
}
|
||||
if !createCalled {
|
||||
t.Error("create after-hook not called")
|
||||
}
|
||||
|
||||
if err := gate.DeleteTask(context.Background(), tk); err != nil {
|
||||
t.Fatalf("delete error: %v", err)
|
||||
}
|
||||
if !deleteCalled {
|
||||
t.Error("delete after-hook not called")
|
||||
}
|
||||
|
||||
if s.GetTask("TIKI-ABC123") != nil {
|
||||
t.Error("task should have been deleted")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAfterHook_Ordering(t *testing.T) {
|
||||
gate, s := newGateWithStore()
|
||||
|
||||
tk := &task.Task{
|
||||
ID: "TIKI-ABC123",
|
||||
Title: "test",
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: 3,
|
||||
}
|
||||
_ = s.CreateTask(tk)
|
||||
|
||||
// hook A mutates a second task through the gate
|
||||
second := &task.Task{
|
||||
ID: "TIKI-BBB222",
|
||||
Title: "second",
|
||||
Status: task.StatusBacklog,
|
||||
Type: task.TypeStory,
|
||||
Priority: 3,
|
||||
}
|
||||
_ = s.CreateTask(second)
|
||||
|
||||
gate.OnAfterUpdate(func(ctx context.Context, _, new *task.Task) error {
|
||||
// only fire for the original trigger, not for the cascaded mutation
|
||||
if new.ID != "TIKI-ABC123" {
|
||||
return nil
|
||||
}
|
||||
sec := s.GetTask("TIKI-BBB222")
|
||||
if sec == nil {
|
||||
return nil
|
||||
}
|
||||
upd := sec.Clone()
|
||||
upd.Title = "modified by hook A"
|
||||
return gate.UpdateTask(ctx, upd)
|
||||
})
|
||||
|
||||
// hook B checks that it sees hook A's mutation
|
||||
var hookBSawMutation bool
|
||||
gate.OnAfterUpdate(func(_ context.Context, _, _ *task.Task) error {
|
||||
sec := s.GetTask("TIKI-BBB222")
|
||||
if sec != nil && sec.Title == "modified by hook A" {
|
||||
hookBSawMutation = true
|
||||
}
|
||||
return nil
|
||||
})
|
||||
|
||||
updated := tk.Clone()
|
||||
updated.Title = "trigger"
|
||||
if err := gate.UpdateTask(context.Background(), updated); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
if !hookBSawMutation {
|
||||
t.Error("hook B should see hook A's mutation in the store")
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTask_DepthExceeded(t *testing.T) {
|
||||
gate, _ := newGateWithStore()
|
||||
|
||||
ctx := withTriggerDepth(context.Background(), maxTriggerDepth+1)
|
||||
tk := &task.Task{
|
||||
ID: "TIKI-DEPTH1", Title: "test", Status: task.StatusBacklog,
|
||||
Type: task.TypeStory, Priority: 3,
|
||||
}
|
||||
err := gate.CreateTask(ctx, tk)
|
||||
if err == nil {
|
||||
t.Fatal("expected depth exceeded error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cascade depth exceeded") {
|
||||
t.Fatalf("expected cascade depth error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteTask_DepthExceeded(t *testing.T) {
|
||||
gate, s := newGateWithStore()
|
||||
|
||||
tk := &task.Task{
|
||||
ID: "TIKI-DEPTH2", Title: "test", Status: task.StatusBacklog,
|
||||
Type: task.TypeStory, Priority: 3,
|
||||
}
|
||||
_ = s.CreateTask(tk)
|
||||
|
||||
ctx := withTriggerDepth(context.Background(), maxTriggerDepth+1)
|
||||
err := gate.DeleteTask(ctx, tk)
|
||||
if err == nil {
|
||||
t.Fatal("expected depth exceeded error")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "cascade depth exceeded") {
|
||||
t.Fatalf("expected cascade depth error, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCreateTask_StoreError(t *testing.T) {
|
||||
gate := NewTaskMutationGate()
|
||||
fs := &failingCreateStore{Store: store.NewInMemoryStore()}
|
||||
gate.SetStore(fs)
|
||||
|
||||
tk := &task.Task{
|
||||
ID: "TIKI-CRERR1", Title: "test", Status: task.StatusBacklog,
|
||||
Type: task.TypeStory, Priority: 3,
|
||||
}
|
||||
err := gate.CreateTask(context.Background(), tk)
|
||||
if err == nil {
|
||||
t.Fatal("expected store error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateTask_StoreError(t *testing.T) {
|
||||
gate := NewTaskMutationGate()
|
||||
fs := &failingUpdateStore{Store: store.NewInMemoryStore(), failID: "TIKI-UPERR1"}
|
||||
gate.SetStore(fs)
|
||||
|
||||
tk := &task.Task{
|
||||
ID: "TIKI-UPERR1", Title: "test", Status: task.StatusBacklog,
|
||||
Type: task.TypeStory, Priority: 3,
|
||||
}
|
||||
_ = fs.CreateTask(tk)
|
||||
|
||||
updated := tk.Clone()
|
||||
updated.Title = "updated"
|
||||
err := gate.UpdateTask(context.Background(), updated)
|
||||
if err == nil {
|
||||
t.Fatal("expected store error")
|
||||
}
|
||||
}
|
||||
|
||||
// failingCreateStore fails CreateTask
|
||||
type failingCreateStore struct {
|
||||
store.Store
|
||||
}
|
||||
|
||||
func (f *failingCreateStore) CreateTask(_ *task.Task) error {
|
||||
return fmt.Errorf("simulated create failure")
|
||||
}
|
||||
|
||||
// failingUpdateStore fails UpdateTask for a specific ID
|
||||
type failingUpdateStore struct {
|
||||
store.Store
|
||||
failID string
|
||||
}
|
||||
|
||||
func (f *failingUpdateStore) UpdateTask(t *task.Task) error {
|
||||
if t.ID == f.failID {
|
||||
return fmt.Errorf("simulated update failure")
|
||||
}
|
||||
return f.Store.UpdateTask(t)
|
||||
}
|
||||
|
||||
func TestTriggerDepth_NilContext(t *testing.T) {
|
||||
// triggerDepth must not panic on nil context
|
||||
depth := triggerDepth(nil) //nolint:staticcheck // SA1012: intentionally testing nil-context safety
|
||||
if depth != 0 {
|
||||
t.Fatalf("expected 0, got %d", depth)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWithTriggerDepth_NilContext(t *testing.T) {
|
||||
// withTriggerDepth must not panic on nil context
|
||||
ctx := withTriggerDepth(nil, 3) //nolint:staticcheck // SA1012: intentionally testing nil-context safety
|
||||
if ctx == nil {
|
||||
t.Fatal("expected non-nil context")
|
||||
}
|
||||
if got := triggerDepth(ctx); got != 3 {
|
||||
t.Fatalf("expected depth 3, got %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDeleteTask_AlreadyDeleted(t *testing.T) {
|
||||
gate := NewTaskMutationGate()
|
||||
s := store.NewInMemoryStore()
|
||||
gate.SetStore(s)
|
||||
|
||||
// delete a task that doesn't exist in store — should return nil gracefully
|
||||
phantom := &task.Task{ID: "TIKI-GONE01", Title: "gone"}
|
||||
err := gate.DeleteTask(context.Background(), phantom)
|
||||
if err != nil {
|
||||
t.Fatalf("expected nil for already-deleted task, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateTask_TaskNotFound(t *testing.T) {
|
||||
gate := NewTaskMutationGate()
|
||||
s := store.NewInMemoryStore()
|
||||
gate.SetStore(s)
|
||||
|
||||
missing := &task.Task{ID: "TIKI-MISS01", Title: "missing"}
|
||||
err := gate.UpdateTask(context.Background(), missing)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for missing task")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "task not found") {
|
||||
t.Fatalf("expected 'task not found' error, got: %v", err)
|
||||
}
|
||||
}
|
||||
495
service/trigger_engine.go
Normal file
|
|
@ -0,0 +1,495 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os/exec"
|
||||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
"github.com/boolean-maybe/tiki/util/duration"
|
||||
)
|
||||
|
||||
// maxTriggerDepth is the maximum cascade depth for triggers.
|
||||
// Root mutation is depth 0; up to 8 cascades are allowed.
|
||||
const maxTriggerDepth = 8
|
||||
|
||||
// runCommandTimeout is the timeout for run() commands executed by triggers.
|
||||
const runCommandTimeout = 30 * time.Second
|
||||
|
||||
// triggerEntry holds a parsed trigger and its description for logging.
|
||||
type triggerEntry struct {
|
||||
description string
|
||||
trigger *ruki.Trigger
|
||||
validated *ruki.ValidatedTrigger
|
||||
}
|
||||
|
||||
// TimeTriggerEntry holds a parsed time trigger and its description.
|
||||
type TimeTriggerEntry struct {
|
||||
Description string
|
||||
Trigger *ruki.TimeTrigger
|
||||
Validated *ruki.ValidatedTimeTrigger
|
||||
}
|
||||
|
||||
// TriggerEngine bridges parsed triggers with the mutation gate.
|
||||
// Before-triggers become MutationValidators, after-triggers become AfterHooks.
|
||||
type TriggerEngine struct {
|
||||
beforeCreate []triggerEntry
|
||||
beforeUpdate []triggerEntry
|
||||
beforeDelete []triggerEntry
|
||||
afterCreate []triggerEntry
|
||||
afterUpdate []triggerEntry
|
||||
afterDelete []triggerEntry
|
||||
timeTriggers []TimeTriggerEntry
|
||||
executor *ruki.TriggerExecutor
|
||||
gate *TaskMutationGate
|
||||
}
|
||||
|
||||
// NewTriggerEngine creates a TriggerEngine from parsed event and time triggers.
|
||||
func NewTriggerEngine(triggers []triggerEntry, timeTriggers []TimeTriggerEntry, executor *ruki.TriggerExecutor) *TriggerEngine {
|
||||
te := &TriggerEngine{timeTriggers: timeTriggers, executor: executor}
|
||||
for _, entry := range triggers {
|
||||
te.addTrigger(entry)
|
||||
}
|
||||
return te
|
||||
}
|
||||
|
||||
func (te *TriggerEngine) addTrigger(entry triggerEntry) {
|
||||
timing, event, ok := triggerTimingEvent(entry)
|
||||
if !ok {
|
||||
slog.Warn("skipping trigger with missing timing/event metadata",
|
||||
"trigger", entry.description)
|
||||
return
|
||||
}
|
||||
switch {
|
||||
case timing == "before" && event == "create":
|
||||
te.beforeCreate = append(te.beforeCreate, entry)
|
||||
case timing == "before" && event == "update":
|
||||
te.beforeUpdate = append(te.beforeUpdate, entry)
|
||||
case timing == "before" && event == "delete":
|
||||
te.beforeDelete = append(te.beforeDelete, entry)
|
||||
case timing == "after" && event == "create":
|
||||
te.afterCreate = append(te.afterCreate, entry)
|
||||
case timing == "after" && event == "update":
|
||||
te.afterUpdate = append(te.afterUpdate, entry)
|
||||
case timing == "after" && event == "delete":
|
||||
te.afterDelete = append(te.afterDelete, entry)
|
||||
default:
|
||||
slog.Warn("skipping trigger with unsupported timing/event",
|
||||
"trigger", entry.description, "timing", timing, "event", event)
|
||||
}
|
||||
}
|
||||
|
||||
// TimeTriggers returns the stored time trigger entries.
|
||||
func (te *TriggerEngine) TimeTriggers() []TimeTriggerEntry {
|
||||
return te.timeTriggers
|
||||
}
|
||||
|
||||
// RegisterWithGate wires the triggers into the gate as validators and hooks.
|
||||
func (te *TriggerEngine) RegisterWithGate(gate *TaskMutationGate) {
|
||||
te.gate = gate
|
||||
|
||||
// before-triggers become validators
|
||||
for _, entry := range te.beforeCreate {
|
||||
gate.OnCreate(te.makeBeforeValidator(entry))
|
||||
}
|
||||
for _, entry := range te.beforeUpdate {
|
||||
gate.OnUpdate(te.makeBeforeValidator(entry))
|
||||
}
|
||||
for _, entry := range te.beforeDelete {
|
||||
gate.OnDelete(te.makeBeforeValidator(entry))
|
||||
}
|
||||
|
||||
// after-triggers become hooks
|
||||
for _, entry := range te.afterCreate {
|
||||
gate.OnAfterCreate(te.makeAfterHook(entry))
|
||||
}
|
||||
for _, entry := range te.afterUpdate {
|
||||
gate.OnAfterUpdate(te.makeAfterHook(entry))
|
||||
}
|
||||
for _, entry := range te.afterDelete {
|
||||
gate.OnAfterDelete(te.makeAfterHook(entry))
|
||||
}
|
||||
}
|
||||
|
||||
// makeBeforeValidator creates a MutationValidator from a before-trigger.
|
||||
// Fail-closed: guard evaluation errors produce a rejection.
|
||||
func (te *TriggerEngine) makeBeforeValidator(entry triggerEntry) MutationValidator {
|
||||
return func(old, new *task.Task, allTasks []*task.Task) *Rejection {
|
||||
tc := &ruki.TriggerContext{Old: old, New: new, AllTasks: allTasks}
|
||||
match, err := te.executor.EvalGuard(eventTriggerForExec(entry), tc)
|
||||
if err != nil {
|
||||
return &Rejection{
|
||||
Reason: fmt.Sprintf("trigger %q guard evaluation failed: %v", entry.description, err),
|
||||
}
|
||||
}
|
||||
if match {
|
||||
if msg, ok := triggerDenyMessage(eventTriggerForExec(entry)); ok {
|
||||
return &Rejection{Reason: msg}
|
||||
}
|
||||
return &Rejection{Reason: "trigger rejected"}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// makeAfterHook creates an AfterHook from an after-trigger.
|
||||
// Guard evaluation errors are logged and the trigger is skipped.
|
||||
func (te *TriggerEngine) makeAfterHook(entry triggerEntry) AfterHook {
|
||||
return func(ctx context.Context, old, new *task.Task) error {
|
||||
depth := triggerDepth(ctx)
|
||||
if depth >= maxTriggerDepth {
|
||||
slog.Warn("trigger cascade depth exceeded, skipping",
|
||||
"trigger", entry.description, "depth", depth)
|
||||
return nil
|
||||
}
|
||||
|
||||
allTasks := te.gate.ReadStore().GetAllTasks()
|
||||
tc := &ruki.TriggerContext{Old: old, New: new, AllTasks: allTasks}
|
||||
|
||||
match, err := te.executor.EvalGuard(eventTriggerForExec(entry), tc)
|
||||
if err != nil {
|
||||
slog.Error("after-trigger guard evaluation failed",
|
||||
"trigger", entry.description, "error", err)
|
||||
return nil
|
||||
}
|
||||
if !match {
|
||||
return nil
|
||||
}
|
||||
|
||||
childCtx := withTriggerDepth(ctx, depth+1)
|
||||
|
||||
if triggerHasRunAction(eventTriggerForExec(entry)) {
|
||||
return te.execRun(childCtx, entry, tc)
|
||||
}
|
||||
return te.execAction(childCtx, entry, tc)
|
||||
}
|
||||
}
|
||||
|
||||
func (te *TriggerEngine) execAction(ctx context.Context, entry triggerEntry, tc *ruki.TriggerContext) error {
|
||||
input := ruki.ExecutionInput{}
|
||||
if triggerRequiresCreateTemplate(eventTriggerForExec(entry)) {
|
||||
tmpl, err := te.gate.ReadStore().NewTaskTemplate()
|
||||
if err != nil {
|
||||
return fmt.Errorf("create template: %w", err)
|
||||
}
|
||||
if tmpl == nil {
|
||||
return fmt.Errorf("create template: store returned nil template")
|
||||
}
|
||||
input.CreateTemplate = tmpl
|
||||
}
|
||||
result, err := te.executor.ExecAction(eventTriggerForExec(entry), tc, input)
|
||||
if err != nil {
|
||||
return fmt.Errorf("trigger %q action execution failed: %w", entry.description, err)
|
||||
}
|
||||
return te.persistResult(ctx, result)
|
||||
}
|
||||
|
||||
func (te *TriggerEngine) persistResult(ctx context.Context, result *ruki.Result) error {
|
||||
var errs []error
|
||||
switch {
|
||||
case result.Update != nil:
|
||||
for _, t := range result.Update.Updated {
|
||||
if err := te.gate.UpdateTask(ctx, t); err != nil {
|
||||
errs = append(errs, fmt.Errorf("update %s: %w", t.ID, err))
|
||||
}
|
||||
}
|
||||
case result.Create != nil:
|
||||
t := result.Create.Task
|
||||
if err := te.gate.CreateTask(ctx, t); err != nil {
|
||||
return fmt.Errorf("trigger create failed: %w", err)
|
||||
}
|
||||
case result.Delete != nil:
|
||||
for _, t := range result.Delete.Deleted {
|
||||
if err := te.gate.DeleteTask(ctx, t); err != nil {
|
||||
errs = append(errs, fmt.Errorf("delete %s: %w", t.ID, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
return errors.Join(errs...)
|
||||
}
|
||||
|
||||
func (te *TriggerEngine) execRun(ctx context.Context, entry triggerEntry, tc *ruki.TriggerContext) error {
|
||||
cmdStr, err := te.executor.ExecRun(eventTriggerForExec(entry), tc)
|
||||
if err != nil {
|
||||
return fmt.Errorf("trigger %q run evaluation failed: %w", entry.description, err)
|
||||
}
|
||||
|
||||
runCtx, cancel := context.WithTimeout(ctx, runCommandTimeout)
|
||||
defer cancel()
|
||||
|
||||
cmd := exec.CommandContext(runCtx, "sh", "-c", cmdStr) //nolint:gosec // cmdStr is a user-configured trigger action, intentionally dynamic
|
||||
setProcessGroup(cmd)
|
||||
cmd.WaitDelay = 3 * time.Second
|
||||
output, err := cmd.CombinedOutput()
|
||||
if err != nil {
|
||||
slog.Error("trigger run() command failed",
|
||||
"trigger", entry.description,
|
||||
"command", cmdStr,
|
||||
"output", string(output),
|
||||
"error", err)
|
||||
return nil // logged, chain continues
|
||||
}
|
||||
|
||||
slog.Info("trigger run() command succeeded",
|
||||
"trigger", entry.description,
|
||||
"command", cmdStr)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadAndRegisterTriggers loads trigger definitions from workflow.yaml, parses them,
|
||||
// and registers them with the gate. Returns the engine (always non-nil), the number
|
||||
// of triggers loaded, and any error. Callers can call StartScheduler on the engine
|
||||
// without nil-checking — it early-returns on zero time triggers.
|
||||
// Fails fast on parse errors — a bad trigger blocks startup.
|
||||
func LoadAndRegisterTriggers(gate *TaskMutationGate, schema ruki.Schema, userFunc func() string) (*TriggerEngine, int, error) {
|
||||
executor := ruki.NewTriggerExecutor(schema, userFunc)
|
||||
empty := func() *TriggerEngine { return NewTriggerEngine(nil, nil, executor) }
|
||||
|
||||
defs, err := config.LoadTriggerDefs()
|
||||
if err != nil {
|
||||
return empty(), 0, fmt.Errorf("loading trigger definitions: %w", err)
|
||||
}
|
||||
|
||||
if len(defs) == 0 {
|
||||
return empty(), 0, nil
|
||||
}
|
||||
|
||||
parser := ruki.NewParser(schema)
|
||||
var eventEntries []triggerEntry
|
||||
var timeEntries []TimeTriggerEntry
|
||||
|
||||
for i, def := range defs {
|
||||
desc := def.Description
|
||||
if desc == "" {
|
||||
desc = fmt.Sprintf("#%d", i+1)
|
||||
}
|
||||
|
||||
rule, err := parser.ParseAndValidateRule(def.Ruki)
|
||||
if err != nil {
|
||||
return empty(), 0, fmt.Errorf("trigger %q: %w", desc, err)
|
||||
}
|
||||
|
||||
switch r := rule.(type) {
|
||||
case ruki.ValidatedTimeRule:
|
||||
vtt := r.TimeTrigger()
|
||||
timeEntries = append(timeEntries, TimeTriggerEntry{
|
||||
Description: def.Description,
|
||||
Trigger: cloneTimeTriggerForService(vtt.TimeTriggerClone()),
|
||||
Validated: vtt,
|
||||
})
|
||||
case ruki.ValidatedEventRule:
|
||||
vt := r.Trigger()
|
||||
eventEntries = append(eventEntries, triggerEntry{
|
||||
description: def.Description,
|
||||
trigger: cloneTriggerForService(vt.TriggerClone()),
|
||||
validated: vt,
|
||||
})
|
||||
default:
|
||||
return empty(), 0, fmt.Errorf("trigger %q: unknown validated rule type %T", desc, rule)
|
||||
}
|
||||
}
|
||||
|
||||
engine := NewTriggerEngine(eventEntries, timeEntries, executor)
|
||||
engine.RegisterWithGate(gate)
|
||||
|
||||
total := len(eventEntries) + len(timeEntries)
|
||||
slog.Info("triggers loaded", "event", len(eventEntries), "time", len(timeEntries))
|
||||
|
||||
return engine, total, nil
|
||||
}
|
||||
|
||||
// StartScheduler launches a background goroutine for each time trigger.
|
||||
// Each goroutine fires on a time.Ticker interval. Context cancellation stops all goroutines.
|
||||
// Safe to call even when there are no time triggers — returns immediately.
|
||||
func (te *TriggerEngine) StartScheduler(ctx context.Context) {
|
||||
if len(te.timeTriggers) == 0 {
|
||||
return
|
||||
}
|
||||
for _, entry := range te.timeTriggers {
|
||||
interval, ok := timeTriggerInterval(entry)
|
||||
if !ok {
|
||||
slog.Warn("skipping time trigger with missing interval metadata",
|
||||
"trigger", entry.Description)
|
||||
continue
|
||||
}
|
||||
d, err := duration.ToDuration(interval.Value, interval.Unit)
|
||||
if err != nil {
|
||||
slog.Error("invalid time trigger interval, skipping",
|
||||
"trigger", entry.Description, "error", err)
|
||||
continue
|
||||
}
|
||||
slog.Info("starting time trigger scheduler",
|
||||
"trigger", entry.Description, "interval", d)
|
||||
go te.runTimeTrigger(ctx, entry, d)
|
||||
}
|
||||
}
|
||||
|
||||
// runTimeTrigger runs a single time trigger on a ticker loop until ctx is cancelled.
|
||||
// All errors are logged and swallowed — the ticker keeps running (fail-open).
|
||||
func (te *TriggerEngine) runTimeTrigger(ctx context.Context, entry TimeTriggerEntry, interval time.Duration) {
|
||||
ticker := time.NewTicker(interval)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return
|
||||
case <-ticker.C:
|
||||
te.executeTimeTrigger(ctx, entry)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// executeTimeTrigger runs a single tick of a time trigger: snapshot tasks, execute, persist.
|
||||
func (te *TriggerEngine) executeTimeTrigger(ctx context.Context, entry TimeTriggerEntry) {
|
||||
allTasks := te.gate.ReadStore().GetAllTasks()
|
||||
input := ruki.ExecutionInput{}
|
||||
if timeTriggerRequiresCreateTemplate(timeTriggerForExec(entry)) {
|
||||
tmpl, err := te.gate.ReadStore().NewTaskTemplate()
|
||||
if err != nil {
|
||||
slog.Error("create template failed", "trigger", entry.Description, "error", err)
|
||||
return
|
||||
}
|
||||
if tmpl == nil {
|
||||
slog.Error("create template failed", "trigger", entry.Description, "error", "store returned nil template")
|
||||
return
|
||||
}
|
||||
input.CreateTemplate = tmpl
|
||||
}
|
||||
result, err := te.executor.ExecTimeTriggerAction(timeTriggerForExec(entry), allTasks, input)
|
||||
if err != nil {
|
||||
slog.Error("time trigger action failed",
|
||||
"trigger", entry.Description, "error", err)
|
||||
return
|
||||
}
|
||||
if err := te.persistResult(ctx, result); err != nil {
|
||||
slog.Error("time trigger persist failed",
|
||||
"trigger", entry.Description, "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
func triggerTimingEvent(entry triggerEntry) (string, string, bool) {
|
||||
switch {
|
||||
case entry.validated != nil:
|
||||
timing, event := entry.validated.Timing(), entry.validated.Event()
|
||||
if timing == "" || event == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return timing, event, true
|
||||
case entry.trigger != nil:
|
||||
if entry.trigger.Timing == "" || entry.trigger.Event == "" {
|
||||
return "", "", false
|
||||
}
|
||||
return entry.trigger.Timing, entry.trigger.Event, true
|
||||
default:
|
||||
return "", "", false
|
||||
}
|
||||
}
|
||||
|
||||
func timeTriggerInterval(entry TimeTriggerEntry) (ruki.DurationLiteral, bool) {
|
||||
switch {
|
||||
case entry.Validated != nil:
|
||||
interval := entry.Validated.IntervalLiteral()
|
||||
if interval.Unit == "" {
|
||||
return ruki.DurationLiteral{}, false
|
||||
}
|
||||
return interval, true
|
||||
case entry.Trigger != nil:
|
||||
if entry.Trigger.Interval.Unit == "" {
|
||||
return ruki.DurationLiteral{}, false
|
||||
}
|
||||
return entry.Trigger.Interval, true
|
||||
default:
|
||||
return ruki.DurationLiteral{}, false
|
||||
}
|
||||
}
|
||||
|
||||
func triggerDenyMessage(trig any) (string, bool) {
|
||||
switch t := trig.(type) {
|
||||
case *ruki.ValidatedTrigger:
|
||||
return t.DenyMessage()
|
||||
case *ruki.Trigger:
|
||||
if t.Deny == nil {
|
||||
return "", false
|
||||
}
|
||||
return *t.Deny, true
|
||||
default:
|
||||
return "", false
|
||||
}
|
||||
}
|
||||
|
||||
func triggerHasRunAction(trig any) bool {
|
||||
switch t := trig.(type) {
|
||||
case *ruki.ValidatedTrigger:
|
||||
return t.HasRunAction()
|
||||
case *ruki.Trigger:
|
||||
return t.Run != nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func triggerRequiresCreateTemplate(trig any) bool {
|
||||
switch t := trig.(type) {
|
||||
case *ruki.ValidatedTrigger:
|
||||
return t.RequiresCreateTemplate()
|
||||
case *ruki.Trigger:
|
||||
return t != nil && t.Action != nil && t.Action.Create != nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func timeTriggerRequiresCreateTemplate(trig any) bool {
|
||||
switch t := trig.(type) {
|
||||
case *ruki.ValidatedTimeTrigger:
|
||||
return t.RequiresCreateTemplate()
|
||||
case *ruki.TimeTrigger:
|
||||
return t != nil && t.Action != nil && t.Action.Create != nil
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func eventTriggerForExec(entry triggerEntry) any {
|
||||
if entry.validated != nil {
|
||||
return entry.validated
|
||||
}
|
||||
return entry.trigger
|
||||
}
|
||||
|
||||
func timeTriggerForExec(entry TimeTriggerEntry) any {
|
||||
if entry.Validated != nil {
|
||||
return entry.Validated
|
||||
}
|
||||
return entry.Trigger
|
||||
}
|
||||
|
||||
func cloneTriggerForService(trig *ruki.Trigger) *ruki.Trigger {
|
||||
if trig == nil {
|
||||
return nil
|
||||
}
|
||||
return &ruki.Trigger{
|
||||
Timing: trig.Timing,
|
||||
Event: trig.Event,
|
||||
Where: trig.Where,
|
||||
Action: trig.Action,
|
||||
Run: trig.Run,
|
||||
Deny: trig.Deny,
|
||||
}
|
||||
}
|
||||
|
||||
func cloneTimeTriggerForService(tt *ruki.TimeTrigger) *ruki.TimeTrigger {
|
||||
if tt == nil {
|
||||
return nil
|
||||
}
|
||||
return &ruki.TimeTrigger{
|
||||
Interval: tt.Interval,
|
||||
Action: tt.Action,
|
||||
}
|
||||
}
|
||||
101
service/trigger_engine_safety_test.go
Normal file
|
|
@ -0,0 +1,101 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/boolean-maybe/tiki/ruki"
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
func TestTriggerEngine_ValidatedOnlyBeforeEntryDenies(t *testing.T) {
|
||||
p := ruki.NewParser(testTriggerSchema{})
|
||||
validated, err := p.ParseAndValidateTrigger(`before create deny "blocked by validated trigger"`, ruki.ExecutorRuntimeEventTrigger)
|
||||
if err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
|
||||
entry := triggerEntry{
|
||||
description: "validated-only",
|
||||
validated: validated,
|
||||
}
|
||||
gate, _ := newGateWithStoreAndTasks()
|
||||
engine := NewTriggerEngine([]triggerEntry{entry}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||||
engine.RegisterWithGate(gate)
|
||||
|
||||
err = gate.CreateTask(context.Background(), &task.Task{
|
||||
ID: "TIKI-VAL001",
|
||||
Title: "should be blocked",
|
||||
Status: "ready",
|
||||
Type: "story",
|
||||
Priority: 3,
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected create denial")
|
||||
}
|
||||
if !strings.Contains(err.Error(), "blocked by validated trigger") {
|
||||
t.Fatalf("expected validated deny message, got: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerEngine_EmptyEventEntryIsSkipped(t *testing.T) {
|
||||
gate, _ := newGateWithStoreAndTasks()
|
||||
engine := NewTriggerEngine([]triggerEntry{{description: "empty"}}, nil, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||||
engine.RegisterWithGate(gate)
|
||||
|
||||
if len(engine.beforeCreate)+len(engine.beforeUpdate)+len(engine.beforeDelete)+len(engine.afterCreate)+len(engine.afterUpdate)+len(engine.afterDelete) != 0 {
|
||||
t.Fatal("expected empty event entry to be skipped")
|
||||
}
|
||||
|
||||
err := gate.CreateTask(context.Background(), &task.Task{
|
||||
ID: "TIKI-EMP001",
|
||||
Title: "allowed",
|
||||
Status: "ready",
|
||||
Type: "story",
|
||||
Priority: 3,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected create error: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerEngine_ValidatedOnlyTimeEntryExecutes(t *testing.T) {
|
||||
p := ruki.NewParser(testTriggerSchema{})
|
||||
validated, err := p.ParseAndValidateTimeTrigger(`every 1day create title="from validated time trigger" priority=3 status="ready" type="story"`, ruki.ExecutorRuntimeTimeTrigger)
|
||||
if err != nil {
|
||||
t.Fatalf("validate: %v", err)
|
||||
}
|
||||
|
||||
gate, s := newGateWithStoreAndTasks()
|
||||
engine := NewTriggerEngine(nil, []TimeTriggerEntry{
|
||||
{
|
||||
Description: "validated-time",
|
||||
Validated: validated,
|
||||
},
|
||||
}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||||
engine.RegisterWithGate(gate)
|
||||
|
||||
engine.executeTimeTrigger(context.Background(), engine.timeTriggers[0])
|
||||
|
||||
all := s.GetAllTasks()
|
||||
if len(all) != 1 {
|
||||
t.Fatalf("expected one created task, got %d", len(all))
|
||||
}
|
||||
if all[0].Title != "from validated time trigger" {
|
||||
t.Fatalf("expected created title to match, got %q", all[0].Title)
|
||||
}
|
||||
}
|
||||
|
||||
func TestTriggerEngine_StartSchedulerEmptyTimeEntryNoPanic(t *testing.T) {
|
||||
gate, _ := newGateWithStoreAndTasks()
|
||||
engine := NewTriggerEngine(nil, []TimeTriggerEntry{
|
||||
{Description: "empty-time-entry"},
|
||||
}, ruki.NewTriggerExecutor(testTriggerSchema{}, nil))
|
||||
engine.RegisterWithGate(gate)
|
||||
|
||||
ctx, cancel := context.WithCancel(context.Background())
|
||||
defer cancel()
|
||||
|
||||
engine.StartScheduler(ctx)
|
||||
}
|
||||
1364
service/trigger_engine_test.go
Normal file
29
service/validators.go
Normal file
|
|
@ -0,0 +1,29 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
// RegisterFieldValidators registers standard field validators with the gate.
|
||||
// Each validator runs on both create and update operations.
|
||||
func RegisterFieldValidators(g *TaskMutationGate) {
|
||||
for _, fn := range task.AllValidators() {
|
||||
wrapped := wrapFieldValidator(fn)
|
||||
g.OnCreate(wrapped)
|
||||
g.OnUpdate(wrapped)
|
||||
}
|
||||
}
|
||||
|
||||
func wrapFieldValidator(fn func(*task.Task) string) MutationValidator {
|
||||
return func(old, new *task.Task, allTasks []*task.Task) *Rejection {
|
||||
// field validators only inspect the proposed task
|
||||
t := new
|
||||
if t == nil {
|
||||
t = old // delete case
|
||||
}
|
||||
if msg := fn(t); msg != "" {
|
||||
return &Rejection{Reason: msg}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
34
service/validators_test.go
Normal file
|
|
@ -0,0 +1,34 @@
|
|||
package service
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
func TestWrapFieldValidator_DeleteCase(t *testing.T) {
|
||||
// when new is nil (delete), the validator should inspect old
|
||||
validator := wrapFieldValidator(func(tk *task.Task) string {
|
||||
if tk.Title == "" {
|
||||
return "title required"
|
||||
}
|
||||
return ""
|
||||
})
|
||||
|
||||
old := &task.Task{ID: "TIKI-DEL001", Title: "has title", Priority: 3}
|
||||
// new is nil → delete case, validator should use old
|
||||
rejection := validator(old, nil, nil)
|
||||
if rejection != nil {
|
||||
t.Errorf("expected no rejection for valid old task, got: %s", rejection.Reason)
|
||||
}
|
||||
|
||||
// old with empty title → validator should reject
|
||||
badOld := &task.Task{ID: "TIKI-DEL002", Title: "", Priority: 3}
|
||||
rejection = validator(badOld, nil, nil)
|
||||
if rejection == nil {
|
||||
t.Fatal("expected rejection for old task with empty title")
|
||||
}
|
||||
if rejection.Reason != "title required" {
|
||||
t.Errorf("expected 'title required', got %q", rejection.Reason)
|
||||
}
|
||||
}
|
||||
|
|
@ -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()
|
||||
|
|
@ -389,3 +440,74 @@ func TestInMemoryStore_GetAllTasks(t *testing.T) {
|
|||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestSearch_WithQueryAndFilter(t *testing.T) {
|
||||
s := NewInMemoryStore()
|
||||
if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-SRC001", Title: "Bug in parser", Tags: []string{"backend"}}); err != nil {
|
||||
t.Fatalf("failed to create task: %v", err)
|
||||
}
|
||||
if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-SRC002", Title: "Bug in UI", Tags: []string{"frontend"}}); err != nil {
|
||||
t.Fatalf("failed to create task: %v", err)
|
||||
}
|
||||
if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-SRC003", Title: "Feature request", Tags: []string{"backend"}}); err != nil {
|
||||
t.Fatalf("failed to create task: %v", err)
|
||||
}
|
||||
|
||||
// query "Bug" + filter for backend tag
|
||||
results := s.Search("Bug", func(t *taskpkg.Task) bool {
|
||||
for _, tag := range t.Tags {
|
||||
if tag == "backend" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
})
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result (Bug + backend), got %d", len(results))
|
||||
}
|
||||
if results[0].Task.ID != "TIKI-SRC001" {
|
||||
t.Errorf("expected TIKI-SRC001, got %s", results[0].Task.ID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch_FilterRejectsAll(t *testing.T) {
|
||||
s := NewInMemoryStore()
|
||||
if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-REJ001", Title: "Task"}); err != nil {
|
||||
t.Fatalf("failed to create task: %v", err)
|
||||
}
|
||||
|
||||
results := s.Search("", func(t *taskpkg.Task) bool {
|
||||
return false // reject all
|
||||
})
|
||||
if len(results) != 0 {
|
||||
t.Fatalf("expected 0 results when filter rejects all, got %d", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestSearch_MatchesTags(t *testing.T) {
|
||||
s := NewInMemoryStore()
|
||||
if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-TAG001", Title: "No match in title", Tags: []string{"backend"}}); err != nil {
|
||||
t.Fatalf("failed to create task: %v", err)
|
||||
}
|
||||
|
||||
results := s.Search("backend", nil)
|
||||
if len(results) != 1 {
|
||||
t.Fatalf("expected 1 result (tag match), got %d", len(results))
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewTaskTemplate_IDCollision(t *testing.T) {
|
||||
s := NewInMemoryStore()
|
||||
// pre-populate so the generated ID always collides
|
||||
if err := s.CreateTask(&taskpkg.Task{ID: "TIKI-FIXED", Title: "blocker"}); err != nil {
|
||||
t.Fatalf("failed to create task: %v", err)
|
||||
}
|
||||
|
||||
// set idGenerator to always return the same ID
|
||||
s.idGenerator = func() string { return "FIXED" }
|
||||
|
||||
_, err := s.NewTaskTemplate()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for ID exhaustion")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
50
store/read_store.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package store
|
||||
|
||||
import (
|
||||
"github.com/boolean-maybe/tiki/task"
|
||||
)
|
||||
|
||||
// ReadStore is the read-only subset of Store.
|
||||
// Consumers that only need to query tasks should depend on this interface.
|
||||
type ReadStore interface {
|
||||
// GetTask retrieves a task by ID
|
||||
GetTask(id string) *task.Task
|
||||
|
||||
// GetAllTasks returns all tasks
|
||||
GetAllTasks() []*task.Task
|
||||
|
||||
// Search searches tasks with optional filter function.
|
||||
// query: case-insensitive search term (searches task IDs, titles, descriptions, and tags)
|
||||
// filterFunc: optional filter function to pre-filter tasks (nil = all tasks)
|
||||
// Returns matching tasks sorted by ID with relevance scores.
|
||||
Search(query string, filterFunc func(*task.Task) bool) []task.SearchResult
|
||||
|
||||
// GetCurrentUser returns the current git user name and email
|
||||
GetCurrentUser() (name string, email string, err error)
|
||||
|
||||
// GetStats returns statistics for the header (user, branch, etc.)
|
||||
GetStats() []Stat
|
||||
|
||||
// GetBurndown returns the burndown chart data
|
||||
GetBurndown() []BurndownPoint
|
||||
|
||||
// GetAllUsers returns list of all git users for assignee selection
|
||||
GetAllUsers() ([]string, error)
|
||||
|
||||
// NewTaskTemplate returns a new task populated with template defaults from new.md.
|
||||
// The task will have an auto-generated ID, git author, and all fields from the template.
|
||||
NewTaskTemplate() (*task.Task, error)
|
||||
|
||||
// AddListener registers a callback for change notifications.
|
||||
// returns a listener ID that can be used to remove the listener.
|
||||
AddListener(listener ChangeListener) int
|
||||
|
||||
// RemoveListener removes a previously registered listener by ID
|
||||
RemoveListener(id int)
|
||||
|
||||
// Reload reloads all data from the backing store
|
||||
Reload() error
|
||||
|
||||
// ReloadTask reloads a single task from disk by ID
|
||||
ReloadTask(taskID string) error
|
||||
}
|
||||
|
|
@ -7,20 +7,12 @@ import (
|
|||
// Store is the interface for task storage engines.
|
||||
// Implementations must be thread-safe and notify listeners on changes.
|
||||
type Store interface {
|
||||
// AddListener registers a callback for change notifications.
|
||||
// returns a listener ID that can be used to remove the listener.
|
||||
AddListener(listener ChangeListener) int
|
||||
|
||||
// RemoveListener removes a previously registered listener by ID
|
||||
RemoveListener(id int)
|
||||
ReadStore
|
||||
|
||||
// CreateTask adds a new task to the store.
|
||||
// Returns error if save fails (IO error, ErrConflict).
|
||||
CreateTask(task *task.Task) error
|
||||
|
||||
// GetTask retrieves a task by ID
|
||||
GetTask(id string) *task.Task
|
||||
|
||||
// UpdateTask updates an existing task.
|
||||
// Returns error if save fails (IO error, ErrConflict).
|
||||
UpdateTask(task *task.Task) error
|
||||
|
|
@ -28,39 +20,8 @@ type Store interface {
|
|||
// DeleteTask removes a task from the store
|
||||
DeleteTask(id string)
|
||||
|
||||
// GetAllTasks returns all tasks
|
||||
GetAllTasks() []*task.Task
|
||||
|
||||
// Search searches tasks with optional filter function.
|
||||
// query: case-insensitive search term (searches task IDs, titles, descriptions, and tags)
|
||||
// filterFunc: optional filter function to pre-filter tasks (nil = all tasks)
|
||||
// Returns matching tasks sorted by ID with relevance scores.
|
||||
Search(query string, filterFunc func(*task.Task) bool) []task.SearchResult
|
||||
|
||||
// AddComment adds a comment to a task
|
||||
AddComment(taskID string, comment task.Comment) bool
|
||||
|
||||
// Reload reloads all data from the backing store
|
||||
Reload() error
|
||||
|
||||
// ReloadTask reloads a single task from disk by ID
|
||||
ReloadTask(taskID string) error
|
||||
|
||||
// GetCurrentUser returns the current git user name and email
|
||||
GetCurrentUser() (name string, email string, err error)
|
||||
|
||||
// GetStats returns statistics for the header (user, branch, etc.)
|
||||
GetStats() []Stat
|
||||
|
||||
// GetBurndown returns the burndown chart data
|
||||
GetBurndown() []BurndownPoint
|
||||
|
||||
// GetAllUsers returns list of all git users for assignee selection
|
||||
GetAllUsers() ([]string, error)
|
||||
|
||||
// NewTaskTemplate returns a new task populated with template defaults from new.md.
|
||||
// The task will have an auto-generated ID, git author, and all fields from the template.
|
||||
NewTaskTemplate() (*task.Task, error)
|
||||
}
|
||||
|
||||
// ChangeListener is called when the store's data changes
|
||||
|
|
|
|||
|
|
@ -5,11 +5,12 @@ import (
|
|||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
taskpkg "github.com/boolean-maybe/tiki/task"
|
||||
"github.com/boolean-maybe/tiki/workflow"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Set up the default status registry for tests.
|
||||
config.ResetStatusRegistry([]config.StatusDef{
|
||||
// set up the default status registry for tests.
|
||||
config.ResetStatusRegistry([]workflow.StatusDef{
|
||||
{Key: "backlog", Label: "Backlog", Emoji: "📥", Default: true},
|
||||
{Key: "ready", Label: "Ready", Emoji: "📋", Active: true},
|
||||
{Key: "in_progress", Label: "In Progress", Emoji: "⚙️", Active: true},
|
||||
|
|
|
|||
|
|
@ -105,7 +105,7 @@ func (s *TikiStore) loadTaskFile(path string, authorMap map[string]*git.AuthorIn
|
|||
var fmMap map[string]interface{}
|
||||
if err := yaml.Unmarshal([]byte(frontmatter), &fmMap); err == nil {
|
||||
if rawID, ok := fmMap["id"]; ok {
|
||||
if idStr, ok := rawID.(string); ok && idStr != "" && idStr != taskID {
|
||||
if idStr, ok := rawID.(string); ok && idStr != "" && !strings.EqualFold(idStr, taskID) {
|
||||
slog.Warn("ignoring frontmatter ID mismatch, using filename",
|
||||
"file", path,
|
||||
"frontmatter_id", idStr,
|
||||
|
|
|
|||
|
|
@ -65,21 +65,6 @@ func (t *Task) Clone() *Task {
|
|||
return clone
|
||||
}
|
||||
|
||||
// Validate validates the task using the standard validator
|
||||
func (t *Task) Validate() ValidationErrors {
|
||||
return QuickValidate(t)
|
||||
}
|
||||
|
||||
// IsValid returns true if the task passes all validation
|
||||
func (t *Task) IsValid() bool {
|
||||
return IsValid(t)
|
||||
}
|
||||
|
||||
// ValidateField validates a single field
|
||||
func (t *Task) ValidateField(fieldName string) *ValidationError {
|
||||
return NewTaskValidator().ValidateField(t, fieldName)
|
||||
}
|
||||
|
||||
// Comment represents a comment on a task
|
||||
type Comment struct {
|
||||
ID string
|
||||
|
|
|
|||
90
task/entities_test.go
Normal file
|
|
@ -0,0 +1,90 @@
|
|||
package task
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestClone_NilTask(t *testing.T) {
|
||||
var nilTask *Task
|
||||
if nilTask.Clone() != nil {
|
||||
t.Fatal("Clone of nil task should return nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClone_FullTask(t *testing.T) {
|
||||
original := &Task{
|
||||
ID: "TIKI-ABC123",
|
||||
Title: "Test task",
|
||||
Description: "desc",
|
||||
Type: TypeStory,
|
||||
Status: StatusReady,
|
||||
Tags: []string{"a", "b"},
|
||||
DependsOn: []string{"TIKI-DEP001"},
|
||||
Due: time.Date(2025, 6, 15, 0, 0, 0, 0, time.UTC),
|
||||
Recurrence: RecurrenceDaily,
|
||||
Assignee: "alice",
|
||||
Priority: 2,
|
||||
Points: 5,
|
||||
Comments: []Comment{{ID: "c1", Author: "bob", Text: "hello"}},
|
||||
CreatedBy: "alice",
|
||||
CreatedAt: time.Date(2025, 1, 1, 0, 0, 0, 0, time.UTC),
|
||||
UpdatedAt: time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC),
|
||||
LoadedMtime: time.Date(2025, 1, 2, 0, 0, 0, 0, time.UTC),
|
||||
}
|
||||
|
||||
clone := original.Clone()
|
||||
|
||||
// verify all scalar fields copied
|
||||
if clone.ID != original.ID {
|
||||
t.Errorf("ID = %q, want %q", clone.ID, original.ID)
|
||||
}
|
||||
if clone.Title != original.Title {
|
||||
t.Errorf("Title = %q, want %q", clone.Title, original.Title)
|
||||
}
|
||||
if clone.Priority != original.Priority {
|
||||
t.Errorf("Priority = %d, want %d", clone.Priority, original.Priority)
|
||||
}
|
||||
if clone.Points != original.Points {
|
||||
t.Errorf("Points = %d, want %d", clone.Points, original.Points)
|
||||
}
|
||||
if clone.Recurrence != original.Recurrence {
|
||||
t.Errorf("Recurrence = %v, want %v", clone.Recurrence, original.Recurrence)
|
||||
}
|
||||
|
||||
// verify deep copy: mutate clone slices, original must be unaffected
|
||||
clone.Tags[0] = "MUTATED"
|
||||
if original.Tags[0] == "MUTATED" {
|
||||
t.Fatal("mutating clone Tags affected original — not a deep copy")
|
||||
}
|
||||
|
||||
clone.DependsOn[0] = "MUTATED"
|
||||
if original.DependsOn[0] == "MUTATED" {
|
||||
t.Fatal("mutating clone DependsOn affected original — not a deep copy")
|
||||
}
|
||||
|
||||
clone.Comments[0].Text = "MUTATED"
|
||||
if original.Comments[0].Text == "MUTATED" {
|
||||
t.Fatal("mutating clone Comments affected original — not a deep copy")
|
||||
}
|
||||
}
|
||||
|
||||
func TestClone_NilSlices(t *testing.T) {
|
||||
original := &Task{
|
||||
ID: "TIKI-ABC123",
|
||||
Title: "bare task",
|
||||
// Tags, DependsOn, Comments are all nil
|
||||
}
|
||||
|
||||
clone := original.Clone()
|
||||
|
||||
if clone.Tags != nil {
|
||||
t.Error("nil Tags should remain nil in clone")
|
||||
}
|
||||
if clone.DependsOn != nil {
|
||||
t.Error("nil DependsOn should remain nil in clone")
|
||||
}
|
||||
if clone.Comments != nil {
|
||||
t.Error("nil Comments should remain nil in clone")
|
||||
}
|
||||
}
|
||||
|
|
@ -2,31 +2,33 @@ package task
|
|||
|
||||
import (
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
"github.com/boolean-maybe/tiki/workflow"
|
||||
)
|
||||
|
||||
type Status string
|
||||
// Status is a type alias for workflow.StatusKey.
|
||||
// This preserves compatibility: task.Status and workflow.StatusKey are the same type.
|
||||
type Status = workflow.StatusKey
|
||||
|
||||
// Convenience constants matching the default workflow.yaml statuses.
|
||||
// These are kept so that tests and internal code can reference them.
|
||||
// convenience constants matching the default workflow.yaml statuses.
|
||||
const (
|
||||
StatusBacklog Status = "backlog"
|
||||
StatusReady Status = "ready"
|
||||
StatusInProgress Status = "in_progress"
|
||||
StatusReview Status = "review"
|
||||
StatusDone Status = "done"
|
||||
StatusBacklog = workflow.StatusBacklog
|
||||
StatusReady = workflow.StatusReady
|
||||
StatusInProgress = workflow.StatusInProgress
|
||||
StatusReview = workflow.StatusReview
|
||||
StatusDone = workflow.StatusDone
|
||||
)
|
||||
|
||||
// ParseStatus normalizes a raw status string and validates it against the registry.
|
||||
// Empty input returns the configured default status.
|
||||
// Unknown values return (DefaultStatus(), false).
|
||||
func ParseStatus(status string) (Status, bool) {
|
||||
normalized := config.NormalizeStatusKey(status)
|
||||
normalized := workflow.NormalizeStatusKey(status)
|
||||
if normalized == "" {
|
||||
return DefaultStatus(), true
|
||||
}
|
||||
reg := config.GetStatusRegistry()
|
||||
if reg.IsValid(normalized) {
|
||||
return Status(normalized), true
|
||||
if reg.IsValid(string(normalized)) {
|
||||
return normalized, true
|
||||
}
|
||||
return DefaultStatus(), false
|
||||
}
|
||||
|
|
@ -48,7 +50,7 @@ func StatusToString(status Status) string {
|
|||
if reg.IsValid(string(status)) {
|
||||
return string(status)
|
||||
}
|
||||
return reg.DefaultKey()
|
||||
return string(reg.DefaultKey())
|
||||
}
|
||||
|
||||
// StatusEmoji returns the emoji for a status from the registry.
|
||||
|
|
@ -81,12 +83,12 @@ func StatusDisplay(status Status) string {
|
|||
|
||||
// DefaultStatus returns the status configured as default in workflow.yaml.
|
||||
func DefaultStatus() Status {
|
||||
return Status(config.GetStatusRegistry().DefaultKey())
|
||||
return config.GetStatusRegistry().DefaultKey()
|
||||
}
|
||||
|
||||
// DoneStatus returns the status configured as done in workflow.yaml.
|
||||
func DoneStatus() Status {
|
||||
return Status(config.GetStatusRegistry().DoneKey())
|
||||
return config.GetStatusRegistry().DoneKey()
|
||||
}
|
||||
|
||||
// AllStatuses returns the ordered list of all configured statuses.
|
||||
|
|
|
|||
167
task/status_test.go
Normal file
|
|
@ -0,0 +1,167 @@
|
|||
package task
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
"github.com/boolean-maybe/tiki/workflow"
|
||||
)
|
||||
|
||||
func setupStatusTestRegistry(t *testing.T) {
|
||||
t.Helper()
|
||||
config.ResetStatusRegistry([]workflow.StatusDef{
|
||||
{Key: "backlog", Label: "Backlog", Emoji: "📥", Default: true},
|
||||
{Key: "ready", Label: "Ready", Emoji: "📋", Active: true},
|
||||
{Key: "in_progress", Label: "In Progress", Emoji: "⚙️", Active: true},
|
||||
{Key: "review", Label: "Review", Emoji: "👀", Active: true},
|
||||
{Key: "done", Label: "Done", Emoji: "✅", Done: true},
|
||||
})
|
||||
t.Cleanup(func() { config.ClearStatusRegistry() })
|
||||
}
|
||||
|
||||
func TestParseStatus(t *testing.T) {
|
||||
setupStatusTestRegistry(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantStatus Status
|
||||
wantOK bool
|
||||
}{
|
||||
{"valid status", "done", "done", true},
|
||||
{"empty input returns default", "", "backlog", true},
|
||||
{"normalized input", "In-Progress", "in_progress", true},
|
||||
{"unknown status", "nonexistent", "backlog", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := ParseStatus(tt.input)
|
||||
if ok != tt.wantOK {
|
||||
t.Errorf("ParseStatus(%q) ok = %v, want %v", tt.input, ok, tt.wantOK)
|
||||
}
|
||||
if got != tt.wantStatus {
|
||||
t.Errorf("ParseStatus(%q) = %q, want %q", tt.input, got, tt.wantStatus)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeStatus(t *testing.T) {
|
||||
setupStatusTestRegistry(t)
|
||||
|
||||
if got := NormalizeStatus("DONE"); got != "done" {
|
||||
t.Errorf("NormalizeStatus(%q) = %q, want %q", "DONE", got, "done")
|
||||
}
|
||||
if got := NormalizeStatus("unknown"); got != "backlog" {
|
||||
t.Errorf("NormalizeStatus(%q) = %q, want %q (default)", "unknown", got, "backlog")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMapStatus(t *testing.T) {
|
||||
setupStatusTestRegistry(t)
|
||||
|
||||
if got := MapStatus("ready"); got != "ready" {
|
||||
t.Errorf("MapStatus(%q) = %q, want %q", "ready", got, "ready")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusToString(t *testing.T) {
|
||||
setupStatusTestRegistry(t)
|
||||
|
||||
if got := StatusToString("done"); got != "done" {
|
||||
t.Errorf("StatusToString(%q) = %q, want %q", "done", got, "done")
|
||||
}
|
||||
if got := StatusToString("nonexistent"); got != "backlog" {
|
||||
t.Errorf("StatusToString(%q) = %q, want default", "nonexistent", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusEmoji(t *testing.T) {
|
||||
setupStatusTestRegistry(t)
|
||||
|
||||
if got := StatusEmoji("done"); got != "✅" {
|
||||
t.Errorf("StatusEmoji(%q) = %q, want %q", "done", got, "✅")
|
||||
}
|
||||
if got := StatusEmoji("nonexistent"); got != "" {
|
||||
t.Errorf("StatusEmoji(%q) = %q, want empty", "nonexistent", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusLabel(t *testing.T) {
|
||||
setupStatusTestRegistry(t)
|
||||
|
||||
if got := StatusLabel("in_progress"); got != "In Progress" {
|
||||
t.Errorf("StatusLabel(%q) = %q, want %q", "in_progress", got, "In Progress")
|
||||
}
|
||||
if got := StatusLabel("nonexistent"); got != "nonexistent" {
|
||||
t.Errorf("StatusLabel(%q) = %q, want raw key", "nonexistent", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusDisplay(t *testing.T) {
|
||||
setupStatusTestRegistry(t)
|
||||
|
||||
if got := StatusDisplay("done"); got != "Done ✅" {
|
||||
t.Errorf("StatusDisplay(%q) = %q, want %q", "done", got, "Done ✅")
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusDisplay_NoEmoji(t *testing.T) {
|
||||
config.ResetStatusRegistry([]workflow.StatusDef{
|
||||
{Key: "plain", Label: "Plain", Default: true},
|
||||
})
|
||||
t.Cleanup(func() { config.ClearStatusRegistry() })
|
||||
|
||||
if got := StatusDisplay("plain"); got != "Plain" {
|
||||
t.Errorf("StatusDisplay(%q) = %q, want %q (no emoji)", "plain", got, "Plain")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultStatus(t *testing.T) {
|
||||
setupStatusTestRegistry(t)
|
||||
|
||||
if got := DefaultStatus(); got != "backlog" {
|
||||
t.Errorf("DefaultStatus() = %q, want %q", got, "backlog")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDoneStatus(t *testing.T) {
|
||||
setupStatusTestRegistry(t)
|
||||
|
||||
if got := DoneStatus(); got != "done" {
|
||||
t.Errorf("DoneStatus() = %q, want %q", got, "done")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllStatuses(t *testing.T) {
|
||||
setupStatusTestRegistry(t)
|
||||
|
||||
all := AllStatuses()
|
||||
expected := []Status{"backlog", "ready", "in_progress", "review", "done"}
|
||||
if len(all) != len(expected) {
|
||||
t.Fatalf("AllStatuses() returned %d, want %d", len(all), len(expected))
|
||||
}
|
||||
for i, s := range all {
|
||||
if s != expected[i] {
|
||||
t.Errorf("AllStatuses()[%d] = %q, want %q", i, s, expected[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsActiveStatus(t *testing.T) {
|
||||
setupStatusTestRegistry(t)
|
||||
|
||||
if IsActiveStatus("backlog") {
|
||||
t.Error("expected backlog to not be active")
|
||||
}
|
||||
if !IsActiveStatus("ready") {
|
||||
t.Error("expected ready to be active")
|
||||
}
|
||||
if !IsActiveStatus("in_progress") {
|
||||
t.Error("expected in_progress to be active")
|
||||
}
|
||||
if IsActiveStatus("done") {
|
||||
t.Error("expected done to not be active")
|
||||
}
|
||||
}
|
||||
112
task/type.go
|
|
@ -1,89 +1,77 @@
|
|||
package task
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"fmt"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
"github.com/boolean-maybe/tiki/workflow"
|
||||
)
|
||||
|
||||
// Type represents the type of work item
|
||||
type Type string
|
||||
// Type is a type alias for workflow.TaskType.
|
||||
// This preserves compatibility: task.Type and workflow.TaskType are the same type.
|
||||
type Type = workflow.TaskType
|
||||
|
||||
// well-known built-in type constants.
|
||||
const (
|
||||
TypeStory Type = "story"
|
||||
TypeBug Type = "bug"
|
||||
TypeSpike Type = "spike"
|
||||
TypeEpic Type = "epic"
|
||||
TypeStory = workflow.TypeStory
|
||||
TypeBug = workflow.TypeBug
|
||||
TypeSpike = workflow.TypeSpike
|
||||
TypeEpic = workflow.TypeEpic
|
||||
)
|
||||
|
||||
type typeInfo struct {
|
||||
label string
|
||||
emoji string
|
||||
}
|
||||
|
||||
var types = map[string]typeInfo{
|
||||
"story": {label: "Story", emoji: "🌀"},
|
||||
"bug": {label: "Bug", emoji: "💥"},
|
||||
"spike": {label: "Spike", emoji: "🔍"},
|
||||
"epic": {label: "Epic", emoji: "🗂️"},
|
||||
"feature": {label: "Story", emoji: "🌀"},
|
||||
"task": {label: "Story", emoji: "🌀"},
|
||||
}
|
||||
|
||||
// normalizeType standardizes a raw type string.
|
||||
func normalizeType(s string) string {
|
||||
s = strings.ToLower(strings.TrimSpace(s))
|
||||
s = strings.ReplaceAll(s, "_", "")
|
||||
s = strings.ReplaceAll(s, "-", "")
|
||||
s = strings.ReplaceAll(s, " ", "")
|
||||
return s
|
||||
}
|
||||
|
||||
func ParseType(t string) (Type, bool) {
|
||||
normalized := normalizeType(t)
|
||||
switch normalized {
|
||||
case "bug":
|
||||
return TypeBug, true
|
||||
case "spike":
|
||||
return TypeSpike, true
|
||||
case "epic":
|
||||
return TypeEpic, true
|
||||
case "story", "feature", "task":
|
||||
return TypeStory, true
|
||||
default:
|
||||
return TypeStory, false
|
||||
// defaultTypeRegistry is built once from the built-in type definitions.
|
||||
// It serves as a fallback when config has not been initialized yet.
|
||||
var defaultTypeRegistry = func() *workflow.TypeRegistry {
|
||||
reg, err := workflow.NewTypeRegistry(workflow.DefaultTypeDefs())
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("task: building default type registry: %v", err))
|
||||
}
|
||||
return reg
|
||||
}()
|
||||
|
||||
// currentTypeRegistry returns the config-provided type registry when available,
|
||||
// falling back to the package-level default built from DefaultTypeDefs().
|
||||
func currentTypeRegistry() *workflow.TypeRegistry {
|
||||
if reg, ok := config.MaybeGetTypeRegistry(); ok {
|
||||
return reg
|
||||
}
|
||||
return defaultTypeRegistry
|
||||
}
|
||||
|
||||
// ParseType parses a raw string into a Type with validation.
|
||||
// Returns the canonical key and true if recognized (including aliases),
|
||||
// or (TypeStory, false) for unknown types.
|
||||
func ParseType(t string) (Type, bool) {
|
||||
return currentTypeRegistry().ParseType(t)
|
||||
}
|
||||
|
||||
// NormalizeType standardizes a raw type string into a Type.
|
||||
func NormalizeType(t string) Type {
|
||||
normalized, _ := ParseType(t)
|
||||
return normalized
|
||||
return currentTypeRegistry().NormalizeType(t)
|
||||
}
|
||||
|
||||
// TypeLabel returns a human-readable label for a task type.
|
||||
func TypeLabel(taskType Type) string {
|
||||
// Direct lookup using Type constant
|
||||
if info, ok := types[string(taskType)]; ok {
|
||||
return info.label
|
||||
}
|
||||
// Fallback to the raw string if unknown
|
||||
return string(taskType)
|
||||
return currentTypeRegistry().TypeLabel(taskType)
|
||||
}
|
||||
|
||||
// TypeEmoji returns the emoji for a task type.
|
||||
func TypeEmoji(taskType Type) string {
|
||||
// Direct lookup using Type constant
|
||||
if info, ok := types[string(taskType)]; ok {
|
||||
return info.emoji
|
||||
}
|
||||
return ""
|
||||
return currentTypeRegistry().TypeEmoji(taskType)
|
||||
}
|
||||
|
||||
// TypeDisplay returns a formatted display string with label and emoji.
|
||||
func TypeDisplay(taskType Type) string {
|
||||
label := TypeLabel(taskType)
|
||||
emoji := TypeEmoji(taskType)
|
||||
if emoji == "" {
|
||||
return label
|
||||
}
|
||||
return label + " " + emoji
|
||||
return currentTypeRegistry().TypeDisplay(taskType)
|
||||
}
|
||||
|
||||
// ParseDisplay reverses a TypeDisplay() string back to a canonical key.
|
||||
// Returns (key, true) on match, or (fallback, false) for unrecognized display strings.
|
||||
func ParseDisplay(display string) (Type, bool) {
|
||||
return currentTypeRegistry().ParseDisplay(display)
|
||||
}
|
||||
|
||||
// AllTypes returns the ordered list of all configured type keys.
|
||||
func AllTypes() []Type {
|
||||
return currentTypeRegistry().Keys()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,10 @@
|
|||
package task
|
||||
|
||||
import "testing"
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
)
|
||||
|
||||
func TestNormalizeType(t *testing.T) {
|
||||
tests := []struct {
|
||||
|
|
@ -104,3 +108,132 @@ func TestTypeDisplay(t *testing.T) {
|
|||
})
|
||||
}
|
||||
}
|
||||
|
||||
// TestTypeHelpers_FallbackWithoutConfig verifies that all type helpers work
|
||||
// when the config registry has not been initialized (fallback to defaults).
|
||||
func TestTypeHelpers_FallbackWithoutConfig(t *testing.T) {
|
||||
config.ClearStatusRegistry()
|
||||
t.Cleanup(func() {
|
||||
// restore for other tests in the package
|
||||
config.ResetStatusRegistry(testStatusDefs())
|
||||
})
|
||||
|
||||
t.Run("NormalizeType", func(t *testing.T) {
|
||||
if got := NormalizeType("bug"); got != TypeBug {
|
||||
t.Errorf("NormalizeType(%q) = %q, want %q", "bug", got, TypeBug)
|
||||
}
|
||||
if got := NormalizeType("feature"); got != TypeStory {
|
||||
t.Errorf("NormalizeType(%q) = %q, want %q (alias)", "feature", got, TypeStory)
|
||||
}
|
||||
if got := NormalizeType("unknown"); got != TypeStory {
|
||||
t.Errorf("NormalizeType(%q) = %q, want %q (fallback)", "unknown", got, TypeStory)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("ParseType", func(t *testing.T) {
|
||||
typ, ok := ParseType("epic")
|
||||
if !ok || typ != TypeEpic {
|
||||
t.Errorf("ParseType(%q) = (%q, %v), want (%q, true)", "epic", typ, ok, TypeEpic)
|
||||
}
|
||||
typ, ok = ParseType("nonsense")
|
||||
if ok {
|
||||
t.Errorf("ParseType(%q) returned ok=true for unknown type", "nonsense")
|
||||
}
|
||||
if typ != TypeStory {
|
||||
t.Errorf("ParseType(%q) fallback = %q, want %q", "nonsense", typ, TypeStory)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("TypeLabel", func(t *testing.T) {
|
||||
if got := TypeLabel(TypeBug); got != "Bug" {
|
||||
t.Errorf("TypeLabel(%q) = %q, want %q", TypeBug, got, "Bug")
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("TypeDisplay", func(t *testing.T) {
|
||||
if got := TypeDisplay(TypeSpike); got != "Spike 🔍" {
|
||||
t.Errorf("TypeDisplay(%q) = %q, want %q", TypeSpike, got, "Spike 🔍")
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func TestParseDisplay(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantType Type
|
||||
wantOK bool
|
||||
}{
|
||||
{"story display", "Story 🌀", TypeStory, true},
|
||||
{"bug display", "Bug 💥", TypeBug, true},
|
||||
{"spike display", "Spike 🔍", TypeSpike, true},
|
||||
{"epic display", "Epic 🗂️", TypeEpic, true},
|
||||
{"unknown display", "Unknown 🤷", TypeStory, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := ParseDisplay(tt.input)
|
||||
if ok != tt.wantOK {
|
||||
t.Errorf("ParseDisplay(%q) ok = %v, want %v", tt.input, ok, tt.wantOK)
|
||||
}
|
||||
if got != tt.wantType {
|
||||
t.Errorf("ParseDisplay(%q) = %q, want %q", tt.input, got, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllTypes(t *testing.T) {
|
||||
types := AllTypes()
|
||||
if len(types) == 0 {
|
||||
t.Fatal("AllTypes() returned empty list")
|
||||
}
|
||||
// verify well-known types are present
|
||||
found := make(map[Type]bool)
|
||||
for _, tp := range types {
|
||||
found[tp] = true
|
||||
}
|
||||
for _, want := range []Type{TypeStory, TypeBug, TypeSpike, TypeEpic} {
|
||||
if !found[want] {
|
||||
t.Errorf("AllTypes() missing %q", want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
input string
|
||||
wantType Type
|
||||
wantOK bool
|
||||
}{
|
||||
{"valid story", "story", TypeStory, true},
|
||||
{"valid bug", "bug", TypeBug, true},
|
||||
{"alias feature", "feature", TypeStory, true},
|
||||
{"unknown", "nonsense", TypeStory, false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
got, ok := ParseType(tt.input)
|
||||
if ok != tt.wantOK {
|
||||
t.Errorf("ParseType(%q) ok = %v, want %v", tt.input, ok, tt.wantOK)
|
||||
}
|
||||
if got != tt.wantType {
|
||||
t.Errorf("ParseType(%q) = %q, want %q", tt.input, got, tt.wantType)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// testStatusDefs returns the standard test status definitions.
|
||||
func testStatusDefs() []config.StatusDef {
|
||||
return []config.StatusDef{
|
||||
{Key: "backlog", Label: "Backlog", Emoji: "📥", Default: true},
|
||||
{Key: "ready", Label: "Ready", Emoji: "📋", Active: true},
|
||||
{Key: "in_progress", Label: "In Progress", Emoji: "⚙️", Active: true},
|
||||
{Key: "review", Label: "Review", Emoji: "👀", Active: true},
|
||||
{Key: "done", Label: "Done", Emoji: "✅", Done: true},
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,66 +1,143 @@
|
|||
package task
|
||||
|
||||
// Validator is the main validation interface
|
||||
type Validator interface {
|
||||
Validate(task *Task) ValidationErrors
|
||||
}
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
// FieldValidator validates a single field
|
||||
type FieldValidator interface {
|
||||
ValidateField(task *Task) *ValidationError
|
||||
}
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
)
|
||||
|
||||
// TaskValidator orchestrates all field validators
|
||||
type TaskValidator struct {
|
||||
validators []FieldValidator
|
||||
}
|
||||
// Priority validation constants
|
||||
const (
|
||||
MinPriority = 1
|
||||
MaxPriority = 5
|
||||
DefaultPriority = 3 // Medium
|
||||
)
|
||||
|
||||
// NewTaskValidator creates a validator with standard rules
|
||||
func NewTaskValidator() *TaskValidator {
|
||||
return &TaskValidator{
|
||||
validators: []FieldValidator{
|
||||
&TitleValidator{},
|
||||
&StatusValidator{},
|
||||
&TypeValidator{},
|
||||
&PriorityValidator{},
|
||||
&PointsValidator{},
|
||||
&DependsOnValidator{},
|
||||
&DueValidator{},
|
||||
&RecurrenceValidator{},
|
||||
// Assignee and Description have no constraints (always valid)
|
||||
},
|
||||
// ValidateTitle returns an error message if the task title is invalid.
|
||||
func ValidateTitle(t *Task) string {
|
||||
title := strings.TrimSpace(t.Title)
|
||||
if title == "" {
|
||||
return "title is required"
|
||||
}
|
||||
const maxTitleLength = 200
|
||||
if len(title) > maxTitleLength {
|
||||
return fmt.Sprintf("title exceeds maximum length of %d characters", maxTitleLength)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// Validate runs all validators and accumulates errors
|
||||
func (tv *TaskValidator) Validate(task *Task) ValidationErrors {
|
||||
var errors ValidationErrors
|
||||
// ValidateStatus returns an error message if the task status is invalid.
|
||||
func ValidateStatus(t *Task) string {
|
||||
if config.GetStatusRegistry().IsValid(string(t.Status)) {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("invalid status value: %s", t.Status)
|
||||
}
|
||||
|
||||
for _, validator := range tv.validators {
|
||||
if err := validator.ValidateField(task); err != nil {
|
||||
errors = append(errors, err)
|
||||
// ValidateType returns an error message if the task type is invalid.
|
||||
func ValidateType(t *Task) string {
|
||||
if currentTypeRegistry().IsValid(t.Type) {
|
||||
return ""
|
||||
}
|
||||
return fmt.Sprintf("invalid type value: %s", t.Type)
|
||||
}
|
||||
|
||||
// ValidatePriority returns an error message if the task priority is out of range.
|
||||
func ValidatePriority(t *Task) string {
|
||||
if t.Priority < MinPriority || t.Priority > MaxPriority {
|
||||
return fmt.Sprintf("priority must be between %d and %d", MinPriority, MaxPriority)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ValidatePoints returns an error message if story points are out of range.
|
||||
func ValidatePoints(t *Task) string {
|
||||
if t.Points == 0 {
|
||||
return ""
|
||||
}
|
||||
const minPoints = 1
|
||||
maxPoints := config.GetMaxPoints()
|
||||
if t.Points < minPoints || t.Points > maxPoints {
|
||||
return fmt.Sprintf("story points must be between %d and %d", minPoints, maxPoints)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ValidateDependsOn returns an error message if any dependency ID is malformed.
|
||||
func ValidateDependsOn(t *Task) string {
|
||||
for _, dep := range t.DependsOn {
|
||||
if !IsValidTikiIDFormat(dep) {
|
||||
return fmt.Sprintf("invalid tiki ID format: %s (expected TIKI-XXXXXX)", dep)
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
return ""
|
||||
}
|
||||
|
||||
// ValidateField validates a single field by name
|
||||
func (tv *TaskValidator) ValidateField(task *Task, fieldName string) *ValidationError {
|
||||
for _, validator := range tv.validators {
|
||||
if err := validator.ValidateField(task); err != nil && err.Field == fieldName {
|
||||
return err
|
||||
// ValidateDue returns an error message if the due date is not normalized to midnight UTC.
|
||||
func ValidateDue(t *Task) string {
|
||||
if t.Due.IsZero() {
|
||||
return ""
|
||||
}
|
||||
if t.Due.Hour() != 0 || t.Due.Minute() != 0 || t.Due.Second() != 0 || t.Due.Nanosecond() != 0 || t.Due.Location() != time.UTC {
|
||||
return "due date must be normalized to midnight UTC (use date-only format)"
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// ValidateRecurrence returns an error message if the recurrence pattern is invalid.
|
||||
func ValidateRecurrence(t *Task) string {
|
||||
if t.Recurrence == RecurrenceNone {
|
||||
return ""
|
||||
}
|
||||
if !IsValidRecurrence(t.Recurrence) {
|
||||
return fmt.Sprintf("invalid recurrence pattern: %s", t.Recurrence)
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
// IsValidPriority checks if a priority value is within the valid range.
|
||||
func IsValidPriority(priority int) bool {
|
||||
return priority >= MinPriority && priority <= MaxPriority
|
||||
}
|
||||
|
||||
// IsValidPoints checks if a points value is within the valid range.
|
||||
func IsValidPoints(points int) bool {
|
||||
if points == 0 {
|
||||
return true
|
||||
}
|
||||
if points < 0 {
|
||||
return false
|
||||
}
|
||||
return points <= config.GetMaxPoints()
|
||||
}
|
||||
|
||||
// IsValidTikiIDFormat checks if a string matches the TIKI-XXXXXX format
|
||||
// where X is an uppercase alphanumeric character.
|
||||
func IsValidTikiIDFormat(id string) bool {
|
||||
if len(id) != 11 || id[:5] != "TIKI-" {
|
||||
return false
|
||||
}
|
||||
for _, c := range id[5:] {
|
||||
if (c < 'A' || c > 'Z') && (c < '0' || c > '9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return true
|
||||
}
|
||||
|
||||
// QuickValidate is a convenience function for quick validation
|
||||
func QuickValidate(task *Task) ValidationErrors {
|
||||
return NewTaskValidator().Validate(task)
|
||||
}
|
||||
|
||||
// IsValid returns true if the task passes all validation rules
|
||||
func IsValid(task *Task) bool {
|
||||
return !QuickValidate(task).HasErrors()
|
||||
// AllValidators returns the complete list of field validation functions.
|
||||
// Each returns an error message (empty string = valid).
|
||||
func AllValidators() []func(*Task) string {
|
||||
return []func(*Task) string{
|
||||
ValidateTitle,
|
||||
ValidateStatus,
|
||||
ValidateType,
|
||||
ValidatePriority,
|
||||
ValidatePoints,
|
||||
ValidateDependsOn,
|
||||
ValidateDue,
|
||||
ValidateRecurrence,
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,65 +0,0 @@
|
|||
package task
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// ValidationError represents a field-level validation failure with rich context
|
||||
type ValidationError struct {
|
||||
Field string // Field name (e.g., "title", "priority")
|
||||
Value any // The invalid value
|
||||
Code ErrorCode // Machine-readable error code
|
||||
Message string // Human-readable message
|
||||
}
|
||||
|
||||
func (e *ValidationError) Error() string {
|
||||
return fmt.Sprintf("%s: %s", e.Field, e.Message)
|
||||
}
|
||||
|
||||
// ErrorCode represents specific validation failure types
|
||||
type ErrorCode string
|
||||
|
||||
const (
|
||||
ErrCodeRequired ErrorCode = "required"
|
||||
ErrCodeTooLong ErrorCode = "too_long"
|
||||
ErrCodeTooShort ErrorCode = "too_short"
|
||||
ErrCodeOutOfRange ErrorCode = "out_of_range"
|
||||
ErrCodeInvalidEnum ErrorCode = "invalid_enum"
|
||||
ErrCodeInvalidFormat ErrorCode = "invalid_format"
|
||||
)
|
||||
|
||||
// ValidationErrors is a collection of field-level errors
|
||||
type ValidationErrors []*ValidationError
|
||||
|
||||
func (ve ValidationErrors) Error() string {
|
||||
if len(ve) == 0 {
|
||||
return "no validation errors"
|
||||
}
|
||||
var msgs []string
|
||||
for _, err := range ve {
|
||||
msgs = append(msgs, err.Error())
|
||||
}
|
||||
return strings.Join(msgs, "; ")
|
||||
}
|
||||
|
||||
// HasErrors returns true if there are any validation errors
|
||||
func (ve ValidationErrors) HasErrors() bool {
|
||||
return len(ve) > 0
|
||||
}
|
||||
|
||||
// ByField returns errors for a specific field
|
||||
func (ve ValidationErrors) ByField(field string) []*ValidationError {
|
||||
var result []*ValidationError
|
||||
for _, err := range ve {
|
||||
if err.Field == field {
|
||||
result = append(result, err)
|
||||
}
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
// HasField returns true if the field has any errors
|
||||
func (ve ValidationErrors) HasField(field string) bool {
|
||||
return len(ve.ByField(field)) > 0
|
||||
}
|
||||
|
|
@ -1,213 +0,0 @@
|
|||
package task
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"slices"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
)
|
||||
|
||||
// TitleValidator validates task title
|
||||
type TitleValidator struct{}
|
||||
|
||||
func (v *TitleValidator) ValidateField(task *Task) *ValidationError {
|
||||
title := strings.TrimSpace(task.Title)
|
||||
|
||||
if title == "" {
|
||||
return &ValidationError{
|
||||
Field: "title",
|
||||
Value: task.Title,
|
||||
Code: ErrCodeRequired,
|
||||
Message: "title is required",
|
||||
}
|
||||
}
|
||||
|
||||
// Optional: max length check (reasonable limit for UI display)
|
||||
const maxTitleLength = 200
|
||||
if len(title) > maxTitleLength {
|
||||
return &ValidationError{
|
||||
Field: "title",
|
||||
Value: task.Title,
|
||||
Code: ErrCodeTooLong,
|
||||
Message: fmt.Sprintf("title exceeds maximum length of %d characters", maxTitleLength),
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// StatusValidator validates task status enum
|
||||
type StatusValidator struct{}
|
||||
|
||||
func (v *StatusValidator) ValidateField(task *Task) *ValidationError {
|
||||
if config.GetStatusRegistry().IsValid(string(task.Status)) {
|
||||
return nil
|
||||
}
|
||||
|
||||
return &ValidationError{
|
||||
Field: "status",
|
||||
Value: task.Status,
|
||||
Code: ErrCodeInvalidEnum,
|
||||
Message: fmt.Sprintf("invalid status value: %s", task.Status),
|
||||
}
|
||||
}
|
||||
|
||||
// TypeValidator validates task type enum
|
||||
type TypeValidator struct{}
|
||||
|
||||
func (v *TypeValidator) ValidateField(task *Task) *ValidationError {
|
||||
validTypes := []Type{
|
||||
TypeStory,
|
||||
TypeBug,
|
||||
TypeSpike,
|
||||
TypeEpic,
|
||||
}
|
||||
|
||||
if slices.Contains(validTypes, task.Type) {
|
||||
return nil // Valid
|
||||
}
|
||||
|
||||
return &ValidationError{
|
||||
Field: "type",
|
||||
Value: task.Type,
|
||||
Code: ErrCodeInvalidEnum,
|
||||
Message: fmt.Sprintf("invalid type value: %s", task.Type),
|
||||
}
|
||||
}
|
||||
|
||||
// Priority validation constants
|
||||
const (
|
||||
MinPriority = 1
|
||||
MaxPriority = 5
|
||||
DefaultPriority = 3 // Medium
|
||||
)
|
||||
|
||||
func IsValidPriority(priority int) bool {
|
||||
return priority >= MinPriority && priority <= MaxPriority
|
||||
}
|
||||
|
||||
func IsValidPoints(points int) bool {
|
||||
if points == 0 {
|
||||
return true
|
||||
}
|
||||
if points < 0 {
|
||||
return false
|
||||
}
|
||||
return points <= config.GetMaxPoints()
|
||||
}
|
||||
|
||||
// PriorityValidator validates priority range (1-5)
|
||||
type PriorityValidator struct{}
|
||||
|
||||
func (v *PriorityValidator) ValidateField(task *Task) *ValidationError {
|
||||
if task.Priority < MinPriority || task.Priority > MaxPriority {
|
||||
return &ValidationError{
|
||||
Field: "priority",
|
||||
Value: task.Priority,
|
||||
Code: ErrCodeOutOfRange,
|
||||
Message: fmt.Sprintf("priority must be between %d and %d", MinPriority, MaxPriority),
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// PointsValidator validates story points range (1-maxPoints from config)
|
||||
type PointsValidator struct{}
|
||||
|
||||
func (v *PointsValidator) ValidateField(task *Task) *ValidationError {
|
||||
const minPoints = 1
|
||||
maxPoints := config.GetMaxPoints()
|
||||
|
||||
// Points of 0 are valid (means not estimated yet)
|
||||
if task.Points == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
if task.Points < minPoints || task.Points > maxPoints {
|
||||
return &ValidationError{
|
||||
Field: "points",
|
||||
Value: task.Points,
|
||||
Code: ErrCodeOutOfRange,
|
||||
Message: fmt.Sprintf("story points must be between %d and %d", minPoints, maxPoints),
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// DependsOnValidator validates dependsOn tiki ID format
|
||||
type DependsOnValidator struct{}
|
||||
|
||||
func (v *DependsOnValidator) ValidateField(task *Task) *ValidationError {
|
||||
for _, dep := range task.DependsOn {
|
||||
if !isValidTikiIDFormat(dep) {
|
||||
return &ValidationError{
|
||||
Field: "dependsOn",
|
||||
Value: dep,
|
||||
Code: ErrCodeInvalidFormat,
|
||||
Message: fmt.Sprintf("invalid tiki ID format: %s (expected TIKI-XXXXXX)", dep),
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// isValidTikiIDFormat checks if a string matches the TIKI-XXXXXX format
|
||||
// where X is an uppercase alphanumeric character.
|
||||
func isValidTikiIDFormat(id string) bool {
|
||||
if len(id) != 11 || id[:5] != "TIKI-" {
|
||||
return false
|
||||
}
|
||||
for _, c := range id[5:] {
|
||||
if (c < 'A' || c > 'Z') && (c < '0' || c > '9') {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// DueValidator validates due date is normalized to midnight UTC
|
||||
type DueValidator struct{}
|
||||
|
||||
func (v *DueValidator) ValidateField(task *Task) *ValidationError {
|
||||
// Zero time (no due date) is valid
|
||||
if task.Due.IsZero() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Validate date is normalized to midnight UTC
|
||||
if task.Due.Hour() != 0 || task.Due.Minute() != 0 || task.Due.Second() != 0 || task.Due.Nanosecond() != 0 || task.Due.Location() != time.UTC {
|
||||
return &ValidationError{
|
||||
Field: "due",
|
||||
Value: task.Due,
|
||||
Code: ErrCodeInvalidFormat,
|
||||
Message: "due date must be normalized to midnight UTC (use date-only format)",
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// RecurrenceValidator validates recurrence pattern
|
||||
type RecurrenceValidator struct{}
|
||||
|
||||
func (v *RecurrenceValidator) ValidateField(task *Task) *ValidationError {
|
||||
if task.Recurrence == RecurrenceNone {
|
||||
return nil
|
||||
}
|
||||
if !IsValidRecurrence(task.Recurrence) {
|
||||
return &ValidationError{
|
||||
Field: "recurrence",
|
||||
Value: task.Recurrence,
|
||||
Code: ErrCodeInvalidFormat,
|
||||
Message: fmt.Sprintf("invalid recurrence pattern: %s", task.Recurrence),
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// AssigneeValidator - no validation needed (any string is valid)
|
||||
// DescriptionValidator - no validation needed (any string is valid)
|
||||
|
|
@ -6,11 +6,12 @@ import (
|
|||
"time"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
"github.com/boolean-maybe/tiki/workflow"
|
||||
)
|
||||
|
||||
func init() {
|
||||
// Set up the default status registry for tests.
|
||||
config.ResetStatusRegistry([]config.StatusDef{
|
||||
// set up the default status registry for tests.
|
||||
config.ResetStatusRegistry([]workflow.StatusDef{
|
||||
{Key: "backlog", Label: "Backlog", Emoji: "📥", Default: true},
|
||||
{Key: "ready", Label: "Ready", Emoji: "📋", Active: true},
|
||||
{Key: "in_progress", Label: "In Progress", Emoji: "⚙️", Active: true},
|
||||
|
|
@ -19,65 +20,37 @@ func init() {
|
|||
})
|
||||
}
|
||||
|
||||
func TestTitleValidator(t *testing.T) {
|
||||
func TestValidateTitle(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
task *Task
|
||||
wantErr bool
|
||||
errCode ErrorCode
|
||||
}{
|
||||
{
|
||||
name: "valid title",
|
||||
task: &Task{Title: "Valid Task"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "empty title",
|
||||
task: &Task{Title: ""},
|
||||
wantErr: true,
|
||||
errCode: ErrCodeRequired,
|
||||
},
|
||||
{
|
||||
name: "whitespace title",
|
||||
task: &Task{Title: " "},
|
||||
wantErr: true,
|
||||
errCode: ErrCodeRequired,
|
||||
},
|
||||
{
|
||||
name: "very long title",
|
||||
task: &Task{Title: strings.Repeat("a", 201)},
|
||||
wantErr: true,
|
||||
errCode: ErrCodeTooLong,
|
||||
},
|
||||
{
|
||||
name: "max length title",
|
||||
task: &Task{Title: strings.Repeat("a", 200)},
|
||||
wantErr: false,
|
||||
},
|
||||
{"valid title", &Task{Title: "Valid Task"}, false},
|
||||
{"empty title", &Task{Title: ""}, true},
|
||||
{"whitespace title", &Task{Title: " "}, true},
|
||||
{"very long title", &Task{Title: strings.Repeat("a", 201)}, true},
|
||||
{"max length title", &Task{Title: strings.Repeat("a", 200)}, false},
|
||||
}
|
||||
|
||||
validator := &TitleValidator{}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validator.ValidateField(tt.task)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("expected error: %v, got: %v", tt.wantErr, err)
|
||||
}
|
||||
if err != nil && err.Code != tt.errCode {
|
||||
t.Errorf("expected error code: %v, got: %v", tt.errCode, err.Code)
|
||||
msg := ValidateTitle(tt.task)
|
||||
if (msg != "") != tt.wantErr {
|
||||
t.Errorf("ValidateTitle() = %q, wantErr %v", msg, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestStatusValidator(t *testing.T) {
|
||||
func TestValidateStatus(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
task *Task
|
||||
wantErr bool
|
||||
}{
|
||||
{"valid backlog", &Task{Status: StatusBacklog}, false},
|
||||
{"valid todo", &Task{Status: StatusReady}, false},
|
||||
{"valid ready", &Task{Status: StatusReady}, false},
|
||||
{"valid in_progress", &Task{Status: StatusInProgress}, false},
|
||||
{"valid review", &Task{Status: StatusReview}, false},
|
||||
{"valid done", &Task{Status: StatusDone}, false},
|
||||
|
|
@ -85,21 +58,17 @@ func TestStatusValidator(t *testing.T) {
|
|||
{"empty status", &Task{Status: ""}, true},
|
||||
}
|
||||
|
||||
validator := &StatusValidator{}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validator.ValidateField(tt.task)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("expected error: %v, got: %v", tt.wantErr, err)
|
||||
}
|
||||
if err != nil && err.Code != ErrCodeInvalidEnum {
|
||||
t.Errorf("expected error code: %v, got: %v", ErrCodeInvalidEnum, err.Code)
|
||||
msg := ValidateStatus(tt.task)
|
||||
if (msg != "") != tt.wantErr {
|
||||
t.Errorf("ValidateStatus() = %q, wantErr %v", msg, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTypeValidator(t *testing.T) {
|
||||
func TestValidateType(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
task *Task
|
||||
|
|
@ -113,21 +82,17 @@ func TestTypeValidator(t *testing.T) {
|
|||
{"empty type", &Task{Type: ""}, true},
|
||||
}
|
||||
|
||||
validator := &TypeValidator{}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validator.ValidateField(tt.task)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("expected error: %v, got: %v", tt.wantErr, err)
|
||||
}
|
||||
if err != nil && err.Code != ErrCodeInvalidEnum {
|
||||
t.Errorf("expected error code: %v, got: %v", ErrCodeInvalidEnum, err.Code)
|
||||
msg := ValidateType(tt.task)
|
||||
if (msg != "") != tt.wantErr {
|
||||
t.Errorf("ValidateType() = %q, wantErr %v", msg, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPriorityValidator(t *testing.T) {
|
||||
func TestValidatePriority(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
task *Task
|
||||
|
|
@ -142,21 +107,17 @@ func TestPriorityValidator(t *testing.T) {
|
|||
{"invalid priority 10", &Task{Priority: 10}, true},
|
||||
}
|
||||
|
||||
validator := &PriorityValidator{}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validator.ValidateField(tt.task)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("expected error: %v, got: %v", tt.wantErr, err)
|
||||
}
|
||||
if err != nil && err.Code != ErrCodeOutOfRange {
|
||||
t.Errorf("expected error code: %v, got: %v", ErrCodeOutOfRange, err.Code)
|
||||
msg := ValidatePriority(tt.task)
|
||||
if (msg != "") != tt.wantErr {
|
||||
t.Errorf("ValidatePriority() = %q, wantErr %v", msg, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPointsValidator(t *testing.T) {
|
||||
func TestValidatePoints(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
task *Task
|
||||
|
|
@ -171,21 +132,17 @@ func TestPointsValidator(t *testing.T) {
|
|||
{"invalid points 100", &Task{Points: 100}, true},
|
||||
}
|
||||
|
||||
validator := &PointsValidator{}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validator.ValidateField(tt.task)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("expected error: %v, got: %v", tt.wantErr, err)
|
||||
}
|
||||
if err != nil && err.Code != ErrCodeOutOfRange {
|
||||
t.Errorf("expected error code: %v, got: %v", ErrCodeOutOfRange, err.Code)
|
||||
msg := ValidatePoints(tt.task)
|
||||
if (msg != "") != tt.wantErr {
|
||||
t.Errorf("ValidatePoints() = %q, wantErr %v", msg, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestDependsOnValidator(t *testing.T) {
|
||||
func TestValidateDependsOn(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
task *Task
|
||||
|
|
@ -202,164 +159,21 @@ func TestDependsOnValidator(t *testing.T) {
|
|||
{"mixed valid and invalid", &Task{DependsOn: []string{"TIKI-ABC123", "bad-id"}}, true},
|
||||
}
|
||||
|
||||
validator := &DependsOnValidator{}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validator.ValidateField(tt.task)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("expected error: %v, got: %v", tt.wantErr, err)
|
||||
}
|
||||
if err != nil && err.Code != ErrCodeInvalidFormat {
|
||||
t.Errorf("expected error code: %v, got: %v", ErrCodeInvalidFormat, err.Code)
|
||||
msg := ValidateDependsOn(tt.task)
|
||||
if (msg != "") != tt.wantErr {
|
||||
t.Errorf("ValidateDependsOn() = %q, wantErr %v", msg, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskValidator_MultipleErrors(t *testing.T) {
|
||||
// Task with multiple validation errors
|
||||
task := &Task{
|
||||
Title: "", // Invalid: empty
|
||||
Status: "invalid", // Invalid: not a valid enum
|
||||
Type: "bad", // Invalid: not a valid enum
|
||||
Priority: 10, // Invalid: out of range
|
||||
Points: -5, // Invalid: negative
|
||||
}
|
||||
|
||||
errors := task.Validate()
|
||||
|
||||
if !errors.HasErrors() {
|
||||
t.Fatal("expected validation errors, got none")
|
||||
}
|
||||
|
||||
expectedErrors := 5
|
||||
if len(errors) != expectedErrors {
|
||||
t.Errorf("expected %d errors, got %d", expectedErrors, len(errors))
|
||||
}
|
||||
|
||||
// Check that each field has an error
|
||||
if !errors.HasField("title") {
|
||||
t.Error("expected title error")
|
||||
}
|
||||
if !errors.HasField("status") {
|
||||
t.Error("expected status error")
|
||||
}
|
||||
if !errors.HasField("type") {
|
||||
t.Error("expected type error")
|
||||
}
|
||||
if !errors.HasField("priority") {
|
||||
t.Error("expected priority error")
|
||||
}
|
||||
if !errors.HasField("points") {
|
||||
t.Error("expected points error")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskValidator_ValidTask(t *testing.T) {
|
||||
task := &Task{
|
||||
Title: "Valid Task",
|
||||
Status: StatusReady,
|
||||
Type: TypeStory,
|
||||
Priority: 3,
|
||||
Points: 5,
|
||||
}
|
||||
|
||||
errors := task.Validate()
|
||||
|
||||
if errors.HasErrors() {
|
||||
t.Errorf("expected no errors, got: %v", errors)
|
||||
}
|
||||
|
||||
if !task.IsValid() {
|
||||
t.Error("expected task to be valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestTaskValidator_SingleFieldValidation(t *testing.T) {
|
||||
task := &Task{
|
||||
Priority: 10, // Invalid
|
||||
}
|
||||
|
||||
err := task.ValidateField("priority")
|
||||
if err == nil {
|
||||
t.Fatal("expected validation error for priority field")
|
||||
}
|
||||
|
||||
if err.Field != "priority" {
|
||||
t.Errorf("expected field 'priority', got '%s'", err.Field)
|
||||
}
|
||||
|
||||
if err.Code != ErrCodeOutOfRange {
|
||||
t.Errorf("expected error code %v, got %v", ErrCodeOutOfRange, err.Code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationErrors_ByField(t *testing.T) {
|
||||
errors := ValidationErrors{
|
||||
{Field: "title", Message: "title error"},
|
||||
{Field: "priority", Message: "priority error 1"},
|
||||
{Field: "priority", Message: "priority error 2"},
|
||||
}
|
||||
|
||||
titleErrors := errors.ByField("title")
|
||||
if len(titleErrors) != 1 {
|
||||
t.Errorf("expected 1 title error, got %d", len(titleErrors))
|
||||
}
|
||||
|
||||
priorityErrors := errors.ByField("priority")
|
||||
if len(priorityErrors) != 2 {
|
||||
t.Errorf("expected 2 priority errors, got %d", len(priorityErrors))
|
||||
}
|
||||
|
||||
nonExistentErrors := errors.ByField("nonexistent")
|
||||
if len(nonExistentErrors) != 0 {
|
||||
t.Errorf("expected 0 errors for nonexistent field, got %d", len(nonExistentErrors))
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationError_Error(t *testing.T) {
|
||||
err := &ValidationError{
|
||||
Field: "title",
|
||||
Value: "",
|
||||
Code: ErrCodeRequired,
|
||||
Message: "title is required",
|
||||
}
|
||||
|
||||
expected := "title: title is required"
|
||||
if err.Error() != expected {
|
||||
t.Errorf("expected error string '%s', got '%s'", expected, err.Error())
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationErrors_Error(t *testing.T) {
|
||||
errors := ValidationErrors{
|
||||
{Field: "title", Message: "title is required"},
|
||||
{Field: "priority", Message: "priority must be between 1 and 5"},
|
||||
}
|
||||
|
||||
errStr := errors.Error()
|
||||
if !strings.Contains(errStr, "title is required") {
|
||||
t.Error("error string should contain title message")
|
||||
}
|
||||
if !strings.Contains(errStr, "priority must be between 1 and 5") {
|
||||
t.Error("error string should contain priority message")
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidationErrors_Error_Empty(t *testing.T) {
|
||||
var ve ValidationErrors
|
||||
got := ve.Error()
|
||||
if got != "no validation errors" {
|
||||
t.Errorf("Error() = %q, want %q", got, "no validation errors")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDueValidator(t *testing.T) {
|
||||
func TestValidateDue(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
task *Task
|
||||
wantErr bool
|
||||
errCode ErrorCode
|
||||
}{
|
||||
{
|
||||
name: "no due date (zero time)",
|
||||
|
|
@ -387,76 +201,98 @@ func TestDueValidator(t *testing.T) {
|
|||
Due: mustParseDateTime("2026-03-16T15:04:05Z"),
|
||||
},
|
||||
wantErr: true,
|
||||
errCode: ErrCodeInvalidFormat,
|
||||
},
|
||||
}
|
||||
|
||||
validator := &DueValidator{}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validator.ValidateField(tt.task)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("DueValidator.ValidateField() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
}
|
||||
if err != nil && err.Code != tt.errCode {
|
||||
t.Errorf("DueValidator.ValidateField() errCode = %v, want %v", err.Code, tt.errCode)
|
||||
msg := ValidateDue(tt.task)
|
||||
if (msg != "") != tt.wantErr {
|
||||
t.Errorf("ValidateDue() = %q, wantErr %v", msg, tt.wantErr)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRecurrenceValidator(t *testing.T) {
|
||||
func TestValidateRecurrence(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
task *Task
|
||||
wantErr bool
|
||||
errCode ErrorCode
|
||||
}{
|
||||
{
|
||||
name: "empty recurrence (none)",
|
||||
task: &Task{Recurrence: RecurrenceNone},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid daily",
|
||||
task: &Task{Recurrence: RecurrenceDaily},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid weekly monday",
|
||||
task: &Task{Recurrence: "0 0 * * MON"},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "valid monthly",
|
||||
task: &Task{Recurrence: RecurrenceMonthly},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "invalid cron pattern",
|
||||
task: &Task{Recurrence: "*/5 * * * *"},
|
||||
wantErr: true,
|
||||
errCode: ErrCodeInvalidFormat,
|
||||
},
|
||||
{
|
||||
name: "invalid string",
|
||||
task: &Task{Recurrence: "every day"},
|
||||
wantErr: true,
|
||||
errCode: ErrCodeInvalidFormat,
|
||||
},
|
||||
{"empty recurrence (none)", &Task{Recurrence: RecurrenceNone}, false},
|
||||
{"valid daily", &Task{Recurrence: RecurrenceDaily}, false},
|
||||
{"valid weekly monday", &Task{Recurrence: "0 0 * * MON"}, false},
|
||||
{"valid monthly", &Task{Recurrence: RecurrenceMonthly}, false},
|
||||
{"invalid cron pattern", &Task{Recurrence: "*/5 * * * *"}, true},
|
||||
{"invalid string", &Task{Recurrence: "every day"}, true},
|
||||
}
|
||||
|
||||
validator := &RecurrenceValidator{}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validator.ValidateField(tt.task)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("RecurrenceValidator.ValidateField() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
msg := ValidateRecurrence(tt.task)
|
||||
if (msg != "") != tt.wantErr {
|
||||
t.Errorf("ValidateRecurrence() = %q, wantErr %v", msg, tt.wantErr)
|
||||
}
|
||||
if err != nil && err.Code != tt.errCode {
|
||||
t.Errorf("RecurrenceValidator.ValidateField() errCode = %v, want %v", err.Code, tt.errCode)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllValidators_MultipleErrors(t *testing.T) {
|
||||
tk := &Task{
|
||||
Title: "", // invalid: empty
|
||||
Status: "invalid", // invalid: not a valid enum
|
||||
Type: "bad", // invalid: not a valid enum
|
||||
Priority: 10, // invalid: out of range
|
||||
Points: -5, // invalid: negative
|
||||
}
|
||||
|
||||
var errors []string
|
||||
for _, fn := range AllValidators() {
|
||||
if msg := fn(tk); msg != "" {
|
||||
errors = append(errors, msg)
|
||||
}
|
||||
}
|
||||
|
||||
if len(errors) != 5 {
|
||||
t.Errorf("expected 5 errors, got %d: %v", len(errors), errors)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllValidators_ValidTask(t *testing.T) {
|
||||
tk := &Task{
|
||||
Title: "Valid Task",
|
||||
Status: StatusReady,
|
||||
Type: TypeStory,
|
||||
Priority: 3,
|
||||
Points: 5,
|
||||
}
|
||||
|
||||
for _, fn := range AllValidators() {
|
||||
if msg := fn(tk); msg != "" {
|
||||
t.Errorf("unexpected validation error: %s", msg)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidTikiIDFormat(t *testing.T) {
|
||||
tests := []struct {
|
||||
id string
|
||||
want bool
|
||||
}{
|
||||
{"TIKI-ABC123", true},
|
||||
{"TIKI-000000", true},
|
||||
{"tiki-abc123", false},
|
||||
{"TASK-ABC123", false},
|
||||
{"TIKI-ABC", false},
|
||||
{"TIKI-ABC1234", false},
|
||||
{"TIKI-ABC12!", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.id, func(t *testing.T) {
|
||||
if got := IsValidTikiIDFormat(tt.id); got != tt.want {
|
||||
t.Errorf("IsValidTikiIDFormat(%q) = %v, want %v", tt.id, got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
|
|
@ -478,3 +314,41 @@ func mustParseDateTime(s string) time.Time {
|
|||
}
|
||||
return t
|
||||
}
|
||||
|
||||
func TestIsValidPriority(t *testing.T) {
|
||||
tests := []struct {
|
||||
priority int
|
||||
want bool
|
||||
}{
|
||||
{0, false},
|
||||
{1, true},
|
||||
{3, true},
|
||||
{5, true},
|
||||
{6, false},
|
||||
{-1, false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := IsValidPriority(tt.priority); got != tt.want {
|
||||
t.Errorf("IsValidPriority(%d) = %v, want %v", tt.priority, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsValidPoints(t *testing.T) {
|
||||
tests := []struct {
|
||||
points int
|
||||
want bool
|
||||
}{
|
||||
{0, true}, // unestimated is valid
|
||||
{-1, false}, // negative
|
||||
{1, true},
|
||||
{5, true},
|
||||
{10, true}, // max default
|
||||
{11, false}, // over max
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := IsValidPoints(tt.points); got != tt.want {
|
||||
t.Errorf("IsValidPoints(%d) = %v, want %v", tt.points, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||