Merge pull request #46 from boolean-maybe/feature/ruki

feature/ruki
This commit is contained in:
boolean-maybe 2026-04-08 11:40:07 -04:00 committed by GitHub
commit 381f98c3af
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
111 changed files with 33497 additions and 1075 deletions

View file

@ -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)

View 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
```

View 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&lt;str&gt;</text>
<text class="hdr" x="694" y="51" text-anchor="middle">list&lt;ref&gt;</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&lt;str&gt;</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&lt;str&gt;</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&lt;str&gt;</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&lt;ref&gt;</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&lt;ref&gt;</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&lt;str&gt;</text>
<text class="hdr" x="694" y="51" text-anchor="middle">list&lt;ref&gt;</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&lt;str&gt;</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&lt;str&gt;</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&lt;str&gt;</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&lt;ref&gt;</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&lt;ref&gt;</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&lt;ref&gt; + also accepts bare id/ref values on the right side</text>
</svg>

After

Width:  |  Height:  |  Size: 14 KiB

View 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

View 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

View 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

View 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 (260460) === -->
<!-- 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 (490680) === -->
<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 (710830) === -->
<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

View 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

View 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

View 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.

View 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

View 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.

View 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"
```
![Qualifier scope by context](images/qualifier-scope.svg)
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`
![Binary operator type resolution](images/binary-op-types.svg)
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).

View 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 ) ;
```
![Statement grammar railroad diagram](images/stmt-railroad.svg)
![Trigger grammar railroad diagram](images/trigger-railroad.svg)
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 ;
```
![Condition grammar railroad diagram](images/cond-railroad.svg)
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 ;
```
![Expression grammar railroad diagram](images/expr-railroad.svg)
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).

View 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.

View 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.

View 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
![Validation pipeline](images/validation-pipeline.svg)
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
```

View file

@ -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

View file

@ -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)
}

View file

@ -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
View 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
View 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)
}
}

View file

@ -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
}
}

View file

@ -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) {

View file

@ -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)

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}
}

View file

@ -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
}

View file

@ -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)

View file

@ -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
View file

@ -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
View file

@ -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=

View file

@ -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,

View file

@ -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,

View file

@ -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.

View file

@ -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)
}

View 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()
}

View 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)
}
}
}

View 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
}

View 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")
}
}

View 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
}
}

View 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)
}
}

View file

@ -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
View file

@ -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)

View file

@ -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
}

View file

@ -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)
}
}

View file

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

View file

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

File diff suppressed because it is too large Load diff

74
ruki/executor_runtime.go Normal file
View 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")
)

View 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

File diff suppressed because it is too large Load diff

210
ruki/grammar.go Normal file
View 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
View 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`
}

View 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
View 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
View 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
View 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
View 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

File diff suppressed because it is too large Load diff

147
ruki/parser.go Normal file
View 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
View 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
View 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
View 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
}
}

File diff suppressed because it is too large Load diff

354
ruki/time_trigger_test.go Normal file
View 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
View 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
}

File diff suppressed because it is too large Load diff

354
ruki/trigger_test.go Normal file
View 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
View 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

File diff suppressed because it is too large Load diff

9
service/build.go Normal file
View 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
View 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)
}
}

View 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) {}

View 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")
}
}

View 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
View 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,
}
}

View 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)
}

File diff suppressed because it is too large Load diff

29
service/validators.go Normal file
View 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
}
}

View 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)
}
}

View file

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

View file

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

View file

@ -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

View file

@ -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},

View file

@ -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,

View file

@ -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
View 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")
}
}

View file

@ -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
View 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")
}
}

View file

@ -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()
}

View file

@ -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},
}
}

View file

@ -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,
}
}

View file

@ -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
}

View file

@ -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)

View file

@ -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)
}
}
}

Some files were not shown because too many files have changed in this diff Show more