Compare commits
134 commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1bf8bd4d9 | ||
|
|
41883b510d | ||
|
|
4891de4163 | ||
|
|
8a68544ac9 | ||
|
|
f4e425b08b | ||
|
|
129f847622 | ||
|
|
7abddcd4c6 | ||
|
|
ef25f5ff98 | ||
|
|
48e796877e | ||
|
|
8838c29149 | ||
|
|
0312ad8fca | ||
|
|
232737940b | ||
|
|
f377bb54dd | ||
|
|
8cc1614724 | ||
|
|
025342c3af | ||
|
|
2175551e38 | ||
|
|
2a6c1201f8 | ||
|
|
936942d7b5 | ||
|
|
2a50feb6fb | ||
|
|
50a3d8ca20 | ||
|
|
1596bc9c39 | ||
|
|
db019108be | ||
|
|
7b7136ce5e | ||
|
|
80e7f1e510 | ||
|
|
f7ca8b44fa | ||
|
|
11b4ae2a7b | ||
|
|
529835df1c | ||
|
|
a63ad74845 | ||
|
|
47a57afc1c | ||
|
|
351d7817e3 | ||
|
|
56d3b64c22 | ||
|
|
6713b5b7c5 | ||
|
|
952c095372 | ||
|
|
73a95e2c5b | ||
|
|
7169f53c20 | ||
|
|
a226f433d4 | ||
|
|
3f86eb93ce | ||
|
|
3b33659338 | ||
|
|
80444f1848 | ||
|
|
31cfd46453 | ||
|
|
14d46d4802 | ||
|
|
ae3634da1b | ||
|
|
70d572b7a4 | ||
|
|
62468209bc | ||
|
|
692e559d5f | ||
|
|
b52e20d30f | ||
|
|
d2c28655bd | ||
|
|
aefb6a757d | ||
|
|
3c658f7332 | ||
|
|
e275604e85 | ||
|
|
12a0ea86f3 | ||
|
|
9ec6588f6b | ||
|
|
7f6f3654c3 | ||
|
|
404b4be3be | ||
|
|
335743b874 | ||
|
|
db900cfa5e | ||
|
|
8cbce760e3 | ||
|
|
9e40a0f56b | ||
|
|
9eee3ea019 | ||
|
|
a5a7c2d124 | ||
|
|
6f0ecf93b3 | ||
|
|
ab06d1a08e | ||
|
|
0fdeed41a5 | ||
|
|
7656ae91ac | ||
|
|
e93675c34f | ||
|
|
0be985a077 | ||
|
|
b5f2ad66fa | ||
|
|
f610e74660 | ||
|
|
0fb4baa899 | ||
|
|
3239c0afad | ||
|
|
b5e352533c | ||
|
|
b89e8c7d1e | ||
|
|
d72283a153 | ||
|
|
69a0b01c0f | ||
|
|
5eed6072cb | ||
|
|
00ba1ed8ec | ||
|
|
65215da3b0 | ||
|
|
4875249855 | ||
|
|
7405ed03cc | ||
|
|
aa37ef1722 | ||
|
|
7eb68505de | ||
|
|
5a7e30cb77 | ||
|
|
b8deb9e5e2 | ||
|
|
b46649b714 | ||
|
|
7045d51003 | ||
|
|
381f98c3af | ||
|
|
682bcb8ace | ||
|
|
182a0fe29b | ||
|
|
7957655487 | ||
|
|
7d48d538c3 | ||
|
|
b5921c68b4 | ||
|
|
bd40184173 | ||
|
|
7aa7b4bc1e | ||
|
|
09b63e9de8 | ||
|
|
2d1a66d086 | ||
|
|
a31fc25e4d | ||
|
|
1cf10874a8 | ||
|
|
c466ca3059 | ||
|
|
116825334c | ||
|
|
7ffd72f978 | ||
|
|
ca5a16e984 | ||
|
|
d78b187ac0 | ||
|
|
f99a76fa56 | ||
|
|
bf2c2408ec | ||
|
|
fe47de1d68 | ||
|
|
9266848570 | ||
|
|
1ee7c5a5fe | ||
|
|
308c1f8a5e | ||
|
|
2d9d81b741 | ||
|
|
47c385513e | ||
|
|
2efaeb25f5 | ||
|
|
1bf6bfa064 | ||
|
|
814494751d | ||
|
|
5a8918b904 | ||
|
|
676a0ec97b | ||
|
|
5781f37ffa | ||
|
|
d9b5d88877 | ||
|
|
81ecdabeae | ||
|
|
614f289fac | ||
|
|
bd59ef7674 | ||
|
|
c2a464b81f | ||
|
|
c364bfba14 | ||
|
|
edc347d258 | ||
|
|
6d65d07c10 | ||
|
|
16dd296b07 | ||
|
|
c0621840e1 | ||
|
|
1737e042a7 | ||
|
|
e8fe742d01 | ||
|
|
7cd8922f2f | ||
|
|
b7982ac028 | ||
|
|
91638fbcd2 | ||
|
|
39a2239125 | ||
|
|
ef4d8b63c4 | ||
|
|
0b95c80ac9 |
31
.doc/doki/doc/ai.md
Normal file
|
|
@ -0,0 +1,31 @@
|
|||
# AI collaboration
|
||||
|
||||
## AI skills
|
||||
|
||||
`tiki` adds optional [agent skills](https://agentskills.io/home) to the repo upon initialization
|
||||
If installed you can:
|
||||
|
||||
- work with [Claude Code](https://code.claude.com), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Codex](https://openai.com/codex), [Opencode](https://opencode.ai) by simply mentioning `tiki` or `doki` in your prompts
|
||||
- create, find, modify and delete tikis using AI
|
||||
- create tikis/dokis directly from Markdown files
|
||||
- Refer to tikis or dokis when implementing with AI-assisted development - `implement tiki xxxxxxx`
|
||||
- Keep a history of prompts/plans by saving prompts or plans with your repo
|
||||
|
||||
## Chat with AI
|
||||
|
||||
If you [configured](config.md) an AI agent these features will be enabled:
|
||||
|
||||
- open current tiki in an AI agent such as [Claude Code](https://code.claude.com) and have it read it. You can then chat or edit it without having to find it first
|
||||
|
||||
|
||||
## Configuration
|
||||
|
||||
in your `config.yaml`:
|
||||
|
||||
```yaml
|
||||
# AI agent integration
|
||||
ai:
|
||||
agent: claude # AI tool for chat: "claude", "gemini", "codex", "opencode"
|
||||
# Enables AI collaboration features
|
||||
# Omit or leave empty to disable
|
||||
```
|
||||
159
.doc/doki/doc/command-line.md
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
# Command line options
|
||||
|
||||
## Usage
|
||||
|
||||
```
|
||||
tiki [command] [options]
|
||||
```
|
||||
|
||||
Running `tiki` with no arguments launches the TUI in an initialized project.
|
||||
|
||||
## Commands
|
||||
|
||||
### init
|
||||
|
||||
Initialize a tiki project in the current git repository. Creates the `.doc/tiki/` directory structure for task storage.
|
||||
|
||||
```bash
|
||||
tiki init
|
||||
```
|
||||
|
||||
### exec
|
||||
|
||||
Execute a [ruki](ruki/index.md) query and exit. Requires an initialized project.
|
||||
|
||||
```bash
|
||||
tiki exec '<ruki-statement>'
|
||||
```
|
||||
|
||||
Examples:
|
||||
```bash
|
||||
tiki exec 'select where status = "ready" order by priority'
|
||||
tiki exec 'update where id = "TIKI-ABC123" set status="done"'
|
||||
```
|
||||
|
||||
### workflow
|
||||
|
||||
Manage workflow configuration files.
|
||||
|
||||
#### workflow reset
|
||||
|
||||
Reset configuration files to their defaults.
|
||||
|
||||
```bash
|
||||
tiki workflow reset [target] [--scope]
|
||||
```
|
||||
|
||||
**Targets** (omit to reset all three files):
|
||||
- `config` — config.yaml
|
||||
- `workflow` — workflow.yaml
|
||||
- `new` — new.md (task template)
|
||||
|
||||
**Scopes** (default: `--local`):
|
||||
- `--global` — user config directory
|
||||
- `--local` — project config directory (`.doc/`)
|
||||
- `--current` — current working directory
|
||||
|
||||
For `--global`, workflow.yaml and new.md are overwritten with embedded defaults. config.yaml is deleted (built-in defaults take over).
|
||||
|
||||
For `--local` and `--current`, files are deleted so the next tier in the [precedence chain](config.md#precedence-and-merging) takes effect.
|
||||
|
||||
```bash
|
||||
# restore all global config to defaults
|
||||
tiki workflow reset --global
|
||||
|
||||
# remove project workflow overrides (falls back to global)
|
||||
tiki workflow reset workflow --local
|
||||
|
||||
# remove cwd config override
|
||||
tiki workflow reset config --current
|
||||
```
|
||||
|
||||
#### workflow install
|
||||
|
||||
Install a named workflow from the tiki repository. Downloads `workflow.yaml` and `new.md` into the scope directory, overwriting any existing files.
|
||||
|
||||
```bash
|
||||
tiki workflow install <name> [--scope]
|
||||
```
|
||||
|
||||
**Scopes** (default: `--local`):
|
||||
- `--global` — user config directory
|
||||
- `--local` — project config directory (`.doc/`)
|
||||
- `--current` — current working directory
|
||||
|
||||
```bash
|
||||
# install the sprint workflow globally
|
||||
tiki workflow install sprint --global
|
||||
|
||||
# install the kanban workflow for the current project
|
||||
tiki workflow install kanban --local
|
||||
```
|
||||
|
||||
#### workflow describe
|
||||
|
||||
Fetch a workflow's description from the tiki repository and print it to stdout.
|
||||
Reads the top-level `description` field of the named workflow's `workflow.yaml`.
|
||||
Prints nothing and exits 0 if the workflow has no description field.
|
||||
|
||||
```bash
|
||||
tiki workflow describe <name>
|
||||
```
|
||||
|
||||
**Examples:**
|
||||
|
||||
```bash
|
||||
# preview the todo workflow before installing it
|
||||
tiki workflow describe todo
|
||||
|
||||
# check what bug-tracker is for
|
||||
tiki workflow describe bug-tracker
|
||||
```
|
||||
|
||||
### demo
|
||||
|
||||
Clone the demo project and launch the TUI. If the `tiki-demo` directory already exists it is reused.
|
||||
|
||||
```bash
|
||||
tiki demo
|
||||
```
|
||||
|
||||
### sysinfo
|
||||
|
||||
Display system and terminal environment information useful for troubleshooting.
|
||||
|
||||
```bash
|
||||
tiki sysinfo
|
||||
```
|
||||
|
||||
## Markdown viewer
|
||||
|
||||
`tiki` doubles as a standalone markdown and image viewer. Pass a file path or URL as the first argument.
|
||||
|
||||
```bash
|
||||
tiki file.md
|
||||
tiki https://github.com/user/repo/blob/main/README.md
|
||||
tiki image.png
|
||||
echo "# Hello" | tiki -
|
||||
```
|
||||
|
||||
See [Markdown viewer](markdown-viewer.md) for navigation and keybindings.
|
||||
|
||||
## Piped input
|
||||
|
||||
When stdin is piped and no positional arguments are given, tiki creates a task from the input. The first line becomes the title; the rest becomes the description.
|
||||
|
||||
```bash
|
||||
echo "Fix the login bug" | tiki
|
||||
tiki < bug-report.md
|
||||
```
|
||||
|
||||
See [Quick capture](quick-capture.md) for more examples.
|
||||
|
||||
## Flags
|
||||
|
||||
| Flag | Description |
|
||||
|---|---|
|
||||
| `--help`, `-h` | Show usage information |
|
||||
| `--version`, `-v` | Show version, commit, and build date |
|
||||
| `--log-level <level>` | Set log level: `debug`, `info`, `warn`, `error` |
|
||||
|
|
@ -17,23 +17,8 @@
|
|||
|
||||
Files stored here:
|
||||
- `config.yaml` - User-global configuration
|
||||
- `workflow.yaml` - Statuses and plugin/view definitions
|
||||
- `new.md` - Custom task template
|
||||
- Plugin definition files (e.g., custom plugin YAML files)
|
||||
|
||||
**User Cache** (temporary data):
|
||||
- **Linux**: `~/.cache/tiki` (or `$XDG_CACHE_HOME/tiki`)
|
||||
- **macOS**: `~/Library/Caches/tiki`
|
||||
- **Windows**: `%LOCALAPPDATA%\tiki`
|
||||
|
||||
**Project-Local** (tasks and documentation, in git):
|
||||
- `.doc/tiki/` - Task files (tikis)
|
||||
- `.doc/doki/` - Documentation files (dokis)
|
||||
- `.doc/tiki/config.yaml` - Project-specific config (overrides user config)
|
||||
|
||||
**Configuration Priority** (first found wins):
|
||||
1. Project config directory (`.doc/tiki/config.yaml`) - highest priority
|
||||
2. User config directory (`~/.config/tiki/config.yaml`)
|
||||
3. Current directory (`./config.yaml` - development only)
|
||||
|
||||
**Environment Variables**:
|
||||
- `XDG_CONFIG_HOME` - Override config directory location (all platforms)
|
||||
|
|
@ -45,6 +30,53 @@ export XDG_CONFIG_HOME=~/my-config
|
|||
tiki # Will use ~/my-config/tiki/ for configuration
|
||||
```
|
||||
|
||||
## Precedence and merging
|
||||
|
||||
`tiki` looks for configuration in three locations, from least specific to most specific:
|
||||
|
||||
1. **User config directory** (platform-specific, see [above](#configuration-directories)) - your personal defaults
|
||||
2. **Project config directory** (`.doc/`) - shared with the team via git
|
||||
3. **Current working directory** (`./`) - local overrides, useful during development
|
||||
|
||||
The more specific location always wins. This means each project can have its own workflow, statuses, and views that differ from your personal defaults. A design-team project might use statuses like "Draft / Review / Approved" while an engineering project uses "Backlog / In Progress / Done" — each defined in their own `.doc/workflow.yaml`.
|
||||
|
||||
### config.yaml merging
|
||||
|
||||
All `config.yaml` files found are merged together. A project config only needs to specify the values it wants to change — everything else is inherited from the user config. Missing values fall back to built-in defaults.
|
||||
|
||||
Search order: user config dir (base) → `.doc/config.yaml` (project) → cwd (highest priority).
|
||||
|
||||
### new.md (task template)
|
||||
|
||||
`new.md` is searched in the same three locations but is **not merged** — the single highest-priority file found wins. If a project provides `.doc/new.md`, it completely replaces the user-level template. If no `new.md` is found anywhere, a built-in embedded template is used.
|
||||
|
||||
Search order: user config dir → `.doc/new.md` (project) → cwd. Last match wins.
|
||||
|
||||
### workflow.yaml merging
|
||||
|
||||
`workflow.yaml` is searched in all three locations. Files that exist are loaded and merged sequentially.
|
||||
|
||||
Search order: user config dir (base) → `.doc/workflow.yaml` (project) → cwd (highest priority).
|
||||
|
||||
**Statuses** — last file with a `statuses:` section wins (complete replacement). A project that defines its own statuses fully replaces the user-level defaults.
|
||||
|
||||
**Views (plugins)** — merged by name across files. The user config is the base; project and cwd files override individual fields:
|
||||
- Non-empty fields in the override replace the base (description, key, view mode)
|
||||
- Non-empty arrays in the override replace the entire base array (lanes, actions)
|
||||
- Empty/zero fields in the override are ignored — the base value is kept
|
||||
- Views that only exist in the override are appended
|
||||
|
||||
**Global plugin actions** (`views.actions`) — merged by key across files. If two files define a global action with the same key, the later file's action wins. Global actions are appended to each tiki plugin's action list; per-plugin actions with the same key take precedence.
|
||||
|
||||
A project only needs to define the views or fields it wants to change. Everything else is inherited from your user config.
|
||||
|
||||
To disable all user-level views for a project, create a `.doc/workflow.yaml` with an explicitly empty views list:
|
||||
|
||||
```yaml
|
||||
views:
|
||||
plugins: []
|
||||
```
|
||||
|
||||
### config.yaml
|
||||
|
||||
Example `config.yaml` with available settings:
|
||||
|
|
@ -71,16 +103,21 @@ Backlog:
|
|||
|
||||
# Appearance settings
|
||||
appearance:
|
||||
theme: auto # Theme: "auto" (detect from terminal), "dark", "light"
|
||||
theme: auto # Theme: "auto" (detect from terminal), "dark", "light",
|
||||
# or a named theme: "dracula", "tokyo-night", "gruvbox-dark",
|
||||
# "catppuccin-mocha", "solarized-dark", "nord", "monokai",
|
||||
# "one-dark", "catppuccin-latte", "solarized-light",
|
||||
# "gruvbox-light", "github-light"
|
||||
gradientThreshold: 256 # Minimum terminal colors for gradient rendering
|
||||
# Options: 16, 256, 16777216 (truecolor)
|
||||
# Gradients disabled if terminal has fewer colors
|
||||
# Default: 256 (works well on most terminals)
|
||||
codeBlock:
|
||||
theme: dracula # Chroma syntax theme for code blocks
|
||||
# Examples: "dracula", "monokai", "catppuccin-macchiato"
|
||||
background: "#282a36" # Code block background color (hex or ANSI e.g. "236")
|
||||
border: "#6272a4" # Code block border color (hex or ANSI e.g. "244")
|
||||
|
||||
# AI agent integration
|
||||
ai:
|
||||
agent: claude # AI tool for chat: "claude", "gemini", "codex", "opencode"
|
||||
# Enables AI collaboration features
|
||||
# Omit or leave empty to disable
|
||||
```
|
||||
|
||||
### workflow.yaml
|
||||
|
|
@ -113,86 +150,109 @@ statuses:
|
|||
done: true
|
||||
|
||||
views:
|
||||
- name: Kanban
|
||||
description: "Move tiki to new status, search, create or delete"
|
||||
foreground: "#87ceeb"
|
||||
background: "#25496a"
|
||||
key: "F1"
|
||||
lanes:
|
||||
- name: Ready
|
||||
filter: status = 'ready' and type != 'epic'
|
||||
action: status = 'ready'
|
||||
- name: In Progress
|
||||
filter: status = 'in_progress' and type != 'epic'
|
||||
action: status = 'in_progress'
|
||||
- name: Review
|
||||
filter: status = 'review' and type != 'epic'
|
||||
action: status = 'review'
|
||||
- name: Done
|
||||
filter: status = 'done' and type != 'epic'
|
||||
action: status = 'done'
|
||||
sort: Priority, CreatedAt
|
||||
- name: Backlog
|
||||
description: "Tasks waiting to be picked up, sorted by priority"
|
||||
foreground: "#5fff87"
|
||||
background: "#0b3d2e"
|
||||
key: "F3"
|
||||
lanes:
|
||||
- name: Backlog
|
||||
columns: 4
|
||||
filter: status = 'backlog' and type != 'epic'
|
||||
actions:
|
||||
- key: "b"
|
||||
label: "Add to board"
|
||||
action: status = 'ready'
|
||||
sort: Priority, ID
|
||||
- name: Recent
|
||||
description: "Tasks changed in the last 24 hours, most recent first"
|
||||
foreground: "#f4d6a6"
|
||||
background: "#5a3d1b"
|
||||
key: Ctrl-R
|
||||
lanes:
|
||||
- name: Recent
|
||||
columns: 4
|
||||
filter: NOW - UpdatedAt < 24hours
|
||||
sort: UpdatedAt DESC
|
||||
- name: Roadmap
|
||||
description: "Epics organized by Now, Next, and Later horizons"
|
||||
foreground: "#e2e8f0"
|
||||
background: "#2a5f5a"
|
||||
key: "F4"
|
||||
lanes:
|
||||
- name: Now
|
||||
columns: 1
|
||||
width: 25
|
||||
filter: type = 'epic' AND status = 'ready'
|
||||
action: status = 'ready'
|
||||
- name: Next
|
||||
columns: 1
|
||||
width: 25
|
||||
filter: type = 'epic' AND status = 'backlog' AND priority = 1
|
||||
action: status = 'backlog', priority = 1
|
||||
- name: Later
|
||||
columns: 2
|
||||
width: 50
|
||||
filter: type = 'epic' AND status = 'backlog' AND priority > 1
|
||||
action: status = 'backlog', priority = 2
|
||||
sort: Priority, Points DESC
|
||||
view: expanded
|
||||
- name: Help
|
||||
description: "Keyboard shortcuts, navigation, and usage guide"
|
||||
type: doki
|
||||
fetcher: internal
|
||||
text: "Help"
|
||||
foreground: "#bcbcbc"
|
||||
background: "#003399"
|
||||
key: "?"
|
||||
- name: Docs
|
||||
description: "Project notes and documentation files"
|
||||
type: doki
|
||||
fetcher: file
|
||||
url: "index.md"
|
||||
foreground: "#ff9966"
|
||||
background: "#2b3a42"
|
||||
key: "F2"
|
||||
actions:
|
||||
- key: "a"
|
||||
label: "Assign to me"
|
||||
action: update where id = id() set assignee=user()
|
||||
- key: "A"
|
||||
label: "Assign to..."
|
||||
action: update where id = id() set assignee=input()
|
||||
input: string
|
||||
plugins:
|
||||
- name: Kanban
|
||||
description: "Move tiki to new status, search, create or delete"
|
||||
key: "F1"
|
||||
lanes:
|
||||
- name: Ready
|
||||
filter: select where status = "ready" and type != "epic" order by priority, createdAt
|
||||
action: update where id = id() set status="ready"
|
||||
- name: In Progress
|
||||
filter: select where status = "inProgress" and type != "epic" order by priority, createdAt
|
||||
action: update where id = id() set status="inProgress"
|
||||
- name: Review
|
||||
filter: select where status = "review" and type != "epic" order by priority, createdAt
|
||||
action: update where id = id() set status="review"
|
||||
- name: Done
|
||||
filter: select where status = "done" and type != "epic" order by priority, createdAt
|
||||
action: update where id = id() set status="done"
|
||||
- name: Backlog
|
||||
description: "Tasks waiting to be picked up, sorted by priority"
|
||||
key: "F3"
|
||||
lanes:
|
||||
- name: Backlog
|
||||
columns: 4
|
||||
filter: select where status = "backlog" and type != "epic" order by priority, id
|
||||
actions:
|
||||
- key: "b"
|
||||
label: "Add to board"
|
||||
action: update where id = id() set status="ready"
|
||||
- name: Recent
|
||||
description: "Tasks changed in the last 24 hours, most recent first"
|
||||
key: Ctrl-R
|
||||
lanes:
|
||||
- name: Recent
|
||||
columns: 4
|
||||
filter: select where now() - updatedAt < 24hour order by updatedAt desc
|
||||
- name: Roadmap
|
||||
description: "Epics organized by Now, Next, and Later horizons"
|
||||
key: "F4"
|
||||
lanes:
|
||||
- name: Now
|
||||
columns: 1
|
||||
width: 25
|
||||
filter: select where type = "epic" and status = "ready" order by priority, points desc
|
||||
action: update where id = id() set status="ready"
|
||||
- name: Next
|
||||
columns: 1
|
||||
width: 25
|
||||
filter: select where type = "epic" and status = "backlog" and priority = 1 order by priority, points desc
|
||||
action: update where id = id() set status="backlog" priority=1
|
||||
- name: Later
|
||||
columns: 2
|
||||
width: 50
|
||||
filter: select where type = "epic" and status = "backlog" and priority > 1 order by priority, points desc
|
||||
action: update where id = id() set status="backlog" priority=2
|
||||
view: expanded
|
||||
- name: Docs
|
||||
description: "Project notes and documentation files"
|
||||
type: doki
|
||||
fetcher: file
|
||||
url: "index.md"
|
||||
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: tasks must pass through review before completion
|
||||
ruki: >
|
||||
before update
|
||||
where new.status = "done" and old.status != "review"
|
||||
deny "tasks must go through review before marking 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
|
||||
- description: tasks must have an assignee before starting
|
||||
ruki: >
|
||||
before update
|
||||
where new.status = "inProgress" and new.assignee is empty
|
||||
deny "assign someone before moving to in-progress"
|
||||
- description: auto-complete epics when all child tasks finish
|
||||
ruki: >
|
||||
after update
|
||||
where new.status = "done" and new.type != "epic"
|
||||
update where type = "epic" and new.id in dependsOn and dependsOn all status = "done"
|
||||
set status="done"
|
||||
- description: cannot delete tasks that are actively being worked
|
||||
ruki: >
|
||||
before delete
|
||||
where old.status = "inProgress"
|
||||
deny "cannot delete an in-progress task — move to backlog or done first"
|
||||
```
|
||||
249
.doc/doki/doc/custom-fields.md
Normal file
|
|
@ -0,0 +1,249 @@
|
|||
# Custom Fields
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Defining custom fields](#defining-custom-fields)
|
||||
- [Field types](#field-types)
|
||||
- [Enum fields](#enum-fields)
|
||||
- [Using custom fields in ruki](#using-custom-fields-in-ruki)
|
||||
- [Storage and frontmatter](#storage-and-frontmatter)
|
||||
- [Templates](#templates)
|
||||
- [Missing field behavior](#missing-field-behavior)
|
||||
|
||||
## Overview
|
||||
|
||||
Custom fields let you extend tikis with project-specific data beyond the built-in fields (title, status, priority, etc.). Define them in `workflow.yaml` and they become first-class citizens: usable in ruki queries, persisted in task frontmatter, and available across all views.
|
||||
|
||||
Use cases include:
|
||||
|
||||
- tracking a sprint or milestone name
|
||||
- adding an effort estimate or story-point alternative
|
||||
- flagging tasks with a boolean (e.g. `blocked`, `reviewed`)
|
||||
- recording a deadline timestamp with time-of-day precision
|
||||
- categorizing tasks with a constrained set of values (enum)
|
||||
- linking related tasks beyond `dependsOn`
|
||||
|
||||
## Defining custom fields
|
||||
|
||||
Add a `fields:` section to your `workflow.yaml`:
|
||||
|
||||
```yaml
|
||||
fields:
|
||||
- name: sprint
|
||||
type: text
|
||||
- name: effort
|
||||
type: integer
|
||||
- name: blocked
|
||||
type: boolean
|
||||
- name: deadline
|
||||
type: datetime
|
||||
- name: category
|
||||
type: enum
|
||||
values:
|
||||
- frontend
|
||||
- backend
|
||||
- infra
|
||||
- docs
|
||||
- name: reviewers
|
||||
type: stringList
|
||||
- name: relatedTasks
|
||||
type: taskIdList
|
||||
```
|
||||
|
||||
Field names must not collide with built-in field names or ruki reserved keywords.
|
||||
|
||||
Custom fields follow the same merge semantics as other `workflow.yaml` sections. If the same field is defined identically in multiple files (user config, project config, cwd), the duplicate is silently accepted. If definitions conflict (different type or different enum values), loading fails with an error.
|
||||
|
||||
## Field types
|
||||
|
||||
| YAML type | Description | ruki type |
|
||||
|---------------|---------------------------------------|------------------|
|
||||
| `text` | free-form string | `string` |
|
||||
| `integer` | whole number | `int` |
|
||||
| `boolean` | true or false | `bool` |
|
||||
| `datetime` | timestamp (RFC3339 or YYYY-MM-DD) | `timestamp` |
|
||||
| `enum` | constrained string from `values` list | `enum` |
|
||||
| `stringList` | list of strings | `list<string>` |
|
||||
| `taskIdList` | list of tiki ID references | `list<ref>` |
|
||||
|
||||
## Enum fields
|
||||
|
||||
Enum fields require a `values:` list. Only those values are accepted when setting the field (case-insensitive matching, canonical casing preserved). Attempting to assign a value outside the list produces a validation error.
|
||||
|
||||
```yaml
|
||||
fields:
|
||||
- name: severity
|
||||
type: enum
|
||||
values:
|
||||
- critical
|
||||
- major
|
||||
- minor
|
||||
- trivial
|
||||
```
|
||||
|
||||
Enum domains are field-scoped: two different enum fields maintain independent value sets. Cross-field enum assignment (e.g. `set severity = category`) is rejected even if the values happen to overlap.
|
||||
|
||||
Non-enum fields must not include a `values:` list.
|
||||
|
||||
## Using custom fields in ruki
|
||||
|
||||
Custom fields work the same as built-in fields in all ruki contexts: `select`, `update`, `create`, `order by`, `where`, and triggers.
|
||||
|
||||
### Filtering with select where
|
||||
|
||||
```sql
|
||||
-- find blocked tasks
|
||||
select where blocked = true
|
||||
|
||||
-- find tasks in a specific sprint
|
||||
select where sprint = "sprint-7"
|
||||
|
||||
-- find critical tasks in the frontend category
|
||||
select where severity = "critical" and category = "frontend"
|
||||
|
||||
-- find tasks with high effort
|
||||
select where effort > 5
|
||||
|
||||
-- find tasks with a deadline before a date
|
||||
select where deadline < 2026-05-01
|
||||
```
|
||||
|
||||
### Updating with update set
|
||||
|
||||
```sql
|
||||
-- assign a sprint
|
||||
update where id = id() set sprint="sprint-7"
|
||||
|
||||
-- mark as blocked
|
||||
update where id = id() set blocked=true
|
||||
|
||||
-- set category and severity
|
||||
update where id = id() set category="backend" severity="major"
|
||||
|
||||
-- clear a custom field (set to empty)
|
||||
update where id = id() set sprint=empty
|
||||
|
||||
-- add a reviewer
|
||||
update where id = id() set reviewers=reviewers + ["alice"]
|
||||
```
|
||||
|
||||
### Ordering with order by
|
||||
|
||||
```sql
|
||||
-- sort by effort descending
|
||||
select where status = "ready" order by effort desc
|
||||
|
||||
-- sort by category, then priority
|
||||
select where status = "backlog" order by category, priority
|
||||
|
||||
-- sort by deadline
|
||||
select where deadline is not empty order by deadline
|
||||
```
|
||||
|
||||
### Creating with custom field defaults
|
||||
|
||||
```sql
|
||||
-- create a task with custom fields
|
||||
create title="New feature" category="frontend" effort=3
|
||||
|
||||
-- create with enum and boolean
|
||||
create title="Fix crash" severity="critical" blocked=false
|
||||
```
|
||||
|
||||
### Plugin filters and actions
|
||||
|
||||
Custom fields integrate into plugin definitions in `workflow.yaml`:
|
||||
|
||||
```yaml
|
||||
views:
|
||||
plugins:
|
||||
- name: Sprint Board
|
||||
key: "F5"
|
||||
lanes:
|
||||
- name: Current Sprint
|
||||
filter: select where sprint = "sprint-7" and status != "done" order by effort desc
|
||||
action: update where id = id() set sprint="sprint-7"
|
||||
- name: Next Sprint
|
||||
filter: select where sprint = "sprint-8" order by priority
|
||||
action: update where id = id() set sprint="sprint-8"
|
||||
actions:
|
||||
- key: "b"
|
||||
label: "Mark blocked"
|
||||
action: update where id = id() set blocked=true
|
||||
```
|
||||
|
||||
## Storage and frontmatter
|
||||
|
||||
Custom fields are stored in task frontmatter alongside built-in fields:
|
||||
|
||||
```yaml
|
||||
---
|
||||
title: Implement search
|
||||
type: story
|
||||
status: in_progress
|
||||
priority: 2
|
||||
points: 3
|
||||
tags:
|
||||
- search
|
||||
sprint: sprint-7
|
||||
blocked: false
|
||||
category: backend
|
||||
effort: 5
|
||||
deadline: 2026-05-15T17:00:00Z
|
||||
reviewers:
|
||||
- alice
|
||||
- bob
|
||||
relatedTasks:
|
||||
- TIKI-ABC123
|
||||
---
|
||||
Search implementation details...
|
||||
```
|
||||
|
||||
Custom fields appear after the built-in fields, sorted alphabetically by name.
|
||||
|
||||
On load, unknown frontmatter keys that are not registered custom fields are preserved as-is and survive save-load round-trips. This allows workflow changes without losing data — see [Schema evolution](ruki/custom-fields-reference.md#schema-evolution-and-stale-data) for details.
|
||||
|
||||
## Templates
|
||||
|
||||
Custom fields can have defaults in `new.md`:
|
||||
|
||||
```markdown
|
||||
---
|
||||
type: story
|
||||
status: backlog
|
||||
priority: 3
|
||||
points: 1
|
||||
sprint: sprint-7
|
||||
blocked: false
|
||||
category: backend
|
||||
---
|
||||
```
|
||||
|
||||
Custom field values in the template are validated against their type definitions and enum constraints, the same as in task files.
|
||||
|
||||
## Missing field behavior
|
||||
|
||||
When a custom field is not set on a task, ruki returns the typed zero value for that field's type:
|
||||
|
||||
| Field type | Zero value |
|
||||
|---------------|--------------------|
|
||||
| `text` | `""` (empty string)|
|
||||
| `integer` | `0` |
|
||||
| `boolean` | `false` |
|
||||
| `datetime` | zero time |
|
||||
| `enum` | `""` (empty string)|
|
||||
| `stringList` | `[]` (empty list) |
|
||||
| `taskIdList` | `[]` (empty list) |
|
||||
|
||||
This means `select where blocked = false` matches both tasks explicitly set to `false` and tasks that never had the `blocked` field set. Use `is empty` / `is not empty` to distinguish:
|
||||
|
||||
```sql
|
||||
-- tasks that explicitly have blocked set (to any value)
|
||||
select where blocked is not empty
|
||||
|
||||
-- tasks where blocked was never set
|
||||
select where blocked is empty
|
||||
```
|
||||
|
||||
Note: for boolean and integer fields, the zero value (`false`, `0`) is also the `empty` value. An explicitly stored `false` and a missing boolean field are indistinguishable at query time. If you need the distinction, consider using an enum field with explicit values (e.g. `yes` / `no` / not set via `empty`).
|
||||
125
.doc/doki/doc/custom-status-type.md
Normal file
|
|
@ -0,0 +1,125 @@
|
|||
# Custom Statuses and Types
|
||||
|
||||
Statuses and types are user-configurable via `workflow.yaml`.
|
||||
Both follow the same structural rules with a few differences noted below.
|
||||
|
||||
## Configuration
|
||||
|
||||
### YAML Shape
|
||||
|
||||
```yaml
|
||||
statuses:
|
||||
- key: backlog
|
||||
label: Backlog
|
||||
emoji: "📥"
|
||||
default: true
|
||||
- key: inProgress
|
||||
label: "In Progress"
|
||||
emoji: "⚙️"
|
||||
active: true
|
||||
- key: done
|
||||
label: Done
|
||||
emoji: "✅"
|
||||
done: true
|
||||
|
||||
types:
|
||||
- key: story
|
||||
label: Story
|
||||
emoji: "🌀"
|
||||
- key: bug
|
||||
label: Bug
|
||||
emoji: "💥"
|
||||
```
|
||||
|
||||
### Shared Rules
|
||||
|
||||
These rules apply identically to both `statuses:` and `types:`:
|
||||
|
||||
| Rule | Detail |
|
||||
|---|---|
|
||||
| Canonical keys | Keys must already be in canonical form. Non-canonical keys are rejected with a suggested canonical form. |
|
||||
| Label defaults to key | When `label` is omitted, the key is used as the label. |
|
||||
| Empty labels rejected | Explicitly empty or whitespace-only labels are invalid. |
|
||||
| Emoji trimmed | Leading/trailing whitespace is stripped from emoji values. |
|
||||
| Unique display strings | Each entry must produce a unique `"Label Emoji"` display. Duplicates are rejected. |
|
||||
| At least one entry | An empty list is invalid. |
|
||||
| Duplicate keys rejected | Two entries with the same canonical key are invalid. |
|
||||
| Unknown keys rejected | Only documented keys are allowed in each entry. |
|
||||
|
||||
### Status-Only Keys
|
||||
|
||||
Statuses support additional boolean flags that types do not:
|
||||
|
||||
| Key | Required | Description |
|
||||
|---|---|---|
|
||||
| `active` | no | Marks a status as active (in-progress work). |
|
||||
| `default` | exactly one | The status assigned to newly created tasks. |
|
||||
| `done` | exactly one | The terminal status representing completion. |
|
||||
|
||||
Valid keys in a status entry: `key`, `label`, `emoji`, `active`, `default`, `done`.
|
||||
|
||||
### Type-Only Behavior
|
||||
|
||||
Types have no boolean flags. The first configured type is used as the creation default.
|
||||
|
||||
Valid keys in a type entry: `key`, `label`, `emoji`.
|
||||
|
||||
### Key Normalization
|
||||
|
||||
Status and type keys use different normalization rules:
|
||||
|
||||
- **Status keys** use camelCase. Splits on `_`, `-`, ` `, and camelCase boundaries, then reassembles as camelCase.
|
||||
Examples: `"in_progress"` -> `"inProgress"`, `"In Progress"` -> `"inProgress"`.
|
||||
|
||||
- **Type keys** are lowercased with all separators stripped.
|
||||
Examples: `"My-Type"` -> `"mytype"`, `"some_thing"` -> `"something"`.
|
||||
|
||||
Keys in `workflow.yaml` must already be in their canonical form. Input normalization (from user queries, ruki expressions, etc.) still applies at lookup time.
|
||||
|
||||
### Inheritance and Override
|
||||
|
||||
- A section (`statuses:` or `types:`) absent from a workflow file means "no opinion" -- it does not override the inherited value.
|
||||
- A non-empty section fully replaces inherited/built-in entries. No merging across files.
|
||||
- The last file (most specific location) with the section present wins.
|
||||
- If no file defines `types:`, built-in defaults are used (`story`, `bug`, `spike`, `epic`).
|
||||
- Statuses have no built-in fallback -- at least one workflow file must define `statuses:`.
|
||||
|
||||
## Failure Behavior
|
||||
|
||||
### Invalid Configuration
|
||||
|
||||
| Scenario | Behavior |
|
||||
|---|---|
|
||||
| Empty list | Error |
|
||||
| Non-canonical key | Error with suggested canonical form |
|
||||
| Empty/whitespace label | Error |
|
||||
| Duplicate display string | Error |
|
||||
| Unknown key in entry | Error |
|
||||
| Missing `default: true` (statuses) | Error |
|
||||
| Missing `done: true` (statuses) | Error |
|
||||
| Multiple `default: true` (statuses) | Error |
|
||||
| Multiple `done: true` (statuses) | Error |
|
||||
|
||||
### Invalid Saved Tasks
|
||||
|
||||
- A tiki with a missing or unknown `type` fails to load and is skipped.
|
||||
- On single-task reload (`ReloadTask`), an invalid file causes the task to be removed from memory.
|
||||
|
||||
### Invalid Templates
|
||||
|
||||
- Missing `type` in a template defaults to the first configured type.
|
||||
- Invalid non-empty `type` in a template is a hard error; creation is aborted.
|
||||
|
||||
### Sample Tasks at Init
|
||||
|
||||
- Each embedded sample is validated against the active registries before writing.
|
||||
- Incompatible samples are silently skipped.
|
||||
- `tiki init` offers a "Create sample tasks" checkbox (default: enabled).
|
||||
|
||||
### Cross-Reference Errors
|
||||
|
||||
If a `types:` override removes type keys still referenced by inherited views, actions, or triggers, startup fails with a configuration error. There is no silent view-skipping or automatic remapping.
|
||||
|
||||
## Pre-Init Rules
|
||||
|
||||
Calling type or status helpers (`task.ParseType()`, `task.AllTypes()`, `task.DefaultType()`, `task.ParseStatus()`, etc.) before `config.LoadWorkflowRegistries()` is a programmer error and panics.
|
||||
|
|
@ -4,10 +4,23 @@ tiki is highly customizable. `workflow.yaml` lets you define your workflow statu
|
|||
how tikis are displayed and organized. Statuses define the lifecycle stages your tasks move through,
|
||||
while plugins control what you see and how you interact with your work. This section covers both.
|
||||
|
||||
## Description
|
||||
|
||||
An optional top-level `description:` field in `workflow.yaml` describes what
|
||||
the workflow is for. It supports multi-line text via YAML's block scalar (`|`)
|
||||
and is used by `tiki workflow describe <name>` to preview a workflow before
|
||||
installing it.
|
||||
|
||||
```yaml
|
||||
description: |
|
||||
Release workflow. Coordinate feature rollout through
|
||||
Planned → Building → Staging → Canary → Released.
|
||||
```
|
||||
|
||||
## Statuses
|
||||
|
||||
Workflow statuses are defined in `workflow.yaml` under the `statuses:` key. Every tiki project must define
|
||||
its statuses here — there is no hardcoded fallback. The default `workflow.yaml` ships with:
|
||||
its statuses here — there is no hardcoded fallback. See [Custom statuses and types](custom-status-type.md). The default `workflow.yaml` ships with:
|
||||
|
||||
```yaml
|
||||
statuses:
|
||||
|
|
@ -19,7 +32,7 @@ statuses:
|
|||
label: Ready
|
||||
emoji: "📋"
|
||||
active: true
|
||||
- key: in_progress
|
||||
- key: inProgress
|
||||
label: "In Progress"
|
||||
emoji: "⚙️"
|
||||
active: true
|
||||
|
|
@ -34,27 +47,54 @@ statuses:
|
|||
```
|
||||
|
||||
Each status has:
|
||||
- `key` — canonical identifier (lowercase, underscores). Used in filters, actions, and frontmatter.
|
||||
- `label` — display name shown in the UI
|
||||
- `key` — canonical camelCase identifier. Used in filters, actions, and frontmatter.
|
||||
- `label` — display name shown in the UI (defaults to key when omitted)
|
||||
- `emoji` — emoji shown alongside the label
|
||||
- `active` — marks the status as "active work" (used for activity tracking)
|
||||
- `default` — the status assigned to new tikis (exactly one status should have this)
|
||||
- `done` — marks the status as "completed" (used for completion tracking)
|
||||
- `default` — the status assigned to new tikis (exactly one required)
|
||||
- `done` — marks the status as "completed" (exactly one required)
|
||||
|
||||
You can customize these to match your team's workflow. All filters and actions in view definitions (see below) must reference valid status keys.
|
||||
|
||||
## Types
|
||||
|
||||
Task types are defined in `workflow.yaml` under the `types:` key. If omitted, built-in defaults are used.
|
||||
See [Custom statuses and types](custom-status-type.md) for the full validation and inheritance rules. The default `workflow.yaml` ships with:
|
||||
|
||||
```yaml
|
||||
types:
|
||||
- key: story
|
||||
label: Story
|
||||
emoji: "🌀"
|
||||
- key: bug
|
||||
label: Bug
|
||||
emoji: "💥"
|
||||
- key: spike
|
||||
label: Spike
|
||||
emoji: "🔍"
|
||||
- key: epic
|
||||
label: Epic
|
||||
emoji: "🗂️"
|
||||
```
|
||||
|
||||
Each type has:
|
||||
- `key` — canonical lowercase identifier. Used in filters, actions, and frontmatter.
|
||||
- `label` — display name shown in the UI (defaults to key when omitted)
|
||||
- `emoji` — emoji shown alongside the label
|
||||
|
||||
The first configured type is used as the default for new tikis.
|
||||
|
||||
## Task Template
|
||||
|
||||
When you create a new tiki — whether in the TUI or command line — field defaults come from a template file.
|
||||
Place `new.md` in your user config directory to override the built-in defaults
|
||||
Place `new.md` in your config directory to override the built-in defaults
|
||||
The file uses YAML frontmatter for field defaults
|
||||
|
||||
### Built-in default
|
||||
|
||||
```markdown
|
||||
---
|
||||
type: story
|
||||
status: backlog
|
||||
title:
|
||||
points: 1
|
||||
priority: 3
|
||||
tags:
|
||||
|
|
@ -62,6 +102,8 @@ tags:
|
|||
---
|
||||
```
|
||||
|
||||
Type and status are omitted but can be added, otherwise they default to the first configured type and the status marked `default: true`.
|
||||
|
||||
## Plugins
|
||||
|
||||
tiki TUI app is much like a lego - everything is a customizable view. Here is, for example,
|
||||
|
|
@ -69,38 +111,35 @@ how Backlog is defined:
|
|||
|
||||
```yaml
|
||||
views:
|
||||
- name: Backlog
|
||||
description: "Tasks waiting to be picked up, sorted by priority"
|
||||
foreground: "#5fff87"
|
||||
background: "#0b3d2e"
|
||||
key: "F3"
|
||||
lanes:
|
||||
- name: Backlog
|
||||
columns: 4
|
||||
filter: status = 'backlog' and type != 'epic'
|
||||
actions:
|
||||
- key: "b"
|
||||
label: "Add to board"
|
||||
action: status = 'ready'
|
||||
sort: Priority, ID
|
||||
plugins:
|
||||
- name: Backlog
|
||||
description: "Tasks waiting to be picked up, sorted by priority"
|
||||
key: "F3"
|
||||
lanes:
|
||||
- name: Backlog
|
||||
columns: 4
|
||||
filter: select where status = "backlog" and type != "epic" order by priority, id
|
||||
actions:
|
||||
- key: "b"
|
||||
label: "Add to board"
|
||||
action: update where id = id() set status="ready"
|
||||
```
|
||||
|
||||
that translates to - show all tikis in the status `backlog`, sort by priority and then by ID arranged visually in 4 columns in a single lane.
|
||||
The `actions` section defines a keyboard shortcut `b` that moves the selected tiki to the board by setting its status to `ready`
|
||||
You define the name, description, caption colors, hotkey, tiki filter and sorting. The `description` is displayed in the header when the view is active. Save this into a `workflow.yaml` file in the config directory
|
||||
You define the name, description, hotkey, and `ruki` expressions for filtering and actions. The `description` is displayed in the header when the view is active. Save this into a `workflow.yaml` file in the config directory
|
||||
|
||||
Likewise the documentation is just a plugin:
|
||||
|
||||
```yaml
|
||||
views:
|
||||
- name: Docs
|
||||
description: "Project notes and documentation files"
|
||||
type: doki
|
||||
fetcher: file
|
||||
url: "index.md"
|
||||
foreground: "#ff9966"
|
||||
background: "#2b3a42"
|
||||
key: "F2"
|
||||
plugins:
|
||||
- name: Docs
|
||||
description: "Project notes and documentation files"
|
||||
type: doki
|
||||
fetcher: file
|
||||
url: "index.md"
|
||||
key: "F2"
|
||||
```
|
||||
|
||||
that translates to - show `index.md` file located under `.doc/doki`
|
||||
|
|
@ -116,31 +155,28 @@ definition that roughly mimics the board:
|
|||
|
||||
```yaml
|
||||
name: Custom
|
||||
foreground: "#5fff87"
|
||||
background: "#005f00"
|
||||
key: "F4"
|
||||
sort: Priority, Title
|
||||
lanes:
|
||||
- name: Ready
|
||||
columns: 1
|
||||
width: 20
|
||||
filter: status = 'ready'
|
||||
action: status = 'ready'
|
||||
filter: select where status = "ready" order by priority, title
|
||||
action: update where id = id() set status="ready"
|
||||
- name: In Progress
|
||||
columns: 1
|
||||
width: 30
|
||||
filter: status = 'in_progress'
|
||||
action: status = 'in_progress'
|
||||
filter: select where status = "inProgress" order by priority, title
|
||||
action: update where id = id() set status="inProgress"
|
||||
- name: Review
|
||||
columns: 1
|
||||
width: 30
|
||||
filter: status = 'review'
|
||||
action: status = 'review'
|
||||
filter: select where status = "review" order by priority, title
|
||||
action: update where id = id() set status="review"
|
||||
- name: Done
|
||||
columns: 1
|
||||
width: 20
|
||||
filter: status = 'done'
|
||||
action: status = 'done'
|
||||
filter: select where status = "done" order by priority, title
|
||||
action: update where id = id() set status="done"
|
||||
```
|
||||
|
||||
### Lane width
|
||||
|
|
@ -159,7 +195,28 @@ lanes:
|
|||
|
||||
If no lanes specify width, all lanes are equally sized (the default behavior).
|
||||
|
||||
### Plugin actions
|
||||
### Global plugin actions
|
||||
|
||||
You can define actions under `views.actions` that are available in **all** tiki plugin views. This avoids repeating common shortcuts in every plugin definition.
|
||||
|
||||
```yaml
|
||||
views:
|
||||
actions:
|
||||
- key: "a"
|
||||
label: "Assign to me"
|
||||
action: update where id = id() set assignee=user()
|
||||
plugins:
|
||||
- name: Kanban
|
||||
...
|
||||
- name: Backlog
|
||||
...
|
||||
```
|
||||
|
||||
Global actions appear in the header alongside per-plugin actions. If a per-plugin action uses the same key as a global action, the per-plugin action takes precedence for that view.
|
||||
|
||||
When multiple workflow files define `views.actions`, they merge by key across files — later files override same-keyed globals from earlier files.
|
||||
|
||||
### Per-plugin actions
|
||||
|
||||
In addition to lane actions that trigger when moving tikis between lanes, you can define plugin-level actions
|
||||
that apply to the currently selected tiki via a keyboard shortcut. These shortcuts are displayed in the header when the plugin is active.
|
||||
|
|
@ -168,135 +225,153 @@ that apply to the currently selected tiki via a keyboard shortcut. These shortcu
|
|||
actions:
|
||||
- key: "b"
|
||||
label: "Add to board"
|
||||
action: status = 'ready'
|
||||
action: update where id = id() set status="ready"
|
||||
- key: "a"
|
||||
label: "Assign to me"
|
||||
action: assignee = CURRENT_USER
|
||||
action: update where id = id() set assignee=user()
|
||||
```
|
||||
|
||||
Each action has:
|
||||
- `key` - a single printable character used as the keyboard shortcut
|
||||
- `label` - description shown in the header
|
||||
- `action` - an action expression (same syntax as lane actions, see below)
|
||||
- `label` - description shown in the header and action palette
|
||||
- `action` - a `ruki` statement (`update`, `create`, `delete`, or `select`)
|
||||
- `hot` - (optional) controls header visibility. `hot: true` shows the action in the header, `hot: false` hides it. When absent, actions default to visible in the header. This does not affect the action palette — all actions are always discoverable via `?` regardless of the `hot` setting
|
||||
- `input` - (optional) declares that the action prompts for user input before executing. The value is the scalar type of the input: `string`, `int`, `bool`, `date`, `timestamp`, or `duration`. The action's `ruki` statement must use `input()` to reference the value
|
||||
|
||||
Example — keeping a verbose action out of the header but still accessible from the palette:
|
||||
|
||||
```yaml
|
||||
actions:
|
||||
- key: "x"
|
||||
label: "Archive and notify"
|
||||
action: update where id = id() set status="done"
|
||||
hot: false
|
||||
```
|
||||
|
||||
When the shortcut key is pressed, the action is applied to the currently selected tiki.
|
||||
For example, pressing `b` in the Backlog plugin changes the selected tiki's status to `ready`, effectively moving it to the board.
|
||||
|
||||
### Action expression
|
||||
`select` actions execute for side-effects only — the output is ignored. They don't require a selected tiki.
|
||||
|
||||
The `action: status = 'backlog'` statement in a plugin is an action to be run when a tiki is moved into the lane. Here `=`
|
||||
means `assign` so status is assigned `backlog` when the tiki is moved. Likewise you can manipulate tags using `+-` (add)
|
||||
or `-=` (remove) expressions. For example, `tags += [idea, UI]` adds `idea` and `UI` tags to a tiki
|
||||
### Input-backed actions
|
||||
|
||||
#### Supported Fields
|
||||
Actions with `input:` prompt the user for a value before executing. When the action key is pressed, a modal input box opens with the action label as the prompt. The user types a value and presses Enter to execute, or Esc to cancel.
|
||||
|
||||
- `status` - set workflow status (must be a key defined in `workflow.yaml` statuses)
|
||||
- `type` - set task type: `story`, `bug`, `spike`, `epic` (case-insensitive)
|
||||
- `priority` - set numeric priority (1-5)
|
||||
- `points` - set numeric points (0 or positive, up to max points)
|
||||
- `assignee` - set assignee string
|
||||
- `tags` - add/remove tags (list)
|
||||
- `dependsOn` - add/remove dependency tiki IDs (list)
|
||||
- `due` - set due date (YYYY-MM-DD format)
|
||||
- `recurrence` - set recurrence pattern (cron format: `0 0 * * *`, `0 0 * * MON`, `0 0 1 * *`)
|
||||
|
||||
#### Operators
|
||||
|
||||
- `=` assigns a value to `status`, `type`, `priority`, `points`, `assignee`, `due`, `recurrence`
|
||||
- `+=` adds tags or dependencies, `-=` removes them
|
||||
- multiple operations are separated by commas: `status=done, tags+=[moved]`
|
||||
|
||||
#### Literals
|
||||
|
||||
- strings can be quoted (`'in_progress'`, `"alex"`) or bare (`done`, `alex`)
|
||||
- use quotes when the value has spaces
|
||||
- integers are used for `priority` and `points`
|
||||
- tag lists use brackets: `tags += [ui, frontend]`
|
||||
- `CURRENT_USER` assigns the current git user to `assignee`
|
||||
- example: `assignee = CURRENT_USER`
|
||||
|
||||
### Filter expression
|
||||
|
||||
The `filter: status = 'backlog'` statement in a plugin is a filter expression that determines which tikis appear in the view.
|
||||
|
||||
#### Supported Fields
|
||||
|
||||
You can filter on these task fields:
|
||||
- `id` - Task identifier (e.g., 'TIKI-m7n2xk')
|
||||
- `title` - Task title text (case-insensitive)
|
||||
- `type` - Task type: 'story', 'bug', 'spike', or 'epic' (case-insensitive)
|
||||
- `status` - Workflow status (must match a key defined in `workflow.yaml` statuses)
|
||||
- `assignee` - Assigned user (case-insensitive)
|
||||
- `priority` - Numeric priority value
|
||||
- `points` - Story points estimate
|
||||
- `tags` (or `tag`) - List of tags (case-insensitive)
|
||||
- `dependsOn` - List of dependency tiki IDs
|
||||
- `due` - Due date (YYYY-MM-DD format, supports time arithmetic)
|
||||
- `recurrence` - Recurrence pattern (cron string, compared as string)
|
||||
- `createdAt` - Creation timestamp
|
||||
- `updatedAt` - Last update timestamp
|
||||
|
||||
All string comparisons are case-insensitive.
|
||||
|
||||
#### Operators
|
||||
|
||||
- **Comparison**: `=` (or `==`), `!=`, `>`, `>=`, `<`, `<=`
|
||||
- **Logical**: `AND`, `OR`, `NOT` (precedence: NOT > AND > OR)
|
||||
- **Membership**: `IN`, `NOT IN` (check if value in list using `[val1, val2]`)
|
||||
- **Grouping**: Use parentheses `()` to control evaluation order
|
||||
|
||||
#### Literals and Special Values
|
||||
|
||||
**Special expressions**:
|
||||
- `CURRENT_USER` - Resolves to the current git user (works in comparisons and IN lists)
|
||||
- `NOW` - Current timestamp
|
||||
|
||||
**Time expressions**:
|
||||
- `NOW - UpdatedAt` - Time elapsed since update
|
||||
- `NOW - CreatedAt` - Time since creation
|
||||
- Duration units: `min`/`minutes`, `hour`/`hours`, `day`/`days`, `week`/`weeks`, `month`/`months`
|
||||
- Examples: `2hours`, `14days`, `3weeks`, `60min`, `1month`
|
||||
- Operators: `+` (add), `-` (subtract or compute duration)
|
||||
|
||||
**Special tag semantics**:
|
||||
- `tags IN ['ui', 'frontend']` matches if ANY task tag matches ANY list value
|
||||
- This allows intersection testing across tag arrays
|
||||
|
||||
#### Examples
|
||||
|
||||
```text
|
||||
# Multiple statuses
|
||||
status = 'ready' OR status = 'in_progress'
|
||||
|
||||
# With tags
|
||||
tags IN ['frontend', 'urgent']
|
||||
|
||||
# High priority bugs
|
||||
type = 'bug' AND priority = 0
|
||||
|
||||
# Features and ideas assigned to me
|
||||
(type = 'feature' OR tags IN ['idea']) AND assignee = CURRENT_USER
|
||||
|
||||
# Unassigned large tasks
|
||||
assignee = '' AND points >= 5
|
||||
|
||||
# Recently created tasks not in backlog
|
||||
(NOW - CreatedAt < 2hours) AND status != 'backlog'
|
||||
```yaml
|
||||
actions:
|
||||
- key: "A"
|
||||
label: "Assign to..."
|
||||
action: update where id = id() set assignee = input()
|
||||
input: string
|
||||
- key: "t"
|
||||
label: "Add tag"
|
||||
action: update where id = id() set tags = tags + [input()]
|
||||
input: string
|
||||
- key: "T"
|
||||
label: "Remove tag"
|
||||
action: update where id = id() set tags = tags - [input()]
|
||||
input: string
|
||||
- key: "p"
|
||||
label: "Set points"
|
||||
action: update where id = id() set points = input()
|
||||
input: int
|
||||
- key: "D"
|
||||
label: "Set due date"
|
||||
action: update where id = id() set due = input()
|
||||
input: date
|
||||
```
|
||||
|
||||
### Sorting
|
||||
The input box is modal while editing — other actions are blocked until Enter or Esc. If the entered value is invalid for the declared type (e.g. non-numeric text for `int`), an error appears in the statusline and the prompt stays open for correction.
|
||||
|
||||
The `sort` field determines the order in which tikis appear in the view. You can sort by one or more fields, and control the direction (ascending or descending).
|
||||
Supported `input:` types: `string`, `int`, `bool`, `date` (YYYY-MM-DD), `timestamp` (RFC3339 or YYYY-MM-DD), `duration` (e.g. `2day`, `1week`).
|
||||
|
||||
#### Sort Syntax
|
||||
Validation rules:
|
||||
- An action with `input:` must use `input()` in its `ruki` statement
|
||||
- An action using `input()` must declare `input:` — otherwise the workflow fails to load
|
||||
- `input()` may only appear once per action
|
||||
|
||||
```text
|
||||
sort: Field1, Field2 DESC, Field3
|
||||
### Search and input box interaction
|
||||
|
||||
The input box serves both search and action-input, with explicit mode tracking:
|
||||
|
||||
- **Search editing**: pressing `/` opens the input box focused for typing. Enter with text applies the search and transitions to **search passive** mode. Enter on empty text is a no-op. Esc clears search and closes the box.
|
||||
- **Search passive**: the search box remains visible as a non-editable indicator showing the active query, while normal task navigation and actions are re-enabled. Pressing `/` again is blocked — dismiss the active search with Esc first, then open a new search. Esc clears the search results and closes the box.
|
||||
- **Action input**: pressing an input-backed action key opens a modal prompt. If search was passive, the prompt temporarily replaces the search indicator. Valid Enter executes the action and restores the passive search indicator (or closes if no prior search). Esc cancels and likewise restores passive search. Invalid Enter keeps the prompt open for correction.
|
||||
- **Modal blocking**: while search editing or action input is active, all other plugin actions and keyboard shortcuts are blocked. The action palette cannot open while the input box is editing.
|
||||
|
||||
### ruki expressions
|
||||
|
||||
Plugin filters, lane actions, and plugin actions all use the [ruki](ruki/index.md) language. Filters use `select` statements. Actions support `update`, `create`, `delete`, and `select` statements (`select` for side-effects only, output ignored).
|
||||
|
||||
#### Filter (select)
|
||||
|
||||
The `filter` field uses a `ruki` `select` statement to determine which tikis appear in a lane. Sorting is part of the select — use `order by` to control display order.
|
||||
|
||||
```sql
|
||||
-- basic filter with sort
|
||||
select where status = "backlog" and type != "epic" order by priority, id
|
||||
|
||||
-- recent items, most recent first
|
||||
select where now() - updatedAt < 24hour order by updatedAt desc
|
||||
|
||||
-- multiple conditions
|
||||
select where type = "epic" and status = "backlog" and priority > 1 order by priority, points desc
|
||||
|
||||
-- assigned to me
|
||||
select where assignee = user() order by priority
|
||||
```
|
||||
|
||||
#### Examples
|
||||
#### Action (update)
|
||||
|
||||
```text
|
||||
# Sort by creation time descending (recent first), then priority, then title
|
||||
sort: CreatedAt DESC, Priority, Title
|
||||
The `action` field uses a `ruki` `update` statement. In plugin context, `id()` refers to the currently selected tiki.
|
||||
|
||||
```sql
|
||||
-- set status on move
|
||||
update where id = id() set status="ready"
|
||||
|
||||
-- set multiple fields
|
||||
update where id = id() set status="backlog" priority=2
|
||||
|
||||
-- assign to current user
|
||||
update where id = id() set assignee=user()
|
||||
```
|
||||
|
||||
#### Supported fields
|
||||
|
||||
- `id` - task identifier (e.g., "TIKI-M7N2XK")
|
||||
- `title` - task title text
|
||||
- `type` - task type (must match a key defined in `workflow.yaml` types)
|
||||
- `status` - workflow status (must match a key defined in `workflow.yaml` statuses)
|
||||
- `assignee` - assigned user
|
||||
- `priority` - numeric priority value (1-5)
|
||||
- `points` - story points estimate
|
||||
- `tags` - list of tags
|
||||
- `dependsOn` - list of dependency tiki IDs
|
||||
- `due` - due date (YYYY-MM-DD format)
|
||||
- `recurrence` - recurrence pattern (cron format)
|
||||
- `createdAt` - creation timestamp
|
||||
- `updatedAt` - last update timestamp
|
||||
|
||||
#### Conditions
|
||||
|
||||
- **Comparison**: `=`, `!=`, `>`, `>=`, `<`, `<=`
|
||||
- **Logical**: `and`, `or`, `not` (precedence: not > and > or)
|
||||
- **Membership**: `"value" in field`, `status not in ["done", "cancelled"]`
|
||||
- **Emptiness**: `assignee is empty`, `tags is not empty`
|
||||
- **Quantifiers**: `dependsOn any status != "done"`, `dependsOn all status = "done"`
|
||||
- **Grouping**: parentheses `()` to control evaluation order
|
||||
|
||||
#### Literals and built-ins
|
||||
|
||||
- Strings: double-quoted (`"ready"`, `"alex"`)
|
||||
- Integers: `1`, `5`
|
||||
- Dates: `2026-03-25`
|
||||
- Durations: `2hour`, `14day`, `3week`, `1month`
|
||||
- Lists: `["bug", "frontend"]`
|
||||
- `user()` — current user
|
||||
- `now()` — current timestamp
|
||||
- `id()` — currently selected tiki (in plugin context)
|
||||
- `input()` — user-supplied value (in actions with `input:` declaration)
|
||||
- `count(select where ...)` — count matching tikis
|
||||
|
||||
For the full language reference, see the [ruki documentation](ruki/index.md).
|
||||
322
.doc/doki/doc/ideas/plugins.md
Normal file
|
|
@ -0,0 +1,322 @@
|
|||
# Customization Examples
|
||||
|
||||
- [Assign to me](#assign-to-me--plugin-action)
|
||||
- [Add tag to task](#add-tag-to-task--plugin-action)
|
||||
- [Custom status + reject action](#custom-status--reject-action)
|
||||
- [Implement with Claude Code](#implement-with-claude-code--pipe-action)
|
||||
- [Search all tikis](#search-all-tikis--single-lane-plugin)
|
||||
- [Quick assign](#quick-assign--lane-based-assignment)
|
||||
- [Stale task detection](#stale-task-detection--time-trigger--plugin)
|
||||
- [My tasks](#my-tasks--user-scoped-plugin)
|
||||
- [Recent ideas](#recent-ideas--good-or-trash)
|
||||
- [Auto-delete stale tasks](#auto-delete-stale-tasks--time-trigger)
|
||||
- [Priority triage](#priority-triage--five-lane-plugin)
|
||||
- [Sprint board](#sprint-board--custom-enum-lanes)
|
||||
- [Severity triage](#severity-triage--custom-enum-filter--action)
|
||||
- [Subtasks in epic](#subtasks-in-epic--custom-taskidlist--quantifier-trigger)
|
||||
- [By topic](#by-topic--tag-based-lanes)
|
||||
|
||||
## Assign to me — global plugin action
|
||||
|
||||
Shortcut key that sets the selected task's assignee to the current git user. Defined under `views.actions`, this shortcut is available in all tiki plugin views.
|
||||
|
||||
```yaml
|
||||
views:
|
||||
actions:
|
||||
- key: "a"
|
||||
label: "Assign to me"
|
||||
action: update where id = id() set assignee=user()
|
||||
```
|
||||
|
||||
The same format works as a per-plugin action (under a plugin's `actions:` key) if you only want it in a specific view.
|
||||
|
||||
## Add tag to task — plugin action
|
||||
|
||||
Appends a tag to the selected task's tag list without removing existing tags.
|
||||
|
||||
```yaml
|
||||
actions:
|
||||
- key: "t"
|
||||
label: "Tag my_project"
|
||||
action: update where id = id() set tags=tags + ["my_project"]
|
||||
```
|
||||
|
||||
## Custom status + reject action
|
||||
|
||||
Define a custom "rejected" status, then add a plugin action on the Backlog view to reject tasks.
|
||||
|
||||
```yaml
|
||||
statuses:
|
||||
- key: rejected
|
||||
label: Rejected
|
||||
emoji: "🚫"
|
||||
done: true
|
||||
```
|
||||
|
||||
```yaml
|
||||
- name: Backlog
|
||||
key: "F3"
|
||||
lanes:
|
||||
- name: Backlog
|
||||
filter: select where status = "backlog" order by priority
|
||||
actions:
|
||||
- key: "r"
|
||||
label: "Reject"
|
||||
action: update where id = id() set status="rejected"
|
||||
```
|
||||
|
||||
## Implement with Claude Code — pipe action
|
||||
|
||||
Shortcut key that pipes the selected task's title and description to Claude Code for implementation.
|
||||
|
||||
```yaml
|
||||
actions:
|
||||
- key: "i"
|
||||
label: "Implement"
|
||||
action: >
|
||||
select title, description where id = id()
|
||||
| run("claude -p 'Implement this: $1. Details: $2'")
|
||||
```
|
||||
|
||||
## Search all tikis — single-lane plugin
|
||||
|
||||
A plugin with one unfiltered lane shows every task. Press `/` to search across all of them.
|
||||
|
||||
```yaml
|
||||
- name: All
|
||||
key: "F5"
|
||||
lanes:
|
||||
- name: All
|
||||
columns: 4
|
||||
filter: select order by updatedAt desc
|
||||
```
|
||||
|
||||
## Quick assign — lane-based assignment
|
||||
|
||||
Three lanes split tasks by assignee. Moving a task into Alice's or Bob's lane auto-assigns it.
|
||||
|
||||
```yaml
|
||||
- name: Team
|
||||
key: "F6"
|
||||
lanes:
|
||||
- name: Unassigned
|
||||
filter: select where assignee is empty order by priority
|
||||
- name: Alice
|
||||
filter: select where assignee = "alice" order by priority
|
||||
action: update where id = id() set assignee="alice"
|
||||
- name: Bob
|
||||
filter: select where assignee = "bob" order by priority
|
||||
action: update where id = id() set assignee="bob"
|
||||
```
|
||||
|
||||
## Stale task detection — time trigger + plugin
|
||||
|
||||
A daily trigger tags in-progress tasks that haven't been updated in a week. A dedicated plugin shows all flagged tasks.
|
||||
|
||||
```yaml
|
||||
triggers:
|
||||
- description: flag stale in-progress tasks
|
||||
ruki: >
|
||||
every 1day
|
||||
update where status = "inProgress" and now() - updatedAt > 7day
|
||||
and "attention" not in tags
|
||||
set tags=tags + ["attention"]
|
||||
```
|
||||
|
||||
```yaml
|
||||
- name: Attention
|
||||
key: "F7"
|
||||
lanes:
|
||||
- name: Needs Attention
|
||||
columns: 4
|
||||
filter: select where "attention" in tags order by updatedAt
|
||||
```
|
||||
|
||||
## My tasks — user-scoped plugin
|
||||
|
||||
Shows only tasks assigned to the current git user.
|
||||
|
||||
```yaml
|
||||
- name: My Tasks
|
||||
key: "F8"
|
||||
lanes:
|
||||
- name: My Tasks
|
||||
columns: 4
|
||||
filter: select where assignee = user() order by priority
|
||||
```
|
||||
|
||||
## Recent ideas — good or trash?
|
||||
|
||||
Two-lane plugin to review recent ideas and trash the ones you don't need. Moving to Trash swaps the "idea" tag for "trash".
|
||||
|
||||
```yaml
|
||||
- name: Recent Ideas
|
||||
description: "Review recent"
|
||||
key: "F9"
|
||||
lanes:
|
||||
- name: Recent Ideas
|
||||
columns: 3
|
||||
filter: select where "idea" in tags and now() - createdAt < 7day order by createdAt desc
|
||||
- name: Trash
|
||||
columns: 1
|
||||
filter: select where "trash" in tags order by updatedAt desc
|
||||
action: update where id = id() set tags=tags - ["idea"] + ["trash"]
|
||||
```
|
||||
|
||||
## Auto-delete stale tasks — time trigger
|
||||
|
||||
Deletes backlog tasks that were created over 3 months ago and haven't been updated in 2 months.
|
||||
|
||||
```yaml
|
||||
triggers:
|
||||
- description: auto-delete stale backlog tasks
|
||||
ruki: >
|
||||
every 1day
|
||||
delete where status = "backlog"
|
||||
and now() - createdAt > 3month
|
||||
and now() - updatedAt > 2month
|
||||
```
|
||||
|
||||
## Priority triage — five-lane plugin
|
||||
|
||||
One lane per priority level. Moving a task between lanes reassigns its priority.
|
||||
|
||||
```yaml
|
||||
- name: Priorities
|
||||
key: "F10"
|
||||
lanes:
|
||||
- name: Critical
|
||||
filter: select where priority = 1 order by updatedAt desc
|
||||
action: update where id = id() set priority=1
|
||||
- name: High
|
||||
filter: select where priority = 2 order by updatedAt desc
|
||||
action: update where id = id() set priority=2
|
||||
- name: Medium
|
||||
filter: select where priority = 3 order by updatedAt desc
|
||||
action: update where id = id() set priority=3
|
||||
- name: Low
|
||||
filter: select where priority = 4 order by updatedAt desc
|
||||
action: update where id = id() set priority=4
|
||||
- name: Minimal
|
||||
filter: select where priority = 5 order by updatedAt desc
|
||||
action: update where id = id() set priority=5
|
||||
```
|
||||
|
||||
## Sprint board — custom enum lanes
|
||||
|
||||
Uses a custom `sprint` enum field. Lanes per sprint; moving a task between lanes reassigns it. The third lane catches unplanned backlog tasks.
|
||||
|
||||
Requires:
|
||||
|
||||
```yaml
|
||||
fields:
|
||||
- name: sprint
|
||||
type: enum
|
||||
values: [sprint-7, sprint-8, sprint-9]
|
||||
```
|
||||
|
||||
```yaml
|
||||
- name: Sprint Board
|
||||
key: "F9"
|
||||
lanes:
|
||||
- name: Current Sprint
|
||||
filter: select where sprint = "sprint-7" and status != "done" order by priority
|
||||
action: update where id = id() set sprint="sprint-7"
|
||||
- name: Next Sprint
|
||||
filter: select where sprint = "sprint-8" order by priority
|
||||
action: update where id = id() set sprint="sprint-8"
|
||||
- name: Unplanned
|
||||
filter: select where sprint is empty and status = "backlog" order by priority
|
||||
action: update where id = id() set sprint=empty
|
||||
```
|
||||
|
||||
## Severity triage — custom enum filter + action
|
||||
|
||||
Lanes per severity level. The last lane combines two values with `or`. A per-plugin action lets you mark a task as trivial without moving it.
|
||||
|
||||
Requires:
|
||||
|
||||
```yaml
|
||||
fields:
|
||||
- name: severity
|
||||
type: enum
|
||||
values: [critical, major, minor, trivial]
|
||||
```
|
||||
|
||||
```yaml
|
||||
- name: Severity
|
||||
key: "F10"
|
||||
lanes:
|
||||
- name: Critical
|
||||
filter: select where severity = "critical" order by updatedAt desc
|
||||
action: update where id = id() set severity="critical"
|
||||
- name: Major
|
||||
filter: select where severity = "major" order by updatedAt desc
|
||||
action: update where id = id() set severity="major"
|
||||
- name: Minor & Trivial
|
||||
columns: 2
|
||||
filter: >
|
||||
select where severity = "minor" or severity = "trivial"
|
||||
order by severity, priority
|
||||
action: update where id = id() set severity="minor"
|
||||
actions:
|
||||
- key: "t"
|
||||
label: "Trivial"
|
||||
action: update where id = id() set severity="trivial"
|
||||
```
|
||||
|
||||
## Subtasks in epic — custom taskIdList + quantifier trigger
|
||||
|
||||
A `subtasks` field on parent tasks tracks their children (inverse of `dependsOn`). A trigger auto-completes the parent when every subtask is done. The plugin shows open vs. completed parents.
|
||||
|
||||
Requires:
|
||||
|
||||
```yaml
|
||||
fields:
|
||||
- name: subtasks
|
||||
type: taskIdList
|
||||
```
|
||||
|
||||
```yaml
|
||||
triggers:
|
||||
- description: close parent when all subtasks are done
|
||||
ruki: >
|
||||
every 5min
|
||||
update where subtasks is not empty
|
||||
and status != "done"
|
||||
and all subtasks where status = "done"
|
||||
set status="done"
|
||||
```
|
||||
|
||||
```yaml
|
||||
- name: Epics
|
||||
key: "F11"
|
||||
lanes:
|
||||
- name: In Progress
|
||||
filter: >
|
||||
select where subtasks is not empty
|
||||
and status != "done"
|
||||
order by priority
|
||||
- name: Completed
|
||||
columns: 1
|
||||
filter: >
|
||||
select where subtasks is not empty
|
||||
and status = "done"
|
||||
order by updatedAt desc
|
||||
```
|
||||
|
||||
## By topic — tag-based lanes
|
||||
|
||||
Split tasks into lanes by tag. Useful for viewing work across domains at a glance.
|
||||
|
||||
```yaml
|
||||
- name: By Topic
|
||||
key: "F11"
|
||||
lanes:
|
||||
- name: Frontend
|
||||
columns: 2
|
||||
filter: select where "frontend" in tags order by priority
|
||||
- name: Backend
|
||||
columns: 2
|
||||
filter: select where "backend" in tags order by priority
|
||||
```
|
||||
298
.doc/doki/doc/ideas/triggers.md
Normal file
|
|
@ -0,0 +1,298 @@
|
|||
# Trigger Ideas
|
||||
|
||||
Candidate triggers for the default workflow. Each represents a common workflow pattern.
|
||||
|
||||
- [WIP limit per assignee](#wip-limit-per-assignee)
|
||||
- [Require description for high-priority tasks](#require-description-for-high-priority-tasks)
|
||||
- [Auto-create next occurrence for recurring tasks](#auto-create-next-occurrence-for-recurring-tasks)
|
||||
- [Return stale in-progress tasks to backlog](#return-stale-in-progress-tasks-to-backlog)
|
||||
- [Unblock tasks when dependencies complete](#unblock-tasks-when-dependencies-complete)
|
||||
- [Prevent re-opening completed tasks directly](#prevent-re-opening-completed-tasks-directly)
|
||||
- [Auto-assign creator on start](#auto-assign-creator-on-start)
|
||||
- [Escalate overdue tasks](#escalate-overdue-tasks)
|
||||
- [Require points estimate before review](#require-points-estimate-before-review)
|
||||
- [Auto-tag bugs as urgent when high priority](#auto-tag-bugs-as-urgent-when-high-priority)
|
||||
- [Prevent epics from being assigned](#prevent-epics-from-being-assigned)
|
||||
- [Auto-remove urgent tag when priority drops](#auto-remove-urgent-tag-when-priority-drops)
|
||||
- [Notify on critical task creation via webhook](#notify-on-critical-task-creation-via-webhook)
|
||||
- [Spike time-box](#spike-time-box)
|
||||
- [Require task breakdown for large estimates](#require-task-breakdown-for-large-estimates)
|
||||
- [Propagate priority from epic to children](#propagate-priority-from-epic-to-children)
|
||||
- [Auto-set due date for in-progress tasks](#auto-set-due-date-for-in-progress-tasks)
|
||||
- [Require a title on creation](#require-a-title-on-creation)
|
||||
- [Prevent creating tasks as done](#prevent-creating-tasks-as-done)
|
||||
- [Epics require a description at creation](#epics-require-a-description-at-creation)
|
||||
- [Prevent deleting epics with active children](#prevent-deleting-epics-with-active-children)
|
||||
- [Block deletion of high-priority tasks](#block-deletion-of-high-priority-tasks)
|
||||
|
||||
## WIP limit per assignee
|
||||
|
||||
Prevent overloading a single person with too many active tasks.
|
||||
|
||||
```yaml
|
||||
- description: limit work in progress to 3 tasks per assignee
|
||||
ruki: >
|
||||
before update
|
||||
where new.status = "inProgress"
|
||||
and new.assignee is not empty
|
||||
and count(select where assignee = new.assignee and status = "inProgress" and id != new.id) >= 3
|
||||
deny "WIP limit reached: assignee already has 3 tasks in progress"
|
||||
```
|
||||
|
||||
## Require description for high-priority tasks
|
||||
|
||||
Force authors to provide context before something becomes urgent.
|
||||
|
||||
```yaml
|
||||
- description: high priority tasks must have a description
|
||||
ruki: >
|
||||
before update
|
||||
where new.priority <= 2 and new.description is empty
|
||||
deny "high priority tasks require a description"
|
||||
```
|
||||
|
||||
## Auto-create next occurrence for recurring tasks
|
||||
|
||||
Recurring tasks automatically regenerate when completed.
|
||||
|
||||
```yaml
|
||||
- description: spawn next occurrence when recurring task completes
|
||||
ruki: >
|
||||
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="backlog"
|
||||
```
|
||||
|
||||
## Return stale in-progress tasks to backlog
|
||||
|
||||
Tasks untouched for 7 days are likely blocked or abandoned.
|
||||
|
||||
```yaml
|
||||
- description: return stale in-progress tasks to backlog after 7 days
|
||||
ruki: >
|
||||
every 1day
|
||||
update where status = "inProgress" and now() - updatedAt > 7day
|
||||
set status="backlog"
|
||||
```
|
||||
|
||||
## Unblock tasks when dependencies complete
|
||||
|
||||
Automatically promote tasks to ready when all their dependencies are done.
|
||||
|
||||
```yaml
|
||||
- description: unblock ready tasks when all dependencies complete
|
||||
ruki: >
|
||||
after update
|
||||
where new.status = "done" and old.status != "done"
|
||||
update where old.id in dependsOn and dependsOn all status = "done"
|
||||
set status="ready"
|
||||
```
|
||||
|
||||
## Prevent re-opening completed tasks directly
|
||||
|
||||
Enforce a deliberate re-triage step through ready status.
|
||||
|
||||
```yaml
|
||||
- description: prevent moving done tasks back to in-progress
|
||||
ruki: >
|
||||
before update
|
||||
where old.status = "done" and new.status = "inProgress"
|
||||
deny "cannot reopen completed tasks directly — move to ready first"
|
||||
```
|
||||
|
||||
## Auto-assign creator on start
|
||||
|
||||
If you pull something into in-progress without assigning it, you implicitly own it.
|
||||
|
||||
```yaml
|
||||
- description: auto-assign tasks to creator when moved to in-progress
|
||||
ruki: >
|
||||
after update
|
||||
where new.status = "inProgress" and new.assignee is empty
|
||||
update where id = new.id set assignee=user()
|
||||
```
|
||||
|
||||
Note: this is the permissive alternative to the "require assignee before starting" trigger
|
||||
that ships in the default workflow. Use one or the other, not both.
|
||||
|
||||
## Escalate overdue tasks
|
||||
|
||||
Overdue items that aren't already P1 get automatically escalated.
|
||||
|
||||
```yaml
|
||||
- description: escalate overdue tasks by raising priority
|
||||
ruki: >
|
||||
every 1day
|
||||
update where due < now() and status not in ["done"] and priority > 1
|
||||
set priority=1
|
||||
```
|
||||
|
||||
## Require points estimate before review
|
||||
|
||||
Ensures the team reflects on effort before review catches unestimated work.
|
||||
|
||||
```yaml
|
||||
- description: tasks must be estimated before entering review
|
||||
ruki: >
|
||||
before update
|
||||
where new.status = "review" and new.points = 0
|
||||
deny "estimate points before moving to review"
|
||||
```
|
||||
|
||||
## Auto-tag bugs as urgent when high priority
|
||||
|
||||
High-priority bugs should be visually flagged without relying on humans to remember.
|
||||
|
||||
```yaml
|
||||
- description: auto-tag high priority bugs as urgent
|
||||
ruki: >
|
||||
after update
|
||||
where new.type = "bug" and new.priority <= 2 and "urgent" not in new.tags
|
||||
update where id = new.id set tags=tags + ["urgent"]
|
||||
```
|
||||
|
||||
## Prevent epics from being assigned
|
||||
|
||||
Epics are containers, not actionable units — assign individual tasks instead.
|
||||
|
||||
```yaml
|
||||
- description: epics track work groups and should not be assigned
|
||||
ruki: >
|
||||
before update
|
||||
where new.type = "epic" and new.assignee is not empty
|
||||
deny "epics represent work groups — assign individual tasks instead"
|
||||
```
|
||||
|
||||
## Auto-remove urgent tag when priority drops
|
||||
|
||||
Paired with "auto-tag bugs as urgent" to keep tags in sync with priority.
|
||||
|
||||
```yaml
|
||||
- description: remove urgent tag when priority is lowered
|
||||
ruki: >
|
||||
after update
|
||||
where old.priority <= 2 and new.priority > 2 and "urgent" in new.tags
|
||||
update where id = new.id set tags=tags - ["urgent"]
|
||||
```
|
||||
|
||||
## Notify on critical task creation via webhook
|
||||
|
||||
P1s deserve immediate attention — shell integration bridges tiki to external alerting.
|
||||
|
||||
```yaml
|
||||
- description: fire webhook when a P1 task is created
|
||||
ruki: >
|
||||
after create
|
||||
where new.priority = 1
|
||||
run("curl -s -X POST https://hooks.example.com/tiki -d 'P1 created: " + new.id + " - " + new.title + "'")
|
||||
```
|
||||
|
||||
## Spike time-box
|
||||
|
||||
Spikes are investigation tasks — they should be time-boxed by definition.
|
||||
|
||||
```yaml
|
||||
- description: time-box spikes to 3 days
|
||||
ruki: >
|
||||
every 1day
|
||||
update where type = "spike" and status = "inProgress" and now() - updatedAt > 3day
|
||||
set status="done"
|
||||
```
|
||||
|
||||
## Require task breakdown for large estimates
|
||||
|
||||
Large estimates are a smell — nudge toward decomposition for better flow.
|
||||
|
||||
```yaml
|
||||
- description: large tasks should be broken into smaller pieces
|
||||
ruki: >
|
||||
before update
|
||||
where new.points >= 8 and new.type != "epic"
|
||||
deny "tasks with 8+ points should be broken down — use an epic instead"
|
||||
```
|
||||
|
||||
## Propagate priority from epic to children
|
||||
|
||||
When an epic gets escalated, its children should follow.
|
||||
|
||||
```yaml
|
||||
- description: raise child task priority when epic priority increases
|
||||
ruki: >
|
||||
after update
|
||||
where new.type = "epic" and new.priority < old.priority
|
||||
update where new.id in dependsOn and priority > new.priority
|
||||
set priority=new.priority
|
||||
```
|
||||
|
||||
## Auto-set due date for in-progress tasks
|
||||
|
||||
Tasks without deadlines drift forever — a default creates gentle pressure.
|
||||
|
||||
```yaml
|
||||
- description: default 7-day due date when work starts
|
||||
ruki: >
|
||||
after update
|
||||
where new.status = "inProgress" and new.due is empty
|
||||
update where id = new.id set due=now() + 7day
|
||||
```
|
||||
|
||||
## Require a title on creation
|
||||
|
||||
Every task needs at least a title — catch empty tasks at the gate.
|
||||
|
||||
```yaml
|
||||
- description: tasks must have a title
|
||||
ruki: >
|
||||
before create
|
||||
where new.title is empty
|
||||
deny "tasks must have a title"
|
||||
```
|
||||
|
||||
## Prevent creating tasks as done
|
||||
|
||||
New tasks should enter the workflow, not skip it entirely.
|
||||
|
||||
```yaml
|
||||
- description: new tasks cannot start as done
|
||||
ruki: >
|
||||
before create
|
||||
where new.status = "done"
|
||||
deny "cannot create a task that is already done"
|
||||
```
|
||||
|
||||
## Epics require a description at creation
|
||||
|
||||
Epics define scope — a description ensures the goal is documented upfront.
|
||||
|
||||
```yaml
|
||||
- description: epics require a description at creation
|
||||
ruki: >
|
||||
before create
|
||||
where new.type = "epic" and new.description is empty
|
||||
deny "epics must have a description explaining the goal"
|
||||
```
|
||||
|
||||
## Prevent deleting epics with active children
|
||||
|
||||
Deleting an epic should not orphan its child tasks.
|
||||
|
||||
```yaml
|
||||
- description: cannot delete epics that still have linked tasks
|
||||
ruki: >
|
||||
before delete
|
||||
where old.type = "epic" and count(select where old.id in dependsOn and status != "done") > 0
|
||||
deny "cannot delete epic with active child tasks"
|
||||
```
|
||||
|
||||
## Block deletion of high-priority tasks
|
||||
|
||||
P1 tasks require explicit demotion before they can be removed.
|
||||
|
||||
```yaml
|
||||
- description: P1 tasks require explicit demotion before deletion
|
||||
ruki: >
|
||||
before delete
|
||||
where old.priority = 1
|
||||
deny "cannot delete a P1 task — lower priority first"
|
||||
```
|
||||
|
|
@ -1,10 +1,17 @@
|
|||
# Documentation
|
||||
- [Quick start](quick-start.md)
|
||||
- [Configuration](config.md)
|
||||
- [Installation](install.md)
|
||||
- [Configuration](config.md)
|
||||
- [Command line options](command-line.md)
|
||||
- [Markdown viewer](markdown-viewer.md)
|
||||
- [Image support](image-requirements.md)
|
||||
- [Custom fields](custom-fields.md)
|
||||
- [Custom statuses and types](custom-status-type.md)
|
||||
- [Customization](customization.md)
|
||||
- [Themes](themes.md)
|
||||
- [ruki](ruki/index.md)
|
||||
- [tiki format](tiki-format.md)
|
||||
- [Quick capture](quick-capture.md)
|
||||
- [AI skills](skills.md)
|
||||
- [AI collaboration](ai.md)
|
||||
- [Recipes](ideas/plugins.md)
|
||||
- [Triggers](ideas/triggers.md)
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
# Markdown viewer
|
||||
|
||||

|
||||
see [requirements](image-requirements.md) for supported terminals, SVG and diagrams support
|
||||
|
||||
## Open Markdown
|
||||
`tiki` can be used as a navigable Markdown viewer. A Markdown file can be opened via:
|
||||
|
|
@ -12,7 +13,7 @@ tiki my-file.md
|
|||
|
||||
- HTTP link
|
||||
```
|
||||
tiki https://raw.githubusercontent.com/mxstbr/markdown-test-file/refs/heads/master/TEST.md
|
||||
tiki https://github.com/boolean-maybe/tiki/blob/main/testdata/go-concurrency.md
|
||||
```
|
||||
|
||||
- From STDIN
|
||||
|
|
@ -64,4 +65,4 @@ Horizontal Navigation
|
|||
|
||||
## Edit and save
|
||||
|
||||
Press `e` to edit the raw Markdown source file in editor
|
||||
Press `e` to edit the raw Markdown source file in editor
|
||||
|
|
|
|||
|
|
@ -9,14 +9,20 @@ All vim-like pager commands are supported in addition to:
|
|||
|
||||
## File and issue management
|
||||
|
||||
`cd` into your **git** repo and run `tiki init` to initialize.
|
||||
`cd` into your **git** repo and run `tiki init` to initialize
|
||||
Or you can also check out a demo project and play around:
|
||||
```
|
||||
cd /tmp && tiki demo
|
||||
```
|
||||
|
||||
Move your tiki around the board with `Shift ←/Shift →`.
|
||||
Make sure to press `?` for help.
|
||||
Press `?` to open the Action Palette — it lists all available actions with their shortcuts.
|
||||
Press `F1` to open a sample doc root. Follow links with `Tab/Enter`
|
||||
|
||||
## AI skills
|
||||
You will be prompted to install skills for
|
||||
- [Claude Code](https://code.claude.com)
|
||||
- [Gemini CLI](https://github.com/google-gemini/gemini-cli)
|
||||
- [Codex](https://openai.com/codex)
|
||||
- [Opencode](https://opencode.ai)
|
||||
|
||||
|
|
@ -50,14 +56,14 @@ Store your notes in remotes!
|
|||
|
||||
`tiki` TUI tool allows creating, viewing, editing and deleting tikis as well as creating custom plugins to
|
||||
view any selection, for example, Recent tikis, Architecture docs, Saved prompts, Security review, Future Roadmap
|
||||
Read more by pressing `?` for help
|
||||
Press `?` to open the Action Palette and discover all available actions
|
||||
|
||||
# AI skills
|
||||
|
||||
`tiki` adds optional [agent skills](https://agentskills.io/home) to the repo upon initialization
|
||||
If installed you can:
|
||||
|
||||
- work with [Claude Code](https://code.claude.com), [Codex](https://openai.com/codex), [Opencode](https://opencode.ai) by simply mentioning `tiki` or `doki` in your prompts
|
||||
- work with [Claude Code](https://code.claude.com), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Codex](https://openai.com/codex), [Opencode](https://opencode.ai) by simply mentioning `tiki` or `doki` in your prompts
|
||||
- create, find, modify and delete tikis using AI
|
||||
- create tikis/dokis directly from Markdown files
|
||||
- Refer to tikis or dokis when implementing with AI-assisted development - `implement tiki xxxxxxx`
|
||||
|
|
|
|||
175
.doc/doki/doc/ruki/custom-fields-reference.md
Normal file
|
|
@ -0,0 +1,175 @@
|
|||
# Custom Fields Reference
|
||||
|
||||
## Table of contents
|
||||
|
||||
- [Overview](#overview)
|
||||
- [Registration and loading](#registration-and-loading)
|
||||
- [Naming constraints](#naming-constraints)
|
||||
- [Type coercion rules](#type-coercion-rules)
|
||||
- [Enum domain isolation](#enum-domain-isolation)
|
||||
- [Validation rules](#validation-rules)
|
||||
- [Persistence and round-trip behavior](#persistence-and-round-trip-behavior)
|
||||
- [Schema evolution and stale data](#schema-evolution-and-stale-data)
|
||||
- [Template defaults](#template-defaults)
|
||||
- [Query behavior](#query-behavior)
|
||||
- [Missing-field semantics](#missing-field-semantics)
|
||||
|
||||
## Overview
|
||||
|
||||
Custom fields extend tiki's built-in field catalog with user-defined fields declared in `workflow.yaml`. This reference covers the precise rules for how custom fields are loaded, validated, persisted, and queried — the behavioral contract behind the [Custom Fields](../custom-fields.md) user guide.
|
||||
|
||||
## Registration and loading
|
||||
|
||||
Custom field definitions are loaded from all `workflow.yaml` files across the three-tier search path (user config, project config, working directory). Files that define `fields:` but no `views:` still contribute field definitions.
|
||||
|
||||
Definitions from multiple files are merged by name:
|
||||
|
||||
- **identical redefinition** (same name, same type, same enum values in same order): silently accepted
|
||||
- **conflicting redefinition** (same name, different type or values): fatal error naming both source files
|
||||
|
||||
After merging, fields are sorted by name for deterministic ordering and registered into the field catalog alongside built-in fields. Once registered, custom fields are available for ruki parsing, validation, and execution.
|
||||
|
||||
Registration happens during bootstrap before any task or template loading occurs.
|
||||
|
||||
## Naming constraints
|
||||
|
||||
Field names must:
|
||||
|
||||
- match the ruki identifier pattern (letters, digits, underscores; must start with a letter)
|
||||
- not collide with ruki reserved keywords (`select`, `update`, `where`, `and`, `or`, `not`, `in`, `is`, `empty`, `order`, `by`, `asc`, `desc`, `set`, `create`, `delete`, `limit`, etc.)
|
||||
- not collide with built-in field names, case-insensitively (`title`, `status`, `priority`, `tags`, `dependsOn`, etc.)
|
||||
- not be `true` or `false` (reserved boolean literals)
|
||||
|
||||
Collision checks are case-insensitive: `Status` and `STATUS` both collide with the built-in `status`.
|
||||
|
||||
## Type coercion rules
|
||||
|
||||
When custom field values are read from frontmatter YAML, they are coerced to the expected type:
|
||||
|
||||
| Field type | Accepted YAML values | Coercion behavior |
|
||||
|---------------|-----------------------------------------------|--------------------------------------------------|
|
||||
| `text` | string | pass-through |
|
||||
| `enum` | string | case-insensitive match against allowed values; stored in canonical casing |
|
||||
| `integer` | integer or decimal number | integer pass-through; decimal accepted only if it represents a whole number (e.g. `3.0` → `3`, but `1.5` → error) |
|
||||
| `boolean` | `true` / `false` | pass-through |
|
||||
| `datetime` | timestamp or date string | native YAML timestamps pass through; strings parsed as RFC3339, with fallback to `YYYY-MM-DD` |
|
||||
| `stringList` | YAML list of strings | each element must be a string |
|
||||
| `taskIdList` | YAML list of strings | each element coerced to uppercase, whitespace trimmed, empty entries dropped |
|
||||
|
||||
## Enum domain isolation
|
||||
|
||||
Each enum field maintains its own independent set of allowed values. Two enum fields never share a domain, even if their values happen to overlap.
|
||||
|
||||
This isolation is enforced at three levels:
|
||||
|
||||
- **assignment**: `set severity = category` is rejected even if both are enum fields
|
||||
- **comparison**: `where severity = category` is rejected (comparing different enum domains)
|
||||
- **in-expression**: string literals in an `in` list are validated against the specific enum field's allowed values
|
||||
|
||||
Enum comparison is case-insensitive: `category = "Backend"` matches a stored `"backend"`.
|
||||
|
||||
## Validation rules
|
||||
|
||||
Custom fields follow the same validation pipeline as built-in fields:
|
||||
|
||||
- **type compatibility**: assignments and comparisons are type-checked (e.g. you cannot assign a string to an integer field, or compare an enum field with an integer literal)
|
||||
- **enum value validation**: string literals assigned to or compared against an enum field must be in that field's allowed values
|
||||
- **ordering**: custom fields of orderable types (`text`, `integer`, `boolean`, `datetime`, `enum`) can appear in `order by` clauses; list types (`stringList`, `taskIdList`) are not orderable
|
||||
|
||||
## Persistence and round-trip behavior
|
||||
|
||||
Custom fields are stored in task file frontmatter alongside built-in fields. When a task is saved:
|
||||
|
||||
- custom fields appear after built-in fields, sorted alphabetically by name
|
||||
- values that look ambiguous in YAML (e.g. a text field containing `"true"`, `"42"`, or `"2026-05-15"`) are quoted to prevent YAML type coercion from corrupting them on reload
|
||||
|
||||
A save-then-load cycle preserves custom field values exactly. This holds as long as:
|
||||
|
||||
- the field definitions in `workflow.yaml` have not changed between save and load
|
||||
- enum values use canonical casing (enforced automatically by coercion)
|
||||
- timestamps round-trip through RFC3339 format
|
||||
|
||||
## Schema evolution and stale data
|
||||
|
||||
When `workflow.yaml` changes — fields renamed, enum values added or removed, field types changed — existing task files may contain values that no longer match the current schema. tiki handles this gracefully:
|
||||
|
||||
### Removed fields
|
||||
|
||||
If a frontmatter key no longer matches any registered custom field, it is preserved as an **unknown field**. Unknown fields survive load-save round-trips: they are written back to the file exactly as found. This allows manual cleanup or re-registration without data loss.
|
||||
|
||||
### Stale enum values
|
||||
|
||||
If a task file contains an enum value that is no longer in the field's allowed values list (e.g. `severity: critical` after `critical` was removed from the enum), the value is **demoted to an unknown field** with a warning. The task still loads and remains visible in views. The stale value is preserved in the file for repair.
|
||||
|
||||
### Type mismatches
|
||||
|
||||
If a value cannot be coerced to the field's current type (e.g. a text value `"not_a_number"` in a field that was changed to `integer`), the same demotion-to-unknown behavior applies: the task loads, the value is preserved, a warning is logged.
|
||||
|
||||
### General principle
|
||||
|
||||
tiki reads leniently and writes strictly. On load, unrecognized or incompatible values are preserved rather than rejected. On save, values are validated against the current schema.
|
||||
|
||||
## Template defaults
|
||||
|
||||
Custom fields in `new.md` templates follow the same coercion and validation rules as task files. If a template contains a value that cannot be coerced (e.g. a type mismatch), the invalid field is dropped with a warning and the template otherwise loads normally.
|
||||
|
||||
Template custom field values are copied into new tasks created via `create` statements or the new-task UI flow.
|
||||
|
||||
## Query behavior
|
||||
|
||||
Custom fields behave identically to built-in fields in ruki queries:
|
||||
|
||||
- usable in `where`, `order by`, `set`, `create`, and `select` field lists
|
||||
- support `is empty` / `is not empty` checks
|
||||
- support `in` / `not in` for list membership
|
||||
- list-type fields support `+` (append) and `-` (remove) operations
|
||||
- quantifiers (`any ... where`, `all ... where`) work on custom `taskIdList` fields
|
||||
|
||||
### Unset list fields
|
||||
|
||||
Unset custom list fields (`stringList`, `taskIdList`) behave the same as empty built-in list fields (`tags`, `dependsOn`). They return an empty list, not an absent value. This means:
|
||||
|
||||
- `"x" in labels` evaluates to `false` (not an error)
|
||||
- `labels + ["new"]` produces `["new"]` (not an error)
|
||||
- `labels is empty` evaluates to `true`
|
||||
|
||||
## Missing-field semantics
|
||||
|
||||
When a custom field has never been set on a task, ruki returns the typed zero value:
|
||||
|
||||
| Field type | Zero value |
|
||||
|---------------|--------------------|
|
||||
| `text` | `""` (empty string)|
|
||||
| `integer` | `0` |
|
||||
| `boolean` | `false` |
|
||||
| `datetime` | zero time |
|
||||
| `enum` | `""` (empty string)|
|
||||
| `stringList` | `[]` (empty list) |
|
||||
| `taskIdList` | `[]` (empty list) |
|
||||
|
||||
Setting a field to `empty` removes it entirely, making it indistinguishable from a field that was never set.
|
||||
|
||||
### Distinguishability by type
|
||||
|
||||
**Enum fields** preserve the missing-vs-set distinction: `""` is not a valid enum member, so `category is empty` only matches tasks where the field was never set (or was cleared). A task with `category = "frontend"` is never empty.
|
||||
|
||||
**Boolean and integer fields** do not preserve this distinction: `false` and `0` are both the zero value and the `empty` value. If you need to tell "never set" from "explicitly false" or "explicitly zero", use an enum field with named values (e.g. `yes` / `no`) instead.
|
||||
|
||||
### Worked examples
|
||||
|
||||
Suppose `blocked` is a custom boolean field:
|
||||
|
||||
| Query | never set | `false` | `true` |
|
||||
|------------------------------------|-----------|---------|--------|
|
||||
| `select where blocked = true` | no | no | yes |
|
||||
| `select where blocked = false` | yes | yes | no |
|
||||
| `select where blocked is empty` | yes | yes | no |
|
||||
| `select where blocked is not empty`| no | no | yes |
|
||||
|
||||
Suppose `category` is a custom enum field with values `[frontend, backend, infra]`:
|
||||
|
||||
| Query | never set | `"frontend"` |
|
||||
|--------------------------------------|-----------|--------------|
|
||||
| `select where category = "frontend"` | no | yes |
|
||||
| `select where category is empty` | yes | no |
|
||||
| `select where category is not empty` | no | yes |
|
||||
288
.doc/doki/doc/ruki/examples.md
Normal file
|
|
@ -0,0 +1,288 @@
|
|||
# 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
|
||||
```
|
||||
|
||||
Select with limit:
|
||||
|
||||
```sql
|
||||
select order by priority limit 3
|
||||
select where "bug" in tags order by priority limit 5
|
||||
select id, title order by priority limit 2 | clipboard()
|
||||
```
|
||||
|
||||
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")
|
||||
```
|
||||
|
||||
Pipe select results to a shell command or clipboard:
|
||||
|
||||
```sql
|
||||
select id, title where status = "done" | run("myscript $1 $2")
|
||||
select id where id = id() | clipboard()
|
||||
select description where id = id() | clipboard()
|
||||
```
|
||||
|
||||
## Before triggers
|
||||
|
||||
Block completion when dependencies remain open:
|
||||
|
||||
```sql
|
||||
before update where new.status = "done" and dependsOn any status != "done" deny "cannot complete tiki with open dependencies"
|
||||
```
|
||||
|
||||
Require a description for high-priority work:
|
||||
|
||||
```sql
|
||||
before update where new.priority <= 2 and new.description is empty deny "high priority tikis need a description"
|
||||
```
|
||||
|
||||
Limit how many in-progress tikis someone can have:
|
||||
|
||||
```sql
|
||||
before update where new.status = "in progress" and count(select where assignee = new.assignee and status = "in progress") >= 3 deny "WIP limit reached for this assignee"
|
||||
```
|
||||
|
||||
## After triggers
|
||||
|
||||
Auto-assign urgent new work:
|
||||
|
||||
```sql
|
||||
after create where new.priority <= 2 and new.assignee is empty update where id = new.id set assignee="booleanmaybe"
|
||||
```
|
||||
|
||||
Create the next recurring tiki:
|
||||
|
||||
```sql
|
||||
after update where new.status = "done" and old.recurrence is not empty create title=old.title priority=old.priority tags=old.tags recurrence=old.recurrence due=next_date(old.recurrence) status="ready"
|
||||
```
|
||||
|
||||
Clear recurrence on the completed source tiki:
|
||||
|
||||
```sql
|
||||
after update where new.status = "done" and old.recurrence is not empty update where id = old.id set recurrence=empty
|
||||
```
|
||||
|
||||
Clean up reverse dependencies on delete:
|
||||
|
||||
```sql
|
||||
after delete update where old.id in dependsOn set dependsOn=dependsOn - [old.id]
|
||||
```
|
||||
|
||||
Run a command after an update:
|
||||
|
||||
```sql
|
||||
after update where new.status = "in progress" and "claude" in new.tags run("claude -p 'implement tiki " + old.id + "'")
|
||||
```
|
||||
|
||||
## Time triggers
|
||||
|
||||
Move stale in-progress tasks back to backlog:
|
||||
|
||||
```sql
|
||||
every 1hour update where status = "in_progress" and updatedAt < now() - 7day set status="backlog"
|
||||
```
|
||||
|
||||
Delete expired done tasks:
|
||||
|
||||
```sql
|
||||
every 1day delete where status = "done" and updatedAt < now() - 30day
|
||||
```
|
||||
|
||||
Create a weekly review task:
|
||||
|
||||
```sql
|
||||
every 1week create title="weekly review" status="ready" priority=3
|
||||
```
|
||||
|
||||
## Invalid examples
|
||||
|
||||
Unknown field:
|
||||
|
||||
```sql
|
||||
select where foo = "bar"
|
||||
```
|
||||
|
||||
Wrong field value type:
|
||||
|
||||
```sql
|
||||
create title="x" priority="high"
|
||||
```
|
||||
|
||||
Using the wrong operator with status:
|
||||
|
||||
```sql
|
||||
select where status < "done"
|
||||
```
|
||||
|
||||
Using `any` on the wrong kind of field:
|
||||
|
||||
```sql
|
||||
select where tags any status = "done"
|
||||
```
|
||||
|
||||
Using `old.` where it is not allowed:
|
||||
|
||||
```sql
|
||||
select where old.status = "done"
|
||||
```
|
||||
|
||||
A list with mixed value types:
|
||||
|
||||
```sql
|
||||
select where status in ["done", 1]
|
||||
```
|
||||
|
||||
Trigger is missing the right action:
|
||||
|
||||
```sql
|
||||
after update where new.status = "done" deny "no"
|
||||
```
|
||||
|
||||
Non-string `run(...)` command:
|
||||
|
||||
```sql
|
||||
after update run(1 + 2)
|
||||
```
|
||||
|
||||
Ordering by a non-orderable field:
|
||||
|
||||
```sql
|
||||
select order by tags
|
||||
select order by dependsOn
|
||||
```
|
||||
|
||||
Order by inside a subquery:
|
||||
|
||||
```sql
|
||||
select where count(select where status = "done" order by priority) >= 1
|
||||
```
|
||||
202
.doc/doki/doc/ruki/images/binary-op-types.svg
Normal file
|
|
@ -0,0 +1,202 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 780 580">
|
||||
<!--
|
||||
Grid system:
|
||||
- Column width: 84px, row height: 28px
|
||||
- Row header: 90px wide
|
||||
- Header bar: 26px tall
|
||||
- All fills use opacity for dark/light background compatibility
|
||||
- Valid cells: green pill with result type
|
||||
- Invalid cells: dim dash
|
||||
-->
|
||||
<defs>
|
||||
<style>
|
||||
.title { font: 700 16px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #A5B4FC; }
|
||||
.op-title { font: 700 14px -apple-system, 'Segoe UI', Roboto, sans-serif; }
|
||||
.hdr { font: 600 11px 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; fill: #E2E8F0; }
|
||||
.rhdr { font: 600 11px 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; fill: #94A3B8; }
|
||||
.cell-ok { font: 600 10px 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; fill: #34D399; }
|
||||
.cell-na { font: 400 11px -apple-system, sans-serif; fill: #475569; }
|
||||
.footnote { font: italic 11px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #64748B; }
|
||||
.legend-text { font: 400 11px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #94A3B8; }
|
||||
</style>
|
||||
<filter id="shadow" x="-4%" y="-4%" width="108%" height="116%">
|
||||
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-color="#000000" flood-opacity="0.3"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<text class="title" x="390" y="24" text-anchor="middle">Binary operator type resolution</text>
|
||||
|
||||
<!-- ==================== + OPERATOR ==================== -->
|
||||
<g transform="translate(20, 44)">
|
||||
<!-- Operator label -->
|
||||
<rect x="0" y="0" width="26" height="26" rx="7" fill="#818CF8" fill-opacity="0.25" stroke="#818CF8" stroke-width="1" stroke-opacity="0.4" filter="url(#shadow)"/>
|
||||
<text x="13" y="17" text-anchor="middle" font-size="16" font-weight="700" fill="#A5B4FC">+</text>
|
||||
<text class="op-title" x="36" y="17" fill="#A5B4FC">addition / concatenation</text>
|
||||
|
||||
<!-- Column header bar -->
|
||||
<rect x="90" y="34" width="646" height="26" rx="6" fill="#334155" fill-opacity="0.6"/>
|
||||
<text class="hdr" x="178" y="51" text-anchor="middle">string</text>
|
||||
<text class="hdr" x="264" y="51" text-anchor="middle">int</text>
|
||||
<text class="hdr" x="350" y="51" text-anchor="middle">date</text>
|
||||
<text class="hdr" x="436" y="51" text-anchor="middle">tstamp</text>
|
||||
<text class="hdr" x="522" y="51" text-anchor="middle">duration</text>
|
||||
<text class="hdr" x="608" y="51" text-anchor="middle">list<str></text>
|
||||
<text class="hdr" x="694" y="51" text-anchor="middle">list<ref></text>
|
||||
|
||||
<!-- Row 1: string -->
|
||||
<text class="rhdr" x="50" y="79" text-anchor="middle">string</text>
|
||||
<rect x="140" y="66" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
|
||||
<text class="cell-ok" x="178" y="81" text-anchor="middle">string</text>
|
||||
<text class="cell-na" x="264" y="81" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="350" y="81" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="436" y="81" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="522" y="81" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="608" y="81" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="694" y="81" text-anchor="middle">—</text>
|
||||
|
||||
<!-- Row 2: int (alternating row background) -->
|
||||
<rect x="90" y="92" width="646" height="28" rx="4" fill="#94A3B8" fill-opacity="0.04"/>
|
||||
<text class="rhdr" x="50" y="107" text-anchor="middle">int</text>
|
||||
<text class="cell-na" x="178" y="107" text-anchor="middle">—</text>
|
||||
<rect x="226" y="94" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
|
||||
<text class="cell-ok" x="264" y="109" text-anchor="middle">int</text>
|
||||
<text class="cell-na" x="350" y="107" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="436" y="107" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="522" y="107" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="608" y="107" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="694" y="107" text-anchor="middle">—</text>
|
||||
|
||||
<!-- Row 3: date -->
|
||||
<text class="rhdr" x="50" y="135" text-anchor="middle">date</text>
|
||||
<text class="cell-na" x="178" y="135" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="264" y="135" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="350" y="135" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="436" y="135" text-anchor="middle">—</text>
|
||||
<rect x="484" y="122" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
|
||||
<text class="cell-ok" x="522" y="137" text-anchor="middle">date</text>
|
||||
<text class="cell-na" x="608" y="135" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="694" y="135" text-anchor="middle">—</text>
|
||||
|
||||
<!-- Row 4: timestamp (alternating row background) -->
|
||||
<rect x="90" y="148" width="646" height="28" rx="4" fill="#94A3B8" fill-opacity="0.04"/>
|
||||
<text class="rhdr" x="50" y="163" text-anchor="middle">tstamp</text>
|
||||
<text class="cell-na" x="178" y="163" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="264" y="163" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="350" y="163" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="436" y="163" text-anchor="middle">—</text>
|
||||
<rect x="484" y="150" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
|
||||
<text class="cell-ok" x="522" y="165" text-anchor="middle">tstamp</text>
|
||||
<text class="cell-na" x="608" y="163" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="694" y="163" text-anchor="middle">—</text>
|
||||
|
||||
<!-- Row 5: list<string> -->
|
||||
<text class="rhdr" x="50" y="191" text-anchor="middle">list<str></text>
|
||||
<rect x="140" y="178" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
|
||||
<text class="cell-ok" x="178" y="193" text-anchor="middle">list<str></text>
|
||||
<text class="cell-na" x="264" y="191" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="350" y="191" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="436" y="191" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="522" y="191" text-anchor="middle">—</text>
|
||||
<rect x="570" y="178" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
|
||||
<text class="cell-ok" x="608" y="193" text-anchor="middle">list<str></text>
|
||||
<text class="cell-na" x="694" y="191" text-anchor="middle">—</text>
|
||||
|
||||
<!-- Row 6: list<ref> (alternating row background) -->
|
||||
<rect x="90" y="204" width="646" height="28" rx="4" fill="#94A3B8" fill-opacity="0.04"/>
|
||||
<text class="rhdr" x="50" y="219" text-anchor="middle">list<ref></text>
|
||||
<text class="cell-na" x="178" y="219" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="264" y="219" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="350" y="219" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="436" y="219" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="522" y="219" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="608" y="219" text-anchor="middle">—</text>
|
||||
<rect x="656" y="206" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
|
||||
<text class="cell-ok" x="694" y="221" text-anchor="middle">list<ref></text>
|
||||
</g>
|
||||
|
||||
<!-- ==================== - OPERATOR ==================== -->
|
||||
<g transform="translate(20, 300)">
|
||||
<!-- Operator label -->
|
||||
<rect x="0" y="0" width="26" height="26" rx="7" fill="#F87171" fill-opacity="0.25" stroke="#F87171" stroke-width="1" stroke-opacity="0.4" filter="url(#shadow)"/>
|
||||
<text x="13" y="17" text-anchor="middle" font-size="16" font-weight="700" fill="#F87171">−</text>
|
||||
<text class="op-title" x="36" y="17" fill="#F87171">subtraction / removal</text>
|
||||
|
||||
<!-- Column header bar -->
|
||||
<rect x="90" y="34" width="646" height="26" rx="6" fill="#334155" fill-opacity="0.6"/>
|
||||
<text class="hdr" x="178" y="51" text-anchor="middle">string</text>
|
||||
<text class="hdr" x="264" y="51" text-anchor="middle">int</text>
|
||||
<text class="hdr" x="350" y="51" text-anchor="middle">date</text>
|
||||
<text class="hdr" x="436" y="51" text-anchor="middle">tstamp</text>
|
||||
<text class="hdr" x="522" y="51" text-anchor="middle">duration</text>
|
||||
<text class="hdr" x="608" y="51" text-anchor="middle">list<str></text>
|
||||
<text class="hdr" x="694" y="51" text-anchor="middle">list<ref></text>
|
||||
|
||||
<!-- Row 1: int -->
|
||||
<text class="rhdr" x="50" y="79" text-anchor="middle">int</text>
|
||||
<text class="cell-na" x="178" y="79" text-anchor="middle">—</text>
|
||||
<rect x="226" y="66" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
|
||||
<text class="cell-ok" x="264" y="81" text-anchor="middle">int</text>
|
||||
<text class="cell-na" x="350" y="79" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="436" y="79" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="522" y="79" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="608" y="79" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="694" y="79" text-anchor="middle">—</text>
|
||||
|
||||
<!-- Row 2: date (alternating row background) -->
|
||||
<rect x="90" y="92" width="646" height="28" rx="4" fill="#94A3B8" fill-opacity="0.04"/>
|
||||
<text class="rhdr" x="50" y="107" text-anchor="middle">date</text>
|
||||
<text class="cell-na" x="178" y="107" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="264" y="107" text-anchor="middle">—</text>
|
||||
<rect x="312" y="94" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
|
||||
<text class="cell-ok" x="350" y="109" text-anchor="middle">duration</text>
|
||||
<text class="cell-na" x="436" y="107" text-anchor="middle">—</text>
|
||||
<rect x="484" y="94" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
|
||||
<text class="cell-ok" x="522" y="109" text-anchor="middle">date</text>
|
||||
<text class="cell-na" x="608" y="107" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="694" y="107" text-anchor="middle">—</text>
|
||||
|
||||
<!-- Row 3: timestamp -->
|
||||
<text class="rhdr" x="50" y="135" text-anchor="middle">tstamp</text>
|
||||
<text class="cell-na" x="178" y="135" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="264" y="135" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="350" y="135" text-anchor="middle">—</text>
|
||||
<rect x="398" y="122" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
|
||||
<text class="cell-ok" x="436" y="137" text-anchor="middle">duration</text>
|
||||
<rect x="484" y="122" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
|
||||
<text class="cell-ok" x="522" y="137" text-anchor="middle">tstamp</text>
|
||||
<text class="cell-na" x="608" y="135" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="694" y="135" text-anchor="middle">—</text>
|
||||
|
||||
<!-- Row 4: list<string> (alternating row background) -->
|
||||
<rect x="90" y="148" width="646" height="28" rx="4" fill="#94A3B8" fill-opacity="0.04"/>
|
||||
<text class="rhdr" x="50" y="163" text-anchor="middle">list<str></text>
|
||||
<rect x="140" y="150" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
|
||||
<text class="cell-ok" x="178" y="165" text-anchor="middle">list<str></text>
|
||||
<text class="cell-na" x="264" y="163" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="350" y="163" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="436" y="163" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="522" y="163" text-anchor="middle">—</text>
|
||||
<rect x="570" y="150" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
|
||||
<text class="cell-ok" x="608" y="165" text-anchor="middle">list<str></text>
|
||||
<text class="cell-na" x="694" y="163" text-anchor="middle">—</text>
|
||||
|
||||
<!-- Row 5: list<ref> -->
|
||||
<text class="rhdr" x="50" y="191" text-anchor="middle">list<ref></text>
|
||||
<text class="cell-na" x="178" y="191" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="264" y="191" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="350" y="191" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="436" y="191" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="522" y="191" text-anchor="middle">—</text>
|
||||
<text class="cell-na" x="608" y="191" text-anchor="middle">—</text>
|
||||
<rect x="656" y="178" width="76" height="22" rx="11" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
|
||||
<text class="cell-ok" x="694" y="193" text-anchor="middle">list<ref></text>
|
||||
</g>
|
||||
|
||||
<!-- Legend + footnotes -->
|
||||
<rect x="270" y="520" width="14" height="14" rx="7" fill="#10B981" fill-opacity="0.15" stroke="#10B981" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<text class="legend-text" x="290" y="531">valid (shows result type)</text>
|
||||
<text class="legend-text" x="460" y="531">— invalid</text>
|
||||
|
||||
<text class="footnote" x="390" y="554" text-anchor="middle">string includes string-like types: string, status, type, id, ref</text>
|
||||
<text class="footnote" x="390" y="570" text-anchor="middle">list<ref> + also accepts bare id/ref values on the right side</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
339
.doc/doki/doc/ruki/images/cond-railroad.svg
Normal file
|
|
@ -0,0 +1,339 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 888">
|
||||
<!--
|
||||
Condition grammar railroad diagram — transparent/dark-bg compatible
|
||||
Tracks: condition, orCond, andCond, notCond, primaryCond, exprCond
|
||||
-->
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="10" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 3.5 L 0 7 z" fill="#94A3B8"/>
|
||||
</marker>
|
||||
<filter id="shadow" x="-4%" y="-4%" width="108%" height="116%">
|
||||
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-color="#000000" flood-opacity="0.3"/>
|
||||
</filter>
|
||||
<style>
|
||||
.title { font: 700 16px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #A5B4FC; }
|
||||
.label { font: bold 14px 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; fill: #94A3B8; }
|
||||
.terminal rect { fill: #3B82F6; fill-opacity: 0.15; stroke: #3B82F6; stroke-width: 1.5; stroke-opacity: 0.5; filter: url(#shadow); }
|
||||
.terminal text { font: 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #93C5FD; font-weight: 600; }
|
||||
.nonterminal rect { fill: #F59E0B; fill-opacity: 0.15; stroke: #F59E0B; stroke-width: 1.5; stroke-opacity: 0.5; filter: url(#shadow); }
|
||||
.nonterminal text { font: 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #FCD34D; font-weight: 500; }
|
||||
.track { stroke: #94A3B8; stroke-width: 1.5; fill: none; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<!-- title -->
|
||||
<text class="title" x="450" y="22" text-anchor="middle">Condition grammar</text>
|
||||
|
||||
<!-- all track content shifted down 8px for title clearance -->
|
||||
<g transform="translate(0, 8)">
|
||||
|
||||
<!-- ==================== condition ==================== -->
|
||||
<g transform="translate(0, 20)">
|
||||
<g>
|
||||
<rect x="2" y="22" width="92" height="28" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
|
||||
<text class="label" x="10" y="36" text-anchor="start">condition</text>
|
||||
</g>
|
||||
|
||||
<circle cx="130" cy="32" r="4" fill="#94A3B8"/>
|
||||
<circle cx="130" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<line class="track" x1="134" y1="32" x2="160" y2="32"/>
|
||||
|
||||
<g class="nonterminal" transform="translate(160, 16)">
|
||||
<rect width="82" height="32" rx="4"/>
|
||||
<text x="41" y="16" text-anchor="middle" dominant-baseline="central">orCond</text>
|
||||
</g>
|
||||
|
||||
<line class="track" x1="242" y1="32" x2="280" y2="32"/>
|
||||
<circle cx="280" cy="32" r="4" fill="#94A3B8"/>
|
||||
<circle cx="280" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
</g>
|
||||
|
||||
<!-- separator -->
|
||||
<line x1="10" y1="80" x2="890" y2="80" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- ==================== orCond ==================== -->
|
||||
<!-- andCond with loopback via "or" below -->
|
||||
<g transform="translate(0, 100)">
|
||||
<g>
|
||||
<rect x="2" y="22" width="66" height="28" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
|
||||
<text class="label" x="10" y="36" text-anchor="start">orCond</text>
|
||||
</g>
|
||||
|
||||
<circle cx="130" cy="32" r="4" fill="#94A3B8"/>
|
||||
<circle cx="130" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<line class="track" x1="134" y1="32" x2="170" y2="32"/>
|
||||
|
||||
<g class="nonterminal" transform="translate(170, 16)">
|
||||
<rect width="96" height="32" rx="4"/>
|
||||
<text x="48" y="16" text-anchor="middle" dominant-baseline="central">andCond</text>
|
||||
</g>
|
||||
<line class="track" x1="266" y1="32" x2="320" y2="32"/>
|
||||
|
||||
<!-- loopback below with "or" keyword -->
|
||||
<path class="track" d="M 310,32 C 310,62 300,76 280,76 L 240,76"/>
|
||||
<g class="terminal" transform="translate(200, 60)">
|
||||
<rect width="40" height="32" rx="16"/>
|
||||
<text x="20" y="16" text-anchor="middle" dominant-baseline="central">or</text>
|
||||
</g>
|
||||
<path class="track" d="M 200,76 C 180,76 170,62 170,42"/>
|
||||
<path d="M 165,46 L 170,36 L 175,46 z" fill="#94A3B8"/>
|
||||
|
||||
<circle cx="320" cy="32" r="4" fill="#94A3B8"/>
|
||||
<circle cx="320" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
</g>
|
||||
|
||||
<!-- separator -->
|
||||
<line x1="10" y1="195" x2="890" y2="195" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- ==================== andCond ==================== -->
|
||||
<!-- notCond with loopback via "and" below -->
|
||||
<g transform="translate(0, 210)">
|
||||
<g>
|
||||
<rect x="2" y="22" width="75" height="28" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
|
||||
<text class="label" x="10" y="36" text-anchor="start">andCond</text>
|
||||
</g>
|
||||
|
||||
<circle cx="130" cy="32" r="4" fill="#94A3B8"/>
|
||||
<circle cx="130" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<line class="track" x1="134" y1="32" x2="170" y2="32"/>
|
||||
|
||||
<g class="nonterminal" transform="translate(170, 16)">
|
||||
<rect width="96" height="32" rx="4"/>
|
||||
<text x="48" y="16" text-anchor="middle" dominant-baseline="central">notCond</text>
|
||||
</g>
|
||||
<line class="track" x1="266" y1="32" x2="320" y2="32"/>
|
||||
|
||||
<!-- loopback below with "and" keyword -->
|
||||
<path class="track" d="M 310,32 C 310,62 300,76 280,76 L 240,76"/>
|
||||
<g class="terminal" transform="translate(195, 60)">
|
||||
<rect width="50" height="32" rx="16"/>
|
||||
<text x="25" y="16" text-anchor="middle" dominant-baseline="central">and</text>
|
||||
</g>
|
||||
<path class="track" d="M 195,76 C 175,76 170,62 170,42"/>
|
||||
<path d="M 165,46 L 170,36 L 175,46 z" fill="#94A3B8"/>
|
||||
|
||||
<circle cx="320" cy="32" r="4" fill="#94A3B8"/>
|
||||
<circle cx="320" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
</g>
|
||||
|
||||
<!-- separator -->
|
||||
<line x1="10" y1="305" x2="890" y2="305" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- ==================== notCond ==================== -->
|
||||
<!-- Two alternatives: "not" notCond (recursive) | primaryCond -->
|
||||
<g transform="translate(0, 320)">
|
||||
<g>
|
||||
<rect x="2" y="40" width="75" height="28" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
|
||||
<text class="label" x="10" y="54" text-anchor="start">notCond</text>
|
||||
</g>
|
||||
|
||||
<circle cx="130" cy="50" r="4" fill="#94A3B8"/>
|
||||
<circle cx="130" cy="50" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<line class="track" x1="134" y1="50" x2="170" y2="50"/>
|
||||
|
||||
<!-- upper: not + notCond -->
|
||||
<path class="track" d="M 170,50 C 170,20 180,10 200,10"/>
|
||||
<g class="terminal" transform="translate(200, -6)">
|
||||
<rect width="50" height="32" rx="16"/>
|
||||
<text x="25" y="16" text-anchor="middle" dominant-baseline="central">not</text>
|
||||
</g>
|
||||
<line class="track" x1="250" y1="10" x2="274" y2="10"/>
|
||||
<g class="nonterminal" transform="translate(274, -6)">
|
||||
<rect width="96" height="32" rx="4"/>
|
||||
<text x="48" y="16" text-anchor="middle" dominant-baseline="central">notCond</text>
|
||||
</g>
|
||||
<path class="track" d="M 370,10 C 390,10 400,20 400,50"/>
|
||||
|
||||
<!-- lower: primaryCond (straight through) -->
|
||||
<line class="track" x1="170" y1="50" x2="200" y2="50"/>
|
||||
<g class="nonterminal" transform="translate(200, 34)">
|
||||
<rect width="130" height="32" rx="4"/>
|
||||
<text x="65" y="16" text-anchor="middle" dominant-baseline="central">primaryCond</text>
|
||||
</g>
|
||||
<line class="track" x1="330" y1="50" x2="400" y2="50"/>
|
||||
|
||||
<line class="track" x1="400" y1="50" x2="440" y2="50"/>
|
||||
<circle cx="440" cy="50" r="4" fill="#94A3B8"/>
|
||||
<circle cx="440" cy="50" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
</g>
|
||||
|
||||
<!-- separator -->
|
||||
<line x1="10" y1="420" x2="890" y2="420" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- ==================== primaryCond ==================== -->
|
||||
<!-- Two alternatives: ( condition ) | exprCond -->
|
||||
<g transform="translate(0, 440)">
|
||||
<g>
|
||||
<rect x="2" y="40" width="108" height="28" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
|
||||
<text class="label" x="10" y="54" text-anchor="start">primaryCond</text>
|
||||
</g>
|
||||
|
||||
<circle cx="140" cy="50" r="4" fill="#94A3B8"/>
|
||||
<circle cx="140" cy="50" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<line class="track" x1="144" y1="50" x2="180" y2="50"/>
|
||||
|
||||
<!-- upper: ( condition ) -->
|
||||
<path class="track" d="M 180,50 C 180,20 190,10 210,10"/>
|
||||
<g class="terminal" transform="translate(210, -6)">
|
||||
<rect width="30" height="32" rx="16"/>
|
||||
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">(</text>
|
||||
</g>
|
||||
<line class="track" x1="240" y1="10" x2="258" y2="10"/>
|
||||
<g class="nonterminal" transform="translate(258, -6)">
|
||||
<rect width="100" height="32" rx="4"/>
|
||||
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">condition</text>
|
||||
</g>
|
||||
<line class="track" x1="358" y1="10" x2="376" y2="10"/>
|
||||
<g class="terminal" transform="translate(376, -6)">
|
||||
<rect width="30" height="32" rx="16"/>
|
||||
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">)</text>
|
||||
</g>
|
||||
<path class="track" d="M 406,10 C 426,10 436,20 436,50"/>
|
||||
|
||||
<!-- lower: exprCond (straight through) -->
|
||||
<line class="track" x1="180" y1="50" x2="210" y2="50"/>
|
||||
<g class="nonterminal" transform="translate(210, 34)">
|
||||
<rect width="100" height="32" rx="4"/>
|
||||
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">exprCond</text>
|
||||
</g>
|
||||
<line class="track" x1="310" y1="50" x2="436" y2="50"/>
|
||||
|
||||
<line class="track" x1="436" y1="50" x2="476" y2="50"/>
|
||||
<circle cx="476" cy="50" r="4" fill="#94A3B8"/>
|
||||
<circle cx="476" cy="50" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
</g>
|
||||
|
||||
<!-- separator -->
|
||||
<line x1="10" y1="537" x2="890" y2="537" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- ==================== exprCond ==================== -->
|
||||
<!-- expr followed by optional tail: 7 alternatives or bypass -->
|
||||
<g transform="translate(0, 555)">
|
||||
<g>
|
||||
<rect x="2" y="40" width="83" height="28" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
|
||||
<text class="label" x="10" y="54" text-anchor="start">exprCond</text>
|
||||
</g>
|
||||
|
||||
<circle cx="130" cy="50" r="4" fill="#94A3B8"/>
|
||||
<circle cx="130" cy="50" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<line class="track" x1="134" y1="50" x2="160" y2="50"/>
|
||||
|
||||
<g class="nonterminal" transform="translate(160, 34)">
|
||||
<rect width="60" height="32" rx="4"/>
|
||||
<text x="30" y="16" text-anchor="middle" dominant-baseline="central">expr</text>
|
||||
</g>
|
||||
<line class="track" x1="220" y1="50" x2="260" y2="50"/>
|
||||
|
||||
<!-- branch: bypass (no tail) above, or one of 7 tails -->
|
||||
<!-- bypass: straight across above -->
|
||||
<path class="track" d="M 250,50 C 250,15 260,5 280,5 L 730,5 C 750,5 760,15 760,50"/>
|
||||
|
||||
<!-- compareTail: compareOp + expr -->
|
||||
<path class="track" d="M 260,50 C 260,68 270,78 290,78"/>
|
||||
<g class="nonterminal" transform="translate(290, 62)">
|
||||
<rect width="110" height="32" rx="4"/>
|
||||
<text x="55" y="16" text-anchor="middle" dominant-baseline="central">compareOp</text>
|
||||
</g>
|
||||
<line class="track" x1="400" y1="78" x2="420" y2="78"/>
|
||||
<g class="nonterminal" transform="translate(420, 62)">
|
||||
<rect width="60" height="32" rx="4"/>
|
||||
<text x="30" y="16" text-anchor="middle" dominant-baseline="central">expr</text>
|
||||
</g>
|
||||
<path class="track" d="M 480,78 C 740,78 740,60 760,50"/>
|
||||
|
||||
<!-- isEmptyTail: is empty -->
|
||||
<path class="track" d="M 260,50 C 260,108 270,118 290,118"/>
|
||||
<g class="terminal" transform="translate(290, 102)">
|
||||
<rect width="36" height="32" rx="16"/>
|
||||
<text x="18" y="16" text-anchor="middle" dominant-baseline="central">is</text>
|
||||
</g>
|
||||
<line class="track" x1="326" y1="118" x2="346" y2="118"/>
|
||||
<g class="terminal" transform="translate(346, 102)">
|
||||
<rect width="70" height="32" rx="16"/>
|
||||
<text x="35" y="16" text-anchor="middle" dominant-baseline="central">empty</text>
|
||||
</g>
|
||||
<path class="track" d="M 416,118 C 740,118 740,60 760,50"/>
|
||||
|
||||
<!-- isNotEmptyTail: is not empty -->
|
||||
<path class="track" d="M 260,50 C 260,148 270,158 290,158"/>
|
||||
<g class="terminal" transform="translate(290, 142)">
|
||||
<rect width="36" height="32" rx="16"/>
|
||||
<text x="18" y="16" text-anchor="middle" dominant-baseline="central">is</text>
|
||||
</g>
|
||||
<line class="track" x1="326" y1="158" x2="346" y2="158"/>
|
||||
<g class="terminal" transform="translate(346, 142)">
|
||||
<rect width="50" height="32" rx="16"/>
|
||||
<text x="25" y="16" text-anchor="middle" dominant-baseline="central">not</text>
|
||||
</g>
|
||||
<line class="track" x1="396" y1="158" x2="416" y2="158"/>
|
||||
<g class="terminal" transform="translate(416, 142)">
|
||||
<rect width="70" height="32" rx="16"/>
|
||||
<text x="35" y="16" text-anchor="middle" dominant-baseline="central">empty</text>
|
||||
</g>
|
||||
<path class="track" d="M 486,158 C 740,158 740,60 760,50"/>
|
||||
|
||||
<!-- inTail: in expr -->
|
||||
<path class="track" d="M 260,50 C 260,188 270,198 290,198"/>
|
||||
<g class="terminal" transform="translate(290, 182)">
|
||||
<rect width="36" height="32" rx="16"/>
|
||||
<text x="18" y="16" text-anchor="middle" dominant-baseline="central">in</text>
|
||||
</g>
|
||||
<line class="track" x1="326" y1="198" x2="346" y2="198"/>
|
||||
<g class="nonterminal" transform="translate(346, 182)">
|
||||
<rect width="60" height="32" rx="4"/>
|
||||
<text x="30" y="16" text-anchor="middle" dominant-baseline="central">expr</text>
|
||||
</g>
|
||||
<path class="track" d="M 406,198 C 740,198 740,60 760,50"/>
|
||||
|
||||
<!-- notInTail: not in expr -->
|
||||
<path class="track" d="M 260,50 C 260,228 270,238 290,238"/>
|
||||
<g class="terminal" transform="translate(290, 222)">
|
||||
<rect width="50" height="32" rx="16"/>
|
||||
<text x="25" y="16" text-anchor="middle" dominant-baseline="central">not</text>
|
||||
</g>
|
||||
<line class="track" x1="340" y1="238" x2="360" y2="238"/>
|
||||
<g class="terminal" transform="translate(360, 222)">
|
||||
<rect width="36" height="32" rx="16"/>
|
||||
<text x="18" y="16" text-anchor="middle" dominant-baseline="central">in</text>
|
||||
</g>
|
||||
<line class="track" x1="396" y1="238" x2="416" y2="238"/>
|
||||
<g class="nonterminal" transform="translate(416, 222)">
|
||||
<rect width="60" height="32" rx="4"/>
|
||||
<text x="30" y="16" text-anchor="middle" dominant-baseline="central">expr</text>
|
||||
</g>
|
||||
<path class="track" d="M 476,238 C 740,238 740,60 760,50"/>
|
||||
|
||||
<!-- anyTail: any primaryCond -->
|
||||
<path class="track" d="M 260,50 C 260,268 270,278 290,278"/>
|
||||
<g class="terminal" transform="translate(290, 262)">
|
||||
<rect width="50" height="32" rx="16"/>
|
||||
<text x="25" y="16" text-anchor="middle" dominant-baseline="central">any</text>
|
||||
</g>
|
||||
<line class="track" x1="340" y1="278" x2="360" y2="278"/>
|
||||
<g class="nonterminal" transform="translate(360, 262)">
|
||||
<rect width="130" height="32" rx="4"/>
|
||||
<text x="65" y="16" text-anchor="middle" dominant-baseline="central">primaryCond</text>
|
||||
</g>
|
||||
<path class="track" d="M 490,278 C 740,278 740,60 760,50"/>
|
||||
|
||||
<!-- allTail: all primaryCond -->
|
||||
<path class="track" d="M 260,50 C 260,308 270,318 290,318"/>
|
||||
<g class="terminal" transform="translate(290, 302)">
|
||||
<rect width="50" height="32" rx="16"/>
|
||||
<text x="25" y="16" text-anchor="middle" dominant-baseline="central">all</text>
|
||||
</g>
|
||||
<line class="track" x1="340" y1="318" x2="360" y2="318"/>
|
||||
<g class="nonterminal" transform="translate(360, 302)">
|
||||
<rect width="130" height="32" rx="4"/>
|
||||
<text x="65" y="16" text-anchor="middle" dominant-baseline="central">primaryCond</text>
|
||||
</g>
|
||||
<path class="track" d="M 490,318 C 740,318 740,60 760,50"/>
|
||||
|
||||
<!-- exit -->
|
||||
<line class="track" x1="760" y1="50" x2="800" y2="50"/>
|
||||
<circle cx="800" cy="50" r="4" fill="#94A3B8"/>
|
||||
<circle cx="800" cy="50" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
</g>
|
||||
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 16 KiB |
303
.doc/doki/doc/ruki/images/expr-railroad.svg
Normal file
|
|
@ -0,0 +1,303 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 712">
|
||||
<!--
|
||||
Expression grammar railroad diagram — transparent/dark-bg compatible
|
||||
Tracks: expr, unaryExpr, funcCall, qualifiedRef
|
||||
-->
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="10" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 3.5 L 0 7 z" fill="#94A3B8"/>
|
||||
</marker>
|
||||
<filter id="shadow" x="-4%" y="-4%" width="108%" height="116%">
|
||||
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-color="#000000" flood-opacity="0.3"/>
|
||||
</filter>
|
||||
<style>
|
||||
.title { font: 700 16px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #A5B4FC; }
|
||||
.label { font: bold 14px 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; fill: #94A3B8; }
|
||||
.terminal rect { fill: #3B82F6; fill-opacity: 0.15; stroke: #3B82F6; stroke-width: 1.5; stroke-opacity: 0.5; filter: url(#shadow); }
|
||||
.terminal text { font: 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #93C5FD; font-weight: 600; }
|
||||
.nonterminal rect { fill: #F59E0B; fill-opacity: 0.15; stroke: #F59E0B; stroke-width: 1.5; stroke-opacity: 0.5; filter: url(#shadow); }
|
||||
.nonterminal text { font: 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #FCD34D; font-weight: 500; }
|
||||
.lit-terminal rect { fill: #6366F1; fill-opacity: 0.15; stroke: #6366F1; stroke-width: 1.5; stroke-opacity: 0.5; filter: url(#shadow); }
|
||||
.lit-terminal text { font: 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #A5B4FC; font-weight: 500; }
|
||||
.track { stroke: #94A3B8; stroke-width: 1.5; fill: none; }
|
||||
.track-dot { fill: #94A3B8; }
|
||||
.label-pill { fill: #94A3B8; fill-opacity: 0.08; stroke: #94A3B8; stroke-width: 0.5; stroke-opacity: 0.2; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<!-- Title -->
|
||||
<text class="title" x="450" y="22" text-anchor="middle">Expression grammar</text>
|
||||
|
||||
<!-- All content shifted down 8px for title -->
|
||||
<g transform="translate(0, 8)">
|
||||
|
||||
<!-- ==================== expr ==================== -->
|
||||
<!-- unaryExpr with loopback via +/- below -->
|
||||
<g transform="translate(0, 20)">
|
||||
<!-- label background pill -->
|
||||
<rect class="label-pill" x="2" y="20" width="50" height="24" rx="6"/>
|
||||
<text class="label" x="10" y="36" text-anchor="start">expr</text>
|
||||
|
||||
<!-- entry dot with outer ring -->
|
||||
<circle fill="#94A3B8" cx="100" cy="32" r="4"/>
|
||||
<circle fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3" cx="100" cy="32" r="7"/>
|
||||
<line class="track" x1="104" y1="32" x2="140" y2="32"/>
|
||||
|
||||
<g class="nonterminal" transform="translate(140, 16)">
|
||||
<rect width="110" height="32" rx="4"/>
|
||||
<text x="55" y="16" text-anchor="middle" dominant-baseline="central">unaryExpr</text>
|
||||
</g>
|
||||
<line class="track" x1="250" y1="32" x2="310" y2="32"/>
|
||||
|
||||
<!-- loopback below with +/- alternatives -->
|
||||
<path class="track" d="M 300,32 C 300,58 292,72 276,72"/>
|
||||
|
||||
<!-- two alternatives for the operator: + and - -->
|
||||
<!-- + path (upper of the two) -->
|
||||
<g class="terminal" transform="translate(220, 56)">
|
||||
<rect width="30" height="32" rx="16"/>
|
||||
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">+</text>
|
||||
</g>
|
||||
<path class="track" d="M 276,72 L 250,72"/>
|
||||
<path class="track" d="M 220,72 C 200,72 190,62 180,52"/>
|
||||
|
||||
<!-- - path (lower) -->
|
||||
<path class="track" d="M 276,72 C 276,98 268,108 250,108"/>
|
||||
<g class="terminal" transform="translate(220, 92)">
|
||||
<rect width="30" height="32" rx="16"/>
|
||||
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">-</text>
|
||||
</g>
|
||||
<path class="track" d="M 220,108 C 200,108 186,90 180,52"/>
|
||||
|
||||
<!-- arrow pointing up at rejoin -->
|
||||
<path class="track" d="M 180,52 C 175,42 170,38 140,42"/>
|
||||
<path d="M 135,46 L 140,36 L 145,46 z" fill="#94A3B8"/>
|
||||
|
||||
<!-- exit dot with outer ring -->
|
||||
<circle fill="#94A3B8" cx="310" cy="32" r="4"/>
|
||||
<circle fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5" cx="310" cy="32" r="7"/>
|
||||
</g>
|
||||
|
||||
<!-- separator line between expr and unaryExpr -->
|
||||
<line x1="10" y1="155" x2="890" y2="155" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- ==================== unaryExpr ==================== -->
|
||||
<!-- 11-way alternative fan. Use literal type boxes for value types -->
|
||||
<g transform="translate(0, 160)">
|
||||
<!-- label background pill -->
|
||||
<rect class="label-pill" x="2" y="84" width="98" height="24" rx="6"/>
|
||||
<text class="label" x="10" y="100" text-anchor="start">unaryExpr</text>
|
||||
|
||||
<!-- entry dot with outer ring -->
|
||||
<circle fill="#94A3B8" cx="120" cy="96" r="4"/>
|
||||
<circle fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3" cx="120" cy="96" r="7"/>
|
||||
<line class="track" x1="124" y1="96" x2="160" y2="96"/>
|
||||
|
||||
<!-- 1. funcCall (y=-30) -->
|
||||
<path class="track" d="M 160,96 C 160,0 170,-30 190,-30"/>
|
||||
<g class="nonterminal" transform="translate(190, -46)">
|
||||
<rect width="92" height="32" rx="4"/>
|
||||
<text x="46" y="16" text-anchor="middle" dominant-baseline="central">funcCall</text>
|
||||
</g>
|
||||
<path class="track" d="M 282,-30 C 380,-30 390,0 390,96"/>
|
||||
|
||||
<!-- 2. subQuery (y=2) -->
|
||||
<path class="track" d="M 160,96 C 160,30 170,2 190,2"/>
|
||||
<g class="nonterminal" transform="translate(190, -14)">
|
||||
<rect width="92" height="32" rx="4"/>
|
||||
<text x="46" y="16" text-anchor="middle" dominant-baseline="central">subQuery</text>
|
||||
</g>
|
||||
<path class="track" d="M 282,2 C 370,2 380,30 390,96"/>
|
||||
|
||||
<!-- 3. qualifiedRef (y=34) -->
|
||||
<path class="track" d="M 160,96 C 160,54 170,34 190,34"/>
|
||||
<g class="nonterminal" transform="translate(190, 18)">
|
||||
<rect width="120" height="32" rx="4"/>
|
||||
<text x="60" y="16" text-anchor="middle" dominant-baseline="central">qualifiedRef</text>
|
||||
</g>
|
||||
<path class="track" d="M 310,34 C 370,34 380,54 390,96"/>
|
||||
|
||||
<!-- 4. listLiteral (y=66) -->
|
||||
<path class="track" d="M 160,96 C 160,76 170,66 190,66"/>
|
||||
<g class="nonterminal" transform="translate(190, 50)">
|
||||
<rect width="110" height="32" rx="4"/>
|
||||
<text x="55" y="16" text-anchor="middle" dominant-baseline="central">listLiteral</text>
|
||||
</g>
|
||||
<path class="track" d="M 300,66 C 370,66 380,76 390,96"/>
|
||||
|
||||
<!-- 5. string (y=96 — straight through) -->
|
||||
<line class="track" x1="160" y1="96" x2="190" y2="96"/>
|
||||
<g class="lit-terminal" transform="translate(190, 80)">
|
||||
<rect width="78" height="32" rx="16"/>
|
||||
<text x="39" y="16" text-anchor="middle" dominant-baseline="central">string</text>
|
||||
</g>
|
||||
<line class="track" x1="268" y1="96" x2="390" y2="96"/>
|
||||
|
||||
<!-- 6. date (y=128) -->
|
||||
<path class="track" d="M 160,96 C 160,116 170,128 190,128"/>
|
||||
<g class="lit-terminal" transform="translate(190, 112)">
|
||||
<rect width="64" height="32" rx="16"/>
|
||||
<text x="32" y="16" text-anchor="middle" dominant-baseline="central">date</text>
|
||||
</g>
|
||||
<path class="track" d="M 254,128 C 370,128 380,116 390,96"/>
|
||||
|
||||
<!-- 7. duration (y=160) -->
|
||||
<path class="track" d="M 160,96 C 160,140 170,160 190,160"/>
|
||||
<g class="lit-terminal" transform="translate(190, 144)">
|
||||
<rect width="86" height="32" rx="16"/>
|
||||
<text x="43" y="16" text-anchor="middle" dominant-baseline="central">duration</text>
|
||||
</g>
|
||||
<path class="track" d="M 276,160 C 370,160 380,140 390,96"/>
|
||||
|
||||
<!-- 8. int (y=192) -->
|
||||
<path class="track" d="M 160,96 C 160,166 170,192 190,192"/>
|
||||
<g class="lit-terminal" transform="translate(190, 176)">
|
||||
<rect width="50" height="32" rx="16"/>
|
||||
<text x="25" y="16" text-anchor="middle" dominant-baseline="central">int</text>
|
||||
</g>
|
||||
<path class="track" d="M 240,192 C 370,192 380,166 390,96"/>
|
||||
|
||||
<!-- 9. empty (y=224) -->
|
||||
<path class="track" d="M 160,96 C 160,196 170,224 190,224"/>
|
||||
<g class="lit-terminal" transform="translate(190, 208)">
|
||||
<rect width="70" height="32" rx="16"/>
|
||||
<text x="35" y="16" text-anchor="middle" dominant-baseline="central">empty</text>
|
||||
</g>
|
||||
<path class="track" d="M 260,224 C 370,224 380,196 390,96"/>
|
||||
|
||||
<!-- 10. fieldRef (y=256) -->
|
||||
<path class="track" d="M 160,96 C 160,224 170,256 190,256"/>
|
||||
<g class="nonterminal" transform="translate(190, 240)">
|
||||
<rect width="88" height="32" rx="4"/>
|
||||
<text x="44" y="16" text-anchor="middle" dominant-baseline="central">fieldRef</text>
|
||||
</g>
|
||||
<path class="track" d="M 278,256 C 370,256 380,224 390,96"/>
|
||||
|
||||
<!-- 11. ( expr ) (y=288) -->
|
||||
<path class="track" d="M 160,96 C 160,256 170,288 190,288"/>
|
||||
<g class="terminal" transform="translate(190, 272)">
|
||||
<rect width="30" height="32" rx="16"/>
|
||||
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">(</text>
|
||||
</g>
|
||||
<line class="track" x1="220" y1="288" x2="236" y2="288"/>
|
||||
<g class="nonterminal" transform="translate(236, 272)">
|
||||
<rect width="60" height="32" rx="4"/>
|
||||
<text x="30" y="16" text-anchor="middle" dominant-baseline="central">expr</text>
|
||||
</g>
|
||||
<line class="track" x1="296" y1="288" x2="312" y2="288"/>
|
||||
<g class="terminal" transform="translate(312, 272)">
|
||||
<rect width="30" height="32" rx="16"/>
|
||||
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">)</text>
|
||||
</g>
|
||||
<path class="track" d="M 342,288 C 370,288 380,256 390,96"/>
|
||||
|
||||
<!-- exit -->
|
||||
<line class="track" x1="390" y1="96" x2="430" y2="96"/>
|
||||
<!-- exit dot with outer ring -->
|
||||
<circle fill="#94A3B8" cx="430" cy="96" r="4"/>
|
||||
<circle fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5" cx="430" cy="96" r="7"/>
|
||||
</g>
|
||||
|
||||
<!-- separator line between unaryExpr and funcCall -->
|
||||
<line x1="10" y1="505" x2="890" y2="505" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- ==================== funcCall ==================== -->
|
||||
<g transform="translate(0, 510)">
|
||||
<!-- label background pill -->
|
||||
<rect class="label-pill" x="2" y="20" width="82" height="24" rx="6"/>
|
||||
<text class="label" x="10" y="36" text-anchor="start">funcCall</text>
|
||||
|
||||
<!-- entry dot with outer ring -->
|
||||
<circle fill="#94A3B8" cx="120" cy="32" r="4"/>
|
||||
<circle fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3" cx="120" cy="32" r="7"/>
|
||||
<line class="track" x1="124" y1="32" x2="150" y2="32"/>
|
||||
|
||||
<g class="nonterminal" transform="translate(150, 16)">
|
||||
<rect width="100" height="32" rx="4"/>
|
||||
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">identifier</text>
|
||||
</g>
|
||||
<line class="track" x1="250" y1="32" x2="270" y2="32"/>
|
||||
|
||||
<g class="terminal" transform="translate(270, 16)">
|
||||
<rect width="30" height="32" rx="16"/>
|
||||
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">(</text>
|
||||
</g>
|
||||
<line class="track" x1="300" y1="32" x2="330" y2="32"/>
|
||||
|
||||
<!-- optional args: bypass above, expr with comma loopback below -->
|
||||
<!-- main path: expr -->
|
||||
<g class="nonterminal" transform="translate(330, 16)">
|
||||
<rect width="60" height="32" rx="4"/>
|
||||
<text x="30" y="16" text-anchor="middle" dominant-baseline="central">expr</text>
|
||||
</g>
|
||||
<line class="track" x1="390" y1="32" x2="440" y2="32"/>
|
||||
|
||||
<!-- loopback below with comma -->
|
||||
<path class="track" d="M 430,32 C 430,62 420,72 400,72"/>
|
||||
<g class="terminal" transform="translate(355, 56)">
|
||||
<rect width="30" height="32" rx="16"/>
|
||||
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">,</text>
|
||||
</g>
|
||||
<path class="track" d="M 355,72 C 340,72 330,62 330,42"/>
|
||||
<path d="M 325,46 L 330,36 L 335,46 z" fill="#94A3B8"/>
|
||||
|
||||
<!-- bypass above: no args -->
|
||||
<path class="track" d="M 320,32 C 320,5 330,-5 350,-5 L 410,-5 C 430,-5 440,5 440,32"/>
|
||||
|
||||
<g class="terminal" transform="translate(440, 16)">
|
||||
<rect width="30" height="32" rx="16"/>
|
||||
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">)</text>
|
||||
</g>
|
||||
<line class="track" x1="470" y1="32" x2="510" y2="32"/>
|
||||
<!-- exit dot with outer ring -->
|
||||
<circle fill="#94A3B8" cx="510" cy="32" r="4"/>
|
||||
<circle fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5" cx="510" cy="32" r="7"/>
|
||||
</g>
|
||||
|
||||
<!-- separator line between funcCall and qualifiedRef -->
|
||||
<line x1="10" y1="605" x2="890" y2="605" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- ==================== qualifiedRef ==================== -->
|
||||
<g transform="translate(0, 610)">
|
||||
<!-- label background pill -->
|
||||
<rect class="label-pill" x="2" y="20" width="118" height="24" rx="6"/>
|
||||
<text class="label" x="10" y="36" text-anchor="start">qualifiedRef</text>
|
||||
|
||||
<!-- entry dot with outer ring -->
|
||||
<circle fill="#94A3B8" cx="140" cy="32" r="4"/>
|
||||
<circle fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3" cx="140" cy="32" r="7"/>
|
||||
<line class="track" x1="144" y1="32" x2="180" y2="32"/>
|
||||
|
||||
<!-- alternative: old | new -->
|
||||
<path class="track" d="M 180,32 C 180,10 190,0 210,0"/>
|
||||
<g class="terminal" transform="translate(210, -16)">
|
||||
<rect width="50" height="32" rx="16"/>
|
||||
<text x="25" y="16" text-anchor="middle" dominant-baseline="central">old</text>
|
||||
</g>
|
||||
<path class="track" d="M 260,0 C 280,0 290,10 290,32"/>
|
||||
|
||||
<path class="track" d="M 180,32 C 180,54 190,64 210,64"/>
|
||||
<g class="terminal" transform="translate(210, 48)">
|
||||
<rect width="50" height="32" rx="16"/>
|
||||
<text x="25" y="16" text-anchor="middle" dominant-baseline="central">new</text>
|
||||
</g>
|
||||
<path class="track" d="M 260,64 C 280,64 290,54 290,32"/>
|
||||
|
||||
<line class="track" x1="290" y1="32" x2="310" y2="32"/>
|
||||
<g class="terminal" transform="translate(310, 16)">
|
||||
<rect width="24" height="32" rx="16"/>
|
||||
<text x="12" y="16" text-anchor="middle" dominant-baseline="central">.</text>
|
||||
</g>
|
||||
<line class="track" x1="334" y1="32" x2="354" y2="32"/>
|
||||
<g class="nonterminal" transform="translate(354, 16)">
|
||||
<rect width="100" height="32" rx="4"/>
|
||||
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">identifier</text>
|
||||
</g>
|
||||
<line class="track" x1="454" y1="32" x2="490" y2="32"/>
|
||||
<!-- exit dot with outer ring -->
|
||||
<circle fill="#94A3B8" cx="490" cy="32" r="4"/>
|
||||
<circle fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5" cx="490" cy="32" r="7"/>
|
||||
</g>
|
||||
|
||||
</g><!-- end content wrapper -->
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
95
.doc/doki/doc/ruki/images/qualifier-scope.svg
Normal file
|
|
@ -0,0 +1,95 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 500 350">
|
||||
<!--
|
||||
Grid system:
|
||||
- Row height: 40px, gap: 6px
|
||||
- Column widths: context=200, old=100, new=100
|
||||
- Start x=40, y=60
|
||||
- Indicator pills: 60x28, rx=14
|
||||
- All fills use opacity for transparency on any background
|
||||
-->
|
||||
<defs>
|
||||
<style>
|
||||
.title { font: 700 16px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #A5B4FC; }
|
||||
.col-hdr { font: 700 15px 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; }
|
||||
.row-label { font: 500 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #CBD5E1; }
|
||||
.note-text { font: 500 11px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #FBBF24; }
|
||||
.legend-text { font: 400 11px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #94A3B8; }
|
||||
.indicator { font: 700 14px -apple-system, sans-serif; }
|
||||
</style>
|
||||
<!-- Checkmark icon -->
|
||||
<symbol id="check" viewBox="0 0 16 16">
|
||||
<path d="M3 8.5 L6.5 12 L13 4" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round" fill="none"/>
|
||||
</symbol>
|
||||
<!-- X icon -->
|
||||
<symbol id="cross" viewBox="0 0 16 16">
|
||||
<path d="M4 4 L12 12 M12 4 L4 12" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" fill="none"/>
|
||||
</symbol>
|
||||
<!-- Drop shadow filter -->
|
||||
<filter id="shadow" x="-4%" y="-4%" width="108%" height="116%">
|
||||
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-color="#000000" flood-opacity="0.3"/>
|
||||
</filter>
|
||||
</defs>
|
||||
|
||||
<!-- Title -->
|
||||
<text class="title" x="250" y="30" text-anchor="middle">Qualifier scope by context</text>
|
||||
|
||||
<!-- Subtle top border -->
|
||||
<line x1="40" y1="38" x2="470" y2="38" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- Column header backgrounds -->
|
||||
<rect x="270" y="42" width="80" height="28" rx="6" fill="#818CF8" fill-opacity="0.08"/>
|
||||
<rect x="380" y="42" width="80" height="28" rx="6" fill="#34D399" fill-opacity="0.08"/>
|
||||
|
||||
<!-- Column headers -->
|
||||
<text class="col-hdr" x="310" y="62" text-anchor="middle" fill="#818CF8">old.</text>
|
||||
<text class="col-hdr" x="420" y="62" text-anchor="middle" fill="#34D399">new.</text>
|
||||
|
||||
<!-- Separator line -->
|
||||
<line x1="40" y1="74" x2="470" y2="74" stroke="#475569" stroke-width="1" stroke-opacity="0.5"/>
|
||||
|
||||
<!-- Row 1: standalone statement -->
|
||||
<text class="row-label" x="50" y="102">standalone statement</text>
|
||||
<rect x="280" y="86" width="60" height="28" rx="14" fill="#EF4444" fill-opacity="0.2" stroke="#EF4444" stroke-width="1" stroke-opacity="0.4" filter="url(#shadow)"/>
|
||||
<use href="#cross" x="296" y="92" width="16" height="16" color="#F87171"/>
|
||||
<rect x="390" y="86" width="60" height="28" rx="14" fill="#EF4444" fill-opacity="0.2" stroke="#EF4444" stroke-width="1" stroke-opacity="0.4" filter="url(#shadow)"/>
|
||||
<use href="#cross" x="406" y="92" width="16" height="16" color="#F87171"/>
|
||||
|
||||
<!-- Row 2: create trigger (alternate row highlight) -->
|
||||
<rect x="40" y="120" width="430" height="40" rx="4" fill="#94A3B8" fill-opacity="0.04"/>
|
||||
<text class="row-label" x="50" y="148">create trigger</text>
|
||||
<rect x="280" y="132" width="60" height="28" rx="14" fill="#EF4444" fill-opacity="0.2" stroke="#EF4444" stroke-width="1" stroke-opacity="0.4" filter="url(#shadow)"/>
|
||||
<use href="#cross" x="296" y="138" width="16" height="16" color="#F87171"/>
|
||||
<rect x="390" y="132" width="60" height="28" rx="14" fill="#10B981" fill-opacity="0.2" stroke="#10B981" stroke-width="1" stroke-opacity="0.4" filter="url(#shadow)"/>
|
||||
<use href="#check" x="406" y="138" width="16" height="16" color="#34D399"/>
|
||||
|
||||
<!-- Row 3: update trigger -->
|
||||
<text class="row-label" x="50" y="194">update trigger</text>
|
||||
<rect x="280" y="178" width="60" height="28" rx="14" fill="#10B981" fill-opacity="0.2" stroke="#10B981" stroke-width="1" stroke-opacity="0.4" filter="url(#shadow)"/>
|
||||
<use href="#check" x="296" y="184" width="16" height="16" color="#34D399"/>
|
||||
<rect x="390" y="178" width="60" height="28" rx="14" fill="#10B981" fill-opacity="0.2" stroke="#10B981" stroke-width="1" stroke-opacity="0.4" filter="url(#shadow)"/>
|
||||
<use href="#check" x="406" y="184" width="16" height="16" color="#34D399"/>
|
||||
|
||||
<!-- Row 4: delete trigger (alternate row highlight) -->
|
||||
<rect x="40" y="212" width="430" height="40" rx="4" fill="#94A3B8" fill-opacity="0.04"/>
|
||||
<text class="row-label" x="50" y="240">delete trigger</text>
|
||||
<rect x="280" y="224" width="60" height="28" rx="14" fill="#10B981" fill-opacity="0.2" stroke="#10B981" stroke-width="1" stroke-opacity="0.4" filter="url(#shadow)"/>
|
||||
<use href="#check" x="296" y="230" width="16" height="16" color="#34D399"/>
|
||||
<rect x="390" y="224" width="60" height="28" rx="14" fill="#EF4444" fill-opacity="0.2" stroke="#EF4444" stroke-width="1" stroke-opacity="0.4" filter="url(#shadow)"/>
|
||||
<use href="#cross" x="406" y="230" width="16" height="16" color="#F87171"/>
|
||||
|
||||
<!-- Separator -->
|
||||
<line x1="40" y1="264" x2="470" y2="264" stroke="#475569" stroke-width="1" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- Note about quantifier body -->
|
||||
<rect x="40" y="274" width="430" height="30" rx="8" fill="#FBBF24" fill-opacity="0.1" stroke="#FBBF24" stroke-width="1" stroke-opacity="0.3" filter="url(#shadow)"/>
|
||||
<text class="note-text" x="255" y="293" text-anchor="middle">quantifier body (any / all): both old. and new. are disabled</text>
|
||||
|
||||
<!-- Legend -->
|
||||
<rect x="100" y="316" width="16" height="16" rx="8" fill="#10B981" fill-opacity="0.2" stroke="#10B981" stroke-width="1" stroke-opacity="0.4"/>
|
||||
<use href="#check" x="102" y="318" width="12" height="12" color="#34D399"/>
|
||||
<text class="legend-text" x="124" y="328">allowed</text>
|
||||
|
||||
<rect x="230" y="316" width="16" height="16" rx="8" fill="#EF4444" fill-opacity="0.2" stroke="#EF4444" stroke-width="1" stroke-opacity="0.4"/>
|
||||
<use href="#cross" x="232" y="318" width="12" height="12" color="#F87171"/>
|
||||
<text class="legend-text" x="254" y="328">not allowed</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 5.8 KiB |
286
.doc/doki/doc/ruki/images/stmt-railroad.svg
Normal file
|
|
@ -0,0 +1,286 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1020 560">
|
||||
<!--
|
||||
Grid system for Ruki railroad diagrams (transparent/dark-bg compatible)
|
||||
========================================
|
||||
Terminal (keyword) box: height=32, rx=16 (fully rounded ends)
|
||||
Non-terminal box: height=32, rx=4 (subtle rounding)
|
||||
Track vertical spacing: ~130px between track baselines
|
||||
Label column: x=10, track starts at x=140
|
||||
Line/dot color: #94A3B8
|
||||
Terminal: fill=#3B82F6 fill-opacity=0.15, stroke=#3B82F6 stroke-opacity=0.5
|
||||
Non-terminal: fill=#F59E0B fill-opacity=0.15, stroke=#F59E0B stroke-opacity=0.4
|
||||
Loopback: below main line (standard railroad repetition)
|
||||
Bypass: above main line for optional elements
|
||||
|
||||
Visual polish:
|
||||
- Drop shadow filter on all boxes (subtle, dark-bg friendly)
|
||||
- Label background pills with rounded rect behind each rule name
|
||||
- Entry dots with outer ring; exit dots with double circle
|
||||
- Separator lines between tracks
|
||||
- Title text at top in accent color
|
||||
- viewBox height +20 to accommodate title; content shifted down 8px
|
||||
-->
|
||||
<defs>
|
||||
<filter id="shadow" x="-4%" y="-4%" width="108%" height="116%">
|
||||
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-color="#000000" flood-opacity="0.3"/>
|
||||
</filter>
|
||||
<marker id="arrow" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="10" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 3.5 L 0 7 z" fill="#94A3B8"/>
|
||||
</marker>
|
||||
<style>
|
||||
.title { font: 700 16px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #A5B4FC; }
|
||||
.label { font: bold 14px 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; fill: #94A3B8; }
|
||||
.terminal rect { fill: #3B82F6; fill-opacity: 0.15; stroke: #3B82F6; stroke-width: 1.5; stroke-opacity: 0.5; filter: url(#shadow); }
|
||||
.terminal text { font: 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #93C5FD; font-weight: 600; }
|
||||
.nonterminal rect { fill: #F59E0B; fill-opacity: 0.15; stroke: #F59E0B; stroke-width: 1.5; stroke-opacity: 0.4; filter: url(#shadow); }
|
||||
.nonterminal text { font: 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #FCD34D; font-weight: 500; }
|
||||
.track { stroke: #94A3B8; stroke-width: 1.5; fill: none; }
|
||||
.track-dot { fill: #94A3B8; }
|
||||
.separator { stroke: #475569; stroke-width: 0.5; stroke-opacity: 0.3; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<!-- diagram title -->
|
||||
<text class="title" x="410" y="22" text-anchor="middle">Statement grammar</text>
|
||||
|
||||
<!-- content shifted down 8px to accommodate title -->
|
||||
<g transform="translate(0, 8)">
|
||||
|
||||
<!-- ==================== selectStmt ==================== -->
|
||||
<!-- Main line at y=50, field-list bypass above at y=10, loopback below at y=90 -->
|
||||
<g transform="translate(0, 30)">
|
||||
<!-- label background pill -->
|
||||
<g class="label-group">
|
||||
<rect x="5" y="38" width="120" height="24" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
|
||||
<text class="label" x="65" y="54" text-anchor="middle">selectStmt</text>
|
||||
</g>
|
||||
|
||||
<!-- entry dot with ring -->
|
||||
<circle class="track-dot" cx="140" cy="50" r="4"/>
|
||||
<circle cx="140" cy="50" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<line class="track" x1="144" y1="50" x2="170" y2="50"/>
|
||||
|
||||
<!-- select keyword -->
|
||||
<g class="terminal" transform="translate(170, 34)">
|
||||
<rect width="72" height="32" rx="16"/>
|
||||
<text x="36" y="16" text-anchor="middle" dominant-baseline="central">select</text>
|
||||
</g>
|
||||
<line class="track" x1="242" y1="50" x2="260" y2="50"/>
|
||||
|
||||
<!-- === optional field list group (260–460) === -->
|
||||
|
||||
<!-- main path: * terminal -->
|
||||
<line class="track" x1="260" y1="50" x2="290" y2="50"/>
|
||||
<g class="terminal" transform="translate(290, 34)">
|
||||
<rect width="32" height="32" rx="16"/>
|
||||
<text x="16" y="16" text-anchor="middle" dominant-baseline="central">*</text>
|
||||
</g>
|
||||
<line class="track" x1="322" y1="50" x2="460" y2="50"/>
|
||||
|
||||
<!-- alternate path below: identifier { , identifier } at y=90 -->
|
||||
<path class="track" d="M 270,50 C 270,70 280,90 300,90"/>
|
||||
<g class="nonterminal" transform="translate(300, 74)">
|
||||
<rect width="100" height="32" rx="4"/>
|
||||
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">identifier</text>
|
||||
</g>
|
||||
<line class="track" x1="400" y1="90" x2="440" y2="90"/>
|
||||
<path class="track" d="M 440,90 C 450,90 460,80 460,50"/>
|
||||
|
||||
<!-- loopback below identifier: , identifier at y=130 -->
|
||||
<path class="track" d="M 430,90 C 430,110 420,130 400,130"/>
|
||||
<g class="terminal" transform="translate(356, 114)">
|
||||
<rect width="32" height="32" rx="16"/>
|
||||
<text x="16" y="16" text-anchor="middle" dominant-baseline="central">,</text>
|
||||
</g>
|
||||
<line class="track" x1="356" y1="130" x2="340" y2="130"/>
|
||||
<g class="nonterminal" transform="translate(230, 114)">
|
||||
<rect width="100" height="32" rx="4"/>
|
||||
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">identifier</text>
|
||||
</g>
|
||||
<path class="track" d="M 230,130 C 220,130 210,110 210,100"/>
|
||||
<!-- arrowhead pointing up at the rejoin into the identifier path -->
|
||||
<path d="M 205,100 L 210,90 L 215,100 z" fill="#94A3B8"/>
|
||||
|
||||
<!-- bypass path above: skip field list entirely at y=10 -->
|
||||
<path class="track" d="M 260,50 C 260,30 270,10 290,10 L 430,10 C 450,10 460,30 460,50"/>
|
||||
|
||||
<!-- === end field list group === -->
|
||||
<line class="track" x1="460" y1="50" x2="490" y2="50"/>
|
||||
|
||||
<!-- === optional where + condition group (490–680) === -->
|
||||
<g class="terminal" transform="translate(490, 34)">
|
||||
<rect width="70" height="32" rx="16"/>
|
||||
<text x="35" y="16" text-anchor="middle" dominant-baseline="central">where</text>
|
||||
</g>
|
||||
<line class="track" x1="560" y1="50" x2="580" y2="50"/>
|
||||
<g class="nonterminal" transform="translate(580, 34)">
|
||||
<rect width="100" height="32" rx="4"/>
|
||||
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">condition</text>
|
||||
</g>
|
||||
<line class="track" x1="680" y1="50" x2="710" y2="50"/>
|
||||
|
||||
<!-- bypass above: skip where+condition -->
|
||||
<path class="track" d="M 480,50 C 480,20 490,10 510,10 L 680,10 C 700,10 710,20 710,50"/>
|
||||
|
||||
<!-- === optional orderBy group (710–830) === -->
|
||||
<line class="track" x1="710" y1="50" x2="740" y2="50"/>
|
||||
<g class="nonterminal" transform="translate(740, 34)">
|
||||
<rect width="88" height="32" rx="4"/>
|
||||
<text x="44" y="16" text-anchor="middle" dominant-baseline="central">orderBy</text>
|
||||
</g>
|
||||
<line class="track" x1="828" y1="50" x2="860" y2="50"/>
|
||||
|
||||
<!-- bypass above: skip orderBy -->
|
||||
<path class="track" d="M 730,50 C 730,20 740,10 760,10 L 830,10 C 850,10 860,20 860,50"/>
|
||||
|
||||
<!-- exit dot with double circle -->
|
||||
<circle class="track-dot" cx="860" cy="50" r="4"/>
|
||||
<circle cx="860" cy="50" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
</g>
|
||||
|
||||
<!-- separator between selectStmt and createStmt -->
|
||||
<line class="separator" x1="10" y1="140" x2="1010" y2="140"/>
|
||||
|
||||
<!-- ==================== createStmt ==================== -->
|
||||
<!-- Main line at y=32, loopback below at y=72 -->
|
||||
<g transform="translate(0, 150)">
|
||||
<!-- label background pill -->
|
||||
<g class="label-group">
|
||||
<rect x="5" y="18" width="122" height="24" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
|
||||
<text class="label" x="66" y="34" text-anchor="middle">createStmt</text>
|
||||
</g>
|
||||
|
||||
<!-- entry dot with ring -->
|
||||
<circle class="track-dot" cx="140" cy="32" r="4"/>
|
||||
<circle cx="140" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<line class="track" x1="144" y1="32" x2="170" y2="32"/>
|
||||
|
||||
<!-- create keyword -->
|
||||
<g class="terminal" transform="translate(170, 16)">
|
||||
<rect width="74" height="32" rx="16"/>
|
||||
<text x="37" y="16" text-anchor="middle" dominant-baseline="central">create</text>
|
||||
</g>
|
||||
<line class="track" x1="244" y1="32" x2="280" y2="32"/>
|
||||
|
||||
<!-- assignment non-terminal -->
|
||||
<g class="nonterminal" transform="translate(280, 16)">
|
||||
<rect width="110" height="32" rx="4"/>
|
||||
<text x="55" y="16" text-anchor="middle" dominant-baseline="central">assignment</text>
|
||||
</g>
|
||||
<line class="track" x1="390" y1="32" x2="440" y2="32"/>
|
||||
|
||||
<!-- loopback below: from after assignment back to before assignment -->
|
||||
<path class="track" d="M 430,32 C 430,62 420,76 400,76 L 300,76 C 280,76 270,62 270,42"/>
|
||||
<!-- arrowhead pointing up at the rejoin -->
|
||||
<path d="M 265,46 L 270,36 L 275,46 z" fill="#94A3B8"/>
|
||||
|
||||
<!-- exit dot with double circle -->
|
||||
<circle class="track-dot" cx="440" cy="32" r="4"/>
|
||||
<circle cx="440" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
</g>
|
||||
|
||||
<!-- separator between createStmt and updateStmt -->
|
||||
<line class="separator" x1="10" y1="270" x2="1010" y2="270"/>
|
||||
|
||||
<!-- ==================== updateStmt ==================== -->
|
||||
<!-- Main line at y=32, loopback below at y=76 -->
|
||||
<g transform="translate(0, 280)">
|
||||
<!-- label background pill -->
|
||||
<g class="label-group">
|
||||
<rect x="5" y="18" width="124" height="24" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
|
||||
<text class="label" x="67" y="34" text-anchor="middle">updateStmt</text>
|
||||
</g>
|
||||
|
||||
<!-- entry dot with ring -->
|
||||
<circle class="track-dot" cx="140" cy="32" r="4"/>
|
||||
<circle cx="140" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<line class="track" x1="144" y1="32" x2="170" y2="32"/>
|
||||
|
||||
<!-- update keyword -->
|
||||
<g class="terminal" transform="translate(170, 16)">
|
||||
<rect width="78" height="32" rx="16"/>
|
||||
<text x="39" y="16" text-anchor="middle" dominant-baseline="central">update</text>
|
||||
</g>
|
||||
<line class="track" x1="248" y1="32" x2="268" y2="32"/>
|
||||
|
||||
<!-- where keyword -->
|
||||
<g class="terminal" transform="translate(268, 16)">
|
||||
<rect width="70" height="32" rx="16"/>
|
||||
<text x="35" y="16" text-anchor="middle" dominant-baseline="central">where</text>
|
||||
</g>
|
||||
<line class="track" x1="338" y1="32" x2="358" y2="32"/>
|
||||
|
||||
<!-- condition -->
|
||||
<g class="nonterminal" transform="translate(358, 16)">
|
||||
<rect width="100" height="32" rx="4"/>
|
||||
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">condition</text>
|
||||
</g>
|
||||
<line class="track" x1="458" y1="32" x2="478" y2="32"/>
|
||||
|
||||
<!-- set keyword -->
|
||||
<g class="terminal" transform="translate(478, 16)">
|
||||
<rect width="50" height="32" rx="16"/>
|
||||
<text x="25" y="16" text-anchor="middle" dominant-baseline="central">set</text>
|
||||
</g>
|
||||
<line class="track" x1="528" y1="32" x2="548" y2="32"/>
|
||||
|
||||
<!-- assignment non-terminal -->
|
||||
<g class="nonterminal" transform="translate(548, 16)">
|
||||
<rect width="110" height="32" rx="4"/>
|
||||
<text x="55" y="16" text-anchor="middle" dominant-baseline="central">assignment</text>
|
||||
</g>
|
||||
<line class="track" x1="658" y1="32" x2="710" y2="32"/>
|
||||
|
||||
<!-- loopback below -->
|
||||
<path class="track" d="M 700,32 C 700,62 690,76 670,76 L 568,76 C 548,76 538,62 538,42"/>
|
||||
<path d="M 533,46 L 538,36 L 543,46 z" fill="#94A3B8"/>
|
||||
|
||||
<!-- exit dot with double circle -->
|
||||
<circle class="track-dot" cx="710" cy="32" r="4"/>
|
||||
<circle cx="710" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
</g>
|
||||
|
||||
<!-- separator between updateStmt and deleteStmt -->
|
||||
<line class="separator" x1="10" y1="400" x2="1010" y2="400"/>
|
||||
|
||||
<!-- ==================== deleteStmt ==================== -->
|
||||
<g transform="translate(0, 420)">
|
||||
<!-- label background pill -->
|
||||
<g class="label-group">
|
||||
<rect x="5" y="18" width="120" height="24" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
|
||||
<text class="label" x="65" y="34" text-anchor="middle">deleteStmt</text>
|
||||
</g>
|
||||
|
||||
<!-- entry dot with ring -->
|
||||
<circle class="track-dot" cx="140" cy="32" r="4"/>
|
||||
<circle cx="140" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<line class="track" x1="144" y1="32" x2="170" y2="32"/>
|
||||
|
||||
<!-- delete keyword -->
|
||||
<g class="terminal" transform="translate(170, 16)">
|
||||
<rect width="72" height="32" rx="16"/>
|
||||
<text x="36" y="16" text-anchor="middle" dominant-baseline="central">delete</text>
|
||||
</g>
|
||||
<line class="track" x1="242" y1="32" x2="270" y2="32"/>
|
||||
|
||||
<!-- where keyword -->
|
||||
<g class="terminal" transform="translate(270, 16)">
|
||||
<rect width="70" height="32" rx="16"/>
|
||||
<text x="35" y="16" text-anchor="middle" dominant-baseline="central">where</text>
|
||||
</g>
|
||||
<line class="track" x1="340" y1="32" x2="370" y2="32"/>
|
||||
|
||||
<!-- condition -->
|
||||
<g class="nonterminal" transform="translate(370, 16)">
|
||||
<rect width="100" height="32" rx="4"/>
|
||||
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">condition</text>
|
||||
</g>
|
||||
<line class="track" x1="470" y1="32" x2="510" y2="32"/>
|
||||
|
||||
<!-- exit dot with double circle -->
|
||||
<circle class="track-dot" cx="510" cy="32" r="4"/>
|
||||
<circle cx="510" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
</g>
|
||||
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 14 KiB |
307
.doc/doki/doc/ruki/images/trigger-railroad.svg
Normal file
|
|
@ -0,0 +1,307 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 900 708">
|
||||
<!--
|
||||
Trigger railroad diagram — transparent/dark-bg compatible
|
||||
6 tracks: trigger, timing, event, action, deny, runAction
|
||||
-->
|
||||
<defs>
|
||||
<marker id="arrow" viewBox="0 0 10 7" refX="10" refY="3.5" markerWidth="10" markerHeight="7" orient="auto-start-reverse">
|
||||
<path d="M 0 0 L 10 3.5 L 0 7 z" fill="#94A3B8"/>
|
||||
</marker>
|
||||
<filter id="shadow" x="-4%" y="-4%" width="108%" height="116%">
|
||||
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-color="#000000" flood-opacity="0.3"/>
|
||||
</filter>
|
||||
<style>
|
||||
.label { font: bold 14px 'SF Mono', 'Cascadia Code', 'Fira Code', monospace; fill: #94A3B8; }
|
||||
.title { font: 700 16px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #A5B4FC; }
|
||||
.terminal rect { fill: #3B82F6; fill-opacity: 0.15; stroke: #3B82F6; stroke-width: 1.5; stroke-opacity: 0.5; filter: url(#shadow); }
|
||||
.terminal text { font: 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #93C5FD; font-weight: 600; }
|
||||
.nonterminal rect { fill: #F59E0B; fill-opacity: 0.15; stroke: #F59E0B; stroke-width: 1.5; stroke-opacity: 0.5; filter: url(#shadow); }
|
||||
.nonterminal text { font: 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #FCD34D; font-weight: 500; }
|
||||
.track { stroke: #94A3B8; stroke-width: 1.5; fill: none; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<!-- title -->
|
||||
<text class="title" x="450" y="22" text-anchor="middle">Trigger grammar</text>
|
||||
|
||||
<!-- all tracks shifted down 8px to accommodate title -->
|
||||
<g transform="translate(0, 8)">
|
||||
|
||||
<!-- ==================== trigger ==================== -->
|
||||
<!-- Main line y=50, bypass above at y=10 for optional where -->
|
||||
<g transform="translate(0, 30)">
|
||||
<g>
|
||||
<rect x="2" y="40" width="71" height="24" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
|
||||
<text class="label" x="10" y="54" text-anchor="start">trigger</text>
|
||||
</g>
|
||||
|
||||
<!-- entry dot with outer ring -->
|
||||
<circle cx="120" cy="50" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<circle cx="120" cy="50" r="4" fill="#94A3B8"/>
|
||||
<line class="track" x1="124" y1="50" x2="150" y2="50"/>
|
||||
|
||||
<!-- timing non-terminal -->
|
||||
<g class="nonterminal" transform="translate(150, 34)">
|
||||
<rect width="80" height="32" rx="4"/>
|
||||
<text x="40" y="16" text-anchor="middle" dominant-baseline="central">timing</text>
|
||||
</g>
|
||||
<line class="track" x1="230" y1="50" x2="256" y2="50"/>
|
||||
|
||||
<!-- event non-terminal -->
|
||||
<g class="nonterminal" transform="translate(256, 34)">
|
||||
<rect width="72" height="32" rx="4"/>
|
||||
<text x="36" y="16" text-anchor="middle" dominant-baseline="central">event</text>
|
||||
</g>
|
||||
<line class="track" x1="328" y1="50" x2="358" y2="50"/>
|
||||
|
||||
<!-- optional where + condition: main path -->
|
||||
<g class="terminal" transform="translate(358, 34)">
|
||||
<rect width="70" height="32" rx="16"/>
|
||||
<text x="35" y="16" text-anchor="middle" dominant-baseline="central">where</text>
|
||||
</g>
|
||||
<line class="track" x1="428" y1="50" x2="454" y2="50"/>
|
||||
<g class="nonterminal" transform="translate(454, 34)">
|
||||
<rect width="100" height="32" rx="4"/>
|
||||
<text x="50" y="16" text-anchor="middle" dominant-baseline="central">condition</text>
|
||||
</g>
|
||||
<line class="track" x1="554" y1="50" x2="600" y2="50"/>
|
||||
|
||||
<!-- bypass above for optional where+condition -->
|
||||
<path class="track" d="M 348,50 C 348,20 358,10 378,10 L 570,10 C 590,10 600,20 600,50"/>
|
||||
|
||||
<!-- alternative: action or deny -->
|
||||
<!-- upper path: action -->
|
||||
<path class="track" d="M 600,50 C 600,20 610,10 630,10"/>
|
||||
<g class="nonterminal" transform="translate(630, -6)">
|
||||
<rect width="80" height="32" rx="4"/>
|
||||
<text x="40" y="16" text-anchor="middle" dominant-baseline="central">action</text>
|
||||
</g>
|
||||
<path class="track" d="M 710,10 C 730,10 740,20 740,50"/>
|
||||
|
||||
<!-- lower path: deny -->
|
||||
<path class="track" d="M 600,50 C 600,80 610,90 630,90"/>
|
||||
<g class="nonterminal" transform="translate(630, 74)">
|
||||
<rect width="80" height="32" rx="4"/>
|
||||
<text x="40" y="16" text-anchor="middle" dominant-baseline="central">deny</text>
|
||||
</g>
|
||||
<path class="track" d="M 710,90 C 730,90 740,80 740,50"/>
|
||||
|
||||
<line class="track" x1="740" y1="50" x2="780" y2="50"/>
|
||||
<!-- exit dot with thicker outer ring -->
|
||||
<circle cx="780" cy="50" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
<circle cx="780" cy="50" r="4" fill="#94A3B8"/>
|
||||
</g>
|
||||
|
||||
<!-- separator between trigger and timing -->
|
||||
<line x1="10" y1="160" x2="890" y2="160" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- ==================== timing ==================== -->
|
||||
<g transform="translate(0, 170)">
|
||||
<g>
|
||||
<rect x="2" y="22" width="64" height="24" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
|
||||
<text class="label" x="10" y="36" text-anchor="start">timing</text>
|
||||
</g>
|
||||
|
||||
<!-- entry dot with outer ring -->
|
||||
<circle cx="120" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<circle cx="120" cy="32" r="4" fill="#94A3B8"/>
|
||||
<line class="track" x1="124" y1="32" x2="160" y2="32"/>
|
||||
|
||||
<!-- upper: before -->
|
||||
<path class="track" d="M 160,32 C 160,10 170,0 190,0"/>
|
||||
<g class="terminal" transform="translate(190, -16)">
|
||||
<rect width="78" height="32" rx="16"/>
|
||||
<text x="39" y="16" text-anchor="middle" dominant-baseline="central">before</text>
|
||||
</g>
|
||||
<path class="track" d="M 268,0 C 288,0 298,10 298,32"/>
|
||||
|
||||
<!-- lower: after -->
|
||||
<path class="track" d="M 160,32 C 160,54 170,64 190,64"/>
|
||||
<g class="terminal" transform="translate(190, 48)">
|
||||
<rect width="78" height="32" rx="16"/>
|
||||
<text x="39" y="16" text-anchor="middle" dominant-baseline="central">after</text>
|
||||
</g>
|
||||
<path class="track" d="M 268,64 C 288,64 298,54 298,32"/>
|
||||
|
||||
<line class="track" x1="298" y1="32" x2="340" y2="32"/>
|
||||
<!-- exit dot with thicker outer ring -->
|
||||
<circle cx="340" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
<circle cx="340" cy="32" r="4" fill="#94A3B8"/>
|
||||
</g>
|
||||
|
||||
<!-- separator between timing and event -->
|
||||
<line x1="10" y1="278" x2="890" y2="278" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- ==================== event ==================== -->
|
||||
<g transform="translate(0, 290)">
|
||||
<g>
|
||||
<rect x="2" y="22" width="56" height="24" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
|
||||
<text class="label" x="10" y="36" text-anchor="start">event</text>
|
||||
</g>
|
||||
|
||||
<!-- entry dot with outer ring -->
|
||||
<circle cx="120" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<circle cx="120" cy="32" r="4" fill="#94A3B8"/>
|
||||
<line class="track" x1="124" y1="32" x2="160" y2="32"/>
|
||||
|
||||
<!-- top: create -->
|
||||
<path class="track" d="M 160,32 C 160,2 170,-8 190,-8"/>
|
||||
<g class="terminal" transform="translate(190, -24)">
|
||||
<rect width="78" height="32" rx="16"/>
|
||||
<text x="39" y="16" text-anchor="middle" dominant-baseline="central">create</text>
|
||||
</g>
|
||||
<path class="track" d="M 268,-8 C 288,-8 298,2 298,32"/>
|
||||
|
||||
<!-- middle: update -->
|
||||
<g class="terminal" transform="translate(190, 16)">
|
||||
<rect width="78" height="32" rx="16"/>
|
||||
<text x="39" y="16" text-anchor="middle" dominant-baseline="central">update</text>
|
||||
</g>
|
||||
<!-- straight through for middle option -->
|
||||
<line class="track" x1="160" y1="32" x2="190" y2="32"/>
|
||||
<line class="track" x1="268" y1="32" x2="298" y2="32"/>
|
||||
|
||||
<!-- bottom: delete -->
|
||||
<path class="track" d="M 160,32 C 160,62 170,72 190,72"/>
|
||||
<g class="terminal" transform="translate(190, 56)">
|
||||
<rect width="78" height="32" rx="16"/>
|
||||
<text x="39" y="16" text-anchor="middle" dominant-baseline="central">delete</text>
|
||||
</g>
|
||||
<path class="track" d="M 268,72 C 288,72 298,62 298,32"/>
|
||||
|
||||
<line class="track" x1="298" y1="32" x2="340" y2="32"/>
|
||||
<!-- exit dot with thicker outer ring -->
|
||||
<circle cx="340" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
<circle cx="340" cy="32" r="4" fill="#94A3B8"/>
|
||||
</g>
|
||||
|
||||
<!-- separator between event and action -->
|
||||
<line x1="10" y1="407" x2="890" y2="407" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- ==================== action ==================== -->
|
||||
<!-- Center at y=55 to give room for 4 alternatives above and below -->
|
||||
<g transform="translate(0, 420)">
|
||||
<g>
|
||||
<rect x="2" y="45" width="64" height="24" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
|
||||
<text class="label" x="10" y="59" text-anchor="start">action</text>
|
||||
</g>
|
||||
|
||||
<!-- entry dot with outer ring -->
|
||||
<circle cx="120" cy="55" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<circle cx="120" cy="55" r="4" fill="#94A3B8"/>
|
||||
<line class="track" x1="124" y1="55" x2="160" y2="55"/>
|
||||
|
||||
<!-- top: runAction -->
|
||||
<path class="track" d="M 160,55 C 160,10 170,-5 190,-5"/>
|
||||
<g class="nonterminal" transform="translate(190, -21)">
|
||||
<rect width="104" height="32" rx="4"/>
|
||||
<text x="52" y="16" text-anchor="middle" dominant-baseline="central">runAction</text>
|
||||
</g>
|
||||
<path class="track" d="M 294,-5 C 314,-5 324,10 324,55"/>
|
||||
|
||||
<!-- upper-middle: createStmt -->
|
||||
<path class="track" d="M 160,55 C 160,35 170,25 190,25"/>
|
||||
<g class="nonterminal" transform="translate(190, 9)">
|
||||
<rect width="104" height="32" rx="4"/>
|
||||
<text x="52" y="16" text-anchor="middle" dominant-baseline="central">createStmt</text>
|
||||
</g>
|
||||
<path class="track" d="M 294,25 C 314,25 324,35 324,55"/>
|
||||
|
||||
<!-- lower-middle: updateStmt (straight through) -->
|
||||
<line class="track" x1="160" y1="55" x2="190" y2="55"/>
|
||||
<g class="nonterminal" transform="translate(190, 39)">
|
||||
<rect width="104" height="32" rx="4"/>
|
||||
<text x="52" y="16" text-anchor="middle" dominant-baseline="central">updateStmt</text>
|
||||
</g>
|
||||
<line class="track" x1="294" y1="55" x2="324" y2="55"/>
|
||||
|
||||
<!-- bottom: deleteStmt -->
|
||||
<path class="track" d="M 160,55 C 160,75 170,85 190,85"/>
|
||||
<g class="nonterminal" transform="translate(190, 69)">
|
||||
<rect width="104" height="32" rx="4"/>
|
||||
<text x="52" y="16" text-anchor="middle" dominant-baseline="central">deleteStmt</text>
|
||||
</g>
|
||||
<path class="track" d="M 294,85 C 314,85 324,75 324,55"/>
|
||||
|
||||
<line class="track" x1="324" y1="55" x2="370" y2="55"/>
|
||||
<!-- exit dot with thicker outer ring -->
|
||||
<circle cx="370" cy="55" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
<circle cx="370" cy="55" r="4" fill="#94A3B8"/>
|
||||
</g>
|
||||
|
||||
<!-- separator between action and deny -->
|
||||
<line x1="10" y1="548" x2="890" y2="548" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- ==================== deny ==================== -->
|
||||
<g transform="translate(0, 560)">
|
||||
<g>
|
||||
<rect x="2" y="22" width="52" height="24" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
|
||||
<text class="label" x="10" y="36" text-anchor="start">deny</text>
|
||||
</g>
|
||||
|
||||
<!-- entry dot with outer ring -->
|
||||
<circle cx="120" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<circle cx="120" cy="32" r="4" fill="#94A3B8"/>
|
||||
<line class="track" x1="124" y1="32" x2="150" y2="32"/>
|
||||
|
||||
<g class="terminal" transform="translate(150, 16)">
|
||||
<rect width="62" height="32" rx="16"/>
|
||||
<text x="31" y="16" text-anchor="middle" dominant-baseline="central">deny</text>
|
||||
</g>
|
||||
<line class="track" x1="212" y1="32" x2="240" y2="32"/>
|
||||
|
||||
<g class="nonterminal" transform="translate(240, 16)">
|
||||
<rect width="72" height="32" rx="4"/>
|
||||
<text x="36" y="16" text-anchor="middle" dominant-baseline="central">string</text>
|
||||
</g>
|
||||
<line class="track" x1="312" y1="32" x2="350" y2="32"/>
|
||||
<!-- exit dot with thicker outer ring -->
|
||||
<circle cx="350" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
<circle cx="350" cy="32" r="4" fill="#94A3B8"/>
|
||||
</g>
|
||||
|
||||
<!-- separator between deny and runAction -->
|
||||
<line x1="10" y1="620" x2="890" y2="620" stroke="#475569" stroke-width="0.5" stroke-opacity="0.3"/>
|
||||
|
||||
<!-- ==================== runAction ==================== -->
|
||||
<g transform="translate(0, 630)">
|
||||
<g>
|
||||
<rect x="2" y="22" width="90" height="24" rx="6" fill="#94A3B8" fill-opacity="0.08" stroke="#94A3B8" stroke-width="0.5" stroke-opacity="0.2"/>
|
||||
<text class="label" x="10" y="36" text-anchor="start">runAction</text>
|
||||
</g>
|
||||
|
||||
<!-- entry dot with outer ring -->
|
||||
<circle cx="120" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1" stroke-opacity="0.3"/>
|
||||
<circle cx="120" cy="32" r="4" fill="#94A3B8"/>
|
||||
<line class="track" x1="124" y1="32" x2="150" y2="32"/>
|
||||
|
||||
<g class="terminal" transform="translate(150, 16)">
|
||||
<rect width="52" height="32" rx="16"/>
|
||||
<text x="26" y="16" text-anchor="middle" dominant-baseline="central">run</text>
|
||||
</g>
|
||||
<line class="track" x1="202" y1="32" x2="222" y2="32"/>
|
||||
|
||||
<g class="terminal" transform="translate(222, 16)">
|
||||
<rect width="30" height="32" rx="16"/>
|
||||
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">(</text>
|
||||
</g>
|
||||
<line class="track" x1="252" y1="32" x2="272" y2="32"/>
|
||||
|
||||
<g class="nonterminal" transform="translate(272, 16)">
|
||||
<rect width="60" height="32" rx="4"/>
|
||||
<text x="30" y="16" text-anchor="middle" dominant-baseline="central">expr</text>
|
||||
</g>
|
||||
<line class="track" x1="332" y1="32" x2="352" y2="32"/>
|
||||
|
||||
<g class="terminal" transform="translate(352, 16)">
|
||||
<rect width="30" height="32" rx="16"/>
|
||||
<text x="15" y="16" text-anchor="middle" dominant-baseline="central">)</text>
|
||||
</g>
|
||||
<line class="track" x1="382" y1="32" x2="420" y2="32"/>
|
||||
<!-- exit dot with thicker outer ring -->
|
||||
<circle cx="420" cy="32" r="7" fill="none" stroke="#94A3B8" stroke-width="1.5" stroke-opacity="0.5"/>
|
||||
<circle cx="420" cy="32" r="4" fill="#94A3B8"/>
|
||||
</g>
|
||||
|
||||
</g>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 15 KiB |
106
.doc/doki/doc/ruki/images/validation-pipeline.svg
Normal file
|
|
@ -0,0 +1,106 @@
|
|||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 820 310">
|
||||
<defs>
|
||||
<filter id="shadow" x="-4%" y="-4%" width="108%" height="116%">
|
||||
<feDropShadow dx="0" dy="1" stdDeviation="2" flood-color="#000000" flood-opacity="0.3"/>
|
||||
</filter>
|
||||
<filter id="success-glow" x="-10%" y="-10%" width="120%" height="120%">
|
||||
<feGaussianBlur stdDeviation="3" result="blur"/>
|
||||
<feMerge>
|
||||
<feMergeNode in="blur"/>
|
||||
<feMergeNode in="SourceGraphic"/>
|
||||
</feMerge>
|
||||
</filter>
|
||||
<marker id="flow-arrow" viewBox="0 0 12 8" refX="12" refY="4" markerWidth="12" markerHeight="8" orient="auto">
|
||||
<path d="M 0 0 L 12 4 L 0 8 z" fill="#94A3B8"/>
|
||||
</marker>
|
||||
<marker id="err-arrow" viewBox="0 0 12 8" refX="12" refY="4" markerWidth="12" markerHeight="8" orient="auto">
|
||||
<path d="M 0 0 L 12 4 L 0 8 z" fill="#F87171"/>
|
||||
</marker>
|
||||
<style>
|
||||
.title { font: 700 16px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #A5B4FC; }
|
||||
.stage-text { font: 700 13px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #93C5FD; }
|
||||
.data-text { font: 600 12px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #6EE7B7; }
|
||||
.err-title { font: 700 11px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #FCA5A5; }
|
||||
.err-detail { font: 400 10px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #FDA4AF; }
|
||||
.flow-line { stroke: #94A3B8; stroke-width: 1.5; fill: none; marker-end: url(#flow-arrow); }
|
||||
.err-line { stroke: #F87171; stroke-width: 1.5; fill: none; stroke-dasharray: 5,3; marker-end: url(#err-arrow); }
|
||||
.err-line-solid { stroke: #F87171; stroke-width: 1.5; fill: none; marker-end: url(#err-arrow); }
|
||||
.stage-label { font: 500 10px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #64748B; }
|
||||
.legend-text { font: 400 11px -apple-system, 'Segoe UI', Roboto, sans-serif; fill: #94A3B8; }
|
||||
</style>
|
||||
</defs>
|
||||
|
||||
<text class="title" x="410" y="24" text-anchor="middle">Validation pipeline</text>
|
||||
|
||||
<!-- ===== Main pipeline (y=70) ===== -->
|
||||
|
||||
<!-- Input text -->
|
||||
<rect x="20" y="50" width="90" height="40" rx="10" fill="#10B981" fill-opacity="0.12" stroke="#10B981" stroke-width="1.5" stroke-opacity="0.4" filter="url(#shadow)"/>
|
||||
<text class="data-text" x="65" y="74" text-anchor="middle">Input text</text>
|
||||
<line class="flow-line" x1="110" y1="70" x2="148" y2="70"/>
|
||||
|
||||
<!-- Lexer / Parser -->
|
||||
<rect x="148" y="46" width="134" height="48" rx="10" fill="#3B82F6" fill-opacity="0.12" stroke="#3B82F6" stroke-width="1.5" stroke-opacity="0.4" filter="url(#shadow)"/>
|
||||
<text class="stage-text" x="215" y="74" text-anchor="middle">Lexer / Parser</text>
|
||||
<line class="flow-line" x1="282" y1="70" x2="318" y2="70"/>
|
||||
|
||||
<!-- AST -->
|
||||
<rect x="318" y="52" width="60" height="36" rx="10" fill="#10B981" fill-opacity="0.12" stroke="#10B981" stroke-width="1.5" stroke-opacity="0.4" filter="url(#shadow)"/>
|
||||
<text class="data-text" x="348" y="74" text-anchor="middle">AST</text>
|
||||
<line class="flow-line" x1="378" y1="70" x2="414" y2="70"/>
|
||||
|
||||
<!-- Semantic Validator -->
|
||||
<rect x="414" y="46" width="176" height="48" rx="10" fill="#3B82F6" fill-opacity="0.12" stroke="#3B82F6" stroke-width="1.5" stroke-opacity="0.4" filter="url(#shadow)"/>
|
||||
<text class="stage-text" x="502" y="74" text-anchor="middle">Semantic Validator</text>
|
||||
<line class="flow-line" x1="590" y1="70" x2="626" y2="70"/>
|
||||
|
||||
<!-- Valid AST -->
|
||||
<rect x="626" y="50" width="100" height="40" rx="10" fill="#10B981" fill-opacity="0.12" stroke="#10B981" stroke-width="1.5" stroke-opacity="0.4" filter="url(#success-glow)"/>
|
||||
<text class="data-text" x="676" y="74" text-anchor="middle">Valid AST</text>
|
||||
|
||||
<!-- ===== Parse errors (branch down from Lexer/Parser) ===== -->
|
||||
<line class="err-line" x1="215" y1="94" x2="215" y2="140"/>
|
||||
<!-- stage 1 label with pill background -->
|
||||
<rect x="191" y="122" width="50" height="16" rx="4" fill="#64748B" fill-opacity="0.15"/>
|
||||
<text class="stage-label" x="215" y="134" text-anchor="middle">stage 1</text>
|
||||
|
||||
<rect x="130" y="144" width="170" height="52" rx="8" fill="#EF4444" fill-opacity="0.1" stroke="#EF4444" stroke-width="1.5" stroke-opacity="0.35" filter="url(#shadow)"/>
|
||||
<text class="err-title" x="215" y="164" text-anchor="middle">Parse errors</text>
|
||||
<text class="err-detail" x="215" y="180" text-anchor="middle">unknown keyword, missing clause, bad syntax</text>
|
||||
|
||||
<!-- ===== Semantic errors (branch down from Semantic Validator) ===== -->
|
||||
<line class="err-line" x1="502" y1="94" x2="502" y2="140"/>
|
||||
<!-- stage 2 label with pill background -->
|
||||
<rect x="478" y="122" width="50" height="16" rx="4" fill="#64748B" fill-opacity="0.15"/>
|
||||
<text class="stage-label" x="502" y="134" text-anchor="middle">stage 2</text>
|
||||
|
||||
<!-- Semantic error umbrella -->
|
||||
<rect x="350" y="144" width="304" height="36" rx="8" fill="#EF4444" fill-opacity="0.1" stroke="#EF4444" stroke-width="1.5" stroke-opacity="0.35" filter="url(#shadow)"/>
|
||||
<text class="err-title" x="502" y="166" text-anchor="middle">Semantic validation errors</text>
|
||||
|
||||
<!-- Sub-category connectors -->
|
||||
<line class="err-line-solid" x1="400" y1="180" x2="400" y2="200"/>
|
||||
<line class="err-line-solid" x1="502" y1="180" x2="502" y2="200"/>
|
||||
<line class="err-line-solid" x1="604" y1="180" x2="604" y2="200"/>
|
||||
|
||||
<!-- Sub-category boxes -->
|
||||
<rect x="345" y="200" width="110" height="52" rx="8" fill="#EF4444" fill-opacity="0.08" stroke="#EF4444" stroke-width="1" stroke-opacity="0.25" filter="url(#shadow)"/>
|
||||
<text class="err-title" x="400" y="219" text-anchor="middle">Structural</text>
|
||||
<text class="err-detail" x="400" y="237" text-anchor="middle">dup fields, trigger rules</text>
|
||||
|
||||
<rect x="467" y="200" width="110" height="52" rx="8" fill="#EF4444" fill-opacity="0.08" stroke="#EF4444" stroke-width="1" stroke-opacity="0.25" filter="url(#shadow)"/>
|
||||
<text class="err-title" x="522" y="219" text-anchor="middle">Field / Qualifier</text>
|
||||
<text class="err-detail" x="522" y="237" text-anchor="middle">unknown, old./new. scope</text>
|
||||
|
||||
<rect x="589" y="200" width="110" height="52" rx="8" fill="#EF4444" fill-opacity="0.08" stroke="#EF4444" stroke-width="1" stroke-opacity="0.25" filter="url(#shadow)"/>
|
||||
<text class="err-title" x="644" y="219" text-anchor="middle">Type / Operator</text>
|
||||
<text class="err-detail" x="644" y="237" text-anchor="middle">mismatch, bad usage</text>
|
||||
|
||||
<!-- ===== Legend ===== -->
|
||||
<rect x="200" y="275" width="12" height="12" rx="3" fill="#3B82F6" fill-opacity="0.12" stroke="#3B82F6" stroke-width="1" stroke-opacity="0.4"/>
|
||||
<text class="legend-text" x="218" y="285">processing stage</text>
|
||||
<rect x="350" y="275" width="12" height="12" rx="3" fill="#10B981" fill-opacity="0.12" stroke="#10B981" stroke-width="1" stroke-opacity="0.4"/>
|
||||
<text class="legend-text" x="368" y="285">data</text>
|
||||
<rect x="430" y="275" width="12" height="12" rx="3" fill="#EF4444" fill-opacity="0.1" stroke="#EF4444" stroke-width="1" stroke-opacity="0.35"/>
|
||||
<text class="legend-text" x="448" y="285">error</text>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 6.9 KiB |
33
.doc/doki/doc/ruki/index.md
Normal file
|
|
@ -0,0 +1,33 @@
|
|||
# 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).
|
||||
|
||||
## Recipes
|
||||
|
||||
ready-to-use examples for common workflow patterns
|
||||
|
||||
- [Plugins](../ideas/plugins.md)
|
||||
- [Triggers](../ideas/triggers.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.
|
||||
- [Custom Fields Reference](custom-fields-reference.md): coercion rules, enum isolation, persistence round-trips, schema evolution, and missing-field semantics.
|
||||
|
||||
290
.doc/doki/doc/ruki/operators-and-builtins.md
Normal file
|
|
@ -0,0 +1,290 @@
|
|||
# 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 |
|
||||
| `input()` | declared type | 0 | valid only in plugin actions with `input:` declaration |
|
||||
| `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()
|
||||
update where id = id() set assignee = input()
|
||||
update where id = id() set tags = tags + [input()]
|
||||
```
|
||||
|
||||
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.
|
||||
- `input()` returns the value typed by the user at the action prompt. Its return type matches the `input:` declaration on the action (e.g. `input: string` means `input()` returns `string`). Only valid in plugin action statements that declare `input:`. May only appear once per action. Accepted `timestamp` input formats: RFC3339, with YYYY-MM-DD as a convenience fallback.
|
||||
|
||||
`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 four shell-related forms:
|
||||
|
||||
**`call(...)`** — a string-returning expression
|
||||
|
||||
- Returns a string result from a shell command
|
||||
- Can be used in any expression context
|
||||
|
||||
**`run(...)` in triggers** — an `after`-trigger action
|
||||
|
||||
- Used as the top-level action of an `after` trigger
|
||||
- Command string may reference `old.` and `new.` fields
|
||||
- Example: `after update where new.status = "in progress" run("echo hello")`
|
||||
|
||||
**`| run(...)` on select** — a pipe suffix
|
||||
|
||||
- Executes a command for each row returned by `select`
|
||||
- Uses positional arguments `$1`, `$2`, etc. for field substitution
|
||||
- Command must be a string literal or expression, but **field references are not allowed in the command** itself
|
||||
- `|` is a statement suffix, not an operator
|
||||
- Example: `select id, title where status = "done" | run("myscript $1 $2")`
|
||||
|
||||
**`| clipboard()` on select** — a pipe suffix
|
||||
|
||||
- Copies selected field values to the system clipboard
|
||||
- Fields within a row are tab-separated; rows are newline-separated
|
||||
- Takes no arguments — grammar enforces empty parentheses
|
||||
- Cross-platform via `atotto/clipboard` (macOS, Linux, Windows)
|
||||
- Example: `select id where id = id() | clipboard()`
|
||||
|
||||
Comparison of pipe targets and trigger actions:
|
||||
|
||||
| Form | Context | Field access | Behavior |
|
||||
|---|---|---|---|
|
||||
| trigger `run()` | after-trigger action | `old.`, `new.` allowed | shell execution via expression evaluation |
|
||||
| pipe `\| run()` | select suffix | field refs disallowed in command | shell execution with positional args `$1`, `$2` |
|
||||
| pipe `\| clipboard()` | select suffix | n/a (no arguments) | writes rows to system clipboard |
|
||||
|
||||
See [Semantics](semantics.md#pipe-actions-on-select) for pipe evaluation model.
|
||||
147
.doc/doki/doc/ruki/quick-start.md
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
# 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
|
||||
select where status != "done" order by priority limit 3
|
||||
```
|
||||
|
||||
`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
|
||||
```
|
||||
|
||||
`select` may pipe results to a shell command or to the clipboard:
|
||||
|
||||
```sql
|
||||
select id, title where status = "done" | run("myscript $1 $2")
|
||||
select id where id = id() | clipboard()
|
||||
```
|
||||
|
||||
`| run(...)` executes the command for each row with field values as positional arguments (`$1`, `$2`). `| clipboard()` copies the selected fields to the system clipboard.
|
||||
|
||||
## 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
|
||||
|
||||
### Recipes
|
||||
ready-to-use examples for common workflow patterns
|
||||
|
||||
- [Plugins](../ideas/plugins.md)
|
||||
- [Triggers](../ideas/triggers.md)
|
||||
|
||||
### More info
|
||||
|
||||
- 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.
|
||||
241
.doc/doki/doc/ruki/semantics.md
Normal file
|
|
@ -0,0 +1,241 @@
|
|||
# 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`.
|
||||
|
||||
`limit`
|
||||
|
||||
- Must be a positive integer.
|
||||
- Applied after filtering and sorting, before any pipe action.
|
||||
- If the limit exceeds the result count, all results are returned (no error).
|
||||
|
||||
`create`
|
||||
|
||||
- `create` is a list of assignments.
|
||||
- At least one assignment is required.
|
||||
- The resulting task must have a non-empty `title`. This can come from an explicit `title=...` assignment or from the task template.
|
||||
- Duplicate assignments to the same field are rejected.
|
||||
- Every assigned field must exist in the injected schema.
|
||||
- `id`, `createdBy`, `createdAt`, and `updatedAt` are immutable and cannot be assigned.
|
||||
|
||||
`update`
|
||||
|
||||
- `update` has two parts: a `where` condition and a `set` assignment list.
|
||||
- At least one assignment in `set` is required.
|
||||
- The `where` clause and every right-hand side expression are validated.
|
||||
- Duplicate assignments inside `set` are rejected.
|
||||
- `id`, `createdBy`, `createdAt`, and `updatedAt` are immutable and cannot be assigned.
|
||||
|
||||
`delete`
|
||||
|
||||
- `delete` always requires a `where` condition.
|
||||
- The `where` condition is validated exactly like `select where ...`.
|
||||
|
||||
## Trigger semantics
|
||||
|
||||
Triggers have the shape:
|
||||
|
||||
```text
|
||||
<timing> <event> [where <condition>] <deny-or-action>
|
||||
```
|
||||
|
||||
Rules:
|
||||
|
||||
- `before` triggers must have `deny`.
|
||||
- `before` triggers must not have an action or `run(...)`.
|
||||
- `after` triggers must not have `deny`.
|
||||
- `after` triggers must have either a CRUD action or `run(...)`.
|
||||
- trigger CRUD actions may be `create`, `update`, or `delete`, but not `select`
|
||||
|
||||
Examples:
|
||||
|
||||
```sql
|
||||
before update where new.status = "done" and dependsOn any status != "done" deny "cannot complete tiki with open dependencies"
|
||||
after create where new.priority <= 2 and new.assignee is empty update where id = new.id set assignee="booleanmaybe"
|
||||
after delete update where old.id in dependsOn set dependsOn=dependsOn - [old.id]
|
||||
```
|
||||
|
||||
At runtime, triggers execute in a pipeline: before-triggers run as validators before persistence, the mutation is persisted, then after-triggers run as hooks. For the full execution model, cascade behavior, configuration, and runtime details, see [Triggers](triggers.md).
|
||||
|
||||
## Qualifier scope
|
||||
|
||||
Qualifier rules depend on the event:
|
||||
|
||||
- `create` triggers: `new.` is allowed, `old.` is not
|
||||
- `delete` triggers: `old.` is allowed, `new.` is not
|
||||
- `update` triggers: both `old.` and `new.` are allowed
|
||||
- standalone statements: neither `old.` nor `new.` is allowed
|
||||
|
||||
Examples:
|
||||
|
||||
```sql
|
||||
before create where new.type = "story" and new.description is empty deny "stories must have a description"
|
||||
before delete where old.priority <= 2 deny "cannot delete high priority tikis"
|
||||
before update where old.status = "in progress" and new.status = "done" deny "tikis must go through review before completion"
|
||||
```
|
||||
|
||||

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

|
||||
|
||||
For the detailed type rules and built-ins, see [Types And Values](types-and-values.md) and [Operators And Built-ins](operators-and-builtins.md).
|
||||
|
||||
## Pipe actions on select
|
||||
|
||||
`select` statements may include an optional pipe suffix:
|
||||
|
||||
```text
|
||||
select <fields> where <condition> [order by ...] [limit N] | run(<command>)
|
||||
select <fields> where <condition> [order by ...] [limit N] | clipboard()
|
||||
```
|
||||
|
||||
### `| run(...)` — shell execution
|
||||
|
||||
Evaluation model:
|
||||
|
||||
- The `select` runs first, producing zero or more rows.
|
||||
- For each row, the `run()` command is executed with positional arguments (`$1`, `$2`, etc.) substituted from the selected fields in left-to-right order.
|
||||
- Each command execution has a **30-second timeout**.
|
||||
- Command failures are **non-fatal** — remaining rows still execute.
|
||||
- Stdout and stderr are **fire-and-forget** (not captured or returned).
|
||||
|
||||
Rules:
|
||||
|
||||
- Explicit field names are required — `select *` and bare `select` are rejected when used with a pipe.
|
||||
- The command expression must be a string literal or string-typed expression, but **field references are not allowed** in the command string itself.
|
||||
- Positional arguments `$1`, `$2`, etc. are substituted by the runtime before each command execution.
|
||||
|
||||
Example:
|
||||
|
||||
```sql
|
||||
select id, title where status = "done" | run("myscript $1 $2")
|
||||
```
|
||||
|
||||
For a task with `id = "TIKI-ABC123"` and `title = "Fix bug"`, the command becomes:
|
||||
|
||||
```bash
|
||||
myscript "TIKI-ABC123" "Fix bug"
|
||||
```
|
||||
|
||||
Pipe `| run(...)` on select is distinct from trigger `run()` actions. See [Triggers](triggers.md) for the difference.
|
||||
|
||||
### `| clipboard()` — copy to clipboard
|
||||
|
||||
Evaluation model:
|
||||
|
||||
- The `select` runs first, producing zero or more rows.
|
||||
- The selected field values are written to the system clipboard.
|
||||
- Fields within a row are **tab-separated**; rows are **newline-separated**.
|
||||
- Uses `atotto/clipboard` internally — works on macOS, Linux (requires `xclip` or `xsel`), and Windows.
|
||||
|
||||
Rules:
|
||||
|
||||
- Explicit field names are required — same restriction as `| run(...)`.
|
||||
- `clipboard()` takes no arguments — the grammar enforces empty parentheses.
|
||||
|
||||
Examples:
|
||||
|
||||
```sql
|
||||
select id where id = id() | clipboard()
|
||||
select id, title where status = "done" | clipboard()
|
||||
```
|
||||
|
||||
For a single task with `id = "TIKI-ABC123"` and `title = "Fix bug"`, the clipboard receives:
|
||||
|
||||
```text
|
||||
TIKI-ABC123 Fix bug
|
||||
```
|
||||
|
||||
229
.doc/doki/doc/ruki/syntax.md
Normal file
|
|
@ -0,0 +1,229 @@
|
|||
# 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 ] [ "limit" int ] [ pipeAction ] ;
|
||||
fieldList = identifier { "," identifier } ;
|
||||
pipeAction = "|" ( runAction | clipboardAction ) ;
|
||||
clipboardAction = "clipboard" "(" ")" ;
|
||||
createStmt = "create" assignment { assignment } ;
|
||||
|
||||
orderBy = "order" "by" sortField { "," sortField } ;
|
||||
sortField = identifier [ "asc" | "desc" ] ;
|
||||
updateStmt = "update" "where" condition "set" assignment { assignment } ;
|
||||
deleteStmt = "delete" "where" condition ;
|
||||
|
||||
assignment = identifier "=" expr ;
|
||||
|
||||
trigger = timing event [ "where" condition ] ( action | deny ) ;
|
||||
timing = "before" | "after" ;
|
||||
event = "create" | "update" | "delete" ;
|
||||
|
||||
action = runAction | createStmt | updateStmt | deleteStmt ;
|
||||
runAction = "run" "(" expr ")" ;
|
||||
deny = "deny" string ;
|
||||
|
||||
timeTrigger = "every" duration ( createStmt | updateStmt | deleteStmt ) ;
|
||||
```
|
||||
|
||||

|
||||
|
||||

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

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

|
||||
|
||||
Examples:
|
||||
|
||||
```sql
|
||||
title
|
||||
old.status
|
||||
["bug", "frontend"]
|
||||
next_date(recurrence)
|
||||
count(select where status = "done")
|
||||
2026-03-25 + 2day
|
||||
tags + ["needs-triage"]
|
||||
```
|
||||
|
||||
## Operator binding summary
|
||||
|
||||
Condition operators:
|
||||
|
||||
- highest: a condition in parentheses, or a condition built from a single expression
|
||||
- then: `not`
|
||||
- then: `and`
|
||||
- lowest: `or`
|
||||
|
||||
Expression operators:
|
||||
|
||||
- only one binary precedence level exists for expressions
|
||||
- `+` and `-` associate left to right
|
||||
|
||||
That means:
|
||||
|
||||
```sql
|
||||
select where priority = 1 or priority = 2 and status = "done"
|
||||
```
|
||||
|
||||
parses as:
|
||||
|
||||
```text
|
||||
priority = 1 or (priority = 2 and status = "done")
|
||||
```
|
||||
|
||||
## Syntax notes
|
||||
|
||||
- `any` and `all` apply to the condition that comes right after them. If you want to combine that condition with `and` or `or`, use parentheses.
|
||||
- `select` used inside expressions is only valid as a `count(...)` argument. Bare subqueries are rejected during validation.
|
||||
- The grammar accepts `run(<expr>)`, but only as the top-level action of an `after` trigger.
|
||||
- `old.` and `new.` are only allowed in some trigger conditions. See [Semantics](semantics.md) and [Validation And Errors](validation-and-errors.md).
|
||||
320
.doc/doki/doc/ruki/triggers.md
Normal file
|
|
@ -0,0 +1,320 @@
|
|||
# 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
|
||||
|
||||
**Note:** This section describes trigger `run()` actions. For the pipe syntax `| run(...)` and `| clipboard()` on select statements, see [Semantics](semantics.md#pipe-actions-on-select) and [Operators And Built-ins](operators-and-builtins.md#shell-related-forms). Both `run()` and `clipboard()` are pipe-only targets — they cannot appear as trigger actions.
|
||||
|
||||
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.
|
||||
|
||||
## Recipes
|
||||
|
||||
For a catalog of ready-to-use trigger examples — WIP limits, recurring tasks, auto-escalation, and more — see [Trigger Ideas](../ideas/triggers.md).
|
||||
165
.doc/doki/doc/ruki/types-and-values.md
Normal file
|
|
@ -0,0 +1,165 @@
|
|||
# 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`
|
||||
|
||||
- validated through the injected schema against the `types:` section of `workflow.yaml`
|
||||
- production normalization lowercases, trims, and removes separators
|
||||
- default built-in types are `story`, `bug`, `spike`, and `epic`
|
||||
- type keys must be canonical (matching normalized form); aliases are not supported
|
||||
- unknown type values are rejected — no silent fallback
|
||||
|
||||
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.
|
||||
367
.doc/doki/doc/ruki/validation-and-errors.md
Normal file
|
|
@ -0,0 +1,367 @@
|
|||
# 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)
|
||||
- [Limit errors](#limit-errors)
|
||||
- [Built-in and subquery errors](#built-in-and-subquery-errors)
|
||||
|
||||
## Overview
|
||||
|
||||
This page explains the errors you can get in `ruki`. It covers syntax errors, unknown fields, type mismatches, invalid enum values, unsupported operators, and invalid trigger structure.
|
||||
|
||||
## Validation layers
|
||||
|
||||

|
||||
|
||||
`ruki` has two distinct failure stages:
|
||||
|
||||
1. Parse-time failures
|
||||
2. Validation-time failures
|
||||
|
||||
Parse-time failures happen when the input does not fit the grammar at all.
|
||||
|
||||
Examples:
|
||||
|
||||
```sql
|
||||
drop where id = 1
|
||||
update set status="done"
|
||||
delete id = "x"
|
||||
after update select
|
||||
```
|
||||
|
||||
Validation-time failures happen after parsing, once the AST is checked against schema and semantic rules.
|
||||
|
||||
Examples:
|
||||
|
||||
```sql
|
||||
select where foo = "bar"
|
||||
create title="x" priority="high"
|
||||
select where status < "done"
|
||||
before update where new.status = "done"
|
||||
```
|
||||
|
||||
## Structural statement and trigger errors
|
||||
|
||||
Statements:
|
||||
|
||||
- `create` must have at least one assignment
|
||||
- `update` must have at least one assignment in `set`
|
||||
- duplicate assignments to the same field are rejected
|
||||
|
||||
Triggers:
|
||||
|
||||
- `before` triggers must have `deny`
|
||||
- `before` triggers must not have action or `run(...)`
|
||||
- `after` triggers must have an action or `run(...)`
|
||||
- `after` triggers must not have `deny`
|
||||
- trigger actions must not be `select`
|
||||
|
||||
Examples:
|
||||
|
||||
```sql
|
||||
before update where new.status = "done" update where id = old.id set status="done"
|
||||
after update where new.status = "done" deny "no"
|
||||
before update where new.status = "done"
|
||||
after update where new.status = "done"
|
||||
```
|
||||
|
||||
## Field and qualifier errors
|
||||
|
||||
Unknown field errors:
|
||||
|
||||
```sql
|
||||
select where foo = "bar"
|
||||
create title="x" foo="bar"
|
||||
```
|
||||
|
||||
Immutable field errors:
|
||||
|
||||
- `id`, `createdBy`, `createdAt`, and `updatedAt` cannot be assigned in `create` or `update`
|
||||
|
||||
```sql
|
||||
create title="x" id="TIKI-ABC123"
|
||||
update where status = "done" set createdBy="someone"
|
||||
```
|
||||
|
||||
Qualifier misuse:
|
||||
|
||||
- `old.` and `new.` are invalid in standalone statements
|
||||
- `old.` is invalid in create-trigger contexts
|
||||
- `new.` is invalid in delete-trigger contexts
|
||||
- both are invalid inside quantifier bodies
|
||||
|
||||
Examples:
|
||||
|
||||
```sql
|
||||
select where old.status = "done"
|
||||
create title=old.title
|
||||
after create where old.status = "done" update where id = new.id set status="done"
|
||||
before delete where new.status = "done" deny "x"
|
||||
before update where dependsOn any old.status = "done" deny "blocked"
|
||||
```
|
||||
|
||||
## Required field errors
|
||||
|
||||
The resulting task from `create` must have a non-empty `title`. If the template does not provide one, a `title=...` assignment is required.
|
||||
|
||||
`title`, `status`, `type`, and `priority` reject `empty` assignment:
|
||||
|
||||
```sql
|
||||
create title="" priority=2
|
||||
create title="x" status=empty
|
||||
update where id = "TIKI-ABC123" set priority=empty
|
||||
```
|
||||
|
||||
## Type and operator errors
|
||||
|
||||
Comparison mismatches:
|
||||
|
||||
```sql
|
||||
select where priority = "high"
|
||||
select where status = title
|
||||
select where type = assignee
|
||||
```
|
||||
|
||||
Unsupported operators:
|
||||
|
||||
```sql
|
||||
select where title < "hello"
|
||||
select where status < "done"
|
||||
select where recurrence < recurrence
|
||||
```
|
||||
|
||||
Invalid assignment types:
|
||||
|
||||
```sql
|
||||
create title="x" priority="high"
|
||||
create title="x" assignee=42
|
||||
create title="x" status=title
|
||||
update where id="x" set title=status
|
||||
```
|
||||
|
||||
Invalid binary expressions:
|
||||
|
||||
```sql
|
||||
create title="x" priority=1 + "a"
|
||||
select where due = 2026-03-25 + 2026-03-20
|
||||
create title="x" dependsOn=dependsOn + status
|
||||
create title="x" dependsOn=dependsOn + tags
|
||||
```
|
||||
|
||||
## Enum and list errors
|
||||
|
||||
Unknown enum values:
|
||||
|
||||
```sql
|
||||
select where status = "nonexistent"
|
||||
select where type = "nonexistent"
|
||||
create title="x" status="nonexistent"
|
||||
create title="x" type="nonexistent"
|
||||
```
|
||||
|
||||
Invalid enum list membership:
|
||||
|
||||
```sql
|
||||
select where status in ["done", "bogus"]
|
||||
select where type in ["bug", "bogus"]
|
||||
```
|
||||
|
||||
List strictness:
|
||||
|
||||
- list literals must be homogeneous
|
||||
- `list<string>` fields reject non-string elements
|
||||
- the special `list<ref>` assignment path accepts string-literal lists, but not arbitrary string fields or mixed element expressions
|
||||
|
||||
Invalid examples:
|
||||
|
||||
```sql
|
||||
select where status in ["done", 1]
|
||||
create title="x" tags=[1, 2]
|
||||
create title="x" dependsOn=["TIKI-ABC123", title]
|
||||
select where status in dependsOn
|
||||
select where tags any status = "done"
|
||||
```
|
||||
|
||||
## Order by errors
|
||||
|
||||
Unknown field:
|
||||
|
||||
```sql
|
||||
select order by nonexistent
|
||||
```
|
||||
|
||||
Non-orderable types:
|
||||
|
||||
```sql
|
||||
select order by tags
|
||||
select order by dependsOn
|
||||
select order by recurrence
|
||||
```
|
||||
|
||||
Duplicate field:
|
||||
|
||||
```sql
|
||||
select order by priority, priority desc
|
||||
```
|
||||
|
||||
Order by inside a subquery:
|
||||
|
||||
```sql
|
||||
select where count(select where status = "done" order by priority) >= 1
|
||||
```
|
||||
|
||||
## Limit errors
|
||||
|
||||
Validation error (must be positive):
|
||||
|
||||
```sql
|
||||
select limit 0
|
||||
```
|
||||
|
||||
Parse errors (invalid token after `limit`):
|
||||
|
||||
```sql
|
||||
select limit -1
|
||||
select limit "three"
|
||||
select limit
|
||||
```
|
||||
|
||||
## Pipe validation errors
|
||||
|
||||
Pipe actions (`| run(...)` and `| clipboard()`) on `select` have several restrictions:
|
||||
|
||||
`select *` with pipe:
|
||||
|
||||
```sql
|
||||
select * where status = "done" | run("echo $1")
|
||||
select * where status = "done" | clipboard()
|
||||
```
|
||||
|
||||
Bare `select` with pipe:
|
||||
|
||||
```sql
|
||||
select | run("echo $1")
|
||||
select | clipboard()
|
||||
```
|
||||
|
||||
Both are rejected because explicit field names are required when using a pipe. This applies to both `run()` and `clipboard()` targets.
|
||||
|
||||
Non-string command:
|
||||
|
||||
```sql
|
||||
select id where status = "done" | run(42)
|
||||
```
|
||||
|
||||
Field references in command:
|
||||
|
||||
```sql
|
||||
select id, title where status = "done" | run(title + " " + id)
|
||||
select id, title where status = "done" | run("echo " + title)
|
||||
```
|
||||
|
||||
Field references are not allowed in the pipe command expression itself. Use positional arguments (`$1`, `$2`) instead.
|
||||
|
||||
Pipe on non-select statements:
|
||||
|
||||
```sql
|
||||
update where status = "done" set priority=1 | run("echo done")
|
||||
create title="x" | run("echo created")
|
||||
delete where id = "TIKI-ABC123" | run("echo deleted")
|
||||
```
|
||||
|
||||
Pipe suffix is only valid on `select` statements.
|
||||
|
||||
## 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
|
||||
```
|
||||
|
||||
## input() errors
|
||||
|
||||
`input()` without `input:` declaration on the action:
|
||||
|
||||
```sql
|
||||
update where id = id() set assignee = input()
|
||||
```
|
||||
|
||||
Fails at workflow load time with: `input() requires 'input:' declaration on action`.
|
||||
|
||||
`input:` declared but `input()` not used:
|
||||
|
||||
```yaml
|
||||
- key: "a"
|
||||
label: "Ready"
|
||||
action: update where id = id() set status="ready"
|
||||
input: string
|
||||
```
|
||||
|
||||
Fails at workflow load time: `declares 'input: string' but does not use input()`.
|
||||
|
||||
Duplicate `input()` (more than one call per action):
|
||||
|
||||
```sql
|
||||
update where id = id() set assignee=input(), title=input()
|
||||
```
|
||||
|
||||
Fails with: `input() may only be used once per action`.
|
||||
|
||||
Type mismatch (declared type incompatible with target field):
|
||||
|
||||
```yaml
|
||||
- key: "a"
|
||||
label: "Assign to"
|
||||
action: update where id = id() set assignee = input()
|
||||
input: int
|
||||
```
|
||||
|
||||
Fails at workflow load time: `int` is not assignable to string field `assignee`.
|
||||
|
||||
`input()` with arguments:
|
||||
|
||||
```sql
|
||||
update where id = id() set assignee = input("name")
|
||||
```
|
||||
|
||||
Fails with: `input() takes no arguments`.
|
||||
|
||||
|
|
@ -1,10 +0,0 @@
|
|||
# AI skills
|
||||
|
||||
`tiki` adds optional [agent skills](https://agentskills.io/home) to the repo upon initialization
|
||||
If installed you can:
|
||||
|
||||
- work with [Claude Code](https://code.claude.com), [Codex](https://openai.com/codex), [Opencode](https://opencode.ai) by simply mentioning `tiki` or `doki` in your prompts
|
||||
- create, find, modify and delete tikis using AI
|
||||
- create tikis/dokis directly from Markdown files
|
||||
- Refer to tikis or dokis when implementing with AI-assisted development - `implement tiki xxxxxxx`
|
||||
- Keep a history of prompts/plans by saving prompts or plans with your repo
|
||||
79
.doc/doki/doc/themes.md
Normal file
|
|
@ -0,0 +1,79 @@
|
|||
# Themes
|
||||
|
||||
## Setting a theme
|
||||
|
||||
Set the theme in your `config.yaml` under the `appearance` section (see [Configuration](config.md) for file locations and merging rules):
|
||||
|
||||
```yaml
|
||||
appearance:
|
||||
theme: dracula
|
||||
```
|
||||
|
||||
Available values:
|
||||
- `auto` — detects your terminal background and picks `dark` or `light` automatically (default)
|
||||
- `dark`, `light` — built-in base themes
|
||||
- Any named theme listed below
|
||||
|
||||
## Dark themes
|
||||
|
||||
### dark
|
||||
|
||||
The built-in dark base theme. Used by `auto` when a dark terminal background is detected.
|
||||
|
||||

|
||||
|
||||
### dracula
|
||||
|
||||

|
||||
|
||||
### tokyo-night
|
||||
|
||||

|
||||
|
||||
### gruvbox-dark
|
||||
|
||||

|
||||
|
||||
### catppuccin-mocha
|
||||
|
||||

|
||||
|
||||
### solarized-dark
|
||||
|
||||

|
||||
|
||||
### nord
|
||||
|
||||

|
||||
|
||||
### monokai
|
||||
|
||||

|
||||
|
||||
### one-dark
|
||||
|
||||

|
||||
|
||||
## Light themes
|
||||
|
||||
### light
|
||||
|
||||
The built-in light base theme. Used by `auto` when a light terminal background is detected.
|
||||
|
||||

|
||||
|
||||
### catppuccin-latte
|
||||
|
||||

|
||||
|
||||
### solarized-light
|
||||
|
||||

|
||||
|
||||
### gruvbox-light
|
||||
|
||||

|
||||
|
||||
### github-light
|
||||
|
||||

|
||||
BIN
.doc/doki/doc/themes/catppuccin-latte.png
vendored
Normal file
|
After Width: | Height: | Size: 268 KiB |
BIN
.doc/doki/doc/themes/catppuccin-mocha.png
vendored
Normal file
|
After Width: | Height: | Size: 277 KiB |
BIN
.doc/doki/doc/themes/dark.png
vendored
Normal file
|
After Width: | Height: | Size: 272 KiB |
BIN
.doc/doki/doc/themes/dracula.png
vendored
Normal file
|
After Width: | Height: | Size: 283 KiB |
BIN
.doc/doki/doc/themes/github-light.png
vendored
Normal file
|
After Width: | Height: | Size: 268 KiB |
BIN
.doc/doki/doc/themes/gruvbox-dark.png
vendored
Normal file
|
After Width: | Height: | Size: 264 KiB |
BIN
.doc/doki/doc/themes/gruvbox-light.png
vendored
Normal file
|
After Width: | Height: | Size: 268 KiB |
BIN
.doc/doki/doc/themes/light.png
vendored
Normal file
|
After Width: | Height: | Size: 265 KiB |
BIN
.doc/doki/doc/themes/monokai.png
vendored
Normal file
|
After Width: | Height: | Size: 272 KiB |
BIN
.doc/doki/doc/themes/nord.png
vendored
Normal file
|
After Width: | Height: | Size: 243 KiB |
BIN
.doc/doki/doc/themes/one-dark.png
vendored
Normal file
|
After Width: | Height: | Size: 237 KiB |
BIN
.doc/doki/doc/themes/solarized-dark.png
vendored
Normal file
|
After Width: | Height: | Size: 234 KiB |
BIN
.doc/doki/doc/themes/solarized-light.png
vendored
Normal file
|
After Width: | Height: | Size: 239 KiB |
BIN
.doc/doki/doc/themes/tokyo-night.png
vendored
Normal file
|
After Width: | Height: | Size: 260 KiB |
|
|
@ -43,7 +43,7 @@ The `.doc/` directory contains two main subdirectories:
|
|||
Tiki files are saved in `.doc/tiki` directory and can be managed via:
|
||||
|
||||
- `tiki` cli
|
||||
- AI tools such as `claude`, `codex` or `opencode`
|
||||
- AI tools such as `claude`, `gemini`, `codex` or `opencode`
|
||||
- manually
|
||||
|
||||
A tiki is made of its frontmatter that includes all fields related to a tiki status and types and its description
|
||||
|
|
@ -88,10 +88,11 @@ title: Implement user authentication
|
|||
|
||||
#### type
|
||||
|
||||
Optional string. Default: `story`.
|
||||
Optional string. Defaults to the first type defined in `workflow.yaml`.
|
||||
|
||||
Valid values: `story`, `bug`, `spike`, `epic`. Aliases `feature` and `task` resolve to `story`.
|
||||
In the TUI each type has an icon: Story 🌀, Bug 💥, Spike 🔍, Epic 🗂️.
|
||||
Valid values are the type keys defined in the `types:` section of `workflow.yaml`.
|
||||
Default types: `story`, `bug`, `spike`, `epic`. Each type can have a label and emoji
|
||||
configured in `workflow.yaml`. Aliases are not supported; use the canonical key.
|
||||
|
||||
```yaml
|
||||
type: bug
|
||||
|
|
|
|||
|
|
@ -46,8 +46,6 @@ Just configuring multiple plugins. Create a file like `brainstorm.yaml`:
|
|||
```text
|
||||
name: Brainstorm
|
||||
type: doki
|
||||
foreground: "##ffff99"
|
||||
background: "#996600"
|
||||
key: "F6"
|
||||
url: new-doc-root.md
|
||||
```
|
||||
|
|
|
|||
10
.github/workflows/go.yml
vendored
|
|
@ -13,7 +13,7 @@ jobs:
|
|||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
go-version: ['1.24.x']
|
||||
go-version: ['1.25.x']
|
||||
|
||||
steps:
|
||||
- name: Checkout code
|
||||
|
|
@ -43,7 +43,7 @@ jobs:
|
|||
go test -coverprofile=coverage.out -covermode=atomic $pkgs
|
||||
|
||||
- name: Upload coverage to Codecov
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.go-version == '1.24.x'
|
||||
if: matrix.os == 'ubuntu-latest' && matrix.go-version == '1.25.x'
|
||||
uses: codecov/codecov-action@v5
|
||||
with:
|
||||
token: ${{ secrets.CODECOV_TOKEN }}
|
||||
|
|
@ -62,13 +62,13 @@ jobs:
|
|||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24.x'
|
||||
go-version: '1.25.x'
|
||||
cache: true
|
||||
|
||||
- name: Run golangci-lint
|
||||
uses: golangci/golangci-lint-action@v7
|
||||
with:
|
||||
version: v2.8.0
|
||||
version: v2.11.3
|
||||
args: --timeout=5m
|
||||
|
||||
build:
|
||||
|
|
@ -82,7 +82,7 @@ jobs:
|
|||
- name: Set up Go
|
||||
uses: actions/setup-go@v5
|
||||
with:
|
||||
go-version: '1.24.x'
|
||||
go-version: '1.25.x'
|
||||
cache: true
|
||||
|
||||
- name: Build
|
||||
|
|
|
|||
|
|
@ -20,6 +20,10 @@ linters:
|
|||
errcheck:
|
||||
check-type-assertions: true
|
||||
|
||||
staticcheck:
|
||||
checks:
|
||||
- "-SA5011" # t.Fatal stops via runtime.Goexit; nil deref after guard is safe
|
||||
|
||||
govet:
|
||||
enable-all: true
|
||||
disable:
|
||||
|
|
|
|||
2
Makefile
|
|
@ -1,7 +1,7 @@
|
|||
.PHONY: help build install clean test lint snapshot
|
||||
|
||||
# Build variables
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev")
|
||||
VERSION := $(shell git describe --tags --always --dirty 2>/dev/null | sed 's/^v//' || echo "dev")
|
||||
COMMIT := $(shell git rev-parse HEAD 2>/dev/null || echo "unknown")
|
||||
DATE := $(shell date -u +"%Y-%m-%dT%H:%M:%SZ")
|
||||
LDFLAGS := -ldflags "-X github.com/boolean-maybe/tiki/config.Version=$(VERSION) -X github.com/boolean-maybe/tiki/config.GitCommit=$(COMMIT) -X github.com/boolean-maybe/tiki/config.BuildDate=$(DATE)"
|
||||
|
|
|
|||
58
README.md
|
|
@ -2,38 +2,24 @@ Follow me on X: [
|
||||
|
||||
Now support images and Mermaid diagrams in Kitty-compatible terminals (iTerm2, Kitty, WezTerm, Ghostty)
|
||||
|
||||

|
||||
|
||||
supported:
|
||||
- PNG, JPEG, GIF, BMP, WebP, TIFF
|
||||
- SVG
|
||||
- Mermaid diagram blocks
|
||||
|
||||
see [requirements](.doc/doki/doc/image-requirements.md) for more details
|
||||
|
||||
|
||||
`tiki` is a simple and lightweight way to keep your tasks, prompts, documents, ideas, scratchpads in your project **git** repo
|
||||
`tiki` is a terminal-first Markdown workspace for tasks, docs, prompts, and notes stored in your **git** repo
|
||||
|
||||

|
||||
|
||||
[Documentation](.doc/doki/doc/index.md)
|
||||
|
||||
Markdown is the new go-to format for everything, it's simple, efficient, human and AI native - project management,
|
||||
documentation, brainstorming ideas, incomplete implementations, AI prompts and plans and what not are saved as Markdown files.
|
||||
Stick them in your repo. Keep around for as long as you need. Find them back in **git** history. Make issues out of them
|
||||
and take them through an agile lifecycle. `tiki` helps you save and organize these files:
|
||||
What `tiki` does:
|
||||
|
||||
- Standalone **Markdown viewer** - view and edit Markdown or image files, navigate to local/external/GitHub/GitLab links, image display
|
||||
- Standalone **Markdown viewer** with images, Mermaid diagrams, and link/TOC navigation
|
||||
- Keep, search, view and version Markdown files in the **git repo**
|
||||
- **Wiki-style** documentation with multiple entry points
|
||||
- Keep a **to-do list** with priorities, status, assignee and size
|
||||
- Issue management with **Kanban/Scrum** style board and burndown chart
|
||||
- **Plugin-first** architecture - user-defined plugins with filters and actions like Backlog, Recent, Roadmap
|
||||
- AI **skills** to enable [Claude Code](https://code.claude.com), [Codex](https://openai.com/codex), [Opencode](https://opencode.ai) work with natural language commands like
|
||||
- SQL-like command language [ruki](.doc/doki/doc/ruki/index.md) to query and update tasks and define custom workflows
|
||||
- **Plugin-first** architecture - user-defined plugins based on [ruki](.doc/doki/doc/ruki/index.md) and custom views
|
||||
- AI **skills** to enable [Claude Code](https://code.claude.com), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Codex](https://openai.com/codex), [Opencode](https://opencode.ai) work with natural language commands like
|
||||
"_create a tiki from @my-file.md_"
|
||||
"_mark tiki ABC123 as complete_"
|
||||
|
||||
|
|
@ -72,15 +58,22 @@ make build install
|
|||
### Verify installation
|
||||
```bash
|
||||
tiki --version
|
||||
tiki --help
|
||||
```
|
||||
|
||||
## Quick start
|
||||
|
||||
### Markdown viewer
|
||||
|
||||
<img src=".doc/doki/doc/markdown-viewer.gif" alt="Markdown viewer demo" width="800">
|
||||

|
||||
|
||||
`tiki my-markdownfile` to view, edit and navigate markdown files in terminal.
|
||||
if you have no Markdown file to try - use this:
|
||||
```
|
||||
tiki https://github.com/boolean-maybe/tiki/blob/main/testdata/go-concurrency.md
|
||||
```
|
||||
see [requirements](.doc/doki/doc/image-requirements.md) for supported terminals, SVG and diagrams support
|
||||
|
||||
All vim-like pager commands are supported in addition to:
|
||||
- `Tab/Enter` to select and load a link in the document
|
||||
- `e` to edit it in your favorite editor
|
||||
|
|
@ -89,14 +82,23 @@ All vim-like pager commands are supported in addition to:
|
|||
|
||||
<img src=".doc/doki/doc/kanban.gif" alt="Kanban demo" width="800">
|
||||
|
||||
to try with a demo project just run:
|
||||
|
||||
```
|
||||
cd /tmp && tiki demo
|
||||
```
|
||||
|
||||
this will clone and show a demo project. Once done you can try your own:
|
||||
|
||||
`cd` into your **git** repo and run `tiki init` to initialize.
|
||||
Move your tiki around the board with `Shift ←/Shift →`.
|
||||
Make sure to press `?` for help.
|
||||
Press `?` to open the Action Palette — it lists all available actions with their shortcuts.
|
||||
Press `F1` to open a sample doc root. Follow links with `Tab/Enter`
|
||||
|
||||
### AI skills
|
||||
You will be prompted to install skills for
|
||||
You will be prompted to install skills for
|
||||
- [Claude Code](https://code.claude.com)
|
||||
- [Gemini CLI](https://github.com/google-gemini/gemini-cli)
|
||||
- [Codex](https://openai.com/codex)
|
||||
- [Opencode](https://opencode.ai)
|
||||
|
||||
|
|
@ -115,8 +117,6 @@ grep ERROR server.log | sort -u | while read -r line; do echo "$line" | tiki; do
|
|||
|
||||
Read more [quick capture docs](.doc/doki/doc/quick-capture.md).
|
||||
|
||||
Happy tikking!
|
||||
|
||||
## tiki
|
||||
Keep your tickets in your pockets!
|
||||
|
||||
|
|
@ -142,14 +142,14 @@ Store your notes in remotes!
|
|||
|
||||
`tiki` TUI tool allows creating, viewing, editing and deleting tikis as well as creating custom plugins to
|
||||
view any selection, for example, Recent tikis, Architecture docs, Saved prompts, Security review, Future Roadmap
|
||||
Read more by pressing `?` for help
|
||||
Press `?` to open the Action Palette and discover all available actions
|
||||
|
||||
## AI skills
|
||||
|
||||
`tiki` adds optional [agent skills](https://agentskills.io/home) to the repo upon initialization
|
||||
If installed you can:
|
||||
|
||||
- work with [Claude Code](https://code.claude.com), [Codex](https://openai.com/codex), [Opencode](https://opencode.ai) by simply mentioning `tiki` or `doki` in your prompts
|
||||
- work with [Claude Code](https://code.claude.com), [Gemini CLI](https://github.com/google-gemini/gemini-cli), [Codex](https://openai.com/codex), [Opencode](https://opencode.ai) by simply mentioning `tiki` or `doki` in your prompts
|
||||
- create, find, modify and delete tikis using AI
|
||||
- create tikis/dokis directly from Markdown files
|
||||
- Refer to tikis or dokis when implementing with AI-assisted development - `implement tiki xxxxxxx`
|
||||
|
|
@ -170,4 +170,4 @@ to contribute:
|
|||
|
||||

|
||||
[](https://goreportcard.com/report/github.com/boolean-maybe/tiki)
|
||||
[](https://pkg.go.dev/github.com/boolean-maybe/tiki)
|
||||
[](https://pkg.go.dev/github.com/boolean-maybe/tiki)
|
||||
|
|
|
|||
|
|
@ -1,237 +1,150 @@
|
|||
---
|
||||
name: tiki
|
||||
description: view, create, update, delete tikis and manage dependencies
|
||||
allowed-tools: Read, Grep, Glob, Update, Edit, Write, WriteFile, Bash(git add:*), Bash(git rm:*)
|
||||
allowed-tools: Read, Grep, Glob, Write, Bash(tiki exec:*), Bash(git log:*), Bash(git blame:*)
|
||||
---
|
||||
|
||||
# tiki
|
||||
|
||||
A tiki is a Markdown file in tiki format saved in the project `.doc/tiki` directory
|
||||
with a name like `tiki-abc123.md` in all lower letters.
|
||||
IMPORTANT! files are named in lowercase always
|
||||
If this directory does not exist prompt user for creation
|
||||
A tiki is a task stored as a Markdown file in `.doc/tiki/`.
|
||||
Filename: `tiki-<6char>.md` (lowercase) → ID: `TIKI-<6CHAR>` (uppercase).
|
||||
Example: `tiki-x7f4k2.md` → `TIKI-X7F4K2`
|
||||
|
||||
## tiki ID format
|
||||
All CRUD operations go through `tiki exec '<ruki-statement>'`.
|
||||
This handles validation, triggers, file persistence, and git staging automatically.
|
||||
Never manually edit tiki files or run `git add`/`git rm` — `tiki exec` does it all.
|
||||
|
||||
ID format: `TIKI-ABC123` where ABC123 is 6-char random alphanumeric
|
||||
**Derived from filename, NOT stored in frontmatter**
|
||||
For full `ruki` syntax, see `.doc/doki/doc/ruki/`.
|
||||
|
||||
Examples:
|
||||
- Filename: `tiki-x7f4k2.md` → ID: `TIKI-X7F4K2`
|
||||
## Field reference
|
||||
|
||||
## tiki format
|
||||
| Field | Type | Notes |
|
||||
|---|---|---|
|
||||
| `id` | `id` | immutable, auto-generated `TIKI-XXXXXX` |
|
||||
| `title` | `string` | required on create |
|
||||
| `description` | `string` | markdown body content |
|
||||
| `status` | `status` | from `workflow.yaml`, default `backlog` |
|
||||
| `type` | `type` | bug, feature, task, story, epic |
|
||||
| `priority` | `int` | 1–5 (1=high, 5=low), default 3 |
|
||||
| `points` | `int` | story points 1–10 |
|
||||
| `assignee` | `string` | |
|
||||
| `tags` | `list<string>` | |
|
||||
| `dependsOn` | `list<ref>` | list of tiki IDs |
|
||||
| `due` | `date` | `YYYY-MM-DD` format |
|
||||
| `recurrence` | `recurrence` | cron: `0 0 * * *` (daily), `0 0 * * MON` (weekly), `0 0 1 * *` (monthly) |
|
||||
| `createdBy` | `string` | immutable |
|
||||
| `createdAt` | `timestamp` | immutable |
|
||||
| `updatedAt` | `timestamp` | immutable |
|
||||
|
||||
A tiki format is Markdown with some requirements:
|
||||
Priority descriptions: 1=high, 2=medium-high, 3=medium, 4=medium-low, 5=low.
|
||||
Valid statuses are configurable — check `statuses:` in `~/.config/tiki/workflow.yaml`.
|
||||
|
||||
### frontmatter
|
||||
## Query
|
||||
|
||||
```markdown
|
||||
---
|
||||
title: My ticket
|
||||
type: story
|
||||
status: backlog
|
||||
priority: 3
|
||||
points: 5
|
||||
tags:
|
||||
- markdown
|
||||
- metadata
|
||||
dependsOn:
|
||||
- TIKI-ABC123
|
||||
due: 2026-04-01
|
||||
recurrence: 0 0 * * MON
|
||||
---
|
||||
```sh
|
||||
tiki exec 'select' # all tasks
|
||||
tiki exec 'select title, status' # field projection
|
||||
tiki exec 'select id, title where status = "done"' # filter
|
||||
tiki exec 'select where "bug" in tags order by priority' # tag filter + sort
|
||||
tiki exec 'select where due < now()' # overdue
|
||||
tiki exec 'select where due - now() < 7day' # due within 7 days
|
||||
tiki exec 'select where dependsOn any status != "done"' # blocked tasks
|
||||
tiki exec 'select where assignee = user()' # my tasks
|
||||
```
|
||||
|
||||
where fields can have these values:
|
||||
- type: bug, feature, task, story, epic
|
||||
- status: configurable via `workflow.yaml`. Default statuses: backlog, ready, in_progress, review, done.
|
||||
To find valid statuses for the current project, check the `statuses:` section in `~/.config/tiki/workflow.yaml`.
|
||||
- priority: is any integer number from 1 to 5 where 1 is the highest priority. Mapped to priority description:
|
||||
- high: 1
|
||||
- medium-high: 2
|
||||
- medium: 3
|
||||
- medium-low: 4
|
||||
- low: 5
|
||||
- points: story points from 1 to 10
|
||||
- dependsOn: list of tiki IDs (TIKI-XXXXXX format) this task depends on
|
||||
- due: due date in YYYY-MM-DD format (optional, date-only)
|
||||
- recurrence: recurrence pattern in cron format (optional). Supported: `0 0 * * *` (daily), `0 0 * * MON` (weekly on Monday, etc.), `0 0 1 * *` (monthly)
|
||||
Output is an ASCII table. To read a tiki's full markdown body, use `Read` on `.doc/tiki/tiki-<id>.md`.
|
||||
|
||||
### body
|
||||
## Create
|
||||
|
||||
The body of a tiki is normal Markdown
|
||||
```sh
|
||||
tiki exec 'create title="Fix login"' # minimal
|
||||
tiki exec 'create title="Fix login" priority=2 status="ready" tags=["bug"]' # full
|
||||
tiki exec 'create title="Review" due=2026-04-01 + 2day' # date arithmetic
|
||||
tiki exec 'create title="Sprint review" recurrence="0 0 * * MON"' # recurrence
|
||||
```
|
||||
|
||||
if a tiki needs an attachment it is implemented as a normal markdown link to file syntax for example:
|
||||
- Logs are attached [logs](mylogs.log)
|
||||
- Here is the 
|
||||
- Check out docs: <https://www.markdownguide.org>
|
||||
- Contact: <user@example.com>
|
||||
|
||||
## Describe
|
||||
|
||||
When asked a question about a tiki find its file and read it then answer the question
|
||||
If the question is who created this tiki or who updated it last - use the git username
|
||||
For example:
|
||||
- who created this tiki? use `git log --follow --diff-filter=A -- <file_path>` to see who created it
|
||||
- who edited this tiki? use `git blame <file_path>` to see who edited the file
|
||||
|
||||
## View
|
||||
|
||||
`Created` timestamp is taken from git file creation if available else from the file creation timestamp.
|
||||
|
||||
`Author` is taken from git history as the git user who created the file.
|
||||
|
||||
## Creation
|
||||
|
||||
When asked to create a tiki:
|
||||
|
||||
- Generate a random 6-character alphanumeric ID (lowercase letters and digits)
|
||||
- The filename should be lowercase: `tiki-abc123.md`
|
||||
- If status is not specified use the default status from `~/.config/tiki/workflow.yaml` (typically `backlog`)
|
||||
- If priority is not specified use 3
|
||||
- If type is not specified - prompt the user or use `story` by default
|
||||
|
||||
Example: for random ID `x7f4k2`:
|
||||
- Filename: `tiki-x7f4k2.md`
|
||||
- tiki ID: `TIKI-X7F4K2`
|
||||
Output: `created TIKI-XXXXXX`. Defaults: status from workflow.yaml (typically `backlog`), priority 3, type `story`.
|
||||
|
||||
### Create from file
|
||||
|
||||
if asked to create a tiki from Markdown or text file - create only a single tiki and use the entire content of the
|
||||
file as its description. Title should be a short sentence summarizing the file content
|
||||
|
||||
#### git
|
||||
|
||||
After a new tiki is created `git add` this file.
|
||||
IMPORTANT - only add, never commit the file without user asking and permitting
|
||||
When asked to create a tiki from a file:
|
||||
1. Read the source file
|
||||
2. Summarize its content into a short title
|
||||
3. Use the file content as the description:
|
||||
```sh
|
||||
tiki exec 'create title="Summary of file" description="<escaped content>"'
|
||||
```
|
||||
Escape double quotes in the content with backslash.
|
||||
|
||||
## Update
|
||||
|
||||
When asked to update a tiki - edit its file
|
||||
For example when user says "set TIKI-ABC123 in progress" find its file and edit its frontmatter line from
|
||||
`status: backlog` to `status: in progress`
|
||||
```sh
|
||||
tiki exec 'update where id = "TIKI-X7F4K2" set status="done"' # status change
|
||||
tiki exec 'update where id = "TIKI-X7F4K2" set priority=1' # priority
|
||||
tiki exec 'update where status = "ready" set status="cancelled"' # bulk update
|
||||
tiki exec 'update where id = "TIKI-X7F4K2" set tags=tags + ["urgent"]' # add tag
|
||||
tiki exec 'update where id = "TIKI-X7F4K2" set due=2026-04-01' # set due date
|
||||
tiki exec 'update where id = "TIKI-X7F4K2" set due=empty' # clear due date
|
||||
tiki exec 'update where id = "TIKI-X7F4K2" set recurrence="0 0 * * MON"' # set recurrence
|
||||
tiki exec 'update where id = "TIKI-X7F4K2" set recurrence=empty' # clear recurrence
|
||||
```
|
||||
|
||||
### git
|
||||
Output: `updated N tasks`.
|
||||
|
||||
After a tiki is updated `git add` this file
|
||||
IMPORTANT - only add, never commit the file without user asking and permitting
|
||||
### Implement
|
||||
|
||||
## Deletion
|
||||
When asked to implement a tiki and the user approves implementation, set its status to `review`:
|
||||
```sh
|
||||
tiki exec 'update where id = "TIKI-X7F4K2" set status="review"'
|
||||
```
|
||||
|
||||
When asked to delete a tiki `git rm` its file
|
||||
If for any reason `git rm` cannot be executed and the file is still there - delete the file
|
||||
## Delete
|
||||
|
||||
## Implement
|
||||
```sh
|
||||
tiki exec 'delete where id = "TIKI-X7F4K2"' # by ID
|
||||
tiki exec 'delete where status = "cancelled"' # bulk
|
||||
```
|
||||
|
||||
When asked to implement a tiki and the user approves implementation change its status to `review` and `git add` it
|
||||
Output: `deleted N tasks`.
|
||||
|
||||
## Dependencies
|
||||
|
||||
Tikis can declare dependencies on other tikis via the `dependsOn` frontmatter field.
|
||||
Values are tiki IDs in `TIKI-XXXXXX` format. A dependency means this tiki is blocked by or requires the listed tikis.
|
||||
```sh
|
||||
# view a tiki's dependencies
|
||||
tiki exec 'select id, title, status, dependsOn where id = "TIKI-X7F4K2"'
|
||||
|
||||
### View dependencies
|
||||
# find what depends on a tiki (reverse lookup)
|
||||
tiki exec 'select id, title where "TIKI-X7F4K2" in dependsOn'
|
||||
|
||||
When asked about a tiki's dependencies, read its frontmatter `dependsOn` list.
|
||||
For each dependency ID, read that tiki file to show its title, status, and assignee.
|
||||
Highlight any dependencies that are not yet `done` -- these are blockers.
|
||||
# find blocked tasks (any dependency not done)
|
||||
tiki exec 'select id, title where dependsOn any status != "done"'
|
||||
|
||||
Example: "what blocks TIKI-X7F4K2?"
|
||||
1. Read `.doc/tiki/tiki-x7f4k2.md` frontmatter
|
||||
2. For each ID in `dependsOn`, read that tiki and report its status
|
||||
# add a dependency
|
||||
tiki exec 'update where id = "TIKI-X7F4K2" set dependsOn=dependsOn + ["TIKI-ABC123"]'
|
||||
|
||||
### Find dependents
|
||||
|
||||
When asked "what depends on TIKI-ABC123?" -- grep all tiki files for that ID in their `dependsOn` field:
|
||||
# remove a dependency
|
||||
tiki exec 'update where id = "TIKI-X7F4K2" set dependsOn=dependsOn - ["TIKI-ABC123"]'
|
||||
```
|
||||
grep -l "TIKI-ABC123" .doc/tiki/*.md
|
||||
|
||||
## Provenance
|
||||
|
||||
`ruki` does not access git history. Use git commands for authorship questions:
|
||||
|
||||
```sh
|
||||
# who created this tiki
|
||||
git log --follow --diff-filter=A -- .doc/tiki/tiki-x7f4k2.md
|
||||
|
||||
# who last edited this tiki
|
||||
git blame .doc/tiki/tiki-x7f4k2.md
|
||||
```
|
||||
Then read each match and check if TIKI-ABC123 appears in its `dependsOn` list.
|
||||
|
||||
### Add dependency
|
||||
Created timestamp and author are also available via:
|
||||
```sh
|
||||
tiki exec 'select createdAt, createdBy where id = "TIKI-X7F4K2"'
|
||||
```
|
||||
|
||||
When asked to add a dependency (e.g. "TIKI-X7F4K2 depends on TIKI-ABC123"):
|
||||
1. Verify the target tiki exists: check `.doc/tiki/tiki-abc123.md` exists
|
||||
2. Read `.doc/tiki/tiki-x7f4k2.md`
|
||||
3. Add `TIKI-ABC123` to the `dependsOn` list (create the field if missing)
|
||||
4. Do not add duplicates
|
||||
5. `git add` the modified file
|
||||
## Important
|
||||
|
||||
### Remove dependency
|
||||
|
||||
When asked to remove a dependency:
|
||||
1. Read the tiki file
|
||||
2. Remove the specified ID from `dependsOn`
|
||||
3. If `dependsOn` becomes empty, remove the field entirely (omitempty)
|
||||
4. `git add` the modified file
|
||||
|
||||
### Validation
|
||||
|
||||
- Each value must be a valid tiki ID format: `TIKI-` followed by 6 alphanumeric characters
|
||||
- Each referenced tiki must exist in `.doc/tiki/`
|
||||
- Before adding, verify the target file exists; warn if it doesn't
|
||||
- Circular dependencies: warn the user but do not block (e.g. A depends on B, B depends on A)
|
||||
|
||||
## Due Dates
|
||||
|
||||
Tikis can have a due date via the `due` frontmatter field.
|
||||
The value must be in `YYYY-MM-DD` format (date-only, no time component).
|
||||
|
||||
### Set due date
|
||||
|
||||
When asked to set a due date (e.g. "set TIKI-X7F4K2 due date to April 1st 2026"):
|
||||
1. Read `.doc/tiki/tiki-x7f4k2.md`
|
||||
2. Add or update the `due` field with value `2026-04-01`
|
||||
3. Format must be `YYYY-MM-DD` (4-digit year, 2-digit month, 2-digit day)
|
||||
4. `git add` the modified file
|
||||
|
||||
### Remove due date
|
||||
|
||||
When asked to remove or clear a due date:
|
||||
1. Read the tiki file
|
||||
2. Remove the `due` field entirely (omitempty)
|
||||
3. `git add` the modified file
|
||||
|
||||
### Query by due date
|
||||
|
||||
Users can filter tasks by due date in the TUI using filter expressions:
|
||||
- `due = '2026-04-01'` - exact match
|
||||
- `due < '2026-04-01'` - before date
|
||||
- `due < NOW` - overdue tasks
|
||||
- `due - NOW < 7day` - due within 7 days
|
||||
|
||||
### Validation
|
||||
|
||||
- Date must be in `YYYY-MM-DD` format
|
||||
- Must be a valid calendar date (no Feb 30, etc.)
|
||||
- Date-only (no time component)
|
||||
|
||||
## Recurrence
|
||||
|
||||
Tikis can have a recurrence pattern via the `recurrence` frontmatter field.
|
||||
The value must be a supported cron pattern. Displayed as English in the TUI (e.g. "Weekly on Monday").
|
||||
This is metadata-only — it does not auto-create tasks on completion.
|
||||
|
||||
### Set recurrence
|
||||
|
||||
When asked to set a recurrence (e.g. "set TIKI-X7F4K2 to recur weekly on Monday"):
|
||||
1. Read `.doc/tiki/tiki-x7f4k2.md`
|
||||
2. Add or update the `recurrence` field with value `0 0 * * MON`
|
||||
3. `git add` the modified file
|
||||
|
||||
Supported patterns:
|
||||
- `0 0 * * *` — Daily
|
||||
- `0 0 * * MON` — Weekly on Monday (through SUN for other days)
|
||||
- `0 0 1 * *` — Monthly
|
||||
|
||||
### Remove recurrence
|
||||
|
||||
When asked to remove or clear a recurrence:
|
||||
1. Read the tiki file
|
||||
2. Remove the `recurrence` field entirely (omitempty)
|
||||
3. `git add` the modified file
|
||||
|
||||
### Validation
|
||||
|
||||
- Must be one of the supported cron patterns listed above
|
||||
- Empty/omitted means no recurrence
|
||||
- `tiki exec` handles `git add` and `git rm` automatically — never do manual git staging for tikis
|
||||
- Never commit without user permission
|
||||
- Exit codes: 0 = ok, 2 = usage error, 3 = startup failure, 4 = query error
|
||||
|
|
|
|||
BIN
assets/light.png
Normal file
|
After Width: | Height: | Size: 286 KiB |
BIN
assets/markdown-viewer.gif
Normal file
|
After Width: | Height: | Size: 12 MiB |
251
cmd_workflow.go
Normal file
|
|
@ -0,0 +1,251 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"strings"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
)
|
||||
|
||||
// runWorkflow dispatches workflow subcommands. Returns an exit code.
|
||||
func runWorkflow(args []string) int {
|
||||
if len(args) == 0 {
|
||||
printWorkflowUsage()
|
||||
return exitUsage
|
||||
}
|
||||
switch args[0] {
|
||||
case "reset":
|
||||
return runWorkflowReset(args[1:])
|
||||
case "install":
|
||||
return runWorkflowInstall(args[1:])
|
||||
case "describe":
|
||||
return runWorkflowDescribe(args[1:])
|
||||
case "--help", "-h":
|
||||
printWorkflowUsage()
|
||||
return exitOK
|
||||
default:
|
||||
_, _ = fmt.Fprintf(os.Stderr, "unknown workflow command: %s\n", args[0])
|
||||
printWorkflowUsage()
|
||||
return exitUsage
|
||||
}
|
||||
}
|
||||
|
||||
// runWorkflowReset implements `tiki workflow reset [target] --scope`.
|
||||
func runWorkflowReset(args []string) int {
|
||||
positional, scope, err := parseScopeArgs(args)
|
||||
if errors.Is(err, errHelpRequested) {
|
||||
printWorkflowResetUsage()
|
||||
return exitOK
|
||||
}
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
printWorkflowResetUsage()
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
target := config.ResetTarget(positional)
|
||||
if !config.ValidResetTarget(target) {
|
||||
_, _ = fmt.Fprintf(os.Stderr, "error: unknown target: %q (use config, workflow, or new)\n", positional)
|
||||
printWorkflowResetUsage()
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
affected, err := config.ResetConfig(scope, target)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return exitInternal
|
||||
}
|
||||
|
||||
if len(affected) == 0 {
|
||||
fmt.Println("nothing to reset")
|
||||
return exitOK
|
||||
}
|
||||
for _, path := range affected {
|
||||
fmt.Println("reset", path)
|
||||
}
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// runWorkflowInstall implements `tiki workflow install <name> --scope`.
|
||||
func runWorkflowInstall(args []string) int {
|
||||
name, scope, err := parseScopeArgs(args)
|
||||
if errors.Is(err, errHelpRequested) {
|
||||
printWorkflowInstallUsage()
|
||||
return exitOK
|
||||
}
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
printWorkflowInstallUsage()
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error: workflow name required")
|
||||
printWorkflowInstallUsage()
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
results, err := config.InstallWorkflow(name, scope, config.DefaultWorkflowBaseURL)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return exitInternal
|
||||
}
|
||||
|
||||
for _, r := range results {
|
||||
if r.Changed {
|
||||
fmt.Println("installed", r.Path)
|
||||
} else {
|
||||
fmt.Println("unchanged", r.Path)
|
||||
}
|
||||
}
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// runWorkflowDescribe implements `tiki workflow describe <name>`.
|
||||
// describe is a read-only network call, so scope flags are rejected
|
||||
// to keep the CLI surface honest.
|
||||
func runWorkflowDescribe(args []string) int {
|
||||
name, err := parsePositionalOnly(args)
|
||||
if errors.Is(err, errHelpRequested) {
|
||||
printWorkflowDescribeUsage()
|
||||
return exitOK
|
||||
}
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
printWorkflowDescribeUsage()
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
if name == "" {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error: workflow name required")
|
||||
printWorkflowDescribeUsage()
|
||||
return exitUsage
|
||||
}
|
||||
|
||||
desc, err := config.DescribeWorkflow(name, config.DefaultWorkflowBaseURL)
|
||||
if err != nil {
|
||||
_, _ = fmt.Fprintln(os.Stderr, "error:", err)
|
||||
return exitInternal
|
||||
}
|
||||
if desc == "" {
|
||||
return exitOK
|
||||
}
|
||||
if strings.HasSuffix(desc, "\n") {
|
||||
fmt.Print(desc)
|
||||
} else {
|
||||
fmt.Println(desc)
|
||||
}
|
||||
return exitOK
|
||||
}
|
||||
|
||||
// parseScopeArgs extracts an optional positional argument and a required --scope flag.
|
||||
// Returns errHelpRequested for --help/-h.
|
||||
func parseScopeArgs(args []string) (string, config.Scope, error) {
|
||||
var positional string
|
||||
var scopeStr string
|
||||
|
||||
for _, arg := range args {
|
||||
switch arg {
|
||||
case "--help", "-h":
|
||||
return "", "", errHelpRequested
|
||||
case "--global", "--local", "--current":
|
||||
if scopeStr != "" {
|
||||
return "", "", fmt.Errorf("only one scope allowed: already have --%s", scopeStr)
|
||||
}
|
||||
scopeStr = strings.TrimPrefix(arg, "--")
|
||||
default:
|
||||
if strings.HasPrefix(arg, "--") {
|
||||
return "", "", fmt.Errorf("unknown flag: %s", arg)
|
||||
}
|
||||
if positional != "" {
|
||||
return "", "", fmt.Errorf("multiple positional arguments: %q and %q", positional, arg)
|
||||
}
|
||||
positional = arg
|
||||
}
|
||||
}
|
||||
|
||||
if scopeStr == "" {
|
||||
scopeStr = "local"
|
||||
}
|
||||
|
||||
return positional, config.Scope(scopeStr), nil
|
||||
}
|
||||
|
||||
// parsePositionalOnly extracts a single positional argument and rejects any
|
||||
// flag other than --help/-h. Used by subcommands that don't take a scope.
|
||||
func parsePositionalOnly(args []string) (string, error) {
|
||||
var positional string
|
||||
for _, arg := range args {
|
||||
switch arg {
|
||||
case "--help", "-h":
|
||||
return "", errHelpRequested
|
||||
}
|
||||
if strings.HasPrefix(arg, "--") {
|
||||
return "", fmt.Errorf("unknown flag: %s", arg)
|
||||
}
|
||||
if positional != "" {
|
||||
return "", fmt.Errorf("multiple positional arguments: %q and %q", positional, arg)
|
||||
}
|
||||
positional = arg
|
||||
}
|
||||
return positional, nil
|
||||
}
|
||||
|
||||
func printWorkflowUsage() {
|
||||
fmt.Print(`Usage: tiki workflow <command>
|
||||
|
||||
Commands:
|
||||
reset [target] [--scope] Reset config files to defaults
|
||||
install <name> [--scope] Install a workflow from the tiki repository
|
||||
describe <name> Fetch and print a workflow's description
|
||||
|
||||
Run 'tiki workflow <command> --help' for details.
|
||||
`)
|
||||
}
|
||||
|
||||
func printWorkflowResetUsage() {
|
||||
fmt.Print(`Usage: tiki workflow reset [target] [--scope]
|
||||
|
||||
Reset configuration files to their defaults.
|
||||
|
||||
Targets (omit to reset all):
|
||||
config Reset config.yaml
|
||||
workflow Reset workflow.yaml
|
||||
new Reset new.md (task template)
|
||||
|
||||
Scopes (default: --local):
|
||||
--global User config directory
|
||||
--local Project config directory (.doc/)
|
||||
--current Current working directory
|
||||
`)
|
||||
}
|
||||
|
||||
func printWorkflowInstallUsage() {
|
||||
fmt.Print(`Usage: tiki workflow install <name> [--scope]
|
||||
|
||||
Install a named workflow from the tiki repository.
|
||||
Downloads workflow.yaml and new.md into the scope directory,
|
||||
overwriting any existing files.
|
||||
|
||||
Scopes (default: --local):
|
||||
--global User config directory
|
||||
--local Project config directory (.doc/)
|
||||
--current Current working directory
|
||||
|
||||
Example:
|
||||
tiki workflow install sprint --global
|
||||
`)
|
||||
}
|
||||
|
||||
func printWorkflowDescribeUsage() {
|
||||
fmt.Print(`Usage: tiki workflow describe <name>
|
||||
|
||||
Fetch a workflow's description from the tiki repository and print it.
|
||||
Reads the top-level 'description' field of the named workflow.yaml.
|
||||
|
||||
Example:
|
||||
tiki workflow describe todo
|
||||
`)
|
||||
}
|
||||
414
cmd_workflow_test.go
Normal file
|
|
@ -0,0 +1,414 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/boolean-maybe/tiki/config"
|
||||
)
|
||||
|
||||
// setupWorkflowTest creates a temp config dir for workflow commands.
|
||||
func setupWorkflowTest(t *testing.T) string {
|
||||
t.Helper()
|
||||
xdgDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", xdgDir)
|
||||
config.ResetPathManager()
|
||||
t.Cleanup(config.ResetPathManager)
|
||||
|
||||
tikiDir := filepath.Join(xdgDir, "tiki")
|
||||
if err := os.MkdirAll(tikiDir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tikiDir
|
||||
}
|
||||
|
||||
func overrideBaseURL(t *testing.T, url string) {
|
||||
t.Helper()
|
||||
orig := config.DefaultWorkflowBaseURL
|
||||
config.DefaultWorkflowBaseURL = url
|
||||
t.Cleanup(func() { config.DefaultWorkflowBaseURL = orig })
|
||||
}
|
||||
|
||||
func TestParseScopeArgs(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
positional string
|
||||
scope config.Scope
|
||||
wantErr error
|
||||
errSubstr string
|
||||
}{
|
||||
{
|
||||
name: "global no positional",
|
||||
args: []string{"--global"},
|
||||
positional: "",
|
||||
scope: config.ScopeGlobal,
|
||||
},
|
||||
{
|
||||
name: "local with positional",
|
||||
args: []string{"workflow", "--local"},
|
||||
positional: "workflow",
|
||||
scope: config.ScopeLocal,
|
||||
},
|
||||
{
|
||||
name: "scope before positional",
|
||||
args: []string{"--current", "config"},
|
||||
positional: "config",
|
||||
scope: config.ScopeCurrent,
|
||||
},
|
||||
{
|
||||
name: "help flag",
|
||||
args: []string{"--help"},
|
||||
wantErr: errHelpRequested,
|
||||
},
|
||||
{
|
||||
name: "short help flag",
|
||||
args: []string{"-h"},
|
||||
wantErr: errHelpRequested,
|
||||
},
|
||||
{
|
||||
name: "missing scope defaults to local",
|
||||
args: []string{"config"},
|
||||
positional: "config",
|
||||
scope: config.ScopeLocal,
|
||||
},
|
||||
{
|
||||
name: "unknown flag",
|
||||
args: []string{"--verbose"},
|
||||
errSubstr: "unknown flag",
|
||||
},
|
||||
{
|
||||
name: "multiple positional",
|
||||
args: []string{"config", "workflow", "--global"},
|
||||
errSubstr: "multiple positional arguments",
|
||||
},
|
||||
{
|
||||
name: "duplicate scopes",
|
||||
args: []string{"--global", "--local"},
|
||||
errSubstr: "only one scope allowed",
|
||||
},
|
||||
{
|
||||
name: "no args defaults to local",
|
||||
args: nil,
|
||||
scope: config.ScopeLocal,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
positional, scope, err := parseScopeArgs(tt.args)
|
||||
|
||||
if tt.wantErr != nil {
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("expected error %v, got %v", tt.wantErr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if tt.errSubstr != "" {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if msg := err.Error(); !strings.Contains(msg, tt.errSubstr) {
|
||||
t.Fatalf("expected error containing %q, got %q", tt.errSubstr, msg)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if positional != tt.positional {
|
||||
t.Errorf("positional = %q, want %q", positional, tt.positional)
|
||||
}
|
||||
if scope != tt.scope {
|
||||
t.Errorf("scope = %q, want %q", scope, tt.scope)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestParsePositionalOnly(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
args []string
|
||||
positional string
|
||||
wantErr error
|
||||
errSubstr string
|
||||
}{
|
||||
{name: "no args", args: nil},
|
||||
{name: "single positional", args: []string{"sprint"}, positional: "sprint"},
|
||||
{name: "help flag", args: []string{"--help"}, wantErr: errHelpRequested},
|
||||
{name: "short help flag", args: []string{"-h"}, wantErr: errHelpRequested},
|
||||
{name: "rejects scope", args: []string{"sprint", "--global"}, errSubstr: "unknown flag"},
|
||||
{name: "rejects unknown flag", args: []string{"sprint", "--verbose"}, errSubstr: "unknown flag"},
|
||||
{name: "multiple positional", args: []string{"a", "b"}, errSubstr: "multiple positional arguments"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
positional, err := parsePositionalOnly(tt.args)
|
||||
|
||||
if tt.wantErr != nil {
|
||||
if !errors.Is(err, tt.wantErr) {
|
||||
t.Fatalf("expected error %v, got %v", tt.wantErr, err)
|
||||
}
|
||||
return
|
||||
}
|
||||
if tt.errSubstr != "" {
|
||||
if err == nil {
|
||||
t.Fatal("expected error, got nil")
|
||||
}
|
||||
if msg := err.Error(); !strings.Contains(msg, tt.errSubstr) {
|
||||
t.Fatalf("expected error containing %q, got %q", tt.errSubstr, msg)
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if positional != tt.positional {
|
||||
t.Errorf("positional = %q, want %q", positional, tt.positional)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// --- runWorkflow dispatch tests ---
|
||||
|
||||
func TestRunWorkflow_NoArgs(t *testing.T) {
|
||||
if code := runWorkflow(nil); code != exitUsage {
|
||||
t.Errorf("exit code = %d, want %d", code, exitUsage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflow_UnknownSubcommand(t *testing.T) {
|
||||
if code := runWorkflow([]string{"bogus"}); code != exitUsage {
|
||||
t.Errorf("exit code = %d, want %d", code, exitUsage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflow_Help(t *testing.T) {
|
||||
if code := runWorkflow([]string{"--help"}); code != exitOK {
|
||||
t.Errorf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
}
|
||||
|
||||
// --- runWorkflowReset integration tests ---
|
||||
|
||||
func TestRunWorkflowReset_GlobalAll(t *testing.T) {
|
||||
tikiDir := setupWorkflowTest(t)
|
||||
|
||||
if err := os.WriteFile(filepath.Join(tikiDir, "workflow.yaml"), []byte("custom"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if code := runWorkflowReset([]string{"--global"}); code != exitOK {
|
||||
t.Errorf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml"))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(got) == "custom" {
|
||||
t.Error("workflow.yaml was not reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowReset_NothingToReset(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
if code := runWorkflowReset([]string{"config", "--global"}); code != exitOK {
|
||||
t.Errorf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowReset_InvalidTarget(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
if code := runWorkflowReset([]string{"themes", "--global"}); code != exitUsage {
|
||||
t.Errorf("exit code = %d, want %d", code, exitUsage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowReset_DefaultsToLocal(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
// without an initialized project, --local scope fails with exitInternal (not exitUsage)
|
||||
if code := runWorkflowReset([]string{"config"}); code == exitUsage {
|
||||
t.Error("missing scope should not produce usage error — it should default to --local")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowReset_Help(t *testing.T) {
|
||||
if code := runWorkflowReset([]string{"--help"}); code != exitOK {
|
||||
t.Errorf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
}
|
||||
|
||||
// --- runWorkflowInstall integration tests ---
|
||||
|
||||
func TestRunWorkflowInstall_Success(t *testing.T) {
|
||||
tikiDir := setupWorkflowTest(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/workflows/sprint/workflow.yaml":
|
||||
_, _ = w.Write([]byte("sprint workflow"))
|
||||
case "/workflows/sprint/new.md":
|
||||
_, _ = w.Write([]byte("sprint template"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
overrideBaseURL(t, server.URL)
|
||||
|
||||
if code := runWorkflowInstall([]string{"sprint", "--global"}); code != exitOK {
|
||||
t.Fatalf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml"))
|
||||
if string(got) != "sprint workflow" {
|
||||
t.Errorf("workflow.yaml = %q, want %q", got, "sprint workflow")
|
||||
}
|
||||
got, _ = os.ReadFile(filepath.Join(tikiDir, "new.md"))
|
||||
if string(got) != "sprint template" {
|
||||
t.Errorf("new.md = %q, want %q", got, "sprint template")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowInstall_MissingName(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
if code := runWorkflowInstall([]string{"--global"}); code != exitUsage {
|
||||
t.Errorf("exit code = %d, want %d", code, exitUsage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowInstall_InvalidName(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
if code := runWorkflowInstall([]string{"../../etc", "--global"}); code != exitInternal {
|
||||
t.Errorf("exit code = %d, want %d", code, exitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowInstall_NotFound(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
overrideBaseURL(t, server.URL)
|
||||
|
||||
if code := runWorkflowInstall([]string{"nonexistent", "--global"}); code != exitInternal {
|
||||
t.Errorf("exit code = %d, want %d", code, exitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowInstall_DefaultsToLocal(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
// without an initialized project, --local scope fails with exitInternal (not exitUsage)
|
||||
if code := runWorkflowInstall([]string{"sprint"}); code == exitUsage {
|
||||
t.Error("missing scope should not produce usage error — it should default to --local")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowInstall_Help(t *testing.T) {
|
||||
if code := runWorkflowInstall([]string{"--help"}); code != exitOK {
|
||||
t.Errorf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
}
|
||||
|
||||
// --- runWorkflowDescribe integration tests ---
|
||||
|
||||
func TestRunWorkflowDescribe_Success(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/workflows/sprint/workflow.yaml" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("description: |\n sprint desc\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
overrideBaseURL(t, server.URL)
|
||||
|
||||
if code := runWorkflowDescribe([]string{"sprint"}); code != exitOK {
|
||||
t.Errorf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowDescribe_MissingName(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
if code := runWorkflowDescribe(nil); code != exitUsage {
|
||||
t.Errorf("exit code = %d, want %d", code, exitUsage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowDescribe_InvalidName(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
if code := runWorkflowDescribe([]string{"../../etc"}); code != exitInternal {
|
||||
t.Errorf("exit code = %d, want %d", code, exitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowDescribe_NotFound(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
overrideBaseURL(t, server.URL)
|
||||
|
||||
if code := runWorkflowDescribe([]string{"nonexistent"}); code != exitInternal {
|
||||
t.Errorf("exit code = %d, want %d", code, exitInternal)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowDescribe_UnknownFlag(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
if code := runWorkflowDescribe([]string{"sprint", "--verbose"}); code != exitUsage {
|
||||
t.Errorf("exit code = %d, want %d", code, exitUsage)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowDescribe_RejectsScopeFlags(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
for _, flag := range []string{"--global", "--local", "--current"} {
|
||||
if code := runWorkflowDescribe([]string{"sprint", flag}); code != exitUsage {
|
||||
t.Errorf("%s: exit code = %d, want %d", flag, code, exitUsage)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflowDescribe_Help(t *testing.T) {
|
||||
if code := runWorkflowDescribe([]string{"--help"}); code != exitOK {
|
||||
t.Errorf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunWorkflow_DescribeDispatch(t *testing.T) {
|
||||
_ = setupWorkflowTest(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("description: hi\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
overrideBaseURL(t, server.URL)
|
||||
|
||||
if code := runWorkflow([]string{"describe", "sprint"}); code != exitOK {
|
||||
t.Errorf("exit code = %d, want %d", code, exitOK)
|
||||
}
|
||||
}
|
||||
|
|
@ -64,11 +64,11 @@ type BarChart struct {
|
|||
func DefaultTheme() Theme {
|
||||
colors := config.GetColors()
|
||||
return Theme{
|
||||
AxisColor: colors.BurndownChartAxisColor,
|
||||
LabelColor: colors.BurndownChartLabelColor,
|
||||
ValueColor: colors.BurndownChartValueColor,
|
||||
BarColor: colors.BurndownChartBarColor,
|
||||
BackgroundColor: config.GetContentBackgroundColor(),
|
||||
AxisColor: colors.BurndownChartAxisColor.TCell(),
|
||||
LabelColor: colors.BurndownChartLabelColor.TCell(),
|
||||
ValueColor: colors.BurndownChartValueColor.TCell(),
|
||||
BarColor: colors.BurndownChartBarColor.TCell(),
|
||||
BackgroundColor: config.GetColors().ContentBackgroundColor.TCell(),
|
||||
BarGradientFrom: colors.BurndownChartGradientFrom.Start,
|
||||
BarGradientTo: colors.BurndownChartGradientTo.Start,
|
||||
DotChar: '⣿', // braille full cell for dense dot matrix
|
||||
|
|
|
|||
|
|
@ -44,7 +44,7 @@ func barFillColor(bar Bar, row, total int, theme Theme) tcell.Color {
|
|||
|
||||
// Use adaptive gradient: solid color when gradients disabled
|
||||
if !config.UseGradients {
|
||||
return config.FallbackBurndownColor
|
||||
return config.GetColors().FallbackBurndownColor.TCell()
|
||||
}
|
||||
|
||||
t := float64(row) / float64(total-1)
|
||||
|
|
|
|||
|
|
@ -25,14 +25,13 @@ func NewCompletionPrompt(words []string) *CompletionPrompt {
|
|||
inputField := tview.NewInputField()
|
||||
|
||||
// Configure the input field
|
||||
inputField.SetFieldBackgroundColor(config.GetContentBackgroundColor())
|
||||
inputField.SetFieldTextColor(config.GetContentTextColor())
|
||||
|
||||
colors := config.GetColors()
|
||||
inputField.SetFieldBackgroundColor(colors.ContentBackgroundColor.TCell())
|
||||
inputField.SetFieldTextColor(colors.ContentTextColor.TCell())
|
||||
cp := &CompletionPrompt{
|
||||
InputField: inputField,
|
||||
words: words,
|
||||
hintColor: colors.CompletionHintColor,
|
||||
hintColor: colors.CompletionHintColor.TCell(),
|
||||
}
|
||||
|
||||
return cp
|
||||
|
|
|
|||
|
|
@ -27,8 +27,9 @@ type DateEdit struct {
|
|||
// NewDateEdit creates a new date input field.
|
||||
func NewDateEdit() *DateEdit {
|
||||
inputField := tview.NewInputField()
|
||||
inputField.SetFieldBackgroundColor(config.GetContentBackgroundColor())
|
||||
inputField.SetFieldTextColor(config.GetContentTextColor())
|
||||
colors := config.GetColors()
|
||||
inputField.SetFieldBackgroundColor(colors.ContentBackgroundColor.TCell())
|
||||
inputField.SetFieldTextColor(colors.ContentTextColor.TCell())
|
||||
|
||||
de := &DateEdit{
|
||||
InputField: inputField,
|
||||
|
|
|
|||
|
|
@ -29,8 +29,9 @@ func NewEditSelectList(values []string, allowTyping bool) *EditSelectList {
|
|||
inputField := tview.NewInputField()
|
||||
|
||||
// Configure the input field
|
||||
inputField.SetFieldBackgroundColor(config.GetContentBackgroundColor())
|
||||
inputField.SetFieldTextColor(config.GetContentTextColor())
|
||||
colors := config.GetColors()
|
||||
inputField.SetFieldBackgroundColor(colors.ContentBackgroundColor.TCell())
|
||||
inputField.SetFieldTextColor(colors.ContentTextColor.TCell())
|
||||
|
||||
esl := &EditSelectList{
|
||||
InputField: inputField,
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import (
|
|||
)
|
||||
|
||||
func TestEditSelectList_ArrowNavigation(t *testing.T) {
|
||||
values := []string{"ready", "in_progress", "review", "done"}
|
||||
values := []string{"ready", "inProgress", "review", "done"}
|
||||
esl := NewEditSelectList(values, true)
|
||||
|
||||
tests := []struct {
|
||||
|
|
@ -39,7 +39,7 @@ func TestEditSelectList_ArrowNavigation(t *testing.T) {
|
|||
name: "down from first goes to second",
|
||||
initialIndex: 0,
|
||||
key: tcell.KeyDown,
|
||||
expectedText: "in_progress",
|
||||
expectedText: "inProgress",
|
||||
expectedIndex: 1,
|
||||
description: "Down arrow should move to next value",
|
||||
},
|
||||
|
|
@ -92,12 +92,12 @@ func TestEditSelectList_ArrowNavigation(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestEditSelectList_FreeFormTyping(t *testing.T) {
|
||||
values := []string{"ready", "in_progress", "review", "done"}
|
||||
values := []string{"ready", "inProgress", "review", "done"}
|
||||
esl := NewEditSelectList(values, true)
|
||||
|
||||
// Start at a specific index
|
||||
esl.currentIndex = 1
|
||||
esl.InputField.SetText(values[1]) // "in_progress"
|
||||
esl.InputField.SetText(values[1]) // "inProgress"
|
||||
|
||||
// Simulate typing (any character key resets index to -1)
|
||||
event := tcell.NewEventKey(tcell.KeyRune, 'x', tcell.ModNone)
|
||||
|
|
@ -110,7 +110,7 @@ func TestEditSelectList_FreeFormTyping(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestEditSelectList_SubmitHandler(t *testing.T) {
|
||||
values := []string{"ready", "in_progress", "review", "done"}
|
||||
values := []string{"ready", "inProgress", "review", "done"}
|
||||
esl := NewEditSelectList(values, true)
|
||||
|
||||
var submittedText string
|
||||
|
|
@ -132,7 +132,7 @@ func TestEditSelectList_SubmitHandler(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestEditSelectList_SubmitFromList(t *testing.T) {
|
||||
values := []string{"ready", "in_progress", "review", "done"}
|
||||
values := []string{"ready", "inProgress", "review", "done"}
|
||||
esl := NewEditSelectList(values, true)
|
||||
|
||||
var submittedText string
|
||||
|
|
@ -155,7 +155,7 @@ func TestEditSelectList_SubmitFromList(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestEditSelectList_SetInitialValue(t *testing.T) {
|
||||
values := []string{"ready", "in_progress", "review", "done"}
|
||||
values := []string{"ready", "inProgress", "review", "done"}
|
||||
esl := NewEditSelectList(values, true)
|
||||
|
||||
// Set to a value that exists in the list
|
||||
|
|
@ -182,7 +182,7 @@ func TestEditSelectList_SetInitialValue(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestEditSelectList_Clear(t *testing.T) {
|
||||
values := []string{"ready", "in_progress", "review", "done"}
|
||||
values := []string{"ready", "inProgress", "review", "done"}
|
||||
esl := NewEditSelectList(values, true)
|
||||
|
||||
esl.SetInitialValue("review")
|
||||
|
|
@ -198,7 +198,7 @@ func TestEditSelectList_Clear(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestEditSelectList_SetText(t *testing.T) {
|
||||
values := []string{"ready", "in_progress", "review", "done"}
|
||||
values := []string{"ready", "inProgress", "review", "done"}
|
||||
esl := NewEditSelectList(values, true)
|
||||
|
||||
// Start with a list selection
|
||||
|
|
@ -235,7 +235,7 @@ func TestEditSelectList_EmptyValues(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestEditSelectList_NavigationAfterTyping(t *testing.T) {
|
||||
values := []string{"ready", "in_progress", "review", "done"}
|
||||
values := []string{"ready", "inProgress", "review", "done"}
|
||||
esl := NewEditSelectList(values, true)
|
||||
|
||||
// Type some text (simulated by SetText which sets index to -1)
|
||||
|
|
@ -267,7 +267,7 @@ func TestEditSelectList_SetLabel(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestEditSelectList_TypingDisabled_IgnoresNonArrowKeys(t *testing.T) {
|
||||
values := []string{"ready", "in_progress", "done"}
|
||||
values := []string{"ready", "inProgress", "done"}
|
||||
esl := NewEditSelectList(values, false) // typing disabled
|
||||
|
||||
esl.SetInitialValue("ready")
|
||||
|
|
@ -288,7 +288,7 @@ func TestEditSelectList_TypingDisabled_IgnoresNonArrowKeys(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestEditSelectList_TypingDisabled_ArrowKeysStillWork(t *testing.T) {
|
||||
values := []string{"ready", "in_progress", "done"}
|
||||
values := []string{"ready", "inProgress", "done"}
|
||||
esl := NewEditSelectList(values, false)
|
||||
|
||||
handler := esl.InputHandler()
|
||||
|
|
@ -307,7 +307,7 @@ func TestEditSelectList_TypingDisabled_ArrowKeysStillWork(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestEditSelectList_TypingEnabled_AllowsFreeForm(t *testing.T) {
|
||||
values := []string{"ready", "in_progress", "done"}
|
||||
values := []string{"ready", "inProgress", "done"}
|
||||
esl := NewEditSelectList(values, true) // typing enabled
|
||||
|
||||
esl.SetInitialValue("ready")
|
||||
|
|
@ -324,7 +324,7 @@ func TestEditSelectList_TypingEnabled_AllowsFreeForm(t *testing.T) {
|
|||
}
|
||||
|
||||
func TestEditSelectList_SubmitCallbackNotFiredWhenTypingDisabled(t *testing.T) {
|
||||
values := []string{"ready", "in_progress", "done"}
|
||||
values := []string{"ready", "inProgress", "done"}
|
||||
esl := NewEditSelectList(values, false)
|
||||
|
||||
callCount := 0
|
||||
|
|
|
|||
|
|
@ -37,8 +37,9 @@ func NewIntEditSelect(min, max int, allowTyping bool) *IntEditSelect {
|
|||
}
|
||||
|
||||
inputField := tview.NewInputField()
|
||||
inputField.SetFieldBackgroundColor(config.GetContentBackgroundColor())
|
||||
inputField.SetFieldTextColor(config.GetContentTextColor())
|
||||
colors := config.GetColors()
|
||||
inputField.SetFieldBackgroundColor(colors.ContentBackgroundColor.TCell())
|
||||
inputField.SetFieldTextColor(colors.ContentTextColor.TCell())
|
||||
|
||||
ies := &IntEditSelect{
|
||||
InputField: inputField,
|
||||
|
|
@ -178,17 +179,17 @@ func (ies *IntEditSelect) InputHandler() func(event *tcell.EventKey, setFocus fu
|
|||
|
||||
switch key {
|
||||
case tcell.KeyUp:
|
||||
// Decrement value (wraps at min to max)
|
||||
ies.clearOnType = false // user is navigating, not typing fresh
|
||||
ies.decrement()
|
||||
return
|
||||
|
||||
case tcell.KeyDown:
|
||||
// Increment value (wraps at max to min)
|
||||
ies.clearOnType = false // user is navigating, not typing fresh
|
||||
ies.increment()
|
||||
return
|
||||
|
||||
case tcell.KeyDown:
|
||||
// Decrement value (wraps at min to max)
|
||||
ies.clearOnType = false // user is navigating, not typing fresh
|
||||
ies.decrement()
|
||||
return
|
||||
|
||||
case tcell.KeyRune:
|
||||
// If typing is disabled, silently ignore
|
||||
if !ies.allowTyping {
|
||||
|
|
|
|||
|
|
@ -156,21 +156,21 @@ func TestArrowKeyNavigation(t *testing.T) {
|
|||
|
||||
handler := ies.InputHandler()
|
||||
|
||||
// Test down arrow (increment)
|
||||
downEvent := tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone)
|
||||
handler(downEvent, nil)
|
||||
|
||||
if ies.GetValue() != 6 {
|
||||
t.Errorf("After KeyDown, expected value=6, got %d", ies.GetValue())
|
||||
}
|
||||
|
||||
// Test up arrow (decrement)
|
||||
// Test up arrow (increment)
|
||||
upEvent := tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone)
|
||||
handler(upEvent, nil)
|
||||
handler(upEvent, nil)
|
||||
|
||||
if ies.GetValue() != 6 {
|
||||
t.Errorf("After KeyUp, expected value=6, got %d", ies.GetValue())
|
||||
}
|
||||
|
||||
// Test down arrow (decrement)
|
||||
downEvent := tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone)
|
||||
handler(downEvent, nil)
|
||||
handler(downEvent, nil)
|
||||
|
||||
if ies.GetValue() != 4 {
|
||||
t.Errorf("After 2x KeyUp, expected value=4, got %d", ies.GetValue())
|
||||
t.Errorf("After 2x KeyDown, expected value=4, got %d", ies.GetValue())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -180,20 +180,20 @@ func TestArrowKeyWrapAround(t *testing.T) {
|
|||
|
||||
handler := ies.InputHandler()
|
||||
|
||||
// Down at max wraps to min
|
||||
downEvent := tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone)
|
||||
handler(downEvent, nil)
|
||||
|
||||
if ies.GetValue() != 0 {
|
||||
t.Errorf("After KeyDown at max, expected value=0, got %d", ies.GetValue())
|
||||
}
|
||||
|
||||
// Up at min wraps to max
|
||||
// Up at max wraps to min
|
||||
upEvent := tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone)
|
||||
handler(upEvent, nil)
|
||||
|
||||
if ies.GetValue() != 0 {
|
||||
t.Errorf("After KeyUp at max, expected value=0, got %d", ies.GetValue())
|
||||
}
|
||||
|
||||
// Down at min wraps to max
|
||||
downEvent := tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone)
|
||||
handler(downEvent, nil)
|
||||
|
||||
if ies.GetValue() != 9 {
|
||||
t.Errorf("After KeyUp at min, expected value=9, got %d", ies.GetValue())
|
||||
t.Errorf("After KeyDown at min, expected value=9, got %d", ies.GetValue())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -449,17 +449,17 @@ func TestIntEditSelect_TypingDisabled_ArrowKeysWork(t *testing.T) {
|
|||
|
||||
handler := ies.InputHandler()
|
||||
|
||||
// Up arrow (decrement)
|
||||
// Up arrow (increment)
|
||||
handler(tcell.NewEventKey(tcell.KeyUp, 0, tcell.ModNone), nil)
|
||||
if ies.GetValue() != 4 {
|
||||
t.Errorf("Expected value 4 after up arrow, got %d", ies.GetValue())
|
||||
if ies.GetValue() != 6 {
|
||||
t.Errorf("Expected value 6 after up arrow, got %d", ies.GetValue())
|
||||
}
|
||||
|
||||
// Down arrow (increment)
|
||||
// Down arrow (decrement)
|
||||
handler(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone), nil)
|
||||
handler(tcell.NewEventKey(tcell.KeyDown, 0, tcell.ModNone), nil)
|
||||
if ies.GetValue() != 6 {
|
||||
t.Errorf("Expected value 6 after down arrows, got %d", ies.GetValue())
|
||||
if ies.GetValue() != 4 {
|
||||
t.Errorf("Expected value 4 after down arrows, got %d", ies.GetValue())
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -31,8 +31,9 @@ type RecurrenceEdit struct {
|
|||
// NewRecurrenceEdit creates a new recurrence editor.
|
||||
func NewRecurrenceEdit() *RecurrenceEdit {
|
||||
inputField := tview.NewInputField()
|
||||
inputField.SetFieldBackgroundColor(config.GetContentBackgroundColor())
|
||||
inputField.SetFieldTextColor(config.GetContentTextColor())
|
||||
colors := config.GetColors()
|
||||
inputField.SetFieldBackgroundColor(colors.ContentBackgroundColor.TCell())
|
||||
inputField.SetFieldTextColor(colors.ContentTextColor.TCell())
|
||||
|
||||
re := &RecurrenceEdit{
|
||||
InputField: inputField,
|
||||
|
|
|
|||
|
|
@ -23,11 +23,12 @@ type TaskList struct {
|
|||
selectionIndex int
|
||||
idColumnWidth int // computed from widest ID
|
||||
idGradient config.Gradient // gradient for ID text
|
||||
idFallback tcell.Color // fallback solid color for ID
|
||||
titleColor string // tview color tag for title, e.g. "[#b8b8b8]"
|
||||
selectionColor string // tview color tag for selected row highlight
|
||||
statusDoneColor string // tview color tag for done status indicator
|
||||
statusPendingColor string // tview color tag for pending status indicator
|
||||
idFallback config.Color // fallback solid color for ID
|
||||
titleColor config.Color // color for title text
|
||||
selectionColor config.Color // foreground color for selected row highlight
|
||||
selectionBgColor config.Color // background color for selected row highlight
|
||||
statusDoneColor config.Color // color for done status indicator
|
||||
statusPendingColor config.Color // color for pending status indicator
|
||||
}
|
||||
|
||||
// NewTaskList creates a new TaskList with the given maximum visible row count.
|
||||
|
|
@ -37,9 +38,10 @@ func NewTaskList(maxVisibleRows int) *TaskList {
|
|||
Box: tview.NewBox(),
|
||||
maxVisibleRows: maxVisibleRows,
|
||||
idGradient: colors.TaskBoxIDColor,
|
||||
idFallback: config.FallbackTaskIDColor,
|
||||
idFallback: colors.FallbackTaskIDColor,
|
||||
titleColor: colors.TaskBoxTitleColor,
|
||||
selectionColor: colors.TaskListSelectionColor,
|
||||
selectionColor: colors.TaskListSelectionFg,
|
||||
selectionBgColor: colors.TaskListSelectionBg,
|
||||
statusDoneColor: colors.TaskListStatusDoneColor,
|
||||
statusPendingColor: colors.TaskListStatusPendingColor,
|
||||
}
|
||||
|
|
@ -92,14 +94,14 @@ func (tl *TaskList) ScrollDown() {
|
|||
}
|
||||
|
||||
// SetIDColors overrides the gradient and fallback color for the ID column.
|
||||
func (tl *TaskList) SetIDColors(g config.Gradient, fallback tcell.Color) *TaskList {
|
||||
func (tl *TaskList) SetIDColors(g config.Gradient, fallback config.Color) *TaskList {
|
||||
tl.idGradient = g
|
||||
tl.idFallback = fallback
|
||||
return tl
|
||||
}
|
||||
|
||||
// SetTitleColor overrides the tview color tag for the title column.
|
||||
func (tl *TaskList) SetTitleColor(color string) *TaskList {
|
||||
// SetTitleColor overrides the color for the title column.
|
||||
func (tl *TaskList) SetTitleColor(color config.Color) *TaskList {
|
||||
tl.titleColor = color
|
||||
return tl
|
||||
}
|
||||
|
|
@ -134,9 +136,9 @@ func (tl *TaskList) buildRow(t *task.Task, selected bool, width int) string {
|
|||
// Status indicator: done = checkmark, else circle
|
||||
var statusIndicator string
|
||||
if config.GetStatusRegistry().IsDone(string(t.Status)) {
|
||||
statusIndicator = tl.statusDoneColor + "\u2713[-]"
|
||||
statusIndicator = tl.statusDoneColor.Tag().String() + "\u2713[-]"
|
||||
} else {
|
||||
statusIndicator = tl.statusPendingColor + "\u25CB[-]"
|
||||
statusIndicator = tl.statusPendingColor.Tag().String() + "\u25CB[-]"
|
||||
}
|
||||
|
||||
// Gradient-rendered ID, padded to idColumnWidth
|
||||
|
|
@ -151,10 +153,10 @@ func (tl *TaskList) buildRow(t *task.Task, selected bool, width int) string {
|
|||
titleAvailable := max(width-1-1-tl.idColumnWidth-1, 0)
|
||||
truncatedTitle := tview.Escape(util.TruncateText(t.Title, titleAvailable))
|
||||
|
||||
row := fmt.Sprintf("%s %s %s%s[-]", statusIndicator, idText, tl.titleColor, truncatedTitle)
|
||||
row := fmt.Sprintf("%s %s %s%s[-]", statusIndicator, idText, tl.titleColor.Tag().String(), truncatedTitle)
|
||||
|
||||
if selected {
|
||||
row = tl.selectionColor + row
|
||||
row = tl.selectionColor.Tag().WithBg(tl.selectionBgColor).String() + row
|
||||
}
|
||||
|
||||
return row
|
||||
|
|
|
|||
|
|
@ -27,6 +27,7 @@ func TestNewTaskList(t *testing.T) {
|
|||
|
||||
if tl == nil {
|
||||
t.Fatal("NewTaskList returned nil")
|
||||
return
|
||||
}
|
||||
if tl.maxVisibleRows != 5 {
|
||||
t.Errorf("Expected maxVisibleRows=5, got %d", tl.maxVisibleRows)
|
||||
|
|
@ -39,7 +40,7 @@ func TestNewTaskList(t *testing.T) {
|
|||
if tl.idGradient != colors.TaskBoxIDColor {
|
||||
t.Error("Expected ID gradient from config")
|
||||
}
|
||||
if tl.idFallback != config.FallbackTaskIDColor {
|
||||
if tl.idFallback != config.GetColors().FallbackTaskIDColor {
|
||||
t.Error("Expected ID fallback from config")
|
||||
}
|
||||
if tl.titleColor != colors.TaskBoxTitleColor {
|
||||
|
|
@ -180,7 +181,7 @@ func TestFewerItemsThanViewport(t *testing.T) {
|
|||
func TestSetIDColors(t *testing.T) {
|
||||
tl := NewTaskList(10)
|
||||
g := config.Gradient{Start: [3]int{255, 0, 0}, End: [3]int{0, 255, 0}}
|
||||
fb := tcell.ColorRed
|
||||
fb := config.NewColor(tcell.ColorRed)
|
||||
|
||||
result := tl.SetIDColors(g, fb)
|
||||
if result != tl {
|
||||
|
|
@ -196,12 +197,13 @@ func TestSetIDColors(t *testing.T) {
|
|||
|
||||
func TestSetTitleColor(t *testing.T) {
|
||||
tl := NewTaskList(10)
|
||||
result := tl.SetTitleColor("[#ff0000]")
|
||||
c := config.NewColor(tcell.ColorRed)
|
||||
result := tl.SetTitleColor(c)
|
||||
if result != tl {
|
||||
t.Error("SetTitleColor should return self for chaining")
|
||||
}
|
||||
if tl.titleColor != "[#ff0000]" {
|
||||
t.Errorf("Expected [#ff0000], got %s", tl.titleColor)
|
||||
if tl.titleColor != c {
|
||||
t.Errorf("Expected color red, got %v", tl.titleColor)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -269,14 +271,16 @@ func TestBuildRow(t *testing.T) {
|
|||
|
||||
t.Run("selected row has selection color prefix", func(t *testing.T) {
|
||||
row := tl.buildRow(pendingTask, true, width)
|
||||
if !strings.HasPrefix(row, tl.selectionColor) {
|
||||
t.Errorf("selected row should start with selection color %q", tl.selectionColor)
|
||||
selTag := tl.selectionColor.Tag().WithBg(tl.selectionBgColor).String()
|
||||
if !strings.HasPrefix(row, selTag) {
|
||||
t.Errorf("selected row should start with selection color %q", selTag)
|
||||
}
|
||||
})
|
||||
|
||||
t.Run("unselected row has no selection prefix", func(t *testing.T) {
|
||||
row := tl.buildRow(pendingTask, false, width)
|
||||
if strings.HasPrefix(row, tl.selectionColor) {
|
||||
selTag := tl.selectionColor.Tag().WithBg(tl.selectionBgColor).String()
|
||||
if strings.HasPrefix(row, selTag) {
|
||||
t.Error("unselected row should not start with selection color")
|
||||
}
|
||||
})
|
||||
|
|
|
|||
|
|
@ -14,8 +14,8 @@ import (
|
|||
type WordList struct {
|
||||
*tview.Box
|
||||
words []string
|
||||
fgColor tcell.Color
|
||||
bgColor tcell.Color
|
||||
fgColor config.Color
|
||||
bgColor config.Color
|
||||
}
|
||||
|
||||
// NewWordList creates a new WordList component.
|
||||
|
|
@ -43,7 +43,7 @@ func (w *WordList) GetWords() []string {
|
|||
}
|
||||
|
||||
// SetColors sets the foreground and background colors.
|
||||
func (w *WordList) SetColors(fg, bg tcell.Color) *WordList {
|
||||
func (w *WordList) SetColors(fg, bg config.Color) *WordList {
|
||||
w.fgColor = fg
|
||||
w.bgColor = bg
|
||||
return w
|
||||
|
|
@ -58,8 +58,8 @@ func (w *WordList) Draw(screen tcell.Screen) {
|
|||
return
|
||||
}
|
||||
|
||||
wordStyle := tcell.StyleDefault.Foreground(w.fgColor).Background(w.bgColor)
|
||||
spaceStyle := tcell.StyleDefault.Background(config.GetContentBackgroundColor())
|
||||
wordStyle := tcell.StyleDefault.Foreground(w.fgColor.TCell()).Background(w.bgColor.TCell())
|
||||
spaceStyle := tcell.StyleDefault.Background(config.GetColors().ContentBackgroundColor.TCell())
|
||||
|
||||
currentX := x
|
||||
currentY := y
|
||||
|
|
|
|||
|
|
@ -14,6 +14,7 @@ func TestNewWordList(t *testing.T) {
|
|||
|
||||
if wl == nil {
|
||||
t.Fatal("NewWordList returned nil")
|
||||
return
|
||||
}
|
||||
|
||||
if !reflect.DeepEqual(wl.words, words) {
|
||||
|
|
@ -59,8 +60,8 @@ func TestGetWords(t *testing.T) {
|
|||
|
||||
func TestSetColors(t *testing.T) {
|
||||
wl := NewWordList([]string{"test"})
|
||||
fg := tcell.ColorRed
|
||||
bg := tcell.ColorGreen
|
||||
fg := config.NewColor(tcell.ColorRed)
|
||||
bg := config.NewColor(tcell.ColorGreen)
|
||||
|
||||
result := wl.SetColors(fg, bg)
|
||||
|
||||
|
|
|
|||
77
config/aitools.go
Normal file
|
|
@ -0,0 +1,77 @@
|
|||
package config
|
||||
|
||||
import "path"
|
||||
|
||||
// AITool defines a supported AI coding assistant.
|
||||
// To add a new tool, add an entry to the aiTools slice below.
|
||||
// NOTE: the action palette (press Ctrl+A) surfaces available actions; update docs if tool names change.
|
||||
type AITool struct {
|
||||
Key string // config identifier: "claude", "gemini", "codex", "opencode"
|
||||
DisplayName string // human-readable label for UI: "Claude Code"
|
||||
Command string // CLI binary name
|
||||
PromptFlag string // flag preceding the prompt arg, or "" for positional
|
||||
SkillDir string // relative base dir for skills: ".claude/skills"
|
||||
SettingsFile string // relative path to local settings file, or "" if none
|
||||
}
|
||||
|
||||
// aiTools is the single source of truth for all supported AI tools.
|
||||
var aiTools = []AITool{
|
||||
{
|
||||
Key: "claude",
|
||||
DisplayName: "Claude Code",
|
||||
Command: "claude",
|
||||
PromptFlag: "--append-system-prompt",
|
||||
SkillDir: ".claude/skills",
|
||||
SettingsFile: ".claude/settings.local.json",
|
||||
},
|
||||
{
|
||||
Key: "gemini",
|
||||
DisplayName: "Gemini CLI",
|
||||
Command: "gemini",
|
||||
PromptFlag: "-i",
|
||||
SkillDir: ".gemini/skills",
|
||||
},
|
||||
{
|
||||
Key: "codex",
|
||||
DisplayName: "OpenAI Codex",
|
||||
Command: "codex",
|
||||
PromptFlag: "",
|
||||
SkillDir: ".codex/skills",
|
||||
},
|
||||
{
|
||||
Key: "opencode",
|
||||
DisplayName: "OpenCode",
|
||||
Command: "opencode",
|
||||
PromptFlag: "--prompt",
|
||||
SkillDir: ".opencode/skill",
|
||||
},
|
||||
}
|
||||
|
||||
// PromptArgs returns CLI arguments to pass a prompt string to this tool.
|
||||
// If PromptFlag is set, returns [flag, prompt]; otherwise returns [prompt].
|
||||
func (t AITool) PromptArgs(prompt string) []string {
|
||||
if t.PromptFlag != "" {
|
||||
return []string{t.PromptFlag, prompt}
|
||||
}
|
||||
return []string{prompt}
|
||||
}
|
||||
|
||||
// SkillPath returns the relative file path for a skill (e.g. "tiki" → ".claude/skills/tiki/SKILL.md").
|
||||
func (t AITool) SkillPath(skill string) string {
|
||||
return path.Join(t.SkillDir, skill, "SKILL.md")
|
||||
}
|
||||
|
||||
// AITools returns all supported AI tools.
|
||||
func AITools() []AITool {
|
||||
return aiTools
|
||||
}
|
||||
|
||||
// LookupAITool finds a tool by its config key. Returns false if not found.
|
||||
func LookupAITool(key string) (AITool, bool) {
|
||||
for _, t := range aiTools {
|
||||
if t.Key == key {
|
||||
return t, true
|
||||
}
|
||||
}
|
||||
return AITool{}, false
|
||||
}
|
||||
84
config/aitools_test.go
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestAITools_ReturnsAllTools(t *testing.T) {
|
||||
tools := AITools()
|
||||
if len(tools) != 4 {
|
||||
t.Fatalf("expected 4 tools, got %d", len(tools))
|
||||
}
|
||||
|
||||
keys := make(map[string]bool)
|
||||
for _, tool := range tools {
|
||||
keys[tool.Key] = true
|
||||
}
|
||||
for _, expected := range []string{"claude", "gemini", "codex", "opencode"} {
|
||||
if !keys[expected] {
|
||||
t.Errorf("missing tool key %q", expected)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupAITool_Found(t *testing.T) {
|
||||
tool, ok := LookupAITool("claude")
|
||||
if !ok {
|
||||
t.Fatal("expected to find claude")
|
||||
return
|
||||
}
|
||||
if tool.Command != "claude" {
|
||||
t.Errorf("expected command 'claude', got %q", tool.Command)
|
||||
}
|
||||
if tool.DisplayName != "Claude Code" {
|
||||
t.Errorf("expected display name 'Claude Code', got %q", tool.DisplayName)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLookupAITool_NotFound(t *testing.T) {
|
||||
_, ok := LookupAITool("unknown")
|
||||
if ok {
|
||||
t.Error("expected false for unknown tool")
|
||||
}
|
||||
}
|
||||
|
||||
func TestAITool_PromptArgs_WithFlag(t *testing.T) {
|
||||
tool := AITool{PromptFlag: "--append-system-prompt"}
|
||||
args := tool.PromptArgs("hello")
|
||||
if len(args) != 2 {
|
||||
t.Fatalf("expected 2 args, got %d", len(args))
|
||||
}
|
||||
if args[0] != "--append-system-prompt" {
|
||||
t.Errorf("expected flag '--append-system-prompt', got %q", args[0])
|
||||
}
|
||||
if args[1] != "hello" {
|
||||
t.Errorf("expected prompt 'hello', got %q", args[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAITool_PromptArgs_Positional(t *testing.T) {
|
||||
tool := AITool{PromptFlag: ""}
|
||||
args := tool.PromptArgs("hello")
|
||||
if len(args) != 1 {
|
||||
t.Fatalf("expected 1 arg, got %d", len(args))
|
||||
}
|
||||
if args[0] != "hello" {
|
||||
t.Errorf("expected prompt 'hello', got %q", args[0])
|
||||
}
|
||||
}
|
||||
|
||||
func TestAITool_SkillPath(t *testing.T) {
|
||||
tool := AITool{SkillDir: ".claude/skills"}
|
||||
path := tool.SkillPath("tiki")
|
||||
if path != ".claude/skills/tiki/SKILL.md" {
|
||||
t.Errorf("expected '.claude/skills/tiki/SKILL.md', got %q", path)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAITool_SkillPath_SingularDir(t *testing.T) {
|
||||
tool := AITool{SkillDir: ".opencode/skill"}
|
||||
path := tool.SkillPath("doki")
|
||||
if path != ".opencode/skill/doki/SKILL.md" {
|
||||
t.Errorf("expected '.opencode/skill/doki/SKILL.md', got %q", path)
|
||||
}
|
||||
}
|
||||
|
|
@ -7,68 +7,29 @@ import (
|
|||
"strings"
|
||||
)
|
||||
|
||||
//nolint:unused
|
||||
const artFire = "▓▓▓▓▓▓╗ ▓▓ ▓▓ ▓▓ ▓▓\n╚═▒▒═╝ ▒▒ ▒▒ ▒▒ ▒▒\n ▒▒ ▒▒ ▒▒▒▒ ▒▒\n ░░ ░░ ░░ ░░ ░░\n ╚═╝ ╚═╝ ╚═╝ ╚═╝ ╚═╝"
|
||||
|
||||
const artDots = "▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒\n▒ ● ● ● ▓ ● ▓ ● ▓ ● ▓ ● ▒\n▒ ▓ ● ▓ ▓ ● ▓ ● ● ▓ ▓ ● ▒\n▒ ▓ ● ▓ ▓ ● ▓ ● ▓ ● ▓ ● ▒\n▒ ▓ ● ▓ ▓ ● ▓ ● ▓ ● ▓ ● ▒\n▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒ ▒"
|
||||
|
||||
// fireGradient is the color scheme for artFire (yellow → orange → red)
|
||||
//
|
||||
//nolint:unused
|
||||
var fireGradient = []string{"#FFDC00", "#FFAA00", "#FF7800", "#FF5000", "#B42800"}
|
||||
|
||||
// dotsGradient is the color scheme for artDots (bright cyan → blue gradient)
|
||||
// Each character type gets a different color:
|
||||
// ● (dot) = bright cyan (text)
|
||||
// ▓ (dark shade) = medium blue (near)
|
||||
// ▒ (medium shade) = dark blue (far)
|
||||
var dotsGradient = []string{"#40E0D0", "#4682B4", "#324664"}
|
||||
|
||||
// var currentArt = artFire
|
||||
// var currentGradient = fireGradient
|
||||
var currentArt = artDots
|
||||
var currentGradient = dotsGradient
|
||||
|
||||
// GetArtTView returns the art logo formatted for tview (with tview color codes)
|
||||
// uses the current gradient colors
|
||||
// GetArtTView returns the art logo formatted for tview (with tview color codes).
|
||||
// Colors are sourced from the palette via ColorConfig.
|
||||
func GetArtTView() string {
|
||||
if currentArt == artDots {
|
||||
// For dots art, color by character type, not by row
|
||||
return getDotsArtTView()
|
||||
}
|
||||
colors := GetColors()
|
||||
dotColor := colors.LogoDotColor.Hex()
|
||||
shadeColor := colors.LogoShadeColor.Hex()
|
||||
borderColor := colors.LogoBorderColor.Hex()
|
||||
|
||||
// For other art, color by row
|
||||
lines := strings.Split(currentArt, "\n")
|
||||
var result strings.Builder
|
||||
|
||||
for i, line := range lines {
|
||||
// pick color based on line index (cycle if more lines than colors)
|
||||
colorIdx := i
|
||||
if colorIdx >= len(currentGradient) {
|
||||
colorIdx = len(currentGradient) - 1
|
||||
}
|
||||
color := currentGradient[colorIdx]
|
||||
fmt.Fprintf(&result, "[%s]%s[white]\n", color, line)
|
||||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// getDotsArtTView colors the dots art by character type
|
||||
func getDotsArtTView() string {
|
||||
lines := strings.Split(artDots, "\n")
|
||||
var result strings.Builder
|
||||
|
||||
// dotsGradient: [0]=● (text), [1]=▓ (near), [2]=▒ (far)
|
||||
for _, line := range lines {
|
||||
for _, char := range line {
|
||||
var color string
|
||||
switch char {
|
||||
case '●':
|
||||
color = dotsGradient[0] // bright cyan
|
||||
color = dotColor
|
||||
case '▓':
|
||||
color = dotsGradient[1] // medium blue
|
||||
color = shadeColor
|
||||
case '▒':
|
||||
color = dotsGradient[2] // dark blue
|
||||
color = borderColor
|
||||
default:
|
||||
result.WriteRune(char)
|
||||
continue
|
||||
|
|
@ -79,8 +40,3 @@ func getDotsArtTView() string {
|
|||
}
|
||||
return result.String()
|
||||
}
|
||||
|
||||
// GetFireIcon returns fire icon with tview color codes
|
||||
func GetFireIcon() string {
|
||||
return "[#FFDC00] ░ ▒ ░ \n[#FFAA00] ▒▓██▓█▒░ \n[#FF7800] ░▓████▓██▒░ \n[#FF5000] ▒▓██▓▓▒░ \n[#B42800] ▒▓░ \n[white]\n"
|
||||
}
|
||||
|
|
|
|||
48
config/caption_colors_test.go
Normal file
|
|
@ -0,0 +1,48 @@
|
|||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestCaptionColorForIndex_Valid(t *testing.T) {
|
||||
cc := ColorsFromPalette(DarkPalette())
|
||||
for i := 0; i < 6; i++ {
|
||||
pair := cc.CaptionColorForIndex(i)
|
||||
if pair.Foreground.IsDefault() {
|
||||
t.Errorf("index %d: foreground is default", i)
|
||||
}
|
||||
if pair.Background.IsDefault() {
|
||||
t.Errorf("index %d: background is default", i)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptionColorForIndex_Wraps(t *testing.T) {
|
||||
cc := ColorsFromPalette(DarkPalette())
|
||||
first := cc.CaptionColorForIndex(0)
|
||||
wrapped := cc.CaptionColorForIndex(6)
|
||||
if first.Foreground.Hex() != wrapped.Foreground.Hex() {
|
||||
t.Errorf("expected index 6 to wrap to index 0: got fg %s vs %s", wrapped.Foreground.Hex(), first.Foreground.Hex())
|
||||
}
|
||||
if first.Background.Hex() != wrapped.Background.Hex() {
|
||||
t.Errorf("expected index 6 to wrap to index 0: got bg %s vs %s", wrapped.Background.Hex(), first.Background.Hex())
|
||||
}
|
||||
}
|
||||
|
||||
func TestCaptionColorForIndex_Negative(t *testing.T) {
|
||||
cc := ColorsFromPalette(DarkPalette())
|
||||
pair := cc.CaptionColorForIndex(-1)
|
||||
if !pair.Foreground.IsDefault() {
|
||||
t.Errorf("expected default foreground for negative index, got %s", pair.Foreground.Hex())
|
||||
}
|
||||
if !pair.Background.IsDefault() {
|
||||
t.Errorf("expected default background for negative index, got %s", pair.Background.Hex())
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllThemesHaveCaptionColors(t *testing.T) {
|
||||
for name, info := range themeRegistry {
|
||||
p := info.Palette()
|
||||
if len(p.CaptionColors) < 6 {
|
||||
t.Errorf("theme %q: has %d caption colors, want at least 6", name, len(p.CaptionColors))
|
||||
}
|
||||
}
|
||||
}
|
||||
128
config/color.go
Normal file
|
|
@ -0,0 +1,128 @@
|
|||
package config
|
||||
|
||||
// Unified color type that stores a single color and produces tcell, hex, and tview tag forms.
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
// Color is a unified color representation backed by tcell.Color.
|
||||
// Zero value wraps tcell.ColorDefault (transparent/inherit).
|
||||
type Color struct {
|
||||
color tcell.Color
|
||||
}
|
||||
|
||||
// NewColor creates a Color from a tcell.Color value.
|
||||
func NewColor(c tcell.Color) Color {
|
||||
return Color{color: c}
|
||||
}
|
||||
|
||||
// NewColorHex creates a Color from a hex string like "#rrggbb" or "rrggbb".
|
||||
func NewColorHex(hex string) Color {
|
||||
return Color{color: tcell.GetColor(hex)}
|
||||
}
|
||||
|
||||
// NewColorRGB creates a Color from individual R, G, B components (0-255).
|
||||
func NewColorRGB(r, g, b int32) Color {
|
||||
return Color{color: tcell.NewRGBColor(r, g, b)}
|
||||
}
|
||||
|
||||
// DefaultColor returns a Color wrapping tcell.ColorDefault (transparent/inherit).
|
||||
func DefaultColor() Color {
|
||||
return Color{color: tcell.ColorDefault}
|
||||
}
|
||||
|
||||
// TCell returns the underlying tcell.Color for use with tview widget APIs.
|
||||
func (c Color) TCell() tcell.Color {
|
||||
return c.color
|
||||
}
|
||||
|
||||
// RGB returns the red, green, blue components of the color.
|
||||
func (c Color) RGB() (int32, int32, int32) {
|
||||
return c.color.RGB()
|
||||
}
|
||||
|
||||
// Hex returns the color as a "#rrggbb" hex string.
|
||||
// Returns "-" for ColorDefault (tview's convention for default/transparent).
|
||||
func (c Color) Hex() string {
|
||||
if c.color == tcell.ColorDefault {
|
||||
return "-"
|
||||
}
|
||||
r, g, b := c.color.RGB()
|
||||
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
|
||||
}
|
||||
|
||||
// tagColor returns the color's name (e.g. "green") if it has one, otherwise its hex string.
|
||||
// Named colors are important for tview: "[green]" resolves to the terminal's ANSI palette,
|
||||
// which is often brighter than the literal hex equivalent "[#008000]".
|
||||
func (c Color) tagColor() string {
|
||||
if c.color == tcell.ColorDefault {
|
||||
return "-"
|
||||
}
|
||||
if name := c.color.Name(); name != "" {
|
||||
return name
|
||||
}
|
||||
r, g, b := c.color.RGB()
|
||||
return fmt.Sprintf("#%02x%02x%02x", r, g, b)
|
||||
}
|
||||
|
||||
// Tag returns a ColorTag builder for constructing tview color tags.
|
||||
func (c Color) Tag() ColorTag {
|
||||
return ColorTag{fg: c}
|
||||
}
|
||||
|
||||
// IsDefault returns true if this is the default/transparent color.
|
||||
func (c Color) IsDefault() bool {
|
||||
return c.color == tcell.ColorDefault
|
||||
}
|
||||
|
||||
// ColorTag is a composable builder for tview [fg:bg:attr] color tags.
|
||||
// Use Color.Tag() to create one, then chain Bold() / WithBg() as needed.
|
||||
type ColorTag struct {
|
||||
fg Color
|
||||
bg *Color
|
||||
bold bool
|
||||
}
|
||||
|
||||
// Bold returns a new ColorTag with the bold attribute set.
|
||||
func (t ColorTag) Bold() ColorTag {
|
||||
t.bold = true
|
||||
return t
|
||||
}
|
||||
|
||||
// WithBg returns a new ColorTag with the given background color.
|
||||
func (t ColorTag) WithBg(c Color) ColorTag {
|
||||
t.bg = &c
|
||||
return t
|
||||
}
|
||||
|
||||
// String renders the tview color tag string.
|
||||
// Named colors (e.g. "green") are preserved so tview uses the terminal's ANSI palette.
|
||||
//
|
||||
// Examples:
|
||||
//
|
||||
// Color.Tag().String() → "[green]" or "[#rrggbb]"
|
||||
// Color.Tag().Bold().String() → "[green::b]" or "[#rrggbb::b]"
|
||||
// Color.Tag().WithBg(bg).String() → "[green:#rrggbb]"
|
||||
func (t ColorTag) String() string {
|
||||
fg := t.fg.tagColor()
|
||||
|
||||
hasBg := t.bg != nil
|
||||
if !hasBg && !t.bold {
|
||||
return "[" + fg + "]"
|
||||
}
|
||||
|
||||
bg := "-"
|
||||
if hasBg {
|
||||
bg = t.bg.tagColor()
|
||||
}
|
||||
|
||||
attr := ""
|
||||
if t.bold {
|
||||
attr = "b"
|
||||
}
|
||||
|
||||
return "[" + fg + ":" + bg + ":" + attr + "]"
|
||||
}
|
||||
146
config/color_test.go
Normal file
|
|
@ -0,0 +1,146 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
func TestNewColor(t *testing.T) {
|
||||
c := NewColor(tcell.ColorYellow)
|
||||
if c.TCell() != tcell.ColorYellow {
|
||||
t.Errorf("TCell() = %v, want %v", c.TCell(), tcell.ColorYellow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewColorHex(t *testing.T) {
|
||||
c := NewColorHex("#ff8000")
|
||||
r, g, b := c.RGB()
|
||||
if r != 255 || g != 128 || b != 0 {
|
||||
t.Errorf("RGB() = (%d, %d, %d), want (255, 128, 0)", r, g, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNewColorRGB(t *testing.T) {
|
||||
c := NewColorRGB(10, 20, 30)
|
||||
r, g, b := c.RGB()
|
||||
if r != 10 || g != 20 || b != 30 {
|
||||
t.Errorf("RGB() = (%d, %d, %d), want (10, 20, 30)", r, g, b)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDefaultColor(t *testing.T) {
|
||||
c := DefaultColor()
|
||||
if !c.IsDefault() {
|
||||
t.Error("DefaultColor().IsDefault() = false, want true")
|
||||
}
|
||||
if c.TCell() != tcell.ColorDefault {
|
||||
t.Errorf("TCell() = %v, want ColorDefault", c.TCell())
|
||||
}
|
||||
}
|
||||
|
||||
func TestColor_Hex(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
c Color
|
||||
want string
|
||||
}{
|
||||
{"black", NewColorRGB(0, 0, 0), "#000000"},
|
||||
{"white", NewColorRGB(255, 255, 255), "#ffffff"},
|
||||
{"red", NewColorRGB(255, 0, 0), "#ff0000"},
|
||||
{"default", DefaultColor(), "-"},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
if got := tt.c.Hex(); got != tt.want {
|
||||
t.Errorf("Hex() = %q, want %q", got, tt.want)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestColor_IsDefault(t *testing.T) {
|
||||
if NewColor(tcell.ColorWhite).IsDefault() {
|
||||
t.Error("white.IsDefault() = true, want false")
|
||||
}
|
||||
if !NewColor(tcell.ColorDefault).IsDefault() {
|
||||
t.Error("default.IsDefault() = false, want true")
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorTag_String(t *testing.T) {
|
||||
c := NewColorRGB(255, 128, 0)
|
||||
got := c.Tag().String()
|
||||
want := "[#ff8000]"
|
||||
if got != want {
|
||||
t.Errorf("Tag().String() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorTag_Bold(t *testing.T) {
|
||||
c := NewColorRGB(255, 128, 0)
|
||||
got := c.Tag().Bold().String()
|
||||
want := "[#ff8000:-:b]"
|
||||
if got != want {
|
||||
t.Errorf("Tag().Bold().String() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorTag_WithBg(t *testing.T) {
|
||||
fg := NewColorRGB(255, 255, 255)
|
||||
bg := NewColorHex("#3a5f8a")
|
||||
got := fg.Tag().WithBg(bg).String()
|
||||
want := "[#ffffff:#3a5f8a:]"
|
||||
if got != want {
|
||||
t.Errorf("Tag().WithBg().String() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorTag_BoldWithBg(t *testing.T) {
|
||||
fg := NewColorRGB(255, 128, 0)
|
||||
bg := NewColorRGB(0, 0, 0)
|
||||
got := fg.Tag().Bold().WithBg(bg).String()
|
||||
want := "[#ff8000:#000000:b]"
|
||||
if got != want {
|
||||
t.Errorf("Tag().Bold().WithBg().String() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorTag_WithBgBold(t *testing.T) {
|
||||
// order shouldn't matter
|
||||
fg := NewColorRGB(255, 128, 0)
|
||||
bg := NewColorRGB(0, 0, 0)
|
||||
got := fg.Tag().WithBg(bg).Bold().String()
|
||||
want := "[#ff8000:#000000:b]"
|
||||
if got != want {
|
||||
t.Errorf("Tag().WithBg().Bold().String() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorTag_DefaultFg(t *testing.T) {
|
||||
c := DefaultColor()
|
||||
got := c.Tag().String()
|
||||
want := "[-]"
|
||||
if got != want {
|
||||
t.Errorf("DefaultColor().Tag().String() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorTag_DefaultBg(t *testing.T) {
|
||||
fg := NewColorRGB(255, 0, 0)
|
||||
bg := DefaultColor()
|
||||
got := fg.Tag().WithBg(bg).String()
|
||||
want := "[#ff0000:-:]"
|
||||
if got != want {
|
||||
t.Errorf("Tag().WithBg(default).String() = %q, want %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestColorHexRoundTrip(t *testing.T) {
|
||||
original := "#5e81ac"
|
||||
c := NewColorHex(original)
|
||||
got := c.Hex()
|
||||
if got != original {
|
||||
t.Errorf("hex round-trip: NewColorHex(%q).Hex() = %q", original, got)
|
||||
}
|
||||
}
|
||||
427
config/colors.go
|
|
@ -1,10 +1,6 @@
|
|||
package config
|
||||
|
||||
// Color and style definitions for the UI: gradients, tcell colors, tview color tags.
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell/v2"
|
||||
)
|
||||
// Color and style definitions for the UI: gradients, unified Color values.
|
||||
|
||||
// Gradient defines a start and end RGB color for a gradient transition
|
||||
type Gradient struct {
|
||||
|
|
@ -12,203 +8,302 @@ type Gradient struct {
|
|||
End [3]int // R, G, B (0-255)
|
||||
}
|
||||
|
||||
// CaptionColorPair holds the foreground and background colors for a plugin caption row.
|
||||
type CaptionColorPair struct {
|
||||
Foreground Color
|
||||
Background Color
|
||||
}
|
||||
|
||||
// ColorConfig holds all color and style definitions per view
|
||||
type ColorConfig struct {
|
||||
// Caption colors
|
||||
CaptionFallbackGradient Gradient
|
||||
|
||||
// Task box colors
|
||||
TaskBoxSelectedBackground tcell.Color
|
||||
TaskBoxSelectedText tcell.Color
|
||||
TaskBoxSelectedBorder tcell.Color
|
||||
TaskBoxUnselectedBorder tcell.Color
|
||||
TaskBoxUnselectedBackground tcell.Color
|
||||
TaskBoxSelectedBorder Color
|
||||
TaskBoxUnselectedBorder Color
|
||||
TaskBoxUnselectedBackground Color
|
||||
TaskBoxIDColor Gradient
|
||||
TaskBoxTitleColor string // tview color string like "[#b8b8b8]"
|
||||
TaskBoxLabelColor string // tview color string like "[#767676]"
|
||||
TaskBoxDescriptionColor string // tview color string like "[#767676]"
|
||||
TaskBoxTagValueColor string // tview color string like "[#5a6f8f]"
|
||||
TaskListSelectionColor string // tview color string for selected row highlight, e.g. "[white:#3a5f8a]"
|
||||
TaskListStatusDoneColor string // tview color string for done status indicator, e.g. "[#00ff7f]"
|
||||
TaskListStatusPendingColor string // tview color string for pending status indicator, e.g. "[white]"
|
||||
TaskBoxTitleColor Color
|
||||
TaskBoxLabelColor Color
|
||||
TaskBoxDescriptionColor Color
|
||||
TaskBoxTagValueColor Color
|
||||
TaskListSelectionFg Color // selected row foreground
|
||||
TaskListSelectionBg Color // selected row background
|
||||
TaskListStatusDoneColor Color
|
||||
TaskListStatusPendingColor Color
|
||||
|
||||
// Task detail view colors
|
||||
TaskDetailIDColor Gradient
|
||||
TaskDetailTitleText string // tview color string like "[yellow]"
|
||||
TaskDetailLabelText string // tview color string like "[green]"
|
||||
TaskDetailValueText string // tview color string like "[white]"
|
||||
TaskDetailCommentAuthor string // tview color string like "[yellow]"
|
||||
TaskDetailEditDimTextColor string // tview color string like "[#808080]"
|
||||
TaskDetailEditDimLabelColor string // tview color string like "[#606060]"
|
||||
TaskDetailEditDimValueColor string // tview color string like "[#909090]"
|
||||
TaskDetailEditFocusMarker string // tview color string like "[yellow]"
|
||||
TaskDetailEditFocusText string // tview color string like "[white]"
|
||||
TaskDetailTagForeground tcell.Color
|
||||
TaskDetailTagBackground tcell.Color
|
||||
TaskDetailTitleText Color
|
||||
TaskDetailLabelText Color
|
||||
TaskDetailValueText Color
|
||||
TaskDetailCommentAuthor Color
|
||||
TaskDetailEditDimTextColor Color
|
||||
TaskDetailEditDimLabelColor Color
|
||||
TaskDetailEditDimValueColor Color
|
||||
TaskDetailEditFocusMarker Color
|
||||
TaskDetailEditFocusText Color
|
||||
TaskDetailTagForeground Color
|
||||
TaskDetailTagBackground Color
|
||||
TaskDetailPlaceholderColor Color
|
||||
|
||||
// Search box colors
|
||||
SearchBoxLabelColor tcell.Color
|
||||
SearchBoxBackgroundColor tcell.Color
|
||||
SearchBoxTextColor tcell.Color
|
||||
// Content area colors (base canvas for editable/readable content)
|
||||
ContentBackgroundColor Color
|
||||
ContentTextColor Color
|
||||
|
||||
// Input box colors
|
||||
InputBoxLabelColor Color
|
||||
InputBoxBackgroundColor Color
|
||||
InputBoxTextColor Color
|
||||
|
||||
// Input field colors (used in task detail edit mode)
|
||||
InputFieldBackgroundColor tcell.Color
|
||||
InputFieldTextColor tcell.Color
|
||||
InputFieldBackgroundColor Color
|
||||
InputFieldTextColor Color
|
||||
|
||||
// Completion prompt colors
|
||||
CompletionHintColor tcell.Color
|
||||
CompletionHintColor Color
|
||||
|
||||
// Burndown chart colors
|
||||
BurndownChartAxisColor tcell.Color
|
||||
BurndownChartLabelColor tcell.Color
|
||||
BurndownChartValueColor tcell.Color
|
||||
BurndownChartBarColor tcell.Color
|
||||
BurndownChartAxisColor Color
|
||||
BurndownChartLabelColor Color
|
||||
BurndownChartValueColor Color
|
||||
BurndownChartBarColor Color
|
||||
BurndownChartGradientFrom Gradient
|
||||
BurndownChartGradientTo Gradient
|
||||
BurndownHeaderGradientFrom Gradient // Header-specific chart gradient
|
||||
BurndownHeaderGradientTo Gradient
|
||||
|
||||
// Header view colors
|
||||
HeaderInfoLabel string // tview color string for view name (bold)
|
||||
HeaderInfoSeparator string // tview color string for horizontal rule below name
|
||||
HeaderInfoDesc string // tview color string for view description
|
||||
HeaderKeyBinding string // tview color string like "[yellow]"
|
||||
HeaderKeyText string // tview color string like "[white]"
|
||||
HeaderInfoLabel Color
|
||||
HeaderInfoSeparator Color
|
||||
HeaderInfoDesc Color
|
||||
HeaderKeyBinding Color
|
||||
HeaderKeyText Color
|
||||
|
||||
// Points visual bar colors
|
||||
PointsFilledColor string // tview color string for filled segments
|
||||
PointsUnfilledColor string // tview color string for unfilled segments
|
||||
PointsFilledColor Color
|
||||
PointsUnfilledColor Color
|
||||
|
||||
// Header context help action colors
|
||||
HeaderActionGlobalKeyColor string // tview color string for global action keys
|
||||
HeaderActionGlobalLabelColor string // tview color string for global action labels
|
||||
HeaderActionPluginKeyColor string // tview color string for plugin action keys
|
||||
HeaderActionPluginLabelColor string // tview color string for plugin action labels
|
||||
HeaderActionViewKeyColor string // tview color string for view action keys
|
||||
HeaderActionViewLabelColor string // tview color string for view action labels
|
||||
HeaderActionGlobalKeyColor Color
|
||||
HeaderActionGlobalLabelColor Color
|
||||
HeaderActionPluginKeyColor Color
|
||||
HeaderActionPluginLabelColor Color
|
||||
HeaderActionViewKeyColor Color
|
||||
HeaderActionViewLabelColor Color
|
||||
|
||||
// Plugin caption colors (auto-generated per theme)
|
||||
CaptionColors []CaptionColorPair
|
||||
|
||||
// Plugin-specific colors
|
||||
DepsEditorBackground Color // muted slate for dependency editor caption
|
||||
|
||||
// Fallback solid colors for gradient scenarios (used when UseGradients = false)
|
||||
FallbackTaskIDColor Color // Deep Sky Blue (end of task ID gradient)
|
||||
FallbackBurndownColor Color // Purple (start of burndown gradient)
|
||||
|
||||
// Logo colors (header art)
|
||||
LogoDotColor Color // bright turquoise (● dots)
|
||||
LogoShadeColor Color // medium blue (▓ shade)
|
||||
LogoBorderColor Color // dark blue (▒ border)
|
||||
|
||||
// Statusline colors (bottom bar, powerline style)
|
||||
StatuslineBg string // hex color for stat segment background, e.g. "#3a3a5c"
|
||||
StatuslineFg string // hex color for stat segment text, e.g. "#cccccc"
|
||||
StatuslineAccentBg string // hex color for accent segment background (first segment), e.g. "#5f87af"
|
||||
StatuslineAccentFg string // hex color for accent segment text, e.g. "#1c1c2e"
|
||||
StatuslineInfoFg string // hex color for info message text
|
||||
StatuslineInfoBg string // hex color for info message background
|
||||
StatuslineErrorFg string // hex color for error message text
|
||||
StatuslineErrorBg string // hex color for error message background
|
||||
StatuslineFillBg string // hex color for empty statusline area between segments
|
||||
StatuslineBg Color
|
||||
StatuslineFg Color
|
||||
StatuslineAccentBg Color
|
||||
StatuslineAccentFg Color
|
||||
StatuslineInfoFg Color
|
||||
StatuslineInfoBg Color
|
||||
StatuslineErrorFg Color
|
||||
StatuslineErrorBg Color
|
||||
StatuslineFillBg Color
|
||||
}
|
||||
|
||||
// DefaultColors returns the default color configuration
|
||||
func DefaultColors() *ColorConfig {
|
||||
// Palette defines the base color values used throughout the UI.
|
||||
// Each entry is a semantic name for a unique color; ColorConfig fields reference these.
|
||||
// To change a color everywhere it appears, change it here.
|
||||
type Palette struct {
|
||||
HighlightColor Color // yellow — accents, focus markers, key bindings, borders
|
||||
TextColor Color // white — primary text on dark background
|
||||
TransparentColor Color // default/transparent — inherit background
|
||||
MutedColor Color // #686868 — de-emphasized text, placeholders, hints, borders, dim values/labels, descriptions
|
||||
SoftBorderColor Color // subtle border for unselected task boxes (dark: matches MutedColor, light: recedes)
|
||||
SoftTextColor Color // #b4b4b4 — secondary readable text (task box titles, action labels)
|
||||
AccentColor Color // #008000 — label text (green)
|
||||
ValueColor Color // #8c92ac — field values (cool gray)
|
||||
InfoLabelColor Color // #ffa500 — orange, header view name
|
||||
|
||||
// Selection
|
||||
SelectionBgColor Color // #3a5f8a — steel blue selection row background
|
||||
|
||||
// Action key / accent blue
|
||||
AccentBlue Color // #5fafff — cyan-blue (action keys, points bar, chart bars)
|
||||
SlateColor Color // #5f6982 — muted blue-gray (tag values, unfilled bar segments)
|
||||
|
||||
// Logo
|
||||
LogoDotColor Color // #40e0d0 — bright turquoise (● in header art)
|
||||
LogoShadeColor Color // #4682b4 — steel blue (▓ in header art)
|
||||
LogoBorderColor Color // #324664 — dark navy (▒ in header art)
|
||||
|
||||
// Gradients
|
||||
CaptionFallbackGradient Gradient // Midnight Blue → Royal Blue
|
||||
DeepSkyBlue Color // #00bfff — task ID base color + gradient fallback
|
||||
DeepPurple Color // #865ad6 — fallback for burndown gradient
|
||||
|
||||
// Content area
|
||||
ContentBackgroundColor Color // canvas background (transparent/default — inherits terminal bg)
|
||||
|
||||
// Statusline
|
||||
StatuslineDarkBg Color // darkest statusline background (accent foreground)
|
||||
StatuslineMidBg Color // mid statusline background (info/error/fill)
|
||||
StatuslineBorderBg Color // statusline main background + deps editor background
|
||||
StatuslineText Color // statusline primary text
|
||||
StatuslineAccent Color // statusline accent background
|
||||
StatuslineOk Color // statusline info/success foreground
|
||||
|
||||
// Plugin caption colors (6 curated fg/bg pairs per theme)
|
||||
CaptionColors []CaptionColorPair
|
||||
}
|
||||
|
||||
// darkenRGB returns a darkened version of an RGB triple. ratio 0 = no change, 1 = black.
|
||||
func darkenRGB(rgb [3]int, ratio float64) [3]int {
|
||||
return [3]int{
|
||||
int(float64(rgb[0]) * (1 - ratio)),
|
||||
int(float64(rgb[1]) * (1 - ratio)),
|
||||
int(float64(rgb[2]) * (1 - ratio)),
|
||||
}
|
||||
}
|
||||
|
||||
// gradientFromColor derives a gradient from a single Color by darkening for the start.
|
||||
func gradientFromColor(c Color, darkenRatio float64) Gradient {
|
||||
r, g, b := c.RGB()
|
||||
end := [3]int{int(r), int(g), int(b)}
|
||||
return Gradient{Start: darkenRGB(end, darkenRatio), End: end}
|
||||
}
|
||||
|
||||
// ColorsFromPalette builds a ColorConfig from a Palette.
|
||||
func ColorsFromPalette(p Palette) *ColorConfig {
|
||||
idGradient := gradientFromColor(p.DeepSkyBlue, 0.2)
|
||||
deepPurpleSolid := Gradient{Start: [3]int{134, 90, 214}, End: [3]int{134, 90, 214}}
|
||||
blueCyanSolid := Gradient{Start: [3]int{90, 170, 255}, End: [3]int{90, 170, 255}}
|
||||
headerPurpleSolid := Gradient{Start: [3]int{160, 120, 230}, End: [3]int{160, 120, 230}}
|
||||
headerCyanSolid := Gradient{Start: [3]int{110, 190, 255}, End: [3]int{110, 190, 255}}
|
||||
|
||||
return &ColorConfig{
|
||||
// Caption fallback gradient
|
||||
CaptionFallbackGradient: Gradient{
|
||||
Start: [3]int{25, 25, 112}, // Midnight Blue (center)
|
||||
End: [3]int{65, 105, 225}, // Royal Blue (edges)
|
||||
},
|
||||
CaptionFallbackGradient: p.CaptionFallbackGradient,
|
||||
|
||||
// Task box
|
||||
TaskBoxSelectedBackground: tcell.PaletteColor(33), // Blue (ANSI 33)
|
||||
TaskBoxSelectedText: tcell.PaletteColor(117), // Light Blue (ANSI 117)
|
||||
TaskBoxSelectedBorder: tcell.ColorYellow,
|
||||
TaskBoxUnselectedBorder: tcell.ColorGray,
|
||||
TaskBoxUnselectedBackground: tcell.ColorDefault, // transparent/no background
|
||||
TaskBoxIDColor: Gradient{
|
||||
Start: [3]int{30, 144, 255}, // Dodger Blue
|
||||
End: [3]int{0, 191, 255}, // Deep Sky Blue
|
||||
},
|
||||
TaskBoxTitleColor: "[#b8b8b8]", // Light gray
|
||||
TaskBoxLabelColor: "[#767676]", // Darker gray for labels
|
||||
TaskBoxDescriptionColor: "[#767676]", // Darker gray for description
|
||||
TaskBoxTagValueColor: "[#5a6f8f]", // Blueish gray for tag values
|
||||
TaskListSelectionColor: "[white:#3a5f8a]", // White text on steel blue background
|
||||
TaskListStatusDoneColor: "[#00ff7f]", // Spring green for done checkmark
|
||||
TaskListStatusPendingColor: "[white]", // White for pending circle
|
||||
TaskBoxSelectedBorder: p.HighlightColor,
|
||||
TaskBoxUnselectedBorder: p.SoftBorderColor,
|
||||
TaskBoxUnselectedBackground: p.TransparentColor,
|
||||
TaskBoxIDColor: idGradient,
|
||||
TaskBoxTitleColor: p.SoftTextColor,
|
||||
TaskBoxLabelColor: p.MutedColor,
|
||||
TaskBoxDescriptionColor: p.MutedColor,
|
||||
TaskBoxTagValueColor: p.SlateColor,
|
||||
TaskListSelectionFg: p.TextColor,
|
||||
TaskListSelectionBg: p.SelectionBgColor,
|
||||
TaskListStatusDoneColor: p.AccentColor,
|
||||
TaskListStatusPendingColor: p.TextColor,
|
||||
|
||||
// Task detail
|
||||
TaskDetailIDColor: Gradient{
|
||||
Start: [3]int{30, 144, 255}, // Dodger Blue (same as task box)
|
||||
End: [3]int{0, 191, 255}, // Deep Sky Blue
|
||||
},
|
||||
TaskDetailTitleText: "[yellow]",
|
||||
TaskDetailLabelText: "[green]",
|
||||
TaskDetailValueText: "[#8c92ac]",
|
||||
TaskDetailCommentAuthor: "[yellow]",
|
||||
TaskDetailEditDimTextColor: "[#808080]", // Medium gray for dim text
|
||||
TaskDetailEditDimLabelColor: "[#606060]", // Darker gray for dim labels
|
||||
TaskDetailEditDimValueColor: "[#909090]", // Lighter gray for dim values
|
||||
TaskDetailEditFocusMarker: "[yellow]", // Yellow arrow for focus
|
||||
TaskDetailEditFocusText: "[white]", // White text after arrow
|
||||
TaskDetailTagForeground: tcell.NewRGBColor(180, 200, 220), // Light blue-gray text
|
||||
TaskDetailTagBackground: tcell.NewRGBColor(30, 50, 120), // Dark blue background (more bluish)
|
||||
TaskDetailIDColor: idGradient,
|
||||
TaskDetailTitleText: p.HighlightColor,
|
||||
TaskDetailLabelText: p.AccentColor,
|
||||
TaskDetailValueText: p.ValueColor,
|
||||
TaskDetailCommentAuthor: p.HighlightColor,
|
||||
TaskDetailEditDimTextColor: p.MutedColor,
|
||||
TaskDetailEditDimLabelColor: p.MutedColor,
|
||||
TaskDetailEditDimValueColor: p.SoftTextColor,
|
||||
TaskDetailEditFocusMarker: p.HighlightColor,
|
||||
TaskDetailEditFocusText: p.TextColor,
|
||||
TaskDetailTagForeground: p.SoftTextColor,
|
||||
TaskDetailTagBackground: p.SelectionBgColor,
|
||||
TaskDetailPlaceholderColor: p.MutedColor,
|
||||
|
||||
// Search box
|
||||
SearchBoxLabelColor: tcell.ColorWhite,
|
||||
SearchBoxBackgroundColor: tcell.ColorDefault, // Transparent
|
||||
SearchBoxTextColor: tcell.ColorWhite,
|
||||
// Content area
|
||||
ContentBackgroundColor: p.ContentBackgroundColor,
|
||||
ContentTextColor: p.TextColor,
|
||||
|
||||
// Input field colors
|
||||
InputFieldBackgroundColor: tcell.ColorDefault, // Transparent
|
||||
InputFieldTextColor: tcell.ColorWhite,
|
||||
// Input box
|
||||
InputBoxLabelColor: p.TextColor,
|
||||
InputBoxBackgroundColor: p.TransparentColor,
|
||||
InputBoxTextColor: p.TextColor,
|
||||
|
||||
// Input field
|
||||
InputFieldBackgroundColor: p.TransparentColor,
|
||||
InputFieldTextColor: p.TextColor,
|
||||
|
||||
// Completion prompt
|
||||
CompletionHintColor: tcell.NewRGBColor(128, 128, 128), // Medium gray for hint text
|
||||
CompletionHintColor: p.MutedColor,
|
||||
|
||||
// Burndown chart
|
||||
BurndownChartAxisColor: tcell.NewRGBColor(80, 80, 80), // Dark gray
|
||||
BurndownChartLabelColor: tcell.NewRGBColor(200, 200, 200), // Light gray
|
||||
BurndownChartValueColor: tcell.NewRGBColor(235, 235, 235), // Very light gray
|
||||
BurndownChartBarColor: tcell.NewRGBColor(120, 170, 255), // Light blue
|
||||
BurndownChartGradientFrom: Gradient{
|
||||
Start: [3]int{134, 90, 214}, // Deep purple
|
||||
End: [3]int{134, 90, 214}, // Deep purple (solid, not gradient)
|
||||
},
|
||||
BurndownChartGradientTo: Gradient{
|
||||
Start: [3]int{90, 170, 255}, // Blue/cyan
|
||||
End: [3]int{90, 170, 255}, // Blue/cyan (solid, not gradient)
|
||||
},
|
||||
BurndownHeaderGradientFrom: Gradient{
|
||||
Start: [3]int{160, 120, 230}, // Purple base for header chart
|
||||
End: [3]int{160, 120, 230}, // Purple base (solid)
|
||||
},
|
||||
BurndownHeaderGradientTo: Gradient{
|
||||
Start: [3]int{110, 190, 255}, // Cyan top for header chart
|
||||
End: [3]int{110, 190, 255}, // Cyan top (solid)
|
||||
},
|
||||
BurndownChartAxisColor: p.MutedColor,
|
||||
BurndownChartLabelColor: p.MutedColor,
|
||||
BurndownChartValueColor: p.MutedColor,
|
||||
BurndownChartBarColor: p.AccentBlue,
|
||||
BurndownChartGradientFrom: deepPurpleSolid,
|
||||
BurndownChartGradientTo: blueCyanSolid,
|
||||
BurndownHeaderGradientFrom: headerPurpleSolid,
|
||||
BurndownHeaderGradientTo: headerCyanSolid,
|
||||
|
||||
// Points visual bar
|
||||
PointsFilledColor: "[#508cff]", // Blue for filled segments
|
||||
PointsUnfilledColor: "[#5f6982]", // Gray for unfilled segments
|
||||
// Points bar
|
||||
PointsFilledColor: p.AccentBlue,
|
||||
PointsUnfilledColor: p.SlateColor,
|
||||
|
||||
// Header
|
||||
HeaderInfoLabel: "[orange]",
|
||||
HeaderInfoSeparator: "[#555555]",
|
||||
HeaderInfoDesc: "[#888888]",
|
||||
HeaderKeyBinding: "[yellow]",
|
||||
HeaderKeyText: "[white]",
|
||||
HeaderInfoLabel: p.InfoLabelColor,
|
||||
HeaderInfoSeparator: p.MutedColor,
|
||||
HeaderInfoDesc: p.MutedColor,
|
||||
HeaderKeyBinding: p.HighlightColor,
|
||||
HeaderKeyText: p.TextColor,
|
||||
|
||||
// Header context help actions
|
||||
HeaderActionGlobalKeyColor: "#ffff00", // yellow for global actions
|
||||
HeaderActionGlobalLabelColor: "#ffffff", // white for global action labels
|
||||
HeaderActionPluginKeyColor: "#ff8c00", // orange for plugin actions
|
||||
HeaderActionPluginLabelColor: "#b0b0b0", // light gray for plugin labels
|
||||
HeaderActionViewKeyColor: "#5fafff", // cyan for view-specific actions
|
||||
HeaderActionViewLabelColor: "#808080", // gray for view-specific labels
|
||||
HeaderActionGlobalKeyColor: p.HighlightColor,
|
||||
HeaderActionGlobalLabelColor: p.TextColor,
|
||||
HeaderActionPluginKeyColor: p.InfoLabelColor,
|
||||
HeaderActionPluginLabelColor: p.SoftTextColor,
|
||||
HeaderActionViewKeyColor: p.AccentBlue,
|
||||
HeaderActionViewLabelColor: p.MutedColor,
|
||||
|
||||
// Statusline (Nord theme)
|
||||
StatuslineBg: "#434c5e", // Nord polar night 3
|
||||
StatuslineFg: "#d8dee9", // Nord snow storm 1
|
||||
StatuslineAccentBg: "#5e81ac", // Nord frost blue
|
||||
StatuslineAccentFg: "#2e3440", // Nord polar night 1
|
||||
StatuslineInfoFg: "#a3be8c", // Nord aurora green
|
||||
StatuslineInfoBg: "#3b4252", // Nord polar night 2
|
||||
StatuslineErrorFg: "#bf616a", // Nord aurora red
|
||||
StatuslineErrorBg: "#3b4252", // Nord polar night 2
|
||||
StatuslineFillBg: "#3b4252", // Nord polar night 2
|
||||
// Plugin-specific
|
||||
DepsEditorBackground: p.StatuslineBorderBg,
|
||||
|
||||
// Fallback solid colors
|
||||
FallbackTaskIDColor: p.DeepSkyBlue,
|
||||
FallbackBurndownColor: p.DeepPurple,
|
||||
|
||||
// Logo
|
||||
LogoDotColor: p.LogoDotColor,
|
||||
LogoShadeColor: p.LogoShadeColor,
|
||||
LogoBorderColor: p.LogoBorderColor,
|
||||
|
||||
// Statusline
|
||||
StatuslineBg: p.StatuslineBorderBg,
|
||||
StatuslineFg: p.StatuslineText,
|
||||
StatuslineAccentBg: p.StatuslineAccent,
|
||||
StatuslineAccentFg: p.StatuslineDarkBg,
|
||||
StatuslineInfoFg: p.StatuslineOk,
|
||||
StatuslineInfoBg: p.StatuslineMidBg,
|
||||
StatuslineErrorFg: p.HighlightColor,
|
||||
StatuslineErrorBg: p.StatuslineMidBg,
|
||||
StatuslineFillBg: p.StatuslineMidBg,
|
||||
|
||||
// Plugin caption colors
|
||||
CaptionColors: p.CaptionColors,
|
||||
}
|
||||
}
|
||||
|
||||
// CaptionColorForIndex returns the caption color pair for a plugin at the given config index.
|
||||
// Wraps modulo slice length. Returns zero-value for negative index or empty slice.
|
||||
func (cc *ColorConfig) CaptionColorForIndex(index int) CaptionColorPair {
|
||||
if index < 0 || len(cc.CaptionColors) == 0 {
|
||||
return CaptionColorPair{}
|
||||
}
|
||||
return cc.CaptionColors[index%len(cc.CaptionColors)]
|
||||
}
|
||||
|
||||
// Global color config instance
|
||||
var globalColors *ColorConfig
|
||||
var colorsInitialized bool
|
||||
|
|
@ -221,36 +316,10 @@ var UseGradients bool
|
|||
// Screen-wide gradients show more banding on 256-color terminals, so require truecolor
|
||||
var UseWideGradients bool
|
||||
|
||||
// Plugin-specific background colors for code-only plugins
|
||||
var (
|
||||
// DepsEditorBackground: muted slate for the dependency editor caption
|
||||
DepsEditorBackground = tcell.NewHexColor(0x4e5768)
|
||||
)
|
||||
|
||||
// Fallback solid colors for gradient scenarios (used when UseGradients = false)
|
||||
var (
|
||||
// Caption title fallback: Royal Blue (end of gradient)
|
||||
FallbackTitleColor = tcell.NewRGBColor(65, 105, 225)
|
||||
// Task ID fallback: Deep Sky Blue (end of gradient)
|
||||
FallbackTaskIDColor = tcell.NewRGBColor(0, 191, 255)
|
||||
// Burndown chart fallback: Purple (start of gradient)
|
||||
FallbackBurndownColor = tcell.NewRGBColor(134, 90, 214)
|
||||
// Caption row fallback: Midpoint of Midnight Blue to Royal Blue
|
||||
FallbackCaptionColor = tcell.NewRGBColor(45, 65, 169)
|
||||
)
|
||||
|
||||
// GetColors returns the global color configuration with theme-aware overrides
|
||||
// GetColors returns the global color configuration for the effective theme
|
||||
func GetColors() *ColorConfig {
|
||||
if !colorsInitialized {
|
||||
globalColors = DefaultColors()
|
||||
// Apply theme-aware overrides for critical text colors
|
||||
if GetEffectiveTheme() == "light" {
|
||||
globalColors.SearchBoxLabelColor = tcell.ColorBlack
|
||||
globalColors.SearchBoxTextColor = tcell.ColorBlack
|
||||
globalColors.InputFieldTextColor = tcell.ColorBlack
|
||||
globalColors.TaskDetailEditFocusText = "[black]"
|
||||
globalColors.HeaderKeyText = "[black]"
|
||||
}
|
||||
globalColors = ColorsFromPalette(PaletteForTheme())
|
||||
colorsInitialized = true
|
||||
}
|
||||
return globalColors
|
||||
|
|
|
|||
|
|
@ -1,3 +1,7 @@
|
|||
version: 0.5.0
|
||||
description: |
|
||||
Default tiki workflow. A lightweight kanban-style flow with
|
||||
Backlog → Ready → In Progress → Review → Done, plus Story / Bug / Spike / Epic task types.
|
||||
statuses:
|
||||
- key: backlog
|
||||
label: Backlog
|
||||
|
|
@ -7,7 +11,7 @@ statuses:
|
|||
label: Ready
|
||||
emoji: "📋"
|
||||
active: true
|
||||
- key: in_progress
|
||||
- key: inProgress
|
||||
label: "In Progress"
|
||||
emoji: "⚙️"
|
||||
active: true
|
||||
|
|
@ -20,87 +24,157 @@ statuses:
|
|||
emoji: "✅"
|
||||
done: true
|
||||
|
||||
types:
|
||||
- key: story
|
||||
label: Story
|
||||
emoji: "🌀"
|
||||
- key: bug
|
||||
label: Bug
|
||||
emoji: "💥"
|
||||
- key: spike
|
||||
label: Spike
|
||||
emoji: "🔍"
|
||||
- key: epic
|
||||
label: Epic
|
||||
emoji: "🗂️"
|
||||
|
||||
views:
|
||||
- name: Kanban
|
||||
description: "Move tiki to new status, search, create or delete"
|
||||
default: true
|
||||
foreground: "#87ceeb"
|
||||
background: "#25496a"
|
||||
key: "F1"
|
||||
lanes:
|
||||
- name: Ready
|
||||
filter: status = 'ready' and type != 'epic'
|
||||
action: status = 'ready'
|
||||
- name: In Progress
|
||||
filter: status = 'in_progress' and type != 'epic'
|
||||
action: status = 'in_progress'
|
||||
- name: Review
|
||||
filter: status = 'review' and type != 'epic'
|
||||
action: status = 'review'
|
||||
- name: Done
|
||||
filter: status = 'done' and type != 'epic'
|
||||
action: status = 'done'
|
||||
sort: Priority, CreatedAt
|
||||
- name: Backlog
|
||||
description: "Tasks waiting to be picked up, sorted by priority"
|
||||
foreground: "#5fff87"
|
||||
background: "#0b3d2e"
|
||||
key: "F3"
|
||||
lanes:
|
||||
- name: Backlog
|
||||
columns: 4
|
||||
filter: status = 'backlog' and type != 'epic'
|
||||
actions:
|
||||
- key: "b"
|
||||
label: "Add to board"
|
||||
action: status = 'ready'
|
||||
sort: Priority, ID
|
||||
- name: Recent
|
||||
description: "Tasks changed in the last 24 hours, most recent first"
|
||||
foreground: "#f4d6a6"
|
||||
background: "#5a3d1b"
|
||||
key: Ctrl-R
|
||||
lanes:
|
||||
- name: Recent
|
||||
columns: 4
|
||||
filter: NOW - UpdatedAt < 24hours
|
||||
sort: UpdatedAt DESC
|
||||
- name: Roadmap
|
||||
description: "Epics organized by Now, Next, and Later horizons"
|
||||
foreground: "#e2e8f0"
|
||||
background: "#2a5f5a"
|
||||
key: "F4"
|
||||
lanes:
|
||||
- name: Now
|
||||
columns: 1
|
||||
width: 25
|
||||
filter: type = 'epic' AND status = 'ready'
|
||||
action: status = 'ready'
|
||||
- name: Next
|
||||
columns: 1
|
||||
width: 25
|
||||
filter: type = 'epic' AND status = 'backlog' AND priority = 1
|
||||
action: status = 'backlog', priority = 1
|
||||
- name: Later
|
||||
columns: 2
|
||||
width: 50
|
||||
filter: type = 'epic' AND status = 'backlog' AND priority > 1
|
||||
action: status = 'backlog', priority = 2
|
||||
sort: Priority, Points DESC
|
||||
view: expanded
|
||||
- name: Help
|
||||
description: "Keyboard shortcuts, navigation, and usage guide"
|
||||
type: doki
|
||||
fetcher: internal
|
||||
text: "Help"
|
||||
foreground: "#bcbcbc"
|
||||
background: "#003399"
|
||||
key: "?"
|
||||
- name: Docs
|
||||
description: "Project notes and documentation files"
|
||||
type: doki
|
||||
fetcher: file
|
||||
url: "index.md"
|
||||
foreground: "#ff9966"
|
||||
background: "#2b3a42"
|
||||
key: "F2"
|
||||
actions:
|
||||
- key: "a"
|
||||
label: "Assign to me"
|
||||
action: update where id = id() set assignee=user()
|
||||
- key: "y"
|
||||
label: "Copy ID"
|
||||
action: select id where id = id() | clipboard()
|
||||
- key: "Y"
|
||||
label: "Copy content"
|
||||
action: select title, description where id = id() | clipboard()
|
||||
- key: "+"
|
||||
label: "Priority up"
|
||||
action: update where id = id() set priority = priority - 1
|
||||
- key: "-"
|
||||
label: "Priority down"
|
||||
action: update where id = id() set priority = priority + 1
|
||||
- key: "u"
|
||||
label: "Flag urgent"
|
||||
action: update where id = id() set priority=1 tags=tags+["urgent"]
|
||||
hot: false
|
||||
- key: "A"
|
||||
label: "Assign to..."
|
||||
action: update where id = id() set assignee=input()
|
||||
input: string
|
||||
hot: false
|
||||
- key: "t"
|
||||
label: "Add tag"
|
||||
action: update where id = id() set tags=tags+[input()]
|
||||
input: string
|
||||
hot: false
|
||||
- key: "T"
|
||||
label: "Remove tag"
|
||||
action: update where id = id() set tags=tags-[input()]
|
||||
input: string
|
||||
hot: false
|
||||
plugins:
|
||||
- name: Kanban
|
||||
description: "Move tiki to change status, search, create or delete\nShift Left/Right to move"
|
||||
default: true
|
||||
key: "F1"
|
||||
lanes:
|
||||
- name: Ready
|
||||
filter: select where status = "ready" and type != "epic" order by priority, createdAt
|
||||
action: update where id = id() set status="ready"
|
||||
- name: In Progress
|
||||
filter: select where status = "inProgress" and type != "epic" order by priority, createdAt
|
||||
action: update where id = id() set status="inProgress"
|
||||
- name: Review
|
||||
filter: select where status = "review" and type != "epic" order by priority, createdAt
|
||||
action: update where id = id() set status="review"
|
||||
- name: Done
|
||||
filter: select where status = "done" and type != "epic" order by priority, createdAt
|
||||
action: update where id = id() set status="done"
|
||||
- name: Backlog
|
||||
description: "Tasks waiting to be picked up, sorted by priority"
|
||||
key: "F3"
|
||||
lanes:
|
||||
- name: Backlog
|
||||
columns: 4
|
||||
filter: select where status = "backlog" and type != "epic" order by priority, id
|
||||
actions:
|
||||
- key: "b"
|
||||
label: "Add to board"
|
||||
action: update where id = id() set status="ready"
|
||||
- name: Recent
|
||||
description: "Tasks changed in the last 24 hours, most recent first"
|
||||
key: Ctrl-R
|
||||
lanes:
|
||||
- name: Recent
|
||||
columns: 4
|
||||
filter: select where now() - updatedAt < 24hour order by updatedAt desc
|
||||
- name: Roadmap
|
||||
description: "Epics organized by Now, Next, and Later horizons"
|
||||
key: "F4"
|
||||
lanes:
|
||||
- name: Now
|
||||
columns: 1
|
||||
width: 25
|
||||
filter: select where type = "epic" and status = "ready" order by priority, points desc
|
||||
action: update where id = id() set status="ready"
|
||||
- name: Next
|
||||
columns: 1
|
||||
width: 25
|
||||
filter: select where type = "epic" and status = "backlog" and priority = 1 order by priority, points desc
|
||||
action: update where id = id() set status="backlog" priority=1
|
||||
- name: Later
|
||||
columns: 2
|
||||
width: 50
|
||||
filter: select where type = "epic" and status = "backlog" and priority > 1 order by priority, points desc
|
||||
action: update where id = id() set status="backlog" priority=2
|
||||
view: expanded
|
||||
- name: Docs
|
||||
description: "Project notes and documentation files"
|
||||
type: doki
|
||||
fetcher: file
|
||||
url: "index.md"
|
||||
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: tasks must pass through review before completion
|
||||
ruki: >
|
||||
before update
|
||||
where new.status = "done" and old.status != "review"
|
||||
deny "tasks must go through review before marking 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
|
||||
- description: tasks must have an assignee before starting
|
||||
ruki: >
|
||||
before update
|
||||
where new.status = "inProgress" and new.assignee is empty
|
||||
deny "assign someone before moving to in-progress"
|
||||
- description: auto-complete epics when all child tasks finish
|
||||
ruki: >
|
||||
after update
|
||||
where new.status = "done" and new.type != "epic"
|
||||
update where type = "epic" and new.id in dependsOn and dependsOn all status = "done"
|
||||
set status="done"
|
||||
- description: cannot delete tasks that are actively being worked
|
||||
ruki: >
|
||||
before delete
|
||||
where old.status = "inProgress"
|
||||
deny "cannot delete an in-progress task — move to backlog or done first"
|
||||
- description: spawn next occurrence when recurring task completes
|
||||
ruki: >
|
||||
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="backlog"
|
||||
|
|
|
|||
|
|
@ -13,8 +13,8 @@ const (
|
|||
TaskBoxPaddingExpanded = 4 // Width padding in expanded mode
|
||||
TaskBoxMinWidth = 10 // Minimum width fallback
|
||||
|
||||
// Search box dimensions
|
||||
SearchBoxHeight = 3
|
||||
// Input box dimensions
|
||||
InputBoxHeight = 3
|
||||
|
||||
// TaskList default visible rows
|
||||
TaskListDefaultMaxRows = 10
|
||||
|
|
|
|||
272
config/fields.go
Normal file
|
|
@ -0,0 +1,272 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync/atomic"
|
||||
|
||||
"github.com/boolean-maybe/tiki/workflow"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// customFieldYAML represents a single field entry in the workflow.yaml fields: section.
|
||||
type customFieldYAML struct {
|
||||
Name string `yaml:"name"`
|
||||
Type string `yaml:"type"`
|
||||
Values []string `yaml:"values,omitempty"` // enum only
|
||||
}
|
||||
|
||||
// customFieldFileData is the minimal YAML structure for reading fields from workflow.yaml.
|
||||
type customFieldFileData struct {
|
||||
Fields []customFieldYAML `yaml:"fields"`
|
||||
}
|
||||
|
||||
// registriesLoaded tracks whether LoadWorkflowRegistries has been called.
|
||||
var registriesLoaded atomic.Bool
|
||||
|
||||
// RequireWorkflowRegistriesLoaded returns an error if LoadWorkflowRegistries
|
||||
// (or LoadStatusRegistry + LoadCustomFields) has not been called yet.
|
||||
// Intended for use by store/template code that needs registries to be ready
|
||||
// but should not auto-load them from disk.
|
||||
func RequireWorkflowRegistriesLoaded() error {
|
||||
if !registriesLoaded.Load() {
|
||||
return fmt.Errorf("workflow registries not loaded; call config.LoadWorkflowRegistries() first")
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// MarkRegistriesLoadedForTest sets the registriesLoaded flag without loading
|
||||
// from disk. Use in tests that call workflow.RegisterCustomFields directly.
|
||||
func MarkRegistriesLoadedForTest() {
|
||||
registriesLoaded.Store(true)
|
||||
}
|
||||
|
||||
// ResetRegistriesLoadedForTest clears the registriesLoaded flag.
|
||||
// Use in tests that need to verify the unloaded-registry error path.
|
||||
func ResetRegistriesLoadedForTest() {
|
||||
registriesLoaded.Store(false)
|
||||
}
|
||||
|
||||
// LoadWorkflowRegistries is the shared startup helper that loads all
|
||||
// workflow-registry-based sections (statuses, types, custom fields) from
|
||||
// workflow.yaml files. Callers must build a fresh ruki.Schema after this returns.
|
||||
func LoadWorkflowRegistries() error {
|
||||
if err := LoadStatusRegistry(); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := LoadCustomFields(); err != nil {
|
||||
return err
|
||||
}
|
||||
registriesLoaded.Store(true)
|
||||
return nil
|
||||
}
|
||||
|
||||
// LoadCustomFields reads the fields: section from all workflow.yaml files,
|
||||
// validates and merges definitions, and registers them with workflow.RegisterCustomFields.
|
||||
// Uses FindRegistryWorkflowFiles (no views filtering) so files with empty views:
|
||||
// still contribute custom field definitions.
|
||||
// Merge semantics: identical redefinitions allowed, conflicting redefinitions error.
|
||||
func LoadCustomFields() error {
|
||||
files := FindRegistryWorkflowFiles()
|
||||
if len(files) == 0 {
|
||||
// no workflow files at all — no custom fields to register, clear any stale state
|
||||
workflow.ClearCustomFields()
|
||||
return nil
|
||||
}
|
||||
|
||||
// collect all field definitions with their source file
|
||||
type fieldSource struct {
|
||||
def customFieldYAML
|
||||
file string
|
||||
}
|
||||
var allFields []fieldSource
|
||||
|
||||
for _, path := range files {
|
||||
defs, err := readCustomFieldsFromFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("reading custom fields from %s: %w", path, err)
|
||||
}
|
||||
for _, d := range defs {
|
||||
allFields = append(allFields, fieldSource{def: d, file: path})
|
||||
}
|
||||
}
|
||||
|
||||
if len(allFields) == 0 {
|
||||
workflow.ClearCustomFields()
|
||||
return nil
|
||||
}
|
||||
|
||||
// merge: identical definitions allowed, conflicting definitions error
|
||||
type mergedField struct {
|
||||
def workflow.FieldDef
|
||||
sourceFile string
|
||||
}
|
||||
merged := make(map[string]*mergedField)
|
||||
|
||||
for _, fs := range allFields {
|
||||
def, err := convertCustomFieldDef(fs.def)
|
||||
if err != nil {
|
||||
return fmt.Errorf("field %q in %s: %w", fs.def.Name, fs.file, err)
|
||||
}
|
||||
|
||||
if existing, ok := merged[def.Name]; ok {
|
||||
if !fieldDefsEqual(existing.def, def) {
|
||||
return fmt.Errorf("conflicting definition for custom field %q: defined differently in %s and %s",
|
||||
def.Name, existing.sourceFile, fs.file)
|
||||
}
|
||||
// identical redefinition — skip
|
||||
continue
|
||||
}
|
||||
|
||||
merged[def.Name] = &mergedField{def: def, sourceFile: fs.file}
|
||||
}
|
||||
|
||||
// build ordered slice for registration
|
||||
defs := make([]workflow.FieldDef, 0, len(merged))
|
||||
for _, m := range merged {
|
||||
defs = append(defs, m.def)
|
||||
}
|
||||
// sort by name for deterministic ordering
|
||||
sort.Slice(defs, func(i, j int) bool {
|
||||
return defs[i].Name < defs[j].Name
|
||||
})
|
||||
|
||||
if err := workflow.RegisterCustomFields(defs); err != nil {
|
||||
return fmt.Errorf("registering custom fields: %w", err)
|
||||
}
|
||||
|
||||
slog.Debug("loaded custom fields", "count", len(defs))
|
||||
return nil
|
||||
}
|
||||
|
||||
// FindRegistryWorkflowFiles returns all workflow.yaml files that exist,
|
||||
// without the views-filtering that FindWorkflowFiles applies.
|
||||
// Used by registry loaders (statuses, custom fields) that need to read
|
||||
// configuration sections regardless of whether the file defines views.
|
||||
func FindRegistryWorkflowFiles() []string {
|
||||
pm := mustGetPathManager()
|
||||
|
||||
candidates := []string{
|
||||
pm.UserConfigWorkflowFile(),
|
||||
filepath.Join(pm.ProjectConfigDir(), defaultWorkflowFilename),
|
||||
defaultWorkflowFilename, // relative to cwd
|
||||
}
|
||||
|
||||
var result []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, path := range candidates {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
abs = path
|
||||
}
|
||||
if seen[abs] {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
continue
|
||||
}
|
||||
seen[abs] = true
|
||||
result = append(result, path)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// readCustomFieldsFromFile reads the fields: section from a single workflow.yaml.
|
||||
func readCustomFieldsFromFile(path string) ([]customFieldYAML, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var fd customFieldFileData
|
||||
if err := yaml.Unmarshal(data, &fd); err != nil {
|
||||
return nil, fmt.Errorf("parsing %s: %w", path, err)
|
||||
}
|
||||
|
||||
return fd.Fields, nil
|
||||
}
|
||||
|
||||
// convertCustomFieldDef converts a YAML field definition to a workflow.FieldDef.
|
||||
func convertCustomFieldDef(def customFieldYAML) (workflow.FieldDef, error) {
|
||||
if def.Name == "" {
|
||||
return workflow.FieldDef{}, fmt.Errorf("field name is required")
|
||||
}
|
||||
|
||||
if err := workflow.ValidateFieldName(def.Name); err != nil {
|
||||
return workflow.FieldDef{}, err
|
||||
}
|
||||
|
||||
vt, err := parseFieldType(def.Type)
|
||||
if err != nil {
|
||||
return workflow.FieldDef{}, err
|
||||
}
|
||||
|
||||
fd := workflow.FieldDef{
|
||||
Name: def.Name,
|
||||
Type: vt,
|
||||
Custom: true,
|
||||
}
|
||||
|
||||
if vt == workflow.TypeEnum {
|
||||
if len(def.Values) == 0 {
|
||||
return workflow.FieldDef{}, fmt.Errorf("enum field requires non-empty values list")
|
||||
}
|
||||
fd.AllowedValues = make([]string, len(def.Values))
|
||||
copy(fd.AllowedValues, def.Values)
|
||||
} else if len(def.Values) > 0 {
|
||||
return workflow.FieldDef{}, fmt.Errorf("values list is only valid for enum fields")
|
||||
}
|
||||
|
||||
return fd, nil
|
||||
}
|
||||
|
||||
// parseFieldType maps workflow.yaml type strings to workflow.ValueType.
|
||||
func parseFieldType(s string) (workflow.ValueType, error) {
|
||||
switch strings.ToLower(s) {
|
||||
case "text":
|
||||
return workflow.TypeString, nil
|
||||
case "integer":
|
||||
return workflow.TypeInt, nil
|
||||
case "boolean":
|
||||
return workflow.TypeBool, nil
|
||||
case "datetime":
|
||||
return workflow.TypeTimestamp, nil
|
||||
case "enum":
|
||||
return workflow.TypeEnum, nil
|
||||
case "stringlist":
|
||||
return workflow.TypeListString, nil
|
||||
case "taskidlist":
|
||||
return workflow.TypeListRef, nil
|
||||
default:
|
||||
return 0, fmt.Errorf("unknown field type %q (valid: text, integer, boolean, datetime, enum, stringList, taskIdList)", s)
|
||||
}
|
||||
}
|
||||
|
||||
// fieldDefsEqual returns true if two FieldDefs are structurally identical
|
||||
// (same name, same type, and for enums, same normalized values).
|
||||
func fieldDefsEqual(a, b workflow.FieldDef) bool {
|
||||
if a.Name != b.Name || a.Type != b.Type {
|
||||
return false
|
||||
}
|
||||
if a.Type == workflow.TypeEnum {
|
||||
if len(a.AllowedValues) != len(b.AllowedValues) {
|
||||
return false
|
||||
}
|
||||
// require exact spelling and order for duplicate enum declarations
|
||||
for i := range a.AllowedValues {
|
||||
if a.AllowedValues[i] != b.AllowedValues[i] {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
225
config/fields_test.go
Normal file
|
|
@ -0,0 +1,225 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/boolean-maybe/tiki/workflow"
|
||||
)
|
||||
|
||||
// setupLoadCustomFieldsTest creates temp dirs and configures the path manager
|
||||
// so LoadCustomFields can discover workflow.yaml files.
|
||||
func setupLoadCustomFieldsTest(t *testing.T) (cwdDir string) {
|
||||
t.Helper()
|
||||
workflow.ClearCustomFields()
|
||||
t.Cleanup(func() { workflow.ClearCustomFields() })
|
||||
|
||||
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 TestLoadCustomFields_BasicTypes(t *testing.T) {
|
||||
cwdDir := setupLoadCustomFieldsTest(t)
|
||||
|
||||
content := `
|
||||
fields:
|
||||
- name: notes
|
||||
type: text
|
||||
- name: score
|
||||
type: integer
|
||||
- name: active
|
||||
type: boolean
|
||||
- name: startedAt
|
||||
type: datetime
|
||||
- name: labels
|
||||
type: stringList
|
||||
- name: related
|
||||
type: taskIdList
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := LoadCustomFields(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
checks := []struct {
|
||||
name string
|
||||
wantType workflow.ValueType
|
||||
}{
|
||||
{"notes", workflow.TypeString},
|
||||
{"score", workflow.TypeInt},
|
||||
{"active", workflow.TypeBool},
|
||||
{"startedAt", workflow.TypeTimestamp},
|
||||
{"labels", workflow.TypeListString},
|
||||
{"related", workflow.TypeListRef},
|
||||
}
|
||||
for _, c := range checks {
|
||||
f, ok := workflow.Field(c.name)
|
||||
if !ok {
|
||||
t.Errorf("Field(%q) not found", c.name)
|
||||
continue
|
||||
}
|
||||
if f.Type != c.wantType {
|
||||
t.Errorf("Field(%q).Type = %v, want %v", c.name, f.Type, c.wantType)
|
||||
}
|
||||
if !f.Custom {
|
||||
t.Errorf("Field(%q).Custom = false, want true", c.name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCustomFields_EnumWithValues(t *testing.T) {
|
||||
cwdDir := setupLoadCustomFieldsTest(t)
|
||||
|
||||
content := `
|
||||
fields:
|
||||
- name: severity
|
||||
type: enum
|
||||
values: [low, medium, high, critical]
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := LoadCustomFields(); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
f, ok := workflow.Field("severity")
|
||||
if !ok {
|
||||
t.Fatal("severity field not found")
|
||||
}
|
||||
if f.Type != workflow.TypeEnum {
|
||||
t.Errorf("severity.Type = %v, want TypeEnum", f.Type)
|
||||
}
|
||||
wantVals := []string{"low", "medium", "high", "critical"}
|
||||
if len(f.AllowedValues) != len(wantVals) {
|
||||
t.Fatalf("severity.AllowedValues length = %d, want %d", len(f.AllowedValues), len(wantVals))
|
||||
}
|
||||
for i, v := range wantVals {
|
||||
if f.AllowedValues[i] != v {
|
||||
t.Errorf("AllowedValues[%d] = %q, want %q", i, f.AllowedValues[i], v)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCustomFields_BadTypeRejected(t *testing.T) {
|
||||
cwdDir := setupLoadCustomFieldsTest(t)
|
||||
|
||||
content := `
|
||||
fields:
|
||||
- name: broken
|
||||
type: nosuchtype
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := LoadCustomFields()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCustomFields_EnumWithoutValues(t *testing.T) {
|
||||
cwdDir := setupLoadCustomFieldsTest(t)
|
||||
|
||||
content := `
|
||||
fields:
|
||||
- name: severity
|
||||
type: enum
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := LoadCustomFields()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for enum without values")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCustomFields_ConflictingRedefinition(t *testing.T) {
|
||||
cwdDir := setupLoadCustomFieldsTest(t)
|
||||
pm := mustGetPathManager()
|
||||
|
||||
// write field definition in project config
|
||||
projectWorkflow := filepath.Join(pm.ProjectConfigDir(), "workflow.yaml")
|
||||
content1 := `
|
||||
fields:
|
||||
- name: score
|
||||
type: integer
|
||||
`
|
||||
if err := os.WriteFile(projectWorkflow, []byte(content1), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// write conflicting definition in cwd
|
||||
content2 := `
|
||||
fields:
|
||||
- name: score
|
||||
type: text
|
||||
`
|
||||
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content2), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := LoadCustomFields()
|
||||
if err == nil {
|
||||
t.Fatal("expected error for conflicting redefinition")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadCustomFields_IdenticalRedefinition(t *testing.T) {
|
||||
cwdDir := setupLoadCustomFieldsTest(t)
|
||||
pm := mustGetPathManager()
|
||||
|
||||
// write identical definitions in two locations
|
||||
content := `
|
||||
fields:
|
||||
- name: score
|
||||
type: integer
|
||||
`
|
||||
projectWorkflow := filepath.Join(pm.ProjectConfigDir(), "workflow.yaml")
|
||||
if err := os.WriteFile(projectWorkflow, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(cwdDir, "workflow.yaml"), []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
if err := LoadCustomFields(); err != nil {
|
||||
t.Fatalf("identical redefinition should succeed: %v", err)
|
||||
}
|
||||
|
||||
f, ok := workflow.Field("score")
|
||||
if !ok {
|
||||
t.Fatal("score field not found")
|
||||
}
|
||||
if f.Type != workflow.TypeInt {
|
||||
t.Errorf("score.Type = %v, want TypeInt", f.Type)
|
||||
}
|
||||
}
|
||||
|
|
@ -48,8 +48,6 @@ Just configuring multiple plugins. Create a file like `brainstorm.yaml`:
|
|||
```text
|
||||
name: Brainstorm
|
||||
type: doki
|
||||
foreground: "##ffff99"
|
||||
background: "#996600"
|
||||
key: "F6"
|
||||
url: new-doc-root.md
|
||||
```
|
||||
|
|
|
|||
178
config/init.go
|
|
@ -1,6 +1,7 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
|
|
@ -25,10 +26,17 @@ func IsProjectInitialized() bool {
|
|||
return info.IsDir()
|
||||
}
|
||||
|
||||
// InitOptions holds user choices from the init dialog.
|
||||
type InitOptions struct {
|
||||
AITools []string
|
||||
SampleTasks bool
|
||||
}
|
||||
|
||||
// PromptForProjectInit presents a Huh form for project initialization.
|
||||
// Returns (selectedAITools, proceed, error)
|
||||
func PromptForProjectInit() ([]string, bool, error) {
|
||||
var selectedAITools []string
|
||||
// Returns (options, proceed, error)
|
||||
func PromptForProjectInit() (InitOptions, bool, error) {
|
||||
var opts InitOptions
|
||||
opts.SampleTasks = true // default enabled
|
||||
|
||||
// Create custom theme with brighter description and help text
|
||||
theme := huh.ThemeCharm()
|
||||
|
|
@ -67,18 +75,23 @@ AI skills extend your AI assistant with commands to manage tasks and documentati
|
|||
Select AI assistants to install (optional), then press Enter to continue.
|
||||
Press Esc to cancel project initialization.`
|
||||
|
||||
aiOptions := make([]huh.Option[string], 0, len(AITools()))
|
||||
for _, t := range AITools() {
|
||||
aiOptions = append(aiOptions, huh.NewOption(fmt.Sprintf("%s (%s/)", t.DisplayName, t.SkillDir), t.Key))
|
||||
}
|
||||
|
||||
form := huh.NewForm(
|
||||
huh.NewGroup(
|
||||
huh.NewMultiSelect[string]().
|
||||
Title("Initialize project").
|
||||
Description(description).
|
||||
Options(
|
||||
huh.NewOption("Claude Code (.claude/skills/)", "claude"),
|
||||
huh.NewOption("OpenAI Codex (.codex/skills/)", "codex"),
|
||||
huh.NewOption("OpenCode (.opencode/skill/)", "opencode"),
|
||||
).
|
||||
Options(aiOptions...).
|
||||
Filterable(false).
|
||||
Value(&selectedAITools),
|
||||
Value(&opts.AITools),
|
||||
huh.NewConfirm().
|
||||
Title("Create sample tasks").
|
||||
Description("Create example tikis in project").
|
||||
Value(&opts.SampleTasks),
|
||||
),
|
||||
).WithTheme(theme).
|
||||
WithKeyMap(keymap).
|
||||
|
|
@ -87,12 +100,12 @@ Press Esc to cancel project initialization.`
|
|||
err := form.Run()
|
||||
if err != nil {
|
||||
if errors.Is(err, huh.ErrUserAborted) {
|
||||
return nil, false, nil
|
||||
return InitOptions{}, false, nil
|
||||
}
|
||||
return nil, false, fmt.Errorf("form error: %w", err)
|
||||
return InitOptions{}, false, fmt.Errorf("form error: %w", err)
|
||||
}
|
||||
|
||||
return selectedAITools, true, nil
|
||||
return opts, true, nil
|
||||
}
|
||||
|
||||
// EnsureProjectInitialized bootstraps the project if .doc/tiki is missing.
|
||||
|
|
@ -105,7 +118,7 @@ func EnsureProjectInitialized(tikiSkillMdContent, dokiSkillMdContent string) (bo
|
|||
return false, fmt.Errorf("failed to stat task directory: %w", err)
|
||||
}
|
||||
|
||||
selectedTools, proceed, err := PromptForProjectInit()
|
||||
opts, proceed, err := PromptForProjectInit()
|
||||
if err != nil {
|
||||
return false, fmt.Errorf("failed to prompt for project initialization: %w", err)
|
||||
}
|
||||
|
|
@ -113,18 +126,18 @@ func EnsureProjectInitialized(tikiSkillMdContent, dokiSkillMdContent string) (bo
|
|||
return false, nil
|
||||
}
|
||||
|
||||
if err := BootstrapSystem(); err != nil {
|
||||
if err := BootstrapSystem(opts.SampleTasks); err != nil {
|
||||
return false, fmt.Errorf("failed to bootstrap project: %w", err)
|
||||
}
|
||||
|
||||
// Install selected AI skills
|
||||
if len(selectedTools) > 0 {
|
||||
if err := installAISkills(selectedTools, tikiSkillMdContent, dokiSkillMdContent); err != nil {
|
||||
if len(opts.AITools) > 0 {
|
||||
if err := installAISkills(opts.AITools, tikiSkillMdContent, dokiSkillMdContent); err != nil {
|
||||
// Non-fatal - log warning but continue
|
||||
slog.Warn("some AI skills failed to install", "error", err)
|
||||
fmt.Println("You can manually copy ai/skills/tiki/SKILL.md and ai/skills/doki/SKILL.md to the appropriate directories.")
|
||||
} else {
|
||||
fmt.Printf("✓ Installed AI skills for: %s\n", strings.Join(selectedTools, ", "))
|
||||
fmt.Printf("✓ Installed AI skills for: %s\n", strings.Join(opts.AITools, ", "))
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -144,55 +157,45 @@ func installAISkills(selectedTools []string, tikiSkillMdContent, dokiSkillMdCont
|
|||
return fmt.Errorf("embedded doki SKILL.md content is empty")
|
||||
}
|
||||
|
||||
// Define target paths for both tiki and doki skills
|
||||
type skillPaths struct {
|
||||
tiki string
|
||||
doki string
|
||||
type skillDef struct {
|
||||
name string
|
||||
content string
|
||||
}
|
||||
skills := []skillDef{
|
||||
{"tiki", tikiSkillMdContent},
|
||||
{"doki", dokiSkillMdContent},
|
||||
}
|
||||
|
||||
toolPaths := map[string]skillPaths{
|
||||
"claude": {
|
||||
tiki: ".claude/skills/tiki/SKILL.md",
|
||||
doki: ".claude/skills/doki/SKILL.md",
|
||||
},
|
||||
"codex": {
|
||||
tiki: ".codex/skills/tiki/SKILL.md",
|
||||
doki: ".codex/skills/doki/SKILL.md",
|
||||
},
|
||||
"opencode": {
|
||||
tiki: ".opencode/skill/tiki/SKILL.md",
|
||||
doki: ".opencode/skill/doki/SKILL.md",
|
||||
},
|
||||
skillNames := make([]string, len(skills))
|
||||
for i, s := range skills {
|
||||
skillNames[i] = s.name
|
||||
}
|
||||
|
||||
var errs []error
|
||||
for _, tool := range selectedTools {
|
||||
paths, ok := toolPaths[tool]
|
||||
for _, toolKey := range selectedTools {
|
||||
tool, ok := LookupAITool(toolKey)
|
||||
if !ok {
|
||||
errs = append(errs, fmt.Errorf("unknown tool: %s", tool))
|
||||
errs = append(errs, fmt.Errorf("unknown tool: %s", toolKey))
|
||||
continue
|
||||
}
|
||||
|
||||
// Install tiki skill
|
||||
tikiDir := filepath.Dir(paths.tiki)
|
||||
//nolint:gosec // G301: 0755 is appropriate for user-owned skill directories
|
||||
if err := os.MkdirAll(tikiDir, 0755); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to create tiki directory for %s: %w", tool, err))
|
||||
} else if err := os.WriteFile(paths.tiki, []byte(tikiSkillMdContent), 0644); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to write tiki SKILL.md for %s: %w", tool, err))
|
||||
} else {
|
||||
slog.Info("installed tiki AI skill", "tool", tool, "path", paths.tiki)
|
||||
for _, skill := range skills {
|
||||
path := tool.SkillPath(skill.name)
|
||||
dir := filepath.Dir(path)
|
||||
//nolint:gosec // G301: 0755 is appropriate for user-owned skill directories
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to create %s directory for %s: %w", skill.name, toolKey, err))
|
||||
} else if err := os.WriteFile(path, []byte(skill.content), 0644); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to write %s SKILL.md for %s: %w", skill.name, toolKey, err))
|
||||
} else {
|
||||
slog.Info("installed AI skill", "tool", toolKey, "skill", skill.name, "path", path)
|
||||
}
|
||||
}
|
||||
|
||||
// Install doki skill
|
||||
dokiDir := filepath.Dir(paths.doki)
|
||||
//nolint:gosec // G301: 0755 is appropriate for user-owned skill directories
|
||||
if err := os.MkdirAll(dokiDir, 0755); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to create doki directory for %s: %w", tool, err))
|
||||
} else if err := os.WriteFile(paths.doki, []byte(dokiSkillMdContent), 0644); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to write doki SKILL.md for %s: %w", tool, err))
|
||||
} else {
|
||||
slog.Info("installed doki AI skill", "tool", tool, "path", paths.doki)
|
||||
if tool.SettingsFile != "" {
|
||||
if err := ensureSkillPermissions(tool.SettingsFile, skillNames); err != nil {
|
||||
errs = append(errs, fmt.Errorf("failed to update %s settings: %w", toolKey, err))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -201,3 +204,68 @@ func installAISkills(selectedTools []string, tikiSkillMdContent, dokiSkillMdCont
|
|||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func skillPermissionEntry(name string) string {
|
||||
return fmt.Sprintf("Skill(%s)", name)
|
||||
}
|
||||
|
||||
// ensureSkillPermissions creates or updates a settings file to include
|
||||
// Skill(<name>) entries in permissions.allow for each given skill name.
|
||||
// Existing permissions and other top-level keys are preserved.
|
||||
func ensureSkillPermissions(settingsPath string, skillNames []string) error {
|
||||
settings := make(map[string]any)
|
||||
|
||||
data, err := os.ReadFile(settingsPath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return fmt.Errorf("reading %s: %w", settingsPath, err)
|
||||
}
|
||||
if len(data) > 0 {
|
||||
if err := json.Unmarshal(data, &settings); err != nil {
|
||||
return fmt.Errorf("parsing %s: %w", settingsPath, err)
|
||||
}
|
||||
}
|
||||
|
||||
perms, _ := settings["permissions"].(map[string]any)
|
||||
if perms == nil {
|
||||
perms = make(map[string]any)
|
||||
settings["permissions"] = perms
|
||||
}
|
||||
|
||||
allowRaw, _ := perms["allow"].([]any)
|
||||
existing := make(map[string]bool, len(allowRaw))
|
||||
for _, v := range allowRaw {
|
||||
if s, ok := v.(string); ok {
|
||||
existing[s] = true
|
||||
}
|
||||
}
|
||||
|
||||
changed := false
|
||||
for _, name := range skillNames {
|
||||
entry := skillPermissionEntry(name)
|
||||
if !existing[entry] {
|
||||
allowRaw = append(allowRaw, entry)
|
||||
changed = true
|
||||
}
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
|
||||
perms["allow"] = allowRaw
|
||||
out, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshaling settings: %w", err)
|
||||
}
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(settingsPath), 0750); err != nil {
|
||||
return fmt.Errorf("creating directory for %s: %w", settingsPath, err)
|
||||
}
|
||||
//nolint:gosec // G306: 0644 is appropriate for user settings files
|
||||
if err := os.WriteFile(settingsPath, append(out, '\n'), 0644); err != nil {
|
||||
return fmt.Errorf("writing %s: %w", settingsPath, err)
|
||||
}
|
||||
|
||||
slog.Info("updated Claude settings", "path", settingsPath, "skills", skillNames)
|
||||
return nil
|
||||
}
|
||||
|
|
|
|||
182
config/init_test.go
Normal file
|
|
@ -0,0 +1,182 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
const settingsFile = ".claude/settings.local.json"
|
||||
|
||||
func TestEnsureSkillPermissions_CreatesFile(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
if err := ensureSkillPermissions(settingsFile, []string{"tiki", "doki"}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got := readSettings(t)
|
||||
allow := getAllow(t, got)
|
||||
|
||||
if len(allow) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d: %v", len(allow), allow)
|
||||
}
|
||||
if allow[0] != "Skill(tiki)" || allow[1] != "Skill(doki)" {
|
||||
t.Errorf("unexpected entries: %v", allow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSkillPermissions_MergesExisting(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
writeSettings(t, map[string]any{
|
||||
"permissions": map[string]any{
|
||||
"allow": []any{"Bash(go test:*)"},
|
||||
},
|
||||
})
|
||||
|
||||
if err := ensureSkillPermissions(settingsFile, []string{"tiki"}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got := readSettings(t)
|
||||
allow := getAllow(t, got)
|
||||
|
||||
if len(allow) != 2 {
|
||||
t.Fatalf("expected 2 entries, got %d: %v", len(allow), allow)
|
||||
}
|
||||
if allow[0] != "Bash(go test:*)" {
|
||||
t.Errorf("existing entry clobbered: %v", allow)
|
||||
}
|
||||
if allow[1] != "Skill(tiki)" {
|
||||
t.Errorf("new entry missing: %v", allow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSkillPermissions_AlreadyPresent(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
writeSettings(t, map[string]any{
|
||||
"permissions": map[string]any{
|
||||
"allow": []any{"Skill(tiki)", "Skill(doki)"},
|
||||
},
|
||||
})
|
||||
|
||||
before, _ := os.ReadFile(settingsFile)
|
||||
if err := ensureSkillPermissions(settingsFile, []string{"tiki", "doki"}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
after, _ := os.ReadFile(settingsFile)
|
||||
|
||||
if string(before) != string(after) {
|
||||
t.Error("file was modified despite all skills already present")
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSkillPermissions_PreservesOtherKeys(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
writeSettings(t, map[string]any{
|
||||
"outputStyle": "Explanatory",
|
||||
"permissions": map[string]any{
|
||||
"allow": []any{"Bash(go test:*)"},
|
||||
},
|
||||
})
|
||||
|
||||
if err := ensureSkillPermissions(settingsFile, []string{"tiki"}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got := readSettings(t)
|
||||
style, ok := got["outputStyle"].(string)
|
||||
if !ok || style != "Explanatory" {
|
||||
t.Errorf("outputStyle not preserved: got %v", got["outputStyle"])
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSkillPermissions_EmptyPermissions(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
writeSettings(t, map[string]any{
|
||||
"permissions": map[string]any{},
|
||||
})
|
||||
|
||||
if err := ensureSkillPermissions(settingsFile, []string{"tiki"}); err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
|
||||
got := readSettings(t)
|
||||
allow := getAllow(t, got)
|
||||
if len(allow) != 1 || allow[0] != "Skill(tiki)" {
|
||||
t.Errorf("unexpected entries: %v", allow)
|
||||
}
|
||||
}
|
||||
|
||||
func TestEnsureSkillPermissions_MalformedJSON(t *testing.T) {
|
||||
t.Chdir(t.TempDir())
|
||||
|
||||
if err := os.MkdirAll(filepath.Dir(settingsFile), 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
//nolint:gosec // G306: 0644 is appropriate for test fixture files
|
||||
if err := os.WriteFile(settingsFile, []byte("{invalid json"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
err := ensureSkillPermissions(settingsFile, []string{"tiki"})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for malformed JSON")
|
||||
}
|
||||
}
|
||||
|
||||
// helpers
|
||||
|
||||
func readSettings(t *testing.T) map[string]any {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(settingsFile)
|
||||
if err != nil {
|
||||
t.Fatalf("reading %s: %v", settingsFile, err)
|
||||
}
|
||||
var m map[string]any
|
||||
if err := json.Unmarshal(data, &m); err != nil {
|
||||
t.Fatalf("parsing %s: %v", settingsFile, err)
|
||||
}
|
||||
return m
|
||||
}
|
||||
|
||||
func getAllow(t *testing.T, settings map[string]any) []string {
|
||||
t.Helper()
|
||||
perms, _ := settings["permissions"].(map[string]any)
|
||||
if perms == nil {
|
||||
t.Fatal("missing permissions key")
|
||||
}
|
||||
rawAllow, _ := perms["allow"].([]any)
|
||||
if rawAllow == nil {
|
||||
t.Fatal("missing allow key")
|
||||
}
|
||||
allow := make([]string, len(rawAllow))
|
||||
for i, v := range rawAllow {
|
||||
s, ok := v.(string)
|
||||
if !ok {
|
||||
t.Fatalf("allow[%d] is not a string: %v", i, v)
|
||||
}
|
||||
allow[i] = s
|
||||
}
|
||||
return allow
|
||||
}
|
||||
|
||||
func writeSettings(t *testing.T, settings map[string]any) {
|
||||
t.Helper()
|
||||
if err := os.MkdirAll(filepath.Dir(settingsFile), 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
data, err := json.MarshalIndent(settings, "", " ")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
//nolint:gosec // G306: 0644 is appropriate for test fixture files
|
||||
if err := os.WriteFile(settingsFile, append(data, '\n'), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
121
config/install.go
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"time"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
const (
|
||||
httpTimeout = 15 * time.Second
|
||||
maxResponseSize = 1 << 20 // 1 MiB
|
||||
)
|
||||
|
||||
var DefaultWorkflowBaseURL = "https://raw.githubusercontent.com/boolean-maybe/tiki/main"
|
||||
|
||||
var validWorkflowName = regexp.MustCompile(`^[a-zA-Z0-9]([a-zA-Z0-9._-]*[a-zA-Z0-9])?$`)
|
||||
|
||||
// InstallResult describes the outcome for a single installed file.
|
||||
type InstallResult struct {
|
||||
Path string
|
||||
Changed bool
|
||||
}
|
||||
|
||||
var installFiles = []string{
|
||||
defaultWorkflowFilename,
|
||||
templateFilename,
|
||||
}
|
||||
|
||||
// InstallWorkflow fetches a named workflow from baseURL and writes its files
|
||||
// to the directory for the given scope, overwriting existing files.
|
||||
// baseURL is the root URL before "/workflows" (e.g. "https://raw.githubusercontent.com/boolean-maybe/tiki/main").
|
||||
func InstallWorkflow(name string, scope Scope, baseURL string) ([]InstallResult, error) {
|
||||
dir, err := resolveDir(scope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
fetched := make(map[string]string, len(installFiles))
|
||||
for _, filename := range installFiles {
|
||||
content, err := fetchWorkflowFile(baseURL, name, filename)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("fetch %s/%s: %w", name, filename, err)
|
||||
}
|
||||
fetched[filename] = string(content)
|
||||
}
|
||||
|
||||
var results []InstallResult
|
||||
for _, filename := range installFiles {
|
||||
path := filepath.Join(dir, filename)
|
||||
changed, err := writeFileIfChanged(path, fetched[filename])
|
||||
if err != nil {
|
||||
return results, fmt.Errorf("write %s: %w", filename, err)
|
||||
}
|
||||
results = append(results, InstallResult{Path: path, Changed: changed})
|
||||
}
|
||||
|
||||
return results, nil
|
||||
}
|
||||
|
||||
// DescribeWorkflow fetches the workflow.yaml for name from baseURL and
|
||||
// returns the value of its top-level `description:` field. Returns empty
|
||||
// string if the field is absent.
|
||||
func DescribeWorkflow(name, baseURL string) (string, error) {
|
||||
body, err := fetchWorkflowFile(baseURL, name, defaultWorkflowFilename)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
var wf struct {
|
||||
Description string `yaml:"description"`
|
||||
}
|
||||
if err := yaml.Unmarshal(body, &wf); err != nil {
|
||||
return "", fmt.Errorf("parse %s/workflow.yaml: %w", name, err)
|
||||
}
|
||||
return wf.Description, nil
|
||||
}
|
||||
|
||||
var httpClient = &http.Client{Timeout: httpTimeout}
|
||||
|
||||
// fetchWorkflowFile validates the workflow name and downloads a single file
|
||||
// from baseURL. Returns the raw body bytes.
|
||||
func fetchWorkflowFile(baseURL, name, filename string) ([]byte, error) {
|
||||
if !validWorkflowName.MatchString(name) {
|
||||
return nil, fmt.Errorf("invalid workflow name %q: use letters, digits, hyphens, dots, or underscores", name)
|
||||
}
|
||||
|
||||
url := fmt.Sprintf("%s/workflows/%s/%s", baseURL, name, filename)
|
||||
|
||||
ctx, cancel := context.WithTimeout(context.Background(), httpTimeout)
|
||||
defer cancel()
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("request failed: %w", err)
|
||||
}
|
||||
defer func() { _ = resp.Body.Close() }()
|
||||
|
||||
if resp.StatusCode == http.StatusNotFound {
|
||||
return nil, fmt.Errorf("workflow %q not found (%s)", name, filename)
|
||||
}
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return nil, fmt.Errorf("unexpected HTTP %d for %s", resp.StatusCode, url)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(io.LimitReader(resp.Body, maxResponseSize))
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read response: %w", err)
|
||||
}
|
||||
|
||||
return body, nil
|
||||
}
|
||||
213
config/install_test.go
Normal file
|
|
@ -0,0 +1,213 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestInstallWorkflow_Success(t *testing.T) {
|
||||
tikiDir := setupResetTest(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
switch r.URL.Path {
|
||||
case "/workflows/sprint/workflow.yaml":
|
||||
_, _ = w.Write([]byte("statuses:\n - key: todo\n"))
|
||||
case "/workflows/sprint/new.md":
|
||||
_, _ = w.Write([]byte("---\ntitle:\n---\n"))
|
||||
default:
|
||||
http.NotFound(w, r)
|
||||
}
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
results, err := InstallWorkflow("sprint", ScopeGlobal, server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallWorkflow() error = %v", err)
|
||||
}
|
||||
if len(results) != 2 {
|
||||
t.Fatalf("expected 2 results, got %d", len(results))
|
||||
}
|
||||
for _, r := range results {
|
||||
if !r.Changed {
|
||||
t.Errorf("expected %s to be changed on fresh install", r.Path)
|
||||
}
|
||||
}
|
||||
|
||||
got, err := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read workflow.yaml: %v", err)
|
||||
}
|
||||
if string(got) != "statuses:\n - key: todo\n" {
|
||||
t.Errorf("workflow.yaml content = %q", string(got))
|
||||
}
|
||||
|
||||
got, err = os.ReadFile(filepath.Join(tikiDir, "new.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read new.md: %v", err)
|
||||
}
|
||||
if string(got) != "---\ntitle:\n---\n" {
|
||||
t.Errorf("new.md content = %q", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWorkflow_Overwrites(t *testing.T) {
|
||||
tikiDir := setupResetTest(t)
|
||||
|
||||
if err := os.WriteFile(filepath.Join(tikiDir, "workflow.yaml"), []byte("old content"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("new content"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
results, err := InstallWorkflow("sprint", ScopeGlobal, server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("InstallWorkflow() error = %v", err)
|
||||
}
|
||||
for _, r := range results {
|
||||
if !r.Changed {
|
||||
t.Errorf("expected %s to be changed on overwrite", r.Path)
|
||||
}
|
||||
}
|
||||
|
||||
got, _ := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml"))
|
||||
if string(got) != "new content" {
|
||||
t.Errorf("workflow.yaml not overwritten: %q", string(got))
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWorkflow_NotFound(t *testing.T) {
|
||||
_ = setupResetTest(t)
|
||||
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
|
||||
_, err := InstallWorkflow("nonexistent", ScopeGlobal, server.URL)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent workflow, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWorkflow_AlreadyUpToDate(t *testing.T) {
|
||||
_ = setupResetTest(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("same content"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
if _, err := InstallWorkflow("sprint", ScopeGlobal, server.URL); err != nil {
|
||||
t.Fatalf("first install: %v", err)
|
||||
}
|
||||
|
||||
results, err := InstallWorkflow("sprint", ScopeGlobal, server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("second install: %v", err)
|
||||
}
|
||||
for _, r := range results {
|
||||
if r.Changed {
|
||||
t.Errorf("expected %s to be unchanged on repeat install", r.Path)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWorkflow_InvalidName(t *testing.T) {
|
||||
_ = setupResetTest(t)
|
||||
|
||||
for _, name := range []string{"../../etc", "a b", "", "foo/bar", "-dash", "dot."} {
|
||||
_, err := InstallWorkflow(name, ScopeGlobal, "http://unused")
|
||||
if err == nil {
|
||||
t.Errorf("expected error for name %q, got nil", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeWorkflow_Success(t *testing.T) {
|
||||
_ = setupResetTest(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path != "/workflows/sprint/workflow.yaml" {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
_, _ = w.Write([]byte("description: |\n Sprint workflow.\n Two-week cycles.\nstatuses:\n - key: todo\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
desc, err := DescribeWorkflow("sprint", server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("DescribeWorkflow() error = %v", err)
|
||||
}
|
||||
want := "Sprint workflow.\nTwo-week cycles.\n"
|
||||
if desc != want {
|
||||
t.Errorf("description = %q, want %q", desc, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeWorkflow_NoDescriptionField(t *testing.T) {
|
||||
_ = setupResetTest(t)
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {
|
||||
_, _ = w.Write([]byte("statuses:\n - key: todo\n"))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
desc, err := DescribeWorkflow("sprint", server.URL)
|
||||
if err != nil {
|
||||
t.Fatalf("DescribeWorkflow() error = %v", err)
|
||||
}
|
||||
if desc != "" {
|
||||
t.Errorf("description = %q, want empty", desc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeWorkflow_NotFound(t *testing.T) {
|
||||
_ = setupResetTest(t)
|
||||
|
||||
server := httptest.NewServer(http.NotFoundHandler())
|
||||
defer server.Close()
|
||||
|
||||
_, err := DescribeWorkflow("nonexistent", server.URL)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for nonexistent workflow, got nil")
|
||||
}
|
||||
}
|
||||
|
||||
func TestDescribeWorkflow_InvalidName(t *testing.T) {
|
||||
for _, name := range []string{"../../etc", "a b", "", "foo/bar", "-dash", "dot."} {
|
||||
if _, err := DescribeWorkflow(name, "http://unused"); err == nil {
|
||||
t.Errorf("expected error for name %q, got nil", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInstallWorkflow_AtomicFetch(t *testing.T) {
|
||||
tikiDir := setupResetTest(t)
|
||||
|
||||
callCount := 0
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
if callCount == 1 {
|
||||
_, _ = w.Write([]byte("ok"))
|
||||
return
|
||||
}
|
||||
http.NotFound(w, r)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
_, err := InstallWorkflow("partial", ScopeGlobal, server.URL)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for partial failure, got nil")
|
||||
}
|
||||
|
||||
for _, filename := range []string{"workflow.yaml", "new.md"} {
|
||||
if _, statErr := os.Stat(filepath.Join(tikiDir, filename)); !os.IsNotExist(statErr) {
|
||||
t.Errorf("%s should not exist after fetch failure", filename)
|
||||
}
|
||||
}
|
||||
}
|
||||
226
config/loader.go
|
|
@ -1,6 +1,6 @@
|
|||
package config
|
||||
|
||||
// Viper configuration loader: reads config.yaml from the binary's directory
|
||||
// Viper configuration loader: merges config.yaml from multiple locations
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
|
@ -10,14 +10,19 @@ import (
|
|||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/gdamore/tcell/v2"
|
||||
"github.com/muesli/termenv"
|
||||
"github.com/spf13/pflag"
|
||||
"github.com/spf13/viper"
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// lastConfigFile tracks the most recently merged config file path for saveConfig().
|
||||
var lastConfigFile string
|
||||
|
||||
// Config holds all application configuration loaded from config.yaml
|
||||
type Config struct {
|
||||
Version string `mapstructure:"version"`
|
||||
|
||||
// Logging configuration
|
||||
Logging struct {
|
||||
Level string `mapstructure:"level"` // "debug", "info", "warn", "error"
|
||||
|
|
@ -41,7 +46,7 @@ type Config struct {
|
|||
|
||||
// Appearance configuration
|
||||
Appearance struct {
|
||||
Theme string `mapstructure:"theme"` // "dark", "light", "auto"
|
||||
Theme string `mapstructure:"theme"` // "auto", "dark", "light", or a named theme (see ThemeNames())
|
||||
GradientThreshold int `mapstructure:"gradientThreshold"` // Minimum color count for gradients (16, 256, 16777216)
|
||||
CodeBlock struct {
|
||||
Theme string `mapstructure:"theme"` // chroma syntax theme (e.g. "dracula", "monokai")
|
||||
|
|
@ -49,44 +54,45 @@ type Config struct {
|
|||
Border string `mapstructure:"border"` // hex "#6272a4" or ANSI "244"
|
||||
} `mapstructure:"codeBlock"`
|
||||
} `mapstructure:"appearance"`
|
||||
|
||||
// AI agent configuration — valid keys defined in aitools.go via AITools()
|
||||
AI struct {
|
||||
Agent string `mapstructure:"agent"`
|
||||
} `mapstructure:"ai"`
|
||||
}
|
||||
|
||||
var appConfig *Config
|
||||
|
||||
// LoadConfig loads configuration from config.yaml
|
||||
// Priority order (first found wins): project config → user config → current directory (dev)
|
||||
// If config.yaml doesn't exist, it uses default values
|
||||
// LoadConfig loads configuration by merging config.yaml from multiple locations.
|
||||
// Files are merged in precedence order (user → project → cwd); later files override
|
||||
// earlier ones. Missing values fall back to built-in defaults.
|
||||
func LoadConfig() (*Config, error) {
|
||||
// Reset viper to clear any previous configuration
|
||||
viper.Reset()
|
||||
|
||||
// Configure viper to look for config.yaml
|
||||
// Viper uses first-found priority, so project config takes precedence
|
||||
viper.SetConfigName("config")
|
||||
viper.SetConfigType("yaml")
|
||||
|
||||
// Add search paths in priority order (first added = highest priority)
|
||||
projectConfigDir := filepath.Dir(GetProjectConfigFile())
|
||||
viper.AddConfigPath(projectConfigDir) // Project config (highest priority)
|
||||
viper.AddConfigPath(GetConfigDir()) // User config
|
||||
viper.AddConfigPath(".") // Current directory (development)
|
||||
|
||||
// Set default values
|
||||
setDefaults()
|
||||
lastConfigFile = ""
|
||||
|
||||
// Read the config file (if it exists)
|
||||
if err := viper.ReadInConfig(); err != nil {
|
||||
if _, ok := err.(viper.ConfigFileNotFoundError); ok {
|
||||
slog.Debug("no config.yaml found, using defaults")
|
||||
} else {
|
||||
slog.Error("error reading config file", "error", err)
|
||||
return nil, err
|
||||
// merge config files in precedence order (first = base, last = highest priority)
|
||||
for _, path := range findConfigFiles() {
|
||||
f, err := os.Open(path)
|
||||
if err != nil {
|
||||
slog.Warn("failed to open config file", "path", path, "error", err)
|
||||
continue
|
||||
}
|
||||
} else {
|
||||
slog.Debug("loaded configuration", "file", viper.ConfigFileUsed())
|
||||
mergeErr := viper.MergeConfig(f)
|
||||
_ = f.Close()
|
||||
if mergeErr != nil {
|
||||
return nil, fmt.Errorf("merging config from %s: %w", path, mergeErr)
|
||||
}
|
||||
lastConfigFile = path
|
||||
slog.Debug("merged configuration", "file", path)
|
||||
}
|
||||
|
||||
// Allow environment variables to override config file
|
||||
if lastConfigFile == "" {
|
||||
slog.Debug("no config.yaml found, using defaults")
|
||||
}
|
||||
|
||||
// environment variables and flags override everything
|
||||
viper.SetEnvPrefix("TIKI")
|
||||
viper.SetEnvKeyReplacer(strings.NewReplacer(".", "_"))
|
||||
viper.AutomaticEnv()
|
||||
|
|
@ -95,17 +101,47 @@ func LoadConfig() (*Config, error) {
|
|||
slog.Warn("failed to bind command line flags", "error", err)
|
||||
}
|
||||
|
||||
// Unmarshal config into struct
|
||||
cfg := &Config{}
|
||||
if err := viper.Unmarshal(cfg); err != nil {
|
||||
slog.Error("failed to unmarshal config", "error", err)
|
||||
return nil, err
|
||||
return nil, fmt.Errorf("unmarshaling config: %w", err)
|
||||
}
|
||||
|
||||
appConfig = cfg
|
||||
return cfg, nil
|
||||
}
|
||||
|
||||
// findConfigFiles returns existing config.yaml paths in merge order
|
||||
// (user config → project → cwd). Deduplicates by absolute path.
|
||||
func findConfigFiles() []string {
|
||||
pm := mustGetPathManager()
|
||||
|
||||
candidates := []string{
|
||||
pm.ConfigFile(), // user config (base)
|
||||
filepath.Join(pm.ProjectConfigDir(), "config.yaml"), // project override
|
||||
filepath.Join(".", "config.yaml"), // cwd override (highest)
|
||||
}
|
||||
|
||||
var result []string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, path := range candidates {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
abs = path
|
||||
}
|
||||
if seen[abs] {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
continue
|
||||
}
|
||||
seen[abs] = true
|
||||
result = append(result, path)
|
||||
}
|
||||
|
||||
return result
|
||||
}
|
||||
|
||||
// setDefaults sets default configuration values
|
||||
func setDefaults() {
|
||||
// Logging defaults
|
||||
|
|
@ -121,7 +157,7 @@ func setDefaults() {
|
|||
// Appearance defaults
|
||||
viper.SetDefault("appearance.theme", "auto")
|
||||
viper.SetDefault("appearance.gradientThreshold", 256)
|
||||
viper.SetDefault("appearance.codeBlock.theme", "nord")
|
||||
// code block theme resolved dynamically in GetCodeBlockTheme()
|
||||
}
|
||||
|
||||
// bindFlags binds supported command line flags to viper so they can override config values.
|
||||
|
|
@ -156,19 +192,44 @@ func GetConfig() *Config {
|
|||
return appConfig
|
||||
}
|
||||
|
||||
// viewsFileData represents the views section of workflow.yaml for read-modify-write.
|
||||
type viewsFileData struct {
|
||||
Actions []map[string]interface{} `yaml:"actions,omitempty"`
|
||||
Plugins []map[string]interface{} `yaml:"plugins"`
|
||||
}
|
||||
|
||||
// workflowFileData represents the YAML structure of workflow.yaml for read-modify-write.
|
||||
// kept in config package to avoid import cycle with plugin package.
|
||||
// all top-level sections must be listed here to survive round-trip serialization.
|
||||
type workflowFileData struct {
|
||||
Statuses []map[string]interface{} `yaml:"statuses,omitempty"`
|
||||
Plugins []map[string]interface{} `yaml:"views"`
|
||||
Version string `yaml:"version,omitempty"`
|
||||
Description string `yaml:"description,omitempty"`
|
||||
Statuses []map[string]interface{} `yaml:"statuses,omitempty"`
|
||||
Types []map[string]interface{} `yaml:"types,omitempty"`
|
||||
Views viewsFileData `yaml:"views,omitempty"`
|
||||
Triggers []map[string]interface{} `yaml:"triggers,omitempty"`
|
||||
Fields []map[string]interface{} `yaml:"fields,omitempty"`
|
||||
}
|
||||
|
||||
// readWorkflowFile reads and unmarshals workflow.yaml from the given path.
|
||||
// Handles both old list format (views: [...]) and new map format (views: {plugins: [...]}).
|
||||
func readWorkflowFile(path string) (*workflowFileData, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading workflow.yaml: %w", err)
|
||||
}
|
||||
|
||||
// convert legacy views list format to map format before unmarshaling
|
||||
var raw map[string]interface{}
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
return nil, fmt.Errorf("parsing workflow.yaml: %w", err)
|
||||
}
|
||||
ConvertViewsListToMap(raw)
|
||||
data, err = yaml.Marshal(raw)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("re-marshaling workflow.yaml: %w", err)
|
||||
}
|
||||
|
||||
var wf workflowFileData
|
||||
if err := yaml.Unmarshal(data, &wf); err != nil {
|
||||
return nil, fmt.Errorf("parsing workflow.yaml: %w", err)
|
||||
|
|
@ -176,6 +237,23 @@ func readWorkflowFile(path string) (*workflowFileData, error) {
|
|||
return &wf, nil
|
||||
}
|
||||
|
||||
// ConvertViewsListToMap converts old views list format to new map format in-place.
|
||||
// Old: views: [{name: Kanban, ...}] → New: views: {plugins: [{name: Kanban, ...}]}
|
||||
func ConvertViewsListToMap(raw map[string]interface{}) {
|
||||
views, ok := raw["views"]
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
if _, isMap := views.(map[string]interface{}); isMap {
|
||||
return
|
||||
}
|
||||
if list, isList := views.([]interface{}); isList {
|
||||
raw["views"] = map[string]interface{}{
|
||||
"plugins": list,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// writeWorkflowFile marshals and writes workflow.yaml to the given path.
|
||||
func writeWorkflowFile(path string, wf *workflowFileData) error {
|
||||
data, err := yaml.Marshal(wf)
|
||||
|
|
@ -214,7 +292,7 @@ func getPluginViewModeFromWorkflow(pluginName string, defaultValue string) strin
|
|||
return defaultValue
|
||||
}
|
||||
|
||||
for _, p := range wf.Plugins {
|
||||
for _, p := range wf.Views.Plugins {
|
||||
if name, ok := p["name"].(string); ok && name == pluginName {
|
||||
if view, ok := p["view"].(string); ok && view != "" {
|
||||
return view
|
||||
|
|
@ -243,13 +321,13 @@ func SavePluginViewMode(pluginName string, configIndex int, viewMode string) err
|
|||
wf = &workflowFileData{}
|
||||
}
|
||||
|
||||
if configIndex >= 0 && configIndex < len(wf.Plugins) {
|
||||
if configIndex >= 0 && configIndex < len(wf.Views.Plugins) {
|
||||
// update existing entry by index
|
||||
wf.Plugins[configIndex]["view"] = viewMode
|
||||
wf.Views.Plugins[configIndex]["view"] = viewMode
|
||||
} else {
|
||||
// find by name or create new entry
|
||||
existingIndex := -1
|
||||
for i, p := range wf.Plugins {
|
||||
for i, p := range wf.Views.Plugins {
|
||||
if name, ok := p["name"].(string); ok && name == pluginName {
|
||||
existingIndex = i
|
||||
break
|
||||
|
|
@ -257,13 +335,13 @@ func SavePluginViewMode(pluginName string, configIndex int, viewMode string) err
|
|||
}
|
||||
|
||||
if existingIndex >= 0 {
|
||||
wf.Plugins[existingIndex]["view"] = viewMode
|
||||
wf.Views.Plugins[existingIndex]["view"] = viewMode
|
||||
} else {
|
||||
newEntry := map[string]interface{}{
|
||||
"name": pluginName,
|
||||
"view": viewMode,
|
||||
}
|
||||
wf.Plugins = append(wf.Plugins, newEntry)
|
||||
wf.Views.Plugins = append(wf.Views.Plugins, newEntry)
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -300,14 +378,13 @@ func GetMaxImageRows() int {
|
|||
return rows
|
||||
}
|
||||
|
||||
// saveConfig writes the current viper configuration to config.yaml
|
||||
// saveConfig writes the current viper configuration to config.yaml.
|
||||
// Saves to the last merged config file, or the user config dir if none was loaded.
|
||||
func saveConfig() error {
|
||||
configFile := viper.ConfigFileUsed()
|
||||
configFile := lastConfigFile
|
||||
if configFile == "" {
|
||||
// If no config file was loaded, save to user config directory
|
||||
configFile = GetConfigFile()
|
||||
}
|
||||
|
||||
return viper.WriteConfigAs(configFile)
|
||||
}
|
||||
|
||||
|
|
@ -320,42 +397,28 @@ func GetTheme() string {
|
|||
return theme
|
||||
}
|
||||
|
||||
// GetEffectiveTheme resolves "auto" to actual theme based on terminal detection
|
||||
var cachedEffectiveTheme string
|
||||
|
||||
// GetEffectiveTheme resolves "auto" to actual theme based on terminal detection.
|
||||
// Uses termenv OSC 11 query to detect the terminal's actual background color,
|
||||
// falling back to COLORFGBG env var, then dark.
|
||||
// Result is cached — safe to call after tview takes over the terminal.
|
||||
func GetEffectiveTheme() string {
|
||||
if cachedEffectiveTheme != "" {
|
||||
return cachedEffectiveTheme
|
||||
}
|
||||
theme := GetTheme()
|
||||
if theme != "auto" {
|
||||
cachedEffectiveTheme = theme
|
||||
return theme
|
||||
}
|
||||
// Detect via COLORFGBG env var (format: "fg;bg")
|
||||
if colorfgbg := os.Getenv("COLORFGBG"); colorfgbg != "" {
|
||||
parts := strings.Split(colorfgbg, ";")
|
||||
if len(parts) >= 2 {
|
||||
bg := parts[len(parts)-1]
|
||||
// 0-7 = dark colors, 8+ = light colors
|
||||
if bg >= "8" {
|
||||
return "light"
|
||||
}
|
||||
}
|
||||
output := termenv.NewOutput(os.Stdout)
|
||||
if output.HasDarkBackground() {
|
||||
cachedEffectiveTheme = "dark"
|
||||
} else {
|
||||
cachedEffectiveTheme = "light"
|
||||
}
|
||||
return "dark" // default fallback
|
||||
}
|
||||
|
||||
// GetContentBackgroundColor returns the background color for markdown content areas
|
||||
// Dark theme needs black background for light text; light theme uses terminal default
|
||||
func GetContentBackgroundColor() tcell.Color {
|
||||
if GetEffectiveTheme() == "dark" {
|
||||
return tcell.ColorBlack
|
||||
}
|
||||
return tcell.ColorDefault
|
||||
}
|
||||
|
||||
// GetContentTextColor returns the appropriate text color for content areas
|
||||
// Dark theme uses white text; light theme uses black text
|
||||
func GetContentTextColor() tcell.Color {
|
||||
if GetEffectiveTheme() == "dark" {
|
||||
return tcell.ColorWhite
|
||||
}
|
||||
return tcell.ColorBlack
|
||||
return cachedEffectiveTheme
|
||||
}
|
||||
|
||||
// GetGradientThreshold returns the minimum color count required for gradients
|
||||
|
|
@ -368,9 +431,13 @@ func GetGradientThreshold() int {
|
|||
return threshold
|
||||
}
|
||||
|
||||
// GetCodeBlockTheme returns the chroma syntax highlighting theme for code blocks
|
||||
// GetCodeBlockTheme returns the chroma syntax highlighting theme for code blocks.
|
||||
// Defaults to the theme registry's chroma mapping when not explicitly configured.
|
||||
func GetCodeBlockTheme() string {
|
||||
return viper.GetString("appearance.codeBlock.theme")
|
||||
if t := viper.GetString("appearance.codeBlock.theme"); t != "" {
|
||||
return t
|
||||
}
|
||||
return ChromaThemeForEffective()
|
||||
}
|
||||
|
||||
// GetCodeBlockBackground returns the background color for code blocks
|
||||
|
|
@ -382,3 +449,8 @@ func GetCodeBlockBackground() string {
|
|||
func GetCodeBlockBorder() string {
|
||||
return viper.GetString("appearance.codeBlock.border")
|
||||
}
|
||||
|
||||
// GetAIAgent returns the configured AI agent tool name, or empty string if not configured
|
||||
func GetAIAgent() string {
|
||||
return viper.GetString("ai.agent")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,8 @@ import (
|
|||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
func TestLoadConfig(t *testing.T) {
|
||||
|
|
@ -180,9 +182,13 @@ func TestLoadConfigCodeBlockDefaults(t *testing.T) {
|
|||
t.Fatalf("LoadConfig failed: %v", err)
|
||||
}
|
||||
|
||||
// codeBlock.theme defaults to "nord"; background and border have no defaults
|
||||
if cfg.Appearance.CodeBlock.Theme != "nord" {
|
||||
t.Errorf("expected default codeBlock.theme 'nord', got '%s'", cfg.Appearance.CodeBlock.Theme)
|
||||
// codeBlock.theme is empty in config (resolved dynamically by GetCodeBlockTheme)
|
||||
if cfg.Appearance.CodeBlock.Theme != "" {
|
||||
t.Errorf("expected empty default codeBlock.theme, got '%s'", cfg.Appearance.CodeBlock.Theme)
|
||||
}
|
||||
// GetCodeBlockTheme resolves to "nord" for dark (default) theme
|
||||
if got := GetCodeBlockTheme(); got != "nord" {
|
||||
t.Errorf("expected GetCodeBlockTheme() 'nord' for dark theme, got '%s'", got)
|
||||
}
|
||||
if cfg.Appearance.CodeBlock.Background != "" {
|
||||
t.Errorf("expected empty default codeBlock.background, got '%s'", cfg.Appearance.CodeBlock.Background)
|
||||
|
|
@ -192,6 +198,355 @@ func TestLoadConfigCodeBlockDefaults(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_ProjectOverridesUser(t *testing.T) {
|
||||
// set up user config dir with base settings
|
||||
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)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(userTikiDir, "config.yaml"), []byte(`
|
||||
logging:
|
||||
level: error
|
||||
header:
|
||||
visible: false
|
||||
`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// set up project dir with override for logging only
|
||||
projectDir := t.TempDir()
|
||||
docDir := filepath.Join(projectDir, ".doc")
|
||||
if err := os.MkdirAll(docDir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(docDir, "config.yaml"), []byte(`
|
||||
logging:
|
||||
level: debug
|
||||
`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// use a clean cwd with no config
|
||||
cwdDir := t.TempDir()
|
||||
originalDir, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(originalDir) }()
|
||||
_ = os.Chdir(cwdDir)
|
||||
|
||||
appConfig = nil
|
||||
ResetPathManager()
|
||||
// override project root to our test project dir
|
||||
pm := mustGetPathManager()
|
||||
pm.projectRoot = projectDir
|
||||
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig failed: %v", err)
|
||||
}
|
||||
|
||||
// project override wins
|
||||
if cfg.Logging.Level != "debug" {
|
||||
t.Errorf("expected logging.level 'debug' from project, got %q", cfg.Logging.Level)
|
||||
}
|
||||
// user setting preserved for fields not in project config
|
||||
if cfg.Header.Visible != false {
|
||||
t.Errorf("expected header.visible false from user config, got %v", cfg.Header.Visible)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_CwdOverridesProject(t *testing.T) {
|
||||
// user config
|
||||
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)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(userTikiDir, "config.yaml"), []byte(`
|
||||
logging:
|
||||
level: error
|
||||
`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// project config
|
||||
projectDir := t.TempDir()
|
||||
docDir := filepath.Join(projectDir, ".doc")
|
||||
if err := os.MkdirAll(docDir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(docDir, "config.yaml"), []byte(`
|
||||
logging:
|
||||
level: warn
|
||||
`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// cwd config (highest priority)
|
||||
cwdDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(cwdDir, "config.yaml"), []byte(`
|
||||
logging:
|
||||
level: debug
|
||||
`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
originalDir, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(originalDir) }()
|
||||
_ = os.Chdir(cwdDir)
|
||||
|
||||
appConfig = nil
|
||||
ResetPathManager()
|
||||
pm := mustGetPathManager()
|
||||
pm.projectRoot = projectDir
|
||||
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig failed: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Logging.Level != "debug" {
|
||||
t.Errorf("expected logging.level 'debug' from cwd, got %q", cfg.Logging.Level)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfig_UserOnlyFallback(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(userTikiDir, "config.yaml"), []byte(`
|
||||
logging:
|
||||
level: info
|
||||
header:
|
||||
visible: false
|
||||
`), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// cwd with no config, project with no config
|
||||
cwdDir := t.TempDir()
|
||||
originalDir, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(originalDir) }()
|
||||
_ = os.Chdir(cwdDir)
|
||||
|
||||
appConfig = nil
|
||||
ResetPathManager()
|
||||
pm := mustGetPathManager()
|
||||
pm.projectRoot = cwdDir // no .doc/ dir here
|
||||
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig failed: %v", err)
|
||||
}
|
||||
|
||||
if cfg.Logging.Level != "info" {
|
||||
t.Errorf("expected logging.level 'info' from user config, got %q", cfg.Logging.Level)
|
||||
}
|
||||
if cfg.Header.Visible != false {
|
||||
t.Errorf("expected header.visible false from user config, got %v", cfg.Header.Visible)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigAIAgent(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
configPath := filepath.Join(tmpDir, "config.yaml")
|
||||
|
||||
configContent := `
|
||||
ai:
|
||||
agent: claude
|
||||
`
|
||||
if err := os.WriteFile(configPath, []byte(configContent), 0644); err != nil {
|
||||
t.Fatalf("failed to create test config: %v", err)
|
||||
}
|
||||
|
||||
originalDir, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(originalDir) }()
|
||||
_ = os.Chdir(tmpDir)
|
||||
|
||||
t.Setenv("XDG_CONFIG_HOME", tmpDir)
|
||||
appConfig = nil
|
||||
ResetPathManager()
|
||||
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig failed: %v", err)
|
||||
}
|
||||
|
||||
if cfg.AI.Agent != "claude" {
|
||||
t.Errorf("expected ai.agent 'claude', got '%s'", cfg.AI.Agent)
|
||||
}
|
||||
if got := GetAIAgent(); got != "claude" {
|
||||
t.Errorf("GetAIAgent() = '%s', want 'claude'", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadConfigAIAgentDefault(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
originalDir, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(originalDir) }()
|
||||
_ = os.Chdir(tmpDir)
|
||||
|
||||
appConfig = nil
|
||||
|
||||
cfg, err := LoadConfig()
|
||||
if err != nil {
|
||||
t.Fatalf("LoadConfig failed: %v", err)
|
||||
}
|
||||
|
||||
if cfg.AI.Agent != "" {
|
||||
t.Errorf("expected empty ai.agent by default, got '%s'", cfg.AI.Agent)
|
||||
}
|
||||
if got := GetAIAgent(); got != "" {
|
||||
t.Errorf("GetAIAgent() = '%s', want ''", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavePluginViewMode_PreservesTriggers(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// write a workflow.yaml that includes triggers
|
||||
workflowContent := `statuses:
|
||||
- key: backlog
|
||||
label: Backlog
|
||||
default: true
|
||||
- key: done
|
||||
label: Done
|
||||
done: true
|
||||
views:
|
||||
- name: Kanban
|
||||
default: true
|
||||
key: "F1"
|
||||
lanes:
|
||||
- name: Done
|
||||
filter: status = 'done'
|
||||
action: status = 'done'
|
||||
sort: Priority, CreatedAt
|
||||
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"
|
||||
`
|
||||
workflowPath := filepath.Join(tmpDir, "workflow.yaml")
|
||||
if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// simulate what SavePluginViewMode does: read → modify → write
|
||||
wf, err := readWorkflowFile(workflowPath)
|
||||
if err != nil {
|
||||
t.Fatalf("readWorkflowFile failed: %v", err)
|
||||
}
|
||||
|
||||
// modify a view mode (same as SavePluginViewMode logic)
|
||||
if len(wf.Views.Plugins) > 0 {
|
||||
wf.Views.Plugins[0]["view"] = "compact"
|
||||
}
|
||||
|
||||
if err := writeWorkflowFile(workflowPath, wf); err != nil {
|
||||
t.Fatalf("writeWorkflowFile failed: %v", err)
|
||||
}
|
||||
|
||||
// verify triggers survived the round-trip by reading raw YAML
|
||||
rawData, err := os.ReadFile(workflowPath)
|
||||
if err != nil {
|
||||
t.Fatalf("reading workflow.yaml after write: %v", err)
|
||||
}
|
||||
|
||||
var raw map[string]interface{}
|
||||
if err := yaml.Unmarshal(rawData, &raw); err != nil {
|
||||
t.Fatalf("parsing raw YAML: %v", err)
|
||||
}
|
||||
triggers, ok := raw["triggers"]
|
||||
if !ok {
|
||||
t.Fatal("triggers section missing after round-trip write")
|
||||
}
|
||||
triggerList, ok := triggers.([]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("triggers is not a list, got %T", triggers)
|
||||
}
|
||||
if len(triggerList) != 2 {
|
||||
t.Fatalf("expected 2 triggers after round-trip, got %d", len(triggerList))
|
||||
}
|
||||
|
||||
// also verify via typed struct
|
||||
wf2, err := readWorkflowFile(workflowPath)
|
||||
if err != nil {
|
||||
t.Fatalf("readWorkflowFile after write failed: %v", err)
|
||||
}
|
||||
if len(wf2.Triggers) != 2 {
|
||||
t.Fatalf("expected 2 triggers in struct after round-trip, got %d", len(wf2.Triggers))
|
||||
}
|
||||
desc0, _ := wf2.Triggers[0]["description"].(string)
|
||||
if desc0 != "block completion with open dependencies" {
|
||||
t.Errorf("trigger[0] description = %q, want %q", desc0, "block completion with open dependencies")
|
||||
}
|
||||
}
|
||||
|
||||
func TestSavePluginViewMode_PreservesDescription(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
workflowContent := `description: |
|
||||
Release workflow. Coordinate feature rollout through
|
||||
Planned → Building → Staging → Canary → Released.
|
||||
statuses:
|
||||
- key: backlog
|
||||
label: Backlog
|
||||
default: true
|
||||
- key: done
|
||||
label: Done
|
||||
done: true
|
||||
views:
|
||||
- name: Kanban
|
||||
default: true
|
||||
key: "F1"
|
||||
lanes:
|
||||
- name: Done
|
||||
filter: status = 'done'
|
||||
action: status = 'done'
|
||||
sort: Priority, CreatedAt
|
||||
`
|
||||
workflowPath := filepath.Join(tmpDir, "workflow.yaml")
|
||||
if err := os.WriteFile(workflowPath, []byte(workflowContent), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
wf, err := readWorkflowFile(workflowPath)
|
||||
if err != nil {
|
||||
t.Fatalf("readWorkflowFile failed: %v", err)
|
||||
}
|
||||
wantDesc := "Release workflow. Coordinate feature rollout through\nPlanned → Building → Staging → Canary → Released.\n"
|
||||
if wf.Description != wantDesc {
|
||||
t.Errorf("description after read = %q, want %q", wf.Description, wantDesc)
|
||||
}
|
||||
|
||||
if len(wf.Views.Plugins) > 0 {
|
||||
wf.Views.Plugins[0]["view"] = "compact"
|
||||
}
|
||||
if err := writeWorkflowFile(workflowPath, wf); err != nil {
|
||||
t.Fatalf("writeWorkflowFile failed: %v", err)
|
||||
}
|
||||
|
||||
wf2, err := readWorkflowFile(workflowPath)
|
||||
if err != nil {
|
||||
t.Fatalf("readWorkflowFile after write failed: %v", err)
|
||||
}
|
||||
if wf2.Description != wantDesc {
|
||||
t.Errorf("description after round-trip = %q, want %q", wf2.Description, wantDesc)
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetConfig(t *testing.T) {
|
||||
// Reset appConfig
|
||||
appConfig = nil
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
---
|
||||
title:
|
||||
type: story
|
||||
status: backlog
|
||||
points: 1
|
||||
priority: 3
|
||||
tags:
|
||||
|
|
|
|||
708
config/palettes.go
Normal file
|
|
@ -0,0 +1,708 @@
|
|||
package config
|
||||
|
||||
// Palette constructors for all built-in and named themes.
|
||||
// Each function returns a Palette with canonical hex values from the theme's specification.
|
||||
|
||||
import (
|
||||
"github.com/gdamore/tcell/v2"
|
||||
)
|
||||
|
||||
// DarkPalette returns the color palette for dark backgrounds.
|
||||
func DarkPalette() Palette {
|
||||
return Palette{
|
||||
HighlightColor: NewColorHex("#ffff00"),
|
||||
TextColor: NewColorHex("#ffffff"),
|
||||
TransparentColor: DefaultColor(),
|
||||
MutedColor: NewColorHex("#686868"),
|
||||
SoftBorderColor: NewColorHex("#686868"),
|
||||
SoftTextColor: NewColorHex("#b4b4b4"),
|
||||
AccentColor: NewColor(tcell.ColorGreen),
|
||||
ValueColor: NewColorHex("#8c92ac"),
|
||||
InfoLabelColor: NewColorHex("#ffa500"),
|
||||
|
||||
SelectionBgColor: NewColorHex("#3a5f8a"),
|
||||
|
||||
AccentBlue: NewColorHex("#5fafff"),
|
||||
SlateColor: NewColorHex("#5f6982"),
|
||||
|
||||
LogoDotColor: NewColorHex("#40e0d0"),
|
||||
LogoShadeColor: NewColorHex("#4682b4"),
|
||||
LogoBorderColor: NewColorHex("#324664"),
|
||||
|
||||
CaptionFallbackGradient: Gradient{
|
||||
Start: [3]int{25, 25, 112},
|
||||
End: [3]int{65, 105, 225},
|
||||
},
|
||||
DeepSkyBlue: NewColorRGB(0, 191, 255),
|
||||
DeepPurple: NewColorRGB(134, 90, 214),
|
||||
|
||||
ContentBackgroundColor: DefaultColor(),
|
||||
|
||||
StatuslineDarkBg: NewColorHex("#2e3440"),
|
||||
StatuslineMidBg: NewColorHex("#3b4252"),
|
||||
StatuslineBorderBg: NewColorHex("#434c5e"),
|
||||
StatuslineText: NewColorHex("#d8dee9"),
|
||||
StatuslineAccent: NewColorHex("#5e81ac"),
|
||||
StatuslineOk: NewColorHex("#a3be8c"),
|
||||
|
||||
CaptionColors: []CaptionColorPair{
|
||||
{Foreground: NewColorHex("#87ceeb"), Background: NewColorHex("#25496a")}, // steel-blue (Kanban signature)
|
||||
{Foreground: NewColorHex("#8cd98c"), Background: NewColorHex("#003300")}, // green
|
||||
{Foreground: NewColorHex("#ffd78c"), Background: NewColorHex("#4d3200")}, // orange
|
||||
{Foreground: NewColorHex("#a9f1ea"), Background: NewColorHex("#13433e")}, // teal
|
||||
{Foreground: NewColorHex("#b7bcc7"), Background: NewColorHex("#1d2027")}, // blue-gray
|
||||
{Foreground: NewColorHex("#b0c4d4"), Background: NewColorHex("#1e2d3a")}, // slate blue
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// LightPalette returns the color palette for light backgrounds.
|
||||
func LightPalette() Palette {
|
||||
return Palette{
|
||||
HighlightColor: NewColorHex("#0055dd"),
|
||||
TextColor: NewColor(tcell.ColorBlack),
|
||||
TransparentColor: DefaultColor(),
|
||||
MutedColor: NewColorHex("#808080"),
|
||||
SoftBorderColor: NewColorHex("#b0b8c8"),
|
||||
SoftTextColor: NewColorHex("#404040"),
|
||||
AccentColor: NewColorHex("#006400"),
|
||||
ValueColor: NewColorHex("#4a4e6a"),
|
||||
InfoLabelColor: NewColorHex("#b85c00"),
|
||||
|
||||
SelectionBgColor: NewColorHex("#b8d4f0"),
|
||||
|
||||
AccentBlue: NewColorHex("#0060c0"),
|
||||
SlateColor: NewColorHex("#7080a0"),
|
||||
|
||||
LogoDotColor: NewColorHex("#20a090"),
|
||||
LogoShadeColor: NewColorHex("#3060a0"),
|
||||
LogoBorderColor: NewColorHex("#6080a0"),
|
||||
|
||||
CaptionFallbackGradient: Gradient{
|
||||
Start: [3]int{100, 140, 200},
|
||||
End: [3]int{60, 100, 180},
|
||||
},
|
||||
DeepSkyBlue: NewColorRGB(0, 100, 180),
|
||||
DeepPurple: NewColorRGB(90, 50, 160),
|
||||
|
||||
ContentBackgroundColor: DefaultColor(),
|
||||
|
||||
StatuslineDarkBg: NewColorHex("#eceff4"),
|
||||
StatuslineMidBg: NewColorHex("#e5e9f0"),
|
||||
StatuslineBorderBg: NewColorHex("#d8dee9"),
|
||||
StatuslineText: NewColorHex("#2e3440"),
|
||||
StatuslineAccent: NewColorHex("#5e81ac"),
|
||||
StatuslineOk: NewColorHex("#4c7a5a"),
|
||||
|
||||
CaptionColors: []CaptionColorPair{
|
||||
{Foreground: NewColorHex("#e0f0ff"), Background: NewColorHex("#3a6a90")}, // steel-blue (Kanban signature)
|
||||
{Foreground: NewColorHex("#d2ded6"), Background: NewColorHex("#467153")}, // green (StatuslineOk)
|
||||
{Foreground: NewColorHex("#edd6bf"), Background: NewColorHex("#a45200")}, // orange (InfoLabelColor)
|
||||
{Foreground: NewColorHex("#d2d3da"), Background: NewColorHex("#5a5e80")}, // indigo (ValueColor)
|
||||
{Foreground: NewColorHex("#c7e7e3"), Background: NewColorHex("#1a8174")}, // teal (LogoDotColor)
|
||||
{Foreground: NewColorHex("#dfdfdf"), Background: NewColorHex("#616161")}, // gray (MutedColor)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// DraculaPalette returns the Dracula theme palette.
|
||||
// Ref: https://draculatheme.com/contribute
|
||||
func DraculaPalette() Palette {
|
||||
return Palette{
|
||||
HighlightColor: NewColorHex("#ff79c6"), // pink
|
||||
TextColor: NewColorHex("#f8f8f2"), // foreground
|
||||
TransparentColor: DefaultColor(),
|
||||
MutedColor: NewColorHex("#6272a4"), // comment
|
||||
SoftBorderColor: NewColorHex("#44475a"), // current line
|
||||
SoftTextColor: NewColorHex("#bfbfbf"),
|
||||
AccentColor: NewColorHex("#50fa7b"), // green
|
||||
ValueColor: NewColorHex("#bd93f9"), // purple
|
||||
InfoLabelColor: NewColorHex("#ffb86c"), // orange
|
||||
|
||||
SelectionBgColor: NewColorHex("#44475a"),
|
||||
|
||||
AccentBlue: NewColorHex("#8be9fd"), // cyan
|
||||
SlateColor: NewColorHex("#6272a4"), // comment
|
||||
|
||||
LogoDotColor: NewColorHex("#8be9fd"),
|
||||
LogoShadeColor: NewColorHex("#bd93f9"),
|
||||
LogoBorderColor: NewColorHex("#44475a"),
|
||||
|
||||
CaptionFallbackGradient: Gradient{
|
||||
Start: [3]int{40, 42, 54},
|
||||
End: [3]int{68, 71, 90},
|
||||
},
|
||||
DeepSkyBlue: NewColorHex("#8be9fd"),
|
||||
DeepPurple: NewColorHex("#bd93f9"),
|
||||
|
||||
ContentBackgroundColor: DefaultColor(),
|
||||
|
||||
StatuslineDarkBg: NewColorHex("#21222c"),
|
||||
StatuslineMidBg: NewColorHex("#282a36"),
|
||||
StatuslineBorderBg: NewColorHex("#44475a"),
|
||||
StatuslineText: NewColorHex("#f8f8f2"),
|
||||
StatuslineAccent: NewColorHex("#bd93f9"),
|
||||
StatuslineOk: NewColorHex("#50fa7b"),
|
||||
|
||||
CaptionColors: []CaptionColorPair{
|
||||
{Foreground: NewColorHex("#cbf5fe"), Background: NewColorHex("#2a464c")}, // cyan
|
||||
{Foreground: NewColorHex("#b0fdc4"), Background: NewColorHex("#184b25")}, // green
|
||||
{Foreground: NewColorHex("#ffdfbd"), Background: NewColorHex("#4d3720")}, // orange
|
||||
{Foreground: NewColorHex("#f9fdcb"), Background: NewColorHex("#484b2a")}, // yellow
|
||||
{Foreground: NewColorHex("#b8c0d6"), Background: NewColorHex("#1d2231")}, // comment
|
||||
{Foreground: NewColorHex("#ffc3e5"), Background: NewColorHex("#4d243b")}, // pink
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// TokyoNightPalette returns the Tokyo Night theme palette.
|
||||
// Ref: https://github.com/folke/tokyonight.nvim
|
||||
func TokyoNightPalette() Palette {
|
||||
return Palette{
|
||||
HighlightColor: NewColorHex("#e0af68"), // yellow
|
||||
TextColor: NewColorHex("#c0caf5"), // foreground
|
||||
TransparentColor: DefaultColor(),
|
||||
MutedColor: NewColorHex("#565f89"), // comment
|
||||
SoftBorderColor: NewColorHex("#3b4261"),
|
||||
SoftTextColor: NewColorHex("#a9b1d6"),
|
||||
AccentColor: NewColorHex("#9ece6a"), // green
|
||||
ValueColor: NewColorHex("#7aa2f7"), // blue
|
||||
InfoLabelColor: NewColorHex("#ff9e64"), // orange
|
||||
|
||||
SelectionBgColor: NewColorHex("#283457"),
|
||||
|
||||
AccentBlue: NewColorHex("#7aa2f7"),
|
||||
SlateColor: NewColorHex("#565f89"),
|
||||
|
||||
LogoDotColor: NewColorHex("#7dcfff"),
|
||||
LogoShadeColor: NewColorHex("#7aa2f7"),
|
||||
LogoBorderColor: NewColorHex("#3b4261"),
|
||||
|
||||
CaptionFallbackGradient: Gradient{
|
||||
Start: [3]int{26, 27, 38},
|
||||
End: [3]int{59, 66, 97},
|
||||
},
|
||||
DeepSkyBlue: NewColorHex("#7dcfff"),
|
||||
DeepPurple: NewColorHex("#bb9af7"),
|
||||
|
||||
ContentBackgroundColor: DefaultColor(),
|
||||
|
||||
StatuslineDarkBg: NewColorHex("#16161e"),
|
||||
StatuslineMidBg: NewColorHex("#1a1b26"),
|
||||
StatuslineBorderBg: NewColorHex("#24283b"),
|
||||
StatuslineText: NewColorHex("#c0caf5"),
|
||||
StatuslineAccent: NewColorHex("#7aa2f7"),
|
||||
StatuslineOk: NewColorHex("#9ece6a"),
|
||||
|
||||
CaptionColors: []CaptionColorPair{
|
||||
{Foreground: NewColorHex("#c5e9ff"), Background: NewColorHex("#263e4d")}, // sky-blue
|
||||
{Foreground: NewColorHex("#d3e9bc"), Background: NewColorHex("#2f3e20")}, // green
|
||||
{Foreground: NewColorHex("#ffd3b9"), Background: NewColorHex("#4d2f1e")}, // orange
|
||||
{Foreground: NewColorHex("#efdbba"), Background: NewColorHex("#44341f")}, // yellow
|
||||
{Foreground: NewColorHex("#b3b7ca"), Background: NewColorHex("#1a1d29")}, // comment
|
||||
{Foreground: NewColorHex("#fbc2cc"), Background: NewColorHex("#4a232a")}, // red
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GruvboxDarkPalette returns the Gruvbox Dark theme palette.
|
||||
// Ref: https://github.com/morhetz/gruvbox
|
||||
func GruvboxDarkPalette() Palette {
|
||||
return Palette{
|
||||
HighlightColor: NewColorHex("#fabd2f"), // yellow
|
||||
TextColor: NewColorHex("#ebdbb2"), // fg
|
||||
TransparentColor: DefaultColor(),
|
||||
MutedColor: NewColorHex("#928374"), // gray
|
||||
SoftBorderColor: NewColorHex("#504945"), // bg2
|
||||
SoftTextColor: NewColorHex("#bdae93"), // fg3
|
||||
AccentColor: NewColorHex("#b8bb26"), // green
|
||||
ValueColor: NewColorHex("#83a598"), // blue
|
||||
InfoLabelColor: NewColorHex("#fe8019"), // orange
|
||||
|
||||
SelectionBgColor: NewColorHex("#504945"),
|
||||
|
||||
AccentBlue: NewColorHex("#83a598"),
|
||||
SlateColor: NewColorHex("#665c54"), // bg3
|
||||
|
||||
LogoDotColor: NewColorHex("#8ec07c"), // aqua
|
||||
LogoShadeColor: NewColorHex("#83a598"),
|
||||
LogoBorderColor: NewColorHex("#3c3836"), // bg1
|
||||
|
||||
CaptionFallbackGradient: Gradient{
|
||||
Start: [3]int{40, 40, 40},
|
||||
End: [3]int{80, 73, 69},
|
||||
},
|
||||
DeepSkyBlue: NewColorHex("#83a598"),
|
||||
DeepPurple: NewColorHex("#d3869b"), // purple
|
||||
|
||||
ContentBackgroundColor: DefaultColor(),
|
||||
|
||||
StatuslineDarkBg: NewColorHex("#1d2021"), // bg0_h
|
||||
StatuslineMidBg: NewColorHex("#282828"), // bg0
|
||||
StatuslineBorderBg: NewColorHex("#3c3836"), // bg1
|
||||
StatuslineText: NewColorHex("#ebdbb2"),
|
||||
StatuslineAccent: NewColorHex("#689d6a"), // dark aqua
|
||||
StatuslineOk: NewColorHex("#b8bb26"),
|
||||
|
||||
CaptionColors: []CaptionColorPair{
|
||||
{Foreground: NewColorHex("#c7d7d1"), Background: NewColorHex("#27322e")}, // aqua-blue
|
||||
{Foreground: NewColorHex("#dfe09d"), Background: NewColorHex("#37380b")}, // green
|
||||
{Foreground: NewColorHex("#ffc698"), Background: NewColorHex("#4c2608")}, // orange
|
||||
{Foreground: NewColorHex("#fcdfaa"), Background: NewColorHex("#4b390e")}, // yellow
|
||||
{Foreground: NewColorHex("#bab6b2"), Background: NewColorHex("#1f1c19")}, // gray
|
||||
{Foreground: NewColorHex("#fd9a90"), Background: NewColorHex("#4b1610")}, // red
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// CatppuccinMochaPalette returns the Catppuccin Mocha theme palette.
|
||||
// Ref: https://catppuccin.com/palette
|
||||
func CatppuccinMochaPalette() Palette {
|
||||
return Palette{
|
||||
HighlightColor: NewColorHex("#f9e2af"), // yellow
|
||||
TextColor: NewColorHex("#cdd6f4"), // text
|
||||
TransparentColor: DefaultColor(),
|
||||
MutedColor: NewColorHex("#6c7086"), // overlay0
|
||||
SoftBorderColor: NewColorHex("#45475a"), // surface0
|
||||
SoftTextColor: NewColorHex("#bac2de"), // subtext1
|
||||
AccentColor: NewColorHex("#a6e3a1"), // green
|
||||
ValueColor: NewColorHex("#89b4fa"), // blue
|
||||
InfoLabelColor: NewColorHex("#fab387"), // peach
|
||||
|
||||
SelectionBgColor: NewColorHex("#45475a"),
|
||||
|
||||
AccentBlue: NewColorHex("#89b4fa"),
|
||||
SlateColor: NewColorHex("#585b70"), // surface2
|
||||
|
||||
LogoDotColor: NewColorHex("#94e2d5"), // teal
|
||||
LogoShadeColor: NewColorHex("#89b4fa"),
|
||||
LogoBorderColor: NewColorHex("#313244"), // surface0
|
||||
|
||||
CaptionFallbackGradient: Gradient{
|
||||
Start: [3]int{30, 30, 46},
|
||||
End: [3]int{69, 71, 90},
|
||||
},
|
||||
DeepSkyBlue: NewColorHex("#89dceb"), // sky
|
||||
DeepPurple: NewColorHex("#cba6f7"), // mauve
|
||||
|
||||
ContentBackgroundColor: DefaultColor(),
|
||||
|
||||
StatuslineDarkBg: NewColorHex("#11111b"), // crust
|
||||
StatuslineMidBg: NewColorHex("#1e1e2e"), // base
|
||||
StatuslineBorderBg: NewColorHex("#313244"), // surface0
|
||||
StatuslineText: NewColorHex("#cdd6f4"),
|
||||
StatuslineAccent: NewColorHex("#89b4fa"),
|
||||
StatuslineOk: NewColorHex("#a6e3a1"),
|
||||
|
||||
CaptionColors: []CaptionColorPair{
|
||||
{Foreground: NewColorHex("#c9dcfd"), Background: NewColorHex("#293648")}, // blue
|
||||
{Foreground: NewColorHex("#d7f2d5"), Background: NewColorHex("#324430")}, // green
|
||||
{Foreground: NewColorHex("#fdddc9"), Background: NewColorHex("#4b3629")}, // peach
|
||||
{Foreground: NewColorHex("#fcf0d9"), Background: NewColorHex("#4b4435")}, // yellow
|
||||
{Foreground: NewColorHex("#f9eaea"), Background: NewColorHex("#483d3d")}, // flamingo
|
||||
{Foreground: NewColorHex("#cff2ec"), Background: NewColorHex("#2c4440")}, // teal
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SolarizedDarkPalette returns the Solarized Dark theme palette.
|
||||
// Ref: https://ethanschoonover.com/solarized/
|
||||
func SolarizedDarkPalette() Palette {
|
||||
return Palette{
|
||||
HighlightColor: NewColorHex("#b58900"), // yellow
|
||||
TextColor: NewColorHex("#839496"), // base0
|
||||
TransparentColor: DefaultColor(),
|
||||
MutedColor: NewColorHex("#586e75"), // base01
|
||||
SoftBorderColor: NewColorHex("#073642"), // base02
|
||||
SoftTextColor: NewColorHex("#93a1a1"), // base1
|
||||
AccentColor: NewColorHex("#859900"), // green
|
||||
ValueColor: NewColorHex("#268bd2"), // blue
|
||||
InfoLabelColor: NewColorHex("#cb4b16"), // orange
|
||||
|
||||
SelectionBgColor: NewColorHex("#073642"),
|
||||
|
||||
AccentBlue: NewColorHex("#268bd2"),
|
||||
SlateColor: NewColorHex("#586e75"),
|
||||
|
||||
LogoDotColor: NewColorHex("#2aa198"), // cyan
|
||||
LogoShadeColor: NewColorHex("#268bd2"),
|
||||
LogoBorderColor: NewColorHex("#073642"),
|
||||
|
||||
CaptionFallbackGradient: Gradient{
|
||||
Start: [3]int{0, 43, 54},
|
||||
End: [3]int{7, 54, 66},
|
||||
},
|
||||
DeepSkyBlue: NewColorHex("#268bd2"),
|
||||
DeepPurple: NewColorHex("#6c71c4"), // violet
|
||||
|
||||
ContentBackgroundColor: DefaultColor(),
|
||||
|
||||
StatuslineDarkBg: NewColorHex("#002b36"), // base03
|
||||
StatuslineMidBg: NewColorHex("#073642"), // base02
|
||||
StatuslineBorderBg: NewColorHex("#073642"),
|
||||
StatuslineText: NewColorHex("#839496"),
|
||||
StatuslineAccent: NewColorHex("#268bd2"),
|
||||
StatuslineOk: NewColorHex("#859900"),
|
||||
|
||||
CaptionColors: []CaptionColorPair{
|
||||
{Foreground: NewColorHex("#9dcbeb"), Background: NewColorHex("#0b2a3f")}, // blue
|
||||
{Foreground: NewColorHex("#c8d18c"), Background: NewColorHex("#282e00")}, // green
|
||||
{Foreground: NewColorHex("#e8ae96"), Background: NewColorHex("#3d1707")}, // orange
|
||||
{Foreground: NewColorHex("#9fd5d1"), Background: NewColorHex("#0d302e")}, // cyan
|
||||
{Foreground: NewColorHex("#b4bec1"), Background: NewColorHex("#1a2123")}, // base01
|
||||
{Foreground: NewColorHex("#ec908f"), Background: NewColorHex("#420f0e")}, // red
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// NordPalette returns the Nord theme palette.
|
||||
// Ref: https://www.nordtheme.com/docs/colors-and-palettes
|
||||
func NordPalette() Palette {
|
||||
return Palette{
|
||||
HighlightColor: NewColorHex("#ebcb8b"), // nord13 — yellow
|
||||
TextColor: NewColorHex("#eceff4"), // nord6 — snow storm
|
||||
TransparentColor: DefaultColor(),
|
||||
MutedColor: NewColorHex("#4c566a"), // nord3
|
||||
SoftBorderColor: NewColorHex("#434c5e"), // nord2
|
||||
SoftTextColor: NewColorHex("#d8dee9"), // nord4
|
||||
AccentColor: NewColorHex("#a3be8c"), // nord14 — green
|
||||
ValueColor: NewColorHex("#81a1c1"), // nord9 — blue
|
||||
InfoLabelColor: NewColorHex("#d08770"), // nord12 — orange
|
||||
|
||||
SelectionBgColor: NewColorHex("#434c5e"),
|
||||
|
||||
AccentBlue: NewColorHex("#88c0d0"), // nord8 — frost cyan
|
||||
SlateColor: NewColorHex("#4c566a"),
|
||||
|
||||
LogoDotColor: NewColorHex("#8fbcbb"), // nord7 — frost teal
|
||||
LogoShadeColor: NewColorHex("#81a1c1"),
|
||||
LogoBorderColor: NewColorHex("#3b4252"), // nord1
|
||||
|
||||
CaptionFallbackGradient: Gradient{
|
||||
Start: [3]int{46, 52, 64},
|
||||
End: [3]int{59, 66, 82},
|
||||
},
|
||||
DeepSkyBlue: NewColorHex("#88c0d0"),
|
||||
DeepPurple: NewColorHex("#b48ead"), // nord15 — purple
|
||||
|
||||
ContentBackgroundColor: DefaultColor(),
|
||||
|
||||
StatuslineDarkBg: NewColorHex("#2e3440"), // nord0
|
||||
StatuslineMidBg: NewColorHex("#3b4252"), // nord1
|
||||
StatuslineBorderBg: NewColorHex("#434c5e"), // nord2
|
||||
StatuslineText: NewColorHex("#d8dee9"), // nord4
|
||||
StatuslineAccent: NewColorHex("#5e81ac"), // nord10
|
||||
StatuslineOk: NewColorHex("#a3be8c"),
|
||||
|
||||
CaptionColors: []CaptionColorPair{
|
||||
{Foreground: NewColorHex("#c9e3ea"), Background: NewColorHex("#293a3e")}, // frost-cyan
|
||||
{Foreground: NewColorHex("#d6e2cb"), Background: NewColorHex("#31392a")}, // green
|
||||
{Foreground: NewColorHex("#eac9bf"), Background: NewColorHex("#3e2922")}, // orange
|
||||
{Foreground: NewColorHex("#f4e3c3"), Background: NewColorHex("#473d2a")}, // yellow
|
||||
{Foreground: NewColorHex("#aeb3bc"), Background: NewColorHex("#171a20")}, // nord3
|
||||
{Foreground: NewColorHex("#dca8ac"), Background: NewColorHex("#391d20")}, // red
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// MonokaiPalette returns the Monokai theme palette.
|
||||
// Ref: https://monokai.pro/
|
||||
func MonokaiPalette() Palette {
|
||||
return Palette{
|
||||
HighlightColor: NewColorHex("#e6db74"), // yellow
|
||||
TextColor: NewColorHex("#f8f8f2"), // foreground
|
||||
TransparentColor: DefaultColor(),
|
||||
MutedColor: NewColorHex("#75715e"), // comment
|
||||
SoftBorderColor: NewColorHex("#49483e"),
|
||||
SoftTextColor: NewColorHex("#cfcfc2"),
|
||||
AccentColor: NewColorHex("#a6e22e"), // green
|
||||
ValueColor: NewColorHex("#66d9ef"), // cyan
|
||||
InfoLabelColor: NewColorHex("#fd971f"), // orange
|
||||
|
||||
SelectionBgColor: NewColorHex("#49483e"),
|
||||
|
||||
AccentBlue: NewColorHex("#66d9ef"),
|
||||
SlateColor: NewColorHex("#75715e"),
|
||||
|
||||
LogoDotColor: NewColorHex("#a6e22e"),
|
||||
LogoShadeColor: NewColorHex("#66d9ef"),
|
||||
LogoBorderColor: NewColorHex("#3e3d32"),
|
||||
|
||||
CaptionFallbackGradient: Gradient{
|
||||
Start: [3]int{39, 40, 34},
|
||||
End: [3]int{73, 72, 62},
|
||||
},
|
||||
DeepSkyBlue: NewColorHex("#66d9ef"),
|
||||
DeepPurple: NewColorHex("#ae81ff"), // purple
|
||||
|
||||
ContentBackgroundColor: DefaultColor(),
|
||||
|
||||
StatuslineDarkBg: NewColorHex("#1e1f1c"),
|
||||
StatuslineMidBg: NewColorHex("#272822"), // bg
|
||||
StatuslineBorderBg: NewColorHex("#3e3d32"),
|
||||
StatuslineText: NewColorHex("#f8f8f2"),
|
||||
StatuslineAccent: NewColorHex("#66d9ef"),
|
||||
StatuslineOk: NewColorHex("#a6e22e"),
|
||||
|
||||
CaptionColors: []CaptionColorPair{
|
||||
{Foreground: NewColorHex("#baeef8"), Background: NewColorHex("#1f4148")}, // cyan
|
||||
{Foreground: NewColorHex("#d7f2a1"), Background: NewColorHex("#32440e")}, // green
|
||||
{Foreground: NewColorHex("#fed09a"), Background: NewColorHex("#4c2d09")}, // orange
|
||||
{Foreground: NewColorHex("#f2eebc"), Background: NewColorHex("#454223")}, // yellow
|
||||
{Foreground: NewColorHex("#c1bfb7"), Background: NewColorHex("#23221c")}, // comment
|
||||
{Foreground: NewColorHex("#e08ea5"), Background: NewColorHex("#4b0c22")}, // pink-red
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// OneDarkPalette returns the Atom One Dark theme palette.
|
||||
// Ref: https://github.com/Binaryify/OneDark-Pro
|
||||
func OneDarkPalette() Palette {
|
||||
return Palette{
|
||||
HighlightColor: NewColorHex("#e5c07b"), // yellow
|
||||
TextColor: NewColorHex("#abb2bf"), // foreground
|
||||
TransparentColor: DefaultColor(),
|
||||
MutedColor: NewColorHex("#5c6370"), // comment
|
||||
SoftBorderColor: NewColorHex("#3e4452"),
|
||||
SoftTextColor: NewColorHex("#9da5b4"),
|
||||
AccentColor: NewColorHex("#98c379"), // green
|
||||
ValueColor: NewColorHex("#61afef"), // blue
|
||||
InfoLabelColor: NewColorHex("#d19a66"), // orange
|
||||
|
||||
SelectionBgColor: NewColorHex("#3e4452"),
|
||||
|
||||
AccentBlue: NewColorHex("#61afef"),
|
||||
SlateColor: NewColorHex("#5c6370"),
|
||||
|
||||
LogoDotColor: NewColorHex("#56b6c2"), // cyan
|
||||
LogoShadeColor: NewColorHex("#61afef"),
|
||||
LogoBorderColor: NewColorHex("#3b4048"),
|
||||
|
||||
CaptionFallbackGradient: Gradient{
|
||||
Start: [3]int{40, 44, 52},
|
||||
End: [3]int{62, 68, 82},
|
||||
},
|
||||
DeepSkyBlue: NewColorHex("#61afef"),
|
||||
DeepPurple: NewColorHex("#c678dd"), // purple
|
||||
|
||||
ContentBackgroundColor: DefaultColor(),
|
||||
|
||||
StatuslineDarkBg: NewColorHex("#21252b"),
|
||||
StatuslineMidBg: NewColorHex("#282c34"), // bg
|
||||
StatuslineBorderBg: NewColorHex("#3b4048"),
|
||||
StatuslineText: NewColorHex("#abb2bf"),
|
||||
StatuslineAccent: NewColorHex("#61afef"),
|
||||
StatuslineOk: NewColorHex("#98c379"),
|
||||
|
||||
CaptionColors: []CaptionColorPair{
|
||||
{Foreground: NewColorHex("#b8dbf8"), Background: NewColorHex("#1d3548")}, // blue
|
||||
{Foreground: NewColorHex("#d1e4c3"), Background: NewColorHex("#2e3b24")}, // green
|
||||
{Foreground: NewColorHex("#ead2ba"), Background: NewColorHex("#3f2e1f")}, // orange
|
||||
{Foreground: NewColorHex("#f1deba"), Background: NewColorHex("#453a25")}, // yellow
|
||||
{Foreground: NewColorHex("#b6b9bf"), Background: NewColorHex("#1c1e22")}, // comment
|
||||
{Foreground: NewColorHex("#eeb2b6"), Background: NewColorHex("#432123")}, // red
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// --- Light themes ---
|
||||
|
||||
// CatppuccinLattePalette returns the Catppuccin Latte (light) theme palette.
|
||||
// Ref: https://catppuccin.com/palette
|
||||
func CatppuccinLattePalette() Palette {
|
||||
return Palette{
|
||||
HighlightColor: NewColorHex("#df8e1d"), // yellow
|
||||
TextColor: NewColorHex("#4c4f69"), // text
|
||||
TransparentColor: DefaultColor(),
|
||||
MutedColor: NewColorHex("#9ca0b0"), // overlay0
|
||||
SoftBorderColor: NewColorHex("#ccd0da"), // surface0
|
||||
SoftTextColor: NewColorHex("#5c5f77"), // subtext1
|
||||
AccentColor: NewColorHex("#40a02b"), // green
|
||||
ValueColor: NewColorHex("#1e66f5"), // blue
|
||||
InfoLabelColor: NewColorHex("#fe640b"), // peach
|
||||
|
||||
SelectionBgColor: NewColorHex("#ccd0da"),
|
||||
|
||||
AccentBlue: NewColorHex("#1e66f5"),
|
||||
SlateColor: NewColorHex("#acb0be"), // surface2
|
||||
|
||||
LogoDotColor: NewColorHex("#179299"), // teal
|
||||
LogoShadeColor: NewColorHex("#1e66f5"),
|
||||
LogoBorderColor: NewColorHex("#bcc0cc"), // surface1
|
||||
|
||||
CaptionFallbackGradient: Gradient{
|
||||
Start: [3]int{239, 241, 245},
|
||||
End: [3]int{204, 208, 218},
|
||||
},
|
||||
DeepSkyBlue: NewColorHex("#04a5e5"), // sky
|
||||
DeepPurple: NewColorHex("#8839ef"), // mauve
|
||||
|
||||
ContentBackgroundColor: DefaultColor(),
|
||||
|
||||
StatuslineDarkBg: NewColorHex("#eff1f5"), // base
|
||||
StatuslineMidBg: NewColorHex("#e6e9ef"), // mantle
|
||||
StatuslineBorderBg: NewColorHex("#dce0e8"), // crust
|
||||
StatuslineText: NewColorHex("#4c4f69"),
|
||||
StatuslineAccent: NewColorHex("#1e66f5"),
|
||||
StatuslineOk: NewColorHex("#40a02b"),
|
||||
|
||||
CaptionColors: []CaptionColorPair{
|
||||
{Foreground: NewColorHex("#c7d9fd"), Background: NewColorHex("#1e66f5")}, // blue (ValueColor)
|
||||
{Foreground: NewColorHex("#f7e3c7"), Background: NewColorHex("#8d5a12")}, // yellow (HighlightColor)
|
||||
{Foreground: NewColorHex("#ffd8c2"), Background: NewColorHex("#b54708")}, // peach (InfoLabelColor)
|
||||
{Foreground: NewColorHex("#dedfe4"), Background: NewColorHex("#5e606f")}, // overlay0 (MutedColor)
|
||||
{Foreground: NewColorHex("#c5e4e6"), Background: NewColorHex("#148187")}, // teal (LogoDotColor)
|
||||
{Foreground: NewColorHex("#c0e9f9"), Background: NewColorHex("#0381b3")}, // sky (DeepSkyBlue)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// SolarizedLightPalette returns the Solarized Light theme palette.
|
||||
// Ref: https://ethanschoonover.com/solarized/
|
||||
func SolarizedLightPalette() Palette {
|
||||
return Palette{
|
||||
HighlightColor: NewColorHex("#b58900"), // yellow (same accent colors as dark)
|
||||
TextColor: NewColorHex("#657b83"), // base00
|
||||
TransparentColor: DefaultColor(),
|
||||
MutedColor: NewColorHex("#93a1a1"), // base1
|
||||
SoftBorderColor: NewColorHex("#eee8d5"), // base2
|
||||
SoftTextColor: NewColorHex("#586e75"), // base01
|
||||
AccentColor: NewColorHex("#859900"), // green
|
||||
ValueColor: NewColorHex("#268bd2"), // blue
|
||||
InfoLabelColor: NewColorHex("#cb4b16"), // orange
|
||||
|
||||
SelectionBgColor: NewColorHex("#eee8d5"),
|
||||
|
||||
AccentBlue: NewColorHex("#268bd2"),
|
||||
SlateColor: NewColorHex("#93a1a1"),
|
||||
|
||||
LogoDotColor: NewColorHex("#2aa198"), // cyan
|
||||
LogoShadeColor: NewColorHex("#268bd2"),
|
||||
LogoBorderColor: NewColorHex("#eee8d5"),
|
||||
|
||||
CaptionFallbackGradient: Gradient{
|
||||
Start: [3]int{253, 246, 227},
|
||||
End: [3]int{238, 232, 213},
|
||||
},
|
||||
DeepSkyBlue: NewColorHex("#268bd2"),
|
||||
DeepPurple: NewColorHex("#6c71c4"), // violet
|
||||
|
||||
ContentBackgroundColor: DefaultColor(),
|
||||
|
||||
StatuslineDarkBg: NewColorHex("#fdf6e3"), // base3
|
||||
StatuslineMidBg: NewColorHex("#eee8d5"), // base2
|
||||
StatuslineBorderBg: NewColorHex("#eee8d5"),
|
||||
StatuslineText: NewColorHex("#657b83"),
|
||||
StatuslineAccent: NewColorHex("#268bd2"),
|
||||
StatuslineOk: NewColorHex("#859900"),
|
||||
|
||||
CaptionColors: []CaptionColorPair{
|
||||
{Foreground: NewColorHex("#c9e2f4"), Background: NewColorHex("#2073ae")}, // blue (ValueColor)
|
||||
{Foreground: NewColorHex("#ede2bf"), Background: NewColorHex("#826300")}, // yellow (HighlightColor)
|
||||
{Foreground: NewColorHex("#f2d2c5"), Background: NewColorHex("#b74414")}, // orange (InfoLabelColor)
|
||||
{Foreground: NewColorHex("#d5dbdd"), Background: NewColorHex("#52666d")}, // base01 (SoftTextColor)
|
||||
{Foreground: NewColorHex("#cae8e5"), Background: NewColorHex("#217d76")}, // cyan (LogoDotColor)
|
||||
{Foreground: NewColorHex("#e1e6bf"), Background: NewColorHex("#637200")}, // green (AccentColor)
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GruvboxLightPalette returns the Gruvbox Light theme palette.
|
||||
// Ref: https://github.com/morhetz/gruvbox
|
||||
func GruvboxLightPalette() Palette {
|
||||
return Palette{
|
||||
HighlightColor: NewColorHex("#9d6104"), // dark yellow (deepened for light-bg contrast)
|
||||
TextColor: NewColorHex("#3c3836"), // fg (dark0_hard)
|
||||
TransparentColor: DefaultColor(),
|
||||
MutedColor: NewColorHex("#928374"), // gray
|
||||
SoftBorderColor: NewColorHex("#d5c4a1"), // bg2
|
||||
SoftTextColor: NewColorHex("#504945"), // fg3 (dark2)
|
||||
AccentColor: NewColorHex("#79740e"), // dark green
|
||||
ValueColor: NewColorHex("#076678"), // dark blue
|
||||
InfoLabelColor: NewColorHex("#af3a03"), // dark orange
|
||||
|
||||
SelectionBgColor: NewColorHex("#d5c4a1"),
|
||||
|
||||
AccentBlue: NewColorHex("#076678"),
|
||||
SlateColor: NewColorHex("#bdae93"), // bg3
|
||||
|
||||
LogoDotColor: NewColorHex("#427b58"), // dark aqua
|
||||
LogoShadeColor: NewColorHex("#076678"),
|
||||
LogoBorderColor: NewColorHex("#ebdbb2"), // bg1
|
||||
|
||||
CaptionFallbackGradient: Gradient{
|
||||
Start: [3]int{251, 241, 199},
|
||||
End: [3]int{235, 219, 178},
|
||||
},
|
||||
DeepSkyBlue: NewColorHex("#076678"),
|
||||
DeepPurple: NewColorHex("#8f3f71"), // dark purple
|
||||
|
||||
ContentBackgroundColor: DefaultColor(),
|
||||
|
||||
StatuslineDarkBg: NewColorHex("#fbf1c7"), // bg0
|
||||
StatuslineMidBg: NewColorHex("#ebdbb2"), // bg1
|
||||
StatuslineBorderBg: NewColorHex("#d5c4a1"), // bg2
|
||||
StatuslineText: NewColorHex("#3c3836"),
|
||||
StatuslineAccent: NewColorHex("#427b58"),
|
||||
StatuslineOk: NewColorHex("#79740e"),
|
||||
|
||||
CaptionColors: []CaptionColorPair{
|
||||
{Foreground: NewColorHex("#e2d6c3"), Background: NewColorHex("#8b5b0f")}, // amber/ochre
|
||||
{Foreground: NewColorHex("#dedcc3"), Background: NewColorHex("#6f6a0d")}, // green
|
||||
{Foreground: NewColorHex("#ebcec0"), Background: NewColorHex("#c44103")}, // orange
|
||||
{Foreground: NewColorHex("#d0ded5"), Background: NewColorHex("#3f7554")}, // aqua
|
||||
{Foreground: NewColorHex("#dedbd8"), Background: NewColorHex("#6a5f55")}, // gray
|
||||
{Foreground: NewColorHex("#e2d7d2"), Background: NewColorHex("#8b5e4b")}, // warm brown
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// GithubLightPalette returns the GitHub Light theme palette.
|
||||
// Ref: https://github.com/primer/github-vscode-theme
|
||||
func GithubLightPalette() Palette {
|
||||
return Palette{
|
||||
HighlightColor: NewColorHex("#0550ae"), // blue accent
|
||||
TextColor: NewColorHex("#1f2328"), // fg.default
|
||||
TransparentColor: DefaultColor(),
|
||||
MutedColor: NewColorHex("#656d76"), // fg.muted
|
||||
SoftBorderColor: NewColorHex("#d0d7de"), // border.default
|
||||
SoftTextColor: NewColorHex("#424a53"),
|
||||
AccentColor: NewColorHex("#116329"), // green
|
||||
ValueColor: NewColorHex("#0969da"), // blue
|
||||
InfoLabelColor: NewColorHex("#953800"), // orange
|
||||
|
||||
SelectionBgColor: NewColorHex("#ddf4ff"),
|
||||
|
||||
AccentBlue: NewColorHex("#0969da"),
|
||||
SlateColor: NewColorHex("#8c959f"),
|
||||
|
||||
LogoDotColor: NewColorHex("#0969da"),
|
||||
LogoShadeColor: NewColorHex("#0550ae"),
|
||||
LogoBorderColor: NewColorHex("#d0d7de"),
|
||||
|
||||
CaptionFallbackGradient: Gradient{
|
||||
Start: [3]int{255, 255, 255},
|
||||
End: [3]int{246, 248, 250},
|
||||
},
|
||||
DeepSkyBlue: NewColorHex("#0969da"),
|
||||
DeepPurple: NewColorHex("#8250df"), // purple
|
||||
|
||||
ContentBackgroundColor: DefaultColor(),
|
||||
|
||||
StatuslineDarkBg: NewColorHex("#ffffff"),
|
||||
StatuslineMidBg: NewColorHex("#f6f8fa"), // canvas.subtle
|
||||
StatuslineBorderBg: NewColorHex("#eaeef2"),
|
||||
StatuslineText: NewColorHex("#1f2328"),
|
||||
StatuslineAccent: NewColorHex("#0969da"),
|
||||
StatuslineOk: NewColorHex("#116329"),
|
||||
|
||||
CaptionColors: []CaptionColorPair{
|
||||
{Foreground: NewColorHex("#c2daf6"), Background: NewColorHex("#0a72ed")}, // blue
|
||||
{Foreground: NewColorHex("#c4d8ca"), Background: NewColorHex("#188d3b")}, // green
|
||||
{Foreground: NewColorHex("#e5cdbf"), Background: NewColorHex("#ba4600")}, // orange
|
||||
{Foreground: NewColorHex("#e6d9bf"), Background: NewColorHex("#9a6700")}, // amber
|
||||
{Foreground: NewColorHex("#d9dbdd"), Background: NewColorHex("#5b626a")}, // muted
|
||||
{Foreground: NewColorHex("#e6d2d2"), Background: NewColorHex("#9b4a4a")}, // muted red
|
||||
},
|
||||
}
|
||||
}
|
||||
50
config/palettes_test.go
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
package config
|
||||
|
||||
import "testing"
|
||||
|
||||
func TestAllPalettesHaveNonDefaultCriticalFields(t *testing.T) {
|
||||
for name, info := range themeRegistry {
|
||||
p := info.Palette()
|
||||
critical := map[string]Color{
|
||||
"TextColor": p.TextColor,
|
||||
"HighlightColor": p.HighlightColor,
|
||||
"AccentColor": p.AccentColor,
|
||||
"MutedColor": p.MutedColor,
|
||||
"AccentBlue": p.AccentBlue,
|
||||
"InfoLabelColor": p.InfoLabelColor,
|
||||
}
|
||||
for field, c := range critical {
|
||||
if c.IsDefault() {
|
||||
t.Errorf("theme %q: %s is default/transparent", name, field)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestLightPalettesHaveDarkText(t *testing.T) {
|
||||
for name, info := range themeRegistry {
|
||||
if !info.Light {
|
||||
continue
|
||||
}
|
||||
p := info.Palette()
|
||||
r, g, b := p.TextColor.RGB()
|
||||
luminance := 0.299*float64(r) + 0.587*float64(g) + 0.114*float64(b)
|
||||
if luminance > 160 {
|
||||
t.Errorf("light theme %q: TextColor luminance %.0f is too bright (expected dark text)", name, luminance)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestDarkPalettesHaveLightText(t *testing.T) {
|
||||
for name, info := range themeRegistry {
|
||||
if info.Light {
|
||||
continue
|
||||
}
|
||||
p := info.Palette()
|
||||
r, g, b := p.TextColor.RGB()
|
||||
luminance := 0.299*float64(r) + 0.587*float64(g) + 0.114*float64(b)
|
||||
if luminance < 128 {
|
||||
t.Errorf("dark theme %q: TextColor luminance %.0f is too dark (expected light text)", name, luminance)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -324,9 +324,15 @@ func GetUserConfigWorkflowFile() string {
|
|||
return mustGetPathManager().UserConfigWorkflowFile()
|
||||
}
|
||||
|
||||
// configFilename is the default name for the configuration file
|
||||
const configFilename = "config.yaml"
|
||||
|
||||
// defaultWorkflowFilename is the default name for the workflow configuration file
|
||||
const defaultWorkflowFilename = "workflow.yaml"
|
||||
|
||||
// templateFilename is the default name for the task template file
|
||||
const templateFilename = "new.md"
|
||||
|
||||
// FindWorkflowFiles returns all workflow.yaml files that exist and have non-empty views.
|
||||
// Ordering: user config file first (base), then project config file (overrides), then cwd.
|
||||
// This lets LoadPlugins load the base and merge overrides on top.
|
||||
|
|
@ -369,21 +375,29 @@ func FindWorkflowFiles() []string {
|
|||
return result
|
||||
}
|
||||
|
||||
// hasEmptyViews returns true if the workflow file has an explicit empty views list (views: []).
|
||||
// hasEmptyViews returns true if the workflow file has an explicit empty views section.
|
||||
// Handles both old list format (views: []) and new map format (views: {plugins: []}).
|
||||
func hasEmptyViews(path string) bool {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
type viewsOnly struct {
|
||||
Views []any `yaml:"views"`
|
||||
|
||||
var raw struct {
|
||||
Views interface{} `yaml:"views"`
|
||||
}
|
||||
var vo viewsOnly
|
||||
if err := yaml.Unmarshal(data, &vo); err != nil {
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
return false
|
||||
}
|
||||
switch v := raw.Views.(type) {
|
||||
case []interface{}:
|
||||
return len(v) == 0
|
||||
case map[string]interface{}:
|
||||
plugins, _ := v["plugins"].([]interface{})
|
||||
return plugins != nil && len(plugins) == 0
|
||||
default:
|
||||
return false
|
||||
}
|
||||
// Explicitly empty (views: []) vs. not specified at all
|
||||
return vo.Views != nil && len(vo.Views) == 0
|
||||
}
|
||||
|
||||
// FindWorkflowFile searches for workflow.yaml in config search paths.
|
||||
|
|
@ -397,9 +411,38 @@ func FindWorkflowFile() string {
|
|||
return files[0]
|
||||
}
|
||||
|
||||
// GetTemplateFile returns the path to the user's custom new.md template
|
||||
func GetTemplateFile() string {
|
||||
return mustGetPathManager().TemplateFile()
|
||||
// FindTemplateFile returns the highest-priority new.md file that exists,
|
||||
// searching user config → .doc/ (project) → cwd. Returns empty string if
|
||||
// none found, in which case the caller should fall back to the embedded template.
|
||||
func FindTemplateFile() string {
|
||||
pm := mustGetPathManager()
|
||||
|
||||
// candidate paths in discovery order: user config (base) → project → cwd (highest)
|
||||
candidates := []string{
|
||||
pm.TemplateFile(),
|
||||
filepath.Join(pm.ProjectConfigDir(), templateFilename),
|
||||
templateFilename,
|
||||
}
|
||||
|
||||
var best string
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, path := range candidates {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
abs = path
|
||||
}
|
||||
if seen[abs] {
|
||||
continue
|
||||
}
|
||||
if _, err := os.Stat(path); err != nil {
|
||||
continue
|
||||
}
|
||||
seen[abs] = true
|
||||
best = path
|
||||
}
|
||||
|
||||
return best
|
||||
}
|
||||
|
||||
// EnsureDirs creates all necessary directories with appropriate permissions
|
||||
|
|
|
|||
|
|
@ -293,7 +293,6 @@ func TestGlobalAccessorFunctions(t *testing.T) {
|
|||
{"GetTaskDir", GetTaskDir},
|
||||
{"GetDokiDir", GetDokiDir},
|
||||
{"GetProjectConfigFile", GetProjectConfigFile},
|
||||
{"GetTemplateFile", GetTemplateFile},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -344,6 +343,142 @@ func TestInitPaths(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestFindTemplateFile_CwdOverridesProject(t *testing.T) {
|
||||
// user config with new.md
|
||||
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)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(userTikiDir, "new.md"), []byte("---\npriority: 1\n---"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// project .doc/ with new.md
|
||||
projectDir := t.TempDir()
|
||||
docDir := filepath.Join(projectDir, ".doc")
|
||||
if err := os.MkdirAll(docDir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(docDir, "new.md"), []byte("---\npriority: 2\n---"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// cwd with new.md (highest priority)
|
||||
cwdDir := t.TempDir()
|
||||
if err := os.WriteFile(filepath.Join(cwdDir, "new.md"), []byte("---\npriority: 3\n---"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
originalDir, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(originalDir) }()
|
||||
_ = os.Chdir(cwdDir)
|
||||
|
||||
ResetPathManager()
|
||||
pm := mustGetPathManager()
|
||||
pm.projectRoot = projectDir
|
||||
|
||||
got := FindTemplateFile()
|
||||
gotAbs, _ := filepath.Abs(got)
|
||||
wantAbs, _ := filepath.Abs("new.md")
|
||||
if gotAbs != wantAbs {
|
||||
t.Errorf("FindTemplateFile() = %q, want cwd file %q", gotAbs, wantAbs)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindTemplateFile_ProjectOverridesUser(t *testing.T) {
|
||||
// user config with new.md
|
||||
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)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(userTikiDir, "new.md"), []byte("---\npriority: 1\n---"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// project .doc/ with new.md
|
||||
projectDir := t.TempDir()
|
||||
docDir := filepath.Join(projectDir, ".doc")
|
||||
if err := os.MkdirAll(docDir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(docDir, "new.md"), []byte("---\npriority: 2\n---"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// cwd with NO new.md
|
||||
cwdDir := t.TempDir()
|
||||
originalDir, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(originalDir) }()
|
||||
_ = os.Chdir(cwdDir)
|
||||
|
||||
ResetPathManager()
|
||||
pm := mustGetPathManager()
|
||||
pm.projectRoot = projectDir
|
||||
|
||||
got := FindTemplateFile()
|
||||
want := filepath.Join(docDir, "new.md")
|
||||
if got != want {
|
||||
t.Errorf("FindTemplateFile() = %q, want project file %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindTemplateFile_UserOnlyFallback(t *testing.T) {
|
||||
// user config with new.md
|
||||
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)
|
||||
}
|
||||
if err := os.WriteFile(filepath.Join(userTikiDir, "new.md"), []byte("---\npriority: 1\n---"), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// no project .doc/, no cwd new.md
|
||||
cwdDir := t.TempDir()
|
||||
originalDir, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(originalDir) }()
|
||||
_ = os.Chdir(cwdDir)
|
||||
|
||||
ResetPathManager()
|
||||
pm := mustGetPathManager()
|
||||
pm.projectRoot = cwdDir
|
||||
|
||||
got := FindTemplateFile()
|
||||
want := filepath.Join(userTikiDir, "new.md")
|
||||
if got != want {
|
||||
t.Errorf("FindTemplateFile() = %q, want user config file %q", got, want)
|
||||
}
|
||||
}
|
||||
|
||||
func TestFindTemplateFile_NoneFound(t *testing.T) {
|
||||
// empty user config dir (no new.md)
|
||||
userDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", userDir)
|
||||
if err := os.MkdirAll(filepath.Join(userDir, "tiki"), 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
// empty cwd
|
||||
cwdDir := t.TempDir()
|
||||
originalDir, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(originalDir) }()
|
||||
_ = os.Chdir(cwdDir)
|
||||
|
||||
ResetPathManager()
|
||||
pm := mustGetPathManager()
|
||||
pm.projectRoot = cwdDir
|
||||
|
||||
got := FindTemplateFile()
|
||||
if got != "" {
|
||||
t.Errorf("FindTemplateFile() = %q, want empty string when no file exists", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetPathManager(t *testing.T) {
|
||||
// Save original XDG
|
||||
origXDG := os.Getenv("XDG_CONFIG_HOME")
|
||||
|
|
|
|||
168
config/reset.go
Normal file
|
|
@ -0,0 +1,168 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
)
|
||||
|
||||
// Scope identifies which config tier to operate on.
|
||||
type Scope string
|
||||
|
||||
// ResetTarget identifies which config file to reset.
|
||||
type ResetTarget string
|
||||
|
||||
const (
|
||||
ScopeGlobal Scope = "global"
|
||||
ScopeLocal Scope = "local"
|
||||
ScopeCurrent Scope = "current"
|
||||
|
||||
TargetAll ResetTarget = ""
|
||||
TargetConfig ResetTarget = "config"
|
||||
TargetWorkflow ResetTarget = "workflow"
|
||||
TargetNew ResetTarget = "new"
|
||||
)
|
||||
|
||||
// resetEntry pairs a filename with the default content to restore for global scope.
|
||||
// If defaultContent is empty, the file is always deleted (no embedded default exists).
|
||||
type resetEntry struct {
|
||||
filename string
|
||||
defaultContent string
|
||||
}
|
||||
|
||||
var resetEntries = []resetEntry{
|
||||
// TODO: embed a default config.yaml once one exists; until then, global reset deletes the file
|
||||
{filename: configFilename, defaultContent: ""},
|
||||
{filename: defaultWorkflowFilename, defaultContent: defaultWorkflowYAML},
|
||||
{filename: templateFilename, defaultContent: defaultNewTaskTemplate},
|
||||
}
|
||||
|
||||
// ResetConfig resets configuration files for the given scope and target.
|
||||
// Returns the list of file paths that were actually modified or deleted.
|
||||
func ResetConfig(scope Scope, target ResetTarget) ([]string, error) {
|
||||
dir, err := resolveDir(scope)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
entries, err := filterEntries(target)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
var affected []string
|
||||
for _, e := range entries {
|
||||
path := filepath.Join(dir, e.filename)
|
||||
changed, err := resetFile(path, scope, e.defaultContent)
|
||||
if err != nil {
|
||||
return affected, fmt.Errorf("reset %s: %w", e.filename, err)
|
||||
}
|
||||
if changed {
|
||||
affected = append(affected, path)
|
||||
}
|
||||
}
|
||||
|
||||
return affected, nil
|
||||
}
|
||||
|
||||
// resolveDir returns the directory path for the given scope.
|
||||
func resolveDir(scope Scope) (string, error) {
|
||||
switch scope {
|
||||
case ScopeGlobal:
|
||||
return GetConfigDir(), nil
|
||||
case ScopeLocal:
|
||||
if !IsProjectInitialized() {
|
||||
return "", fmt.Errorf("not in an initialized tiki project (run 'tiki init' first)")
|
||||
}
|
||||
return GetProjectConfigDir(), nil
|
||||
case ScopeCurrent:
|
||||
cwd, err := os.Getwd()
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("get working directory: %w", err)
|
||||
}
|
||||
return cwd, nil
|
||||
default:
|
||||
return "", fmt.Errorf("unknown scope: %s", scope)
|
||||
}
|
||||
}
|
||||
|
||||
// ValidResetTarget reports whether target is a recognized reset target.
|
||||
func ValidResetTarget(target ResetTarget) bool {
|
||||
switch target {
|
||||
case TargetAll, TargetConfig, TargetWorkflow, TargetNew:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// filterEntries returns the reset entries matching the target.
|
||||
func filterEntries(target ResetTarget) ([]resetEntry, error) {
|
||||
if target == TargetAll {
|
||||
return resetEntries, nil
|
||||
}
|
||||
var filename string
|
||||
switch target {
|
||||
case TargetConfig:
|
||||
filename = configFilename
|
||||
case TargetWorkflow:
|
||||
filename = defaultWorkflowFilename
|
||||
case TargetNew:
|
||||
filename = templateFilename
|
||||
default:
|
||||
return nil, fmt.Errorf("unknown target: %q (use config, workflow, or new)", target)
|
||||
}
|
||||
for _, e := range resetEntries {
|
||||
if e.filename == filename {
|
||||
return []resetEntry{e}, nil
|
||||
}
|
||||
}
|
||||
return nil, fmt.Errorf("no reset entry for target %q", target)
|
||||
}
|
||||
|
||||
// resetFile either overwrites or deletes a file depending on scope and available defaults.
|
||||
// For global scope with non-empty default content, the file is overwritten.
|
||||
// Otherwise the file is deleted. Returns true if the file was changed.
|
||||
func resetFile(path string, scope Scope, defaultContent string) (bool, error) {
|
||||
if scope == ScopeGlobal && defaultContent != "" {
|
||||
return writeFileIfChanged(path, defaultContent)
|
||||
}
|
||||
return deleteIfExists(path)
|
||||
}
|
||||
|
||||
// writeFileIfChanged writes content to path, skipping if the file already has identical content.
|
||||
// Returns true if the file was actually changed.
|
||||
func writeFileIfChanged(path string, content string) (bool, error) {
|
||||
existing, err := os.ReadFile(path)
|
||||
if err == nil && string(existing) == content {
|
||||
return false, nil
|
||||
}
|
||||
return writeFile(path, content)
|
||||
}
|
||||
|
||||
// writeFile writes content to path unconditionally, creating parent dirs if needed.
|
||||
func writeFile(path string, content string) (bool, error) {
|
||||
dir := filepath.Dir(path)
|
||||
//nolint:gosec // G301: 0755 is appropriate for config directory
|
||||
if err := os.MkdirAll(dir, 0755); err != nil {
|
||||
return false, fmt.Errorf("create directory %s: %w", dir, err)
|
||||
}
|
||||
//nolint:gosec // G306: 0644 is appropriate for config file
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
return false, err
|
||||
}
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// deleteIfExists removes a file if it exists. Returns true if the file was deleted.
|
||||
func deleteIfExists(path string) (bool, error) {
|
||||
err := os.Remove(path)
|
||||
if err == nil {
|
||||
return true, nil
|
||||
}
|
||||
if errors.Is(err, os.ErrNotExist) {
|
||||
return false, nil
|
||||
}
|
||||
return false, err
|
||||
}
|
||||
268
config/reset_test.go
Normal file
|
|
@ -0,0 +1,268 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// setupResetTest creates a temp config dir, sets XDG_CONFIG_HOME, and resets
|
||||
// the path manager so GetConfigDir() points to the temp dir.
|
||||
// Returns the tiki config dir (e.g. <tmp>/tiki).
|
||||
func setupResetTest(t *testing.T) string {
|
||||
t.Helper()
|
||||
xdgDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", xdgDir)
|
||||
ResetPathManager()
|
||||
t.Cleanup(ResetPathManager)
|
||||
|
||||
tikiDir := filepath.Join(xdgDir, "tiki")
|
||||
if err := os.MkdirAll(tikiDir, 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return tikiDir
|
||||
}
|
||||
|
||||
// writeTestFile is a test helper that writes content to path.
|
||||
func writeTestFile(t *testing.T, path, content string) {
|
||||
t.Helper()
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetConfig_GlobalAll(t *testing.T) {
|
||||
tikiDir := setupResetTest(t)
|
||||
|
||||
// seed all three files with custom content
|
||||
writeTestFile(t, filepath.Join(tikiDir, "config.yaml"), "logging:\n level: debug\n")
|
||||
writeTestFile(t, filepath.Join(tikiDir, "workflow.yaml"), "custom: true\n")
|
||||
writeTestFile(t, filepath.Join(tikiDir, "new.md"), "custom template\n")
|
||||
|
||||
affected, err := ResetConfig(ScopeGlobal, TargetAll)
|
||||
if err != nil {
|
||||
t.Fatalf("ResetConfig() error = %v", err)
|
||||
}
|
||||
if len(affected) != 3 {
|
||||
t.Fatalf("expected 3 affected files, got %d: %v", len(affected), affected)
|
||||
}
|
||||
|
||||
// config.yaml should be deleted (no embedded default)
|
||||
if _, err := os.Stat(filepath.Join(tikiDir, "config.yaml")); !os.IsNotExist(err) {
|
||||
t.Error("config.yaml should be deleted after global reset")
|
||||
}
|
||||
|
||||
// workflow.yaml should contain embedded default
|
||||
got, err := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read workflow.yaml: %v", err)
|
||||
}
|
||||
if string(got) != GetDefaultWorkflowYAML() {
|
||||
t.Error("workflow.yaml does not match embedded default after global reset")
|
||||
}
|
||||
|
||||
// new.md should contain embedded default
|
||||
got, err = os.ReadFile(filepath.Join(tikiDir, "new.md"))
|
||||
if err != nil {
|
||||
t.Fatalf("read new.md: %v", err)
|
||||
}
|
||||
if string(got) != GetDefaultNewTaskTemplate() {
|
||||
t.Error("new.md does not match embedded default after global reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetConfig_GlobalSingleTarget(t *testing.T) {
|
||||
tests := []struct {
|
||||
target ResetTarget
|
||||
filename string
|
||||
deleted bool // true = file deleted, false = file overwritten with default
|
||||
}{
|
||||
{TargetConfig, "config.yaml", true},
|
||||
{TargetWorkflow, "workflow.yaml", false},
|
||||
{TargetNew, "new.md", false},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(string(tt.target), func(t *testing.T) {
|
||||
tikiDir := setupResetTest(t)
|
||||
|
||||
writeTestFile(t, filepath.Join(tikiDir, tt.filename), "custom\n")
|
||||
|
||||
affected, err := ResetConfig(ScopeGlobal, tt.target)
|
||||
if err != nil {
|
||||
t.Fatalf("ResetConfig() error = %v", err)
|
||||
}
|
||||
if len(affected) != 1 {
|
||||
t.Fatalf("expected 1 affected file, got %d", len(affected))
|
||||
}
|
||||
|
||||
_, statErr := os.Stat(filepath.Join(tikiDir, tt.filename))
|
||||
if tt.deleted {
|
||||
if !os.IsNotExist(statErr) {
|
||||
t.Errorf("%s should be deleted", tt.filename)
|
||||
}
|
||||
} else {
|
||||
if statErr != nil {
|
||||
t.Errorf("%s should exist after reset: %v", tt.filename, statErr)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetConfig_LocalDeletesFiles(t *testing.T) {
|
||||
tikiDir := setupResetTest(t)
|
||||
|
||||
// set up project dir with .doc/tiki so IsProjectInitialized() passes
|
||||
projectDir := t.TempDir()
|
||||
docDir := filepath.Join(projectDir, ".doc")
|
||||
if err := os.MkdirAll(filepath.Join(docDir, "tiki"), 0750); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
pm := mustGetPathManager()
|
||||
pm.projectRoot = projectDir
|
||||
|
||||
// seed project config files
|
||||
writeTestFile(t, filepath.Join(docDir, "config.yaml"), "custom\n")
|
||||
writeTestFile(t, filepath.Join(docDir, "workflow.yaml"), "custom\n")
|
||||
writeTestFile(t, filepath.Join(docDir, "new.md"), "custom\n")
|
||||
|
||||
// also write global defaults so we can verify local doesn't overwrite
|
||||
writeTestFile(t, filepath.Join(tikiDir, "workflow.yaml"), "global\n")
|
||||
|
||||
affected, err := ResetConfig(ScopeLocal, TargetAll)
|
||||
if err != nil {
|
||||
t.Fatalf("ResetConfig() error = %v", err)
|
||||
}
|
||||
if len(affected) != 3 {
|
||||
t.Fatalf("expected 3 affected files, got %d: %v", len(affected), affected)
|
||||
}
|
||||
|
||||
// all project files should be deleted
|
||||
for _, name := range []string{"config.yaml", "workflow.yaml", "new.md"} {
|
||||
if _, err := os.Stat(filepath.Join(docDir, name)); !os.IsNotExist(err) {
|
||||
t.Errorf("project %s should be deleted after local reset", name)
|
||||
}
|
||||
}
|
||||
|
||||
// global workflow should be untouched
|
||||
got, err := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("global workflow.yaml should still exist: %v", err)
|
||||
}
|
||||
if string(got) != "global\n" {
|
||||
t.Error("global workflow.yaml should be untouched after local reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetConfig_CurrentDeletesFiles(t *testing.T) {
|
||||
_ = setupResetTest(t)
|
||||
|
||||
cwdDir := t.TempDir()
|
||||
originalDir, _ := os.Getwd()
|
||||
defer func() { _ = os.Chdir(originalDir) }()
|
||||
_ = os.Chdir(cwdDir)
|
||||
|
||||
writeTestFile(t, filepath.Join(cwdDir, "workflow.yaml"), "override\n")
|
||||
|
||||
affected, err := ResetConfig(ScopeCurrent, TargetWorkflow)
|
||||
if err != nil {
|
||||
t.Fatalf("ResetConfig() error = %v", err)
|
||||
}
|
||||
if len(affected) != 1 {
|
||||
t.Fatalf("expected 1 affected file, got %d", len(affected))
|
||||
}
|
||||
if _, err := os.Stat(filepath.Join(cwdDir, "workflow.yaml")); !os.IsNotExist(err) {
|
||||
t.Error("cwd workflow.yaml should be deleted after current reset")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetConfig_IdempotentOnMissingFiles(t *testing.T) {
|
||||
_ = setupResetTest(t)
|
||||
|
||||
// reset when no files exist — should succeed with 0 affected
|
||||
affected, err := ResetConfig(ScopeGlobal, TargetConfig)
|
||||
if err != nil {
|
||||
t.Fatalf("ResetConfig() error = %v", err)
|
||||
}
|
||||
if len(affected) != 0 {
|
||||
t.Errorf("expected 0 affected files for missing config.yaml, got %d", len(affected))
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetConfig_GlobalWorkflowCreatesDir(t *testing.T) {
|
||||
// use a fresh temp dir where tiki subdir doesn't exist yet
|
||||
xdgDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", xdgDir)
|
||||
ResetPathManager()
|
||||
t.Cleanup(ResetPathManager)
|
||||
|
||||
affected, err := ResetConfig(ScopeGlobal, TargetWorkflow)
|
||||
if err != nil {
|
||||
t.Fatalf("ResetConfig() error = %v", err)
|
||||
}
|
||||
if len(affected) != 1 {
|
||||
t.Fatalf("expected 1 affected file, got %d", len(affected))
|
||||
}
|
||||
|
||||
// should have created the directory and written the default
|
||||
tikiDir := filepath.Join(xdgDir, "tiki")
|
||||
got, err := os.ReadFile(filepath.Join(tikiDir, "workflow.yaml"))
|
||||
if err != nil {
|
||||
t.Fatalf("read workflow.yaml: %v", err)
|
||||
}
|
||||
if string(got) != GetDefaultWorkflowYAML() {
|
||||
t.Error("workflow.yaml should match embedded default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetConfig_GlobalSkipsWhenAlreadyDefault(t *testing.T) {
|
||||
tikiDir := setupResetTest(t)
|
||||
|
||||
// write the embedded default content — reset should detect no change
|
||||
writeTestFile(t, filepath.Join(tikiDir, "workflow.yaml"), GetDefaultWorkflowYAML())
|
||||
|
||||
affected, err := ResetConfig(ScopeGlobal, TargetWorkflow)
|
||||
if err != nil {
|
||||
t.Fatalf("ResetConfig() error = %v", err)
|
||||
}
|
||||
if len(affected) != 0 {
|
||||
t.Errorf("expected 0 affected files when already default, got %d", len(affected))
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidResetTarget(t *testing.T) {
|
||||
valid := []ResetTarget{TargetAll, TargetConfig, TargetWorkflow, TargetNew}
|
||||
for _, target := range valid {
|
||||
if !ValidResetTarget(target) {
|
||||
t.Errorf("ValidResetTarget(%q) = false, want true", target)
|
||||
}
|
||||
}
|
||||
invalid := []ResetTarget{"themes", "invalid", "reset"}
|
||||
for _, target := range invalid {
|
||||
if ValidResetTarget(target) {
|
||||
t.Errorf("ValidResetTarget(%q) = true, want false", target)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetConfig_LocalRejectsUninitializedProject(t *testing.T) {
|
||||
// point projectRoot at a temp dir that has no .doc/tiki
|
||||
xdgDir := t.TempDir()
|
||||
t.Setenv("XDG_CONFIG_HOME", xdgDir)
|
||||
ResetPathManager()
|
||||
t.Cleanup(ResetPathManager)
|
||||
|
||||
pm := mustGetPathManager()
|
||||
pm.projectRoot = t.TempDir() // empty dir — not initialized
|
||||
|
||||
_, err := ResetConfig(ScopeLocal, TargetAll)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for uninitialized project, got nil")
|
||||
}
|
||||
if msg := err.Error(); !strings.Contains(msg, "not in an initialized tiki project") {
|
||||
t.Errorf("unexpected error message: %s", msg)
|
||||
}
|
||||
}
|
||||
47
config/shipped_workflows_test.go
Normal file
|
|
@ -0,0 +1,47 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// TestShippedWorkflows_HaveDescription ensures every workflow.yaml shipped in
|
||||
// the repo's top-level workflows/ directory has a non-empty top-level
|
||||
// description field and parses cleanly as a full workflowFileData.
|
||||
// Guards against a maintainer dropping the description when adding a new
|
||||
// shipped workflow.
|
||||
func TestShippedWorkflows_HaveDescription(t *testing.T) {
|
||||
matches, err := filepath.Glob("../workflows/*/workflow.yaml")
|
||||
if err != nil {
|
||||
t.Fatalf("glob shipped workflows: %v", err)
|
||||
}
|
||||
if len(matches) == 0 {
|
||||
t.Fatal("no shipped workflows found at ../workflows/*/workflow.yaml")
|
||||
}
|
||||
|
||||
for _, path := range matches {
|
||||
t.Run(filepath.Base(filepath.Dir(path)), func(t *testing.T) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
t.Fatalf("read %s: %v", path, err)
|
||||
}
|
||||
|
||||
var desc struct {
|
||||
Description string `yaml:"description"`
|
||||
}
|
||||
if err := yaml.Unmarshal(data, &desc); err != nil {
|
||||
t.Fatalf("unmarshal description from %s: %v", path, err)
|
||||
}
|
||||
if desc.Description == "" {
|
||||
t.Errorf("%s: missing or empty top-level description", path)
|
||||
}
|
||||
|
||||
if _, err := readWorkflowFile(path); err != nil {
|
||||
t.Errorf("readWorkflowFile(%s) failed: %v", path, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
@ -4,159 +4,168 @@ 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.
|
||||
// The first file from FindWorkflowFiles() that contains a non-empty statuses list wins.
|
||||
// LoadStatusRegistry reads statuses: and types: from workflow.yaml files.
|
||||
// Both registries are loaded into locals first, then published atomically
|
||||
// so no intermediate state exists where one is updated and the other stale.
|
||||
// Returns an error if no statuses are defined anywhere (no Go fallback).
|
||||
func LoadStatusRegistry() error {
|
||||
files := FindWorkflowFiles()
|
||||
files := FindRegistryWorkflowFiles()
|
||||
if len(files) == 0 {
|
||||
return fmt.Errorf("no workflow.yaml found; statuses must be defined in workflow.yaml")
|
||||
}
|
||||
|
||||
statusReg, statusPath, err := loadStatusRegistryFromFiles(files)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if statusReg == nil {
|
||||
return fmt.Errorf("no statuses defined in workflow.yaml; add a statuses: section")
|
||||
}
|
||||
|
||||
typeReg, typePath, err := loadTypeRegistryFromFiles(files)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// publish both atomically
|
||||
registryMu.Lock()
|
||||
globalStatusRegistry = statusReg
|
||||
globalTypeRegistry = typeReg
|
||||
registryMu.Unlock()
|
||||
|
||||
slog.Debug("loaded status registry", "file", statusPath, "count", len(statusReg.All()))
|
||||
slog.Debug("loaded type registry", "file", typePath, "count", len(typeReg.All()))
|
||||
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) (*workflow.StatusRegistry, string, error) {
|
||||
var lastReg *workflow.StatusRegistry
|
||||
var lastFile string
|
||||
|
||||
for _, path := range files {
|
||||
reg, err := loadStatusesFromFile(path)
|
||||
if err != nil {
|
||||
return fmt.Errorf("loading statuses from %s: %w", path, err)
|
||||
return nil, "", fmt.Errorf("loading statuses from %s: %w", path, err)
|
||||
}
|
||||
if reg != nil {
|
||||
registryMu.Lock()
|
||||
globalRegistry = reg
|
||||
registryMu.Unlock()
|
||||
slog.Debug("loaded status registry", "file", path, "count", len(reg.statuses))
|
||||
return nil
|
||||
lastReg = reg
|
||||
lastFile = path
|
||||
}
|
||||
}
|
||||
|
||||
return fmt.Errorf("no statuses defined in workflow.yaml; add a statuses: section")
|
||||
return lastReg, lastFile, nil
|
||||
}
|
||||
|
||||
// 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)
|
||||
// Also resets types to built-in defaults and clears custom fields so test helpers
|
||||
// don't leak registry state. Intended for tests only.
|
||||
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()
|
||||
workflow.ClearCustomFields()
|
||||
registriesLoaded.Store(true)
|
||||
}
|
||||
|
||||
// ResetTypeRegistry replaces the global type registry with one built from the
|
||||
// given defs, without touching the status registry. Intended for tests that
|
||||
// need custom type configurations while keeping existing status setup.
|
||||
func ResetTypeRegistry(defs []workflow.TypeDef) {
|
||||
reg, err := workflow.NewTypeRegistry(defs)
|
||||
if err != nil {
|
||||
panic(fmt.Sprintf("ResetTypeRegistry: %v", err))
|
||||
}
|
||||
registryMu.Lock()
|
||||
globalTypeRegistry = reg
|
||||
registryMu.Unlock()
|
||||
}
|
||||
|
||||
// ClearStatusRegistry removes the global registry. Intended for test teardown.
|
||||
// ClearStatusRegistry removes the global registries and clears custom fields.
|
||||
// Intended for test teardown.
|
||||
func ClearStatusRegistry() {
|
||||
registryMu.Lock()
|
||||
globalRegistry = nil
|
||||
globalStatusRegistry = nil
|
||||
globalTypeRegistry = nil
|
||||
registryMu.Unlock()
|
||||
workflow.ClearCustomFields()
|
||||
registriesLoaded.Store(false)
|
||||
}
|
||||
|
||||
// --- 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 ---
|
||||
// --- internal: statuses ---
|
||||
|
||||
// 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)
|
||||
|
|
@ -171,55 +180,101 @@ func loadStatusesFromFile(path string) (*StatusRegistry, error) {
|
|||
return nil, nil // no statuses in this file, try next
|
||||
}
|
||||
|
||||
return buildRegistry(ws.Statuses)
|
||||
return workflow.NewStatusRegistry(ws.Statuses)
|
||||
}
|
||||
|
||||
func buildRegistry(defs []StatusDef) (*StatusRegistry, error) {
|
||||
if len(defs) == 0 {
|
||||
return nil, fmt.Errorf("statuses list is empty")
|
||||
// --- internal: types ---
|
||||
|
||||
// validTypeDefKeys is the set of allowed keys inside a types: entry.
|
||||
var validTypeDefKeys = map[string]bool{
|
||||
"key": true, "label": true, "emoji": true,
|
||||
}
|
||||
|
||||
// loadTypesFromFile loads types from a single workflow.yaml.
|
||||
// Returns (registry, present, error):
|
||||
// - (nil, false, nil) when the types: key is absent — file does not override
|
||||
// - (reg, true, nil) when types: is present and valid
|
||||
// - (nil, true, err) when types: is present but invalid (empty list, bad entries)
|
||||
func loadTypesFromFile(path string) (*workflow.TypeRegistry, bool, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
return nil, false, fmt.Errorf("reading %s: %w", path, err)
|
||||
}
|
||||
|
||||
reg := &StatusRegistry{
|
||||
statuses: make([]StatusDef, 0, len(defs)),
|
||||
byKey: make(map[string]StatusDef, len(defs)),
|
||||
// first pass: check whether the types key exists at all
|
||||
var raw map[string]interface{}
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
return nil, false, fmt.Errorf("parsing %s: %w", path, err)
|
||||
}
|
||||
|
||||
for i, def := range defs {
|
||||
if def.Key == "" {
|
||||
return nil, fmt.Errorf("status at index %d has empty key", i)
|
||||
}
|
||||
rawTypes, exists := raw["types"]
|
||||
if !exists {
|
||||
return nil, false, nil // absent — no opinion
|
||||
}
|
||||
|
||||
normalized := NormalizeStatusKey(def.Key)
|
||||
def.Key = normalized
|
||||
// present: validate the raw structure
|
||||
typesSlice, ok := rawTypes.([]interface{})
|
||||
if !ok {
|
||||
return nil, true, fmt.Errorf("types: must be a list, got %T", rawTypes)
|
||||
}
|
||||
if len(typesSlice) == 0 {
|
||||
return nil, true, fmt.Errorf("types section must define at least one type")
|
||||
}
|
||||
|
||||
if _, exists := reg.byKey[normalized]; exists {
|
||||
return nil, fmt.Errorf("duplicate status key %q", normalized)
|
||||
// validate each entry for unknown keys and convert to TypeDef
|
||||
defs := make([]workflow.TypeDef, 0, len(typesSlice))
|
||||
for i, entry := range typesSlice {
|
||||
entryMap, ok := entry.(map[string]interface{})
|
||||
if !ok {
|
||||
return nil, true, fmt.Errorf("type at index %d: expected mapping, got %T", i, entry)
|
||||
}
|
||||
|
||||
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
|
||||
for k := range entryMap {
|
||||
if !validTypeDefKeys[k] {
|
||||
return nil, true, fmt.Errorf("type at index %d: unknown key %q (valid keys: key, label, emoji)", i, k)
|
||||
}
|
||||
}
|
||||
|
||||
reg.byKey[normalized] = def
|
||||
reg.statuses = append(reg.statuses, def)
|
||||
var def workflow.TypeDef
|
||||
keyRaw, _ := entryMap["key"].(string)
|
||||
def.Key = workflow.TaskType(keyRaw)
|
||||
def.Label, _ = entryMap["label"].(string)
|
||||
def.Emoji, _ = entryMap["emoji"].(string)
|
||||
defs = append(defs, 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)
|
||||
reg, err := workflow.NewTypeRegistry(defs)
|
||||
if err != nil {
|
||||
return nil, true, err
|
||||
}
|
||||
|
||||
return reg, nil
|
||||
return reg, true, nil
|
||||
}
|
||||
|
||||
// loadTypeRegistryFromFiles iterates workflow files. The last file that has a
|
||||
// types: key wins. Files without a types: key are skipped (no override).
|
||||
// If no file defines types:, returns a registry built from DefaultTypeDefs().
|
||||
func loadTypeRegistryFromFiles(files []string) (*workflow.TypeRegistry, string, error) {
|
||||
var lastReg *workflow.TypeRegistry
|
||||
var lastFile string
|
||||
|
||||
for _, path := range files {
|
||||
reg, present, err := loadTypesFromFile(path)
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("loading types from %s: %w", path, err)
|
||||
}
|
||||
if present {
|
||||
lastReg = reg
|
||||
lastFile = path
|
||||
}
|
||||
}
|
||||
|
||||
if lastReg != nil {
|
||||
return lastReg, lastFile, nil
|
||||
}
|
||||
|
||||
// no file defined types: — fall back to built-in defaults
|
||||
reg, err := workflow.NewTypeRegistry(workflow.DefaultTypeDefs())
|
||||
if err != nil {
|
||||
return nil, "", fmt.Errorf("building default type registry: %w", err)
|
||||
}
|
||||
return reg, "<built-in>", nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,20 +1,24 @@
|
|||
package config
|
||||
|
||||
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},
|
||||
{Key: "inProgress", Label: "In Progress", Emoji: "⚙️", Active: true},
|
||||
{Key: "review", Label: "Review", Emoji: "👀", Active: true},
|
||||
{Key: "done", Label: "Done", Emoji: "✅", Done: true},
|
||||
}
|
||||
}
|
||||
|
||||
func setupTestRegistry(t *testing.T, defs []StatusDef) {
|
||||
func setupTestRegistry(t *testing.T, defs []workflow.StatusDef) {
|
||||
t.Helper()
|
||||
ResetStatusRegistry(defs)
|
||||
t.Cleanup(func() { ClearStatusRegistry() })
|
||||
|
|
@ -38,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},
|
||||
|
|
@ -67,7 +71,7 @@ func TestRegistry_IsValid(t *testing.T) {
|
|||
}{
|
||||
{"backlog", true},
|
||||
{"ready", true},
|
||||
{"in_progress", true},
|
||||
{"inProgress", true},
|
||||
{"In-Progress", true}, // normalization
|
||||
{"review", true},
|
||||
{"done", true},
|
||||
|
|
@ -95,7 +99,7 @@ func TestRegistry_IsActive(t *testing.T) {
|
|||
}{
|
||||
{"backlog", false},
|
||||
{"ready", true},
|
||||
{"in_progress", true},
|
||||
{"inProgress", true},
|
||||
{"review", true},
|
||||
{"done", false},
|
||||
}
|
||||
|
|
@ -116,6 +120,7 @@ func TestRegistry_Lookup(t *testing.T) {
|
|||
def, ok := reg.Lookup("ready")
|
||||
if !ok {
|
||||
t.Fatal("expected to find 'ready'")
|
||||
return
|
||||
}
|
||||
if def.Label != "Ready" {
|
||||
t.Errorf("expected label 'Ready', got %q", def.Label)
|
||||
|
|
@ -135,7 +140,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", "inProgress", "review", "done"}
|
||||
|
||||
if len(keys) != len(expected) {
|
||||
t.Fatalf("expected %d keys, got %d", len(expected), len(keys))
|
||||
|
|
@ -147,61 +152,144 @@ func TestRegistry_Keys(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
func TestRegistry_NormalizesKeys(t *testing.T) {
|
||||
custom := []StatusDef{
|
||||
func TestRegistry_RejectsNonCanonicalKeys(t *testing.T) {
|
||||
_, err := workflow.NewStatusRegistry([]workflow.StatusDef{
|
||||
{Key: "In-Progress", Label: "In Progress", Default: true},
|
||||
{Key: " DONE ", Label: "Done", Done: true},
|
||||
{Key: "done", Label: "Done", Done: true},
|
||||
})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-canonical key")
|
||||
}
|
||||
if got := err.Error(); got != `status key "In-Progress" is not canonical; use "inProgress"` {
|
||||
t.Errorf("got error %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_CanonicalKeysWork(t *testing.T) {
|
||||
custom := []workflow.StatusDef{
|
||||
{Key: "inProgress", Label: "In Progress", Default: true},
|
||||
{Key: "done", Label: "Done", Done: true},
|
||||
}
|
||||
setupTestRegistry(t, custom)
|
||||
reg := GetStatusRegistry()
|
||||
|
||||
if !reg.IsValid("in_progress") {
|
||||
t.Error("expected 'in_progress' to be valid after normalization")
|
||||
if !reg.IsValid("inProgress") {
|
||||
t.Error("expected 'inProgress' to be valid")
|
||||
}
|
||||
if !reg.IsValid("done") {
|
||||
t.Error("expected 'done' to be valid after normalization")
|
||||
t.Error("expected 'done' to be valid")
|
||||
}
|
||||
}
|
||||
|
||||
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{
|
||||
{Key: "alpha", Label: "Alpha"},
|
||||
func TestBuildRegistry_RequiresExplicitDefault(t *testing.T) {
|
||||
defs := []workflow.StatusDef{
|
||||
{Key: "alpha", Label: "Alpha", Done: true},
|
||||
{Key: "beta", Label: "Beta"},
|
||||
}
|
||||
reg, err := buildRegistry(defs)
|
||||
_, err := workflow.NewStatusRegistry(defs)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no status is marked default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRegistry_RequiresExplicitDone(t *testing.T) {
|
||||
defs := []workflow.StatusDef{
|
||||
{Key: "alpha", Label: "Alpha", Default: true},
|
||||
{Key: "beta", Label: "Beta"},
|
||||
}
|
||||
_, err := workflow.NewStatusRegistry(defs)
|
||||
if err == nil {
|
||||
t.Fatal("expected error when no status is marked done")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRegistry_RejectsDuplicateDefault(t *testing.T) {
|
||||
defs := []workflow.StatusDef{
|
||||
{Key: "alpha", Label: "Alpha", Default: true},
|
||||
{Key: "beta", Label: "Beta", Default: true, Done: true},
|
||||
}
|
||||
_, err := workflow.NewStatusRegistry(defs)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate default")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRegistry_RejectsDuplicateDone(t *testing.T) {
|
||||
defs := []workflow.StatusDef{
|
||||
{Key: "alpha", Label: "Alpha", Default: true, Done: true},
|
||||
{Key: "beta", Label: "Beta", Done: true},
|
||||
}
|
||||
_, err := workflow.NewStatusRegistry(defs)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate done")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRegistry_RejectsDuplicateDisplay(t *testing.T) {
|
||||
defs := []workflow.StatusDef{
|
||||
{Key: "alpha", Label: "Open", Emoji: "🟢", Default: true},
|
||||
{Key: "beta", Label: "Open", Emoji: "🟢", Done: true},
|
||||
}
|
||||
_, err := workflow.NewStatusRegistry(defs)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for duplicate display")
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRegistry_LabelDefaultsToKey(t *testing.T) {
|
||||
defs := []workflow.StatusDef{
|
||||
{Key: "alpha", Emoji: "🔵", Default: true},
|
||||
{Key: "beta", Emoji: "🔴", Done: true},
|
||||
}
|
||||
reg, err := workflow.NewStatusRegistry(defs)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if reg.DefaultKey() != "alpha" {
|
||||
t.Errorf("expected default to fall back to first status 'alpha', got %q", reg.DefaultKey())
|
||||
def, ok := reg.Lookup("alpha")
|
||||
if !ok {
|
||||
t.Fatal("expected to find alpha")
|
||||
}
|
||||
if def.Label != "alpha" {
|
||||
t.Errorf("expected label to default to key, got %q", def.Label)
|
||||
}
|
||||
}
|
||||
|
||||
func TestBuildRegistry_EmptyWhitespaceLabel(t *testing.T) {
|
||||
defs := []workflow.StatusDef{
|
||||
{Key: "alpha", Label: " ", Default: true},
|
||||
{Key: "beta", Done: true},
|
||||
}
|
||||
_, err := workflow.NewStatusRegistry(defs)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for whitespace-only label")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -212,10 +300,10 @@ func TestNormalizeStatusKey(t *testing.T) {
|
|||
}{
|
||||
{"backlog", "backlog"},
|
||||
{"BACKLOG", "backlog"},
|
||||
{"In-Progress", "in_progress"},
|
||||
{"in progress", "in_progress"},
|
||||
{"In-Progress", "inProgress"},
|
||||
{"in progress", "inProgress"},
|
||||
{" DONE ", "done"},
|
||||
{"In_Review", "in_review"},
|
||||
{"In_Review", "inReview"},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
|
|
@ -227,6 +315,218 @@ func TestNormalizeStatusKey(t *testing.T) {
|
|||
}
|
||||
}
|
||||
|
||||
// writeTempWorkflow creates a temp workflow.yaml with the given content and returns its path.
|
||||
func writeTempWorkflow(t *testing.T, dir, content string) string {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, "workflow.yaml")
|
||||
if err := os.WriteFile(path, []byte(content), 0644); err != nil {
|
||||
t.Fatalf("writing workflow file: %v", err)
|
||||
}
|
||||
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()
|
||||
|
||||
f1 := writeTempWorkflow(t, dir1, `
|
||||
statuses:
|
||||
- key: alpha
|
||||
label: Alpha
|
||||
default: true
|
||||
- key: beta
|
||||
label: Beta
|
||||
done: true
|
||||
`)
|
||||
f2 := writeTempWorkflow(t, dir2, `
|
||||
statuses:
|
||||
- key: gamma
|
||||
label: Gamma
|
||||
default: true
|
||||
- key: delta
|
||||
label: Delta
|
||||
done: true
|
||||
`)
|
||||
|
||||
reg, path, err := loadStatusRegistryFromFiles([]string{f1, f2})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if reg == nil {
|
||||
t.Fatal("expected non-nil registry")
|
||||
}
|
||||
if path != f2 {
|
||||
t.Errorf("expected path %q, got %q", f2, path)
|
||||
}
|
||||
if reg.DefaultKey() != "gamma" {
|
||||
t.Errorf("expected default key 'gamma' from last file, got %q", reg.DefaultKey())
|
||||
}
|
||||
if len(reg.All()) != 2 {
|
||||
t.Errorf("expected 2 statuses from last file, got %d", len(reg.All()))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadStatusRegistryFromFiles_SkipsFileWithoutStatuses(t *testing.T) {
|
||||
dir1 := t.TempDir()
|
||||
dir2 := t.TempDir()
|
||||
|
||||
f1 := writeTempWorkflow(t, dir1, `
|
||||
statuses:
|
||||
- key: alpha
|
||||
label: Alpha
|
||||
default: true
|
||||
- key: beta
|
||||
label: Beta
|
||||
done: true
|
||||
`)
|
||||
// second file has views but no statuses
|
||||
f2 := writeTempWorkflow(t, dir2, `
|
||||
views:
|
||||
- name: backlog
|
||||
`)
|
||||
|
||||
reg, path, err := loadStatusRegistryFromFiles([]string{f1, f2})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if reg == nil {
|
||||
t.Fatal("expected non-nil registry")
|
||||
}
|
||||
if path != f1 {
|
||||
t.Errorf("expected path %q (first file with statuses), got %q", f1, path)
|
||||
}
|
||||
if reg.DefaultKey() != "alpha" {
|
||||
t.Errorf("expected default key 'alpha', got %q", reg.DefaultKey())
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadStatusRegistryFromFiles_ParseErrorStopsEarly(t *testing.T) {
|
||||
dir1 := t.TempDir()
|
||||
dir2 := t.TempDir()
|
||||
|
||||
f1 := writeTempWorkflow(t, dir1, `
|
||||
statuses:
|
||||
- key: alpha
|
||||
label: Alpha
|
||||
default: true
|
||||
- key: beta
|
||||
label: Beta
|
||||
done: true
|
||||
`)
|
||||
f2 := writeTempWorkflow(t, dir2, `
|
||||
statuses: [[[invalid yaml
|
||||
`)
|
||||
|
||||
_, _, err := loadStatusRegistryFromFiles([]string{f1, f2})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for malformed YAML in second file")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRegistry_IsDone(t *testing.T) {
|
||||
setupTestRegistry(t, defaultTestStatuses())
|
||||
reg := GetStatusRegistry()
|
||||
|
|
@ -238,3 +538,403 @@ 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)
|
||||
}
|
||||
}
|
||||
|
||||
// --- type loading tests ---
|
||||
|
||||
func TestLoadTypesFromFile_HappyPath(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := writeTempWorkflow(t, dir, `
|
||||
types:
|
||||
- key: story
|
||||
label: Story
|
||||
emoji: "🌀"
|
||||
- key: bug
|
||||
label: Bug
|
||||
emoji: "💥"
|
||||
`)
|
||||
reg, present, err := loadTypesFromFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if !present {
|
||||
t.Fatal("expected present=true")
|
||||
}
|
||||
if reg == nil {
|
||||
t.Fatal("expected non-nil registry")
|
||||
}
|
||||
if !reg.IsValid("story") {
|
||||
t.Error("expected story to be valid")
|
||||
}
|
||||
if !reg.IsValid("bug") {
|
||||
t.Error("expected bug to be valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTypesFromFile_Absent(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := writeTempWorkflow(t, dir, `
|
||||
statuses:
|
||||
- key: open
|
||||
label: Open
|
||||
default: true
|
||||
- key: done
|
||||
label: Done
|
||||
done: true
|
||||
`)
|
||||
reg, present, err := loadTypesFromFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if present {
|
||||
t.Error("expected present=false when types: key is absent")
|
||||
}
|
||||
if reg != nil {
|
||||
t.Error("expected nil registry when absent")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTypesFromFile_EmptyList(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := writeTempWorkflow(t, dir, `types: []`)
|
||||
_, present, err := loadTypesFromFile(f)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for types: []")
|
||||
}
|
||||
if !present {
|
||||
t.Error("expected present=true even for empty list")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTypesFromFile_NonCanonicalKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := writeTempWorkflow(t, dir, `
|
||||
types:
|
||||
- key: Story
|
||||
label: Story
|
||||
`)
|
||||
_, _, err := loadTypesFromFile(f)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-canonical key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTypesFromFile_UnknownKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := writeTempWorkflow(t, dir, `
|
||||
types:
|
||||
- key: story
|
||||
label: Story
|
||||
aliases:
|
||||
- feature
|
||||
`)
|
||||
_, _, err := loadTypesFromFile(f)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for unknown key 'aliases'")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTypesFromFile_MissingLabelDefaultsToKey(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := writeTempWorkflow(t, dir, `
|
||||
types:
|
||||
- key: task
|
||||
emoji: "📋"
|
||||
`)
|
||||
reg, _, err := loadTypesFromFile(f)
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if got := reg.TypeLabel("task"); got != "task" {
|
||||
t.Errorf("expected label to default to key, got %q", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTypesFromFile_InvalidYAML(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := writeTempWorkflow(t, dir, `types: [[[invalid`)
|
||||
_, _, err := loadTypesFromFile(f)
|
||||
if err == nil {
|
||||
t.Fatal("expected error for invalid YAML")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTypeRegistryFromFiles_LastFileWithTypesWins(t *testing.T) {
|
||||
dir1 := t.TempDir()
|
||||
dir2 := t.TempDir()
|
||||
|
||||
f1 := writeTempWorkflow(t, dir1, `
|
||||
types:
|
||||
- key: alpha
|
||||
label: Alpha
|
||||
`)
|
||||
f2 := writeTempWorkflow(t, dir2, `
|
||||
types:
|
||||
- key: beta
|
||||
label: Beta
|
||||
- key: gamma
|
||||
label: Gamma
|
||||
`)
|
||||
|
||||
reg, path, err := loadTypeRegistryFromFiles([]string{f1, f2})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if path != f2 {
|
||||
t.Errorf("expected path %q, got %q", f2, path)
|
||||
}
|
||||
if reg.IsValid("alpha") {
|
||||
t.Error("expected alpha to NOT be valid (overridden)")
|
||||
}
|
||||
if !reg.IsValid("beta") {
|
||||
t.Error("expected beta to be valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTypeRegistryFromFiles_SkipsFilesWithoutTypes(t *testing.T) {
|
||||
dir1 := t.TempDir()
|
||||
dir2 := t.TempDir()
|
||||
|
||||
f1 := writeTempWorkflow(t, dir1, `
|
||||
types:
|
||||
- key: alpha
|
||||
label: Alpha
|
||||
`)
|
||||
f2 := writeTempWorkflow(t, dir2, `
|
||||
statuses:
|
||||
- key: open
|
||||
label: Open
|
||||
default: true
|
||||
- key: done
|
||||
label: Done
|
||||
done: true
|
||||
`)
|
||||
|
||||
reg, path, err := loadTypeRegistryFromFiles([]string{f1, f2})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if path != f1 {
|
||||
t.Errorf("expected path %q (file with types), got %q", f1, path)
|
||||
}
|
||||
if !reg.IsValid("alpha") {
|
||||
t.Error("expected alpha to be valid")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTypeRegistryFromFiles_FallbackToBuiltins(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := writeTempWorkflow(t, dir, `
|
||||
statuses:
|
||||
- key: open
|
||||
label: Open
|
||||
default: true
|
||||
- key: done
|
||||
label: Done
|
||||
done: true
|
||||
`)
|
||||
|
||||
reg, path, err := loadTypeRegistryFromFiles([]string{f})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if path != "<built-in>" {
|
||||
t.Errorf("expected built-in path, got %q", path)
|
||||
}
|
||||
if !reg.IsValid("story") {
|
||||
t.Error("expected built-in story type")
|
||||
}
|
||||
}
|
||||
|
||||
func TestLoadTypeRegistryFromFiles_ParseErrorStops(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
f := writeTempWorkflow(t, dir, `
|
||||
types:
|
||||
- key: Story
|
||||
label: Story
|
||||
`)
|
||||
_, _, err := loadTypeRegistryFromFiles([]string{f})
|
||||
if err == nil {
|
||||
t.Fatal("expected error for non-canonical key")
|
||||
}
|
||||
}
|
||||
|
||||
func TestResetTypeRegistry(t *testing.T) {
|
||||
setupTestRegistry(t, defaultTestStatuses())
|
||||
|
||||
custom := []workflow.TypeDef{
|
||||
{Key: "task", Label: "Task"},
|
||||
{Key: "incident", Label: "Incident"},
|
||||
}
|
||||
ResetTypeRegistry(custom)
|
||||
|
||||
reg := GetTypeRegistry()
|
||||
if !reg.IsValid("task") {
|
||||
t.Error("expected 'task' to be valid after ResetTypeRegistry")
|
||||
}
|
||||
if reg.IsValid("story") {
|
||||
t.Error("expected 'story' to NOT be valid after ResetTypeRegistry")
|
||||
}
|
||||
}
|
||||
|
|
|
|||
119
config/system.go
|
|
@ -3,9 +3,11 @@ package config
|
|||
import (
|
||||
_ "embed"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
gonanoid "github.com/matoous/go-nanoid/v2"
|
||||
|
|
@ -56,8 +58,35 @@ func GenerateRandomID() string {
|
|||
return id
|
||||
}
|
||||
|
||||
// sampleFrontmatterRe extracts type and status values from sample tiki frontmatter.
|
||||
var sampleFrontmatterRe = regexp.MustCompile(`(?m)^(type|status):\s*(.+)$`)
|
||||
|
||||
// validateSampleTiki checks whether a sample tiki's type and status
|
||||
// are valid against the current workflow registries.
|
||||
func validateSampleTiki(template string) bool {
|
||||
matches := sampleFrontmatterRe.FindAllStringSubmatch(template, -1)
|
||||
statusReg := GetStatusRegistry()
|
||||
typeReg := GetTypeRegistry()
|
||||
for _, m := range matches {
|
||||
key, val := m[1], strings.TrimSpace(m[2])
|
||||
switch key {
|
||||
case "type":
|
||||
if _, ok := typeReg.ParseType(val); !ok {
|
||||
return false
|
||||
}
|
||||
case "status":
|
||||
if !statusReg.IsValid(val) {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
// BootstrapSystem creates the task storage and seeds the initial tiki.
|
||||
func BootstrapSystem() error {
|
||||
// If createSamples is true, embedded sample tikis are validated against
|
||||
// the active workflow registries and only valid ones are written.
|
||||
func BootstrapSystem(createSamples bool) error {
|
||||
// Create all necessary directories
|
||||
if err := EnsureDirs(); err != nil {
|
||||
return fmt.Errorf("ensure directories: %w", err)
|
||||
|
|
@ -66,59 +95,50 @@ func BootstrapSystem() error {
|
|||
taskDir := GetTaskDir()
|
||||
var createdFiles []string
|
||||
|
||||
// Helper function to create a sample tiki
|
||||
createSampleTiki := func(template string) (string, error) {
|
||||
randomID := GenerateRandomID()
|
||||
taskID := fmt.Sprintf("TIKI-%s", randomID)
|
||||
taskFilename := fmt.Sprintf("tiki-%s.md", randomID)
|
||||
taskPath := filepath.Join(taskDir, taskFilename)
|
||||
|
||||
// Replace placeholder in template
|
||||
taskContent := strings.Replace(template, "TIKI-XXXXXX", taskID, 1)
|
||||
if err := os.WriteFile(taskPath, []byte(taskContent), 0644); err != nil {
|
||||
return "", fmt.Errorf("write task: %w", err)
|
||||
if createSamples {
|
||||
// ensure workflow registries are loaded before validating samples;
|
||||
// on first run this may require installing the default workflow first
|
||||
if err := InstallDefaultWorkflow(); err != nil {
|
||||
slog.Warn("failed to install default workflow for sample validation", "error", err)
|
||||
}
|
||||
if err := LoadWorkflowRegistries(); err != nil {
|
||||
slog.Warn("failed to load workflow registries for sample validation; skipping samples", "error", err)
|
||||
createSamples = false
|
||||
}
|
||||
return taskPath, nil
|
||||
}
|
||||
|
||||
// Create board sample (original welcome tiki)
|
||||
boardPath, err := createSampleTiki(initialTaskTemplate)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create board sample: %w", err)
|
||||
}
|
||||
createdFiles = append(createdFiles, boardPath)
|
||||
if createSamples {
|
||||
type sampleDef struct {
|
||||
name string
|
||||
template string
|
||||
}
|
||||
samples := []sampleDef{
|
||||
{"board", initialTaskTemplate},
|
||||
{"backlog 1", backlogSample1},
|
||||
{"backlog 2", backlogSample2},
|
||||
{"roadmap now", roadmapNowSample},
|
||||
{"roadmap next", roadmapNextSample},
|
||||
{"roadmap later", roadmapLaterSample},
|
||||
}
|
||||
|
||||
// Create backlog samples
|
||||
backlog1Path, err := createSampleTiki(backlogSample1)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create backlog sample 1: %w", err)
|
||||
}
|
||||
createdFiles = append(createdFiles, backlog1Path)
|
||||
for _, s := range samples {
|
||||
if !validateSampleTiki(s.template) {
|
||||
slog.Info("skipping incompatible sample tiki", "name", s.name)
|
||||
continue
|
||||
}
|
||||
|
||||
backlog2Path, err := createSampleTiki(backlogSample2)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create backlog sample 2: %w", err)
|
||||
}
|
||||
createdFiles = append(createdFiles, backlog2Path)
|
||||
randomID := GenerateRandomID()
|
||||
taskID := fmt.Sprintf("TIKI-%s", randomID)
|
||||
taskFilename := fmt.Sprintf("tiki-%s.md", randomID)
|
||||
taskPath := filepath.Join(taskDir, taskFilename)
|
||||
|
||||
// Create roadmap samples
|
||||
roadmapNowPath, err := createSampleTiki(roadmapNowSample)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create roadmap now sample: %w", err)
|
||||
taskContent := strings.Replace(s.template, "TIKI-XXXXXX", taskID, 1)
|
||||
if err := os.WriteFile(taskPath, []byte(taskContent), 0644); err != nil {
|
||||
return fmt.Errorf("create sample %s: %w", s.name, err)
|
||||
}
|
||||
createdFiles = append(createdFiles, taskPath)
|
||||
}
|
||||
}
|
||||
createdFiles = append(createdFiles, roadmapNowPath)
|
||||
|
||||
roadmapNextPath, err := createSampleTiki(roadmapNextSample)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create roadmap next sample: %w", err)
|
||||
}
|
||||
createdFiles = append(createdFiles, roadmapNextPath)
|
||||
|
||||
roadmapLaterPath, err := createSampleTiki(roadmapLaterSample)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create roadmap later sample: %w", err)
|
||||
}
|
||||
createdFiles = append(createdFiles, roadmapLaterPath)
|
||||
|
||||
// Write doki documentation files
|
||||
dokiDir := GetDokiDir()
|
||||
|
|
@ -165,6 +185,11 @@ func GetDefaultNewTaskTemplate() string {
|
|||
return defaultNewTaskTemplate
|
||||
}
|
||||
|
||||
// GetDefaultWorkflowYAML returns the embedded default workflow.yaml content
|
||||
func GetDefaultWorkflowYAML() string {
|
||||
return defaultWorkflowYAML
|
||||
}
|
||||
|
||||
// InstallDefaultWorkflow installs the default workflow.yaml to the user config directory
|
||||
// if it does not already exist. This runs on every launch to handle first-run and upgrade cases.
|
||||
func InstallDefaultWorkflow() error {
|
||||
|
|
|
|||
84
config/themes.go
Normal file
|
|
@ -0,0 +1,84 @@
|
|||
package config
|
||||
|
||||
// Theme registry: maps theme names to palette constructors, dark/light classification,
|
||||
// chroma syntax theme, and navidown markdown renderer style.
|
||||
|
||||
import (
|
||||
"log/slog"
|
||||
"sort"
|
||||
)
|
||||
|
||||
// ThemeInfo holds all metadata for a named theme.
|
||||
type ThemeInfo struct {
|
||||
Light bool // true = light base, false = dark base
|
||||
ChromaTheme string // chroma syntax theme for code blocks
|
||||
NavidownStyle string // navidown markdown renderer style name
|
||||
Palette func() Palette // palette constructor
|
||||
}
|
||||
|
||||
// themeRegistry maps theme names to their ThemeInfo.
|
||||
// "dark" and "light" are the built-in base themes; named themes extend this.
|
||||
var themeRegistry = map[string]ThemeInfo{
|
||||
// built-in base themes
|
||||
"dark": {Light: false, ChromaTheme: "nord", NavidownStyle: "dark", Palette: DarkPalette},
|
||||
"light": {Light: true, ChromaTheme: "github", NavidownStyle: "light", Palette: LightPalette},
|
||||
|
||||
// named dark themes
|
||||
"dracula": {Light: false, ChromaTheme: "dracula", NavidownStyle: "dracula", Palette: DraculaPalette},
|
||||
"tokyo-night": {Light: false, ChromaTheme: "tokyonight-night", NavidownStyle: "tokyo-night", Palette: TokyoNightPalette},
|
||||
"gruvbox-dark": {Light: false, ChromaTheme: "gruvbox", NavidownStyle: "gruvbox-dark", Palette: GruvboxDarkPalette},
|
||||
"catppuccin-mocha": {Light: false, ChromaTheme: "catppuccin-mocha", NavidownStyle: "catppuccin-mocha", Palette: CatppuccinMochaPalette},
|
||||
"solarized-dark": {Light: false, ChromaTheme: "solarized-dark256", NavidownStyle: "solarized-dark", Palette: SolarizedDarkPalette},
|
||||
"nord": {Light: false, ChromaTheme: "nord", NavidownStyle: "nord", Palette: NordPalette},
|
||||
"monokai": {Light: false, ChromaTheme: "monokai", NavidownStyle: "monokai", Palette: MonokaiPalette},
|
||||
"one-dark": {Light: false, ChromaTheme: "onedark", NavidownStyle: "one-dark", Palette: OneDarkPalette},
|
||||
|
||||
// named light themes
|
||||
"catppuccin-latte": {Light: true, ChromaTheme: "catppuccin-latte", NavidownStyle: "catppuccin-latte", Palette: CatppuccinLattePalette},
|
||||
"solarized-light": {Light: true, ChromaTheme: "solarized-light", NavidownStyle: "solarized-light", Palette: SolarizedLightPalette},
|
||||
"gruvbox-light": {Light: true, ChromaTheme: "gruvbox-light", NavidownStyle: "gruvbox-light", Palette: GruvboxLightPalette},
|
||||
"github-light": {Light: true, ChromaTheme: "github", NavidownStyle: "github-light", Palette: GithubLightPalette},
|
||||
}
|
||||
|
||||
var defaultTheme = themeRegistry["dark"]
|
||||
|
||||
// lookupTheme returns the ThemeInfo for the effective theme.
|
||||
// Logs a warning and returns the dark theme for unrecognized names.
|
||||
func lookupTheme() ThemeInfo {
|
||||
name := GetEffectiveTheme()
|
||||
if info, ok := themeRegistry[name]; ok {
|
||||
return info
|
||||
}
|
||||
slog.Warn("unknown theme, falling back to dark", "theme", name)
|
||||
return defaultTheme
|
||||
}
|
||||
|
||||
// IsLightTheme returns true if the effective theme has a light background.
|
||||
func IsLightTheme() bool {
|
||||
return lookupTheme().Light
|
||||
}
|
||||
|
||||
// GetNavidownStyle returns the navidown markdown renderer style for the effective theme.
|
||||
func GetNavidownStyle() string {
|
||||
return lookupTheme().NavidownStyle
|
||||
}
|
||||
|
||||
// PaletteForTheme returns the Palette for the effective theme.
|
||||
func PaletteForTheme() Palette {
|
||||
return lookupTheme().Palette()
|
||||
}
|
||||
|
||||
// ChromaThemeForEffective returns the chroma syntax theme name for the effective theme.
|
||||
func ChromaThemeForEffective() string {
|
||||
return lookupTheme().ChromaTheme
|
||||
}
|
||||
|
||||
// ThemeNames returns a sorted list of all registered theme names.
|
||||
func ThemeNames() []string {
|
||||
names := make([]string, 0, len(themeRegistry))
|
||||
for name := range themeRegistry {
|
||||
names = append(names, name)
|
||||
}
|
||||
sort.Strings(names)
|
||||
return names
|
||||
}
|
||||
120
config/themes_test.go
Normal file
|
|
@ -0,0 +1,120 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
chromaStyles "github.com/alecthomas/chroma/v2/styles"
|
||||
)
|
||||
|
||||
func TestThemeRegistryComplete(t *testing.T) {
|
||||
names := ThemeNames()
|
||||
if len(names) != 14 {
|
||||
t.Fatalf("expected 14 themes, got %d: %v", len(names), names)
|
||||
}
|
||||
}
|
||||
|
||||
func TestThemeNamesAreSorted(t *testing.T) {
|
||||
names := ThemeNames()
|
||||
for i := 1; i < len(names); i++ {
|
||||
if names[i] < names[i-1] {
|
||||
t.Errorf("ThemeNames() not sorted: %q before %q", names[i-1], names[i])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAllPalettesResolve(t *testing.T) {
|
||||
for name, info := range themeRegistry {
|
||||
// calling Palette() must not panic
|
||||
p := info.Palette()
|
||||
if p.TextColor.IsDefault() {
|
||||
t.Errorf("theme %q: TextColor is default/transparent", name)
|
||||
}
|
||||
if p.HighlightColor.IsDefault() {
|
||||
t.Errorf("theme %q: HighlightColor is default/transparent", name)
|
||||
}
|
||||
if p.AccentColor.IsDefault() {
|
||||
t.Errorf("theme %q: AccentColor is default/transparent", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsLightThemeClassification(t *testing.T) {
|
||||
expectedLight := map[string]bool{
|
||||
"dark": false,
|
||||
"light": true,
|
||||
"dracula": false,
|
||||
"tokyo-night": false,
|
||||
"gruvbox-dark": false,
|
||||
"catppuccin-mocha": false,
|
||||
"solarized-dark": false,
|
||||
"nord": false,
|
||||
"monokai": false,
|
||||
"one-dark": false,
|
||||
"catppuccin-latte": true,
|
||||
"solarized-light": true,
|
||||
"gruvbox-light": true,
|
||||
"github-light": true,
|
||||
}
|
||||
for name, wantLight := range expectedLight {
|
||||
info, ok := themeRegistry[name]
|
||||
if !ok {
|
||||
t.Errorf("theme %q not in registry", name)
|
||||
continue
|
||||
}
|
||||
if info.Light != wantLight {
|
||||
t.Errorf("theme %q: Light = %v, want %v", name, info.Light, wantLight)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestUnknownThemeFallsToDark(t *testing.T) {
|
||||
// simulate unknown theme by looking up directly in registry
|
||||
_, ok := themeRegistry["nonexistent-theme"]
|
||||
if ok {
|
||||
t.Error("expected nonexistent-theme to not be in registry")
|
||||
}
|
||||
// lookupTheme() falls back to dark — verify via default
|
||||
if defaultTheme.Light {
|
||||
t.Error("default theme should be dark (Light=false)")
|
||||
}
|
||||
if defaultTheme.ChromaTheme != "nord" {
|
||||
t.Errorf("default chroma theme = %q, want nord", defaultTheme.ChromaTheme)
|
||||
}
|
||||
}
|
||||
|
||||
func TestChromaThemesExist(t *testing.T) {
|
||||
for name, info := range themeRegistry {
|
||||
style := chromaStyles.Get(info.ChromaTheme)
|
||||
if style == nil {
|
||||
t.Errorf("theme %q: chroma theme %q not found in chroma registry", name, info.ChromaTheme)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestNavidownStylesValid(t *testing.T) {
|
||||
// navidown supports these style names; unknown names fall back to "dark"
|
||||
validNavidown := map[string]bool{
|
||||
"dark": true, "light": true,
|
||||
"dracula": true, "tokyo-night": true,
|
||||
"pink": true, "ascii": true, "notty": true,
|
||||
// additional named themes
|
||||
"gruvbox-dark": true, "catppuccin-mocha": true,
|
||||
"solarized-dark": true, "nord": true,
|
||||
"monokai": true, "one-dark": true,
|
||||
"catppuccin-latte": true, "solarized-light": true,
|
||||
"gruvbox-light": true, "github-light": true,
|
||||
}
|
||||
for name, info := range themeRegistry {
|
||||
if !validNavidown[info.NavidownStyle] {
|
||||
t.Errorf("theme %q: navidown style %q is not a known navidown style", name, info.NavidownStyle)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestChromaThemeForEffectiveNonEmpty(t *testing.T) {
|
||||
for name, info := range themeRegistry {
|
||||
if info.ChromaTheme == "" {
|
||||
t.Errorf("theme %q: ChromaTheme is empty", name)
|
||||
}
|
||||
}
|
||||
}
|
||||
91
config/triggers.go
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
package config
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
||||
"gopkg.in/yaml.v3"
|
||||
)
|
||||
|
||||
// TriggerDef represents a single trigger entry in workflow.yaml.
|
||||
type TriggerDef struct {
|
||||
Description string `yaml:"description"`
|
||||
Ruki string `yaml:"ruki"`
|
||||
}
|
||||
|
||||
// triggerFileData is the minimal YAML structure for reading triggers from workflow.yaml.
|
||||
type triggerFileData struct {
|
||||
Triggers []TriggerDef `yaml:"triggers"`
|
||||
}
|
||||
|
||||
// LoadTriggerDefs discovers and returns raw trigger definitions from workflow.yaml files.
|
||||
// Uses its own discovery path (not FindWorkflowFiles) to avoid the empty-views filter.
|
||||
// Override semantics: last file with a triggers: section wins (cwd > project > user).
|
||||
// An explicit empty list (triggers: []) overrides inherited triggers.
|
||||
// The caller is responsible for parsing each TriggerDef.Ruki with ruki.ParseTrigger.
|
||||
func LoadTriggerDefs() ([]TriggerDef, error) {
|
||||
pm := mustGetPathManager()
|
||||
|
||||
// candidate paths in discovery order: user config → project config → cwd
|
||||
candidates := []string{
|
||||
pm.UserConfigWorkflowFile(),
|
||||
filepath.Join(pm.ProjectConfigDir(), defaultWorkflowFilename),
|
||||
defaultWorkflowFilename, // relative to cwd
|
||||
}
|
||||
|
||||
var winningDefs []TriggerDef
|
||||
seen := make(map[string]bool)
|
||||
|
||||
for _, path := range candidates {
|
||||
abs, err := filepath.Abs(path)
|
||||
if err != nil {
|
||||
abs = path
|
||||
}
|
||||
if seen[abs] {
|
||||
continue
|
||||
}
|
||||
seen[abs] = true
|
||||
|
||||
defs, found, err := readTriggersFromFile(path)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("reading triggers from %s: %w", path, err)
|
||||
}
|
||||
if found {
|
||||
// last file with a triggers: section wins
|
||||
winningDefs = defs
|
||||
}
|
||||
}
|
||||
|
||||
return winningDefs, nil
|
||||
}
|
||||
|
||||
// readTriggersFromFile reads a workflow.yaml and returns its triggers section.
|
||||
// Returns (defs, true, nil) if the file exists and has a triggers: key.
|
||||
// Returns (nil, false, nil) if the file doesn't exist or has no triggers: key.
|
||||
func readTriggersFromFile(path string) ([]TriggerDef, bool, error) {
|
||||
data, err := os.ReadFile(path)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil, false, nil
|
||||
}
|
||||
return nil, false, err
|
||||
}
|
||||
|
||||
// check whether the YAML contains a triggers: key at all
|
||||
// (absent section = no opinion, does not override)
|
||||
var raw map[string]interface{}
|
||||
if err := yaml.Unmarshal(data, &raw); err != nil {
|
||||
return nil, false, fmt.Errorf("parsing YAML: %w", err)
|
||||
}
|
||||
if _, hasTriggers := raw["triggers"]; !hasTriggers {
|
||||
return nil, false, nil
|
||||
}
|
||||
|
||||
var tf triggerFileData
|
||||
if err := yaml.Unmarshal(data, &tf); err != nil {
|
||||
return nil, false, fmt.Errorf("parsing triggers: %w", err)
|
||||
}
|
||||
|
||||
return tf.Triggers, true, nil
|
||||
}
|
||||